☰
抑郁症诊断项目拆解:基于ResNet的多模态音视频特征分析
2026/9/28 13:29:57 网站建设 项目流程

简介:这是一份基于AVEC2014数据集与Resnet网络实现抑郁症诊断的Python项目源码,面向计算机专业学生的课程设计、期末大作业,也适合希望提升深度学习实战能力的学习者。AVEC2014数据集在抑郁识别研究中被广泛使用,ResNet作为经典深度残差网络,二者结合覆盖了从生物信号预处理到模型训练评估的完整实验链路。项目源码包含数据预处理、数据集加载、模型结构定义、训练、验证与测试等模块,注释详细,并配有README说明与依赖清单,便于理解每一步设计意图。压缩包共11个文件,以9个Python脚本为主,辅以requirements.txt和README.md,整体仅9KB,结构紧凑,适合作为入门级别的完整参考项目。目前已有92人学习下载,项目经导师指导并获得98分评价,既可作为期末大作业蓝本,也是学习医学信号与深度学习结合的实践范例。

1. 抑郁症诊断项目拆解:它到底在做什么模型

这个项目拿到的第一刻,我的反应是:“哦,原来不是把一整段视频丢进网络,而是把音视频特征切成帧序列喂给 ResNet。” 这是一个很典型的“多模态特征 + 图像分类骨架”的思路。AVEC2014 是抑郁症识别领域绕不开的公开数据集,里面是真实的患者访谈视频和对应的 PHQ-8 抑郁评分;ResNet 在这里不是用来做图像识别的,而是把一维特征序列重排成二维特征图,再用残差结构去拟合从特征到严重等级之间的映射。这不属于“看完就能上手”的玩具项目,而是值得在课程设计或者期末大作业里当主线任务的项目——有真实数据、有预处理环节、有模型训练和验证闭环。

它的适用人群非常明确:计算机相关专业的学生,尤其是正在做课程设计、期末大作业,或者想拿一份完整深度学习项目来练手的学习者。你拿到的东西不是一个动不动就上亿参数的 SOTA 模型,而是一套能在普通 GPU(甚至 CPU 也能跑通小 batch)上运作的完整链路——数据加载、预处理、训练、验证、测试、可视化,每一样都铺好了。如果你只有模糊的“深度学习项目该怎么做”概念,这份代码的价值就在于它能把你从“调库”拽到“改代码”的层级。

2. AVEC2014 数据集:评分机制、文件结构与模态融合入口

2.1 PHQ-8 评分先弄懂:分类阈值不是拍脑袋定的

AVEC2014 的标签不是“抑郁 / 不抑郁”的二分类,而是一个 0 到 24 的整数分,对应 PHQ-8 抑郁量表得分。大家在课程答辩时经常被问“你的类别是怎么定的”,如果回答“我随便分了五类”,那基本就露馅了。这个项目里跑的是 7 分类,依据是 AVEC2014 官方的映射协议:

提示:0–9 分是“无症状到轻度”,10–14 是“中度”,15–19 是“中重度”,20–24 是“重度”。项目里一般拆成 7 档:0–2、3–5、6–8、9–11、12–14、15–17、18–24。别嫌档位细,小 class 之间信号差异很小,恰恰是 ResNet 这类模型能学到东西的地方。

标签在哪?数据集根目录下的train_split.csv、dev_split.csv、test_split.csv里都有PHQ8_Score这一列。预处理脚本会读取这个分数,然后走一条固定的映射逻辑。注意这里的映射要写在preprocess.py里,一旦你在dataset.py里重写一套不同的映射规则,训练和测试时的标签口径就对不上了,最后算出来的准确率基本不可信。

2.2 文件命名规则与模态划分:别在读取阶段就翻车

每个被试有一个唯一的 ID,格式类似301_F_录音室编号这种风格。命名里的字母代表性别(F/M),后面跟着录音室编号。你拿到原始 AVEC2014 数据后,会看到三种类型的文件:

  • 视频文件(.mp4):录自访谈现场,通常几十秒到几分钟不等。
  • 音频文件(.wav):从视频里抽出来的,采样率一般是 16kHz。
  • 转录文本(.txt):访谈内容的文字记录。

