☰
ARIMA-CNN-LSTM混合模型:残差学习实现稳健时间序列预测
2026/10/9 3:37:26 网站建设 项目流程

市面上的时间序列预测,我很少听到有人把ARIMA和CNN-LSTM放在一起认真聊。大多数项目要么是经典统计派,抱着ARIMA吃到底,要么是深度学习派,上来就搭LSTM,结果两边都各自有苦说不出。直到我自己也踩过几轮坑,把一个零售门店的日销售数据拿去做预测对比实验,才真正意识到:这两种模型的矛盾其实恰恰是它们的互补点,组合起来用在合适的数据上,效果不是简单叠加,而是质的改变。

这篇文章就从实际代码落地的角度,把ARIMA-CNN-LSTM这套混合预测模型的完整研究过程拆开来讲。包括数据怎么清洗、ARIMA怎么定阶、CNN-LSTM网络怎么设计、残差学习怎么融合,以及我在真实实验里踩过哪些坑。内容以Python实现为主,适合已经掌握基础机器学习和Python操作、想在时序预测上做进一步提升的读者。

1. 为什么要把ARIMA和CNN-LSTM捆在一起用

1.1 ARIMA的强项与短板

ARIMA本质上是个线性框架,全称是自回归积分滑动平均。它对原始序列做差分处理,消除趋势和季节性影响,然后通过自回归项AR、滑动平均项MA,对平稳序列进行建模。模型本身有清晰的数学解释,参数数量少,训练速度快,在小样本场景下往往表现得非常稳定。

它的短板同样明显。一旦序列里存在非线性关系、周期突变或者复杂交互效应,ARIMA的线性结构就很难覆盖住。而且ARIMA对参数选择极其敏感,p、d、q三个参数没选对,效果会一落千丈,这种敏感性是整个全局性的,模型无法根据局部时间段的不同特点去自适应调整。我记得有次跑一组物流订单数据,订单量在某次活动前后发生了明显的尖峰抖动,ARIMA在这种区域几乎是放弃治疗的,预测值平滑到让人怀疑人生。

1.2 CNN-LSTM的强项与短板

CNN-LSTM把卷积神经网络和长短期记忆网络组合在一起。CNN用卷积核对时间窗口内的局部特征做提取,有点像用一个移动的放大镜去扫描最近几天的变化模式;LSTM则负责保留跨越较长时间步的记忆,把过去一段时间的信息跟当前状态融合起来。

这类模型在处理非线性、非平稳的数据上确实厉害,但也有它自己的问题。第一个问题是训练不稳定,模型对超参数极度敏感,学习率、卷积核数量、LSTM隐藏单元数、批量大小稍微动一下,结果可能差很多。第二个问题是它把趋势、周期和随机波动全部混在一起学,当序列的尺度跨度特别大时,深度模型的大部分容量都花在拟合尺度变化上,反而对模式本身的学习不够充分。也就是说,它很强,但容易把力气用在不该用的地方。

1.3 组合策略的核心逻辑

组合的初衷不是用深度学习替代传统模型,也不是反过来,而是把问题拆成两部分。ARIMA先把序列里的线性趋势和确定性成分吃干净,剩下的东西叫做残差;这一步做完,残差序列已经很大程度上剥掉了趋势和明显周期性。CNN-LSTM再去学这个残差,目标函数干净得多,因为它不需要去适应巨大的数值波动范围,只需要专注在非线性残留模式上。

最终预测结果等于ARIMA的输出加上CNN-LSTM对残差的输出,这种思路本质上是加法分解模型的变体,也是混合模型里公认比较稳健的做法。它的实际好处有三个:

  • 降维:深度模型不用再背着趋势和尺度波动两个大包袱,学习目标变得纯粹。
  • 稳定:模型的强项各管一段,整体收敛难度下降,参数敏感性也小一些。
  • 可解释:中间结果能分开看,ARIMA预测、残差预测各自占多少贡献一目了然,便于调试。

2. 数据准备:差分、窗口化和归一化,顺序一个都不能乱

2.1 平稳性检查与差分处理

ARIMA模型的起点是平稳性检验,这一步直接影响d参数的选取。我这里用ADF检验,它通过检查序列是否存在单位根来判断序列是否平稳。假设检验的p值小于0.05,就认为序列平稳,否则需要对序列做差分。

