简介:面向 Python 期末大作业与课程设计场景,这是一套基于 ResNet 的人脸表情识别项目,完整提供源码、数据集与模型相关文件,覆盖从人脸检测、数据处理、模型训练到界面测试的完整链路,尤其适合需要高质量结课项目的学生快速落地。压缩包共 16 个文件、约 5.2MB,包含 7 个表情分类示例图片(对应开心、悲伤、愤怒、惊讶、厌恶、恐惧、中性七类)、3 个 Python 源文件(模型定义、混淆矩阵可视化、测试调用)、2 个说明文档、1 个演示视频,以及 XML/JSON 配置与人脸检测器、模型信息,目录结构清晰,便于按需查看和复现。目前已有 190 人学习下载,是期末答辩中得分较高的常见选题之一。代码带有详细注释,新手也能理解每一步的用意;资源中还配有视频演示和 README,降低了环境配置与运行门槛,下载后简单部署即可使用,系统界面美观、功能齐全,具备很高的实际应用价值,可作为课程设计或大作业的直接参考。
1. 期末大作业,别从零手写 ResNet:这套表情识别方案为什么能直接拿高分
很多人在 python 期末大作业里选人脸表情识别,是觉得它比电商后台、学生管理系统这类 CRUD 项目更有视觉冲击力,演示效果好。但真动手就发现掉进坑里:从零手写一个 ResNet 结构就要两三百行,训练时 loss 死活不降,最后交上去一个连自己都说不清原理的项目。其实期末评审看重的是三件事:模型结构讲得出为什么、训练过程有曲线有数据、演示时能真实跑通。拿 ResNet 做人脸表情识别恰恰在这三点上都有优势——ResNet 的残差结构本身就是答辩时的得分点,FER2013 这类公开数据集让实验结果有据可查,而表情识别有七个类别,准确率做到 60% 以上就能拿得出手。
这套方案适合两类人:一类是 python 基础扎实但没做过深度学习项目的同学,另一类是只想快速交差但希望答辩不被问倒的同学。前者我会给你完整的训练调参思路,后者可以直接照抄代码。下文从 ResNet 选型、数据集处理、训练代码、踩坑清单一直讲到验证技巧,全部围绕一个目标:让你在有限时间内产出一个结构完整、能演示、能解释的期末大作业。
2. ResNet 选型与结构分析:为什么表情识别用 18 层就够,别盲目套 ResNet-50
2.1 表情识别任务的特点与 ResNet 残差结构的契合点
人脸表情识别本质上是一个图像分类任务,输入是一张人脸图片,输出是七个情绪类别之一:生气、厌恶、恐惧、开心、悲伤、惊讶、中性。和 ImageNet 上的一千类分类不同,表情识别的类别数少、类间差异细微——开心和惊讶的区别在嘴巴和眼睛的弧度上,而悲伤和中性往往只有嘴角的细微差别。这意味着模型需要的是捕捉局部纹理差异的能力,而不是极大的感受野和极深的特征层级。
ResNet 的核心贡献是残差学习。假设网络某一层的期望映射是 H(x),ResNet 让这一层去拟合残差 F(x) = H(x) - x,然后通过捷径连接把输入 x 原样加到输出上。这样做的直接好处是梯度可以绕过中间的卷积层直接回传到浅层,解决了深层网络退化问题。对表情识别来说,残差结构还有一个不那么显眼但很实用的特性:它让网络在训练初期更容易「什么都不学到」——因为 F(x) 初始化为 0 时输出就是 x,这反而让训练更稳定。而表情数据集的样本量通常不大,训练稳定性比极限精度更值钱。
另一个契合点是 FER2013 的输入尺寸。FER2013 的图片是 48x48 的灰度图,分辨率很低,下采样两次就到 12x12 了,再深的网络也没有足够空间去提取更高层的语义。这是很多人忽略的:模型深度应该和数据分辨率匹配。你拿 ResNet-50 去跑 48x48 的输入,前几层卷积之后特征图已经缩得很小,后面的层基本在做无效计算。
2.2 ResNet-18 还是 ResNet-50:算力、数据量和答辩深度之间的权衡
我见过不少同学一上来就选 ResNet-50,理由是「层数越深越厉害」,但实际训练出来的效果往往不如 ResNet-18。原因不复杂:ResNet-50 有约 2350 万参数,ResNet-18 只有约 1170 万,而 FER2013 训练集只有 28709 张图。参数翻倍但数据量不变,过拟合风险显著上升。在期末大作业的场景里,你没有那么多时间做精细的正则化调参,选一个不容易过拟合的模型是更务实的策略。
具体到结构差异:ResNet-18 使用的是 BasicBlock,每个 block 是两个 3x3 卷积;ResNet-50 使用的是 Bottleneck,先 1x1 降维再 3x3 卷积再 1x1 升维。Bottleneck 的设计初衷是在更深网络中控制计算量,但在 18 层这个尺度上完全没有必要。从答辩角度来看,ResNet-18 的残差结构图更好画,forward 过程更好讲清楚,老师问「你的跳跃连接是怎么实现的」你也能看着代码直接回答。
如果你用 ImageNet 预训练权重,那更要用 ResNet-18。预训练 ResNet-50 在 ImageNet 上见过的都是 224x224 的自然图像,迁移到 48x48 的灰度人脸图,低层卷积核学到的东西还能用——边缘、纹理、颜色梯度——但高层语义完全不匹配。用 ResNet-18 配合预训练权重,微调收敛快,通常 15 个 epoch 就能看到明显效果,这正好匹配期末作业的时间预算。
2.3 修改 ResNet 输出层与输入通道:两个必须动的位置
无论你用的是 torchvision 自带的 resnet18 还是自己写的结构,有两个位置必须改。第一个是输入层:torchvision 模型的第一个卷积层是 nn.Conv2d(3, 64, kernel_size=7, stride=2),它接收三通道 RGB 图,而 FER2013 是单通道灰度图。最简单的做法不是在读图时用 PIL 的 convert('RGB') 强行复制三通道,而是直接改模型第一层为 nn.Conv2d(1, 64, kernel_size=3, stride=1, padding=1)。注意这里我同时把 7x7 的大卷积核换成了 3x3——48x48 的输入用 stride=2 的 7x7 卷积会直接丢信息,第一层就把分辨率砍半对低分辨率数据就是灾难。
第二个是输出层:resnet18 默认的 fc 层输出维度是 1000(ImageNet 类别数),要改成 7。代码很简单:
import torchvision.models as models import torch.nn as nn model = models.resnet18(weights=None) # 先用随机初始化 model.conv1 = nn.Conv2d(1, 64, kernel_size=3, stride=1, padding=1, bias=False) model.maxpool = nn.Identity() # 48x48 太小,去掉第一个 maxpool model.fc = nn.Linear(model.fc.in_features, 7) # 如果要用预训练权重 # model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) # 加载预训练权重后,同样要替换 conv1 和 fc,否则形状不匹配这里的逻辑是:第一层从 3 通道改成 1 通道匹配灰度图,同时把 stride 从 2 改成 1、核大小从 7 改成 3,避免低分辨率输入在前向传播时尺寸骤降。去掉 maxpool 也是同理——原始 ResNet 是为 224x224 设计的,48x48 的图经过 stride=2 的卷积加 maxpool 只剩 12x12,再往下走特征图就太小了。fc 层改成 7 对应七个表情类别。
参数说明:bias=False 是因为后面的 BatchNorm 层自带偏置项,卷积层再加 bias 是重复的;ResNet18_Weights.IMAGENET1K_V1 是 PyTorch 官方预训练权重的标准枚举写法,如果你的 torchvision 版本较老,用weights=None然后手动load_state_dict也可以,但要注意新版本里pretrained=True的写法已经废弃了。我一般会用weights=None从零训练,因为 FER2013 的域和 ImageNet 差异太大,预训练权重在灰度小图上带来的提升有限,反而让代码结构多了一层条件判断,增加答辩时被追问的风险。
3. 数据集处理与增强:从 FER2013 读数据到自建数据集的完整流程
3.1 FER2013 的结构和读取方式:48x48 灰度图的 CSV 读法
FER2013 是最常用的人脸表情识别公开数据集,由 Kaggle 在 2013 年的挑战赛上发布,包含 35887 张 48x48 灰度人脸图。它的存储形式很特别:不是一堆图片文件,而是一个 CSV 文件,每一行是一张图片。CSV 有三列:emotion(0 到 6 的整数标签)、pixels(2304 个由空格分隔的像素值,48x48=2304)、Usage(该行属于 Training 还是 PublicTest 或 PrivateTest)。
很多人在这个数据集上翻车,是不知道怎么把一串像素值还原成图片。其实就是一个简单的 reshape,然后注意 FER2013 的像素顺序是行优先的,不需要转置。读取的代码可以写成这样:
import pandas as pd import numpy as np from PIL import Image df = pd.read_csv('fer2013.csv') print(df['Usage'].value_counts()) # 确认三个子集的划分 # 取一行做验证 row = df.iloc[0] label = row['emotion'] pixels = np.array(row['pixels'].split(), dtype=np.uint8).reshape(48, 48) img = Image.fromarray(pixels, mode='L') img.save('sample.png')pandas 读 CSV 后用 split() 按空格切分字符串,再转成 uint8 数组并 reshape 成 48x48。这里两个细节值得注意:一是 dtype 一定要用 np.uint8 而不是默认的 int64,否则后面转 PIL 图会出现类型错误或内存浪费;二是 mode='L' 表示单通道灰度图,如果不指定这个参数,PIL 可能把 int8 数组误解为调色板模式,输出会花屏。
3.2 训练集 / 验证集 / 测试集的划分:别把数据洗乱了
FER2013 官方已经划分好了 Training、PublicTest、PrivateTest 三个集合,但很多网上的代码不区分,直接全量拿来训练,然后说「准确率 95%」。这是典型的数据泄露,答辩时老师问一句「你的测试集是不是训练集的一部分」你就答不上来。
正确的做法是严格按 Usage 字段划分:Training 有 28709 张用于训练,PublicTest 有 3589 张用于验证和调参,PrivateTest 有 3589 张作为最终测试集。如果你的代码用了随机划分,一定要固定随机种子:
SEED = 42 np.random.seed(SEED) torch.manual_seed(SEED)这里固定随机种子有两个作用:一是让你的实验结果可复现,答辩时老师让你重新跑一遍,结果不能差太多;二是避免每次跑代码都重新划分数据导致训练集和验证集重叠。我见过一个同学的代码,每次运行都用 train_test_split(test_size=0.2) 重新划分,有一次划分出来验证集里出现了训练集的图,准确率虚高到 85%,答辩演示时又跌回 60%,非常尴尬。
3.3 数据增强策略:RandomCrop、RandomHorizontalFlip 和 Normalize 的搭配
FER2013 的训练集只有 28709 张,分配到 7 个类别每类大约 4000 张,这对深度学习来说是不够的。数据增强是必须的,但幅度要克制。表情识别的特殊性在于:水平翻转是安全增强(左右脸大致对称),但垂直翻转就会产生诡异的人脸,旋转超过 15 度也会让表情特征失真。
我常用的增强组合是这样,写在 torchvision 的 transforms 里,和测试集的数据处理分开:
from torchvision import transforms train_transform = transforms.Compose([ transforms.RandomHorizontalFlip(p=0.5), # 随机水平翻转 transforms.RandomAffine(degrees=10, translate=(0.05, 0.05)), # 小幅旋转和平移 transforms.ToTensor(), # 转 Tensor 并缩放到 [0,1] transforms.Normalize(mean=[0.5], std=[0.5]) # 归一化到 [-1,1] ]) test_transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize(mean=[0.5], std=[0.5]) ])注意 RandomCrop 我没用,因为 48x48 的图再裁剪就剩 40x40 以下了,表情的关键区域可能在裁剪时被切掉。RandomAffine 的 degrees=10 表示正负 10 度内的随机旋转,translate=(0.05, 0.05) 表示水平和竖直方向最多平移 5%。Normalize 的 mean 和 std 都用 0.5,表示把 [0,1] 的像素值映射到 [-1,1],这是很多预训练模型期望的输入分布,虽然我们不一定用预训练,但保持这个习惯没坏处。
数据增强后的效果可以直接观察验证——把增强后的图片存下来看一遍,确认没有旋转过度、没有把关键器官切掉。这一步只要 5 分钟,但能防止你在训练一天后才发现输入数据是花的。
3.4 自建数据集:用 OpenCV 摄像头采集自己做数据集的流程
如果不想只用 FER2013,想加点自己的数据展示「迁移能力」,或者导师要求不能用公开数据集,那可以用 OpenCV 调用摄像头自建小数据集。常见做法是先用 OpenCV 的 Haar Cascade 检测人脸区域,把检测到的框裁剪出来缩放到 48x48 保存。采集代码的核心部分如下:
import cv2 import os cap = cv2.VideoCapture(0) cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml') save_dir = 'my_faces/happy' os.makedirs(save_dir, exist_ok=True) count = 0 while count < 200: ret, frame = cap.read() if not ret: break gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces = cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(48, 48)) for (x, y, w, h) in faces: face = gray[y:y+h, x:x+w] face_resized = cv2.resize(face, (48, 48)) cv2.imwrite(os.path.join(save_dir, f'{count}.jpg'), face_resized) count += 1 cv2.rectangle(frame, (x, y), (x+w, y+h), (0, 255, 0), 2) cv2.imshow('capture', frame) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()这个脚本会不断从摄像头读取画面,检测到人脸后截取、缩放、保存。几个参数说明:scaleFactor=1.1 表示每次缩放检测窗口的尺度,值越小检测越精细但越慢;minNeighbors=5 表示至少要 5 个相邻检测才确认是人脸,值越大误检越少但漏检变多;minSize=(48,48) 过滤掉太小的检测框,避免保存一堆噪点图。采集时注意别只在一个角度拍,正面、左右侧脸、不同光照下各采一部分,否则模型学到的是「一个特定人的特定角度」,换个人就不工作。
自建数据集和 FER2013 混合训练时,建议自建数据的比例不要超过 20%,否则模型会偏向你一个人的长相特征。数据量上,每个类别 150~200 张就足够做微调了。
4. 训练与评估全流程代码:数据加载、模型训练、准确率评估一条龙
4.1 Dataset 与 DataLoader:把 CSV 数据接入 PyTorch 训练管线
写完数据增强之后,需要把数据和标签封装成 PyTorch 的 Dataset 类,这样才能用 DataLoader 做批量加载。这里有一个很多人忽略的细节:FER2013 的 CSV 有官方划分,你的 Dataset 类应该在初始化时就过滤出对应子集,而不是全部加载再手动画索引。代码可以这样写:
import torch from torch.utils.data import Dataset, DataLoader class FER2013Dataset(Dataset): def __init__(self, csv_path, usage='Training', transform=None): df = pd.read_csv(csv_path) self.df = df[df['Usage'] == usage].reset_index(drop=True) self.transform = transform def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.iloc[idx] label = torch.tensor(int(row['emotion']), dtype=torch.long) pixels = np.array(row['pixels'].split(), dtype=np.uint8).reshape(48, 48) img = Image.fromarray(pixels, mode='L') if self.transform: img = self.transform(img) return img, label train_dataset = FER2013Dataset('fer2013.csv', usage='Training', transform=train_transform) val_dataset = FER2013Dataset('fer2013.csv', usage='PublicTest', transform=test_transform) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True, num_workers=2) val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False, num_workers=2)这个 Dataset 类在getitem里实时解析像素字符串,而不是在init里一次性全部解析。虽然多了一点 IO 开销,但内存占用小很多——28709 张图一次性加载也就 60MB 左右,但如果你的自建数据集图片很大或者数量上百万,实时解析就是必须的。label 用 torch.long 是因为 nn.CrossEntropyLoss 要求目标张量是长整型,不能用默认的 int64 之外的浮点型。
DataLoader 的 batch_size 这里设 64,在 48x48 的低分辨率输入下,这个 batch 大小在普通笔记本的 GPU 上毫无压力,甚至是 CPU 也能跑。shuffle=True 在每个 epoch 开始时打乱数据顺序,这是训练集必须的,但验证集不能 shuffle——因为你可能要根据样本索引去查错误分类的图片,打乱了就找不到了。
4.2 训练主循环:loss 计算、反向传播、学习率的设置
训练主循环的模式是固定的,但有几个参数值得认真讲。首先是用交叉熵损失函数,因为这是七分类问题,而且 FER2013 的类别分布本身不平衡——「开心」类样本最多,「厌恶」类样本最少。这个问题后面专门讲,先把训练主循环写出来:
import torch.optim as optim from tqdm import tqdm device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = model.to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=8, gamma=0.5) num_epochs = 30 best_val_acc = 0.0 for epoch in range(num_epochs): model.train() running_loss = 0.0 correct = 0 total = 0 for images, labels in tqdm(train_loader, desc=f'Epoch {epoch+1}/{num_epochs}'): images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() running_loss += loss.item() * images.size(0) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() train_acc = 100.0 * correct / total train_loss = running_loss / total # 验证集评估 model.eval() val_correct = 0 val_total = 0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, predicted = torch.max(outputs, 1) val_total += labels.size(0) val_correct += (predicted == labels).sum().item() val_acc = 100.0 * val_correct / val_total print(f'Epoch {epoch+1}: train_loss={train_loss:.4f}, train_acc={train_acc:.2f}%, val_acc={val_acc:.2f}%') if val_acc > best_val_acc: best_val_acc = val_acc torch.save(model.state_dict(), 'best_model.pth')参数和逻辑说明:优化器用 Adam 而不是 SGD,因为 Adam 对学习率的敏感度低,期末场景下不需要精细调节学习率策略就能收敛。learning rate 从 1e-3 开始是比较通用的选择,太大(1e-2)容易在训练初期震荡,太小(1e-4)收敛太慢。weight_decay=1e-4 是 L2 正则化的系数,对防止过拟合有一点帮助。scheduler 每 8 个 epoch 把学习率减半,这样训练后期可以用更小的步长在损失曲面底部精细搜索。
关键的一行是 torch.save(model.state_dict(), 'best_model.pth'),每次验证集准确率创新高就保存一份。这是你的后悔药——训练后期出现过拟合时,你能回滚到验证集表现最好的那个权重,而不是最后 epoch 的权重。很多同学只保存最后一个 epoch 的模型,结果最后几个 epoch 正好过拟合,演示时效果很差,这就是没留后路的血泪经验。
4.3 类别不平衡的权重处理:用 class_weight 提升少数类准确率
FER2013 的类别分布偏差很直观:happy 和 neutral 各有大约 9000 张左右,而 disgust 只有约 600 张。如果你不做任何处理,模型会把 disgust 一律预测成 happy,反正整体准确率也不会太难看。但在答辩时,老师很可能专门抽查少数类的预测效果。解决方式是给 CrossEntropyLoss 传入每个类别的权重,让少数类的 loss 在反向传播时被放大:
from sklearn.utils.class_weight import compute_class_weight class_names = ['angry', 'disgust', 'fear', 'happy', 'sad', 'surprise', 'neutral'] labels_array = train_dataset.df['emotion'].values weights = compute_class_weight('balanced', classes=np.unique(labels_array), y=labels_array) weights_tensor = torch.tensor(weights, dtype=torch.float32).to(device) criterion = nn.CrossEntropyLoss(weight=weights_tensor)compute_class_weight 是 sklearn 里现成的工具函数,balanced 模式会根据每个类别的样本数反推权重:样本少的类别权重高,样本多的类别权重低。这样做之后,disgust 类的 loss 占比上升,模型会花更多精力学习这个类。
但要注意的是,加了类别权重后,整体准确率通常会下降一两个百分点,因为模型把一部分预测能力「让」给了少数类。这在期末作业里是完全可接受的——你可以在答辩时说「我的模型通过损失函数权重平衡了类别分布,在 disgust 类别上 F1 显著提升」,这比整体准确率高两个点更有说服力。
4.4 完整训练脚本的组织方式:一个 train.py 搞定全部流程
把上面所有代码组织成一个完整的 train.py,结构是:导入库 → 定义 transforms → 定义 Dataset 类 → 定义模型 → 设置 loss 和 optimizer → 训练循环 → 保存最优模型。文件组织成单文件有个好处:期末交源码时,老师只需要跑一个文件就能复现。如果你把代码拆成五个文件再加一堆配置,反而容易被质疑代码组织能力。
训练跑起来之后,你需要盯两个曲线:train_loss 要稳步下降,val_acc 要同步上升。如果 train_loss 下降但 val_acc 停滞不动,说明过拟合已经开始了,应该停止训练取最优 checkpoint。如果两者都不动,检查数据加载和模型输出形状是不是对的,用一段固定 seed 的代码在训练前单步跑一次 forward 确认输出维度是 (batch_size, 7)。
5. ResNet 表情识别避坑指南:从数据泄露到损失不降的 5 个高频翻车现场
5.1 数据泄露:验证集里混入了训练集,准确率虚高
现象:验证集准确率高达 90%,但用摄像头实时测试时准确率不到 50%,差距悬殊。
原因:很多网上的教程代码在加载 FER2013 时不做 Usage 区分,全量数据参与训练,验证集和测试集都是训练集的一部分。模型「背下来」了训练样本,而不是学到了泛化的表情特征。另一个隐蔽的来源是自己写随机划分时没固定随机种子,每次运行都重新划分,某次划分恰好把相似样本放进了两个集合。
解决:严格使用官方 Usage 字段划分,或者用固定 seed 的 train_test_split。在答辩前用摄像头实时采集几张不包含在训练集里的照片做一次实测,如果实时准确率明显低于测试集准确率,优先怀疑数据泄露。
5.2 48x48 小图配大卷积核:信息在第一层就丢了
现象:模型训练了 20 个 epoch,loss 维持在 1.9 左右下不去,准确率在 15% 附近徘徊(七分类随机水平是 14.3%)。
原因:直接用 torchvision 默认的 resnet18 结构,第一层是 kernel_size=7, stride=2 的卷积,48x48 的输入经过这一层后变成 24x24,再过 maxpool 变成 12x12。信息在第一步就大量丢失,后续层拿到的特征图分辨率太低。
解决:把 conv1 改成 kernel_size=3, stride=1, padding=1,同时去掉第一个 maxpool。这是针对低分辨率输入的标准改造方案。你可以在训练前打印模型输出的特征图尺寸来验证。
5.3 不带 BatchNorm 的模型网络:loss 震荡得像在蹦迪
现象:训练过程中 train_loss 在大幅震荡,每一步的 loss 值忽高忽低,准确率也在 20%-40% 之间剧烈波动。
原因:如果自己从零实现了 ResNet 但漏掉了 BatchNorm 层,或者把 BatchNorm 放在了不正确的顺序上,梯度在深层传播时很容易出现量级差异,导致训练不稳定。
解决:优先使用 torchvision 自带的 resnet18,不要自己复现。如果确实要手写,确认每个 BasicBlock 的顺序是 Conv → BN → ReLU,同时确认在训练模式用 model.train() 开启 BN 的统计量更新,评估时用 model.eval() 冻结 BN。
5.4 归一化参数前后不一致:训练集用了 Normalize 测试集忘了用
现象:训练准确率正常,但验证集准确率突然掉到 10% 以下,甚至接近 0%。
原因:训练时用的 transform 里有 Normalize(mean=[0.5], std=[0.5]),但验证集和测试集的 transform 里只有 ToTensor(),像素范围是 [0,1] 而不是 [-1,1]。模型在训练时看到的数据分布和测试时不一致,特征分布完全偏移。
解决:检查代码里是否定义了独立的 test_transform,确保它和 train_transform 中除了增强项之外的 Normalize 参数完全一致。这是代码复制粘贴最容易犯的错误。
5.5 评估时忘了 model.eval():BatchNorm 统计量在漂移
现象:单个图片测试时准确率时好时坏,同一个输入跑两次结果还不一样。
原因:模型在训练模式下(model.train()),BatchNorm 层用当前 batch 的均值和方差做归一化。如果你在推理时忘了切到 model.eval(),BatchNorm 用的就是当前这个 batch(可能只有 1 张图)的统计量,结果自然随机波动。
解决:推理前加 model.eval(),再用 torch.no_grad() 包裹推理代码。前者切 BN 到固定统计量模式,后者关闭梯度计算省内存。这两个操作在 PyTorch 里是独立的,必须都做。
6. 用混淆矩阵和 Grad-CAM 可视化把答辩分数拉满
训练完模型只是拿到了基础分,把模型「讲清楚」才是拉开差距的地方。期末答辩时老师最常问的问题有两个:「模型学到了什么特征?」「哪些表情容易混淆?」。这两个问题分别对应 Grad-CAM 热力图和混淆矩阵分析,我建议把这两张图放进你的演示 PPT 或现场展示脚本里。
混淆矩阵用 sklearn 一行代码就能生成,关键是分析哪两类最容易被混淆,以及为什么。以 FER2013 常见的训练结果为例,fear 和 surprise 的混淆通常最严重——因为惊讶和恐惧的嘴巴都是张开的,区别主要在看眼睛和眉毛的细节。在答辩时主动说出这个发现,比等老师问你要强得多:
from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay import matplotlib.pyplot as plt all_preds = [] all_labels = [] model.eval() with torch.no_grad(): for images, labels in val_loader: images = images.to(device) outputs = model(images) _, preds = torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.numpy()) cm = confusion_matrix(all_labels, all_preds) disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=class_names) disp.plot(cmap='Blues') plt.savefig('confusion_matrix.png', dpi=150)这段代码跑完会保存一张 7x7 的混淆矩阵热力图。提交到演示材料里时,图上最明显的非对角线亮块就是你的分析切入点。计算公式和错误案例结合起来讲,就构成了一个完整的分析闭环。
Grad-CAM 是另一个高级可视化手段——它用最后一个卷积层的梯度生成热力图,告诉你看哪一块区域激活最强。PyTorch 的实现需要注册 hook 来捕获中间层的输出和梯度:
from torch.nn import functional as F target_layer = model.layer4[-1] # ResNet 最后一个 BasicBlock 的最后一个卷积层 activations = {} gradients = {} def forward_hook(module, input, output): activations['value'] = output def backward_hook(module, grad_input, grad_output): gradients['value'] = grad_output[0] target_layer.register_forward_hook(forward_hook) target_layer.register_backward_hook(backward_hook) # 推理单张图 img, label = val_dataset[0] img_batch = img.unsqueeze(0).to(device) output = model(img_batch) score = output[0, output.argmax()] # 取预测类别的得分 model.zero_grad() score.backward() # 计算 Grad-CAM 热力图 weights = gradients['value'].mean(dim=(2, 3), keepdim=True) # 全局平均池化 cam = (weights * activations['value']).sum(dim=1, keepdim=True) cam = F.relu(cam) # 只保留正激活 cam = F.interpolate(cam, size=(48, 48), mode='bilinear', align_corners=False) cam = cam.squeeze().cpu().detach().numpy()代码逻辑分四步:注册 hook 捕获目标层的输出和梯度;取模型对预测类别的得分做反向传播;把梯度和激活值相乘再全局平均池化,得到每个通道的权重;用权重加权激活值后做 ReLU 过滤负值,再上采样到输入尺寸。最终的热力图叠加到原图上,高亮区域就是模型判断表情的主要依据。
实际使用中你会发现两个有意思的现象:一是模型通常聚焦在眼睛和嘴巴区域,这符合人类的认知;二是如果模型对某张图判断错误,热力图往往是散的,或者聚焦在背景上。这些现象都可以在答辩现场演示,边跑边说。如果我做这个项目,会把单张推理封装成一个函数,现场传入摄像头拍到的实时画面,让模型预测并叠加 Grad-CAM 热力图——这个演示的冲击力远大于一张静态的准确率表格。最后也是最重要的一条经验:所有可视化脚本在答辩前一天完整跑一遍,确保测试集的路径正确、模型权重能加载、摄像头能打开。设备兼容性是最容易被忽视的翻车点,提前踩过坑,答辩时就稳了。希望帮到你。
本文还有配套的精品资源,点击获取