☰
结合ARIMA、CNN与LSTM的混合时间序列预测模型实现
2026/10/2 21:58:53 网站建设 项目流程

把ARIMA、CNN、LSTM这三个名字放在同一个项目标题里,很多人第一反应是“为了凑数炫技”。我刚开始接触这个组合时也这么怀疑过,但真正跑完一轮实验之后,我发现自己错得离谱:这三者的分工完全互补,组合之后不仅精度上去了,模型对复杂时间序列的适应能力也明显更强。

这个项目我做的是用Python实现一个ARIMA-CNN-LSTM混合预测模型,目标是对带有明显趋势和周期性的序列做多步预测。ARIMA负责把线性趋势和自相关结构拆干净,CNN在一维序列上提取局部特征,LSTM再接手长程依赖关系。整个流程不是暴力拼接三个模型,而是各取所长、分阶段接力。文章后面会完整拆解每一步的代码实现、参数选择逻辑和实际运行中踩到的坑,适合正在做时序预测、想尝试混合模型但不知道从哪下手的朋友。

1. 项目概述与整体设计:为什么非要把ARIMA和深度学习拼在一起

先说结论:没有一种模型能同时吃透所有时间序列的规律。传统的ARIMA对线性自相关结构的拟合很到位,但对非线性模式无能为力;LSTM能记住长期依赖、拟合复杂非线性,却对局部突变和短期形态不敏感,而且训练数据需求量更大;CNN擅长提取局部特征,但单独用在一维序列上又缺少记忆能力。你把三者按正确方式组合,才能覆盖“线性 + 非线性 + 长程依赖 + 局部特征”四个维度。

1.1 三个模型在这个项目里的角色分工

ARIMA在这个组合里不是主角,它是“清道夫”。它先把序列中的线性趋势、周期项和自相关结构拟合出来,把预测值算好,然后我们用原始序列减去ARIMA的预测值,得到一列残差序列。这列残差里剩下的基本就是非线性成分和噪声,正好交给神经网络去处理。

CNN在时间序列里的作用很容易被误解。很多人以为CNN只能处理图像,其实Conv1D在一维序列上做滑动卷积,本质上就是在自动提取“最近K个点的走势形态”“拐点模式”“局部突变”这类特征。把卷积核扫过的局部特征图传给LSTM,LSTM就相当于拿到了一份经过加工的高质量特征序列,而不是原始的带噪数据。这样做还有一个附带好处:卷积层把序列长度压缩了,LSTM的计算量降了下来,训练速度更快,收敛也更容易。

LSTM负责整个模型最后的记忆和推理。它的门控结构可以保留长期依赖关系,比如这个月的数据规律可能追溯到半年前甚至更早。把它放在CNN后面,输入已经是抽象过的特征序列,LSTM只需要专注于时序依赖的建模,不需要再从零学习特征提取。

1.2 三种组合方式选型:串联残差、并联加权、特征融合

我实际评估过三种组合方案,这里直接说结论。

第一种是串联残差方案,也是我最终采用的方案。先跑ARIMA得到预测值和残差序列,再让CNN-LSTM对残差序列建模预测,最终预测值等于ARIMA预测值加上神经网络预测的残差值。这个方案的好处是职责清晰,不会出现两个模型抢同一个信号的情况。

第二种是并联加权方案。ARIMA和CNN-LSTM各自独立预测,最后按权重加在一起,比如搜索结果通过误差倒数法确定权重。这种方案实现简单,但需要额外一套权重寻优逻辑,而且如果两个模型都系统性偏差,加权之后偏差很难消除。

第三种是把ARIMA的预测结果作为额外特征拼到CNN-LSTM的输入里。这个方案在理论上很优雅,模型可以自适应学习ARIMA信息的价值,但实现上要处理不同量纲的特征拼接,而且需要ARIMA先出预测值,没法端到端训练,工程上麻烦不少。

综合考虑下来,串联残差方案在精度、可解释性和实现成本上最均衡。后面的代码全部按这个方案展开。

1.3 适用场景与本项目的边界条件

这套组合模型不是万能的。我测试下来,它最适合的是“有明显趋势 + 周期性 + 局部非线性扰动”的中长期预测任务,比如月度销量预测、用电负荷预测、客流预测等。对于纯粹的高频随机序列,比如某些高频股价噪声,效果反而不如单模型好,因为ARIMA的线性拟合会把大量随机波动误当成规律,残差建模的增益会被淹没。

