← Back to the problem

Romania ONIA 2026 · Correction by IOAI Training

The Talkative Robots

This page gives the full approach. Read it once you have searched.

Metric
40 x triplets + 60 x accuracy
Evaluated on
5-fold CV on train (task 2) + self-consistency (task 1); official labels are private
Our score
task 2 CV acc 0.999; task 1 self-consistent
Expected level
task 2 accuracy >= 0.85 gives full 60 points

Expected level reached

We solved the problem ourselves: downloaded the data, ran the experiments and measured the score below. The statement and data belong to their organisers.

The problem in brief

Three "robot factions" (IronClad, Prism, Genesis) each use their own speech-synthesis system, with several voices inside each faction. Task 1 (40 points): 300 unlabelled clips contain 100 Romanian sentences, each read once by each of the 3 robots, and you must group them into the 100 triplets of identical text. Task 2 (60 points): from 900 labelled training clips (300 per faction), predict the faction of 400 new test clips. The statement hints at a twist: the factions are "moving their communications to dynamic frequencies".

The test labels and the task-1 grouping are private. We report a 5-fold cross-validation accuracy for task 2, which we check against the structure of the test set, and an internal consistency check for task 1. Neither is an official score.

How you are scored

points=40⋅#correct triplets100+P2,P2={60if accuracy≥0.8560⋅accuracyotherwise\text{points} = 40\cdot\frac{\#\text{correct triplets}}{100} + P_2, \qquad P_2 = \begin{cases} 60 & \text{if accuracy} \ge 0.85 \\ 60\cdot\text{accuracy} & \text{otherwise} \end{cases}

A triplet counts only if its 3 files are exactly a ground-truth triplet. As the statement says, any task-2 accuracy of at least 0.85 earns the full 60 points. So 0.85 on task 2 is the target. Task 1 has no threshold.

First look at the data

The first thing to print is the audio format, and it gives the game away:

factiontraining clipssample rateenergy up to
IronClad (1)30024 000 Hzabout 11.3 kHz
Prism (2)30016 000 Hz8 kHz (the Nyquist limit)
Genesis (3)30022 050 Hzabout 8.3 kHz

In training, the sample rate alone classifies every clip perfectly. The test set, however, has 200 clips at 16 kHz, 100 at 22.05 kHz and 100 at 24 kHz. These are the "dynamic frequencies": some test clips were resampled. A model that uses the sample rate (or anything correlated with it, such as the highest frequency present) would label all 200 16-kHz clips as Prism. If half of them are really IronClad, that rule caps out at 75 % accuracy, under the 0.85 bar.

The task-1 clips are 100 at each sample rate, so there each faction keeps its own rate. The sentences are spontaneous Romanian speech about medicine and dentistry, 2 to 35 seconds long.

A first baseline

  • Task 2, sample-rate rule: 100 % in cross-validation, and at most about 75 % on the test set (see above). A classic leakage trap.
  • Task 1, matching by duration only (the same sentence should take about as long for each robot): 0 of the 100 triplets agree with our final answer. The three synthesisers speak at different speeds, and many sentences have similar lengths.

The key idea

Remove the shortcut before learning, and use a speech recogniser for the content.

  1. Task 2: resample everything to 16 kHz first, training and test alike, and look only at the 0-8 kHz band that every clip has. What is left is the true "fingerprint" of each synthesiser: its spectral envelope, timbre and dynamics. Our features are the mean and standard deviation of 64 log-mel bands and of 20 MFCCs over the speech frames, plus MFCC deltas, the fraction of silence and the duration (190 numbers per clip). A standardised logistic regression is enough. Because the training clips go through the same resampling as the test clips, cross-validation measures exactly the test condition.
  2. Task 1: transcribe, then match texts, one per faction. Whisper-base, forced to Romanian, transcribes each clip. Its transcripts are imperfect ("osul de sus" comes out as "o sude sus"), but character 3-5-gram TF-IDF vectors are robust to such spelling noise. The cosine similarity of two transcripts is high only when the sentences are the same. The structure of the problem then helps: each triplet holds exactly one clip from each faction, and the task-2 model tells us the faction of every task-1 clip (it agrees 100 % with their sample rates). Grouping becomes a 3-way assignment: match IronClad to Prism with the Hungarian algorithm, attach Genesis clips to the pairs, then re-match IronClad against the (Prism, Genesis) pairs a few times.

Transcript similarity in our triplets, and the faction predicted for each test sample rate

