☰
ARIMA-CNN-LSTM组合模型:线性基线加残差修正的时间序列预测实践
2026/10/1 3:50:16 网站建设 项目流程

1. 为什么把ARIMA、CNN和LSTM放在一起

说个实话,我一开始也觉得 ARIMA-CNN-LSTM 这种缝合怪模型有点“为了复杂而复杂”。直到有一次做设备寿命预测,纯 ARIMA 预测趋势还行,但一到故障前的非线性抖动就彻底抓瞎;换纯 LSTM,故障模式倒是捕捉到了,可预测值总是整体偏一个常数,怎么看都不对劲。后来把 ARIMA 作为基线、CNN-LSTM 去拟合残差,才把两个问题同时解决。这篇文章就是我基于这个组合模型做的完整研究和 Python 实现记录,适合正在做时间序列预测、想让统计模型和深度学习模型互相“搭把手”的读者。

1.1 单一模型的局限

先说清楚一个容易踩的误区:很多教程喜欢把 ARIMA、CNN、LSTM 摆在一起,好像三个模型堆起来就一定更准。但实际组合之前,你得先明白它们各自的“主场”和“盲区”。

ARIMA 是经典的自回归积分滑动平均模型,核心假设是数据经过差分后具有线性相关性。它的优势特别明显:模型参数少、解释性强、在小样本和强趋势数据上非常稳定。但问题也出在“线性”上。你在用 ARIMA 拟合带有突变、非线性波动、多变量交互作用的数据时,残差里往往还藏着大量可学习的信息,模型本身却已经无能为力。

CNN 擅长提取局部特征,这一点在图像上已经被反复验证。在时间序列里,它能识别连续几个时间步的形态,比如尖峰、谷底、短期上升斜率。可 CNN 的卷积核只能看到它固定大小的窗口,天然缺少长距离依赖记忆。你给它 1000 步序列,想要捕捉几个月前的周期性拐点,基本做不到,除非堆叠非常深的网络,但那样训练成本和过拟合风险又上来了。

LSTM 就是为了解决长期依赖而生的,通过门控机制选择记住或遗忘历史信息,在处理序列数据时表现很好。但它对序列的趋势项和季节项并不是天生友好的,特别是不做差分和归一化就直接丢进去,模型常常会用大量隐藏单元去被迫模拟线性趋势,结果训练慢、泛化差,还容易对噪声过拟合。

1.2 组合模型的核心逻辑

既然这三个模型各有长短,组合的核心思路就不是简单把预测结果算平均,而是用一种“分工+纠错”的方式。ARIMA 先负责把线性趋势、可解释的季节性周期吃干榨净,输出一个基线预测;然后把真实值和 ARIMA 预测值相减,得到残差序列。这个残差里剩下的主要是非线性、非平稳的部分,再交给 CNN-LSTM 去学习。

为什么不在同一个模型里同时处理原始序列和残差?因为深度网络的容量是有限的,如果直接把原始序列丢给 LSTM,它除了学非线性,还得重新学一遍线性趋势和周期。结果就是模型参数很大、收敛很慢,还常常出现整体偏移。把它拆开之后,深度网络只需要面对一个均值接近零、更平稳的残差序列,学习任务简单很多,网络也不需要堆得特别深。

这套逻辑其实很像信号处理里的“分而治之”:先去掉可线性解释的主信号,再对剩余部分做非线性建模。我把这种结构称为“线性基线+残差修正”。在后面的 Python 实现里,你会发现代码结构也是按照这个思路走的:预测结果不是等权重相加,而是 ARIMA 预测值直接加上深度学习网络输出的残差预测量。

2. 整体思路与架构设计

在写代码之前,先把整个预测流程画在脑子里很重要。ARIMA-CNN-LSTM 不是三个模型平行跑,而是串行流水线。我习惯把这个流水线分成四段:数据准备、ARIMA 建模、残差构造、CNN-LSTM 训练。每一步的输出都是下一步的输入,中间任何一个环节出错,最后预测都会崩。

2.1 模型结构拆解

整个流水线可以这样看:

原始时间序列经过缺失值处理后,先做平稳性检验和差分,确定 ARIMA 的阶数。ARIMA 在训练集上拟合并生成预测值,这一步得到的是对线性趋势的拟合。紧接着计算训练集真实值和预测值的差,得到残差序列。

然后把这个残差序列转成有监督学习的窗口样本:用前 look_back 步的残差去预测当前步的残差。每个样本输入 CNN 卷积层提取局部模式,再经过 LSTM 层捕捉时间依赖,最后通过全连接层输出一个残差预测值。在测试阶段,ARIMA 生成测试集预测,CNN-LSTM 生成残差修正,两者相加就是最终预测结果。

