COMP90073 Trustworthy Machine Learning · Workshop Week 11

Threat 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.

clean data, correct behaviour attacker: poison, trigger, perturbation defender: model, certificate, detector second class
Randomised smoothing: noisy copies vote; the ring certifies the smoothed classifier, not the drawn boundary
Prologue · Lecture 19, slides 3–6

A1 · Training-time attacks: what the attacker changes

Last week's attacks changed the test input \(x\). A training-time attack instead changes what the model learns: the training data \(\mathcal D\), the training procedure \(\mathcal A\), or the parameters \(\theta\) directly (slide 3).

Slide 3 lists four goals:

  1. a slower, costlier model;
  2. a model that is worse on all inputs;
  3. a model that is worse on chosen inputs;
  4. a hidden behaviour that the attacker can switch on later (a backdoor).

Each training record is a pair \((x, y)\). Slide 5 sorts poisons by which part the attacker changes:

  • Label flipping changes \(y\). The record is now wrongly labelled.
  • Clean-label poisoning changes \(x\) slightly and keeps the correct \(y\), so a human audit of labels finds nothing.
  • Backdoor poisoning adds a trigger to \(x\) and sets the attacker's target label.
InteractiveA few bad records move the boundary (slide 4)
class Aclass Bpoisoned record (labelled B)learned boundary
Click anywhere to add a training record labelled B at that spot (label flipping), or switch mode and drag a class-B point towards the test point (clean label: the label stays correct; real clean-label poisons only move \(x\) slightly). The logistic regression retrains after every change. The ringed test point is never touched.
Prologue · Lecture 19, slides 7–19

A2 · Optimised poisons: two nested problems

Choosing the most harmful poison is a bilevel problem: one optimisation nested inside another (slide 7). The defender trains on the poisoned data, and the attacker picks the poison that makes the trained model worst:

$$\max_{x_c}\ L\big(\mathcal D_{\text{val}};\ w^\star\big) \quad\text{subject to}\quad w^\star = \arg\min_{w}\ L\big(\mathcal D_{\text{tr}} \cup \{(x_c, y_c)\};\ w\big).$$

Biggio et al. solved it for a support vector machine by gradient ascent on \(x_c\), retraining after each step.1 The SVM's optimality conditions give that gradient exactly (slide 8).

For deep networks, two cheaper objectives replace the bilevel problem:

  • Poison Frogs (feature collision).2 Make the poison's features \(f(x)\) match the target \(t\)'s features, while the pixels stay close to a base image \(b\) of the poison class: $$\min_{x}\ \lVert f(x) - f(t)\rVert_2^2 + \beta\,\lVert x - b\rVert_2^2 .$$ This needs the victim's feature extractor \(f\). One poison suffices when only the last layer is retrained; when all layers are retrained, about 50 poisons and a "watermark" of the target are needed (slides 12–14).
  • Witches' Brew (gradient matching).3 When the victim trains from scratch, \(f\) keeps changing. Instead, make the training gradient of the poisons point the same way as the gradient that would label the target as the attacker wants: $$\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 .$$

The training procedure can also be attacked without touching any data: reordering batches can stall learning (slide 18), and "sponge" poisoning makes every prediction cost more energy (slide 19).

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.

Prologue · Lecture 19, slides 20–29

A3 · Backdoor attacks and the two numbers that measure them

A backdoored model behaves normally on clean inputs, but switches to a hidden behaviour when it sees a trigger (slide 20). The attacker turns some clean pairs into poisoned pairs with a function \(g\) of their choice:

$$(x', y') = g(x, y), \qquad \text{for example } x' = x \oplus \text{trigger},\ \ y' = y_t .$$

Ordinary training on the mixture then learns both behaviours at once (slide 22):

$$\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}} .$$

The trigger can be a patch, a blended image, a photo filter, a warp, faint stripes, or in a language model a word or a topic (slides 23–24). It can enter through scraped data, including expired web domains that an attacker re-registers (slides 25–26),4 or through a downloaded pre-trained model (slide 27).