The full solution

  1. Read the audio, resample to 16 kHz (scipy.signal.resample_poly), normalise the peak, and compute an STFT (512-sample window, 10 ms hop), a 64-band mel filterbank and a DCT for the MFCCs. Frames below the 30th energy percentile are treated as silence.
  2. Task 2: standardise, fit a logistic regression (C=1C=1) on the 900 training clips, predict the 400 test clips.
  3. Task 1: predict the faction of the 300 clips, transcribe them with Whisper-base (language='ro', about 1.2 s per clip on 2 CPU threads), build TF-IDF character n-grams and cosine similarities, then run the alternating Hungarian assignment.
r, c = linear_sum_assignment(-S[np.ix_(A, B)])            # IronClad <-> Prism
pairs = [(A[i], B[j]) for i, j in zip(r, c)]
W = [[S[a, k] + S[b, k] for k in C] for a, b in pairs]     # attach Genesis to each pair
r, c = linear_sum_assignment(-np.array(W))

Results

Task 2

approach5-fold CV accuracyon the test set
sample-rate rule1.000at most about 0.75 (all 200 16-kHz clips called Prism)
HistGradientBoosting on 16-kHz features0.993same split as below
logistic regression on 16-kHz features0.99916 kHz: 100 IronClad + 100 Prism; 22.05 kHz: 99 Genesis, 1 IronClad; 24 kHz: 100 IronClad

The test predictions split the 16-kHz clips into exactly 100 IronClad and 100 Prism. That is the signature of 100 resampled IronClad clips, which the model recognises by their voice, not their format. On the unaltered clips the predictions agree with the faction implied by the sample rate 199 times out of 200. CV accuracy is 0.999, well above the 0.85 that gives full marks.

Task 1 (no labels, consistency checks only)

checkresult
matching on duration only, compared with the final triplets0 / 100 identical
two independent transcriptions (automatic language detection, then forced Romanian)the same 100 triplets
clips whose 2 most similar transcripts are exactly their two triplet partners298 / 300
triplets whose weakest pair still has similarity of at least 0.298 / 100 (the two others are visibly the same sentence, badly transcribed)

With automatic language detection, Whisper sometimes decided a clip was Spanish, Polish or even Arabic. Forcing Romanian fixed those transcripts, yet the triplets did not change: the one-per-faction assignment had already corrected the weak matches.

What to remember

  • Always print the file formats. A sample rate (or a resolution, or a file size) can encode the label, and the organisers may break that shortcut in the test set on purpose.
  • Remove a leak by making it impossible to use. Resample every clip to the same rate before computing features, so that cross-validation reproduces the test condition.
  • Use a pretrained model for the content and a small model for the style. Whisper gives the words (task 1), while simple spectral statistics capture which synthesiser spoke (task 2).
  • Character n-grams are robust to noisy transcripts. They match "o sude sus" with "osul de sus" where exact words would fail.
  • Turn the structure of the problem into constraints. "One clip per robot in each triplet" turns a fuzzy clustering problem into an assignment problem with an exact solver.
Full code (solution.py)
"""The Talkative Robots (Romania ONIA 2026, camp test 2).
Task 2 (faction of a clip): every clip is resampled to 16 kHz FIRST (in training the sample rate alone gives
the class, but the test set resamples some IronClad clips to 16 kHz), then log-mel/MFCC statistics +
logistic regression.  Task 1 (300 clips = 100 texts x 3 robots): Whisper-base transcripts (Romanian),
character n-gram TF-IDF similarity, faction of each clip from the task-2 model, then a 3-way assignment
(one clip per faction per triplet) solved with alternating Hungarian matchings.
Data: platform.olimpiada-ai.ro/problems/274 dataset.zip -> work/ ; Whisper: HF openai/whisper-base
(safetensors) -> work/whisper-base.   Run: python solution.py
"""
import os, json, glob
os.chdir(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'work'))
import numpy as np, pandas as pd, soundfile as sf, torch
from scipy.optimize import linear_sum_assignment
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
from sklearn.model_selection import cross_val_predict, StratifiedKFold
torch.set_num_threads(2)

# ------------------------------------------------------------------ audio features (16 kHz, sample-rate agnostic)
from scipy.signal import resample_poly, stft
from scipy.fft import dct
from math import gcd
def melbank(sr=16000,n_fft=512,n=64,fmin=20,fmax=8000):
    hz2m=lambda f:2595*np.log10(1+f/700); m2hz=lambda m:700*(10**(m/2595)-1)
    pts=m2hz(np.linspace(hz2m(fmin),hz2m(fmax),n+2)); bins=np.fft.rfftfreq(n_fft,1/sr); W=np.zeros((n,len(bins)))
    for i in range(n):
        l,c,r=pts[i],pts[i+1],pts[i+2]; W[i]=np.clip(np.minimum((bins-l)/(c-l),(r-bins)/(r-c)),0,None)
    return W
