☰
ARIMA-CNN-LSTM混合模型实战:时间序列预测的组合拳
2026/10/1 3:50:16 网站建设 项目流程

拿到一个时间序列预测需求,很多朋友的第一反应是在ARIMA和LSTM之间二选一。ARIMA经典可靠,LSTM擅长抓非线性特征,可实际数据根本不讲道理——它既有明显线性趋势,又夹杂复杂的周期波动和随机扰动,单用哪个都差一口气。这篇项目笔记就来拆一个我常用的“组合拳”:ARIMA-CNN-LSTM混合预测模型。整套方案我用Python完整实现过,代码可以直接跑,关键细节都会讲透。适合正在做销量预测、负荷预测、宏观指标预测的读者参考,也适合想搞懂统计模型和深度学习如何配合的新手。

1. 混合模型整体设计与思路拆解

1.1 为什么非要把ARIMA、CNN、LSTM三个模型叠在一起

单一模型的局限其实不用我多解释。ARIMA是线性模型,对趋势和季节性的提取很强,但对突变点、异常波动、非线性交互基本无能为力。LSTM虽然能学非线性,可它天生对数据的尺度和分布敏感,碰到强趋势序列,训练时损失函数容易被趋势项主导,最后学出来的往往是“上一个值加一个小修正”这种平庸结果。

CNN在时间序列里并不是用来做图像识别,而是用它的一维卷积核提取局部模式。时间序列里的短期波动、周期片段、突变形态,本质上就是长度不等的局部模式。CNN能把这些模式抽象成特征图,再交给LSTM去学习时间依赖。所以三个模型放在一起不是堆参数玩,而是各自解决一类问题:ARIMA负责线性趋势和确定性周期,CNN负责局部特征提取,LSTM负责长短期依赖的建模。

这个逻辑想通之后,整个项目的设计方向就很清晰了:不是把三个模型做加权平均,而是让它们分工协作。

1.2 三个模型各自扮演什么角色

很多人容易把混合模型理解成“模型越复杂越好”,实际做下来就会发现,关键在于每个模型都要做自己最擅长的那件事,同时避免互相干扰。我在这个项目里给三个模型定的分工是这样的:

  • ARIMA:负责数据里的确定性成分,包括趋势、季节性、周期性,以及一阶或二阶差分后的平稳相关结构。它输出的预测值代表“按照历史规律推出来的基线”。
  • CNN:负责从残差序列中提取局部相关模式,比如连续几个时间点的联动变化、突变的形态特征。一维卷积在这里等价于一个带学习能力的滑动滤波器。
  • LSTM:负责在CNN提取的特征之上,继续建模时间上的依赖关系,尤其是当序列存在较长周期的记忆效应时。

这个分工有个隐性好处:ARIMA把趋势和周期吃掉之后,剩下的残差序列尺度小、相对平稳,CNN和LSTM不用浪费太多网络容量去拟合大趋势,可以集中精力学习微观模式。

1.3 串联残差建模:我最后选定的结构

ARIMA-CNN-LSTM的混合方式有好几种,并行加权、串行特征拼接、残差串联都有人做。我实践下来最稳的是残差串联建模,结构大致是这样的:

第一步,用ARIMA对原始序列做拟合和预测,得到趋势基线预测值。 第二步,用原始值减去ARIMA的拟合值,得到残差序列。 第三步,对残差序列做归一化,构造滑动窗口数据集,交给CNN-LSTM来学习和预测。 第四步,最终预测值等于ARIMA预测值加上CNN-LSTM预测的残差值。

为什么选残差串联而不是直接把ARIMA预测结果作为一个特征喂给LSTM?因为趋势项数值范围大,混进特征里会主导神经网络的梯度方向。残差建模相当于先做了一次“非线性差分”,把数据范围压缩到零附近,神经网络在零附近学映射,比在大数值范围上稳定得多。

1.4 这套结构的应用边界

必须说句实话,混合模型不是万能的。如果你的数据本身线性很强、波动很小,ARIMA单独用就已经够了,叠神经网络纯粹是增加复杂度,还可能因为残差没有可学模式而引入噪声。反过来,如果数据强非线性、几乎没有确定趋势,那跳过ARIMA直接让CNN-LSTM吃原始数据效果往往更好。

