ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

BMA-LSTM金融时序预测:多模型融合降低方差实战

BMA-LSTM金融时序预测:多模型融合降低方差实战

简介:这份资源面向具备一定编程基础与金融数据分析经验的量化分析师、金融工程学生及科研人员,提供一套在MATLAB R2025b中实现的BMA-LSTM股票价格预测完整项目。它针对金融时间序列非线性、非平稳与高噪声的痛点,用多个结构不同的LSTM子模型分别训练,再基于验证集误差以近似贝叶斯方法计算权重,实现多模型集成,并构建预测区间以量化不确定性,从而提升预测精度与鲁棒性。压缩包共1个docx文件,约120KB,内容涵盖数据预处理、子模型定义与训练、贝叶斯权重计算、测试集成、评估可视化及GUI交互界面设计,并附模拟数据生成、模型保存与部署预测的全流程代码。已有158人学习。读者可据此掌握LSTM与贝叶斯模型平均融合的工程实现路径,理解权重调优、复杂度控制与数据质量管理要点,为量化投资、风险管理及后续引入注意力机制、多资产联合建模等改进提供可扩展的实践参考。

1. 金融预测里,BMA-LSTM 到底在解决什么麻烦

做股票价格预测的人,大多经历过同一个尴尬:单模型跑出来的曲线,回测漂亮得像艺术品,一上模拟盘就原形毕露。LSTM 尤其典型,它记性好、能吃长序列,但初始化权重、超参数、训练轮数稍微动一动,预测结果就飘得厉害。你调了三天的模型,换台机器重跑一遍,误差可能差出两个百分点。这种不确定性,才是金融时序预测真正的敌人。

BMA-LSTM 这个组合,思路其实很朴素:既然单个 LSTM 不稳,那就训练一批结构不同、初始化不同的 LSTM,再用贝叶斯模型平均(BMA)给它们分配权重,把多个模型的预测按后验概率加权融合。它解决的不是“让某个模型更准”,而是“让整体预测更稳、更可解释”。适合谁?适合已经会用 MATLAB 搭 LSTM、但被单模型方差折磨过的量化从业者,也适合想把概率预测引入风控的研究者。下面这套流程,我会按能复现的标准拆开讲。

2. 从单 LSTM 到 BMA:为什么加权比调参更值

2.1 单模型预测的方差从哪来

LSTM 在股票价格这种低信噪比数据上,方差来源至少有四层。第一层是权重初始化,随机种子不同,收敛到的局部最优就不同;第二层是超参数,窗口长度、隐藏层单元数、学习率、dropout 率,任意一个变动都会改变输出;第三层是数据切分,训练集和验证集的边界挪一挪,模型学到的分布就变了;第四层是训练随机性,mini-batch 的洗牌顺序、早停的触发点,都会让最终权重产生差异。

这四层叠加起来,导致一个很现实的问题:你没法判断某次预测变好,是因为模型真的学到了规律,还是因为运气好抽到了有利的初始化。金融数据本身噪声大,这种“运气成分”占比往往高得吓人。我见过不少项目,调参调到凌晨,最后发现提升的那点精度,换一个随机种子就没了。

BMA 的价值就在这里。它不追求消灭方差,而是承认方差存在,然后用概率的方式把多个模型的不确定性量化出来。每个 LSTM 是一个“专家”,BMA 根据它们在验证集上的表现,计算后验权重,表现好的权重大,表现差的权重小,但不会直接扔掉。最终预测是加权和,方差自然被平滑掉一部分。

2.2 BMA 的权重是怎么算出来的

BMA 的核心公式不复杂。假设有 K 个模型,第 k 个模型对下一时刻价格的预测是 y_k,它的后验权重是 w_k,那么 BMA 的融合预测就是:

y_BMA = Σ w_k · y_k

关键在于 w_k 怎么定。标准做法是用贝叶斯定理:

w_k = P(M_k | D) ∝ P(D | M_k) · P(M_k)

