Discord

Checklist CAIO 2025 National Qualifier · Task 2

SOTA-AI Editorial

Applied Problem Solving: Loan Default Prediction

Predict whether each loan is paid back from borrower and loan features, with emphasis on detecting defaults.

Written
8 October 2026
Result
No score measured because the official CSV files were inaccessible; the script provides a runnable candidate pipeline.
Points
Not measured; see result for the limitation.
Olympiad
Canada AI Olympiad (CAIO), 2025 · National Qualifier

Hints

Hint 1

Read the metric carefully: the positive class for the score is default, even though the source target uses 0 for default.

Hint 2

Inspect the class ratio and feature types; treat grades, subgrades, and borrower categories as categorical.

Hint 3

Use a stratified holdout and tune the threshold for default F1 rather than assuming 0.5.

Hint 4

After choosing the threshold and settings, refit on all 100,000 labeled rows and emit predictions in the exact test ID order.

The solution in one line

Tune a mixed-feature boosted classifier for default-class F1; the runnable solution is ready, but a score is unverified because the official data could not be retrieved.

The editorial

Applied Problem Solving: Loan Default Prediction — CAIO 2025

Problem domain: Tabular machine learning, imbalanced binary classification
Official metric: F1 score for the default class (loan_paid_back = 0) on 5,000 undisclosed rows selected from a 20,000-row test set; code quality is also considered.

Abridged statement

Predict whether a loan is repaid using borrower and loan information. The released notebook describes 100,000 labeled training applications and 20,000 test applications. loan_paid_back is 1 for repayment and 0 for default; approximately 80% of training examples are repaid. The contest instructions prohibit external data. See the SOTA task summary and organizer's 2025 materials.

Dataset analysis and EDA

The official summary describes 12 predictors: annual income, debt-to-income ratio, credit score, loan amount, interest rate, gender, marital status, education level, employment status, loan purpose, grade, and subgrade. The grade is A–F and the subgrade is 1–5. The target is imbalanced toward repayment (roughly 4:1).

The organizer published train.csv, test.csv, and a post-contest test_with_target.csv. Those files are linked from the SOTA task page and CAIO page, but Google Drive requests from this execution environment lead to sign-in or are denied. Therefore row-level missingness, category counts, outliers, duplicate IDs, and feature/target distributions were not measured here. The script prints sample counts, class balance, and categorical columns once run against the official files.

Experiments

Experiment Dataset / validation Result
Stratified 80/20 validation with threshold tuning Not run: source CSV could not be downloaded No score
Post-contest released-test evaluation Not run: test_with_target.csv could not be downloaded No score

No leaderboard or organizer score is claimed. In particular, the 100/100 maximum possible task score is not evidence that this implementation achieved full credit.

Chosen method

The solution script uses a scikit-learn pipeline with median imputation for numeric columns, one-hot encoding for categorical columns, and HistGradientBoostingClassifier. It maps the official label to default = 1, selects a probability threshold that maximizes default-class F1 on a stratified 20% holdout, then fits on all released training rows and writes binary loan_paid_back predictions for the test IDs.

Threshold selection matters because F1 is evaluated on defaults, the minority class; a generic 0.5 cutoff is not guaranteed to maximize that score. The script can optionally evaluate against post-contest test_with_target.csv, but that file is never included in fitting.

Score and provenance

Measured score: none. The metric and split design are organizer/checklist-reported. No local score was possible because the official notebook and CSV files were inaccessible from this execution environment. To reproduce a score, run the script on the source files; pass --released-test-target to evaluate on the published target-bearing test file. If that file contains all 20,000 labels, its full-file F1 is a post-contest local measurement, not necessarily the official score on the organizer's undisclosed 5,000-row subset.

Implementation and diagnostics

The implementation is CPU-compatible, deterministic under the selected seed, keeps the held-out rows out of training during threshold selection, and uses no external data. It reports holdout F1, precision, recall, threshold, feature count, and class balance. No prediction file or trained model was generated here. The official notebook's baseline uses standardization, one-hot encoding, and a class-balanced decision tree; the source description says accuracy/code quality were general applied-round criteria, while this task's stated target score is default-class F1.

Compute and footprint

