ARIMA-CNN-LSTM混合模型:Python时间序列预测实战
最近有个项目要做产品销量预测数据里既有明显的趋势和周期又掺杂了不少突发波动。我先用ARIMA试拟合快、解释性强但遇到非线性部分就明显乏力换成LSTM能抓非线性了可高频局部特征又反应迟钝。最后把ARIMA、CNN、LSTM三套东西按“线性残差分解 局部特征提取 时序记忆建模”的思路拼成一个混合模型用Python完整跑通了训练、验证和预测链路。这篇文章就把整套方案讲清楚从为什么这样组合到每一步代码怎么写再到训练过程中踩过的坑全部整理出来。对正在做时间序列预测、想尝试ARIMA-CNN-LSTM混合模型、又不想只看论文复现不了代码的朋友这篇应该能帮你少走一段弯路。1. 为什么偏偏是ARIMA、CNN、LSTM三件套先把结论摆在前面单靠任何一个模型都撑不起一个真实业务预测系统。ARIMA擅长捕捉线性趋势和固定周期LSTM擅长从长序列里学依赖关系CNN擅长提取局部特征。把它们按特定顺序串联本质上是把预测问题拆成了“可解释部分 难解释部分”让每个模型只处理自己最擅长的环节。1.1 三者的分工补位ARIMA差分自回归移动平均模型在传统统计预测里地位很稳固。它的核心逻辑是先对序列做差分让非平稳数据变得平稳再用自回归项AR和移动平均项MA去拟合残差。优点是计算快、参数有明确统计含义缺点是它假设序列是线性的市场销量、网络流量这类数据里的突变和交互效应它学不动。CNN卷积神经网络在这里不是用来做图像分类而是作为特征提取器。时间序列可以看作一维图像卷积核滑动到不同窗口上能自动抽取局部趋势、尖峰、平台期这类模式。我一般用一维卷积滤波器数量控制在64到128之间kernel size取3或5比如Conv1D(filters128, kernel_size3)。这样既不会把窗口信息打得太碎又能让LSTM拿到更高质量的特征序列。LSTM长短期记忆网络解决的是长期依赖问题。普通RNN在反向传播时会梯度消失LSTM通过输入门、遗忘门、输出门三个门控结构把信息有选择地保留或丢弃。在混合模型里LSTM负责读入CNN提取的特征序列学习时间步之间的依赖最后通过全连接层输出预测值。1.2 混合模型适用边界这个组合不是万能的它更适合“有一定线性趋势 明显周期 局部随机扰动”的序列比如销量、用电负荷、交通流量、股票收盘价这类金融时间序列。如果数据本身就是纯白噪声或者样本量小于几百条混不出什么效果ARIMA反而更稳。做这个项目的时候我给自己定的组合顺序是先用ARIMA拟合原序列得到预测值和残差再用CNN-LSTM对残差部分建模最后把两部分预测相加。ARIMA吃掉“水位的主升浪”深度学习网络去修“水面的波纹”分工边界很清楚。具体到实现顺序是差分确认、数据归一化、构造窗口样本、ARIMA残差计算、CNN-LSTM训练、逆归一化还原预测。2. 数据准备与差分——决定预测上限的隐藏环节很多人在混合模型上吃亏不是网络结构没搭对而是数据预处理糊里糊涂就开始了。差分阶数选错、归一化范围没记录、窗口切分不对后面全崩。这一部分是整个项目最容易出错、也最容易被忽略的地方。2.1 原始数据采集与清洗我拿到的原始数据是CSV格式两列date和value。读取后先做三件事去重、补缺失、看分布。import pandas as pd import numpy as np df pd.read_csv(sales.csv, parse_dates[date], index_coldate) df df[~df.index.duplicated(keepfirst)] df df.sort_index() # 缺失值用线性插值补 df[value] df[value].interpolate(methodlinear) # 看一眼取值区间防止有异常大数混进来 print(df[value].describe())这一步千万别跳。重复索引会导致后面窗口切分时时间错位缺失值如果不处理LSTM训练时NaN会直接传染整批数据。另外我会把数据和中文备注都对齐到UTC时间戳避免跨时区问题把数据顺序打乱。2.2 ADF检验与差分阶数确定ARIMA需要平稳序列但确定差分阶数不能靠肉眼要用ADF检验。from statsmodels.tsa.stattools import adfuller import matplotlib.pyplot as plt def check_stationarity(series): result adfuller(series, autolagAIC) print(fADF Statistic: {result[0]}) print(fp-value: {result[1]}) return result[1] 0.05 d 0 temp df[value].copy() while not check_stationarity(temp): d 1 temp df[value].diff(d).dropna()我这里循环判断直到p值小于0.05为止。实测下来大部分销量数据一阶差分就够了个别带强趋势的会到二阶。注意差分之后再还原预测时要用累计求和cumsum()后面讲代码时会细说。这个差分阶数d要记录好因为它直接传给ARIMA(order(p,d,q))。2.3 滑动窗口构造样本LSTM不是一次看全序列而是按滑动窗口切出固定长度样本。我用的窗口大小是60步预测1步也就是用过去60个时间点预测下1个时间点类似用两周的历史预测明天。窗口大小是超参数后面讲避坑时再展开。WINDOW 60 STEP 1 def build_samples(data, windowWINDOW, stepSTEP): X, y [], [] for i in range(window, len(data) - step 1): X.append(data[i - window:i]) y.append(data[i:i step]) return np.array(X), np.array(y)这里有个关键点我是对归一化后的序列切窗口data已经是0到1之间的值。归一化的scaler要在整个训练集上拟合不能混入验证集信息否则属于未来数据泄漏我踩过这个坑后面细说。3. Python代码实现从零搭建ARIMA-CNN-LSTM在代码设计上我遵循一条主线原始序列 → 平稳化 → ARIMA拟合出线性预测和残差 → 残差归一化 → 滑动窗口 → CNN特征提取 → LSTM序列建模 → 全连接回归 → 加上ARIMA预测 → 差分还原 → 得到真实值预测。下面按模块拆开讲。3.1 项目结构我习惯把代码拆成四个文件别堆在一个notebook里后面改参数会很痛苦project/ ├── data_preprocess.py # 数据清洗、ADF检验、差分、归一化、切窗 ├── model_arima.py # ARIMA参数选择与残差提取 ├── model_cnn_lstm.py # CNN-LSTM网络定义与训练 ├── predict_eval.py # 预测还原、误差计算、可视化每个文件实现单一职责测试时用if __name__ __main__快速验证。文章的代码片段会按这四个文件组织。3.2 ARIMA残差提取模块先用auto_arima或者网格搜索确定p和q。项目里我用的pmdarima库速度不错也能自动定阶。from statsmodels.tsa.arima.model import ARIMA import pmdarima as pm # 差分后序列差分层级记为d diff_series df[value].diff(d).dropna() auto_model pm.auto_arima(diff_series, seasonalTrue, m7, stepwiseTrue, suppress_warningsTrue) print(auto_model.order) # 比如(2,0,3) order auto_model.order model ARIMA(df[value], order(order[0], d, order[1])) fit model.fit() # 样本内拟合值 fitted fit.fittedvalues # 残差 真实值 - 拟合值 resid df[value] - fitted注意auto_arima是在差分序列上定阶但ARIMA拟合要用原序列并传入d。这里如果搞混了残差会完全对不上。获取残差之后这个resid就是交给CNN-LSTM的部分。因为ARIMA已经把线性趋势、周期性都吸收得差不多了残差应该更接近“随机扰动 局部模式”正好适合CNN去提取局部特征。3.3 CNN特征提取模块CNN部分的核心代码from tensorflow.keras.layers import Input, Conv1D, MaxPooling1D, LSTM, Dense, Dropout from tensorflow.keras.models import Model input_layer Input(shape(WINDOW, 1)) # 一维卷积提取局部特征 x Conv1D(filters128, kernel_size3, activationrelu, paddingsame)(input_layer) x MaxPooling1D(pool_size2)(x) x Conv1D(filters64, kernel_size3, activationrelu, paddingsame)(x) # 传入LSTM前调整特征维度 x LSTM(units64, return_sequencesFalse)(x) x Dropout(0.2)(x) output Dense(1)(x) model Model(inputsinput_layer, outputsoutput) model.compile(optimizeradam, lossmse)这里我用paddingsame保持序列长度不变避免卷积后窗口尺寸缩短导致信息丢失。一个容易犯的错误是忘掉MaxPooling1D会让时间步减少一半如果后面LSTM的return_sequences或Dense参数没跟着调整形状会对不上。CNN的局部感受野就像拿着放大镜看数据它能把连续几个时间点的趋势、拐点提取成一张张“特征卡片”。LSTM再把这一摞卡片按时间顺序读完最终输出一个预测值。3.4 LSTM序列建模模块LSTM内部运作可以理解成这样序列里的每个时间步进来时LSTM会把“当前输入”和“上一时刻的记忆”组合计算更新一个细胞状态。遗忘门决定哪些历史信息要扔掉输入门决定哪些新信息要写入输出门决定当前时刻要放出什么。代码里设置return_sequencesFalse表示只取最后一个时间步的隐藏状态作为整个序列的浓缩信息。# 构建训练集和测试集 X_train, y_train build_samples(train_resid_scaled) X_test, y_test build_samples(test_resid_scaled) # 调整输入维度: (样本数, 时间步长, 特征数) 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))CNN-LSTM模型接收的输入形状是(batch_size, WINDOW, 1)最后一维表示特征数。如果原始数据不只一列而是包含天气、促销标记等多维特征最后一维就相应改成特征数量卷积层和Dense层的输入维度也会随之变化。3.5 模型融合与预测还原训练完成之后融合预测是这个模型最微妙的环节差一点都不行。# CNN-LSTM预测残差部分 resid_pred_scaled model.predict(X_test) # 逆归一化回原始残差量纲 resid_pred scaler_resid.inverse_transform(resid_pred_scaled) # ARIMA对测试集时间段的预测 arima_pred fit.get_forecast(stepslen(test_df)).predicted_mean # 融合残差预测 ARIMA预测 final_pred resid_pred.flatten() arima_pred这里有个容易踩的大坑scaler在训练时是用训练集残差拟合的预测时不能重新用全量数据拟合一版。否则会把测试集的信息提前暴露给模型得到的准确率是虚高的。如果是做了差分再预测的最后还要还原一次把预测值逐次加上上一时刻的真实值类似cumsumfinal_recovered np.zeros_like(final_pred) final_recovered[0] last_actual final_pred[0] for i in range(1, len(final_pred)): final_recovered[i] final_recovered[i-1] final_pred[i]这一步错位是最常见的bug来源预测值明明很好看一还原就整体偏移多半就是差分还原时候少了偏移量。4. 训练参数设置与过拟合控制混合模型的可调参数比单一模型多参数之间还互相影响。我把自己实测的一组参数列出来并解释为什么这么选方便你根据数据规模微调。4.1 关键超参数测试记录我测试过的参数组合如下参数测试范围最终选择说明WINDOW30 / 60 / 9060窗口太小抓不到周期太大引入噪声CNN filters64 / 128128特征太少拟合不足太多容易过拟合kernel_size3 / 53kernel太大会压缩局部响应LSTM units32 / 64 / 1286464步内64个单元已足够Dropout0.1 / 0.2 / 0.50.2防止训练集准确率100%测试集崩掉Batch Size32 / 6464训练和验证损失更稳定选择WINDOW60有一个实际细节数据是按天记录的60天正好覆盖两个自然月的销售周期既包含月内波动又不至于吞入太多几个月前的陈旧规律。4.2 早停与模型保存策略训练时用EarlyStopping监控验证集损失保留表现最好的权重而不是最后一次迭代的权重。from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint callbacks [ EarlyStopping(monitorval_loss, patience15, restore_best_weightsTrue), ModelCheckpoint(best_model.keras, monitorval_loss, save_best_onlyTrue) ] history model.fit( X_train, y_train, validation_split0.1, epochs150, batch_size64, callbackscallbacks, verbose1 )restore_best_weightsTrue是个很容易被忽略但非常有用的参数。默认情况下训练结束后模型保留的是最后一次epoch的权重而最后一次epoch往往已经过拟合了。加上这个参数模型会回滚到验证集损失最小的那个状态。还有一个细节验证集不能直接和测试集重叠。我的划分比例是训练集70%、验证集10%、测试集20%并且按时间顺序划分不随机打乱。打乱时序会让模型“偷看”未来数据导致线下指标很好看、线上完全失效。5. 实验效果分析与误差溯源模型搭好之后评估环节最能看出问题。我记录了三个维度的对比结果和误差分析这部分对你自己调参特别有参考价值。5.1 与单一LSTM、ARIMA的对比我用同样的训练集、测试集分别跑了纯ARIMA、纯LSTM和ARIMA-CNN-LSTM评估指标用RMSE和MAPE模型RMSEMAPEARIMA0.837.2%LSTM0.716.1%ARIMA-CNN-LSTM0.544.3%混合模型在RMSE上比单一LSTM下降了约24%比ARIMA下降了约35%效果是实打实的。原因在于ARIMA先剥离了线性趋势让CNN-LSTM只需要关注残差里的局部模式学习负担大大降低避免了单一LSTM把大量参数浪费在线性趋势的拟合上。这个对比要特别注意一点不能只看误差。我画了预测曲线和真实曲线发现混合模型对转折点的响应更快。单一LSTM在大趋势拐弯处会滞后一两个时间步混合模型因为在残差里已经看到了线性部分的偏移所以拐弯时能更快纠正方向。5.2 误差从哪里来我把误差最大的几个预测点拉出来逐一看了原始数据发现三个规律第一节假日效应是最大误差源。遇到长假前后序列会出现明显的“节前冲高、节后回落”ARIMA的周期性参数是固定的很难实时调整这种突发的假期节奏。CNN虽然能识别局部尖峰但普通卷积核也没有“这次要放长假”的高维特征。所以在真实业务里如果能额外加一列假期因子作为外部特征效果会更好。第二趋势突变点误差很大。比如某天突然出现爆款短视频带火了产品序列瞬间翻倍任何模型都难以及时反应。这种点不宜强行追求拟合否则模型会被个别离群点带偏影响后续几十个点的预测质量。第三差分阶数选择对长周期序列影响大。我在实验中发现如果d选择1但序列明显带二次趋势ARIMA拟合出的残差里还有趋势残留CNN-LSTM要用卷积去补这部分“慢变量”效果就会打折扣。这种情况应该先用二阶差分或者对序列做对数变换削掉指数增长。6. 避坑清单我在该项目里踩过的6个坑这一部分是最想把笔记本拍在桌子上的内容。混合模型代码量不大但每一步都有隐蔽的坑通常一个坑就能让预测结果全盘皆输。6.1 时间泄漏预处理时用全量数据拟合scaler我犯过的最致命错误是在切分训练集和测试集之前就对全量数据做了归一化scaler看到了测试集的最大值和最小值。看起来只是把数据变了范围实际上等于把未来信息泄露给了模型训练时验证集指标虚高上线后直接崩。正确做法是只在训练集上拟合scaler再用这个scaler分别转换训练集和测试集from sklearn.preprocessing import MinMaxScaler scaler MinMaxScaler() train_scaled scaler.fit_transform(train.values.reshape(-1, 1)) test_scaled scaler.transform(test.values.reshape(-1, 1))6.2 差分还原错位用ARIMA时我习惯先差分再拟合但最后预测值还原时我第一版代码把偏移量弄丢了一列。原因是差分后的序列长度比原序列少1还原时要从原序列的最后一个值开始累加不是从0开始。更隐蔽的是如果预测了多步每一步都要用前一步的实际观测值作为基准错一个就全错。6.3 卷积层后LSTM输入形状不匹配Conv1D和MaxPooling1D会改变序列长度。第一版我用了MaxPooling1D(pool_size2)之后没算过新长度LSTM层报维度错误。后来我统一用paddingsame保持卷积层输出长度与输入相同pooling后长度变成WINDOW/2LSTM的输入形状跟着改报送错就消失了。正确的理解是CNN-LSTM中间连接的是序列长度不是特征维度。6.4 随机种子未固定结果无法复现LSTM训练有随机初始化哪怕网络结构一模一样跑两次结果也会不同。我的做法是在代码最上面固定所有随机源import random import numpy as np import tensorflow as tf random.seed(42) np.random.seed(42) tf.random.set_seed(42)这样每次运行结果一致后面微调参数才有可比性。否则你会分不清效果提升到底来自参数调整还是随机波动。6.5 滑动窗口长度拍脑袋窗口长度不是越大越好。我测试过WINDOW120训练时间翻倍但RMSE没有明显下降。反而在数据量只有几百条的情况下窗口太长会导致样本数量锐减LSTM吃饱了数据才开心样本少了它立刻摆烂。一般原则是样本量在2000条以上才考虑90以上的窗口如果只有几百条60以内是更稳妥的选择。6.6 评估指标选错我有一次只用RMSE评估结果模型在峰值附近误差被平方放大了看起来很大其实整体曲线拟合得挺好。后来改成同时看RMSE、MAE、MAPE三个指标MAE对异常值不敏感MAPE能反映相对误差水平配合起来评估更全面。尤其是业务汇报时MAPE更容易向非技术同事解释直接说“平均预测偏差百分之几”就够清楚了。还有一个指标注意点如果数据里存在0值或接近0值MAPE会变得巨大甚至无穷这时改用SMAE或者WMAPE更稳妥。我处理销量数据时就遇到过0销量的日子第一版MAPE算出一个天文数字排查后才发现是分母为0导致的。7. 一些个人实验体会这套ARIMA-CNN-LSTM混合模型我陆陆续续跑了两周最大的感受是“分工”比“堆模型”重要。模型不是越多越好关键是让每个模型承担的任务边界清晰ARIMA负责线性趋势CNN负责局部特征LSTM负责时序依赖。边界一旦糊掉模型之间会互相抢活干效果反而不如单一LSTM。另外如果你想把这套代码用在别的序列上可以先跑一遍ARIMA看残差。如果残差看起来完全是白噪声那CNN-LSTM这部分基本学不到东西直接退化成纯ARIMA如果残差还有明显波动说明线性模型确实吃不干净这时候混合模型的优势才会显露出来。这个“试错前置”能帮你省下大量调参时间。最后分享一个小技巧预测结果一定要画图看时序曲线不要只看误差表。误差表只能告诉你“差多少”曲线能告诉你“差在什么位置”——是在拐点滞后、还是在波峰削平、还是在周期性偏移。我就是在一次画图时发现所有误差集中在每隔7天的固定位置顺着这个规律排查出数据里有个周期性缺失没有补齐。很多看起来很玄学的问题画一张图就清楚了。如果要继续扩展可以在CNN-LSTM之间加入注意力机制、把ARIMA升级为SARIMA处理多周期季节性、或者把多个模型按加权集成的方式融合。但我个人建议先把基础版跑通再逐步加复杂度不然出了问题真的很难定位到底是谁的锅。