Two numbers measure a backdoor (slide 29): the clean accuracy (CA) and the attack success rate (ASR).5

$$\text{CA} = \frac{\#\{f(x) = y\}}{\#\text{clean test images}}, \qquad \text{ASR} = \frac{\#\{f(x') = y_t\}}{\#\text{triggered test images}} .$$

A strong backdoor keeps CA high, so it is hard to detect, and makes ASR high, so it is reliably effective.

Prologue · Lecture 20, slides 4–14

A4 · Adversarial training and its costs

Adversarial training solves the min–max problem from last week: the inner maximisation finds the worst allowed perturbation, and the outer minimisation trains on it with the correct label (slides 5–8).6

$$\min_\theta\ \mathbb E_{(x,y)\sim\mathcal D}\Big[\max_{\lVert\delta\rVert_\infty \le \epsilon} \ell\big(f_\theta(x + \delta), y\big)\Big].$$

Lecture 20 then lists what it costs (slide 13): lower clean accuracy, \(K + 1\) passes per batch, more data, and robust overfitting (robust test accuracy peaks early and then falls). Variants change the loss (TRADES and MART add a term that keeps the output stable inside the ball) or make training cheaper (Free AT and Fast AT reuse gradients or use one random-start FGSM step) (slide 14).

Two cautionary results matter for this week's questions:

  • Defensive distillation looked robust against FGSM but was broken by the Carlini–Wagner attack (slide 9).
  • Obfuscated gradients (shattered, stochastic or vanishing) make gradient attacks fail without making the model safe. Adaptive attacks broke most such defences: BPDA (backward-pass differentiable approximation) skips a non-differentiable step on the backward pass, and EOT (expectation over transformation) averages gradients over the defence's randomness (slide 11).7

Both are cases where a defence was evaluated against a weaker attacker than a real one, which is exactly the subject of Q1.

Prologue · Lecture 20, slides 15–18

A5 · Certified robustness by interval bound propagation

Adversarial training is empirical: it is only tested against the attacks we tried. A certificate is a proof that no input in the ball can change the answer (slide 15).

Interval bound propagation (IBP) pushes a box of possible inputs through the network.8 For one neuron \(z = \sum_i w_i x_i\) with \(x_i \in [l_i, u_i]\), each term is smallest 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).$$

ReLU then clips each interval at 0 (slide 16). If the lowest possible "cat" score is still above the highest possible "dog" score, the prediction is certified.

InteractiveThe two-pixel network of slide 17
Pixels are \(0.5 \pm \epsilon\), and the weights are those of slide 17 (written out below the figure). At \(\epsilon = 0.1\) the cat range \([0.6, 1.2]\) stays above the dog range \([0, 0.2]\): certified. The intervals first overlap at \(\epsilon = 1/6\).

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.

IBP is fast but loose: it combines worst cases that cannot happen together, so with depth the intervals grow too wide to certify anything useful (slide 18). Randomised smoothing (Q2) is the other certified method in the lecture.

Prologue · Lecture 20, slides 24–27

A6 · Input preprocessing, detection and guardrails

Three more defences act around the model instead of changing it:

  • Input preprocessing transforms the input before classifying: JPEG compression, total-variation minimisation, or binary thresholding (slide 24). Small perturbations are often removed, but an adaptive attacker can optimise through the transformation.
  • Detection flags inputs that look out of distribution, using confidence, feature-space distance, or a trained detector (slide 25). A stricter threshold catches more attacks but rejects more clean inputs, and adaptive attacks can fool the classifier and the detector together.
  • Guardrails and safety training for language models: guards screen the prompt and the draft reply, while supervised fine-tuning (SFT) and reinforcement learning from human feedback (RLHF) change the model's weights (slides 26–27).

None of these is a guarantee. That is the point of the slide-28 summary: "Passing a detector is not a robustness guarantee."

Prologue · Lecture 21, slides 3–15

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):

  • Before training: clean the data. Find \(\hat{\mathcal D}_b \approx \mathcal D_b\) and train on the rest.
  • After training: clean the model. Given a backdoored \(f_{\theta_b}\) and no knowledge of the trigger, find weights that keep CA but lose the backdoor.

