☰
基于声纹Deep-SVDD的电机异常检测:从梅尔频谱到PyQt5落地实践
2026/10/27 16:43:31 网站建设 项目流程

简介:基于声纹Deep-SVDD深度学习的电机异常检测工具,采用PyQt5构建图形界面,配合Python实现声音信号采集、分析与异常识别,面向工业设备维护、智能制造及机器学习应用开发者。工具以电机正常运行声音为基线,通过一分类深度模型刻画正常特征分布,对偏离明显的音频样本给出异常提示,可用于实时监测与早期故障预警。包体共10个文件、约4.83MB,涵盖Python源代码(py)、训练好的Deep-SVDD模型(h5)、标准化参数(scalar_ss)、阈值数据(npy)、配置文件(json)及界面预览图(png),结构清晰,便于直接运行或二次开发。该资源已有2172人学习下载,包含可直接运行的PyQt5界面与预训练模型,读者可据此理解Deep-SVDD在声学异常检测中的落地流程,也可迁移至其他设备状态监测场景。

1. 基于声纹 deep-SVDD 的电机异常检测:从听音诊断到自动化判别的落地路径

电机运转是否正常,老师傅靠耳朵听,新人靠手感摸,产线靠振动传感器测。但声音这个信号其实比振动更早暴露问题——轴承早期磨损、转子扫膛、松动的螺栓,在振动值还没超阈值时,声纹里已经有高频谐波和冲击成分的变化。这个工具的核心思路是用深度支持向量数据描述(deep-SVDD)对正常工况下的声纹特征学一个“紧致边界”,边界之外的都算异常。它只用正常样本训练,不需要收集故障数据,这对大多数工厂来说几乎是唯一现实可行的前提——因为故障样本本来就少,也不愿意等它坏。适合设备保全工程师、故障诊断算法开发者、以及正在做工业声学检测但被数据标注困住的人。下面从特征输入、模型原理、PyQt5 封装到现场调试,完整拆一遍这条技术路线。

2. 特征输入:把电机声音变成模型能学的固定维度表征

deep-SVDD 不是端到端直接吃原始波形,它和大多数工业声学方案一样,需要先把声音转成二维特征图。这里有两个常见选择:一是梅尔频谱图,二是 MFCC 倒谱系数。对电机异常检测这个场景,我更推荐梅尔频谱图直接作为输入——MFCC 在语音识别里表现好,是因为它做了 DCT 去相关,适合说话人识别这类任务,但电机异常信号往往集中在特定频带的能量突起和调制边带,梅尔频谱保留了完整的频带能量分布,不会把异常特征在去相关过程中抹掉。

2.1 采集与分帧参数:为什么采样率选 48 kHz 而不是 16 kHz

电机异响的有效频率范围通常在 20 Hz 到 12 kHz 之间,峰值在 2 kHz 到 8 kHz。按奈奎斯特采样定理,采样率至少是最高频率的两倍,16 kHz 采样率理论上能覆盖 8 kHz,但实际频谱分析里还要留出过渡带和抗混叠滤波器的衰减区间,所以 48 kHz 是更稳妥的选择——这也直接决定了后面所有特征尺寸的计算。

分帧参数我一般这样设:帧长 2048 点,帧移 512 点,窗函数用汉宁窗。2048 点对应约 42.7 ms 的时窗,在 48 kHz 采样率下频率分辨率能达到约 23.4 Hz,足够分辨电机转频的整数倍谐波;帧移 512 点对应的时域分辨率约 10.7 ms,能捕捉到轴承保持架故障这类瞬态冲击。

分帧之后每帧都要做短时傅里叶变换,然后映射到梅尔刻度。梅尔滤波器组数量取 128 是性价比最高的——太少频带分辨率不够,太多训练参数膨胀且容易过拟合。最后把连续 64 帧堆叠成一张特征图,形状是 64×128,刚好对应约 0.68 秒的声音片段,这个时长既能覆盖电机稳定运转的周期特征,又不至于让样本体积过大。

以下是完整的特征提取代码,建议直接存成feature_extractor.py:

import numpy as np import librosa def extract_log_mel_feature(file_path, sr=48000, n_fft=2048, hop_length=512, n_mels=128, n_frames=64): # 加载音频并按目标采样率重采样 y, orig_sr = librosa.load(file_path, sr=sr, mono=True) # 过滤掉首尾 0.5 秒,避免开关机和启停冲击污染特征 start = int(0.5 * sr) end = len(y) - int(0.5 * sr) y = y[start:end] if end > start else y # 计算梅尔频谱,幅值转 dB 刻度并做归一化 mel_spec = librosa.feature.melspectrogram( y=y, sr=sr, n_fft=n_fft, hop_length=hop_length, n_mels=n_mels, power=2.0 ) log_mel = librosa.power_to_db(mel_spec, ref=np.max) log_mel = (log_mel - log_mel.mean()) / (log_mel.std() + 1e-8) # 按帧数切段,不足一帧的补零到固定长度 num_frames = log_mel.shape[1] if num_frames < n_frames: pad_width = n_frames - num_frames log_mel = np.pad(log_mel, ((0, 0), (0, pad_width)), mode='constant') num_frames = n_frames # 滑动切片得到多个样本,相邻段间重叠 50%,做数据增广 stride = n_frames // 2 samples = [] for start_frame in range(0, num_frames - n_frames + 1, stride): segment = log_mel[:, start_frame:start_frame + n_frames] samples.append(segment.T) # 转成 [帧, 梅尔频带] 形状 return np.array(samples) # 使用示例:一条 3 秒的录音能得到约 3~4 个训练样本 samples = extract_log_mel_feature("motor_normal_001.wav") print(samples.shape) # 输出类似 (3, 64, 128)

这段代码的核心是把一长段录音切成多个固定大小的重叠片段。切片跨度取 32 帧(即 50% 重叠),让相邻样本之间有重复覆盖,训练时相当于做了时间维度的数据增广,这对只有少量正常样本的现场场景很有用。归一化用的ref=np.max是全局最大幅值,而不是逐帧,这是有意的——如果逐帧归一化,会把背景噪声的相对电平差异抹平,模型学到的就不是“声音形状”,而是被拉伸后的假特征。

2.2 数据集目录结构与正常样本的采集边界

deep-SVDD 的训练只需要正常样本,但“正常”的定义需要非常小心。这里的边界不是线画出来的,是数据覆盖出来的。采集正常样本时至少要覆盖三类变化:不同负载档位(空载、半载、满载)、不同转速区间、不同环境噪声水平。如果漏了某个工况,模型会把那个工况下的声音当成边界外,上线第一天就疯狂误报。

目录结构建议这样组织,后续 PyQt5 推理时加载也方便:

dataset/ ├── normal/ # 所有正常样本,不限采集时间 │ ├── idle_1.wav │ ├── half_load_2.wav │ └── full_load_1.wav ├── abnormal/ # 可选:仅用于事后验证模型判别力 │ ├── bearing_wear_1.wav │ └── loose_bolt_2.wav └── test_audio/ # 现场临时录音,推理阶段使用

一个容易忽视的细节是录音设备的一致性。训练时用 USB 麦克风采的数据,部署时换成了无线头戴麦,频响曲线不同(尤其是低频衰减差异),梅尔特征分布会整体偏移,模型在训练集上的 AUC 再好看,现场也大概率翻车。我一般会让现场采集设备与训练设备保持一致,或者采集时直接用目标设备的麦克风采样归一化特性做一次全局补偿。

3. deep-SVDD 模型训练:最小超球原理与训练配置细节

deep-SVDD 不是传统的二分类,也不是重建式自编码器那种“能重建就是正常”。它的核心思想非常直白:用神经网络把输入映射到一个特征空间,然后在特征空间里找到一个最小半径的超球,把几乎所有正常样本的特征向量包在球内。异常样本因为偏离了正常模式,映射后的特征会落在球外或球面附近,通过距离中心的距离来判断异常程度。

3.1 目标函数与两个关键约束:为什么网络不能有偏置项

deep-SVDD 的目标函数由两部分组成:

[ \min_{W} \frac{1}{n} \sum_{i=1}^{n} |\phi(x_i; W) - c|^2 + \frac{\lambda}{2} \sum_{l=1}^{L} |W_l|_F^2 ]

