← Retour à l'épreuve

Romania ONIA 2026 · Correction IOAI Training

The Archive of Contradictions

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

Métrique
macro-F1
Évaluée sur
held-out validation (official test labels are private)
Notre score
0.596 macro-F1 (0.600 in a 2nd run)
Niveau attendu
macro-F1 >= 0.6 gives 100 points (0.3 gives 0)

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 fact-checking agency wants to automate its analysts. Each input is a short English claim ("The Battle of Shiloh took place in 1864.") and your system must say whether an encyclopedic archive supports it, refutes it, or does not contain enough information. You get 14,998 labelled training claims, 3,000 test claims and the archive itself: wiki_pages.csv, about 487,000 Wikipedia introductions split into numbered sentences. The task is a version of the FEVER benchmark (Thorne et al., 2018); using the original FEVER labels or gold evidence is forbidden, any NLP or ML technique is allowed, and the organisers point to a pretrained natural-language-inference (NLI) model in the documentation.

The expected pipeline is the classic one: retrieve relevant pages, select evidence sentences, decide with an NLI model. The interesting twist, which a careful contestant discovers by looking at the archive, is that this pipeline can only run on a small fraction of the claims.

How you are scored

The metric is macro-F1 over the three classes:

F1c=2 Pc RcPc+Rc,Macro F1=F1SUPPORTS+F1REFUTES+F1NEI3.\text{F1}_c = \frac{2\,\text{P}_c\,\text{R}_c}{\text{P}_c+\text{R}_c},\qquad \text{Macro F1} = \frac{\text{F1}_{\text{SUPPORTS}} + \text{F1}_{\text{REFUTES}} + \text{F1}_{\text{NEI}}}{3}.

Each class weighs the same, so neglecting the rarer REFUTES (20 % of training claims) or NOT ENOUGH INFO (25 %) is costly. Points are

points={100if Macro F1≥0.60if Macro F1≤0.3⌊Macro F1−0.30.3×100⌋otherwise,\text{points} = \begin{cases} 100 & \text{if Macro F1} \ge 0.6\\ 0 & \text{if Macro F1} \le 0.3\\ \left\lfloor \dfrac{\text{Macro F1}-0.3}{0.3}\times 100\right\rfloor & \text{otherwise,}\end{cases}

so 0.6 macro-F1 is the full-score target (statement, "Evaluare"). The test labels are private: every score below is measured on a held-out 20 % of the training claims (fold 0 of a stratified 5-fold split, 3,000 claims), never on the test.

First look at the data

The archive is mostly missing. Count the first characters of the 487k page titles: 255k start with "A", about 230k with a digit, and almost nothing else. The alphabetically last "A" page is Arthur_Sweeney, and even within that range many famous pages (Angelina Jolie, Earth, The Avengers) are absent. In the original FEVER the evidence pages are spread over all 5.4 million Wikipedia pages, so a claim about "Nikolaj Coster-Waldau" simply has no evidence here.

To measure it, we look up the claim's leading entity (the longest prefix of the claim, optionally after "The", that is a page title, ignoring the "(film)"-style disambiguation suffix). Only 6.1 % of the training claims and 6.8 % of the test claims have their subject page in the archive. When it is there, retrieval works nicely ("Anne Hathaway played the character Mia Thermopolis." finds the sentence "...before landing the lead role of Mia Thermopolis in the Disney comedy film The Princess Diaries"), but for 93 % of the claims the decision has to come from the claim itself.

The claims carry strong clues of their own. FEVER claims were written by annotators who were asked to mutate true sentences into false ones, and the easiest way to make a claim false is to negate or restrict it. The result is a famous annotation artefact (Schuster et al., 2019):

Share of each label among training claims containing a given word: overall 55 % SUPPORTS, 20 % REFUTES, 25 % NOT ENOUGH INFO; but claims with "not" are 81 % REFUTES, "only" 83 %, "incapable" 95 %, "yet" 94 %.

NOT ENOUGH INFO claims have softer cues: vague or speculative vocabulary such as "attended", "popular", "successful", "will". A model that reads the claim alone can therefore get far above chance; the question is how far.

A first baseline

Claim-only, bag of words: TF-IDF on word unigrams and bigrams (plus character 2 to 5-grams), class-balanced logistic regression. 5-fold cross-validated macro-F1: 0.517 (per class: NEI 0.38, REFUTES 0.53, SUPPORTS 0.64). Adding a frozen sentence embedding of the claim (all-MiniLM-L6-v2, 384 dimensions) to the TF-IDF features gives 0.549. That is 82 points: decent, but the full score needs 0.6.

The key idea

Since retrieval can only help 6 % of the claims, the gains must come from reading claims better, and the best reader is a pretrained transformer fine-tuned end to end on the 12,000 training claims.

Why is fine-tuning so much better than "frozen embedding + linear model"? A frozen sentence embedding was trained to make paraphrases close to each other. For that objective, "X was in the film Y" and "X was not in the film Y" are near-identical, so the negation, which is the single most useful signal here, is almost erased. Fine-tuning moves the whole network so that the directions that separate the labels (negation, restriction, hedging, specificity of the claimed fact, plausibility of entity pairs memorised during pretraining) become large in the pooled vector. With 3 classes and only 22 M parameters (MiniLM-L6), three epochs on CPU are enough.

Two details matter for macro-F1:

  • Class-weighted cross-entropy, with weight wc=N3Ncw_c = \frac{N}{3 N_c}, so that a REFUTES or NEI error costs about twice a SUPPORTS error. Without it the model drifts to the majority class and the NEI F1 collapses.
  • Blending the fine-tuned model with the bag-of-words model, P=0.3 PLR+0.7 PMiniLMP = 0.3\,P_{\text{LR}} + 0.7\,P_{\text{MiniLM}}: the two make different mistakes (exact n-gram cues versus contextual meaning), so averaging probabilities helps a little.

And what about the evidence pipeline? We built it: subject-title retrieval, TF-IDF ranking of the page's sentences against the claim (keeping the top 5 plus the page's first sentence), then the cross-encoder nli-deberta-v3-base (trained on SNLI and MultiNLI, not on FEVER) scores each (sentence, claim) pair. On the 6 % of claims with evidence, stacking the NLI scores on top of the bag-of-words model raised macro-F1 from 0.51 to 0.62 on that subset. But the fine-tuned claim model already reaches 0.64 on the same subset, and adding the NLI scores to it made things slightly worse (0.633 for the lightest weight we tried). Our NLI scores are noisy: often the retrieved sentences are about the right entity but not about the claimed fact, and "neutral" then pushes towards NOT ENOUGH INFO for claims that are in fact supported by a sentence the page does not contain. A better sentence selector could fix it, but at most it can move 6 % of the claims.

