Checklist Bulgaria selection 2026 IOAI Team Selection · Task 1
Hallucinations
Bulgarian title: Халюцинации
Build at most 16 unsupervised features that let a fixed gradient-boosting classifier tell correct, hallucinated and incoherent image captions apart.
The task
A research team used a large language model to caption a large image collection; some captions hallucinate objects that are not in the image, and some are incoherent. The contestant must build a filter that labels each caption as correct (class 0), hallucinated with a substituted noun (class 1) or incoherent with shuffled words (class 2).
The classifier is a fixed, pre-set GradientBoostingClassifier whose hyperparameters may not be changed, and no other models may be added. At most 16 features may be passed to it, and they must be built with unsupervised methods only: labels may be used for analysis and selection but not to train the feature pipeline. Features may use the provided CLIP model through transformers (image-text similarity), nltk (only nltk.tokenize, nltk.util and nltk.lm), torch and numpy (for processing, not training), and linguistic information (tokens, POS tags, Penn Treebank tags, stop-word flags, lemmas) obtained only through the provided helper get_linguistic_features(caption), which wraps spaCy.
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 3 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.
- Task notebook Bulgarian original of Task notebook
- Official solution (probing and entropy features) Bulgarian original of Official solution (probing and entropy features)
- Official solution (SVD features) Bulgarian original of Official solution (SVD features)
Read the task notebook in English
!pip install numpy==2.2.6 \
torch==2.6.0 \
torchvision==0.21.0 \
nltk==3.9.4 \
spacy==3.8.14 \
matplotlib==3.10.9 \
tqdm==4.67.3 \
transformers==5.9.0 \
datasets==4.8.5 \
scikit-learn==1.7.2
!python -m spacy download en_core_web_sm
Hallucinations
You are a researcher in an artificial intelligence laboratory and you lead an important project to create a large-scale multimodal dataset. To automate and speed up the process, your team has used a Large Language Model (LLM) to generate text descriptions (captions) for hundreds of thousands of images.
Unfortunately, data annotation by an LLM often produces serious deviations. Because of decoding defects or a lack of sufficient visual context, the model sometimes starts to "hallucinate" objects that do not exist in the picture at all (for example, it claims to see a dog when there is a cat in the picture), or it generates completely incoherent and illogical text (incoherent text). Your task is to clean this dataset by building a robust filter that recognises whether a given caption is correct, hallucinated or completely incoherent.
Task and constraints
The system you are developing will be integrated into an environment with extremely strict memory and compute-time limits. For this reason, the project's chief engineer has imposed the following iron rules on your solution:
-
Fixed classifier: You must use the predefined GradientBoostingClassifier. It is strictly forbidden to change its hyperparameters or to add other models (such as neural networks) to the classification pipeline.
-
Number of features: You may construct and pass at most 16 features to the classifier. You will have to choose carefully which metrics carry the most semantic and visual information.
-
No training (unsupervised): Only
unsupervisedmethods may be used to construct the features. The labels in the data may be used only for analysis and selection, not for training your feature-extraction pipeline. -
Libraries: To extract these features you may use only:
transfomers– for extracting embeddings and computing the semantic similarity between the image and the text, but only and exclusively the loaded model.nltk– Only and exclusively the following submodules are allowed:nltk.tokenizenltk.utilnltk.lm
torchandnumpy- For processing the embeddings, but not for trainingspacy– importing and using spacy directly in your code is forbidden. All linguistic information (Parts-Of-Speech tags, tokens) must be extracted only through the helper function provided by the senior researchers:get_linguistic_features(caption).
No other external libraries are allowed!
Data
The dataset is loaded with the datasets library and contains the following key fields for each example:
image: The image (PIL Image).caption: The text description generated by the LLM.label: The target class you have to predict (0, 1 or 2).
The labels correspond to the three possible states of the text:
- Class 0: Correct caption.
- Class 1: Hallucination (substituted noun).
- Class 2: Incoherent text (shuffled words).
For your experiments you have a training split (train - 2400 examples) and a validation split (val - 800 examples without labels), both balanced. Using external data is forbidden. It is recommended that you look at the raw data to build an intuition for the differences between the classes before you start extracting features.
Available tools
To make the task at least a little easier, the senior researchers on the team have prepared a helper function for you - get_linguistic_features(caption). It uses spacy and automatically returns the parts of speech (Part-of-Speech tags) of the words in the given sentence.
Description of the elements returned by get_linguistic_features()
| Key | Type | Description | Possible values and meaning |
|---|---|---|---|
tokens |
list[str] |
A list of all tokens (words, numbers, punctuation marks, etc.) in the text. | Arbitrary text strings, for example: "cat", "running", ".", "2025". Each element is an original token from the text. |
pos |
list[str] |
Universal grammatical category (Part-of-Speech) for each token. | ADJ – adjective;ADP – adposition (preposition);ADV – adverb;AUX – auxiliary verb;CCONJ – coordinating conjunction;DET – determiner;INTJ – interjection;NOUN – noun;NUM – numeral;PART – particle;PRON – pronoun;PROPN – proper noun;PUNCT – punctuation mark;SCONJ – subordinating conjunction;SYM – symbol;VERB – verb;X – other/unrecognised type;SPACE – space. |
tag |
list[str] |
Detailed grammatical tag according to the Penn Treebank. | NN – noun, singular;NNS – noun, plural;NNP – proper noun, singular;NNPS – proper noun, plural;VB – verb, base form;VBD – verb, past tense;VBG – verb in the -ing form;VBN – past participle;VBP – present tense (not 3rd person singular);VBZ – present tense (3rd person singular);JJ – adjective;JJR – comparative adjective;JJS – superlative adjective;RB – adverb;RBR – comparative adverb;RBS – superlative adverb;PRP – personal pronoun;PRP$ – possessive pronoun;DT – determiner;IN – preposition or subordinating conjunction;CC – coordinating conjunction;CD – cardinal number;. – end of sentence;, – comma. |
is_stop |
list[bool] |
Shows whether the token is a stop word. | True – the token is a stop word (e.g. the, and, is, of);False – the token is not a stop word. |
lemma |
list[str] |
Base (dictionary) form of the word. | A text string, for example:"cats" → "cat";"running" → "run";"are" → "be";"better" → "good". |
Example
Input:
caption = "The cats are running quickly."
Output:
{
"tokens": ["The", "cats", "are", "running", "quickly", "."],
"pos": ["DET", "NOUN", "AUX", "VERB", "ADV", "PUNCT"],
"tag": ["DT", "NNS", "VBP", "VBG", "RB", "."],
"is_stop": [True, False, True, False, False, False],
"lemma": ["the", "cat", "be", "run", "quickly", "."]
}
Your goal is to combine this linguistic information creatively (sentence structure, number of nouns, grammatical correctness) with CLIP's visual understanding (whether the text really matches the picture) in order to choose the perfect 16 features and save the laboratory's data!
Scoring
Scoring is run on a hidden dataset (test split), and the main metric for ranking the models is macro F1, to ensure equally good recognition of all three classes. You can see your preliminary score by submitting predictions for the validation split on Kaggle. The last notebook you saved with the Save & Run All (Commit) option is the one that counts.
import numpy as np
import torch
import nltk
import spacy
import pandas as pd
import matplotlib.pyplot as plt
from tqdm import tqdm
from transformers import CLIPProcessor, CLIPModel
from datasets import load_dataset
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
# 1. NLTK Setup
print("Downloading NLTK resources (punkt)...")
nltk.download("punkt", quiet=True)
nltk.download("punkt_tab", quiet=True)
# 2. spaCy Setup
print("Loading spaCy (en_core_web_sm)...")
nlp = spacy.load("en_core_web_sm")
def get_linguistic_features(caption: str) -> dict:
"""
Helper function that provides only raw linguistic data.
"""
doc = nlp(caption)
return {
"tokens": [t.text for t in doc],
"pos": [t.pos_ for t in doc],
"tag": [t.tag_ for t in doc],
"is_stop": [t.is_stop for t in doc],
"lemma": [t.lemma_ for t in doc]
}
# 3. Loading the data
print("Loading the training and validation data...")
labeled_data = load_dataset("delyanboychev/hallucinations_problem_data")
print(f"Successfully loaded splits: {list(labeled_data.keys())}")
# 4. CLIP Setup
print("Initialising the CLIP model...")
if torch.cuda.is_available():
device = "cuda"
elif torch.backends.mps.is_available():
device = "mps"
else:
device = "cpu"
print(f"Device used: {device.upper()}")
model_id = "openai/clip-vit-base-patch32"
processor = CLIPProcessor.from_pretrained(model_id)
model = CLIPModel.from_pretrained(model_id).to(device)
model.eval()
print("All resources and data were loaded successfully!")
Visualisation
# Helper function provided by the senior researchers :)
def get_pos_tags(text):
"""
Returns a structure suitable for visualisation (text)
and for mathematical processing (dict).
"""
features = get_linguistic_features(text)
readable_tags = [f"{token} ({pos})" for token, pos in zip(features["tokens"], features["pos"])]
return readable_tags
# Dictionary to make the classes easier to read
label_map = {
0: "0: Original (Correct)",
1: "1: Substituted object (Hallucination)",
2: "2: Shuffled words (Incoherent)"
}
# We visualise exactly 3 examples
for i in range(3):
sample = labeled_data["train"][i]
image = sample["image"]
caption = sample["caption"]
label = sample["label"]
print("\n" + "="*60)
print(f"Example {i+1}")
print(f"Label: {label_map.get(label, 'Unknown')}")
print(f"Text (Caption): {caption}")
print("-" * 60)
# We call the helper function
pos_tags = get_pos_tags(caption)
print("Parts of speech (POS Tags):")
print(" | ".join(pos_tags))
print("="*60)
# We show the image itself
plt.figure(figsize=(4, 4))
plt.imshow(image)
plt.title(f"Label: {label}")
plt.axis("off")
plt.show()
Solution
# !!! YOU MAY IMPORT SPECIFIC MODULES AND KEEP GLOBAL VARIABLES, BUT ONLY IN THIS CELL !!!
def extract_features(dataset_split, batch_size=64, is_train=False):
"""
Extracts features in batches.
Returns ONLY the matrix X (with features, max 64 columns).
is_train can help you if you need to keep some state extracted from
the training set that you can use at test time.
"""
# =========================================================
# THIS IS WHERE YOU MUST WRITE YOUR SOLUTION
# =========================================================
# Basic functions are given to help you with the solution!
def get_simple_nltk_feature(text: str) -> float:
"""Example: Lexical diversity using NLTK."""
tokens = nltk.word_tokenize(text.lower())
return len(set(tokens)) / len(tokens) if tokens else 0.0
def get_noun_density(ling_data: dict) -> float:
"""Example: Noun density, using ling_data."""
nouns = [1 for pos in ling_data["pos"] if pos == "NOUN"]
return sum(nouns) / len(ling_data["tokens"]) if ling_data["tokens"] else 0.0
# =========================================================
X = []
for i in tqdm(range(0, len(dataset_split), batch_size), desc="Extracting features"):
batch = dataset_split[i : i + batch_size]
images = batch["image"]
captions = batch["caption"]
# --- Step 1: Extracting linguistic primitives ---
# We use the mandatory helper function
ling_results = [get_linguistic_features(cap) for cap in captions]
# --- Step 2: Computing the CLIP features ---
inputs = processor(
text=captions, images=images, return_tensors="pt", padding=True
).to(device)
with torch.no_grad():
outputs = model(**inputs)
# Normalising the embeddings
image_embeds = outputs.image_embeds / outputs.image_embeds.norm(p=2, dim=-1, keepdim=True)
text_embeds = outputs.text_embeds / outputs.text_embeds.norm(p=2, dim=-1, keepdim=True)
# Batched Cosine Similarity
cosine_sims = (image_embeds * text_embeds).sum(dim=-1).cpu().numpy()
# --- Step 3: Assembling the final vector ---
for idx, sim in enumerate(cosine_sims):
# ATTENTION: Add your new features to this list!
# Maximum limit: 16 elements.
# We take the ready-made data for the current example
ling_data = ling_results[idx]
# We compute the features
nltk_feat = get_simple_nltk_feature(captions[idx])
noun_feat = get_noun_density(ling_data)
feature_vector = [sim, nltk_feat, noun_feat]
X.append(feature_vector)
return np.array(X)
Scoring
# ==========================================
# ATTENTION: IT IS STRICTLY FORBIDDEN TO CHANGE THIS BLOCK!
# ==========================================
BATCH_SIZE = 64
print("--- Processing the Train Split ---")
# We take the labels for training
y_train = np.array(labeled_data["train"]["label"])
safe_train_data = labeled_data["train"].remove_columns("label")
safe_val_data = labeled_data["val"].remove_columns("label")
X_train = extract_features(safe_train_data, batch_size=BATCH_SIZE, is_train=True)
print("\n--- Processing the Val Split ---")
X_val = extract_features(safe_val_data, batch_size=BATCH_SIZE, is_train=False)
assert X_train.shape[1] <= 16, f"Error: You are using {X_train.shape[1]} features, and the limit is 16!"
assert X_val.shape[1] <= 16, f"Error: The validation set has {X_val.shape[1]} features!"
print("\nTraining the fixed Gradient Boosting Classifier...")
# We use a pipeline with StandardScaler for numerical stability
clf = make_pipeline(
StandardScaler(),
GradientBoostingClassifier(
n_estimators=400,
max_depth=5,
learning_rate=0.05,
subsample=0.8,
max_features="sqrt",
min_samples_leaf=5,
random_state=42,
),
)
# We train the model
clf.fit(X_train, y_train)
# We predict on the validation set
print("\nGenerating predictions...")
y_val_pred = clf.predict(X_val)
# Creating a file for submission to Kaggle
print("Creating submission.csv...")
submission_df = pd.DataFrame({
"id": labeled_data["val"]["id"],
"label": y_val_pred
})
submission_df.to_csv("submission.csv", index=False)
print("\n" + "=" * 50)
print("DONE! The file 'submission.csv' was saved successfully.")
print("You can download it and submit it to Kaggle to see your score!")
print("=" * 50)
Translated by SOTA. The Bulgarian original is the official version and wins wherever the two differ. The task notebook loads the data from the Hugging Face dataset delyanboychev/hallucinations_problem_data. The two solution notebooks carry an earlier version of the statement (a local copy of the data, a labelled validation split of 600 examples, and '64 features' in its closing sentence), so their printed results come from that version. 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
- A Hugging Face dataset (delyanboychev/
hallucinations_problem_data) with image (PIL), caption and label; balanced train split of 2,400 labelled examples and validation split of 800 unlabelled examples; the pre-loaded CLIP model; the helper function. - You submit
- The executed notebook; predictions for the validation split can be submitted to Kaggle for a preliminary score, and the last notebook saved with 'Save & Run All (Commit)' is taken.
- Scoring
- Macro F1 on a hidden test split.
- Rules
- Fixed GradientBoostingClassifier; at most 16 features.
- Unsupervised feature construction only.
- Only transformers (the loaded model), the listed nltk submodules, torch, numpy and the provided spaCy helper; no other libraries; no external data.
- Format
- Bulgarian IOAI 2026 team selection (after the National Competition); Day 1, Task 1. Dates are not published in the repository. The statement refers to a Kaggle submission.