Credit scorecards with scorecraft::CHEAT SHEET

Points scorecards for binary targets (credit risk, fraud, propensity) on the optimal binning and WOE engine of OptimalBinningWoE, with explicit scale alignment, production SQL and IRB risk parameters.

The pipeline every stage is a function

Each stage returns an object that prints; every decision goes to a ledger. Two shortcuts chain the stages.

  1. 0scr_split()type columns, split the sample
  2. 1scr_triage()profile, sentinels, early failures
  3. 2scr_bin()optimal bins and three gates
  4. 3scr_model()model votes and consensus
  5. 4scr_scorecard()logistic fit on WOE, points
  6. 5scr_align()raw score to the declared scale
  7. 6scr_cutoff()cut-off, strategy, rejects
  8. 7scr_sql()scoring in R and in SQL
scr_select() = 0 to 3scr_scorecard() = 4 and 5

Configure

scr_config(preset = "moderate", ...) Every knob of every stage in one object. The preset sets how tight the funnel is; override any key by name.

cfg <- scr_config("moderate", nthread = 4)
presetvariablesmin votescorr cutIV floor
aggressive10 to 1530.60.03
moderate10 to 2520.70.02
lazy10 to 4010.80.02

scr_config_keys(stage = NULL) Every key with its stage (0 to 12), default and meaning.

scr_presets() This table. scr_verbose(on) Progress messages on or off.

Reading conventions

objective = "risk"
target 1 is bad; more points are safer (higher_is_safer, odds safe:event).
objective = "propensity"
target 1 is good; more points, more likely (higher_is_riskier, event:safe).

objective never changes the selection; event_level decides which target value is the event.

Points per bin: sc$points

vl_score_01WOEpoints
(-Inf; 33.36]-2.0661
(33.36; 38.15]-0.7322
(38.15; 44.24]-0.6620
(44.24; 48.06]-0.5216
(48.06; 63.94]0.04-1
(63.94; 72.61]0.70-21
(72.61; +Inf]1.00-30

On scr_demo: score = 538 + points of 12 variables.

Select variables stages 0 to 3

res <- scr_select(scr_demo, "default", cfg,
         drop = c("id", "churn"),
         date_col = "ref_date")

scr_select(data, target, config, drop, date_col, event_level, export) Split, triage, binning and consensus in one call. With date_col the hold-out is out of time; without it, a stratified random 30%.

inputtriagebinning screenhold-outcorr. approved

No candidate leaves the report: each one keeps the stage it failed at, and why.

Stage by stage

scr_split(data, target, date_col = NULL, ratio = 0.3) Type the columns and split train and hold-out.

scr_triage(split, config) Profile on train only. Fails CONSTANT, NEAR_CONSTANT, TOO_MANY_MISSING, HIGH_CARDINALITY, NO_SIGNAL, DUPLICATE_OF. A sentinel (-999) with mass and signal becomes a flag column x__sp.

scr_bin(triage, config) Optimal bins on train, in parallel by column, then three gates: the eight admission rules below; hold-out revalidation with frozen bins (IV ratio, PSI); redundancy pruning by rank correlation on the WOE space.

IV_BELOW_MINIV_SUSPICIOUSNOT_MONOTONICTOO_FEW_BINSTOO_MANY_BINSSMALL_BINDEGENERATE_BINBINNING_ERROR

scr_model(bins, config) glmnet, xgboost, lightgbm and ranger vote; the consensus is weighted by each model's hold-out Gini and the shortlist stays in [target_min, target_max].

Read the result

scr_selected(res, which = "final") The shortlist; also "consensus", "manual".

scr_funnel(res, only_selected = FALSE) Every input column, its IV, KS, PSI and the reason it stopped.

scr_gains(res) Bin-level gains of the approved variables.

scr_leakage(res, threshold = NULL) Suspicious IV and degenerate bins.

scr_score_metrics(sc) AUC, KS and Gini per sample, with bootstrap CI.

scr_score_gains(sc, sample = "holdout") Gains per score band frozen on train.

summary(res), plot(res), as.data.frame(res) Executive summary, funnel bar chart, funnel table.

Build the scorecard stages 4 and 5

sc <- scr_scorecard(res, base_score = 600,
        base_odds = 50, pdo = 20,
        challenger = "xgboost")
sc$alignment   # the fitted scale map

