2.13 脉冲神经网络 2.13.1 SNN 脉冲神经网络
一句话
神经元像生物神经元一样积累膜电位,超过阈值才发出一个脉冲;信息由「何时发放」承载。在有事件才计算的神经形态硬件上,没有输入时几乎不计算(用 PyTorch 在 CPU、GPU 上仿真时,每个时间步照样做稠密运算)。
图中只有 LSM(2.14.3)用到了脉冲单元,而且整个储备池不训练。这里讲的是可以端到端训练的脉冲网络。
LIF(泄漏积分发放)神经元 :
U_t 是积分后、重置前的膜电位,V_t 是重置后的膜电位。β < 1 是泄漏(没有输入时电位慢慢回落),I_t 是输入电流(上一层脉冲的加权和),Θ 是阶跃函数,发放后当场减去阈值(软重置),和下面的代码一致。另一种常见写法把重置推迟到下一步再减(snnTorch 的默认写法),两者差一个 β 因子,是不同的离散约定。
训练的难点与替代梯度 :阶跃函数的导数几乎处处为 0,反向传播传不过去。替代梯度法(Neftci 2019 等)前向时照常用阶跃函数,反向时假装它是一个平滑函数(比如 fast sigmoid),用平滑函数的导数代替。网络按时间展开后就能用 BPTT 训练。另一条路是先训练普通网络,再把它转换成脉冲网络。
事件数据 :事件相机(DVS)每个像素独立工作,亮度变化超过阈值才输出一个事件 (x, y, t, 极性),时间分辨率到微秒级,没有固定帧率。事件式触觉传感器同理,只有接触变化时才输出。脉冲网络天然适合这类异步、稀疏的数据。但现实中更常见的做法是把事件流重新积分成「帧」再交给普通的 CNN 或 Transformer:
表示
做法
事件计数图
一个时间窗内每个像素的事件数(正负极性分两个通道)
体素网格
把时间也切成若干格,得到一个时空三维张量
时间面
每个像素记录最近一次事件的时刻,越新越亮
这样做能直接用现成模型,代价是丢掉了一部分时间分辨率。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 import torchimport torch.nn as nnclass SpikeFn (torch.autograd.Function): """前向:阶跃函数(发放或不发放);反向:用 fast sigmoid 的导数代替(替代梯度)""" @staticmethod def forward (ctx, v ): ctx.save_for_backward(v) return (v > 0 ).float () @staticmethod def backward (ctx, grad ): (v,) = ctx.saved_tensors return grad / (1 + 10 * v.abs ()) ** 2 class LIFLayer (nn.Module): def __init__ (self, din, dout, beta=0.9 , v_th=1.0 ): super ().__init__() self.fc = nn.Linear(din, dout) self.beta, self.v_th = beta, v_th def forward (self, x ): v = torch.zeros(x.size(1 ), self.fc.out_features) spikes = [] for t in range (x.size(0 )): v = self.beta * v + self.fc(x[t]) s = SpikeFn.apply(v - self.v_th) v = v - s * self.v_th spikes.append(s) return torch.stack(spikes) def sweep_batch (n, T=40 , C=20 ): """刺激在 20 个传感点上从左扫到右(类 0)或从右扫到左(类 1),比如触觉阵列上的滑动方向""" y = torch.randint(0 , 2 , (n,)) x = (torch.rand(T, n, C) < 0.02 ).float () for i in range (n): start = torch.randint(0 , 10 , (1 ,)).item() for c in range (C): ch = c if y[i] == 0 else C - 1 - c x[start + c, i, ch] = 1.0 return x, y torch.manual_seed(0 ) net = nn.ModuleList([LIFLayer(20 , 64 ), LIFLayer(64 , 2 )]) opt = torch.optim.Adam(net.parameters(), lr=2e-3 ) for step in range (300 ): x, y = sweep_batch(32 ) counts = net[1 ](net[0 ](x)).sum (0 ) loss = nn.functional.cross_entropy(counts, y) opt.zero_grad(); loss.backward(); opt.step() with torch.no_grad(): x, y = sweep_batch(500 ) hidden = net[0 ](x) acc = (net[1 ](hidden).sum (0 ).argmax(1 ) == y).float ().mean().item() print (f"滑动方向分类准确率 {acc:.3 f} (每个传感点有一个扫描刺激脉冲,另有随机背景脉冲;两类的脉冲数分布相同,主要差在先后顺序)" )print (f"隐藏层平均发放率 {hidden.mean():.3 f} (每个神经元每个时间步发放的概率;在支持稀疏执行的神经形态硬件上,越低越省能耗)" )
上排是两类输入:每个传感点有一个扫描刺激脉冲,再加上随机的背景脉冲;两类的脉冲数分布相同,主要差在扫描的先后顺序。训练后的隐藏层对两种顺序给出不同的放电模式,输出层据此分类。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import torchtorch.manual_seed(0 ) n_ev, H, W = 5000 , 32 , 32 t = torch.sort(torch.rand(n_ev)).values x = (16 + 10 * torch.cos(6 * t) + torch.randn(n_ev)).clamp(0 , W - 1 ).long() y = (16 + 10 * torch.sin(6 * t) + torch.randn(n_ev)).clamp(0 , H - 1 ).long() p = (torch.rand(n_ev) < 0.5 ).long() count = torch.zeros(2 , H, W).index_put_((p, y, x), torch.ones(n_ev), accumulate=True ) B = 5 bins = (t * B).long().clamp(max =B - 1 ) voxel = torch.zeros(B, H, W).index_put_((bins, y, x), torch.where(p == 1 , 1.0 , -1.0 ), accumulate=True ) surface = torch.zeros(H * W).scatter_reduce(0 , y * W + x, t, reduce="amax" ).view(H, W) print ("计数图" , tuple (count.shape), "体素网格" , tuple (voxel.shape), "时间面" , tuple (surface.shape))print ("每个时间格里的事件数:" , torch.bincount(bins, minlength=B).tolist())
典型例子
神经形态芯片:Intel Loihi、IBM TrueNorth、SpiNNaker,在特定任务上功耗比 GPU 低一到两个数量级
事件相机:DVS128 Gesture 手势识别、高速运动下的跟踪
事件式触觉:NUS 的 NeuTouch 触觉皮肤配合 SNN 做视触觉物体识别和滑移检测(RSS 2020)
低功耗的语音唤醒词检测
优势 :事件触发、稀疏计算,在神经形态硬件上功耗极低;脉冲的时间本身携带信息;和事件相机、事件式触觉的数据形式直接匹配
局限 :不可导,要靠替代梯度或网络转换;在 GPU 上仿真反而比普通网络慢;精度普遍低于同规模的普通网络;工具链和硬件还不普及
适合的数据 :稀疏的事件流和脉冲数据:事件相机、事件式触觉、神经电生理信号;功耗受限的边缘设备
2.14 储备池与随机网络 这一族的共同点:网络的大部分权重随机生成后固定不变,只训练读出层。读出层用平方损失(岭回归)时,训练就是一次线性代数求解;用逻辑回归之类的读出时,仍要迭代优化。
2.14.1 ESN 回声状态网络 〔图:ESN〕
一句话
一个随机连接、固定不训练的大循环网络(储备池)把输入序列的历史「搅拌」成高维状态,只训练一个线性层从状态里读出答案。
图中 ESN 的隐藏单元是随机相连的「循环单元」。Jaeger 2001 年提出。
a 是泄漏率。W_in 和 W 随机生成后不变,只有 W_out 用岭回归一次解出:
回声状态性质 :储备池的状态只取决于近期的输入历史,初始状态的影响会像回声一样逐渐消失。常用做法是把 W 的谱半径(最大特征值的模)缩放到 1 附近或略小于 1。这是经验规则,单独并不能保证回声状态性质,Yildiz、Jaeger、Kiebel 2012 年构造过谱半径小于 1 仍不满足的反例;对标准 tanh 储备池,最大奇异值小于 1 是一个更保守的充分条件。实际性质还受泄漏率、输入缩放和输入本身影响。在稳定的范围内,谱半径越接近 1,记忆通常越长。
物理储备池计算 :既然储备池只要「有记忆的非线性动力系统」就行,就可以用真实的物理系统充当:光学器件、自旋电子器件,甚至软体机器人的身体(Nakajima 等用类章鱼臂的软体结构当储备池做计算)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 import mathimport torchdef mackey_glass (n, tau=17 , dt=0.1 , sub=10 ): """Mackey-Glass 混沌时间序列:dx/dt = 0.2·x(t-τ)/(1 + x(t-τ)^10) - 0.1·x(t)""" hist = int (tau / dt) x = [1.2 ] * (hist + 1 ) for _ in range ((n + 300 ) * sub): x_tau = x[-hist - 1 ] x.append(x[-1 ] + dt * (0.2 * x_tau / (1 + x_tau ** 10 ) - 0.1 * x[-1 ])) return torch.tensor(x[-n * sub::sub]) torch.manual_seed(0 ) data = mackey_glass(3000 ) data = (data - data.mean()) / data.std() def esn (in_scale=0.5 , ridge=1e-4 , N=400 , leak=0.3 , rho=0.9 ): torch.manual_seed(0 ) W_in = (torch.rand(N) - 0.5 ) * in_scale bias = (torch.rand(N) - 0.5 ) * in_scale W = torch.rand(N, N) - 0.5 W[torch.rand(N, N) > 0.1 ] = 0 W *= rho / torch.linalg.eigvals(W).abs ().max () step = lambda x, u: (1 - leak) * x + leak * torch.tanh(W_in * u + W @ x + bias) x, states = torch.zeros(N), [] for u in data[:-1 ]: x = step(x, u) states.append(x) X, Y = torch.stack(states), data[1 :] feats = lambda X, U: torch.cat([torch.ones(len (X), 1 ), U[:, None ], X], 1 ) washout, split = 200 , 2500 Ftr = feats(X[washout:split], data[washout:split]) W_out = torch.linalg.solve(Ftr.T @ Ftr + ridge * torch.eye(Ftr.size(1 )), Ftr.T @ Y[washout:split]) pred = feats(X[split:], data[split:-1 ]) @ W_out nrmse = (((pred - Y[split:]) ** 2 ).mean().sqrt() / Y[split:].std()).item() x, u, outs = X[split - 1 ], data[split], [] for _ in range (300 ): x = step(x, u) u = torch.cat([torch.ones(1 ), u.view(1 ), x]) @ W_out outs.append(u) err = (torch.stack(outs) - data[split + 1 :split + 301 ]).abs () return nrmse, err[:20 ].mean().item(), err[80 :100 ].mean().item(), err[200 :].mean().item() fmt = lambda e: f"{e:.3 f} " if math.isfinite(e) else "∞" for in_scale, ridge, note in [(0.5 , 1e-4 , "默认设置" ), (1.0 , 1e-4 , "输入缩放 0.5 → 1.0" ), (0.5 , 1e-6 , "岭系数 1e-4 → 1e-6" )]: nrmse, e1, e2, e3 = esn(in_scale, ridge) print (f"{note} :单步预测 NRMSE {nrmse:.4 f} ;自由生成误差 第 1–20 步 {fmt(e1)} ," f"第 81–100 步 {fmt(e2)} ,第 201–300 步 {fmt(e3)} (数据标准差 1)" )
单步预测几乎和真实序列重合;自由生成时每一步的误差都会带进下一步,300 步内误差缓慢累积,仍然跟得住混沌序列的形状。
常见说法与实测
常见说法 :ESN 只训练线性读出层,训练就是一次岭回归,简单、快、稳定;只要谱半径小于 1,储备池就有回声状态性质(后半句是常见误解,见上文)。
实测 :同一个随机储备池,三种设置的单步预测都很好(NRMSE 0.009–0.013),自由生成却差别巨大:默认设置(输入缩放 0.5、岭系数 1e-4)300 步内误差都在 0.1 以内;输入缩放改成 1.0,自由生成发散到无穷;岭系数改成 1e-6,单步预测反而最准(0.0090),自由生成第 81–100 步的平均误差到 18。两种预测都从训练段之后的第一个点 data[2501] 开始比较。
怎么理解 :「训练简单」说的是求解读出层那一步:一次线性求解,不用梯度迭代;它在数值上稳不稳,要看条件数、精度、岭系数和求解器(见本段最后)。难点在超参。单步预测每一步都喂真实输入,误差不会累积;自由生成把自己的输出当下一步输入,读出层的小误差经过储备池一遍遍放大,正则太弱(读出层过拟合)或者输入缩放太大,都可能让这个闭环失稳。单步预测好,不能说明自由生成也好。另外两件事要分开看:谱半径小于 1 本身保证不了回声状态性质;即使储备池有这个性质,自由生成时输出反馈回输入形成闭环,闭环稳不稳定还要单独判断。另外,发散也可能部分来自数值条件:正规方程 FᵀF 的条件数很大、岭系数又很小时,求解本身就不稳定(换 float64、QR 或 SVD 会更稳),这里没有单独排除。
典型例子
混沌时间序列预测:Jaeger 与 Haas 2004 年在 Science 上用 ESN 预测 Mackey-Glass 序列,误差比此前方法小几个数量级
无线信道均衡、语音特征的时序分类
物理储备池计算:光学、自旋电子、软体机器人身体
优势 :训练只是一次岭回归,秒级完成;没有 BPTT 和梯度消失爆炸的问题;储备池可以用任何有衰减记忆的物理系统实现
局限 :储备池随机固定,表达能力有限,大规模任务上不如 LSTM、Transformer;对谱半径、泄漏率、输入缩放、正则系数很敏感,尤其是自由生成(上面的例子里输入缩放从 0.5 改成 1.0,自由生成前 20 步的平均误差就到了 2.0,数据标准差才是 1,第 81–100 步已经发散到无穷);储备池要很大
适合的数据 :低维连续时间序列的预测与系统辨识:混沌系统、信号均衡、传感器流;物理或类脑硬件上的时序计算
2.14.2 ELM 极限学习机 〔图:ELM〕
一句话
单隐层前馈网络,输入到隐藏层的权重随机生成后固定,只用最小二乘一次解出隐藏层到输出的权重。
图中 ELM 是输入层到隐藏层之间随机连线的前馈网络。黄广斌等 2004 年以 ELM 之名提出(2006 年的期刊版最常被引用);同样的思想更早有 Pao 1994 年的随机向量函数连接网络(RVFL),后来又有 Rahimi 与 Recht 2007 年的随机特征(2008 年的后续工作叫 random kitchen sinks),学界对命名和原创性有过争议。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 import timeimport torchclass ELM : def __init__ (self, in_dim, n_hidden, ridge=1e-3 ): self.W = torch.randn(in_dim, n_hidden) self.b = torch.randn(n_hidden) self.ridge = ridge def hidden (self, X ): return torch.tanh(X @ self.W + self.b) def fit (self, X, Y ): H = self.hidden(X) self.beta = torch.linalg.solve(H.T @ H + self.ridge * torch.eye(H.size(1 )), H.T @ Y) return self def predict (self, X ): return self.hidden(X) @ self.beta torch.manual_seed(0 ) X = torch.rand(2000 , 2 ) * 4 - 2 Y = torch.sin(2 * X[:, :1 ]) * torch.cos(2 * X[:, 1 :]) Xtr, Ytr, Xte, Yte = X[:1500 ], Y[:1500 ], X[1500 :], Y[1500 :] t0 = time.time() elm = ELM(2 , 300 ).fit(Xtr, Ytr) print (f"ELM:训练 {1000 * (time.time() - t0):.1 f} ms,测试 MSE {((elm.predict(Xte) - Yte) ** 2 ).mean():.5 f} " )t0 = time.time() mlp = torch.nn.Sequential(torch.nn.Linear(2 , 64 ), torch.nn.Tanh(), torch.nn.Linear(64 , 1 )) opt = torch.optim.Adam(mlp.parameters(), lr=1e-2 ) for _ in range (2000 ): loss = ((mlp(Xtr) - Ytr) ** 2 ).mean() opt.zero_grad(); loss.backward(); opt.step() print (f"反向传播 MLP:训练 {1000 * (time.time() - t0):.0 f} ms,测试 MSE {((mlp(Xte) - Yte) ** 2 ).mean().item():.5 f} " )
典型例子
嵌入式和在线场景的快速分类:肌电(EMG)、脑电(EEG)信号的实时识别
工业过程的软测量
在线序列 ELM:数据流到来时增量更新输出权重
优势 :训练极快,没有学习率和迭代;λ > 0 时是严格凸问题,解唯一(不加正则时要看 H 是否列满秩,秩不足就用伪逆取最小范数解);本例要调的主要是隐藏层宽度和正则系数(随机权重的尺度、激活函数也会影响结果)
局限 :隐藏层不学习,需要比反向传播网络多得多的隐藏单元;不能学特征,对图像等高维原始数据效果差;隐藏单元数 L 很大时,求解 L×L 的 HᵀH 又慢又占内存(样本多时 H 可以分块累加成 HᵀH)
适合的数据 :中小规模的低维特征向量,对训练速度要求高的场合:嵌入式在线学习、生理信号快速分类
2.14.3 LSM 液态机 〔图:LSM〕
一句话
储备池换成随机连接的脉冲神经元:输入像石子投进液体激起涟漪,某一时刻「液体」的状态记录了最近一段输入的历史;只训练线性读出层。
图中 LSM 的隐藏单元是「脉冲隐藏单元」(带三角的绿色圆),随机连接。Maass、Natschläger、Markram 2002 年提出,和 ESN 几乎同时独立出现:ESN 来自机器学习,LSM 来自计算神经科学,后者更关心生物合理性。
结构 :输入脉冲 → 随机连接的 LIF 神经元群(不训练)→ 在若干时间窗内统计每个神经元的发放数作为特征 → 线性读出(岭回归或逻辑回归)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 import torchtorch.manual_seed(0 ) C_in, N, T = 20 , 200 , 100 W_in = (torch.rand(N, C_in) < 0.2 ).float () * torch.rand(N, C_in) * 1.5 W_rec = (torch.rand(N, N) < 0.1 ).float () * torch.randn(N, N) * 0.4 def reservoir (spikes_in, beta=0.9 , v_th=1.0 ): """spikes_in: (T, C_in) → 储备池的脉冲 (T, N)""" v, s, out = torch.zeros(N), torch.zeros(N), [] for t in range (spikes_in.size(0 )): v = beta * v + W_in @ spikes_in[t] + W_rec @ s s = (v > v_th).float () v = v * (1 - s) out.append(s) return torch.stack(out) templates = (torch.rand(3 , T, C_in) < 0.05 ).float () def sample (cls ): x = templates[cls].clone() shift = torch.randint(-2 , 3 , (1 ,)).item() x = torch.roll(x, shift, 0 ) x = torch.clamp(x + (torch.rand(T, C_in) < 0.01 ).float (), 0 , 1 ) return x def features (x, n_bins=5 ): return reservoir(x).view(n_bins, T // n_bins, N).sum (1 ).flatten() ys_tr = torch.arange(3 ).repeat(40 ); ys_te = torch.arange(3 ).repeat(100 ) F_tr = torch.stack([features(sample(c)) for c in ys_tr]) F_te = torch.stack([features(sample(c)) for c in ys_te]) Y = torch.nn.functional.one_hot(ys_tr, 3 ).float () Wr = torch.linalg.solve(F_tr.T @ F_tr + 1.0 * torch.eye(F_tr.size(1 )), F_tr.T @ Y) acc = ((F_te @ Wr).argmax(1 ) == ys_te).float ().mean().item() print (f"3 类时空脉冲模式的分类准确率 {acc:.3 f} ;储备池平均发放率 {F_tr.mean() / (T // 5 ):.3 f} " )
典型例子
口语数字识别:脉冲储备池 + 线性读出(Verstraeten 2005)
计算神经科学:作为皮层微环路的计算模型
神经形态硬件上的时序分类
优势 :只训练线性读出,训练快、是凸问题;事件触发,适合神经形态芯片低功耗运行;天然处理连续的时间输入流
局限 :仿真要小时间步积分,在普通硬件上慢;储备池超参(连接密度、权重尺度、时间常数)全靠调;精度通常不如端到端训练的网络
适合的数据 :脉冲和事件流:事件相机、事件式触觉、神经信号;类脑芯片上的时序分类
2.15 其他经典网络 2.15.1 SVM 支持向量机 〔图:SVM〕
一句话
找一个让两类之间「间隔」最大的超平面;借助核函数,可以在高维特征空间里画线性边界,对应原空间里的非线性边界。
图里 SVM 被画成一个小的前馈结构,作者也说明它严格说不算神经网络。Boser、Guyon、Vapnik 1992 年引入核方法,Cortes 与 Vapnik 1995 年提出软间隔。
软间隔 SVM 的优化目标 (合页损失 + L2 正则):
间隔等于 2/‖w‖,最小化 ‖w‖ 就是最大化间隔。合页损失对 yᵢ(wᵀxᵢ + b) ≥ 1 的样本(分对且离边界足够远)为 0,最终的解只由 yᵢ(wᵀxᵢ + b) ≤ 1 的样本决定:正好落在间隔边界上的、进到间隔里的,以及分错的。严格地说,支持向量是对偶解里系数 αᵢ > 0 的样本,它们都在这个范围里;反过来,正好落在间隔边界上的样本也可能 αᵢ = 0(多余的边界点),所以按这个不等式筛出来的是支持向量的候选范围。两类重叠多时,支持向量可以占样本的很大一部分。
核技巧 :对偶问题里数据只以内积 xᵢᵀxⱼ 的形式出现,把它换成核函数 k(xᵢ, xⱼ) 就等于在一个高维特征空间里做线性 SVM,而不用显式算出那个空间。最常用的 RBF 核:
和神经网络的关系 :线性 SVM 等于「单层线性网络 + 合页损失 + 权重衰减」;RBF 核 SVM 的决策函数和 RBF 网络(2.4.5)形式相同,中心取在支持向量上;随机傅里叶特征可以把核 SVM 近似成「随机特征 + 线性分类器」,和 ELM(2.14.2)是同一类做法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 import torchdef hinge_svm (X, y, epochs=500 , lam=1e-3 , lr=0.1 ): w = torch.zeros(X.shape[1 ], requires_grad=True ) b = torch.zeros(1 , requires_grad=True ) opt = torch.optim.SGD([w, b], lr=lr) for _ in range (epochs): loss = torch.clamp(1 - y * (X @ w + b), min =0 ).mean() + lam / 2 * w @ w opt.zero_grad(); loss.backward(); opt.step() return w.detach(), b.detach() torch.manual_seed(0 ) n = 400 r = torch.cat([torch.rand(n // 2 ), 1.5 + torch.rand(n // 2 )]) th = torch.rand(n) * 2 * torch.pi X = torch.stack([r * torch.cos(th), r * torch.sin(th)], 1 ) y = torch.cat([-torch.ones(n // 2 ), torch.ones(n // 2 )]) w, b = hinge_svm(X, y) print ("线性 SVM 训练集准确率:" , (torch.sign(X @ w + b) == y).float ().mean().item())gamma, D = 1.0 , 300 Omega = torch.randn(2 , D) * (2 * gamma) ** 0.5 phase = torch.rand(D) * 2 * torch.pi phi = lambda X: (2 / D) ** 0.5 * torch.cos(X @ Omega + phase) print ("核近似误差:" , (phi(X[:5 ]) @ phi(X[:5 ]).T - torch.exp(-gamma * torch.cdist(X[:5 ], X[:5 ]) ** 2 )).abs ().max ().item())w, b = hinge_svm(phi(X), y, lr=0.5 ) print ("RBF 核 SVM(随机特征近似)训练集准确率:" , (torch.sign(phi(X) @ w + b) == y).float ().mean().item())margin = y * (phi(X) @ w + b) print ("y·f(x) ≤ 1 的样本数(在间隔上、间隔内或被分错,≈ 支持向量;SGD 只跑了固定轮数,没检查是否到最优):" , (margin <= 1.0 + 1e-3 ).sum ().item(), "/" , n)
典型例子
文本分类:TF-IDF 特征 + 线性 SVM,垃圾邮件、情感分类的强基线
行人检测:HOG 特征 + SVM(Dalal & Triggs 2005)
手写数字:深度学习之前的最好方法之一
R-CNN(2014):用 CNN 提特征,再用 SVM 分类
生物信息:蛋白质分类、基因表达数据分类
优势 :最大间隔,泛化好,小样本上稳;凸优化,没有非全局的局部最优(w 通常唯一,偏置 b 不一定唯一);核技巧处理非线性;解只依赖支持向量
局限 :核方法训练复杂度大约是样本数的平方到三次方,十万样本以上就很慢;核函数和超参(C、γ)要调;不学特征表示;原生只输出类别,没有概率
适合的数据 :几百到几万个样本的固定长度特征向量:手工特征(HOG、TF-IDF)、高维稀疏文本、生物信息特征;深度特征之上的分类器
2.15.2 SOM 自组织映射(Kohonen 网络)〔图:KN〕
一句话
一张二维网格上的神经元互相竞争,离输入最近的获胜者和它在网格上的邻居一起向输入靠拢;训练完,高维数据被映射到网格上,且相似的数据落在相邻的格子里。
图中 KN 是输入连到一片没有输出层的神经元。Kohonen 1982 年提出,属于竞争学习(1.10)。
训练 :每来一个输入 x:
找获胜单元(BMU):c = argmin_j ‖x − w_j‖
获胜者和它在网格上的邻居一起更新:
r 是单元在网格上的坐标,学习率 η 和邻域半径 σ 随训练逐渐缩小:先粗略地铺开,再精细地调整。
拓扑保持 :邻居一起更新,使得网格上相邻的单元对应输入空间里相近的区域。SOM 因此既是聚类(每个单元是一个原型),也是可视化工具(把高维数据摊在一张二维地图上)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 import torchtorch.manual_seed(0 ) H, W, D = 10 , 10 , 3 weights = torch.rand(H * W, D) grid = torch.stack(torch.meshgrid(torch.arange(H), torch.arange(W), indexing="ij" ), -1 ).view(-1 , 2 ).float () colors = torch.rand(3000 , 3 ) n_iter = 5000 for t in range (n_iter): x = colors[torch.randint(0 , len (colors), (1 ,))] bmu = ((weights - x) ** 2 ).sum (1 ).argmin() frac = t / n_iter lr, sigma = 0.5 * (1 - frac) + 0.01 , 4 * (1 - frac) + 0.5 h = torch.exp(-((grid - grid[bmu]) ** 2 ).sum (1 ) / (2 * sigma ** 2 ))[:, None ] weights += lr * h * (x - weights) w = weights.view(H, W, D) neighbor = ((w[1 :] - w[:-1 ]).norm(dim=-1 ).mean() + (w[:, 1 :] - w[:, :-1 ]).norm(dim=-1 ).mean()) / 2 random_pair = (weights[torch.randperm(H * W)] - weights).norm(dim=1 ).mean() print (f"网格上相邻单元的平均色差 {neighbor:.3 f} ,随机两单元 {random_pair:.3 f} (相邻的颜色相近 = 拓扑保持)" )quant_err = torch.cdist(colors, weights).min (1 ).values.mean() print (f"量化误差(每个颜色到最近原型的平均距离){quant_err:.3 f} " )
典型例子
高维数据可视化:世界各国生活水平地图、基因表达谱聚类
Kohonen 1988 年的「音素打字机」:芬兰语语音识别
客户分群、传感器数据的工况聚类
机器人导航中的拓扑地图
优势 :无监督;把高维数据映射到二维,倾向于保留局部的邻近关系(不保证完整保持拓扑),便于可视化;结果可解释(每个单元是一个原型)
局限 :网格大小、衰减日程需要人为设定;没有明确的目标函数;在大规模高维数据的可视化上,t-SNE、UMAP 更常用
适合的数据 :中等维度的连续向量的聚类与可视化:颜色、传感器特征、基因表达谱、用户画像
2.15.3 NTM 神经图灵机 〔图:NTM〕
一句话
一个控制器网络(LSTM 或 MLP)加一块外部记忆矩阵,用可微的「软」读写头访问记忆;整个系统可以端到端训练,学会复制、排序这类简单算法。
图中 NTM 的控制器下方挂着一排「记忆单元」,就是外部记忆。Graves、Wayne、Danihelka 2014 年提出。
读 :读头输出一个在 N 个记忆槽上的权重分布 w_t(和为 1),读出的是加权平均:
写 :先按权重擦除一部分(擦除向量 e),再加上新内容(添加向量 a):
寻址 (权重 w 怎么来),四步:
按内容 :控制器给出一个键 k 和锐度 β,与每个记忆槽算余弦相似度后做 softmax
插值 :用门 g 在「按内容的结果」和「上一步的权重」之间插值
移位 :用一个小卷积核把权重向前或向后挪一格(实现「下一个位置」)
锐化 :权重取 γ 次方再归一化,防止移位后变模糊
按内容寻址就是注意力;位置寻址让它能像指针一样顺序读写。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 import torchimport torch.nn as nnimport torch.nn.functional as Fclass NTM (nn.Module): """LSTM 控制器 + 一个读头 + 一个写头;寻址包含内容、插值、移位、锐化四步""" def __init__ (self, in_dim, out_dim, ctrl=100 , N=16 , Mdim=12 ): super ().__init__() self.N, self.Md = N, Mdim self.ctrl = nn.LSTMCell(in_dim + Mdim, ctrl) n_addr = Mdim + 1 + 1 + 3 + 1 self.read_head = nn.Linear(ctrl, n_addr) self.write_head = nn.Linear(ctrl, n_addr + 2 * Mdim) self.out = nn.Linear(ctrl + Mdim, out_dim) def address (self, params, w_prev, M ): k, beta, g, s, gamma = params.split([self.Md, 1 , 1 , 3 , 1 ], -1 ) beta, g = F.softplus(beta), torch.sigmoid(g) s, gamma = F.softmax(s, -1 ), 1 + F.softplus(gamma) wc = F.softmax(beta * F.cosine_similarity(M, k[:, None ], dim=-1 ), -1 ) wg = g * wc + (1 - g) * w_prev ws = s[:, :1 ] * torch.roll(wg, -1 , 1 ) + s[:, 1 :2 ] * wg + s[:, 2 :] * torch.roll(wg, 1 , 1 ) w = ws ** gamma return w / (w.sum (-1 , keepdim=True ) + 1e-8 ) def forward (self, x ): T, B, _ = x.shape M = torch.full((B, self.N, self.Md), 1e-6 ) h = c = torch.zeros(B, self.ctrl.hidden_size) r = torch.zeros(B, self.Md) w_r = w_w = F.one_hot(torch.zeros(B, dtype=torch.long), self.N).float () outs = [] for t in range (T): h, c = self.ctrl(torch.cat([x[t], r], -1 ), (h, c)) wp = self.write_head(h) w_w = self.address(wp[:, :self.Md + 6 ], w_w, M) e, a = torch.sigmoid(wp[:, self.Md + 6 :2 * self.Md + 6 ]), wp[:, 2 * self.Md + 6 :] M = M * (1 - w_w[..., None ] * e[:, None ]) + w_w[..., None ] * a[:, None ] w_r = self.address(self.read_head(h), w_r, M) r = (w_r[..., None ] * M).sum (1 ) outs.append(self.out(torch.cat([h, r], -1 ))) return torch.stack(outs) def copy_task (B, L, bits=6 ): """复制任务:先看一段随机二值向量,遇到结束符后把它原样输出""" seq = torch.randint(0 , 2 , (L, B, bits)).float () x = torch.zeros(2 * L + 1 , B, bits + 1 ) x[:L, :, :bits] = seq x[L, :, bits] = 1 return x, seq torch.manual_seed(0 ) model = NTM(7 , 6 ) opt = torch.optim.Adam(model.parameters(), lr=1e-3 ) for step in range (3000 ): L = torch.randint(1 , 6 , (1 ,)).item() x, seq = copy_task(32 , L) out = model(x)[L + 1 :] loss = F.binary_cross_entropy_with_logits(out, seq) opt.zero_grad(); loss.backward() nn.utils.clip_grad_norm_(model.parameters(), 10 ) opt.step() if step % 1000 == 999 : with torch.no_grad(): x, seq = copy_task(200 , 5 ) err = ((torch.sigmoid(model(x)[6 :]) > 0.5 ).float () != seq).float ().mean().item() print (f"第 {step + 1 } 步:长度 5 的复制任务,每个比特的错误率 {err:.3 f} (瞎猜 0.5)" )
典型例子
原论文:学会复制、重复复制、联想回忆、排序等简单算法,并能推广到比训练时更长的序列
可微神经计算机 DNC(Nature 2016):在伦敦地铁线路图上找路线、在家谱图上推断亲属关系
记忆增强网络的少样本学习(Santoro 2016)
思想延续:注意力就是可微的按内容寻址;检索增强生成(RAG)、带外部记忆的智能体都是「计算与存储分离」的延续
优势 :计算与存储分离,记忆容量不受隐状态大小限制;读写可微,能端到端学会简单算法;在复制类任务上能推广到更长的序列
局限 :训练不稳定、收敛慢、对超参敏感;实现复杂;实际任务上已被 Transformer 和检索增强取代
适合的数据 :需要显式读写外部存储的序列任务:算法学习、图上的推理、少样本快速绑定
2.15.4 胶囊网络 CapsNet
一句话
用一个向量(胶囊)代替一个标量神经元:向量的长度表示「这个实体存在的概率」,方向表示它的姿态;低层胶囊通过「动态路由」投票决定激活哪个高层胶囊。
Sabour、Frosst、Hinton 2017 年提出。动机:CNN 的池化丢掉了部件之间的精确空间关系,一张五官位置被打乱的脸也可能被认成脸。
squash 非线性 :保持方向,把长度压到 0 到 1 之间:
动态路由 :低层胶囊 i 用变换矩阵预测高层胶囊 j 的姿态 û_{j|i} = W_ij u_i;高层胶囊的输入是这些预测的加权和 s_j = Σ_i c_ij û_{j|i},耦合系数 c_ij = softmax_j(b_ij)。迭代几次,每次把「预测与结果的一致程度」加到 b_ij 上:
预测一致的低层胶囊会把更多的票投给同一个高层胶囊,相当于「部件的姿态互相吻合,才认定整体存在」。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 import torchimport torch.nn as nndef squash (s, dim=-1 ): n2 = (s ** 2 ).sum (dim, keepdim=True ) return n2 / (1 + n2) * s / (n2.sqrt() + 1e-8 ) class CapsLayer (nn.Module): def __init__ (self, n_in, d_in, n_out, d_out, iters=3 ): super ().__init__() self.W = nn.Parameter(0.01 * torch.randn(1 , n_in, n_out, d_out, d_in)) self.iters = iters def forward (self, u ): u_hat = (self.W @ u[:, :, None , :, None ]).squeeze(-1 ) b = torch.zeros(u_hat.shape[:3 ]) for _ in range (self.iters): c = b.softmax(dim=2 ) v = squash((c[..., None ] * u_hat).sum (1 )) b = b + (u_hat * v[:, None ]).sum (-1 ) return v class CapsNet (nn.Module): def __init__ (self ): super ().__init__() self.conv = nn.Sequential(nn.Conv2d(1 , 64 , 9 ), nn.ReLU()) self.primary = nn.Conv2d(64 , 16 * 8 , 9 , stride=2 ) self.digits = CapsLayer(16 * 6 * 6 , 8 , 10 , 16 ) def forward (self, x ): p = self.primary(self.conv(x)) p = p.view(x.size(0 ), 16 , 8 , 6 , 6 ).permute(0 , 1 , 3 , 4 , 2 ).reshape(x.size(0 ), -1 , 8 ) v = self.digits(squash(p)) return v.norm(dim=-1 ) model = CapsNet() print ("输出:" , tuple (model(torch.randn(2 , 1 , 28 , 28 )).shape)) print (f"参数量: {sum (p.numel() for p in model.parameters()):,} (大头在胶囊之间的变换矩阵)" )
典型例子
MultiMNIST:两个数字大面积重叠时,CapsNet 能把它们分开识别
smallNORB:带 EM 路由的矩阵胶囊(2018)对未见过的视角泛化更好
一些医学影像的小规模实验
优势 :用向量表示姿态,部件与整体的空间关系显式建模;对视角变化的泛化更好;重叠物体分离效果好
局限 :动态路由的迭代计算昂贵,难以扩展到大图像和深层网络;在 ImageNet 等大规模任务上没有超过 CNN 和 ViT;后续发展基本停滞,主要作为「等变表示」思想的一个分支来了解
适合的数据 :小尺寸、部件空间关系和视角变化重要的图像