ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

WDCNN轴承故障诊断:振动信号分类与t-SNE可视化实践

WDCNN轴承故障诊断:振动信号分类与t-SNE可视化实践

简介:围绕WDCNN的轴承故障诊断实践包,面向具备深度学习基础、希望将一维卷积网络应用于振动信号故障分类的学习者与工程人员。内容聚焦从原始轴承数据到故障类别判定的完整流程,并借助t-SNE观察不同故障特征的可分性。压缩包共13个文件,约8.66MB,包含10个mat数据文件、2个Python脚本和1张png图像;数据覆盖正常、内圈、外圈及滚动体等状态,脚本对应数据处理与模型运行,图像用于呈现可视化结果。已有1281人学习下载。读者可据此复现实验、梳理数据预处理与网络训练思路,结合特征分布图分析模型表征效果,也能为课程设计、论文实验和故障诊断入门提供可操作的参考。

1. 从一段振动信号说起:WDCNN 轴承故障诊断能解决什么问题

设备巡检里最让人头疼的不是机器响,而是响得不明不白。基于 WDCNN 的轴承故障诊断项目,把一段振动信号切成固定长度样本,用宽卷积核网络做端到端的深度学习特征提取,再用 t-SNE 把倒数第二层特征摊到二维平面上看类别分布。它适合手上已有振动数据、想快速跑通故障分类基线,并且需要一张能放进分析报告的 t-SNE 可视化图的开发者;也适合想从人工特征工程转到深度学习诊断的团队做第一版骨架。

2. 工程骨架与数据管线:从原始振动到 1×2048 样本

轴承故障诊断项目最容易翻车的地方,不是网络写错,而是数据管线没对齐。raw 信号怎么切窗、标签怎么绑定、训练集和验证集怎么分,这三件事决定了模型上限。WDCNN 只负责从样本里提特征,喂进去的样本如果混进了验证集片段,后面准确率再高也只是数字好看。

2.1 目录结构:把数据、模型、训练、可视化拆开

我一般把这类项目拆成数据、模型、训练、可视化四块,不把所有逻辑塞进一个main.py。这样换数据集时只动dataset.py,换网络时只动model.py,t-SNE 出图也不依赖训练脚本。

bearing-wdcnn/ ├── data/raw/ # 原始 csv/mat,按类别或工况分目录 ├── data/processed/ # 切片后的 npz,可选 ├── src/dataset.py # 滑窗、标签映射、Dataset ├── src/model.py # WDCNN 网络 ├── src/train.py # 训练、验证、早停 ├── src/tsne.py # 特征导出、t-SNE 绘图 ├── src/utils.py # 随机种子、日志、指标 ├── configs/base.yaml # 窗长、步长、学习率 └── runs/ # best.pt、日志、tsne.png

这份目录清单里,data/raw只放原始记录,不手工改;runs专门存权重、日志和图片,训练一次生成一个时间戳目录。configs/base.yaml把窗长、步长、batch size、学习率这些参数外置,后面做对比实验不用改代码。src/tsne.py单独放,是因为可视化经常要在不同 checkpoint 上重复跑,不能每次重新训练。

环境按常规 PyTorch 项目装即可,命令不复杂,关键是训练和可视化分两步跑:

python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install torch numpy scikit-learn matplotlib pandas python src/train.py --data data/raw --win 2048 --step 1024 --epochs 80 python src/tsne.py --ckpt runs/best.pt --out runs/tsne.png

第一段建虚拟环境,第二段装依赖。--win 2048表示每个样本 2048 个采样点,--step 1024表示相邻样本重叠一半;--epochs 80不是固定值,小数据集通常 30 到 80 轮就能看出趋势。tsne.py读best.pt里的权重和归一化参数,输出tsne.png。如果数据在.mat文件里,先写一个小脚本把振动通道和标签读成 NumPy 数组,不要直接在 Dataset 里解析.mat,否则训练时反复读盘会拖慢很多。

2.2 滑动窗口与标签生成:样本怎么切才不埋雷

原始振动信号是一条长序列,模型吃的是固定长度片段。常见做法是用滑动窗口切,窗长覆盖 2 到 3 个冲击周期,步长取窗长的一半左右。窗太长会把不同故障混在一起,窗太短又看不到周期性冲击。公开轴承数据常见采样率在几千到几万赫兹,2048 点是一个比较稳妥的起点。

