尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

基于PyTorch的表情识别实战:CNN、ResNet与VGG模型对比与训练优化

基于PyTorch的表情识别实战:CNN、ResNet与VGG模型对比与训练优化 简介基于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 python3.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 解析 fer201348×48 灰度图与 7 类标签fer2013.csv 每行三列emotion0-6 整数、pixels2304 个空格分隔的整数、UsageTraining / 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(), dtypenp.float32).reshape(48, 48) img img / 255.0 # 关键原始值是 0-255必须归一化split()按空格把字符串拆成列表reshape(48,48)还原成单通道矩阵/255.0把范围压到 [0,1]。7 类标签映射为0angry1disgust2fear3happy4sad5surprise6neutral。很多人漏掉归一化直接喂网络结果 loss 高、收敛慢还以为是模型写错了。训练集各类别数量分布如下后面所有采样策略都以这张表为依据类别angrydisgustfearhappysadsurpriseneutral训练集样本数399543640977215483031714965disgust 只有 436 张happy 是它的 16 倍。不做任何处理模型会把所有不确定样本都猜成 happy整体准确率看着不低但 disgust 的召回率可能接近 0论文里的分类报告根本没法看。2.3 预处理与类别不平衡数据增强、归一化和 WeightedRandomSampler自定义 Dataset 子类把解析逻辑封装起来from torch.utils.data import Dataset class FerDataset(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] img np.array(row[pixels].split(), dtypenp.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(p0.5), transforms.RandomAffine(degrees15, translate(0.1, 0.1)), transforms.Normalize((0.5,), (0.5,)), ]) test_transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)), ])RandomHorizontalFlip(p0.5)按 50% 概率水平翻转。表情任务里 flip 对 happy、sad 语义不变但写对比实验时记得说明这一点审稿人常问。RandomAffine(degrees15, 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_sampleslen(sample_weights), replacementTrue) train_loader DataLoader(train_ds, batch_size64, samplersampler, num_workers4)weights取样本数的倒数disgust 的采样权重约是 happy 的 16 倍。replacementTrue允许重复采样保证每个 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_classes7): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 32, 3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 48 - 24 nn.Conv2d(32, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 24 - 12 nn.Conv2d(64, 128, 3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 12 - 6 ) self.classifier nn.Sequential( nn.Dropout(0.5), nn.Linear(128 * 6 * 6, 256), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(256, num_classes), ) def forward(self, x): return self.classifier(self.features(x))卷积层全部padding1保持分辨率不变池化负责降采样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, stride1, downsampleNone): super().__init__() self.conv1 nn.Conv2d(in_ch, out_ch, 3, stridestride, padding1, biasFalse) self.bn1 nn.BatchNorm2d(out_ch) self.conv2 nn.Conv2d(out_ch, out_ch, 3, padding1, biasFalse) 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)当stride2或通道数变化时shortcut 分支用 1×1 卷积做 downsample把 identity 的尺寸对齐到输出。biasFalse是因为后面紧跟 BatchNormbias 会被 BN 的平移项吸收留着只会增加冗余参数。不过实际项目里我从不让 ResNet 处理 48×48 输入时直接用 torchvision 原版因为第一个 7×7 卷积 stride2 会把 48×48 直接砍到 24×24信息损失太大。常见做法是换成 torchvision 的 resnet18 骨架只改输入和输出层from torchvision import models model models.resnet18(weightsNone) model.conv1 nn.Conv2d(1, 64, 3, stride1, padding1, biasFalse) # 3通道改1通道 model.maxpool nn.Identity() # 去掉7x7后的最大池化 model.fc nn.Linear(512, 7)weightsNone表示随机初始化从零训练。conv1改成 3×3 stride1 的 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, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue)] in_ch out_ch layers [nn.MaxPool2d(2)] return nn.Sequential(*layers) class SimpleVGG(nn.Module): def __init__(self, num_classes7): 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(inplaceTrue), nn.Dropout(0.5), nn.Linear(512, num_classes), )每个 block 内卷积不改变分辨率只有MaxPool2d(2)减半。最后一层池化后 512 通道 × 3×3 的空间尺寸送入分类器前展平成 4608 维。VGG 的参数量大头在最后两个全连接层这也是它在小数据集上最容易过拟合的根源。3.4 三种模型参数量对比与精度预期模型参数量约单 epoch 训练时间RTX 3080测试精度常见区间SimpleCNN3 层2.8M1.5 分钟58%-62%ResNet18改 stem11M4 分钟66%-70%SimpleVGG类 VGG1130M5 分钟63%-67%提示以上区间来自我自己的实验不同数据划分、增强强度下会波动 ±3 个点别把它当成 benchmark 数字。论文里写对比结果时要注明随机种子、数据划分和增强配置否则实验不可复现。三组数字放在一起能看出规律ResNet 的参数量只有 VGG 的三分之一精度反而最高说明残差结构在小数据上的归纳偏置更有效CNN 基线参数量最小但精度明显落后说明它的表征能力确实不够。这个对比本身就是论文里值得写的一页表格。4. 表情识别训练实战损失函数、优化器、学习率与过拟合控制模型和数据处理就绪后训练环节的决策直接影响最终分数。表情识别不是跑通就行评价指标、类别权重、学习率调度、早停策略都得定下来。下面的配置是我在三个模型上都验证过的组合按模型分开记录参数CNN 基线ResNet18SimpleVGG优化器AdamSGD Momentum(0.9)Adam初始学习率1e-31e-2前 5 epoch 线性 warmup1e-4批次大小6412864最大训练轮数6010080学习率调度ReduceLROnPlateauCosineAnnealingReduceLROnPlateau4.1 CrossEntropyLoss 与类别权重少数类怎么照顾分类任务默认用nn.CrossEntropyLoss它内部已经包含 LogSoftmax 和 NLLLoss所以模型输出层不要额外加 softmax推理取 argmax 时也不受影响。由于前面用了 WeightedRandomSampler损失函数这里不再设 class weightcriterion nn.CrossEntropyLoss()如果不用 sampler可以给损失函数传权重两个方案选一个即可class_weights torch.tensor([1.0/c for c in counts], dtypetorch.float32) criterion nn.CrossEntropyLoss(weightclass_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, modemax, factor0.5, patience5) # 每个 epoch 结束后调用 # scheduler.step(val_acc)modemax表示监控值越高越好这里监控验证集准确率。patience5表示连续 5 个 epoch 不创新高就降 lrfactor0.5表示每次减半。CosineAnnealing 适合固定轮数训练把学习率从初始值平滑降到接近 0ResNet18 用它会比 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 / totaloutputs.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(fearly stop at epoch {epoch}) breakpatience10表示验证集连续 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_locationcpu)) 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), modebilinear, align_cornersFalse) model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) model.conv1 nn.Conv2d(1, 64, 3, stride1, padding1, biasFalse) # 输入回退到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 维度会直接报维度错误。本文还有配套的精品资源点击获取
返回列表