ResNet 到底在看哪里?我用码道做了一个 Grad-CAM 可视化实验
项目地址cnn-mnist-playground:基于 PyTorch 的 CNN 手写数字识别与可视化实验项目 - AtomGit前面几篇文章里我已经在同一个cnn-mnist-playground项目中完成了 CNN 手写数字识别、Conv1 / Conv2 Feature Map 可视化以及 CNN 与 MLP 的对比实验。做到这里以后我又有了一个新的问题模型虽然能把数字识别出来但它在做出这个分类结果时到底更关注图像中的哪些区域所以这一次我继续使用码道 Agent在原项目基础上加入一个轻量级 ResNet并实现 Grad-CAM 可视化让分类结果背后的空间响应能够直接显示出来。一、继续在原项目上加入 ResNet这次我没有重新开一个仓库而是继续在原来的项目上增量开发。现在整个实验路线已经变成CNN 手写数字识别→ Feature Map 可视化→ CNN vs MLP→ ResNet→ Grad-CAM这样做的好处是输入任务始终都是 MNIST只改变网络结构和分析方式更方便观察不同方法之间的差异。这一次用的也不是直接照搬 torchvision 的原版 ResNet18而是根据 MNIST 的28×28单通道输入设计了一个轻量 ResNet。模型输入为1×28×28最终输出 10 个类别对应数字 09。网络中使用了多个残差块并逐步把空间尺寸从28×28降到14×14最后降到7×7再通过 Global Average Pooling 和全连接层完成分类。最终模型真实参数量为174,970二、本地真实训练测试准确率 99.14%代码完成以后我还是按照之前的方式不让 Agent 去假设训练结果而是在自己的 PyTorch 环境中进行真实训练。训练命令python -m scripts.train_resnet --epochs 5训练完成后模型权重保存到app/weights/mnist_resnet.pth最终 ResNet 的真实测试集准确率为99.14%而上一篇 CNN vs MLP 实验中MLP 的真实测试准确率为97.92%在当前这套 MNIST 实验配置下轻量 ResNet 得到了更高的测试准确率。当然这只能说明当前实验结果不能简单得出“ResNet 在所有情况下都一定优于 MLP”的结论。三、Grad-CAM 到底在做什么Grad-CAM 的全称是Gradient-weighted Class Activation Mapping它想解决的问题并不是“模型预测了什么”而是对于当前这个类别卷积特征图中的哪些空间区域对输出贡献相对更大它的大致过程是先进行一次正常的前向传播得到分类结果然后针对目标类别进行反向传播计算目标类别得分对最后卷积层 Feature Map 的梯度。这些梯度经过空间平均后可以得到每个通道对应的权重再利用这些权重对 Feature Map 加权求和经过 ReLU 和归一化以后就得到了最终的 Grad-CAM。这次项目选择的 Target Layer 是stage3[-1].conv2也就是最后一个残差阶段中最后一个 BasicBlock 的第二个卷积层。为了获取真实的 Feature Map 和 Gradient我在实现中使用了 PyTorch Hooktarget_layer.register_forward_hook( self._save_activation)target_layer.register_full_backward_hook( self._save_gradient)也就是说页面上看到的热力图不是随机生成的图片而是真正根据当前输入、当前模型和当前目标类别实时计算出来的。四、最终页面效果前端新增了一个独立的ResNet Grad-CAM 可视化区域。页面中可以直接看到模型名称MnistResNet输入尺寸1×28×28参数量174,970测试准确率99.14%以及 Grad-CAM Target Layerstage3[-1].conv2。手写一个数字以后点击“生成 Grad-CAM”页面就会同时显示预测类别、置信度、Target Class、原始输入、Grad-CAM 热力图和 Overlay 叠加图。整个流程已经完整打通手写数字→ ResNet 推理→ 得到预测类别→ 反向传播→ Grad-CAM→ Heatmap→ Overlay五、真实案例数字 0第一个测试是手写数字0。最终结果Prediction0 Confidence95.18% Target Class0 Target Layerstage3[-1].conv2从热力图中可以看到高响应区域主要分布在数字笔画及其内部邻近区域。Overlay 把热力图直接叠加到了原始输入上这样比单独看一张 Heatmap 更容易观察空间响应与数字结构之间的位置关系。不过这里需要特别注意Grad-CAM 的高响应区域不能简单理解成“模型真正看到了这里”。更加准确的理解应该是在当前输入和当前目标类别下这些空间区域对类别输出的贡献相对更大。六、再测试数字 8 和 9随后我又分别手写了数字8和9。数字8的结果为Prediction8 Confidence99.75%从当前结果来看数字 8 的高响应区域主要集中在中上部以及两个环的连接附近。数字9的结果为Prediction9 Confidence99.91%数字 9 的高响应区域则更多集中在上方闭环以及中部附近。三个真实案例分别为数字 0置信度 95.18%数字 8置信度 99.75%数字 9置信度 99.91%。不同输入产生的 Grad-CAM 空间分布明显不同这也是这次实验最直观的地方。七、真正运行以后发现了一个 Bug7×7 对不上 28×28这次项目在真实运行 pytest 的时候还发现了一个很典型的问题。最开始执行pytest tests/test_resnet.py tests/test_gradcam.py -vResNet 本身的测试基本正常但 Grad-CAM 出现了多个失败。继续看 Traceback 后发现实际上核心问题只有一个GradCAMError: cam 与 base_image 形状不一致: (7, 7) vs (28, 28)原因也比较直接。原始 MNIST 图片是28×28但是经过 ResNet 的多次下采样后最后卷积层已经变成7×7所以从这个卷积层得到的原始 Grad-CAM 自然也是7×7。而 Overlay 需要把热力图和原始图像逐像素叠加7×7和28×28显然不能直接进行计算。八、解决方法先进行空间尺寸对齐这个问题不能简单地通过删除 shape 检查解决。因为即使不报错两个不同尺寸的数组依然无法正常进行 Overlay。最后采用的处理流程是Grad-CAM 7×7 ↓ Bilinear Resize ↓ 28×28 ↓ 与原始输入 Overlay ↓ 28×28 ↓ 显示时放大 ↓ 224×224核心代码类似if cam.shape ! base.shape: cam np.asarray( Image.fromarray(cam, modeF).resize( ( int(base.shape[1]), int(base.shape[0]) ), resampleImage.BILINEAR, ), dtypenp.float32, )这样原始 Grad-CAM 依然来自真实的7×7特征空间只是在进行 Overlay 前把它插值到和原始 MNIST 图像一致的28×28。最后放大到224×224只是为了让网页显示更加清楚。九、修复以后100 个专项测试全部通过修复尺寸问题以后我重新运行pytest tests/test_resnet.py tests/test_gradcam.py -v最终得到100 passed, 2 warnings in 3.94s这说明 ResNet 和 Grad-CAM 相关的模型结构、API、Heatmap、Overlay、空白输入以及异常处理等测试都已经正常。十、完整回归测试192 Passed因为这一次是在原来的项目上继续增加功能所以仅仅保证 Grad-CAM 能运行还不够。还要确认原来的 CNN、MLP、Feature Map、CNN vs MLP 和 API 没有因为新功能被破坏。于是最后重新运行整个测试集pytest -v最终结果192 passed, 2 warnings in 6.84s到这里ResNet Grad-CAM 的整个功能链条才算真正完成。相比“页面看起来能运行”我觉得这种完整回归测试更重要因为它可以确认新增功能没有破坏前面的实验。总结这一次在原来的 MNIST 实验平台上继续加入了轻量 ResNet 和 Grad-CAM。最终得到的真实结果是ResNet 参数量174,970测试准确率99.14%数字 0 的预测置信度95.18%数字 8 为99.75%数字 9 为99.91%ResNet Grad-CAM 专项测试100 passed完整项目回归测试192 passed。相比单纯看到一个分类结果我觉得 Grad-CAM 更有意思的地方是我们终于可以观察当前分类结果对应的空间响应主要分布在哪里。当然Grad-CAM 仍然不是完整的模型因果解释它更适合作为一种辅助观察神经网络决策过程的方法。现在这条实验路线已经做到CNN→ Feature Map→ CNN vs MLP→ ResNet→ Grad-CAM下一步准备继续进入目标检测开始做YOLO。项目地址cnn-mnist-playground:基于 PyTorch 的 CNN 手写数字识别与可视化实验项目 - AtomGit