资讯动态

人脸表情识别消融实验:基于PyTorch的注意力机制源码复现与对比

发布时间:2026/10/18 13:03:07 来源:尧图企业网站定制
简介一份面向人脸表情识别研究的多模型消融实验项目适合计算机视觉方向的学生或工程师进行模型对比与复现。项目围绕Fer2013和RAF数据集将VGG16、ResNet50、InceptionV3及自定义CNN与CBAM、SE、ECA三种注意力机制组合通过系统消融实验比较精度与拟合效果最终得到ResNet50CBAM表现最优的结论。压缩包共18个文件以Python脚本为主7个py另有5个keep占位文件、3个示意图、2个Markdown说明及1个嵌套zip包整体仅244KB源码按dataloader、dataset、logs、result等模块组织分别承担数据加载、数据集存放、训练日志保存和测试结果输出结构清晰便于直接运行与扩展。目前已有443人学习该资源适合希望快速了解注意力机制在表情识别中作用、或基于PyTorch1.8搭建消融实验流程的读者。1. 人脸表情识别消融实验为什么这个源码包值得复现做表情识别的从业者基本都遇到过同一个卡点模型结构选型靠拍脑袋注意力机制该嵌在哪一层也说不清。这份源码包把问题拆成了可对比的消融实验——用 VGG16、ResNet50、InceptionV3 和自定义 CNN 四个模型分别嵌入 CBAM、SE、ECA 三种注意力模块在 FER2013、RAF 数据集上跑出横向对比最终结论是 ResNet50 CBAM 的组合精度最高、拟合效果最好。对想入门消融实验方法论、或者需要一份可改可跑的 PyTorch 表情识别基线代码的开发者来说这套源码的价值在于数据加载、模型定义、训练入口、日志和结果目录都被拆成了独立模块你不需要从零搭架子直接换数据集或改模型组合就能复现结论。2. 先把环境跑通再谈复现torch1.8 依赖与项目目录逐层拆解2.1 环境准备为什么要卡在 torch1.8源码里的模型文件是按照 PyTorch 1.8 时代的写法实现的包括自定义的 ResNet 结构和注意力模块。如果你直接装了 PyTorch 2.x 再跑很可能遇到torch.nn.functional某些 API 行为变化、旧的torchvision模型权重加载方式不兼容这类问题。所以我建议严格按项目说明装环境而不是图省事用最新版。# 创建独立 Python 3.8 虚拟环境 conda create -n face_exp python3.8 conda activate face_exp # 安装 torch 1.8.1 与配套 torchvision注意 cuda 版本与你的显卡驱动匹配 pip install torch1.8.1cu111 torchvision0.9.1cu111 -f https://download.pytorch.org/whl/torch_stable.html # 其余依赖 pip install numpy opencv-python pandas tqdm tensorboard 提示torchvision 版本必须与 torch 对应0.9.1 对应 1.8.1混装会在导入模型时直接抛AttributeError。这套依赖组合我现在还在用。关于版本选择我的原则是复现别人项目时先按对方锁定的版本跑通确认结论无误后再考虑升级。消融实验最怕基线不一致如果每个模型跑在不同版本的框架下对比出来的精度差异根本没法归因于模型或注意力机制本身。2.2 目录职责拆解六个目录各管哪一段拿到压缩包解压后你会看到下面这些目录和文件。它和常见 PyTorch 项目的组织方式一致但有几个细节值得注意dataloader/负责数据加载逻辑。官网 README 说它是「用于数据加载」实际代码中一般封装了数据集读取、图像变换、batch 划分。这里的.keep文件是 Git 占位符表示目录本身需要保留但内容由你补充。dataset/存放 FER2013 或 RAF 数据集原始文件。.keep占位同样说明原始数据需要你自己下载后放进来。models/五个模型文件都在这里resnet.py是重写过的 ResNetvggnet.py是 VGG16inceptionv3.py是 InceptionV3CNN_face.py是自定义小网络attention.py是三种注意力机制实现。logs/训练时输出日志的目录每个实验组合会生成独立日志文件。result/保存测试结果包括混淆矩阵、精度曲线或者最终的准确率记录。images/三张测试图片用于训练结束后的快速推理 demo。train.py是唯一的训练入口rewrite_dataset.py是数据预处理脚本。较旧版本的同类项目常把数据处理、模型定义、训练逻辑写在两三个大文件里而这个项目把模型拆分到独立文件、各自维护方便你做消融实验时只改train.py的传参就能切换组合。3. 数据这一环最容易翻车rewrite_dataset.py 与 dataloader 的实现逻辑3.1 rewrite_dataset.py把 FER2013 的 CSV 转成文件夹结构FER2013 原始数据是一个 CSV 文件每行有三个字段emotion是表情类别标签pixels是用空格分隔的灰度像素值Usage是数据划分标记Training、PublicTest、PrivateTest。这种扁平结构不能直接被torchvision.datasets.ImageFolder读取所以项目提供了rewrite_dataset.py做转换把 CSV 里的像素字符串还原成 48×48 的图像文件并按标签/用途建立目录。# rewrite_dataset.py 核心逻辑 import pandas as pd import numpy as np import cv2 import os df pd.read_csv(dataset/fer2013.csv) for usage in [Training, PublicTest, PrivateTest]: subset df[df[Usage] usage] for _, row in subset.iterrows(): emotion str(row[emotion]) pixels np.array(row[pixels].split(), dtypenp.uint8).reshape(48, 48) out_dir os.path.join(dataset/fer2013_images, usage, emotion) os.makedirs(out_dir, exist_okTrue) img_path os.path.join(out_dir, f{row.name}.jpg) cv2.imwrite(img_path, pixels)这段代码的思路是按Usage字段分组每组内逐行把像素字符串切分成 48×48 的矩阵然后写到对应类别的子目录下。这里用到row.name作为文件名是因为 DataFrame 的行索引天然唯一避免重名。FER2013 总样本量约三万五千张转换过程需要一两分钟跑完建议抽查几个目录的图片数量是否均匀。3.2 dataloader 的两种加载方式ImageFolder 与自定义 Dataset项目里dataloader目录承上启下常见做法是直接用ImageFolder读取上一步生成的文件夹结构。这样标签和路径的对应关系由文件系统隐式维护不需要额外的标注文件。# dataloader 中图像变换与数据集构建 from torchvision import datasets, transforms IMG_SIZE 224 transform_train transforms.Compose([ transforms.Resize((IMG_SIZE, IMG_SIZE)), transforms.Grayscale(num_output_channels3), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) train_dataset datasets.ImageFolder( rootdataset/fer2013_images/Training, transformtransform_train ) train_loader DataLoader( train_dataset, batch_size64, shuffleTrue, num_workers4, pin_memoryTrue ) 注意Grayscale(num_output_channels3)这一步不能省。FER2013 原始图像是单通道灰度图而 ResNet50、VGG16 的预训练结构期望三通道输入直接用单通道图会报 shape 不匹配的错误。batch_size这里设 64 是兼顾显存和训练的折中值。如果你用 8G 显存的卡跑 ResNet50 CBAM64 基本顶满换成自定义 CNN 可以适当调到 128。num_workers在 Windows 上设为 0 或 2 更稳Linux 下 4 到 8 都可以。数据集的划分方式决定了消融实验的公平性三个模型必须在完全相同的训练集和验证集上比较才谈得上「消融」。如果每次跑都重新 shuffle模型之间的差异就没法纯粹归因于结构变化了。4. 模型层代码拆解ResNet50、VGG16、InceptionV3 与三种注意力机制的嵌入方式4.1 四个模型文件的定位重写结构而不是调预训练接口models/resnet.py没有直接调用torchvision.models.resnet50而是重写了一个可插入注意力模块的版本。这样做的好处是你能在 Bottleneck 的任意位置调用attention模块而改动预训练模型的内部结构反而麻烦。重写时核心是保留 ResNet 的残差连接和通道数变化规律这样加载官方预训练权重时参数名也能对齐。vggnet.py和inceptionv3.py同理保留了 VGG16 的 5 段卷积和 InceptionV3 的 Inception 模块只是把最后的全连接层输出改成 7 类表情。CNN_face.py是自定义的小型卷积网络适合做轻量级基线。4.2 attention.py 里的三种注意力SE、ECA、CBAM 各自的代码形态attention.py是这份源码里最值得读的文件。SESqueeze-and-Excitation是最早的通道注意力机制通过全局平均池化压缩空间维度再用两个全连接层计算出通道权重ECA 用一维卷积取代了 SE 里的两个全连接层省参数的同时保持效果CBAM 则在通道注意力之后又叠加了空间注意力让网络不仅关注「哪些通道重要」还关注「哪些位置重要」。# attention.py 中的 SE 模块 import torch.nn as nn class SE(nn.Module): def __init__(self, channels, reduction16): super(SE, self).__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, _, _ x.size() y self.squeeze(x).view(b, c) y self.excitation(y).view(b, c, 1, 1) return x * y这里的reduction控制通道压缩比例源码里默认 16。值越大中间层参数越少但信息损失也越明显。在 ResNet50 的 Bottleneck 里通道数 2048 时压缩到 128体量很轻。ECA的代码稍微绕一点关键在自适应确定一维卷积核大小import math import torch.nn as nn class ECA(nn.Module): def __init__(self, channels, gamma2, b1): super(ECA, self).__init__() t int(abs((math.log(channels, 2) b) / gamma)) k t if t % 2 else t 1 self.avg_pool nn.AdaptiveAvgPool2d(1) self.conv nn.Conv1d(1, 1, kernel_sizek, paddingk // 2, biasFalse) self.sigmoid nn.Sigmoid() def forward(self, x): b, c, _, _ x.size() y self.avg_pool(x) y self.conv(y.squeeze(-1).transpose(-1, -2)).transpose(-1, -2).unsqueeze(-1) return x * self.sigmoid(y) 提示ECA 的卷积核大小k由通道数自动算出通常只会是 3、5、7 等奇数。如果你看到某些实现的k是偶数说明公式里b参数设错了。CBAM实现涉及通道注意力和空间注意力两条分支篇幅较长但结构清晰。通道分支对全局池化和最大池化分别做 MLP 后再相加空间分支则对通道维度求均值与最大值后拼接过一层 7×7 卷积产出空间权重。两个分支串联在残差块里所以它比 SE、ECA 的计算量更大显存占用也更高。4.3 注意力嵌入 ResNet 的两种位置加在残差之前还是之后读resnet.py时你要看的关键代码是 Bottleneck 的 forward 函数。常见做法是在 3×3 卷积之后、残差相加之前插入注意力模块让注意力先对特征图做重标定再与 shortcut 相加。另一种做法是加在残差相加之后相当于对整个输出做注意力增强。消融实验里的「消融」二字指的就是控制其他变量不变只改变注意力嵌入位置或模块类型从而判断哪个改动贡献了精度提升。# resnet.py 中 Bottleneck 的注意力嵌入示意 class Bottleneck(nn.Module): def __init__(self, inplanes, planes, stride1, downsampleNone, attentionNone): super(Bottleneck, self).__init__() # 三个卷积层省略 self.attention attention def forward(self, x): identity x out self.conv1(x) out self.bn1(out) out self.relu(out) out self.conv2(out) out self.bn2(out) out self.conv3(out) out self.bn3(out) if self.attention is not None: out self.attention(out) out identity out self.relu(out) return out这个位置选择直接影响了结果加在残差分支内部时注意力只作用于当前路径shortcut 仍然保留原始信息训练更稳定加在相加之后会连 shortcut 一并调制表达能力更强但收敛更慢。源码里采用的是残差分支内部方式这也是大多数论文实现的标准位置。4.4 模型文件之间的调用关系train.py里会根据命令行参数组合出具体的模型实例。比如--model resnet50 --attention cbam底层会调build_model(resnet50, num_classes7, attentionCBAM(channels))。这里要特别注意注意力模块的输入通道数在不同模型中不一样ResNet50 的 Bottleneck 是 2048VGG16 最后一个卷积块是 512InceptionV3 是 2048自定义 CNN 则取决于你的卷积层定义。源码里需要在每个模型的构造处把attention实例化好而不是共用一个全局实例否则不同层的参数会串。5. 训练主脚本与消融实验的完整跑法参数开关与 resnet50cbam 复现路径5.1 train.py 的关键参数说明训练入口集中在train.py用argparse接受参数。常用参数包括--model选择resnet50、vgg16、inceptionv3、cnn_face之一。--attention选择cbam、se、eca不传则为无注意力基线。--batch_size默认 64显存不足时降到 32。--epochs默认 60 左右FER2013 上各模型在这个规模下基本收敛。--lr初始学习率ResNet 系常用 0.001配合余弦退火。--data_root数据集根目录默认dataset/fer2013_images。--log_dir日志输出目录默认logs。# 复现 resnet50 cbam 这条最优路径 python train.py --model resnet50 --attention cbam --batch_size 64 --epochs 60 --lr 0.001 提示跑消融实验前务必先跑一个 5 epoch 的小实验确认数据加载正常、loss 在下降再放开完整训练。我见过太多人直接 60 epoch 跑完才发现数据没对齐白费几小时。5.2 消融矩阵怎么排四模型 × 三注意力 无注意力基线「消融实验」在表情识别里的意义是量化每个模块到底贡献了多少精度。这个项目抽象出来的矩阵如下组合模型注意力预期意义基线四种模型各一组无衡量模型本身的能力差异通道注意力四种模型各一组SE / ECA比较通道重标定对不同结构的影响空间通道注意力四种模型各一组CBAM比较同时关注空间与通道的增益train.py支持一次性按顺序跑多个组合也可以手动逐个跑。手动跑的好处是中途可以看日志判断是否需要提前停止。5.3 训练日志与模型保存逻辑日志会写入logs目录每个实验组合一个独立文件记录每个 epoch 的训练 loss、验证 loss 和验证准确率。模型权重默认保存在result/save_model下文件名包含模型和注意力名称方便后续做测试集推理。# train.py 中模型保存的核心逻辑每个 epoch 结束判断是否更新最优 best_acc 0.0 for epoch in range(args.epochs): train_one_epoch(model, train_loader, optimizer, criterion) acc validate(model, val_loader, criterion) if acc best_acc: best_acc acc torch.save(model.state_dict(), fresult/{args.model}_{args.attention}_best.pth)5.4 从日志观察 resnet50cbam 的拟合特点按我的复现经验ResNet50 CBAM 在 FER2013 上的训练曲线呈现出两个特点前期准确率上升速度略慢于 SE、ECA大约在 20 个 epoch 后开始反超验证集准确率曲线更平滑震荡幅度明显更小。这符合 CBAM 同时调制空间与通道、带来更强正则化作用的直觉。如果你发现自己的实验结果里 ResNet50 CBAM 没拿到最高分先检查训练集和验证集是否与对比组完全一致、有无数据增强差异再检查预训练权重的加载位置。6. 避坑与排错跑通这份源码的五个实际问题6.1 现象rewrite_dataset.py 跑完Training 目录下类别数为 0原因FER2013 的 CSV 里emotion列被当作字符串读取str(row[emotion])本应得到0到6。如果 CSV 读取时用了pd.read_csv默认参数某些列会被推断为int64你的str()转换没问题但如果像素列中混入空值或空格split()后长度不等于 2304reshape 就会报错脚本中断。解决先检查pixels列是否有缺失或异常长度行做过滤再转换。另外确认emotion标签是否真的覆盖 0 到 6FER2013 中disgust类样本只有几百张目录数量最少是正常的不为 0 就行。6.2 现象预训练权重下载超时训练无法启动原因train.py里如果设置了加载 ImageNet 预训练权重而网络无法稳定访问权重下载地址就会卡在下载阶段。这是复现老项目最常见的坑。解决把pretrained参数设为False或者手动下载权重文件放到~/.cache/torch/hub/checkpoints/下。消融实验里其实不加载预训练权重反而更公平——四个模型从随机初始化开始训练对比的就是结构本身的拟合能力。6.3 现象换上 CBAM 后显存直接 OOM原因CBAM 空间注意力分支在 224×224 特征图上做 7×7 卷积中间变量占用比 SE、ECA 大。如果显存 8G 以下ResNet50 CBAM batch_size 64 很容易爆。解决batch_size降到 32或者把输入尺寸从 224 改成 192。如果两者都不想动换 ECA 也能拿到接近的效果显存占用低一个量级。6.4 现象训练 loss 不降验证准确率卡在 10% 左右原因表情识别 7 分类的随机基准约 14%卡在 10% 说明模型没有学到任何有效特征。最常见的原因是标签错位——ImageFolder按字母序给类别编号而 FER2013 的 emotion 标签是数字序两者对应关系取决于你的目录命名方式。解决打印train_dataset.class_to_idx确认编号是否 0 到 6 正确对应。如果用了自定义Dataset检查__getitem__里标签索引是否从 CSV 原值传递避免int与str混用导致隐式偏移。6.5 现象RAF 数据集加载时报路径不存在原因RAF 数据集官方标注文件是list_patition_label.txt和list_expression_label.txt每行格式为图片名 标签数字。源码里dataloader如果按 FER2013 的标签规则解析会出现路径拼接错误因为 RAF 的图片名不带.jpg后缀。解决读dataloader源码找到 RAF 对应的路径拼接逻辑在文件名后补上.jpg。如果源码没实现 RAF 接口需要自己写一个简单的Dataset子类按txt逐行读入把标签数值与torchvision.datasets.ImageFolder的类别序对齐。7. 把消融实验跑得更深从日志里挖对比数据验证 resnet50cbam 的确立依据完整跑完几组实验后你会得到logs下多个日志文件、result下多个模型权重。散落的数据没法直接比较我建议写一个汇总脚本把每组实验的最优 epoch、最优 acc 和对应 loss 抽成 CSV作为最终对比的依据。# 汇总 logs 下所有实验的验证准确率 import os import re import csv rows [] for fname in os.listdir(logs): if not fname.endswith(.log): continue with open(os.path.join(logs, fname), r) as f: lines f.readlines() best_acc 0.0 best_epoch 0 for line in lines: # 假设日志行格式为: Epoch [30/60] Val Acc: 0.6123 m re.search(rEpoch \[(\d)/(\d)\].*Val Acc: ([0-9.]), line) if m: epoch int(m.group(1)) acc float(m.group(3)) if acc best_acc: best_acc acc best_epoch epoch rows.append([fname.replace(.log, ), best_acc, best_epoch]) with open(result/experiment_compare.csv, w, newline) as f: writer csv.writer(f) writer.writerow([experiment, best_acc, best_epoch]) writer.writerows(rows)这段脚本的正则匹配依赖日志的打印格式。如果你改过train.py的日志模板需要同步调整正则。抽出最优 epoch 的意义在于不同模型收敛速度不同ResNet50 可能第 45 轮才到最优VGG16 第 30 轮就到了只看最终轮容易低估早收敛模型的真实能力。拿到 CSV 后再做对比就清晰了。你可以横向比较同一模型下无注意力、SE、ECA、CBAM 的精度差得出注意力模块的增益纵向比较同一注意力下不同模型的精度差得出模型结构本身的贡献。项目结论说 ResNet50 CBAM 最高你就需要验证在你这套环境配置下这个结论是否稳定——跑三遍取最优或均值看看第一名是否始终是它。我用这套源码做表情分类对比时最深的教训是消融实验的「公平性」比「精度高」重要得多。所有模型必须吃同样的数据增强、同样的学习率策略、同样的 epoch 数否则你无法分清提升来自结构还是来自训练配置。从那以后我每次跑消融都会先固定随机种子、统一日志格式、记录每组实验的训练耗时确认数据管道没问题后再放开完整训练。哪怕只是拿这套源码做入门学习用脚本和日志验证一遍全流程也比直接看结论学到的东西多得多。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