资讯动态

多输入单输出LSTM实现与调参:从数据预处理到PyTorch源码详解

发布时间:2026/10/25 12:04:38 来源:尧图企业网站定制
简介面向需要掌握序列预测建模的 Python 开发者这份资料以股票收盘价预测为场景完整实现了多输入单输出 LSTM 模型的源代码与数据集。模型将开盘价、最高价、最低价等多维特征组织成滑动窗口序列借助 LSTM 门控机制捕获价格走势中的长期依赖最终输出单一收盘价清晰演示了从数据清洗、特征构造到网络搭建与预测评估的完整流程。资源包共十个文件包含 Jupyter Notebook 源码、CSV 训练数据、YAML 项目说明等整体约一点零五兆字节已有六百一十八人浏览学习。内容覆盖数据预处理、滑动窗口构造、LSTM 网络搭建、训练验证与结果对比等关键环节既适合课程设计与小型项目参考也可迁移至天气、能耗等单输出时序预测任务适合初学者按步骤复现。1. 多输入单输出LSTM模型这种结构为什么值得自己手写一遍做LSTM预测的人早晚会遇到一个场景手里有温度、湿度、风速、气压四路传感器数据却只想预测下一小时的能耗。输出只有一个数输入却是多路信号这就是多输入单输出LSTM模型的典型形态。这类项目在GitHub上最常见的打包形式是“源代码数据集JupyterNotebook运行说明”但拿到压缩包跑通容易换成自己的数据后从数据整理到调参每一步都踩得到坑。下面就用Python把这个结构从头拆开讲清数据集怎么构建、模型怎么写、参数怎么设以及时间序列最容易被忽略的数据泄漏问题。适合正要复现一个LSTM预测项目、又不想把模型当黑匣子的人。2. 先把数据收拾利索多变量时序的数据集构建与滑窗策略2.1 从“一堆列”到“一个监督学习样本”输入特征与目标列怎么定多输入单输出LSTM模型输入层的形状是[batch, input_steps, num_features]输出层的形状是[batch, output_steps]。其中num_features是除目标列之外的输入特征个数input_steps是模型能“回头看”的时间步长。常见做法是把温度、湿度、风速、气压四列作为输入特征能耗列作为目标列用过去24个时间点的四个特征预测未来1个时间点的能耗。这里的output_steps1就是单输出的含义。数据整理的核心是把原始二维表行是时间、列是特征转成三维样本。滑窗是主流做法从时间轴起点出发每次取连续input_steps行作为输入样本取后面紧邻一行或几行的目标列作为标签窗口滑动一格重复生成下一个样本。需要注意input_steps的语义它不是“用过去多久”而是“用过去多少个采样点”比如10分钟采样一次24步就是4小时如果原始数据的采样频率是1小时那24步就代表整整一天。换成自己的数据时务必先看一眼时间戳的间隔别把步长当成小时数。input_steps也不是越大越好。窗口过长样本数量按n - input_steps快速减少特征里真正有用的周期信息被大量无关点稀释训练时间还成倍上涨。窗口过短周期特征又抓不住。我一般是先画出目标列的自相关图看它的周期长度再在周期长度附近试12、24、48三档。比如存在明显的24小时日周期用24或48做窗口通常能比12有明显改善。2.2 用Pandas与NumPy写滑窗生成函数代码与参数说明下面这段代码我一般直接在JupyterNotebook里跑。它把标准化后的NumPy数组转成X和yX是[batch, input_steps, num_features]y是[batch, 1]。import numpy as np def create_sequences(data, input_steps24, output_steps1): data: 标准化之后的二维数组shape [总时间步, 全部特征数] 最后一列约定为目标列其余列为输入特征 num_features data.shape[1] - 1 X, y [], [] total len(data) - input_steps - output_steps 1 for i in range(total): X.append(data[i : i input_steps, :num_features]) y.append(data[i input_steps : i input_steps output_steps, num_features]) return np.array(X), np.array(y) # 示例df_raw 是原始DataFrame最后一列叫 target arr df_raw.values.astype(np.float32) X, y create_sequences(arr, input_steps24, output_steps1) print(X.shape, y.shape) # 比如 (975, 24, 4) (975, 1)这段代码里的切片要注意两个边界。i从0开始到total-1结束保证最后一个窗口结束在数组尾部不越界y取的是窗口结束位置之后紧邻的output_steps个目标值。input_steps设为24表示用过去24个采样点预测下一个采样点output_steps如果想从“预测下一小时”改成“预测未来3小时”可以改成3但后面模型的输出层也要相应调整。把目标列固定为最后一列是后面所有代码能跑对的前提加新特征时保持这个约定就行。滑窗完成后X与y的行数相同且保持时间先后顺序。此时必须先按时间把数据切分成训练段和测试段再分别滑窗顺序不能反过来。如果先滑窗再随机切分测试样本会和训练样本在时间上重叠后面的验证就失去意义。为了避免每次跑实验都重新生成一遍数据我一般会把X和y直接保存成.npy文件代码里补两行np.save(X_train.npy, X_train) np.save(y_train.npy, y_train)下次重新打开Notebook只需要np.load省去重复滑窗的等待时间。不要用csv存档因为三维数组存csv会丢失形状信息加载回来后还要自己reshape容易出维度错。2.3 标准化必须分两套MinMaxScaler按列fit的细节LSTM对输入尺度敏感原始数据里温度是20-35、风速是0-10、能耗是几千级直接把这几列送进网络梯度会被大数值列主导。常见做法是用MinMaxScaler把每一列压缩到[0,1]区间。必须注意的细节是scaler要分输入和输出两套并且在训练段上fit不能在全部数据上fit。from sklearn.preprocessing import MinMaxScaler # 按时间顺序切分前80%训练后20%测试 split_idx int(len(df_raw) * 0.8) train_df df_raw.iloc[:split_idx].copy() test_df df_raw.iloc[split_idx:].copy() scaler_x MinMaxScaler(feature_range(0, 1)) scaler_y MinMaxScaler(feature_range(0, 1)) # 只在训练段上fit测试段用同样的scaler做transform train_x_scaled scaler_x.fit_transform(train_df.iloc[:, :-1]) train_y_scaled scaler_y.fit_transform(train_df.iloc[:, -1].values.reshape(-1, 1)) test_x_scaled scaler_x.transform(test_df.iloc[:, :-1]) test_y_scaled scaler_y.transform(test_df.iloc[:, -1].values.reshape(-1, 1))如果偷懒在全量数据上fit测试段的最大值会提前进入scaler的参数训练阶段相当于偷看了未来数据评估出来的误差会偏乐观。reshape(-1, 1)是因为sklearn的scaler要求二维输入而目标列在DataFrame里默认是一维这一步经常被漏掉然后报“Expected 2D array”错误。把训练段比例定在0.8意味着模型从80%的数据里学规律剩下20%做验收不要为了追求训练量而把测试段压得太短否则验证误差的波动会大到没法判断参数好坏。标准化完成后记得把scaler_x和scaler_y保存起来预测阶段要拿同一套变换还原数值。3. 用PyTorch实现多输入单输出LSTMPython源码拆解与训练循环3.1 为什么选PyTorch而不是Keras形状可见性和调试体验同样一个多输入单输出LSTMKeras里可以用Sequential API几行写完但隐藏层状态、张量形状、梯度流动都成了黑匣子遇到形状不匹配的报错只能靠猜。PyTorch这边的好处是网络定义里每一步的形状变化都写在forward里input和output的维度可以随时看。对于要做实验、要换特征、要调参的这类项目PyTorch改代码成本更低社区里源码也大多以PyTorch形式出现遇到问题能找到的踩坑记录更多。自建类时有一个前置知识需要确认torch.nn.LSTM的默认布局是[seq_len, batch, num_features]即batch在中间。我在工程里一般都会打开batch_firstTrue让输入布局变成[batch, seq_len, num_features]这样和NumPy里创建的X形状直接对齐不用每次喂数据都做轴转置。否则就要用torch.transpose(x, 0, 1)换轴报错时容易看花眼。batch_first在官方文档里默认是False新人不写这个参数就很容易中招。3.2 网络定义batch_first与全连接输出的形状匹配多输入单输出LSTM源码的核心类实现如下。num_features在前面滑窗时已经确定hidden_size是需要调的参数这里先用64起步num_layers取2。import torch import torch.nn as nn class MultiInputSingleOutputLSTM(nn.Module): def __init__(self, num_features4, hidden_size64, num_layers2, dropout0.2): super().__init__() self.lstm nn.LSTM( input_sizenum_features, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue, dropoutdropout ) self.fc nn.Linear(hidden_size, 1) def forward(self, x): # x: [batch, input_steps, num_features] out, (h_n, c_n) self.lstm(x) last_hidden out[:, -1, :] # [batch, hidden_size] y_pred self.fc(last_hidden) # [batch, 1] return y_predforward里的两个细节决定输出形状。第一out是LSTM层在每个时间步的输出形状是[batch, input_steps, hidden_size]out[:, -1, :]取的是最后一个时间步的隐状态它汇总了整条输入序列的信息再送入全连接层如果这里错取out[:, 0, :]或直接用h_n输出形状会变成[batch, hidden_size]或更复杂的[batch, num_layers, hidden_size]全连接层输入维度对不上会直接报维度错。第二输出层用Linear(hidden_size, 1)把隐状态压成一个数值这是单输出的关键。实际部署时有些情况需要考虑序列尾部填充如果一个批次里样本长度不齐LSTM的out在填充位置也会产生隐状态此时out[:, -1, :]取到的是填充位的状态预测会失真。这个项目里滑窗长度固定不存在这个问题但你要知道这个边界。如果以后扩展到变长序列取最后有效时间步的隐状态时要配合mask操作。3.3 训练循环与数据集封装MSELoss、Adam、DataLoader的几个注释模型定义完成之后数据要封装成TensorDataset并通过DataLoader喂给模型。这里有几个形状相关的注释点新手最容易在这里翻车。from torch.utils.data import TensorDataset, DataLoader X_train_t torch.tensor(X_train, dtypetorch.float32) y_train_t torch.tensor(y_train, dtypetorch.float32).view(-1, 1) X_test_t torch.tensor(X_test, dtypetorch.float32) y_test_t torch.tensor(y_test, dtypetorch.float32).view(-1, 1) train_dataset TensorDataset(X_train_t, y_train_t) train_loader DataLoader(train_dataset, batch_size32, shuffleFalse) device torch.device(cuda if torch.cuda.is_available() else cpu) model MultiInputSingleOutputLSTM(num_features4, hidden_size64, num_layers2).to(device) criterion nn.MSELoss() optimizer torch.optim.Adam(model.parameters(), lr0.001) epochs 100 for epoch in range(epochs): model.train() total_loss 0.0 for X_batch, y_batch in train_loader: X_batch, y_batch X_batch.to(device), y_batch.to(device) optimizer.zero_grad() y_pred model(X_batch) loss criterion(y_pred, y_batch) loss.backward() optimizer.step() total_loss loss.item() if (epoch 1) % 20 0 or epoch 0: avg_loss total_loss / len(train_loader) print(fEpoch {epoch1:3d}/{epochs}, Loss: {avg_loss:.6f})y_train_t的view(-1, 1)是为了把目标列从[batch]变成[batch, 1]和模型输出y_pred的形状保持一致否则MSELoss在广播时会产生完全没意义的梯度。batch_size32在大多数中等规模数据集上是稳妥起点调大要小心显存调小会让损失曲线剧烈震荡。shuffleFalse在时间序列里更常见窗口之间有重叠打乱后相邻样本高度相似有序喂给模型训练更平稳。criterion选MSELoss是因为预测值是连续数值属于回归任务如果做的是分类型的单输出比如故障类别损失函数要换成CrossEntropyLoss那是另一个方向。训练过程的几个附加项值得一并写进实验代码torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0)放在loss.backward()之后、optimizer.step()之前能防止梯度爆炸优化器里带上weight_decay1e-5相当于L2正则对抑制过拟合有实际帮助学习率可以配合StepLR每30轮乘以0.1衰减。这三个不是必须但多输入特征如果相关性高梯度波动会比单输入明显加上之后少很多反复试的弯路。4. 在JupyterNotebook里跑通并调参多输入单输出LSTM的必调参数4.1 预测结果是一条直线hidden_size与学习率的搭配陷阱第一次把训练循环跑通验证集上的预测曲线像一条平直线这是LSTM预测项目里最常见也最劝退的现象。原因通常不是网络写错了而是模型在偷懒直接输出训练集目标列的均值代价函数反而更低。输入特征和目标的相关性太弱或者模型容量不足时LSTM都会倾向于走这个捷径。我之前就在一个用气象数据预测发电功率的项目上翻过车hidden_size16、lr0.01跑了100轮loss降得很慢且早早平了预测曲线就是一条直线。把hidden_size提到64、学习率降到0.001之后曲线才开始出现起伏。这两个参数本质上是搭配问题hidden_size太小模型学不到特征之间的交互学习率太大又在最优点附近来回震荡。一个可用的起点组合是hidden_size64、num_layers2、lr0.001在这个基础上再按结果微调。判断这个现象的辅助手段是画训练过程的loss曲线如果loss曲线在前几轮快速下降后就停滞在一个平台数值平台对应的是方差水平基本就是均值预测的征兆。此时先不要急着加层数而是确认输出序列是否先去除了趋势如果目标列有明显漂移先做一阶差分再进模型直线问题会缓解很多。4.2 loss震荡或过拟合batch_size、dropout与早停的配合损失曲线反复震荡或验证loss在训练后期反向拉升是第二高频的问题。前者常见于batch_size过小比如8梯度更新方向噪声太大后者是模型在训练集上背诵了样本却没有泛化能力。调整思路分三步把batch_size适当加大到32或64在LSTM层之间加上dropout随机丢弃一部分神经元的连接最后用一个简单的早停逻辑在验证loss不再下降时终止训练。早停我一般用原生Python实现不引入额外的回调库。它在每一轮结束时计算测试段的loss记录历史最低值连续patience轮没有降低就break。best_val_loss float(inf) patience 15 wait 0 for epoch in range(epochs): model.train() for X_batch, y_batch in train_loader: optimizer.zero_grad() y_pred model(X_batch) loss criterion(y_pred, y_batch) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() model.eval() with torch.no_grad(): val_pred model(X_test_t.to(device)) val_loss criterion(val_pred, y_test_t.to(device)).item() if val_loss best_val_loss: best_val_loss val_loss torch.save(model.state_dict(), best_model.pt) wait 0 else: wait 1 if wait patience: print(fEarly stopping at epoch {epoch1}) break if (epoch 1) % 20 0: print(fEpoch {epoch1}, train_loss: {loss.item():.4f}, val_loss: {val_loss:.4f})torch.save保存的是state_dict也就是权重字典不含网络结构恢复时必须用同一个类定义再加装参数。patience设为15意味着要容忍15轮没有进步后停手时间序列数据噪声大设太小容易误停。训练结束后想恢复最佳模型用model.load_state_dict(torch.load(best_model.pt))即可这是防止训练后期过拟合的兜底手段。4.3 把实验参数收拢成一个配置字典批量对照实验的做法调参阶段不要每次手改代码。我把所有可变参数收敛成一个字典每次实验只改dict里的值并记录这一次实验的配置和验证loss。相同数据集和网络结构下参数与效果的映射关系见下表。参数名常见范围对模型的影响input_steps12~72记忆窗口长短太小丢上下文太大增加计算量hidden_size32~128容量大小太小欠拟合太大过拟合且训练变慢num_layers1~3层数增加非线性表达能力2层是多数场景的折中batch_size16~64梯度稳定性越小越震荡越大越平稳但吃显存lr0.0005~0.005学习率大则收敛快但容易跳过最优点dropout0~0.3正则强度只在num_layers1时生效配置字典的好处举一个例子要对比input_steps12和24的差别直接复制一份config改值后跑完记录而不是在代码里改数字再重新启动整个Notebook。JupyterNotebook的单元格可以一次跑完整个实验列表把多个config放进一个列表循环执行最后打印出一张对比表。我一般还会把loss曲线用matplotlib画在同一张图上观察哪个配置收敛更快比单看表格数字更直观。画loss曲线时注意训练loss是按batch平均的验证loss是整段评估的两者数值量纲一致但统计口径不同横轴对齐到epoch再看趋势别把单步抖动误判成参数问题。5. 避坑与常见问题排查数据泄漏、序列顺序与张量形状多输入单输出LSTM这类项目的坑很少单独出现经常是数据泄漏和形状错误叠在一起排查效率最高的做法是按数据流顺序走一遍先确认划分和标准化没有泄漏再确认X和y形状匹配最后才看训练曲线。下面这五条是我在复现这类项目时最常遇到的问题。5.1 滑窗之后乱打乱数据顺序预测值“超前”的泄漏现象训练loss非常低验证集上的预测曲线和真实值几乎重合甚至看起来比真实值提前了若干个时间点明显是“预测了未来”。原因为了增加样本多样性对滑窗生成的X_train和y_train做了random shuffle或者直接用train_test_split随机切割而它的默认shuffleTrue。时间序列样本之间本身存在强相关性打乱后训练集里混入了测试时段的数据模型相当于已经见过答案。解决严格按时间顺序切分先用前80%行做训练、后20%行做测试再分别滑窗如果要shuffle只能在训练段内部做且用DataLoader的shuffleFalse。测试集永远不能参与任何形式的随机重排。验证预测图是否超前的最直接办法是画时间对齐图把预测曲线沿时间轴平移一位看是否仍然重合如果错位后更准就说明模型其实在用当前值猜当前值属于典型的泄漏特征。5.2 MinMaxScaler在全体数据上fit训练阶段偷看了未来现象测试集误差做得漂亮但把模型部署到新数据上后误差翻倍。原因scaler MinMaxScaler().fit(全体数据)scaler内部保存的min和max已经包含了测试段的极值。这跟上一节是同一类数据泄漏只是藏在预处理里更难发现。解决严格按前面的做法用训练段fit一套scaler_x和scaler_y测试段和新数据一律调用transform不再重新fit。还有一个配套习惯fit完后立刻把scaler_x、scaler_y连同模型权重一起保存预测新数据时用同一套参数做变换否则新数据的分布一变化标准化的结果也会漂移。检查已有项目是否踩了这个坑可以把scaler的data_min_和data_max_打印出来再对比训练段的真实极值不一致就说明fit范围铺到了测试段。5.3 输入形状报错LSTM的三个维度分别指什么现象运行model(X_batch)时报“RuntimeError: input must have 3 dimensions, got 2”或者维度对不上的错误。原因LSTM要求输入是三维而滑窗生成X之后容易丢掉最后一维。例如手滑写成X_train[:, :, 0]取单一特征X就变成两维或者忘记batch_firstTrue把[batch, seq_len, feature]当成正确形状实际它期望的是[seq_len, batch, feature]。解决报错时先打印X.shape和y.shape两个数再定位。X必须是[batch, input_steps, num_features]三个维度分别指样本数、时间步数、特征数。统一开启batch_firstTrue并保持X_train_t、X_test_t的构建方式一致不要一个用了torch.tensor、另一个用torch.from_numpy后没转dtypefloat64和float32混传也会触发隐式类型错误。用view函数改形状时要算清楚总元素数比如X是(1000, 24, 4)可以view(1000, 24, 4)或reshape成(24000, 4)但绝不能直接view(1000, 4)因为元素数对不上。5.4 验证损失比训练损失低得离谱样本相关性与划分比例现象验证集loss只有训练集loss的几分之一模型在测试上表现“过分好”。原因时间序列里相邻的验证样本与训练样本时间上很近目标值本身就有连续性模型即使泛化能力一般也会在这些相近样本上取得低误差。另一种情况是验证样本太少比如几万行数据只留了1%误差波动很大偶然落到了低点。解决把测试段拉长到至少20%并计算多种指标而不是只看MSE比如RMSE、MAPE和与“预测均值”基准的对比。如果模型误差低于均值基准才算真正学到了时序模式否则只是靠目标值本身平稳的特点混过去了。如果坚持要留出少量测试数据就用多段交叉验证的思路评估多次而不是相信一次划分的验证loss时间序列的交叉验证要注意按连续块切分不能随机折。5.5 num_layers1时dropout被静默忽略现象设置了dropout0.5训练曲线和loss没有任何变化怀疑dropout没生效。原因PyTorch的nn.LSTM参数里写得明确dropout只在num_layers1时才作用于各层之间。当num_layers1时传dropout等于白传框架不会给任何提示。解决要么把num_layers改成2以上要么删掉dropout改用其他正则手段比如在fc层之后手动加一个nn.Dropout。这里的经验是单层LSTM本身参数就不多过拟合风险低更值得先把hidden_size和学习率调对再考虑正则强度。检查时用model.eval()配合两次前向对比一次训练模式下dropout随机丢弃推理模式下不丢弃输出稳定不变说明它没起作用。6. 预测效果怎么验证才算合格反归一化、递归多步与随机种子模型输出的结果还是[0,1]区间内的值最后一步要把它还原成原始单位再和人比较。这里有一个反向坑直接对y_pred做inverse_transform时把形状扁平成了一维scaler_y会报特征数不匹配。稳妥写法是保持[y_batch, 1]的形状不变只对目标列做逆变换。如果不做这一步RMSE和MAPE计算出来的数值全都对不上原始数据的量级报告里的数字没意义。# 反归一化保持形状为 [batch, 1]一次性还原 y_pred_original scaler_y.inverse_transform(y_pred.cpu().numpy()) y_true_original scaler_y.inverse_transform(y_test_t.cpu().numpy())单步预测跑通后真实需求往往是预测未来多个小时。最常见的做法是递归多步预测把模型输出的预测值当成下一步的输入特征拼回去循环滚动预测。缺点也明显误差会逐步累积预测步数一长曲线就衰减成直线。更稳的办法是直接多步输出把fc层改成Linear(hidden_size, output_steps)训练时y就是未来多步的序列这个改动会让训练复杂一点但误差不会滚雪球。def recursive_predict(model, last_window, steps): preds [] model.eval() with torch.no_grad(): for _ in range(steps): p model(last_window) # [1, 1] preds.append(p.item()) # 把新预测拼回窗口末尾去掉窗口最前面一行 next_window torch.cat([last_window[:, 1:, :], p], dim1) last_window next_window return preds这段递归代码里p拼回的是目标列位置而输入特征的其他列如果也是未知的需要另外用已知值或另一套预测模型填充这一点在工程上比网络结构更影响效果。复现性是这类项目最后的一环。PyTorch的初始化带随机性同一份源代码每次训练出来的权重都可能不同。我一般在Notebook最前面的单元格固定三样东西random.seed(42)、np.random.seed(42)、torch.manual_seed(42)有GPU时再补一句torch.cuda.manual_seed_all(42)。这样至少能保证在相同环境下同一份代码和数据集能跑出可对比的结果。我的血泪经验是早期做LSTM预测时不固定种子调参前后对比的loss差了几个点我以为是参数生效了其实是随机性作祟白白浪费了两天时间。把随机种子和config一起写进Notebook的同一个单元格每次实验记录都存一份这是这个方向上最划算的投入。希望这些细节能帮你在多输入单输出LSTM的复现路上少走几个弯路。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