· 1318 字 · 3 分钟阅读

婴儿哭声多模态分析:STFT + Mel 双通道 CNN


是什么

新生儿哭声是他们与世界沟通的唯一方式——饿了、困了、不舒服、需要换尿布……不同需求对应不同的哭声模式。这个项目的目标是:用深度学习自动识别婴儿哭声背后的原因,属于声音事件分类(Audio Event Classification)的子问题。

「多模态」指的是什么

项目名带「Multimodal(多模态)」三个字,但这里的”模态”并不是指音频 + 视频/文本之类的跨模态,而是指同一段音频被提取成两种不同的频谱表示,堆叠成 2 通道输入

原始 wav
  ├── STFT 频谱(线性频率轴)   → 通道 1
  └── Mel 频谱(感知频率轴)    → 通道 2
       ↓ np.stack(axis=0)
   shape: (2, 128, 128) 的张量 → 喂给 CNN

两种频谱各有侧重:

  • STFT 谱 在频率轴上是线性等距的,保留高频细节——婴儿哭声里那些尖锐的瞬态突发能被精确刻画
  • Mel 谱 按人耳对数感知做了 mel 滤波,低频分辨率更高——更贴近人类对”哭声听感”的判断

把两个谱当作 RGB 图像里的 R 和 G 通道堆起来,让 CNN 的第一层卷积核就能在两种视角间做跨通道融合。这是项目里最值得说的一个设计。

数据预处理流水线

原始 wav
  → librosa.load                       # 默认采样率
  → 加噪增强(可选)                    # np.random.randn(len(y)) * 0.005
  → pad / 截断 到 10 秒
  → STFT(n_fft=2048, hop=512, hann)   # → 1025 × T
  → amplitude_to_db                    # 转 dB
  → Mel(n_mels=128)                    # → 128 × T
  → resize 到 128 × 128
  → np.stack((S_db, S_db_mel), axis=0) # (2, 128, 128)

几个工程决定:

  • 统一长度 10 秒:不足的 pad,超过的截断。批训练必需的代价是丢掉长片段的尾部信息
  • resize 到 128×128:方形输入便于沿用图像分类的 CNN 结构(4 次 stride-2 pooling 后正好剩 8×8)
  • 只用高斯噪声做增强np.random.randn * 0.005,没有时间拉伸/移频/SpecAugment——这是后面”反思”里会回来吐槽的点

标签是用 sklearn.preprocessing.LabelEncoder 从文件夹名编码而来——数据按类别名组织在子文件夹里,目录遍历即标签。

模型结构

完整的 SoundModel

class SoundModel(nn.Module):
    def __init__(self, n_classes):
        super().__init__()
        # 4 个卷积块:Conv → BN → ReLU → MaxPool(2,2)
        self.conv1 = nn.Conv2d(2,   32,  3, 1, 1);  self.bn1 = nn.BatchNorm2d(32)
        self.conv2 = nn.Conv2d(32,  64,  3, 1, 1);  self.bn2 = nn.BatchNorm2d(64)
        self.conv3 = nn.Conv2d(64,  128, 3, 1, 1);  self.bn3 = nn.BatchNorm2d(128)
        self.conv4 = nn.Conv2d(128, 256, 3, 1, 1);  self.bn4 = nn.BatchNorm2d(256)
        self.pool    = nn.MaxPool2d(2, 2)
        self.dropout = nn.Dropout(0.5)

        # 3 个全连接层
        self.fc1 = nn.Linear(256 * 8 * 8, 512)
        self.fc2 = nn.Linear(512, 256)
        self.fc3 = nn.Linear(256, n_classes)

特征图尺寸变化(输入 2×128×128):

StageShape通道含义
Input2 × 128 × 128STFT + Mel
Conv1 + Pool32 × 64 × 64低层时频纹理
Conv2 + Pool64 × 32 × 32局部能量模式
Conv3 + Pool128 × 16 × 16段级声学特征
Conv4 + Pool256 × 8 × 8高层抽象
Flatten16384
FC1512
Dropout(0.5)512
FC2256
FC3n_classeslogits

