1. 从“为什么要翻旧账”开始:波尔兹曼机到底想解决什么问题
说实话,波尔兹曼机(Boltzmann Machine)是我在深度学习里学得最“拧巴”的一块。它没有卷积网络那种空间上的直觉,没有RNN那种时间上的顺序感,上来就是一团无向图、一堆二值神经元,再配一个从统计力学里借来的分布。当初我硬啃了好几轮,才把那条逻辑链捋顺:为什么能量函数能定义概率分布、为什么直接训练不现实、为什么“受限”两个字救了整个方向。写这篇笔记,就是想把这条链完整记录下来。
1.1 波尔兹曼机的出身背景
波尔兹曼机由Hinton和Sejnowski在1985年前后提出,背景是神经网络第一次寒冬刚缓和、大家都还在摸索“到底什么样的网络能学习”的阶段。它的核心思想不是模仿生物神经元的前馈传导,而是把网络看成一组随机变量,让它们通过“状态之间的相互作用”来模拟数据分布。名字里的“波尔兹曼”来自统计力学中的玻尔兹曼分布,这个分布描述了大量微观粒子在不同能量状态上的出现概率,Hinton把它搬到了神经网络里:神经元的各个激活状态组合也有自己的“能量”,系统倾向于停留在低能量状态。
在当时的理论框架下,这是一个很自然的生成式建模思路。但也是因为太“自然”了,它的训练计算量在当时几乎没有可行性。所以我在笔记里给它的定位是:一个思想价值远大于实用价值的模型,它是后来受限波尔兹曼机、深度信念网络、乃至现代能量模型的理论源头。
1.2 它不是分类器,是生成模型
学分类网络学多了之后第一次看波尔兹曼机,很容易犯一个错误:拿“输入一堆特征,输出一个标签”的思维去套它。但波尔兹曼机不存在“标签”这个概念。它要做的事情只有一件:学习训练数据背后的概率分布,然后能从分布中采样出与训练数据相似的新样本。
举个例子,如果训练数据是人脸图片,波尔兹曼机学到的分布会让“像人脸的状态组合”有更高的概率,之后从模型中采样出来的状态组合就更可能呈现人脸的结构。这种“先学分布,再生成样本”的路径,和我们熟悉的判别式模型(输入到输出的映射)是完全不同的哲学。理解这一步,才能理解它内部的所有设计选择。
1.3 这篇笔记适合谁看
我写这篇笔记时已经具备了一些深度学习和概率论基础,但坦白说,刚接触时还是被能量函数和配分函数绕晕过。所以这篇笔记适合三类人看:学过神经网络基础、但被概率图模型卡住的;想看明白RBM和DBN到底怎么回事的;以及对生成模型、能量模型的发展脉络感兴趣的朋友。如果你只是想快速跑一个RBM做实验,可以直接跳到第6章,但前面几章能解释清楚“为什么代码要那样写”。
2. 能量函数与玻尔兹曼分布:把“概率”变成“山坡上的小球”
波尔兹曼机最核心的思想,是用能量函数给每一个状态打分,再用玻尔兹曼分布把分数转换成概率。这一章我按自己理解的方式拆开讲。
2.1 无向图节点如何定义状态集合
先看网络结构。波尔兹曼机是一个无向图模型,节点代表神经元,边代表神经元之间的连接权重。每个节点是一个二值随机变量,取值0或1(严格说可以用更一般的分布,经典形式是二值的)。所有节点的一种取值组合,就是一个“状态”,状态集合的大小是2的节点数次方。这里面没有前馈网络那种层与层之间的“数据流向”,信息在节点之间是对称传播的,所以叫无向。
我当时画了一张2节点的图:节点A和节点B之间有一条无向边,边上的权重是w。如果把节点扩大到n个,全连接的结构就是波尔兹曼机的完整形态。注意“全连接”这个细节很重要,它意味着节点之间两两都有权重,没有可见层和隐藏层内部的隔离,这也直接导致了后文训练时的可怕复杂度。
2.2 能量函数长什么样,负号怎么理解
能量函数的定义是:
E(x) = -∑ᵢ bᵢxᵢ - ∑_{i<j} wᵢⱼxᵢxⱼ
其中xᵢ是节点i的取值(0或1),bᵢ是偏置,wᵢⱼ是节点i和j之间的连接权重。看到这个公式可能会觉得奇怪:为什么前面有负号?好好想一想就能明白:我们希望“高概率的状态”对应“低能量”,低能量就要求尽量激活那些偏置为正的节点、尽量满足那些权重为正的节点配对。负号在这里起的是一个方向转换的作用——把“越符合偏好”映射到“能量越低”。
生活中可以这样类比:你背着一个登山包在山坡上,重力势能就是能量,你处在坑底时势能最低,也最稳定。系统里各个节点的状态就是各种各样的位置,能量函数描述了每个位置的“高低”,低能量状态就是坑底,其他状态就是山坡上。系统不会固定在某一点,而是在不同状态之间跳来跳去,但更喜欢待在坑底附近。
2.3 从能量到概率只差一个配分函数
有了能量函数之后,玻尔兹曼分布给出每个状态的概率:
p(x) = (1/Z) e^{-E(x)/T}
其中T是温度参数,经典情况下设为1。Z是配分函数,它的定义是对所有可能状态求和:
Z = ∑ₓ e^{-E(x)}
把前面的能量代入,得到的具体状态概率就是“该状态能量越低,指数越大,概率越高”。如果没有那个Z做归一化,所有的计算都只是相对值,无法成为一个真正的概率分布。理解配分函数是理解波尔兹曼机训练难点的第一把钥匙,因为它涉及对所有状态的计算,而状态的数目是呈指数级增长的。
2.4 随机神经元:这个东西是真随机还是假随机
波尔兹曼机的神经元和前馈网络里的神经元有一个本质差别:它不是输出确定性的非线性变换结果,而是按照概率随机取0或1。在给定其他节点状态时,某个节点取1的条件概率是:
p(xᵢ = 1 | x_{-i}) = sigmoid( (∑_{j≠i} wᵢⱼxⱼ + bᵢ) / T )
看到这个公式有没有觉得熟悉?它就是我们熟悉的逻辑斯蒂函数,只不过输入从“上层输出加权和”变成了“邻居节点的加权和加上自己的偏置”。也就是说,一个节点的激活概率取决于它的所有邻居状态,而它一旦按这个概率随机抽到1或0,又会反过来影响邻居的状态。整个网络就是在这样的反复“商量”中,逐步进入一种平衡状态。所谓随机,不是没有规律,而是把确定性的加权和变成了采样概率,给系统注入了探索随机性,这个设计在采样和训练阶段都是关键。
3. 训练卡脖子的原因:配分函数与模型项期望
了解完能量和分布,下一个绕不过去的问题是:怎么训练?这里我先记录最经典的推导,然后说清楚它到底卡在哪。
3.1 从最大似然开始推导梯度
训练目标是最大化训练数据的对数似然。对于单个可见样本v,在全连接波尔兹曼机里,先引入隐藏变量h,计算:
ln p(v) = ln ∑ₕ e^{-E(v,h)} - ln Z
对参数θ(比如某个权重wᵢⱼ)求导,得到:
∂ ln p(v) / ∂θ = E_{p(h|v)} [ -∂E(v,h) / ∂θ ] - E_{p(v,h)} [ -∂E(v,h) / ∂θ ]
这个式子经常让我看得头晕,但拆开其实很清晰:第一项是“给定可见数据时,隐藏变量条件分布下的能量梯度期望”,第二项是“模型联合分布下能量梯度的期望”。第一项对应让数据状态的能量越低越好,第二项对应让模型生成的任意状态的平均能量不要无限低下去。两者拉扯之下,模型既不会“只认识训练集”,又会让训练集状态的概率尽量大。
3.2 数据项容易,模型项要命
问题马上就来了:第一项还算好算,给定数据,我们可以对隐藏变量做条件采样;但第二项涉及模型自身的联合分布p(v,h),这个分布正是我们要学的东西,在训练初期它完全看不出什么形状,想靠它采样来计算期望,就需要马尔可夫链蒙特卡洛(MCMC)方法。
MCMC的基本思路是:根据当前权重设定一个随机游走过程,在状态空间里不断跳转,等链子“烧起来”之后,用采集到的样本近似估计分布期望。思路没错,但实际操作起来有一个致命的硬伤:全连接波尔兹曼机的条件概率计算需要遍历所有节点,而且网络的状态空间是2的n次方,链子的收敛时间长得让人怀疑人生。稍微大一点的网络,跑一个epoch可能要等上几天。
我训练时用过一个只有20个节点的玩具波尔兹曼机,链子的混合速度已经慢得明显,老老实实收敛更是遥遥无期。这也是为什么那个年代的论文里,实验一般只做极小规模的问题,比如经典的“二分量”演示。
3.3 MCMC为什么救不了场
有人会问:既然理论上MCMC能算出期望,那多采几步不就行了?问题在于两点:第一,MCMC的燃烧期(burn-in)极长,你永远不知道从哪个初始状态出发才算达到平衡分布;第二,相邻采样之间存在强自相关,你要获得足够多的有效样本,实际需要的时间是理论时间的好多倍。更麻烦的是,梯度更新的每一步都需要重新做一次采样,也就是说每更新一个权重,就要跑一遍耗时的MCMC。这套流程放到当年的计算机上,基本就是把“能训练”和“能实际用起来”彻底割裂了。
3.4 模拟退火:给采样过程加个“冷却机制”
在正式聊解决方案之前,我还想提一个和采样相关的经典技巧——模拟退火。原理是把温度T做文章:高温时系统状态混乱,各个状态的概率差别被抹平,采样更容易在状态空间里“乱窜”;低温时系统倾向于停留在低能量状态,类似物理里的退火过程。实际做法是先在高温下随机走一段时间,再逐步降温,最后得到的状态往往对应一个不错的低能量状态。
这个方法在组合优化里很有用,放在波尔兹曼机里则可以用来帮助MCMC更快地进入合理区域。不过等后面RBM引入对比散度算法后,我就不再依赖模拟退火了,至少在经典RBM训练里很少看到它的身影。但了解它的存在,能帮助理解玻尔兹曼分布中温度参数“为什么会在某些变体里被重新引入”。
4. RBM和对比散度:留一条能走通的路
全连接波尔兹曼机的训练难度太大了,所以才有受限波尔兹曼机(Restricted Boltzmann Machine,RBM)的出现。这一章的标题完全可以叫“限制就是解放”。
4.1 受限结构解决了什么问题
RBM的结构就是两层:可见层和隐藏层。可见层之间没有连接,隐藏层之间也没有连接,所有连接都只在可见层和隐藏层之间。这个“层内无连接”的小小改动,直接让条件概率计算从“遍历所有邻居”变成了“只需要考虑另一层的状态”。
为什么这么神奇?因为在全连接结构里,节点的条件概率依赖所有其他节点,这本身没问题。问题出在联合分布的采样和期望计算上,层内连接会让MCMC的吉布斯采样每一步都变得极其复杂,而且隐藏单元之间互相拖累,采样收敛极慢。RBM把层内连接全部去掉之后,吉布斯采样变成了一步“按可见层更新所有隐藏单元,再按隐藏层更新所有可见单元”的交替过程,计算量一下子从指数级降到多项式级。
4.2 条件概率为什么突然变得好算
在RBM中,每个隐藏单元hⱼ的激活概率只依赖当前可见层的状态v。公式是:
p(hⱼ = 1 | v) = sigmoid(∑ᵢ wᵢⱼvᵢ + cⱼ)
这里没有其他隐藏单元的干扰,因为隐藏层内部没有连接。反过来,可见单元vᵢ的激活概率只依赖当前隐藏层的状态h:
p(vᵢ = 1 | h) = sigmoid(∑ⱼ wᵢⱼhⱼ + bᵢ)
这正是RBM高效的核心。有了这两个条件概率,吉布斯采样就变成一个清洗明快的循环:给定v,并行采样所有h;给定h,并行采样所有v。每轮交替就能得到一组新样本,这是后面一切实用算法的基础。
4.3 CD-k的直觉:一步重构为什么够用
有了高效的吉布斯采样,Hinton等人又提出了对比散度算法(Contrastive Divergence,CD-k)。这个算法的核心是:训练时不需要等MCMC完全收敛,只做k步吉布斯采样就用来估计模型项期望。最夸张也最常用的做法是k=1,也就是只做一步采样。
第一次看这个做法,我的第一反应是“这也能行?”。一步采样得到的数据和真正的模型分布差得远了。但Hinton在论文和讲座里反复强调,不要在“剪枝”的角度理解CD,它的梯度方向虽然是有偏的,但“这个偏差在实际中往往能让训练收敛到一个不错位置”。更直白的理解是:在权重更新不大步长很小的情况下,模型分布每轮变化也比较小,自然数据就被一拨一拨地推着往数据分布靠拢,几万步之后,错误方向的偏差会被大量正确的方向抵消掉。
后来我实际跑实验也确认了这个结论:CD-1在很多任务上已经够用,CD-5甚至更多步可以提高一点精度,但训练时间成倍增加,除非任务特别敏感,否则性价比不高。
4.4 权重更新的完整形式
把CD-1写成完整的参数更新规则就是:
Δwᵢⱼ = η (⟨vᵢhⱼ⟩_data - ⟨vᵢhⱼ⟩_recon)
Δbᵢ = η (⟨vᵢ⟩_data - ⟨vᵢ⟩_recon)
Δcⱼ = η (⟨hⱼ⟩_data - ⟨hⱼ⟩_recon)
其中⟨·⟩_data表示训练数据下的期望(即把真实数据作为可见层状态,采样隐藏层),⟨·⟩_recon表示从一步吉布斯采样得到的“重构”状态下的期望。这个形式和PNN或者逻辑回归的梯度更新很像:正项来自数据,负项来自模型一步重构,抵消一部分之后,权重就朝让数据状态概率更大的方向前进一点。
我在笔记里特别标记了一个地方:RBM的输入可以不止二值。经典推导假设v和h都是二值,但实际中可以给可见层换成高斯分布来处理实值像素。那样条件概率的公式会变成高斯单元的均值表达式,本质上还是用线性加权和去预测可见层值。理解经典二值版本之后,再看高斯版本很轻松。
5. 手推一个2节点波尔兹曼机:把公式落实到纸面上
理论扯了一堆,我决定用一个最小例子把所有公式过一遍。只有自己手推过一次,那些符号才不会继续飘在半空。
5.1 定义这个最小系统的参数
假设一个只有两个可见节点的波尔兹曼机,没有隐藏节点。两个节点分别是v₁和v₂,偏置是b₁和b₂,它们之间的权重是w。写出能量函数:
E(v₁, v₂) = -b₁v₁ - b₂v₂ - w v₁v₂
为了演算方便,取一组随机数字:b₁ = 0.2,b₂ = -0.3,w = 0.5。然后一个个代入四种可能状态。
5.2 四种状态的能量与概率分布
四种状态分别是00、01、10、11。能量分别为:
- E(00) = 0
- E(01) = -b₂ = 0.3
- E(10) = -b₁ = -0.2
- E(11) = -b₁ - b₂ - w = -0.2 + 0.3 - 0.5 = -0.4
注意能量越低的概率越高。把这些代入玻尔兹曼分布,先求配分函数Z:
Z = e^{-E(00)} + e^{-E(01)} + e^{-E(10)} + e^{-E(11)} = e⁰ + e^{-0.3} + e^{0.2} + e^{0.4} ≈ 1 + 0.7408 + 1.2214 + 1.4918 ≈ 4.4540
然后就能算每个状态的概率:
- p(00) = 1 / 4.454 ≈ 0.2245
- p(01) = 0.7408 / 4.454 ≈ 0.1663
- p(10) = 1.2214 / 4.454 ≈ 0.2742
- p(11) = 1.4918 / 4.454 ≈ 0.3349
看到没有,尽管b₂是负的,“11状态”仍然拿到了最高概率。为什么?因为w是正数,两个节点同时激活带来的合作效应,把能量压下去了。这就是正权重促进“组团激活”的最直观例子。
5.3 最大似然梯度告诉我们什么
现在假设训练数据中“11状态”出现得非常频繁,我们希望提高p(11)。梯度公式给出:
∂ ln p(11) / ∂w = 1 - p(11)
如果p(11)≈0.335,那么这个梯度约为0.665,为正,所以w会增大。w增大意味着什么?回到能量公式,w增大时,状态11的能量变得更低,其他状态的能量不变,于是配分函数里11那一项的占比变大,p(11)进一步上升。这正好符合“数据里经常出现11、模型就调整参数让11状态概率更高”的直觉。
对于偏置的梯度和上式类似,∂ln p(11)/∂b₁ = 1 - p(v₁ = 1)(另一个状态如果是1,则导数表达式相应变化)。数据里v₁=1出现得多,b₁就会上调,让单个节点的激活门槛降低。整个梯度推导过程就是“提高真实状态的能量优势,压低其他状态”,一句话总结完毕。
5.4 一个带数字的对照实验
为了验证推导没错,我写了几行Python验算了一下上面的数值例子。代码很简单,就是按定义计算能量、配分函数和概率。
import numpy as np def energy(v1, v2, b1, b2, w): return -b1 * v1 - b2 * v2 - w * v1 * v2 def prob(state, b1, b2, w): z = 0.0 probs = {} for s1 in [0, 1]: for s2 in [0, 1]: e = energy(s1, s2, b1, b2, w) z += np.exp(-e) for s1 in [0, 1]: for s2 in [0, 1]: e = energy(s1, s2, b1, b2, w) probs[(s1, s2)] = np.exp(-e) / z return probs b1, b2, w = 0.2, -0.3, 0.5 res = prob(None, b1, b2, w) for state, p in res.items(): print(state, round(p, 4))输出和手算的完全一致。这种“先手算,再代码验证”的方式,是我学习这类模型效率最高的方式。别看这个例子小,它把能量、配分函数、概率、梯度的所有概念都落地了一遍;后面再去推RBM的更新公式,就有个扎实的底子。
6. 用PyTorch写一个极简RBM:代码验证与踩坑记录
笔记的最后一部分,是我在完成理论学习后写的实践验证。我用PyTorch实现了一个极简的RBM,用MNIST跑了一遍,整个过程踩了不少坑,记录下来应该对大家有直接帮助。
6.1 代码结构:一个极简RBM类
代码不需要多复杂,就一个类,包含权重和两个偏置。我定义了前向的“采样”和“重构”接口:
import torch import torch.nn as nn class RBM(nn.Module): def __init__(self, n_visible, n_hidden): super().__init__() self.W = nn.Parameter(torch.randn(n_hidden, n_visible) * 0.01) self.b = nn.Parameter(torch.zeros(n_visible)) self.c = nn.Parameter(torch.zeros(n_hidden)) def sample_h(self, v): p_h_given_v = torch.sigmoid(torch.mm(v, self.W.t()) + self.c) h = torch.bernoulli(p_h_given_v) return p_h_given_v, h def sample_v(self, h): p_v_given_h = torch.sigmoid(torch.mm(h, self.W) + self.b) v = torch.bernoulli(p_v_given_h) return p_v_given_h, v def encode(self, v): p_h_given_v = torch.sigmoid(torch.mm(v, self.W.t()) + self.c) return p_h_given_v注意初始化时我把权重设成了均值为0、标准差0.01的小随机数,这是第一个关键点。我试过直接用默认的1.0标准差初始化,结果预训练出来的特征全是黑色噪点,完全学不进去,调小之后就正常了。
6.2 训练主循环:CD-1
训练主循环用CD-1,代码非常短:
def train_rbm(model, train_loader, lr=0.01, epochs=10, momentum=0.9): optimizer = torch.optim.SGD(model.parameters(), lr=lr, momentum=momentum) for epoch in range(epochs): epoch_loss = 0 for batch in train_loader: v0, _ = batch v0 = v0.view(v0.size(0), -1) # 数据项:给定输入采样隐藏层 _, h0 = model.sample_h(v0) # 模型项:一步吉布斯采样 _, v1 = model.sample_v(h0) _, h1 = model.sample_h(v1) # 计算更新需要的统计量 pos = torch.mm(h0.t(), v0) neg = torch.mm(h1.t(), v1) loss = (torch.sum(pos - neg) / v0.size(0)) # 只是近似来观察趋势 optimizer.zero_grad() # 用手动梯度更新权重 with torch.no_grad(): model.W.grad = (pos - neg) / v0.size(0) model.b.grad = (torch.sum(v0 - v1, dim=0)) / v0.size(0) model.c.grad = (torch.sum(h0 - h1, dim=0)) / v0.size(0) optimizer.step() epoch_loss += loss.item() print(f"epoch {epoch}, approx loss {epoch_loss / len(train_loader)}")这里我用了手动梯度赋值,而不是PyTorch自动求导。原因很简单:RBM的能量函数对参数有直接的解析梯度,不需要反向传播,自动求导反而会把采样操作的随机性也纳入梯度,反而麻烦。
6.3 我踩过的初始化、学习率和采样步数的坑
第一个坑是学习率。RBM对学习率极其敏感,我一开始用0.1,损失直接发散。后来逐步调小,发现0.01到0.005之间比较稳。如果学习率再大,权重会瞬间冲出小数值区域,之后怎么都回不来。
第二个坑是动量。给SGD加momentum=0.9之后,训练稳定性和收敛速度都有明显提升。这个和普通神经网络的经验差不多,但在RBM上体现得更明显,可能是因为采样本身带随机性,动量能平滑掉一部分噪声。
第三个坑是采样步数。CD-1在MNIST上效果还行,但直接看重构结果会发现细节模糊。我试过CD-5,重构边缘清晰了一些,但训练时间涨了不到一倍,收益不算特别大。真正让重构效果更稳定的是把输入先转成伯努利样本,而不是直接把像素值当作二值输入;这是因为MNIST像素不是严格的0/1,连续值会让RBM的采样经常跑到非数据区。
还有一个细节是权重衰减。给权重加一点L2正则能防止隐藏单元数量过多时的过拟合。我实际测试过128个隐藏单元的时候不加重构差别不大,但加到512隐藏单元时明显出现过拟合迹象,重构出来的图像出现了一些“记忆”中的噪声模式。
6.4 结果长什么样:从重构效果说起
训练几个epoch之后,我拿一批测试图片做了重构实验。大致效果是:整体轮廓能重构出来,数字的粗大结构在,但边缘和细节比较模糊。这和RBM本身的建模能力有关——它能把低阶统计关系学得很好,但不太擅长高阶结构,所以重构图像像是“看着手写数字画了一幅素描”。
作为学习笔记,我觉得这个结果已经达到了验证目的:RBM确实能从数据里学出有用的特征表示,预训练得到的隐藏层特征如果再接一个分类器,哪怕不微调,分类准确率也能跑到80%以上。这说明CD算法和玻尔兹曼分布的理论推导没有骗人。
回头再看整个波尔兹曼机家族,从全连接版本到RBM到DBN,我最大的感触是:限制条件有时候是朋友。全连接BM理论上很强大,但因为训练难而寸步难行;RBM去掉层内连接,反而在生成式预训练史上留下了深刻烙印。后来出现的深度信念网络、深度玻尔兹曼机,乃至现在很多能量模型和对比学习方法,都能看到这套“能量函数加采样训练”的影子。学模型不要只盯着最新的网络结构,回去翻翻这些老零件的原理解析,很多现代发明的灵感其实都写在二三十年前的论文里了。