← Retour à l'épreuve

EUROAI (CEOAI) 2026 · Correction IOAI Training

Trace Twins

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

Métrique
ROC-AUC per part -> points
Évaluée sur
official test
Notre score
100/100 pts (AUC A 0.917, B 0.802)
Niveau attendu
full marks at AUC 0.84 (A) and 0.78 (B)

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

A malware analyst records, for each suspicious program run in a sandbox, the sequence of system calls it makes (about 270 possible commands such as ntopenkey or ldrloaddll). Each recording is cut into windows of exactly 200 calls. You must write a function that receives pairs of windows and returns a score that is higher when the two windows were cut from the same program. The negatives are nasty: every "different program" pair is drawn from the same malware category (two Trojans, two Worms...), so you cannot win by recognising the category. In Part A the command names are real; in Part B every window has been renamed with its own random permutation of the vocabulary, so the same call has a different name in each window. You submit a pickled Python object; it is run on programs never seen in training.

How you are scored

Each part is scored by the ROC-AUC of your scores over 3,000 pairs (half twins, half same-category strangers):

AUC=P(s(twin pair)>s(stranger pair)),\text{AUC} = P\big(s(\text{twin pair}) > s(\text{stranger pair})\big),

the probability that a random positive pair is ranked above a random negative pair (0.5 = coin flip). It is then turned into points:

Part A=round⁡(max⁡(3,min⁡(50, 3+47 AA−0.50.34))),Part B=round⁡(max⁡(2,min⁡(50, 2+48 AB−0.50.28))).\text{Part A} = \operatorname{round}\Big(\max\big(3, \min(50,\ 3 + 47\,\tfrac{A_A - 0.5}{0.34})\big)\Big),\qquad \text{Part B} = \operatorname{round}\Big(\max\big(2, \min(50,\ 2 + 48\,\tfrac{A_B - 0.5}{0.28})\big)\Big).

So the targets are AUC 0.84 in Part A and 0.78 in Part B, each worth the full 50 points (statement, "Scoring" section).

How we evaluate. The round's test_data.csv contains the test programs with their program_id, and the organisers published the pre-judging script with its seeds (program split, pair sampling, scrambling). Re-running that exact construction gives the official 3,000 + 3,000 test pairs with their labels. We never looked at these pairs while developing: all choices were made on a validation split of the training programs (30 % of programs held out, as in the starter notebook).

First look at the data

The training file has 4,843 programs in 8 categories, with traces of 100 to 4,000 calls (median about 800), so a program contributes between 0 and 8 windows. Three observations guide everything:

  • Traces are extremely repetitive. Many windows are long loops of two or three calls (ldrgetprocedureaddress repeated 200 times is common). Two different programs that both spend a window in such a loop look identical, which caps what any method can reach.
  • Twins share a vocabulary, not a position. Two windows of the same program can be far apart in time, so they rarely share long exact substrings, but they tend to use the same set of calls in similar proportions.
  • In Part B the names are not random strings, they are other real names. The renaming is a permutation of the vocabulary, so a scrambled window "looks" like a normal one but every name lies.

Three versions of the same 8 calls: real names, and two independent renamings. Every name changes, but the positions holding the same command (same colour) do not, which gives the equality pattern 0 1 2 3 1 2 3 1 shared by all three.

A first baseline

Part A. Treat each window as a document whose "words" are its calls and its 2- and 3-grams, weight them by TF-IDF with a logarithmic term frequency, and score a pair by the cosine similarity of the two vectors. Validation AUC: 0.80 (0.785 on the official test), which gives 43 points. It is a fair baseline: no learning at all, just "do they use the same words?".

Part B. That baseline is useless after renaming: the same call has a different name in each window, so any feature that uses names scores AUC 0.5 (2 points).

The key idea

There are two ideas, one per part.