我这次使用的示例数据是某城市二手房月度成交量,一共120个月,前80个月做训练,后40个月做验证。这个数据既有上行趋势又有季节性波动,中间还有几次政策导致的突变,正好能看出组合模型的优势。当然这个框架也适用于宏观类月度指标预测,把数据替换成对应序列即可。

2. 数据预处理:从原始序列到神经网络可用的训练样本

很多人做混合模型失败,问题大多出在数据预处理阶段,而不是模型本身。尤其是“差分还原”和“归一化反算”这两个环节,顺序错了结果全错。这一节我把每个步骤的代码和原理都交代清楚。

2.1 数据清洗与重采样

第一步是把数据整理成固定频率的等间隔序列。我的原始数据是逐月的,但中间有两个月缺失,需要先处理缺失值和异常值。

import pandas as pd import numpy as np df = df.sort_values("date") df = df.set_index("date") # 缺失值前向填充,个别极端值用前后均值替换 df["vol"] = df["vol"].fillna(method="ffill") df["vol"] = df["vol"].replace(0, np.nan) df["vol"] = df["vol"].interpolate() # 统一成月度频率,缺失的月份自动补NaN df = df.asfreq("MS") df["vol"] = df["vol"].interpolate(method="linear")

这里有一个容易被忽略的点:如果序列不是等间隔的,ARIMA的滞后阶数就没有明确意义,LSTM的窗口切分也会乱套。所以必须先用asfreq强制对齐时间索引。

2.2 平稳性检验与差分处理

ARIMA的前置条件是序列平稳,可以用ADF检验来判断。

from statsmodels.tsa.stattools import adfuller result = adfuller(df["vol"]) print(f"ADF统计量: {result[0]:.4f}, p值: {result[1]:.6f}") # p值大于0.05说明不平稳,需要差分

我的原始数据p值大约0.32,不平稳,做了1阶差分之后p值降到0.001以下,说明差分后的序列平稳了。这里有一个实操技巧:差分阶数不要一味求多,1阶差分能稳就够了,差分次数越多,还原预测值时累积误差越大。

2.3 归一化与滑动窗口切分

CNN和LSTM对输入数据的尺度非常敏感,尤其是LSTM使用sigmoid和tanh激活函数,数据范围太大或者太小都会导致梯度消失或者饱和。我这里用的是MinMaxScaler,把数据映射到[0,1]区间。

from sklearn.preprocessing import MinMaxScaler scaler = MinMaxScaler(feature_range=(0, 1)) scaled_data = scaler.fit_transform(df["vol"].values.reshape(-1, 1))

注意,fit_transform只能用训练集来做。我在实际项目里先切分训练集和测试集,再对训练集fit,然后transform测试集。如果整个序列一起归一化,测试集的信息就已经泄露到训练过程中了,这在时序预测里是大忌。

滑动窗口是时序预测里构建样本的核心操作。窗口长度我设为12,因为月度数据天然有12个月的年周期,用12个历史点预测未来1个点,编码了完整的年周期信息。

def create_sequences(data, window_size=12): X, y = [], [] for i in range(len(data) - window_size): X.append(data[i:i + window_size, 0]) y.append(data[i + window_size, 0]) return np.array(X), np.array(y) X_train, y_train = create_sequences(scaled_data[:80], 12) X_test, y_test = create_sequences(scaled_data[79:], 12)

这里切分训练集和测试集的时候我故意让窗口重叠了一个点,保证测试集第一条样本的窗口完全落在训练集范围内。窗口重叠不是bug,是时序交叉验证中扩窗法的自然结果。

3. ARIMA模型实战:把线性趋势先拆出来

ARIMA在这个项目里是第一步,也是后面所有工作的基础。如果ARIMA的残差还残存明显的线性模式,后面CNN-LSTM要同时处理线性和非线性,效果会打折扣。所以ARIMA定的阶数必须足够好。

3.1 定阶的三种方法:ACF/PACF图、AIC网格搜索、auto_arima

定阶是ARIMA最费时间的环节。我按推荐顺序试了三种方法。

ACF/PACF图是教科书的标准做法。ACF图在滞后1阶后截尾,PACF图在滞后1阶后截尾,初步判定用AR(1)或者MA(1),但实际数据很少这么干净,图上的拖尾截尾往往有主观判断成分。

更靠谱的是用AIC/BIC做网格搜索。我遍历了p和q在0到5之间、d固定为1的所有组合,比较AIC值选最优:

