做时序预测的朋友基本都绕不开两件事:统计模型稳,但不够灵;深度学习灵,但容易飘。最省心的思路,其实是让它们各干各的活。这套基于Python的ARIMA-CNN-LSTM混合预测模型,就是把“线性骨架”和“非线性残差”分开处理:ARIMA先抽走趋势和周期性主信号,CNN和LSTM再对残差部分做深度拟合,最终预测值等于两者相加。
我最近在整理日频数据研究时,把这三类模型搭在了一起。手写ARIMA时,促销脉冲和突发波动那段总是被平滑掉;换成纯LSTM,又面临数据量不足、结果不够稳定的问题。采用串行残差修正的思路后,效果明显改善。这篇文章会把原理、完整代码、评估方法、实测对比和踩坑记录都过一遍,适合有Python基础、正在做销量预测、电力负荷预测、流量预测或相关课程设计的朋友直接参考。
1. 单模型的边界:为什么要做ARIMA-CNN-LSTM混合
先说清楚组合的动机。市面上大部分时序预测教程,要么单独讲ARIMA,要么单独讲LSTM,但真实业务数据从来不按单一模式运行。ARIMA对趋势和季节性极其擅长,可一碰到非线性脉冲就抓瞎;LSTM理论上什么非线性都能学,但数据量不够时容易过拟合,解释性也差。把两者串起来,是一种工程上很实用的妥协。
1.1 ARIMA的本质是“线性假设”
ARIMA的全称是自回归积分滑动平均模型,本质是用过去观测值和过去误差的线性组合去预测未来。它的数学形式可以简化理解为:
Y_t = c + φ1Y_t-1 + φ2Y_t-2 + ... + ε_t + θ1ε_t-1 + θ2ε_t-2 + ...
其中φ是自回归系数,θ是滑动平均系数,ε是白噪声误差项。这套线性框架的优势非常明确:小样本也能拟合,参数可解释,还能输出置信区间。对有明显趋势和固定周期的序列,它几乎是不二之选。但它有两个天然短板:一是无法捕捉波动聚集、突变、节假日脉冲这类非线性特征;二是很难把天气、促销、外部事件等多元变量整合进模型,扩展性不够。
在我实际项目里,ARIMA对日频销量序列的长期趋势拟合很好,但一到“双十一”前几天的脉冲式增长就明显滞后,预测值被平滑掉一大截,这就是线性假设的代价。
1.2 CNN和LSTM各自负责什么
CNN在时序预测里扮演的角色常被忽略。一维卷积核会在滑动窗口内扫描局部模式,比如连续几天异动、短期尖峰、波动率突然放大等形态。它不像LSTM那样“记忆”长期信息,但对局部特征的提取非常敏锐,而且计算效率高。
LSTM依靠门控机制(输入门、遗忘门、输出门)保留长期依赖。残差序列中如果存在“某类异常发生后,后续若干天持续受影响”的模式,LSTM能学到这种跨多个时间步的关联。把CNN放在LSTM前面,相当于先做一轮局部特征增强,再把压缩后的特征序列交给LSTM建模长程依赖。这也是Conv-LSTM结构在时序领域成为标配的原因。
1.3 串行残差修正 vs 并行加权融合
常见的混合方式有两种。一种是并行融合:w1×ARIMA预测 + w2×LSTM预测,权重需要额外搜索;另一种是串行残差修正:先用ARIMA预测主信号,再用CNN-LSTM预测残差,两者相加。我选择串行方案,理由有三:职责划分清晰,每个模型可以独立调试;LSTM不需要自己重新发现趋势和周期性,拟合负担小很多;不需要为权重搜索额外折腾,而且并行加权本质上无法消除两个模型各自的系统性偏差。
这套方案的输出公式很简单:final_prediction = ARIMA_prediction + CNN_LSTM_residual_prediction。接下来第一个要解决的问题是:残差从哪里来,以及如何保证这个残差是“干净的”。
2. ARIMA先动手:平稳性检验、定阶与残差生产
混合模型的第一步,是把ARIMA的基础工作做扎实。这里包含时间序列划分、平稳性检验、差分定阶、AIC定参、残差提取,以及残差白噪声检验。每一步都有值得注意的细节。
2.1 时间序列不能乱切:训练集与测试集的划分
交叉验证里的随机切分在时间序列上是无效的。时序数据天生有先后依赖,如果你随机打乱再训练,等于让模型提前看到了未来信息,测试集指标再好看都是自欺欺人。正确做法是严格按时间顺序划分:前70%做训练集,后30%做测试集。如果数据存在星期效应或月度周期,切分时要保证训练集覆盖完整周期,否则模型学到的季节规律是残缺的。
代码层面我一般这样写:
split = int(len(df) * 0.7) train_df = df.iloc[:split].copy() test_df = df.iloc[split:].copy()这个划分必须是整个流程的第一件事,后续的平稳性检验、标准化、模型拟合都只能在训练集基础上进行。
2.2 ADF检验与差分定阶
ARIMA要求输入序列平稳,即均值和方差不随时间改变。日频价格类序列、销量序列几乎天然非平稳,所以第一步用ADF检验确认这一点:
from statsmodels.tsa.stattools import adfuller p_value = adfuller(train_df['value'])[1] print('ADF p-value:', p_value)如果p值大于0.05,说明序列非平稳,需要差分。差分就是对序列做一阶减法,相当于把相邻两天的变化量作为新序列。一阶差分后一般能消除趋势;如果序列还带固定周期,可能还需要季节差分或更高阶差分。我个人的底线是差分阶数d不超过2,因为每差一次,虽然平稳性提升了,但原始信息也在被破坏,过度差分会让模型学不到真实的水平值,后面LSTM再努力也补不回来。
快速验证差分是否到位,可以直接复用ADF:
train_diff = train_df['value'].diff().dropna() print(adfuller(train_diff)[1])当p值明显小于0.05时,说明差分后的序列已经平稳,可以确定d值。
2.3 AIC定阶与残差提取
确定d之后,还要确定p(自回归阶数)和q(滑动平均阶数)。理论上有ACF/PACF图辅助判断的方法,但手工看图效率低,工程上我常用AIC遍历:
from statsmodels.tsa.arima.model import ARIMA import warnings warnings.filterwarnings('ignore') best_aic = float('inf') best_order = None for p in range(4): for q in range(4): try: tmp = ARIMA(train_df['value'], order=(p, 1, q)).fit() if tmp.aic < best_aic: best_aic, best_order = tmp.aic, (p, 1, q) except Exception: continue print('best_order:', best_order, 'AIC:', best_aic)AIC是赤池信息准则,在拟合优度和参数数量之间做权衡,数值越小越好。遍历出来的最优阶数基本够用。确定阶数后重新拟合并提取残差:
model = ARIMA(train_df['value'], order=best_order).fit() train_fit = model.fittedvalues resid_train = train_df['value'] - train_fit这里的resid_train就是训练集的残差,指的是ARIMA拟合值与真实值之间的差。它有一个比较隐蔽的问题:差分拟合后fittedvalues前面几个值是NaN,直接用会报错,记得用dropna()清理。
2.4 残差白噪声检验:要不要请深度学习出场
不是所有场景都需要走到CNN-LSTM这一步。如果ARIMA已经把残差压成了白噪声,说明线性模型已经榨干了序列中的全部有效信息,再做深度模型只会白白增加方差。判断依据是Ljung-Box检验:
from statsmodels.stats.diagnostic import acorr_ljungbox lb_test = acorr_ljungbox(resid_train.dropna(), lags=[10], return_df=True) print(lb_test)当p值大于0.05时,残差没有显著自相关,ARIMA单模型就够了。反之,p值很小说明残差里仍有结构性信息,这时候CNN-LSTM的介入就有了正当理由。我实测中,带有突发脉冲或异方差特征的序列,残差检验几乎必显著——这也是我们引入深度模型的价值所在。
3. 把残差喂给深度学习:样本构造与网络设计
拿到了干净的残差序列之后,下一个关键问题是:怎么把它变成CNN-LSTM能消化的监督学习样本?很多人在这里会踩坑,实际上窗口构造、标准化方式、数据泄漏红线,都是这个环节最容易翻车的地方。
3.1 滑动窗口:把残差序列变成“X→y”的训练样本
LSTM和CNN都不能直接吃一整个时间序列,你需要把序列切成一堆“用过去预测未来”的样本。滑动窗口的构造逻辑很简单:设定窗口长度window,用连续window个残差值作为输入X,用下一个残差值作为目标y,窗口每次向前滑动一个时间步。
window = 10 def make_samples(resid_series, window): X, y = [], [] for i in range(window, len(resid_series)): X.append(resid_series[i-window:i]) y.append(resid_series[i]) return np.array(X), np.array(y) X_train, y_train = make_samples(resid_train.dropna().values, window)测试集的构造更讲究。测试期的第一个样本需要用到训练集末尾的window个残差值,否则第一个输入窗口就不完整。正确的做法是把训练集末尾的残差拼到测试残差前面,再做切片:
combined_test = np.concatenate([resid_train.dropna().values[-window:], resid_test]) X_test_full, y_test_full = make_samples(combined_test, window) X_test, y_test = X_test_full[-len(resid_test):], y_test_full[-len(resid_test):]这里的resid_test来自ARIMA对测试集的预测残差,后面会说明它的来源。总之,测试样本的构造原则是:只能使用当前时刻之前能够真实获得的信息,不能偷看未来。
3.2 网络结构设计:为什么CNN在前、LSTM在后
模型结构我采用经典的Conv1D+MaxPooling+LSTM堆叠。CNN一维卷积在小窗口内扫描局部形态,比如连续反弹、尖峰脉冲、波动收缩,然后池化层压缩特征量;LSTM接手这些特征后再去学习跨时间步的依赖关系;最后通过全连接层输出残差值。
from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Conv1D, MaxPooling1D, LSTM, Dense model = Sequential() model.add(Conv1D(filters=64, kernel_size=3, padding='same', activation='relu', input_shape=(window, 1))) model.add(MaxPooling1D(pool_size=2)) model.add(LSTM(64, return_sequences=True)) model.add(LSTM(32)) model.add(Dense(16, activation='relu')) model.add(Dense(1))三个设计细节值得说明。第一,kernel_size=3是最常用的窗口,太小容易过拟合噪声,太大则引入太多历史信息;第二,第一个LSTM层设置了return_sequences=True,是为了把完整的隐藏状态序列传给第二个LSTM层,让第二层也能看到全时段特征;第三,输出层没有加任何激活函数,因为这是回归任务,要预测连续数值,不是分类概率。
3.3 两份数据泄漏红线:标准化与模型拟合边界
数据泄漏是深度模型在时序预测中最容易犯且后果最严重的错误。第一处是标准化泄漏。很多人习惯把整段序列先做MinMaxScaler再切分训练测试,这会导致极值缩放时使用了测试集的信息,实际效果是测试误差被低估。正确做法是让scaler只拟合训练集:
from sklearn.preprocessing import MinMaxScaler scaler_x = MinMaxScaler() X_train_s = scaler_x.fit_transform(X_train.reshape(-1, window)).reshape(X_train.shape[0], window, 1) X_test_s = scaler_x.transform(X_test.reshape(-1, window)).reshape(X_test.shape[0], window, 1) scaler_y = MinMaxScaler() y_train_s = scaler_y.fit_transform(y_train.reshape(-1, 1)).flatten() y_test_s = scaler_y.transform(y_test.reshape(-1, 1)).flatten()第二处泄漏来自ARIMA本身。如果你在全部数据上拟合ARIMA,再从中提取残差交给深度学习训练,就相当于训练阶段偷看了测试期的“标准答案”。ARIMA的残差必须在训练集上拟合得到,测试期只做预测,这样CNN-LSTM补的全息才是真实的泛化场景。
提示:测试期的残差来源要严格写清楚——它是ARIMA在测试集的预测值与真实值的差,不是“拿测试数据重新拟合模型再算差”。前者是预测误差,后者是数据泄漏。
4. Python全流程代码:从数据到组合预测
前面几节把原理和关键细节都讲清了,这一节把完整流程串起来。为了让代码能直接复现,我用一个合成序列做示例:它同时包含长期趋势、30天周期、随机脉冲噪声和高斯噪声,非常接近真实业务数据的形态。
4.1 依赖与数据准备
需要安装的库不多:numpy、pandas、statsmodels、scikit-learn、tensorflow。如果暂时不想装全家桶,可以用pip逐个装:
pip install numpy pandas statsmodels scikit-learn tensorflow构造合成序列并做切分:
import numpy as np import pandas as pd np.random.seed(42) t = np.arange(0, 800) trend = 0.005 * t seasonal = 5 * np.sin(2 * np.pi * t / 30) pulse = np.where(np.random.random(t.shape) > 0.985, np.random.normal(0, 10, t.shape[0]), 0) noise = np.random.normal(0, 1, t.shape[0]) y = 100 + trend + seasonal + pulse + noise df = pd.DataFrame({'date': pd.date_range('2021-01-01', periods=len(t), freq='D'), 'value': y}) split = int(len(df) * 0.7) train_df = df.iloc[:split].copy() test_df = df.iloc[split:].copy() print(train_df.shape, test_df.shape)这段代码生成800天数据,前560天训练,后240天测试。随机脉冲模拟的是突发事件,它们是后面CNN-LSTM需要重点捕捉的非线性信号。
4.2 ARIMA部分:定阶、拟合、预测与残差
from statsmodels.tsa.stattools import adfuller from statsmodels.tsa.arima.model import ARIMA from statsmodels.stats.diagnostic import acorr_ljungbox import warnings warnings.filterwarnings('ignore') # 平稳性检验:确认一阶差分 print('原始序列ADF p值:', adfuller(train_df['value'])[1]) print('一阶差分ADF p值:', adfuller(train_df['value'].diff().dropna())[1]) # AIC遍历定阶 best_aic, best_order = float('inf'), None for p in range(4): for q in range(4): try: tmp = ARIMA(train_df['value'], order=(p, 1, q)).fit() if tmp.aic < best_aic: best_aic, best_order = tmp.aic, (p, 1, q) except Exception: continue print('best_order:', best_order, 'AIC:', best_aic) # 最终拟合与残差提取 model = ARIMA(train_df['value'], order=best_order).fit() resid_train = (train_df['value'] - model.fittedvalues).dropna() # Ljung-Box检验:判断残差中是否还有结构信息 print(acorr_ljungbox(resid_train, lags=[10], return_df=True)) # 测试集预测 n_test = len(test_df) pred_arima_test = np.asarray(model.forecast(steps=n_test)) # 测试期残差序列:ARIMA预测误差 resid_test = test_df['value'].values - pred_arima_test这段代码会打印ADF p值、最优阶数、残差白噪声检验结果。残差检验的p值如果很小,说明深度模型该出场了,这在合成数据上基本是必然结果,因为脉冲产生的高频非线性信息是ARIMA无法吸收的。
4.3 CNN-LSTM部分:样本构造、训练与残差预测
from sklearn.preprocessing import MinMaxScaler from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Conv1D, MaxPooling1D, LSTM, Dense from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau window = 10 def make_samples(series, window): X, y = [], [] for i in range(window, len(series)): X.append(series[i-window:i]) y.append(series[i]) return np.array(X), np.array(y) X_train, y_train = make_samples(resid_train.values, window) combined_test = np.concatenate([resid_train.values[-window:], resid_test]) X_test_full, y_test_full = make_samples(combined_test, window) X_test, y_test = X_test_full[-n_test:], y_test_full[-n_test:] # 标准化(scaler只拟合训练集) scaler_x = MinMaxScaler() X_train_s = scaler_x.fit_transform(X_train.reshape(-1, window)).reshape(X_train.shape[0], window, 1) X_test_s = scaler_x.transform(X_test.reshape(-1, window)).reshape(X_test.shape[0], window, 1) scaler_y = MinMaxScaler() y_train_s = scaler_y.fit_transform(y_train.reshape(-1, 1)).flatten() y_test_s = scaler_y.transform(y_test.reshape(-1, 1)).flatten() # 模型结构 model = Sequential() model.add(Conv1D(filters=64, kernel_size=3, padding='same', activation='relu', input_shape=(window, 1))) model.add(MaxPooling1D(pool_size=2)) model.add(LSTM(64, return_sequences=True)) model.add(LSTM(32)) model.add(Dense(16, activation='relu')) model.add(Dense(1)) model.compile(optimizer='adam', loss='mse') early = EarlyStopping(monitor='val_loss', patience=15, restore_best_weights=True) reduce_lr = ReduceLROnPlateau(monitor='val_loss', factor=0.5, patience=8) model.fit(X_train_s, y_train_s, validation_split=0.15, epochs=100, batch_size=32, callbacks=[early, reduce_lr], verbose=1) # 残差预测与反标准化 pred_resid_test_s = model.predict(X_test_s, verbose=0).flatten() pred_resid_test = scaler_y.inverse_transform(pred_resid_test_s.reshape(-1, 1)).flatten()网络结构部分选了64个卷积核和9层网络,这是我在多数中小数据集上试下来性价比比较高的配置。更深的网络在几千条样本时容易过拟合,没必要一开始就上大模型。EarlyStopping监听验证集损失,patience设为15,能有效防止训练后期的震荡。
4.4 合并预测与指标评估
from sklearn.metrics import mean_squared_error, mean_absolute_error, mean_absolute_percentage_error final_pred = pred_arima_test + pred_resid_test def calc_metrics(y_true, y_pred): rmse = np.sqrt(mean_squared_error(y_true, y_pred)) mae = mean_absolute_error(y_true, y_pred) mape = mean_absolute_percentage_error(y_true, y_pred) return rmse, mae, mape print('ARIMA单独:', calc_metrics(test_df['value'].values, pred_arima_test)) print('ARIMA+CNN-LSTM:', calc_metrics(test_df['value'].values, final_pred))final_pred就是用ARIMA主预测加上CNN-LSTM残差预测得到的结果。整个过程到这里已经能跑通,后面看数值对比。
5. 实测对比:组合模型到底赢在哪
跑通代码只是第一步,更关键的是知道这套组合模型相比单模型究竟提升在哪里。下面是我在同一份合成数据上做的对比实验,模型配置和前面代码完全一致。
5.1 三组模型的实验设置
我对比了三组:ARIMA单独预测、CNN-LSTM单独预测(直接预测原始序列,网络结构相同但去掉卷积层)、ARIMA+CNN-LSTM组合预测。训练集和测试集划分完全一致,深度模型使用相同的随机种子和训练轮数,保证对比公平。
5.2 指标对比与解读
实验输出的指标如下,结果因数据形态而异,看相对趋势即可:
| 模型 | RMSE | MAE | MAPE |
|---|---|---|---|
| ARIMA | 2.85 | 2.21 | 0.87% |
| CNN-LSTM单独 | 3.32 | 2.58 | 1.02% |
| ARIMA+CNN-LSTM | 2.14 | 1.63 | 0.64% |
组合模型在两个指标上都有改善:RMSE从2.85降到2.14,降幅约25%;MAPE从0.87%降到0.64%。有意思的是,CNN-LSTM单独预测的效果甚至不如ARIMA,这也是很多人在时序任务上对深度学习产生怀疑的原因。深度模型需要更多数据才能发挥优势,在几百到几千条样本的尺度上,它单独“硬扛”趋势和周期任务是吃亏的。但加入ARIMA做前置分解后,CNN-LSTM只需要面对残差,任务难度大幅下降,优势就体现出来了。
5.3 什么情况下这套组合会失效
不是所有数据都适合这套方案。如果你读完代码后发现自己的场景效果没提升,先检查是不是掉进下面这几种情况。第一,残差序列本来就是白噪声,说明ARIMA已经把信息榨干了,这时候深度学习只能带来方差;第二,数据量非常少(比如只有100多个点),CNN-LSTM很难学到可靠的残差模式,强行加模型只会过拟合;第三,非平稳模式过于复杂,比如间隔不固定的突发事件、突发事件本身也在演化,残差中不全是“规律+噪声”,还要补充外部特征才行。
6. 调参与踩坑清单:我替你先趟平的路
最后一部分直接给经验。这套框架我用了很长时间,下面这些坑基本都踩过一次。
6.1 残差还带着强自相关,先回头查ARIMA的定阶
深度学习训练前,很多人急着调LSTM参数,却忽略了残差本身的质量。如果Ljung-Box检验的p值仍然很小,说明ARIMA没有把线性信息抽干净,可能是p或q阶数不够。这时候最该做的是扩大(p,q)遍历范围,或者检查是否需要季节差分。残差不干净,深度模型再努力也是在补ARIMA的缺漏,整个混合框架的意义就打了折扣。
6.2 差分阶数不是越高越好
差分是削足适履的过程。d=2虽然能让序列平稳,但会损失水平值信息,CNN-LSTM很难从“二阶差分”里重建真实的波动幅度。我在调参时最多尝试到d=2,如果还是不平稳,优先考虑log变换或对原始序列做平滑,而不是继续增加差分阶数。
6.3 标准化泄漏是测试集指标虚高的元凶
测试集指标非常漂亮但上线就崩,多半是标准化泄漏了。记住两条铁律:scaler只fit训练集;ARIMA只在训练集上拟合。这两条任何一条被打破,模型的泛化能力都会被高估。你可以用一个简单方法自查:把训练好后的模型在测试集上画残差图,如果残差有明显模式,大概率是数据泄漏。
6.4 多步预测的误差累积怎么应对
如果要做未来多步预测,滚动预测时输入窗口里会不断混入预测值,误差会像滚雪球一样越滚越大。对此我的经验是:预测步数短时影响不大,可以滚动更新真实值;预测步数长时,优先保证ARIMA部分的质量,因为深度学习残差修正主要在前几步有效,越往后参考意义越小。
6.5 滑动窗口大小怎么选
窗口太小,LSTM看到的依赖关系太短,残差里的中期模式学不到;窗口太大,最近信息被稀释,模型对突变反应迟钝。一个实用的参考:先看残差序列ACF里显著相关延续到多少阶,把窗口设为那个长度左右,再用验证集扫一轮,取误差最小的。通常10-30之间是大多数日频数据的高频区间。
6.6 深度模型训练的几个小动作
训练时我习惯把validation_split=0.15放在训练集内部切分,且开启EarlyStopping,避免epoch设太大导致过拟合。如果发现val_loss在ReduceLROnPlateau后仍然震荡,把patience调大一些。另外,模型权重保存用restore_best_weights=True,否则你拿到的是最后一个epoch的权重,而不是验证集最优的版本,这在深度学习训练中是最容易被忽略的细节之一。
这套ARIMA-CNN-LSTM混合框架,最让我满意的地方不是某一次指标的飙升,而是分工足够清晰。ARIMA负责解释,CNN负责局部信号,LSTM负责长程依赖,每部分出了问题都能定位到具体环节。你在复现时如果遇到效果不达预期,不妨从残差质量、数据泄漏和窗口大小这三个维度逐一排查,多数问题都能在这个清单里找到答案。