Skip to contents

This is the first of three articles on the IRB risk parameters. It starts where Get started ends and builds the probability of default; LGD and EAD under IRB builds the other two parameters, and Expected loss and regulatory capital turns the three into expected loss, capital and accounting ECL.

The regulatory texts behind the parameter tables are listed in scr_irb_params(). Regulation changes and each supervisor interprets it: check the tables against the texts in force before any regulatory use.

The introductory guide ends with a scorecard whose alignment turns a score into a probability. That probability is a good ranking device and a fair estimate of the event rate of the development sample; it is not yet a probability of default (PD) in the sense a capital or provisioning model needs. Four things separate the two:

  • the default definition: the target must be a default event built by a stated rule (days past due with material arrears, unlikeliness to pay, a probation before cure), not whatever flag happened to be in the table;
  • the long-run average: the level of the PD must be anchored to the average one-year default rate observed over many cohorts, not to the event rate of one sample, which is a single draw of the cycle;
  • grades: obligors are pooled into rating grades with a monotone PD, enough obligors and defaults to estimate it, and a stable distribution;
  • the margin of conservatism and the floor: the grade PD carries an explicit add-on for estimation error and for known deficiencies, and it is never reported below the floor of its asset class.

Every step below leaves a record: the merges and repairs of the grades, the entries of the margin ledger, the parameter table used for the floor. The names follow the public frameworks (the Basel Framework of the BCBS, the EBA guidelines on PD and LGD estimation, BCB Resolution 303/2023) only where they explain a name; the package is a technical tool and the frameworks are tables of numbers it reads, not prose it interprets.

library(scorecraft)
library(data.table)
cfg <- scr_config(verbose = FALSE, nthread = 2, use_ranger = FALSE,
                  use_lightgbm = FALSE, n_boot = 200)

1. The default flag and the default-rate series

scr_demo_panel is a monthly panel: 600 obligors over 36 months with days past due, arrears, exposure, a restructuring flag and a behavioral score. scr_default() runs the definition of default over it as a small state machine per obligor: a trigger (dpd >= 90 with arrears above both the absolute and the relative materiality thresholds) opens an event, the event closes after three consecutive months without a trigger (twelve when the obligor was restructured during the event). The row-level flags are the product; the events table and the ledger document what the rule did.

d <- scr_default(scr_demo_panel, id = "id", date = "ref_date", dpd = "dpd",
                 arrears = "arrears", exposure = "exposure",
                 restructured = "restructured", config = cfg)
d
#> <scr_default> 600 units x 21,600 rows | 176 events | level obligor
#>   rule: dpd >= 90 with material arrears | probation 3 months (12 after restructuring)
#>   triggers: dpd 100.0% | median 5 months in default | cured 87.5%
head(d$events, 4)
#>    event_id     id      start        end trigger months cured
#>      <char> <char>     <Date>     <Date>  <char>  <int> <int>
#> 1:  O0003#1  O0003 2024-10-01 2025-05-01     dpd      8     1
#> 2:  O0011#1  O0011 2025-08-01 2025-11-01     dpd      4     1
#> 3:  O0013#1  O0013 2023-05-01 2023-08-01     dpd      4     1
#> 4:  O0016#1  O0016 2023-05-01 2023-08-01     dpd      4     1
d$ledger$detail
#> [1] "dpd >= 90 and arrears >= 100 and arrears/exposure > 0.01; probation 3 (12 after restructuring); level obligor"

scr_default_rate() turns the flagged panel into one-year default rates by cohort: at every cohort start the non-defaulted obligors form the population and the outcome is a default within the next twelve months. The long-run average is the arithmetic mean of the cohort rates. When the analyst proposes an adjusted average (lra_adjusted, for instance because the period lacks bad years), it is compared with a benchmark, the larger of the last five years’ mean and the whole period’s mean, and flagged when it sits below; the adjusted value is recorded, never applied. The panel covers less than two years of complete windows, so the print says plainly that the average is not a long-run one yet.

dr <- scr_default_rate(d, by = "quarter", config = cfg)
dr
#> <scr_dr> 8 quarterly cohorts over 1.7 years | horizon 12 months
#>   default rate: mean 9.63% | weighted 9.64% | min 9.03% | max 10.28% | sd 0.42%
#>   long-run average 9.63% | benchmark 9.63% (max of last-5-years 9.63% and all-years 9.63%)
#>   note: fewer than five years of cohorts; the average is not a long-run one yet
dr$table
#>        cohort     n defaults         dr
#>        <Date> <int>    <int>      <num>
#> 1: 2023-01-01   600       59 0.09833333
#> 2: 2023-04-01   590       59 0.10000000
#> 3: 2023-07-01   572       55 0.09615385
#> 4: 2023-10-01   570       53 0.09298246
#> 5: 2024-01-01   572       53 0.09265734
#> 6: 2024-04-01   574       59 0.10278746
#> 7: 2024-07-01   565       51 0.09026549
#> 8: 2024-10-01   565       55 0.09734513

2. Calibrating the scorecard to the central tendency

