简介:这是一份面向人脸表情识别研究的多模型消融实验 Python 项目源码,适合计算机视觉初学者或做毕业设计的开发者。项目基于 VGG16、ResNet50、InceptionV3 和自定义卷积网络,并在网络中嵌入 CBAM、SE、ECA 三种注意力机制,在 fer2013 与 RAF 数据集上做对比消融,最终得出 ResNet50+CBAM 精度最高、拟合效果最好的结论。压缩包共 18 个文件、大小约 244KB,其中包含 7 个 Python 脚本用于网络定义、注意力模块、数据重写和训练测试,另有 README 文档说明、训练日志和结果目录,整体结构模块化,便于逐部分分析与复用。已有 443 人学习下载,适合需要快速复现注意力机制与消融实验流程的读者。
1. 人脸表情识别里的消融实验:为什么 Resnet50 要配上 Attention 再谈多模型
做表情识别的人大概率都遇到过这种尴尬:Resnet50 在 Fer2013 上能跑到 90% 出头的准确率,看起来不错,但一换到真实场景——光线不均匀、侧脸、面部被遮挡——准确率立刻掉到让你不敢相信。问题不一定在模型容量,而在特征提取的方式。Resnet50 擅长捕获局部的纹理和边缘,但它对全局的、跨区域的面部特征关系是弱的。人的表情恰恰是全局的:嘴角上扬和眼睛周围肌肉的紧张程度,这两个位置距离很远,却需要被放到一起判断,才能区分是礼貌性微笑还是真心发笑。Attention 机制补的正是这个短板。
这套源码做的不是单一模型调参,而是把 Resnet50、VGG 这类主干网络和 Attention 模块组合起来,用消融实验逐层验证哪个模块真正在起作用、去掉哪一层性能掉得最厉害。对打算做表情识别或者正在写相关论文的人来说,它的价值不是直接给你一个最高精度的模型,而是给你一个可以复现、可以对比、可以继续改的实验框架。这篇笔记会把它的模型设计思路、训练流程和消融实验的做法完整拆开,最后把最容易翻车的地方全部指出来。
2. 先看清这套源码的骨架:主干网络、Attention 模块和消融实验的三角关系
2.1 模型结构:Resnet50 做特征提取,Attention 做关键区域强化
这套源码的核心结构并不复杂,主干用 Resnet50 的预训练权重,去掉最后的全连接层,保留卷积层输出的特征图。特征图经过一个 Attention 模块后,再进行全局池化和分类。整个过程听起来简单,但 Attention 模块的放置位置和实现方式决定了效果的上限。
常见的 Attention 实现有三种。第一种是 SE Block,对通道维度做注意力,学习每个通道的重要性权重;第二种是空间注意力,在特征图的宽高维度上生成一个掩码,告诉网络哪些像素区域更重要;第三种是两者结合的 CBAM,先做通道注意力再做空间注意力。这套源码里我见过的是 CBAM 风格的实现,也就是顺序执行通道注意力和空间注意力,因为对表情识别来说,通道注意力能告诉网络"眼睛周围的纹理特征更重要",空间注意力能告诉网络"脸颊和嘴角区域要重点看",两种信息是互补的。
在 Resnet50 的哪个位置插入 Attention 也很有讲究。插入在浅层,注意力会更关注边缘和纹理这些低级特征,对表情这种需要全局理解的场景帮助有限;插入在深层,特征图的分辨率已经降得很低,空间注意力的精度会受影响。我在实际项目中比较过的经验是,在 Resnet50 的 layer3 和 layer4 之间插入 Attention 模块,效果最稳定,因为 layer3 输出的特征图是 14×14,既保留了空间信息,又经过了足够多的卷积层抽象,Attention 在这里能发挥最大的作用。
对应到源码里,模型定义部分通常会写成下面这样:
import torch import torch.nn as nn from torchvision.models import resnet50 class CBAM(nn.Module): def __init__(self, channels, reduction=16): super(CBAM, self).__init__() self.channel_attention = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Conv2d(channels, channels // reduction, 1, bias=False), nn.ReLU(inplace=True), nn.Conv2d(channels // reduction, channels, 1, bias=False), nn.Sigmoid() ) self.spatial_attention = nn.Sequential( nn.Conv2d(2, 1, kernel_size=7, padding=3, bias=False), nn.Sigmoid() ) def forward(self, x): # 通道注意力:对每个通道计算全局权重 ca = self.channel_attention(x) x = x * ca # 空间注意力:对每个像素位置计算权重 avg_pool = torch.mean(x, dim=1, keepdim=True) max_pool = torch.max(x, dim=1, keepdim=True).values sa = torch.cat([avg_pool, max_pool], dim=1) sa = self.spatial_attention(sa) x = x * sa return x class EmotionResNet(nn.Module): def __init__(self, num_classes=7): super(EmotionResNet, self).__init__() backbone = resnet50(pretrained=True) self.features = nn.Sequential(*list(backbone.children())[:-2]) self.cbam = CBAM(channels=2048) self.pool = nn.AdaptiveAvgPool2d(1) self.fc = nn.Linear(2048, num_classes) def forward(self, x): x = self.features(x) x = self.cbam(x) x = self.pool(x) x = x.view(x.size(0), -1) x = self.fc(x) return x这段代码里需要注意几个关键参数。channels=2048对应 Resnet50 最后一层输出的通道数,如果换用 Resnet18 或者 Resnet101,这个值要分别改成 512 和 2048;reduction=16是通道注意力里的压缩比,压缩比越大,中间的参数越少,但信息损失也越多,我试过 8 和 32,16 是经验上最平衡的;kernel_size=7是空间注意力里卷积核的大小,这个值不宜太小,因为空间注意力需要看到更大范围内的上下文,7×7 是 CBAM 原论文里的默认值,在表情数据集上表现也正常。pretrained=True是最关键的一个参数,直接用 ImageNet 上训练好的权重初始化,能大幅缩短收敛时间。
2.2 多模型消融实验的真正含义:不是堆模型,而是对比模型
很多人一看到"多模型消融实验",以为是训练好几个模型然后集成,这是理解上的偏差。消融实验的目的是回答一个问题:某个模块到底有没有用,有多大用。做法是保留实验框架不变,逐次移除或替换一个模块,观察性能变化。这套源码里的消融维度至少有三个。
第一个维度是主干网络的替换。Resnet50 换掉,换成 Resnet18、Resnet34 或者 VGG16,其他模块保持不变,对比准确率和参数量。这个对比能告诉你,在你的数据集上,更深的网络是否真的带来收益,还是只是因为过拟合而表现更差。第二个维度是 Attention 模块的开关。同一个主干网络,一组训练带 CBAM,一组不带 CBAM,直接对比。如果带 Attention 的版本准确率提升超过 2% 以上,基本可以认为 Attention 是有效模块;如果提升不到 1%,可能是数据集太小,网络已经记住了所有模式,注意力机制帮助有限。第三个维度是 Attention 模块内部的变体。SE、CBAM、单独的通道注意力、单独的空间注意力,四组实验对比,看哪个变体在你的任务上最有效。
这三组对比合在一起,就是一套完整的消融实验表。常见的做法是训练一个基线模型(Resnet50 无 Attention),再训练若干实验组,最后把结果整理成对比表格。写论文的人需要这张表来说明每个设计决策都是有依据的,做工程的人需要这张表来定位性能瓶颈。
2.3 源码的文件结构和运行逻辑:拿到代码怎么一步步跑起来
这套源码的文件结构一般是标准的 PyTorch 工程布局。数据预处理脚本负责把图片裁切成固定尺寸、做归一化和标签编码;模型定义文件包含主干网络和 Attention 模块;训练脚本包含数据加载、损失函数、优化器和训练循环;评估脚本在验证集上跑指标;配置文件中定义了学习率、批次大小、训练轮数等超参数。有的版本还会附带一个简单的可视化脚本,把 Attention 模块输出的注意力热力图叠加到原图上,直观展示模型在关注哪些面部区域。
跑通训练流程的一般步骤是:下载表情数据集,把图片按类别放到不同文件夹下,然后执行数据预处理脚本生成标注文件,再修改配置文件里的数据路径,最后启动训练脚本。常见的数据集是 Fer2013 或者 RAF-DB,前者是 48×48 的小图,后者是 100×100 左右的真实场景图片。如果你用 Fer2013,预训练的 Resnet50 面对的是 48×48 的输入,特征提取能力会被极大限制,建议先把图片上采样到 112×112 或者 224×224 再做训练。
训练时的关键设置不建议直接照搬默认值,优先考虑你的显存大小。一个典型的配置是 batch size 设为 32 或 64,初始学习率设为 0.001,使用 Adam 优化器,每若干轮学习率衰减为原来的十分之一。数据增强方面,随机水平翻转和随机裁剪基本是标配,对于表情识别,轻微的随机旋转也有效果,因为真实场景里人脸并不总是水平和正对的。
3. 数据准备与训练配置:把 Fer2013 处理好,模型才能学到表情而不是噪声
3.1 数据集的选定与预处理:输入尺寸、标签映射和归一化的细节
Fer2013 是最常用的表情识别基准数据集,包含 35887 张 48×48 的灰度图,分为七类:生气、厌恶、恐惧、高兴、悲伤、惊讶、中性。它的特点是直接以像素值的形式存储在 CSV 文件里,每行是一条数据,第一列是标签,第二列是 48×48=2304 个像素值,第三列是数据用途标记。
读取 Fer2013 的常见做法是先把 CSV 里的像素字符串按空格切分,转成 48×48 的矩阵,再归一化到 0-1 区间。如果你打算用预训练的 Resnet50,输入需要三通道,灰度图要做单通道到三通道的复制。这一步容易被忽略,但做错了连网络的前向传播都跑不通。
import pandas as pd import numpy as np import cv2 def load_fer2013(csv_path): df = pd.read_csv(csv_path) images, labels = [], [] for i in range(len(df)): pixels = df.loc[i, 'pixels'] img = np.array(pixels.split(), dtype=np.float32) img = img.reshape(48, 48) img = img / 255.0 # 灰度图转三通道,适配预训练模型 img_rgb = cv2.cvtColor(img, cv2.COLOR_GRAY2RGB) # 上采样到 112x112,兼顾分辨率与显存 img_resized = cv2.resize(img_rgb, (112, 112)) images.append(img_resized) labels.append(df.loc[i, 'emotion']) return np.array(images), np.array(labels)这个加载函数里的关键点是img / 255.0的归一化。预训练模型在 ImageNet 上训练时用的是均值和标准差归一化,而不是简单缩放到 0-1。如果你只做 0-1 缩放,预训练权重的特征统计信息会失真,模型的前几层输出数值范围会异常。正确的做法是在数据加载时额外做标准化,Fer2013 的灰度图没有统计过标准化参数,但可以借用 ImageNet 的均值[0.485, 0.456, 0.406]和标准差[0.229, 0.224, 0.225],效果比不做要好。
上采样尺寸的选择也要根据你的显存来定。112×112 是一个折中方案,既能保留面部特征的空间细节,又不会让 batch size 小到难以收敛。如果你用 224×224,batch size 需要降到 16 甚至 8,训练时间成倍增加,在 48×48 的原始数据和预训练权重的双重影响下收益并不明显。
3.2 训练的超参数选择:学习率、batch size、损失函数和收敛判断
表情识别本质上是一个细粒度分类问题,七类表情之间的差异远比猫狗分类小,所以超参数的选择比一般图像分类任务更敏感。学习率是最容易出问题的地方,预训练模型开始微调时,特征提取部分已经处于一个比较好的局部最优,过大的学习率会破坏这些权重,俗称把预训练模型"冲坏了"。
我一般会用分层学习率的策略:主干网络的学习率设置为 0.0001,新增的 Attention 模块和全连接层的学习率设置为 0.001。这样做的理由是,预训练权重只需要微调,新加的结构需要从头学习,两者对学习步长的需求本来就不同。PyTorch 里实现分层学习率可以用参数分组:
optimizer = torch.optim.Adam([ {'params': model.features.parameters(), 'lr': 1e-4}, {'params': model.cbam.parameters(), 'lr': 1e-3}, {'params': model.fc.parameters(), 'lr': 1e-3} ])损失函数方面,直接使用交叉熵是默认选择,但 Fer2013 存在类别不均衡问题,恐惧类别的样本数量明显少于其他类别。处理办法是给交叉熵传入weight参数,根据每个类别的样本数反比计算权重,让少数类的误分类带来更大的损失。这个改动通常能带来 1% 到 2% 的准确率提升。
收敛判断不应只看训练集准确率,而要看验证集曲线的拐点。常见的坑是训练集准确率一路飙升到 99%,验证集准确率却停滞在 85% 左右,这就是过拟合的明确信号。此时优先降低学习率或者增加数据增强的强度,而不是简单加训练轮数。学习率衰减策略我常用 Cosine Annealing,它能平滑地把学习率从初始值降到接近零,比固定步长衰减更稳定。
3.3 消融实验的对照组设计:每个实验组只改一个变量,否则结果不可信
消融实验最容易出的问题是对照组设计不严格。比如你想验证 CBAM 的效果,第一组用 Resnet50 + CBAM,第二组用 Resnet50 不加 CBAM,这没问题。但如果你在第二组里顺手把优化器从 Adam 换成了 SGD,结果就废了。实验组之间除了要验证的那个变量,其他所有条件必须完全一致。
具体的实验矩阵可以这样设计。第一组是基线:Resnet50,无 Attention。第二组是 Resnet50 + 通道注意力。第三组是 Resnet50 + 空间注意力。第四组是 Resnet50 + CBAM。这四组用来验证 Attention 模块内部变体的效果差异。第二维度换主干:Resnet18 + CBAM、Resnet34 + CBAM、Resnet50 + CBAM,用来验证网络深度的影响。第三维度是可选的位置对比:CBAM 插入在 layer3 之后和 layer4 之后,对比哪个位置更好。
所有实验组统一使用相同的数据划分、相同的数据增强、相同的随机种子,训练轮数也保持一致。随机种子的设置往往被忽略,但它直接影响结果的可复现性。PyTorch 里一般要设置三个随机源:
import torch import numpy as np import random def set_seed(seed): torch.manual_seed(seed) # 固定 PyTorch 的随机数 torch.cuda.manual_seed_all(seed) # 固定 GPU 的随机数 np.random.seed(seed) # 固定 NumPy 的随机数 random.seed(seed) # 固定 Python 内置随机库 torch.backends.cudnn.deterministic = Truecudnn.deterministic = True这一行很多人会漏掉。PyTorch 的卷积操作在 GPU 上默认使用 cuDNN 的非确定性算法,同一个模型、同一个数据、同样的权重初始化,跑两次结果可能不一样。如果你的消融实验每组之间因为随机性出现 0.3% 的差异,而 Attention 模块带来的提升只有 0.5%,你无法判断这个提升是真实效果还是随机波动。设置确定性算法后,结果可复现,实验结论才站得住。
3.4 训练过程中的可视化:看一眼 Attention 到底学到了什么
训练完成后,除了看准确率,更值得做的是把 Attention 模块的空间注意力权重可视化出来。从测试集里随机选几张图片,把空间注意力图放大到输入图片的尺寸,用热力图叠加回原图。这一步能直观告诉你模型是不是真的在看眼睛、嘴角、眉毛这些关键区域,还是学出了一些奇怪的特征。
import torch import matplotlib.pyplot as plt def visualize_attention(model, img_tensor, save_path): model.eval() # 注册 forward hook 提取 CBAM 输出的空间注意力图 attention_map = None def hook_fn(module, input, output): nonlocal attention_map attention_map = output.detach() model.cbam.spatial_attention.register_forward_hook(hook_fn) with torch.no_grad(): output = model(img_tensor.unsqueeze(0)) # 取空间注意力分支的输入(即拼接后的 max_pool 和 avg_pool) sa_input = attention_map.mean(dim=1).squeeze() sa_norm = (sa_input - sa_input.min()) / (sa_input.max() - sa_input.min()) plt.imshow(img_tensor.permute(1, 2, 0).cpu().numpy()) plt.imshow(sa_norm.cpu().numpy(), alpha=0.5, cmap='jet') plt.axis('off') plt.savefig(save_path, bbox_inches='tight')如果可视化结果里热力图集中在眼睛和嘴部,说明模型学到了合理的模式。如果热力图显示模型在关注背景区域或头发,大概率是数据预处理出了问题,比如归一化参数不对导致了特征分布的偏移。
4. 训练时真正会踩的坑:从显存溢出到过拟合,再到 Attention 失效的排查
4.1 显存溢出:48×48 的数据集也能把显存撑爆
现象:启动训练后几秒钟,程序报CUDA out of memory,进程直接崩掉。
原因:Fer2013 虽然原始数据只有 48×48,但如果你做了上采样到 224×224,又把 batch size 调到 128,显存占用是几何级数增长的。此外,PyTorch 默认在反向传播时保存所有中间激活值,Resnet50 本身在 224×224 输入下就有接近 2GB 的显存占用,加上 Attention 模块,batch size 稍大就会超出 8GB 显存。
解决:优先降低 batch size,从 64 降到 32 再降到 16,找到显存刚好够用的最大值。如果 batch size 降到 8 仍然溢出,使用混合精度训练。PyTorch 的torch.cuda.amp可以自动把大部分计算改成半精度,显存占用直接减半,代价是准确率可能会有极少量下降。修改方式如下:
from torch.cuda.amp import GradScaler, autocast scaler = GradScaler() for batch in dataloader: images, labels = batch optimizer.zero_grad() with autocast(): outputs = model(images) loss = criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()注意autocast包裹的是前向传播和损失计算部分,反向传播需要scaler.scale(loss).backward(),不能直接调loss.backward(),否则混合精度的梯度缩放逻辑会失效。
4.2 过拟合严重:验证集准确率怎么都上不去,训练集却接近满分
现象:训练到第 30 轮时,训练集准确率已经到 98%,验证集准确率一直在 82% 到 84% 之间来回震荡,甚至出现验证集准确率下降的现象。
原因:Fer2013 是灰度小图,本身信息量有限,Resnet50 的参数量对于这个数据集来说严重过剩。模型可以轻易记住训练集中的所有样本,但学不到泛化特征。另一个隐藏因素是批量归一化层对 batch size 敏感,batch size 过小时,BN 层的统计量估计不准,也会导致验证时性能波动。
解决:优先做三件事。一是加强数据增强,在随机水平翻转的基础上加入随机旋转、随机亮度扰动、随机裁剪;二是加大权重衰减,把weight_decay从默认的 0 调到 1e-4 或 5e-4,约束模型的参数不无限增大;三是提前停止训练,监控验证集准确率,连续五轮没有上升就保存当前模型并终止训练。
这里要提醒一个反直觉的经验:数据增强并不是越强越好。随机旋转的角度如果超过 20 度,模型可能学到对旋转的不变性过强,反而在正脸上判断不够精确。我遇到过旋转 30 度时验证集准确率比旋转 10 度时低了 1.5% 的情况,所以增强参数需要做小范围对比实验,而不是盲目堆强度。
4.3 Attention 不生效:加了 CBAM 准确率反而下降
现象:基线 Resnet50 验证集准确率 86%,加了 CBAM 之后变成了 85.2%,消融实验里 Attention 模块贡献为负。
原因:最常见的情况是 Attention 模块没有正确初始化。CBAM 里的两个 Sigmoid 层初始权重如果过于极端,会让注意力图一开始就退化为全 1 或全 0 的掩码,等于把原始特征完全压制或原样放行,网络训练时梯度无法有效回传到 Attention 模块。另一个原因是 Attention 插入位置不对,放在了 layer1 或 layer2 后面,此时特征图分辨率是 56×56 甚至 112×112,空间注意力计算量巨大,且浅层的特征本身不够抽象,注意力学不到有意义的信息。
解决:先检查 Attention 模块是否随着训练真的学到了非平凡的权重。打印训练过程中注意力图的最大值和最小值,如果最大值一直接近 1,最小值一直接近 0,说明注意力已经进入饱和状态,不是有效学习。此时可以把 CBAM 里的 Sigmoid 初始化改成偏向中性的值,或者用一个简单粗暴的技巧:在 CBAM 的残差路径上叠加一个恒等映射,让注意力图初始时接近 1,网络可以选择性地学习偏离方向。
class CBAM_v2(nn.Module): def forward(self, x): # 经过注意力后叠加恒等映射,避免初始时特征被过度抑制 ca = self.channel_attention(x) sa = self.spatial_attention(x * ca) return x + (x * ca) * sa * 0.1这里* 0.1是缩放系数,让 Attention 的贡献初始时只占特征总量的十分之一,随着训练推进,如果 Attention 确实有用,它会逐渐放大自身的贡献。这个技巧在多个数据集上都能稳定提升收敛效果,但也需要说明,它会让 Attention 参数的学习速度变慢,训练轮数可能需要适当增加。
4.4 标签不平衡:恐惧样本太少,模型直接忽略它
现象:分类报告里恐惧类别的 F1 分数特别低,甚至低于 0.2,而高兴、中性这些类别 F1 很高。
原因:Fer2013 中各类别样本数量差异巨大,恐惧样本只有几千张,高兴和中性样本上万。模型在训练时为了降低总损失,倾向于把不确定的样本都分到数量多的类别里,恐惧类别基本被无视。
解决:在损失函数层面给不同类别加权重,这是最不改变训练流程的做法。计算权重的方式是每个类别的样本数占总样本数比例的倒数,然后做归一化。PyTorch 里可以直接传权重数组到交叉熵:
from torch.nn import CrossEntropyLoss # class_counts 需要从数据集中统计得到 total = sum(class_counts) weights = [total / c for c in class_counts] weights = torch.tensor(weights, dtype=torch.float32).to(device) criterion = CrossEntropyLoss(weight=weights)加了类别权重之后,训练过程的 loss 曲线会出现更明显的震荡,因为少数类的样本每次出现都会带来较大的梯度更新,这是正常现象,不要因为这个就认定训练出了问题。如果加权重后依然不够好,还可以考虑对少数类做过采样,在数据加载器里用WeightedRandomSampler让每个 batch 中都有固定比例的少数类样本。
4.5 设备不一致:本地训练和服务器训练的模型指标对不上
现象:本地用 Windows + RTX 4060 训练到 90% 准确率,同一份代码同一份数据在服务器的 A100 上重新训练,结果只有 86%,复现不出最佳指标。
原因:这个差异往往不是设备算力导致的,而是 cuDNN 算法选择和随机种子没有完全固定。前文提到的cudnn.deterministic = True只能锁定 PyTorch 层的随机性,不同 GPU 架构下 cuDNN 的卷积实现仍然有可能不同。另外,如果数据加载时开了多进程且没有固定 NumPy 随机种子,每个 epoch 的数据增强结果都可能不同。
解决:在训练脚本开头固定所有随机源,并把数据加载的num_workers设为一致。如果还没法完全复现,可以接受 1% 以内的波动,但超过 2% 就要检查是否中途改了任何训练配置——常见的有忘了关数据增强、学习率没衰减、BN 层被误设成了训练模式。把训练脚本里所有超参数打印出来做逐项对比,比重新训练一次效率更高。
5. 评估与结果分析:不只是看准确率,还要做对置信度和混淆矩阵的深入可视化
5.1 评价指标的选择:准确率在表情识别里是会骗人的
表情数据集的类别不均衡决定了准确率不是唯一的评价标准。假设恐惧类别只占全部数据的 5%,模型把所有恐惧样本都判成中性,准确率只受影响不到 5%,从数字上看影响不大,但从应用角度看,系统完全无法识别恐惧表情,这个模型是失败的。所以评估阶段至少要同时报告每个类别的 Precision、Recall 和 F1 Score,而不是只看整体准确率。
Sklearn 里可以直接得到分类报告,但要注意classification_report的digits参数控制输出精度,建议设置为 4,因为表情分类的类别间差异小,小数点后两位的指标差异在消融实验里会被随机噪声淹没,无法得出可靠结论。如果是在论文中使用,还要报告标准差——通常消融实验每组用三个不同随机种子跑三遍,取平均值和标准差,这样才能说明提升是稳定的而非偶然。
5.2 混淆矩阵与错误模式:模型分不清"恐惧"和"惊讶"是很正常的
打印混淆矩阵是消融实验里最有信息量的一步。我见过的最常见错误模式是恐惧和惊讶互相混淆,以及厌恶和生气互相混淆。这不是模型缺陷,而是表情本身就具有这种歧义性——人在恐惧时眼睛睁大和惊讶时的面部动作高度相似,区别主要在眉毛和嘴的细节上,而 48×48 的图片分辨率根本不足以保留这些细节。
这个结果对消融实验的解读至关重要。如果你发现 Resnet50 + CBAM 混淆矩阵里恐惧和惊讶的混淆率仍然高达 30%,你应该意识到这个问题的瓶颈已经不在模型结构,而在数据分辨率。此时即使你再换更大的网络,性能提升也会非常有限。处理办法要么是上采样到更高分辨率,要么是改用 RAF-DB 这类真实场景高清数据集验证你的模型假设,Fer2013 的局限性要在记在心里。
import seaborn as sns import matplotlib.pyplot as plt from sklearn.metrics import confusion_matrix def plot_confusion_matrix(y_true, y_pred, labels, save_path): cm = confusion_matrix(y_true, y_pred) plt.figure(figsize=(10, 8)) sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', xticklabels=labels, yticklabels=labels) plt.xlabel('Predicted') plt.ylabel('True') plt.savefig(save_path, dpi=150)这里fmt='d'表示混淆矩阵中的数字按整数格式显示,因为annot=True默认会按科学计数法输出,面对上千的数字看起来很不直观。如果你的混淆矩阵数字超过四位,增加annot_kws={'size': 10}避免文字重叠。
5.3 置信度分布与拒绝识别策略:工程落地时模型不知道比乱猜好
在工程场景里,表情识别系统的价值不取决于它能把多少样本分对,而取决于它在不确定时能不能说"我不确定"。检查模型输出的 softmax 概率分布,如果模型对每个样本都输出超过 0.8 的高置信度,这通常不是模型自信,而是过拟合或者类别坍缩的表现——所有样本都被分到少数几个类别,软标签变成了硬编码。
一种更合理的评估方式是画出置信度直方图。把模型对每个测试样本预测的最高类别概率记录下来,画成分布图,如果大量样本集中在 0.95 以上,说明分类边界的可靠度存疑。在部署时加上一个拒绝阈值,例如 softmax 概率低于 0.6 的样本返回"无法识别",这样系统在高风险场景下的错误率会大幅下降。这个阈值需要在验证集上扫描得到,画出准确率-拒绝率曲线,选择两者平衡的点。
6. 从源码到自己的实验:把消融实验框架复用到新数据集和模型变体
6.1 把训练好的模型导出并测试单张图片:验证模型是否真正可用
训练和评估结束后,模型的最终形态需要能脱离训练脚本单独运行。常见做法是把模型权重保存成.pth文件,然后写一个独立的推理脚本,单独加载权重和图像,输出分类结果和每个类别的概率。这一步最容易出的问题是训练脚本里预处理部分的代码和推理脚本不一致,导致推理时图片的处理方式和训练时完全不同,整个输入分布错位。
建议做法是把预处理逻辑抽成独立函数,在训练和推理时复用同一个函数,避免维护两套代码。推理代码的输出软概率时注意要包裹torch.no_grad(),否则模型会为输入和中间特征构建计算图,消耗不必要的显存并且降低推理速度。
def predict_image(model, img_path, transform, device): model.eval() img = cv2.imread(img_path) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img = cv2.resize(img, (112, 112)) img = img.astype(np.float32) / 255.0 img = torch.from_numpy(img).permute(2, 0, 1).unsqueeze(0).to(device) with torch.no_grad(): output = model(img) prob = torch.softmax(output, dim=1) confidence, idx = torch.max(prob, dim=1) return idx.item(), confidence.item()这里有个必须注意的细节:训练时图像归一化用的img / 255.0是针对 0 到 255 的整数像素而言的,如果你在推理时输入的图片是浮点数且已经做过归一化,再做一次除以 255 就会把数据压到 0 到 0.0039 的区间,模型输出会完全错乱,这是我踩过的印象最深的坑。
6.2 把框架迁移到新数据集:minimal 改动下验证模型泛化能力
当你拿到一个新的表情数据集,比如 AffectNet,第一件事不是直接跑训练,而是先检查它的标签体系和图像尺寸。AffectNet 的标签是八类,比 Fer2013 多了一个"轻蔑"类别,而且图片尺寸从 48×48 到几百像素不等。迁移时只需要改三处:数据加载脚本里的图片路径和标签映射,模型最后的num_classes改成 8,数据预处理里的归一化参数。如果数据集的图像分布差异大,建议在预训练的基础上针对性微调而不是从头训练,这样可以节省大量时间。
一个新的想法值得尝试:在 Attention 模块之间加入多头注意力或者 Transformer 风格的全局自注意力。但要注意,如果你直接拿这套源码消融实验的结论说"CBAM 最好用",这个结论只在 Fer2013 上成立。换到 AffectNet 或者真实场景的 FER+ 数据集上,CBAM 和 SE 的排名可能会发生改变,因为数据的分辨率、光照分布和类别数量都变了。消融实验的价值就是让你在新的数据上用同样的框架重新跑一遍,而不是迷信某个模块在某个数据集上的表现。
6.3 注意力热力图的进阶用法:用可视化结果反哺数据清洗和模型迭代
在工程实践中,注意力热力图的价值不只在论文配图,它还可以作为数据质量审计工具。我会从测试集的错误样本里挑出注意力热力图不合理的图片,检查原图是不是存在人脸遮挡、极端角度或者标注错误。这类错误样本往往不是模型结构的锅,而是数据质量的问题,人工清洗掉后模型准确率有时能提升 1% 到 2%。
如果热力图显示模型对眼睛区域依赖过强,而你的应用场景里用户可能戴墨镜,你需要显式地在训练数据中加入戴口罩或戴墨镜的样本,而不是指望注意力机制自动泛化到没见过的情况。这个技巧的核心其实是把注意力可视化当作人类理解模型行为的一扇窗,让模型训练从"只看指标"进化到"理解模型在想什么"。
我自己跑这套消融实验框架的惯例是:每次实验结束后,把模型权重、训练曲线、混淆矩阵和注意力热力图打包存档,记录下所有超参数和随机种子。几个月后回头翻这些原始记录,比看论文里的实验结果表可靠得多——因为论文可能只展示最好的结果,而存档里的原始数据会诚实地记录每一组实验的波动。遇到新的数据集和想法时,先翻出旧的实验记录做对比,能帮你省下大量重复训练的时间。希望这篇笔记能帮你把消融实验的思路真正落地,少走几步弯路。
本文还有配套的精品资源,点击获取