import { useEffect, useMemo, useState } from "react"; import { getMetrics, type Metrics as MetricsData, type MetricRow } from "../api"; import { useT } from "../i18n"; interface AnomalyGroup { family: string; label: string; rows: { intensity: string; values: Record }[]; } const MOBILE_BREAKPOINT_PX = 900; const FINAL_MODEL_FONT_DESKTOP = 32; const FINAL_MODEL_FONT_MOBILE = 24; const FINAL_DESC_FONT_DESKTOP = 11; const FINAL_DESC_FONT_MOBILE = 12; const KPI_VALUE_FONT_DESKTOP = 26; const KPI_VALUE_FONT_MOBILE = 30; const PR_AUC_BAR_HEIGHT = 10; function useIsMobile(): boolean { const [isMobile, setIsMobile] = useState(() => { if (typeof window === "undefined" || !window.matchMedia) return false; return window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT_PX}px)`).matches; }); useEffect(() => { if (typeof window === "undefined" || !window.matchMedia) return; const mq = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT_PX}px)`); const handler = (event: MediaQueryListEvent) => setIsMobile(event.matches); setIsMobile(mq.matches); mq.addEventListener("change", handler); return () => mq.removeEventListener("change", handler); }, []); return isMobile; } function SwipeHint() { return (
← swipe →
); } function groupAnomalies(rows: MetricRow[]): AnomalyGroup[] { const families: Record = { route_deviation: "ROUTE DEVIATION", altitude: "ALTITUDE BUST", speed: "SPEED ANOMALY", holding: "HOLDING TURNS", freeze: "TRANSPONDER CUT", }; const grouped: Record = {}; if (rows.length === 0) return []; const keys = Object.keys(rows[0].synthetic_per_type); for (const key of keys) { const family = Object.keys(families).find((f) => key.startsWith(f)); if (!family) continue; const intensity = key.slice(family.length).trim() || "—"; if (!grouped[family]) { grouped[family] = { family, label: families[family], rows: [] }; } const values: Record = {}; for (const row of rows) values[row.model] = row.synthetic_per_type[key]; grouped[family].rows.push({ intensity, values }); } return Object.values(grouped); } function pct(value: number): string { return `${(value * 100).toFixed(1)}%`; } function delta(target: number, baseline: number): string { if (baseline <= 0) return "—"; const change = ((target - baseline) / baseline) * 100; const sign = change >= 0 ? "+" : ""; return `${sign}${change.toFixed(0)}%`; } const METHODOLOGY = [ { k: "TRAIN SPLIT", v: "2017 – 2019 / 61,008 windows" }, { k: "VALIDATION", v: "2020 Q1 / 7,679 windows" }, { k: "TEST (HELD-OUT)", v: "2020 Q1–Q2 / 7,788 windows" }, { k: "WINDOW LENGTH", v: "60 steps × 10 s = 10 min" }, { k: "FEATURES (7)", v: "x_rel, y_rel, alt, velocity, sin/cos hdg, vertrate" }, { k: "SCALING", v: "Standard scaler fit on train only" }, { k: "THRESHOLD RULE", v: "99th percentile of validation error" }, { k: "SYN. SAMPLE", v: "2,000 windows × 12 anomaly variants" }, { k: "FRAMEWORK", v: "PyTorch (MPS) · Optuna · MLflow" }, ]; const MODEL_NOTES: Record = { Baseline: "Isolation Forest on summary features. Reference to prove DL adds value.", LSTM: "Sequence-to-sequence LSTM autoencoder. Strong baseline for time series.", Transformer: "Self-attention encoder/decoder. Captures long-range dependencies.", "VAE-LSTM": "Variational LSTM autoencoder. Probabilistic, principled threshold.", }; export default function Metrics() { const t = useT(); const isMobile = useIsMobile(); const [data, setData] = useState(null); const [error, setError] = useState(null); useEffect(() => { getMetrics().then(setData).catch((reason) => setError(String(reason))); }, []); const groups = useMemo(() => (data ? groupAnomalies(data.results) : []), [data]); if (error) return
{t.monitor.offline}
; if (!data || data.results.length === 0) return
{t.metrics.none}
; const baseline = data.results.find((row) => row.model === "Baseline"); const winner = data.results.find((row) => row.model === data.selected_model) ?? data.results[0]; const maxPr = Math.max(...data.results.map((row) => row.real_pr_auc)); const finalModelFontSize = isMobile ? FINAL_MODEL_FONT_MOBILE : FINAL_MODEL_FONT_DESKTOP; const finalDescFontSize = isMobile ? FINAL_DESC_FONT_MOBILE : FINAL_DESC_FONT_DESKTOP; const kpiValueFontSize = isMobile ? KPI_VALUE_FONT_MOBILE : KPI_VALUE_FONT_DESKTOP; const finalDescMaxWidth = isMobile ? "100%" : 720; return (
FINAL MODEL
{winner.model.toUpperCase()}
Selected after a head-to-head evaluation of 4 detectors on identical preprocessing, splits and metrics. Held-out PR-AUC and synthetic robustness drive the choice; the other three remain as documented baselines.
{[ { label: "REAL ROC-AUC", value: pct(winner.real_roc_auc), hint: "anomaly separability" }, { label: "REAL PR-AUC", value: pct(winner.real_pr_auc), hint: "rare-class precision/recall" }, { label: "SYNTHETIC ROC", value: pct(winner.synthetic_mean_roc_auc), hint: "mean across 12 variants" }, { label: "vs BASELINE", value: baseline ? delta(winner.real_pr_auc, baseline.real_pr_auc) : "—", hint: "PR-AUC improvement over IF", }, ].map((kpi) => (
{kpi.label}
{kpi.value}
{kpi.hint}
))}
HEAD-TO-HEAD COMPARISON held-out test · 2020 · {data.results.length} detectors
{isMobile ? (
{data.results.map((row) => { const chosen = row.model === data.selected_model; return (
{row.model} {chosen ? " ★" : ""}
{MODEL_NOTES[row.model] ?? ""}
REAL ROC
{pct(row.real_roc_auc)}
REAL PR-AUC
{pct(row.real_pr_auc)}
SYN ROC
{pct(row.synthetic_mean_roc_auc)}
Δ vs BASELINE
= baseline.real_pr_auc ? "var(--info)" : "var(--muted)", }} > {baseline ? delta(row.real_pr_auc, baseline.real_pr_auc) : "—"}
PR-AUC
); })}
) : (
{data.results.map((row) => { const chosen = row.model === data.selected_model; return ( ); })}
MODEL REAL ROC REAL PR-AUC SYN ROC Δ vs BASELINE PR-AUC
{row.model} {chosen ? " ★" : ""}
{MODEL_NOTES[row.model] ?? ""}
{pct(row.real_roc_auc)} {pct(row.real_pr_auc)} {pct(row.synthetic_mean_roc_auc)} = baseline.real_pr_auc ? "var(--info)" : "var(--muted)", }} > {baseline ? delta(row.real_pr_auc, baseline.real_pr_auc) : "—"}
)}
SYNTHETIC ANOMALY PERFORMANCE ROC-AUC by anomaly family and intensity
{isMobile && }
{data.results.map((row) => ( ))} {groups.map((group) => group.rows.map((row, idx) => ( {data.results.map((mdl) => { const value = row.values[mdl.model]; const isWinner = Math.max(...Object.values(row.values)) === value && data.results.length > 1; return ( ); })} )), )}
ANOMALY INTENSITY {row.model}
{idx === 0 ? group.label : ""} {row.intensity} {pct(value)}
METHODOLOGY
{METHODOLOGY.map((entry) => (
{entry.k}
{entry.v}
))}
WHY THIS MODEL
The {winner.model} reaches the highest held-out PR-AUC ({pct(winner.real_pr_auc)}) while maintaining strong synthetic robustness ({pct(winner.synthetic_mean_roc_auc)} mean ROC). Probabilistic latent space gives a principled threshold and uncertainty estimates, which matters more than raw ROC when anomalies are rare.
METRIC PRIMER
ROC-AUC → probability a random anomaly scores higher than a random normal. 50% = random, 100% = perfect.
PR-AUC → area under precision/recall. More informative than ROC when anomalies are rare (our case).
HELD-OUT → test data the model never saw during training or hyperparameter tuning. The honest measure of generalization.
); }