--- title: "Optimal Binning and Weight of Evidence: A Practical Guide" author: "José Evandeilton Lopes" date: "`r Sys.Date()`" output: rmarkdown::html_vignette: toc: true toc_depth: 3 vignette: > %\VignetteIndexEntry{Optimal Binning and Weight of Evidence: A Practical Guide} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>", fig.width = 7.5, fig.height = 5, fig.align = "center", warning = FALSE, message = FALSE ) options(width = 100, digits = 4) ``` This vignette is the working reference for analysts who bin variables for a living. It covers what the package computes, how to read what it returns, and how to decide what to keep — using a benchmark dataset that ships with the package, so every result below is reproducible with no extra downloads. The companion vignette, [An Industrial Scorecard Pipeline](industrial-pipeline.html), takes the same machinery to a wide production base with out-of-time validation, a `recipes` pipeline and deployment artefacts. ```{r library} library(OptimalBinningWoE) ``` # Why bin A scorecard needs predictors that a credit committee, a regulator and a production database can all agree on. Binning buys three things a raw continuous variable does not give you: - **A bounded, monotone transformation.** Outliers and skew stop mattering; the relationship with risk becomes a step function you can plot on one page. - **Missing values as first-class citizens.** A missing income is a level, not an imputation problem. - **A linear-in-the-logit predictor.** After the WoE transform, logistic regression coefficients are directly interpretable and the model is a table. The cost is resolution: binning discards within-bin variation. The whole craft is choosing cut points that pay for that loss. # The quantities ## Weight of Evidence For bin $i$ with $n_{i,1}$ events and $n_{i,0}$ non-events, out of population totals $N_1$ and $N_0$: $$ \mathrm{WoE}_i \;=\; \ln\!\left(\frac{n_{i,1}/N_1}{n_{i,0}/N_0}\right) \;=\; \ln \theta_i + \ln\frac{N_0}{N_1}, \qquad \theta_i = \frac{n_{i,1}}{n_{i,0}} $$ WoE is the bin log-odds shifted by a constant. Positive means riskier than the portfolio average, negative means safer, and the scale is comparable across variables — which is why WoE, not the raw category, is what enters the model. ## Information Value $$ \mathrm{IV} \;=\; \sum_{i=1}^{k} \left(\frac{n_{i,1}}{N_1} - \frac{n_{i,0}}{N_0}\right) \mathrm{WoE}_i $$ This is the symmetrised Kullback–Leibler divergence (Jeffreys divergence) between the event and non-event distributions across bins. It is the standard single-number summary of predictive strength, and the package grades it with the bands from Siddiqi (2006): | IV | Band | Reading | |---|---|---| | $< 0.02$ | Unpredictive | drop | | $[0.02,\ 0.10)$ | Weak | keep only if it adds diversity | | $[0.10,\ 0.30)$ | Medium | the workhorses of a scorecard | | $[0.30,\ 0.50)$ | Strong | strong, verify it is not a proxy | | $\ge 0.50$ | Suspicious | almost always leakage | IV never decreases when you split a bin, so it cannot be used on its own to choose the number of bins — that is what the optimisation algorithms are for. ## Monotonicity Both quantities a scorecard cares about are strictly increasing functions of the bin odds $\theta_i$: $$ \pi_i = \frac{n_{i,1}}{n_{i,1}+n_{i,0}} = \frac{\theta_i}{1+\theta_i}, \qquad \mathrm{WoE}_i = \ln\theta_i + \ln\frac{N_0}{N_1} $$ So "the event rate is monotone across bins" and "the WoE is monotone across bins" are the same statement, and checking either one suffices. For a numerical variable this is a genuine constraint — the bin order is the interval order. For a nominal variable it is not: the bins can always be relabelled in WoE order, so monotonicity there is free. # The data The package bundles the Statlog (German Credit) benchmark: 1000 loan applications, 7 numerical and 13 categorical attributes, from the UCI Machine Learning Repository. ```{r data} german <- read.csv( gzfile(system.file("extdata", "germancredit.csv.gz", package = "OptimalBinningWoE")), stringsAsFactors = FALSE ) # credit_risk is 1 for a good customer; the event we model is default german$default <- 1L - german$credit_risk german$credit_risk <- NULL dim(german) table(german$default) ``` ```{r data-glimpse} str(german[, c("duration", "amount", "age", "purpose", "savings")]) ``` # One variable, end to end `obwoe()` is the single entry point. Point it at a data frame and a target and it detects each variable's type, dispatches the right algorithm and returns a fitted binning. ```{r single-fit} fit <- obwoe(german, target = "default", feature = "duration", min_bins = 3, max_bins = 6) fit ``` The per-variable result carries everything the transformation needs. ```{r single-result} res <- fit$results$duration data.frame( bin = res$bin, count = res$count, pos = res$count_pos, rate = round(res$count_pos / res$count, 4), woe = round(res$woe, 4), iv = round(res$iv, 4) ) ``` Two things to read here. The event rate climbs from 10% to 54% across the bins — longer loans default more, and the ordering never reverses. And the IV column shows where the information sits: the two extreme bins carry 73% of it between them, while the largest bin, holding 40% of the portfolio, contributes almost nothing. `cutpoints` is the authoritative boundary vector; bin labels are for humans. ```{r single-cutpoints} res$cutpoints ``` Intervals are half-open on the right, $(a,\,b]$, throughout the package: a loan of exactly 10 months falls in the bin that ends at 10. ```{r single-plot, fig.height=4.5} plot(fit, type = "woe", feature = "duration") ``` # Reading the gains table `obwoe_gains()` turns a fitted binning into the table a credit analyst actually reviews: cumulative capture, KS, lift. ```{r gains} gains <- obwoe_gains(fit, feature = "duration", sort_by = "woe") gains ``` The three headline numbers: - **KS** is the largest gap between the cumulative event and non-event distributions, $\max_i |F_1(i) - F_0(i)|$. It answers "at the best cut, how much better than random is this variable at separating the two populations". - **Gini** is $2\,\mathrm{AUC}-1$ on the binned score. - **Lift** in a bin is its event rate over the portfolio rate — the operational number for a cutoff policy. ```{r gains-plot, fig.height=6} op <- par(mfrow = c(2, 2), mar = c(4, 4, 2, 1)) plot(gains, type = "cumulative") plot(gains, type = "ks") plot(gains, type = "lift") plot(gains, type = "woe_iv") par(op) ``` # All variables at once Drop the `feature` argument and every column is binned. Numerical and categorical variables are routed automatically. ```{r multi-fit} model <- obwoe(german, target = "default", min_bins = 2, max_bins = 6) model ``` ```{r multi-summary} summary(model) ``` ## Screening with `obwoe_select()` `summary()` ranks; `obwoe_select()` decides. It applies the two criteria that govern admission in practice — IV strength and guaranteed ordering — and returns a verdict per variable, with the reason attached. ```{r select} sel <- obwoe_select(model) head(sel[, c("feature", "type", "n_bins", "total_iv", "iv_class", "ks", "gini", "monotonic", "quality", "selected")], 10) ``` Nothing is ever dropped from the output. A base with 500 candidates returns 500 rows, so the automatic verdict can be reviewed rather than trusted. ```{r select-rejected} sel[!sel$selected, c("feature", "total_iv", "iv_class", "reason")] ``` `status` — the checking-account balance — is the strongest variable in the file and is rejected for it. An IV of 0.67 sits in the *Suspicious* band, where single-variable strength is far more often a symptom of target leakage than of a genuinely dominant predictor. Raise `iv_max` to admit it deliberately: ```{r select-admit} admitted <- obwoe_select(model, iv_max = Inf) admitted[admitted$feature == "status", c("feature", "total_iv", "quality", "selected", "reason")] ``` The policy is configurable end to end. Each rule that fires is appended to `reason`, so a variable rejected on two counts reports both. ```{r select-policy} strict <- obwoe_select( model, iv_min = 0.02, # drop the Unpredictive band iv_max = 0.50, # drop the Suspicious band require_monotonic = "numeric", # ordering is intrinsic only for numerics monotonicity = "strict", # no ties between adjacent bins min_bin_pct = 0.05, # every bin holds at least 5% of the base allow_degenerate = FALSE, # no bin without events or without non-events top_n = 8, sort_by = "ks" ) table(strict$reason) ``` ## The bin-level view `detail = "full"` returns one row per variable *and* optimised bin, joined to the complete gains table of each. This is the object to hand to a model validation team. ```{r select-full} detail <- obwoe_select(model, detail = "full") dim(detail) detail[detail$feature == "savings", c("bin", "n_categories", "count", "pos_rate", "woe", "iv", "lift")] ``` # Choosing an algorithm The package ships 37 algorithms: 21 numerical, 16 categorical. They differ in what they optimise, not in what they return. | Family | Algorithms | Optimises | |---|---|---| | Information-theoretic | `mdlp`, `fast_mdlp`, `dmiv`, `ivb` | entropy or IV gain per split, with an MDL stopping rule | | Statistical merging | `cm`, `fetb`, `mob` | merges neighbours whose difference fails a $\chi^2$ or Fisher test | | Shape-constrained | `ir`, `mrblp`, `mblp`, `oslp` | best fit subject to a monotonicity constraint | | Exact optimisation | `dp`, `milp`, `sblp`, `bb` | global optimum of IV under bin-count and size constraints | | Metaheuristic | `sab`, `gmb`, `mba` | simulated annealing, greedy or agglomerative search | | Unsupervised | `ewb`, `kmb`, `ubsd`, `sketch` | equal width, k-means, standard deviation, streaming quantiles | Three questions settle the choice in practice. **Does the model face a regulator?** Then the WoE profile must be monotone, and a shape-constrained method (`ir`, `mrblp`, `mblp`) or `mob` earns its keep. `ir` runs the Pool Adjacent Violators algorithm and merges the bins it pools, so the bins it returns are monotone in their own observed event rate. **Is the relationship expected to be non-monotone?** Utilisation and age often are U-shaped. Use an information-theoretic method (`mdlp`, `dmiv`) or `jedi` and let the data pick the shape. **How large is the base?** `sketch` is designed for streaming and very large $n$; the exact optimisers (`milp`, `bb`) are the most expensive and are best kept for a shortlist. ```{r algo-list} algos <- obwoe_algorithms() table(numerical = algos$numerical, categorical = algos$categorical) ``` A comparison run costs a few lines. Here five methods on `amount`, a variable with no clean monotone relationship: ```{r algo-compare} compare <- function(alg) { f <- obwoe(german, target = "default", feature = "amount", algorithm = alg, min_bins = 2, max_bins = 6) s <- obwoe_select(f, require_monotonic = "none") data.frame(algorithm = alg, n_bins = s$n_bins, iv = round(s$total_iv, 4), ks = round(s$ks, 4), monotonic = s$monotonic) } do.call(rbind, lapply(c("jedi", "mdlp", "mob", "ir", "dp"), compare)) ``` `amount` is a hard case, and the spread is the point. `mdlp` and `dp` find a non-trivial partition worth `IV` ≈ 0.11; `jedi` and `mob` stop at two bins and find almost nothing. `ir` lands in between at three bins: enforcing monotonicity on a variable whose empirical rate violates it can only be done by pooling, so the shape constraint is paid for in resolution. Running four or five algorithms on the awkward variables and reading this table is cheap and usually decisive. # Applying the transformation `obwoe_apply()` scores a data frame with a fitted binning. It is the function to use on a validation sample, on a scoring run, or on next month's file. ```{r apply} scored <- obwoe_apply(german, model, keep_original = FALSE) head(scored[, c("default", "duration_bin", "duration_woe", "purpose_bin", "purpose_woe")], 4) ``` Values outside the training range fall into the extreme bins; categories never seen during training and `NA` receive `na_woe`, which defaults to 0 — the neutral value, since $\mathrm{WoE}=0$ means "portfolio average". ```{r apply-newdata} two <- obwoe(german, target = "default", feature = c("duration", "purpose"), max_bins = 6) new_data <- data.frame( duration = c(4, 10, 200, NA), purpose = c("car (new)", "unseen category", "education", NA) ) obwoe_apply(new_data, two, keep_original = TRUE) ``` A WoE-transformed frame is exactly what a logistic regression wants: ```{r apply-model} keep <- sel$feature[sel$selected] woe_cols <- paste0(keep, "_woe") train <- scored[, c("default", woe_cols)] glm_fit <- glm(default ~ ., data = train, family = binomial()) round(head(coef(summary(glm_fit)), 6), 4) ``` Coefficients on WoE predictors should all come out positive: a WoE of $+1$ means one log-odds more risk, so a negative coefficient signals a variable fighting the rest of the model, usually through correlation. # Exporting to SQL `obwoe_sql()` writes the same transformation as SQL, so the scoring can run in the warehouse instead of in R. ```{r sql} obwoe_sql( model, table = "risk.applications", features = c("duration", "purpose"), keep_columns = "application_id", dialect = "postgres" ) ``` Points worth knowing: the intervals reproduce `obwoe_apply()` exactly, cut points are written as the shortest decimal that parses back to the identical `double`, and every expression opens with an explicit `IS NULL` branch — because in SQL `NULL <= 5` is `NULL`, not `FALSE`, and a missing value would otherwise fall through to `ELSE`. Fourteen dialects are supported, along with `style = "view"`, `"cte"` and `"case"`. ```{r sql-case} obwoe_sql(model, features = "age", style = "case", comment = FALSE) ``` # Preprocessing `ob_preprocess()` handles the two pathologies that break binning before it starts: missing values and extreme outliers. ```{r preprocess} set.seed(2024) messy <- c(rnorm(800, 5000, 2000), rep(NA, 100), runif(100, -1e4, 5e4)) y <- rbinom(1000, 1, 0.3) prep <- ob_preprocess( feature = messy, target = y, outlier_method = "iqr", outlier_process = TRUE, preprocess = "both" ) prep$report ``` The cleaned vector is in `prep$preprocess$feature_preprocessed` and goes straight into `obwoe()`. Missing values become a sentinel that the binner keeps as its own bin, so the information in "this field was blank" is preserved rather than imputed away. # Where to go next - [An Industrial Scorecard Pipeline](industrial-pipeline.html) — a wide base, out-of-time validation, `recipes` and `step_obwoe()`, scorecard points, stability monitoring and deployment. - `?obwoe` for the fitting interface, `?obwoe_select` for the screening rules, `?obwoe_sql` for the SQL contract, `?obwoe_gains` for the gains table definitions. # References Siddiqi, N. (2006). *Credit Risk Scorecards: Developing and Implementing Intelligent Credit Scoring*. John Wiley & Sons. Thomas, L. C., Edelman, D. B., & Crook, J. N. (2002). *Credit Scoring and Its Applications*. SIAM. Navas-Palencia, G. (2020). Optimal binning: mathematical programming formulation. *arXiv:2001.08025*. Fayyad, U. M., & Irani, K. B. (1993). Multi-interval discretization of continuous-valued attributes for classification learning. *IJCAI*. Hofmann, H. (1994). *Statlog (German Credit Data)*. UCI Machine Learning Repository. ```{r session} sessionInfo() ```