ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ARIMA-CNN-LSTM混合模型:残差学习实现稳健时间序列预测

ARIMA-CNN-LSTM混合模型:残差学习实现稳健时间序列预测 市面上的时间序列预测我很少听到有人把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_coldate) 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, window12): 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、qARIMA的参数里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, orderbest_order) arima_fit arima_model.fit() # 训练集上的拟合值注意d0时前几个值会是NaN train_fitted arima_fit.fittedvalues train_fitted train_fitted.dropna() # 计算训练残差 train_resid train_series.loc[train_fitted.index] - train_fittedstatsmodels在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(stepsn_forecast).predicted_mean arima_forecast.index test_series.index # 获取置信区间可选 ci arima_fit.get_forecast(stepsn_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, lags20) 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( filters64, kernel_size3, activationrelu, paddingsame, input_shape(window_size, feature_dim) )) model.add(MaxPooling1D(pool_size2)) model.add(LSTM(units64, activationtanh, return_sequencesFalse)) model.add(Dropout(0.2)) model.add(Dense(units16, activationrelu)) model.add(Dense(units1)) model.compile(optimizeradam, lossmse) 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( monitorval_loss, patience10, restore_best_weightsTrue ) history model.fit( X_train, y_train, validation_split0.1, epochs100, batch_size32, callbacks[early_stop], verbose1 )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 残差学习的完整流程整个模型的融合逻辑可以拆成五个步骤训练ARIMA模型得到训练集拟合值和测试集预测值。计算训练集真实值与ARIMA拟合值的差得到训练残差。将训练残差通过滑动窗口和归一化构造CNN-LSTM的训练样本。训练CNN-LSTM对残差建模并预测测试集残差。将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, windowwindow_size) X_test, _ create_sequences(test_resid_scaled, windowwindow_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, modemulti_step): # ARIMA部分 arima_step_forecast arima_fit.get_forecast(steps1).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, mape6.2 基准实验与混合模型对比我在一组日销售数据上做过对比数据有180个训练点60个测试点。对比三个模型单独ARIMA、单独CNN-LSTM、以及ARIMA-CNN-LSTM残差学习组合结果大致如下不同数据集会有差异这里只展示相对趋势模型RMSEMAEMAPEARIMA12.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(y0, colorred, linestyle--, linewidth1) 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的实现笔记能帮你在自己的数据上少走几次弯路。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进