import numpy as np import torch from torch.utils.data import Dataset class BearingWindowDataset(Dataset): def __init__(self, signals, labels, win=2048, step=1024): self.x, self.y = [], [] for sig, lab in zip(signals, labels): sig = np.asarray(sig, dtype=np.float32).reshape(-1) for start in range(0, len(sig) - win + 1, step): seg = sig[start:start + win].copy() seg = seg - seg.mean() # 去直流分量,保留冲击成分 self.x.append(seg[None, :]) # [1, win] self.y.append(int(lab)) self.x = torch.from_numpy(np.stack(self.x)) self.y = torch.tensor(self.y, dtype=torch.long) def __len__(self): return self.y.numel() def __getitem__(self, idx): return self.x[idx], self.y[idx]

这段代码的逻辑很直接:signals是每条记录的一维振动数组列表,labels是每条记录对应的故障类别。外层循环按记录走,内层循环按step切窗,每个窗口减去自身均值,去掉直流偏置后再堆成[N, 1, win]的张量。win和step是核心参数,win决定感受野,step决定样本数量和重叠度。step小于win时样本有重叠,能缓解样本不足,但重叠太多会让训练集和验证集看起来很像,后面划分要按记录文件走,不能按窗口随机打乱。

如果后面要做多通道振动或电流信号,把seg[None, :]改成np.stack([ch1, ch2], axis=0),模型第一层Conv1d的in_ch同步改成通道数。标签映射建议单独维护一个label_map,比如正常、内圈、外圈、滚动体各对应一个整数,不要依赖文件名排序去推类别编号,否则换个目录顺序标签就错位了。

2.3 归一化与数据集划分:别让验证集偷看训练集

归一化最常见的错误是先对整个数据集算均值和方差,再划分训练验证。这样验证集的分布信息已经进了训练过程,准确率会虚高。正确顺序是先按记录文件划分,再只用训练集算mean/std,最后把同一组参数应用到验证集和测试集。划分也要按记录或工况走,不能把同一条记录切出来的窗口随机分到两边。

import numpy as np def split_recordings(files, ratios=(0.7, 0.15, 0.15), seed=42): rng = np.random.default_rng(seed) files = list(files) rng.shuffle(files) n = len(files) n_tr, n_va = int(n * ratios[0]), int(n * ratios[1]) return files[:n_tr], files[n_tr:n_tr + n_va], files[n_tr + n_va:] train_files, val_files, test_files = split_recordings(all_files) train_ds = BearingWindowDataset(train_signals, train_labels, win=2048, step=1024) val_ds = BearingWindowDataset(val_signals, val_labels, win=2048, step=1024) mean = train_ds.x.mean() std = train_ds.x.std() + 1e-8 for ds in (train_ds, val_ds): ds.x = (ds.x - mean) / std

split_recordings按文件列表打乱后切分,ratios是训练、验证、测试比例,seed固定随机种子方便复现。mean和std只从train_ds.x算,1e-8防止除零。验证集和测试集用同一个mean/std,不能各自再算一遍。更严格的做法院是留出一个工况做测试,比如用负载 0 和 1 训练,负载 2 验证,负载 3 测试,这样能看出模型是不是只记住了某一组工况的分布。

注意:如果各类样本数量差很多,先看混淆矩阵再决定要不要加WeightedRandomSampler或CrossEntropyLoss(weight=...),不要一上来就上重采样。

3. WDCNN 模型拆解:宽卷积核、BN 与特征压缩链路

WDCNN 的核心思路是用第一层宽卷积核直接吃原始振动信号,靠大感受野抓故障冲击的局部形态,后面接几层小卷积核做深层特征组合。它比手工提取时域、频域特征再喂分类器的流程短,也比普通小卷积核 CNN 更适合处理轴承冲击这种短时、周期性的波形。模型不复杂,但每一层的尺寸和通道数要跟输入长度对齐。

3.1 网络结构参数:第一层为什么用 64×1 宽核

第一层用kernel_size=64、stride=16,目的是在原始信号上覆盖较长的时间窗,同时用较大步长快速降采样。轴承故障冲击往往只持续几个采样点,宽核能把冲击和周围的衰减振荡一起看进去,BN 放在卷积后面,训练初期收敛会更顺。后面几层用 3×1 小核,逐层组合局部特征,最后用全局池化压成一个向量,再接全连接分类。

层关键参数输出长度(输入 2048)
Conv116 通道,kernel 64,stride 16,padding 24128
MaxPoolkernel 2,stride 264
Conv232 通道,kernel 3,stride 1,padding 164
MaxPool2/232
Conv3-564 通道,kernel 3,padding 132
AdaptiveAvgPool输出 11
FC64→100→类别数-

