← Retour à l'épreuve

NEOAI 2026 · Correction IOAI Training

Message Author Re-identification

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

Métrique
mAP@5
Évaluée sur
held-out validation (official test labels are private)
Notre score
mAP@5 0.247 (25 held-out authors)
Niveau attendu
author solution 0.173 on public LB (pools differ)

Niveau attendu atteint

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

You get short chat messages in Russian (median 60 characters, the style of a Telegram group) and must find, for every test message, the 5 other test messages most likely written by the same person. Training messages come from 50 authors (11 506 messages); the 5 122 test messages come from other authors, so this is re-identification: you cannot learn the test authors as classes, you need a similarity that groups messages by writer. The twist is the model you are given. It is a 22-layer ModernBERT encoder (the USER2-base architecture), but both copies are broken: in the "zero" copy a quarter of all weights were set to 0, and in the "shuffle" copy only an eighth of the weights were zeroed but the 22 transformer blocks were put in a random order.

The data comes from the Kaggle competition archive. The test authors are not given and we never submitted anything, so all scores below come from our own validation: we keep 25 of the 50 training authors aside (3 540 messages) and retrieve among their messages only.

How you are scored

mAP@5. For a query message qq, let relq(i)=1\text{rel}_q(i)=1 if the ii-th retrieved message has the same author, and Pq(k)P_q(k) the precision of the first kk retrieved messages. Then

AP@5(q)=1min⁡(5,nq)∑k=15Pq(k) relq(k),\text{AP@5}(q) = \frac{1}{\min(5, n_q)} \sum_{k=1}^{5} P_q(k)\,\text{rel}_q(k),

where nqn_q is the number of other messages by the same author, and mAP@5 averages this over all queries. A correct hit at rank 1 counts more than one at rank 5.

What to aim for. The organisers publish no threshold. On the public leaderboard the "author solution" scores 0.173 and the best contestant 0.166. Our validation numbers are not directly comparable: we search among the messages of 25 authors, and the number of test authors is unknown (with more authors, retrieval is harder; our same pipeline drops from about 0.21 to 0.14 when the pool grows from 25 to 50 authors).

First look at the data

The messages are very short (half of them have fewer than 20 tokens), informal, full of slang and typos. An author is recognisable by habits more than by topics: the use of capitals, of "??" or ")))", certain words, the length of messages. A character n-gram TF-IDF captures exactly these habits and gives a first baseline: mAP@5 0.154 on the held-out authors (random ranking gives about 0.03).

Then the model. Comparing the two checkpoints tensor by tensor reveals what the story hides:

  • the zeroed entries are random: 25.0 % of each tensor in "zero", 12.5 % in "shuffle", at different positions;
  • where both copies are non-zero, the values are almost identical (correlation 0.9995 on the embedding table) but never exactly equal, so the two copies are two very close checkpoints of one model;
  • the layer 0 block is the only one without an attention LayerNorm (ModernBERT skips it in the first block), and it sits at position 0 in "shuffle" too.

Used as they are, both copies give poor embeddings: mAP@5 0.068 for "zero" and 0.079 for "shuffle", much worse than TF-IDF.

A first baseline

Character TF-IDF (1 to 3 characters inside words, sublinear term frequency) and cosine similarity: 0.154. Simple, but it already beats the broken models by a factor of two.

The key idea

Repair the model before using it: the two copies are damaged in complementary ways, so together they contain almost the whole network.

Step 1: put the layers back in order. A transformer block is defined by four big matrices (attention WqkvW_{qkv}, WoW_o, MLP WiW_i, WoW_o). For true layer ii in "zero" and position jj in "shuffle", compute the correlation of these matrices on the entries that are non-zero in both. The left panel shows the result: every row has exactly one bright cell (correlation 0.999), all the others are at 0.002. Different layers of a trained network are essentially uncorrelated, while the same layer in two close checkpoints is almost identical, so the matching is unambiguous. We still solve it as an assignment problem (Hungarian algorithm, linear_sum_assignment) to guarantee a permutation.

Layer matching and retrieval quality of each model

Reordering alone barely helps (0.079 to 0.084): the reordered copy still misses 12.5 % of its weights, and the residual stream of a transformer is surprisingly tolerant to layer order, so the order was not the main damage.

