我做多变量时序预测有几年了,最近把一个预测项目整体改造成了“区间概率预测”,效果比原来单纯给一个数实用得多。这套方案的核心链路是 PSO-CNN-RF-ABKDE:CNN 负责从多变量时间序列里提特征,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.4,c1、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、学习率
- RF:max_depth、min_samples_leaf、max_features、n_estimators
- ABKDE:带宽缩放系数、分桶数量
每一维的取值范围要基于经验和计算资源预先设好。比如学习率从 1e-4 到 1e-2 取对数尺度,而不是线性尺度,因为学习率对模型性能的影响近乎是指数级别的。卷积核大小我限制在 3 到 7,再大在这个数据集上不会带来明显收益。RF 的 n_estimators 我限制在 100 到 400,再高只增加训练时间。
目标函数是整个 PSO 的指挥棒,选什么决定最终模型气质。如果只优化 RMSE,PSO 搜出来的模型会拼命追求点预测准确,但区间质量可能很差。我做区间预测时用的是分位数损失(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(shape=input_shape) x = Conv1D(filters=64, kernel_size=3, padding='same', activation='relu')(inp) x = BatchNormalization()(x) x = MaxPooling1D(pool_size=2)(x) x = Dropout(0.2)(x) x = Conv1D(filters=32, kernel_size=3, padding='same', activation='relu')(x) x = GlobalAveragePooling1D()(x) return Model(inputs=inp, outputs=x)先用带 Dense 头的模型训练特征提取器:
def build_cnn_regressor(feature_extractor): out = Dense(1)(feature_extractor.output) model = Model(inputs=feature_extractor.input, outputs=out) model.compile(optimizer=Adam(0.001), loss='mse') return model cnn = build_cnn_regressor(feature_extractor) cnn.fit(X_train, y_train, validation_data=(X_val, y_val), epochs=30, batch_size=64, verbose=0)然后冻结 CNN,提取特征后训练 RF:
from 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_estimators=200, max_depth=10, min_samples_leaf=3, max_features='sqrt' ) 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_estimators=params['n_estimators'], max_depth=params['max_depth'], min_samples_leaf=params['min_samples_leaf'], max_features=params['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_particles=12, n_iter=20, dim=8): 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, alpha=0.1, n_bins=5): # 按预测值分位数分桶 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_method='scott') 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 的核心不在核函数选得多花哨,而在“自适应”三个字——误差分布会随着预测场景变化,区间建模也必须跟着变化,这才是概率预测能落地的根本原因。