The scorecard comes from scr_demo with the fast configuration. Its hold-out event rate is 14.5%, well above the 9.6% long-run average of the panel: a sample of the development window, not the cycle.

res <- scr_select(scr_demo, "default", config = cfg, drop = c("id", "churn"),
                  date_col = "ref_date")
sc <- scr_scorecard(res)
sc
#> <scr_scorecard> target "default" | 12 variables | higher_is_safer
#>   scale: 600 points at odds 50:1 (safe:event), PDO 20 | alignment regression
#>   score = 491.1967 + -26.3189 * logit | base_points = 538
#>   train    n 2,800   AUC 0.7856 [0.7643, 0.8087]  KS 0.4411  Gini 0.5713
#>   holdout  n 1,400   AUC 0.7394 [0.7070, 0.7750]  KS 0.3889  Gini 0.4788
#>   score PSI (hold-out): 0.0069 - fixed: stable | adjusted (0.0181): stable
#> 
#> Points (first rows)
#>   vl_score_01                  (-Inf;33.360000]             -2.063      61
#>   vl_score_01                  (33.360000;38.150000]        -0.731      22
#>   vl_score_01                  (38.150000;44.240000]        -0.658      20
#>   vl_score_01                  (44.240000;48.060000]        -0.523      16
#>   vl_score_01                  (48.060000;63.940000]         0.040      -1
#>   vl_score_01                  (63.940000;72.610000]         0.704     -21
#>   vl_score_01                  (72.610000;+Inf]              0.996     -30
#>   vl_score_02                  (-Inf;40.880000]             -0.824      22
#>   ... (+56 rows)

scr_calibrate() re-anchors the probability to the central tendency (CT) without touching the points: it returns a new alignment (I*, S*) whose predict(..., type = "prob") is the calibrated PD, while the scorecard keeps its own alignment for the score. Passing an scr_dr object as target uses its long-run average and records where the CT came from.

cal <- scr_calibrate(sc, target = dr)
cal
#> <scr_pd_calibration> method intercept | CT 9.632% (scr_dr long-run average (8 quarterly cohorts)) | sample rate 14.500% | n 1,400
#>   event ln(odds)* = -0.448504 +1.000000 * ln(odds)   [prior shift -0.464482]
#>   alignment: I 0.141187 -> 0.589690 | S -0.912143 -> -0.912143
#>   mean PD 13.699% -> 9.632% | AR observed 0.4788 -> 0.4788 | AR implied 0.5207 -> 0.5259

Four methods are available. "intercept" is the prior-correction shift of King and Zeng, refined by a one-dimensional root so that the mean PD equals the CT exactly; the slope is untouched, so every rank statistic is untouched. "logodds_ab" and "qmm" fit both intercept and slope of ln(odds*) = a + b ln(odds) so that the mean PD equals the CT and the accuracy ratio implied by the calibrated PDs equals a target. "logodds_ab" takes the target from ar_target or, by default, from the accuracy ratio observed on the sample, which is Tasche’s quasi-moment matching proper; the package’s "qmm" is the outcome-free variant, whose target is the implied accuracy ratio of the uncalibrated PDs. "scaling" is the proportional rescaling PD * CT / ybar, projected back onto a logit map. All four hit the CT; only the slope differs, and a monotone map of the ln(odds) never changes the AUC of the hold-out.

ho <- sc$samples$holdout
auc_score <- sc$metrics[sample == "holdout", auc]
compare <- rbindlist(lapply(c("intercept", "logodds_ab", "qmm", "scaling"), function(m) {
  cm <- scr_calibrate(sc, target = dr, method = m)
  p  <- predict(cm$alignment, ho$link, type = "prob")
  data.table(method = m, mean_pd_before = cm$mean_pd_before, mean_pd_after = cm$mean_pd_after,
             slope_ratio = cm$slope_ratio, auc_before = auc_score,
             auc_after = scr_metrics(p, ho$y, ci = FALSE)$auc)
}))
compare
#>        method mean_pd_before mean_pd_after slope_ratio auc_before auc_after
#>        <char>          <num>         <num>       <num>      <num>     <num>
#> 1:  intercept      0.1369896    0.09631563   1.0000000   0.739406  0.739406
#> 2: logodds_ab      0.1369896    0.09631563   0.8847832   0.739406  0.739406
#> 3:        qmm      0.1369896    0.09631563   0.9869091   0.739406  0.739406
#> 4:    scaling      0.1369896    0.09631563   0.9475886   0.739406  0.739406

3. Rating grades

scr_grades() cuts the production score into grades whose PD is monotone. The default construction is "geometric": a master scale whose midpoints form a geometric sequence from percentile 1 to percentile 99 of the calibrated PD, with ratio (p99 / p1)^(1 / (K - 1)) for K grades, and whose PD boundaries are converted into score cut points through the calibrated alignment. Grade 1 is the safest, the highest scores under higher_is_safer. Grades with fewer than min_obligors obligors or min_defaults defaults are merged with the neighbor of closer default rate; the sequence of grade PDs is then repaired by pool-adjacent-violators when it is not monotone. Every merge is recorded in repairs.

