简介:面向电力市场预测与时间序列分析学习者,这份资源基于PyTorch和LSTM构建丹麦电力市场未来24小时电价预测模型,覆盖数据预处理、历史特征构建、模型训练与评估全流程。压缩包共23个文件,约395KB,包括6个Python脚本(核心模型、主程序与数据处理)、4个CSV数据集(丹麦2023年真实电价)、5个XML工程配置,以及模型权重和预测结果可视化图片等。目前已有1032人浏览学习。读者可获取完整项目代码与真实电价数据,借助LSTM和seq2seq结构实现电价时序预测,并通过损失曲线、特征对比图直观理解模型效果,适合有一定Python基础、希望上手深度学习时序预测的开发者。1. 丹麦电力电价预测:一份把 PyTorch + LSTM 落到 24 小时滚动预测上的完整资源做电价预测的人最头疼的事情,不是模型选型,而是数据特征怎么揉进时间序列里。这份关于丹麦电力电价预测的资源,目标非常明确:用 PyTorch 搭 LSTM,吃进历史价格和相关的历史特征,滚动输出未来 24 小时的预测电价。它不是一篇泛泛讲 LSTM 原理的文章,而是一整套可以照着跑的实现。北欧电力市场有个特点——可再生能源占比极高,电价波动比国内大得多,负电价也时有发生,这恰恰是时序模型最能体现价值的场景。适合谁用?做能源交易辅助决策的开发者、研究电力负荷与价格预测的学生、以及想在时序预测项目里从 MLP 切到 LSTM 的从业者。这套资源把特征构造、滑窗数据、模型训练和评估串成了一条线,拿到手就能开始跑实验。2. 电价预测的核心矛盾:为什么选 LSTM 而不是普通回归2.1 电价序列的三个特点决定模型选型北欧电力市场的现货电价不是一条平滑曲线,它有三个非常明显的统计特征:强周期性(每天几个峰谷、周末和工作日差异)、突变性(风电出力骤增时电价可能瞬间跌到负值)、以及长程依赖(前几天的价格水平对今天有明显影响)。普通线性回归或随机森林处理周期性能靠特征工程硬凑,但长程依赖很难手动构造。LSTM 通过门控机制把过去若干时间步的信息选择性记忆到隐状态里,天然适合这种带时间依赖的回归任务。具体到这份项目资源,它把输入设计成「历史特征 + 历史价格」的双流形式。历史特征包括小时、星期、节假日标记、天气预报的风速和温度等外生变量,历史价格则是指定回溯窗口内的 lag 序列。相比只喂价格的纯时间序列模型,这种混合输入能显著降低峰谷时段的预测误差,尤其是碰上风电大发导致的价格塌陷。在选型上,LSTM 对比 GRU 没有绝对优劣,但在电价这类噪声较高的数据上,LSTM 的输出门对极端值的抑制更稳,项目里选它属于保守但合理的做法。2.2 输入窗口与输出步长的拆解这个资源的核心配置是:输入回溯窗口取 168 小时(7 天),输出未来 24 小时。168 这个数字的好处是刚好覆盖一个完整周周期,模型能同时看到上周同小时的价差和最近一两天的趋势。输出 24 个点有两种实现路线,一种是 seq2seq 结构,编码器吃 168 步然后解码器逐步吐出 24 个预测值;另一种是简化的多输出结构,LSTM 最后一个隐状态直接接一个输出维度为 24 的全连接层。资源里用的是后者,代码更短、训练更快,代价是丢失了逐点的隐状态反馈。我一般会建议先跑通多输出结构,因为它把问题简化成了「一个序列回归任务」,调参成本低。等基线稳定了再换 seq2seq 对比效果。做实验时也可以对比一下两种结构在风电气象剧烈变化日的表现,通常 seq2seq 在长 horizon 上略好,但训练时间几乎是前者的两倍。这个资源在代码里把两种结构的切换留成了注释,方便直接改。3. 数据准备与滑窗 Dataset:从原始 CSV 到模型输入3.1 原始数据的字段清洗与对齐丹麦电价数据通常从能源交易平台下载,常见字段包括时间戳、现货价格(DKK/MWh)、风能预测出力、总负荷预报、区域价格(东丹麦/西丹麦)等。拿到手第一步不是建模,而是检查时间戳是否连续,有没有缺失行。北欧市场的时间戳是 CET/CEST 混用的,夏令时切换那天会少一个小时或多一个小时,这个坑几乎每年踩一次。处理方式是在读入 CSV 后先做一次时间索引重采样。统一转为 UTC 再转回本地时间会省掉不少麻烦,因为换成 UTC 后,时间序列天然是连续等间隔的,不会出现 23 点或 25 点的问题。之后对缺失值做前向填充,对价格列做异常值截断——超过 99.5 分位数的值直接压到阈值,避免极端跳变把 LSTM 的梯度拉崩。资源里附带的预处理脚本基本覆盖了这几个步骤,建议不要跳过异常值截断,特别是碰上寒潮导致的电价暴涨周,不截断的话之后归一化会非常难看。3.2 滑窗数据集的实现要点PyTorch 里做时序预测最方便的方式是继承torch.utils.data.Dataset,在__getitem__里按索引切片。滑窗设计的核心参数是lookback和horizon,资源里的设定是 lookback=168,horizon=24,步长 stride=24(每天生成一个样本)。stride 设成 24 而不是 1 的原因是不让训练样本高度重叠,否则相邻样本几乎一摸一样,模型会很快过拟合到训练集上。import torch from torch.utils.data import Dataset class PriceDataset(Dataset): def __init__(self, features, prices, lookback=168, horizon=24, stride=24): self.features = torch.tensor(features, dtype=torch.float32) self.prices = torch.tensor(prices, dtype=torch.float32) self.lookback = lookback self.horizon = horizon self.stride = stride def __len__(self): # 最后一个能完整取到 lookback + horizon 的窗口索引 return max(0, (len(self.prices) - self.lookback - self.horizon) // self.stride + 1) def __getitem__(self, idx): start = idx * self.stride x = self.features[start:start + self.lookback] # [lookback, feat_dim] x_price = self.prices[start:start + self.lookback] # [lookback] y = self.prices[start + self.lookback:start + self.lookback + self.horizon] # [horizon] return x, x_price, y这段代码的逻辑很直白:特征矩阵和价格序列共用同一个时间索引,窗口起始点由idx * stride计算。输入由两部分组成,x是外生特征,x_price是历史价格滞后序列。y是未来 24 小时的真实价格,训练时用作回归目标。注意__len__里的减法和整除,这是在避免最后一个窗口越界。参数上需要特别说明两点。第一,lookback=168要求数据集至少要有 192 小时以上的连续数据,如果你的数据只有一个月,这个窗口基本把样本数砍掉了三分之一,可以考虑把 lookback 缩小到 72 来换样本量。第二,horizon=24是固定的,但如果你的业务只需要未来 6 小时,这里可以改小,模型输出维度也会对应变化,全连接层不用动。3.3 特征归一化的顺序问题归一化这一步最容易翻车,因为顺序错了会导致信息泄漏。常见的错误做法是先对整个数据集做StandardScaler.fit,再切训练集和测试集——这让测试集的均值和方差都参与了缩放,评估结果会虚高。正确做法是先切分,再在训练集上 fit,然后用同一组参数 transform 测试集。资源里用的MinMaxScaler或StandardScaler都可以,但要注意价格列如果出现负电价,MinMaxScaler会把它映射到接近 0 的位置,模型反而能学到一个「负值即异常」的隐式信号,不算坏事。另外外生特征和时间特征的处理方式不同。小时、星期这类周期变量最好用正弦余弦编码,让 23 点和 0 点在数值上相邻,而不是跳变到 0;风速、温度、负荷预报这些连续变量直接做标准化即可。资源里的特征构造脚本同时输出两种版本的时间特征,默认用正弦余弦编码,效果上比直接用整数编码能减少 2%~3% 的 MAPE,属于低成本的免费提升。4. PyTorch LSTM 训练实战:模型定义与超参数调法4.1 模型结构:LSTM 加全连接输出import torch.nn as nn class LSTMPriceForecaster(nn.Module): def __init__(self, feat_dim, hidden_size=128, num_layers=2, horizon=24): super().__init__() self.lstm = nn.LSTM( input_size=feat_dim + 1, # 外生特征 + 价格拼在一起 hidden_size=hidden_size, num_layers=num_layers, batch_first=True ) self.regressor = nn.Sequential( nn.Linear(hidden_size, 64), nn.ReLU(), nn.Linear(64, horizon) ) def forward(self, x_feat, x_price): # x_feat: [batch, lookback, feat_dim] # x_price: [batch, lookback, 1] x = torch.cat([x_feat, x_price.unsqueeze(-1)], dim=-1) out, _ = self.lstm(x) # out: [batch, lookback, hidden] last_hidden = out[:, -1, :] # 取最后一个时间步 return self.regressor(last_hidden) # [batch, horizon]模型结构非常紧凑。input_size是特征维度和价格维度的拼接,LSTM 每个时间步吃进一个包含所有信息的向量。这里有个细节:为什么不把价格单独用一层 LSTM 编码再融合?因为拼接方式更简单,而且在这个数据规模下,分别编码带来的精度提升有限,反而多了一倍参数量和调参难度。out[:, -1, :]取最后一个时间步的隐状态,它浓缩了整个窗口的信息,之后接一个两层全连接把隐状态映射到 24 个预测值。隐藏层大小hidden_size=128是经验值。数据量大(比如超过 5 万小时)可以上 256,数据少(不到 1 万小时)用 64 更稳。num_layers=2让模型能捕捉更抽象的时间依赖,但 3 层以上在这个任务里收益很小,训练时间却涨得明显,不建议再加。4.2 训练循环:损失函数与优化器的选择import torch.optim as optim model = LSTMPriceForecaster(feat_dim=feat_dim, hidden_size=128, num_layers=2, horizon=24) optimizer = optim.Adam(model.parameters(), lr=1e-3) scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', factor=0.5, patience=5) criterion = nn.MSELoss() for epoch in range(epochs): model.train() train_loss = 0.0 for x_feat, x_price, y in train_loader: optimizer.zero_grad() pred = model(x_feat, x_price) loss = criterion(pred, y) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() train_loss += loss.item() * x_feat.size(0) val_loss = evaluate(model, val_loader, criterion) scheduler.step(val_loss) # 记录每个 epoch 的 train_loss / len(train_loader) 与 val_loss训练循环的关键点有三个。第一,clip_grad_norm_几乎必备,LSTM 在长序列上梯度很容易爆炸,尤其输入里有极端电价时,不裁剪的话 loss 会突然变成 NaN。第二,ReduceLROnPlateau比固定学习率衰减实用得多,它会在验证集 loss 连续 5 个 epoch 不降时自动把学习率减半,给了模型跳出局部振荡的机会。第三,loss 用 MSELoss 还是 SmoothL1Loss 值得讨论——MSELoss 对均方误差的惩罚是平方级的,会把少量高偏差样本放大;如果想更关注中位数误差,SmoothL1Loss 更稳。资源里默认 MSELoss,我通常会在对比实验里同时跑这两个,选验证集 MAPE 更低的那个。批大小一般取 64 或 128。滑窗样本的重叠率如果较高,大 batch 反而加剧过拟合,所以资源里用 stride=24 配合 batch=64 是一套不错的平衡。训练 50 个 epoch 左右就能看到验证 loss 基本走平,再往后就是过拟合区间。4.3 验证集切分与评估指标的计算# 按时间顺序切分,禁止随机切分 train_ratio, val_ratio = 0.7, 0.15 n = len(dataset) train_end = int(n * train_ratio) val_end = int(n * (train_ratio + val_ratio)) train_ds = torch.utils.data.Subset(dataset, range(0, train_end)) val_ds = torch.utils.data.Subset(dataset, range(train_end, val_end)) test_ds = torch.utils.data.Subset(dataset, range(val_end, n))时序预测的切分必须按时间顺序,随机切分会让模型提前偷看未来数据。这个资源的顺序切分方式是对的。评估指标上,除了 RMSE 和 MAE,电价预测领域习惯额外看 MAPE 和峰值时段的偏差。但 MAPE 在出现负电价时是失效的——真实价格接近 0 或为负时,MAPE 会算出一个极大的数,容易误导调参。建议同时打印「正价格时段的 MAPE」和「全时段 MAE」,分开看更有参考价值。5. 避坑与排查:做电价预测时常踩的五个真实翻车点5.1 负电价把 MAPE 指标直接打爆现象:验证集 MAPE 高达几万甚至几十万,看起来模型完全不可用,但画出来的预测曲线其实和真实值贴得很近。原因:MAPE 的分母是真实价格。北欧市场负电价出现时,分母接近零,哪怕绝对误差只有 0.5,MAPE 也会被放大到不可理喻。解决:评估指标分开算。写一个函数,只在真实价格大于某个阈值(比如 5 DKK/MWh)时计算 MAPE,其余时段用 MAE 或 RMSE 衡量。报告结果时明确写「正价时段 MAPE」,业内默认这种口径。5.2 归一化 fit 到了全量数据现象:训练时 loss 降得漂亮,验证集也没问题,但模型上线后第一天的预测就偏得离谱。原因:预处理脚本里先用全量数据计算了均值和方差,再切分训练验证。验证集和测试集的统计量已经泄露到了模型训练阶段,离线指标自然虚高。解决:严格按「先切分、后归一化」重排流程。训练集只做 fit,验证集和测试集用训练集的 transform 参数做转换。这是一个很小的改动,但对线上效果有决定性影响。从那以后我每次跑时序项目都强制先写切分,再写归一化,顺序不允许反过来。5.3 LSTM 输入忘记拼价格序列,只喂了外生特征现象:把小时、星期、风速、温度都喂进去,模型收敛很快,但预测曲线几乎是平的,峰谷完全出不来。原因:价格预测的核心驱动因素是价格自身的历史惯性。外生特征只是辅助信号,丢掉 lag 价格后模型等于盲猜。LSTM 输入里必须同时包含特征和价格历史,这个拼法不能省。解决:检查__getitem__里返回的元素个数,确认是三元组而不是二元组。代码里x和x_price各走各的通道,在模型内部torch.cat拼接,保证每个时间步的 LSTM 输入都同时包含外部特征和价格。5.4 stride 设成 1 导致训练集样本高度雷同现象:训练 loss 一路下降但验证集在 20 个 epoch 后开始反弹,典型的过拟合征兆,但调 dropout 和 weight decay 都没用。原因:stride=1 时相邻窗口只有 1 小时差异,训练样本几乎重复,等于把同样的数据看了几十遍。模型把噪声也背了下来,泛化能力自然差。解决:stride 至少设为 horizon 的一半,推荐等于 horizon。也就是说每 24 小时取一个训练样本。代价是样本量变小,但换来的是更好的泛化。5.5 夏令时切换日导致样本缺失或错位现象:特定日期附近模型误差突然增大,看数据发现时间戳连续但小时数错位,预测值和真实值差了一个小时。原因:丹麦时间在 3 月和 10 月切换夏令时,原始 CSV 里那一天只有 23 或 25 个小时。直接 resample 成等间隔索引时,模型学到的时间特征和真实小时错位了。解决:数据加载后先用 UTC 归一化时间戳,再转回本地时区并重采样。这样能保证每天固定 24 个点。同时把「夏令时切换日」做成一个特征列,模型可以学习这一天的特殊模式,而不是把它当作普通的一天。6. 把单日预测拆成滚动多步:上线前的最后一块拼图资源里的基础模型一次只能预测未来 24 小时。但在实际业务场景中,交易决策往往需要在不同时刻更新预测——比如早上拿到 06:00 的预测,中午要更新 18:00 的价格判断。这时候不需要重新训练模型,而是用滚动预测的方式复用现有模型。核心思路是:把「一次预测 24 小时」变成「每 1 小时预测未来 1 小时」,并用真实值逐步更新窗口。def rolling_forecast(model, dataset, start_idx, horizon=24): model.eval() # 从 start_idx 开始,用窗口内的真实值逐步前移 current = start_idx preds = [] with torch.no_grad(): for i in range(horizon): x_feat, x_price, _ = dataset[current] # 窗口结束位置 = current + lookback pred = model(x_feat.unsqueeze(0), x_price.unsqueeze(0)).squeeze(0) # [horizon] preds.append(pred[i].item()) current += 1 # 窗口平移 1 小时,x_price 会引入真实值 return preds这段代码的巧妙之处在于:第 i 次循环时,dataset[current]的输入窗口已经包含了前面 i 小时的预测或真实价格,LSTM 重新计算隐状态并输出第 i 小时的预测值。如果当前时刻的真实价格已经发布,就用真实值;如果还没发布(比如在做未来 48 小时的推演),可以用上一轮的预测值回填。这是递归预测的一种变体,业内叫 teacher forcing 与 free running 的切换。实际部署时还要考虑特征对齐的问题:滚动预测到第 20 小时的时候,外生特征里的小时、星期、风速这些值还容易拿到,但「负荷预报」这类字段可能只有未来 24 小时的版本,超出范围后就得退化用气候平均或直接置零。资源里没有展开这部分,但跑通代码后你一定会遇到——通用的处理方式是把外生特征也一起滚动,能拿到实测就用实测,拿不到就用最近几天的同时段均值兜底,确保模型输入的数值范围不会突然跳变。验证滚动预测效果有一个简单的做法:把测试集的真实未来 168 小时按每小时滚动重新预测一遍,对比单次预测 168 小时的结果差异。你会发现滚动预测的累计误差不会像单次预测那样随着 horizon 单调增大,反而会因为每一步都吃了新的真实值而变得更稳。这背后的原因是 LSTM 的隐状态是迭代更新的,而不是一个一次性输出的固定向量。从那以后我每次上线前都强制走一遍这个滚动验证,确认当前模型在「实时更新」场景下不会翻车,然后再交付给下游使用。希望这篇拆解能帮你把这份资源跑通,也欢迎把你在调参过程中踩到的其他坑拿出来一起聊。本文还有配套的精品资源,点击获取
简介:面向电力市场预测与时间序列分析学习者,这份资源基于PyTorch和LSTM构建丹麦电力市场未来24小时电价预测模型,覆盖数据预处理、历史特征构建、模型训练与评估全流程。压缩包共23个文件,约395KB,包括6个Python脚本(核心模型、主程序与数据处理)、4个CSV数据集(丹麦2023年真实电价)、5个XML工程配置,以及模型权重和预测结果可视化图片等。目前已有1032人浏览学习。读者可获取完整项目代码与真实电价数据,借助LSTM和seq2seq结构实现电价时序预测,并通过损失曲线、特征对比图直观理解模型效果,适合有一定Python基础、希望上手深度学习时序预测的开发者。1. 丹麦电力电价预测:一份把 PyTorch + LSTM 落到 24 小时滚动预测上的完整资源做电价预测的人最头疼的事情,不是模型选型,而是数据特征怎么揉进时间序列里。这份关于丹麦电力电价预测的资源,目标非常明确:用 PyTorch 搭 LSTM,吃进历史价格和相关的历史特征,滚动输出未来 24 小时的预测电价。它不是一篇泛泛讲 LSTM 原理的文章,而是一整套可以照着跑的实现。北欧电力市场有个特点——可再生能源占比极高,电价波动比国内大得多,负电价也时有发生,这恰恰是时序模型最能体现价值的场景。适合谁用?做能源交易辅助决策的开发者、研究电力负荷与价格预测的学生、以及想在时序预测项目里从 MLP 切到 LSTM 的从业者。这套资源把特征构造、滑窗数据、模型训练和评估串成了一条线,拿到手就能开始跑实验。2. 电价预测的核心矛盾:为什么选 LSTM 而不是普通回归2.1 电价序列的三个特点决定模型选型北欧电力市场的现货电价不是一条平滑曲线,它有三个非常明显的统计特征:强周期性(每天几个峰谷、周末和工作日差异)、突变性(风电出力骤增时电价可能瞬间跌到负值)、以及长程依赖(前几天的价格水平对今天有明显影响)。普通线性回归或随机森林处理周期性能靠特征工程硬凑,但长程依赖很难手动构造。LSTM 通过门控机制把过去若干时间步的信息选择性记忆到隐状态里,天然适合这种带时间依赖的回归任务。具体到这份项目资源,它把输入设计成「历史特征 + 历史价格」的双流形式。历史特征包括小时、星期、节假日标记、天气预报的风速和温度等外生变量,历史价格则是指定回溯窗口内的 lag 序列。相比只喂价格的纯时间序列模型,这种混合输入能显著降低峰谷时段的预测误差,尤其是碰上风电大发导致的价格塌陷。在选型上,LSTM 对比 GRU 没有绝对优劣,但在电价这类噪声较高的数据上,LSTM 的输出门对极端值的抑制更稳,项目里选它属于保守但合理的做法。2.2 输入窗口与输出步长的拆解这个资源的核心配置是:输入回溯窗口取 168 小时(7 天),输出未来 24 小时。168 这个数字的好处是刚好覆盖一个完整周周期,模型能同时看到上周同小时的价差和最近一两天的趋势。输出 24 个点有两种实现路线,一种是 seq2seq 结构,编码器吃 168 步然后解码器逐步吐出 24 个预测值;另一种是简化的多输出结构,LSTM 最后一个隐状态直接接一个输出维度为 24 的全连接层。资源里用的是后者,代码更短、训练更快,代价是丢失了逐点的隐状态反馈。我一般会建议先跑通多输出结构,因为它把问题简化成了「一个序列回归任务」,调参成本低。等基线稳定了再换 seq2seq 对比效果。做实验时也可以对比一下两种结构在风电气象剧烈变化日的表现,通常 seq2seq 在长 horizon 上略好,但训练时间几乎是前者的两倍。这个资源在代码里把两种结构的切换留成了注释,方便直接改。3. 数据准备与滑窗 Dataset:从原始 CSV 到模型输入3.1 原始数据的字段清洗与对齐丹麦电价数据通常从能源交易平台下载,常见字段包括时间戳、现货价格(DKK/MWh)、风能预测出力、总负荷预报、区域价格(东丹麦/西丹麦)等。拿到手第一步不是建模,而是检查时间戳是否连续,有没有缺失行。北欧市场的时间戳是 CET/CEST 混用的,夏令时切换那天会少一个小时或多一个小时,这个坑几乎每年踩一次。处理方式是在读入 CSV 后先做一次时间索引重采样。统一转为 UTC 再转回本地时间会省掉不少麻烦,因为换成 UTC 后,时间序列天然是连续等间隔的,不会出现 23 点或 25 点的问题。之后对缺失值做前向填充,对价格列做异常值截断——超过 99.5 分位数的值直接压到阈值,避免极端跳变把 LSTM 的梯度拉崩。资源里附带的预处理脚本基本覆盖了这几个步骤,建议不要跳过异常值截断,特别是碰上寒潮导致的电价暴涨周,不截断的话之后归一化会非常难看。3.2 滑窗数据集的实现要点PyTorch 里做时序预测最方便的方式是继承torch.utils.data.Dataset,在__getitem__里按索引切片。滑窗设计的核心参数是lookback和horizon,资源里的设定是 lookback=168,horizon=24,步长 stride=24(每天生成一个样本)。stride 设成 24 而不是 1 的原因是不让训练样本高度重叠,否则相邻样本几乎一摸一样,模型会很快过拟合到训练集上。import torch from torch.utils.data import Dataset class PriceDataset(Dataset): def __init__(self, features, prices, lookback=168, horizon=24, stride=24): self.features = torch.tensor(features, dtype=torch.float32) self.prices = torch.tensor(prices, dtype=torch.float32) self.lookback = lookback self.horizon = horizon self.stride = stride def __len__(self): # 最后一个能完整取到 lookback + horizon 的窗口索引 return max(0, (len(self.prices) - self.lookback - self.horizon) // self.stride + 1) def __getitem__(self, idx): start = idx * self.stride x = self.features[start:start + self.lookback] # [lookback, feat_dim] x_price = self.prices[start:start + self.lookback] # [lookback] y = self.prices[start + self.lookback:start + self.lookback + self.horizon] # [horizon] return x, x_price, y这段代码的逻辑很直白:特征矩阵和价格序列共用同一个时间索引,窗口起始点由idx * stride计算。输入由两部分组成,x是外生特征,x_price是历史价格滞后序列。y是未来 24 小时的真实价格,训练时用作回归目标。注意__len__里的减法和整除,这是在避免最后一个窗口越界。参数上需要特别说明两点。第一,lookback=168要求数据集至少要有 192 小时以上的连续数据,如果你的数据只有一个月,这个窗口基本把样本数砍掉了三分之一,可以考虑把 lookback 缩小到 72 来换样本量。第二,horizon=24是固定的,但如果你的业务只需要未来 6 小时,这里可以改小,模型输出维度也会对应变化,全连接层不用动。3.3 特征归一化的顺序问题归一化这一步最容易翻车,因为顺序错了会导致信息泄漏。常见的错误做法是先对整个数据集做StandardScaler.fit,再切训练集和测试集——这让测试集的均值和方差都参与了缩放,评估结果会虚高。正确做法是先切分,再在训练集上 fit,然后用同一组参数 transform 测试集。资源里用的MinMaxScaler或StandardScaler都可以,但要注意价格列如果出现负电价,MinMaxScaler会把它映射到接近 0 的位置,模型反而能学到一个「负值即异常」的隐式信号,不算坏事。另外外生特征和时间特征的处理方式不同。小时、星期这类周期变量最好用正弦余弦编码,让 23 点和 0 点在数值上相邻,而不是跳变到 0;风速、温度、负荷预报这些连续变量直接做标准化即可。资源里的特征构造脚本同时输出两种版本的时间特征,默认用正弦余弦编码,效果上比直接用整数编码能减少 2%~3% 的 MAPE,属于低成本的免费提升。4. PyTorch LSTM 训练实战:模型定义与超参数调法4.1 模型结构:LSTM 加全连接输出import torch.nn as nn class LSTMPriceForecaster(nn.Module): def __init__(self, feat_dim, hidden_size=128, num_layers=2, horizon=24): super().__init__() self.lstm = nn.LSTM( input_size=feat_dim + 1, # 外生特征 + 价格拼在一起 hidden_size=hidden_size, num_layers=num_layers, batch_first=True ) self.regressor = nn.Sequential( nn.Linear(hidden_size, 64), nn.ReLU(), nn.Linear(64, horizon) ) def forward(self, x_feat, x_price): # x_feat: [batch, lookback, feat_dim] # x_price: [batch, lookback, 1] x = torch.cat([x_feat, x_price.unsqueeze(-1)], dim=-1) out, _ = self.lstm(x) # out: [batch, lookback, hidden] last_hidden = out[:, -1, :] # 取最后一个时间步 return self.regressor(last_hidden) # [batch, horizon]模型结构非常紧凑。input_size是特征维度和价格维度的拼接,LSTM 每个时间步吃进一个包含所有信息的向量。这里有个细节:为什么不把价格单独用一层 LSTM 编码再融合?因为拼接方式更简单,而且在这个数据规模下,分别编码带来的精度提升有限,反而多了一倍参数量和调参难度。out[:, -1, :]取最后一个时间步的隐状态,它浓缩了整个窗口的信息,之后接一个两层全连接把隐状态映射到 24 个预测值。隐藏层大小hidden_size=128是经验值。数据量大(比如超过 5 万小时)可以上 256,数据少(不到 1 万小时)用 64 更稳。num_layers=2让模型能捕捉更抽象的时间依赖,但 3 层以上在这个任务里收益很小,训练时间却涨得明显,不建议再加。4.2 训练循环:损失函数与优化器的选择import torch.optim as optim model = LSTMPriceForecaster(feat_dim=feat_dim, hidden_size=128, num_layers=2, horizon=24) optimizer = optim.Adam(model.parameters(), lr=1e-3) scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', factor=0.5, patience=5) criterion = nn.MSELoss() for epoch in range(epochs): model.train() train_loss = 0.0 for x_feat, x_price, y in train_loader: optimizer.zero_grad() pred = model(x_feat, x_price) loss = criterion(pred, y) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() train_loss += loss.item() * x_feat.size(0) val_loss = evaluate(model, val_loader, criterion) scheduler.step(val_loss) # 记录每个 epoch 的 train_loss / len(train_loader) 与 val_loss训练循环的关键点有三个。第一,clip_grad_norm_几乎必备,LSTM 在长序列上梯度很容易爆炸,尤其输入里有极端电价时,不裁剪的话 loss 会突然变成 NaN。第二,ReduceLROnPlateau比固定学习率衰减实用得多,它会在验证集 loss 连续 5 个 epoch 不降时自动把学习率减半,给了模型跳出局部振荡的机会。第三,loss 用 MSELoss 还是 SmoothL1Loss 值得讨论——MSELoss 对均方误差的惩罚是平方级的,会把少量高偏差样本放大;如果想更关注中位数误差,SmoothL1Loss 更稳。资源里默认 MSELoss,我通常会在对比实验里同时跑这两个,选验证集 MAPE 更低的那个。批大小一般取 64 或 128。滑窗样本的重叠率如果较高,大 batch 反而加剧过拟合,所以资源里用 stride=24 配合 batch=64 是一套不错的平衡。训练 50 个 epoch 左右就能看到验证 loss 基本走平,再往后就是过拟合区间。4.3 验证集切分与评估指标的计算# 按时间顺序切分,禁止随机切分 train_ratio, val_ratio = 0.7, 0.15 n = len(dataset) train_end = int(n * train_ratio) val_end = int(n * (train_ratio + val_ratio)) train_ds = torch.utils.data.Subset(dataset, range(0, train_end)) val_ds = torch.utils.data.Subset(dataset, range(train_end, val_end)) test_ds = torch.utils.data.Subset(dataset, range(val_end, n))时序预测的切分必须按时间顺序,随机切分会让模型提前偷看未来数据。这个资源的顺序切分方式是对的。评估指标上,除了 RMSE 和 MAE,电价预测领域习惯额外看 MAPE 和峰值时段的偏差。但 MAPE 在出现负电价时是失效的——真实价格接近 0 或为负时,MAPE 会算出一个极大的数,容易误导调参。建议同时打印「正价格时段的 MAPE」和「全时段 MAE」,分开看更有参考价值。5. 避坑与排查:做电价预测时常踩的五个真实翻车点5.1 负电价把 MAPE 指标直接打爆现象:验证集 MAPE 高达几万甚至几十万,看起来模型完全不可用,但画出来的预测曲线其实和真实值贴得很近。原因:MAPE 的分母是真实价格。北欧市场负电价出现时,分母接近零,哪怕绝对误差只有 0.5,MAPE 也会被放大到不可理喻。解决:评估指标分开算。写一个函数,只在真实价格大于某个阈值(比如 5 DKK/MWh)时计算 MAPE,其余时段用 MAE 或 RMSE 衡量。报告结果时明确写「正价时段 MAPE」,业内默认这种口径。5.2 归一化 fit 到了全量数据现象:训练时 loss 降得漂亮,验证集也没问题,但模型上线后第一天的预测就偏得离谱。原因:预处理脚本里先用全量数据计算了均值和方差,再切分训练验证。验证集和测试集的统计量已经泄露到了模型训练阶段,离线指标自然虚高。解决:严格按「先切分、后归一化」重排流程。训练集只做 fit,验证集和测试集用训练集的 transform 参数做转换。这是一个很小的改动,但对线上效果有决定性影响。从那以后我每次跑时序项目都强制先写切分,再写归一化,顺序不允许反过来。5.3 LSTM 输入忘记拼价格序列,只喂了外生特征现象:把小时、星期、风速、温度都喂进去,模型收敛很快,但预测曲线几乎是平的,峰谷完全出不来。原因:价格预测的核心驱动因素是价格自身的历史惯性。外生特征只是辅助信号,丢掉 lag 价格后模型等于盲猜。LSTM 输入里必须同时包含特征和价格历史,这个拼法不能省。解决:检查__getitem__里返回的元素个数,确认是三元组而不是二元组。代码里x和x_price各走各的通道,在模型内部torch.cat拼接,保证每个时间步的 LSTM 输入都同时包含外部特征和价格。5.4 stride 设成 1 导致训练集样本高度雷同现象:训练 loss 一路下降但验证集在 20 个 epoch 后开始反弹,典型的过拟合征兆,但调 dropout 和 weight decay 都没用。原因:stride=1 时相邻窗口只有 1 小时差异,训练样本几乎重复,等于把同样的数据看了几十遍。模型把噪声也背了下来,泛化能力自然差。解决:stride 至少设为 horizon 的一半,推荐等于 horizon。也就是说每 24 小时取一个训练样本。代价是样本量变小,但换来的是更好的泛化。5.5 夏令时切换日导致样本缺失或错位现象:特定日期附近模型误差突然增大,看数据发现时间戳连续但小时数错位,预测值和真实值差了一个小时。原因:丹麦时间在 3 月和 10 月切换夏令时,原始 CSV 里那一天只有 23 或 25 个小时。直接 resample 成等间隔索引时,模型学到的时间特征和真实小时错位了。解决:数据加载后先用 UTC 归一化时间戳,再转回本地时区并重采样。这样能保证每天固定 24 个点。同时把「夏令时切换日」做成一个特征列,模型可以学习这一天的特殊模式,而不是把它当作普通的一天。6. 把单日预测拆成滚动多步:上线前的最后一块拼图资源里的基础模型一次只能预测未来 24 小时。但在实际业务场景中,交易决策往往需要在不同时刻更新预测——比如早上拿到 06:00 的预测,中午要更新 18:00 的价格判断。这时候不需要重新训练模型,而是用滚动预测的方式复用现有模型。核心思路是:把「一次预测 24 小时」变成「每 1 小时预测未来 1 小时」,并用真实值逐步更新窗口。def rolling_forecast(model, dataset, start_idx, horizon=24): model.eval() # 从 start_idx 开始,用窗口内的真实值逐步前移 current = start_idx preds = [] with torch.no_grad(): for i in range(horizon): x_feat, x_price, _ = dataset[current] # 窗口结束位置 = current + lookback pred = model(x_feat.unsqueeze(0), x_price.unsqueeze(0)).squeeze(0) # [horizon] preds.append(pred[i].item()) current += 1 # 窗口平移 1 小时,x_price 会引入真实值 return preds这段代码的巧妙之处在于:第 i 次循环时,dataset[current]的输入窗口已经包含了前面 i 小时的预测或真实价格,LSTM 重新计算隐状态并输出第 i 小时的预测值。如果当前时刻的真实价格已经发布,就用真实值;如果还没发布(比如在做未来 48 小时的推演),可以用上一轮的预测值回填。这是递归预测的一种变体,业内叫 teacher forcing 与 free running 的切换。实际部署时还要考虑特征对齐的问题:滚动预测到第 20 小时的时候,外生特征里的小时、星期、风速这些值还容易拿到,但「负荷预报」这类字段可能只有未来 24 小时的版本,超出范围后就得退化用气候平均或直接置零。资源里没有展开这部分,但跑通代码后你一定会遇到——通用的处理方式是把外生特征也一起滚动,能拿到实测就用实测,拿不到就用最近几天的同时段均值兜底,确保模型输入的数值范围不会突然跳变。验证滚动预测效果有一个简单的做法:把测试集的真实未来 168 小时按每小时滚动重新预测一遍,对比单次预测 168 小时的结果差异。你会发现滚动预测的累计误差不会像单次预测那样随着 horizon 单调增大,反而会因为每一步都吃了新的真实值而变得更稳。这背后的原因是 LSTM 的隐状态是迭代更新的,而不是一个一次性输出的固定向量。从那以后我每次上线前都强制走一遍这个滚动验证,确认当前模型在「实时更新」场景下不会翻车,然后再交付给下游使用。希望这篇拆解能帮你把这份资源跑通,也欢迎把你在调参过程中踩到的其他坑拿出来一起聊。本文还有配套的精品资源,点击获取