很多读者会在这一步问:能不能把 ARIMA 的预测值也作为 CNN-LSTM 的输入特征?可以,但我个人不建议一开始就这么做。因为 ARIMA 预测值本身可能带误差,把它当特征会让误差在模型间传播,而且会让残差网络学到的模式变模糊。等基线跑通了,再尝试把 ARIMA 预测值、时间戳特征、外部变量作为附加特征一起喂给深度网络,效果可能会更好。

2.2 为什么用残差而非原始值

有人可能会反问:既然 LSTM 理论上什么序列都能学,为什么不直接让 CNN-LSTM 在原始值上预测,再用 ARIMA 去修正它的残差?这个问题我也认真想过。从理论上看两种路径都能成立,但从工程实践看,先 ARIMA 后深度模型的做法更稳定。

原因很简单:ARIMA 对趋势和季节性的拟合是参数化且高效的,同样的效果如果让神经网络学,往往需要很大的模型容量和海量数据。而现实中的时间序列数据长度经常只有几百到几千条,深度学习模型容量一大就容易过拟合。先把线性部分剥离掉,相当于给神经网络降维,它只需要学习一个波动范围小、均值接近零的残差序列,“任务难度”小很多。

另外,从可解释性角度看,ARIMA 部分提供了清晰的趋势项结果,业务同事问“为什么预测值是这个数”时,你能拆开说:线性趋势来自 ARIMA,非线性波动修正来自残差网络。这在汇报时也是个很大的优势。总之,残差学习不是为了把简单问题复杂化,而是为了让每个模型都只干自己最擅长的活。

3. 数据准备与Python环境搭建

写深度学习代码最怕的不是模型结构,而是数据预处理做得不干净。这个项目也一样,我在多次复现时发现,很多人的模型效果差并非结构不对,而是归一化、窗口划分、差分还原这些细节出了问题。

3.1 环境依赖

我的开发环境是 Python 3.9,主要依赖这几个库:

  • statsmodels:做 ARIMA 建模、ADF 检验、ACF/PACF 图
  • pandas、numpy:做数据清洗和序列窗口化
  • scikit-learn:做归一化和评估指标计算
  • TensorFlow / Keras:构建 CNN-LSTM 网络
  • matplotlib:可视化预测结果

如果你用的是 PyTorch,也可以实现同样的结构,但下面代码我以 Keras 为例,因为 Sequential API 写短小的卷积加 LSTM 结构非常顺手。安装可以用 pip install statsmodels pandas numpy scikit-learn tensorflow matplotlib。如果已经装过,注意 statsmodels 的版本,新版 0.13 之后 ARIMA 接口有一些调整,代码里最好统一用 statsmodels.tsa.arima.model.ARIMA。

3.2 数据预处理与平稳性检验

ARIMA 的第一步是检验平稳性。这里我习惯用 ADF 检验,也就是 Augmented Dickey-Fuller 检验。如果 p 值大于 0.05,说明序列不平稳,需要做差分。差分阶数 d 一般取 0、1、2,不要盲目贪多,差得太多会把有价值的长周期信息也差掉。

import pandas as pd import numpy as np from statsmodels.tsa.stattools import adfuller data = pd.read_csv('data.csv', parse_dates=['date'], index_col='date') values = data['value'].astype(float) # ADF检验 result = adfuller(values) print(f'ADF统计量: {result[0]:.4f}, p值: {result[1]:.4f}')

如果 p 值大于 0.05,就做一阶差分:

diff_values = values.diff().dropna() result = adfuller(diff_values) print(f'差分后p值: {result[1]:.4f}')

这里有个特别容易踩的坑:差分后的序列用于 ARIMA 建模,但最终预测结果要还原到原始尺度。在代码里我习惯把差值再累加回去,而不是直接把模型输出当最终结果。尤其是后面加了残差修正,更要在加回 ARIMA 预测值时保持同一尺度,否则图表上看着收敛很好,实际数值却是错的。

数据划分也要注意,时间序列不能用随机 train_test_split。我通常按时间顺序切分,比如前 80% 做训练、后 20% 做测试。而且归一化的时候,只能 fit 在训练集上,再 transform 测试集,不能用全量数据去 fit Scaler,否则等于把未来信息泄露给了模型。

4. ARIMA部分实现与评价

ARIMA 在这个组合里是“地基”。地基要是歪了,后面深度学习再努力也救不回来。所以 ARIMA 部分我不能只扔一个 auto_arima 了事,还得说清楚怎么定阶、怎么看残差。

