Chavín Microfinance — 90-day default risk
Chavín approves microloans in five regions. Risk wants every application in
data/holdout.csv ranked, because the credit team can pull and review only
5% of them a week. They do not want a yes/no; they want the order.
Time: 90 minutes. Write everything in solution.py.
About 5% of applications default. A model that answers "no default" every time is 95% accurate, and finds nothing. Accuracy is not the metric here and never was.
Files
| File | What it is |
|---|---|
data/applications.csv |
12 000 decided applications. Has default_90d. |
data/holdout.csv |
15 000 applications to rank. No default_90d. |
Columns
| Column | Notes |
|---|---|
application_id |
Unique. |
application_date |
ISO date. |
employment_sector |
Sector code, long-tailed. The scoring window opened new codes — see rule 3. |
education |
primaria, secundaria, tecnica, universitaria, postgrado. Ordered. |
income_monthly |
Declared monthly income in soles. 999999 means not declared. |
credit_history_months |
Months of bureau history. |
prior_defaults |
Count of earlier defaults. |
requested_amount |
Loan requested. |
debt_to_income |
Ratio. Some rows are negative, which is impossible — a sign error at capture. |
channel |
branch, app, agent. |
region |
Five regions. |
default_90d |
Target: 1 if 90 days past due. applications.csv only. |
Known defects, stated as rules
income_monthlyuses999999for "not declared". That is missing, not a large income.- Negative
debt_to_incomeis invalid. Treat those as missing. Do not take the absolute value. employment_sectorgained codes after the training window closed. A handful of codes appear inholdout.csvand in no training row.educationis ordinal. Its ordering carries information that one-hot encoding discards.
Tasks
T1 — class_balance(df) -> dict (10 pts)
Exactly three keys: rows, defaults, default_rate (a share, not a percentage, rounded
to four decimals).
T2 — unseen_levels(train, holdout) -> dict[str, list[str]] (15 pts)
Across the categorical columns employment_sector, education, channel and region,
return {column: sorted list of levels present in holdout and absent from train} — only
for the columns that have at least one.
T3 — clean(df) -> pd.DataFrame (15 pts)
Return the frame with:
- 999999 in income_monthly replaced by missing;
- negative debt_to_income replaced by missing;
- a new integer column education_level: primaria=1, secundaria=2, tecnica=3,
universitaria=4, postgrado=5.
No rows may be dropped. An undeclared income is a fact about the applicant.
T4 — design_matrix(train, holdout) -> (X_train, X_holdout) (20 pts)
Return two numeric matrices — arrays or DataFrames — with the same number of columns,
one row per input row, and no missing values in X_holdout. Rule 3 is the whole point:
whatever encodes the categories has to survive a level it has never seen, without widening
the matrix and without losing the applicant.
T5 — fit_predict(train, holdout) -> np.ndarray (40 pts)
Both frames arrive raw. Return a risk score per holdout row, in file order — higher means likelier to default. Any monotone score works; probabilities are fine.
Graded on two numbers:
- Average precision (area under precision–recall): must beat 2.5× the base rate, then
>= 0.140 is competent and >= 0.155 is good.
- Recall inside the 5% review budget: of all the applications that really defaulted,
the share that lands in your top 5% must be >= 0.170.
Allowed: pandas, numpy, scikit-learn.