The full solution

  1. Split: stratified 5-fold on train.csv, fold 0 (3,000 claims) is our validation set, the models are trained on the remaining 12,000.
  2. Model A: TF-IDF (1-2 grams, sublinear tf, min_df 2) concatenated with the frozen mean-pooled MiniLM embedding, logistic regression (C=2C = 2, class_weight="balanced").
  3. Model B: MiniLM-L6 + mean pooling + linear head, AdamW (lr 5⋅10−55\cdot10^{-5}, one-cycle schedule, weight decay 0.01), batch 32, max 48 tokens, 3 epochs, class-weighted cross-entropy. About 13 minutes on 2 CPU threads.
  4. Blend 0.3 PA+0.7 PB0.3\,P_A + 0.7\,P_B, predict the argmax, write id,subtaskID,label.

The heart of model B:

h = self.enc(**batch).last_hidden_state            # (B, T, 384)
m = batch["attention_mask"].unsqueeze(-1).float()
logits = self.head((h * m).sum(1) / m.sum(1))       # mean pooling over real tokens
loss = F.cross_entropy(logits, labels, weight=class_weights)

Results

Macro-F1 on our validation fold (official test labels are private). The first line is 5-fold cross-validation.

approachmacro-F1points (formula)
TF-IDF words + chars, logistic regression (5-fold CV)0.51772
frozen MiniLM embedding, logistic regression (5-fold CV)0.51571
TF-IDF + frozen MiniLM, logistic regression (5-fold CV)0.54982
same + retrieval/NLI features stacked for all claims (5-fold CV)0.54381
fine-tuned MiniLM, claim only0.59598
fine-tuned MiniLM + NLI evidence fusion (weight 0.5 / 1 / 2)0.595 / 0.592 / 0.59098 to 96
0.3 x logistic regression + 0.7 x fine-tuned MiniLM (final)0.600 (exploration run), 0.596 (clean solution.py run)100 / 98
final blend + per-class decision biases tuned for macro-F1 (cross-fitted)0.600100 (no gain)

