☰
ARIMA与CNN-LSTM组合模型:残差修正实现高精度时间序列预测
2026/10/3 14:37:10 网站建设 项目流程

1. 项目概述与模型选型思路

1.1 为什么同时需要 ARIMA 与深度学习的组合

先说结论:单一模型做时间序列预测,很容易在“线性趋势”和“非线性波动”之间顾此失彼。ARIMA 是经典统计模型的代表,擅长抓线性趋势、季节性和自相关结构,但它对突发性的非线性变化、多变量交互特征基本无感。反过来,CNN 和 LSTM 这类深度学习模型能拟合非常复杂的非线性关系,却经常忽略序列本身的差分平稳特性,训练数据一少、噪声一大,预测结果就容易放飞自我。

我在做这个项目的时候,手里拿到的是一组日度销售流量数据,大概有两年的历史记录。一开始我只用 LSTM 去硬拟合,效果不算差,但每逢促销日、节假日这种大波动窗口,预测值总会被平滑掉一大截。后来改成纯 ARIMA,趋势倒是稳了,可遇到非线性的突发上升就完全跟不上。最后把三者组合起来,才真正找到平衡点:ARIMA 负责把“能解释的线性规律”先吃掉,CNN-LSTM 再对剩下的残差部分做非线性拟合,最终的预测结果在平稳段和突变段都有了明显改善。

这个思路听起来简单,实际落地里有不少细节。ARIMA 的输出不能直接拼给深度学习模型,残差怎么构造、窗口怎么切、归一化怎么做、两个模型的预测结果怎么融合,每一步都会直接影响最终效果。下面我把整个方案的架构、代码实现和踩坑过程完整梳理一遍,给想做同类预测模型的朋友一个可以直接落地的参考。

1.2 整体架构:三个模型如何协同工作

这个项目采用的是一种“残差修正”架构,也叫两阶段建模。先让 ARIMA 对原始序列做第一轮预测,得到拟合值和未来预测值;然后计算真实值与 ARIMA 拟合值之间的残差序列;接着把这个残差序列作为 CNN-LSTM 的输入目标,用滑动窗口的方式构造特征,训练深度学习模型去学习残差中的非线性模式;最后一步做预测时,ARIMA 的预测结果加上 CNN-LSTM 的残差预测结果,就是最终输出。

之所以不把三个模型做成大杂烩并联,是因为三种模型的数学假设完全不同。ARIMA 要求序列经过差分后是平稳的,而 LSTM 更喜欢数值范围受限的连续特征,两者直接拼接反而会互相干扰。残差修正架构的好处是各司其职:线性部分交给统计模型,非线性部分交给神经网络,融合逻辑清晰,也容易排查问题。实际运行时,我会先用 pmdarima 库的 auto_arima 自动定阶,避免手动调 p、d、q 的试错成本;再用 Keras 搭建 CNN-LSTM 模型,把一维卷积当作局部特征提取器,把 LSTM 当作序列记忆单元,最后接一个全连接层输出残差预测值。

整个项目的代码结构分为数据预处理、ARIMA 建模、CNN-LSTM 建模、结果融合与评估五个模块。后面我会逐步展开每个模块的细节,包括为什么这样设计、代码怎么组织、参数怎么确定。

2. 核心原理拆解:三个模型在项目里分别承担什么角色

2.1 ARIMA:线性基线与差分平稳性

ARIMA 模型的全称是自回归积分滑动平均模型,它的核心思想是:经过 d 阶差分后,非平稳序列可以转化为平稳序列,然后用过去 p 步的历史值和 q 步的预测误差来线性地预测当前值。数学上可以写成 AR、差分、MA 三部分的组合,但实际用 Python 时不需要手推公式,pmdarima 库的 auto_arima 会自动搜索最优的 (p, d, q) 组合,甚至还能处理季节性参数 (P, D, Q, m)。

