← Back to the problem

Indonesia IOAI selection 2025 · Correction by IOAI Training

Shuffled Documents

This page gives the full approach. Read it once you have searched.

Metric
accuracy
Evaluated on
official test
Our score
96 % accuracy (100 test documents)
Expected level
baseline 0 %; 100 pts = best contestant (unpublished)

We solved the problem ourselves: downloaded the data, ran the experiments and measured the score below. The statement and data belong to their organisers.

The problem in brief

Every document in the collection is an Indonesian text of exactly two paragraphs, on topics from Balinese cooking to video games. The first paragraphs are stored in one list and the second paragraphs in another, and the second list has been shuffled. You must write a function that, given both lists, returns for each first paragraph the index of the second paragraph that continues it. A labelled development set of 25 documents is provided; the real test set was only released in the last hour of the contest, without labels.

The statement announces 300 test documents; the published test set (afaji/wikipair-test) actually holds 100. The organisers have since published the answers (afaji/wikipair-test-answer), so we score on the official test set.

How you are scored

Accuracy of the returned list:

acc=1N∑i=1N1[answeri=σ(i)],\text{acc} = \frac{1}{N}\sum_{i=1}^{N} \mathbf{1}\big[\text{answer}_i = \sigma(i)\big],

where σ\sigma is the true pairing. Random guessing gets 1/N1/N, so 1 % on the test set. Points are relative to the best contestant, with a 0 % baseline:

points=100⋅accaccbest.\text{points} = 100 \cdot \frac{\text{acc}}{\text{acc}_{\text{best}}}.

The best contestant's accuracy is not published, so there is no fixed target.

First look at the data

Paragraphs are short (about 4 sentences, 470 to 530 characters) and well written. Two things stand out.

  1. The second paragraph almost always names its subject again. "Proses pembuatan Bebek betutu dimulai...", "Perkembangan Kecerdasan Buatan bermula...". So the pairs share rare words, and that should make the task easy.
  2. The setters planted look-alike topics. The test set contains pairs of documents on nearly the same subject: Lionel Messi and Neymar Jr (both footballers, both at Barcelona), Pemanasan Global (global warming) and Perubahan iklim (climate change), Briyani and Kebab (both spiced meat dishes). These pairs are where all the difficulty lives.

The dev set has none of these traps: every method we tried scores 100 % on dev, so dev cannot tell methods apart. Keep this in mind, it shapes how we choose the final model.

A first baseline

Represent each paragraph by its word TF-IDF vector, compute the cosine similarity of every (first, second) pair, and for each first paragraph pick the most similar second paragraph. On test, this gets 86 %, far above the 1 % of random guessing.

Look at the predictions, though: 12 of the 100 second paragraphs are chosen by two different first paragraphs, which is impossible, since every second paragraph belongs to exactly one document. The baseline throws away a piece of information that the statement gives us for free.

The key idea

The answer is a permutation, so decode it as one. Let SijS_{ij} be the similarity between first paragraph ii and second paragraph jj. Choosing a partner for each row independently ("greedy") maximises each Si,π(i)S_{i,\pi(i)} separately and allows collisions. What we really want is the pairing that is best as a whole:

π^=arg⁡max⁡π∈permutations∑i=1NSi,π(i).\hat\pi = \arg\max_{\pi \in \text{permutations}} \sum_{i=1}^{N} S_{i,\pi(i)} .

This is the classic assignment problem. There are N!N! permutations, but the Hungarian algorithm solves it exactly in O(N3)O(N^3) (scipy.optimize.linear_sum_assignment, a millisecond for N=100N=100). Why does it help? When two first paragraphs both prefer the same second paragraph jj, only one of them can have it. The other is forced to its second choice, which is very often its true partner: the collision was caused by a strong confuser, not by a missing partner. One-to-one decoding turns "two near-ties" into "one swap at worst". It adds 6 to 10 points to every scorer we tried.

The second ingredient is the similarity itself, and here the trap topics matter. A bag of words sees that the Messi paragraph and the Neymar paragraph share "Barcelona", "teknik", "visi permainan". A multilingual sentence encoder (we use multilingual-e5-small) maps each paragraph to a vector that represents its meaning, so paraphrases of the same idea land close together even with different words.

Choosing between scorers without labels. The dev set is saturated, and tuning on the test answers would be cheating. But the test texts were available, and we can build a labelled task from them for free: cut every paragraph into its first and second halves (by sentences), and ask each scorer to re-match the halves among the 100 first paragraphs, then among the 100 second paragraphs. The answer to this proxy task is known by construction, and it contains the same look-alike topics. On it, TF-IDF scorers re-match only 53 to 57 % of the halves and e5 re-matches 95 %. So our submission is e5 similarities + Hungarian assignment, chosen before looking at the test answers.

Left: e5 similarity matrix with the true partner on the diagonal. Right: proxy accuracy and test accuracy for each scorer, greedy and one-to-one

The left panel shows why the task is solvable: the diagonal stands out clearly against an almost uniform background, except for a few bright off-diagonal cells, which are exactly the look-alike pairs.