这个项目主要用的是前两者。文本模态在这里不是主力,但如果你做模态融合拓展,txt文件可以作为第三个信息源。音频和视频的时间轴是对齐的,所以预处理阶段的核心工作就是:以固定窗口把长序列切成段,然后对每一段提取特征。视频走的是图像帧——每 N 帧抽一张,抽完 Resize 成统一尺寸;音频走的是频谱图——计算短时傅里叶变换(STFT),把 1 维波形变成 2 维时频图。两条支路到此汇合,之后就是标准的图像分类管线。

2.3 模态融合的入口:在 dataset.py 里做 concat

如果你只跑单模态,比如只用视频帧,dataset.py里返回的(video_frames, label)就够了;如果你想加音频,简单的方式是让__getitem__同时返回(video_frames, audio_spec, label),然后在模型的forward里做特征拼接:

# model.py 中的双模态 forward 示意 import torch import torch.nn as nn class DepressionResnet(nn.Module): def __init__(self, base_model, num_classes=7, fusion='concat'): super().__init__() self.base_model = base_model # 把 ResNet 最后的全连接层去掉,拿特征 self.feature_dim = base_model.fc.in_features base_model.fc = nn.Identity() # 音频分支:一组简单的卷积 + 池化,把频谱图压成同样长度 self.audio_branch = nn.Sequential( nn.Conv2d(1, 32, kernel_size=3, stride=2, padding=1), nn.ReLU(), nn.AdaptiveAvgPool2d((1, 1)) ) self.fc = nn.Linear(self.feature_dim + 32, num_classes) def forward(self, video_frames, audio_spec=None): vid_feat = self.base_model(video_frames) # [B, feature_dim] if audio_spec is not None: aud_feat = self.audio_branch(audio_spec) # [B, 32] aud_feat = aud_feat.view(aud_feat.size(0), -1) feat = torch.cat([vid_feat, aud_feat], dim=1) # [B, feature_dim+32] else: feat = vid_feat return self.fc(feat)

参数上要注意AdaptiveAvgPool2d((1, 1))是个很好的选择——不管音频频谱图的长宽是多少,最后都能池化成固定维度,这样即便你不统一音频的长度,模型也不会报错。实际使用时音频片段建议还是预先 pad 或截断到统一长度,否则每个 batch 的audio_spec形状不一致,DataLoader 会直接抛异常。

2.4 划分逻辑:train/dev/test 别混在一起

项目里有train.py和test.py,这说明训练脚本和测试脚本是分开的。AVEC2014 的标准做法是:train_split.csv对应训练集,dev_split.csv对应验证集,test_split.csv对应测试集。很多初学者写代码时把所有数据揉在一起,自己随机切分,然后跑来问“为什么准确率那么高”——原因是他把训练集的样本漏到测试集里去了,特征分布被模型见过了,当然高分。这个项目是严格按官方协议走的,这是个好习惯,答辩时老师问起来你也能兜得住。

2.5 预处理脚本的两个硬指标:帧率统一和尺寸统一

预处理的目标只有一个:让所有输入形状一致。视频部分一般做这三步:

  1. 用 OpenCV 按固定帧率抽帧,例如fps=5,也就是每秒取 5 帧。
  2. 对抽出来的帧做缩放和中心裁剪,目标尺寸通常是(224, 224),配合 ResNet 的输入要求。
  3. 把连续的帧堆叠成[N, H, W, C]的张量序列,再由dataset.py转换成[N, C, H, W]。

音频部分同理:STFT 窗口 25ms,步长 10ms,然后转成对数梅尔频谱图,resize 到和视频特征相近的尺寸。这一步是整个项目最耗时的环节,但也是决定模型上限的环节——数据处理得干净,后面的训练就是走流程;数据脏乱,ResNet 再深也救不回来。

3. 源码包逐文件拆解:八个文件的执行链路

3.1 文件清单与职责划分

项目压缩包里的文件可以分为三类:配置说明(README.md、requirements.txt)、数据与预处理(preprocess.py、load_data.py、dataset.py)、模型与训练(model.py、train.py、validate.py、test.py、writer.py)。注意所有源码里有两个train.py同名文件但大小写不同,这在 Windows 上没问题,在 Linux 下会互相覆盖,稍后避坑章会细说。