Per-class F1 of the final blend (exploration run): NEI 0.46, REFUTES 0.63, SUPPORTS 0.71. NOT ENOUGH INFO stays the hardest class, which makes sense: whether the archive could verify a claim is not visible in the claim's wording, only in how specific and checkable it sounds.

Reading the table honestly: the same pipeline gave 0.600 and 0.596 in two runs that differ only by the random seed of fine-tuning, so we sit right at the 0.6 line on 3,000 held-out claims, and the standard error of a macro-F1 on 3,000 examples is about 0.01. On the private test, expect roughly 0.58 to 0.61, that is 93 to 100 points. Training the final models on all 15,000 claims instead of 12,000 (we did not, to keep a clean validation) would most likely add a little.

Ideas we tried that did not help: tuning per-class decision biases for macro-F1 (no gain once cross-fitted), stacking NLI scores into the fine-tuned model. An idea we could not finish: probing a small instruction-tuned LLM (Qwen2.5-1.5B) for its world knowledge of each claim; the shared machine's GPU was saturated. It is the natural next step, because it attacks the SUPPORTS versus REFUTES confusion with actual knowledge rather than wording.

What to remember

  • Inspect the knowledge source before building on it. Five minutes of counting page titles showed that 93 % of the claims have no evidence in the archive, which completely changes where the effort should go.
  • Annotation artefacts are real signal. Crowd-written datasets leak their labels through wording (negations for REFUTES, vague words for NEI). Exploit them in a competition, and remember them when you read a paper's claim that a model "verifies facts".
  • Fine-tune rather than freeze when the label depends on small words. General-purpose sentence embeddings flatten negation; end-to-end fine-tuning of even a 22 M-parameter encoder brought +0.05 macro-F1.
  • Macro-F1 punishes ignored classes: use class weights (or balanced sampling) during training, and check per-class F1, not only the average.
  • Validate the add-on, not the idea. Retrieval + NLI is the textbook pipeline and it did help a weak model, but it added nothing on top of a strong one: only a held-out comparison could tell.
Code complet (solution.py)
"""The Archive of Contradictions (ONIA 2026, national team selection, test 2).

FEVER-style claim verification: SUPPORTS / REFUTES / NOT ENOUGH INFO, scored by macro-F1.

What the data taught us: wiki_pages.csv only contains pages whose titles start with a digit or
with "A" (up to "Arthur_S..."), so the subject page of only ~6 % of the claims is in the corpus.
Retrieval + NLI on those few claims did not improve macro-F1 on top of a good claim-only model
(see the write-up), so the final pipeline is claim-only:

  1. TF-IDF (word 1-2 grams) + frozen MiniLM sentence embedding -> class-balanced logistic regression.
  2. MiniLM-L6 (all-MiniLM-L6-v2) fine-tuned end to end on the claims (mean pooling + linear head,
     class-weighted cross-entropy, 3 epochs).
  3. Average of probabilities: 0.3 * (1) + 0.7 * (2), argmax.

Validation = fold 0 of a stratified 5-fold split of train.csv (20 %, 3,000 claims); the models used
for the test predictions are trained on the other 80 % (training on 100 % would be slightly better).
Run from the folder holding train.csv, public_test.csv and wiki_pages.csv. CPU, 2 threads, ~15 min.
"""
import re
import collections
import numpy as np
import pandas as pd
import torch
from scipy.sparse import hstack, csr_matrix
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import f1_score
from sklearn.model_selection import StratifiedKFold
from transformers import AutoTokenizer, AutoModel

torch.set_num_threads(2); torch.manual_seed(0); np.random.seed(0)
LAB = ["NOT ENOUGH INFO", "REFUTES", "SUPPORTS"]
ENC = "sentence-transformers/all-MiniLM-L6-v2"

tr = pd.read_csv("train.csv"); te = pd.read_csv("public_test.csv")
y = tr.label.map(LAB.index).values
tri, vai = next(StratifiedKFold(5, shuffle=True, random_state=0).split(tr, y))


def corpus_coverage():
    """Diagnostic: share of claims whose leading entity is a page title of the corpus."""
    titles = pd.read_csv("wiki_pages.csv", usecols=["doc_id"]).doc_id.dropna()
    base = lambda d: re.sub(r" \(.*\)$", "", d.replace("_", " ").replace("-LRB-", "(")
                            .replace("-RRB-", ")")).lower()
    index = set(base(d) for d in titles)

    def has_page(claim):
        toks = claim.lower().rstrip(".").split()
        start = 1 if toks and toks[0] == "the" else 0
        return any(" ".join(toks[start:start + n]) in index for n in range(min(8, len(toks)), 1, -1))
    print("claims whose subject page exists:", round(np.mean([has_page(c) for c in tr.claim]), 3))