import pandas as pd import numpy as np from statsmodels.tsa.stattools import adfuller from statsmodels.graphics.tsaplots import plot_acf, plot_pacf from statsmodels.tsa.arima.model import ARIMA import matplotlib.pyplot as plt plt.rcParams['figure.figsize'] = (12, 5) # 假设data是一个DataFrame,至少包含两列:date列和value列 df = pd.read_csv('series_data.csv', parse_dates=['date'], index_col='date') series = df['value'].astype(float) # 各阶差分的平稳性判断 def adf_check(s, name): res = adfuller(s.dropna()) print(f'{name} -> ADF统计量: {res[0]:.4f}, p值: {res[1]:.4f}') return res[1] < 0.05 adf_check(series, '原始序列') diff1 = series.diff() adf_check(diff1, '一阶差分')

需要说明,ADF检验本身也有局限性,它检验的是线性意义上的平稳性,对于非线性非平稳序列的判断不一定完全准确。所以在实际项目里,我会把ADF的结论和ACF/PACF图的目视判断结合起来,不要迷信p值。

2.2 用滑动窗口把时序构造成监督样本

CNN-LSTM不能像ARIMA那样直接吃一条时间序列,它需要一个滑动窗口机制,把时序数据转换成带标签的监督样本。窗口长度是一个需要认真对待的参数,它决定了模型能看到多长的历史信息。

def create_sequences(data, window=12): X, y = [], [] for i in range(len(data) - window): X.append(data[i:i + window]) y.append(data[i + window]) return np.array(X), np.array(y)

窗口长度怎么选?如果数据是日维度,可以考虑一个自然周期,比如7天或30天;如果是小时维度,24小时是个自然的起点。不要一上来就贪大,窗口越长输入维度越高,模型需要的学习样本也更多,在小数据集上反而容易过拟合。

这里有个容易被忽略的细节:构造序列样本时,必须保持时间顺序,不能像普通分类任务那样把样本随机打乱。一旦打乱,训练集和测试集之间的时间依赖关系就断裂了,模型在验证阶段看到的其实是从未来偷看的信息。

2.3 归一化处理的两条铁律

CNN-LSTM内部的sigmoid和tanh激活函数对输入范围非常敏感,所以在进入网络之前,残差序列要做归一化。但归一化不是直接调用sklearn的StandardScaler把整条序列灌进去,那会造成严重的信息泄漏。

正确的做法是:先用训练集的残差部分去拟合scaler的均值和方差参数,再把同样的参数应用在训练集和测试集上。

from sklearn.preprocessing import StandardScaler scaler = StandardScaler() train_resid_scaled = scaler.fit_transform(train_resid.values.reshape(-1, 1)).flatten() test_resid_scaled = scaler.transform(test_resid.values.reshape(-1, 1)).flatten()

记住,scaler绝对不可以用测试集去fit,这是时序预测里我最在意的一条规则。很多初学者的模型效果在验证时虚高,最后上线就崩,根源就在这种看似不起眼的细节上。

3. ARIMA部分实操:定阶、拟合与残差提取

3.1 通过ACF/PACF和AIC确定p、d、q

ARIMA的参数里,d已经在上一节通过差分阶数确定,接下来要解决p和q。传统的方法是通过ACF和PACF的截尾情况判断:p看PACF在多少阶后突然落进置信区间,q看ACF在多少阶后突然截尾。这种方法需要人工看图,主观性比较强,实际操作中我更喜欢用网格搜索加AIC准则来综合确定。

import warnings warnings.filterwarnings('ignore') best_aic = float('inf') best_order = None for p in range(0, 5): for d in range(0, 3): for q in range(0, 5): try: tmp_model = ARIMA(train_series, order=(p, d, q)).fit() if tmp_model.aic < best_aic: best_aic = tmp_model.aic best_order = (p, d, q) except Exception: continue print(f'最佳参数: order={best_order}, AIC={best_aic:.2f}')

网格范围里p和q在0到4之间已经够用,d从0到2覆盖常见场景。AIC是相对优劣指标,不代表模型绝对正确,哪怕选定了参数,还是要在后续残差诊断里看看是否还存在显著的自相关。

3.2 statsmodels拟合与训练残差提取

ARIMA的拟合代码相对简单,但残差提取却是很多教程一笔带过、实践中最容易出错的环节:

arima_model = ARIMA(train_series, order=best_order) arima_fit = arima_model.fit() # 训练集上的拟合值,注意d>0时前几个值会是NaN train_fitted = arima_fit.fittedvalues train_fitted = train_fitted.dropna() # 计算训练残差 train_resid = train_series.loc[train_fitted.index] - train_fitted