4.1 定阶:从ACF/PACF到信息准则

ARIMA 有三个超参数 p、d、q。d 是差分阶数,已经在上一步确定了。p 是自回归阶数,q 是移动平均阶数。传统定阶方法是看差分后序列的 ACF 和 PACF 图:ACF 拖尾、PACF 截尾选 p;ACF 截尾、PACF 拖尾选 q。但在项目里,靠肉眼看图定阶容易犹豫不决,我更喜欢结合信息准则搜索。

我用 AIC 来搜索候选参数组合,让模型自动选出相对最优的 p 和 q。搜索范围不要太大,p 和 q 建议都在 0 到 5 之间,阶数太大会带来过拟合,尤其是在数据量不大时。

import itertools from statsmodels.tsa.arima.model import ARIMA best_aic = float('inf') best_order = None for p in range(0, 6): for q in range(0, 6): try: model = ARIMA(diff_values, order=(p, 1, q)) result = model.fit() if result.aic < best_aic: best_aic = result.aic best_order = (p, 1, q) except Exception: continue print(f'最优阶数: {best_order}, AIC: {best_aic:.2f}')

注意,diff_values 已经是差分后的序列,所以 ARIMA 模型里的 d 我写成了 1。如果你想让 ARIMA 内部自动处理差分,也可以直接把原始序列传进去,并设置 order=(p, d, q)。但我在残差学习组合中倾向于手动差分,因为后面还需要手动还原,逻辑更清晰。

4.2 建模、预测与残差白噪声检验

确定阶数后,直接在训练集上拟合 ARIMA,并预测到时候写代码要注意预测范围。我通常不直接调用 predict 去预测所有测试集,而是先拟合训练集,然后用 forecast 得到测试集预测。

model = ARIMA(train_values, order=best_order) arima_result = model.fit() train_pred = arima_result.predict(start=train_index[0], end=train_index[-1]) test_pred = arima_result.forecast(steps=len(test_values))

预测完成后,必须对 ARIMA 的训练残差做白噪声检验。说得直白点,如果残差里还有明显的自相关,说明 ARIMA 没有把线性信号提取干净,你要么调阶数,要么考虑数据本身不适合线性模型。用 Ljung-Box 检验时,p 值大于 0.05 可以认为残差基本是白噪声,但实际项目里这种情况很少,所以不要因为 p 值不小就完全不调。

残差序列是组合模型中深度学习部分的“训练标签”。在切分窗口之前,我先检查一下残差分布。如果均值明显偏离 0,就减掉均值,让残差中心化。这样做不是为了凑数,而是让 CNN-LSTM 的学习目标更平稳,训练速度会快不少。

5. CNN-LSTM部分实现

ARIMA 这层做完,GPU 的活儿才刚开始。CNN-LSTM 在这里的任务不是预测原始值,而是预测残差。所以整个数据构造和训练流程都围绕残差序列展开。

5.1 构造滑动窗口数据集

残差序列本质上还是一个时间序列,要把监督学习的问题构造成“用前 look_back 个已知残差值预测当前残差值”。这一步我写了一个通用函数:

def create_windows(data, look_back=12): 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)

look_back 怎么选?可以先用自相关图看残差序列在哪些滞后阶上还有较强的相关,但更实用的办法是结合业务周期和经验。如果是日粒度数据,至少取 7 或 14;如果是小时粒度数据,考虑 24、48 或 168。太小的窗口让模型看不到足够的历史形态,太大又容易引入无关噪声。我自己一般会试 10、20、48 三档,再看验证集指标确定。

输入到 CNN 前需要 reshape。Keras 的 Conv1D 期望输入形状是 (samples, time_steps, features),所以我们把窗口数据变成三维数组:

X_train = X_train.reshape((X_train.shape[0], X_train.shape[1], 1))

这个 1 是特征通道数,表示每个时间步只有一个残差值。如果你后续想加入外部变量,可以在这一步把特征维度扩展成多个值,模型不需要改结构,直接用就行。

5.2 构建CNN-LSTM组合网络

网络结构我用了比较克制的方案:一层 Conv1D 加池化,再接入一个 LSTM,最后用全连接输出一维结果。之所以没有堆好几层卷积,是因为残差序列的信息密度远低于图像,深度太深反而容易把噪声也学进去。

from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Conv1D, MaxPooling1D, LSTM, Dense, Dropout model = Sequential() model.add(Conv1D(filters=64, kernel_size=3, activation='relu', input_shape=(look_back, 1))) model.add(MaxPooling1D(pool_size=2)) model.add(LSTM(units=32, return_sequences=False)) model.add(Dropout(0.2)) model.add(Dense(1)) model.compile(optimizer='adam', loss='mse', metrics=['mae']) model.summary()

