ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

人脸表情识别消融实验源码解析:VGG16、ResNet50、InceptionV3与CBAM、SE、ECA注意力机制对比

人脸表情识别消融实验源码解析:VGG16、ResNet50、InceptionV3与CBAM、SE、ECA注意力机制对比

简介:这份资源面向计算机视觉方向的学生与算法入门者,提供一套基于多模型消融实验的人脸表情识别完整源码,用于对比主流卷积网络与注意力机制组合在FER2013、RAF数据集上的表现。项目以PyTorch 1.8为框架,将VGG16、ResNet50、InceptionV3及自定义CNN分别与CBAM、SE、ECA三种注意力模块结合,通过消融实验验证ResNet50+CBAM精度最高、拟合效果最好,适合作为课程设计、毕业设计或论文复现的参考方案。压缩包共18个文件,约244KB,包含7个Python脚本、2个Markdown说明文档、3张结果图片及若干占位文件,代码按dataloader数据加载、dataset数据集、logs训练日志、result测试结果等模块组织,结构清晰便于二次修改。目前已有443人学习,读者可借此掌握多模型对比实验的设计思路、注意力模块嵌入方式与训练评估流程。

1. 从一份能跑消融实验的人脸表情识别源码说起

人脸表情识别这个方向,很多人卡在同一个地方:模型能跑通,但说不清到底哪个模块在起作用。换掉主干网络、加上注意力、调完超参,精度涨了两个点,是注意力真的有用,还是随机种子抽得好?这份face-recognize-master源码解决的正是这个问题——它把 VGG16、ResNet50、InceptionV3 和自定义 CNN 放在同一套训练流程里,再分别嵌入 CBAM、SE、ECA 三种注意力模块,在 FER2013 和 RAF 数据集上做完整的消融对比。作者最后给出的结论是 ResNet50 + CBAM 精度最高、拟合效果最好,但比结论更有价值的是这套可复现的实验框架。它适合正在做表情识别课程设计、想复现注意力消融实验,或者需要一套干净的多模型对比脚手架的人。下面我按「资源结构 → 数据准备 → 模型与注意力实现 → 训练与结果 → 避坑 → 进阶」的顺序拆一遍,能直接抄的地方我都标了参数。

2. 源码结构与数据加载:先搞清楚每个目录在干什么

拿到一个压缩包,我习惯先看目录树再动手装环境。这份源码的结构不复杂,但有几个目录如果理解错了,后面训练会直接报错或者读到空数据。

2.1 目录职责与文件清单

从项目正文给出的结构看,核心目录和文件如下:

路径作用备注
dataloader/数据加载逻辑自定义 Dataset 和 DataLoader 封装
dataset/存放数据集FER2013、RAF 放这里
models/模型定义resnet.py、CNN_face.py、inceptionv3.py、attention.py、vggnet.py
logs/训练日志训练时自动写入
result/测试结果保存评估输出
train.py训练入口主脚本
rewrite_dataset.py数据集重写格式转换用
images/示例图片文档配图

models/目录是这份源码的核心。resnet.py、inceptionv3.py、vggnet.py分别对应三种主干网络,CNN_face.py是自定义卷积网络,attention.py里集中放了 CBAM、SE、ECA 三种注意力模块。这种把注意力单独抽出来的写法很实用——你不需要改主干网络代码,只要在构建模型时决定插不插、插哪个。

dataloader/和dataset/要配合看。dataset/只负责放原始数据,dataloader/负责把数据读成张量。很多人第一次跑会把数据直接丢在根目录,结果train.py找不到路径,这是最常见的翻车点之一。

2.2 数据准备:FER2013 和 RAF 的目录约定

FER2013 原始格式是 CSV,每行一个像素序列加标签;RAF 通常是按类别分文件夹的图片。这份源码用rewrite_dataset.py做格式统一,常见做法是把它转成ImageFolder能识别的结构:

# 转换后的目录结构应该是这样 dataset/ ├── train/ │ ├── angry/ │ ├── disgust/ │ ├── fear/ │ ├── happy/ │ ├── neutral/ │ ├── sad/ │ └── surprise/ └── test/ ├── angry/ └── ...

rewrite_dataset.py的作用就是把 FER2013 的 CSV 按标签拆成上面这种按类分文件夹的图片。运行前先确认两件事:一是 CSV 的列顺序(通常是 emotion, pixels, usage),二是输出路径要和dataloader里写的路径一致。我一般会先跑一遍转换,然后ls dataset/train/happy | wc -l数一下每类图片数量,确认没有某一类为空。