我在这个项目里最关注 ARIMA 的两个作用。第一是给出基准预测,第二是构造残差序列。auto_arima 的搜索过程会基于 AIC 或 BIC 准则选择参数,样本量不大的情况下 BIC 更保守,不容易过拟合。项目里的日度销售流量数据有明显的周周期性,所以我把 seasonal 参数设为 True,seasonal_period 设为 7,让模型自动捕捉以周为单位的重复模式。

需要注意的是,ARIMA 对缺失值非常敏感。原始数据里有几天因为系统故障没有记录,如果直接丢给 auto_arima,拟合结果会产生竖向偏移。我的做法是先用前向填充补缺失值,再对极端离群值做缩尾处理,保证序列的连续性。这一步看着简单,但不做的话,后面的残差序列会带进大量虚假波动,深度学习模型学到的"模式"就不是真实模式,而是缺失值造成的伪影。

2.2 CNN:从局部窗口提取多尺度特征

CNN 在图像领域很出名,但在时间序列预测里它做的是另一件事:通过一维卷积核在时间轴上滑动,提取局部窗口内的短期模式。比如一个大小为 3 的卷积核,可以捕捉连续三个时间点的变化形态——上升、下降、尖峰、拐点——这些局部特征会被抽象成更高维的特征图,供后续的 LSTM 使用。

我一开始有一个误区,觉得序列预测既然有时间依赖关系,直接用 LSTM 就行,加 CNN 不是多此一举吗?实测下来发现,CNN 的好处有两个。第一,它把序列切分成局部模式再传递给 LSTM,等效于做了特征工程,LSTM 学习压力明显变小;第二,多组卷积核可以并行提取不同尺度的特征,比如一组核关注短期突变,另一组核关注连续三天的趋势,这在单一 LSTM 里很难做到。项目中我用了两层一维卷积,第一层 64 个卷积核,第二层 32 个卷积核,卷积核大小都取 3,这样既能提取局部特征,又不会把序列压得太碎。

另一个细节是池化层。我在第一层卷积后加了一个 MaxPooling1D,把序列长度压缩一半,减少后续计算量。但是在第二层卷积后没有再用全局池化,而是保留序列结构,因为 LSTM 需要按时间步读取输入,如果把时间维度彻底压平,LSTM 就失去了序列记忆能力。这个取舍是实战中踩了几次坑才确定的。

2.3 LSTM:长期依赖与序列记忆机制

LSTM 是循环神经网络的一种改进结构,通过引入输入门、遗忘门、输出门三个门控机制,让信息可以在长序列中流动。传统 RNN 在反向传播时容易出现梯度消失,导致模型记不住几十步之前的信息,LSTM 通过细胞状态那条"传送带",把长期信息一路传递下来,只在需要时更新或遗忘。

在残差修正架构里,LSTM 的输入是 CNN 提取出的特征序列。滑动窗口长度我设为 14 天,也就是用过去两周的数据预测未来一天的残差。之所以选 14 而不是 7,是因为销售流量数据既受周周期影响,也受两周一次的调价活动影响,LSTM 的细胞状态有能力在 14 步范围内记住这种双周节奏。如果窗口太短,模型看不到完整周期;如果窗口太长,训练样本数会减少,反而容易过拟合。窗口大小这个超参数对 LSTM 模型的影响比学习率还大,建议读者在自己的数据上做 7、14、21、28 的网格搜索。

LSTM 单元数我设置为 64。太小的话记忆容量不足,太大则容易把训练数据里的噪声也记住,泛化能力下降。项目早期我用过 128 个单元,训练集 RMSE 很低,但验证集表现明显变差,典型的过拟合信号,后来降到 64 才稳定下来。

3. Python 环境准备与数据预处理实操

3.1 工具链选择与依赖安装

整个项目的实现基于 Python 3.9,深度学习部分使用 TensorFlow 2.x 的 Keras 接口,统计建模部分使用 pmdarima 库,数据处理使用 pandas 和 numpy。我强烈建议用虚拟环境管理依赖,避免 TensorFlow 与 pmdarima 或者 scikit-learn 之间出现版本冲突。

