多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

MATLAB实现受限玻尔兹曼机:从能量模型到CD-k训练与调参

MATLAB实现受限玻尔兹曼机:从能量模型到CD-k训练与调参 简介面向机器学习初学者与深度学习研究者的受限玻尔兹曼机RBMMATLAB实现包聚焦于无监督特征学习与样本生成。项目基于标准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) -vWh - av - bh对应的联合概率是 P(v,h) exp(-E) / Z其中 Z 是配分函数要对所有可能的 v、h 组合求和。Z 在真实场景中几乎不可计算这正是 RBM 训练绕不开 CD-k对比散度的原因——我们没法直接求精确梯度只能用采样去近似。推导条件概率时有个关键性质因为层内无连接给定某一层时另一层的节点是条件独立的。于是 P(h_j1|v) sigmoid(Σ_i w_ij v_i b_j)P(v_i1|h) sigmoid(Σ_j w_ij h_j a_i)。这里的 sigmoid 就是 1/(1exp(-x))。我一般会在代码里手写这个函数而不是依赖 MATLAB 信号处理工具箱的 sigmf——手写只依赖基本运算符换机器跑不容易出环境问题。再解释 CD-k 的本质。标准最大似然梯度里包含正相和负相负相要对模型分布采样代价太高。对比散度的思路是用一步或 k 步吉布斯采样去近似负相。实际工程里k1 的效果在多数数据集上已经够用。为什么因为一步重构虽然噪声大但方向基本正确训练初期梯度方差大反而有助于跳出局部结构后期靠学习率衰减和动量可以把噪声压下来。这个取舍到第 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.batchSize1 : 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 的超参比普通神经网络更敏感这里给一组常用范围和我实际调出来的默认值。参数常见范围我的默认值说明学习率 lr0.001 ~ 0.10.01太大会震荡太小收敛慢MNIST 上 0.01 起步较稳动量 mc0.5 ~ 0.90.5前 5 轮→ 0.9前期避免跑偏后期加速收敛权重衰减 wd1e-4 ~ 2e-41e-4防止权重过大配合权重直方图检查隐藏单元数100 ~ 1000500784 维输入配 500 隐藏单元是常用配置batchSize10 ~ 20064太大梯度平滑但慢太小噪声大maxEpoch5 ~ 3015看验证集重构误差曲线决定是否早停迭代策略上先用 0.5 动量跑前 5 个 epoch让模型确定主方向之后切到 0.9 加速收敛。学习率衰减可以按 epoch 乘 0.99或者到后期观察验证误差连续两个 epoch 不降就早停。这套流程比较常规但比固定超参硬跑到底少踩很多坑。4. 监控与调参重构误差、激活率和 CD-k 的细节4.1 k1 与 k3 的取舍什么时候值得增加采样步数CD-k 里的 k 是吉布斯采样的步数。k1 的优势是训练快劣势是梯度噪声大k3 更接近模型分布但每一步慢三倍。对 MNIST 这类相关性不强的图像数据k1 完全够用。但对文本单词向量或基因表达这类特征高度相关的数据一步重构可能只学到局部的共现模式这时增加到 k3 会有肉眼可见的重构改进。改成 CD-3 只需把“隐藏层→可见层→隐藏层”这段重复三遍最后一步的 p_h1 参与梯度计算中间采样全部用二值。我只会在小数据集上做对比实验时用 k3大数据集一律 k1因为三倍训练时间通常换不来三倍精度。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*Wb 一次性算出来再对整个矩阵做 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 章的排查清单对照自己碰到的现象会省不少摸索时间。希望帮到你。本文还有配套的精品资源点击获取
返回列表