MB=melbank()
def to16(x,sr):
    if sr==16000: return x
    g=gcd(16000,sr); return resample_poly(x,16000//g,sr//g)
def feat(path):
    x,sr=sf.read(path,dtype='float32'); x=to16(x,sr).astype(np.float32)
    x=x/(np.abs(x).max()+1e-9)
    _,_,Z=stft(x,16000,nperseg=512,noverlap=512-160); P=np.abs(Z)**2
    M=np.log(MB@P+1e-8); C=dct(M,axis=0,norm='ortho')[:20]
    e=M.mean(0); voiced=e>np.quantile(e,0.3)               # frames with speech
    Mv=M[:,voiced]; Cv=C[:,voiced]; dC=np.diff(Cv,axis=1)
    sil=(~voiced).mean()
    return np.concatenate([Mv.mean(1),Mv.std(1),Cv.mean(1),Cv.std(1),np.abs(dC).mean(1),[sil,len(x)/16000]])

def transcribe(files):
    from transformers import WhisperProcessor, WhisperForConditionalGeneration
    P = WhisperProcessor.from_pretrained('whisper-base')
    M = WhisperForConditionalGeneration.from_pretrained('whisper-base', use_safetensors=True).eval()
    out = {}
    for f in files:
        x, sr = sf.read(f, dtype='float32'); x = to16(x, sr)
        with torch.no_grad():
            ids = M.generate(P(x, sampling_rate=16000, return_tensors='pt').input_features,
                             max_new_tokens=200, language='ro', task='transcribe')
        out[os.path.basename(f)] = P.batch_decode(ids, skip_special_tokens=True)[0].strip()
    return out

def triplets(S, fac):
    """3-way assignment: one clip of each faction per triplet, maximising transcript similarity"""
    A, B, C = [np.where(fac == k)[0] for k in (1, 2, 3)]
    r, c = linear_sum_assignment(-S[np.ix_(A, B)]); pairs = [(A[i], B[j]) for i, j in zip(r, c)]
    W = np.array([[S[a, k] + S[b, k] for k in C] for a, b in pairs])
    r, c = linear_sum_assignment(-W); trip = [(pairs[i][0], pairs[i][1], C[j]) for i, j in zip(r, c)]
    for _ in range(3):                                     # re-match A against the (B, C) pairs
        bc = [(t[1], t[2]) for t in trip]
        W = np.array([[S[a, b] + S[a, c] for b, c in bc] for a in A])
        r, c = linear_sum_assignment(-W); trip = [(A[i],) + bc[j] for i, j in zip(r, c)]
    return trip

if __name__ == '__main__':
    tr = pd.read_csv('task2/task2_train.csv', sep=';')
    Xtr = np.stack([feat('task2/train/' + n) for n in tr.name]); ytr = tr['class'].values
    clf = make_pipeline(StandardScaler(), LogisticRegression(C=1, max_iter=3000))
    cv = cross_val_predict(clf, Xtr, ytr, cv=StratifiedKFold(5, shuffle=True, random_state=0))
    print('task 2, 5-fold CV accuracy (all clips at 16 kHz): %.4f' % (cv == ytr).mean())
    clf.fit(Xtr, ytr)
    te = sorted(glob.glob('task2/test/*.wav')); pte = clf.predict(np.stack([feat(f) for f in te]))
    srte = np.array([sf.info(f).samplerate for f in te])
    print(pd.crosstab(srte, pte, rownames=['test sample rate'], colnames=['predicted']))
    f1 = sorted(glob.glob('task1/*.wav')); fac = clf.predict(np.stack([feat(f) for f in f1]))
    asr = transcribe(f1); names = [os.path.basename(f) for f in f1]
    txt = [' '.join(asr[n].lower().replace(',', ' ').replace('.', ' ').split()) for n in names]
    E_ = TfidfVectorizer(analyzer='char_wb', ngram_range=(3, 5), sublinear_tf=True).fit_transform(txt)
    S = (E_ @ E_.T).toarray(); trip = triplets(S, fac)
    rows = [(i + 1, 1, '|'.join(names[k] for k in t)) for i, t in enumerate(trip)]
    rows += [(os.path.basename(f), 2, int(p)) for f, p in zip(te, pte)]
    pd.DataFrame(rows, columns=['datapointID', 'subtaskID', 'answer']).to_csv('../submission.csv', index=False)
    print('submission.csv written:', len(rows), 'rows')