Two sheets, sized for Letter landscape — print at 100 % scale, no margins. Download the PDF
OptimalBinningWoE logo
OptimalBinningWoE
Cheat sheet · sheet 1 of 2 · bin, screen, transform

Optimal binning and Weight of Evidence for credit scoring and risk modelling: 37 C++ binning implementations behind one R interface, from a raw feature store to a deployed scorecard.

version 1.14.0 install.packages("OptimalBinningWoE") library(OptimalBinningWoE)
Step 1Fit obwoe() Bin every column, numerical and categorical, in one call.
Step 2Screen obwoe_select() One verdict and one reason per candidate. Nothing is dropped silently.
Step 3Transform obwoe_apply() Apply the fitted bins to new data, or inside a recipe.
Step 4Deploy obwoe_sql() The same transform as SQL CASE, for 14 dialects.

What the transform measures

Binning replaces a raw column by its bins; WoE replaces each bin by the log-ratio of the event and non-event distributions it holds.

WoEi = ln(pi / qi)   ·   IV = ∑i (pi − qi) · WoEi

pi = share of the events that fall in bin i; qi = share of the non-events. A positive WoE is a riskier bin.

IV strength bands (Siddiqi, 2006)

Unpredictive< 0.02
Weak0.02 – 0.10
Medium0.10 – 0.30
Strong0.30 – 0.50
Suspicious≥ 0.50

An IV at or above 0.50 is far more often leakage than signal, which is why obwoe_select() rejects it by default. Medium is where the workhorses of a scorecard sit.

Fit step 1

obwoe(data, target, feature = NULL,
      min_bins = 2, max_bins = 7,
      algorithm = "auto",
      control = control.obwoe())
data
Data frame holding the target and the candidates.
target
Column name. Binary 0/1, or multinomial 0,1,2,…
feature
NULL bins every column but the target.
min_bins
max_bins
Bin-count envelope the optimiser must respect.
algorithm
One of the 28 names, or "auto": jedi for a binary target, jedi_mwoe for a multinomial one.
control
See control.obwoe() on sheet 2.

What comes back: an obwoe object

m <- obwoe(german, target = "default", max_bins = 6)

m$summary      # feature, type, algorithm, n_bins, total_iv
m$results$age  # bin, woe, iv, count, count_pos, count_neg,
               #   cutpoints, converged, iterations
m$target_type  # "binary" or "multinomial"

Look at it

summary(m)
Feature table, sort_by = "iv", decreasing.
print(m)
Compact fit report.
plot(m, type=)
"iv" ranking bar chart · "woe" profile of one feature · "bins" counts and event rate. Pass feature=, top_n = 15.

Screen step 2

Two criteria govern admission: Information Value strength and guaranteed rank ordering. Every candidate returns a row, so an automatic verdict stays reviewable.

sel <- obwoe_select(m,
    detail = "summary",     # or "full": one row per bin
    iv_min = 0.02, iv_max = 0.5,
    require_monotonic = "numeric",  # "all" | "none"
    monotonicity = "weak",  # or "strict"
    min_bins = 2, max_bins = Inf,
    min_bin_pct = 0, allow_degenerate = FALSE,
    top_n = NULL, sort_by = "iv", decreasing = TRUE)

Columns that carry the verdict

total_iv, iv_class
IV and its Siddiqi band.
ks, gini, auc
Discrimination of the binned score.
monotonic
monotonic_strict
Event-rate ordering, plus monotonic_direction, n_violations, spearman.
n_degenerate_bins
Bins with no events or no non-events.
quality
Excellent · Good · Fair · Rejected.
selected
The flag. reason and reason_desc say why.

Reason codes (joined by ";")

OKIV_BELOW_MIN IV_SUSPICIOUSNOT_MONOTONIC TOO_FEW_BINSTOO_MANY_BINS SMALL_BINDEGENERATE_BIN NOT_IN_TOP_NBINNING_ERROR
keep <- sel$feature[sel$selected]

Transform step 3

obwoe_apply(data, obj,
            suffix_bin = "_bin", suffix_woe = "_woe",
            keep_original = TRUE, na_woe = 0)

Adds <feature>_bin and <feature>_woe per binned column. na_woe is the fallback for an unseen category or an unmodelled missing value. Numerical intervals are half-open on the right, (a, b].

The manual path, one column at a time

ob_cutpoints_num()
Bin a numeric vector at cut points you supply.
ob_cutpoints_cat()
Same for a categorical vector.
ob_apply_woe_num()
Map a numeric vector through a fitted result; missing_values = c(-999).
ob_apply_woe_cat()
Categorical counterpart; missing_values = c("NA","Missing","").

Evaluate