gr <- scr_grades(sc, cal, n_grades = 7, min_obligors = 30, min_defaults = 10)
gr
#> <scr_grades> target "default" | 5 grades (geometric) on holdout | PD source: lra (sample default rate; pass `dr` for the series) | higher_is_safer
#>   concentration: HHI 0.241 | CV 0.452 | HI 0.116 | repairs 2 | calibrated to CT 9.632%
#>   grade label    score_lo  score_hi      n  share   def       dr  pd_mean    pd_be
#>   1     1+2+3      571.27       Inf    341  24.4%    13    3.81%    1.85%    3.81%
#>   2     4          548.82    571.27    382  27.3%    32    8.38%    5.01%    8.38%
#>   3     5          525.01    548.82    403  28.8%    72   17.87%   10.13%   17.87%
#>   4     6          497.66    525.01    208  14.9%    63   30.29%   20.46%   30.29%
#>   5     7            -Inf    497.66     66   4.7%    23   34.85%   39.43%   34.85%
#>   repair (min_counts): 1 -> 2 | n 42, defaults 1 (minimum 30 / 10)
#>   repair (min_counts): 1+2 -> 3 | n 139, defaults 4 (minimum 30 / 10)
gr$master_scale
#> <scr_master_scale> 7 grades (geometric) | ratio between midpoints 2.095
#>   grade  label         pd_lo     pd_mid      pd_hi
#>   1      1            0.000%     0.526%     0.761%
#>   2      2            0.761%     1.102%     1.595%
#>   3      3            1.595%     2.308%     3.341%
#>   4      4            3.341%     4.835%     6.999%
#>   5      5            6.999%    10.129%    14.661%
#>   6      6           14.661%    21.219%    30.712%
#>   7      7           30.712%    44.451%   100.000%
gr$repairs
#>          step merged   into                              reason
#>        <char> <char> <char>                              <char>
#> 1: min_counts      1      2  n 42, defaults 1 (minimum 30 / 10)
#> 2: min_counts    1+2      3 n 139, defaults 4 (minimum 30 / 10)

Seven grades were asked for and five survive: the two safest grades of the scale hold too few defaults on the hold-out and are folded into the third. Concentration is reported as the Herfindahl index of the grade shares, the coefficient of variation of the shares and the Herfindahl-based hi index, the three numbers the stability test of section 6 compares against.

unlist(gr$concentration)
#>       hhi        cv        hi         k 
#> 0.2409357 0.4524142 0.1157005 5.0000000

A master scale can also be supplied, when the institution already has one. scr_master_scale() builds it from the first and last midpoints (or from supplied midpoints or bands); method = "supplied" grades by its PD bands and keeps its labels through the merges.

ms <- scr_master_scale(0.005, 0.40, n_grades = 8)
ms
#> <scr_master_scale> 8 grades (geometric) | ratio between midpoints 1.870
#>   grade  label         pd_lo     pd_mid      pd_hi
#>   1      1            0.000%     0.500%     0.684%
#>   2      2            0.684%     0.935%     1.279%
#>   3      3            1.279%     1.749%     2.391%
#>   4      4            2.391%     3.270%     4.472%
#>   5      5            4.472%     6.116%     8.363%
#>   6      6            8.363%    11.437%    15.641%
#>   7      7           15.641%    21.389%    29.250%
#>   8      8           29.250%    40.000%   100.000%
gs <- scr_grades(sc, cal, method = "supplied", master_scale = ms, min_defaults = 10)
gs$table[, .(grade, label, score_lo, score_hi, n, defaults, dr, pd_be)]
#>    grade   label score_lo score_hi     n defaults         dr      pd_be
#>    <int>  <char>    <num>    <num> <int>    <int>      <num>      <num>
#> 1:     1 1+2+3+4 562.5195      Inf   480       16 0.03333333 0.03333333
#> 2:     2       5 543.2569 562.5195   355       41 0.11549296 0.11549296
#> 3:     3       6 522.8067 543.2569   318       66 0.20754717 0.20754717
#> 4:     4       7 499.6677 522.8067   174       55 0.31609195 0.31609195
#> 5:     5       8     -Inf 499.6677    73       25 0.34246575 0.34246575
gs$repairs
#>          step merged   into                              reason
#>        <char> <char> <char>                              <char>
#> 1: min_counts      1      2  n 34, defaults 1 (minimum 30 / 10)
#> 2: min_counts    1+2      3 n 106, defaults 4 (minimum 30 / 10)
#> 3: min_counts  1+2+3      4 n 239, defaults 7 (minimum 30 / 10)

The two-pass path: grade PDs from the cohort series

So far pd_be is the default rate of the grade on the hold-out, one sample again. The grade PD should be the long-run average of the grade’s own cohort default rates, and that series can only be built once the grades exist. Hence two passes: grade the cohort panel with the cut points of the first call, build the series by grade with scr_default_rate(), and pass it as dr to a second call with identical arguments. The cut points are the same; only the source of pd_be changes, and the ledger says so.

