A repayment-propensity model on 30,000 real accounts: six months of payment history in, a ranked chase list with an economic cut-off out. Calibrated, checked for leakage and fairness, exported to plain JavaScript — and scoring live on this page.
Every ledger with credit in it faces the same morning: more accounts in arrears than hands to chase them. The ladder in case 02 decided what to send and when; the arrears path in case 04 decided who must not be chased. What neither did is decide who to call first. That is a ranking problem, and ranking is where a model earns its place: not to replace judgement, but to put the two hundred most likely-to-slip, highest-exposure accounts at the top of the list before anyone picks up a phone.
The UCI Default of Credit Card Clients dataset (Yeh & Lien, 2009): 30,000 credit-card accounts from a Taiwanese bank, April to September 2005. It is used here because it has exactly the shape of a receivables ageing history — monthly repayment status, bill and payment amounts — and a clean, real outcome. 22.1% of accounts missed the next payment.
| FIELD | WHAT IT IS | USED AS |
|---|---|---|
| LIMIT_BAL, AGE | Credit limit (NT$) and age | Limit is a feature; age is reported by group, never a feature |
| PAY_1 … PAY_6 | Repayment status each month: −1 paid in full, 0 revolving, 1–9 = months late | The ageing history — the model's core |
| BILL_AMT1 … 6 | Statement balance each month | Exposure, utilisation, trend |
| PAY_AMT1 … 6 | Amount actually paid each month | Share of the previous bill paid, zero-payment months |
| SEX, EDUCATION, MARRIAGE | Demographics | Excluded from the model; sex used only for the fairness check |
| default payment next month | 1 = the next payment was missed | The target |
Three things had to be settled first. Undocumented codes — the education and marital fields contain values the codebook never defines; they were folded into "other" rather than dropped. Time order — every feature is built from the six months before the outcome month; nothing from the scored month leaks in. And the protocol — a stratified 80/20 hold-out set touched exactly once, with five-fold cross-validation on the training half for every comparison, so the reported numbers are not the numbers the model was tuned on.
# features built only from history known before the month being predicted (train.py) pay = d[PAY].clip(lower=-1) # -2 (no spend) and -1 (paid in full) both mean "not late" f["late_now"] = pay["PAY_1"].clip(lower=0) f["months_late"] = (pay > 0).sum(axis=1) w = np.array([6, 5, 4, 3, 2, 1]) # the last month counts most f["late_recency"] = (pay.clip(lower=0).values * w).sum(axis=1) / w.sum() ratio = np.where(prev_bill > 0, paid / np.maximum(prev_bill, 1), 1.0) # share of last bill actually paid f["pay_ratio_mean"] = np.clip(ratio, 0, 2).mean(axis=1) X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, stratify=y, random_state=42) cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) # every comparison happens inside the training half
| FEATURE | MEANING | WHY |
|---|---|---|
| late_recency | Months late each month, weighted 6…1 from latest to oldest | Recent lateness predicts; lateness five months ago mostly doesn't |
| late_now · max_late · months_late | How late right now; the worst month; how many late months | Level, severity and persistence — three different things |
| late_trend | Latest status minus the status two months earlier | Getting worse is not the same as being bad |
| util_now · util_mean | Bill as a share of the limit, now and on average | Headroom — the classic credit signal |
| pay_ratio_now · pay_ratio_mean | Share of the previous bill actually paid | Behaviour, not status: paying 5% every month is a pattern |
| zero_pay_months · paid_total_6m | Months with no payment at all; total paid in six months | Disengagement is the strongest tell before a miss |
| bill_now · bill_trend · limit | Exposure and its direction | What is at stake if the model is right |
| MODEL | CV AUC | TEST AUC | TEST PR-AUC | BRIER |
|---|---|---|---|---|
| Logistic regression (scaled, C = 0.5) | 0.769 | 0.755 | 0.523 | 0.140 |
| Gradient boosting (220 trees, depth 3, lr 0.05) | 0.785 | 0.781 | 0.564 | 0.134 |
Sex and age are not features. The model is still checked against them on the held-out set, because a feature like "share of bill paid" can carry demographic signal indirectly. It scores each group about as well as the whole, and its mean score tracks each group's actual miss rate:
| GROUP | N | AUC | ACTUAL MISS RATE | MEAN SCORE |
|---|
A probability is not a decision. Rank accounts by probability × exposure, contact the top k%, and every contact costs something while only some would-have-missed accounts pay because you called. Move the two levers below; the model's job is to make the best k visible.
| CARD | |
|---|---|
| Intended use | Prioritise which accounts in arrears a human contacts first. Not an automatic decision about any customer; exclusions (vulnerability, disputes, kept plans) run before any score, as in case 04. |
| Training data | UCI credit-card clients, Taiwan, April–September 2005, 30,000 accounts, 24,000 used for training. |
| Not valid for | Any ledger it was not fitted on. B2B invoices, utilities, 2026. The method transfers; the model does not. Re-fit on your own history first. |
| Known failure modes | New accounts with under three months of history (features degenerate to zero); accounts whose bills are zero (utilisation undefined, clipped); behaviour shifts after a policy change (a new reminder ladder changes the very patterns the model learned). |
| Monitoring | Monthly: calibration by decile against realised misses; population-stability index on late_recency and pay_ratio_mean; AUC by group. Re-fit when calibration drifts past ±5 points in any decile or PSI exceeds 0.2. |
| Explainability | Per-account path contributions (shown above), which sum exactly to the model's output; global importance by split gain. |
A good collections model is a ranked list with a cut-off, not a verdict. Six months of "how late, how much, how paid" carries most of what there is to know; a small, calibrated, explainable model turns it into the order to call people in, and the economics say where to stop. Everything above it — the exclusions, the ladder, the tie-out — stays human. The model just makes sure the humans start in the right place.
Have an arrears book and a team that can't call everyone? A ranked list on your own history is a Seal-sized piece of work: two to four weeks, fixed fee, model card included. It starts with a TRACE of the ledger it will rank.
Start with a TRACE → train.py README model.json