这里有个细节:MaxPooling1D 会降低时间维度,LSTM 看到的序列长度会比 look_back 短一半左右。这不算坏事,因为池化能提取高层局部特征,也减少了 LSTM 的计算量。但如果你发现时间步太少导致 LSTM 难以记忆,可以把 pool_size 改成 1,或者去掉池化层。

CNN 的卷积核大小我固定在 3,过大的卷积核在时间序列上容易把一个突变和后面的平稳段强行组合在一起,反而破坏局部语义。filters 从 64 开始,如果数据特别少,可以降到 32。LSTM 的 units 我推荐 16 到 64 之间,不要一上来就 128,残差序列的任务没那么复杂,units 过大只会让验证集指标抖动。

5.3 训练与预测的还原

训练时有一个容易忽略的大坑:时间序列样本不能 shuffle。Keras 的 fit 默认是 shuffle=False,但如果你不小心设置了 shuffle=True,就会让模型提前看到未来样本,训练损失非常好看,一到真实预测就崩。

from tensorflow.keras.callbacks import EarlyStopping early_stop = EarlyStopping(monitor='val_loss', patience=10, restore_best_weights=True) history = model.fit( X_train, y_train, validation_split=0.1, epochs=100, batch_size=32, callbacks=[early_stop], verbose=1 )

训练结束之后,CNN-LSTM 输出的是残差的预测值。在最终组合之前,要做两步还原:如果残差做了标准化,先 inverse_transform;如果残差做了减均值,要加回均值。然后和 ARIMA 的测试预测值逐项相加,得到最终预测结果。

residual_pred = model.predict(X_test).flatten() residual_pred = scaler_residual.inverse_transform(residual_pred.reshape(-1, 1)).flatten() final_pred = arima_test_pred + residual_pred

检查最终预测值时,留意一个症状:如果预测曲线整体比真实值低一段距离,很可能不是模型问题,而是残差在网络训练时被中心化了,但预测时忘了加回均值。我踩过这个坑两次,每次都在还原环节,后来写成代码注释提醒自己。

6. 组合预测结果与调参经验

模型搭好之后,很多人会急着看预测图。但一个负责任的评估不能只看图,还得量化对比。我这里用 RMSE、MAE、MAPE 三个指标,和基线模型做对比,才能判断组合模型到底有没有增量。

6.1 评估指标与对比

以我之前一组日粒度销量数据为例,训练集长度约 800 条,测试集约 200 条。纯 ARIMA 的 RMSE 在测试集上大约是 12.4;纯 LSTM(直接在原始值上训练)的 RMSE 大约 10.8;ARIMA-LSTM 的 RMSE 降到了 9.6;而 ARIMA-CNN-LSTM 的 RMSE 是 8.9。注意,这个结果受数据和参数影响很大,我不建议你把它当作万能结论。

三个模型对比中,最值得关注的是 ARIMA-LSTM 和 ARIMA-CNN-LSTM 的差距。前者直接用 LSTM 学习残差,后者先用 CNN 提局部特征。在数据里有明显的局部尖峰或短期拐点模式时,CNN 的局部感知能力确实帮了大忙。但如果是平缓波动为主的数据,CNN 带来的提升会很小,甚至差不多。

我建议你先做两两对比,把每个模型的结果都算一遍,然后画在一张图上。具体代码可以用 sklearn.metrics:

from sklearn.metrics import mean_squared_error, mean_absolute_error import numpy as np rmse = np.sqrt(mean_squared_error(y_true, y_pred)) mae = mean_absolute_error(y_true, y_pred) mape = np.mean(np.abs((y_true - y_pred) / y_true)) * 100

MAPE 在真实值接近 0 时会变得异常巨大,如果数据里有这种情况,建议改用 MAE 或者 WMAPE,别死磕 MAPE。

6.2 调参避坑实录

调参方面,我总结几个特别实用的心得,都是踩过坑换来的。

第一,look_back 不要只试一个值。我建议把候选的 look_back 和 LSTM units 一起做一个小网格搜索,比如 look_back 取 10、20、48,units 取 16、32、64。虽然跑起来多花一点时间,但比没有依据地拍脑袋强得多。网格搜索时,每轮训练都用同样的随机种子,至少跑两次取平均,否则结果会被初始化噪声干扰。

第二,EarlyStopping 的 patience 不要设太大。很多人为了等一个更优指标,把 patience 设为 50,结果模型早就在验证集上过拟合了。我习惯设 10 到 15,并且开启 restore_best_weights,让模型回到验证集最优的权重。

