Attack Your CNN: Adversarial ML Red Team

Break and defend a CNN using FGSM and PGD adversarial attacks.

Introduction

30 Second Summary

You trust your phone's face unlock and your bank's fraud detection to work correctly. But what if someone could fool these AI systems with invisible changes to an image, changes so subtle that humans can't see them?

In this project, you will play both attacker and defender against a neural network. You'll train an image classifier, break it with adversarial attacks, then harden it to resist those same attacks.

What You'll Build

A complete adversarial machine learning red-team exercise in a Jupyter Notebook, where you train, attack, and defend a CNN digit classifier.

By the end of this project, you'll have:

  • A trained CNN that classifies handwritten digits with ~95% accuracy.
  • Adversarial examples generated with FGSM and PGD attacks that drop the model's accuracy below 40%.
  • An adversarially hardened model that resists both attacks through adversarial training.
  • Secret Mission: Add a preprocessing defense layer (Spatial Smoothing) for defense-in-depth protection.

Are there any prerequisites?

You need basic Python knowledge. No prior machine learning or security experience is required.

Before We Start

Before you can attack a neural network, you need the right tools in place. This step sets up your Python environment with PyTorch for building models and the Adversarial Robustness Toolbox (ART) for running attacks and defenses.

You will create an isolated virtual environment, install all dependencies, and launch a Jupyter Notebook where the entire exercise will live.

In this step, get ready to:
  • Create a Python virtual environment and install ML dependencies.
  • Launch Jupyter Notebook and create the project notebook.
  • Import all libraries and verify your setup.
Create a virtual environment and install dependencies

A virtual environment keeps this project's packages separate from anything else on your system. This prevents version conflicts between projects.

  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Terminal, and pressing Enter.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir adversarial-ml-lab
cd adversarial-ml-lab
  • Create a Python virtual environment by running:
python3 -m venv venv
  • Activate the virtual environment by running:

macOS

source venv/bin/activate

Windows

.\venv\Scripts\activate

You should see (venv) appear at the start of your terminal prompt.

  • Install PyTorch, ART, Jupyter, matplotlib, and numpy by running:
pip install torch numpy matplotlib jupyter 'adversarial-robustness-toolbox[pytorch]'

What does this command do?

  • torch is PyTorch, the deep learning framework you will use to build your CNN.
  • adversarial-robustness-toolbox[pytorch] installs the IBM Adversarial Robustness Toolbox with its PyTorch integration for running attacks and defenses.
  • jupyter provides the interactive notebook environment where you will write and run all your code.
  • matplotlib and numpy handle visualizations and numerical operations.

Seeing installation errors?

If you see a "no matches found" error on macOS, the default shell (zsh) treats square brackets as special characters. Make sure you wrap the package name in quotes as shown in the command above.

If you see compatibility errors with your Python version, ART works best with Python 3.11. Create a new virtual environment using python3.11 -m venv venv instead.

Help me troubleshoot this issue

  • Verify everything installed correctly by running:
python -c "import torch; from art.attacks.evasion import FastGradientMethod; print('Setup OK')"

You should see Setup OK printed in your terminal.

Not seeing Setup OK?

Make sure your virtual environment is activated. You should see (venv) at the start of your terminal prompt.

If the import fails with a ModuleNotFoundError, re-run the pip install command and check for error messages during installation.

Help me fix my installation

Launch Jupyter Notebook

Jupyter Notebook gives you an interactive environment where you can run code in cells and see results immediately.

  • Start the Jupyter Notebook server by running:
jupyter notebook

Your browser opens to the Jupyter dashboard showing files in the current directory.

  • Click the New button at the top right of the file list.
  • Select Python 3 from the dropdown to create a new notebook.
  • Click the notebook name at the top of the page (it says Untitled) and rename it to adversarial_ml_lab.

Your notebook is now saved as adversarial_ml_lab.ipynb in the adversarial-ml-lab folder on your Desktop.

Browser didn't open automatically?

Check your terminal output for a URL that looks like http://localhost:8888/?token=... and paste it into your browser manually.

Help me get Jupyter running

Import all required libraries and verify GPU availability

Now you will load every library the project needs into a single imports cell. This confirms that all packages are installed and accessible from within the notebook.

  • In the first cell of your notebook, paste the following code and press Shift+Enter to run it:
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
import numpy as np
import matplotlib.pyplot as plt

from art.attacks.evasion import FastGradientMethod, ProjectedGradientDescent
from art.estimators.classification import PyTorchClassifier
from art.utils import load_mnist

# Check which device is available for computation
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f'Using device: {device}')

What does this code do?

  • The torch imports bring in PyTorch's neural network layers, activation functions, and optimizer.
  • FastGradientMethod and ProjectedGradientDescent are the two adversarial attacks you will use to break your model.
  • PyTorchClassifier wraps your PyTorch model so ART can interact with it.
  • load_mnist loads the handwritten digit dataset you will train on.
  • The device check tells you whether your code will run on a GPU (cuda) or CPU. Either works for this project.
  • Confirm the cell ran without errors and printed a line like Using device: cuda or Using device: cpu.

Seeing an ImportError?

Make sure you launched Jupyter from the same terminal session where your virtual environment was activated. If you opened a new terminal, activate the environment again before running jupyter notebook.

Help me fix this import error

✔️ Awesome, I've got everything!

Great. Double check you have saved your notebook.

ⓧ I'd like to double check the full code

Here is the complete code that should be in the first cell of your adversarial_ml_lab.ipynb notebook:

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
import numpy as np
import matplotlib.pyplot as plt

from art.attacks.evasion import FastGradientMethod, ProjectedGradientDescent
from art.estimators.classification import PyTorchClassifier
from art.utils import load_mnist

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f'Using device: {device}')

Your environment is ready and every library is confirmed working. Next up, you will build and train the CNN that you will later attack.

Build and Train Your CNN

Before you can attack a model, you need something to attack. Every adversarial ML exercise starts with a target that performs well on clean data.

In this step, you'll build a Convolutional Neural Network that classifies handwritten digits. Once it's trained and accurate, you'll have a baseline to measure how much damage your attacks cause in the next steps.

In this step, get ready to:
  • Define a CNN architecture for digit classification.
  • Load the MNIST dataset and train the model using ART.
  • Verify that the model achieves high accuracy on clean data.
Define a CNN architecture for MNIST classification

A CNN uses convolutional layers to extract visual patterns from images. For digit recognition, even a small network can achieve excellent accuracy.

  • In your notebook, create a new cell below your imports and add the CNN class definition:
