☰
QRBiLSTM区间预测:结合BiLSTM与分位数回归的MATLAB实现与评估指南
2026/10/28 1:02:57 网站建设 项目流程

简介:一套基于MATLAB的QRBiLSTM双向长短期记忆神经网络分位数回归时间序列区间预测实现,面向需要开展风速、负荷、功率等单变量序列区间预测的研究者,程序结构清晰,便于二次开发。资源内含完整程序与示例数据,运行环境为Matlab 2018及以上,输入输出均为单变量,Excel数据可直接替换,便于快速验证模型效果。压缩包共5个文件,包括4个m源文件和一个xlsx数据文件,其中主程序、分位数回归层、区间覆盖率/预测区间平均宽度等指标计算脚本均已齐备,整体仅20KB,轻量易用。已有318人学习。借助完整程序可掌握QRBiLSTM的建模流程,利用双向LSTM提取时序依赖,结合分位数回归输出置信水平下的上下界,适用于短期负荷、风电功率、气象要素等区间预测场景,为后续研究提供可复用的基线工具。

1. QRBiLSTM 是什么:一个点预测给不了决策边界,区间才能派上用场

做风电功率预测那会,业务方拿着我的点预测曲线问:"你说明天 12 点功率是 320MW,调度室按 320 去安排备用机组,万一实际只有 260 怎么办?" 这个问题一下子把单点预测的局限问透了。实际工程要的不是一个数,而是一个范围——最差到哪、最好到哪。QRBiLSTM 就是为这个场景设计的:用双向长短期记忆网络(BiLSTM)抓时间序列在滑窗内的前后向特征,再通过分位数回归同时输出 5%、50%、95% 三个分位边界,两条边界之间的范围就是预测区间。它适用于负荷预测、电价预测、交通流量、电池 SOC 估计、设备健康度等需要携带不确定度输出的时间序列任务。下面从原理到 MATLAB 实现一步步拆,新手能照着跑通,熟手可以直接跳到第 5 章看评估和踩坑。

2. 原理先行:BiLSTM 特征提取与分位数损失如何生成区间边界

这一章先把两个技术点拆开:BiLSTM 在学什么,分位数损失在干什么。两者的结合点在于"共享特征、分头输出"——同一个 BiLSTM 骨干网络把滑窗序列编码成特征向量,三个输出头分别按 0.05、0.5、0.95 三个分位损失训练。这个设计直接决定了区间预测的质量上限,理解后再调参才有方向。

2.1 从 LSTM 到 BiLSTM:滑窗内反向信息为什么能压低区间噪声

LSTM 的隐藏状态只依赖过去的信息,天然适合因果建模。但时间序列里的很多模式不是纯因果的。比如日内负荷曲线,凌晨 3 点的低谷往往意味着上午 9 点要爬坡;如果只看正向,模型要等低谷完全流过窗口才能形成判断,而双向结构在同一时刻同时看到了窗口里前后两侧的点,能更快锁定"谷后必升"这种形态。对区间预测来说,上下边界对局部形态的敏感度比单点预测更高,反向信息带来的特征补充能让边界更贴合实际波动。

BiLSTM 在滑窗预测场景里不违反因果约束:对 t 时刻,用 [t-lag+1, t] 这 lag 个历史点预测 t+1 的分位数。前向 LSTM 从 t-lag+1 读到 t,后向 LSTM 从 t 读到 t-lag+1,两个方向看到的都是历史数据,t+1 的实际值没有进入输入。这一点想清楚,后面就不会老是怀疑自己"信息泄漏"。

MATLAB 的bilstmLayer内部维护前向和后向两套 LSTM 单元,输出是拼接后的 [h_f; h_b]。同样的 hiddenUnits 下,参数量大约是单向 LSTM 的两倍,所以调参时有个经验:单向 LSTM 用 32 个单元够的任务,双向可以试 48~64,但不要直接翻倍到 128 以上——区间预测不是网络越大越准,hiddenUnits 过大容易把噪声也学进去,分位边界抖得更厉害。这个数有时候真的有点玄学,必须结合数据量和训练 loss 曲线来定。