其中 P(M_k) 是先验,通常取均匀分布;P(D | M_k) 是模型 k 在观测数据 D 上的边际似然。边际似然直接算很难,实操里一般用验证集上的预测误差来近似。常见近似方式有两种:一种是用高斯似然,把验证集残差的方差代进去;另一种是用信息准则(BIC 或 AIC)做指数化再归一化。

我一般用高斯似然近似,因为它对残差分布的要求没那么强,而且计算量小。具体做法是:对每个 LSTM,在验证集上算预测残差,求残差方差 σ_k²,然后令:

log P(D | M_k) ≈ -0.5 · N · log(σ_k²)

N 是验证集样本数。这个近似假设残差服从零均值高斯分布,虽然金融数据常有厚尾,但作为权重分配的依据,够用了。算完所有模型的 log 边际似然后,做 softmax 归一化,就得到 w_k。

注意:如果某个模型的验证集残差方差特别大,它的权重会趋近于零,但不会完全为零。这是 BMA 和简单平均的区别——它保留了“万一这个模型在测试集上翻身”的可能性,只是概率很低。

2.3 在 MATLAB 里搭 BMA-LSTM 的最小骨架

下面这段代码是核心骨架,跑通它你就能看到 BMA 融合的效果。我假设你已经有了一个预处理好的价格序列,存在priceData里,是一个列向量。

% 参数设置 numModels = 8; % LSTM 模型数量 windowSize = 30; % 输入窗口长度 hiddenUnits = 64; % 隐藏层单元数 trainRatio = 0.7; % 训练集比例 valRatio = 0.15; % 验证集比例 testRatio = 0.15; % 测试集比例 % 构造监督学习样本:用前 windowSize 个点预测下一个点 numSamples = length(priceData) - windowSize; X = zeros(windowSize, numSamples); Y = zeros(1, numSamples); for i = 1:numSamples X(:, i) = priceData(i : i + windowSize - 1); Y(i) = priceData(i + windowSize); end % 划分训练、验证、测试 numTrain = floor(numSamples * trainRatio); numVal = floor(numSamples * valRatio); idxTrain = 1:numTrain; idxVal = numTrain + 1 : numTrain + numVal; idxTest = numTrain + numVal + 1 : numSamples; XTrain = X(:, idxTrain); YTrain = Y(idxTrain); XVal = X(:, idxVal); YVal = Y(idxVal); XTest = X(:, idxTest); YTest = Y(idxTest); % 训练多个 LSTM,保存验证集残差 models = cell(numModels, 1); valResiduals = zeros(numModels, length(YVal)); for k = 1:numModels % 每次用不同的随机种子和略微不同的超参数 rng(k * 100); layers = [ sequenceInputLayer(windowSize) lstmLayer(hiddenUnits + randi([-16, 16]), 'OutputMode', 'last') dropoutLayer(0.1 + 0.1 * rand()) fullyConnectedLayer(1) regressionLayer ]; options = trainingOptions('adam', ... 'MaxEpochs', 80, ... 'MiniBatchSize', 32, ... 'InitialLearnRate', 0.005 * (0.8 + 0.4 * rand()), ... 'ValidationData', {XVal, YVal}, ... 'ValidationFrequency', 20, ... 'Verbose', false, ... 'Plots', 'none'); % 转成 cell 格式以适配 sequenceInputLayer XTrainCell = num2cell(XTrain, 1)'; XValCell = num2cell(XVal, 1)'; XTestCell = num2cell(XTest, 1)'; net = trainNetwork(XTrainCell, YTrain', layers, options); models{k} = net; % 在验证集上预测,记录残差 YValPred = predict(net, XValCell)'; valResiduals(k, :) = YVal - YValPred; end % 计算 BMA 权重:高斯似然近似 logLik = zeros(numModels, 1); for k = 1:numModels sigma2 = var(valResiduals(k, :)); logLik(k) = -0.5 * length(YVal) * log(sigma2 + eps); end % softmax 归一化 w = exp(logLik - max(logLik)); w = w / sum(w); % 测试集上做 BMA 融合预测 YTestPredAll = zeros(numModels, length(YTest)); for k = 1:numModels YTestPredAll(k, :) = predict(models{k}, XTestCell)'; end YBMA = w' * YTestPredAll; % 评估 rmseSingle = sqrt(mean((YTestPredAll(1, :) - YTest).^2)); rmseBMA = sqrt(mean((YBMA - YTest).^2)); fprintf('单模型 RMSE: %.4f\n', rmseSingle); fprintf('BMA 融合 RMSE: %.4f\n', rmseBMA);

