ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用MATLAB手写DBN:RBM预训练到股票收益率预测实战

用MATLAB手写DBN:RBM预训练到股票收益率预测实战 最近在折腾时间序列预测的时候发现深度置信网络DBN这个老牌选手居然在股票数据上表现挺有意思。今天就拿MATLAB实操一把咱们边写代码边聊门道。这篇不是水文也不是劝你放弃LSTM转DBN而是记录一次把DBN从原理走到MATLAB代码的真实过程DBN到底怎么搭、RBM预训练那套流程该怎么实现、用股票收盘价做回归预测会踩哪些坑。不管你是做量化研究、毕业论文实验还是纯粹想给时间序列预测加一种baseline这篇应该都能给你省点时间。先说清楚我下面用的预测目标不是价格本身而是收益率后面会解释为什么环境是MATLAB R2023a不需要额外装深度学习工具箱核心代码自己写。整个过程我会拆成数据准备、RBM实现、DBN组装、实验结果四块最后把所有踩过的坑挑出来单独讲。1. 为什么又把DBN请出来做预测1.1 DBN到底是什么DBN是多层随机神经网络全称Deep Belief Network基础组件是受限玻尔兹曼机RBM。你可以把RBM理解成一个“双向翻译器”可见层接收输入隐藏层负责提炼特征层内没有连接层之间全连接。训练的时候模型不只是学着从输入预测目标而是学着把输入数据本身的概率分布复现出来这个过程叫重构。RBM的能量函数是 E(v,h) -a^T v - b^T h - v^T W h训练目标就是最小化这个能量。实际操作不直接求能量最小值而是用对比散度Contrastive DivergenceCD算法近似求梯度。CD算法的核心是先根据真实输入算隐藏层状态再根据隐藏层状态反推一个“重建输入”用真实输入和重建输入之间的差异来更新权重。这个思路放到今天看依然很聪明因为它在做分类或回归之前先让网络对数据分布有了一个基本认知。DBN的完整训练分成两段第一段是逐层无监督预训练每层当RBM单独训练第二段是在顶层加一个监督输出层把整个网络当成普通前馈网络做反向传播微调。这两段配合起来是当年解决深度网络梯度消失问题的主流方案之一。1.2 股票时间序列哪些地方适合DBN股票收益率序列有一个很让人头疼的特点信噪比极低、非平稳、还带明显的聚集效应。你很容易堆一个巨复杂的模型上去结果在训练集上拟合得漂漂亮亮测试集上一塌糊涂。DBN在这种场景下有个天然的缓冲优势——预训练阶段只学数据本身的结构不碰标签相当于先做了一遍特征提取再进入监督学习。对中小样本的行情数据这个缓冲能明显降低过拟合风险。另外股票数据里弱相关的特征非常多比如动量、均值回复、短期波动聚集。DBN的多层结构可以把这些弱信号一层一层组合成更高层的抽象特征。我个人的体会是DBN在单特征比如只有收益率上的表现中规中矩但一旦加入多个维度的特征它的优势会被放大。这一点和CNN、LSTM的用法不太一样CNN擅长处理空间结构LSTM擅长处理时间顺序而DBN更像是“通用特征蒸馏器”。1.3 和LSTM、ARIMA这类模型怎么选ARIMA是线性模型适合平稳序列处理不了太强的非线性关系LSTM擅长长距离依赖但对序列长度和数据量都很敏感训练时动不动就给你表演一个不收敛。DBN的位置很巧妙用滑动窗口把时间依赖转换成空间特征不需要维护递归结构训练稳定CPU上也能跑。选型建议是如果数据只有几百到几千条DBN作为baseline很值得试如果有几十万条甚至上百万条训练样本那LSTM或者Transformer可能更有优势。不是DBN不能处理大数据而是它的预训练成本会随数据量线性上涨优势就变小了。2. 数据准备与滑动窗口构造2.1 用收益率而不是原始价格价格序列几乎是一阶随机游走模型太容易学到“今天收盘价等于昨天收盘价”这种无意义解。如果直接用收盘价做预测你会看到预测曲线比真实曲线滞后一拍那个结果看起来挺漂亮但根本没用。正确做法是把原始价格转成对数收益率% 读取CSV假设列名是Date, Close data readtable(stock_daily.csv); price data.Close; % 对数收益率 ret [0; diff(log(price))]; ret(1) [];收益率序列的均值更接近0方差也更稳定预测任务变得有意义。接下来的所有代码都以收益率序列为准。归一化时有一个关键细节必须只用训练集的统计量不能用全样本的均值、方差或最小最大值。很多新手在这里踩坑先对整个序列做min-max归一化再切训练测试集这等于让模型在训练阶段偷看了未来数据的分布测试指标会虚高。% 按时间切分不要打乱顺序 trainLen floor(length(ret) * 0.8); trainRet ret(1:trainLen); testRet ret(trainLen1:end); % 只用训练集的上下限做min-max归一化 trainMin min(trainRet); trainMax max(trainRet); trainNorm (trainRet - trainMin) / (trainMax - trainMin); testNorm (testRet - trainMin) / (trainMax - trainMin); % 测试集可能超出[0,1]裁剪一下 testNorm min(max(testNorm, 0), 1);这里把数据映射到[0,1]是为了后面RBM的可见层能用伯努利分布建模。如果你习惯用z-score标准化也行但喂给RBM之前要再套一个sigmoid函数把值压回(0,1)区间。2.2 滑动窗口生成样本我采用窗口长度为20预测未来1步。窗口的物理含义是用最近20个交易日的收益率信息去预测下一个交易日的收益率。代码很直接就是一个for循环win 20; h 1; % 训练样本 Xtrain []; Ytrain []; for t win:length(trainNorm)-h Xtrain(end1, :) trainNorm(t-win1:t); Ytrain(end1) trainNorm(th); end % 测试样本 Xtest []; Ytest []; for t win:length(testNorm)-h Xtest(end1, :) testNorm(t-win1:t); Ytest(end1) testNorm(th); end注意不同位置取出来的样本是有重叠的。前20个数据和后20个数据只差一个点两个样本高度相关。这类重叠样本不能直接当独立样本打乱训练时必须保持时间顺序或者至少要在打乱后使用batch训练让随机性发挥作用。测试集更是不能打乱必须按时间一条条过否则你衡量的是模型在随机排列样本上的表现时间序列的连续性信息全被破坏了。2.3 关于数据集的补充说明有的做法是把整个收益率序列归一化后再滑动窗口再切分。这虽然看着干净但测试集窗口里的归一化数据已经依赖了训练集的统计量而且还会被测试集本身影响因为如果先统一归一化再切分测试集的均值和上下限其实参与了计算。严格的做法就是先切分再只从训练集里算出变换参数应用到测试集。这个顺序问题我在一开始经常忽略后来吃过亏。另一个容易被忽略的是复权问题。如果你手头的是不复权价格遇到分红送股会出现一个断崖式的价格跳空这个跳空会被模型当成可预测的规律学习。实盘数据建议用前复权或者后复权代码上和数据源相关这里就不展开了。3. RBM预训练与DBN微调MATLAB代码拆解3.1 RBM训练函数对比散度严格意义上的DBN没有现成MATLAB内置函数除非你去装第三方深度学习工具箱。为了不折腾依赖我自己写了一个简化版RBM训练函数核心是CD-1算法。CD-1的意思是用1步吉布斯采样近似计算负梯度这对大多数场景足够用了。function [W, bv, bh] rbm_train(X, hNum, opts) % 简化版伯努利-伯努利RBM训练 % X: N x vNum, 输入取值尽量在[0,1]附近 % opts.lr, opts.epochs, opts.batchSize [N, vNum] size(X); W 0.01 * randn(vNum, hNum); bv zeros(1, vNum); bh zeros(1, hNum); lr opts.lr; for epoch 1:opts.epochs idx randperm(N); for s 1:opts.batchSize:N batch idx(s:min(sopts.batchSize-1, N)); v0 X(batch, :); % 正向阶段根据真实输入计算隐层概率并采样 ph sigmoid(v0 * W bh); h double(rand(size(ph)) ph); % CD-1负向阶段由采样隐层重建可见层 pv sigmoid(h * W bv); ph2 sigmoid(pv * W bh); % 梯度更新数据相关项减重建相关项 dW (v0 * ph - pv * ph2) / numel(batch); dbv mean(v0 - pv); dbh mean(ph - ph2); W W lr * dW; bv bv lr * dbv; bh bh lr * dbh; end end end function y sigmoid(x) y 1 ./ (1 exp(-x)); end这是伯努利版本的RBM所以训练数据要先缩放到[0,1]。如果用连续值的高斯RBM可见层那一项要换能量函数代码会多不少但原理一样。先把伯努利版本跑通后面的改进都好说。3.2 逐层预训练把特征一层层蒸馏出来假设隐藏层结构定为[40, 20, 10]意思是第一个RBM有40个隐单元第二个20个第三个10个。第一个RBM的输入是窗口数据输出40维特征把输出作为第二个RBM的输入再输出20维特征以此类推。hids [40, 20, 10]; layers cell(1, length(hids)); inputData Xtrain; for i 1:length(hids) opts.lr 0.05; opts.epochs 100; opts.batchSize 32; [W, bv, bh] rbm_train(inputData, hids(i), opts); layers{i}.W W; layers{i}.bv bv; layers{i}.bh bh; % 生成下一层RBM的输入特征 inputData sigmoid(inputData * W bh); end这里每一层训练完会把输入数据重新过一遍激活函数得到新的特征矩阵作为下一层的“数据”。注意预训练阶段完全不需要标签这就是无监督特征学习的含义。隐藏层结构不是越大越好。窗口长度20[40,20,10]已经能提取比较抽象的特征了。如果你把第一个隐藏层放大到200个节点训练时间暴涨但预测精度不会线性提升反而容易过拟合。这跟“容量”和“数据量”之间的平衡有关。3.3 输出层与反向传播微调预训练完成后网络里有三层预训练权重。要在顶层加一个线性输出节点输入特征经过前三层非线性变换后最后一层直接做线性回归。微调阶段我用的是手写BP没有调工具箱因为这样可以完全控制初始化权重。numHid length(hids); W cell(1, numHid); b cell(1, numHid); for i 1:numHid W{i} layers{i}.W; b{i} layers{i}.bh; end Wout 0.01 * randn(hids(end), 1); bout 0; lr 0.002; for epoch 1:500 sIdx randperm(size(Xtrain, 1)); for j 1:32:size(Xtrain, 1) idx sIdx(j:min(j31, size(Xtrain, 1))); xb Xtrain(idx, :); yb Ytrain(idx); % 前向传播 a xb; acts cell(1, numHid 1); acts{1} a; for i 1:numHid a sigmoid(a * W{i} b{i}); acts{i1} a; end yhat a * Wout bout; % 反向传播 delta_out (yhat - yb) / numel(idx); dWout acts{numHid1} * delta_out; dbout sum(delta_out); % 从最后一个隐藏层逐层往前推 delta delta_out * Wout .* (a .* (1 - a)); for i numHid:-1:2 dW{i} acts{i} * delta; db{i} sum(delta); delta delta * W{i} .* (acts{i} .* (1 - acts{i})); end dW{1} xb * delta; db{1} sum(delta); % 梯度下降更新 for i 1:numHid W{i} W{i} - lr * dW{i}; b{i} b{i} - lr * db{i}; end Wout Wout - lr * dWout; bout bout - lr * dbout; end end这个微调代码不长但它是“能跑”的骨架。实际使用时建议加上动量项、L2正则和验证集早停。我在第一次跑的时候没有加正则训练集MSE压到很低测试结果却飘了后来加了0.001的权重衰减才稳住。最后预测的时候用同样的前向传播把输出反归一化回收益率尺度a Xtest; for i 1:numHid a sigmoid(a * W{i} b{i}); end yhatNorm a * Wout bout; predRet yhatNorm * (trainMax - trainMin) trainMin;这里得到的predRet就是预测的下一期收益率和真实收益率testRet对比就能算RMSE和方向命中率了。4. 实验结果与参数调优4.1 我看到的实验现象用一段日线数据跑下来窗口20、三层隐藏层[40,20,10]、预训练100轮、微调500轮测试集RMSE大概在0.018左右方向命中率在55%上下。这个方向命中率不算惊艳但作为纯模型验证我觉得已经能说明DBN没有过时。平稳段的表现更好方向命中率能到60%以上遇到波动率突然放大的日子误差明显增加。需要特别说明的是这个数字只代表我这次实验的数据不同股票、不同采样频率、不同时间段的特征差很远不要直接拿它当普遍结论。看到测试集RMSE很低也不能直接推导出“能赚钱”因为实盘还有交易成本、流动性和滑点这些都不是RMSE能反映的。4.2 参数选择经验隐藏层结构窗口20用[40,20,10]够用窗口100可以考虑[80,30,20]。RBM预训练学习率0.01到0.1之间太高会出NaN。微调学习率0.001到0.01优先从0.002开始。预训练轮数50到200基本能收敛。微调轮数300到1000配合验证集早停。梯度下降动量0.9能明显抑制震荡。随机种子用rng(2024)固定不然每次跑结果都不一样。还有就是randperm会打乱样本顺序我在训练时保留了这个随机性这样每个epoch的batch组合都不同相当于一种正则化。测试集不要打乱按原始顺序过一遍。4.3 常见问题排查第一个高发问题是预测曲线严重滞后。如果直接用收盘价做预测预测值几乎等于前一日价格看着拟合得很好其实就是在复制上一条数据。换收益率序列之后会好很多。如果收益率序列上还有滞后感可以考虑把窗口从20加大到30或40。第二个问题是过拟合。训练集MSE很低测试集MSE很高这种情况下先不要急着加数据先看隐藏层节点是不是太多了。把第一层从40降到20或者微调时加正则项都能压住过拟合。调参的时候不要频繁看测试集否则参数会隐式地过拟合测试集。第三个问题是测试集归一化越界。我用的是训练集min-max测试集里出现更大的值会超过[0,1]直接裁剪到边界。这虽然会让极端值信息打折扣但至少模型输入不会越界。另一个方法是直接对收益率做z-score然后接sigmoid映射也能处理这个问题。第四个问题是每次跑的结果不一样。RBM和BP里都有随机初始化加上randperm结果自然有波动。复现实验前先写一行rng(2024)固定所有随机数生成器。5. 更省事的替代方案与后续扩展5.1 用MATLAB自带堆叠自编码器做baseline严格区分一下DBN用的是RBM生成式预训练堆叠自编码器SAE用的是重构式预训练。两者思想很像但实现和数学细节不同。如果你只想快速验证“预训练微调”这个思路在股票数据上到底行不行MATLAB深度学习工具箱里已经有现成的自编码器接口hiddenSize1 40; autoenc1 trainAutoencoder(Xtrain, hiddenSize1, ... EncoderTransferFunction, logsig, ... DecoderTransferFunction, purelin, ... MaxEpochs, 100, ... UseGPU, false); feat1 encode(autoenc1, Xtrain);再把输出层接到feat1上用普通线性回归或者fitnet微调就能跑通一个简化版深度回归模型。这种方案胜在代码短但你得到的不是严格意义的DBN只能当快速验证。5.2 多步预测怎么做单步预测比较简单多步预测有两条路线。一是递归预测用第一步的预测值当成新的历史窗口继续预测第二步但误差会累积预测越远越不可信。二是直接预测设计DBN输出层的节点数等于步数一次预测出未来h个值训练时目标也改成h列矩阵。我更推荐第二种模型训练虽然稍微复杂一点但累积误差的问题小很多。还有一个工程上的经验做多步预测时训练窗口不要从序列开头一直滑到末尾而是每隔几步取一个样本减少样本重叠程度。这样测试集评估更接近真实部署场景。5.3 特征扩展只用收盘价收益率还是太朴素。可以考虑加入成交量、最高最低价、相对强弱指标RSI、MACD柱等特征。加入这些特征要注意不要在滑动窗口内引入未来信息比如RSI的计算必须只使用当前窗口内已有的数据不能调用全样本的均值。DBN比较擅长把弱特征组合起来特征维度高的时候反而更能体现预训练的价值。我对一个同时包含收益率、波动率、成交量的多特征版本做过实验方向命中率比单特征版本高了大约3个百分点。这个差距在噪声很大的金融数据里不算小建议你跑单特征版本的同时也试试多特征。5.4 关于股票预测应用的提醒在量化研究里55%到60%的方向命中率只能算一个研究信号离可用策略还很远。真实交易还要考虑交易成本、持仓周期、止损规则一个模型在回测里表现好不等于实盘能赚钱。我写这篇纯粹是把DBN在时间序列预测上的能力做一个完整的技术拆解不构成任何投资建议。如果你要用这个思路做实证研究建议把评价指标从RMSE扩展到方向准确率、信息系数、回撤分析等。这个项目做下来我最大的体会是DBN没有想象中那么神秘也没有完全过时。CD算法虽然原理简单但亲手写成MATLAB代码并看到它收敛对理解深度学习的初始化、预训练和特征提取非常有帮助。数据预处理、切分、归一化这些基础工作决定了一个模型能不能复现关键实验的时候先把这些搞扎实再谈换模型。后面我想试试把DBN预训练出来的特征再喂给LSTM做混合结构看看能不能把DBN的稳定性和LSTM的时序记忆结合起来。这个思路还在折腾中等有结果了再来记录。
返回列表