Perception & GenerationintermediateGPU~25 minColab

Plant a Shortcut, Catch the Model

ازرع اختصاراً واضبط النموذج متلبّساً

Accuracy measures agreement with the labels, not the reason for it

A colleague hands you a cat-vs-dog classifier with excellent test accuracy. You have no reason to doubt it, and that is the problem: a test split drawn from the same collection as the training data shares every accident of that collection. In this workshop you create such an accident on purpose. A coloured square, under one percent of the image, is stamped onto most dogs and a few cats. Two identical networks train on the same photos, one with the square and one without. On the ordinary test split the network that saw the square wins by 0.082 in accuracy. Reverse where the square appears and the same network falls to 0.116, worse than a coin flip. Grad-CAM then shows you where it was looking.

The goal

Train an honest model that genuinely separates cats from dogs, show that the shortcut model's accuracy collapses once the square no longer agrees with the label, and use Grad-CAM to show that its decision draws on the square far more than the honest model's does.

Colab opens a read-only copy. Save a copy to Drive to keep your edits.

The notebook needs a keyboard — best opened on a desktop.

The papers behind this

A answers one question: does the model agree with the labels on examples it has not seen? It does not answer why. If the training photos and the test photos were collected the same way, any accident of that collection — a watermark, a background, a camera — sits in both, and a model that learned the accident scores just as well as one that learned the object.

This is , and it is hard to see from outside because nothing fails. The usual advice is to be sceptical of your data. Advice like that is easy to agree with and easy to forget. So instead of reading about a shortcut, you will build one, measure what it does to the numbers, and then look inside the model to see it being used.

Check that the code hash matches the notebook and that a GPU is attached.

import azimuth_nb as azimuth

env = azimuth.setup(SLUG, lang=LANG, profile=PROFILE)
Workshop code
Plant a Shortcut, Catch the Model
Tesla T4 · 14.6 GB · 12.7 GB RAM · PyTorch 2.11.0+cu130
profile: free
assets:
  · cifar-10-python.tar.gz — 166,502 KB, UNPINNED
ready · seed=17, epochs=20, batch_size=256, lr=0.003, width=32, patch_px=3, shortcut_rate=0.95
code · 93bb602420424303

In the Arabic build this should name a Noto Arabic font. If it does not, figure labels will draw as empty boxes.

# matplotlib draws Arabic as isolated letters in logical order, so the Arabic
# build reshapes every label and draws it with a font that has Arabic glyphs.
# DejaVu, matplotlib's default, has none.
import glob

import arabic_reshaper
import matplotlib.pyplot as plt
from bidi.algorithm import get_display
from matplotlib import font_manager

ARABIC_FONTS = ("NotoNaskhArabic-Regular.ttf", "NotoSansArabic-Regular.ttf")
font_path = next(
    (p for name in ARABIC_FONTS for p in glob.glob(f"/usr/share/fonts/**/{name}", recursive=True)),
    None,
)
if font_path:
    font_manager.fontManager.addfont(font_path)
    arabic_family = font_manager.FontProperties(fname=font_path).get_name()
    plt.rcParams["font.family"] = [arabic_family, "DejaVu Sans"]
elif env.lang == "ar":
    print("⚠ لم يُعثر على خطّ عربي؛ ستظهر النصوص العربية في الرسوم مربّعات فارغة.")


def label(en_text, ar_text):
    """Pick the string for this build; shape Arabic so matplotlib draws it joined."""
    if env.lang == "ar":
        return get_display(arabic_reshaper.reshape(ar_text))
    return en_text


if env.lang == "ar":
    print(f"خطّ الرسوم: {font_path or 'غير متوفّر'}")
else:
    print(f"figure font: {font_path or 'default'}")
Workshop code
figure font: /usr/share/fonts/truetype/noto/NotoNaskhArabic-Regular.ttf

