拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

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 清洗累计值并计算每日新增

原始数据通常是累计值,但我更倾向预测“每日新增”,因为累计值单调上升,模型不管怎么输出都是合理的,很难体现预测质量差异。每日新增的噪声更大,但更有挑战性,也更能看出模型对异常波动的反应。

清洗过程我建议按以下步骤来:

  1. 丢弃缺失值,或向前填充缺失日期的累计值;
  2. 对累计值做diff(),得到每日新增;
  3. 将新增值中出现负数的位置修正为 0(可能是因为统计口径修正导致的负增长);
  4. 用 7 天滑动平均做一次轻量平滑,降低周末不报数的周期性噪声。

为什么用 7 天?因为很多地区的疫情数据存在明显的“周内周期性”:工作日报告多、周末报告少。7 天平滑可以抹掉这个假周期,让模型专注学习真实趋势。这一步不是必须的,但实测之后预测曲线平滑度明显变好。

2.3 构造滑动窗口数据集

拿到干净的每日新增序列后,还要把它变成“监督学习”格式,也就是“用过去 N 天预测未来 M 天”。我把 N 设为 14,M 设为 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是 14,input_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_size=1, hidden_size=32, num_layers=1, output_size=7, dropout=0.2): super(CovidPredictor, self).__init__() self.lstm = nn.LSTM( input_size=input_size, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, dropout=dropout 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_first=True设上,这样输入和输出都把 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(), lr=1e-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(f"Epoch {epoch+1:3d}/{num_epochs}, Loss: {avg_loss:.6f}")

每次迭代都要先optimizer.zero_grad()清空梯度,否则 PyTorch 默认会累积梯度。我一开始忘了这步,导致 loss 曲线震荡得很厉害。

3.4 数据加载器配置

DataLoader的batch_size我设为 16。这个值不算大,因为数据量本身就少,样本大致只有一百出头。批大小太大容易让优化器收敛到尖锐的局部极小值,太小则梯度噪声大,训练不稳定。16 是个比较折中的选择。

还有一个小技巧:DataLoader里配置shuffle=False。对于时序预测,训练样本本身就有顺序,除非你刻意做随机抽样,否则不要在批次内打乱顺序。如果硬要 shuffle,也必须先保证训练集内部按时间分块后随机抽取块,而不是对独立的窗口样本随机打乱。这一点直接关系到后续验证集评估的公正性,我会在常见问题里详细展开。

4. 训练调参与模型评估实录

4.1 超参数初始值与选择依据

先说结论:我最终使用的超参数组合是学习率1e-3,batch size16,隐藏层维度32,LSTM 层数1,dropout0.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 约等于 1200,RMSE 约等于 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 则通过隐藏状态依次读取每个时间步,天然能够捕捉序列内部的状态演化。如果你只是想快速做一个 baseline,MLP 没问题;但只要数据有前后依赖,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_size=16、hidden_size=32对整个显存来讲只是毛毛雨,不需要担心。我第一次实现在更大规模的 LSTM(隐藏层 512,输入特征 10 个)上就遇到了显存爆炸问题,解决办法是改用梯度累积:把batch_size=64拆成 4 个batch_size=16的微批次,每完成一个微批次累加梯度,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_size=7。只有在需要超长预测(比如未来 30 天)时,才考虑序列采样和教师强制。

此外,如果验证集 loss 很低,但画出曲线后发现预测曲线比真实曲线整体平移了一天,很可能你把“预测未来 7 天”和“预测第 8 天”混为一谈了。也就是在构造标签时,把目标值错位了一个时间步。这个问题肉眼很难发现,我一般会打印一个批次中窗口的结束日期和目标日期做对比,确保没有偏移。

5.5 环境配置与复现细节

最后补一句环境问题。PyTorch 的安装本身不算难,但如果你在 Windows 或 WSL 下用 GPU,请务必提前确认自己 CUDA 版本和 PyTorch 版本的对应关系,安装完立刻跑一段assert torch.cuda.is_available()。这个项目用 CPU 也能跑通,所以即使没有 GPU 也不会影响阅读和复现。

如果你用 Anaconda 管理 Python 环境,建议新建一个专用的conda env,Python 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 带来的提升大。如果你也在做类似的预测项目,建议先把数据管好,再去动模型。最后再分享一个小技巧:画预测对比图时,把真实曲线和预测曲线直接叠加在归一化前的坐标轴上,用浅色虚线标出预测开始的时间点,这张图会直观地告诉你是模型误差、数据噪声还是整体趋势预测失败,尤其适合在做技术汇报的时候用。

返回列表