简介:基于AVEC2014数据集与ResNet网络的抑郁症诊断系统,是一套面向深度学习入门者及医疗影像/情感计算方向研究人员的完整可运行源码。项目使用MTCNN进行人脸对齐与裁剪,从视频中按间隔抽取100帧并保留原始标签,实现从预处理、数据加载到模型训练、验证与测试的全流程闭环。资源共11个文件,以Python脚本为主,分别负责视频帧与标签生成、人脸提取、模型结构定义、数据迭代、训练、验证及测试等环节,另含环境依赖清单与运行说明文档,整体压缩包仅8KB,轻量易部署。目前已有314人浏览学习,适合希望通过实际项目掌握ResNet应用、PyTorch数据流构建及情感计算任务的读者。通过这份资料,可获得完整数据集下载地址、分模块代码逻辑与运行指引,方便复现实验或基于此进行学术扩展与课程设计二次开发。
1. 抑郁症诊断系统:先从AVEC2014数据集和ResNet源码包说起
做深度学习落地的人,很多都盯着医学影像、情感计算这类方向,但真正敢把项目跑通的人并不多。原因很简单:数据难拿、预处理繁琐、模型训练黑匣子,一个环节卡住就翻车。这套基于AVEC2014数据集和ResNet网络的抑郁症诊断系统,正是解决这个痛点的完整源码包。它把视频数据变成人脸帧,再用MTCNN对齐裁剪,最后丢给ResNet做分类识别,整条管线是通的,不是那种只有模型没有数据的半成品。适合正在做情感计算、心理健康AI方向毕设或课题的研究生,也适合想快速上手人脸视频分类的工程开发者。源码里有完整的预处理、训练、验证、测试脚本,还有TensorBoard可视化记录,照着跑就能复现实验流程。下面我把每个文件的职责、参数设置和踩坑点拆开讲。
2. AVEC2014数据集:理解标签结构和预处理逻辑
AVEC2014是音视觉情感挑战赛使用的抑郁程度评估数据集,录制的是被试者与人机交互时的视频,每个受试者对应一个抑郁严重程度评分。这个项目的预处理方式很有代表性:不是用整段视频训练,而是按固定间隔抽帧,再做人脸对齐裁剪,把视频问题转化成图像分类问题。这种思路避免了视频模型带来的计算量爆炸,也让ResNet这类成熟CNN可以直接迁移使用。
2.1 标签文件的生成与合并细节
项目里preprocess.py的generate_label_file()函数,负责把原始评分数据合并成一个统一的CSV文件。原始数据集中视频文件和得分表是分离的,一个视频对应一个评分值,但文件名和评分之间的对应关系往往需要手动整理。这个函数做的就是自动匹配:扫描视频文件夹,提取视频文件名,再根据文件名去查找对应的情绪评分,最终合并成label.csv。
def generate_label_file(): # 假设原始评分表是 train_label.csv,包含 Participant_ID 和 PHQ_8 两列 label_df = pd.read_csv('train_label.csv') video_files = os.listdir('train_videos/') records = [] for vf in video_files: # 从视频文件名中提取参与者ID pid = vf.split('_')[0] # 在评分表中查找对应评分 score = label_df.loc[label_df['Participant_ID'] == int(pid), 'PHQ_8'].values if len(score) > 0: records.append({'video_name': vf, 'label': score[0]}) result = pd.DataFrame(records) result.to_csv('label.csv', index=False)这段代码的核心逻辑是按文件名匹配ID再关联评分。实际中有个坑:原始AVEC2014训练集是视频和临床评分分开存放的,而且不同来源的文件名格式不一致,比如有的叫101_1.mp4,有的叫101.mp4。代码里的split('_')[0]不一定能正确提取ID,需要先人工确认命名规律再写匹配规则。我这里给的版本是常见做法,实际要按你下载的数据集结构调整。
标签归一化是另一个关键点。AVEC2014的PHQ-8评分范围是0到24,如果直接作为分类标签,样本分布会很不均衡。很多复现项目会把评分按阈值二值化,比如大于等于10算抑郁阳性,小于10算阴性。这个阈值选择直接决定任务难度:阈值越高,阳性样本越少,模型越容易偏向多数类。
2.2 视频抽帧策略:100帧间隔采样原理
get_img()函数负责从每个视频中抽取固定数量的帧。项目设定每个视频取100到105帧,不是均匀抽取,而是按照时间间隔来。这么做的原因有两个:一是AVEC2014视频时长不一致,短的几十秒,长的三分钟,统一帧数才能让batch维度固定;二是连续帧之间高度相似,如果随机全抽会造成冗余,模型学不到多样化的面部特征。
def get_img(video_path, save_dir, num_frames=100): cap = cv2.VideoCapture(video_path) total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) interval = max(total_frames // num_frames, 1) count = 0 saved = 0 while True: ret, frame = cap.read() if not ret: break if count % interval == 0 and saved < num_frames: cv2.imwrite(os.path.join(save_dir, f'frame_{saved:04d}.jpg'), frame) saved += 1 count += 1 cap.release()核心逻辑是计算采样间隔interval,保证最终抽出的帧数不超过100。这里有个取舍:interval取整会丢失尾部帧,如果视频很短,比如只有50帧,那么interval=1,实际上全部帧都被抽出来了,依然小于100。这会导致数据不平衡——长的视频和短的视频采样密度完全不一样。我一般会在抽帧后检查每个视频的实际帧数,低于80帧的样本直接剔除或做数据增强补齐。
抽帧后的图片分辨率也需要注意。原始视频帧分辨率通常在480x640左右,但直接拿这个喂给ResNet会浪费大量显存,而且人脸区域占比很小。所以项目把抽帧和人脸检测串联起来,先用get_img抽帧,再对每一帧做人脸提取。
2.3 MTCNN人脸对齐:为什么需要裁剪而不是整帧输入
get_face()函数是这个预处理流程的核心。它使用MTCNN检测每帧图片中的人脸位置,然后裁剪人脸区域并保存。MTCNN全称是Multi-task Cascaded Convolutional Networks,它同时输出人脸框、关键点坐标(眼睛、鼻子、嘴角共5个点),能做人脸检测和对齐。
from mtcnn import MTCNN detector = MTCNN() def get_face(frame, save_path, target_size=224): result = detector.detect_faces(frame) if len(result) == 0: return False # 未检测到人脸,跳过该帧 x, y, w, h = result[0]['box'] # 扩大边界框,避免截掉下巴和额头 x = max(0, x - int(0.1 * w)) y = max(0, y - int(0.1 * h)) w = w + int(0.2 * w) h = h + int(0.2 * h) face = frame[y:y+h, x:x+w] face = cv2.resize(face, (target_size, target_size)) cv2.imwrite(save_path, face) return TrueMTCNN的detect_faces返回的是一个列表,每个元素是一个字典,包含box和keypoints。这里只用了box做裁剪,没有用关键点做仿射变换对齐。严格来说,为了消除头部姿态差异,应该用眼睛坐标把图像旋转到水平。但作为基线系统,只裁剪不旋转也能跑出不错的效果,因为ResNet对平移和轻微旋转变换有一定鲁棒性。如果你想提点,可以加上关键点对齐步骤。
人脸检测失败的情况必须处理。实际运行中,会有少量帧检测不到人脸,原因可能是画面模糊、头部偏转角度过大或者光照极差。代码里直接返回False跳过这一帧,意味着这个视频的实际帧数会少于100。数据管线的兼容性要求dataset.py能处理不定长的样本列表。
3. 数据加载与模型结构:从Dataset类到ResNet改造
预处理完成后,得到的是大量人脸图片文件和对应的标签CSV。接下来需要把这些离散文件组织成PyTorch能高效读取的数据集。这一章拆解load_data.py、dataset.py和model.py三个文件的配合逻辑。
3.1 load_data.py和dataset.py:把路径和标签配对
load_data.py做的事情是扫描图片目录,生成一个包含所有图片路径及其对应标签的列表。dataset.py则继承torch.utils.data.Dataset,实现__getitem__和__len__两个抽象方法,让数据可以被DataLoader并行加载。
# load_data.py def load_img_paths_and_labels(img_dir, label_csv): df = pd.read_csv(label_csv) img_paths = [] labels = [] for video_name in df['video_name']: video_dir = os.path.join(img_dir, video_name.split('.')[0]) if not os.path.exists(video_dir): continue for img_name in sorted(os.listdir(video_dir)): img_path = os.path.join(video_dir, img_name) img_paths.append(img_path) labels.append(df.loc[df['video_name'] == video_name, 'label'].values[0]) return img_paths, labels # dataset.py import torch from torch.utils.data import Dataset from PIL import Image import torchvision.transforms as transforms class DepressionDataset(Dataset): def __init__(self, img_paths, labels, transform=None): self.img_paths = img_paths self.labels = labels self.transform = transform or transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) def __len__(self): return len(self.img_paths) def __getitem__(self, idx): img = Image.open(self.img_paths[idx]).convert('RGB') label = self.labels[idx] img = self.transform(img) return img, torch.tensor(label, dtype=torch.float32)这里的标签没有做二值化处理,dtype=torch.float32说明这是一个回归任务,预测的是PHQ-8具体分数。有一个容易忽视的问题:同一个视频的多帧人脸图片被当成独立样本处理,训练时模型不可能看到完整视频上下文,它学到的只是“某张人脸对应某个分数”的静态映射。这种方式简单但忽略了时序信息,是这类项目的常见妥协。
DataLoader的参数会影响训练效果。batch_size我建议设在32左右,因为每帧都是224x224的RGB图像,显存占用可控。num_workers根据机器CPU核数设定,Windows下建议设为0,否则容易报DataLoader worker错误。
3.2 model.py:ResNet18还是ResNet50
项目使用的骨干网络是ResNet。从工程实践看,这个项目应该用的是预训练的ResNet18或ResNet50,最后接一个全连接层输出预测值。ResNet的核心优势是残差连接——跳过连接让梯度可以直接流过网络深处,解决了深层网络退化问题。
import torch.nn as nn from torchvision import models class DepressionResNet(nn.Module): def __init__(self, num_classes=1, pretrained=True): super(DepressionResNet, self).__init__() # 使用ResNet18,参数量小,不容易过拟合 self.backbone = models.resnet18(pretrained=pretrained) # 替换最后一层全连接,输出单个回归值 in_features = self.backbone.fc.in_features self.backbone.fc = nn.Linear(in_features, num_classes) def forward(self, x): return self.backbone(x)如果只用单张人脸图片判断抑郁程度,ResNet18已经够用了。ResNet50虽然精度上可能略有提升,但在小样本数据集上更容易过拟合,而且训练时间多出一倍。AVEC2014的完整训练集只有几十个视频,抽帧后可能有几千张人脸图片,但本质上这些图片来自不同时刻的同一批人,样本多样性不足。复杂模型在这里反而容易记住训练集的个体特征,泛化能力变差。
回归任务和分类任务的网络头写法不一样。分类会在最后加nn.LogSoftmax然后用交叉熵损失,而这里输出层没有激活函数,配合MSELoss做回归。这是刻意为之——PHQ-8分数是一个连续值,回归更自然。如果你改成二分类,就要把num_classes改成2,损失函数换成CrossEntropyLoss,标签也要先做阈值映射。
3.3 预训练权重的加载与冻结策略
pretrained=True会从torchvision缓存中加载在ImageNet上预训练好的权重。ImageNet训练的模型学习到了丰富的纹理、边缘、形状特征,迁移到人脸任务上能显著加速收敛。但人脸图片和ImageNet的自然图像差距不小,所以一般不建议冻结所有层。
常见的策略分三种:
- 全部参数微调:所有层都参与梯度更新,收敛最快,但小数据集风险高
- 冻结前几层:只微调高层语义特征,能保留底层通用特征
- 分层学习率:骨干网络用小学习率,分类头用大学习率
def setup_optimizer(model, lr=1e-4, backbone_lr_ratio=0.1): backbone_params = [] head_params = [] for name, param in model.named_parameters(): if 'fc' in name: head_params.append(param) else: backbone_params.append(param) optimizer = torch.optim.Adam([ {'params': backbone_params, 'lr': lr * backbone_lr_ratio}, {'params': head_params, 'lr': lr} ], weight_decay=1e-4) return optimizer这个优化器配置的思路是:预训练骨干网络只需要微调,所以学习率缩小10倍;新初始化的全连接层需要从零学起,所以用正常学习率。这是迁移学习的标准做法,比整个网络统一学习率稳定得多。我在实验中遇到过统一学习率设为1e-3时loss直接发散的情况,降级为分层学习率后训练就稳了。
4. 训练流程:train.py、validate.py与TensorBoard监控
有了数据和模型,接下来是训练闭环。train.py负责迭代训练集更新权重,validate.py在验证集上评估当前模型,writer.py把损失曲线写入TensorBoard。这里主要讲训练脚本的核心结构、损失函数选择和验证指标的计算。
4.1 训练主循环与回归损失函数
训练脚本的逻辑很直接:加载数据、定义模型和优化器、循环epoch、每个batch做前向和反向传播、定期保存checkpoint。关键区别在于损失函数的选择——这个项目是回归任务,用MSELoss计算预测分数和真实PHQ-8分数之间的均方误差。
import torch import torch.nn as nn from torch.utils.data import DataLoader from sklearn.metrics import mean_absolute_error import numpy as np def train_one_epoch(model, dataloader, optimizer, criterion, device): model.train() running_loss = 0.0 all_preds = [] all_labels = [] for imgs, labels in dataloader: imgs = imgs.to(device) labels = labels.to(device) optimizer.zero_grad() outputs = model(imgs).squeeze() loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() * imgs.size(0) all_preds.extend(outputs.detach().cpu().numpy()) all_labels.extend(labels.cpu().numpy()) epoch_loss = running_loss / len(dataloader.dataset) epoch_mae = mean_absolute_error(all_labels, all_preds) return epoch_loss, epoch_maeoutputs.squeeze()这行很关键。模型的输出形状是(batch_size, 1),labels形状是(batch_size,),如果不做squeeze,MSELoss会按广播机制计算,意味着每个输出元素和每个标签元素两两比较,得到一个(batch_size, batch_size)的损失矩阵,不仅训练完全乱掉,而且显存消耗巨大。这是新手最容易踩的坑之一。
判断模型好坏要看两部分:训练损失是否持续下降,以及平均绝对误差(MAE)是否达标。MAE的含义是预测分数和真实分数平均差几个单位。PHQ-8评分0-24范围内,MAE在3以下算可用,2以下已经相当不错。如果MAE超过4,基本等于随机猜测,要先检查数据划分是否泄漏。
4.2 学习率调度与早停
学习率是深度学习里最需要“玄学”调节的超参数。固定学习率训练经常遇到这种情况:前几十个epoch损失下降明显,后面loss在小范围内震荡,再也降不下去。这时需要学习率衰减,让优化器在接近最优点时步长变小。
from torch.optim.lr_scheduler import ReduceLROnPlateau scheduler = ReduceLROnPlateau(optimizer, mode='min', factor=0.5, patience=5, verbose=True) for epoch in range(num_epochs): train_loss, train_mae = train_one_epoch(...) val_loss, val_mae = validate(...) scheduler.step(val_loss) # 保存验证集MAE最低的模型 if val_mae < best_mae: best_mae = val_mae torch.save(model.state_dict(), 'best_model.pth')ReduceLROnPlateau的机制是:如果验证loss连续patience个epoch没有改善,就把学习率乘以factor衰减一半。这里mode='min'表示监控的指标越小越好。verbose=True会在学习率变化时打印提示信息,方便观察训练动态。
早停没有在上面代码中写全,但实际训练必须配合。常见做法是设置patience为15到20个epoch,如果验证集MAE连续那么久没有刷新记录,直接终止训练,并把最后一次最优模型载入。AVEC2014这种小数据集,训练50个epoch之内基本就能收敛,没必要跑100个epoch以上,纯属浪费时间。
4.3 writer.py与TensorBoard的监控方法
writer.py的作用是创建SummaryWriter对象,把每个epoch的训练损失写入事件文件,之后通过TensorBoard可视化损失曲线。这比打印日志直观得多,能一眼看出模型是否收敛、是否过拟合。
from torch.utils.tensorboard import SummaryWriter def create_writer(log_dir='runs/depression_experiment'): writer = SummaryWriter(log_dir) return writer def log_metrics(writer, train_loss, val_loss, train_mae, val_mae, epoch): writer.add_scalar('Loss/train', train_loss, epoch) writer.add_scalar('Loss/val', val_loss, epoch) writer.add_scalar('MAE/train', train_mae, epoch) writer.add_scalar('MAE/val', val_mae, epoch)启动TensorBoard的命令是tensorboard --logdir=runs,然后在浏览器打开默认端口6006的页面。训练过程中可以实时刷新看到训练loss和验证loss两条曲线的变化。判定过拟合有个直观标准:训练loss持续下降而验证loss在某一点后开始反弹,曲线呈现“开口”状,这时应该停止训练或者加强正则化。
TensorBoard还可以用来比较不同实验配置。我的习惯是每次调整参数都换一个log_dir,比如runs/lr_1e4和runs/lr_1e5,最后在TensorBoard里同时选中两个目录就能叠加对比。这比手动记日志高效得多。
5. 训练避坑:人脸数据管线最容易踩的四个常见问题
小数据集训练本身就够让人头疼了,数据管线的隐性bug还会火上浇油。下面是这个项目最容易翻车的四类问题,每一条都来自实际操作中的血泪经验。
5.1 数据泄漏:同一视频的帧同时出现在训练和验证集
现象是验证集MAE异常低,甚至逼近0,但换到新数据上预测效果很差。
原因在于划分数据集时直接对图片列表做随机train_test_split,同一个视频抽出的100帧被分到了两个集合里。模型在训练时已经“见过”了来自这些帧的人脸身份,验证阶段等于开卷考试。
解决方法是按视频粒度划分,保证一个视频的全部帧只出现在训练集或验证集中。我一般先把视频名去重,再对视频名列表划分,最后根据视频名映射回图片路径。
5.2 MTCNN检测不到人脸导致样本不均衡
现象是训练集每个epoch的batch大小不固定,训练到一半报错说最后一个batch不足,或者某些视频有效帧数特别少,模型学不到信息。
原因是MTCNN在低分辨率、极端角度、遮挡情况下会漏检,导致抽帧后的图片被丢弃。
解决办法是在预处理阶段统计每个视频的有效帧数,低于50帧的视频可以降低阈值重跑MTCNN,也可以把这部分视频改用整帧输入不裁剪人脸。另一个思路是把检测阈值从默认的0.9降低到0.7,会多检出一些人脸,但也会引入更多误检框。
5.3 MSELoss在回归任务上的梯度异常
现象是训练前几个batch的loss非常大(上百万),随后变成NaN,训练中断。
原因是回归任务的标签范围是0到24,但预训练模型最后全连接层是随机初始化的,初始预测值可能非常大。如果batch里同时存在极端预测值,MSE损失的梯度会爆炸。
解决方法是先把标签归一化到0到1区间,即label / 24,让模型输出压缩到这个范围内。另一个方案是使用HuberLoss替代MSELoss,它对异常值不那么敏感,梯度不会无限增大。我从那以后做回归任务默认用HuberLoss,省了很多调试时间。
5.4 灰度图和RGB图的通道错位
现象是模型能跑通但精度极差,训练loss下降缓慢。
原因是部分视频帧是灰度图,Image.open默认读成单通道,但ResNet的第一层卷积期望3通道输入。PyTorch的ToTensor()会把单通道变成形状(1, H, W),和预训练模型的(3, H, W)不匹配。虽然运行时不会报错——因为后面如果直接resize到224可能触发隐式广播——但语义完全错误。
解决方法是在__getitem__里强制convert('RGB')。另一个隐蔽情况是图片虽然后缀是.jpg,但实际编码是灰度JPEG,用cv2.imread读取后只返回二维数组,处理逻辑里要做一次维度检查。
6. 模型验证与进阶:从静态帧预测到时间序列特征融合
训练完成的模型在验证集和测试集上如何评估,决定这个系统能不能实际使用。同时可以对这个基线方案做改进,比如引入时间维度特征。
6.1 测试集评估与模型可解释性分析
test.py用于加载训练好的模型权重,在测试集上做预测并输出评估指标。和训练逻辑不同,测试阶段不需要梯度计算,也不用计算损失反向传播,只需要前向推理。
def test(model, dataloader, device): model.eval() predictions = [] ground_truth = [] with torch.no_grad(): for imgs, labels in dataloader: imgs = imgs.to(device) outputs = model(imgs).squeeze() predictions.extend(outputs.cpu().numpy()) ground_truth.extend(labels.numpy()) mae = mean_absolute_error(ground_truth, predictions) # 计算相关系数,衡量预测和真实值的线性相关程度 corr = np.corrcoef(ground_truth, predictions)[0, 1] print(f'Test MAE: {mae:.4f}, Correlation: {corr:.4f}')MAE和相关系数是两个不同维度的指标。MAE衡量绝对误差,相关系数衡量排序一致性。如果一个模型的MAE是2.5但相关系数只有0.3,说明预测的绝对误差还可以但排名相关性差,模型学到的是某种全局偏移规律。理想情况下两者都表现好,相关系数到0.7以上说明模型的预测和真实评分存在明显线性关联。
还可以做误差分布分析:预测误差在哪些分数段最大。我的经验是PHQ-8评分在10到15之间(中度抑郁)的样本最难预测,低分和高分样本相对容易。这是因为中间段视频中面部表情差异不如极端情况明显,模型区分度不够。
6.2 进阶方向:视频帧融合与时序信息利用
当前模型把每帧图像看成独立样本,丢弃了时间上下文。一个自然的改进是:对同一个视频的100帧分别提取特征,然后取平均或加权融合,作为该视频的最终表示。
def predict_video_level(model, frame_paths, device): model.eval() features = [] with torch.no_grad(): for frame_path in frame_paths: img = Image.open(frame_path).convert('RGB') img_tensor = transform(img).unsqueeze(0).to(device) feat = model.backbone(img_tensor) # 提取倒数第二层的特征 features.append(feat) # 特征平均池化 video_feat = torch.mean(torch.stack(features), dim=0) # 通过分类头得到最终预测 prediction = model.backbone.fc(video_feat) return prediction.item()这种帧级别特征平均的方法,相比单帧预测有两个优势。一是对短时表情抖动更鲁棒,某一帧因为光照或遮挡导致的异常特征会被平均掉;二是输出结果对应该视频全体信息而非某一时刻,更符合临床评估场景。缺点是推理时间增加100倍,但对于离线分析来说完全可以接受。
6.3 消融实验:你该关注哪些配置组合
复现这个项目时,我强烈建议做一轮消融实验,确认每个模块的贡献。常见的实验组合有:
| 配置变量 | 基线设置 | 对比设置 |
|---|---|---|
| 骨干网络 | ResNet18 | ResNet50 |
| 输入类型 | 人脸裁剪图 | 整帧原图 |
| 帧采样数 | 100帧 | 50帧 / 200帧 |
| 损失函数 | MSELoss | HuberLoss |
| 学习率 | 1e-4 | 5e-5 / 1e-3 |
消融实验的意义不是证明哪个最好,而是搞清楚每个变量对结果的敏感度。我在做这个项目时发现,输入类型从整帧换成对齐人脸后,MAE下降了将近1.5个点,而ResNet18换ResNet50只提升了0.2个点但训练时间翻倍。这种结论只有自己跑过才知道,读论文很难获得这种体感。
做消融实验有个纪律:一次只改一个变量,其余配置保持一致。如果同时换了骨干网络和损失函数,最后效果提升不知道归因于哪个改动,等于白做。从那以后我每次调整配置都强制走一遍完整训练流程并记录所有指标,模型权重文件名带上实验编号,避免事后混淆。这个习惯让我的复现工作省掉了大量重复劳动,希望帮到你。
本文还有配套的精品资源,点击获取