这段代码有几个关键点需要说明。numModels设成 8 是经验值,太少融合效果不明显,太多训练时间线性增长,8 到 12 之间比较平衡。windowSize取 30 是日频数据的常见选择,对应大约一个半月的交易日,如果你做的是分钟级数据,这个值要相应缩小。hiddenUnits在 64 附近波动,每个模型加一个 ±16 的随机偏移,是为了制造模型多样性。

valResiduals记录的是每个模型在验证集上的逐点残差,不是单一误差指标。用逐点残差算方差,比用 RMSE 更细,因为它保留了残差的分布信息。logLik的计算里加了eps防止 log(0),这是数值稳定性的常规操作。

权重w算出来后,你会发现表现最好的模型权重可能在 0.3 到 0.4 之间,其余模型分摊剩下的权重。这就是 BMA 的特点:不搞赢家通吃,而是让每个模型都有发言权,只是音量不同。

3. 数据预处理与模型多样性:BMA 效果的两个命门

3.1 价格序列的平稳化处理

股票价格是非平稳序列,直接喂给 LSTM,模型很容易学到“价格一直在涨”这种伪规律。常见做法是转成对数收益率:

% 假设原始价格在 rawPrice 里 logRet = diff(log(rawPrice)); % 如果要做价格预测,再累积回去 % 但训练时用收益率,预测时再还原

对数收益率的好处是量纲统一、近似平稳,而且有明确的金融含义。但这里有个坑:BMA 融合的是价格预测还是收益率预测?如果你在收益率上训练,BMA 融合后的也是收益率,最后要累积求和还原成价格。还原这一步会放大误差,因为累积求和把每一步的小误差都加起来了。

我的做法是:训练和融合都在收益率上做,但评估时同时看收益率 RMSE 和还原后的价格 RMSE。如果价格 RMSE 明显比收益率 RMSE 恶化,说明累积误差在作祟,这时候要么缩短预测步长,要么改用差分后的价格做目标。

注意:不要用归一化后的价格直接训练。归一化会引入未来信息,因为均值和方差是从全序列算的。正确做法是用训练集的均值和方差去归一化验证集和测试集,或者干脆用收益率。

3.2 怎么让多个 LSTM 真正“不同”

BMA 的前提是模型之间有差异。如果所有 LSTM 结构一样、初始化一样,那融合等于没融合。制造多样性的手段有几种:

第一种是随机初始化种子不同。这是最省事的,但多样性有限,因为结构相同,收敛到的解可能很接近。

第二种是超参数扰动。隐藏层单元数、dropout 率、学习率、batch size,每个都加一点随机扰动。上面代码里就是这么做的。扰动幅度要控制好,太大导致个别模型完全跑偏,太小又起不到多样化效果。我一般让隐藏层单元数在 ±25% 内波动,学习率在 ±20% 内波动。

第三种是结构差异。比如有的用单层 LSTM,有的用双层;有的加注意力机制,有的不加。这种多样性最强,但实现成本高,适合对精度要求苛刻的场景。

第四种是数据子采样。每个模型用不同的训练子集,比如 bootstrap 采样。这在统计上最符合 BMA 的精神,但金融数据样本量本来就少,子采样会进一步减少有效样本,要谨慎。

我一般用第二种为主、第一种为辅,结构差异留到进阶阶段再上。实测下来,超参数扰动带来的多样性,已经能让 BMA 比单模型在测试集上稳定提升 5% 到 15% 的 RMSE。