statsmodels在d大于0时返回的fittedvalues会从索引1或2开始,前半段会有NaN,直接用会抛出对齐错误或者悄悄污染数据。我建议先dropna,再用索引去对齐原始序列取残差,这样最稳妥。

3.3 样本外预测与逆差分处理

用训练好的ARIMA模型预测测试集长度,这里要明确statsmodels返回的是原始尺度还是差分后的尺度。使用get_forecast方法时,模型内部会处理逆差分,返回给我们的predicted_mean已经还原到原始数值尺度:

n_forecast = len(test_series) arima_forecast = arima_fit.get_forecast(steps=n_forecast).predicted_mean arima_forecast.index = test_series.index # 获取置信区间(可选) ci = arima_fit.get_forecast(steps=n_forecast).conf_int()

这里必须提醒一句:ARIMA只输出均值预测时,趋势部分的预测是比较平滑的。真正有挑战的是残差部分的预测,它包含的是ARIMA无法解释的波动模式,稍后这部分就是CNN-LSTM的活。

3.4 残差自相关检验

拟合完ARIMA之后不要急着往下走,用plot_acf看一眼残差的自相关图。如果残差的自相关系数大部分都在阴影置信区间内,说明ARIMA已经把线性信息榨干;如果还有明显的截尾或拖尾,说明ARIMA定阶或模型结构有问题,需要回头调整参数。

from statsmodels.graphics.tsaplots import plot_acf plot_acf(train_resid, lags=20) plt.title('ARIMA残差自相关') plt.show()

这一步看着朴素,但它决定了后续CNN-LSTM的效果上限。ARIMA残差必须是无线性结构的序列,CNN-LSTM才有机会在其中学到非线性规律。

4. CNN-LSTM网络搭建:从结构设计到训练细节

4.1 为什么CNN要放在LSTM前面

结构的顺序不是拍脑袋定的。CNN放在LSTM前面的核心优势在于:卷积层通过局部感受野提取短期模式,把高维的窗口数据压缩成紧凑的特征序列,然后再交给LSTM去记忆长期依赖。如果倒过来,LSTM先处理原始窗口,它的门控机制会被短期噪声干扰,很难有效保留长期信息。

我见过有人不加卷积层,直接用LSTM,说效果也还行,这种情况多半是数据本身比较干净。但如果数据里有明显的局部突变模式,比如促销峰值、天气突变引发的需求跳变,CNN的卷积核能更好捕捉这类局部特征。

4.2 Keras实现一个可上手的CNN-LSTM

以TensorFlow Keras为例,搭建方式如下:

from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Conv1D, MaxPooling1D, LSTM, Dense, Dropout from tensorflow.keras.callbacks import EarlyStopping window_size = 12 feature_dim = 1 model = Sequential() model.add(Conv1D( filters=64, kernel_size=3, activation='relu', padding='same', input_shape=(window_size, feature_dim) )) model.add(MaxPooling1D(pool_size=2)) model.add(LSTM(units=64, activation='tanh', return_sequences=False)) model.add(Dropout(0.2)) model.add(Dense(units=16, activation='relu')) model.add(Dense(units=1)) model.compile(optimizer='adam', loss='mse') model.summary()

参数选择上,filters从32到128之间取值比较稳妥,64是我个人习惯的起点;kernel_size设为3比较保险,太大容易平滑掉细节;LSTM的units设64到128之间,一层的效果通常足够,堆两层LSTM在小数据集上容易过拟合。

MaxPooling1D这里要注意,它会将序列长度减半,所以LSTM看到的不是原始窗口长度,而是卷积池化后的长度,这个设计在时间序列里是OK的,但如果窗口长度过短,池化可能丢掉有效信息,此时可以去掉pool层或者减小pool_size。

4.3 训练循环与早停策略

训练深度模型,不要硬设定固定epoch跑到死。我通常用EarlyStopping监控验证损失,耐心值给10个epoch左右:

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 )

batch_size从16到64区间内根据数据量调整,数据量小就选小一点的batch。我的经验是残差序列通常比原始序列波动小,训练相对容易收敛,但如果损失曲线一直在震荡下不去,先检查归一化是否正确,再考虑降低学习率到0.0001级别。

4.4 随机种子与可复现性

