Checklist OAI 2025 Stage I · Task 3
SOTA-AI Editorial
ECG Signal Disorder Detection
Design at most four meta-features of synthetic single-lead ECG segments so that a small random forest classifies them into normal and four arrhythmia classes.
Hints
Hint 1
Check class balance before choosing the metric or forest weights.
Hint 2
Locate a rough QRS region from the largest positive and negative extrema.
Hint 3
Measure irregularity, dominant deviation, and post-QRS level change with four scalar features.
Hint 4
Preserve the sign of the strongest non-QRS deviation; it separates positive atrial waves from negative ventricular waves.
The solution in one line
A four-feature 10-tree Random Forest with signed non-QRS deviation reaches 98.4413% released-validation BAC and 100/100 points.
The editorial
Poland 2025 Stage I — Wykrywanie Zaburzeń Sygnału EKG (ECG Anomaly Detection)
Domain: Synthetic biomedical time-series classification and signal feature engineering
Metric: Five-class balanced accuracy. The task awards 100 points at BAC ≥98% and 0 points at BAC ≤75%.
Official sources: SOTA checklist · starter notebook · organizer worked solution
Abridged statement
Classify a length-150 single-lead ECG segment into normal, AFib, PAC, PVC, or ST-elevation. Build at most four meta-features and train a Random Forest with at most 10 trees and maximum depth 10. Other classifiers are disallowed; feature construction may use Python, NumPy, and SciPy.
Dataset analysis and EDA
The released NPZ contains 2,000 training and 1,500 validation signals, each with 150 float64 samples. Training counts are 1,400 normal and 150 for each anomaly. Validation counts are 819 normal, 142 AFib, 191 PAC, 197 PVC, and 151 ST-elevation. The organizer verifies there are no missing values and notes extreme amplitudes are meaningful signal morphology, so they are retained. The data are synthetic approximations. Visual EDA describes AFib as irregular baseline activity, PAC as a premature atrial wave that can appear before or after the visible QRS, PVC as an abnormal ventricular complex, and ST-elevation as a raised post-QRS segment.
Experiments
The organizer's v1 features are maximum-deviation position, unsigned maximum-deviation amplitude, total absolute changes, and a post-QRS two-window mean difference. With a balanced 10-tree, depth-10 forest, this obtains 82.9749% BAC and 35/100 points. The v2 experiment replaces the global deviation amplitude with a pre-QRS-only amplitude; it falls to 80.84% BAC because PAC changes may occur after the visible QRS and because post-QRS information also helps PVC and ST-elevation.
The organizer's v3 subtracts a slow sinusoidal baseline (0.5*sin(i/45)) and smooths the signal with a width-5 moving average before selecting the maximum deviation. Its four features obtain 97.3691% BAC and 97/100 points in the executed organizer notebook. I reproduced that exact score from the GitHub NPZ.
For the local optimization, I tried raw extrema and positions, R-relative window means/ranges/roughness, pre/post segment standard deviations, signed and unsigned deviations, and alternate ST-window differences. Replacing the unsigned amplitude with the signed strongest deviation outside the QRS proxy gave the best result. Keeping the other three organizer v3 features, this candidate reaches 98.4413% BAC. The best single replacements for the other slots were lower: alternative position 96.8887%, alternative roughness 97.5488%, and alternative ST difference 98.1507%. I also tested 64 permitted Random Forest hyperparameter combinations on the organizer features; none exceeded v3's 97.3691% BAC without the feature change.
Selected solution
ecg_solution.py implements baseline correction and QRS-proxy segmentation, then computes: (1) smoothed maximum-deviation position, (2) signed maximum deviation outside the QRS proxy, (3) total absolute changes there, and (4) the early/late post-QRS mean difference. It fits a 10-tree, depth-10 balanced Random Forest with random_state=42. It uses the official training labels only; validation labels are used only to score the fitted model.
Validation score and evidence
Local validation: 98.4413% BAC, 100/100 points under the official scoring rule. Reproduced from the official train_validation_sets.npz using the standalone runner. Command:
python solutions/2025/stage1/ecg_solution.py path/to/train_validation_sets.npz
The 97.3691% organizer result is notebook-recorded; the 98.4413% result is locally measured. Neither establishes performance on the secret test split.
Compute and runtime
The standalone fit-and-score command completed in about 10 seconds end to end on the local CPU, including Python and scientific-library startup. The model uses four scalar features and ten depth-10 trees, comfortably within the official one-minute CPU limit. No GPU is needed.
Alternatives
The notebook discusses phase-aligning each signal at its R peak and using fixed offsets, as well as hybrid time/frequency features based on FFT and morphology. These were suggested but not measured in the executed notebook. Locally, direct R-relative regions and raw peak/roughness features were tested as one-feature replacements and were weaker than the selected signed deviation.
Code
Solution code
"""Four-feature Random Forest for the Poland 2025 ECG task.
The implementation follows the official solution's baseline correction and
QRS proxy segmentation. It replaces the unsigned maximum-deviation amplitude
with its signed counterpart on the non-QRS signal; this gives a higher score
on the released validation split while keeping the task's four-feature and
Random Forest limits.
Run the released-validation check with:
python ecg_solution.py /path/to/train_validation_sets.npz
No validation targets are used during fitting. The NPZ and fitted model are
not copied into this repository.
"""
from __future__ import annotations
import sys
from dataclasses import dataclass
import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import balanced_accuracy_score
def extract_meta_features(signals: np.ndarray) -> np.ndarray:
"""Map each length-150 ECG to four allowed meta-features."""
x = np.asarray(signals, dtype=np.float64)
if x.ndim == 1:
x = x[None, :]
if x.ndim != 2 or x.shape[1] != 150:
raise ValueError(f"expected shape (n, 150), got {x.shape}")
# Remove the slow baseline drift used by the organizer's strongest model.
x = x - 0.5 * np.sin(np.arange(x.shape[1], dtype=np.float64) / 45.0)
min_idx = x.argmin(axis=1)
max_idx = x.argmax(axis=1)
center = (min_idx + max_idx) // 2
period = (1.5 * (max_idx - min_idx)).astype(int)
pre_end = np.clip(center - period, 0, x.shape[1])
post_start = np.clip(center + period, 0, x.shape[1])
pre = [row[:end] for row, end in zip(x, pre_end)]
post = [row[start:] for row, start in zip(x, post_start)]
outside_qrs = [np.concatenate((left, right)) for left, right in zip(pre, post)]
# Feature 1: the location of the strongest deviation after smoothing.
smooth = [np.convolve(row, np.ones(5) / 5.0, mode="same") for row in outside_qrs]
max_deviation_position = np.asarray(
[np.argmax(np.abs(row - row.mean())) for row in smooth], dtype=np.float64
)
# Feature 2: signed amplitude of the strongest deviation. Keeping its sign
# distinguishes positive atrial waves from negative ventricular waves.
signed_deviation = np.asarray(
[row[np.argmax(np.abs(row - row.mean()))] - row.mean() for row in outside_qrs],
dtype=np.float64,
)
# Feature 3: total variation outside the approximate QRS region.
sum_of_changes = np.asarray(
[np.abs(np.diff(row)).sum() for row in outside_qrs], dtype=np.float64
)
# Feature 4: early-versus-late mean difference after the QRS proxy.
post_mean_difference = np.asarray(
[
row[:20].mean() - row[20:40].mean() if len(row) >= 40 else 0.0
for row in post
],
dtype=np.float64,
)
return np.column_stack(
(max_deviation_position, signed_deviation, sum_of_changes, post_mean_difference)
)
@dataclass
class ECGSolution:
"""Trainable wrapper matching the organizer's four-feature RF interface."""
random_forest: RandomForestClassifier | None = None
@staticmethod
def get_rf_hyperparameters() -> dict[str, object]:
return {
"n_estimators": 10,
"max_depth": 10,
"random_state": 42,
"class_weight": "balanced",
}
def compute_meta_features(self, signals: np.ndarray) -> np.ndarray:
return extract_meta_features(signals)
def fit(self, signals: np.ndarray, labels: np.ndarray) -> "ECGSolution":
self.random_forest = RandomForestClassifier(**self.get_rf_hyperparameters())
self.random_forest.fit(self.compute_meta_features(signals), np.asarray(labels))
return self
def predict(self, signals: np.ndarray) -> np.ndarray:
if self.random_forest is None:
raise RuntimeError("call fit(X_train, y_train) before predict")
return self.random_forest.predict(self.compute_meta_features(signals))
def validation_score(npz_path: str) -> tuple[float, int]:
"""Fit on the released train split and score once on released validation."""
from sklearn.metrics import balanced_accuracy_score
data = np.load(npz_path, allow_pickle=True)
model = ECGSolution().fit(data["X_train"], data["y_train"])
bac = balanced_accuracy_score(data["y_validation"], model.predict(data["X_validation"]))
points = int(round(min(max((100.0 * bac - 75.0) * 100.0 / 23.0, 0.0), 100.0)))
return float(bac), points
if __name__ == "__main__":
if len(sys.argv) != 2:
raise SystemExit("usage: python ecg_solution.py train_validation_sets.npz")
bac, points = validation_score(sys.argv[1])
print(f"Validation balanced accuracy: {100.0 * bac:.4f}%")
print(f"Estimated score: {points}/100")
Editorials are written by members of the SOTA community, not by the olympiad. The official statement and scoring win wherever an editorial differs.