The chosen scikit-learn model is intended for a CPU and a 100,000-row, 12-feature dataset. Runtime and peak memory were not measured because no CSV was available. The pipeline and requirements are small; it does not need a GPU or pretrained weights.

Alternatives considered

  • The organizer's class-balanced decision tree is a direct baseline but can be unstable and may leave F1 below a boosted-tree model.
  • Logistic regression would be a useful interpretable baseline after one-hot encoding, but may underfit nonlinear income, credit, and grade interactions.
  • CatBoost or LightGBM could be strong for mixed tabular data, but add dependencies and were not empirically compared.
  • Returning the majority repayment class would score poorly on default F1.

These are methodological expectations, not measured comparisons on the unavailable data.

Sources and reuse

The source page does not state a data license. The report links the organizer materials and does not copy data or notebook contents. The contest prohibits external data and AI assistance during the live exam; this implementation uses only the official feature files for its intended historical practice workflow.

Code

Solution code solution.py · 127 lines
#!/usr/bin/env python3
"""Train a default-focused classifier for the 2025 CAIO loan task.

Expected files: train.csv and test.csv from the official CAIO Drive folder.
The optional test_with_target.csv is a post-contest release and is used only
for a final, explicitly labeled evaluation; it never enters model fitting.
"""

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 = "loan_paid_back"
ID_CANDIDATES = ("id", "ID", "loan_id")


def make_pipeline(frame: pd.DataFrame, seed: int) -> Pipeline:
    """Build a compact one-hot + histogram-gradient-boosting pipeline."""
    categorical = list(frame.select_dtypes(include=["object", "category", "bool"]).columns)
    numeric = [c for c in frame.columns if c 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=450,
        max_leaf_nodes=31,
        l2_regularization=2.0,
        class_weight="balanced",
        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]:
    """Return the threshold that maximizes F1 for default (encoded as 1)."""
    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))
    f1 = 2 * precision[:-1] * recall[:-1] / np.maximum(precision[:-1] + recall[:-1], 1e-12)
    idx = int(np.argmax(f1))
    return float(thresholds[idx]), float(f1[idx])


def default_target(series: pd.Series) -> np.ndarray:
    """Map the supplied label (1 = repaid, 0 = default) to 1 = default."""
    paid = pd.to_numeric(series, errors="raise").astype(int).to_numpy()
    values = set(np.unique(paid).tolist())
    if not values.issubset({0, 1}):
        raise ValueError(f"{TARGET} must contain only 0/1; found {sorted(values)}")
    return 1 - paid


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


def feature_frame(frame: pd.DataFrame, label: bool) -> pd.DataFrame:
    drop = [c for c in [id_column(frame), TARGET if label else None] if c 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("--released-test-target", type=Path, help="Optional post-contest test_with_target.csv")
    parser.add_argument("--output", type=Path, default=Path("submission.csv"))
    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 = default_target(train[TARGET])
    X = feature_frame(train, label=True)
    X_test = feature_frame(test, label=False).reindex(columns=X.columns)
    counts = np.bincount(y, minlength=2)
    print(f"train={len(train):,}; features={X.shape[1]}; defaults={counts[1]:,} ({counts[1]/len(y):.3%})")
    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, best_f1 = best_f1_threshold(y[valid_idx], p_valid)
    y_valid_hat = (p_valid >= threshold).astype(int)
    print(
        f"holdout default-F1={best_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}"
    )

    final_model = make_pipeline(X, args.seed)
    final_model.fit(X, y)
    p_test = final_model.predict_proba(X_test)[:, 1]

    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_eval = final_model.predict_proba(feature_frame(released, label=True).reindex(columns=X.columns))[:, 1]
        y_eval = default_target(released[TARGET])
        print(f"released-test default-F1 at holdout-selected threshold={f1_score(y_eval, p_eval >= threshold, zero_division=0):.6f}")
        released_id = id_column(released)
        if released_id and id_column(test) and not np.array_equal(released[released_id], test[id_column(test)]):
            print("warning: released-label rows are not in the same order as test.csv; evaluation was on the released file order")

    predicted_default = (p_test >= threshold).astype(int)
    output = pd.DataFrame()
    test_id = id_column(test)
    output["id"] = test[test_id] if test_id else np.arange(len(test))
    # The official target is 1 for paid back, so invert the tuned default label.
    output[TARGET] = 1 - predicted_default
    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.