← Retour à l'épreuve

Indonesia IOAI selection 2026 · Correction IOAI Training

Missing Token

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

Métrique
MAE
Évaluée sur
held-out validation (official test labels are private)
Notre score
MAE 0.274 (5-fold CV), baseline 3.37
Niveau attendu
notebook baseline (middle position): MAE 3.37 on CV

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

An alien transmission uses an alphabet of 30 symbols (holk, zyr, plorg...), and each message is 7 to 20 symbols long. In every damaged message exactly one symbol has been removed. You know which symbol is missing but not where it was. From 1 500 training messages, given with the true position, you must predict the position of the missing symbol in 500 test messages. It is a Kaggle simulation from the first training camp (Pelatnas 1) of Indonesia's IOAI 2026 team selection. The statement hints that a hidden "grammar" orders the symbols.

How you are scored

The metric is the mean absolute error between predicted and true positions:

MAE=1n∑i=1n∣k^i−ki∣.\text{MAE} = \frac{1}{n}\sum_{i=1}^{n} \left|\hat k_i - k_i\right| .

The notebook's baseline predicts the middle of the message. The Kaggle leaderboard needs a login, so neither the test labels nor the leaderboard scores are available to us. We evaluate by 5-fold cross-validation on the training set.

First look at the data

Rebuild the complete training messages by inserting the missing symbol at its labelled position, then ask: where does each symbol tend to appear? The answer is striking.

Left: box plots of the relative position (0 = first, 1 = last) of each of the 30 symbols, sorted by median; they climb steadily from holk, always first, to groxx, always last, with narrow boxes. Right: a 30 by 30 matrix of the probability that the row symbol comes before the column symbol, with symbols in that order: almost pure red above the diagonal and blue below, blurred only next to the diagonal.

  • Each symbol has a typical place: holk is almost always first, sark second, ..., orp and groxx last. Sorting the symbols by their mean relative position gives a single hidden total order of the alphabet.
  • A message is a subset of the alphabet written roughly in that order. No symbol repeats inside a message.
  • The order is not perfect. Only 17 % of messages are perfectly sorted, about 13 % of adjacent pairs are swapped, and the violations are local: two thirds of the swapped pairs are within 2 places of each other in the hidden order, and 80 % of messages have no inversion between non-adjacent positions. The right panel shows this: certainty far from the diagonal, uncertainty only next to it.

So the "grammar" is simply sort the symbols, then add a little local noise.

A first baseline

approachCV MAEexact position
middle of the message (official baseline)3.3658.1 %
relative position of the symbol x message length0.86836.7 %

Even the crude second rule, "symbol m usually sits at a fraction rmr_m of the message, so predict rm⋅Lr_m \cdot L", divides the error by four. But it ignores the information that matters most: which symbols are actually present in this message.

The key idea

Use the other tokens of the message as landmarks. If the hidden order were exact, the missing symbol would go right after every present symbol that precedes it in the order, so its position would be

k^=#{ t∈message:rank(t)<rank(m) }.\hat k = \#\{\, t \in \text{message} : \text{rank}(t) < \text{rank}(m) \,\} .

This alone gives CV MAE 0.274, with the exact position in 74 % of cases.

To handle the noise in a principled way, treat it as a probabilistic model. From the training messages estimate, for every pair of symbols, P(a≺b)P(a \prec b), the probability that aa appears before bb (pair counts with add-one smoothing). Inserting mm at slot kk claims that mm comes after the kk tokens on its left and before the others, so under a pairwise-independence approximation

log⁡p(k)=∑j<klog⁡P(sj≺m)+∑j≥klog⁡P(m≺sj)+const.\log p(k) = \sum_{j < k} \log P(s_j \prec m) + \sum_{j \ge k} \log P(m \prec s_j) + \text{const} .

Normalising gives a posterior over the L+1L+1 slots. For an absolute-error metric the best point prediction is the median of this posterior, not its mode (the mean would minimise squared error instead). In practice the posterior is sharply peaked, so the median, the mode and the counting rule almost always agree.

Why can we not do much better? When two neighbouring symbols are swapped in the original message, there is no evidence in the message for which of the two slots next to them the missing symbol occupied. Of the counting rule's errors, 96 % are off by exactly one position. They are the unavoidable cost of the local noise.

The full solution

  1. Rebuild the complete training messages by inserting missing_unit at label.
  2. Count, over all pairs of positions in every message, how often each symbol precedes each other, and turn the counts into smoothed probabilities P(a≺b)P(a \prec b).
  3. For each damaged message, compute the slot log-likelihoods above, normalise them and take the median slot.
def posterior(s, m, P):
    ll = np.array([sum(np.log(P(t, m)) for t in s[:k]) + sum(np.log(P(m, t)) for t in s[k:])
                   for k in range(len(s) + 1)])
    p = np.exp(ll - ll.max()); return p / p.sum()