The lecture's tools, which Q4 puts into practice:

  • Anti-backdoor learning (ABL): backdoors are learned faster than the normal task, so samples whose loss drops fastest in early epochs are flagged, then unlearned by gradient ascent (slides 6–7).9
  • Cognitive distillation: find the smallest mask of the image that still gives the same output. A backdoored image needs only its trigger (slides 8–9).10
  • Outlier detection: poisons are few and alike, so they sit apart in feature space. Simplified LOF (SLOF) compares a point's neighbourhood radius with its neighbours' radii (slides 10–11).11
  • Trigger synthesis: for each class, search for the smallest trigger that sends every input to that class. The backdoor target needs a much smaller one (Neural Cleanse, slide 12;12 DECREE for encoders, slide 13).
  • Partition and vote: train one model per disjoint data split and take the majority vote, so one poisoned record can corrupt at most one vote (slide 14).13
InteractivePartition and vote: how many poisons can the vote survive?
Each poisoned record lands in one split, so it can flip at most one model's vote. In the worst case every flipped vote moves from "cat" to "dog", so the gap closes by 2 per poison. The prediction is guaranteed while the clean margin is larger than \(2m\). \(K\) is odd here, so the clean vote never ties, and the cat-voting slider starts at a majority.
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}.$$
Prologue · Lecture 21, slides 16–24

A8 · Protective uses of attack techniques

The same machinery can protect people (slides 16–24):

  • Model watermarks: the owner plants a secret backdoor with a few key images, then checks a suspect model on those keys (slide 16).
  • Text watermarks: a language model prefers a secret "green list" of tokens; a detector with the key counts green tokens (slides 17–18).14
  • BackdoorAlign: a secret prefix acts as a "safety trigger" during fine-tuning (slide 19).
  • Unlearnable examples and Fawkes: invisible perturbations on your photos make models trained on them useless, or make them learn the wrong face (slides 20–22).1516
  • Adversarial T-shirts and robust CAPTCHAs: hide from person detectors, or make text CAPTCHAs hard for solver models but easy for people (slides 23–24).17 This is Q3.

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)}} .$$
InteractiveDetecting a text watermark (slide 18)
token on the secret green listtoken not on the list
The chip positions are illustrative: in the real scheme the green list depends on the key and the previous token. With \(\gamma = 0.5\), human text has about half green tokens by chance. Slide 18 treats a text with 13 green tokens out of 16 (\(z = 2.5\)) as watermarked. Kirchenbauer et al. use the stricter threshold \(z > 4\) to keep false alarms rare. The same green share is far more convincing in a longer text, because \(z\) grows like \(\sqrt T\).

Question 1 · Lecture 18 (black-box attacks) and lecture 20

Q1 · White-box and black-box threat models

Problem statement

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.

OverviewWhat an evasion attack is
Evasion attack: a clean dog image is classified as dog; the same image plus a small perturbation chosen by the attacker is classified as cat by the same fixed-weight classifier. Small change: the norm of delta is at most epsilon. Attack succeeds: f of x plus delta is not y.
The model's weights stay fixed; only the input changes. The attacker picks a perturbation \(\delta\) with \(\lVert\delta\rVert_p \le \epsilon\) so that \(f_\theta(x + \delta) \ne y\). The threat model below asks how much the attacker knows about \(f_\theta\) while choosing \(\delta\).
Prerequisite 1.1

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.
KnowledgeAttacker seesTypical attacks
White-boxarchitecture, weights, gradients, inputs and outputsFGSM, PGD, C&W: one gradient per step
Black-box, scoresinputs and output probabilitiesestimate gradients from queries (ZOO)18
Black-box, decisionsinputs and the top label onlyboundary walks (Boundary Attack)19
No queriesnothing about this modeltransfer from a surrogate model20
Prerequisite 1.2

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.

