ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从零手搓Hopfield网络:Hebbian学习、能量函数与联想记忆原理详解

从零手搓Hopfield网络:Hebbian学习、能量函数与联想记忆原理详解

1. 为什么要在2024年重新聊Hopfield网络

第一次接触Hopfield网络是在一门神经计算选修课上,当时教材把它放在玻尔兹曼机前面当铺垫,两页纸带过,给我的印象就是"一个老掉牙的、只能存几个黑白格子的玩具模型"。后来做序列建模、做检索召回,兜兜转转又绕回联想记忆这个命题,才发现这个1982年的东西被严重低估了。它本质上回答了一个非常朴素但极其重要的问题:一个由大量简单单元互相连接构成的系统,能不能不靠显式的存储地址,而是靠"能量下降"这种物理式的动力学过程,把残缺的、带噪声的输入自动收敛到它见过的某个完整模式上。这就是联想记忆(associative memory)的内核,也是今天向量检索、去噪自编码器、甚至某些注意力机制背后共享的直觉。

我把这个项目定位成一次"从零手搓+原理吃透"的复现练习:用纯NumPy实现离散Hopfield网络,把Hebbian学习规则、能量函数、异步更新、容量上限、伪吸引子这些概念全部落到可运行的代码上,再用几个可控的实验把它的能力边界摸清楚。它适合三类人看——想搞懂能量模型和吸引子动力学但被公式劝退的初学者;需要给课程作业或技术分享找一个"小而完整"案例的开发者;以及做检索、纠错、模式补全相关工作时想补一补经典理论底子的从业者。全文不依赖任何深度学习框架,一台装了Python和NumPy的机器就能跑完全部实验,代码可以直接抄。

我先把结论摆在这里,省得你读到一半才发现方向不对:Hopfield网络不是拿来和现代大模型比性能的,它的价值在于用最少的数学把"记忆即吸引子"这件事讲透。你把它的容量公式、能量单调性、更新顺序这些细节啃下来,再去看后来的玻尔兹曼机、受限玻尔兹曼机、能量基模型,会发现很多设计选择是一脉相承的。下面我按"设计思路—核心原理—实操实现—踩坑排查"的顺序展开,中间会穿插我实际跑实验时的参数和结果。

2. 整体设计与思路拆解

2.1 为什么选离散Hopfield而不是连续型

Hopfield网络有两个常见变体:离散型(神经元状态取±1或{0,1})和连续型(状态是实数,用sigmoid或tanh做激活)。我这次选离散型,理由很实在。第一,离散型的能量函数是严格单调递减的,每一次异步更新都能保证能量不增,这个性质可以用几行代码验证,是理解"动力学收敛"最干净的入口;连续型虽然更接近生物神经元,但能量下降的证明要多绕几步,对第一次接触的人不友好。第二,离散型的权重矩阵有明确的Hebbian闭式解,不需要梯度下降,训练过程就是一次矩阵外积累加,几行就写完,能把注意力集中在"网络行为"而不是"优化器调参"上。第三,离散型的状态是二值的,可视化极其直观——把状态reshape成二维就是一张黑白图,收敛过程可以逐帧打印出来看,这对建立直觉帮助巨大。

连续型不是不重要,只是它更适合放在理解了离散型之后作为扩展。我在文末会提一句怎么平滑过渡到连续型,但主体实验全部基于离散型。这个取舍的逻辑是:先建立一个能被完全验证的最小闭环,再谈扩展,而不是一上来就堆一个自己都说不清收敛性的复杂模型。

2.2 存储方式:为什么用Hebbian外积而不是别的

Hopfield网络最反直觉的地方在于它"没有训练"——至少没有我们熟悉的损失函数和反向传播。它的权重是这样定的:对每一个要记住的模式ξ,计算它的外积ξξᵀ,把所有模式的外积加起来,对角线置零,再除以神经元数量N。用公式写就是:

$$W = \frac{1}{N}\sum_{\mu=1}^{P} \xi^{(\mu)}\xi^{(\mu)T}, \quad W_{ii}=0$$

为什么是这个形式?直觉上,外积矩阵的第(i,j)个元素正比于"第i个神经元和第j个神经元在第μ个模式里是否同号"。如果它们经常同号,权重就是正的(互相兴奋);经常异号,权重就是负的(互相抑制)。这正好符合Hebbian学习那句著名的"一起激发的神经元连在一起"。当网络处于某个存储模式附近时,每个神经元收到的局部场(local field)恰好被这个"同号加权"的结构推向正确方向,于是模式成为动力学的不动点。

