多变量多输出时间序列预测:PyTorch LSTM实战指南
2026/9/16 6:30:38 网站建设 项目流程

简介:面向深度学习入门者与时间序列预测初学者,这份基于PyTorch的LSTM多变量多输出预测使用例,演示了如何利用滑动窗口(以50个历史时刻预测下一时刻多个变量)完成回归建模。代码生成由sinx、cosx、tanx组合出的序列,输入步长0.1的等间距数据,清晰展示数据构造、LSTM网络搭建、训练与预测流程,可帮助快速理解多变量多输出场景的实现细节,并迁移到股票、气象等真实数据中。包体结构精简,仅包含1个Python脚本文件,压缩包大小约1KB,无需复杂配置即可阅读和运行,适合用作者思路对照参数修改和二次开发。资源已吸引8299人浏览学习,经过较多学习者检验,具有较好的参考价值;通过实际运行脚本,读者能直观观察LSTM对周期性函数序列的拟合效果与预测误差,借此掌握输入维度、隐藏层节点、输出维度等关键设置的调整方法。

1. 多变量多输出时间序列预测,为什么 PyTorch + LSTM 是稳妥起点

假设你手里是某园区过去 30 天的温度、湿度和用电负荷,每分钟一条记录,老板要的不再是“明天峰值多少”,而是未来 6 个小时的负荷曲线。这类任务在电力、交通、水文径流预报里非常普遍,输入是多条传感器曲线,输出是未来多个时刻的数值,有时还要同时给出多个目标的预测。PyTorch 里的 LSTM 神经网络天然适合这种带时序依赖的多变量输入,但很多人在第一步就卡住了:数据怎么切窗、label 怎么给、模型输出到底该 reshape 成什么形状。下面给出一个能直接跑通的多变量多输出时间序列预测示例,从数据集构造一路到评估曲线,你可以照着复制、改参数,迁移到自己的项目里。

2. 多变量多输出 LSTM 的数据构造:滑窗逻辑与归一化顺序

在训练任何 PyTorch 模型之前,都要先把普通 DataFrame 转成“窗口样本”。LSTM 的基本输入约定是[batch_size, seq_len, feature_dim],这决定了滑窗代码是第一道坎。

2.1 先分清“多输出”是步长预测还是多目标预测

LSTM 时间序列预测常把“多输出”说混。一种含义是“多步预测”,输入过去 24 个时刻,输出未来 12 个时刻;另一种是“多目标预测”,同一个时刻输出温度和负荷两个目标。实际项目里两者经常同时出现:输入多个特征,既输出未来多个时刻,又输出多个目标。下面这段示例采用“共享 LSTM 主干 + 全连接层展平”的方式,输出张量形状为[batch, horizon, num_targets],一套网络覆盖两种需求。

这个设计对回归头有什么直接影响?如果只做单步预测,全连接层输出维度就等于num_targets;如果要多步,就得乘上horizon。把两步合在一起时,输出头的线性变换会同时混合时间和目标维度,后面用view拆开。这种做法的优点是权重复用、代码简洁,缺点是不同目标的量纲差异会被同一个损失函数平均。所以第 2.3 节的归一化不要偷懒,最好先标准化再进模型。

2.2 用 Pandas 把原始序列切成 [batch, seq_len, feature_dim]

假设已经准备好了模拟数据,3 个特征分别是负荷、温度、湿度。下面的 Pandas 片段只为说明结构,实际项目中把df换成你的真实数据即可。

import numpy as np import pandas as pd np.random.seed(42) total_steps = 2000 t = np.arange(total_steps) # 构造 3 个带周期和噪声的特征,模拟传感器数据 load = 50 + 10 * np.sin(t / 40) + np.random.normal(0, 1, total_steps) temp = 20 + 3 * np.sin(t / 80) + 0.2 * t / total_steps + np.random.normal(0, 0.5, total_steps) hum = 60 + 5 * np.cos(t / 60) + np.random.normal(0, 1, total_steps) df = pd.DataFrame({'load': load, 'temp': temp, 'hum': hum})

原始数据是 2000 行,3 列。滑窗函数从每一行开始取出history行作为输入 x,紧随其后的horizon行作为标签 y,x 和 y 之间没有重叠。

