聊个比较新的时序预测架构:PhaseFormer。如果你最近在折腾长序列预测,应该已经注意到,这类问题早就不是“把LSTM堆深一点”就能解决的了。Transformer入局后,PatchTST、TimesNet这些名字轮番出场,但真正把“分块(patch)”和“相位(phase)”放到一起作为核心设计思想的网络,PhaseFormer是目前比较值得花时间拆解的一个。它的核心思路一句话就能说清:先把原始时间序列切成若干片段(也就是标题里说的“补丁”),再把这些片段映射到相位空间,让网络去学“相位演化”而不是直接死磕原始波形。
这篇博文适合三类人看:一是做时序预测项目、想找一个新的基线模型来对比效果的工程师;二是刚接触时序深度学习、想搞明白patch机制和相位表示到底在做什么的研究生;三是已经在跑Transformer类预测模型、但觉得结果总是“太钝、太平滑”想换思路的人。接下来我会把PhaseFormer的设计动机、核心模块、实现细节和训练中我踩过的坑都过一遍,代码部分基于PyTorch给出可直接改的参考实现。
1. 这个网络到底想解决什么问题
1.1 长序列预测的三座大山:误差累积、分布漂移、计算开销
先说说为什么要搞这么一个新的网络结构。长序列预测(Long Sequence Forecasting)和短序列预测完全不是一回事。短序列你拿个ARIMA、LightGBM加一堆滞后特征都能打,但一旦要预测未来几十个甚至上百个时间点,问题就变得非常不讲武德。
第一座大山是误差累积。传统做法是“递推预测”,预测出t+1,把它当输入再去预测t+2,这样每一步的小误差都会像滚雪球一样放大。到第50步时,预测序列基本已经变成一条水平线了,这就是为什么现在主流模型越来越喜欢“直接多步预测”(Direct Multi-Step),一次把未来50步全部输出。PhaseFormer走的就是这个路线。
第二座大山是分布漂移。训练数据里看到的分布,预测阶段往往已经变了,尤其是带有明显趋势和周期成分的序列。你让网络死记硬背训练集里的均值、方差,换一段新数据就原形毕露。所以模型必须学某种“结构不变性”,周期、相位、相对变化趋势,这些比绝对值更稳定。
第三座大山是计算开销。标准Transformer用全局注意力,复杂度是O(n²)。序列长度一上去,显存和时间都受不了。Patch化其实最早就是为了解决这个问题的,把n个点压缩成n/p个片段,注意力长度大幅缩短。PhaseFormer继承了这一优点,同时希望通过相位表示进一步降低建模难度。
1.2 “补丁到相位”解决了什么问题
我最早看到“从补丁到相位”这个说法,第一反应是:“补丁”是不是软件补丁?当然不是。这里的补丁就是patch的直译,意思是把时间轴切成一块一块的片段。这名字其实取得挺形象:每个patch就像打在上面的一个补丁,把局部信息缝起来。
那“到相位”又是怎么回事?如果你做过信号处理,对“相位phase”这个词一定不陌生:正弦波里,相位决定了波形在某个时刻处于上升、峰值、下降还是谷值,它描述的是“循环状态”。PhaseFormer借用了这个思想,但不是简单地对原始序列做傅里叶变换取相位角,而是通过神经网络把每一个patch映射到一个高维相位空间,让“片段A当前处于周期的哪个阶段”“片段B比片段A领先了多少”这些信息变得可计算、可学习。
这样做的好处是显而易见的:对于带有周期性、季节性、节律性的数据——电力负荷有日周期和星期周期,交通流量有早晚高峰,股票虽然没有严格周期但也有明显的节律性——网络不需要从头去硬拟合每个点的绝对值,而是学习“相位差”。相位差这个量在时间平移下是不变的,所以模型对数据分布漂移的抵抗力更强,这正是长序列预测最需要的东西。
1.3 适用场景边界:先用它,还是别用它
不看场景就吹模型,是时序预测最容易犯的错误。PhaseFormer并不是万能药,我实际测试下来的感受是,它有三个非常对口的场景。
第一类是高周期性的数据。电力负荷、气象温度、能耗监控、传感器读数,这些序列有稳定或半稳定的周期,Effect非常明显。第二类是“多尺度周期叠加”的数据,比如同时存在日周期和星期周期的序列,patch加相位的方式可以在不同尺度上捕捉这种叠加关系。第三类是训练数据相对稀缺的场景,由于相位表示本质上是把原始信号的复杂模式压缩成更少但更本质的自由度,模型在小样本下比普通Transformer更不容易过拟合。
反过来,如果你面对的是纯随机事件驱动型序列,比如某APP的突发流量、完全没有规律的异常告警计数,那PhaseFormer的优势就不明显了。相位描述的是“循环状态”,一个没有周期性的序列,相位学出来也是噪声,效果可能还不如一个简单的MLP加滞后特征。这是第一点要先提醒的:先看清楚数据里有没有周期结构,再决定要不要上这个模型。
2. 整体结构与设计思路拆解
2.1 patch长度和步长怎么选:周期长度是第一参考系
PhaseFormer的第一步是序列分块。这一步看起来简单,实际却是整个模型效果的分水岭。我见过不少人把patch_len直接设成Transformer里常见的16、32,然后发现效果还不如DLinear,问题就出在“尺度匹配”上。
这里的核心原则是:patch长度应该和数据的周期结构挂钩,而不是拍脑袋。如果序列有一个明显的周期(比如24小时),patch_len取周期的四分之一到二分之一,也就是6到12,是比较合理的区间。为什么不是取整个周期?因为单个patch只负责提供“局部波形特征”,而跨patch的序列关系才是用来建模周期演化的。如果patch太长,每个patch内部就包含了好几个周期,相位信息的区分度反而下降。
另一个关键参数是步长stride,也就是相邻patch起点之间的距离。stride等于patch_len时就是非重叠切分,信息丢失少、计算开销小;stride小于patch_len时是重叠切分,能缓解边界信息丢失问题,但带来的冗余会明显拉长训练时间。我的习惯做法是:先用非重叠切分训练一个baseline,如果预测结果在周期切换点有明显毛刺,再尝试重叠率50%的切分。
还需要考虑一个容易被忽略的问题:patch数量。假设原始长度是T,patch_len是p,那么patch数约等于T/p。如果patch数太少,比如少于8个,主干Transformer的注意力机制基本学不到什么东西,因为注意力需要一定的序列长度才能发挥出上下文建模能力。这种情况下,要么把输入窗口拉长,要么减小patch_len。
2.2 如何把片段变成相位:复数域映射
分块完成之后,每个patch是一个p维向量。PhaseFormer要做的,是把这个向量映射到相位空间。这里最自然的方式是用复数域:一个复数可以分解成模长和幅角,模长对应片段的“强度/幅值”,幅角对应片段的“相位/状态”。
具体做法是用一个线性层或一维卷积把每个patch的向量投影到复数域。严格说,神经网络不能直接操作复数,所以实现时是把向量映射到一个维度为“2×d_phase”的实数空间,前一半当作实部,后一半当作虚部。然后通过atan2计算幅角得到相位,通过模长公式得到振幅。这样每个patch最终被表示成一组“振幅向量+相位向量”,合起来其实就是极坐标下的高维表示。
为什么用极坐标而不是直接用直角坐标?因为相位这个量对于时间平移具有天然的周期不变性。两个相同波形、只是平移了两个采样点的patch,在直角坐标系下欧氏距离可能很大,但在极坐标系下只是相位角的整体偏移,网络学起来会轻松很多。另外,极坐标表示让“振幅和相位解耦”,网络可以选择性地放大对相位路径的学习,这是普通线性Embedding做不到的。
当然,这里要强调一下:“相位”不是只有傅里叶变换才能得到。PhaseFormer里的相位完全是由网络学出来的,没有显式的三角函数基。这一点理解起来比较绕,但反过来想更清晰:网络只需要一个能表征“循环相对位置”的空间,复数域恰好提供了这种几何结构。如果你所在领域对可解释性要求很高,可以在训练后把学习到的相位角做PCA或者投影到2D空间,往往能看到序列的周期轨迹。
2.3 主干网络信息流:相位演化建模
patch变成相位向量之后,就进入主干网络。主干的作用可以理解成“相位演化模型”:输入是一系列随时间排列的相位向量,输出是未来若干个时间步对应的相位向量。
主干网络可以用Transformer Encoder,这是最常规的选择,也是PhaseFormer名字里“Former”的来源。注意力机制天然适合建模“第i个patch相对于第j个patch的相位差关系”,因为attention score本身就是一种相对关系度量。不过这里有一个需要留意的地方:既然已经工作在相位空间,位置编码就不能乱用。如果直接加绝对位置编码,会把相位信息的“相对性”破坏掉;更合理的做法是像PatchTST一样只加可学习的通道表征,或者把位置信息也编码成相位差的旋转形式,让位置编码和相位表示在几何上兼容。
主干网络内部层数不需要很深。我做对比实验时发现,2到4层Encoder配上合适的patch大小,效果已经能超过6层Transformer直接作用在原始点上。原因是相位表示已经把非线性复杂度消化了一部分,主干网络不需要再承担“从零学习周期模式”的重担。如果你用卷积主干(比如TCN、Inception模块)也不是不行,但注意力在灵活建模跨patch长程依赖上更有优势。
解码端则是把预测出的相位向量连同振幅向量一路反变换回原始数值空间。常见做法是接一个全连接层,把每个patch的相位+振幅拼接成向量,再映射回patch_len维。预测输出时要注意:振幅和相位要同步预测,不能只预测相位。因为相位告诉我们“波形走到哪一步了”,但“这一段的强度有多大”还得由振幅决定,两者缺一不可。
2.4 损失函数设计:别把相位当作普通数值
损失函数是PhaseFormer最容易翻车的地方。如果你直接对相位向量算MSE,会遇到一个非常尴尬的问题:相位是一个圆上的量,2π和0在数值上距离很远,但在物理上完全一样。假设真实相位是359度,模型预测是1度,MSE会认为误差是358度,实际误差只有2度。这就是相位回绕问题。
解决思路有两种。第一种是把相位表示返回成复数后计算损失,也就是对比复平面上的实部、虚部,而不是直接对比角度。这样2π和0天然重合,梯度也连续。第二种是对角度差做一个周期规整操作:先算差值,加π,对2π取模,再减π,把差值映射到[-π, π]区间,然后再算平方误差。我在代码里实现的是第二种,简单直观,效果也不错。
除此之外,损失函数还可以加上径向损失。径向指的是振幅这一路,如果你只关注相位而不管振幅,模型最后预测出来的波形幅度会非常保守,整体偏向均值。所以在总损失里我会把相位损失和振幅损失分开设置权重,通常相位损失权重可以略微高于振幅损失,因为相位误差在长序列预测中导致的“错位惩罚”远大于幅度偏差。再往后可以加一个一阶差分损失,让相邻输出之间的变化趋势更平滑,这个对抑制过平滑也有一定帮助。
3. 实操实现与核心环节
3.1 数据预处理与patch数据集构建
先约定输入格式:一个形状为(B, C, T)的张量,B是batch大小,C是变量数,T是时间步数。PhaseFormer实际处理的不是原始点序列,而是一串patch嵌入序列,所以预处理阶段就要把T维切分并重排为patch序列。
切分前我强烈建议先做归一化。时序模型最常用的策略是实例归一化(Instance Normalization),也就是对每个样本、每个变量独立计算均值和标准差,然后做标准化;预测完成后再逆变换回原始尺度。这样做的好处是让模型专注学习形态,而不是被不同量纲的绝对值带偏。对PhaseFormer来说,归一化还有一个额外效果:它会放大序列中微小的周期结构,让相位学习更容易收敛。
切分时需要注意最后一段不足patch_len的情况。最省事的方法是直接丢弃尾部不足一个patch的样本点;但如果剩余部分占整体比例很高,比如超过10%,丢弃会浪费信息,更好的办法是做零填充然后加一个mask。实际使用中,如果窗口长度T选得合适——比如T能被patch_len整除——这个头疼问题基本可以避免,所以我在选窗口长度时会刻意让T=patch_len×N。
def create_patches(x, patch_len, stride=None): # x: (B, C, T) if stride is None: stride = patch_len B, C, T = x.shape num_patches = (T - patch_len) // stride + 1 patches = [] for i in range(num_patches): start = i * stride patches.append(x[:, :, start:start + patch_len]) return torch.stack(patches, dim=1) # (B, num_patches, C, patch_len)这里把patch维度放在第1维,后面送入编码器时再变形成(B, num_patches, C×patch_len)。
3.2 相位编码实现:atan2的数学细节
相位编码层的输入是经过线性投影的patch表示,输出是振幅和相位。实现时有两个关键细节,先说第一个:为什么用atan2而不是acos或者sin。
atan2(y, x)是四象限反正切函数,它能从实部和虚部直接得到角度,并且根据实部虚部的符号自动判断角度落在哪个象限。如果你用acos(real / amp),会因为浮点误差导致传进去的参数略大于1或略小于-1,直接出现NaN;如果用sin或cos单独表示相位,又会丢失角度唯一性。atan2是数值上最稳的选择。
第二个细节:相位向量的维度。我不会让每个patch只产生一个标量相位,因为单一标量无法表征复杂波形。实际做法是让每个patch产生d_phase个相位分量,也就是一组角度向量。比如d_phase取32,每个patch最终表示为一个32维相位向量加一个32维振幅向量。为什么要这么多维?因为真实数据的周期结构不是简单正弦波,它可能由多个不同频率的循环叠加而成,高维相位空间允许网络用不同维度去建模不同频率的循环成分,等于给模型配了多把“尺子”去量不同尺度的周期。
class PhaseEncoder(nn.Module): def __init__(self, patch_len, n_vars, d_model, d_phase): super().__init__() self.input_proj = nn.Linear(patch_len * n_vars, d_model) self.phase_proj = nn.Linear(d_model, 2 * d_phase) def forward(self, patches): # patches: (B, num_patches, C, patch_len) B, N, C, P = patches.shape x = patches.reshape(B, N, C * P) x = self.input_proj(x) # (B, N, d_model) z = self.phase_proj(x) # (B, N, 2 * d_phase) real, imag = z.chunk(2, dim=-1) amplitude = torch.sqrt(real ** 2 + imag ** 2 + 1e-6) phase = torch.atan2(imag, real) # 角度范围 [-pi, pi] return amplitude, phase3.3 模型骨架代码:PyTorch实现
把上面的模块串起来,一个最小可运行的PhaseFormer参考实现大概长这样。这里我刻意省略了很多花哨技巧,保留最核心的路径:patch化、相位编码、Transformer主干、解码输出。
import torch import torch.nn as nn class PhaseFormer(nn.Module): def __init__(self, n_vars, patch_len, stride, d_model=128, d_phase=32, n_layers=3, pred_len=24): super().__init__() self.patch_len = patch_len self.stride = stride self.encoder = PhaseEncoder(patch_len, n_vars, d_model, d_phase) self.pe = PositionalEncoding(d_model=d_phase * 2) encoder_layer = nn.TransformerEncoderLayer( d_model=d_phase * 2, nhead=4, dim_feedforward=256, batch_first=True, dropout=0.1 ) self.transformer = nn.TransformerEncoder(encoder_layer, num_layers=n_layers) self.head = nn.Linear(d_phase * 2, patch_len) def forward(self, x): # x: (B, C, T) patches = create_patches(x, self.patch_len, self.stride) # (B, N, C, P) amp, phase = self.encoder(patches) # (B, N, d_phase) feat = torch.cat([amp, phase], dim=-1) # (B, N, 2*d_phase) feat = self.pe(feat) feat = self.transformer(feat) out = self.head(feat) # (B, N, patch_len) # 这里只做了“patch级映射”,实际使用时还要还原回点级序列 return out这个实现是简化的,重点是展示信息流。真正工程落地时,解码端通常不是直接全连接输出patch_len,而是先把整个patch序列通过一个Conv1d上采样还原到原始分辨率,再截取pred_len步。另外,PhaseFormer预测的应该是未来若干个patch,而不仅是当前patch的还原,所以实际输出头要设计成“将最后的patch表示线性变换成pred_len/patch_len个未来patch的振幅和相位”,然后再重构波形。
3.4 训练配置与指标定义
训练配置这部分,我直接给出自己跑通后比较稳的一组经验值。优化器用AdamW,初始学习率1e-3,配合CosineAnnealingLR逐步降到1e-5;weight_decay设为1e-4;batch大小取决于序列长度,一般16到64;训练轮数50到100轮,配合早停。
损失函数部分,刚才说过的相位规整MSE是核心。我在实际项目里用它的变体:
def periodic_mse(pred_phase, true_phase): diff = (pred_phase - true_phase + torch.pi) % (2 * torch.pi) - torch.pi return torch.mean(diff ** 2)振幅损失用普通MSE。总损失记为:L = λ_phase × periodic_mse + λ_amp × mse(amp_pred, amp_true) + λ_diff × 差分损失。λ_phase我会设得大一点,比如1.0,λ_amp设0.5,λ_diff设0.1到0.3之间。如果你发现预测结果过于尖锐、抖动明显,就把λ_diff调大;如果预测太钝、细节丢失,就调小。
评价指标上,除了通用的MSE、MAE、sMAPE之外,我强烈建议加一个自定义的“相位误差指标”。做法是把预测序列和真实序列都做希尔伯特变换,提取瞬时相位,然后计算相位差的绝对值平均。这个指标对周期错位非常敏感,一旦模型学歪了,它比MSE更能暴露问题。别小看这一步,很多情况下MSE降了,但预测曲线和真实曲线整体错位一个相位,肉眼看着极其别扭,可数值却“看起来不错”,相位误差指标能堵住这个漏洞。
4. 训练过程中的常见问题与排查
4.1 相位回绕:MSE在2π边界上翻车
这个问题我在2.4节提过,但实际踩坑时远远比理论描述要严重。第一次跑PhaseFormer时,我没有用相位规整损失,直接对角度做MSE,训练到一半loss开始剧烈震荡,验证集甚至出现过loss飙到几千的情况。排查之后发现就是相位回绕造成的:当预测角度越过π边界时,误差忽然从一个很小的值跳到接近2π,梯度也跟着爆掉。
解决方案就是前面那个periodic_mse,所有涉及相位的损失都必须做“圆上的距离”计算。另外还有一个更稳妥的办法:不要直接输出角度,而是让网络输出经过单位化的复数向量,损失也定义在复数空间。这样网络永远学不到“跨越边界”的概念,自然也就不存在回绕问题。两种方案我都试过,相位规整MSE在收敛速度上有优势,复数损失在极端长序列上更稳,建议大家根据数据情况都跑一版对比。
4.2 patch大小与预测步长的尺度匹配
很多人在PhaseFormer上效果差,不是模型问题,是patch_len和pred_len的比例没配对。设想一下,你要预测未来24小时,patch_len却设成2,那么模型需要预测12个patch才能凑满24步,任何一个patch的相位预测失误都会被放大;反过来patch_len设成48,那模型只能输出半个patch的预测信息,细节几乎全部丢失。
我自己的经验公式是:patch_len设置在预测步长的1/4到1之间。如果预测步长是24,patch_len可以取6、12、24,分别代表“细粒度”“中粒度”“粗粒度”三档,用验证集去选。换个角度想:预测的本质是“外推”,如果每个输出patch太窄,模型学的更多是噪声;太宽,又容易丢失相位切换点。这个平衡只能通过实验找,没有绝对正确答案。
顺带提一句:输入窗口长度也要跟着调整。输入窗口太短,只有三四个patch,注意力机制几乎不起作用;太长,训练开销又大。我的默认值是让输入窗口产生至少16个patch,最好32个。以patch_len=12为例,输入长度T就是192到384个时间点,这在长序列预测里属于比较合理的范围。
4.3 曝光偏差与预测过平滑
预测过平滑是几乎所有transformer时序模型的通病,PhaseFormer如果不做特殊处理,在长预测长度上也会出现“预测曲线变成均值线”的问题。原因在于解码时,模型倾向于输出一个“保险的中间值”来最小化平均误差,尤其是使用MSE损失时,中位数/均值确实是最小化L2误差的解。这不是PhaseFormer独有的问题,但相位表示会稍微缓解它,因为相位本身是周期性的,不容易被困在均值上。
缓解过平滑有几个实际操作:一是差分或去趋势预处理,把序列变成平稳序列后再训练;二是用分位数损失或负对数似然损失替代纯MSE,逼迫模型给出更明确的分布;三是输入输出都增强高频成分,比如对原始序列做前向差分并作为额外通道喂给模型。我实测下来,效果最明显的是第三条,代价是训练时间增加30%左右。
曝光偏差主要出现在递推解码的变体里。如果你把PhaseFormer改造成自回归模式,也就是用预测出的patch替换输入到下一步,那么训练时一定要加真实patch混合(Scheduled Sampling)或者直接用并行解码。我的建议很干脆:长序列预测尽量用一次并行输出所有patch,不要自回归。自回归的误差累积成本远比收益高。
4.4 指标不涨,先检查归一化与基准划分
模型跑了十来个epoch,loss死活不降,不要急着调网络结构,先检查三个地方。
第一是归一化方向。时序预测里常见的坑:对全序列算均值方差,导致标准化后的训练数据“看到了未来”,验证集和测试集的信息泄漏到训练过程里,结果指标虚高,模型实际部署时被打回原形。正确做法是只用训练集的统计量来归一化训练集和测试集,或者在每个样本内部独立归一化。PhaseFormer更推荐后者,因为样本内的均值方差对应“相对波动”,和相位表示的思路一致。
第二是基准划分是否合理。很多人直接随机打乱数据划分训练集和验证集,这对时序预测是致命的。随机划分会让验证集里出现训练集未来时间段的数据,模型等于提前看到了答案,训练曲线自然一片祥和。时序数据必须按时间顺序切分:前70%训练,中间10%验证,最后20%测试。
第三是损失函数里各项的尺度不匹配。振幅损失的数值天然比相位损失大,如果直接把两个loss相加,梯度会被振幅主导,相位根本学不动。所以训练前先打印两个loss的初始值,把权重调成同一量级,我一般在0.1到10之间手动调一轮。
5. 我的实验体会与可扩展方向
5.1 我在实际跑模型过程中的几点体会
第一次跑通PhaseFormer时,最大感受是“数据形态变了,模型学到的东西完全不同”。同样的数据,普通Transformer学出来的是“每个时刻应该输出什么值”,PhaseFormer学出来的是“当前片段处在周期的什么位置、下一个片段应该往哪个相位方向走”。后者的泛化性明显更好,尤其当我换了一段不同年份的电力负荷数据做测试时,普通模型直接崩,而PhaseFormer只出现了轻微相位偏移。
但也要说句公道话:PhaseFormer的前置条件比较多。它对数据周期性的依赖很强,如果序列是近似随机的,相位空间基本是个无序球,模型很难学到有价值的结构。另外,它的调参维度比普通Transformer多了一层,patch_len和d_phase都需要额外搜索,对团队实验成本是不小的消耗。我建议把它作为一个高潜力基线,而不是无脑默认模型。
还有一个细节值得分享:训练PhaseFormer时,先把主干Transformer层数设为1,只训练相位编码和解码头,观察loss能否降到比较低的值,再逐渐加深主干。如果一层Transformer都训不下来,问题基本出在patch化或相位编码,而不是深度不够。这个“由简到繁”的排查策略能省不少试错时间。
5.2 后面值得继续扩展的几个方向
PhaseFormer这套“patch+相位”的框架其实还可以延伸出不少变体。一个方向是把它和多尺度patch结合:同时在多个patch_len下提取相位,然后融合,这样能同时建模日周期、周周期甚至月周期叠加的数据。另一个方向是引入概率输出,把相位和振幅建模成分布参数,配合似然损失,这样不仅能量化不确定性,还能天然缓解过平滑问题。还有一个我最近在试的方向,是把相位空间可视化作为数据探针:在训练好的模型里,把验证集的patch编码成相位向量,用UMAP投影到2D,你能直观看到数据点在圆环、球面或螺旋线上运动。这种做法对理解数据的周期结构非常有帮助,算是PhaseFormer带来的一个意外收获。
如果要给一个最小闭环建议,我会说:下次拿到一个新的时序预测任务,先花20分钟算一下数据有没有显著性周期,有,就认真调一版PhaseFormer;没有,直接上MLP基线。别让模型替你做数据探索的功课,合适的数据遇上合适的网络,才是这个架构真正发挥威力的前提。