predict = lambda s, m, P: int(np.searchsorted(np.cumsum(posterior(s, m, P)), 0.5))   # median

The whole model is a 30×3030 \times 30 table of counts, which fits in seconds and is fully interpretable.

Results

5-fold cross-validation on the 1 500 training messages (the official test labels are private):

approachCV MAEexact position
middle of the message (baseline)3.3658.1 %
relative position x length0.86836.7 %
count present tokens ranked before mm0.27473.8 %
pairwise-order posterior, argmax0.27474.5 %
pairwise-order posterior, median (final)0.27474.5 %
Mallows-style posterior (penalty per inversion, θ=1.5\theta = 1.5)0.27174.3 %

The Mallows variant, which scores each slot by e−θ⋅#inversionse^{-\theta \cdot \#\text{inversions}}, is the same idea with a single noise parameter. Its gain of 0.003 is within cross-validation noise, so we keep the parameter-free pairwise model. We started a learned re-ranker (gradient boosting on slot features such as neighbour ranks and pairwise log-probabilities) but did not pursue it: with 96 % of the remaining errors being off-by-one ambiguities created by the noise itself, there is little left for a heavier model to find.

What to remember

  • Rebuild the clean objects and look for structure first. Box plots of positions revealed a hidden total order in minutes; no sequence model was needed.
  • Use the context as landmarks. The other tokens of the message pin the missing one down far better than its average position does.
  • Turn a heuristic into a probabilistic model (pairwise order probabilities, posterior over slots). It handles noise gracefully and makes the uncertainty explicit.
  • Pick the point estimate that matches the metric: the median for MAE, the mean for MSE, the mode for exact-match accuracy.
  • Analyse residual errors before adding complexity: when every remaining error is an irreducible off-by-one, stop.
Code complet (solution.py)
"""Missing Token (Pelatnas 1 IOAI 2026, simulation 2, Indonesia).

The 30 alien symbols follow a hidden total order (with a little local noise: ~13 % of adjacent pairs are swapped).
1. Rebuild the 1500 complete training messages (insert missing_unit at label).
2. Learn P(a before b) for every pair of symbols (Laplace-smoothed counts).
3. For a damaged message, score every insertion slot k by the likelihood that the missing symbol m comes
   after the tokens left of k and before the tokens right of k; normalise into a posterior over slots.
4. Predict the posterior MEDIAN (the MAE-optimal point estimate).
Evaluated by 5-fold cross-validation on train (test labels are private). Writes submission.csv.
"""
import numpy as np, pandas as pd
from collections import defaultdict
from sklearn.model_selection import KFold

tr, te = pd.read_csv("train.csv"), pd.read_csv("test.csv")
COLS = [f"x{i}" for i in range(20)]
clean = lambda df: [[t for t in row if t != "<PAD>"] for row in df[COLS].values]
seqs, miss, y = clean(tr), tr["missing_unit"].tolist(), tr["label"].values
full = [s[:k] + [m] + s[k:] for s, m, k in zip(seqs, miss, y)]

def fit(idx):
    before = defaultdict(lambda: 1.0)                      # add-one smoothing
    pos = defaultdict(list)
    for i in idx:
        s = full[i]
        for j, t in enumerate(s): pos[t].append(j / (len(s) - 1))
        for a in range(len(s)):
            for b in range(a + 1, len(s)): before[(s[a], s[b])] += 1
    P = lambda a, b: before[(a, b)] / (before[(a, b)] + before[(b, a)])
    rank = {t: np.mean(v) for t, v in pos.items()}         # mean relative position = place in the hidden order
    return P, rank

def posterior(s, m, P):
    ll = np.array([sum(np.log(P(t, m)) for t in s[:k]) + sum(np.log(P(m, t)) for t in s[k:]) for k in range(len(s) + 1)])
    p = np.exp(ll - ll.max()); return p / p.sum()

predict = lambda s, m, P: int(np.searchsorted(np.cumsum(posterior(s, m, P)), 0.5))

if __name__ == "__main__":
    err = defaultdict(list)
    for a, b in KFold(5, shuffle=True, random_state=0).split(y):
        P, rank = fit(a)
        for i in b:
            s, m, t = seqs[i], miss[i], y[i]
            err["baseline: middle position"].append(abs(len(s) // 2 - t))
            err["relative position x length"].append(abs(round(rank[m] * len(s)) - t))
            err["count tokens ranked before m"].append(abs(sum(rank[x] < rank[m] for x in s) - t))
            err["pairwise posterior median"].append(abs(predict(s, m, P) - t))
    for k, v in err.items(): print(f"{k:32s} CV MAE {np.mean(v):.4f}   exact {np.mean(np.array(v) == 0):.3f}")
    P, _ = fit(range(len(y)))
    pred = [predict(s, m, P) for s, m in zip(clean(te), te["missing_unit"])]
    pd.DataFrame({"id": range(len(te)), "label": pred}).to_csv("submission.csv", index=False)
    print("submission.csv written")