2.2 分位数损失(pinball loss)和三个输出头的设计

分位数回归的核心是 pinball loss,也叫分位数损失、弹球损失。对目标分位 τ,损失定义为:

L_τ(y, ŷ) = τ · max(y - ŷ, 0) + (1 - τ) · max(ŷ - y, 0)

当 τ=0.5 时两个惩罚项对称,等价于 MAE;当 τ=0.05 时,模型低估值(y > ŷ)只被罚 0.05,高估值(y < ŷ)被罚 0.95,模型为了把损失压小会倾向于预测得偏低,学到的就是条件下分位数。反过来 τ=0.95 让模型倾向于预测偏高,得到上分位数。一低一高两条边界,中间包住的就是条件分布的主体区间。

这个损失在 MATLAB 里写起来很短,但它是整个自定义训练循环的核心:

function loss = quantileLoss(yTrue, yPred, tau) % yTrue: 1 x batchSize 实际值 % yPred: 1 x batchSize 预测值 % tau: 目标分位数,如 0.05 / 0.5 / 0.95 err = yTrue - yPred; loss = mean(max(tau * err, (tau - 1) * err), 'all'); end

说明:当 y > ŷ 时 err 为正,max 第一项 tau*err 起作用;当 y < ŷ 时第二项 (tau-1)*err 为正,因为 tau-1 是负数乘以负 err 得正。两个 max 只有一个非零,精确对应 pinball loss 的定义。用 'all' 让输出是标量,dlgradient 才能对全网络参数求梯度。

分位组合的选择,我按业务风险等级给一张常用表:

分位组合区间含义适用场景
0.05 / 0.5 / 0.9590% 预测区间负荷、电价常规调度
0.025 / 0.5 / 0.97595% 预测区间设备告警、安全裕度要求高
0.01 / 0.5 / 0.9998% 预测区间极端风险场景

三个输出头的结构,常见做法是 BiLSTM 后接一个全连接层,节点数设为 3,顺序对应 [0.05, 0.5, 0.95]。共享骨干、分头输出的好处是参数效率高,而且三个分位共用一套特征,分位曲线之间的趋势一致性更好,不容易出现"5% 分位线跑到 95% 分位线上方"的分位交叉。训练时把三个 pinball loss 直接求和,梯度同时更新全部参数。

3. MATLAB 数据准备:把原始时间序列切成带标签的滑窗样本

数据集部分的关键不是网络,而是"切分顺序"和"归一化顺序"。常见做法是:先读 .mat 数据并强制成列向量,再按时间比例切训练/测试,接着只拿训练段估计归一化参数,最后做滑窗。顺序一旦反了,区间预测整体失真。

3.1 .mat 数据格式与只用训练集做归一化

先说归一化。很多初学者会把整条序列的 mean、std 算一遍再切分,这在点预测里影响不大,但在区间预测里是硬伤——测试段的分布特征混进了训练数据,训练出来的区间在测试段上会"过于自信",覆盖率虚高,到真正上线时立刻翻车。

正确的顺序是先切分、再只拿训练段估计归一化参数:

% 读取 .mat 数据,假设变量名为 data(N x 1 列向量) load('timeseries_data.mat'); data = data(:); % 强制列向量 % 先切分,后归一化 trainRatio = 0.8; nTrain = floor(length(data) * trainRatio); trainRaw = data(1:nTrain); testRaw = data(nTrain+1:end); % 归一化参数只用训练段估计 mu = mean(trainRaw); sigma = std(trainRaw); trainNorm = (trainRaw - mu) / sigma; testNorm = (testRaw - mu) / sigma;

说明:mu 和 sigma 是之后反归一化的唯一依据,必须与测试段完全无关。测试段的标准差可能和训练段不一样,但区间预测要评估的就是模型在"没见过但同分布"数据上的表现,所以测试段必须用训练段的参数来变换。如果测试段分布漂移严重,PICP 会明显下降,那说明数据本身有概念漂移,不是归一化的锅。

如果你手上没有现成数据,可以先用合成序列跑通整个流程,再换真实数据。我调试时常用这个生成器:

% 合成数据:趋势 + 24 小时周期 + 高斯噪声 n = 2000; t = (1:n)'; trend = 0.01 * t; seasonal = 10 * sin(2*pi*t/24); noise = randn(n, 1) * 2; data = trend + seasonal + noise;

说明:合成数据的好处是噪声分布已知,你能直观验证"区间是否真的包住了大部分真实值"。把这一段替换掉 load 那行,其余流程完全一致,适合先做代码走通。

提示:数据量方面,我做过的最小规模是 1000 个点、滑窗 12、训练/测试 8:2,效果可用;低于 500 个点,分位边界会明显抖动,建议先用合成数据验证流程。

3.2 滑窗长度怎么定:自相关分析与三个候选值

滑窗 lag 是最影响结果的超参数。太小,模型看不到周期性;太大,训练样本数减少,且 BiLSTM 要在很长的窗口里找信号,容易把注意力耗在无关段。我一般先画自相关图做粗选:

figure; autocorr(trainNorm, 60); title('训练段自相关分析');

如果序列有明显日周期,小时数据会在 lag=24 处出现峰值;周周期在 lag=168。自相关衰减到置信带内之后仍然显著的最近滞后,可以作为 lag 的参考下限。常见做法是取 lag=12、24、48 三个候选,各跑一轮完整训练,比较测试段 PICP 和 PINAW,选区间质量好的。

滑窗构造函数如下,这段代码建议直接保存成createLagData.m:

function [X, Y] = createLagData(data, lag) % data: N x 1 列向量 % X: (N-lag) x lag 滑窗矩阵,每行是一个历史窗口 % Y: (N-lag) x 1 目标值,第 i 行对应 X(i, :) 的下一步 n = size(data, 1) - lag; X = zeros(n, lag); Y = zeros(n, 1); for i = 1:n X(i, :) = data(i : i + lag - 1, 1)'; Y(i, 1) = data(i + lag, 1); end end

使用例子:

lag = 24; [XTrain, YTrain] = createLagData(trainNorm, lag); [XTest, YTest ] = createLagData(testNorm, lag);

说明:X 的每一行是一个 lag 长度的历史窗口,Y 是窗口之后紧邻的那一个点的真实值,实现的是"用过去 24 个点预测下一个点"的单步预测。循环写法在数据量上千时速度可以接受;如果数据上百万行,可以改成向量化写法,但注意边界对齐容易出错,先用循环确认结果再优化。

这里有个细节:测试段的第一个窗口 XTest(1,:) 包含训练段末尾的 lag-1 个点,这在滚动预测里是合理的——预测下一个点本来就要用到紧邻的历史。不要因为"测试段数据混了训练段"而把第一行删掉,删了反而破坏了时间连续性。

4. 搭建 QRBiLSTM 模型:sequenceInput 维度、自定义损失与训练循环

MATLAB 里做 QRBiLSTM 有两条路:一是 trainNetwork 加自定义 outputLayer,二是 dlnetwork 加自定义训练循环。我推荐后者,因为分位数损失有三个不同权重的输出项,塞进标准训练流程的自定义层里,MATLAB 对多输出自定义损失的支持很别扭;自定义训练循环里可以直观地写 for 循环累加三个 τ 的损失,梯度回传也自然。

注意:这套代码需要 Deep Learning Toolbox,dlnetwork 从 R2019b 开始提供完整支持,R2021a 之后的版本直接跑,不需要额外配置编译器。

4.1 网络结构:sequenceInput 接 bilstmLayer 接全连接输出三分位

网络本身不复杂,但有一个维度陷阱必须说清楚:sequenceInputLayer 的输入是"每个时间步的特征数",不是滑窗长度。我们是单变量序列,每个时间步只有 1 个特征,所以第一层是 sequenceInputLayer(1),lag 是时间维,由后续层自动处理。

% 关键超参数 lag = 24; % 滑窗长度,与数据准备一致 numHiddenUnits = 64; % BiLSTM 隐层单元数 numQuantiles = 3; % 三个分位:0.05, 0.5, 0.95 % 构建网络层 layers = [ sequenceInputLayer(1, 'Name', 'input') % 单变量:每步 1 个特征 bilstmLayer(numHiddenUnits, 'OutputMode', 'last', 'Name', 'bilstm') fullyConnectedLayer(32, 'Name', 'fc1') reluLayer('Name', 'relu') fullyConnectedLayer(numQuantiles, 'Name', 'fc_out') ]; dlnet = dlnetwork(layers);

