简介:这是一份面向人脸表情识别研究与深度学习初学者的项目源码,以消融实验方式系统对比主流卷积神经网络模型在融合不同注意力机制后的识别性能。项目采用视觉几何组网络、残差网络、初始网络及自定义卷积神经网络作为基线,分别嵌入卷积块注意力、压缩激励、高效通道注意力三种模块,在公开表情数据集上进行训练与验证,实验结果清晰表明残差网络搭配卷积块注意力具有最高准确率和最佳拟合度,代码基于PyTorch 1.8实现。压缩包共包含十八个文件,其中以七个脚本文件为核心,覆盖数据加载、模型构建、注意力实现、训练流程与结果保存等完整环节,另配备说明文档、效果图片及目录占位文件,整体包体仅244KB,非常轻量。项目在目录设计上区分数据加载器、数据集、日志和结果等模块,方便读者按阶段复现实验并分析日志。该资源已有四百四十三人学习,适合作为课程设计、毕业设计或算法选型时的直接参考。
1. 人脸表情识别项目里的消融实验:为什么ResNet50+Attention会被放在一起验证
做表情识别时我遇到过一种尴尬:基线模型在验证集上跑到72%的准确率,给ResNet50加上CBAM之后只涨了0.6%,看起来像Attention模块白加了。后来我把随机种子固定、数据划分对齐、训练轮数统一,重新把整套消融实验跑完才发现,这0.6%其实是稳定复现的增益——只不过它被实验噪声盖住了。这个项目源码解决的就是这件事:用一套可复现的多模型消融实验框架,把ResNet50与Attention各自贡献多少、合在一起是否有效、在什么条件下失效,彻底拆开讲清楚。适合正在做人脸表情识别方向毕业设计、论文复现,或是在评估“加个注意力模块到底值不值”的算法工程师。
2. 先定实验边界:ResNet50基线选型、Attention接入位置与消融矩阵
2.1 ResNet50当基线的三个理由与一个边界
人脸表情识别这个任务有两个显著特点:输入通常是48×48或64×64的小图,数据量从几千到几万张不等。这种规模下,ResNet50作为基线几乎是行业默认选项,原因有三条:第一,torchvision一行就能加载带ImageNet预训练权重的resnet50,省去从头训练的时间,而表情数据通常不够支撑从头收敛;第二,ResNet50的Bottleneck结构输出通道是2048维,高层特征足够抽象,后续接Attention模块时“有东西可以重标定”;第三,论文和开源项目大多拿它做对照,你跑出来的结果能和别人直接比。
ResNet50不是万能的。如果手头数据量只有两三千张,ResNet50在训练集上很快就能过拟合,验证集准确率反而比ResNet18差。这个边界要在选基线时先想清楚:你的数据集规模和对齐条件决定了基线层次。数据少,用ResNet18加Attention反而更合理,因为浅层网络参数少,Attention带来的相对增益更明显。
为什么不选VGG或EfficientNet当基线?VGG参数量大,光是全连接层就占了大部分存储,在表情这种小数据集上纯属浪费。EfficientNet压缩率好,但它的复合缩放结构太复杂,消融实验里“多一个变量”会显著增加解释难度。这正好呼应做消融实验的第一原则:一次只变一个变量。
2.2 Attention模块选型:SE、CBAM与自注意力的取舍
Attention在表情识别里的作用,本质是对特征图做重标定——让模型更关注眼睛、嘴巴周围这些判别性区域,忽略背景和遮挡。最常见的三个选择是SE(通道注意力)、CBAM(通道+空间注意力)和自注意力(Non-local)。三者的语义不同:SE只对通道维度做权重加权,CBAM在通道加权之后再对空间位置做一次加权,自注意力则直接对全局位置两两建模。表情识别中的遮挡、头部姿态变化、光照不均这些问题,空间注意力语义上更直观,这也是CBAM在表情任务里比SE更常用的原因。
| Attention类型 | 参数量增量(以2048通道为例) | 计算开销 | 适合场景 |
|---|---|---|---|
| SE | 约21万参数(reduction=16) | 低,只做全局池化和两个全连接 | 小数据量,想最少改动 |
| CBAM | 约21万通道参数+982个空间卷积参数 | 中,多了7×7空间卷积 | 遮挡多、姿态变化的场景 |
| 自注意力 | 约400万参数(2048通道投影) | 高,QKV矩阵乘法开销大 | 大样本量,浅层网络替代主干 |
在实际训练里,CBAM的通道分支可以直接复用SE的实现,空间分支是独立的,这种模块化结构让消融实验写起来非常省事。自注意力在小数据集上很容易过拟合,通常只在模型最后一层接一个,并且加很强的dropout才能稳住。
2.3 消融矩阵怎么定:四个实验组和两个控制条件
消融矩阵的设计核心是“每次只动一个变量”。以这个项目为例,我会定四个实验组:ResNet50基线、ResNet50+SE、ResNet50+CBAM、ResNet50+SE+CBAM。前三个用来验证单一Attention的增益,第四个用来验证两个Attention叠加是否还有增量。如果你还想做更细的消融,可以再拆一组“CBAM不加空间分支只留通道分支”,但这组实验和“SE”在效果上高度相关,容易让人混淆,非必要不做。
| 实验组编号 | 模型构成 | 验证目标 |
|---|---|---|
| A | ResNet50 baseline | 对照基准 |
| B | ResNet50 + SE | 通道注意力单独作用 |
| C | ResNet50 + CBAM | 通道+空间注意力叠加作用 |
| D | ResNet50 + SE + CBAM | 组合是否还有增量 |
两个控制条件必须写死在训练脚本里:随机种子和训练数据划分。随机种子不一致,两次baseline的结果差甚至可能超过Attention带来的提升,那整个实验就白做了。我的经验是统一固定seed为42,并且数据划分在实验开始前就生成并保存到磁盘,四个模型都用同一份train/val/test文件。Attention的接入位置也要固定——统一接在ResNet50的layer4输出之后。如果一组接在layer4后、另一组接在每个Bottleneck内部,结果差异说不清是模块的贡献还是位置的影响。
3. 把FER2013变成训练数据:数据集读取、增强与类不均衡处理的完整代码
3.1 FER2013的CSV结构解析:不写死列名的读取方法
FER2013是这个方向最普及的公开数据集,单张48×48灰度人脸图,共35887张,7类表情(愤怒、厌恶、恐惧、开心、悲伤、惊讶、中性)。它打包在一个CSV文件里,官方划分方式是Training、PublicTest、PrivateTest三个集合。CSV的列结构在不同版本里有差异——有的顺序是emotion,pixels,Usage,有的是Usage开头,直接按下标读很容易翻车。
import csv import numpy as np from PIL import Image from torch.utils.data import Dataset class FER2013Dataset(Dataset): def __init__(self, csv_path, phase='train', transform=None): self.transform = transform self.images = [] self.labels = [] with open(csv_path, 'r', encoding='utf-8') as f: reader = csv.reader(f) header = next(reader) # 用列名定位索引,不依赖列顺序 col_usage = header.index('Usage') col_pixels = header.index('pixels') col_emotion = header.index('emotion') for row in reader: if row[col_usage].strip() != phase: continue pixels = np.array( [int(p) for p in row[col_pixels].split()], dtype=np.uint8 ).reshape(48, 48) img = Image.fromarray(pixels).convert('RGB') # 灰度转三通道 self.images.append(img) self.labels.append(int(row[col_emotion])) def __len__(self): return len(self.images) def __getitem__(self, idx): img = self.images[idx] label = self.labels[idx] if self.transform: img = self.transform(img) return img, label这段代码的关键在header.index()那三行。CSV列顺序在不同下载源里并不一致,按列名找到索引再去读,数据格式再乱也不会崩。灰度图转RGB这一步很多人会漏:ResNet50的预训练权重是三通道输入,如果你直接拿单通道灰度图喂进去,torchvision会报维度错误,或者更隐蔽——某些实现里维度能广播成功但特征分布完全不对,训练时loss死活降不下去。
3.2 数据增强pipeline:随机仿射、随机擦除和归一化
FER2013的原始样本分辨率太低,直接resize到224×224喂给ResNet50会非常吃力。数据增强里我特意加了两个针对表情域的处理:随机仿射用来模拟头部姿态变化,随机擦除用来模拟眼镜、头发等局部遮挡。这两者正好也是表情识别任务里最常见的干扰项,训练时见过这类样本,Attention模块才有机会学会“绕过遮挡、聚焦判别区域”。
from torchvision import transforms train_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomAffine( degrees=15, translate=(0.05, 0.05), scale=(0.9, 1.1) ), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomErasing( p=0.3, scale=(0.02, 0.15), ratio=(0.3, 3.3) ), transforms.ToTensor(), transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5]) ]) val_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5]) ])参数取值说明:degrees=15表示最大旋转15度,旋转过大会把眼睛和嘴的空间关系破坏掉,表情判别信息丢失;translate是平移比例,5%很小,只用来模拟人脸在画面里的轻微偏移。RandomErasing的p=0.3表示三成概率擦掉一块区域,擦除面积在2%~15%之间。这个比例我调过几次,擦太多模型学成“盲猜”,擦太少起不到遮挡模拟的作用。注意增强只加在训练集,验证集只用Resize和归一化,否则Baseline和Attention组的验证集数据不一致,结果就是黑匣子。
3.3 类别不均衡处理:WeightedRandomSampler的使用
FER2013里“厌恶”类样本只有几百张,“开心”和“中性”各有四五千张,直接用CrossEntropy训练,模型会对大类过拟合,小类几乎学不进去。两种处理方式:一是把损失函数换成带类别权重的CrossEntropy,二是在DataLoader里用WeightedRandomSampler。我的习惯是用第二种,因为它不改变损失函数语义,消融实验四个模型的损失函数完全一致,控制变量更干净。
from torch.utils.data import WeightedRandomSampler def make_balanced_sampler(dataset): labels = np.array(dataset.labels) class_counts = np.bincount(labels) class_weights = 1.0 / class_counts sample_weights = class_weights[labels] sampler = WeightedRandomSampler( weights=sample_weights, num_samples=len(sample_weights), replacement=True ) return samplerWeightedRandomSampler的思路是给每个样本分配一个采样概率,样本所属类别样本量越少,它的权重越高。replacement=True表示允许同一个样本在一个epoch里被重复采样,这对小类别来说相当于隐式过采样。这里有个细节:加了平衡采样器之后,模型看到的类别分布变均匀了,但验证集仍然要保持原始分布,不能也加sampler,否则验证准确率和别人跑的结果没有可比性。
4. 搭模型:ResNet50基线、SE与CBAM的实现以及统一封装
4.1 用torchvision加载ResNet50做基线
基线的定义决定了整个消融实验的天花板。我用torchvision的resnet50,权重视模型版本用两种写法兼容:新版接口用weights=ResNet50_Weights.IMAGENET1K_V1,旧版训练脚本里常写的pretrained=True在新版本里已经标记为弃用。最后接一个2048→7的全连接层,把ImageNet的1000类输出换成7种表情。
import torch.nn as nn from torchvision.models import resnet50, ResNet50_Weights def build_resnet50_baseline(num_classes=7): model = resnet50(weights=ResNet50_Weights.IMAGENET1K_V1) in_features = model.fc.in_features model.fc = nn.Linear(in_features, num_classes) return model唯一的改动是最后全连接层,前面的卷积层全部保留预训练权重。为什么不在训练时冻结它们?表情识别和ImageNet图像分类底层特征有共享部分——边缘、纹理、颜色块这些低级特征在两边都有用,所以只微调顶层就能快速收敛。但如果只训练顶层而让Backbone完全冻结,表情特有的局部纹理(比如嘴巴弯曲的弧度)就学不到,效果反而不如全量微调。我的经验是全量微调但学习率调到1e-4,比从零训练低一个数量级,既利用了预训练特征又能适应新任务。
4.2 SE与CBAM模块的PyTorch实现
SE块是最简洁的注意力结构:先对特征图做全局平均池化得到每个通道的标量,再经过两个全连接层学习通道间的依赖关系,最后用Sigmoid产出0~1的权重,乘回原特征图。核心代码只有十行左右:
import torch import torch.nn as nn class SEBlock(nn.Module): def __init__(self, channels, reduction=16): super().__init__() self.squeeze = nn.AdaptiveAvgPool2d(1) self.excitation = nn.Sequential( nn.Linear(channels, channels // reduction), nn.ReLU(inplace=True), nn.Linear(channels // reduction, channels), nn.Sigmoid() ) def forward(self, x): b, c, h, w = x.shape w_att = self.squeeze(x).view(b, c) w_att = self.excitation(w_att).view(b, c, 1, 1) return x * w_attreduction=16是通道压缩比例,2048通道先压到128再还原,中间参数总量约21万。这个比例不是玄学,它平衡了表达能力与参数量——reduction太小(比如4),全连接层参数暴涨,容易过拟合;太大(比如64),通道间依赖关系建模能力不足,Attention效果会被稀释。
CBAM在SE基础上多了一个空间注意力分支:把特征图在通道维度取平均和最大值,拼成2通道的张量,过一层7×7卷积,再Sigmoid得到空间权重。平均值代表每个位置的总体响应强度,最大值代表最强烈的局部响应,两者互补,能同时捕捉“哪里都重要”和“哪里最突出”的信息。
class SpatialAttention(nn.Module): def __init__(self, kernel_size=7): super().__init__() self.conv = nn.Conv2d(2, 1, kernel_size, padding=kernel_size // 2) self.sigmoid = nn.Sigmoid() def forward(self, x): avg_out = torch.mean(x, dim=1, keepdim=True) max_out, _ = torch.max(x, dim=1, keepdim=True) att = torch.cat([avg_out, max_out], dim=1) return x * self.sigmoid(self.conv(att)) class CBAM(nn.Module): def __init__(self, channels, reduction=16): super().__init__() self.channel = SEBlock(channels, reduction) self.spatial = SpatialAttention(kernel_size=7) def forward(self, x): x = self.channel(x) x = self.spatial(x) return x注意CBAM的forward里通道注意力先作用、空间注意力后作用,这是原论文里的顺序。如果你把顺序反过来,效果会有细微下降,但这不在消融实验的讨论范围内——四个实验组的顺序保持一致即可。
4.3 统一封装:一个函数生成全部消融模型
消融实验最怕代码复制粘贴出偏差。我的做法是把每个实验组的模型封装成一个build_model(name)函数,训练脚本只传名字,不直接改模型代码。这样四个实验组的差异只在函数内部的配置项里,训练流程完全复用,降低操作失误概率。
from torchvision.models import resnet50, ResNet50_Weights def build_model(model_name, num_classes=7): if model_name == 'resnet50_baseline': model = resnet50(weights=ResNet50_Weights.IMAGENET1K_V1) model.fc = nn.Linear(model.fc.in_features, num_classes) return model if model_name == 'resnet50_se': model = resnet50(weights=ResNet50_Weights.IMAGENET1K_V1) # 在layer4输出后接SEBlock,保留预训练卷积层 model.layer4 = nn.Sequential( model.layer4, SEBlock(2048) ) model.fc = nn.Linear(model.fc.in_features, num_classes) return model if model_name == 'resnet50_cbam': model = resnet50(weights=ResNet50_Weights.IMAGENET1K_V1) model.layer4 = nn.Sequential( model.layer4, CBAM(2048) ) model.fc = nn.Linear(model.fc.in_features, num_classes) return model if model_name == 'resnet50_se_cbam': model = resnet50(weights=ResNet50_Weights.IMAGENET1K_V1) model.layer4 = nn.Sequential( model.layer4, SEBlock(2048), CBAM(2048) ) model.fc = nn.Linear(model.fc.in_features, num_classes) return model把Attention接在model.layer4 = nn.Sequential(...)这个位置,是经过考虑的。layer4输出的特征图分辨率是7×7(输入224×224经过5次下采样),空间信息已经被压缩到足够抽象,此时做空间注意力计算量最小。如果你把它接到layer2,特征图28×28,空间注意力卷积参数量不变但FLOPs会涨16倍,训练时间明显拉长,消融实验的时间成本会翻倍。我一般只接在layer4后,如果论文需要对比不同插入位置的影响,那属于另一个消融维度,要单独写一组实验。
5. 跑通消融实验:训练脚本、结果统计表和4个高频坑
5.1 固定随机种子与训练主循环
训练脚本是整个消融实验的骨架,所有实验组共用同一份代码,只是传入的model_name不同。随机种子必须在导入所有库之后、任何模型初始化之前固定,否则torch的卷积层初始化顺序不同,基线都会跑偏。
import random import numpy as np import torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed)训练主循环按标准写法来,一个epoch内遍历DataLoader,计算损失、反向传播、更新参数,然后按epoch记录验证集准确率。优化器我选AdamW而不是SGD,因为消融实验里AdamW的学习率对超参不那么敏感,四个模型跑出来的结果不容易被优化器“误伤”。
def train_one_epoch(model, dataloader, optimizer, criterion, device): model.train() total_loss = 0.0 correct = 0 total = 0 for images, labels in dataloader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() * images.size(0) _, preds = torch.max(outputs, dim=1) correct += (preds == labels).sum().item() total += labels.size(0) return total_loss / total, correct / total训练配置我通常这样设:batch size 64(单卡显存不足就降到32),初始学习率1e-4,weight decay 1e-4,epoch数50。学习率调度器用ReduceLROnPlateau,patience设为5,当验证集loss连续5个epoch不下降就把学习率降到原来的十分之一。这套参数在多个公开数据集上表现稳定,唯一的坑是batch size太小(比如8或16)时BatchNorm的统计量不稳定,Attention的效果会被噪声淹没。
5.2 结果统计表:记录什么、怎么判断“有效”
消融实验跑完,不能只记录一个最终准确率就下结论。四个实验组的模型,每个都要记录参数量、测试集准确率、每轮训练耗时。此外我强烈建议每个实验组至少跑三次,取均值±标准差。Attention带来的提升通常只有0.5%~1.5%,如果只跑一次,实验噪声和Attention效果没法区分。
| 模型 | 参数量 | 测试准确率(均值±标准差) | 每epoch耗时 |
|---|---|---|---|
| resnet50_baseline | 记录实际值 | 记录实际值 | 记录实际值 |
| resnet50_se | 记录实际值 | 记录实际值 | 记录实际值 |
| resnet50_cbam | 记录实际值 | 记录实际值 | 记录实际值 |
| resnet50_se_cbam | 记录实际值 | 记录实际值 | 记录实际值 |
判断标准不是看准确率涨了多少,而是看差异是否超过标准差。如果baseline的均值是72.3±0.4,CBAM是73.1±0.3,两者差值0.8大于标准差范围,才可以说“带来的提升是统计显著的”。如果差值是0.3而标准差也是0.4,那这个结果只能写成“无显著差异”,别硬解释。
5.3 消融实验的4个高频坑:现象、原因与解决
坑一:两次跑同一个baseline,结果差了2%以上。 现象:模型代码没改,训练脚本没动,验证集准确率却忽高忽低。原因:随机种子没固定,数据加载顺序、模型初始化、dropout都受影响。解决:在训练脚本最开头加set_seed(42),并且确认DataLoader的shuffle=True也接收了这个seed参数。
坑二:加了Attention,准确率反而比baseline低了0.5%。 现象:CBAM组训练收敛更快,但最终验证集准确率不如基线。原因:训练时没加载预训练权重,或者Attention插入位置不对,把模块接到了全连接层之后——那里输入已经是一维向量,空间注意力的卷积根本做不了。解决:检查build_model里是否传了预训练权重;确认Attention插在layer4和fc之间,插在fc后面等于白加。
坑三:四个实验组的训练曲线长得一模一样,准确率几乎没差别。 现象:Attention完全没有发挥作用。原因:训练epoch太少,模型还没收敛到能区分细节的阶段;或者学习率太大,loss在震荡。解决:先把baseline跑到50个epoch看曲线是否平稳,再加Attention对比。如果baseline没收敛,Attention的增益无从谈起。
坑四:验证集上加了Attention的结果是高了,但换了数据集划分就不再成立。 现象:同一套代码在FER2013上有效,换到CK+或者RAF-DB上就失效。原因:数据划分不一致,或者增强管线在不同数据集的预处理上存在差异。解决:固定数据划分文件,四个实验组共享同一份train/val/test三份CSV;增强管线只改训练集,验证集固定用同一套变换。
6. 最后验证:用Grad-CAM对比Attention落点,给消融结果上个保险
准确率是结果,但Attention到底关注到了什么,还是要看热力图。我习惯在每个实验组训练完之后,取测试集里面部有遮挡或头部姿态偏转的样本,用Grad-CAM可视化和不加Attention的基线模型的类激活图差异。
def grad_cam(model, img_tensor, target_layer, class_idx): feature_map = None gradient = None def forward_hook(module, input, output): nonlocal feature_map feature_map = output def backward_hook(module, grad_input, grad_output): nonlocal gradient gradient = grad_output[0] handle_f = target_layer.register_forward_hook(forward_hook) handle_b = target_layer.register_full_backward_hook(backward_hook) output = model(img_tensor.unsqueeze(0)) model.zero_grad() output[0, class_idx].backward() handle_f.remove() handle_b.remove() weights = torch.mean(gradient, dim=(2, 3), keepdim=True) cam = torch.relu((weights * feature_map).sum(dim=1, keepdim=True)) cam = cam.squeeze().detach().cpu().numpy() return (cam - cam.min()) / (cam.max() - cam.min() + 1e-8)对baseline,target_layer取layer4的最后一个Bottleneck输出;对加了CBAM的模型,target_layer取CBAM模块本身的输出。对比两张热力图,如果CBAM组的热力图在眼睛、嘴巴区域更集中,背景区域的响应明显被压低,就说明Attention确实做了“聚焦”这件事。有一次实验准确率只涨了0.3%,但热力图中Attention组对被遮挡眼睛的样本响应明显更准,这时候看混淆矩阵才能发现,增益集中在“惊讶”和“恐惧”这两类遮挡多的表情上。
Grad-CAM不是银弹。如果热力图差异不明显,也别急着否定Attention——先检查是不是register_full_backward_hook在最新版PyTorch里被弃用导致梯度没采到,这是我踩过的坑,旧接口register_backward_hook在torch 1.8之后就标记为废弃了。跑Grad-CAM的代码必须放在model.eval()模式下,否则BatchNorm和dropout的行为会引入额外噪声,热力图会花成一片。
这个项目做到最后,我最大的收获不是那零点几个百分点的提升,而是养成了“每次加模块之前先想清楚怎么验证”的习惯。准确率、热力图、混淆矩阵三样东西放在一起看,才敢说某个模块是真的有效。希望帮到你。
本文还有配套的精品资源,点击获取