这张表里,第一层输出长度按(2048 + 2×24 - 64) / 16 + 1 = 128算出来,池化后变 64,第二层池化后变 32。后面三层卷积不改变长度,只改变通道组合。最后用AdaptiveAvgPool1d(1)而不是写死Flatten维度,是为了换窗长时不用手算全连接输入。padding=24是我一般会保留的设置,既能控制输出长度,也不会让边缘信息丢得太狠。

3.2 PyTorch 实现:前向链路逐段说明

模型代码保持直白,features负责卷积特征,classifier负责分类,forward里加一个return_feat开关,t-SNE 直接复用同一套网络,不用再写一个特征提取器。

import torch import torch.nn as nn class WDCNN(nn.Module): def __init__(self, num_classes=10, in_ch=1): super().__init__() self.features = nn.Sequential( nn.Conv1d(in_ch, 16, kernel_size=64, stride=16, padding=24, bias=False), nn.BatchNorm1d(16), nn.ReLU(inplace=True), nn.MaxPool1d(2, 2), nn.Conv1d(16, 32, kernel_size=3, padding=1, bias=False), nn.BatchNorm1d(32), nn.ReLU(inplace=True), nn.MaxPool1d(2, 2), nn.Conv1d(32, 64, kernel_size=3, padding=1, bias=False), nn.BatchNorm1d(64), nn.ReLU(inplace=True), nn.Conv1d(64, 64, kernel_size=3, padding=1, bias=False), nn.BatchNorm1d(64), nn.ReLU(inplace=True), nn.Conv1d(64, 64, kernel_size=3, padding=1, bias=False), nn.BatchNorm1d(64), nn.ReLU(inplace=True), nn.AdaptiveAvgPool1d(1) ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(64, 100), nn.ReLU(inplace=True), nn.Dropout(0.3), nn.Linear(100, num_classes) ) def forward(self, x, return_feat=False): feat = self.features(x) # [B, 64, 1] logits = self.classifier(feat) # [B, num_classes] if return_feat: return logits, feat.flatten(1) # [B, 64] return logits

逻辑上,输入[B, 1, 2048]先过宽卷积,通道从 1 升到 16,长度从 2048 降到 128;两次池化后长度降到 32;三层 3×1 卷积保持长度,只做通道混合;全局平均池化把[B, 64, 32]压成[B, 64, 1],再展平送全连接。return_feat=True返回的是分类前的 64 维特征,t-SNE 用的就是这一层,不是 softmax 输出。

参数上,num_classes按你的标签表来,常见公开数据是 10 类;in_ch默认 1,多通道时改成实际通道数。Dropout(0.3)是防止全连接层过拟合的常规手段,小数据集可以调到 0.2 到 0.5。bias=False后面接 BN,卷积层可以不写偏置,少一组参数。窗长小于 1024 时第一层卷积可能输出长度不够,切窗参数要提前检查。

3.3 训练脚本:损失、优化器与早停

训练部分不要只写一个for epoch in range(...),至少要有训练和验证两套指标,保存验证集最好的权重,而不是最后一轮权重。损失函数用交叉熵,优化器用 Adam,学习率从1e-3起,权重衰减加一点防止过拟合。

import torch from torch import nn def run_epoch(model, loader, criterion, device, optimizer=None): training = optimizer is not None model.train(training) total_loss, correct, total = 0.0, 0, 0 for x, y in loader: x, y = x.to(device), y.to(device) with torch.set_grad_enabled(training): logits = model(x) loss = criterion(logits, y) if training: optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() * y.size(0) correct += (logits.argmax(1) == y).sum().item() total += y.size(0) return total_loss / total, correct / total device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = WDCNN(num_classes=len(class_names)).to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) best_acc, bad = 0.0, 0 for epoch in range(80): tr_loss, tr_acc = run_epoch(model, train_loader, criterion, device, optimizer) va_loss, va_acc = run_epoch(model, val_loader, criterion, device) if va_acc > best_acc: best_acc, bad = va_acc, 0 torch.save({"model": model.state_dict(), "mean": mean, "std": std}, "runs/best.pt") else: bad += 1 if bad >= 8: break

run_epoch用optimizer is not None区分训练和验证,训练时开梯度、反向传播、更新参数,验证时只前向。total_loss按样本数加权平均,correct统计分类正确的样本数。外层循环里,验证准确率超过历史最好就保存best.pt,连续 8 轮不提升就提前停,避免在训练集上磨太久。保存时把mean/std一起存进去,后面 t-SNE 和单条样本推理都用同一套归一化参数,省得再翻训练脚本。