We use two classes from CIFAR-10: cat and dog. They are the pair small networks confuse most often — similar size, similar fur, similar poses at 32 by 32 pixels. That matters here. A shortcut is most tempting when the real task is hard, because the cheap cue reduces the loss faster than the genuine features do.

The classes are found by name in the archive's metadata, not by position. The dog share should be close to one half.

# CIFAR-10 ships as pickled batches inside one tarball. We read only two
# classes, and we find them by NAME from the archive's own metadata rather than
# trusting that cat is index 3 — an index is a property of whoever exported
# the file.
import pickle
import tarfile

import numpy as np
import torch

torch.manual_seed(env.cfg["seed"])
np.random.seed(env.cfg["seed"])
device = "cuda" if torch.cuda.is_available() else "cpu"


def read_member(tar, suffix):
    member = next(m for m in tar.getmembers() if m.name.endswith(suffix))
    return pickle.load(tar.extractfile(member), encoding="latin1")


with tarfile.open(env.assets["cifar-10-python.tar.gz"], "r:gz") as tar:
    names = read_member(tar, "batches.meta")["label_names"]
    cat_id, dog_id = names.index("cat"), names.index("dog")

    def two_classes(batches):
        xs, ys = [], []
        for b in batches:
            raw = read_member(tar, b)
            labels = np.array(raw["labels"])
            keep = (labels == cat_id) | (labels == dog_id)
            xs.append(raw["data"][keep])
            ys.append((labels[keep] == dog_id).astype(np.int64))  # cat=0, dog=1
        x = np.concatenate(xs).reshape(-1, 3, 32, 32)
        return torch.from_numpy(x), torch.from_numpy(np.concatenate(ys))

    x_train, y_train = two_classes([f"data_batch_{i}" for i in range(1, 6)])
    x_test, y_test = two_classes(["test_batch"])

# Keep the uint8 images on the device; batches are converted on the fly.
x_train, y_train = x_train.to(device), y_train.to(device)
x_test, y_test = x_test.to(device), y_test.to(device)

n_train, n_test = len(y_train), len(y_test)
dog_share = round(float(y_train.float().mean()), 3)

if env.lang == "ar":
    print(f"صور التدريب: {n_train}   صور الاختبار: {n_test}   نسبة الكلاب: {dog_share}")
else:
    print(f"train images: {n_train}   test images: {n_test}   dog share: {dog_share}")
Workshop code
train images: 10000   test images: 2000   dog share: 0.5

The shortcut is a small magenta square in the bottom-right corner. It is not attached to every dog, and it is not kept off every cat. A dog carries it with some probability, and a cat carries it with the complementary probability. That is a : strong, but imperfect, the way real ones are. A hospital's scanner model correlates with diagnosis; a photographer's watermark correlates with subject. Neither is the thing being classified.

The square is stamped after augmentation, so it never moves. Every image is drawn once with a fixed seed, so both models below see exactly the same training images.

Look at the grid before reading the numbers. Would you have noticed the square if no one had told you it was there?

# The shortcut: a small solid square in the bottom-right corner.
#
# In training, a dog carries it with probability `shortcut_rate` and a cat with
# probability 1 - shortcut_rate. At 0.5 the square says nothing about the
# label; at 0.95 it is a near-perfect cue.
#
# The flags are drawn ONCE with a fixed generator, so every model below trains
# on byte-identical inputs.
PATCH = env.cfg["patch_px"]
PATCH_RGB = torch.tensor([255, 0, 255], dtype=torch.uint8, device=device).view(3, 1, 1)


def draw_flags(labels, rate_for_dogs, gen):
    """Per-image True/False: does this image carry the square?"""
    p = torch.where(labels == 1, rate_for_dogs, 1.0 - rate_for_dogs)
    return torch.rand(len(labels), generator=gen).to(labels.device) < p


