---
title: "An Industrial Scorecard Pipeline"
author: "José Evandeilton Lopes"
date: "`r Sys.Date()`"
output:
  rmarkdown::html_vignette:
    toc: true
    toc_depth: 3
vignette: >
  %\VignetteIndexEntry{An Industrial Scorecard Pipeline}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r setup, include = FALSE}
knitr::opts_chunk$set(
  collapse = TRUE,
  comment = "#>",
  fig.width = 7.5,
  fig.height = 4.5,
  fig.align = "center",
  warning = FALSE,
  message = FALSE
)
options(width = 100, digits = 4)
```

The [practical guide](introduction.html) covers what each function computes.
This vignette runs a full origination scorecard the way it is run in a risk
department: a wide base with the pathologies real bases have, an out-of-time
validation window, a `recipes` pipeline that can be versioned and shipped, and
the artefacts a model governance committee asks for.

Everything below runs in a few seconds and uses only `recipes`, which the
package already imports.

```{r libs}
library(OptimalBinningWoE)
library(recipes)
set.seed(20260819)
```

# The setting

An origination model is built once and lived with for years. The constraints
that shape the pipeline are not statistical:

- **The base is wide and mostly worthless.** Feature stores hand you hundreds
  of columns. Most carry nothing; a few carry the future.
- **Leakage is the default failure mode.** Fields populated after booking —
  collections activity, first-payment status — look spectacular in development
  and are unavailable at decision time.
- **The model must be defensible.** Every variable that enters needs a reason,
  and every variable that was dropped needs one too.
- **It must run where the data is.** The final artefact is usually SQL in a
  warehouse, not an R object.
- **It must be monitored.** A scorecard that was excellent last year and is
  never re-checked is a liability.

The pipeline below is organised around those five facts.

# A realistic origination base

The generator below produces an application base with the pathologies that
matter: skewed monetary fields, missing values in three fields, a high-cardinality dealer
code with rare levels, two near-duplicates of variables already present, eight
pure-noise columns, and one leaky field populated only after the loan was
booked.

```{r data-generator}
make_base <- function(n, vintage) {
  age    <- pmax(18, round(rnorm(n, 41, 13)))
  income <- round(exp(rnorm(n, 8.1, 0.55)))
  tenure <- pmax(0, round(rexp(n, 1 / 48)))
  util   <- pmin(1.6, pmax(0, rbeta(n, 2, 4) + rnorm(n, 0, 0.08)))
  inq    <- rpois(n, 1.3)
  dlq    <- rpois(n, 0.35)
  bureau <- round(rnorm(n, 640, 85))
  ltv    <- pmin(1.3, pmax(0.1, rnorm(n, 0.72, 0.16)))

  region  <- sample(c("N", "NE", "CO", "SE", "S"), n, TRUE, c(.09, .27, .07, .42, .15))
  channel <- sample(c("branch", "broker", "digital", "partner"), n, TRUE, c(.34, .21, .33, .12))
  product <- sample(c("auto", "personal", "payroll", "card"), n, TRUE, c(.28, .35, .22, .15))
  occ     <- sample(c("salaried", "self_employed", "retired", "public", "informal"), n, TRUE)
  housing <- sample(c("owned", "rented", "family", "mortgaged"), n, TRUE, c(.31, .34, .20, .15))
  dealer  <- sample(c(LETTERS[1:3], paste0("Z", 1:14)), n, TRUE,
                    c(rep(.30, 3), rep(.10 / 14, 14)))

  lp <- -3.80 -
    0.019 * (age - 41) -
    0.55 * scale(log(income))[, 1] +
    1.35 * util +
    0.24 * inq + 0.42 * dlq -
    0.008 * (bureau - 640) +
    1.70 * ltv +
    0.30 * (channel == "broker") - 0.22 * (channel == "branch") +
    0.55 * (occ == "informal") - 0.40 * (occ == "public") +
    0.45 * (housing == "rented") - 0.006 * pmin(tenure, 120)
  y <- rbinom(n, 1, plogis(lp))

  # a second bureau vendor and a declared-income field: near-duplicates of
  # variables already in the base, which is what feature stores actually hand you
  bureau_alt <- round(0.80 * bureau + 0.20 * rnorm(n, 640, 85) + rnorm(n, 0, 35))
  income_declared <- round(income * exp(rnorm(n, 0, 0.15)))

  df <- data.frame(
    age, income, tenure_months = tenure, utilisation = util,
    inquiries_6m = inq, delinq_12m = dlq, bureau_score = bureau, ltv,
    bureau_alt, income_declared,
    region, channel, product, occupation = occ, housing,
    dealer_code = dealer, stringsAsFactors = FALSE
  )

  # eight columns of pure noise and four uninformative flags
  for (j in 1:8) df[[sprintf("noise_%02d", j)]] <- rnorm(n)
  for (j in 1:4) df[[sprintf("flag_%02d", j)]] <- sample(c("Y", "N"), n, TRUE)

  # populated only after booking: unavailable at decision time
  df$collections_after_booking <- ifelse(y == 1, rpois(n, 2.2), rpois(n, 0.05))

  df$utilisation[sample(n, n * 0.12)] <- NA
  df$tenure_months[sample(n, n * 0.07)] <- NA
  df$occupation[sample(n, n * 0.05)] <- NA

  df$vintage <- vintage
  df$default <- y
  df
}
```

Development is the first half of 2024; validation is the second half, held out
by time rather than at random. An out-of-time window is what catches a model
that has learned a vintage instead of a risk.

```{r data-build}
dev <- make_base(20000, "2024H1")
oot <- make_base(8000, "2024H2")