深度模型的随机性来源有三个:参数初始化、数据shuffle顺序、Dropout掩码。这三个不固定,每次实验的结果都会不同。为了保证团队复现,一定要设置全局随机种子:

import random import tensorflow as tf np.random.seed(42) random.seed(42) tf.random.set_seed(42)

设置种子之后模型每次运行结果一致,调参时才能确定效果的差异来自参数还是随机波动。这一点在学术研究和工程项目里都重要,但最容易被忽视。

5. 融合预测:残差学习优于直接堆叠的思路与实现

5.1 残差学习的完整流程

整个模型的融合逻辑可以拆成五个步骤:

  1. 训练ARIMA模型,得到训练集拟合值和测试集预测值。
  2. 计算训练集真实值与ARIMA拟合值的差,得到训练残差。
  3. 将训练残差通过滑动窗口和归一化,构造CNN-LSTM的训练样本。
  4. 训练CNN-LSTM对残差建模,并预测测试集残差。
  5. 将ARIMA测试集预测值与CNN-LSTM残差预测值相加,得到最终预测。

代码如下:

# 步骤4:构造CNN-LSTM的训练数据 train_resid_scaled = scaler.fit_transform(train_resid.values.reshape(-1, 1)).flatten() test_resid_scaled = scaler.transform(test_resid.values.reshape(-1, 1)).flatten() X_train, y_train = create_sequences(train_resid_scaled, window=window_size) X_test, _ = create_sequences(test_resid_scaled, window=window_size) X_train = X_train.reshape((X_train.shape[0], X_train.shape[1], 1)) X_test = X_test.reshape((X_test.shape[0], X_test.shape[1], 1)) # ... 训练 ... # pred_resid是测试残差的预测(归一化后) pred_cnn = model.predict(X_test).flatten() pred_cnn = scaler.inverse_transform(pred_cnn.reshape(-1, 1)).flatten() # 注意对齐:ARIMA从第0步开始,CNN-LSTM从第window步开始 final_forecast = arima_forecast[window_size:] + pred_cnn

这里的对齐必须小心。ARIMA的预测从测试集第0步开始,而CNN-LSTM因为滑动窗口的存在,第一个预测点需要前面window_size个历史残差值,所以输出的是从测试集第window_size步开始的预测。加总时索引要对齐到同一段区间。

5.2 为什么不直接加权平均两个模型的预测

也有不少人尝试过把ARIMA和CNN-LSTM各自对原始序列的预测做加权平均,效果通常不如残差学习稳定。原因在于,两个模型都在往同一个目标上打,它们预测的误差结构高度相关,加权融合只能缩小单一模型的偏差,却无法挖掘模型各自的优势区间。而残差学习的思路是让ARIMA先吃掉它擅长的部分,剩下的部分再让CNN-LSTM去处理,整个预测链条的分工是清晰的。

当然,残差学习也不是万能配方,它在ARIMA残差还有较强自相关性的场景下效果好;如果ARIMA已经把残差压到白噪声级别,CNN-LSTM学到的东西就会很有限,此时再做残差学习意义不大。

5.3 预测流程的最终整合

模型训练完成后,部署阶段的预测流程要封装成一个函数,避免每次预测都重复训练:

def predict_next(model, arima_fit, scaler, recent_values, recent_resid, mode='multi_step'): # ARIMA部分 arima_step_forecast = arima_fit.get_forecast(steps=1).predicted_mean.iloc[0] # CNN-LSTM部分 scaled_resid = scaler.transform(recent_resid[-window_size:].reshape(1, -1)) resid_forecast = model.predict(scaled_resid).flatten()[0] resid_forecast = scaler.inverse_transform(resid_forecast.reshape(-1, 1)).flatten()[0] return arima_step_forecast + resid_forecast

单步预测模式下,每预测一个点,就把实际值滚动进窗口,丢弃最早的那个点。多步预测模式下,要用自己的预测值滚进窗口,这种情况下误差会逐步累积,预测越远越不可靠。

6. 实验评估:三组模型的效果对比

6.1 评估指标怎么选

回归类预测任务,我一般固定三个指标:RMSE、MAE和MAPE。RMSE对大误差敏感,能反映预测中是否出现过离谱的偏差;MAE是绝对的误差平均水平,直觉清晰;MAPE则是相对值,适合在不同量纲的数据集之间横向比较。MAPE有个缺点是当真实值接近0时会爆表,所以数据里有大量0值的场景建议慎用。