pnl <- merge(d$flags, as.data.table(scr_demo_panel)[, .(id, date = ref_date, score)],
             by = c("id", "date"))
pnl[, grade := predict(gr, score = score)]
drg <- scr_default_rate(pnl, id = "id", date = "date", default = "default",
                        grade = "grade", by = "quarter", config = cfg)
head(drg$table, 5)
#>        cohort  grade     n defaults         dr
#>        <Date> <char> <int>    <int>      <num>
#> 1: 2023-01-01      1   401       20 0.04987531
#> 2: 2023-01-01      2    73        6 0.08219178
#> 3: 2023-01-01      3    44        8 0.18181818
#> 4: 2023-01-01      4    54       12 0.22222222
#> 5: 2023-01-01      5    28       13 0.46428571
gr2 <- scr_grades(sc, cal, n_grades = 7, min_obligors = 30, min_defaults = 10, dr = drg)
identical(gr2$breaks, gr$breaks)
#> [1] TRUE
gr2$table[, .(grade, label, n, dr, pd_mean, pd_be, n_series, t_series)]
#>    grade  label     n         dr    pd_mean      pd_be n_series t_series
#>    <int> <char> <int>      <num>      <num>      <num>    <int>    <int>
#> 1:     1  1+2+3   341 0.03812317 0.01850543 0.04661908     3151        8
#> 2:     2      4   382 0.08376963 0.05006927 0.14443910      557        8
#> 3:     3      5   403 0.17866005 0.10129601 0.16957030      324        8
#> 4:     4      6   208 0.30288462 0.20460388 0.21934765      391        8
#> 5:     5      7    66 0.34848485 0.39432077 0.40823352      185        8
gr2$ledger[action == "grade_pd_source", detail]
#> [1] "grade PD from the long-run average of 8 cohorts (quarter)"

4. Margin of conservatism, floor and the PD model

scr_moc() appends entries to the margin-of-conservatism ledger of the grades. Category C, the general estimation error, is quantified from the data: "ci_timeseries" takes the one-sided t interval of the long-run average over the cohort series, per grade; "ci_binomial" and "bootstrap" work on the obligors when no series exists. Categories A (data and methodological deficiencies) and B (changes in standards or environment) are expert quantities: a value in PD units and a non-empty reason are both mandatory. The ledger is append-only: A and B entries accumulate, a new C supersedes the previous one, which stays in the ledger with active = FALSE.

One caveat applies to the series here. Quarterly cohorts with a twelve-month window overlap: consecutive cohorts share nine of their twelve months and most of their obligors, so the eight cohort rates are far from eight independent observations and the t interval understates the estimation error. On a series of that kind use annual cohorts (by = "year") for the margin, or widen it and say so in the reason; this panel is too short for annual cohorts, which is why the quarterly series is kept.

gm <- scr_moc(gr2, "C", method = "ci_timeseries", dr = drg)
gm <- scr_moc(gm, "A", value = 0.001,
              reason = "unlikeliness-to-pay trigger not available before 2024")
gm
#> <scr_grades> target "default" | 5 grades (geometric) on holdout | PD source: lra | higher_is_safer
#>   concentration: HHI 0.241 | CV 0.452 | HI 0.116 | repairs 2 | calibrated to CT 9.632%
#>   grade label    score_lo  score_hi      n  share   def       dr  pd_mean    pd_be
#>   1     1+2+3      571.27       Inf    341  24.4%    13    3.81%    1.85%    4.66%
#>   2     4          548.82    571.27    382  27.3%    32    8.38%    5.01%   14.44%
#>   3     5          525.01    548.82    403  28.8%    72   17.87%   10.13%   16.96%
#>   4     6          497.66    525.01    208  14.9%    63   30.29%   20.46%   21.93%
#>   5     7            -Inf    497.66     66   4.7%    23   34.85%   39.43%   40.82%
#>   repair (min_counts): 1 -> 2 | n 42, defaults 1 (minimum 30 / 10)
#>   repair (min_counts): 1+2 -> 3 | n 139, defaults 4 (minimum 30 / 10)
#>   margin of conservatism (active, mean over grades): C 248.2 bp | A 10.0 bp

scr_pd() assembles the PD model: pd_moc = pd_be + A + B + C, then the floor of the asset class under the framework of params, and pd_final = max(pd_moc, floor). scr_irb_params() is a table of numbers selected by a preset; the floors are one of its tables, editable, and an edited table is flagged in the ledger.

