← Back to the problem

IOAI 2026 · Correction by IOAI Training

Find the Order

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

Metric
pairwise ordering accuracy x 100
Evaluated on
official test
Our score
82.09 raw on leaderboard B (>= 80.7 norm.)
Expected level
best contestant 93.8 normalised (raw <= 87.4)

Our solution does not reach the expected level: there is still room to beat it

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

Each test item is a short spoken English conversation between two people, cut into turns (one audio file per turn, 7 to 20 turns). The files have been shuffled, and you must put them back in chronological order. You are told which two files are the first and second turns, which fixes where the dialogue starts. Only three pretrained models are allowed (wav2vec 2.0, Whisper and the small language model Qwen2.5-0.5B), the machine has one GPU and no internet, and the whole notebook, including any training, must finish within 10 minutes on 100 hidden dialogues. You get 1 288 training dialogues with their true order and 100 public test dialogues to check yourself.

How you are scored

For a dialogue with nn turns there are M=n(n−1)/2M = n(n-1)/2 pairs of turns. A pair is correct when your order puts the two turns in the same relative order as the truth. If II pairs are inverted, the dialogue scores

score=1−IM,\text{score} = 1 - \frac{I}{M},

and the task score is the mean over the 100 dialogues, times 100. A random order scores about 50, so 50 is the floor, not 0. An invalid permutation scores 0 for that dialogue.

The organisers released the graded splits after the contest (Hugging Face dataset IOAI-official/ioai-2026-find-the-order), so we score our final predictions on leaderboard B, the split that decided the final ranking, with the official metric. Every choice below was made on training dialogues only.

What is a good score? The ranking does not use the raw number directly. The IOAI 2026 contest rules (technical appendix, section 2.5) normalise every task:

Norm=100⋅raw−MinMax−Min,\text{Norm} = 100 \cdot \frac{\text{raw} - \text{Min}}{\text{Max} - \text{Min}},

where Min is the official baseline (here the identity permutation, raw 49.0 on leaderboard B) and Max is the larger of 0.9×0.9 \times the Scientific Committee's own solution and the best raw score of any contestant. The official results table (ioai-official.org, Results 2026) lists 466 contestants: 333 got 0 on this task, a cluster sits around 5 to 15 (raw scores barely above the baseline), and 36 contestants scored above 80. The best normalised score was 93.8. Since nobody reached 100, Max must be 0.9×0.9 \times the committee score, so Max is at most 90 raw. That lets us convert our raw score into a safe lower bound on the normalised one.

First look at the data

Three facts shape the whole solution.

  1. The speakers alternate. Each file contains one speaker turn, so in the true order speaker A is always at even positions (0, 2, 4, ...) and speaker B at odd positions. The two prefix files tell you which voice is A and which is B. If you can tell the two voices apart, you already know the parity of every turn's position.
  2. The content is very local. These are everyday textbook dialogues (shopping, a dinner, asking for directions), with a median of 7 words per turn. A turn usually answers the turn just before it: "What is your favorite?" is followed by "I love anything with chocolate the best!". So the key quantity is "how well does turn jj follow turn ii?".
  3. The transcripts are good but not perfect. Whisper-base gets most turns right, with a few amusing slips ("I scream please" for "Ice cream, please", "1336 we in" for "1336 yuan"). The ordering method must tolerate noisy text.

A first baseline

The official baseline submits the identity permutation (files in their given order) and scores 49.0 on leaderboard B, the same as a random order (50.9).

The cheapest real improvement uses the prefix: put the two known turns first and leave the rest in file order. That alone gives 69.1, because 2 of the 7 to 20 turns are now perfect and they sit at the start, which fixes all pairs that involve them.