predictors <- setdiff(names(dev), c("default", "vintage"))
c(dev = nrow(dev), oot = nrow(oot), predictors = length(predictors))
c(dev_rate = mean(dev$default), oot_rate = mean(oot$default))
```

## Missing values become levels

Binning treats a missing value as information, not as a gap to be filled. The
convention throughout the package is a numeric sentinel and a character level,
which the binner then places in its own bin — so "utilisation was not
reported" gets its own WoE instead of borrowing the mean's.

```{r missing}
as_levels <- function(df) {
  num <- vapply(df, is.numeric, logical(1))
  df[num] <- lapply(df[num], function(v) replace(v, is.na(v), -999))
  df[!num] <- lapply(df[!num], function(v) replace(v, is.na(v), "MISSING"))
  df
}

dev <- as_levels(dev)
oot <- as_levels(oot)
```

`ob_preprocess()` does the same job with outlier treatment attached when a
variable needs it; see the [practical guide](introduction.html#preprocessing).

# Screening at scale

Bin everything first, decide afterwards. Binning all `r length(predictors)`
candidates over 20,000 rows takes a fraction of a second, so there is no reason
to pre-filter by intuition.

```{r fit}
binning <- obwoe(dev, target = "default", feature = predictors,
                 min_bins = 2, max_bins = 6)
binning
```

`obwoe_select()` turns the fitted binning into a decision. The policy below is
a defensible default for origination: drop the unpredictive band, drop the
suspicious band, require monotonicity where the bin order is intrinsic, and
refuse bins holding less than 3% of the base.

```{r select}
sel <- obwoe_select(
  binning,
  iv_min            = 0.02,
  iv_max            = 0.50,
  require_monotonic = "numeric",
  min_bin_pct       = 0.03,
  sort_by           = "iv"
)

head(sel[, c("feature", "type", "n_bins", "total_iv", "iv_class",
             "ks", "monotonic", "quality", "selected")], 12)
```

The screening reduces the candidates to a shortlist and records why each of the
others went.

```{r select-reasons}
table(sel$reason)
```

## The two rejections worth reading

```{r select-leak}
sel[sel$reason != "OK" & sel$total_iv > 0.05,
    c("feature", "total_iv", "iv_class", "n_degenerate_bins", "reason")]
```

`collections_after_booking` is the leak, and it is not subtle: an IV of about 7
against a best-real-variable IV of 0.36, and a KS above 0.84. No genuine
application variable behaves like that. This is exactly the field that carries
a scorecard through development and destroys it in production, and the default
`iv_max = 0.50` catches it without anyone having to notice. `n_degenerate_bins`
is worth reading alongside it: a variable that also produces a bin with no
events or no non-events is separating the target perfectly somewhere, which is
the same diagnosis by another route.

```{r select-rare}
sel[grepl("SMALL_BIN", sel$reason),
    c("feature", "n_bins", "min_bin_pct", "min_bin_count", "reason")]
```

`dealer_code` is the high-cardinality field. Its rare levels cannot support a
stable estimate, and `min_bin_pct` says so.

```{r shortlist}
shortlist <- sel$feature[sel$selected]
shortlist
```

## Evidence for the committee

`detail = "full"` produces the bin-level table that goes into the model
document: every surviving variable, every bin, with the counts and rates behind
its WoE.

```{r evidence}
evidence <- obwoe_select(binning, detail = "full")
evidence[evidence$feature == "bureau_score",
         c("bin", "count", "pos", "pos_rate", "woe", "iv", "lift")]
