← Retour à l'épreuve

Romania ONIA 2026 · Correction IOAI Training

Robots in the Maze

Cette page donne toute la méthode. Lisez-la une fois que vous avez cherché.

Métrique
exact match (T1-4) + macro F1 (T5)
Évaluée sur
held-out validation (official test labels are private)
Notre score
T1-4 exact, T5 0.944 macro F1 (CV)
Niveau attendu
full task 5 points at macro F1 >= 0.93

Niveau attendu atteint

Nous avons résolu l'épreuve nous-mêmes : téléchargé les données, mené les expériences et mesuré le score ci-dessous. L'énoncé et les données appartiennent à leurs organisateurs. Cette correction est rédigée en anglais.

The problem in brief

A robotics lab logs one row per experiment: the arena (desert, forest, ice, urban), the weather, and about twenty numbers measured during the run, such as average speed, distance, collisions, items picked up, pauses, turn rate or how consistently the robot patrols. Each robot follows one of four strategies: explorer (visit as many places as possible), collector (pick up items), guardian (patrol the same regions) and sprinter (go fast). You get 1 200 labelled experiments in train.csv and 400 unlabelled ones in test.csv. Four warm-up questions ask for simple statistics of the training table, and the main task asks you to predict the strategy of every test robot.

How you are scored

Tasks 1 to 4 (50 points in total) are checked by exact equality with the value computed from train.csv. Task 5 (50 points) uses the macro F1-score over the K=4K = 4 strategies. For a class cc with precision PcP_c and recall RcR_c:

F1c=2 Pc RcPc+Rc,Macro-F1=14∑c=14F1c.F1_c = \frac{2\,P_c\,R_c}{P_c + R_c}, \qquad \text{Macro-F1} = \frac{1}{4}\sum_{c=1}^{4} F1_c .

The points for task 5 are 50×Macro-F150 \times \text{Macro-F1}, capped at 50 as soon as Macro-F1 reaches 0.93. So the target is clear: 0.93 macro F1 gives full marks, and nothing above it counts.

The test labels are private, so all task 5 scores below come from cross-validation on the training set.

First look at the data

Tasks 1 to 4 are one line of pandas each:

taskcodeanswer
1. number of arena typestrain.arena_type.nunique()4
2. maximum average speedtrain.avg_speed_mps.max()2.641200394995685
3. most frequent arenatrain.arena_type.value_counts().idxmax()forest (414 rows)
4. maximum items collectedtrain.items_collected.max()22

The only trap is task 2. Grading is by exact equality, so do not round the maximum speed: the example file shows 4.31, but the true value has 16 significant digits, and writing 2.64 would lose 15 points. Print it with repr() or let pandas write the float to the CSV.

For task 5, the table is clean: no missing values, classes reasonably balanced (352 explorers, 307 collectors, 304 sprinters, 237 guardians). A few columns are clipped at a bound (99 rows have detour_index exactly −10-10, 185 rows have efficiency_score exactly 10). That is a sign the data is synthetic: every value looks like a draw from a distribution that depends on the strategy, plus noise, then clipped. Some pairs of columns are strongly tied (speed and distance correlate at 0.93, items collected and pickup efficiency at 0.93), as you would expect physically.

The most useful look is the average of each feature per strategy, measured in standard deviations from the overall mean:

Class means of the 12 most discriminative features, in standard deviations

Every strategy has a clear signature that matches its description. Collectors pick up many items (+1.3 std). Guardians are slow, careful (large safety margin, many pauses, accurate scans) and consistent patrollers. Sprinters are fast, with big speed bursts, long distances and few turns. Explorers are the middle class: they turn a lot and synchronise scans with turns, but on most features they sit near the average. Expect the explorers to be the hardest class to recognise.

A first baseline

Always start with the trivial predictor: answering "explorer" for every robot gives macro F1 0.113, because three of the four classes get an F1 of zero. Then try the usual tabular workhorse, a random forest with 500 trees on all features (arena and weather one-hot encoded). With 5-fold cross-validation repeated 3 times it reaches 0.913 macro F1. Gradient boosting does about the same (0.918). That is close, but still under the 0.93 threshold, so around 46 points instead of 50.

The key idea

Look at the shape of the classes before reaching for the most powerful model. The profile figure suggests that each strategy simply shifts the mean of a handful of features, with roughly the same spread in every class. If each class cc is a Gaussian cloud with mean μc\mu_c and a covariance Σ\Sigma shared by all classes, the Bayes-optimal classifier has a closed form. Taking the log of p(x∣c) πcp(x \mid c)\,\pi_c and dropping the terms that do not depend on cc gives a score that is linear in xx:

δc(x)=x⊤Σ−1μc  −  12 μc⊤Σ−1μc  +  log⁡πc,\delta_c(x) = x^\top \Sigma^{-1}\mu_c \;-\; \tfrac12\,\mu_c^\top \Sigma^{-1}\mu_c \;+\; \log \pi_c ,

and we predict the class with the largest δc(x)\delta_c(x). The quadratic term x⊤Σ−1xx^\top\Sigma^{-1}x is the same for every class, so it cancels: the boundaries between classes are hyperplanes. This model is Linear Discriminant Analysis (LDA). It estimates only the four means and one pooled covariance matrix, about 300 numbers in total, so it is very stable with 1 200 rows.

A tree ensemble, by contrast, has to approximate each oblique hyperplane with a staircase of axis-aligned splits, and with 1 200 noisy rows the staircase is coarse. That is exactly why the forest, usually stronger on tabular data, loses here. The data was generated in a way that favours a linear boundary, and a linear model matches that structure.

The figure below projects the training robots on the three LDA directions (the directions that best separate the class means relative to the shared spread). Guardians and sprinters form clean, separate clouds. Collectors and explorers touch each other, and explorers sit in the middle of everything, as the profiles predicted.

Training robots projected on the three discriminant axes

The full solution

  1. Features. Drop robot_id, one-hot encode arena_type and weather, keep all numeric columns as they are. No feature engineering is needed: adding flags for the clipped values did not help (0.939).
  2. Model. Standardise, then fit LinearDiscriminantAnalysis(). Standardising does not change LDA's predictions, but it makes the coefficients comparable between features.
  3. Validation. 5-fold stratified cross-validation repeated 3 times, scored with f1_score(average="macro"). The standard deviation over folds (about 0.015) tells you that differences of 0.003 between linear models are just noise.
  4. Submission. Four GLOBAL rows for tasks 1 to 4 (full-precision speed), then one row per test robot with subtask 5.
X = pd.get_dummies(train.drop(columns=["robot_id", "strategy_label"]),
                   columns=["arena_type", "weather"]).astype(float)
model = make_pipeline(StandardScaler(), LinearDiscriminantAnalysis())
model.fit(X, train["strategy_label"])

The cross-validated confusion matrix of LDA confirms the picture. Guardians (F1 0.962) and sprinters (0.969) are almost always right. The remaining errors are mostly collectors taken for explorers (23) and explorers taken for collectors or sprinters (13 and 11). Explorers have the lowest F1 (0.910), because a robot that explores a lot also collects some items and moves fairly fast: it shares a little of every other signature.

Results

Task 5 scores: macro F1, 5-fold stratified CV repeated 3 times on the 1 200 training rows (the test labels are private).

approachmacro F1 (CV)task 5 points
always "explorer"0.1135.7
random forest, 500 trees0.913 ± 0.01945.7
gradient boosting (HistGradientBoosting)0.91845.9
RBF SVM, C = 10.936 ± 0.01350
logistic regression, C = 0.30.941 ± 0.01550
logistic regression + clipping flags0.939 ± 0.01450
linear SVM0.945 ± 0.01450
LDA (final)0.944 ± 0.01650

With tasks 1 to 4 exact, the expected total is the full 100 points: the final model clears the 0.93 cap by about one standard deviation of the CV estimate.

What to remember

  • Exact-match questions punish rounding. When a grader compares values by equality, print floats at full precision (repr), never the two decimals shown in an example.
  • Look at class profiles first. A table of class means in standard deviations tells you in one glance which features matter and which class will be hard (the one with no strong signature).
  • Match the model to the structure of the data. When classes are shifted Gaussian clouds, the optimal boundary is linear, and LDA or logistic regression beats random forests and boosting, which must build oblique boundaries from axis-aligned steps.
  • Know your threshold. Above 0.93 extra macro F1 earns nothing, so a simple, stable model with a safety margin is better than a fragile one tuned to the third decimal.
  • Read CV noise before comparing models. With a fold-to-fold standard deviation around 0.015, logistic regression, LDA and a linear SVM are tied; pick the simplest.
Code complet (solution.py)
"""Robots in the Maze (ONIA 2026, county round, grades 9-10).

Tasks 1-4: pandas statistics on train.csv. Task 5: predict the strategy
(explorer / collector / guardian / sprinter) with Linear Discriminant Analysis.
Run from the folder that contains train.csv and test.csv (here: work/data/).
Writes submission.csv and, if a figures/ folder is given, the two figures.
"""
import sys, warnings
import numpy as np
import pandas as pd
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import RepeatedStratifiedKFold, cross_val_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