g <- obwoe_gains(m, feature = "duration")
g <- obwoe_gains(scored, target = "default",
                 feature = "score_decile",
                 use_column = "direct", n_groups = 10)
use_column
"auto" · "bin" · "woe" · "direct".
sort_by
"id" (the algorithm's own order, default) · "woe" · "event_rate" · "bin".
n_groups
Cut a continuous score into that many quantile groups.

18 statistics per bin

countcount_pct pos_countneg_count pos_rateneg_rate pos_pctneg_pct oddslog_odds woeiv cum_pos_pctcum_neg_pct kslift capture_rate
g$metrics   # ks, gini, auc, total_iv, ks_bin
plot(g, type = "cumulative")  # "ks" | "lift" | "woe_iv"

Related helpers: obwoe_gains_score() for the bin-level engine behind a fitted result, obwoe_gains_variable() for any binned data frame plus a grouping column.

Prepare the raw column

ob_preprocess(feature, target,
   num_miss_value = -999, char_miss_value = "N/A",
   outlier_method = "iqr",   # "zscore" | "grubbs"
   outlier_process = FALSE,
   preprocess = "both",
   iqr_k = 1.5, zscore_threshold = 3,
   grubbs_alpha = 0.05)

Missing values become a value of their own rather than a dropped row, so the bin that holds them carries its own WoE and stays visible in the model document.

ob_check_distincts(x, target)  # cardinality and
                              # separation warnings

Runnable from a clean session

The Statlog (German Credit) benchmark ships with the package.

german <- read.csv(gzfile(system.file(
  "extdata", "germancredit.csv.gz",
  package = "OptimalBinningWoE")),
  stringsAsFactors = FALSE)

german$default <- 1L - german$credit_risk
german$credit_risk <- NULL

m   <- obwoe(german, "default", max_bins = 6)
sel <- obwoe_select(m)
out <- obwoe_apply(german, m)
OptimalBinningWoE 1.14.0 · MIT · José Evandeilton Lopes evandeilton.github.io/OptimalBinningWoE Sheet 1 of 2 — continues with algorithms, scorecards and SQL
Choose, score, deploy
Cheat sheet · sheet 2 of 2 · algorithms, scorecard, SQL

28 algorithm names cover 37 implementations — 21 accept a numerical feature, 16 a categorical one, 9 both. Every one is callable on its own, and the whole pipeline is callable as a single function.

C++ via Rcpp obwoe_algorithms() vignette("introduction")

The 28 algorithms Numerical · Categorical

FamilyNameN COptimises
Information-
theoretic
jediNCJoint entropy-driven intervals (default)
jedi_mwoeNCJEDI for a multinomial target
mdlpN·Fayyad–Irani MDL stopping rule
fast_mdlpN·MDLP with a monotonicity constraint
dmivNCDivergence measures (Zeng, 2013)
ivb·CIV by dynamic programming
Statistical
merging
cmNCEnhanced ChiMerge, χ² on neighbours
fetbNCFisher's exact test on neighbours
mobNCMonotonic optimal binning
Shape-
constrained
irN·Isotonic regression (PAVA)
mrblpN·Monotonic risk + likelihood-ratio pre-bins
mblpN·Monotonic binning by linear programming
oslpN·Optimal supervised partitioning
ldbN·Local density binning
lpdbN·Local polynomial density binning
gmb·CGreedy merge under monotonicity
Exact
optimisation
dpNCDynamic programming, global optimum
bbN·Branch and bound
milp·CMixed-integer formulation
sblp·CSequential bounded partitioning
Search &
metaheuristic
udtNCEntropy-based tree partitioning
sab·CSimulated annealing
mba·CAgglomerative monotonic merging
swb·CSliding window over ordered levels
Unsupervised
& streaming
sketchNCStreaming quantile sketch, sketch_k
ewbN·Equal width, then IV refinement
kmbN·K-means initialisation
ubsdN·Standard-deviation cut points

Picking one

  • Must the WoE be monotone? Numerical: ir, mrblp, mblp, mob. Categorical: gmb, mob, mba.
  • Millions of rows, or a stream? sketch, then ewb or kmb.
  • Need the provable optimum? dp and bb for numerical, milp and sblp for categorical.
  • High cardinality with rare levels? sketch, mba, swb.
  • None of the above: keep algorithm = "auto".

Points and scaling

sc <- obwoe_scale(pdo = 20, score_ref = 600,
                 odds_ref = 50,
                 direction = "higher_is_safer")
s  <- obwoe_score(link, sc, round = TRUE)
Score = Offset − Factor · η,   Factor = PDO / ln 2

The models predict the log-odds of the event, so the sign is negative: a high WoE is a risky bin and must score fewer points. Two identities pin the scale — a case at odds_ref scores exactly score_ref, and doubling the good-to-bad odds adds exactly pdo points, everywhere.

Tune the binning

control.obwoe(
   bin_cutoff = 0.05,            # min share per bin
   max_n_prebins = 20,           # pre-merge ceiling
   convergence_threshold = 1e-6,
   max_iterations = 1000,
   bin_separator = "%;%",        # joins merged levels
   verbose = FALSE)

max_n_prebins is a modelling decision, not a detail. Pre-binning runs before the optimiser sees the data, so a heavy tail can be smeared into one quantile cell and lost with no warning. Leave the default, and tune it against held-out IV for long-tailed continuous predictors only.

Call one algorithm directly

r <- ob_numerical_ir(german$duration, y,
        min_bins = 3, max_bins = 5,
        bin_cutoff = 0.05, max_n_prebins = 20,
        auto_monotonicity = TRUE)

r <- ob_categorical_jedi(german$purpose, y,
        bin_separator = "%;%")

Every name in the table is exported as ob_numerical_<name>(), ob_categorical_<name>(), or both. They return the same bin, woe, iv and count vectors that obwoe() stores per feature.

The whole pipeline in one call

card <- obwoe_scorecard(german,
    target = "default", split = 0.7,
    validation = NULL,      # an out-of-time frame
    exclude = NULL, feature = NULL,
    binning = list(max_bins = 6),
    screening = list(iv_max = 0.5),
    engine = "glm",         # "obwoe" | "glmnet" | list()
    control = control.obwoe_scorecard(),
    file = "scorecard.xlsx", seed = 42)

Split, bin, screen by IV and correlation, fit, scale to points, and write the model document. Three properties are enforced rather than assumed:

  • Binning is fitted on the training rows only, so the hold-out is not inflated by supervised leakage.
  • A variable whose WoE coefficient comes out negative is dropped and the model refitted — the WoE already carries the direction.
  • The generated points SQL reproduces the R card score exactly, unseen categories included.

Reading the object

card$points
The fixed points table, one row per variable and bin.
card$screening
obwoe_select() plus a stage column: in_model, sign_rejected, corr_pruned, constant_woe, screened_out.
card$samples
Per sample: scores, gains table, headline metrics.
card$stability
PSI for the score and for every variable in the model.
card$warnings
Everything the pipeline wants a human to read.
predict(card, new_data, type = "score")
# "card" | "link" | "prob" | "woe"

Redundancy and stability

obcorr(df, method = "all", threads = 0)
pearsonspearman kendallhoeffding distancebiweight pbendrobust alternative
p <- obwoe_prune(woe_df, ranking = keep,
                  cutoff = 0.7, method = "pearson")
p$keep; p$dropped   # variable, correlated_with, correlation

Prune in the WoE space — that is the space the model actually sees.

s <- obwoe_psi(base, compare, n_groups = 10)
s$psi; s$table; s$flag
PSIFlagReading
< 0.10stableNo action
0.10 – 0.25watchMonitor the shift
> 0.25actPopulation has moved

A band populated in one vintage and empty in the other reports Inf rather than being smoothed away — a segment that has vanished is exactly what monitoring exists to catch.

tidymodels

rec <- recipe(default ~ ., data = german) |>
  step_obwoe(all_predictors(), outcome = "default",
             algorithm = "auto",
             min_bins = 2, max_bins = 10,
             bin_cutoff = 0.05, output = "woe",
             na_woe = 0)

prep(), bake(), tidy() and required_pkgs() are all implemented. Four parameters are tunable, each with a dials constructor:

tune()dialsRange
algorithmobwoe_algorithm()28 names
min_binsobwoe_min_bins()2 – 5
max_binsobwoe_max_bins()5 – 20
bin_cutoffobwoe_bin_cutoff()0.01 – 0.10

Deploy to SQL step 4

obwoe_sql(m, table = "risk.applications",
   features = keep,
   output = "woe",       # "bin" | "index" | "both"
   style = "select",     # "case" | "cte" | "view"
   dialect = "postgres",
   na_value = 0, explicit_bounds = TRUE,
   quote_identifiers = "auto", file = NULL)
ansipostgresmysql mariadbsqlserveroracle sparkhivedatabricks bigquerysnowflakeredshift duckdbsqlite
CASE WHEN duration IS NULL THEN 0
     WHEN duration <= 7 THEN -1.31218638896617
     WHEN duration > 7 AND duration <= 10 THEN -0.4519851
     ... ELSE 0 END AS duration_woe

Every expression opens with an explicit IS NULL branch, because NULL <= 5 is NULL in SQL, not FALSE.