简介面向深度学习入门者与时间序列预测初学者这份基于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行作为标签 yx 和 y 之间没有重叠。def sliding_window(data, history24, horizon12, pred_colsNone): 把多变量时间序列切成监督学习样本。 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, history24, horizon12, 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_firstTrue。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 相对简单注意时间序列一般不用shuffleTrue因为窗口之间天然存在重叠打乱会让模型更多地记住相邻样本的规律而不是时间依赖本身。如果样本量非常大可以设置shuffleTrue提高训练稳定性但验证集仍按时间顺序切分。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_size64, shuffleFalse) val_dataset TensorDataset( torch.from_numpy(X_val_scaled).float(), torch.from_numpy(y_val_scaled).float() ) val_loader DataLoader(val_dataset, batch_size64, shuffleFalse)到这一步数据形状已经明确。下面这张表总结了滑窗阶段需要关注的参数参数含义常用取值范围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, dropout0.1): super().__init__() # 多变量 LSTMinput_dim 是输入特征数 self.lstm nn.LSTM( input_sizeinput_dim, hidden_sizehidden_dim, num_layersnum_layers, batch_firstTrue, dropoutdropout 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_firstTrue让输入输出都是[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_dimX_train_scaled.shape[-1], hidden_dim64, num_layers2, horizon12, num_targetsy_train_scaled.shape[-1] ).to(device) criterion nn.MSELoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3, weight_decay1e-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_norm1.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(fepoch {epoch:3d} | train loss {avg_loss:.6f})clip_grad_norm_是 LSTM 的保命函数。展开 24 步之后梯度会沿时间反向传播权重范数很容易超过 1.0特别是数据有尖峰时。max_norm1.0表示如果梯度范数超过这个值就整体缩放不影响方向只限制大小。如果训练后期还有偶发 loss 爆高可以调小到 0.5 或增大到 2.0 看验证集表现。Adam 一般从1e-3开始如果发现 loss 在前几个 epoch 下降很慢先调到3e-4不要一上来就换优化器。weight_decay1e-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参数只在多层之间生效不会加在最后一层。也就是说你的dropout0.1控制的是第一层到第二层之间的随机失活输出头前面还需要自己加一个nn.Dropout上面的LSTMMultiOutput已经做了这件事。如果你设num_layers1传入的 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_targets1反归一化同样工作。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, squaredFalse) 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], markero, labeltrue load) plt.plot(pred_val[sample_idx, :, 0], markerx, labelpred load) plt.legend() plt.xlabel(future horizon step) plt.ylabel(load) plt.grid(alpha0.3) plt.show()如果曲线在第一个点就明显偏移大概率不是模型问题而是滑窗对齐有问题。检查一下y[i history: i history horizon]的第一个值是否正好是x[i: ihistory]的最后一个值之后那个时刻。时间序列预测最常见的 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], axis1) print(baseline RMSE:, mean_squared_error( y_val[:, :, 0].ravel(), baseline_pred.ravel(), squaredFalse ))注意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特征多取 128num_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多输出项目里绝大多数错误都出在滑窗时把未来步放进了输入而不是网络本身。本文还有配套的精品资源点击获取