```

The event rate falls monotonically across the bureau score, which is the shape
the business expects. A variable whose shape contradicts the business is a
finding, not a nuisance.

# Redundancy

IV ranks variables one at a time. Two variables can both be strong and carry
the same information, and a logistic regression on WoE will show it as an
unstable or sign-flipped coefficient. `obcorr()` computes the pairwise
correlations in the WoE space — the space the model actually sees.

```{r corr}
woe_dev <- obwoe_apply(dev, binning, keep_original = FALSE)
pairs <- obcorr(woe_dev[, paste0(shortlist, "_woe")], method = "pearson")

head(pairs[order(-abs(pairs$pearson)), ], 5)
```

Pruning is a greedy pass: for every pair above the cutoff, drop whichever
member the screening ranked lower.

```{r prune}
prune <- function(pairs, ranking, cutoff = 0.70) {
  hits <- pairs[abs(pairs$pearson) >= cutoff, , drop = FALSE]
  weaker <- mapply(function(a, b) {
    c(a, b)[which.max(c(match(a, ranking), match(b, ranking)))]
  }, hits$x, hits$y)
  unique(as.character(weaker))
}

ranking <- paste0(shortlist, "_woe")
dropped <- prune(pairs, ranking, cutoff = 0.70)

final_vars <- setdiff(shortlist, sub("_woe$", "", dropped))
dropped
c(shortlist = length(shortlist), dropped = length(dropped),
  final = length(final_vars))
```

The two near-duplicates go, each losing to the member of its pair that the
screening ranked higher. Nothing is lost: they carried the same information,
and keeping both would have split one variable's coefficient across two
columns.

# The pipeline as a recipe

`step_obwoe()` puts the binning inside a `recipes` object. That matters for
production: the recipe learns its cut points from the training data only, and
`bake()` replays them on any new frame. There is no path by which validation
data can influence the bins.

```{r recipe}
dev$default_f <- factor(dev$default, levels = c(0, 1))
oot$default_f <- factor(oot$default, levels = c(0, 1))

form <- reformulate(final_vars, response = "default_f")

rec <- recipe(form, data = dev) |>
  step_obwoe(all_predictors(), outcome = "default_f",
             min_bins = 2, max_bins = 6, bin_cutoff = 0.03,
             output = "woe")

prepped <- prep(rec, training = dev)
prepped
```

`tidy()` exposes what the step learned — the artefact to archive alongside the
model.

```{r recipe-tidy}
rules <- tidy(prepped, number = 1)
head(rules, 8)
nrow(rules)
```

`bake()` applies it. The development and validation frames go through the same
object, so the transformation is identical by construction.

```{r bake}
train_woe <- bake(prepped, new_data = dev)
oot_woe   <- bake(prepped, new_data = oot)

head(train_woe, 3)
```

# The model

On WoE predictors, logistic regression is the natural choice: the transform has
already linearised each variable against the log-odds, so what remains is
weighting.

```{r model}
fit <- glm(default_f ~ ., data = train_woe, family = binomial())
round(coef(summary(fit)), 4)
```

The sign check is the first thing to look at. A WoE of $+1$ means one more
log-odds of risk, so **every coefficient should be positive**. A negative one
means a variable is fighting the rest of the model, almost always through
residual correlation.

```{r model-signs}
sum(coef(fit)[-1] < 0)
```

Coefficients clustering near 1.0 are a good sign too: it says the WoE transform
already carried most of the calibration, and the regression is mostly
reweighting rather than repairing.

# Scorecard points

Risk departments deploy points, not log-odds. The standard scaling fixes a
reference score at a reference odds and a *points to double the odds* (PDO):

$$
\text{Score} = \text{Offset} + \text{Factor}\times \ln(\text{odds}),
\qquad
\text{Factor} = \frac{\text{PDO}}{\ln 2},
\qquad
\text{Offset} = \text{Score}_0 - \text{Factor}\times\ln(\text{Odds}_0)
$$

where $\text{odds}$ is good-to-bad. The model's linear predictor $\eta$ is the
log-odds of *default*, the other direction, so the score is
$\text{Offset} - \text{Factor}\,\eta$. With 600 points at 50:1 odds and 20
points to double them:

```{r points}
pdo <- 20
factor_ <- pdo / log(2)
offset_ <- 600 - factor_ * log(50)

