Checklist OAI 2025 Stage I · Task 2
SOTA-AI Editorial
Hallucination Detection
Predict whether a language model's answer to a factual question is correct, using the answer, its tokens and four higher-temperature alternative answers with token probabilities.
Hints
Hint 1
Compare token probabilities for the answer spans across the four alternative generations.
Hint 2
Measure whether the four generations agree on the extracted factual answer.
Hint 3
Clean malformed token tails before calculating probability and span features.
Hint 4
Combine probability, semantic, answer-agreement, style, and question-type features in a small tabular classifier.
The solution in one line
An all-feature XGBoost model reaches organizer-reported AUC 0.8226 (100/100) after cleaning corrupted alternative-token tails and engineering answer-span probability features.
The editorial
Poland 2025 Stage I — Wykrywanie Halucynacji (Hallucination Detection)
Domain: NLP uncertainty estimation and binary classification
Metric: ROC AUC. The scoring rule gives 0 points at AUC ≤0.70 and 100 points at AUC ≥0.82.
Official sources: SOTA checklist · starter notebook · organizer worked solution
Abridged statement
Classify whether an LLM's answer to a factual question is correct. Each record contains the question and main answer, generated tokens, four higher-temperature supporting answers, their tokens and token probabilities, and a trusted is_correct label. The final evaluation runs on CPU within five minutes using the permitted tabular libraries.
Dataset analysis and EDA
The release has 2,967 training records and 990 validation records, with eight columns and no null fields. Main answers average 19.44 words in train and 19.46 in validation; the four supporting answers average 20.34 words in both. Labels are imbalanced, with roughly twice as many incorrect answers as correct answers. The organizer's data checks found one malformed answer tag in train, none in validation, no empty tagged answers, and 439 training records whose supporting token lists cannot reconstruct the supporting answer. A \n\n inside one token list precedes unrelated generated text and misaligned probabilities; the worked notebook trims this tail before feature extraction.
Experiments
The worked notebook explores six feature groups and an XGBoost classifier:
- Token-probability statistics: minima, means, maxima, standard deviations, lengths, variance, and generalized negative log likelihood, both for answer spans and for each supporting response.
- Semantic features: TF-IDF, question/answer similarity, and lexical overlap.
- Cross-answer agreement: number of unique extracted answers, count of the modal answer, and agreement ratio.
- Style and structure: answer length, sentence-length variation, and longest-common-subsequence similarity.
- Question type: regex flags for who/what/where/when/why/how/which.
- Data cleanup: remove malformed tags and truncate the bad token/probability tails described above.
The organizer reports AUC 0.7117 using only statistical features, 0.7417 semantic, 0.7277 cross-answer consistency, 0.6141 style, 0.5987 question type, and 0.8178 answer-probability features. A random baseline is 0.5000. Excluding answer-probability features from the full set falls to 0.7775; other single-group ablations range from 0.8129 to 0.8221. The all-feature XGBoost model with early stopping scores 0.8226. A separate three-fold search over 256 parameter sets (768 fits) reports best parameters max_depth=3, n_estimators=500, learning_rate=0.01, subsample=0.5, colsample_bytree=0.8, min_child_weight=2, gamma=0.1 and validation AUC 0.8263.
Selected solution and validation evidence
The standalone implementation in hallucination_detection.py reworks the organizer's six feature groups and the best grid-searched XGBoost configuration. It fits TF-IDF on training text and fits the classifier only on training labels; validation labels are used only for the final metric. The selected tree count and hyperparameters came from three-fold cross-validation on the training set. It requires the task-permitted xgboost package.
The organizer's selected all-feature XGBoost implementation reports AUC 0.8226, above the full-credit threshold, for 100/100 organizer-reported points. The separate searched model reports 0.8263. The notebook also reports the most important features as the number of nonmatching answer spans, number of matching answer spans, answer probability standard deviation, other-answer probability standard deviation, and unique extracted answer count.
These scores are copied from the organizer's executed notebook outputs; they were not independently rerun. train.json and valid.json are hosted only on Google Drive. Attempts to reach those links failed with HTTP 403: CONNECT tunnel failed, and the files are absent from the official GitHub repository and local mirrors. The local Python environment also lacks xgboost, so no local score is available. No hidden labels or secret-test claims are used.
Compute and runtime
The official limit is five minutes on CPU. The executed notebook records 768 three-fold parameter-search fits but no total wall-clock time. The final prediction is a compact XGBoost model over engineered features; feature-extraction and inference timings are not reported.
Alternatives
The notebook measures each feature family alone and removes each family from the full feature set. The answer-probability group is the strongest single family; removing it causes the largest ablation drop. A random classifier provides the 0.50 baseline. External semantic encoders are not used in the selected solution.
Code
Solution code
"""Train-only implementation of Poland 2025 Stage I: hallucination detection.
The feature groups and XGBoost classifier follow the organizer's worked
notebook: token-probability statistics, TF-IDF similarities, answer agreement,
style, question type, and probabilities on extracted answer spans. TF-IDF is
fit only on the supplied training records. Validation labels are read only for
the final ROC-AUC report. The estimator uses the best parameters selected by
the organizer's three-fold search on training data.
Usage:
python hallucination_detection.py train.json valid.json
Dependencies allowed by the task: numpy, scikit-learn, and xgboost. The script
does not download data, load pretrained encoders, or use external assets.
"""
from __future__ import annotations
import argparse
import json
import re
from collections import Counter
from difflib import SequenceMatcher
from pathlib import Path
from typing import Any
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics import roc_auc_score
from sklearn.metrics.pairwise import cosine_similarity
SUPPORT_COUNT = 4
def load_records(path: str | Path) -> list[dict[str, Any]]:
with open(path, "r", encoding="utf-8") as handle:
records = json.load(handle)
if not isinstance(records, list):
raise ValueError("JSON root must be a list of sample objects")
return records
def _text(value: Any) -> str:
return value if isinstance(value, str) else ""
def _as_list(value: Any) -> list:
return value if isinstance(value, list) else []
def clean_sample(sample: dict[str, Any]) -> dict[str, Any]:
"""Trim malformed support token/probability tails at the first blank line."""
cleaned = dict(sample)
new_tokens: list[list] = []
new_probabilities: list[list] = []
tokens_by_answer = _as_list(sample.get("supporting_tokens"))
probs_by_answer = _as_list(sample.get("supporting_probabilities"))
for tokens, probabilities in zip(tokens_by_answer, probs_by_answer):
tokens = _as_list(tokens)
probabilities = _as_list(probabilities)
cutoff = next((i for i, token in enumerate(tokens) if "\n\n" in str(token)), None)
if cutoff is not None:
tokens = tokens[: cutoff + 1]
probabilities = probabilities[: cutoff + 1]
common = min(len(tokens), len(probabilities))
new_tokens.append(tokens[:common])
new_probabilities.append(probabilities[:common])
cleaned["supporting_tokens"] = new_tokens
cleaned["supporting_probabilities"] = new_probabilities
return cleaned
def _clean_text(text: str) -> str:
return re.sub(r"[^\w\s]", "", text.lower()).strip()
def _answer_spans(text: str) -> list[str]:
return re.findall(r"<answer>(.*?)</answer>", text, flags=re.IGNORECASE | re.DOTALL)
class HallucinationFeatures:
"""All six organizer feature families in a single deterministic pipeline."""
def __init__(self) -> None:
self.vectorizer = TfidfVectorizer()
self.feature_names_: list[str] | None = None
self.is_fitted = False
def fit(self, train_records: list[dict[str, Any]]) -> "HallucinationFeatures":
corpus: list[str] = []
for raw in train_records:
sample = clean_sample(raw)
corpus.append(_clean_text(_text(sample.get("question"))))
corpus.append(_clean_text(_text(sample.get("answer"))))
corpus.extend(
_clean_text(_text(answer))
for answer in _as_list(sample.get("supporting_answers"))
)
if not corpus or not any(corpus):
raise ValueError("training data contains no usable text for TF-IDF")
self.vectorizer.fit(corpus)
self.is_fitted = True
return self
@staticmethod
def _prob_stats(values: Any) -> tuple[float, float, float, float, float]:
arr = np.asarray(values, dtype=np.float64)
if arr.size == 0:
return (0.0, 0.0, 0.0, 0.0, 0.0)
return (
float(arr.min()),
float(arr.mean()),
float(arr.max() - arr.min()),
float(-np.log(arr + 1e-12).sum() / arr.size),
float(arr.var()),
)
@staticmethod
def _token_answer_probability(tokens: list, probabilities: list) -> tuple[float, str]:
try:
start = tokens.index("<answer>")
end = tokens.index("</answer>")
except ValueError:
return 0.0, ""
if end < start:
start, end = end, start
left, right = start + 1, end
span_probs = probabilities[left:right]
span_tokens = tokens[left:min(right, len(tokens))]
if not span_probs or not span_tokens:
return 0.0, "".join(map(str, span_tokens))
return float(np.mean(np.asarray(span_probs, dtype=np.float64))), "".join(map(str, span_tokens))
def _features(self, raw_sample: dict[str, Any]) -> dict[str, float]:
sample = clean_sample(raw_sample)
features: dict[str, float] = {}
supporting_answers = [_text(x) for x in _as_list(sample.get("supporting_answers"))]
supporting_tokens = _as_list(sample.get("supporting_tokens"))
supporting_probs = _as_list(sample.get("supporting_probabilities"))
# Token-probability statistics for each of the four alternate answers.
for i in range(SUPPORT_COUNT):
probs = supporting_probs[i] if i < len(supporting_probs) else []
mtp, avg, mpd, gnll, var = self._prob_stats(probs)
features[f"[stats] mtp_{i}"] = mtp
features[f"[stats] avgtp_{i}"] = avg
features[f"[stats] mpd_{i}"] = mpd
features[f"[stats] g_nll_{i}"] = gnll
features[f"[stats] var_prob_{i}"] = var
# Semantic/text features: answer-span vocabulary diversity, TF-IDF, QA cosine.
extracted = [span for answer in supporting_answers for span in _answer_spans(answer)]
normalized_spans = [_clean_text(span) for span in extracted]
words = " ".join(normalized_spans).split()
features["[sem] bow_unique_ratio"] = len(set(words)) / len(words) if words else 0.0
if normalized_spans:
span_matrix = self.vectorizer.transform(normalized_spans)
mean_vector = np.asarray(span_matrix.mean(axis=0)).ravel()
features["[sem] tfidf_avg"] = float(mean_vector.mean())
else:
features["[sem] tfidf_avg"] = 0.0
question = _clean_text(_text(sample.get("question")))
answer = _clean_text(_text(sample.get("answer")))
qa_matrix = self.vectorizer.transform([question, answer])
features["[sem] qa_cosine_similarity"] = float(
cosine_similarity(qa_matrix[0:1], qa_matrix[1:2])[0, 0]
)
# Agreement across extracted answers (exact text, as in the notebook).
answer_counts = Counter(extracted)
common_count = answer_counts.most_common(1)[0][1] if answer_counts else 0
features["[cross] num_unique_answers"] = float(len(answer_counts))
features["[cross] most_common_answer_count"] = float(common_count)
features["[cross] agreement_ratio"] = (
common_count / len(supporting_answers) if supporting_answers else 0.0
)
# Style and structure, including the notebook's longest matching block.
def sentences(text: str) -> list[str]:
return [part.strip() for part in re.split(r"[.!?]+", text) if part.strip()]
answer_sentences = sentences(_text(sample.get("answer")))
if not answer_sentences:
answer_sentences = [_text(sample.get("answer"))]
all_sentences = answer_sentences + [
sentence for other in supporting_answers for sentence in sentences(other)
]
lengths = [len(sentence.split()) for sentence in all_sentences]
features["[style] sentence_length_variance"] = float(np.var(lengths)) if lengths else 0.0
features["[style] average_sentence_length"] = float(np.mean(lengths)) if lengths else 0.0
lcs = [
SequenceMatcher(None, _text(sample.get("answer")), other).find_longest_match(
0, len(_text(sample.get("answer"))), 0, len(other)
).size
for other in supporting_answers
]
features["[style] average_lcs"] = float(np.mean(lcs)) if lcs else 0.0
# Question type one-hot flags.
q_lower = question.strip()
for word in ("who", "what", "where", "when", "why", "how", "which"):
features[f"[type] is_{word}"] = float(bool(re.match(rf"^{word}\b", q_lower)))
# Mean answer-span probabilities, categorized by substring in the main answer.
answer_probabilities: list[float] = []
other_probabilities: list[float] = []
for i, support_answer in enumerate(supporting_answers[:SUPPORT_COUNT]):
token_list = supporting_tokens[i] if i < len(supporting_tokens) else []
prob_list = supporting_probs[i] if i < len(supporting_probs) else []
mean_probability, span_text = self._token_answer_probability(token_list, prob_list)
if span_text and span_text in _text(sample.get("answer")):
answer_probabilities.append(mean_probability)
else:
other_probabilities.append(mean_probability)
for name, values in (("answer", answer_probabilities), ("other", other_probabilities)):
features[f"[ansprob] {name}_min"] = float(np.min(values)) if values else 0.0
features[f"[ansprob] {name}_mean"] = float(np.mean(values)) if values else 0.0
features[f"[ansprob] {name}_max"] = float(np.max(values)) if values else 0.0
features[f"[ansprob] {name}_std"] = float(np.std(values)) if values else 0.0
features[f"[ansprob] {name}_len"] = float(len(values))
for i in range(SUPPORT_COUNT):
values = np.asarray(
supporting_probs[i] if i < len(supporting_probs) else [], dtype=np.float64
)
features[f"[ansprob] supporting_proba_{i}_min"] = float(values.min()) if values.size else 0.0
features[f"[ansprob] supporting_proba_{i}_mean"] = float(values.mean()) if values.size else 0.0
features[f"[ansprob] supporting_proba_{i}_max"] = float(values.max()) if values.size else 0.0
features[f"[ansprob] supporting_proba_{i}_std"] = float(values.std()) if values.size else 0.0
features[f"[ansprob] supporting_proba_{i}_len"] = float(values.size)
return features
def transform(self, records: list[dict[str, Any]]) -> np.ndarray:
if not self.is_fitted:
raise RuntimeError("fit the feature pipeline on training records first")
rows = [self._features(record) for record in records]
if not rows:
return np.empty((0, len(self.feature_names_ or [])), dtype=np.float32)
if self.feature_names_ is None:
self.feature_names_ = list(rows[0])
expected = self.feature_names_
if any(list(row) != expected for row in rows):
raise RuntimeError("feature schema changed between records")
return np.asarray([[row[name] for name in expected] for row in rows], dtype=np.float32)
def fit_transform(self, train_records: list[dict[str, Any]]) -> np.ndarray:
self.fit(train_records)
matrix = self.transform(train_records)
self.feature_names_ = list(self._features(train_records[0])) if train_records else []
return matrix
class HallucinationDetector:
"""XGBoost wrapper that predicts ``P(is_correct=True)``."""
def __init__(self, n_estimators: int = 500):
self.features = HallucinationFeatures()
self.n_estimators = int(n_estimators)
self.model = None
def fit(self, train_records: list[dict[str, Any]]) -> "HallucinationDetector":
if not train_records:
raise ValueError("training data is empty")
try:
from xgboost import XGBClassifier
except ImportError as exc:
raise RuntimeError(
"Install the task-permitted xgboost package to fit the classifier"
) from exc
x_train = self.features.fit_transform(train_records)
y_train = np.asarray([int(bool(row.get("is_correct", False))) for row in train_records])
self.model = XGBClassifier(
n_estimators=self.n_estimators,
learning_rate=0.01,
max_depth=3,
min_child_weight=2,
subsample=0.5,
colsample_bytree=0.8,
gamma=0.1,
objective="binary:logistic",
eval_metric="auc",
random_state=42,
n_jobs=-1,
)
# Validation data is deliberately not passed to fit or early stopping.
self.model.fit(x_train, y_train)
return self
def predict_correct_probability(self, records: list[dict[str, Any]]) -> np.ndarray:
if self.model is None:
raise RuntimeError("call fit(train_records) before prediction")
x = self.features.transform(records)
class_one_column = list(self.model.classes_).index(1)
return self.model.predict_proba(x)[:, class_one_column]
def predict_hallucinations(self, sample: dict[str, Any]) -> float:
"""Competition-compatible scalar; despite the name, returns P(correct)."""
return float(self.predict_correct_probability([sample])[0])
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("train_json", help="released training JSON")
parser.add_argument("valid_json", help="released validation JSON; used only for scoring")
parser.add_argument("--n-estimators", type=int, default=500)
args = parser.parse_args()
train_records = load_records(args.train_json)
valid_records = load_records(args.valid_json)
detector = HallucinationDetector(n_estimators=args.n_estimators).fit(train_records)
probabilities = detector.predict_correct_probability(valid_records)
targets = np.asarray([int(bool(row.get("is_correct", False))) for row in valid_records])
auc = roc_auc_score(targets, probabilities)
print(f"train records: {len(train_records)}")
print(f"validation records: {len(valid_records)}")
print(f"validation ROC AUC: {auc:.4f}")
score = 0 if auc <= 0.70 else (100 if auc >= 0.82 else round(100 * (auc - 0.70) / 0.12))
print(f"estimated score: {score}/100")
if __name__ == "__main__":
main()
Editorials are written by members of the SOTA community, not by the olympiad. The official statement and scoring win wherever an editorial differs.