def sliding_window(data, history=24, horizon=12, pred_cols=None): """把多变量时间序列切成监督学习样本。 data: DataFrame,列顺序为输入特征顺序 history: 用过去多少个时刻预测未来 horizon: 预测未来多少个时刻 pred_cols: 需要预测的原始列下标,例如 [0, 1] 表示预测 load 和 temp """ if pred_cols is None: pred_cols = list(range(data.shape[1])) all_values = data.values xs, ys = [], [] for i in range(len(data) - history - horizon + 1): x = all_values[i:i + history, :] # [history, feature_dim] y = all_values[i + history:i + history + horizon, pred_cols] # [horizon, num_targets] xs.append(x) ys.append(y) return np.array(xs), np.array(ys) X, y = sliding_window(df, history=24, horizon=12, pred_cols=[0]) print(X.shape) # (1965, 24, 3) print(y.shape) # (1965, 12, 1)

这里pred_cols=[0]是只预测load,但输入仍然是 3 个特征,所以已经满足“多变量输入、单目标多步输出”。如果改成pred_cols=[0, 1],y 的最后一个维度就是 2,输出变成多目标多步。滑窗得到的样本数是len(data) - history - horizon + 1,也就是 2000 - 24 - 12 + 1 = 1965。

sliding_window的实现没有做归一化,也没有划分训练集,它只负责把时间轴变成窗口样本。很多新手的报错都来自这里:x 是[history, feature_dim],但模型期待 batch 维,所以必须在外层包上样本数;上面np.array自动补出了[samples, history, feature_dim],正好对应 PyTorch 的batch_first=True

2.3 归一化、切分验证集,以及防止未来信息泄漏

LSTM 的激活函数对输入尺度敏感,多特征量纲不一致时,量级大的特征会主导梯度。常见做法是对每个输入特征分别做标准化,对每个输出目标分别做标准化。

from sklearn.preprocessing import StandardScaler # 先按时间顺序切分,再 fit scaler,避免验证集信息进入训练过程 train_len = int(len(X) * 0.8) X_train, X_val = X[:train_len], X[train_len:] y_train, y_val = y[:train_len], y[train_len:] # 把窗口展平成二维,按特征列标准化 scaler_X = StandardScaler() scaler_X.fit(X_train.reshape(-1, X_train.shape[-1])) X_train_scaled = scaler_X.transform(X_train.reshape(-1, X_train.shape[-1])).reshape(X_train.shape) X_val_scaled = scaler_X.transform(X_val.reshape(-1, X_val.shape[-1])).reshape(X_val.shape) scaler_y = StandardScaler() scaler_y.fit(y_train.reshape(-1, y_train.shape[-1])) y_train_scaled = scaler_y.transform(y_train.reshape(-1, y_train.shape[-1])).reshape(y_train.shape) y_val_scaled = scaler_y.transform(y_val.reshape(-1, y_val.shape[-1])).reshape(y_val.shape)

这里必须提醒:先按时间顺序切分,再 fit scaler。如果先用全部数据 fit,再随机切分,验证集就会把自己的均值和方差“告诉”训练过程,这在时间序列预测里属于未来信息泄漏。scaler_X.fit用的是训练集的全部时间步,scaler_y.fit同理。

构造 DataLoader 相对简单,注意时间序列一般不用shuffle=True,因为窗口之间天然存在重叠,打乱会让模型更多地记住相邻样本的规律,而不是时间依赖本身。如果样本量非常大,可以设置shuffle=True提高训练稳定性,但验证集仍按时间顺序切分。

import torch from torch.utils.data import TensorDataset, DataLoader train_dataset = TensorDataset( torch.from_numpy(X_train_scaled).float(), torch.from_numpy(y_train_scaled).float() ) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=False) val_dataset = TensorDataset( torch.from_numpy(X_val_scaled).float(), torch.from_numpy(y_val_scaled).float() ) val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False)

到这一步,数据形状已经明确。下面这张表总结了滑窗阶段需要关注的参数:

参数含义常用取值范围
history用过去多少步24、48、72,尽量覆盖业务周期
horizon预测未来多少步12、24,不超过训练样本长度的四分之一
feature_dim输入特征数由原始列数决定,可做特征筛选
num_targets输出目标数1 到多个,目标量纲不一致时优先标准化
batch_size每个 batch 的窗口数64、128,显存不足时减半

滑窗代码本身不复杂,但窗口重叠会让样本之间相关性很高,这在后续评估中也要注意:不能像分类任务那样简单做 K 折交叉验证,否则同一个时间点的数据可能同时出现在训练集和验证集里。

3. 在 PyTorch 中定义多变量 LSTM 网络与训练循环

数据准备好之后,模型定义反而是最简单的。PyTorch 的nn.LSTM封装了全部门控逻辑,你只需要关注输入形状、输出头和解码方式。

3.1 nn.LSTM + 多输出头的核心结构

