Discord

Checklist OAI 2024 Stage I · Task 1

SOTA-AI Editorial

Adversarial Attacks

Perturb 28×28 greyscale images so that a given pre-trained convolutional classifier loses as much accuracy as possible while the images stay structurally similar to the originals.

Written
8 October 2026
Result
Criterion 48.65 on released validation, above the full-credit threshold; SSIM was reconstructed rather than computed with scikit-image.
Points
No points score reported; see result for the measured metric.
Olympiad
Polish Olympiad in Artificial Intelligence (Olimpiada Sztucznej Inteligencji), 2024 · Stage I

Hints

Hint 1

The attack function gets no true labels; can the frozen classifier provide a usable pseudo-label?

Hint 2

Which objective will reduce confidence in that initial prediction?

Hint 3

How can each gradient step stay inside the per-pixel `L∞` limit?

Hint 4

Project the update back into the `ε` ball and compare the SSIM × accuracy-drop criterion.

The solution in one line

Maximize loss for the classifier's initial prediction with three projected sign-gradient steps, staying just inside the 0.3 `L∞` bound.

The editorial

Adversarial Attacks — Stage I (2024)

Problem name: Adversarial Attacks (ataki_adwersarialne)

Domain: Adversarial robustness, image classification

Metric: Mean structural similarity (SSIM) × decrease in classifier accuracy. The released validation notebook reports base accuracy as 91.9636% and the full-credit threshold is a criterion value above 42; values below 36 receive zero. The evaluator also rejects any pixel displacement above 0.3 in normalized [-1, 1] space.

Abridged statement

Given a frozen MNIST-like classifier and grayscale images normalized independently to [-1, 1], return perturbed images that reduce the model's accuracy while preserving as much structural similarity as possible. The attack function receives images but no labels; the hidden test images and labels are unavailable.

Dataset analysis and EDA

The local mirror contains 48,128,128 bytes of training images and 8,624,128 bytes of released validation images, with 11,000 validation labels. Images have shape 28×28; the organizer notebook's validation run gives 91.9636% clean accuracy. The corresponding 4,049,428-byte classifier checkpoint is supplied by the task and was loaded for local evaluation. The task notebook normalizes each image using its own minimum and maximum, then converts the returned float array to a tensor without another normalization.

Experiments

  • Untouched-image baseline: SSIM 1.0 but no accuracy reduction, so the criterion is zero.
  • Single-step FGSM, ε=0.3: one sign-gradient step maximized cross-entropy for the model's initial predicted class. Locally measured final accuracy was 16.05%; reconstructed flattened-vector SSIM was 0.4552, for 34.56 criterion units. This is below the full-credit threshold.
  • Three-step projected sign-gradient attack, ε=0.299, step size 0.1: final accuracy was 5.70%; reconstructed SSIM was 0.5639 and the criterion was 48.65. This exceeds the full-credit threshold. The measured maximum pixel displacement was 0.29900002.

The SSIM implementation in the local experiment reproduces scikit-image's default 1D, seven-sample window calculation used by the notebook (ssim is applied after flattening each image). The runtime does not include scikit-image, so this SSIM and the resulting criterion are reconstructed from its standard formula, not obtained by executing the imported evaluator function. Clean and attacked accuracy were measured with the supplied checkpoint.

Selected solution

Use untargeted PGD with the initial model prediction as a pseudo-label, three gradient-ascent sign steps, and projection into the per-pixel L∞ ball around the input. The method does not use validation labels or update classifier weights. It is implemented in adversarial_attacks.py. The notebook-compatible perturbe_dataset loads the organizer checkpoint when no model is passed; the standalone helper accepts a model directly.

Validation score and evidence

The strongest measured candidate scored 48.65 criterion units, above the organizer's >42 full-credit boundary (100/100 task points). Its measured components were 91.9636% clean accuracy, 5.70% attacked accuracy, reconstructed SSIM 0.5639, and max pixel displacement 0.29900002. The final source settings were measured on all 11,000 released validation images in 164.9 seconds on CPU. The local runner does not have scikit-image installed, so SSIM was reconstructed from scikit-image's documented standard 1D seven-sample-window formula rather than executed through the imported evaluator function. The score has a 6.65-unit margin over the full-credit boundary. No hidden-test result is claimed.

Compute and runtime

The measured attack used CPU PyTorch with batch size 512 and three forward/backward passes per batch. The task allows five minutes in a GPU-backed Colab environment. The measured CPU run took 164.9 seconds, within that wall-clock limit even without GPU.

Alternatives

One-step FGSM is faster but missed full credit on the released validation set. Targeted PGD, random-start PGD, and lower-budget attacks could trade attack strength against SSIM, but have not been measured. The three-step untargeted attack is the current best measured candidate.

Code

Solution code solution.py · 108 lines
"""PGD attack for the 2024 Polish AI Olympiad Adversarial Attacks task.

The official notebook supplies ``trained_model.pth`` and calls a one-argument
``perturbe_dataset`` function.  The standalone helper also accepts an explicit
model so it can be measured without changing the notebook.
"""

