ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

深度学习故障诊断实战:振动信号到一维CNN分类的完整流程

深度学习故障诊断实战:振动信号到一维CNN分类的完整流程

简介:面向深度学习与故障诊断的初学者,这份入门示例包完整展示了从数据预处理、模型搭建到模型训练与评估的闭环流程。示例围绕CNN、RNN/LSTM、自编码器等常用模型,结合传感器数据与日志文本的处理场景,细化数据清洗、标准化、特征工程、数据集划分等关键环节;同时覆盖权重初始化、优化器选择、学习率调整、正则化及模型融合等训练细节,内容系统且贴近实践。压缩包共57个文件,包含40个mat数据文件、10个png可视化图、3个Python脚本、2个h5模型权重及2个说明文档,整体大小46.13MB,目录按数据、图片、模型与文档清晰划分,便于按模块对照学习。已有908人学习下载。读者可跟随脚本与说明文档,从零搭建故障诊断基线模型,理解各环节代码实现与调参优化思路,适合作为课程设计、项目入门或论文复现的参考资料。

1. 基于深度学习的故障诊断示例:从振动信号到故障分类的完整路径

A同学在课程设计里选了故障诊断方向,他复现了一篇论文的模型结构,把振动信号做FFT之后取频谱幅值,丢进全连接网络做分类,准确率只有六成多。查了半天,发现是数据划分和预处理出的问题。这个例子很典型:基于深度学习的故障诊断入门项目,难点往往不在模型结构,而在于怎么把原始信号处理成可训练的样本,再让模型真正学会故障特征。这份方案要解决的就是这个问题——从数据预处理、模型搭建到模型训练,给你一条能完整走通的路。如果你正准备做类似的毕设或课设,或者想快速搭一个故障诊断原型,这可以作为起点。

2. 数据预处理设计:滑窗切片、归一化与样本划分

2.1 为什么不能把整段原始信号直接丢给模型

原始振动信号是连续采样的时间序列。以常见的旋转机械为例,转频约30Hz、采样率24kHz时,一个旋转周期大约对应800个采样点。故障冲击信号往往叠加在周期振动和背景噪声上,如果直接把几万点的整段波形输入网络,网络既要自己定位故障冲击的位置,又要区分噪声和真实特征,对入门项目来说要求太高。而且整段信号长度不固定,批量训练也不方便。

我的做法是滑窗切片。每个样本取1024个点,约覆盖1.3个旋转周期,既能包含完整的故障冲击形态,又不至于把窗口拉得太长导致计算量浪费。步长取512,相邻窗口重叠50%,目的是在样本量有限时扩充训练样本。这里有一个需要权衡的点:重叠率越高,样本数越多,但相邻样本的相似度也越高,后面划分数据集时如果处理不当,会造成信息泄漏。这个坑在避坑章节会重点展开。

import numpy as np def sliding_window(data, window_size=1024, stride=512): n = len(data) samples = [] for start in range(0, n - window_size + 1, stride): samples.append(data[start:start + window_size]) return np.stack(samples)

这段代码的逻辑很直接:从信号起点开始,每隔stride个点取一个长度为window_size的窗口,直到剩余长度不足一个窗口为止。参数可以按实际工况调整:转频更低时适当加大window_size,保证单个样本覆盖至少一个完整旋转周期;采样率更高时同步放大窗口,避免频率分辨率过粗。我一般会把window_size和stride作为独立超参数维护,因为它们直接影响模型输入维度和最终样本数量。

2.2 归一化:只允许在训练集上拟合统计量

振动信号在不同工况下的幅值差异很大,同一传感器在不同时期采到的数据,峰值可能差出好几倍。如果不做归一化,网络前几层的梯度容易被大数值特征主导,训练过程会变得很不稳定。通常的做法是按通道做z-score归一化,也就是减均值、除标准差。

这里有一个入门项目最容易犯的错:为了省事把全部数据拼在一起调用fit_transform。这意味着验证集和测试集的均值、标准差提前参与了训练,相当于模型间接见过测试集的分布信息,验证分数会虚高。正确做法是训练集的scaler只fit一次,验证集和测试集只做transform。

from sklearn.preprocessing import StandardScaler scaler = StandardScaler() # train_windows, val_windows, test_windows 由 sliding_window 切片得到 X_train = scaler.fit_transform(train_windows.reshape(-1, 1)).reshape(-1, window_size, 1) X_val = scaler.transform(val_windows.reshape(-1, 1)).reshape(-1, window_size, 1) X_test = scaler.transform(test_windows.reshape(-1, 1)).reshape(-1, window_size, 1)

注意最后的reshape目标是(-1, window_size, 1),多出的最后一个维度是通道数。一维卷积的输入形状是(batch_size, 信号长度, 通道数),单通道振动信号这里固定为1。把通道维显式写出来,是为了后续如果要接入多传感器数据,只需要改通道数,模型主体不用动。