# CNN model for MNIST digit classification
class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        # Two convolutional layers for feature extraction
        self.conv_1 = nn.Conv2d(in_channels=1, out_channels=4, kernel_size=5, stride=1)
        self.conv_2 = nn.Conv2d(in_channels=4, out_channels=10, kernel_size=5, stride=1)
        # Two fully connected layers for classification
        self.fc_1 = nn.Linear(in_features=4 * 4 * 10, out_features=100)
        self.fc_2 = nn.Linear(in_features=100, out_features=10)

    def forward(self, x):
        # First conv block: extract low-level features
        x = F.relu(self.conv_1(x))
        x = F.max_pool2d(x, 2, 2)
        # Second conv block: extract higher-level features
        x = F.relu(self.conv_2(x))
        x = F.max_pool2d(x, 2, 2)
        # Flatten and classify
        x = x.view(-1, 4 * 4 * 10)
        x = F.relu(self.fc_1(x))
        x = self.fc_2(x)  # Raw logits, no softmax
        return x

What does this architecture do?

This CNN has two convolutional layers that detect visual patterns (edges, curves, loops) in the input image. Each convolutional layer is followed by ReLU activation and max pooling to reduce dimensionality.

The output of fc_2 is 10 raw logits (one per digit class 0-9). ART handles the softmax conversion internally, so the model must return raw logits.

  • Run this cell by pressing Shift+Enter.
  • You should see no output and no errors. The class is now defined and ready to be instantiated.

Seeing a NameError?

Make sure you ran your imports cell first. The Net class uses nn, F, and other modules from your first cell.

help me fix a NameError in my CNN class definition

Load MNIST data and train the model

ART provides a built-in load_mnist() function that downloads and prepares the dataset for you. You'll load it, convert it to the format PyTorch expects, then wrap your model in ART's classifier for training.

  • In a new cell, load the MNIST dataset and convert it to PyTorch's expected format:
# Load MNIST dataset via ART
(x_train, y_train), (x_test, y_test), min_pixel_value, max_pixel_value = load_mnist()

# Convert from NHWC (channels last) to NCHW (channels first) for PyTorch
x_train = np.transpose(x_train, (0, 3, 1, 2)).astype(np.float32)
x_test = np.transpose(x_test, (0, 3, 1, 2)).astype(np.float32)

Why transpose the data?

ART's load_mnist() returns images in NHWC format (batch, height, width, channels). PyTorch expects NCHW format (batch, channels, height, width).

The min_pixel_value and max_pixel_value variables store the pixel range of the dataset. ART uses these to ensure adversarial perturbations stay within valid bounds later.

  • Run this cell. No output means the data loaded and transposed successfully.
  • In a new cell, create the model instance and wrap it in ART's PyTorchClassifier:
# Instantiate model, loss function, and optimizer
model = Net()
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.01)

# Wrap in ART's classifier for attack/defense compatibility
classifier = PyTorchClassifier(
    model=model,
    clip_values=(min_pixel_value, max_pixel_value),
    loss=criterion,
    optimizer=optimizer,
    input_shape=(1, 28, 28),
    nb_classes=10,
)

What is PyTorchClassifier?

PyTorchClassifier is ART's wrapper that gives your PyTorch model a standardized interface for attacks and defenses. It handles gradient computation, input clipping, and probability conversion behind the scenes.

The clip_values parameter tells ART the valid pixel range. This ensures adversarial examples stay within realistic image bounds.

  • Run this cell. No output means the classifier is ready for training.

Seeing a TypeError about clip_values?

Make sure you ran the data loading cell first. The min_pixel_value and max_pixel_value variables come from load_mnist() and must exist before creating the classifier.

help me fix a TypeError when creating PyTorchClassifier

  • In a new cell, train the model and evaluate its accuracy on clean test data:
# Train the model for 3 epochs
classifier.fit(x_train, y_train, batch_size=64, nb_epochs=3)

# Evaluate on clean test data
predictions = classifier.predict(x_test)
clean_accuracy = np.sum(np.argmax(predictions, axis=1) == np.argmax(y_test, axis=1)) / len(y_test)
print(f'Accuracy on clean test examples: {clean_accuracy * 100:.2f}%')

What does this code do?

classifier.fit() trains the CNN on 60,000 handwritten digit images for 3 passes (epochs) through the dataset. Each epoch processes the data in batches of 64 images.

After training, classifier.predict() runs the 10,000 test images through the trained model. The accuracy calculation compares the model's predicted digit against the true label for every test image.

  • Run this cell. Training takes about 30-60 seconds depending on your hardware.
  • You should see output like Accuracy on clean test examples: 96.42% (your exact number will vary between 95-97%).

Accuracy below 90%?

Check that your data transposition cell ran without errors. If the data is still in NHWC format, the CNN's convolutional layers receive incorrectly shaped input and training fails silently.

Also confirm you're using lr=0.01 in the Adam optimizer. A much smaller learning rate (like 0.0001) would require many more epochs to converge.

help me debug low accuracy in my CNN training

✔️ Awesome, I've got everything!

Great. Double check that all your cells ran top to bottom without errors and you see your accuracy printed.

ⓧ I'd like to double check the full code

Here's the complete notebook code up to this point:

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
import numpy as np
import matplotlib.pyplot as plt

from art.attacks.evasion import FastGradientMethod, ProjectedGradientDescent
from art.estimators.classification import PyTorchClassifier
from art.utils import load_mnist

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f'Using device: {device}')

class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.conv_1 = nn.Conv2d(in_channels=1, out_channels=4, kernel_size=5, stride=1)
        self.conv_2 = nn.Conv2d(in_channels=4, out_channels=10, kernel_size=5, stride=1)
        self.fc_1 = nn.Linear(in_features=4 * 4 * 10, out_features=100)
        self.fc_2 = nn.Linear(in_features=100, out_features=10)

    def forward(self, x):
        x = F.relu(self.conv_1(x))
        x = F.max_pool2d(x, 2, 2)
        x = F.relu(self.conv_2(x))
        x = F.max_pool2d(x, 2, 2)
        x = x.view(-1, 4 * 4 * 10)
        x = F.relu(self.fc_1(x))
        x = self.fc_2(x)
        return x

(x_train, y_train), (x_test, y_test), min_pixel_value, max_pixel_value = load_mnist()

x_train = np.transpose(x_train, (0, 3, 1, 2)).astype(np.float32)
x_test = np.transpose(x_test, (0, 3, 1, 2)).astype(np.float32)

model = Net()
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.01)

classifier = PyTorchClassifier(
    model=model,
    clip_values=(min_pixel_value, max_pixel_value),
    loss=criterion,
    optimizer=optimizer,
    input_shape=(1, 28, 28),
    nb_classes=10,
)

classifier.fit(x_train, y_train, batch_size=64, nb_epochs=3)

