! pip install torch torchvision matplotlib numpyThreat Models, Certified Defences and Backdoor Attacks
Four questions: white-box versus black-box risk, the limits of randomised smoothing, adversarial CAPTCHAs, and a backdoored MNIST classifier you can probe live. Each prerequisite is explained right before it is needed, with a figure you can play with.
A1 · Training-time attacks: what the attacker changes
Last week's attacks changed the test input. A training-time attack changes what the model learns (slide 3).
Orange: what a training-time attacker can touch. Dashed: where last week's test-time attacker acted.
$$(x,\ y) \to (x,\ y')$$
Same input, wrong label.
$$(x,\ y) \to (x + \delta,\ y)$$
Slightly changed input, correct label: a label audit finds nothing.
$$(x,\ y) \to (x \oplus \text{trigger},\ y_t)$$
Trigger added, label set to the attacker's target.
A2 · Optimised poisons: two nested problems
Choosing the most harmful poison is a bilevel problem: one optimisation nested inside another (slide 7).
Victim: support vector machine.
Idea: exact gradient of the validation error from the SVM's optimality conditions.
Idea: feature collision. The poison looks like a cat but has the target dog's features.
Needs: the victim's feature extractor \(f\).
Idea: gradient matching. Poisons push the weights the same way as "label the target as cat".
Works when the victim trains from scratch.
Poison Frogs2 keeps the poison near a base image \(b\) while matching the target \(t\) in feature space:
$$\min_{x}\ \lVert f(x) - f(t)\rVert_2^2 + \beta\,\lVert x - b\rVert_2^2 .$$Witches' Brew3 aligns the poisons' training gradient with the attacker's desired gradient:
$$\min_{\Delta}\ 1 - \cos\!\Big(\nabla_\theta \mathcal L\big(f_\theta(t), y_{\text{adv}}\big),\ \tfrac{1}{P}\textstyle\sum_{i} \nabla_\theta \mathcal L\big(f_\theta(x_i + \Delta_i), y_i\big)\Big), \qquad \lVert\Delta_i\rVert_\infty \le \epsilon .$$Derivation · Poison Frogs' backward step is a weighted average
Poison Frogs alternates a gradient step on the feature term with an exact step on the pixel term (slide 12). After the forward step gives \(\hat x\), the backward step solves
$$x = \arg\min_{z}\ \tfrac{1}{2\lambda}\lVert z - \hat x\rVert_2^2 + \tfrac{\beta}{2}\lVert z - b\rVert_2^2 .$$Set the derivative to zero:
$$\tfrac{1}{\lambda}(z - \hat x) + \beta (z - b) = 0 \quad\Longrightarrow\quad z = \frac{\hat x + \lambda\beta\, b}{1 + \lambda\beta}.$$So every backward step pulls the poison a fixed fraction of the way back to the base image \(b\), which keeps the poison visually close to the base image.
A3 · Backdoor attacks and the two numbers that measure them
A backdoored model behaves normally on clean inputs, and switches to a hidden behaviour when it sees a trigger (slide 20).
Ordinary training on the mixture learns both behaviours at once (slide 22). Nothing special is needed.
$$\mathcal L = \underbrace{\mathbb E_{(x,y)\sim\mathcal D_c}\big[\ell(f(x), y)\big]}_{\text{normal behaviour}} + \underbrace{\mathbb E_{(x',y')\sim\mathcal D_b}\big[\ell(f(x'), y')\big]}_{\text{hidden behaviour}} .$$High CA: nobody notices.
High ASR: the backdoor works.
A4 · Adversarial training and its costs
Adversarial training trains on the worst allowed perturbation, keeping the correct label (slides 5–8).6
| Change | Methods | Idea |
|---|---|---|
| the loss | TRADES, MART | fit the clean label, and keep the output stable inside the ball |
| the cost | Free AT, Fast AT | reuse gradients, or use one random-start FGSM step |
A5 · Certified robustness by interval bound propagation
Interval bound propagation (IBP) pushes a whole box of inputs through the network, one neuron at a time.8
For one neuron \(z = \sum_i w_i x_i\) with \(x_i \in [l_i, u_i]\), each term is extreme at one end of its interval:
$$z_{\min} = \sum_{i} \min(w_i l_i,\ w_i u_i), \qquad z_{\max} = \sum_{i} \max(w_i l_i,\ w_i u_i).$$The network in the figure is
$$h_1 = x_1 + x_2, \qquad h_2 = x_1 - x_2, \qquad \text{cat} = \operatorname{ReLU}(h_1) - \operatorname{ReLU}(h_2), \qquad \text{dog} = \operatorname{ReLU}(h_2).$$"Not certified" does not mean "attackable". The worst real input, \(x_1 = 0.5 + \epsilon\) and \(x_2 = 0.5 - \epsilon\), gives cat \(= 1 - 2\epsilon\) and dog \(= 2\epsilon\), so the label only flips at \(\epsilon = 0.25\). The gap between \(1/6\) and \(0.25\) is the looseness of slide 18.
A6 · Input preprocessing, detection and guardrails
Three more defences act around the model instead of changing it.
How: JPEG compression, total-variation minimisation or binary thresholding removes fine detail.
Limit: an adaptive attacker optimises through the transform.
How: flag out-of-distribution inputs by confidence, feature distance or a trained detector.
Limit: false alarms; adaptive attacks fool both models.
How: guards screen the prompt and the draft; supervised fine-tuning (SFT) and RLHF change the weights.
Limit: unsafe replies remain; safe requests get refused.
A7 · Backdoor defences: cleaning the data or the model
A useful defence keeps CA high and drives ASR down (slide 3). There are two starting points (slides 4–5).
Given the poisoned set \(\mathcal D\), with \(|\mathcal D_b| \ll |\mathcal D|\).
Goal: find \(\hat{{\mathcal D}}_b \approx \mathcal D_b\) and train on the rest.
Given a backdoored \(f_{{\theta_b}}\); the trigger is unknown.
Goal: new weights that keep CA and lose the backdoor.
| Tool | Stage | Signal it uses | Slides |
|---|---|---|---|
| Anti-backdoor learning9 | data | poisons reach low loss faster; flag them, then unlearn by gradient ascent | 6–7 |
| Cognitive distillation10 | data | a backdoored image keeps its output with only a tiny mask (the trigger) | 8–9 |
| Outlier detection (SLOF)11 | data | poisons are few and alike, so they sit apart in feature space | 10–11 |
| Trigger synthesis12 | model | the target class needs a much smaller trigger (Neural Cleanse, DECREE) | 12–13 |
| Partition and vote13 | data | one poisoned record corrupts at most one model's vote | 14 |
Q4 tries cognitive distillation and outlier detection on a real backdoored model, and shows the loss signal behind anti-backdoor learning.
Derivation · why the vote survives \(m < \text{margin}/2\) poisons
Let \(n_{\text{cat}}\) and \(n_{\text{dog}}\) be the clean vote counts. Each of the \(m\) poisoned records sits in one split, so at most \(m\) models change their vote.
The worst case moves each changed vote from cat to dog, so after poisoning
$$n'_{\text{cat}} \ge n_{\text{cat}} - m, \qquad n'_{\text{dog}} \le n_{\text{dog}} + m .$$Cat still wins if \(n_{\text{cat}} - m > n_{\text{dog}} + m\), that is
$$m < \frac{n_{\text{cat}} - n_{\text{dog}}}{2}.$$A8 · Protective uses of attack techniques
The same machinery can protect people (slides 16–24).
The text watermark is detected with a one-sided z-test. With \(T\) tokens, green share \(\gamma\) and \(|s|_G\) green tokens:
$$z = \frac{|s|_G - \gamma T}{\sqrt{T\gamma(1-\gamma)}} .$$Q1 · White-box and black-box threat models
Explain the different risk models associated with a white-box and a black-box threat model for evasion attacks, from the perspective of someone deploying a model.
The components of a threat model
A threat model states what we assume about the attacker. It has three parts:
- Goal. For an evasion attack: make a deployed model misclassify a chosen input at test time.
- Capability. What the attacker may change, for example any \(\delta\) with \(\lVert\delta\rVert_\infty \le \epsilon\).
- Knowledge. What the attacker knows about the model. This is where white-box and black-box differ.
| Knowledge | Attacker sees | Typical attacks |
|---|---|---|
| White-box | architecture, weights, gradients, inputs and outputs | FGSM, PGD, C&W: one gradient per step |
| Black-box, scores | inputs and output probabilities | estimate gradients from queries (ZOO)18 |
| Black-box, decisions | inputs and the top label only | boundary walks (Boundary Attack)19 |
| No queries | nothing about this model | transfer from a surrogate model20 |
Black-box attacks still work: queries and transfer
Hiding the gradient does not remove it. A score-based attacker can estimate each partial derivative with two queries:
$$\frac{\partial \ell}{\partial x_i} \approx \frac{\ell(x + h\,\mathbf e_i) - \ell(x - h\,\mathbf e_i)}{2h}.$$For a \(d\)-pixel image a full gradient costs \(2d\) queries, and random-direction estimates cost far fewer. A transfer attacker needs no queries at all: an attack crafted on a surrogate model often works on the target too (Q3 measures this).
So black-box access makes attacks more expensive, not impossible. White-box access is the strongest attacker: anything a black-box attacker can do, a white-box attacker can do too.
An attacker estimates the gradient of a score-based MNIST model by central differences, one coordinate at a time.
Show solution
\(d = 784\), so one gradient costs \(2 \times 784 = 1568\) queries, and ten steps cost \(15\,680\). The white-box attacker needs 10 back-propagations.
Black-box access raised the cost by about three orders of magnitude, but did not stop the attack. Rate limits help, but a patient attacker, or one with a surrogate, still gets through.
Bridge to the question. Black-box is a statement about the attacker's cost. The question asks which assumption a defender should plan for.
Official answer
-
Step 1 · White-box
The attacker has access to the model, its gradients, and both inputs and outputs: almost complete visibility.
-
Step 2 · Black-box
The attacker only has access to the inputs and outputs of the model.
-
Step 3 · Why black-box is not enough for a defender
Assuming black-box access is not sufficient for security. Queries and transfer attacks still find adversarial examples, and the model's details can leak (stolen weights, an open-source base model, model extraction by queries).
-
Step 4 · The defender's choice
As a defender, we should almost always assume white-box access. It lets us assess the security of the model under a worst-case assumption about the attacker's access.
A defence that only holds against black-box attackers is security through obscurity: it fails the moment the model leaks, like the obfuscated-gradient defences of lecture 20, slide 11.
Q2 · Limits of randomised smoothing, and how to use it in practice
Certified defences using randomised smoothing produce guarantees that adversarial examples do not exist up to a fixed radius. Describe the limitations of these defences. How would you create an operational system that used certified defences to help mitigate the risk of adversarial attacks?
The smoothed classifier
Randomised smoothing does not certify the base model \(f\). It builds a new classifier \(g\) that returns the class \(f\) predicts most often under Gaussian noise (slide 19):21
$$g(x) = \arg\max_{c}\ \Pr\big[f(x + \eta) = c\big], \qquad \eta \sim \mathcal N(0, \sigma^2 I).$$Let \(p_A\) be the probability of the top class \(A\) under the noise. If \(p_A > \tfrac12\), the vote is \(A\).
Where the certified radius comes from
The certified radius is (slide 21)
$$R = \sigma\, \Phi^{-1}(p_A),$$where \(\Phi^{-1}\) is the inverse of the standard normal CDF. Any \(\delta\) with \(\lVert\delta\rVert_2 < R\) leaves \(g(x + \delta) = A\).
Derivation · the radius from the worst-case boundary, in one dimension
We only know one number about \(f\) near \(x\): the noise lands in class \(A\) with probability \(p_A\). Which base classifier makes the vote flip after the shortest move?
The worst case puts all of \(A\)'s mass on one side of a flat boundary, with the attacker moving straight towards it. Measure along that direction. The Gaussian around \(x\) has \(p_A\) of its mass below the boundary at distance \(r\):
$$\Phi\!\left(\frac{r}{\sigma}\right) = p_A \quad\Longrightarrow\quad r = \sigma\,\Phi^{-1}(p_A).$$Moving the centre by \(\lVert\delta\rVert_2\) towards the boundary leaves \(A\) a share
$$\Phi\!\left(\frac{r - \lVert\delta\rVert_2}{\sigma}\right),$$which is still above \(\tfrac12\) exactly while \(\lVert\delta\rVert_2 < r\). So \(R = r = \sigma\Phi^{-1}(p_A)\): at that distance the vote is tied (slide 21).
Footnote: the full proof uses the Neyman–Pearson lemma (among all regions with a given probability under one Gaussian, a half-space has the least probability under a shifted Gaussian) to show that the half-space is indeed the worst case. It also handles the runner-up class \(B\):
$$R = \tfrac{\sigma}{2}\big(\Phi^{-1}(\underline{p_A}) - \Phi^{-1}(\overline{p_B})\big),$$which reduces to the formula above when \(\overline{p_B} = 1 - \underline{p_A}\).
Estimating \(p_A\), and when to abstain
We cannot compute \(p_A\); we count votes over \(n\) noisy copies and get \(\hat p_A\) (slide 22). The count could be high by chance, so we subtract a safety margin from Hoeffding's inequality (slide 22):22
$$\Pr\big(\hat p_A - p_A \ge t\big) \le e^{-2nt^2} = \alpha \quad\Longrightarrow\quad t = \sqrt{\frac{\ln(1/\alpha)}{2n}} .$$Then certify with the "safe guess" of slide 22, a lower confidence bound \(\underline{p_A} = \hat p_A - t\):
$$R = \sigma\, \Phi^{-1}\big(\underline{p_A}\big).$$The certificate then holds with probability at least \(1 - \alpha\) over the random noise (with \(\alpha = 0.001\), the bound fails at most once in a thousand). If \(\underline{p_A} \le \tfrac12\), the cat might lose the vote, so the procedure abstains (slide 22).
Why the guarantee is an \(\ell_2\) guarantee
The Gaussian is round, so the certificate is a round (\(\ell_2\)) ball. The usual image threat model is an \(\ell_\infty\) box, and in \(d\) dimensions the box's corners are far outside the ball:
$$\lVert\delta\rVert_\infty \le \epsilon \quad\Longrightarrow\quad \lVert\delta\rVert_2 \le \epsilon\sqrt d,$$with equality at the corners, where every pixel moves by \(\pm\epsilon\). To cover the whole \(\ell_\infty\) box we would need \(R \ge \epsilon\sqrt d\). For MNIST with \(\epsilon = 0.1\) that is \(0.1 \times 28 = 2.8\), far above typical certified radii (slide 22: ImageNet, 49% certified at \(R = 0.5\)).
Use slide 22: \(n = 100\,000\) noisy copies, \(90\,000\) votes for cat, \(\alpha = 0.001\), \(\sigma = 0.25\).
Show solution
With \(n = 100\): \(t = \sqrt{6.91/200} \approx 0.186\), so \(\underline{p_A} \approx 0.714\) and \(R = 0.25\,\Phi^{-1}(0.714) \approx 0.14\). Fewer copies, a much smaller certificate.
The \(\ell_\infty\) box needs \(R \ge 2.8\): nine times the radius we just certified.
Bridge to the question. Each number points at a limitation: the certificate costs \(10^5\) forward passes, it is an \(\ell_2\) statement, and it is small.
Worked solution: an operational system
A certificate cannot tell whether an input is an attack: an adversarial example also gets a radius. But a small radius means the input is close to the decision boundary, and that is where adversarial examples live. The figure below uses the radius as a triage signal.
-
Step 1 · The threat model is narrow
Certified defences typically only apply to an \(\ell_2\) norm threat model. Patches, rotations, or an \(\ell_\infty\) attack with the same \(\epsilon\) can lie outside the certified ball.
-
Step 2 · Conservative and expensive
They are by necessity conservative, and require additional computation to construct the guarantee: about \(10^5\) noisy forward passes per certified input.
-
Step 3 · It certifies a different model
Crucially, the guarantee is not for the base model \(f\) but for the smoothed model \(g\): the (estimated) most likely class under Gaussian noise. You must deploy \(g\), with its lower clean accuracy, to get the guarantee.
-
Step 4 · A certificate is not an alarm
An operational system cannot tell from the certificate whether a sample is under attack. The certified radius may be the radius of an adversarial example, which is itself certified as whatever wrong class it landed in.
-
Step 5 · Use the radius for triage
But the radius measures how close a sample is to the decision boundary, and samples close to the boundary may be adversarial. Route samples with a small radius, or an abstention, to additional review: a human, a slower ensemble, or a request for more information.
Q3 · Adversarial perturbations for human-friendly CAPTCHAs
In the lectures it was mentioned that adversarial attacks against CAPTCHA systems have the potential to make the CAPTCHA task more human friendly. How is this possible, and what would be required to do this? Can you think of other systems that can be improved in a similar fashion?
Transferability, and the gap between people and models
Two facts from the attack lectures combine here:
- The perception gap. A small \(\ell_\infty\) perturbation barely changes what a person sees, yet it can change what a model predicts.
- Transfer. Adversarial examples crafted on one model often fool other models trained for the same task, even with different architectures.20
Old CAPTCHAs fought solvers by distorting the letters, which also hurt people. An adversarial CAPTCHA keeps the letters clear and adds noise that targets solver models instead (slide 24).
A CAPTCHA has several characters, and the solver must read all of them. If it reads each character correctly with probability \(p\), independently, it solves a \(k\)-character CAPTCHA with probability
$$P_{\text{solve}} = p^{k}.$$A solver reads clean digits with per-character accuracy 0.98, and the CAPTCHA has 4 digits.
Show solution
A moderate drop per character becomes a large drop per CAPTCHA, because the solver must get every character right. People are unaffected if the noise is small.
Bridge to the question. The second number is roughly what we measure below for a solver the CAPTCHA designer never saw.
Empirical study: attack one solver, test another
We trained two MNIST "solvers" offline: a LeNet (the designer's surrogate) and a fully-connected network (an unseen solver). Each digit gets PGD noise against the LeNet only, then both models read it. 2000 test digits per budget; numbers computed with PyTorch and embedded.
-
Step 1 · What is required
We need access to models designed to defeat CAPTCHA systems. Then we can construct adversarial examples against those models, and a CAPTCHA built from them should defeat them.
-
Step 2 · Why it generalises
The attack is designed against one or several solver models, but transferability implies it is also likely to work against other, undefended solvers.
-
Step 3 · Why it is more human-friendly
So we can make CAPTCHAs that are harder for machines to read without making them harder for people. The characters no longer need heavy distortion; the difficulty for machines comes from perturbations people barely notice.
-
Step 4 · The arms race
Solver models can start to use adversarial defences, such as adversarial training. As the lectures discussed, no guarantee is infallible. But even as the arms race continues, it pushes CAPTCHAs towards stronger noise against machines, not towards letters that are harder for people to read.
-
Step 5 · Other systems
The same idea protects people elsewhere (lecture 21):
- Fawkes cloaks personal photos so face-recognition models learn the wrong face.
- Unlearnable examples make scraped photos useless as training data.
- Adversarial clothing hides the wearer from person detectors.
- Anti-scraping or anti-OCR text, and image cloaks against style mimicry by generative models.
Q4 · A backdoored MNIST classifier, built and then detected
Backdoor attack demonstration notebook. Poison 2% of MNIST's training images with a small white square and the label 0, train a LeNet on the mixture, measure clean accuracy and attack success rate, then try to find the poisoned images with feature-space outlier detection (SLOF) and cognitive distillation.
Cell numbers count every cell of w11-tutorial-notebook.ipynb from 0, markdown included. Code is shown verbatim. "Plot from cell N" is the notebook's saved output.
To get numbers we could show live, we re-ran the notebook's code offline (PyTorch 2.6, CPU, same seeds and hyperparameters) and exported the trained LeNet to this page. Its final-epoch numbers are within a fraction of a point of the notebook's own run (earlier epochs differ by up to 4 points), and its JavaScript forward pass matches PyTorch's logits to within \(4 \times 10^{-6}\).
4.1 · Set-up and a first look at MNIST
# Notebook cell: imports, reproducible seed, device, and MNIST download + poisoned/triggered dataset wrappers
import os
import random
from typing import Optional, Tuple
import numpy as np
from PIL import Image
import torch
from torch import Tensor
from torch.utils.data import Dataset
import torchvision
from torchvision import transforms
from torchvision.datasets import MNIST
# -------------------------
# Reproducibility & device
# -------------------------
SEED = 42
random.seed(SEED)
np.random.seed(SEED)
torch.manual_seed(SEED)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(SEED)
def get_device() -> torch.device:
"""
Return the best available device: CUDA > MPS > CPU.
"""
if torch.cuda.is_available():
return torch.device("cuda")
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available() and torch.backends.mps.is_built():
return torch.device("mps")
return torch.device("cpu")
device = get_device()
print("Using device:", device)# -------------------------
# Root directory for data
# -------------------------
root = "./data"
os.makedirs(root, exist_ok=True)
# -------------------------
# Define basic transform
# (convert to tensor)
# -------------------------
transform = transforms.Compose([
transforms.PILToTensor(), # (1, H, W) uint8
transforms.ConvertImageDtype(torch.float32), # -> float32 in [0,1]
])
# -------------------------
# Download and create datasets
# -------------------------
mnist_train = MNIST(root=root, train=True, download=True, transform=transform)
mnist_test = MNIST(root=root, train=False, download=True, transform=transform)
print(f"Training set size: {len(mnist_train)}")
print(f"Test set size: {len(mnist_test)}")
# Example: show one sample (tensor shape + label)
img, label = mnist_train[0]
print("Sample image shape:", img.shape)
print("Sample label:", label)
Output of cell 6
Training set size: 60000 Test set size: 10000 Sample image shape: torch.Size([1, 28, 28]) Sample label: 5
import matplotlib.pyplot as plt
%matplotlib inline
def show_image_grid(imgs, nrow=10):
grid_img = torchvision.utils.make_grid(imgs, nrow=nrow)
fig= plt.figure(figsize=(10, 500), dpi=80)
plt.imshow(grid_img.permute(1, 2, 0).detach().cpu().numpy())
plt.show()
plt.close()# ------------------------
# Randomly pick some indices
# ------------------------
n_samples = 20
idxs = np.random.choice(len(mnist_train), size=n_samples, replace=False)
imgs, labels = zip(*[mnist_train[i] for i in idxs])
print("Labels:", labels)
show_image_grid(list(imgs), nrow=10)Plot from cell 9 (the notebook's saved output)
Output of cell 9
Labels: (7, 3, 8, 9, 3, 9, 7, 7, 5, 4, 2, 5, 6, 8, 1, 7, 4, 8, 0, 7)
4.2 · Trigger injection
The trigger is a filled square of side size and intensity value, placed pad pixels from a chosen corner. Cell 11 writes it into a copy of a \((1, H, W)\) array. For the bottom-left corner the top-left pixel of the square is
for the 3-pixel trigger used in training.
def add_square_trigger_numpy(img: np.ndarray, size: int = 3, value: int = 255, location: str = "bottom_left", pad: int = 1):
"""
Add a black/white square trigger to a (1,H,W) numpy array in [0,255] uint8.
- size: side length in pixels
- value: pixel intensity (0=black, 255=white)
- location: position of the square ('bottom_left' etc.)
- pad: offset from the edge
Returns a modified copy of the image.
"""
assert img.ndim == 3 and img.shape[0] == 1, "expected (1,H,W)"
_, H, W = img.shape
arr = img.copy()
if location == "bottom_left":
r0, c0 = H - pad - size, pad
elif location == "bottom_right":
r0, c0 = H - pad - size, W - pad - size
elif location == "top_left":
r0, c0 = pad, pad
elif location == "top_right":
r0, c0 = pad, W - pad - size
else:
raise ValueError(f"Unknown location: {location}")
r1, c1 = r0 + size, c0 + size
arr[:, r0:r1, c0:c1] = value
return arrpoisoned_imgs = []
for i in range(len(imgs)):
poisoned_imgs.append(torch.from_numpy(add_square_trigger_numpy(imgs[i].numpy(), size=5, value=255, location="bottom_right", pad=1)))
show_image_grid(list(poisoned_imgs), nrow=10)Plot from cell 12 (the notebook's saved output)
Cell 12 previews a 5 × 5 bottom-right trigger, but the attack in cell 16 uses a 3 × 3 bottom-left one. The figure in 4.5 shows why this matters: in our model the trigger succeeds on 99.7% of non-zero digits at the trained bottom-left position and 79% on average within 2 pixels of it, but only 0.5% on average further away.
4.3 · The poisoned dataset
Cell 15 subclasses torchvision.datasets.MNIST and overwrites self.data and self.targets in place, so every later read sees the poisoned images. This simulates a dataset that was tampered with before you downloaded it.
Indices are chosen with random.Random(seed).sample, so the same seed always poisons the same images:
# Cell: InMemoryPoisonedMNIST - subclass of torchvision.datasets.MNIST
from typing import Optional, Dict, Any, Sequence
import os
import json
import random
import numpy as np
import torch
from torchvision.datasets import MNIST
class InMemoryPoisonedMNIST(MNIST):
"""
Subclass of torchvision.datasets.MNIST that can apply an *in-memory*
deterministic data-poisoning modification to the dataset tensors.
After calling `apply_poison(...)` the underlying `self.data` and `self.targets`
are modified (uint8 images in `self.data` are overwritten in-place).
This simulates a dataset that was downloaded already poisoned.
Parameters
----------
root : str
dataset root passed to MNIST
train : bool
train or test split (poisoning typically applied to train=True)
download : bool
whether to download
"""
def __init__(self, root: str = "./data", train: bool = True, download: bool = True, **kwargs):
super().__init__(root=root, train=train, download=download, **kwargs)
# internal bookkeeping
self.poisoned_indices: Optional[Sequence[int]] = None
self.poison_metadata: Optional[Dict[str, Any]] = None
# keep a small in-memory copy if user wants to be able to restore later
self._orig_data = None
self._orig_targets = None
def _compute_coords(self, H: int, W: int, size: int, location: str, pad: int):
"""Helper: compute (r0,r1,c0,c1) for square given image HxW."""
s = int(size)
if location == "bottom_left":
r0, c0 = H - pad - s, pad
elif location == "bottom_right":
r0, c0 = H - pad - s, W - pad - s
elif location == "top_left":
r0, c0 = pad, pad
elif location == "top_right":
r0, c0 = pad, W - pad - s
elif location == "center":
r0, c0 = (H - s) // 2, (W - s) // 2
else:
raise ValueError(f"Unknown location: {location}")
r1, c1 = r0 + s, c0 + s
# clamp
r0, r1 = max(0, r0), min(H, r1)
c0, c1 = max(0, c0), min(W, c1)
return r0, r1, c0, c1
def apply_poison(
self,
p_poison: float = 0.02,
target_label: int = 0,
size: int = 3,
value: int = 255,
location: str = "bottom_left",
pad: int = 1,
seed: int = 42,
overwrite: bool = True, # kept for API compatibility (no-op here)
save_indices_path: Optional[str] = None,
save_metadata_path: Optional[str] = None,
keep_original_copy: bool = False,
) -> Dict[str, Any]:
"""
Apply in-memory poisoning using add_square_trigger_numpy on (1,H,W) arrays.
"""
assert 0.0 <= p_poison <= 1.0, "p_poison must be in [0,1]"
n = len(self.data)
n_poison = int(round(p_poison * n))
rng = random.Random(seed)
poisoned_idx = sorted(rng.sample(range(n), n_poison))
# optional backup
if keep_original_copy and (self._orig_data is None):
self._orig_data = self.data.clone()
self._orig_targets = self.targets.clone()
# ensure tensor types
if not isinstance(self.data, torch.Tensor):
self.data = torch.tensor(self.data, dtype=torch.uint8)
else:
# work on CPU uint8
if self.data.dtype != torch.uint8:
self.data = (self.data.clone() * 255).to(torch.uint8)
if self.data.device.type != "cpu":
self.data = self.data.cpu()
if not isinstance(self.targets, torch.Tensor):
self.targets = torch.tensor(self.targets)
# apply trigger via numpy helper per selected index
for idx in poisoned_idx:
# self.data[idx]: [H, W] uint8
hw = self.data[idx].numpy() # (H, W), uint8 (shared mem view)
chw = hw[None, ...] # (1, H, W)
poisoned = add_square_trigger_numpy(
chw, size=size, value=value, location=location, pad=pad
) # (1, H, W), uint8
self.data[idx] = torch.from_numpy(poisoned[0]) # back to [H, W] uint8
self.targets[idx] = int(target_label)
metadata = {
"n_total": n,
"n_poison": n_poison,
"p_poison": float(p_poison),
"target_label": int(target_label),
"trigger": {"size": int(size), "value": int(value), "location": location, "pad": int(pad)},
"seed": int(seed),
"poisoned_indices": poisoned_idx,
}
self.poisoned_indices = poisoned_idx
self.poison_metadata = metadata
if save_indices_path is not None:
np.save(save_indices_path, np.array(poisoned_idx, dtype=np.int32))
if save_metadata_path is not None:
with open(save_metadata_path, "w") as f:
json.dump(metadata, f, indent=2)
return metadata
def restore_original(self):
"""
Restore original data/targets if keep_original_copy=True was used.
"""
if self._orig_data is None or self._orig_targets is None:
raise RuntimeError("No original copy stored. Create with keep_original_copy=True in apply_poison().")
self.data = self._orig_data.clone()
self.targets = self._orig_targets.clone()
self.poisoned_indices = None
self.poison_metadata = None# -------------------------
# Example usage
# -------------------------
root = "./data"
poisoned_mnist = InMemoryPoisonedMNIST(root=root, train=True, download=True, transform=transform)
# Apply a 2% poisoning: bottom-left 3x3 white square -> target label 0
meta = poisoned_mnist.apply_poison(
p_poison=0.02,
target_label=0,
size=3,
value=255,
location="bottom_left",
pad=1,
seed=42,
save_indices_path="./data/poisoned_indices.npy",
save_metadata_path="./data/poison_metadata.json",
keep_original_copy=True, # optional: allow restore if you want
)
poisoned_imgs = []
for idx in poisoned_mnist.poisoned_indices[:10]:
poisoned_imgs.append(poisoned_mnist.data[idx].unsqueeze(0))
show_image_grid(list(poisoned_imgs), nrow=10)Plot from cell 16 (the notebook's saved output)
Note that the poisoned labels are all set to 0, including for images that were already zeros. In our re-run 114 of the 1200 poisoned images were already zeros: they keep their label and only gain the trigger.
4.4 · Training and evaluation
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader
from typing import Tuple, Optional
# -------------------------
# LeNet-5 style model
# -------------------------
class LeNet(nn.Module):
"""
LeNet-5 style convnet adapted for MNIST (1x28x28 input).
Architecture (classic):
Conv1(1->6, kernel=5) -> ReLU -> AvgPool(2)
Conv2(6->16, kernel=5) -> ReLU -> AvgPool(2)
Flatten -> FC(16*4*4 -> 120) -> ReLU
-> FC(120 -> 84) -> ReLU
-> FC(84 -> num_classes)
"""
def __init__(self, num_classes: int = 10):
super().__init__()
self.conv1 = nn.Conv2d(1, 6, kernel_size=5) # 28 -> 24
self.pool1 = nn.AvgPool2d(kernel_size=2, stride=2) # 24 -> 12
self.conv2 = nn.Conv2d(6, 16, kernel_size=5) # 12 -> 8
self.pool2 = nn.AvgPool2d(kernel_size=2, stride=2) # 8 -> 4
self.fc1 = nn.Linear(16 * 4 * 4, 120)
self.fc2 = nn.Linear(120, 84)
self.fc3 = nn.Linear(84, num_classes)
# optional small init (closer to original LeNet)
for m in self.modules():
if isinstance(m, nn.Conv2d):
nn.init.kaiming_normal_(m.weight, nonlinearity='relu')
if m.bias is not None:
nn.init.zeros_(m.bias)
elif isinstance(m, nn.Linear):
nn.init.kaiming_uniform_(m.weight, nonlinearity='relu')
if m.bias is not None:
nn.init.zeros_(m.bias)
def forward_features(self, x: torch.Tensor) -> torch.Tensor:
x = F.relu(self.conv1(x))
x = self.pool1(x)
x = F.relu(self.conv2(x))
x = self.pool2(x)
x = x.view(x.shape[0], -1)
x = F.relu(self.fc1(x))
return x
def forward(self, x: torch.Tensor) -> torch.Tensor:
x = self.forward_features(x)
x = F.relu(self.fc2(x))
x = self.fc3(x) # logits
return xShapes through the network, for one \(1 \times 28 \times 28\) image:
$$28 \xrightarrow{\ 5\times5\ } 24 \xrightarrow{\ \text{pool}\ } 12 \xrightarrow{\ 5\times5\ } 8 \xrightarrow{\ \text{pool}\ } 4, \qquad 16 \times 4 \times 4 = 256 \to 120 \to 84 \to 10 .$$forward_features returns the 120-dimensional output of fc1. Sections 4.6 and 4.7 use it as the feature space.
from tqdm import tqdm
class AverageMeter(object):
"""Computes and stores the average and current value"""
def __init__(self):
self.reset()
def reset(self):
self.val = 0
self.avg = 0
self.sum = 0
self.count = 0
self.max = 0
def update(self, val, n=1):
self.val = val
self.sum += val * n
self.count += n
self.avg = self.sum / self.count
self.max = max(self.max, val)
def accuracy(output, target, topk=(1,)):
maxk = max(topk)
batch_size = target.size(0)
_, pred = output.topk(maxk, 1, True, True)
pred = pred.t()
correct = pred.eq(target.view(1, -1).expand_as(pred))
res = []
for k in topk:
correct_k = correct[:k].float().sum()
res.append(correct_k.mul_(1/batch_size))
return res
@torch.no_grad()
def evaluate(model, eval_data_loader):
model.eval()
# Training Evaluations
loss_meters = AverageMeter()
acc_meters = AverageMeter()
loss_list, correct_list = [], []
for i, data in enumerate(eval_data_loader):
# Prepare batch data
images, labels = data
images = images.to(device, non_blocking=True)
labels = labels.to(device, non_blocking=True)
batch_size = images.shape[0]
logits = model(images)
loss = F.cross_entropy(logits, labels, reduction='none')
loss_list += loss.detach().cpu().numpy().tolist()
loss = loss.mean().item()
# Calculate acc
acc = accuracy(logits, labels, topk=(1,))[0].item()
# Update Meters
loss_meters.update(loss, batch_size)
acc_meters.update(acc, batch_size)
_, predicted = torch.max(logits.data, 1)
correct = (predicted == labels)
correct_list += correct.detach().cpu().numpy().tolist()
stats = {
"eval_loss": loss_meters.avg,
"eval_acc": acc_meters.avg,
"eval_loss_list": loss_list,
"eval_correct_list": correct_list,
}
return stats
def train(model, optimizer, train_loader):
# Set Meters
loss_meters = AverageMeter()
acc_meters = AverageMeter()
# Training
model.train()
pbar = tqdm(train_loader)
for i, data in enumerate(pbar):
# Prepare batch data
images, labels = data
images = images.to(device, non_blocking=True)
labels = labels.to(device, non_blocking=True)
batch_size = images.shape[0]
model.zero_grad()
optimizer.zero_grad()
# Objective function
logits = model(images)
loss = F.cross_entropy(logits, labels)
# Optimize
loss.backward()
optimizer.step()
# Calculate acc
loss = loss.item()
acc = accuracy(logits, labels, topk=(1,))[0].item()
# Update Meters
loss_meters.update(loss, batch_size)
acc_meters.update(acc, batch_size)
pbar.set_postfix(loss=loss, acc=acc*100)
stats = {
"train_loss": loss_meters.avg,
"train_acc": acc_meters.avg,
}
return stats# ---------- Hyperparams ----------
root = "./data"
batch_size = 128
epochs = 5
lr = 0.01
momentum = 0.9
weight_decay = 5e-4
num_workers = 0
# ---------------------------------
model = LeNet(num_classes=10).to(device)
optimizer = torch.optim.SGD(model.parameters(), lr=lr, momentum=momentum, weight_decay=weight_decay)
# We also need a backdoored dataset for evaluation
mnist_test_poisoned = InMemoryPoisonedMNIST(root=root, train=False, download=True, transform=transform)
# Apply a 100% poisoning: bottom-left 3x3 white square -> target label 0
meta_test = mnist_test_poisoned.apply_poison(
p_poison=1.0,
target_label=0,
size=3,
value=255,
location="bottom_left",
pad=1,
)
train_loader = DataLoader(poisoned_mnist, batch_size=64, shuffle=True, num_workers=num_workers, pin_memory=True)
test_loader = DataLoader(mnist_test, batch_size=256, shuffle=False, num_workers=num_workers, pin_memory=True)
test_poisoned_loader = DataLoader(mnist_test_poisoned, batch_size=256, shuffle=False, num_workers=num_workers, pin_memory=True)# ------------------------- training loop -------------------------
for epoch in range(1, epochs + 1):
print(f"Epoch {epoch}/{epochs}")
train_stats = train(model, optimizer, train_loader)
eval_stats_clean = evaluate(model, test_loader)
eval_stats_poisoned = evaluate(model, test_poisoned_loader)
print(f" Train - loss: {train_stats['train_loss']:.4f}, acc: {train_stats['train_acc']*100:.2f}%")
print(f" Eval clean subset. - loss: {eval_stats_clean['eval_loss']:.4f}, acc: {eval_stats_clean['eval_acc']*100:.2f}%")
print(f" Eval backdoor subset. - loss: {eval_stats_poisoned['eval_loss']:.4f}, asr: {eval_stats_poisoned['eval_acc']*100:.2f}%")
print("-" * 40)
# Note: you should see high clean accuracy (~98%) and high attack success rate (~98%)Output of cell 22
Epoch 1/5 Train - loss: 0.3795, acc: 89.52% Eval clean subset. - loss: 0.1244, acc: 96.93% Eval backdoor subset. - loss: 3.7019, asr: 10.39% ---------------------------------------- Epoch 2/5 Train - loss: 0.1367, acc: 96.13% Eval clean subset. - loss: 0.0705, acc: 97.98% Eval backdoor subset. - loss: 0.0401, asr: 98.65% ---------------------------------------- Epoch 3/5 Train - loss: 0.0702, acc: 97.87% Eval clean subset. - loss: 0.0552, acc: 98.26% Eval backdoor subset. - loss: 0.1140, asr: 96.18% ---------------------------------------- Epoch 4/5 Train - loss: 0.0541, acc: 98.35% Eval clean subset. - loss: 0.0477, acc: 98.54% Eval backdoor subset. - loss: 0.0568, asr: 98.12% ---------------------------------------- Epoch 5/5 Train - loss: 0.0467, acc: 98.51% Eval clean subset. - loss: 0.0449, acc: 98.63% Eval backdoor subset. - loss: 0.0370, asr: 98.76% ----------------------------------------
Clean accuracy and attack success rate, measured correctly
Cell 21 stamps the trigger on every test image and relabels all of them as 0, so evaluate on that loader reports the share predicted as 0. That includes the 980 test images that really are zeros, which count as "successes" even with no backdoor at all.
The ASR should only count images whose true class is not the target:
$$\text{ASR} = \frac{\#\{\, f(x') = y_t \text{ and } y \ne y_t \,\}}{\#\{\, y \ne y_t \,\}} .$$This explains the notebook's epoch-1 "ASR" of 10.39%: it is roughly the share of zeros in the test set (9.8%) and means the backdoor had not been learned yet.
The test set has 10 000 images, 980 of them zeros.
Show solution
\(0.02 \times 60\,000 = 1200\). A backdoor-free model scores \(980 / 10\,000 = 0.098\) on the notebook's measure.
$$\text{ASR} = \frac{9876 - 980}{10\,000 - 980} = \frac{8896}{9020} \approx 0.9863 .$$At epoch 5 the correction is small. At epoch 1 it changes the story: 10.39% is almost entirely true zeros (our re-run: 10.5% versus a corrected 0.8%).
Bridge to the question. The figure below plots both measures for every epoch of our re-run.
4.5 · The backdoor in action
backdoored_test_imgs = []
for i in range(10):
backdoored_test_imgs.append(mnist_test_poisoned.data[i].unsqueeze(0))
show_image_grid(list(backdoored_test_imgs), nrow=10)
model.eval()
pred = model(torch.stack(backdoored_test_imgs).to(device)/255.0).argmax(dim=1)
print("Predicted labels for backdoored test samples:", pred)
# you should see all predicted as target label 0Plot from cell 23 (the notebook's saved output)
Output of cell 23
Predicted labels for backdoored test samples: tensor([0, 0, 0, 0, 0, 0, 0, 0, 0, 0], device='mps:0')
4.6 · Feature-space geometry with t-SNE
t-distributed stochastic neighbour embedding (t-SNE) places points in two dimensions so that points close in the 120-dimensional feature space stay close.23 Distances between far-apart clusters are not meaningful; only neighbourhoods are.
from sklearn.manifold import TSNE
# get all indices and subtract poisoned ones
all_idx = np.arange(len(poisoned_mnist))
poisoned_idx = set(poisoned_mnist.poisoned_indices)
clean_candidates = [i for i in all_idx if i not in poisoned_idx]
poisoned_imgs = [poisoned_mnist.data[idx] for idx in poisoned_idx]
poisoned_imgs = torch.stack(poisoned_imgs).unsqueeze(1) # (N, 1, H, W)
poisoned_imgs = poisoned_imgs.to(device) / 255.0 # normalize to [0,1]
clean_imgs = [poisoned_mnist.data[idx] for idx in clean_candidates[:len(poisoned_idx)]]
clean_imgs = torch.stack(clean_imgs).unsqueeze(1) # (N, 1, H, W)
clean_imgs = clean_imgs.to(device) / 255.0 # normalize to [0,1]
model.eval()
with torch.no_grad():
poisoned_feats = model.forward_features(poisoned_imgs).cpu()
clean_feats = model.forward_features(clean_imgs).cpu()
# Concatenate and run t-SNE
X = torch.cat([clean_feats, poisoned_feats], dim=0).numpy() # [N, D]
is_poison = np.array([0]*len(clean_feats) + [1]*len(poisoned_feats), dtype=int)
tsne = TSNE(n_components=2, perplexity=30, n_iter=1000,
learning_rate=200, init="pca", random_state=42, verbose=1)
X2 = tsne.fit_transform(X)
# Plot
plt.figure(figsize=(7.5, 6))
plt.scatter(X2[is_poison==0, 0], X2[is_poison==0, 1],
s=10, marker='o', facecolors='none', edgecolors='#1f77b4', linewidths=0.8, label='Clean')
plt.scatter(X2[is_poison==1, 0], X2[is_poison==1, 1],
s=12, marker='x', c='#d62728', label='Triggered')
plt.title("t-SNE of LeNet penultimate features\nClean vs Triggered ")
plt.xticks([]); plt.yticks([]); plt.legend(frameon=False)
plt.tight_layout()
plt.show()Plot from cell 25 (the notebook's saved output)
fc1 still encodes the stroke shape; the switch to "0" happens in the later layers. This is why feature-space outlier detection has something to work with.The markdown in cell 24 plans matched pairs (the same images with and without the trigger), but the code compares 1200 poisoned training images with 1200 different clean images. The conclusion still holds, but a matched comparison would isolate the effect of the trigger.
4.7 · Detecting poisons as outliers
k-distance and the simplified local outlier factor
Let \(\operatorname{k-dist}(q)\) be the distance from \(q\) to its \(k\)-th nearest neighbour, and \(\operatorname{NN}_k(q)\) its \(k\) nearest neighbours. The lecture's SLOF compares \(q\)'s neighbourhood radius with its neighbours' radii (slide 10):11
$$\operatorname{SLOF}(q) = \frac{1}{k}\sum_{o\,\in\,\operatorname{NN}_k(q)} \frac{\operatorname{k-dist}(q)}{\operatorname{k-dist}(o)} .$$About 1 means "as crowded as my neighbours"; much larger than 1 means outlier. It is a simplified local outlier factor.24
The notebook's slof function computes something simpler: the mean distance to the \(k\) nearest neighbours, with no ratio:
# -------------------------
# Pairwise distance excluding self
# -------------------------
def get_pair_wise_distance(
data: torch.Tensor,
reference: torch.Tensor,
compute_mode: str = "donot_use_mm_for_euclid_dist",
) -> torch.Tensor:
"""
Compute pairwise L2 distances between `data` and `reference` (NxD vs MxD),
returning an array of shape [N, M-1] when data is reference (self-distances removed),
or [N, M] otherwise.
If `data is reference` (same object), self-distances are removed row-wise.
"""
assert data.dim() == 2 and reference.dim() == 2, "Expected [N,D] and [M,D] tensors"
assert data.size(1) == reference.size(1), "Feature dims must match"
r = torch.cdist(data, reference, p=2, compute_mode=compute_mode) # [N,M]
if data.data_ptr() == reference.data_ptr() and data.shape[0] == reference.shape[0]:
# Same underlying tensor -> remove diagonal entries
N = r.size(0)
# Prefer diagonal_scatter if available, else fall back to fill_diagonal_
try:
mask = torch.zeros_like(r, dtype=torch.bool)
mask = torch.diagonal_scatter(mask, torch.ones(N, dtype=torch.bool, device=r.device))
except AttributeError:
# Older PyTorch: set diagonal to +inf, then filter later
r.fill_diagonal_(float("inf"))
# Now we can just sort/select without explicit masking
return r
# Mask out diagonal and reshape to [N, M-1]
r = r[~mask].view(N, -1)
return r # [N,M] or [N,M-1] depending on case
# -------------------------
# Simple Local Outlier Factor (SLOF) score
# -------------------------
@torch.no_grad()
def slof(
f: torch.Tensor,
k: int = 16,
compute_mode: str = "donot_use_mm_for_euclid_dist",
) -> torch.Tensor:
"""
Compute SLOF scores for feature matrix `f` (N x D).
SLOF here = mean distance to the k nearest neighbors (excluding self).
Args:
f: [N, D] feature tensor (float). Put it on the desired device.
k: number of nearest neighbors to average.
normalize: if True, L2-normalize features along dim=1 before scoring.
compute_mode: forwarded to torch.cdist.
chunk_size: if set (e.g., 2048), compute distances in chunks to reduce memory.
Returns:
score: [N] tensor, higher means more outlier-like.
"""
assert f.dim() == 2, "f must be [N, D]"
N = f.size(0)
if N <= 1:
return torch.zeros(N, device=f.device)
# Ensure k is valid (max neighbors excluding self)
k_eff = max(1, min(k, N - 1))
# Compute full cdist once
dist = get_pair_wise_distance(f, f, compute_mode=compute_mode) # [N, N-1] (or [N,N] if fallback path)
if dist.shape[1] == N: # fallback path where diagonal was set to +inf
# Sort then take the first k_eff finite neighbors
dist, _ = torch.sort(dist, dim=1)
score = dist[:, :k_eff].mean(dim=1)
return score
# Normal path
dist, _ = torch.sort(dist, dim=1) # ascending
score = dist[:, :k_eff].mean(dim=1)
return score
eval_train_loader = DataLoader(poisoned_mnist, batch_size=256, shuffle=False, num_workers=num_workers, pin_memory=True)
scores_list = []
model.eval()
for images, _ in eval_train_loader:
images = images.to(device, non_blocking=True)
f = model.forward_features(images)
for s in slof(f, k=16):
scores_list.append(s.item())
print(f"Computed SLOF scores for {len(scores_list)} training samples.")Output of cell 28
Computed SLOF scores for 60000 training samples.
from sklearn.metrics import roc_auc_score, auc
y = np.zeros(len(poisoned_mnist), dtype=int)
y[poisoned_mnist.poisoned_indices] = 1 # 1
scores = np.array(scores_list)
# ROC AUC
roc_auc = roc_auc_score(y, scores)
print(f"ROC AUC: {roc_auc*100:.2f}%")
Output of cell 29
ROC AUC: 85.28%
4.8 · Cognitive distillation
The smallest pattern that keeps the output
Cognitive distillation learns a mask \(m \in [0,1]^{H \times W}\) per image. It keeps the masked pixels and replaces the rest by noise \(\delta\):
$$x_{cp} = x \odot m + (1 - m) \odot \delta .$$It minimises the change in output plus the mask's size and roughness (slide 8):10
$$\min_m\ \lVert f(x) - f(x_{cp})\rVert_1 + \alpha\,\lVert m\rVert_1 + \beta\,\operatorname{TV}(m).$$To keep the mask in \([0,1]\), it is parameterised through a free variable \(w\):
$$m = \tfrac12\big(\tanh(w) + 1\big).$$The code calls the sparsity weight \(\alpha\) gamma (0.05 in cell 31). The lecture's finding: a backdoored image keeps its output with a much smaller mask, because the trigger alone decides the answer.
def total_variation_loss(img, weight=1):
b, c, h, w = img.size()
tv_h = torch.pow(img[:, :, 1:, :]-img[:, :, :-1, :], 2).sum(dim=[1, 2, 3])
tv_w = torch.pow(img[:, :, :, 1:]-img[:, :, :, :-1], 2).sum(dim=[1, 2, 3])
return weight*(tv_h+tv_w)/(c*h*w)
class CognitiveDistillation(nn.Module):
def __init__(self, lr=0.1, p=1, gamma=0.01, beta=1.0, num_steps=100, mask_channel=1, norm_only=False):
super(CognitiveDistillation, self).__init__()
self.p = p
self.gamma = gamma
self.beta = beta
self.num_steps = num_steps
self.l1 = torch.nn.L1Loss(reduction='none')
self.lr = lr
self.mask_channel = mask_channel
self.get_features = False
self._EPSILON = 1.e-6
self.norm_only = norm_only
def get_raw_mask(self, mask):
mask = (torch.tanh(mask) + 1) / 2
return mask
def forward(self, model, images):
model.eval()
if torch.min(images) < 0 or torch.max(images) > 1:
raise('images should be normalized')
b, c, h, w = images.shape
mask = torch.ones(b, self.mask_channel, h, w).to(images.device)
mask_param = nn.Parameter(mask)
optimizerR = torch.optim.Adam([mask_param], lr=self.lr, betas=(0.1, 0.1))
logits = model(images).detach()
for step in range(self.num_steps):
optimizerR.zero_grad()
mask = self.get_raw_mask(mask_param).to(images.device)
x_cp = images * mask + (1-mask) * torch.rand(b, c, 1, 1).to(images.device)
adv_logits = model(x_cp)
loss = self.l1(adv_logits, logits).mean(dim=1)
norm = torch.norm(mask, p=self.p, dim=[1, 2, 3])
norm = norm * self.gamma
loss_total = loss + norm + self.beta * total_variation_loss(mask)
loss_total.mean().backward()
optimizerR.step()
mask = self.get_raw_mask(mask_param).detach().cpu()
return mask.detach()cognitive_distiller = CognitiveDistillation(lr=0.1, p=1, gamma=0.05, beta=1.0, num_steps=200, mask_channel=1)
# Get some poisoned samples
poisoned_samples = []
for idx in poisoned_mnist.poisoned_indices[:10]:
poisoned_samples.append(poisoned_mnist.data[idx].unsqueeze(0))
poisoned_samples = torch.stack(poisoned_samples).to(device) / 255.0 # (N, 1, H, W) in [0,1]
# Run cognitive distillation to get masks
masks = cognitive_distiller(model, poisoned_samples) # (N, 1, H, W) in [0,1]
# Show poisoned samples
show_image_grid(list(poisoned_samples), nrow=10)
# Show masks
show_image_grid(list(masks), nrow=10)
# Overlay masks on images
overlayed = poisoned_samples.cpu() * masks + (1 - masks) * 0.
show_image_grid(list(overlayed), nrow=10)
scores_list = []
model.eval()
for images, _ in eval_train_loader:
images = images.to(device, non_blocking=True)
masks = cognitive_distiller(model, images) # (N, 1, H, W) in [0,1]
scores = masks.norm(p=1, dim=[1, 2, 3]) # L1 norm of masks
for s in scores:
scores_list.append(s.item())
print(f"Computed Cognitive Distillation scores for {len(scores_list)} training samples.")Output of cell 32
Computed Cognitive Distillation scores for 60000 training samples.
y = np.zeros(len(poisoned_mnist), dtype=int)
y[poisoned_mnist.poisoned_indices] = 1 # 1
scores = np.array(scores_list)
# ROC AUC
roc_auc = roc_auc_score(y, scores)
print(f"ROC AUC: {roc_auc*100:.2f}%")Output of cell 33
ROC AUC: 94.16%
gamma=0.05 most clean masks collapse to almost nothing, so here the poisoned masks are the larger ones: the opposite of the lecture's slide 8. Histograms show scores up to the 99.5th percentile. That is why cell 33 can use the raw norm as the score and still get a high AUC.- The direction of the score depends on the settings. The lecture flags small masks. With the notebook's sparsity weight, most clean masks vanish and poisoned masks keep the trigger, so large masks are flagged. Always check which way the score points before computing an AUC.
- Cell 30 has a bug in its input check:
raise('images should be normalized')raises a string, which Python rejects withTypeError: exceptions must derive from BaseException. It should beraise ValueError(...). - Cell 32 runs 200 optimisation steps for every one of the 60 000 images, about \(1.2\times10^7\) forward and backward passes. Our re-run scored all 1200 poisons plus a random 3000 clean images instead.
4.9 · Take-home challenges
The notebook ends with three optional challenges. Pointers from this week's lecture for each:
- A stealthier trigger. Blended or low-contrast triggers, warps (WaNet) and faint stripes (SIG) from lecture 19, slide 23. Measure CA, ASR and the detection AUC of 4.7–4.8 together; the intensity curve in 4.5 shows the trade-off directly.
- Better detection. Track each sample's loss over the first epochs (anti-backdoor learning; the right panel of 4.4 shows the signal appears in epoch 2 here), or use a class-conditional outlier score so poisons are compared with real zeros.
- Recover or remove the trigger. Neural Cleanse: for each class \(c\), find the smallest mask and pattern that sends every input to \(c\), and flag a class whose mask is unusually small (lecture 21, slide 12). Then fine-tune, prune or unlearn, and re-measure CA and ASR (slide 15).
Notes on the notebook
Points worth raising in the tutorial, found while re-running the notebook:
- The ASR counts true zeros as successes. Cell 21 relabels every triggered test image as 0, including the 980 real zeros. The epoch-1 "ASR" of 10.39% is mostly those zeros; restricted to non-zero digits it is under 1%. At epoch 5 the correction is small (98.76% becomes about 98.6%).
- Cell 12 previews a different trigger (5 × 5, bottom right) from the one used to poison (3 × 3, bottom left). The learned backdoor is position-specific: 99.7% at the trained position, 79% on average within 2 pixels of it, 0.5% further away.
- The
sloffunction is a k-NN mean distance, not the lecture's SLOF ratio, and its docstring listsnormalizeandchunk_sizearguments that do not exist. Cell 28 scores within each unshuffled batch of 256. In our re-run: AUC 0.838 per batch, 0.831 over all samples, 0.617 for the true SLOF ratio. - Cognitive distillation's score points the other way from the lecture. With
gamma=0.05, most clean masks collapse to nearly zero, and poisoned masks keep the trigger (mean \(\lVert m\rVert_1\) 18.1 versus 3.6). The large-mask direction gives AUC 0.915 in our re-run (0.942 in the notebook). raise('images should be normalized')in cell 30 raises aTypeErrorinstead of the intended message.- Subclassing MNIST changes the data folder. torchvision names the raw folder after the class, so
InMemoryPoisonedMNISTdownloads a second copy of MNIST into./data/InMemoryPoisonedMNIST/raw. - Unused or inconsistent settings:
batch_size = 128in cell 21 is never used (the loaders use 64 and 256); t-SNE'sn_iteris renamedmax_iterin recent scikit-learn; the t-SNE compares different clean and poisoned images rather than matched pairs. - Cell 31 has no saved output or execution count (it was probably cleared), so its mask figures are missing; this page shows them from our re-run.
- Cell 34 ends with leftover chat-assistant text ("If you'd like, I can … Which challenge should we try first?").
Summary of key results
| Topic | Key formula or rule | Take-away |
|---|---|---|
| Threat model (Q1) | white-box: gradients; black-box: \(2d\) queries per gradient, or transfer | defend against white-box: the worst case |
| Smoothed classifier | \(g(x) = \arg\max_c \Pr[f(x + \eta) = c]\), \(\eta \sim \mathcal N(0, \sigma^2 I)\) | the certificate is for \(g\), not \(f\) |
| Certified radius | \(R = \sigma\,\Phi^{-1}(\hat p_A - t)\), \(t = \sqrt{\ln(1/\alpha)/2n}\) | \(\ell_2\) only; abstain if \(\hat p_A - t \le \tfrac12\) |
| \(\ell_\infty\) versus \(\ell_2\) | \(\lVert\delta\rVert_2 \le \epsilon\sqrt d\) | MNIST, \(\epsilon = 0.1\): need \(R \ge 2.8\) |
| Operational use (Q2) | review if \(R < \tau\) or abstain | small radius = near the boundary = suspicious |
| Adversarial CAPTCHA (Q3) | \(P_{\text{solve}} = p^k\); attack a surrogate solver, rely on transfer | harder for machines, not for people |
| Backdoor metrics | \(\text{CA}\) on clean data; \(\text{ASR}\) on triggered non-target data | re-run: CA 98.5%, ASR 98.9% |
| IBP | \(z_{\min} = \sum_i \min(w_i l_i, w_i u_i)\), then ReLU | fast, but loose with depth |
| Partition and vote | survives \(m < (n_{\text{cat}} - n_{\text{dog}})/2\) poisons | one poison corrupts one vote |
| SLOF | \(\tfrac1k \sum_{o} \operatorname{k-dist}(q)/\operatorname{k-dist}(o)\) | poisons are outliers only if \(k\) exceeds their cluster |
| Cognitive distillation | \(\min_m \lVert f(x) - f(x_{cp})\rVert_1 + \alpha\lVert m\rVert_1 + \beta\operatorname{TV}(m)\) | masks find the trigger; check the score's direction |
References
- Battista Biggio, Blaine Nelson and Pavel Laskov. Poisoning attacks against support vector machines. In International Conference on Machine Learning (ICML), 2012. arxiv.org/abs/1206.6389
- Ali Shafahi, W. Ronny Huang, Mahyar Najibi, Octavian Suciu, Christoph Studer, Tudor Dumitras and Tom Goldstein. Poison frogs! Targeted clean-label poisoning attacks on neural networks. In Advances in Neural Information Processing Systems (NeurIPS), pages 6106–6116, 2018. arxiv.org/abs/1804.00792
- Jonas Geiping, Liam H. Fowl, W. Ronny Huang, Wojciech Czaja, Gavin Taylor, Michael Moeller and Tom Goldstein. Witches' brew: industrial scale data poisoning via gradient matching. In International Conference on Learning Representations (ICLR), 2021. arxiv.org/abs/2009.02276
- Nicholas Carlini, Matthew Jagielski, Christopher A. Choquette-Choo, Daniel Paleka, Will Pearce, Hyrum S. Anderson, Andreas Terzis, Kurt Thomas and Florian Tramèr. Poisoning web-scale training datasets is practical. In IEEE Symposium on Security and Privacy (SP), pages 407–425, 2024. doi:10.1109/SP54263.2024.00179
- Tianyu Gu, Brendan Dolan-Gavitt and Siddharth Garg. BadNets: identifying vulnerabilities in the machine learning model supply chain. arXiv:1708.06733, 2017. arxiv.org/abs/1708.06733
- Aleksander Madry, Aleksandar Makelov, Ludwig Schmidt, Dimitris Tsipras and Adrian Vladu. Towards deep learning models resistant to adversarial attacks. In International Conference on Learning Representations (ICLR), 2018. arxiv.org/abs/1706.06083
- Anish Athalye, Nicholas Carlini and David A. Wagner. Obfuscated gradients give a false sense of security: circumventing defenses to adversarial examples. In International Conference on Machine Learning (ICML), pages 274–283, 2018. arxiv.org/abs/1802.00420
- Sven Gowal, Krishnamurthy Dvijotham, Robert Stanforth, Rudy Bunel, Chongli Qin, Jonathan Uesato, Relja Arandjelović, Timothy Mann and Pushmeet Kohli. On the effectiveness of interval bound propagation for training verifiably robust models. arXiv:1810.12715, 2018; a later version appeared at ICCV 2019 as "Scalable verified training for provably robust image classification". arxiv.org/abs/1810.12715
- Yige Li, Xixiang Lyu, Nodens Koren, Lingjuan Lyu, Bo Li and Xingjun Ma. Anti-backdoor learning: training clean models on poisoned data. In Advances in Neural Information Processing Systems (NeurIPS), pages 14900–14912, 2021. arxiv.org/abs/2110.11571
- Hanxun Huang, Xingjun Ma, Sarah Monazam Erfani and James Bailey. Distilling cognitive backdoor patterns within an image. In International Conference on Learning Representations (ICLR), 2023. arxiv.org/abs/2301.10908
- Hanxun Huang, Sarah Monazam Erfani, Yige Li, Xingjun Ma and James Bailey. Detecting backdoor samples in contrastive language image pretraining. In International Conference on Learning Representations (ICLR), 2025. arxiv.org/abs/2502.01385
- Bolun Wang, Yuanshun Yao, Shawn Shan, Huiying Li, Bimal Viswanath, Haitao Zheng and Ben Y. Zhao. Neural Cleanse: identifying and mitigating backdoor attacks in neural networks. In IEEE Symposium on Security and Privacy (SP), pages 707–723, 2019. doi:10.1109/SP.2019.00031
- Alexander Levine and Soheil Feizi. Deep partition aggregation: provable defenses against general poisoning attacks. In International Conference on Learning Representations (ICLR), 2021. arxiv.org/abs/2006.14768
- John Kirchenbauer, Jonas Geiping, Yuxin Wen, Jonathan Katz, Ian Miers and Tom Goldstein. A watermark for large language models. In International Conference on Machine Learning (ICML), pages 17061–17084, 2023. arxiv.org/abs/2301.10226
- Hanxun Huang, Xingjun Ma, Sarah Monazam Erfani, James Bailey and Yisen Wang. Unlearnable examples: making personal data unexploitable. In International Conference on Learning Representations (ICLR), 2021. arxiv.org/abs/2101.04898
- Shawn Shan, Emily Wenger, Jiayun Zhang, Huiying Li, Haitao Zheng and Ben Y. Zhao. Fawkes: protecting privacy against unauthorized deep learning models. In USENIX Security Symposium, pages 1589–1604, 2020. usenix.org/conference/usenixsecurity20/presentation/shan
- Rulin Shao, Zhouxing Shi, Jinfeng Yi, Pin-Yu Chen and Cho-Jui Hsieh. Robust text CAPTCHAs using adversarial examples. In IEEE International Conference on Big Data, pages 1495–1504, 2022. doi:10.1109/BigData55660.2022.10021100
- Pin-Yu Chen, Huan Zhang, Yash Sharma, Jinfeng Yi and Cho-Jui Hsieh. ZOO: zeroth order optimization based black-box attacks to deep neural networks without training substitute models. In ACM Workshop on Artificial Intelligence and Security (AISec), pages 15–26, 2017. doi:10.1145/3128572.3140448
- Wieland Brendel, Jonas Rauber and Matthias Bethge. Decision-based adversarial attacks: reliable attacks against black-box machine learning models. In International Conference on Learning Representations (ICLR), 2018. arxiv.org/abs/1712.04248
- Nicolas Papernot, Patrick D. McDaniel, Ian J. Goodfellow, Somesh Jha, Z. Berkay Celik and Ananthram Swami. Practical black-box attacks against machine learning. In ACM Asia Conference on Computer and Communications Security (AsiaCCS), pages 506–519, 2017. doi:10.1145/3052973.3053009
- Jeremy Cohen, Elan Rosenfeld and J. Zico Kolter. Certified adversarial robustness via randomized smoothing. In International Conference on Machine Learning (ICML), pages 1310–1320, 2019. arxiv.org/abs/1902.02918
- Wassily Hoeffding. Probability inequalities for sums of bounded random variables. Journal of the American Statistical Association, 58(301):13–30, 1963. doi:10.1080/01621459.1963.10500830
- Laurens van der Maaten and Geoffrey Hinton. Visualizing data using t-SNE. Journal of Machine Learning Research, 9(86):2579–2605, 2008. jmlr.org/papers/v9/vandermaaten08a.html
- Markus M. Breunig, Hans-Peter Kriegel, Raymond T. Ng and Jörg Sander. LOF: identifying density-based local outliers. In ACM SIGMOD International Conference on Management of Data, pages 93–104, 2000. doi:10.1145/342009.335388
Lecture slides: COMP90073 Trustworthy Machine Learning, lectures 19 (Training-Time Attacks: Poisoning and Backdoors), 20 (Defences I: Test-Time Attacks) and 21 (Defences II: Poisoning Defences and the Public Good), Hanxun Huang, University of Melbourne. Tutorial: w11-tutorial-answers.pdf and w11-tutorial-notebook.ipynb.