Checklist OAI 2025 Stage II · Task 1
SOTA-AI Editorial
Non-Normal Distribution
Build one network that denoises 28×28 greyscale images, classifies the noise as Gaussian or uniform and estimates μ and σ of Gaussian noise.
Hints
Hint 1
Convert byte pixels to `[0, 1]` and exploit the paired clean/noisy training images for denoising.
Hint 2
Train the noise classifier jointly; return a probability because the evaluator compares the returned value with 0.5.
Hint 3
The parameter fields are absent from training, but the clean/noisy pair still gives a likelihood for the latent Gaussian parameters.
Hint 4
Account for clipping: an observed 0 or 1 is an inequality on the noise sample, not an exact residual.
The solution in one line
A shared CNN, censored-Gaussian parameter learning, and train-only frozen-feature classifier refit score 100/100 on released validation; hidden-test performance is unknown.
The editorial
Rozkład nienormalny — Non-Normal Distribution
Problem domain: Image denoising, noise-family classification, and Gaussian parameter estimation
Official metric: Four weighted components, 25 points each: classification accuracy scaled from 0.50 to 0.95, mean PSNR scaled from 10 to 16 dB, and full credit for each of Gaussian μ and σ MSE below 0.005. The sum is rounded to an integer.
Abridged statement
Given a noisy 28×28 grayscale image, return a denoised image, a probability for uniform noise (label 1; Gaussian is label 0), and estimates of Gaussian mean and standard deviation. Train only on paired clean/noisy images and noise-family labels; parameter labels are released only for validation/test scoring. Platform inference is limited to five minutes on GPU.
Data analysis
- Official training data: 30,000 paired images, evenly split between Gaussian and uniform noise. Validation data: 2,000 pairs, evenly split, with parameters available for scoring.
- Images are stored as
28×28×1uint8arrays and must be scaled to[0, 1]. Training rows containoriginal,noised, andlabel; they do not containparams. - Validation Gaussian parameters have
μmean/std0.2421 / 0.1439, with range0.0003–0.4997;σmean/std0.1768 / 0.0711, with range0.0501–0.3000. Uniform rows use parameters[0, 0.4]. - The training data retains clean/noisy pairs, so clipping is observable. The solution uses the correct censored likelihood for pixels clipped to 0 or 1 rather than treating their residuals as exact noise samples.
Experiments
The official notebook defines the evaluation function but its released starter solution is only a template. The mirror's alternative notebook contains a random-output placeholder; it has no recorded organizer score. No validation parameter targets are passed to either training stage.
| Experiment | Training inputs | Result |
|---|---|---|
| Official starter architecture / mirror placeholder | Paired images and labels | No released score recorded; mirror placeholder returns random predictions. |
| Shared CNN with residual denoising, binary noise head, and censored Gaussian likelihood head | train.pkl only; five CPU epochs |
98/100; classifier accuracy 0.909. |
| Refit only the classifier's final linear head on frozen train-image features | train.pkl only; 40 CPU head epochs after the five-epoch fit |
100/100; classifier accuracy 0.960. Denoising and Gaussian estimates are unchanged. |
Chosen method
The solution trains a shared convolutional network for residual denoising and global noise features, then refits only the classifier's final linear layer on frozen features from train.pkl. The classifier returns probabilities (the released evaluator thresholds the returned value directly at 0.5). Gaussian μ and σ are trained from the paired training pixels with a likelihood that treats clipped observations as left- or right-censored; validation/test parameter fields are never read. The code creates no checkpoint or copied data artifact.
Measured score and compute
The five-epoch train-only fit initially scored 98/100 on the 2,000 released validation images: PSNR 19.8606 dB, classification accuracy 0.909, Gaussian-mean MSE 0.0006841, and Gaussian-standard-deviation MSE 0.0010840, for partial points (25.00, 22.72, 25.00, 25.00). Refitting the final classifier head on the frozen features of the same 30,000 training images raised accuracy to 0.960 and the official formula returns 100/100; the other metrics remain unchanged, with partial points (25.00, 25.00, 25.00, 25.00). The head-only step took 54.72 seconds on CPU, in addition to the original 374.31 second fit. The official evaluator logic was applied to the released validation rows; validation parameters were used only for scoring. CUDA is unavailable on this host, so a platform GPU runtime estimate cannot be measured locally. Hidden-test performance is unknown.
Alternatives considered
- The official starter trains a small U-Net/ConvNet for denoising and classification, but its provided model does not supervise Gaussian parameters.
- Estimating parameters as the mean/std of a predicted noise residual was considered; image detail and clipping can bias those statistics, so the solution supervises the parameter head with a censored likelihood on training pairs.
- A fixed mean/std prior would not meet the per-image MSE cutoffs reliably across the released parameter ranges.
Code
Solution code
"""Multi-task denoising model for the 2025 Polish AI Olympiad noise task.
Training uses only the released training pairs, their noise-type labels, and
the pixel censoring caused by clipping. Validation/test parameter values are
never read by this module.
"""
from __future__ import annotations
import pickle
from pathlib import Path
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader, Dataset
class _PairedImages(Dataset):
def __init__(self, path: str | Path):
with Path(path).open("rb") as handle:
rows = pickle.load(handle)
self.original = np.stack([row["original"] for row in rows]).astype(np.float32)
self.noised = np.stack([row["noised"] for row in rows]).astype(np.float32)
self.labels = np.asarray([row["label"] for row in rows], dtype=np.float32)
if self.original.max() > 1.0:
self.original /= 255.0
self.noised /= 255.0
def __len__(self) -> int:
return len(self.labels)
def __getitem__(self, index: int):
clean = torch.from_numpy(self.original[index]).permute(2, 0, 1)
noised = torch.from_numpy(self.noised[index]).permute(2, 0, 1)
return {
"original": clean,
"noised": noised,
"label": torch.tensor(self.labels[index]),
}
class Model(nn.Module):
"""Predict the clean image, noise family, and Gaussian parameters."""
def __init__(self):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(1, 24, kernel_size=3, padding=1),
nn.ReLU(inplace=True),
nn.Conv2d(24, 24, kernel_size=3, padding=1),
nn.ReLU(inplace=True),
nn.Conv2d(24, 32, kernel_size=3, padding=1),
nn.ReLU(inplace=True),
nn.Conv2d(32, 32, kernel_size=3, padding=1),
nn.ReLU(inplace=True),
)
self.noise_residual = nn.Conv2d(32, 1, kernel_size=3, padding=1)
nn.init.zeros_(self.noise_residual.weight)
nn.init.zeros_(self.noise_residual.bias)
self.global_features = nn.Sequential(
nn.AdaptiveAvgPool2d(1),
nn.Flatten(),
nn.Linear(32, 64),
nn.ReLU(inplace=True),
)
self.noise_type_head = nn.Linear(64, 1)
self.parameter_head = nn.Linear(64, 2)
def forward(self, x: torch.Tensor):
features = self.features(x)
residual = self.noise_residual(features)
denoised = (x - residual).clamp(0.0, 1.0)
pooled = self.global_features(features)
noise_type = torch.sigmoid(self.noise_type_head(pooled))
raw_parameters = self.parameter_head(pooled)
mean = 0.5 * torch.sigmoid(raw_parameters[:, :1])
std = 0.05 + 0.25 * torch.sigmoid(raw_parameters[:, 1:2])
return denoised, noise_type, mean, std
def _gaussian_censored_nll(
original: torch.Tensor,
noised: torch.Tensor,
mean: torch.Tensor,
std: torch.Tensor,
) -> torch.Tensor:
"""Gaussian likelihood with exact and left/right censored pixels."""
clean = original.flatten(1)
observed = noised.flatten(1)
mu = mean
sigma = std.clamp_min(1e-4)
lower_censored = observed <= 0.0
upper_censored = observed >= 1.0
exact = ~(lower_censored | upper_censored)
residual = observed - clean
exact_log_likelihood = (
-0.5 * ((residual - mu) / sigma).square()
- torch.log(sigma)
- 0.9189385332
)
lower_log_likelihood = torch.special.log_ndtr((-clean - mu) / sigma)
upper_log_likelihood = torch.special.log_ndtr((mu - (1.0 - clean)) / sigma)
log_likelihood = torch.where(
lower_censored,
lower_log_likelihood,
torch.where(upper_censored, upper_log_likelihood, exact_log_likelihood),
)
return -log_likelihood.mean(dim=1)
def _refit_classifier_head(
model: Model,
dataset: Dataset,
device: torch.device,
epochs: int = 40,
batch_size: int = 1024,
) -> None:
"""Tune the noise classifier on frozen CNN features from training rows."""
model.eval()
features = []
labels = []
extraction_loader = DataLoader(
dataset, batch_size=512, shuffle=False, num_workers=0
)
with torch.no_grad():
for batch in extraction_loader:
noised = batch["noised"].to(device=device, dtype=torch.float32)
features.append(model.global_features(model.features(noised)))
labels.append(batch["label"].to(device=device, dtype=torch.float32).reshape(-1))
features = torch.cat(features, dim=0)
labels = torch.cat(labels, dim=0)
torch.manual_seed(7)
if device.type == "cuda":
torch.cuda.manual_seed_all(7)
optimizer = torch.optim.Adam(
model.noise_type_head.parameters(), lr=0.02, weight_decay=1e-4
)
for _ in range(epochs):
permutation = torch.randperm(len(features), device=device)
for indices in permutation.split(batch_size):
logits = model.noise_type_head(features[indices]).reshape(-1)
loss = F.binary_cross_entropy_with_logits(logits, labels[indices])
optimizer.zero_grad(set_to_none=True)
loss.backward()
optimizer.step()
def train_model(
train_loader=None,
data_root: str | Path = ".",
device: torch.device | str | None = None,
epochs: int = 5,
batch_size: int = 256,
learning_rate: float = 1e-3,
) -> Model:
"""Train from train.pkl or an official-style training DataLoader.
No validation data or validation parameter targets are accessed. When a
loader is provided, its training dataset is rebatched for throughput.
"""
if device is None:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
device = torch.device(device)
if train_loader is None:
dataset = _PairedImages(Path(data_root) / "train.pkl")
else:
dataset = train_loader.dataset
loader = DataLoader(dataset, batch_size=batch_size, shuffle=True, num_workers=0)
model = Model().to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=learning_rate, weight_decay=1e-4)
for _ in range(epochs):
model.train()
for batch in loader:
original = batch["original"].to(device=device, dtype=torch.float32)
noised = batch["noised"].to(device=device, dtype=torch.float32)
labels = batch["label"].to(device=device, dtype=torch.float32).reshape(-1)
denoised, noise_type, mean, std = model(noised)
denoising_loss = F.mse_loss(denoised, original)
classification_loss = F.binary_cross_entropy(
noise_type.reshape(-1), labels
)
gaussian_mask = labels == 0.0
if gaussian_mask.any():
parameter_loss = _gaussian_censored_nll(
original[gaussian_mask],
noised[gaussian_mask],
mean[gaussian_mask],
std[gaussian_mask],
).mean()
else:
parameter_loss = denoising_loss.new_zeros(())
loss = denoising_loss + 0.15 * classification_loss + 0.10 * parameter_loss
optimizer.zero_grad(set_to_none=True)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=2.0)
optimizer.step()
_refit_classifier_head(model, dataset, device)
model.eval()
return model
Editorials are written by members of the SOTA community, not by the olympiad. The official statement and scoring win wherever an editorial differs.