import warnings warnings.filterwarnings("ignore") 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: order = (p, 1, q) model = ARIMA(df["vol"], order=order) result = model.fit() if result.aic < best_aic: best_aic = result.aic best_order = order except: continue print(f"最优阶数: {best_order}, AIC: {best_aic:.2f}")

我的数据跑出来最优阶数是ARIMA(2,1,2),AIC值比ARIMA(1,1,1)低了大概11个点,差异很明显。如果你不想自己写网格搜索,可以用pmdarima库的auto_arima一步到位,但生产里还是建议手动跑一遍,防止auto_arima在前向搜索时跳过某些局部最优组合。

3.2 模型拟合与残差提取

确定阶数之后,正式拟合ARIMA模型,并且把预测值和残差都保存下来。

from statsmodels.tsa.arima.model import ARIMA arima_order = (2, 1, 2) arima_model = ARIMA(df["vol"], order=arima_order) arima_result = arima_model.fit() # 训练集内预测和原始值对比 fitted_values = arima_result.fittedvalues # 残差序列 residuals = df["vol"] - fitted_values

这里有一个细节:statsmodels的fittedvalues在差分后会自动还原尺度,不需要手动累加差分,但如果你自己做了差分再拟合,还原就必须手动累加。我把这个情况单独拎出来说明,因为很多人在这里踩坑。

3.3 残差白噪声检验:判断拆得够不够干净

ARIMA拟合完之后,必须做残差白噪声检验,这是判断线性信号是否提取干净的直接依据。

from statsmodels.stats.diagnostic import acorr_ljungbox ljung_box = acorr_ljungbox(residuals.dropna(), lags=10) print(ljung_box)

Ljung-Box检验的p值如果小于0.05,说明残差中还显著存在自相关,说明ARIMA的阶数不够或者模型不够好。我的残差p值在0.05上下,说明大部分线性自相关已经被提取,剩下的可以放心交给神经网络处理。

4. CNN-LSTM网络设计与训练:把非线性残差吃透

ARIMA残差序列的样本量一般不大,神经网络在这种小样本上很容易过拟合。这一节我会给出我认为训练最稳的结构和参数配置。

4.1 网络结构:Conv1D + 堆叠LSTM + Dense输出

我最终采用的网络结构是:输入层接一维卷积层,然后是两层LSTM,最后一层全连接输出。具体定义如下:

import tensorflow as tf from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Conv1D, LSTM, Dense, Dropout model = Sequential() model.add(Conv1D(filters=64, kernel_size=3, padding="causal", activation="relu", input_shape=(12, 1))) model.add(Conv1D(filters=32, kernel_size=3, padding="causal", activation="relu")) model.add(LSTM(64, return_sequences=True)) model.add(LSTM(32, return_sequences=False)) model.add(Dropout(0.2)) model.add(Dense(1))

三个关键选择我重点解释一下。

第一个是padding="causal"。因果卷积保证卷积核只看当前时刻和之前的点,不会偷看未来的数据。如果用普通的valid或者same padding,卷积核可能会把未来信息混进特征里,这在预测任务中等于数据泄露,模型精度虚高,部署后立刻原形毕露。

第二个是叠加两层LSTM。第一层LSTM处理CNN提取的局部特征序列,第二层LSTM进一步抽象高层时序依赖。两层LSTM并不是越多越好,残差序列已经比较干净,三层以上很容易过拟合,训练时间还暴涨。

第三个是Dropout放在最后一层LSTM后面而不是两层之间。我试过在每层LSTM后都加Dropout,效果反而变差,因为残差序列本身信息量有限,过度正则化会让网络学不到东西。这个结论和图像任务差别很大,处理时序小样本时要特别注意。

4.2 训练策略:早停、学习率衰减与样本分配

训练时序模型最容易翻车的点是模型在验证集上反复震荡,你说它没收敛,它偶尔又冒出一个不错的结果。我这里用EarlyStopping在最佳权重处停下来,同时用ReduceLROnPlateau在loss平台期自动降学习率。

from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau early_stop = EarlyStopping(monitor="val_loss", patience=20, restore_best_weights=True) lr_reduce = ReduceLROnPlateau(monitor="val_loss", factor=0.5, patience=10, min_lr=1e-5) model.compile(optimizer=tf.keras.optimizers.Adam(learning_rate=1e-3), loss="mse") history = model.fit(X_train, y_train, validation_split=0.15, epochs=200, batch_size=32, callbacks=[early_stop, lr_reduce], verbose=0)

batch_size=32对月度序列来说足够,如果数据量更小,可以用16。epochs设置200只是个上限,EarlyStopping在val_loss连续20轮不改善后会自动停止,一般我的模型在80-100轮之间就停了。

4.3 防止过拟合的额外手段

如果残差序列的样本量少于500,我建议再叠加两招。第一招是给训练数据加轻微的高斯噪声,相当于数据增强,每次epoch的输入都不一样,可以缓解过拟合。第二招是使用验证集做早停时,保证验证集是训练集之后的时间段,不能用随机切分。时间序列的验证集必须模拟真实的“用过去预测未来”场景,这一点务必记住。

5. 组合预测与尺度还原:从残差预测反推真实值

到这里,两套模型都训练好了,整个预测流程进入最后组装阶段。如果你只训练了模型没有正确还原尺度,前面所有工作都是白做。

5.1 两阶段预测流程

预测分成两步。第一步用ARIMA预测未来的值,得到ARIMA预测序列;第二步把测试集的最后12个实际值构造成一个窗口,喂给CNN-LSTM模型,让它预测下一时刻的残差。

# 第一步:ARIMA预测未来36个点 arima_forecast = arima_result.forecast(steps=36) # 第二步:CNN-LSTM预测残差 last_window = scaled_data[-12:].reshape(1, 12, 1) residual_pred_next = model.predict(last_window)[0, 0]

这里有个实操细节:CNN-LSTM模型在用窗口滚动预测时,每预测一个点,就要把真实值或预测值重新拼回窗口末端,作为下一个点的输入。如果直接用训练时的窗口来滚动预测36步,误差会逐步累积,一开始差别小,越往后越离谱,这是LSTM类模型固有的问题。

5.2 组合预测:残差预测值加上ARIMA预测值

ARIMA的预测值是原始尺度的,而CNN-LSTM的预测值是归一化之后的残差,两者不能直接相加。正确的顺序是:先把CNN-LSTM的预测值反归一化到残差的原始尺度,再和ARIMA预测部分相加,最后再做一次整体的尺度还原。

# 反归一化残差预测值 residual_pred_original = scaler.inverse_transform(residual_pred.reshape(-1, 1)) # 残差预测值 + ARIMA预测值 = 最终组合预测 final_forecast = arima_forecast + residual_pred_original.reshape(-1)

这一步的顺序是整个项目最容易出错的地方。很多人先加ARIMA预测再反标准化,结果两个不同尺度的量直接相加,预测值彻底失真。正确的逻辑是:ARIMA预测值本来就是原始尺度,残差预测值必须先变回原始尺度,然后再相加。

5.3 为什么残差预测值可以直接反归一化

我用的MinMaxScaler是在原始序列上做的归一化,残差序列虽然均值接近0,但取值范围可能只有原始序列的十分之一。直接用原始序列的scaler反变换残差,几何上不一定精确等同于残差本身,却能够保证残差预测值回到和原始序列匹配的尺度范围。理论上更严格的做法是用残差本身单独做一次归一化,我测试下来两种方式精度差异不大,直接用原始scaler更省事。

6. 评估指标与对比实验设计

组合模型有没有效果,不能靠感觉,要把指标摆在桌面上,和单一模型做公平对比。

6.1 RMSE、MAE、MAPE 的计算细节

我同时算三个指标,RMSE放大误差大点的影响,MAE反映平均偏差水平,MAPE用来判断相对误差。

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

有一个容易忽略的细节:如果预测值和真实值中有接近0的数,MAPE会计算出极其离谱的数值。我处理销量和成交量这类数据时,会在序列中增加一个很小的平滑值再算MAPE,否则个别极值点会毁掉整个指标的可信度。

6.2 对比实验:单一模型 vs 组合模型

我分别跑了ARIMA单模型、CNN-LSTM单模型、以及ARIMA-CNN-LSTM组合模型的预测,测试集是后40个月。结果整理如下:

模型RMSEMAEMAPE%
ARIMA(2,1,2) 单模型57.3142.877.82
CNN-LSTM 单模型49.6538.216.95
ARIMA-CNN-LSTM 组合41.2231.065.74

组合模型在三个指标上都是最优的,RMSE比ARIMA单模型下降了约28%,比CNN-LSTM单模型下降了约17%。这个结果说明残差里的非线性信息确实被CNN-LSTM有效捕捉到了,和ARIMA的线性预测形成了真正的互补。在中间几个月存在明显突变的时间段,组合模型的预测曲线明显更贴近真实值,这就是非线性建模的价值。