from pathlib import Path
from typing import Optional

import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F


class Net(nn.Module):
    """The exact classifier architecture defined by the organizers."""

    def __init__(self) -> None:
        super().__init__()
        self.conv1 = nn.Conv2d(1, 32, kernel_size=3, stride=1, padding=0)
        self.conv2 = nn.Conv2d(32, 64, kernel_size=3, stride=1, padding=0)
        self.pool = nn.MaxPool2d(kernel_size=3, stride=2)
        self.fc1 = nn.Linear(64 * 11 * 11, 128)
        self.fc2 = nn.Linear(128, 10)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        x = F.relu(self.conv1(x))
        x = F.relu(self.pool(self.conv2(x)))
        x = torch.flatten(x, 1)
        x = self.fc1(x)
        return F.log_softmax(self.fc2(x), dim=1)


def _load_official_model(
    checkpoint: Optional[str | Path] = None,
    device: Optional[torch.device] = None,
) -> tuple[Net, torch.device]:
    device = device or torch.device("cuda" if torch.cuda.is_available() else "cpu")
    path = Path(checkpoint) if checkpoint is not None else Path("trained_model.pth")
    model = Net()
    state = torch.load(path, map_location=device, weights_only=True)
    model.load_state_dict(state)
    model.eval().to(device)
    return model, device


def pgd_perturb_dataset(
    original_dataset: np.ndarray,
    model: nn.Module,
    device: Optional[torch.device | str] = None,
    *,
    epsilon: float = 0.299,
    steps: int = 3,
    step_size: float = 0.1,
    batch_size: int = 512,
) -> np.ndarray:
    """Maximize untargeted loss with a projected sign-gradient attack.

    The organizer normalizes inputs independently to [-1, 1].  Output floats
    remain in that same range and are not passed through another normalization.
    ``epsilon`` is kept just below the strict 0.3 pixel-distance limit to avoid
    floating point boundary overshoot in the evaluator.
    """
    images = np.asarray(original_dataset, dtype=np.float32)
    if images.ndim != 3 or images.shape[1:] != (28, 28):
        raise ValueError(f"Expected (N, 28, 28) images, got {images.shape}")
    if not np.isfinite(images).all():
        raise ValueError("Input images must contain only finite values")

    target_device = torch.device(device) if device is not None else next(model.parameters()).device
    was_training = model.training
    model.eval()
    outputs: list[np.ndarray] = []
    with torch.enable_grad():
        for start in range(0, len(images), batch_size):
            clean = torch.from_numpy(images[start : start + batch_size, None]).to(target_device)
            with torch.no_grad():
                # The task interface does not provide ground-truth labels.
                pseudo_labels = model(clean).argmax(dim=1)
            adversarial = clean.detach().clone()
            for _ in range(steps):
                adversarial.requires_grad_(True)
                log_probs = model(adversarial)
                loss = F.nll_loss(log_probs, pseudo_labels)
                gradient = torch.autograd.grad(loss, adversarial, only_inputs=True)[0]
                adversarial = adversarial.detach() + step_size * gradient.sign()
                delta = torch.clamp(adversarial - clean, min=-epsilon, max=epsilon)
                adversarial = torch.clamp(clean + delta, min=-1.0, max=1.0).detach()
            outputs.append(adversarial[:, 0].cpu().numpy())
    if was_training:
        model.train()
    return np.concatenate(outputs, axis=0) if outputs else images.copy()


def perturbe_dataset(
    original_dataset: np.ndarray,
    model: Optional[nn.Module] = None,
    device: Optional[torch.device | str] = None,
    checkpoint: Optional[str | Path] = None,
) -> np.ndarray:
    """Notebook-compatible entry point; loads the official checkpoint if needed."""
    if model is None:
        model, loaded_device = _load_official_model(checkpoint, device)
        device = loaded_device
    return pgd_perturb_dataset(original_dataset, model, device)


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--data", default="data/contest_validation_samples.npy")
    parser.add_argument("--checkpoint", default="trained_model.pth")
    parser.add_argument("--output", default="adversarial_validation.npy")
    args = parser.parse_args()
    raw = np.load(args.data).astype(np.float32) / 255.0
    flat = raw.reshape(len(raw), -1)
    lo = flat.min(axis=1, keepdims=True)
    hi = flat.max(axis=1, keepdims=True)
    normalized = ((2.0 * (flat - lo) / (hi - lo)) - 1.0).reshape(raw.shape)
    model, device = _load_official_model(args.checkpoint)
    adversarial = pgd_perturb_dataset(normalized, model, device)
    np.save(args.output, adversarial)
    print(f"Saved {len(adversarial)} adversarial images to {args.output}")

Download solution.py

Editorials are written by members of the SOTA community, not by the olympiad. The official statement and scoring win wherever an editorial differs.