
简介基于Pytorch框架的RAF-DB数据集人脸情绪识别项目面向计算机相关专业毕设、课程设计与深度学习入门进阶人群。资源提供完整项目源码、预训练模型下载指引及项目说明文档覆盖数据预处理、模型训练、精度评估与交互式可视化等环节。压缩包共28个文件以16个Python脚本为主另有8个pyc编译文件、2个txt说明和2个md文档整体仅30KB便于快速部署与二次开发。项目已在RAF-DB公开数据集上完成验证训练集准确率约99.999%、测试集准确率约82.226%可作为基线方法直接对比实验。已有405人学习下载适合想复现人脸表情识别流程、撰写毕业设计或扩展DIY功能的开发者参考。1. 从一张 LRW 标签图说起RAFR-DB 上的情绪识别到底在解决什么人脸情绪识别在工程上往往不是“识别表情”而是“识别标签分布”。RAF-DBReal-world Affective Faces Database给每张脸不是唯一硬标签而是一个概率分布单标签之外还附带了对齐过的关键点坐标和遮挡、光照、姿态的多样性注释。很多初学者拿到标题里这个 zip 之后以为训练一个 CNN 就行实际第一个坑就在label文件里——你看到的多数样本是6或3但训练集的真实分布并不是均匀的中性表情占比远高于厌恶和恐惧。Pytorch 的DataLoader默认按索引顺序 shuffle如果不针对这种长尾分布做加权采样最终准确率会虚高但混淆矩阵惨不忍睹。这个项目标题里“源码模型项目说明”的组合说明它不只是一个算法 Demo而是一个能提交毕业设计的完整工程需要数据加载、训练、验证、可视化、导出模型推理五个环节都跑得通。对于做毕设或者刚接触深度学习的从业者你真正要学的是把“数据集读取”和“模型训练”拆成两个独立模块然后用一个可复现的配置脚本把它们粘起来。Pytorch 的生态最不缺的就是模型定义和训练循环模板缺的是对 RAF-DB 这类真实场景数据集的正确预处理方式。下面我会按照自己搭这类项目的顺序展开先讲明白 RAF-DB 数据组织方式和标签读取再给出一个完整的 Pytorch 训练管线之后是调参与排错最后收在如何把模型效果好这件事验证得让人信服。2. RAF-DB 数据集与 Pytorch 数据管线的搭建要点2.1 RAF-DB 的文件结构与标签读取逻辑RAF-DB 原始压缩包解压后有两个主要目录list_patition_label.txt对应 train/test 划分basic目录存放 aligned 后的图片。它共有 7 类基本表情surprise、fear、disgust、happiness、sadness、anger、neutral。最容易被忽略的是还有第二个标注集compound但毕设通常只用 basic 7 类。读标签时不要直接用str.split()取第一个数字因为行末还会跟一个实数表示标注可信度。常见做法是def load_raf_list(file_path): samples [] with open(file_path, r) as f: for line in f: parts line.strip().split() if len(parts) 2: continue img_name parts[0] label int(parts[1]) - 1 # RAF-DB 标签从 1 开始 samples.append((img_name, label)) return samplesparts[1]是大类标签parts[0]不是完整路径而是相对于basic目录的文件名。注意标签减 1 是因为 Pytorch 的CrossEntropyLoss要求从 0 开始的类别索引。这是最容易出错的地方我第一次跑的时候忘记减 1训练 loss 一直不降检查标签才发现是类别 7 和类别 0 被混淆。RAF-DB 官方推荐使用Image目录下已经对齐的版本对齐后的图片是 100x100 的灰度图但实际训练时可以读成 RGB 或灰度全看预训练模型需求。2.2 图像预处理与数据增强的配置思路RAF-DB 图片尺寸小、人脸居中直接用Resize((112, 112))会损失信息。我一般会做两步先Resize((128, 128))再随机裁剪回112x112这比直接 resize 多一点平移不变性。如果使用 ImageNet 预训练模型需要把灰度图转成三通道并做Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225])。以下是训练与验证应分别使用的 transformfrom torchvision import transforms train_transform transforms.Compose([ transforms.Resize((128, 128)), transforms.RandomCrop(112), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) val_transform transforms.Compose([ transforms.Resize((112, 112)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])RandomCrop这里有个参数细节RAF-DB 图片人脸已经对齐过大的裁剪比例会切掉眉眼区域所以我只用(128, 128)裁剪到112相当于最多切 16 像素边距而不是常见的RandomResizedCrop。RandomHorizontalFlip对 7 类表情基本对称可以放心用。如果你想用灰度图输入Normalize要改成单通道均值和方差并且模型第一层卷积的in_channels要改成 1。2.3 Pytorch Dataset 类与 DataLoader 的权重采样将标签文件与图片目录关联起来需要写一个Dataset子类核心是__getitem__返回(image, label, path)三元组。path看似没用但在分析错误样本时非常有用——你想看哪张图被分错没有路径就得重新查索引。from torch.utils.data import Dataset from PIL import Image import os class RAFDataset(Dataset): def __init__(self, root_dir, list_file, transformNone): self.root_dir root_dir self.transform transform self.samples load_raf_list(list_file) def __len__(self): return len(self.samples) def __getitem__(self, idx): img_name, label self.samples[idx] img_path os.path.join(self.root_dir, img_name) image Image.open(img_path).convert(RGB) if self.transform: image self.transform(image) return image, label, img_path训练时不要直接DataLoader(dataset, shuffleTrue)先用sklearn或numpy统计训练集每个类别的样本数然后构造WeightedRandomSampler。RAF-DB 训练集有 12271 张其中 neutral 大约 3200 张disgust 大约 700 张如果你不做任何处理模型学到的是一个“永远猜 neutral”的 26% 准确率下限精调后对 disgust 的召回可能只有 30%。加权采样的 weight 设为目标类样本数的倒数再归一化代码如下from torch.utils.data.sampler import WeightedRandomSampler import numpy as np def make_weights(labels, num_classes7): class_counts np.bincount(labels, minlengthnum_classes) class_weights 1.0 / class_counts.astype(np.float32) sample_weights class_weights[labels] return torch.from_numpy(sample_weights) weights make_weights(train_labels) sampler WeightedRandomSampler(weights, num_sampleslen(weights), replacementTrue) train_loader DataLoader(train_dataset, batch_size64, samplersampler)WeightedRandomSampler的replacementTrue意味着同一个样本在同一 epoch 可能被抽多次这对小样本类的过拟合有一点副作用但相比不过采样导致的类别遗忘收益更大。我一般搭配TTA测试时增强来抑制重复采样带来的噪声后面第 5 章会讲。3. Pytorch 人脸情绪识别模型构建与训练闭环3.1 为什么从 ResNet18 而不是大模型开始RAF-DB 每张图的尺寸只有 100x100 原始大小输入分辨率摆在那里用 ResNet50 或更深的网络很容易在第一层卷积后就没有足够空间下采样。我推荐先使用 ResNet18 作为基线理由有三预训练权重好找训练速度在单张 1080Ti 上只需要二十分钟一个 epochRAF-DB 类别语义不像 ImageNet 那样丰富深层特征不如浅层特征重要后续换注意力机制时 ResNet18 的中间特征图尺寸更容易观察。不过要注意torchvision.models.resnet18(pretrainedTrue)是针对 224x224 设计的输入 112x112 时最后的全局平均池化仍能工作但第一个 7x7 卷积步长为 2 会扔掉很多空间细节。常见改法是把第一层卷积的stride改成 1去掉后面的maxpool这样 112x112 输入经过 conv1 后仍是 56x56而不是 28x28。改动如下import torch.nn as nn from torchvision import models class RAFResNet(nn.Module): def __init__(self, num_classes7): super().__init__() self.backbone models.resnet18(pretrainedTrue) # 修改第一层以适配小图像 self.backbone.conv1 nn.Conv2d(3, 64, kernel_size7, stride1, padding3, biasFalse) self.backbone.maxpool nn.Identity() self.backbone.fc nn.Sequential( nn.Dropout(0.4), nn.Linear(self.backbone.fc.in_features, num_classes) ) def forward(self, x): return self.backbone(x)把stride1之后第一层感受野变大对 112 输入来说特征图分辨率勉强可用。maxpool直接换成Identity是常规操作因为 56x56 再池化成 28x28 会进一步丢失表情细节。dropout 加在全连接层前RAF-DB 样本量小这一层正则是必要的。3.2 损失函数与优化器的不对称选择交叉熵是默认选择但这里要注意 RAF-DB 的标签本身存在标注噪声某些中性表情与悲伤表情在图片上几乎无法区分。直接硬标签交叉熵会导致模型对边界样本过度自信。我更喜欢用LabelSmoothing版本的交叉熵比如smoothing0.1这能强制模型输出概率不要落到 0 或 1 的极端。Pytorch 1.10 以后可以用torch.nn.CrossEntropyLoss(label_smoothing0.1)直接实现不需要自定义损失。优化器选用 AdamW 而不是 Adam因为 RAF-DB 训练集小Adam 的 L2 正则与权重衰减在不同参数上会有耦合AdamW 的 decoupled weight decay 对超参数变化更稳。训练时推荐用余弦退火学习率import torch.optim as optim optimizer optim.AdamW(model.parameters(), lr1e-4, weight_decay1e-4) scheduler optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max40, eta_min1e-6) criterion nn.CrossEntropyLoss(label_smoothing0.1)T_max设为总 epoch 数的一半让学习率前半程逐渐降至最低后半程重新热身不对CosineAnnealingLR只会单调下降T_max40表示 40 个 epoch 内从lr降到eta_min。这里没有重启所以如果你想跑 80 个 epoch就把T_max80。调参中发现 1e-4 的学习率对 ResNet18 是安全的如果用 AdamW 且 batch size 为 64lr可以直接设 5e-4 但需要配合 warmup 头几个 epoch否则初期 loss 会震荡。3.3 训练循环与验证指标计算的正确姿势训练循环要记录 loss、准确率、以及每个类别的召回率。RAF-DB 的测试集结构比较特殊它有一个list_patition_label.txt里划分好的 test 文件大约 3068 张。验证时不要使用torch.no_grad()里顺便 eval要记得调用model.eval()否则 BatchNorm 的 running stats 仍然会被更新这是很多调了几天 loss 不高但验证准确率上不去的隐形原因。def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, correct 0.0, 0 for images, labels, _ in loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() * images.size(0) pred outputs.argmax(dim1) correct (pred labels).sum().item() return total_loss / len(loader.dataset), correct / len(loader.dataset) def validate(model, loader, criterion, device): model.eval() total_loss, correct 0.0, 0 class_correct [0] * 7 class_total [0] * 7 with torch.no_grad(): for images, labels, _ in loader: images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) total_loss loss.item() * images.size(0) pred outputs.argmax(dim1) correct (pred labels).sum().item() for c in range(7): class_total[c] (labels c).sum().item() class_correct[c] ((pred labels) (labels c)).sum().item() return total_loss / len(loader.dataset), correct / len(loader.dataset), class_correct, class_total注意validate里不能对criterion做任何修改因为它没有参与反向传播但 label smoothing 会根据 batch 内标签动态计算没影响。验证时计算每个类别的召回率是很重要的RAF-DB 测试集里 fear 和 disgust 本来就少只看总准确率 85% 可能 fear 的召回只有 20%这种结果在毕设答辩时一眼就会被看穿。我将 7 个类别的召回率保存在一个 CSV 里方便画混淆矩阵。4. 训练 RAF-DB 时必踩的坑过拟合、类别不均衡与图像对齐4.1 过拟合的判定loss 曲线与验证集合的划分RAF-DB 官方只提供了一次 train/test 划分但很多同学喜欢自己再拆一个 valid 集用于 early stopping。我建议不要动官方 test 集把 train 集的最后 500 张作为验证集或者做 5 折交叉验证。因为官方 test 集是真实场景分布你用它做早停最终报告的准确率会偏高。常见的训练曲线如下训练 loss 一直在降验证 loss 在第 25 个 epoch 附近开始回升这就是过拟合。Pytorch 里可以先每次 epoch 后记录验证 loss然后保存最低 loss 对应的模型。不要只用准确率作为早停指标情绪识别数据不平衡验证准确率可能在第 15 个 epoch 达到峰值保持不动但验证 loss 继续下降。原因是模型把困难样本的置信度调低这从准确率上反映不出来但对最后的模型融合和置信度阈值有帮助。所以我通常同时保存 loss 最低和准确率最高的两个 checkpoint。4.2 类别不均衡的另一种解法焦点损失与加权交叉熵第 2 章的WeightedRandomSampler是从采样角度解决不均衡第 3 章的 label smoothing 是从正则角度这里再补一个损失函数角度。如果你发现加权采样后 disgust 的召回上去了但 happy 的准确率掉得很厉害说明采样过于激进。这时候可以退掉采样改用Focal Loss它对难样本预测概率接近 0.5 的样本施加更大的梯度减弱易分类样本的梯度淹没。Pytorch 实现 Focal Loss 不需要额外依赖继承nn.Module写一个即可import torch.nn.functional as F class FocalLoss(nn.Module): def __init__(self, gamma2.0, alphaNone): super().__init__() self.gamma gamma self.alpha alpha # 长度 7 的权重数组 def forward(self, logits, labels): ce F.cross_entropy(logits, labels, reductionnone) pt torch.exp(-ce) if self.alpha is not None: alpha_t self.alpha[labels] focal alpha_t * (1 - pt) ** self.gamma * ce else: focal (1 - pt) ** self.gamma * ce return focal.mean()实战中gamma2.0会让模型特别关注那些置信度低的样本但 RAF-DB 里某些模糊样本本身噪声很大过度关注会把标注错误也学进去。我一般把 gamma 设为 1.0alpha 按类别样本比例的倒数归一化。如果和WeightedRandomSampler同时用要小心 alpha 和采样权重叠加导致极少数类别训练不稳定。推荐只用一个机制我自己的习惯是先用普通交叉熵配标签平滑跑通整个管线再切换 Focal Loss 做实验对比。4.3 对齐质量对最终准确率的影响RAF-DB 提供的是已经基于 5 个关键点眼睛、鼻子、嘴角对齐后的图片但并非完美。有些图像的嘴唇区域有明显遮挡还有些眼睛方向偏转。如果你的预处理阶段使用RandomCrop裁剪区域可能切掉部分眉毛这对 anger 和 surprise 这类依赖眉毛纹理的情绪是毁灭性的。一个稳妥策略是在训练时做小范围平移而验证时只用中心裁剪。另外我发现直接将图片Resize到 112x112 再送进模型与先Resize到 128 再CenterCrop到 112 相比后者在 RAF-DB 测试集上准确率高约 1.5%。原因是 128 到 112 的缩放后裁剪保留了中心区域更精确的高频信息官方对齐时已经把人脸放在中心所以中心裁剪不会丢关键部位。如果用的是 png 图片而非 jpg注意Image.open(...).convert(RGB)之后是否包含 alpha 通道RAF-DB 原始图片不含透明通道但如果自己爬取扩充数据则要处理。4.4 迁移学习中的 BatchNorm 与冻结策略很多人加载完pretrainedTrue就直接全网络微调对于 RAF-DB 只有 1 万多张图的场景容易在后期过拟合到训练集的背景纹理。我推荐第一阶段冻结 backbone 的conv1到layer3只训练layer4和fc跑 10 个 epoch 后解冻全部参数并将学习率降到原来的十分之一。Pytorch 实现冻结有两种方式requires_grad_(False)或者设置param_group。用后者更方便backbone model.backbone for name, param in backbone.named_parameters(): if layer4 not in name and fc not in name: param.requires_grad False trainable_params [p for p in model.parameters() if p.requires_grad] optimizer optim.AdamW(trainable_params, lr1e-3)注意如果使用model.eval()时 BatchNorm 的running_mean和running_var也会继续更新但冻结参数不影响 BN 统计量的更新。BN 统计量需要在冻结阶段继续更新因为输入分布变化后 BN 的 running stats 会漂移。我建议冻结时不冻结 BN 的affine参数requires_grad只关掉权重和偏置即可BN 层track_running_stats保持True。5. 让毕设更扎实注意力可视化、混淆矩阵与模型导出最后收在三个具体技巧它们不直接提升准确率但能让答辩和后续部署少被刁难。第一是选一个轻量注意力机制加在 ResNet18 的layer3之后比如 SE Block公式简单且 Pytorch 实现只有十几行。RAF-DB 上的公开实验显示 SE 模块能提升 1% 左右但对小样本类别更友好因为通道注意力可以抑制背景通道激活。实现时直接改 forwardclass SEBlock(nn.Module): def __init__(self, channels, reduction16): super().__init__() self.fc nn.Sequential( nn.AdaptiveAvgPool2d((1, 1)), nn.Flatten(), nn.Linear(channels, max(4, channels // reduction)), nn.ReLU(), nn.Linear(max(4, channels // reduction), channels), nn.Sigmoid() ) def forward(self, x): b, c, _, _ x.shape return x * self.fc(x).view(b, c, 1, 1)第二是验证模型可信度时不要只贴准确率一定要输出 7x7 的混淆矩阵。用sklearn.metrics.confusion_matrix直接算保存成matplotlib热力图。同时把预测错误的样本原图、标签、预测结果放在一张大图里用torchvision.utils.make_grid拼起来。这样不仅自己能看到模型分错的是不是标签错误答辩老师也会觉得工程完整。样例代码from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay import matplotlib.pyplot as plt def plot_confusion(y_true, y_pred, save_path): cm confusion_matrix(y_true, y_pred) labels [surprise, fear, disgust, happiness, sadness, anger, neutral] disp ConfusionMatrixDisplay(confusion_matrixcm, display_labelslabels) fig, ax plt.subplots(figsize(8, 8)) disp.plot(axax, colorbarFalse) plt.xticks(rotation45) plt.tight_layout() plt.savefig(save_path, dpi200)第三是导出 TorchScript 或 ONNX 模型方便没有 Pytorch 环境的同学直接推理。导出 ONNX 时注意输入尺寸要固定为 112x112因为你的模型依赖全局平均池化的自适应能力ONNX 导出后动态维度在某些推理引擎中会报错。使用torch.onnx.export(model, dummy_input, raf.onnx, opset_version11, input_names[input], output_names[output])导出后可以从onnxruntime加载验证输出一致性确保两边的数值误差在 1e-5 以内。实际部署时 RAF-DB 的人脸已经对齐你不需要额外的检测框。但如果你想从一张完整图片开始识别需要在模型前加一个人脸检测器比如 OpenCV 的 DNN 人脸检测或 MTCNN然后按检测到的关键点做相似变换对齐到 112x112。这一步不在标题范围内但对“可作毕设”这个定位来说写一个align_and_predict的封装函数会让你的项目说明看起来更完整。最终模型参数保存在best_model.pth别忘了同时保存一个config.json记录 transform 参数和类别索引映射这样换机器复现时才不会因为图片缩放方式不一致而得到完全不同的结果。本文还有配套的精品资源点击获取