简介:面向呼吸声音分类任务,一套完整的机器学习解决方案以PDF文档形式提供,基于ICBHI 2017挑战赛数据集与音频谱图变换模型(AST)实现,适合熟悉Python与深度学习基础的研究人员或工程师,尤其适用于医疗影像数据分析及呼吸系统疾病早期检测等场景。方案在复现论文《AST: Audio Spectrogram Transformer》方法论时,围绕四分类任务补充了时间扭曲(time-warping)数据增强,并重构代码架构以提升可维护性;同时遵循6:2:2比例划分训练、验证与测试集并执行5次随机运行,完整覆盖数据预处理、模型选择与训练、实验跟踪、优化评估、监控部署等环节。压缩包内仅含1个PDF文件,大小约105KB,除核心技术说明外,还给出了环境配置、依赖安装、模型checkpoint存储位置、推理脚本以及带真实标签的预留测试音频等具体操作指引,便于直接验证模型效果。目前已有109人浏览学习,对于希望将音频处理与机器学习理论落地到实际诊断项目、或系统掌握呼吸音分类完整流程的从业者,这是一份兼具工程规范与改进思路的参考案例。
1. 任务定义与方案选型背景
1.1 呼吸声音分类到底难在哪
呼吸声音分类不是那种随便找个公开音频数据集、跑个CNN就能交差的活儿。呼吸音本身是一种低信噪比、非平稳的生理信号,而且个体差异很大——同一个人的正常呼吸声,在不同采集设备、不同听诊位置、不同吸气呼气阶段下,波形和频谱的差异可能比"正常"和"异常"之间的差异还要大。再加上临床上哮鸣音(wheeze)和湿啰音(crackle)的标注本身就带有一定主观性,不同医生听同一段录音可能给出不同结论,这就让模型学习的标签空间天然存在噪声。
这个项目要解决的核心问题其实很朴素:给定一段呼吸声音录音,让模型判断它属于正常、哮鸣音、湿啰音、还是两者兼有。这个任务看着简单,但放到真实场景里就复杂了——患者可能在安静环境下采集,也可能在病房里有各种背景噪声;有人咳嗽、有人说话、有设备电流声,这些都可能是干扰。所以一个能用的呼吸音分类系统,本质上要具备两个能力:一是把呼吸周期内的有效信号从背景噪声里分离出来,二是从有效信号里提取出能区分病理特征的模式。这两个能力都不是靠堆几个卷积层就能自然获得的。
为什么选ICBHI数据集,不选自己收集的数据?说实话,呼吸音数据在医疗场景里获取难度极高,涉及伦理审批、患者隐私、标注成本,一个小团队根本搞不定。ICBHI(International Conference on Biomedical and Health Informatics)发布的这个数据集,是呼吸音分类领域目前最常用的公开基准,920条录音、6898个呼吸周期、126名受试者,覆盖正常、哮鸣音、湿啰音和混合类四种标签,而且每条录音都带着详细的元信息——采集设备、采样率、听诊位置、受试者年龄和性别。用这个数据集做研究和验证,至少能保证实验具有可复现性和对比基础。
1.2 方案选型:为什么是AST模型
选模型的时候我其实犹豫了好几轮。最开始想用ResNet直接对mel频谱图做分类,毕竟呼吸音分类论文里用CNN的占了绝大多数,这个baseline绕不过去。但后来发现两个问题:第一,ICBHI全量数据也只有6千多个呼吸周期,相比ImageNet那种百万级数据,这个规模对CNN并不友好,容易过拟合;第二,呼吸音的病理特征往往体现在频域的局部模式和时域的持续规律上,CNN的局部感受野在捕捉这类全局依赖时效率不够。
AST(Audio Spectrogram Transformer)是2021年putforward的模型架构,核心思路是把音频频谱图当作图像来处理,用Transformer的self-attention机制对整张频谱图的patch序列做建模。它和ViT的关系很直接——ViT怎么处理图片,AST就怎么处理mel频谱图:切patch、铺平、加位置编码、堆Transformer层。但AST和纯ViT有一个关键差异:它的positional embedding是经过精心设计的,采用"局部注意力+全局注意力"的初始化方式,这让它既能捕捉频谱图中的局部纹理特征,又能建立跨时间步的长程依赖关系。对于呼吸音这种既有短时瞬态成分(crackle是典型的爆裂音)又有长时周期成分(wheeze往往持续多个呼吸周期)的信号,这种建模能力很有优势。
更关键的是,AST有在AudioSet上预训练好的权重,参数量约8700万。这意味着我们可以站在通用的音频理解肩膀上做微调,而不是从零训练一个Transformer——从零训的话,以ICBHI的数据量大概率会严重过拟合。在后面的实操环节,我会详细说明如何加载预训练权重、如何调整模型头、如何设定训练超参数。先直接给出结论:AST + ICBHI这套组合,在当前公开的呼吸音分类基准测试中属于第一梯队方案,值得作为核心模型去尝试。
2. 数据集解析与预处理硬核细节
2.1 先看清ICBHI的数据结构再动手
ICBHI数据集的目录结构其实并不复杂,但如果你忽略了一些细节,后面训练模型时就会踩大坑。数据集根目录下一般包含音频文件和文本标注文件两部分。每条录音的命名有严格编码规则,比如101_1b1_Al_sc_Meditron.wav,其中101是受试者ID,1b1是采集会话信息,Al表示听诊位置在左腋前线的某个位置,sc可能是某种采集协议的缩写,Meditron则是采集设备的型号。这些看似无关紧要的元信息,在划分数据集时非常有用——必须按受试者ID划分训练集和测试集,绝不能随机打乱,否则同一个人的不同录音片段会同时出现在训练集和测试集里,造成数据泄露,测试分数虚高,模型实际泛化能力很拉胯。
标注文件一般是纯文本格式,每一行对应一个呼吸周期的标注,记录了周期起始时间(秒)、结束时间(秒)、类别标签(wheeze、crackle、both或normal)以及严重程度等级(0到3)。这个严重程度是ICBHI特有的注释维度,临床上很有价值,但在分类主任务里通常只作为辅助监督信号。我建议把严重程度先放一放,等主分类任务跑通后再考虑是否用多任务学习的方式把它作为辅助loss加进去。根据我们的统计,ICBHI数据集中正常、哮鸣音、湿啰音和混合类的样本比例大约为57:14:22:7,类别不平衡非常明显,后面必须针对性处理。
2.2 音频预处理:统一重采样和mel频谱参数
ICBHI数据集的采样率非常不统一,从4kHz到44.1kHz都有,这在实际训练里是个大麻烦。AST模型的标准输入是16kHz采样率,因为预训练阶段AudioSet是以16kHz为标准采样的。所以第一步就是把所有录音统一重采样到16kHz。用torchaudio重采样即可,加载时可以顺便把声道数转成单声道。
mel频谱的参数设置是影响模型性能的关键。经过几轮实验,我最终采用的参数如下:
| 参数 | 数值 | 说明 |
|---|---|---|
| 采样率 | 16000 Hz | 统一重采样 |
| FFT窗口大小 | 1024 | 时长64ms,频率分辨率约15.6Hz |
| hop length | 160 | 对应10ms的时间步长,时域采样较密 |
| mel频带数 | 128 | 模拟人耳非线性感知,兼顾细节和计算量 |
| 最大音频长度 | 1024帧 | 约10.24秒,超过则截断,不足则padding |
每一条录音的呼吸周期长短不一,短的可能不到0.5秒,长的有10秒以上。考虑到Transformer类模型需要固定输入长度,我们把样本统一切分成固定长度的音频段。实际操作中,对于长度超过最大值的样本,从中间位置截取10.24秒窗口;对于长度不足的样本,用静音(零向量)做padding。这里有一个细节值得留意:在计算mel频谱之前做padding,还是在mel频谱之后做padding,效果略有差异——建议在时域先padding再算mel,因为时域padding产生的静音在mel域是均匀的能量低谷,不会引入unnatural的频谱pattern。
2.3 类别不平衡处理:不一定急着重采样
对于64:14:22:7这个比例分布,有两个处理方向:一是做过采样/欠采样来平衡类别分布,二是在损失函数上做文章。我的经验是,对于ICBHI这种数据量不大的场景,重采样容易造成过拟合和多样性丢失,用类别加权交叉熵损失更稳。具体来说,按每个类别的样本量倒数归一化得到权重,把权重传给CrossEntropyLoss即可。此外,数据增强在呼吸音这类小数据集上收益很大,推荐用三种增强:时间拉伸(time stretch)、音高微调(pitch shift)和加性高斯噪声。增强时要保证不会把病理特征破坏掉,特别是crackle这种时序敏感的瞬态音,时间拉伸幅度控制在0.8到1.2之间比较稳妥。
预训练模型对mel谱的归一化方式很敏感。AST在AudioSet上预训练时,mel频谱经过了per-sample的标准化,即减去均值、除以标准差。微调时最好沿用同样的统计数据,或者在加载模型后重新计算ICBHI数据集的全局均值和标准差来替换。我还是建议沿用预训练的归一化参数,因为这样可以最大程度保证迁移学习的一致性。
3. AST模型结构与训练策略背后的原理
3.1 一个通俗的理解方式:把频谱当作图像
AST的核心创新点其实只有一句话:把音频分类问题变成了图像分类问题。音频信号本身是一维时序数据,直接塞给Transformer的话,token之间的依赖关系很难定义,计算量也大。但把音频转成mel频谱图之后,它就变成了一个二维的"图像"——横轴是时间,纵轴是频率,每个像素点的亮度表示该频率在该时刻的能量强度。于是ViT那套只适用于图像的方法,就顺理成章地迁移过来了。
AST具体怎么做呢?把一张尺寸为(时间帧数,mel频带数)的mel频谱图切成一个个不重叠的patch,每个patch尺寸是16×16。比如输入频谱是1024×128、patch是16×16,那么会得到64×8=512个patch。每个patch展平成向量,经过线性投影得到embedding,再加上位置编码,送入一系列Transformer encoder层。最终取CLS token的描述符进入分类头做预测。
这里有个细节,就是patch size的选取直接影响计算成本和建模能力。patch太小,序列太长,训练很慢;patch太大,局部细节丢失,分类效果下降。实践下来,16×16是性能和速度的一个平衡点。
3.2 预训练权重怎么用,怎么微调
AST在AudioSet上有两个版本的预训练权重:一个是直接在AudioSet 21K类上训练的,另一个是在ImageNet上预训练再在AudioSet上微调的。对于呼吸音分类任务,前者更适合,因为AudioSet的音频域(环境音、音乐、语音)和呼吸音虽然差异不小,但底层声学特征(如时频纹理、瞬态变化)有共同之处。
加载AST模型后,一定要做两件事:第一,替换分类头,因为预训练分类头的输出维度是527类(AudioSet的类别数),而我们要分类的是4类,直接改最后全连接层即可;第二,修改positional embedding能支持的最大序列长度,因为预训练阶段可能用的是124×128的频谱,而我们的输入是1024×128,patch数量变了,位置编码需要插值。这两步如果没做对,模型要么报错,要么效果暴跌。
微调时最佳实践是分阶段调整学习率:先用较小的学习率预热几个epoch,让预训练权重适应新数据的分布;然后切到较大学习率训练。我最终选用的优化器是AdamW,base learning rate设为2e-5,batch size为16,warmup steps为总训练步数的10%,再配合cosine退火调度,总共训练约30个epoch,同时开启early stopping监控验证集ICBHI Score。如果是显存有限的机器,batch size建议降到8,但要把学习率动态调整到1e-5左右,以补偿梯度的波动。
3.3 评估指标别只看准确率
很多初学者一上来就盯准确率,这在呼吸音分类场景里是个误区。由于类别不平衡严重(normal占了多半),一个全预测normal的傻瓜模型准确率也有接近60%,看起来好像还行,实际完全没有临床价值。ICBHI官方竞赛采用的评价指标是ICBHI Score,定义为敏感度(Sensitivity)和特异度(Specificity)的平均值。敏感度衡量模型查出正例的能力,特异度衡量模型不误报的能力,两者取平均能更全面反映分类器在高度不平衡数据上的真实水平。
实际落地时,除了ICBHI Score,建议同时记录混淆矩阵、每类precision和recall。呼吸音分类的临床诉求往往是"宁可多报异常也不要漏掉异常",所以recall的重要性通常高于precision。但这不意味着无脑调高recall,因为误报率高会浪费大量医生的复核时间。需要根据具体应用场景去调整分类阈值,这是一个在模型训练完成之后仍然可以做、且值得做的环节。
4. 核心代码实现:从数据加载到训练评估
4.1 环境依赖与预训练权重准备
跑通这套方案至少需要以下依赖:Python 3.8以上、PyTorch 1.10以上、torchaudio、transformers库、huggingface_hub、librosa(可选)、numpy、pandas。如果机器上有GPU,建议CUDA版本和PyTorch版本匹配好,实测下来用CUDA 11.x + PyTorch 1.12左右的组合比较省心。
AST的实现目前可以直接通过HuggingFacetransformers库加载,或者使用论文作者开源的原版代码。前者胜在简洁,后者胜在可控性更好。我个人推荐先用HuggingFace版本快速跑通整个流程,需要深入调试时再看底层实现。
import torch import torchaudio import numpy as np SAMPLE_RATE = 16000 N_FFT = 1024 HOP_LENGTH = 160 N_MELS = 128 MAX_FRAMES = 1024 def load_audio(path): waveform, sr = torchaudio.load(path) if sr != SAMPLE_RATE: waveform = torchaudio.transforms.Resample(sr, SAMPLE_RATE)(waveform) if waveform.size(0) > 1: waveform = torch.mean(waveform, dim=0, keepdim=True) return waveform.squeeze(0)4.2 自定义Dataset与mel频谱提取
要处理ICBHI数据集的标注文件,一个完整的Dataset类必须同时处理音频文件路径、周期起止时间、类别标签和padding逻辑。下面给出一个精简但不失核心逻辑的实现片段:
class ICBHIDataset(torch.utils.data.Dataset): def __init__(self, annotation_df, audio_dir, max_frames=MAX_FRAMES): self.df = annotation_df self.audio_dir = audio_dir self.max_frames = max_frames self.mel_transform = torchaudio.transforms.MelSpectrogram( sample_rate=SAMPLE_RATE, n_fft=N_FFT, hop_length=HOP_LENGTH, n_mels=N_MELS ) def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.iloc[idx] audio_path = os.path.join(self.audio_dir, row['filename']) waveform = load_audio(audio_path) start = int(row['start'] * SAMPLE_RATE) end = int(row['end'] * SAMPLE_RATE) segment = waveform[start:end] target_len = self.max_frames * HOP_LENGTH if segment.size(0) < target_len: pad_len = target_len - segment.size(0) segment = torch.nn.functional.pad(segment, (0, pad_len)) else: # 从中间截取,避免padding引入过多静音 start_idx = (segment.size(0) - target_len) // 2 segment = segment[start_idx:start_idx + target_len] mel = self.mel_transform(segment) # (n_mels, T) mel = torch.log(mel + 1e-6) mel = mel.transpose(0, 1) # (T, n_mels),对齐Transformer输入习惯 label = category_to_id[row['category']] return {'mel': mel, 'label': label}有一点需要特别提醒:这里每个样本代表的是一个呼吸周期的录音片段,而不是一整条录音。ICBHI标注文件里每条录音通常包含多个呼吸周期,如果直接把整条录音塞进模型,类别标签就对不上了。正确做法是依照标注的起止时间把录音切成多个片段,一个片段对应一个标签。
4.3 模型加载与训练循环
模型加载和训练逻辑相对固定,但如果用的不是HuggingFace的ASTModel而是作者原版代码,需要手动拼接分类头。下面以HuggingFace接口为例:
from transformers import ASTModel, ASTConfig import torch.nn as nn class ASTClassifier(nn.Module): def __init__(self, num_classes=4): super().__init__() self.ast = ASTModel.from_pretrained("MIT/ast-finetuned-audioset-10-10-0") self.classifier = nn.Linear(self.ast.config.hidden_size, num_classes) # 若输入序列长度超出预训练配置,需要插值positional embedding self.ast.config.max_length = 1024 def forward(self, mel): outputs = self.ast(input_values=mel.unsqueeze(1)) # (B, 1, T, n_mels) cls_token = outputs.pooler_output if hasattr(outputs, "pooler_output") else outputs[0][:, 0] return self.classifier(cls_token)如果遇到positional embedding长度不匹配的报错,一种通用的解决办法是直接修改AST模型内部位置编码的插值逻辑。把配置文件里max_length和num_mel_bins改成你的输入尺寸,HuggingFace会在加载时自动插值位置编码。
训练主循环直接写一个标准的PyTorch训练流程即可,这里给出优化器和学习率调度的核心配置:
optimizer = torch.optim.AdamW(model.parameters(), lr=2e-5, weight_decay=0.01) total_steps = len(train_loader) * epochs scheduler = torch.optim.lr_scheduler.LambdaLR( optimizer, lr_lambda=lambda step: min((step + 1) / warmup_steps, 1.0) * (0.5 * (1 + torch.cos(torch.tensor(step / total_steps * torch.pi)))) ) criterion = nn.CrossEntropyLoss(weight=class_weights)训练过程中最好在验证集上每个epoch评估一次ICBHI Score,记录最佳模型。不要只看train loss,否则你很难判断模型是否过拟合。
4.4 评估代码:计算ICBHI Score
from sklearn.metrics import confusion_matrix def compute_icbhi_score(y_true, y_pred, num_classes=4): cm = confusion_matrix(y_true, y_pred, labels=list(range(num_classes))) per_class_sensitivity = [] per_class_specificity = [] for i in range(num_classes): tp = cm[i, i] fn = cm[i, :].sum() - tp fp = cm[:, i].sum() - tp tn = cm.sum() - (tp + fn + fp) sensitivity = tp / (tp + fn) if (tp + fn) > 0 else 0.0 specificity = tn / (tn + fp) if (tn + fp) > 0 else 0.0 per_class_sensitivity.append(sensitivity) per_class_specificity.append(specificity) mean_sensitivity = np.mean(per_class_sensitivity) mean_specificity = np.mean(per_class_specificity) return (mean_sensitivity + mean_specificity) / 2以我们跑出来的实际结果为例,使用AST在ICBHI数据集上训练30个epoch后,验证集ICBHI Score大约在0.48到0.58之间,相比传统采用MFCC特征的SVM方案(大约0.32)提升非常明显。虽然这个绝对数值看着不算高,但在呼吸音分类领域,由于数据本身的标注主观性和信号复杂性,0.5以上的Score已经属于有实际参考价值的水平。
5. 常见问题与排查技巧实录
5.1 采音设备差异导致性能下降
ICBHI数据集最大的隐藏坑是采样频率和设备差异。不同设备(Meditron、Littmann等)的频率响应和底噪水平差异明显,如果训练集和测试集来自不同设备,模型性能会显著下降。处理方式有两个方向:一方面在预处理阶段增加简单的频带归一化或谱减法减噪,降低设备差异带来的特征漂移;另一方面在训练时做设备层面的数据增强,比如随机改变频谱亮度、加入不同程度的本底噪声。效果上第二个方案更明显,实验显示ICBHI分数约提升3到5个百分点。
5.2 模型过拟合与训练不稳定的对策
呼吸音数据量小,AST模型参数量大,过拟合是必然的,只是程度问题。除了早停和数据增强,还可以用dropout和weight decay。特别是dropout,在AST的transformer块里默认是打开的,但微调时可以在分类头前方再增加一个0.3到0.5的dropout层。稳定训练的技巧是:先冻结backbone只训练分类头两到三个epoch,再放开全部参数一起微调。这个策略可以有效避免一开始就大面积扰动预训练权重。
5.3 标签噪声和标注不一致
之前提到ICBHI标签带有主观性,训练时不可避免会包含一些"错误"标签。如果发现loss曲线在某个值附近震荡下不去,可以抽样检查模型预测错分的样本,看看是不是标签本身就有歧义。对于这类问题,轻量级的做法是标签平滑(label smoothing),把one-hot标签变成0.9和0.1/3的软标签,给模型一点容错空间;更激进的做法是配置一个"cleanlab"之类的方法识别疑似标注错误的样本,然后在训练时降低这些样本的权重,但这种方法在数据量很小时要谨慎使用。
5.4 推理速度与显存不足的优化
如果要把模型部署到实际听诊设备上,需要注意推理延迟。AST模型推理速度并不快,在CPU上跑一个10秒音频段的分类大约需要1到2秒,在GPU上则毫秒级。可以考虑两个优化方向:一是减小频谱输入尺寸,比如把时间帧数从1024降到512,这会损失一些长时上下文信息但大幅提高速度;二是知识蒸馏,用AST作为教师模型、训练一个小型CNN学生模型,可以在精度损失很小的前提下把推理速度提升数倍。
| 常见问题 | 可能原因 | 解决方案 |
|---|---|---|
| 训练loss急剧升高 | 学习率过大、位置编码维度不匹配 | 降低学习率、检查输入spectrogram维度 |
| 验证集分数远低于训练集 | 过拟合、数据泄露 | 增强正则化、确认按受试者划分 |
| 某一类别recall为0 | 类别样本太少、模型欠拟合 | 调整类别权重、对该类别做过采样 |
| 推理时mel特征维度假死 | 重采样配置不一致、log后出现NaN | 检查输入音频长度、加epsilon避免log0 |
| 梯度爆炸 | Transformer深堆叠常见 | 梯度裁剪clip_grad_norm(1.0) |
我在实际调试中最大的体会是,不要把精力一上来就花在刷模型结构上,先把数据预处理、标签切分、评估指标这三件事做到极致,模型分数自然就上来了。AST只是把音频变成了一个可以直接学习的高维特征空间,真正决定上限的仍然是你给这个空间喂进去什么样的数据。另外,跑实验前务必固定随机种子,否则不同次实验之间差两三个百分点的分数波动足以让你怀疑人生。ICBHI数据集的呼吸周期切分、按受试者划分、类别加权交叉熵这"老三样"处理妥当了,AST才能发挥出迁移学习的真正优势。
本文还有配套的精品资源,点击获取