简介:面向机器学习初学者与深度学习研究者的受限玻尔兹曼机(RBM)MATLAB实现包,聚焦于无监督特征学习与样本生成。项目基于标准RBM训练流程,提供完整可运行代码,包括trainRBM.m主训练脚本、RBM.m核心模型、gibbs.m对比散度采样、sigm.m激活函数等,并内置RBM100、RBM200、RBM500等多组预训练参数以及K1至K20不同隐藏层规模的模型,便于直接加载和验证。压缩包共29个文件,以7个m源码、8个mat权重数据、10个jpg可视化结果为主,另外包含说明文档与手写数字训练/验证数据集,整体大小8.86MB。资源已吸引约600人学习下载,适合在科研实验、课程作业或企业项目中快速搭建RBM基线模型。通过训练损失曲线、权重图与Gibbs采样效果图,可以直观掌握受限玻尔兹曼机的收敛过程与生成能力。
1. 受限玻尔兹曼机(RBM)的 MATLAB 复现:能不能跑通,卡点在哪
受限玻尔兹曼机(RBM)在 MATLAB 里能不能快速跑通,我见过太多人卡在同一个地方:对比散度采样那三层代码。RBM 结构非常简单,只有一层可见层 v 和一层隐藏层 h,层内无连接,但它解决的是一个典型的无监督问题——从一批没有标签的图像(比如 MNIST 手写数字)里学出能重建原始输入的特征。对工程来说,它最大的价值是给下游分类器或深度网络做预训练,把 784 维的像素空间压缩成几百维的鲁棒表达。
这篇分享不推理论黑匣子,直接给你一套能跑的 MATLAB 实现,从能量模型、CD-k 训练、参数调优到常见问题排查,照着走一遍,就能把模型用在图像特征提取的任务上。适合正在 MATLAB 里做图像处理、神经网络数字识别,或者想用最小代价验证 RBM 公式的人。写代码时你会发现,RBM 的难点不在模型本身,而在采样细节、超参配合和数据预处理这三件事。
2. 能量模型与 CD-k 原理:RBM 在训练时到底在算什么
2.1 能量函数与两个条件概率:一切梯度的来源
RBM 是一个无向图模型,可见层有 nVis 个节点,隐藏层有 nHid 个节点,权重矩阵 W 的维度是 nVis×nHid。可见层偏置是 a,隐藏层偏置是 b。它定义了一个能量函数:
E(v,h) = -v'Wh - a'v - b'h
对应的联合概率是 P(v,h) = exp(-E) / Z,其中 Z 是配分函数,要对所有可能的 v、h 组合求和。Z 在真实场景中几乎不可计算,这正是 RBM 训练绕不开 CD-k(对比散度)的原因——我们没法直接求精确梯度,只能用采样去近似。
推导条件概率时有个关键性质:因为层内无连接,给定某一层时,另一层的节点是条件独立的。于是 P(h_j=1|v) = sigmoid(Σ_i w_ij v_i + b_j),P(v_i=1|h) = sigmoid(Σ_j w_ij h_j + a_i)。这里的 sigmoid 就是 1/(1+exp(-x))。我一般会在代码里手写这个函数,而不是依赖 MATLAB 信号处理工具箱的 sigmf——手写只依赖基本运算符,换机器跑不容易出环境问题。
再解释 CD-k 的本质。标准最大似然梯度里包含正相和负相,负相要对模型分布采样,代价太高。对比散度的思路是用一步或 k 步吉布斯采样去近似负相。实际工程里,k=1 的效果在多数数据集上已经够用。为什么?因为一步重构虽然噪声大,但方向基本正确,训练初期梯度方差大反而有助于跳出局部结构;后期靠学习率衰减和动量可以把噪声压下来。这个取舍到第 4 章还会展开。
2.2 用 MATLAB OOP 封装一个最小 RBM 类
在 MATLAB 里写 RBM,我建议直接用类封装。嵌套函数传参的方式容易让 W、a、b 在不同函数间漏传,类的好处是状态明确,训练完直接拿对象里的 W 做可视化或继续堆叠。这里用 handle 类而不是 value 类,因为 handle 类在函数里修改属性是原对象生效,不需要 return 一整包属性回去,这是 MATLAB OOP 架构里比较实用的一条经验。
classdef RBM < handle properties W % nVis x nHid 权重矩阵 a % 1 x nVis 可见层偏置 b % 1 x nHid 隐藏层偏置 nVis nHid end methods function obj = RBM(nVis, nHid) obj.nVis = nVis; obj.nHid = nHid; % 权重初始化用 0.1 * randn,偏置从零开始 obj.W = 0.1 * randn(nVis, nHid); obj.a = zeros(1, nVis); obj.b = zeros(1, nHid); end function p = probH(obj, v) % 输入 v: m x nVis,输出隐藏层激活概率 p = 1 ./ (1 + exp(-(v * obj.W + obj.b))); end function h = sampleH(obj, v) % 按概率采样隐藏层,与均匀随机数比较 p = obj.probH(v); h = double(p > rand(size(p))); end function p = probV(obj, h) % 输入 h: m x nHid,输出可见层激活概率 p = 1 ./ (1 + exp(-(h * obj.W' + obj.a))); end function v = sampleV(obj, h) p = obj.probV(h); v = double(p > rand(size(p))); end end end代码逻辑本身很简单,但有两个细节值得说。第一,sampleH 里用 p > rand(size(p)),而不是 round(p)。前者是真正按概率抽样的随机过程,后者把概率变成确定性阈值,会破坏吉布斯采样的随机性。第二,probH 和 sampleV 分开,是因为训练时不总是需要采样:计算重构误差时用概率值更平滑,采样的离散值反而引入额外噪声。
初始化一个 784 输入、500 隐藏单元的 RBM,一行就够:rbm = RBM(784, 500)。后续不管预训练、可视化还是堆叠 DBN,都拿着这个对象操作。资源包里保留了完整的 RBM 类实现,并用 demo 脚本调用它完成 MNIST 训练演示。
3. 从数据到训练主循环:一份能跑的 MNIST 预训练脚本
3.1 数据归一化与二值化:Bernoulli 可见单元的硬性要求
RBM 最常见的是 Bernoulli 可见单元,它假设输入是 0/1 的二值向量。MNIST 原始数据是 0 到 255 的灰度值,直接用会偏离模型假设。常见做法有两种。
第一种是阈值二值化,像素值大于 128 记 1,否则记 0。这种方法干净,还原出来的图像有清晰墨迹感,对数字结构保留充分。第二种是随机二值化,生成一个与图像同形的均匀随机数矩阵,如果像素值/255 大于随机数则记 1,否则记 0。随机二值化把灰度信息保留在概率层面,但引入了额外噪声。我在实际项目里,后续要接分类器时用阈值二值化;只做重构可视化时,两种都行。
function binData = binarizeMNIST(data, method) % data: n x 784 的灰度数据, 取值 0~255 switch method case 'threshold' binData = double(data > 128); case 'stochastic' randMat = rand(size(data)); binData = double((data / 255) > randMat); otherwise error('未知的 binarize 方法'); end end预处理还有一个容易被忽略的环节:把数据随机打乱,并划分出固定验证集。训练时每个 batch 是打乱的,但验证集必须固定。如果不固定,每次计算的重构误差来自不同样本,曲线噪声会大到判断不了模型有没有收敛。我在训练前会按 8:2 切分训练集和验证集,验证集不参与任何权重更新。
3.2 CD-1 训练主循环:矢量化写法与动量更新
训练核心是对比散度。一次迭代里,先把可见层 v0 放进模型,算隐藏层激活概率 p_h0,采样得到 h0;再让 h0 反推可见层 p_v1,采样得到 v1;再用 v1 算隐藏层概率 p_h1。梯度就是正相与负相的差值,矩阵形式以下显示。
训练脚本必须矢量化,这是 MATLAB 代码性能的分水岭。逐样本 for 循环在几千样本、几百维输入下会慢到怀疑人生,而矩阵乘法可以一次算完整个 batch 的 sigmoid。这份资源里我给的是可直接跑的完整函数。
function trainRBM(rbm, trainData, valData, opts) % opts 字段: maxEpoch, batchSize, lr, momentum, weightDecay lr = opts.lr; mc = opts.momentum; wd = opts.weightDecay; nBatch = floor(size(trainData, 1) / opts.batchSize); % 动量缓冲,初始为 0 vW = zeros(size(rbm.W)); va = zeros(size(rbm.a)); vb = zeros(size(rbm.b)); valErr = zeros(opts.maxEpoch, 1); for epoch = 1:opts.maxEpoch idx = randperm(size(trainData, 1)); for b = 1:nBatch v0 = trainData(idx((b-1)*opts.batchSize+1 : b*opts.batchSize), :); % 正相:可见层 -> 隐藏层 p_h0 = 1 ./ (1 + exp(-(v0 * rbm.W + rbm.b))); h0 = double(p_h0 > rand(size(p_h0))); % 一步吉布斯采样:隐藏层 -> 可见层 -> 隐藏层 p_v1 = 1 ./ (1 + exp(-(h0 * rbm.W' + rbm.a))); v1 = double(p_v1 > rand(size(p_v1))); p_h1 = 1 ./ (1 + exp(-(v1 * rbm.W + rbm.b))); % 梯度:正相 - 负相 dW = (v0' * p_h0 - v1' * p_h1) / opts.batchSize - wd * rbm.W; da = mean(v0 - v1); db = mean(p_h0 - p_h1); % 动量更新 vW = mc * vW + lr * dW; va = mc * va + lr * da; vb = mc * vb + lr * db; rbm.W = rbm.W + vW; rbm.a = rbm.a + va; rbm.b = rbm.b + vb; end % 用固定验证集计算重构误差 p_v_all = 1 ./ (1 + exp(-(valData * rbm.W + rbm.b))); p_v_all = 1 ./ (1 + exp(-(p_v_all * rbm.W' + rbm.a))); valErr(epoch) = mean(sum((valData - p_v_all).^2, 2)); end end训练函数里几个选择说明一下。p_h0、p_h1 用概率而不是采样后的 0/1 值算梯度,这是 CD 的常规做法,能降低采样方差。v1 对二值数据用采样;如果可见单元是实值数据,则应把 v1 直接设为 p_v1,否则会丢掉灰度信息。da、db 用 mean 而不是 sum,把梯度归一化到单样本量级,这样学习率在不同 batchSize 下可以保持稳定,不会因为 batch 变大梯度随之变大。
3.3 超参取值表与迭代策略
RBM 的超参比普通神经网络更敏感,这里给一组常用范围和我实际调出来的默认值。
| 参数 | 常见范围 | 我的默认值 | 说明 |
|---|---|---|---|
| 学习率 lr | 0.001 ~ 0.1 | 0.01 | 太大会震荡,太小收敛慢;MNIST 上 0.01 起步较稳 |
| 动量 mc | 0.5 ~ 0.9 | 0.5(前 5 轮)→ 0.9 | 前期避免跑偏,后期加速收敛 |
| 权重衰减 wd | 1e-4 ~ 2e-4 | 1e-4 | 防止权重过大,配合权重直方图检查 |
| 隐藏单元数 | 100 ~ 1000 | 500 | 784 维输入配 500 隐藏单元是常用配置 |
| batchSize | 10 ~ 200 | 64 | 太大梯度平滑但慢,太小噪声大 |
| maxEpoch | 5 ~ 30 | 15 | 看验证集重构误差曲线决定是否早停 |
迭代策略上,先用 0.5 动量跑前 5 个 epoch,让模型确定主方向;之后切到 0.9 加速收敛。学习率衰减可以按 epoch 乘 0.99,或者到后期观察验证误差,连续两个 epoch 不降就早停。这套流程比较常规,但比固定超参硬跑到底少踩很多坑。
4. 监控与调参:重构误差、激活率和 CD-k 的细节
4.1 k=1 与 k=3 的取舍:什么时候值得增加采样步数
CD-k 里的 k 是吉布斯采样的步数。k=1 的优势是训练快,劣势是梯度噪声大;k=3 更接近模型分布,但每一步慢三倍。对 MNIST 这类相关性不强的图像数据,k=1 完全够用。但对文本单词向量或基因表达这类特征高度相关的数据,一步重构可能只学到局部的共现模式,这时增加到 k=3 会有肉眼可见的重构改进。
改成 CD-3 只需把“隐藏层→可见层→隐藏层”这段重复三遍,最后一步的 p_h1 参与梯度计算,中间采样全部用二值。我只会在小数据集上做对比实验时用 k=3,大数据集一律 k=1,因为三倍训练时间通常换不来三倍精度。
4.2 三个监控指标:比损失函数更可靠的信号
RBM 没有直接的损失函数,重构误差是最常用的代理指标。但算重构误差有几个习惯:必须在固定验证集上算,不能在 log 看到的 batch 上算;必须用 p_v 的连续概率而不是采样后的 v1 去算,否则指标自带噪声;误差绝对值只做相对参考,同一模型换初始化后数值会变化。
下面这段代码是我每次训练后必跑的检查脚本。
function inspectRBM(rbm, valData, hidProbs) % 1. 验证集重构误差,用连续概率 p_v = 1 ./ (1 + exp(-(valData * rbm.W + rbm.b))); p_v = 1 ./ (1 + exp(-(p_v * rbm.W' + rbm.a))); mseVal = mean(sum((valData - p_v).^2, 2)); fprintf('验证集重构误差: %.4f\n', mseVal); % 2. 隐藏单元激活率 act = mean(hidProbs, 1); % 每个隐藏单元的平均激活概率 lowAct = sum(act < 0.01); fprintf('激活率低于 0.01 的隐藏单元数: %d / %d\n', lowAct, rbm.nHid); % 3. 权重直方图 figure; histogram(rbm.W(:), 50); title('权重直方图'); end激活率的判断标准是我自己的经验:所有隐藏单元的平均激活率最好落在 0.01 到 0.5 之间。大量单元低于 0.01,说明它们从未被激活,这是死单元(dead unit);全部高于 0.5,说明隐藏层过于饱和,特征区分度下降。权重直方图应该大致对称地分布在 0 附近,如果出现极端大权重或明显的双峰分布,基本可以判断是学习率或权重衰减没配好。调参这事说玄学也玄学,但上面三个指标至少能把“模型没坏”和“模型学出东西了”区分开。
4.3 把监控变成早停策略
我建议训练过程中也跑这套检查,而不是等训练完再后悔。训练日志按以下频率记录:每个 epoch 记录验证集重构误差;每个 epoch 检查一次死单元数量;每 5 个 epoch 看一眼权重直方图。验证集重构误差连续 3 个 epoch 的下降幅度小于 0.5%,就停止训练。这样既避免过拟合,也省得盯着训练集上的噪声曲线瞎猜。
| 监控项 | 频率 | 触发动作 |
|---|---|---|
| 验证集重构误差 | 每个 epoch | 连续 3 次下降不足 0.5% 则早停 |
| 死单元比例 | 每个 epoch | 超过 20% 就调初始化或学习率 |
| 权重直方图 | 每 5 个 epoch | 出现极端分布则降学习率 |
训练结束时把三张图存成 PNG 留档。做对比实验时,有图有数据比凭记忆调参靠谱得多,也能拿去跟同事对齐问题。
5. RBM 常见问题排查:五个让我翻过车的坑
5.1 现象一:重构误差震荡不降,甚至越跑越高
验证集重构误差过完前几个 epoch 还在原地波动,先别动网络结构,检查三件事。第一,学习率太大,梯度的正负相抵消后更新步长仍然过大,参数在最优值附近来回跳动;第二,动量没有从 0.5 开始,一开始就开 0.9 容易把前期噪声方向累积下来;第三,数据没归一化,灰度值直接进 Bernoulli 可见单元,偏置和梯度的量纲全乱了。解决方法是把学习率降到 0.001、动量固定 0.5、确认输入都是 0/1,三者都改完再跑 5 个 epoch。如果曲线还不降,才需要考虑数据稀疏性一类数据集本身的问题。
5.2 现象二:隐藏单元集体失效,激活率趋近 0
训练完统计隐藏激活率,发现超过八成单元的平均激活概率小于 0.01。原因通常是权重初始化太大。randn(784,500)1.0 会让 sigmoid 输入落到饱和区,梯度趋近 0,单元被永久“冻住”;或者学习率初期太大,权重快速被推到极端值。解决方法是把初始化改成 0.1randn 或按层尺寸缩放的 Xavier 方案,学习率降到 0.001 试一轮。如果模型已经训废,建议重新初始化而不是继续调衰减,因为死单元没有恢复梯度的通道。
5.3 现象三:中文注释乱码,版本兼容问题
我手上有同事的代码在 MATLAB 2023a 打开后中文注释全变乱码,跑起来报错信息也是一堆问号。这是 2023a/2023b 默认编码从 GBK 切到 UTF-8 后最典型的兼容问题。解决方法是把 .m 文件另存为 UTF-8 编码,或者在 MATLAB 预设里调整语言编码设置。路径里有中文时还会出现找不到文件的怪问题,所以我所有脚本和数据集路径一律用英文,省去这类无谓排障。
5.4 现象四:训练慢到没法用,看起来卡死在 batch 循环
RBM 训练慢,八成不是 MATLAB 本身的问题,而是实现里出现了逐样本循环。有人会把可见层到隐藏层的计算写成 for i = 1:size(v,1) 一行行算 sigmoid。784 维乘几千样本的量级下,双重循环比矩阵乘法慢 50 倍以上。解决方法是把 v0*W+b 一次性算出来,再对整个矩阵做 sigmoid。另外,某些工具箱的 sigmf 函数会引入符号运算或额外校验,性能也差,手写 sigmoid 最稳。如果已经写了逐样本循环,改完矢量化后训练时间基本能缩短到原来的二十分之一。
5.5 现象五:重构误差很低,但下游分类精度上不去
重构误差低只说明模型能把输入还原出来,不代表隐藏特征对分类有利。RBM 是生成模型,优化的目标是数据分布而非类别边界。如果拿隐藏层输出直接训练逻辑回归时分类精度不理想,常见原因要么是特征里混杂了大量重建性信息而没有类别区分度,要么是隐藏层激活概率没经过标准化直接喂给了分类器。解决方法是先对隐藏激活做标准化,再做一步有监督微调——把 RBM 堆成 DBN 后对整体跑反向传播,这就是第 6 章要做的事。分类精度上不去的时候先检查模型定位是否合适,别再纠结超参。
6. 进阶:把 RBM 堆成 DBN 并用权重可视化验证
6.1 从单层到 DBN:一层特征不够,就再加一层
单层 RBM 学到的还是边缘、笔画这种初级特征。想得到更抽象的语义特征,标准路径是堆叠:第一个 RBM 在原始像素上训练完,把训练集的隐藏层激活概率算出来;把激活概率二值化后作为第二个 RBM 的可见层输入,再训练第二层。堆叠的好处是每一层的输入分布相对稳定,这比端到端直接训练深层网络更容易收敛,也是早期深度学习在 MNIST 上能跑出成绩的主要手段。
% 假设 rbm1 已经训练完成 hid1Prob = rbm1.probH(trainData); hid1Bin = double(hid1Prob > rand(size(hid1Prob))); rbm2 = RBM(rbm1.nHid, 300); opts = struct('maxEpoch', 10, 'batchSize', 64, 'lr', 0.01, ... 'momentum', 0.5, 'weightDecay', 1e-4); trainRBM(rbm2, hid1Bin, valHid1Bin, opts);这里有一个我自己踩过的细节:第二层的可见单元仍然按 Bernoulli 假设,所以输入必须再二值化。有人直接把连续的激活概率喂进去,虽然也能训练,但模型假设和输入分布不一致时,训练会更敏感。如果坚持用连续输入,就应该把第二层改成高斯可见单元,对应地去掉训练代码里采样 v1 的步骤,直接用概率值做重构,也就是第 3 章代码里预留的那个分支。
6.2 权重可视化:一眼看出模型到底学到了什么
训练 RBM 后最有说服力的验证不是数值指标,而是把权重画出来看。MNIST 输入是 28×28,权重矩阵 W 的每一列就是一个隐藏单元,把它 reshape 成 28×28 的灰度图。如果学到了特征,图像里会浮现数字的局部笔画或边缘方向;如果权重是一团噪点或全是同方向条纹,说明训练大概率失败了。第二层及更高层的权重也可以画,但输入是抽象激活值,reshape 后看不出直观语义,一般只可视化第一层。
figure; % 随机抽 100 个隐藏单元的权重列来展示 idx = randperm(rbm.nHid, min(100, rbm.nHid)); for i = 1:numel(idx) subplot(10, 10, i); imagesc(reshape(rbm.W(:, idx(i)), [28, 28])); colormap gray; axis off; end这个可视化脚本建议每个 epoch 结束后跑一次,观察权重从噪点逐渐长出笔画的过程。权重变化太剧烈说明学习率偏大,变化太慢说明学习率偏小——这个直观信号比任何监控曲线都更早提醒你调参方向。另一个排查技巧:把每个隐藏单元激活率最高的样本挑出来看,如果某个单元的激活样本全是同一个数字的变体,说明这个单元真的学到了语义特征,这算是第二个很实用的验证手段。
我后来每次训练 RBM 都强制自己走一遍固定流程:先固定验证集算重构误差,再查隐藏激活率分布,最后把第一层权重画出来看结构。三步全部通过,才认为模型可以拿去用,任何一步不对就回去调参数。这份资源里放了完整的 RBM 类、训练脚本、可视化脚本和 MNIST 预处理代码,拿到手先跑一遍 demo,再用第 5 章的排查清单对照自己碰到的现象,会省不少摸索时间。希望帮到你。
本文还有配套的精品资源,点击获取