Part A: learn what "similar" means instead of fixing it. Cosine similarity treats every shared call as equal evidence. But sharing ntclose (used by every program) means little, while sharing a rare registry call in similar proportions means a lot, and "similar proportions" itself depends on the call. So we turn the pair problem into an ordinary classification problem. For a pair (u,v)(u, v) with frequency vectors fu,fv∈R270f_u, f_v \in \mathbb{R}^{270} we build symmetric pair features

ϕ(u,v)=[min⁡(fu,fv), ∣fu−fv∣, cos⁡(u,v), Jaccard(u,v), min⁡(du,dv), ∣du−dv∣]\phi(u, v) = \big[\min(f_u, f_v),\ |f_u - f_v|,\ \cos(u,v),\ \text{Jaccard}(u,v),\ \min(d_u, d_v),\ |d_u - d_v|\big]

(dd = number of distinct calls), and train gradient-boosted trees on tens of thousands of labelled pairs sampled from the training programs exactly as the grader samples them (half twins, half same-category strangers). The model learns which calls are discriminative and how much disagreement is normal. Symmetry matters: ϕ(u,v)=ϕ(v,u)\phi(u,v) = \phi(v,u), so the score cannot depend on the order of the pair.

Part B: describe each window with what a renaming cannot change. If π\pi is the secret permutation of a window, any statistic TT with T(π(w))=T(w)T(\pi(w)) = T(w) for every π\pi survives the scrambling. Three families of such invariants:

  1. Equality patterns. Replace each short nn-gram by the pattern of which positions are equal: ntclose ntopenkey ntclose and abc xyz abc both become 0 1 0. These patterns describe the control flow of the program (loops, alternations, open/query/close rhythms).
  2. Frequency ranks. Rename every call by its rank of frequency within the window (0 = most frequent). The relabelled sequence is identical whatever the renaming, as long as the window keeps the same frequency profile.
  3. Recurrence gaps. For each call, the distance since its previous occurrence (log-binned). A loop of period 3 produces gaps of 3, whatever the names.

Each family becomes a bag of "words" (p5:01201, r2:0,3, g:4...), and each gives a TF-IDF cosine between the two windows. Then we reuse the Part A recipe: boosting on these nine cosines plus symmetric summaries of the frequency profile (sorted counts, entropy, number of runs). To make the training pairs look like the test pairs, we scramble the training windows with the organisers' own scrambling function.

Why does this not destroy the information? Because what identifies a program is not the spelling of its calls but its behaviour: the same loops, the same call rhythms, the same balance between a few dominant calls. All of that is invariant.

The full solution

  1. Windows and pairs. Cut the training traces into windows with the organisers' code, then sample 40,000 labelled pairs per part with the grader's sampling rule (same-category negatives).

  2. Part A features. Frequency vectors over the 270 calls, TF-IDF on 1- to 3-grams (fitted on training windows only), cosine and Jaccard similarity, distinct-call counts, combined symmetrically.

  3. Part B features. Scramble the training windows; build the invariant words; one TF-IDF model per family; nine cosines plus profile summaries.

    def canon(seq):                      # ("ntclose","ntopenkey","ntclose") -> (0, 1, 0)
        m = {}; return tuple(m.setdefault(t, len(m)) for t in seq)
    words += [f"p{n}:" + "".join(map(str, canon(w[i:i+n]))) for n in (3,4,5,6)
              for i in range(len(w) - n + 1)]
    
  4. Models. One HistGradientBoostingClassifier per part (300 trees, learning rate 0.08); the score of a pair is the predicted probability of "twin".

  5. Submission. Everything (vectorisers and models) lives inside the Submission object, which implements score_A, score_B and the __call__ protocol of the grader, so cloudpickle captures a self-contained file. Training takes about 3 minutes on 2 CPU threads.

Results

