最近在整理一个声音信号分类的小任务:需要把两类声音自动区分开,比如工地上“电钻声”和“正常敲击声”,或者办公室里“碎纸机运行声”和“空调风噪声”。任务本身不算大,但一通折腾下来,从特征提取到分类器调参,踩了不少坑。这篇博客就用Matlab把“声音信号二分类+SVM分类实战”从头到尾拆开讲清楚:为什么要这样设计、每一步代码在干什么、踩过哪些坑、怎么排查。适合刚接触音频分类、不想一上来就上深度学习的同学,也适合已经有Python基础想对比Matlab做法的朋友。我会尽量用口语和实操细节来说,保证你看完能直接在自己的数据集上把流程跑起来。
1. 项目拆解:声音信号二分类为什么选择SVM
1.1 技术选型:SVM适合什么样的声音分类场景
很多人一听到“声音分类”,第一反应是CNN、LSTM或者Transformer,但这些方案有一个共同前提:需要足够多的数据,还得有GPU。实际项目里,尤其是工业现场或实验室场景,我们能拿到的干净样本往往只有几十到几百条,每条几秒钟。这种规模下,深度模型很容易过拟合,调参成本还高。SVM(支持向量机)反而更合适:它不依赖海量数据,在高维小样本上表现稳定,训练速度快,模型结果可解释,Matlab里一行fitcsvm就能训练。
二分类是SVM最擅长的领域。它的核心思路是:在特征空间里找一个“间隔最大”的分隔超平面。如果原始特征维度不高但有重叠,可以通过核函数把数据映射到更高维空间,让两类变得线性可分。声音信号本身不是一个向量,而是一段时间序列,因此不能直接把波形丢给SVM,而是要先把它转化成一组能够刻画声音特性的数值特征。这也是整个项目里最关键、也最容易被忽略的一步。特征没提好,换再好的分类器也没用;特征提好了,哪怕是线性SVM都能拿到不错的准确率。
1.2 整体处理流程
我把这个项目的完整流程拆成六个环节:
- 数据准备:读取声音文件,统一采样率、声道、长度。
- 预加重与分帧加窗:模拟人耳听觉,把长信号切成短帧。
- 特征提取:从每一帧里提取短时能量、过零率、MFCC等,再聚合为单个样本的特征向量。
- 数据划分与归一化:按“样本”为单位划分训练集、测试集,避免数据泄漏。
- SVM训练:使用RBF核,通过交叉验证搜索最优参数。
- 评估与调优:用混淆矩阵、ROC曲线、准确率/F1等指标看效果,迭代调整特征组合和参数。
流程看起来简单,但每个环节都有“为什么这么做”和“有没有更好的做法”。下面结合代码和实际经验,逐一展开。
1.3 数据与标签设计:二分类不是“贴标签”那么简单
声音二分类的“两类”必须明确且可区分。我第一次做的时候,本想区分“男生说话”和“女生说话”,但录了一段环境混合噪声后,发现模型学到的是“麦克风远近”而不是音色。后来把样本重新录制,保证同等距离和环境,效果立刻提升。所以第一步要定义清楚:两个类别在声学上到底靠什么区分?是频率特征?时域包络?还是节奏规律?
数据组织上,我建议按“事件切片”来保存每个样本,而不是把一段长录音直接切块。比如有10段录音,每段5秒,其中包含多次声音事件,那么每个事件单独截成一个样本文件,同时记录类别标签。这样做的好处是训练/测试划分时可以按“不同录音来源”划分,避免同一段录音的相邻切片出现在训练和测试集里。如果直接随机划分所有切片,可能出现数据泄漏,测试准确率虚高,部署时立刻打回原形。
标签在Matlab里可以直接用数值,比如正类为1、负类为0(fitcsvm也支持逻辑值true/false)。不要用字符串标签,fitcsvm接受不了。建议把所有样本路径、标签存到一个表格里,方便后续循环读取。
2. 特征工程:从一段波形到SVM能读懂的向量
2.1 数据读取与格式统一
Matlab读取音频文件用audioread,这一步本身很简单,麻烦在格式不统一。实际项目中,有的录音是44.1kHz、有的是48kHz,有的是双声道、有的是单声道,样本长度也不一样。SVM要求每条样本的特征向量维度一致,所以必须先做统一。
我的固定做法是:先把采样率统一到16kHz。语音和大多数机械声的有效频率范围都在8kHz以下,16kHz采样率足够,而且能减少计算量。如果原始采样率是48kHz,用resample降下来。声道方面,如果是立体声,直接取平均转成单声道,代码是x = mean(x, 2)。长度方面,设置一个固定长度sampleLen,比如1秒或2秒。长度不够的补零,长度超出的截取。注意,这里补零和截取都要以“样本起点对齐”为前提,最好每个样本都是某个声音事件的完整片段,而不是从中间硬截。
下面是读取一个样本的示例代码:
function x = loadSample(filePath, fsTarget, sampleLen) % 读取并统一音频格式 % fsTarget: 目标采样率(Hz) % sampleLen: 目标样本长度(采样点数) [x, fs] = audioread(filePath); if size(x, 2) > 1 x = mean(x, 2); % 多声道转单声道 end if fs ~= fsTarget x = resample(x, fsTarget, fs); end if length(x) < sampleLen x = [x; zeros(sampleLen - length(x), 1)]; % 补零 else x = x(1:sampleLen); % 截断 end % 去掉直流分量 x = x - mean(x); end2.2 预加重、分帧与加窗:声音信号的基本预处理
数字声音信号在传播过程中,高频成分衰减比低频快。人耳会自动补偿这种衰减,机器不会。所以预处理的第一步是预加重,用一个一阶高通滤波器把高频拉回来。常用系数是0.97:
x = filter([1 -0.97], 1, x);预加重之后,信号不能整段直接做FFT,因为声音是时变信号,不同时刻频率特性不同。标准做法是分帧:比如每帧25毫秒,帧移10毫秒。这样相邻帧之间有一段重叠,避免窗边缘信息丢失,同时能捕捉到声音的短时变化。分帧后再对每帧加汉明窗,抑制频谱泄漏。分帧函数可以自己写,也可以直接用buffer。我自己习惯手写,逻辑更清楚:
function frames = enframe(x, winLen, hopLen) % x: 单声道信号 % winLen: 帧长(采样点数) % hopLen: 帧移(采样点数) n = length(x); numFrame = floor((n - winLen) / hopLen) + 1; frames = zeros(numFrame, winLen); idx = 1; for i = 1:numFrame if idx + winLen - 1 <= n frames(i, :) = x(idx:idx+winLen-1)'; end idx = idx + hopLen; end win = hamming(winLen, 'periodic'); frames = frames .* win'; % 每一帧乘窗函数 end这里有个容易被忽略的点:帧数计算要用floor,最后一个不足一帧的残段直接丢弃,不要用零补齐,否则会引入低频伪影。winLen是帧长,25ms在16kHz下就是round(16000*0.025)=400点;hopLen同理是round(16000*0.010)=160点。
2.3 三种核心特征:短时能量、过零率、MFCC
分帧之后,每一帧都变成一个一维数组。接下来要提特征。我常用三种,各有侧重:
- 短时能量:计算每帧内采样点平方和,反映声音的响度变化。适合区分“有声音”和“没声音”,但对于区分“电钻声”和“敲击声”这种响度相近的类别,单独用远远不够。
- 过零率:每帧内信号跨越零点的次数/帧长度,粗略反映频率高低。清音、摩擦音过零率高,浊音和低频机械声过零率低。它对噪声比较敏感,所以通常会和能量一起看。
- MFCC(梅尔频率倒谱系数):模仿人耳对不同频率的非线性感知,把频谱映射到Mel尺度,再做DCT取倒谱系数。它几乎是目前传统声音分类的标配特征。MFCC对音色、内容有很强的表征能力,但计算稍复杂。
短时能量和过零率的计算:
energy = sum(frames.^2, 2); % 每帧能量 zc = sum(abs(diff(sign(frames), 1, 2)) > 0, 2) / winLen; % 过零率MFCC在Matlab里有现成函数,在Audio Toolbox中可以直接用mfcc。如果没有该工具箱,也可以用filterBank手动实现,但代码量比较大。这里给出基于工具箱的快速实现:
coeffs = mfcc(frames, fsTarget, ... 'LogEnergy', 'replace', ... 'WindowLength', winLen, ... 'OverlapLength', winLen - hopLen, ... 'NumCoeffs', 13); % coeffs 是 numFrame x 13 的矩阵mfcc函数默认会做预加重,所以如果已经手动预加过一遍,这里可以设置'Window'相关参数,或者干脆不要重复预加重。我建议是:手动做预加重,然后调用mfcc时指定'DetectSound'为false之类的选项。更稳妥的做法是直接阅读工具箱文档,弄清楚每个参数是否已经包含预加重。这里编译器不会报错,但特征会有差异。
得到每一帧的特征后,不能直接把这些矩阵拼起来当样本向量。SVM处理的是定长向量,而帧数会随样本长度变化。我见过有人把所有帧的特征拉平成一个大向量,这样维度可能上千,小样本下训练极慢还容易过拟合。更简单、更实用的做法是计算统计量:把整个样本所有帧的energy、zc、MFCC等特征的均值和方差拼成一个固定长度的向量。这样保留了一段时间内的总体特征和波动信息。比如:
featureVector = [ mean(energy), std(energy), ... mean(zc), std(zc), ... mean(coeffs, 1), std(coeffs, 1) ];这样单个样本的特征维度是:1+1+1+1+13+13=30维。别小看30维,它足以描述声音的响度变化、频率粗略分布和音色全局特征。如果后续发现区分度不够,再增加每帧的特征组合或引入其他时域、频域统计量。
2.4 特征归一化:很多新手在这里翻车
SVM对特征尺度非常敏感。如果能量特征数值在几千到几万,MFCC只有几十,模型会默认“能量”更重要,但实际可能“音色”更有区分度。所以必须做归一化。我通常用z-score标准化:
% trainFeat: nTrain x d 矩阵 % testFeat: nTest x d 矩阵 mu = mean(trainFeat, 1); sd = std(trainFeat, 0, 1); sd(sd == 0) = 1; % 防止常数列除零 trainFeat = (trainFeat - mu) ./ sd; testFeat = (testFeat - mu) ./ sd;这里最关键的坑是:必须只使用训练集计算均值和标准差,再应用于测试集。如果你用整个数据集先标准化再划分训练/测试,测试集的信息就提前泄漏到了训练过程里,交叉验证和测试准确率都会被高估。实际部署时,你只能拿训练阶段固定的mu和sd去处理新样本,这个顺序不能乱。
3. SVM训练与预测:Matlab实战
3.1 fitcsvm关键参数怎么设置
Matlab训练SVM二分类器的接口是fitcsvm。最常用的几个参数:
KernelFunction:核函数类型,支持'linear'、'polynomial'、'rbf'等。默认是'linear'。声音特征通常不是严格线性可分的,所以我一般直接用'rbf'。RBF核相当于把每个样本映射到无穷维空间,能够处理非线性边界。KernelScale:RBF核的尺度参数,可以理解为“影响半径”。数值越大,决策边界越平滑;越小,边界越复杂。设为'auto'会按启发式规则自动估计一个初值,后续配合交叉验证调优。BoxConstraint:软间隔惩罚系数C。越大越重视训练集上的分类正确性,越小越重视模型泛化能力。默认是1,需要和KernelScale一起调。Standardize:是否在每个特征列上做标准化。因为我们手动归一化了,这里可以设true也不冲突,设true更保险。如果手动没有归一化,这里一定要设true。Prior:类别先验概率。数据不平衡时很有用,后面单独说。
一个典型的训练代码如下:
svmModel = fitcsvm(trainFeat, trainLabel, ... 'KernelFunction', 'rbf', ... 'KernelScale', 'auto', ... 'BoxConstraint', 1, ... 'Standardize', true, ... 'ClassNames', [0, 1]); % 保证类别标签顺序注意ClassNames要写清楚,因为后面画ROC时需要用score对应到哪个类别。
3.2 训练与测试代码实现
假设我们已经有一个结构体或者表格数据:samples表包含三列:filePath(文件路径)、label(0或1)。我们先用一个循环读取所有样本,提取特征并存储:
fsTarget = 16000; sampleLen = fsTarget * 1; % 1秒 features = []; labels = []; for i = 1:height(samples) x = loadSample(samples.filePath{i}, fsTarget, sampleLen); % 分帧 winLen = round(0.025 * fsTarget); hopLen = round(0.010 * fsTarget); frames = enframe(x, winLen, hopLen); % 特征 energy = sum(frames.^2, 2); zc = sum(abs(diff(sign(frames), 1, 2)) > 0, 2) / winLen; coeffs = mfcc(frames, fsTarget, ... 'WindowLength', winLen, ... 'OverlapLength', winLen - hopLen, ... 'NumCoeffs', 13); featureVector = [mean(energy), std(energy), ... mean(zc), std(zc), ... mean(coeffs, 1), std(coeffs, 1)]; features = [features; featureVector]; labels = [labels; samples.label(i)]; end接下来划分训练集和测试集。我用cvpartition保证按类别分层随机划分:
rng(42); cv = cvpartition(labels, 'Holdout', 0.3); trainIdx = training(cv); testIdx = test(cv); trainFeat = features(trainIdx, :); trainLabel = labels(trainIdx); testFeat = features(testIdx, :); testLabel = labels(testIdx);然后做归一化和训练:
mu = mean(trainFeat, 1); sd = std(trainFeat, 0, 1); sd(sd == 0) = 1; trainFeat = (trainFeat - mu) ./ sd; testFeat = (testFeat - mu) ./ sd; svmModel = fitcsvm(trainFeat, trainLabel, ... 'KernelFunction', 'rbf', ... 'KernelScale', 'auto', ... 'BoxConstraint', 1, ... 'Standardize', false, ... % 因为已经手动归一化 'ClassNames', [0, 1]);有人会问:Standardize设false还是true?如果手动已经标准化,设false即可,否则会再标准一次,但这次是基于训练数据内部的标准差,结果差异不大。我更建议设true,这样即使手动忘了归一化也有兜底。不过测试时predict不会自动用训练数据的统计量归一化,所以还是需要手动保存mu/sd并应用。
3.3 用训练好的模型预测新样本
训练完成后,对新样本的预测分两步:提取特征,再标准化,然后predict。
% 假设newFeat是1xd的特征向量 newFeatScaled = (newFeat - mu) ./ sd; [label_pred, score_pred] = predict(svmModel, newFeatScaled);predict的第二个返回值score,在二分类问题中是n×2的矩阵,每列表示该样本属于对应类别的“得分”。如果设置了'FitPosterior',true,score就是后验概率;否则是基于决策边界的距离,也可以用来排序。画ROC曲线时,通常取正类(类别1)对应的那一列,即score_pred(:,2)。
label_pred的值就是之前定义的ClassNames中的数值0或1。到此,一个能跑通的最小闭环就完成了。
4. 模型评估与调优:别只盯着准确率
4.1 用混淆矩阵和ROC曲线看真实水平
很多初学朋友只看准确率,但在二分类里,如果数据不平衡,准确率会骗人。比如95%都是类0,模型全部预测为类0也能得到95%准确率,看起来很好,实际上类1一个都没抓到。所以我会强制自己看混淆矩阵、精确率、召回率和F1分数。
先用Matlab直接画混淆矩阵:
[labelPred, score] = predict(svmModel, testFeat); cm = confusionchart(testLabel, labelPred, ... 'RowSummary', 'row-normalized', ... 'ColumnSummary', 'column-normalized');从混淆矩阵可以算出:
- 精确率(Precision)= TP / (TP + FP),意思是预测为类1的样本里真正是类1的比例。
- 召回率(Recall)= TP / (TP + FN),意思是所有真实类1样本里被正确找到的比例。
- F1 = 2 * Precision * Recall / (Precision + Recall)。
在声音分类任务里,这两个指标往往此消彼长:放宽阈值,召回率升高,精确率下降;收紧阈值则反过来。所以需要看ROC曲线和AUC。
绘制ROC曲线,用perfcurve:
[Xroc, Yroc, ~, AUC] = perfcurve(testLabel, score(:, 2), 1); figure; plot(Xroc, Yroc); xlabel('假正率'); ylabel('真正率'); title(['ROC曲线, AUC = ', num2str(AUC)]);AUC越接近1越好,0.5说明模型和随机猜没区别。我在实际项目中,AUC低于0.8一般不会上线,至少要0.9以上,并且混淆矩阵中两类都要有较好的F1。如果只追求总体准确率,忽略单个类别的召回,跟风噪声源区分任务里很容易漏报。
4.2 交叉验证与网格搜索:找到合适的C和KernelScale
RBF核的SVM有两个关键超参数:BoxConstraint(C)和KernelScale(gamma相关)。它们对模型性能影响巨大,不能随便设。我的做法是做一个简单的网格搜索,用K折交叉验证选最优参数。
代码结构如下:
cList = [0.1, 1, 10, 30]; gammaScaleList = [0.1, 0.5, 1, 2, 5]; results = table(); k = 5; for ci = 1:length(cList) for gi = 1:length(gammaScaleList) cvMdl = fitcsvm(trainFeat, trainLabel, ... 'KernelFunction', 'rbf', ... 'KernelScale', gammaScaleList(gi), ... 'BoxConstraint', cList(ci), ... 'Standardize', false); cvMdl = crossval(cvMdl, 'KFold', k); loss = kfoldLoss(cvMdl); acc = 1 - loss; results = [results; table(cList(ci), gammaScaleList(gi), acc)]; end end results.Properties.VariableNames = {'C', 'KernelScale', 'Acc'}; [bestScore, idx] = max(results.Acc); bestC = results.C(idx); bestKernelScale = results.KernelScale(idx);这里要提醒一个坑:网格搜索里,如果用的是RBF核且KernelScale是手动指定的,那么训练集和测试集的所有特征都应在同一个尺度范围。如果没归一化,不同特征量纲不同,网格搜索的结果会非常不可靠。所以归一化要在进入网格搜索之前完成。
训练集足够大的时候,可以留一部分独立的验证集,避免过度调参。如果数据量小,建议直接把整个训练数据做K折交叉验证,最终参数确定后再用测试集评估一次。不要用测试集反复指导调参,那是把测试集当验证集用,会高估泛化能力。
4.3 不同特征组合的效果对比
特征组合不同,效果差距很大。我实际在一组“敲击声 vs 电钻声”任务上做过对比,数据量每类120条,训练集160条,测试集80条,结果大致如下:
| 特征组合 | 测试准确率 | AUC | 训练时间 |
|---|---|---|---|
| 仅短时能量+过零率(4维) | 72.5% | 0.78 | 极短 |
| 仅MFCC均值+方差(26维) | 93.8% | 0.97 | 较短 |
| 能量+过零率+MFCC(30维) | 95.0% | 0.98 | 稍长 |
从表中可以看到,MFCC是主要贡献者,而能量和过零率能带来约1-2个百分点的提升。这个规律在许多声音分类任务里都成立。不过也有例外:如果分类目标是“大声喊叫”和“轻声说话”,短时能量和过零率可能就够用了。所以不要死记公式,要针对你的任务做特征组合对比。
另外,MFCC的系数个数也影响效果。我试过12、13、20个系数,结果差异不大,13个是常用默认值。系数太多反而会引入细节噪声。如果对某个任务效果不理想,可以把MFCC的一阶差分、二阶差分也加上,进一步描述时序变化。但直接加差分会拉高维度,小样本下慎用。
5. 常见问题与避坑实录
5.1 两类样本数量不均衡怎么办
声音分类任务里,采集到的正负样本数量经常差距悬殊。比如“故障声音”很少,“正常运行声音”很多。如果直接用原始数据训练,SVM会偏向多数类,导致少数类召回率低。解决方案有几种,按效果从好到差排序:
- 设置类别权重:在
fitcsvm中设置'Prior','uniform',让两类先验概率相等。这是最省事的做法。 - 使用
Cost矩阵,把少数类分错设置更高代价。比如'Cost',[0 1; 2 0],表示把类0错分成类1的代价是2,让模型更重视类0。 - 数据增强过采样:对少数类样本做微小时间平移、加少量噪声、改变音量生成新样本。这种方法有效,但要注意不要和测试集重复。
- 下采样多数类:随机丢弃多数类样本,直到数量接近少数类。适合样本总量比较大的情况。
我自己常用Prior','uniform'加数据增强组合。单纯下采样会浪费太多数据。
5.2 归一化和数据划分的“顺序陷阱”
前面已经提到,归一化必须在划分训练/测试之后进行,且只拟合训练集的统计量。我在第一次做的时候,图省事用zscore对整个特征矩阵标准化,再划分数据集,结果测试准确率高达98%,后来用新录音实测只有70%。排查后才发现是归一化泄漏。新样本没法获得未来样本的均值和方差,只能用训练时的统计量。这是一个非常经典、非常隐蔽的坑。建议在代码里明确分成三段:
- 划分数据;
- 用训练集计算mu/sd;
- 分别归一化训练集和测试集。
5.3 模型过拟合与泛化能力问题
SVM在小样本上很容易过拟合,尤其是RBF核KernelScale很小、BoxConstraint很大的时候,决策边界会弯曲得特别厉害,训练集准确率接近100%,测试集却惨不忍睹。判断过拟合最简单的办法:比较训练集准确率和验证集准确率,如果两者差距超过5%-10%,大概率过拟合了。
我处理过拟合的思路:
- 增大
KernelScale,让边界更平滑; - 减小
BoxConstraint,允许更多训练样本落在间隔内; - 增加训练样本量(录音采集、数据增强);
- 简化特征,比如只保留MFCC前8-13个系数,去掉方差过大的特征列。
另外,如果用了一个类别的样本来自同一批次录音,另一个类别来自不同批次,模型可能学到的是“录音环境差异”而不是“声音类型差异”。解决方法是采集时尽量让两类样本在相同环境、相同设备、不同时刻分布,并且按录音来源划分训练测试集。
5.4 Matlab实现里容易踩的代码级坑
最后整理几个Matlab特有的问题,虽然小,但非常耽误时间。
audioread返回的列向量是列,不是行。mean(x,2)可以正确降维,如果写成mean(x),会把所有声道和时间平均成一个数,样本直接废掉。mfcc函数在不同版本参数名不同。比如旧版叫mfcc,新版叫mfcc但多了WindowLength、OverlapLength等选项。建议用doc mfcc查一下当前版本支持哪些参数,不要照搬网上代码。fitcsvm要求训练样本是矩阵,每一行是一个样本。如果特征提取时把列向量拼错,比如变成features = [features, featureVector'],可能维度混乱,要反复调试。统一用行向量拼接。- 当某一类样本数量为0或只有1个时,
fitcsvm直接报错。可以先打印类别计数检查。 - 如果特征列中某一列在训练集上是常数,
std为0,归一化会除零。上面代码里已经处理:sd(sd==0)=1。如果忘了,会出现NaN,后续训练直接失败。 - 内存溢出问题:不要在一个循环里把每个样本的所有帧特征存成一个大矩阵再处理,应该每提取一个样本就立即聚合为一行特征,这样内存占用很小。
还有一个经验技巧:把所有提取特征的过程封装成一个函数,比如extractSampleFeature(filePath, fsTarget, sampleLen),然后缓存到.mat文件。因为每次调参都要重新读音频、提特征,太花时间。我通常把训练集特征矩阵、测试集特征矩阵、标签一起保存,网格搜索时直接加载,几秒钟就能跑完一轮。
写在最后:一点个人体会
做这个项目过程中,我最大的感受是:声音分类项目里最难的往往不是SVM本身,而是特征工程和数据质量。同样的分类器,只要特征提得合适,结果可能从60分跳到95分。我现在拿到一个新任务,会先用短时能量+过零率快速跑一条baseline,确认数据划分和代码流程没bug,再加入MFCC并做参数调优。这样能快速把“数据问题”和“算法问题”分开排查。还有一个小技巧:在调参之前,把提取好的特征矩阵用boxplot或者tsne可视化一下,直观看看两类有没有分开。如果特征分布重叠得厉害,那就先回头改特征,而不是急着调C和gamma。希望这篇实战记录能帮你少踩几个坑。