我对比过另外两种思路:一是用伪逆规则(pseudo-inverse rule)直接解一个线性方程组,容量确实更高,但实现复杂、对噪声更敏感,不适合作为第一版;二是用梯度下降去最小化某种重构损失,那就变成现代自编码器的路子了,失去了Hopfield"无训练"的教学价值。所以最终锁定标准Hebbian规则,它的优点是闭式、可解释、和能量函数天然配套,缺点是容量有限(后面会算),但这个缺点本身就是极好的教学素材。

2.3 更新策略:异步还是同步,这是个坑

Hopfield网络有两种更新方式。同步更新是每个时间步所有神经元同时根据上一时刻的状态计算新状态;异步更新是每个时间步随机(或按固定顺序)挑一个神经元更新。这两者在收敛性上有本质区别:异步更新在W对称且对角线为零的条件下,能量严格单调不增,必然收敛到不动点或二周期环;同步更新则可能产生振荡,能量不保证单调。

我第一版图省事用了同步更新,结果在存储模式较多时观察到状态在两个构型之间来回跳,能量曲线上下震荡,当时还以为是代码写错了,排查了半天才想起这个理论结论。后来改成异步更新,能量曲线立刻变成单调下降的阶梯状,收敛行为干净利落。这个坑值得单独强调:如果你复现时发现网络不收敛,先检查更新是不是同步的。我在实操部分会给出异步更新的具体实现,包括如何用随机排列保证每个"扫描周期"内每个神经元恰好更新一次。

2.4 整体实验设计

整个项目分四个实验,层层递进。实验一验证单个模式的存储与恢复,确认基本闭环能跑通;实验二测试带噪声输入的纠错能力,量化"能扛多少比例的翻转";实验三扫描存储模式数量P,找到容量崩塌的临界点,和理论值对照;实验四观察伪吸引子(spurious attractor)现象,也就是网络收敛到一个谁都没存过的"混血"状态。这四个实验覆盖了Hopfield网络最核心的四个行为特征,跑完一遍,你对它的能力边界就有数了。

数据方面,我用的是手工构造的小尺寸二值图(比如5×5、7×7的字母或几何图形),而不是真实数据集。原因很简单:Hopfield网络的容量随N线性增长,N太小存不下几个模式,N太大又不好可视化和手工检查。5×5到10×10这个区间刚好能在"看得懂"和"存得下"之间取得平衡。下面进入核心原理和代码。

3. 核心原理拆解与关键细节

3.1 能量函数:整个网络的"指挥棒"

Hopfield网络最漂亮的地方是它有一个标量能量函数:

$$E = -\frac{1}{2}\sum_{i}\sum_{j} W_{ij}s_i s_j + \sum_i \theta_i s_i$$

其中s_i是神经元状态(±1),θ_i是阈值(无外部输入时通常取0)。这个函数的意义在于:网络的每一次异步更新,都等价于在能量地形图上往下走一步。具体来说,当第k个神经元翻转时,能量的变化量是:

$$\Delta E = -2 s_k \left(\sum_{j} W_{kj}s_j - \theta_k\right)$$

括号里那一项就是神经元k的局部场h_k。如果s_k和h_k同号,说明当前状态已经"顺应"了局部场,翻转会让能量上升,所以不翻;如果异号,翻转后ΔE为负,能量下降,于是翻。这就是为什么异步更新必然收敛——每一步要么不动,要么能量严格下降,而能量有下界(有限状态空间),所以必然在有限步内停在一个局部极小值。

我在代码里加了一个能量计算函数,每次更新后打印能量,亲眼看着它从高位阶梯式下降到平稳,那种"物理系统自发找最低点"的感觉非常直观。这里有个细节:能量函数里的1/2系数是为了抵消双重求和带来的重复计数,如果你忘了这个1/2,能量值会翻倍,虽然不影响收敛行为,但和理论对照时会对不上,排查起来很烦。

3.2 容量上限:0.138N是怎么来的

Hopfield网络能可靠存储多少个模式?经典结论是P ≈ 0.138N,超过这个数,恢复质量急剧下降。这个数字不是拍脑袋来的,它来自统计物理的复制对称分析。我这里不推完整推导,但把直觉讲清楚:每个存储模式都会在能量地形上挖一个坑(吸引子),但模式之间不是正交的,它们的外积会互相干扰,产生"串扰项"(crosstalk)。当P增大时,串扰的方差正比于P/N,一旦串扰的强度接近信号强度,原本的坑就被填平或移位,恢复就失败了。临界点解出来正好是P/N ≈ 0.138。

