简介面向Python课程期末大作业的完整人脸表情识别项目基于ResNet网络实现包含可直接运行的源码、配套图像数据集与详细说明文档适合计算机相关专业学生作为课程设计、毕业设计或自学项目参考。项目经过本地编译与严格调试评审分达到95分以上难度适中助教审定能够满足学习与二次开发需求。压缩包共103个文件、54.11MB涵盖Python源码与编译产物.py/.pyc、预训练模型权重.hdf5、人脸表情图片样本.png/.jpg/.jpeg、项目配置与说明文件.xml/.iml/.md/.txt以及操作演示视频.mp4等目录组织清晰。已有135人学习下载。其中除了完整代码和数据外还提供了可复用的ResNet/Xception等模型权重以及happy、surprised等示例图片和演示GIF便于直观理解表情分类效果说明文档能帮助快速梳理项目结构与参数调优思路是期末作业或入门人脸识别方向的实用素材。1. 期末大作业选ResNet做人脸表情识别先想清楚这三件事“python期末大作业基于ResNet的人脸表情识别源码数据集说明文档.zip”这个标题拆开看就是一个经典组合用ResNet网络做表情分类配套现成的源码、数据集和说明文档。很多人的期末大作业死在“代码能跑但答辩一问就懵”——数据怎么加载的、resnet的残差结构到底解决了什么问题、表情数据集有哪些坑光跑通远远不够。这篇按任务定义、源码拆解、调参、避坑、答辩加分的顺序把整套方案从头讲到能讲清楚适合要交作业的学生也适合第一次接触torchvision和预训练模型的入门者。读完你能复现训练也能把每个参数为什么这么设说明白。2. 人脸表情识别在做什么任务定义、ResNet选型与数据集先验2.1 表情识别本质是图像分类先定你的标签空间拿到这个题目第一件事不是打开代码而是确认你的标签空间。人脸表情识别在公开数据集里几乎是固定的套路CK、FER2013、RAF-DB这些常用数据集绝大多数按七类标注——愤怒、厌恶、恐惧、开心、悲伤、惊讶、中性少部分会把“轻蔑”单独拆出来变成八类。不同数据集的表情类别不完全一致这直接决定你最后的全连接层输出维度是7还是8。我见过不少翻车案例说明文档里写着七类分类代码里num_classes7但数据集目录里实际躺着八个文件夹训练时ImageFolder自动按文件夹名排序生成标签最后一个类别永远没有样本参与训练val_acc却显示正常因为预测时那个类别从来没出现过。所以动手前先把标签分布统计一遍这是最便宜的事故预防。import os from collections import Counter data_root ./data/train # 以你解压后的实际路径为准 label_count Counter() for cls_name in os.listdir(data_root): cls_path os.path.join(data_root, cls_name) if os.path.isdir(cls_path): n len(os.listdir(cls_path)) label_count[cls_name] n for cls, n in label_count.most_common(): print(f{cls}: {n})这个脚本的逻辑很简单列出训练目录下每个文件夹统计每个类别的图片数量。ImageFolder在PyTorch里就是按文件夹名排序后生成从0开始的整数标签所以这里的cls_name排序和训练时的标签索引是严格对应的。如果发现某个类别图片数远少于其他类后面调参时必须加权损失或者做采样否则模型会把稀有表情全部忽略。2.2 ResNet的残差结构如何解决深层网络退化为什么期末作业普遍选ResNet而不是VGG或者更早的AlexNet因为ResNet解决了“越深越差”的反直觉问题。普通卷积网络堆到十几层以上时训练误差不降反升这被称为网络退化注意这不是过拟合过拟合是训练误差低但验证误差高退化是训练误差本身就下不去。原因在于深层网络做反向传播时梯度要逐层连乘回去连乘次数越多梯度越容易消失或爆炸。ResNet的核心改动是引入残差块让某一层的输出从F(x)变成F(x) x其中x是输入本身。这样梯度在反向传播时多了一条“捷径”可以直接穿过若干层传到前面相当于给梯度修了一条高速公路。这条身份映射还有一个工程价值就算F(x)这一支学不到东西网络至少能退化成“直接复制输入”所以深层网络的表现不会比浅层更差。期末答辩被问到“为什么选ResNet”你抓住“解决退化、梯度能直传、加深网络不掉点”这三点就够用了不要背教科书里的定义。2.3 为什么期末场景选ResNet-18而不是ResNet-50或VGG同一个ResNet家族里也分18层、34层、50层、101层期末作业最常见的两个选择是ResNet-18和ResNet-50。选型的核心约束是数据量、算力和训练时间不是“层数越多越高级”。模型参数量单张224x224前向显存占用典型训练耗时小数据集CPU期末答辩观感ResNet-18约11M较低快结构清楚好讲ResNet-34约21M中等中等略冷门ResNet-50约25M较高慢容易被追问“为什么用这么重的模型”VGG16约138M很高很慢参数巨大不好解释表情数据集本身图片量级也就几千到几万张而且普遍是小分辨率灰度图。ResNet-18在数据量不大时已经能把七类表情训到不错的水平训练速度快显存占用低实体机没有独立显卡也能靠CPU熬出来。ResNet-50的优势在ImageNet这种千万级大图数据集上才明显在小数据集上更容易过拟合。如果源码里给的是ResNet-50也不用慌常见做法是加载预训练权重做迁移学习靠ImageNet上学到的通用特征兜底。但你要想清楚一个问题表情识别输入经常是48x48的灰度图像和ImageNet的224x224三通道分布差距很大迁移学习带来的提升没有想象中那么玄学有时甚至不如从零训练。这个我放在第5章细讲。2.4 数据集形态FER2013灰度单通道与自制目录拿到zip先看数据集是什么形态这决定你的数据加载代码。常见有两种形态。一种是FER2013的CSV格式每一行是emotion,pixel,Usagepixel列里是48x482304个灰度值另一种是文件夹形态train/anger/xxx.jpg这种结构也可能是data/生气/xxx.jpg中文目录。两种加载方式完全不同。CSV格式要用pandas或csv读取再把pixel字符串拆成2304个整数转成(48,48)的单通道张量文件夹形态直接用torchvision.datasets.ImageFolder最省事。问题在于FER2013原版是灰度单通道而ImageFolder默认按三通道读图灰度图会被复制成三个通道虽然能跑但会浪费计算量而且如果你用了ImageNet预训练权重模型期望的输入是三通道灰度单通道会因为维度不匹配直接报错。from PIL import Image import numpy as np img_path ./data/train/anger/000001.jpg img Image.open(img_path) print(fmode{img.mode}, size{img.size}) arr np.array(img) print(fndim{arr.ndim}, shape{arr.shape})这段代码检查单张图片的通道数。mode如果是L说明是灰度单通道RGB是三通道。arr.ndim为2表示灰度为3表示彩色。这一步的结论直接决定transforms里要不要加transforms.Grayscale(num_output_channels3)以及要不要在model.conv1上把输入通道从3改成1。很多人的源码跑不通卡点根本不是网络结构而是数据通道数没对齐属于基础但非常隐蔽的坑。3. 把源码跑起来环境、目录与train.py最小可复现3.1 环境准备Python版本、PyTorch与CUDA匹配期末大作业最怕的环境问题是“在自己电脑上跑通到答辩机器上装环境装了两小时”。先说结论Python装3.8到3.10这一段最稳别为了尝鲜装3.13。很多开源源码和依赖库还没跟进新Python版本装包时容易碰一鼻子灰。PyTorch选2.x系列的稳定版即可CPU版本也能训练只是慢。CUDA这块容易踩坑。安装GPU版PyTorch前先在命令行跑一句nvidia-smi看显卡驱动支持的CUDA版本然后去PyTorch官网选对应版本的安装命令。常见错误是显卡驱动是CUDA 11.x却装了要求CUDA 12.x的PyTorch运行时提示找不到libcudart或者torch.cuda.is_available()返回False。conda create -n emo_resnet python3.9 conda activate emo_resnet pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install numpy pandas matplotlib tqdm这里用conda新建了Python 3.9的环境隔离系统Python避免把全局环境搞乱。第二行激活环境后后续的pip install都装在这个独立环境里。PyTorch的安装地址用了cu118意思是CUDA 11.8版本如果你的驱动支持CUDA 12.x把cu118换成cu121之类的对应标识拿不准时先跑nvidia-smi看一眼右上角的版本号。如果只想CPU训练直接pip install torch torchvision即可但训练时间会成倍拉长。装完验证一下在Python里执行import torch; print(torch.cuda.is_available())。输出True才说明GPU可用输出False就老老实实CPU跑或回去检查驱动和PyTorch版本匹配问题。3.2 目录结构这个压缩包里每个部分负责什么绝大多数这类项目的目录组织方式是固定套路解压后大致长这样具体名称以说明文档为准目录/文件职责dataset/或data/存放图片数据集内部按类别分子目录models/或model.pyResNet结构定义也可能是从torchvision引入train.py训练主脚本包含数据加载、模型初始化、训练循环、保存权重predict.py或test.py加载训练好的权重对单张图片做推理requirements.txt依赖包列表说明文档.docx或README.md项目说明、怎么跑、参数含义拿到压缩包第一件事不是直接跑train.py而是打开说明文档确认三个信息数据集放哪个目录、训练入口是哪个脚本、依赖包版本有没有特别要求。缺任何一个盲跑大概率在import阶段就报错。训练产物一般是best_model.pth或model_final.pth这个格式的权重文件里面存的是state_dict也就是模型的参数张量。predict脚本加载它时需要先构造一个和训练时完全相同的模型结构再执行load_state_dict否则会报key不匹配的错误。3.3 数据加载直接改造torchvision内置Dataset这类小项目最常见的实现方式是用torchvision.datasets.ImageFolder它对文件夹结构的数据集几乎是零成本接入。整个数据加载链路里最容易被忽视的是transforms的搭配表情识别是细粒度分类图片分辨率小直接缩放到224x224会丢失大量纹理细节但ResNet结构又要输入224x224所以一套合理的数据预处理是随机裁剪、水平翻转、归一化。from torchvision import datasets, transforms from torch.utils.data import DataLoader train_transform transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomCrop(224), transforms.RandomHorizontalFlip(p0.5), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) train_dataset datasets.ImageFolder(root./data/train, transformtrain_transform) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue, num_workers0, pin_memoryTrue) print(类别:, train_dataset.classes) print(样本数:, len(train_dataset))Resize((256, 256))先把图放大到256RandomCrop(224)再随机裁224等于做了一个简单的数据增强让模型每次看到的都是同一张图的不同区域有一定的抗过拟合作用。RandomHorizontalFlip对表情识别是安全的因为左右翻转后“开心”还是“开心”“愤怒”还是“愤怒”标签不会变。归一化的均值标准差直接沿用ImageNet的数值这在预训练模型上是标准做法。num_workers0在Windows上不会出问题设大于0在某些机器上会因为多进程和主程序交互报错这是Windows上PyTorch的老毛病慢就慢一点先保证不崩。3.4 训练主脚本最小可用的train.py怎么组织一个能交作业的train.py结构上必须包含五个部分数据加载、模型初始化、损失函数与优化器、训练循环、模型保存。老师答辩时一定会问“你的训练主流程是什么”这个脚本就是用来回答这个问题的。先看模型初始化这一块的写法期末项目最常用的路径有两种一种是从torchvision加载预训练ResNet-18把最后一层全连接改成7分类另一种是自己实现一个残差块再拼成ResNet完全从零训练。两种都合理前者快后者更容易展示你对网络结构的理解。import torch import torch.nn as nn from torchvision import models def build_model(num_classes7, use_pretrainedTrue): model models.resnet18(weightsDEFAULT if use_pretrained else None) in_features model.fc.in_features model.fc nn.Linear(in_features, num_classes) return model model build_model(num_classeslen(train_dataset.classes)) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-4)这段代码里weightsDEFAULT是torchvision新版推荐写法它会加载ImageNet预训练权重。如果你的torchvision版本较老也可以写成pretrainedTrue二者对应新旧两代API。model.fc.in_features先取出原全连接层的输入维度再替换成输出为num_classes的新全连接层这样前面的卷积层参数全部保留只需要从头训最后的分类头。损失函数用CrossEntropyLoss它内部已经包含了softmax所以模型输出层不需要额外加softmax直接输出7个类别的logits即可。优化器用Adam还是SGD都有道理SGDL2正则训得更稳但需要手动调学习率Adam收敛快对小数据集更友好。期末场景建议用Adam学习率初始值给1e-4别上来就1e-3表情数据集小学习率太大容易震荡。训练循环部分每个epoch结束时打一次平均损失和验证集准确率并保存验证集上最好的模型。保存的时候别只存model.state_dict()顺手把epoch和best_acc也存进去答辩时老师问“你这个模型训到第几个epoch最好”你立刻能答上来。4. 参数怎么调五组必调参数与三类监控指标4.1 batch_size与学习率要一起动很多人调参只动学习率忽略了batch_size。这两个参数是绑定的batch_size越大梯度越平滑噪声越小这时候可以适当加大学习率batch_size越小每个batch的梯度越不稳定学习率必须相应调小否则loss会像心电图一样上下跳。期末项目的机器配置差异很大常见做法是先确定batch_size再按经验给学习率。batch_size给16到64这段区间比较合适ResNet-18在224x224输入下用GTX 1060 6GB显存batch_size设为32能稳定跑把输入缩到224后还爆显存就先降到16再不行就检查是不是数据加载时把图片尺寸弄错了。一个实用的经验换batch_size时学习率按比例缩放。batch从32改成64学习率从1e-4上调到2e-4batch从32降到16学习率下调到5e-5。这不是什么严格数学推导是工程上常用的近似答辩被问到“为什么不改batch只改lr”时这就是你的理由。4.2 学习率衰减策略StepLR还是CosineAnnealing训练初期用大学习率快速收敛后期用小学习率精细调整这比全程固定学习率效果好得多。常见的两种调度器在期末项目里都常出现区别就在衰减节奏。StepLR是按固定步长阶梯式衰减比如每隔15个epoch学习率乘以0.1适合你能大概判断模型在哪个epoch收敛的情况。CosineAnnealing则让学习率按余弦曲线从初始值平滑降到接近0中途会经历“学习率低—调不动—又回升”的循环对小数据集、训练epoch不多时表现不错而且不需要你去猜衰减节点属于设置一次就不用管的类型。from torch.optim.lr_scheduler import StepLR, CosineAnnealingLR # 方案A每15个epoch学习率降为原来的0.1 scheduler_a StepLR(optimizer, step_size15, gamma0.1) # 方案B50个epoch内从初始lr余弦衰减到1e-6 scheduler_b CosineAnnealingLR(optimizer, T_max50, eta_min1e-6)两种方案在使用上有一个显著差异StepLR要配合训练代码里的if epoch % step_size 0这种判断去打日志方便你观察每次衰减后loss的变化CosineAnnealing则不用盯衰减点但训练结束时学习率已经很低如果还想继续追加训练最好重建优化器否则学习率回升的逻辑会让人迷惑。期末项目总epoch数一般在30到80之间两种都能用我更倾向StepLR因为答辩时能把“在第15个epoch学习率降了10倍loss从0.8掉到0.5”讲成一个具体的故事。4.3 类别不平衡的处理加权损失与采样器表情数据集的类别分布天然不平衡FER2013里“开心”和“中性”样本多“厌恶”“恐惧”样本少。如果直接用普通交叉熵模型会倾向于把所有样本预测成多数类因为只要这样做整体loss就能降低。两个常用解法是加权损失和加权采样。加权损失的做法是给每个类别一个权重样本少的类别算loss时乘一个更大的系数迫使模型重视这些类。权重的计算很简单总样本数除以每个类别的样本数再归一化。PyTorch的CrossEntropyLoss直接支持weight参数把权重张量传进去就行。import torch from torch.utils.data.sampler import WeightedRandomSampler # 计算每个类别的样本数然后取倒数作为权重 class_counts [train_dataset.targets.count(i) for i in range(len(train_dataset.classes))] total sum(class_counts) class_weights [total / c for c in class_counts] criterion nn.CrossEntropyLoss(weighttorch.tensor(class_weights, dtypetorch.float)) # 或者用加权采样器让每个batch里都能抽到稀有类 sample_weights [class_weights[target] for target in train_dataset.targets] sampler WeightedRandomSampler(sample_weights, num_sampleslen(sample_weights), replacementTrue) train_loader DataLoader(train_dataset, batch_size32, samplersampler)两种方式可以叠加用也可以只用一种。加权损失的优势是改动小一行参数搞定加权采样器的优势是每个batch里稀有类的出现频率直接提高模型能真实看到这些样本。如果题目数据集本身均匀这部分可以跳过如果答辩老师发现你处理过类别不平衡这是明显的加分项。4.4 训练日志怎么判读loss、acc与过拟合的三条判断线训练跑起来之后判断模型状态主要看三条曲线训练loss、训练acc、验证acc。很多人只看验证acc这是不够的。现象判断处理训练loss不降验证acc在50%左右晃模型没学会可能是学习率太大或数据加载出错先降学习率看有没有变化训练loss降了训练acc很高验证acc低过拟合模型在背训练集加数据增强、加Dropout、减小模型容量训练loss和验证acc都在降正常训练保持当前参数不要动让它跑完验证acc先升后降训练acc还在升过拟合开始最佳模型在前面加载之前保存的best_model这里有一个常见的误操作训练完直接拿最后一轮的权重去测试。正确做法是每个epoch记录验证acc只保留验证acc最高的那一次权重这叫“early stopping但用权重回溯代替中断训练”。很多源码里已经写了“保存最优模型”的逻辑你要确保用的是best_model.pth而不是last_model.pth别在最后一步把结果搞砸了。5. 避坑指南跑这个项目最常见的5个翻车现场5.1 CUDA out of memory现象训练刚开始或跑到第二个epoch弹出一大段红色报错核心是CUDA out of memory。原因最常见的不是显存不够而是输入图像尺寸太大或batch_size太大。很多人用ImageNet预训练模型时忘了改输入尺寸把500x500的原图直接塞给ResNet前向传播到第3层就爆了。解决先把transforms.Resize固定到224或256再确认batch_size不超过32还爆就降到16。如果这些都没问题检查是不是num_workers开太多数据加载子进程也会占显存。最后的手段是torch.cuda.empty_cache()加在训练循环开头但治标不治本。5.2 loss完全不下降一直在3.0附近现象训练了好几轮loss稳定在3.0附近不动验证acc也在类别数分之一附近徘徊比如7分类就卡在14%左右。原因3.0这个数值是交叉熵在7分类下的随机猜测起点ln(7)约等于1.95但多分类下会更高一点loss不降通常是两个原因一是学习率太大或太小太大loss会震荡太小loss几乎不动二是标签和数据没对齐——比如你用了别人的预训练权重但权重是在不同标签顺序上训的。解决先把学习率分别试1e-3、1e-4、1e-5看哪个区间loss能松动。如果都不动找一张训练集图片单独跑一次模型前向打印预测结果和真实标签人工核对是不是“愤怒”的图预测成了“中性”且损失巨大。这类问题90%靠打印中间结果能定位到。5.3 验证acc卡在50%上下怎么调都上不去现象训练loss正常下降但验证准确率始终在50%左右波动超过不了60%。原因通道数处理错误是隐藏元凶。FER2013是灰度图被ImageFolder默认按三通道读了之后模型把同一份灰度数据复制三份当作RGB输入虽然能跑通但信息并没有增加而且用ImageNet预训练权重时模型统计学到的颜色分布和灰度图差很远。解决在transforms里加transforms.Grayscale(num_output_channels3)把灰度图显式转成三通道但保持灰度内容或者在模型第一层把卷积输入通道改成1舍弃预训练权重从零训练。前者能继续用预训练权重稳定度高推荐先试这个。5.4 测试时模型几乎把所有人都预测成“中性”现象训练时acc还行但拿单张图片去测试无论什么表情预测结果都是“中性”或者“开心”。原因类别不平衡导致模型学习到了捷径——只要预测成多数类就能保证整体loss低。表情数据集里“中性”和“开心”样本最多模型逐渐学会偷懒。解决按4.3节的加权损失处理。还有一个容易忽略的点测试时的transforms必须和训练时一致尤其Normalize的均值和标准差训练时做了归一化测试时没做模型看到的分布完全不同预测就会偏向某一个类。检查predict.py里的transforms是否复制了训练时的完整流程。5.5 加载预训练权重时提示fc维度不匹配现象运行model.load_state_dict(checkpoint)时报错提示fc.weight size mismatch或者用旧版API加载新版torchvision模型时直接崩溃。原因torchvision版本升级后预训练权重的存储格式和模型定义有变化。旧代码里写pretrainedTrue在新版本中已弃用而新写法weightsDEFAULT加载的权重需要模型结构完全一致才能对齐。解决最省事的做法是不要手动改模型的fc层后再去load整个权重而是先加载预训练权重再替换最后一层替换后的fc参数不做加载从零初始化。import torch from torchvision import models from torchvision.models import ResNet18_Weights # 先加载预训练模型保留原始fc层的结构 model models.resnet18(weightsResNet18_Weights.IMAGENET1K_V1) # 再替换fc层fc层的参数不包含在预训练权重里 num_classes 7 model.fc torch.nn.Linear(model.fc.in_features, num_classes) # 保存权重时也把state_dict结构对应的信息保留 torch.save({model_state_dict: model.state_dict(), num_classes: num_classes}, best_model.pth)这里的关键顺序是先加载完整预训练权重再替换fc层。如果反过来模型结构里fc输出是7加载ImageNet权重时fc输出是1000必然报维度不匹配。保存时把num_classes存进字典predict脚本加载权重时先用这个值构造模型彻底避免维度对不上。6. 答辩加分的三个进阶技巧可视化、困难样本与混淆矩阵6.1 做一个预测可视化脚本别只输出类别号交作业时如果只输出“这个人是开心”说服力很弱。改进版是在推理脚本里画出图片、真实标签、预测标签、每个类别的置信度概率让老师一眼看到模型不是瞎猜。用一个matplotlib的柱状图展示概率分布代码量不大但展示效果很强。6.2 针对困难样本做增强而不是盲目加数据如果验证集里“恐惧”和“厌恶”老是混淆单独为这两类增加亮度抖动、随机旋转和遮挡增强会比全局增强更有效。判断困难样本的方法是打印验证集里被预测错的样本统计哪两类最容易互相误判再针对性补数据。这个思路叫困难样本挖掘说出来比“我用了数据增强”高级很多。6.3 验证时输出混淆矩阵量化每个类别的表现准确率只给一个数字混淆矩阵能展示模型具体在哪两类上犯错。用sklearn.metrics.confusion_matrix跑一次验证集把结果打印成表格答辩时指着“愤怒被误判成中性”那一格说明后续改进方向这个闭环讲完作业的完成度就有了。我自己的习惯是训练完先打印混淆矩阵再选三个预测错的样本做可视化最后才去调参数。这个顺序帮我省了很多重复训练的时间——因为有些“问题”根本不在模型里而在标签和数据预处理里。希望这篇能帮你把期末项目从“跑通”推到“讲明白”少走我踩过的那些弯路。本文还有配套的精品资源点击获取