def paint(images, flags):
    """Stamp the square onto the flagged images. Works on uint8 or float batches."""
    out = images.clone()
    if flags.any():
        patch = PATCH_RGB.to(out.dtype) if out.dtype == torch.uint8 else PATCH_RGB.float() / 255.0
        out[flags, :, -PATCH:, -PATCH:] = patch
    return out


gen = torch.Generator().manual_seed(env.cfg["seed"])
rate = env.cfg["shortcut_rate"]
train_flags = draw_flags(y_train, rate, gen)

# Three test sets built from the SAME test photos. Only the square moves.
test_sets = {
    "matched": paint(x_test, draw_flags(y_test, rate, gen)),  # same rule as training
    "clean": x_test.clone(),  # no square at all
    "flipped": paint(x_test, draw_flags(y_test, 1.0 - rate, gen)),  # the rule reversed
}

patch_pixel_share = round(PATCH * PATCH / (32 * 32), 4)
dogs_with_patch = round(float(train_flags[y_train == 1].float().mean()), 3)
cats_with_patch = round(float(train_flags[y_train == 0].float().mean()), 3)

# Show what a learner would see if they actually looked at the training data.
gallery = paint(x_train[:16], train_flags[:16]).cpu()
fig, axes = plt.subplots(2, 8, figsize=(11, 3.2))
for ax, img, y, f in zip(axes.flat, gallery, y_train[:16].cpu(), train_flags[:16].cpu()):
    ax.imshow(img.permute(1, 2, 0).numpy(), interpolation="nearest")
    ax.set_title(
        label("dog" if y else "cat", "كلب" if y else "قطة") + (" ■" if f else ""), fontsize=9
    )
    ax.axis("off")
fig.suptitle(
    label("Training images (■ = carries the square)", "صور من بيانات التدريب (■ = تحمل المربّع)")
)
plt.tight_layout()
plt.show()

if env.lang == "ar":
    print(f"المربّع يغطّي {patch_pixel_share:.2%} من بكسلات الصورة")
    print(f"كلاب تحمله: {dogs_with_patch:.1%}   قطط تحمله: {cats_with_patch:.1%}")
else:
    print(f"the square covers {patch_pixel_share:.2%} of the pixels")
    print(f"dogs carrying it: {dogs_with_patch:.1%}   cats carrying it: {cats_with_patch:.1%}")
Workshop code
the square covers 0.88% of the pixels
dogs carrying it: 95.6%   cats carrying it: 5.2%

Note the last stage: an 8 by 8 feature map. That map is what Grad-CAM reads later, so each of its cells covers a 4 by 4 patch of the image.

# A small ResNet: three stages of residual blocks at 32, 16 and 8 pixels.
# The last stage's 8x8 feature map is what Grad-CAM will read later.
import torch.nn as nn
import torch.nn.functional as F

MEAN = torch.tensor([0.491, 0.482, 0.447], device=device).view(1, 3, 1, 1)
STD = torch.tensor([0.247, 0.243, 0.262], device=device).view(1, 3, 1, 1)


class Block(nn.Module):
    def __init__(self, c_in, c_out, stride):
        super().__init__()
        self.conv1 = nn.Conv2d(c_in, c_out, 3, stride, 1, bias=False)
        self.bn1 = nn.BatchNorm2d(c_out)
        self.conv2 = nn.Conv2d(c_out, c_out, 3, 1, 1, bias=False)
        self.bn2 = nn.BatchNorm2d(c_out)
        self.skip = nn.Sequential()
        if stride != 1 or c_in != c_out:
            self.skip = nn.Sequential(
                nn.Conv2d(c_in, c_out, 1, stride, bias=False), nn.BatchNorm2d(c_out)
            )

    def forward(self, x):
        h = F.relu(self.bn1(self.conv1(x)))
        return F.relu(self.bn2(self.conv2(h)) + self.skip(x))