第一项是让所有正常样本的特征向量到超球中心 (c) 的距离尽可能小,第二项是权重衰减正则。本质上是在损失函数里压紧特征分布的半径——这和分类任务里的“拉大类别间距”正好相反,它是把所有东西往一个点上收缩。

训练里有两条硬性约束。第一条:最后一层隐藏层不能加偏置项(bias)。加了偏置等于让特征空间多了一个可平移的维度,网络会学出一个在平凡方向上“作弊”的解——直接把所有输入映射到同一点,虽然损失降到极低,但这个映射没有任何判别力。第二条:中心 (c) 不能作为可训练参数随梯度更新,它必须在第一次前向传播后固定下来。常见做法是对网络做一次预热前向,取所有正常样本特征的平均值作为初始中心,然后冻结它。

网络结构上不需要堆很深,我建议用三层卷积加一层全连接,输出维度设 32 维。这个维度是我在几个电机噪声数据集上试下来的平衡点——16 维容易把细微异常也压进超球,64 维以上训练参数增加但判别力没有同步提升。

完整的训练脚本如下:

import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader, TensorDataset class DeepSVDDNet(nn.Module): def __init__(self, input_shape=(64, 128), latent_dim=32): super().__init__() c, h, w = 1, input_shape[0], input_shape[1] self.feature_extractor = nn.Sequential( nn.Conv2d(1, 16, kernel_size=3, stride=2, padding=1), # 下采样到 32x64 nn.ReLU(), nn.Conv2d(16, 32, kernel_size=3, stride=2, padding=1), # 下采样到 16x32 nn.ReLU(), nn.Conv2d(32, 64, kernel_size=3, stride=2, padding=1), # 下采样到 8x16 nn.ReLU(), nn.AdaptiveAvgPool2d((1, 1)), nn.Flatten(), nn.Linear(64, latent_dim, bias=False) # 关键:最后一层无偏置 ) def forward(self, x): return self.feature_extractor(x) class DeepSVDDTrainer: def __init__(self, latent_dim=32, nu=0.05, lr=1e-4): self.net = DeepSVDDNet(latent_dim=latent_dim) self.lr = lr self.nu = nu # 控制超球半径的分位数,类似 SVM 的松弛变量 self.center = None def init_center(self, dataloader): # 预热前向:用全部正常样本特征均值固定中心,之后不再更新 features = [] self.net.eval() with torch.no_grad(): for batch in dataloader: feat = self.net(batch) features.append(feat) all_feat = torch.cat(features, dim=0) self.center = all_feat.mean(dim=0, keepdim=True) def train(self, train_loader, epochs=100, patience=10): self.init_center(train_loader) optimizer = optim.Adam(self.net.parameters(), lr=self.lr) best_loss = float('inf') wait = 0 for epoch in range(epochs): self.net.train() epoch_loss = 0.0 for batch in train_loader: optimizer.zero_grad() feat = self.net(batch) # 距离中心越近越好,等价于最小化超球半径 dist = torch.sum((feat - self.center) ** 2, dim=1) # 损失 = 平均距离 + 权重衰减(通过 optimizer 内置 weight_decay) loss = dist.mean() loss.backward() optimizer.step() epoch_loss += loss.item() avg_loss = epoch_loss / len(train_loader) print(f"Epoch {epoch+1:03d}, Loss: {avg_loss:.6f}") # 早停:连续 patience 轮损失不下降就退出 if avg_loss < best_loss: best_loss = avg_loss wait = 0 else: wait += 1 if wait >= patience: print("Early stopping triggered.") break return self.net # 训练入口:加载特征,构造 DataLoader # 特征 shape 为 (N, 64, 128),需增加通道维度变成 (N, 1, 64, 128) # features 来自 2.1 节的 extract_log_mel_feature 批量输出 # X = torch.tensor(features, dtype=torch.float32).unsqueeze(1) # dataset = TensorDataset(X) # loader = DataLoader(dataset, batch_size=32, shuffle=True) # trainer = DeepSVDDTrainer(nu=0.05) # model = trainer.train(loader, epochs=100) # torch.save({"state_dict": model.state_dict(), "center": trainer.center}, "svdd_model.pth")

训练里的nu参数值得单独说。它对应 SVDD 原论文里的松弛项占比,控制“允许多少个正常样本落在球外”。设 0.05 意味着训练时允许约 5% 的正常样本不收入球内,这在采集数据混入少量非稳态噪声(比如旁边有人走动、风吹过麦克风)时很有用。但如果现场录音环境本身就稳定,nu可以降到 0.01,让边界更紧,异常召回率更高。

训练过程中要盯的不只是 loss。我一般每 5 轮在验证集(同样是正常样本,但没参与训练)上算一次平均距离,如果训练 loss 降但验证距离不降反升,说明网络过拟合到了训练样本的具体噪声模式而非泛化的正常声纹模式,需要增大权重衰减系数或减少潜伏维度。

3.2 推理分数与阈值的关系:距离本身不是概率

模型训练完成后,任何一个声音片段的异常分数就是特征向量到中心的欧氏距离:

[ s(x) = |\phi(x) - c|_2 ]

这个分数没有上界,不是一个天然的 0-1 概率。很多新手拿到模型后会直接问“阈值设多少”,这个问题本身就问错了——应该先用一批正常样本和少量已知异常样本画分布直方图,看两类距离的重叠区域,再决定阈值。

两个典型设置:正常样本距离通常集中在 0 到 3 之间,异常样本距离通常在 6 以上。但现场工况一变,这个分布整体会偏移。所以推理端不要写死一个绝对阈值,而是记录部署初期的距离基线,用“相对偏移倍数”来判异常——比如当前距离超过正常基线均值的 3 倍且持续时间超过 1 秒才报警。

4. PyQt5 界面封装:录音线程、模型推理与结果展示的工程化

模型训练好只是第一步,真正能拿到车间用的是界面工具。PyQt5 在这里承担的是完整应用壳:录音控制、文件选择、模型加载、实时推理、结果展示。最核心的工程问题不是界面布局,而是“录音不能卡界面”和“推理不能阻塞交互”这两件事。

4.1 录音与推理线程分离:signal/slot 的正确用法

PyQt5 的界面线程必须保持响应,否则录音 3 秒期间窗口转圈,操作体验直接回到 Windows 95。标准做法是录音线程跑pyaudio的数据采集,推理线程跑 PyTorch 的前向计算,两者通过信号把结果传回主线程刷新界面。千万不能在槽函数里直接做重计算。

下面是录音推理核心类的完整实现:

import sys import threading import numpy as np import pyaudio import torch from PyQt5.QtCore import QThread, pyqtSignal from PyQt5.QtWidgets import (QApplication, QMainWindow, QPushButton, QVBoxLayout, QWidget, QLabel, QTextEdit) class RecorderThread(QThread): """录音线程:持续采集音频,达到指定长度后发信号""" finished_chunk = pyqtSignal(np.ndarray) def __init__(self, sample_rate=48000, chunk_duration=1.0): super().__init__() self.sr = sample_rate self.chunk_size = int(sample_rate * chunk_duration) self.recording = False self.stream = None def run(self): p = pyaudio.PyAudio() self.stream = p.open( format=pyaudio.paInt16, channels=1, rate=self.sr, input=True, frames_per_buffer=1024 ) buffer = np.array([], dtype=np.int16) while self.recording: data = np.frombuffer(self.stream.read(1024), dtype=np.int16) buffer = np.concatenate([buffer, data]) if len(buffer) >= self.chunk_size: # 只发送最近一个 chunk,避免内存无限增长 self.finished_chunk.emit(buffer[-self.chunk_size:].copy()) buffer = np.array([], dtype=np.int16) self.stream.stop_stream() self.stream.close() p.terminate() class InferenceThread(QThread): """推理线程:加载模型并计算异常分数""" result_ready = pyqtSignal(float) def __init__(self, model_path): super().__init__() self.audio = None self.model_path = model_path # 延迟加载:线程启动后再初始化模型,避免卡应用启动 self.model = None self.center = None self.ready = False def load_model(self): checkpoint = torch.load(self.model_path, map_location="cpu") # 特征提取网络与训练时代码共用 DeepSVDDNet self.model = DeepSVDDNet(latent_dim=32) self.model.load_state_dict(checkpoint["state_dict"]) self.model.eval() self.center = checkpoint["center"] self.ready = True def set_audio(self, audio): self.audio = audio def run(self): if not self.ready: self.load_model() # 复用 2.1 的特征提取函数 features = extract_log_mel_feature_from_raw(self.audio, sr=48000) scores = [] with torch.no_grad(): for feat in features: tensor = torch.tensor(feat, dtype=torch.float32).unsqueeze(0).unsqueeze(0) out = self.model(tensor) dist = torch.sqrt(((out - self.center) ** 2).sum(dim=1)).item() scores.append(dist) final_score = float(np.mean(scores)) self.result_ready.emit(final_score)

这段代码里有几个工程细节值得展开。首先,finished_chunk发的是buffer[-self.chunk_size:].copy(),只取最近 1 秒的数据而不是累积全部录音——连续推理时如果持续发送整段历史,内存会被撑爆,而且旧数据比重过大会稀释当下异常特征。其次,推理线程用load_model()延迟加载而不是在构造时加载,保证界面弹出来是快的,模型加载放在后台,用户看到的不是白屏而是“加载中”状态。

extract_log_mel_feature_from_raw是从 2.1 节的函数改出来的版本,唯一区别是输入不是文件路径而是 numpy 数组,改法很简单:把librosa.load换掉,直接用原始数组传进librosa.feature.melspectrogram,其他参数完全一致。

4.2 界面布局与现场交互逻辑

界面不需要复杂,三个区域够了:控制区(开始/停止录音按钮、模型路径选择)、实时状态区(当前异常分数、判定结果)、日志区(历史判定记录带时间戳)。判定逻辑里我建议加一个“连续确认”机制——单次分数超阈值先标记为“疑似”,连续 3 次(约 3 秒)都超阈值才标记为“异常”。电机的瞬时冲击噪声(比如旁边有人敲击管道)会导致单次误判,连续确认能把这类虚警压下去。

主窗口的接法简洁干净:

class MainWindow(QMainWindow): def __init__(self): super().__init__() self.recorder = RecorderThread(sample_rate=48000, chunk_duration=1.0) self.inferer = InferenceThread("svdd_model.pth") self.inferer.load_model() # 录音线程完成一段采集 -> 立刻交给推理线程,不落盘 self.recorder.finished_chunk.connect(self.inferer.set_audio) self.recorder.finished_chunk.connect(self.inferer.start) # 推理结果回到主线程刷新界面 self.inferer.result_ready.connect(self.update_result) def update_result(self, score): threshold = self.normal_baseline * 3.0 status = "正常" if score < threshold else "疑似异常" self.result_label.setText(f"距离: {score:.3f} | {status}")

这里把录音线程和推理线程的接收都连到了同一个finished_chunk信号上,顺序是:先set_audio把数据准备好,再start触发推理。要注意set_audio的槽函数必须是直接连接(默认就是直连),不能排队,否则start运行时音频可能还没赋值上,推理线程读到的是上一轮旧数据。

5. 避坑与常见问题排查:从数据到部署的六个典型翻车点

这个方案我在三个电机噪声数据集上跑过,也帮朋友在现场调过,踩过的坑排名不分前后,但每条都值得记下来。

5.1 现象:模型在验证集上 AUC 0.98,现场却把正常电机判成异常

原因几乎都是采集工况不一致。训练集是电机在车间稳定运转时录的,现场测试时电机带了负载或者旁边有其他设备启动,梅尔频谱的全局能量分布已经变了。这不是模型没学好,是数据分布漂移。解决方法是重新审视正常样本的覆盖范围——把负载档位、转速、环境本底噪声都加进训练集。最实用的做法是部署前用目标电机的正常声音采集 20 分钟,和训练集特征分布做一次余弦相似度检查,低于阈值的先补数据再部署。

5.2 现象:训练 loss 降得很快但验证距离不降,甚至反升

这是过拟合到正常样本的个性噪声上。特征提取网络太深或者潜伏维度太高,网络把每个样本的专属噪声模式都记住了,泛化到新正常样本时映射距离反而更大。解决:一是增大权重衰减系数,二是降潜伏维度到 16 或 24,三是把nu从 0.05 提到 0.1,允许更多训练样本不参与紧致化。判断标准是训练 loss 与验证距离的差值——两者差距超过 30% 就基本可以断定过拟合了。

5.3 现象:PyQt5 界面点击“开始录音”后窗口未响应约 3 秒

这是在槽函数里直接做了音频初始化。pyaudio.open()这台设备上的启动延迟有时到 1 秒以上,放进 GUI 线程就表现为窗口卡死。解决:把录音的初始化挪到RecorderThread.run()里,线程启动本身不阻塞,初始化完成后再开始采数。

5.4 现象:推理结果忽高忽低,同一段正常声音两次判出不同结果

大概率是推理用的是整段录音但没做稳态段截取。电机启动瞬间的电流冲击声、麦克风靠近时的风噪都会让特征图整体偏高。解决:在特征提取前用短时能量检测找出稳态段——计算每个分帧的 RMS 能量,把头部 10% 和尾部 10% 丢弃,只保留中间能量稳定的 80%。这个逻辑我建议直接写进extract_log_mel_feature函数里,而不是每次推理时手动处理。

5.5 现象:多个正常电机部署后阈值要各调各的,没法统一

根源是每台电机的基础噪声不同——功率等级、安装位置、传动方式都会影响绝对声压级和频率分布。硬性统一阈值是行不通的。我一般在部署流程里加一个“自校准”步骤:对新到场的电机先录 30 秒正常声音,计算这段声音的平均距离作为基线,报警阈值设为该基线的 3 倍。这样每台电机用自己的基线做相对判断,而不是拿统一绝对距离去套。

5.6 现象:训练时 GPU 显存溢出

批量大小设为 32 时其实不至于,但如果你把 64 帧窗口理解成序列用 RNN 或 Transformer 去跑,显存确实会爆。deep-SVDD 用的是纯卷积,显存占用和单张图片分类差不多。如果你的网络结构里加了 LSTM,建议退回到卷积——电机声纹的时频局部特征本来就是平移不变的,不需要序列模型去建模长程依赖。真要用序列建模,把 batch size 降到 8 以下。

6. 现场部署的验证闭环与阈值自校准技巧

模型能不能用,不是训练完看一眼 loss 就行的。我的验证流程分三步走,每一步都在部署现场完成,而不是留在开发机里。

第一步是基线采集。设备安装后先用工具连续录 5 分钟正常运转声音,按 2.1 节的特征提取方式切出约 200 个特征图,全部过一遍模型,记录距离分布的均值 ( \mu ) 和标准差 ( \sigma )。这一步是一切的基准——之后所有阈值判断都相对于这组统计量,不直接用全局绝对值。

第二步是人工注入验证。用工具敲击电机外壳模拟松动异响、用手电钻在旁边制造宽带噪声、用砂纸摩擦轴端模拟摩擦声,每种异常录 10 秒,同样切成特征图过模型,看距离是否显著大于 ( \mu + 3\sigma )。如果三种模拟异常里有两种距离不超标,说明模型的敏感度方向错了,需要复查特征提取里的频带范围是否覆盖了对应频段。

第三步是阈值可视化校准。我用 matplotlib 把正常样本距离直方图和异常样本距离直方图叠加画在同一个坐标轴上,直接看到两类分布的重叠区域。阈值选在重叠区域的右边缘,保证正常样本误报率低于 1%,同时异常召回率不低于 90%。这个过程不用写代码到工具里,部署时临时跑一段 Python 就行。

最后教你一个压误报的技巧:双阈值判定。把 ( \mu + 3\sigma ) 设为警告阈值,( \mu + 5\sigma ) 设为报警阈值。距离落在两者之间时只记录日志不触发联动,落在 ( \mu + 5\sigma ) 之上才真正报警。因为真实故障的冲击特征距离往往是正常的 6 到 10 倍,和瞬时环境噪声有明显的幅度差,双阈值能把虚警率再砍掉一半。

从那以后我每次部署声纹检测工具,都强制走一遍这三步闭环:基线采集、人工注入验证、直方图定阈值。哪怕客户催得再急也不跳过——血泪经验告诉我,跳过任何一步,上线不出三天就会收到现场误报投诉。

希望这个工具和这套流程能帮你少走一段弯路。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询