Discord

Checklist Colombia AI Olympiad 2025 Problem 1 (round not stated) · Task 1

SOTA-AI Editorial

Heart Disease Risk Prediction

Predict whether a synthetic patient has a high or low five-year heart-disease risk.

Written
8 October 2026
Result
No F1 measured because competition CSV files were unavailable; no Kaggle submission result is claimed.
Points
Not measured; see result for the limitation.
Olympiad
Colombian Artificial Intelligence Olympiad (Olimpiada Colombiana de Inteligencia Artificial) – Universidad Antonio Nariño and the Olimpiadas Colombianas team, 2025 · Problem 1 (round not stated)

Hints

Hint 1

Identify the exact target, ID, and submission columns from the sample submission before fitting.

Hint 2

Check the approximately 40% high-risk prevalence and distinguish nominal from ordinal categories.

Hint 3

Use stratified validation and optimize the decision threshold for F1; report precision and recall alongside it.

Hint 4

Fit the selected model on all labeled rows and preserve test ID order in the submission.

The solution in one line

Tune a mixed-feature boosted classifier for high-risk F1; code is ready, while score and data diagnostics remain unverified because Kaggle files were inaccessible.

The editorial

Heart Disease Risk Prediction — Colombia AI Olympiad 2025, Problem 1

Problem domain: Tabular machine learning, binary classification
Official metric: F1 score

Abridged statement

Given synthetic patient attributes, predict whether the patient has high five-year heart-disease risk. The target column is heart_disease_risk (1 high, 0 low). The SOTA task page describes a Kaggle task titled “Colombian AI Olympiad 2025 (Pr1) Heart Attack.”

Dataset analysis and EDA

The released competition files are train.csv, test.csv, and sample_submission.csv. The task summary describes a Python-generated synthetic dataset inspired by dependencies in the Framingham Heart Study. It names age, gender, smoking, cholesterol and yearly cholesterol change, stress level, and diet quality; it says roughly 40% of samples are high risk. The described dependency structure includes gender affecting smoking, smoking affecting cholesterol and cholesterol change, and age affecting baseline cholesterol. Some categories are ordinal and distributions overlap.

Exact dataset size, feature spellings beyond the target, category levels, missingness, and correlations were not measured here because the Kaggle data endpoint returned no downloadable contents in this environment. The solution prints the actual row count, class balance, numeric columns, and categorical columns once the files are available.

Experiments

Experiment Result
Stratified 80/20 validation with F1 threshold tuning Not run: competition files could not be downloaded
Released/Kaggle hidden evaluation Not submitted; no organizer score available here

Chosen method

The solution script uses a scikit-learn HistGradientBoostingClassifier with median imputation, one-hot encoding for string categories and nominal gender/smoking fields, and a deterministic stratified 80/20 validation split. It selects the probability threshold that maximizes high-risk F1 on the validation fold, refits on all labeled rows, and writes id,heart_disease_risk predictions.

This is a compact CPU model for nonlinear interactions in mixed tabular features. The threshold is chosen against F1 rather than accuracy, which is the organizer's stated metric. Ordinal variables that are supplied as numeric levels remain ordered numeric features; string-valued levels are one-hot encoded.

Score and provenance

Measured score: none. F1 is the organizer-reported metric. The local score and Kaggle score are unknown because neither the competition data nor a released labeled test file was retrievable from this execution environment. No perfect score is claimed.

Implementation and diagnostics

The script prints train/test sizes, target prevalence, feature dtypes, validation threshold, precision, recall, and F1. It drops the ID from model features, aligns test columns to the training schema, and writes IDs in test order. It can optionally evaluate a released labeled test CSV with --released-test-target; such a measurement should be labeled post-contest and must not be confused with a live Kaggle score.

The dataset is synthetic, so feature dependencies may be stronger and more structured than in clinical records. A simple train/validation split estimates performance on the same synthetic generator; it does not establish clinical validity.

Compute and footprint

The selected model runs on CPU. No runtime, memory, or hardware measurement was possible without the data. It does not require a GPU, outside medical data, or pretrained weights.

Alternatives considered

  • Logistic regression is interpretable but may miss the stated nonlinear and cascading dependencies.
  • ExtraTrees or random forests provide useful baselines for synthetic interactions but were not measured.
  • CatBoost/LightGBM can be strong on mixed tabular features and may be worth testing if available; no comparison was possible here.
  • Maximizing accuracy or using the default 0.5 threshold may sacrifice the F1 score.

These are candidate experiments rather than results.

Sources and reuse

The SOTA page says reuse is subject to Kaggle competition rules. This repository links the competition and does not copy its data or statement files.

Code

Solution code solution.py · 115 lines
#!/usr/bin/env python3
"""F1-oriented solution for Colombia AI Olympiad 2025 Heart Disease Risk."""

from __future__ import annotations

import argparse
from pathlib import Path

import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.impute import SimpleImputer
from sklearn.metrics import f1_score, precision_recall_curve, precision_score, recall_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder

TARGET = "heart_disease_risk"
ID_CANDIDATES = ("id", "ID", "patient_id")