ARIMA-CNN-LSTM适合的场景,是那种既有明显趋势和周期性,又夹杂着非线性局部扰动的序列。典型的比如月度销量、电力负荷、流量指标、宏观月度指标这类数据。我自己常用它处理月度指标预测,背景噪音大、节假日效应明显、又存在长期趋势,单一模型很难同时兼顾。

2. 动手前的关键准备:数据与序列处理

2.1 时间序列数据最容易被忽视的三个问题

时序项目和普通机器学习项目最大的区别在于:样本之间不是独立的。这个特性会在数据处理的每个环节给你挖坑。

第一个问题是缺失值。很多人习惯直接删掉缺失行,这在普通表格数据里问题不大,时序数据一旦删行,时间间隔就乱了,ARIMA的滞后结构全被打乱。正确做法是先用前后值插值或线性插值补齐,保证时间轴连续。

第二个问题是异常值。ARIMA对离群点很敏感,一个异常值就能把拟合的滞后系数带偏。深度学习模型倒是对单个异常值相对鲁棒,但异常值会影响归一化的上下界。建议先做一次可视化,把明显跳变点找出来,用中位数或前后均值平滑掉,再进入建模流程。

第三个问题是时间索引。用pandas读数据后,一定要把时间列转成datetime类型并设成索引,同时检查频率是否连续。我见过很多新手直接把数据框丢给模型,结果ARIMA的forecast步数和真实时间轴对不上,最后拼预测值时错位得一塌糊涂。

2.2 ARIMA定阶到底怎么定

ARIMA有三个参数:p(自回归阶数)、d(差分阶数)、q(移动平均阶数)。很多教程让人看ACF和PACF图的截尾和拖尾来判断,想法没错,但实际操作中图往往不干净,尤其在经济和业务数据上,截尾拖尾判断经常模棱两可。

我自己的做法是先用ADF检验确定d。如果原始序列不平稳,做一阶差分后再检验,直到通过显著性水平为止。大多数业务场景d取1就够,取2要谨慎,过度差分会把原本可预测的信息也差掉。

确定p和q我一般用两步:先用ACF和PACF图粗看一个范围,再用AIC/BIC做网格搜索。实际项目里我更推荐直接使用pmdarima库的auto_arima函数做stepwise搜索,它会自动在给定范围内找AIC最小的组合。不过要注意,auto_arima给出的结果不一定符合业务直觉,如果它选了很大的p值,往往说明数据里有较强的自回归结构,这时可以考虑先用ARIMA拟合,再观察残差是否还有可学习的模式。

2.3 归一化与滑动窗口构造

CNN-LSTM对输入尺度敏感,残差序列虽然已经比原始序列平稳,但还是需要归一化。我通常用MinMaxScaler或StandardScaler。MinMaxScaler适合已知上下界且分布比较均匀的残差,StandardScaler适合有极端偏态的情况。两个都试过,通常残差序列用StandardScaler更稳,因为残差的分布接近零均值对称,标准化后天然适配LSTM的tanh激活函数。

滑动窗口是一个关键超参数。窗口长度代表用过去多少个时间步来预测下一个时间步。对月度数据,我习惯先用12或者24,对应年度周期和两年周期。如果数据是周粒度,可以试8、12、26。窗口设太短,模型看不到周期信息;窗口设太长,训练样本数量骤减,而且LSTM需要更长训练时间。实际项目中可以做一个简单对比实验:窗口长度在候选值里跑一遍验证集误差,选最小的。

2.4 训练/测试划分的硬性规定

这句话我要反复强调:时间序列数据绝不能随机划分训练集和测试集,也不能用随机打乱的交叉验证。严格按时间顺序切分是硬性规定。否则你用未来数据训练了模型,再用过去的数据测试,整个评估结果就是骗自己的。

正确做法是保留最后10%到20%作为测试集。如果要做模型选择或调参,再从训练集末尾切出一段验证集。需要注意的是,构建滑动窗口时不能让窗口跨越训练集和测试集的边界,否则窗口里的样本既包含训练信息又包含测试信息,测试时又泄漏了。

这里有个容易忽略的细节:神经网络训练过程中的validation_split参数默认取训练数据的最后一部分,这个逻辑在时序上勉强可用,但如果你在训练前对数据做了shuffle,那验证集就变成了随机样本,时序验证就没意义了。所以要么训练前不shuffle,要么先切好验证集再训练。