文件核心职责关键输出
preprocess.py视频抽帧、音频转频谱、标签映射预处理后的特征文件(.npy或.h5)
load_data.py读取原始 AVEC2014 目录结构、生成索引文件名列表 + 对应标签的.csv
dataset.pyDataset子类,负责按索引加载特征一个样本(帧序列 / 特征张量 + 标签)
model.py定义 ResNet 分类网络模型结构
train.py主训练循环:加载数据、前向、反向、存权重训练日志 +.pth权重
validate.py在验证集上计算损失和准确率验证指标
test.py用最优权重跑测试集最终 MAE/准确率报告
writer.py基于 TensorBoard 或本地日志的指标记录训练曲线数据

3.2 执行顺序:先 load_data 再 preprocess 再 train

拿到源码包后的第一件事不是跑train.py,而是按顺序走完整个链路。实际执行顺序是:

# 第一步:扫描原始数据目录,生成 train/dev/test 的索引清单 python load_data.py --data_dir /path/to/AVEC2014 # 第二步:对索引清单中的每个样本做预处理(抽帧 + 频谱 + 标签映射) python preprocess.py --split train --out_dir ./processed python preprocess.py --split dev --out_dir ./processed python preprocess.py --split test --out_dir ./processed # 第三步:训练模型 python train.py --epochs 50 --batch_size 16 --lr 1e-4 # 第四步:验证 python validate.py --checkpoint ./checkpoints/best.pth # 第五步:测试并输出最终报告 python test.py --checkpoint ./checkpoints/best.pth

load_data.py的作用是生成一个映射文件,告诉预处理脚本“哪些文件对应哪个标签”。这一步先把数据清单理顺了,后面的处理才能批量跑。做课程设计时最怕的是把这三个脚本混在一个文件里写,出了问题想排查都无从下手——这份源码的分工是合理的,每个脚本只做一件事。

3.3 dataset.py:核心是索引到样本的映射

dataset.py是所有数据流动的中枢,它的核心逻辑是:给定一个整数索引,返回(input_tensor, label)。这里有个重点:预处理后的特征如果是视频帧,可能非常大——一个人几分钟的视频抽帧后是几百张图,全放进内存会爆。常见做法是让preprocess.py先把每个样本的帧堆叠成一个.npy文件,dataset.py在__getitem__里按需读取,同时做随机裁剪或翻转扩展数据:

# dataset.py 的关键部分(根据项目流程补全的骨架) import os import torch import numpy as np from torch.utils.data import Dataset class AVEC2014Dataset(Dataset): def __init__(self, index_file, data_dir, transform=None, train=True): self.index_file = index_file self.data_dir = data_dir self.transform = transform self.train = train self.samples = [] with open(index_file, 'r', encoding='utf-8') as f: for line in f: # 每行格式: subject_id, label_class, npy_path parts = line.strip().split(',') self.samples.append((parts[0], int(parts[1]), parts[2])) def __len__(self): return len(self.samples) def __getitem__(self, idx): subject_id, label, npy_path = self.samples[idx] # 按需加载,避免一次性把所有特征载入内存 frames = np.load(os.path.join(self.data_dir, npy_path)) frames = torch.from_numpy(frames).float() # frames 的形状是 [T, C, H, W],这里可以做随机时序采样 if self.train and frames.size(0) > 16: start = np.random.randint(0, frames.size(0) - 16) frames = frames[start:start + 16] if self.transform: frames = self.transform(frames) label_tensor = torch.tensor(label, dtype=torch.long) return frames, label_tensor

逻辑说明:__getitem__里按需np.load而不是在__init__里一次性加载全部帧序列,这是处理视频类数据的基本功,否则几十个被试的数据就能撑爆 16G 内存。train=True时用随机时序切片,可以理解成一种时间维度的数据增强;train=False时就不做这一步,保证验证和测试的确定性。

参数说明:npy_path是相对路径,拼接在data_dir后面,这样数据集目录可以整体移动而不需要改代码。标签在索引文件里已经映射成了分类整数,dataset.py里不再做转换——这也是避免标签口径不一致的工程手段。

