设备点检记录里那批最让人头疼的数据,一定是故障样本少得可怜、正常样本多到发愁的那种。正常工况跑一天能攒几千条振动信号,可真正出故障的那几十分钟,能留下几十条有效记录就算运气好。拿这种数据去训深度学习模型,结果往往很尴尬:模型学会了“躺平”,不管输入什么信号都预测成正常,准确率还能高达95%以上,可现场真正要防的故障漏报,一次都没拦住。
今天聊的这个方案,标题叫“基于聚类欠采样与深度孪生自注意力网络的不平衡故障诊断”。简单说,就是用聚类欠采样把数据配平,再用孪生自注意力网络把故障特征学出来。这俩一个在数据侧“治标”,一个在模型侧“治本”,组合起来专门对付漏报问题。做设备健康管理、故障诊断算法,或者研究不平衡学习的同学,这篇内容应该能给你一些能直接落地的思路。
1. 方案设计思路:为什么非得是这个组合
1.1 不平衡故障诊断到底难在哪
故障诊断的不平衡性不是简单的“样本数量有差异”,它带来的麻烦是结构性的。
正常设备运行状态相对单一,振动信号的分布比较集中,特征也稳定。而故障状态五花八门:轴承内圈磨损、外圈裂纹、滚动体点蚀、齿轮断齿,每种故障的信号形态完全不同,即使同一种故障,在不同转速、不同负载下表现也不一样。这就导致少数类样本内部高度分散,样本量又少,模型根本学不到一个紧凑、有区分度的特征分布。
更要命的是,常规深度学习模型的训练目标是最小化整体损失,在极度不平衡的数据上,模型只需要无脑预测多数类,就能把损失压得很低。准确率这个指标在这种场景下会骗人——全部判为正常,准确率照样很好看,但故障漏报的代价可能是一整条产线停机,或者一次大型设备损坏。
所以做不平衡故障诊断,评估指标就得从准确率切换到少数类召回率、F1-score、G-mean这些更诚实的指标。方法设计也要围着“怎么让少数类不被多数类淹没”来转。
1.2 数据层面方法那么多,为什么选中聚类欠采样
解决不平衡问题,数据层面的思路无非两大类:给少数类“添丁”的过采样,以及给多数类“裁军”的欠采样。
先说过采样。SMOTE是典型代表,它在少数类样本之间插值生成新样本。听起来不错,但故障信号有个特点——信噪比低、噪声干扰大。如果少数类样本本身数量极少、又混着噪声,插值生成的伪样本极大概率落在噪声区域,不但没帮助,反而会误导模型把噪声特征当成故障特征。我在实际测试里见过,有些故障类别做完SMOTE之后F1不升反降,就是因为生成了大量不真实的中间态样本。
再看随机欠采样。它简单粗暴地从多数类里随机丢样本,把类别比例配平。问题在于随机丢会破坏多数类的分布结构。正常工况并不是一个均匀的球,它可能包含多个工况、多个转速段、多种负载状态,随机抽样可能会把某些本来就少见的正常子状态给丢掉,导致模型对正常工况的建模出现“偏科”。
聚类欠采样的思路就聪明在这里:先用聚类算法把多数类划分成若干自然簇,然后在每个簇内部按比例抽取样本。这个过程相当于把多数类压缩成一份保留原始分布结构的“精简版”,而不是随机乱扔。工业数据里,正常运行状态往往天然分成几个集群,比如低速轻载一个簇、高速重载一个簇,聚类欠采样能把这种结构信息保留下来。
用生活类比解释:随机欠采样是把一摞书随便抽掉一半,抽完可能某类书一本不剩;聚类欠采样是先按科目分好类,每科各留几本,虽然总量少了,但知识结构还是完整的。
1.3 为什么网络结构选“孪生+自注意力”
数据配平之后,接下来是特征提取。常用的CNN、LSTM在平衡数据集上表现不错,但在故障样本稀缺的场景下,它们有个共同的问题:本质上是在学一个绝对的分类边界。
CNN靠堆卷积层扩大感受野,但振动信号的故障冲击往往只占据很短的时间片段,想捕捉这种局部突变,网络得叠得很深,深了就容易过拟合。LSTM能建模时序依赖,但训练效率低,对长序列的记忆也有天花板。在少数类样本只有几十条的情况下,这两种网络都很难训练出稳健的分类器。
孪生网络把问题换了个角度:不直接学“这个信号属于哪类”,而是学“这两段信号是不是来自同一类状态”。它天然适合小样本和类别不平衡的场景——只要构造出足够多的样本对,即使某个类别只有几十条样本,也能通过两两比较的方式把特征空间拉出来。故障类型区分变成了度量学习问题,同类样本特征靠近,异类样本特征远离,分类边界是用距离衡量的,对少数类非常友好。
自注意力机制则是解决“特征到底该看哪里”的问题。振动信号里,故障特征往往淹没在强烈的背景噪声和正常振动分量中,传统卷积用固定大小的卷积核扫全图,注意力不集中。自注意力让模型自己学习每个时间位置的重要性权重,自动聚焦到故障冲击发生的片段,等于给模型配了一个会自己打聚光灯的“信号分析师”。
两个模块的组合逻辑也很清晰:聚类欠采样负责让数据分布合理,孪生自注意力负责让模型在有限样本下学到真正有判别力的特征表示。数据侧和模型侧各解决一段问题,互相不冲突。
2. 聚类欠采样模块:从算法到工程实现
2.1 聚类欠采样完整流程
聚类欠采样的实现流程可以拆成六步:
第一步,从训练集中把多数类和少数类分开。这里要注意,多数类指的是“正常工况”这一类,少数类是各类故障的合集。如果故障有好几种类型,需要把每种故障单独当成一个类别来配平,不能混在一起处理,否则模型无法区分不同故障模式。
第二步,对多数类特征做标准化处理。振动信号的特征维度通常较高,不同通道或不同特征的量纲差异大,不标准化直接聚类,距离度量会被量纲大的维度主宰。
第三步,对标准化后的多数类跑KMeans聚类。聚类数K是核心超参,后面单独说怎么选。
第四步,统计每个簇的样本容量,计算每个簇在多数类中的占比。
第五步,确定总体采样目标量,然后按簇占比把采样名额分配到各个簇。分配时需要注意,每个簇至少保留1个样本,防止某些小簇被完全抽空。
第六步,在每一个簇内部做不放回随机抽样,抽满该簇的分配名额。然后把采样得到的多数类样本和少数类样本合并,构成新的训练集。
这一步做完,训练集里多数类和少数类的比例就被拉到了预设的均衡状态。
2.2 代码实现与关键参数解析
用Python实现聚类欠采样很直接,核心代码大概长这样:
import numpy as np from sklearn.cluster import KMeans def cluster_undersample(majority_samples, n_clusters, sampling_ratio=1.0, random_state=42): """ 聚类欠采样 majority_samples: 多数类样本矩阵, shape (N, D) n_clusters: KMeans 聚类数 sampling_ratio: 采样后多数类总量 / 原多数类总量 """ kmeans = KMeans(n_clusters=n_clusters, random_state=random_state, n_init=10) cluster_labels = kmeans.fit_predict(majority_samples) unique_clusters, cluster_counts = np.unique(cluster_labels, return_counts=True) total_majority = len(majority_samples) target_total = int(total_majority * sampling_ratio) sampled_indices = [] for cluster_id in unique_clusters: # 按簇占比计算该簇应采样的数量 cluster_ratio = cluster_counts[cluster_id] / total_majority n_from_cluster = int(target_total * cluster_ratio) cluster_indices = np.where(cluster_labels == cluster_id)[0] # 当前簇样本量不够分配名额时,取全部样本 n_from_cluster = min(n_from_cluster, len(cluster_indices)) chosen = np.random.choice( cluster_indices, size=n_from_cluster, replace=False ) sampled_indices.extend(chosen) return majority_samples[sampled_indices]两个关键参数直接决定效果。
第一个是n_clusters。聚类数决定了多数类内部结构被划分的粒度。工业数据上我一般从多数类样本量的平方根附近起步,比如正常样本有5000条,K就设70左右。然后结合业务知识微调:如果现场知道设备有几个典型工况,K最好不小于工况数量;如果数据还包含不同的转速段、负载段,K还要再大一些。K太小,簇内的样本不够“纯”,保留了过多内部差异;K太大,每个簇的样本量太少,抽样的时候小簇可能被抽空,反而引入采样噪声。
第二个是sampling_ratio。如果想让训练集类别完全均衡,ratio就设为少数类总量除以多数类总量。但在故障类型很多、每种故障样本又极少的情况下,强行完全均衡会让多数类被压缩到和最小故障类一个量级,信息损失太大。我的经验是,先按完全均衡跑一个基线,然后逐步把ratio往上调,找到故障类F1开始明显下降之前那个临界点。工程中经常取ratio让多数类最终数量是少数类的2到3倍,效果比强行均衡更稳。
注意:KMeans对初始中心点敏感,
random_state必须固定,否则每次跑出来的采样结果都不一样,实验对比时会分不清性能变化是来自模型还是来自采样。
2.3 聚类欠采样的边界情况与处理
实际工业数据比理想情况脏得多,几个高频出现的坑提前说下。
第一,噪声点会形成孤立小簇。KMeans会把远离主群的噪声点强行归到某个簇,或者形成样本数极少的小簇。这些噪声点如果被采样进训练集,会让模型学到虚假的“正常模式”。处理方式有两种:一是对簇内样本量设定阈值,比如样本数少于该簇平均样本量10%的簇直接剔除;二是在聚类前先用孤立森林或3倍标准差规则把明显的离群点滤掉。
第二,多数类内部工况分布不均。有些工况运行时间长,样本多,有些短,样本少。如果只按簇占比抽样,稀务工况的样本可能被抽掉大半。如果稀务工况在业务上同样重要,可以改成每个簇均匀分配采样名额,牺牲一点整体分布保真度,换来每个子状态的覆盖。
第三,少数类内部也有不同故障模式。上面说过,不同类型故障必须分开配比。实际代码里,应该对每个故障类别分别计算样本量,再分别和多数类做配平。如果把所有故障样本当成一个“少数类”整体,模型能区分故障和正常,但分不清具体故障类型,诊断价值就少了一大半。
第四,高维特征下的聚类退化。特征维度很高时,欧氏距离会趋向于集中,KMeans的聚类效果会变差。可以先做主成分分析或者自动编码器降维到50到100维,再做聚类欠采样。效率更高,聚类结果也更干净。
3. 深度孪生自注意力网络:架构拆解与实现要点
3.1 孪生网络在故障诊断里怎么用
孪生网络的核心是一个共享权值的特征提取器,输入两段信号,分别提取特征,然后通过距离度量判断它们是否属于同一类别。
假设有一个特征提取器f_θ,输入信号x得到特征向量f_θ(x)。训练时,构造样本对(x_i, x_j),如果来自同一类别,优化目标就是让它们的特征距离尽可能小;如果来自不同类别,就让特征距离尽可能大。距离通常用欧氏距离或者余弦距离。
这种“比较式”学习方式给少数类带来的优势很明显:模型不需要见到足够多的某类样本来学绝对边界,只需要在成对学生中学会“相似/不同”的判断。就像认人一样,重要特征是通过对比来界定的,不需要认识全世界的人才能认出自己的熟人。
推理阶段也有讲究。模型不能直接把测试样本送进一个分类层输出类别,而是要把测试信号的特征向量,和已经存好的各类支撑样本的特征向量做距离比较,K近邻投票决定类别。支撑样本可以从带标签的训练数据里选,每个类别保留几个最有代表性的样本特征就行。
3.2 自注意力模块怎么嵌进特征提取器
在孪生网络里,自注意力模块放在特征提取器的中后段比较合适。原始振动信号如果直接做自注意力,序列太长、计算量太大,而且原始信号噪声重,注意力权重容易被噪声带偏。合理的结构是:信号先经过几层卷积或全连接提取初步特征,然后切成时间片段,再把片段序列送入自注意力层,最后做全局池化和特征投影。
以PyTorch为例,核心类大概是这样:
import torch import torch.nn as nn class SelfAttention1D(nn.Module): def __init__(self, embed_dim, num_heads=4, dropout=0.1): super().__init__() self.mha = nn.MultiheadAttention(embed_dim, num_heads, dropout=dropout, batch_first=True) self.norm1 = nn.LayerNorm(embed_dim) self.ffn = nn.Sequential( nn.Linear(embed_dim, embed_dim * 4), nn.ReLU(), nn.Linear(embed_dim * 4, embed_dim) ) self.norm2 = nn.LayerNorm(embed_dim) def forward(self, x): # x: (B, T, D) attn_out, _ = self.mha(x, x, x) x = self.norm1(x + attn_out) ffn_out = self.ffn(x) x = self.norm2(x + ffn_out) return x class FaultEncoder(nn.Module): def __init__(self, input_dim, seg_len=16, embed_dim=64, num_heads=4): super().__init__() self.proj = nn.Linear(input_dim, embed_dim) self.attn = SelfAttention1D(embed_dim, num_heads) self.fc = nn.Sequential( nn.Linear(seg_len * embed_dim, 128), nn.ReLU(), nn.Linear(128, 64) ) def forward(self, x): # x: (B, T) 原始振动信号 x = self.proj(x.unsqueeze(-1)) # (B, T, D) x = x.reshape(x.size(0), -1, self.attn.mha.embed_dim) # 如果序列过长,先做分段池化再进注意力 x = self.attn(x) # (B, T, D) B, T, D = x.shape x = x.reshape(B, T * D) # 展平 x = self.fc(x) x = nn.functional.normalize(x, p=2, dim=1) return x class SiameseNetwork(nn.Module): def __init__(self, input_dim, **kwargs): super().__init__() self.encoder = FaultEncoder(input_dim, **kwargs) def forward(self, x1, x2): z1 = self.encoder(x1) z2 = self.encoder(x2) return z1, z2有几个实现细节需要特别提醒。
一是残差连接不能省。自注意力虽然能捕捉远距离依赖,但深层堆叠时容易梯度退化,残差结构能稳定训练。
二是特征归一化放在最后一步。输出向量做L2归一化之后,欧氏距离和余弦相似度在数学上是等价的,训练会更稳定。不归一化直接用原始特征计算距离,特征尺度小的类别在距离比较里会吃亏。
三是序列长度控制。自注意力的复杂度是O(T²),T是序列长度。振动信号一秒钟可能就有几千个采样点,直接全序列做注意力计算量太大。我的做法是先用一维卷积或平均池化把序列长度降下来,保留关键形态但把长度缩短到几十个时间步,再做自注意力。工业数据上,把这个阈值控制在64个步长以内,训练速度和显存占用都比较友好。
3.3 损失函数与训练策略
孪生网络的训练目标是让同类特征靠近、异类特征远离。最常用的是对比损失(Contrastive Loss):
对于一对特征(z1, z2),如果它们属于同一类,损失是d²;如果属于不同类,损失是max(0, margin - d)²。其中d是特征间的欧氏距离,margin是预定阈值,表示异类样本至少要离开多远。
def contrastive_loss(z1, z2, label, margin=2.0): distance = torch.nn.functional.pairwise_distance(z1, z2) loss = torch.where( label == 1, distance.pow(2), torch.clamp(margin - distance, min=0).pow(2) ) return loss.mean()训练策略上,样本对的构造比损失函数更重要。正对(同类样本)和负对(异类样本)的比例建议控制在1比1到1比2之间。如果负对太多,模型会偷懒,把所有样本都推得远远的,反正没有正对压力。如果正对太多,模型又可能把所有样本都拉到一起。
我踩过的一个坑是“难分负对不够”。工业数据里,有些故障样本的特征本来就接近正常样本,如果训练时每次随机采样对,大概率采到那些明显好分的样本对,模型学完还是个“睁眼瞎”。后来加了在线困难样本挖掘:在每个batch内部,计算所有样本对的距离,找出距离小于当前margin的负对,把这些难分对额外加到训练里。改动不大,但故障类F1能明显再上一个台阶。
训练过程的优化器建议用AdamW,初始学习率1e-3到1e-4之间,配合warmup和余弦退火。另外,用平衡后的训练集训练,但验证集一定用保持了原始类别比例的数据,这样早停策略才有意义——如果验证集也是配平后的,模型的过拟合倾向会被隐藏。
4. 实验验证与结果分析:这套组合到底能涨多少点
4.1 实验设置与评价指标
验证方案效果需要一套完整的实验流程。我以一次模拟实验来展示完整的对比逻辑。假设数据来自某旋转机械试验台,正常状态有5000条样本,三种故障类型(内圈故障、外圈故障、滚动体故障)各只有80条样本。每条样本是1024个采样点的一维振动信号片段。按照7比3划分训练集和测试集,训练集内部做不平衡处理,测试集保持原始类别比例不变。
评价指标重点关注四个:准确率、故障类F1均值、G-mean、各类召回率。准确率只作为参考,核心看少数类的召回和F1。
4.2 基线方法对比
| 方法 | 准确率 | 故障类F1均值 | G-mean | 现象 |
|---|---|---|---|---|
| 原始CNN(不做任何不平衡处理) | 96.1% | 0.482 | 0.351 | 模型几乎放弃所有故障类 |
| 随机欠采样 + CNN | 91.5% | 0.674 | 0.612 | 有一定提升,但漏报仍然偏多 |
| SMOTE + CNN | 92.3% | 0.703 | 0.656 | 部分故障类别反而变差 |
| 聚类欠采样 + CNN | 93.8% | 0.741 | 0.698 | 数据层面的改进效果明显 |
| 聚类欠采样 + 孪生自注意力 | 95.4% | 0.863 | 0.832 | 本文组合方法 |
原始CNN的不平衡问题看得非常清楚:准确率96.1%很唬人,但故障类F1只有0.482,相当于有一半故障没被识别出来。随机欠采样把F1拉到0.674,说明配平数据确实有用;SMOTE表面分数更好,但逐类细看会发现外圈故障的召回率反而下降,插值生成的伪样本管了倒忙。聚类欠采样比随机欠采样又高了0.067的F1,这个提升主要来自多数类结构信息的保留。
最终组合的故障类F1从最开始的0.482涨到0.863,G-mean从0.351涨到0.832。注意最终的准确率并不是表格里最高的,因为模型不再无脑判正常,一些边界信号会被谨慎地判为故障,这在工业场景里恰恰是合理的——漏报和误报之间,宁可多报几次让人工看一眼。
4.3 消融实验:每个模块到底在起什么作用
| 配置 | 故障类F1均值 | 结论 |
|---|---|---|
| 完整方案:聚类欠采样 + 孪生自注意力 | 0.863 | 基准 |
| 去掉聚类欠采样,只用孪生自注意力 | 0.621 | 数据配平贡献巨大 |
| 去掉自注意力,用孪生 + CNN | 0.774 | 注意力贡献约0.09 F1 |
| 去掉孪生结构,用聚类欠采样 + 自注意力分类器 | 0.792 | 孪生结构贡献约0.07 F1 |
消融实验的结果很有意思,数据层面模块的贡献比模型层面更大。这也是工业经验的验证:不平衡诊断问题里,先把数据配平做好,再谈网络结构创新。聚类欠采样不应该被当成一个可有可无的预处理,它的贡献直接反映在0.24的F1差值上。
自注意力的贡献主要体现在难分样本的判别上。检查自注意力权重热力图可以看到,模型确实会把权重集中在故障冲击发生的时间窗附近,背景噪声区域的注意力权重很低。这种特征选择能力,是普通卷积层很难学到的。
5. 实操中的常见问题与排查技巧
5.1 高频问题速查表
实践过程中我把踩过的坑整理成了速查表,遇到问题可以直接对照着查。
| 现象 | 可能原因 | 解决措施 |
|---|---|---|
| 训练loss震荡不降 | 学习率过高、特征未归一化 | 降到1e-4以下,在输出层加L2归一化 |
| 模型把几乎所有样本判为正常 | 训练集配平没做好、正对太少 | 检查sampling_ratio,调整正负对比例到1比1左右 |
| 自注意力权重趋近均匀分布 | 序列过长、输入噪声太大 | 先用卷积下采样再进注意力,或者增大注意力head数量 |
| 测试准确率高但故障召回低 | 验证集用了配平后的数据 | 验证集必须保持原始类别比例 |
| 聚类采样结果每次不一样 | KMeans初值敏感 | 固定random_state,增大n_init |
| 训练集精度高、测试集塌方 | 过拟合,支撑样本选得不好 | 支撑样本不能选训练集全量,每类只选少量代表点 |
5.2 训练不收敛的排查思路
孪生网络训练不收敛是新手最容易碰到的难题。症状通常是loss居高不下、验证集F1始终在0.5以下徘徊。排查顺序按这几步来。
第一步,检查特征输出是否做了归一化。对比损失对距离的绝对值敏感,如果特征向量不归一化,不同batch的特征尺度忽大忽小,损失函数就会疯狂波动。加上L2归一化之后,距离范围被限制在0到2之间,训练会稳定很多。
第二步,检查正负样本对的比例和构造方式。我一个朋友的项目里,负对数量是正对的三倍,模型很快把所有样本都推开了,特征空间被撕裂,正对样本也靠不拢。把比例调到1比1之后,问题立刻缓解。
第三步,检查margin的设置是否过大。对比损失里margin如果取得太大,模型会拼命把异类样本推远,导致特征空间过于分散,正类样本反而聚不起来。工业数据上margin设在1到2之间通常够用。
5.3 推理阶段的几个关键细节
模型训练完之后,很多人只在测试集上算了个F1就收工了。但真正上线部署,推理流程和训练流程是不对称的。
测试样本的特征提取和支撑样本的特征提取必须完全一致,包括预处理、归一化、切段方式。有一个项目里,训练时用了振动信号的加速度幅值,测试时却用了速度幅值,特征分布整体偏移,后果就是训练F1接近0.9,上线后直接掉到0.5。
支撑样本的特征向量库需要持续更新。现场运行几个月后,设备的状态可能因为润滑条件变化、部件磨损累积而发生漂移,老支撑样本的代表性下降。我的做法是每周用新打标签的样本增量更新支撑特征库,这个过程不需要重新训练整个网络,成本很低,但能有效延缓模型性能衰减。
5.4 工程落地的几点忠告
最后说几句工程角度的建议。
第一,切段长度要有物理意义。振动信号切段不是随便切一个1024点就完事。切段长度最好覆盖至少一个转子旋转周期,这样每个样本都包含完整的故障冲击循环。如果切得太短,故障冲击可能只出现在部分样本里,模型学到的特征一致性差。
第二,对极少数样本要舍得做数据增强。如果某类故障只有二三十条样本,聚类欠采样再怎么努力也很难凭空变出更多信息。这时候可以对少数类信号做小幅度的平移、加噪、幅值缩放和时域扭曲,扩充样本量。增强幅度不要太大,控制在原始信号幅度的5%到10%以内,否则会引入失真。
第三,不要盲目追求端到端。有些振动信号本身含有明显的工频成分和齿轮啮合频率,这些成分对故障诊断是干扰而非帮助。先用带通滤波或谱分析把关心频段提取出来,再做不平衡处理和特征学习,效果会比直接喂原始信号好很多。这不是多此一举,而是让模型把有限的表达能力用在刀刃上。
我在实际应用里最深的一个感受是,这套方案真正解决的是“漏报”问题。以前巡检靠人耳听诊和经验判断,微弱故障很难发现;现在模型能在故障冲击还很不明显的时候给出预警,值班工程师只需要去复核模型报出的那几条少数字段。不过也要说句公道话,如果故障样本真的只有个位数,再精巧的方法也难为无米之炊,那时候不如先别上深度学习,靠传统特征和规则撑住,同时有意识地攒标注数据,等样本量上来后再切换模型。这套方案适合的是“少数类有一定数量但不多”的场景,从这个起点出发,它能帮你把故障识别能力往上实实在在拉一个台阶。