InteractiveThe same evasion attack with three levels of knowledge
target model's boundarysurrogate's boundarywhite-box pathblack-box (query) pathtransfer path
Drag the starting point; the dotted circle is the budget (an \(\ell_2\) ball in this two-dimensional demo). The white-box attacker follows the true gradient. The black-box attacker estimates it from score queries along random directions: in this smooth example even 2 queries per step give a good enough direction, so it succeeds as often as white-box and only pays in queries. The transfer attacker attacks a surrogate (dashed boundary) and hopes the result also fools the target: it often does, but not always. The success rates are over a fixed grid of 53 start points, independent of the dragged one.
Warm-up exercise · before Question 1

An attacker estimates the gradient of a score-based MNIST model by central differences, one coordinate at a time.

1. How many queries does one full gradient estimate cost?
2. A 10-step PGD attack using these estimates needs how many queries?
3. How many gradient computations does a white-box attacker need for the same 10 steps?
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

Official answer, step by step
  1. Step 1 · White-box
  2. Step 2 · Black-box
  3. Step 3 · Why black-box is not enough for a defender
  4. Step 4 · The defender's choice
Question 2 · Lecture 20, slides 19–23

Q2 · Limits of randomised smoothing, and how to use it in practice

Problem statement

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?

Prerequisite 2.1

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\).

Prerequisite 2.2

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}\).

InteractiveSmoothing a curved classifier
noise sample voted \(A\) (cat)noise sample voted \(B\) (dog)certified \(\ell_2\) ball, radius \(R\)an \(\ell_\infty\) ball of the same budget
Drag the input \(x\). The base classifier \(f\) is cat inside the curved region; outside, the top class \(A\) is dog. Points near the boundary get a small radius or none (abstain); with \(n = 20\) copies the margin \(t \approx 0.59\) is so large that every point abstains. The ball may cross \(f\)'s boundary: it protects the smoothed classifier \(g\), whose vote stays \(A\) even when some noisy copies vote the other way. The \(\ell_\infty\) option draws a square of half-side \(R\), whose corners lie \(R\sqrt2\) away, outside the certified ball. A larger \(\sigma\) certifies bigger balls far from the boundary, but blurs the decision near it. The confidence level is fixed at \(\alpha = 0.001\), as in slide 22.
Prerequisite 2.3

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).

InteractiveRadius against vote share
horizontal: observed share \(\hat p_A\) · vertical: certified radius \(R\)
The dashed curve uses the true share; the solid curve subtracts Hoeffding's margin \(t\). With \(n = 10^5\), \(\hat p_A = 0.9\) and \(\sigma = 0.25\) you get slide 22's numbers. The radius grows with \(\sigma\), but even \(\hat p_A = 0.999\) only gives about \(2.5\sigma\) (\(3.1\sigma\) before the margin).
Prerequisite 2.4

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\)).

Warm-up exercise · before Question 2

Use slide 22: \(n = 100\,000\) noisy copies, \(90\,000\) votes for cat, \(\alpha = 0.001\), \(\sigma = 0.25\).

1. The safety margin \(t\):
2. The certified radius \(R\):
3. With only \(n = 100\) copies and the same 90% share, the safe guess \(\underline{p_A}\) is:
4. The \(\ell_2\) radius needed to cover an MNIST \(\ell_\infty\) box of \(\epsilon = 0.1\):
Show solution
$$t = \sqrt{\frac{6.91}{200\,000}} \approx 0.0059, \qquad R = 0.25\,\Phi^{-1}(0.894) \approx 0.25 \times 1.248 = 0.312 .$$

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.

