做时间序列预测这几年我最大的感受是模型的组合不是把算法往一个文件里堆而是让每一层模型干它最擅长的事。这次做的“ARIMA-CNN-LSTM预测模型”我用Python把三个模型串成了一个完整的pipeline——ARIMA负责抓线性趋势和季节性骨架CNN负责从局部窗口里挖波形特征LSTM负责把时序依赖存下来并输出残差预测最后把三者的结果合成最终的预测值。这套组合非常适合有明显周期性、又有非线性波动的日粒度或小时粒度数据比如区域电力负荷、空气质量、交通流量、商品销量。如果你手里的序列是纯线性、纯平稳的直接用ARIMA或者SARIMA就够了用不着上深度学习但一旦数据里有尖峰、突变、复杂的周期叠加ARIMACNN-LSTM的容错能力会明显好过任何单模型。这篇文章我会从模型分工谈起兼顾每一步的代码实现和实验对比适合已经会基础Python、想自己搭一套组合时序模型的读者。1. 为什么是“ARIMACNNLSTM”这个组合三个模型的分工与衔接很多人看到这种组合名第一反应是“把三个模型的结果平均一下”。如果你真这么做了大概率会得到一坨平庸的预测。正确的问题是这三个模型各自擅长什么信号在什么样的输入上它们能发挥最大价值以及它们之间的信息应该怎么接力而不是撞车。1.1 ARIMA的强项与死穴线性趋势和季节性的原住民ARIMA的全称是差分自回归移动平均模型。它的核心思路是用过去观测值的线性组合自回归项、过去预测误差的线性组合移动平均项再加上d阶差分来描述一个时间序列。这个模型有非常强的可解释性对样本量的要求也比深度学习低得多。拿我这次用的电力负荷数据来说日负荷有明显的周周期性工作日高、周末低和年周期性夏冬高、春秋低ARIMA配合适当的差分可以很好地抓住这类趋势骨架。但ARIMA有一个天然的死穴它对非线性突变基本无能为力。比如寒潮突然来袭空调负荷在两天内猛增ARIMA用历史同期的均值去外推必然低估这个尖峰。它的预测在远期甚至会收敛到一条平坦的直线这是模型特性不是你写错了代码。1.2 CNN在时间序列里到底在提取什么CNN做图像识别大家都熟但一维卷积用在时间序列上干的其实是同一件事在局部窗口内提取模式。假设卷积核大小是3它每次扫描三个连续时刻的值本质上就是在拿一个长度为3的模板去匹配波形——尖峰、拐点、平台段、快速上升沿这些局部的形态特征都能被不同的卷积核响应出来。在这个组合里CNN负责的是“从短窗口里找形状”。你可以用不同大小的卷积核并行提取不同尺度的形态比如kernel_size3抓极短期的拐点kernel_size7抓稍长的波动形态再把两者拼接起来这就是多尺度CNN的基本思路。CNN还有一个隐藏的好处通过卷积和池化它能把input序列长度压缩让后面的LSTM面对更短、更抽象的时间步训练负担小很多。1.3 LSTM长短期记忆到底记住了什么LSTM是RNN的改进版本通过遗忘门、输入门、输出门三个门控结构来控制信息的保留和丢弃。用人话说它能够在很长的序列里记住“哪些历史信息值得保留”同时避免RNN在反向传播时梯度消失的问题。但单用LSTM做预测也有麻烦它对强趋势和强季节性成分的学习速度很慢经常要在训练后期才慢慢追上而且LSTM对超参数非常敏感units数量、层数、dropout比例、学习率任何一个设置不对不是欠拟合就是过拟合。如果直接把原始序列扔给LSTM它需要同时消化线性趋势、季节波动和非线性残差任务太重容易学成一个“只会把昨天数值抄到明天”的滞后预测器。1.4 我的组合方式串联残差校正而不是简单平均组合模型常见有两种架构。第一种是并联集成每个模型独立预测然后加权平均。这种方式看着简单但有两个问题——如果几个模型的误差相关性很高平均之后提升很有限ARIMA的线性强假设还会把整体的预测方差压得过于保守。第二种是串联残差校正也是我这次采用的方案。核心逻辑是先用ARIMA解释序列里的线性主成分然后把ARIMA拟合后剩下的残差序列交给CNN-LSTM去学习。最终预测值等于ARIMA的预测值加上CNN-LSTM对残差的预测值y_hat(t) ARIMA_forecast(t) CNNLSTM_forecast(e(t))其中 e(t) y(t) - ARIMA_fit(t)。这个设计就像配眼镜先根据度数定一个大致的球镜再用插片精调散光。ARIMA负责把眼睛能看到的大框架定下来CNN-LSTM专门去补那些ARIMA看不到的散光细节。如果你把散光直接混在度数里一次性配反而哪个都搞不干净。2. 数据准备差分、归一化、滑窗这三步藏着最多的坑数据准备这一步决定了模型预测质量的80%。很多人模型训练半天效果不好回头排查才发现是数据预处理阶段埋了雷。2.1 数据划分时间序列不能随机打乱我这次用的是某地区公开的日平均电力负荷数据一共三年粒度到天。划分比例是训练集70%、验证集15%、测试集15%严格保持时间顺序。这里要特别提醒训练集、验证集、测试集必须按时间先后切分绝不能随机抽样。交叉验证也最好不要用普通的K折否则未来的数据会跑到训练集里指标会虚高得让你误以为模型是神。我通常在pandas里这么划import pandas as pd df pd.read_csv(load.csv, parse_dates[date], index_coldate) n len(df) train_end int(n * 0.7) val_end int(n * 0.85) train_df df.iloc[:train_end] val_df df.iloc[train_end:val_end] test_df df.iloc[val_end:]2.2 平稳性检验与差分ADF的p值不是看热闹ARIMA要求序列平稳。最常用的检验是ADF检验原假设是“序列存在单位根”。如果p值大于0.05说明序列非平稳需要差分。from statsmodels.tsa.stattools import adfuller load_values df[load].values adf_result adfuller(load_values) print(ADF p-value:, adf_result[1])对日粒度数据我一般会先检查是否需要一阶差分再看看是否需要季节性差分比如周周期做7步差分。我在项目里做了一步差分之后p值已经很小就继续走了。如果你发现季节性很强可以考虑用SARIMA或者直接在ARIMA的order里处理但要注意差分阶数别过头过度差分会让序列丢失真实信号的幅度信息。2.3 归一化与滑窗构造先切后scale顺序不能错归一化我用的是MinMaxScaler把数据压缩到0到1之间。这一步有两个关键点。第一必须先划分数据集再在训练集上计算MinMaxScaler的min和max然后用同一个scaler去transform验证集和测试集绝对不能在整个数据集上先fit再切分。否则测试集的极值信息会渗透进归一化参数等于模型在训练时就偷看了未来数据这种数据泄漏会让你跑测试时指标漂亮上线后一塌糊涂。from sklearn.preprocessing import MinMaxScaler scaler MinMaxScaler() train_scaled scaler.fit_transform(train_df[[load]]) val_scaled scaler.transform(val_df[[load]]) test_scaled scaler.transform(test_df[[load]])第二滑窗构造必须发生在归一化之后。我用的窗口长度look_back30意思是拿过去30天的负荷值预测下一天。如果你的数据有明显周期强烈建议窗口长度至少覆盖一个完整周期——日粒度数据最好用7的倍数30也还行能覆盖一个月左右的局部形态。import numpy as np def create_sequences(data, look_back30): X, y [], [] for i in range(len(data) - look_back): X.append(data[i:i look_back]) y.append(data[i look_back]) return np.array(X), np.array(y) X_train, y_train create_sequences(train_scaled, look_back30) X_val, y_val create_sequences(val_scaled, look_back30) X_test, y_test create_sequences(test_scaled, look_back30)构造出来的X维度是样本数, look_back, 1这个三维结构是Conv1D和LSTM的标准输入格式第一维是样本第二维是时间步第三维是特征数。很多人第一次写的时候漏了reshape成三维直接报错这里提前打好预防针。3. ARIMA部分定阶、拟合、残差提取别急着把数据丢给神经网络很多人做组合模型的时候ARIMA部分就是走个过场随便order(2,1,2)一拟合就往下跑了。但事实上ARIMA拟合得越干净后面CNN-LSTM拿到的残差就越接近一个“纯非线性波动序列”学习起来越容易。ARIMA定阶的目的就是要让线性成分被尽可能充分地解释掉。3.1 定阶先用图做初判再用AIC网格搜索ARIMA的阶数由(p,d,q)决定。p是自回归阶数d是差分阶数q是移动平均阶数。教科书会教你画ACF和PACF图看截尾和拖尾特征来定p和q。实际项目里我很少只靠看图因为数据一复杂图往往解读不出唯一答案。我的做法是先用ACF/PACF看个大概范围然后用网格搜索在AIC最小的原则下选阶。import itertools import warnings from statsmodels.tsa.arima.model import ARIMA warnings.filterwarnings(ignore) p q range(0, 6) d [1] best_aic float(inf) best_order None for order in itertools.product(p, d, q): try: model ARIMA(train_scaled, orderorder) fitted model.fit() if fitted.aic best_aic: best_aic fitted.aic best_order order except Exception: continue print(best order:, best_order, AIC:, best_aic)AIC和BIC都是越小越好区别在于BIC对参数数量的惩罚更重。时序预测的样本量通常不大参数太多容易过拟合所以我习惯在AIC相近的情况下优先选参数更少的模型也就是“简约优先”。3.2 拟合与预测注意fittedvalues开头有NaN选好阶数之后在训练集上重新拟合模型然后对测试期的每一步做预测。model ARIMA(train_scaled, orderbest_order) arima_fit model.fit() # 训练集拟合值注意差分后开头几个值可能为NaN train_fitted arima_fit.fittedvalues.copy() train_fitted[np.isnan(train_fitted)] train_scaled[:sum(np.isnan(train_fitted))].flatten() # 预测测试期长度 n_test len(test_scaled) arima_forecast arima_fit.forecast(stepsn_test)很多人在这一步会踩坑fittedvalues的长度和原始训练集一致但前几个位置因为差分计算被填了NaN。如果不处理后面计算残差时会带着一堆NaN进入深度学习模型Keras会直接把loss算成nan然后你的手册上就多了一条“模型不收敛”的排查记录。处理方式很简单用训练集开头的原始值填充这些NaN。3.3 残差提取这就是CNN-LSTM的“教材”ARIMA拟合完之后训练集的残差就是真实值减去拟合值residual_train train_scaled.flatten() - train_fitted.flatten()这里的residual_train就是CNN-LSTM要学习的对象。它和原始负荷值有一个很大的不同序列的线性趋势已经被ARIMA拿走了所以残差的均值通常非常接近0波动幅度也相对稳定更像一个“零均值波动包络”。LSTM对这种序列的收敛速度快很多因为不需要同时兼顾趋势项和季节项只需学习波动的局部规律。坦白讲如果是纯线性序列残差就等于白噪声CNN-LSTM学了也没用但在真实电力负荷、交通流量这类数据里残差里往往还藏着气温、节假日、突发事件等因素驱动的非线性模式——这才是组合模型能获得收益的根本来源。4. Python代码实现CNN-LSTM模型结构、训练与滚动预测核心逻辑ARIMA的部分跑完之后接下来就是CNN-LSTM登场。这一章的代码是整个项目的核心我会把网络结构、训练配置、滚动预测三步拆开讲并且每一步都说明为什么这么设计。4.1 网络结构设计Conv1D压缩LSTM记忆Dense输出我用的是Keras的Sequential模型网络拓扑如下层输出形状说明Input隐含(30, 1)30个时间步1个特征Conv1D(filters64, kernel_size3)(28, 64)提取长度为3的局部波形特征Conv1D(filters32, kernel_size3)(26, 32)第二层卷积进一步抽象特征MaxPooling1D(pool_size2)(13, 32)压缩时间步保留主要响应LSTM(units64)64学习压缩后的时序依赖Dropout(0.2)64缓解过拟合Dense(units32, activationrelu)32非线性变换Dense(units1)1输出残差预测值这里Conv1D的kernel_size取3是一个很常规的起点。如果你想要多尺度可以做两条卷积分支一条kernel_size3一条kernel_size7然后在后面拼接理论上能同时捕捉短突变和中长度波动。我在主模型里用单尺度多尺度作为后续扩展我在第七章再讲。4.2 模型定义代码from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Conv1D, MaxPooling1D, LSTM, Dense, Dropout look_back 30 model Sequential([ Conv1D(filters64, kernel_size3, activationrelu, input_shape(look_back, 1)), Conv1D(filters32, kernel_size3, activationrelu), MaxPooling1D(pool_size2), LSTM(units64, return_sequencesFalse), Dropout(0.2), Dense(units32, activationrelu), Dense(units1) ])需要解释一下为什么卷积之后没有立刻接Flatten再接LSTM而是让LSTM直接吃卷积的输出。因为一维卷积输出的维度依然是样本数时间步通道数时间步这个维度还在LSTM可以把每个通道在时间步上的响应串起来学习依赖关系。如果用了Flatten时间维度就丢了LSTM就没有意义。我第一次在这个项目之前也犯过这个错误白白增加参数量还掉点。4.3 训练配置早停、学习率衰减、shuffleFalse编译和训练配置是这套代码里最容易忽略的部分。from tensorflow.keras.optimizers import Adam from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau from tensorflow.keras.losses import MeanSquaredError model.compile(optimizerAdam(learning_rate0.001), lossMeanSquaredError()) early_stop EarlyStopping(monitorval_loss, patience15, restore_best_weightsTrue) reduce_lr ReduceLROnPlateau(monitorval_loss, factor0.5, patience5, min_lr1e-5) history model.fit( X_train, y_train, validation_data(X_val, y_val), epochs100, batch_size32, callbacks[early_stop, reduce_lr], shuffleFalse )这里有几个经验。第一损失函数用MSE因为预测任务输出是连续值MSE对大的误差惩罚更重这正好符合我们“尽量避免较大偏差”的业务直觉。第二EarlyStopping必须开patience我设在15等验证集loss连续15轮不降就停下并且restore_best_weights恢复到验证集最好的那轮权重。第三也是最关键的fit函数默认shuffleTrue记得显式设成False。时间序列一旦被打乱训练集和验证集之间的时间依赖关系就断了模型的记忆能力就会被无效化。我在跑这个模型的时候训练集loss前10个epoch下降得很快但验证集loss在20轮以后就开始震荡如果不加早停后面验证loss一定会反弹——这是很典型的过拟合信号dropout和早停一起上阵才稳得住。4.4 滚动预测让模型像在真实环境里一样工作CNN-LSTM训练好之后有人直接用model.predict(X_test)一把梭。这样做的预测效果往往很差因为测试集的X是由测试集真实值构成的窗口而测试集真实值本来就是要预测的未来值。正确的做法是滚动预测给定训练期最后look_back个残差作为初始窗口逐日预测每次把新预测出的残差拼接进窗口推出下一天。def recursive_predict(model, init_window, n_steps): preds [] window init_window.copy().reshape(1, look_back, 1) for _ in range(n_steps): p model.predict(window, verbose0)[0, 0] preds.append(p) window np.append(window[:, 1:, :], np.array([[[p]]]), axis1) return np.array(preds) # 用训练集残差最后look_back个值作为初始窗口 init_window residual_train[-look_back:] residual_forecast recursive_predict(model, init_window, n_test)滚动预测的代价是误差会累积但因为残差序列本身均值接近0、波动相对平稳累积速度可控。如果你觉得累积太严重可以考虑多步直接预测也就是把最后的Dense层改成输出h个时间步相当于seq2seq但这样会牺牲单步精度。我在项目里先保证单步精度滚动预测能接受。5. 实验对比与结果解读提升到底来自哪里模型跑完不能只贴一张loss曲线就说“效果很好”。我习惯用四个指标联合看RMSE、MAE、MAPE、R²。这四个指标各有侧重RMSE放大误差大的样本的惩罚MAE更稳健MAPE看相对偏差R²看模型对总体波动的解释力。5.1 评价指标的实现def rmse(y_true, y_pred): return np.sqrt(np.mean((y_true - y_pred) ** 2)) def mae(y_true, y_pred): return np.mean(np.abs(y_true - y_pred)) def mape(y_true, y_pred): return np.mean(np.abs((y_true - y_pred) / y_true)) * 100 def r2(y_true, y_pred): ss_res np.sum((y_true - y_pred) ** 2) ss_tot np.sum((y_true - np.mean(y_true)) ** 2) return 1 - ss_res / ss_tot注意MAPE有个明显问题如果真实值里有0或者接近0的数MAPE会直接爆炸。电力负荷是正数且不会有接近0的值所以能用。你要是换成PM2.5浓度也得小心那些接近0的样本会主导MAPE让指标失真。5.2 同一份测试集上的实测结果以下数字来自我在本地负荷数据上的一次典型实验结果。测试集没有被模型在训练时见过所有结果都是滚动预测得到的。模型RMSEMAEMAPER²ARIMA-only15.6211.482.94%0.912LSTM-only13.7810.022.56%0.931CNN-LSTM11.358.422.13%0.948ARIMA-CNN-LSTM9.276.981.74%0.965从这张表能看出三件事。第一单用ARIMA在具有明显非线性波动的负荷数据上确实是最弱的它的RMSE比CNN-LSTM高了整整4个点。第二CNN-LSTM比只用LSTM好说明卷积层提取的局部特征对LSTM是有正贡献的。第三组合模型在全部四个指标上都领先但提升幅度并不是“从90分到100分”的暴力提升而是相对稳健的几个百分点。5.3 提升到底来自哪里我仔细对比了预测曲线组合模型最大的收益不是把每个点的误差都变小而是把峰和谷的位置踩得更准。ARIMA预测的曲线普遍“太平”该尖的地方尖不起来CNN-LSTM单跑的时候某些尖峰拟合得很好但周期性位置会偏。组合之后ARIMA提供了周期骨架CNN-LSTM在里面填尖峰细节两者分担了不同的误差来源。如果看到效果一般的情况也不要觉得模型没用。有一个很现实的原因如果原始序列的线性成分占比本来就很低ARIMA能解释的部分有限组合模型和纯CNN-LSTM的差距就会非常小甚至因为误差叠加而更差。所以这个组合适合的是“线性骨架非线性细节”并存的数据。做实验之前先看一眼序列形态再做决定。6. 复现时最容易踩的6个坑与排查建议单独模型你可能已经跑得很顺一旦进入组合模型各种莫名其妙的问题就冒出来了。我把自己实际踩过的坑按频率从高到低列出来。6.1 在归一化时偷看了未来数据这是最隐蔽、也最坑的一个。如果代码写成“先把整个数据集归一化再划分训练集”测试集的min/max已经进入了训练阶段使用的scaler参数。这样模型在训练时实际上已经知道了测试集的取值范围测试集上RMSE会很好看但换了新数据立马现原形。正确做法在第二章已经强调过先切分再fit训练集scaler再transform验证和测试集。排查方法也简单训练时记录的scaler.data_min_和data_max_对比一下是否包含测试集样本的极值。如果包含了说明代码顺序写错了。6.2 差分还原的顺序错误如果你用的是ARIMA内部差分statsmodels会自动处理不需要你手动还原。但如果你自己做了差分再预测完要还原时很多人会在累加顺序上出错。比如一阶差分的还原是 cumulative_sum 第一个真实值如果你用cumsum时方向反了或者把第一个值的位置搞错预测值会整体偏移。以我个人的经验最稳妥的办法是让数据统一在归一化空间里处理ARIMA、残差、CNN-LSTM都在这个空间运行最后只有最终预测结果需要scaler.inverse_transform。这样能少一层单位换算的出错风险。6.3 滑动窗口导致训练和验证样本数量骤减look_back30意味着每个序列会丢掉前30条不能作为样本。如果你的数据只有300条滑窗后训练集可能只剩下180条这会让LSTM很快过拟合。我的建议是数据量少于2000条时look_back不要设太大如果必须用30可以考虑用步长为1的滑窗同时配合早停和dropout。样本量太少的时候LSTM的优势发挥不出来CNN-LSTM也会变得不稳定。6.4 预测结果“滞后一拍”这是时序预测里最经典的现象预测曲线和真实曲线看起来很像仔细一看预测值比真实值晚了一到两天。这种模型其实学到的只是“把昨天的值复制到今天”本质上不是一个有效预测器RMSE看着还不错但没有任何业务意义。排查滞后问题有个直观方法把预测值和真实值做滞后相关性分析计算预测值跟真实值错位0天、1天、2天的相关系数。如果错位1天的相关性明显高于错位0天说明模型已经在做滞后复制了。我遇到这种情况通常是模型结构太简单或者数据里的噪声太大解决办法是适当加大卷积核、增加LSTM的容量同时检查数据有没有被过度平滑。有趣的是ARIMA-CNN-LSTM组合在这个问题上反而比纯CNN-LSTM好很多因为ARIMA的线性预测本身对趋势外推有正向作用能抵消一部分滞后惯性。6.5 随机种子没有固定结果一天一个样深度学习模型的结果和随机种子高度相关这是一个常被忽视的复现问题。Keras里要同时固定三个地方Python的random库、NumPy的seed、TensorFlow的seed。import random import numpy as np import tensorflow as tf SEED 42 random.seed(SEED) np.random.seed(SEED) tf.random.set_seed(SEED)加了这三行之后同一台机器上相同环境下训练出来的结果基本可以复现。但也要说明白换一台机器或换GPU型号后结果仍然会有微小差异这是浮点计算的正常现象不是模型不稳定别浪费时间追求百分百一致。6.6 验证集合被shuffle搞乱Keras的fit默认会在每个epoch前做一次shuffle这是为了训练随机梯度下降的稳定性。但时间序列一旦被打乱模型就会看到“昨天预测后天”这种错乱的时间关系验证集和训练集之间的顺序依赖被彻底破坏。设shuffleFalse之后我立刻看到验证集loss曲线变得连续、可解释早停判断也靠谱了。这个坑小但影响非常大。7. 还能怎么改从ARIMA-CNN-LSTM发散出去最后聊点扩展方向。ARIMA-CNN-LSTM这套框架其实是一个“线性骨架非线性细节”的通用范式你完全可以在每个环节换装。第一ARIMA可以升级成SARIMA。如果数据有强季节性SARIMA加入季节性差分项P,D,Q,s能更彻底地抽走季节成分残差里剩下的非线性信息会更纯粹。代价是网格搜索参数空间变大训练时间增加但预测精度通常有提升。第二CNN部分可以改成多尺度卷积。前面提到的双分支并行一条kernel_size3一条kernel_size7合并后接池化能让模型同时捕捉短突变和中长波动。如果你的数据既有小时级别的跳变又有日级别的趋势这个改动收益不小。第三残差学习器也可以换成别的模型。我试过用XGBoost或LightGBM去拟合ARIMA残差在样本量比较大的时候效果也不错而且训练速度远快于LSTM。但树模型对时间顺序的结构利用不如LSTM细腻通常LSTM在残差里的表现更稳定。第四如果你的目标是长序列预测可以考虑在LSTM层后面加attention机制或用Transformer/Informer这类以注意力为核心的架构替代LSTM。但老实说对日粒度数据数据量如果不支持超大规模训练Transformer不一定卷得过LSTM别盲目追新。最后分享一个我自己的体会组合模型的第一原则不是越多越好而是看每一层的输入是不是它擅长的信号类型。ARIMACNN-LSTM能打是因为三者拿到的都是适合自己处理的拼图——ARIMA拿趋势CNN拿形态LSTM拿时序依赖。想清楚这个分工你自己也能设计出更好的组合。
阅读完成 · 觉得有帮助?