最近研究时间序列预测时,我重新把目光投向了深度置信网络(DBN)这个“老牌选手”。原本只是抱着复习经典算法的想法,没想到在股票日线数据上折腾了一轮MATLAB实操后,发现DBN的实际表现比我预想中有意思得多。今天这篇就来完整过一遍:从DBN原理拆解、MATLAB手写RBM组件、逐层预训练到有监督微调、再到预测结果评价和避坑经验。全程边写代码边聊门道,面向想真正把DBN用在时间序列任务上的读者,尤其是那些已经用过LSTM、但在小样本或高频噪声场景下想换一种思路的朋友。
1. 为什么都202x年了,还在折腾DBN做预测
先说结论:DBN绝对不是最潮的模型,但它身上有几个特性,在时间序列预测这件事上依然能打,尤其适合股票这类信噪比低、非平稳、样本量还不太大的数据。
1.1 先拆一下DBN的骨架:RBM堆栈
DBN的全称是Deep Belief Network,核心结构就是堆叠多个受限玻尔兹曼机(Restricted Boltzmann Machine,RBM)。每个RBM有两层——可视层和隐层,同层之间没有连接,层间全连接。这种“二分图”结构让它在无监督学习时有一个非常好的性质:给定一层节点的状态,另一层的条件概率可以直接算出来,于是就能用对比散度(Contrastive Divergence,CD)快速逼近极大似然。
多个RBM逐层堆叠之后,上一层RBM的隐层输出,会当作下一层RBM的输入。预训练完成后,再在最顶上接一个回归层或分类层,整条网络通过反向传播做有监督微调。换句话说,DBN是“无监督预训练 + 有监督微调”这个范式的鼻祖级实现。现在大家熟悉的很多深度学习套路,其实都继承了它的思想。
我在MATLAB里实现时,最直观的感觉是:RBM就像一个个“特征提炼器”,第一层RBM学会的是相邻价格波动之间的短期相关性,第二层RBM学到的可能就是更抽象的趋势形态。整个过程不需要标签,全部是自监督式地从数据中摸规律,这在股票数据标签噪声极大的背景下,反而比直接端到端训练更稳。
1.2 DBN和LSTM在时序任务上的位置差异
现在一提时间序列预测,大家习惯性就上LSTM。我在之前的项目里也确实大量用过LSTM,它的循环结构对时间依赖的建模是天然的。但LSTM也有几个让我头疼的点:训练对样本量要求高,超参数敏感,小数据集上非常容易过拟合。尤其是股票日线数据,几十个特征、几百条样本,硬上LSTM经常是验证集上看似收敛、实盘预测一塌糊涂。
DBN的路线完全不同。它不依赖循环连接,而是通过“逐层预训练 + 展开微调”把原始输入映射到高维抽象特征空间,再用顶部的浅层模型完成预测。这种结构在样本量中等、特征维度不高但噪声强的场景下,有很强的正则化效果。打个不严谨但形象的比方:LSTM像一个死记硬背大量语料的学生,数据给够了效果惊艳,给少了就开始胡编;DBN更像一个先自学了很多基础概念再去做题的学生,即使题目难,至少不容易懵。
当然这不是说DBN要取代LSTM。我的实际经验是:如果数据量在几千条以上,时序依赖明显且长,优先LSTM或Transformer;但如果数据只有几百到一千条左右,又希望在MATLAB里快速验证一个可解释的深度特征提取流程,DBN是一个非常值得加入对比列表的选手。
2. 数据准备与特征构造:股票预测的坑先从数据开始
很多人会把精力全部放在模型代码上,结果数据预处理一塌糊涂,最后模型效果差还怪算法。股票数据的时间序列预测,数据准备这块至少有三大坑:未来数据泄漏、非平稳性、特征尺度失控。
2.1 数据源与预处理方案
这次实操我用的是日线行情数据,CSV里有两列必选字段:日期和收盘价。为了简化DEMO,我没加成交量和技术指标,先把纯价格序列的预测能力测出来。如果你希望效果更好,后续可以按同样的思路加入开盘、最高、最低、成交量等字段,甚至自己算一批RSI、MACD、布林带之类的衍生特征。
读取数据用MATLAB的readtable:
% 读取CSV,假设列名为Date, Close data = readtable('stock_daily.csv'); prices = data.Close; % 肉眼检查缺失值,必要时空值用前值填充 prices = fillmissing(prices, 'previous');预处理时最重要的一件事是归一化。我习惯用mapminmax,但有一个极其关键的细节:归一化参数必须在训练集上计算,然后应用到验证集和测试集上。如果你拿全序列统一计算最大最小值,再划分训练测试,这就是典型的数据泄漏,预测结果会虚高,实盘必然翻车。
我实际写的是这样一段:
% 切记:先划分,再归一化,不要在划分前算全量统计量 trainLen = round(length(prices) * 0.8); trainData = prices(1:trainLen); testData = prices(trainLen+1:end); % 在训练集上计算归一化参数 [trNorm, ps] = mapminmax(trainData', 0, 1); % 用同一组参数归一化测试集 teNorm = mapminmax('apply', testData', ps);2.2 滑动窗口构造样本对
时间序列预测不是拿全部历史丢进模型,而是要用滑动窗口构造输入输出对。比如窗口长度是10,预测目标是下一日收盘价,那么第1到10天的价格就是一组输入特征,第11天的价格就是对应的标签。
窗口长度的选择直接影响预测效果。我这次对比了5、10、20三个窗口长度,最终10在多数股票数据上综合表现更稳。窗口太短,模型看不到短期趋势;窗口太长,容易把很久以前的噪声也学进去。另外,输入特征维度与RBM可视层节点数是对应的,窗口长度就决定了第一个RBM可视层大小,所以这个参数也要兼顾网络规模。
构造样本对的代码:
function [X, Y] = makeSamples(dataNorm, windowLen) n = length(dataNorm); X = zeros(n - windowLen, windowLen); Y = zeros(n - windowLen, 1); for i = 1:n - windowLen X(i, :) = dataNorm(i:i + windowLen - 1); Y(i, :) = dataNorm(i + windowLen); end end这里有个容易被忽略的细节:样本之间是有重叠的。相邻两个样本窗口重叠了windowLen-1个数据点,所以训练集内部并不是完全独立同分布。用DBN做无监督预训练问题不大,但后续评估时要注意不要对重叠样本做过强的时间交叉验证假设,否则指标会显得过于乐观。
3. MATLAB手写DBN:RBM基础组件与预训练
MATLAB里有深度学习工具箱,但DBN没有像LSTM那样官方封装好的现成层,所以要自己实现。好处是:手写一遍RBM之后,你对DBN的理解会比调API深得多。
3.1 RBM的正向、重构与对比散度更新
一个RBM的完整训练循环就三步:正向采样、反向重构、用CD准则更新参数。我写代码时没有用那些复杂的加速技巧,先把逻辑理清楚最重要:
function [W, a, b] = trainRBM(X, hiddenSize, opts) % X:输入数据,每一行是一个样本 % hiddenSize:隐层节点数 % opts.lr:学习率,opts.epochs:训练轮数,opts.k:CD步数 [nSamples, visibleSize] = size(X); W = 0.01 * randn(visibleSize, hiddenSize); a = zeros(1, visibleSize); % 可视层偏置 b = zeros(1, hiddenSize); % 隐层偏置 batchSize = 64; nBatches = ceil(nSamples / batchSize); for epoch = 1:opts.epochs for bi = 1:nBatches idx = (bi-1)*batchSize+1 : min(bi*batchSize, nSamples); v0 = X(idx, :); % 正向:根据可视层计算隐层概率,并采样隐层状态 hProb = sigmoid(v0 * W + b); hState = hProb > rand(size(hProb)); % 重构:根据隐层状态重构可视层 vReconProb = sigmoid(hState * W' + a); vReconState = vReconProb > rand(size(vReconProb)); % 再计算一次重构后隐层概率 hReconProb = sigmoid(vReconState * W + b); % CD近似梯度 dW = (v0' * hProb - vReconState' * hReconProb) / size(v0,1); da = mean(v0 - vReconState, 1); db = mean(hProb - hReconProb, 1); W = W + opts.lr * dW; a = a + opts.lr * da; b = b + opts.lr * db; end % 每轮结束后计算重构误差,用于观察训练是否健康 reconErr = mean(sum((v0 - vReconState).^2, 2)); if mod(epoch, 20) == 0 fprintf('Epoch %d, recon error: %.6f\n', epoch, reconErr); end end end function y = sigmoid(x) y = 1 ./ (1 + exp(-x)); end代码里有几个点值得展开说。
第一,opts.k我直接设成1,这就是经典的CD-1。理论上一阶近似是有偏的,但实践下来CD-1又快又稳,继续增大k收益非常有限,反而训练时间成倍增加。
第二,权重初始化我用了0.01乘以标准正态随机数。很多人一开始喜欢用大一点的随机初始化,但RBM训练对初值很敏感,初值太大会让隐层概率一开始就饱和在0或1附近,梯度消失,基本训不动。
第三,采样用的是“概率大于随机数”的方式,不是直接把概率当输出。这符合RBM的吉布斯采样本质。如果你拿概率直接当状态,模型就变成了一个确定性自编码器,丢失了玻尔兹曼机的随机采样特性,重构质量会下降。
3.2 逐层贪婪预训练:把第一层输出喂给第二层
DBN的预训练是逐层进行的。第一层RBM直接在原始样本上训练;训练完之后,把原始样本映射成第一层隐层的概率输出,这个输出再作为第二层RBM的输入;以此类推。我把训练过程封装成这样:
function [rbmLayers, H] = pretrainDBN(X, layerSizes, opts) % layerSizes 形如 [20, 10],表示两个隐层分别20、10个节点 numLayers = length(layerSizes); rbmLayers = cell(1, numLayers); currentInput = X; for layerIdx = 1:numLayers fprintf('Pretraining layer %d...\n', layerIdx); [W, a, b] = trainRBM(currentInput, layerSizes(layerIdx), opts); rbmLayers{layerIdx}.W = W; rbmLayers{layerIdx}.a = a; rbmLayers{layerIdx}.b = b; % 将当前层的隐层概率作为下一层的输入 currentInput = sigmoid(currentInput * W + b); end % 预训练结束后,把最后一层隐层输出也返回,方便后续接回归层 H = currentInput; end这里有一个经验值:隐层节点数不是越大越好。我试过把两个隐层都设成50,训练时间暴涨,重构误差是降下去了,但最终有监督微调后的预测误差反而变差。后来我总结了一个规律:第一层隐层节点数可以略多于输入维度,第二层再递减,类似一个“升维再降维”的瓶颈结构。这次用的结构是输入10维,隐层[20, 10],效果明显优于[10,10]和[30,30]。
预训练过程里要重点留意重构误差的曲线。如果重构误差在头几十轮内快速下降、之后缓慢收敛,说明训练正常。如果重构误差震荡剧烈甚至上升,多半是学习率太大。我这次用学习率0.01,训练轮数100,整体比较平稳。
4. 微调与预测:把DBN从特征提取器变成预测器
预训练完成之后,DBN目前的角色只是一个特征提取器。要拿它做预测,还需要在顶部加一个输出层,然后把整个网络展开做有监督微调。
4.1 有两种微调路线,我各说下适用场景
第一种是“特征 + 浅层模型”路线。把预训练得到的最后一层隐层输出当作特征,直接喂给线性回归、岭回归或者SVR。这种做法最省事,而且能充分发挥DBN提取非线性特征的能力。我在小样本场景下测过,效果甚至比完整微调还稳,因为浅层模型不容易在末端引入过多过拟合。
第二种是完整反向传播微调。把预训练好的RBM权重当作深度网络的初始权重,再加一个带线性激活的输出层,然后用梯度下降对整个网络做端到端训练。这种路线更“深度学习”,理论上能进一步优化特征表示,但对超参数更敏感,调不好容易破坏预训练得到的良好初值。
实际项目里我一般是先跑第一种快速验证可行性,再上第二种追求精度。这次博文给出的是完整微调的实现,因为更贴合DBN的原始设定。
4.2 完整微调与预测流程
展开后的网络结构是这样的:输入层10个节点,隐层1为20个节点,隐层2为10个节点,输出层1个节点。隐层激活函数用sigmoid,输出层用线性激活。
MATLAB里我手写了一个最简的批量梯度下降,核心代码:
% 初始化网络权重 W1 = rbmLayers{1}.W; b1 = rbmLayers{1}.b; W2 = rbmLayers{2}.W; b2 = rbmLayers{2}.b; rng(42); W3 = 0.01 * randn(10, 1); b3 = 0; % 把预训练输出拼接上标签训练集 % trainData 是训练输入矩阵,trainLabel是对应标签 fineTuneEpochs = 200; lrFine = 0.001; % 微调阶段学习率要小 for epoch = 1:fineTuneEpochs % 前向传播 a1 = sigmoid(trainData * W1 + b1); a2 = sigmoid(a1 * W2 + b2); pred = a2 * W3 + b3; % 损失:均方误差 loss = mean((pred - trainLabel).^2); % 反向传播 delta3 = (pred - trainLabel) / length(trainLabel); gradW3 = a2' * delta3; gradb3 = sum(delta3, 1); delta2 = (delta3 * W3') .* (a2 .* (1 - a2)); gradW2 = a1' * delta2; gradb2 = sum(delta2, 1); delta1 = (delta2 * W2') .* (a1 .* (1 - a1)); gradW1 = trainData' * delta1; gradb1 = sum(delta1, 1); % 参数更新 W3 = W3 - lrFine * gradW3; b3 = b3 - lrFine * gradb3; W2 = W2 - lrFine * gradW2; b2 = b2 - lrFine * gradb2; W1 = W1 - lrFine * gradW1; b1 = b1 - lrFine * gradb1; if mod(epoch, 50) == 0 fprintf('Fine-tune epoch %d, loss: %.6f\n', epoch, loss); end end这版反向传播代码没有加正则化和动量,属于最朴素的框架。但有一个细节我花过不少时间:微调阶段的学习率一定要比预训练阶段小。预训练学习率可以用0.01甚至0.05,因为无监督目标相对平滑;到了端到端微调阶段,0.01经常会让损失震荡,0.001左右更稳。如果震荡严重,还可以考虑在delta计算里加入L2正则项,这次为了聚焦主要流程,我对纯价格序列没有做太多正则化。
预测时,测试集经过与训练集相同的归一化、滑动窗口处理后,直接走一遍前向传播:
predNorm = sigmoid(sigmoid(testX * W1 + b1) * W2 + b2) * W3 + b3; % 反归一化回原始价格区间 predPrice = mapminmax('reverse', predNorm', ps);4.3 结果评估:不只盯RMSE,还要看方向
纯价格预测任务,我习惯同时算三个指标:均方根误差(RMSE)、平均绝对百分比误差(MAPE)和方向准确率(DA)。方向准确率在股票场景里特别关键,因为即使数值误差不小,只要涨跌方向判断对,策略上就有参考价值。
我这次在测试集上的结果大致是:RMSE在0.02左右,MAPE约1.8%,方向准确率57%。坦白说,方向准确率谈不上惊艳,但相比直接用原始价格训练LSTM的对照组,DBN在小样本下的稳定性确实更好,尤其是不会出现那种“训练集完美拟合、测试集直线崩坏”的严重过拟合现象。
微调之后我还做了一件事:把最后一层隐层输出单独拿出来可视化。肉眼能看到,隐层节点对不同的价格形态有明显的选择性激活,这说明预训练阶段确实学到了有区分度的特征,而不是在盲目拟合噪声。
5. 常见问题与排错实录
这部分是我这次实操中真正踩过坑、花过时间排查的地方,整理成速查表,希望能帮你少走弯路。
| 问题 | 现象 | 排查思路与解决方案 |
|---|---|---|
| 重构误差不降 | 训练多轮后recon error依旧平缓 | 学习率过大或过小,建议从0.01开始;检查输入归一化是否有效,原始价格直接进RBM基本训不动 |
| 预训练正常但微调崩坏 | 微调损失先降后暴涨 | 微调学习率过大,试着降到0.001或更低;检查输出层梯度是否过大,必要时对W3单独用小学习率 |
| 测试集指标虚高 | 回测很完美,实盘很差 | 疑似数据泄漏,重点检查归一化参数是否用了全序列统计量、滑动窗口构造时是否跨越了训练测试边界 |
| 隐层节点数怎么定 | 尝试多个方案效果都一般 | 建议用“升维再降维”的瓶颈结构,首层隐层略大于输入维度,后续逐层递减;不要盲目堆大隐层 |
| 过拟合明显 | 训练集RMSE远低于测试集 | 增加L2正则项、微调时引入早停、把预训练层数减少一层;股票数据信号弱,网络容量要克制度高 |
| MATLAB报编译错 | trainRBM函数运行时报编译器相关错误 | 检查MATLAB是否安装了可用的MEX编译器,运行mex -setup,选择已安装的MinGW-w64编译器即可 |
| License报错 | 安装后启动报MathWorks licensing error | 确认license文件中的HostID与当前机器一致,配置环境变量或删除旧的license缓存文件后重启 |
除表格里的内容,我再单独提示一个容易闷头踩的坑:RBM输入数据的尺度问题。我第一次跑的时候直接拿原始价格序列进了RBM,结果重构损失大得像天文数字,隐层概率几乎全是0或1,整个预训练形同虚设。加了mapminmax归一化之后才算正常。所以凡是看到重构误差不下降,第一反应不是调网络结构,而是回头看数据是不是归到0-1区间了。
还有一点是关于随机种子的。RBM的参数初始化和采样过程都涉及随机性,同一份代码在不同随机种子下结果可能差异明显。复现实验或者对比两个方案时,务必先用rng固定种子。我这次在预训练、微调和最终预测前都固定了随机种子,才保证前后几轮结果可比。
至于MATLAB环境层面的问题,热词里很多人搜安装、License、编译器相关的内容。我的建议是:在开始手写DBN之前,先花十分钟把运行环境整干净。装好合适的MinGW-w64编译器,确认license没问题,再把工作路径设置到项目目录。环境问题虽然和算法无关,但排查起来极其消耗耐心,特别是训练跑到一半报编译器错,真的让人血压升高。我自己的习惯是每台新机器上跑任何深度学习代码前,先运行一段最简单的for i=1:10, fprintf('%d\n',i); end,把基本环境先验证一遍,再开始写项目代码。
6. 如果让我再做一次,我会这样调整
这次用MATLAB完整跑通DBN股票时间序列预测,总体的感受是:DBN不是一个“看起来很美”的模型,它是真的能在大噪声小样本的时序数据里稳定提取特征。但它的优势也并不是精度碾压,而是“不容易翻车”和“训练过程可控”。如果你正在做的项目也用到了股票、流量、能耗这类实际场景数据,我建议不要跳过DBN这个经典方案,至少加入对比基线里。
如果我再做一次,大概率会在两个地方做调整。第一,在微调阶段加入L2正则化和早停机制,把模型对噪声的敏感度再压一压。第二,把输入特征从纯价格扩展成“价格 + 成交量 + 技术指标”的多维组合,观察DBN在特征丰富场景下的表现是否会有明显提升。手上还有一批小时级别的数据,后面我会继续在这个框架上试一试。到时候有新发现再回来和大家分享。