我在一个周末翻出以前的笔记,突然决定把脉冲神经网络捡起来。这已经是第三个人这么跟我说了:“你要理解人工智能的下一波,不看SNN是不行的。”这话有多少水分先不论,但有一件事是确定的——脉冲神经网络(SNN)里最有代表性的无监督学习规则STDP,和普通深度学习里的梯度下降完全是两套逻辑。我准备用PyTorch从零搭建一个SNN,实现STDP,并给出完整可运行的代码。没有现成的SNN库,没有复杂的分布式系统,就是最小实现。适合已经会用PyTorch做常规深度学习、但对神经形态计算和SNN好奇的读者;你会发现,写一个SNN最难的部分不是代码量,而是理解“时间”这个维度在整个计算过程中的地位。
1. 为什么放着人工神经网络不用,要折腾脉冲神经网络和STDP
1.1 脉冲神经网络到底“新”在哪里:从数值到事件
人工神经网络的基本操作是:上一层的输出经过线性变换和非线性激活,得到当前层的输出。所有信息都表示成一个连续浮点数。而SNN里的“输出”是稀疏的离散脉冲——0和1,而且这些脉冲发生在一个连续时间线上。在SNN中,一个神经元不是在每个时间步都“说话”的,只有在膜电位累积超过阈值时才发放一个脉冲,发完之后膜电位被重置。这意味着信息不再是“这个神经元的值有多大”,而是“这个神经元在什么时候发了一个脉冲”,以及“一共发了多少个脉冲”。
这个观点的转变,会让你重新理解神经网络中的几乎每一个设计:激活函数变成了阈值操作,连续前向变成了事件驱动,梯度这个概念在无监督场景下干脆消失了。从应用角度看,SNN的低功耗异步计算潜力非常适合边缘设备、神经形态芯片和实时信号处理。所谓“事件驱动”,让SNN只在有脉冲到达时才做计算,平均功耗往往只有同等规模ANN的零头。当然,这个优势在现代通用GPU上没那么明显,但从原理上理解它,能帮你判断哪些场景真正值得用SNN,而不是盲目跟风。
1.2 STDP:赫布学习加上了时间顺序
赫布规则有个著名表述:fire together, wire together。如果两个神经元同时被激活,它们之间的突触会增强。但真实生物神经系统里,“同时”是有精确时间尺度的。STDP在赫布规则的基础上加了一个时间贡献:如果突触前神经元先发放、随后突触后神经元发放,那么突触权重增强(长时程增强,LTP);反过来,如果突触后神经元先发放、突触前神经元稍后才发放,那么突触权重会被削弱(长时程抑制,LTD)。这个“时间顺序”让STDP天然能学习输入信号的时序结构,这是传统静态网络很难直接做到的事。
用生活化的类比来说:如果一个人(突触前)先敲了你的门,接着你(突触后)打开了门,你们之间的这个“联系”会加强;如果反过来,你先打开了门,之后他才敲门,你自然会觉得这次敲门不太重要,这个联系就会弱化。STDP就是把这个朴素直觉变成了一条有明确数学形式的规则。这些看起来简单的机制,恰恰是神经科学里被验证最充分的突触可塑性规则之一,也是很多类脑芯片训练SNN的核心底座。
1.3 PyTorch在这里不是“训练框架”,而是“计算框架”
很多读者第一反应是:PyTorch不是用来做反向传播的吗?SNN不反向传播,为什么还要用它?我的理由是:PyTorch最核心的价值其实是张量运算和自动广播。SNN的仿真本质上是一个带时间维度的张量运算过程,PyTorch可以很自然地让输入脉冲向量和权重矩阵做矩阵乘法,对每个时间步做批量处理。与此同时,PyTorch的生态太成熟了——后续要接数据预处理、可视化、GPU加速,都是现成的。而现成的SNN框架(SpikingJelly、snnTorch、Norse)虽然好用,但它们的封装层次往往比较高,你很难完整看到STDP每一步在做什么运算。这次的目标是“从零搭建”,所以我会用PyTorch的底层API手动实现每一个模块。只有这样,SNN的每个细节——膜电位、痕迹变量、权重更新时机——才不会被框架的黑盒掩盖掉。
2. 动手前必须定好的三个设计决策:LIF模型、脉冲编码、时间步长
2.1 LIF神经元:把连续的膜电位动力学离散化
SNN最常用的神经元模型是Leaky Integrate-and-Fire(LIF)。名字看着复杂,其实就三件事:输入电流会持续累积到膜电位上(积分为膜电位,因为膜本身是电容);膜电位会随时间泄漏(leak,等效于电阻);膜电位一旦超过阈值,神经元就发放一个脉冲,然后膜电位被重置回静息电位。
连续时间下的LIF微分方程是:
τ_m · dV/dt = -V + I_in
其中V是膜电位,τ_m是膜时间常数,I_in是输入电流。用前向欧拉法把时间离散化,步长为dt,就得到:
V(t+dt) = V(t) · exp(-dt/τ_m) + I_in(t)
如果V(t+dt) ≥ V_thr,就发放脉冲并重置V = 0。这个离散形式是写代码时实际使用的公式。exp(-dt/τ_m)是个衰减系数,它决定了“之前累积的膜电位在下一步还剩多少”。这个衰减系数是LIF“泄漏”二字的来源,也是它比简单积分器更容易防止膜电位无限膨胀的关键。新手最容易忘记的一点是:LIF不是简单地把输入累加,它同时也在不断“漏电”,这个衰减会让膜电位对近期输入更敏感,时间稍远的输入贡献会指数级减小。
2.2 输入编码:不是所有数据都适合直接变成脉冲
SNN的输入必须是脉冲序列。RGB图像不可能直接塞进网络,你得先把像素值编码成脉冲。常用的方法有两类:率编码和时间编码。
率编码的做法是:把输入值映射成神经元在一段时间内的发放频率。比如一个像素值0.8,就让它平均80%的时间步发放脉冲,0.2则只在20%的时间步发放。这种做法实现简单,对噪声鲁棒,但信息密度低——要几十甚至上百个时间步才能表达一个数值。时间编码的做法则是:让脉冲出现在输入时间序列的特定位置。比如数值越大,脉冲发放得越早;数值越小,发放越晚。时间编码信息密度高、能发挥SNN的时序优势,但设计上有不少讲究。
在本文的演示任务中,我直接用“离散时间事件”来构造输入——每个时间步,哪些输入神经元发放脉冲本身就构成了模式。这省去了编码步骤,也能更清楚地看到STDP在学习什么。如果你要在自己的数据上用SNN,我建议先从率编码开始,等调试稳定之后再尝试时间编码,因为率编码对脉冲数的随机波动更鲁棒,问题诊断也容易得多。
2.3 时间步长与仿真时长:精度和开销的弹簧
时间步长dt直接决定仿真的时间分辨率。dt越小,指数衰减的计算越接近连续系统,但同样的物理时间需要更多步数,训练开销线性上升。我给的核心代码里,dt = 1.0,所有时间常数都以“步”为单位,这样最直观。仿真总时长T则取决于任务需求:要让神经元有足够时间累积膜电位并发放脉冲,但也不能太长以至于任务结束后还在随机发放。
关于dt的选型,我的通用建议是:先根据输入信号的动态范围确定大致时长,设置dt使得整个事件窗口至少覆盖20~50步。如果时间常数τ远小于dt,指数衰减会退化成“一步归零”;如果τ远大于总时长T,膜电位几乎没有泄漏,又会失去LIF的“遗忘”特性。在调参时,我喜欢做一个小脚本,把exp(-dt/tau_m)直接打印出来,如果这个系数小于0.5或大于0.99,我都会谨慎确认参数是否合理。
2.4 到底要不要用现成的SNN框架
这一步我纠结过。SpikingJelly、Norse、snnTorch这些库已经很成熟,尤其SpikingJelly在GPU上优化了脉冲神经网络的仿真和训练,开箱即用。但我最后还是决定用PyTorch裸写,理由有三:第一,STDP在绝大多数深度学习框架里不是一等公民,用现成库反而绑手绑脚;第二,裸写能精确控制权重更新的时机和方式,核心代码量其实很小,不超过200行;第三,你只要把每个模块写明白了,后面想换成别的神经元模型或学习规则都很容易,直接替换对应模块就行。对想真正理解SNN原理的人来说,这比调框架API的收益高得多。如果你的目标是快速在业务里试用SNN,那直接上现成框架没问题;但如果你想搞懂STDP背后的计算逻辑,从零实现一遍绕不过去。
3. 从生物学到代码:STDP规则与“痕迹变量”的落地
3.1 标准STDP公式:时间差如何决定权重变化
STDP的权重更新量取决于突触前后脉冲的时间差 Δt = t_post - t_pre。规则是:
- 如果Δt > 0(突触前先发放,突触后后发放),Δw = A₊ · exp(-Δt / τ₊)
- 如果Δt < 0(反过来),Δw = -A₋ · exp(Δt / τ₋)
这里的A₊和A₋分别是长时程增强和长时程抑制的幅度,τ₊和τ₋是时间窗口常数。公式的含义很简单:突触前后的脉冲离得越近,权重调整幅度越大;超过时间窗口,权重几乎没有变化。这个指数窗的形状决定了网络能学到多长跨度的时序关联。窗口太短,只有精确同步的脉冲才能引起学习;窗口太长,时间顺序的差异被平滑掉,学习也没有区分度。
在实际系统中,神经元发放频率可能很高,每个时刻会有多对脉冲组合发生,逐对计算代价很高。所以在工程实现里,我们不会真的去记录每个脉冲的时间戳再两两计算,而是会转用“痕迹变量”来近似。
3.2 用痕迹变量做在线STDP:让网络记住“最近活跃过”
与其记录每个神经元每次脉冲的精确时间,再用时间差查表,不如维护一个指数衰减的痕迹。当某个神经元在t时刻发放脉冲时,它的痕迹变量设成1,此后每个时间步按指数衰减:
trace = trace · exp(-1/τ) + spike
这个公式更新后,痕迹变量在当前时刻的值就代表“这个神经元最近有多活跃”。用这种思路,STDP规则变成两条非常朴素的更新规则:
- 当突触后神经元发放脉冲时,对所有连接执行 w += A₊ · pre_trace。此时pre_trace越大,说明突触前神经元“刚刚活跃过”,正好和突触后发放形成了LTP。
- 当突触前神经元发放脉冲时,对所有连接执行 w -= A₋ · post_trace。如果post_trace不为0,说明突触后神经元刚发过脉冲,现在突触前又发,形成LTD。
这个“痕迹”方案避免了存储整个脉冲序列、再回溯计算每个脉冲对的时间差,计算复杂度大幅降低,而且在在线仿真里非常好写。这也是很多神经形态芯片实现STDP时采用的实际方案。代码实现上,痕迹变量就是两个普通tensor,每个时间步做一次衰减加法,不涉及任何高深数学。
3.3 为什么这里不用autograd来更新权重
细心的读者会发现,我用nn.Parameter存权重,却打算直接改weight.data,这在PyTorch里是“破坏计算图”的写法。原因很简单:STDP不是基于梯度下降的规则,它的权重更新来自脉冲时间差和痕迹变量,没有任何关于损失函数的梯度信息。你当然可以把STDP写成自定义autograd Function,让“梯度”等于STDP增量,但这样做既别扭又容易出错。
我倾向于把整个SNN仿真看作一个“带状态的计算过程”:权重在仿真过程中不断被外部规则更新,而神经元的膜电位和痕迹变量本身就是状态变量。这其实更接近PyTorch里RNN的hidden state更新方式,只是权重也在每个时间步被更新而已。理解这一点,就不会纠结“为什么没有loss.backward()”了。只有当你要把SNN的输出接到一个传统损失函数做监督学习时,才需要引入替代梯度之类的机制,让STDP和梯度信号共存。
4. 完整代码拆解:LIF神经元、STDP突触和仿真主循环
4.1 项目文件结构
我用一个简单的目录组织:
stdp_project/ ├── lif.py # LIF神经元模块 ├── stdp.py # STDP突触模块 └── train.py # 仿真主循环和实验三个文件加起来不到200行,核心依赖只有PyTorch和matplotlib。第一次写的时候也可以全塞进一个文件里,但分成模块会让你后面替换神经元模型或学习规则时轻松很多。下面每个文件我都会给出完整代码,并逐段解释里面的设计意图。
4.2 LIF神经元模块:几十行代码复刻膜电位动力学
# lif.py import torch import torch.nn as nn class LIFNeuron(nn.Module): def __init__(self, num_neurons, tau_m=20.0, threshold=1.0, dt=1.0): super().__init__() self.num_neurons = num_neurons self.tau_m = tau_m self.threshold = threshold self.dt = dt self.reset_state() def reset_state(self): self.membrane = torch.zeros(self.num_neurons) self.spikes = torch.zeros(self.num_neurons) def forward(self, current): decay = torch.exp(-self.dt / self.tau_m) self.membrane = self.membrane * decay + current spikes = (self.membrane >= self.threshold).float() self.membrane = torch.where(spikes > 0, torch.zeros_like(self.membrane), self.membrane) self.spikes = spikes return spikesforward实现的就是LIF离散方程。这里有一个细节:发放脉冲后,膜电位不是直接减去阈值,而是重置为0。这是integrate-and-fire模型的常见简化。在某些更精细的模型里,重置方式会改成“减去阈值”,把超出阈值的部分保留下来作为残余电位。前者实现简单且稳定,所以我先用它。
另外一个值得注意的地方是:self.membrane和self.spikes都是普通tensor状态,不参与梯度。整个LIF模块在PyTorch里其实不需要被当成可训练模块,只是为了方便统一用nn.Module管理状态。如果后面要接监督学习,再用替代梯度做反向传播,就需要更复杂的处理了。
4.3 STDP突触模块:权重和痕迹变量放在一起
# stdp.py import torch import torch.nn as nn class STDPSynapse(nn.Module): def __init__(self, pre_size, post_size, w_init=0.10, a_plus=0.01, a_minus=0.012, tau_pre=20.0, tau_post=20.0, w_min=0.0, w_max=1.0): super().__init__() self.pre_size = pre_size self.post_size = post_size self.a_plus = a_plus self.a_minus = a_minus self.tau_pre = tau_pre self.tau_post = tau_post self.w_min = w_min self.w_max = w_max self.weight = nn.Parameter( torch.full((pre_size, post_size), w_init) + 0.01 * torch.randn(pre_size, post_size) ) self.pre_trace = torch.zeros(pre_size) self.post_trace = torch.zeros(post_size) self.decay_pre = torch.exp(-1.0 / tau_pre) self.decay_post = torch.exp(-1.0 / tau_post) def reset_state(self): self.pre_trace = torch.zeros(self.pre_size) self.post_trace = torch.zeros(self.post_size) def forward(self, pre_spikes): return pre_spikes @ self.weight def update_traces(self, pre_spikes, post_spikes): self.pre_trace = self.pre_trace * self.decay_pre + pre_spikes self.post_trace = self.post_trace * self.decay_post + post_spikes def update_weights(self, pre_spikes, post_spikes): ltp = self.a_plus * torch.outer(self.pre_trace, post_spikes) ltd = self.a_minus * torch.outer(pre_spikes, self.post_trace) self.weight.data += (ltp - ltd) self.weight.data.clamp_(self.w_min, self.w_max)forward完成了最简单的电流传导:突触前脉冲向量pre_spikes和权重矩阵相乘,得到突触后神经元接收到的输入电流。这里我用向量外积实现STDP更新:torch.outer(self.pre_trace, post_spikes)得到的是pre_size × post_size的矩阵,其中[i, j]位就是pre_trace[i] * post_spikes[j],当且仅当j神经元在当下发放了脉冲时,才对该列权重产生LTP;torch.outer(pre_spikes, self.post_trace)则对当i神经元当下发放时产生LTD。这个写法既利用了GPU矩阵运算,也完全复现了STDP的在线式更新逻辑。
代码里我还在初始权重上加了微小的随机扰动,这是为了打破输出神经元之间的对称性。如果不加这个扰动,两个神经元在相同输入和相同初始条件下很可能学到完全一样的权重,整个网络等于退化成只有一个有效神经元。这个细节是踩坑后加上的,后面我会单独说。
4.4 仿真主循环:一个时间步里所有事情发生的顺序
# train.py import torch from lif import LIFNeuron from stdp import STDPSynapse T = 40 input_size = 20 output_size = 2 lif = LIFNeuron(num_neurons=output_size, tau_m=20.0, threshold=1.0, dt=1.0) syn = STDPSynapse(pre_size=input_size, post_size=output_size, w_init=0.1) def run_simulation(input_seq): syn.reset_state() lif.reset_state() for t in range(T): pre_spikes = input_seq[t] current = syn.forward(pre_spikes) post_spikes = lif.forward(current) syn.update_traces(pre_spikes, post_spikes) syn.update_weights(pre_spikes, post_spikes) return syn.weight这段代码演示了一个时间步内的事情:突触前脉冲经过权重投影产生电流,电流进入LIF神经元,神经元发放脉冲,突触前/后痕迹更新,权重根据痕迹更新。注意,每次仿真之前必须调用reset_state清除神经元膜电位和痕迹变量,否则上一次仿真的状态会泄漏到本次,结果完全不可复现。这个“每次仿真前重置状态”的习惯,比任何参数调整都重要。
5. 实测结果:一个简单的STDP网络如何区分两个时序模式
5.1 实验任务:让STDP网络自己学出“时序选择性”
为了验证STDP确实在起作用,我构造了一个可控的小任务。输入层有20个神经元,输出层有2个神经元,全连接,初始权重全部在0.1附近。输入样本是20个时间步的脉冲序列:
- 模式A:在时间步0到9,输入神经元0到9依次发放脉冲(每步只发放一个)。
- 模式B:在时间步10到19,输入神经元10到19依次发放脉冲。
每个样本总共跑40个时间步。训练阶段,交替呈现模式A和模式B各100次。测试时,分别输入一个模式A样本和一个模式B样本,观察两个输出神经元对两种模式的响应。
这里的关键点是,STDP会形成“时序选择性”:如果某个输出神经元系统性在模式A的输入脉冲之后发放,那么连接这些输入神经元和该输出神经元的权重就会增强;反之,如果这个输出神经元恰好总在模式B时更活跃,它就会对模式B的输入更敏感。最终我们希望看到两个输出神经元分别被两个模式“唤醒”,而这一切没有任何标签参与。
5.2 训练设置与关键参数
| 参数 | 取值 | 说明 |
|---|---|---|
| 输入层大小 | 20 | 对应20个输入脉冲源 |
| 输出层大小 | 2 | 对应2个LIF神经元 |
| 仿真时长T | 40 | 每个样本跑40个时间步 |
| 膜时间常数τ_m | 20 | 膜电位衰减系数约0.95 |
| 阈值V_th | 1.0 | 膜电位超过1则发放 |
| 初始权重w | 0.1 | 所有突触在0.1附近 |
| STDP幅度A₊/A₋ | 0.01 / 0.012 | LTD略大于LTP |
| 时间窗口τ_pre/τ_post | 20 / 20 | 痕迹衰减时间常数 |
| 权重裁剪范围 | [0, 1] | 防止权重发散 |
A₋比A₊略大,这个细节很重要:如果增强幅度大于抑制幅度,权重会持续增长到上限,网络很快失去区分能力。让抑制略强,权重分布会维持在一个更自然的稳态区间。
5.3 结果可视化与观察
训练结束后,我输出了最终权重矩阵(20×2)。典型的结果是:神经元0对模式A对应的输入神经元0到9有较大权重,对模式B对应的输入神经元10到19权重很小;神经元1则完全反过来。换句话说,每个输出神经元都把自己“锚定”到了其中一个模式。
更直观的做法是分别输入两个模式,统计2个输出神经元在仿真窗口内各自的发放次数。实测中我经常看到的现象是:一个输出神经元在模式A下发放10次以上,在模式B下只发放2次;另一个神经元则完全反过来。这说明网络虽然没有经过任何监督训练,仅仅靠STDP就学会了用不同输出通道响应不同的输入时序。权重演化过程也很有趣:最开始权重接近均匀,经过几十次模式交替后,某个神经元会因为随机初始化的微小偏差在某类输入下发放得略多一点,STDP会把这个优势逐步放大,形成类似“赢者通吃”的分化。这个过程很像神经科学里说的神经元竞争。
需要注意的是,这个实验是高度可控的简单场景。真实数据上的SNN无监督特征学习还远没有传统深度网络那么优雅,需要更复杂的编码、更细致的超参数调节甚至兴奋/抑制神经元的混合。我的目的是验证STDP实现是否正确,而不是宣称SNN在通用任务上超越了深度学习。
6. 从“能跑”到“能信”:我调试SNN时踩过的五个大坑
6.1 脉冲灭绝:神经元从不发放,权重被永久冻结
我第一次跑这个实验时,输出神经元从头到尾一个脉冲都没有发。表面看起来“网络很安静”,但问题很严重:如果神经元不发放,post_trace永远为0,STDP的LTP通道直接失效;由于pre_spikes一直在发,LTD倒是会生效,权重会持续被压制到0。整个学习过程完全废掉。
排查思路:先检查输入电流的量级。当时我的初始权重是0.01,输入脉冲又是稀疏的0/1序列,瞬间电流太小,膜电位根本够不到阈值。解决方法是把初始权重调到0.1,或者降低阈值到0.5,也可以暂时在输入电流上加一个小幅常数噪声。这个坑很多初学者都会踩,而且因为它不报错,只是结果不对,所以最难发现。我后来养成的习惯是:训练前先手动输入一个强脉冲,看神经元会不会发,如果连强输入都不发,那就是参数问题而不是学习问题。
6.2 权重爆炸:A₊和A₋取值不当导致全部顶到上限
另一个常见问题正好相反:权重增长过快,几个样本之后全部顶到1.0。原因通常是A₊和A₋相差太大,或者神经元发放过于频繁,STDP更新次数太多。权重一旦全体饱和,网络同样失去选择性。我在实验里把A₊/A₋设为0.01/0.012,配合权重裁剪[0, 1]后效果稳定。调试时建议先记住这个经验规律:在当前任务里,LTP发生的频率通常更高,所以A₊应该比A₋略小来平衡;如果任务中脉冲密度很低,两者的差距可以缩小。具体数值需要根据你的任务微调,但记住“抑制略强”这个原则能省很多事。
6.3 同质化:两个输出神经元学成一模一样
如果两个输出神经元初始权重相同,输入分布又完全对称,STDP有可能让它们走到完全相同的权重解,没有任何分化。这不是代码bug,而是对称性导致的动力学平衡没有被打破。解决方法有三个:给初始权重加不同的微小扰动;给膜电位或输入加噪声;或者在后处理中引入侧抑制,让一个神经元发放后短暂抑制其他神经元。在本文的代码里,我用的是最小修复——给两个输出神经元设置略不同的初始权重,并在权重初始化时加入随机噪声打破对称性。如果你想要更稳健的分化,侧抑制是更接近生物系统的方式,但实现复杂度会高一些。
6.4 时间常数设置过小导致“每个时间步都在发放”
还有一个隐蔽问题:时间常数太短、仿真步长又大,膜电位每个时间步都归零再重新累积,你可能看到神经元在一个样本里几乎每个时间步都在发放。这看起来“有活性”,实际上毫无选择性,因为脉冲频率已经饱和了,STDP的任何细节都丢失了。这种情况需要把τ_m调大一些,或者降低输入电流幅度。我一般用发放率作为健康指标:如果神经元在45%到60%以上的时间步都在发放,我会认为仿真参数过于激进,反而对学习不利。一个有效的SNN训练过程,输出脉冲应该是稀疏且对输入有选择性的,而不是一根“平均兴奋”的探针。
6.5 基于痕迹的STDP代码里,一个顺序变化就会改变学习结果
最后想强调一个细节。主循环里update_traces和update_weights的执行顺序,不同论文和库的做法甚至不完全一致。有些实现先更新痕迹,再用上一时刻痕迹更新权重;有些先计算权重更新,再更新痕迹。这两种顺序在数学上对应的是“在线更新”和“事件触发更新”的细微差别。在简单任务中差异可能不大,但一旦你要复现某篇论文里的STDP实验,建议严格以该论文的伪代码为准,或者在代码里预留一个参数来切换两种顺序。
我自己的经验是:一定要清晰记录每一步使用的“状态”到底是t时刻还是t+1时刻的。哪怕只是差了一个时间步,在分布不规整的脉冲序列上,学习结果都可能天差地别。这也是我在第4章的代码里刻意把update_traces放在update_weights之前的原因——它更贴近“突触前脉冲先影响痕迹,痕迹再影响突触可塑性”的因果顺序。如果你在某次实验中发现STDP始终学不出结构,先别着急调学习率,回头检查一下这个顺序。
跑通这个项目之后,我最大的感受是:SNN和STDP的代码门槛一点都不高,真正需要花心思的是理解“事件在时间上的先后关系如何被转化成权重变化”。当你亲手看到两个神经元因为输入时序的不同而自然分化时,那种直观的冲击感比读十篇综述都强。后面如果你想继续深入,可以从侧抑制、对偶STDP规则、替代梯度监督训练这几个方向扩展,这套最小实现打底,足够你在上面折腾很久了。