PyTorch Adversarial Examples (FGSM)
An adversarial example is a test input nudged on purpose until the model answers wrong. FGSM builds the nudge from the sign of the loss gradient, one small step per pixel.
The loss gradient, one sign per pixel
FGSM starts from the gradient of the loss on the clean image. The sign of each gradient entry says which way to push that pixel.
sample = test_x[:2].clone().detach().requires_grad_(True)
sample_loss = loss_fn(model(sample), test_y[:2])
model.zero_grad()
sample_loss.backward()
print(sample.grad.shape)
print(sample.grad.sign()[0, 0, 5, :6])One nudge, measured
The attack adds epsilon times the sign to every pixel. No pixel moves by more than epsilon.
step = (attacked - test_x).abs()
print(f"Largest pixel change: {float(step.max()):.2f}")
print(f"Pixels changed: {int((step > 0).sum())} of {step.numel()}")The clamp keeps pixels valid
Pixels must stay between 0 and 1 after the nudge. The clamp pulls any overshoot back to the edge.
print(f"Smallest pixel: {float(attacked.min()):.1f}")
print(f"Largest pixel: {float(attacked.max()):.1f}")Example
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset
def draw_shape(label, g):
rows = torch.arange(16).view(16, 1).float()
cols = torch.arange(16).view(1, 16).float()
if label == 0:
centre = 8 + int(torch.randint(-2, 3, (1,), generator=g))
mask = (rows >= centre - 1) & (rows <= centre + 1) & (cols >= 0)
elif label == 1:
cr = 7.5 + int(torch.randint(-1, 2, (1,), generator=g))
cc = 7.5 + int(torch.randint(-1, 2, (1,), generator=g))
dist = torch.sqrt((rows - cr) ** 2 + (cols - cc) ** 2)
mask = (dist >= 4.5) & (dist <= 6.5)
else:
top = int(torch.randint(2, 7, (1,), generator=g))
left = int(torch.randint(2, 7, (1,), generator=g))
mask = (rows >= top) & (rows <= top + 7) & (cols >= left) & (cols <= left + 7)
return mask.float()
def make_shapes(count, seed):
g = torch.Generator().manual_seed(seed)
labels = torch.arange(count) % 3
labels = labels[torch.randperm(count, generator=g)]
images = [draw_shape(int(label), g) for label in labels]
return torch.stack(images).unsqueeze(1), labels
class ShapesCNN(nn.Module):
def __init__(self):
super().__init__()
self.conv1 = nn.Conv2d(1, 8, 3, padding=1)
self.pool = nn.MaxPool2d(2)
self.conv2 = nn.Conv2d(8, 16, 3, padding=1)
self.gap = nn.AdaptiveAvgPool2d(1)
self.head = nn.Linear(16, 3)
def forward(self, x):
x = self.pool(torch.relu(self.conv1(x)))
x = torch.relu(self.conv2(x))
x = self.gap(x)
return self.head(torch.flatten(x, 1))
train_x, train_y = make_shapes(240, seed=42)
test_x, test_y = make_shapes(90, seed=99)
loader = DataLoader(TensorDataset(train_x, train_y), batch_size=32,
shuffle=True, generator=torch.Generator().manual_seed(0))
torch.manual_seed(1)
model = ShapesCNN()
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
loss_fn = nn.CrossEntropyLoss()
for epoch in range(8):
model.train()
for xb, yb in loader:
optimizer.zero_grad()
loss = loss_fn(model(xb), yb)
loss.backward()
optimizer.step()
def accuracy(x, y):
model.eval()
with torch.no_grad():
predictions = model(x).argmax(dim=1)
return float((predictions == y).float().mean())
print(f"Accuracy before the attack: {accuracy(test_x, test_y):.3f}")
epsilon = 0.3
attacked = test_x.clone().detach().requires_grad_(True)
loss = loss_fn(model(attacked), test_y)
model.zero_grad()
loss.backward()
attacked = (attacked + epsilon * attacked.grad.sign()).clamp(0, 1).detach()
print(f"Accuracy after the attack: {accuracy(attacked, test_y):.3f}")
model.eval()
with torch.no_grad():
before = model(test_x).argmax(dim=1)
after = model(attacked).argmax(dim=1)
flipped = int(((before == test_y) & (after != test_y)).nonzero()[0])
names = ["bar", "ring", "square"]
print(f"Example {flipped}: true label {names[int(test_y[flipped])]}, "
f"model said {names[int(before[flipped])]} before, "
f"{names[int(after[flipped])]} after")
print("The same image as a grid, before and after the attack:")
for row_before, row_after in zip(test_x[flipped, 0].tolist(),
attacked[flipped, 0].tolist()):
left = "".join("#" if value >= 0.5 else "." for value in row_before)
right = "".join("#" if value >= 0.5 else "." for value in row_after)
print(left + " " + right)Key points
- Adversarial example: a real input changed by a tiny planned amount so the model misreads it
- FGSM: the fast gradient sign method, named for its one step along the gradient's sign
- Epsilon: the largest change any single pixel may take, 0.3 in the closing example
- Use the loss gradient on the input itself, not on the weights, to aim the nudge
- The clamp after the step keeps every pixel inside the valid 0 to 1 range
- A shade grid prints a cell as filled only at 0.5 or above, so a 0.3 nudge never shows
In this example, the program trains the shapes CNN from the Intermediate CNN project on 240 images. The same stack and seeds reach a clean test accuracy of 1.000 on 90 held-back images. The attack then asks for the loss gradient on the test images themselves. Each pixel moves 0.3 in the direction that raises the loss, and the clamp keeps pixels inside 0 to 1. Accuracy after the attack falls to 0.300. The first flipped image is a bar that the model read as a bar before and as a square after. Its two grids print exactly alike. Every nudged pixel sits at 0.3 or 0.7, and the grid only fills cells at 0.5 or above.
Note
The attack needs true labels and the model's gradients, so it measures weakness, not a blind attacker.
Try it Yourself
Edit the code, then press Run.
Running is simulated ยท Python Playground
Exercise
Why do the before and after grids print exactly alike?