尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

ARIMA+CNN+LSTM组合模型:残差串联实现高精度时间序列预测

ARIMA+CNN+LSTM组合模型:残差串联实现高精度时间序列预测 1. 单模型的天花板我为什么最后把ARIMA和深度学习绑在了一起先说结论ARIMA不是不能做时间序列预测而是它的天花板在公式层面就被定死了——它只能刻画线性关系。CNN和LSTM理论上能逼近任何非线性函数可你让它们单独去学一段带明显趋势和周期性的序列会发现它们学趋势学得很慢经常是网络参数调了一圈又要回头看数据看它是不是有季节成分没拆干净。把三者组合起来本质上是让ARIMA先把简单但固定的那部分规律消化掉CNN-LSTM只接一个相对干净的残差序列整个模型的收敛速度和预测精度反而都上去了。我最初产生这个想法是在做一个流量预测的小项目。数据量不大一周的采样点大概两三千个趋势明确、有工作日和周末的周期变化夹杂不少突发的毛刺。我先是单独用ARIMA测试集上的表现还行但高频抖动的地方永远预测不到后来换LSTM又发现它在长周期趋势上容易出现滞后总比真实变化慢半拍。最后我把两者串起来——ARIMA负责主趋势预测LSTM只负责修行扣除趋势之后剩下的东西——效果明显改善。后来又在这个框架上加入CNN做残差序列的局部特征提取把电负荷数据、股价数据、传感器数据都跑了一遍稳定性不错这才有了这篇文章里你们看到的完整版本。这篇文章适合的读者有两类一类是已经跑通过ARIMA和LSTM的基本流程但对组合方式还有疑问的人另一类是正准备给领导或客户交付预测系统、需要从单模型升级到更高精度方案的工程师。我会把架构选择、完整Python代码、调参教训、多步预测时的误差控制都讲清楚代码可以直接复制改数据用。1.1 ARIMA那部分到底在解决什么问题ARIMA模型由自回归项、差分项和移动平均项构成它的核心假设是时序的当前值可以由过去的若干个值线性组合再加上若干个过去的预测误差来综合解释。听上去很像是机器学习里的线性回归但它比线性回归多了一个误差也参与建模的机制这让它在有自相关结构的数据上表现相当可靠。但问题在于它假定这种自相关结构在整个序列里是稳定不变的。现实数据多半不是这样尤其是带事件冲击的数据。举个例子一天中的用电量在晚上会有骤升这个骤升的形态、幅度跟当天温度、是不是周末、有没有大型电视节目都有关系ARIMA模型无论怎么调阶数都很难把这部分纯线性的规律抓干净。所以在组合框架里ARIMA的价值是大方向校准它不是用来处理细节的。1.2 CNN和LSTM在残差接力中各吃什么把ARIMA的预测做完以后用真实值减去ARIMA的预测值或拟合值得到的就是残差序列。如果ARIMA已经捕获了主要的线性趋势残差序列应该近似围绕零上下波动。但这不代表残差是纯随机白噪声。拿实际数据看残差里还残留着局部的相关性——比如突发流量后的一连串衰减波动、微观周期、非线性耦合。LSTM在这个环节专治长期依赖。它通过输入门、遗忘门、输出门控制信息的保留和丢弃在几十步的窗口里哪部分旧信息值得存下来、哪里可以扔掉都是网络自己学的。而CNN负责的是局部特征。我常用一维卷积核扫过时间窗口这相当于给序列做了多次局部模式提取比如两个连续波动之间的相对形态、短时间内的斜率变化。接在CNN后面的池化层会把序列时间维度压缩这等于告诉LSTM你不需要关注每一个原始采样点只需要关注抽象之后的浓缩特征。两者分工残差里的高维结构才能被学透。2. 架构第一道选择题残差串联还是预测结果并联把三种模型组合起来网上流传的方案大致有三类第一类是把ARIMA的预测结果当作一个额外特征拼到CNN-LSTM的输入里第二类是三个模型各自独立预测再加权平均第三类就是我一直选的残差串联让ARIMA先输出再让CNN-LSTM去拟合残差。三种我都做过对比各有各的道理但实际效果差距很明显。2.1 三种组合方式横向对比我拿一组带明显趋势和随机突变的模拟序列做过对照实验三种方案用同一个测试集、同样的训练次数和窗口长度结果是这样组合方式测试集MAE训练收敛速度我对它的评价ARIMA预测作为特征输入CNN-LSTM中等慢模型容易重复学习ARIMA已学会的规律ARIMA与CNN-LSTM独立预测后加权中等中权重选择麻烦无法适应分段变化的数据ARIMA先预测CNN-LSTM拟合残差最好快分工清晰收敛稳定先说第一种为什么容易出问题。当你把ARIMA的预测结果作为特征放进CNN-LSTM时实际上给了网络一个提示答案——这个答案本身是线性的、粗糙的网络在训练中会不断把它当参考主线结果是特征维度和网络深度都上去了但残差细节依然没有学到。我看了训练过程的损失曲线收敛速度比残差串行慢很多而且容易陷入局部最优。第二种加权平均的问题更直接什么时候ARIMA权重应该高一点什么时候CNN-LSTM权重应该高一点如果是固定权重数据规律一旦在某个时间点发生变化整个预测就朝着错误方向偏如果做成动态权重等于又增加了一个需要训练和调参的环节复杂度上去了收益却有限。2.2 为什么我最终确定串行残差方案残差串行最核心的逻辑是让每个模型只负责自己擅长的那件事。ARIMA模型对线性趋势和周期成分高度敏感它对整体方向把握非常稳CNN-LSTM对非线性波动和局部形态敏感你给它一段围绕零波动的残差序列它不用再费力去学上升趋势或者季节性拐点这些大结构可以把所有网络容量都用来拟合残差的微妙规律。这带来的直接好处是训练稳定性。在实际操作中我发现串行残差方案的训练损失曲线下降得比前两种平滑。LSTM最怕的是训练初期梯度爆炸加上不规则趋势同时出现但残差序列的均值基本接近零数值范围被限制在一个较窄的区间这天然缓解了训练不稳定问题。因此在我后续做的所有项目里这个方案都成了基础标配。3. 完整Python实现从数据清洗到结果评估下面就是可以动手跑的完整流程。我尽量用最少的封装把每一步都拆开方便你替换成自己的数据。3.1 环境依赖与数据准备需要这些库numpy、pandas、statsmodels、scikit-learn、torch、matplotlib。PyTorch的安装这里就不展开了注意Python版本和CUDA版本的匹配即可CPU环境也能跑只是训练稍慢。import numpy as np import pandas as pd import matplotlib.pyplot as plt from statsmodels.tsa.arima.model import ARIMA from sklearn.preprocessing import StandardScaler from sklearn.metrics import mean_squared_error, mean_absolute_error import torch import torch.nn as nn from torch.utils.data import DataLoader, TensorDataset数据加载部分我假设你有一列时间戳和一列数值df pd.read_csv(your_series.csv, parse_dates[date]) series df[value].values.astype(float) # 按时间顺序切分不允许随机打乱 train_ratio 0.8 train_len int(len(series) * train_ratio) train_raw series[:train_len] test_raw series[train_len:]这里有个很基础但特别容易踩的坑时序预测的划分必须严格按时间顺序绝对不能像普通分类任务那样shuffle。这一点从我第一次跑LSTM时就吃过亏——shuffle之后模型在训练集上表现极好测试集上一塌糊涂原因就是它见过未来的数据样本。序列切分之后顺手把均值方差打出来看一眼如果训练集和测试集的均值差异过大说明数据分布本身发生了漂移任何模型都比较难处理。3.2 ARIMA拟合与预测注意拟合值和预测值的区别ARIMA部分我用的是statsmodels的实现model_arima ARIMA(train_raw, order(2, 1, 2)) res_arima model_arima.fit() # 样本内拟合值注意fittedvalues第一个值常为NaN fitted_train res_arima.fittedvalues # 样本外预测 pred_test_arima res_arima.forecast(stepslen(test_raw))有两个关键点必须提醒。第一fittedvalues的索引从0开始但第一个值因为差分运算往往缺失我习惯在计算残差时从索引1开始对齐。这个细节一旦忽略残差序列就会整体错位后面CNN-LSTM学到的东西全是错乱的。第二样本内预测要用fittedvalues样本外预测要用forecast两者不要混用。我见过有人用predict(start...)去做样本外预测结果因为动态预测和静态预测的参数差异预测结果完全走样。3.3 残差计算与序列化窗口构建有了ARIMA的拟合值和预测值残差序列就好算了# 对齐样本内残差跳过第一个NaN train_resid train_raw[1:] - np.asarray(fitted_train)[1:] test_resid test_raw - np.asarray(pred_test_arima) # 可视化残差确认它大致围绕0波动 plt.figure(figsize(10, 3)) plt.plot(train_resid, labeltrain resid) plt.legend() plt.show()注意这里所谓的test_resid是按测试集ARIMA预测得到的残差你把它们整体展示出来没问题但CNN-LSTM在训练时只能用train_resid不能用任何测试集信息。预测时再用滚动方式逐步求解。构建监督学习样本时要定义一个滑动窗口函数def create_sequences(data, seq_len): 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 20 # 标准化只用训练残差的统计量 scaler StandardScaler() train_resid_scaled scaler.fit_transform(train_resid.reshape(-1, 1)).flatten() X_train, y_train create_sequences(train_resid_scaled, seq_len)这里我用了StandardScaler而不是MinMaxScaler原因后面单独说。窗口长度20代表模型用前20个残差点预测下一个点这个超参数在第四章会展开讲。3.4 CNN-LSTM模型定义与训练循环我的模型结构是Conv1d、ReLU、MaxPool1d、LSTM、全连接层class CNNLSTM(nn.Module): def __init__(self, seq_len, n_filters32, kernel_size3, lstm_hidden64, lstm_layers2, dropout0.2): super().__init__() self.conv1 nn.Conv1d(1, n_filters, kernel_size, paddingkernel_size // 2) self.relu nn.ReLU() self.pool nn.MaxPool1d(2) self.lstm nn.LSTM( input_sizen_filters, hidden_sizelstm_hidden, num_layerslstm_layers, batch_firstTrue, dropoutdropout if lstm_layers 1 else 0 ) self.fc nn.Linear(lstm_hidden, 1) def forward(self, x): # 输入形状(batch, seq_len) x x.unsqueeze(1) # (batch, 1, seq_len) x self.relu(self.conv1(x)) # (batch, n_filters, seq_len) x self.pool(x) # (batch, n_filters, seq_len // 2) x x.transpose(1, 2) # (batch, seq_len // 2, n_filters) out, _ self.lstm(x) # (batch, seq_len // 2, lstm_hidden) out out[:, -1, :] # 取最后一个时间步 out self.fc(out) return out.squeeze(-1)训练时需要把数据装进DataLoader同时准备一个验证集用于早停# 从训练集最后分割一段做验证 val_size int(X_train.shape[0] * 0.2) X_val, y_val X_train[-val_size:], y_train[-val_size:] X_train_cut, y_train_cut X_train[:-val_size], y_train[:-val_size] train_dataset TensorDataset( torch.FloatTensor(X_train_cut), torch.FloatTensor(y_train_cut) ) val_dataset TensorDataset( torch.FloatTensor(X_val), torch.FloatTensor(y_val) ) batch_size 64 train_loader DataLoader(train_dataset, batch_sizebatch_size, shuffleTrue) val_loader DataLoader(val_dataset, batch_sizebatch_size, shuffleFalse)注意训练阶段采样可以shuffle因为每个样本都是滑动窗口切片shuffle不会破坏时间顺序反而有助于收敛。这跟原始序列不能shuffle是两回事。训练循环和经验写法def train_model(model, train_loader, val_loader, epochs60, lr1e-3, patience12): optimizer torch.optim.Adam(model.parameters(), lrlr) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.5, patience5 ) criterion nn.MSELoss() best_val_loss float(inf) best_state None counter 0 for epoch in range(epochs): model.train() train_loss 0.0 for X_batch, y_batch in train_loader: optimizer.zero_grad() pred model(X_batch) loss criterion(pred, y_batch) loss.backward() # 梯度裁剪LSTM训练必备 nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() train_loss loss.item() * X_batch.size(0) model.eval() val_loss 0.0 with torch.no_grad(): for X_batch, y_batch in val_loader: pred model(X_batch) loss criterion(pred, y_batch) val_loss loss.item() * X_batch.size(0) train_loss / len(train_loader.dataset) val_loss / len(val_loader.dataset) scheduler.step(val_loss) if val_loss best_val_loss: best_val_loss val_loss best_state { k: v.clone() for k, v in model.state_dict().items() } counter 0 else: counter 1 if counter patience: print(fEarly stop at epoch {epoch}) break model.load_state_dict(best_state) return model model CNNLSTM(seq_lenseq_len) model train_model(model, train_loader, val_loader)我对LSTM训练有个执念梯度裁剪一定不能省。LSTM在序列长度较长时梯度范数经常突然变大不裁剪的话loss曲线会出现断崖式跳变前几个epoch好不容易降到0.01下一个epoch直接跳到上百。加了clip_grad_norm_之后稳定性会好很多。3.5 递归预测与最终指标计算CNN-LSTM预测测试集残差时残差值本身是未知的所以要用滚动预测def recursive_predict(model, seed_scaled, n_steps, seq_len): model.eval() window seed_scaled[-seq_len:].copy() preds [] with torch.no_grad(): for _ in range(n_steps): x torch.FloatTensor(window).unsqueeze(0) p model(x).item() preds.append(p) # 把新预测值推进窗口丢掉最旧的点 window np.append(window[1:], p) return np.array(preds) # 用训练残差末尾一段作为初始种子 seed train_resid_scaled[-seq_len:] pred_test_resid_scaled recursive_predict( model, seed, len(test_raw), seq_len ) # 反标准化残差 pred_test_resid scaler.inverse_transform( pred_test_resid_scaled.reshape(-1, 1) ).flatten() # 最终预测 ARIMA预测 残差预测 final_pred np.asarray(pred_test_arima) pred_test_resid指标计算mae mean_absolute_error(test_raw, final_pred) rmse np.sqrt(mean_squared_error(test_raw, final_pred)) print(fMAE: {mae:.4f}) print(fRMSE: {rmse:.4f})这一步骤很容易被忽略但非常重要递归预测是逐步把上一步的预测值当作下一步的输入所以误差会累积。如果序列的噪声很大递归步数一长后期几乎等于在预测错误的基础上继续犯错。这个问题我在第五章专门讲应对方案。4. 调参心得与收敛阶段的细节问题组合模型的参数自由度比单一模型大不少窗口长度、卷积核大小、LSTM隐藏层数、dropout、batch_size、学习率每一项都会影响最终效果。下面说的都是我自己跑过的经验不能保证在所有数据上都是最优但能把试错范围大大缩小。4.1 窗口长度和网络深度之间的权衡窗口长度是一个一边是回忆一边是噪声的超参数。窗口太短模型看不到足够的历史关联窗口太长模型被迫去拟合很多无关的早期信息反而稀释了近期规律的重要性。在我接触的数据里20到50之间的窗口长度是性价比最高的区间。这里有一个经验值参考窗口长度至少要覆盖一个完整的周期长度如果数据有明显周期比如24小时、7天就先用24或7*24做基准再逐步缩短尝试。网络深度方面我发现CNN一层加LSTM两层就足够。很多人一上来就把LSTM堆到三层四层看起来更强大实际上在时序预测这种监督信号相对有限的场景里深层模型很容易过拟合残差序列本来就比原始序列简单用不到那么大的容量。我的建议是先让一层CNN加一层LSTM跑通再根据验证集表现逐步加深。千万不要一上来就追求大网络。4.2 为什么残差序列最好用StandardScaler而不是MinMaxScaler很多教程在处理时序数据时习惯用MinMaxScaler把数据压到0到1之间这在原始序列上问题不大。但对于ARIMA残差我用StandardScaler有两个理由。第一残差序列理论上应该围绕零波动MinMaxScaler会把整体平移到0到1区间这本身不改变形状但LSTM的激活函数对零附近的小数值最敏感StandardScaler得到的标准分数能更好地落在激活函数的敏感区域。第二MinMaxScaler对最大最小值极其敏感残差里只要出现一个极端脉冲其他所有值都会被压缩到一个很窄的区间训练时梯度非常微弱。StandardScaler基于均值和标准差稳健性明显更好。4.3 训练收敛里最常遇到的两个坑第一个坑是训练集和验证集越分越像。我的习惯是把验证集从训练集末尾切割而不是随机切。原因是验证集必须模拟未来的数据如果随机切等于用跟训练集同分布的中间片段做验证早停的泛化信号就是假的。第二个坑是学习率调度不及时。LSTM训练的最佳学习率波动很大有时候前20个epoch在0.001下稳步下降后面就停滞了。我建议一定要配合学习率衰减比如ReduceLROnPlateau当验证损失连续几个epoch不下降时果断把学习率降一半。这是我在多个实验里反复验证有效的手段。5. 多步预测时的误差累积问题与生产对策我在第三章已经提到过递归预测会累积误差现在把这个问题讲透。很多人在实验室里只评估单步预测的结果模型看起来效果很好一旦要预测未来10步、30步、100步就会发现预测曲线越到后面越趋于平滑甚至直接衰减成一条水平线。这不是模型坏了而是递归预测的固有缺陷。5.1 递归预测的伤害到底有多大用一组数字来说明。假设单步预测的误差标准差是0.1那么两步递归预测时第二步的输入已经带上了第一步的误差第三步又叠加了前两步的误差。如果模型对扰动本身比较敏感误差会近似以指数方式增长。实际数据中LSTM往往对预测值有一种惯性它会倾向于把输入窗口中近期数值的发展趋势延续下去如果某个输入值偏了后面的输出就容易朝着同一个方向累计偏移直到预测曲线偏离真实值很远。5.2 控制误差的实际手段第一个手段是缩小预测步长。如果业务允许尽量把模型做成滚动预测每收到一个新的真实值就重新预测下一步而不是一口气预测未来100步。这是最直接也最有效的办法。第二个手段是混合预测策略用ARIMA做长期趋势外推CNN-LSTM只做短期的残差修正。不一定要让CNN-LSTM预测全部未来步数可以让它每5步重置一次用最近获得的真实值重新初始化窗口。第三个手段是调整训练时的损失函数。比如在训练阶段就用多步递归损失让模型在训练时看到误差累积的过程。这个方法实现上稍微复杂但确实可以显著提高模型在多步滚动预测场景下的鲁棒性。6. 组合模型的适用边界与我的使用建议很多人看完这类组合模型容易上头觉得模型越多越厉害但我在几个项目里也逐渐摸清了这套组合的适用边界。它不是万能的有些场景反而是在给自己增加负担。6.1 什么时候不需要上这套组合如果你的数据本身是强线性、强周期性的比如某些工业设备的平稳运行数据ARIMA单独的表现已经很稳定再叠加CNN-LSTM带来的提升可能不到5%但训练和调参时间要翻几倍。我的判断标准是先用ARIMA跑一遍测试集算一下MAE和RMSE如果误差已经能被业务接受停在这里就好。组合模型的边际收益要放在ARIMA误差明显但单纯深度学习又学不稳的场景里才最明显。另外如果数据量很小比如几百个点深度学习这部分的优势完全发挥不出来。LSTM需要足够多的序列样本才能学到门控规律小数据集下直接上ARIMA或简单的机器学习回归模型往往更稳。6.2 组合模型上线的额外注意事项上线部署时我建议把ARIMA部分和CNN-LSTM部分分开做成两个独立的服务或两个独立的函数中间用残差文件解耦。这样当数据规律变化时你可以只重新训练ARIMA或者只重新训练残差模型而不需要整个流程重新来一遍。此外残差模型的输入输出都是相对值对数据漂移的响应比原始序列模型更敏感你最好用一个监控系统实时观察残差预测的绝对值是否持续偏大一旦连续多日超出阈值说明ARIMA部分需要重新拟合。最后分享一个实操习惯每次跑完组合模型我都会把ARIMA的预测值、CNN-LSTM的残差预测值、最终融合曲线这三条线画在一起看。只盯着最终指标不看中间过程很容易漏掉某个环节失效的问题。比如有次MAE波动不大但看图才发现ARIMA预测已经整体偏移是CNN-LSTM把偏移强行拉回来了。这种相互救场看起来很漂亮但在极端边界条件下容易翻车。能提早发现问题比模型有多惊艳重要得多。
返回列表