2.3 数据集划分:先切时间轴,再做滑窗

一个常见做法是把原始信号按时间顺序切成三段,前60%做训练、中间20%做验证、最后20%做测试。这里的关键是顺序:必须先切出三段原始信号,然后在每一段内部各自滑窗,最后合并成三个样本集合。如果先对整个信号滑窗,再把窗口随机打乱划分,训练集里很可能出现测试集窗口的时间相邻样本,这种“邻居泄漏”会让模型表现得比实际好。

# 假设原始信号 data 是一维数组,label 是整数 split1 = int(len(data) * 0.6) split2 = int(len(data) * 0.8) train_data = data[:split1] val_data = data[split1:split2] test_data = data[split2:] train_windows = sliding_window(train_data) val_windows = sliding_window(val_data) test_windows = sliding_window(test_data)

标签处理相对简单。故障诊断通常是多分类问题,比如正常、内圈故障、外圈故障、滚动体故障四类。一段连续信号的工况是固定的,所以每个窗口的标签直接继承所在信号段的故障类型,不需要逐点标注。经过这一步,原始时序信号就变成了批量的(1024, 1)样本和对应的整数标签,模型可以直接消费。

注意:如果窗口重叠率高于50%,相邻样本重合度过高,即使按时间顺序划分,也要保证训练、验证、测试来自不同的原始信号段,否则“邻居泄漏”依然存在。

3. 模型搭建:一维CNN基线到残差结构的演进

3.1 一维卷积的选型理由

故障诊断有一个老思路:先做FFT得到频谱,再把频谱当成图像去分类。这个思路不是不行,但FFT会丢失时域上的冲击位置信息,还要额外处理窗函数选择、频率分辨率等问题。更直接的做法是让模型在原始时域波形上学习特征。一维卷积网络天然适合这种输入:卷积核在时间轴上滑动,既能捕捉局部冲击特征,又比全连接网络参数少很多,不容易过拟合。

对比之下,把原始波形直接展平喂给全连接网络,参数量会非常夸张。一个1024点样本输入全连接层,隐层64个神经元就是65536个参数,而一维卷积第一层只有32乘3乘1等于96个参数。参数少了三个数量级,正则化压力小很多,这是选一维CNN作为基线的核心原因。

3.2 基线结构与每层作用

import tensorflow as tf from tensorflow.keras import layers, models def build_1d_cnn(input_length=1024, num_classes=4): model = models.Sequential([ layers.Input(shape=(input_length, 1)), layers.Conv1D(32, 3, strides=2, padding='same', activation='relu'), layers.BatchNormalization(), layers.MaxPooling1D(pool_size=2), layers.Conv1D(64, 3, strides=2, padding='same', activation='relu'), layers.BatchNormalization(), layers.GlobalAveragePooling1D(), layers.Dense(64, activation='relu'), layers.Dense(num_classes, activation='softmax') ]) return model model = build_1d_cnn() model.summary()

结构上分三段:两个卷积块负责提取不同尺度的局部特征,中间用GlobalAveragePooling1D把特征图压缩成向量,最后接两个全连接层输出分类概率。用GlobalAveragePooling1D而不是Flatten,是因为它对每个特征图取平均,把维度从(batch, 时间步, 通道数)压成(batch, 通道数),既减少了全连接层的参数量,又自带一定的平滑效果,能抑制过拟合。

第二个卷积层的卷积核数量从32翻倍到64,这是为了补偿空间分辨率减半带来的信息损失。如果继续加深网络,可以沿用“特征图变小、通道数变大”的模式,这也是图像分类网络里常见的设计逻辑。

3.3 残差模块与损失函数的选择

基线网络在简单的合成样本上够用,但碰到故障特征比较细微的工况,可能学不到足够判别性的信息。我一般会在第二个卷积块后再接一个残差模块,让梯度能够跨层回传。

def residual_block(x, output_channel, stride=1): shortcut = x x = layers.Conv1D(output_channel, 3, strides=stride, padding='same')(x) x = layers.BatchNormalization()(x) x = layers.ReLU()(x) x = layers.Conv1D(output_channel, 3, padding='same')(x) x = layers.BatchNormalization()(x) if shortcut.shape[-1] != output_channel or stride != 1: shortcut = layers.Conv1D(output_channel, 1, strides=stride)(shortcut) x = layers.add([x, shortcut]) return layers.ReLU()(x)

残差模块的关键在于shortcut分支:当输入输出形状一致时,shortcut是直连;不一致时用1乘1卷积调整通道数。深层网络的梯度可以通过shortcut直接回传,不会因为层数加深而消失。入门阶段不需要堆很多层,我把两个卷积块和一个残差块串起来就是一个完整方案。下面是用函数式API组装的方式:

input_tensor = tf.keras.Input(shape=(1024, 1)) x = layers.Conv1D(32, 3, strides=2, padding='same')(input_tensor) x = layers.BatchNormalization()(x) x = layers.ReLU()(x) x = layers.Conv1D(64, 3, strides=2, padding='same')(x) x = layers.BatchNormalization()(x) x = layers.ReLU()(x) x = residual_block(x, 128, stride=2) x = layers.GlobalAveragePooling1D()(x) x = layers.Dense(64, activation='relu')(x) outputs = layers.Dense(num_classes, activation='softmax')(x) model = tf.keras.Model(inputs=input_tensor, outputs=outputs)

模型最后用softmax输出每个类别的概率,类别数对应故障类型数。如果标签是one-hot,用categorical_crossentropy;如果标签是整数,用sparse_categorical_crossentropy,两者数学上等价。我习惯用整数标签,省一步one-hot编码。评估指标不建议只用准确率,因为故障诊断场景下正常样本往往远多于故障样本,准确率会被多数类带着走。

4. 模型训练:优化器配置、早停机制与收敛判断

4.1 训练配置与优化器选择

训练配置上,我通常用AdamW优化器,初始学习率1e-3,weight_decay设为1e-4。AdamW相比Adam多了解耦的权重衰减项,对小数据集和信号分类任务更稳,能减少过拟合。

model.compile( optimizer=tf.keras.optimizers.AdamW(learning_rate=1e-3, weight_decay=1e-4), loss='sparse_categorical_crossentropy', metrics=['accuracy'] )

学习率是训练里最敏感的超参数。1e-3对大多数信号分类任务是一个能落地的起点,但我也见过直接在1e-2上从第一个epoch开始就乱跳的情况。如果loss下降过慢,可以试着提高到2e-3或3e-3;如果loss持续震荡甚至变成NaN,优先调低到5e-4或更低,再做进一步排查。

4.2 回调机制:早停、学习率衰减与模型保存

只配固定学习率通常不够。训练初期损失下降快,后期会在最优解附近震荡,这时候需要把步长降下来。我同时挂两个回调:

from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau early_stop = EarlyStopping( monitor='val_loss', patience=15, restore_best_weights=True ) reduce_lr = ReduceLROnPlateau( monitor='val_loss', factor=0.5, patience=5, min_lr=1e-6 )

EarlyStopping每轮结束后看验证集损失,连续15轮没有再下降就停止训练,并恢复验证集损失最小的那套权重,避免把最后一轮过拟合的权重当作最终结果。ReduceLROnPlateau则负责在验证集损失连续5轮不下降时,把学习率乘0.5,让模型在接近最优点时用更小的步长精细调整。这两个回调相当于训练过程的“后悔药”,没有它们,训练到后期大概率过拟合,而且你很难确定最佳权重是第几个epoch产生的。

如果还想保留中间产物,可以用ModelCheckpoint在验证集损失最低的那一轮保存权重文件。我习惯把文件名写成best_model_val_{val_acc:.4f}.h5,方便之后对比实验。

4.3 训练日志与过拟合判断

history = model.fit( X_train, y_train, validation_data=(X_val, y_val), epochs=100, batch_size=64, callbacks=[early_stop, reduce_lr], verbose=1 )

batch_size取64,是因为1024点样本在GPU上并行计算很快,64个样本一批既能保证梯度估计相对稳定,显存占用也不高。如果是纯CPU环境,建议降到32,否则一个epoch要等很久。

单个epoch的流程是:把训练样本切成多个批次,每批做一次前向计算、反向传播和参数更新,所有批次跑完后算一次平均训练损失,再用验证集算一次验证损失。验证集不参与梯度更新,所以val_loss的变化更接近模型在未见数据上的表现。

观察日志时,只要train_loss持续下降而val_loss在第10个epoch左右开始回升,就说明模型开始过拟合,开始记住训练样本的细节而不是泛化规律。如果两者都降不下去,问题往往不在训练参数,而在前面数据预处理或模型表达能力不足。这些训练技巧不只是故障诊断专用,换成语音识别、心电图分类等一维信号任务,流程也完全一样。所谓人工智能项目落地,多数时候就是把数据、模型、训练三件事都做扎实,少出边界问题。

5. 避坑指南:故障诊断项目里最常见的五个翻车点

5.1 数据泄漏:验证集虚高的第一元凶

现象:训练出来的模型在验证集上准确率超过98%,你拿它去测试另一台设备采集的数据,准确率立刻掉到60%多。

原因:最常见的是归一化时用全量数据一起fit,或者窗口重叠后没有按时间顺序切分。两种情况都让模型在训练阶段提前接触了测试集的统计信息甚至部分样本。

