← Back to the problem

IOAI 2026 · Correction by IOAI Training

Ghost of the Machine

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

Metric
mean exp(-|p-t|/100) x 100
Evaluated on
official test
Our score
91.4 on leaderboard B (93.0 on A)
Expected level
Scientific Committee 93.41 on leaderboard A

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 passage (500 to 800 words) starts as human-written text from a book or encyclopedia and, at one unknown point, continues as text generated by a language model that was given the human part as a prompt. Each side has at least 180 words. You must output the character index of the switch. The only pretrained model allowed is the text encoder bge-base-en-v1.5 (110 M parameters, no generation), and the whole notebook, training included, must run in 10 minutes on one GPU. You get 1,221 labelled training passages. Leaderboard A (380 passages) was shown live and leaderboard B (380 passages) decided the ranking; both answer files are now public, so we score on them directly.

How you are scored

With pp the predicted index and tt the true one:

score=100⋅mean⁡ e−∣p−t∣/τ,τ=100.\text{score} = 100 \cdot \operatorname{mean}\, e^{-|p - t|/\tau}, \qquad \tau = 100 .

One sentence is about 120 characters, so being off by one sentence already costs about 70 % of that passage's points: you must find the exact sentence. Reference points from the statement: the official baseline (the average boundary fraction times the length) scores 28.6 on A, and the Scientific Committee, who fine-tuned the encoder, scores 93.41 on A. In the official results, only 6 of 440 contestants scored at least 90 on this task, and the median was 0.

First look at the data

  • The switch is always at a sentence start. In all 1,221 training passages the boundary comes right after ". " (1,207 times), "! " or "? ". Splitting on these gives about 27 candidate positions per passage, and the true one is always among them. Snapping the baseline guess to the nearest sentence start already lifts it from 32.7 to 42.9 on B.
  • The human side has fingerprints. The human texts carry tokenisation artefacts of their source ("Anselm ," with a space before punctuation, " ."), many more en dashes (1.0 against 0.02 per 1,000 characters) and straight double quotes (2.2 against 0.1). The machine continuation has clean typography. The model, on the other hand, "sounds like an LLM": smooth transitions, evaluative summaries ("despite the profound material and intellectual losses...").
  • The boundary sits at 59.5%±6.4%59.5 \% \pm 6.4 \% of the length, a useful but weak prior.

A first baseline

Classify each sentence as human or machine, then pick the split that agrees best with the labels. With 13 style counts only (the marks above, sentence length, mean word length, relative position) and a logistic regression, 97.4 % of sentences are classified correctly, yet the change-point score is only 71.2 on B. The few errors are concentrated exactly where it matters: around the switch, where the first machine sentence continues the human topic and imitates its style.

The key idea

Two ideas turn a good sentence classifier into an exact locator.

1. Use the fact that there is exactly one switch. With pi=P(machine∣sentence i)p_i = P(\text{machine} \mid \text{sentence } i), the log-likelihood that the switch is at candidate kk is

L(k)=∑i<klog⁡(1−pi)+∑i≥klog⁡pi.L(k) = \sum_{i<k} \log(1 - p_i) + \sum_{i \ge k} \log p_i .

One noisy sentence in the middle of a human paragraph cannot move the answer, because every other sentence votes against it. Adding the bge CLS embedding to the style counts raises sentence accuracy to 98.7 %; we temper the likelihood (αL\alpha L, since neighbouring sentences are not independent), add the Gaussian prior on the position, and forbid splits leaving fewer than 150 words on a side. That gives 90.0 on B.

2. Model the transition itself. Most of the remaining errors are one sentence off, because the first machine sentence looks human. What gives it away is not the sentence alone but the change relative to the previous sentence. A second logistic regression is trained on pairs of consecutive sentences, with features [ek−ek−1, ek⊙ek−1][e_k - e_{k-1},\ e_k \odot e_{k-1}] on the standardised embeddings and style counts, to predict "the switch is at kk". Its log-odds are added to the posterior:

log⁡post(k)=αL(k)+βlogit⁡qk+log⁡π(k)+const.\log \mathrm{post}(k) = \alpha L(k) + \beta \operatorname{logit} q_k + \log \pi(k) + \text{const}.

Decision. Since the score is e−∣p−t∣/100e^{-|p - t|/100}, the best answer under the posterior is

p⋆=arg⁡max⁡c∑kpost(k) e−∣c−ck∣/100,p^\star = \arg\max_{c} \sum_k \mathrm{post}(k)\, e^{-|c - c_k|/100},

which hedges between two neighbouring sentences when the posterior is split. Here the posteriors are so peaked that it almost always coincides with the most probable sentence (91.43 against 91.43 on B).