3.3 验证集残差的高斯假设检验

BMA 权重计算依赖残差服从高斯分布这个近似。如果残差严重偏离高斯,权重分配就会失真。跑完模型后,我习惯做一步检验:

% 对每个模型的验证集残差做正态性检验 for k = 1:numModels [h, p] = lillietest(valResiduals(k, :)); if h == 1 fprintf('模型 %d 的残差拒绝正态假设,p = %.4f\n', k, p); end end

如果超过一半的模型残差都拒绝正态,说明高斯似然近似不可靠,这时候要么改用 t 分布似然,要么改用信息准则算权重。信息准则的做法是:

% 用 BIC 近似边际似然 for k = 1:numModels rss = sum(valResiduals(k, :).^2); n = length(YVal); bic = n * log(rss / n) + 2 * log(n); % 简化版 BIC logLik(k) = -0.5 * bic; end

BIC 对残差分布的假设更弱,但它是渐近近似,小样本下可能不如高斯似然准。我的经验是:验证集样本超过 200 个,用高斯似然;少于 200 个,用 BIC 更稳。

4. GUI 设计:把 BMA-LSTM 包装成能用的工具

4.1 界面布局与回调逻辑

MATLAB 的 App Designer 或者传统的 GUIDE 都能做 GUI。我倾向用 App Designer,因为布局更灵活,回调函数写起来清晰。核心控件包括:

  • 一个文件选择按钮,用来加载 CSV 格式的价格数据
  • 一个下拉框,选择预测目标(收盘价、开盘价、最高价)
  • 两个数值输入框,设置训练集比例和 LSTM 模型数量
  • 一个“开始训练”按钮
  • 一个坐标轴,显示原始价格和 BMA 预测曲线
  • 一个文本区域,输出 RMSE、权重分布等指标

回调逻辑的关键是把训练过程放在后台,避免界面卡死。MATLAB 里可以用parfeval或者batch做异步训练,但最简单的做法是用drawnow在训练循环里刷新进度条。

% 在训练循环里更新进度 for k = 1:numModels % ... 训练代码 ... progress = k / numModels; app.ProgressBar.Value = progress; app.StatusLabel.Text = sprintf('正在训练第 %d / %d 个模型', k, numModels); drawnow; end

drawnow会强制刷新界面,代价是训练速度略微下降,但用户体验好很多。如果模型数量多、训练时间长,建议用parfeval并行训练,但要注意并行工具箱的许可和内存占用。

4.2 预测结果的可视化要点

BMA 的输出不只是点预测,还有权重分布和预测区间。可视化时,我一般画三样东西:

第一,原始价格曲线和 BMA 预测曲线叠加,让用户直观看到拟合效果。第二,每个 LSTM 的权重柱状图,展示哪些模型被 BMA 看重。第三,预测区间,用验证集残差的加权方差算出来:

% 计算 BMA 预测的方差 varBMA = 0; for k = 1:numModels varBMA = varBMA + w(k) * (valResiduals(k, :).^2 + (YTestPredAll(k, :) - YBMA).^2); end stdBMA = sqrt(varBMA); upperBound = YBMA + 1.96 * stdBMA; lowerBound = YBMA - 1.96 * stdBMA;

这个方差公式是 BMA 的经典分解:一部分来自模型内部的残差方差,一部分来自模型之间的预测差异。画出来就是一个 95% 置信区间,比单模型的点预测信息量大得多。

注意:预测区间在金融场景里要谨慎解读。它假设残差分布稳定,但市场波动率会突变,区间可能在某些时段明显偏窄。我一般会在图上标注波动率突变点,提醒用户区间可能不可靠。

4.3 参数持久化与批量回测

GUI 里调好的参数,应该能保存下来,下次直接加载。MATLAB 里用save和load存成.mat文件就行:

% 保存参数 params.numModels = app.NumModelsEditField.Value; params.windowSize = app.WindowSizeEditField.Value; params.hiddenUnits = app.HiddenUnitsEditField.Value; save('bma_lstm_params.mat', 'params'); % 加载参数 loaded = load('bma_lstm_params.mat'); app.NumModelsEditField.Value = loaded.params.numModels;

