
简介一款面向时间序列预测研究与毕业设计场景的完整源码包基于ETTh1数据集分别实现LSTM、Transformer以及自定义线性模型等多种预测方案。通过调整模型名称、序列长度等超参数即可切换不同架构进行对比实验适合计算机、人工智能等专业学生用于课程设计或初期项目演示。压缩包共包含39个文件其中34个Python脚本覆盖数据加载、模型构建、训练评估等核心模块另有shell启动脚本与Markdown项目说明整体仅64KB轻量易用。项目说明文档详细梳理了目录结构与运行方式工具脚本包含时间特征提取、评估指标计算、数据掩码处理等常用功能同时提供与主代码重复的备份文件目录便于独立模块调用与二次开发。目前已有1430人学习下载代码均经过测试运行成功兼具学习借鉴与实际落地价值尤其适合在基础代码上扩展自定义改进模型。1. 分别用 LSTM、Transformers 和自定义模型跑 ETTh1先想清楚你在对比什么ETTh1 数据集是电力变压器油温的公开时间序列数据小时级采样特征是 7 维连续值目标通常是下一段油温 OT。标题里的 LSTM、Transformers 和自定义模型不是并列的三个选项而是三种不同复杂度与可解释性的对比基准LSTM 代表循环结构Transformers 代表注意力结构自定义模型则用来验证一个问题——在 ETTh1 这种强周期、低维度数据上复杂模型到底有没有多拿几个点的收益。这篇笔记按“能跑通”的标准把数据预处理、三类模型的 PyTorch 实现、超参与踩坑一次讲清适合正在复现源码、做课程设计或想评估时序模型选型的从业者。所有代码都以最少依赖写成复制后改路径就能出图。2. ETTh1 数据集的字段与预处理先跑通一个 10 分钟能出图的基线2.1 ETTh1 在 CSV 里的真实长什么样7 列、每小时采样、注意 OT 列不是标签先把 ETTh1 的文件结构看清楚后面所有代码都依赖这个布局。ETTh1 的 CSV 一般是一个宽表第一列是时间戳后面跟着 7 个特征列最后一列是 OTOil Temperature油温。ETT 系列里带 h 的是小时级采样带 m 的是 15 分钟级采样ETTh1 之所以常被用来做长序列预测实验是因为它的周期性非常明显每天 24 点、每周 7 天的模式都很强模型很容易学出“低谷”和“高峰”的相对关系。列名含义在模型里的角色date采样时间戳参与排序与切分不输入模型HUFL高压负荷输入特征HULL高压负荷丢失量输入特征MUFL中压负荷输入特征MULL中压负荷丢失量输入特征LUFL低压负荷输入特征LULL低压负荷丢失量输入特征OT油温输入特征同时作为预测目标ETTh1 的预测目标通常不是“预测全部 7 列”而是“基于 7 列历史数据预测未来 N 步的 OT 列”。这一点在复现源码时要先确认因为你可能拿到一份把 OT 放在最后一列的数据也可能拿到一份把 OT 放在第二列的版本索引写错会让模型在训练时无意中把目标列当成普通特征最终指标虚高。2.2 按时间切分的 train/val/test 划分与归一化Scaler 只允许 fit 训练段时间序列预测和其他监督学习的最大区别是不能随机打乱数据再划分。ETTh1 的 7 个特征列都是同一根时间轴上的连续读数如果随机抽样验证集和测试集里会混入与训练集相邻的样本等于把“未来”泄露给模型。常见做法是按时间顺序切出 70% 训练、15% 验证、15% 测试代码里直接用手工切分不要用默认带 shuffle 的 train_test_split。import pandas as pd import numpy as np from sklearn.preprocessing import StandardScaler raw pd.read_csv(data/ETTh1.csv, parse_dates[date]) raw raw.sort_values(date).reset_index(dropTrue) data raw.drop(columns[date]).values.astype(np.float32) n len(data) train_end int(n * 0.70) val_end int(n * 0.85) scaler StandardScaler() scaler.fit(data[:train_end]) data_scaled scaler.transform(data) train_data data_scaled[:train_end] val_data data_scaled[train_end:val_end] test_data data_scaled[val_end:]这段代码有两个关键点。第一scaler.fit只作用在data[:train_end]即训练段上之后再用训练段的均值和标准差去 transform 全部数据。如果先对整份数据做 fit验证集和测试集的统计量已经在训练阶段被“看过”得到的误差会偏小这个坑在时序项目里经常让人误判模型效果。第二sort_values(date)强制按时间排序因为部分 ETTh1 版本的行顺序并非严格递增不排序直接切分会让训练集尾部混入早期数据。提示如果拿到的 CSV 里有缺失值先对每一列做线性插值再进模型。ETTh1 通常不缺数但改成自己的数据时这一步几乎必然遇到。3. 用 LSTM 复现 ETTh1 预测最小训练脚本与三个必调参数3.1 LSTM 网络结构选择隐藏层、层数、双向与否决定收敛速度LSTM 做 ETTh1 的思路很直接把 48 个历史时刻的 7 维特征逐时刻送入循环单元最后用最后一个隐状态去预测未来 96 步的 OT。比较省事的 PyTorch 实现是直接用nn.LSTM然后接一个小的线性头。常见做法是 hidden_size 取 32 到 64num_layers 取 1 到 2不建议一上来就把 hidden_size 调到 128因为 ETTh1 的样本量并不大隐层太宽会加速过拟合。import torch import torch.nn as nn class LSTMPredictor(nn.Module): def __init__(self, input_size7, hidden_size64, num_layers2, pred_len96): super().__init__() self.lstm nn.LSTM(input_sizeinput_size, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue, dropout0.1 if num_layers 1 else 0.0) self.head nn.Sequential( nn.Linear(hidden_size, 32), nn.GELU(), nn.Linear(32, pred_len) ) def forward(self, x): out, _ self.lstm(x) return self.head(out[:, -1, :])out[:, -1, :]取的是最后一个时间步的隐状态它的维度是 (batch, hidden_size)。这个向量经过两层线性映射直接输出 96 个未来时刻的 OT 预测值。GELU 激活放在中间层比 ReLU 在回归任务里更平顺不会频繁出现输出被截断到 0 的情况。dropout 只在层数大于 1 时开启默认值 0.1 就够了因为 LSTM 的隐状态本身已经有信息压缩能力dropout 过大会让训练损失收敛变慢。3.2 滑窗构造样本和 DataLoaderseq_len 与 pred_len 的对应关系ETTh1 的原始 CSV 是一根长序列要喂给 LSTM 必须先切成“历史窗口 未来窗口”的样本对。seq_len 一般取 48 或 96pred_len 取 96也就是说用过去两天或四天的小时数据预测未来四天的油温。滑窗步长可以取 1这样样本量足够大但样本之间会高度重叠后面避坑部分会专门讲这个问题。def make_windows(data, seq_len48, pred_len96): X, y [], [] for i in range(len(data) - seq_len - pred_len 1): x data[i : i seq_len] # (seq_len, 7) t data[i seq_len : i seq_len pred_len] y.append(t[:, -1]) # 只预测 OT 列 X.append(x) return np.stack(X).astype(np.float32), np.stack(y).astype(np.float32)循环的终止条件是len(data) - seq_len - pred_len 1保证每个样本的未来窗口都完整落在数据范围内。x的形状是 (seq_len, 7)y的形状是 (pred_len,)取t[:, -1]是因为目标固定在最后一列也就是 OT 列。这里要特别注意x的最后一个时刻和y的第一个时刻不能重合否则模型在预测第一步时等于直接看了当前值测试指标会异常好看。from torch.utils.data import TensorDataset, DataLoader X_train, y_train make_windows(train_data) X_val, y_val make_windows(val_data) train_dataset TensorDataset(torch.from_numpy(X_train), torch.from_numpy(y_train)) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue)shuffleTrue只打乱样本顺序不打乱每个窗口内部的时间顺序这一点不用担心。真正要留意的是 batch_sizeLSTM 反传路径长batch_size 太大容易让梯度方向互相抵消ETTh1 这种量级的数据建议从 32 起步显存紧张就降到 16。3.3 训练循环里的三个必调参数batch_size 小一点、梯度裁剪、收敛判据LSTM 在 ETTh1 上的训练循环并不复杂但有几个参数直接决定你是跑出正常下降曲线还是中途变成 NaN。第一个是梯度裁剪clip_grad_norm_几乎是必加项因为 96 步预测的反传路径比较长梯度范数很容易冲到很大。第二个是优化器常见做法是 Adam lr1e-3SGD 在这个任务上收敛太慢。第三个是 EarlyStoppingpatience 设 5 左右验证损失连续 5 轮不下降就保存最优权重退出。model LSTMPredictor(input_size7, hidden_size64, num_layers2, pred_len96) criterion nn.MSELoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3) for epoch in range(50): model.train() epoch_loss 0.0 for xb, yb in train_loader: optimizer.zero_grad() loss criterion(model(xb), yb) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() epoch_loss loss.item() * xb.size(0) train_loss epoch_loss / len(train_loader.dataset) val_loss evaluate(model, val_loader) print(fepoch {epoch:02d} train {train_loss:.6f} val {val_loss:.6f})evaluate函数就是把 val_loader 里的样本过一遍累计 MSE 但不反传梯度。EarlyStopping 我一般会手写而不是直接套 PyTorch Lightning因为手写逻辑更透明patience, best_val 5, float(inf) for epoch in range(50): # 上述训练代码 if val_loss 1e-4 best_val: best_val val_loss torch.save(model.state_dict(), best_lstm.pt) patience 5 else: patience - 1 if patience 0: break1e-4是一个很小的容差避免验证损失只下降了 0.00001 就重置 patience。如果训练过程中 loss 突然变成 NaN优先检查两处学习率是否太高以及是否漏了梯度裁剪。4. 用 Transformer 在 ETTh1 上做主实验先按 Encoder-only 与中等维度起步4.1 输入嵌入与位置编码标量嵌入 可学习位置编码在小数据上更稳Transformer 处理 ETTh1 和 NLP 里的用法不太一样。输入不是离散 token而是 7 个连续数值所以不需要词嵌入直接用nn.Linear(7, d_model)把每一时刻的 7 维向量映射到模型维度。位置编码这里有一个值得注意的选择ETTh1 的小时级数据有很强的 24 小时周期预定义的正弦位置编码能工作但可学习位置编码在 ETTh1 这种样本量不大的场景下往往更省事因为它能根据数据自动调整位置向量的分布。class TransformerPredictor(nn.Module): def __init__(self, input_size7, d_model128, nhead8, num_layers2, pred_len96, dropout0.1): super().__init__() self.embed nn.Linear(input_size, d_model) self.pos nn.Parameter(torch.randn(1, 256, d_model) * 0.02) encoder_layer nn.TransformerEncoderLayer( d_modeld_model, nheadnhead, dim_feedforward256, batch_firstTrue, dropoutdropout) self.encoder nn.TransformerEncoder(encoder_layer, num_layersnum_layers) self.head nn.Linear(d_model, pred_len) def forward(self, x): B, T, C x.shape x self.embed(x) self.pos[:, :T, :] x self.encoder(x) return self.head(x.mean(dim1))self.pos[:, :T, :]会在序列长度变化时自动截取前 T 个位置向量所以训练时用 96 步、推理时用 48 步也不会报维度错误。位置向量乘了 0.02这是经验做法防止随机初始化的位置编码在训练初期直接盖过输入特征。mean pooling 取代“取最后一步”是为了绕开 Transformer 的一个特性自注意力本身没有位置偏置最后一个 token 不包含“序列结束”的语义直接取最后一步做回归头在 ETTh1 上经常出现相位偏移。4.2 Transformer 的训练参数lr 要比 LSTM 低一个数量级用 AdamWTransformer 在 ETTh1 上的训练和 LSTM 完全不是一个手感。Adam 默认的 1e-3 在这个模型上大概率会让 loss 剧烈震荡常见做法是 AdamW lr1e-4再加一个很小的 weight_decay。维度上 d_model 建议 128 而不是 64因为 nhead8 时64 维分到每个注意力头只有 8 维表达能力太差128 维分给 8 个头每头 16 维才勉强够用。optimizer torch.optim.AdamW(model.parameters(), lr1e-4, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max30)CosineAnnealing 在整个训练周期内把学习率从 1e-4 平滑降到接近 0比固定学习率更容易在训练后期把损失压下去。如果训练到第 5 轮就出现过拟合先把 num_layers 从 3 降到 2而不是急着加 dropout因为 ETTh1 只有一年的小时级数据注意力层叠多了参数涨得很快。weight_decay1e-4 对自注意力层的权重能起到约束作用但不要加到 bias 和 LayerNorm 上否则收敛会变慢。提示如果你的训练集是 ETTh1 的前 70%epoch 数一般不超过 30。Transformer 比 LSTM 收敛快跑太久只会记住训练段噪声。4.3 注意力输出与预测头的拼接pred_len 不能直接塞进全连接Encoder-only Transformer 的输出形状是 (batch, seq_len, d_model)要把这个三维张量变成 (batch, pred_len) 的预测值中间一定要有一次池化或展平。直接out[:, -1, :]接全连接不是不行但在强周期序列上效果不如平均池化。原因是 Transformer 每层的自注意力都在做“全局信息聚合”最后一个时间步未必包含全局趋势平均池化可以强迫模型把整段历史的信息压缩到一个向量里。x self.encoder(x) # (batch, seq_len, d_model) x x.mean(dim1) # (batch, d_model) return self.head(x) # (batch, pred_len)如果想让 Transformer 更强也可以在这之后再拼一个小的两层 MLP第一层降到 64第二层输出 pred_len。但 ETTh1 的特征维度只有 7目标序列又是强周期信号线性 head 已经足够再加深预测头很容易把训练损失压到很低、验证损失却在上升。真正需要加复杂度的地方在 Encoder 部分而不是预测头。5. 自定义模型与模型对比避坑省事路线和五条血泪经验5.1 自定义模型的推荐起点Patch Linear“自定义模型”在源码包里往往不是魔改 Transformer而是自己组一个轻量级网络作为最低基准用来回答“传统结构到底比复杂结构差多少”。ETTh1 这种低维度强周期数据第一推荐的是 Patch Linear把 96 个时间步按每 8 步切成一个 patch每个 patch 里的 56 个数值8 步 × 7 特征拍平后经过一个共享线性层再接预测头。class PatchLinear(nn.Module): def __init__(self, seq_len96, pred_len96, feature_dim7, patch_size8): super().__init__() n_patches seq_len // patch_size self.embed nn.Linear(patch_size * feature_dim, 64) self.head nn.Linear(64 * n_patches, pred_len) def forward(self, x): B, T, C x.shape x x.reshape(B, T // patch_size, patch_size * C) x self.embed(x).relu() return self.head(x.flatten(1))reshape把 (batch, 96, 7) 变成 (batch, 12, 56)每个 patch 内的时间信息和特征信息被压缩到同一个向量里。共享的embed层让每个 patch 使用相同的特征提取逻辑等于给模型灌入“局部时序模式一致”的先验。这个模型的参数量比 LSTM 和 Transformer 小一大截训练速度也快很多很适合作第一版基线。5.2 另一个自定义选项两卷积加全局池化如果你希望模型能天然捕捉局部时序趋势可以选择两条 Conv1d 加全局平均池化的结构。Conv1d 的核在时间维上滑动kernel_size3 和 5 分别覆盖 3 小时和 5 小时的局部窗口对 ETTh1 的短期波动很敏感。class ConvBaseline(nn.Module): def __init__(self, input_size7, pred_len96): super().__init__() self.conv nn.Sequential( nn.Conv1d(input_size, 32, kernel_size3, padding1), nn.ReLU(), nn.Conv1d(32, 32, kernel_size5, padding2), nn.ReLU(), ) self.head nn.Linear(32, pred_len) def forward(self, x): x x.transpose(1, 2) # (batch, feature, time) x self.conv(x).mean(dim-1) # 对时间维做全局平均池化 return self.head(x)transpose(1, 2)很关键因为 Conv1d 期望输入是 (batch, channels, length)而序列模型习惯是 (batch, length, features)。padding1 和 padding2 分别让输出长度保持和输入一致全局平均池化再把不定的时间长度压成固定 32 维。相比 Patch LinearConvBaseline 对局部波形的拟合更细腻但更容易把训练集的尖峰噪声也学进去。5.3 三模型超参速查与对比方法下面的表是这一套实验的常见起步参数实际跑的时候以你自己的验证集为准。对比时不要只记录最后一次 loss每个模型同一组随机种子跑三次取均值才能看出真正的差距。模型结构要点优化器与 lrbatch_size建议 epoch参数量级LSTMhidden64, layer2Adam, 1e-33250中等Transformerd_model128, layer2AdamW, 1e-46430最大PatchLinearpatch8, embed64Adam, 1e-36430最小ConvBaselineconv3conv5, 32 通道Adam, 1e-36430较小5.4 五个现象级踩坑记录每条都是真金白银换来的踩坑一验证集误差低到不真实。现象是模型还没训练几轮val_loss 就比 train_loss 还低。原因几乎都是归一化泄露scaler 对整个数据先 fit 再切分或者验证集在划分前参与过统计量计算。解决方法是严格先切分、后 fit并且把训练段 fit 的 scaler 保存下来测试阶段只做 transform重新 fit 一次都是错的。踩坑二验证集和训练集之间互相重叠。现象是训练曲线和验证曲线走势完全一致模型看起来什么数据都拟合得很好。原因是滑窗步长为 1 时训练集最后一个窗口和验证集第一个窗口只隔了几行验证集实际上包含了训练集末尾的大部分信息。解决方法是切分时在 train 和 val 之间留出至少 pred_len 个点的空白间隔或者把验证集的起点再向后推一段。踩坑三Transformer 的 loss 中途冲高后无法恢复。现象是前几轮正常下降第 4、5 轮突然跳到初始值的两倍。原因是学习率太高且没有 warmup。解决方法是把 lr 降到 1e-4用 AdamW并对前 3 到 5 轮做线性 warmup或者直接用 CosineAnnealing 从头衰减。ETTh1 上的 Transformer 对学习率极其敏感这是我复现时翻车次数最多的一个点。踩坑四LSTM 训练到一半 loss 变成 NaN。现象是前 20 轮正常第 21 轮开始 loss 变 nan之后永远回不来。原因是梯度在 96 步反传路径上累积爆炸。解决方法是加clip_grad_norm_max_norm 取 1.0同时把 num_layers 从 2 降到 1 试跑往往能立刻定位是不是层数过深导致的问题。踩坑五固定随机种子仍然复现不出结果。现象是同一份代码在 GPU 上每次跑测试集 RMSE 都不同差出 0.01 到 0.02。原因是 GPU 上的 cudnn benchmark 和原子操作有随机性光设torch.manual_seed不够。解决方法是加torch.backends.cudnn.deterministic True然后每个模型固定 seed 跑三次报告“均值 ± 方差”而不是只挑最好的一次写进实验结论。6. 把 ETTh1 实验改造成你自己的数据替换 CSV 的四个改动点与验证习惯标题里的源码包在 ETTh1 上跑通之后一般下一个需求就是迁移到自己的时间序列数据。ETTh1 的代码结构其实很通用日期列做排序、7 列特征做归一化、最后一列做目标。你自己的 CSV 只要保证列顺序一致大部分代码可以原封不动。你的 CSV 列对应 ETTh1 角色需要改的位置第一列时间戳date数据加载与排序逻辑中间若干列特征HUFL/HULL/MUFL 等去掉 date 列后自动成为输入target 列OT必须放在最后一列且滑动窗口取[:, -1]缺失值无加一行 interpolateimport pandas as pd import numpy as np df pd.read_csv(your_data.csv) df df.sort_values(df.columns[0]).reset_index(dropTrue) feature_cols [c for c in df.columns if c not in (df.columns[0], target)] values df[feature_cols [target]].values.astype(np.float32)这段模板假设第一列是时间戳、目标列叫 target。如果你的目标列不叫这个名字直接把target替换成真实列名。拿到自采数据后第一件事是画出原始曲线看看有没有异常尖峰和长时间空缺很多看起来是模型问题的情况其实是数据源本身有传感器断数或者单位不一致。验证多步预测效果时不要只看最终 RMSE。我一般会把真实 OT 曲线和预测曲线画在同一张图上重点观察最后 20 步是否出现相位偏移。ETTh1 这种强周期数据前 50 步可能拟合得很好后 46 步如果整体向右偏了一个小时MSE 也会很难看但曲线图能一眼看出模型学的是周期还是学的是趋势。反归一化也要记得模型输出的是标准化后的 OT画图和计算业务指标前要scaler.inverse_transform把它还原成真实油温。如果忘了这一步RMSE 会小到诱人但那个数字完全没有业务含义。我现在拿到一份新的时序数据第一件事永远是先画一遍原始曲线确认数据质量再跑最轻量的 PatchLinear 基线最后才上 LSTM 和 Transformer。这个习惯帮我挡掉过很多次看起来是模型问题、其实是数据问题的翻车现场希望帮到你。本文还有配套的精品资源点击获取