3.4 model.py:选 ResNet 的骨架子类而不是魔改

模型的搭建思路不是从零手写残差块,而是用torchvision里的resnet18或resnet34作为骨干,替换最后的全连接层。为什么要用 18/34 而不是 50/101?这个数据集规模不算大,过深的网络在几个小时的录音数据上容易过拟合,18 层的参数量对课程设计来说已经非常能打了。从工程角度讲,用预训练权重初始化骨干网络能加快收敛,项目在训练脚本里默认从 ImageNet 预训练权重开始,这比随机初始化省一半时间:

# model.py 中构建模型的典型写法 import torchvision.models as models import torch.nn as nn def build_model(num_classes=7, use_pretrained=True): # 用 resnet18 作为骨干,避免过拟合,同时保留足够的特征抽象能力 model = models.resnet18(pretrained=use_pretrained) in_features = model.fc.in_features # 替换最后一层,把原本 1000 类输出改成 PHQ-8 的 7 个等级 model.fc = nn.Linear(in_features, num_classes) return model

逻辑说明:model.fc.in_features先取出原全连接层的输入维度,这是个稳写法——如果你换成resnet34,这一行依然成立,不需要硬编码 512 或 256。参数说明:pretrained=True在课程设计中是推荐选项,因为 AVEC2014 的数据量不足以从头训练出一个鲁棒的卷积核;如果你的机器下载不了预训练权重,把pretrained改为False,但训练轮数建议从 50 提升到 80 才能补回损失。

3.5 train.py:一个完整的训练循环里有什么

训练循环是所有学生最容易抄错的部分。这份源码里的train.py结构是标准的:设置随机种子、初始化模型、定义优化器和损失函数、循环 epoch、每个 epoch 内遍历 DataLoader、反向传播、梯度裁剪、保存最优模型。关键部分看学习率的设置:

# train.py 中训练循环的骨架 import torch import torch.nn as nn from torch.utils.data import DataLoader def train_one_epoch(model, dataloader, optimizer, criterion, device): model.train() running_loss = 0.0 correct = 0 total = 0 for inputs, labels in dataloader: inputs, labels = inputs.to(device), labels.to(device) # inputs 形状为 [B, T, C, H, W],需要合并 batch 和时间维度 B, T, C, H, W = inputs.shape inputs = inputs.view(B * T, C, H, W) optimizer.zero_grad() outputs = model(inputs) # [B*T, num_classes] # 对一个视频的所有帧取平均,得到视频级预测 logits = outputs.view(B, T, -1).mean(dim=1) loss = criterion(logits, labels) loss.backward() # 梯度裁剪,防止 LSTM/深层的梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() running_loss += loss.item() * inputs.size(0) _, preds = torch.max(logits, 1) correct += (preds == labels).sum().item() total += labels.size(0) epoch_loss = running_loss / total epoch_acc = correct / total return epoch_loss, epoch_acc

逻辑说明:视频输入的[B, T, C, H, W]是一个典型五维张量,不能直接进 ResNet,所以要先把B和T合并成B*T,过完模型后再重新拆开,在时间维度上取平均得到视频级预测。这里用的是简单平均池化,你也可以换成先对帧特征加权再做分类,但平均池化在大多数情况下已经够用。

参数说明:max_norm=1.0是梯度裁剪的阈值,在 ResNet 里一般是防患于未然,但在引入音频分支后就成了刚需——两个模态的梯度尺度往往不同,容易互相干扰。B, T, C, H, W = inputs.shape这行不要在训练循环外部做,因为最后一个 batch 的样本数可能不等于batch_size,每次都现算最稳妥。

3.6 validate.py 和 test.py:两者的差异你要讲得清

很多学生会把validate.py和test.py搞混。这里的区别是:validate 在训练过程中跑,用于挑选最优模型;test 在训练结束后跑,用于报告最终指标。validate.py加载的是当前 epoch 结束时的临时权重,跑的是dev_split.csv对应的验证集;test.py加载的是验证集上指标最好的那个best.pth,跑的是test_split.csv。两者的评估指标也不完全一样——验证阶段重点看准确率和损失,测试阶段还要额外计算 MAE(平均绝对误差),因为 PHQ-8 是回归量,MAE 更能反映预测值与真实评分之间的实际偏差:

