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.
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
- SOTA task page, including data links and task rules
- CAIO Prepare page
- Official Round 2 Colab notebook
- Official Round 2 Drive folder
- Post-contest
test_with_target.csv
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
#!/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()
Editorials are written by members of the SOTA community, not by the olympiad. The official statement and scoring win wherever an editorial differs.