prm <- scr_irb_params("bcb")
prm$pd_floor
#>        asset_class floor
#>             <char> <num>
#> 1:       corporate 5e-04
#> 2:            bank 5e-04
#> 3:       sovereign    NA
#> 4: retail_mortgage 5e-04
#> 5: qrre_transactor 5e-04
#> 6:   qrre_revolver 1e-03
#> 7:    retail_other 5e-04
pd <- scr_pd(gm, params = prm, asset_class = "retail_other")
pd
#> <scr_pd> target "default" | bcb | retail_other | floor 0.05% | 5 grades | ttc
#>   calibration: intercept to CT 9.632% | grades: geometric, PD source lra | HHI 0.241
#>   grade  score_lo  score_hi      n    pd_be   moc_a   moc_b   moc_c   pd_moc pd_final floor
#>   1        571.27       Inf    341    4.66%    10.0     0.0    65.5    5.42%    5.42%      
#>   2        548.82    571.27    382   14.44%    10.0     0.0   383.2   18.38%   18.38%      
#>   3        525.01    548.82    403   16.96%    10.0     0.0   168.2   18.74%   18.74%      
#>   4        497.66    525.01    208   21.93%    10.0     0.0   262.8   24.66%   24.66%      
#>   5          -Inf    497.66     66   40.82%    10.0     0.0   361.1   44.53%   44.53%      
#>   portfolio: PD_BE 15.141% | MoC 235.0 bp (A/B/C in bp) | PD_final 17.491% | 0.0% of obligors at the floor

The floor does not bind on this portfolio, whose safest grade sits above 5%. The table carries every intermediate column, so a validator can follow the number from the sample default rate to the final PD:

pd$table[, .(grade, label, n, dr, pd_be, moc_a, moc_c, pd_moc, floor, pd_final, floor_applied)]
#>    grade  label     n         dr      pd_be moc_a       moc_c     pd_moc floor
#>    <int> <char> <int>      <num>      <num> <num>       <num>      <num> <num>
#> 1:     1  1+2+3   341 0.03812317 0.04661908 0.001 0.006554451 0.05417353 5e-04
#> 2:     2      4   382 0.08376963 0.14443910 0.001 0.038315668 0.18375476 5e-04
#> 3:     3      5   403 0.17866005 0.16957030 0.001 0.016817727 0.18738803 5e-04
#> 4:     4      6   208 0.30288462 0.21934765 0.001 0.026282240 0.24662989 5e-04
#> 5:     5      7    66 0.34848485 0.40823352 0.001 0.036112137 0.44534566 5e-04
#>      pd_final floor_applied
#>         <num>        <lgcl>
#> 1: 0.05417353         FALSE
#> 2: 0.18375476         FALSE
#> 3: 0.18738803         FALSE
#> 4: 0.24662989         FALSE
#> 5: 0.44534566         FALSE

Two readings of this table would stop a validator. Grades 2 and 3 end within half a point of each other (18.4% and 18.7%), which is not a meaningful differentiation of risk: the long-run series of grade 2 is much worse than its hold-out default rate (14.4% against 8.4%) and its margin is the widest, so the two grades collapse after the margin. On a real portfolio the answer is to merge them, or to revisit the cut points once more cohorts exist.

5. Production: R and SQL agree

scr_apply() scores new rows with the scorecard and adds the grade, the calibrated individual PD and the grade PD; predict() does the same from a vector of scores. The production SQL of the PD model is the SQL of the scorecard plus one block: a CASE over the score cut points for the grade and another for the final PD.

new <- head(scr_demo, 5)
scr_apply(pd, new)
#>       score score_points grade         pd     pd_be  pd_final
#>       <num>        <num> <int>      <num>     <num>     <num>
#> 1: 546.5330          546     3 0.07533388 0.1695703 0.1873880
#> 2: 562.3290          562     2 0.04500420 0.1444391 0.1837548
#> 3: 560.5217          559     2 0.04777441 0.1444391 0.1837548
#> 4: 507.0636          507     4 0.24239419 0.2193476 0.2466299
#> 5: 536.7619          536     3 0.10258165 0.1695703 0.1873880
predict(pd, score = c(480, 560, 640), type = "pd_final")
#> [1] 0.44534566 0.18375476 0.05417353
sql <- scr_sql(pd, table = "customers", dialect = "duckdb")
cat(tail(sql, 6), sep = "\n")
#> -- Block 4: rating grade and final PD from the score cut points (5 grades, higher_is_safer)
#> SELECT
#>     s.*,
#>     CASE WHEN score <= 497.65810792199238 THEN 5 WHEN score <= 525.006786354523 THEN 4 WHEN score <= 548.82434894947983 THEN 3 WHEN score <= 571.2740854703145 THEN 2 ELSE 1 END AS grade,
#>     CASE WHEN score <= 497.65810792199238 THEN 0.44534566116911861 WHEN score <= 525.006786354523 THEN 0.24662988720215417 WHEN score <= 548.82434894947983 THEN 0.1873880268001506 WHEN score <= 571.2740854703145 THEN 0.18375476313787917 ELSE 0.054173526552942053 END AS pd_final
#> FROM score_scr s;

The SQL is run on a few hundred rows in DuckDB and compared with scr_apply(): the score, the grade and the final PD are the same numbers.

con <- DBI::dbConnect(duckdb::duckdb())
DBI::dbWriteTable(con, "customers", head(scr_demo, 300))
got <- DBI::dbGetQuery(con, paste(sql, collapse = "\n"))
DBI::dbDisconnect(con, shutdown = TRUE)
ref <- scr_apply(pd, head(scr_demo, 300))
head(got[, c("score", "grade", "pd_final")], 3)
#>      score grade  pd_final
#> 1 546.5330     3 0.1873880
#> 2 562.3290     2 0.1837548
#> 3 560.5217     2 0.1837548
all.equal(got$score, ref$score)
#> [1] TRUE
identical(as.integer(got$grade), ref$grade)
#> [1] TRUE
all.equal(got$pd_final, ref$pd_final)
#> [1] TRUE