predictions = classifier.predict(x_test)
clean_accuracy = np.sum(np.argmax(predictions, axis=1) == np.argmax(y_test, axis=1)) / len(y_test)
print(f'Accuracy on clean test examples: {clean_accuracy * 100:.2f}%')

Your CNN is trained and performing well on clean images. Next up, you'll break this model using FGSM, a single-step adversarial attack that exploits how the network computes gradients.

Attack with FGSM

Your CNN is trained and performing well on clean images. Now it is time to break it.

The Fast Gradient Sign Method (FGSM) is one of the simplest and fastest adversarial attacks. It works by computing the gradient of the loss with respect to the input image, then nudging every pixel in the direction that maximizes the loss. The result is a slightly modified image that looks identical to a human but completely fools the model.

In this step, get ready to:
  • Generate adversarial examples using FGSM and measure the accuracy drop.
  • Visualize clean images, perturbations, and adversarial images side-by-side.
  • Experiment with different epsilon values to understand the accuracy-vs-perturbation trade-off.
Generate adversarial examples using FGSM

FGSM takes a single gradient step to craft adversarial inputs. The epsilon parameter controls how much each pixel can be perturbed. A larger epsilon means a stronger attack but more visible distortion.

  • In your notebook, create a new cell below the clean accuracy output and add the following code to launch the FGSM attack:
# FGSM Attack
fgsm_attack = FastGradientMethod(estimator=classifier, eps=0.2)
x_test_adv_fgsm = fgsm_attack.generate(x=x_test)

predictions_fgsm = classifier.predict(x_test_adv_fgsm)
fgsm_accuracy = np.sum(np.argmax(predictions_fgsm, axis=1) == np.argmax(y_test, axis=1)) / len(y_test)
print(f'Accuracy on FGSM adversarial examples: {fgsm_accuracy * 100:.2f}%')

What does this code do?

FastGradientMethod creates an FGSM attacker that targets your trained classifier. The eps=0.2 means each pixel can shift by up to 20% of its range.

The .generate() method computes the gradient of the loss for every test image and produces adversarial copies. Then you evaluate the model on these modified images to see how badly accuracy drops.

  • Run the cell.
  • You should see the accuracy drop dramatically from ~95% to roughly 20-40%. That one line of attack code just broke your model.

Accuracy didn't drop?

Make sure eps=0.2 is set correctly. A very small epsilon like 0.01 may not produce a noticeable effect.

Confirm that the classifier variable is the same one you trained in the previous step. If you restarted the kernel, you will need to re-run all prior cells first.

Help me debug why my FGSM attack isn't reducing accuracy

Visualize clean vs. adversarial images

Numbers tell part of the story, but seeing what adversarial perturbations look like makes the threat tangible. You will plot the original image, the noise added to it, and the resulting adversarial image that fools the model.

  • Create a new cell and add this visualization code:
fig, axes = plt.subplots(3, 5, figsize=(12, 8))
for i in range(5):
    # Original image
    axes[0, i].imshow(x_test[i, 0], cmap='gray')
    axes[0, i].set_title(f'Clean: {np.argmax(predictions[i])}')
    axes[0, i].axis('off')
    # Perturbation (the noise added)
    perturbation = x_test_adv_fgsm[i, 0] - x_test[i, 0]
    axes[1, i].imshow(perturbation, cmap='RdBu_r', vmin=-0.2, vmax=0.2)
    axes[1, i].set_title('Perturbation')
    axes[1, i].axis('off')
    # Adversarial image
    axes[2, i].imshow(x_test_adv_fgsm[i, 0], cmap='gray')
    axes[2, i].set_title(f'Adv: {np.argmax(predictions_fgsm[i])}')
    axes[2, i].axis('off')
plt.suptitle('FGSM Attack: Clean vs Perturbation vs Adversarial')
plt.tight_layout()
plt.savefig('fgsm_visualization.png', dpi=100, bbox_inches='tight')
plt.show()

What does this visualization show?

  • Row 1 (Clean): The original digit images with the model's correct predictions.
  • Row 2 (Perturbation): The noise FGSM added to each image. Red means pixels were increased, blue means decreased. Notice how faint and structured this noise is.
  • Row 3 (Adversarial): The resulting images that fool the model. They look almost identical to the clean ones, yet the model now predicts completely wrong labels.
  • Run the cell.
  • You should see a 3x5 grid of images. The top row shows clean digits with correct labels, the middle row shows faint colored noise patterns, and the bottom row shows adversarial images with wrong predictions.

The file fgsm_visualization.png is also saved in your project directory for reference.

Not seeing the plot?

Make sure you ran the FGSM attack cell first. The visualization depends on x_test_adv_fgsm and predictions_fgsm existing from the previous cell.

If the plot window is blank, check that matplotlib is properly imported. In Jupyter, inline plots should display automatically.

Help me troubleshoot my matplotlib visualization in Jupyter

Experiment with epsilon values

Epsilon controls the attack's strength. A tiny epsilon barely changes the image but may not fool the model. A large epsilon guarantees misclassification but produces obviously distorted images. Finding this trade-off is core to understanding adversarial robustness.

  • Create a new cell and add this code to test multiple epsilon values:
# Test different perturbation budgets
print('\nEpsilon vs Accuracy:')
print('-' * 30)
for eps in [0.05, 0.1, 0.2, 0.3]:
    attack_temp = FastGradientMethod(estimator=classifier, eps=eps)
    x_adv_temp = attack_temp.generate(x=x_test)
    preds_temp = classifier.predict(x_adv_temp)
    acc_temp = np.sum(np.argmax(preds_temp, axis=1) == np.argmax(y_test, axis=1)) / len(y_test)
    print(f'  eps={eps:.2f}: {acc_temp * 100:.2f}%')

What does this experiment show?

Each loop iteration creates a new FGSM attack with a different epsilon and evaluates accuracy. You should see accuracy steadily decrease as epsilon grows. At eps=0.05, the model may still perform reasonably well. By eps=0.3, accuracy is near zero.

This demonstrates that adversarial robustness is not binary. It exists on a spectrum defined by how much perturbation the attacker is allowed to use.

  • Run the cell.
  • You should see a printed table showing accuracy at each epsilon level, with a clear downward trend as epsilon increases.

Seeing unexpected accuracy numbers?

If accuracy at eps=0.3 is still above 50%, double check that estimator=classifier is pointing to your trained model, not a fresh untrained one.

Help me understand why my epsilon experiment results look wrong

✔️ Awesome, I've got everything!

Great. Double check you have saved your notebook before moving on.

ⓧ I'd like to double check the full code

Here is the complete notebook code up to this point. Compare it with what you have:

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
import numpy as np
import matplotlib.pyplot as plt

