Discord

Checklist Bulgaria selection 2025 IOAI Team Selection · Task 5

Embedding Compression for Clustering

Compress sentence embeddings to 32 dimensions while preserving their k-means clustering structure.

  • NLP
  • Dimensionality reduction
  • Bulgarian original · English translation

The task

A social-media company classifies user content with high-quality text embeddings (simulated with Sentence-BERT) and wants to move part of the classification onto mobile devices. The contestant writes a transform function that maps the original embeddings (described as 768-dimensional) to 32 dimensions while keeping as much of the clustering ability as possible.

Only the marked implementation cells may be changed. Training models is allowed, but k-means or k-NN may not be used directly inside the transform function. Final submissions are evaluated over 10 runs, so the solution should be robust to random initialisation.

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

In English

This task was published in Bulgarian. SOTA translated its 2 files into English on 17 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 364 words and 8 code cells

Problem

Background

A leading social media company has developed a sophisticated content classification system that helps to organise and categorise the user-generated content on its platform. Its newest artificial intelligence technology produces high-quality embeddings for text content (which we will simulate in this task with the help of Sentence-BERT embeddings).

The challenge

Although the current system works excellently on server infrastructure, the company aims to move part of these classification capabilities directly onto users' mobile devices. This requires a significant reduction of the embedding dimensions, while at the same time preserving as much as possible of the original clustering ability.

Your task

Your task is to develop a transformation function that can transform the original 768-dimensional embeddings into 32-dimensional embeddings, while at the same time preserving the essential information needed for accurate clustering of the content.

Rules

  • Do not change the provided cells, other than those marked for your implementation.
  • Training models is permitted, but directly using K-Means or KNN in the transformation function is forbidden.
  • You may evaluate with fewer or more than 10 runs, but the final submissions will be scored with 10 runs.
  • Hint: Try to make your solution as stable as possible with respect to random initialisations.

Setup

import os
import torch
import pickle
import random
import numpy as np
import torch.nn as nn

from tqdm import tqdm
from sklearn.datasets import fetch_20newsgroups
from sklearn.cluster import KMeans
from sklearn.metrics import normalized_mutual_info_score
from torch.utils.data import DataLoader, Dataset
from sklearn.model_selection import train_test_split
from sentence_transformers import SentenceTransformer
def set_seed(seed_value = 42):
  random.seed(seed_value)
  np.random.seed(seed_value)
  torch.manual_seed(seed_value)
  if torch.cuda.is_available():
      torch.cuda.manual_seed(seed_value)
      torch.cuda.manual_seed_all(seed_value)
      torch.backends.cudnn.deterministic = True
      torch.backends.cudnn.benchmark = False

set_seed()
class TextDataset(Dataset):
    def __init__(self, texts, labels, model_name='all-MiniLM-L6-v2'):
        self.texts = texts
        self.labels = labels
        self.model = SentenceTransformer(model_name)
        self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
        self.model.to(self.device)

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

    def __getitem__(self, idx):
        text = self.texts[idx]
        with torch.no_grad():
            emb = self.model.encode(text, convert_to_tensor=True)
        label = self.labels[idx]
        return emb, label
def load_and_process_split(split_name, model_name='all-MiniLM-L6-v2'):
    with open(f"{split_name}_texts.pkl", "rb") as f:
        texts, labels = pickle.load(f)

    dataset = TextDataset(texts, labels, model_name=model_name)
    loader = DataLoader(dataset, batch_size=32, shuffle=False)

    X = []
    y = []
    for emb, label in tqdm(loader):
        X.append(emb.cpu().numpy())
        y.append(label.numpy())

    return np.vstack(X), np.concatenate(y)

Main code

X768, y = load_and_process_split("public")
k = 4
set_seed()
km768 = KMeans(n_clusters=k, random_state=0).fit(X768)
public_clusters = km768.labels_
def transform(X_high):
    assert X_high.ndim == 2 and X_high.shape[1] >= 32, "Input must be 2D with >=32 dims."
    ### TODO: YOUR CODE HERE
    out = X_high[:, :32]
    ###

    assert out.ndim == 2 and out.shape[1] == 32, f"Embeddings must be shape (N,32), got {out.shape}."
    return out
def evaluate_clustering(ref_labels, n_clusters, n_runs=10):
    nmi_scores = []
    for seed in range(n_runs):
        set_seed(seed)
        X32 = transform(X768)
        km = KMeans(n_clusters=n_clusters, random_state=0).fit(X32)
        pred = km.labels_
        nmi = normalized_mutual_info_score(ref_labels, pred)
        nmi_scores.append(nmi)

    mean_nmi = np.mean(nmi_scores)
    std_nmi = np.std(nmi_scores)
    return mean_nmi, std_nmi

mean_nmi, std_nmi = evaluate_clustering(public_clusters, k)
print(f"\nPublic NMI (first-32 dims): {mean_nmi:.4f} ± {std_nmi:.4f}")

Understanding the Evaluation Metric: NMI

Normalized Mutual Information (NMI) is a measure that tells us how well two different clustering assignments agree with each other. It's particularly well-suited for this challenge for several reasons:

  • Scale Independence: NMI is normalized between 0 (no mutual information) and 1 (perfect correlation), making it easy to interpret regardless of the number of clusters or data points.

  • Permutation Invariance: NMI doesn't require the cluster labels to match exactly - it only cares about the overall grouping structure. This is important because k-means can assign different numerical labels to the same logical clusters in different runs.

In our case, we use NMI to compare:

  • The clustering obtained from the original 768-dimensional embeddings (reference)
  • The clustering obtained from your transformed 32-dimensional embeddings

A higher NMI score means your transformation better preserves the original clustering structure, which is exactly what we want for the mobile deployment scenario.

Translated by SOTA. The Bulgarian original is the official version and wins wherever the two differ. The task statement speaks of 768-dimensional embeddings, but the notebook's all-MiniLM-L6-v2 model produces 384-dimensional ones; the copy of the statement in the solution says 384 and adds a submission section. The solution is a sample solution by a member of the 2025 national team. 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
public_texts.pkl (texts and labels; private_texts.pkl is also in the repository), embedded in the notebook with a SentenceTransformer model.
You submit
The notebook with the implemented transform(X_high) returning an (N, 32) array.
Scoring
Normalised mutual information between the k-means clustering of the original embeddings (reference) and the k-means clustering of the transformed 32-dimensional embeddings, averaged over 10 seeded runs.
Rules
  • No k-means or k-NN inside transform.
  • Do not modify cells other than the marked ones.
Format
Bulgarian IOAI 2025 team selection, Day 2, Task 5. Dates and format are not published in the repository.

Details

Year
2025
Round
IOAI Team Selection · Task 5
Language
Bulgarian; English translation by SOTA
License
Not stated by the source