我做多变量时序预测有几年了最近把一个预测项目整体改造成了“区间概率预测”效果比原来单纯给一个数实用得多。这套方案的核心链路是 PSO-CNN-RF-ABKDECNN 负责从多变量时间序列里提特征RF 负责回归预测PSO 负责全局寻优ABKDE 负责把预测误差变成概率区间。今天把整个项目的套路、代码骨架和踩过的坑都摊开讲一遍适合已经在做时序预测、但对概率区间建模还不太熟悉的同学。很多人刚听到“区间概率预测”会觉得是把预测值上下加减一个误差其实没那么简单。真正的区间预测要回答的是未来值最可能落在哪个范围里以及这个范围在多大置信度下成立。为了把这句话落地我把项目拆成两半前半段用 CNN-RF 拿一个尽量准的点预测后半段用 ABKDE 对预测残差做密度估计最后在点预测两侧生成区间。PSO 则贯穿前后负责把模型超参和密度估计参数一起优化。整体不复杂但每一步都有坑下面按我的项目实施顺序来写。1. 项目定位为什么要做区间预测而不是单点预测1.1 单点预测的痛点和区间预测的价值先讲一个实际场景。早前做设备负荷预测业务方拿到“明天最大负荷 1000 千瓦”之后还是不知道怎么排产因为没人告诉他们这个 1000 千瓦有多可靠。如果实际值是 1200直接超限是 900备料又多备了。单点预测最大的问题是把不确定性问题伪装成了确定性问题而现实中时序预测的误差从来不是均匀的工作日和节假日的误差分布就明显不一样。区间概率预测给的不再是一个点而是一个带概率边界的范围比如“90% 概率明天的负荷在 930 到 1080 之间”。业务方看到这个区间后可以按最坏情况准备容量也可以按期望值优化成本。做这类项目的核心目标不是把 RMSE 压到多漂亮而是要拿到一个可靠的、宽度尽可能窄的区间并且这个区间的实际覆盖率要和宣称置信度对得上。这也是为什么我最后选择用 ABKDE而不是简单用训练集误差的均值加减标准差。1.2 多变量时序输入到底在预测什么这个项目名称里的“多变量”指的是输入不只有目标序列本身而是多个外部变量一起进模型。比如预测设备温度除了历史温度还可以把转速、负载、环境温度、运行时长都作为输入。这样做的意义在于单变量时间序列能提供的有效信息有限很多突变靠历史目标值本身根本看不出来必须依赖其他变量的联动变化。实际输入一般组织成三维矩阵样本数 × 时间窗口长度 × 变量数。输出可以是单步的也可以是未来多步。比如用过去 24 小时的 8 个变量预测下一小时的温度那输入形状就是 (batch_size, 24, 8)输出形状是 (batch_size, 1)。如果预测未来 4 小时输出还可以改成 (batch_size, 4)。需要注意变量之间量纲差异很大后续做归一化时必须逐列处理不能让负载量级淹没温度变化。2. 模型框架拆解四个模块各司其职2.1 PSO把超参数搜索变成群体寻优PSO 粒子群优化算法的思路很直观。想象一群鸟在空间里找食物每只鸟记住自己找到过的最好位置同时也知道种群当前找到的最好位置下一次飞行的方向由这两个位置共同引导。对应到超参数搜索每个粒子就是一组超参数组合位置坐标是超参数取值适应度就是验证集上的目标 loss。粒子每一轮的更新公式是v w * v c1 * r1 * (pbest - x) c2 * r2 * (gbest - x) x x v其中 w 是惯性权重控制上一轮速度的保留程度c1、c2 是认知和社会学习因子r1、r2 是随机数避免粒子陷在局部最优。我通常取 w 从 0.9 衰减到 0.4c1、c2 都取 1.5 左右。这个配置不是我拍脑袋定的而是对比了几组参数后得到的稳当选择w 太大粒子容易飞出去w 太小又容易早熟线性衰减是成本最低的折中方案。用 PSO 不是因为 GridSearch 不能用而是这个项目里 CNN 和 RF 加起来的超参数组合太多了。网格搜索在这种高维组合面前根本算不动随机搜索又完全不利用已经尝试过的结果。PSO 在每一代更新时会保留 pbest 和 gbest 的“记忆”同等迭代次数下更容易找到好的超参数区域。而且 PSO 天然支持并行每个粒子的评估是互相独立的把种群数设到 12 到 20配合多进程训练时间可以压下来不少。2.2 CNN从多变量时序里提局部特征CNN 通常被大家联想到图像但 1D CNN 在时间序列上同样管用。它的卷积核沿时间轴滑动每次看一小段窗口内的多个变量相当于在不同时间尺度上做局部模式匹配。比如连续 3 个时刻的转速和温度同时上升这个局部组合可能预示后面负荷要涨普通全连接网络很难自动捕捉这类跨变量的短时序组合。实现时我用两层 Conv1D 加 GlobalAveragePooling1D。第一层用 64 个尺寸为 3 的卷积核第二层用 32 个尺寸为 3 的卷积核中间插 MaxPooling 和 Dropout。为什么用卷积核 3 而不是 5因为我的数据采样频率高短时间内的大范围滑动窗口容易把噪声也学进去小卷积核堆叠实际上扩大了感受野但参数更少训练更稳。CNN 在我这里不是最终预测器而是特征提取器。我先把 CNN 接到一个 Dense 输出层训练让卷积层学会提取和预测目标最相关的表示然后把这些表示作为新的特征输入随机森林。这么做的好处是 RF 不用直接面对原始时间序列输入维度大幅下降训练速度快很多而且 CNN 的平移不变性也能让特征对时序中的小偏移不那么敏感。2.3 RF非线性回归主体随机森林在这里承担的是最终回归任务。有人可能会问CNN 后面再接两个全连接层不就行了吗为什么绕一圈用 RF我的经验和教训是全连接层在小样本时序任务里非常容易过拟合而且对输入特征的尺度很敏感。RF 则不一样它是由多棵决策树投票组成的集成模型对异常值不敏感能处理非线性关系也不太需要做太多特征工程。RF 真正需要调的超参数不多主要就是 n_estimators、max_depth、min_samples_leaf 和 max_features。很多人一上来就堆 n_estimators恨不得设成 1000实际上树多了以后边际收益很小反而训练和推理都变慢。我更关注 max_depth 和 min_samples_leaf这两个直接决定树会不会过拟合。PSO 搜索时我也是把这两个参数放在更靠前的优先级上。CNN 和 RF 的衔接有个关键点提取特征时必须用同一套标准化参数和同一套 CNN 权重。我先把模型在训练集上训好冻结所有 CNN 权重再用它同时转换训练集、验证集和测试集最后把三组特征分别交给 RF。如果每次训练都让 CNN 重新更新特征分布一直在变RF 看到的特征空间不稳定预测结果会很飘。2.4 ABKDE把残差变成概率区间ABKDE 这个名字看着唬人拆开就是 Adaptive Bandwidth Kernel Density Estimation自适应带宽核密度估计。它的作用是对预测残差建模从而得到每个预测值附近的误差分布。先说 KDE 核密度估计。简单理解就是不假设残差服从正态分布而是把一堆历史残差当成“样本点”用核函数在每一个样本点附近画一个小山包把所有山包叠加起来形成一个平滑的概率密度曲线。常见核函数是高斯核曲线形状会随着带宽变大变平滑随着带宽变小变得尖锐。固定带宽的问题在于时序预测残差往往不是同方差的。低值段的误差很小高值段的误差很大如果拿一个固定带宽去描述所有残差区间要么低值段太宽要么高值段太窄。ABKDE 的做法是让带宽随样本密度或预测值大小自适应变化预测值处于残差密集区域时带宽小一些保留更多细节处于稀疏区域时带宽大一些避免密度估计出现太多毛刺。实际操作中我会按预测值分桶在同一个桶内取残差再根据该桶的样本量和标准差调整带宽这样得到的误差分布更贴近真实情况。区间生成就简单了先用模型得到预测值 y_pred再用 ABKDE 估计残差分布的分位数比如要 90% 置信区间就取残差分布的 5% 和 95% 分位点分别加到 y_pred 上。这样输出的区间天然反映了“模型在哪里更自信”。3. 核心细节与实操要点3.1 数据处理滑窗、归一化、时序拆分怎么做数据预处理是这类项目里最容易翻车的地方。第一步是生成滑窗样本。给定原始多变量数据 data窗口长度 n_steps 表示用过去多少步预测未来 horizon 步。我常用函数如下def make_windows(data, target_col, n_steps, horizon): X, y [], [] for i in range(len(data) - n_steps - horizon 1): X.append(data[i:i n_steps, :]) y.append(data[i n_steps:i n_steps horizon, target_col]) return np.array(X), np.array(y)滑窗步长如果为 1会产生大量高度重叠的样本。这对训练集本身不是问题但划分验证集和测试集时一定要先按时间切分再去建窗。否则训练集和验证集里会出现几乎一样的窗口验证效果虚高上线后直接打回原形。归一化也要小心。很多人直接用 sklearn 的 StandardScaler 对全量数据 fit这相当于让模型在训练时偷看了验证集和测试集的均值方差会造成轻微但真实的信息泄漏。正确做法是先切分数据再只在训练集上 fit 标准化器然后用同一个标准化器转换验证集和测试集。每个变量单独归一化且目标变量如果要做预测也必须和目标变量本身的归一化保持一一对应。3.2 PSO 搜索空间和目标函数怎么定PSO 的搜索空间需要先编码。我会把参数定义成独立向量每个维度做归一化到 [0, 1]然后解码成真实参数CNN卷积核数量、卷积核大小、Dropout、学习率RFmax_depth、min_samples_leaf、max_features、n_estimatorsABKDE带宽缩放系数、分桶数量每一维的取值范围要基于经验和计算资源预先设好。比如学习率从 1e-4 到 1e-2 取对数尺度而不是线性尺度因为学习率对模型性能的影响近乎是指数级别的。卷积核大小我限制在 3 到 7再大在这个数据集上不会带来明显收益。RF 的 n_estimators 我限制在 100 到 400再高只增加训练时间。目标函数是整个 PSO 的指挥棒选什么决定最终模型气质。如果只优化 RMSEPSO 搜出来的模型会拼命追求点预测准确但区间质量可能很差。我做区间预测时用的是分位数损失pinball loss作为目标把多个分位点的损失加起来如 0.05、0.5、0.95 三个分位点的平均损失。分位数损失公式是L(y, y_hat, tau) (y - y_hat) * tau if y y_hat else (y_hat - y) * (1 - tau)这个函数同时对预测点和区间边界进行约束。0.5 分位点对应中位数预测0.05 和 0.95 对应区间下上界三者一起优化得到的模型不会只盯着中心点而忽略误差分布。3.3 验证策略为什么不能用随机 K 折时序数据和普通表格数据最大的区别是时间顺序本身带有信息。随机 K 折交叉验证会把未来的样本混进训练集模型相当于“穿越”到过去做预测测试结果必然虚高。我在项目里用的是 walk-forward 滚动验证。sklearn 的 TimeSeriesSplit 可以按时间顺序切出多折每折用过去数据训练未来数据验证和真实上线场景更接近。用 PSO 时适应度评估也必须走同一套时序划分。我在 PSO 内部固定了一个验证窗口每一代每个粒子都在这同一个窗口上打分数。这样才能保证粒子之间的比较是公平的。如果每次评估都用不同的随机划分PSO 的适应度噪声会特别大粒子会在几个差不多的区域反复震荡收敛得很慢。4. 实操过程与核心代码实现4.1 环境准备这个项目的依赖并不复杂核心库是 TensorFlow/Keras、scikit-learn、scipy、numpy、pandas。我用 Keras 是因为搭 CNN 代码短调试起来快PyTorch 也可以只是训练循环要多写不少样板代码。安装命令pip install numpy pandas scikit-learn scipy tensorflow4.2 搭建 CNN 特征提取器和 RF 回归器先建 CNN 特征提取器。注意这里返回的不是最终预测而是中间特征向量from tensorflow.keras.layers import Input, Conv1D, MaxPooling1D, Dropout, GlobalAveragePooling1D, Dense, BatchNormalization from tensorflow.keras.models import Model from tensorflow.keras.optimizers import Adam def build_feature_extractor(input_shape): inp Input(shapeinput_shape) x Conv1D(filters64, kernel_size3, paddingsame, activationrelu)(inp) x BatchNormalization()(x) x MaxPooling1D(pool_size2)(x) x Dropout(0.2)(x) x Conv1D(filters32, kernel_size3, paddingsame, activationrelu)(x) x GlobalAveragePooling1D()(x) return Model(inputsinp, outputsx)先用带 Dense 头的模型训练特征提取器def build_cnn_regressor(feature_extractor): out Dense(1)(feature_extractor.output) model Model(inputsfeature_extractor.input, outputsout) model.compile(optimizerAdam(0.001), lossmse) return model cnn build_cnn_regressor(feature_extractor) cnn.fit(X_train, y_train, validation_data(X_val, y_val), epochs30, batch_size64, verbose0)然后冻结 CNN提取特征后训练 RFfrom sklearn.ensemble import RandomForestRegressor fe feature_extractor.predict(X_train).reshape(len(X_train), -1) fe_val feature_extractor.predict(X_val).reshape(len(X_val), -1) rf RandomForestRegressor( n_estimators200, max_depth10, min_samples_leaf3, max_featuressqrt ) rf.fit(fe, y_train) y_val_pred rf.predict(fe_val)特征提取器在 PSO 里每次都要重新训练因此模型不宜太深。上面这个两层卷积结构已经能给出不错的特征同时单次训练控制在半分钟内适合和 PSO 配合使用。4.3 PSO 优化循环骨架PSO 的完整代码不短我贴一个最核心的骨架。重点是适应度函数里要包含“重建 CNN 转换特征 训练 RF 计算分位数损失”这一整套流程。import numpy as np class Particle: def __init__(self, dim): self.pos np.random.rand(dim) self.vel np.random.uniform(-0.1, 0.1, dim) self.pbest_pos self.pos.copy() self.pbest_score float(inf) def objective(x): params decode_params(x) # 解码超参 cnn build_and_train_cnn(params, X_train, y_train) fe_train cnn.predict(X_train).reshape(len(X_train), -1) fe_val cnn.predict(X_val).reshape(len(X_val), -1) rf RandomForestRegressor( n_estimatorsparams[n_estimators], max_depthparams[max_depth], min_samples_leafparams[min_samples_leaf], max_featuresparams[max_features] ) rf.fit(fe_train, y_train) y_val_pred rf.predict(fe_val) return pinball_loss(y_val, y_val_pred, [0.05, 0.5, 0.95]) def pso(n_particles12, n_iter20, dim8): particles [Particle(dim) for _ in range(n_particles)] gbest_pos None gbest_score float(inf) for it in range(n_iter): for p in particles: score objective(p.pos) if score p.pbest_score: p.pbest_score score p.pbest_pos p.pos.copy() if score gbest_score: gbest_score score gbest_pos p.pos.copy() w 0.9 - 0.5 * it / n_iter c1, c2 1.5, 1.5 for p in particles: r1, r2 np.random.rand(dim), np.random.rand(dim) p.vel w * p.vel c1 * r1 * (p.pbest_pos - p.pos) c2 * r2 * (gbest_pos - p.pos) p.pos np.clip(p.pos p.vel, 0, 1) return gbest_pos注意我每次迭代都对粒子位置做了 clip把位置限制在 [0, 1] 范围内避免某一维解出超参范围。实际运行时pso 每评估一个粒子都要重训 CNN所以建议用 multiprocessing 把种群并行掉或者适当调小种群和迭代次数。我一般先跑一个小种群确认目标函数没有 bug再放成 12 粒子跑 20 代。4.4 用 ABKDE 生成预测区间有了 RF 的点预测后区间部分我按“预测值分桶 核密度估计 分位数提取”来实现。分桶是为了让不同预测水平的残差分布分开避免高值段和低值段共用一套误差。from scipy.stats import gaussian_kde def abkde_interval(y_true_val, y_pred_val, y_pred_test, alpha0.1, n_bins5): # 按预测值分位数分桶 edges np.quantile(y_pred_val, np.linspace(0, 1, n_bins 1)) bins np.digitize(y_pred_val, edges[1:-1]) test_bins np.digitize(y_pred_test, edges[1:-1]) lower, upper [], [] for i, pred in enumerate(y_pred_test): resid y_true_val[bins test_bins[i]] - y_pred_val[bins test_bins[i]] if len(resid) 20: resid y_true_val - y_pred_val # 兜底样本太少就退回全局残差 kde gaussian_kde(resid, bw_methodscott) grid np.linspace(resid.min(), resid.max(), 500) cdf np.cumsum(kde(grid)) cdf / cdf[-1] q_low, q_high np.interp([alpha / 2, 1 - alpha / 2], cdf, grid) lower.append(pred q_low) upper.append(pred q_high) return np.array(lower), np.array(upper)如果要更严格的“自适应带宽”可以把bw_method改为1.06 * resid.std() * len(resid) ** (-0.2)这就是基于样本标准差和样本量的自适应调整。样本量越大带宽越小残差波动越大带宽越大。ABKDE 本质上就是这句话的数学化表达。区间质量我用三个指标一起看PICP真实值落在区间内的比例目标是接近 1 - alpha。PINAW平均区间宽度除以目标变量取值范围越小越好。Pinball 平均损失同时惩罚漏报和区间过宽。如果 PICP 已经达标但 PINAW 太大说明带宽选粗了模型有信心区分不同误差场景分桶后应当让区间更细如果 PICP 不够说明残差分布估计得太乐观需要扩大带宽或者增加分桶数。5. 常见问题与排查技巧实录5.1 PSO 跑完一次结果完全不同这个现象很常见。CNN 训练本身有随机性Keras 又依赖底层数值库GPU 上的结果甚至可能因为并行原因无法完全复现。另外RF 的特征重要性也会因为每次 CNN 初始权重不同而有差异。我的处理办法是在 PSO 之前先固定随机种子import os, random as rn import numpy as np import tensorflow as tf os.environ[PYTHONHASHSEED] 0 np.random.seed(42) rn.seed(42) tf.random.set_seed(42)还要确保数据划分顺序固定。如果结果仍然波动明显可能是适应度函数本身噪声太大。我会把每个粒子评估两次取平均或者改用 walk-forward 中固定验证集而不是随机子集。稳定性比单次精度更重要否则 PSO 搜出来的超参数没有可比性。5.2 区间覆盖率始终不够区间预测最尴尬的就是宣称 90%实际只有 70%。出现这种情况我第一反应不是调整 ABKDE 带宽而是先看误差分布是不是随时间变化。如果模型只在某个时段效果差残差整体被“平均”掉了任何单一分位数都会低估尾部风险。解决思路是把残差建模条件化按小时、星期、预测值大小分桶分别建区间。比如白天和夜间的误差分布明显不同那就分开建 KDE。分桶后覆盖率通常会回升但代价是每个桶的样本量变少需要保证至少几十个残差样本才能稳定估计。最后还有一个兜底校准方法在验证集上算一个缩放系数把区间宽度统一乘以这个系数用二分搜索找到让 PICP 恰好达到目标置信度的缩放因子。这个方法本质上是对区间做 conformal calibration虽然简单但在业务上线前特别管用。5.3 CNN 特征提取器训练不稳定我在初期踩过一个坑为了让 CNN 的 Dense 头收敛把学习率设得很大结果特征提取器输出分布漂移很快后面 RF 的输入每次都不同预测值像在抖动。后来我把学习率调到 1e-3 到 1e-4 之间并加了 BatchNormalization。Feature extractor 的输出最好做一次标准化后再交给 RF因为 RF 虽然对尺度不敏感但稳定的输入分布能让树的划分更可靠。训练 CNN 时 batch size 也别太大。多变量时序样本重叠度高如果 batch size 太大一个 batch 里可能出现大量重复信息梯度方向偏置明显。我用 64 或者 32比 256 更稳。5.4 常见问题速查表现象可能原因处理办法PSO 搜索时间长每次评估都重训 CNN减小 CNN 深度、加并行、限制粒子数区间太宽残差分布跨度过大按预测值/时段分桶分桶内 KDE区间太窄训练残差低估测试误差用验证集校准缩放系数覆盖率达标但业务不可用PINAW 过大降低置信度或用更细粒度分桶RF 特征不稳定CNN 权重没冻结固定 CNN 权重后一次性生成所有特征6. 最后说点我自己的体会这类“PSO 调超参 CNN 提特征 RF 做回归 ABKDE 出区间”的组合看起来模块很多容易让人产生“只要模型够复杂就能解决问题”的错觉。实际跑下来我发现真正决定区间质量的反而不是模型多深而是残差建模和验证策略是否扎实。PSO 和 CNN 再强如果训练集和验证集之间发生时间泄漏或者残差用所有样本一刀切区间覆盖率一定好看不了。如果只让我留一条经验我会说先把点预测做成稳定可复现的基线再单独建残差模型最后才引入 PSO 去调所有模块。顺序反了你会分不清模型变好到底是因为超参更优还是残差模块碰巧处理得更合理。另外ABKDE 的核心不在核函数选得多花哨而在“自适应”三个字——误差分布会随着预测场景变化区间建模也必须跟着变化这才是概率预测能落地的根本原因。