简介面向Python期末大作业场景这份压缩包给出了基于ResNet完成人脸表情识别的完整工程适合正在学习深度学习与图像识别、需要参考完整项目来搭建或完善课程设计的学生。压缩包共16个文件大小约5.2MB包含3个Python脚本模型定义、训练、测试及混淆矩阵绘制、7张示例表情图片、依赖清单、说明文档以及一个mp4演示视频结构清晰便于按模块对照学习。已有235人浏览下载。项目覆盖图像预处理、ResNet残差网络构建、FER类数据集准备与增强、损失函数与优化器选择、训练及验证评估、混淆矩阵可视化等关键环节同时提供基于OpenCV和Haar级联分类器的人脸检测与实时表情识别演示。借助这份资料既能快速理解从数据到模型再到推理的完整流程也能直接修改代码、复用模块或扩展为自己的期末项目。1. 期末大作业选 ResNet 人脸表情识别能跑通、能解释、能交出成绩的完整链路学生党最常遇到的课题就是标题里这种“python期末大作业基于ResNet的人脸表情识别.zip”下载、解压、跑通、改几个参数、贴几张曲线图、写报告交差。这个标题能成为搜索热词背后是刚需——深度学习课设期末既要证明你看懂了网络结构又要在答辩时让模型当场动起来。ResNet 是 CNN 里性价比最高的基线七类表情识别又是数据规模不大、任务难度适中、可视化效果好的落地点两者组合恰好能撑起一份完整的期末答卷。适合没有 GPU 集群、只有一台笔记本的本科生也适合想快速对比 ResNet 与 ViT 的入门研究者。先给结论这种作业的正确姿势不是手写一个卷积网络从头训练而是加载 ImageNet 预训练权重做微调把功夫花在数据切分、标签噪声处理和训练参数上。这篇文章把这条链路拆开讲从 ResNet 选型到 zip 交付每一步都给可抄的参数和会踩的坑。2. ResNet 在人脸表情识别里的角色为什么默认选它而不是 VGG 或 Swin2.1 退化问题与残差连接为什么表情识别用很深的网络反而会输ResNet 的核心贡献不是把网络做得更深而是让加深不再帮倒忙。2015 年之前VGG 一路堆到 19 层再往下加深连训练集准确率都会先饱和再下降这叫退化问题degradation。它不是梯度消失因为 BatchNorm 已经把梯度流的问题压住了真正的瓶颈在于优化深层网络的解空间更大但梯度在深层路径上更难找到恒等映射。ResNet 在每个残差块里加了一条恒等映射输入 x 直接跳到输出端块内只学习残差 F(x)。这样一来深层网络的优化空间里永远包含浅层解20 层至少不会比 10 层差。放在人脸表情识别上你大概率只需要分 7 类数据量从几千张到几万张不等网络深到 50 层以上收益非常有限。答辩时经常被问为什么不加 FPN、位置编码或者自注意力回答口径很明确——这些组件是为多尺度目标检测和序列建模准备的单张裁剪好的人脸做整图七分类浅层的粗粒度边缘特征加上深层的细粒度语义特征已经够用。ResNet 自带的 BatchNorm 也能容纳更大的学习率抖动比 VGG 好调得多。踩过的坑是有人拿 VGG 那套 0.01 初始学习率直接训 ResNetloss 直接飞到 NaN这个在第 4 章会展开。2.2 用 torchvision 加载 ResNet18 的最小实现先给最小可跑代码替换最后的全连接层把 1000 类 ImageNet 分类头换成 7 类表情头。import torch.nn as nn import torchvision.models as models model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) # 老版本 torchvision 用 pretrainedTrue新版本推荐 weights 枚举避免后续版本警告 num_classes 7 model.fc nn.Linear(model.fc.in_features, num_classes) print(model.fc)关键点有两个。第一weightsmodels.ResNet18_Weights.IMAGENET1K_V1会当场从 torchvision 的权重服务器下载约 44MB 的预训练权重到缓存目录离线机器必须先手动放好权重文件否则代码会卡在下载阶段。第二替换model.fc时用model.fc.in_features而不是写死 512这样把 ResNet18 换成 ResNet34/50 时代码不用改。替换后新的 fc 层是随机初始化的backbone 保持 ImageNet 预训练状态这为下一节的两阶段训练留好了接口。2.3 ResNet18 / 34 / 50 怎么选期末作业要算算力账选型不是越深越好要看你的硬件和交作业周期。下面这张表是我在笔记本 CPU 上跑 224x224 单张图片的大致耗时只做量级参考。模型参数量单张 CPU 推理耗时(近似)期末作业适合度ResNet18约 11.7M约 30ms首选CPU 可训练ResNet34约 21.3M约 60ms数据够多时可换ResNet50约 25.6M约 100ms有 GPU 且想刷分再上如果只有一台笔记本 CPUResNet18 是唯一不折腾的选择。期末作业要求的是可复现不是刷榜。ResNet50 用了 Bottleneck 结构参数量没有翻倍但计算量明显更大在小数据集上还更容易过拟合。我一般会把 ResNet18 的 backbone 冻住先训 10 个 epoch再解冻全模型微调最后测试准确率能到 70% 上下fer2013 数据集上这个表现已经足够写进报告。如果你想在报告中体现思考和对比可以加一张 ResNet18 与 ResNet50 的收敛曲线对比理由就写计算资源受限18 在验证集上不输 50。3. 数据集与预处理fer2013 的 CSV 解析、ImageNet 归一化与增强参数3.1 表情数据集三选一标签噪声比数据集大小更致命人脸表情识别有几个公开数据集但它们的坑完全不在一个维度上。fer2013 约 3.5 万张 48x48 灰度图Kaggle 和官网都能直接下载期末最常用RAF-DB 约 1.5 万张彩色图标注质量更高但要填申请表单CK 只有几百段受试者序列数据量小且极易造成数据泄漏。我先给一张对比表。数据集规模通道/分辨率获取难度主要问题fer2013约 3.5 万张灰度 / 48x48低直接下标签噪声大disgust 类只有几百张RAF-DB约 1.5 万张RGB / 约100x100中需申请申请要等审核CK数百段序列RGB或灰度中需申请按人切分否则泄漏严重选 fer2013 还是 RAF-DB取决于老师对报告数据的预期。fer2013 最容易拿到论文引用也多但因为标签噪声70% 左右的准确率就是合理上限RAF-DB 标签可靠认真微调能上 85%但要提前两周申请。期末作业如果时间只有一周老老实实选 fer2013。这个决定能让你的数据准备阶段省下大量和邮箱、申请表单搏斗的时间。3.2 灰度图归一化参数为什么不建议改用 0.5 均值表情数据集里很大一部分是灰度图而 ResNet 预训练权重的第一个卷积层统计来自 ImageNet 的 RGB 三通道。常见的兼容做法是灰度图按 L 模式读出来再.convert(RGB)把同一个像素点复制到三个通道归一化继续用 ImageNet 的mean[0.485, 0.456, 0.406]、std[0.229, 0.224, 0.225]。这是迁移学习里最稳的组合。不要在灰度图上换成 0.5 均值那一套除非你打算放弃预训练权重从头训练。原因在于预训练模型的 BatchNorm 统计量已经记住了 ImageNet 的分布输入分布偏太多第一层卷积输出就会整体偏移后续 BN 层会花大量 epoch 去重新适应。换个说法你想用巨人肩膀就得接受巨人的尺码。如果是纯灰度图还要注意torchvision.transforms.Grayscale(num_output_channels3)只能用于非 L 模式的输入直接读 fer2013 的像素数组时手动 convert 更可控。3.3 自定义 Dataset 类解析 fer2013 的 CSV 并输出标准张量fer2013 的原始文件是一个 CSV每一行是emotion,pixels,Usage其中 pixels 是 2304 个 0 到 255 的整数用空格分隔。我通常写一个 Dataset 类在内存里完成解析尽量避免把 CSV 先转成图片目录再读取省掉中间步骤。import numpy as np import torch from PIL import Image from torch.utils.data import Dataset class Fer2013Dataset(Dataset): def __init__(self, csv_path, transformNone): # 跳过表头直接按行读避免 pandas 在大文件上的额外开销 lines open(csv_path, encodingutf-8).read().strip().split(\n)[1:] self.lines lines self.transform transform def __len__(self): return len(self.lines) def __getitem__(self, idx): parts self.lines[idx].split(,) label int(parts[0]) # emotion 在第一个字段 pixels np.array(parts[1].split(), dtypenp.uint8) img Image.fromarray(pixels.reshape(48, 48), modeL).convert(RGB) if self.transform is not None: img self.transform(img) return img, torch.tensor(label, dtypetorch.long)这个类把 48x48 的灰度数组转成三通道 PIL 图片再交给 transform 做后续处理。改换 RAF-DB 时只需要替换__getitem__里读图的方式其他接口完全一致。配合下面的训练集增强参数就能喂给 ResNet18。import torchvision.transforms as T IMAGENET_MEAN [0.485, 0.456, 0.406] IMAGENET_STD [0.229, 0.224, 0.225] train_transform T.Compose([ T.Resize((256, 256)), T.RandomCrop(224), T.RandomHorizontalFlip(p0.5), T.RandomRotation(10), T.ToTensor(), T.Normalize(IMAGENET_MEAN, IMAGENET_STD), ]) test_transform T.Compose([ T.Resize((224, 224)), T.ToTensor(), T.Normalize(IMAGENET_MEAN, IMAGENET_STD), ])这四个增强参数是经过权衡的Resize 到 256 再 RandomCrop 到 224给模型一点上下文抖动防止把图片边缘当成特征水平翻转对人脸是对称操作基本无损RandomRotation 只给 10 度超过 15 度就会制造出奇怪的歪脸样本最后归一化必须和预训练一致。不要去加 CutOut、MixUp 这类花活期末答辩时解释不清小数据集上也容易掉点。3.4 划分数据集的底线按人切开别按图片随机切fer2013 官方已经给了 train/val/test 的划分标记直接用即可。但如果换成 RAF-DB、CK 这类带受试者编号的数据集必须按人分组再划分。这是表情识别里面最典型的血泪经验同一受试者的不同帧高度相关按图片随机切分会把同一个人的脸同时塞进训练集和测试集模型相当于开卷考试测试准确率虚高 8 到 10 个点。解决方法是先按 subject_id 聚合再用sklearn.model_selection.GroupShuffleSplit切分保证测试集里的人脸从未在训练里出现过。4. 训练全流程两阶段微调的参数设定从冻结到解冻4.1 两阶段训练法先只训新头再全量微调直接把预训练模型扔进优化器全量训练在小数据集上很容易把 backbone 学到的通用特征冲掉尤其当标签噪声存在时前几个 epoch 就会过拟合训练集。常见做法是分两阶段。第一阶段冻结 backbone只训练替换出来的 fc 层让新分类头先适应表情特征的分布。第二阶段解冻全部参数用低一到两个数量级的学习率做微调。# 第一阶段冻结 backbone只训练 fc 层 for name, param in model.named_parameters(): if fc not in name: param.requires_grad False optimizer torch.optim.AdamW( filter(lambda p: p.requires_grad, model.parameters()), lr1e-3, weight_decay5e-4 ) # 训练 8 到 10 个 epoch 后进入第二阶段 for name, param in model.named_parameters(): param.requires_grad True optimizer torch.optim.AdamW( model.parameters(), lr5e-5, weight_decay5e-4 )两个学习率差着 20 倍。原因是替换出来的 fc 是随机初始化需要 1e-3 这样的偏大学习率快速收敛backbone 已经预训练过5e-5 只是让它缓慢适应人脸表情分布防止破坏原有的空间结构。如果你用 SGD 而不是 AdamW学习率要整体调大一些SGD 在这个任务上对学习率更敏感。4.2 类别不均衡与损失函数给厌恶类一点话语权fer2013 的七个类别数量悬殊happy 有近九千张disgust 只有几百张。直接拿默认的 CrossEntropyLoss 训练模型会把所有不确定样本都推向样本多的类别。常见的处理方式是算类别权重把少数类的 loss 放大。import numpy as np import torch.nn as nn # labels 是训练集全部标签类别顺序按 fer2013 官方0 angry, 1 disgust, ... class_counts np.bincount(labels, minlength7) weights 1.0 / class_counts.astype(np.float32) weights weights / weights.sum() * 7 # 归一化到均值 1 criterion nn.CrossEntropyLoss( weighttorch.from_numpy(weights).float(), label_smoothing0.1 )label_smoothing0.1是 PyTorch 1.10 之后集成进 CrossEntropyLoss 的参数它把 one-hot 标签往均匀分布方向平滑了一点能缓解标签噪声。注意如果某个类权重被放大太多模型也会在验证集上多预测该类导致整体准确率不升反降。如果发现 disgust 的召回上去了但其他类崩了就把权重做一次平方根压缩让类别差异没那么极端。4.3 训练主循环与 BN 模式切换评估时必须切 evalResNet 的 BatchNorm 在训练和推理时的行为完全不同训练时用当前 batch 的统计量推理时用累计的全局统计量。忘掉model.eval()是这类项目最常见的低级翻车训练准确率很高测试准确率却上不去。下面这个函数把训练和验证统一成一份代码。def run_epoch(model, loader, criterion, optimizerNone, devicecpu): training optimizer is not None model.train(training) # True 进入训练模式False 进入 eval 模式 total_loss, correct, total 0.0, 0, 0 with torch.set_grad_enabled(training): for x, y in loader: x, y x.to(device), y.to(device) logits model(x) loss criterion(logits, y) if training: optimizer.zero_grad() loss.backward() optimizer.step() pred logits.argmax(dim1) total_loss loss.item() * x.size(0) correct (pred y).sum().item() total y.size(0) return total_loss / total, correct / total调用方式很直接训练时传optimizer验证时传None。model.train(False)等价于model.eval()同时做 BN 统计量切换和 Dropout 关闭。torch.set_grad_enabled(training)则保证推理阶段不建计算图内存占用大幅下降。4.4 checkpoint 保存只存 state_dict不要让模型文件膨胀交作业前最后一步是保存模型我建议只保存state_dict而不是整个model对象。整个模型会把类定义、Python 版本信息一起序列化换台机器或换 torchvision 版本就可能加载失败。state_dict 只是一个参数映射兼容性好文件也更小。torch.save({ epoch: epoch, model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), best_acc: best_acc, }, checkpoint.pt)加载时用model.load_state_dict(torch.load(checkpoint.pt)[model_state_dict])即可。一个 ResNet18 的 state_dict 约 45MBzip压缩后能到 40MB 以内。如果老师限制了作业包大小可以把模型文件单独压缩或者去掉最后一轮只保留最佳轮次。这里也顺带解释标题里的.zip整个项目目录打包交付数据和 checkpoint 要不要打进去看老师要求一般只交代码加一个 README。4.5 参数速查表期末作业直接照抄这一套参数阶段一阶段二说明优化器AdamWAdamWSGD 也行但要调大 lr学习率1e-35e-5全部解冻后必须降 lrbatch_size3232CPU 跑就降到 16epoch1015看验证集准确率是否还在涨学习率衰减无CosineAnnealing阶段二用余弦衰减收尾weight_decay5e-45e-4防止全量微调过拟合这套参数在 fer2013 上用 ResNet18 能做到验证集 68% 到 72%。如果你的验证集准确率低于 65%先不要怀疑参数回头检查数据预处理是不是把灰度图归一化写错了。5. 避坑现场数据泄漏、zip 伪加密与参数翻车的五条排查记录5.1 数据泄漏测试准确率比训练还高先查是不是同一个人进了两边现象训练迭代中验证集准确率一直 95% 以上比训练集还高答辩时被老师追问却说不出理由。原因用了 CK、RAF-DB 这类按受试者采集的数据集却没有按人切分。同一个人的不同表情帧被随机分到了训练集和测试集模型见过这个人的脸测试当然接近开卷。解决先按 subject_id 分组用GroupShuffleSplit做切分保证一个人只能出现在一个集合里。切分代码就一行GroupShuffleSplit(n_splits1, test_size0.2).split(X, y, groupssubjects)。如果数据集本身不带 subject 字段至少加固定随机种子再切分并在报告里写明划分方式。5.2 归一化错误导致 loss 不降灰度图复制通道时把 mean/std 写反现象训练 loss 从第一个 epoch 开始就在 1.9 附近徘徊降不下去预测结果几乎全是 happy 类。原因灰度图没有转成三通道或者归一化用了mean[0.5,0.5,0.5], std[0.5,0.5,0.5]。预训练 ResNet 的 BN 统计量是在 ImageNet 彩色分布下算出来的输入分布偏太多第一层卷积输出直接偏移到激活函数的饱和区。解决Image.fromarray(pixels, modeL).convert(RGB)保证三通道Normalize严格使用 ImageNet 参数。另外把输入数据打印一眼print(x.min(), x.max())Normalize 之后应该是负数到正数的大致分布而不是 0 到 255。5.3 zip 伪加密和中文目录老师机器解压打不开项目路径全是乱码现象从网盘或群里拖回来的同名工程包双击解压提示输入密码明明分享者说没有密码解压成功后运行python train.py又报文件不存在。原因第一种是 zip 伪加密文件的加密标志位被置 1 但内容并没有真正加密常见于某些压缩工具或从 CTF 资源站流出的包第二种是制作 zip 时中文文件名编码混乱Windows 自带解压器按 GBK 解出来一串乱码目录代码里的相对路径自然对不上。解决先用 Python 检查加密标志位确认是不是伪加密。import zipfile with zipfile.ZipFile(project.zip) as zf: for info in zf.infolist(): if info.flag_bits 0x1: print(f[加密标志] {info.filename} flag0x{info.flag_bits:04x})如果只是加密标志位为 1 但内容未加密用 7-Zip 直接打开通常能解压。更省事的办法把工程解压后立刻放到纯英文路径例如C:\emotion\目录里不要出现“期末”“作业”这类中文字段。PIL 在 Windows 下读中文路径偶尔会抛OSError: [Errno 22]改成英文路径后该问题直接消失。5.4 Windows 下 DataLoader 卡死与显存 OOM先降 num_workers 再降 batch_size现象在 Windows 上训练num_workers4时程序启动后偶尔直接卡住不动换成num_workers0就正常。另一个场景是 GPU 显存 4GBbatch_size64跑不到两个 step 就CUDA out of memory。原因Windows 的 DataLoader 使用 spawn 方式启动子进程反复创建 worker 时会和 Jupyter 或某些 IDE 的进程模型冲突显存不够则是单张 224x224 三通道图加上 ResNet 中间特征图在 batch 64 时激活值轻松超过 4GB。解决Windows 上无脑num_workers0等代码完全跑通再尝试提到 2显存不够就把 batch_size 降到 16再用torch.cuda.amp混合精度训练显存占用能再降一半。如果还在用 10 系列老显卡混合精度收益有限优先降 batch。5.5 随机种子未固定导致三次训练结果差 8%以及 checkpoint 文件交不上现象同一份代码跑三次验证集准确率分别是 68%、72%、64%报告里的数字像是抽奖。临近截止日才发现 checkpoint 有 45MB作业包上限 50MB压缩后勉强压线。原因没有固定 Python、NumPy、PyTorch 三套随机种子DataLoader 的 shuffle 顺序每次都不同checkpoint 又存了多余的优化器状态。解决训练脚本开头固定种子这是给最终报告留的后悔药。import random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed)模型文件缩身的两条路只保留best_acc对应的 checkpoint不保留最后一步用 zip 压缩交付zip -r project.zip project_dir -x *__pycache__* *.pyc。把数据和 checkpoint 排除在外的话代码包通常在几 MB 量级完全够传。6. 交付前最后一小时用混淆矩阵、TTA 和 Grad-CAM 把报告做厚6.1 混淆矩阵找到生气、厌恶、悲伤三个混滑类模型跑完测试集后不要只看总准确率。用sklearn.metrics.confusion_matrix把 7x7 矩阵打印出来重点盯三个负向情绪之间的互相串类。fer2013 里生气和厌恶经常互相标错这是数据集本身的噪声不是模型缺陷。把串得最狠的一对样本图存下来在报告里写“该类错误与标注噪声高度相关”这比空喊调参有力得多。6.2 测试时增强TTA三行代码白捡一个点测试时把图片做一次水平翻转两次预测取平均。水平翻转对人脸表情是几乎无损的对称操作实现成本极低。with torch.no_grad(): prob torch.softmax(model(x), dim1) prob_flip torch.softmax(model(torch.flip(x, dims[3])), dim1) pred ((prob prob_flip) / 2).argmax(dim1)这个技巧能稳定提升 0.5 到 1 个百分点的准确率而且代码短到答辩时一句就能讲完。6.3 Grad-CAM让老师看到模型在看嘴还是看眼睛用torchcam库或者手写十几行 hook把最后一层卷积特征图按梯度加权叠回原图。正常训练好的表情模型热区应该集中在眼部、嘴角和眉间。如果热区打在脸颊背景上说明模型学到的是肤色或裁剪框边缘这种模型换张脸就崩。把 3 到 4 张热力图贴进报告是证明模型没偷懒最直观的素材。我现在做任何模型项目都会留一个 result.md里面固定放混淆矩阵、TTA 前后的准确率、Grad-CAM 样例三样东西。看着可视化做事比盯着 loss 曲线反复猜要可靠得多这套习惯从期末作业一直用到了正式项目里希望帮到你。本文还有配套的精品资源点击获取