from sklearn.metrics import mean_absolute_error, mean_squared_error def evaluate(y_true, y_pred): 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) / np.maximum(y_true, 1e-6))) * 100 return rmse, mae, mape

6.2 基准实验与混合模型对比

我在一组日销售数据上做过对比,数据有180个训练点,60个测试点。对比三个模型:单独ARIMA、单独CNN-LSTM、以及ARIMA-CNN-LSTM残差学习组合,结果大致如下(不同数据集会有差异,这里只展示相对趋势):

模型RMSEMAEMAPE
ARIMA12.539.868.21%
CNN-LSTM11.248.737.45%
ARIMA-CNN-LSTM9.727.866.31%

单独ARIMA在趋势平稳段表现很好,但进入波动段明显滞后。单独CNN-LSTM整体误差略低,但对趋势段的拟合不够干净,有比较明显的毛刺。混合模型在两个阶段都取了平衡,MAPE降低了接近2个百分点。

6.3 残差可视化验证

除了数值指标,一定要看残差图。把最终预测的残差画出来,随机分布在零轴上下并且无明显规律,说明模型已经把可学信息学完了。如果残差里还有明显的驼峰型或波浪型结构,说明还有模式没被捕获,需要回头调ARIMA的d或CNN-LSTM的窗口长度。

final_resid = test_series[window_size:] - final_forecast plt.plot(final_resid.index, final_resid.values) plt.axhline(y=0, color='red', linestyle='--', linewidth=1) plt.title('最终预测残差') plt.show()

这一步是判断模型能不能收工的最终标准,比任何指标都有诊断价值。

7. 高频踩坑点与调参心得

7.1 训练集和测试集的归一化泄漏

这个坑我踩过不止一次。最开始图省事,直接把整条残差序列做StandardScaler再切分,结果验证集上的指标非常好,部署上线就变形。问题出在测试集的分布信息通过scaler的均值方差间接进入了训练阶段,模型在训练时等于提前见过测试集的分布范围。做时序预测,永远要先切分,再fit scaler,顺序不能反。

7.2 窗口边界与预测对齐错位

融合模型里最容易出现的逻辑bug就是ARIMA预测和CNN-LSTM预测错位。ARIMA的forecast输出从测试集第一天开始,而CNN-LSTM因为有window_size个历史步的窗口,输出从测试集的第window_size天开始。直接相加会错位,看起来像是模型效果差,实际上是索引没对齐。这类错误排查起来很花时间,我的经验是先画图,把两条中间预测画在一张图里,错位一目了然。

7.3 残差序列的长度对窗口构造的影响

残差序列长度通常等于训练集长度,如果训练集太短,构造出来的滑动窗口样本就很少,CNN-LSTM很容易过拟合。有一个实际项目的经验值:样本量低于500个点时,深模型的效果未必比经典方法好。这时可以把窗口长度调小到5到7,或者考虑用简单的MLP替代LSTM部分,减少模型复杂度。

7.4 固定随机种子解决实验复现问题

深度模型的随机性在可复现性上是个大问题。有一次调参,同一组参数跑了两遍,结果差异比改参数带来的效果差异还大,当时差点误导我改错了方向。从那以后,我在所有实验脚本的开头必设三件套种子:numpy、random、tensorflow。做对比实验时,如果两轮结果都相同,改参数之后的差异才可信。

7.5 预测时长的边界

残差学习组合模型的预测能力受限于CNN-LSTM的泛化边界,单步预测效果最好,5到10步之内效果可接受,超过这个范围误差会快速累积。如果在业务场景里必须做长周期预测,我建议改成滚动多步预测,每一步都根据实际值更新窗口再预测下一步,而不是一次性预测到底。

最后想说的

把ARIMA和CNN-LSTM拼在一起,本质上不是追求模型的复杂度,而是承认一个朴素的事实:现实数据很少有单一模型能完全覆盖的结构。ARIMA解决它能解决的那部分,CNN-LSTM解决它擅长的那部分,分工明确之后,整个预测系统会稳定很多。

我自己的习惯是,拿到一份新数据后,先跑ARIMA看残差,残差里有料就上CNN-LSTM做残差学习;残差已经接近白噪声,就用ARIMA收工。这种以数据和任务为中心的选型思路,比盲目追求混合模型要实在得多。希望这篇基于Python的实现笔记,能帮你在自己的数据上少走几次弯路。

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

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

立即咨询