下面是一个支持多变量输入、多步长输出、多目标输出的最小 LSTM 模型。它把 LSTM 最后一个时间步的隐藏状态接到一个全连接层上,输出维度是horizon * num_targets,再用view拆成[batch, horizon, num_targets]

import torch import torch.nn as nn class LSTMMultiOutput(nn.Module): def __init__(self, input_dim, hidden_dim, num_layers, horizon, num_targets, dropout=0.1): super().__init__() # 多变量 LSTM:input_dim 是输入特征数 self.lstm = nn.LSTM( input_size=input_dim, hidden_size=hidden_dim, num_layers=num_layers, batch_first=True, dropout=dropout if num_layers > 1 else 0.0 ) self.dropout = nn.Dropout(dropout) # 多输出头:一次性输出未来 horizon 步 × 每个目标的预测 self.fc = nn.Linear(hidden_dim, horizon * num_targets) self.horizon = horizon self.num_targets = num_targets def forward(self, x): # x: [batch, seq_len, input_dim] out, _ = self.lstm(x) # out: [batch, seq_len, hidden_dim] last = out[:, -1, :] # 取最后一个历史时刻的隐藏状态 last = self.dropout(last) y = self.fc(last) # [batch, horizon * num_targets] return y.view(-1, self.horizon, self.num_targets)

这段代码里有几个决定结果的关键点。

batch_first=True让输入输出都是[batch, seq_len, ...],否则 PyTorch 默认是[seq_len, batch, ...],初学者特别容易混淆。out[:, -1, :]取的是最后一个历史时刻的输出,假设输入是过去 24 步,就取第 24 步的隐状态。self.fc的输出没有激活函数,因为这是回归任务,最后一步直接输出实数值即可。

nn.LSTM默认会对隐藏状态做正交初始化,一般不需要手动初始化太多。如果训练很久不收敛,可以再增加下面这段,放在__init__里:

for name, param in self.lstm.named_parameters(): if 'weight_ih' in name: nn.init.xavier_uniform_(param) elif 'weight_hh' in name: nn.init.orthogonal_(param)

Xavier 初始化适合输入输出规模接近的网络,正交初始化能缓解梯度消失或梯度爆炸。这段代码不是必须的,但它能让你的 LSTM 在同样学习率下更快进入稳定下降。

3.2 训练循环里的损失函数、梯度裁剪和学习率

多输出回归的损失函数首选MSELoss,因为 LSTM 训练时均方误差对异常值更敏感,也让梯度更平滑。如果对预测偏差方向有额外要求,可以在 MSE 后面加一个方向惩罚项,但工程上先把 MSE 跑通再说。

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = LSTMMultiOutput( input_dim=X_train_scaled.shape[-1], hidden_dim=64, num_layers=2, horizon=12, num_targets=y_train_scaled.shape[-1] ).to(device) criterion = nn.MSELoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-5) epochs = 60 for epoch in range(epochs): model.train() total_loss = 0.0 for xb, yb in train_loader: xb, yb = xb.to(device), yb.to(device) optimizer.zero_grad() pred = model(xb) # [batch, horizon, num_targets] loss = criterion(pred, yb) loss.backward() # 梯度裁剪是 LSTM 训练的关键,防止时间步叠加后梯度爆炸 nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() total_loss += loss.item() * xb.size(0) avg_loss = total_loss / len(train_loader.dataset) if epoch % 10 == 0 or epoch == epochs - 1: print(f'epoch {epoch:3d} | train loss {avg_loss:.6f}')

clip_grad_norm_是 LSTM 的保命函数。展开 24 步之后,梯度会沿时间反向传播,权重范数很容易超过 1.0,特别是数据有尖峰时。max_norm=1.0表示如果梯度范数超过这个值就整体缩放,不影响方向,只限制大小。如果训练后期还有偶发 loss 爆高,可以调小到 0.5 或增大到 2.0 看验证集表现。

Adam 一般从1e-3开始;如果发现 loss 在前几个 epoch 下降很慢,先调到3e-4,不要一上来就换优化器。weight_decay=1e-5是 L2 正则,对防止过拟合有一点帮助,但不要设置到 1e-2 以上。在多输出任务里,如果不同目标量纲差异很大,MSELoss会对量纲大的目标给更高权重。因此前面 2.3 节对 y 做了标准化,让所有目标的方差接近,这才是处理这类问题更稳妥的顺序。

3.3 环境搭建和 GPU 适配的常见坑