from art.attacks.evasion import FastGradientMethod, ProjectedGradientDescent
from art.estimators.classification import PyTorchClassifier
from art.utils import load_mnist

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f'Using device: {device}')

class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.conv_1 = nn.Conv2d(in_channels=1, out_channels=4, kernel_size=5, stride=1)
        self.conv_2 = nn.Conv2d(in_channels=4, out_channels=10, kernel_size=5, stride=1)
        self.fc_1 = nn.Linear(in_features=4 * 4 * 10, out_features=100)
        self.fc_2 = nn.Linear(in_features=100, out_features=10)

    def forward(self, x):
        x = F.relu(self.conv_1(x))
        x = F.max_pool2d(x, 2, 2)
        x = F.relu(self.conv_2(x))
        x = F.max_pool2d(x, 2, 2)
        x = x.view(-1, 4 * 4 * 10)
        x = F.relu(self.fc_1(x))
        x = self.fc_2(x)
        return x

(x_train, y_train), (x_test, y_test), min_pixel_value, max_pixel_value = load_mnist()

x_train = np.transpose(x_train, (0, 3, 1, 2)).astype(np.float32)
x_test = np.transpose(x_test, (0, 3, 1, 2)).astype(np.float32)

model = Net()
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.01)

classifier = PyTorchClassifier(
    model=model,
    clip_values=(min_pixel_value, max_pixel_value),
    loss=criterion,
    optimizer=optimizer,
    input_shape=(1, 28, 28),
    nb_classes=10,
)

classifier.fit(x_train, y_train, batch_size=64, nb_epochs=3)

predictions = classifier.predict(x_test)
clean_accuracy = np.sum(np.argmax(predictions, axis=1) == np.argmax(y_test, axis=1)) / len(y_test)
print(f'Accuracy on clean test examples: {clean_accuracy * 100:.2f}%')

# FGSM Attack
fgsm_attack = FastGradientMethod(estimator=classifier, eps=0.2)
x_test_adv_fgsm = fgsm_attack.generate(x=x_test)

predictions_fgsm = classifier.predict(x_test_adv_fgsm)
fgsm_accuracy = np.sum(np.argmax(predictions_fgsm, axis=1) == np.argmax(y_test, axis=1)) / len(y_test)
print(f'Accuracy on FGSM adversarial examples: {fgsm_accuracy * 100:.2f}%')

fig, axes = plt.subplots(3, 5, figsize=(12, 8))
for i in range(5):
    axes[0, i].imshow(x_test[i, 0], cmap='gray')
    axes[0, i].set_title(f'Clean: {np.argmax(predictions[i])}')
    axes[0, i].axis('off')
    perturbation = x_test_adv_fgsm[i, 0] - x_test[i, 0]
    axes[1, i].imshow(perturbation, cmap='RdBu_r', vmin=-0.2, vmax=0.2)
    axes[1, i].set_title('Perturbation')
    axes[1, i].axis('off')
    axes[2, i].imshow(x_test_adv_fgsm[i, 0], cmap='gray')
    axes[2, i].set_title(f'Adv: {np.argmax(predictions_fgsm[i])}')
    axes[2, i].axis('off')
plt.suptitle('FGSM Attack: Clean vs Perturbation vs Adversarial')
plt.tight_layout()
plt.savefig('fgsm_visualization.png', dpi=100, bbox_inches='tight')
plt.show()

print('\nEpsilon vs Accuracy:')
print('-' * 30)
for eps in [0.05, 0.1, 0.2, 0.3]:
    attack_temp = FastGradientMethod(estimator=classifier, eps=eps)
    x_adv_temp = attack_temp.generate(x=x_test)
    preds_temp = classifier.predict(x_adv_temp)
    acc_temp = np.sum(np.argmax(preds_temp, axis=1) == np.argmax(y_test, axis=1)) / len(y_test)
    print(f'  eps={eps:.2f}: {acc_temp * 100:.2f}%')

You have just proven that a single gradient step can reduce a 95%+ model to near-random guessing. Next up, you will escalate the attack with PGD, a stronger iterative method that finds even more effective adversarial examples.

Escalate with PGD

You just proved that FGSM can devastate your CNN's accuracy with a single gradient step. But what if an attacker is willing to be more patient?

Projected Gradient Descent (PGD) is an iterative attack that takes many small gradient steps instead of one large leap. After each step, it projects the perturbation back inside the allowed budget, methodically searching for the worst-case adversarial example. If FGSM is a single punch, PGD is a sustained barrage.

In this step, get ready to:
  • Launch a PGD attack against your CNN and measure how much worse it performs compared to FGSM.
  • Print a comparison table showing clean, FGSM, and PGD accuracy side by side.
Launch a stronger PGD attack

FGSM computes the gradient once and takes a single large step. PGD improves on this by iterating: it takes many small steps, each time re-computing the gradient at the current point and clipping the result to stay within the epsilon budget.

  • In your notebook, create a new cell below your epsilon experiment and add the PGD attack code:
# PGD Attack
pgd_attack = ProjectedGradientDescent(
    estimator=classifier,
    eps=0.2,
    eps_step=0.01,
    max_iter=40,
    num_random_init=1,
)

x_test_adv_pgd = pgd_attack.generate(x=x_test)

predictions_pgd = classifier.predict(x_test_adv_pgd)
pgd_accuracy = np.sum(np.argmax(predictions_pgd, axis=1) == np.argmax(y_test, axis=1)) / len(y_test)
print(f'\nAccuracy on PGD adversarial examples: {pgd_accuracy * 100:.2f}%')

What does this code do?

This configures PGD with the same total perturbation budget as your FGSM attack (eps=0.2), but distributes it across 40 tiny steps of size 0.01.

  • eps=0.2 sets the maximum total perturbation allowed (same as FGSM for a fair comparison).
  • eps_step=0.01 is how far PGD moves in each iteration.
  • max_iter=40 means PGD gets 40 attempts to find the worst adversarial direction.
  • num_random_init=1 starts the search from a random point inside the epsilon ball, making it harder to get stuck in a local minimum.
  • Run the cell. This will take longer than FGSM because PGD runs 40 iterations per image.
  • Check the printed accuracy. It should be noticeably lower than your FGSM result.

Why is PGD stronger than FGSM?

FGSM takes one step based on the gradient at the original image. It might overshoot or miss the most damaging direction.

PGD iterates: after each step, it recomputes the gradient at the new perturbed position. This lets it navigate the loss landscape more effectively, finding adversarial examples that are closer to the worst-case scenario within the allowed budget.

PGD running very slowly?

PGD is significantly more expensive than FGSM because it runs 40 forward/backward passes per image. If it takes more than a few minutes, try running on a smaller subset first by replacing x_test with x_test[:1000] to confirm the attack works, then switch back to the full test set.