Left and middle: per-sentence P(machine) (bars) and the posterior of the switch (red) for two passages of leaderboard B. The left one shows the typical failure: the first machine sentence gets only 0.53 and the switch is placed one sentence late. Right: scores on leaderboard B.

The full solution

  1. Split each passage into sentences at the candidate positions.
  2. Encode every sentence with frozen bge-base-en-v1.5 (CLS vector, L2-normalised, 96 tokens). This takes about 3 minutes for train plus one test split on a laptop GPU, so it fits the 10-minute budget on the contest GPU.
  3. Sentence classifier: logistic regression (C=0.01C = 0.01) on [embedding, 13 style counts], standardised.
  4. Transition classifier: logistic regression on pair features of consecutive sentences.
  5. Posterior over candidates with α=2\alpha = 2, β=1\beta = 1, the position prior and the 150-word constraint; output the expected-score maximiser.

α\alpha, β\beta and CC were chosen by 5-fold cross-validation grouped by passage on the training set (92.5), never on the leaderboards. Grouping matters: sentences of one passage share topic and style, and an ungrouped split would leak them across folds.

Results

approachCV on trainleaderboard Aleaderboard B
official baseline: mean fraction of the length28.632.7
mean fraction snapped to the nearest sentence start40.142.9
style counts + change point68.168.571.2
bge CLS + style + change point (α=1\alpha = 1)91.790.191.0
bge CLS + style + change point (α=2\alpha = 2, prior)92.290.190.0
+ transition classifier, β=1\beta = 1 (final, solution.py)92.593.091.4
same, most probable sentence instead of expected score93.091.4

On leaderboard B the final model finds the exact sentence in 88.7 % of passages; most misses are one sentence early or late (as in the left panel above), and a handful are 3 to 7 sentences off. On A, the split where the committee reports 93.41, we reach 93.0 without any fine-tuning, using only frozen embeddings and two logistic regressions. Fine-tuning bge on the sentence task, as the committee did, is the natural next step to recover the remaining boundary sentences.

What to remember

  • Find the discrete structure first. The switch is always at a sentence start: this cuts thousands of positions down to about 27, and a regression on characters into a classification over sentences.
  • Aggregate with the constraint. A single change point turns 98.7 %-accurate but noisy sentence votes into an exact location; sum log-probabilities instead of thresholding each sentence.
  • Model transitions, not only states. The hardest sentence is the one right after the switch; a classifier on consecutive pairs captures the change that a sentence-level classifier misses.
  • Decide with the metric. For e−∣p−t∣/τe^{-|p - t|/\tau}, choose the position maximising the expected score under the posterior; it hedges only when the model is genuinely unsure.
  • Cheap features still count. Typography (en dashes, quotes, tokenisation artefacts) gives 97 % sentence accuracy on its own: always look at the raw characters before reaching for embeddings.
Full code (solution.py)
"""IOAI 2026 Day 2, Ghost of the Machine: find where human text switches to machine text.

1. Candidates: the switch is always at a sentence start (after ". ", "! ", "? "), ~27 per passage.
2. Sentence classifier P(machine | sentence): logistic regression on the frozen bge-base-en-v1.5 CLS
   embedding + 13 style counts (en dashes, straight quotes, " ," tokenisation artefacts, length...).
3. Transition classifier P(switch at k | sentences k-1, k): logistic regression on [e_k - e_{k-1}, e_k * e_{k-1}].
4. Single change-point posterior over candidates:
     log post(k) = alpha * (sum_{i<k} log(1-p_i) + sum_{i>=k} log p_i) + beta * logit q_k + log prior(k/len)
   restricted to splits leaving >= 150 words on each side, then the position maximising the EXPECTED
   official score sum_k post(k) exp(-|c - c_k| / 100).
alpha, beta and C were chosen by 5-fold grouped cross-validation on train (alpha=2, beta=1, C=0.01).
Usage: python solution.py <data_dir> <split_dir_to_predict> answers.jsonl [answers_truth.jsonl]
"""
import re, sys, json, numpy as np, torch
from transformers import AutoTokenizer, AutoModel
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
torch.set_num_threads(2)
ALPHA, BETA, C, TAU = 2.0, 1.0, 0.01, 100.0
MARKS = ["–", '"', "'", " ,", " .", "’", "—", "(", ";", ":"]
DEV = "cuda" if torch.cuda.is_available() else ("mps" if torch.backends.mps.is_available() else "cpu")


def cands(t):
    return [0] + [m.end() for m in re.finditer(r'[.!?]["\')]?\s+', t)]


def sentences(t):
    c = cands(t); e = c[1:] + [len(t)]
    return c, [t[a:b] for a, b in zip(c, e)]