class SmallResNet(nn.Module):
    def __init__(self, width):
        super().__init__()
        w = width
        self.stem = nn.Sequential(
            nn.Conv2d(3, w, 3, 1, 1, bias=False), nn.BatchNorm2d(w), nn.ReLU()
        )
        self.stage1 = nn.Sequential(Block(w, w, 1), Block(w, w, 1))
        self.stage2 = nn.Sequential(Block(w, 2 * w, 2), Block(2 * w, 2 * w, 1))
        self.stage3 = nn.Sequential(Block(2 * w, 4 * w, 2), Block(4 * w, 4 * w, 1))
        self.head = nn.Linear(4 * w, 2)

    def features(self, x):
        return self.stage3(self.stage2(self.stage1(self.stem(x))))

    def forward(self, x):
        return self.head(self.features(x).mean(dim=(2, 3)))


def to_input(batch_u8):
    return (batch_u8.float() / 255.0 - MEAN) / STD


def augment(batch_u8, gen):
    """Random horizontal flip per image, random 4-px shift per batch. The square
    is stamped AFTER this, so it always sits in the same corner — as a real
    watermark would."""
    flip = (torch.rand(len(batch_u8), generator=gen) < 0.5).to(batch_u8.device)
    out = batch_u8.clone()
    out[flip] = out[flip].flip(-1)
    padded = F.pad(out, (4, 4, 4, 4))
    dx, dy = torch.randint(0, 9, (2,), generator=gen).tolist()
    return padded[:, :, dy : dy + 32, dx : dx + 32]


def train(flags, run_seed, epochs, tag):
    """Train a fresh model. Same seed -> same init and same batch order, so two
    runs differ ONLY in which images carry the square."""
    torch.manual_seed(run_seed)
    gen = torch.Generator().manual_seed(run_seed)
    model = SmallResNet(env.cfg["width"]).to(device)
    opt = torch.optim.AdamW(model.parameters(), lr=env.cfg["lr"], weight_decay=5e-4)
    bs = env.cfg["batch_size"]
    steps = epochs * ((n_train + bs - 1) // bs)
    sched = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=env.cfg["lr"], total_steps=steps)
    for epoch in range(epochs):
        model.train()
        order = torch.randperm(n_train, generator=gen).to(device)
        total, correct = 0.0, 0
        for i in range(0, n_train, bs):
            idx = order[i : i + bs]
            xb = paint(augment(x_train[idx], gen), flags[idx])
            logits = model(to_input(xb))
            loss = F.cross_entropy(logits, y_train[idx])
            opt.zero_grad(set_to_none=True)
            loss.backward()
            opt.step()
            sched.step()
            total += loss.item() * len(idx)
            correct += (logits.argmax(1) == y_train[idx]).sum().item()
        if epoch == 0 or (epoch + 1) % 5 == 0 or epoch + 1 == epochs:
            if env.lang == "ar":
                print(
                    f"[{tag}] الحقبة {epoch + 1:>2}  الخسارة {total / n_train:.3f}  دقة التدريب {correct / n_train:.3f}"
                )
            else:
                print(
                    f"[{tag}] epoch {epoch + 1:>2}  loss {total / n_train:.3f}  train acc {correct / n_train:.3f}"
                )
    return model.eval()


@torch.no_grad()
def accuracy(model, images_u8):
    hits = 0
    for i in range(0, len(images_u8), 500):
        hits += (
            (model(to_input(images_u8[i : i + 500])).argmax(1) == y_test[i : i + 500]).sum().item()
        )
    return hits / len(images_u8)
Workshop code

Both models start from the same initial weights, see the same batches in the same order, and train for the same number of epochs. The only difference is whether the square is stamped. Whatever separates their results below is caused by the square and nothing else.

This model is the control. If its clean accuracy is low, the comparison that follows is meaningless.

# The honest model: same photos, same schedule, no square anywhere.
no_flags = torch.zeros_like(train_flags)
honest = train(no_flags, env.cfg["seed"], env.cfg["epochs"], "honest")
honest_clean_acc = round(accuracy(honest, test_sets["clean"]), 4)

