简介:基于ResNet的人脸表情识别Python实现源码与配套数据集,面向正在准备毕业设计、期末大作业或课程设计的高校学生,也适合刚接触深度学习的开发者用于表情分类实战练习。资源共16个文件,压缩包约5.2MB,内部以Python脚本为核心(模型构建、训练测试、混淆矩阵绘制),配合按7类表情整理的图像数据集、依赖清单与说明文档,以及一段演示视频,方便快速跑通并理解流程。目前已有237人学习下载。源码经过本地编译验证,评审得分98分,内容由助教审定,难度适中,结构清晰。读者可借助类别索引文件快速对应表情标签,借助依赖清单文件还原运行环境,结合数据集与模型代码进行训练、评估和结果可视化;通过混淆矩阵还能观察各类别识别效果,便于进一步调优。也可在此基础上替换骨干网络或扩充数据,用于论文实验或课设展示。
1. 人脸表情识别为什么值得用ResNet:从七分类任务到工程落地
拿到“基于ResNet的人脸表情识别python实现源码+数据集(高分项目).zip”这个压缩包的人,大多是要交一个能跑、能讲、能答辩的完整项目,而不是研究人脸识别前沿。人脸表情识别本质是一个七分类任务——把angry、disgust、fear、happy、sad、surprise、neutral这七类从一张人脸图片里分出来,难点在于表情差异是局部纹理变化,光照、姿态、遮挡都会让分类器翻车。ResNet能成为这个任务的主流底座,靠的是残差连接。网络加深不再导致退化,模型可以从粗粒度的人脸结构一路学到细粒度的肌肉纹理特征,这也是ImageNet夺冠之后它迅速被移植到表情识别的原因。这个方向适合课程设计、毕业设计,也适合想快速验证一个迁移学习demo的python开发者。
2. 表情数据集的清洗与划分:FER2013和CK+怎么处理才能训练
标题里写着“数据集”,但压缩包里的数据从来不是能直接喂给模型的形态。常见做法是里面放着FER2013的CSV原始文件、CK+的序列帧,或者已经按标签分好文件夹的图片目录。三种形态,处理方式完全不同,第一步做错了,后面的训练全白搭。我一般拿到压缩包先做一件事:打开目录看一眼是CSV还是文件夹,再决定写哪套解析代码。
2.1 FER2013的CSV结构与CK+的序列帧差异
FER2013是一个单文件CSV,三列:emotion、pixels、Usage。emotion是0到6的整数,对应七类表情;pixels是48×48的灰度像素值,按空格分隔成2304个数字;Usage字段把数据分成Training、PublicTest、PrivateTest三部分。这个结构的好处是解析简单,坏处是它本身已经是灰度小图,放大后纹理细节有限,模型上限被数据卡住。用它做训练,输入尺寸通常是48×48,不需要resize到224也能跑,但ResNet的输入习惯是224×224,所以要做一次插值放大。
CK+是另一套逻辑。它不是一张张独立图片,而是593个序列,每个序列从平静表情开始,到峰值表情结束,中间有若干帧。直接拿整个序列训练会引入大量中性帧,模型学到的不是表情而是“这张脸长什么样”。我一般只取每个序列的最后三帧——峰值帧——作为正样本,一个序列最多产出3张可用图片。这个取舍会导致CK+的总样本数不到一千张,所以CK+通常只做测试或微调,主力训练还是靠FER2013。
2.2 把FER2013的CSV解析成目录结构:minimal脚本
不管后面用PyTorch还是TensorFlow,先把CSV落成“按类别分文件夹”的目录结构最省心。下面这个脚本把FER2013按Usage字段拆成train和test两个集合。
import pandas as pd from pathlib import Path from PIL import Image import numpy as np csv_path = "fer2013.csv" root = Path("fer2013_split") df = pd.read_csv(csv_path) emotion_names = { 0: "angry", 1: "disgust", 2: "fear", 3: "happy", 4: "sad", 5: "surprise", 6: "neutral" } for split in ["Training", "PublicTest", "PrivateTest"]: subset = df[df["Usage"] == split] target = root / ("train" if split == "Training" else "test") for _, row in subset.iterrows(): label = int(row["emotion"]) img = np.array(row["pixels"].split(), dtype=np.uint8).reshape(48, 48) out_dir = target / emotion_names[label] out_dir.mkdir(parents=True, exist_ok=True) Image.fromarray(img).save(out_dir / f"{row.name}.png")这段代码的逻辑是:先用pandas把CSV读进来,按Usage字段把行筛成训练集和测试集;对每一行,把pixels字符串按空格分割,转成48×48的灰度数组,再按emotion标号落进对应类别的文件夹。文件名用row.name(CSV的行号)保证不重名。这里没做One-Hot编码,也没做归一化,因为归一化放到DataLoader里做更合适——模型训练时每次拿到的都是同一个预处理流程,避免训练和推理两套标准。
参数说明:save那一行的format默认按扩展名推断,写.png即可;reshape一定要在split之后,否则2304个数字会排错位置;mkdir加了exist_ok=True,重复运行脚本不会报错。这个脚本跑完,train目录下应该有28709张图,test目录下是PublicTest和PrivateTest合并的7178张左右,具体数量以压缩包内CSV实际行为准。
2.3 类别不均衡:happy样本是disgust的几十倍
FER2013的七类样本数不是均匀的。happy和neutral通常各占四分之一左右,disgust经常只有两千张上下,这种不均衡会让模型偏向多数类。训练时如果发现验证集准确率很高但混淆矩阵里disgust和fear几乎全错,先别调网络结构,回来看数据分布。
我用的是一个简单粗暴的办法:给DataLoader加WeightedRandomSampler。先统计每个类别的样本数,算权重,少数类的采样权重放大,让每个batch里各类别出现频率接近。
from torch.utils.data import WeightedRandomSampler labels = [] for emotion_dir in train_dir.iterdir(): class_id = list(emotion_names.values()).index(emotion_dir.name) for img_path in emotion_dir.glob("*.png"): labels.append(class_id) class_counts = np.bincount(labels) weights = 1.0 / class_counts[labels] sampler = WeightedRandomSampler(weights, num_samples=len(labels), replacement=True)这个sampler的逻辑是:weights数组的长度等于样本总数,每个样本的权重是该类别样本数的倒数,少数类权重反而大。replacement=True表示采样时允许重复取同一张图,实际效果等同于对少数类做了在线过采样,且不会复制图片占磁盘。多数类样本被采到的概率降低,网络更新方向不再被happy这类大头带偏。注意sampler和batch_size同时生效时,DataLoader的shuffle参数必须设成False,否则会冲突。
但也不是所有项目都适合重采样。如果你的最终目标是“在真实场景下测模型”,真实场景里happy就是比disgust多,强行平衡会让模型在真实分布下误报率上升。这里要分清楚:毕业设计看混淆矩阵和各类别的average acc,重采样基本必做;产品demo要的是总体准确率,那就保持原分布,只对少数类做简单的旋转和尺度增强。这个分寸感很多人没讲,我提一句。
3. ResNet18还是ResNet50:表情识别模型选型与代码改造
数据集整理完之后,下一个决策是“用哪个ResNet”。标题里只写了ResNet,没有具体层数,这个模糊恰恰是压缩包源码里最需要看的地方——有的项目用ResNet50,有的用ResNet34,还有的用ResNet18加一层自注意力模块。选型不是越深越好,要对着数据量算账。
3.1 表情数据量撑不起ResNet50:参数规模与过拟合的账
ResNet18的参数量约1170万,ResNet50约2350万,ResNet101超过4250万。FER2013的28709张训练图分配到7个类,平均每类约4100张,这对于ResNet18来说是“刚好够微调”的量,对ResNet50来说偏少。深网络在训练集上可以把loss压到很低,但验证集准确率反而不如浅一点的模型,这是我跑表情识别时反复遇到的情况。原因不玄学:最后的全连接层参数爆炸,浅层卷积又在ImageNet预训练里学的是物体轮廓和纹理,表情数据量不足以把这些参数重新校准。
| 模型 | 参数量 | 在FER2013上的经验准确率区间 | 单epoch训练耗时(相对) |
|---|---|---|---|
| ResNet18 | 约1170万 | 65%-72% | 基准 |
| ResNet34 | 约2180万 | 64%-72% | 约1.4倍 |
| ResNet50 | 约2350万 | 61%-70%(更易过拟合) | 约1.8倍 |
这个表是我在单卡GTX 1660上跑的经验值,不同设备会有差异。所以我的默认选择是ResNet18,输入224×224,最后一层改成7分类。只有两种情况会上ResNet34或ResNet50:一是拿到的数据集是增强后的大规模私有数据,二是做比赛需要刷高分数,用集成或大模型加各种trick。常规课设和毕设,ResNet18的精度和训练速度最平衡。
3.2 用torchvision加载ResNet18并替换分类头:最小可跑代码
PyTorch里改ResNet的分类头非常直接,核心是理解网络的最后一层是fc,输入维度由前面的层决定——ResNet18的全局平均池化后是512维,ResNet50是2048维,改fc层时这个维度必须写对。
import torch import torch.nn as nn from torchvision import models def build_resnet18(num_classes=7, pretrained=True): if pretrained: weights = models.ResNet18_Weights.IMAGENET1K_V1 model = models.resnet18(weights=weights) else: model = models.resnet18(weights=None) model.fc = nn.Sequential( nn.Dropout(p=0.5), nn.Linear(model.fc.in_features, num_classes) ) return model model = build_resnet18(num_classes=7, pretrained=True)逻辑说明:resnet18(weights=IMAGENET1K_V1)返回的是在ImageNet上预训练好的模型,model.fc原本是一个输出1000类的线性层;我们把它整体替换成一个带Dropout的Sequential,第一层Dropout按0.5概率随机失活神经元,第二层Linear把512维映射到7维。这里没有加激活函数,因为分类loss用的是CrossEntropyLoss,它内部已经包含了softmax的数值计算,提前加ReLU反而会把负logit截断。
参数说明:Dropout的p值在0.3到0.6之间都可以试,表情识别我常用0.5;num_classes要和数据集标签对齐,FER2013是7,如果你用的是JAFFE或RAF-DB这类更细的数据集,这里要同步改;pretrained=True是迁移学习的关键,直接随机初始化在小数据集上训练,准确率会比预训练低10到15个百分点,这是可以量到的差距。
3.3 再往前走一步:给ResNet18加位置编码式的细粒度增强
ResNet18能解决的是“整体人脸特征提取”,但表情是局部肌肉动作为主,眼睛、眉毛、嘴角这些细粒度区域才是决定性线索。纯ResNet18的最后一层输出是7×7的特征图,每个格子感受野偏大,细粒度信息会被池化抹掉。如果项目要冲高分,常见做法是给ResNet加一个分支:把layer3的输出和layer4的输出做特征融合,类似轻量FPN的思路,再让融合后的特征过一层自注意力,让模型把注意力放到嘴部和眼部。这个改造带来的准确率提升通常在3到5个百分点之间,但训练时间会增加,代码复杂度也上来了。
如果你是想交一个能稳定运行、答辩能讲清楚的项目,我不建议一上来就做这个改造。先在基础ResNet18上把baseline跑出来,记录混淆矩阵,再去加FPN分支和自注意力模块做对比实验——有对比数据,报告才好写,这也是“高分项目”里最有含金量的部分。
4. 训练参数配置:学习率、数据增强与收敛曲线怎么调
ResNet18只是骨架,真正让模型在表情数据集上出成绩的是训练配置。同样的网络,学习率用0.1还是0.001,差别能到10个百分点以上;做不做数据增强,验证集准确率差距也摆在那里。这一章把三个最关键的旋钮讲清楚:数据增强怎么写、学习率怎么退火、checkpoint什么时候存。
4.1 数据增强:表情识别里的transforms组合
FER2013是48×48灰度图,输入给ResNet18时resize到224,这个放大会让像素边缘出现插值锯齿。对抗的办法是把数据增强放在resize前后做组合。以下是我常用的增强组合。
from torchvision import transforms train_transform = transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomResizedCrop(224, scale=(0.8, 1.0)), transforms.RandomHorizontalFlip(p=0.5), transforms.ColorJitter(brightness=0.2, contrast=0.2), transforms.RandomGrayscale(p=0.1), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) valid_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ])这套组合的逻辑是:Resize到256再随机裁剪到224,等价于给模型看不同尺度的脸部区域;水平翻转对表情识别是安全的——人脸左右翻转后,表情语义基本不变,这比上下翻转安全得多,上下翻转会把眼睛变到嘴的位置,属于错误增强。ColorJitter的亮度对比度扰动模拟不同光照条件,RandomGrayscale按0.1的概率把彩色图转灰度,让模型不要过度依赖颜色。最后Normalize用的mean和std是ImageNet的统计值,迁移学习场景下沿用这套数值,不要自己重新算,否则预训练权重的感受野统计会错位。
验证集只做Resize和Normalize,不做随机增强,保证评估指标稳定。测试集如果也做了RandomHorizontalFlip,同一张图每次预测结果会不一样,这是很多人踩过的一个坑。
注意:验证集和测试集不要使用RandomHorizontalFlip这类随机增强,否则同一张图片多次推理结果不一致,评估指标也会波动。
4.2 优化器与学习率策略:冻结浅层还是全部微调
表情识别的标准配置是SGD加momentum,不是Adam。SGD在小数据集上更容易收敛到平坦的极小值,泛化更好;Adam的早期收敛快,但后期验证集准确率经常被SGD反超。优化器参数设置如下。
optimizer = torch.optim.SGD( model.parameters(), lr=0.001, momentum=0.9, weight_decay=5e-4, ) scheduler = torch.optim.lr_scheduler.StepLR( optimizer, step_size=10, gamma=0.1, )lr=0.001是ResNet18配合ImageNet预训练权重时的安全起点。如果你用的是没有预训练的随机初始化,lr要提到0.01甚至0.1,否则收敛太慢;反过来,如果加载预训练权重还开0.01,模型会出现几轮loss不降反升的震荡。momentum=0.9是SGD的标准值,weight_decay=5e-4对所有参数加L2正则,这个值和batch_size关系不大,直接抄通常没问题。StepLR每10个epoch把学习率乘以0.1,适合训练总轮数在30到50轮的项目——前10轮大步探索,中间10轮收敛,最后10轮精修。
一个常被忽略的决策是“冻结哪些层”。迁移学习有两种主流做法:一种冻结前几层只用后几层微调,适合数据量极小的场景;另一种全部参数一起微调,适合FER2013这个规模。我一般选第二种,因为表情特征是高层语义,浅层的边缘检测、颜色纹理特征本来就通用,一起微调不会带来明显泛化损失,反而省去了反复试验冻结层数的麻烦。
epoch数我建议先从40轮开始。表情识别不是越大越好,FER2013上跑到20轮左右验证集准确率基本已经进入平台期,继续硬训只会看到训练loss继续降、验证acc波动横盘。把epoch从80改成40,训练时间减半,最终精度几乎不变。如果嫌40轮不保险,配合早停看验证loss连续5轮不降就停,同样是省时间的好策略。
4.3 训练主循环与checkpoint保存:每个epoch记什么
训练循环要写对三件事:模型train/eval模式切换、梯度清零、checkpoint里保存哪些字段。下面是一个可复用的主循环骨架。
def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, correct, total = 0.0, 0, 0 for images, labels in loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() logits = model(images) loss = criterion(logits, labels) loss.backward() optimizer.step() total_loss += loss.item() * images.size(0) correct += (logits.argmax(dim=1) == labels).sum().item() total += images.size(0) return total_loss / total, correct / total def validate(model, loader, criterion, device): model.eval() total_loss, correct, total = 0.0, 0, 0 with torch.no_grad(): for images, labels in loader: images, labels = images.to(device), labels.to(device) logits = model(images) loss = criterion(logits, labels) total_loss += loss.item() * images.size(0) correct += (logits.argmax(dim=1) == labels).sum().item() total += images.size(0) return total_loss / total, correct / total这两个函数覆盖了训练和评估的最小闭环:train模式会启用Dropout和BN的批次统计,eval模式会关闭Dropout并用BN的全局均值方差,这两者必须配对出现,否则验证指标没有参考价值。loss.backward()之后必须带上optimizer.step()和optimizer.zero_grad(),缺少任何一个都会梯度累积或原地不动。validate里用torch.no_grad()包裹推理,省显存且不会误改梯度。
checkpoint推荐每轮都保存,但只保留最优和最后一轮。
best_acc = 0.0 for epoch in range(1, 41): train_loss, train_acc = train_one_epoch(...) val_loss, val_acc = validate(...) print(f"epoch {epoch:02d} train_acc {train_acc:.4f} val_acc {val_acc:.4f}") checkpoint = { "epoch": epoch, "model_state": model.state_dict(), "optimizer_state": optimizer.state_dict(), "val_acc": val_acc, } if val_acc > best_acc: best_acc = val_acc torch.save(checkpoint, "best_model.pt") if epoch % 10 == 0: torch.save(checkpoint, f"checkpoint_epoch{epoch}.pt")保存模型时不要只存model.state_dict(),要把epoch、优化器状态、验证集准确率一起存进去。原因是训练被打断时,光有模型权重没法恢复学习率位置和momentum状态;而best_model.pt里存了val_acc,你在写报告时可以直接读出来填表,不需要重新跑一遍推理。
5. 训练和推理中的常见坑:从加载报错到识别率上不去的排查笔记
模型代码能在本地跑通,和模型真正出成绩,中间隔着一条全是坑的路。这一章的每条记录都是实际复现排错时会遇见的现象,按“现象→原因→解决”写,遇到问题能直接对号入座。
5.1 迁移学习加载预训练权重后loss不降反升
现象:导入torchvision的resnet18预训练模型,把fc改成7类,用lr=0.001开始训练,第一个epoch的loss接近2.0,之后不降反升,到第三个epoch直接变成nan。
原因:多半是学习率相对于batch_size过大了。迁移学习场景里预训练权重已经很接近最优点,过大学习率会让权重一步跨出合理区间;另外如果代码里对输入做了错误归一化,比如用0到255的原始像素值直接喂给模型,BN层会算出一个方差极大的批次统计量,loss必然爆炸。
解决:先确认Normalize和ToTensor顺序正确,ToTensor会把像素缩放到0到1,Normalize再按ImageNet均值方差做标准化;然后把lr降到0.0001试跑两个epoch,如果loss还是发散,把batch_size减半再看梯度是否正常。这个坑八成不是网络写错了,而是输入分布和预训练权重不匹配。
5.2 训练loss下降但验证准确率卡在60%附近,怎么调都上不去
现象:训练集loss从1.8降到0.4,训练集准确率到95%,验证集准确率始终在58%到62%之间抖动,换ResNet34也一样。
原因:这个现象优先怀疑两个方向——类别不均衡和验证集分布错位。FER2013的PublicTest和PrivateTest划分与训练集同源,但如果你的解析脚本把PrivateTest也合进了训练集,验证集就只剩下难度更高的PublicTest,准确率上限会被压低。另一个常见原因是验证集缺了Normalize,或者Resize尺寸不一致,比如训练用224、验证用256,模型看到的特征尺度完全不一样。
解决:先把验证集和训练集的transforms逐行对比,确保只有数据增强不同,归一化和resize必须一致;再用脚本统计验证集各类别样本数,如果差异极大,按照2.3节的做法处理训练集;最后在验证时打印每个类别的分类准确率而不是只看总体acc,你会发现某些类别只有30%,那问题就清楚地指向不均衡或标注噪声。
5.3 推理阶段同一张图片每次预测的结果都不一样
现象:训练完加载best_model.pt,对同一张测试图片连续跑两次,第一次预测happy,第二次预测sad,概率分布每次都有微小浮动。
原因:推理时没有切换模型到eval模式。模型里的Dropout层在train模式下是随机失活神经元的,每次前向传播都在采样不同子网络,输出自然不稳定。加载完checkpoint后必须执行model.eval(),并且推理代码包在torch.no_grad()里。
解决:在predict函数开头加model.eval(),再调用model(images);如果此时图片还带着requires_grad,输出会累积计算图导致显存上涨,用torch.no_grad()包住推理段。顺手检查一下checkpoint加载的键名,model_state_dict和model.state_dict()的key顺序不一致时,load_state_dict会报missing keys,这个报错信息里列出来的key可以帮助定位到底有没有真正把权重载入。
5.4 CPU训练一个epoch要十几分钟,GPU显存又不够
现象:笔记本CPU跑ResNet18,输入224×224,batch_size设64,一个epoch要17分钟,40轮就是十几个小时;换成GPU后batch_size想设128,直接OutOfMemory。
原因:CPU慢是预期内的,但很多人的慢来自不必要的开销——DataLoader的num_workers设成0,图片解码和预处理全在主进程串行;GPU OOM则是batch_size和输入尺寸吃掉了显存,ResNet18在224×224分辨率下,batch_size 64大约需要6到8GB显存,集成显卡根本扛不住。
解决:CPU训练先限缩到最小配置——输入尺寸改176×176(ResNet18可以接受非正方形输入,池化层会自动适配),batch_size降到16,num_workers设成你的CPU逻辑核数的一半,pin_memory=True。GPU训练则把batch_size从32开始试,OOM就减半,直到不报错为止;如果有混合精度条件,torch.cuda.amp的GradScaler能把显存占用砍掉约一半,训练速度也更快。表情识别这个数据量,没有GPU也能出结果,就是把batch_size和epoch数调小,多等一个晚上而已。
5.5 把PyTorch模型导出成ONNX后预测准确率下降
现象:用torch.onnx.export导出模型,在onnxruntime里跑推理,发现和PyTorch原模型预测结果不一致,某些图片的类别直接反转。
原因:最常见是导出时没固定输入尺寸,动态shape导致Resize和Normalize的输出布局变化;也可能是导出前模型处于train模式,Dropout层被带进了ONNX图。
解决:导出前强制model.eval(),export接口里设置input_names、output_names和opset_version;如果是自定义的transforms里的操作,导出的ONNX只包含网络图,不含预处理,确保外部推理时预处理和训练时一致,尤其是Normalize的mean/std不能漏。导出完后用onnxruntime直接跑一次对比脚本,把每个类别的概率列出来核对。
6. 进阶验证:用混淆矩阵和遮挡测试判断模型是否真的可用
模型训练完毕,准确率数字能看了,但离“能交差”还差一步——要证明模型是真的在识别人脸表情,而不是在背训练集。这一步在答辩和工程对接里价值很高。
6.1 混淆矩阵:定位模型到底在哪些类上打架
先在验证集上跑一遍推理,收集所有真实标签和预测标签,用sklearn画出混淆矩阵。
from sklearn.metrics import confusion_matrix import seaborn as sns import matplotlib.pyplot as plt emotion_names = ["angry", "disgust", "fear", "happy", "sad", "surprise", "neutral"] all_preds, all_labels = [], [] model.eval() with torch.no_grad(): for images, labels in valid_loader: logits = model(images.to(device)) preds = logits.argmax(dim=1).cpu() all_preds.extend(preds.numpy()) all_labels.extend(labels.numpy()) cm = confusion_matrix(all_labels, all_preds) sns.heatmap(cm, annot=True, fmt="d", cmap="Blues", xticklabels=emotion_names, yticklabels=emotion_names) plt.show()逻辑说明:argmax(dim=1)取每个样本概率最大的类作为预测结果;confusion_matrix的行是真值、列是预测值,对角线越亮说明分类越可靠。表情识别里最常见的混淆对是fear和surprise——两者都有睁大眼睛的形态,张嘴幅度相近;sad和neutral也容易混,因为平静表情和轻度悲伤在嘴部、眉部特征上高度重叠。看到这两对混在一起,说明模型行为正常,属于数据本身的类间相似度高,报告里写清这一点反而是加分项。
6.2 遮挡敏感性测试:哪些区域对预测结果影响最大
比混淆矩阵更直观的是遮挡测试。把测试图片的眼睛区域、嘴巴区域分别涂黑,再送入模型看预测变化。
def occlude_and_predict(model, image_tensor, bbox, device): # image_tensor 是 (C, H, W) 的归一化张量 occluded = image_tensor.clone() x1, y1, x2, y2 = bbox occluded[:, y1:y2, x1:x2] = 0.0 with torch.no_grad(): logits = model(occluded.unsqueeze(0).to(device)) return torch.softmax(logits, dim=1).squeeze(0) # 眼睛区域、嘴巴区域按 224x224 输入图的坐标估算 eye_bbox = (60, 70, 164, 110) mouth_bbox = (70, 160, 150, 190)这个测试回答一个关键问题:模型是依赖眼睛和嘴部这些语义区域做判断,还是靠背景、肤色等无关线索。如果遮住眼睛后预测从surprise变成neutral,说明模型确实抓到了眼部特征;如果遮住背景区域预测却大幅变化,说明模型学偏了,需要回去检查数据增强里的RandomResizedCrop是否裁剪过狠,或者训练集里是否混入了大量背景干扰。遮挡测试在答辩现场演示效果很好,两张图一对比,模型的决策依据一目了然。
我的习惯是最终把这三个东西放进交付报告:best_model.pt、混淆矩阵热力图、遮挡测试效果图。模型文件保证可复现,两张图用来证明“模型学的是表情”。表情识别这个方向,ResNet18加迁移学习加正确的数据增强,已经能做出70%左右的验证准确率,比起堆模型深度,把数据分布吃透、把验证手段做全,才是让它从“能跑”变成“可信”的关键。希望这些踩过的坑能帮你少走几步弯路。
本文还有配套的精品资源,点击获取