# test.py 中计算 MAE 的片段 def compute_mae(predictions, labels): # 预测值先过 softmax 得到概率分布,再按类别索引做期望 probs = torch.softmax(predictions, dim=1) pred_scores = torch.sum(probs * score_range, dim=1) mae = torch.mean(torch.abs(pred_scores - labels)) return mae.item()

score_range是一个长度为 7 的张量,存放每个类别的中间分数,例如[1, 4, 7, 10, 13, 16, 21]。这个设计比直接取argmax再映射回分数更平滑,避免了“差一个类别但分数差很多”的误伤。答辩时能说出这一步,老师就知道你理解了一个分类模型在回归指标上的局限。

3.7 writer.py:训练可视化不是花架子

writer.py封装了 TensorBoard 日志逻辑。写训练曲线的价值在于:你能早点发现过拟合的时间点,从而决定什么时候执行早停。它的用法就是在train.py里初始化一个实例,每个 epoch 结束写一次指标:

# writer.py 的典型接口封装 from torch.utils.tensorboard import SummaryWriter class MetricsWriter: def __init__(self, log_dir='./runs'): self.writer = SummaryWriter(log_dir) self.global_step = 0 def write_train_metrics(self, loss, acc, epoch): self.writer.add_scalar('train/loss', loss, epoch) self.writer.add_scalar('train/acc', acc, epoch) self.global_step = epoch def write_val_metrics(self, loss, acc, mae, epoch): self.writer.add_scalar('val/loss', loss, epoch) self.writer.add_scalar('val/acc', acc, epoch)

原始项目里如果没有这个文件,训练完就只有一张控制台输出的日志表;有了它,你可以在 TensorBoard 里直观看到 loss 曲线的下降趋势。这在写课程报告时是很好的图源,建议正式跑实验时始终开启。

4. 四个必踩的坑:现象、原因与解决记录

4.1 坑一:train.py与Train.py大小写问题

现象:在 Linux 服务器上解压后,发现train.py的内容变成了一个完全不同的脚本,原来的主训练代码找不到了;或者在 Windows 上跑没问题,但打包发给队友后对方在 macOS 上报ModuleNotFoundError。

原因:压缩包里同时存在train.py和Train.py,在大小写不敏感的文件系统(Windows/macOS 默认)上,它们会被当成同一个文件;后者解压时覆盖前者。这在跨平台协作时是个经典翻车点,跟代码本身一行关系都没有,纯属资源整理疏忽。解决:动手前先把压缩包里的文件名全列出来,确认是否只有train.py这一个训练入口,把多余的那个重命名成比如train_frames.py再开始配置环境。

4.2 坑二:训练时视频帧采样没对齐,batch 里形状不一

现象:训练到第二个 epoch 时报错,提示stack expects each tensor to be equal size或者RuntimeError: invalid argument 0: Sizes of tensors must match。

原因:dataset.py里对视频帧序列做了随机时序切片,切片长度是 16,但某些视频预处理后不足 16 帧——边界情况没有被考虑。不少预处理脚本在抽帧时会因为视频解码失败导致得到 0 帧,这种样本被 DataLoader 收集后,collate阶段就会因为形状不一致而爆炸。解决:在__getitem__里加一个边界判断,帧数不够时做重复填充,常见的做法是循环取帧直到凑满目标长度:

if frames.size(0) < 16: repeat_times = (16 + frames.size(0) - 1) // frames.size(0) frames = frames.repeat(repeat_times, 1, 1, 1)[:16]

4.3 坑三:乱动预处理参数导致标签和特征错位

现象:训练loss看着很合理但val准确率始终在 20% 上下徘徊,像随机猜测。检查了模型结构也没发现问题,数据加载也不报错,一度怀疑是网络不给力。

