基于ResNet的人脸表情识别实战:从FER2013到期末答辩全流程
简介面向 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-502.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_size7, stride2)它接收三通道 RGB 图而 FER2013 是单通道灰度图。最简单的做法不是在读图时用 PIL 的 convert(RGB) 强行复制三通道而是直接改模型第一层为 nn.Conv2d(1, 64, kernel_size3, stride1, padding1)。注意这里我同时把 7x7 的大卷积核换成了 3x3——48x48 的输入用 stride2 的 7x7 卷积会直接丢信息第一层就把分辨率砍半对低分辨率数据就是灾难。第二个是输出层resnet18 默认的 fc 层输出维度是 1000ImageNet 类别数要改成 7。代码很简单import torchvision.models as models import torch.nn as nn model models.resnet18(weightsNone) # 先用随机初始化 model.conv1 nn.Conv2d(1, 64, kernel_size3, stride1, padding1, biasFalse) model.maxpool nn.Identity() # 48x48 太小去掉第一个 maxpool model.fc nn.Linear(model.fc.in_features, 7) # 如果要用预训练权重 # model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) # 加载预训练权重后同样要替换 conv1 和 fc否则形状不匹配这里的逻辑是第一层从 3 通道改成 1 通道匹配灰度图同时把 stride 从 2 改成 1、核大小从 7 改成 3避免低分辨率输入在前向传播时尺寸骤降。去掉 maxpool 也是同理——原始 ResNet 是为 224x224 设计的48x48 的图经过 stride2 的卷积加 maxpool 只剩 12x12再往下走特征图就太小了。fc 层改成 7 对应七个表情类别。参数说明biasFalse 是因为后面的 BatchNorm 层自带偏置项卷积层再加 bias 是重复的ResNet18_Weights.IMAGENET1K_V1 是 PyTorch 官方预训练权重的标准枚举写法如果你的 torchvision 版本较老用weightsNone然后手动load_state_dict也可以但要注意新版本里pretrainedTrue的写法已经废弃了。我一般会用weightsNone从零训练因为 FER2013 的域和 ImageNet 差异太大预训练权重在灰度小图上带来的提升有限反而让代码结构多了一层条件判断增加答辩时被追问的风险。3. 数据集处理与增强从 FER2013 读数据到自建数据集的完整流程3.1 FER2013 的结构和读取方式48x48 灰度图的 CSV 读法FER2013 是最常用的人脸表情识别公开数据集由 Kaggle 在 2013 年的挑战赛上发布包含 35887 张 48x48 灰度人脸图。它的存储形式很特别不是一堆图片文件而是一个 CSV 文件每一行是一张图片。CSV 有三列emotion0 到 6 的整数标签、pixels2304 个由空格分隔的像素值48x482304、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(), dtypenp.uint8).reshape(48, 48) img Image.fromarray(pixels, modeL) img.save(sample.png)pandas 读 CSV 后用 split() 按空格切分字符串再转成 uint8 数组并 reshape 成 48x48。这里两个细节值得注意一是 dtype 一定要用 np.uint8 而不是默认的 int64否则后面转 PIL 图会出现类型错误或内存浪费二是 modeL 表示单通道灰度图如果不指定这个参数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_size0.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(p0.5), # 随机水平翻转 transforms.RandomAffine(degrees10, 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 的 degrees10 表示正负 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_okTrue) count 0 while count 200: ret, frame cap.read() if not ret: break gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces cascade.detectMultiScale(gray, scaleFactor1.1, minNeighbors5, minSize(48, 48)) for (x, y, w, h) in faces: face gray[y:yh, x:xw] 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), (xw, yh), (0, 255, 0), 2) cv2.imshow(capture, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()这个脚本会不断从摄像头读取画面检测到人脸后截取、缩放、保存。几个参数说明scaleFactor1.1 表示每次缩放检测窗口的尺度值越小检测越精细但越慢minNeighbors5 表示至少要 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, usageTraining, transformNone): df pd.read_csv(csv_path) self.df df[df[Usage] usage].reset_index(dropTrue) 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]), dtypetorch.long) pixels np.array(row[pixels].split(), dtypenp.uint8).reshape(48, 48) img Image.fromarray(pixels, modeL) if self.transform: img self.transform(img) return img, label train_dataset FER2013Dataset(fer2013.csv, usageTraining, transformtrain_transform) val_dataset FER2013Dataset(fer2013.csv, usagePublicTest, transformtest_transform) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue, num_workers2) val_loader DataLoader(val_dataset, batch_size64, shuffleFalse, num_workers2)这个 Dataset 类在getitem里实时解析像素字符串而不是在init里一次性全部解析。虽然多了一点 IO 开销但内存占用小很多——28709 张图一次性加载也就 60MB 左右但如果你的自建数据集图片很大或者数量上百万实时解析就是必须的。label 用 torch.long 是因为 nn.CrossEntropyLoss 要求目标张量是长整型不能用默认的 int64 之外的浮点型。DataLoader 的 batch_size 这里设 64在 48x48 的低分辨率输入下这个 batch 大小在普通笔记本的 GPU 上毫无压力甚至是 CPU 也能跑。shuffleTrue 在每个 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(), lr1e-3, weight_decay1e-4) scheduler optim.lr_scheduler.StepLR(optimizer, step_size8, gamma0.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, descfEpoch {epoch1}/{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(fEpoch {epoch1}: 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_decay1e-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, classesnp.unique(labels_array), ylabels_array) weights_tensor torch.tensor(weights, dtypetorch.float32).to(device) criterion nn.CrossEntropyLoss(weightweights_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 个 epochloss 维持在 1.9 左右下不去准确率在 15% 附近徘徊七分类随机水平是 14.3%。原因直接用 torchvision 默认的 resnet18 结构第一层是 kernel_size7, stride2 的卷积48x48 的输入经过这一层后变成 24x24再过 maxpool 变成 12x12。信息在第一步就大量丢失后续层拿到的特征图分辨率太低。解决把 conv1 改成 kernel_size3, stride1, padding1同时去掉第一个 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_matrixcm, display_labelsclass_names) disp.plot(cmapBlues) plt.savefig(confusion_matrix.png, dpi150)这段代码跑完会保存一张 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), keepdimTrue) # 全局平均池化 cam (weights * activations[value]).sum(dim1, keepdimTrue) cam F.relu(cam) # 只保留正激活 cam F.interpolate(cam, size(48, 48), modebilinear, align_cornersFalse) cam cam.squeeze().cpu().detach().numpy()代码逻辑分四步注册 hook 捕获目标层的输出和梯度取模型对预测类别的得分做反向传播把梯度和激活值相乘再全局平均池化得到每个通道的权重用权重加权激活值后做 ReLU 过滤负值再上采样到输入尺寸。最终的热力图叠加到原图上高亮区域就是模型判断表情的主要依据。实际使用中你会发现两个有意思的现象一是模型通常聚焦在眼睛和嘴巴区域这符合人类的认知二是如果模型对某张图判断错误热力图往往是散的或者聚焦在背景上。这些现象都可以在答辩现场演示边跑边说。如果我做这个项目会把单张推理封装成一个函数现场传入摄像头拍到的实时画面让模型预测并叠加 Grad-CAM 热力图——这个演示的冲击力远大于一张静态的准确率表格。最后也是最重要的一条经验所有可视化脚本在答辩前一天完整跑一遍确保测试集的路径正确、模型权重能加载、摄像头能打开。设备兼容性是最容易被忽视的翻车点提前踩过坑答辩时就稳了。希望帮到你。本文还有配套的精品资源点击获取