3. Python完整实现与参数选择详解

3.1 环境与依赖

我的实验环境是Windows下的Python 3.10,核心库版本如下:

pandas==2.0.3 numpy==1.24.3 statsmodels==0.14.0 pmdarima==2.0.4 scikit-learn==1.3.0 tensorflow==2.13.0

statsmodels和pmdarima都提供ARIMA实现,pmdarima的auto_arima对自动定阶很友好,statsmodels则适合手动精细控制。两者不冲突,可以一起装。TensorFlow版本会影响keras的接口写法,我这里用tf.keras。

3.2 ARIMA建模部分

先读取数据。这里我以一套带趋势和季节性的月度指标序列为例,数据文件结构很简单:date列和value列。

import pandas as pd import numpy as np import warnings warnings.filterwarnings("ignore") from statsmodels.tsa.stattools import adfuller from pmdarima import auto_arima from sklearn.preprocessing import StandardScaler from sklearn.metrics import mean_squared_error, mean_absolute_error df = pd.read_csv("monthly_series.csv", parse_dates=["date"], index_col="date") df = df.asfreq("MS") # 确保月度频率连续 df["value"] = df["value"].interpolate(method="linear") # 按时间顺序切分 train_size = int(len(df) * 0.8) train = df.iloc[:train_size] test = df.iloc[train_size:]

ARIMA定阶直接用auto_arima。这里我把季节参数打开,m=12表示12个月的周期,如果你确定数据没有季节性,可以把seasonal设为False,速度会快很多。

arima_model = auto_arima( train["value"], start_p=0, max_p=5, start_q=0, max_q=5, d=None, # 让模型自动做单位根检验来确定差分阶数 seasonal=True, m=12, start_P=0, max_P=2, start_Q=0, max_Q=2, trace=True, stepwise=True, information_criterion="aic", error_action="ignore" ) print(arima_model.summary())

stepwise=True走的是启发式搜索,速度比全网格快很多,一般几十秒内能出结果。我用这套数据跑出来的是SARIMA(2,1,1)(1,1,1,12),也就是有季节分量的ARIMA。选它不是因为参数“好看”,而是在AIC指标下它最优,同时参数个数没有过于膨胀。

接着做样本内拟合和测试集预测:

arima_model.fit(train["value"]) train_pred = arima_model.predict_in_sample() test_pred = arima_model.predict(n_periods=len(test))

预测之后立刻计算残差。注意对齐索引:

residual = train["value"].values - np.asarray(train_pred).reshape(-1)

这里得到的residual就是交给CNN-LSTM去学的部分。

3.3 残差提取与CNN-LSTM构建

构造滑动窗口数据集,窗口长度我用12。上一步的标准化要用fit在训练残差上,再transform到测试残差,千万不能全量数据一起fit,否则又是泄漏。

def create_dataset(series, time_steps=12): X, y = [], [] for i in range(len(series) - time_steps): X.append(series[i: i + time_steps]) y.append(series[i + time_steps]) return np.array(X), np.array(y) scaler = StandardScaler() # 训练集残差是已知的,直接构造 train_residual_scaled = scaler.fit_transform(residual.reshape(-1, 1)).reshape(-1) X_train, y_train = create_dataset(train_residual_scaled, time_steps=12) # 测试集残差如何得到?ARIMA对测试集的预测是直接外推,而CNN-LSTM需要真实残差做监督信号 # 这里先说明:测试阶段我们先用训练集末尾窗口做单步迭代预测,因此先不构造测试集的X/y, # 而是把训练集末尾的窗口作为初始输入,后续在迭代循环中更新 X_init = train_residual_scaled[-12:].reshape(1, 12, 1)

CNN-LSTM模型结构如下。我先用Conv1D卷积核在输入序列上做特征提取,再经过池化压缩维度,接LSTM学习时间依赖,最后输出一个残差预测值。

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

有几个细节值得展开。padding="causal"在时间序列卷积里非常重要,它保证卷积核只能看到当前时刻及之前的信息,不会让未来数据“漏”进来参与当前时刻的特征计算,这在预测任务里是底线。池化层我放在卷积之后,是因为Conv1D输出的特征图仍然很长,直接丢给LSTM会让序列长度过长、计算量大,池化压缩后LSTM学起来也更稳定。Dropout放在LSTM后面,防止LSTM隐层过拟合。

3.4 模型训练与预测对齐

训练时用EarlyStopping防止过拟合,监控验证集损失:

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 )