类别不平衡明显时,给CrossEntropyLoss传weight,权重按训练集类别频率的倒数算,再归一化。不要用测试集调权重,也不要用验证集反复试到准确率最高,验证集只做早停和模型选择,最终指标在测试集上跑一次。

4. t-SNE 可视化:从倒数第二层特征看类别是否分开

t-SNE 在这个项目里不是训练组件,而是诊断工具。它把高维特征压到二维,让人肉眼判断模型学到的类别边界有没有分开。重点看两件事:同类样本是不是聚在一起,不同类样本有没有明显混叠。图好看不代表模型可靠,但图完全糊成一团,通常说明特征没学到东西,或者数据管线有问题。

4.1 特征导出:hook 还是直接 forward

最省事的方式是模型forward里留return_feat开关,验证和测试时直接取分类前特征。用 hook 也能做,但多一层回调,调试时容易忘记注销。我一般用显式返回,代码短,逻辑清楚。

import numpy as np import torch @torch.no_grad() def extract_features(model, loader, device): model.eval() feats, labels = [], [] for x, y in loader: x = x.to(device) _, f = model(x, return_feat=True) feats.append(f.cpu().numpy()) labels.append(y.numpy()) return np.concatenate(feats, axis=0), np.concatenate(labels, axis=0)

这段代码关掉梯度,遍历loader,每次取[B, 64]特征和标签,最后拼成[N, 64]和[N]。model.eval()必须加,BN 和 Dropout 在训练模式和推理模式下行为不同,忘了切 eval 会让特征分布带随机性。t-SNE 建议用测试集或留出工况的特征,不要用训练集,训练集聚得太开参考价值不大。

4.2 t-SNE 参数与绘图:perplexity、随机种子、颜色

t-SNE 对参数和随机种子敏感,perplexity一般取 5 到 50,样本多可以往 30 到 50 调,样本少就降到 5 到 15。init="pca"比随机初始化更稳,random_state固定后图可复现。画之前对特征做一次标准化,避免某些维度数值大主导距离。

import matplotlib.pyplot as plt from sklearn.manifold import TSNE def plot_tsne(feats, labels, class_names, out_path, seed=42): feats = (feats - feats.mean(0, keepdims=True)) / (feats.std(0, keepdims=True) + 1e-8) emb = TSNE( n_components=2, perplexity=30, init="pca", learning_rate="auto", random_state=seed ).fit_transform(feats) plt.figure(figsize=(7, 6)) for c, name in enumerate(class_names): idx = labels == c plt.scatter(emb[idx, 0], emb[idx, 1], s=12, alpha=0.8, label=name) plt.legend(markerscale=2, fontsize=9) plt.tight_layout() plt.savefig(out_path, dpi=300)

逻辑上,先按特征维度做 z-score,再送TSNE降到二维,最后按类别循环画散点。perplexity=30是常用起点,learning_rate="auto"让 sklearn 自己估学习率,老版本不支持就改成learning_rate=200。s=12控制点大小,alpha=0.8让重叠点还能看出密度,dpi=300保证报告里不糊。类别名从label_map反查,不要手写顺序,否则颜色和图例对不上。

注意:t-SNE 的坐标轴没有物理单位,只表示二维嵌入位置,不要在图里标“距离多远”或“准确率多少”。

4.3 结果误读:t-SNE 图好看不等于模型可靠

t-SNE 保留的是局部邻域关系,不是全局距离。两个簇离得远,不代表模型在测试集上一定准;两个簇挨得近,也不代表类别一定混。常见误读是拿一张训练集 t-SNE 图证明模型好,训练集特征分开是应该的,真正要看的是测试集或跨工况留出集。还有一个坑是每次换随机种子图变化很大,看到某次图很漂亮就截下来用,这种图不能当结论。

更稳妥的做法是把 t-SNE 和混淆矩阵放在一起看。t-SNE 发现某两类混在一起,回头查混淆矩阵是不是这两类互相误判;如果混淆矩阵也很差,再去看数据切片、标签映射和归一化。t-SNE 只做定性观察,不参与调参,也不拿它的图去反推准确率。图是给人看的,指标是给模型看的,两边对不上时先信指标,再查数据。

5. 避坑与排查:轴承诊断项目最容易翻车的五件事