解决:归一化严格按“训练集fit、测试集transform”执行;先按时间切原始信号,再在各段内部滑窗;滑窗重叠率不要超过50%。为了保险,我通常会在代码里加一个断言,确认训练集和测试集窗口的起始时间范围没有交集。

5.2 样本不均衡:把所有故障都误判成正常

现象:训练集里正常样本5000段,内圈故障样本只有300段,训练完准确率92%,但看混淆矩阵,故障类别几乎全被漏掉。

原因:交叉熵损失会更照顾多数类,模型输出多数类的预测就能把总损失压得很低,但它并没有真正学会故障特征。

解决:第一选择是用class_weight给少数类加权重,权重按样本量的倒数计算;第二选择是在滑窗时对少数类做局部过采样,比如小幅时间平移、幅值缩放,生成更多故障样本。加了权重之后,不能只看总体准确率,要看每个故障类别的F1是否都上来了。

5.3 训练崩溃:损失变成NaN

现象:训练到第8轮,loss从0.5突然变成NaN,之后所有轮次都是NaN,模型等于废了。

原因:大概率是学习率过大导致梯度爆炸,小概率是输入数据里混入了NaN或无穷值。

解决:先用一次全量扫描确认输入数据没有NaN,然后把学习率降一个数量级,从1e-3降到1e-4重跑。还不行就在优化器里加gradient clipping,比如clipnorm=1.0。另外固定随机种子,否则每次初始权重不同,问题难以复现排查。

5.4 把准确率当唯一指标

现象:答辩时导师问能不能用到产线上,你自己心里清楚,准确率再高也掩盖不了故障类别漏报严重的问题。

原因:多分类任务存在类不平衡时,准确率不是一个足够客观的指标。生产环境更关心故障发生时不漏报、正常时不误报,这两个诉求需要分开看。

解决:用混淆矩阵区分漏报和误报,用宏观F1或每个类别的F1做横评。如果故障漏报代价高,可以降低该类别在测试时的置信度阈值,获取更高召回率。

5.5 训练集样本量不足

现象:一个工况只采集了十几秒信号,滑窗后只有几十个样本,网络训练三五个epoch就过拟合。

原因:深度学习需要足够的数据量来支撑参数估计,样本太少时模型容量完全用不上。

解决:先用逻辑回归或浅层MLP做一个基线,确认数据本身可分;再考虑数据增强,比如叠加随机噪声、小幅缩放幅值、随机时间平移。这些增强策略与故障特征的物理形态有关,不要盲目做,幅度太大会破坏标签语义。

6. 验收与答辩:如何证明这套方案真的有效

6.1 设计一组可复现的对照实验

验收的第一步是确认方案真的比基线好,而不是“好像能跑”。我建议至少设置三组对比:原始波形直接输入全连接网络、FFT频谱输入全连接网络、原始波形输入一维CNN。三组用完全相同的训练集、验证集、测试集和训练轮数,记录准确率、宏观F1、参数量和训练耗时。以一组示例记录为例:

方法输入特征宏观F1参数量训练耗时
全连接原始波形0.528.1M2.4 min
全连接FFT频谱0.711.2M1.1 min
一维CNN原始波形0.860.4M3.2 min

这样答辩时可以直接说“在相同数据切分和训练轮数下,一维CNN的宏观F1比FFT基线高出15个百分点”,而不是空泛地说效果不错。

6.2 画清楚两张图

答辩中最能说明问题的可视化是两张图。第一张是训练过程的训练损失和验证损失曲线,画在同一个坐标系里,标出EarlyStopping触发的位置,说明模型没有明显过拟合。第二张是测试集混淆矩阵,行表示真实类别,列表示预测类别,标签直接用正常、内圈故障、外圈故障等业务名称。混淆矩阵能一眼看出哪些故障类别之间容易混淆,比“准确率98%”更有说服力。

如果还有第二组工况的数据做泛化测试,把泛化测试集的宏观F1单独标出来,会更有说服力。

6.3 答辩时的讲解顺序与主动交代局限

答辩讲解顺序要跟实现顺序一致,不要花时间炫耀代码细节。我的经验是:先用一分钟讲清楚数据来源和问题定义,再用一分钟讲数据预处理最关键的两个决定——按时间切分和归一化边界。接下来用三分钟讲模型结构以及每个结构选择的理由,最后用一分钟展示对照实验结果。整个过程中,把重点放在“我为什么这样设计”而不是“我写了什么代码”。

还有一个技巧:主动说局限。比如“本方案只在单一工况数据上做过验证,跨工况泛化还未测试”。这比导师问到再说要好,也显得你对方法边界有清醒认识。

从那以后,我每次做故障诊断实验,都会先花半小时确认数据切分和归一化的边界,再进入调参,这个习惯帮我少翻了好几回车,也希望这套方案能让你交作业的路顺畅一点。希望帮到你。

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

RELATED READING

延伸阅读

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