几个细节:

  • 每层都有 BatchNorm——对频谱这种动态范围很大的输入很关键,能让训练稳定不少
  • Dropout 只放在 FC1 和 FC2 之间——卷积层靠 BN 抗过拟合,全连接层靠 Dropout
  • 没有 GlobalAvgPool——直接 Flatten 256·8·8 = 16384,FC1 的参数量是大头(8.4M),整个模型主要的参数都在这里

训练配置

optimizer = Adam(lr=1e-5, weight_decay=1e-5)
scheduler = CosineAnnealingLR(optimizer, T_max=num_epochs, eta_min=0)
criterion = CrossEntropyLoss()

batch_size      = 32
train/val split = 80/20(随机切)
early_stop      = 验证 loss 连续 5 epoch 不降就停

几个值得注意的选择:

  • lr=1e-5 偏保守:通常从头训练的 CNN 会用 1e-3,这里小两个量级。猜测是数据量小、不想冲飞,但代价是收敛慢
  • CosineAnnealingLR + 早停 patience=5:余弦退火会让 lr 在末期降到 0 附近,此时验证 loss 自然会震荡——patience 太小有可能在 lr 衰减阶段被误触发,调参时要留意
  • train/val 随机切:音频数据集如果同一段录音被切成多个 10 秒片段,随机切可能让同源片段同时落到训练集和验证集,造成指标虚高。生产环境要改成按”录音 ID”分组切

训练完会输出三张曲线(loss / accuracy / lr)和一张混淆矩阵热力图。保存的产物:

  • best_urbansound8k_model.pth —— 验证最优权重
  • urbansound8k_model_final.pth —— 训练结束时权重
  • label_encoder.pkl —— 类别编码器(推理时要加载)

推理

test.py 的逻辑很直接:加载权重 + label_encoder,把新音频走同一套预处理流水线(STFT + Mel → 2 通道 128×128),forward 后 argmax,再用 label_encoder 反查类别名。

只要预处理函数和训练时一致,推理就只是一次前向。

反思

回头看,能改进的地方不少:

模型层面

  • 4 层 CNN + 3 层 FC 已经是 2015 年左右的架构思路。今天会优先试 wav2vec 2.0 / HuBERT 这类自监督预训练特征做编码器,下游接一个小分类头——小数据集上的表达能力差别会很大
  • 即便坚持 CNN 路线,也应该上 CRNN(CNN+BiGRU) 或加 attention pooling,让模型显式建模时间维度,而不是靠 16384 维的 flatten 硬记

数据增强

  • 只加噪声太单薄。现成的可选项很多:SpecAugment(时间/频率轴 mask)、MixUp时域伸缩 + 移频(用 librosa.effects 即可)、RIR 卷积模拟房间混响
  • 对婴儿哭声这种小数据集场景,增强往往比换模型收益更大

评估

  • 只看 accuracy 和混淆矩阵不够。婴儿哭声分类的类别天然不均衡(“饿”远多于”疼痛”),应该看 per-class F1 / macro F1,必要时做类别加权采样

真实场景的鸿沟

  • 公开数据集(录音棚 / 近距离麦克风 / 单一婴儿)和家庭实采(远场、电视背景音、多人对话)之间差距非常大
  • 想真正落地一个”哭声助手”,域适配和鲁棒性才是真正的难题,模型选型反而是相对小的问题

后话

这实际上是我本科毕业的时候拿到的课题,也是第一次做信号处理相关的,确实在其中也学到了不少东西,回想起来那几个月泡在实验室里面的时光。又回看起这个仓库,历历往事仿佛就在眼前。虽说那个时候还有些稚嫩。已经毕业快一年了啊。。。

github.com/WizardHeHeJun/Multimodal-analysis-of-infant-crying

💬 留下你的想法~

用 GitHub 账号登录,留个表情或写两句都好——「悄悄告诉你哦,我每条都会看的呢」