简介:LSTM与Transformer融合的多特征时间序列预测模型,以PyTorch实现并提供完整源码与数据集,面向机器学习学习者和算法工程师,适用于风力发电功率、光伏发电量、设备剩余寿命、环境浓度跟踪等单变量回归预测场景。压缩包内共160个文件,整体约1.95MB,含31个Python源文件、87个pyc字节码、4个CSV示例数据文件,以及XML配置、zbak备份等辅助内容,目录结构清晰,便于定位主程序、数据与配置文件。已有72人学习下载,适合需替换自有数据开展实验或作为基线模型进行二次开发的读者。整个代码由具备多年机器学习算法开发经验的人员编写,关键逻辑配有注释,模型结合LSTM对局部特征的提取与Transformer对全局依赖的建模,在多维特征输入下表现出较好的预测精度与稳定性;数据集和示例输出文件一并附上,可支持论文复现与课设改造。
1. 多特征时间序列预测:为什么纯LSTM不够,融合模型才是长序列的解法
做时间序列预测的人,大概率经历过这个阶段:先用LSTM,效果不错;序列变长、特征变多之后,LSTM的预测精度开始下滑,尤其是预测曲线后段明显滞后于真实值。这时候换Transformer,发现它对数值型多特征时序的局部模式捕捉又不够细,收敛还慢。基于PyTorch的LSTM与Transformer融合模型,就是为了同时拿到LSTM对时序局部依赖的敏感度和Transformer对长程依赖的建模能力,专攻多特征时间序列预测这类场景。这份资源附了完整源码和一份可直接用的CSV数据集,训练、验证、预测全流程能跑通。适合两类人:一是时序预测项目的开发者,想找一个比单模型更稳的基线;二是正在学PyTorch的从业者,想读一份结构清晰、能改参数就能用的融合模型代码。
2. 数据预处理与数据集构建:滑窗参数、归一化陷阱与张量形状
2.1 多特征时序的数据形态与滑窗构造
多特征时间序列预测,输入不再是单列数值,而是一个三维张量:(batch_size, seq_len, feature_num)。seq_len是回看窗口长度,feature_num是每个时间步的特征数。模型的任务是给定过去seq_len个时间步的全部特征,预测未来一个或多个时间步的目标值。资源附带的数据集是CSV格式,每一行是一个时间步,列包括时间戳、多个特征列和一个目标列。读取之后第一步不是直接进模型,而是用滑窗把它切成样本。
import numpy as np import pandas as pd def create_sequences(data, feature_cols, target_col, seq_len=24, pred_len=1, stride=1): X, y = [], [] for i in range(0, len(data) - seq_len - pred_len + 1, stride): X.append(data[feature_cols].iloc[i:i+seq_len].values) y.append(data[target_col].iloc[i+seq_len:i+seq_len+pred_len].values) return np.array(X), np.array(y)这段代码做的事情是:从第i行开始,取seq_len行所有特征列作为输入X,再取紧接着的pred_len行目标列作为输出y。stride控制窗口滑动的步长,默认为1,表示每个时间步都生成一个样本。seq_len取值很关键,如果数据是小时级且带有明显的日周期,seq_len=24是起步值;如果数据带周周期,seq_len=168(24×7)通常更合理。pred_len先设为1,把单步预测跑通再改多步。
2.2 归一化必须只在训练集上拟合
多特征时序里各个特征的量纲差异很大,比如温度在0到40之间,风速在0到15之间,设备负荷可能在几百到几千之间。不归一化直接进模型,LSTM的遗忘门和输入门会被大数值特征主导,Transformer的self-attention算出来的注意力权重也会失真。归一化方式推荐StandardScaler或MinMaxScaler,这里建议用StandardScaler,因为多特征数据里常出现离群点,MinMaxScaler会被极端值压缩正常值的分布区间。
from sklearn.preprocessing import StandardScaler # 按时间顺序切分,前70%训练,后30%验证 split_idx = int(len(df) * 0.7) train_df = df.iloc[:split_idx] val_df = df.iloc[split_idx:] # 关键一步:scaler只在训练集上fit scaler = StandardScaler() train_scaled = scaler.fit_transform(train_df[feature_cols]) val_scaled = scaler.transform(val_df[feature_cols])这里最容易被忽视的是顺序。scaler.fit_transform(train_df)拿到的是训练集的均值和标准差,再用这个均值标准差去transform(val_df)。如果对整个数据集先fit再切分,测试集中的统计信息会泄漏到训练过程中,验证指标会虚高,上线后真实效果直接打对折。这是时序任务里最常见的翻车点之一。切分数据时只能按时间顺序切,不能像分类任务那样随机打乱,因为时序样本之间存在时间依赖,打乱后训练集里会出现未来数据,模型等于开了天眼。
2.3 构造PyTorch Dataset与DataLoader
数据准备好之后需要封装成Dataset。这里要注意X和y的形状:X是(num_samples, seq_len, feature_num),y是(num_samples, pred_len)。DataLoader的batch_size和shuffle参数,时序任务里训练集可以shuffle=True,因为每个样本已经是一个独立的时间窗口,样本间不再有直接的前后依赖;验证集必须shuffle=False,否则评估曲线无法按时间顺序对齐。
import torch from torch.utils.data import Dataset, DataLoader class TimeSeriesDataset(Dataset): def __init__(self, X, y): self.X = torch.tensor(X, dtype=torch.float32) self.y = torch.tensor(y, dtype=torch.float32) def __len__(self): return len(self.X) def __getitem__(self, idx): return self.X[idx], self.y[idx] train_dataset = TimeSeriesDataset(X_train, y_train) val_dataset = TimeSeriesDataset(X_val, y_val) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False)shuffle=True在训练集上还有一个额外的好处:打破相邻窗口的相似性,让每个batch的数据分布更均匀,训练收敛更稳定。但注意不要设置drop_last=True,除非你确定最后一个batch的尺寸不会导致模型崩溃。实际使用中,batch_size从64起步,显存紧张时降到32,验证集没必要用完整数据集一次跑完,分batch推理更稳。
3. 融合模型PyTorch实现:LSTM块、Transformer编码器与特征融合层
3.1 融合架构设计:并行融合更稳
LSTM和Transformer融合,主流做法有两种:串行和并行。串行是把LSTM的输出当作Transformer的输入,或者反过来,两个模型串联。这种做法的弊端是第一级模型的信息瓶颈会限制第二级模型的输入质量。并行融合是让LSTM和Transformer各处理一份输入,再把两路的特征拼接起来送入回归头。资源源码里用的是并行融合,理由很直接:LSTM和Transformer关注的是序列的不同维度,LSTM擅长捕捉近邻时间步的连续变化趋势,Transformer的self-attention能直接建模任意两个时间步之间的依赖。并行让两条支路互不干扰,融合层再决定各自贡献多少。
模型参数设计如下:d_model=64,nhead=4,num_layers=2,dropout=0.1。d_model是Transformer隐层维度,理论上越大表达力越强,但对时序预测来说64已经够用,再大容易过拟合。nhead=4意味着每个注意力头负责64/4=16维的子空间,多了头数并不能带来质的提升,反而增加计算量。num_layers这里指的是TransformerEncoderLayer的堆叠层数,2层是性价比最高的选择,1层欠拟合,3层以上在小数据集上很容易过拟合。
3.2 LSTM支路的PyTorch实现
class LSTMBranch(nn.Module): def __init__(self, input_size, hidden_size=64, num_layers=2, dropout=0.1): super().__init__() self.lstm = nn.LSTM( input_size=input_size, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, dropout=dropout, bidirectional=False ) def forward(self, x): # x: (batch, seq_len, input_size) output, (h_n, c_n) = self.lstm(x) # 取最后一层的最后时间步输出 last_hidden = h_n[-1] # (batch, hidden_size) return last_hiddennn.LSTM的参数里batch_first=True表示输入形状是(batch, seq_len, feature),不设这个参数默认是(seq_len, batch, feature),新手经常在这里排错。bidirectional=False,双向LSTM确实能多看一遍反向信息,但对时间序列预测来说,预测时只能依赖过去,双向会引入未来信息,属于数据泄漏。h_n的形状是(num_layers, batch, hidden_size),取h_n[-1]就是最后一层的隐状态,这个向量包含了LSTM对整个序列的压缩表示。丢掉output序列所有时间步的输出,只用最后隐状态,是因为后续融合层只需要一个向量级的序列表示。
3.3 Transformer支路与位置编码
Transformer支路处理同一份输入,但需要额外加位置编码。self-attention本身是位置无关的,它对序列中所有时间步的计算是对称的,如果不加位置编码,模型分不清第1个时间步和第24个时间步在顺序上的区别。对时序预测来说,顺序信息极其关键——第23步和第24步的关系跟第1步和第24步的关系完全不同。这里用sinusoidal位置编码,也就是Transformer原论文里的那种:
class TransformerBranch(nn.Module): def __init__(self, d_model=64, nhead=4, num_layers=2, dropout=0.1, seq_len=24): super().__init__() self.d_model = d_model # 输入特征先线性投影到d_model维度 self.input_proj = nn.Linear(input_size, d_model) # sinusoidal位置编码,不需要学习参数 pe = torch.zeros(seq_len, d_model) position = torch.arange(0, seq_len).unsqueeze(1).float() div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-torch.log(torch.tensor(10000.0)) / d_model)) pe[:, 0::2] = torch.sin(position * div_term) pe[:, 1::2] = torch.cos(position * div_term) self.register_buffer('pe', pe.unsqueeze(0)) self.encoder_layer = nn.TransformerEncoderLayer( d_model=d_model, nhead=nhead, dim_feedforward=128, dropout=dropout, batch_first=True ) self.encoder = nn.TransformerEncoder(self.encoder_layer, num_layers=num_layers) def forward(self, x): batch, seq_len, _ = x.shape x = self.input_proj(x) # (batch, seq_len, d_model) x = x + self.pe[:, :seq_len, :] # 加入位置编码 x = self.encoder(x) # (batch, seq_len, d_model) # 取序列维度上的均值池化 return x.mean(dim=1) # (batch, d_model)input_proj的作用是把feature_num维输入映射到d_model=64维,因为Transformer编码器内部的计算维度必须是d_model。位置编码用register_buffer注册,不会在反向传播时更新梯度。dim_feedforward=128是前馈网络隐层维度,一般设为d_model的2倍。均值池化比取最后时间步更稳,因为Transformer每一层的输出已经是全局交互后的结果,均值能综合所有时间步的信息,而最后时间步可能丢失早期的重要模式。
3.4 融合层与完整模型
class LSTMTFusionModel(nn.Module): def __init__(self, input_size, seq_len, d_model=64, hidden_size=64, num_layers=2, nhead=4, dropout=0.1, pred_len=1): super().__init__() self.lstm_branch = LSTMBranch(input_size=input_size, hidden_size=hidden_size, num_layers=num_layers, dropout=dropout) self.transformer_branch = TransformerBranch(input_size=input_size, d_model=d_model, nhead=nhead, num_layers=num_layers, dropout=dropout, seq_len=seq_len) fusion_dim = hidden_size + d_model self.fusion = nn.Sequential( nn.Linear(fusion_dim, 32), nn.ReLU(), nn.Dropout(dropout), nn.Linear(32, pred_len) ) def forward(self, x): lstm_out = self.lstm_branch(x) transformer_out = self.transformer_branch(x) fused = torch.cat([lstm_out, transformer_out], dim=-1) return self.fusion(fused)pred_len=1时输出一个单值,pred_len改成多步时输出多个未来时间步的预测值。融合层的维度是hidden_size + d_model,这里两个分支都用64,融合后128维,先降到32维再输出,中间加ReLU和Dropout是为了防止融合层直接过拟合。实际跑的时候要注意:如果LSTM的hidden_size和Transformer的d_model不同,torch.cat维度不匹配会报错,这是融合模型最常见的维度错误。引资源源码的模型默认两者一致,改参数时要同步修改。
4. 训练策略与超参数调优:损失函数、学习率调度与早停
4.1 损失函数选型:MSE还是Huber
时序预测的默认损失是MSE,但它在预测值和真实值相差较大时梯度过大,训练初期容易震荡。HuberLoss是对MSE和MAE的折中:误差小于delta时用MSE的平方损失,误差大于delta时退化为MAE的线性损失。多特征时序数据里偶尔会有异常点,Huber对这类点更鲁棒。delta通常取1.0,误差在1以内按平方放大梯度,超过1按线性缩小梯度。资源源码里默认用HuberLoss,跑出来的收敛曲线比MSE更平滑,验证集的指标也更稳定。
criterion = nn.HuberLoss(delta=1.0)用MSE的话,模型会把更多注意力放在少数大误差样本上,这在负荷预测或设备寿命预测中意味着极端工况主导了训练方向。Huber损失相当于告诉模型:正常误差精细优化,异常误差别太较劲。如果你的预测目标本身没有明显异常点,用MSE也完全可以,但从时序数据的常态来看,Huber的保险系数更高。
4.2 优化器与学习率调度
优化器建议用AdamW而不是Adam。AdamW把权重衰减从梯度更新中解耦,能更干净地控制模型复杂度,对Transformer这类带LayerNorm的结构尤其友好。学习率初始值1e-3到3e-4,我一般先跑5个epoch看损失变化:如果损失爆炸或震荡,降一个量级;如果收敛缓慢,升到1e-3。配合CosineAnnealingLR调度器比固定学习率效果好,它的思路是让学习率从初始值按余弦曲线逐步衰减到最小值,前期快速下降逼近最优区域,后期精细收敛。
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=1e-5) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50, eta_min=1e-6)T_max是半个周期内的epoch数,这里设为50,意味着前50个epoch学习率从3e-4降到1e-6。如果你的训练总量是100个epoch,刚好一个完整余弦周期。eta_min是学习率下限,设1e-6是为了避免后期完全停止更新。实践中如果发现50个epoch后验证指标还在下降,可以继续训练但学习率已经压得很低,收益不大,不如调整模型结构或数据预处理。
4.3 训练循环与早停机制
def train_epoch(model, train_loader, criterion, optimizer, device): model.train() total_loss = 0 for X_batch, y_batch in train_loader: X_batch, y_batch = X_batch.to(device), y_batch.to(device) optimizer.zero_grad() pred = model(X_batch) loss = criterion(pred, y_batch) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() total_loss += loss.item() * X_batch.size(0) return total_loss / len(train_loader.dataset)clip_grad_norm_是LSTM+Transformer组合里必须有的操作。LSTM在长序列上容易梯度爆炸,Transformer叠加多层后梯度范数也不小,把梯度范数裁剪到1.0,训练稳定性会好很多。验证集上的评估逻辑类似,只是不加梯度更新:
def validate(model, val_loader, criterion, device): model.eval() total_loss = 0 with torch.no_grad(): for X_batch, y_batch in val_loader: X_batch, y_batch = X_batch.to(device), y_batch.to(device) pred = model(X_batch) loss = criterion(pred, y_batch) total_loss += loss.item() * X_batch.size(0) return total_loss / len(val_loader.dataset)早停的判断标准:连续patience=10个epoch验证损失不下降,就停止训练并回滚到验证损失最低的那个epoch权重。这个策略能有效防止过拟合,尤其是融合模型参数量不算小,在小数据集上很容易在后期过拟合。每个epoch结束对比当前验证损失和最佳损失,如果更小就保存模型权重;累计10次没有突破就终止。
4.4 评估指标的选择
验证损失是模型内部的优化目标,对外汇报时要用业务可解释的指标。推荐三个:MAE平均绝对误差、RMSE均方根误差、MAPE平均绝对百分比误差。MAPE计算时如果真实值里有0或接近0的值,百分比会爆炸,需要过滤。比如工业负荷预测里凌晨负荷接近0,MAPE算出来几千个百分点,没有参考价值。此时改用sMAPE或直接看MAE更实在。一个习惯是训练过程中把这三个指标一起打印,如果RMSE远大于MAE,说明存在少数大误差样本,模型对极端值的拟合还不够好。
5. 避坑与常见问题排查:五个让新手翻车的细节
5.1 归一化泄漏:验证指标好看,上线必翻车
现象:训练集和验证集分开划分了,但归一化时偷懒对整个数据集一次性fit_transform,验证损失看着很低,但在新数据上预测结果完全不对,偏差很大。原因:验证集或测试集的均值方差提前参与了训练,模型在验证时已经见过同分布信息,这在时间序列预测里等于作弊。解决:严格按时间顺序切出训练集,只用训练集fit平均值和标准差,验证集和测试集只做transform,这一步没有妥协余地。
5.2 验证集切分时打乱了时间顺序
现象:为了平衡样本分布,按随机方式把数据切分成训练集和验证集,训练损失降得很好,验证损失也正常,但预测曲线和真实值对不上,整体滞后。原因:时间序列样本之间不是独立同分布的,随机切分把未来数据泄漏进了训练集,模型学到了提前“预知”的能力。解决:按时间顺序切分,训练集永远是前70%或前80%,验证集用最后一段。如果要用K折交叉验证,也要按时间顺序分折,不能随机分。
5.3 Transformer位置编码和序列长度不匹配
现象:训练时seq_len=24一切正常,推理时换了一组更长的数据,模型直接报维度错误:The size of tensor a (48) must match the size of tensor b (24)。原因:位置编码矩阵在初始化时写死了seq_len,输入更长时位置编码不够用。解决:位置编码矩阵用register_buffer注册后,forward里改成语义切片pe[:, :seq_len, :],这样训练和推理时的序列长度可以不一样。如果序列长度变化幅度很大,建议直接改成可学习位置编码,让模型自己适应不同长度。
5.4 显存占用大、训练慢
现象:seq_len=24时训练速度尚可,改成seq_len=168之后显存直接翻了近十倍,训练时间从分钟级变成小时级。原因:Transformer的self-attention复杂度是O(seq_len^2),序列长度翻7倍,计算量和显存占用翻49倍。LSTM虽然只跟序列长度线性相关,但融合模型在Transformer支路上仍然会放大。解决:如果确实需要长序列,先考虑缩小nhead或d_model,或者把长序列切成长度较短的多个窗口做分层建模。还有一个思路是只让LSTM支路吃长序列,Transformer支路吃下采样的短序列,两路长度不用一致,融合层的维度一致就行。
5.5 随机种子未固定,结果跑飞
现象:同一份数据和同一个模型,每次训练出来的验证损失都不一样,有时差5%,有时差20%。原因:PyTorch的权重初始化、DataLoader的shuffle、Dropout都引入了随机性,不固定种子导致结果不可复现。解决:训练脚本开头统一固定三个随机源:
import random import numpy as np import torch random.seed(42) np.random.seed(42) torch.manual_seed(42) torch.cuda.manual_seed_all(42)注意DataLoader设置worker_init_fn才能让多进程数据加载也使用固定种子,否则每次迭代数据顺序还是随机的。固定种子之后,同一份代码在不同机器上跑出的结果也会有一些细微偏差,这是GPU浮点运算的并行差异导致的,在可接受范围内。
6. 模型验证与多步预测:可视化检验和参数量的估算
模型训练完,第一步不是看指标,而是把验证集的预测曲线和真实曲线画在一起,用眼睛看趋势是否对齐。指标数值只能反映误差大小,看不出滞后和幅值偏差。滞后通常表现为预测曲线比真实曲线晚一个时间步,原因多半是归一化泄漏或LSTM的隐状态没有充分捕捉趋势。幅值偏低则可能是HuberLoss的delta设置过小,模型对大误差不够敏感。
多步预测有两种实现方式。简单的是直接多步——把pred_len设成目标步数,模型一次输出多个值。另一种是递归预测——先预测下一个时间步,把预测值当作输入再预测下一步,缺点是大误差会逐点累积。两个方式都试一下,多数时候直接多步更稳,因为模型在训练时见过完整的未来窗口,不会产生递归累积漂移。
模型参数量和感受野可以用下面这段代码快速估算:
total_params = sum(p.numel() for p in model.parameters()) print(f"Total parameters: {total_params}")运行后会看到融合模型的参数量通常在10万到30万之间。LSTM支路的参数主要来自四个门的权重矩阵,Transformer支路的参数集中在自注意力和前馈网络。如果参数量超过50万,而训练数据只有几千条,大概率过拟合;反之参数量在10万以下,训练数据够多但验证损失不降,说明模型容量不足。
做多步预测时还有一个验证技巧:把预测起点依次平移,对比每条预测曲线的衰减情况。如果预测步数越远误差越大,这是正常的;但如果第2步的预测误差就明显劣于第1步好几倍,说明模型学到的不是趋势而是最近邻的值,需要增大seq_len或加深Transformer层数。
从那以后我每次训练融合模型,都会强制走一遍:固定随机种子、检查归一化是否只fit训练集、验证预测曲线是否滞后、估算参数量是否合理。这套流程帮我挡住了至少一半的无效训练,希望帮到你。
本文还有配套的精品资源,点击获取