The hardware and bandwidth for this mirror is donated by METANET, the Webhosting and Full Service-Cloud Provider.
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[@]metanet.ch.

Optimal Binning and Weight of Evidence: A Practical Guide

José Evandeilton Lopes

2026-08-22

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, takes the same machinery to a wide production base with out-of-time validation, a recipes pipeline and deployment artefacts.

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:

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.

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)
#> [1] 1000   21
table(german$default)
#> 
#>   0   1 
#> 700 300
str(german[, c("duration", "amount", "age", "purpose", "savings")])
#> 'data.frame':    1000 obs. of  5 variables:
#>  $ duration: int  6 48 12 42 24 36 24 36 12 30 ...
#>  $ amount  : int  1169 5951 2096 7882 4870 9055 2835 6948 3059 5234 ...
#>  $ age     : int  67 22 49 45 53 35 53 35 61 28 ...
#>  $ purpose : chr  "domestic appliances" "domestic appliances" "retraining" "radio/television" ...
#>  $ savings : chr  "unknown/no savings account" "... < 100 DM" "... < 100 DM" "... < 100 DM" ...

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.

fit <- obwoe(german, target = "default", feature = "duration",
             min_bins = 3, max_bins = 6)
fit
#> Optimal Binning Weight of Evidence
#> ===================================
#> 
#> Target: default ( binary )
#> Features processed: 1 
#> 
#> Results:  1  successful
#> 
#> Top features by IV:
#>   duration: IV = 0.2727 (6 bins, jedi)

The per-variable result carries everything the transformation needs.

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)
)
#>                     bin count pos   rate     woe     iv
#> 1       (-Inf;7.000000]    87   9 0.1034 -1.3122 0.1068
#> 2  (7.000000;10.000000]    84  18 0.2143 -0.4520 0.0155
#> 3 (10.000000;16.000000]   262  63 0.2405 -0.3029 0.0225
#> 4 (16.000000;33.000000]   397 128 0.3224  0.1046 0.0044
#> 5 (33.000000;39.000000]    88  38 0.4318  0.5729 0.0316
#> 6      (39.000000;+Inf]    82  44 0.5366  0.9939 0.0918

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.

res$cutpoints
#> [1]  7 10 16 33 39

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.

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.

gains <- obwoe_gains(fit, feature = "duration", sort_by = "woe")
gains
#> Gains Table: duration 
#> ================================================== 
#> 
#> Observations: 1000  |  Bins: 6
#> Total IV: 0.2727
#> 
#> Performance Metrics:
#>   KS Statistic: 19.00%
#>   Gini Coefficient: 26.65%
#>   AUC: 0.6332
#> 
#>                    bin count pos_rate     woe     iv cum_pos_pct    ks lift
#>       (39.000000;+Inf]    82   53.66%  0.9939 0.0918       14.7%  9.2% 1.79
#>  (33.000000;39.000000]    88   43.18%  0.5729 0.0316       27.3% 14.8% 1.44
#>  (16.000000;33.000000]   397   32.24%  0.1046 0.0044       70.0% 19.0% 1.07
#>  (10.000000;16.000000]   262   24.05% -0.3029 0.0225       91.0% 11.6% 0.80
#>   (7.000000;10.000000]    84   21.43% -0.4520 0.0155       97.0%  8.1% 0.71
#>        (-Inf;7.000000]    87   10.34% -1.3122 0.1068      100.0%  0.0% 0.34

The three headline numbers:

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.