conda create -n ts_forecast python=3.9 conda activate ts_forecast pip install pandas numpy matplotlib scikit-learn pmdarima tensorflow

pmdarima 会自动安装 statsmodels 作为底层依赖,不需要单独配置。TensorFlow 在 CPU 环境下跑这个规模的模型完全没有问题,我的数据量只有 700 多行,单次训练不到两分钟,GPU 反而是杀鸡用牛刀。如果你后续要扩大数据规模,再考虑 GPU 版本的 TensorFlow。

这里补充一个安装细节:不同操作系统上,pmdarima 的 wheel 包可能会依赖特定版本的 OpenBLAS,如果安装时出现编译错误,优先检查是否为 Python 版本太高导致缺少预编译包。用 conda 安装可以绕开大部分编译问题,我一般默认先用 conda 装 pmdarima。

3.2 数据清洗、平稳性检验与差分处理

拿到原始数据后,第一步是检查时间索引是否连续。真实业务数据经常有缺失日期,需要先补全再填充。项目里的处理方式是先按日生成完整日期范围,然后用 reindex 把缺失日期补成 NaN,接着用前向填充补缺失值。如果缺失集中在某个连续区间,前向填充会造成一段平台区,这时候我会改用线性插值,避免破坏趋势。

import pandas as pd import numpy as np df = pd.read_csv('sales_flow.csv', parse_dates=['date'], index_col='date') df = df.asfreq('D').reindex(pd.date_range(df.index.min(), df.index.max(), freq='D')) df['value'] = df['value'].interpolate(method='linear')

平稳性检验用的是 ADF 单位根检验。statsmodels 里可以直接调用 adfuller,如果 p 值小于 0.05,说明序列平稳,不需要差分;否则要做一阶差分。auto_arima 会自动确定差分阶数 d,但为了理解数据,我建议手动看一眼。

from statsmodels.tsa.stattools import adfuller adf_result = adfuller(df['value'].dropna()) print(f'ADF Statistic: {adf_result[0]:.4f}') print(f'p-value: {adf_result[1]:.4f}')

项目里的原始序列 ADF 检验的 p 值在 0.3 左右,明显非平稳,一阶差分后 p 值降到 0.01 以下,说明 d 取 1 是合适的。差分不仅让 ARIMA 可以建模,对后续残差分析也有帮助——残差序列如果依然存在强趋势,说明 ARIMA 没有把线性部分提取干净。

3.3 滑动窗口构造与数据集划分

在构造 CNN-LSTM 的训练数据之前,必须先进行归一化。我选用 MinMaxScaler,把数据缩放到 [0, 1] 区间。为什么不用 StandardScaler?因为 LSTM 使用 sigmoid 和 tanh 作为激活函数,输入范围接近 [0,1] 或 [-1,1] 时梯度传播更稳定;标准化后的数据虽然均值是 0,方差为 1,但可能会有超出激活函数饱和区的值,导致训练初期梯度消失。

归一化要放在滑动窗口构造之前,而且必须是在训练集上 fit,再对验证集和测试集 transform。这个顺序千万不能反,否则验证集的信息会提前泄漏到训练集里,模型评估结果会虚高。

from sklearn.preprocessing import MinMaxScaler scaler = MinMaxScaler() scaled_values = scaler.fit_transform(df[['value']].dropna())

滑动窗口构造使用一个函数生成 X 和 y。X 的维度是 [样本数, 窗口长度, 特征数],y 的维度是 [样本数, 1]。在这个项目里,特征数是 1,因为只有一列销售流量值。如果你想引入更多的外生变量,比如天气、促销标记、节假日标记,只需要在构造窗口时把对应列拼接进去,特征数就会相应增加。

