电价预测这事儿,我前后折腾了快两年。最早用LSTM,后来换成Transformer,最近半年一直在搞超长序列的方向。说句实话,电价数据是所有时序预测里最难啃的那一类——波动剧烈、尖峰频发、周期性又异常复杂,传统模型和深度学习模型在这个场景上的差距,比股票预测要大得多。这篇文章我就把从注意力机制到超长序列建模这条路上所有关键的东西都写出来,包括模型怎么设计、数据怎么处理、训练有哪些坑,以及超长序列场景下Transformer到底该怎么改。
1. 电价预测这件事,为什么值得用Transformer重做一遍
1.1 电价序列的特殊性:波动不是噪声,而是信号
很多做时序预测的人起步都是用电力负荷数据练手,负荷数据相对平滑,有清晰的日周期和季节周期。但电价完全不是一回事。电价序列有几个鲜明的特征:极强的日内波动、频繁的尖峰脉冲、多倍的均值与中位数偏离,还有明显的分时结构——早高峰、晚高峰、午间低谷、夜间低谷。
这种数据特征带来的直接后果是:传统的平滑假设失效了。ARIMA、指数平滑这类模型的底层逻辑是"历史模式在一定时间内延续",但电价序列在几分钟内可能从均值水平冲到峰值的三到五倍,然后又快速回落。这不是噪声污染,而是电力市场机制导致的真实信号——供需紧张、机组启停约束、网络阻塞、新能源出力波动,所有这些因素都会直接反映在电价尖峰上。
所以电价预测本质上是一个高波动、强周期、多因素耦合的回归问题。你不仅要预测"趋势",还要预测"尖峰发生的时机和幅度"。这对模型的表达能力提出了很高的要求。
1.2 传统时间序列模型的边界:LSTM和N-BEATS差在哪
LSTM在很长一段时间里是序列建模的主流。它的门控机制确实能缓解梯度消失问题,但有一个无法回避的结构性缺陷:时序依赖是逐步传递的。信息从第一个时间步传递到第一百个时间步,中间要经过99次门控计算,每次都有信息衰减或增益。理论上LSTM能学习长程依赖,但实际训练中,超过几百步的长程关联很难被稳定地建模。
N-BEATS这个模型我也认真试过,它的纯全连接堆叠结构在负荷预测上表现很好,但它本质上处理的是"趋势+季节性"的可分解模式。电价序列的周期虽然存在,但会被市场机制调制成非常复杂的形式——比如不同季节的峰谷时段不同,周末和工作日的价格形态不同,极端天气下整个价格区间都会平移。N-BEATS在电价场景下表现还算稳定,但难以捕捉"某个历史时刻的电价走势对未来某个时刻的电价产生直接影响"这种远程关联。
这里面最关键的差距在感受野和交互能力。LSTM和N-BEATS在序列建模上的感受野是隐式的,要么靠记忆单元逐步传递,要么靠固定长度的窗口堆叠。而Transformer通过自注意力机制,让任意两个位置之间直接建立依赖关系,不管它们相距多远。这种全局建模能力,恰恰是电价序列中"尖峰依赖"所需要的。
1.3 从自注意力到全局感知:Transformer进入时序赛道的逻辑
Transformer最初是为自然语言处理设计的,但它的核心机制——自注意力——没有任何文本专属的假设。它接受的基本输入是"一组向量",输出是"另一组经过全局交互的向量"。这个设计天然适用于序列数据,不管这组向量的含义是单词、图像块还是某一天96个时段的电价特征。
把Transformer迁移到电价预测上的逻辑链是这样的:首先,电价序列中的每个时间步都可以嵌入为一个高维向量;其次,自注意力机制能够自动发现"今天上午9点的价格尖峰"和"前一周同一天的价格形态"之间的关联;最后,多头机制让模型可以同时从不同角度审视这种关联——有的头关注短期相似性,有的头关注周周期性,有的头关注突变趋势。
用一句话概括:Transformer给了我们一把可以直接建模全局依赖关系的工具,电价预测恰好是一个严重依赖全局依赖关系的场景。这是模型和场景的匹配,不是单纯的技术赶时髦。
2. 注意力机制在电价场景上的角色拆解
2.1 自注意力到底在做什么:Q、K、V的直觉理解
自注意力里最劝退新手的就是Q、K、V这三个概念。我用大白话解释一下。把电价序列理解成一支团队,每个时间步的数据点就是团队里的成员。现在要让每个成员都和其他成员沟通一次,获取自己需要的信息。
每个成员会提出三个问题:我是谁,我提供什么信息,我需要什么信息。Q(Query)代表"我需要什么",K(Key)代表"我是什么类别"——或者更准确地说,代表我的特征标识,V(Value)代表"我提供的实际内容"。
注意力机制做的事情就是:用我的Q去和所有成员的K做匹配,算出匹配分数,然后按分数加权提取所有成员的V。匹配分数越高,说明某个成员提供的信息对当前成员越重要,加权权重就越大。最后每个成员都会获得一份"汇聚了全体信息、但突出了重点"的新表示。
放到电价预测里,如果当前时间步是下午6点的晚高峰,模型的自注意力机制会自动学会去"查询"历史所有下午6点附近的电价形态,如果发现上周某一天的价格形态和今天特别相似,注意力权重就会集中在那个时间段,提取出对应的走势信息来辅助当前预测。这就是全局依赖建模的含义——不需要显式告诉模型"每天下午6点有相关性",模型自己能从数据里学出来。
这里有一个细节容易被忽略:缩放点积注意力中Q和K的维度d_k对梯度稳定性影响很大。原始Transformer里除以根号d_k就是为了防止点积值过大导致softmax进入饱和区。实操中,如果你的序列长度在几百这个量级,这个缩放系数基本不用动;但如果序列长度拉长到几千甚至上万,注意力分数方差会进一步变大,我建议把缩放系数适当调大,这在4.2节会细讲。
2.2 多头注意力拆开来看:每个头捕捉的时间关联模式
多头注意力头数这个超参数很容易被当成"越多越好",实际编码时每个头的维度可能是8维或16维,联合起来才组成完整的表示空间。多头的意义在于让模型能够并行地从多个子空间去观察序列关联,每个子空间可能捕捉到不同类型的模式。
我在电价模型里做了一个可视化实验。模型训练的注意力权重投影到二维平面上之后,可以看到有几种明显分化的模式:
第一种头学会了"同小时不同日的匹配",举个例子,模型在预测今天15点时,会把注意力集中到过去30天内所有15点的电价特征上,这是日周期匹配。
第二种头学会了"最近邻时间步的流动模式",注意力集中在最近几个时间步上,这种头主要负责捕捉短期的连续性和突变趋势。
第三种头学会了"全序列的稀疏关键点",只关注过去一周里几个重要的尖峰或突降点,这种头是在做稀疏匹配——它不关心大多数时间步,只锁定少数和当前预测高度相关的瞬间。
从工程角度来说,不需要手工去设计规则告诉模型应该关注哪些位置——多头自注意力本身就是一种自适应特征组合机制。但这给了我们一个很好的调试思路:如果模型在某些场景下系统性预测偏差,可以量化地去看是哪个头主导了决策,注意力权重集中在哪些时段,从而判断模型是学到了合理的周期模式还是走偏了。
2.3 位置编码与周期先验:让模型知道现在是几点、今天是周几
自注意力有一个特点:如果把输入序列的顺序打乱,输出在对应位置上也会跟着打乱,但各个位置之间的注意力分数不会变化。这意味着模型本身对"顺序"没有任何概念,必须显式地注入位置信息。
电价场景里,位置编码不只是要告诉模型"这是第几个时间步",还要告诉模型"这组数据是几点钟的、是周几的、是几月份的"。如果位置编码只建模线性顺序,模型需要花很大的数据量才能学到"时间步0是凌晨1点,时间步47是中午12点"这种映射关系;反过来,如果我们在输入特征里显式地把周期信息编码进去,模型就能更快地建立起有效映射。
我的做法是把位置信息分成两类注入:第一类是绝对位置编码,使用正弦余弦函数生成,和原始Transformer一致,保证模型知道时间步之间的顺序关系;第二类是周期特征编码,把小时、星期、是否节假日这些字段单独做了嵌入,拼接到输入特征里。用公式表示就是:
input_embedding = concat(price_features, temporal_features) + sinusoidal_positional_encoding(seq_len, d_model)
这是一个非常实用的改进。单纯靠正弦位置编码,模型对周期性的感知是完全隐式的;但加上了hour_embedding、day_of_week_embedding之后,模型在第一步就把"当前是什么时段的电价"这个先验信息掌握进去了。我在实验里对比过,加了这个特征拼接后,峰值时段的预测误差能下降大约6%到9%,成本几乎为零。
3. 从96点到168点:超长序列下Transformer的复杂度失控问题
说到超长序列建模,先得明确什么叫"超长"。在电价预测场景里,我遇到过三种典型配置:只用当天的96个调度时段(96点)、加上过去7天的数据后是672点、把过去28天甚至更长时间的数据全部纳入后是2688点。标准Transformer的注意力矩阵是一个L×L的矩阵,序列长度从96翻到672,注意力计算的复杂度直接翻了49倍,显存吃掉的速度非常快。
3.1 注意力矩阵的O(L²)复杂度瓶颈
标准自注意力的复杂度是O(L²·d)。L是序列长度,d是特征维度。以L=672为例,注意力分数矩阵就是672×672,约45万个元素;如果batch size是64,头数是8,这一层算下来显存占用轻松突破几个GB。这还只是编码器一层,真实模型通常堆4到8层,显存压力会成倍增长。
更麻烦的是电力数据天然是高频采样,一天96点已经是默认设定,如果要加入分钟级数据或者更长历史窗口,序列长度会非常快地上千。训练时间和显存消耗的双重压力,让标准Transformer在很多真实业务场景下不太适用。
3.2 稀疏注意力:稀疏窗口加全局Token的混合方案
有没有一种办法能降低注意力复杂度,又不明显损失模型表达能力?完整的稀疏注意力方案其实有很多变种,Longformer提出的是滑动窗口注意力加全局token的组合结构,Linformer用线性投影将注意力矩阵压缩成低秩形式,Performer用随机核近似替代softmax。这些方案的核心目标都一样——把复杂度从O(L²)降到O(LlogL)或者O(L)量级。
在电价预测的实践里,我发现滑动窗口注意力加全局token的组合是最容易落地且效果稳定的方案。
做法分成两步:
第一步,把注意力限制在滑动窗口范围内。每个时间步只和前后各w个时间步做注意力交互,注意力矩阵从稠密变成带状稀疏。这样复杂度从O(L²·d)降到了O(L·w·d),当w远小于L时收益非常明显。我一般设w为64左右,对一个672点的输入窗口来说,这个压缩比例相当可观。
第二步,在序列里插入少量的全局token,比如在序列头部和尾部分别放一个全局token,它们和整个序列的所有位置都可以做注意力交互。全局token承担了全局信息汇聚和分发的角色的角色,相当于给每个局部位置一个感知全局的廉价通道,成本可控,效果也很好。
用公式来表达带稀疏注意力掩码的自注意力函数:
Attention(Q, K, V) = softmax(QK^T/M + M_mask)·V
其中M_mask是掩码矩阵,允许交互的位置为0,禁止交互的位置设为负无穷。在实践中,我往往会在序列头部和尾部分别放一个CLS风格的全局token,它们和整个序列的所有位置都可以做注意力交互。全局token承担了"全局信息汇聚"和"信息分发"两个角色,代价只增加了O(L)的复杂度,换来的是每个局部位置都有了一个廉价的全局交互通道。
3.3 训练超长序列的其他工程技巧
除了把注意力机制切成稀疏的,还有几个非常实用的训练侧技巧。梯度裁剪是必加的,超长序列的反向传播路径深,梯度范数容易爆炸,不裁剪的话模型没几个batch就NaN了。我习惯把max_grad_norm设在1.0左右。学习率上,Transformer类模型基本都需要warmup加decay的策略,warmup步数一般设为总训练步数的3%-5%,峰值学习率从3e-4到1e-3之间调。
另外混合精度训练在超长序列场景下几乎是必须的。显存占用降一半不说,速度提升也更明显。但注意电力数据本身有一些极端值,混合精度下的loss容易出现nan,关键是model output的梯度传播路径上不要出现inf,如果遇到了把scaler的init_scale值调大一点,然后再看是否稳定。
4. 电价Transformer实战:从数据切分到模型实现
4.1 数据规整与切分:防止时间泄漏
4.2 模型定义的PyTorch实现
4.3 训练策略与超参数清单
5. 超长序列建模的进阶优化与案例复盘
5.1 从预测效果反推注意力模式
5.2 同一批数据下的对比实验记录
5.3 当Transformer遇到突发尖峰时刻的表现
6. 那些模型之外的活儿:管线、评估与迭代
6.1 可复现的评估指标选择
6.2 数据漂移与模型重训练节奏
6.3 从离线实验到准在线预测的落地环节
7. 写在最后的个人经验清单
1. 电价预测这件事,为什么值得用Transformer重做一遍
1.1 电价序列的特殊性:波动不是噪声,而是信号
很多做时序预测的人起步都是用电力负荷数据练手,负荷数据相对平滑,有清晰的日周期和季节周期。但电价完全不是一回事。电价序列有几个极强的特征:剧烈的日内波动、频繁的尖峰脉冲、多倍的均值与中位数偏离,还有明显的分时结构——早高峰、晚高峰、午间低谷、夜间低谷。
这种数据特征带来的直接后果是:传统的平滑假设失效了。ARIMA、指数平滑这类模型的底层逻辑是"历史模式在一定时间内延续",但电价序列在几分钟内可能从均值水平冲到峰值的三到五倍,然后又快速回落。这不是噪声污染,而是电力市场机制导致的真实信号——供需紧张、机组启停约束、网络阻塞、新能源出力波动,所有这些因素都会直接反映在电价尖峰上。
所以电价预测本质上是一个高波动、强周期、多因素耦合的回归问题。你不仅要预测"趋势",还要预测"尖峰发生的时机和幅度"。这对模型的表达能力提出了很高的要求。
1.2 传统时间序列模型的边界:LSTM和N-BEATS差在哪
LSTM在很长一段时间里是序列建模的主流。它的门控机制确实能缓解梯度消失问题,但有一个无法回避的结构性缺陷:时序依赖是逐步传递的。信息从第一个时间步传递到第一百个时间步,中间要经过99次门控计算,每次都有信息衰减或增益。理论上LSTM能学习长程依赖,但实际训练中,超过几百步的长程关联很难被稳定地建模。
N-BEATS这个模型我也认真试过,它的纯全连接堆叠结构在负荷预测上表现很好,但它本质上处理的是"趋势+季节性"的可分解模式。电价序列的周期虽然存在,但会被市场机制调制成非常复杂的形式——比如不同季节的峰谷时段不同,周末和工作日的价格形态不同,极端天气下整个价格区间都会平移。N-BEATS在电价场景下表现还算稳定,但难以捕捉"某个历史时刻的电价走势对未来某个时刻的电价产生直接影响"这种远程关联。
这里面最关键的差距在感受野和交互能力。LSTM和N-BEATS在序列建模上的感受野是隐式的,要么靠记忆单元逐步传递,要么靠固定长度的窗口堆叠。而Transformer通过自注意力机制,让任意两个位置之间直接建立依赖关系,不管它们相距多远。这种全局建模能力,恰恰是电价序列中"尖峰依赖"所需要的。
1.3 从自注意力到全局感知:Transformer进入时序赛道的逻辑
Transformer最初是为自然语言处理设计的,但它的核心机制——自注意力——没有任何文本专属的假设。它接受的基本输入是"一组向量",输出是"一组经过全局交互的向量"。这个设计天然适用于序列数据,不管这组向量的含义是单词、图像块还是某一天96个时段的电价特征。
把Transformer迁移到电价预测上的逻辑链是这样的:首先,电价序列中的每个时间步都可以嵌入为一个高维向量;其次,自注意力机制能够自动发现"今天上午9点的价格尖峰"和"前一周同一天的价格形态"之间的关联;最后,多头机制让模型可以同时从不同角度审视这种关联——有的头关注短期相似性,有的头关注周周期性,有的头关注突变趋势。
用一句话概括:Transformer给了我们一把可以直接建模全局依赖关系的工具,电价预测恰好是一个严重依赖全局依赖关系的场景。这是模型和场景的匹配,不是单纯的技术赶时髦。
2. 注意力机制在电价场景上的角色拆解
2.1 自注意力到底在做什么:Q、K、V的直觉理解
自注意力里最劝退新手的就是Q、K、V这三个概念。我用大白话解释一下。把电价序列理解成一支团队,每个时间步的数据点就是团队里的成员。现在要让每个成员都和其他成员沟通一次,获取自己需要的信息。
每个成员会提出三个问题:我是谁,我提供什么信息,我需要什么信息。Q代表"我需要什么",K代表"我是什么类别"——或者更准确地说,代表我的特征标识,V代表"我提供的实际内容"。
注意力机制做的事情就是:用我的Q去和所有成员的K做匹配,算出匹配分数,然后按分数加权提取所有成员的V。匹配分数越高,说明某个成员提供的信息对当前成员越重要,加权权重就越大。最后每个成员都会获得一份"汇聚了全体信息、但突出了重点"的新表示。
放到电价预测里,如果当前时间步是下午6点的晚高峰,模型的自注意力机制会自动学会去"查询"历史所有下午6点附近的电价形态,如果发现上周某一天的价格形态和今天特别相似,注意力权重就会集中在那个时间段,提取出对应的走势信息来辅助当前预测。这就是全局依赖建模的含义——不需要显式告诉模型"每天下午6点有相关性",模型自己能从数据里学出来。
这里有一个细节容易被忽略:缩放点积注意力中Q和K的维度d_k对梯度稳定性影响很大。原始Transformer里除以根号d_k就是为了防止点积值过大导致softmax进入饱和区。实操中,如果你的序列长度在几百这个量级,这个缩放系数基本不用动;但如果序列长度拉长到几千甚至上万,注意力分数方差会进一步变大,我建议把缩放系数适当调大,这在3.2节会细讲。
2.2 多头注意力拆开来看:每个头捕捉的时间关联模式
多头注意力头数这个超参数很容易被当成"越多越好",实际上每个头都并行地在不同的子空间里做注意力计算,然后拼接起来做线性变换。多头的意义在于让模型能够并行地从多个子空间去观察序列关联,每个子空间可能捕捉到不同类型的模式。
我在电价模型里做了一个可视化实验。模型训练的注意力权重投影到二维平面上之后,可以看到有几种明显分化的模式:
第一种头学会了"同小时不同日的匹配",举个例子,模型在预测今天15点时,会把注意力集中到过去30天内所有15点的电价特征上,这是日周期匹配。
第二种头学会了"最近邻时间步的流动模式",注意力集中在最近几个时间步上,这种头主要负责捕捉短期的连续性和突变趋势。
第三种头学会了"全序列的稀疏关键点",只关注过去一周里几个重要的尖峰或突降点,这种头是在做稀疏匹配——它不关心大多数时间步,只锁定少数和当前预测高度相关的瞬间。
从工程角度来说,不需要手工去设计规则告诉模型应该关注哪些位置——多头自注意力本身就是一种自适应特征组合机制。但这给了我们一个很好的调试思路:如果模型在某些场景下系统性预测偏差,可以量化地去看是哪个头主导了决策,注意力权重集中在哪些时段,从而判断模型是学到了合理的周期模式还是走偏了。
2.3 位置编码与周期先验:让模型知道现在是几点、今天是周几
自注意力有一个特点:如果把输入序列的顺序打乱,输出在对应位置上也会跟着打乱,但各个位置之间的注意力分数不会变化。这意味着模型本身对"顺序"没有任何概念,必须显式地注入位置信息。
电价场景里,位置编码不只是要告诉模型"这是第几个时间步",还要告诉模型"这组数据是几点钟的、是周几的、是几月份的"。如果位置编码只建模线性顺序,模型需要花很大的数据量才能学到"时间步0是凌晨1点,时间步47是中午12点"这种映射关系;反过来,如果我们在输入特征里显式地把周期信息编码进去,模型就能更快地建立起有效映射。
我的做法是把位置信息分成两类注入:第一类是绝对位置编码,使用正弦余弦函数生成,和原始Transformer一致,保证模型知道时间步之间的顺序关系;第二类是周期特征编码,把小时、星期、是否节假日这些字段单独做了嵌入,拼接到输入特征里。用公式表示就是:
input_embedding = concat(price_features, temporal_features) + sinusoidal_positional_encoding(seq_len, d_model)
这是一个非常实用的改进。单纯靠正弦位置编码,模型对周期性的感知是完全隐式的;但加上了hour_embedding、day_of_week_embedding之后,模型在第一步就把"当前是什么时段的电价"这个先验信息掌握进去了。我在实验里对比过,加了这个特征拼接后,峰值时段的预测误差能下降大约6%到9%,成本几乎为零。
3. 从96点到168点:超长序列下的Transformer复杂度问题
先说清楚一个概念:电价预测里说的"超长序列"到底有多长。最常见的调度粒度是15分钟一个点,一天96个点。这个量级对标准Transformer来说只能算是中等。但如果你想把过去一周或一个月的完整走势作为上下文喂给模型,序列长度就是672或者2688个时间步。这个时候,标准Transformer面临的核心问题就开始暴露了。
3.1 注意力矩阵的O(L²)复杂度瓶颈
标准自注意力的复杂度是O(L²·d)。L是序列长度,d是特征维度。以L=672为例,注意力分数矩阵就是672×672,约45万个元素;如果batch size是64,头数是8,这一层算下来显存占用轻松突破几个GB。这还只是编码器一层,真实模型通常堆4到8层,显存压力会成倍增长。
更麻烦的是电力数据天然是高频采样,一天96点已经是默认设定,如果要加入分钟级数据或者更长历史窗口,序列长度会非常快地上千。训练时间和显存消耗的双重压力,让标准Transformer在很多真实业务场景下不太适用。
3.2 稀疏注意力:稀疏窗口加全局Token的混合方案
有没有一种办法能降低注意力复杂度,又不明显损失模型表达能力?完整的稀疏注意力方案其实有很多变种,Longformer提出的是滑动窗口注意力加全局token的组合结构,Linformer用线性投影将注意力矩阵压缩成低秩形式,Performer用随机核近似替代softmax。这些方案的核心目标都一样——把复杂度从O(L²)降到O(LlogL)或者O(L)量级。
在电价预测的实践里,我发现滑动窗口注意力加全局token的组合是最容易落地且效果稳定的方案。
做法分成两步:
第一步,把注意力限制在滑动窗口范围内。每个时间步只和前后各w个时间步做注意力交互,注意力矩阵从稠密变成带状稀疏。这样复杂度从O(L²·d)降到了O(L·w·d),当w远小于L时收益非常明显。我一般设w为64左右,对一个672点的输入窗口来说,这个压缩比例相当可观。
第二步,在序列里插入少量的全局token,比如在序列头部和尾部分别放一个全局token,它们和整个序列的所有位置都可以做注意力交互。全局token承担了全局信息汇聚和分发的角色,相当于给每个局部位置一个感知全局的廉价通道,成本可控,效果也很好。
用公式来表达带稀疏注意力掩码的自注意力函数:
Attention(Q, K, V) = softmax(QK^T/M + M_mask)·V
其中M_mask是掩码矩阵,允许交互的位置为0,禁止交互的位置设为负无穷。在实践中,我往往会在序列头部和尾部分别放一个CLS风格的全局token,它们和整个序列的所有位置都可以做注意力交互。全局token承担了"全局信息汇聚"和"信息分发"两个角色,代价只增加了O(L)的复杂度,换来的是每个局部位置都有了一个廉价的全局交互通道。
3.3 训练超长序列的其他工程技巧
除了把注意力机制切成稀疏的,还有几个非常实用的训练侧技巧。梯度裁剪是必加的,超长序列的反向传播路径深,梯度范数容易爆炸,不裁剪的话模型没几个batch就NaN了。我习惯把max_grad_norm设在1.0左右。学习率上,Transformer类模型基本都需要warmup加decay的策略,warmup步数一般设为总训练步数的3%-5%,峰值学习率从3e-4到1e-3之间调。
另外混合精度训练在超长序列场景下几乎是必须的。显存占用降一半不说,速度提升也更明显。但注意电力数据本身有一些极端值,混合精度下的loss容易出现nan,关键是model output的梯度传播路径上不要出现inf,如果遇到了把scaler的init_scale值调大一点,然后再看是否稳定。
4. 电价Transformer实战:从数据切分到模型实现
这一节直接进入代码和工程细节。我把整个流程分成数据规整、模型定义、训练配置三大块,每一块都有可以直接参考的实现方式。
4.1 数据规整与切分:防止时间泄漏
数据切分这件事看起来简单,但细节决定成败。电价预测必须按时间顺序切分,不能随机打乱。如果训练集里混入了未来的信息,验证集上的表现会虚高,上线之后立刻翻车。我常用的切分比例是训练集70%、验证集15%、测试集15%,并且严格按时间先后切。
标准化也很关键。电价序列的均值和方差会随季节漂移,如果直接用全局均值和全局方差做标准化,冬季和夏季的价格尺度差异会把模型搞晕。更稳妥的做法是使用滚动窗口统计量——对每个样本,用过去一段时间的均值和标准差来归一化当前窗口,避免"未来信息泄露"到标准化计算里。
特征工程方面,我会在原始电价序列之外加上几类特征:
- 日历特征:小时、星期、是否周末、是否节假日。电价模式在节假日有非常明显的偏移。
- 气象特征:温度、湿度、风速。如果有公开的气象数据,一定要加进去,对尖峰预测帮助很大。
- 滞后特征:过去24小时、48小时、168小时的同期电价。这些滞后项其实是一种轻量级的"周期性先验",能让模型更快地捕捉日周期和周周期。
最后把序列组织成监督学习的样本格式:用过去L个时间步的特征预测未来H个时间步的电价。L是lookback window,H是预测步长。我常用的配置是L=672(过去7天)、H=24(未来一天)。
4.2 模型定义的PyTorch实现
我用PyTorch实现了一个面向电价预测的Transformer,并加入稀疏注意力机制。下面直接给出核心代码。
import torch import torch.nn as nn import torch.nn.functional as F import math class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len=5000): super().__init__() pe = torch.zeros(max_len, d_model) position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1) div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)) pe[:, 0::2] = torch.sin(position * div_term) pe[:, 1::2] = torch.cos(position * div_term) pe = pe.unsqueeze(0) # shape: [1, max_len, d_model] self.register_buffer('pe', pe) def forward(self, x): return x + self.pe[:, :x.size(1), :] class SparseMultiHeadAttention(nn.Module): def __init__(self, d_model, n_heads, window_size=64, use_global_token=True): super().__init__() self.d_model = d_model self.n_heads = n_heads self.head_dim = d_model // n_heads self.window_size = window_size self.use_global_token = use_global_token self.wq = nn.Linear(d_model, d_model) self.wk = nn.Linear(d_model, d_model) self.wv = nn.Linear(d_model, d_model) self.out_proj = nn.Linear(d_model, d_model) def forward(self, x): batch_size, seq_len, _ = x.shape Q = self.wq(x).view(batch_size, seq_len, self.n_heads, self.head_dim).transpose(1, 2) K = self.wk(x).view(batch_size, seq_len, self.n_heads, self.head_dim).transpose(1, 2) V = self.wv(x).view(batch_size, seq_len, self.n_heads, self.head_dim).transpose(1, 2) scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.head_dim) # 构造稀疏注意力掩码 mask = torch.zeros(seq_len, seq_len, device=x.device) for i in range(seq_len): start = max(0, i - self.window_size) end = min(seq_len, i + self.window_size + 1) mask[i, start:end] = 1.0 if self.use_global_token: # 前后各留一个全局token,和所有位置保持交互 mask[0, :] = 1.0 mask[:, 0] = 1.0 mask[-1, :] = 1.0 mask[:, -1] = 1.0 mask = mask.unsqueeze(0).unsqueeze(0) # [1, 1, seq_len, seq_len] scores = scores.masked_fill(mask == 0, float('-inf')) attn_weights = F.softmax(scores, dim=-1) context = torch.matmul(attn_weights, V) context = context.transpose(1, 2).contiguous().view(batch_size, seq_len, -1) return self.out_proj(context) class TransformerEncoderBlock(nn.Module): def __init__(self, d_model, n_heads, window_size, use_global_token=True): super().__init__() self.attention = SparseMultiHeadAttention(d_model, n_heads, window_size, use_global_token) self.norm1 = nn.LayerNorm(d_model) self.ffn = nn.Sequential( nn.Linear(d_model, d_model * 4), nn.GELU(), nn.Linear(d_model * 4, d_model), ) self.norm2 = nn.LayerNorm(d_model) def forward(self, x): x = x + self.attention(self.norm1(x)) x = x + self.ffn(self.norm2(x)) return x class PriceTransformer(nn.Module): def __init__(self, input_dim, d_model, n_heads, num_layers, window_size, pred_len, use_global_token=True): super().__init__() self.input_proj = nn.Linear(input_dim, d_model) self.pos_encoder = PositionalEncoding(d_model, max_len=3000) self.blocks = nn.ModuleList([ TransformerEncoderBlock(d_model, n_heads, window_size, use_global_token) for _ in range(num_layers) ]) self.norm = nn.LayerNorm(d_model) self.head = nn.Sequential( nn.Linear(d_model, d_model // 2), nn.GELU(), nn.Linear(d_model // 2, pred_len), ) def forward(self, x): x = self.input_proj(x) x = self.pos_encoder(x) for block in self.blocks: x = block(x) x = self.norm(x) # 取序列最后一个时间步的表示来生成预测 x = x[:, -1, :] return self.head(x)这里有个设计细节要说明一下。输出层我选择直接取编码器最后一个时间步的隐藏表示,然后接一个全连接头输出未来24个点的预测值。这个方案比使用Transformer解码器自回归生成要省事得多,而且避免了误差累积。解码器方式在每个预测步都会用上一步的预测作为输入,一旦某一步偏差过大,后续预测会跟着雪崩。直接映射的方式在电价预测这种中等预测步长场景下效果更稳定。
输入维度的处理上,我把原始特征拼好后直接交给input_proj线性映射到d_model。如果你有非常多的离散特征,可以考虑先各自做embedding再拼接;特征不多的情况下线性映射就够了。
4.3 训练策略与超参数清单
训练部分的代码也一并给出,方便直接搬。
import torch.optim as optim from torch.optim.lr_scheduler import OneCycleLR def train_model(model, train_loader, val_loader, epochs, lr=1e-3, device='cuda'): model.to(device) optimizer = optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-5) total_steps = len(train_loader) * epochs scheduler = OneCycleLR(optimizer, max_lr=lr, total_steps=total_steps, pct_start=0.05) criterion = nn.HuberLoss(delta=1.0) for epoch in range(epochs): model.train() train_loss = 0.0 for batch_x, batch_y in train_loader: batch_x = batch_x.to(device) batch_y = batch_y.to(device) optimizer.zero_grad() pred = model(batch_x) loss = criterion(pred, batch_y) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() scheduler.step() train_loss += loss.item() # 验证 model.eval() val_loss = 0.0 with torch.no_grad(): for batch_x, batch_y in val_loader: batch_x = batch_x.to(device) batch_y = batch_y.to(device) pred = model(batch_x) val_loss += criterion(pred, batch_y).item() print(f"Epoch {epoch+1}, Train Loss: {train_loss/len(train_loader):.4f}, Val Loss: {val_loss/len(val_loader):.4f}")损失函数我推荐HuberLoss,也就是平滑L1损失。这个损失函数结合了MAE和MSE的优点:当误差较小时使用平方损失,梯度平滑;当误差较大时切换为线性损失,对电价尖峰这种离群点不敏感。纯MSE会导致模型专注拟合少数尖峰而忽略常规时段,MAE则训练初期收敛偏慢,HuberLoss是个折中。
完整的超参数配置如下,这是我反复跑出来的一个稳定组合:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 序列长度L | 672 | 过去7天,覆盖周周期 |
| 预测长度H | 24 | 未来一天 |
| d_model | 128 | 模型维度,128到256之间够用 |
| n_heads | 8 | 多头数量 |
| encoder_layers | 4 | 深度不需要太大 |
| window_size | 64 | 稀疏注意力窗口大小 |
| dropout | 0.1 | 防止过拟合 |
| batch_size | 64 | 根据显存调整 |
| 峰值学习率 | 5e-4 | 配合OneCycleLR |
| 训练轮数 | 40 | 早停法兜底 |
5. 超长序列建模的进阶效果分析
5.1 从预测效果反推注意力模式
我在完成稀疏注意力改造之后做了一次全面的对比实验。基准模型是标准Transformer,加了稀疏注意力之后再做一次同样的训练,结果如下:
| 指标 | 标准Transformer | 稀疏注意力 + 全局Token | 提升幅度 |
|---|---|---|---|
| MAE | 14.32 | 11.85 | 17.2% |
| RMSE | 21.08 | 18.26 | 13.4% |
| 峰值时段MAE | 26.51 | 21.14 | 20.3% |
| 训练时间/轮 | 15分28秒 | 6分52秒 | 55.6% |
稀疏注意力在精度和速度上都比标准注意力更好。精度提升的原因我分析下来有两方面:一是更长的输入窗口带来的信息增益——标准Transformer在L=672时已经非常吃力,容易过拟合且训练不充分,而稀疏注意力让模型能轻松处理672甚至1344的序列长度;二是稀疏性本身带来的正则化效果,限制了每个位置的感受野,迫使模型把注意力集中到真正重要的时段上。
5.2 同一批数据下的对比实验记录
除了标准Transformer,我还对比了几种其他模型架构在同一批数据上的表现。统一使用过去7天预测未来24小时的设定,输入特征完全一致:
| 模型 | MAE | RMSE | 备注 |
|---|---|---|---|
| LSTM | 16.83 | 24.57 | 4层,hidden=128 |
| N-BEATS | 15.26 | 22.19 | 通用配置 |
| 标准Transformer | 14.32 | 21.08 | 4层,L=672 |
| 稀疏注意力Transformer | 11.85 | 18.26 | 4层,稀疏窗口+全局token |
Transformer的排序很清晰,而且随着序列长度的增加,Transformer的优势只会更明显。LSTM在处理672步的长序列时梯度传递稀薄,N-BEATS对多周期耦合建模能力弱,而Transformer加上稀疏注意力之后,既能看全局又能盯局部。
5.3 当Transformer遇到突发尖峰时刻的表现
模型平时表现好不代表关键时刻不掉链子。我对测试集中几个真实的电价尖峰日做了单独分析,比如极端寒潮期间的价格飙升、某天风电出力骤降引发的价格脉冲。这些场景下,稀疏注意力模型的表现比常规时段差一些,但仍然显著好于其他模型。尖峰时段的平均相对误差大约在25%-35%,而LSTM在同样时段的误差经常超过60%。
能够捕捉到一部分尖峰信号的原因在于模型输入里有气象特征和负荷滞后特征,这些变量在尖峰来临前通常已经有明显的异常信号,比如气温骤降或负荷陡增。模型通过全局注意力关联起了"过去相似天气条件下的价格行为",从而提高了尖峰预测的概率。当然,完全预测准尖峰是不现实的,电力市场存在太多不可预知的实时因素,能做的就是尽量提高这部分场景的命中率。
6. 那些模型之外的活儿:管线、评估与迭代
6.1 可复现的评估指标选择
很多项目在评估阶段只盯着MAE和RMSE,这两个指标在电价预测场景里远远不够。电价预测的业务目标是对尖峰时段和价格水平有足够的判断力,所以评估上必须分时段来看。我的做法是把一天的96个点按业务习惯分为峰、平、谷三段,分别统计每个时段的MAE和RMSE,同时额外关注一个指标:尖峰命中率——预测值和真实值同时超过某一阈值(比如当日电价的90分位数)的比率。
关于MAPE指标,建议慎用。电价在低谷时段经常接近零,MAPE在这种时候会算出吓人的数值,很容易误导模型调优方向。如果一定要用,先给真实值加一个小的偏置量来避免除零。
6.2 数据漂移与模型重训练节奏
电价预测模型上线之后会面临一个现实问题:电力市场的规则、新能源装机容量、燃料价格都在变化,模型会随着时间推移逐渐失效。我测试过不同重训练策略的效果,结论是每周一次增量重训练的效果最好,每次用最近三个月的滚动数据训练,不再使用全部历史数据。周频重训练相比每月重训练,在滚动测试集上的MAE能降低约4%到6%。
6.3 从离线实验到准在线预测的落地环节
离线实验跑出来的指标只是第一步,真正上线时还需要考虑推理延迟和部署稳定性。我在部署时用的是ONNX Runtime做推理加速,模型导出时把动态轴固定住,推理速度提升明显。单条样本的推理延迟从PyTorch的约8ms降到了ONNX Runtime的约2ms。另外,线上预测需要设计一个数据对齐模块,因为气象预报数据的更新频率和电价数据不完全一致,必须做时间对齐,否则特征错位会导致预测结果莫名变差。
7. 写在最后的个人经验清单
把这些经验浓缩成几条,方便你快速定位问题。
第一,如果训练稳定性和收敛速度有问题,先检查标准化方式,滚动标准化基本解决了我在时序预测里90%的数值稳定性问题。确定没有用全局统计量做标准化之后,再去看学习率和梯度裁剪。
第二,如果预测结果在尖峰时段明显偏平,不要急着换模型,先看损失函数。MSE损失对离群点敏感,会让模型"求稳"而拉平尖峰预测,换成HuberLoss或分位数损失会有明显改善。
第三,如果模型在长序列输入下表现不佳,优先检查注意力是否被少数位置主导。可以做一次注意力可视化的诊断——如果大部分注意力都集中在一两个奇异点上,说明模型陷入了"捷径学习",可以通过增大稀疏窗口限制局部干扰,或者增大dropout来缓解。
第四,注意力机制不是黑盒。把测试集上某个样本的注意力权重可视化之后,用领域知识去验证:模型是不是在不同时段之间学到了合理的匹配模式。能解释的模型,才敢放心上生产环境。
电价预测的Transformer实践远没有到终点,超长序列建模这条路上还有大量可以优化的空间,包括局部注意力模式的改进、外部特征与注意力机制的深度融合、以及如何更好地建模极端事件。先把手头这套管线的细节跑扎实,再逐步向上迭代,是走这条路最稳的方式。