def style(s, a, n_text):
    n = max(len(s), 1)
    return [1000 * s.count(m) / n for m in MARKS] + [np.log(n), np.mean([len(w) for w in s.split()] or [0]), a / n_text]


class Encoder:
    def __init__(self, path):
        self.tok = AutoTokenizer.from_pretrained(path); self.m = AutoModel.from_pretrained(path).eval().to(DEV)

    @torch.no_grad()
    def __call__(self, sents, bs=32):
        out = []
        for i in range(0, len(sents), bs):
            b = self.tok([s.strip() for s in sents[i:i + bs]], padding=True, truncation=True, max_length=96, return_tensors="pt").to(DEV)
            out.append(torch.nn.functional.normalize(self.m(**b).last_hidden_state[:, 0], dim=-1).float().cpu())
        return torch.cat(out).numpy()


def featurise(docs, enc):
    """Per passage: candidate positions, word counts, sentence features X and pair features Xp."""
    allS, info = [], []
    for t in docs:
        c, S = sentences(t); allS += S
        info.append((c, np.cumsum([0] + [len(s.split()) for s in S]), [style(s, a, len(t)) for s, a in zip(S, c)]))
    E = enc(allS); out, i = [], 0
    for (c, words, H) in info:
        n = len(c); X = np.hstack([E[i:i + n], np.array(H)]); i += n
        out.append(dict(c=np.array(c), words=words, X=X))
    return out


def pair_feats(X):
    prev = np.vstack([np.zeros((1, X.shape[1])), X[:-1]])
    return np.hstack([X - prev, X * prev])


def predict_one(p, q, d, n_text, prior):
    lp, lq = np.log(np.clip(p, 1e-6, 1)), np.log(np.clip(1 - p, 1e-6, 1)); n = len(p)
    L = np.array([lq[:k].sum() + lp[k:].sum() for k in range(n)])
    lo = np.log(np.clip(q, 1e-6, 1)) - np.log(np.clip(1 - q, 1e-6, 1))
    lpr = -0.5 * ((d["c"] / n_text - prior[0]) / prior[1]) ** 2
    ok = (d["words"][:n] >= 150) & (d["words"][-1] - d["words"][:n] >= 150)
    if not ok.any():
        ok[:] = True
    s = np.where(ok, ALPHA * L + BETA * lo + lpr, -np.inf); post = np.exp(s - s.max()); post /= post.sum()
    gain = np.exp(-np.abs(d["c"][:, None] - d["c"][None, :]) / TAU) @ post   # expected score of each choice
    return int(d["c"][gain.argmax()])


def read(path):
    return [json.loads(l) for l in open(path)]


if __name__ == "__main__":
    data, split, out = sys.argv[1], sys.argv[2], sys.argv[3]
    enc = Encoder(f"{data}/../bge" if len(sys.argv) < 6 else sys.argv[5])
    tr = read(f"{data}/public/train/data.jsonl")
    ans = {r["id"]: r["boundary_char_index"] for r in read(f"{data}/public/train_answers.jsonl")}
    Ftr = featurise([r["text"] for r in tr], enc)
    X = np.vstack([f["X"] for f in Ftr])
    y = np.concatenate([(f["c"] >= ans[r["id"]]).astype(int) for f, r in zip(Ftr, tr)])
    yp = np.concatenate([(f["c"] == ans[r["id"]]).astype(int) for f, r in zip(Ftr, tr)])
    sc = StandardScaler().fit(X)
    Xp = np.vstack([pair_feats(sc.transform(f["X"])) for f in Ftr])
    clf = LogisticRegression(C=C, max_iter=3000).fit(sc.transform(X), y)
    clf_t = LogisticRegression(C=C, max_iter=3000).fit(Xp, yp)
    fr = np.array([ans[r["id"]] / len(r["text"]) for r in tr]); prior = (fr.mean(), fr.std())
    te = read(f"{data}/{split}/data.jsonl"); Fte = featurise([r["text"] for r in te], enc)
    preds = {}
    for r, f in zip(te, Fte):
        Z = sc.transform(f["X"])
        preds[r["id"]] = predict_one(clf.predict_proba(Z)[:, 1], clf_t.predict_proba(pair_feats(Z))[:, 1], f, len(r["text"]), prior)
    with open(out, "w") as fh:
        for k, v in preds.items():
            fh.write(json.dumps({"id": k, "boundary_char_index": v}) + "\n")
    if len(sys.argv) > 4:
        truth = {r["id"]: r["boundary_char_index"] for r in read(sys.argv[4])}
        s = [np.exp(-abs(preds[k] - truth[k]) / TAU) for k in truth]
        print(f"score {100 * np.mean(s):.2f}  exact {np.mean([preds[k] == truth[k] for k in truth]):.3f}")