Next, the voices. For each chunk we compute 20 MFCCs (a compact summary of the spectrum, obtained by a discrete cosine transform of Whisper's log-mel bands) and keep their mean and standard deviation over time: a 40-number voice fingerprint. A 2-means clustering seeded by the two prefix chunks, forced to give speaker A exactly ⌈n/2⌉\lceil n/2 \rceil turns, assigns 99.5 % of the chunks to the right speaker, and 97 of the 100 dialogues are split perfectly. Placing A's turns at even positions and B's at odd positions (in arbitrary order inside each group) brings the score to 70.2. The speaker split is almost free and almost perfect, but on its own it barely helps: it halves the number of candidates for each position, and the real work is ordering the turns of each speaker.

The key idea

Turn the problem into "which turn comes next?", score that with the language model, and correct the scores for how likely each turn is on its own.

For every ordered pair (i,j)(i, j) where ii and jj belong to different speakers, ask Qwen for the log-probability of turn jj given turn ii:

s(i→j)=log⁡PLM("B: tj"∣"A: ti").s(i \to j) = \log P_{\text{LM}}(\text{"B: } t_j\text{"} \mid \text{"A: } t_i\text{"}).

A dialogue order is then a path through all turns that starts with the two prefix turns, and its score is the sum of ss along the path. Finding the best path is a travelling-salesman-like search, but with at most 20 turns and the speaker constraint, a beam search is enough.

There is a trap, visible in the left panel below. Raw log-probabilities are dominated by the length and banality of the candidate: a short, generic turn ("Yes, I know.", "Okay.") is likely after almost anything, while a long specific turn is unlikely everywhere. The row-wise best candidate (white star) then becomes the same "magnet" turn for many rows, and the true successor (red box) wins only 24 % of the time over all leaderboard B turns.

The fix comes from information theory. What we want is not "is jj probable after ii?" but "is jj more probable after ii than in general?". That is the pointwise mutual information

PMI(i,j)=log⁡P(tj∣ti)−log⁡P(tj),\text{PMI}(i, j) = \log P(t_j \mid t_i) - \log P(t_j),

where log⁡P(tj)\log P(t_j) is the probability of turn jj after a neutral header. We use a softened version s(i→j)−αlog⁡P(tj)s(i\to j) - \alpha \log P(t_j); α=0.75\alpha = 0.75 was the best value on 60 training dialogues (with α=1\alpha = 1 the correction starts to over-reward long, unusual turns). With it, the true successor becomes the top candidate 39 % of the time, and the magnet disappears (right panel).

Pair scores for one dialogue, ordered by true position, raw versus PMI-corrected

Notice also the checkerboard: since speakers alternate, a turn can only be followed by a turn of the other speaker, so half of the matrix is never computed. The speaker split from the baseline is what makes this possible.

Even with PMI, a single "best next turn" is right less than half the time, so greedy decoding is a poor idea. A global search that adds up the evidence along the whole chain is much better: the sum rewards orders in which every transition is plausible, not just the next one.

The full solution

  1. Transcribe and fingerprint. Resample each chunk from 44.1 kHz to 16 kHz, transcribe it with Whisper-base (batched per dialogue), and compute the 40-number MFCC fingerprint.

  2. Split the speakers with the seeded, balanced 2-means described above.

  3. Pair matrix. For every pair (i,j)(i,j) of different speakers, compute s(i→j)s(i \to j) with the prompt A conversation between two people.\nA: <t_i>\nB: <t_j>\n and sum the log-probabilities of the tokens of the second line. Also compute log⁡P(tj)\log P(t_j) once per turn. About 50 pairs per dialogue, batched 24 at a time.

  4. Beam search with width 200 over chains that start with the prefix, adding s(i→j)−0.75log⁡P(tj)s(i \to j) - 0.75 \log P(t_j) at each step. Two partial chains that have used the same set of turns and end on the same turn have the same possible futures, so we keep only the better one (the classic dynamic-programming trick for the travelling salesman, here inside a beam).

    key = (frozenset(seq + (j,)), j)          # (turns used, last turn)
    if key not in cand or cand[key][0] < s + w:
        cand[key] = (s + w, seq + (j,))
    
  5. Re-rank with the full context. The pair scores only see one previous turn. We take the 8 best chains from the beam and score each as a complete dialogue: the sum over positions p≥2p \ge 2 of log⁡P(tσ(p)∣tσ(0),…,tσ(p−1))\log P(t_{\sigma(p)} \mid t_{\sigma(0)}, \dots, t_{\sigma(p-1)}), one forward pass of Qwen per chain. The chain with the highest full-context likelihood wins. Here no PMI correction is needed: all 8 chains contain the same turns, so the per-turn priors add up to the same total and cancel.

  6. Convert the chain into the rank format (P[i]P[i] = position of chunk ii) and write answers.json.

On our CPU (2 threads), transcription takes about 10 minutes, the pair matrix about 9 minutes and the re-ranking about 2 minutes for 100 dialogues; on the contest GPU the same pipeline would run in a few minutes, within the 10-minute limit. We did not fine-tune Qwen: the zero-shot model with the PMI correction already does most of the work.

Scores of each stage on leaderboard B, and where the final score sits among contestants

Results

All scores are pairwise ordering accuracy ×100\times 100 on the official leaderboard B split (100 dialogues). Hyperparameters (α\alpha, beam width, KK) were chosen on training dialogues.

approachleaderboard B
official baseline: identity permutation49.0
random order50.9
prefix first, rest in file order69.1
prefix + speaker parity (MFCC 2-means)70.2
greedy next-turn choice, PMI scores70.2
beam search, raw pair scores (α=0\alpha = 0)79.1
beam search, PMI scores (α=0.75\alpha = 0.75)80.9
beam + full-context re-ranking of the top 8 (final)82.1
for reference: best of the 8 candidates (oracle)87.3

On the 60 training dialogues we used for tuning, the same stages give 78.4 (beam, PMI) and 80.1 after re-ranking (oracle 85.5), so the gain from re-ranking is consistent and not an artefact of the test set.

With the official normalisation (Min 49.0, Max at most 90), the raw 82.1 is worth at least 80.7 normalised points, which would have placed it around 37th of 466 contestants or better; the best contestant reached 93.8 normalised, which corresponds to a raw score of at most about 87.4. The exact normalised value depends on the committee's reference score, which is not published. The gap to the top is mostly in the language model: the oracle line shows that a better order is often among our 8 candidates (87.3 if we always picked the best one), but a 0.5B model cannot always tell which one it is. Natural next steps would be a bigger Whisper for cleaner text, fine-tuning Qwen on the 1 288 training dialogues to predict the next turn, and adding acoustic continuity (pauses, intonation at the end of a question).

What to remember

  • Use the structure you are given. The prefix and the alternation of speakers are free information: together they take you from 50 to 70 before any language model is used, and alternation halves the search space.
  • Reduce ordering to pairwise "what comes next" scores, then search globally. Greedy choice wastes the scores; a beam search with dynamic-programming merging (same set used, same last item) finds much better chains.
  • Correct language-model likelihoods for the prior. Raw log⁡P(j∣i)\log P(j \mid i) favours short, generic candidates; subtracting αlog⁡P(j)\alpha \log P(j) (a PMI-style score) measures how much ii actually explains jj. The same trick appears in retrieval, multiple-choice scoring with LMs and collocation mining.
  • Generate a few candidates cheaply, then re-rank them with a more expensive model. Local scores to shortlist, full context to decide: a pattern that transfers to translation, speech recognition and search.
  • Know the floor of your metric. Pairwise accuracy gives 50 to a random order, so 70 is a modest gain and 82 a big one.
Full code (solution.py)
"""Find the Order (IOAI 2026, day 1): put the shuffled turns of a two-speaker dialogue back in order.

Pipeline (only the allowed models: Whisper + Qwen2.5-0.5B):
  1. Whisper transcribes every chunk; MFCC statistics describe each chunk's voice.
  2. The two prefix chunks seed a balanced 2-speaker split (A speaks at even positions, B at odd).
  3. Qwen scores every ordered pair (i -> j) of turns from different speakers: log P(j | i),
     minus 0.75 * log P(j) (a PMI correction that stops short generic turns from winning everywhere).
  4. Beam search (with DP state merging) finds the best chain starting with the prefix.
  5. The 8 best chains are re-scored with the whole dialogue as context; the best one is kept.
Usage: python solution.py <split_dir> [answers.json]   (split_dir contains prefix.json and <id>/chunk_k.wav)
"""
import os, sys, json, glob
os.environ.setdefault("OMP_NUM_THREADS", "2")
import numpy as np, soundfile as sf, torch
from scipy.signal import resample_poly
from scipy.fft import dct
from transformers import (WhisperForConditionalGeneration, WhisperProcessor,
                          AutoModelForCausalLM, AutoTokenizer)
torch.set_num_threads(2)
DEV = "cuda" if torch.cuda.is_available() else "cpu"
ALPHA, BEAM, K = 0.75, 200, 8          # ALPHA chosen on 60 training dialogues
HEADER = "A conversation between two people.\n"

proc = WhisperProcessor.from_pretrained("models/whisper-base")
asr = WhisperForConditionalGeneration.from_pretrained("models/whisper-base").to(DEV).eval()
tok = AutoTokenizer.from_pretrained("models/Qwen2.5-0.5B")
lm = AutoModelForCausalLM.from_pretrained("models/Qwen2.5-0.5B", dtype=torch.float32).to(DEV).eval()

# ---------------- 1. audio -> text + voice features ----------------
def load16k(path):
    x, sr = sf.read(path, dtype="float32")
    if x.ndim > 1: x = x.mean(1)
    return resample_poly(x, 160, 441).astype(np.float32) if sr == 44100 else x

def voice_features(x):
    """Mean and std of 20 MFCCs (DCT of Whisper's log-mel bands): a cheap voice fingerprint."""
    mel = proc.feature_extractor(x, sampling_rate=16000, return_tensors="np").input_features[0]
    mel = mel[:, :max(1, len(x) // 160)]                 # drop the padding to 30 s
    c = dct(mel, axis=0, norm="ortho")[:20]
    return np.concatenate([c.mean(1), c.std(1)])

@torch.no_grad()
def transcribe(xs):
    inp = proc.feature_extractor(xs, sampling_rate=16000, return_tensors="pt").input_features.to(DEV)
    ids = asr.generate(inp, language="en", task="transcribe", max_new_tokens=120)
    return [t.strip() for t in proc.batch_decode(ids, skip_special_tokens=True)]

# ---------------- 2. speakers ----------------
def assign_speakers(voice, prefix):
    """Balanced 2-means: chunk prefix[0] is A, prefix[1] is B, A owns ceil(n/2) chunks."""
    n = len(voice); X = (voice - voice.mean(0)) / (voice.std(0) + 1e-6)
    a, b = prefix; nA = (n + 1) // 2
    cA, cB = X[a], X[b]; isA = np.zeros(n, bool)
    for _ in range(10):
        d = np.linalg.norm(X - cA, axis=1) - np.linalg.norm(X - cB, axis=1)
        d[a], d[b] = -1e9, 1e9
        new = np.zeros(n, bool); new[np.argsort(d)[:nA]] = True
        if (new == isA).all(): break
        isA = new; cA, cB = X[isA].mean(0), X[~isA].mean(0)
    return [0 if v else 1 for v in isA]

# ---------------- 3. language-model scores ----------------
def line(spk, text): return f"{'AB'[spk]}: {text}\n"

@torch.no_grad()
def cond_logprob(contexts, conts):
    """Batch of sum_t log P(continuation tokens | context)."""
    ids, starts = [], []
    for c, x in zip(contexts, conts):
        ci, xi = tok(c).input_ids[-200:], tok(x).input_ids
        ids.append(ci + xi); starts.append(len(ci))
    L = max(map(len, ids)); pad = tok.pad_token_id or 0
    inp = torch.full((len(ids), L), pad); att = torch.zeros((len(ids), L), dtype=torch.long)
    for k, s in enumerate(ids): inp[k, :len(s)] = torch.tensor(s); att[k, :len(s)] = 1
    logp = torch.log_softmax(lm(input_ids=inp.to(DEV), attention_mask=att.to(DEV)).logits.float(), -1).cpu()
    return np.array([float(logp[k, torch.arange(starts[k] - 1, len(s) - 1), torch.tensor(s[starts[k]:])].sum())
                     for k, s in enumerate(ids)])

def pair_matrix(texts, spk):
    n = len(texts)
    prior = cond_logprob([HEADER] * n, [line(spk[i], texts[i]) for i in range(n)])   # log P(j)
    M = np.full((n, n), np.nan)
    pairs = [(i, j) for i in range(n) for j in range(n) if spk[i] != spk[j]]       # speakers alternate
    for s in range(0, len(pairs), 24):
        b = pairs[s:s + 24]
        lp = cond_logprob([HEADER + line(spk[i], texts[i]) for i, _ in b], [line(spk[j], texts[j]) for _, j in b])
        for (i, j), v in zip(b, lp): M[i, j] = v
    return M, prior

# ---------------- 4. beam search over chains ----------------
def top_orders(M, prior, prefix):
    W = M - ALPHA * prior[None, :]; n = len(prior)
    beams = [(0.0, tuple(prefix))]
    for _ in range(n - 2):
        cand = {}
        for s, seq in beams:
            rest = [j for j in range(n) if j not in seq]
            ok = [j for j in rest if not np.isnan(W[seq[-1], j])]
            for j in ok or rest:                    # fallback if the speaker split was wrong
                w = W[seq[-1], j] if not np.isnan(W[seq[-1], j]) else -50.0
                key = (frozenset(seq + (j,)), j)       # same set + same last turn = same future: merge
                if key not in cand or cand[key][0] < s + w: cand[key] = (s + w, seq + (j,))
        beams = sorted(cand.values(), key=lambda x: -x[0])[:BEAM]
    return [seq for _, seq in beams[:K]]

# ---------------- 5. full-context re-ranking ----------------
@torch.no_grad()
def full_score(texts, seq):
    """sum over positions p >= 2 of log P(turn p | all previous turns)."""
    pieces = [HEADER] + [line(p % 2, texts[i]) for p, i in enumerate(seq)]
    ids, spans = [], []
    for pc in pieces:
        t = tok(pc).input_ids; spans.append((len(ids), len(ids) + len(t))); ids += t
    lp = torch.log_softmax(lm(input_ids=torch.tensor([ids]).to(DEV)).logits.float()[0], -1).cpu()
    return sum(float(lp[torch.arange(a - 1, b - 1), torch.tensor(ids[a:b])].sum()) for a, b in spans[3:])

def to_rank(seq):
    P = [0] * len(seq)
    for r, i in enumerate(seq): P[i] = r
    return P

def pairwise_score(P, G):
    n = len(G)
    if n < 2: return 1.0
    inv = sum((P[i] < P[j]) != (G[i] < G[j]) for i in range(n) for j in range(i + 1, n))
    return 1 - inv / (n * (n - 1) / 2)

if __name__ == "__main__":
    split = sys.argv[1]
    prefix = json.load(open(f"{split}/prefix.json"))
    truth = json.load(open(sys.argv[2])) if len(sys.argv) > 2 else None
    out, scores = {}, []
    for did in sorted(prefix, key=int):
        n = len(glob.glob(f"{split}/{did}/chunk_*.wav"))
        xs = [load16k(f"{split}/{did}/chunk_{i}.wav") for i in range(n)]
        texts = transcribe(xs)
        spk = assign_speakers(np.array([voice_features(x) for x in xs]), prefix[did])
        M, prior = pair_matrix(texts, spk)
        cands = top_orders(M, prior, prefix[did])
        best = max(cands, key=lambda seq: full_score(texts, seq))
        out[did] = to_rank(best)
        if truth: scores.append(pairwise_score(out[did], truth[did]))
    json.dump(out, open("answers.json", "w"))
    if truth: print(f"pairwise ordering accuracy: {100 * np.mean(scores):.2f}")