if env.lang == "ar":
    print(f"\nدقة النموذج النزيه على صور اختبار نظيفة: {honest_clean_acc:.3f}")
else:
    print(f"\nhonest model, clean test images: {honest_clean_acc:.3f}")
Workshop code
[honest] epoch  1  loss 0.672  train acc 0.578
[honest] epoch  5  loss 0.552  train acc 0.718
[honest] epoch 10  loss 0.411  train acc 0.810
[honest] epoch 15  loss 0.315  train acc 0.861
[honest] epoch 20  loss 0.249  train acc 0.896

honest model, clean test images: 0.866

Watch how fast its training accuracy rises compared with the honest model's. Then read the matched-test comparison as a reviewer would.

# The shortcut model: identical in every way except the square.
shortcut = train(train_flags, env.cfg["seed"], env.cfg["epochs"], "shortcut")
shortcut_matched_acc = round(accuracy(shortcut, test_sets["matched"]), 4)
honest_matched_acc = round(accuracy(honest, test_sets["matched"]), 4)
leaderboard_gap = round(shortcut_matched_acc - honest_matched_acc, 4)

# This is the comparison a normal workflow makes: a held-out split drawn from
# the same pipeline as the training data.
if env.lang == "ar":
    print("\nاختبار من نفس مصدر بيانات التدريب:")
    print(f"  النموذج النزيه   {honest_matched_acc:.3f}")
    print(f"  نموذج الاختصار   {shortcut_matched_acc:.3f}")
    print(f"  الفارق           {leaderboard_gap:+.3f}")
else:
    print("\ntest split from the same pipeline as training:")
    print(f"  honest model     {honest_matched_acc:.3f}")
    print(f"  shortcut model   {shortcut_matched_acc:.3f}")
    print(f"  gap              {leaderboard_gap:+.3f}")
Workshop code
[shortcut] epoch  1  loss 0.411  train acc 0.831
[shortcut] epoch  5  loss 0.195  train acc 0.952
[shortcut] epoch 10  loss 0.189  train acc 0.952
[shortcut] epoch 15  loss 0.184  train acc 0.952
[shortcut] epoch 20  loss 0.154  train acc 0.953

test split from the same pipeline as training:
  honest model     0.865
  shortcut model   0.946
  gap              +0.082

On a test split drawn the same way as the training data, the shortcut model scores 0.946 and the honest model 0.865. If these two were entries in a model comparison, the shortcut model would be chosen.

Nothing about this number is wrong. The test images are genuinely unseen, and the labels are correct. The problem is that the test set carries the same correlation as the training set, so it rewards the model for using it. A test split only checks to more of the same data.

The three groups use the same photos. Only the square changes. Look at the honest model's bars first: if they are level, the photos themselves did not get harder.

# Now break the correlation. Same photos, the square either removed or reversed.
results = {
    name: {"honest": accuracy(honest, imgs), "shortcut": accuracy(shortcut, imgs)}
    for name, imgs in test_sets.items()
}
shortcut_clean_acc = round(results["clean"]["shortcut"], 4)
shortcut_flipped_acc = round(results["flipped"]["shortcut"], 4)
honest_flipped_acc = round(results["flipped"]["honest"], 4)
shortcut_collapse = round(shortcut_matched_acc - shortcut_flipped_acc, 4)

