
简介面向计算机视觉与深度学习初学者及研究者这是一套基于多模型消融实验的人脸表情识别项目完整源码。项目基于PyTorch 1.8搭建集成了VGG16、ResNet50、InceptionV3以及自定义CNN等四种主干网络并将CBAM、SE、ECA三种注意力机制分别嵌入上述模型在FER2013与RAF数据集上开展系统性对比消融实验最终验证ResNet50结合CBAM的配置在识别精度与模型拟合程度上均达到最优相关结论具备较强的参考价值。资源包共18个文件以7个Python源码为核心覆盖数据加载、模型构建、注意力模块、训练与测试等完整流程另含5个keep占位文件、3张实验图片、2份说明文档及1个内嵌压缩包整体大小仅244KB目录结构清晰便于直接对照学习。已有443人浏览学习适合复现实验、完成课程设计或深入理解注意力机制对人脸表情识别任务的具体影响。1. 人脸表情识别项目的标题拆解消融实验才是这套源码的核心价值在FER2013这类公开数据集上把ResNet50的层数堆到152层识别准确率并不会比ResNet50加一个Coordinate Attention更高这是很多人脸表情识别项目里最反直觉的结论。这套Python源码要解决的不是“怎么把表情识别准确率刷到99%”而是给你一套严谨的多模型横向对比框架——以ResNet50为主干分别接入SE、CBAM、Coordinate Attention等注意力模块用消融实验量化每个模块对表情识别效果的贡献。适合正在做毕业设计、准备论文实验章节、或者要验证一个新注意力模块是否值得上线的工程师。它不追求模型最前沿追求的是变量可控、结果可复现、每个参数改动都能说清楚原因。2. 人脸表情识别先解决数据问题FER2013预处理与PyTorch数据流水线2.1 为什么选FER2013做消融实验基准数据集消融实验最怕数据集本身波动太大模型还没开始比数据就把结果搞乱了。常见做法是用FER2013作为基准数据集它有28709张训练图、3589张公共验证图和3589张测试图图片统一48x48灰度分7类生气、厌恶、恐惧、开心、难过、惊讶、中性。类别不全但足够做模块对比。AffectNet数据量更大但标注噪声高用来做最终效果展示可以做消融实验对比基线时反而容易引入额外变量。这个项目源码里对数据集的约定一般是训练集/验证集按原始划分不动测试集只在最终评估时碰一次。表情识别有个经典痛点就是类别不均衡“厌恶”类样本常年只有几百张“开心”“中性”各有几千张所以预处理阶段就要把样本分布统计好。# dataset_stats.py import numpy as np from collections import Counter import pandas as pd df pd.read_csv(fer2013.csv) # 原始csv三列emotion, pixels, Usage df[pixels_list] df[pixels].apply(lambda x: np.array(x.split(), dtypenp.float32)) # 统计各类别样本数用于后续加权采样或loss权重设置 train_df df[df[Usage] Training] counter Counter(train_df[emotion]) print(counter) # 输出示例Counter({6: 4965, 3: 7215, 4: 4830, ...}) # emotion映射0生气,1厌恶,2恐惧,3开心,4难过,5惊讶,6中性这段代码逻辑很简单但很关键pixels列是一串用空格分隔的灰度值字符串必须转成float32数组才能进模型。如果不先统计分布后面做数据增强时可能会对“厌恶”类做过度增强导致过拟合。参数上注意dtype必须用float32PyTorch默认float32如果用float64会增加显存占用且无精度收益。2.2 用PyTorch搭一套训练数据流水线把灰度图归一化做对表情识别和ImageNet分类有个重要差别输入是单通道灰度图而ResNet50预训练权重是在三通道ImageNet上训出来的。这里有两个派别一种是把灰度图复制三遍变成伪三通道另一种是只用第一通道卷积核。项目源码里常见的是第一种因为可以直接加载torchvision的ResNet50预训练权重做迁移学习省去改网络结构的麻烦。# fer_data_loader.py import torch from torch.utils.data import Dataset, DataLoader from torchvision import transforms from PIL import Image import numpy as np class Fer2013Dataset(Dataset): def __init__(self, df, transformNone): self.df df self.transform transform def __len__(self): return len(self.df) def __getitem__(self, idx): row self.df.iloc[idx] pixels np.array(row[pixels_list]).reshape(48, 48).astype(np.uint8) img Image.fromarray(pixels, modeL) # L表示灰度模式 img img.convert(RGB) # 灰度---伪三通道 label int(row[emotion]) if self.transform: img self.transform(img) return img, label # 训练transform随机水平翻转随机遮挡归一化 train_transform transforms.Compose([ transforms.RandomHorizontalFlip(p0.5), transforms.RandomAffine(degrees15, translate(0.1, 0.1)), transforms.ColorJitter(brightness0.2, contrast0.2), transforms.ToTensor(), transforms.Normalize(mean[0.5, 0.5, 0.5], std[0.5, 0.5, 0.5]) ])这里有个容易翻车的地方Image.fromarray接收的dtype必须是uint8如果上游pixels转成了float32这里会直接报错或生成全黑图。Normalize用0.5/0.5是因为灰度图复制三遍后像素范围是0-255ToTensor后变成0-1减0.5除0.5后数据落在-1到1之间这和ImageNet预训练用的mean[0.485,0.456,0.406]不一样但实测在表情识别上0.5均值效果更好因为灰度图没有彩色分布偏移。2.3 数据增强参数怎么设RandomAffine和RandomErasing的边界消融实验里数据增强策略必须固定否则你没法说清是注意力模块起作用还是增强策略起作用。项目源码里一般会固定一套增强参数RandomHorizontalFlip概率0.5、RandomAffine旋转15度、平移0.1、ColorJitter亮度对比度各0.2。这些参数来自人脸对齐后的经验值旋转超过20度会让表情语义发生变化比如“惊讶”在过度旋转后可能被人类标注者看成“恐惧”。这里要特别提醒验证集和测试集不要加RandomAffine和ColorJitter只做ToTensor和Normalize。很多人会把训练增强直接套到验证集上结果验证loss抖动得像心跳图模型好坏根本看不出来。数据流水线固定后下一步才是模型主干和注意力模块的嫁接这是这套源码的核心结构设计。3. ResNet50主干与Attention模块的实现SE、CBAM、Coordinate Attention拆开讲3.1 为什么ResNet50适合做消融实验的骨架而不是ResNet18或EfficientNet选ResNet50做消融实验主干是综合考虑深度、预训练权重的可获得性、以及模块插入的便利性。ResNet18太浅注意力模块加上去准确率提升不明显分辨不出模块好坏ResNet101训练时间太长消融实验要跑四个以上模型变体时间成本翻倍。ResNet50在ImageNet上的预训练权重质量高且torchvision里直接torchvision.models.resnet50(pretrainedTrue)就能拿到不用自己训一个backbone。更关键的是结构解耦性。ResNet50分成stem首层卷积、layer1到layer4四个残差阶段注意力模块可以很方便地插在任意一个阶段后面。项目源码里最常见的做法是插在layer3和layer4之间因为48x48输入经过四次下采样后layer3输出的特征图是6x6空间分辨率还在注意力能学到空间位置信息layer4输出是3x3分辨率太低注意力模块施展不开。3.2 写一个Coordinate Attention模块吃透SENet的通道注意力原理很多人在源码里看到Attention第一反应是NLP里的Transformer Self-Attention但人脸表情识别里的Attention几乎都是轻量级即插即用模块最常见的是SENet的SeBlock和Coordinate Attention。SENet做的是通道注意力把特征图压缩成一个通道描述符学习每个通道的重要性权重然后乘回去。Coordinate Attention是SENet的一个变体它在压缩时保留了空间方向信息把特征图沿高度和宽度方向分别做池化再进行卷积融合。# attention_modules.py import torch import torch.nn as nn import torch.nn.functional as F class CoordAtt(nn.Module): def __init__(self, inp, oup, reduction32): super().__init__() # 高度方向与宽度方向的池化保留位置信息 self.pool_h nn.AdaptiveAvgPool2d((None, 1)) self.pool_w nn.AdaptiveAvgPool2d((1, None)) mip max(8, inp // reduction) # 隐藏层通道数reduction是压缩比 self.conv1 nn.Conv2d(inp, mip, kernel_size1, stride1, padding0) self.bn1 nn.BatchNorm2d(mip) self.act nn.Hardswish() self.conv_h nn.Conv2d(mip, oup, kernel_size1, stride1, padding0) self.conv_w nn.Conv2d(mip, oup, kernel_size1, stride1, padding0) def forward(self, x): identity x n, c, h, w x.size() x_h self.pool_h(x) # (n, c, h, 1) x_w self.pool_w(x).permute(0, 1, 3, 2) # (n, c, 1, w) 转成 (n, c, w, 1) y torch.cat([x_h, x_w], dim2) # 沿高度维度拼接 y self.conv1(y) y self.bn1(y) y self.act(y) x_h, x_w torch.split(y, [h, w], dim2) x_w x_w.permute(0, 1, 3, 2) a_h self.conv_h(x_h).sigmoid() a_w self.conv_w(x_w).sigmoid() out identity * a_w * a_h return outCoordAtt的forward里有几个细节容易被忽略。池化时x_w的shape是(n, c, 1, w)要permute成(n, c, w, 1)才能和x_h在dim2上拼接。拼接后的y经过卷积融合再split回h和w两个方向。最后每个方向各自过sigmoid得到注意力权重a_w和a_h广播相乘后乘回输入。reduction32是常见默认值输入通道2048时隐藏层是64reduction改太小参数量涨得快在消融实验里要统一所有注意力模块的reduction否则不公平。如果你想把NLP里的multi-head注意力拿来做表情识别也可以但效果通常不如这种轻量模块因为特征图只有6x6自注意力学到的全局关系有限还容易过拟合。这里踩过的坑是有人把attention is all you need里的公式硬搬过来训练速度慢一倍准确率反而掉了两个点。所以源码里用Coordinate Attention这类模块不是因为它名字里有attention而是它参数量小、插进去稳定、可解释性强。3.3 用一行代码改造ResNet50替换layer4前的Bottleneck输出把注意力模块接进ResNet50不需要重写整个网络只需要在torchvision的ResNet基础上做外科手术式的修改。核心思路是拿到resnet50的children列表在layer4之前插入CoordAtt模块或者在layer4的每个Bottleneck后都插入。项目源码里一般两种都支持通过config控制。# resnet50_attention.py import torchvision.models as models import torch.nn as nn def build_backbone(archresnet50, attentionca, pretrainedTrue): model getattr(models, arch)(pretrainedpretrained) # 冻结stem和layer1的bn加快收敛并保留预训练特征 for name, param in model.named_parameters(): if layer1 in name or conv1 in name or bn1 in name: param.requires_grad False if attention ca: attn CoordAtt(inp1024, oup1024, reduction16) elif attention se: attn SEBlock(inp1024, reduction16) elif attention cbam: attn CBAMBlock(inp1024, reduction16) else: attn nn.Identity() # 在layer3输出之后、layer4之前插入注意力模块 # layer3输出的通道数是1024layer4输入期望也是1024 old_layer4 model.layer4 model.layer4 nn.Sequential(attn, old_layer4) return model这里有个隐藏的坑torchvision里的ResNet50前向是stem - layer1 - layer2 - layer3 - layer4 - avgpool - fc。layer4本身是Sequential容器你直接把它替换成nn.Sequential(attn, old_layer4)是可行的。但注意CoordAtt的oup必须是1024因为layer3输出是1024通道layer4第一个Bottleneck的conv1接收的输入通道也是1024。如果这里通道数写错了前向跑起来会直接报shape mismatch而且报错信息会指向layer4内部排查起来很费劲。另外冻结stem和layer1的参数是个实践技巧。48x48的输入比ImageNet的224x224小很多浅层特征差异大不冻结的话微调前期容易把预训练权重的底层纹理特征破坏掉。layer2和layer3保持可训练layer4和注意力模块重点更新这能显著缩短收敛时间。项目里一般用这种配置跑30个epoch就能达到基线水平。4. 多模型消融实验设计四组对照实验与训练脚本实现4.1 消融实验的矩阵怎么定Baseline、SE、CBAM、CA、CA_SE叠加消融实验的核心是控制变量。这套源码的模型对比表一般长这样模型编号主干注意力模块参数量(约)说明M0ResNet50无25.6M基线M1ResNet50SEBlock25.7M只加通道注意力M2ResNet50CBAM25.8M通道空间注意力M3ResNet50CoordAtt25.7M通道方向位置注意力M4ResNet50CA SE25.9M双注意力叠加测试参数量差距只有0.1M级别这说明注意力模块是轻量的准确率差异不是参数量带来的而是注意力机制本身。M4最容易出现的现象是准确率反而比M3低因为两层注意力对6x6特征图来说冗余度过高梯度传播路径变长。源码里一般会把这五个模型都跑一遍但训练资源配置成一致——同一个GPU、同batch size、同lr、同epoch数。4.2 训练脚本怎么写才不混乱config字典驱动多组实验多模型训练最怕的是代码里到处硬编码模型名跑完M1忘了M0结果一比对发现baseline的lr和M1不一样。项目源码里会用单个config字典管理所有超参数每次训练只改model_name和attention_type两个字段。# train_ablation.py import torch import torch.nn as nn import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR from torch.utils.tensorboard import SummaryWriter import copy, os, json, time def train_model(cfg): torch.manual_seed(cfg[seed]) torch.cuda.manual_seed_all(cfg[seed]) model build_backbone( archcfg[arch], attentioncfg[attention], pretrainedTrue ).cuda() model.fc nn.Linear(model.fc.in_features, 7).cuda() criterion nn.CrossEntropyLoss( weighttorch.tensor(cfg[class_weight]).cuda() ) optimizer optim.AdamW( model.parameters(), lrcfg[lr], weight_decaycfg[weight_decay] ) scheduler CosineAnnealingLR(optimizer, T_maxcfg[epochs]) train_loader build_dataloader(splitTraining, cfgcfg) val_loader build_dataloader(splitPublicTest, cfgcfg) best_acc 0.0 for epoch in range(cfg[epochs]): model.train() running_loss 0.0 for imgs, labels in train_loader: imgs, labels imgs.cuda(), labels.cuda() optimizer.zero_grad() outputs model(imgs) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() model.eval() correct 0 total 0 with torch.no_grad(): for imgs, labels in val_loader: imgs, labels imgs.cuda(), labels.cuda() outputs model(imgs) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() val_acc 100.0 * correct / total if val_acc best_acc: best_acc val_acc best_model copy.deepcopy(model.state_dict()) writer.add_scalar(f{cfg[exp_name]}/loss, running_loss / len(train_loader), epoch) writer.add_scalar(f{cfg[exp_name]}/val_acc, val_acc, epoch) print(fEpoch {epoch1}/{cfg[epochs]} | Loss: {running_loss/len(train_loader):.4f} | Val Acc: {val_acc:.2f}%) torch.save(best_model, f{cfg[exp_name]}_best.pth) return best_acc if __name__ __main__: base_cfg { arch: resnet50, epochs: 30, lr: 3e-4, weight_decay: 1e-4, batch_size: 64, seed: 42, num_workers: 4, } # 跑4组消融只改attention字段和exp_name for attn_name in [none, se, cbam, ca]: cfg copy.deepcopy(base_cfg) cfg[attention] attn_name cfg[exp_name] fablation_{attn_name} best train_model(cfg) print(f{attn_name} best val_acc: {best:.2f}%)训练脚本里有几个参数值得较真。lr3e-4搭配AdamW是微调ResNet50的常用起手式如果是从头训练要提到1e-3但消融实验必须用迁移学习否则训练时间翻三倍而且结果不稳定。class_weight这里要传长度为7的Tensor数值按训练集类别频率的倒数归一化类别不均衡时这个参数比任何注意力模块都管用但它不属于消融变量所有模型都要用同一套class_weight。num_workers4在Windows上有时会报BrokenPipeError改成0能跑但速度慢Linux上设4或8都行。4.3 消融实验的评估协议只信测试集验证集结果不能写进结论评估阶段有两条铁律。第一条所有模型选checkpoint的原则一致都用验证集准确率最高的那个epoch不能M0用epoch25、M3用epoch15。第二条最终结论只用PrivateTest测试集准确率验证集准确率只用来选模型。为了做到这一点源码里会在训练完成后单独写一个eval_test.py脚本统一加载各模型的best.pth在相同的预处理下跑测试集输出每个类别的分类准确率和macro-F1。这里要提醒一个血泪坑FER2013的PrivateTest一定要警惕标签错位因为csv原始文件是按行存储的如果有人用pandas做过dropna或reset_index行顺序一变结果就全错了。源码里加载测试集时不要重新shuffle不要过滤脏数据加载顺序严格按csv原始行号来否则你报的准确率对不上别人的结果。5. 消融实验避坑指南五个让准确率对不上的常见问题与排查方法5.1 梯度更新了但训练loss不降注意力模块把BatchNorm位置放错了现象加入SEBlock或CoordAtt后训练loss从1.9降到1.7就卡住验证准确率在30%左右徘徊但单独跑ResNet50就能正常收敛到65%以上。原因注意力模块里的Conv2d后面跟了BatchNorm但插入位置在ResNet50的layer3和layer4之间时BatchNorm的moving_mean和moving_var没有初始化好而且如果模型里其他层被冻结梯度更新到注意力模块后BN的统计量抖动很大。解决先把注意力模块里的BatchNorm换成GroupNorm(num_groups32)或者在训练前用一小批数据跑几次前向让BN统计量预热。更快的方法是把reduction调大从16改成32隐藏层通道数变少BN的方差波动自然变小。检查看loss曲线如果前10个epoch是缓慢下降然后平台十有八九是这个问题。5.2 验证准确率比训练集高数据泄漏发生在预处理阶段现象训练准确率85%验证准确率却92%明显违反直觉。仔细一看原来是训练集里做了RandomAffine但验证集的pixels数组在读取时没有做归一化像素值范围还是0-255模型输出的logits分布完全不对。原因验证集和测试集的transform里Normalize的mean和std参数复制错了或者ToTensor没有调用导致输入分布和训练时不一致。这个问题隐蔽在验证代码里前向推理一般不报错只是结果悄悄变差或变好。解决写一个断言检查在第一轮迭代时打印输入Tensor的均值训练集和验证集都应该在-0.5到0.5附近。源码里可以加一行代码assert abs(imgs.mean().item()) 0.6, normalize可能写错了训练阶段直接让程序报错而不是等着结果对不上。5.3 ResNet50加载预训练权重后发现fc层维度对不上源码里改了分类头现象报错Error(s) in loading state_dict ... size mismatch for fc.weight模型能训练但准确率一直很低。原因torchvision的ResNet50默认fc输出1000类要做7类表情分类必须替换fc。有人在build_backbone里改了fc但又忘了改load_state_dict的strict参数导致预训练权重里fc相关的键全部加载失败。解决加载预训练时显式忽略fc层model.load_state_dict(torch.load(resnet50.pth), strictFalse)然后再替换fc。或者在build_backbone里先替换fc再加载权重。项目源码里建议的顺序是建模型 - 替换fc - load_state_dict(strictFalse) - 冻结浅层。如果把顺序写成加载再替换fc那层的random init权重会保留消融实验里所有模型的fc都必须用同一个随机种子初始化否则fc层初始化的差异会盖过注意力模块的差异。5.4 双注意力叠加效果反而变差不是模块写错是特征图太小现象M4CASE叠加验证准确率比M3低单独看两个模块的代码逻辑都没问题前向也能跑通。原因48x48输入经过ResNet50下采样后layer3输出的特征图只有6x6。SE做通道压缩时丢掉了空间信息CA虽然保留方向信息但和SE叠加时两个注意力权重相乘相当于在36个空间位置上做了两轮重标定信息被过度压缩梯度回传时注意力权重趋近于饱和。解决这种情况不是bug是模型的特性。如果要叠加把注意力模块分别插到不同位置比如SE插在layer2后CA插在layer4前让它们在不同尺度上各管一段。不要两个都插在layer3和layer4之间。消融实验的价值就在这里——不是为了证明模块越多越好而是找出边界。5.5 训练日志显示准确率有波动但最终结果对不上GitHub上的开源数字现象自己复现的FER2013准确率在68%左右别人项目README上写72%怎么调都差一截。原因最常见的差异来自数据集的划分方式。FER2013官方是Training/PublicTest/PrivateTest三份但有些博客把Training和PublicTest合并再重新随机划分这样训练集变大验证集变小结果自然偏高。还有一种情况是有人做了人脸对齐预处理把48x48的原始图用OpenCV的landmark对齐到44x44相当于额外用了外部数据信息。解决源码关键要写清楚评测协议。项目仓库里一般会有一个README或实验记录文档描述数据和划分方式。自己复现时先跑一次官方baseline如果ResNet50不加任何注意力模块能跑到70%说明数据流水线正常如果只有65%多半是增强策略或预训练权重加载有问题。消融实验追求的是相对差异不是绝对数值M3比M0高2.5个百分点这个相对收益才是论文里要写的。6. 用Grad-CAM热力图验证注意力模块到底关注了哪些区域训练结束后除了看准确率表格还要回答一个问题注意力模块是不是真的帮模型把注意力放在了眼睛和嘴巴上。表情识别的关键区域基本集中在眼部、眉部和嘴部如果模型学到的特征是背景或头发纹理acc再高也是过拟合。项目源码里一般会写一个可视化脚本用Grad-CAM生成热力图叠加到输入图上每组模型各选几张典型样本做对比。# grad_cam_visualize.py import cv2 import numpy as np import torch from torchvision import transforms def grad_cam(model, img_tensor, target_layer): activations {} gradients {} def forward_hook(module, input, output): activations[value] output def backward_hook(module, grad_input, grad_output): gradients[value] grad_output[0] hook target_layer.register_forward_hook(forward_hook) hook_b target_layer.register_full_backward_hook(backward_hook) output model(img_tensor.unsqueeze(0).cuda()) score output[0, output.argmax(dim1)[0]] model.zero_grad() score.backward() weights gradients[value].mean(dim(2, 3), keepdimTrue) cam (weights * activations[value]).sum(dim1, keepdimTrue) cam F.relu(cam).squeeze().cpu().data.numpy() cam cv2.resize(cam, (48, 48)) cam (cam - cam.min()) / (cam.max() - cam.min() 1e-8) return cam这段代码挂了两个hook前向hook拿特征图反向hook拿梯度然后对特征图的通道维做梯度加权平均。target_layer在ResNet50上一般选layer4的最后一个Bottleneck也就是model.layer4[-1]太浅的层热力图会贴着边缘看不出语义区域。cam图resize回48x48后直接用OpenCV的applyColorMap转伪彩色叠加到原图上。可视化结果通常能看到一个规律baseline模型的热力图弥散在整个脸部而加了CA模块的模型热力图更集中亮区会聚在眼睛和嘴角附近。这就是消融实验在定性层面的价值——不光告诉你模块带来了0.8%的提升还告诉你提升来自哪里。另外热力图的对比要和准确率对比一起看如果M3准确率高但热力图看不出明显区域集中那可能是class_weight的影响不是注意力模块的功劳。最后再提一个工程习惯每次跑完一组消融实验把模型名、seed、验证准确率、测试准确率、热力图路径、以及训练时用的GPU型号和PyTorch版本写进一个results.json别只存在终端输出里。很多项目过了三个月回来看不懂自己当时为什么M2比M1高就是因为没留版本记录。这组源码的最终价值不在于跑出多高的准确率而在于你随时能把每个模块的贡献用数字和热力图讲清楚。希望帮到你。本文还有配套的精品资源点击获取