to_score <- function(link) round(offset_ - factor_ * link)

dev$score <- to_score(predict(fit, newdata = train_woe, type = "link"))
oot$score <- to_score(predict(fit, newdata = oot_woe, type = "link"))

summary(dev$score)
```

Because the model is linear in WoE, the points decompose additively per bin,
which is what makes a scorecard a table a branch officer can read.

```{r points-decomposition}
lead_var <- final_vars[1]
per_bin <- rules[rules$terms == lead_var, c("bin", "woe")]
per_bin$points <- round(-factor_ * coef(fit)[[lead_var]] * per_bin$woe)

lead_var
per_bin
```

# Validation

## Rank ordering out of time

```{r gains}
gains_oot <- obwoe_gains(oot, target = "default", feature = "score",
                         use_column = "direct", n_groups = 10, sort_by = "bin")
gains_oot
```

Three things to check, in order. The event rate must fall monotonically from
the worst decile to the best — a break means the score does not rank. KS and
Gini must be close to their development values. And the top decile's lift is
the number the business will quote.

```{r gains-dev}
gains_dev <- obwoe_gains(dev, target = "default", feature = "score",
                         use_column = "direct", n_groups = 10, sort_by = "bin")

data.frame(
  sample = c("development", "out-of-time"),
  ks     = round(c(gains_dev$metrics$ks, gains_oot$metrics$ks), 2),
  gini   = round(c(gains_dev$metrics$gini, gains_oot$metrics$gini), 2),
  auc    = round(c(gains_dev$metrics$auc, gains_oot$metrics$auc), 4)
)
```

A drop of more than a few points from development to out-of-time is the usual
signature of overfitting; holding steady, as here, is what a stable model looks
like.

```{r gains-plot, fig.height=6}
op <- par(mfrow = c(2, 2), mar = c(4, 4, 2, 1))
plot(gains_oot, type = "cumulative")
plot(gains_oot, type = "ks")
plot(gains_oot, type = "lift")
plot(gains_oot, type = "woe_iv")
par(op)
```

## Population stability

PSI compares the distribution of a variable between two periods:

$$
\mathrm{PSI} = \sum_i (p_i - q_i)\,\ln\frac{p_i}{q_i}
$$

which is the same Jeffreys divergence that defines IV, applied to two vintages
of one variable instead of to two classes. The conventional reading is
$<0.10$ stable, $0.10$–$0.25$ worth watching, $>0.25$ act.

```{r psi}
psi <- function(p, q) {
  p <- pmax(p, 1e-6)
  q <- pmax(q, 1e-6)
  sum((p - q) * log(p / q))
}

share <- function(x, levels) as.numeric(table(factor(x, levels))) / length(x)

bins_dev <- obwoe_apply(dev, binning, keep_original = FALSE)
bins_oot <- obwoe_apply(oot, binning, keep_original = FALSE)

psi_vars <- vapply(final_vars, function(v) {
  levels <- binning$results[[v]]$bin
  psi(share(bins_dev[[paste0(v, "_bin")]], levels),
      share(bins_oot[[paste0(v, "_bin")]], levels))
}, numeric(1))

score_cuts <- c(-Inf, quantile(dev$score, seq(0.1, 0.9, 0.1)), Inf)
psi_score <- psi(share(cut(dev$score, score_cuts), levels(cut(dev$score, score_cuts))),
                 share(cut(oot$score, score_cuts), levels(cut(dev$score, score_cuts))))

psi_table <- data.frame(
  variable = c("SCORE", final_vars),
  psi = round(c(psi_score, psi_vars), 4),
  row.names = NULL
)
psi_table <- psi_table[order(-psi_table$psi), ]
row.names(psi_table) <- NULL
psi_table
```

Comparing bin shares rather than raw quantiles is deliberate: the bins are what
the model consumes, the comparison works for numerical and categorical
variables alike, and a shift that does not cross a cut point is a shift the
model never sees.

Both vintages come from the same generator here, so everything is stable by
construction. In production this table is the monthly monitoring report, and
the score's own PSI is the headline.

# Deployment

## To the warehouse

The scoring lives where the data lives. `obwoe_sql()` writes the WoE
transformation as SQL that reproduces `bake()` exactly.

```{r sql}
sql <- obwoe_sql(
  binning,
  table        = "risk.applications",
  features     = final_vars,
  keep_columns = c("application_id", "vintage"),
  dialect      = "postgres",
  style        = "view",
  view_name    = "risk.v_application_woe"
)