我在实验三里用N=100(10×10图)扫描P从1到30,实测在P≈13到15之间开始出现明显的恢复失败,和0.138×100≈13.8吻合得相当好。这个对照实验强烈建议你自己跑一遍,比看十遍公式都管用。需要提醒的是,0.138是"可靠恢复"的保守估计,如果你只要求"部分恢复"或者模式之间恰好比较正交,实际能存的数量会高一些,但不要指望突破太多,这是Hebbian规则的内在限制,想突破就得上伪逆规则或更复杂的结构。

3.3 伪吸引子:网络"记岔了"的根源

除了存储模式本身对应的吸引子,能量地形上还会出现一些"计划外"的局部极小值,叫伪吸引子。最常见的是混合态(mixture state):如果存了模式A和B,那么A和B在某些位上取平均、某些位上取"多数票"的构型,也可能成为一个稳定的不动点。网络从某个初始状态出发,可能收敛到这种谁都没存过的状态,表现为"输出了一张似是而非的图"。

这不是bug,是Hopfield网络的固有特性。我在实验四里故意构造了两个相似度较高的模式,然后从一个介于两者之间的初始状态出发,成功复现了混合态。理解这一点很重要:Hopfield网络的"记忆"不是精确的数据库查询,而是带噪声的、可能出错的模式完成。它的鲁棒性和它的不可靠性是同一枚硬币的两面。实际应用中如果对精确性要求高,要么限制存储数量远离容量上限,要么在输出后加一层校验。

3.4 权重对称性与自连接的处理

理论保证收敛的前提是W对称且对角线为零。Hebbian外积天然对称(ξξᵀ是对称的),所以对称性不用额外处理。但对角线必须手动置零,原因是自连接W_ii会让神经元"自己激励自己",破坏能量函数的单调性证明,还可能导致状态锁死。我见过有人忘了置零,结果网络对某些输入完全不响应,排查很久才发现是自连接在作祟。

置零的代价是每个神经元少了一个信息来源,但换来的是干净的收敛保证,非常划算。代码里就是一行np.fill_diagonal(W, 0),千万别漏。另外,如果你用{0,1}而不是{-1,+1}作为状态,权重公式和能量函数都要相应调整({0,1}版本更接近原始论文,但{-1,+1}版本数学上更对称、更好推),我统一用{-1,+1},避免混淆。

4. 实操过程与核心环节实现

4.1 环境与数据准备

环境极简:Python 3.9+,NumPy,Matplotlib(只用于可视化,可选)。不需要GPU,不需要深度学习框架。数据我用代码直接构造,以5×5的字母"T"和"L"为例,用+1表示黑格、-1表示白格,展平成25维向量。构造方式就是手写一个二维数组再flatten,这样每个模式长什么样一目了然,方便后面可视化对照。

import numpy as np import matplotlib.pyplot as plt def make_pattern(grid): # grid: 二维列表,1表示黑,0表示白 arr = np.array(grid, dtype=float) return np.where(arr > 0, 1.0, -1.0).flatten() pattern_T = make_pattern([ [1,1,1,1,1], [0,0,1,0,0], [0,0,1,0,0], [0,0,1,0,0], [0,0,1,0,0], ]) pattern_L = make_pattern([ [1,0,0,0,0], [1,0,0,0,0], [1,0,0,0,0], [1,0,0,0,0], [1,1,1,1,1], ])

这里有个实操细节:模式向量必须是±1,不能是0/1混用,否则外积的符号语义就乱了。我一开始图省事直接用0/1数组,结果权重全是正的,网络只会把所有状态往全1推,完全失效。这个错误很隐蔽,因为代码不报错,只是行为诡异,所以构造数据时一定要显式做np.where转换。

4.2 权重矩阵的构建

按Hebbian规则累加外积,然后置零对角线。注意除以N这个归一化因子,它影响的是局部场的量级,进而影响更新时的判定,但对最终收敛到的吸引子位置没有本质影响(因为符号不变)。我保留1/N是为了和理论公式对齐,方便后面算能量时数值稳定。

def build_weights(patterns): N = patterns.shape[1] W = np.zeros((N, N)) for p in patterns: W += np.outer(p, p) W /= N np.fill_diagonal(W, 0) return W

实测下来,P较小时(比如2到5个模式),权重矩阵的对角线置零后,非对角元素的值域大概在±0.2到±0.4之间,这个量级下局部场的判别很清晰。当P接近容量上限时,非对角元素的绝对值会变小、分布变乱,串扰开始显现,这也是为什么容量会崩塌——信号被噪声淹没了。

4.3 异步更新与能量监控

