← Retour à l'épreuve

Romania ONIA 2026 · Correction IOAI Training

The Ghost in the Machine

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

Métrique
F1 (failure) + MAE (anomaly score)
Évaluée sur
held-out last 6 hours of the training day (official test labels are private)
Notre score
F1 0.678, MAE 3.72 -> 100/100 points
Niveau attendu
full marks at F1 >= 0.6 and MAE <= 8

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 monitor 200 cloud servers. Each row is one snapshot of one server: six sensor metrics (CPU, memory, disk I/O, latency, error rate, temperature), plus an undocumented health_score, the server's region and hardware type, a short alert title written by humans in many styles, and a multi-line system log. From one training day (6 000 rows) you must predict, for a test day two weeks later (1 500 rows): (A) whether the server will fail in the next 4 hours, where the label is missing for 40 % of training rows, and (B) an anomaly_score between 0 and 100. The data is deliberately messy: missing values, impossible negative readings, free-form text.

The test labels are private. We validate on the last six hours of the training day (training on 00:00-17:59, scoring 18:00-23:59), which is the closest we can get to "a later period".

How you are scored

Two tasks, 50 points each:

A: 50⋅clip⁡ ⁣(F1−0.30.3, 0, 1),B: 50⋅clip⁡ ⁣(13−MAE5, 0, 1).\text{A: } 50\cdot\operatorname{clip}\!\Big(\frac{F_1-0.3}{0.3},\,0,\,1\Big), \qquad \text{B: } 50\cdot\operatorname{clip}\!\Big(\frac{13-\text{MAE}}{5},\,0,\,1\Big).

F1F_1 is computed on the positive class (failure = 1), and the answers must be exactly 0 or 1. Full marks need F1≥0.6F_1 \ge 0.6 and MAE≤8\text{MAE} \le 8. Below F1=0.3F_1=0.3 or above MAE=13\text{MAE}=13, the task scores zero.

First look at the data

  • Failures are rare: 19.6 % of the labelled rows. Predicting "fails" everywhere already gives F1=0.32F_1 = 0.32, so the scale really starts at 0.3.
  • Corrupted sensors: about 5 % of each metric is missing, and some disk I/O and error-rate values are negative, which is physically impossible. We treat them as missing and keep a flag.
  • The text is very structured underneath. The 60-odd alert titles reduce to a few families once the server name is removed: six "routine" phrasings ("Heartbeat OK", "Scheduled health check"...) and alerts about errors, memory, CPU, network or disk. The logs contain only 33 distinct message templates once digits are masked, in five severity levels from DEBUG to CRIT. Some templates are strong failure signals: OOM killer, ECC memory errors, RAID degraded, cascading service failures.
  • The anomaly score has a memory. Averaged over servers, it is about 40 just after midnight and climbs to a plateau of 73 by 04:00. The first snapshot of srv_000 scores 6 and the next one 20. A score that starts near 0 at the beginning of the recording and settles later behaves like a running statistic of each server's history, not a function of the current row alone. The test day also starts at 00:01, so the same warm-up applies there.

The anomaly score warms up, and the value of each feature group

A first baseline

LightGBM on the six raw sensor metrics (plus health score) only:

  • A: F1=0.454F_1 = 0.454, which is only 25 points;
  • B: MAE=7.91\text{MAE} = 7.91, barely under the full-marks line (predicting the median gives 8.89).

The numbers alone carry little signal. Most of the information is in the text.

The key idea

Turn the text into counts, and give each row its server's past.

  1. Logs as counted events. For each row we count the lines of each severity level (DEBUG, INFO, WARN, ERROR, CRIT) and of each important template (OOM, ECC, RAID, cascade, thermal throttling, link flap, write failures...). We also parse the numbers the templates contain (GC pause in ms, health-check latency, connection-pool percentage, the cpu and mem values the log repeats) and add a single severity index sev=#WARN+3 #ERROR+9 #CRIT\text{sev}=\#\text{WARN}+3\,\#\text{ERROR}+9\,\#\text{CRIT}. The alert title becomes a "routine" flag plus five subsystem flags. These counts raise F1F_1 from 0.45 to 0.68 and cut the MAE from 7.9 to 4.7.
  2. Per-server history. Sort each server's rows by time and add: the row's position kk in the day (this is what captures the warm-up), the running mean and an exponentially weighted mean (half-life of 3 snapshots) of severity, error rate, CRIT count and the routine flag, the previous and next snapshot's values, and the minutes since the previous snapshot. If the anomaly score is a smoothed history, these features describe exactly that history. The MAE drops further, from 4.7 to 3.6. Failure prediction hardly moves: a failure in the next 4 hours depends mostly on what the server shows now.
  3. The F1 threshold matters as much as the model. With 20 % positives, the threshold that maximises F1 is far below 0.5. We pick it from out-of-fold predictions on the training rows (it comes out around 0.18 to 0.20), never on the hold-out. At 0.5, the same model scores only about 0.60 on the hold-out.