warnings.filterwarnings("ignore")
DATA = sys.argv[1] if len(sys.argv) > 1 else "."
FIG = sys.argv[2] if len(sys.argv) > 2 else None

train = pd.read_csv(f"{DATA}/train.csv")
test = pd.read_csv(f"{DATA}/test.csv")

# ---------- Tasks 1-4: exact statistics on train.csv ----------
t1 = train["arena_type"].nunique()                 # 4
t2 = train["avg_speed_mps"].max()                  # 2.641200394995685 (keep full precision!)
t3 = train["arena_type"].value_counts().idxmax()   # forest
t4 = int(train["items_collected"].max())           # 22
print("Task 1-4:", t1, repr(t2), t3, t4)

# ---------- Task 5: features ----------
def features(df):
    X = df.drop(columns=["robot_id", "strategy_label"], errors="ignore")
    X = pd.get_dummies(X, columns=["arena_type", "weather"]).astype(float)
    return X

X, y = features(train), train["strategy_label"]
Xt = features(test).reindex(columns=X.columns, fill_value=0.0)

models = {
    "random forest": RandomForestClassifier(500, n_jobs=2, random_state=0),
    "logistic regression": make_pipeline(StandardScaler(), LogisticRegression(C=0.3, max_iter=5000)),
    "LDA": make_pipeline(StandardScaler(), LinearDiscriminantAnalysis()),
}
cv = RepeatedStratifiedKFold(n_splits=5, n_repeats=3, random_state=0)
for name, m in models.items():
    s = cross_val_score(m, X, y, cv=cv, scoring="f1_macro", n_jobs=2)
    print(f"{name:20s} macro-F1 {s.mean():.4f} +- {s.std():.4f}")

final = models["LDA"].fit(X, y)
pred = final.predict(Xt)

rows = [{"robot_id": "GLOBAL", "subtaskID": k, "answer": v}
        for k, v in [(1, t1), (2, t2), (3, t3), (4, t4)]]
rows += [{"robot_id": r, "subtaskID": 5, "answer": p} for r, p in zip(test["robot_id"], pred)]
pd.DataFrame(rows).to_csv("submission.csv", index=False)
print("submission.csv written,", len(rows), "rows")

# ---------- Figures ----------
if FIG:
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    plt.rcParams.update({"font.size": 12})
    colors = {"collector": "#1b9e77", "explorer": "#d95f02", "guardian": "#7570b3", "sprinter": "#e7298a"}

    # 1) standardised class means: each strategy has a "signature"
    num = train.select_dtypes("number")
    z = (num - num.mean()) / num.std()
    prof = z.groupby(train["strategy_label"]).mean().T
    prof = prof.loc[prof.abs().max(axis=1).sort_values(ascending=False).index[:12]]
    fig, ax = plt.subplots(figsize=(8, 6.5))
    im = ax.imshow(prof.values, cmap="RdBu_r", vmin=-1.5, vmax=1.5, aspect="auto")
    ax.set_xticks(range(4)); ax.set_xticklabels(prof.columns)
    ax.set_yticks(range(len(prof))); ax.set_yticklabels(prof.index)
    for i in range(prof.shape[0]):
        for j in range(4):
            ax.text(j, i, f"{prof.values[i, j]:+.1f}", ha="center", va="center", fontsize=10)
    fig.colorbar(im, label="class mean (in standard deviations)")
    ax.set_title("Each strategy shifts a few features")
    fig.tight_layout(); fig.savefig(f"{FIG}/profiles.png", dpi=150); plt.close(fig)

    # 2) the 3 LDA axes: classes are almost linearly separable blobs
    Z = final.transform(X)
    fig, axes = plt.subplots(1, 2, figsize=(12, 5.2))
    for ax, (a, b) in zip(axes, [(0, 1), (0, 2)]):
        for c, col in colors.items():
            m = (y == c).values
            ax.scatter(Z[m, a], Z[m, b], s=9, alpha=0.6, c=col, label=c)
        ax.set_xlabel(f"discriminant axis {a+1}"); ax.set_ylabel(f"discriminant axis {b+1}")
    axes[0].legend(markerscale=2, loc="best")
    fig.suptitle("Training robots projected on the LDA axes")
    fig.tight_layout(); fig.savefig(f"{FIG}/lda.png", dpi=150); plt.close(fig)