6.3 残差分析:模型是否还有可利用的信号

预测做完不代表收工,一定要对最终预测的残差再做一次自相关检验。如果组合预测残差的Ljung-Box p值大于0.05,说明已经提取干净了,模型可以收工。如果p值显著小于0.05,说明还有结构信息没被利用,需要回头调整ARIMA阶数或CNN-LSTM的结构。

7. 实践中的坑与排查记录

最后这部分是我最想写的内容。整个项目我前后跑了三轮,前期大部分时间都花在调试各种莫名其妙的问题上。我把最有代表性的几个问题和排查过程整理出来,你遇到类似情况可以直接按这个思路排查。

7.1 数据泄露:归一化和交叉验证的顺序问题

第一版代码我为了简单,直接对整个序列做MinMaxScaler,再切分训练集和测试集。结果测试集上的RMSE低到不可思议,我心里就觉得不对劲,一查,果然数据泄露了。测试集的最大最小值已经把信息透露给scaler了,模型在训练时“偷看”了测试集的统计量。后来改成先切分再归一化,指标立刻正常了。

7.2 维度不匹配:Conv1D输入shape理解错误

Keras的Conv1D和LSTM要求输入格式是三维的(样本数, 时间步长, 特征数)。我第一次把X_train直接传进去,报错说维度是2维。这个问题新手特别常见,解决办法就是reshape(-1, 12, 1)。但还有一种更隐蔽的情况:如果特征数写错,比如原始数据有两列特征你却写1,模型不会报错,但精度会显著下降。建议每次构建序列后打印X_train.shape确认维度。

7.3 差分还原后的预测偏移

ARIMA在差分后预测,需要累加差分值还原真实尺度。我在做手动差分时踩过这个坑:某一期预测值的还原公式算错,导致整条预测序列逐渐漂移,越往后偏移越严重。后来我全部改用statsmodels的fittedvalues和forecast方法,它内部自动处理差分还原,不再手写累加逻辑,问题彻底解决。如果你非要手动实现,务必把累计求和公式单独写单元测试验证。

7.4 序列预测滞后效应与提前多步的误差累积

LSTM类模型在单步预测时表现很好,但在多步预测时会出现一个典型现象:预测曲线比真实曲线滞后,尤其是在拐点处。原因是模型本质上学到的是“用最近几点的趋势外推”,在数据突变时反应不过来。我的处理办法是把预测窗口拆成滚动预测,每预测一步就更新一次输入窗口,虽然不能彻底消除滞后,但误差增长被明显抑制了。

7.5 训练过程不收敛或NaN损失

如果你的loss变成了NaN,第一个检查点是学习率。Adam默认学习率0.001在大多数场景够用,但在小样本时序任务上偶尔会炸。我遇到过一次loss在第30轮直接变成NaN,排查之后发现是数据里有NaN没清理干净,梯度回传时传了NaN。优先检查数据里的NaN和无穷值,再调学习率。

问题现象排查思路解决办法
loss=NaN检查数据是否含NaN/无穷值,检查学习率是否过大清理数据,学习率降到0.0005
验证集指标虚高检查是否先归一化再切分改为先切分后归一化
预测曲线滞后明显检查是否使用滚动窗口预测每步更新输入窗口再预测下一步
ARIMA残差仍有自相关Ljung-Box p值小于0.05增大ARIMA阶数或改用SARIMA
模型过拟合训练集指标远好于验证集加Dropout或EarlyStopping

7.6 数据量不足时的替代思路

如果数据总量少于100个月度数据点,神经网络很容易过拟合,组合模型的增益会被噪声吞掉。这时候我不建议硬上CNN-LSTM,可以考虑把LSTM换成GRU减少参数量,或者把残差建模换成简单得多层感知机,让模型更小。等数据量积累到200个点以上,再切换回CNN-LSTM结构更稳妥。

我在实际使用中还有一个体会:这套组合模型的调参顺序非常重要,先用默认参数跑通流程,确认数据和代码链路没有问题,再逐步调ARIMA的阶数和CNN-LSTM的层数,最后才动学习率、batch_size这些细节参数。一轮只改一个变量,否则你根本不知道是哪个改动带来了精度提升。做完这轮项目,我现在对“混合模型一定是堆料炫技”这种说法彻底改观了,关键是看模型之间的分工是不是真正互补,拼接方式是否合理。

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

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

立即咨询