这类项目代码量不大,坑基本集中在数据边界和评估口径上。下面五条是我在类似项目里反复遇到的,每条按现象、原因、解决写,照着排查能省不少时间。

5.1 数据泄漏:同一条记录被切进训练和验证

现象:训练准确率 99%,验证准确率也 98%,换一个新工况测试直接掉到 60% 以下。

原因:切窗后把所有窗口随机打乱再划分,同一条记录切出来的相邻窗口高度重叠,训练集和验证集里都有几乎一样的片段,验证集等于开卷考试。

解决:按记录文件或工况划分,先分文件再切窗。切完加一道检查,训练文件和验证文件不能有交集:

train_files = set(train_file_names) val_files = set(val_file_names) assert train_files.isdisjoint(val_files), "训练集和验证集用了同一批记录文件"

这段断言放在数据加载之后、训练之前,train_file_names和val_file_names是原始记录文件名列表。isdisjoint返回True才继续,交集不为空直接报错。更严格的做法是留一个工况完全不出现在训练集里,用跨工况测试看泛化。

5.2 归一化顺序错误:先标准化再划分

现象:验证集指标比测试集高很多,换个随机种子波动很大。

原因:先对整个数据集算mean/std,再划分训练验证。验证集的分布信息提前进了归一化参数,训练过程间接看到了验证集。

解决:先划分,再只用训练集算mean/std,验证集和测试集复用同一组参数。多通道数据按通道分别算,不要把所有通道混在一起算一个标量。归一化参数跟权重一起保存,推理时直接读,不要每次重新算。

5.3 标签与工况错位:文件名排序埋的雷

现象:混淆矩阵里某两类互相误判特别严重,但看波形又不像。

原因:用os.listdir的顺序直接映射标签,或者用文件名里的数字当类别编号,换一台机器目录顺序变了,标签和信号对不上。

解决:维护显式label_map,比如{"normal": 0, "inner": 1, "outer": 2, "ball": 3},读文件时按目录名或配置表取标签,不依赖排序。训练前打印每个类别的样本数和一条样本的标签,人工抽查几条,确认信号和标签对得上。

5.4 过拟合:训练准确率 99%,测试集掉到 60%

现象:训练 loss 一直降,验证 loss 先降后升,测试集准确率明显低于验证集。

原因:样本少、窗口重叠多、模型容量偏大,或者全连接层参数太多。也有可能是某一类样本在训练集里占比过高,模型偏向多数类。

解决:先加数据增强,比如加轻微高斯噪声、随机裁剪、幅值缩放;再降模型容量,减小全连接层或提高 Dropout;最后看类别权重和混淆矩阵。早停按验证集准确率或 loss 走,不要按训练 loss 停。数据增强只对训练集做,验证和测试保持原样。

5.5 t-SNE 图不收敛:随机种子和 perplexity 的影响

现象:同一份特征跑两次 t-SNE,一次分得很开,一次糊成一团。

原因:t-SNE 是随机算法,perplexity和初始化方式对结果影响大,样本量少时更明显。特征没有标准化也会让某些维度主导距离。

解决:固定random_state,先用perplexity=30跑一版,再试 10 和 50 对比;初始化用init="pca";画之前对特征做 z-score。如果多种参数下都糊,不要继续调图,回去看特征本身和混淆矩阵。t-SNE 图只作为辅助观察,不作为模型选择依据。

6. 二次开发:把 WDCNN 换成自己的数据和任务

换数据时先改采样率和窗长,不要直接套 2048。窗长按物理时间折算,比如采样率 12 kHz,2048 点约 0.17 秒,能覆盖多个冲击周期;如果采样率降到 2 kHz,同样 2048 点就是 1 秒,可能混进不同工况。多通道信号把in_ch改成通道数,第一层卷积核从64×1改成64×C,其余层不变,先跑通再考虑加残差或多尺度卷积分支。换模型不要一次改三处,先保留 WDCNN 基线,只加一个残差连接或一个 3×1 与 5×1 并行分支,看验证集和跨工况测试有没有提升。

验证方法我一般按工况留出:训练集放部分负载,验证集放另一部分,测试集放完全没见过的负载。指标看准确率、每类召回率和混淆矩阵,t-SNE 只看测试集特征。改完模型先跑一次 t-SNE,再跑一次混淆矩阵,两个结果对不上就回头查数据切片和标签映射。从那以后我每次拿到新振动数据,先按工况留出验证集,再跑模型和 t-SNE,图好看但混淆矩阵差的时候,一律以混淆矩阵为准。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进