approachPart A AUCPart B AUCpoints
constant score (starter notebook)0.5000.5003 + 2 = 5
Jaccard of call sets (validation)0.7500.5 (names useless)38 + 2
TF-IDF 1-3-gram cosine (validation)0.8040.545 + 2
same, official test0.78542
Part B: best single invariant view, 6-gram equality patterns (validation)0.73743
Part B: recurrence gaps + profile, boosted (validation; first try with a bug that silently disabled the other views)0.78550
pairwise boosting, all features (validation)0.9170.81750 + 50
final model, rebuilt official test0.9170.80250 + 50 = 100

Two lessons hide in this table. First, learning the similarity is worth more than any hand-tuning of the cosine: the jump from 0.80 to 0.92 in Part A comes only from letting a model weigh the evidence. Second, no single invariant view reaches the Part B target, but they are complementary: equality patterns see the control flow, ranks see the frequency profile, gaps see the loop periods, and the combination clears 0.78 comfortably.

The "bug" row is a classic Python trap worth knowing: building several TfidfVectorizer(analyzer=lambda d: ... pre ...) in a loop makes every lambda use the last value of pre, so all views silently became the same one (their AUCs were all exactly 0.5 except the last). Binding the loop variable with lambda d, pre=pre: ... fixes it.

Left: ROC curves of the final model on the rebuilt official test, AUC 0.917 for Part A and 0.802 for Part B. Right: validation AUC of each renaming-invariant view used alone in Part B (0.58 to 0.74) and of their boosted combination (0.82), above the 0.78 full-marks line.

What to remember

  • A pair-scoring task is a classification task on pairs. Build symmetric pair features (min⁡\min, ∣ ⋅−⋅ ∣|\,\cdot - \cdot\,|, similarities) and train on pairs sampled like the grader samples them, including the hard negatives.
  • When the input is corrupted by a group of transformations, look for invariants. Here the group is "all renamings", and equality patterns, frequency ranks and recurrence distances are invariant to it. The same reflex works for images (rotation-invariant features), sets (order-invariant pooling) and graphs.
  • Make the training data suffer the same corruption as the test data. Scrambling the training windows with the organisers' function is what lets the model learn which invariants matter.
  • Combine weak complementary views. Several 0.6 to 0.74 signals that see different aspects of the data beat any single one.
  • Rebuild the grader locally whenever its code is public. It turns guesswork into an exact score, and it catches format errors before they cost a submission.
Code complet (solution.py)
"""Trace Twins (CEOAI 2026, practice round 2): a full solution.

Part A (real command names): n-gram TF-IDF cosine + a pairwise gradient-boosting model on
per-command count features.  Part B (each window renamed independently): describe every
window only with renaming-invariant "words" (equality patterns inside short n-grams,
frequency-rank n-grams, recurrence gaps), then the same pairwise boosting recipe.

Needs public_traces.csv (train) and test_data.csv (the round's test programs, labels are
implied by program_id). Rebuilds the exact hidden pairs of the pre-judging script and scores them.
"""
import csv, io, pickle, random, time, json
from collections import Counter, defaultdict
import os; os.environ.setdefault("OMP_NUM_THREADS", "2")
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer, CountVectorizer
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.metrics import roc_auc_score

WINDOW, WPP = 200, 8

# ---------------- data helpers (copied from the organisers' scripts) ----------------
def load(p):
    with open(p) as f:
        r = csv.reader(f); next(r)
        return [{"program_id": int(a), "category": c, "tokens": t.split()} for a, c, t in r]