# dataloader 里典型的数据加载写法(示意) from torchvision import datasets, transforms from torch.utils.data import DataLoader train_transform = transforms.Compose([ transforms.Grayscale(num_output_channels=3), # FER2013 是灰度图,主干网络要 3 通道 transforms.Resize((224, 224)), # ResNet50/VGG16 默认输入尺寸 transforms.RandomHorizontalFlip(), # 表情左右翻转,增强 transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) # ImageNet 统计量 ]) train_set = datasets.ImageFolder('dataset/train', transform=train_transform) train_loader = DataLoader(train_set, batch_size=64, shuffle=True, num_workers=4)

这里有几个参数值得说清楚。Grayscale(num_output_channels=3)是因为 FER2013 是单通道灰度图,而 ResNet50、VGG16 这些在 ImageNet 上预训练的模型第一层卷积期望 3 通道输入,不转就会在 forward 时报通道数不匹配。Resize((224, 224))是这几个主干网络的标准输入尺寸,InceptionV3 虽然支持 299,但统一到 224 也能跑,只是精度略有差异。Normalize用的是 ImageNet 的均值和方差,因为主干网络是预训练权重,输入分布要尽量对齐。batch_size=64在 8G 显存上跑 ResNet50 基本够用,显存小就降到 32 或 16。

提示:num_workers在 Windows 上设成大于 0 有时会卡住,遇到卡死先改成 0 试试。

3. 模型与注意力机制:主干网络怎么选、注意力怎么插

这一章是整份源码最值得细看的部分。消融实验的价值全在这里——你得清楚每个模型为什么这么写,注意力模块插在哪个位置,以及不同组合之间的差异到底来自哪里。

3.1 三种主干网络的选型理由

models/下放了 ResNet50、InceptionV3、VGG16 和自定义 CNN,这不是随便凑数,而是覆盖了卷积网络演进的几个典型思路。

VGG16 结构最规整,全是 3x3 卷积堆叠,特征提取能力强但参数量大(约 1.38 亿),训练慢,容易过拟合小数据集。它在消融实验里通常作为「经典基线」出现。

ResNet50 引入残差连接,解决了深层网络退化问题,参数量约 2500 万,比 VGG16 轻,在 FER2013 这种 3 万多张图的数据集上表现稳定。作者最终结论是 ResNet50 + CBAM 最好,这和 ResNet 的残差结构能更好承载注意力模块有关——注意力加在残差分支上,梯度回传更顺。

InceptionV3 用多尺度卷积核并行,参数量约 2700 万,对多尺度表情特征(比如眼睛和嘴巴的尺度差异)有天然优势,但结构复杂,改动注意力模块时插入点不如 ResNet 直观。

自定义 CNN(CNN_face.py)通常是几层卷积加池化的轻量结构,用来验证「不加预训练、纯自建网络能到什么水平」,是消融实验里的下界参照。

# models/resnet.py 里典型的构建方式(示意) import torchvision.models as models import torch.nn as nn def get_resnet50(num_classes=7, pretrained=True): model = models.resnet50(pretrained=pretrained) # 替换最后的全连接层,适配 7 类表情 model.fc = nn.Linear(model.fc.in_features, num_classes) return model

num_classes=7对应 FER2013 的七类表情:angry、disgust、fear、happy、neutral、sad、surprise。pretrained=True表示加载 ImageNet 预训练权重,这是小数据集上能否收敛的关键——从零训练 FER2013 很容易卡在 40% 左右的精度上不去。替换model.fc是因为原模型输出 1000 类,要改成 7 类。

3.2 CBAM、SE、ECA 三种注意力的插入位置

attention.py里集中实现了三种注意力,它们的核心区别在于「关注什么维度」。

SE(Squeeze-and-Excitation)只做通道注意力:先全局平均池化把每个通道压成一个数,再过两层全连接算出通道权重,最后乘回原特征。它回答的是「哪些通道更重要」。

CBAM(Convolutional Block Attention Module)是通道注意力加空间注意力:先做通道注意力(类似 SE 但用最大池化和平均池化双路),再做空间注意力(在通道维度上做池化和卷积,算出每个位置的重要性)。它同时回答「哪些通道重要」和「哪些位置重要」。

ECA(Efficient Channel Attention)是 SE 的轻量改进:用一维卷积代替全连接,避免降维带来的信息损失,参数量极小。

# attention.py 里 CBAM 的典型结构(示意) import torch import torch.nn as nn class ChannelAttention(nn.Module): def __init__(self, in_channels, ratio=16): super().__init__() self.avg_pool = nn.AdaptiveAvgPool2d(1) self.max_pool = nn.AdaptiveMaxPool2d(1) self.fc = nn.Sequential( nn.Linear(in_channels, in_channels // ratio), nn.ReLU(), nn.Linear(in_channels // ratio, in_channels) ) self.sigmoid = nn.Sigmoid() def forward(self, x): avg_out = self.fc(self.avg_pool(x).view(x.size(0), -1)) max_out = self.fc(self.max_pool(x).view(x.size(0), -1)) out = self.sigmoid(avg_out + max_out).view(x.size(0), -1, 1, 1) return x * out

