目录
- 时间驱动与事件驱动
-
- 时间驱动
- 事件驱动
- 基于时间驱动的脉冲神经元
-
- spikingjelly:LIF神经元
- 实验仿真
时间驱动与事件驱动
时间驱动
为了便于理解时间驱动,我们可以将SNN(spiking neuron network)看作是一种RNN,它的输入是电压增量(电流与膜电阻的乘积),隐藏状态是膜电压,输出是脉冲。这样的SNN神经元具有马尔可夫性:当前时刻的输出只与当前时刻的输入,神经元自身的状态有关。
可以用充电,放电,重置3个方程描述脉冲神经元:H(t)=f(V(t−1),X(t))H(t)=f(V(t-1),X(t))H(t)=f(V(t−1),X(t))S(t)=g(H(t)−Vthreshold)=Θ(H(t)−Vthreshold)S(t)=g(H(t)-V_{threshold})=\Theta(H(t)-V_{threshold})S(t)=g(H(t)−Vthreshold)=Θ(H(t)−Vthreshold)V(t)=H(t)⋅(1−S(t))+Vreset⋅S(t)V(t)=H(t)\cdot(1-S(t))+V_{reset}\cdot S(t)V(t)=H(t)⋅(1−S(t))+Vreset⋅S(t)其中,V(t)V(t)V(t)是神经元的膜电压,X(t)X(t)X(t)是外源输入,即电压增量,H(t)H(t)H(t)是神经元的隐藏状态,可以理解为神经元还没有发脉冲前的瞬间,f(V(t−1),X(t))f(V(t-1),X(t))f(V(t−1),X(t))是神经元的状态更新方程,不同的神经元,区别在于更新方程不同。
对于LIF神经元,其动作的微分方程和近似差分方程分别为:τmdV(t)dt=−(V(t)−Vreset)+X(t)\tau_{m}\frac{dV(t)}{dt}=-(V(t)-V_{reset})+X(t)τmdtdV(t)=−(V(t)−Vreset)+X(t)τm(V(t)−V(t−1))=−(V(t−1)−Vreset)+X(t)\tau_{m}(V(t)-V(t-1))=-(V(t-1)-V_{reset})+X(t)τm(V(t)−V(t−1))=−(V(t−1)−Vreset)+X(t)因此,对应的充电(状态更新)方程为:f(V(t−1),X(t))=V(t−1)+1τm(−(V(t−1)−Vreset)+X(t))f(V(t-1),X(t))=V(t-1)+\frac{1}{\tau_{m}}(-(V(t-1)-V_{reset})+X(t))f(V(t−1),X(t))=V(t−1)+τm1(−(V(t−1)−Vreset)+X(t))放电方程中的S(t)S(t)S(t)是神经元发放的脉冲,g(x)=Θ(x)g(x)=\Theta(x)g(x)=Θ(x)是阶跃函数,也被称为脉冲函数,脉冲函数的输出仅为0或1。
重置表示电压重置过程:发放脉冲,则电压重置为VresetV_{reset}Vreset;如果没有发放脉冲,则电压不变。
注意到,在RNN中,使用了可微分的激活函数,例如tanh。但SNN中对应的脉冲函数g(x)g(x)g(x)是不可微分的,这就导致了包含SNN层的深度学习模型不能用反向传播的方式来训练。但是我们可以用一个形状与阶跃函数相似的激活函数σ(x)\sigma(x)σ(x)去代替。这被称为梯度替代法。
在前向传播时,使用g(x)=Θ(x)g(x)=\Theta(x)g(x)=Θ(x),神经元的输出是离散的0和1,网络依然是标准的SNN,但反向传播时,使用梯度替代函数代替计算脉冲函数的梯度:g′(x)=σ′(αx)g'(x)=\sigma'(\alpha x)g′(x)=σ′(αx)其中,α\alphaα可以调整激活函数的平滑程度:σ(αx)=11+exp(−αx)\sigma(\alpha x)=\frac{1}{1+exp(-\alpha x)}σ(αx)=1+exp(−αx)1当α\alphaα越大,σ(αx)\sigma(\alpha x)σ(αx)就越接近Θ(x)\Theta(x)Θ(x),但也越容易在靠近x=0x=0x=0时梯度爆炸,远离x=0x=0x=0时则容易梯度消失,导致网络难以训练。
事件驱动
对于事件驱动的SNN,不需要通过时钟驱动SNN的计算,神经元的状态更新由事件触发。
在脉冲响应模型(Spike response model,SRM)中,使用显式的V−tV-tV−t方程来描述神经元的活动,而不是用微分方程去描述神经元的充电过程。由于V−tV-tV−t是已知的,因此给与任何输入X(t)X(t)X(t),神经元的响应V(t)V(t)V(t)都可以被直接算出(不需要提供V(t−1)V(t-1)V(t−1)的信息,因此与时钟取消了关联)。
Tempotron神经元也是一种SNN神经元,其命名来源于ANN中的感知器,感知器是最简单的ANN神经元,其对输入数据加权求和,输出二值0或1来表示数据的分类结果。Tempotron神经元可以看作是SNN中的感知器,它同样对输入数据加权求和,再输出二分类结果。
Tempotron的膜电位定义为:V(t)=∑iwi∑tiK(t−ti)+VresetV(t)=\sum_{i}w_{i}\sum_{t_{i}}K(t-t_{i})+V_{reset}V(t)=i∑witi∑K(t−ti)+Vreset其中,wiw_{i}wi是第iii个输入的权重,也可以看作是所连接的突触的权重;tit_{i}ti是第iii个输入的脉冲发射时刻,K(t−ti)K(t-t_{i})K(t−ti)是由于输入脉冲引发的突触后膜电位(postsynaptic potentials,PSPs);VresetV_{reset}Vreset是Tempotron的重置电位,或者称为静息电位。
其中,关于K(t−ti)K(t-t_{i})K(t−ti)是一个关于tit_{i}ti的函数(PSP Kernel),当t≥tit\geq t_{i}t≥ti时,其函数表达为:K(t−ti)=V0(exp(−t−tiτ)−exp(−t−tiτs))K(t-t_{i})=V_{0}(exp(-\frac{t-t_{i}}{\tau})-exp(-\frac{t-t_{i}}{\tau_{s}}))K(t−ti)=V0(exp(−τt−ti)−exp(−τst−ti))当t<tit<t_{i}t<ti时,其函数表达为:K(t−ti)=0K(t-t_{i})=0K(t−ti)=0其中,V0V_{0}V0是归一化系数,使得函数的最大值为1;τ\tauτ是膜电位时间常数,可以看出输入的脉冲在Tempotron上会引起瞬时的电位激增,但之后会呈指数衰减;τs\tau_{s}τs是突触电流的时间常数,这一项的存在表示突触上传导的电流会随时间衰减。
单个的Tempotron可以作为一个二分类器,分类结果的判别,是看Tempotron的膜电位在仿真周期内是否超过阈值:
其中tmax=argmaxt{Vt}t_{max}=argmax_{t}\left\{V_{t}\right\}tmax=argmaxt{Vt}。从Tempotron的输出结果也可以看出,Tempotron只能发射不超过1个脉冲。单个Tempotron只能做二分类,但多个Tempotron就可以做多分类。
关于Tempotron的训练,可以使用梯度下降法优化网络参数wiw_{i}wi,以二分类问题为例,损失函数被定义为仅在分类错误的情况下存在。当实际类别yyy是1而实际输出y^\widehat{y}y是0,损失为Vthreshold−VtmaxV_{threshold}-V_{t_{max}}Vthreshold−Vtmax;当实际类别是0而实际输出是1,损失为Vtmax−VthresholdV_{t_{max}}-V_{threshold}Vtmax−Vthreshold。可以统一写为:E=(y−y^)(Vthreshold−Vtmax)E=(y-\widehat{y})(V_{threshold}-V_{t_{max}})E=(y−y)(Vthreshold−Vtmax)直接对参数求梯度,可以得到:
因为:∂V(tmax)∂tmax=0\frac{\partial V(t_{max})}{\partial t_{max}}=0∂tmax∂V(tmax)=0
基于时间驱动的脉冲神经元
spikingjelly:LIF神经元
后续内容的实验部分将基于第三方库spikingjelly进行。SpikingJelly是一个开源脉冲神经网络深度学习框架,使用PyTorch作为自动微分后端,利用C++和CUDA扩展进行性能增强,同时支持CPU和GPU。框架中包含数据集,可视化,深度学习三大模块。
安装spikingjelly:
pip install spikingjelly
在spikingjelly中,约定只能输出脉冲,即0或1的神经元,都可以称之为"脉冲神经元",使用脉冲神经元的网络,进而也可以称之为脉冲神经网络。在spikingjelly.clock_driven.neuron
中定义了各种常见的脉冲神经元模型,我们以spikingjelly.clock_driven.neuron.LIFNode
为例进行了解。
首先导入相关模块:
import torch
import torch.nn as nn
import numpy as np
from spikingjelly.clock_driven import neuron
from spikingjelly import visualizing
import matplotlib.pyplot as plt
新建一个LIF神经元构成的网络层:
lif=neuron.LIFNode(tau=100.0)
LIF神经元的参数有以下:
- tau:膜电位时间常数;
- v_threshold:神经元的阈值电压;
- v_reset:神经元的重置电压。如果不为None,当神经元发射脉冲后,电压会被重置为v_reset;如果设置为None,则电压会被减去v_threshold;
- surrogate_function:反向传播时,脉冲函数的替代函数。
其中 surrogate_function 参数,在前向传播时的行为与阶跃函数完全相同。
神经元的数量是在初始化或调用 reset()
函数重新初始化后,根据第一次接收输入的 shape
自动决定的。
与RNN中的神经元非常类似,脉冲神经元也是有状态的,或者说是有记忆。脉冲神经元的状态变量,一般是它的膜电位V(t)V(t)V(t)(发射脉冲前),因此,spikingjelly.clock_driven.neuron
中的神经元,都有对象v
,可以打印刚刚新建神经元的膜电位:
print(lif.v) # 0.0
电位是0,因为我们没有给它任何输入,我们给几个不同的输入,观察神经元的电压shape
:
x = torch.rand(size=[2, 3])
lif(x)
print('x.shape', x.shape, 'lif.v.shape', lif.v.shape)
# x.shape torch.Size([2, 3]) lif.v.shape torch.Size([2, 3])
lif.reset()x = torch.rand(size=[4, 5, 6])
lif(x)
print('x.shape', x.shape, 'lif.v.shape', lif.v.shape)
# x.shape torch.Size([4, 5, 6]) lif.v.shape torch.Size([4, 5, 6])
回顾前面的LIF神经元状态更新方程(充电):τmdV(t)dt=−(V(t)−Vreset)+X(t)\tau_{m}\frac{dV(t)}{dt}=-(V(t)-V_{reset})+X(t)τmdtdV(t)=−(V(t)−Vreset)+X(t)其中,τm\tau_{m}τm是膜电位时间常数,VresetV_{reset}Vreset是重置电压,对于这样的微分方程,由于X(t)X(t)X(t)不是常数,所以不易求出解析解,我们用差分方程近似微分:τm(V(t)−V(t−1))=−(V(t−1)−Vreset)+X(t)\tau_{m}(V(t)-V(t-1))=-(V(t-1)-V_{reset})+X(t)τm(V(t)−V(t−1))=−(V(t−1)−Vreset)+X(t)因此可以得到V(t)V(t)V(t)(神经元未发射脉冲前的瞬时电压)的表达式:V(t)=f(V(t−1),X(t))=V(t−1)+1τm(−(V(t−1)−Vreset)+X(t))V(t)=f(V(t-1),X(t))=V(t-1)+\frac{1}{\tau_{m}}(-(V(t-1)-V_{reset})+X(t))V(t)=f(V(t−1),X(t))=V(t−1)+τm1(−(V(t−1)−Vreset)+X(t))不同的神经元,充电方程不尽相同。但膜电位超过阈值电压后,释放脉冲,以及释放脉冲后,膜电位的重置都是相同的。因此它们全部继承自 spikingjelly.clock_driven.neuron.BaseNode
,共享相同的放电、重置方程。
surrogate_function()
在前向传播时是阶跃函数,只要输入大于或等于0,就会返回1,否则会返回0。我们将这种元素仅为0或1的 tensor 视为脉冲。
发射脉冲消耗了神经元累积的电荷,因此膜电位会有一个瞬时的降低,即电压重置,在spikingjelly中,设置了两种重置方式,根据spikingjelly.clock_driven.neuron
中的构造函数的参数之一 v_reset
选择:
- Hard:
v_reset=1.0
(默认值为1.0),释放脉冲后,膜电位直接被设置成重置电压:V(t)=VresetV(t)=V_{reset}V(t)=Vreset; - Soft:
v_reset=None
,释放脉冲后,膜电位减去阈值电压:V(t)=V(t)−VthresholdV(t)=V(t)-V_{threshold}V(t)=V(t)−Vthreshold。
实验仿真
接下来,我们将逐步给与神经元输入,并查看它的膜电位和输出脉冲。
现在我们给与LIF神经元层持续的输入,并画出其放电后的膜电位和输出脉冲:
lif.reset()
x = torch.as_tensor([2.0])
T = 150
s_list = [] # 输出脉冲
v_list = [] # 隐藏状态:膜电位
for t in range(T):s_list.append(lif(x))v_list.append(lif.v)visualizing.plot_one_neuron_v_s(np.asarray(v_list), np.asarray(s_list), v_threshold=lif.v_threshold, v_reset=lif.v_reset,dpi=200)
plt.show()
我们给与的输入shape=[1]
(固定数值2.0),因此这个神经元层只有一个神经元。它的膜电位(Membrane Potentials)和输出脉冲随着时间变化情况如上图。
现在将该层神经元重置(重新初始化),并给与shape=[32]
的随机数组输入,查看这32个神经元的膜电位和输出脉冲:
lif.reset()
x = torch.rand(size=[32]) * 4
T = 50
s_list = [] # 32个神经元在50步仿真下输出的脉冲
v_list = [] # 32个神经元在50步仿真下的膜电位
for t in range(T):s_list.append(lif(x).unsqueeze(0)) # unsqueeze(0)增加首维度,lif(x).unsqueeze(0) shape=[1,32]v_list.append(lif.v.unsqueeze(0))s_list = torch.cat(s_list) # shape=[50,32]
v_list = torch.cat(v_list) # shape=[50,32]visualizing.plot_2d_heatmap(array=np.asarray(v_list), title='Membrane Potentials', xlabel='Simulating Step',ylabel='Neuron Index', int_x_ticks=True, x_max=T, dpi=200)
visualizing.plot_1d_spikes(spikes=np.asarray(s_list), title='Spiking', xlabel='Simulating Step',ylabel='Neuron Index', dpi=200)
plt.show()
从Spiking中可以看出,有部分神经元发射了脉冲,有的却没有发射。我们可以想像,在重复输入一个信息对象的过程中,该信息对象对应的某个特征持续被感知到,迫使该神经元膜电位上升而作出发射脉冲的行为。
从上面的仿真可以看出,脉冲神经元层可以接收连续数值张量,但只能输出脉冲,如果仅包含LIF脉冲神经元层,模型不需要训练(因为没有可学习参数)。
对比传统的神经元,传统神经元的激活是瞬时的,缺少时间尺度上提供的信息,从生物角度看,此刻兴奋的神经元在下一时刻应该惯性地也比较兴奋。这就引出了脉冲神经网络(SNN)的想法,神经元的兴奋度不应该是一瞬间更新的,而是具有慢慢衰减的表现,在持续输入信息的刺激下,最后激活的兴奋区才是预测判别结果。SNN更贴近生物角度,适合处理与脉冲序列有关的应用前景。
研究问题随笔
通过观察LIF神经元,发现神经元缺少知识,若想嵌入到深度学习模型,只能作为替代激活函数的一个工具。LIF神经元只是模拟了生物上信息的传递与神经元电位的改变,后续研究或许可以在LIF的机理上进行修改,为神经元增加知识。