
简介基于ResNet与AVEC2014数据集构建的抑郁症智能诊断系统Python实现定位为计算机与人工智能方向的课程设计、综合实践项目。内容覆盖数据预处理、多模态行为特征提取、模型训练、验证与测试的完整链路代码采用模块化组织便于复现和二次调参。压缩包共15个文件以9个Python脚本为主体涵盖数据加载、网络定义、训练与评估等环节另附README说明文档及配置备份整体仅11KB结构紧凑、便于快速上手。目前已有101人学习使用。借助这套实现可掌握深度残差网络在临床辅助诊断中的落地方法理解语音、面部表情与文本语义特征的跨模态融合思路并获得一套可直接扩展的工程骨架适合希望将深度学习理论用于实际课题的本科高年级学生或研究人员。1. 不是玄学ResNet 做抑郁症诊断到底在学什么用 ResNet 做 AVEC2014 抑郁症诊断第一反应很多人会觉得是“玄学”——一堆人脸视频帧或语音跑进卷积网络出来一个分数凭什么说这是抑郁程度其实 AVEC2014 把任务定义得非常工程化给你受访者的音视频数据让你预测 PHQ-8 量表分数0 到 24越大越抑郁。模型不需要“懂心理”只需要从信号里找出和 PHQ-8 相关的模式这本质上是一个回归任务跟预测房价、空气质量没有区别。这套项目最值得复现的地方在于它把医学诊断、心理学量表、计算机视觉三件事压进一条 Python 流水线数据切分、预训练权重、回归头、评估指标每个环节都有明确的坑。对做 Python 期末大作业或简历项目的同学来说它既有 ResNet 视觉模型又有真实数据集还能讲出“医疗 AI 落地”的故事。对熟手来说AVEC2014 的数据格式、CLNF 特征、CCC 指标这些细节比模型本身更需要留意。这篇笔记就按“数据怎么变成 Tensor → ResNet 怎么改 → 训练怎么调 → 哪里会翻车”的顺序把整套复现路径拆开。2. AVEC2014 数据拆解从原始音视频到可训练的样本AVEC2014 官方提供的是特征文件而不是整段的原始视频或音频。但要用 ResNet我们通常会把原始音频转成语谱图让模型吃掉图像或者从视频里抽帧用 ResNet 提取高维特征。下面这套流程是常见做法也是我实际复现时走的路线。2.1 拿到手先看目录train/dev/test 和标签文件长什么样AVEC2014 的数据包解压后常见结构是三个子任务目录audio、video、text还有对应的train、dev、test划分。每个被试participant有一个唯一的 ID比如301、302对应的标签文件里是 PHQ-8 分数。我一般先写一段脚本把所有样本和标签列出来确认没有缺失。import os import pandas as pd data_root AVEC2014/ label_path os.path.join(data_root, labels, train_label.csv) label_df pd.read_csv(label_path) print(label_df.head()) print(样本数, len(label_df))运行后能看到类似下面的输出Participant_ID PHQ8 0 301 7 1 302 6 2 303 14这段代码只做一件事用 pandas 读取标签文件确认列名和数据量。Participant_ID是后续所有音频文件命名的依据PHQ8是回归目标。注意 AVEC2014 官方给的标签文件在历史版本里叫法可能不一样有的是表头大小写不同有的藏在子目录里第一步先把路径理清楚后面才不会反复报 FileNotFoundError。参数说明data_root指向你解压后的根目录label_path按实际文件名改比如train_split.csv。如果没有现成的 CSV很多版本的数据包给的是.txt或.mat用pd.read_csv(sep\t)或scipy.io.loadmat读取即可。2.2 把音频转成语谱图让 ResNet 能“看”一维信号ResNet 是二维卷积网络吃的是图像。音频是一维时间序列直接塞进去不行。最常见的做法是将每段录音切成若干个 3 秒切片用librosa转成 mel 语谱图保存为浮点数组再在训练时当作单通道图像输入。import librosa import numpy as np def audio_to_mel(wav_path, save_path, sr16000, n_mels64, duration3.0): y, sr librosa.load(wav_path, srsr, durationduration) # 固定长度不足则补零超过则截断 target_len int(sr * duration) if len(y) target_len: y np.pad(y, (0, target_len - len(y))) else: y y[:target_len] mel librosa.feature.melspectrogram(yy, srsr, n_melsn_mels, fmax8000) log_mel librosa.power_to_db(mel) # 转成 dB 单位 # 标准化到 0~1方便 CNN 收敛 log_mel (log_mel - log_mel.min()) / (log_mel.max() - log_mel.min() 1e-8) np.save(save_path, log_mel.astype(np.float32)) return log_mel.shape逻辑说明先加载音频并统一采样率到 16kHz因为语音信号的主要能量集中在 4kHz 以下16kHz 足够。duration3.0控制每个切片的时长太长会导致一个样本包含太多静音太短又丢失上下文。melspectrogram把波形转成 64 个 mel 频带power_to_db是近似人耳听觉的响度变换最后归一化到 [0,1] 区间方便 ResNet 的 BatchNorm 处理。参数说明n_mels64是语谱图的高度宽度由时长决定3 秒音频在 hop_length512 时大约是 173 帧所以输入张量维度是 (64, 173)。你可以把n_mels调到 128 增加频率分辨率但显存占用和计算量会跟着涨。fmax8000是 mel 滤波器的最高频率语音任务没必要开到 16000。2.3 标签标准化把 PHQ-8 分数映射到适合回归的范围PHQ-8 分数的范围是 0 到 24直接作为回归目标也能训练但模型输出层是个线性单元预测值可能跑到负数或 30 以上评估时很难受。标准做法是做均值方差归一化或者映射到 [0,1]。我一般用朴素的最小最大归一化因为后期画图、解释分数都方便。def normalize_labels(series): min_val series.min() max_val series.max() norm (series - min_val) / (max_val - min_val) return norm, min_val, max_val norm_labels, min_v, max_v normalize_labels(label_df[PHQ8]) label_df[PHQ8_norm] norm_labels print(min:, min_v, max:, max_v)逻辑说明归一化公式是 (x - min) / (max - min)做完后标签落在 [0,1]。训练时模型输出一个浮点数评测时再反归一化还原成 PHQ-8 分数。参数说明min_val和max_val必须来自训练集不能用整个数据集的统计值否则验证集的信息会泄露到训练中。很多人在这里图省事直接把全部数据归一化最后验证指标虚高下面避坑章会再展开。3. ResNet 模型搭建与训练改最后一层然后让损失收敛ResNet 在 AVEC2014 里不是拿来分类而是做回归。架构上只需要把最后的 1000 分类全连接层换成 1 个输出节点。但真正影响结果的是损失函数、评估指标和训练策略。3.1 改动模型用预训练 ResNet18 还是 ResNet50对于音视频特征ResNet18 足够因为语谱图的模式相对简单ResNet50 容易过拟合尤其是在 AVEC2014 这种样本量只有百来个人级别的竞赛数据上。下面这段代码修改torchvision的预训练模型。import torch import torch.nn as nn from torchvision import models def build_resnet_regressor(archresnet18, freeze_backboneFalse): if arch resnet18: model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) elif arch resnet50: model models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V2) else: raise ValueError(Unknown arch) in_features model.fc.in_features model.fc nn.Sequential( nn.Dropout(0.5), nn.Linear(in_features, 1) ) if freeze_backbone: for name, param in model.named_parameters(): if fc not in name: # 只冻结除了 fc 之外的部分 param.requires_grad False return model model build_resnet_regressor(resnet18, freeze_backboneFalse) print(model)逻辑说明model.fc.in_features是 ResNet 全局池化后输出的特征维度ResNet18 是 512ResNet50 是 2048。把fc替换成 Dropout 线性层输出维度为 1这样模型就从一个分类器变成了回归器。freeze_backboneTrue时除fc外的所有参数不更新只训练最后一层适合数据量很小的情况。参数说明Dropout(0.5)是防止最后一层过拟合比例可以调成 0.3 或 0.7。如果你要做特征融合比如把 CLNF 人工特征拼进来可以把Linear的输入维度改为in_features extra_dim后面进阶章会给出例子。3.2 训练循环回归任务用 MSE验证用 CCC分类任务用 CrossEntropy回归任务最自然的是 MSE但 AVEC2014 官方的评估指标是 CCCConcordance Correlation Coefficient一致性相关系数它同时度量相关性和绝对误差。训练时用 MSE 便于梯度下降验证和早停用 CCC这样两者互补。import torch.nn.functional as F def train_one_epoch(model, dataloader, optimizer, device): model.train() total_loss 0 for x, y in dataloader: x, y x.to(device), y.to(device) optimizer.zero_grad() pred model(x).squeeze(1) # (B,) 回归值 loss F.mse_loss(pred, y) loss.backward() optimizer.step() total_loss loss.item() * x.size(0) return total_loss / len(dataloader.dataset)逻辑说明每次迭代取一个 batch前向得到预测分数和真实标签做 MSE反向传播更新权重。.squeeze(1)是为了把(B, 1)张量压缩成(B,)让pred和y形状一致否则 MSE 会按矩阵广播计算得到错误的结果。参数说明optimizer我一般用 Adam初始学习率 1e-4。dataloader的 batch size 建议 16 或 32太大容易过拟合太小会导致 BatchNorm 不稳定这一点在避坑章会重点讲。3.3 评估指标MAE、RMSE、CCC 哪个才是裁判AVEC2014 的排行榜主要看 CCC。CCC 的公式同时包含相关系数 ρ 和绝对差部分当预测均值与真实均值偏移时CCC 会显著下降所以它比 Pearson 相关系数更严格。下面是计算 CCC 的 NumPy 实现。def concordance_cc(y_true, y_pred): mean_true np.mean(y_true) mean_pred np.mean(y_pred) v_true np.var(y_true, ddof1) v_pred np.var(y_pred, ddof1) cov np.cov(y_true, y_pred)[0, 1] rho_c 2 * cov / (v_true v_pred (mean_true - mean_pred) ** 2) return rho_c逻辑说明分子是 2 倍协方差分母是两组方差之和加均值差的平方。这个公式的妙处在于如果预测值整体比真实值高 2 分即使趋势完全一致CCC 也会扣分。所以训练回归头时要尽量保证预测分布的均值和真实分布接近必要的时候在验证集上做一个偏差校正把预测值减掉平均误差。参数说明ddof1表示样本方差对应无偏估计。实际评测时还会同时看 MAE 和 RMSE但最终决定排名的是 CCC。我见过一些人只盯着 RMSE 调参调了半天 RMSE 降了CCC 却不变甚至更差原因就是 MAE/RMSE 对绝对误差敏感而 CCC 还要求方差匹配。4. AVEC2014 训练避坑五个让我翻车的细节这部分是我复现时真实踩过的痕迹。每条都是“现象 → 原因 → 解决”的格式排查顺序就是踩坑顺序。4.1 验证集 CCC 虚高测试集直接崩现象训练时验证集 CCC 稳定在 0.6 以上换成官方 dev 集立刻掉到 0.2。原因我把同一说话人的多个切片随机放入 train 和 val导致同一个人的不同语谱图同时出现在两个集合里。ResNet 记住了人的声纹而不是抑郁模式。AVEC2014 的样本本质上是“每人多条录音”标准做法按参与人 ID 划分不能按文件划分。解决按Participant_ID分组切分确保一个 ID 的数据要么全在 train要么全在 val。可以用sklearn.model_selection.GroupKFold实现分组交叉验证。from sklearn.model_selection import GroupKFold gkf GroupKFold(n_splits5) for train_idx, val_idx in gkf.split(features, labels, groupsparticipant_ids): # train_idx / val_idx 是样本索引但每个参与者的所有样本都只在一边 pass4.2 语谱图参数不对ResNet 把静音当成特征现象训练损失下不去accuracy-like 指标异常高。原因我一开始用 30 秒整段音频生成语谱图结果大部分时间是静音模型学到的是音频长度和静音比例跟抑郁无关。另一个原因是librosa.load默认带res_typekaiser_best耗时不说重采样对语音影响不大增加的带宽反而让网络关注高频噪声。解决每段音频只取前 3 秒和中段 3 秒并且只在语音活动段VAD附近截取。librosa.load指定sr16000, res_typekaiser_fast切分前先去掉首尾 0.5 秒。y, sr librosa.load(wav_path, sr16000, res_typekaiser_fast) # 去掉开头结尾部分静音 y y[int(0.5 * sr): -int(0.5 * sr)] if len(y) sr else y4.3 类别不均衡低分样本太多回归输出全堆在低分区间现象预测值的分布集中在 2~6 分真实分布却横跨 0~20。原因AVEC2014 数据集中轻度或无症状的人数本来就多回归目标没有做任何重加权模型在 MSE 损失下倾向于学习样本量大的区间。解决对标签做分箱按分箱后的样本数量对损失加权。常用做法是用torch.utils.data.WeightedRandomSampler根据 PHQ-8 分箱的倒数构造采样权重。分箱数设 5~8 即可太多会过拟合。import numpy as np from torch.utils.data import WeightedRandomSampler # 将 PHQ8 分为 6 箱 bins np.linspace(0, 24, 7) bin_idx np.digitize(labels, bins) counts np.bincount(bin_idx) weights 1.0 / counts[bin_idx] sampler WeightedRandomSampler(weights, num_sampleslen(weights), replacementTrue)4.4 冻结预训练模型只顾超参忘了改 BatchNorm现象freeze_backboneTrue之后loss 一直震荡CCC 上不去。原因PyTorch 的 BatchNorm 层在param.requires_gradFalse时仍会继续更新 running_mean 和 running_var而且如果你的 batch size 只有 4统计值非常不稳定。这种“部分冻结”会让 BN 层失效。解决冻结时把 BatchNorm 也切换到 eval 模式或者干脆不冻结。我的习惯是数据量超过 200 就不冻结只把学习率调成主干 1e-5、fc 层 1e-3效果比冻结好。def freeze_bn(model): for m in model.modules(): if isinstance(m, nn.BatchNorm2d): m.eval()4.5 训练不收敛dropout 放在全连接后面回归输出被“抖掉”现象训练 loss 前几个 epoch 下降然后突然变 NaN。原因我在fc的 Dropout 前接了 BN但fc前的全局池化输出特征分布本来就在 [0,1] 附近再经过 BN 和 Dropout 后数值溢出。回归任务是单输出丢掉一个通道就会让梯度爆炸。解决Dropout 只放在全连接层之前且训练初期用warmup把学习率从 0 线性提升到 1e-4防止梯度爆炸。如果还是 NaN检查pred是否含 NaN打印出来定位是那一步溢出的。5. 进阶用交叉验证和特征融合把基线模型做成稳定系统到这里你已经有了一个能跑通的训练流程。但如果要让这个系统更可靠或者拿去交作业、写在简历上还有两个值得加的技巧分组交叉验证和手工特征融合。分组交叉验证在第 4 章已经提到实际上你应该把官方 traindev 合并起来跑 5 折 GroupKFold最后报告 5 折的平均 CCC。AVEC2014 官方数据量很小单用 train 作训练集dev 作验证集会有几百条样本的差别对最终指标影响很大。5 折之后模型的稳定性能高不少而且你能看出哪些参与者在某一折上被分到训练/验证导致结果波动这是定位过拟合方向的关键。特征融合方面AVEC2014 原本的人工特征非常有用。CLNF 提供的脸部特征和 openSMILE 的音频特征可以直接和 ResNet 提取的深层特征拼接再一起进入全连接回归头。做法是先用预训练 ResNet 提取每个语谱图样本的 512 维向量feat_resnet再把同一段音频的 openSMILE 特征feat_clnf维度几百到几千不等归一化后和它拼接得到一个新的特征向量送入一个简单的三层 MLP 做回归。class FusionRegressor(nn.Module): def __init__(self, resnet_dim512, clnf_dim384, hidden_dim128): super().__init__() self.fc nn.Sequential( nn.Linear(resnet_dim clnf_dim, hidden_dim), nn.ReLU(), nn.Dropout(0.3), nn.Linear(hidden_dim, 1) ) def forward(self, resnet_feat, clnf_feat): feat torch.cat([resnet_feat, clnf_feat], dim1) return self.fc(feat).squeeze(1)参数说明resnet_dim取决于你的主干网络ResNet18 为 512。clnf_dim根据你用的手工特征决定先用 pandas 读取特征 CSV看看有多少列就填多少。这个融合模型的好处是显眼可解释性也比纯黑盒好你可以单独打印fc第一层权重看哪些手工特征对抑郁症诊断贡献大。验证时我习惯把每个参与者的所有切片预测取平均再作为该参与者的最终预测而不是直接对切片计算 CCC。因为 AVEC2014 的标签是每人一个分数切片级评估会把同一参与者的误差当作独立样本导致误导。从那以后我每次做医疗时序数据模型都会强制走一遍“分组切分 → 人工特征融合 → 每人级平均评估”这条流水线。希望帮到你。本文还有配套的精品资源点击获取