说明:bilstmLayer 的 OutputMode 设为 'last',因为滑窗预测只关心整个窗口编码出的最终特征;如果做序列到序列预测才需要 'sequence'。中间全连接层 32 个单元带 ReLU,给非线性映射留空间;最后一层不加激活函数,分位数输出理论上是任意实数。

参数上,numHiddenUnits 从 64 起步比较稳。数据噪声大时降到 48,序列模式复杂时升到 96。我试过把 hiddenUnits 拉到 128,训练时间翻倍,但 PICP 和 PINAW 几乎没变,说明 64 在这个数据规模下已经够用。

4.2 自定义训练循环:adamupdate 与三个分位数的梯度回传

模型损失写在一个函数里,核心是三个分位损失的求和。这里不是简单平均三个 pinball loss,而是直接求和,三个输出头会自适应协调权重。如果你发现中位数拟合明显差于两边,可以单独给 τ=0.5 的损失乘 1.2~1.5 的权重,这在噪声大的数据上有奇效。

function [loss, grads] = modelLoss(dlnet, dlX, dlY, taus) % dlX: 1 x batch x lag(CBT 格式) % dlY: 1 x batch dlYPred = forward(dlnet, dlX); % numQuantiles x batch loss = 0; for k = 1:numel(taus) err = dlY - dlYPred(k, :); loss = loss + mean(max(taus(k)*err, (taus(k)-1)*err), 'all'); end grads = dlgradient(loss, dlnet.Learnables); end

说明:forward 得到的三行输出分别对应 0.05、0.5、0.95 分位。每个 τ 的损失独立计算后累加,dlgradient 对整个 loss 求梯度,三个输出头共享同一个 BiLSTM 骨干,梯度会同时更新全部参数。

训练循环里有一个必须注意的点:dlnetwork 的序列输入格式是 CBT(channel × batch × time),而滑窗矩阵是 n×lag。每个 batch 需要先转置再 reshape 成 1×batch×lag 的三维数组:

% 超参数 maxEpochs = 200; miniBatchSize = 64; learnRate = 0.001; gradDecay = 0.9; gradDecaySq = 0.999; taus = [0.05, 0.5, 0.95]; numTrain = size(XTrain, 1); numItersPerEpoch = ceil(numTrain / miniBatchSize); % Adam 状态变量 trailingAvg = []; trailingAvgSq = []; for epoch = 1:maxEpochs shuffledIdx = randperm(numTrain); for iter = 1:numItersPerEpoch idx = shuffledIdx((iter-1)*miniBatchSize + 1 : min(iter*miniBatchSize, numTrain)); % 关键:转置 + reshape 成 CBT 格式 XBatch = reshape(XTrain(idx, :)', [1, numel(idx), lag]); dlX = dlarray(XBatch, 'CBT'); % 1 x batch x lag dlY = dlarray(YTrain(idx, :)', 'CB'); % 1 x batch [loss, grads] = dlfeval(@modelLoss, dlnet, dlX, dlY, taus); % adamupdate 的迭代号必须全局递增 globalIter = (epoch-1) * numItersPerEpoch + iter; [dlnet, trailingAvg, trailingAvgSq] = adamupdate(dlnet, grads, ... trailingAvg, trailingAvgSq, globalIter, learnRate, ... gradDecay, gradDecaySq, 1e-8); if mod(iter, 20) == 0 fprintf('Epoch %d, iter %d/%d, loss %.4f\n', ... epoch, iter, numItersPerEpoch, extractdata(loss)); end end end

说明几点。'CB'是 channel-batch 格式,'CBT'是 channel-batch-time 格式,顺序不能搞混。adamupdate 是内置 Adam 更新函数,trailingAvg 和 trailingAvgSq 第一次传空数组即可;globalIter 必须全局唯一,否则某些版本收敛异常。学习率 0.001 是 BiLSTM 训练的安全起点,loss 震荡不降时降到 0.0005,不要一上来就 0.01——分位数损失比 MSE 更不平滑,大学习率容易在早期把分位边界学歪。