Step 2: fill the holes. Take every weight from the less damaged "shuffle" copy, and wherever it is 0, use the value from "zero":

wrepaired={wshuffleif wshuffle≠0wzerootherwise.w_{\text{repaired}} = \begin{cases} w_{\text{shuffle}} & \text{if } w_{\text{shuffle}} \neq 0 \\ w_{\text{zero}} & \text{otherwise.} \end{cases}

Because the two zero masks are independent, a weight is lost only if it was zeroed in both copies, which happens with probability 0.125×0.25≈3 %0.125 \times 0.25 \approx 3\,\% instead of 12.5 % or 25 %. This is the same reasoning as RAID storage: two damaged copies with independent failures almost never fail at the same place. The repaired model jumps to 0.205, more than twice either copy and well above TF-IDF. (Averaging the two copies where both are alive gives 0.202: the values are so close that it makes no difference.)

Why does a model trained for something else find authors at all? The checkpoints seem to have been trained for author identification on other users (their state dictionary even contains a classification head with 50 class centres), so the embedding space already groups messages by writing style.

The full solution

  1. Convert the two model.pt files to safetensors inside the sandbox (they are third-party pickles).
  2. Repair: recover the layer order by weight correlation, fill the zeros of "shuffle" with "zero".
  3. Fine-tune the top 6 blocks and the final norm on the 50 training authors with a CosFace loss (cosine softmax with margin m=0.2m = 0.2 and scale s=30s = 30), which pulls each message towards a learnable centre of its author and away from the others:
L=−log⁡es(cos⁡θy−m)es(cos⁡θy−m)+∑c≠yescos⁡θc.\mathcal L = -\log \frac{e^{s(\cos\theta_{y} - m)}}{e^{s(\cos\theta_{y} - m)} + \sum_{c \neq y} e^{s\cos\theta_c}}.

Batches of 16 messages padded to at most 64 tokens, AdamW (learning rate 2×10−52\times 10^{-5} for the encoder, 10−210^{-2} for the centres), 2 epochs. On the held-out authors this adds only 1.4 points (0.205 to 0.219): with 25 training authors the model quickly starts to memorise them. 4. Embed each test message (mean pooling of the last layer, L2 normalisation). 5. Mix two similarities: cosine of the embeddings and cosine of the character TF-IDF vectors. They live on different scales, so we standardise each row (z-score per query) before mixing, S=0.7 z(Semb)+0.3 z(Stfidf)S = 0.7\,z(S_{\text{emb}}) + 0.3\,z(S_{\text{tfidf}}).

S = 0.7 * zrows(E @ E.T) + 0.3 * zrows((X @ X.T).toarray())
np.fill_diagonal(S, -np.inf)                 # a message may not retrieve itself
top = np.argsort(-S, axis=1)[:, :5]
  1. Output the 5 best test messages for each test message.

Results

mAP@5 on the 3 540 messages of 25 held-out training authors (the test authors are private).

approachmAP@5
random ranking0.03
"zero" copy as given0.068
"shuffle" copy as given0.079
"shuffle" copy, layers reordered0.084
character TF-IDF0.154
repaired (reordered, holes averaged)0.202
repaired (reordered, holes filled)0.205
repaired + fine-tuned 2 epochs on 25 other authors0.219
repaired + fine-tuned 3 epochs0.216
fine-tuned (3 epochs) + TF-IDF, z-score mix 0.7 / 0.3 (final)0.247
same + query expansion with 3 neighbours0.253

The final pipeline reaches 0.247. Query expansion (replacing each similarity row by its average with the rows of its 3 nearest neighbours) added 0.6 points, within the noise of a 25-author evaluation, and its memory cost grows quickly, so we left it out. Mixing similarities from two very different views (a neural model of meaning and style, and raw character habits) is worth 3 points, more than fine-tuning.

What to remember

  • Look inside the weights. Counting zeros and correlating tensors told us everything: which entries were destroyed, that the two copies were the same model, and where each layer went. Five minutes of tensor statistics beat any amount of training here.
  • Independent damage is repairable. Two copies that each lose a random part of their weights lose almost nothing together (p1p2p_1 p_2 instead of p1p_1 or p2p_2). The same idea underlies ensembling, RAID and error-correcting codes.
  • Matching problems are assignment problems. When you need a one-to-one correspondence (layers, neurons, objects between frames), build a similarity matrix and solve it with the Hungarian algorithm.
  • Re-identification is retrieval, not classification. Train with a margin loss on the authors you have, but evaluate by retrieving among authors the model never saw, exactly as the test does.
  • Mix similarities after normalising them. Cosines from two models have different scales and spreads; a per-query z-score makes a weighted sum meaningful.
Code complet (solution.py)
"""Message Author Re-identification (NEOAI 2026, day 1, NLP).

Two damaged copies of one ModernBERT encoder (USER2-base architecture, 22 layers):
  * "zero":    25 % of the weights set to 0, layers in the right order
  * "shuffle": 12.5 % of the weights set to 0, layers in a random order
Pipeline:
  1. (sandbox) convert the two model.pt files to safetensors  -> see convert() below
  2. recover the layer order of "shuffle" by matching each of its layers to the layer of
     "zero" with the highest weight correlation (Hungarian algorithm), then repair the model:
     take every weight from "shuffle" and fill its zeros with the value from "zero"
  3. fine-tune the top 6 layers on the 50 training authors (CosFace loss)
  4. embed test messages (mean pooling), mix cosine similarity with a char TF-IDF similarity,
     and output the 5 most similar other test messages.

Data: kaggle competition "neoai-2026-day-1-nlp" (train_.csv, test_.csv, config.json,
      tokenizer/my_tokenizer, coca_cola_zero/..., coca_cola_shuffle/...).
Usage: run_sandboxed.sh . python3 solution.py convert   (only step that unpickles third-party files)
       python3 solution.py                              (writes submission.csv, not submitted)
Runtime: ~1 min repair + ~10 min fine-tuning on Apple MPS; peak memory ~2 GB.
"""
import sys, numpy as np, pandas as pd, torch, torch.nn.functional as F
from scipy.optimize import linear_sum_assignment
from safetensors.torch import load_file, save_file
from sklearn.feature_extraction.text import TfidfVectorizer
from transformers import AutoTokenizer, ModernBertConfig, ModernBertModel

torch.set_num_threads(2); torch.manual_seed(0)
DEV = 'mps' if torch.backends.mps.is_available() else 'cpu'
L = 22
MATS = ['attn.Wqkv.weight', 'attn.Wo.weight', 'mlp.Wi.weight', 'mlp.Wo.weight']


def convert():   # run inside the sandbox: torch.load of third-party files
    for n in ['coca_cola_zero/checkpoint_user2_zero', 'coca_cola_shuffle/checkpoint_user2_shuffle']:
        sd = torch.load(f'{n}/model.pt', map_location='cpu', weights_only=True)
        save_file({k: v.contiguous() for k, v in sd.items()}, n.split('/')[0] + '.safetensors')


def corr(a, b):
    """Pearson correlation over the entries that are alive (non-zero) in both tensors."""
    m = (a != 0) & (b != 0); a, b = a[m].double(), b[m].double()
    a, b = a - a.mean(), b - b.mean()
    return float((a * b).sum() / (a.norm() * b.norm()))


def repair():
    Z = {k[6:]: v for k, v in load_file('coca_cola_zero.safetensors').items() if k.startswith('model.')}
    H = {k[6:]: v for k, v in load_file('coca_cola_shuffle.safetensors').items() if k.startswith('model.')}
    M = np.array([[np.mean([corr(Z[f'layers.{i}.{n}'], H[f'layers.{j}.{n}']) for n in MATS])
                   for j in range(L)] for i in range(L)])
    _, perm = linear_sum_assignment(-M)               # perm[i] = shuffled position of true layer i
    print('layer order recovered, matched correlations >=', M[np.arange(L), perm].min().round(3))
    Hu = {k: v for k, v in H.items() if not k.startswith('layers.')}
    for i, j in enumerate(perm):
        for k, v in H.items():
            if k.startswith(f'layers.{j}.'):
                Hu[f'layers.{i}.' + k.split('.', 2)[2]] = v
    # the shuffled copy has fewer zeros: trust it, patch its holes with the other copy
    return {k: torch.where(Hu[k] != 0, Hu[k], Z[k]) for k in Z}


def build(sd):
    cfg = ModernBertConfig.from_pretrained('config.json'); cfg._attn_implementation = 'sdpa'
    m = ModernBertModel(cfg); m.load_state_dict(sd, strict=False, assign=True)
    return m.to(DEV)


def pad_len(n, cap):                                   # few distinct shapes -> stable MPS memory
    return min(cap, 32 * ((n + 31) // 32))


def pool(m, tok, texts, cap):
    e = tok(texts, padding='max_length', truncation=True, return_tensors='pt',
            max_length=pad_len(max(len(x) for x in tok(texts).input_ids), cap)).to(DEV)
    h = m(input_ids=e.input_ids, attention_mask=e.attention_mask).last_hidden_state
    k = e.attention_mask.unsqueeze(-1).float()
    return F.normalize((h * k).sum(1) / k.sum(1), dim=1)


def finetune(m, tok, texts, authors, n_layers=6, epochs=2, bs=16, s=30.0, margin=0.2):
    for p in m.parameters():
        p.requires_grad = False
    params = list(m.final_norm.parameters()) + [p for l in m.layers[-n_layers:] for p in l.parameters()]
    for p in params:
        p.requires_grad = True
    classes = {a: i for i, a in enumerate(sorted(set(authors)))}
    y = np.array([classes[a] for a in authors])
    W = torch.nn.Parameter(torch.randn(len(classes), m.config.hidden_size, device=DEV) * 0.02)
    opt = torch.optim.AdamW([{'params': params, 'lr': 2e-5}, {'params': [W], 'lr': 1e-2}], weight_decay=0.01)
    for ep in range(epochs):
        m.train(); perm = np.random.RandomState(ep).permutation(len(texts))
        for b in range(len(texts) // bs):
            idx = perm[b * bs:(b + 1) * bs]
            z = pool(m, tok, [texts[i] for i in idx], 64)
            yy = torch.tensor(y[idx], device=DEV)
            cos = z @ F.normalize(W, dim=1).T
            loss = F.cross_entropy(s * (cos - margin * F.one_hot(yy, len(classes))), yy)   # CosFace
            loss.backward(); opt.step(); opt.zero_grad()
        print(f'  epoch {ep} last loss {loss.item():.3f}', flush=True)
    return m


@torch.no_grad()
def embed(m, tok, texts, bs=32):
    m.eval(); order = np.argsort([len(x) for x in texts]); out = np.zeros((len(texts), m.config.hidden_size), np.float32)
    for i in range(0, len(texts), bs):
        idx = order[i:i + bs]
        out[idx] = pool(m, tok, [texts[j] for j in idx], 128).cpu().numpy()
    return out


def zrows(S):                                          # put both similarities on the same scale
    return (S - S.mean(1, keepdims=True)) / S.std(1, keepdims=True)


if __name__ == '__main__':
    if sys.argv[1:] == ['convert']:
        convert(); sys.exit()
    if DEV == 'mps':
        torch.mps.set_per_process_memory_fraction(0.12)
    tr = pd.read_csv('train_.csv').dropna().reset_index(drop=True)
    te = pd.read_csv('test_.csv'); te['text'] = te.text.fillna('')
    tok = AutoTokenizer.from_pretrained('tokenizer/my_tokenizer')
    m = finetune(build(repair()), tok, list(tr.text), list(tr.target))
    E = embed(m, tok, list(te.text))
    X = TfidfVectorizer(analyzer='char_wb', ngram_range=(1, 3), sublinear_tf=True, min_df=2).fit_transform(te.text)
    S = 0.7 * zrows(E @ E.T) + 0.3 * zrows((X @ X.T).toarray())
    np.fill_diagonal(S, -np.inf)                        # a message may not retrieve itself
    top = np.argsort(-S, axis=1)[:, :5]
    pd.DataFrame({'index': te['index'], 'target': [' '.join(map(str, te['index'].values[r])) for r in top]}
                 ).to_csv('submission.csv', index=False)
    print('wrote submission.csv (not submitted)')