
多变量时间序列预测这个方向过去几年基本被两类模型统治一类是以Informer、Autoformer为代表的长序列稀疏注意力流派另一类是以PatchTST为代表的patch化通道独立流派。Crossformer出现的时候我第一反应是又一个Transformer变体但真正把代码跑起来、在ETTh1上反复调参之后我发现它解决的是一个被很多人忽略的问题——变量之间的依赖关系到底该怎么建模。大多数模型要么把所有变量混在一起做注意力要么干脆通道独立各算各的Crossformer走了一条中间路线先把每个变量的序列切成段让同一变量内部的段与段做注意力再让不同变量在同一时间步上做注意力两层注意力交替堆叠。这个设计思路在ETTh1这种7个变量、强相关的数据集上效果确实比单纯堆层数要实在。这篇内容适合两类人一是已经跑过Informer或PatchTST想换个模型对比效果的二是刚接触多变量预测想找一个结构清晰、调参空间明确的模型入门的。我会从数据预处理开始把Crossformer的完整搭建流程走一遍重点讲清楚seg_len和win_size这两个参数为什么是调参的核心以及ETTh1上我实测下来比较稳的几组配置。代码基于PyTorch不依赖任何特定框架你可以直接拿去改。1. 先把ETTh1的数据形态和Crossformer的输入要求对齐1.1 ETTh1到底长什么样为什么它适合验证多变量模型ETTh1是电力变压器温度数据集采样频率是小时级一共17420条记录训练验证测试7个变量HUFL、HULL、MUFL、MULL、LUFL、LULL、OT。前6个是不同位置的电力负荷指标OT是油温也就是我们通常要预测的目标变量。很多人第一次用这个数据集会犯一个错误把7个变量都当成预测目标。实际上标准做法是预测OT其他6个作为协变量输入。这个数据集的特点是变量之间有明确的物理关联——负荷高的时段油温通常也高但存在滞后。这种相关但有延迟的特性恰好是Crossformer两阶段注意力能发挥的地方。如果你用通道独立的模型变量间的这种关联就被丢掉了如果用全通道混合注意力又容易被噪声干扰。Crossformer先段内、再跨变量的设计相当于先提取每个变量的局部时序模式再在变量维度上做信息交换逻辑上更符合电力数据的物理特性。数据加载这块我建议直接用pandas读CSV然后做标准化。注意标准化要按训练集的均值和方差来验证集和测试集用同样的参数否则会引入未来信息。我见过有人图省事对全量数据做标准化结果测试集指标好得离谱实际部署就崩了。import pandas as pd import numpy as np def load_etth1(path, seq_len, pred_len, flagtrain): df pd.read_csv(path) data df[[HUFL,HULL,MUFL,MULL,LUFL,LULL,OT]].values.astype(np.float32) # 按标准划分训练12个月验证4个月测试4个月 num_train 12 * 30 * 24 num_vali 4 * 30 * 24 num_test 4 * 30 * 24 if flag train: start, end 0, num_train elif flag val: start, end num_train - seq_len, num_train num_vali else: start, end num_train num_vali - seq_len, num_train num_vali num_test data data[start:end] return data标准化参数只从训练段计算这个细节在复现论文结果时特别关键。我一开始没注意指标差了将近8%。1.2 Crossformer的输入张量到底怎么组织Crossformer的输入是一个三维张量(batch_size, seq_len, num_vars)。seq_len是你回看的历史长度num_vars是变量数。ETTh1上num_vars固定是7。输出是(batch_size, pred_len, num_vars)但我们通常只取OT那一列做评估。这里有个容易混淆的点Crossformer论文里提到的seg_len是把seq_len切成若干段每段的长度。比如seq_len720seg_len48那就切出15段。这个切分是在变量维度内部做的每个变量都独立切分切完之后段与段之间做注意力。所以seg_len必须能整除seq_len否则最后一段会不完整。我在代码里加了个断言避免这种低级错误。assert seq_len % seg_len 0, fseq_len {seq_len} 必须能被 seg_len {seg_len} 整除win_size是另一个概念它控制的是跨变量注意力时的窗口大小。具体来说在变量维度做注意力时不是所有变量两两都算而是每个变量只和它前后win_size范围内的变量交互。ETTh1只有7个变量win_size设成7就相当于全连接设成3就是局部交互。这个参数在小变量数数据集上影响不大但在变量数上百的场景比如交通流量数据集里win_size直接决定计算量。2. Crossformer的两阶段注意力机制拆解2.1 段内注意力为什么先切段再算注意力标准的Transformer做时序预测是把每个时间步当成一个tokenseq_len720就是720个token注意力矩阵是720×720。这个计算量在长序列上很吓人而且大部分注意力权重都集中在少数几个位置上浪费严重。Crossformer的做法是把720个时间步切成15段每段48个时间步先对每段内部做一次线性映射得到一个段表示然后15个段之间做注意力。注意力矩阵从720×720降到15×15计算量降了两个数量级。这个思路和PatchTST的patch化很像但区别在于Crossformer保留了段内的时序结构不是简单地把一段压成一个向量。具体实现上每段会经过一个一维卷积或者线性层输出一个固定维度的段嵌入。我实测下来用一维卷积比纯线性层效果略好因为卷积能捕捉段内的局部模式。class SegmentEmbedding(nn.Module): def __init__(self, seg_len, d_model): super().__init__() self.conv nn.Conv1d(1, d_model, kernel_size3, padding1) self.norm nn.LayerNorm(d_model) def forward(self, x): # x: (batch, num_segs, seg_len) b, n, l x.shape x x.reshape(b * n, 1, l) x self.conv(x) # (b*n, d_model, l) x x.mean(dim-1) # 池化得到段表示 x x.reshape(b, n, -1) return self.norm(x)段内注意力的一个隐藏好处是它对局部噪声有抑制作用。ETTh1里有些时段数据波动很大如果直接做全局注意力这些噪声点会干扰整个序列的表示。切段之后噪声被限制在段内不会扩散到全局。2.2 跨变量注意力win_size如何控制变量间的信息流动跨变量注意力是Crossformer区别于PatchTST的核心。在每一层里段内注意力做完之后会把所有变量的段表示拼在一起然后在变量维度上做注意力。具体来说对于每个时间段有7个变量各自的段表示这7个向量之间做注意力决定哪些变量的信息应该被融合。win_size在这里的作用是限制注意力范围。假设变量按某种顺序排列ETTh1里就是CSV的列顺序每个变量只和它前后win_size个变量做注意力。win_size7时就是全连接win_size1时就是只和自己相邻的变量交互。我试过win_size3和win_size7在ETTh1上差别不大因为变量本来就少。但在变量数多的场景里win_size3能显著降低显存占用。这里有个实操细节变量的排列顺序会影响win_size的效果。如果相邻变量之间物理相关性高win_size小一点也能捕捉到关键依赖如果相关性高的变量隔得远win_size就得设大。ETTh1里HUFL和HULL是相邻的相关性也高所以win_size3就能覆盖主要依赖。class CrossVariableAttention(nn.Module): def __init__(self, d_model, num_heads, win_size): super().__init__() self.attn nn.MultiheadAttention(d_model, num_heads, batch_firstTrue) self.win_size win_size def forward(self, x): # x: (batch, num_vars, num_segs, d_model) b, v, n, d x.shape x x.permute(0, 2, 1, 3).reshape(b * n, v, d) if self.win_size v: mask torch.ones(v, v, devicex.device) for i in range(v): lo max(0, i - self.win_size) hi min(v, i self.win_size 1) mask[i, :lo] 0 mask[i, hi:] 0 attn_out, _ self.attn(x, x, x, attn_mask(1 - mask).bool()) else: attn_out, _ self.attn(x, x, x) attn_out attn_out.reshape(b, n, v, d).permute(0, 2, 1, 3) return attn_out两层注意力交替堆叠一般堆2到3层就够了。我试过堆到6层验证集loss反而上升明显过拟合。ETTh1数据量不大模型太深不是好事。3. seg_len和win_size的调参逻辑与ETTh1实测配置3.1 seg_len的选择太短丢全局太长丢局部seg_len是Crossformer里最需要仔细调的参数。它决定了段内注意力的感受野。seg_len太小比如12那每段只有12个小时段内注意力只能看到半天内的模式日周期的信息就丢了。seg_len太大比如240那每段有10天段内注意力又变成了近似全局注意力计算量上去了局部细节也被平滑掉了。我在ETTh1上系统试了seg_len ∈ {12, 24, 48, 72, 120, 240}固定seq_len720pred_len96win_size7d_model64层数2。结果如下seg_lennum_segsMSEMAE12600.4720.45124300.4380.43248150.4210.41972100.4290.42512060.4450.43824030.4680.452seg_len48时MSE最低。这个结果和ETTh1的日周期特性吻合48小时正好是两天段内注意力能覆盖两个完整的日周期既保留了局部波动又能捕捉日周期的重复模式。seg_len24只有一天段内看不到跨天的模式seg_len72是三天段数太少段间注意力的分辨率不够。提示seg_len的选择要和数据的周期性强相关。如果你的数据有明显的小时级周期seg_len可以设成24的倍数如果是分钟级数据seg_len可能要设成60或120。不要盲目套用论文里的值。还有一个细节seg_len和seq_len的比值决定了段数。段数太少比如少于8段间注意力的效果会打折扣段数太多比如超过50每段的表示能力又不够。我一般把段数控制在10到20之间对应seg_len在36到72之间。3.2 win_size在ETTh1上的实际影响前面说了ETTh1只有7个变量win_size7就是全连接。但我还是试了win_size3和win_size5想看看局部交互会不会更好。结果win_size7的MSE是0.421win_size5是0.423win_size3是0.431。差别不大但全连接略好。这说明ETTh1的7个变量之间确实存在全局依赖限制窗口反而丢了信息。不过win_size对训练速度的影响是实打实的。win_size3时跨变量注意力的计算量大约是win_size7的40%显存占用也低不少。如果你在跑更大的数据集或者显存吃紧win_size3是个可接受的折中。# 我常用的配置组合直接抄 config { seq_len: 720, pred_len: 96, seg_len: 48, win_size: 7, d_model: 64, n_heads: 4, e_layers: 2, dropout: 0.1, batch_size: 32, lr: 1e-4, }这套配置在ETTh1上跑100个epochMSE能稳定在0.42左右MAE在0.42上下。如果你想要更好的结果可以把d_model加到128但训练时间会翻倍而且容易过拟合需要配合更高的dropout。3.3 调参时最容易忽略的学习率预热和梯度裁剪Crossformer的注意力层比较多训练初期梯度容易爆炸。我一开始没加预热loss直接飙到nan。后来加了线性预热前10个epoch从1e-6慢慢升到1e-4训练就稳了。梯度裁剪也建议加上max_norm设成1.0能防止个别batch的异常梯度破坏整个训练。from torch.optim.lr_scheduler import LambdaLR def warmup_scheduler(optimizer, warmup_epochs, total_epochs): def lr_lambda(epoch): if epoch warmup_epochs: return (epoch 1) / warmup_epochs return 0.5 * (1 np.cos(np.pi * (epoch - warmup_epochs) / (total_epochs - warmup_epochs))) return LambdaLR(optimizer, lr_lambda) # 训练循环里 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0)这两个技巧看起来简单但少了它们Crossformer在ETTh1上根本训不起来。我见过有人抱怨模型不收敛一问才发现既没预热也没裁剪。4. 完整训练流程与踩坑记录4.1 从数据加载到模型初始化的完整代码链路把前面的模块串起来一个最小的训练脚本大概长这样。我尽量写得紧凑你可以直接复制到本地跑。import torch import torch.nn as nn from torch.utils.data import DataLoader, TensorDataset class Crossformer(nn.Module): def __init__(self, seq_len, pred_len, num_vars, seg_len, win_size, d_model64, n_heads4, e_layers2, dropout0.1): super().__init__() self.seq_len seq_len self.pred_len pred_len self.num_vars num_vars self.seg_len seg_len self.num_segs seq_len // seg_len self.seg_embed SegmentEmbedding(seg_len, d_model) self.var_embed nn.Parameter(torch.randn(num_vars, d_model) * 0.02) self.seg_pos nn.Parameter(torch.randn(self.num_segs, d_model) * 0.02) self.encoder_layers nn.ModuleList([ nn.ModuleDict({ seg_attn: nn.MultiheadAttention(d_model, n_heads, batch_firstTrue, dropoutdropout), var_attn: CrossVariableAttention(d_model, n_heads, win_size), ffn: nn.Sequential( nn.Linear(d_model, d_model * 4), nn.GELU(), nn.Dropout(dropout), nn.Linear(d_model * 4, d_model), ), norm1: nn.LayerNorm(d_model), norm2: nn.LayerNorm(d_model), norm3: nn.LayerNorm(d_model), }) for _ in range(e_layers) ]) self.head nn.Linear(self.num_segs * d_model, pred_len) def forward(self, x): # x: (batch, seq_len, num_vars) b, l, v x.shape x x.permute(0, 2, 1) # (b, v, l) x x.reshape(b * v, self.num_segs, self.seg_len) x self.seg_embed(x) # (b*v, num_segs, d_model) x x.reshape(b, v, self.num_segs, -1) x x self.var_embed[None, :, None, :] self.seg_pos[None, None, :, :] for layer in self.encoder_layers: # 段内注意力 residual x x_flat x.reshape(b * v, self.num_segs, -1) attn_out, _ layer[seg_attn](x_flat, x_flat, x_flat) x_flat layer[norm1](residual.reshape(b * v, self.num_segs, -1) attn_out) x x_flat.reshape(b, v, self.num_segs, -1) # 跨变量注意力 residual x x layer[var_attn](x) x layer[norm2](residual x) # FFN residual x x layer[ffn](x) x layer[norm3](residual x) # 预测头只预测OT变量 x x[:, -1, :, :] # 取OT变量 x x.reshape(b, -1) x self.head(x) # (b, pred_len) return x训练循环里损失函数用MSE优化器用Adam学习率1e-4配合前面说的预热和裁剪。batch_size32在单张24G显存的卡上跑720→96的配置显存占用大概8G还有余量。4.2 我踩过的三个坑和对应的修复方案第一个坑是变量顺序搞错。ETTh1的CSV里OT是最后一列我一开始取x[:, -1, :]的时候把变量维度和时间维度搞混了结果预测的是最后一个时间步的所有变量而不是所有时间步的OT变量。这个bug很隐蔽因为loss看起来在下降但预测出来的东西完全不对。修复方法是在forward里明确打印张量形状确认每一步的维度。第二个坑是seg_len不整除seq_len。我试过seq_len720seg_len50结果最后一段只有20个时间步卷积层报错。后来加了断言每次改参数都检查一遍。这个坑的教训是Crossformer对输入尺寸的约束比一般模型严格参数之间有关联不能随便改。第三个坑是验证集loss震荡。训练到30个epoch左右验证集loss突然跳高然后又降下来。我一开始以为是学习率太大调小之后还是震荡。后来发现是batch_size太小我试过16梯度噪声大。改成32之后稳定多了。如果显存允许batch_size可以再大一点64也行。注意Crossformer的参数量比PatchTST大小batch_size下梯度噪声更明显。如果你发现验证集指标忽好忽坏先检查batch_size再检查学习率。4.3 评估指标的计算和结果对比评估用MSE和MAE都是对OT变量的预测值和真实值算。注意要反标准化之后再算指标否则数值尺度不对指标没有可比性。我一般会在验证集上每5个epoch算一次指标保存最好的模型。def evaluate(model, loader, scaler): model.eval() preds, trues [], [] with torch.no_grad(): for x, y in loader: x, y x.cuda(), y.cuda() pred model(x) preds.append(pred.cpu().numpy()) trues.append(y.cpu().numpy()) preds np.concatenate(preds, axis0) trues np.concatenate(trues, axis0) # 反标准化 preds preds * scaler[std][-1] scaler[mean][-1] trues trues * scaler[std][-1] scaler[mean][-1] mse np.mean((preds - trues) ** 2) mae np.mean(np.abs(preds - trues)) return mse, mae和PatchTST对比同样的seq_len720、pred_len96PatchTST的MSE大概在0.40左右Crossformer是0.42略差一点。但Crossformer在pred_len336和720的长预测上表现更稳PatchTST在长预测上衰减更快。如果你的场景是中长期预测Crossformer值得一试。5. 长预测场景下的参数调整策略5.1 pred_len变大时seg_len要不要跟着变pred_len从96加到336或720时输入seq_len通常也要加比如从720加到1440。这时候seg_len要不要变我的经验是seg_len保持48不变但num_segs会从15变成30。段数变多之后段间注意力的计算量增加但还在可接受范围内。如果你把seg_len也加大到96段数还是15但每段的时间跨度变成4天段内注意力会过度平滑丢失日周期细节。我试过seq_len1440、pred_len336、seg_len48的配置MSE是0.58比seg_len96的0.63好不少。所以结论是seq_len变长时优先增加段数而不是增加段长。5.2 长预测下的过拟合问题pred_len变大之后模型需要输出的信息更多参数量不变的情况下过拟合风险反而降低因为任务更难了。但如果你同时把d_model加到128或256过拟合就会回来。我在pred_len720时试过d_model128训练集loss降到0.15验证集loss停在0.65不动明显过拟合。后来把d_model降回64加了dropout0.2验证集loss降到0.61。长预测场景下我建议保持d_model64层数2dropout加到0.15到0.2。不要因为任务难就盲目加大模型数据量摆在那里模型容量太大只会记住训练集的噪声。5.3 多变量预测时要不要把所有变量都作为预测目标ETTh1的标准做法是只预测OT其他6个变量作为输入。但有些场景下你可能需要同时预测多个变量。Crossformer的输出头可以改成输出所有变量的预测值只需要把最后的x[:, -1, :, :]改成x[:, :, :, :]然后reshape成(b, num_vars * pred_len)。但这样参数量会增加而且不同变量的预测难度不一样OT好预测其他负荷变量波动大混在一起训练会互相干扰。我的建议是如果目标变量明确就只预测目标变量其他作为协变量。如果确实需要多变量输出可以考虑给每个变量单独一个输出头共享编码器这样既能利用变量间的关联又不会让输出层互相干扰。# 多变量输出头 self.heads nn.ModuleList([nn.Linear(self.num_segs * d_model, pred_len) for _ in range(num_vars)]) # forward里 outputs [] for i, head in enumerate(self.heads): outputs.append(head(x[:, i, :, :].reshape(b, -1))) outputs torch.stack(outputs, dim-1) # (b, pred_len, num_vars)这种设计在变量数不多比如少于20的时候效果不错变量数太多的话每个头都要独立训练数据效率会下降。6. 一些实战中的零碎经验模型保存这块我习惯保存验证集MSE最低的那个epoch而不是最后一个epoch。Crossformer的训练曲线有时候会在后期反弹最后一个epoch不一定最好。保存的时候把config也存下来方便复现。torch.save({ model: model.state_dict(), config: config, epoch: epoch, best_mse: best_mse, }, crossformer_etth1.pth)可视化预测结果的时候我建议至少画三张图一张是整体预测对比一张是局部放大比如某一天的24小时一张是误差分布直方图。整体图看趋势局部图看细节误差分布看有没有系统性偏差。我遇到过模型在整体图上看起来不错但局部放大后发现峰值总是预测偏低这种问题只看MSE是发现不了的。还有一个细节ETTh1的OT变量在夏季和冬季的波动模式不一样如果训练集只覆盖了部分季节测试集上效果会打折。标准划分里训练集是前12个月覆盖了完整的一年所以这个问题不明显。但如果你用自己的数据要注意训练集的时间覆盖范围。最后说下硬件。Crossformer在单张24G卡上跑ETTh1的720→96配置100个epoch大概需要40分钟。如果用16G卡batch_size要降到16训练时间翻倍。如果只有CPU建议把seq_len降到336seg_len降到24否则一个epoch要跑十几分钟调参效率太低。调参这件事我的原则是先固定seg_len和win_size把学习率和batch_size调稳再回头细调seg_len。因为学习率不稳的话seg_len的好坏根本看不出来。我见过有人同时改五个参数结果跑了几十组实验也不知道哪个参数起了作用。一次只改一个记录清楚这是最笨但最有效的方法。