When a model identifies a dog breed, I want to know what in the photograph contributed to its answer. Is it responding to the dog, or to something in the background? To find out, I implemented two methods that turn those contributions into heatmaps, CAM and Grad-CAM, using ResNet50 and the Stanford Dogs dataset.
On my first 50 test images, the two methods produced matching results, and I took that agreement as evidence that my Grad-CAM implementation was broken. I did find problems in the code. But I also had to rethink what I expected the comparison to show: at the point in ResNet50 I had chosen to inspect, CAM and Grad-CAM have a mathematical reason to agree.
These notes separate the implementation problems from that mistaken expectation. Without that separation, I could keep changing working code just to make the pictures look different.
How the heatmaps are calculated
To see why my maps matched, I need to follow how ResNet50 turns a photograph into a class score. Near the end of the network, the image is represented by a stack of small spatial maps called feature maps. For a standard input, the dimensions are:
| Representation | Dimensions |
|---|---|
| Input image | 224 × 224 pixels |
| Final feature maps | 2,048 maps, each 7 × 7 cells |
| After global average pooling | One average per feature map |
The network’s classifier combines those averages to calculate a score for each class. CAM, short for class activation mapping, uses the classifier’s weights to trace each map’s contribution back to its location in the image. For a particular dog breed, it multiplies each feature map by that breed’s weight and adds the weighted maps together. Larger contributions show up more strongly in the result.
Comparing CAM with Grad-CAM means comparing two ways of obtaining those weights. CAM relies on the connection between global average pooling and a linear classifier. Grad-CAM instead asks how much a small change in a feature-map value would change the selected class score; those derivatives are the gradients. It averages the gradients within each feature map to get a weight, combines the weighted maps, and then applies ReLU to the result to keep only positive values.
Because Grad-CAM doesn’t depend on that connection, it applies to more network structures. That doesn’t mean I should expect it to produce a different picture on every structure.
Why the maps matched on ResNet50
My first reaction to the matching pictures overlooked what global average pooling does. Each of the 49 cells in a final feature map contributes equally to that map’s average, and the linear classifier then multiplies the average by a fixed weight for the selected class.
So the gradient of the raw class score is the same at every cell in that map: the classifier’s weight divided by 49. Different maps can have different weights, but averaging the gradients within each map recovers weights proportional to CAM’s.
Once both heatmaps are scaled to the same display range, the common factor of 49 disappears. If both methods also keep positive values in the same way, the displayed maps can coincide. The Grad-CAM paper derives this relationship in section 3.1.
That means agreement alone can’t serve as a debugging test. What matters is whether my code computes the intended method at the intended layer, and the gradients and the architecture let me check that more directly than the look of the final picture.
Saving gradients in PyTorch
My implementation also had a separate problem: I saved output.detach() in a forward hook. The hook captures a module’s output while the model processes the image. PyTorch records the operations connecting that output to the class score so it can work backward and calculate the gradients. Calling detach() saved the values but cut their connection to that record.
I changed the hook to keep the original output and called retain_grad() on it so PyTorch would save its gradients. After backward() runs on the selected class score, the saved output’s .grad holds the result. Now I can check whether the gradient signal is present instead of inferring it from two similar-looking pictures.
I also ran into an error about mixing regular backward hooks with full backward hooks on one module. Using retain_grad() let this implementation avoid a module backward hook. My earlier account blamed the collision on ResNet’s BatchNormalization layers, but the error alone can’t establish which registration caused it.
Choosing a layer to inspect
Later I moved the hook from model.layer4 to model.layer4[-1].conv3, and the maps no longer matched exactly. That change needs more care than my original explanation gave it. I had written that layer4 included global average pooling, but the Torchvision implementation applies avgpool after layer4 returns.
Moving to conv3 meant choosing an earlier representation inside the last residual block, one still followed by batch normalization, the residual addition and a ReLU. That ReLU replaces negative values with zero, so it can block the influence of earlier values at some positions while letting it through at others. The gradients at this earlier point therefore need not have the uniform spatial pattern expected after the final block.
So the new pictures explain a different representation. Changing the location did produce the visible difference I had been looking for at the start, but I can’t take that difference as proof that the old hook location was wrong.
Comparing the maps with the dog annotations
I also compared the heatmaps with the bounding boxes annotated in Stanford Dogs, using intersection over union: the overlap between the predicted and annotated regions, relative to their combined area.
| Method | Reported mean intersection over union |
|---|---|
| CAM | 0.355 |
| Grad-CAM | 0.357 |
Comparison settings: 50 images; two training epochs; heatmap threshold 0.5.
These results give me little reason to prefer either method. I changed the hooked layer as well as the method, so I can’t attribute the small difference to Grad-CAM alone. Both maps remain coarse spatial summaries of a classifier’s output.
If I repeat the comparison, I need to keep the image, class score, layer and map processing fixed. I would first check the CAM/Grad-CAM relationship the architecture predicts, and only then investigate localization. What I would change about my initial approach is the assumption that two matching maps must mean something has gone wrong.