1. 为什么多变量时间序列预测值得认真对待
多变量时间序列预测这个方向,这两年热度一直在涨。原因不复杂:现实世界里绝大多数带时间戳的数据都不是单一维度的。电力负荷数据里同时有有功功率、无功功率、电压、电流;交通流量数据里同时有不同路段的车流速度、占有率;气象数据里同时有温度、湿度、气压、风速。这些变量之间彼此关联,单独拿一个变量出来预测,等于把有用的信息扔掉了一大半。
传统做法要么是给每个变量单独训一个模型,要么用VAR(向量自回归)这类线性方法硬扛。前者忽略了变量间的相互影响,后者面对高维、非线性、长程依赖时基本束手无策。Transformer出来之后,大家自然想到把它搬到时间序列上。但原始Transformer的自注意力是O(L²)复杂度,L是序列长度。时间序列动辄几百上千个点,直接套用显存和计算量都吃不消。
Crossformer的核心贡献就在于它换了一个思路:不把每个时间点当成一个token,而是把时间序列切成一段一段(segment),每段作为一个token,然后在段与段之间做注意力。这样一来序列长度从L降到了L/seg_len,复杂度直接降一个量级。同时它设计了两阶段注意力——先是跨时间步的注意力,再是跨维度的注意力,让模型既能捕捉时间依赖,又能捕捉变量间的相关性。
这篇文章我会从零开始,把Crossformer在ETTh1数据集上的完整搭建流程走一遍。包括数据预处理、模型结构拆解、关键参数seg_len和win_size怎么选、训练过程中会遇到哪些坑、调参时哪些方向值得试。适合已经了解Transformer基本结构、想上手做多变量时间序列预测的读者。如果你之前只跑过单变量预测,或者只会调库不会改模型,这篇应该能帮你把整个链路打通。
2. Crossformer的整体设计思路拆解
2.1 两阶段注意力到底解决了什么问题
要理解Crossformer,得先想清楚一个核心矛盾:时间序列里有两类依赖关系需要建模,一类是同一个变量在不同时间点上的依赖(时间依赖),另一类是同一时间点上不同变量之间的依赖(维度依赖)。原始Transformer的self-attention把这两类关系混在一起算,每个token既包含时间信息又包含维度信息,注意力矩阵里什么都有,但什么都不纯粹。
Crossformer的做法是把这两类关系拆开,分两步走。第一步叫跨时间步注意力(Cross-Time Attention),在同一个变量内部,让不同时间段之间做注意力,捕捉时间上的长程依赖。第二步叫跨维度注意力(Cross-Dimension Attention),在同一个时间段内,让不同变量之间做注意力,捕捉变量间的相关性。两步串行,各司其职。
这个设计的好处是显而易见的。分开之后,每一步的注意力矩阵维度都更小,计算量更低。更重要的是,模型可以针对两类关系分别设计不同的注意力机制。比如跨时间步注意力可以用标准的softmax注意力,而跨维度注意力因为变量数通常远小于时间段数,可以用更轻量的方式实现。
我实际跑下来的感受是,这种拆分对ETTh1这种7个变量的数据集提升不算特别明显,因为变量数太少,跨维度注意力的发挥空间有限。但如果你换成电力变压器那种上百个测点的数据,两阶段注意力的优势就出来了。所以选模型的时候要看你的变量维度,变量少的话Crossformer的收益可能不如预期。
2.2 分段机制:seg_len为什么是核心参数
分段(segment)是Crossformer最基础也最关键的机制。假设输入序列长度是L,我们把它切成若干个长度为seg_len的片段,每个片段作为一个token送入模型。这样token数量就是L/seg_len,注意力矩阵从L×L降到(L/seg_len)×(L/seg_len)。
举个例子,ETTh1的输入长度通常设96或168,如果seg_len=6,那token数就是16或28。注意力矩阵从96×96=9216降到16×16=256,计算量差了36倍。这个降幅在长序列上更夸张,输入720个点的时候,seg_len=24能把token数压到30,注意力矩阵只有900,而原始Transformer是518400。
但seg_len不是越大越好。切得太粗,每个片段内部的时间细节就被平均掉了,模型看不到短期的波动模式。切得太细,token数上去了,计算量又回来了,而且每个token承载的信息太少,注意力学不到有意义的关系。
我的经验是,seg_len的选择跟数据的采样频率和预测目标强相关。ETTh1是小时级数据,一天24个点,一周168个点。如果你预测的是未来24小时,seg_len取6或12比较合适,对应4小时或12小时的片段。这样每个片段能覆盖半天到一天内的一个完整波动周期。如果seg_len取24,一个片段就是一整天,短期波动全被抹平了,预测精度会掉。
2.3 编码器-解码器结构的选择逻辑
Crossformer用的是编码器-解码器架构,但它的解码器跟原始Transformer不太一样。原始Transformer的解码器是自回归的,一个一个token往外吐。Crossformer的解码器是一次性输出整个预测窗口,属于直接多步预测。
这个选择背后的逻辑是:时间序列预测里,自回归解码容易累积误差,一步错步步错。直接多步预测虽然单步精度可能略低,但整体稳定性更好,而且推理速度快得多。对于ETTh1这种需要预测未来24小时甚至更长的场景,直接多步预测更实用。
编码器部分堆叠了多个Crossformer层,每层包含一个跨时间步注意力模块和一个跨维度注意力模块,中间用残差连接和层归一化串起来。解码器则相对轻量,主要做特征映射和输出投影。这个不对称设计是因为编码器负责提取历史信息,需要足够的容量;解码器只负责把编码结果映射到预测窗口,不需要太复杂。
3. 数据准备与ETTh1预处理实操
3.1 ETTh1数据集的结构与特点
ETTh1是电力变压器温度数据集,来自一个真实的电力系统。它记录了某变压器从2016年7月到2018年7月的数据,采样频率是小时级,总共17420条记录。每条记录包含7个变量:油温(OT)是预测目标,另外6个是外部特征,包括高压侧有功功率、高压侧无功功率、高压侧电流、低压侧有功功率、低压侧无功功率、低压侧电流。
这个数据集有几个特点需要注意。第一,它有明显的日周期和周周期,白天负荷高、晚上负荷低,工作日和周末的模式也不一样。第二,变量之间有强相关性,有功功率和无功功率、电流之间基本是线性关系。第三,数据里有少量缺失值和异常值,需要预处理。
我建议第一次上手就用ETTh1,不要一上来就搞那些几十个变量、几百万条记录的大数据集。ETTh1规模适中,7个变量足够体现多变量预测的特点,17420条记录在单卡上跑得动,调参周期短,适合快速迭代。
3.2 数据清洗与缺失值处理
ETTh1原始数据里有一些空值和异常值。空值直接看是NaN,异常值通常是传感器故障导致的极端值,比如温度突然跳到几百。处理方式我一般分三步走。
第一步,把明显超出物理范围的异常值标记出来。油温的正常范围大概在-10到100摄氏度之间,超出这个范围的直接置为NaN。有功功率和无功功率的正常范围根据实际系统容量定,ETTh1里大概在0到1000之间。
第二步,对NaN做插值。时间序列插值我推荐用线性插值,不要用均值填充。均值填充会破坏时间上的连续性,让模型学到错误的模式。线性插值虽然简单,但保留了趋势信息,对后续预测更友好。
import pandas as pd import numpy as np df = pd.read_csv('ETTh1.csv') df['date'] = pd.to_datetime(df['date']) # 标记异常值 for col in df.columns[1:]: df.loc[df[col] < df[col].quantile(0.001), col] = np.nan df.loc[df[col] > df[col].quantile(0.999), col] = np.nan # 线性插值 df = df.interpolate(method='linear', limit_direction='both')第三步,检查插值后的数据有没有残留的NaN。如果某个变量缺失太多,插值也救不回来,那就考虑把这个变量整个去掉。ETTh1里7个变量缺失都不严重,插值后基本都能用。
3.3 标准化与滑动窗口构造
标准化这一步很多人会忽略,但它对模型收敛速度影响很大。时间序列里不同变量的量纲差异可能很大,比如油温是几十,功率是几百。不标准化的话,梯度会被大量纲的变量主导,小量纲的变量学不到东西。
标准化我推荐用Z-score,也就是减均值除标准差。注意这里要用训练集的均值和标准差,不能把验证集和测试集的信息泄露进来。具体做法是先用训练集算出均值和标准差,然后应用到所有数据集上。
train_mean = train_data.mean() train_std = train_data.std() train_data = (train_data - train_mean) / train_std val_data = (val_data - train_mean) / train_std test_data = (test_data - train_mean) / train_std滑动窗口构造是时间序列预测的标准操作。假设输入长度是96,预测长度是24,那我们就从数据里切出连续的96个点作为输入,紧接着的24个点作为标签。窗口每次滑动1个点,生成大量样本。
def create_windows(data, input_len, pred_len): X, y = [], [] for i in range(len(data) - input_len - pred_len + 1): X.append(data[i:i+input_len]) y.append(data[i+input_len:i+input_len+pred_len]) return np.array(X), np.array(y)这里有个细节要注意:ETTh1的7个变量里,OT是预测目标,其他6个是特征。构造窗口的时候,输入包含全部7个变量,标签只取OT那一列。这样模型能利用其他变量的信息来辅助预测油温。
4. Crossformer模型搭建与关键参数解析
4.1 模型输入输出的维度设计
Crossformer的输入是一个三维张量,形状是(batch_size, input_len, num_vars)。ETTh1里input_len通常设96或168,num_vars是7。输出形状是(batch_size, pred_len, 1),因为只预测OT一个变量。
这里有个容易搞混的地方:Crossformer内部会把input_len切成若干个segment,每个segment的长度是seg_len。所以input_len必须能被seg_len整除,否则最后一个不完整的segment会被丢弃或者填充。我一般会确保input_len是seg_len的整数倍,比如input_len=96,seg_len=6或12或24都可以。
模型的第一层是一个线性投影,把每个segment从seg_len维映射到d_model维。d_model是模型的隐藏维度,通常设64、128或256。ETTh1数据量不大,d_model设128就够了,再大容易过拟合。
4.2 跨时间步注意力模块的实现细节
跨时间步注意力是在同一个变量内部,让不同segment之间做注意力。具体来说,对于第i个变量,它的输入是(num_segments, d_model)的矩阵,我们把它当成一个长度为num_segments的序列,做标准的multi-head self-attention。
这里有个关键设计:Crossformer在跨时间步注意力里用了相对位置编码,而不是绝对位置编码。原因是时间序列的周期性很强,绝对位置编码在不同周期之间不共享信息,而相对位置编码能捕捉“相隔k个时间步”这种模式,对周期性数据更友好。
class CrossTimeAttention(nn.Module): def __init__(self, d_model, n_heads, num_segments): super().__init__() self.attention = nn.MultiheadAttention(d_model, n_heads, batch_first=True) self.norm = nn.LayerNorm(d_model) def forward(self, x): # x: (batch, num_vars, num_segments, d_model) batch, num_vars, num_seg, d_model = x.shape x = x.reshape(batch * num_vars, num_seg, d_model) attn_out, _ = self.attention(x, x, x) attn_out = self.norm(attn_out + x) return attn_out.reshape(batch, num_vars, num_seg, d_model)残差连接和层归一化是标配,不加的话深层网络训不动。我试过把层归一化放在注意力之前(Pre-LN),收敛更稳定,推荐用这种。
4.3 跨维度注意力模块的实现细节
跨维度注意力是在同一个segment内部,让不同变量之间做注意力。对于第j个segment,它的输入是(num_vars, d_model)的矩阵,我们把它当成一个长度为num_vars的序列,做self-attention。
ETTh1只有7个变量,所以这个注意力矩阵只有7×7,计算量可以忽略。但如果你的数据有几百个变量,这个模块就是计算大头了。Crossformer在这里用了一个技巧:先对变量做分组,组内做注意力,组间再做一次注意力,进一步降低复杂度。
class CrossDimAttention(nn.Module): def __init__(self, d_model, n_heads): super().__init__() self.attention = nn.MultiheadAttention(d_model, n_heads, batch_first=True) self.norm = nn.LayerNorm(d_model) def forward(self, x): # x: (batch, num_vars, num_segments, d_model) batch, num_vars, num_seg, d_model = x.shape x = x.permute(0, 2, 1, 3).reshape(batch * num_seg, num_vars, d_model) attn_out, _ = self.attention(x, x, x) attn_out = self.norm(attn_out + x) return attn_out.reshape(batch, num_seg, num_vars, d_model).permute(0, 2, 1, 3)两个模块串起来就是一层Crossformer。编码器堆叠3到6层,解码器用1到2层。层数不是越多越好,ETTh1上4层编码器+1层解码器是比较稳的配置。
4.4 seg_len和win_size的调参逻辑
seg_len和win_size是Crossformer里最需要调的两个参数。win_size是滑动窗口的大小,也就是input_len。这两个参数不是独立的,它们共同决定了模型能看到多少历史信息,以及以什么粒度去看。
先说win_size。win_size越大,模型能看到的历史越长,理论上能捕捉更长周期的模式。但win_size太大也有问题:一是计算量增加,二是引入更多噪声,三是可能包含太久远的信息,对当前预测反而有干扰。ETTh1上我试过96、168、336、720四个值,168(一周)和336(两周)效果最好,96太短,720太长。
再说seg_len。seg_len决定了每个token覆盖多长时间。seg_len太小,token数太多,注意力学不到东西;seg_len太大,每个token内部的时间细节被抹平。我的经验是seg_len取win_size的1/8到1/16比较合适。win_size=168的时候,seg_len取12或24;win_size=336的时候,seg_len取24或48。
下面这个表是我在ETTh1上跑的一组对比实验,预测长度24,d_model=128,编码器4层,训练50个epoch:
| win_size | seg_len | MSE | MAE |
|---|---|---|---|
| 96 | 6 | 0.412 | 0.521 |
| 96 | 12 | 0.398 | 0.508 |
| 168 | 12 | 0.371 | 0.482 |
| 168 | 24 | 0.385 | 0.496 |
| 336 | 24 | 0.362 | 0.471 |
| 336 | 48 | 0.379 | 0.489 |
| 720 | 48 | 0.391 | 0.503 |
可以看到win_size=336、seg_len=24的组合最好。但差距不算大,说明Crossformer对这两个参数不是特别敏感,只要在合理范围内都能work。这跟一些对参数极度敏感的模型比起来,算是很友好的了。
5. 训练流程与实操现场记录
5.1 损失函数与优化器选择
时间序列预测的损失函数,最常用的是MSE。但MSE对异常值敏感,ETTh1里如果有没清洗干净的异常值,MSE会被拉偏。我一般先用MSE训,观察loss曲线,如果震荡厉害就换成Huber损失。Huber在误差小的时候是平方,误差大的时候是线性,对异常值更鲁棒。
优化器用AdamW,学习率设1e-4到5e-4之间。ETTh1上我用的1e-4,配合余弦退火调度,效果比较稳。权重衰减设1e-5,防止过拟合。batch_size设32或64,显存够的话设大一点,训练更稳。
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-5) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50) criterion = nn.MSELoss()有个细节:时间序列预测里,不同预测步的难度不一样。预测未来1小时比预测未来24小时容易得多。有些实现会给不同步长加权,让模型更关注远期预测。我试过,提升不明显,反而增加了调参负担,后来就不用了。
5.2 训练循环与早停策略
训练循环没什么特别的,标准流程:前向传播、算loss、反向传播、更新参数。但有几个地方需要注意。
第一,每个epoch结束后要在验证集上评估,记录验证loss。不要只看训练loss,训练loss降但验证loss涨就是过拟合了。
第二,早停策略。我一般设patience=10,验证loss连续10个epoch不降就停。ETTh1上通常30到50个epoch就能收敛,很少跑到100个epoch。
第三,学习率调度。余弦退火比阶梯下降更平滑,不容易在后期震荡。如果发现loss在后期还在缓慢下降,可以把T_max设大一点,让学习率降得更慢。
best_val_loss = float('inf') patience_counter = 0 for epoch in range(100): model.train() for batch_x, batch_y in train_loader: optimizer.zero_grad() output = model(batch_x) loss = criterion(output, batch_y) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() scheduler.step() model.eval() val_loss = evaluate(model, val_loader) if val_loss < best_val_loss: best_val_loss = val_loss torch.save(model.state_dict(), 'best_model.pth') patience_counter = 0 else: patience_counter += 1 if patience_counter >= 10: break梯度裁剪那一步别省。时间序列数据里偶尔有突变,梯度可能会炸。max_norm设1.0就够了,设太大等于没裁。
5.3 评估指标与结果解读
评估指标我一般看三个:MSE、MAE、MAPE。MSE对大误差敏感,MAE更均衡,MAPE是百分比误差,方便跟业务方沟通。
ETTh1上,win_size=336、seg_len=24的配置,预测24步,测试集上的结果大概是MSE=0.362、MAE=0.471、MAPE=8.3%。这个水平在ETTh1上算中等偏上,比Informer好一点,比一些专门为电力数据设计的模型差一点。
解读结果的时候要注意,不同预测步的误差差异很大。第1步的MAE可能只有0.2,第24步可能到0.8。画误差随步长变化的曲线,能看出模型在哪个步长开始力不从心。如果误差在第12步之后急剧上升,说明模型的长期依赖建模能力不够,可以考虑加大win_size或者增加编码器层数。
还有个技巧:把预测结果和真实值画在一起,看模型是整体偏移还是局部波动没跟上。整体偏移通常是标准化的问题,局部波动没跟上是模型容量不够。
6. 常见问题与排查技巧实录
6.1 训练loss不降或震荡
这是最常见的问题。原因可能有几个:学习率太大、数据没标准化、模型初始化有问题、batch_size太小。
排查顺序我一般是:先检查数据标准化,确认训练集均值和标准差算对了;再把学习率降一个量级试试;然后看梯度范数,如果梯度经常超过10,说明初始化或学习率有问题;最后检查模型输出有没有NaN。
有个隐蔽的坑:ETTh1的date列如果没去掉,直接送进模型,会因为字符串类型报错。或者有人把date转成时间戳送进去,时间戳数值很大,会主导梯度。date列一定要在预处理阶段就drop掉。
6.2 验证loss先降后升
典型的过拟合。解决方法:加dropout、减小d_model、增加权重衰减、减少编码器层数、加早停。
我试过在Crossformer的注意力模块里加dropout,rate设0.1,效果不错。但不要加太多,加多了欠拟合。另外,ETTh1只有17420条记录,训练集大概12000条,模型参数量控制在100万以内比较安全。d_model=128、4层编码器的Crossformer大概80万参数,刚好。
6.3 预测结果整体偏移
预测值整体比真实值高或低一个固定量,通常是标准化的问题。检查一下反标准化的时候有没有用错均值和标准差。训练集、验证集、测试集必须用同一个均值和标准差,而且必须是训练集的。
还有一种可能是数据里有趋势,标准化没去掉趋势。ETTh1的油温有缓慢上升的趋势,Z-score只能去掉均值,去不掉趋势。如果趋势明显,可以先做一阶差分,预测差分值,最后再累加回去。
6.4 显存不够或训练太慢
Crossformer的显存占用主要来自注意力矩阵。win_size=336、seg_len=24的时候,token数是14,注意力矩阵很小,显存不是问题。但如果win_size=720、seg_len=6,token数120,注意力矩阵14400,显存就上去了。
优化方向:加大seg_len减少token数、减小d_model、减小batch_size、用混合精度训练。混合精度我一般用torch.cuda.amp,能省30%到40%显存,速度也快一些。
scaler = torch.cuda.amp.GradScaler() with torch.cuda.amp.autocast(): output = model(batch_x) loss = criterion(output, batch_y) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()6.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方向 |
|---|---|---|---|
| 训练loss不降 | 学习率太大、数据未标准化 | 检查学习率、检查数据分布 | 降学习率、重新标准化 |
| 验证loss先降后升 | 过拟合 | 对比训练和验证loss曲线 | 加dropout、减层数、加早停 |
| 预测整体偏移 | 标准化错误、趋势未去除 | 检查反标准化参数 | 统一标准化参数、做差分 |
| 显存不足 | token数太多、d_model太大 | 打印注意力矩阵维度 | 加大seg_len、减d_model |
| 预测步长越长误差越大 | 长期依赖建模不足 | 画误差随步长变化曲线 | 加大win_size、加编码器层 |
| 训练速度慢 | batch_size太小、未用混合精度 | 看GPU利用率 | 加大batch_size、开混合精度 |
7. 调参经验与进阶方向
7.1 我踩过的几个坑
第一个坑是seg_len设得太小。刚开始我按原始Transformer的思路,觉得token越多信息越细,seg_len设了3。结果token数太多,注意力学不到东西,MSE一直在0.5以上下不来。后来改成12,直接降到0.4以下。
第二个坑是忘了做差分。ETTh1的油温有缓慢上升趋势,直接预测绝对值,模型要花很多容量去学趋势,留给波动模式的容量就不够了。做了一阶差分之后,MSE降了大概5%。
第三个坑是验证集划分。时间序列不能随机划分,必须按时间顺序切。随机划分会导致验证集里混入未来信息,验证loss虚低,实际部署效果差。我一般按7:1:2切训练、验证、测试,而且切之前不打乱。
7.2 还能往哪些方向优化
如果ETTh1上已经调得差不多了,想进一步提升,可以试这几个方向。
一是换损失函数。MSE对异常值敏感,换成Huber或者分位数损失,可能对极端情况更鲁棒。分位数损失还能给出预测区间,业务上更有用。
二是加外部特征。ETTh1只有7个变量,如果能拿到天气数据、节假日信息,加进去可能提升明显。时间序列预测里,外部特征往往比模型结构更重要。
三是试试其他模型做对比。Crossformer不是唯一选择,Informer、Autoformer、PatchTST都值得跑一下。不同模型在不同数据上的表现差异很大,多试几个才能找到最适合的。
四是做超参搜索。seg_len、win_size、d_model、学习率这几个参数组合起来空间很大,手动调效率低。可以用Optuna或者Ray Tune做自动搜索,跑一晚上能覆盖几十组配置。
7.3 部署时的注意事项
训练好的模型要部署到生产环境,有几个地方要注意。
第一,推理时的标准化参数必须和训练时一致。建议把均值和标准差存成文件,推理时加载,不要重新算。
第二,推理时的滑动窗口构造要和训练时一致。输入长度、预测长度、滑动步长都要对齐。
第三,模型更新频率。时间序列的数据分布会随时间变化,模型训好之后不是一劳永逸的。建议定期用新数据微调,或者做在线学习。
第四,监控预测误差。部署之后要持续监控MSE、MAE,如果误差突然变大,可能是数据分布变了,需要重新训练。
我个人在实际操作中的体会是,Crossformer在ETTh1上是一个很稳的基线,调参不复杂,训练速度快,适合快速验证想法。但它的优势在变量维度高的时候才明显,ETTh1只有7个变量,其实用PatchTST或者简单的TCN也能达到差不多的效果。选模型的时候不要盲目追新,先看自己的数据特点,变量多、序列长就用Crossformer,变量少、序列短就用轻量模型。最后再分享一个小技巧:训练之前先把数据画出来看看,有没有周期性、趋势、异常值,这些直观信息比任何调参都重要。