def create_sequences(data, window=14): X, y = [], [] for i in range(window, len(data)): X.append(data[i-window:i, 0]) y.append(data[i, 0]) return np.array(X), np.array(y) window = 14 X, y = create_sequences(scaled_values, window) X = X.reshape((X.shape[0], X.shape[1], 1))

数据集划分遵循时间序列的基本原则:不能随机打乱,必须按时间顺序切割。我按 7:2:1 的比例切分训练集、验证集和测试集,切分点是第 500 天和第 640 天左右。随机打乱会让模型提前"看到"未来的信息,在时间序列任务里是大忌,这点怎么强调都不过分。

4. 模型实现与训练细节

4.1 ARIMA 自动定阶与残差序列构造

ARIMA 部分我直接用 pmdarima 的 auto_arima。关键参数是 seasonal 设为 True,seasonal_period 设为 7,trace 设为 True,可以打印搜索过程。maxiter 默认值在数据量小时够用,如果出现收敛警告,可以适当加大。

from pmdarima import auto_arima model_arima = auto_arima( df['value'].dropna(), seasonal=True, m=7, trace=True, stepwise=True, approximation=False, n_fits=50 ) print(model_arima.summary())

搜索出来的最优模型是 ARIMA(2,1,2)(1,1,0)[7],非季节性部分用两个自回归项和两个移动平均项,季节性部分只保留了季节自回归项。这个结果比较经典,既有一阶差分,也有一阶季节差分,说明序列既存在整体趋势,也存在以周为单位的季节漂移。

拿到 ARIMA 模型后,用 in-sample 的 fitted_values 计算残差序列。注意 fitted_values 的长度和原始序列一样,但前几个值会因为模型初始化而缺失,需要去掉或者填充。我保留了与原始序列等长的残差数组,开头缺失部分用 NaN 表示,后续构造滑动窗口时会自然跳过这些样本。

in_sample_pred = model_arima.predict_in_sample() residual = df['value'].dropna().values - in_sample_pred

残差序列构造出来后,我做了两个检查。第一,残差的均值是否接近 0,如果明显偏离,说明 ARIMA 的偏误是系统性的,可以尝试调整模型;第二,残差的自相关图是否还存在显著的结构,如果残差还有周期性,说明季节项提取不够彻底。项目实测中,残差的自相关在滞后 14 处仍有一个小尖峰,这就是 CNN-LSTM 需要学习的那部分非线性残差模式。

4.2 CNN-LSTM 模型结构与参数设计

模型结构上,我采用了"卷积 + 池化 + LSTM + 全连接"的经典堆叠方式。输入形状是 (None, 14, 1),第一个维度是样本数,第二个维度是窗口长度 14,第三个维度是特征数 1。

from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Conv1D, MaxPooling1D, LSTM, Dense, Dropout from tensorflow.keras.optimizers import Adam model = Sequential() model.add(Conv1D(filters=64, kernel_size=3, activation='relu', input_shape=(window, 1))) model.add(MaxPooling1D(pool_size=2)) model.add(Conv1D(filters=32, kernel_size=3, activation='relu')) model.add(LSTM(units=64, return_sequences=False)) model.add(Dropout(0.2)) model.add(Dense(units=1))

第一层卷积输出形状变成 (None, 6, 64),经过 MaxPooling 之后是 (None, 3, 64)。第二层卷积的 kernel_size 是 3,但因为输入长度只剩 3,卷积核实际上会在整个时间轴上滑动,输出形状为 (None, 1, 32)。这时 LSTM 的输入只有一个时间步,理论上已经退化成普通全连接层。这里我踩过一个坑。

如果只有一层卷积加池化后直接接 LSTM,时间步长度通常会保留下来;但加了两层卷积后,序列长度被压缩得过于剧烈,LSTM 实际上学不到序列信息。我最终的调整是减少池化层,改为第一层卷积后不池化,或者把第二层卷积的 kernel_size 改成 1,保证 LSTM 输入至少有 4 到 6 个时间步。模型结构需要根据输入长度灵活调整,不能死搬代码。

