简介:这是一份基于RNN模型的轴承故障检测项目源码,面向人工智能、自动化、软件工程等计算机相关专业的在校学生、教师及企业开发者,可作为毕业设计、课程设计、课程作业或项目初期立项演示。资源源于比赛/委托项目,旨在用RNN模型判断机械是否发生故障,压缩包内包含轴承原始数据、训练集与测试集,以及数据预处理、模型定义和主流程三个Python脚本,代码全部测试通过,评审平均分达94.5分。包体共8个文件,除3个py脚本外,还有2个zip格式数据集、1个csv数据表、1个docx说明文档和1个md项目说明,整体约47.1MB,目录层次清晰,既可快速复现亦可按模块替换数据或调整网络结构。已有851人学习下载,适合初学者对照理解RNN在故障诊断中的应用,也适合在此基础上扩展特征提取、优化模型或搭建完整轴承故障检测系统。
1. 基于RNN模型的轴承故障检测:这个比赛项目到底在解决什么问题
某次设备故障诊断类的比赛上,大家用同样的公开数据集,排行榜中段的分数挤成一团。有人把振动信号按窗口切成长度为几百的序列,丢给LSTM去分类,分数直接往上跳了一截。轴承故障检测这个任务,本质上是判断一维振动时序信号属于哪种状态:正常、内圈故障、外圈故障、滚动体故障。RNN(实际落地多用LSTM/GRU)天然适合这类按时间顺序排列的信号,因为它能记住前几步的形态变化,而这个能力恰好对应轴承故障从冲击、磨损到扩展的演化过程。这篇笔记会把整个方案从数据准备、模型搭建、训练调参到坑点排查完整拆开,中间给到能直接复制运行的Python代码和参数说明。适合准备参加设备故障诊断比赛、做工业预测维护,或者手里正好有一批振动信号想训练分类模型的工程师。
2. 轴承振动数据怎么变成RNN能吃的输入:滑窗切分与数据集划分
2.1 为什么是RNN而不是直接上CNN:故障信号藏在时间依赖里
轴承故障检测的传统做法是提取时域统计量、频域包络谱特征,再给分类器。深度学习方案里,CNN在图像上很强,但处理一维振动信号时,它看到的是局部感受野内的模式,相当于在信号上滑动卷积核。RNN不同,它把信号当作一个时间序列去读,每一步的隐藏状态都包含前面所有步的信息。轴承故障的振动信号里,故障特征往往表现为周期性冲击和调制,这种前后关联正是RNN建模的重点。
不过工程上很少用最原始的RNN,因为长序列下梯度容易消失。常见做法是选LSTM或者GRU,它们有门控机制,能记住更长的依赖。很多比赛方案里用的是LSTM,部分会再叠一层双向结构。理解这一点对后续模型设计很重要:标题写RNN,落地时用LSTM/GRU是成熟且能拿分的操作,后面模型代码也按这个思路写。
2.2 滑窗切分:把长振动信号切成样本序列的代码与参数
轴承振动信号通常是连续采样的长序列。一次实验采几秒甚至几分钟,采样率可能到12kHz或48kHz,直接整段丢给RNN,序列太长,训练很慢而且梯度不稳定。常见做法是滑窗切分:用一个固定长度的窗口在信号上滑动,每个窗口切出来作为一个样本,窗口内就是RNN的一个时间步序列。
下面这段代码完成核心的切分工作。假设原始数据是CSV,每一行是一个采样点的加速度值,data目录下按故障类别分文件夹存放。
import numpy as np import pandas as pd import os from sklearn.preprocessing import LabelEncoder def load_and_split(signal, window_size=512, step_size=256): """ 将一维振动信号切分为固定长度的滑窗样本 signal: 完整的一维时序信号 window_size: 每个样本的序列长度 step_size: 窗口滑动步长 返回: X_window, 形状为 (样本数, window_size) """ X_window = [] for start in range(0, len(signal) - window_size, step_size): end = start + window_size X_window.append(signal[start:end]) return np.array(X_window) def build_dataset_from_folder(data_dir, label_map, window_size=512, step_size=256): X_all = [] y_all = [] for folder_name, label in label_map.items(): folder_path = os.path.join(data_dir, folder_name) for file_name in os.listdir(folder_path): if not file_name.endswith('.csv'): continue df = pd.read_csv(os.path.join(folder_path, file_name), header=None) signal = df.values[:, 0].astype(np.float32) # 去均值,让信号中心化为0,有利于RNN收敛 signal = signal - np.mean(signal) X_window = load_and_split(signal, window_size, step_size) X_all.append(X_window) y_all.append(np.full(len(X_window), label, dtype=np.int64)) X = np.concatenate(X_all, axis=0) y = np.concatenate(y_all, axis=0) return X, y # 使用示例:假设data目录下文件夹分别是 normal, inner, outer, ball label_map = {'normal': 0, 'inner': 1, 'outer': 2, 'ball': 3} X, y = build_dataset_from_folder('data', label_map, window_size=512, step_size=256) print('样本总数:', X.shape[0], '每个样本长度:', X.shape[1])逻辑说明:load_and_split在信号上从0开始,每次前进step_size个点,取window_size个点作为一个样本。step_size小于window_size时相邻窗口有重叠,重叠等于数据增强,样本量变大;step_size等于window_size时样本互不重叠,训练更快但样本量少。build_dataset_from_folder遍历每一个故障类别文件夹,读取CSV后先去均值。去均值这一步很重要,振动信号如果有直流偏移,RNN的输入分布会整体偏移,收敛变慢。
参数建议:采样率12kHz左右时,window_size取512或者1024比较常见。轴承转速一般在1500~3000rpm,一个旋转周期对应时间很短,512个点在12kHz采样率下约42毫秒,足够覆盖多个旋转周期,故障冲击模式能完整落到窗口里。step_size一般取window_size的25%~50%,既能增加样本量又不至于让相邻样本几乎一模一样导致过拟合。如果你的数据采样率更高,比如48kHz,window_size可以适当加大到1024或者2048。
2.3 训练集、验证集、测试集怎么分:数据泄漏是比赛里的隐形杀手
很多人在这个环节翻车。振动信号是连续的,相邻两个采样点在时间上高度相关,同一个文件切出来的滑窗样本之间也有很强的相关性。如果直接train_test_split随机打乱,训练集和验证集里会出现同一个原始信号切出来的相邻窗口,验证分数虚高,模型看起来很好,一到比赛测试集就掉点。
正确思路是按原始记录来划分。把所有原始文件先按文件名排序,取前70%的文件切窗进训练集,中间15%进验证集,最后15%进测试集。这样训练集和验证集来自不同时间段的信号,能反映模型的真实泛化能力。
from sklearn.model_selection import train_test_split # 先按文件划分,再切窗 # 假设files按时间顺序排列 train_files, test_files = train_test_split(all_files, test_size=0.2, shuffle=False) # train_files再切出一部分做验证 train_files, val_files = train_test_split(train_files, test_size=0.15, shuffle=False)代码逻辑:shuffle=False就是关键,按时间顺序保留文件的前后关系,模拟真实场景。验证集和测试集的分布更接近比赛规则。如果比赛允许你用整个数据集训练,最后再用单独留出的测试集测一次,那就更干净。
提示:切窗之后的标准化最好在训练集上计算均值和标准差,再应用到验证集和测试集。不要在整个数据集上做标准化,否则同样属于信息泄漏。
这里补充一点:标准化方式可以直接用z-score,即减均值除标准差。但在2.2的代码里我已经对每条信号分别去均值了,如果你选择进一步标准化,建议按故障类别分别处理或者在全训练集上算统计量,不要用sklearn的StandardScaler直接fit整个X,因为fit过程会用到验证集信息。
3. 模型搭建:从RNN到LSTM分类器的完整实现
3.1 网络结构怎么定:LSTM层数、隐藏层大小和全连接头的设计
面对比赛项目,模型不需要太复杂。一个标准的方案是:输入序列经过一到两层LSTM,取最后一个时间步的隐藏状态,接一个全连接层映射到故障类别数,最后加Softmax。LSTM的中间输出在哪里取,是一个容易纠结的点。
常见做法是取序列最后一个时间步的hidden state,也就是outputs[:, -1, :]。这样做的前提是最后一个时间步已经聚合了整个序列的信息。如果数据本身是平稳信号,全局信息更重要,这个做法很好用。如果担心最后一个时间步信息丢失,可以同时把所有时间步的输出做平均池化或最大池化,再把池化结果拼起来。比赛里我一般先试最后时间步,如果验证分数不理想再换池化。
import torch import torch.nn as nn class RNNBearingClassifier(nn.Module): """ LSTM轴承故障分类模型 input_size: 输入特征维度,振动信号单通道所以是1 hidden_size: LSTM隐藏层维度 num_layers: LSTM层数 num_classes: 分类数量 dropout: LSTM层之间的dropout比例 """ def __init__(self, input_size=1, hidden_size=128, num_layers=2, num_classes=4, dropout=0.3): super(RNNBearingClassifier, self).__init__() self.lstm = nn.LSTM( input_size=input_size, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, dropout=dropout if num_layers > 1 else 0.0 ) self.classifier = nn.Sequential( nn.Linear(hidden_size, 64), nn.ReLU(), nn.Dropout(dropout), nn.Linear(64, num_classes) ) self.init_weights() def init_weights(self): # LSTM权重初始化对收敛速度影响很大 for name, param in self.lstm.named_parameters(): if 'weight_ih' in name: nn.init.xavier_uniform_(param) elif 'weight_hh' in name: nn.init.orthogonal_(param) elif 'bias' in name: nn.init.constant_(param, 0) def forward(self, x): # x: (batch_size, seq_len, input_size) out, (h_n, c_n) = self.lstm(x) # 取最后一个时间步的hidden state last_hidden = h_n[-1] # (batch_size, hidden_size) logits = self.classifier(last_hidden) return logits逻辑说明:这个模型把输入序列形状定为(batch_size, seq_len, input_size),也就是每个样本是512个时间步,每个时间步输入1维振动值。LSTM的h_n形状是(num_layers, batch_size, hidden_size),h_n[-1]取最后一层的最后时间步输出,这是整个序列压缩后的特征向量。全连接头做两层映射,中间加ReLU和dropout,防止过拟合。
参数说明:hidden_size=128在大多数轴承数据集上是够用的,数据量大、类别多时可以加到256。num_layers=2是常见选择,层数再往上收益很小而且训练变慢。dropout=0.3是在隐藏层和全连接头都做了约束,如果训练loss很低但验证loss一直降不下来,可以加大到0.5。需要强调一点,输入是单通道振动信号时input_size=1,如果你把时域和频域特征拼在一起当成多通道输入,那input_size就要改成特征数量。
3.2 训练循环:跑通最小闭环,观察loss和验证准确率
模型定义好之后,需要一个能直接运行的训练脚本。下面这段代码实现了完整的训练循环,包括数据加载、训练、验证和模型保存。为了方便跑通,数据部分直接沿用第2章切好的X和y。
import torch import torch.nn as nn from torch.utils.data import TensorDataset, DataLoader from sklearn.metrics import accuracy_score device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = RNNBearingClassifier(input_size=1, hidden_size=128, num_layers=2, num_classes=4).to(device) # 把X从 (样本数, seq_len) 变成 (样本数, seq_len, 1) X_train_t = torch.FloatTensor(X_train).unsqueeze(-1) y_train_t = torch.LongTensor(y_train) X_val_t = torch.FloatTensor(X_val).unsqueeze(-1) y_val_t = torch.LongTensor(y_val) train_dataset = TensorDataset(X_train_t, y_train_t) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) val_dataset = TensorDataset(X_val_t, y_val_t) val_loader = DataLoader(val_dataset, batch_size=128, shuffle=False) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) best_val_acc = 0.0 for epoch in range(30): model.train() train_loss_sum = 0.0 for xb, yb in train_loader: xb, yb = xb.to(device), yb.to(device) optimizer.zero_grad() logits = model(xb) loss = criterion(logits, yb) loss.backward() # 梯度裁剪,防止RNN训练中常见的梯度爆炸 nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() train_loss_sum += loss.item() * xb.size(0) train_loss = train_loss_sum / len(train_loader.dataset) model.eval() preds_all = [] y_true_all = [] with torch.no_grad(): for xb, yb in val_loader: xb, yb = xb.to(device), yb.to(device) logits = model(xb) pred = torch.argmax(logits, dim=1) preds_all.extend(pred.cpu().numpy()) y_true_all.extend(yb.cpu().numpy()) val_acc = accuracy_score(y_true_all, preds_all) if val_acc > best_val_acc: best_val_acc = val_acc torch.save(model.state_dict(), 'best_rnn_bearing.pth') print(f'Epoch {epoch+1:02d} | Train Loss: {train_loss:.4f} | Val Acc: {val_acc:.4f}')逻辑说明:训练循环里三个细节值得注意。第一个是unsqueeze(-1),把每个样本从(seq_len,)变成(seq_len, 1),补齐LSTM要求的输入通道维度。第二个是梯度裁剪clip_grad_norm_,这是RNN训练里最常用的防梯度爆炸手段,很多情况下loss变成NaN都是从这里救回来的。第三个是保存验证集准确率最高的模型而不是最后一个epoch的模型,防止后期过拟合覆盖最优权重。
参数说明:lr=1e-3是Adam的常见初始值,如果训练loss下降很慢可以试2e-3,如果loss震荡可以降到5e-4。batch_size=64在一般的显存下够用,窗口越长、hidden_size越大,batch_size要相应调小。epoch=30是初始值,后面结合早停来判断。
3.3 三个关键超参数:hidden_size、num_layers、dropout怎么协同调整
很多新手只调学习率,实际上hidden_size、num_layers和dropout三者是联动的,单独调一个容易顾此失彼。
| 参数 | 小值表现 | 大值表现 | 调整建议 |
|---|---|---|---|
| hidden_size(如64) | 训练快,欠拟合,验证分数偏低 | 训练慢,容易过拟合 | 从128起步,验证集准确率不再提升时不要加 |
| num_layers(如1或2) | 表达力弱,模型记忆能力差 | 参数剧增,训练不稳定 | 数据量少于几万样本时用2层足够 |
| dropout(如0.2) | 正则化弱,训练和验证差距大 | 训练loss高,收敛慢 | 训练集和验证集准确率差距大于5%时加大 |
具体操作上,我习惯先固定num_layers=2,用hidden_size=128和dropout=0.3跑一轮。如果训练loss一直偏高、验证集准确率上不去,先调大hidden_size到256,同时把dropout降到0.2。如果训练loss很快降到很低但验证集一直跟不上,把dropout加到0.5,或者提前停止。
注意:
num_layers增加时,模型参数量会显著增加,同一个数据集上训练时间可能翻倍。在比赛时间有限的情况下,优先用隐藏层宽度换性能,不要盲目堆层数。
4. 训练过程分析与调参:怎么判断模型是真的学到了故障特征
4.1 训练曲线怎么看:欠拟合、过拟合和batch size的影响
每个epoch打印的训练loss和验证准确率不是随便看看的。常见情况有三种:第一种,训练loss和验证loss都高,说明模型没学到东西,欠拟合,此时调大hidden_size或者增加层数,不要调dropout。第二种,训练loss很低但验证loss高,过拟合,加大dropout、减小hidden_size、增加数据增强或者早停都有效。第三种,训练loss下降但验证准确率反复横跳,这往往是学习率偏大或者batch size太小造成的。
batch size这个参数经常被忽略。RNN训练时batch size影响的是梯度估计的稳定性。batch size太小,比如16,梯度噪声大,loss曲线会抖动;batch size太大,比如512,每个batch更新一次,收敛速度慢而且容易收敛到sharp minima。轴承故障数据切窗后样本量大,我用64或128居多。如果显存允许,优先把batch size定在64左右,再去调学习率。
4.2 类别不平衡:滚动体故障样本少时用class_weight和F1来兜底
比赛提供的数据集里,正常状态和部分故障类型样本量可能差距很大。比如内圈、外圈故障样本很多,滚动体故障样本很少。直接用准确率作指标,模型会把所有样本都预测成多数类,验证集准确率看起来70%以上,但实际上少数类完全没学到。
常见做法有两个方向。一个是在损失函数上加类别权重,给少数类更高的权重。另一个是把评价指标切换成macro F1,比赛一般看这个。下面给出带类别权重的训练改动。
from sklearn.utils.class_weight import compute_class_weight class_weights = compute_class_weight( class_weight='balanced', classes=np.unique(y_train), y=y_train ) class_weights_t = torch.FloatTensor(class_weights).to(device) criterion = nn.CrossEntropyLoss(weight=class_weights_t)代码逻辑:compute_class_weight根据训练集每个类别的样本数量自动算出权重,少数类权重高,多数类权重低。把这些权重传给CrossEntropyLoss,相当于每个样本的loss按类别重新加权。这样模型在预测少数类时,错误代价变高,被迫多花容量去学少数类特征。
改动之后验证集上不能只看准确率,要同时打印混淆矩阵,确认每个类别的召回率都还过得去。如果少数类准确率上来了但多数类掉太多,可以把class_weight从balanced换成手动调一下,比如多数类权重给0.5,少数类给2.0,按验证集表现微调。
4.3 测试集评估与混淆矩阵:评估代码直接决定你对模型的信心
训练完的模型不能只拿一个准确率交差。我要在测试集上输出混淆矩阵、每个类别的精确率和召回率,这一步能看出模型到底在哪些故障类型上混淆。比如内圈故障容易被预测成外圈故障,说明特征区分度不够,可以考虑在模型里加频域特征通道。
import numpy as np from sklearn.metrics import confusion_matrix, classification_report model.eval() all_preds = [] all_labels = [] with torch.no_grad(): for xb, yb in test_loader: xb = xb.to(device) logits = model(xb) preds = torch.argmax(logits, dim=1).cpu().numpy() all_preds.extend(preds) all_labels.extend(yb.numpy()) cm = confusion_matrix(all_labels, all_preds) print('混淆矩阵:') print(cm) print(classification_report(all_labels, all_preds, target_names=['normal', 'inner', 'outer', 'ball']))这段代码跑完之后,重点看两个地方。第一个是混淆矩阵的对角线是不是明显高于非对角线,如果某些类别之间互相混淆严重,说明窗口长度可能太短,故障冲击没有完整落在窗口内。第二个是每个类别的F1-score,平均值才是比赛排名的真实参考。target_names要跟标签定义保持一致,不然输出的报告没法看。
5. 避坑记录:RNN轴承故障检测里最容易翻车的五个细节
5.1 数据泄漏导致验证集分数虚高,测试集直接崩塌
现象:训练的时候验证准确率能到99%,拿比赛测试集一跑只有70%。
原因:随机划分训练集和验证集,同一个原始信号文件切出来的窗口同时出现在两边,LSTM相当于已经见过一部分答案。
解决:按原始文件划分数据,训练集和验证集来自不同的信号记录。切窗后不要在全局做标准化,统一用训练集统计量。
5.2 窗口长度随意选,模型性能忽高忽低
现象:窗口长度从256改成512,准确率涨了5个点;改成1024反而下降。
原因:窗口太短,故障冲击和调制模式装不全,RNN没法学到完整周期;窗口太长,样本数变少,序列中的噪声被当成特征。
解决:先用转速算一个旋转周期对应的采样点数,窗口长度至少覆盖2到3个旋转周期。然后在256、512、1024之间各跑一次,用验证集F1选择最优值。这个扫描成本不高,但收益往往很直接。
5.3 梯度爆炸导致loss变成NaN
现象:训练到第二个epoch,loss突然变成nan,之后一直nan。
原因:RNN反向传播时梯度经过多个时间步累积,数值容易爆炸,学习率偏大时尤其明显。
解决:加clip_grad_norm_,max_norm从0.5到1.0之间选一个值。同时把学习率降到5e-4再跑。如果还出现nan,检查输入数据里有没有NaN值。
5.4 类别不平衡下准确率虚高
现象:验证集准确率85%,但看混淆矩阵发现滚动体故障几乎全被预测成正常。
原因:滚动体故障样本太少,模型把所有样本往多数类上推,准确率依然很高。
解决:使用class_weight加权损失,评价指标改成macro F1。如果加权后多数类F1下降明显,考虑用少数类样本做数据增强。
5.5 模型在训练集表现好,换一台设备或工况就失效
现象:在比赛提供的A设备数据上测试集F1很高,但实际部署或者换数据集后分数断崖下跌。
原因:不同设备、转速、负载下的振动信号分布差异大,模型学到的特征和设备强相关。
解决:先做简单的数据清洗,检查采样率和单位是否一致。训练时可以加入随机噪声增强,或者把信号归一化到同一个量纲。如果比赛数据包含多种工况,按工况划分验证集,专门评估跨工况泛化能力。
6. 进阶技巧:让RNN故障检测再往上走的三个方向
RNN方案跑通以后,想继续刷分有三条路可以走。第一条是加注意力机制。LSTM虽然能记住信息,但最后一个时间步的hidden state把所有信息压成一个向量,中间某些关键的冲击特征可能被稀释。可以在LSTM输出后面加一个attention层,让模型自己决定每个时间步的重要性,最后加权求和作为分类特征。这样对间歇性冲击故障尤其有效。
第二条是双向LSTM。单向LSTM只看过去的信息,双向结构让每个时间步同时看到前后文。轴承故障信号里的调制边带,往往需要结合前后多个周期的形态才能判断。双向LSTM实现也简单,把nn.LSTM改成bidirectional=True,注意全连接层的输入维度要从hidden_size变成hidden_size * 2。
第三条是把时域和频域特征拼起来。实验证明,把振动信号做FFT后取幅值谱或者功率谱,拼在LSTM输入特征维度上,相当于给模型显式提供了频域先验。常见做法是每个滑窗内先加窗再做FFT,取前一半幅值谱,和原始时域信号拼接成多通道输入。这个改动对轴承故障检测的提升很稳定,代价是计算量变大。
我自己做这类比赛项目时,习惯先把最简单的LSTM跑通拿到一个基准分数,再逐一加注意力、双向、频域拼接,每加一个就对比一次验证集F1。不涨分就撤掉,涨分就保留,坚持只做有正向收益的改动,尽量不做堆砌式调参。这样每次拿到的模型都是可控的,出了问题也能快速定位到具体哪个模块。希望这次拆解的思路和代码能帮到你,也祝你在类似的数据集上少走弯路。
本文还有配套的精品资源,点击获取