简介:这份资源是面向计算机相关专业学生与项目实战学习者的抑郁症诊断课程设计完整方案,以AVEC2014数据集为基础,采用ResNet网络完成抑郁程度识别任务,适合作为期末大作业、课程设计或深度学习入门练手项目。压缩包共11个文件,以9个Python源码文件为主,涵盖数据预处理、数据集加载、模型定义、训练、验证与测试等完整流程,另附requirements依赖清单、README说明文档及一个txt文件,整体约8KB,结构精简、便于快速跑通。项目已获导师指导并取得98分认可,代码模块划分清晰,读者可据此掌握从数据读取到模型评估的全链路实现思路,并在此基础上替换数据集或调整网络结构进行二次开发。目前已有746人学习下载,适合需要参考高分项目模板、补齐工程实践环节的学习者。
1. AVEC2014 加 ResNet 做抑郁症诊断:这套组合到底能跑出什么结果
depression 识别这个方向,很多人第一次接触就是 AVEC2014 这个数据集。它全称 Audio/Visual Emotion Challenge 2014,是音频、视频双模态的情感挑战赛数据,其中抑郁症识别是它的核心子任务之一。数据集里每一条样本是一个受试者的访谈录像片段,标签是 PHQ-8 抑郁量表分数,分数越高抑郁倾向越明显。用 ResNet 做这个任务,本质是把视频帧序列当成图像分类问题来处理——从人脸区域提取空间特征,再映射到抑郁分数。Python 源码加数据集的组合之所以被反复搜索,是因为这个任务链条长、坑多,从数据预处理到模型微调每一步都有翻车的可能。这套方案适合已经会 Python、跑过 PyTorch 或 TensorFlow 基础模型、想找一个完整多模态项目练手的人。如果你只是想跑通一个 demo,它不算轻量;但如果你想理解「视频 → 人脸 → 深度特征 → 回归分数」这条链路,它是个很扎实的起点。
2. AVEC2014 数据集的真实结构和预处理链路
2.1 数据集目录长什么样、标签怎么对齐
AVEC2014 的原始结构通常按训练集、开发集、测试集划分,每个受试者一个独立文件夹。文件夹里一般包含视频文件、音频文件,以及一个记录 PHQ-8 分数的标签文件。标签文件常见格式是每行一个受试者 ID 加分数,或者 CSV 里两列。这里第一个容易踩的坑是:受试者 ID 在文件夹名、视频文件名、标签文件里可能写法不一致,比如有的带前缀有的不带,直接按字符串匹配会漏掉大量样本。
我一般会先写一个脚本把三者的 ID 统一成纯数字或统一前缀,再建立映射字典。下面这段代码做的是扫描目录、读取标签、对齐 ID 并输出一份干净的清单:
import os import pandas as pd # 数据集根目录,按实际路径改 ROOT = "AVEC2014" # 标签文件路径,按实际文件名改 LABEL_FILE = os.path.join(ROOT, "labels.csv") def normalize_id(raw): """把各种写法的受试者 ID 统一成纯数字字符串""" return "".join(ch for ch in str(raw) if ch.isdigit()) # 读取标签,假设两列:id, phq8 labels = pd.read_csv(LABEL_FILE) labels["sid"] = labels.iloc[:, 0].apply(normalize_id) label_map = dict(zip(labels["sid"], labels.iloc[:, 1])) records = [] for split in ["train", "dev", "test"]: split_dir = os.path.join(ROOT, split) if not os.path.isdir(split_dir): continue for name in os.listdir(split_dir): sid = normalize_id(name) video = os.path.join(split_dir, name, "video.mp4") if sid in label_map and os.path.exists(video): records.append({"sid": sid, "split": split, "video": video, "score": label_map[sid]}) df = pd.DataFrame(records) df.to_csv("clean_manifest.csv", index=False) print(df.groupby("split")["score"].describe())逻辑说明:normalize_id把 ID 里的非数字字符全部去掉,解决命名不一致问题。label_map建立 ID 到分数的映射,只有同时满足「ID 在标签里」和「视频文件存在」两个条件的样本才会进入清单。最后按 split 分组打印分数分布,用来确认训练集和测试集的分数范围是否接近。参数方面,ROOT和LABEL_FILE必须按你实际拿到的目录结构改,不同来源的 AVEC2014 打包方式差异很大,不要假设文件名固定。
2.2 从视频到人脸帧:抽帧频率和裁剪策略
ResNet 吃的是固定尺寸的图像,所以视频必须先抽帧再裁人脸。抽帧频率是个关键参数。AVEC2014 的访谈视频通常几分钟到十几分钟,如果按 30fps 全抽,一个受试者就是上万帧,训练时显存和 IO 都扛不住。常见做法是每秒抽 1 到 5 帧,我一般先用 1fps 跑通流程,确认模型能收敛后再考虑加密。
人脸裁剪用 OpenCV 的 Haar 级联或 Dlib 的检测器都行。Haar 快但漏检多,Dlib 准但慢。实操里我会加一个兜底逻辑:检测不到人脸时,直接裁画面中心区域,而不是丢弃这一帧。因为丢弃会导致某些受试者剩余帧数过少,样本不平衡会更严重。
import cv2 import os def extract_faces(video_path, out_dir, fps=1, size=(224, 224)): os.makedirs(out_dir, exist_ok=True) cap = cv2.VideoCapture(video_path) src_fps = cap.get(cv2.CAP_PROP_FPS) or 25 step = max(int(src_fps / fps), 1) detector = cv2.CascadeClassifier( cv2.data.haarcascades + "haarcascade_frontalface_default.xml") idx, saved = 0, 0 while True: ok, frame = cap.read() if not ok: break if idx % step == 0: gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces = detector.detectMultiScale(gray, 1.1, 5) if len(faces) > 0: x, y, w, h = max(faces, key=lambda f: f[2] * f[3]) else: # 兜底:裁中心区域 H, W = frame.shape[:2] w, h = W // 2, H // 2 x, y = W // 4, H // 4 crop = frame[y:y+h, x:x+w] crop = cv2.resize(crop, size) cv2.imwrite(os.path.join(out_dir, f"{saved:05d}.jpg"), crop) saved += 1 idx += 1 cap.release() return saved逻辑说明:step控制抽帧间隔,fps=1表示每秒一帧。detectMultiScale返回多个人脸框时取面积最大的那个,避免背景人脸干扰。兜底分支保证每帧都有输出,不会因为检测失败断掉。size统一到 224×224,这是 ResNet 的标准输入尺寸。参数上,1.1是每次缩放比例,5是最小邻居数,调大这两个值会减少误检但可能漏检,按你的视频清晰度微调。
3. ResNet 做抑郁分数回归:改造分类头与训练配置
3.1 为什么用 ResNet、用哪个版本、预训练权重怎么接
ResNet 的核心是残差连接,它让深层网络在梯度回传时不会轻易退化。抑郁症识别这个任务,输入是人脸图像序列,本质是空间特征提取,ResNet 的卷积堆叠正好匹配。版本选择上,ResNet18 和 ResNet50 是最常用的两个。ResNet18 参数少、训练快,适合先跑通;ResNet50 表达能力强,但需要更多数据和更长的训练时间。AVEC2014 的样本量不算大,我一般先用 ResNet18 验证流程,确认没有数据层面的问题后再换 ResNet50 看有没有提升。
预训练权重的作用是让卷积层一开始就具备通用图像特征,而不是从随机噪声学起。PyTorch 里直接加载 ImageNet 预训练权重,然后把最后的全连接层换成输出 1 个值的回归头。注意:不要冻结全部卷积层,那样模型学不到人脸和抑郁相关的特征。常见做法是冻结前几层,后面的层用较小学习率微调。
import torch import torch.nn as nn from torchvision import models def build_model(arch="resnet18", pretrained=True): if arch == "resnet18": model = models.resnet18(weights="IMAGENET1K_V1" if pretrained else None) feat_dim = model.fc.in_features elif arch == "resnet50": model = models.resnet50(weights="IMAGENET1K_V2" if pretrained else None) feat_dim = model.fc.in_features else: raise ValueError("unsupported arch") # 替换分类头为回归头 model.fc = nn.Sequential( nn.Linear(feat_dim, 256), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(256, 1) ) # 冻结前两个 stage,降低过拟合风险 for name, param in model.named_parameters(): if name.startswith(("conv1", "bn1", "layer1", "layer2")): param.requires_grad = False return model逻辑说明:feat_dim取原全连接层输入维度,ResNet18 是 512,ResNet50 是 2048。回归头用两层线性加 Dropout,中间维度 256 是经验值,太大容易过拟合,太小欠拟合。冻结conv1到layer2是因为浅层特征通用性强,微调它们收益低还容易破坏预训练知识。Dropout(0.5)在样本量不大时很关键,如果训练 loss 下降但验证 loss 上升,可以调到 0.6。
3.2 训练循环、损失函数和学习率调度
抑郁分数是连续值,所以用回归损失。MSELoss 是最直接的,但它对异常值敏感。如果标签里有极端高分样本,SmoothL1Loss 更稳。我一般先用 SmoothL1,观察收敛情况后再决定要不要换 MSE。
优化器用 AdamW,学习率设 1e-4 到 3e-4 之间。因为卷积层大部分冻结了,实际参与训练的参数不多,学习率不需要太小。调度器用 CosineAnnealingLR,让学习率从初始值平滑降到接近 0,避免后期震荡。
import torch from torch.utils.data import DataLoader, Dataset from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR from PIL import Image import pandas as pd class FaceDataset(Dataset): def __init__(self, manifest, img_root, transform=None): self.df = pd.read_csv(manifest) self.img_root = img_root self.transform = transform def __len__(self): return len(self.df) def __getitem__(self, i): row = self.df.iloc[i] # 取该受试者所有帧中的中间一帧作为代表 import os frames = sorted(os.listdir(os.path.join(self.img_root, row["sid"]))) mid = frames[len(frames) // 2] img = Image.open(os.path.join(self.img_root, row["sid"], mid)).convert("RGB") if self.transform: img = self.transform(img) return img, torch.tensor([row["score"]], dtype=torch.float32) def train(manifest, img_root, epochs=30, bs=16, lr=2e-4): from torchvision import transforms tf = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) ds = FaceDataset(manifest, img_root, tf) dl = DataLoader(ds, batch_size=bs, shuffle=True, num_workers=4) model = build_model().cuda() opt = AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr=lr) sched = CosineAnnealingLR(opt, T_max=epochs) loss_fn = torch.nn.SmoothL1Loss() for ep in range(epochs): model.train() total = 0.0 for x, y in dl: x, y = x.cuda(), y.cuda() opt.zero_grad() pred = model(x) loss = loss_fn(pred, y) loss.backward() opt.step() total += loss.item() * x.size(0) sched.step() print(f"epoch {ep+1} loss {total/len(ds):.4f}")逻辑说明:FaceDataset每个受试者只取中间一帧,这是最简版本。实际做的时候可以把多帧特征做平均或拼接,但先用单帧跑通。RandomHorizontalFlip是唯一的数据增强,因为人脸左右翻转不改变抑郁程度。Normalize用的 ImageNet 均值方差,和预训练权重匹配。filter(lambda p: p.requires_grad, ...)确保优化器只更新没冻结的参数。bs=16在 8G 显存上跑 ResNet18 没问题,ResNet50 可能要降到 8。
4. 多帧特征聚合:从单帧回归到视频级预测
4.1 平均池化、注意力池化和它们的适用边界
单帧预测的问题是它丢掉了时间信息。一个受试者的视频里,不同帧的表情、姿态差异很大,单帧结果波动也大。常见做法是把一个受试者的所有帧过一遍网络,得到每帧的特征向量,再聚合成一个视频级表示。最简单的聚合是平均池化,把所有帧特征取平均。它稳定、不易过拟合,但会稀释掉关键帧的信息。
注意力池化是进阶做法:给每帧学一个权重,让模型自己决定哪些帧更重要。实现上就是加一个小的打分网络,对每帧特征算一个标量分数,softmax 归一化后加权求和。注意力池化在样本量足够时通常比平均池化好,但样本少时容易过拟合。我的经验是:AVEC2014 这种规模,先用平均池化把 baseline 跑出来,确认流程没问题后再试注意力。
import torch import torch.nn as nn class AttentionPool(nn.Module): def __init__(self, dim): super().__init__() self.score = nn.Sequential( nn.Linear(dim, 128), nn.Tanh(), nn.Linear(128, 1) ) def forward(self, feats): # feats: (B, T, D) w = self.score(feats) # (B, T, 1) w = torch.softmax(w, dim=1) return (feats * w).sum(dim=1) # (B, D) class VideoModel(nn.Module): def __init__(self, backbone, dim=512, pool="mean"): super().__init__() self.backbone = nn.Sequential(*list(backbone.children())[:-1]) self.pool_type = pool self.attn = AttentionPool(dim) if pool == "attn" else None self.head = nn.Linear(dim, 1) def forward(self, x): # x: (B, T, C, H, W) B, T = x.shape[:2] x = x.view(B * T, *x.shape[2:]) f = self.backbone(x).flatten(1) # (B*T, D) f = f.view(B, T, -1) if self.pool_type == "attn": f = self.attn(f) else: f = f.mean(dim=1) return self.head(f)逻辑说明:backbone去掉原全连接层,输出每帧的特征向量。AttentionPool用两层线性加 Tanh 算分数,softmax 在时间维度归一化。VideoModel把 batch 和帧数合并后过 backbone,再拆回(B, T, D)做聚合。参数上,dim要和 backbone 输出维度一致,ResNet18 是 512。pool切换 mean 和 attn,方便对比实验。
4.2 训练时的显存控制和梯度累积
多帧输入会让显存占用成倍增长。如果T=16、bs=4,实际过网络的样本数是 64,和单帧bs=64差不多。显存不够时,梯度累积是标准解法:用小 batch 跑多次,累加梯度后再更新一次参数。这样等效于大 batch,但显存占用不变。
def train_video(model, dl, epochs=30, accum=4, lr=1e-4): opt = torch.optim.AdamW(model.parameters(), lr=lr) loss_fn = torch.nn.SmoothL1Loss() for ep in range(epochs): model.train() opt.zero_grad() for i, (x, y) in enumerate(dl): x, y = x.cuda(), y.cuda() pred = model(x) loss = loss_fn(pred, y) / accum loss.backward() if (i + 1) % accum == 0: opt.step() opt.zero_grad() print(f"epoch {ep+1} done")逻辑说明:loss / accum是关键,保证累加后的梯度等效于大 batch 的平均梯度。accum=4表示每 4 个小 batch 更新一次。注意最后一个不完整的累积周期要手动补一次opt.step(),否则会丢梯度。这个写法在显存紧张时很实用,代价是训练速度略慢。
5. 避坑与排查:这套流程里最容易翻车的五个地方
5.1 标签泄露:同一受试者的帧同时出现在训练和验证集
现象:验证集 loss 异常低,但测试集表现很差。原因:抽帧后按帧随机划分数据集,同一个受试者的不同帧被分到了训练和验证两边,模型实际上见过验证集的人。解决:划分必须以受试者为单位,一个受试者的所有帧只能出现在一个 split 里。检查方法是看 manifest 里 sid 是否跨 split 重复。
5.2 人脸检测失败导致某些受试者帧数为零
现象:训练时某些样本报文件不存在,或者某个受试者抽帧后目录是空的。原因:Haar 检测器对侧脸、遮挡、低分辨率帧漏检严重,兜底逻辑没写或写错。解决:加中心裁剪兜底,并在抽帧后打印每个受试者的帧数,帧数低于阈值的受试者要单独检查原始视频。
5.3 预训练权重加载失败但没报错
现象:模型能跑,但 loss 一直不降。原因:weights="IMAGENET1K_V1"在旧版 torchvision 里参数名不同,或者网络下载失败后静默用了随机初始化。解决:加载后手动检查几个卷积层的权重是否非随机,或者打印model.conv1.weight.std(),预训练权重的标准差通常在 0.1 量级,随机初始化会明显不同。
5.4 分数归一化不一致导致预测值全偏
现象:预测分数集中在某个窄区间,和真实分数分布对不上。原因:训练时对标签做了归一化,推理时忘了反归一化,或者训练和推理用了不同的均值和方差。解决:把归一化参数存下来,推理时严格用同一组参数反变换。更稳的做法是训练时不做标签归一化,直接回归原始 PHQ-8 分数。
5.5 DataLoader 的 num_workers 在 Windows 上卡死
现象:训练脚本在 Windows 上启动后卡住不动。原因:num_workers > 0在 Windows 上需要if __name__ == "__main__"保护,否则子进程会重复导入主模块。解决:把训练入口包在if __name__ == "__main__":里,或者先把num_workers设为 0 验证流程,确认没问题再调大。
6. 把结果做扎实:评估指标、消融实验和我的固定习惯
评估这块,很多人只报一个 MAE 就结束了,但抑郁症分数回归只看 MAE 不够。RMSE 对大误差更敏感,能暴露模型在极端样本上的问题。皮尔逊相关系数衡量预测值和真实值的线性相关程度,在心理量表预测里经常被审稿人要求。我一般三个都报,并且按分数区间分段看 MAE——低分段的误差和高分段的误差往往差异很大,只报总体值会掩盖问题。
消融实验是让结果可信的关键。至少要做这几组对比:单帧 vs 多帧平均 vs 注意力池化;ResNet18 vs ResNet50;冻结不同层数的影响。每组只改一个变量,其他配置完全一致。跑完把结果整理成表,比在正文里堆描述有说服力得多。
| 配置 | MAE | RMSE | Pearson |
|---|---|---|---|
| ResNet18 单帧 | 待填 | 待填 | 待填 |
| ResNet18 多帧平均 | 待填 | 待填 | 待填 |
| ResNet18 注意力池化 | 待填 | 待填 | 待填 |
| ResNet50 多帧平均 | 待填 | 待填 | 待填 |
表格里的数值必须是你自己跑出来的,不要抄任何来源。跑的时候固定随机种子,否则同一配置两次结果对不上,消融就没意义了。我习惯在训练脚本开头加torch.manual_seed(42)和numpy.random.seed(42),并在日志里记录当前 commit 或脚本版本。
最后一个具体技巧:推理阶段做测试时增强。把每个受试者的帧分成三组,分别取前段、中段、后段,各跑一次模型,三次预测取平均。这个做法几乎不增加训练成本,但通常能把 MAE 降几个百分点。代价是推理时间翻三倍,如果对延迟不敏感,值得加。
我自己在这个方向上翻过最狠的一次车,是忘了按受试者划分数据集,验证集 MAE 低到 2.1,换到测试集直接飙到 7.8,排查了一整天才发现是标签泄露。从那以后我养成了一个习惯:任何按帧或按片段展开的数据集,划分脚本里第一行就写按主体 ID 分组,跑完先打印每个 split 的 ID 交集,确认为空才继续。这个习惯帮我省了很多后悔药。希望帮到你。
本文还有配套的精品资源,点击获取