4.3 训练设置:学习率、批次与早停机制

训练配置上,我用 Adam 优化器,初始学习率 0.001,损失函数用均方误差 MSE。批次大小设为 32,训练轮次上限是 100,同时配合 EarlyStopping 和 ReduceLROnPlateau 两个回调。EarlyStopping 监视验证集损失,连续 10 轮不下降就停止训练,并恢复最佳权重;ReduceLROnPlateau 则是在验证损失连续 5 轮没有改善时把学习率降为原来的 0.5,帮助模型跨过局部极小点。

from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau early_stop = EarlyStopping(monitor='val_loss', patience=10, restore_best_weights=True) reduce_lr = ReduceLROnPlateau(monitor='val_loss', factor=0.5, patience=5, min_lr=0.00001) history = model.fit( X_train, y_train, validation_data=(X_val, y_val), epochs=100, batch_size=32, callbacks=[early_stop, reduce_lr], verbose=1 )

实测下来,模型一般在第 20 到 30 轮就触发早停,验证集损失在初期快速下降,随后进入平台期。使用 EarlyStopping 最大的好处是省心,不需要反复调整 epoch 数量。唯一要注意的是 patience 的取值,太小会在验证损失尚在下降时提前停止,太大又可能让模型进入过拟合区间。在样本量小于 1000 的项目中,patience 取 10 是个稳妥的默认值。

5. 模型评估与结果对比分析

5.1 评估指标选择:RMSE、MAE 与 MAPE

时间序列预测的评估指标不能只看一个,RMSE 对大误差敏感,MAE 反映平均误差水平,MAPE 则用于衡量相对误差。这里数据没有零值,MAPE 是安全的;如果序列本身包含接近 0 的值,MAPE 会爆炸,要改用 SMA 或 WMAPE。

from sklearn.metrics import mean_squared_error, mean_absolute_error def rmse(y_true, y_pred): return np.sqrt(mean_squared_error(y_true, y_pred)) def mape(y_true, y_pred): return np.mean(np.abs((y_true - y_pred) / y_true)) * 100

有一点必须强调:所有评估指标都要在"反归一化"之后计算。CNN-LSTM 的输出是在 [0,1] 区间内的,直接用这个值算 RMSE 没有业务含义,必须先通过 scaler.inverse_transform 还原成原始数值,再与真实值比较。ARIMA 的预测值本身就是原始尺度,两者融合前要保证尺度一致。

5.2 单模型与组合模型实测对比

在测试集 70 天数据上,我分别跑了纯 ARIMA、纯 CNN-LSTM、ARIMA-CNN-LSTM 组合模型三种方案。测试结果如下表所示:

模型RMSEMAEMAPE
纯 ARIMA18.4213.616.28%
纯 CNN-LSTM16.7512.085.11%
ARIMA-CNN-LSTM 组合11.938.543.64%

从数字上看,组合模型的 RMSE 比纯 ARIMA 低了约 35%,比纯 CNN-LSTM 低了约 28%。这个提升幅度在我的预期之内,因为残差修正架构让 ARIMA 先提取线性主趋势,深度学习模型只需要处理残差,学习难度大幅降低。纯 CNN-LSTM 虽然理论上也能拟合非线性趋势,但没有 ARIMA 的差分平稳化处理,遇到原始序列的强趋势时,卷积层的特征提取效率会打折扣。

有意思的是,组合模型的预测误差分布更均匀。纯 ARIMA 在促销波动段的误差特别大,MAPE 能达到 12%;组合模型在同区间的 MAPE 降到 5% 左右。这说明 CNN-LSTM 确实学到了残差中的突变模式,对突发波动的预测能力是明显增强的。

6. 常见问题排查与实战避坑指南

6.1 数据泄漏:时间序列任务的头号陷阱