help me speed up my PGD attack

Compare FGSM vs. PGD results

You now have accuracy numbers for both attacks. Printing them side by side makes the difference undeniable.

  • In a new cell, add the comparison table:
print('\n--- Attack Comparison ---')
print(f'  Clean accuracy:  {clean_accuracy * 100:.2f}%')
print(f'  FGSM accuracy:   {fgsm_accuracy * 100:.2f}%')
print(f'  PGD accuracy:    {pgd_accuracy * 100:.2f}%')

What does this code do?

This prints a clean comparison of all three accuracy measurements. You should see a clear downward staircase: clean accuracy is highest, FGSM drops it significantly, and PGD drops it even further.

The gap between FGSM and PGD accuracy demonstrates why iterative attacks matter. A model that seems somewhat robust to FGSM might still be completely broken by PGD.

  • Run the cell and review your comparison table.

PGD accuracy not lower than FGSM?

If your PGD accuracy is similar to or higher than FGSM, double-check that num_random_init=1 is set. Without random initialization, PGD can get stuck and produce weaker adversarial examples.

help me debug my PGD attack results

✔️ Awesome, I've got everything!

Great. Double check you've saved your notebook before moving on.

ⓧ I'd like to double check the full code

Here is the complete notebook code through the end of this step:

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
import numpy as np
import matplotlib.pyplot as plt

from art.attacks.evasion import FastGradientMethod, ProjectedGradientDescent
from art.estimators.classification import PyTorchClassifier
from art.utils import load_mnist

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f'Using device: {device}')

class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.conv_1 = nn.Conv2d(in_channels=1, out_channels=4, kernel_size=5, stride=1)
        self.conv_2 = nn.Conv2d(in_channels=4, out_channels=10, kernel_size=5, stride=1)
        self.fc_1 = nn.Linear(in_features=4 * 4 * 10, out_features=100)
        self.fc_2 = nn.Linear(in_features=100, out_features=10)

    def forward(self, x):
        x = F.relu(self.conv_1(x))
        x = F.max_pool2d(x, 2, 2)
        x = F.relu(self.conv_2(x))
        x = F.max_pool2d(x, 2, 2)
        x = x.view(-1, 4 * 4 * 10)
        x = F.relu(self.fc_1(x))
        x = self.fc_2(x)
        return x

(x_train, y_train), (x_test, y_test), min_pixel_value, max_pixel_value = load_mnist()

x_train = np.transpose(x_train, (0, 3, 1, 2)).astype(np.float32)
x_test = np.transpose(x_test, (0, 3, 1, 2)).astype(np.float32)

model = Net()
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.01)

classifier = PyTorchClassifier(
    model=model,
    clip_values=(min_pixel_value, max_pixel_value),
    loss=criterion,
    optimizer=optimizer,
    input_shape=(1, 28, 28),
    nb_classes=10,
)

classifier.fit(x_train, y_train, batch_size=64, nb_epochs=3)

predictions = classifier.predict(x_test)
clean_accuracy = np.sum(np.argmax(predictions, axis=1) == np.argmax(y_test, axis=1)) / len(y_test)
print(f'Accuracy on clean test examples: {clean_accuracy * 100:.2f}%')

fgsm_attack = FastGradientMethod(estimator=classifier, eps=0.2)
x_test_adv_fgsm = fgsm_attack.generate(x=x_test)

predictions_fgsm = classifier.predict(x_test_adv_fgsm)
fgsm_accuracy = np.sum(np.argmax(predictions_fgsm, axis=1) == np.argmax(y_test, axis=1)) / len(y_test)
print(f'Accuracy on FGSM adversarial examples: {fgsm_accuracy * 100:.2f}%')

fig, axes = plt.subplots(3, 5, figsize=(12, 8))
for i in range(5):
    axes[0, i].imshow(x_test[i, 0], cmap='gray')
    axes[0, i].set_title(f'Clean: {np.argmax(predictions[i])}')
    axes[0, i].axis('off')
    perturbation = x_test_adv_fgsm[i, 0] - x_test[i, 0]
    axes[1, i].imshow(perturbation, cmap='RdBu_r', vmin=-0.2, vmax=0.2)
    axes[1, i].set_title('Perturbation')
    axes[1, i].axis('off')
    axes[2, i].imshow(x_test_adv_fgsm[i, 0], cmap='gray')
    axes[2, i].set_title(f'Adv: {np.argmax(predictions_fgsm[i])}')
    axes[2, i].axis('off')
plt.suptitle('FGSM Attack: Clean vs Perturbation vs Adversarial')
plt.tight_layout()
plt.savefig('fgsm_visualization.png', dpi=100, bbox_inches='tight')
plt.show()

print('\nEpsilon vs Accuracy:')
print('-' * 30)
for eps in [0.05, 0.1, 0.2, 0.3]:
    attack_temp = FastGradientMethod(estimator=classifier, eps=eps)
    x_adv_temp = attack_temp.generate(x=x_test)
    preds_temp = classifier.predict(x_adv_temp)
    acc_temp = np.sum(np.argmax(preds_temp, axis=1) == np.argmax(y_test, axis=1)) / len(y_test)
    print(f'  eps={eps:.2f}: {acc_temp * 100:.2f}%')

# PGD Attack
pgd_attack = ProjectedGradientDescent(
    estimator=classifier,
    eps=0.2,
    eps_step=0.01,
    max_iter=40,
    num_random_init=1,
)

x_test_adv_pgd = pgd_attack.generate(x=x_test)

predictions_pgd = classifier.predict(x_test_adv_pgd)
pgd_accuracy = np.sum(np.argmax(predictions_pgd, axis=1) == np.argmax(y_test, axis=1)) / len(y_test)
print(f'\nAccuracy on PGD adversarial examples: {pgd_accuracy * 100:.2f}%')

print('\n--- Attack Comparison ---')
print(f'  Clean accuracy:  {clean_accuracy * 100:.2f}%')
print(f'  FGSM accuracy:   {fgsm_accuracy * 100:.2f}%')
print(f'  PGD accuracy:    {pgd_accuracy * 100:.2f}%')

You now have hard evidence that your CNN is even more vulnerable than FGSM alone suggested. Next up, you'll flip from offense to defense and harden your model against both attacks using adversarial training.

Defend with Adversarial Training

You have successfully broken your CNN with both FGSM and PGD attacks. The PGD attack was especially devastating, reducing your model's accuracy to near-random guessing.

Now it is time to switch from offense to defense. You will use adversarial training to harden your CNN against the same attacks that just destroyed it. The core idea is simple: train the model on adversarial examples so it learns to recognize them.

