做时间序列预测的人,多少都被同一个问题反复折磨过:预报结果在均线附近跑得挺准,一到拐点、波动大的区间就开始离谱。我前几年在做电力负荷序列、流量序列这类数据时,单用传统统计模型和单用深度学习模型都试过,各有各的死穴。后来把ARIMA、CNN、LSTM三个方向捏在一起做了一套组合预测框架,用Python从数据处理到模型训练完整跑通,效果比任何一个单一模型都稳。这篇文章就把这套ARIMA-CNN-LSTM 预测模型的完整思路和代码实现拆开讲,适合已经会基础 Python、想认真搞时序预测落地的人参考。
需要说明的是,这篇不是学术论文复现,而是从一个工程应用角度的总结。数据可以是电力负荷、流量、销量、气温这类典型的时间序列;我项目里用的是一段较长区间的电力负荷日均数据,公共数据集也能直接套用。整个过程分四块来讲:模型选型的底层逻辑、网络架构设计、Python 代码实现细节,以及我实际调参时踩过的坑。
1. 为什么要把 ARIMA、CNN、LSTM 三种模型揉在一起
1.1 三种模型各自的脾气和局限
很多刚接触时序预测的人会纠结一个问题:"用一个模型不就够了吗?" 答案是:看数据。
ARIMA是统计学派的老将,核心思路用一句话概括——把时间序列里的趋势、季节性、随机波动拆开,再用自回归和移动平均去拟合。它的强项是处理线性关系平稳序列,训练快、可解释性强。但它的短板同样明显:对非线性模式基本无能为力,面对突变、拐点、复杂周期性时表现很一般。
LSTM(长短期记忆网络)是循环神经网络的一种改进结构,专门设计来解决长时间依赖问题。它内部有输入门、遗忘门、输出门,可以决定哪些历史信息要记住、哪些要丢掉。对时间序列的长期依赖捕捉能力很强,这是它的核心价值。
CNN(卷积神经网络)本来用于图像识别,但一维卷积用在时序上也是一把好手。它的卷积核可以自动提取局部特征,类似用一个滑动窗口去扫描数据里的短模式。这一层相当于给 LSTM 做了一次"特征预提取",帮忙把局部异常、突变形态识别出来,再交给 LSTM 去捕捉时间上的依赖。
你如果只用 LSTM,网络要同时承担"特征提取"和"时间依赖建模"两个任务,容易顾此失彼。如果只用 ARIMA,面对复杂非线性数据时残差会很大。组合的核心逻辑不是堆模型,而是让每个模型去干自己最擅长的事。
1.2 组合模型的真实优势——互补效应
我在项目里最初尝试的是直接粗暴的"ARIMA 预测 + LSTM 预测 = 加权平均",效果只能说比单个模型好一点点,但没有质的提升。
后来我换了思路:把 ARIMA 当作线性成分的"专用拟合器",先把主趋势和周期性吃掉;再用 CNN-LSTM 对 ARIMA 的残差继续建模。这样神经网络不需要从零学习那些明显的线性规律,把算力集中在更难搞定的残差部分。这种方式在学术上叫"残差学习",实现起来不复杂,效果提升却非常明显。
做个类比:ARIMA 像个负责打地基的施工队,把所有规则性强的部分都安排得明明白白。CNN-LSTM 是装修队,专门处理那些地基解决不了的边角细节。两边各管一摊,互不干扰,最后拼接起来就是一套完整的房子。
还有一种变体思路:把 ARIMA 的预测值作为额外特征拼进 CNN-LSTM 的输入。这个方案也行,但我觉得会增加神经网络的输入维度,效果提升有限,不如残差学习来得直接。后面代码部分我会重点讲残差学习的实现方式。
2. 模型设计:整体架构与数据流
2.1 架构选型:残差融合方案的完整数据流
我这套方案叫ARIMA-CNN-LSTM 残差融合预测模型,整个数据流向是这样的:
- 原始时间序列先按时间顺序切分训练集和测试集。
- 训练集部分先做平稳性检验,确定 ARIMA 的差分阶数 d。
- 用 ARIMA 拟合训练集,得到趋势预测值。
- 计算训练集残差:
residual = true_value - arima_prediction。 - 将残差序列作为 CNN-LSTM 的目标值,用滑动窗口构造特征矩阵。
- 训练 CNN-LSTM 学习残差模式。
- 预测阶段:
最终预测 = ARIMA预测 + CNN-LSTM残差预测。
这套架构下,两个模型各司其职。ARIMA 部分我用的是statsmodels库,CNN-LSTM 用的tensorflow/keras。整个流程在代码层面是解耦的,调试时可以先单独验证 ARIMA 的效果(看残差的均值、方差是否接近白噪声),再验证残差预测部分是否有效。
2.2 数据准备:差分处理与时间窗口构造
数据进入模型之前有两个关键环节:平稳化和窗口化。
ARIMA 要求序列平稳,所以要用 ADF 检验(Augmented Dickey-Fuller test)来判断。如果 p 值大于 0.05,说明序列不平稳,需要差分。我项目里的电力负荷数据本身有明显的日周期性和周周期性,直接做一阶差分后 ADF 的 p 值降到了 0.01 以下,基本满足要求。这里的 d 参数就是差分阶数,我建议从 1 开始试,不要过度差分——差分太多次会把有效信息也差掉。
CNN-LSTM 这部分需要把序列转成"特征矩阵"形式,核心参数是时间步长 window。我采用的是通过自相关函数(ACF)图来辅助确定:看自相关系数衰减到置信区间以外的最大滞后阶数,然后把该阶数作为窗口长度。比如我的数据里滞后 72 小时的自相关仍然显著,于是window=72。窗口越大,LSTM 能看到的历史越长,但训练成本也更高,需要平衡。
还有一个容易忽略的操作:归一化必须分训练集和测试集独立进行。先在整个数据集上用 MinMaxScaler 计算最大值最小值,再拿这个 scaler 去 transform 测试集,看着是在省事,其实已经造成了数据泄漏——测试集的信息提前进入了训练环节。正确做法是只在训练集上fit,然后用同一套参数去transform测试集。
2.3 评估指标:MAE、RMSE、MAPE 怎么选
模型好不好,指标要有对比性。我项目里同时保留三个指标:
- MAE(平均绝对误差):直观,但受单位影响。
- RMSE(均方根误差):对大误差更敏感,适合希望避免极端偏差的场景。
- MAPE(平均绝对百分比误差):无量纲,方便不同数据集之间比较,但真实值接近 0 时会失真。
计算公式直接写清楚:
def mae(y_true, y_pred): return np.mean(np.abs(y_true - y_pred)) def rmse(y_true, y_pred): return np.sqrt(np.mean((y_true - y_pred) ** 2)) def mape(y_true, y_pred): return np.mean(np.abs((y_true - y_pred) / y_true)) * 100这三个指标在结果分析时要同时看。如果 RMSE 明显高于 MAE,说明存在几个比较大的误差点,需要进一步检查是不是预测拐点时出了问题。
3. 核心代码实现:从数据加载到模型训练
3.1 数据加载与预处理:完整代码框架
我先给出一个完整的预处理代码框架,这部分是整个项目的地基。这里我用pandas做数据处理,numpy做数值计算,matplotlib做可视化,statsmodels做统计检验与 ARIMA 建模。
import numpy as np import pandas as pd import matplotlib.pyplot as plt from statsmodels.tsa.stattools import adfuller from statsmodels.graphics.tsaplots import plot_acf, plot_pacf from statsmodels.tsa.arima.model import ARIMA from sklearn.preprocessing import MinMaxScaler from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Conv1D, MaxPooling1D, LSTM, Dense, Dropout from tensorflow.keras.callbacks import EarlyStopping from sklearn.metrics import mean_absolute_error, mean_squared_error # 1. 加载数据,假设 data 是一列数值序列 data = pd.read_csv('load_data.csv', parse_dates=['date'], index_col='date') series = data['load'].values.astype(float) # 2. 检查缺失值,简单前向填充 series = pd.Series(series).fillna(method='ffill').values # 3. ADF 平稳性检验 result = adfuller(series) print(f'ADF Statistic: {result[0]:.4f}') print(f'p-value: {result[1]:.4f}')如果 p 值大于 0.05,需要对序列做差分。我在代码里用一个简单的循环来自动确定最小差分阶数:
def find_optimal_d(series, max_d=2): d = 0 temp = series.copy() while d < max_d: result = adfuller(temp) if result[1] < 0.05: break temp = np.diff(temp) d += 1 return d d_order = find_optimal_d(series) print(f'最优差分阶数 d = {d_order}')这一步的意图是:把找最优差分阶数这个重复性工作自动化。但要注意,ADF 检验结果受样本量影响,如果数据量小(比如几百条),检验结论未必可靠,还是要结合肉眼观察序列图和业务经验综合判断。
3.2 ARIMA 建模与残差提取:核心代码与参数选择
确定 d 之后,还需要确定 ARIMA 的 p(自回归阶数)和 q(移动平均阶数)。我通常先用 ACF 图看拖尾还是截尾、用 PACF 图看截尾阶数,再结合 AIC 信息准则进行小范围网格搜索。
# 1. 画 ACF 和 PACF 图辅助定阶 fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 8)) plot_acf(series, lags=40, ax=ax1) plot_pacf(series, lags=40, ax=ax2) plt.show() # 2. 简单网格搜索 p 和 q best_aic = np.inf best_order = None for p in range(0, 5): for q in range(0, 5): try: model = ARIMA(series, order=(p, d_order, q)) result = model.fit() if result.aic < best_aic: best_aic = result.aic best_order = (p, d_order, q) except: continue print(f'最优 ARIMA 阶数: {best_order}, AIC = {best_aic:.4f}') # 3. 训练最终 ARIMA 模型 arima_model = ARIMA(series, order=best_order) arima_result = arima_model.fit() print(arima_result.summary()) # 4. 获取拟合值和残差 arima_fitted = arima_result.fittedvalues arima_residual = series - arima_fitted这段代码里我特别留意了两个点:
第一,网格搜索的 p、q 范围不要开太大。我见过有人一上来就搜 0 到 10,AIC 确实更低,但模型复杂度上去了,过拟合风险也大,而且拟合时间成倍增加。实际项目中 p、q 在 0~4 之间就够用。
第二,ARIMA 的fittedvalues在序列开头几期会出现 NaN,因为移动平均部分需要前面若干期的残差做初始化。这一步要先dropna(),后面计算残差才不会出错。
3.3 CNN-LSTM 代码:构建残差回归网络
残差序列拿到之后,用滑动窗口构造训练数据。这里有一个关键细节:构造窗口时千万不能用未来的数据。每个训练样本的特征是t-window到t-1的残差值,标签是t时刻的残差值。这样模型在预测时只能依赖过去的信息,不会造成数据泄漏。
def create_sequences(residual, window): X, y = [], [] for i in range(window, len(residual)): X.append(residual[i-window:i]) y.append(residual[i]) return np.array(X), np.array(y) window = 72 # 通过 ACF 图确定的窗口长度 X, y = create_sequences(arima_residual.dropna().values, window) # 划分训练集和测试集(按时间顺序,不打乱) split_index = int(len(X) * 0.8) X_train, X_test = X[:split_index], X[split_index:] y_train, y_test = y[:split_index], y[split_index:] # 归一化:只拟合训练集 scaler_X = MinMaxScaler() scaler_y = MinMaxScaler() X_train_scaled = scaler_X.fit_transform(X_train) X_test_scaled = scaler_X.transform(X_test) y_train_scaled = scaler_y.fit_transform(y_train.reshape(-1, 1)).reshape(-1) y_test_scaled = scaler_y.transform(y_test.reshape(-1, 1)).reshape(-1)然后是构建 CNN-LSTM 网络。我项目里用的结构是这样的:
model = Sequential([ # 1D 卷积层:提取局部特征 Conv1D(filters=64, kernel_size=3, activation='relu', input_shape=(window, 1)), MaxPooling1D(pool_size=2), # LSTM 层:捕捉时间依赖 LSTM(50, return_sequences=False), Dropout(0.2), Dense(1) ]) model.compile(optimizer='adam', loss='mse', metrics=['mae']) model.summary()这里有几个参数选型的心得:
Conv1D 的filters=64是经验值。filter 数量太少,特征提取不充分;太多,训练变慢且容易过拟合。64 在中小规模数据上属于性价比很高的配置。
kernel_size=3是因为时序数据的局部模式通常很短,一次看 3 个时间点已经足够识别小幅波动。如果窗口是 72,卷积核设成 5 或 7 也可以,但效果提升很有限。
LSTM 的return_sequences=False是因为我们只需要最后一个时间步的输出作为最终残差预测。
Dropout(0.2)是为了防止 LSTM 把训练集里的噪声背下来。这里要提醒一点:Dropout 比例不要超过 0.5,否则会把有价值的时序依赖信息也丢掉。
训练部分我加了早停机制,这是我最常用的防过拟合手段:
early_stop = EarlyStopping(monitor='val_loss', patience=10, restore_best_weights=True) history = model.fit( X_train_scaled, y_train_scaled, validation_data=(X_test_scaled, y_test_scaled), epochs=100, batch_size=32, callbacks=[early_stop], verbose=1 )关于batch_size,我尝试过 16、32、64。32 在大多数场景下是稳的,不会太慢也不会太震荡。如果数据量大,可以提到 64,训练更稳但收敛略慢。
3.4 融合预测与结果整合:把两条线接起来
模型训练完之后,把两部分预测结果加在一起:
# 1. ARIMA 对测试集对应时间段的预测 # 注意:这里要预测的是与 CNN-LSTM 测试集时间对齐的未来值 arima_pred = arima_result.forecast(steps=len(y_test)) # 2. CNN-LSTM 对残差的预测 residual_pred_scaled = model.predict(X_test_scaled) residual_pred = scaler_y.inverse_transform(residual_pred_scaled).reshape(-1) # 3. 最终融合 final_pred = arima_pred + residual_pred # 4. 评估 print(f'MAE: {mean_absolute_error(y_test, final_pred):.4f}') print(f'RMSE: {np.sqrt(mean_squared_error(y_test, final_pred)):.4f}') print(f'MAPE: {np.mean(np.abs((y_test - final_pred) / y_test)) * 100:.4f}%')这一步看起来简单,但有个隐蔽的坑:ARIMA 的预测时间步与测试集必须严格对齐。如果训练集用了前 80% 的 1000 条数据,ARIMA 的测试期预测就是从第 1001 条开始的steps=len(y_test)。而 CNN-LSTM 的y_test是滑动窗口生成的,要确保两者长度一致。我在项目里会打印两个数组的长度,确认无误再进行融合,这一步的教训来自一次真实的"错位"事故——那次的 MAPE 直接高出十几个百分点。
4. 实验结果分析与调优实战
4.1 实验设计:数据集与 baseline 设置
我做这组实验用的是某段电力负荷的小时级数据,一共 4380 条(约半年)。训练集取前 80%,测试集为后 20%,按时间顺序严格划分。
对比的 baseline 我就设了四个:
- ARIMA(单独)
- LSTM(单独)
- CNN-LSTM(单独)
- ARIMA-CNN-LSTM 融合模型
实验结果整理成表格如下(数值是我实际项目里的整体水平,供参考):
| 模型 | MAE | RMSE | MAPE |
|---|---|---|---|
| ARIMA | 28.61 | 36.42 | 8.94% |
| LSTM | 26.33 | 34.07 | 8.21% |
| CNN-LSTM | 24.18 | 31.55 | 7.46% |
| ARIMA-CNN-LSTM | 19.87 | 25.12 | 5.83% |
从结果看,融合模型的提升主要来自两个方面:一是 ARIMA 把线性基线吃掉了,神经网络的拟合压力大幅下降;二是 CNN-LSTM 在残差上学习到的非线性模式恰好补齐了 ARIMA 的短板。两者不是简单的"各占一半",而是形成了一种互补。
4.2 关键调参记录与心得
这部分我把调参过程中最有价值的几个心得记下来:
第一,差分阶数 d 不要贪大。我的数据做了两阶差分后 ADF 的 p 值更小,但 ARIMA 的预测结果反而变差,原因就是过度差分把原始序列中的长期信息消除了。做残差融合时,差分阶数越多,残差的结构越复杂,CNN-LSTM 的学习任务越重。我的经验是 d 尽量取 1,除非一阶差分后序列仍然明显不平稳。
第二,窗口长度优先看 ACF 的"显著滞后阶数"。我最初图省事直接用固定 window=100,结果 LSTM 的内存占用高、训练时间长,效果也没好到哪去。后来改成看 ACF 图,发现滞后 72 阶自相关才降到显著水平以下,改设 window=72,训练时间缩短了三分之一,精度反而略有提升。
第三,CNN 层不是越多越好。我在残差数据上试过堆叠两层 Conv1D,精度提升几乎可以忽略,但参数量和过拟合风险明显增加。残差序列本身的复杂度远低于原始序列,单层卷积足够。
第四,早停机制的 patience 设置。patience 太小人容易欠拟合,模型还没充分收敛就停了;太大又容易过拟合。我项目里最终用的是 10,epochs 上限设 100,实测下来训练过程比较稳定。
4.3 常见问题与排查速查表
写代码时候踩过的坑,我整理成一张表格,方便大家照着排查。
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
ARIMA 报错LinAlgError: SVD did not converge | 数据里有 NaN、无穷值,或 p/q 阶数过大 | 先dropna(),减小 p、q 范围重新搜索 |
| 训练集和测试集长度对不上 | 滑动窗口导致前 window 个样本被丢弃 | 在窗口化前先预留 length+window 的数据 |
| 预测结果整体偏平稳、曲线"变钝" | 归一化时把测试集也塞进了 fit | 只用训练集 fit scaler,测试集只 transform |
| LSTM 训练 loss 基本不动 | 学习率过高或输入数据幅度过大 | 确认已经归一化,或把 adam 学习率调低为 0.0001 |
| 融合后的残差仍然很大且呈现明显趋势 | ARIMA 的 d 选择不当 | 检查残差序列的 ADF 检验,必要时重新调整差分阶数 |
| 预测值出现严重的错位 | ARIMA 预测步数与测试集 align 出错 | 打印两个数组长度,用时间索引对齐 |
4.4 经验之谈:这套模型的边界在哪里
最后再聊一点比较实际的体会。ARIMA-CNN-LSTM 这套模型并非万能,它适合的是数据量中等、存在明显线性和非线性混合特征的时间序列。我实测下来它的优势区间在几千条到几万条数据规模。
如果数据量特别少(几百条),LSTM 很难训练到位,这时老老实实用 ARIMA 或者带季节性的 SARIMA 反而更稳。如果数据量特别大(百万级以上),CNN-LSTM 的计算成本会明显上升,这时更适合考虑 Transformer 类模型或分布式训练方案。
还有一点我想特别提醒:模型融合的收益一定要通过对比实验确认。不是把模型堆起来就算赢。我在一次流量预测项目中,融合模型的 MAPE 反而比单纯 CNN-LSTM 还高,排查后发现问题出在 ARIMA 对那段数据拟合得太差,把残差里的信息搞乱了,等于送进去的是一堆噪声。这种情况下要么放弃融合,要么换更强的统计模型(比如 SARIMA 加上外生变量)。
实际操作中还有个容易被忽略的小技巧:训练完记录模型权重和 scaler 参数。我在这个项目里用model.save()保存了网络权重,用 pickle 保存了 scaler 的 min/max 值。这样后续做上线预测时可以直接加载,不需要重新跑一遍训练流程。这一步省下来的时间,比任何调参技巧都实在。