原因:这是一个典型的“标签污染”问题。原始预处理脚本是按数据集目录顺序读取文件并生成.npy的,如果在中间环节加了shuffle=True或者人为删掉了一些样本而没有同步更新索引文件,训练时模型拿到的特征和标签就不再对应同一个被试了。网络在这种错配数据上学会了“每个类别都输出差不多概率”,准确率自然上不去。解决:每次跑新实验前,强制重新执行load_data.py重新生成索引,不要在旧索引上改来改去;一旦发现 val 准确率持续异常,第一件事不是调模型,而是抽查一个.npy文件对应的标签,人工对齐一次数据通路。

4.4 坑四:train/validate/test 三个阶段的预处理不一致

现象:训练时用了随机水平翻转,验证和测试阶段也沿用了同样的 transform,结果测试集 MAE 偏高,老师问为什么,你答不上来。

原因:数据增强只应出现在训练阶段,验证和测试要用同一套确定性预处理。水平翻转会让模型在训练时见过左右翻转的帧,但验证时没有翻转——虽然对 AVEC2014 这种访谈视频来说,水平翻转不会改变语义,但严格做实验时这种不一致会让人怀疑整个评估流程的规范性。解决:在dataset.py的初始化参数里传入train=True/False,测试和验证阶段无条件把transform固定为 Resize + Normalize,不要在验证阶段引入任何随机性。

5. 进阶用法与验证技巧:MAE/RMSE 指标怎么解读,模型怎么改更好

5.1 评估指标的正确打开方式

这个项目的测试集输出除了准确率,还应该包括 MAE 和 RMSE。准确率看的是分类正确率,但 PHQ-8 是 0–24 的整数分,把 6 分预测成 8 分虽然判错,但误差很小;把 6 分预测成 20 分才是真正不可接受的。MAE 衡量的是回归误差的平均绝对值,RMSE 更惩罚大误差:

  • MAE < 4:对课程设计来说已经是很好的成绩,意味着平均偏差不到一个严重等级。
  • RMSE 明显大于 MAE:说明存在个别样本被预测得非常离谱——这时要重点检查那些离群样本的输入数据质量,比如音频频谱是否异常、视频帧是否抽到了全黑画面。
  • 准确率超过 50% 在 7 分类任务中已经相当可观,不要迷信 90% 的准确率——7 分类随机猜测的基准线是 14.3%。

5.2 训练参数怎么调:从这份源码出发

如果第一次训练结果不理想,我的建议是调整顺序:先动batch_size和lr,再考虑网络深度。batch_size从 16 改成 8 通常会让训练更稳定但速度更慢;lr从 1e-4 降到 5e-5 时,往往能多压出几个百分点的准确率。ResNet 系列对学习率比较敏感,1e-3 大概率跳过最优点,1e-4 到 5e-5 是安全区间。

当你想要进一步提升性能,优先尝试预训练权重。这个项目如果原生代码里pretrained=False,是第一推荐改的地方——在 AVEC2014 这样的小数据集上,花额外 20 轮训练去学底层边缘特征就是浪费算力。

5.3 模型层面的两个轻量升级方向

第一个是帧级特征加 Temporal Pooling,把简单的 mean 改为加权平均:

# model.py 中帧级输出的时间注意力示意 # 对上一步的 [B, T, D] 特征做可学习的加权聚合,替代 mean import torch.nn.functional as F weights = torch.softmax(self.attention_fc(frame_feats), dim=1) # [B, T, 1] video_feat = torch.sum(frame_feats * weights, dim=1) # [B, D]

第二个是引入音频分支,前面在 2.3 节里已经给出了双模态前向的骨架,把原来的单模态video_frames换成(video_frames, audio_spec)两个输入即可。多模态融合在 AVEC 系列比赛里是几十年来的核心涨点手段,你只要做了就有实质性的加分内容。

5.4 我的血泪经验:验证集是你最好的朋友

从那以后,我每次拿到新数据集都会强制走一遍“可视化验证”流程:直接从dataset.py里取出 4 个样本,打印标签和帧数,存成网格图看一眼;然后跑一个 1 epoch 的短训练,确认 loss 在下降,再做正式训练。这套流程帮我规避了至少二十次“预处理静默失败”的浪费。这份资源的价值不在于看懂,而在于亲手跑通——你今天下载、解压、把requirements.txt安装好、把数据路径留给load_data.py,明天就能看到第一条 loss 曲线。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询