基于PyTorch的表情识别实战:CNN、ResNet与VGG模型对比与训练优化
2026/9/14 1:25:11 网站建设 项目流程

简介:基于PyTorch构建的人脸面部表情识别项目,覆盖CNN、ResNet、VGG三种主流卷积神经网络模型,配套完整源码、训练模型与研究论文,面向人工智能、计算机科学与技术等专业学生,可支撑毕业设计、课程设计中的表情识别课题,也可作为入门计算机视觉的实践参考。压缩包共31个文件,体量约101.28MB,其中14个py文件为模型定义、训练与测试脚本,5个ipynb文件提供分步实验过程,另有md说明文档、pptx答辩演示、mp4演示视频及模型权重等,结构清晰便于按需查阅。项目中覆盖悲伤、害怕、厌恶、快乐、气愤、惊讶、中性七类基本表情的识别流程,并包含数据划分、可视化、人脸检测等辅助配置,能够帮助理解从数据预处理、网络搭建到多模型对比评估的完整研究链。目前已有247人浏览学习,下载后可直接对照README文件快速上手,适合需要搭建表情识别系统、准备毕业设计或撰写相关课题论文的读者,也可为课堂项目和开源社区实践提供参考。

1. 用 PyTorch 做表情识别前,先想清楚 CNN、ResNet、VGG 三条路的适用边界

人脸表情识别在课堂专注度分析、驾驶员疲劳监测等场景里不是主角,却最影响体验。很多人把 ImageNet 的做法直接搬来,把 48×48 灰度表情图塞进上百层网络,结果准确率上不去,训练时间倒涨得吓人。这个标题把 CNN、ResNet、VGG 放在一起对比,真正要回答的不是哪个准确率最高,而是精度、训练成本、可复现性在有限算力下怎么权衡。

先给一个反直觉的结论:在 fer2013 这类小尺寸、高标签噪声的数据集上,ResNet18 相对 VGG 类模型的精度提升远没有 ImageNet 上明显。样本只有三万张出头,网络加深的收益会被过拟合吃回去。所以这个项目的研究价值,是用同一份数据、同一套训练流程量化三个模型的差异,论文里的对比实验才有依据。

适合正在做课程设计、毕业设计,或刚装好 PyTorch 想完整跑一个视觉分类项目的工程师。按数据管线、模型定义、训练调参、结果分析四步走,CPU 能跑小批次,GPU 完成全部对比。代码基于 PyTorch 常用 API 编写,不依赖第三方训练库。

2. PyTorch 环境与表情数据管线:fer2013 加载、预处理与 DataLoader 的坑

表情识别项目里,数据管线花的时间通常比模型定义多一倍。fer2013 是最常用的公开表情数据集,共 35887 张 48×48 灰度图、7 类表情,官方已划分好 Training、PublicTest、PrivateTest 三份。它有两个经典坑:像素值以 0-255 的字符串形式存在 CSV 里,很多人忘记归一化;类别严重不平衡,disgust 只有几百张,happy 有七千多张。下面按环境、解析、采样三步把管线搭起来。

2.1 Anaconda 搭建 PyTorch 环境:CPU 与 GPU 安装的差异

常见做法是用 Anaconda 建独立环境,避免污染系统 Python。CPU 版安装最省事:

conda create -n fer python=3.10 -y conda activate fer pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu

--index-url指向 PyTorch 官方 CPU wheel 源,Windows 和 Linux 通用,macOS 不需要这个参数。GPU 版先执行nvidia-smi看驱动支持的最高 CUDA 版本,再装对应 wheel,例如支持 CUDA 12.1 就执行:

pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121

装完验证:

import torch print(torch.__version__) print(torch.cuda.is_available())

torch.cuda.is_available()返回 True 才说明 GPU 可用。新手最常见的坑是装了 CPU 版 torch,代码里model.to('cuda')不报错但实际跑在 CPU,训练慢十倍。另一个坑是混装:conda 源里已有 CPU 版 torch,再用 pip 装 GPU 版,两个版本互相覆盖,环境状态不可控。

注意:pip list里出现两个 torch 版本时,先pip uninstall torch torchvision清干净再重装,不要直接覆盖。

2.2 从 CSV 解析 fer2013:48×48 灰度图与 7 类标签