scr_scorecard(x, features, base_score, base_odds, pdo, direction, challenger, points_style) Logistic regression on the WOE columns with a sign check (a non-positive coefficient leaves, one at a time), points per bin, bootstrap CI, bands frozen on train, PSI and CSI.

600620 50:1100:1 odds (log scale) score The scale 600 points at odds 50:1; + 20 points (pdo) doubles the odds
ln(odds) = I + S·logit → score = a + b·logit
factor = pdo / ln 2
offset = base_score − factor·ln(base_odds)

scr_align(raw, y, base_score, base_odds, pdo, direction, method = "regression") Align the raw score of any engine: empirical log-odds regressed on score bands, composed with the PDO map. Two scorecards aligned this way compare point for point.

predict(align, raw, type = "score") Points, or type = "prob" for the implied probability.

Challenger ("xgboost", "lightgbm"): aligned to the same scale for comparison, with supports_scorecard = FALSE: no points, no reason codes. Points style: "base_plus_deviation" or "distributed".

Decide stage 6

scr_cutoff(sc, n_cuts = NULL, cuts = NULL) Approval, event rate on each side, events avoided and KS at each cut. Cuts are train quantiles applied frozen to the hold-out.

scr_strategy(sc, revenue_good = 1080, loss_bad = 4500) Bands with volume, event rate, decision and expected profit per account:

EP = (1 − p)·revenue_good − p·loss_bad
p* = revenue_good / (revenue_good + loss_bad)
- - break-even p* approve review, ≤ 1.25 p* decline

scr_reject(sc, population = NULL, accepted = NULL) Honest reject inference: population scope, outcome coverage per band and a sensitivity band (the rejects 2, 4 or 8 times worse), never a single invented multiplier.

Coarse classing lab manual bins

Manual bins and manual variable choice, each with a reason, benchmarked against the optimal bins on train and hold-out.

open lab propose verdict acceptor discard choose apply next variable

Verdict: ACCEPTABLE, REVIEW or BLOCKED. The reason is mandatory and the ledger is append-only.

lab <- scr_coarse_classing(res)
p <- scr_classing_propose(lab, "ds_region",
  groups = list(edge = c("NORTH", "SOUTH"),
    core = c("EAST", "WEST", "CENTRE")))
lab <- scr_classing_accept(lab, p,
  reason = "edge/core is what pricing uses")
lab <- scr_classing_choose(lab,
  drop = "vl_score_10",
  reason = "not available at decision time")
res2 <- scr_classing_apply(lab)  # scr_result
sc2 <- scr_scorecard(res2)

scr_coarse_classing(x, features = NULL, max_iv_loss = NULL) Open the lab on every variable that reached binning.

scr_classing_view(lab, variable) Current bins, train and hold-out IV.

scr_classing_propose(lab, variable, ...) One instruction per call:

breaks = c(20, 45)groups = list(...)merge = c(2, 3)split = c(id, at)missing_toother_toreset = TRUE

scr_classing_accept(lab, proposal, reason, override = FALSE) and scr_classing_discard(lab, proposal, reason) Record the decision; override accepts a BLOCKED one.

scr_classing_choose(lab, keep, drop, force, reason) The final variable list by hand.

scr_classing_apply(lab) Commit; the scorecard, the R scoring and the SQL follow unchanged.

scr_classing_spec(lab, file), scr_classing_read(file), scr_classing_import(lab, file) Specification round trip through CSV or xlsx, for a business reviewer.

scr_decisions(x) The decision ledger of a lab, result or scorecard.

Metrics on any vector

scr_metrics(score, y, higher_is_event = TRUE, n_boot = 200) AUC, KS and Gini with a bootstrap CI.

scr_psi(base, compare, n_groups = 10, alpha = 0.05) PSI with the fixed thresholds and the sample-size-adjusted critical value.

scr_iv(g, y, laplace = 0.5) Information value of any grouping.

Production, monitoring and IRB risk parameters

Every regime-specific number is a table selected by a preset: the functions read the tables, never the law.

Score in production stage 7

scr_apply(sc, newdata)     # score, points
scr_reasons(sc, newdata, k = 4)
scr_sql(sc, table = "prd.customers",
        dialect = "databricks",
        file = "score.sql")
scr_export(sc, "output")