6. Validation on the cohort panel

scr_pd_validate() runs the standard battery on a monthly panel with the default flag and the grade at every month, building the cohorts exactly as scr_default_rate() does. The panel is graded with the cut points of the PD model through predict(), the same path the production SQL follows.

  • Calibration, per grade pooled over cohorts and per cohort: the Jeffreys test, the binomial test with its critical count, the normal z, the traffic light on the Jeffreys p-value; for the portfolio, the same tests on the totals, Hosmer-Lemeshow over the grades, the multi-period test over the cohort default rates and the Brier score.
  • Discrimination: AUC, Gini and KS with a bootstrap interval on the score, and the S statistic against the development AUC, with the DeLong standard error of the current AUC.
  • Stability: the PSI of the grade distribution per cohort against the development sample, the migration matrix pooled over the cohorts, and the concentration test on the coefficient of variation.

The overlap noted in section 4 matters here as well: the tests pooled over quarterly cohorts count the same obligor once per cohort, over windows that overlap, and the multi-period test treats overlapping cohort rates as independent, so their p-values are more confident than the data allow. Read them as indicators, and confirm on annual cohorts when the series is long enough.

pnl[, grade := predict(pd, score = score)]
v <- scr_pd_validate(pd, pnl, id = "id", date = "date", default = "default",
                     grade = "grade", score = "score", by = "quarter")
v
#> <scr_pd_validation> target "default" | 8 quarterly cohorts, 12-month window | overall light: RED
#>   portfolio: N 4,608 | D 444 | DR 9.64% vs pd_final 11.12% | Jeffreys p 0.9995 | binomial p 0.9995 (critical 549) | HL chi2 12.51 (p 0.0284) | multi-period z -11.19
#>   grade       n     d       dr       pd    p_jeff   p_binom light 
#>   1        3151   147    4.67%    5.42%    0.9711    0.9737 green 
#>   2         557    80   14.36%   18.38%    0.9940    0.9949 green 
#>   3         324    55   16.98%   18.74%    0.7906    0.8110 green 
#>   4         391    86   21.99%   24.66%    0.8905    0.9014 green 
#>   5         185    76   41.08%   44.53%    0.8276    0.8459 green 
#>   discrimination (score): AUC 0.7564 [0.7344, 0.7781] vs initial 0.7394 | S -1.44, p 0.9250 | KS 0.4027
#>   stability: grade PSI 0.9634 (shift, adjusted shift) at cohort 2024-10-01 | MWB up - / down - | CV 1.245 vs 0.452 (p 0.1869)

The summary is one row per test with its statistic, p-value and light; the overall light is the worst of them. A row without a testable result is grey, and grey never counts as green.

v$summary
#>                    test     level    statistic    p_value  light
#>                  <char>    <char>        <num>      <num> <char>
#>  1:            jeffreys portfolio   0.09635417 0.99946944  green
#>  2: jeffreys_grades_red     grade   0.00000000 0.79058505  green
#>  3:            binomial portfolio 549.00000000 0.99951352  green
#>  4:              normal portfolio  -3.21341063 0.99934416  green
#>  5:     hosmer_lemeshow portfolio  12.51211309 0.02840601  green
#>  6:        multi_period portfolio -11.19460843 1.00000000  green
#>  7:      auc_vs_initial portfolio  -1.43959713 0.92500929  green
#>  8:          psi_grades portfolio   0.96337125         NA    red
#>  9: migration_mwb_upper portfolio           NA         NA   grey
#> 10:    concentration_cv portfolio   1.24524312 0.18694109  green

One red, and it is readable. The one-sided tests are green on every grade because the tested PD is pd_final, which carries the margin of conservatism: the observed default rates sit below it, as they should. Hosmer-Lemeshow is two-sided and its p-value would charge that same conservatism as miscalibration; its light is green because every grade sits on the conservative side, and the statistic stays in the table so that a validator sees both readings. The grade PSI is large because the panel is a different population from the development sample: its behavioral score puts two thirds of the obligors in grade 1 where the hold-out had a quarter. That is exactly what the PSI is there to flag; a validator would now ask whether the cut points belong on that population at all.

