
简介围绕食物声音辨物这一语音识别任务资源完整提供CNN与XGBoost两种建模方案并配套源码、数据集与入门教程。CNN分支以梅尔频率倒谱系数MFCC为输入特征通过多层卷积与池化逐级提取声音的局部纹理和时序模式适配咀嚼、切割等非平稳食物声信号XGBoost分支则基于PLP参数或手工特征构建梯度提升决策树集成以弱学习器迭代优化分类边界突显传统机器学习在特征工程上的可控性与可解释性。两种方法形成深度学习与经典算法在同一任务上的直接对比便于读者理解不同技术路线的应用场景。学习内容覆盖声学模型、特征提取、训练评估与优化全流程并扩展了支持向量机、随机森林等备选算法的思路。压缩包大小约534.6MB已有340人学习适合语音识别初学者或对声音分类感兴趣的开发者下载实践。1. 食物声音辨物是什么只听咔嚓声模型就能告诉你嘴里是薯片还是苹果把手机贴着餐桌录一段用餐音频模型要回答的不是“有人在说话”而是“这个人咬的到底是什么”——薯片、苹果、胡萝卜还是苏打饼干。这个任务看起来冷门实际落地场景不少餐饮门店做顾客进食偏好统计、健康App自动记饮食日志、甚至智能冰箱判断你取走的零食种类。反直觉的一点它不需要高保真麦克风16kHz采样率也能大致跑通但换到22050Hz之后胡萝卜和薯片这种相似脆声类才能被真正分开。真正的门槛永远是数据集自建一两百条短音频比调模型参数更花时间。这篇文章用CNN和XGBoost各跑一条完整路线从录数据、提特征到训练调参给出一套能直接复现的最小实现适合正在做音频分类却缺一个具体项目练手的人。2. 数据集怎么建先定义6类食物声音再做特征工程2.1 类别怎么定把食物选择变成声学差异食物声音辨物本质是短音频多分类问题第一步不是选模型而是定“听得出的类别”。类别之间必须有稳定的声学差异否则模型再强也白搭。常见做法是选6类差异明显的食物脆薯片、生胡萝卜、红苹果、苏打饼干、软面包块、喝水吞咽。这6类的声学签名差别很大值得先写下来再录数据薯片声接近白噪声高频能量饱满时间包络随机听起来“沙沙”的胡萝卜脆得更硬咬断瞬间的脉冲更短更尖锐高频衰减更慢苹果因为含水高频振铃带着阻尼感比胡萝卜更“润”苏打饼干中频密实咬碎时有节奏感软面包几乎没什么高频能量集中在低频和摩擦声喝水则是一个低能量、带吞咽间隔的包络。把这些声学差异写进标注表后文提特征时就知道该保留哪些维度。如果不想全部自己录ESC-50这类公开音效基准里也有咀嚼、撕咬类条目可以先把其中几个类挑出来验证整条管线等设备齐了再补自己的真实数据。自建数据初期不要追求量大先把每类录音的一致性做扎实后面模型的收益会比盲目加样本更明显。2.2 录制与预处理1.5秒短样本、目录规范和RMS归一化建议每类录2530条每条1.5秒左右。用sounddevice录起来很简单import sounddevice as sd import scipy.io.wavfile as wav import numpy as np fs 22050 duration 1.5 for i in range(25): input(f按回车录制 薯片 第 {i1} 条) audio sd.rec(int(fs * duration), sampleratefs, channels1, dtypefloat32) sd.wait() # 目录按 data/类别/序号.wav 组织标签由目录名生成 wav.write(fdata/chips/{i:02d}.wav, fs, (audio * 32767).astype(np.int16))录制的关键不是设备而是控制变量嘴巴偏离麦克风10厘米左右不要忽远忽近按下录音后先空0.2秒再咬让后处理能干净地切掉按键噪声同一个人分两三天录制避免某一天的状态污染全部样本。目录结构固定为data/类别/编号.wav训练时直接扫目录标签由目录名生成这样标注错位的概率会大幅下降。采样率选22050而不是语音识别常用的16000是因为食物声音的区分信息大量出现在8kHz以上的高频区。22050的奈奎斯特频率是11025Hz能把薯片、胡萝卜的高频振铃保留下来。如果为了和已有语音识别管线对齐用16000也能跑但“胡萝卜vs薯片”这类相似类的准确率通常会掉3到5个点。2.3 特征提取一份代码同时产出CNN输入和XGBoost输入后面两条路线吃的东西不同CNN吃log-mel频谱图相当于把1.5秒音频压成一张64x64的“图像”XGBoost吃统计特征向量。一次把两种输入都算出来避免反复加载音频import librosa import numpy as np def extract_features(path, n_mels64, n_fft1024, hop512): y, sr librosa.load(path, sr22050, duration1.5) y, _ librosa.effects.trim(y, top_db25) # 去首尾静音 # RMS 归一化把响度拉到统一水平缓解录音距离差异 rms_all librosa.feature.rms(yy, frame_lengthn_fft, hop_lengthhop) target 0.05 gain target / (np.mean(rms_all) 1e-8) y y * min(gain, 50.0) mel librosa.feature.melspectrogram(yy, srsr, n_fftn_fft, hop_lengthhop, n_melsn_mels) log_mel librosa.power_to_db(mel) mfcc librosa.feature.mfcc(yy, srsr, n_mfcc13, n_fftn_fft, hop_lengthhop) zcr librosa.feature.zero_crossing_rate(yy, frame_lengthn_fft, hop_lengthhop) cent librosa.feature.spectral_centroid(yy, srsr, n_fftn_fft, hop_lengthhop) rms librosa.feature.rms(yy, frame_lengthn_fft, hop_lengthhop) flat librosa.feature.spectral_flatness(yy, n_fftn_fft, hop_lengthhop) stats [] for feat in [mfcc, zcr, cent, rms, flat]: stats.extend([np.mean(feat), np.std(feat), np.percentile(feat, 25), np.percentile(feat, 75)]) return log_mel, np.array(stats, dtypenp.float32)这里的n_fft1024对应约46毫秒窗长hop512是23毫秒步进。n_mels64把频谱压到64阶梅尔刻度对食物声音来说足够细再大只会增加计算量。统计特征维度是(13 1 1 1 1) * 4 6813是MFCC系数数后面四个分别是过零率、频谱质心、RMS、谱平坦度各取均值、标准差、25%分位和75%分位。这个68维向量就是第4章XGBoost的输入。3. CNN卷积网络路线把频谱图当图像三层卷积就能辨物3.1 为什么CNN适合这种短音频时频局部模式是它的强项食物声音的辨识信号不是“整张频谱长得像”而是局部的时频纹理薯片在某一帧突然出现一串宽带脉冲苹果的高频振铃持续几十毫秒再衰减苏打饼干是一连串有节奏的窄带撞击。这些局部模式恰好是卷积操作擅长的——卷积核在时频图上滑过本质上是在时间轴上匹配“某种频谱形态出现了几次”。和图像分类不同这个任务输入是单通道的64x64左右小图不需要ResNet那样的深网络。三层卷积加池化就能把瞬态和共振峰都提出来参数量小反而更容易在几百条数据上收敛。选CNN还有一层实操理由推理时对时长不敏感。现场录了2.1秒音频经过trim和Mel变换后比训练时长多几十帧最后用自适应池化压掉多余维度就能直接推理不必强行裁成1.5秒。3.2 模型结构卷积加批归一化自适应池化兜底PyTorch实现如下import torch import torch.nn as nn class FoodSoundCNN(nn.Module): def __init__(self, n_mels64, num_classes6): super().__init__() self.backbone nn.Sequential( nn.Conv2d(1, 16, kernel_size3, padding1), nn.BatchNorm2d(16), nn.ReLU(), nn.MaxPool2d(2), # 64x64 - 32x32 nn.Conv2d(16, 32, kernel_size3, padding1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2), # 32x32 - 16x16 nn.Conv2d(32, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(), nn.AdaptiveAvgPool2d((1, 1)) # 16x16 - 1x1 ) self.classifier nn.Linear(64, num_classes) def forward(self, x): h self.backbone(x).flatten(1) return self.classifier(h)输入形状是(batch, 1, n_mels, time)。前两层卷积都接BatchNorm和ReLU用MaxPool把频率维从64压到32再到16。第三层卷积不急着压改用AdaptiveAvgPool2d((1, 1))把任意尺寸的时频特征收敛成64维向量再过线性分类器。用自适应池化而不是全连接层就是为了让网络吃下可变帧数。各层输出尺寸变化如下网络层输出尺寸输入(1, 64, T)Conv1 BN ReLU MaxPool(16, 32, T/2)Conv2 BN ReLU MaxPool(32, 16, T/4)Conv3 BN ReLU AdaptiveAvgPool(64, 1, 1)Linear(6,)训练循环import torch.nn.functional as F from torch.utils.data import DataLoader, TensorDataset def train_one_epoch(model, loader, optimizer): model.train() losses [] for x, y in loader: optimizer.zero_grad() loss F.cross_entropy(model(x), y) loss.backward() optimizer.step() losses.append(loss.item()) return sum(losses) / len(losses) model FoodSoundCNN() optimizer torch.optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) for epoch in range(30): loss train_one_epoch(model, train_loader, optimizer) print(fepoch {epoch1:02d} loss {loss:.3f})训练参数batch_size16lr1e-330个epoch。自建数据总共不到200条30轮足够。如果第10轮训练loss还在0.5以上先回头查数据流程不要急着调网络结构——CNN在这种规模上收敛慢多半是增强没加对或标签有问题。3.3 数据增强时域平移、加噪和SpecAugment我会在log-mel域做三种增强这样不用反复解音频def augment(log_mel): # 时间平移随机滚动幅度 ±15% shift np.random.randint(-int(log_mel.shape[1] * 0.15), int(log_mel.shape[1] * 0.15)) log_mel np.roll(log_mel, shift, axis1) # 随机屏蔽 2 个梅尔频带和 2 段 5 帧的时间块 mel_bins log_mel.shape[0] freq_start np.random.randint(max(mel_bins - 2, 1)) log_mel[freq_start:freq_start 2] -80.0 time_start np.random.randint(max(log_mel.shape[1] - 6, 1)) log_mel[:, time_start:time_start 6] -80.0 return log_melnp.roll做时间平移会有边缘环绕这在短音频上无伤大雅。把频带或时间块的值设为-80dB等于在log-mel图上盖一小块静音这就是SpecAugment简化版——强迫模型同时看多条时频证据而不是死盯某一个瞬时特征。一个重要原则增强只对训练集做验证集和测试集保持原样。常见翻车是把增强也套在验证集上导致验证loss永远震荡下不来。我一般把augment写进Dataset的__getitem__里每个epoch看到的训练数据都不同相当于把不到200条样本放大成一个无限数据流CNN不容易背下原始样本。4. XGBoost方法68维手工特征加梯度提升小数据集的稳路线4.1 为什么小数据集上XGBoost常比CNN先出结果当每类只有25条样本时CNN很容易过拟合而XGBoost吃的是68维汇总统计特征天然做了信息压缩再加上树模型的正则项对冗余和噪声的容忍度比全连接层强得多。实际跑下来同样一个数据集XGBoost往往半小时内给出75%到85%的验证准确率CNN可能要试十几次增强和结构调整才能追上。但这不代表CNN没有价值CNN能发现“单帧高频瞬态出现了三次”这种细粒度的顺序信息而统计特征把时间顺序平均掉了。所以我做项目的习惯是先跑XGBoost拿到可信基线如果基线能到80%以上再决定要不要花时间调CNN如果XGBoost怎么调都只有60%出头那不是模型问题是数据或特征问题先回头修数据。4.2 把68维特征灌进XGBoost一次收敛的标配参数特征矩阵整理成(样本数, 68)标签用0到5的整数。训练代码import xgboost as xgb from sklearn.model_selection import train_test_split X_train, X_val, y_train, y_val train_test_split( X, y, test_size0.2, stratifyy, random_state42 ) model xgb.XGBClassifier( objectivemulti:softmax, num_class6, tree_methodhist, # 直方图建树内存占用低训练更快 n_estimators400, max_depth4, learning_rate0.05, subsample0.8, colsample_bytree0.8, reg_lambda1.0, n_jobs-1, eval_metricmlogloss, ) model.fit(X_train, y_train, eval_set[(X_val, y_val)], early_stopping_rounds20, verbose50)参数说明objectivemulti:softmax是6分类的标准设置对二分类换成binary:logistic并去掉num_class即可这就是常说的xgboost二分类模型配置。tree_methodhist是提升运行效率最划算的一个开关在几百条样本上几乎不损失精度训练速度快不少。max_depth4在这个规模上已经够深再深就开始背训练集了。reg_lambda1.0是L2正则和subsample、colsample_bytree一起约束模型复杂度。4.3 超参自动设置用RandomizedSearchCV把调参交给机器XGBoost虽然有正则项但具体参数值还是要试。我一般用RandomizedSearchCV做超参自动设置因为网格穷举太浪费算力from sklearn.model_selection import RandomizedSearchCV import xgboost as xgb param_grid { n_estimators: [200, 400, 600], max_depth: [3, 4, 6], learning_rate: [0.02, 0.05, 0.1], subsample: [0.7, 0.9, 1.0], colsample_bytree: [0.7, 0.9, 1.0], reg_lambda: [0.1, 1.0, 10.0], } base xgb.XGBClassifier(objectivemulti:softmax, num_class6, tree_methodhist, n_jobs-1, eval_metricmlogloss) search RandomizedSearchCV(base, param_grid, n_iter40, cv5, scoringaccuracy, random_state42, n_jobs-1) search.fit(X_train, y_train) print(search.best_params_) print(search.best_score_)n_iter40表示随机抽40组参数每组做5折交叉验证。不到200条样本时整个搜索十几分钟就能跑完完全不用GPU。调参玄学的成分很低关键是固定random_state保证可复现以及不要直接把搜索结果里的参数死记硬背到新数据集上——换一组录音分布就变了每次都应该让搜索重新跑一遍。4.4 保存模型、二分类扩展和事件检测方向训练完用joblib把最佳模型存盘部署时直接加载import joblib joblib.dump(search.best_estimator_, food_sound_xgb.joblib)如果后续只想判断“人在不在吃东西”把objective换成binary:logisticeval_metric换成logloss其余流程不变。在多语种语音识别场景里这个模型可以当作前置事件检测器先对音频流做滑窗每当窗口预测出六类中的任意一类且置信度超过阈值就标记为“进食事件”再交给下游语音系统决定是否忽略或记录。这套思路在语音识别、音频事件检测项目里都能直接嫁接。5. 食物声音辨物最容易翻车的5件事现象、原因和解决办法5.1 验证集准确率上下浮动超过5个点问题出在划分方式现象是同一份代码反复跑验证集准确率每次差5%以上看起来像在“碰运气”。真正原因往往是录音时把同一段长录音切成多条随机划分train/val时同一条音频的不同片段同时进了训练集和验证集模型等于提前“见过”了验证数据。解决方法是按录音批次做分组划分而不是按样本随机洗牌。我在代码里用GroupShuffleSplitgroup就是录音批次ID同一批录音要么全在训练侧要么全在验证侧。录制时每类至少分3批否则分组后验证集会太小。这也是数据一致性比样本量更重要的一处体现。5.2 CNN训练集99%验证集50%先缩模型再加增强现象是训练loss降到0.05以下验证集准确率却只有一半这是小数据集上最典型的过拟合。原因是每类25条样本对CNN太单薄模型把训练样本的细节当成了类别规律。解决顺序很有讲究第一步把增强参数调大并确认只作用于训练集第二步把第一层卷积通道从16降到8减少可学习参数第三步把weight_decay从1e-4提高到1e-3。这三步做完仍过拟合再考虑换更小的卷积核。小数据集上“缩模型”永远比“换结构”见效快这是攒下来的血泪经验。5.3 XGBoost卡在60%上不去加特征比调参更有效现象是max_depth试了好几组、学习率也换了档准确率纹丝不动。原因往往是统计特征把时间信息丢光了薯片的“咔”是一个短促瞬态被平均后只剩一个均值模型根本分不清“一个尖峰”和“一串平缓噪声”。解决方向是往特征里补时间结构。我会用librosa.feature.delta(mfcc)把MFCC的一阶差分拼进特征向量同时把谱平坦度flat保留好——它直接衡量信号接近白噪声的程度对“薯片vs胡萝卜”这种脆声分类帮助很大。还要打印混淆矩阵看具体是哪两类互混如果胡萝卜和薯片混说明高频保留还不够优先检查采样率和mel频带数而不是继续调参。5.4 换一台设备录音准确率暴跌这是域迁移现象是笔记本上训练能到95%换到手机录音推理只剩70%。原因是不同麦克风频响曲线和灵敏度不同训练分布和推理分布根本不一致再好的固定验证集也代表不了真实表现。解决方法是录制阶段就故意混入两台设备、两个房间的数据推理前给音频加50Hz高通滤波把空调和风声这类低频噪声压掉同时给音量做随机抖动增强。还有一个习惯值得坚持永远留出“最后一个月才录的样本”作为测试集模拟真实部署时的时间分布迁移。如果这类表现仍然差就不是调参能解决的得回头补数据。5.5 标签错得隐蔽先串听一遍再画t-SNE看一眼现象是loss降得很漂亮但某两类准确率诡异地互换比如胡萝卜的测试样本大量被预测成薯片而训练时单类召回都正常。原因多半是录音时目录放错位置或者切段时把上一段的尾巴切进了下一段开头。解决方法是训练前做一次“串听”检查随机把每条音频和标签对播一遍。这个过程花不了半小时但能躲掉最伤士气的翻车。再画一个t-SNE看特征空间正常情况同类抱团、异类分开如果某个类散到别的类中间先回去查标注不要急着调参。特征空间的可视化是这类小数据集项目最快的排错手段。6. 两个方案怎么选混淆矩阵做最终验证再决定部署哪条最终选择标准我只看一条测试集上混淆矩阵的对角占比和单类召回综合结果。两个模型都训好后分别统计每一个食物类别的召回率通常会发现XGBoost类间整体均衡CNN在“胡萝卜vs薯片”这类相似类别上更准但少数低频类比如面包掉点更明显。如果只能部署一个选“平均准确率与单类最低召回率乘积”更高的那个如果要做多点低资源部署XGBoost模型文件几十MB起步而CNN可以量化到几MB后者更合适。部署前最后跑一遍端到端推理模拟现场输入path test_data/apple_01.wav log_mel, stats extract_features(path) # CNN 路线补 batch 和通道两维 x_cnn torch.from_numpy(log_mel).unsqueeze(0).unsqueeze(0) probs torch.softmax(cnn_model(x_cnn), dim1) # XGBoost 路线 probs_xgb xgb_model.predict_proba(stats.reshape(1, -1))[0] print(CNN 预测:, classes[probs.argmax().item()]) print(XGBoost 预测:, classes[probs_xgb.argmax()])如果现场音频里混进说话声、碗筷碰撞声两条路线的输出会同时变差。这时重新用第5章的串听流程把那类样本筛出来重录比调模型参数更有效。我的个人习惯是每次接触新场景先跑XGBoost稳定突破90%之后再考虑把CNN接上做融合融合不做简单的概率平均而是把两个模型的预测概率拼成12维特征喂给一个逻辑回归——这个朴素的stacking通常还能再涨两个点。食物声音辨物这个方向难的不是模型而是数据和验证习惯把这两样理顺CNN和XGBoost都能成为趁手的工具。希望帮到你。本文还有配套的精品资源点击获取