scr_apply(sc, newdata, what = "score") The frozen pre-processing, bins and points; nothing is refitted. what: "score", "points", "woe", "all". On a selection: scr_apply(res, newdata, what = "both") gives WOE and bin labels.

scr_reasons(sc, newdata, k = 4, reference = "mean") Reason codes: the variables that took the most points from each row.

scr_sql(x, table, dialect, file) Production SQL in blocks: pre-processing CTE, WOE/BIN from the authoritative cut points, then the score (what = "score", "woe", "all"). R and SQL agree, verified by test.

ansidatabrickssparkhivemysqlmariadbsqlserverbigquerypostgresoraclesnowflakeredshiftduckdbsqlite

scr_export(x, dir, stamp = TRUE) Deliverables in a timestamped folder: for a scorecard, scorecard_, validation_ and strategy_ workbooks plus the SQL; for a selection, the selection workbook, the WOE SQL and a Markdown summary.

Monitor

scr_monitor(sc, newdata, date_col, target) Per period: score PSI with frozen bands, CSI of every variable with the signed points shift and, with a target, AUC/KS/Gini by vintage.

scr_monitoring_plan(sc) The thresholds contract; edit the Monitoring_Plan sheet and pass it back as plan.

stablemoderateshift 00.100.25

Next to it, the n-adjusted critical value: 0.034 at n = m = 1000 with 10 bins.

Database and many targets

con <- scr_connect(dsn = "DW")
rs <- scr_run(con, "dtm", config = cfg,
        targets = c("default", "churn"))
scr_compare(rs); scr_core(rs, min_targets = 2)

scr_connect(dsn, driver) ODBC with BIGINT read as numeric, or any DBI driver.

scr_fetch(con, table, sample_frac, seed) Reproducible server-side sampling.

scr_run(), scr_compare(), scr_core() One selection per target, a comparison table, the variables that cross targets.

Parameters and default

scr_irb_params(framework) Editable tables: PD and LGD floors, supervisory LGD, CCFs, correlations, maturity, output floor, standardised weights.

"bcb""basel3_final""crr3"

scr_default(data, id, date, dpd, arrears, exposure, utp, restructured, obligor) Default flag from a monthly panel: 90 days past due with material arrears, or unlikeliness to pay; probation 3 months (12 if restructured); obligor pulling effect.

scr_default_rate(x, horizon = 12, by = "quarter") One-year default rates by cohort and the long-run average; lra_adjusted is benchmarked, never applied.

PD: calibration and grades

params <- scr_irb_params("bcb")
d <- scr_default(scr_demo_panel, id = "id",
       date = "ref_date", dpd = "dpd")
dr <- scr_default_rate(d, by = "quarter")
cal <- scr_calibrate(sc, target = dr)
gr <- scr_grades(sc, calibration = cal,
        n_grades = 8)
gr <- scr_moc(gr, "C", method = "ci_binomial")
pd <- scr_pd(gr, params = params,
        asset_class = "retail_other")
pd_be+ MoC A+ MoC B+ MoC C pd_moc → floor pd_final pd_final =max(pd_moc,floor)

scr_calibrate(x, target, method) Re-anchor the PD to the central tendency; the points stay. Methods:

"intercept""logodds_ab""scaling""qmm"

scr_master_scale(pd_min, pd_max, n_grades) Geometric master scale.

scr_grades(x, calibration, n_grades, method) Score cut points with monotone grade PDs ("geometric", "quantile", "supplied"); small grades merged and logged.

scr_moc(x, category, method, value, reason) Margin of conservatism: C estimation error is computed ("ci_timeseries", "ci_binomial", "bootstrap"); A and B need a value and a reason.

scr_pd(grades, params, asset_class, philosophy = "ttc") Final grade table with floor.

predict(pd, score = s, type = "pd_final") Grade, PD or final PD of new scores.

scr_pd_validate(x, newdata, id, date, default, score) Jeffreys, binomial, normal, Hosmer-Lemeshow, multi-period, AUC, concentration, PSI and migration, with traffic lights.

scr_migration(grade_t0, grade_t1) Migration matrix and bandwidths.

scr_pd_pit_ttc(pd, z, rho, to = "pit") One-factor PIT/TTC bridge.

LGD: workout and two stages

default P(cure) 1 − P(cure) cure: mean LGD severity model logistic on WOE,sign check fractional logitor beta regression
wo <- scr_workout(scr_demo_lgd,
        scr_demo_lgd_cashflows,
        rates = scr_demo_rates)