The full solution

def predict_pair(first_list, second_list):
    E1 = model.encode(["query: " + x for x in first_list], normalize_embeddings=True)
    E2 = model.encode(["query: " + x for x in second_list], normalize_embeddings=True)
    S = E1 @ E2.T                                   # cosine similarities, N x N
    rows, cols = linear_sum_assignment(-S)           # best permutation (maximise total S)
    return cols.tolist()
  1. Encode all paragraphs with the same encoder (e5 expects a "query: " prefix for symmetric similarity tasks) and L2-normalise, so the dot product is the cosine similarity.
  2. Build the N×NN\times N similarity matrix.
  3. Solve the assignment problem on −S-S (scipy minimises a cost).

It runs in a few seconds on a laptop CPU for 100 documents (plus a one-off 470 MB model download).

Results

Official test set, 100 documents. "Proxy" is the label-free half-paragraph task built from the test texts, the only signal used to choose the scorer.

scorerdevproxy (no labels)test, greedytest, one-to-one
random4 %1 %
word TF-IDF (sublinear tf)100 %53 %86 %94 %
char 3-5-gram TF-IDF100 %54 %82 %92 %
word TF-IDF (raw tf)100 %57 %91 %98 %
e5-small embeddings (submitted)100 %95 %93 %96 %

Our submission scores 96 %. Its 4 errors are two swapped pairs: Messi with Neymar, and global warming with climate change. Since the assignment is a permutation, errors always come in such cycles.

An honest twist: plain word TF-IDF with raw term frequency reaches 98 %, but the proxy task would not have picked it. Looking at the errors afterwards, the reason is clear. Second paragraphs repeat their subject's name several times ("Neymar" appears 4 times in his second paragraph), and raw term frequency rewards that repetition while the sublinear version 1+log⁡tf1 + \log \text{tf} dampens it. The proxy task cannot see this effect, because a half paragraph rarely repeats a name. That is the limit of a proxy: it is only as good as its resemblance to the real task. Neither method separates Pemanasan Global from Perubahan iklim: the two topics are close to synonyms, and each paragraph mentions its own label only once.

What to remember

  • Use the structure of the output. When the answer is a matching, a ranking or a partition, decode it with the right combinatorial solver (Hungarian algorithm, sorting, flows) instead of independent argmaxes. It is free accuracy.
  • Sentence embeddings beat word overlap when the confusers share vocabulary; TF-IDF remains a strong, fast baseline when documents repeat rare names.
  • When the validation set is saturated or missing, build a proxy task from unlabelled data (split, mask or shuffle the texts you have): it lets you choose a method without touching the test labels.
  • Check that your proxy looks like the real task: ours rewarded meaning but could not see name repetition, and missed a slightly better scorer.
  • Errors in a permutation come in cycles: one confusion always costs at least two answers.
Full code (solution.py)
"""Shuffled Documents / Acak Dokumen (OSN AI 2025, Indonesia, exhibition final) -- worked solution.

Task: first[i] and second[j] are the two paragraphs of Indonesian documents, second is shuffled.
Return answer[i] = index of the second paragraph that continues first[i]. Metric: accuracy.

Data (Hugging Face, no login), downloaded into DATA_DIR:
  afaji/wikipair-dev, afaji/wikipair-dev-answer (25 docs), afaji/wikipair-test,
  afaji/wikipair-test-answer (100 docs; labels are 1-based)
  files: <name>/data/{first_part,second_part,answer}-00000-of-00001.parquet
Model: intfloat/multilingual-e5-small (safetensors, 470 MB), any multilingual sentence encoder works.

Pipeline: (1) score every (first, second) pair, (2) solve the ONE-TO-ONE assignment with the
Hungarian algorithm instead of an independent argmax per row. The scorer is chosen with a
label-free proxy task built from the test texts themselves (split each paragraph in two halves).

Run: python solution.py DATA_DIR [MODEL_DIR] [--figures]
"""
import re, sys
import numpy as np
import pandas as pd
import torch
from scipy.optimize import linear_sum_assignment
from sklearn.feature_extraction.text import TfidfVectorizer

torch.set_num_threads(2)
args = [a for a in sys.argv[1:] if not a.startswith("--")]
DATA_DIR = args[0] if args else "work"
MODEL_DIR = args[1] if len(args) > 1 else "intfloat/multilingual-e5-small"


def load(split):
    f = pd.read_parquet(f"{DATA_DIR}/wikipair-{split}/first_part.parquet").text.astype(str).tolist()
    s = pd.read_parquet(f"{DATA_DIR}/wikipair-{split}/second_part.parquet").text.astype(str).tolist()
    g = pd.read_parquet(f"{DATA_DIR}/wikipair-{split}-answer/answer.parquet").label.astype(int) - 1
    return f, s, g.to_numpy()


# ------------------------------------------------------------------------------------------------
# Scorers: S[i, j] = how well second[j] continues first[i]
# ------------------------------------------------------------------------------------------------
def tfidf_scores(f, s, **kw):
    vec = TfidfVectorizer(**kw).fit(f + s)
    return (vec.transform(f) @ vec.transform(s).T).toarray()


