ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

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

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

简介:面向需要掌握序列预测建模的 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_steps=1,就是单输出的含义。

数据整理的核心是把原始二维表(行是时间、列是特征)转成三维样本。滑窗是主流做法:从时间轴起点出发,每次取连续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和y,X是[batch, input_steps, num_features],y是[batch, 1]。

import numpy as np def create_sequences(data, input_steps=24, output_steps=1): """ 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_steps=24, output_steps=1) 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实现多输入单输出LSTM:Python源码拆解与训练循环

3.1 为什么选PyTorch而不是Keras:形状可见性和调试体验

同样一个多输入单输出LSTM,Keras里可以用Sequential API几行写完,但隐藏层状态、张量形状、梯度流动都成了黑匣子,遇到形状不匹配的报错只能靠猜。PyTorch这边的好处是网络定义里每一步的形状变化都写在forward里,input和output的维度可以随时看。对于要做实验、要换特征、要调参的这类项目,PyTorch改代码成本更低,社区里源码也大多以PyTorch形式出现,遇到问题能找到的踩坑记录更多。

自建类时有一个前置知识需要确认:torch.nn.LSTM的默认布局是[seq_len, batch, num_features],即batch在中间。我在工程里一般都会打开batch_first=True,让输入布局变成[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_features=4, hidden_size=64, num_layers=2, dropout=0.2): super().__init__() self.lstm = nn.LSTM( input_size=num_features, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, dropout=dropout ) 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_pred

forward里的两个细节决定输出形状。第一,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, dtype=torch.float32) y_train_t = torch.tensor(y_train, dtype=torch.float32).view(-1, 1) X_test_t = torch.tensor(X_test, dtype=torch.float32) y_test_t = torch.tensor(y_test, dtype=torch.float32).view(-1, 1) train_dataset = TensorDataset(X_train_t, y_train_t) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=False) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = MultiInputSingleOutputLSTM(num_features=4, hidden_size=64, num_layers=2).to(device) criterion = nn.MSELoss() optimizer = torch.optim.Adam(model.parameters(), lr=0.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(f"Epoch {epoch+1:3d}/{epochs}, Loss: {avg_loss:.6f}")

y_train_t的view(-1, 1)是为了把目标列从[batch]变成[batch, 1],和模型输出y_pred的形状保持一致,否则MSELoss在广播时会产生完全没意义的梯度。batch_size=32在大多数中等规模数据集上是稳妥起点,调大要小心显存,调小会让损失曲线剧烈震荡。shuffle=False在时间序列里更常见,窗口之间有重叠,打乱后相邻样本高度相似,有序喂给模型训练更平稳。criterion选MSELoss是因为预测值是连续数值,属于回归任务;如果做的是分类型的单输出,比如故障类别,损失函数要换成CrossEntropyLoss,那是另一个方向。

训练过程的几个附加项值得一并写进实验代码:torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)放在loss.backward()之后、optimizer.step()之前,能防止梯度爆炸;优化器里带上weight_decay=1e-5相当于L2正则,对抑制过拟合有实际帮助;学习率可以配合StepLR每30轮乘以0.1衰减。这三个不是必须,但多输入特征如果相关性高,梯度波动会比单输入明显,加上之后少很多反复试的弯路。

4. 在JupyterNotebook里跑通并调参:多输入单输出LSTM的必调参数

4.1 预测结果是一条直线:hidden_size与学习率的搭配陷阱

第一次把训练循环跑通,验证集上的预测曲线像一条平直线,这是LSTM预测项目里最常见也最劝退的现象。原因通常不是网络写错了,而是模型在偷懒:直接输出训练集目标列的均值,代价函数反而更低。输入特征和目标的相关性太弱,或者模型容量不足时,LSTM都会倾向于走这个捷径。

我之前就在一个用气象数据预测发电功率的项目上翻过车,hidden_size=16、lr=0.01跑了100轮,loss降得很慢且早早平了,预测曲线就是一条直线。把hidden_size提到64、学习率降到0.001之后,曲线才开始出现起伏。这两个参数本质上是搭配问题:hidden_size太小,模型学不到特征之间的交互;学习率太大,又在最优点附近来回震荡。一个可用的起点组合是hidden_size=64、num_layers=2、lr=0.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_norm=1.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(f"Early stopping at epoch {epoch+1}") break if (epoch + 1) % 20 == 0: print(f"Epoch {epoch+1}, 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_layers>1时生效

配置字典的好处举一个例子:要对比input_steps=12和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随机切割,而它的默认shuffle=True。时间序列样本之间本身存在强相关性,打乱后训练集里混入了测试时段的数据,模型相当于已经见过答案。

解决:严格按时间顺序切分,先用前80%行做训练、后20%行做测试,再分别滑窗;如果要shuffle,只能在训练段内部做,且用DataLoader的shuffle=False。测试集永远不能参与任何形式的随机重排。验证预测图是否超前的最直接办法是画时间对齐图,把预测曲线沿时间轴平移一位,看是否仍然重合;如果错位后更准,就说明模型其实在用当前值猜当前值,属于典型的泄漏特征。

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_first=True,把[batch, seq_len, feature]当成正确形状,实际它期望的是[seq_len, batch, feature]。

解决:报错时先打印X.shape和y.shape两个数再定位。X必须是[batch, input_steps, num_features],三个维度分别指样本数、时间步数、特征数。统一开启batch_first=True,并保持X_train_t、X_test_t的构建方式一致,不要一个用了torch.tensor、另一个用torch.from_numpy后没转dtype,float64和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_layers=1时dropout被静默忽略

现象:设置了dropout=0.5,训练曲线和loss没有任何变化,怀疑dropout没生效。

原因:PyTorch的nn.LSTM参数里写得明确,dropout只在num_layers>1时才作用于各层之间。当num_layers=1时传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], dim=1) 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的复现路上少走几个弯路。

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

返回列表