简介这份资源是面向深度学习初学者与情感识别方向开发者的Python实战源码包围绕VGGNet卷积神经网络实现情绪识别任务适合想理解CNN在情感分析中落地流程、需要可复用代码框架的读者。压缩包共11个文件约12.38MB以6个py脚本为核心覆盖数据构建、模型定义、训练与识别推理等环节另含2个hdf5权重文件、1个json配置、1张结果图和1份说明文档便于直接复现与二次实验。目前已有309人学习下载。内容涵盖数据预处理、VGGNet模型搭建、训练优化、模型评估与结果可视化等完整链路读者可据此掌握从数据集构建到情绪识别的实现思路并借助已有权重快速验证效果为类似多模态情感识别任务提供可参考的工程结构。1. EmotionVGGnet 情绪识别一份 Python 源码能跑出什么拿到EmotionVGGnet情绪识别-python源码.zip这个包多数人第一反应是解压、装依赖、跑train.py然后被一堆形状不匹配和路径报错劝退。它本质上是一个把 VGG 式卷积骨干接到情绪分类头上的小型工程输入一张人脸图输出 7 类离散情绪生气、厌恶、恐惧、开心、中性、难过、惊讶的概率分布。适合两类人一是想快速搭一个能演示的情绪识别 demo 的开发者二是想拿它当骨架、换成自己数据集做迁移的算法同学。它不解决人脸检测也不解决多人场景输入得是已经裁好的人脸。这一章先把边界划清楚后面几章再拆骨干、数据、训练和排错。2. 骨干与分类头EmotionVGGnet 到底长什么样EmotionVGGnet 这个名字里的 VGG 不是指原版 16 层或 19 层而是借用了 VGG 的堆叠思路连续的小卷积核3×3加池化逐级把空间尺寸压下去、通道数提上来最后接全连接做分类。情绪识别和 ImageNet 分类的差别在于情绪信号集中在眉毛、嘴角、眼角这些局部区域全局纹理反而没那么重要所以骨干不需要太深通道也不用堆到 512 以上。2.1 为什么用 VGG 式堆叠而不是直接上 ResNet情绪识别数据集普遍偏小公开的 FER2013 也就三万多张 48×48 的灰度图换成 ResNet 这种带残差和 BatchNorm 的大结构很容易在几百个 step 内就把训练集拟合到 99%验证集卡在 60% 上下。VGG 式堆叠参数少、感受野增长可控在小图上反而更稳。常见做法是 4 个卷积块每块两个 3×3 卷积加一个 2×2 最大池化通道依次 64、128、256、512最后全局池化接两层全连接。我一般会把第一层改成 5×5 或加一个 1×1 降维因为 48×48 的输入用 3×3 第一层感受野太小边缘信息抓不住。2.2 用 PyTorch 搭出可复现的骨干下面这段是骨干的最小实现直接抄进model.py就能用。注意padding1保证卷积后尺寸不变池化才负责降采样。import torch import torch.nn as nn class EmotionVGGnet(nn.Module): def __init__(self, num_classes7, in_channels1): super().__init__() # 4 个卷积块通道 64-128-256-512 self.features nn.Sequential( # block1: 48x48 - 24x24 nn.Conv2d(in_channels, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.Conv2d(64, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # block2: 24x24 - 12x12 nn.Conv2d(64, 128, 3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.Conv2d(128, 128, 3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # block3: 12x12 - 6x6 nn.Conv2d(128, 256, 3, padding1), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.Conv2d(256, 256, 3, padding1), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # block4: 6x6 - 3x3 nn.Conv2d(256, 512, 3, padding1), nn.BatchNorm2d(512), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), ) # 全局平均池化避免全连接层参数爆炸 self.gap nn.AdaptiveAvgPool2d(1) self.classifier nn.Sequential( nn.Dropout(0.5), nn.Linear(512, 256), nn.ReLU(inplaceTrue), nn.Dropout(0.3), nn.Linear(256, num_classes), ) def forward(self, x): x self.features(x) x self.gap(x).flatten(1) # [B, 512] return self.classifier(x)逻辑说明features负责特征提取gap把 3×3×512 压成 512 维向量classifier做分类。参数上in_channels1对应灰度图如果你的数据是 RGB 就改成 3num_classes7是 FER 标准类别数换成自己的数据集时改这里。Dropout(0.5)放在全连接前是血泪经验小数据集不加这个验证集 loss 会在第 5 个 epoch 后开始往上走。2.3 输入尺寸和通道数怎么定源码里如果写死了 48×48你换成 224×224 的图会直接报形状错误。原因是AdaptiveAvgPool2d(1)虽然能吃掉任意空间尺寸但前面的池化次数决定了最小尺寸不能低于 2 的 4 次方也就是 16。低于 16 的输入会在某个池化层变成 0。常见做法是统一 resize 到 48×48 或 64×64前者对齐 FER2013后者给嘴角细节留更多像素。通道数方面灰度图省显存、训练快彩色图在肤色和光照变化大的场景下更稳但参数量翻三倍小数据集上不一定划算。3. 数据管线从原始人脸到能喂进网络的张量情绪识别翻车最多的地方不在模型在数据。你拿到的源码包通常带一个data/目录或一个fer2013.csv但真实场景里你手里是散落的 jpg标签在文件名或另一个 csv 里。这一章把从原始图到 DataLoader 的链路拆开。3.1 标签映射和类别不平衡的处理FER2013 的 7 类分布极不均匀开心和中性各占约 25%厌恶只有 1.5%。直接训练模型会把所有样本预测成多数类准确率看着有 25%实际没用。常见做法有两种一是用WeightedRandomSampler按类别频率倒数采样二是损失函数加weight参数。我一般两个都用采样器管 batch 内平衡权重管梯度。import os import pandas as pd from PIL import Image from torch.utils.data import Dataset, DataLoader, WeightedRandomSampler from torchvision import transforms # 7 类情绪顺序必须和模型输出对齐 EMOTIONS [angry, disgust, fear, happy, neutral, sad, surprise] LABEL2IDX {e: i for i, e in enumerate(EMOTIONS)} class FERDataset(Dataset): def __init__(self, csv_path, root_dir, transformNone): self.df pd.read_csv(csv_path) # 至少两列: filename, emotion self.root root_dir self.transform transform def __len__(self): return len(self.df) def __getitem__(self, idx): row self.df.iloc[idx] img Image.open(os.path.join(self.root, row[filename])).convert(L) label LABEL2IDX[row[emotion]] if self.transform: img self.transform(img) return img, label # 训练集增强随机翻转 小角度旋转 归一化 train_tf transforms.Compose([ transforms.Resize((48, 48)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(10), transforms.ToTensor(), transforms.Normalize(mean[0.5], std[0.5]), ]) dataset FERDataset(labels.csv, images/, transformtrain_tf) # 按类别频率倒数给权重缓解不平衡 labels [LABEL2IDX[e] for e in dataset.df[emotion]] class_count pd.Series(labels).value_counts().sort_index().values class_weights 1.0 / class_count sample_weights [class_weights[l] for l in labels] sampler WeightedRandomSampler(sample_weights, num_sampleslen(sample_weights), replacementTrue) loader DataLoader(dataset, batch_size64, samplersampler, num_workers4)逻辑说明convert(L)强制灰度和模型in_channels1对齐Normalize(mean[0.5], std[0.5])把像素从 [0,1] 映射到 [-1,1]这是 VGG 式网络的常规操作。WeightedRandomSampler的replacementTrue表示有放回采样少数类会被反复抽到。参数上batch_size64在 8G 显存上跑 48×48 够用num_workers4在 Windows 上如果报错就改成 0。3.2 验证集和测试集的划分陷阱很多人从同一个 csv 里随机切 8:2结果同一张图的不同增强版本同时进了训练和验证验证准确率虚高。正确做法是按原始图片 ID 或按人划分同一个人的人脸不能同时出现在两边。源码包里如果已经切好了train/和test/目录直接用如果只有一个大目录用GroupShuffleSplit按文件名前缀分组。from sklearn.model_selection import GroupShuffleSplit # 假设文件名格式为 subjectID_xxx.jpg按 subjectID 分组 groups dataset.df[filename].str.split(_).str[0] gss GroupShuffleSplit(n_splits1, test_size0.2, random_state42) train_idx, val_idx next(gss.split(dataset.df, groupsgroups)) train_df dataset.df.iloc[train_idx].reset_index(dropTrue) val_df dataset.df.iloc[val_idx].reset_index(dropTrue)参数说明test_size0.2是验证集比例random_state42保证可复现。这一步不做后面调参全是玄学验证集涨了测试集掉你根本不知道信哪个。4. 训练循环与关键参数让 loss 真的往下走模型和数据都齐了训练脚本写不对照样白搭。这一章给一个能直接跑的训练循环并把学习率、优化器、早停这几个参数讲透。4.1 优化器和学习率的选型情绪识别这种小任务Adam 比 SGD 收敛快但最终精度 SGD momentum 往往高 1 到 2 个点。我一般先用 Adam 跑 20 个 epoch 看趋势确认模型结构没问题再换 SGD 精调。学习率初始值 Adam 用 1e-3SGD 用 1e-2配合CosineAnnealingLR或ReduceLROnPlateau。import torch.optim as optim from torch.optim.lr_scheduler import ReduceLROnPlateau device torch.device(cuda if torch.cuda.is_available() else cpu) model EmotionVGGnet(num_classes7, in_channels1).to(device) # 类别权重传给损失函数和采样器双保险 weights torch.tensor(class_weights, dtypetorch.float32).to(device) criterion nn.CrossEntropyLoss(weightweights) optimizer optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) scheduler ReduceLROnPlateau(optimizer, modemin, factor0.5, patience3) best_val_acc 0.0 patience_counter 0 for epoch in range(50): model.train() running_loss 0.0 for imgs, labels in loader: imgs, labels imgs.to(device), labels.to(device) optimizer.zero_grad() outputs model(imgs) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() * imgs.size(0) # 验证 model.eval() correct, total 0, 0 val_loss 0.0 with torch.no_grad(): for imgs, labels in val_loader: imgs, labels imgs.to(device), labels.to(device) outputs model(imgs) val_loss criterion(outputs, labels).item() * imgs.size(0) preds outputs.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) val_acc correct / total scheduler.step(val_loss) # 早停验证准确率 5 个 epoch 不涨就停 if val_acc best_val_acc: best_val_acc val_acc torch.save(model.state_dict(), best.pth) patience_counter 0 else: patience_counter 1 if patience_counter 5: print(fEarly stop at epoch {epoch}) break print(fEpoch {epoch}: loss{running_loss/len(dataset):.4f}, val_acc{val_acc:.4f})逻辑说明weight_decay1e-4是 L2 正则防止全连接层过拟合ReduceLROnPlateau在验证 loss 不降时把学习率砍半patience3表示等 3 个 epoch。早停的patience_counter 5是经验值数据集越小这个值越要小否则白跑。参数上batch_size和lr要联动batch 翻倍 lr 也可以适当放大但别超过 5e-3Adam。4.2 训练过程中该盯哪些指标只看准确率会被不平衡数据骗。必须同时看混淆矩阵和每类的 F1。厌恶类样本少模型可能一个都预测不对但整体准确率还有 80%。常见做法是每个 epoch 结束后在验证集上算一次classification_report重点看 disgust 和 fear 的 recall。from sklearn.metrics import classification_report, confusion_matrix model.eval() all_preds, all_labels [], [] with torch.no_grad(): for imgs, labels in val_loader: imgs imgs.to(device) preds model(imgs).argmax(dim1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.numpy()) print(classification_report(all_labels, all_preds, target_namesEMOTIONS, digits4)) print(confusion_matrix(all_labels, all_preds))如果 disgust 的 recall 低于 0.2说明采样权重还不够或者这一类本身标注噪声太大考虑合并到 angry 或直接丢弃。这一步不做你交付的模型在真实场景里就是个只会说“开心”和“中性”的黑匣子。5. 避坑与排查源码跑不起来时先看这几条这一章列的是我在不同机器上复现这类源码时最常撞的 5 个问题每条按现象、原因、解决写。现象RuntimeError: Given groups1, weight of size [64, 3, 3, 3], expected input[64, 1, 48, 48] to have 3 channels原因模型第一层in_channels默认是 3但你的数据是灰度图通道数对不上。 解决把EmotionVGGnet(in_channels1)显式传进去或者把数据convert(RGB)。别改源码里的默认值改调用处否则换数据集时又乱。现象训练 loss 一直卡在 1.9 左右不降准确率约 14%原因标签映射错了模型输出顺序和EMOTIONS列表不一致或者 csv 里 emotion 列是数字但被当成字符串。 解决打印dataset[0]看 label 是不是 0 到 6 的整数再打印LABEL2IDX确认顺序。FER2013 原始 csv 的 emotion 列是 0 到 6别自己再映射一遍。现象验证准确率比训练准确率高 10 个点以上原因验证集里混进了训练集的增强版本或者验证集太小少于 500 张。 解决按人分组切分验证集至少占 15% 且每类不少于 30 张。如果做不到用交叉验证别信单次划分的结果。现象CUDA out of memorybatch_size 已经降到 16原因AdaptiveAvgPool2d之前的特征图太大或者num_workers开太多导致内存泄漏。 解决先把num_workers0排除多进程问题再把输入从 64×64 降到 48×48。还不行就在features里多加一个池化层把 6×6 压到 3×3。现象推理时单张图预测结果和训练时验证结果差很多原因推理时忘了model.eval()BatchNorm 用了 batch 统计量或者归一化参数和训练不一致。 解决推理前必须model.eval()和torch.no_grad()归一化的 mean/std 从训练配置里读别硬编码。6. 从能跑到好用换数据集和导出推理的两个技巧源码跑通只是起点真正要投入得看它能不能换成你自己的数据、能不能脱离训练脚本做推理。第一个技巧是冻结骨干只训分类头当你只有几百张标注图时把features的requires_grad设为 False只训classifier学习率用 1e-310 个 epoch 就能看到效果比全量微调稳得多。第二个技巧是导出 ONNX 做部署验证这一步能暴露很多训练时看不出的问题。# 冻结骨干只训分类头 for param in model.features.parameters(): param.requires_grad False # 只把 classifier 的参数传给优化器 optimizer optim.Adam(model.classifier.parameters(), lr1e-3) # 导出 ONNX输入尺寸必须和训练一致 dummy torch.randn(1, 1, 48, 48).to(device) torch.onnx.export( model, dummy, emotion_vggnet.onnx, input_names[input], output_names[logits], dynamic_axes{input: {0: batch}, logits: {0: batch}}, opset_version11, )导出后拿onnxruntime跑一张图对比 PyTorch 和 ONNX 的输出如果最大差值超过 1e-4说明某层有算子不兼容常见的是AdaptiveAvgPool2d在旧 opset 上行为不一致把opset_version提到 12 以上。验证方法很简单同一张图两边各跑一次np.allclose过不了就逐层排查。我自己的习惯是每换一个数据集先跑 5 个 epoch 看验证集混淆矩阵如果多数类 recall 超过 0.9 而少数类低于 0.1直接回去调采样权重不浪费算力。情绪识别这个方向模型结构不是瓶颈数据质量和类别平衡才是。希望帮到你。本文还有配套的精品资源点击获取