Discord

Checklist OAI 2024 Stage I · Task 6

Pruning

Zero out as many weights and biases as possible in a trained regression MLP while keeping its mean squared error low.

  • Neural network pruning (regression model)
  • Polish original · English translation

The task

Network architectures are often disproportionately large for the task, and pruning (removing individual weights or whole neurons) can shrink them with little loss of accuracy. This task considers only zeroing individual weights; the architecture may not be changed.

The contestant implements your_pruning_algorithm(model), which receives a trained MLP for a regression problem (input layer of size 128, hidden layer of size 1024 with Sigmoid activation, output layer of size 10) and returns a version with as many zero parameters (weights and biases) as possible while keeping the mean squared error of the predictions as low as possible.

The parameters must be saved with the provided save_parameters function to model_parameters.pkl, which is the file that is scored.

Abridged and translated by SOTA from the official Polish materials. The official statement has the exact rules, and it wins wherever this summary differs.

In English

This task was published in Polish. SOTA translated it into English on 16 September 2026. Only the words changed in the notebooks: markdown, code comments, messages and printed output. The code, file names and paths are the original's, so a translated notebook runs with the original data.

Read the task notebook in English 740 words and 14 code cells

Pruning

pruning.png

Introduction

Deep Learning is a highly experimental field and, as a consequence, a given problem may have many satisfactory solutions (usually non-optimal ones). It often happens that neural network architectures are disproportionately large relative to the complexity of the task. It turns out that we can slim models down with only a small loss in prediction accuracy.

Pruning a network consists in removing individual weights or entire neurons. This method has many advantages:

  • reducing the size of the network,
  • speeding up inference,
  • counteracting overfitting,
  • improving results.

To reduce the size of the network effectively, we must zero out a sufficiently large number of elements in its weight matrices. This will allow us to compress the model in memory better. However, zeroing the weights alone is not enough to speed up inference. In addition, sparse matrix computation (Sparse Matrix) has to be implemented and used effectively. Another pruning method can be removing entire neurons - thereby reducing the actual size of the weight matrices.

In this task we will focus only on zeroing weights in the model. You may not change the network architecture (e.g. by removing a neuron or an entire hidden layer). We will consider this problem using the example of regression.

Task

Implement the function your_pruning_algorithm(model : torch.nn.Module) -> pruned_model: torch.nn.Module, which takes as input the model implemented below and returns a pruned version of it - i.e. one with as many zeroed model parameters (weights and biases) as possible, while keeping the mean squared error (MSE) of the predictions as low as possible.

Further down in the notebook you will find a cell containing the place for your function. The cells you are to modify will be very clearly marked!

You will be scored on the basis of the result of the function below (the higher the value, the better):

