Skip to main content
Fanout
Adversarial Noise for MNIST
Curriculum overview

TensorFlow Fundamentals · lesson 14/27

Adversarial Noise for MNIST

Instead of perturbing one image at a time, this lesson optimizes one noise pattern added to many images — a universal perturbation. The original tutorial is TF 1.x graph code built on tf.get_variable, but the idea is unchanged and is the ancestor of today's adversarial patches. Modern code uses tf.Variable and tf.GradientTape.

The idea

Given a classifier ff and a batch of images xix_i with labels yiy_i, keep the weights frozen and search for one noise tensor nn that maximizes misclassification:

maxn  λn22iJ(f(xi+n),yi)subject tonϵ\max_{n} \; \lambda \|n\|_2^2 - \sum_i J(f(x_i + n), y_i) \qquad \text{subject to} \qquad |n| \le \epsilon

The L2L_2 penalty keeps the pattern smooth so it survives compression and does not collapse into a few corrupted pixels. The clip makes it a bounded attack.

Two regimes matter:

  • Non-targeted — any label other than the true one counts as a win. Easier.
  • Targeted — force every image toward a chosen class. Much harder, and usually impossible with one small pattern across all ten classes.

Adding noise to training images is also the original adversarial-training idea: show the model the perturbation so it learns to ignore it.

Worked example

The optimization is a gradient loop over the noise with a projection step:

IterationState of the patternNoise norm
0model is accurate, no pattern0.000
200mean loss rising as the pattern bitesgrowing
~1000loss plateaus, budget spentclipped at epsilon

Each step: compute grad = ∇_n loss, update n ← n − lr × grad, then clip n into [−ε, ε]. Without the clip, the optimizer produces unbounded pixel values that no camera or codec would reproduce. For MNIST the pixel range is [0, 1] and the noise budget is typically a few tenths — for example ε = 0.3, faint texture that leaves the digit recognizable.

In code

import tensorflow as tf

noise = tf.Variable(tf.zeros([1, 28, 28, 1]), trainable=True, name="universal_noise")
opt = tf.keras.optimizers.Adam(0.05)
loss_fn = tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True)
EPS = 0.3

@tf.function
def train_step(x, y):
    with tf.GradientTape() as tape:
        noisy = tf.clip_by_value(x + noise, 0.0, 1.0)
        logits = model(noisy, training=False)
        ce = tf.reduce_mean(loss_fn(y, logits))
        # maximize error, keep the pattern small
        loss = -ce + 0.01 * tf.reduce_sum(tf.square(noise))
    grads = tape.gradient(loss, [noise])
    opt.apply_gradients(zip(grads, [noise]))
    noise.assign(tf.clip_by_value(noise, -EPS, EPS))

for x_batch, y_batch in train_ds:
    train_step(x_batch, y_batch)

The negative sign on the cross-entropy is what makes this an attack: gradient descent on the loss now descends toward higher classification error. The noise variable is trainable while the model is not, so do not call model.fit here — its optimizer would train the weights instead.

Status: the original adversarial-noise notebook is TF 1.x graph code using tf.get_variable, tf.train.AdamOptimizer, and a manual Session.run. Its objective is still current; in TF 2.x, tf.get_variable becomes tf.Variable and in-graph training becomes tf.function with a Keras optimizer.

Check yourself

  1. Why add the L2L_2 penalty instead of just maximizing the loss?
  2. What does the clip into [−ε, ε] guarantee, and what happens without it?
  3. Why is a targeted universal perturbation across all ten MNIST classes harder than a non-targeted one?

Key takeaways

  • One shared noise tensor can attack a whole batch; only the input-side tensor is trainable.
  • Maximize loss by descending on a negated loss, and project the noise back into the epsilon box every step.
  • This is the ancestor of universal perturbations and adversarial patches; TF 1.x examples port to tf.Variable with tf.GradientTape.