fer2013.csv 每行三列:emotion(0-6 整数)、pixels(2304 个空格分隔的整数)、Usage(Training / PublicTest / PrivateTest)。解析代码:

import pandas as pd import numpy as np df = pd.read_csv('fer2013.csv') pixels = df['pixels'].iloc[0] img = np.array(pixels.split(), dtype=np.float32).reshape(48, 48) img = img / 255.0 # 关键:原始值是 0-255,必须归一化

split()按空格把字符串拆成列表,reshape(48,48)还原成单通道矩阵,/255.0把范围压到 [0,1]。7 类标签映射为:0=angry,1=disgust,2=fear,3=happy,4=sad,5=surprise,6=neutral。很多人漏掉归一化直接喂网络,结果 loss 高、收敛慢,还以为是模型写错了。

训练集各类别数量分布如下,后面所有采样策略都以这张表为依据:

类别angrydisgustfearhappysadsurpriseneutral
训练集样本数399543640977215483031714965

disgust 只有 436 张,happy 是它的 16 倍。不做任何处理,模型会把所有不确定样本都猜成 happy,整体准确率看着不低,但 disgust 的召回率可能接近 0,论文里的分类报告根本没法看。

2.3 预处理与类别不平衡:数据增强、归一化和 WeightedRandomSampler

自定义 Dataset 子类,把解析逻辑封装起来:

from torch.utils.data import Dataset class FerDataset(Dataset): def __init__(self, df, transform=None): self.df = df self.transform = transform def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.iloc[idx] img = np.array(row['pixels'].split(), dtype=np.float32) img = img.reshape(48, 48) / 255.0 label = int(row['emotion']) if self.transform: img = self.transform(img) return img, label

预处理管线:

from torchvision import transforms train_transform = transforms.Compose([ transforms.ToTensor(), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomAffine(degrees=15, translate=(0.1, 0.1)), transforms.Normalize((0.5,), (0.5,)), ]) test_transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)), ])

RandomHorizontalFlip(p=0.5)按 50% 概率水平翻转。表情任务里 flip 对 happy、sad 语义不变,但写对比实验时记得说明这一点,审稿人常问。RandomAffine(degrees=15, translate=(0.1, 0.1))表示 ±15° 旋转加最多 10% 平移,模拟头部姿态变化。Normalize((0.5,), (0.5,))把像素范围映射到 [-1,1],这是 torchvision 预训练模型的标准输入范围,后面要接 ImageNet 权重时得换成 ImageNet 的 mean/std。

类别不平衡用 WeightedRandomSampler 在采样层面解决:

from torch.utils.data import WeightedRandomSampler train_df = df[df['Usage'] == 'Training'] counts = train_df['emotion'].value_counts().sort_index().values weights = 1.0 / counts sample_weights = train_df['emotion'].map(lambda x: weights[x]).values sampler = WeightedRandomSampler(sample_weights, num_samples=len(sample_weights), replacement=True) train_loader = DataLoader(train_ds, batch_size=64, sampler=sampler, num_workers=4)

weights取样本数的倒数,disgust 的采样权重约是 happy 的 16 倍。replacement=True允许重复采样,保证每个 epoch 少数类被抽到足够多次。用了这个 sampler 后,损失函数里就不要再叠 class weight,两者叠加会过度补偿,少数类开始过拟合、多数类反而不稳。

3. 用 PyTorch 实现三种模型:CNN 基线、ResNet 残差块与 VGG 堆叠的差异

三个模型输入输出完全一致:48×48 单通道灰度图进,7 类概率出。差异集中在特征提取器的设计哲学:CNN 基线靠人工堆叠卷积层,ResNet 引入跨层恒等映射,VGG 用统一的小卷积核反复堆叠。这三条路在表情数据上的行为差异,比在 ImageNet 上更值得观察,因为数据量小,结构偏差会被放大。

3.1 CNN 基线:三层卷积加全连接,先跑通管线

基线模型不追求精度,追求简单、可复现、训练快,用来验证数据和损失函数有没有问题:

import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes=7): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 48 -> 24 nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 24 -> 12 nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 12 -> 6 ) self.classifier = nn.Sequential( nn.Dropout(0.5), nn.Linear(128 * 6 * 6, 256), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(256, num_classes), ) def forward(self, x): return self.classifier(self.features(x))