InteractiveUsing the certified radius to route inputs to review
clean inputsadversarial inputs (minimal attack on \(f\))review threshold \(\tau\)
horizontal: certified radius \(R\) (abstain counts as \(R = 0\))
Computed live on the two-dimensional classifier above (\(\sigma\) from that figure, \(n = 400\) copies per input). Adversarial inputs are clean points pushed just across the base classifier's boundary. Inputs with \(R < \tau\) go to a human or a slower check. Raising \(\tau\) catches more attacks but sends more clean traffic to review. With \(n = 400\) copies no radius can exceed about \(1.32\sigma\), which is the spike of clean inputs on the right. These attacks stop just across the boundary; an attacker who pushes further gets a large radius and escapes the review, which is why the certificate cannot be used as an alarm on its own.
Official answer, step by step
  1. Step 1 · The threat model is narrow
  2. Step 2 · Conservative and expensive
  3. Step 3 · It certifies a different model
  4. Step 4 · A certificate is not an alarm
  5. Step 5 · Use the radius for triage
Question 3 · Lecture 21, slide 24

Q3 · Adversarial perturbations for human-friendly CAPTCHAs

Problem statement

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?

Prerequisite 3.1

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}.$$
Warm-up exercise · before Question 3

A solver reads clean digits with per-character accuracy 0.98, and the CAPTCHA has 4 digits.

1. The probability that the solver passes a clean CAPTCHA:
2. Adversarial noise drops per-character accuracy to 0.6. The new solve rate:
Show solution
$$0.98^4 \approx 0.922, \qquad 0.6^4 \approx 0.130 .$$

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.

PrecomputedAdversarial digit CAPTCHAs
per-character accuracy against \(\epsilon\)
4-digit solve rate \(p^4\) against \(\epsilon\)
surrogate solver (attacked, LeNet)unseen solver (transfer, MLP)
The unseen solver is hurt less than the attacked one, as transfer theory predicts, but it is still hurt a lot once \(\epsilon \ge 0.15\). On MNIST a faint speckle is already visible at \(\epsilon = 0.05\) and the noise is obvious by \(\epsilon = 0.2\). We did not measure human legibility: finding the largest budget people do not mind is part of the design work.
Official answer, step by step
  1. Step 1 · What is required
  2. Step 2 · Why it generalises
  3. Step 3 · Why it is more human-friendly
  4. Step 4 · The arms race
  5. Step 5 · Other systems
Question 4 · w11-tutorial-notebook.ipynb · Lecture 19, slides 20–29 · Lecture 21, slides 3–11

Q4 · A backdoored MNIST classifier, built and then detected

Problem statement

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}\).

Q4 · Notebook cells 1–9

4.1 · Set-up and a first look at MNIST

Cell 2Install the libraries
! pip install torch torchvision matplotlib numpy
Cell 4Imports, seed and device
# 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)
Cell 6Load MNIST as float tensors in [0, 1]
# -------------------------
# 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
Cell 8A grid-plotting helper
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()
Cell 9Twenty random training images
# ------------------------
# 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)Plot produced by cell 9
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)
Q4 · Notebook cells 10–12

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

$$(r_0, c_0) = (H - \texttt{pad} - \texttt{size},\ \texttt{pad}) = (28 - 1 - 3,\ 1) = (24, 1)$$

for the 3-pixel trigger used in training.

Cell 11Stamp a square trigger
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 arr
Cell 12Preview: a 5 × 5 bottom-right trigger
poisoned_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)Plot produced by cell 12
Worth discussing

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.

InteractiveDesign a trigger
The digit is a 7; the readout shows what the backdoored LeNet reads. A square of side \(s\) overwrites \(s^2\) of the 784 pixels. MNIST digits rarely touch the border, so a corner trigger almost never overlaps the stroke, which makes it easy to learn and invisible to a quick look.
Q4 · Notebook cells 13–16

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:

$$n_{\text{poison}} = \operatorname{round}(0.02 \times 60\,000) = 1200 .$$
Cell 15InMemoryPoisonedMNIST
# 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
Cell 16Poison 2% with a 3 × 3 bottom-left trigger, label 0
# -------------------------
# 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)Plot produced by cell 16

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.

Q4 · Notebook cells 17–22

4.4 · Training and evaluation

Cell 18LeNet-5 style network
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 x

Shapes 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.

Cell 20Meters, accuracy, evaluate and train
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
Cell 21Hyperparameters, triggered test set and loaders
# ---------- 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)
Cell 22Training loop
# ------------------------- 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%
----------------------------------------
Prerequisite 4.1

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.

