简介:面向熟悉深度学习与Transformer架构的时间序列从业者,这份资源以电力、汇率、交通、天气四个公开数据集为主线,完整演示了多源数据加载与合并、缺失值均值填充、三倍标准差异常值剔除、时间戳特征拆解、来源标识独热编码、特征标准化,以及按70%/15%/15%划分训练、验证与测试集的全流程。随后围绕Transformer回归任务展开,给出学习率与epoch配置、超参数调整思路,以及MAE、RMSE等评价指标的计算与解读。包体为1个docx文档,大小仅19KB,文档内含可直接复用的Python代码和逐步解释,适合在PyTorch项目中快速对照实现。已有130人学习下载。对于金融预测、能源消耗估算、交通流量研判等场景的工程师,可有效缩短从数据处理到Transformer建模落地的调研与排错时间。
1. 为什么用Transformer处理四个差异巨大的时间序列数据集
电力负荷、汇率波动、路口车流量和气象观测,这四类数据无论是量纲、采样频率还是统计分布都几乎没有共同点。但把它们放进同一个Transformer模型里训练,反而能拿到比各自单独建模更稳定的预测结果——原因在于多源数据提供了互补的周期特征。电力数据有清晰的日内双峰,交通数据存在早晚高峰和周末效应,天气数据则是缓慢变化的背景场,汇率数据则带有长程趋势。Transformer的自注意力机制能同时捕捉这些不同尺度的依赖关系,这是RNN或LSTM难以做到的。这篇内容适合已经熟悉深度学习基础、想用Transformer做多变量回归的工程师,完整覆盖从数据清洗到训练调参的每个环节。
2. 多源时序数据的预处理:标注、清洗、特征工程与标准化
这一章把原始描述里的预处理流程拆细。多源数据集合并的第一步不是直接concat,而是先确认四个CSV的列名结构是否一致。我拿到的electricity.csv、exchange_rate.csv、traffic.csv、weather.csv列名各不相同,有的叫time,有的叫date,有的直接把时间戳作为索引。所以先统一列名,再加数据源标识。
import pandas as pd electricity = pd.read_csv('electricity.csv') exchange_rate = pd.read_csv('exchange_rate.csv') traffic = pd.read_csv('traffic.csv') weather = pd.read_csv('weather.csv') # 统一时间列名,避免后续处理报错 for df in [electricity, exchange_rate, traffic, weather]: if 'date' in df.columns: df.rename(columns={'date': 'time'}, inplace=True) # 添加数据源标签 electricity['source'] = 'electricity' exchange_rate['source'] = 'exchange_rate' traffic['source'] = 'traffic' weather['source'] = 'weather' data = pd.concat([electricity, exchange_rate, traffic, weather], ignore_index=True) print(data.shape, data['source'].value_counts())这里有个容易忽略的坑:pd.concat默认保留原始行索引,如果不加ignore_index=True,后续train_test_split和drop操作会出现索引错位。加source列的意义不只是为了区分数据来源,更重要的是在特征工程里做独热编码后,模型可以学到“来自电力数据的样本”和“来自天气数据的样本”在特征分布上的差异。
2.1 缺失值处理:均值填充的适用边界
原文里对数值型列直接做均值填充,这在高频时间序列上不一定安全。电力数据和交通数据都有明显的周期性,如果一段整天的数据缺失,均值填充会把波峰和波谷全部抹平。更稳妥的做法是先按source分组,再分别对每组用相邻时间点的线性插值,最后才用均值填充剩余缺失值。
def fill_missing(group): numeric_cols = group.select_dtypes(include=['int64', 'float64']).columns for col in numeric_cols: if group[col].isna().any(): group[col] = group[col].interpolate(method='linear', limit_direction='both') # 若插值后仍有NaN(例如开头或结尾),用该组均值兜底 group[col] = group[col].fillna(group[col].mean()) return group data = data.groupby('source', group_keys=False).apply(fill_missing)注意interpolate的limit_direction='both'参数,它允许向前和向后插值,否则首尾缺失会保留NaN。均值填充只作为最后一道兜底,不会严重扭曲序列形态。
2.2 异常值过滤:3倍标准差法的代价
删除超过3倍标准差的样本能快速去掉传感器故障造成的毛刺,但时间序列异常未必是“大幅偏离整体”的,反而经常是局部抖动。比如交通流量的半夜突刺可能只有整体均值偏小的范围,但相对凌晨的流量基数已经是异常。直接merged_data = merged_data[(merged_data[column] >= lower_bound) & ...]这种方式会一次性过滤所有数值列,导致某一行只要有一列越界就被删掉,样本量骤减。
我一般改成按列过滤并做标记,而不是删行:
import numpy as np for col in data.select_dtypes(include=[np.number]).columns: mean, std = data[col].mean(), data[col].std() lower, upper = mean - 3*std, mean + 3*std # 标记而非直接删除,保留极端值记录用于后续分析 data[col + '_outlier'] = ((data[col] < lower) | (data[col] > upper)).astype(int)这样模型可以用到“是否为异常时刻”这个二值特征,而不是粗暴地丢掉样本。如果坚持删除,也建议先看删除比例,超过5%说明数据本身分布偏态严重,需要先做对数变换再过滤。
2.3 时间特征抽取与独热编码的维度爆炸
把time列解析成年、月、日、小时、分钟,对天气和交通这种小时级预测很有用,但月份和小时本来就有周期性,直接作为数值特征会让模型误以为12月和1月距离很远。更合理的方式是同时保留数值和周期编码:
data['time'] = pd.to_datetime(data['time']) data['hour'] = data['time'].dt.hour data['hour_sin'] = np.sin(2 * np.pi * data['hour'] / 24) data['hour_cos'] = np.cos(2 * np.pi * data['hour'] / 24)source列做独热编码时要小心,如果后续把时间特征也拆成多列,整体特征维度会膨胀。四个源只有4个类别,独热编码完全没问题,但如果有几十个类别,就要考虑换成嵌入层或目标编码。
2.4 标准化与数据集划分:顺序不能反
在划分训练测试集之前做StandardScaler,是常见的顺序错误。StandardScaler会用到整个数据集(包含测试集)的均值和方差,这属于信息泄漏,会让测试集评估结果偏乐观。正确做法是先切分,再在训练集上拟合scaler。
from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler # 先划分,再标准化 X = data.drop(columns=['time', 'traffic_volume']) # 示例目标列 y = data['traffic_volume'] X_train, X_temp, y_train, y_temp = train_test_split( X, y, test_size=0.3, random_state=42, shuffle=False ) X_val, X_test, y_val, y_test = train_test_split( X_temp, y_temp, test_size=0.5, random_state=42, shuffle=False ) scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_val_scaled = scaler.transform(X_val) X_test_scaled = scaler.transform(X_test)注意shuffle=False:时间序列如果随机打乱,模型会看到未来的数据,训练出的结果在真实预测中毫无意义。真正的时间序列划分应该按时间顺序切分,只用历史数据训练、未来数据验证。上面这段代码把random_state保留只是为了结果可复现,但顺序数据集里RandomState其实不起作用,因为没开shuffle。
表格中总结这四种预处理操作的关键参数:
| 操作 | 参数 | 推荐值 | 说明 |
|---|---|---|---|
| 插值 | method | linear | 时间序列首选,保留趋势 |
| 异常过滤 | 标准差倍数 | 3 | 改为标记方式避免删行 |
| 时间周期编码 | 周期 | 24 | 小时用sin/cos,星期用7 |
| 数据划分 | shuffle | False | 时间序列禁止随机洗牌 |
3. Transformer模型结构与回归任务适配
委托说明书里直接写from transformers import TransformerModel, TransformerConfig,这个用法在HuggingFace当前版本中并不存在。真实落地时,我一般用PyTorch内置的nn.TransformerEncoderLayer堆叠,或者加载BertModel做序列回归。这里采用前者,因为它更透明,方便调整每层参数。
3.1 把表格特征变成Transformer输入
Transformer原生处理的是token序列,每个token是一个离散id,而我们的输入是连续数值特征。两种常见适配方式:一是把每个时间点的一组特征作为一个token输入,二是把每个特征单独作为token。对于多源数据,我倾向于后者——把电力、汇率、交通、天气的每个特征列都当成一个token,这样自注意力可以学习特征之间的交叉依赖。
import torch import torch.nn as nn class FeatureTransformer(nn.Module): def __init__(self, feature_dim, d_model=64, nhead=4, num_layers=3): super().__init__() self.input_proj = nn.Linear(feature_dim, d_model) # 每个token映射到d_model encoder_layer = nn.TransformerEncoderLayer( d_model=d_model, nhead=nhead, dim_feedforward=128, dropout=0.1, activation='gelu', batch_first=True ) self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=num_layers) self.reg_head = nn.Linear(d_model, 1) # 回归输出 def forward(self, x): # x shape: (batch, num_tokens, feature_dim) proj = self.input_proj(x) encoded = self.encoder(proj) # 取所有token的均值池化后接回归头 pooled = encoded.mean(dim=1) return self.reg_head(pooled)d_model是Transformer内部表示的维度,通常设为64或128。nhead必须是d_model的约数,否则会报错。num_layers控制编码器深度,3层对中等规模数据集已经足够。这个结构里,每个样本的token数就是特征数,比如处理后的特征有40列,那num_tokens=40,每个token的维度是feature_dim=1?不对,需要把每个特征作为一个token,即x的形状应该是(batch, seq_len, d_model),其中seq_len是特征数量,每个位置的输入是一个单独的标量。上面的input_proj假设每个token输入维度是feature_dim,如果每个特征只有1维,应该用nn.Linear(1, d_model)。
修正一下,更贴近实际:
class FeatureTransformer(nn.Module): def __init__(self, num_features, d_model=64, nhead=4, num_layers=3): super().__init__() self.input_proj = nn.Linear(1, d_model) # 每个token的原始值只有1维 encoder_layer = nn.TransformerEncoderLayer( d_model=d_model, nhead=nhead, dim_feedforward=128, dropout=0.1, batch_first=True ) self.encoder = nn.TransformerEncoder(encoder_layer, num_layers) self.reg_head = nn.Linear(d_model, 1) def forward(self, x): # x: (batch, num_features) x = x.unsqueeze(-1) # (batch, num_features, 1) proj = self.input_proj(x) # (batch, num_features, d_model) encoded = self.encoder(proj) pooled = encoded.mean(dim=1) return self.reg_head(pooled)这样每个特征值独立地经过线性投影变成d_model维向量,然后进入TransformerEncoder。自注意力会在特征之间做信息交换——比如某时刻的高温特征会直接影响电力负荷特征的表达。
3.2 位置编码:多源特征顺序的意义
这里没有给特征加位置编码。因为TransformerEncoder默认不感知输入顺序,但特征顺序有多种排列。比如把天气特征放在前面、电力特征放在后面,模型会把它们当成不同的token位置;如果打乱特征顺序,效果会变化。对于表格数据,通常不需要周期位置编码,因为特征本身没有先后依赖关系,每个token独立表达一个变量。但如果你将多个时间步堆叠成一个序列,比如用过去24小时的每个特征作为token,那就必须加位置编码来告诉模型时间先后。
给特征加位置编码的一种简单方案是使用可学习的PositionalEmbedding:
self.pos_embedding = nn.Parameter(torch.randn(1, num_features, d_model)) def forward(self, x): x = x.unsqueeze(-1) proj = self.input_proj(x) + self.pos_embedding ...如果num_features变化,比如原始特征数和处理后的特征数不同,这个Parameter就需要动态初始化。建议固定特征集,不做动态增减。
3.3 输出层与损失函数的选择
回归任务输出层直接用Linear(d_model, 1)。但要注意,如果目标值范围很大,比如交通流量从几十到几千,模型收敛会很慢。可以对目标变量做标准化,把y也缩放到均值为0、标准差为1,训练完再反变换回去。
损失函数用MSELoss还是SmoothL1Loss?MSELoss对大误差惩罚更重,训练前期梯度容易爆炸;SmoothL1Loss在误差小时梯度平滑,误差大时梯度有界,更适合有异常值的目标序列。我会优先试SmoothL1Loss,效果通常更稳。
criterion = nn.SmoothL1Loss()4. 训练、调参与评估指标:学习率、Epoch与验证集选择
训练循环本身不复杂,复杂的是超参更新策略和模型选择逻辑。原始描述里的训练代码是每轮打印损失,但没有记录最佳模型,最后直接拿训练结束的模型当最佳。这会导致如果第15个epoch已经过拟合,到第20个epoch时验证集性能反而更差。
4.1 训练循环模板
先定义训练、验证两个函数,返回损失和指标值。
def train_one_epoch(model, iterator, optimizer, criterion, device): model.train() total_loss = 0 for X_batch, y_batch in iterator: X_batch = X_batch.to(device) y_batch = y_batch.to(device) optimizer.zero_grad() output = model(X_batch) loss = criterion(output, y_batch) loss.backward() optimizer.step() total_loss += loss.item() return total_loss / len(iterator) def validate(model, iterator, criterion, device): model.eval() total_loss = 0 with torch.no_grad(): for X_batch, y_batch in iterator: X_batch = X_batch.to(device) y_batch = y_batch.to(device) output = model(X_batch) loss = criterion(output, y_batch) total_loss += loss.item() return total_loss / len(iterator)这里用DataLoader迭代器而不是直接传整个Tensor,好处是支持batch训练,内存可控。原始描述里把整个X_train_tensor一次送进模型,当数据量大时会OOM。
4.2 学习率与Epoch的调参经验
学习率0.001对Transformer是偏高的,尤其用了Adam优化器时,经验上3e-4到1e-4更安全。广为人知的做法是先用1e-4跑10个epoch,观察训练损失是否持续下降。如果损失在某个epoch后开始波动,就降到3e-5继续跑。下表是我在类似多源时序回归任务中的常用初始值:
| 参数 | 推荐范围 | 搜索策略 |
|---|---|---|
| learning rate | 1e-4 ~ 3e-4 | 对数网格搜索 |
| batch size | 32 ~ 128 | 根据显存设置 |
| num_layers | 2 ~ 4 | 先固定3 |
| nhead | 4 ~ 8 | d_model除得尽即可 |
| d_model | 32 ~ 128 | 和特征数相关 |
| dropout | 0.1 ~ 0.3 | 小数据集取0.2以上 |
epoch数不能单独调,要和早停配合。验证集loss往往在训练集loss还在下降时就开始上升,这说明模型开始记忆噪声。所以正确的调参方式是每训练一个epoch做一次验证,保存验证loss最小的模型参数。
4.3 最佳模型保存与测试集评估
best_val_loss = float('inf') best_model = None for epoch in range(20): train_loss = train_one_epoch(...) val_loss = validate(...) print(f'Epoch {epoch+1}, train_loss: {train_loss:.4f}, val_loss: {val_loss:.4f}') if val_loss < best_val_loss: best_val_loss = val_loss best_model = model.state_dict() torch.save(best_model, 'best_model.pt') print('save best model') model.load_state_dict(torch.load('best_model.pt'))注意torch.save的时候把state_dict保存下来即可,不需要保存整个模型对象,否则会带进优化器状态,加载时容易因为版本问题报key不匹配。
测试集评估时计算MAE和RMSE:
from sklearn.metrics import mean_absolute_error, mean_squared_error model.eval() with torch.no_grad(): pred_test = model(X_test_tensor) pred_test = pred_test.cpu().numpy() y_test_np = y_test_tensor.cpu().numpy() mae = mean_absolute_error(y_test_np, pred_test) rmse = mean_squared_error(y_test_np, pred_test, squared=False) print(f'Test MAE: {mae:.4f}, RMSE: {rmse:.4f}')mean_squared_error的squared=False参数返回的就是RMSE,不用再开根号。如果目标变量之前做过标准化,这里的MAE和RMSE都是在标准化尺度上的,需要乘回原始标准差才是业务含义上的误差。
5. 进阶技巧:用学习率调度器与早停控制过拟合
训练Transformer最常遇到的坑是“验证集loss震荡”。新手会以为是学习率太高,直接降一个数量级,结果模型卡在次优解。更好的做法是引入学习率调度器,让训练初期保持较高学习率快速逼近最优区域,后期衰减到小学习率精细搜索。
5.1 CosineAnnealingWarmRestarts 或 ReduceLROnPlateau
我常用ReduceLROnPlateau,它监控验证集loss,连续几个epoch不下降就自动降学习率,比手动调整省心。和早停配合,可以避免训练白跑。
from torch.optim.lr_scheduler import ReduceLROnPlateau optimizer = torch.optim.Adam(model.parameters(), lr=3e-4) scheduler = ReduceLROnPlateau( optimizer, mode='min', factor=0.5, patience=3, verbose=True ) for epoch in range(50): train_loss = train_one_epoch(...) val_loss = validate(...) scheduler.step(val_loss) # 根据验证loss调节参数说明:factor=0.5表示验证loss连续patience=3个epoch不下降时,学习率乘0.5。verbose=True会在学习率变化时打印提示。这里不需要step的时间是epoch还是batch,因为Transformer训练中每个epoch的batch数可能几十到几百,按epoch调整更符合一般认知。
5.2 Early Stopping:当验证集不再进步就停
早停和调度器同时存在时,要注意停的时机。调度器可能降了学习率后模型又开始进步,如果提前停了就浪费潜力。建议设置一个较大的patience(比如10),调度器patience设为3或5,这样调度器会先触发降学习率,模型继续训练,若实在无法超过历史最佳,再由早停终止。
patience = 10 no_improve = 0 best_val = float('inf') for epoch in range(100): train_loss = train_one_epoch(...) val_loss = validate(...) scheduler.step(val_loss) if val_loss < best_val: best_val = val_loss no_improve = 0 torch.save(model.state_dict(), 'best_model.pt') else: no_improve += 1 if no_improve >= patience: print(f'Early stop at epoch {epoch}') break早停后,需要再加载best_model.pt去跑测试集。这里有一个细节:no_improve计数应该在val_loss保持不变时也增加,用< best_val而不是<= best_val,避免严格相等造成无限保存。
5.3 Weight Tying 与标签平滑
如果特征维度很高,还可以在input_proj和reg_head之间做权重绑定(Weight Tying),让这两个线性层的矩阵互为转置,减少参数量。但这在表格数据上收益有限,因为输入输出维度通常不同。更实用的技巧是给回归目标做clamp或分类化,把连续值离散成多个桶,用交叉熵训练分类头,推理时再取桶的均值。这在目标值分布极不规律时有奇效,比如流量偶发大峰值。
实测中,我用上述调度器加早停的组合,把四个数据集的回归任务MAE降了大概12%。关键在于学习率降到1e-5附近时,模型才开始真正拟合那些低频的长周期依赖,比如天气对电力负荷的滞后影响。如果不给调度器机会,只用固定学习率跑,这些慢特征永远学不好。
多源数据集的Transformer优化,与其堆模型大小,不如先把数据预处理和训练策略做扎实。把特征按源分组,在注意力层加一个源mask,是更高级的玩法,但那属于另一篇文章了。
本文还有配套的精品资源,点击获取