In this step, get ready to:
  • Implement adversarial training by augmenting your training data with PGD-generated adversarial examples.
  • Evaluate the hardened model against FGSM and PGD to measure the improvement.
  • Visualize side-by-side predictions proving the robust model resists attacks that broke the original.
Implement adversarial training

Adversarial training works by generating adversarial examples from the training data, mixing them with the original clean examples, and retraining the model on both. The model learns to classify inputs correctly even when they have been perturbed.

  • In your notebook, create a new cell and generate adversarial training data by pasting this code:
# Generate adversarial training data using PGD on a subset
x_train_subset = x_train[:10000]
y_train_subset = y_train[:10000]

pgd_train_attack = ProjectedGradientDescent(
    estimator=classifier,
    eps=0.2,
    eps_step=0.01,
    max_iter=40,
    num_random_init=1,
)
x_train_adv = pgd_train_attack.generate(x=x_train_subset)

# Combine original and adversarial training data
x_train_combined = np.concatenate([x_train_subset, x_train_adv], axis=0)
y_train_combined = np.concatenate([y_train_subset, y_train_subset], axis=0)

What does this code do?

You take a 10,000-image subset of the training data and run PGD against it to create adversarial versions. Using a subset keeps generation time reasonable while providing enough diversity for the model to learn from.

You then concatenate the clean training images with their adversarial counterparts. The labels stay the same for both, because the adversarial image of a "7" is still a "7" to humans. The model now has 20,000 training examples: 10,000 clean and 10,000 adversarial.

Why use PGD instead of FGSM for training data?

PGD generates stronger adversarial examples than FGSM because it uses multiple iterative steps. Training against the stronger attack builds robustness against both PGD and weaker attacks like FGSM.

  • Run the cell. This will take a few minutes as PGD iterates 40 times per image across 10,000 examples.

Once generation completes, you are ready to create and train a fresh model on this augmented dataset.

  • In the next cell, create and train a new robust model by pasting this code:
# Create a fresh model for adversarial training
robust_model = Net()
robust_criterion = nn.CrossEntropyLoss()
robust_optimizer = optim.Adam(robust_model.parameters(), lr=0.001)

robust_classifier = PyTorchClassifier(
    model=robust_model,
    clip_values=(min_pixel_value, max_pixel_value),
    loss=robust_criterion,
    optimizer=robust_optimizer,
    input_shape=(1, 28, 28),
    nb_classes=10,
)

# Train on combined data
robust_classifier.fit(x_train_combined, y_train_combined, batch_size=64, nb_epochs=5)

What does this code do?

You create a completely fresh CNN with the same architecture as before. This ensures the robustness comes from the training data, not from some leftover state.

The learning rate is set to 0.001 instead of the original 0.01. A lower learning rate helps the model converge properly when training on the mixed clean and adversarial data.

Training runs for 5 epochs (instead of 3) to give the model enough time to learn patterns in both clean and adversarial inputs.

  • Run the cell. Training takes a couple of minutes depending on your hardware.

Training seems stuck or accuracy is very low?

Make sure the learning rate is 0.001 for the robust model. Using 0.01 (the original model's rate) prevents convergence on the augmented dataset.

Also confirm you are calling robust_classifier.fit() with x_train_combined and y_train_combined, not the original training arrays.

Help me debug my adversarial training setup.

Evaluate the defended model against all attacks

Your robust model is trained. Now you will throw the same attacks at it and compare the results to the original undefended model.

  • In a new cell, evaluate the robust model on clean data and both attacks by pasting this code:
# Evaluate the robust model
# Clean accuracy
robust_preds_clean = robust_classifier.predict(x_test)
robust_clean_acc = np.sum(np.argmax(robust_preds_clean, axis=1) == np.argmax(y_test, axis=1)) / len(y_test)

# FGSM attack on robust model
fgsm_attack_robust = FastGradientMethod(estimator=robust_classifier, eps=0.2)
x_test_adv_fgsm_robust = fgsm_attack_robust.generate(x=x_test)
robust_preds_fgsm = robust_classifier.predict(x_test_adv_fgsm_robust)
robust_fgsm_acc = np.sum(np.argmax(robust_preds_fgsm, axis=1) == np.argmax(y_test, axis=1)) / len(y_test)

# PGD attack on robust model
pgd_attack_robust = ProjectedGradientDescent(
    estimator=robust_classifier,
    eps=0.2,
    eps_step=0.01,
    max_iter=40,
    num_random_init=1,
)
x_test_adv_pgd_robust = pgd_attack_robust.generate(x=x_test)
robust_preds_pgd = robust_classifier.predict(x_test_adv_pgd_robust)
robust_pgd_acc = np.sum(np.argmax(robust_preds_pgd, axis=1) == np.argmax(y_test, axis=1)) / len(y_test)

# Before/after comparison
print("\n--- Before vs After Adversarial Training ---")
print(f"{'Metric':<25} {'Original':<15} {'Robust':<15}")
print("-" * 55)
print(f"{'Clean accuracy':<25} {clean_accuracy*100:.2f}%{'':<8} {robust_clean_acc*100:.2f}%")
print(f"{'FGSM accuracy':<25} {fgsm_accuracy*100:.2f}%{'':<8} {robust_fgsm_acc*100:.2f}%")
print(f"{'PGD accuracy':<25} {pgd_accuracy*100:.2f}%{'':<8} {robust_pgd_acc*100:.2f}%")

What does this code do?

You run three evaluations on the robust model: clean test data, FGSM adversarial examples, and PGD adversarial examples. Each attack is regenerated fresh against the robust model (not reusing the old adversarial examples from the original).

The final comparison table shows the original model's accuracy alongside the robust model's accuracy on each attack type. Clean accuracy should remain above 90%, while adversarial accuracy should improve significantly.

  • Run the cell. This takes a few minutes as PGD generates adversarial examples against the robust model.
  • Check the comparison table in the output. Your robust model's FGSM and PGD accuracy numbers should be significantly higher than the original model's.

Robust model accuracy is not improving?

If FGSM and PGD accuracy on the robust model are still very low, check that you trained with nb_epochs=5 and a learning rate of 0.001.

Also verify that x_train_combined contains 20,000 examples (10,000 clean + 10,000 adversarial). You can check with x_train_combined.shape which should show (20000, 1, 28, 28).

Help me understand why my adversarial training did not improve robustness.

Visualize the robustness improvement

Numbers tell one story, but seeing the same adversarial images classified differently by the two models makes the improvement concrete.

  • In a new cell, create a side-by-side visualization by pasting this code:
# Visualization of robustness improvement
fig, axes = plt.subplots(2, 5, figsize=(12, 5))
for i in range(5):
    axes[0, i].imshow(x_test_adv_pgd[i, 0], cmap="gray")
    orig_pred = np.argmax(classifier.predict(x_test_adv_pgd[i:i+1])[0])
    axes[0, i].set_title(f"Original: {orig_pred}")
    axes[0, i].axis("off")

    axes[1, i].imshow(x_test_adv_pgd[i, 0], cmap="gray")
    robust_pred = np.argmax(robust_classifier.predict(x_test_adv_pgd[i:i+1])[0])
    true_label = np.argmax(y_test[i])
    axes[1, i].set_title(f"Robust: {robust_pred} (true: {true_label})")
    axes[1, i].axis("off")

axes[0, 0].set_ylabel("Original\nModel", fontsize=10)
axes[1, 0].set_ylabel("Robust\nModel", fontsize=10)
plt.suptitle("Same Adversarial Images: Original vs Robust Model Predictions")
plt.tight_layout()
plt.savefig("robustness_comparison.png", dpi=100, bbox_inches="tight")
plt.show()

What does this code do?

You display the same PGD adversarial images in two rows. The top row shows what the original (undefended) model predicts for each image. The bottom row shows what the robust model predicts, alongside the true label.

The original model gives wrong predictions because it was never trained on adversarial inputs. The robust model correctly classifies many of these same images because it has seen similar perturbations during training.

  • Run the cell.
  • You should see a 2-row grid of 5 images. The top row labels show incorrect predictions from the original model, while the bottom row shows the robust model's predictions matching (or closer to) the true labels.

Visualization not showing?

If the plot does not render inline, make sure you have %matplotlib inline set in an earlier cell. Alternatively, check that robustness_comparison.png was saved to your project directory.

My robustness visualization is not displaying in Jupyter.

✔️ Awesome, I've got everything!

Great. Double check you have saved your notebook with all cells run successfully.

ⓧ I'd like to double check the full code

Here is the complete notebook code up to this point. Compare it against your cells to find any differences.

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
import numpy as np
import matplotlib.pyplot as plt

from art.attacks.evasion import FastGradientMethod, ProjectedGradientDescent
from art.estimators.classification import PyTorchClassifier
from art.utils import load_mnist

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f'Using device: {device}')

