简介:基于时间卷积神经网络与分位数回归的时间序列区间预测模型,配套Matlab完整源码与数据集,面向需要开展时序不确定性分析的科研人员和工程开发者。模型将时间卷积网络的特征提取能力与分位数回归的分位点估计相结合,可输出多置信水平下的预测区间,适合电力负荷预测、风速预测等单变量时序场景。资源包共九个文件,以八个Matlab脚本和一个Excel数据集为主,代码覆盖数据处理、网络构建、分位数损失层、区间覆盖率与区间平均宽度百分比等模块,主程序可直接运行,自动产出多种结果图并计算平均绝对误差、均方根误差、决定系数等指标,注释清楚,易于二次开发。压缩包仅二十三KB,轻量便捷,已有八十八人学习。整体而言,这份资源从数据到模型再到评价指标形成完整闭环,既有教学参考价值,也可直接迁移到实际预测任务,对初学和进阶者均有帮助。
1. QRTCN把时间序列预测从一根线变成一条区间
QRTCN这个缩写拆开是三件事:时间卷积神经网络(TCN)负责把时间序列特征抽出来,分位数回归负责给出不同置信水平下的预测区间,Matlab把这些组织成一套能直接改参数、直接画图的源码。很多做负荷预测、汇率分析、设备预警的人一开始奔着LSTM去,但LSTM在Matlab里调试慢,长序列还容易遇到梯度问题;换成分位数回归加时间卷积的组合后,训练更稳定,输出也从一根预测线变成带上下界的区间。
这篇文章要讲清楚:TCN为什么适合时间序列特征提取,分位数损失和普通MSE差在哪,Matlab源码里数据怎么整理、网络怎么搭建、参数怎么调,以及区间预测必须看的PICP、PINAW指标。适合已经用深度学习做时间序列预测、想从点预测升级到区间预测的人,也适合拿到源码想搞清楚每个参数含义的初学者。
2. 从TCN到QRTCN:为什么用时间卷积而不是LSTM
2.1 因果卷积与膨胀卷积:感受野是怎么扩大的
TCN的核心是因果卷积(causal convolution):t时刻的输出只看得到t时刻及以前的输入,这正好对应时间序列预测场景——预测未来时不能偷看未来。你在Matlab里写convolution1dLayer,默认卷积是双向的,要保证因果性,得做左侧padding或者掩码,很多源码里其实用"左侧padding后截断"实现。
接着是膨胀卷积(dilated convolution)。它的作用是在不增加参数量的前提下扩大感受野。设kernel size为k,第i层的dilation factor为d_i,第i层每个卷积核覆盖的输入跨度是(k-1)×d_i+1。多层叠加后,感受野按公式累加:
RF = 1 + Σ (k_i - 1) × d_i
例如k=3,dilation按1、2、4、8递增,4层网络在深度12的时候,感受野已经覆盖到31个时间步。这个覆盖范围直接决定模型能"看到"多长的历史,时间序列预测中常见的周期项(比如24小时的日周期、168小时的周周期)就是靠这个机制被吃进模型里的。如果你只是把TCN当作"换掉LSTM的卷积网络",不理解感受野,后面调参数会非常被动。
| 组件 | Matlab常见写法 | 作用 |
|---|---|---|
| 因果卷积 | convolution1dLayer配合padding裁剪 | 保证输出只依赖当前及过去 |
| 膨胀卷积 | convolution1dLayer的DilationFactor参数 | 成倍扩大感受野,不增加参数量 |
| 残差连接 | additionLayer或自定义层 | 缓解深层网络梯度衰减 |
| 分位数输出 | fullyConnectedLayer(numQuantiles) | 同时输出多个分位点预测值 |
2.2 残差块:深层TCN训练不塌的秘密
TCN堆叠十几层后梯度可能不稳定,残差块就是解决办法。块内结构通常是:1D卷积→ReLU→Dropout→1D卷积→ReLU→残差相加。如果输入输出通道数不一致,残差连接中间要插一个1×1卷积对齐维度。你在Matlab里看到的源码,多数实现是把残差块展开成一层一层的layer array,而不是封装成独立模块,这么做是为了能用trainingOptions直接跑通。
权重归一化是个容易忽略的细节。Matlab里没有直接对应weight normalization的层,常见做法是换用batchNormalizationLayer,或者在自定义层里手动做。用batch norm要小心:时间序列输入是三维的(特征×时间×样本),batch norm默认按通道做,效果尚可,但模型的稳定性对batch size比较敏感。如果你的训练集只有几百个样本,batch size设太小,batch norm的统计量会抖,这时候宁可去掉bn层,把学习率调低。
2.3 分位数损失:QRTCN和普通TCN的本质差别
普通TCN用MSE,拟合的是条件均值,输出的是点预测。QRTCN用分位数损失,拟合的是条件分位数,输出的是多个分位数。这是整个模型最关键的一处改动。
function loss = quantileLoss(yPred, yTrue, tau) % yPred: 网络输出, 形状 [numQuantiles, numSamples] % yTrue: 真实值, 形状 [1, numSamples] % tau: 分位点列向量, 例如 [0.05; 0.50; 0.95] err = yTrue - yPred; % err>0 表示预测偏低, 权重为 tau % err<0 表示预测偏高, 权重为 (1-tau) loss = mean(max(tau .* err, (tau - 1) .* err), 'all'); end这段代码的逻辑是:当预测值低于真实值(err > 0),损失权重大小是tau;当预测值高于真实值(err < 0),权重是1-tau。比如tau=0.95时,预测低于真实值的惩罚远大于预测高于真实值,网络就会偏向给出偏保守的"偏上"估计——这正是上边界的行为。下边界同理取0.05,中间0.5对应中位数,相当于一种更稳健的均值估计。
这里要强调一个常见误区:不要对三个分位点分别训练三个模型。QRTCN的价值在于共享TCN特征提取层,三个分位数一起训练,网络学到的是同一个特征空间下的三种映射。分开训练不仅慢,而且三个分位点之间会互相矛盾,最终画出来的区间可能扭曲。你在看Matlab源码时,确认输出层是3个神经元、损失是分位数损失,基本就能判断这是不是真QRTCN。
3. Matlab源码落地:从数据整理到完整训练流程
3.1 滑动窗口:把单变量序列变成监督学习样本
时间序列预测的第一步不是搭网络,而是把原始序列改造成"输入-输出"对。常见做法是固定窗口长度,按步长1滑动;要预测未来horizon步,就把第t+horizon时刻的值当作第t窗口的标签。
function [XTrain, YTrain] = createSlidingWindow(data, windowSize, horizon) % data: 列向量, 单变量时间序列 % windowSize: 输入窗口长度 % horizon: 预测步长, 1表示下一步 n = length(data) - windowSize - horizon + 1; XTrain = zeros(windowSize, n); YTrain = zeros(1, n); for i = 1:n XTrain(:, i) = data(i : i + windowSize - 1); YTrain(i) = data(i + windowSize + horizon - 1); end % Deep Learning Toolbox 的 sequenceInputLayer 接受 [特征数, 时间步, 样本数] XTrain = reshape(XTrain, windowSize, 1, n); end这段代码里有几个点要说明。第一,XTrain的维度是windowSize×1×n,对应sequenceInputLayer要求的[特征维度, 时间步, 样本数],所以windowSize放在第一维。如果你之后改用LSTM,输入格式一样,这也是把这套代码改成LSTM做对比实验很方便的原因。第二,步长固定为1,样本之间高度重叠,训练时网络会反复看到相似的窗口,这会导致训练损失看起来很低,但测试集上的表现更接近真实水平。第三,horizon大于1时,预测的是t+horizon这个单点的值,不是未来一段曲线;想要预测未来一段区间,需要把输出层改成输出horizon个数。
3.2 用dlnetwork搭建QRTCN而不是regressionLayer
我一般不用regressionLayer,因为它的内置损失是MSE,而QRTCN的训练目标是分位数损失,用trainingOptions跑regressionLayer,网络学出来的是均值而非分位点。正确做法是用dlnetwork手动定义前向传播和损失,再用dlfeval计算梯度。
% 搭建一个轻量QRTCN主干 layers = [ sequenceInputLayer(1, 'Name', 'in') convolution1dLayer(3, 8, 'Padding', 'causal', 'DilationFactor', 1, 'Name', 'c1') reluLayer('Name', 'r1') convolution1dLayer(3, 8, 'Padding', 'causal', 'DilationFactor', 2, 'Name', 'c2') reluLayer('Name', 'r2') convolution1dLayer(3, 8, 'Padding', 'causal', 'DilationFactor', 4, 'Name', 'c3') reluLayer('Name', 'r3') fullyConnectedLayer(3, 'Name', 'fc_quantile') % 3个输出对应3个分位点 ]; dlnet = dlnetwork(layers);你需要注意,'Padding', 'causal'这个设置是Matlab里比较特殊的关键字,它会自动保证因果性,不需要手动裁剪。如果你的Matlab版本不支持这个参数,就得改用手动左侧padding再加convolution1dLayer。训练循环部分是这样:
for epoch = 1:maxEpochs [loss, gradients] = dlfeval(@modelLoss, dlnet, dlX, dlY, tau); [dlnet, state] = dlupdate(@sgdmupdate, dlnet, gradients, state, learnRate); end function [loss, gradients] = modelLoss(dlnet, dlX, dlY, tau) dlYPred = forward(dlnet, dlX); loss = quantileLoss(dlYPred, dlY, tau); gradients = dlgradient(loss, dlnet.Learnables); end这段代码里dlX是dlarray格式,用dlarray(X, 'CTB')转换,'CTB'表示Channel、Time、Batch三个维度。forward之后输出的形状是[3, 1, numSamples],三行分别对应三个分位点。全连接层输出是3,刚好匹配tau的向量长度。
3.3 预测输出与画区间阴影
训练完成后输出:
dlYPred = predict(dlnet, dlXTest); YPred = extractdata(dlYPred); % YPred(1,:) 下边界, YPred(2,:) 中位数, YPred(3,:) 上边界如果你的源码在数据进入网络之前做了归一化,预测完必须逆变换回原始尺度。常见做法是存下训练集的min和max,用YPred * (max - min) + min还原。这一步错位,画出来的区间宽度和位置全不对,但代码不会报错,是隐蔽性最强的问题。
画区间用Matlab的fill函数,对应到"matlab如何将区间画成线段"这类的实操场景:
t = 1:length(actual); fill([t, fliplr(t)], [lower, fliplr(upper)], [0.8 0.8 0.8], 'EdgeColor', 'none'); hold on; plot(t, mid, 'b', 'LineWidth', 1.5); plot(t, actual, 'k');fill需要把上下边界首尾相接围成闭合多边形,所以x轴是t加翻转的t,y轴是upper加翻转的lower,'EdgeColor','none'去掉边界线,灰色半透明用RGB三元组[0.8 0.8 0.8]表示。如果你想带透明度,可以用'FaceAlpha', 0.3。画出来的图里,中间蓝色线是预测中位数,黑色线是真实值,灰色带就是90%预测区间。
4. 参数调节与区间质量检验:QRTCN最关键的三组参数
4.1 分位点选什么:0.05/0.5/0.95不是唯一选择
通用做法是0.05、0.5、0.95三档,对应90%预测区间。但分位点的选择本质上由业务需求决定:金融风险场景经常用0.01、0.5、0.99,希望极端情况也被包住;设备预警场景用0.1、0.5、0.9就够了,区间太宽反而没有告警意义。分位数回归本身不对误差分布做任何假设,所以不存在"正确的分位点",只存在"对业务有用的分位点"。
如果你想同时输出多个区间,比如80%和95%两个置信水平,输出层神经元数量可以设成5个,对应[0.025, 0.1, 0.5, 0.9, 0.975]。训练损失就是这五个分位数损失的均值。我试过这种组合,训练时间几乎不增加,但能一套模型出两种区间,给业务方演示时很有说服力。
4.2 膨胀系数和卷积核:感受野要覆盖主周期
感受野的计算公式前面已经给了,这里直接给一组参考值。假设目标序列有明显的24小时周期,windowSize设成168小时(一周),那么感受野至少要有168。用k=3,dilation按1、2、4、8、16、32、64递增,7层卷积的感受野累计是1+2×(2+4+8+16+32+64+128)=509,已经覆盖7层窗口长度。如果k=5,同样7层,感受野直接破千。这个例子说明,膨胀卷积的深层的感受野增长速度是指数级的,不需要把网络堆得很深。
| 卷积核 | 膨胀序列 | 层数 | 感受野 | 适用序列 |
|---|---|---|---|---|
| 3 | 1,2,4,8,16,32 | 6 | 127 | 短周期、无强趋势 |
| 3 | 1,2,4,8,16,32,64 | 7 | 255 | 日周期+周趋势 |
| 5 | 1,2,4,8,16 | 5 | 156 | 中等长度平滑序列 |
| 7 | 1,2,4,8 | 4 | 127 | 有局部波动但周期短 |
卷积核大小建议控制在3到7之间。kernel太大,浅层就能看到很宽的局部窗口,但参数量涨得快,而且容易把局部噪声当成特征。遇到明显周期,比如24小时,可以把某一层的kernel设成24或25,等于让每个卷积核完整覆盖一个周期,这比强行用3×N层的组合更直观。
要调参时,可以先在训练集上跑一次基线,记录训练损失下降曲线。如果训练损失在前期下降很快、后期震荡明显,通常是学习率偏大;如果损失在某个epoch后几乎不动,但区间宽度一直偏大,可能是输出层参数初始化或者分位点设置的问题。不要一上来就调膨胀系数。
4.3 PICP、PINAW和CWC:区间预测的三个硬指标
做时间序列区间预测,光看均方误差是不够的。三个指标必须一起看。
PICP(Prediction Interval Coverage Probability)衡量真实值落在预测区间内的比例;PINAW(Prediction Interval Normalized Average Width)衡量区间平均宽度与数据范围的比值;CWC(Coverage Width Criterion)把两者合并成一个可比较的单值,在覆盖不够时加大惩罚。直接给出计算代码:
function [picp, pinaw, cwc] = calcIntervalMetrics(lower, upper, actual, alpha) % lower、upper: 上下边界列向量 % actual: 真实值列向量 % alpha: 显著性水平, 0.1 表示90%置信区间 coverage = (actual >= lower) & (actual <= upper); picp = mean(coverage); dataRange = max(actual) - min(actual); pinaw = mean(upper - lower) / dataRange; eta = double(picp < (1 - alpha)); cwc = pinaw * (1 + eta * exp(-8 * (picp - (1 - alpha)))); endPICP的期望值是1-alpha,比如alpha=0.1时PICP应该接近0.9。如果PICP远高于0.9,比如到了0.98,不一定是好事——这说明区间过宽,PINAW会变大。反之,PICP过低就是区间覆盖不住风险,在业务上比"区间宽"更致命。CWC中的指数惩罚系数我一般取8,这个值越大,对覆盖不足的惩罚越陡。你可以根据自己的业务容忍度调整,没有标准答案。
这三个指标会出现在很多源码附带的demo脚本里。拿到源码后,先把alpha改成你要的置信水平,跑一遍测试集,记住PICP和PINAW的基线值。后续每改一次结构,都拿新值跟基线比,才能判断改动是正向还是负向。另外,如果你愿意折腾,可以把PINAW作为优化目标、PICP不低于1-alpha作为约束,用matlab优化工具箱里的patternsearch搜超参。代价是每评估一次就要完整训练一轮模型,时间成本很高,我通常只在dilation和kernel尺寸拿不准时才这么干。
5. 落地时的三个检查:滑窗回测、尺度泄漏与边界一致性
拿到一份QRTCN的Matlab源码,别急着换数据跑。先检查三件事,任何一件出错,测试集上的漂亮指标都可能是假象。
第一件是切片方式。时间序列的划分必须保持时间顺序:前80%训练,后20%测试,顺手写清楚每段对应的时间区间。随机打乱切分会把未来信息泄漏到训练集里,这在曲线预测场景里基本宣判死刑。我用滚动回测时,会把测试集的预测点逐个推进:先用前windowSize步预测第windowSize+1步,等到真实值出来,再滑动窗口预测下一步。这样贴近线上使用方式,但要注意,滚动回测的每一步都要重新调用predict。
第二件是归一化统计量的计算范围。训练集归一化时只允许用训练集自身的min/max或mean/std,保存成.mat文件;预测时加载这个.mat,对测试集做同样的线性变换。很多源码错误地对完整序列做归一化再切分,导致测试集的信息已经从统计量里泄漏进训练过程。这种情况最迷惑人的地方在于PICP指标可能比实际更高——不是模型变好了,是测试已经"提前看到"了全局的上下界。把min/max和mean/std单独存盘这个动作,比加深网络更有价值。
第三件是边界一致性。三个分位点由同一个网络输出,但网络没有内建约束保证lower ≤ mid ≤ upper,偶尔会出现下边界比上边界大的情况,尤其在序列突变的位置。我的做法是在损失函数里加一个边界惩罚项:
boundaryPenalty = 10 * mean(max(0, lower - upper), 'all');这个惩罚系数我一般取10到20之间。太大会强行把区间压得过宽,太小则起不到约束作用。如果发现加了惩罚后PINAW明显变大,先把惩罚系数减半,再看PICP有没有相应改善。这三个检查做完,源码才算真正跑通——用你不会踩数据泄漏的视觉方法,替换掉"跑完就是胜利"的惯性,在区间预测这条路上能少返工半年。
本文还有配套的精品资源,点击获取