def make_windows(traces):
    out = []
    for tr in traces:
        s = tr["tokens"]; n = (len(s) // WINDOW) * WINDOW
        out += [{"program_id": tr["program_id"], "category": tr["category"], "wid": j // WINDOW,
                 "tokens": s[j:j + WINDOW]} for j in range(0, n, WINDOW)][:WPP]
    return out

def make_pairs(ws, n, seed):
    """Half positives (same program), half negatives from the SAME category."""
    rng = random.Random(seed); bc, bp = defaultdict(list), defaultdict(list)
    for k, w in enumerate(ws): bc[w["category"]].append(k); bp[w["program_id"]].append(k)
    cats = sorted(bc); multi = [p for p in bp if len(bp[p]) >= 2]; P, L = [], []
    while len(P) < n:
        if rng.random() < 0.5:
            a, b = rng.sample(bp[rng.choice(multi)], 2); P.append((a, b)); L.append(1)
        else:
            pool = bc[rng.choice(cats)]
            for _ in range(50):
                a, b = rng.sample(pool, 2)
                if ws[a]["program_id"] != ws[b]["program_id"]: P.append((a, b)); L.append(0); break
    return P, L

def scramble(ws, off):
    vocab = sorted({t for w in ws for t in w["tokens"]}); out = []
    for w in ws:
        r = random.Random((w["program_id"] * 1_000_000 + w["wid"]) ^ off); sh = list(vocab); r.shuffle(sh)
        m = dict(zip(vocab, sh)); out.append([m[t] for t in w["tokens"]])
    return out

# ---------------- features ----------------
def ngrams(w, ns=(1, 2, 3)):
    return [" ".join(w[i:i + n]) for n in ns for i in range(len(w) - n + 1)]

def canon(seq):                       # "abca" -> (0,1,2,0): same output whatever the names
    m = {}; return tuple(m.setdefault(t, len(m)) for t in seq)

def invariant_words(w):
    """Bag of renaming-invariant words for one scrambled window."""
    c = Counter(w); first = {}
    for i, t in enumerate(w): first.setdefault(t, i)
    order = sorted(c, key=lambda t: (-c[t], first[t]))
    rank = {t: min(i, 12) for i, t in enumerate(order)}          # 0 = most frequent command
    rs = [rank[t] for t in w]; out = []
    for n in (1, 2, 3, 4):
        out += [f"r{n}:" + ",".join(map(str, rs[i:i + n])) for i in range(len(w) - n + 1)]
    for n in (3, 4, 5, 6):
        out += [f"p{n}:" + "".join(map(str, canon(w[i:i + n]))) for i in range(len(w) - n + 1)]
    last = {}
    for i, t in enumerate(w):         # log-binned distance to the previous use of the same command
        out.append("g:%d" % min(int(np.log2(i - last[t]) * 2), 20) if t in last else "g:new")
        last[t] = i
    return out

def window_stats(w):
    c = Counter(w); cnt = sorted(c.values(), reverse=True)
    prof = np.zeros(20); prof[:min(20, len(cnt))] = cnt[:20]
    p = np.array(cnt) / len(w)
    runs = 1 + sum(w[i] != w[i - 1] for i in range(1, len(w)))
    return np.concatenate([prof / len(w), [len(c), runs, -(p * np.log(p)).sum()]])

def idx(P): return np.array([p[0] for p in P]), np.array([p[1] for p in P])

def gbm():
    return HistGradientBoostingClassifier(max_iter=300, learning_rate=0.08, early_stopping=False, random_state=0)

class Submission:
    GROUPS = ["r1", "r2", "r3", "r4", "p3", "p4", "p5", "p6", "g"]

    def __init__(self, train_traces, n_pairs=40000):
        # ---- Part A ----
        wT = make_windows(train_traces); WT = [w["tokens"] for w in wT]
        self.vocab = sorted({t for w in WT for t in w})
        self.cv = CountVectorizer(analyzer=lambda d: d, vocabulary=self.vocab)
        self.tfA = TfidfVectorizer(analyzer=lambda d: d, sublinear_tf=True, min_df=2).fit([ngrams(w) for w in WT])
        P, L = make_pairs(wT, n_pairs, seed=11)
        self.mA = gbm().fit(self.featsA(WT, P), L)
        # ---- Part B: train on windows scrambled exactly like the test ----
        WS = scramble(wT, 999); D = [invariant_words(w) for w in WS]
        self.tfB = {g: TfidfVectorizer(analyzer=lambda d, g=g: [x for x in d if x.split(":")[0] == g],
                                       sublinear_tf=True, min_df=2).fit(D) for g in self.GROUPS}
        P, L = make_pairs(wT, n_pairs, seed=12)
        self.mB = gbm().fit(self.featsB(WS, P, D), L)

    def featsA(self, W, P):
        a, b = idx(P)
        C = self.cv.transform(W).toarray().astype(np.float32) / WINDOW   # command frequencies
        T = self.tfA.transform([ngrams(w) for w in W])
        cos = np.asarray(T[a].multiply(T[b]).sum(1)).ravel()
        S = [set(w) for w in W]; nd = np.array([len(s) for s in S])
        jac = np.array([len(S[i] & S[j]) / len(S[i] | S[j]) for i, j in P])
        return np.hstack([np.minimum(C[a], C[b]), np.abs(C[a] - C[b]), cos[:, None], jac[:, None],
                          np.minimum(nd[a], nd[b])[:, None], np.abs(nd[a] - nd[b])[:, None]])

    def featsB(self, W, P, D=None):
        a, b = idx(P); D = D or [invariant_words(w) for w in W]
        cos = []
        for g in self.GROUPS:
            X = self.tfB[g].transform(D); cos.append(np.asarray(X[a].multiply(X[b]).sum(1)).ravel())
        S = np.array([window_stats(w) for w in W])
        return np.hstack([np.array(cos).T, np.minimum(S[a], S[b]), np.abs(S[a] - S[b])])

    def score_A(self, windows, pairs): return self.mA.predict_proba(self.featsA(windows, pairs))[:, 1]
    def score_B(self, windows, pairs): return self.mB.predict_proba(self.featsB(windows, pairs))[:, 1]

    def __call__(self, data: bytes) -> bytes:       # interface expected by the Nitro grader
        req = pickle.loads(data)
        fn = self.score_A if req["part"] == "A" else self.score_B
        buf = io.BytesIO(); np.save(buf, np.asarray(list(fn(req["windows"], req["pairs"])), dtype=np.float64))
        return buf.getvalue()

def official_test(path="test_data.csv"):
    """Same split, seeds and scrambling as pre_judging_script.py."""
    tr = load(path); ids = sorted({t["program_id"] for t in tr}); random.Random(20260613).shuffle(ids)
    A = set(ids[: len(ids) // 2])
    wA = make_windows([t for t in tr if t["program_id"] in A]); pA, lA = make_pairs(wA, 3000, 1234)
    wB = make_windows([t for t in tr if t["program_id"] not in A]); pB, lB = make_pairs(wB, 3000, 5678)
    return [w["tokens"] for w in wA], pA, lA, scramble(wB, 1235), pB, lB

def ptsA(a): return round(max(3, min(50, 3 + (a - 0.5) / 0.34 * 47)))
def ptsB(a): return round(max(2, min(50, 2 + (a - 0.5) / 0.28 * 48)))

if __name__ == "__main__":
    t0 = time.time()
    sol = Submission(load("public_traces.csv"))
    print(f"trained in {time.time() - t0:.0f}s")
    WA, pA, lA, WB, pB, lB = official_test()
    sA, sB = sol.score_A(WA, pA), sol.score_B(WB, pB)
    aA, aB = roc_auc_score(lA, sA), roc_auc_score(lB, sB)
    # simple baselines for comparison
    T = sol.tfA.transform([ngrams(w) for w in WA]); a, b = idx(pA)
    base = roc_auc_score(lA, np.asarray(T[a].multiply(T[b]).sum(1)).ravel())
    print(f"baseline A (TF-IDF cosine): {base:.4f}")
    print(f"Part A AUC {aA:.4f} -> {ptsA(aA)}/50 | Part B AUC {aB:.4f} -> {ptsB(aB)}/50 | total {ptsA(aA) + ptsB(aB)}")
    json.dump({"aucA": aA, "aucB": aB, "baseA": base, "lA": lA, "lB": lB, "sA": list(map(float, sA)),
               "sB": list(map(float, sB))}, open("results.json", "w"))