异步更新的关键是"每个扫描周期内每个神经元恰好更新一次",我用np.random.permutation(N)生成一个随机顺序,按这个顺序逐个更新。更新一个神经元时,计算它的局部场h_i = W[i] @ s,然后令s_i = sign(h_i)(h_i为0时保持原值,避免抖动)。每更新一个神经元就记录一次能量,这样能量曲线是逐步下降的阶梯,能清楚看到每一步的贡献。

def energy(W, s): return -0.5 * s @ W @ s def async_update(W, s, max_sweeps=20): N = len(s) s = s.copy() energies = [energy(W, s)] for sweep in range(max_sweeps): order = np.random.permutation(N) changed = False for i in order: h = W[i] @ s new_s = 1.0 if h > 0 else (-1.0 if h < 0 else s[i]) if new_s != s[i]: s[i] = new_s changed = True energies.append(energy(W, s)) if not changed: break return s, energies

这里有个性能上的小优化:W[i] @ s每次都是O(N),一个扫描周期是O(N²),对于N=100、几十个扫描周期来说完全够用,不需要向量化。如果你要跑大N的批量实验,可以把局部场整体算成W @ s再逐个取,但那样就失去了"异步"的语义(因为整体算的时候用的是旧状态),所以老老实实逐个算。max_sweeps设20是经验值,实测大部分情况5到10个扫描周期就收敛了,设大一点是防止个别情况卡住。

4.4 噪声注入与恢复测试

测试纠错能力时,我随机翻转输入向量的若干位,翻转比例从0%扫到50%,每个比例跑多次取平均恢复率。恢复率的定义是:收敛后的状态和最近的那个存储模式的汉明距离是否为0(完全恢复),或者汉明距离小于某个阈值(部分恢复)。我两个指标都记录,因为完全恢复率在噪声大时会掉得很快,部分恢复率更能反映"趋势正确"。

def add_noise(pattern, flip_ratio, rng): s = pattern.copy() n_flip = int(len(s) * flip_ratio) idx = rng.choice(len(s), n_flip, replace=False) s[idx] *= -1 return s def recovery_rate(W, patterns, flip_ratio, trials=50, rng=None): rng = rng or np.random.default_rng(0) full, partial = 0, 0 for _ in range(trials): p = patterns[rng.integers(len(patterns))] noisy = add_noise(p, flip_ratio, rng) recovered, _ = async_update(W, noisy) dists = [np.sum(recovered != q) for q in patterns] d = min(dists) if d == 0: full += 1 if d <= len(p) * 0.1: partial += 1 return full / trials, partial / trials

实测结果:翻转比例在10%以内时,完全恢复率接近100%;20%左右开始下降;30%以上基本靠运气。这个拐点和文献里的经验值一致。注意每次试验要重新随机选模式、重新加噪声,不能复用同一个噪声样本,否则统计有偏。

4.5 容量扫描实验

固定N=100,P从1扫到30,每个P生成P个随机模式(随机±1),构建权重,然后对每个模式加10%噪声测试恢复,记录完全恢复率。为了减少随机性,每个P重复5次取平均。结果画出来是一条先平后陡降的曲线,拐点在13到15之间,和0.138N吻合。

def capacity_scan(N=100, P_max=30, noise=0.1, repeats=5, rng=None): rng = rng or np.random.default_rng(42) results = [] for P in range(1, P_max + 1): rates = [] for _ in range(repeats): pats = rng.choice([-1.0, 1.0], size=(P, N)) W = build_weights(pats) r, _ = recovery_rate(W, pats, noise, trials=20, rng=rng) rates.append(r) results.append((P, np.mean(rates))) return results

这个实验跑起来大概几十秒,非常值得亲手做。你会亲眼看到"存得越多、每个记得越差"的权衡曲线,这比任何文字描述都有说服力。有个细节:随机模式之间近似正交,所以实测容量会比手工构造的相似模式高一些;如果你用相似度高的模式(比如几个只差一两格的字母),拐点会提前,这也是为什么实际应用里要尽量让存储的模式彼此差异大。

5. 常见问题与排查技巧实录

5.1 网络不收敛或来回振荡

最常见的原因是用了同步更新。同步更新下能量不保证单调,状态可能在两个构型间跳。解决办法就是改成异步,按随机排列逐个更新。第二个可能原因是忘了置零对角线,自连接破坏了能量单调性。第三个是权重不对称——如果你手动改过W或者用了非对称的学习规则,收敛保证就失效了。排查顺序:先看更新方式,再看对角线,最后检查W是否严格对称(np.allclose(W, W.T))。

5.2 恢复率异常低,几乎不工作