批量回测是另一个实用功能。用户可能想测试不同时间段、不同股票,这时候需要一个循环,自动跑完所有组合,输出汇总表格。我一般用uitable展示结果,每行是一个回测配置,列包括 RMSE、方向准确率、最大回撤。

方向准确率在金融预测里比 RMSE 更受关注,因为它直接关系到交易信号。算法很简单:预测涨跌方向和实际涨跌方向一致的比例。

directionPred = sign(YBMA(2:end) - YBMA(1:end-1)); directionTrue = sign(YTest(2:end) - YTest(1:end-1)); dirAcc = mean(directionPred == directionTrue);

这个指标能帮你判断 BMA 融合是否真的保留了方向信息。有时候 RMSE 降了,但方向准确率没提升,说明模型只是把幅度拟合得更准,但拐点还是抓不住。

5. 避坑与排查:BMA-LSTM 落地时最容易翻车的五件事

5.1 权重全压在一个模型上

现象:算出来的 BMA 权重里,某一个模型的权重超过 0.9,其余模型几乎为零。

原因:通常是某个模型的验证集残差方差远小于其他模型,导致它的 log 似然远高于别人。这往往不是因为它真的更好,而是因为它过拟合了验证集,或者验证集恰好落在它擅长的区间。

解决:给权重加一个下限,比如每个模型至少 0.02 的权重,防止完全塌缩。或者改用温度参数调节 softmax 的平滑度:

temperature = 2.0; % 大于 1 会让权重更平滑 w = exp((logLik - max(logLik)) / temperature); w = w / sum(w);

温度设成 2 到 5 之间,权重分布会明显更均匀。代价是融合预测可能略微偏向表现差的模型,但稳定性更好。

5.2 训练集和测试集的时间泄漏

现象:测试集 RMSE 低得离谱,但实盘模拟一塌糊涂。

原因:预处理时用了全序列的统计量,比如用整个价格序列算均值和方差做归一化,或者用未来数据填充缺失值。LSTM 的窗口滑动如果跨过了训练集和测试集的边界,也会泄漏。

解决:严格按时间顺序切分,所有预处理参数只从训练集算。窗口滑动时,测试集的第一个窗口必须完全来自测试集内部,不能借用训练集的尾巴。我一般会在切分后检查一遍:

assert(max(idxTrain) < min(idxVal), '训练集和验证集有重叠'); assert(max(idxVal) < min(idxTest), '验证集和测试集有重叠');

5.3 残差方差为零导致 log 爆炸

现象:某个模型的验证集残差全是零,或者接近零,算 log 时出现 Inf 或 NaN。

原因:模型在验证集上完美预测,这通常发生在验证集样本极少,或者模型严重过拟合。金融数据里,完美预测基本不可能,出现这种情况多半是数据泄漏。

解决:在方差计算里加一个下限:

sigma2 = max(var(valResiduals(k, :)), 1e-6);

同时检查验证集样本数,如果少于 30 个,BMA 权重不可靠,建议扩大验证集或改用交叉验证。

5.4 LSTM 训练不收敛

现象:训练损失震荡,或者一直不下降,最终模型预测一条直线。

原因:学习率太大、序列太长、梯度爆炸。金融收益率数值很小,通常在 0.01 量级,如果直接喂给 LSTM,梯度会非常小,学习几乎停滞。

解决:把收益率放大 100 倍再训练,预测完再缩回去。或者用sequenceInputLayer的Normalization参数做在线归一化。学习率从 0.001 开始试,配合piecewise学习率调度。

options = trainingOptions('adam', ... 'InitialLearnRate', 0.001, ... 'LearnRateSchedule', 'piecewise', ... 'LearnRateDropFactor', 0.5, ... 'LearnRateDropPeriod', 30, ... ...);

5.5 GUI 卡死或无响应

现象:点击“开始训练”后,界面冻结,进度条不动,Windows 提示“程序未响应”。