训练完保存模型,注意连带保存归一化参数和 lag:

save('QRBiLSTM_Model.mat', 'dlnet', 'taus', 'mu', 'sigma', 'lag');

这个细节很多人漏掉。换了脚本后预测结果对不上,十有八九是反归一化参数没存全。

5. 区间评估与避坑:PICP、PINAW 和四个经典翻车点

训练完先别急着画图。区间预测的评估和点预测完全不同——只看 RMSE 没有任何意义,因为区间模型的 RMSE 可能很差,但覆盖率完全合格。工程上必须两个指标一起看:PICP(预测区间覆盖率)和 PINAW(归一化平均区间宽度)。

5.1 两个必须一起看的指标:PICP 与 PINAW

PICP 衡量真实值落在区间内的比例,PINAW 衡量区间平均宽度。单独看任何一个都是陷阱:PICP 高但 PINAW 巨大,等于没预测;PINAW 窄但 PICP 低,等于预测了个寂寞。

% 测试集预测,注意同样要 reshape 成 CBT dlXTest = dlarray(reshape(XTest', [1, size(XTest,1), lag]), 'CBT'); dlYPred = predict(dlnet, dlXTest); YPred = extractdata(dlYPred)'; % nTest x 3 % 反归一化 lowerPred = YPred(:, 1) * sigma + mu; midPred = YPred(:, 2) * sigma + mu; upperPred = YPred(:, 3) * sigma + mu; yTestReal = YTest * sigma + mu; % PICP:覆盖率 picp = mean((yTestReal >= lowerPred) & (yTestReal <= upperPred)) * 100; % PINAW:用实际值与边界共同覆盖的范围做归一化 dataRange = max([yTestReal; upperPred]) - min([yTestReal; lowerPred]); pinaw = mean(upperPred - lowerPred) / dataRange; fprintf('PICP = %.2f%%, PINAW = %.4f\n', picp, pinaw);

说明:PINAW 的分母用"实际值与预测边界共同覆盖的范围",这样即使预测边界显著超出实际值范围,也能体现区间过宽的问题。参考标准:对 90% 区间,PICP 在 85%~95% 之间算合理,PINAW 尽量小于 0.3;PICP 低于 80% 说明模型没学到不确定性结构,高于 98% 则十有八九是区间过宽。

指标含义合理范围(90% 区间)
PICP真实值落在区间内比例85%~95%
PINAW平均区间宽度 / 数据范围小于 0.3 为佳
CWC覆盖率与宽度的加权综合越低越好

如果只想用一个数判断模型好坏,可以看 CWC(覆盖率代价指标):CWC = PINAW + γ·exp(-η·(PICP-μ)),当 PICP 低于目标 μ 时给予指数惩罚。但日常调参我还是习惯 PICP 和 PINAW 分开看,因为能直观看出是"低估了不确定性"还是"区间太胖"。

5.2 避坑记录:四个经典翻车点

这几条是我不交学费学不到的,按"现象 → 原因 → 解决"写。

翻车点 1:训练 loss 变成 NaN,前 20 个 epoch 内最常见

现象:loss 输出为 NaN,后续所有 epoch 全部 NaN,模型报废。

原因:一是学习率偏大,分位数损失在异常点上的梯度很大,BiLSTM 的循环结构放大了梯度;二是归一化后的数据里有极端离群值,max 操作遇到 NaN 会一路传播。

解决:先检查归一化后的数据是否有 NaN:any(isnan(XTrain(:)))。数据没问题就把 learnRate 降到 0.0005,并在训练循环里加梯度裁剪:

% 梯度裁剪,防止梯度范数爆炸 gradNorm = sqrt(sum(cellfun(@(x) sum(x(:).^2), grads.Value))); if gradNorm > 5 scale = 5 / gradNorm; grads.Value = cellfun(@(x) x * scale, grads.Value, 'UniformOutput', false); end

翻车点 2:5% 分位线跑到 95% 分位线上方(分位交叉)

现象:画区间图时发现下边界和上边界交叉,区间出现负宽度,业务方直接看不懂图。

原因:三个输出头在极端噪声或非对称分布下各自拟合,偶发交叉。共享 BiLSTM 骨干已经大幅减少了这种情况,但在测试集某些连续段仍可能出现。

解决:预测后对三个分位输出按行排序,最小值给下边界、最大值给上边界、中间值给中位数。这不会改变分位数的整体质量,但保证区间在每个时间点上都数学有效:

YPredSorted = sort(YPred, 2); % 每行从小到大排序 lowerPred = YPredSorted(:, 1); midPred = YPredSorted(:, 2); upperPred = YPredSorted(:, 3);

翻车点 3:PICP 虚高,但区间宽得没法用

现象:PICP 高达 98%,图上区间像个大喇叭,业务方吐槽"这不就告诉我什么都有可能吗"。

原因:模型欠拟合,分位边界退化成"覆盖整个数据范围"的常数带。常见于 hiddenUnits 太小(16 以下)、训练 epoch 不足、或者数据本身信噪比极低。

解决:别急着加模型复杂度,先看训练 loss 是否还在稳定下降。如果已到平台期,把 hiddenUnits 从 64 提到 96,并给 τ=0.5 的损失乘 1.2 的权重,强制中位数曲线拟合得更紧,区间宽度自然被压下来。

翻车点 4:测试段滑窗起点错位,前 20 个点覆盖率崩掉

现象:测试段预测结果在开头一段突然跳变,覆盖率在测试段前 20 个点严重偏低。

原因:有人写代码时对测试段单独从第二个点开始构造滑窗,丢掉了跨越训练/测试边界的那一行历史窗口,导致预测起点错位。

解决:用 3.2 节的 createLagData 直接作用在整个 testNorm 上,让它用测试段第一个点之前的训练段数据补齐历史窗口。构造完检查 XTest(1, end) 是否等于 testNorm(1),XTest(1, 1) 是否是训练段倒数第 lag 个点,对上了才能继续。

6. 进阶验证:多步滚动预测前先验证单步区间稳定性

模型能出区间不代表能用。真实业务要的是"未来 24 小时的区间",而第 4 章训练的是单步预测器。从单步到多步,我的做法分两步:先验证单步区间在多个随机种子下的稳定性,再做滚动预测。

单步稳定性验证很简单:用 5 个不同随机种子(rng(1) 到 rng(5))各训练一遍,记录每遍的 PICP 和 PINAW。如果 5 遍 PICP 的标准差大于 3 个百分点,说明模型对初始化过于敏感,先不上多步,回头调 hiddenUnits 或在 bilstmLayer 后面加一层 dropoutLayer(0.2),把对初始化的依赖压下来。

滚动多步预测的常见做法是自递归:把 t+1 的中位数预测值作为历史窗口的新端点,推进窗口预测 t+2,依次滚到目标步长。每一步只取中位数回填窗口,不要用上边界或下边界回填——边界误差会随滚动步长指数累积,区间宽到失去意义。

验证滚动脉冲时,把每个滚动点的三个分位输出全保存下来,最后算一条"滚动 PICP"曲线,看它随预测步长的衰减趋势。如果第 1 步 PICP 是 88%、第 12 步掉到 60%,说明模型短期结构学得还行、长期趋势没抓住,这时候回头检查 lag 是否覆盖了主周期,而不是盲目加层。

最后说一个我的习惯:所有代码和数据固定在一个文件夹,训练脚本、预测脚本、评估脚本分开写,中间结果存 .mat。每次调参只重跑训练脚本,评估脚本读最新的模型文件,不会出现"模型换了评估结果还是旧的"这种低级事故。血泪经验,调参到第 20 版时,光靠脑子根本记不清哪个模型对应哪个结果,文件命名带时间戳是最便宜的后悔药。

如果你正在做负荷、电价或设备健康度的区间预测,QRBiLSTM 这个方向值得投入。先跑通今天这套单步流程,再往多步滚动扩展,每一步的 PICP 和 PINAW 变化都会告诉你模型瓶颈在哪。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询