数据泄漏在时间序列任务里太容易发生了。比如 MinMaxScaler 在完整序列上 fit,再分训练集和测试集,测试集的信息就泄漏到了训练过程中。另一个常见的泄漏是把归一化放在滑动窗口切分之后,但没有先分割训练集,导致构造的训练样本里含有测试集的数据。我在项目早期就犯过这个错误,看起来验证集指标很漂亮,一上真实数据就崩盘,后来才发现是归一化的 fit 范围出了问题。

正确的流程是:先按时间顺序切分原始序列,再对训练部分 fit scaler,然后分别 transform 训练、验证和测试部分。滑动窗口的构造也要在划分之后进行,确保训练集和验证集之间没有窗口重叠。如果窗口长度是 14,训练集最后一个样本的末端日期不能延伸到验证集第一个样本的起始日期,否则就是间接的泄漏。严格做法是在切分时直接留出窗口长度的缓冲带。

6.2 LSTM 训练不稳定的排查经验

有几周我的模型在训练集上损失下降得很顺利,但验证集损失前几轮疯狂跳动,然后直接发散。排查下来发现是归一化后数据里有极小值,导致梯度异常。后续加了梯度裁剪,并检查了输入数据的分布,发现某几天的异常低值没有被清洗掉,缩放后形成尖峰。把离群值处理掉之后,训练曲线立刻稳定了。

LSTM 对输入尺度极其敏感,即使归一化到 [0,1],如果数据中存在大量接近 0 和接近 1 的极端值,tanh 和 relu 的梯度依然会不稳定。我的建议是归一化后用直方图快速检查分布,如果两端堆积过多,可以做一次 log1p 变换后再缩放。这种处理在销售数据、流量数据里很常见。

6.3 融合策略的细节:残差预测结果的上限约束

最后一步把 ARIMA 预测值和 CNN-LSTM 残差预测值相加时,要注意残差预测结果可能会出现不合理的极端值。深度学习模型虽然学了残差的模式,但它不会知道残差应该在什么范围内波动。如果某一天的残差预测值是 30 个单位,而历史上残差从来都在 [-10, 10] 之间,那这个结果就有问题。我的做法是对残差预测值做分位数截断,超出历史残差 98.5% 分位数或低于 1.5% 分位数的值,强制压缩到边界值。

这个约束看似简单,但在真实预测中非常有用,它避免了模型在极端情况下给出离谱的数字。另一个细节是:如果 ARIMA 的预测误差本身很大,残差预测结果再准也无济于事,所以顺序上要先用 ARIMA 把大趋势拟合到位,再谈残差修正。两个模型的预测能力是先后关系,不是平行关系。

6.4 其他常见问题速查表

问题可能原因解决方案
auto_arima 搜索过慢stepwise=False 且 n_fits 过大改用 stepwise=True,n_fits=20
LSTM 训练损失为 NaN学习率过高或数据未归一化降低学习率至 0.0001,检查数据分布
预测序列整体偏移未反归一化或残差截断过紧检查 inverse_transform,放宽分位数
验证集指标好但测试集较差时间序列数据划分不严格增加窗口长度的缓冲带,重新划分
残差序列仍有明显周期性ARIMA 季节项设置不当调整 m 参数,或尝试外生变量

实际做完这一整套流程,我的体感是:ARIMA-CNN-LSTM 这个组合不是简单的模型叠加,而是一种互补式的预测策略。ARIMA 把线性规律学到极致,CNN-LSTM 把非线性残差消化掉,各自的短板都被对方弥补。这个思路可以迁移到电力负荷预测、交通流量预测、库存需求预测等多个场景,只要数据时序特征明显,残差修正的框架就成立。

最后再分享一个小技巧:CNN-LSTM 的输入窗口长度不要与 ARIMA 的季节周期完全一致。你可以尝试让窗口略大于一个周期,比如周周期是 7,窗口用 10 或 14,这样 LSTM 能多看到一点趋势外延,预测稳定性往往比恰好等于周期要好。这点是我在多次对比测试里发现的规律,建议你在自己项目里也试一试。

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

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

立即咨询