首页 / 资讯中心 / 文章详情

脉冲神经网络STDP学习规则:Brian2仿真从公式到代码的完整实现

脉冲神经网络STDP学习规则:Brian2仿真从公式到代码的完整实现 ★ FEATURED ARTICLE
1. 从一次脉冲时序实验说起STDP到底在解决什么问题如果你接触过脉冲神经网络Spiking Neural Network, SNN大概率会碰到一个绕不开的概念——STDP全称Spike-Timing-Dependent Plasticity中文一般叫“脉冲时序依赖可塑性”。我第一次认真研究它是因为一个很实际的问题传统人工神经网络靠反向传播调权重但脉冲网络里信息是离散的脉冲时间精度到了毫秒级反向传播那套东西很难直接搬过来。那生物大脑是怎么学的答案之一就是STDP。简单说STDP是一种基于突触前后脉冲相对时序来调整连接强度的学习规则。突触前神经元先发放脉冲、突触后神经元后发放这个连接就被增强叫长时程增强LTP反过来突触后先发放、突触前后发放连接就被削弱叫长时程抑制LTD。这个“先来后到”的时间差通常用Δt表示是STDP的核心变量。这个机制能做什么它能让你在没有标签、没有全局误差信号的情况下让网络自己从脉冲时序中提取因果结构。适合谁看做类脑计算的研究生、想入门SNN的工程师、对计算神经科学感兴趣的开发者以及那些被“脉冲网络怎么训练”卡住的人。我下面会用Brian2这个仿真器把STDP从公式到代码、从参数到坑完整走一遍。Brian2是Python生态里做脉冲神经网络仿真最顺手的工具之一语法接近数学表达方程和神经元模型可以直接写不用手写数值积分。我踩过的第一个坑就是以为STDP就是一个公式抄进去就能跑。实际上指数窗、时间常数、权重边界、更新时机每一个都会让结果天差地别。这篇内容就是把这些细节摊开讲清楚。2. 内容整体设计与思路拆解2.1 为什么选Brian2而不是其他仿真器做SNN仿真常见的选择有Brian2、NEST、NEURON、BindsNET等。我选Brian2的理由很直接它把“方程”和“代码”之间的距离压到了最短。比如你要定义一个泄漏积分发放LIF神经元Brian2里就是几行方程eqs dv/dt (v_rest - v R*I)/tau : volt 而NEST需要你去查它内置模型的参数文档NEURON则要写Hoc或NMODL门槛高不少。对于STDP这种需要频繁改时间常数、改学习窗口的实验Brian2的迭代速度是压倒性的。另一个原因是Brian2的Synapses对象原生支持pre和post代码块STDP的“前后脉冲到达时改权重”这个逻辑可以直接写成事件驱动的代码非常贴合STDP的生物学定义。你不需要自己去写脉冲队列和时序比较仿真器帮你处理了。当然Brian2也有代价。它的性能不如NEST在大规模网络上的表现单线程仿真几万个神经元就开始吃力。但做STDP的机制验证几百到几千个神经元足够了这个规模下Brian2完全够用。2.2 STDP的数学形式与方案选型STDP最经典的成对形式pair-based权重更新量是当Δt t_post - t_pre 0前脉冲先到Δw A_plus * exp(-Δt / tau_plus)当Δt 0后脉冲先到Δw -A_minus * exp(Δt / tau_minus)其中A_plus、A_minus是学习率tau_plus、tau_minus是时间常数通常tau_plus和tau_minus取20ms左右A_minus略大于A_plus这样网络更倾向于抑制防止权重爆炸。为什么用指数窗而不是其他形状生物实验数据比如经典的Bi Poo 1998实验显示STDP的时间窗大致呈指数衰减。指数形式还有一个好处它可以用一个“迹”trace变量来在线实现不需要存储所有历史脉冲。这就是所谓的“all-to-all”和“nearest-neighbor”实现方式的区别。在Brian2里我推荐用**迹变量trace**的方式来实现STDP而不是显式地比较每一对脉冲。原因很简单显式比较需要保存脉冲历史内存开销大而且代码复杂。迹变量的思路是每个突触前脉冲让一个变量x_pre增加1然后按tau_plus衰减突触后脉冲到来时权重按x_pre的值增加。反过来同理。这种实现方式在Brian2里非常自然因为Brian2的Synapses支持在pre和post代码块里定义微分方程。下面我会详细展开。2.3 整体实验设计思路我设计的验证实验是这样的一个小的脉冲网络包含两组神经元一组作为突触前一组作为突触后中间用STDP连接。然后我人为地给突触前和突触后注入有固定时间差的脉冲观察权重是增强还是削弱以及增强/削弱的幅度是否和理论公式一致。这个设计的目的有三个第一验证STDP的实现是否正确第二观察参数变化对学习窗口的影响第三为后续更大网络的训练打基础。很多人一上来就跑大网络结果权重不收敛根本不知道是STDP写错了还是网络结构有问题。先用最小系统验证是我强烈建议的路径。3. 核心细节解析与实操要点3.1 神经元模型的选择与参数STDP本身不依赖具体的神经元模型但神经元模型会影响脉冲发放的时序。我一般用LIF模型因为它简单、计算快而且参数有明确的生理意义。Brian2里定义LIF的典型写法tau_m 20 * ms v_rest -70 * mV v_thresh -50 * mV v_reset -70 * mV R 100 * Mohm eqs dv/dt (v_rest - v R*I)/tau_m : volt (unless refractory) 这里有几个细节要注意。unless refractory是必须的否则在不应期里电压还会继续积分导致脉冲发放异常。不应期一般设2ms到5ms我习惯用2ms因为STDP的时间窗是20ms量级不应期太长会掩盖时序效应。另外R和I的单位要匹配。Brian2对单位很严格R*I必须是volt量纲。如果你用nA做电流单位R就要用Mohm这样nA*Mohm mV再转成volt。我见过不少人在这里报单位错误查半天。3.2 STDP的迹变量实现这是核心部分。在Brian2里STDP的迹变量实现大概长这样tau_plus 20 * ms tau_minus 20 * ms A_plus 0.01 A_minus 0.012 stdp_eqs dx_pre/dt -x_pre/tau_plus : 1 dx_post/dt -x_post/tau_minus : 1 w : 1 on_pre x_pre 1 w clip(w A_minus * x_post, 0, w_max) on_post x_post 1 w clip(w A_plus * x_pre, 0, w_max) 这里有一个非常容易搞反的地方突触前脉冲到来时应该用突触后的迹x_post来更新权重而且是减去A_minus * x_post。为什么因为突触前脉冲到来时如果突触后刚刚发放过x_post还很大说明是“后先发、前后来”这是LTD的情况权重应该减小。反过来突触后脉冲到来时用x_pre权重增加对应LTP。我最初就把这两个搞反了结果网络越学权重越大直接饱和。后来我养成了一个习惯在代码旁边写注释标明“pre脉冲到来 - 检查post迹 - LTD”这样不容易错。clip函数用来限制权重范围防止权重跑到负值或无穷大。w_max一般设1.0w_min设0。这个边界在生物上对应突触的物理极限在计算上防止数值爆炸。3.3 时间常数与学习率的取值逻辑tau_plus和tau_minus的取值直接决定学习窗口的宽度。20ms是文献里最常见的值对应生物实验里STDP窗口的大致范围。如果你把tau_plus设成5ms窗口就变得很窄只有非常接近的脉冲对才会引起权重变化设成50ms窗口很宽但时间选择性就差了。A_plus和A_minus的比值也很关键。如果A_plus A_minus网络容易出现权重两极分化有的连接越来越强有的越来越弱最后大部分权重跑到边界。让A_minus略大于A_plus比如1.05到1.2倍可以给网络一个微弱的抑制偏置帮助稳定。这个技巧在训练循环网络时特别有用。还有一个隐藏参数权重更新的时机。Brian2默认在脉冲事件发生时立即更新权重。但如果你用了event-driven的更新方式要注意权重更新和膜电位更新的顺序。我一般让权重更新在脉冲发放之后这样时序逻辑更清晰。3.4 连接结构与初始化突触连接的初始化方式会影响STDP的早期动态。我试过三种全连接均匀初始化、稀疏随机初始化、以及基于距离的初始化。对于机制验证全连接均匀初始化最方便因为你可以精确控制每个突触的前后脉冲时序。权重初始值一般设0.5左右不要设0或1。设0的话LTP需要多次脉冲才能把权重拉起来早期学习很慢设1的话LTD空间很小容易饱和。0.5是一个比较平衡的起点。连接的延迟也要注意。Brian2里可以给每个突触设延迟默认是0ms。如果延迟不为0脉冲到达时间会偏移影响STDP的时序判断。做机制验证时我建议先把延迟设为0排除干扰。4. 实操过程与核心环节实现4.1 环境搭建与依赖安装Brian2的安装很简单但有几个依赖容易出问题。我一般用conda建一个干净的环境conda create -n snn python3.10 conda activate snn pip install brian2 numpy matplotlibBrian2依赖Cython做代码生成所以系统里要有C编译器。Linux下一般自带gccWindows下需要装Visual Studio Build Tools。如果安装时报“cannot find vcvarsall.bat”之类的错误就是编译器没配好。我建议Windows用户直接用WSL省去很多麻烦。安装完成后跑一个最小测试from brian2 import * start_scope() eqs dv/dt -v/(10*ms) : 1 neurons NeuronGroup(1, eqs, thresholdv1, resetv0) print(Brian2 version:, brian2.__version__)能打印出版本号就说明环境没问题。4.2 构建最小STDP验证网络下面是我用来验证STDP的最小网络完整代码。这个网络只有两个神经元一个前、一个后中间一个STDP突触。我通过SpikeGeneratorGroup给它们注入精确控制的脉冲。from brian2 import * start_scope() defaultclock.dt 0.1 * ms # 神经元参数 tau_m 20 * ms v_rest -70 * mV v_thresh -50 * mV v_reset -70 * mV R 100 * Mohm eqs dv/dt (v_rest - v R*I)/tau_m : volt (unless refractory) I : amp # 两个神经元pre和post neurons NeuronGroup(2, eqs, thresholdvv_thresh, resetvv_reset, refractory2*ms, methodexact) neurons.v v_rest # STDP参数 tau_plus 20 * ms tau_minus 20 * ms A_plus 0.01 A_minus 0.012 w_max 1.0 stdp_eqs dx_pre/dt -x_pre/tau_plus : 1 dx_post/dt -x_post/tau_minus : 1 w : 1 on_pre x_pre 1 w clip(w - A_minus * x_post, 0, w_max) on_post x_post 1 w clip(w A_plus * x_pre, 0, w_max) S Synapses(neurons, neurons, modelstdp_eqs, on_preon_pre, on_poston_post) S.connect(i0, j1) S.w 0.5 # 用SpikeGeneratorGroup精确控制脉冲时序 # 这里省略具体注入代码核心是让pre和post有固定时间差这段代码的关键在于on_pre和on_post里的更新逻辑。我特意把w - A_minus * x_post写在on_pre里把w A_plus * x_pre写在on_post里对应LTD和LTP。4.3 注入脉冲时序并观察权重变化为了精确控制时序我用SpikeGeneratorGroup或者直接给神经元注入电流脉冲。更可控的方式是用TimedArray给I赋值让神经元在指定时刻发放。我设计的时间差序列是Δt从-40ms到40ms步长5ms。对每个Δt重置网络让pre和post分别在这个时间差下发放一次然后记录权重变化。deltas np.arange(-40, 41, 5) * ms dw [] for dt_val in deltas: # 重置 neurons.v v_rest S.w 0.5 # 注入电流让pre在t50ms发放post在t50msdt_val发放 # 具体注入方式略 run(100*ms) dw.append(S.w[0] - 0.5)跑完之后把dw对Δt画出来应该看到经典的STDP曲线Δt0时dw为正Δt0时dw为负在Δt0附近有一个不连续点。我实测下来曲线形状和理论公式吻合得很好。但有一个细节在Δt接近0的时候由于数值积分步长是0.1ms时序判断会有微小误差。如果你把dt设成0.01ms曲线会更平滑但仿真时间会变长。0.1ms对于20ms的时间窗来说足够了。4.4 参数扫描与学习窗口的定量分析验证完基本形状后我做了一组参数扫描看tau_plus和A_minus/A_plus比值对学习窗口的影响。结果整理成表格tau_plus (ms)tau_minus (ms)A_minus/A_plus窗口宽度 (ms)最大LTP幅度10101.0~300.01020201.0~600.01020201.2~600.01040401.0~1200.01020101.0不对称0.010从表里能看出几个规律。第一tau越大窗口越宽时间选择性越差。第二tau_plus和tau_minus不相等时LTP和LTD的窗口不对称这在实际网络里可以用来偏置学习方向。第三A_minus/A_plus的比值不影响窗口形状只影响LTD的深度。我个人的经验是做模式识别任务时tau取20ms、A_minus/A_plus取1.1左右比较稳做时序预测任务时tau可以取10ms让网络对精细时序更敏感。5. 常见问题与排查技巧实录5.1 权重不更新或更新方向相反这是最常见的问题。如果你发现权重完全不动先检查三件事第一on_pre和on_post是否真的被触发了。可以在里面加print语句看脉冲到来时有没有输出。第二x_pre和x_post的初始值是否为0。如果初始值不为0第一次更新就会带上一个偏置。第三clip的边界是否把权重卡死了。如果w初始就是0而LTD又把它往下压它就会一直停在0。如果更新方向相反八成是on_pre和on_post里的逻辑写反了。记住pre脉冲到来时检查post迹做LTDpost脉冲到来时检查pre迹做LTP。我建议在代码里用注释把这句话写死每次改代码都看一眼。5.2 权重饱和到边界权重全部跑到0或w_max说明LTP和LTD不平衡。可能的原因有A_plus太大、A_minus太小、或者网络活动太强导致脉冲太多。解决办法有三个第一调大A_minus/A_plus比值给LTD更多权重。第二加权重归一化每次更新后把所有权重按比例缩放保持总和恒定。第三加homeostatic机制根据神经元的平均发放率调整学习率。我一般先用第一种简单直接。如果还不行再上归一化。归一化的代码大概是这样network_operation(dt100*ms) def normalize(): w_sum S.w[:].sum() if w_sum 0: S.w S.w / w_sum * target_sum这个操作每100ms执行一次把权重总和拉回目标值。注意不要在每次脉冲后都归一化那样计算量太大。5.3 仿真速度太慢Brian2默认用Python生成C代码再编译速度已经不错了。但如果你的网络有几千个神经元、几万个突触仿真还是会慢。加速的手段有几个第一用methodeuler而不是exacteuler虽然精度低一点但快很多。第二减少记录变量StateMonitor只记录你真正需要的变量。第三用run(..., reporttext)看瓶颈在哪里。还有一个容易被忽略的点STDP的迹变量更新是每个突触每个脉冲都做的如果突触数量大这部分开销很可观。可以考虑用event-driven的更新方式只在脉冲到来时更新而不是每个时间步都积分。Brian2里可以用(event-driven)标记dx_pre/dt -x_pre/tau_plus : 1 (event-driven)这样x_pre只在脉冲事件时更新平时不积分能省不少时间。5.4 常见问题速查表现象可能原因排查方法解决方式权重完全不变on_pre/on_post未触发加print调试检查Synapses.connect和脉冲注入权重方向相反pre/post逻辑写反对照LTP/LTD定义交换on_pre和on_post里的更新权重饱和到1LTP过强看A_plus/A_minus比值调大A_minus或加归一化权重饱和到0LTD过强看A_minus/A_plus比值调小A_minus或提高w初始值仿真太慢迹变量每步积分用profiler看耗时改用event-driven曲线不对称tau_plus≠tau_minus检查两个时间常数按需调整不对称有时是feature单位报错R和I量纲不匹配检查单位表达式确保R*I是volt量纲5.5 几个我踩过的坑第一个坑忘记start_scope()。Brian2里如果不调用start_scope()多次运行会累积对象导致奇怪的行为。我养成了每次新建网络前都调一次的习惯。第二个坑S.w的索引方式。S.w是一个数组S.w[0]是第一个突触的权重。但如果你用S.w 0.5赋值它会广播到所有突触。如果你只想改一个要用S.w[0] 0.5。这个在调试时很容易搞混。第三个坑脉冲注入的时序精度。用TimedArray注入电流时电流的采样率要和仿真步长匹配。如果TimedArray的dt是1ms而仿真步长是0.1ms电流会在1ms内保持不变导致脉冲发放时刻有±1ms的抖动。对于20ms的STDP窗口1ms抖动还能接受但如果tau设成5ms就影响很大了。解决办法是把TimedArray的dt设成和仿真步长一致。第四个坑权重更新的顺序。如果pre和post在同一时刻发放Brian2的处理顺序会影响结果。我一般避免让它们同时发放或者在代码里加一个微小的延迟来打破对称。6. 从最小系统到实际网络STDP的扩展思路6.1 从成对STDP到三重STDP成对STDP有一个已知的问题它对脉冲频率不敏感。两个神经元以不同频率发放只要时间差一样权重变化就一样。但生物实验显示高频脉冲对引起的权重变化更大。为了解决这个问题有人提出了三重STDPtriplet STDP它同时考虑三个脉冲的时序关系。在Brian2里实现三重STDP需要增加额外的迹变量。比如除了x_pre和x_post还要加x_pre2和x_post2分别对应慢速和快速的迹。更新规则会复杂一些但核心思路是一样的用迹变量捕捉历史脉冲的影响。我试过三重STDP效果确实比成对的好尤其是在频率敏感的任务上。但参数也更多调起来更麻烦。我的建议是先用成对STDP把流程跑通确认没问题了再上三重。6.2 加入奖励调制R-STDPSTDP是无监督的它只关心时序不关心结果好坏。如果你想让网络做强化学习就需要R-STDPReward-modulated STDP。思路是STDP的权重更新量乘以一个奖励信号奖励为正时增强LTP、削弱LTD奖励为负时反过来。在Brian2里实现R-STDP可以加一个全局的奖励变量然后在on_pre和on_post里乘以这个变量。奖励信号可以来自外部也可以根据网络输出计算。这个扩展让STDP从“无监督学习”变成了“强化学习”适用范围大大扩展。6.3 网络规模扩展与性能权衡最小系统验证完之后下一步就是扩展到实际网络。我一般按这个顺序先扩到100个神经元看权重分布是否合理再扩到1000个看是否出现同步振荡最后扩到10000个看仿真时间是否可接受。每扩一次都要重新检查参数。因为网络规模变了脉冲活动的统计特性会变原来的A_plus/A_minus可能就不平衡了。我通常会在扩展后先跑一段“预热”期让权重自己找到平衡然后再开始正式任务。性能方面1000个神经元、10万个突触的网络在普通笔记本上跑1秒仿真大概需要几秒到几十秒。如果要做长时间训练建议用服务器或者GPU加速。Brian2有GPU版本叫Brian2GeNN但配置起来麻烦一些我一般先用CPU版验证逻辑确认没问题了再考虑GPU。6.4 权重可视化与调试技巧调试STDP网络可视化是关键。我一般会画三张图第一张是权重分布直方图看是否出现两极分化第二张是权重随时间的变化曲线看是否收敛第三张是神经元的脉冲栅格图看网络活动是否正常。权重分布如果出现双峰说明网络在分化一部分连接变强、一部分变弱。这在某些任务上是好事比如特征选择但在另一些任务上可能是过拟合。权重随时间如果一直震荡不收敛说明学习率太大或者LTP/LTD不平衡。脉冲栅格如果所有神经元同步发放说明网络太兴奋需要加抑制。这些可视化用matplotlib就能做代码不复杂但能省下大量盲猜的时间。7. 我个人在实际操作中的体会STDP这个东西公式看起来简单但真正跑起来细节多到让人头疼。我最大的体会是不要一上来就调大网络先用两个神经元把时序逻辑验证清楚。我见过太多人直接跑大网络结果权重不收敛然后花几天时间排查最后发现是on_pre和on_post写反了。如果先用最小系统验证这个问题五分钟就能发现。另一个体会是参数没有万能值。文献里的tau20ms、A0.01是在特定网络结构、特定输入模式下测出来的。你的网络不一样参数就要重新调。我一般会做一个参数扫描把tau和A的比值作为两个维度画一个热力图看哪个区域权重分布最健康。这个扫描可能要跑几十次仿真但比盲调快得多。还有一点记录实验日志。每次改参数、改代码都记下来改了什么、结果如何。STDP的调试过程很容易陷入“改了这里忘了那里”的混乱。我有一个简单的markdown文件每次实验前写一行“实验编号、参数、预期结果”跑完写“实际结果、是否吻合”。这个习惯帮我省了很多重复劳动。最后分享一个小技巧如果你发现权重更新幅度太小看不出来可以先把A_plus和A_minus放大10倍跑一次看方向对不对然后再调回正常值。方向对了再细调幅度。这个“先放大看方向再缩小调精度”的方法在调试任何学习规则时都管用。STDP只是脉冲网络学习的一个起点。把它跑通之后你可以往上叠三重STDP、奖励调制、卷积结构慢慢搭出更复杂的系统。但不管叠多少层底层的时序逻辑和参数平衡永远是第一位的。
阅读完成 · 觉得有帮助?
咨询建站