lgd <- scr_lgd(wo, drivers = c("product",
         "ltv", "months_on_book"))
lgd <- scr_lgd_downturn(lgd, periods = dt,
         reason = "rates above 13% in 2022-23")
lgd <- scr_lgd_floor(lgd, params = params)
# dt: data.frame(start, end) of downturn dates

scr_workout(defaults, cashflows, rates) Realised LGD per default: recoveries, direct costs and drawings discounted to the default date.

scr_lgd(x, drivers, holdout = 0.3) Cure × severity on cohort split, then pools.

scr_lgd_pools(x, n_pools) Re-pool the predicted LGD.

scr_lgd_downturn(x, periods, method, reason) Downturn per pool: "type1" observed impact, "type3" add-on, "none".

scr_lgd_floor(x, params, asset_class, secured_share) Input floors by collateral.

scr_lgd_validate(x) Calibration, discrimination (generalised AUC) and stability battery.

scr_elbe(x, grid = c(0, 6, 12, 24, 36)) ELBE and in-default LGD by months since default.

scr_bin_continuous(data, target, features) Monotone bins against a continuous target.

EAD: conversion factors

drawn CCF × undrawn rest of the limit EAD = drawn + CCF · (limit − drawn)
rds <- scr_ead_data(scr_demo_ead,
         facility_id = "facility_id",
         date_col = "ref_date", limit = "limit",
         drawn = "drawn", defaulted = "defaulted",
         drivers = c("product", "months_on_book"))
ead <- scr_ead(rds, drivers = c("product",
         "utilisation_ref", "months_on_book"))

scr_ead_data(snapshots, facility_id, date_col, limit, drawn, defaulted, drivers) Realised CCF from monthly facility snapshots.

scr_ead(x, drivers, holdout = 0.3) Driver admission (TOO_FEW_DEFAULTS, NO_SEPARATION, NOT_MONOTONIC, UNSTABLE_HOLDOUT) and CCF pools with MoC and the standardised floor.

scr_ead_downturn(x, periods, method, reason) Downturn CCF per pool.

scr_ead_validate(x, newdata) Calibration, discrimination, back-testing, stability.

EL, capital and ECL

scr_el(pd, lgd, ead, defaulted, elbe) Expected loss per exposure, PD × LGD × EAD (ELBE when defaulted).

scr_irb_rw(pd, lgd, ead, m, asset_class, approach = "airb") IRB risk weight of the one-factor model, with floors, correlation and maturity adjustment.

K = LGD·(scr_pd_stress(PD, R, 0.999) − PD)·MA
RWA = 12.5·K·EAD
corporatecorporate_smebanksovereignhvcreretail_mortgageqrre_revolverqrre_transactorretail_other

scr_sa_rw(asset_class, ltv, rating, ...) Standardised risk weight.

scr_pd_stress(pd, rho, q = 0.999) Conditional PD of the one-factor model.

cap <- scr_capital(scr_demo_portfolio,
         segment = "segment",
         asset_class = "asset_class",
         provisions = "provision",
         params = params)
cap$totals; cap$segments

scr_capital(x, pd, lgd, ead, segment, asset_class, provisions, params) RWA under IRB and the standardised approach, output floor, EL against provisions, floor impact, sensitivity and concentration.

scr_ecl(pd_term, lgd, ead, eir, stage, dpd, pd_orig, scenarios, weights) Expected credit loss from monthly hazards, discounted at the EIR, with weighted scenarios:

ECL = Σt S(t−1)·ht·LGDt·EADt·(1 + r)−t/12
Stage 1Stage 2Stage 3 12-month ECLperforming lifetime ECL≥ 30 dpd or 2 × PD lifetime ECL≥ 90 dpd, impaired

One contract for every model

object fromscr_applyscr_sqlscr_export
scr_select()
scr_scorecard()
scr_coarse_classing()
scr_pd()
scr_lgd()
scr_ead()
scr_capital()

Demo data synthetic

scr_demo4,200 applications, two targets
scr_demo_panelmonthly panel for the default flag
scr_demo_lgddefaults, _cashflows, scr_demo_rates
scr_demo_eadmonthly facility snapshots
scr_demo_portfolioexposures for EL, capital and ECL