class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.conv_1 = nn.Conv2d(in_channels=1, out_channels=4, kernel_size=5, stride=1)
        self.conv_2 = nn.Conv2d(in_channels=4, out_channels=10, kernel_size=5, stride=1)
        self.fc_1 = nn.Linear(in_features=4 * 4 * 10, out_features=100)
        self.fc_2 = nn.Linear(in_features=100, out_features=10)

    def forward(self, x):
        x = F.relu(self.conv_1(x))
        x = F.max_pool2d(x, 2, 2)
        x = F.relu(self.conv_2(x))
        x = F.max_pool2d(x, 2, 2)
        x = x.view(-1, 4 * 4 * 10)
        x = F.relu(self.fc_1(x))
        x = self.fc_2(x)
        return x

(x_train, y_train), (x_test, y_test), min_pixel_value, max_pixel_value = load_mnist()

x_train = np.transpose(x_train, (0, 3, 1, 2)).astype(np.float32)
x_test = np.transpose(x_test, (0, 3, 1, 2)).astype(np.float32)

model = Net()
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.01)

classifier = PyTorchClassifier(
    model=model,
    clip_values=(min_pixel_value, max_pixel_value),
    loss=criterion,
    optimizer=optimizer,
    input_shape=(1, 28, 28),
    nb_classes=10,
)

classifier.fit(x_train, y_train, batch_size=64, nb_epochs=3)

predictions = classifier.predict(x_test)
clean_accuracy = np.sum(np.argmax(predictions, axis=1) == np.argmax(y_test, axis=1)) / len(y_test)
print(f'Accuracy on clean test examples: {clean_accuracy * 100:.2f}%')

fgsm_attack = FastGradientMethod(estimator=classifier, eps=0.2)
x_test_adv_fgsm = fgsm_attack.generate(x=x_test)

predictions_fgsm = classifier.predict(x_test_adv_fgsm)
fgsm_accuracy = np.sum(np.argmax(predictions_fgsm, axis=1) == np.argmax(y_test, axis=1)) / len(y_test)
print(f'Accuracy on FGSM adversarial examples: {fgsm_accuracy * 100:.2f}%')

fig, axes = plt.subplots(3, 5, figsize=(12, 8))
for i in range(5):
    axes[0, i].imshow(x_test[i, 0], cmap='gray')
    axes[0, i].set_title(f'Clean: {np.argmax(predictions[i])}')
    axes[0, i].axis('off')
    perturbation = x_test_adv_fgsm[i, 0] - x_test[i, 0]
    axes[1, i].imshow(perturbation, cmap='RdBu_r', vmin=-0.2, vmax=0.2)
    axes[1, i].set_title('Perturbation')
    axes[1, i].axis('off')
    axes[2, i].imshow(x_test_adv_fgsm[i, 0], cmap='gray')
    axes[2, i].set_title(f'Adv: {np.argmax(predictions_fgsm[i])}')
    axes[2, i].axis('off')
plt.suptitle('FGSM Attack: Clean vs Perturbation vs Adversarial')
plt.tight_layout()
plt.savefig('fgsm_visualization.png', dpi=100, bbox_inches='tight')
plt.show()

print('\nEpsilon vs Accuracy:')
print('-' * 30)
for eps in [0.05, 0.1, 0.2, 0.3]:
    attack_temp = FastGradientMethod(estimator=classifier, eps=eps)
    x_adv_temp = attack_temp.generate(x=x_test)
    preds_temp = classifier.predict(x_adv_temp)
    acc_temp = np.sum(np.argmax(preds_temp, axis=1) == np.argmax(y_test, axis=1)) / len(y_test)
    print(f'  eps={eps:.2f}: {acc_temp * 100:.2f}%')

pgd_attack = ProjectedGradientDescent(
    estimator=classifier,
    eps=0.2,
    eps_step=0.01,
    max_iter=40,
    num_random_init=1,
)

x_test_adv_pgd = pgd_attack.generate(x=x_test)

predictions_pgd = classifier.predict(x_test_adv_pgd)
pgd_accuracy = np.sum(np.argmax(predictions_pgd, axis=1) == np.argmax(y_test, axis=1)) / len(y_test)
print(f'\nAccuracy on PGD adversarial examples: {pgd_accuracy * 100:.2f}%')

print('\n--- Attack Comparison ---')
print(f'  Clean accuracy:  {clean_accuracy * 100:.2f}%')
print(f'  FGSM accuracy:   {fgsm_accuracy * 100:.2f}%')
print(f'  PGD accuracy:    {pgd_accuracy * 100:.2f}%')

# Adversarial Training Defense
x_train_subset = x_train[:10000]
y_train_subset = y_train[:10000]