def make_pipeline(frame: pd.DataFrame, seed: int) -> Pipeline:
    categorical = list(frame.select_dtypes(include=["object", "category", "bool"]).columns)
    # Numeric sex/smoking flags are nominal and should not acquire a false order.
    for column in frame.columns:
        name = column.lower()
        if any(token in name for token in ("gender", "sex", "smok")) and column not in categorical:
            categorical.append(column)
    numeric = [column for column in frame.columns if column not in categorical]

    transform = ColumnTransformer(
        [
            ("numeric", SimpleImputer(strategy="median", add_indicator=True), numeric),
            (
                "categorical",
                Pipeline(
                    [
                        ("impute", SimpleImputer(strategy="most_frequent")),
                        ("onehot", OneHotEncoder(handle_unknown="ignore", sparse_output=False)),
                    ]
                ),
                categorical,
            ),
        ],
        remainder="drop",
        sparse_threshold=0.0,
    )
    classifier = HistGradientBoostingClassifier(
        learning_rate=0.06,
        max_iter=400,
        max_leaf_nodes=31,
        l2_regularization=1.5,
        early_stopping=True,
        validation_fraction=0.1,
        n_iter_no_change=25,
        random_state=seed,
    )
    return Pipeline([("features", transform), ("model", classifier)])


def best_f1_threshold(y_true: np.ndarray, probability: np.ndarray) -> tuple[float, float]:
    precision, recall, thresholds = precision_recall_curve(y_true, probability)
    if thresholds.size == 0:
        return 0.5, float(f1_score(y_true, probability >= 0.5, zero_division=0))
    values = 2 * precision[:-1] * recall[:-1] / np.maximum(precision[:-1] + recall[:-1], 1e-12)
    index = int(np.argmax(values))
    return float(thresholds[index]), float(values[index])


def id_column(frame: pd.DataFrame) -> str | None:
    return next((name for name in ID_CANDIDATES if name in frame.columns), None)


def features(frame: pd.DataFrame, labeled: bool) -> pd.DataFrame:
    drop = [name for name in (id_column(frame), TARGET if labeled else None) if name is not None]
    return frame.drop(columns=drop).copy()


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--train", type=Path, default=Path("train.csv"))
    parser.add_argument("--test", type=Path, default=Path("test.csv"))
    parser.add_argument("--output", type=Path, default=Path("submission.csv"))
    parser.add_argument("--released-test-target", type=Path, help="Optional labeled test file for post-contest evaluation")
    parser.add_argument("--seed", type=int, default=42)
    args = parser.parse_args()

    train = pd.read_csv(args.train)
    test = pd.read_csv(args.test)
    if TARGET not in train:
        raise ValueError(f"Training data must contain {TARGET!r}")
    y = pd.to_numeric(train[TARGET], errors="raise").astype(int).to_numpy()
    if not set(np.unique(y)).issubset({0, 1}):
        raise ValueError(f"{TARGET} must contain only 0/1 labels")
    X = features(train, labeled=True)
    X_test = features(test, labeled=False).reindex(columns=X.columns)
    print(f"train={len(train):,}; test={len(test):,}; features={X.shape[1]}; high-risk={y.mean():.3%}")
    print(f"numeric columns={list(X.select_dtypes(include=np.number).columns)}")
    print(f"categorical columns={list(X.select_dtypes(include=['object', 'category', 'bool']).columns)}")

    train_idx, valid_idx = train_test_split(
        np.arange(len(y)), test_size=0.2, random_state=args.seed, stratify=y
    )
    validation_model = make_pipeline(X.iloc[train_idx], args.seed)
    validation_model.fit(X.iloc[train_idx], y[train_idx])
    p_valid = validation_model.predict_proba(X.iloc[valid_idx])[:, 1]
    threshold, tuned_f1 = best_f1_threshold(y[valid_idx], p_valid)
    y_valid_hat = p_valid >= threshold
    print(
        f"holdout-F1={tuned_f1:.6f}; threshold={threshold:.6f}; "
        f"precision={precision_score(y[valid_idx], y_valid_hat, zero_division=0):.6f}; "
        f"recall={recall_score(y[valid_idx], y_valid_hat, zero_division=0):.6f}"
    )

    model = make_pipeline(X, args.seed)
    model.fit(X, y)

    if args.released_test_target:
        released = pd.read_csv(args.released_test_target)
        if TARGET not in released:
            raise ValueError(f"Released test file must contain {TARGET!r}")
        p_released = model.predict_proba(features(released, labeled=True).reindex(columns=X.columns))[:, 1]
        y_released = pd.to_numeric(released[TARGET], errors="raise").astype(int).to_numpy()
        print(f"released-test F1 at holdout-selected threshold={f1_score(y_released, p_released >= threshold, zero_division=0):.6f}")

    p_test = model.predict_proba(X_test)[:, 1]
    output = pd.DataFrame()
    test_id = id_column(test)
    output["id"] = test[test_id] if test_id else np.arange(len(test))
    output[TARGET] = (p_test >= threshold).astype(int)
    output.to_csv(args.output, index=False)
    print(f"wrote {len(output):,} rows to {args.output}")


if __name__ == "__main__":
    main()

Download solution.py

Editorials are written by members of the SOTA community, not by the olympiad. The official statement and scoring win wherever an editorial differs.