心音分类这个项目我断断续续做了两周多,最开始纯粹是被一段异常心音录音勾起了兴趣——那“咕咚、咕咚”的节律里藏着一点多余的杂音,人耳能听出来不对劲,但要说清楚到底哪类问题,得靠专业医生。于是我就想,能不能用MATLAB把这套“听”的逻辑自动化,做一个能区分正常与异常心音的分类器。做完之后发现,这个任务的难度其实恰到好处:既有信号处理的硬核部分,又有机器学习的模型选型环节,非常适合作一篇带完整落地的入门级教程。这篇文章我会把心音分类器的完整链路拆开讲,从数据预处理、特征提取到分类器训练,以及我踩过的那些坑。
如果你正打算用MATLAB入门机器学习,想找一个小而完整、能跑通全流程的项目,心音分类器是个很好的选择。它不要求你有深厚的医学背景,也不需要GPU训练网络,一套普通的笔记本就能完成全部实验。
1. 项目思路与整体设计:先搞清楚要解决什么问题
1.1 核心需求解析:从“听诊”到“分类”
心音分类的本质,是把医生用听诊器听到的声音信号,转成一组数值特征,再用机器学习算法学习正常与异常心音之间的差异。人耳听觉其实是一个极其复杂的特征提取系统,能同时感知频率、响度、节律、音色等维度;而计算机没有这个能力,我们需要把声音里最关键的判别信息显式地抽出来,喂给分类器。
说的再直白一点,分类器本身没有任何医疗知识,它只认识数字。你要做的,是把每一条心音录音变成一行数字(特征向量),然后告诉它“这一行代表正常”、“那一行代表异常”。学完规律之后,它看到新录音提取出的特征,能告诉你这个更像哪一边。
这个任务拆开来看,包含几个清晰的环节:
- 数据获取与整理:找到公开的、带标签的心音数据集。
- 预处理:去噪、归一化、把长录音切成合适长度的片段。
- 特征提取:从每个片段里算出一组有区分度的数值。
- 特征选择与可视化:看看特征有没有把两类样本分开的趋势。
- 分类器训练与调参:在训练集上学规律,在测试集上看效果。
- 评估与上线:通过准确率、敏感性、特异性等指标判断系统能否用。
整个链路里,特征提取和分类器选型是核心,前者决定信息的上限,后者决定信息被利用的程度。这也是这篇文章标题里“特征提取和分类”两个词对应的重心。
1.2 为什么选MATLAB:信号处理与机器学习的无缝衔接
很多新手会纠结用Python还是MATLAB。我的看法是:如果你主要做音频、振动、生物电这类一维信号处理,MATLAB的开发效率明显高得多。你用20行代码能完成滤波、重采样、分帧加窗等一系列操作,在Python里可能要写四个不同库的导入和参数匹配。
MATLAB的优势集中在这么几点:
- 内置了大量信号处理函数,
bandpass、butter、spectrogram都是一行调用,对新手极友好。 - 自带的Classification Learner App可以零代码完成多个分类器的训练和对比,快速判断方向对不对。
- 官方文档例子丰富,搜心音分类能找到PhysioNet的配套脚本,节省起步时间。
- 不需要搭环境,安装后开箱即用。
当然,MATLAB也有短板:做深度学习生态不如PyTorch灵活,CV领域的新模型迁移麻烦。但心音是典型的一维信号任务,搭建深度网络不是必须的,传统的特征工程+经典机器学习模型反而更容易调试,也更适合用来理解分类问题的本质。
1.3 整体流程设计:先跑通管道再优化精度
我做这类项目习惯遵循一个原则:先拿最简单的方案把整个管道跑通,再回来优化瓶颈。这个思路放在心音分类器上,具体落地是这样的:
先用一个很朴素的特征集(比如时域统计量+过零率)加一个最简单的分类器(决策树),把“原始音频-预处理-特征提取-训练-预测”的闭环建立起来。这时候不需要追求准确率,而是要排查管道里有没有逻辑漏洞,比如标签和样本有没有对齐、特征矩阵维度对不对、训练集和测试集有没有数据泄露。
管道通了之后,再逐步把特征集升级成时频结合的特征组合,把分类器换成SVM或集成模型,用交叉验证做严格评估。整个过程步步为营,出了问题也能快速定位在哪一段。
注意:项目中最常见的灾难不是模型效果差,而是数据管道本身有bug。先用简单管线跑通,就是对排查这类问题的最好保障。
2. 数据准备与预处理:分类器的地基稳不稳,全看这里
2.1 数据集选型:首选PhysioNet 2016心音数据集
做心音分类绕不开PhysioNet/CinC Challenge 2016这个公开数据集。它包含了七百多人的心音录音,分为正常、异常两类,还标注了录音来源于哪个公开子库。
这个数据集有几个优点值得强调:
- 规模和格式很友好,原始音频是.wav格式,直接用MATLAB的
audioread读进来。 - 样本带有明确的分类标签,免去人工标注的时间。
- Challenge的官方页面提供了训练集和测试集的划分方案,可以照用,方便和别人的结果对比。
下载之后建议按这样的结构整理目录:
heart_sound/ ├── training/ │ ├── normal/ % 正常心音 │ └── abnormal/ % 异常心音 └── test/ ├── normal/ └── abnormal/2.2 预处理核心操作:重采样、带通滤波与分段
原始心音录音的采样率不统一(常见的有2000 Hz、44100 Hz等),这会影响后续特征提取的一致性,所以要先把所有数据重采样到同一个频率。PhysioNet官方推荐的参考频率是2000 Hz,因为心音的绝大部分能量集中在800 Hz以下,2000 Hz采样率足够保留特征频段,还能大幅压缩数据量。
紧接着做带通滤波。心音的主要成分集中在20 Hz到600 Hz,低于20 Hz的是肌肉震颤和基线漂移,高于600 Hz的大多是环境噪声和听诊器摩擦声。MATLAB里用bandpass函数一行就能实现:
fs = 2000; [sig, fs_orig] = audioread('heart.wav'); sig_resample = resample(sig, fs, fs_orig); sig_filt = bandpass(sig_resample, [20 600], fs);滤波之后要分段。一整段录音有几十秒钟,直接整段提取特征会把不同心动周期的信息搅在一起,分类器很难抓到稳定的模式。合理的做法是切成固定长度的心音片段,用2到5秒的窗口比较常见。窗口太长会包含多个周期导致特征被平均化,太短则可能截不完整一个心动周期。
我实测之后觉得2.8秒左右比较合适——既能包含2到3个完整心动周期,又不会让特征计算受到过多周期差异的干扰。相邻片段之间留50%重叠,可以增强样本数量,缓解数据量不足的问题。每个数据集的录音数量不太一样,片段切完之后通常能多出一批样本,给后续训练提供更充分的数据。
segment_len = round(2.8 * fs); overlap = round(segment_len * 0.5); segments = []; for start_pos = 1:(segment_len-overlap):(length(sig_filt)-segment_len) segments = [segments; sig_filt(start_pos:start_pos+segment_len-1)]; end2.3 归一化与标签对齐
每个片段提取完特征后,我们要做一次逐样本的归一化。为什么要做?因为不同录音的音量有差异,同一个特征(比如均方根值)在不同录音间的绝对值差异可能比正常与异常之间的真实差异还大,这会干扰分类器的学习。归一化之后,每个样本的特征均值归零、方差为一,模型看到的是“模式差异”,而不是“音量差异”。
代码很简单:
feature_norm = (feature - mean(feature)) / std(feature);这一步容易忽视但是极其重要。我见过不止一个项目的特征矩阵不归一化就送进SVM,结果高斯核函数计算出的距离全部被量纲大的特征主导,分类效果很难看。
标签对齐是个表面简单但容易出错的环节。我自己的习惯是构建一个结构体或表格,每个样本的ID和标签保存在一起。片段切割顺序、特征提取顺序、标签顺序三者的索引必须一一对应,否则训练时标签完全错乱,模型学出来的东西毫无意义。
提示:处理多个文件夹的数据时,建议把文件名、片段编号一并存到特征矩阵的末尾列。一旦发现预测结果异常,可以回溯检查是不是数据错位了。
3. 特征提取:分类器的“燃料”质量决定最终效果
3.1 时域特征:简单但别小看
时域特征是从信号波形本身直接计算出的统计量。它的优势是计算极快、物理意义直观,在区分正常与异常心音时能提供最基础的判别信息。常见的有这几类:
- 均方根值(RMS):体现信号整体的能量水平。异常心音往往伴随额外杂音,能量分布会有变化。
- 峰值系数(峰值/RMS):衡量信号的冲击特性。心音中的扑落音或喷射音会让波形产生更尖锐的峰值。
- 过零率:单位时间内信号符号变化的次数,能间接反映频率高低。异常心音中高频成分增多时,过零率会上升。
- 熵率:衡量波形的复杂程度。收缩期杂音会让信号变得更加“ chaotic ”,复杂度增加。
举个例子,主动脉瓣狭窄患者的心音里会有明显的喷射性收缩期杂音,波形比正常心音更加毛糙,时域上的过零率和波形熵就会显著偏高。这些特征单独看不完美,但组合起来能提供很强的互补信息。
% 以一条样本为例,计算基础时域特征 rms_val = sqrt(mean(seg.^2)); peak_val = max(abs(seg)); crest_factor = peak_val / rms_val; zero_cross = sum(abs(diff(sign(seg))) > 0) / length(seg);3.2 频域特征:观察心音的“频谱指纹”
时域特征不够的时候,频域特征是下一层的信息来源。对心音片段做傅里叶变换,可以得到它的频谱分布。正常心音的能量集中在40到100 Hz低频区间,而异常心音,尤其是带杂音的情况,在200到500 Hz甚至更高的频率上会出现额外的能量峰。
常用频域特征包括:
- 频谱质心:反映频谱能量集中位置的“重心”。杂音会导致频谱质心向高频偏移。
- 频谱带宽:衡量能量在频谱上的分散程度。杂音越宽频,带宽越大。
- 子带能量比:把频谱分成若干子带,统计每个子带的能量占比。这是一个比较细的特征,能够捕捉特定频带的异常能量聚集。
- 梅尔频率倒谱系数(MFCC):最初为语音识别设计,但在心音分类上表现同样不错,它模拟人耳对频率的非线性感知,用一组系数刻画频谱包络的形状。
MFCC前12到13个系数在心音分类中很常用。它们把高维频谱降成低维系数,并且去掉了与人耳感知无关的细节。
% 计算频谱质心 [pxx, f] = pwelch(seg, [], [], [], fs); spectral_centroid = sum(f .* pxx) / sum(pxx);3.3 时频特征:小波包分解为什么要用
频域特征有一个静态的缺陷:它丢掉了时间信息。心音是典型的非平稳信号,S1(第一心音)和S2(第二心音)出现的时间、间隔、频率成分都在动态变化。两个表面频谱接近的心音片段,可能因为杂音出现的时间点不同而有本质区别。
这时候要用到小波包分解。小波包可以看成一组带通滤波器组,把信号同时按频率和时间分解,得到一组时频系数。对这些系数做统计(能量、方差、熵),能够得到比纯频域更丰富的特征。
MATLAB里用wpdec做小波包分解:
wpt = wpdec(seg, 4, 'db4'); % 提取第4层各节点的能量特征 energy_features = []; for i = 1:16 cfs = wpcoef(wpt, i); energy_features = [energy_features sum(cfs.^2)]; end小波基的选择对结果有影响,但不需要过度纠结。实测db4和sym5在小波基中表现稳定,后续特征选择会帮你挑出有效维度,基函数带来的微小差异到那时基本被稀释了。
3.4 特征组合策略:先富集再筛选
很多人做特征提取时喜欢一开始就把特征列表压缩得很短,觉得特征多了会引起过拟合。我的做法反过来:先把能算的特征都算出来,再用特征选择算法筛掉冗余项。原因很简单,手工挑选特征时你根本不知道哪些组合有区分度,筛掉之后再想补回来代价比现在大得多。
特征组合的流程一般是这样的:
- 对每个片段计算时域特征(约5到8个)。
- 计算频域特征,包括频谱质心、带宽、子带能量和MFCC(约15到20个)。
- 做小波包分解,提取各节点能量和熵(约10到30个)。
- 全部拼接成一个特征向量,落在30到60维的区间。
特征向量维度太高时,可以用主成分分析(PCA)降维到20维以内,但要注意PCA是线性的,对非线性关系帮助有限。也可以直接用分类器内置的重要性评估来筛选特征,后面的章节会细说。
4. 分类器选型与实操:从简单模型到可用的系统
4.1 候选模型对比:决策树、SVM、KNN、Bagging
特征工程完成之后,分类器的作用就是把特征空间里的两类样本尽可能分开。适合心音分类的经典模型我按推荐顺序排个序,附上各自适合的场景:
| 分类器 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| 决策树 | 可解释性强,训练快 | 单独用容易过拟合,精度一般 | 基线效果、理解特征重要性 |
| KNN | 简单直接,无需训练 | 特征维度高时计算量大 | 小数据集、快速验证 |
| SVM(高斯核) | 对小样本高维数据效果好 | 参数敏感,需调C和gamma | 中等规模数据集的主力模型 |
| Bagged Trees / Random Forest | 抗过拟合,精度高 | 模型体积大,解释性弱 | 数据量较大时的首选 |
决策树适合做基线,不是因为效果会最好,而是因为训练后你直接看树的分裂规则,能直观感受哪些特征最重要。比如树的第一层如果按“频谱质心是否大于150 Hz”分裂,你就知道频域特征在整个任务里的贡献很大。
SVM和随机森林是我在这个项目里真正推荐的模型。SVM对中等规模样本的泛化能力强,随机森林对特征噪声的忍耐度高。两者可以都训练出来,再看测试集效果。
4.2 训练集与验证集划分:避免数据泄露
心音分类里有一个隐性陷阱:来自同一条长录音的多个片段高度相似。如果你把某条录音的一部分片段放进训练集,另一部分放进测试集,模型记忆了录音本身的个体特征,而不是疾病类别的共性规律,测试精度会虚高。这在医学信号处理中叫“数据泄露”或“个体泄露”。
解决方法是按录音而不是按片段划分数据。每一条录音要么完全属于训练集,要么完全属于测试集,保证同一录音的片段不会同时出现在训练和测试中。这样评估的性能才有临床参考价值——未来的新病人相当于一条从未见过的录音。
% 按录音ID划分,而不是按片段划分 rec_ids = unique(record_label); rng(42); train_recs = randsample(rec_ids, round(0.8*length(rec_ids))); train_mask = ismember(record_label, train_recs); test_mask = ~train_mask;4.3 实战训练代码与超参数调整
下面是我用的一个SVM训练流程,其中特征矩阵X是归一化后的,标签Y用1和-1表示正常和异常。
% 训练集和测试集 X_train = X(train_mask, :); Y_train = Y(train_mask); X_test = X(test_mask, :); Y_test = Y(test_mask); % 高斯核SVM,先用默认参数跑基线 svm_model = fitcsvm(X_train, Y_train, 'KernelFunction', 'rbf', 'Standardize', true); % 网格搜索C和gamma [C_grid, gamma_grid] = meshgrid(0.1:0.2:2, 0.01:0.03:0.5); cv_acc = zeros(size(C_grid)); for i = 1:numel(C_grid) mdl = fitcsvm(X_train, Y_train, 'KernelFunction', 'rbf', ... 'BoxConstraint', C_grid(i), 'KernelScale', 1/sqrt(2*gamma_grid(i))); cv_acc(i) = crossval('mcr', X_train, Y_train, 'Predfun', ... @(xtr, ytr, xte) predict(fitcsvm(xtr, ytr, ... 'KernelFunction', 'rbf', 'BoxConstraint', C_grid(i), ... 'KernelScale', 1/sqrt(2*gamma_grid(i))), xte)); end调参和别的事情一样,先粗后细。先用大步长确定最佳参数所在的大致区域,再在小范围内精细搜索。如果网格搜索计算量太大,可以用bayesopt做贝叶斯优化,能少跑不少次训练。
对于随机森林,MATLAB里也用fitcensemble配合Bag即可:
bag_mdl = fitcensemble(X_train, Y_train, 'Method', 'Bag', ... 'NumLearningCycles', 100, 'Learners', 'Tree');树的棵数我建议从50棵开始往上试,到150棵后增益基本饱和,再增加只会拖慢训练速度。学习器深度(即每棵树的最大分裂层数)默认取值已经够用,不需要手工限制得太严格。
4.4 类别不平衡处理:正常样本多、异常样本少怎么办
PhysioNet 2016数据集中正常样本的数量明显多于异常样本,直接训练会把所有样本都判成多数类,得到看似很高但其实毫无用处的准确率。处理这个问题有两种常用思路:
一种是对少数类做加权,让模型在计算损失时给异常样本更高权重。SVM中可以用'Cost'参数指定误分类代价:
cost_matrix = [0 1; 2 0]; % 把异常判成正常的代价更高 svm_model = fitcsvm(X_train, Y_train, 'KernelFunction', 'rbf', ... 'Cost', cost_matrix);另一种是数据层面的处理:对少数类样本做过采样(复制或插入合成样本),或者对多数类做欠采样。MATLAB的Classification Learner App里没有直接的SMOTE接口,得自己写或用第三方工具。相比之下调整代价矩阵更简洁,也够用。
我在这类任务上的经验是:先用代价矩阵把两类错误配平,看混淆矩阵里特异性和敏感性的变化,再决定要不要引入更复杂的重采样方法。多数情况下代价矩阵就能满足需求。
5. 结果评估与问题排查:准确率高不代表模型好
5.1 评估指标:准确率、敏感性、特异性、AUC
心音分类不只是看准确率,医学诊断场景里误判方向不同,代价也不同。漏判一个异常患者,会导致他错过进一步检查;误判一个正常人为异常,顶多多做一次复查。这两个错误不是等价的,所以评估时要同时看四个指标:
- 准确率(Accuracy):总体判断正确的比例。
- 敏感性(Sensitivity/Recall):实际异常样本中被正确识别出的比例。
- 特异性(Specificity):实际正常样本中被正确识别出的比例。
- AUC:ROC曲线下面积,衡量分类器在不同阈值下的整体区分能力。
MATLAB里用混淆矩阵可以方便地算出这些数值:
[labels_pred, score] = predict(svm_model, X_test); confmat = confusionmat(Y_test, labels_pred); accuracy = sum(diag(confmat)) / sum(confmat(:)); sensitivity = confmat(2,2) / sum(confmat(2,:)); specificity = confmat(1,1) / sum(confmat(1,:));5.2 常见问题速查表
我在项目中途和复盘过程中遇到了不少问题,这里整理成一张速查表,方便自己以后用,也方便参考的各位排查:
| 常见问题 | 可能原因 | 解决思路 |
|---|---|---|
| 准确率很高但敏感性很低 | 类别不平衡 | 调整代价矩阵或重采样 |
| 训练集精度高、测试集精度低 | 过拟合或数据泄露 | 检查是否按录音划分数据 |
| 特征增加后效果反而变差 | 冗余维度干扰 | 做特征选择或PCA降维 |
| 不同类别的AUC很接近但整体波动大 | 数据量不足 | 增大片段重叠率或收集更多数据 |
| 预测结果全部为正常 | SVM参数不合适或样本严重不平衡 | 先切换决策树看基线,再调SVM参数 |
5.3 用混淆矩阵做模型“体检”
单独看准确率是最容易自我欺骗的。我第一次跑完模型,准确率报出92%,当时还挺高兴,结果看了混淆矩阵发现异常样本的检出率只有60%。这说明模型只是把大多数正常样本赌对了,异常样本几乎全靠猜。
把混淆矩阵打印出来,你会很直观地看到模型在哪个类别上犯蠢。比如如果异常样本多数被分到正常侧,大概率是异常样本的特征和正常样本重叠太严重,这时候回看特征分布图,尝试追加时频特征可能比盲目调参更有效;如果异常和正常在特征空间有明显间隔但模型还是分错,才说明是分类器容量或参数不够。
提示:我在调模型时养成了一个习惯——每次实验都记录特征组合、模型参数、四个评估指标的完整表格。这样回头复查时不用靠记忆猜哪个版本做了什么事,排查效率高很多。
6. 项目复盘:我再做一次会怎么改进
6.1 我踩过的几个坑,希望你能避开
第一个坑是差点被数据泄露坑了。最初我按片段随机划分训练集和测试集,测试集AUC直接冲到0.98,兴奋了一阵子。后来做跨录音验证才发现真实水平在0.85左右。这个教训值钱:医学信号任务里,必须在患者级别做划分。
第二个坑是把特征工程做过头。有一版我提取了八十多个特征,包含大量非线性变换后的冗余信息,结果模型训练集精度接近100%,测试集反而更差。后来才明白,特征不是越多越好,加了太多噪声维度,分类器找不到焦点。用特征选择算法把维度压到25个之后,测试集效果反而提升了五个百分点。
第三个坑是忽略信号长度本身的区别。有些录音只有几秒钟,有些有几十秒,不控制片段长度直接提取特征,长短片段特征值的统计口径完全不同。后来统一了重采样率和片段长度,模型才稳定下来。
6.2 这个项目还可以怎么延伸
心音分类器只是起点。把特征提取的框架稍作调整,就能迁移到其他生物信号分类任务上,比如肺音分类(判断湿啰音、干啰音)、肠鸣音分析、震动信号故障诊断等。特征工程和分类器训练的思路是通用的,换一个数据集就能复用整个管道。
如果想把精度进一步提高,可以考虑引入深度学习,比如把心音片段转成梅尔频谱图,用预训练的卷积神经网络做图像分类;或者直接用一维卷积网络对原始波形端到端建模。但深度学习需要更多数据和更久的训练时间,对于新手来说,先用经典机器学习把整个流程吃透,是性价比更高的路径。
根据我个人做完一遍的体会,这个项目的最大价值不在于最终成绩有多好,而在于它强制你把信号处理和机器学习这两块技能真正打通。特征提取阶段你需要理解信号在频域长什么样、杂音会改变什么物理量;分类器训练阶段你需要理解数据划分、交叉验证和评估指标之间的微妙关系。这两样东西合起来,才是做真实计算任务的核心能力。