pgd_train_attack = ProjectedGradientDescent(
    estimator=classifier,
    eps=0.2,
    eps_step=0.01,
    max_iter=40,
    num_random_init=1,
)
x_train_adv = pgd_train_attack.generate(x=x_train_subset)

x_train_combined = np.concatenate([x_train_subset, x_train_adv], axis=0)
y_train_combined = np.concatenate([y_train_subset, y_train_subset], axis=0)

robust_model = Net()
robust_criterion = nn.CrossEntropyLoss()
robust_optimizer = optim.Adam(robust_model.parameters(), lr=0.001)

robust_classifier = PyTorchClassifier(
    model=robust_model,
    clip_values=(min_pixel_value, max_pixel_value),
    loss=robust_criterion,
    optimizer=robust_optimizer,
    input_shape=(1, 28, 28),
    nb_classes=10,
)

robust_classifier.fit(x_train_combined, y_train_combined, batch_size=64, nb_epochs=5)

robust_preds_clean = robust_classifier.predict(x_test)
robust_clean_acc = np.sum(np.argmax(robust_preds_clean, axis=1) == np.argmax(y_test, axis=1)) / len(y_test)

fgsm_attack_robust = FastGradientMethod(estimator=robust_classifier, eps=0.2)
x_test_adv_fgsm_robust = fgsm_attack_robust.generate(x=x_test)
robust_preds_fgsm = robust_classifier.predict(x_test_adv_fgsm_robust)
robust_fgsm_acc = np.sum(np.argmax(robust_preds_fgsm, axis=1) == np.argmax(y_test, axis=1)) / len(y_test)

pgd_attack_robust = ProjectedGradientDescent(
    estimator=robust_classifier,
    eps=0.2,
    eps_step=0.01,
    max_iter=40,
    num_random_init=1,
)
x_test_adv_pgd_robust = pgd_attack_robust.generate(x=x_test)
robust_preds_pgd = robust_classifier.predict(x_test_adv_pgd_robust)
robust_pgd_acc = np.sum(np.argmax(robust_preds_pgd, axis=1) == np.argmax(y_test, axis=1)) / len(y_test)

print('\n--- Before vs After Adversarial Training ---')
print(f"{'Metric':<25} {'Original':<15} {'Robust':<15}")
print('-' * 55)
print(f"{'Clean accuracy':<25} {clean_accuracy*100:.2f}%{'':8} {robust_clean_acc*100:.2f}%")
print(f"{'FGSM accuracy':<25} {fgsm_accuracy*100:.2f}%{'':8} {robust_fgsm_acc*100:.2f}%")
print(f"{'PGD accuracy':<25} {pgd_accuracy*100:.2f}%{'':8} {robust_pgd_acc*100:.2f}%")

fig, axes = plt.subplots(2, 5, figsize=(12, 5))
for i in range(5):
    axes[0, i].imshow(x_test_adv_pgd[i, 0], cmap='gray')
    orig_pred = np.argmax(classifier.predict(x_test_adv_pgd[i:i+1])[0])
    axes[0, i].set_title(f'Original: {orig_pred}')
    axes[0, i].axis('off')
    axes[1, i].imshow(x_test_adv_pgd[i, 0], cmap='gray')
    robust_pred = np.argmax(robust_classifier.predict(x_test_adv_pgd[i:i+1])[0])
    true_label = np.argmax(y_test[i])
    axes[1, i].set_title(f'Robust: {robust_pred} (true: {true_label})')
    axes[1, i].axis('off')
axes[0, 0].set_ylabel('Original\nModel', fontsize=10)
axes[1, 0].set_ylabel('Robust\nModel', fontsize=10)
plt.suptitle('Same Adversarial Images: Original vs Robust Model Predictions')
plt.tight_layout()
plt.savefig('robustness_comparison.png', dpi=100, bbox_inches='tight')
plt.show()

Your CNN now resists the same attacks that previously destroyed it. You have completed the full red-team/blue-team cycle: build, break, and defend. Next up, you will take this defense further with a Secret Mission that adds a preprocessing layer for defense-in-depth.

Secret mission

Add a Preprocessing Defense Layer

Your adversarially trained model is tougher now. But what if you could add another layer of defense that processes inputs before they even reach the model? In this secret mission, you will combine adversarial training with Spatial Smoothing for defense-in-depth.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing cloud costs or subscriptions to worry about. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Jupyter Notebook server (running process).
  • Python virtual environment with PyTorch, ART, matplotlib, and numpy installed.
  • Project files: adversarial_ml_lab.ipynb, fgsm_visualization.png, robustness_comparison.png.

Keep everything running

No action needed. Choose this if you are still actively building or plan to extend your adversarial ML experiments in the next session.

  • Your Jupyter Notebook server stays running and accessible in your browser.
  • Your virtual environment and all installed packages remain on disk.
  • Your saved visualizations (fgsm_visualization.png and robustness_comparison.png) stay in your project folder.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your files and virtual environment stay on disk for next time.

  • In your Jupyter Notebook browser tab, click File, then Close and Shut Down Notebook.
  • Switch to the terminal where Jupyter is running and press Ctrl+C to stop the server. Confirm with y if prompted.
  • If your virtual environment is activated, run:
deactivate

When you come back, reactivate your virtual environment and start Jupyter again from your project folder.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes your notebook, saved images, and all installed packages.

  • In your Jupyter Notebook browser tab, click File, then Close and Shut Down Notebook.
  • Switch to the terminal where Jupyter is running and press Ctrl+C to stop the server. Confirm with y if prompted.
  • Deactivate the virtual environment if it is still active:
deactivate
  • Navigate to your Desktop (the parent of your project folder) and remove the entire project directory.

On macOS/Linux:

cd ~/Desktop
rm -rf [[PROJECT_DIR="your project folder name"]]

On Windows:

cd ~\Desktop
rmdir /s /q [[PROJECT_DIR="your project folder name"]]

This removes your notebook, saved images, virtual environment, and all downloaded MNIST data in one step.

Nice Work!

Nice Work!

Nice work! You just completed a full adversarial machine learning red-team and blue-team exercise using PyTorch and IBM's Adversarial Robustness Toolbox.

You've learned how to:

  • Built and trained a CNN image classifier from scratch, then broke it using FGSM (single-step) and PGD (iterative) adversarial attacks.
  • Visualized adversarial perturbations to see how imperceptible noise fools neural networks while remaining invisible to humans.
  • Defended the model using adversarial training, augmenting training data with attack-generated examples to harden the CNN against future attacks.
  • Combined adversarial training with a Spatial Smoothing preprocessing defense for defense-in-depth (Secret Mission).

Ready to quiz yourself?