batch_size取32是默认稳妥值,epochs给100但实际因为early stopping,一般五六十轮就停了。如果训练损失震荡严重,可以把batch_size调小到16或者把learning rate调低。最好一开始就用ReduceLROnPlateau回调,而不是干等Adam自动调整。

最关键的一步来了:ARIMA的test_pred是对未来多步的直接预测,CNN-LSTM的预测则是滚动单步迭代。两者如何对齐?我的做法是:CNN-LSTM从训练集末尾开始,用最近12个残差预测下一个残差,然后把这个预测值拼进窗口,再预测下一个,依次滚动到测试集末尾。

# 滚动预测残差 residual_forecast = [] current_window = X_init.copy() for _ in range(len(test)): next_res = model.predict(current_window, verbose=0)[0, 0] residual_forecast.append(next_res) # 移动窗口:丢掉最旧的值,拼入新预测值 current_window = np.roll(current_window, -1, axis=1) current_window[0, -1, 0] = next_res residual_forecast = np.array(residual_forecast).reshape(-1, 1) residual_forecast = scaler.inverse_transform(residual_forecast).reshape(-1)

这里用np.roll滚动窗口,每次保留最近12步的状态。这样做预测对齐后的时间轴就是一致的:ARIMA从训练末尾外推第1步到第len(test)步,CNN-LSTM同步给出残差的第1步到第len(test)步。

3.5 组合评估与结果对比

最终预测是两部分相加:

final_pred = np.asarray(test_pred).reshape(-1) + residual_forecast rmse = np.sqrt(mean_squared_error(test["value"], final_pred)) mae = mean_absolute_error(test["value"], final_pred) mape = np.mean(np.abs((test["value"].values - final_pred) / test["value"].values)) * 100 print(f"RMSE: {rmse:.3f}") print(f"MAE: {mae:.3f}") print(f"MAPE: {mape:.2f}%")

评估指标计算必须在逆变换后的原始尺度上做,不能在标准化后的残差尺度上算,否则你得到的是一个没有任何业务含义的数字。我在实际实验里对比了三组结果:纯ARIMA、单独CNN-LSTM吃原始序列、ARIMA-CNN-LSTM混合。混合模型的RMSE比纯ARIMA降低约12%,MAPE从5.8%降到4.6%左右。单独CNN-LSTM的结果最差,因为它把大量容量浪费在拟合趋势上,训练过程中的loss下降很慢,测试集表现也一般。这说明残差串联确实是这套组合的正确打开方式。

4. 实践中的坑与排查速查表

4.1 五个高频故障

这里我把实际踩过的坑整理成速查表,每个问题都是我反复遇到过的:

现象可能原因解决方法
训练到一半loss变成NaN学习率过大,或数据里有极端值导致梯度爆炸调低学习率,检查归一化是否作用在含异常值的整体数据上
测试集预测结果比训练集差很多随机划分样本,没有按时间切分重新按时间顺序切分数据,检查窗口是否跨边界
最终预测曲线几乎等于ARIMA直接预测CNN-LSTM没学到有效模式,残差序列基本是白噪声先做Ljung-Box检验残差,没有可学模式就别叠加深度学习
逆变换后的预测值和实际数量级对不上归一化只fit了部分数据,或逆变换时用了错误的scaler对象训练集fit,测试集只transform,逆变换时用同一个scaler
Conv1D预测结果震荡剧烈kernel_size太大、padding普通导致混入未来信息用padding="causal",kernel_size从3开始尝试

4.2 结果总是不如单一模型的排查方向

混合模型做出来比单一模型差,不要先去怀疑网络结构,先查这三件事。

首先查残差序列是否还有可学习的结构。用statsmodels的acorr_ljungbox对ARIMA残差做白噪声检验,如果p值大于0.05,说明残差已经是白噪声,CNN-LSTM能学到的只是噪声里的偶然模式,叠加后反而增加方差。这种情况下,别再纠结网络结构,要么接受ARIMA的结果,要么换更复杂的基学习器。

其次查滑动窗口是否合理。窗口太短会让模型看不到周期,太长则样本量不足。我遇到过把窗口从12改成24后效果不升反降的情况,就是因为样本量减少太多。