_model = None
def e5_scores(f, s):
    global _model
    if _model is None:
        from sentence_transformers import SentenceTransformer
        _model = SentenceTransformer(MODEL_DIR, device="cpu")
    enc = lambda xs: _model.encode(["query: " + x for x in xs], normalize_embeddings=True)
    return enc(f) @ enc(s).T          # cosine similarity of the two embeddings


SCORERS = {
    "word TF-IDF (sublinear tf)": lambda f, s: tfidf_scores(f, s, sublinear_tf=True),
    "word TF-IDF (raw tf)": lambda f, s: tfidf_scores(f, s),
    "char 3-5-gram TF-IDF": lambda f, s: tfidf_scores(f, s, analyzer="char_wb", ngram_range=(3, 5),
                                                      sublinear_tf=True),
    "e5-small embeddings": e5_scores,
}


# ------------------------------------------------------------------------------------------------
# Decoding: greedy argmax vs one-to-one assignment
# ------------------------------------------------------------------------------------------------
def greedy(S):
    return S.argmax(1)


def hungarian(S):
    rows, cols = linear_sum_assignment(-S)     # maximise the total similarity, each j used once
    return cols[np.argsort(rows)]


def predict_pair(first_list, second_list):
    """The function the notebook asks for (final choice: e5 + Hungarian)."""
    return hungarian(e5_scores(first_list, second_list)).tolist()


# ------------------------------------------------------------------------------------------------
# Label-free proxy validation: cut each paragraph in two halves and re-match the halves
# ------------------------------------------------------------------------------------------------
def halves(paras):
    A, B = [], []
    for p in paras:
        sents = [x for x in re.split(r"(?<=[.!?])\s+", p.strip()) if x]
        k = len(sents) // 2
        if k:
            A.append(" ".join(sents[:k])); B.append(" ".join(sents[k:]))
    return A, B


def proxy_accuracy(scorer, f, s):
    accs = []
    for paras in (f, s):              # firsts among themselves, seconds among themselves
        A, B = halves(paras)
        accs.append(np.mean(hungarian(scorer(A, B)) == np.arange(len(A))))
    return float(np.mean(accs))


def main():
    fd, sd, gd = load("dev")
    f, s, g = load("test")
    print(f"dev {len(fd)} docs, test {len(f)} docs")
    rows = []
    for name, scorer in SCORERS.items():
        S = scorer(f, s)
        rows.append({"scorer": name,
                     "dev (hungarian)": np.mean(hungarian(scorer(fd, sd)) == gd),
                     "proxy (no labels)": proxy_accuracy(scorer, f, s),
                     "test greedy": np.mean(greedy(S) == g),
                     "test hungarian": np.mean(hungarian(S) == g),
                     "greedy collisions": len(S) - len(set(greedy(S)))})
    print(pd.DataFrame(rows).round(3).to_string(index=False))

    pred = np.array(predict_pair(f, s))
    print(f"FINAL e5 + Hungarian: test accuracy {np.mean(pred == g):.3f}; "
          f"errors (i, predicted, gold): {[(int(i), int(pred[i]), int(g[i])) for i in np.where(pred != g)[0]]}")
    if "--figures" in sys.argv:
        figures(f, s, g, rows)


def figures(f, s, g, rows):
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    plt.rcParams.update({"font.size": 11})
    S = e5_scores(f, s)[:, g]                 # reorder columns: gold partner on the diagonal
    fig, axes = plt.subplots(1, 2, figsize=(13, 5.2), gridspec_kw={"width_ratios": [1, 1.15]})
    im = axes[0].imshow(S, cmap="viridis")
    axes[0].set_xlabel("second paragraph (sorted so the true partner is on the diagonal)")
    axes[0].set_ylabel("first paragraph i")
    axes[0].set_title("e5 cosine similarity, 100 x 100")
    fig.colorbar(im, ax=axes[0], fraction=0.046)
    names = [r["scorer"].replace(" (", "\n(") for r in rows]
    x = np.arange(len(rows)); w = 0.27
    for k, (key, col) in enumerate([("proxy (no labels)", "#999999"), ("test greedy", "#9ecae1"),
                                    ("test hungarian", "#2171b5")]):
        vals = [r[key] for r in rows]
        axes[1].bar(x + (k - 1) * w, vals, w, color=col, label=key)
        for xi, v in zip(x, vals):
            axes[1].text(xi + (k - 1) * w, v + 0.01, f"{v:.2f}", ha="center", fontsize=8)
    axes[1].set_xticks(x); axes[1].set_xticklabels(names, fontsize=9)
    axes[1].set_ylim(0, 1.08); axes[1].set_ylabel("accuracy")
    axes[1].set_title("Proxy score, greedy and one-to-one decoding")
    axes[1].legend(fontsize=9, loc="lower right")
    fig.tight_layout()
    fig.savefig("figures/similarity_and_results.png", dpi=150, facecolor="white")


if __name__ == "__main__":
    main()