The full solution

  1. Cleaning: negative disk I/O and error rates become missing (with a flag), and we add the count of missing metrics per row.
  2. Features (about 75 in total): metrics, region and type codes, hour, alert flags, log-level and template counts, parsed log numbers, the severity index, and the per-server history features.
  3. Task A: LightGBM classifier on the 3 576 labelled rows (600 trees, 15 leaves, learning rate 0.03, row and column subsampling 0.8). The 0/1 threshold is chosen by 5-fold out-of-fold F1.
  4. Task B: LightGBM with the L1 (absolute error) objective, which matches the MAE metric, trained on all 6 000 rows, since the anomaly score is never missing. Predictions are clipped to [0, 100].
  5. For the submission, refit on the whole training day and write two rows per test id (subtaskID 1 and 2).

We also tried three ideas for task A that did not change anything beyond noise: using the predicted anomaly score as a feature (0.672), self-training on confident pseudo-labels for the unlabelled rows (0.695), and even the true anomaly score as a feature (0.689, an upper bound). The unlabelled 40 % carries little extra information here.

Results

Hold-out: last 6 hours of the training day (1 564 rows, 625 of them with a failure label).

featuresA: F1B: MAEpoints (A + B)
always "failure" / predict the median0.3248.8894 + 41
sensor metrics only0.4547.91026 + 50
+ alert title and log features (single row)0.6844.69850 + 50
+ per-server history0.6863.64250 + 50
final solution.py (same features, fixed seed)0.6783.722100
+ predicted anomaly score as an A feature0.672
+ self-training on unlabelled rows0.695

Both tasks clear their full-marks lines with room to spare (F1F_1 0.68 against 0.6, MAE 3.7 against 8). The test day comes from another period, so the true margin will be smaller. The feature distributions of the two days are very close (mean severity 13.8 against 13.7, error rate 5.5 against 5.6), which is reassuring.

What to remember

  • Semi-structured logs are a bag of events. Mask the variable parts (digits, ids), count the templates and severity levels, and parse the numbers they carry. Here that was worth more than all the sensors.
  • Look at the target over time. A score that warms up from zero is a running statistic, and per-entity history features (cumulative mean, EWMA, previous and next values) are what model it.
  • For F1 on an imbalanced class, tune the threshold, and tune it on out-of-fold predictions, never on the evaluation set. The optimum here is about 0.2, not 0.5.
  • Match the training loss to the metric: L1 for MAE.
  • Validate on a later period when the test set is a later period. A random split would mix neighbouring snapshots of the same server and flatter the model.
Code complet (solution.py)
"""The Ghost in the Machine (Romania ONIA 2026, national): server failure (F1) + anomaly score (MAE).

Features: cleaned sensor metrics (negative sentinels -> missing), keyword flags from the alert title,
counts of log levels and of each critical log message, numbers parsed from the logs, and per-server
history in time order (the anomaly score behaves like a running statistic: it starts near 0 at
midnight).  LightGBM for both tasks; the failure threshold is chosen on out-of-fold predictions.
Validation: train on 00:00-17:59 of the training day, test on 18:00-23:59 (the real test is another day).
Data: platform.olimpiada-ai.ro/problems/240 dataset.zip -> work/.  Run: python solution.py
"""
import os
os.chdir(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'work'))
import pandas as pd, numpy as np, re, lightgbm as lgb
from sklearn.metrics import f1_score, mean_absolute_error
from sklearn.model_selection import cross_val_predict, KFold

LEVELS=['DEBUG','INFO','WARN','ERROR','CRIT']
PAT={'oom':'OOM killer','ecc':'ECC error','raid':'RAID degraded','cascade':'Cascading failure','thermal':'thermal throttling',
     'flap':'Link flap','writefail':'Write failed','authdown':'Authentication service unreachable','jobfail':'failed with exit code',
     'timeout':'timeout after','reset':'Connection reset','gc':'GC pause','retry':'retry attempt','realloc':'Block reallocation','delay':'collection delayed'}
