简介:面向Python深度学习入门者与图像识别爱好者,项目以VGG16为骨干网络,构建了一个可识别愤怒、快乐、惊讶、厌恶、悲伤、恐惧六种表情的分类模型。压缩包共7个文件,包含5个Python脚本、1个数据集压缩包和1个说明文档,整体大小约59.23MB。5个脚本分别承担模型结构定义、数据集预处理与标签生成、模型训练、评估和单张图片推理演示等任务,说明文档则对使用流程和常见注意点作了交代。项目在VGG16基础上调整全连接层实现六分类,并给出了数据解压、标签生成、训练及模型加载的完整链路。通过此项目,可快速跑通人脸表情识别任务,掌握迁移学习、数据增强和模型保存/加载等关键操作,适合用于课程设计、毕业设计或深度学习入门实践。已有139人学习下载,整体脉络清晰,能帮助初学者少走弯路。
1. 人脸表情识别选 VGG16:一个容易被低估的起点
会议室里部署了一套实时表情分析系统,用的模型是 ResNet,在测试集上准确率接近 90%,结果一到实际场景,坐在后排的员工只要微微低头,系统就把“中性”判成“悲伤”。换回 VGG16 之后,虽然单帧推理慢了十几毫秒,但误判反而少了。这就是很多人对 VGG16 的误会:它结构老、参数多、计算量大,但作为表情识别这种图像分类任务的骨干网络,它的稳定性和可控性远超想象。基于深度学习 VGG16 网络的人脸表情识别,核心是把一张人脸图像映射到生气、厌恶、恐惧、高兴、悲伤、惊讶、中性这七类基本表情。它解决的是“静态图像里这个人什么情绪”的识别问题,适合做人脸表情数据集上的分类实验、毕业设计,以及要求高可解释性的工业场景。新手用它入门能快速看清卷积网络的训练全流程,熟手可以用它当基线模型对比新结构,这篇笔记把这条路走通要踩的坑提前说清楚。
2. VGG16 为什么是表情识别的及格线:结构优势与预训练红利
2.1 拆一遍 VGG16 网络结构,顺便搞懂“16”从哪里来
VGG16 从输入到输出依次是:卷积层组、全连接层组、分类输出层。卷积层组由 5 个 stage 组成,每个 stage 包含 2 到 3 个 3×3 卷积层,每层后面接 ReLU 激活函数,stage 末尾是一个 2×2 的最大池化层。前两个 stage 分别有 64 和 128 个卷积核,中间两个 stage 都是 256 个卷积核,最后两个 stage 各 512 个卷积核。特征图经过 5 次下采样,空间尺寸从 224×224 一路缩小到 7×7,深度从 3 通道增加到 512 通道。最后接两层 4096 维的全连接层,再跟一个 1000 维的 softmax 输出层,用于 ImageNet 的 1000 类分类。“16”指的就是有权重参数的层数总和:13 个卷积层加 3 个全连接层,池化层没有权重,不算在内。
选 VGG16 做表情识别,结构上有一个很容易被忽略的优点:它只用 3×3 小卷积核堆叠,两个 3×3 卷积的叠加等效于一个 5×5 卷积的感受野,三个叠加等效于一个 7×7 卷积。这种设计让每个卷积层都拥有较小的参数量和较强的非线性表达能力,在 Fer2013 这种 48×48 小尺寸人脸数据集上,几乎不用调整卷积层就能直接适配表情特征。相比之下,ResNet 的残差结构在深层网络上收益明显,但当数据量只有几万张表情图时,深层的收益会被过拟合吃掉,VGG16 这种“笨但直白”的结构反而更容易训练出稳定的结果。
PyTorch 里加载 VGG16 预训练模型,代码非常干净,但改分类头这一步必须做对。
import torchvision.models as models import torch.nn as nn # 加载 ImageNet 预训练权重 model = models.vgg16(weights=models.VGG16_Weights.IMAGENET1K_V1) # 统计原始分类头大小 in_features = model.classifier[6].in_features # 替换最后一层,7 对应七类表情 model.classifier[6] = nn.Linear(in_features, 7)这段代码的逻辑是:保留卷积层的预训练参数,把最后用于 ImageNet 1000 类分类的全连接层去掉,替换成一个输出维度为 7 的线性层。model.classifier是一个Sequential容器,下标 6 正好是最后一个线性层。加载预训练权重的意义在于,网络前几层已经学到了边缘、纹理、局部形状这类通用视觉特征,表情识别不需要从零开始学这些,可以省下大量训练时间。这里容易犯的错是只替换classifier[6],却忘记前面的classifier[0]和classifier[3]仍然是 4096 维全连接层,整体参数量依然庞大,在小数据集上极易过拟合。
2.2 预训练权重到底能帮多少:冻结策略与迁移学习的边界
加载 ImageNet 预训练权重之后,常见的做法是冻结前几个 stage 的卷积层参数,只微调后面的卷积层和全连接层。冻结的意思是让这些层的参数在反向传播时不更新,梯度直接等于零。这样做的原因是,ImageNet 预训练模型靠前几层学到的是颜色、边缘、拐角这种与任务无关的底层特征,在大数据集上已经学得足够好,不需要为了表情识别重新扰动。而靠近输出的高层特征与具体任务强相关,比如 ImageNet 里学到的“车轮”“狗耳朵”等语义和表情特征差异很大,这些层需要重新训练。
设计一个简单的冻结逻辑:
# 冻结前 9 个卷积层的参数(对应 stage1 和 stage2) for i, param in enumerate(model.features.parameters()): if i >= 9: # 前 9 层索引从 0 开始 break param.requires_grad = False这段代码遍历model.features(即 5 个卷积 stage 组成的Sequential)的所有参数,把索引小于 9 的参数设置为不更新。VGG16 的features层按顺序排布,每个卷积层后跟一个 ReLU,前两个 stage 共 4 个卷积层加 4 个 ReLU,实际前 9 个可迭代对象里包含了前 4 个卷积层和 4 个 ReLU,索引 8 是第 4 个卷积层后的 ReLU 参数吗?不是,ReLU 没有参数,parameters()只产出有权重的张量,所以索引 0 到 8 实际上覆盖了前 5 个卷积层的权重和偏置。这个细节提醒你:用索引控制冻结层数时,一定要先打印model.features的结构逐层核对,否则你以为冻结了两层,实际冻结了五层。
边界情况是:如果数据集很小,比如只有几千张表情图,可以多冻结几个 stage;如果数据集较大,比如 FER2013 的 3 万多张,冻结前两个 stage 就够了。迁移学习的收益在表情识别场景下非常明显,从头训练 VGG16 在 FER2013 上通常只能做到 60% 出头的准确率,而加载预训练权重再微调,可以稳定到 68% 到 72%。但这个红利也有边界,ImageNet 预训练模型是在自然图像上训练的,人脸表情图像是高度结构化的,预训练模型不一定能适配人脸特有的局部特征(眼睛、嘴角、眉毛的细微变化),所以后面三个 stage 的微调是必要的,不能把所有层都冻结了只训分类头。
3. 数据准备要先于模型:人脸表情数据集选型与预处理管线
3.1 FER2013 打底,CK+ 做补充:数据集特点与适用边界
表情识别领域最常用的数据集就是 FER2013,它由 35887 张 48×48 灰度人脸图组成,分为训练集、验证集、测试集三个部分。每张图已经对齐到以眼睛为中心的标准位置,分类标签是 0 到 6,分别对应生气、厌恶、恐惧、高兴、悲伤、惊讶、中性。还有一个公开数据集 CK+,属于实验室环境下的序列数据集,每段视频序列的最后一帧标记了表情,特点是图像质量高、表情真实,但样本量小,只有几百个序列。
实际做 VGG16 训练时,两个数据集的使用方式不同。FER2013 适合做主训练集,因为它的样本量大、类别分布相对均衡(除厌恶外,其他类别都在 4000 张左右),而且网络上有大量公开的预处理脚本可以参考。CK+ 更适合做测试集或跨数据集验证,验证模型的泛化能力,不适合单独训练 VGG16,因为样本太少,VGG16 特征提取层有 1.38 亿参数,在这个规模上必然过拟合。
处理 FER2013 的 CSV 文件时,常见的做法是先把像素字符串转成 NumPy 数组,再 reshape 成 48×48 的灰度图。这一步要注意 FER2013 原始 CSV 里的像素值没有做归一化,范围是 0 到 255。PyTorch 训练时通常要归一化到 [0, 1] 再减均值除方差,所以像素缩放不能省。下面是完整的加载逻辑:
import pandas as pd import numpy as np def load_fer2013(csv_path): df = pd.read_csv(csv_path) images = [] labels = [] for idx, row in df.iterrows(): pixels = row['pixels'].split(' ') img = np.array(pixels, dtype=np.float32).reshape(48, 48) images.append(img) labels.append(row['emotion']) return np.array(images), np.array(labels)这段代码把每一行的像素字符串按空格切分,转成float32类型的 NumPy 数组,然后 reshape 成 48×48 的单通道灰度图。为什么强调dtype=np.float32?因为 PyTorch 默认的模型权重和输入张量都是float32,如果这里用int64或者uint8,后面输入模型之前必须要做类型转换,否则会直接报类型不匹配的错误。iterrows()在数据量只有 3 万多行时可以接受,但如果换到更大规模的数据集,建议直接向量化操作效率更高。
3.2 灰度转 RGB、尺寸缩放与归一化参数:输入 VGG16 之前的三个关键步骤
VGG16 的原始定义要求输入是三通道 RGB 图像,尺寸是 224×224。FER2013 是 48×48 灰度图,所以数据预处理管线里必须做两件事:灰度图复制成三通道,双线性插值缩放到 224×224。有一类常见错误是直接喂单通道图给 VGG16,PyTorch 会报维度错误,因为首个卷积层期望的输入通道数是 3。
PyTorch 里可以用torchvision.transforms一步完成这些操作:
from torchvision import transforms from PIL import Image # 灰度图转 PIL Image 后转三通道 transform = transforms.Compose([ transforms.Resize((224, 224), interpolation=Image.BILINEAR), transforms.Grayscale(num_output_channels=3), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])这个管线的顺序有讲究:先缩放再做灰度转三通道。因为Resize对单通道灰度图直接生效,如果先转三通道再缩放,三个通道会被重复插值三次,白白浪费计算时间。Grayscale(num_output_channels=3)会把原本单通道的灰度值复制三份,形成三通道但通道间数值完全相同的输入。Normalize用的均值方差不是 FER2013 数据集的统计值,而是 ImageNet 数据集的全局统计值,这样做的目的是让输入分布尽量接近预训练模型期望的分布。需要注意的是,ToTensor()会自动把 NumPy 数组或 PIL Image 的像素值从 [0, 255] 缩放到 [0, 1],所以前面的原始像素值不能提前做归一化,否则会出现二次缩放的问题。
这里有一个细节很多人会忽略:FER2013 的像素没有做对齐增强。原始数据已经把眼睛位置对齐到图像中心附近,但实际部署时摄像头捕捉到的人脸往往会有左右旋转、缩放变化、表情强度差异。如果只在 FER2013 原始训练集上训练,模型对轻微的人脸旋转非常敏感。常见做法是在数据增强阶段加一个RandomRotation(degrees=10)和RandomAffine(translate=(0.05, 0.05)),让模型见过略微偏移和旋转的人脸。VGG16 感受野覆盖整张图,对全局位置变化不敏感,但对局部旋转的鲁棒性需要靠增强来补强。
3.3 类别不均衡与数据增强策略:别让模型把所有人都判成“高兴”
FER2013 中最明显的类别不均衡来自“厌恶”这个类别,只有 600 张左右,而“高兴”和“中性”各有 7000 张以上。如果不做处理,模型为了最小化整体损失,会把大量“厌恶”样本错判为“中性”,因为后者在训练集里出现频率高,预测成中性能让整体损失更低。这类错误在验证集上通常不容易看出来,因为整体准确率依然有 68% 左右,逐类看混淆矩阵才发现“厌恶”的查全率只有 10%。
应对类别不均衡有三种常见做法:加权采样、标签平滑、过采样复制。加权采样的思路是让每个 batch 里各类别出现概率接近均衡,实现方式是在DataLoader里传入一个WeightedRandomSampler,每个样本的权重是类别总样本数的倒数。过采样简单粗暴,直接复制少数类样本,但容易让模型对少数类过拟合。标签平滑的做法是把 hard label 变成 soft label,例如真实标签是“厌恶”,目标向量从[0,1,0,0,0,0,0]改成[0.02, 0.88, 0.02, 0.02, 0.02, 0.02, 0.02],缓解模型对少数类的极端置信度。
我的习惯是先用加权采样跑基线,再对比过采样。加权采样不需要改变数据本身,只需要给DataLoader加一个采样器参数:
from torch.utils.data import WeightedRandomSampler # labels 是训练集所有样本的标签数组 class_counts = np.bincount(labels) weights = 1.0 / class_counts[labels] sampler = WeightedRandomSampler(weights, num_samples=len(weights), replacement=True) train_loader = DataLoader(dataset, batch_size=64, sampler=sampler)关键参数在最后一行:sampler=sampler会替代shuffle=True的作用,因为WeightedRandomSampler内部自带随机采样逻辑。replacement=True表示同一个样本可以同时被多次采到,这样才能让少数类在一个 epoch 内被多次看到。num_samples=len(weights)可以理解为每个 epoch 采多少样本,设置为原始样本总数时,每个 epoch 的步数不变,但少数类出现次数明显增加。
4. 训练与微调 VGG16:损失函数、优化器参数与训练循环代码
4.1 输出层设计:从 1000 类到 7 类,softmax 与交叉熵的配置
VGG16 原始输出层是 1000 维,对应 ImageNet 的 1000 类。表情识别只有 7 类,所以最后的全连接层输出维度改成 7。PyTorch 的交叉熵损失函数nn.CrossEntropyLoss()自带 softmax 操作,所以最后一层不需要手动加softmax,直接输出 7 维 logits,损失函数内部会做 softmax 和负对数似然计算。代码里替换分类头时,nn.Linear(in_features, 7)就完成了这个映射。
选择优化器时,VGG16 参数量大,直接全部层用同一个学习率容易震荡。常见做法是用 Adam 优化器微调后面的全连接层,用较小的学习率微调卷积层,或者干脆全部层用一个统一的小学习率。我的习惯是:
import torch.optim as optim # 分成两组参数,每组用不同学习率 fc_params = model.classifier.parameters() conv_params = model.features.parameters() optimizer = optim.Adam([ {'params': fc_params, 'lr': 1e-4}, {'params': conv_params, 'lr': 1e-5} ], weight_decay=1e-4)两个不同学习率的设定逻辑是:全连接层是从零开始随机初始化的新参数,需要相对大的学习率来快速收敛,而卷积层加载了预训练权重,只需微调即可。这里的关键是conv_params里其实包含了被冻结层的参数,虽然它们被传给优化器,但因为requires_grad=False,梯度为 0,学习率再大也没有实际更新效果。PyTorch 的优化器只对requires_grad=True的参数做更新,所以这个写法没有任何问题。
损失函数的选择直接决定了模型的收敛行为。nn.CrossEntropyLoss()默认对所有类别一视同仁,如果想结合前面提到的类别不均衡处理,可以直接给损失函数传入权重向量:
# 类别权重反比于样本数 class_weights = torch.tensor([1.0, 5.0, 1.5, 1.0, 1.0, 1.0, 1.0]) criterion = nn.CrossEntropyLoss(weight=class_weights)这里class_weights里的数值对应 7 个类别的损失放大倍数,“厌恶”样本数少,权重给到 5,让模型在预测“厌恶”时犯错的代价更高。但这种做法容易让模型变得过度保守,把很多“中性”样本误判成“厌恶”,所以权重不能盲目拉高,通常不超过 5 倍为宜。
4.2 完整训练循环:早停机制加上学习率调度,避免白跑几十个 epoch
VGG16 在单张训练图上做一次前向传播和反向传播,如果 GPU 是入门级的,如 GTX 1660 Ti 或 RTX 3050,一个 epoch 大约需要 40 到 60 秒。跑 50 个 epoch 就是 30 到 50 分钟,如果设置不合理,可能白跑一轮只能得到过拟合模型。所以训练循环里要有两个保护机制:早停(Early Stopping)和学习率衰减。
下面是一个常见的训练循环代码,可以处理 FER2013 的规模:
from copy import deepcopy def train(model, train_loader, val_loader, epochs=50, patience=8): best_acc = 0.0 best_weights = None wait = 0 scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='max', patience=3, factor=0.5, verbose=True ) for epoch in range(epochs): model.train() train_loss = 0.0 for inputs, labels in train_loader: inputs, labels = inputs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step() train_loss += loss.item() * inputs.size(0) # 验证 val_acc = evaluate(model, val_loader) scheduler.step(val_acc) # 验证准确率上升时调整学习率 print(f'Epoch {epoch+1}/{epochs}, Loss: {train_loss/len(train_loader.dataset):.4f}, Val Acc: {val_acc:.4f}') if val_acc > best_acc: best_acc = val_acc best_weights = deepcopy(model.state_dict()) wait = 0 else: wait += 1 if wait >= patience: print(f'Early stopping at epoch {epoch+1}') break model.load_state_dict(best_weights) return modelpatience=8的含义是连续 8 个 epoch 验证集准确率都没有刷新纪录,就停止训练。ReduceLROnPlateau的patience=3则是在验证准确率连续 3 个 epoch 没有提升时,把学习率乘以 0.5。两个 patience 值的设定逻辑是:学习率先降低,给模型在局部区域继续微调的机会,如果连续降低几轮后验证准确率仍然不涨,再用早停兜底。verbose=True可在控制台输出学习率变化,方便确认调度器有没有生效,如果没有输出,大概率是学习率本身没有衰减空间,需要回到优化器参数上检查初始学习率是否过大或过小。
evaluate是一个简单的验证函数,用torch.no_grad()包裹推理过程,计算整体准确率。注意验证和训练要切换model.eval()和model.train(),因为 VGG16 里有 Dropout 层,训练时随机丢弃神经元,验证时不能丢弃,否则每次验证结果都会波动且偏低。
4.3 推理单张图片:图像预处理和模型输出解析的完整链路
训练完成之后,模型需要导出并部署或者做测试集评估。单张图片推理的完整代码要包含从读图到输出的全流程,最容易出错的是预处理部分与训练时不一致。
import torch from PIL import Image def predict_emotion(model, img_path, device): model.eval() img = Image.open(img_path).convert('L') # 转灰度 img = img.resize((224, 224), Image.BILINEAR) img = np.array(img, dtype=np.float32) img = np.stack([img] * 3, axis=-1) # 灰度图复制三通道 img = torch.from_numpy(img).permute(2, 0, 1) / 255.0 img = transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])(img) img = img.unsqueeze(0).to(device) with torch.no_grad(): output = model(img) pred = output.argmax(dim=1).item() return pred推理代码里有一个隐蔽的坑:transforms.Normalize是在ToTensor之后执行的,也就是输入的张量值域必须是 [0, 1]。所以代码里先做了除以 255.0,再调用Normalize。如果你直接用transforms.Normalize处理取值在 0 到 255 的原始像素,均值和方差要按 255 倍调整,否则输入分布完全错位,模型输出会非常不稳定。
img.unsqueeze(0)的作用是增加 batch 维度,把单张 3×224×224 的图像变成 1×3×224×224,这是 PyTorch 模型输入要求的固定格式。output.argmax(dim=1)取每个样本概率最大的类别索引,因为 batch 维度是 1,所以得到的是一个长度为 1 的张量,.item()转成 Python 整数,再映射到对应的表情类别名称。
5. 训练和部署中常见的 5 个坑:现象、原因和解决办法
5.1 验证准确率卡在 55% 上下,涨不上去,训练 Loss 却在下降
这是典型的“欠拟合 + 前馈不一致”的表现。现象是训练集 Loss 一路下降,从 2.0 降到 0.7,但验证集准确率始终在 55% 附近徘徊。检查代码后发现,验证时忘了model.eval(),Dropout 层在验证阶段也处于激活状态,导致每次前向传播都随机丢弃一部分神经元,输出带有大量噪声。除了这个原因,还有一个常见诱因是数据预处理里忘了做标准化或标准差的数值设置错误。
解决方式分三步检查:第一步看验证函数里有没有model.eval(),第二步打印训练集和验证集各一个 batch 的输入分布,确认均值和方差是否与预期的一致,第三步检查shuffle是否只在训练集设置了、验证集是否正确切分。多数情况是第一步和第二步之一出了问题,修好之后验证准确率立刻跳到 68% 以上。
5.2 模型对所有人脸都预测为“高兴”,混淆矩阵里其他类别几乎空白
这个现象的本质是类别不均衡。FER2013 的“高兴”样本量接近 9000 张,而“厌恶”只有 600 张,模型只要无脑输出“高兴”,整体准确率就能到 25% 以上,比随机猜的 14% 高不少。而且训练过程中,模型发现“高兴”带来的损失梯度下降最快,所以收敛到局部最优。
解决方式是在DataLoader里加入WeightedRandomSampler,同时配合损失函数权重。先跑一个加权采样版本,观察混淆矩阵里“厌恶”的查全率是否从 10% 提升到 40% 以上。如果提升有限,再尝试修改分类阈值:因为模型对“厌恶”输出的概率普遍偏低,可以把预测阈值从 0.5 降到 0.25,即只要厌恶类别的 softmax 概率超过 0.25 就判定为厌恶。这一步需要对每个类别单独设定阈值,通常用验证集上的 F1 分数搜索最佳阈值。
5.3 推理速度比预期慢得多,单张图要 80 毫秒以上
VGG16 全连接层有两个 4096 维的大矩阵,全连接层的参数量占到整个模型的 80% 以上,计算强度也集中在这一层。在 CPU 上跑单张推理 80 毫秒是正常水平,问题不在代码,而在架构选型。如果部署环境是 CPU,可以考虑把 VGG16 最后的两个 4096 维全连接层替换成全局平均池化,或者干脆把输入图缩小到 112×112,前提是重新微调模型。另一个常见做法是放弃 VGG16 原始全连接层,只在卷积层后面接一个 1×1 卷积和全局平均池化,参数量从 1.38 亿降到 2000 万以下,精度损失通常只有 1% 到 2%。
如果是 GPU 推理慢,多是因为没有启用半精度推理。用 PyTorch 的torch.autocast(device_type='cuda', dtype=torch.float16)包裹推理过程,VGG16 的推理速度可以提升 1.5 倍左右,显存占用下降一半。但要注意半精度对全连接层的累积误差更敏感,部署前必须在测试集上复测一遍准确率。
5.4 跨数据集测试时准确率直接掉到 30%,训练集表现却很好
在 FER2013 上准确率 70%,换到 CK+ 的序列帧上只有 30%,这是迁移到真实场景时最常见的翻车点。原因有两个:第一,FER2013 是经过人脸对齐的,检测出的人脸框位置已经居中,而摄像头视频流里人脸检测框可能有偏移;第二,CK+ 的表情强度更高,是刻意表演出来的,而 FER2013 是互联网众包标注的自然表情,分布差异很大。
解决方式是做数据增强时加入更强的随机扰动,把RandomAffine的平移范围从 0.05 提高到 0.1,旋转范围从 10 度提高到 15 度。另外,训练集和测试集的预处理必须完全一致。常见错误是把训练集做了随机裁剪,测试集直接缩放,导致推理时模型看到的图像与训练时分布差异巨大。训练和验证必须用同一套预处理管线,区别只在于训练集是否做了随机扰动。
5.5 训练到第 30 个 epoch 时 Loss 突然变成 NaN
现象是 Loss 从 0.6 突然跳到 NaN,之后一直无法恢复。原因通常是学习率过大导致梯度爆炸,特别是微调 VGG16 的最后一层时,新初始化的nn.Linear参数与预训练层特征尺度不匹配,梯度值瞬间变得很大,把所有权重都冲散。解决方式是调低全连接层的学习率到 1e-5 甚至 1e-6,同时给优化器加一个梯度裁剪:
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0)梯度裁剪的逻辑是:计算完所有参数的梯度后,对整个梯度向量求 L2 范数,如果超过max_norm就等比例缩放让范数回到 5.0。这样可以防止单个 batch 的极端样本造成梯度爆炸。另一个原因可能是输入数据里出现了 NaN 像素值,打印输入张量的torch.isnan()检查一遍即可。
6. 用混淆矩阵和单类准确率验证模型,再输出一份能落地的报告
训练完成后的验证环节,不能只看整体准确率,要有细粒度的逐类分析。表情识别项目里,CEO 关心的是整体满意度,算法工程师关心的是哪两类表情最容易互相混淆。常见混淆模式是“恐惧”和“惊讶”互相认错,“厌恶”和“生气”难以区分,这是由表情本身的面部肌肉运动相似度决定的,不是模型缺陷。用混淆矩阵能快速定位这些模式。
把混淆矩阵可视化并计算每一类的查准率和查全率,是模型验证阶段的核心动作:
import seaborn as sns import matplotlib.pyplot as plt from sklearn.metrics import confusion_matrix y_true, y_pred = [], [] with torch.no_grad(): for inputs, labels in test_loader: outputs = model(inputs.to(device)) preds = outputs.argmax(dim=1).cpu().numpy() y_pred.extend(preds) y_true.extend(labels.numpy()) cm = confusion_matrix(y_true, y_pred) # 每一行除以该行总数,得到按真实类别的查全率 cm_normalized = cm.astype('float') / cm.sum(axis=1)[:, np.newaxis] sns.heatmap(cm_normalized, annot=True, fmt='.2f', cmap='Blues') plt.show()如果看到第 3 行(恐惧)和第 6 行(惊讶)的颜色几乎对称地深,说明模型在两者之间高度混淆。此时不能盲目调模型,优先检查标签是否本身就有歧义,FER2013 的控制者研究报告里标注一致性只有 65%,也就是说有一部分标注本身就是错的。先做标签清洗,把那部分样本剔除后重训,往往比换模型结构更有效。
最后一步是输出验证报告,记录整体准确率、每类查准率查全率、推理耗时、模型参数量、显存占用。这份报告要能回答三个问题:模型真实场景下能用吗、哪类表情最容易误判、瓶颈在数据还是模型结构。报告里通常会建议把“恐惧”和“惊讶”在业务上合并为一类,因为区分它们的实际业务价值很低。VGG16 虽然参数多,但对中小规模的分类任务稳定可控,只要不盲目追求新结构,把数据均衡、预处理和阈值调好,它依然是一个值得投入的表情识别基线。希望这份从训练到验证的路径能帮你少走弯路。
本文还有配套的精品资源,点击获取