v$portfolio[, .(cohort, n, d, pd, dr, p_jeffreys, light)]
#>        cohort     n     d        pd         dr p_jeffreys  light
#>        <Date> <int> <int>     <num>      <num>      <num> <char>
#> 1: 2023-01-01   600    59 0.1152841 0.09833333  0.9053676  green
#> 2: 2023-04-01   590    59 0.1136764 0.10000000  0.8528388  green
#> 3: 2023-07-01   572    55 0.1101949 0.09615385  0.8589652  green
#> 4: 2023-10-01   570    53 0.1107401 0.09298246  0.9141923  green
#> 5: 2024-01-01   572    53 0.1105423 0.09265734  0.9163369  green
#> 6: 2024-04-01   574    59 0.1109179 0.10278746  0.7289322  green
#> 7: 2024-07-01   565    51 0.1093999 0.09026549  0.9304551  green
#> 8: 2024-10-01   565    55 0.1088252 0.09734513  0.8084906  green
head(v$stability$psi, 3)
#>        cohort     n       psi flag_fixed   critical flag_adjusted
#>        <Date> <int>     <num>     <char>      <num>        <char>
#> 1: 2023-01-01   600 0.8735703      shift 0.02258983         shift
#> 2: 2023-04-01   590 0.8909743      shift 0.02285785         shift
#> 3: 2023-07-01   572 0.9232355      shift 0.02336389         shift

Migration

scr_migration() counts the obligors in grade i at the cohort start and grade j twelve months later, with defaults and closed accounts in their own columns, and reports the upper and lower matrix-weighted bandwidths, the z of every off-diagonal cell against its neighbor closer to the diagonal, and the mobility summary. The validation pools it over the cohorts whose end date is observed. On this panel the matrix is diagonal: the behavioral score of scr_demo_panel is constant per obligor, so an obligor either stays in its grade or defaults, and the bandwidths are undefined for want of a single move.

v$stability$migration
#> <scr_migration> 5 grades | 4,608 obligors | stable 100.0% | up 0.0% | down 0.0% | default 9.6% | closed 0.0%
#>   MWB upper - | MWB lower - | mean distance 0.000 | 0 cell(s) not decaying from the diagonal (z > 1.645)
#>   from        1       2       3       4       5 default  closed 
#>   1       95.3%    0.0%    0.0%    0.0%    0.0%    4.7%    0.0% 
#>   2        0.0%   85.6%    0.0%    0.0%    0.0%   14.4%    0.0% 
#>   3        0.0%    0.0%   83.0%    0.0%    0.0%   17.0%    0.0% 
#>   4        0.0%    0.0%    0.0%   78.0%    0.0%   22.0%    0.0% 
#>   5        0.0%    0.0%    0.0%    0.0%   58.9%   41.1%    0.0%

On a panel where grades move, the same function on two grade vectors gives the full reading:

set.seed(2)
g0 <- pnl[date == as.Date("2023-01-01"), grade]
g1 <- pmin(5L, pmax(1L, g0 + sample(c(-1L, 0L, 0L, 0L, 1L), length(g0), TRUE)))
g1[sample(length(g1), 25)] <- NA
scr_migration(g0, g1, K = 5)
#> <scr_migration> 5 grades | 600 obligors | stable 71.0% | up 22.3% | down 6.8% | default 0.0% | closed 4.2%
#>   MWB upper 0.2718 | MWB lower 0.3250 | mean distance 0.290 | 0 cell(s) not decaying from the diagonal (z > 1.645)
#>   from        1       2       3       4       5 default  closed 
#>   1       71.8%   23.4%    0.0%    0.0%    0.0%    0.0%    4.7% 
#>   2       20.5%   57.5%   19.2%    0.0%    0.0%    0.0%    2.7% 
#>   3        0.0%   11.4%   70.5%   15.9%    0.0%    0.0%    2.3% 
#>   4        0.0%    0.0%   20.4%   53.7%   24.1%    0.0%    1.9% 
#>   5        0.0%    0.0%    0.0%   28.6%   64.3%    0.0%    7.1%

The point-in-time view

The grade PDs above are through-the-cycle: the long-run average with a margin. scr_pd_pit_ttc() is Vasicek’s one-factor bridge between a through-the-cycle PD and the conditional PD of a given state of the systematic factor z (negative for a stressed year, positive for a benign one), with the correlation rho; the map is invertible, and averaging the conditional PD over z returns the through-the-cycle value.

A point-in-time PD for provisioning or stress testing starts from the best estimate pd_be, not from pd_final: the margin of conservatism and the floor are regulatory devices that an unbiased estimate must not carry. The value of rho is the other choice to make explicitly. The asset correlation of the capital formula is a supervisory parameter, not an estimate of how strongly this portfolio’s default rates move with the cycle; the value below is illustrative and should be replaced by one fitted to a default-rate series long enough to contain a cycle.

rho <- 0.05   # illustrative; fit it to a long default-rate series
pit <- scr_pd_pit_ttc(pd$table$pd_be, z = -1, rho = rho)
data.table(grade = pd$table$grade, pd_be = pd$table$pd_be, pd_pit_stressed = pit,
           back_to_ttc = scr_pd_pit_ttc(pit, z = -1, rho = rho, to = "ttc"))
#>    grade      pd_be pd_pit_stressed back_to_ttc
#>    <int>      <num>           <num>       <num>
#> 1:     1 0.04661908      0.06775148  0.04661908
#> 2:     2 0.14443910      0.19524687  0.14443910
#> 3:     3 0.16957030      0.22624186  0.16957030
#> 4:     4 0.21934765      0.28600282  0.21934765
#> 5:     5 0.40823352      0.49652726  0.40823352

scr_pd(philosophy = "pit") applies the same map inside the PD model, to pd_moc and before the floor, for a regulatory-style point-in-time grade PD:

pd_pit <- scr_pd(gm, params = prm, philosophy = "pit", rho = rho, z = -1)
pd_pit$table[, .(grade, pd_ttc, pd_pit, pd_final)]
#>    grade     pd_ttc     pd_pit   pd_final
#>    <int>      <num>      <num>      <num>
#> 1:     1 0.05417353 0.07810063 0.07810063
#> 2:     2 0.18375476 0.24348290 0.24348290
#> 3:     3 0.18738803 0.24787110 0.24787110
#> 4:     4 0.24662989 0.31792245 0.31792245
#> 5:     5 0.44534566 0.53522700 0.53522700

7. Deliverables

scr_export() writes the PD workbook and the SQL file. With the validation supplied the workbook carries the test sheets; without it the sheets exist with an availability row, never a fabricated number.

out <- file.path(tempdir(), "scorecraft-pd")
ex <- scr_export(pd, out, stamp = FALSE, validation = v)
basename(unlist(ex$files))
#> [1] "pd_default.xlsx"    "sql_pd_default.sql"
openxlsx::getSheetNames(ex$files$pd)
#>  [1] "PD_Grades"                 "Master_Scale"             
#>  [3] "Calibration"               "MoC_Ledger"               
#>  [5] "Floors"                    "Grade_Series"             
#>  [7] "Validation_Calibration"    "Validation_Cohorts"       
#>  [9] "Validation_Discrimination" "Validation_Stability"     
#> [11] "Validation_Summary"        "Migration"                
#> [13] "Decision_Ledger"           "Model_Card"

8. What the ledger recorded

Nothing in the PD model depends on a number that was typed into a script and forgotten. The margin ledger keeps every entry with its category, method, value and reason, active or superseded; the grade repairs keep every merge with the count that forced it; the decision ledger of the model strings the stages together.

pd$moc_ledger[, .(id, category, method, level, grade, value, reason, active)]
#>        id category        method level grade       value
#>     <int>   <char>        <char> <num> <int>       <num>
#>  1:     1        C ci_timeseries  0.95     1 0.006554451
#>  2:     1        C ci_timeseries  0.95     2 0.038315668
#>  3:     1        C ci_timeseries  0.95     3 0.016817727
#>  4:     1        C ci_timeseries  0.95     4 0.026282240
#>  5:     1        C ci_timeseries  0.95     5 0.036112137
#>  6:     2        A        manual    NA     1 0.001000000
#>  7:     2        A        manual    NA     2 0.001000000
#>  8:     2        A        manual    NA     3 0.001000000
#>  9:     2        A        manual    NA     4 0.001000000
#> 10:     2        A        manual    NA     5 0.001000000
#>                                                    reason active
#>                                                    <char> <lgcl>
#>  1:      estimation error, ci_timeseries at 95% one-sided   TRUE
#>  2:      estimation error, ci_timeseries at 95% one-sided   TRUE
#>  3:      estimation error, ci_timeseries at 95% one-sided   TRUE
#>  4:      estimation error, ci_timeseries at 95% one-sided   TRUE
#>  5:      estimation error, ci_timeseries at 95% one-sided   TRUE
#>  6: unlikeliness-to-pay trigger not available before 2024   TRUE
#>  7: unlikeliness-to-pay trigger not available before 2024   TRUE
#>  8: unlikeliness-to-pay trigger not available before 2024   TRUE
#>  9: unlikeliness-to-pay trigger not available before 2024   TRUE
#> 10: unlikeliness-to-pay trigger not available before 2024   TRUE
pd$repairs
#>          step merged   into                              reason
#>        <char> <char> <char>                              <char>
#> 1: min_counts      1      2  n 42, defaults 1 (minimum 30 / 10)
#> 2: min_counts    1+2      3 n 139, defaults 4 (minimum 30 / 10)
pd$ledger[, .(action, detail)]
#>             action
#>             <char>
#> 1: grade_pd_source
#> 2:          grades
#> 3:           moc_C
#> 4:           moc_A
#> 5:        pd_floor
#> 6:        pd_model
#>                                                                                                                            detail
#>                                                                                                                            <char>
#> 1:                                                                      grade PD from the long-run average of 8 cohorts (quarter)
#> 2: 5 grades (geometric, from 7) on sample holdout | min obligors 30, min defaults 10 | 2 merge(s) | HHI 0.241, CV 0.452, HI 0.116
#> 3:                                               ci_timeseries | mean 248.2 bp | estimation error, ci_timeseries at 95% one-sided
#> 4:                                                  manual | mean 10.0 bp | unlikeliness-to-pay trigger not available before 2024
#> 5:                                                                                             PD floor 0.05% (retail_other, bcb)
#> 6:                                       5 grades | portfolio PD 15.141% -> 17.491% (MoC 235.0 bp) -> 17.491% | 0.0% at the floor