ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

基于ResNet的人脸表情识别:从数据到部署的完整实践指南

基于ResNet的人脸表情识别:从数据到部署的完整实践指南 简介这是一套面向Python期末大作业的人脸表情识别项目资源基于ResNet模型实现定位为高校学生课程设计、毕业设计或自学人脸识别的入门参考。压缩包共一百零三个文件整体大小约五十四点一一兆字节内容涵盖十九个py源码脚本和十个pyc编译文件、三十二张png图片与十七张jpg或jpeg图像样本、三个hdf5模型权重、两个mp4演示视频另有md说明文档、xml工程配置、gif动态图等辅助材料。资源已经过本地编译与严格调试评审分达到九十五分以上训练、推理和演示流程均可直接复现。目前已有一百三十五人学习下载。随资源提供完整数据集和说明文档能帮助理解ResNet在表情识别任务中的网络搭建、数据预处理和模型评估方法适合需要交付可靠期末项目或系统学习人脸表情识别的同学参考使用。1. 一个期末大作业为什么值得按工程标准做完把“python期末大作业基于ResNet的人脸表情识别”这行字拆开看它其实是一个相当完整的 CV 入门闭环数据集、预处理、模型、训练、推理、说明书全齐。很多同学拿到这类 zip 包只想改个名字交差但这恰恰是错的——人脸表情识别是典型的“小图、多类、类间差异细微、类别不均衡”任务用 ResNet 当骨架能一口气把迁移学习、数据增强、类别权重、模型可视化这些面试常考点全部串起来。这篇笔记我就按自己落地这类项目的顺序把 ResNet 表情识别的数据组织、训练参数、踩坑记录一次讲透。适合正在做课设的学生也适合想把表情识别快速接到自己 demo 里的工程师。2. ResNet 凭什么成为表情识别的主力骨架选型理由与网络结构拆解2.1 表情识别任务对模型的三点苛刻要求先说清楚为什么表情识别不像猫狗分类那样随便选个网络就能跑。第一表情数据集的原始分辨率通常很低FER2013 这类公开数据集都是 48x48 的灰度图就算自己爬数据做人脸对齐后也就 64x64 到 96x96。图像小意味着网络不能一开始就疯狂下采样否则特征图还没成形就缩小到 4x4全局信息全丢了。第二表情类别之间的差异极度细微生气和厌恶的嘴部线条差异可能只有几个像素中性脸和微表情之间的边界更模糊这要求网络必须具备很强的中高层语义抽象能力。第三类别天然不均衡自然场景里“开心”“中性”出现的频率远高于“厌恶”“恐惧”模型稍微偷懒一点就会把所有样本都往多数类别上推。ResNet 恰好在这三点上都有优势。它的残差结构让网络可以在保持较高分辨率特征图的同时堆深18 层 ResNet 在 48x48 输入下依然能保留较完整的空间信息。而残差连接又保证了梯度可以顺畅地从最后一层传到第一层训练深层模型不像 VGG 那样让人提心吊胆。更关键的是ResNet 在 ImageNet 上的预训练权重非常成熟torchvision 一行代码就能加载这对小数据集任务几乎是决定性的——表情识别公开数据集通常只有几万张图从零训练一个深层网络很容易过拟合而用预训练权重做迁移学习可以大幅压低对数据量的需求。2.2 残差结构到底解决了什么退化不是过拟合很多资料喜欢把 ResNet 的贡献概括成“解决梯度消失”但做过实验的人会告诉你更准确的描述是“解决深层网络的退化问题”。梯度消失会被 Batch Normalization 很大程度上缓解但网络加深到几十层时训练误差反而会先降后升——这不是过拟合过拟合是训练误差低、测试误差高而是纯粹的优化困难。恒等映射路径的引入改变了游戏规则哪怕后面的残差块什么都没学到网络也至少能保持前层已提取的特征相当于给深层网络加了一条“后悔药”通道学到有用信息时再把残差叠加进去。表情识别里这个特性的具体收益是你可以放心地把 ResNet-34、ResNet-50 这类更深的变体用在表情任务上而不必担心网络深了反而学不动。我实际对比过在 48x48 输入下 ResNet-18 和 ResNet-34 的最终准确率差距可能只有 1 到 2 个点但 ResNet-34 的收敛稳定性更好中期波动明显更小。如果你的机器显存有限ResNet-18 够用如果追求最终指标ResNet-34 是性价比最高的选择。ResNet-50 在小图上收益不明显因为它的瓶颈结构本身是为 224x224 左右的大图设计的。2.3 用 torchvision 加载 ResNet替换分类头的两种写法不管用什么后端框架加载 ResNet 并替换分类头都是第一步。以 PyTorch 为例我一般这样处理import torch import torchvision.models as models # 方式一加载预训练权重替换最后一层分类头 model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) num_features model.fc.in_features # 原分类头输入维度是 512resnet18或 2048resnet50 model.fc torch.nn.Linear(num_features, 7) # 7 类表情生气、厌恶、恐惧、开心、中性、难过、惊讶 # 方式二把 ResNet 当特征提取器只训练分类头适合数据量极小的情况 for param in model.parameters(): param.requires_grad False model.fc torch.nn.Linear(num_features, 7) # 后续优化器只传入 model.fc.parameters()这里的核心参数是weightsmodels.ResNet18_Weights.IMAGENET1K_V1它表示加载在 ImageNet-1K 上预训练好的权重。ImageNet 预训练模型学到的是通用的边缘、纹理、形状特征这些底层特征对人脸和表情同样有效所以迁移到表情任务时只需要让网络重新学“高层语义组合方式”也就是分类头附近的那几层。方式二之所以有效是因为表情数据集往往只有几万张甚至几千张图全量微调容易让底层特征被带偏。我个人的习惯是数据量少于 1 万张时用方式二先训出一个能用的基线数据量超过 2 万张再解锁全部层微调。提示torchvision新版本里weightsmodels.ResNet18_Weights.IMAGENET1K_V1是推荐写法旧写法pretrainedTrue会在新版本里被移除。如果你用的是旧代码报错优先检查这一行。3. 把人脸表情数据集喂进 ResNet目录组织、标签对齐与预处理3.1 数据集目录结构与标签对齐最常见的翻车点表情识别数据集的来源很杂有 CSV 文件如 FER2013也有按文件夹分好的图片目录。不管原始格式是什么我强烈建议你先把数据统一整理成train/val/test三个文件夹每个文件夹下按类别分子目录最终结构长这样face_data/ ├── train/ │ ├── angry/ # 0 │ ├── disgust/ # 1 │ ├── fear/ # 2 │ ├── happy/ # 3 │ ├── neutral/ # 4 │ ├── sad/ # 5 │ └── surprise/ # 6 ├── val/ └── test/这个结构的最大好处是torchvision.datasets.ImageFolder可以直接读取类别标签按文件夹名的字母顺序自动从 0 开始编号。但注意字母顺序意味着angry0, disgust1, fear2, happy3, neutral4, sad5, surprise6和你预想的顺序可能不一样。如果你后面要打印混淆矩阵或者计算每个类别的准确率一定要先打印dataset.class_to_idx确认映射关系否则经常出现“模型把恐惧识别成厌恶但你在混淆矩阵里看错了行列”这种乌龙。我在这上面吃过亏当时按自己以为的顺序解析 CSV 标签训练集准确率虚高到 99%换了测试集立刻崩到 40%查了半天才发现是标签错位。3.2 预处理与数据增强48x48 小图的参数怎么设表情识别的输入分辨率普遍偏低预处理阶段有两个选择一是直接喂 48x48 原始分辨率二是先放大到 224x224 再输入。直接喂小图会丢失细节放大到 224x224 又会让 ResNet 的预训练权重更“舒服”。我的做法是折中先用 OpenCV 的人脸检测器把脸抠出来对齐然后缩放到 64x64最后在训练时随机裁剪成 48x48 并做水平翻转。这样既保留了原始分辨率又给模型引入了平移不变性。数据增强参数上我常用的组合是随机水平翻转p0.5随机旋转正负 10 度随机裁剪加 padding 4 像素以及轻微的颜色抖动因为灰度图转三通道后亮度变化是主要扰动因素。归一化方面如果用了 ImageNet 预训练权重必须用 ImageNet 的均值和标准差否则预训练权重的统计量会被破坏from torchvision import transforms train_transform transforms.Compose([ transforms.Grayscale(num_output_channels3), # 灰度图转 3 通道匹配 ResNet 输入 transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(10), transforms.RandomCrop(48, padding4), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) val_transform transforms.Compose([ transforms.Grayscale(num_output_channels3), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])上面的Grayscale(num_output_channels3)很关键。ResNet 的预训练权重第一个卷积层接受 3 通道输入而表情数据集很多是单通道灰度图必须复制成三通道再喂进去。如果你用的数据集本身就是彩色人脸图这一步可以去掉。RandomCrop(48, padding4)的意思是在原图四周补 4 像素的 0 值后再随机裁剪 48x48等效于给模型看稍微偏移后的同一张脸能有效抑制过拟合。注意验证集和测试集不能做随机增强只能做缩放和中值裁剪之类确定性变换否则同一张图每次评估结果都不一样你没法判断训练过程中的波动是模型问题还是数据扰动问题。3.3 自定义 Dataset处理 CSV 格式表情数据的标准写法如果你的数据是 FER2013 那种 CSV 格式第一列是像素值第二列是标签或者需要从数据库读图片ImageFolder就不好使了。这时候写一个自定义Dataset子类是最稳的。我之前处理这类 CSV 数据的模板代码如下import torch from torch.utils.data import Dataset from PIL import Image import numpy as np import os class FerCsvDataset(Dataset): 读取 emotion CSV 数据集每行 label, pixels像素以空格分隔的灰度值 def __init__(self, csv_path, transformNone): self.samples [] self.transform transform with open(csv_path, r) as f: lines f.read().strip().splitlines()[1:] # 跳过表头 for line in lines: parts line.split(,) label int(parts[0]) pixels np.array(parts[1].split(), dtypenp.uint8) self.samples.append((pixels, label)) def __len__(self): return len(self.samples) def __getitem__(self, idx): pixels, label self.samples[idx] # 48x48 灰度图重建 img Image.fromarray(pixels.reshape(48, 48)) if self.transform: img self.transform(img) return img, label这里两个操作要注意。第一lines[1:]跳过表头如果数据文件本身没有表头这个切片会误删第一行样本所以打开文件后先打印前两行确认格式再决定要不要跳。第二像素用空格分隔时parts[1].split()会得到一长串字符串转成np.uint8数组后 reshape 成 48x48。有些 CSV 的像素分隔符是逗号那split(,)和split()的处理就完全相反——先确认再写代码别猜。训练时配合DataLoader使用from torch.utils.data import DataLoader train_dataset FerCsvDataset(data/fer2013/train.csv, transformtrain_transform) val_dataset FerCsvDataset(data/fer2013/val.csv, transformval_transform) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue, num_workers4, pin_memoryTrue) val_loader DataLoader(val_dataset, batch_size64, shuffleFalse, num_workers4, pin_memoryTrue)batch_size的选择要结合显存和 BN 层特性下一章细说。shuffleTrue对训练集是必须的否则每个 epoch 内样本顺序固定BN 的统计量估计会偏差。4. 训练与微调从 ImageNet 预训练权重到表情分类的落地参数4.1 迁移学习 vs 从零训练预训练权重到底带来了多少收益直接说结论在表情识别这种小数据集任务上用 ImageNet 预训练权重的 ResNet-18 比从零训练的 ResNet-18 最终准确率能高出 8 到 15 个百分点而且收敛速度快一倍以上。原因是底层卷积核的可迁移性ImageNet 上学到的边缘检测、纹理模式、颜色统计对人脸同样适用表情识别真正需要学的是“嘴部线条的组合方式”这类高层特征。从零训练意味着网络得重新摸索这些底层特征几万张表情图根本不够。但预训练权重也不是没有副作用。ImageNet 权重是平均脸朝向居中、尺度相对固定的自然图像上训练出来的表情数据集里的人脸往往有偏转、遮挡、夸张角度这会导致预训练阶段学到的某些对“猫耳朵”“车轮”敏感的高层神经元被激活产生干扰。我在实际项目里观察到直接用预训练权重微调时前一两个 epoch 的损失会比从零训练更低但随后会有一段“平台期”——这是在修正高层的偏置。这个过程很正常不要因为损失不降就急着调学习率耐心让网络自己洗掉那些与表情无关的激活模式。4.2 三个必调的参数学习率、batch size 和类别权重学习率是整个训练里最玄学的参数。迁移学习场景下我一般用1e-4起步比从零训练的1e-3要低一个量级——因为预训练权重已经在一个很低的损失区域步子迈太大容易一步跨出好位置。优化器我推荐 AdamW权重衰减设1e-4或5e-5它比 SGD 对学习率更宽容适合非资深玩家如果你想要极限精度可以把模型训练到后半段切到 SGDmomentum 继续调。学习率调度用余弦退火CosineAnnealingLR或者按 epoch 手动衰减每 10 个 epoch 乘 0.1。表情识别的训练 epoch 数不用太多30 到 50 个 epoch 足够收敛再多就会陷入过拟合区间。batch size 的取值受限于 BN 层ResNet 的 BatchNorm 在一个 batch 内统计均值和方差batch size 太小小于 16会导致 BN 统计不稳定损失曲线像锯齿一样抖batch size 太大又会超出显存。我一般用 64显存小于 4GB 时降到 32。另一个关键点是如果加载了预训练权重前几个 epoch 最好冻结所有 BN 层的 running_mean 和 running_var因为这些统计量是 ImageNet 数据的分布小 batch 上更新会污染它们。PyTorch 里冻结 BN 的写法是def set_bn_eval(model): for m in model.modules(): if isinstance(m, torch.nn.BatchNorm2d): m.eval() # 使用 running_mean / running_var不再更新 # 在训练循环里前 5 个 epoch 冻结 BN之后恢复训练模式 if epoch 5: set_bn_eval(model) else: model.train()类别权重是表情任务不能省的一步。我用torch.nn.CrossEntropyLoss(weightclass_weights)权重的计算方式最常见的是1 / 类别样本数然后归一化也可以直接用torch.sqrt(1 / 类别频率)来压低少数类的影响。类别不均衡严重时比如“厌恶”样本只有“开心”的十分之一不加权重的话模型会把所有不确定样本都判成“开心”混淆矩阵会难看得让你怀疑人生。4.3 训练主循环从数据加载到模型保存的完整骨架下面给一份可以直接跑的训练代码骨架我做课设或小项目时一般就用这个底子改。注意代码里的device判断、验证阶段的torch.no_grad()和最终模型保存方式这三个地方最容易写错。import torch import torch.nn as nn from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device) # 类别权重按训练集各类别样本数反比计算 import numpy as np train_labels [label for _, label in train_dataset.samples] counts np.bincount(train_labels, minlength7) weights torch.tensor(1.0 / (counts 1e-6), dtypetorch.float32).to(device) criterion nn.CrossEntropyLoss(weightweights) # 如果你觉得上述权重太激进可以用 sqrt 平滑 # smooth_weights torch.sqrt(weights / weights.sum()) * 7 optimizer AdamW(model.parameters(), lr1e-4, weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_max40) best_val_acc 0.0 for epoch in range(40): model.train() if epoch 3: set_bn_eval(model) # 冻结 BN 统计量 total_loss 0.0 correct 0 total 0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() * images.size(0) _, preds torch.max(outputs, 1) correct (preds labels).sum().item() total labels.size(0) train_acc correct / total val_acc evaluate(model, val_loader, device) scheduler.step() print(fEpoch {epoch1:02d} | Loss {total_loss/total:.4f} | fTrain Acc {train_acc:.4f} | Val Acc {val_acc:.4f}) if val_acc best_val_acc: best_val_acc val_acc torch.save({model_state_dict: model.state_dict(), class_to_idx: train_dataset.class_to_idx if hasattr(train_dataset, class_to_idx) else None}, best_model.pth)配合的验证函数def evaluate(model, val_loader, device): model.eval() correct 0 total 0 with torch.no_grad(): # 验证阶段不计算梯度节省显存且加速 for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) _, preds torch.max(outputs, 1) correct (preds labels).sum().item() total labels.size(0) return correct / total关于模型保存方式我见过好多人只存model.state_dict()结果推理时不知道输入尺寸和类别数还得回头翻代码。所以我习惯把class_to_idx和num_classes一起存进 pth 文件这样部署和复现时一眼就能确认。另外torch.save一个包含state_dict的字典比直接torch.save(model, ...)安全得多——后者会连着类和设备信息一起序列化换台机器经常报错。提示如果训练时显卡显存不够把batch_size减半的同时记得把num_workers调小比如从 4 降到 2。num_workers过大和 batch size 过小同时出现时数据加载会成为新瓶颈GPU 利用率上不去。5. 常见问题与避坑训练崩溃、过拟合和推理翻车的 5 个案例5.1 训练第一个 epoch 损失直接变成 NaN现象是 loss 打印出来就是nan或者从某个 batch 开始突然变成nan之后再也回不来了。最常见的原因是学习率太大导致梯度爆炸但 ResNet 加 BN 对梯度爆炸容忍度其实不低所以八成是别的问题。我之前排查过的一个隐藏原因是输入图片里含有全黑或全白的异常样本经过Normalize后这些像素值变成极端大数把第一层卷积的数值范围直接顶爆。解决办法是数据加载完后跑一遍完整性检查for i, (img, label) in enumerate(train_dataset): if img is None or (img 0).all(): print(fBad sample at index {i}, label {label}) break如果数据本身没问题就把学习率降到1e-5重试一步步往上加。还有一种情况是损失函数里的weight参数包含了 0 值导致某些类别的梯度恒为 0从而让对应 logit 疯长最后数值溢出。给权重加一个极小值1e-6就能避免。5.2 训练集准确率 98%验证集卡在 60%这是表情识别项目里最经典的血泪经验过拟合。症状是前 10 个 epoch 训练集和验证集同步上升之后训练集继续涨验证集开始震荡甚至下降。原因有两个一是数据量太少表情数据集和 ImageNet 的规模差距太大二是模型参数太多ResNet-50 在小数据集上比 ResNet-18 更容易过拟合。解决办法按优先级排先加强数据增强加光照扰动、随机遮挡再砍模型换小一点的 ResNet-18最后考虑加 dropout。表情识别这种任务测试集准确率比“训练集做到完美”重要得多因为你的模型最终是要在别人的照片上跑的。还有个容易被忽略的原因验证集和训练集的人脸来源重叠。很多表情数据集是按帧从视频里切出来的同一个人的相邻帧被分进训练集和验证集导致模型“记住”了这个人而不是学会了表情。正确做法是按视频或人物 ID 划分数据集而不是按帧随机分。遇到这种情况重划验证集后准确率会掉下来但这才是真实水平。5.3 摄像头推理卡顿帧率只有 5 FPS训练好的模型拿到摄像头实时推理时卡顿90% 的问题不在模型计算本身而在预处理链路。常见的是每帧都用 PIL 加载、转格式、单张送 GPU来回切换损耗巨大。正确的推理流程是用 OpenCV 读帧 → 人脸检测器定位 → 裁剪对齐 → 单张推理。并且必须开启 eval 模式和torch.no_grad()import cv2 import torch model.eval() cap cv2.VideoCapture(0) with torch.no_grad(): while True: ret, frame cap.read() if not ret: break # 假设 face 人脸检测器返回的裁剪后 48x48 图像 face preprocess_face(frame) # 返回 tensorshape [1,3,48,48] face face.to(device) logits model(face) _, pred torch.max(logits, 1) # 在 frame 上画框和标签显示这里有两个隐性坑如果model.eval()忘了调BN 层还在用 batch 统计量单张推理时统计量方差巨大输出会变得很怪如果忘了torch.no_grad()模型前向会保存中间激活用于反向传播显存被慢慢吃满几十帧后开始卡顿甚至崩溃。人脸检测器本身也很耗计算用 OpenCV 自带的 Haar Cascade 或者移动端友好的检测模型别一上来就接 YOLO。5.4 测试集准确率虚高标签对齐问题的典型症状症状是训练过程一切正常训练/验证准确率都很像样子但换到真实照片上识别错误百出或者测试集准确率和验证集差一截。这种“黑匣子”式的问题十有八九是类别标签映射错位。我在章节 3.1 提到过按字母排序的问题这里再举一个更隐蔽的场景CSV 数据集的 label 和文件夹的类别序号不一致。比如你的 CSV 里0angry但torchvision.datasets.ImageFolder给你分的是0angry吗不一定如果val文件夹里子目录顺序不同同一个标签号对应的类别就变了。排查办法很简单训练前打印一份映射表存进日志class_names train_dataset.classes class_to_idx train_dataset.class_to_idx print(class_to_idx) # 输出类似 {angry: 0, disgust: 1, fear: 2, ...}训练完成后用这段映射表写一个小的评估脚本单独跑测试集并打印每一类的准确率和混淆矩阵。如果某一类的准确率显著低于其他类优先怀疑是标签错位而不是模型没学好。血泪经验这个检查花不了 5 分钟但能救回你一下午的调参时间。5.5 类别不均衡把“厌恶”和“恐惧”直接淹没在自然场景采集的表情数据里“开心”“中性”可能各占 30%“厌恶”“恐惧”各占 5%如果不处理模型会把后两者学成一团噪声。现象是混淆矩阵里有一整行几乎都是 0也就是“厌恶”类从未被预测过。解决手段有三层第一层是章节 4.2 说的类别权重这是最省事的第二层是过采样每个 epoch 对少数类样本重复采样让每个 batch 里各类别尽量均匀第三层是数据增强时对少数类用更强的扰动。我推荐至少做到前两层。用WeightedRandomSampler可以方便地做过采样from torch.utils.data import WeightedRandomSampler sample_weights torch.zeros(len(train_dataset)) for i, (_, label) in enumerate(train_dataset.samples): sample_weights[i] 1.0 / counts[label] sampler WeightedRandomSampler(sample_weights, num_sampleslen(sample_weights), replacementTrue) train_loader DataLoader(train_dataset, batch_size64, samplersampler, num_workers4)注意用了sampler之后就不能再传shuffleTrue这是DataLoader的硬性约束。num_samples通常保持和原数据集一样大让每个 epoch 的总步数不变化。6. 让表情识别再进一步用 Grad-CAM 验证模型在看哪里表情识别做到 80% 出头的准确率后光看指标无法判断模型学到的策略是否合理。最常见的隐藏 bug 是模型学会“看背景”而不是“看脸”——训练集里”开心“的照片恰好背景偏亮”难过“的恰好偏暗模型就转向了捷径。Grad-CAM 是检查这个问题的轻量工具它能生成一张热力图告诉我们模型做决策时重点关注的是输入图像的哪些区域。一个合理的表情识别模型热力图应该集中在上半脸的眼部周围微笑时眼轮匝肌变化和下半脸的嘴部附近如果热力图大面积集中在背景、额头或者衣领上说明模型学歪了。用 PyTorch 计算 Grad-CAM 并不需要完整复现原版流程核心做法是记录目标特征图对目标类别的 logit 反向传播取梯度对特征图求通道平均再上采样到输入尺寸。以 ResNet-18 为例我通常取model.layer4[-1]的输出作为特征图import torch import torch.nn.functional as F def grad_cam(model, tensor, target_classNone): 返回与输入同尺寸的热力图0~1shape: [H, W] device next(model.parameters()).device model.eval() feature_map None gradient None def forward_hook(module, input, output): nonlocal feature_map feature_map output.detach() def backward_hook(module, grad_input, grad_output): nonlocal gradient gradient grad_output[0].detach() # ResNet-18 的 layer4 是最后一个残差块组成的 stage target_layer model.layer4[-1] fh target_layer.register_forward_hook(forward_hook) bh target_layer.register_full_backward_hook(backward_hook) tensor tensor.unsqueeze(0).to(device) # [1, 3, 48, 48] logits model(tensor) if target_class is None: target_class logits.argmax(dim1).item() # 对目标类别得分反向传播 model.zero_grad() one_hot torch.zeros_like(logits) one_hot[0, target_class] 1.0 logits.backward(gradientone_hot) weights gradient.mean(dim(2, 3), keepdimTrue) # 通道平均权重 cam (weights * feature_map).sum(dim1, keepdimTrue) # 加权求和 cam F.relu(cam) # 只保留正向影响区域 cam F.interpolate(cam, size(48, 48), modebilinear, align_cornersFalse) fh.remove() bh.remove() return cam[0, 0].cpu().numpy()register_full_backward_hook是 PyTorch 新版本推荐写法旧版本的register_backward_hook在nn.Module上已被弃用。gradient拿到的是目标层输出的梯度mean(dim(2,3))是 Grad-CAM 的经典加权操作把高宽的梯度平均成一个通道权重。最后F.relu过滤掉负贡献区域因为负的 CAM 值对应的像素对目标类别是抑制作用不在可视化范围里。实际使用时把热力图和原图叠加透明度设 0.4 左右然后逐个看测试集里每个类别的 sample。我自己的教训是模型的准确率达标不代表决策可信有一次我训练出的模型对”恐惧“类识别准确率很高但 Grad-CAM 显示它重点看的是背景里的窗户边缘——原因是数据里”恐惧“表情的样本恰好全来自同一个室内场景。发现这个问题后我把那批背景重复的样本清洗掉重新训练后准确率掉了 3 个百分点但换到真实场景照片上的表现反而提升了一截。这是我认为整个项目里最有价值的一次排查也让我养成了每次训完分类模型必看 Grad-CAM 的习惯。希望帮到你。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表