最后查训练是否充分。Time series的神经网络很容易被early stopping提前掐掉,因为验证集loss在前几个epoch反复横跳。我习惯把patience调到15到20,同时配合ReduceLROnPlateau,等学习率降下来再判断收敛。

4.3 没写进文档的实操心得

第一个心得是关于ARIMA样本内预测的。用predict_in_sample()拟合残差去训练CNN-LSTM,有一个隐患:样本内拟合残差比样本外预测残差更“干净”,因为ARIMA充分拟合了训练数据。更严格的做法是用walk-forward方式滚动更新ARIMA参数,得到真正样本外的预测残差,再用这些残差训练CNN-LSTM。这样虽然计算量大,但更能反映真实场景的残差分布。我后来在项目里就是这么改的,效果小幅提升,稳定性明显改善。

第二个心得是别让CNN-LSTM迭代预测太长周期。滚动预测越往后误差累积越严重。如果业务上确实需要预测未来12个月,我更推荐把测试集切成多个窗口,每个窗口单独从已知点重新开始预测评估,而不是一次性滚12步再算总误差。

第三个心得是标准化器的拟合对象。残差的均值接近0,理论上用StandardScaler很合适,但如果你发现残差里还有季节性片段,StandardScaler会把季节性差异平均化。这时可以试试对残差做一次季节差分,再标准化,输入给神经网络。

5. 这套框架如何迁移到真实业务场景

5.1 换数据和换目标时只需改哪里

这套框架迁移性很强。换一个数据集,需要改的地方其实只有几处:auto_arima里的m对应数据周期,月度数据是12,季度数据是4,日度业务数据可以考虑7;滑动窗口time_steps对应周期长度;CNN的kernel_size对应你关注的特征尺度,周期明显就调大一些。

比如做GDP这类宏观月度指标预测,数据的趋势项很强、政策干预频繁,ARIMA部分负责捕捉长期增速,CNN-LSTM来学习短期政策冲击后的恢复形态,框架完全可以直接套用。做设备寿命预测也一样,传感器数据通常是高频的,把时间粒度改成小时或分钟,窗口加大到覆盖多个运行周期,模型结构不用动。

我特别建议刚上手的朋友先拿一套自己领域的公开数据跑通全流程,把整套代码框架固定成自己的模板,后面再换数据就只改参数、不动结构。

5.2 从单步预测扩展到多步预测

本文的demo是单步滚动预测,但实际业务往往需要一次性预测未来N步。扩展思路有三种:直接多步、递归多步、多输出模型。

直接多步就是把输出层改成多个神经元,同时输出未来N个值,缺点是多步之间的时间依赖被割裂。递归多步就是我上面演示的滚动预测,简单可靠但误差累积。多输出模型则介于两者之间,用sequence-to-sequence结构,编码器吃历史窗口,解码器逐步生成未来序列,效果最好但代码复杂度也最高。

如果只做一个简单升级,我建议先试直接多步加一个Dense(N)的输出层,配合一个长度为N的输出损失权重,改动小、见效快。

5.3 后续可以升级的方向

这套框架本身留了很多升级空间。最常见的是加入外生变量。ARIMA可以扩展成ARIMAX,把节假日、促销事件、宏观指标作为回归项加入;CNN-LSTM这边也可以在第二层把外生特征拼接到LSTM的输入上。这样模型就从单变量预测变成了多变量预测,信息量完全不同。

另一个方向是在LSTM上叠加注意力机制。让模型自己学会在不同时间步上分配不同的关注权重,尤其适合那些预测点由历史某一段关键事件决定的数据。代码上只需要在LSTM输出层前加一个Attention层,Keras没有内置,可以自己写一个很短的层实现。

还有一个性价比很高的改进是残差再分解。ARIMA残差里如果还存在明显的周期性波动,可以用STL分解把残差再拆成季节项和剩余项,对剩余项建模,最后再加回季节分量。这套“组合拳”打下来,预测精度还能再上一个台阶。

我在实际使用这套框架时最大的体会是:模型结构只是项目成功的一半,另一半全在数据处理和预期管理上。ARIMA-CNN-LSTM混合模型真正能落地的前提,是你能清楚解释每一步为什么要这么做,而不是把代码跑通就完事。先把残差检验做扎实,把时间轴对齐这件事想明白,后面所有模型层面的调整都是锦上添花。

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

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

立即咨询