model <- obwoe(german, target = "default", min_bins = 2, max_bins = 6)
model
#> Optimal Binning Weight of Evidence
#> ===================================
#> 
#> Target: default ( binary )
#> Features processed: 20 
#> 
#> Results:  20  successful
#> 
#> Top features by IV:
#>   status: IV = 0.6640 (4 bins, jedi)
#>   credit_history: IV = 0.2908 (5 bins, jedi)
#>   duration: IV = 0.2727 (6 bins, jedi)
#>   savings: IV = 0.1946 (5 bins, jedi)
#>   purpose: IV = 0.1667 (6 bins, jedi)
#>   ... and 15 more
summary(model)
#> Summary: Optimal Binning Weight of Evidence
#> ============================================
#> 
#> Target: default ( binary )
#> 
#> Aggregate Statistics:
#>   Features: 20 total, 20 successful, 0 errors
#>   Total IV: 2.1900
#>   Mean IV: 0.1095 (SD: 0.1572)
#>   Median IV: 0.0509
#>   IV Range: [0.0000, 0.6640]
#>   Mean Bins: 3.6
#> 
#> IV Classification (Siddiqi, 2006):
#>   Unpredictive: 6 features
#>   Weak        : 8 features
#>   Medium      : 5 features
#>   Suspicious  : 1 features
#> 
#> Feature Details:
#>                  feature        type n_bins  total_iv     iv_class
#>                   status categorical      4 6.640e-01   Suspicious
#>           credit_history categorical      5 2.908e-01       Medium
#>                 duration   numerical      6 2.727e-01       Medium
#>                  savings categorical      5 1.946e-01       Medium
#>                  purpose categorical      6 1.667e-01       Medium
#>                 property categorical      4 1.122e-01       Medium
#>                      age   numerical      5 8.868e-02         Weak
#>      employment_duration categorical      5 8.606e-02         Weak
#>                  housing categorical      3 8.298e-02         Weak
#>  other_installment_plans categorical      3 5.731e-02         Weak
#>      personal_status_sex categorical      4 4.447e-02         Weak
#>           foreign_worker categorical      2 4.294e-02         Weak
#>            other_debtors categorical      3 3.162e-02         Weak
#>         installment_rate   numerical      4 2.632e-02         Weak
#>           number_credits   numerical      2 1.008e-02 Unpredictive
#>                      job categorical      4 8.724e-03 Unpredictive
#>                telephone categorical      2 6.364e-03 Unpredictive
#>        present_residence   numerical      2 1.841e-03 Unpredictive
#>                   amount   numerical      2 1.552e-03 Unpredictive
#>            people_liable   numerical      2 4.339e-05 Unpredictive

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.

sel <- obwoe_select(model)
head(sel[, c("feature", "type", "n_bins", "total_iv", "iv_class",
             "ks", "gini", "monotonic", "quality", "selected")], 10)
#>                     feature        type n_bins total_iv iv_class      ks    gini monotonic
#>                      <char>      <char>  <int>    <num>   <fctr>   <num>   <num>    <lgcl>
#>  1:          credit_history categorical      5  0.29323   Medium 0.18048 0.25361      TRUE
#>  2:                duration   numerical      6  0.27274   Medium 0.19000 0.26647      TRUE
#>  3:                 savings categorical      5  0.19601   Medium 0.18667 0.19826     FALSE
#>  4:                 purpose categorical      6  0.16760   Medium 0.17905 0.22114      TRUE
#>  5:                property categorical      4  0.11264   Medium 0.11714 0.17066      TRUE
#>  6:                     age   numerical      5  0.08868     Weak 0.13143 0.16225      TRUE
#>  7:     employment_duration categorical      5  0.08643     Weak 0.11952 0.16164      TRUE
#>  8:                 housing categorical      3  0.08329     Weak 0.13286 0.13436      TRUE
#>  9: other_installment_plans categorical      3  0.05761     Weak 0.09619 0.09637      TRUE
#> 10:     personal_status_sex categorical      4  0.04467     Weak 0.10000 0.10485      TRUE
#>       quality selected
#>        <fctr>   <lgcl>
#>  1:      Good     TRUE
#>  2: Excellent     TRUE
#>  3:      Fair     TRUE
#>  4: Excellent     TRUE
#>  5: Excellent     TRUE
#>  6:      Fair     TRUE
#>  7:      Fair     TRUE
#>  8:      Fair     TRUE
#>  9:      Fair     TRUE
#> 10:      Fair     TRUE

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.

sel[!sel$selected, c("feature", "total_iv", "iv_class", "reason")]
#>              feature  total_iv     iv_class        reason
#>               <char>     <num>       <fctr>        <char>
#> 1:            status 6.660e-01   Suspicious IV_SUSPICIOUS
#> 2:    number_credits 1.008e-02 Unpredictive  IV_BELOW_MIN
#> 3:               job 8.763e-03 Unpredictive  IV_BELOW_MIN
#> 4:         telephone 6.378e-03 Unpredictive  IV_BELOW_MIN
#> 5: present_residence 1.841e-03 Unpredictive  IV_BELOW_MIN
#> 6:            amount 1.552e-03 Unpredictive  IV_BELOW_MIN
#> 7:     people_liable 4.339e-05 Unpredictive  IV_BELOW_MIN

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:

admitted <- obwoe_select(model, iv_max = Inf)
admitted[admitted$feature == "status",
         c("feature", "total_iv", "quality", "selected", "reason")]
#>    feature total_iv   quality selected reason
#>     <char>    <num>    <fctr>   <lgcl> <char>
#> 1:  status    0.666 Excellent     TRUE     OK

The policy is configurable end to end. Each rule that fires is appended to reason, so a variable rejected on two counts reports both.

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)
#> 
#>           IV_BELOW_MIN IV_BELOW_MIN;SMALL_BIN          IV_SUSPICIOUS                     OK 
#>                      5                      1                      1                      8 
#>              SMALL_BIN 
#>                      5

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.

detail <- obwoe_select(model, detail = "full")
dim(detail)
#> [1] 73 51

