Evaluating Vision Models

Grad-CAM and saliency maps

Grad-CAM paints a heat map over the parts of an image a model reacted to, and the honest version of this lesson is mostly about what that heat map does not prove.

On this page 10
  1. The short answer
  2. The analogy you have lived
  3. Why anyone bothers
  4. How it works, roughly
  5. The part that gets left out
  6. How to keep yourself honest
  7. Where you have seen this
  8. The honest part
  9. Remember this
  10. What to learn next

One lesson, three depths. Pick the one that fits you today — you can switch any time.

Beginner — No maths. Plain English.

The short answer

A saliency map colours in the parts of a picture that a model reacted to when it made its decision.

The analogy you have lived

You show a photo to a child and ask what animal it is. They say dog. You ask how they knew.

They put a finger on the ears.

That finger is the whole idea. The answer alone is one word. The finger tells you what the answer was about. You notice when the child points at the collar, not the dog.

Why anyone bothers

A model that outputs "tumour, 91 percent" gives a doctor nothing to check. There is no working to inspect and no place to disagree.

Put a heat map over the scan and the conversation changes. The doctor can see what the model reacted to. The suspicious region, or a marker somebody drew on the film?

That second case is not hypothetical. Models regularly learn the wrong cue. A hospital's watermark. The ruler placed beside a skin lesion. The camera every sick patient happened to be photographed with. A heat map is how those get caught.

How it works, roughly

A network builds up a stack of internal pictures as it processes an image. Early ones respond to edges. Later ones respond to whole things: a wheel, a face, an ear.

Grad-CAM asks a question of the last such stack. For the class the model chose, which of those internal pictures mattered, and how much?

It weights each internal picture by how much it pushed the answer. Then it adds them up. Parts that pushed against the answer are dropped. What is left is the heat map.

   image → network → internal pictures ─┐
                                        ├→ weighted sum → heat map
   "how much did each one push the      │
    answer toward 'dog'?" ─────────────┘

The part that gets left out

Here is what almost nobody tells you. A heat map can be nearly identical for the class the model picked and for a class it rejected.

Think about what that means. You ask "why dog?" and then "why cat?". The finger lands in the same place both times. The finger is pointing at something interesting, not at a reason.

This is not a rare bug. It happens whenever the heat map is taken from a layer whose internal pictures are not specific to any class. The map looks convincing. It carries no information about the decision.

How to keep yourself honest

Three checks, and all three are cheap.

Ask for a class you know is wrong. If the map barely changes, the map is not explaining the choice.

Scramble the model's learning and try again. Take a network with random, untrained weights. Suppose it still produces a sensible-looking outline. Then that outline came from the picture and the network's shape. Nothing learned went into it.

Cover the highlighted region and re-run. If the answer does not change, the region was not load-bearing. This is the only one of the three that tests something causal.

Most published heat maps have passed none of these.

Where you have seen this

  • Medical imaging tools that shade the region a model flagged.
  • Quality-control cameras that circle the defect they found.
  • Photo apps that show you what they think the subject is.
  • Research papers, where a row of colourful heat maps is used as evidence.

That last one is the one to be careful with.

The honest part

A saliency map shows where the model looked, at best. It does not show what the model concluded, or why. It does not show whether the answer needed that region.

Treat it as a debugging tool that generates suspicions worth testing. It is very good at that. Treat it as proof and you will believe things that are not true.

Remember this

  • A saliency map colours the parts of the image a model responded to.
  • The same map can appear for a class the model rejected, so it is not automatically an explanation.
  • Test it: wrong class, random weights, cover-and-rerun. Cheap, and most maps fail.

What to learn next

Developer — Code and libraries.

Setup

bash
pip install torch      # tested against torch 2.5.1, CPU is fine

Every weight below is set by hand. Nothing is trained and nothing is random, so the numbers reproduce exactly, and the failure that appears is structural rather than a quirk of one training run.

Grad-CAM, and the check that breaks it

gradcam.py
import torch
import torch.nn as nn