卷积层全部padding=1保持分辨率不变,池化负责降采样,48→24→12→6。BatchNorm2d在 batch size 不小于 32 时能让训练对学习率不那么敏感。两个Dropout(0.5)是 CNN 基线控制过拟合的主要手段,去掉后训练集准确率能上 95%,验证集会掉 5 个点以上。

3.2 ResNet18:残差块解决的是退化问题,不是梯度消失

ResNet 论文的核心观察是:网络加深到一定程度,训练误差反而上升,这被称作退化问题,它不是梯度消失,而是深层网络难以学习恒等映射。残差块让网络学习残差F(x) = H(x) - x,当不需要这一层时,权重直接学成接近 0,恒等映射自动保留。基础块实现:

import torch.nn.functional as F class BasicBlock(nn.Module): expansion = 1 def __init__(self, in_ch, out_ch, stride=1, downsample=None): super().__init__() self.conv1 = nn.Conv2d(in_ch, out_ch, 3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(out_ch) self.conv2 = nn.Conv2d(out_ch, out_ch, 3, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_ch) self.downsample = downsample def forward(self, x): identity = x out = F.relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) if self.downsample is not None: identity = self.downsample(x) out += identity # 恒等映射直接相加 return F.relu(out)

stride=2或通道数变化时,shortcut 分支用 1×1 卷积做 downsample,把 identity 的尺寸对齐到输出。bias=False是因为后面紧跟 BatchNorm,bias 会被 BN 的平移项吸收,留着只会增加冗余参数。

不过实际项目里我从不让 ResNet 处理 48×48 输入时直接用 torchvision 原版,因为第一个 7×7 卷积 stride=2 会把 48×48 直接砍到 24×24,信息损失太大。常见做法是换成 torchvision 的 resnet18 骨架,只改输入和输出层:

from torchvision import models model = models.resnet18(weights=None) model.conv1 = nn.Conv2d(1, 64, 3, stride=1, padding=1, bias=False) # 3通道改1通道 model.maxpool = nn.Identity() # 去掉7x7后的最大池化 model.fc = nn.Linear(512, 7)

weights=None表示随机初始化,从零训练。conv1改成 3×3 stride=1 的 stem,保留 48×48 分辨率,maxpool替换成nn.Identity()。这样改动后 ResNet18 的前几层能在小图上保留更多空间信息,在表情任务上通常比原版高 1-2 个点。

3.3 VGG:小卷积核堆叠与感受野换算

VGG 论文的核心论证是:两个 3×3 卷积串联等价于一个 5×5 卷积的感受野,三个串联等价于 7×7,但参数量更少、中间还有两层非线性。fer2013 上我一般用 VGG11 规模,VGG16/19 在这个数据量上属于浪费算力,而且全连接层占了绝大部分参数,过拟合风险更高:

def vgg_block(in_ch, out_ch, num_layers): layers = [] for _ in range(num_layers): layers += [nn.Conv2d(in_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True)] in_ch = out_ch layers += [nn.MaxPool2d(2)] return nn.Sequential(*layers) class SimpleVGG(nn.Module): def __init__(self, num_classes=7): super().__init__() self.features = nn.Sequential( vgg_block(1, 64, 2), # 48 -> 24 vgg_block(64, 128, 2), # 24 -> 12 vgg_block(128, 256, 3), # 12 -> 6 vgg_block(256, 512, 3), # 6 -> 3 ) self.classifier = nn.Sequential( nn.Dropout(0.5), nn.Linear(512 * 3 * 3, 512), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(512, num_classes), )

每个 block 内卷积不改变分辨率,只有MaxPool2d(2)减半。最后一层池化后 512 通道 × 3×3 的空间尺寸,送入分类器前展平成 4608 维。VGG 的参数量大头在最后两个全连接层,这也是它在小数据集上最容易过拟合的根源。

3.4 三种模型参数量对比与精度预期

模型参数量(约)单 epoch 训练时间(RTX 3080)测试精度常见区间
SimpleCNN(3 层)2.8M1.5 分钟58%-62%
ResNet18(改 stem)11M4 分钟66%-70%
SimpleVGG(类 VGG11)30M5 分钟63%-67%

提示:以上区间来自我自己的实验,不同数据划分、增强强度下会波动 ±3 个点,别把它当成 benchmark 数字。论文里写对比结果时,要注明随机种子、数据划分和增强配置,否则实验不可复现。

三组数字放在一起能看出规律:ResNet 的参数量只有 VGG 的三分之一,精度反而最高,说明残差结构在小数据上的归纳偏置更有效;CNN 基线参数量最小但精度明显落后,说明它的表征能力确实不够。这个对比本身,就是论文里值得写的一页表格。

4. 表情识别训练实战:损失函数、优化器、学习率与过拟合控制

模型和数据处理就绪后,训练环节的决策直接影响最终分数。表情识别不是"跑通就行",评价指标、类别权重、学习率调度、早停策略都得定下来。下面的配置是我在三个模型上都验证过的组合,按模型分开记录:

参数CNN 基线ResNet18SimpleVGG
优化器AdamSGD + Momentum(0.9)Adam
初始学习率1e-31e-2(前 5 epoch 线性 warmup)1e-4
批次大小6412864
最大训练轮数6010080
学习率调度ReduceLROnPlateauCosineAnnealingReduceLROnPlateau

4.1 CrossEntropyLoss 与类别权重:少数类怎么照顾

分类任务默认用nn.CrossEntropyLoss,它内部已经包含 LogSoftmax 和 NLLLoss,所以模型输出层不要额外加 softmax,推理取 argmax 时也不受影响。由于前面用了 WeightedRandomSampler,损失函数这里不再设 class weight:

criterion = nn.CrossEntropyLoss()

如果不用 sampler,可以给损失函数传权重,两个方案选一个即可:

class_weights = torch.tensor([1.0/c for c in counts], dtype=torch.float32) criterion = nn.CrossEntropyLoss(weight=class_weights.to(device))

weight每个类的损失加权系数,样本越少的类权重越高。我的经验是:先上 sampler,训练完看分类报告;如果 disgust 的 f1 还是明显低于其他类,再用 class weight 叠加微调,两个方案同时用的过度补偿问题前面已经说过。

4.2 优化器选型与学习率调度:SGD 和 Adam 的差异

ResNet 论文原版用 SGD + Momentum,它在 ImageNet 上表现好,但小数据集上收敛慢,对 warmup 和 lr 敏感。Adam 自适应调整每个参数的学习率,前几十个 epoch 收敛明显更快。在表情数据上,我的选择是:CNN 基线和 VGG 用 Adam 省心,ResNet18 用 SGD 配 Momentum,因为残差结构的梯度量级更稳定,SGD 能训出更好的极值点。

学习率调度按模型分两种:

from torch.optim.lr_scheduler import ReduceLROnPlateau scheduler = ReduceLROnPlateau(optimizer, mode='max', factor=0.5, patience=5) # 每个 epoch 结束后调用: # scheduler.step(val_acc)

mode='max'表示监控值越高越好,这里监控验证集准确率。patience=5表示连续 5 个 epoch 不创新高就降 lr,factor=0.5表示每次减半。CosineAnnealing 适合固定轮数训练,把学习率从初始值平滑降到接近 0,ResNet18 用它会比 ReduceLROnPlateau 稳定。

4.3 完整训练循环与早停:保存验证集最优权重

一个 epoch 的训练函数这样写:

def train_one_epoch(model, loader, criterion, optimizer, device): model.train() total_loss, correct, total = 0.0, 0, 0 for images, labels in loader: 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 = outputs.max(1) correct += (preds == labels).sum().item() total += labels.size(0) return total_loss / total, correct / total

outputs.max(1)返回两个张量:每行最大值和对应索引,索引就是预测类别。loss.item()把 tensor 转成 Python 浮点数,避免累积计算图。optimizer.zero_grad()必须在backward()前调用,否则梯度会在 batch 间累加,模型参数更新方向完全错乱。

主循环加早停,只保存验证集最优权重:

best_val, wait, patience = 0.0, 0, 10 for epoch in range(max_epochs): train_loss, train_acc = train_one_epoch(model, train_loader, criterion, optimizer, device) val_loss, val_acc = evaluate(model, val_loader, criterion, device) scheduler.step(val_acc) if val_acc > best_val: best_val = val_acc torch.save(model.state_dict(), 'best_fer.pth') wait = 0 else: wait += 1 if wait >= patience: print(f'early stop at epoch {epoch}') break

patience=10表示验证集连续 10 个 epoch 不涨就停止。很多人在这一步犯的错是保存最后一个 epoch 的权重——早停时最后一个 epoch 往往已经过拟合,验证集分数低于最优值。表达式里只保存state_dict(),不带整个模型对象,加载时结构自己在代码里重新定义。

4.4 训练日志与验证集隔离

训练日志至少要记录四个量:train_loss、train_acc、val_loss、val_acc,每个 epoch 输出一行。如果发现 train_acc 持续上升但 val_acc 停滞,就是过拟合信号,优先调整数据增强强度而不是加深网络。另一个纪律是:调参期间只用 PublicTest 当验证集,PrivateTest 留到最后统一评估。提前看 PrivateTest 的结果再调参,等于把测试集信息泄进训练流程,论文里的最终数字会失真。

5. 用测试集做结果对比与模型导出:混淆矩阵、迁移学习两个收尾技巧

三个模型训练完成后,最后一步是用 PrivateTest 统一评测,然后做两件事:分析错误模式、尝试迁移学习提升上限。这两个步骤决定论文讨论部分的质量,也是代码工程上最容易出细节问题的环节。

5.1 混淆矩阵与按类召回率

from sklearn.metrics import confusion_matrix, classification_report model.load_state_dict(torch.load('best_fer.pth', map_location='cpu')) model.eval() all_preds, all_labels = [], [] with torch.no_grad(): for images, labels in test_loader: outputs = model(images) all_preds += outputs.argmax(1).tolist() all_labels += labels.tolist() print(classification_report(all_labels, all_preds, target_names=['angry','disgust','fear','happy','sad','surprise','neutral'])) cm = confusion_matrix(all_labels, all_preds)

classification_report直接给出每个类的 precision、recall、f1。表情数据上最常见的错误模式是 fear 被误判成 surprise、angry 被误判成 sad,这两对表情的肌肉形态本来就接近,加上 fer2013 样本里部分标签本身标错,模型学到的边界天然模糊。如果某个类的 f1 特别低,优先怀疑采样策略没生效,其次才是模型能力不足。

5.2 用 ImageNet 预训练权重迁移:48×48 单通道输入的适配

torchvision 的ResNet18_Weights.IMAGENET1K_V1可以直接加载,但输入是 224×224 三通道,和我们的 48×48 单通道不一致。常见做法是把灰度图插值到 224×224 并复制成三通道,加载预训练权重后只冻结除第一层外的部分再微调:

import torch.nn.functional as F from torchvision import models img_gray = torch.randn(4, 1, 48, 48) img3 = img_gray.repeat(1, 3, 1, 1) # (4,3,48,48) img_up = F.interpolate(img3, size=(224, 224), mode='bilinear', align_corners=False) model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) model.conv1 = nn.Conv2d(1, 64, 3, stride=1, padding=1, bias=False) # 输入回退到1通道 model.fc = nn.Linear(512, 7)

提示:repeat(1, 3, 1, 1)是复制通道,expand则是共享内存的视图,后者在某些自动求导路径下会报 in-place 错误。

conv1 改成单通道后,原预训练权重里 conv1 的 3 通道卷积核不能直接加载,PyTorch 会报 size mismatch。处理办法:这部分权重随机初始化,其余层全部加载预训练值。实践证明,低层的边缘、纹理特征在自然图像和表情图像间是通用的,随机初始化第一层对整体精度影响很小,深层语义特征才是迁移学习的价值所在。归一化也要同步替换成 ImageNet 的 mean/std:[0.485, 0.456, 0.406][0.229, 0.224, 0.225]

5.3 导出与重载的两个细节

推理时把model.eval()torch.no_grad()组合使用,前者切换 BatchNorm 和 Dropout 到推理模式,后者关闭自动求导。漏掉eval()是最隐蔽的问题,BN 层在训练模式下使用 batch 统计量,测试时行为不一致会带来 1-2 个点的精度差异。导出用 TorchScript 固定计算图:

scripted = torch.jit.script(model) scripted.save('fer_resnet18.pt') loaded = torch.jit.load('fer_resnet18.pt') out = loaded(torch.randn(1, 1, 48, 48))

torch.jit.script把动态图固化成静态计算图,部署到 C++ 或移动端时结构与 Python 侧完全一致,加载后输入张量的形状要显式给全四维(batch, channel, height, width),省略 batch 维度会直接报维度错误。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询