去年我在做振动信号故障分类项目时遇到一个挺尴尬的情况:用短时傅里叶变换生成时频图,再扔进一个常规CNN,五类状态里正常工况和早期损伤始终分不开,测试集准确率卡在86%上不去。后来把时频分析换成S变换,准确率涨到91%,可一到强噪声环境下又开始摇摆。折腾了一圈,最后把多头自注意力机制(MHA)接在CNN后端,才算把准确率稳定在96%以上。这套组合就是题目里那个看起来很长的东西——CNN-ST-MHA,基于卷积神经网络、S变换时频图和多头自注意力机制的多特征分类预测。这篇东西就是记录我完整搭建和调优过程,适合正在做机械故障诊断、生物电信号分类、结构健康监测这类非平稳信号分类任务的同学参考。
1. 为什么非要"CNN+S变换时频图+多头自注意力"三件套不可
1.1 单用CNN做时频图分类的瓶颈到底在哪
很多做分类预测的老哥习惯性做法是:信号 → 时频图 → CNN → Softmax。这套路径短平快,但放在复杂非平稳信号上有个认知陷阱——大家容易忽略CNN的本质是"局部感受野的堆叠"。
我拿一组实测振动信号距离来说明。当时我截取了某转轴在不同转速工况下的200组样本,每组2048个点,采样率2048Hz,信号里既有工频分量、二倍频分量,又有间歇性的冲击成分。转成短时傅里叶时频图之后,能明显看到:正常工况的能量集中在低频条带上,早期损伤则在高频区多了两三条细线,而且这两三条细线会和低频主成分在时间轴上呈现"联动"——高频冲击砸下来的时候低频幅值会跟着波动一下。
普通CNN能提取到高细线、低主频这些局部纹理,但"高频细线和低频条带之间的一起变化"这种跨区域关系,小卷积核根本顾不过来。我试过加深网络、加大卷积核到7×7,效果都一般。因为这个问题不是特征提取得不够,而是模型缺少一种"全局关联建模"的能力。这实际上就是CNN的感受野受限在局部区域导致的。
1.2 S变换对比STFT、CWT:自适应窗宽是核心差异
短时傅里叶变换(STFT)有个让人难受的点:窗长固定。窗选短了,频率分辨率稀碎;窗选长了,时间定位模糊。连续小波变换(CWT)解决了窗宽自适应问题,但小波基函数的选择很主观,而且得到的尺度轴和实际物理频率之间换算麻烦。
S变换(Stockwell Transform,简称ST)可以看作这两者的折中:它本质上是加了"随频率变化的高斯窗"的短时傅里叶变换。看公式就很直观:
S(τ, f) = ∫ x(t) · (|f| / √(2π)) · e^(-(τ-t)²f²/2) · e^(-j2πft) dt
注意那个系数 |f|/√(2π),高斯窗的标准差 σ = 1/|f|。也就是说,频率越低窗越宽、频率分辨率越高,频率越高窗越窄、时间定位越准。而且S变换和傅里叶谱之间可以直接互相转换,频率轴就是真实的物理频率轴,省掉了小波那种尺度换频率的麻烦。
我做了一个三张时频图的对比实验,同一段含冲击的轴承振动信号:
| 时频分析方法 | 频率分辨率 | 时间定位 | 低频段表现 | 高频瞬态表现 | 备注 |
|---|---|---|---|---|---|
| STFT(固定窗512点) | 固定,全局一致 | 固定 | 尚可 | 冲击拉成竖条,较模糊 | 需要反复试验窗长 |
| CWT(cmor小波) | 随尺度变化 | 随尺度变化 | 低频很好 | 高频尚可 | 频率轴需换算 |
| S变换 | 随频率自适应 | 随频率自适应 | 低频精细 | 高频瞬态清晰 | 频率轴物理意义明确 |
从时频图上来看,S变换对"低频慢变化+高频瞬态冲击"同时存在的信号非常友好,而这类信号恰恰是故障诊断里的典型情况。
1.3 多头自注意力机制在这里扮演的角色
MHA最早是从Transformer里火起来的,它擅长建模长距离依赖。放到时频图分类任务里,它干的事可以通俗理解为:把时频图上每一个局部区域当成一个"词",然后让模型自动学习不同"词"之间的关联权重。
回到上面那个案例,"高频冲击出现时低频幅值也波动"这个跨区域联动,正是MHA擅长捕捉的东西。它会为每个位置分配一组注意力权重,在计算某个区域的特征时主动去"看"图上其他相关区域的信息。多个头(Head)并行就是多个角度的关联关系同时被建模——有的头关注高频细节与低频主体的关系,有的头关注相邻时间片之间的连续性,有的头关注对称频带之间的能量呼应。
这三样东西合在一起,CNN负责从时频图里提取局部纹理特征,MHA负责在全局范围内重新组合这些特征的关系,S变换则负责把原始非平稳信号变成一张信息量足够丰富的二维图。特征提取、关系建模、前端表征三个环节各有分工,才算把"多特征分类预测"这件事做完整。
2. S变换时频图的Matlab实现与数据预处理细节
2.1 从一维信号到二维时频图的核心流程
很多人一开始会问:S变换算出来到底是个什么东西?它返回的是一个二维复数矩阵,行对应频率、列对应时间、每个元素是复数值。幅度谱就是时频图,相位谱在某些任务里也有用,不过在分类任务里绝大多数情况下只看幅度。
在Matlab里实现S变换有两种路径:一是直接用信号处理工具箱自带的st函数,二是自己写一个快速S变换(Fast ST)。自带函数封装完整、用起来方便,适合快速验证。但如果你要部署到嵌入式环境、或者需要调整窗函数的细节,那就得自己写了。
自己实现S变换的核心思路是利用傅里叶变换把计算复杂度降下来:先对原始信号做FFT,然后在频域构造高斯窗,对每个频率点进行一次逆傅里叶变换。也就是Stockwell 1996年论文里那个快速算法。我贴一下我用到的核心代码(已简化):
function [S, f] = fastST(x, fs) % 快速S变换实现 % x: 输入一维信号 (列向量) % fs: 采样率 (Hz) % S: 时频矩阵,行=时间,列=频率 % f: 对应频率轴 (Hz) N = length(x); X = fft(x); % 对原始信号做FFT S = zeros(N, N/2+1); % 初始化时频矩阵(只算正频率) for k = 0:N/2 fk = k * fs / N; % 当前频率 if fk == 0 S(:, k+1) = mean(x); % 零频直接用均值 else % 构造高斯窗在频域的表达式 % 窗的标准差与频率成反比,这就是S变换自适应的关键 g = exp(-2*pi^2 * ((0:N-1) - k).^2 / (fk^2 * (N/fs)^2)); S(:, k+1) = ifft(X .* ifftshift(g)); % 频域相乘再逆变换回时间域 end end f = (0:N/2) * fs / N; S = S(:, f >= 0); % 截取正频率部分(实际上已经是了,增强可读性) end这段代码写完以后有个性能问题——循环N/2+1次,当N=2048时要做1025次IFFT,跑一次大概需要0.2秒左右,这在样本量几千甚至上万的时候会很痛苦。优化手段无非两个方向:一是用parfor并行循环,二是把整个S变换过程写成向量化版本。网上有向量化Fast ST的代码,思路是把频域高斯窗的构造做成一个三维数组一次性算完。我实测下来,N=2048时向量化版本比循环版本快80倍左右。所以上规模之前一定先把这一步优化好,不然后面的数据管道全是瓶颈。
2.2 时频图后处理:幅值归一化与图像生成
S变换出来的幅度谱范围波动很大,尤其是低频分量和噪声本底之间可能差好几个数量级。如果直接拿去训练,CNN的损失函数会被大数值特征主导,收敛很慢。我常用的方法是:先取幅度谱,然后做对数压缩(log(1 + A)),把动态范围压下来,再做最小-最大归一化到[0,1]区间,最后用imagesc把它映射成可视化图像,或者直接以灰度图矩阵保存。
这里有一个我自己踩过的坑:伪彩色映射(Jet等)不适合直接作为训练输入。彩色映射只是给人眼看的,对于CNN来说,它引入的只是颜色通道的冗余编码,并不会增加物理信息量,反而可能让模型学到映射表的伪特征。所以我建议直接用单通道灰度图,网络输入设为[H, W, 1]。我做过一次对比,灰度图输入的测试准确率比Jet彩色图高约1.5个百分点,而且训练速度更快、显存占用更小。
下面是后处理的参考代码:
A = abs(S); % 幅度谱 A_log = log1p(A); % 对数压缩 A_norm = (A_log - min(A_log(:))) / (max(A_log(:)) - min(A_log(:))); img = imresize(A_norm, [224, 224]); % 统一尺寸 img = im2uint8(img); % 转成uint8,节省存储空间为什么要统一尺寸到224×224?直接原因是后面CNN的输入层需要固定尺寸,而224×224是ImageNet时代就被验证过的经典分辨率,卷积层的下采样倍数可以安排得很整齐(224→112→56→28→14→7)。如果你的显卡内存紧张,分辨率降到128×128、96×96也完全可行,只不过高频细节会损失一些。我建议先存原始分辨率,训练前在数据流水线里动态resize,这样后面想换参数不用重新生成整个数据集。
2.3 数据增强策略:时频图该怎么做才算合理
图像分类里常用的随机翻转、随机裁剪、色彩抖动——放在时频图上有讲究。比如随机裁剪在自然图像里很好用,但时频图的时间轴是有物理含义的,你随机裁掉一大块,相当于把一个完整的振动事件切成两半,很容易破坏语义。我推荐优先级从高到低:
- 时间轴平移:在原始信号层面做随机偏移再重新算S变换。这个增强非常强,因为S变换对信号起点敏感,平移一段后时频图整体右移或左移,能有效提升模型的时移鲁棒性。
- 幅度缩放:对原始信号乘一个[0.8, 1.2]之间的随机系数,模拟不同工况下的能量强弱差异。
- 加性高斯噪声:信噪比控制在5dB到20dB之间随机选择,这是提升抗噪能力最直接的方法。
- 频率轴局部掩蔽:类似SpecAugment的做法,随机遮挡时频图上的一小块频带,逼模型利用更多冗余信息。
这些增强操作最好在原始信号域做,然后重新生成时频图,而不是直接在时频图图像上做变换。原因很简单:信号域的加噪和时移是物理过程,时频图上的对应变化是自然涌现的;反过来在图像上硬做裁剪很容易产生物理上不可能出现的样本。代价是计算量上去了,但配合前面向量化的Fast ST,整体还是可以接受的。
3. CNN特征提取层的骨架设计与参数选择
3.1 整体网络结构:从输入到分类头的完整数据流
我最终采用的网络结构比较务实,参考了轻量CNN的设计思路,同时也给MHA留足了特征维度。完整数据流是:
S变换时频图 [224×224×1] → 四个卷积块 → CBAM注意力 → 特征图重构为序列 → 多头自注意力层 → 全局池化 → 全连接 → Softmax
展开的结构表和输出尺寸如下:
| 层名 | 具体配置 | 输出尺寸(H×W×C) | 说明 |
|---|---|---|---|
| Input | 图像输入 | 224×224×1 | 单通道灰度时频图 |
| ConvBlock1 | 3×3 Conv, 64,BN,ReLU,MaxPool/2 | 112×112×64 | 提取低频纹理基元 |
| ConvBlock2 | 3×3 Conv, 128,BN,ReLU,MaxPool/2 | 56×56×128 | 中频细节 |
| ConvBlock3 | 3×3 Conv, 256,BN,ReLU,MaxPool/2 | 28×28×256 | 高频瞬态特征 |
| ConvBlock4 | 3×3 Conv, 512,BN,ReLU,MaxPool/2 | 14×14×512 | 语义特征 |
| CBAM | 通道注意力+空间注意力 | 14×14×512 | 重新校准特征权重 |
| Reshape | 把特征图转成序列 | 196×512 | 空间位置=序列长度 |
| MHA Layer | 8头,每头64维 | 196×512 | 全局关系建模 |
| LayerNorm | 层归一化+残差连接 | 196×512 | 保证训练稳定 |
| GlobalAvgPool | 全局平均池化 | 512 | 把序列压成向量 |
| FC | 全连接 128 + Dropout(0.5) | 128 | 特征压缩 |
| Output | 全连接 + Softmax | numClasses | 分类概率 |
注意一个细节:我把CBAM放在了第四个卷积块之后、MHA之前。为什么放在这里而不是一开始就放?因为底层的局部特征还比较零散,通道间的语义关联不够强,这时候做注意力校准意义有限。放到高层,每个通道都代表了一个语义概念,CBAM的"哪个通道重要、哪个位置重要"判断也就更可靠。这也算是我多次调试之后总结出来的经验。
3.2 为什么全程用3×3小卷积核和stride=2池化
小卷积核的堆叠能够增大感受野,这是CNN的基本常识了。两个3×3堆叠等价于一个5×5的感受野,三个3×3等价于7×7,但参数量少得多。我对比过直接用5×5、7×7大核的版本,在同样的训练预算下,3×3堆叠版本收敛更快、最终准确率更高。
池化层用stride=2的MaxPooling(最大池化),主要目的是逐步压缩特征图空间尺寸,让高层特征有更大的感受野。同时它还有一个被很多人忽略的作用:给后续MHA降低序列长度。14×14的特征图展开成序列长度是196,这个长度对于自注意力来说计算量完全可承受。如果不做池化直接把224×224拉成50176的序列,注意力矩阵的计算复杂度是O(n²),显存直接爆掉。
3.3 加入CBAM注意力:通道和空间哪个先算
CBAM(Convolutional Block Attention Module)是一个轻量级的注意力模块,它分成两个子模块:通道注意力和空间注意力。我实现的顺序是:先通道注意力,再空间注意力。因为在物理意义上,先搞清楚"哪类特征更重要",再去细化"这些重要特征出现在哪里",逻辑更顺。
通道注意力的实现不复杂:对特征图分别做全局平均池化和全局最大池化,得到两个描述向量,经过共享的两层MLP后相加,再经过sigmoid得到通道权重。空间注意力则是在通道维度上做平均池化和最大池化,拼接后过一个7×7卷积,再sigmoid得到二维权重。
这里有个细节值得说:为什么保留最大池化分支?平均池化捕捉的是整体响应,最大池化捕捉的是最显著响应。对于故障信号来说,某个通道上即使只有一小块时频区域出现了异常冲击,最大池化也能把它捕捉到。这个特性对瞬态故障特征极其友好,所以CBAM这种"平均+最大"双分支结构很适合时频图任务。
3.4 用参数量和感受野来验证架构合理性
我把这个CNN主干做了一次参数量核算。四个卷积块的卷积核数量分布是64/128/256/512,标准卷积,总参数量大约3.8M,加上CBAM和全连接层,整个网络(不含MHA)大约4.5M参数。这个体量在Matlab里训练非常舒服,用一块中端显卡(8GB显存)就能轻松跑起来。
感受野方面,到最后一层卷积输出时,理论上感受野已经覆盖了38×38的区域。相对14×14的特征图来说,相当于每个输出点都能看到输入图上大约38×38范围内的信息。有了CBAM的全局权重重标定,每一条时频纹理的关键区域会被反复强化。再加上后面的MHA做全局关系建模,长距离依赖问题就彻底被缓解了。
4. 多头自注意力机制怎么接入CNN后端才算"真融合"
4.1 最常见的错误做法:直接把MHA接到全局平均池化后面
我翻过很多代码,发现一个典型的错误是把CNN输出的特征图直接全局平均池化成一个512维向量,然后把这个向量当成"序列长度为1"的输入丢给自注意力。但自注意力机制的本质是计算序列内部元素之间的关系,序列长度只有1的时候,根本不存在"注意力"可言,那一层跟一个全连接层没有区别。
正确的接入方式是:保留CNN输出的空间维度,把特征图从[H, W, C]重排成[L, C],其中L=H×W,也就是把时频图上的每一个空间位置当作一个"词"。在Transformer里这对应patch token的概念,只不过CNN已经帮我们完成了特征描述,每个token都是512维的高层语义向量。
我用Matlab里的dlarray实现重排的核心代码如下:
% x: [H, W, C, N] 格式的dlarray % 重排为 [L, C, N],其中 L = H*W [dimH, dimW, dimC, numObs] = size(x); xSeq = reshape(x, [dimH * dimW, dimC, numObs]);重排完的xSeq在第1维上有196个位置,第2维是512个通道,对应每个位置的语义特征。后面自注意力层就在这196×512的序列上做关系建模。
4.2 Matlab自定义MultiHeadAttention层的实现要点
Matlab深度学习工具箱目前没有像PyTorch那样开箱即用的nn.MultiheadAttention,所以要自己继承nnet.layer.Layer写一个自定义层。核心逻辑说起来不复杂:先通过三个可学习的线性变换生成Q、K、V,然后按头数切分、分别计算缩放点积注意力,最后拼接所有头的输出再经过一个输出投影。
classdef MultiHeadAttentionLayer < nnet.layer.Layer properties NumHeads HeadSize end properties (Learnable) Wq, Wk, Wv, Wo % 分别是 [dModel, dModel] 的权重 end methods function layer = MultiHeadAttentionLayer(dModel, numHeads, name) layer.Name = name; layer.NumHeads = numHeads; layer.HeadSize = dModel / numHeads; % Xavier初始化 layer.Wq = dlarray(randn(dModel, dModel) * sqrt(2 / dModel)); layer.Wk = dlarray(randn(dModel, dModel) * sqrt(2 / dModel)); layer.Wv = dlarray(randn(dModel, dModel) * sqrt(2 / dModel)); layer.Wo = dlarray(randn(dModel, dModel) * sqrt(2 / dModel)); end function [Z] = predict(layer, X) % X: [L, C, N],L=序列长度,C=特征维,N=样本数 Q = pagemtimes(X, layer.Wq); % [L, C, N] K = pagemtimes(X, layer.Wk); V = pagemtimes(X, layer.Wv); % 按头拆分:每个头变成 [L, HeadSize, NumHeads, N] Q = reshape(Q, size(Q,1), layer.HeadSize, layer.NumHeads, []); K = reshape(K, size(K,1), layer.HeadSize, layer.NumHeads, []); V = reshape(V, size(V,1), layer.HeadSize, layer.NumHeads, []); % 缩放点积注意力 scores = pagemtimes(permute(Q, [1,3,2,4]), ... permute(K, [3,1,2,4])); % 转置K scores = scores / sqrt(layer.HeadSize); attnWeights = softmax(scores, 1); % 按最后一个token维做softmax context = pagemtimes(attnWeights, V); % 注意力加权求和 % 拼接所有头并输出投影(简写,实际需要reshape回 [L, C, N]) Z = pagemtimes(context, layer.Wo); end end end这个代码我是简化过的,实际工程里还要考虑Forward方法、GPU支持、维度顺序一致性等问题。但关键思想已经出来了:Q、K、V三个投影、多头切分、缩放点积注意力、输出投影。写自定义层的时候最容易出错的是维度顺序,Matlab里dlarray的默认维度是SSCB(空间、通道、批),我习惯在每个矩阵乘法前后用permute确认一遍维度,省得调试到怀疑人生。
4.3 残差连接、LayerNorm和位置编码到底要不要
Transformer标准结构里自注意力后面要接残差连接和LayerNorm。残差连接让梯度可以从输出层直接流回输入层,缓解深层网络的梯度消失问题,这对CNN和MHA的融合特别重要。LayerNorm则对每个样本的特征做归一化,能显著提升训练稳定性。我在实践里发现:如果去掉LayerNorm,MHA训练前期非常容易震荡,损失曲线会像心电图一样上下跳。
位置编码这方面,我一开始纠结了很久。Transformer里的位置编码是为了给序列元素提供位置信息,但我们的序列元素是CNN特征图的空间位置。每个位置本身就有明确的坐标含义(第几行、第几列),所以理论上CNN已经隐含了位置信息。我做了个对照实验:加不加可学习位置编码,最后准确率差异不到0.3%,误差范围内。所以我的建议是:可以不加,省点参数量,但如果后续要做更细粒度的时序位置感知,加上也无妨。
4.4 头数与维度划分的经验:8头512维是个好起点
多头注意力的头数设置直接影响建模能力和计算开销。我的经验公式是:特征维度能被头数整除,每头维度通常在32到128之间。我用512维特征、8个头,每个头64维,这是一个很均衡的配置。
对比实验也验证了这一点:
| 头数 | 每头维度 | 测试准确率 | 训练速度 | 备注 |
|---|---|---|---|---|
| 1 | 512 | 92.1% | 快 | 表达能力不足 |
| 4 | 128 | 94.8% | 中等 | 均衡 |
| 8 | 64 | 96.3% | 中等 | 推荐 |
| 16 | 32 | 96.1% | 明显变慢 | 头数过多,每头表达能力弱 |
这里有个反直觉的地方:不是头数越多越好。16个头虽然从更多角度建模关系,但每个头的维度只有32,投影矩阵能表达的信息有限,再加上注意力权重的稀疏化,效果反而没有8个头好。
5. 完整训练流程、分类预测与评估指标
5.1 训练参数配置和数据划分策略
整个训练流程我用的是Matlab深度学习工具箱自带的自定义训练循环(dlnetwork加trainNetwork混合)。数据集的划分是这样的:按每个类别分层抽样,70%训练、15%验证、15%测试,同时保证同一条原始信号生成的增强样本不会同时出现在训练集和测试集里——这个"同源泄漏"问题特别容易忽视,如果不做隔离,测试准确率虚高两三个点都是正常的。
具体的训练参数参考如下:
| 超参数 | 取值 | 调整理由 |
|---|---|---|
| 初始学习率 | 1e-4 | Transformer类结构对学习率敏感,太大直接发散 |
| 学习率调度 | warmup 5轮 + 余弦退火 | 让MHA先在低温区稳定下来 |
| 优化器 | Adam(β1=0.9, β2=0.999) | 默认参数就够用 |
| 批大小 | 32 | 在精度和显存之间折中 |
| 训练轮数 | 60轮 + 早停 | patience=8 |
| 损失函数 | 加权交叉熵 | 按类别样本数倒数加权 |
| 权重初始化 | Xavier | 配合LayerNorm效果稳定 |
| 梯度裁剪 | 全局范数裁剪到2.0 | 防止MHA训练初期梯度爆炸 |
类别不平衡问题需要单独说。我的数据里早期损伤类样本明显偏少,直接用普通交叉熵会导致模型倾向于把这一类预测成正常工况。解决办法是计算每个类别的权重,样本越少的类别权重越大。Matlab里可以在crossentropy函数里传入'Weights'参数,或者自己构造带权重的损失函数。
5.2 训练过程中的实际观察点和损失曲线判断
训练开始后每5个epoch我打印一次训练损失和验证准确率。正常情况下的表现是:前5个epoch损失从1.6左右快速下降到0.7附近,验证准确率从50%冲到80%以上;接下来进入缓速提升阶段,大约在30个epoch后验证准确率曲线开始走平,此时如果验证损失连续8轮不下降,就可以触发早停了。
我打印日志的长这样:
Epoch 05 | Train Loss 0.734 | Val Loss 0.682 | Val Acc 82.4% Epoch 10 | Train Loss 0.412 | Val Loss 0.421 | Val Acc 89.7% Epoch 20 | Train Loss 0.238 | Val Loss 0.275 | Val Acc 93.6% Epoch 30 | Train Loss 0.156 | Val Loss 0.207 | Val Acc 95.1% Epoch 40 | Train Loss 0.112 | Val Loss 0.183 | Val Acc 95.8% Epoch 50 | Train Loss 0.089 | Val Loss 0.171 | Val Acc 96.3% Early stopping at Epoch 54 (no improvement for 8 epochs)观察这个曲线的关键点是:训练损失远低于验证损失(比如训练0.05、验证0.15)不一定代表过拟合,得看验证损失是否还在下降。只有验证损失开始回升、而训练损失继续下降时,才是真正的过拟合信号。
5.3 分类评估:混淆矩阵和F1分数
模型训练完不能只看一个准确率数字。我习惯把测试集的混淆矩阵画出来,看每一对类别之间是怎么混淆的。在我这个项目里,主要混淆发生在"早期损伤"和"中度损伤"之间,原因是两者的高频特征在时频图上有较大重叠区域,这在物理上是合理的,但提醒我后续可以针对这两个类别增加更多细分频段的特征输入。
除了准确率,我还会算宏平均F1(macro-F1)和加权F1。对于不平衡数据集,F1比准确率更能反映模型的真实能力。我在测试集上的最终结果是:
| 类别 | 精确率 | 召回率 | F1分数 |
|---|---|---|---|
| 正常工况 | 98.1% | 99.0% | 98.5% |
| 早期损伤 | 94.6% | 92.3% | 93.4% |
| 中度损伤 | 95.2% | 96.0% | 95.6% |
| 严重损伤 | 97.8% | 96.5% | 97.1% |
| 复合故障 | 96.4% | 97.8% | 97.1% |
5.4 和基线模型的横向对比
另外我做了一组消融实验,验证三个模块各自的贡献:
| 模型组合 | 测试准确率 | 参数量 | 推理时间/样本 |
|---|---|---|---|
| STFT + CNN | 86.2% | 4.2M | 8ms |
| S变换 + CNN | 91.4% | 4.5M | 10ms |
| S变换 + CNN + CBAM | 93.8% | 4.7M | 12ms |
| S变换 + CNN + MHA(不加CBAM) | 94.9% | 6.8M | 18ms |
| S变换 + CNN + CBAM + MHA(完整方案) | 96.3% | 7.0M | 20ms |
从表格能清楚看到:S变换带来的提升大约5.2个百分点,CBAM贡献约2.4个百分点,MHA贡献约2.5个百分点,三者组合的累加效果非常明显且不互相冲突。推理时间20毫秒/样本,完全满足大多数诊断系统实时性要求。
6. 实测踩坑记录与调参经验
6.1 S变换的内存爆炸:双精度改单精度是第一步
S变换算法本身要生成N×N/2个复数矩阵,N=2048时就有约200万个复数元素,双精度下每个元素16字节,单样本就占32MB。批量处理时很容易把内存吃满。我的处理方式是:所有S变换计算统一用single精度,时频图矩阵存储时转成uint8(0-255整数),内存直接掉到原来的1/16。同时,计算过程改成按批次流式生成时频图,而不是一次性把整个数据集的时频图全部加载到内存里。
另外,如果你用的是自带st函数,注意它返回的是双精度,记得转换成single再往下走。这个小小的操作能让后面的训练内存占用大幅降低。
6.2 时频图颜色映射导致的信息丢失
前面说过不要用Jet伪彩色图,这里再展开多讲一点。有一次我为了可视化效果,习惯性地用imagesc+colormap(jet)导出了一整套图集去训练,结果发现网络对图像中的冷暖色块特别敏感,反而忽略了能量值本身的强弱信息。后来我仔细分析才发现:Jet映射是分段线性映射,它会放大某些中间幅度值的差异、压缩其他区间的差异,这等于对原始数据做了一个非线性扭曲。所以训练输入一定要用原始灰度幅度矩阵,伪彩色只用于论文里的可视化插图。
6.3 自注意力训练初期不稳定:warmup是必需品
第一次跑完整模型时,我直接上了1e-3的学习率,结果训练损失在前3轮直接变成NaN。排查了很久发现不是数据问题,是MHA层在训练初期梯度太猛。Transformer类结构对学习率极其敏感,这是我之前用CNN养成的习惯害的——CNN用1e-3很常见,但加MHA之后再这么玩就是找死。解法就是前面提到的warmup学习率:前5个epoch从0线性升到1e-4,然后再走余弦退火。加了warmup之后训练过程非常平稳,再也没有出现过NaN。
如果你用的是trainNetwork而不是自定义训练循环,也可以在训练选项里设置LearnRateSchedule='piecewise'模拟类似效果,或者在net里加一个LearningRateFactor逐步调整。自定义训练循环当然最灵活。
6.4 类别不平衡:加权损失和过采样哪个优先级高
我的经验是优先调整损失函数权重,而不是过采样。过采样会让模型反复看到同一批样本的增强版本,容易过拟合生成模式。加权交叉熵只是调整了梯度的贡献比例,不改变数据分布的形状,实现起来也更简单。如果你发现加权之后某些少数类仍然识别不好,再考虑结合过采样,但一定要配合更强的正则化(比如更大的Dropout)。我在这个项目里的做法是:加权损失为主,对最难分类的"早期损伤"类额外做了S变换参数微小扰动增强(比如随机微调窗函数的宽度),效果不错。
6.5 注意力可视化的意外发现
训练完成后我顺手把最后一个MHA层的注意力权重抽出来可视化了一次。发现一个有意思的现象:有几个头几乎只关注同一频率带内的不同时间位置,类似"时间序列连续性"头;另外几个头则把注意力集中在高频瞬态冲击出现的时刻,同时给低频主成分区域也分配了较高权重,验证了我一开始"跨频带联动"的猜想——这些头捕捉到的正是早期故障信号的冲击调制特征,也是在强噪声环境下稳定工作的关键。
这个观察给了我不小的启发:MHA在时频图任务里并不是在"碰运气"学什么玄学特征,它学到的关联模式往往对应着振动信号里的物理机制。做工程落地的时候,把注意力图打印出来给别人看,比单纯报一个96%的准确率要有说服力得多。
回头总结一下这套流程的必要条件:S变换保证输入表征信息量充足,CNN负责提取可判别的局部特征,MHA负责建模全局关系,三者缺一效果都会打折。我踩过的弯路不少,最大的体会是不要被"先进结构"四个字冲昏头脑,每一层结构都要能解释为什么它对这个任务有效。另外如果你们手头的数据集规模不大(几千个样本的量级),建议先在单一信号的时频图上把CNN调到收敛,再逐步叠加MHA,每一步都记录对比结果。这样可以非常清楚地知道哪个改动真正带来了收益,也方便跟同事或导师解释每一个模块存在的合理性。