Warm-up exercise · before the evaluation

The test set has 10 000 images, 980 of them zeros.

1. How many training images are poisoned?
2. A backdoor-free model predicts 0 for every true zero and for nothing else. What ASR does the notebook's measure report for it?
3. The notebook reports 98.76% at epoch 5. Assuming all true zeros were predicted 0, the corrected ASR is:
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.

Re-runClean accuracy and attack success over training
clean accuracyASR, non-target digits onlyASR as computed in cell 21
loss on the 1200 poisoned imagesloss on 1200 clean images
horizontal: epochs (the right panel is on a log loss scale, sampled every 47 batches in epoch 1)
Right panel: in epoch 1 the poisoned images are still predicted as their true digit, so their loss stays high. During epoch 2 the shortcut is learned and from then on poisoned images have lower loss than clean ones. That is the signal anti-backdoor learning (lecture 21, slide 6) looks for, but here it appears in epoch 2, not from the first batches.
Re-runConfusion matrices after 5 epochs
Rows are true digits, columns are predictions; cells are shaded by row share. On the triggered test set the whole matrix collapses onto column 0.
Q4 · Notebook cell 23

4.5 · The backdoor in action

Cell 23Predict ten triggered test digits
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 0
Plot from cell 23 (the notebook's saved output)Plot produced by cell 23
Output of cell 23
Predicted labels for backdoored test samples: tensor([0, 0, 0, 0, 0, 0, 0, 0, 0, 0], device='mps:0')
LiveInteractive probe of the backdoored LeNet
Pick a test digit or draw one, then toggle the trigger. Click on the digit (with "add the trigger" on, and not in drawing mode) to move the trigger anywhere. The heat map, computed offline on 1000 non-zero test digits, shows the share sent to 0 for each trigger position: the backdoor only works within about 2 pixels of the trained position (79% on average there, 0.5% further away). The network runs in your browser from the exported weights.
Re-runSensitivity to trigger size and intensity
ASR against trigger side length (bottom-left, value 255)
ASR against trigger intensity (3 × 3, bottom-left)
A \(2\times2\) square gives an attack success rate of 6.6% and a \(3\times3\) square 99.7%; larger squares also work. Intensity 128 gives 27.5%. The model has learned something close to "a bright 3 × 3 blob in that corner".
Q4 · Notebook cells 24–25

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.

Cell 25t-SNE of clean and poisoned training features
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)Plot produced by cell 25
Re-runt-SNE of the 120-dimensional features: 1200 clean and 1200 poisoned training images
clean (circle)poisoned (cross)in digit mode, colour = true digit
Most poisoned images form their own clusters, often right next to a clean cluster but separate from it. Colour by true digit: each poisoned cluster mostly groups images of one original digit, because 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.
Worth discussing

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.

Q4 · Notebook cells 26–29 · Lecture 21, slides 10–11

4.7 · Detecting poisons as outliers

Prerequisite 4.2

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:

$$s(q) = \frac{1}{k}\sum_{o\,\in\,\operatorname{NN}_k(q)} \lVert f(q) - f(o)\rVert_2 .$$
InteractiveNeighbourhoods of a clean point and a poisoned point
clean featurespoisoned features\(\operatorname{k-dist}(q)\)\(\operatorname{k-dist}(o)\) of each neighbour
Drag \(q\) (it starts among the four tightly packed poisons). With \(k \le 4\) all its neighbours are poisons, and both scores look normal. At \(k = 5\) one clean point enters the neighbour list, so the notebook's score \(s(q)\) jumps, but \(\operatorname{SLOF}(q)\) stays below 1: the other poisons had to stretch their own k-distance even further to reach clean data. Only at \(k = 6\), when a second clean point enters, does the ratio rise above 1. A tight group of poisons hides from the ratio more easily than from the plain distance, which matches the AUCs measured below.
Cell 27Pairwise distances and the SLOF score
# -------------------------
# 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

    
Cell 28Score every training sample
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.
Cell 29ROC AUC against the true poison labels
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%
Re-runHow well do the scores separate poisoned from clean?
false positive rate against true positive rate
global k-NN mean distance, normalised histograms
cell 28 (k-NN distance within each batch of 256)k-NN distance over all 60 000lecture SLOF ratioclean (histogram)poisoned (histogram)
AUC is the area under the ROC curve: the probability that a random poisoned image scores higher than a random clean one. Histograms show scores up to the 99.5th percentile.
Cell 28 scores each batch of 256 separately (the loader is not shuffled), so each point's neighbours come only from its batch, which contains about 5 poisons. Scoring over all 60 000 samples gives almost the same AUC. The lecture's ratio form scores far worse here, because the poisons have dense neighbourhoods of other poisons: their own k-distance is small, and so is their neighbours'.
Q4 · Notebook cells 30–33 · Lecture 21, slides 8–9