ratio=16是 SE 和 CBAM 里通道降维的默认比例,通道数除以 16 再升回来。这个值不是随便定的——太小(比如 2)降维不够、参数量大,太大(比如 32)会丢信息。在 ResNet50 上,不同 stage 的通道数不同(256、512、1024、2048),ratio=16基本都能用。AdaptiveAvgPool2d(1)和AdaptiveMaxPool2d(1)把任意尺寸特征图压成 1x1,所以注意力模块对输入尺寸不敏感。

插入位置通常是在残差块的卷积之后、残差相加之前。以 ResNet50 的 Bottleneck 为例,注意力加在第三个卷积输出上,再和 shortcut 相加。这个位置能让注意力作用于「即将进入下一层的特征」,同时不影响残差主路径的梯度。

# 在 ResNet Bottleneck 中插入 CBAM(示意) class BottleneckWithCBAM(nn.Module): def __init__(self, ...): super().__init__() # ... 原有卷积层定义 ... self.cbam = CBAM(out_channels) # 在残差相加前插入 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.relu(out) out = self.conv3(out) out = self.bn3(out) out = self.cbam(out) # 注意力在这里生效 out += identity # 再和 shortcut 相加 out = self.relu(out) return out

3.3 消融实验的组合矩阵

消融实验的本质是控制变量。这份源码的组合逻辑是:固定数据集和训练超参,只变「主干网络」和「注意力类型」两个维度。完整的组合矩阵大致是:

主干网络无注意力+SE+CBAM+ECA
VGG16✓✓✓✓
ResNet50✓✓✓✓
InceptionV3✓✓✓✓
自定义 CNN✓✓✓✓

一共 16 组实验。每组要记录训练精度、验证精度、测试精度和训练时长。作者结论是 ResNet50 + CBAM 最优,但你在复现时可能会得到略有差异的结果——这取决于随机种子、数据划分方式和训练轮数。我一般会固定torch.manual_seed(42)和numpy.random.seed(42),让结果尽量可复现。

# train.py 里固定随机种子的常见写法 import torch import numpy as np import random def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False

cudnn.deterministic=True会让 cuDNN 只用确定性算法,代价是速度略慢,但换来了可复现性。benchmark=False同理,关掉自动调优。如果你更在意速度、不在意完全复现,可以把这两个都设成相反值。

4. 训练、日志与结果:把实验跑起来并读懂输出

模型和数据都理清之后,真正跑训练反而简单。这一章说清楚train.py的关键参数、日志怎么看、结果怎么对比。

4.1 训练脚本的关键参数

train.py是入口,通常包含数据集选择、模型选择、注意力选择、学习率、轮数这些参数。常见做法是用argparse暴露出来,方便批量跑消融:

# 跑一组 ResNet50 + CBAM 的典型命令 python train.py \ --dataset fer2013 \ --model resnet50 \ --attention cbam \ --epochs 100 \ --batch_size 64 \ --lr 0.001 \ --seed 42

--dataset决定读dataset/下哪份数据,--model和--attention决定模型组合,--epochs 100是常见设置——FER2013 上 50 轮往往还没收敛,100 轮基本能看到平台期。--lr 0.001配合 Adam 优化器是稳妥起点,如果 loss 震荡明显就降到 0.0001。--seed 42保证可复现。

训练循环里要关注的是 loss 和 accuracy 的走势。常见做法是每个 epoch 结束后在验证集上评估,保存验证精度最高的权重:

# 训练循环核心逻辑(示意) best_acc = 0.0 for epoch in range(args.epochs): model.train() 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() # 验证 model.eval() correct, total = 0, 0 with torch.no_grad(): for imgs, labels in val_loader: imgs, labels = imgs.cuda(), labels.cuda() outputs = model(imgs) _, predicted = outputs.max(1) total += labels.size(0) correct += predicted.eq(labels).sum().item() acc = correct / total if acc > best_acc: best_acc = acc torch.save(model.state_dict(), f'result/best_{args.model}_{args.attention}.pth')

optimizer.zero_grad()必须在loss.backward()之前,否则梯度会累加。model.eval()和torch.no_grad()在验证时都要加,前者关掉 dropout 和 batchnorm 的训练行为,后者省显存。保存文件名带上模型和注意力类型,方便后面批量对比。

4.2 日志与结果对比

logs/目录保存训练日志,result/保存测试结果。日志里通常每个 epoch 一行,记录 train_loss、train_acc、val_loss、val_acc。我一般会写个小脚本把 16 组实验的日志汇总成一张表:

# 汇总多组实验结果的常见做法 import os import re results = [] for fname in os.listdir('logs'): if not fname.endswith('.log'): continue model, attn = re.match(r'(.+?)_(.+?)\.log', fname).groups() with open(f'logs/{fname}') as f: lines = f.readlines() # 取最后一个 epoch 的验证精度 last = lines[-1] val_acc = float(re.search(r'val_acc[:\s]+([\d.]+)', last).group(1)) results.append((model, attn, val_acc)) results.sort(key=lambda x: -x[2]) for model, attn, acc in results: print(f'{model:15s} {attn:8s} {acc:.4f}')

这样跑完 16 组,按验证精度排序,就能直观看到哪组最好。作者的结论 ResNet50 + CBAM 最高,你在复现时如果发现某组异常低,先检查那组的日志有没有报错或者提前中断。

注意:不同主干网络的训练时长差异很大。VGG16 一轮可能是 ResNet50 的两三倍,跑 16 组实验前先估算总时间,别跑到一半发现要几十个小时。

5. 避坑与排查:这几处最容易翻车

消融实验的坑往往不在模型本身,而在数据、环境和实验管理上。下面几条是我踩过或者见别人踩过的,按「现象 → 原因 → 解决」写。

现象:训练 loss 一直是 nan。原因通常是学习率太大,或者输入没有归一化。FER2013 像素值 0-255,如果忘了ToTensor()和Normalize,直接送进网络,梯度会爆炸。 解决:确认 transform 里有ToTensor()(把像素缩到 0-1)和Normalize,学习率从 0.001 起,还不行就降到 0.0001。

现象:验证精度远高于训练精度。原因一般是数据泄漏——训练集和验证集有重叠,或者验证集太小。FER2013 官方有 train/val/test 划分,如果自己随机切分,要确保按样本切而不是按类别切。 解决:用官方划分,或者用train_test_split时加stratify=labels保证类别比例一致。

现象:加了注意力之后精度反而下降。原因可能是注意力模块插入位置不对,或者在小数据集上过拟合。CBAM 参数量比 SE 大,在 FER2013 这种 3 万张的数据集上,如果训练轮数不够,注意力还没学好就过拟合了。 解决:先确认插入位置在残差相加之前;再试试减小注意力模块的学习率,或者增加数据增强(随机裁剪、颜色抖动)。

现象:换到 InceptionV3 时报输入尺寸错误。原因:InceptionV3 默认输入是 299x299,而 dataloader 里写的是 224x224。 解决:要么把 Resize 改成 299,要么在构建 InceptionV3 时设aux_logits=False并确认输入尺寸。统一用 224 也能跑,但精度可能略低。

现象:多组实验跑完,结果无法复现。原因:没有固定随机种子,或者cudnn.benchmark=True导致每次选的卷积算法不同。 解决:按 3.3 里的set_seed固定所有随机源,并把cudnn.deterministic设为 True。代价是速度慢一点,但消融实验要的就是可比性。

6. 进阶:把消融实验做成可复用的对比框架

跑通单组实验只是开始,这份源码真正的价值在于它能撑起一套可复用的消融框架。我一般会做三件事:把实验配置外置成 YAML、把结果自动汇总成表格、把最优模型导出做推理验证。

配置外置是为了批量跑。与其每次改命令行参数,不如写一个configs/目录,每组实验一个 YAML:

# configs/resnet50_cbam.yaml dataset: fer2013 model: resnet50 attention: cbam epochs: 100 batch_size: 64 lr: 0.001 seed: 42

然后写个脚本遍历所有 YAML,依次调train.py。这样 16 组实验可以一条命令跑完,中间不用守着。

结果汇总我习惯用 pandas 直接读日志生成对比表,按「主干网络 × 注意力」的矩阵排列,一眼就能看出哪个组合在哪个数据集上最好。如果要做更严格的对比,可以每组跑 3 个不同种子取平均,减少随机性带来的误判。

最后是推理验证。训练完保存的best_*.pth要能加载回来对新图片做预测,这一步能暴露很多训练时看不出的问题——比如预处理不一致、类别顺序错位。我一般会拿images/里的示例图跑一遍:

# 加载最优模型做单张推理 import torch from PIL import Image from torchvision import transforms model.load_state_dict(torch.load('result/best_resnet50_cbam.pth')) model.eval() transform = transforms.Compose([ transforms.Grayscale(num_output_channels=3), transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) img = Image.open('images/test_face.png') img = transform(img).unsqueeze(0).cuda() with torch.no_grad(): output = model(img) pred = output.argmax(1).item() print(f'预测类别: {pred}')

unsqueeze(0)是加 batch 维度,因为模型期望输入是[N, C, H, W]。预处理必须和训练时完全一致,差一个 Normalize 的均值,预测结果就可能全错。这一步我每次都会强制走一遍,因为它是训练和部署之间最后一道校验。

从那以后我每次做完消融实验,都会先拿几张已知标签的图跑推理,确认模型不是「训练精度好看但实际乱猜」,再去看对比表。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进