首页 / 资讯中心 / 文章详情

人脸表情识别消融实验:ResNet50与注意力机制怎样验证有效性

人脸表情识别消融实验:ResNet50与注意力机制怎样验证有效性 ★ FEATURED ARTICLE
简介这是一份面向人脸表情识别研究与深度学习初学者的项目源码以消融实验方式系统对比主流卷积神经网络模型在融合不同注意力机制后的识别性能。项目采用视觉几何组网络、残差网络、初始网络及自定义卷积神经网络作为基线分别嵌入卷积块注意力、压缩激励、高效通道注意力三种模块在公开表情数据集上进行训练与验证实验结果清晰表明残差网络搭配卷积块注意力具有最高准确率和最佳拟合度代码基于PyTorch 1.8实现。压缩包共包含十八个文件其中以七个脚本文件为核心覆盖数据加载、模型构建、注意力实现、训练流程与结果保存等完整环节另配备说明文档、效果图片及目录占位文件整体包体仅244KB非常轻量。项目在目录设计上区分数据加载器、数据集、日志和结果等模块方便读者按阶段复现实验并分析日志。该资源已有四百四十三人学习适合作为课程设计、毕业设计或算法选型时的直接参考。1. 人脸表情识别项目里的消融实验为什么ResNet50Attention会被放在一起验证做表情识别时我遇到过一种尴尬基线模型在验证集上跑到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万参数reduction16低只做全局池化和两个全连接小数据量想最少改动CBAM约21万通道参数982个空间卷积参数中多了7×7空间卷积遮挡多、姿态变化的场景自注意力约400万参数2048通道投影高QKV矩阵乘法开销大大样本量浅层网络替代主干在实际训练里CBAM的通道分支可以直接复用SE的实现空间分支是独立的这种模块化结构让消融实验写起来非常省事。自注意力在小数据集上很容易过拟合通常只在模型最后一层接一个并且加很强的dropout才能稳住。2.3 消融矩阵怎么定四个实验组和两个控制条件消融矩阵的设计核心是“每次只动一个变量”。以这个项目为例我会定四个实验组ResNet50基线、ResNet50SE、ResNet50CBAM、ResNet50SECBAM。前三个用来验证单一Attention的增益第四个用来验证两个Attention叠加是否还有增量。如果你还想做更细的消融可以再拆一组“CBAM不加空间分支只留通道分支”但这组实验和“SE”在效果上高度相关容易让人混淆非必要不做。实验组编号模型构成验证目标AResNet50 baseline对照基准BResNet50 SE通道注意力单独作用CResNet50 CBAM通道空间注意力叠加作用DResNet50 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, phasetrain, transformNone): self.transform transform self.images [] self.labels [] with open(csv_path, r, encodingutf-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()], dtypenp.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( degrees15, translate(0.05, 0.05), scale(0.9, 1.1) ), transforms.RandomHorizontalFlip(p0.5), transforms.RandomErasing( p0.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]) ])参数取值说明degrees15表示最大旋转15度旋转过大会把眼睛和嘴的空间关系破坏掉表情判别信息丢失translate是平移比例5%很小只用来模拟人脸在画面里的轻微偏移。RandomErasing的p0.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( weightssample_weights, num_sampleslen(sample_weights), replacementTrue ) return samplerWeightedRandomSampler的思路是给每个样本分配一个采样概率样本所属类别样本量越少它的权重越高。replacementTrue表示允许同一个样本在一个epoch里被重复采样这对小类别来说相当于隐式过采样。这里有个细节加了平衡采样器之后模型看到的类别分布变均匀了但验证集仍然要保持原始分布不能也加sampler否则验证准确率和别人跑的结果没有可比性。4. 搭模型ResNet50基线、SE与CBAM的实现以及统一封装4.1 用torchvision加载ResNet50做基线基线的定义决定了整个消融实验的天花板。我用torchvision的resnet50权重视模型版本用两种写法兼容新版接口用weightsResNet50_Weights.IMAGENET1K_V1旧版训练脚本里常写的pretrainedTrue在新版本里已经标记为弃用。最后接一个2048→7的全连接层把ImageNet的1000类输出换成7种表情。import torch.nn as nn from torchvision.models import resnet50, ResNet50_Weights def build_resnet50_baseline(num_classes7): model resnet50(weightsResNet50_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, reduction16): super().__init__() self.squeeze nn.AdaptiveAvgPool2d(1) self.excitation nn.Sequential( nn.Linear(channels, channels // reduction), nn.ReLU(inplaceTrue), 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_attreduction16是通道压缩比例2048通道先压到128再还原中间参数总量约21万。这个比例不是玄学它平衡了表达能力与参数量——reduction太小比如4全连接层参数暴涨容易过拟合太大比如64通道间依赖关系建模能力不足Attention效果会被稀释。CBAM在SE基础上多了一个空间注意力分支把特征图在通道维度取平均和最大值拼成2通道的张量过一层7×7卷积再Sigmoid得到空间权重。平均值代表每个位置的总体响应强度最大值代表最强烈的局部响应两者互补能同时捕捉“哪里都重要”和“哪里最突出”的信息。class SpatialAttention(nn.Module): def __init__(self, kernel_size7): super().__init__() self.conv nn.Conv2d(2, 1, kernel_size, paddingkernel_size // 2) self.sigmoid nn.Sigmoid() def forward(self, x): avg_out torch.mean(x, dim1, keepdimTrue) max_out, _ torch.max(x, dim1, keepdimTrue) att torch.cat([avg_out, max_out], dim1) return x * self.sigmoid(self.conv(att)) class CBAM(nn.Module): def __init__(self, channels, reduction16): super().__init__() self.channel SEBlock(channels, reduction) self.spatial SpatialAttention(kernel_size7) 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_classes7): if model_name resnet50_baseline: model resnet50(weightsResNet50_Weights.IMAGENET1K_V1) model.fc nn.Linear(model.fc.in_features, num_classes) return model if model_name resnet50_se: model resnet50(weightsResNet50_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(weightsResNet50_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(weightsResNet50_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(seed42): 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, dim1) correct (preds labels).sum().item() total labels.size(0) return total_loss / total, correct / total训练配置我通常这样设batch size 64单卡显存不足就降到32初始学习率1e-4weight decay 1e-4epoch数50。学习率调度器用ReduceLROnPlateaupatience设为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.4CBAM是73.1±0.3两者差值0.8大于标准差范围才可以说“带来的提升是统计显著的”。如果差值是0.3而标准差也是0.4那这个结果只能写成“无显著差异”别硬解释。5.3 消融实验的4个高频坑现象、原因与解决坑一两次跑同一个baseline结果差了2%以上。 现象模型代码没改训练脚本没动验证集准确率却忽高忽低。原因随机种子没固定数据加载顺序、模型初始化、dropout都受影响。解决在训练脚本最开头加set_seed(42)并且确认DataLoader的shuffleTrue也接收了这个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), keepdimTrue) cam torch.relu((weights * feature_map).sum(dim1, keepdimTrue)) cam cam.squeeze().detach().cpu().numpy() return (cam - cam.min()) / (cam.max() - cam.min() 1e-8)对baselinetarget_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的行为会引入额外噪声热力图会花成一片。这个项目做到最后我最大的收获不是那零点几个百分点的提升而是养成了“每次加模块之前先想清楚怎么验证”的习惯。准确率、热力图、混淆矩阵三样东西放在一起看才敢说某个模块是真的有效。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站