1. 一维信号分类为什么值得单独拿出来讲
先把场景说清楚。这里的"一维信号"指的是按时间或空间顺序排列的单变量或多变量序列,比如加速度计的三轴振动数据、心电信号的单导联波形、工业设备的电流谐波序列、语音的梅尔频谱时间帧、雷达回波的时序采样点。它们和图像的差别在于:图像有明确的空间局部性(相邻像素构成纹理),而一维信号的核心信息藏在时序依赖里——某个时刻的数值要结合前后几十甚至几百个采样点才能判断它属于哪一类。
传统的做法是人工提特征,比如均值、方差、峰值因子、峭度、FFT主频、小波包能量,然后丢给SVM或者随机森林。这条路我走过很多次,优点是样本需求小、可解释性强,缺点是特征工程的天花板很低,换个工况、换个传感器型号,之前调好的特征可能就废了。深度学习进来之后,1D-CNN成了主流,但它有个硬伤:卷积核的感受野是固定的,想覆盖更长的依赖就得堆层数或者加空洞,参数量和显存消耗跟着上涨。
Transformer换了个思路——用自注意力让序列中任意两个位置直接建立联系,感受野天然是全局的。这对一维信号特别友好,因为很多分类任务的关键证据恰恰跨越了长距离,比如电机故障的周期性冲击、心律失常中RR间期的变化模式。Pytorch实现Transformer序列分类的门槛其实不高,真正容易翻车的地方在输入的组织方式和位置编码的处理上,这两块我后面会重点拆。
这篇内容适合三类人:一是已经会写Pytorch基础模型、想做时序分类但不知道怎么套Transformer的;二是用CNN效果卡住了、想换架构试试的;三是需要一份可以直接改改就跑的代码骨架的。我会给出一套完整的工程化实现,包括数据预处理、模型结构、训练循环、以及我踩过的那些坑。
2. 把原始信号喂进Transformer之前的必要变形
2.1 输入张量的形状到底该长什么样
Pytorch的nn.TransformerEncoder默认期望的输入形状是(seq_len, batch, d_model),注意序列维度在最前面,这是很多人第一个栽跟头的地方。而我们在做数据处理时习惯的格式通常是(batch, seq_len, channels),所以要决定在哪个环节做转置。
我的建议是把数据管道统一成(batch, channels, seq_len),然后在模型forward里第一步就转置,写成x = x.permute(2, 0, 1)。这样做的好处是数据加载、切窗、标准化这些操作都在(batch, channels, seq_len)的直觉下完成,只有进Transformer之前才转换,代码可读性高,排查问题时不容易搞混维度。
这里有个关键判断:你的信号是单通道还是多通道?单通道意味着channels=1,投影层就是nn.Linear(1, d_model);多通道比如三轴加速度,channels=3,投影层是nn.Linear(3, d_model)。投影之后所有通道的信息被融合进一个d_model维的向量表示,序列长度保持不变。
class SignalTransformer(nn.Module): def __init__(self, in_channels=1, d_model=128, nhead=8, num_layers=4, num_classes=5, seq_len=512, dropout=0.1): super().__init__() self.input_proj = nn.Linear(in_channels, d_model) self.pos_embed = nn.Parameter(torch.zeros(1, seq_len, d_model)) encoder_layer = nn.TransformerEncoderLayer( d_model=d_model, nhead=nhead, dim_feedforward=d_model * 4, dropout=dropout, batch_first=True ) self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=num_layers) self.norm = nn.LayerNorm(d_model) self.head = nn.Linear(d_model, num_classes) def forward(self, x): # x: (batch, channels, seq_len) x = x.permute(0, 2, 1) # -> (batch, seq_len, channels) x = self.input_proj(x) # -> (batch, seq_len, d_model) x = x + self.pos_embed x = self.encoder(x) x = self.norm(x.mean(dim=1)) # 全局平均池化 return self.head(x)2.2 位置编码:可学习参数还是正弦函数
Transformer本身是置换不变的,不给位置信息的话,打乱序列顺序模型输出不变,这对时序数据是致命的。所以位置编码必须加。
两种主流选择:一是固定正弦编码,二是可学习的位置嵌入。在做一维信号分类时,我更倾向可学习位置嵌入,原因是信号的采样率、窗口长度在项目里通常是固定的,可学习参数能自适应地学到哪些位置更重要,实测在振动和心电数据集上比正弦编码的精度略高一点(大约0.5到1.5个百分点,视数据集而定)。
但可学习位置嵌入有个必须注意的点:序列长度必须是固定的。如果你的测试集里出现了比训练集更长的序列,位置嵌入的索引会越界。解决办法有两个,要么在预处理阶段强制统一窗口长度,要么用插值的方式动态调整pos_embed的尺寸。我一般选前者,简单可靠。
正弦编码则天然支持任意长度外推,代价是表达灵活性差一些。如果业务场景里序列长度会变,就用正弦编码,公式不用手写,Pytorch里几行就能生成。
提示:位置嵌入的初始化建议用
torch.nn.init.trunc_normal_,标准差设成0.02,比默认的全零或者标准正态都稳,训练初期梯度不会爆炸。
2.3 标准化与窗口切分:细节决定成败
原始信号的量纲差异极大,加速度计可能是±16g,心电信号是毫伏级,直接送进网络,梯度尺度会乱成一锅粥。我用的标准化策略是按通道做Z-score,每个通道减均值除标准差,均值和标准差从训练集统计出来,然后应用到验证集和测试集,绝对不能各自标准化。
窗口切分也很关键。假设采样率1000Hz,你想让模型看到1秒的数据,那窗口长度就是1000。但这里有个反直觉的经验:窗口不是越长越好。我做过对比实验,窗口从256增到2048,精度先升后降,原因是太长的窗口里大部分采样点对分类没有贡献,反而稀释了关键片段的信息密度。一般来说,让窗口长度覆盖2到3个典型周期就够用了。
如果样本量不够,可以用重叠切窗做数据增强,重叠率50%是常见起点。但要注意,重叠切窗会让同一条原始记录切出来的多个窗口被分到训练集和测试集,造成数据泄露——测试精度虚高。正确做法是按原始记录划分数据集,而不是按窗口划分,这一点我后面还会专门说。
3. 模型结构里那些值得推敲的设计选择
3.1 池化方式:CLS token、平均池化还是最大池化
Transformer Encoder输出的形状是(batch, seq_len, d_model),要变成(batch, num_classes)必须做聚合。常见三种做法:
| 池化方式 | 实现 | 适用场景 | 我遇到的实际表现 |
|---|---|---|---|
| 全局平均池化 | x.mean(dim=1) | 通用场景,稳定性好 | 收敛快,精度中上,最稳妥 |
| 全局最大池化 | x.max(dim=1).values | 关键冲击特征明显 | 对故障类信号偶尔更好,但易受噪声影响 |
| CLS token | 序列前拼一个可学习token | BERT风格,需要更多数据 | 小数据集上不如平均池化 |
一维信号分类里,我默认用平均池化。原因很实际:信号里普遍存在噪声和无效片段,平均池化对这些干扰的鲁棒性强。最大池化只在明确知道关键证据是短时冲击时才考虑,比如轴承的早期点蚀故障。CLS token因为引入了一个额外的可学习向量,需要足够多的数据才能学好,样本少于几千条的时候不推荐。
3.2 层数与注意力头数的取舍
d_model必须能被nhead整除,这是硬约束。常见的组合是d_model=128, nhead=8(每个头16维),或者d_model=256, nhead=8(每个头32维)。
层数方面,一维信号分类的任务复杂度普遍低于NLP,4到6层足够。我试过堆到12层,精度没提升多少,训练时间翻倍,还更容易过拟合。参数量控制在50万到300万之间是个比较舒服的区间。
注意力头数也不是越多越好。头数多意味着每个头负责的子空间维度小,能捕捉更多样的模式,但也更容易过拟合。我的经验是:数据量在1万条窗口以下用4到8个头,超过10万条可以上16个头。
3.3 前馈网络维度与dropout的配合
nn.TransformerEncoderLayer里dim_feedforward默认是d_model的4倍,这个比例在信号任务里基本适用。如果你发现模型容量过剩,可以降到2倍;如果欠拟合,升到6倍试试。
dropout的设置有个容易忽略的细节:不要只在大模块上设dropout,注意力权重上的dropout更关键。Pytorch的EncoderLayer里dropout参数同时作用于注意力权重和前馈网络,所以调一个值就够了。但我发现单独给注意力设更高的dropout(比如0.2,前馈用0.1)在噪声大的信号上效果更好。如果需要这种细粒度控制,就得自己拆开写注意力模块。
3.4 一个容易忽视的点:LayerNorm的位置
Pytorch的nn.TransformerEncoderLayer默认是Post-LN(残差之后做归一化),这对深网络训练不太友好,通常需要warmup。而Pre-LN(残差之前归一化)训练更稳定,几乎不需要warmup。从Pytorch 1.12开始,EncoderLayer支持norm_first=True参数开启Pre-LN。
我的建议是一律用norm_first=True。实测在信号分类任务上,Pre-LN的收敛速度和最终精度都更优,尤其是层数超过4层之后差别明显。这个改动只需一个参数,收益却不小。
4. 训练过程中的数据划分与调参实战
4.1 按窗口划分数据集是精度虚高的元凶
这一条我要重点讲,因为它是我见过最常见的坑。假设你有100条原始信号记录,每条切成100个重叠窗口,总共1万个窗口。如果你把这1万个窗口随机打乱,8:2分成训练和测试,那么训练集里的某个窗口和测试集里的某个窗口很可能来自同一条原始记录,它们高度相似甚至部分重叠。模型"见过"类似样本,测试精度自然虚高,可能到95%以上,但上线后掉到70%。
正确做法:按原始记录ID划分,比如80条记录训练,20条测试,然后再各自切窗。这样训练集和测试集的窗口来源完全隔离,评估结果才可信。
from sklearn.model_selection import GroupShuffleSplit splitter = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_idx, test_idx = next(splitter.split(windows, labels, groups=record_ids))用了GroupShuffleSplit之后,如果你的精度从95%掉到78%,别慌,这不是模型变差了,而是之前的评估是假的。这个78%才是真实的泛化能力。
4.2 学习率调度:warmup不是必需品
因为用了Pre-LN,warmup可以省掉。直接用AdamW,学习率设1e-3到3e-4之间,配合余弦退火调度器。
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=epochs, eta_min=1e-6 )weight_decay建议用1e-4,信号任务里正则化对抑制过拟合帮助明显。如果发现训练loss下降但验证loss上升,先把weight_decay加到5e-4试试。
batch size方面,序列分类的显存消耗和batch × seq_len × d_model成正比。seq_len=512, d_model=128时,batch size设64在8GB显存上跑得动。如果显存不够,优先降batch size,别降d_model,因为d_model影响每个位置表达能力的上限。
4.3 早停与模型保存的判据
监控验证集的宏平均F1比准确率更可靠,尤其是类别不平衡的信号数据。早停的patience设10到15,保存验证F1最高的那个epoch的权重。
from sklearn.metrics import f1_score best_f1 = 0 patience_counter = 0 for epoch in range(epochs): train_one_epoch(model, train_loader, optimizer) val_preds, val_labels = evaluate(model, val_loader) f1 = f1_score(val_labels, val_preds, average='macro') if f1 > best_f1: best_f1 = f1 torch.save(model.state_dict(), 'best.pth') patience_counter = 0 else: patience_counter += 1 if patience_counter >= 15: break scheduler.step()有个小细节:scheduler.step()放在早停判断之后或者之前都行,但要和你的调度策略一致。用余弦退火时,我习惯每个epoch都step,不纠结。
4.4 类别不平衡的处理优先级
信号数据里类别不平衡太常见了,正常状态的样本远多于故障状态。处理手段按优先级排序:
- 加权交叉熵:最简单,
nn.CrossEntropyLoss(weight=class_weights),权重按类别频率的倒数算。 - 重采样:对少数类做重叠切窗扩充,或者对多数类下采样。注意重采样要在按记录划分数据集之后做,避免泄露。
- Focal Loss:类别极度不平衡(比如1:100)时才考虑,实现稍微复杂点。
我一般先用加权交叉熵,如果少数类召回率还是上不去,再试重采样。两者叠加通常没必要,容易矫枉过正。
5. 我踩过的坑与对应排查链路
5.1 第一个坑:模型训练loss不降,一直卡在随机水平
这个现象我遇到过两次,排查链路如下。
第一步,检查输入和标签是否对齐。用torch.utils.data.DataLoader取一个batch,打印x.shape和y的前几个值,确认不是标签错位。有一次我写预处理脚本时shuffle开了但没同步打乱标签,模型学了半天学了个寂寞。
第二步,检查位置编码有没有加上。忘了加位置编码的概率不低,因为forward里那行x = x + self.pos_embed很容易在复制粘贴时漏掉。不加位置编码,模型对时序顺序不敏感,在依赖顺序的任务上精度就是随机水平。
第三步,检查标准化。如果某些通道的数值大到几百甚至几千,梯度会爆炸或者消失。我习惯在数据加载后打印每个通道的均值和标准差,正常标准化后应该接近0和1。
第四步,把学习率降到1e-4再试。Transformer对学习率敏感,3e-4在某些数据集上偏大,梯度震荡导致loss不降。
5.2 第二个坑:训练精度极高,验证精度崩盘
这是典型的过拟合,但信号任务里过拟合往往有特殊原因。
先看数据泄露,就是前面说的按窗口划分数据集。如果确认划分没问题,再看模型容量。4层Transformer、d_model=128、几万参数在几千条样本上确实容易过拟合。这时候加dropout到0.3、加weight_decay、减少层数,三管齐下。
还有一个隐蔽原因:验证集和测试集的标准化参数用了各自的统计量。前面强调过,标准化参数必须从训练集统计,应用到所有集合。如果每个集合各自标准化,验证集的分布被"手工对齐"了,训练时学的统计规律对不上,验证精度自然崩。
5.3 第三个坑:推理时显存占用远超训练
训练时好好的,部署推理时显存爆了,原因通常是没切eval()模式或者没关梯度。
model.eval() with torch.no_grad(): logits = model(x) probs = torch.softmax(logits, dim=-1) preds = probs.argmax(dim=-1)另外,如果序列很长(比如seq_len=4096),自注意力的计算复杂度是O(seq_len^2),显存占用是平方级增长。这种情况要么缩短窗口,要么用滑窗分段推理再融合结果。我做过一个设备状态监测项目,seq_len=1600时单条推理显存占用接近1GB,改成seq_len=400滑窗四段推理再投票,显存降到200MB以内,精度还略有提升,因为分段后每段的信噪比更高。
5.4 第四个坑:不同批次间精度波动大
训练日志里验证精度忽高忽低,差的时候能掉十几个点,这个现象背后通常有两三个原因叠加。
一是batch size太小,梯度估计噪声大。把batch size从16提到64,波动会明显收敛。
二是数据分布本身不均匀,某些batch里全是某一类。用DataLoader的shuffle=True能缓解,但如果类别极度不平衡,最好用WeightedRandomSampler保证每个batch的类别分布相对均匀。
三是验证集太小,评估指标方差大。验证集至少要有几百条样本,指标才稳定。如果数据实在少,用交叉验证,把每个fold的结果平均。
6. 从训练到部署的工程化收尾
6.1 模型导出与推理性能
训练完的模型要部署,最省事的方式是导出成TorchScript。
model.eval() example = torch.randn(1, in_channels, seq_len) traced = torch.jit.trace(model, example) traced.save('signal_transformer.pt')导出的模型不依赖训练代码,加载后可以直接推理,推理速度比Python层的模型快20%到40%。需要注意的是,torch.jit.trace对控制流敏感,如果你的forward里有if分支或者动态形状,trace会固定成一种路径,得改用torch.jit.script。
如果部署环境是CPU,可以用torch.quantization做动态量化,模型体积减半,推理速度提升1.5到2倍,精度损失通常在1个百分点以内。对信号分类这种任务,量化后的精度基本够用。
6.2 在线推理的预处理一致性
部署阶段最容易出问题的不是模型,而是预处理不匹配。训练时用的标准化参数、窗口长度、重采样方式,推理时必须一模一样。我的做法是把这些参数和模型权重一起打包保存,推理时从同一个配置文件读取。
artifact = { 'model_state': model.state_dict(), 'mean': train_mean, 'std': train_std, 'seq_len': seq_len, 'label_map': label_map } torch.save(artifact, 'deploy.pt')这样一来,无论谁部署、在哪部署,只要加载这个artifact,预处理和模型就是绑定的,不会出现训练和推理两套逻辑的问题。这个习惯帮我省了很多排查时间。
6.3 一个实用的调参顺序建议
最后分享我实践下来的调参顺序,按收益从大到小排列:
- 先固定
d_model=128, nhead=8, num_layers=4,跑通全流程,确认没有bug。 - 调窗口长度,从256、512、1024各跑一遍,选验证F1最高的。
- 调dropout和weight_decay,解决过拟合问题。
- 调层数和
d_model,在数据量允许的范围内微调。 - 最后才调学习率和调度器,前面几步对了,学习率的影响相对小。
很多人一上来就调学习率,其实窗口长度和数据划分的影响比学习率大得多。先把前面几项定下来,后面的调参才有意义。
另外,可视化的东西别省。混淆矩阵、各类别的PR曲线、注意力权重的heatmap,这些图能告诉你模型到底在关注序列的哪些位置。我做过一个心电分类的任务,注意力权重可视化后发现模型一直在看窗口开头的几十个点,而那部分是切窗时的填充区域——这就是预处理引入的伪影,靠看指标是看不出来的。
整套流程走下来,一维信号分类用Transformer并不比CNN复杂多少,但在处理长程依赖上有天然优势。真正的门槛在数据划分和预处理的一致性上,这两块做扎实了,模型本身的调参反而是最省心的环节。