detail[detail$feature == "savings",
       c("bin", "n_categories", "count", "pos_rate", "woe", "iv", "lift")]
#>                           bin n_categories count pos_rate     woe      iv   lift
#>                        <char>        <int> <num>    <num>   <num>   <num>  <num>
#> 1:             ... >= 1000 DM            1    48   0.1250 -1.0986 0.04394 0.4167
#> 2: unknown/no savings account            1   183   0.1749 -0.7042 0.07680 0.5829
#> 3:       500 <= ... < 1000 DM            1    63   0.1746 -0.7061 0.02656 0.5820
#> 4:        100 <= ... < 500 DM            1   103   0.3301  0.1396 0.00206 1.1003
#> 5:               ... < 100 DM            1   603   0.3599  0.2714 0.04665 1.1996

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.

algos <- obwoe_algorithms()
table(numerical = algos$numerical, categorical = algos$categorical)
#>          categorical
#> numerical FALSE TRUE
#>     FALSE     0    7
#>     TRUE     12    9

A comparison run costs a few lines. Here five methods on amount, a variable with no clean monotone relationship:

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))
#>   algorithm n_bins     iv     ks monotonic
#> 1      jedi      2 0.0016 0.0162      TRUE
#> 2      mdlp      2 0.1056 0.1443      TRUE
#> 3       mob      2 0.0046 0.0143      TRUE
#> 4        ir      3 0.0934 0.1152      TRUE
#> 5        dp      4 0.1142 0.1443      TRUE

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.

scored <- obwoe_apply(german, model, keep_original = FALSE)
head(scored[, c("default", "duration_bin", "duration_woe",
                "purpose_bin", "purpose_woe")], 4)
#>   default          duration_bin duration_woe                      purpose_bin purpose_woe
#> 1       0       (-Inf;7.000000]      -1.3122              domestic appliances    -0.41020
#> 2       1      (39.000000;+Inf]       0.9939              domestic appliances    -0.41020
#> 3       0 (10.000000;16.000000]      -0.3029 furniture/equipment%;%retraining     0.58243
#> 4       0      (39.000000;+Inf]       0.9939                 radio/television     0.09435

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”.

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)
#>   duration         duration_bin duration_woe         purpose                  purpose_bin
#> 1        4      (-Inf;7.000000]      -1.3122       car (new)                    car (new)
#> 2       10 (7.000000;10.000000]      -0.4520 unseen category                         <NA>
#> 3      200     (39.000000;+Inf]       0.9939       education others%;%repairs%;%education
#> 4       NA                 <NA>       0.0000            <NA>                         <NA>
#>   purpose_woe
#> 1      0.3575
#> 2      0.0000
#> 3      0.2315
#> 4      0.0000

A WoE-transformed frame is exactly what a logistic regression wants:

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)
#>                    Estimate Std. Error z value Pr(>|z|)
#> (Intercept)         -0.8522     0.0802 -10.630    0.000
#> credit_history_woe   0.8408     0.1520   5.532    0.000
#> duration_woe         0.9435     0.1661   5.682    0.000
#> savings_woe          0.9785     0.1917   5.104    0.000
#> purpose_woe          1.0711     0.1993   5.373    0.000
#> property_woe         0.5125     0.2715   1.888    0.059

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.

obwoe_sql(
  model,
  table        = "risk.applications",
  features     = c("duration", "purpose"),
  keep_columns = "application_id",
  dialect      = "postgres"
)
#> -- ---------------------------------------------------------------
#> -- Weight of Evidence transformation
#> -- Generated by OptimalBinningWoE 1.13.3
#> -- Algorithm(s): jedi
#> -- Dialect: postgres
#> -- Interval convention: (lower, upper]  -- upper bound inclusive
#> -- Variables: 2
#> --
#> -- Variable                 Type          Bins        IV
#> --   duration               numerical        6   0.27274
#> --   purpose                categorical      6   0.16671
#> -- ---------------------------------------------------------------
#> SELECT
#> application_id,
#> CASE
#>     WHEN duration IS NULL THEN 0
#>     WHEN duration <= 7 THEN -1.3121863889661687
#>     WHEN duration > 7 AND duration <= 10 THEN -0.45198512374305744
#>     WHEN duration > 10 AND duration <= 16 THEN -0.3028722379457563
#>     WHEN duration > 16 AND duration <= 33 THEN 0.10461674470498177
#>     WHEN duration > 33 AND duration <= 39 THEN 0.5728610146854435
#>     WHEN duration > 39 THEN 0.993901334579079
#>     ELSE 0
#> END AS duration_woe,
#> CASE
#>     WHEN purpose IS NULL THEN 0
#>     WHEN purpose IN ('business', 'car (used)') THEN -0.8019940993019
#>     WHEN purpose = 'domestic appliances' THEN -0.4102018501730504
#>     WHEN purpose = 'radio/television' THEN 0.09434664779110719
#>     WHEN purpose IN ('others', 'repairs', 'education') THEN 0.23148312351021103
#>     WHEN purpose = 'car (new)' THEN 0.357522329106064
#>     WHEN purpose IN ('furniture/equipment', 'retraining') THEN 0.5824252658391619
#>     ELSE 0
#> END AS purpose_woe
#> FROM risk.applications;

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".

