做时间序列预测这几年,我前后试过单用 ARIMA、单用 LSTM,也试过直接把特征扔给 CNN,但每个方案都卡在同一个地方:要么线性假设太强,对突变和复杂非线性束手无策;要么神经网络吃数据吃到吐,却把序列里那些稳定的周期信号当成了噪声。直到我把 ARIMA、CNN、LSTM 三个模型按“分工协作”的思路拼在一起,预测效果才真正稳定下来。这套基于 ARIMA-CNN-LSTM 组合模型的完整 Python 实现,包含数据预处理、模型构建、训练调参和踩坑记录,适合正在做时间序列预测、想尝试混合模型但不知道从哪下手的同学参考,也适合想对比单模型和组合模型差距的进阶玩家。
1. 为什么要把 ARIMA、CNN、LSTM 拼在一起做预测
1.1 三种模型各自的底气与短板
先说 ARIMA。它的全称是自回归积分滑动平均模型,核心假设是序列的当前值可以由过去若干期的线性组合加随机误差来解释。对于有趋势、有季节性的数据,先做差分让它平稳,再用自回归和滑动平均部分去拟合,效果通常不错。我做过一组零售销量数据的实验,纯 ARIMA 在趋势平稳段可以做到误差率 5% 以内,但一旦遇到促销、节假日这类外部冲击导致的断崖式波动,预测曲线就会明显滞后,因为它的线性本质无法建模突变后的非线性反弹。
CNN 卷积神经网络本质上是特征提取器。一维卷积可以沿时间轴滑动,用多个卷积核去捕捉局部模式,比如连续三个时间点的上涨形态、一个短暂尖峰、一段水平震荡。它的优势是参数共享、计算高效,对局部特征非常敏感。但 CNN 对长距离依赖没有天然的记忆机制,卷积核的视野也就十几个时间步,再远的上下文它就看不到了。
LSTM 长短期记忆网络就是为了解决时间依赖而生的。它通过门控机制决定记住什么、遗忘什么、输出什么,理论上可以捕获数百个时间步之前的依赖关系。我在用电量数据集上单独跑过 LSTM,它对复杂非线性序列的拟合能力明显强于 ARIMA,但训练时间长、容易过拟合,而且对数据中的线性趋势和确定性季节成分,它的收敛速度反而更慢。
1.2 组合方案的设计思路:各管一段
既然三种模型各有擅长,我的思路就变成了让它们各管一段:ARIMA 负责拟合序列中的线性趋势和确定性周期成分,CNN 负责从滑动窗口里抽取局部形态特征,LSTM 负责学习窗口之外的长程动态依赖。具体来说,先用 ARIMA 对原始序列做一次“基线预测”,得到线性部分的拟合值和对应的残差;然后把残差序列交给 CNN-LSTM 去建模,因为残差里剩下的就是线性模型解释不了的非线性成分和交互效应;最后把 ARIMA 的预测结果和神经网络对残差的预测结果叠起来,得到最终输出。
这样做有个额外的好处:ARIMA 可以把序列里“常识性”的部分先吃掉,神经网络就不需要把算力浪费在这些相对好预测的成分上,可以集中资源去拟合那些真正的难点。我实测下来,组合模型的收敛速度比纯 LSTM 快了大约三成,测试集上的误差下降也超过 15%,解释起来也更容易——至少遇到业务方问“为什么这里预测偏高”时,我可以拆开看是线性部分还是残差部分的锅。
1.3 适用场景与不能盲用的地方
这套组合模型比较适合中短期预测场景,比如销量预测、用电负荷预测、水位流量预测、流量监控这类数据量大、噪声高、非线性强的时序问题。如果你的数据本身是高度规则、纯线性的,那单用 ARIMA 就够了,组合模型反而会把简单问题复杂化。
如果你的数据量很小,比如只有一两百个点,那我不建议上 CNN-LSTM,因为神经网络需要大量样本才能学出稳定的映射关系,样本太少只会过拟合。另外,这套模型默认数据是有时间顺序的,你必须在训练和测试的划分上严格按时间切,打乱顺序等于自欺欺人。组合模型也解决不了数据本身质量差的问题,缺失值、异常值、不平滑的数据,该处理的还是得先处理。
2. 数据准备与特征工程:决定成败的隐藏环节
2.1 数据清洗和格式统一
我做这一个项目的时候,第一步不是写模型,而是把所有能发现的坑都填平。原始数据可能来自数据库导出、Excel 报表或者接口采集,格式五花八门。第一件事是统一时间列,我习惯把时间列解析成 datetime 类型并设为 DataFrame 索引,再检查时间间隔是否均匀。很多地方一天的数据缺一条,如果不补,滑动窗口切出来的序列长度就错位了。
缺失值的处理我首选向前填充,即用上一个观测值填充当前缺口,因为对时序数据来说,邻居值往往比远距离值更接近真实情况。如果缺口太大,比如连续缺了 5 天以上,我会考虑用插值法或者干脆截掉这一段,避免填充出来的假数据干扰模型训练。异常值我会用分位数方法检测,比如 99% 分位之外的点,先看是不是业务真实波动,如果是录入错误就直接修正,不然模型会为了迁就一个离群点而把整体形态学歪。
2.2 平稳性检验与差分处理
ARIMA 的建模前提是序列平稳,我在这里栽过跟头:直接用原始序列拟合,画出来的残差看起来像模像样,但一旦做预测就偏差巨大。后来我养成了一个习惯,在建 ARIMA 之前一定先跑 ADF 检验,看 p 值是否小于 0.05。如果 p 值明显大于 0.05,就说明序列还不平稳,需要做一阶差分或季节差分。
差分阶数的选择也不是拍脑袋。我一般用 ACF 和 PACF 图辅助判断,再结合 AIC 准则去网格搜索最优参数。对于月度数据,通常先试一阶差分,再看结果是否能通过平稳性检验。这里有一个细节我要特别提醒:差分是在训练集上做的,预测时要同步做逆差分还原,否则最终预测值全部偏移一个常数,模型再复杂也没用。
2.3 滑动窗口构造与归一化
CNN 和 LSTM 的输入是固定长度的窗口序列,所以要把一维序列切成若干个“过去 N 个点预测下一个点”的样本对。窗口长度 N 的选择直接影响模型效果:太短,模型看不到足够上下文;太长,样本数量变少,训练效率下降。我常用的区间是 12 到 24 个时间步,具体用多少,会在后面调参时通过实验比较。
归一化这一步不能省。CNN-LSTM 内部用的是激活函数和梯度下降,如果输入数据的量纲差异很大,梯度更新会变得很不稳定。我通常用 MinMaxScaler 把数据压缩到 [0,1] 区间,但有个关键问题:scaler 只能用训练集的数据来 fit,不能在划分数据之前对整个数据集 fit。否则测试集的信息会泄漏到训练里,测试误差会虚低,上线之后立刻现出原形。
3. 核心代码实现:从 ARIMA 到 CNN-LSTM 的完整链路
3.1 库环境与种子设置
我用的 Python 版本是 3.10,模型部分依赖 statsmodels 和 TensorFlow。写代码之前先把随机种子固定,保证实验可复现。不固定种子的话,同一个模型每次跑出来的结果可能都会有差异,没法判断调参到底是真有效还是运气好。
import warnings warnings.filterwarnings("ignore") import numpy as np import pandas as pd import matplotlib.pyplot as plt from sklearn.preprocessing import MinMaxScaler from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score from statsmodels.tsa.stattools import adfuller from statsmodels.tsa.arima.model import ARIMA from tensorflow.keras.models import Model from tensorflow.keras.layers import Input, Conv1D, MaxPooling1D, Flatten, LSTM, Dense, Dropout from tensorflow.keras.optimizers import Adam import tensorflow as tf np.random.seed(42) tf.random.set_seed(42)3.2 ARIMA 部分:先抓线性基线
这一步的目标是把序列拆成“可解释的线性部分”和“剩下的残差”。我先把数据读进来,然后按时间顺序划分训练集和测试集。时间序列的划分必须按时间先后,不能随机抽样,这是铁律。
df = pd.read_csv("data.csv", parse_dates=["date"], index_col="date") series = df["value"].astype(float) # 按时间顺序划分 train_ratio = 0.8 train_size = int(len(series) * train_ratio) train_series = series.iloc[:train_size] test_series = series.iloc[train_size:]接着做平稳性检验,确定 ARIMA 的差分阶数。
result = adfuller(train_series) print("ADF p-value:", result[1])如果 p 值大于 0.05,就做一阶差分再检验。我推荐用 AIC 网格搜索选择 ARIMA 的 (p, d, q) 参数,而不是凭感觉拍。下面的代码会对几个候选参数组合逐一拟合,选取 AIC 最小的那组:
import itertools p_range = range(0, 3) d_range = range(0, 2) q_range = range(0, 3) best_aic = float("inf") best_order = None for order in itertools.product(p_range, d_range, q_range): try: model = ARIMA(train_series, order=order) fit = model.fit() if fit.aic < best_aic: best_aic = fit.aic best_order = order except: continue print("Best ARIMA order:", best_order, "AIC:", best_aic)选定参数后,正式拟合模型并提取残差:
arima_order = best_order arima_model = ARIMA(train_series, order=arima_order) arima_fit = arima_model.fit() # 训练集拟合值(线性部分) train_arima_values = arima_fit.fittedvalues # 残差 = 原始训练值 - ARIMA拟合值 train_resid = train_series.values - train_arima_values.values # 对测试集做预测 arima_forecast = arima_fit.forecast(steps=len(test_series))这里有个细节容易忽略:fittedvalues 的长度可能与原始训练集长度差一阶,因为差分会损失一个点。我通常用align把两边对齐,或者在提取残差时直接放弃第一个点,确保长度一致,否则后面传进 CNN-LSTM 时会出现维度不匹配的报错。
3.3 CNN-LSTM 部分:学习残差的非线性规律
把残差序列作为新的“目标序列”,构造滑动窗口,然后给 CNN-LSTM 喂数据。这里我用的策略是:每个窗口输入过去seq_len个残差值,输出预测下一个残差值。
def create_sequences(data, seq_len=12): X, y = [], [] for i in range(len(data) - seq_len): X.append(data[i:i+seq_len]) y.append(data[i+seq_len]) return np.array(X), np.array(y) seq_len = 12 X_train, y_train = create_sequences(train_resid_clean, seq_len)注意由于 ARIMA 残差并不是 [0,1] 区间,我依然要用 MinMaxScaler 处理残差,但只对残差做缩放,不能对整个序列做一次缩放后拆训练测试,那样会泄漏。
scaler_resid = MinMaxScaler(feature_range=(0, 1)) train_resid_scaled = scaler_resid.fit_transform(train_resid_clean.reshape(-1, 1)).flatten() X_train, y_train = create_sequences(train_resid_scaled, seq_len)测试集同样需要构造窗口,但对测试集做残差计算时注意:ARIMA 在测试集上的“残差”应该由测试集真实值减去 ARIMA 预测值得到,而不是沿用训练期的残差分布。这就要求我先算出测试集残差,再用同一个 scaler 做 transform:
test_resid = test_series.values - arima_forecast.values test_resid_scaled = scaler_resid.transform(test_resid.reshape(-1, 1)).flatten() X_test, y_test = create_sequences(test_resid_scaled, seq_len)3.4 组合模型结构:一维卷积接 LSTM
我的网络结构设计思路是:先用 Conv1D 做局部特征提取,再接 MaxPooling 降维,然后送入 LSTM 学习跨时间步的动态依赖,最后接一个全连接层输出残差预测值。这样既保留了 CNN 对短时形态的敏感度,又利用了 LSTM 对时间顺序的记忆能力。
def build_cnn_lstm(seq_len=12, filters=64, kernel_size=3, lstm_units=50): inputs = Input(shape=(seq_len, 1)) x = Conv1D(filters=filters, kernel_size=kernel_size, activation="relu", padding="same")(inputs) x = MaxPooling1D(pool_size=2)(x) x = Dropout(0.2)(x) x = LSTM(units=lstm_units, return_sequences=False)(x) x = Dense(units=lstm_units//2, activation="relu")(x) x = Dense(1)(x) model = Model(inputs=inputs, outputs=x) model.compile(optimizer=Adam(learning_rate=0.001), loss="mse", metrics=["mae"]) return model model = build_cnn_lstm(seq_len=seq_len) model.summary()这里我特意在卷积核后面加了padding="same",这样用 MaxPooling 降维后序列长度不会缩太多,LSTM 拿到的输入信息更完整。池化窗口我设成 2,如果数据点数很少,我建议不要加池化,直接把卷积输出拉平送进 LSTM,否则信息损失太严重。
训练时我加了早停回调,防止模型过拟合:
from tensorflow.keras.callbacks import EarlyStopping early_stop = EarlyStopping(monitor="val_loss", patience=10, restore_best_weights=True) history = model.fit( X_train.reshape(-1, seq_len, 1), y_train, epochs=100, batch_size=32, validation_split=0.1, callbacks=[early_stop], verbose=1 )训练结束后在测试集上预测残差,再反归一化:
nn_pred_scaled = model.predict(X_test.reshape(-1, seq_len, 1)).flatten() nn_pred = scaler_resid.inverse_transform(nn_pred_scaled.reshape(-1, 1)).flatten()3.5 组合结果:把两部分加起来
最终的预测值是 ARIMA 对测试集的预测加上神经网络对残差的预测。这里要对齐长度,因为滑动窗口会消耗掉前 seq_len 个点,所以 ARIMA 预测值也要从第 seq_len 个位置开始取。
arima_forecast_aligned = arima_forecast[seq_len:] final_pred = arima_forecast_aligned + nn_pred把真实值对应的部分也截取到相同长度:
y_true_aligned = test_series.values[seq_len:]到这里,整个模型的预测链路就完整了。我建议把最终预测画一张图,直接叠在真实值上看,比任何指标都直观。画图代码很简单:
plt.figure(figsize=(12, 5)) plt.plot(y_true_aligned, label="真实值", color="#333333") plt.plot(final_pred, label="组合预测", color="#c0392b") plt.legend() plt.title("ARIMA-CNN-LSTM 组合模型预测效果") plt.show()我把这张图当做每次实验结束后的第一道质检关卡,如果曲线形态对不上,后面调参再精细也没意义。
4. 训练与调参:几个提升精度的关键细节
4.1 滑动窗口长度的选择
seq_len是我调的第一个参数,因为它直接决定模型能看到多远的过去。我通常会横向对比几组:8、12、24、48。窗口太小,模型缺少上下文;窗口太大,样本数量会减少,而且 LSTM 更容易遗忘早期的信息。
有一次项目里数据有明显季节性且季节周期为 12 个月,我用 12 做窗口,预测效果始终差一口气,后来换到 24,效果立刻改善。原因很简单:12 个月的窗口刚好覆盖一个完整周期,但下一个周期的走势不仅依赖上一个周期,还叠加了趋势的变化率,24 个月可以看到两个周期的相对关系。具体选多少,还是要靠实验说话,我建议至少跑一个滑动窗口对比实验,不要图省事直接用默认值。
4.2 卷积核数量与 LSTM 单元数
卷积核数量我一般从 32 起步,加到 64、128,观察验证集的损失变化。卷积核太少,特征提取不足;太多,训练时间成倍增加但收益递减。我在这套模型上最后用的是 64 个卷积核,卷积核大小为 3,因为时间序列不像图像需要很大的感受野,3 个时间步的局部模式已经足够捕捉短时形态。
LSTM 单元数量我选了 50。这个值太高容易过拟合,尤其是数据量不算大的时候;太低则表达力不足。如果训练集只有几千条样本,我建议 LSTM 单元数控制在 32 到 64 之间,结合 Dropout 一起使用。Dropout 我开了 0.2 到 0.3,它不是万能的,开太大反而让模型欠拟合,在我的数据集上 0.2 的表现最稳定。
4.3 训练策略与学习率
优化器我固定用 Adam,初始学习率 0.001 基本是安全默认值。如果你的模型震荡得很厉害,可以把学习率降到 0.0005。我习惯配合 EarlyStopping 一起用,patience 设为 10,也就是说验证损失连续 10 轮没有改善就停止训练,并恢复最优权重。这个设置能省一半以上的训练时间,还能防止后期过拟合。
还有一个细节:验证集不能随机分割,尤其对时序数据。validation_split=0.1默认从序列尾部取最后 10% 作为验证集,这对我来说是合理的,因为它是“未来”的数据。千万不要自己手动随机打乱,那会破坏时间顺序,让模型看到未来的信息。
5. 评估与上线:指标、可视化和落地建议
5.1 评价指标的选择
时间序列预测的评估指标不能只看一个。我最常用的是 RMSE、MAE 和 MAPE。RMSE 对大误差敏感,用来判断模型是否在某些极端点上崩掉很有效;MAE 反映平均误差水平,比较稳健;MAPE 是相对误差,适合跟业务方汇报时用,因为“误差百分之几”这个说法最容易理解。
rmse = np.sqrt(mean_squared_error(y_true_aligned, final_pred)) mae = mean_absolute_error(y_true_aligned, final_pred) mape = np.mean(np.abs((y_true_aligned - final_pred) / y_true_aligned)) * 100 r2 = r2_score(y_true_aligned, final_pred) print(f"RMSE: {rmse:.4f}") print(f"MAE: {mae:.4f}") print(f"MAPE: {mape:.2f}%") print(f"R2: {r2:.4f}")这里有个坑:如果真实值里有 0 或接近 0 的数值,MAPE 会被撑到极大,这时候需要用加权 MAPE 或者改用其他指标,别硬汇报一个吓人的数字。
5.2 误差分析的小技巧
除了整体指标,我还会把预测误差按时间段拆开看,画出误差随时间的分布。比如某个月份误差普遍偏高,很可能是那个月有重大节假日或者外部事件,模型没学到对应的模式。这种做法能帮你发现数据里隐藏的业务规律,也能让你在向团队汇报时更有底气。
我还习惯画出残差的自相关图,检查模型是否有遗漏的信息。如果残差还表现出明显的自相关,说明模型对序列的建模并不充分,需要加特征或者调整结构。如果残差接近白噪声序列,这基本是预测模型能到达的比较好的边界了。
5.3 部署与上线注意事项
模型上线有一个容易被忽略的问题:训练时用的窗口长度和 scaler 参数要在服务端完整保存下来,新来的数据必须用相同的序列拼接方式构造输入。我建议把 scaler 对象和模型权重统一打包,用 pickle 保存,部署时直接加载。另外,线上预测需要每来一个真实值就更新一次窗口,所以你要在服务里维护一个长度固定的缓存队列,新值进来,最旧的值出去。
推理性能方面,CNN-LSTM 的单条预测速度很快,基本在毫秒级别,完全够用。但如果你的系统每天要预测成千上万条序列,就要考虑批量推理,或者把模型导出成 TensorFlow Lite 格式在边缘设备上跑,减少延迟。
6. 常见坑与排查清单
6.1 模型输出偏移了若干步
这八成是窗口对齐问题。滑动窗口的第 i 个样本预测的是第 i+seq_len 个时间点,而 ARIMA 的预测是从第 1 个点开始的,两者天然错位。我第一次跑通的时候,怎么画怎么不对,后来把 ARIMA 的预测结果在前面补了 seq_len 个 NaN,再去对齐,才把图对上。建议先画图确认对齐,再算指标。
6.2 训练集与测试集归一化泄漏
如果不小心把 MinMaxScaler 在全部数据上 fit 了,测试集信息就漏进了训练阶段。这样训练出来的模型在测试集上的表现会异常的好,但上线后效果断崖式下跌。我的习惯是写代码时先拆分数据,再 fit 训练集 scaler,测试集只用 transform。
6.3 ARIMA 拟合报错或产生 NaN
ARIMA 在网格搜索时经常会遇到某个参数组合拟合失败,报错信息五花八门。我建议在循环里包一层 try-except,跳过失败的组合,最终选择成功组合中 AIC 最小的那个。另外,ARIMA 模型的输入不能有 NaN,如果数据清洗阶段漏掉了一个空值,后续每一步都会崩,检查这一步花的十分钟永远值得。
6.4 结果比纯 LSTM 还差
如果组合模型不升反降,先别急着调参,检查一下 ARIMA 残差是不是真的还有值得学习的信息。如果 ARIMA 和真实序列几乎完全吻合,残差接近纯噪声,那么 CNN-LSTM 能学到的只有噪声,加了等于添乱。这种情况下可以考虑只用神经网络建模,或者换一种权重融合方式,比如直接让 CNN-LSTM 建模原始序列,再把 ARIMA 预测作为附加特征拼进去,而不是分成两段。
6.5 模型训练时间过长
如果训练时间实在不能接受,我会先减小 LSTM 单元数和卷积核数,把模型瘦身一遍看效果损失多少。另一个更有效的做法是减少训练轮次,配合更积极的早停,因为很多训练其实早在前 30 轮就收敛了,后面都是无效重复。
7. 个人实操心得与可扩展方向
我在多次实验后发现,ARIMA-CNN-LSTM 这套组合的价值不在于三个模型多高级,而在于它把问题拆解成了不同性质的子任务,再分别用最合适的工具去解决。实际工程中不要只盯着精度指标,还要考虑模型的稳定性和可解释性。ARIMA 的线性分量让代码容易调试,CNN-LSTM 的非线性分量提供了单模型欠缺的灵活性,两者结合之后,整个模型的结构反而比纯神经网络更容易向团队解释。
如果后续还想提升,有两个方向值得尝试。第一个是把外部特征接入模型,比如天气、节假日、营销活动等,可以在网络输入处增加一个特征通道,与历史序列一起训练。第二个是改为在线增量学习,定期用最近的数据对模型做微调,让模型跟上数据分布的变化。我目前在尝试的是把 ARIMA 换成更灵活的递归基线模型,减小过拟合风险,同时也考虑用贝叶斯方法对预测区间做更合理的估计。这个小项目一步步做到现在,最大的体会是:混合模型不是简单的模型拼凑,而是要对数据有足够深的认知,清楚每个模型在序列里到底在找什么。这样每一步选择才有依据,最后的预测结果才能让人信服。