滚动轴承故障诊断在工业设备健康管理里是个老话题,但真正落地的时候,很多人会被一个现象折磨到怀疑人生:同一个模型,在实验室数据上准确率高得吓人,一换到现场工况,性能断崖式下跌。这背后的原因不是模型不行,而是训练数据和实际数据之间存在分布偏移。这些年我试过很多办法,最后发现域自适应迁移学习是性价比最高的路子之一——不需要目标域标签,改一改网络结构和损失函数,就能把模型从实验室“搬”到现场。这篇文章就聊一聊我在TensorFlow环境下实现的一个简单可靠的域自适应轴承故障诊断方法,从原理到代码,再到调参踩坑,尽量一次讲透。
这套方法特别适合两类人看:一类是做故障诊断研究的同学,想快速上手迁移学习但又不想一上来就啃复杂的大模型;另一类是工厂里的设备智能运维工程师,手里有大量未标注现场数据,迫切需要一套能落地的模型迁移方案。只要你有Python和TensorFlow的基本功底,完整跑一遍问题不大。我会把网络怎么搭、损失函数怎么设、训练时要注意哪些细节,以及我踩过的那些坑,全部分享出来。
1. 为什么简单的域自适应能解决轴承故障诊断的痛点
1.1 工况漂移让旧模型失效
我在实际项目里遇到的情况很典型。在实验室环境采集轴承振动信号,控制好转速和负载,训练一个深度学习故障分类模型,准确率轻松做到99%。但这个模型拿到现场,面对另一台设备、另一组工况时,准确率直接掉到70%甚至更低。很多人第一反应是数据不够多,或者模型不够强,于是继续加数据、加网络层数,结果改善有限。
问题的本质是数据分布变了。同样是“内圈故障”这个类别,在1500转/分钟下的振动特征,和2200转/分钟下的振动特征,虽然都包含故障相关的冲击成分,但频带能量分布、调制特征、噪声基底都不一样。传统机器学习方法有个隐含假设:训练集和测试集来自同一分布。当这个假设被打破,再先进的模型也白搭。这在迁移学习里叫域偏移,其中训练数据所在的分布叫源域,实际应用时的数据分布叫目标域。
1.2 域适应与传统微调的区别
面对域偏移,很多人第一个想到的是微调。微调的做法是:用源域数据预训练模型,再用少量目标域标注数据对模型进行二次训练。方法本身没问题,但有一个现实障碍——故障诊断场景下的目标域标注数据极其稀缺。一台正常运行的设备,故障样本本来就不多,等你攒够覆盖各类故障的标注样本,设备可能早就出事了。
域自适应想解决的是另一个问题:目标域完全没有标注,或者只有极少量标注时,怎么把模型迁移过去。它的核心思路是学习一个公共特征空间,让源域和目标域在这个空间里的分布尽量一致。这样一来,源域上训练好的分类器就能直接套用到目标域上。对比微调,域自适应不需要目标域标签,这个特性在工业场景里的价值非常大,毕竟绝大多数现场的振动数据都是裸数据。
1.3 简单方案在工业落地中的现实优势
这几年深度学习模型越做越大,动辄几亿参数,但在工业设备健康管理这个场景里,我认为“够用、稳定、可维护”才是第一原则。现场部署环境往往只有一台普通工控机,甚至有些边缘设备连GPU都没有。一个几百万参数的模型已经能很好完成任务,为什么还要上千万参数的大模型?
这套方法采用的是一个非常经典的三模块结构:特征提取器、标签分类器、域判别器。特征提取器负责把原始信号转换成特征向量;标签分类器在源域有监督数据下学会区分故障类别;域判别器则负责判断特征向量来自源域还是目标域。训练过程本质上是一个博弈:域判别器努力分辨特征的来源,特征提取器则努力让两类特征无法被区分。当域判别器分不清特征来自哪个域,说明分布已经对齐,这时候源域的分类知识就能迁移到目标域。
用这种对抗思路,加上一个不算深的卷积网络,我在多个跨工况迁移任务里都能把目标域准确率从60%~70%拉到90%以上。这个方案参数量小、训练时间短、调参空间友好,对工业现场尤其友好。接下来我会把每个环节放大来看。
2. 整体方案设计与TensorFlow环境准备
2.1 网络结构:三模块组合
整个网络结构需要先讲清楚,因为后面所有代码和调参都围绕它展开。特征提取器是主干网络,我采用的是几个一维卷积层加全局池化,再接一个全连接层输出特征向量。一维卷积直接处理振动信号,在时间和频率两个维度上都能提取局部模式,相比直接用全连接网络处理原始波形,参数量更少、归纳偏置更合适。
标签分类器是特征提取器之后的第一个分支,由两层全连接加Softmax输出类别概率。域判别器是第二个分支,接收同样的特征向量,由两层全连接加Sigmoid输出域概率。这个结构的巧妙之处在于,特征提取器是“共享底座”,分类和域判别都在这同一个特征空间上进行。训练时,标签分类器要求特征保留故障类别信息;域判别器则要求特征抹掉域差异信息。两个目标互相牵制,最后收敛到的特征空间,既能分类、又域无关。
不用太花哨的结构,也不用预训练模型,因为轴承振动信号的故障模式本身比较清晰,发掘到足够的判别特征并不难。模型复杂反而容易过拟合,尤其是在目标域无标签、只能依赖源域监督的情况下,过拟合源域会让迁移效果更差。
2.2 输入信号处理:时域还是频域
关于输入特征,我在这套简单方案里推荐用频域特征。具体做法是,对每段振动信号先做快速傅里叶变换,取幅值谱作为模型的输入特征向量。轴承故障在频域上有非常明显的特征:不同的故障位置(内圈、外圈、滚动体)对应不同的故障特征频率及其谐波成分,频域表示把这些信息直接摊在桌面上,模型学起来容易得多。
如果不做频域变换,直接送原始时域波形进一维卷积,理论上也能学出频域信息,但需要更深的网络和更多的数据。在跨工况迁移设定下,目标域没有标签,源域数据量也有限,直接学时域表示的风险是:模型可能学到与工况强相关的浅层特征,而不是与故障本质相关的深层特征。手工做一次快速傅里叶变换相当于给模型注入了一个强先验,让后续的域对齐更容易实现。
当然,频域特征也有代价:它丢掉了信号的相位信息。但在故障诊断中,基于幅值谱的故障识别是被大量工程实践验证可行的。如果想更稳健一些,可以叠加短时傅里叶变换得到的时频图,把一维数组变成二维图,让卷积网络处理。不过本文的核心是“简单”,所以我后面的示例代码都用幅值谱特征。
2.3 环境搭建与依赖版本注意事项
TensorFlow环境我踩过不少版本坑,这里说一套目前稳定可用的组合。Python 3.8到3.10都没问题,TensorFlow建议2.4以上,我用的是2.8。安装命令很简单,但有几个细节要提醒:
- 强烈建议用虚拟环境管理依赖,避免和系统Python环境相互污染。
- GPU版本的TensorFlow需要额外安装对应版本的CUDA和cuDNN。如果你只是跑这种规模不大的网络,CPU也能接受,训练时间会慢一些,但不影响复现。
- Windows环境下,TensorFlow 2.10以上对GPU的支持策略变了,如果不想折腾CUDA,干脆用CPU版本跑,反正这个模型不大。
除了TensorFlow本身,还需要安装numpy、scipy、scikit-learn、matplotlib这几个常用库。scikit-learn主要用来画混淆矩阵和评估指标,不是必选项,但很方便。
在数据层面,有一个容易被忽视的问题:源域和目标域的数据必须走完全相同的预处理流程。比如采样率不一致,就必须先重采样到同一采样率再做快速傅里叶变换;幅值谱需要用同样的归一化方式,而且归一化的统计量(均值和标准差)只能用源域数据计算,再套用到目标域上。这样是为了避免目标域信息通过全局统计量泄漏到模型里。
3. 域对抗训练的数学原理与实现要点
3.1 域判别损失与分类损失如何协同
整套方法的训练目标包含两部分。第一部分是标签分类损失,用的是标准交叉熵,只对源域样本计算,因为源域有标签。第二部分是域判别损失,用的是二分类交叉熵,源域和目标域的样本都参与计算,标签是“这个样本来自哪个域”。
域判别损失可以认为在回答一个问题:给定一个特征向量,它更可能是源域特征还是目标域特征?如果判别器能轻松分辨,说明特征仍然保留了大量域相关的信息,分布没有对齐;如果判别器完全分不清,输出的域概率稳定在0.5左右,说明源域和目标域的特征分布已经高度重合。
特征提取器要同时面对两个相反的压力。分类损失要求特征包含足够的类别判别信息;域判别损失通过梯度反转要求特征不包含域信息。这两个目标看似矛盾,其实是让特征空间达到一种平衡:分类边界清晰,同时源域和目标域在特征空间里交织在一起。所以当训练效果好的时候,你一定能在特征可视化图上看到“红的和蓝的点混在一起,但每个聚类内部的类别标签几乎一致”的场景。
3.2 梯度反转层:手动实现比封装更直观
域对抗网络最初的论文里用了一个梯度反转层,前向传播时原样返回输入,反向传播时把梯度乘以一个负数。这个层在实现上有几种做法,很多库已经提供了现成封装,但我个人推荐在自定义训练循环里手动控制梯度,原因后面会说。
手动实现的逻辑非常直白。拿一段特征向量,同时送入标签分类器和域判别器,各自算损失。然后分别求出分类损失和域损失对特征提取器参数的梯度。更新特征提取器参数的时候,把分类损失的梯度减去一个系数倍的域损失梯度。这个“减去”就等同于梯度反转。用公式表示就是:
g_特征提取器 = g_分类 - λ * g_域
其中λ是权衡系数,控制域对齐的强度。λ太小时,模型只顾着分类,域对齐不足;λ太大时,模型强行抹掉特征里的域差异,但可能把与分类相关的信息也一并抹掉,导致分类性能下降。关于λ怎么取值,第3.3节讲。
用这种方式而不是梯度反转层封装,好处是训练过程完全可控。你可以分别查看三个子网络的梯度情况,方便定位问题。比如单位损失很大确实现场问题很好解决。我在调试时经常打印域判别准确率,如果它一直维持在0.5附近,说明域对齐基本成功;如果它很快冲到1.0,说明判别器太强了,需要减弱它的能力。
3.3 关键超参数与训练策略
这套方法需要调的超参数不多,但每个都很关键。第一个是λ,也就是域对齐强度。我常用的做法是渐进式增大:训练的前几个epoch,λ从0开始线性增加到设定上限1.0。这样做的好处是让网络先学会基本的分类能力,再逐步引入域对齐压力,训练过程更稳定。如果一开始就强加大的域惩罚,特征提取器会乱套,损失曲线上下乱跳。
第二个是学习率。特征提取器和标签分类器可以用同一个优化器,学习率设1e-3。域判别器要单独设置优化器,学习率建议压低到1e-4。为什么?对抗训练的本质是两个网络竞赛,如果域判别器跑太快,特征提取器永远追不上,训练不会收敛。压低判别器的学习率,相当于给竞赛中的一方绑上沙袋,让特征提取器有喘息空间。
第三个是批大小。我一般用128。域对抗训练对批大小比较敏感,太小的batch会导致域判别器的梯度噪声过大,训练不稳定;太大的batch会占用过多显存。如果你的数据量大,可以把batch加到256,记得同时稍微降低学习率。
训练策略上,我倾向于先单独训练特征提取器和标签分类器几个epoch,等源域分类准确率稳定在90%以上,再打开域判别器和对抗训练。这个细节很多人不注意,直接联合训练,结果前期分类和对抗互相干扰,收敛速度慢。分阶段训练本质上是给网络一个“先学会走路,再学跑步”的启动过程。
4. 核心代码实现与逐步解读
4.1 数据加载与预处理代码
先看数据加载和特征提取。假设你手里有一份振动信号数据,每条样本包含一段固定长度的时域波形,我可以把它转换成幅值谱特征。
import numpy as np from scipy.fft import rfft def signal_to_fft_feature(signal, fft_points=256): # 截取前fft_points*2个点做rfft,得到fft_points个幅值谱点 segment = signal[:fft_points * 2] spectrum = np.abs(rfft(segment)) return spectrum[:fft_points] source_features = [] source_labels = [] for signal, label in zip(source_signals, source_labels_raw): feat = signal_to_fft_feature(signal) source_features.append(feat) source_labels.append(label) target_features = [] for signal in target_signals: feat = signal_to_fft_feature(signal) target_features.append(feat) # 用源域数据计算标准化参数 all_source = np.vstack(source_features) mean = all_source.mean(axis=0) std = all_source.std(axis=0) + 1e-6 def normalize(data): return (data - mean) / std source_features = normalize(np.vstack(source_features)) target_features = normalize(np.vstack(target_features))这段代码有几个关键点。第一,快速傅里叶变换前截取固定长度,保证每条样本的特征维度一致。第二,标准化参数只从源域算,不碰目标域数据。第三,加一个小数值防止除零。实际项目中,我一般还会把幅值谱做一次对数压缩,让数值分布更均衡,模型更容易训练。实现上就是对幅值取log1p。
这里有一个容易踩的坑:如果你的源域和目标域采样率不一样,直接做快速傅里叶变换,同样点数对应的物理频率完全不同,那域对齐就无从谈起。必须先统一采样率,比如都重采样到16千赫兹,再切段、做傅里叶变换。
4.2 模型构建与损失函数代码
接下来是模型定义。我用TensorFlow的Keras接口,但不用它的Sequential,而是自定义三个子模型,方便在训练循环里分别取梯度。
import tensorflow as tf from tensorflow.keras import layers def build_feature_extractor(input_dim, feature_dim=64): inputs = layers.Input(shape=(input_dim,)) x = layers.Dense(128, activation='relu')(inputs) x = layers.Dense(128, activation='relu')(x) x = layers.Dropout(0.3)(x) features = layers.Dense(feature_dim, activation='relu')(x) return tf.keras.Model(inputs, features) def build_label_classifier(num_classes): inputs = layers.Input(shape=(64,)) x = layers.Dense(64, activation='relu')(inputs) x = layers.Dropout(0.2)(x) outputs = layers.Dense(num_classes, activation='softmax')(x) return tf.keras.Model(inputs, outputs) def build_domain_discriminator(): inputs = layers.Input(shape=(64,)) x = layers.Dense(64, activation='relu')(inputs) x = layers.Dropout(0.2)(x) outputs = layers.Dense(1, activation='sigmoid')(x) return tf.keras.Model(inputs, outputs) feature_extractor = build_feature_extractor(256) label_classifier = build_label_classifier(4) domain_discriminator = build_domain_discriminator()因为输入特征是幅值谱,维度是256维,这个网络用全连接层就够了。如果你的数据量很大,或者信号特征复杂,可以把全连接层换成一维卷积网络,把输入重排成二维的时频图。但在“简单可靠”的定位下,全连接网络是性价比最高的选择。特征维度取64,不高不低,足够表达四类或十类的故障信息,同时避免维度太高导致域对齐困难。
损失函数部分,分类用稀疏交叉熵,域判别用二分类交叉熵。数据加载时,把源域和目标域拼在一起,同时构造域标签:
cls_loss_fn = tf.keras.losses.SparseCategoricalCrossentropy(from_logits=False) domain_loss_fn = tf.keras.losses.BinaryCrossentropy(from_logits=False) # 构造组合数据 all_features = tf.concat([source_features, target_features], axis=0) domain_labels = tf.concat([tf.zeros((len(source_features), 1)), tf.ones((len(target_features), 1))], axis=0)我的组合方式是:一条batch里同时包含源域和目标域样本,域标签分别为0和1。分类损失只对源域的部分做掩码,这样就避免目标域的无标签样本干扰分类目标。
4.3 训练循环与评估代码
训练循环是整个方法最核心的部分,我直接给出自定义训练的写法。相比于用Keras的fit,自定义循环能手动控制梯度,这在域对抗训练里有不可替代的灵活性。
batch_size = 128 total_batches = (len(source_features) + len(target_features)) // batch_size opt_feat = tf.keras.optimizers.Adam(learning_rate=1e-3) opt_cls = tf.keras.optimizers.Adam(learning_rate=1e-3) opt_dom = tf.keras.optimizers.Adam(learning_rate=1e-4) lambda_adv = 1.0 for epoch in range(50): # 每个epoch重新打乱组合数据的顺序 all_indices = tf.random.shuffle(tf.range(len(all_features))) for step in range(total_batches): idx = all_indices[step * batch_size : (step + 1) * batch_size] x_batch = tf.gather(all_features, idx) d_batch = tf.gather(domain_labels, idx) with tf.GradientTape() as tape: feature = feature_extractor(x_batch, training=True) pred_label = label_classifier(feature, training=True) pred_domain = domain_discriminator(feature, training=True) # 域损失全部参与 domain_loss = domain_loss_fn(d_batch, pred_domain) # 分类损失只有源域部分参与 is_source = tf.equal(d_batch, 0) source_indices = tf.where(is_source)[:, 0] source_label = tf.cast(label_for_domain0[source_indices], tf.int32) cls_loss = cls_loss_fn(source_label, tf.gather(pred_label, source_indices)) # 分别求梯度 grads_feat_cls = tape.gradient(cls_loss, feature_extractor.trainable_variables) grads_feat_dom = tape.gradient(domain_loss, feature_extractor.trainable_variables) grads_cls = tape.gradient(cls_loss, label_classifier.trainable_variables) grads_dom = tape.gradient(domain_loss, domain_discriminator.trainable_variables) # 关键:特征提取器梯度 = 分类梯度 - lambda * 域梯度 grads_feat = [g_c - lambda_adv * g_d for g_c, g_d in zip(grads_feat_cls, grads_feat_dom)] opt_feat.apply_gradients(zip(grads_feat, feature_extractor.trainable_variables)) opt_cls.apply_gradients(zip(grads_cls, label_classifier.trainable_variables)) opt_dom.apply_gradients(zip(grads_dom, domain_discriminator.trainable_variables)) # 每轮结束打印指标 target_pred = label_classifier(feature_extractor(target_features, training=False), training=False) acc = tf.reduce_mean(tf.cast(tf.equal(tf.argmax(target_pred, axis=1), target_real_labels), tf.float32)) print(f"epoch {epoch}, cls_loss {cls_loss:.4f}, domain_loss {domain_loss:.4f}, target_acc {acc:.4f}")这段代码里,label_for_domain0需要你提前定义:它是源域样本按组合顺序排列的真实标签。我这里为了可读性省略了一些索引细节,真实项目中可以直接维护一个布尔掩码来取源域样本标签。
环境的核心就是那行梯度操作:g_c - lambda_adv * g_d。这个减法把域损失的梯度反向施加到特征提取器上,迫使它往“让域判别器迷惑”的方向更新。每次迭代更新三个子网络,特征提取器承受压力和拉力,标签分类器只关心分类,域判别器只关心分辨来源。这个分工非常干净。
5. 实验效果、常见问题与调参速查
5.1 对比实验:不迁移与迁移差距多大
用这套方法做跨工况迁移,我常用的一个实验设置是:源域用某一负载下的轴承数据,目标域用另一负载下的数据,标签只给源域。对比方案有两个:一是“不迁移”,直接在源域上训练,目标域上测试;二是“域自适应迁移”,也就是本文这套方法。
不迁移的结果往往非常难看。在负载变化明显时,目标域准确率通常在60%~75%之间,有的类别甚至完全识别不出来,比如“滚动体故障”被误判成“内圈故障”。这说明模型学到的特征被工况信息主导了。加上域自适应之后,目标域准确率能提升到90%以上。我印象很深的一次实验中,不迁移准确率为68%,迁移后达到93%,提升25个百分点。而且训练过程只需要50个epoch,跑完不到十分钟。
这里要提醒:不同数据集的提升幅度差异很大。如果源域和目标域之间只差一点点负载变化,那本来准确率就有80%,提升到92%属于锦上添花;如果跨越不同转速和不同负载,不迁移可能只有50%,迁移提升潜力就非常大。
5.2 特征可视化:t-SNE带来的直观感受
很多人问怎么看模型有没有真正对齐域分布。我的经验是两件事:第一看目标域准确率,第二看特征分布的可视化。
具体做法是,训练结束后用特征提取器分别提取源域和目标域的全量特征,拼接起来后用t-SNE降维到二维平面,点按来源域着色。迁移前,你能看到明显的两个簇群,左边红点聚集,右边蓝点聚集,泾渭分明,说明分布差异巨大。迁移后,红点和蓝点交织在一起,但如果按真实故障类别再上一次色,你会看到同一种故障的红蓝点又基本聚成一坨。这个画面就是“域无关、类别可分”的最佳证明。
画t-SNE时有一个小技巧:使用perplexity参数,数据量大时设置高一点,数据量少时设置低一点。默认30往往不够直观,可以调到15或50试试,找到最能反映结构的取值。另外,t-SNE是无监督降维,每次运行结果略有不同,这不是bug,属于正常现象。
5.3 踩坑记录与排查速查表
训练过程中最容易出问题的地方,我整理成一个速查表,都是真实项目中遇到过的。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 域判别准确率长期等于0.5,但目标域准确率也不高 | 特征提取器太弱,或者λ过大 | 减小λ,增强特征提取器能力,检查是否分阶段训练 |
| 域判别准确率瞬间冲到1.0 | 判别器学习率太高,或特征本身太容易区分 | 判别器学习率降为1e-4,增加判别器dropout |
| 分类损失下降,但目标域准确率不涨 | λ过大导致分类信息被抹掉 | 降低λ上限,或缩短对抗训练轮数 |
| 训练损失震荡剧烈 | 对抗训练不平衡 | 减小batch size,或者对域判别器梯度做裁剪 |
| 目标域准确率比不迁移还低 | 数据预处理泄漏了目标域统计量 | 检查标准化是否只用源域统计量 |
| 特征可视化没有明显对齐 | 训练epoch不够,或在分类器收敛前就打开了对抗 | 增加epoch,先单独训练分类器 |
这些坑里,数据预处理泄漏是个隐蔽问题。如果你在归一化时不小心用了全部数据的均值和标准差,等于提前看到了目标域的整体分布信息,虽然测试准确率看起来不错,但这是伪提升。真正落地时目标域数据是动态流入的,你根本不可能提前算全局统计量。所以必须坚持只用源域统计量。
另一个值得说的是训练轮数。我见过很多人训练3个epoch就急着看结果,域对抗任务和普通分类任务不一样,需要更长的迭代时间才能看到域对齐的收益。一般至少要20个epoch以上。如果数据量很大,还可以配合学习率衰减,在最后几个epoch把学习率降到1e-4,让网络收敛得更细腻。
5.4 适用边界与后续扩展
这套方法并不是万能的。如果源域和目标域包含的故障类别根本不对应,比如源域有内圈故障,目标域只有外圈故障,那域对齐就没有意义,甚至可能把故障类别信息搞混。所以在动手前,先确认两个域的故障类型空间基本一致,只是工况不同。这是域自适应方法成立的前提。
还有一个边界情况是源域和目标域差异过大时,比如一个是实验室台架、一个是复杂现场机组,两者关联性太弱,简单的对抗对齐往往不够。此时可以先做一次微调预热,或者引入更复杂的条件对抗机制。但那些方案复杂度高,不一定适合现场快速落地。
这套方法的扩展方向很多。可以改成半监督模式,把目标域少量有标注样本也加入分类损失,进一步提升上限。也可以把一维幅值谱换成二维时频图,配合二维卷积网络,识别能力更强,代价是计算量上升。如果关心可解释性,可以在特征提取器后面加一层注意力机制,让模型告诉我们它主要关注哪些频段。总体上说,这个框架是开放的,核心的域对抗训练逻辑不变,只换输入或者分支结构,就能适配更多场景。
我在实际使用中还有一个体会:域对抗训练需要耐心,不要一看到损失上升就急着调参。对抗过程本来就是波动的,只要整体趋势向下、目标域准确率在缓慢爬坡,就说明训练在往好的方向走。很多入门者最大的问题不是方法不对,而是中途放弃了。这个方法本身并不复杂,但要把训练节奏把握好,需要一点经验积累。希望我上面这些踩坑记录能帮你少走弯路。