最近确实扎进心音信号(PCG)分类这个坑里出不来了——先是被数据折腾,然后被特征折腾,最后被评估指标折腾。前前后后写了不少代码,踩了一堆只有自己知道疼的经验,总算跑出了一个能复现的完整流程。今天把整个Demo项目摊开聊:从心音分割到特征工程,从传统机器学习到CNN训练,每一步为什么这么做、什么参数靠谱、哪里容易翻车,都一并说清楚。
这个项目能解决什么问题?最直接的就是“心音自动分类”:喂进去一段心音(PCG,Phonocardiogram),模型判断它属于正常还是异常,相当于给心音听诊做自动初筛。适合谁看?做生物医学信号处理的学生、刚入门的算法工程师,或者打算把手头心音数据跑成基线模型的朋友,都可以参考这里面的思路。下面先把心音分类的整体逻辑讲透,再给你可落地的代码和参数。
1. 先搞清楚心音分类到底在干什么
1.1 心音不是“心跳的声音”那么简单
很多人以为心音就是“咚咚咚”,这是最大的误区。心音本质上是一个连续的声音序列,里面至少有两波关键成分:第一心音S1,发生在心室收缩开始、二尖瓣和三尖瓣关闭的时候,听感偏低、低沉;第二心音S2,发生在心室舒张开始、主动脉瓣和肺动脉瓣关闭的时候,听感偏短、清脆。S1之后还有一段收缩期,S2之后是一段更长的舒张期,然后进入下一个周期。
健康的血流是层流,瓣膜开关规律,所以心音听上去干净有序。一旦瓣膜出问题、心肌功能异常或者存在先天性畸形,就会产生湍流和非周期性的“杂音”。这些杂音有些落在S1和S2中间(收缩期杂音),有些落在S2之后(舒张期杂音),各有诊断含义。心音分类的核心目标,就是通过机器自动识别这些正常与异常模式的差异,而不是把心音当成普通的“有没有声”。
明确了这一点再看项目,处理思路就会清晰:我们不能简单地把每一帧音频拿去分类,而要先理解心音周期结构,再考虑用哪些信号成分来建特征。很多人直接把心音当环境音去调模型,最后效果上不去,多半就是栽在这层认知上。
1.2 二分类任务背后的完整流程
这个Demo把任务定义为“正常/异常二分类”,数据来自某公开心音竞赛整理过的样本,包含安静环境下的心音录音和医生标注。整个处理链路分了五段:
- 数据加载与重采样:统一采样率,保证后续特征可比。
- 预处理与降噪:带通滤波、工频陷波,去掉与心音无关的分量。
- 心音分割(可选但重要):识别S1/S2位置,为特征工程提供周期信息。
- 特征提取与建模:先跑传统ML(随机森林、XGBoost)搭基线,再上1D CNN验证深度方案。
- 评估与错误分析:用AUC、灵敏度、特异度、5折交叉验证衡量真实水平。
这五段缺一不可。经常有人上来就丢给深度学习“黑盒学习”,忽略了分割和前处理,结果在公开数据上AUC挺漂亮,换到自己的录音立刻拉胯,主要原因就是没有把信号本身的生理学特征吃透。
1.3 评估指标别只看准确率
这类数据集天然存在类别不平衡:正常样本通常占七成以上,异常样本只有两三成。如果你拿accuracy当唯一指标,模型只需一直预测“正常”就能拿到70%以上的准确率,看起来很强,实际没有任何临床意义。
这份项目的指标组合是:
| 指标 | 含义 | 为什么必须看 |
|---|---|---|
| AUC | ROC曲线下面积 | 不受阈值影响,衡量排序能力,适合不平衡场景 |
| 灵敏度/召回率 | 异常被检出的比例 | 做筛查最怕漏掉异常,宁可误报也不要漏报 |
| 特异度 | 正常被判正常的比例 | 控制误报率,减少无谓的进一步检查 |
| F1 | 精确率和召回率的调和平均 | 在阈值固定时评估实用性 |
我还额外做了按病人维度的交叉验证,就是同一个人的录音不要同时出现在训练集和测试集,这个细节后面专题说,但它比“按文件随机切分”公平得多,能大幅挤掉乐观水分的估值。
2. 数据准备与预处理:别急着上模型
2.1 重采样与滤波为什么是第一步
公开数据来源杂,录制设备也不同,采样率有1000Hz、2000Hz、4000Hz各种。模型不认采样率,它只认数字序列;如果混着采样率喂进去,同样的实际频率在特征空间里的表现完全不同,训练必然出问题。我统一把采样率重采样到2000Hz。这个值怎么定的?
心音的临床有效频率范围大致在20~600Hz,主要能量集中在50~200Hz段。按照奈奎斯特定理,理论上400Hz采样就够描述到200Hz以内的能量,但要留足抗混叠滤波的余量、又要兼顾后续做语谱图的频率分辨率,2000Hz是比较折中的选择。如果设备本身是4000Hz采的,降到2000Hz也能把计算量砍掉不少,而心音信息基本无损。
重采样我用的librosa的resample,内部带高质量抗混叠滤波。这里有个小坑:不能直接scipy.signal.resample按新长度重排,因为它是频域插值,处理短信号时容易引入边界振铃;实测下来音频信号还是librosa这类自动加窗的实现更稳。
滤波方面做两级:
- 带通滤波:25Hz~400Hz,通常用Butterworth四阶或六阶。去掉高频电子噪声和低频呼吸、肌肉蠕动等干扰。
- 50Hz工频陷波:国内市电干扰常落在50Hz,用陷波器把它压下去。但要注意心音本身在50Hz附近也有能量,陷波带宽要窄,比如49.5~50.5Hz,别把有效信号掏空了。
没有医疗级前端硬件时,软件层降噪只能做到这个程度。真正的强噪声(比如摩擦音、说话声、环境撞击声)靠滤波是滤不掉的,只能做数据筛选或者在模型层面增加数据增强抗噪。
2.2 降噪的两层实操思路
我在这份信号上试过三种降噪策略:
最简单的是带通滤波,只留有效频带。优点是零成本,缺点是遇到宽频噪声无能为力。
第二层是谱减法:对背景噪声段估计功率谱,然后在带噪信号的幅度谱上做扣除。适合平稳噪声,比如底下有一种恒定的“嗡嗡声”。但在心音这种非平稳、短时突发性强的场景里,谱减法会误伤S1/S2的低频瞬态成分,导致音节边界变模糊,反而影响后续分割。我用它做过一次对比,AUC没升,分割的峰检反而变差了。
第三层是用小波阈值去噪。效果在信号较干净时不错,但运算速度比前两种慢一个量级,而且阈值参数对最终指标的影响敏感。线上推理如果不考虑算力就另说,但考虑到后续可能要移植到手机或嵌入式设备,我最终保留了“带通+陷波+轻度归一化”的简洁组合。降噪不是越狠越好,删掉那些和心音无关的东西就够了,盲目追求“纯净”会把诊断特征一起去掉。
2.3 数据集划分与验证策略
这是整个项目里教训最深的一环。最初我按文件随机切训练集和测试集,5折交叉验证AUC一度到了0.93,非常兴奋。后面我把同一病人的多条录音按病人维度分组后再切,AUC直接掉到0.84左右。这个差异说明什么问题?
同一病人多次录音的相似性远远大于不同病人之间的差异,模型其实学到了“记住这个人”,而不是“识别这种病”。如果测试集里混入同一个人的另外一段录音,得分自然虚高。所以心音分类划分数据必须按病人维度,而不是按文件维度。
另外还要注意分层采样。原始数据里正常/异常比例大约是75%/25%,切分时通过StratifiedKFold按类别比例分层,保证每一折里的正常和异常比例接近完整数据;否则某一折异常样本过少,指标震荡非常大。这个在传统机器学习阶段尤其明显——异常样本少的时候,随机森林那点稀疏特征根本学不住。
3. 心音分割:进阶特征的前置关卡
3.1 分割到底图什么
有人问:我不做分割,直接把整段音交给模型不行吗?可以,CNN确实能隐式学到一些时序结构。但如果要做传统特征工程,包括收缩期到时长的比例、S1/S2幅度比、各时段能量比这些具有生理学解释力的特征时,必须先知道每段信号里S1在哪、S2在哪、收缩期和舒张期的边界在哪。
还有一个原因是应对样本长度差异。公开数据里一条录音最短几秒,最长几十秒,长短差得离谱。不做分割、不切周期,就很难把特征对齐到同一个生理尺度上。分割之后的段落天然成了一个标准周期,长度、位置、上下文都可以对齐。
3.2 简化可行方案:包络+峰值检测
完整学术方案是用Logistic Regression-HSMM或者深度分割网络,但作为个人Demo阶段,我用了一个简单但可跑的方案:希尔伯特包络+移动平均平滑+自适应峰值检测。
核心步骤:
- 对重采样后的心音信号计算希尔伯特变换的幅度,得到瞬时包络。
- 对包络做移动平均平滑(窗口约50ms),消除高频抖动。
- 设计一个自适应阈值——比如取包络均值的1.5倍,或者用百分位数(P75以上)做初筛。
- 找局部极大值,作为候选“心音事件”。
- 利用生理先验分组:同一周期里S1和S2间隔短(收缩期约0.25~0.35秒),S2到下一个S1间隔长(舒张期约0.4~0.7秒)。按这个交替模式把候选峰归类为S1或S2。
这套简化方法在信噪比高的样本上准确率可观,对有大型杂音的异常样本可能把杂音当心音事件,准确性下降。但手动看一看误差范围,作为特征工程的输入已经够用。如果做硬实时系统,还是建议使用HMM/HSMM这类带状态约束的分割方案。
3.3 HMM/HSMM方案的进阶与坑
后来我也换成HMM方案试过一轮。思路是把状态定为S1 -> 收缩期 -> S2 -> 舒张期四个隐状态,用音高、能量、持续时长、频率等观测特征建模马尔可夫转移,状态持续时间用半马尔可夫模型建模。这类方案的优点是不只会找峰值,还能生成明确的状态序列,特征提取非常方便。
但用它的前提是有足够的带分割标注数据。如果只有“正常/异常”这种粗标签,HMM的训练数据质量就不行。我当时的做法是用一套公开的带S1/S2标注的少量样本做预训练,再用无标注数据做无监督初始化,最后拿到我自己的录音上去推断。效果比纯峰值检测稳定,但工程实现明显复杂,调试耗时也上了一个台阶。
给后来者的经验是:如果只是跑基线模型,先上用包络法,把整个流程串通再回来优化分割。分割精度和分类AUC并不是严格的线性关系,健康心音上分割差了50ms,分类结果往往变化不大;反而异常心音的杂音特性,比精准的S1/S2定位影响更大。
4. 特征工程+传统机器学习:先跑出一个能用的基线
4.1 该提哪些特征
传统机器学习阶段的目标不是刷分,是建立一个可信的基线,同时搞清楚“什么样的信号差异对分类最有效”。我按特征用途分了三组:
- 时域统计特征:均方根(RMS)、峰值因子、过零率、信号包络的偏度与峰度。这些指标能粗略刻画“声音的力度”和“节奏稳定性”。
- 谱域特征:MFCC(梅尔频率倒谱系数),一般取20维;再加频带能量比(低频与高频能量之比)。MFCC在语音识别中表现很好,心音也是一种声学信号,放在这里依然有效。
- 周期结构特征:从分割结果里提取,包括心率、S1-S2间隔、S2-S1间隔、收缩期/舒张期时长比、S1/S2幅度比。一个明显的规律是异常杂音会让S1/S2边缘模糊,相应的能量分布和间隔特征就会变化。
我把这些特征合并成一个向量,每个样本取各特征的均值、标准差、最小值、最大值,连同原始信号的中位数和百分位数一起拼起来。维度维持在150个左右,不算高,随机森林和XGBoost都能吃得很轻松。
4.2 实际代码示范:从信号到分类器
数据加载与预处理:
import librosa import numpy as np from scipy import signal as sig TARGET_SR = 2000 def load_heart_sound(path): y, sr = librosa.load(path, sr=None) # 重采样到目标采样率 if sr != TARGET_SR: y = librosa.resample(y, orig_sr=sr, target_sr=TARGET_SR) # 带通滤波 25-400Hz sos = sig.butter(6, [25, 400], btype='bandpass', fs=TARGET_SR, output='sos') y = sig.sosfilt(sos, y) # 50Hz陷波 sos_notch = sig.iirnotch(50, Q=30, fs=TARGET_SR) y = sig.sosfilt(sos_notch, y) return y, TARGET_SRMFCC与时域特征提取:
def extract_features(y, sr): # 帧长50ms, 帧移25ms n_fft = int(sr * 0.05) hop_length = int(sr * 0.025) mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=20, n_fft=n_fft, hop_length=hop_length) rms = librosa.feature.rms(y=y, frame_length=n_fft, hop_length=hop_length)[0] zcr = librosa.feature.zero_crossing_rate(y, frame_length=n_fft, hop_length=hop_length)[0] feat = [] for m in mfcc: feat += [m.mean(), m.std()] feat += [rms.mean(), rms.std(), np.percentile(rms, 75), zcr.mean(), zcr.std()] return np.array(feat)模型训练用XGBoost:
import xgboost as xgb from sklearn.model_selection import StratifiedKFold from sklearn.metrics import roc_auc_score params = { 'n_estimators': 300, 'max_depth': 4, 'learning_rate': 0.05, 'scale_pos_weight': 3.0, # 异常样本约为正常样本的1/3 'eval_metric': 'auc', 'use_label_encoder': False, } skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) for train_idx, test_idx in skf.split(X, y): model = xgb.XGBClassifier(**params) model.fit(X[train_idx], y[train_idx]) prob = model.predict_proba(X[test_idx])[:, 1] print(roc_auc_score(y[test_idx], prob))这个基线在我手里稳定在AUC 0.78~0.82之间波动。说实话,对于一个画风这么粗糙的管线来说,已经是一个可以接受的起点。后续深度学习就是冲着拉开这个数值去的。
4.3 类别不平衡处理:别一上来就SMOTE
XGBoost里scale_pos_weight和sklearn/RandomForest里的class_weight就是最简单的不平衡控制手段。它们的本质是“把少数类的一次误判损失放大”,让模型不敢轻易放弃异常样本。
SMOTE(合成少数类过采样)在表格特征上确实常用,但心音特征大多来自连续帧的统计聚合,盲目的线性插值容易产生特征空间中不存在的合成点。比如异常杂音的MFCC统计值,会跟正常样本的真实分布产生重叠,SMOTE在那个区域插值出来的点,实际上是一种畸形的合成样本,可能让模型学到错误的边界。
我的做法是:先用scale_pos_weight(约等于负类/正类比例,也就是取3)配合AUC选阈值,如果灵敏度还是不够,再考虑在特征层面做人工合成,而不是数据级SMOTE。
4.4 基线效果怎么解读
不要因为传统方法AUC低于深度学习就觉得它没用。传统模型给了两个宝贵东西:一是特征重要性排序,可以直观告诉你哪些特征重要;二是它本身抗过拟合能力强,在小样本上常常比深模型稳定。我当时的特征重要性前五名里,MFCC第一维系数均值、收缩期/舒张期时长比、信号包络偏度都排得很靠前,这说明“能量结构+周期结构”确实是把正常和异常分开的关键线索。深度模型可以隐式学习这些,但手动特征得到的是可解释验证,这是我建议所有新项目都先跑一遍传统模型的原因。
5. 深度学习的完整尝试:从波形到频谱
5.1 两条路线怎么选
深度学习阶段我试了两条主要路线:
- 1D CNN直接吃原始波形:输入固定长度的重采样心音信号,用Conv1D逐层提特征。
- 2D CNN吃语谱图/Mel谱图:先用短时傅里叶变换生成时频图,再用图像分类的套路去做。
我最终选了1D CNN作为主力,原因很实际:心音信号本身不长,但异常杂音可能集中在特定频带和特定时段,一维卷积在时域上刚好能捕捉这种“局部突发”模式;而且1D卷积参数少,训练速度快,在这个样本量不大(几千条)的项目里不容易过拟合。
2D CNN也试了,把Mel谱按照40帧宽度、64个Mel bin切片,输入到ResNet18类似的浅层变体里,指标居然和1D CNN不相上下。这说明信息确实是同一份,关键在于你怎么组织输入。最后考虑部署时的实时性,保留1D方案。
5.2 网络结构与训练细节
在项目里跑的1D CNN结构:
from tensorflow.keras import Sequential from tensorflow.keras.layers import (Conv1D, MaxPooling1D, BatchNormalization, GlobalAveragePooling1D, Dense, Dropout) input_len = 2000 # 1秒 @2000Hz model = Sequential([ Conv1D(64, kernel_size=7, activation='relu', input_shape=(input_len, 1)), BatchNormalization(), MaxPooling1D(pool_size=2), Conv1D(128, kernel_size=5, activation='relu'), BatchNormalization(), MaxPooling1D(pool_size=2), Conv1D(256, kernel_size=3, activation='relu'), BatchNormalization(), GlobalAveragePooling1D(), Dense(128, activation='relu'), Dropout(0.3), Dense(1, activation='sigmoid') ])训练参数:batch size 32,Adam优化器初始学习率1e-3,二元交叉熵损失,EarlyStopping监听验证集AUC,patience设为8个epoch,最大训练40个epoch。数据长度不一样怎么处理?我按1秒窗口切段:每个样本从原始录音中随机截取1秒左右。如果录音不足1秒就原地补零到2000点。这样做一是增加样本量,二是让模型适应不同位置的片段。
还有个细节:输入之前做Z-score标准化,减去全数据集均值再除以标准差,而不是在单条样本内部做归一化。因为内部归一化会把S1/S2的真实振幅差异抹掉,反而丢失重要的心音强度信息。
5.3 数据增强:哪些能用哪些慎用
心音数据不像图像那样可以随便翻转裁剪,盲目增强会制造生理学上不可能出现的样本,把模型学歪。我逐项验证过:
- 随机加性高斯噪声(SNR大于20dB):可用。模拟轻微环境噪声,让模型对底噪更鲁棒。
- 轻微时间伸缩(±5%):谨慎用。心率本身会变,±5%不会严重扭曲周期,但伸缩到10%以上就会影响S1/S2时长比,这个特征恰恰是心音分类的重要线索。
- 随机裁剪1秒片段:可用,等于增加样本条数。
- 幅度随机缩放(0.9~1.1):轻度可用,但别让主要峰值幅度跨过阈值。
- 音高移动(改变采样率再重采样):不推荐。心音频率与瓣膜结构直接相关,整体上移或下移会改变杂音频段,误导模型对频率绝对位置的判断。
按我的实验,加上噪声和随机裁剪后,验证集AUC大约上升了1.5~2个百分点,而且测试时对于不同设备的鲁棒性明显增强。核心原因还是原始数据量不够,增强相当于给了模型更多“观看角度”。
5.4 交叉验证结果与错误分析
最终5折按病人划分后,1D CNN的AUC大概在0.87~0.91之间。听起来没有公开榜单上那些0.95以上好看,但那是带病人隔离的、更接近真实场景的估计。对比一下:
| 方法 | 按文件随机划分AUC | 按病人隔离AUC |
|---|---|---|
| 统计特征+随机森林 | 0.84 | 0.78 |
| MFCC+XGBoost | 0.86 | 0.81 |
| 1D CNN | 0.93 | 0.89 |
| 1D CNN+增强+调参 | 0.94 | 0.90 |
第5列数值才是可信的。我仔细看了看错误样本,发现误判集中在这几类:
- 录音时长极短,只有2~3次完整心搏,截取窗口后信息不足。
- 标注为“异常”但只有轻微舒张期杂音,功率谱上与干净正常样本差异极小。
- 有较强背景噪声但幸运地通过前处理保留了下来,模型把噪声模式当成了异常特征。
第三类错误最危险,因为它意味着模型不是在学习病理特征,而是在学“噪声长什么样”。这在公开数据上还能掩饰,换到安静环境录的干净测试集上立刻崩。所以后面我又加了一条规则:如果测试样本的噪声底过大,先提示“采集质量不合格”,而不是硬给分类结果。
6. 我踩过的坑和排查实录
6.1 标注质量与脏数据问题
公开数据虽然带标注,但标注不等于金标准。心音标注本身就依赖医生听诊经验,存在主观差异。我拿到的数据里有小部分样本,用耳朵听都能明显感觉到S1/S2的位置标反,或者把呼吸声当成心音事件。这类脏数据要是直接进训练集,等于往模型里塞噪声,轻则拉低指标,重则让模型学到错误关联。
我的清洗流程是:先自动筛查,看信号能量、时长、峰值数这些基础指标,剔除明显不合理的样本;然后抽样几十条人为听一遍,并结合包络分割的结果对照检查。做一轮清洗后,AUC大约能涨0.02~0.03——不算巨大,但很值得,因为清洗换来的主要是稳定性。
6.2 采样率和时长不一致导致特征错位
我一开始用了原始采样率直接跑MFCC,忘了对sr参数做限制。librosa在计算MFCC时用的mel_filter会根据给定的采样率生成不同频率刻度,采样率不一致导致同一条数据在不同设备上提取的特征不在同一个频率坐标体系上。这个Bug表现在结果上是模型在训练集表现好但测试集各种飘,排查起来相当隐蔽。
解决办法也很简单:所有信号一律先重采样到2000Hz,再进特征提取,后面全程使用固定sr。另外每个音频的时长差异要处理:过长的截断成定长片段,过短的补零或者干脆丢弃(少于3次心搏的直接不参与训练)。
6.3 “按文件切分”与“按病人切分”的指标落差
这个我前面提过,但值得以单独一条坑的形式强调。我在做消融对比时发现:按文件随机切分,5折AUC均值0.93;按病人隔离切分,同样模型、同样超参数,AUC均值0.89。那4个百分点的水分完全来自“同一病人多段录音太像”引起的记忆效应。后面再看论文里的心音分类指标,我一定会先确认他们的切分方式,否则没法横向比较。
实际落地时更要警惕:如果验证集里包含与训练集同一批设备、同一批受试者的数据,指标基本不靠谱。宁可把AUC打低一点,也要保证评估是真实的。
6.4 设备迁移与实时部署的注意事项
模型在公开数据上跑得再高,遇到新设备录音还是会掉点。不同话筒频响特性不一样,医生用电子听诊器采的和手机麦克风录的音,频谱结构差异明显。我后来的处理方案是:
- 对所有测试音频先做统一重采样和带通滤波,尽量消除设备差异。
- 在输入端做短时能量归一化,以单条录音的总能量为基准调整幅度,减弱音量差异。
- 如果要做实时分类,模型输入窗口不能太长。我实际把输入控制在1秒左右,然后用滑动窗口重叠0.5秒做预测,最后对最近5次结果做平均投票。CPU上单条预测约十几毫秒,手机上跑起来也足够流畅。
这个项目还能继续扩展的方向,我认为至少有三个:一是把二分类升级成多分类,尝试区分收缩期杂音、舒张期杂音、连续性杂音;二是把分割和分类做联合训练,让模型自己学习周期结构;三是把心音分类跟心率估计、呼吸音检测结合,做成更综合的生理音分析工具。每一步都还有得挖,也都有各自的坑等着填。
最后掏心窝子说一句:做这种医学信号项目,别只盯着排行榜上的最高分。先把数据分割清楚、评估方式做实、特征意义搞明白,再碰模型。我踩过最大的坑不是模型不够深,而是用了一个“看起来正确”的验证方式,给自己造了一个漂亮的幻觉。把这个基础打好了,后面加什么模型都是水到渠成的事。