如果你刚开始搭 PyTorch 环境,用 CPU 版也能跑通上面的最小示例。等数据量增大,可以把安装命令中的 CPU 换成 CUDA 对应版本,PyTorch 官网的安装器会根据你的 CUDA 版本和操作系统生成命令。这里有一个比较常见的 Windows 坑:导入torch时报WinError 1114,动态链接库初始化失败,多半是 NVIDIA 驱动、Visual C++ 运行库或 PyTorch 版本与 CUDA 不匹配导致的。先到 PyTorch 官网选择和自己驱动匹配的版本,不要盲装最新版。

还有一个容易被忽略的点:模型中的 LSTM 是两层,PyTorch 的dropout参数只在多层之间生效,不会加在最后一层。也就是说你的dropout=0.1控制的是第一层到第二层之间的随机失活,输出头前面还需要自己加一个nn.Dropout,上面的LSTMMultiOutput已经做了这件事。如果你设num_layers=1,传入的 dropout 会被nn.LSTM忽略,这符合 PyTorch 设计,不必感到意外。

4. 多输出时间序列预测的评估:反归一化、误差指标与曲线

训练结束后,最关键的一步是把预测值从标准化空间还原回原始量纲。很多人在这里直接把标准化后的数字拿去算误差,结果 MAE 全在 0 到 1 之间,根本没法解释。

4.1 直接多步预测和滚动评估的区别

上面的模型是“直接式多步预测”:给模型过去 24 步,它一步输出未来 12 步。这样做的好处是误差不会像递归预测那样逐次累积,但缺点也很明显,输出维度越大,全连接层越难学。评估时,首先要区分两种方法。

直接多步预测使用一个固定窗口,只预测一次,得到完整曲线。滚动评估是在每个新时刻把真实值或预测值拼进窗口,再继续预测,模拟线上环境。滚动评估更适合检验模型在长期运行中的稳定性,但计算量会成倍增加。下面这段代码先用验证集上的直接预测做基础评估,如果你想做滚动,多包一层循环即可。

with torch.no_grad(): model.eval() x_val_tensor = torch.from_numpy(X_val_scaled).float().to(device) y_val_tensor = torch.from_numpy(y_val_scaled) pred_val_scaled = model(x_val_tensor).cpu().numpy() # 反归一化:先展平最后一维,再用 scaler_y.inverse_transform pred_val = scaler_y.inverse_transform( pred_val_scaled.reshape(-1, pred_val_scaled.shape[-1]) ).reshape(pred_val_scaled.shape) y_val = scaler_y.inverse_transform( y_val_tensor.numpy().reshape(-1, y_val_scaled.shape[-1]) ).reshape(y_val_scaled.shape)

这里inverse_transform的输入形状必须是[n, num_targets],所以先reshape(-1, num_targets),还原后再恢复成[样本数, horizon, num_targets]。如果你只预测了 load 一列,num_targets=1,反归一化同样工作。

4.2 按目标变量分步计算 RMSE、MAE 和 MAPE

多输出回归的评估不建议只看一个综合 RMSE。不同目标可能有不同的业务含义,有的容许误差是 ±1,有的是 ±10。按目标分步输出指标,才能判断模型把哪个特征学得更好。

from sklearn.metrics import mean_squared_error, mean_absolute_error def eval_per_target(y_true, y_pred, target_names): for k, name in enumerate(target_names): yt = y_true[:, :, k].ravel() yp = y_pred[:, :, k].ravel() rmse = mean_squared_error(yt, yp, squared=False) mae = mean_absolute_error(yt, yp) mape = np.mean(np.abs((yt - yp) / (np.abs(yt) + 1e-6))) * 100 print(f'{name:6s} RMSE {rmse:.4f} | MAE {mae:.4f} | MAPE {mape:.2f}%') eval_per_target(y_val, pred_val, ['load'])

MAPE需要留意除零问题,这里在分母加了一个1e-6的平滑项,只适合快速检查。如果数据存在大量零值,应该改用SMAPE或直接看 MAE。另外,多步预测的误差会随 horizon 增大而增大,所以你还可以对每个 horizon 步单独计算误差,画出误差曲线,看看是不是第 6 步之后显著变差。如果是,说明模型更擅长短时依赖,这时考虑增加history或引入外部特征输入。

4.3 用 matplotlib 画出预测曲线

一张图能看出模型是否学到了趋势,比一堆数字更直接。随机抽一个验证集样本,画出某个 target 上 12 个时刻的真实值和预测值。

import matplotlib.pyplot as plt sample_idx = 0 plt.figure(figsize=(10, 4)) plt.plot(y_val[sample_idx, :, 0], marker='o', label='true load') plt.plot(pred_val[sample_idx, :, 0], marker='x', label='pred load') plt.legend() plt.xlabel('future horizon step') plt.ylabel('load') plt.grid(alpha=0.3) plt.show()