cond_labels = {
    "matched": label("same rule\nas training", "نفس قاعدة\nالتدريب"),
    "clean": label("square\nremoved", "بلا\nمربّع"),
    "flipped": label("rule\nreversed", "القاعدة\nمعكوسة"),
}
xs = np.arange(len(test_sets))
fig, ax = plt.subplots(figsize=(7, 4))
ax.bar(
    xs - 0.2,
    [results[k]["honest"] for k in test_sets],
    0.4,
    label=label("honest model", "النموذج النزيه"),
    color="#4c72b0",
)
ax.bar(
    xs + 0.2,
    [results[k]["shortcut"] for k in test_sets],
    0.4,
    label=label("shortcut model", "نموذج الاختصار"),
    color="#dd8452",
)
ax.axhline(0.5, color="grey", ls="--", lw=1)
ax.text(
    len(test_sets) - 0.5,
    0.51,
    label("chance", "التخمين العشوائي"),
    color="grey",
    ha="right",
    fontsize=9,
)
ax.set_xticks(xs, [cond_labels[k] for k in test_sets])
ax.set_ylim(0, 1)  # zero-based: the honest model's flat line IS the point
ax.set_ylabel(label("test accuracy", "دقة الاختبار"))
ax.set_title(
    label("Same photos, three placements of the square", "الصور نفسها، وثلاث طرق لتوزيع المربّع")
)
ax.legend(loc="lower left")
plt.tight_layout()
plt.show()

if env.lang == "ar":
    header = ("الاختبار", "النزيه", "الاختصار")
    row_names = {"matched": "مطابق", "clean": "بلا مربّع", "flipped": "معكوس"}
else:
    header = ("test set", "honest", "shortcut")
    row_names = {"matched": "matched", "clean": "clean", "flipped": "flipped"}
print(f"{header[0]:<12}{header[1]:>10}{header[2]:>12}")
for k in test_sets:
    print(f"{row_names[k]:<12}{results[k]['honest']:>10.3f}{results[k]['shortcut']:>12.3f}")
Workshop code
test set        honest    shortcut
matched          0.865       0.946
clean            0.866       0.537
flipped          0.866       0.116

When the square's rule is reversed, the shortcut model drops to 0.116. The honest model, given the same images, scores 0.866.

A score below one half matters. A model that simply ignored the photo would land near chance. Falling below chance means the model is still following a rule, and the rule is now wrong. Most of its decisions were coming from the square, so reversing the square reverses most of its decisions.

With the square removed altogether, the shortcut model scores 0.537. This is the realistic case: a model trained on watermarked photos, deployed on photos without one. It is a that no ordinary test split would have shown you.

Each column is one cat with the square stamped on. Compare the bottom row with the middle row: where does the shortcut model's map concentrate?

