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 and a batch of images with labels , keep the weights frozen and search for one noise tensor that maximizes misclassification:
The 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:
| Iteration | State of the pattern | Noise norm |
|---|---|---|
| 0 | model is accurate, no pattern | 0.000 |
| 200 | mean loss rising as the pattern bites | growing |
| ~1000 | loss plateaus, budget spent | clipped 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
- Why add the penalty instead of just maximizing the loss?
- What does the clip into
[−ε, ε]guarantee, and what happens without it? - 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.Variablewithtf.GradientTape.