torch.set_printoptions(precision=2, sci_mode=False, linewidth=140)
S = 16


def image(corner):
    """A bright 6x6 patch in one corner of a dark 16x16 image."""
    x = torch.full((1, 1, S, S), 0.1)
    if corner == "tl":
        x[..., 2:8, 2:8] = 0.9
    else:
        x[..., 8:14, 8:14] = 0.9
    return x


class Tiny(nn.Module):
    """Weights are set by hand, so every number below is reproducible."""
    def __init__(self):
        super().__init__()
        self.conv = nn.Conv2d(1, 2, 3, padding=1, bias=True)
        with torch.no_grad():
            self.conv.weight[0] = 1 / 9        # channel 0: "something bright here"
            self.conv.weight[1] = -1 / 9       # channel 1: "something dark here"
            self.conv.bias[:] = torch.tensor([-0.3, 0.3])
        self.relu = nn.ReLU()
        self.head = nn.Linear(2 * S * S, 2, bias=False)
        with torch.no_grad():
            w = torch.zeros(2, 2, S, S)
            w[0, 0, :S // 2, :S // 2] = 1.0    # class 0 reads the top-left quadrant
            w[1, 0, S // 2:, S // 2:] = 1.0    # class 1 reads the bottom-right one
            self.head.weight.copy_(w.reshape(2, -1))

    def forward(self, x):
        self.features = self.relu(self.conv(x))     # the layer we will explain
        self.features.retain_grad()                 # keep its gradient after backward
        return self.head(self.features.flatten(1))


def grad_cam(model, x, cls):
    model.zero_grad(set_to_none=True)
    logits = model(x)
    logits[0, cls].backward()
    a = model.features.detach()[0]                  # activations, (C, H, W)
    g = model.features.grad[0]                      # gradients, same shape
    alpha = g.mean(dim=(1, 2))                      # one weight per channel
    cam = torch.relu((alpha[:, None, None] * a).sum(0))
    return logits.detach(), alpha, cam


def show(cam, label):
    m = float(cam.max())
    print(label)
    if m == 0:
        print("   (the whole map is zero)")
        return
    for row in cam:
        print("   " + "".join(" .:-=+*#%@"[min(9, int(v / m * 9))] for v in row))


model = Tiny()

for corner, name in (("tl", "bright patch in the TOP-LEFT"),
                     ("br", "bright patch in the BOTTOM-RIGHT")):
    x = image(corner)
    logits, alpha, cam = grad_cam(model, x, cls=0)
    print(f"\n=== {name} ===")
    print(f"logits: class0 {logits[0,0]:.2f}   class1 {logits[0,1]:.2f}"
          f"   -> predicted {int(logits.argmax())}")
    print(f"Grad-CAM channel weights for class 0: {alpha.tolist()}")
    show(cam, "Grad-CAM for class 0:")

    _, alpha1, cam1 = grad_cam(model, x, cls=1)
    print(f"Grad-CAM channel weights for class 1: {alpha1.tolist()}")
    show(cam1, "Grad-CAM for class 1 (the class it did NOT predict):")

    # A plain input-gradient saliency map, for comparison.
    x2 = x.clone().requires_grad_(True)
    model.zero_grad(set_to_none=True)
    model(x2)[0, 0].backward()
    sal = x2.grad[0, 0].abs()
    show(sal, "input-gradient saliency for class 0:")
Output

=== bright patch in the TOP-LEFT ===
logits: class0 16.09   class1 0.00   -> predicted 0
Grad-CAM channel weights for class 0: [0.25, 0.0]
Grad-CAM for class 0:


     :++++:
     +@@@@+
     +@@@@+
     +@@@@+
     +@@@@+
     :++++:








Grad-CAM channel weights for class 1: [0.25, 0.0]
Grad-CAM for class 1 (the class it did NOT predict):


     :++++:
     +@@@@+
     +@@@@+
     +@@@@+
     +@@@@+
     :++++:








input-gradient saliency for class 0:
     .:--:.
    .-+**+-.
   .-*%@@%+:
   :+%@@@@*-
   -*@@@@@*-
   -*@@@@@*-
   :+%@@@@*-
   .-+****=:
    .:----:.








=== bright patch in the BOTTOM-RIGHT ===
logits: class0 0.00   class1 16.09   -> predicted 1
Grad-CAM channel weights for class 0: [0.25, 0.0]
Grad-CAM for class 0:








           :++++:
           +@@@@+
           +@@@@+
           +@@@@+
           +@@@@+
           :++++:


Grad-CAM channel weights for class 1: [0.25, 0.0]
Grad-CAM for class 1 (the class it did NOT predict):








           :++++:
           +@@@@+
           +@@@@+
           +@@@@+
           +@@@@+
           :++++:


input-gradient saliency for class 0:
   (the whole map is zero)

This output is the lesson

The model is doing exactly what we built it to do. Class 0 reads only the top-left quadrant, class 1 only the bottom-right. The logits confirm it: 16.09 against 0.00 for the top-left image, and the mirror image for the other. There is no ambiguity about what this network uses.

Grad-CAM cannot see any of that. The channel weights are [0.25, 0.0] for class 0 and [0.25, 0.0] for class 1. Identical. Both classes read channel 0 over a quadrant of 64 positions out of 256, so both gradients average to exactly the same 0.25.

So the two heat maps are pixel-for-pixel identical. The map for the predicted class and the map for the rejected class are the same picture. Anyone shown only the first one would conclude, reasonably and wrongly, that it explains the decision.

And it lights up the wrong quadrant without complaint. In the second image, the map for class 0 sits over the bottom-right patch — a region class 0 provably ignores, since its logit is 0.00. The map is showing where the features are, not where the evidence is.

The input-gradient map does distinguish. For the bottom-right image it is exactly zero for class 0, which is the truth: no pixel there can change class 0's score. Different explanation methods disagree, and at least one of them is wrong every time.

Why this happens, and when it does not. Grad-CAM's weight is one scalar per channel, obtained by averaging the gradient over all spatial positions. That average throws the spatial structure away. The method recovers spatial information only from the activations, which are class-agnostic. It works well when late-layer channels are class-specific — which is broadly true for a big ImageNet classifier's final convolutional block, and false here. Apply Grad-CAM to an earlier layer, or to a head that mixes channels spatially, and you get pictures like these.

The three checks, in code

Append this to the script above. It reuses Tiny, image and grad_cam unchanged.

sanity_checks.py
model = Tiny()
x = image("tl")
logits = model(x).detach()
best = int(logits.argmax())

# 1. Wrong-class check: how different is the map for a class the model rejected?
cam_right = grad_cam(model, x, cls=best)[2]
cam_wrong = grad_cam(model, x, cls=1 - best)[2]
print("largest difference between the two maps:",
      (cam_right - cam_wrong).abs().max().item())

# 2. Randomised-weights check: does an untrained network give the same picture?
torch.manual_seed(0)
scrambled = Tiny()                      # a fresh module, then scramble its weights
with torch.no_grad():
    for p in scrambled.parameters():
        torch.nn.init.normal_(p, std=0.1)
cam_rand = grad_cam(scrambled, x, cls=best)[2]
a, b = cam_right.flatten(), cam_rand.flatten()
cos = torch.dot(a, b) / (a.norm() * b.norm() + 1e-12)
print("similarity to the untrained network's map:", round(float(cos), 3))

# 3. Deletion check: black out the hottest region and see if the score moves.
k = cam_right.flatten().topk(40).indices
occluded = x.clone().reshape(1, 1, -1)
occluded[0, 0, k] = 0.0
before = float(model(x)[0, best])
after = float(model(occluded.reshape_as(x))[0, best])
print(f"score for class {best}: {before:.2f} -> {after:.2f}  (drop {before-after:.2f})")
Output
largest difference between the two maps: 0.0
similarity to the untrained network's map: 0.36
score for class 0: 16.09 -> 0.00  (drop 16.09)

Check one fails completely. Zero difference between the map for the chosen class and the map for the rejected one.

Check two is mildly reassuring. A similarity of 0.36 against an untrained network means the map is not purely a function of architecture and input. It is not close to zero either, so some of what you are seeing comes from the shape of the network rather than from anything learned.

Check three passes, and that is the interesting part. Blacking out the forty hottest cells takes the class score from 16.09 to exactly 0.00. For this image the highlighted region really is load-bearing. So a method that cannot tell two classes apart can still point at a causally important region.

That combination is the honest summary of saliency mapping. The region matters; the map is not an explanation of the choice. Run all three checks, and do not let one passing check stand in for the others.

The deletion check is the only one of the three that tests a causal claim. Formalised over many thresholds it becomes the deletion and insertion curves of Petsiuk et al. (2018), which are the closest thing this field has to a quantitative score for a saliency method.

For real models

bash
pip install grad-cam
python
from pytorch_grad_cam import GradCAM
from pytorch_grad_cam.utils.model_targets import ClassifierOutputTarget

cam = GradCAM(model=model, target_layers=[model.layer4[-1]])
heatmap = cam(input_tensor=batch, targets=[ClassifierOutputTarget(281)])

No output block: it depends on your model and your image, and a fabricated heat map would be worse than none. target_layers is the decision that matters most. For a ResNet it is the last block; for a vision transformer you need the reshape transform the library provides, because tokens are not laid out as a spatial grid.

Common mistakes

Choosing the layer that produces the prettiest picture. Earlier layers give sharper maps because they have higher resolution. They are also less class-specific, which is exactly the failure demonstrated above. Sharper is not better.

Upsampling a 7x7 map to 224x224 and treating the result as pixel-precise. A ResNet's final feature map is 7x7 for a 224-pixel input. Every blob you see has been stretched by a factor of 32. The apparent precision is interpolation.

Comparing heat maps across images by their own maxima. Each map above was normalised by its own maximum. A map with a strong response and one with almost none look identical after that. Normalise across the set if you intend to compare.

Presenting the map for the predicted class only. Always show at least one rejected class beside it. If a reviewer cannot tell them apart, neither can you.

Forgetting model.eval(). Dropout and batch-norm in training mode change both the activations and the gradients, and the map becomes non-reproducible. See train and eval mode.

Try it yourself

Change the head so class 0 reads channel 0 and class 1 reads channel 1, and rerun. The channel weights will now differ between the two classes, and the maps will separate — showing you precisely the condition under which Grad-CAM works. Then apply the deletion check to both versions.

What to learn next

Researcher — Mathematics and papers.

Class activation mapping

Zhou et al. (2016) introduced CAM for architectures ending in global average pooling followed by one linear layer. With final feature maps $A^k$ and classifier weight $w_k^c$,

$$ M_c(i,j) = \sum_k w_k^c \, A^k(i,j) $$

The restriction is severe: the architecture must have that exact tail, and models without it had to be modified and retrained.

Selvaraju et al. (2017/2020) removed it. Grad-CAM replaces the classifier weight with a gradient average:

$$ \alpha_k^c = \frac{1}{Z}\sum_{i}\sum_{j} \frac{\partial y^c}{\partial A^k(i,j)}, \qquad L_{\text{Grad-CAM}}^c = \mathrm{ReLU}!\left(\sum_k \alpha_k^c A^k\right) $$

$y^c$ is the pre-softmax score for class $c$, $Z$ the number of spatial positions. For a GAP-plus-linear network $\alpha_k^c$ reduces exactly to $w_k^c / Z$, so Grad-CAM is a strict generalisation of CAM.

The ReLU keeps only features with a positive influence on $y^c$. Removing it produces a signed map that is often more informative and is not what the paper defines.

The global spatial average in $\alpha_k^c$ is the source of the failure demonstrated in the developer block: all spatial structure in the gradient is discarded, so class specificity must come entirely from the channels.

Variants, and what each fixes

MethodChangeFixes
Grad-CAM++ (Chattopadhay et al., 2018)pixel-wise positive weights from higher-order derivativesmultiple instances of one class
XGrad-CAM (Fu et al., 2020)axiomatic weights satisfying sensitivity and conservationtheoretical grounding for ReLU-CNNs
Score-CAM (Wang et al., 2020)masks the input with each activation map, uses the score change as weightgradient-free; no saturation or noise
Ablation-CAM (Desai and Ramaswamy, 2020)weight = drop in score when a channel is zeroedcausal by construction; expensive
Eigen-CAM (Muhammad and Yeasin, 2020)first principal component of the activationsclass-independent by design
HiResCAM (Draelos and Carin, 2020)element-wise gradient-activation product, no spatial averagingprovably faithful for the class it explains

HiResCAM deserves particular attention. Draelos and Carin prove that Grad-CAM's spatial averaging can produce maps highlighting regions that provably did not contribute, and that the element-wise product $\sum_k \frac{\partial y^c}{\partial A^k} \odot A^k$ is faithful for architectures whose head is a single fully-connected layer after the explained layer. The developer example above is a minimal instance of the failure their proof describes.

Sanity checks

Adebayo et al. (2018), Sanity Checks for Saliency Maps, propose two randomisation tests:

  • Model parameter randomisation. Reinitialise the network's weights layer by layer, from the top down, and measure map similarity to the original. A method whose maps survive randomisation is not a function of what the model learned.
  • Data randomisation. Retrain on permuted labels. A method whose maps are unchanged is not a function of the label relationship.

Guided Backpropagation and Guided Grad-CAM fail both, behaving essentially as edge detectors. Plain Grad-CAM and input gradients pass the parameter test. The result is why "it looks like the object" is not evidence for a saliency method.

Kindermans et al. (2019), The (Un)reliability of Saliency Methods, add an input-invariance test: adding a constant to every input, absorbed by an adjusted bias so the model's function is unchanged, alters the maps produced by several popular methods.

Quantitative evaluation

Since ground-truth explanations do not exist, evaluation is comparative and causal.

Deletion and insertion (Petsiuk et al., 2018, RISE): progressively remove the highest-ranked pixels and plot class probability against fraction removed; the area under the deletion curve should be low, and under the insertion curve high. Both are confounded by distribution shift — a blacked-out region is itself out of distribution — which Hooker et al. (2019), ROAR, address by retraining on the modified data at each threshold. ROAR found several popular methods no better than a random ranking.

Pointing game (Zhang et al., 2018): the fraction of images where the map's maximum falls inside a ground-truth bounding box. Cheap, and only weakly related to faithfulness.

Attribution beyond CAMs

Integrated Gradients (Sundararajan et al., 2017) integrates the gradient along a straight path from a baseline $x'$ to the input:

$$ \mathrm{IG}_i(x) = (x_i - x'i) \int{0}^{1} \frac{\partial F(x' + \alpha(x - x'))}{\partial x_i} \, d\alpha $$

It satisfies completeness (attributions sum to $F(x) - F(x')$) and implementation invariance, and it requires choosing a baseline. A black image is the usual choice and is a poor one for any task where black is meaningful.

SHAP and LIME provide model-agnostic alternatives with different guarantees; see SHAP and LIME. For transformers, raw attention weights are not attributions — Jain and Wallace (2019) and Wiegreffe and Pinter (2019) conducted the debate; attention rollout and gradient-weighted attention (Chefer et al., 2021) are the usable versions.

The honest position

Saliency methods are hypothesis generators. They have repeatedly located genuine dataset artefacts — chest-radiograph models keying on hospital-specific tokens, dermatology models keying on rulers placed beside malignant lesions — and that is a real contribution. They do not establish which features a model used, they disagree with each other on the same input, and several fail basic sanity checks. Any claim resting on a heat map should be confirmed by intervention: occlude, retrain without the region, or test on data where the suspected artefact is absent.

Papers

What to learn next