# Grad-CAM: weight each channel of the last 8x8 feature map by how much the
# predicted class's score depends on it, sum, and keep the positive part.
# The result is a coarse map of where the evidence for the decision came from.
#
# We probe BOTH models on the same images — every test photo with the square
# stamped on — and measure how much of each map lands on the square's cell.
CELL = 32 // 8  # one Grad-CAM cell covers 4x4 input pixels
# The square sits in the last cell, but an 8x8 map is coarse and its peak
# smears one cell inward, so we count the corner block that reaches one cell
# beyond the square. `uniform_share` below is that block's share of a flat map.
r0 = max(0, (32 - PATCH) // CELL - 1)
patch_cells = slice(r0, 8)


def gradcam(model, images_u8):
    """Return (cams[N, 8, 8], predicted labels) for a batch."""
    feats = model.features(to_input(images_u8))
    feats.retain_grad()
    logits = model.head(feats.mean(dim=(2, 3)))
    pred = logits.argmax(1)
    model.zero_grad(set_to_none=True)
    logits.gather(1, pred[:, None]).sum().backward()
    weights = feats.grad.mean(dim=(2, 3), keepdim=True)
    cams = F.relu((weights * feats).sum(1)).detach()
    return cams, pred.detach()


def patch_attention(model, images_u8):
    """Mean share of Grad-CAM mass that falls on the square's cell(s)."""
    shares = []
    for i in range(0, len(images_u8), 250):
        cams, _ = gradcam(model, images_u8[i : i + 250])
        mass = cams.sum(dim=(1, 2))
        on_patch = cams[:, patch_cells, patch_cells].sum(dim=(1, 2))
        valid = mass > 0
        shares.append((on_patch[valid] / mass[valid]).cpu())
    return float(torch.cat(shares).mean())


probe = paint(x_test, torch.ones(n_test, dtype=torch.bool, device=device))
patch_attention_honest = round(patch_attention(honest, probe), 4)
patch_attention_shortcut = round(patch_attention(shortcut, probe), 4)
attention_gap = round(patch_attention_shortcut - patch_attention_honest, 4)
uniform_share = round(((8 - r0) ** 2) / 64, 4)


# Pick demonstrative examples instead of indexing blindly: cats that the
# honest model calls cat, the shortcut model calls dog, once the square is on.
def gradcam_all(model, images_u8):
    parts = [gradcam(model, images_u8[i : i + 250]) for i in range(0, len(images_u8), 250)]
    return torch.cat([c for c, _ in parts]), torch.cat([p for _, p in parts])


cats = (y_test == 0).nonzero().squeeze(1)
cam_h, pred_h = gradcam_all(honest, probe[cats])
cam_s, pred_s = gradcam_all(shortcut, probe[cats])
fooled = ((pred_h == 0) & (pred_s == 1)).nonzero().squeeze(1)[:6]
if len(fooled) == 0:  # nothing qualified: show the first cats rather than nothing
    fooled = torch.arange(6, device=device)


def up(c):
    return F.interpolate(
        c[None, None],
        size=32,
        mode="bilinear",
        align_corners=False,
    )[0, 0].cpu()


fig, axes = plt.subplots(3, len(fooled), figsize=(1.9 * len(fooled), 6))
axes = np.array(axes).reshape(3, -1)
row_titles = [
    label("photo + square", "الصورة + المربّع"),
    label("honest model", "النموذج النزيه"),
    label("shortcut model", "نموذج الاختصار"),
]


def pred_name(p):
    return label("dog", "كلب") if p == 1 else label("cat", "قطة")


for j, k in enumerate(fooled.tolist()):
    img = probe[cats[k]].permute(1, 2, 0).cpu().numpy()
    axes[0, j].imshow(img, interpolation="nearest")
    for row, (cams, preds) in enumerate([(cam_h, pred_h), (cam_s, pred_s)], start=1):
        axes[row, j].imshow(img, interpolation="nearest")
        axes[row, j].imshow(up(cams[k]), cmap="jet", alpha=0.5)
        axes[row, j].set_xlabel(pred_name(preds[k].item()), fontsize=9)
for row in range(3):
    for ax in axes[row]:
        ax.set_xticks([])
        ax.set_yticks([])
    axes[row, 0].set_ylabel(row_titles[row], fontsize=9)
fig.suptitle(
    label("Cats with the square: where each model looked", "قطط تحمل المربّع: أين نظر كلّ نموذج")
)
plt.tight_layout()
plt.show()

if env.lang == "ar":
    print("حصّة خريطة Grad-CAM الواقعة على المربّع (على صور الاختبار كلّها):")
    print(f"  لو توزّعت الخريطة بالتساوي   {uniform_share:.3f}")
    print(f"  النموذج النزيه               {patch_attention_honest:.3f}")
    print(f"  نموذج الاختصار               {patch_attention_shortcut:.3f}")
else:
    print("share of the Grad-CAM map that lands on the square (whole test set):")
    print(f"  if spread evenly   {uniform_share:.3f}")
    print(f"  honest model       {patch_attention_honest:.3f}")
    print(f"  shortcut model     {patch_attention_shortcut:.3f}")
Workshop code
share of the Grad-CAM map that lands on the square (whole test set):
  if spread evenly   0.062
  honest model       0.038
  shortcut model     0.451

Across every test photo with the square stamped on, the corner block around the square holds 0.451 of the shortcut model's Grad-CAM map. For the honest model the figure is 0.038. A map spread evenly over the image would put 0.063 there.

The accuracy table told you that something was wrong. The maps tell you what it is, and they would have told you even without the stress test. That is the practical value of : you can inspect a model you have no flipped test for.

Keep one caution. Grad-CAM reads an 8 by 8 map, so it can locate the corner but not the exact square. A coarse map is evidence about where to look next, not proof of what the model computes.

Exercise

The square agreed with the label 0.95 of the time. Real spurious correlations are often far weaker. Set my_rate lower and re-run the cell. Before you do, write down a guess: at what rate does the gap between matched and flipped accuracy disappear? Does the attention on the square fall with it?

# YOUR TURN.
#
# The planted square agreed with the label most of the time. How weak can a
# spurious correlation be and still get taken? Set `my_rate` and re-run this
# cell. 0.5 means the square carries no information; 1.0 means it is perfect.
#
# Predict first: at what rate does the flipped-test accuracy stop falling
# below the matched-test accuracy?
my_rate = 0.75

ex_flags = draw_flags(y_train, my_rate, torch.Generator().manual_seed(env.cfg["seed"]))
ex_model = train(ex_flags, env.cfg["seed"], env.cfg["epochs"], f"rate={my_rate}")
ex_gen = torch.Generator().manual_seed(env.cfg["seed"] + 1)
ex_matched = accuracy(ex_model, paint(x_test, draw_flags(y_test, my_rate, ex_gen)))
ex_flipped = accuracy(ex_model, paint(x_test, draw_flags(y_test, 1.0 - my_rate, ex_gen)))
ex_attention = patch_attention(ex_model, probe)

if env.lang == "ar":
    print(
        f"\nنسبة الارتباط {my_rate}:  مطابق {ex_matched:.3f}   معكوس {ex_flipped:.3f}"
        f"   الانهيار {ex_matched - ex_flipped:+.3f}   انتباه المربّع {ex_attention:.3f}"
    )
else:
    print(
        f"\nrate {my_rate}:  matched {ex_matched:.3f}   flipped {ex_flipped:.3f}"
        f"   collapse {ex_matched - ex_flipped:+.3f}   attention on square {ex_attention:.3f}"
    )
Workshop code

A hint is available in the notebook — env.hint(2)

The square was easy to find because you planted it. Real shortcuts are not announced. What carries over is a set of questions to ask of any result that looks good:

Was the test set collected the same way as the training set? If so, it shares the training set's accidents.

What would the score be if one suspicious feature were removed, or reversed? Build that test even when it is awkward.

Where does the model look? An attribution map takes seconds to draw, and it is the only check here that does not require knowing the shortcut in advance.

The control is checked first. The peak GPU memory printed at the end is the number the profile should declare.

# The control comes first: a shortcut can only be "caught" relative to a model
# that actually learned cats and dogs. If this fails, train longer.
honest_ok = env.check("honest-model-learns", honest_clean_acc)
collapse_ok = env.check("shortcut-collapses", shortcut_collapse)
attention_ok = env.check("gradcam-finds-square", attention_gap)

if device == "cuda":  # the measured peak is what `requires.vramGb` should declare
    peak_vram_gb = round(torch.cuda.max_memory_allocated() / 1e9, 2)
    if env.lang == "ar":
        print(f"ذروة استهلاك ذاكرة GPU: {peak_vram_gb} GB")
    else:
        print(f"peak VRAM: {peak_vram_gb} GB")
Workshop code
✓ Honest model accuracy on clean test images: 0.866 (needs ≥ 0.75)
✓ Shortcut model accuracy lost when the square's rule is reversed: 0.83 (needs ≥ 0.3)
✓ Extra share of the shortcut model's Grad-CAM map on the square, over the honest model's: 0.4127 (needs ≥ 0.1)
peak VRAM: 0.95 GB
receipt = env.receipt()
Workshop code
Workshop complete.

Completion code: AZ-██████████
Paste it on the workshop's page on Azimuth to record it.
Last verified: 2026-10-02 · Tesla T4 · PyTorch 2.11.0+cu130 · Python 3.13.15 · 8894810

Terms in this workshop