
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ᵢ - ∑_{ij} wᵢⱼxᵢxⱼ其中xᵢ是节点i的取值0或1bᵢ是偏置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 MachineRBM的出现。这一章的标题完全可以叫“限制就是解放”。4.1 受限结构解决了什么问题RBM的结构就是两层可见层和隐藏层。可见层之间没有连接隐藏层之间也没有连接所有连接都只在可见层和隐藏层之间。这个“层内无连接”的小小改动直接让条件概率计算从“遍历所有邻居”变成了“只需要考虑另一层的状态”。为什么这么神奇因为在全连接结构里节点的条件概率依赖所有其他节点这本身没问题。问题出在联合分布的采样和期望计算上层内连接会让MCMC的吉布斯采样每一步都变得极其复杂而且隐藏单元之间互相拖累采样收敛极慢。RBM把层内连接全部去掉之后吉布斯采样变成了一步“按可见层更新所有隐藏单元再按隐藏层更新所有可见单元”的交替过程计算量一下子从指数级降到多项式级。4.2 条件概率为什么突然变得好算在RBM中每个隐藏单元hⱼ的激活概率只依赖当前可见层的状态v。公式是p(hⱼ 1 | v) sigmoid(∑ᵢ wᵢⱼvᵢ cⱼ)这里没有其他隐藏单元的干扰因为隐藏层内部没有连接。反过来可见单元vᵢ的激活概率只依赖当前隐藏层的状态hp(vᵢ 1 | h) sigmoid(∑ⱼ wᵢⱼhⱼ bᵢ)这正是RBM高效的核心。有了这两个条件概率吉布斯采样就变成一个清洗明快的循环给定v并行采样所有h给定h并行采样所有v。每轮交替就能得到一组新样本这是后面一切实用算法的基础。4.3 CD-k的直觉一步重构为什么够用有了高效的吉布斯采样Hinton等人又提出了对比散度算法Contrastive DivergenceCD-k。这个算法的核心是训练时不需要等MCMC完全收敛只做k步吉布斯采样就用来估计模型项期望。最夸张也最常用的做法是k1也就是只做一步采样。第一次看这个做法我的第一反应是“这也能行”。一步采样得到的数据和真正的模型分布差得远了。但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.2b₂ -0.3w 0.5。然后一个个代入四种可能状态。5.2 四种状态的能量与概率分布四种状态分别是00、01、10、11。能量分别为E(00) 0E(01) -b₂ 0.3E(10) -b₁ -0.2E(11) -b₁ - b₂ - w -0.2 0.3 - 0.5 -0.4注意能量越低的概率越高。把这些代入玻尔兹曼分布先求配分函数ZZ 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.2245p(01) 0.7408 / 4.454 ≈ 0.1663p(10) 1.2214 / 4.454 ≈ 0.2742p(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, lr0.01, epochs10, momentum0.9): optimizer torch.optim.SGD(model.parameters(), lrlr, momentummomentum) 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, dim0)) / v0.size(0) model.c.grad (torch.sum(h0 - h1, dim0)) / v0.size(0) optimizer.step() epoch_loss loss.item() print(fepoch {epoch}, approx loss {epoch_loss / len(train_loader)})这里我用了手动梯度赋值而不是PyTorch自动求导。原因很简单RBM的能量函数对参数有直接的解析梯度不需要反向传播自动求导反而会把采样操作的随机性也纳入梯度反而麻烦。6.3 我踩过的初始化、学习率和采样步数的坑第一个坑是学习率。RBM对学习率极其敏感我一开始用0.1损失直接发散。后来逐步调小发现0.01到0.005之间比较稳。如果学习率再大权重会瞬间冲出小数值区域之后怎么都回不来。第二个坑是动量。给SGD加momentum0.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去掉层内连接反而在生成式预训练史上留下了深刻烙印。后来出现的深度信念网络、深度玻尔兹曼机乃至现在很多能量模型和对比学习方法都能看到这套“能量函数加采样训练”的影子。学模型不要只盯着最新的网络结构回去翻翻这些老零件的原理解析很多现代发明的灵感其实都写在二三十年前的论文里了。