writeLines(head(strsplit(as.character(sql), "\n")[[1]], 28))
```

The intervals follow the same half-open $(a,\,b]$ convention as `bake()`, cut
points are written at full round-trip precision, and every expression opens
with an explicit `IS NULL` branch — because `NULL <= 5` is `NULL` in SQL, not
`FALSE`, and a missing value would otherwise fall through to `ELSE`.

Write it out and hand it to the data engineering team:

```{r sql-file, eval=FALSE}
obwoe_sql(binning, table = "risk.applications", features = final_vars,
          dialect = "postgres", file = "woe_transform.sql")
```

The linear part goes with it as a small coefficient table:

```{r sql-coefs}
data.frame(
  variable = names(coef(fit)),
  beta     = round(as.numeric(coef(fit)), 6),
  row.names = NULL
)
```

## To R

For batch scoring in R, the recipe is the artefact. Save it with the
coefficients and the screening decisions so the model can be reproduced and
audited later.

```{r deploy-r, eval=FALSE}
artefact <- list(
  recipe       = prepped,
  coefficients = coef(fit),
  scaling      = c(factor = factor_, offset = offset_),
  screening    = sel,
  built_on     = Sys.Date(),
  package      = as.character(utils::packageVersion("OptimalBinningWoE"))
)
saveRDS(artefact, "scorecard_v1.rds")

score_batch <- function(new_data, artefact) {
  woe <- bake(artefact$recipe, new_data = new_data)
  lp <- as.numeric(cbind(1, as.matrix(woe[names(artefact$coefficients)[-1]])) %*%
                     artefact$coefficients)
  round(artefact$scaling[["offset"]] - artefact$scaling[["factor"]] * lp)
}
```

Pinning the package version matters: bin boundaries are part of the model, and
a model that cannot be reproduced cannot be defended.

# Tuning with tidymodels

`step_obwoe()` is `tunable`, so `max_bins`, `min_bins`, `bin_cutoff` and even
the algorithm can be tuned by cross-validation. The block below is not
evaluated here because it needs the Suggested tidymodels stack.

```{r tune, eval=FALSE}
library(tidymodels)

rec_tune <- recipe(form, data = dev) |>
  step_obwoe(all_predictors(), outcome = "default_f",
             max_bins = tune(), bin_cutoff = tune())

wf <- workflow() |>
  add_recipe(rec_tune) |>
  add_model(logistic_reg() |> set_engine("glm"))

grid <- grid_regular(obwoe_max_bins(range = c(3L, 10L)),
                     obwoe_bin_cutoff(range = c(0.01, 0.10)),
                     levels = 4)

folds <- vfold_cv(dev, v = 5, strata = default_f)

tuned <- tune_grid(wf, resamples = folds, grid = grid,
                   metrics = metric_set(roc_auc))

final_wf <- finalize_workflow(wf, select_best(tuned, metric = "roc_auc"))
final_fit <- fit(final_wf, data = dev)
```

Two cautions. Cross-validating the binning is the correct thing to do — binning
is supervised, so cut points chosen on the full sample leak the target — and it
is what `step_obwoe()` inside a `workflow()` gives you for free. But more bins
almost always raise in-sample AUC, so tune against a metric on held-out folds
and keep `max_bins` modest: a scorecard with twelve bins per variable is not a
scorecard anyone will sign.

# Governance checklist

The pipeline above produces, in order, everything a model document needs:

| Question | Artefact |
|---|---|
| Which variables were considered? | `sel`, one row per candidate |
| Why was each one dropped? | `sel$reason`, `sel$reason_desc` |
| Is each variable's shape defensible? | `obwoe_select(detail = "full")` |
| Is the model free of leakage? | `IV_SUSPICIOUS` fires above 0.50 |
| Are the predictors independent? | `obcorr()` on the WoE space |
| Do the coefficients make sense? | all positive on WoE predictors |
| Does it rank out of time? | gains table on the held-out vintage |
| Will it stay stable? | PSI by variable and on the score |
| Can it be reproduced? | the prepped recipe plus the package version |
| Can it be deployed? | `obwoe_sql()` |

# See also

- [Optimal Binning and Weight of Evidence: A Practical Guide](introduction.html)
  — the quantities, reading a gains table, choosing an algorithm.
- `?obwoe_select` for the full rule set and reason codes.
- `?step_obwoe` for the recipe step, including `tunable()` support.
- `?obwoe_sql` for the SQL contract and the supported dialects.

```{r session}
sessionInfo()
```