如果曲线在第一个点就明显偏移,大概率不是模型问题,而是滑窗对齐有问题。检查一下:y[i + history: i + history + horizon]的第一个值,是否正好是x[i: i+history]的最后一个值之后那个时刻。时间序列预测最常见的 bug 就是把未来点当成当前点的下一行,导致预测整体平移一个步长。

4.4 用一个 baseline 判断 LSTM 是否真的有效

很多项目只关心模型预测有多准,却忘记和最简单的 baseline 对比。常见做法是用“最后一次观测值填充”作为持久化预测:如果输入窗口的最后一个值是 50,那未来 12 步全部预测 50。这个 baseline 在某些平滑数据上效果出奇地好,如果 LSTM 连它都跑不过,先不要调模型,回去看输入特征和数据切分。

last_value = X_val[:, -1, 0] # 每个验证样本最后一个历史 load 值,注意这里是原始量纲 baseline_pred = np.repeat(last_value[:, np.newaxis], y_val.shape[1], axis=1) print('baseline RMSE:', mean_squared_error( y_val[:, :, 0].ravel(), baseline_pred.ravel(), squared=False ))

注意,X_val里包含了所有输入特征,取第 0 列就是 load。如果预测目标不是第 0 列,这里要改成对应的列下标。这个 baseline 同样要在原始量纲上比较,不能拿标准化之后的数据去算。如果 LSTM 的 RMSE 比 baseline 只低几个百分点,可能说明历史窗口内目标本身非常平稳,模型的提升空间有限,这时应该把精力放在特征工程和外部变量上,而不是继续堆 LSTM 层数。

5. 多变量多输出 LSTM 的收敛自查与扩展边界

模型跑通之后,真正有价值的是知道什么时候该停、什么时候该扩展。最后这章给三个可以直接上手的自查技巧和一张常用参数表,帮你避开最常见的坑。

5.1 小数据集中快速验证网络逻辑

拿全部数据集训练之前,先取 50 个样本跑 1 个 epoch。如果 loss 没有下降,首先要怀疑的是数据形状而不是模型结构。打印model(xb).shape,预期是[50, horizon, num_targets]。接着用torch.set_grad_enabled(False)跑一个前向,观察输出是否全为同一个常数。如果输出是常数,再检查 LSTM 的out[:, -1, :]和最后的view是否匹配。

一个更直接的方式是让模型去拟合一个已知函数:构造 y = 2 * x[-1, :, 0],也就是只依赖当前时刻的第一个特征。如果 LSTM 在几十个 epoch 内都无法记住这种线性关系,说明学习率设置或数据缩放有问题。

5.2 常用超参速查表

不同数据量下,下面这组值的表现通常比较稳定:

超参数推荐范围说明
hidden_size32 ~ 128数据量小取 32,特征多取 128
num_layers1 ~ 3超过 2 层要小心过拟合
dropout0.1 ~ 0.3多层之间和输出头前都建议设置
learning_rate1e-4 ~ 1e-3Adam 优先从 1e-3 开始
seq_len24 ~ 72至少覆盖一个业务周期
batch_size64 ~ 128序列样本重叠度高,不必追求超大 batch

如果训练 loss 一直不降,先把学习率降到 3e-4;如果降了但验证集不降,增加weight_decay或减少num_layers。不要一上来就扩大hidden_size,多变量多输出场景中,LSTM 的参数量增长很快。

5.3 向序列到序列和注意力扩展的注意点

当 horizon 变长,比如预测未来 48 步以上,一次性输出整个序列的直接多步方法会显得吃力。常见做法是改成序列到序列结构:编码器 LSTM 读取历史窗口,解码器 LSTM 逐步输出未来目标。训练时可以用 teacher forcing 把真实的上一步值作为当前步输入,但推理时只能用上一步预测值,这会让训练和推理分布不一致。解决办法是在训练中按概率随机切换真实值和预测值,比例可以从 0.5 开始调。

想要缓解长时依赖,可以引入注意力机制,把编码器每个时间步的隐状态都保留下来,而不是只用最后一步。这会增加显存占用,也会让代码复杂度上升。我的建议是先把第 4 节的 baseline 和误差曲线做完,确认长步误差确实是主要矛盾时,再考虑序列到序列架构。跑画图前也别忘了再次确认pred.shape,多输出项目里绝大多数错误都出在滑窗时把未来步放进了输入,而不是网络本身。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询