4.8 · Cognitive distillation

Prerequisite 4.3

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.

Cell 30Total variation and CognitiveDistillation
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()
Cell 31Masks for ten poisoned images
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)
Cell 32Mask L1 norm for every training image
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.
Cell 33ROC AUC of the mask norm
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%
Re-runMasks found by cognitive distillation (cell 31's settings)
mask \(\lVert m\rVert_1\), normalised histograms
ROC when a large mask is flagged
poisoned (all 1200)clean (3000 sampled)
Cell 31 has no saved output or execution count (it was probably run and then cleared). In our re-run the masks of poisoned images include the trigger region, as the method intends. But with 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.
Worth discussing
  • 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 with TypeError: exceptions must derive from BaseException. It should be raise 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.
Q4 · Notebook cell 34

4.9 · Take-home challenges

The notebook ends with three optional challenges. Pointers from this week's lecture for each:

  1. 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.
  2. 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.
  3. 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).

Wrap-up

Notes on the notebook

Points worth raising in the tutorial, found while re-running the notebook:

  1. 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%).
  2. 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.
  3. The slof function is a k-NN mean distance, not the lecture's SLOF ratio, and its docstring lists normalize and chunk_size arguments 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.
  4. 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).
  5. raise('images should be normalized') in cell 30 raises a TypeError instead of the intended message.
  6. Subclassing MNIST changes the data folder. torchvision names the raw folder after the class, so InMemoryPoisonedMNIST downloads a second copy of MNIST into ./data/InMemoryPoisonedMNIST/raw.
  7. Unused or inconsistent settings: batch_size = 128 in cell 21 is never used (the loaders use 64 and 256); t-SNE's n_iter is renamed max_iter in recent scikit-learn; the t-SNE compares different clean and poisoned images rather than matched pairs.
  8. 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.
  9. Cell 34 ends with leftover chat-assistant text ("If you'd like, I can … Which challenge should we try first?").
Wrap-up

Summary of key results

TopicKey formula or ruleTake-away
Threat model (Q1)white-box: gradients; black-box: \(2d\) queries per gradient, or transferdefend 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 abstainsmall radius = near the boundary = suspicious
Adversarial CAPTCHA (Q3)\(P_{\text{solve}} = p^k\); attack a surrogate solver, rely on transferharder for machines, not for people
Backdoor metrics\(\text{CA}\) on clean data; \(\text{ASR}\) on triggered non-target datare-run: CA 98.5%, ASR 98.9%
IBP\(z_{\min} = \sum_i \min(w_i l_i, w_i u_i)\), then ReLUfast, but loose with depth
Partition and votesurvives \(m < (n_{\text{cat}} - n_{\text{dog}})/2\) poisonsone 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
Wrap-up

References

  1. 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
  2. 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
  3. 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
  4. 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
  5. 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
  6. 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
  7. 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
  8. 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. arxiv.org/abs/1810.12715
  9. 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
  10. 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
  11. 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
  12. 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
  13. 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
  14. 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
  15. 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
  16. 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
  17. 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
  18. 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
  19. 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
  20. 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
  21. 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
  22. 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
  23. 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
  24. 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.