# ---------- 1. TF-IDF + frozen sentence embeddings + logistic regression ----------
tok = AutoTokenizer.from_pretrained(ENC)


@torch.no_grad()
def frozen_embed(texts, bs=128):
    model = AutoModel.from_pretrained(ENC).eval(); out = []
    for i in range(0, len(texts), bs):
        b = tok(texts[i:i + bs], padding=True, truncation=True, max_length=48, return_tensors="pt")
        h = model(**b).last_hidden_state; m = b["attention_mask"].unsqueeze(-1).float()
        e = (h * m).sum(1) / m.sum(1); out.append(torch.nn.functional.normalize(e, dim=-1).numpy())
    return np.concatenate(out)


E_tr, E_te = frozen_embed(tr.claim.tolist()), frozen_embed(te.claim.tolist())
vec = TfidfVectorizer(ngram_range=(1, 2), min_df=2, sublinear_tf=True).fit(tr.claim.iloc[tri])
feats = lambda texts, E: hstack([vec.transform(texts), csr_matrix(E)]).tocsr()
lr = LogisticRegression(C=2, max_iter=3000, class_weight="balanced")
lr.fit(feats(tr.claim.iloc[tri], E_tr[tri]), y[tri])
P_lr_val = lr.predict_proba(feats(tr.claim.iloc[vai], E_tr[vai]))
P_lr_te = lr.predict_proba(feats(te.claim, E_te))
print("TF-IDF + MiniLM LR, validation macro-F1:", round(f1_score(y[vai], P_lr_val.argmax(1), average="macro"), 4))


# ---------- 2. fine-tuned MiniLM ----------
class ClaimClassifier(torch.nn.Module):
    def __init__(self):
        super().__init__()
        self.enc = AutoModel.from_pretrained(ENC); self.head = torch.nn.Linear(384, 3)

    def forward(self, b):
        h = self.enc(**b).last_hidden_state; m = b["attention_mask"].unsqueeze(-1).float()
        return self.head((h * m).sum(1) / m.sum(1))


encode = lambda texts: tok(texts, padding=True, truncation=True, max_length=48, return_tensors="pt")
weights = torch.tensor(len(y) / (3 * np.bincount(y)), dtype=torch.float32)  # balance the 3 classes
model = ClaimClassifier()
opt = torch.optim.AdamW(model.parameters(), lr=5e-5, weight_decay=0.01)
EPOCHS, BS = 3, 32
sched = torch.optim.lr_scheduler.OneCycleLR(opt, 5e-5, total_steps=EPOCHS * int(np.ceil(len(tri) / BS)),
                                            pct_start=0.1)
for epoch in range(EPOCHS):
    model.train(); order = np.random.permutation(tri)
    for i in range(0, len(order), BS):
        b = order[i:i + BS]
        loss = torch.nn.functional.cross_entropy(model(encode(tr.claim.iloc[b].tolist())),
                                                 torch.tensor(y[b]), weight=weights)
        opt.zero_grad(); loss.backward(); opt.step(); sched.step()
    print("epoch", epoch + 1, "done")


@torch.no_grad()
def predict(texts):
    model.eval()
    return np.concatenate([torch.softmax(model(encode(texts[i:i + 128])), -1).numpy()
                           for i in range(0, len(texts), 128)])


P_ft_val, P_ft_te = predict(tr.claim.iloc[vai].tolist()), predict(te.claim.tolist())
print("fine-tuned MiniLM, validation macro-F1:", round(f1_score(y[vai], P_ft_val.argmax(1), average="macro"), 4))

# ---------- 3. blend and write the submission ----------
P_val = 0.3 * P_lr_val + 0.7 * P_ft_val
print("blend, validation macro-F1:", round(f1_score(y[vai], P_val.argmax(1), average="macro"), 4))
pred = (0.3 * P_lr_te + 0.7 * P_ft_te).argmax(1)
pd.DataFrame({"id": te.id, "subtaskID": 1, "label": [LAB[k] for k in pred]}).to_csv("submission.csv", index=False)
corpus_coverage()