资讯详情

资讯详情

PyTorch+LSTM时间序列预测实战:从数据清洗到模型调参全流程

做时间序列预测的深度学习方法里PyTorch 一直是我用得最顺手的工具。之前我用它做过电力负荷预测、股票走势拟合这次接到一个任务用 COVID-19 的公开累计确诊数据预测未来一段时间的趋势。虽然是老数据的复现但涉及的数据清洗、序列窗口、模型训练和调参流程对一个刚开始接触神经网络的新手来说挺值得完整走一遍的。下面就用 PyTorch 搭一个神经网络模型把从原始 CSV 到可视化预测曲线的链路彻底捋清楚也把我在过拟合、数据泄漏、显存不足这几类坑上折腾出来的经验一并写上。这篇文章适合两类人一类是刚入门 PyTorch、想找一个真实数据集练手时序预测的初学者另一类是已经会训练图像分类模型、但还没碰过序列数据的朋友。我会尽量把每一步“为什么这么做”讲清楚而不是甩一段黑盒代码。1. 项目全貌与思路拆解1.1 为什么用 PyTorch 做这件事选择 PyTorch不是因为它在时间序列领域有绝对优势而是它足够灵活、生态成熟而且调试体验比静态图框架舒服。定义一个 LSTM、改一改全连接层、加个 dropout都是改几行代码的事。我这次的核心任务是快速验证“神经网络预测疫情曲线”这个想法PyTorch 的即时执行模式让我能一边跑数据一边调整结构效率非常高。另外PyTorch 在论文复现和开源项目中使用率很高社区里有大量现成的工具函数比如torch.utils.data.Dataset、DataLoader、torch.optim.lr_scheduler拿来就能用省去了自己造轮子的过程。对于一个需要快速出结果的实验型项目这个优势非常重要。1.2 为什么不用传统 ARIMA 或曲线拟合一开始我也试过传统时间序列方法比如 ARIMA 和 Prophet。它们做趋势外推很快但有几个绕不开的问题第一疫情数据存在大量的周期波动和节假日效应ARIMA 需要人工判断差分阶数和季节性参数第二累计值本身有单调性转成新增值之后噪声很大传统模型对噪声的容错能力有限第三传统模型很难捕捉到“连续几天低增长后突然反弹”这类非线性模式而这些模式恰恰是神经网络擅长的。神经网络虽然没有传统统计模型那么强的可解释性但它的优势在于可以自动学习非线性关系而且通过滑动窗口把历史信息压缩进隐藏状态能记住更长时间的模式。这正是我选择 LSTM 而不是普通全连接网络的核心原因——普通 MLP 输入固定窗口没有记忆能力而 LSTM 的门控机制能选择性地保留或遗忘信息对带噪声的每日新增数据有更好的适应性。1.3 这次项目的核心难点疫情预测看似简单实际上有三个很烧脑的点。第一是数据噪声。公开数据经常出现“累计值相比前一天异常回落”的情况比如某地区修正了统计口径累计确诊数反而减少了。这会导致原始序列有负的“新增值”必须在预处理阶段清洗掉。第二是非平稳性。累计确诊曲线不是平稳序列它的均值和方差都随时间变化。直接喂给神经网络训练会非常不稳定。解决方案是使用累计值的“对数增长”或者训练新增值序列并在训练前做归一化。第三是长期依赖问题。预测未来 7 天还好如果预测未来 30 天模型很容易把前期的快速增长趋势盲目外推导致预测值爆表。这就需要合理设计窗口长度并加入正则化手段如 dropout、早停来抑制过度外推。2. 数据准备从原始疫情数据到可训练样本2.1 数据源与格式这次我使用的是公开的疫情时间序列数据集来自开源社区的整理版字段结构类似“国家/地区/省份/日期/累计确诊/累计死亡/累计治愈”。为了方便演示我只取了某个地区持续 180 天的累计确诊数据。无论数据具体来自哪里核心处理思路都一样读入 CSV过滤目标地区按日期排序。这里必须提醒一句公开疫情数据经常有地区合并、日期缺失、字段类型不统一的问题。比如有的用“2023-01-05”有的用“1/5/2023”直接在pd.read_csv()之后就要做日期格式化。你如果不处理后面做时间轴对齐时会发现索引错位导致模型学到虚假的“跳跃”模式。2.2 清洗累计值并计算每日新增原始数据通常是累计值但我更倾向预测“每日新增”因为累计值单调上升模型不管怎么输出都是合理的很难体现预测质量差异。每日新增的噪声更大但更有挑战性也更能看出模型对异常波动的反应。清洗过程我建议按以下步骤来丢弃缺失值或向前填充缺失日期的累计值对累计值做diff()得到每日新增将新增值中出现负数的位置修正为 0可能是因为统计口径修正导致的负增长用 7 天滑动平均做一次轻量平滑降低周末不报数的周期性噪声。为什么用 7 天因为很多地区的疫情数据存在明显的“周内周期性”工作日报告多、周末报告少。7 天平滑可以抹掉这个假周期让模型专注学习真实趋势。这一步不是必须的但实测之后预测曲线平滑度明显变好。2.3 构造滑动窗口数据集拿到干净的每日新增序列后还要把它变成“监督学习”格式也就是“用过去 N 天预测未来 M 天”。我把 N 设为 14M 设为 7意思是用两周的历史数据预测未来一周的每日新增值。构造方式很简单从头开始每个位置取一个长度为 14 的连续窗口作为输入取后面长度为 7 的窗口作为标签。然后滑动一步继续取下一段。PyTorch 里可以直接用torch.utils.data.Dataset封装也可以用 numpy 切片一次性生成数组。这里有一个常见错误窗口重叠度过高会导致数据集样本高度相关训练集和验证集信息重叠严重。如果你随机打乱所有样本验证集里就可能出现“和训练集窗口只有一天之差”的样本造成验证集上评估结果虚假偏高。正确的做法是按时间顺序切分比如前 80% 时间作为训练后 20% 作为验证而不是随机采样。这个细节属于典型的数据泄漏陷阱我在后面第 5 章还会详细展开。2.4 归一化与训练/验证/测试划分神经网络对输入尺度很敏感。我的操作是先对每日新增序列做 Min-Max 归一化把数值压缩到 [0, 1] 区间。公式很简单x_scaled (x - x_min) / (x_max - x_min)这里要注意x_min和x_max必须只用训练集的数据计算不能把验证集或测试集的统计量混进来。很多新手直接对整条序列做归一化看起来没毛病但这就等于让模型在训练时“见过了”验证集和测试集的最大最小值验证结果会虚高。我自己的做法是先把序列切分成训练、验证、测试三段再各自用训练段的x_min和x_max做归一化。归一化完成后还要把数据整理成 PyTorch 的Tensor。输入张量形状是(样本数, 时间步长, 特征数)这里特征数为 1时间步长为 14。LSTM 的输入格式要求第三维是特征维度即使只有一个特征也不能省略。3. 模型设计与关键实现3.1 LSTM 的输入输出设计我最后选择的模型结构是一层 LSTM 一层全连接中间加一个 dropout。LSTM 的隐藏维度设为 32全连接层把 LSTM 最后一个时间步的输出映射到 7 个预测值。LSTM 在 PyTorch 中的输入是三维张量(batch_size, seq_len, input_size)。对每个样本seq_len是 14input_size是 1。输出有两个部分整个序列每个时间步的输出(batch_size, seq_len, hidden_size)以及最后一个时间步的隐藏状态(num_layers, batch_size, hidden_size)。在做预测时我只需要取[:, -1, :]也就是最后一个时间步的隐藏输出。为什么只取最后一个时间步因为未来 7 天的预测值应该建立在“模型对过去 14 天信息的完整读取”之上最后一个时间步的隐藏状态已经聚合了前面所有信息。如果你直接拿每个时间步的输出做多步预测反而容易引入噪声。这也是 LSTM 类模型最常用的做法。3.2 PyTorch 模型类定义模型定义其实很简洁核心代码如下import torch.nn as nn class CovidPredictor(nn.Module): def __init__(self, input_size1, hidden_size32, num_layers1, output_size7, dropout0.2): super(CovidPredictor, self).__init__() self.lstm nn.LSTM( input_sizeinput_size, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue, dropoutdropout if num_layers 1 else 0 ) self.dropout nn.Dropout(dropout) self.fc nn.Linear(hidden_size, output_size) def forward(self, x): # x shape: (batch_size, seq_len, input_size) lstm_out, _ self.lstm(x) # lstm_out shape: (batch_size, seq_len, hidden_size) last_hidden lstm_out[:, -1, :] # (batch_size, hidden_size) last_hidden self.dropout(last_hidden) out self.fc(last_hidden) # (batch_size, output_size) return out这里我把batch_firstTrue设上这样输入和输出都把 batch 放在第一维符合直觉调试时不用来回转置。num_layers只设了 1因为数据量不大180 天样本裁剪成窗口后也没多少样本太深的 LSTM 反而容易过拟合。dropout加在全连接层之前是最常见的做法可以缓解过拟合。注意 PyTorch 内置的 LSTM 只有在num_layers大于 1 时才允许你传dropout参数否则会报错所以我用了条件表达式来规避这个细节。3.3 训练循环与损失计算损失函数我用 MSE均方误差因为它是回归问题的默认选择对预测值和真实值的偏差做平方惩罚能有效放大较大的预测误差。优化器用 Adam初始学习率设成1e-3。训练循环的标准流程如下import torch import torch.optim as optim model CovidPredictor() criterion nn.MSELoss() optimizer optim.Adam(model.parameters(), lr1e-3) num_epochs 100 for epoch in range(num_epochs): model.train() total_loss 0.0 for x_batch, y_batch in train_loader: optimizer.zero_grad() outputs model(x_batch) loss criterion(outputs, y_batch) loss.backward() optimizer.step() total_loss loss.item() * x_batch.size(0) avg_loss total_loss / len(train_dataset) print(fEpoch {epoch1:3d}/{num_epochs}, Loss: {avg_loss:.6f})每次迭代都要先optimizer.zero_grad()清空梯度否则 PyTorch 默认会累积梯度。我一开始忘了这步导致 loss 曲线震荡得很厉害。3.4 数据加载器配置DataLoader的batch_size我设为 16。这个值不算大因为数据量本身就少样本大致只有一百出头。批大小太大容易让优化器收敛到尖锐的局部极小值太小则梯度噪声大训练不稳定。16 是个比较折中的选择。还有一个小技巧DataLoader里配置shuffleFalse。对于时序预测训练样本本身就有顺序除非你刻意做随机抽样否则不要在批次内打乱顺序。如果硬要 shuffle也必须先保证训练集内部按时间分块后随机抽取块而不是对独立的窗口样本随机打乱。这一点直接关系到后续验证集评估的公正性我会在常见问题里详细展开。4. 训练调参与模型评估实录4.1 超参数初始值与选择依据先说结论我最终使用的超参数组合是学习率1e-3batch size16隐藏层维度32LSTM 层数1dropout0.2训练轮数100。这套参数是用一组人肉网格调参换来的。这里的核心直觉是数据量不大模型参数必须克制。如果隐藏层维度设到 128模型很快就能记忆训练集的所有曲线细节验证集 loss 反而会在第 20 轮后开始上涨。这是典型的过拟合信号。关于学习率1e-3属于 Adam 的常用默认区间。我用过一次1e-2结果 loss 在前几轮直接涨到NaN。后来我检查发现归一化后的数据虽然都在 [0,1] 之间但对 MSE 损失来说梯度过大依然可能导致数值溢出。遇到这种情况优先降低学习率而不是换优化器。4.2 训练过程监控loss 曲线与早停训练了 100 轮我在每个 epoch 结束后同时计算训练集 loss 和验证集 loss发现训练 loss 从第 1 轮的0.09左右快速下降到第 20 轮的0.015之后下降非常缓慢。验证集 loss 从第 45 轮左右开始不再下降甚至有微小的回升趋势。这说明模型在第 45 轮附近就已经接近最优继续训练没有意义。我加了一个早停逻辑如果连续 10 个 epoch 验证集 loss 都没有刷新最低值就提前终止训练并强制恢复模型权重到验证集 loss 最低时的状态。这个逻辑虽然简单但能避免第 60 轮之后出现的轻微过拟合。代码实现可以用一个临时变量保存best_state_dict每次验证集 loss 刷新时更新best_val_loss float(inf) best_state None for epoch in range(num_epochs): # 训练代码... val_loss evaluate(model, val_loader) if val_loss best_val_loss: best_val_loss val_loss best_state {k: v.clone() for k, v in model.state_dict().items()} else: wait 1 if wait 10: break if best_state: model.load_state_dict(best_state)4.3 预测效果评估MAE 与 RMSE我用验证集上最后 7 天的真实数据和模型预测数据分别计算了 MAE 和 RMSE。MAE 约等于 1200RMSE 约等于 1800单位是“日新增确诊数”。这里的数值大小取决于具体地区和时间段并不绝对。我习惯把预测值反归一化之后再做评估因为归一化数据上的误差会“看起来很小”导致你对模型真实表现产生误判。反归一化很简单predictions model(x_batch).detach().numpy() predictions predictions * (x_max - x_min) x_min值得注意的是RMSE 比 MAE 大 50%说明模型在某些日期上犯了比较大的错误。查看曲线后发现这些错误几乎都出现在真实疫情曲线出现“突然反弹”的日期。模型在前 14 天窗口没有捕捉到反弹信号预测结果也就偏向保守。这个现象不是模型结构能完全解决的更多是输入信息不足导致的。4.4 对比实验MLP vs LSTM为了确认 LSTM 的必要性我额外实现了一个普通的全连接网络结构是输入层(14) - 隐藏层(64) - ReLU - Dropout(0.3) - 输出层(7)。同样训练 100 轮效果比 LSTM 差了一些RMSE 上升约 20%而且预测曲线的滞后性非常明显。为什么 MLP 会滞后因为 MLP 不能显式建模时间顺序它把 14 天的输入当成 14 个独立特征完全忽略了“第 3 天输入相对于第 10 天输入更早”这一事实。LSTM 则通过隐藏状态依次读取每个时间步天然能够捕捉序列内部的状态演化。如果你只是想快速做一个 baselineMLP 没问题但只要数据有前后依赖LSTM 的性价比远高于 MLP。这个对比并不是说 LSTM 一定是最优解。实际上近年也有许多研究用 Transformer 做时序预测效果也不错。但对于这个小规模的疫情数据LSTM 已经足够而且训练时间极短CPU 上 100 轮也只要几分钟。5. 常见问题与排查技巧实录5.1 数据泄漏是怎样悄悄发生的数据泄漏是时序预测最容易踩的坑。我最初把完整序列按普通机器学习那样随机拆分把所有窗口样本丢进train_test_split随机抽 20% 做验证。结果验证集 loss 低得离谱RMSE 只有 300。后来我意识到验证集里混入了大量与训练集高度重叠的窗口相当于“模型见过答案”。正确的做法是先按时间把完整序列切分成train_seq、val_seq、test_seq再分别构造窗口。你可以把训练集设定为总序列的前 70%验证集是接下来 15%最后 15% 作为最终测试。这样验证集样本在任何时间上都晚于训练集才是真正评估未来预测能力的方式。5.2 训练发散与梯度爆炸我遇到过 loss 在某个 epoch 突然变成NaN的情况排查下来有两个主因一个是学习率太大另一个是输入数据里有NaN或Inf。如果你用了我前面提到的最小最大值归一化并且在新数据上传入之前没有做fillna归一化就会在空值处产生NaN之后梯度计算全部失效。排查方法很简单在训练前打印x.min()和y.max()再在第一个 batch 前打印模型输出的值。如果输出已经变成NaN那说明输入数据可能有异常。如果输出正常但 loss 发散了优先把学习率降到3e-4再试。我自己的最终训练参数里学习率降到3e-4后曲线反而更平滑。5.3 显存不足和 CPU 训练方案这个模型非常小CPU 训练完全无压力。如果你用的是通用 GPU 环境batch_size16、hidden_size32对整个显存来讲只是毛毛雨不需要担心。我第一次实现在更大规模的 LSTM隐藏层 512输入特征 10 个上就遇到了显存爆炸问题解决办法是改用梯度累积把batch_size64拆成 4 个batch_size16的微批次每完成一个微批次累加梯度4 个之后手动optimizer.step()再清零。对于大多数序列预测任务显存问题的核心不在于模型本身而在于你同时加载了多少个窗口样本。如果数据量真的很大可以先裁剪窗口或者使用torch.utils.data.DataLoader的num_workers和prefetch_factor参数来节省显存峰值。5.4 输出维度错误与多步预测的坑新手最常遇到的报错是维度不匹配。LSTM 输出的三维张量接线性层时如果不取lstm_out[:, -1, :]而是直接把整个三维张量传给nn.Linear就会报mat1 and mat2 shapes cannot be multiplied。这个报错信息已经足够明确但新手常常会去改线性层维度而不是改取值方式。另一个更隐蔽的问题是如果你做多步预测不是一次性输出未来 7 天而是序列式地“预测下一天然后拼回输入继续预测下一天”那么误差会在每一轮被放大。我建议在绝大多数场景下都用单次输出未来多天的结构也就是我代码里的output_size7。只有在需要超长预测比如未来 30 天时才考虑序列采样和教师强制。此外如果验证集 loss 很低但画出曲线后发现预测曲线比真实曲线整体平移了一天很可能你把“预测未来 7 天”和“预测第 8 天”混为一谈了。也就是在构造标签时把目标值错位了一个时间步。这个问题肉眼很难发现我一般会打印一个批次中窗口的结束日期和目标日期做对比确保没有偏移。5.5 环境配置与复现细节最后补一句环境问题。PyTorch 的安装本身不算难但如果你在 Windows 或 WSL 下用 GPU请务必提前确认自己 CUDA 版本和 PyTorch 版本的对应关系安装完立刻跑一段assert torch.cuda.is_available()。这个项目用 CPU 也能跑通所以即使没有 GPU 也不会影响阅读和复现。如果你用 Anaconda 管理 Python 环境建议新建一个专用的conda envPython 3.9 PyTorch 2.x pandas matplotlib 就够了。不同版本之间的代码兼容性差异通常不大但如果你用了较新的torch.compile特性那就要注意老版本会报错。我在实际实验中还发现代码里固定随机种子非常关键。LSTM 初始化权重是随机的如果你不设种子每次运行预测曲线可能都不太一样无法做对比试验。我习惯在训练前加上torch.manual_seed(0) np.random.seed(0)如果用了 GPU还可以加一句torch.backends.cudnn.deterministic True虽然会稍微降低运行速度但保证可复现性。这个项目做完之后最大的体会是时间序列预测里数据预处理和评估方式比模型结构本身更容易决定成败。我一开始投入大量时间调整 LSTM 层数效果远不如老老实实清洗数据、按时间切分窗口、加早停和 dropout 带来的提升大。如果你也在做类似的预测项目建议先把数据管好再去动模型。最后再分享一个小技巧画预测对比图时把真实曲线和预测曲线直接叠加在归一化前的坐标轴上用浅色虚线标出预测开始的时间点这张图会直观地告诉你是模型误差、数据噪声还是整体趋势预测失败尤其适合在做技术汇报的时候用。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →