The hardware and bandwidth for this mirror is donated by dogado GmbH, the Webhosting and Full Service-Cloud Provider. Check out our Wordpress Tutorial.
If you wish to report a bug, or if you are interested in having us mirror your free-software or open-source project, please feel free to contact us at mirror[@]dogado.de.

Coarse classing with an audit trail

Why manual coarse classing still matters

Optimal binning does one job well: it finds the cut points and the groupings that maximise the information value of a variable subject to the admission rules (minimum bin size, monotonicity, hold-out stability). What it cannot know is how the business reads the variable. A bureau score is communicated to underwriters in policy bands; a region is priced as edge and core, not as five regions; an age is quoted in decades. A scorecard whose bins cut at 33.36 and 48.06 is correct, but nobody in the credit committee can explain it, and a bin nobody can explain is a bin nobody will defend when the model is challenged.

There is also a stability argument. The optimal cut points are estimated on one training window, and a handful of them sit on thin slices of the distribution. Coarser, rounder bands lose a little information value on train and often lose nothing on hold-out, while being far less likely to drift when the population shifts. The lab in scorecraft lets the analyst make exactly that trade, and shows the price of it on train and hold-out before anything is committed.

What the lab refuses to do is let a manual decision enter silently. Every proposal is compared against the optimal bins, every acceptance carries a reason, every override of a blocking rule is a row in an append-only ledger, and the automatic artefacts are frozen alongside the manual ones. The scorecard, the R scoring function and the production SQL consume the manual bins through the very same code path as the optimal ones, so there is no second implementation to keep in step.

A fast selection run

The lab opens on an scr_select() result. The configuration below is a light one: a single thread, two consensus voters (elastic net and xgboost) and a short bootstrap. The selection itself is described in vignette("scorecraft", package = "scorecraft").

library(scorecraft)
cfg <- scr_config(verbose = FALSE, nthread = 1, use_glmnet = TRUE, use_ranger = FALSE,
                  use_lightgbm = FALSE, xgb_rounds = 60, n_boot = 20)
res <- scr_select(scr_demo, "default", config = cfg,
                  drop = c("id", "churn"), date_col = "ref_date")
scr_selected(res)
#>  [1] "vl_score_01" "vl_score_02" "vl_score_04" "ds_band"     "vl_late"    
#>  [6] "ds_region"   "vl_score_06" "vl_score_07" "vl_score_05" "ds_channel" 
#> [11] "vl_hist_04"  "vl_score_10"

Opening the lab

scr_coarse_classing() covers every variable that reached binning, not only the consensus shortlist, so a variable failed by screening can be rebinned and forced in later with a reason. author is free text recorded in the ledger; it defaults to the system user.

lab <- scr_coarse_classing(res, author = "analyst")
lab
#> <scr_classing> target "default" | opened 2026-09-25 19:47 by analyst | 37 variables | 0 proposals: 0 accepted, 0 discarded
#>   final choice: 12 variables | consensus 12 | force: (none) | drop: (none)

Without a variable, scr_classing_view() prints one line per variable: its current source (optimal or manual), the number of bins, the train and hold-out IV, the PSI and a verdict computed with the same rules a proposal will face. The last column says whether the variable is currently in the final shortlist (yes, or -).

ov <- scr_classing_view(lab)
#> <scr_classing> target "default" | 37 variables
#>   variable                     source    bins  IV train   IV hold     PSI verdict     shortlist
#>   vl_score_01                  optimal      7    0.3464    0.2877  0.0066 ACCEPTABLE  yes
#>   vl_score_02                  optimal      7    0.1721    0.1234  0.0053 ACCEPTABLE  yes
#>   vl_score_03                  optimal      3    0.0576    0.0517  0.0003 REVIEW      -
#>   vl_score_04                  optimal      7    0.1243    0.1177  0.0032 ACCEPTABLE  yes
#>   vl_score_05                  optimal      5    0.0363    0.0289  0.0024 ACCEPTABLE  yes
#>   vl_score_06                  optimal      7    0.0483    0.0794  0.0049 ACCEPTABLE  yes
#>   vl_score_07                  optimal      6    0.0505    0.0705  0.0071 ACCEPTABLE  yes
#>   vl_score_08                  optimal      3    0.0149    0.0055  0.0026 REVIEW      -
#>   vl_score_09                  optimal      3    0.0072    0.0001  0.0012 REVIEW      -
#>   vl_score_10                  optimal      3    0.0282    0.0432  0.0024 ACCEPTABLE  yes
#>   vl_score_11                  optimal      6    0.0781    0.0177  0.0064 REVIEW      -
#>   vl_score_12                  optimal      6    0.0717    0.0321  0.0061 REVIEW      -
#>   vl_hist_01                   optimal      3    0.0029    0.0158  0.0012 REVIEW      -
#>   vl_hist_01__sp               optimal      2    0.0315    0.0069  0.0017 REVIEW      -
#>   vl_hist_02                   optimal      5    0.0075    0.0730  0.0033 REVIEW      -
#>   vl_hist_02__sp               optimal      2    0.0296    0.0464  0.0000 ACCEPTABLE  -
#>   vl_hist_03                   optimal      3    0.0205    0.0092  0.0013 REVIEW      -
#>   vl_hist_03__sp               optimal      2    0.0357    0.0764  0.0006 ACCEPTABLE  -
#>   vl_hist_04                   optimal      3    0.0332    0.0809  0.0047 ACCEPTABLE  yes
#>   vl_hist_04__sp               optimal      2    0.0642    0.1053  0.0058 ACCEPTABLE  -
#>   vl_partial_01                optimal      5    0.0045    0.0144  0.0051 REVIEW      -
#>   vl_partial_01__sp            optimal      2    0.0020    0.0036  0.0002 REVIEW      -
#>   vl_partial_02                optimal      4    0.0036    0.0111  0.0013 REVIEW      -
#>   vl_partial_02__sp            optimal      2    0.0031    0.0062  0.0002 REVIEW      -
#>   vl_late                      optimal      7    0.0711    0.0638  0.1042 ACCEPTABLE  yes
#>   vl_noise_01                  optimal      3    0.0031    0.0079  0.0015 REVIEW      -
#>   vl_noise_02                  optimal      6    0.0084    0.0231  0.0062 REVIEW      -
#>   vl_noise_03                  optimal      5    0.0188    0.0173  0.0039 REVIEW      -
#>   vl_noise_04                  optimal      3    0.0053    0.0192  0.0004 REVIEW      -
#>   vl_noise_05                  optimal      3    0.0018    0.0020  0.0050 REVIEW      -
#>   vl_noise_06                  optimal      6    0.0024    0.0501  0.0032 REVIEW      -
#>   vl_redundant                 optimal      7    0.1743    0.1071  0.0065 ACCEPTABLE  -
#>   ds_region                    optimal      5    0.0846    0.0971  0.0064 ACCEPTABLE  yes
#>   ds_band                      optimal      4    0.0805    0.0780  0.0013 ACCEPTABLE  yes
#>   ds_channel                   optimal      3    0.0279    0.0438  0.0003 ACCEPTABLE  yes
#>   ds_optin                     optimal      3    0.0065    0.0032  0.0019 REVIEW      -
#>   vl_hist_05__sp               optimal      2    0.0191    0.0428  0.0006 REVIEW      -

With a variable, the view is the bin table with train and hold-out side by side, plus a text bar chart of the event rate. vl_score_01 is a numeric with seven optimal bins; ds_region is a categorical with one bin per state.

scr_classing_view(lab, "vl_score_01")
#> <scr_classing> vl_score_01 (numerical) | current: optimal (jedi) | 7 bins | train IV 0.3464, hold-out IV 0.2877 (ratio 0.86)
#>   monotone: yes | min bin 5.2% | PSI 0.0066 (stable) | KS 0.198 | degenerate bins: 0 | verdict: ACCEPTABLE
#>    id  bin                                  n      %  events   rate      WOE      IV |  n.hold      %    rate WOE.hold
#>     1  (-Inf;33.360000]                   145   5.2%       3   2.1%   -2.063   0.106 |      69   4.9%    4.3%   -1.317
#>     2  (33.360000;38.150000]              162   5.8%      12   7.4%   -0.731   0.024 |      76   5.4%    3.9%   -1.417
#>     3  (38.150000;44.240000]              366  13.1%      29   7.9%   -0.658   0.045 |     171  12.2%    7.6%   -0.723
#>     4  (44.240000;48.060000]              301  10.8%      27   9.0%   -0.523   0.024 |     158  11.3%   10.8%   -0.341
#>     5  (48.060000;63.940000]            1,343  48.0%     198  14.7%    0.040   0.001 |     664  47.4%   15.2%    0.056
#>     6  (63.940000;72.610000]              338  12.1%      85  25.1%    0.704   0.076 |     201  14.4%   22.4%    0.531
#>     7  (72.610000;+Inf]                   145   5.2%      45  31.0%    0.996   0.071 |      61   4.4%   34.4%    1.130
#>   event rate by bin (train | hold-out)
#>     1  #                    2.1% | ##                   4.3%
#>     2  ####                 7.4% | ##                   3.9%
#>     3  ####                 7.9% | ####                 7.6%
#>     4  #####                9.0% | ######              10.8%
#>     5  ########            14.7% | ########            15.2%
#>     6  #############       25.1% | ############        22.4%
#>     7  ################    31.0% | ##################  34.4%
scr_classing_view(lab, "ds_region")
#> <scr_classing> ds_region (categorical) | current: optimal (jedi) | 5 bins | train IV 0.0846, hold-out IV 0.0971 (ratio 1.14)
#>   monotone: yes | min bin 7.9% | PSI 0.0064 (stable) | KS 0.114 | degenerate bins: 0 | verdict: ACCEPTABLE
#>    id  bin                                  n      %  events   rate      WOE      IV |  n.hold      %    rate WOE.hold
#>     1  CENTRE                             539  19.2%      57  10.6%   -0.341   0.020 |     301  21.5%   13.0%   -0.130
#>     2  EAST                             1,139  40.7%     144  12.6%   -0.139   0.008 |     551  39.4%   13.8%   -0.058
#>     3  WEST                               582  20.8%      82  14.1%   -0.014   0.000 |     269  19.2%   10.0%   -0.419
#>     4  NORTH                              318  11.4%      66  20.8%    0.453   0.027 |     178  12.7%   23.6%    0.599
#>     5  SOUTH                              222   7.9%      50  22.5%    0.557   0.030 |     101   7.2%   18.8%    0.312
#>   event rate by bin (train | hold-out)
#>     1  ########            10.6% | ##########          13.0%
#>     2  ##########          12.6% | ###########         13.8%
#>     3  ###########         14.1% | ########            10.0%
#>     4  ################    20.8% | ##################  23.6%
#>     5  #################   22.5% | ##############      18.8%

Proposing bins

scr_classing_propose() takes exactly one instruction per call. For a numeric variable the instruction is breaks (absolute interior cut points), merge (adjacent bin ids) or split (c(id, at)). For a categorical it is groups (a list of character vectors, optionally named), merge (bin ids), missing_to (which bin receives the "MISSING" category) or other_to (the catch-all bin for every training category not listed). A proposal is a value: nothing changes in the lab until it is accepted.

Numeric: breaks, merge, split

Suppose underwriting quotes vl_score_01 in the bands below 40, 40 to 55, 55 to 70 and above 70.

p_breaks <- scr_classing_propose(lab, "vl_score_01", breaks = c(40, 55, 70))
p_breaks
#> <scr_classing_proposal> P001 vl_score_01 | breaks = c(40, 55, 70) | 2026-09-25 19:47
#>                         optimal     manual      delta
#>   n_bins                      7          4         -3
#>   iv_train               0.3464     0.2993    -0.0471
#>   iv_holdout             0.2877     0.2740    -0.0137
#>   iv_ratio               0.8649     0.9236     0.0587
#>   ks                     0.1981     0.2472     0.0490
#>   psi                    0.0066     0.0032    -0.0034
#>   min_bin_pct            0.0518     0.0786     0.0268
#>   largest_bin_pct        0.4796     0.4307    -0.0489
#>   n_degenerate                0          0          0
#>   monotonic                   1          1          0
#>   manual bins (train | hold-out)
#>     1  (-Inf;40.000000]                   405  14.5%   5.9%  -0.970 |     193  13.8%   5.2%  -1.133
#>     2  (40.000000;55.000000]            1,206  43.1%  10.0%  -0.399 |     588  42.0%  11.4%  -0.277
#>     3  (55.000000;70.000000]              969  34.6%  19.6%   0.384 |     520  37.1%  18.1%   0.263
#>     4  (70.000000;+Inf]                   220   7.9%  29.1%   0.904 |      99   7.1%  32.3%   1.035
#>   Verdict: ACCEPTABLE - no warning raised.

The header repeats the instruction. The comparison table has one row per metric and three columns: the optimal bins, the proposal and the difference. Here the four policy bands cost about 0.05 of IV on train and about 0.014 on hold-out, while the IV ratio (hold-out over train), the KS and the smallest bin all improve, and the PSI halves. Below the table are the manual bins themselves with count, share, event rate and WOE on train and on hold-out. No warning was raised, so the verdict is ACCEPTABLE.

merge and split are relative to the current bins of the variable and resolve to absolute cut points, which is what the header shows.

p_merge <- scr_classing_propose(lab, "vl_score_01", merge = c(1, 2))
p_merge$entry$cutpoints
#> [1] 38.15 44.24 48.06 63.94 72.61
p_split <- scr_classing_propose(lab, "vl_score_01",
                                split = c(1, res$fit$results$vl_score_01$cutpoints[1] - 5))
p_split
#> <scr_classing_proposal> P003 vl_score_01 | split = c(1, 28.36) | 2026-09-25 19:47
#>                         optimal     manual      delta
#>   n_bins                      7          8          1
#>   iv_train               0.3464     0.3561     0.0098
#>   iv_holdout             0.2877     0.2895     0.0018
#>   iv_ratio               0.8649     0.8745     0.0097
#>   ks                     0.1981     0.1981     0.0000
#>   psi                    0.0066     0.0067     0.0001
#>   min_bin_pct            0.0518     0.0211    -0.0307
#>   largest_bin_pct        0.4796     0.4796     0.0000
#>   n_degenerate                0          0          0
#>   monotonic                   1          0         -1
#>   manual bins (train | hold-out)
#>     1  (-Inf;28.360000]                    59   2.1%   3.4%  -1.555 |      27   1.9%   7.4%  -0.751
#>     2  (28.360000;33.360000]               86   3.1%   1.2%  -2.648 |      42   3.0%   2.4%  -1.939
#>     3  (33.360000;38.150000]              162   5.8%   7.4%  -0.731 |      76   5.4%   3.9%  -1.417
#>     4  (38.150000;44.240000]              366  13.1%   7.9%  -0.658 |     171  12.2%   7.6%  -0.723
#>     5  (44.240000;48.060000]              301  10.8%   9.0%  -0.523 |     158  11.3%  10.8%  -0.341
#>     6  (48.060000;63.940000]            1,343  48.0%  14.7%   0.040 |     664  47.4%  15.2%   0.056
#>     7  (63.940000;72.610000]              338  12.1%  25.1%   0.704 |     201  14.4%  22.4%   0.531
#>     8  (72.610000;+Inf]                   145   5.2%  31.0%   0.996 |      61   4.4%  34.4%   1.130
#>   Warnings
#>     - NOT_MONOTONIC
#>     - TOO_MANY_BINS
#>   Verdict: REVIEW - advisory warnings only; accept with a reason or discard.

The split proposal is a REVIEW on two counts: the new first bin holds 2% of the training rows and breaks the monotone pattern of the event rate (NOT_MONOTONIC), and eight bins exceed the configured maximum of seven (TOO_MANY_BINS). A REVIEW verdict is advisory: the proposal can be accepted with a reason or discarded. Every proposal takes the next id from the lab’s counter (P001, P002, …), whether or not it is later acted on, so the ids in a session count the proposals made, not the decisions taken. A proposal is a value and can be kept for as long as the lab is open; the lab only refuses a proposal that has already been accepted or discarded.

Categorical: groups, other_to, missing_to

Pricing works with two regions. Names given to the groups are display labels; the bin label stored in the entry is the categories joined by the configuration’s separator, exactly as the binning engine writes it.

p_groups <- scr_classing_propose(lab, "ds_region",
                                 groups = list(edge = c("NORTH", "SOUTH"),
                                               core = c("EAST", "WEST", "CENTRE")))
p_groups
#> <scr_classing_proposal> P004 ds_region | groups = list(edge = c("NORTH", "SOUTH"), core = c("EAST", "WEST", "CENTRE")) | 2026-09-25 19:47
#>                         optimal     manual      delta
#>   n_bins                      5          2         -3
#>   iv_train               0.0846     0.0739    -0.0107
#>   iv_holdout             0.0971     0.0786    -0.0186
#>   iv_ratio               1.1432     1.0568    -0.0864
#>   ks                     0.1141     0.1141     0.0000
#>   psi                    0.0064     0.0003    -0.0061
#>   min_bin_pct            0.0793     0.1929     0.1136
#>   largest_bin_pct        0.4068     0.8071     0.4004
#>   n_degenerate                0          0          0
#>   monotonic                   1          1          0
#>   manual bins (train | hold-out)
#>     1  NORTH | SOUTH                      540  19.3%  21.5%   0.499 |     279  19.9%  21.9%   0.501
#>     2  EAST | WEST | CENTRE             2,260  80.7%  12.5%  -0.149 |   1,121  80.1%  12.7%  -0.156
#>   Warnings
#>     - IV_LOSS_VS_OPTIMAL
#>   Verdict: REVIEW - advisory warnings only; accept with a reason or discard.

Every training category must land somewhere. Listing them all is one option; the other is to name a catch-all bin with other_to, which is also how a reviewer says “everything else goes here”. The result is the same grouping, and the entry records that the second bin is the catch-all (is_other).

p_other <- scr_classing_propose(lab, "ds_region",
                                groups = list(edge = c("NORTH", "SOUTH"), rest = "EAST"),
                                other_to = "rest")
p_other$entry$bin
#> [1] "NORTH%;%SOUTH"        "EAST%;%WEST%;%CENTRE"
p_other$entry$manual$is_other
#> [1] FALSE  TRUE

ds_optin carries a "MISSING" category, which the optimal binning kept on its own. missing_to folds it into another bin.

p_missing <- scr_classing_propose(lab, "ds_optin", missing_to = 1)
p_missing$entry$bin
#> [1] "YES%;%MISSING" "NO"
p_missing$verdict
#> [1] "REVIEW"
p_missing$warnings
#> [1] "IV_BELOW_MIN"       "IV_LOW_ON_HOLDOUT"  "IV_LOSS_VS_OPTIMAL"
#> [4] "IV_RATIO_UNSTABLE"

The optimal bins of ds_optin carried a training IV of 0.0065 (see the overview); folding "MISSING" into YES leaves almost none, below the admission minimum, and loses more than the lab allows on hold-out. With a training IV that close to zero, the hold-out/train IV ratio says nothing either, hence IV_RATIO_UNSTABLE.

Reading the warnings and the verdict

Every proposal is screened with the eight engine rules on train, revalidated on hold-out with the bins frozen, and checked against a few lab-specific rules. Codes fall into two tiers.

ACCEPTABLE means no code at all. The verdict, the codes and the reason travel with the decision into the ledger.

Accepting, discarding and the blocked path

reason is mandatory, at least five characters, and it is the audit trail: write what a reviewer would need to read a year from now. Both verbs return the updated lab, so the idiom is to reassign.

lab <- scr_classing_accept(lab, p_breaks, reason = "policy bands 40/55/70 used by underwriting")

The grouping proposed in the previous section is still valid and is accepted in turn.

lab <- scr_classing_accept(lab, p_groups, reason = "edge/core is what pricing uses")

The ds_optin proposal erased what little signal the variable had, so it is discarded, with a reason, and the variable keeps its optimal bins. A discarded proposal is a ledger row too.

lab <- scr_classing_discard(lab, p_missing, reason = "folding MISSING into YES erases the signal")

A blocking rule cannot be accepted through the normal path. A break at -5000 on vl_score_04, whose minimum is above 16, leaves the first bin empty:

p_blocked <- scr_classing_propose(lab, "vl_score_04", breaks = c(-5000, 50))
p_blocked$verdict
#> [1] "BLOCKED"
p_blocked$blocking
#> [1] "EMPTY_BIN"
scr_classing_accept(lab, p_blocked, reason = "we need this band for the policy")
#> Error:
#> ! scr_classing_accept(): proposal P007 is BLOCKED (EMPTY_BIN). Pass override = TRUE to accept it anyway; the override is recorded.

The override path is override = TRUE. It exists because there are legitimate reasons to carry a band the training data does not populate (a policy floor that will bind on a future population, for example), but it is never silent: the ledger receives an override row naming the blocking codes, followed by the accept row. Here it is exercised on a copy of the lab so that the session carries on without the empty bin.

lab_override <- scr_classing_accept(lab, p_blocked, reason = "deliberate policy floor at -5000",
                                    override = TRUE)
scr_decisions(lab_override)[variable == "vl_score_04", .(seq, action, proposal_id, verdict, warnings, reason)]
#>      seq   action proposal_id verdict
#>    <int>   <char>      <char>  <char>
#> 1:     4 override        P007 BLOCKED
#> 2:     5   accept        P007 BLOCKED
#>                                                                            warnings
#>                                                                              <char>
#> 1:                                                                        EMPTY_BIN
#> 2: NOT_MONOTONIC;SMALL_BIN;IV_DROPS_ON_HOLDOUT;IV_LOW_ON_HOLDOUT;IV_LOSS_VS_OPTIMAL
#>                              reason
#>                              <char>
#> 1: deliberate policy floor at -5000
#> 2: deliberate policy floor at -5000

Choosing the variables

scr_classing_choose() builds the final list as (consensus shortlist + force) - drop, optionally intersected with keep. force is allowed for any variable that reached binning, with two exceptions that need override = TRUE: a variable failed for IV_SUSPICIOUS (the leakage ceiling) and a derived __sp flag when allow_derived_final = FALSE. reason is one string for every variable named, or a character vector named by variable.

vl_score_10 is in the consensus shortlist but will not be available at decision time; vl_score_03 was failed by screening for NOT_MONOTONIC but policy requires it on the card.

scr_funnel(res, cols = "all")[feature %in% c("vl_score_10", "vl_score_03"),
                              .(feature, exit_stage, screen_reason)]
#>        feature   exit_stage screen_reason
#>         <char>       <char>        <char>
#> 1: vl_score_10  07.approved            OK
#> 2: vl_score_03 03.screening NOT_MONOTONIC
lab <- scr_classing_choose(lab, drop = "vl_score_10", force = "vl_score_03",
                           reason = c(vl_score_10 = "not available at decision time",
                                      vl_score_03 = "policy: bureau band must be scored"))

The session summary and the ledger

Printing the lab summarises the session: the variables touched with bins and IV before and after, the verdicts, the reasons, the discards and the final choice.

lab
#> <scr_classing> target "default" | opened 2026-09-25 19:47 by analyst | 37 variables | 7 proposals: 2 accepted, 1 discarded
#>   variable                   action      bins          IV train       IV hold-out verdict     reason
#>   vl_score_01                accepted  7->4     0.3464->0.2993     0.2877->0.2740   ACCEPTABLE  policy bands 40/55/70 used by underwriti
#>   ds_region                  accepted  5->2     0.0846->0.0739     0.0971->0.0786   REVIEW      edge/core is what pricing uses
#>   ds_optin                   discard  P006: folding MISSING into YES erases the sign
#>   final choice: 12 variables | consensus 12 | force: vl_score_03 | drop: vl_score_10

scr_decisions() returns the ledger: one row per decision, append-only, with the author, the timestamp, the instruction, the metrics before and after, the verdict, the codes and the reason. The same function reads the ledger from the committed result and from the scorecard fitted on it.

scr_decisions(lab)[, .(seq, variable, action, proposal_id, verdict, reason)]
#>      seq    variable  action proposal_id    verdict
#>    <int>      <char>  <char>      <char>     <char>
#> 1:     1 vl_score_01  accept        P001 ACCEPTABLE
#> 2:     2   ds_region  accept        P004     REVIEW
#> 3:     3    ds_optin discard        P006     REVIEW
#> 4:     4 vl_score_03   force        <NA>       <NA>
#> 5:     5 vl_score_10    drop        <NA>       <NA>
#>                                        reason
#>                                        <char>
#> 1: policy bands 40/55/70 used by underwriting
#> 2:             edge/core is what pricing uses
#> 3: folding MISSING into YES erases the signal
#> 4:         policy: bureau band must be scored
#> 5:             not available at decision time

The spec round trip: a reviewer edits in a spreadsheet

Not every reviewer works in R. scr_classing_spec() writes the classing as a long table, one row per bin of every variable, optimal and manual. The authoritative columns a reviewer may edit are lower and upper for numerics, categories and is_other for categoricals, and reason; everything else (counts, rates, WOE) is context and is regenerated on read. Open ends are written as empty cells. A .xlsx path writes a workbook through openxlsx; a .csv path needs nothing.

spec <- scr_classing_spec(lab)
spec
#> <scr_classing_spec> 149 bins | 37 variables (2 manual)
#>     variable    type bin_id             bin_label lower upper categories
#>  vl_score_01 numeric      1      (-Inf;40.000000]    NA 40.00       <NA>
#>  vl_score_01 numeric      2 (40.000000;55.000000] 40.00 55.00       <NA>
#>  vl_score_01 numeric      3 (55.000000;70.000000] 55.00 70.00       <NA>
#>  vl_score_01 numeric      4      (70.000000;+Inf] 70.00    NA       <NA>
#>  vl_score_02 numeric      1      (-Inf;40.880000]    NA 40.88       <NA>
#>  vl_score_02 numeric      2 (40.880000;42.700000] 40.88 42.70       <NA>
#>  vl_score_02 numeric      3 (42.700000;48.660000] 42.70 48.66       <NA>
#>  vl_score_02 numeric      4 (48.660000;64.440000] 48.66 64.44       <NA>
#>  vl_score_02 numeric      5 (64.440000;70.580000] 64.44 70.58       <NA>
#>  vl_score_02 numeric      6 (70.580000;75.170000] 70.58 75.17       <NA>
#>  vl_score_02 numeric      7      (75.170000;+Inf] 75.17    NA       <NA>
#>  vl_score_03 numeric      1      (-Inf;73.310000]    NA 73.31       <NA>
#>  is_other  source                                     reason
#>     FALSE  manual policy bands 40/55/70 used by underwriting
#>     FALSE  manual policy bands 40/55/70 used by underwriting
#>     FALSE  manual policy bands 40/55/70 used by underwriting
#>     FALSE  manual policy bands 40/55/70 used by underwriting
#>     FALSE optimal                                       <NA>
#>     FALSE optimal                                       <NA>
#>     FALSE optimal                                       <NA>
#>     FALSE optimal                                       <NA>
#>     FALSE optimal                                       <NA>
#>     FALSE optimal                                       <NA>
#>     FALSE optimal                                       <NA>
#>     FALSE optimal                                       <NA>
#>   ... (+137 rows)
spec_file <- file.path(tempdir(), "classing_default.csv")
scr_classing_spec(lab, file = spec_file)

A reviewer opens the file, moves the first cut of vl_score_01 from 40 to 42 and writes why in reason. Done in R, as it would be done in a spreadsheet cell by cell:

sheet <- read.csv(spec_file, stringsAsFactors = FALSE)
i1 <- sheet$variable == "vl_score_01" & sheet$bin_id == 1
sheet$upper[i1]  <- 42
sheet$reason[sheet$variable == "vl_score_01"] <- "reviewer: first cut moved to 42 to match the bureau band"
write.csv(sheet, spec_file, row.names = FALSE, na = "")

scr_classing_read() validates the file before anything else happens: bin_id must run from 1 to k without gaps, upper must be finite and strictly increasing except on the last bin, and the lower of every bin must equal the upper of the one before it. The edit above touched only upper, so the file is refused with a named reason.

scr_classing_read(spec_file)
#> Error:
#> ! scr_classing_read(): invalid spec
#>   - vl_score_01: `lower` of bin i must equal `upper` of bin i-1 (contiguity)

Fixing the neighbouring lower makes the spec contiguous again.

sheet$lower[sheet$variable == "vl_score_01" & sheet$bin_id == 2] <- 42
write.csv(sheet, spec_file, row.names = FALSE, na = "")
spec_back <- scr_classing_read(spec_file)

scr_classing_import() compares the file with the lab’s current bins and turns every variable that differs into a proposal, with the same checks and the same comparison as a proposal made by hand. The reviewer’s reason arrives as imported_reason; nothing is accepted on the analyst’s behalf.

imported <- scr_classing_import(lab, spec_back)
names(imported)
#> [1] "vl_score_01"
imported$vl_score_01$imported_reason
#> [1] "reviewer: first cut moved to 42 to match the bureau band"
imported$vl_score_01
#> <scr_classing_proposal> P008 vl_score_01 | breaks = c(42, 55, 70) | 2026-09-25 19:47
#>                         optimal    current     manual      delta
#>   n_bins                      7          4          4         -3
#>   iv_train               0.3464     0.2993     0.2990    -0.0474
#>   iv_holdout             0.2877     0.2740     0.2631    -0.0246
#>   iv_ratio               0.8649     0.9236     0.8867     0.0218
#>   ks                     0.1981     0.2472     0.2472     0.0490
#>   psi                    0.0066     0.0032     0.0035    -0.0031
#>   min_bin_pct            0.0518     0.0786     0.0786     0.0268
#>   largest_bin_pct        0.4796     0.4307     0.3904    -0.0893
#>   n_degenerate                0          0          0          0
#>   monotonic                   1          1          1          0
#>   manual bins (train | hold-out)
#>     1  (-Inf;42.000000]                   518  18.5%   6.4%  -0.893 |     242  17.3%   6.2%  -0.943
#>     2  (42.000000;55.000000]            1,093  39.0%  10.2%  -0.375 |     539  38.5%  11.5%  -0.266
#>     3  (55.000000;70.000000]              969  34.6%  19.6%   0.384 |     520  37.1%  18.1%   0.263
#>     4  (70.000000;+Inf]                   220   7.9%  29.1%   0.904 |      99   7.1%  32.3%   1.035
#>   Verdict: ACCEPTABLE - no warning raised.

The comparison now has a fourth column, current, because the variable already carries an accepted manual proposal. Accepting the imported one supersedes it, and the ledger records both facts.

lab <- scr_classing_accept(lab, imported$vl_score_01, reason = imported$vl_score_01$imported_reason)
scr_decisions(lab)[variable == "vl_score_01", .(seq, action, proposal_id, instruction, verdict)]
#>      seq    action proposal_id            instruction    verdict
#>    <int>    <char>      <char>                 <char>     <char>
#> 1:     1    accept        P001 breaks = c(40, 55, 70) ACCEPTABLE
#> 2:     6 supersede        P001                   <NA>       <NA>
#> 3:     7    accept        P008 breaks = c(42, 55, 70) ACCEPTABLE

Committing the lab: what changed

scr_classing_apply() returns a new scr_result. The input result is not modified. Inside the new one, the accepted manual entries replace the optimal ones in fit, the screening and hold-out rows of those variables are recomputed with the same pipeline functions, the final shortlist is the one implied by the choice, and the funnel, gains, SQL and summary are rebuilt.

res2 <- scr_classing_apply(lab)
res2
#> <scr_result> target "default"
#>   4,200 rows (train 2,800 / hold-out 1,400) | split out-of-time at 2026-05-01
#>   event: 14.25% on train, 14.50% on hold-out | 0.7s
#>   convention: risk (target=1 is the bad case)
#> 
#> Funnel
#>   candidates        37 ############################
#>   1. triage         37 ############################
#>   2. binning        37 ############################
#>   3. screening      20 ###############
#>   4. hold-out       16 ############
#>   5. correlation    12 #########
#>   6. consensus      12 #########
#>   7. manual         12 #########
#> 
#> Approved: 12
#>    1. vl_score_01                                  IV  0.299  KS 0.247
#>    2. vl_score_02                                  IV  0.172  KS 0.156
#>    3. vl_score_04                                  IV  0.124  KS 0.120
#>    4. ds_band                                      IV  0.081  KS 0.110
#>    5. vl_late                                      IV  0.071  KS 0.120
#>   ... (+7) - scr_selected() for the list
#> 
#> Models (hold-out)
#>   glmnet    AUC 0.7345 [0.7028, 0.7723]  KS 0.3842
#>   xgboost   AUC 0.7375 [0.7065, 0.7762]  KS 0.3695
#> 
#> Warnings
#>   - 3 derived flag(s) outside the deliverable by policy (allow_derived_final)
#> 
#> Coarse classing: 2 manual bin(s), 1 forced in, 1 dropped, 7 decision(s) by analyst - scr_decisions()

scr_selected() on the new result returns the final list; the automatic one is still there under which = "consensus".

scr_selected(res2)
#>  [1] "vl_score_01" "vl_score_02" "vl_score_04" "ds_band"     "vl_late"    
#>  [6] "ds_region"   "vl_score_06" "vl_score_07" "vl_score_05" "ds_channel" 
#> [11] "vl_hist_04"  "vl_score_03"
scr_selected(res2, "consensus")
#>  [1] "vl_score_01" "vl_score_02" "vl_score_04" "ds_band"     "vl_late"    
#>  [6] "ds_region"   "vl_score_06" "vl_score_07" "vl_score_05" "ds_channel" 
#> [11] "vl_hist_04"  "vl_score_10"
setdiff(scr_selected(res2), scr_selected(res2, "consensus"))
#> [1] "vl_score_03"
setdiff(scr_selected(res2, "consensus"), scr_selected(res2))
#> [1] "vl_score_10"

The funnel gains two columns. provenance says what the lab did to each variable (auto, manual:rebin, manual:add, manual:drop, or manual:rebin+add / manual:rebin+drop when both happened) and manual_reason carries the reason. A dropped variable exits at a new stage, 08.manual_drop; a forced one is 07.approved even though the consensus never selected it.

touched <- c("vl_score_01", "ds_region", "vl_score_03", "vl_score_10")
scr_funnel(res2, cols = "all")[feature %in% touched,
                               .(feature, exit_stage, provenance, manual_reason)]
#>        feature     exit_stage   provenance
#>         <char>         <char>       <char>
#> 1: vl_score_01    07.approved manual:rebin
#> 2:   ds_region    07.approved manual:rebin
#> 3: vl_score_03    07.approved   manual:add
#> 4: vl_score_10 08.manual_drop  manual:drop
#>                                               manual_reason
#>                                                      <char>
#> 1: reviewer: first cut moved to 42 to match the bureau band
#> 2:                           edge/core is what pricing uses
#> 3:                       policy: bureau band must be scored
#> 4:                           not available at decision time

The automatic fit is frozen as fit_auto, so the optimal cut points remain available next to the manual ones for as long as the result lives.

res2$fit_auto$results$vl_score_01$cutpoints
#> [1] 33.36 38.15 44.24 48.06 63.94 72.61
res2$fit$results$vl_score_01$cutpoints
#> [1] 42 55 70
res2$fit$summary[res2$fit$summary$feature %in% c("vl_score_01", "ds_region"),
                 c("feature", "algorithm", "n_bins", "total_iv")]
#>        feature algorithm n_bins   total_iv
#> 1  vl_score_01    manual      4 0.29898958
#> 33   ds_region    manual      2 0.07392963

Refitting the scorecard

scr_scorecard() on the committed result fits the logistic regression on the WOE columns of the final list, the manual ones included, and aligns the score exactly as before. The points of a manually binned variable are distributed over its four policy bands.

sc <- scr_scorecard(res2)
sc
#> <scr_scorecard> target "default" | 12 variables | higher_is_safer
#>   scale: 600 points at odds 50:1 (safe:event), PDO 20 | alignment regression
#>   score = 489.9126 + -27.0744 * logit | base_points = 539
#>   train    n 2,800   AUC 0.7845 [0.7681, 0.8026]  KS 0.4390  Gini 0.5690
#>   holdout  n 1,400   AUC 0.7348 [0.7007, 0.7736]  KS 0.3598  Gini 0.4697
#>   score PSI (hold-out): 0.0070 - fixed: stable | adjusted (0.0181): stable
#> 
#> Points (first rows)
#>   vl_score_01                  (-Inf;42.000000]             -0.893      28
#>   vl_score_01                  (42.000000;55.000000]        -0.375      12
#>   vl_score_01                  (55.000000;70.000000]         0.384     -12
#>   vl_score_01                  (70.000000;+Inf]              0.904     -28
#>   vl_score_02                  (-Inf;40.880000]             -0.824      23
#>   vl_score_02                  (40.880000;42.700000]        -0.770      21
#>   vl_score_02                  (42.700000;48.660000]        -0.228       6
#>   vl_score_02                  (48.660000;64.440000]        -0.093       3
#>   ... (+50 rows)
sc$points[variable == "vl_score_01", .(variable, bin, woe, points)]
#>       variable                   bin        woe points
#>         <char>                <char>      <num>  <num>
#> 1: vl_score_01      (-Inf;42.000000] -0.8929622     28
#> 2: vl_score_01 (42.000000;55.000000] -0.3753944     12
#> 3: vl_score_01 (55.000000;70.000000]  0.3836922    -12
#> 4: vl_score_01      (70.000000;+Inf]  0.9037063    -28
sc$points[variable == "ds_region", .(variable, bin, woe, points)]
#>     variable                  bin        woe points
#>       <char>               <char>      <num>  <num>
#> 1: ds_region        NORTH%;%SOUTH  0.4985359    -15
#> 2: ds_region EAST%;%WEST%;%CENTRE -0.1492097      5

The manual decisions have a price, and the committee should see it. The scorecard fitted on the optimal bins and the consensus shortlist is the benchmark:

sc_auto <- scr_scorecard(res)
rbind(scr_score_metrics(sc_auto)[, .(card = "optimal", sample, auc, auc_lo, auc_hi, ks)],
      scr_score_metrics(sc)[, .(card = "manual", sample, auc, auc_lo, auc_hi, ks)])
#>       card  sample       auc    auc_lo    auc_hi        ks
#>     <char>  <char>     <num>     <num>     <num>     <num>
#> 1: optimal   train 0.7856428 0.7662170 0.8069608 0.4411466
#> 2: optimal holdout 0.7394060 0.7066284 0.7770357 0.3889033
#> 3:  manual   train 0.7844919 0.7681230 0.8025513 0.4390349
#> 4:  manual holdout 0.7348276 0.7007297 0.7736362 0.3598076

On hold-out the manual card gives up about 0.005 of AUC and 0.03 of KS, well inside the bootstrap interval of either card. Whether policy bands, a two-region grouping, a forced bureau variable and a dropped unavailable one are worth that is a business decision; the lab makes sure it is taken with the numbers on the table.

The model card states the provenance in words: which binning algorithms the card mixes, whether the shortlist came from the consensus or from the lab, how many manual bins it carries and which variables were forced in or dropped. The ledger travels into the scorecard as well.

str(sc$model_card[c("binning_algorithm", "shortlist_source", "n_manual_bins",
                    "manual_bins", "forced_in", "manual_dropped", "n_decisions")])
#> List of 7
#>  $ binning_algorithm: chr "manual, jedi"
#>  $ shortlist_source : chr "manual"
#>  $ n_manual_bins    : int 2
#>  $ manual_bins      : chr "vl_score_01, ds_region"
#>  $ forced_in        : chr "vl_score_03"
#>  $ manual_dropped   : chr "vl_score_10"
#>  $ n_decisions      : int 7
nrow(scr_decisions(sc))
#> [1] 7

Production: R and SQL follow the manual bins

Nothing downstream needs to know that a bin was drawn by hand. scr_apply() scores new rows in R with the frozen pre-processing and the frozen bins; the points for vl_score_01 fall in one of the four policy bands and the points for ds_region in one of the two regions.

new <- head(scr_demo, 5)
new[, c("vl_score_01", "ds_region")]
#>   vl_score_01 ds_region
#> 1       71.94      EAST
#> 2       39.02      WEST
#> 3       57.39    CENTRE
#> 4       57.38     NORTH
#> 5       54.20      WEST
scr_apply(sc, new, what = "points")[, .(score, score_points, vl_score_01_points, ds_region_points)]
#>       score score_points vl_score_01_points ds_region_points
#>       <num>        <num>              <num>            <num>
#> 1: 544.1540          545                -28                5
#> 2: 575.3205          576                 28                5
#> 3: 519.6014          521                -12                5
#> 4: 494.8139          495                -12              -15
#> 5: 555.5494          557                 12                5

scr_sql() emits the same thing for the database. The header carries the provenance line; for the two variables touched, the lines below show the frozen pre-processing, the WOE CASE on the manual cut points and groupings with full precision, and the bin-index CASE on the same cuts. The tail composes the whole points from that index.

sql <- scr_sql(sc, table = "prd.customers", dialect = "databricks")
sql_lines <- unlist(strsplit(sql, "\n", fixed = TRUE))
cat(grep("^-- Provenance", sql_lines, value = TRUE), sep = "\n")
#> -- Provenance: 2 manually binned (vl_score_01, ds_region), 1 forced in (vl_score_03), 1 dropped (vl_score_10) - see the decision ledger
cat(grep("WHEN (vl_score_01|ds_region) ", sql_lines, value = TRUE), sep = "\n")
#>     CASE WHEN vl_score_01 IS NULL OR vl_score_01 IN (-999) THEN 52.75 ELSE vl_score_01 END AS vl_score_01,
#>     WHEN vl_score_01 IS NULL THEN 0
#>     WHEN vl_score_01 <= 42 THEN -0.8929621501396132
#>     WHEN vl_score_01 > 42 AND vl_score_01 <= 55 THEN -0.37539440893887893
#>     WHEN vl_score_01 > 55 AND vl_score_01 <= 70 THEN 0.3836922056211275
#>     WHEN vl_score_01 > 70 THEN 0.9037062554415245
#>     WHEN ds_region IS NULL THEN 0
#>     WHEN ds_region IN ('NORTH', 'SOUTH') THEN 0.4985359152057967
#>     WHEN ds_region IN ('EAST', 'WEST', 'CENTRE') THEN -0.1492097461959896
#>     WHEN vl_score_01 IS NULL THEN NULL
#>     WHEN vl_score_01 <= 42 THEN 1
#>     WHEN vl_score_01 > 42 AND vl_score_01 <= 55 THEN 2
#>     WHEN vl_score_01 > 55 AND vl_score_01 <= 70 THEN 3
#>     WHEN vl_score_01 > 70 THEN 4
#>     WHEN ds_region IS NULL THEN NULL
#>     WHEN ds_region IN ('NORTH', 'SOUTH') THEN 1
#>     WHEN ds_region IN ('EAST', 'WEST', 'CENTRE') THEN 2
cat(tail(sql, 8), sep = "\n")
#>       CASE vl_score_06_idx WHEN 1 THEN 9 WHEN 2 THEN 4 WHEN 3 THEN -3 WHEN 4 THEN -4 WHEN 5 THEN -5 WHEN 6 THEN -10 WHEN 7 THEN -15 ELSE 0 END AS vl_score_06_points,
#>       CASE vl_score_07_idx WHEN 1 THEN 15 WHEN 2 THEN 7 WHEN 3 THEN 6 WHEN 4 THEN 1 WHEN 5 THEN -2 WHEN 6 THEN -8 ELSE 0 END AS vl_score_07_points,
#>       CASE vl_score_05_idx WHEN 1 THEN 7 WHEN 2 THEN -3 WHEN 3 THEN -9 WHEN 4 THEN -9 WHEN 5 THEN -15 ELSE 0 END AS vl_score_05_points,
#>       CASE ds_channel_idx WHEN 1 THEN 9 WHEN 2 THEN 4 WHEN 3 THEN -5 ELSE 0 END AS ds_channel_points,
#>       CASE vl_hist_04_idx WHEN 1 THEN 19 WHEN 2 THEN 9 WHEN 3 THEN -3 ELSE 0 END AS vl_hist_04_points,
#>       CASE vl_score_03_idx WHEN 1 THEN 3 WHEN 2 THEN -22 WHEN 3 THEN -19 ELSE 0 END AS vl_score_03_points
#>   FROM woe_scr
#> ) pts;

That the two paths agree number for number, including a value sitting exactly on a manual cut point, is verified by the package tests, which run the generated SQL in DuckDB and compare it with scr_apply() on the same rows. This vignette does not need a database to run.

Governance

A reviewer can reconstruct every manual decision from the deliverables. The ledger is append-only, with one row per accept, discard, supersede, restore (a reset = TRUE proposal that takes a variable back to its optimal bins), override, force, drop and keep, and it travels from the lab into the result, the scorecard and the workbooks written by scr_export(). Four conditions block a proposal: an empty bin, a degenerate bin without smoothing, a bin below the hard minimum share and a manual IV above the leakage ceiling. Accepting a blocked proposal, forcing a variable failed for IV_SUSPICIOUS and forcing a derived __sp flag under allow_derived_final = FALSE each need override = TRUE, which adds its own ledger row.

These binaries (installable software) and packages are in development.
They may not be fully stable and should be used with caution. We make no claims about them.
Health stats visible at Monitor.