Checklist OAI 2026 Stage II · Task 2
SOTA-AI Editorial
Token Predictor
Using GPT-2 XL and a 512-token Shakespeare prompt, split 256 candidate tokens into those that do and do not occur among the next 128 tokens.
Hints
Hint 1
What exactly makes a candidate good: does it occur anywhere in the next 128-token continuation, or only as the very next token?
Hint 2
How do global training-stream frequency and occurrences near the end of the prompt compare with one another?
Hint 3
Do counts over several prompt suffix lengths or recency-weighted counts rank candidates better than a single frequency?
Hint 4
The good and bad set sizes vary. Can a size predictor trained only from the permitted text stream improve balanced accuracy over a fixed selection rule?
The solution in one line
Rank candidates with a train-stream-fitted logistic score from unigram and multi-scale prompt/recency counts, then assign the top 83 candidates to `good_answer` (measured: 100/100 on the released validation split).
The editorial
Poland 2026 Stage 2 — Predyktor tokenów (Token Predictor)
Problem domain: NLP, language-model context features, candidate-token classification, weakly supervised learning from a text stream
Evaluation metric: Mean per-example balanced accuracy: the average of recall on good-token IDs and recall on bad-token IDs, then averaged over examples. Accuracy at or below 0.62 earns 0 points; at or above 0.68 earns 100, with linear scaling between those thresholds.
Abridged statement
Given a 512-token Shakespeare prompt and a candidate list, divide the candidate token IDs into two disjoint lists. A good token appears in the next 128 tokens of the source text; a bad token does not. The 301,966-token training stream and a pretrained GPT-2 XL are provided. The released validation set has 99 examples, and the hidden test has 100. In final evaluation, inference must finish within five minutes on the competition GPU. The allowed libraries are NumPy and PyTorch.
Dataset analysis and EDA
The training input is one contiguous 301,966-token stream encoded with GPT-2 byte-level BPE, drawn from Shakespeare plays. The validation records contain a 512-token prompt, good and bad candidate IDs, and a shuffled candidate list. The good and bad set sizes vary across records: in the released validation archive, good-set sizes range from 52 to 90 and bad-set sizes from 54 to 197. Thus, candidate-set size and class balance are not constant. The 99 validation prompts come from a different, sequential passage of the same source text, so adjacent examples are related rather than independent.
EDA inspected decoded text, prompt suffixes, candidate frequency, token frequency in the full training stream, and counts in progressively shorter prompt windows. I also tested exact suffix n-gram lookup for lengths 2–8. The source stream contains repeated vocabulary and local structure, but exact long suffix matches are sparse for these validation prompts. Global token frequency and prompt occurrence are informative, while neither alone is strong enough to solve the task. A possible GPT-2 XL candidate-logit feature could not be tested because the linked checkpoint was absent from the workspace and could not be downloaded here.
Solution strategy and experiments
The selected implementation builds per-candidate features from the training-stream unigram count, token ID, counts in the full prompt and its last 8–512 tokens, exponentially recency-weighted counts, and prompt-presence/repetition flags. A small logistic ranker was fit offline on all 1,178 stride-256 synthetic 512-to-128 windows available from the released training stream. Good IDs are the unique IDs in each following 128-token horizon. For each synthetic example, negatives are the unique IDs from a separate random 512-token passage, with overlap against the positive set removed. Fixed coefficients are embedded in the submitted NumPy implementation; no model is fit on the validation labels at inference.
The positive-list size prior is also estimated from those training-only windows: the mean number of unique IDs in a 128-token horizon is 82.53, so the implementation assigns the 83 highest-ranked candidates to good_answer. This prior does not assume that the negative pool has the same size as the positive set.
| Experiment | Validation result | Decision |
|---|---|---|
| Rank candidates by global training-stream unigram frequency | Candidate AUC 0.7075; best per-case balanced accuracy about 0.651 | Keep as a useful baseline feature, not as the sole model. |
| Earlier synthetic ranker (mixed uniform-vocabulary and 256-token text negatives), top half | Mean balanced accuracy 0.674894; 91/100 points | Previous baseline; improved by matching negative examples to broader text distractor pools. |
| 1,178-window ranker with separate 512-token text negatives, top half | Candidate AUC 0.74860; mean balanced accuracy 0.68215; 100/100 points | Improved the ranking model using train-stream-only synthetic examples. |
| Same ranker, select 83 candidates using the train-stream mean horizon size | Mean balanced accuracy 0.684145; 100/100 points | Selected. The count is rounded from the training-only mean of 82.53 positive IDs per horizon. |
| Train-only Ridge prediction of per-example positive-set size | MAE 8.20 IDs; mean balanced accuracy 0.68074 | Tested for adaptive set size; lower than the train-derived fixed count. |
| Select a fixed count of 87 after comparing counts on validation | Mean balanced accuracy 0.68476 | Validation-tuned diagnostic only; not used because the count was selected from validation labels. |
| Set size from the sum of candidate probabilities, with a 0.95 scale | Earlier ranker: MAE 13.13 IDs; mean balanced accuracy 0.67333 | Tested and discarded. |
| Exact suffix n-gram continuation features, lengths 2–8 | Grouped-CV candidate AUC 0.7420 / threshold balanced accuracy 0.6730, versus 0.7431 / 0.6736 for numeric prompt-frequency features | Sparse exact matches did not help; excluded from the final scorer. |
| Logistic candidate ranker trained on validation labels, 5-fold grouped CV over contiguous blocks of about 20 examples with a fold-fitted size predictor | Mean balanced accuracy 0.68104 | Validation-only diagnostic, not used by the submitted method. It uses labeled validation examples and is not a deployable or untouched test estimate. |
The final fixed count of 83 is derived from training-stream horizons, not the validation labels. It is not a claim that every example has the same positive-set size. A train-only per-example size regressor was also tested, but its validation score was lower. Validation labels were used only to calculate development metrics and compare global strategies; the final prediction method does not read them, identify validation rows, or use sample-specific answers.
Results and diagnostics
The final file is solutions/stage2/token_predictor.py. It was run against all 99 released validation records using only train_data and each record's prompt/candidate list. It reproduced mean per-example balanced accuracy 0.684145, corresponding to 100/100 under the notebook's scaling and rounding. The outputs formed disjoint partitions and covered every unique candidate ID in all 99 records. Other train-only random-window/C variants scored between 0.683416 and 0.685613 at the same fixed count of 83.
The released validation score reaches the notebook's full-credit threshold. This does not establish a 100/100 hidden-test score: selector variants were compared on a public split whose rows are sequential and related. The GPT-2 XL candidate-logit feature remains disabled because the linked checkpoint was unavailable, and no hidden-test score is available.
Implementation and compute footprint
The solution uses NumPy feature extraction and fixed logistic coefficients; it imports no scikit-learn. It counts the training stream once and caches the resulting unigram table. Each call ranks only the supplied candidate IDs, deduplicates IDs defensively, and returns a disjoint (bad_answer, good_answer) partition. The official two-argument notebook call and the requested classify(model, prompt, token_list) form are both supported. The 99 validation calls took 0.08 seconds on the local CPU in this development environment; this was not a timed competition-GPU run. No GPU is needed for the selected method.
An optional, disabled GPT feature can aggregate candidate log-probabilities across the last prompt positions if model is a callable PyTorch model. It was not run against the actual checkpoint, is not included in the reported score, and should remain disabled unless separately validated within the runtime limit.
Code
Solution code
"""Reference selector for the Poland 2026 Stage 2 Token Predictor task.
The fixed coefficients below were fit on synthetic 512 -> 128 windows from the
released ``train_data`` stream. Each synthetic negative pool is the unique token
IDs in a separate 512-token train-stream passage. No validation labels or test
data are used by this implementation. The optional GPT score is disabled by
default because the linked checkpoint was unavailable in development.
"""
import numpy as np
import sys
try: # PyTorch is present in the competition notebook; keep local import optional.
import torch
except Exception: # pragma: no cover - only used by the no-Torch fallback.
torch = None
_FEATURE_MEAN = np.array([
4.439068193316061, 7.5808674621186505, 0.32660577188298817,
0.011596554301948505, 0.02197783436966874, 0.04064807202856513,
0.0723129446508735, 0.1235428903836694, 0.20539516229626634,
0.2708107025597128, 0.32660577188298817, 0.018205440508726144,
0.035132483759573965, 0.06873787981149397, 0.1351611388203299,
0.2618426371948405, 0.4566788500004205, 0.6638684132018945,
0.2647733219987104, 0.14929028489806068,
], dtype=np.float64)
_FEATURE_SCALE = np.array([
1.9453688139198702, 1.8419604652077692, 0.6482562535473356,
0.09165644305498111, 0.13064430140027666, 0.18731052398013506,
0.26483061425757753, 0.3652494606517419, 0.49312811124244105,
0.5807374601567306, 0.6482562535473356, 0.1152347974453774,
0.18517550220255316, 0.3221801734017333, 0.5918317114819062,
1.107257373779291, 1.9077164632841024, 2.7679248635975813,
0.4412124317784936, 0.35637437580744263,
], dtype=np.float64)
_LOGIT_COEF = np.array([
0.036170994594721985, -0.008155684753416471, 0.11274541700691562,
0.0006105046836685159, 0.003234422842235215, 0.046435253263629205,
0.10919321103044169, 0.0812843312483594, 0.06522753787115525,
0.0038248940943025334, 0.11274541700691562, 0.03161033431627779,
0.01647783389316946, -0.07417918247656942, -0.16562777234102322,
-0.06635250660690528, 0.2100115080949274, 0.4410114644265936,
0.11645010143341361, 0.0221769163112388,
], dtype=np.float64)
_LOGIT_INTERCEPT = -0.0291854165435196
_VOCAB_SIZE = 50257
_GOOD_COUNT_PRIOR = 83 # round(mean unique 128-token horizon size: 82.53)
def _to_numpy(value, dtype=None):
"""Convert Python, NumPy, or CPU/GPU tensor values into a NumPy array."""
if hasattr(value, "detach"):
value = value.detach()
if hasattr(value, "cpu"):
value = value.cpu()
if hasattr(value, "numpy"):
value = value.numpy()
return np.asarray(value, dtype=dtype)
class TokenPredictorSolution:
"""Rank candidate tokens using a train-stream-fitted logistic model.
``model_logit_blend`` is an experimental option. It can add a standardized
score aggregated from the GPT model's next-token log probabilities at the
last 128 prompt positions. Leave it at 0 unless that feature is validated
on the official checkpoint and within the five-minute budget.
"""
def __init__(self, model_logit_blend=0.0):
self.model_logit_blend = float(model_logit_blend)
self._token_counts = None
@staticmethod
def _notebook_global(name):
"""Find data/model globals whether this code is pasted or imported."""
value = globals().get(name)
if value is None:
main_module = sys.modules.get("__main__")
value = getattr(main_module, name, None) if main_module is not None else None
return value
def _get_token_counts(self, token_list):
if self._token_counts is None:
stream = self._notebook_global("train_data")
if stream is None:
return np.zeros(max(_VOCAB_SIZE, max(token_list, default=-1) + 1), dtype=np.int64)
values = _to_numpy(stream).reshape(-1).astype(np.int64, copy=False)
size = max(_VOCAB_SIZE, int(values.max()) + 1 if values.size else _VOCAB_SIZE)
self._token_counts = np.bincount(values, minlength=size)
if len(token_list) and max(token_list) >= len(self._token_counts):
pad = np.zeros(max(token_list) + 1 - len(self._token_counts), dtype=np.int64)
self._token_counts = np.concatenate([self._token_counts, pad])
return self._token_counts
def _candidate_features(self, prompt, candidates, token_counts):
p = _to_numpy(prompt, dtype=np.int64).reshape(-1)
p = p[-512:]
c = np.asarray(candidates, dtype=np.int64)
vocab_size = max(len(token_counts), _VOCAB_SIZE, int(c.max()) + 1 if c.size else _VOCAB_SIZE)
# The column order matches the synthetic-window fit described above.
columns = [np.log1p(token_counts[c]), np.log1p(c)]
for width in (0, 8, 16, 32, 64, 128, 256, 384, 512):
window = p[-(width or len(p)):]
freq = np.bincount(window, minlength=vocab_size)
columns.append(np.log1p(freq[c]))
positions = np.arange(len(p), dtype=np.float64)
for decay in (8, 16, 32, 64, 128, 256, 512):
weights = np.exp(-(len(p) - 1 - positions) / float(decay))
weighted = np.bincount(p, weights=weights, minlength=vocab_size)
columns.append(weighted[c])
prompt_freq = np.bincount(p, minlength=vocab_size)[c]
columns.extend([(prompt_freq > 0).astype(np.float64),
(prompt_freq > 1).astype(np.float64)])
return np.column_stack(columns)
def _model_context_score(self, model, prompt, candidates):
"""Return mean next-token log probability over the prompt's last 128 steps."""
if torch is None or model is None or not callable(model) or not candidates:
return None
try:
try:
device = next(model.parameters()).device
except (AttributeError, StopIteration, TypeError):
device = None
p = prompt
if not torch.is_tensor(p):
p = torch.as_tensor(p, dtype=torch.long, device=device)
else:
p = p.to(device=device, dtype=torch.long)
p = p.reshape(-1)[-512:]
with torch.no_grad():
logits = model(p.unsqueeze(0))[0]
count = min(128, int(logits.shape[0]))
log_probs = torch.log_softmax(logits[-count:].float(), dim=-1)
candidate_ids = torch.as_tensor(candidates, dtype=torch.long, device=logits.device)
selected = log_probs.index_select(1, candidate_ids)
aggregate = torch.logsumexp(selected, dim=0) - np.log(float(count))
return _to_numpy(aggregate, dtype=np.float64)
except Exception:
# Fall back cleanly if a platform wrapper exposes a nonstandard model.
return None
def classify(self, model, prompt=None, token_list=None):
"""Return disjoint ``(bad_answer, good_answer)`` candidate partitions.
The three-argument form matches the official evaluator. The two-argument
form is also accepted for the starter notebook's ``classify(prompt, list)``
call; in that case the model is read from the notebook global if present.
"""
if token_list is None:
token_list = prompt
prompt = model
model = self._notebook_global("model")
# The task normally supplies unique IDs, but collapse duplicates before
# ranking so an ID can never be placed in both output partitions.
original = [int(x) for x in _to_numpy(token_list).reshape(-1).tolist()]
candidates = np.unique(np.asarray(original, dtype=np.int64))
if not candidates.size:
return [], []
token_counts = self._get_token_counts(candidates.tolist())
x = self._candidate_features(prompt, candidates, token_counts)
standardized = (x - _FEATURE_MEAN) / _FEATURE_SCALE
scores = standardized @ _LOGIT_COEF + _LOGIT_INTERCEPT
if self.model_logit_blend != 0.0:
context_score = self._model_context_score(model, prompt, candidates.tolist())
if context_score is not None and np.all(np.isfinite(context_score)):
spread = float(np.std(context_score))
if spread > 1e-8:
lm_z = (context_score - float(np.mean(context_score))) / spread
scores = scores + self.model_logit_blend * np.clip(lm_z, -5.0, 5.0)
# Estimate the number of distinct IDs in the next 128-token horizon
# from 1,178 synthetic train-stream windows (mean 82.53). This avoids
# assuming that the negative candidate pool is the same size as the
# positive set.
n_good = max(1, min(len(candidates), _GOOD_COUNT_PRIOR))
good_idx = set(np.argsort(scores, kind="stable")[-n_good:].tolist())
good_ids = set(candidates[list(good_idx)].tolist())
good = [int(value) for value in candidates if int(value) in good_ids]
bad = [int(value) for value in candidates if int(value) not in good_ids]
return bad, good
# Name used by the official starter notebook.
YourSolution = TokenPredictorSolution
Editorials are written by members of the SOTA community, not by the olympiad. The official statement and scoring win wherever an editorial differs.