第三,ARIMA 部分的阶数不要为了迎合深度模型故意调复杂。如果你把 ARIMA 的 p、q 调得非常高,它在训练集上会拟合得很激进,残差里几乎没有可学模式,CNN-LSTM 自然没什么作用。相反,稍微保守一点的 ARIMA 阶数,反而会让残差网络有发挥空间。

第四,归一化必须严格放在数据切分之后。如果先对全序列做 MinMaxScaler,再切出一部分测试集,测试集的尺度信息已经被模型看到了。这个问题在时间序列里相当于未来信息泄露,会让评估结果虚高,但上线后立刻现原形。

7. 常见问题与排查技巧实录

最后这部分是真正的实战记录。我把自己复现这个模型时遇到过的典型问题整理成慢查手册,方便你对照排查。

7.1 ARIMA残差仍然存在强自相关

如果你做完 Ljung-Box 检验发现残差 p 值远小于 0.05,先不要急着上 CNN-LSTM。残差里有明显自相关,说明 ARIMA 的阶数没有抓住线性结构,此时用深度学习拟合残差,会学到一段“没被处理干净”的低频成分,而不是真正的非线性模式。我的排查顺序是:先重新看 ACF/PACF 图,适当提高 p 或 q;如果提高后 AIC 反而变大,就考虑序列可能包含周期性成分,需要先用 STL 分解或差分季节项。

7.2 组合模型效果没有提升甚至下降

这种情况经常出现在数据本身就高度线性平稳的数据集上。ARIMA 已经拟合得很好,残差基本是白噪声,CNN-LSTM 只能学到随机噪声,你说它能有什么提升?所以不要指望这套组合在所有数据上都能锦上添花。对我来说,如果在试了 3 个 look_back 值、调整了网络宽度之后,组合模型 RMSE 和纯 ARIMA 相差不到 5%,我会直接放弃深度模型,回到纯 ARIMA。学会在什么时候不用组合模型,也是经验的一部分。

7.3 深度学习部分常见的代码报错

我列几个高频报错和解法:

  • ValueError: Input 0 is incompatible with layer lstm_1: expected ndim=3, found ndim=2。这个通常出现在 Dense 层输出直接接 LSTM 层的场景。检查你是否在输入时做了 reshape 成三维数组,以及 Conv1D 之后是否因为 Flatten 把维度降到了二维。如果用了 Flatten,LSTM 自然接不上。
  • Dimensions must be equal错误,多半是窗口数据和标签长度不一致。检查create_windows里i的循环边界,特别是数据长度较小时,最后一个窗口可能取不到对应标签。
  • AttributeError: module 'statsmodels.tsa.arima_model' has no attribute 'ARIMA',这是版本问题。新版接口在statsmodels.tsa.arima.model,旧代码要迁移。
  • 训练时 loss 变成 NaN。优先检查残差序列中是否有极端异常值,其次是学习率过大。可以先对残差做 winsorize 或换用 Adam 默认学习率再试。

7.4 多步滚动预测的工程实现

如果业务要预测未来 30 天而不是一步,滚动预测的坑会更多。一个比较简单但工程上常用的思路是:用 ARIMA 生成未来 30 步的基线预测,然后 CNN-LSTM 也按递归方式预测残差——也就是说,每一步预测完成后,把预测出的残差拼接到历史残差序列末尾,重新构造窗口,再预测下一步。这里要注意,测试阶段每一步都用上一步的预测值作为输入,而不是真实值,否则评估结果会过于乐观。

这种递归滚动预测在长周期下误差会累积,所以实际项目中,我更推荐每 7 天或 15 天做一次模型重新校准,或者直接用滑动窗口重新训练。工程上永远不存在“训练一次永远预测”的模型,尤其是带有残差修正的组合模型,数据分布一变,两层模型的误差会同时放大。

最后再分享一个小技巧:在完成预测后,把 ARIMA 残差和 CNN-LSTM 预测的残差画两张图对比,如果深度网络预测的残差曲线基本贴着真实残差走,说明它确实学到了模式;如果接近一条水平线,说明残差里没有可学信息,或者网络能力不够。这个可视化判断比看 RMSE 更直观。对我来说,ARIMA-CNN-LSTM 并不是一个需要无脑上线的万能模型,但它让我在处理复杂时间序列时多了一套可拆解、可解释、可调试的工具。下次你面对一个预测任务,不妨先从把线性部分“剥离”出来的角度思考,这种分工思路可能比盲目换模型更解决问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询