obwoe_sql(model, features = "age", style = "case", comment = FALSE)
#> -- age_woe
#> CASE
#>     WHEN age IS NULL THEN 0
#>     WHEN age <= 24 THEN 0.4808349100823084
#>     WHEN age > 24 AND age <= 26 THEN 0.2833624113072646
#>     WHEN age > 26 AND age <= 29 THEN 0.13116116160255956
#>     WHEN age > 29 AND age <= 34 THEN 0.05061000088641792
#>     WHEN age > 34 THEN -0.3112125698619751
#>     ELSE 0
#> END

Preprocessing

ob_preprocess() handles the two pathologies that break binning before it starts: missing values and extreme outliers.

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
#>   variable_type missing_count outlier_count
#> 1       numeric           100            73
#>                                                                                                       original_stats
#> 1 { min: -8995.151324, Q1: 3697.784305, median: 5113.928039, mean: 6625.179803, Q3: 6705.149551, max: 49477.654407 }
#>                                                                                                   preprocessed_stats
#> 1 { min: -2161.216958, Q1: 3042.732437, median: 4792.608602, mean: 4703.116058, Q3: 6517.601547, max: 11703.913644 }

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

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.

sessionInfo()
#> R version 4.6.1 (2026-06-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 26.04 LTS
#> 
#> Matrix products: default
#> BLAS:   /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3 
#> LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.32.so;  LAPACK version 3.12.0
#> 
#> locale:
#>  [1] LC_CTYPE=pt_BR.UTF-8       LC_NUMERIC=C               LC_TIME=pt_BR.UTF-8       
#>  [4] LC_COLLATE=C               LC_MONETARY=pt_BR.UTF-8    LC_MESSAGES=pt_BR.UTF-8   
#>  [7] LC_PAPER=pt_BR.UTF-8       LC_NAME=C                  LC_ADDRESS=C              
#> [10] LC_TELEPHONE=C             LC_MEASUREMENT=pt_BR.UTF-8 LC_IDENTIFICATION=C       
#> 
#> time zone: America/Bahia
#> tzcode source: system (glibc)
#> 
#> attached base packages:
#> [1] stats     graphics  grDevices utils     datasets  methods   base     
#> 
#> other attached packages:
#> [1] recipes_1.3.3            dplyr_1.2.1              OptimalBinningWoE_1.13.3
#> 
#> loaded via a namespace (and not attached):
#>  [1] utf8_1.2.6          future_1.75.0       sass_0.4.10         generics_0.1.4     
#>  [5] class_7.3-23        lattice_0.22-9      DiceDesign_1.10     listenv_1.0.0      
#>  [9] digest_0.6.39       magrittr_2.0.5      timechange_0.4.0    evaluate_1.0.5     
#> [13] grid_4.6.1          RColorBrewer_1.1-3  fastmap_1.2.0       jsonlite_2.0.0     
#> [17] Matrix_1.7-5        nnet_7.3-20         survival_3.8-6      purrr_1.2.2        
#> [21] scales_1.4.0        codetools_0.2-20    jquerylib_0.1.4     lava_1.9.2         
#> [25] cli_3.6.6           rlang_1.3.0         hardhat_1.4.3       parallelly_1.48.0  
#> [29] future.apply_1.20.2 splines_4.6.1       withr_3.0.3         cachem_1.1.0       
#> [33] dials_1.4.4         yaml_2.3.12         otel_0.2.0          prodlim_2026.03.11 
#> [37] parallel_4.6.1      tools_4.6.1         globals_0.19.1      vctrs_0.7.3        
#> [41] R6_2.6.1            rpart_4.1.27        lubridate_1.9.5     lifecycle_1.0.5    
#> [45] MASS_7.3-65         pkgconfig_2.0.3     pillar_1.11.1       bslib_0.11.0       
#> [49] glue_1.8.1          data.table_1.18.4   Rcpp_1.1.2          xfun_0.60          
#> [53] tibble_3.3.1        tidyselect_1.2.1    knitr_1.51          farver_2.1.2       
#> [57] htmltools_0.5.9     rmarkdown_2.31      ipred_0.9-15        timeDate_4052.112  
#> [61] gower_1.0.2         compiler_4.6.1

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.