← Retour à l'épreuve

France OFIA 2026 · Correction IOAI Training

Where Is Spy-C?

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

Métrique
50 x id accuracy + 50 x location hit rate
Évaluée sur
held-out validation (identity) + by-eye check of 63 test images (location); official test labels are private
Notre score
about 84/100 (est.: id 0.858, loc ~0.95)
Niveau attendu
top of leaderboard 95.08 (no qualification threshold)

Notre solution n'atteint pas le niveau attendu : il reste de la marge pour faire mieux

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 secret "Spies Group" of five animals (a beaver, a fox, a pigeon, a rabbit and a raccoon) is plotting to take over the world chocolate market. You are given 2 000 past missions, each described by 12 columns (where the agent last met an informant, where a radio signal was intercepted, the mission type, the agent's status, speciality and language level, contact frequency, duration, budget and a discretion score), together with the agent who carried it out. For 1 000 new missions you must do two things: name the agent from the table, and point to that agent in the mission's surveillance picture, a crowded "Where's Wally?" style drawing of 1024, 2048 or 4096 pixels. Every picture contains several animals, and the agent is the one wearing its own distinctive accessories: an orange hard hat and bow tie for the beaver, black sunglasses and a scarf for the fox, a fedora and a messenger bag for the pigeon, red sneakers and a backpack for the rabbit, a grey hoodie and round glasses for the raccoon. You get no annotated image at all, only five "off-duty" sprites of each animal without its accessories.

Data: the Kaggle dataset timmor/ofia2026 (tables and sprites) and the Hugging Face dataset OlympiadesIA/OFIA2026 (the 1 000 test images, 10.9 GB). The statement and the scoring rules come from the competition pages on Kaggle. The official test labels are private, and we never submitted anything. Identification is scored on a held-out split of the training table. For localisation, we ran our detector on a subset of 63 test images (every 16th one) and checked each predicted point by eye. The localisation number is therefore a self-made estimate, not an official score.

How you are scored

The score is out of 100, half for each part:

Score=50⋅#{correct identities}1000+50⋅#{correct locations}1000.\text{Score} = 50\cdot\frac{\#\{\text{correct identities}\}}{1000} + 50\cdot\frac{\#\{\text{correct locations}\}}{1000}.

A location counts as correct when the predicted point falls in the 200×200200\times200 square around the agent's centre:

max⁡(∣x−x∗∣, ∣y−y∗∣)≤100 pixels.\max\big(|x-x^\ast|,\ |y-y^\ast|\big) \le 100 \text{ pixels}.

The two parts are linked. You must point to the animal of the true class, so a wrong identity almost always costs the location as well. If aa is the identification accuracy and dd is the probability of finding the right animal once you know who to look for, then roughly

Score≈50 a+50 a d.\text{Score} \approx 50\,a + 50\,a\,d .

What to aim for. The organisers publish no threshold: the 100 best scores on the private leaderboard qualify. The competition leaderboard (read through the Kaggle API) is topped by 95.08, then 94.05 and 93.53. The organisers' example submission (random guesses) scores about 10.

First look at the data

The table. There are 2 000 training missions, exactly 400 per agent, and nothing is missing. Taken one at a time, the columns look almost useless: the class means of the contact frequency, budget and so on differ only a little, and the categorical columns are only mildly unbalanced. Then you plot the coordinates:

Hidden geometry in the mission table

  • The meeting point lies either on a logarithmic spiral (two thirds of the missions) or in a blob at the centre. In log-polar coordinates a log spiral r=a ebθr = a\,e^{b\theta} becomes a straight line, since ln⁡r=ln⁡a+bθ\ln r = \ln a + b\theta. So the quantity (ln⁡r−bθ)(\ln r - b\theta), taken modulo one turn 2πb2\pi b, tells you which arm a point sits on. Scanning bb and keeping the value that makes this "phase" sharpest gives b≈0.19b \approx 0.19, and the histogram (middle panel) shows three arms. The arms are not shared equally: arm 0 is mostly the pigeon and the rabbit, while arms 1 and 2 hold the beaver, the fox and the raccoon.
  • The radio zone lies in one of three nested shapes: a small disk, a square ring, and an outer frame squashed against the ±2.9\pm 2.9 border. Here the raccoon and the fox favour the disk, while the pigeon and the rabbit favour the outer frame.

None of this can be read from xx and yy separately: a tree splitting on raw coordinates has to approximate a spiral with a staircase of rectangles. The structure only appears once you change coordinates.

The images. They are AI-generated cartoon scenes (a farm fair, a pirate harbour, a museum...) full of people, plus usually several of the five animals. The catch, which you only discover by looking at many images:

A test image, the best detection for each species, and why the image alone cannot name the agent

In test_0016, the table says "pigeon", and indeed the pigeon wears its fedora and bag. But the fox also wears sunglasses and the rabbit carries a backpack. Decoys wear accessories too, sometimes a full set. So the image does not reveal who the agent is: the table is needed for identity, and the image only for location. Notice also the pitfall in the figure: CLIP's best "beaver" is a firefighter in a yellow helmet. The real beaver, bottom left, wears nothing and is not the agent anyway.

A first baseline

Identity. On raw columns, 5-fold cross-validated accuracy is 0.30 for logistic regression, 0.50 for a random forest and 0.55 for gradient-boosted trees. That would give about 27 points out of 50.

Location. Without labelled images, the honest baseline is the image centre. That is almost never within 100 pixels of the agent, so it scores close to 0 out of 50.

The key idea

Two changes of viewpoint unlock the problem: polar coordinates for the table, and zero-shot vision models for the images.

1. Features that match how the data was generated. The class depends on which spiral arm and which radio shape a mission falls in, and these are simple functions of polar quantities:

r=x2+y2,θ=atan2⁡(y,x),phase=(ln⁡r−b θ) mod 2πb2πb,r=\sqrt{x^2+y^2},\qquad \theta=\operatorname{atan2}(y,x),\qquad \text{phase} = \frac{(\ln r - b\,\theta) \bmod 2\pi b}{2\pi b},

and, for the radio zone, both the Euclidean radius and the "square radius" max⁡(∣x∣,∣y∣)\max(|x|,|y|), which tells a disk from a square ring. Each of these alone lifts boosting from 0.55 to between 0.60 and 0.68 (the spiral phase is the best single one). Together they reach 0.85, because the remaining columns (budget, duration, discretion score...) only become informative inside a given arm or shape. One example: the sign of score_discretion separates the beaver from the pigeon in the outer radio frame. That is again an interaction, which is why trees work here and Gaussian models (QDA, 0.29 to 0.51) do not.

2. Locating without training data. You cannot train a detector without boxes, but you can use models that already understand images and text. OWLv2 is an open-vocabulary detector: give it the text "a beaver" and it returns boxes. CLIP scores how well a crop matches a sentence. Our pipeline:

  • Run OWLv2 with 11 text queries ("a beaver", ..., "a raccoon wearing a grey hoodie and round glasses", "an animal") on overlapping 1024×10241024\times1024 tiles (4096 images are first halved). Merge the boxes with non-maximum suppression and keep the 40 best proposals.
  • Crop each proposal with some margin and let CLIP compare it with 27 prompts: "a cartoon drawing of a beaver", the same with accessories, and 17 distractors ("a person", "a child", "a cow", "a barrel"...). The distractors matter: without "a person", the yellow-helmeted firefighter would become a very convincing beaver.
  • For the agent ss predicted from the table, return the centre of the crop with the highest P(s)+P(s with accessories)P(s) + P(s \text{ with accessories}).

Why not simply ask OWLv2 for "a beaver"? On whole images its species labels were unreliable: in test_0000 its best "beaver" box was the raccoon, and its best "rabbit" box was the beaver. OWLv2 is good at finding things that look like animals, while CLIP, looking at a crop enlarged to 224×224224\times224, is much better at telling which animal it is. Splitting "where" and "what" between two models is a classic zero-shot pattern.

The full solution

  1. Identity. Drop the four raw coordinates and add: radius, angle and square radius for both points, the spiral phase with b=0.19b = 0.19, the arm index (0, 1, 2, or "centre"), and the position along the arm (ln⁡r−carm)/b(\ln r - c_{\text{arm}})/b. Then fit HistGradientBoostingClassifier with 300 rounds, learning rate 0.05 and 31 leaves:
ph = np.mod(np.log(r) - SPIRAL_B * theta, PER) / PER          # PER = 2*pi*b: one turn
D["arm"] = np.where(r < 1.8, -1, np.round(ph * 3) % 3)          # 3 arms, or the centre blob
D["phi"] = np.where(D.arm >= 0, (np.log(r) - D.arm / 3 * PER) / SPIRAL_B, -10)
  1. Location. OWLv2 (google/owlv2-base-patch16-ensemble) proposals on tiles, then CLIP ViT-B/16 classification of the crops, as described above. On 2 CPU threads this takes about 7 s for a 1024 image and 30 to 40 s for a 2048 or 4096 image (about 20 minutes for our 63-image subset, and roughly 6 hours for all 1 000).

  2. Submission. One row per mission: the predicted identity, and the centre of the chosen crop in original pixels.

Results

Identification (5-fold CV on the 2 000 training missions, plus a held-out 20 % split):

approachaccuracypoints / 50
logistic regression, raw columns0.29915.0
random forest, raw columns0.50225.1
gradient boosting, raw columns0.55027.5
QDA on numeric + polar features0.51425.7
boosting + radio radius only0.62231.1
boosting + spiral phase only0.68434.2
single decision tree (depth 8) on polar features0.79139.5
boosting, all polar features0.83841.9
boosting, polar features + spiral arm, raw coordinates removed (CV)0.85042.5
same model, held-out 20 % split0.85842.9

Localisation, checked by eye on 63 test images (every 16th one: 15 at 1024 px, 27 at 2048 px, 21 at 4096 px). For each one we looked at the chosen crop and asked: is it an animal of the predicted species, wearing that species' accessories?

outcomeimages
correct: the agent, accessories visible58
detector miss: the agent is not among the 40 proposals (two crowded 4096 scenes)2
a plain rabbit was found, the table hesitated (0.52 rabbit / 0.48 pigeon), and an accessorised pigeon was in the image: probably an identity error1
an animal of the right species, accessories unclear2

So, once the identity is right, the detector finds the agent in about 58 to 60 of 62 images, d≈0.94d \approx 0.94 to 0.970.97. With a≈0.858a \approx 0.858:

Score≈50(0.858)+50(0.858)(0.95)≈42.9+40.7≈84.\text{Score} \approx 50(0.858) + 50(0.858)(0.95) \approx 42.9 + 40.7 \approx 84 .

Our estimated total is about 83 to 84 out of 100, against 95.08 at the top of the leaderboard. Two cautions about this estimate. First, the check is conditional on the identity predicted by the table: when the table is wrong, the chosen animal may be an accessorised decoy that looks perfectly convincing, so the visual check cannot see identity errors, and these are accounted for only through aa. Second, 63 images give an uncertainty of about ±3\pm 3 percentage points on dd.

Where the remaining points are. The gap to the top is almost all in identification (0.86 here, which would have to be about 0.95). The images can help after all, even though they cannot decide the identity alone. When the table hesitates between two agents, only one of them is usually wearing its accessories in the picture, as in the rabbit/pigeon case above. A natural next step is a Bayesian combination,

P(s∣table,image)∝Ptab(s)⋅Limg(s),P(s \mid \text{table}, \text{image}) \propto P_{\text{tab}}(s)\cdot L_{\text{img}}(s),

where Limg(s)L_{\text{img}}(s) measures how strongly the best ss crop matches "ss wearing its accessories" rather than "plain ss". This needs calibration, for example using the off-duty sprites as examples of "plain", and we did not have the compute budget to validate it. On the detection side, keeping more than 40 proposals on 4096 images would fix both detector misses.

What to remember

  • Plot coordinates before modelling them. A spiral and nested squares are invisible to per-column statistics and costly for trees on raw x,yx, y. A change of coordinates (polar, log-polar, max⁡(∣x∣,∣y∣)\max(|x|,|y|)) turned 55 % into 85 %.
  • Look for the generator's parameters. Scanning the spiral rate bb for the sharpest phase histogram is a cheap, label-free way to recover a hidden parameter. The same trick works for periods, frequencies and rotation angles.
  • No labels does not mean no model. Open-vocabulary detectors (OWLv2, Grounding DINO) propose boxes from text, and CLIP classifies crops from text. Give CLIP negative prompts ("a person", "a barrel"), or it will happily call a firefighter a beaver.
  • Read how the score couples the two parts. Here location only counts for the right animal, so the score behaves like 50a(1+d)50a(1+d): identification accuracy is worth twice its face value.
  • When you evaluate without labels, say exactly what you checked. A by-eye check of 63 predictions is useful, but it measures something narrower than the official metric. Write down what it can and cannot see.
Code complet (solution.py)
"""OFIA 2026, stage 1: "Where is Spy-C?", worked solution.

Part 1 (identity, 50 pts): the mission table hides geometric structure (a 3-armed logarithmic spiral for
the meeting point, nested shapes for the radio zone). Polar features + gradient boosting: about 85 %.
Part 2 (location, 50 pts): no training images at all, so we go zero-shot. OWLv2 proposes animal boxes on
1024-px tiles, CLIP scores each crop against "a cartoon drawing of a <species>" prompts, and we return
the centre of the best crop of the species predicted in part 1.

Usage:  python solution.py <data dir with X_train.csv...> <image dir> [clip dir]
Images missing from <image dir> get the image centre (we only processed a subset, see the correction).
Needs HF_HOME pointing to a cache holding google/owlv2-base-patch16-ensemble.
"""
import os, sys
os.environ.setdefault("HF_HUB_OFFLINE", "1")
import numpy as np, pandas as pd, torch
from PIL import Image
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.metrics import accuracy_score
from sklearn.model_selection import StratifiedKFold, cross_val_predict
from torchvision.ops import nms
from transformers import Owlv2Processor, Owlv2ForObjectDetection, CLIPModel, CLIPProcessor

DATA = sys.argv[1] if len(sys.argv) > 1 else "work"
IMG = sys.argv[2] if len(sys.argv) > 2 else "work/img"
CLIP_DIR = sys.argv[3] if len(sys.argv) > 3 else "work/clip_b16"   # openai/clip-vit-base-patch16 (safetensors)

# ------------------------------------------------------------------ part 1: identity from the table
SPIRAL_B = 0.19; PER = 2 * np.pi * SPIRAL_B     # log-spiral rate, found by maximising phase sharpness
def feats(X):
    """Polar coordinates, spiral arm and position along the arm, radio-zone shape."""
    D=X.drop(columns=['coord_rencontre_x','coord_rencontre_y','coord_radio_x','coord_radio_y']).copy()
    rx,ry,qx,qy=X.coord_rencontre_x,X.coord_rencontre_y,X.coord_radio_x,X.coord_radio_y
    D['r_r']=np.hypot(rx,ry); D['r_th']=np.arctan2(ry,rx); D['r_cheb']=np.maximum(abs(rx),abs(ry))
    D['q_r']=np.hypot(qx,qy); D['q_th']=np.arctan2(qy,qx); D['q_cheb']=np.maximum(abs(qx),abs(qy))
    ph=np.mod(np.log(D.r_r+1e-9)-SPIRAL_B*D.r_th,PER)/PER
    D['phase']=ph; D['arm']=np.where(D.r_r<1.8,-1,np.round(ph*3)%3).astype(int)
    D['phi']=np.where(D.arm>=0,(np.log(D.r_r+1e-9)-D.arm/3*PER)/SPIRAL_B,-10)
    for c in ['type_mission','statut_agent','specialite','niveau_langue']: D[c]=D[c].astype('category')
    return D


def identity_model():
    return HistGradientBoostingClassifier(categorical_features="from_dtype", max_iter=300, learning_rate=0.05,
                                          max_leaf_nodes=31, random_state=0)


# ------------------------------------------------------------------ part 2: zero-shot localisation
torch.set_num_threads(2)
Image.MAX_IMAGE_PIXELS = None
ANIMALS = ["beaver", "fox", "pigeon", "rabbit", "raccoon"]
ACC = ["wearing an orange hard hat and a bow tie", "wearing black sunglasses and a scarf",
       "wearing a fedora hat and a messenger bag", "wearing red sneakers and a backpack",
       "wearing a grey hoodie and round glasses"]
OWL_Q = [f"a {a}" for a in ANIMALS] + [f"a {a} {c}" for a, c in zip(ANIMALS, ACC)] + ["an animal"]
NEG = ["a person", "a child", "a cow", "a pig", "a sheep", "a goat", "a horse", "a dog", "a cat",
       "a duck", "a chicken", "a bear", "a squirrel", "a building", "a table", "a tree", "a barrel"]
ITEMS = ["an orange hard hat", "a bow tie", "an earpiece", "black sunglasses", "a scarf", "a fedora hat",
         "a messenger bag", "red sneakers", "a smartwatch", "a backpack", "a grey hoodie", "round glasses"]
CLIP_T = ([f"a cartoon drawing of a {a}" for a in ANIMALS] +
          [f"a cartoon drawing of a {a} {c}" for a, c in zip(ANIMALS, ACC)] +
          [f"a cartoon drawing of {n}" for n in NEG] +
          [f"a cartoon drawing of an animal wearing {x}" for x in ITEMS])

def load_models():
    global owl_p, owl, clip_p, clip, TXT, owl_txt
    owl_p = Owlv2Processor.from_pretrained("google/owlv2-base-patch16-ensemble")
    owl = Owlv2ForObjectDetection.from_pretrained("google/owlv2-base-patch16-ensemble").eval()
    clip_p = CLIPProcessor.from_pretrained(CLIP_DIR)
    clip = CLIPModel.from_pretrained(CLIP_DIR).eval()
    with torch.no_grad():
        t = clip_p(text=CLIP_T, return_tensors="pt", padding=True)
        TXT = clip.get_text_features(**t)
        TXT = getattr(TXT, "pooler_output", TXT)
        TXT = TXT / TXT.norm(dim=-1, keepdim=True)
        owl_txt = owl_p(text=[OWL_Q], return_tensors="pt")


def owl_tile(tile):
    """Return boxes (x0,y0,x1,y1 in tile pixels) and the per-query sigmoid scores."""
    inp = owl_p(images=tile, return_tensors="pt")
    with torch.no_grad():
        out = owl(input_ids=owl_txt["input_ids"], attention_mask=owl_txt["attention_mask"],
                  pixel_values=inp["pixel_values"])
    s = out.logits[0].sigmoid()
    b = out.pred_boxes[0]                       # cx, cy, w, h relative to the (square) tile
    W = tile.size[0]
    xyxy = torch.stack([b[:, 0] - b[:, 2] / 2, b[:, 1] - b[:, 3] / 2,
                        b[:, 0] + b[:, 2] / 2, b[:, 1] + b[:, 3] / 2], 1) * W
    return xyxy, s


def candidates(img, work=2048, tile=1024, stride=512, keep=40):
    scale = min(1.0, work / max(img.size))
    im = img.resize((round(img.size[0] * scale), round(img.size[1] * scale))) if scale < 1 else img
    W, H = im.size
    boxes, scores = [], []
    xs = list(range(0, max(W - tile, 0) + 1, stride)) or [0]
    ys = list(range(0, max(H - tile, 0) + 1, stride)) or [0]
    for y0 in ys:
        for x0 in xs:
            b, s = owl_tile(im.crop((x0, y0, x0 + tile, y0 + tile)))
            b = b + torch.tensor([x0, y0, x0, y0])
            ok = (b[:, 2] - b[:, 0] < 400) & (b[:, 3] - b[:, 1] < 400)   # animals are small
            boxes.append(b[ok]); scores.append(s[ok])
    B = torch.cat(boxes); S = torch.cat(scores)
    best = S.max(1).values
    idx = nms(B, best, 0.3)[:keep]
    B, S = B[idx] / scale, S[idx]                  # back to original pixels
    return B.numpy(), S.numpy(), img


def clip_scores(img, B):
    crops = []
    for x0, y0, x1, y1 in B:
        cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
        side = max(x1 - x0, y1 - y0) * 1.25 + 8
        crops.append(img.crop((int(cx - side / 2), int(cy - side / 2), int(cx + side / 2), int(cy + side / 2))))
    with torch.no_grad():
        f = clip.get_image_features(**clip_p(images=crops, return_tensors="pt"))
        f = getattr(f, "pooler_output", f)
        f = f / f.norm(dim=-1, keepdim=True)
        return (f @ TXT.T).numpy()          # raw cosine similarities




def locate(path, species):
    """Centre (x, y) of the best CLIP crop for `species` among the OWLv2 proposals."""
    img = Image.open(path).convert("RGB")
    B, _, img = candidates(img)
    C = clip_scores(img, B)[:, :27] * 100                 # animals, accessorised animals, negatives
    P = np.exp(C - C.max(1, keepdims=True)); P /= P.sum(1, keepdims=True)
    sp = P[:, species] + P[:, 5 + species]                # P(this species, with or without accessories)
    x0, y0, x1, y1 = B[sp.argmax()]
    return int((x0 + x1) / 2), int((y0 + y1) / 2)


if __name__ == "__main__":
    X = pd.read_csv(f"{DATA}/X_train.csv"); y = pd.read_csv(f"{DATA}/y_train.csv").iloc[:, 0].values
    T = pd.read_csv(f"{DATA}/X_test.csv")
    D, DT = feats(X), feats(T)
    for c in D.select_dtypes("category"):
        DT[c] = pd.Categorical(DT[c], categories=D[c].cat.categories)
    p = cross_val_predict(identity_model(), D, y, cv=StratifiedKFold(5, shuffle=True, random_state=0))
    print(f"identity, 5-fold CV accuracy {accuracy_score(y, p):.4f}  -> {50 * accuracy_score(y, p):.1f} / 50")
    ident = identity_model().fit(D, y).predict(DT)

    load_models()
    xs, ys = np.zeros(len(T), int), np.zeros(len(T), int)
    for i in range(len(T)):
        path = f"{IMG}/test_{i:04d}.png"
        if os.path.exists(path):
            xs[i], ys[i] = locate(path, ident[i])
            print(i, ["castor", "renard", "pigeon", "lapin", "raton-laveur"][ident[i]], xs[i], ys[i], flush=True)
        else:
            xs[i] = ys[i] = 512
    pd.DataFrame({"id": range(len(T)), "spy_identity": ident, "spy_position_x": xs,
                  "spy_position_y": ys}).to_csv("submission.csv", index=False)