原因:训练循环在主线程里跑,MATLAB 的单线程界面无法同时处理回调。drawnow如果调用太频繁,反而会拖慢训练。

解决:用parfeval把训练放到后台:

f = parfeval(@trainBMAModels, 1, XTrain, YTrain, XVal, YVal, numModels); % 然后轮询 f.State,更新界面 while strcmp(f.State, 'running') pause(0.5); app.StatusLabel.Text = '训练中...'; drawnow; end [models, w] = fetchOutputs(f);

如果没装并行工具箱,退而求其次,把drawnow换成pause(0.01),减少界面刷新频率。

6. 进阶技巧:用滚动窗口 BMA 应对市场状态切换

BMA 的权重是在验证集上一次性算出来的,但市场状态会变。牛市里表现好的模型,熊市里可能一塌糊涂。固定权重的 BMA,本质上假设模型相对表现是稳定的,这个假设在金融数据里经常不成立。

我的做法是滚动窗口 BMA。具体来说,不只用最近一段验证集算权重,而是每隔一段时间重新算一次。比如每 20 个交易日,用过去 60 个交易日的残差重新估计权重。这样权重会随着市场状态缓慢漂移,比固定权重更适应。

实现上,把权重计算从训练阶段剥离出来,做成一个独立函数:

function w = computeBMAWeights(valResiduals, temperature) % valResiduals: numModels x numValSamples numModels = size(valResiduals, 1); logLik = zeros(numModels, 1); for k = 1:numModels sigma2 = max(var(valResiduals(k, :)), 1e-6); logLik(k) = -0.5 * size(valResiduals, 2) * log(sigma2); end w = exp((logLik - max(logLik)) / temperature); w = w / sum(w); end

然后在测试集上滚动调用:

rollingWindow = 60; stepSize = 20; YBMA_rolling = zeros(size(YTest)); for t = 1:stepSize:length(YTest) idxEnd = min(t + rollingWindow - 1, length(YTest)); idxStart = max(1, idxEnd - rollingWindow + 1); % 用这段窗口的残差重新算权重 wRoll = computeBMAWeights(valResiduals(:, idxStart:idxEnd), 2.0); % 对当前时刻做融合预测 YBMA_rolling(t:idxEnd) = wRoll' * YTestPredAll(:, t:idxEnd); end

滚动窗口的长度是个权衡:太短,权重估计噪声大;太长,跟不上市场变化。60 个交易日大约三个月,是我试下来比较平衡的值。步长 20 天对应一个月调一次权重,频率不算高,但足够捕捉中期状态切换。

还有一个技巧是给不同模型加“遗忘因子”。越近的残差,权重越大。这相当于指数加权,让 BMA 对近期表现更敏感:

lambda = 0.95; % 遗忘因子 weights_time = lambda .^ (size(valResiduals, 2) - (1:size(valResiduals, 2))); for k = 1:numModels weightedResidual = valResiduals(k, :) .* sqrt(weights_time); sigma2 = max(var(weightedResidual), 1e-6); logLik(k) = -0.5 * length(weightedResidual) * log(sigma2); end

lambda取 0.95 意味着 20 天前的残差权重降到 0.36,60 天前降到 0.05。这个衰减速度适合日频数据,如果是周频或月频,lambda要相应调大。

最后说一个我踩过的坑:滚动 BMA 的权重更新频率,不要和预测频率一样。如果你每天预测一次,每天更新权重,权重会跟着噪声剧烈波动,反而比固定权重更差。权重更新要慢于预测,让它在多个预测周期上平滑。我一般让权重更新频率是预测频率的 1/5 到 1/10。

这套 BMA-LSTM 的方案,我从单模型调到多模型融合,前后折腾了大半年。最大的教训是:不要指望 BMA 能化腐朽为神奇,它只能在你已经有一批还不错的模型时,帮你把方差压下去。如果单个 LSTM 的 RMSE 比朴素预测还差,先回去检查数据和训练流程,别急着上 BMA。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进