score(s,ϵ)={0if ϵ>1000(1ϵ1000)1.5s1.5otherwise\mathrm{score}(s, \epsilon) = \begin{cases} 0 & \text{if } \epsilon > 1000 \\ (1 - \frac{\epsilon}{1000})^{1.5} \cdot s ^{1.5} & \text{otherwise} \end{cases}

where:

  • ss - the number of zero model parameters divided by the total number of model parameters (sparsity)
  • ϵ\epsilon - the mean squared error on the test set (MSE)

This criterion and all the functions mentioned above are implemented by us below.

Constraints

  • Your function should return the model in at most 5 minutes on Google Colab with a GPU.

  • The weights file should be saved with the function save_parameters under the name model_parameters.pkl.

  • You may not change the model architecture, i.e. it must have exactly:

    • an input layer of size 128
    • a hidden layer of size 1024
    • the Sigmoid activation function
    • an output layer of size 10

Submission files

  • This notebook
  • The model parameters (weights), saved with the function save_parameters. Do not change the name of the generated file: model_parameters.pkl.

Evaluation

The weights file you provide will be scored. However, you should also provide a working notebook which, after all cells are run with the flag FINAL_EVALUATION_MODE set to True, produces the weights file model_parameters.pkl in under 5 minutes (measured on Google Colab with GPU access).

For this task you can score between 0 and 1.5 points. If your score is below 0.085, you will get 0 points, and if it is above 0.95, you will get 1.5 points. Between these values, your points increase linearly with the value of score.

Starter code

FINAL_EVALUATION_MODE = False  # During checking we will set this flag to True.
######################### DO NOT CHANGE THIS CELL ##########################
import copy
import pickle

import numpy as np
from IPython.display import clear_output
from tqdm.auto import tqdm

import torch
from torch import nn
from torch.utils.data import Dataset, DataLoader
from torch.optim import SGD

np.random.seed(0)
torch.manual_seed(0)

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

print(f"Using {device} device")

Loading the data

######################### DO NOT CHANGE THIS CELL ##########################

# Function that loads the training and validation data as np.array
def load_data_from_file(x_train_path, y_train_path, x_valid_path, y_valid_path):
    X_train = np.load(x_train_path)
    y_train = np.load(y_train_path)

    X_valid = np.load(x_valid_path)
    y_valid = np.load(y_valid_path)

    return X_train, y_train, X_valid, y_valid


# Dataset class
class InMemDataset(Dataset):
    def __init__(self, xs, ys, device='cpu'):
        super().__init__()
        self.dataset = []
        for i in tqdm(range(len(xs))):
            self.dataset.append((torch.tensor(xs[i]).to(device).float(), torch.tensor(ys[i]).to(device).float() ))

    def __len__(self):
        return len(self.dataset)

    def __getitem__(self, idx):
        return self.dataset[idx]
######################### DO NOT CHANGE THIS CELL ##########################

# Let us load the data and create the dataloaders
X_train, y_train, X_valid, y_valid = load_data_from_file(
    "train_data/X_train.npy",
    "train_data/y_train.npy",
    "valid_data/X_valid.npy",
    "valid_data/y_valid.npy",
)

batch_size = 128

_train = InMemDataset(X_train, y_train, device)

_valid = InMemDataset(X_valid, y_valid, device)

loaders = {
    "train" : DataLoader(_train, batch_size=batch_size, shuffle=True),
    "valid" : DataLoader(_valid, batch_size=batch_size, shuffle=False),
}

Code with the scoring criterion

######################### DO NOT CHANGE THIS CELL ##########################

# The overall criterion defined in the task statement
def score(mse_loss, sparsity, mse_weight=1.5, sparsity_weight=1.5):

    if type(mse_loss) == np.ndarray:
        mse_loss[mse_loss > 1000] = 1000
    else:
        if mse_loss > 1000:
            mse_loss = 1000

    score = (1 - mse_loss / 1000) ** mse_weight * sparsity**sparsity_weight
    return score

# Ratio of zeroed weights to all weights
def get_sparsity(model):
    total_params = 0
    zero_params = 0

    for name, param in model.named_parameters():
        if "weight" in name or "bias" in name:
            total_params += param.numel()
            zero_params += torch.sum(param == 0).item()

    sparsity = zero_params / total_params
    return sparsity


# Mean squared error (MSE)
def compute_error(model, data_loader):
    model.eval()

    losses = 0
    num_of_el = 0
    with torch.no_grad():
        for x, y in data_loader:
            outputs = model(x)
            num_of_el += x.shape[0] * y.shape[1]
            losses += model.loss(outputs, y, reduction="sum")

    return losses / num_of_el


def points(score):
    def scale(x, lower=0.085, upper=0.95, max_points=1.5):
        scaled = min(max(x, lower), upper)
        return (scaled - lower) / (upper - lower) * max_points
    return scale(score)

Model Architecture

######################### DO NOT CHANGE THIS CELL ##########################

# Let us define the architecture of our network
class MLP(nn.Module):
    def __init__(self, *args):
        super().__init__()
        self.flatten = nn.Flatten()
        self.layers = nn.Sequential(
            nn.Linear(128, 1024),
            nn.Sigmoid(),
            nn.Linear(1024, 10),
        )

    def forward(self, x):
        x = self.flatten(x)
        logits = self.layers(x)
        return logits

    def loss(self, input, target, reduction="mean"):
        mse_loss = nn.MSELoss(reduction=reduction)
        return mse_loss(input, target)
######################### DO NOT CHANGE THIS CELL ##########################

# Initialisation of the network weights
def init_weights(m):
    ''' Initialize the weights in the module m.'''
    if isinstance(m, nn.Linear):
        torch.nn.init.xavier_normal_(m.weight)
        m.bias.data.fill_(0.01)


# Function for saving the model weights to a file - remember that your weights file must be named: model_parameters.pkl
def save_parameters(model, file_name="model_parameters.pkl", to_file=True):

    params_to_save = {}
    for name, param in model.named_parameters():
        params_to_save[name] = param.to("cpu")
    
    if not to_file:
        return params_to_save
    
    with open(f"{file_name}", "wb") as f:
        pickle.dump(params_to_save, f)


# Function for loading the model weights from a file
def load_parameters(model, file_name="model_parameters.pkl", from_file=True, params=None):

    if from_file:
        with open(f"{file_name}", "rb") as f:
            params_to_load = pickle.load(f)
    else:
        params_to_load = params
        
    for name, param in model.named_parameters():
        with torch.no_grad():
            param[...] = params_to_load[name].to(device)

Model Training

######################### DO NOT CHANGE THIS CELL ##########################

# Function for training the model
def train_model(model: nn.Module,
              data_loaders: dict[str, DataLoader],
              num_epochs: int,
              optimizer: torch.optim.Optimizer,
              verbose: bool =True
              ) -> tuple[torch.Tensor, float]:

    """Function for training the model.

    Args:
        model (torch.nn.Module): The neural network to train.
        data_loaders (dict[str, DataLoader]): A dictionary containing the DataLoaders for the training and validation sets.
        num_epochs (int): The number of epochs to train for.
        optimizer (torch.optim.Optimizer): The optimiser used to train the model.
        verbose (bool, optional): If True, shows the training progress.

    Returns:
        tuple[torch.Tensor, float]: A tuple containing the best set of model parameters found during training and the corresponding value of the loss function on the validation set.
    """
    if FINAL_EVALUATION_MODE:
        verbose = False

    best_epoch = None
    best_params = None
    best_val_loss = np.inf

    for epoch in range(num_epochs):
        model.train()
        _iter = 1
        for inputs, targets in data_loaders['train']:
            optimizer.zero_grad()
            outputs = model(inputs)
            loss = model.loss(outputs, targets)
            loss.backward()
            optimizer.step()

            if verbose:
                if _iter % 10 == 0:
                    print(f"Minibatch {_iter:>6}    |  loss {loss.item():>5.2f}  |")

            _iter +=1

        val_loss = compute_error(model, data_loaders["valid"])

        if val_loss < best_val_loss:
            best_epoch = epoch
            best_val_loss = val_loss
            best_params = [copy.deepcopy(p.detach().cpu()) for p in model.parameters()]

        if verbose:
            clear_output(True)
            m = f"After epoch {epoch:>2} | valid loss: {val_loss:>5.2f}"
            print("{0}\n{1}\n{0}".format("-" * len(m), m))

    if best_params is not None:
        if verbose:
            print(f"\nLoading best params on validation set in epoch {best_epoch} with loss {best_val_loss:.2f}")
        with torch.no_grad():
            for param, best_param in zip(model.parameters(), best_params):
                param[...] = best_param

    return best_params, best_val_loss
######################### DO NOT CHANGE THIS CELL ##########################

initial_model = MLP().to(device)
initial_model.apply(init_weights)

optimizer = SGD(
    initial_model.parameters(),
    lr = 0.01,
    momentum = 0.95,
    weight_decay = 0.001)

best_params, best_val_loss = train_model(initial_model, loaders, num_epochs=100, optimizer=optimizer, verbose=True)

loss = compute_error(initial_model, loaders["valid"])
m = f"| Validation loss: {loss:>5.2f} |"
print("{0}\n{1}\n{0}".format("-" * len(m), m))

Example solution

Below we present a simple solution which is obviously not optimal. Its only purpose is to show how the whole notebook is meant to work.

def starter_pruning_algorithm(model):
    with torch.no_grad():
        model.layers[0].weight[:, 0:2] = 0
    return model
if not FINAL_EVALUATION_MODE:
    # Let us make a deep copy, so as not to change the weights of the trained model
    model_to_prune = copy.deepcopy(initial_model)

    # Let us prune the weights with the example solution
    model_to_prune = starter_pruning_algorithm(model_to_prune)

    # Saving the model parameters (here we changed the file name; you should save as "model_parameters.pkl")
    save_parameters(model_to_prune, "starter_model_parameters.pkl")

    # Now let us see how to load previously saved weights into a newly created model
    new_model = MLP().to(device)
    loss = compute_error(new_model, loaders["valid"])
    print(f"The new model has loss {loss:.3f}")

    # Loading the model parameters
    load_parameters(new_model, "starter_model_parameters.pkl")
    loss = compute_error(new_model, loaders["valid"])
    print(f"After loading the parameters, the model has loss {loss:.3f}")

    mse = compute_error(new_model, loaders["valid"])
    sparsity = get_sparsity(new_model)

    print(f"Model MSE: {mse:.3f} Sparsity: {sparsity:.3f}")
    model_score = score(mse, sparsity)
    print(f"Your model's score is {model_score:.3f}!")
    print(f"Your solution gets {points(model_score):.3f}/1.5 points!")

Your solution

This section is the only place where you may change the code!

def your_pruning_algorithm(model):
    # TODO
    pruned_model = starter_pruning_algorithm(model)
    # Saving the model parameters 
    save_parameters(pruned_model, "model_parameters.pkl")
    return pruned_model

model_to_prune = copy.deepcopy(initial_model)
your_pruning_algorithm(model_to_prune)

Evaluation

The code below will be used to evaluate the solution. After you send us your solution, the function evaluate_algorithm(X_valid, y_valid) will be executed, i.e. code almost identical to the code below will be run on the image directory test_data, which is available only to the task graders.

Before submitting, make sure that the whole notebook (also with the flag set to FINAL_EVALUATION_MODE = True) runs from start to finish without errors and without user intervention, and saves the weights to the file model_parameters.pkl after executing the Run All command. Also check that validation_script.py returns the expected result.

def evaluate(X_test, y_test):
    """Validator"""
    test_model = MLP().to(device)
    load_parameters(test_model)

    batch_size = 128

    _test = InMemDataset(X_test, y_test, device)
    test_loader = DataLoader(_test, batch_size=batch_size, shuffle=False)

    mse = compute_error(test_model, test_loader)
    sparsity = get_sparsity(test_model)

    print(f"Model had error: {mse:.3f} and sparsity: {sparsity:.3f}")
    model_score = score(mse, sparsity)

    return model_score
if not FINAL_EVALUATION_MODE:    
    model_score = evaluate(X_valid, y_valid)
    print(f"Your solution gets score {model_score:.3f} on validation set.")
    print(f"Your solution gets {points(model_score):.3f}/1.5 points on validation set!")

Translated by SOTA. The Polish original is the official version and wins wherever the two differ. The notebook reads the train_data and valid_data folders from the original task folder on GitHub. If you organise this olympiad and would like the translation removed, email [email protected] and we will take it down.

At a glance

You get
X_train.npy / y_train.npy and X_valid.npy / y_valid.npy in the task folder; the starter code trains the initial model with SGD for 100 epochs.
You submit
This notebook and model_parameters.pkl saved with save_parameters.
Scoring
score(s, ε) = 0 if ε > 1000, otherwise (1 − ε/1000)^1.5 · s^1.5, where s is the fraction of zero parameters (sparsity) and ε is the test MSE. 0 points if score < 0.085, 1.5 points if score > 0.95, linear in between.
Rules
  • The function must return the model within 5 minutes on Google Colab with a GPU; the notebook with FINAL_EVALUATION_MODE = True must produce model_parameters.pkl within 5 minutes.
  • The architecture must remain exactly 128 → 1024 (Sigmoid) → 10; neurons and layers may not be removed.
  • The weights file must be named model_parameters.pkl.
Format
Stage I (online, solved at home), 22 April – 27 May 2024; notebook submitted through the Olympiad's submission website and scored automatically. Worth up to 1.5 points of the stage total of 10.

Details

Year
2024, Online
Round
Stage I · Task 6
Language
Polish; English translation by SOTA
License
Not stated by the source