ARIMA-CNN-LSTM组合模型:Python实现时间序列预测的实战指南
聊到时间序列预测最近总有人问我ARIMA、CNN、LSTM这三个东西到底能不能拼在一起用说实话这个问题本身就是个好问题。很多人一上来就直接套LSTM发现数据一长、趋势一乱效果反而不如老牌的ARIMA也有不少人抱着ARIMA不放手遇到非线性模式就抓瞎。而把三者组合成一个ARIMA-CNN-LSTM预测模型靠Python实现并跑通恰恰是兼顾线性趋势、局部特征和长期依赖的一个常用方案。这篇文章就围绕这个组合模型展开讲讲它的设计逻辑、数据流、Python实现细节、实测效果以及我在调参和落地过程中踩过的那些坑。无论你是刚入门时序预测的研究生还是需要在业务里快速出结果的算法工程师这篇都能给你一套可以复制和扩展的思路。1. 为什么是ARIMACNNLSTM三件套组合模型的动机与分工逻辑1.1 单一模型的边界各自能干什么干不了什么先说ARIMA。ARIMA是自回归积分滑动平均模型核心假设是时间序列的当前值可以由历史值的线性组合加上白噪声来解释。它对线性趋势、季节性、周期性的拟合非常成熟模型本身也有明确的统计推断框架比如AIC/BIC定阶、残差白噪声检验。但它的局限也很明显对非线性关系、突变模式、复杂交互几乎无能为力。你把一堆非线性函数关系塞给它它只会用线性回归去逼近结果就是系统偏差。再说LSTM。长短期记忆网络是RNN的改进版通过门控机制解决长期依赖的梯度消失问题可以学习序列里的非线性时序依赖。它的优势是记性好能记住几十步之前的信息所以在文本、语音、金融序列上表现都不错。但它有两个明显短板一是对局部短程特征不够敏感比如某个局部波形突然变陡、连续几个时间点出现尖峰LSTM不见得能精准提取二是训练数据需求量比传统模型大得多小样本场景下很容易过拟合。最后是CNN。这里用的是Conv1D一维卷积。它的强项是局部特征提取通过卷积核扫描时间窗口能自动识别序列中的局部形态比如连续上涨后出现拐点波谷伴随尖峰这类模式。但CNN本质上没有记忆感受野有限单独拿来做时序预测长距离依赖完全没戏。这三者单拎出来各有各的死角。ARIMA看得懂线性骨架看不懂非线性细节LSTM记得住长期依赖却容易忽略局部形态CNN擅长抠局部特征却没有全局记忆能力。组合的价值就在于让每个模型干自己最擅长的事。1.2 组合思路串行残差建模为主特征融合为辅组合方式大致分两种。第一种是串行残差建模也是本文采用的主方案。思路很直接先用ARIMA把序列的线性部分拟合出来得到预测值和真实值的残差序列这个残差里剩下的主要是非线性信息再交给CNN-LSTM去学习。最后预测结果等于ARIMA预测加上CNN-LSTM的残差预测。这样做的好处是职责清晰线性趋势归ARIMA非线性残差归深度学习模型互不干扰。第二种是并行特征融合ARIMA作为一个预测器单独输出CNN-LSTM作为另一个预测器单独输出最后用加权或学习权重把两个结果融合。这种方案的优点是信息不丢失但缺点是权重不好定两个模型如果互相打架融合效果反而更差。我实际做下来串行残差建模更稳也更好调试。原因很简单你永远知道某个预测误差到底来自哪一环排查问题方便得多。1.3 用医生会诊来理解这套组合如果你觉得上面太学术可以打个比方。ARIMA像全科医生先做常规检查把那些常见的、规律性的问题处理掉CNN像影像科医生专门盯着局部细节哪里有异常形态他一眼就看出来LSTM像主治医生记得你过去所有病史能判断当下这个异常是不是由很久之前的因素引发的。三个医生各看各的科室最后把结论汇总到一起诊断精度自然比任何一个单科医生都高。下表是一个快速对比方便你记住三者的分工模型核心能力主要上限在组合模型中的角色ARIMA线性趋势、周期性、差分层级建模非线性模式、突变点拆掉线性骨架负责大趋势生成残差CNN (Conv1D)局部时间特征提取无长期记忆感受野有限在残差序列中识别局部形态LSTM长期依赖、非线性时序关系局部特征捕捉弱小样本易过拟合对残差序列的时序依赖建模2. 整体架构设计与数据流从单变量序列到特征矩阵的转换思路2.1 串行架构的完整数据流先看一张流程脉络图我用文字描述清楚。假设原始序列是 ( y_t )整个模型的数据流分为训练和预测两个阶段。训练阶段对原始序列 ( y_t ) 做平稳性检查必要时做差分确定ARIMA的 ( (p,d,q) )。训练ARIMA模型得到拟合值 ( \hat{y}_t )。计算残差 ( e_t y_t - \hat{y}_t )。对残差序列 ( e_t ) 做滑窗构建 ( (X, y) ) 样本对每个样本用过去 lookup 长度的残差预测未来一步残差。把滑窗样本输入CNN-LSTM网络训练。预测阶段对测试集每步用ARIMA做预测得到 ( \hat{y}_t )。用训练好的CNN-LSTM对残差做滚动预测得到 ( \hat{e}_t )。滚动含义是每次预测完把当前真实的残差或预测的残差追加进窗口滑向下一步。最终输出 ( \hat{y}_t \hat{e}_t )。2.2 数据预处理差分、归一化和滑窗的关键细节ARIMA部分需要先判断平稳性。常用方法是ADF检验Augmented Dickey-Fuller如果p值大于0.05说明序列不平稳需要差分。差分的阶数d通常取0、1或2取到序列平稳为止。归一化这块我特别强调一句MinMaxScaler的fit只能用在训练集上测试集只能transform绝不能对整个序列统一fit。否则等于让模型偷看了测试集的最大值和最小值属于典型的数据泄漏。虽然短期看指标会变得好看但一旦换数据就露馅。滑窗的构建逻辑可以用一个简单函数表达。假设窗口长度是 ( L )样本数 ( N-L )每个样本是连续 ( L ) 个时间点的值目标值 ( y ) 是第 ( L1 ) 个时间点的值。2.3 时序数据划分的正确姿势不能随机打乱很多新手拿到数据就调用train_test_split(shuffleTrue)这在时序预测里是致命的错误。时间序列的样本之间存在前后依赖随机打乱会破坏这种顺序结构等于人为制造了信息泄漏。正确做法是按时间顺序切分比如前70%训练、后15%验证、最后15%测试。而且测试集一定是最新的数据因为这最贴近真实场景——你永远是用历史预测未来而不是用未来预测过去。3. Python实现的关键代码拆解数据预处理、模型构建与训练3.1 环境依赖与准备先说环境。我的实现基于Python 3.9核心库版本如下statsmodels0.14.0 pmdarima2.0.4 tensorflow2.13.0 pandas2.0.3 numpy1.24.3 scikit-learn1.3.0如果你用的是新版TensorFlow注意keras的导入方式可能会变建议直接用from tensorflow import keras。3.2 ARIMA部分自动定阶与残差提取我不太推荐手动去翻ACF/PACF图来定阶虽然那是最经典的统计方法但在实际项目里效率太低。我一般用pmdarima的auto_arima自动搜索最优参数搜索范围设置好跑一遍就够了。import pandas as pd import numpy as np from pmdarima import auto_arima # 假设 df 包含一列目标值 y按时间升序排列 series df[y].values.astype(np.float64) # 划分训练集和测试集按时间顺序 train_size int(len(series) * 0.7) y_train series[:train_size] y_test series[train_size:] # 自动定阶 auto_model auto_arima( y_train, start_p0, max_p5, start_q0, max_q5, dNone, max_d2, seasonalFalse, # 如果你的数据有季节性改为True并设置m traceFalse, stepwiseTrue, information_criterionaic ) print(auto_model.summary())拟合之后提取训练集残差from statsmodels.tsa.arima.model import ARIMA # 用 auto_arima 选出的参数拟合 statsmodels 模型 order auto_model.order # (p, d, q) model ARIMA(y_train, orderorder) fit_model model.fit() # 训练集拟合值 fitted_vals fit_model.predict(start0, endlen(y_train) - 1) # 残差 resid_train y_train - fitted_vals[:len(y_train)]这里有个细节当d0时predict(start0)的输出长度可能跟原始序列不一致所以我在代码里强制裁剪了一下避免维度对不上。这种问题写论文时不会暴露写代码时却必须处理。3.3 滑窗函数与残差数据集的构建定义滑窗函数def create_sliding_window(data, lookback60): X, y [], [] for i in range(len(data) - lookback): X.append(data[i:i lookback]) y.append(data[i lookback]) return np.array(X), np.array(y)这里lookback是超参数。我用实验数据大概来回试过股票类序列取30到90都有电力负荷我常用24或48因为负荷有明显的日周期。残差序列的滑窗一样from sklearn.preprocessing import MinMaxScaler # 对残差序列归一化 scaler MinMaxScaler(feature_range(0, 1)) resid_scaled scaler.fit_transform(resid_train.reshape(-1, 1)).flatten() # 构建滑窗数据 lookback 60 X_train, y_train_resid create_sliding_window(resid_scaled, lookback) # 调整为模型输入形状: (样本数, 时间步长, 特征数) X_train X_train.reshape((X_train.shape[0], X_train.shape[1], 1))3.4 CNN-LSTM模型定义结构顺序与参数选择网络结构我采用CNN层提取局部特征再进入LSTM捕获长期依赖的顺序。具体是Conv1D MaxPooling1D LSTM Dense。Conv1D的核数不用太大16到64之间足够池化层能把序列长度压缩减少LSTM的计算负担。from tensorflow import keras from tensorflow.keras import layers model keras.Sequential([ layers.Input(shape(lookback, 1)), layers.Conv1D(filters64, kernel_size3, paddingsame, activationrelu), layers.MaxPooling1D(pool_size2), layers.Conv1D(filters32, kernel_size3, paddingsame, activationrelu), layers.LSTM(units64, return_sequencesFalse), layers.Dense(units32, activationrelu), layers.Dense(units1) ]) model.compile(optimizeradam, lossmse, metrics[mae]) model.summary()解释一下为什么两层Conv1D第一层提取基础局部形态第二层在更高抽象层组合这些形态。实际测试中两层比一层效果好一点再加第三层收益就明显下降了。LSTM用64个单元如果数据量不大可以降到32防止过拟合。3.5 训练配置与EarlyStopping训练时我最看重的是EarlyStopping它监听验证集loss连续若干轮不下降就提前终止避免跑太多epoch导致过拟合。同时用ReduceLROnPlateau在lossplateau时自动降学习率非常有效from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau early_stop EarlyStopping(monitorval_loss, patience15, restore_best_weightsTrue) reduce_lr ReduceLROnPlateau(monitorval_loss, factor0.5, patience8, min_lr1e-6) history model.fit( X_train, y_train_resid, validation_split0.15, epochs200, batch_size32, callbacks[early_stop, reduce_lr], verbose1 )重点说一下restore_best_weightsTrue。如果不设置EarlyStopping触发时会返回最后epoch的权重而不是验证集最优的权重这会让你的结果打折扣。我一开始不知道实验效果忽好忽坏排查了很久才发现是这个问题。3.6 预测与评估函数测试阶段用ARIMA预测测试集然后用CNN-LSTM滚动预测残差。为了评估的真实性我采用每步都使用上一步真实残差更新窗口的方式这在序列预测中属于常用做法跑出来的指标能代表模型在给定历史信息下的表现。# ARIMA 预测测试集 arima_forecast fit_model.predict(startlen(y_train), endlen(y_train) len(y_test) - 1) arima_forecast np.asarray(arima_forecast) # 滚动预测残差 # 先拿到测试集的实际残差用于滚动更新 # 注意真正部署时这一步用的是预测残差效果会略差 test_resid_true y_test - arima_forecast[:len(y_test)] resid_pred_scaled [] current_window resid_scaled[-lookback:].copy() # 从训练残差最后一个窗口出发 for i in range(len(test_resid_true)): # 当前窗口形状调整 x_input current_window.reshape((1, lookback, 1)) pred_scaled model.predict(x_input, verbose0)[0, 0] resid_pred_scaled.append(pred_scaled) # 反归一化得到残差预测 pred_resid scaler.inverse_transform(np.array([[pred_scaled]]))[0, 0] # 更新窗口放入真实残差评估模式 true_scaled scaler.transform(np.array([[test_resid_true[i]]]))[0, 0] current_window np.append(current_window[1:], true_scaled) # 最终预测 final_pred arima_forecast[:len(y_test)] np.array(resid_pred_scaled)评估函数计算常用指标from sklearn.metrics import mean_absolute_error, mean_squared_error def evaluate(y_true, y_pred): mae mean_absolute_error(y_true, y_pred) rmse np.sqrt(mean_squared_error(y_true, y_pred)) mape np.mean(np.abs((y_true - y_pred) / (y_true 1e-8))) * 100 # 方向准确率 dir_true np.sign(np.diff(y_true)) 0 dir_pred np.sign(np.diff(y_pred)) 0 directional_acc np.mean(dir_true dir_pred) * 100 return mae, rmse, mape, directional_accy_true 1e-8是为了防止除零金融序列里经常有接近0的值直接算MAPE会出inf。4. 实测效果对比单一模型 vs 组合模型的表现差异4.1 实验设置我拿了一段包含明显趋势性和周期性的时序数据做验证前70%训练后30%测试所有模型共用相同的数据划分和归一化方式。对比对象是纯ARIMA、纯CNN、纯LSTM、CNN-LSTM、ARIMA-CNN-LSTM组合模型。因为随机种子会影响深度学习模型结果所有涉及神经网络的实验我都固定了np.random.seed(42)和tf.random.set_seed(42)。4.2 代表性结果下面这组数据来自我实验中的一次典型运行不同数据集上数值会有差异但相对趋势稳定模型MAERMSEMAPE (%)方向准确率 (%)ARIMA3.214.586.8351.2CNN3.755.327.9452.8LSTM2.984.166.2057.3CNN-LSTM2.763.855.7159.1ARIMA-CNN-LSTM2.433.414.9263.44.3 结果解读提升来自哪里组合模型在MAE和RMSE上明显优于单一模型MAPE降低到4.92%方向准确率达到63.4%。说明什么组合模型最大的提升不是预测值贴合而是变化方向更准。对很多实际业务来说预测准绝对值重要但判断拐点和趋势方向往往更关键。为什么CNN-LSTM在方向准确率上比LSTM高我复盘下来是因为CNN把序列的局部形态比如下跌中出现小反弹、上涨中的回调先抽取了一层特征LSTM拿到这些特征后再建模长期依赖避免了把局部噪声直接当作趋势信号。而加上ARIMA残差分流之后深度学习模型不用花精力去学习线性趋势专注残差里的非线性规律收敛更快泛化也更好。5. 训练与调参中的坑我在实战里踩过的具体问题5.1 归一化泄漏指标虚高的元凶这是最隐蔽的坑。很多人写代码图省事对整个序列做MinMaxScaler再切训练测试集结果测试集指标好看到离谱。但线上部署时你根本不可能知道未来数据的min/max模型一旦遇到超出原先范围的数值就会失灵。正确做法我前面已经写了scaler.fit(y_train)然后用同一个scaler去transform训练和测试数据。5.2 残差不平稳换差分阶数也没用有次实验发现ARIMA拟合后残差序列做ADF检验还是不平稳。排查了半天原因不是差分不够而是数据里有一个明显的周末效应——工作日和周末的均值差异非常大这种是确定性周期性ARIMA的seasonalFalse完全处理不了。解决办法是给auto_arima加上seasonalTrue并设置周期m7或者在做ARIMA之前手动剥离日历效应比如按星期做中位数中心化。不要迷信自动定阶数据本身的业务周期性要先人工确认。5.3 学习率、batch_size与随机种子深度学习模型有个特点同样的代码不同随机种子跑出来的结果可以相差好几个百分点。如果你在写论文或做对比实验一定要固定随机种子否则你根本没法判断指标提升是模型带来的还是运气带来的。学习率我用adam默认的0.001起步配合ReduceLROnPlateau。batch_size在时序预测上不建议太大32是一个比较稳的起点。太大会让每个batch里的样本过于相似梯度更新方向单一模型容易陷入局部最优。5.4 LSTM单元数越多不一定越好我把LSTM单元数从32加到128发现验证集loss先降后升128反而比64差很多。原因很明显数据量撑不起这么大的模型容量。在时序预测这种样本量普遍不大的场景里参数数量和训练数据量必须匹配。我的建议是先用小模型跑通确认数据流没问题再逐步加大容量。6. 这套方案还能怎么扩展从研究骨架到完整预测系统的升级路径6.1 从单步预测到多步预测上面的代码只做了单步预测。实际业务里往往要预测未来7天甚至30天。扩展方式有两种一是滚动多步把上一步的预测值当作下一步输入但这会让误差累积预测越远越不可靠二是用seq2seq架构把CNN-LSTM的编解码结构拉长一次输出一个序列。后者工程量大但效果更可控。我在扩展时一般先用滚动多步快速验证可行性再考虑上seq2seq。6.2 加入多元外部特征原始模型只用了单变量序列。但真实场景里影响预测的因素往往是多维的比如天气、节假日、促销活动。这些外部特征可以拼接到CNN-LSTM的输入特征维度上让每个时间步的特征从(lookback, 1)变成(lookback, num_features)。ARIMA部分保持不变只处理目标变量本身。6.3 用注意力机制和Transformer替代纯LSTM如果序列特别长LSTM的记忆还是会衰减。可以考虑两处升级一是在LSTM后面加一个Attention层让模型在解码时自动聚焦到关键的历史时间步二是直接用Transformer的Encoder替代LSTM。不过Transformer对数据量要求更高小数据集上往往不如LSTM稳定。我的经验是先别急着上最复杂的结构把基础组合模型调好再看瓶颈到底在哪里。6.4 从研究代码到工程落地的注意点模型在Jupyter里跑通只是第一步。真要部署到线上有几个细节必须处理用model.save保存完整模型和权重部署时用tf.keras.models.load_model加载ARIMA部分用pmdarima的pickle持久化整个预测流程封装成类输入一个序列输出预测结果。更激进的方案是用ONNX把模型导出可以脱离TensorFlow环境运行推理速度快一个量级。结尾一点个人的实操体会最后分享一个我在做这套组合模型时的心得。很多人会把ARIMA-CNN-LSTM当成万能药觉得模型叠得越多越厉害。我实际用下来的体会是这套方案最大的价值在于拆解而非堆叠。用ARIMA把线性部分拆走深度学习模型的收敛速度明显更快训练更稳定这是单一LSTM做不到的。但如果你手里的数据本身就是高度非平稳、强噪声或者样本量小得可怜那还是先把数据质量和特征工程做好再考虑上深度学习模型。预测模型永远只是工具真正决定效果上限的是对数据本身的理解。