先检查模式向量是不是±1。如果混入了0或者用了0/1,外积语义就错了。其次检查归一化因子,虽然它不影响吸引子位置,但如果忘了除N,局部场量级过大,sign判定虽然还对,但能量数值会很难看,容易误导调试。第三,检查测试时用的模式是不是就是训练时存的那批——我见过把训练模式和测试模式搞混的,那当然恢复不了。最后,如果P已经超过容量,恢复率低是正常的,不是bug。

5.3 收敛到一个"四不像"的状态

这是伪吸引子,尤其是混合态。判断方法:算收敛状态和所有存储模式的汉明距离,如果都不为0但都比较小(比如各差几个位),基本就是混合态。缓解办法:减少存储模式数量、增大模式间差异、或者在输出后加一层"最近邻校验"(把结果和存储模式比对,取最近的)。如果业务上不能容忍这种错误,Hopfield可能不是合适的选择,要考虑更精确的检索结构。

5.4 能量曲线不是单调下降

理论上异步更新能量单调不增,如果你看到上升,一定是实现有问题。检查点:能量函数里的1/2系数有没有漏(漏了不影响单调性但数值翻倍);更新时是不是用了更新后的状态去算能量(应该用,这样才对应真实的能量变化);有没有在更新循环外手动改状态。我建议在每次更新后断言new_energy <= old_energy + 1e-9,一旦违反立刻报错,能快速定位问题。

5.5 常见问题速查表

现象最可能原因排查动作解决方式
状态来回振荡同步更新检查更新循环改为随机排列异步更新
完全不响应输入对角线未置零打印W对角线np.fill_diagonal(W,0)
恢复率极低模式非±1检查数据构造用np.where转±1
收敛到混合态伪吸引子算与各模式汉明距离减模式数或加最近邻校验
能量上升实现错误加断言监控检查1/2系数与更新顺序
容量远低于0.138N模式太相似算模式间相关增大模式差异或减数量

5.6 几条踩坑心得

第一条,先把N设小。我一开始直接用20×20的图,N=400,跑起来慢不说,出问题还不好可视化。后来退回5×5,每个状态能打印成小方阵,一眼看出哪里错了,效率高得多。小规模验证通了再放大,这是复现任何模型的通用策略。

第二条,能量曲线是最好的调试工具。它把"网络内部发生了什么"压缩成一条线,单调下降说明动力学正常,震荡说明更新方式有问题,长时间不降说明卡在某个平坦区域。养成画能量曲线的习惯,比打印一堆状态有用。

第三条,随机种子要固定。异步更新的随机排列、噪声注入、模式生成都涉及随机性,不固定种子的话,两次运行结果对不上,根本没法判断改动是否有效。我在所有实验里都显式传rng,保证可复现。

第四条,别拿它和大模型比。我见过有人问"Hopfield能不能做图像分类",能,但效果远不如一个简单的CNN。它的定位是理解吸引子动力学和能量模型的教学与原型工具,不是生产级方案。想清楚这一点,你就不会在错误的方向上浪费时间。

6. 从离散到连续:一个自然的扩展方向

跑完离散版之后,如果想再往前走一步,连续Hopfield网络是个平滑的过渡。把状态从±1换成实数,激活函数用tanh,能量函数相应变成带积分的形式,异步更新换成常微分方程的梯度流。核心直觉不变——还是在能量地形上往下走,只是路径变成连续的。这个扩展能让你把Hopfield和后来的连续能量模型、甚至某些形式的注意力(把注意力权重看成一种软性的模式匹配)联系起来。我个人的体会是,离散版帮你建立"记忆即吸引子"的骨架,连续版帮你理解"软匹配"和"梯度动力学"怎么接上去,两者配合着看,对整个能量基模型家族的理解会立体很多。

代码上改动不大:把sign换成np.tanh(beta * h),beta控制陡峭程度,beta很大时退化为离散版;更新改成s = s + dt * (-s + tanh(beta * W @ s))这样的迭代。实验上可以观察beta从大到小时,吸引子从"硬"变"软"、恢复从"全有全无"变成"渐变"的过程,很有意思。这部分我留作练习,不展开代码,因为主体目标已经达成——把离散Hopfield的每一个关键环节都落到可运行、可验证的代码上。

最后分享一个我实际用下来的小技巧:如果你要给别人讲这个模型,别一上来就推能量函数,先跑一个"存两个字母、加噪声、看它恢复"的demo,让观众亲眼看到残缺的图被补全,再回头讲为什么能补全。先有现象,再有理论,理解成本会低很多。这个顺序我自己试过好几次,效果比反过来讲好得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进