ROUTINE=['periodic status report','Scheduled health check','System check completed','Routine monitoring ping','Heartbeat OK','Monitoring sweep']
def build(d):
    f=pd.DataFrame(index=d.index)
    num=['cpu_usage','memory_usage','disk_io','network_latency','error_rate','temperature','health_score']
    for c in num: f[c]=d[c]
    f['disk_neg']=(d.disk_io<0).astype(int); f['err_neg']=(d.error_rate<0).astype(int)
    f.loc[d.disk_io<0,'disk_io']=np.nan; f.loc[d.error_rate<0,'error_rate']=np.nan
    f['n_missing']=d[num].isna().sum(axis=1)
    ts=pd.to_datetime(d.timestamp); f['hour']=ts.dt.hour+ts.dt.minute/60
    f['region']=d.region.astype('category').cat.codes; f['stype']=d.server_type.astype('category').cat.codes
    f['srv']=d.server_id.str[4:].astype(int)
    t=d.alert_title.str.replace(r'srv_\d+','',regex=True)
    f['routine']=t.str.contains('|'.join(ROUTINE)).astype(int)
    for k,w in {'a_err':'rror','a_mem':'emory|RAM','a_cpu':'CPU|rocessor','a_net':'etwork|acket|onnectiv|timeout','a_disk':'isk'}.items(): f[k]=t.str.contains(w).astype(int)
    lg=d.system_log
    for L in LEVELS: f['n_'+L]=lg.str.count(r'\] '+L)
    f['n_lines']=lg.str.count('\n')+1
    for k,p in PAT.items(): f['p_'+k]=lg.str.count(re.escape(p))
    f['gc_ms']=lg.str.extract(r'GC pause (\d+)ms')[0].astype(float)
    f['lat_ms']=lg.str.extract(r'Check latency (\d+)ms')[0].astype(float)
    f['pool_pct']=lg.str.extract(r'pool at (\d+)%')[0].astype(float)
    f['log_cpu']=lg.str.extract(r'cpu=(\d+)%')[0].astype(float); f['log_mem']=lg.str.extract(r'mem=(\d+)%')[0].astype(float)
    f['sev']=f.n_WARN+3*f.n_ERROR+9*f.n_CRIT
    return f

def history(d, F):
    """per-server features computed in time order (the anomaly score looks like a running statistic)"""
    F = F.copy(); ts = pd.to_datetime(d.timestamp)
    o = np.argsort(ts.values, kind='stable'); g = d.server_id.values
    tmp = pd.DataFrame({'g': g, 'ts': ts.values, 'sev': F.sev.values, 'err': F.error_rate.fillna(F.error_rate.median()).values,
                        'crit': F.n_CRIT.values, 'rout': F.routine.values}, index=d.index).iloc[o]
    G = tmp.groupby('g')
    tmp['k'] = G.cumcount()
    for c in ['sev', 'err', 'crit', 'rout']:
        tmp['cm_' + c] = G[c].transform(lambda s: s.expanding().mean())
        tmp['ew_' + c] = G[c].transform(lambda s: s.ewm(halflife=3).mean())
        tmp['prev_' + c] = G[c].shift(1)
        tmp['next_' + c] = G[c].shift(-1)
    tmp['dt_prev'] = G.ts.diff().dt.total_seconds() / 60
    tmp = tmp.loc[d.index]
    for c in tmp.columns:
        if c not in ('g', 'ts', 'sev', 'err', 'crit', 'rout'): F[c] = tmp[c].values
    return F

P = dict(n_estimators=600, learning_rate=0.03, num_leaves=15, min_child_samples=20, subsample=0.8,
         subsample_freq=1, colsample_bytree=0.8, n_jobs=2, verbose=-1, random_state=0)

def oof_threshold(X, y):
    p = cross_val_predict(lgb.LGBMClassifier(**P), X, y, cv=KFold(5, shuffle=True, random_state=0),
                          method='predict_proba')[:, 1]
    return max((f1_score(y, p > t), t) for t in np.arange(0.1, 0.8, 0.02))[1]

def fit_predict(Ftr, tr, Fte):
    lab = tr.failure.notna().values
    t = oof_threshold(Ftr[lab], tr.failure.values[lab])
    clf = lgb.LGBMClassifier(**P).fit(Ftr[lab], tr.failure.values[lab])
    reg = lgb.LGBMRegressor(objective='l1', **P).fit(Ftr, tr.anomaly_score.values)
    return (clf.predict_proba(Fte)[:, 1] > t).astype(int), np.clip(reg.predict(Fte), 0, 100), t

if __name__ == '__main__':
    tr = pd.read_csv('train.csv').sort_values('timestamp').reset_index(drop=True)
    F = history(tr, build(tr)); cut = (tr.timestamp >= '2025-03-01 18:00:00').values
    pa, pb, t = fit_predict(F[~cut], tr[~cut], F[cut])
    v = tr[cut]; m = v.failure.notna().values
    f1 = f1_score(v.failure.values[m], pa[m]); mae = mean_absolute_error(v.anomaly_score, pb)
    pts = 50 * min(1, max(0, (f1 - 0.3) / 0.3)) + 50 * min(1, max(0, (13 - mae) / 5))
    print(f'hold-out 18:00-24:00  F1={f1:.3f} (threshold {t:.2f})  MAE={mae:.3f}  -> {pts:.1f}/100 points')
    te = pd.read_csv('test.csv'); Fte = history(te, build(te))[F.columns]
    pa, pb, t = fit_predict(F, tr, Fte)
    sub = pd.concat([pd.DataFrame({'id': te.id, 'subtaskID': 1, 'answer': pa.astype(object)}),
                     pd.DataFrame({'id': te.id, 'subtaskID': 2, 'answer': pb.round(2).astype(object)})])
    sub.to_csv('../submission.csv', index=False); print('submission.csv:', len(sub), 'rows; predicted failure rate %.3f' % pa.mean())