OptimalBinningWoE 1.13.6
Two scorecard reporting defects fixed at the origin (2026-09-03)
Both were long known and patched downstream by fisiv; they now live where the defect is.
.ob_score_metrics()ignored the scaling direction. The AUC was always mirrored (1 - AUC_event), which is right for ahigher_is_saferscore and reports the complement forhigher_is_riskier– AUC 0.05 next to a KS of 0.78 in the01_Model_Summarysheet.obwoe_scorecard()now passesscaling$directionand the mirror applies only when the score is high-for-safe. Gini follows. KS was never affected.07_Score_Gains$mean_scorewas hard-codedNA.obwoe_report()now fills it from the frozen training bands (newband_breakselement of theobwoe_scorecardobject; recomputed from the training score for objects saved by earlier versions). Rows are matched by band label, so a label that does not reproduce yieldsNArather than a shifted number.
OptimalBinningWoE 1.13.5
The SQL test evaluator now reads literals the way a database does (2026-08-31)
Single-defect release, and the defect is in the test suite rather than in the package. The CRAN checks for 1.13.4 were OK on all thirteen regular flavours; the additional noLD check – x86_64 Linux with R-devel configured --disable-long-double – reported two test failures.
-
tests/testthat/test-obwoe-sql.Rfailed onnoLDat the boundary check “boundaries hold for cut points that need full double precision”: the observation sitting exactly on the first cut point was reported in bin 2 rather than bin 1, twice.The generated SQL was correct.
obwoe_sql()wrote the cut point-1.3003598122031599with all seventeen significant digits it needs, and any conforming reader – every database this SQL targets included – parses that string back to exactly the fitted double.The fault was in the miniature CASE evaluator the tests use to score the generated SQL. It read each literal back with
as.numeric(), andas.numeric()is not a conforming reader on a build without long double. R accumulates the decimal digits of a string inLDOUBLE; where that is no wider than adouble, a literal carrying more than fifteen digits can come back a few ULP from the double it names. There-1.3003598122031599was read one ULP low, so the observation equal to the cut point failedx <= -1.3003598122031599and fell through to the next branch. The evaluator, not the SQL, put it in the wrong bin.The evaluator now decodes a literal without trusting
as.numeric()with anything it cannot be shown to parse exactly. Digits below 2^53 with at most 22 decimals are read asm / 10^nd, one correctly rounded division of two exactly representable doubles. Otherwisesprintf()– which hands the rendering to the C library, correctly rounded on every platform – confirmsas.numeric()’s answer by printing it back, and where it does not print back, the neighbouring doubles are bracketed around the literal by exact decimal comparison and the nearer of the two wins.This also removes a smaller inaccuracy in the other direction: where
LDOUBLEis 80 bits, as on x86_64,as.numeric()can double-round a sixteen-digit literal and disagree with the database over a literal the package considers valid. A regression test was added for the evaluator’s reader itself, over 3,600 values spanning 1e-9 to 1e9: the literal
obwoe_sql()writes must decode back to the identical double. The boundary tests are only as trustworthy as that reader, and onnoLDthis is what fails first if it regresses.
No package code changed, so no generated SQL, no fitted binning and no API changed with this release.
OptimalBinningWoE 1.13.4
CRAN release: 2026-08-26
SQL literals on platforms without an extended long double (2026-08-25)
Single-defect release. The CRAN checks for 1.13.3 failed on r-release-macos-arm64 and r-oldrel-macos-arm64 only; every other flavour, macOS on x86_64 included, was clean.
-
obwoe_sql()could write a cut point one bit away from the fitted value on Apple silicon, moving any observation that sits exactly on that boundary into the next bin. The generated SQL was internally consistent and gave no warning; it simply scored those rows with the wrong bin’s Weight of Evidence.The literal writer searched for the shortest decimal string that parses back to the identical double, checking each candidate with
as.numeric()before emitting it. That check is not sound on aarch64. R accumulates the decimal digits of a string inLDOUBLE, which on aarch64 macOS is no wider than adouble, so beyond about fifteen digitsas.numeric()can land one bit from the nearest double – rejecting a literal that does round trip and accepting one that does not. With every candidate rejected, the search fell through to a fallback that wrote fewer digits than the value needs: the cut point-0.13964785691628961came out as-0.13964785691629, which is the smaller number, so an observation equal to the cut point failedx <= -0.13964785691629and fell one bin up.The search now decides for itself. A candidate with
nddecimals is checked asm / 10^nd, wheremis its digits read as an integer: whilemis below 2^53 andndis at most 22, both operands are exact, so IEEE 754 gives the correctly rounded quotient – the nearest double to the candidate – on every platform R runs on. Candidates outside those bounds are not judged at all; the value falls back to seventeen significant digits, the width that identifies a double uniquely.as.numeric()still has to agree before a candidate is accepted, not to decide the question but so that a literal R itself reads back as a different double is never written into an audit artifact.Cut points that are exact in binary – integers, halves, quarters – still read short. About five per cent of values now carry one more digit than they did in 1.13.3, being those the new check declines to judge.
digitsnow means decimal places, as documented. It rounded to the requested number of places and then formatted the result with R’s default seven significant digits, sodigits = 8on1234.5678901234emitted1234.568rather than1234.56789012. Values needing at most seven significant digits – the common case, and the one the test suite covered – were unaffected.
macos-latest is back in the GitHub Actions check matrix. It had been disabled while infer had no ARM64 binary; that binary is back, and the architecture is the one this release repairs.
Three regression tests were added to tests/testthat/test-obwoe-sql.R: a bulk round trip over 3,500 values, a boundary check on cut points taken from continuous data, and a diagnostic that spells out a one-bit disagreement between the SQL and obwoe_apply(), which waldo reports only as “don’t know how to show the difference”. The existing boundary test used integer-valued cut points, which any literal writer renders exactly, and so could not catch this.
OptimalBinningWoE 1.13.3
CRAN release: 2026-08-23
Audit fixes (2026-08-21)
Bug-fix release from a third internal audit, covering the WoE/IV return contract, the converged flag and the max_bins constraint across all 37 algorithm/type combinations. Every item below was reproduced before the fix and re-verified after it. The regression suite gained tests/testthat/test-audit-regressions.R, which fails on 1.13.2.
Behavior changes (read before upgrading)
-
mdlp(numerical),gmbandfetb(categorical) now honourmax_bins. All three stopped on their own criterion and never re-checked the cap, somax_bins = 5returned 18, 11 and 10 bins respectively – silently, with no warning. All three roxygen blocks already documentedmax_binsas a hard constraint, so the code was wrong, not the documentation.These three algorithms now produce different bins. For
mdlpthe returned partition is no longer the unconstrained MDL optimum when the cap binds: merging continues past the MDL stopping point, each step taking the pair with the smallest increase in MDL cost.min_binsis never violated to satisfymax_bins. obwoe_apply()now refuses multiclass models instead of scoring them wrongly. A multinomial fit carries a bins x classes WoE matrix; the lookup linear-indexed it column-major and returned class 1’s WoE for every row, discarding the other classes with no error and no warning. It now stops with an actionable message. The per-class matrix is still in$resultsfor callers that want to handle it themselves.dmivnow reports a numerictotal_ivwheresummary$total_ivwas previouslyNAfor every feature. Code that tested for thatNAwill see a number.
Corrected values
-
The categorical
sketchengine computed every WoE against the wrong marginal. It passed(total_neg, total_pos)to helpers whose signature is(total_pos, total_neg). On a random 8-category feature with no real signal, the reported IV was 10.4021 against a true value of 0.0043 – wrong by three orders of magnitude, and wrong in the direction that makes a useless variable look like the strongest predictor in the model. WoE deviated fromlog((pos_i/TP)/(neg_i/TN))by up to 2.7721; it is now within 0.0002.The identical defect was fixed in the numerical twin some releases ago and never replicated here. A numerical-vs-categorical parity test now covers every algorithm that has both variants, so this class of divergence cannot recur silently.
dmivreturnsivandtotal_ivalongside the divergence measures it already reported. IV is computed from the smoothed class distributions rather than fromwoe, because the defaultbin_method = "woe1"is Zeng’s log-oddsln((pos + 0.5)/(neg + 0.5)), which differs from standard WoE by the constantln(TP/TN); deriving IV from it would be wrong.cm(categorical) exposestotal_ivat top level, like the other 15 categorical engines, instead of only insidemetadata.metadatais unchanged, so existing callers keep working.
Fixed crashes
sab(categorical) no longer aborts withunordered_map::atwhen a category contains zero events – a routine situation in credit data. The positive-count map only gained a key for categories with at least one event, but was read with.at()in seven places.obwoe_scorecard()and the cutoff table now reject a missing target with a clear message naming the sample and the count, instead of failing deep inside withmissing value where TRUE/FALSE needed. The development frame was already checked; samples passed throughvalidation =were not. TheNAis rejected at the boundary rather than swept up withna.rm = TRUE, which would have silently charged every unknown-outcome row to the non-events and corrupted KS and AUC.
converged now means the same thing everywhere
The flag was effectively inverted in several engines: initialised false and set true only on a degenerate shortcut or an in-loop tolerance test, while the normal successful exit – reaching the bin-count target – set nothing. So ordinary well-binned features reported FALSE and only degenerate ones reported TRUE.
-
dmivandbb(numerical) reportconvergedon reaching the bin-count target, matching the categoricaldmiv, which already did. -
ir(numerical) reportsconvergedfor the exact binning it produces when a feature has two or fewer distinct values. Every binary 0/1 feature previously reportedFALSEdespite a correct result. -
dp,gmb,mba,sketch,cmanddmiv(categorical) reportconvergedon every successful termination path, including the fast paths that bypassed the flag entirely.
The intended contract is now documented in src/common/bin_structures.h: converged == true means the algorithm reached a valid stopping state (tolerance met, monotonicity achieved, or the bin-count target reached); false means it exhausted max_iterations.
Three algorithms were quadratic in the number of rows
lpdb, ldb and numerical udt scaled as n^2.00, n^2.00 and n^2.30. A single variable with 10^6 rows would have taken lpdb roughly 72 minutes; measured against jedi at the same size they were 2,772x the median algorithm’s cost. Nothing warned about it.
Two distinct causes, neither of them inherent to the methods:
ldbandlpdbestimated the density with a naive double loop, evaluating the Gaussian kernel of every observation against every other one. The same defect had been written twice, in two files, which is how it survived. It is replaced by the standard linear-binning estimator – the one R’s owndensity()uses – which now lives once insrc/common/optimal_binning_common.hso the two cannot diverge again.udtrescanned every observation once per candidate split, allocating two vectors and recomputing the parent entropy each time, giving O(u x n). Information gain depends only on integer counts, so a single sweep carrying running totals produces the identical value.
Measured at n = 50,000, against a build of the previous revision:
| algorithm | before | after | speedup |
|---|---|---|---|
ldb |
10.740s | 0.010s | 1074x |
lpdb |
10.725s | 0.011s | 975x |
udt |
7.354s | 0.019s | 387x |
All three now scale linearly and land within a factor of two of jedi, the package default. At n = 400,000 they take 0.085s, 0.087s and 0.170s against jedi’s 0.103s – sizes the previous code could not reach at all.
Results. udt and ldb are bit-identical to the previous revision: udt by construction, and ldb’s local-minimum search resolves the grid estimate to the same cut points. Both are pinned by a new regression test.
lpdb changes. It differentiates the density twice to find inflection points, and finite differences taken between adjacent observations are not the same thing as finite differences on a properly sampled curve. Its critical points are now located on the estimation grid. On German Credit the partitions generally improve – duration goes from 2 bins and IV 0.0923 to 5 bins and IV 0.2635, age from 2 bins and 0.0628 to 4 bins and 0.0781 – and no variable tested got materially worse. Anyone with a fitted lpdb model should expect different cut points.
Also removed OBN_LPDB::local_polynomial_density(), which no longer had a caller and never did local polynomial regression despite its name.
Fixed: categorical dmiv ignored max_bins
-
ob_categorical_dmiv()returnedL - 1bins for anL-category feature, whatevermax_binswas set to. The merge loop compared the cost of the best available merge againstconvergence_thresholdand broke out when it had barely moved. With many similarly sized categories the second-cheapest merge costs exactly what the cheapest one did, so the test fired on the second iteration, after a single merge, and nothing re-imposed the cap afterwards. Reported asconverged = TRUE, with no warning.Reproduced at n = 60,000 with
max_binsof 3, 5 and 8 alike: 40 levels returned 31 bins, 60 returned 50, 120 returned 119 and 300 returned 299. All ninedivergence_methodchoices and bothbin_methodchoices were affected. The roxygen already documentedmax_binsas a hard constraint, so the code was wrong, not the documentation.The loop now records the convergence and keeps merging by the same criterion — the pair with the lowest divergence, i.e. the most similar pair — until the cap is met (
src/OBC_DMIV_v5.cpp). This is the remedy already applied tofetb, whose loop had the same shape. The merge ordering is unchanged, andmin_binsis never violated to satisfymax_bins.dmivproduces different bins wherever the cap used to be abandoned; on inputs where it was reached anyway, results are byte-identical to the previous release — verified across all 16 categorical engines and 13 variables of the bundled German Credit data, 208 combinations, with the RNG stream fixed so the stochastic engines are comparable. The numericaldmivnever had the defect and is untouched.A new regression test asserts that every categorical engine honours
max_binson a high-cardinality feature, at two values ofbin_cutoffand two ofmax_bins. ?ob_categorical_dmivdescribedconvergence_thresholdas stopping the merging. It records convergence;max_binsis a hard constraint and merging continues until the bin count meets it.find_most_similar_bins()now seeds its best pair with the first mergeable one instead of{0, 0}. The search only replaces that seed on a strictly smaller divergence, so a distance matrix that was entirelydouble::max– or that held aNaN, against which every comparison is false – would have returned a pair naming the same bin twice, and merging a bin with itself then erasing the duplicate would have dropped its observations. No input reaching that state was found, so this closes a defensive gap rather than a demonstrated defect.
Fixed: categorical mba read past the end of its bin vector
-
ob_categorical_mba()performed an out-of-range read while reducing the pre-bins. The list of candidate bins to merge is built before any merging starts, and every merge erases a bin, so an index taken later in that list could point past the end of the shrunken vector. The existing guard only clamped the indices passed to the merge itself, not the read that selects a merge partner.This is undefined behaviour, not a wrong number: under a checked standard library it aborts the R session, and without one it reads foreign memory and carries on. Reproduced with 60 categories,
max_n_prebins = 20andn = 60,000, at either the defaultbin_cutoffor a smaller one.Stale indices are now skipped. The guard fires only where the previous code was already out of range, so results are byte-identical on every input that worked before — verified across all 16 categorical engines and 13 variables of the bundled German Credit data.
mbarejoins the regression test that asserts every categorical engine’s bins account for every observation.
Fixed: categorical ivb and gmb dropped observations
-
ob_categorical_ivb()andob_categorical_gmb()discarded the categories that did not fit withinmax_n_prebinsinstead of pooling them, so those observations left the binning entirely. The vector of bins was resized, and everything past the cap was destroyed along with its counts. Nothing signalled it: no warning,error = FALSE,converged = TRUE, and atotal_ivreported as if it described the whole sample.Default settings hide the defect —
bin_cutoff = 0.05lets at most 20 categories survive the rare-category merge, which is exactly the defaultmax_n_prebins— but a smaller cutoff reaches it. With 60 levels andbin_cutoff = 0.005, both engines lost 39,306 of 60,000 rows (65.5%);dpandjediaccounted for every row on the same input.Both now fold the excess into the smallest retained bin (
src/OBC_IVB_v5.cpp,src/OBC_GMB_v5.cpp). Which categories are kept as separate identities is unchanged — still themax_n_prebinsmost frequent — so results on the path where the cap never bound are byte-identical to the previous release: verified across all 16 categorical engines and 13 variables of the bundled German Credit data, 208 combinations, with the RNG stream fixed so the stochastic engines are comparable.A new regression test asserts that every categorical engine’s bins account for every observation.
Documentation
-
New vignette,
Algorithm Reference: the 37 Binning Engines. Reference documentation for all 28 algorithms across their 37 algorithm/feature-type combinations, written from a reading of the C++ implementation of every engine and a check of that reading against the literature each one invokes. Entries are organised by the mechanism the code actually uses — exact dynamic programming, recursive entropy partitioning, statistical merging, isotonic regression, density estimation, divergence, streaming, metaheuristic, and greedy IV merging — which groups the engines differently from their names.Two rules governed it: a claim appears only if it traces to a specific line of the shipped source or to a publication verified to exist, and the name is never taken as evidence of the mechanism. Where neither could be established, the vignette says so — it closes with a section listing what could not be verified, including the provenance of the Information Value interpretation bands and whether Kerber’s ChiMerge uses the continuity correction this package implements.
Substantive findings it documents:
obwoe()does not forward algorithm-specific parameters and drops them silently, demonstrated live;mdlpdoes not implement the Fayyad–Irani criterion it cites whilefast_mdlpdoes;fetbcomputes a hypergeometric point probability, not a Fisher exact-test p-value;bbneither branches nor bounds and numericaldpbuilds no dynamic-programming table, while the package’s only real DP sits insidesketchbehind ann <= 50threshold; the numericalsketchdeparts from the KLL construction it cites in both compactor capacity and compaction rule, so the quoted error bound does not transfer; anddmiv’s defaultbin_method = "woe1"is a per-bin log-odds rather than standard WoE.It also records parameters that are accepted and never read (
polynomial_degreeinlpdb;max_n_prebinsinfast_mdlp, numericalsketch, categoricalfetbandsab),convergence_thresholdbeing inert in five numerical engines, and three citation errors in the shipped documentation. -
max_n_prebinsis documented as the modelling decision it is. For numerical features, pre-binning runs before any algorithm sees the data, so the default of 20 quantile cells can smear a heavy tail and lose the signal in it before optimization begins – silently, withconverged = TRUE. Benchmarks on two open datasets (76,020 x 369 and 590,540 x 454, five-fold held-out IV) move the median held-out IV of heavy-tailed numerical predictors by +129% and +39% when the parameter is raised from 20 to 200, with the bin count essentially unchanged.The default is deliberately not changed. The same experiment shows the effect runs both ways: on the larger benchmark, raising it cost 15% of held-out IV on the twenty-five strongest predictors while inflating IV on weak ones, and no cheap rule separated the two cases.
?control.obwoenow says so, and recommends per-variable tuning against held-out data. min_binsis documented as frequently binding. It reads as a safety floor but often sets the partition, because several algorithms stop merging on their own criterion well beforemax_bins. On German Creditamountwithmax_bins = 5, the default returns two bins and an IV of 0.0016 whilemin_bins = 4returns 0.0824 – same data, same algorithm.Corrected the 1.13.0 entry on binary size. It described
-Os,-fvisibility=hidden,-ffunction-sections,-Wl,--gc-sectionsand acleanupscript, none of which are in the package: the visibility and section flags hid Rcpp symbols and broke the build on every platform and were reverted inb74e95b. The entry is corrected rather than deleted so the flags are not reinstated by someone reading the old claim.
Smaller items
An unmeasured IV is no longer reported as an IV of zero. With no finite IV anywhere,
summary()printedTotal IV: 0.0000andIV Range: [Inf, -Inf], asserting “no predictive power” about features that were never measured;print()on astep_obwoerecipe printedtotal IV=0.0000. Both reportNAwith a note.obwoe_gains()failed withattempt to select less than one element in get1indexandplot(type = "iv")withneed finite 'xlim' values; both now say what is actually wrong.sketch(numerical) returns abinlabel for a constant feature, sosummary$n_binsis1instead ofNA.udt(numerical) keeps WoE and IV finite withlaplace_smoothing = 0. A bin holding no events gavewoe = -Infandiv = Inf; both distributions are now floored with the package-wide epsilon, so the parameter stays usable. Reachable only throughob_numerical_udt()directly –obwoe()never forwards the argument.
OptimalBinningWoE 1.13.2
The fitted object now records how it was fitted
obwoe()returns thecontrolit used, alongside the effectivemin_bins,max_binsandalgorithm.callwas never a substitute: it records only the arguments the caller typed, never the defaults that actually applied, so a saved model could not say what produced it.-
A custom
bin_separatornow works end to end. Everything that has to split grouped categories out of a bin label reads the separator from the model instead of assuming the package default:obwoe_apply(), the points table, the points SQL and the WoE SQL emitted into the workbook.This was a silent corruption, not a cosmetic gap. Fitting with
control.obwoe(bin_separator = "||")and applying the model back to its own training data put 754 of 900 rows on thena_woefallback, because the labels were split on"%;%"and never matched. The generated SQL was worse:a||e||ccame out asg IN ('a', '|', '|', 'e', '|', '|', 'c')– broken SQL, no warning. Both are exact now. Models saved by earlier versions keep working. They carry no
controlelement, so the separator falls back to the package default rather than erroring on a missing field.
Nothing changes for the default configuration: "%;%" remains the default everywhere, so only the custom-separator path – which was broken – behaves differently.
OptimalBinningWoE 1.13.1
Audit fixes (2026-08-20)
Bug-fix release addressing an internal code audit of 1.13.0. Every item below either changes a computed value that was previously wrong, or removes an API surface the author never intended to publish. See the pull request for the full item-by-item breakdown.
API changes
fit_logistic_regression()is no longer exported. It is still used internally (renamed to.ob_fit_logistic_regression()) to implementengine = "obwoe"inobwoe_scorecard(), which is unaffected.ob_gains_table()andob_gains_table_feature()are renamed toobwoe_gains_score()andobwoe_gains_variable()respectively, to sit alongside the rest of theobwoe_*family. Behavior is unchanged; only the names moved.obwoe_gains()(the higher-level, plot-producing function) is a different function and was not renamed.
Behavior changes (read before upgrading)
obwoe_apply()now routes a missing categorical value to the fitted “missing” bin’s WoE when the binning built one, instead of always usingna_woe. Previouslyobwoe_apply()ignored any missing-value bin learned during training and always returnedna_woeforNA, while the generated deployment SQL (obwoe_sql(),null_to_na_bin = TRUEby default) routedIS NULLto that bin’s WoE — so R and the SQL scored the same missing value differently.na_woeis now only a fallback for variables where no missing-value bin exists. If you fit a model with realNAs in a categorical predictor, the WoEobwoe_apply()/predict()return forNAmay change in this version; it now matches the SQL and the bin actually fitted. See?obwoe_applyand?obwoe_sql.obwoe_scorecard(..., drop_negative = TRUE)now actually removes a single negative-coefficient variable instead of silently keeping it. Previously the removal loop stopped one variable too early whenever exactly one variable had a negative coefficient, so that case fell through to a warning instead of either fixing the model or raisingstop(). It now keeps removing negative-coefficient variables until either none remain or only one variable is left (in which case, if it is still negative,obwoe_scorecard()stops with an error, as documented).ob_cutpoints_num()’s bins are now right-closed(a, b], and bothob_cutpoints_num()andob_cutpoints_cat()return the pieces (id, andcutpointsfor the numerical version)ob_apply_woe_num()/ob_apply_woe_cat()require. Previouslyob_cutpoints_num()built left-closed[a, b)bins — the opposite ofob_apply_woe_num()’sinclude_upper_bound = TRUEdefault — so a value sitting exactly on a cutpoint could get a different, often sign-flipped, WoE depending on whether it went through the fit or the apply side; and neither manual cutpoint function’s result could be handed to its matching apply function at all, because both lacked theidelement (andob_cutpoints_num()the top-levelcutpoints) the apply side requires.ob_cutpoints_cat()’s emitted bin labels also now use"%;%"(matchingob_apply_woe_cat()’s default separator and the main pipeline) instead of echoing the"+"-joined input verbatim; the"+"input format is unchanged. If you useob_cutpoints_num()/ob_cutpoints_cat()and parse theirbinlabels yourself, check the new format. See?ob_cutpoints_numand?ob_cutpoints_cat.
Fixes
- Gains tables sorted by
sort_by = "bin"(including the score bands inobwoe_scorecard()) were ordered lexicographically instead of by the bins’ natural (level) order, silently understating KS by as much as a third on real data. -
step_obwoe(algorithm = "auto")could resolve to the multiclass algorithm for a genuinely binary outcome whenever the outcome factor declared an unobserved third level. -
print()on an unpreppedstep_obwoerecipe step errored whenalgorithm = tune::tune(). -
obwoe_gains(use_column = "woe", n_groups = k)returnedNAWoE and zero total IV for every bin. -
plot.obwoe_gains(type = "cumulative")assumed every bin held an equal share of the population; the x-axis now reflects each bin’s real size. - The advertised algorithm count was corrected from “36 (20 numerical, 16 categorical)” to the actual 37 (21 numerical, 16 categorical) — the numerical
iralgorithm was implemented, documented and tested, but was never counted.
OptimalBinningWoE 1.13.0
New features (2026-08-20)
obwoe_scorecard() — the pipeline as one artefact
Runs the whole origination workflow in a single call — stratified split, binning, screening by Information Value and correlation, model fitting, PDO scaling — and returns an object that also writes itself out as an .xlsx model document. The point is not convenience: each stage records why it did what it did, so the workbook is reviewable evidence rather than a set of numbers.
The binning sees the training rows only. Binning is supervised: cut points and WoE are both chosen against the target, so fitting them on the full base before splitting leaks the hold-out into the transformation. The split therefore happens first, and the binning is fitted once, on the training rows, and applied everywhere else. Measured on German Credit, the difference is a hold-out AUC of 0.768 fitted the leaky way against 0.709 fitted correctly — the leak buys six points of AUC that do not exist.
A negative WoE coefficient is a fault, not a result. The WoE already carries the direction of risk, so a negative slope means the model is reversing a variable to compensate for another. Such variables are dropped one at a time, worst first, and the model refitted, with each removal recorded.
The points table is fixed, not per applicant. Points follow the Siddiqi allocation,
points_ij = Offset/k - Factor(beta_j WoE_ij + alpha/k), so a bin is worth the same number of points to everyone. Rounding each bin once costs at mostk/2points against the unrounded model score; that drift is measured on every sample and reported rather than hidden by re-apportioning the rounding per row, which would make the same bin worth different points to different applicants.The deployment SQL reproduces the R score exactly. Both the WoE form and the integer-points form are generated, the total computed in an outer
SELECTover a subquery — referring to a select-list alias within the sameSELECTis a MySQL extension that ANSI SQL, SQLite, PostgreSQL, SQL Server and Oracle all reject. Verified against live SQLite on all 1000 German Credit rows: maximum difference 0, unseen categories included.A value in no fitted bin scores a defined fallback. It is counted and warned about, per sample and per variable, but it still produces a score — both in R and in SQL, from one and the same
na_woefigure. A single unseen category cannot void an application.Thirteen sheets. Model summary, scorecard, coefficients with standard errors, bin statistics, screening funnel with a reason per rejected variable, correlations before and after pruning, score gains, PSI between samples, cut-off strategy, SQL in both forms, and a reproducibility record.
Engines are a three-function contract —
fit,link,coef— withglm, the package’s own C++ L-BFGS logistic regression, andglmnetregistered, and custom engines accepted.coef()returningNULLdeclares the model non-additive, and the pipeline then produces no points table rather than fabricating one from a model that has no per-variable decomposition. A missing engine package is an error by default, not a silent substitution: a workbook documenting a model the analyst did not ask for is worse than a call that fails.
Supporting functions
-
obwoe_scale()andobwoe_score()implement PDO scaling,Factor = PDO/ln 2andOffset = Score0 - Factor ln(Odds0), in both score directions. Doubling the good:bad odds moves the score by exactly one PDO at every point of the range. -
obwoe_prune()removes redundant variables iteratively, dropping the worse-ranked member of the strongest surviving pair and recomputing, rather than resolving all pairs against the original matrix at once. -
obwoe_psi()computes the Population Stability Index between two samples, for both binned and continuous inputs, using interior quantiles so that a merely shifted distribution does not produce a degenerate band. -
obwoe_report()writes the workbook for an already-fitted scorecard. -
predict()methods for"score","card","link","prob"and"woe", reading only the binning, the coefficients and the scaling.
Fixed during review
The screening funnel named the wrong stage. Every variable that left the pipeline after Information Value screening was labelled
corr_pruned, including those dropped by the negative-coefficient check and those that turned out constant after the WoE transform. The funnel is the sheet a reviewer reads to learn why a variable is absent, so a mislabelled rejection is a false statement in a model document.stagenow distinguishesin_model,sign_rejected,corr_pruned,constant_woeandscreened_out, each assigned by the step that actually rejected the variable.The cut-off strategy sheet ignored the score direction. It always approved applicants scoring at or above the cut, which is right under the default scale but exactly inverted under
direction = "higher_is_riskier"— the sheet then approved the worst applicants and reported an approved bad rate above the rejected one, at every one of its twenty cut-offs. The approval rule now follows the direction of the scale.fileis validated before the pipeline runs. A missingopenxlsx, a non-existent directory or an unwritable one used to surface only at the final write, after the binning, the screening and the fit had all been computed and were about to be discarded. The check now happens up front: the failure arrives in about 0.04 s instead of after the whole run.
OptimalBinningWoE 1.12.0
New features (2026-08-19)
Two additions close the gap between a fitted binning and a deployed scorecard: deciding which variables deserve to enter the model, and shipping the accepted transformation to the database where the data lives.
obwoe_select() — automated variable screening
Screens every binned variable of an obwoe model against the two criteria that govern variable admission in credit risk practice — predictive strength, graded with the Siddiqi (2006) Information Value bands, and guaranteed rank ordering, measured by monotonicity of the bin event rate — and returns a verdict for each.
Nothing is ever dropped. A base with 500 candidate variables yields 500 rows. Each carries a
selectedflag, a machine-readablereasonlisting every rule it violated, and areason_descin plain language, so the automatic verdict can be reviewed, overridden, or replaced by the analyst’s own triage.Two levels of detail.
detail = "summary"gives one row per variable with its headline metrics;detail = "full"expands to one row per variable and optimised bin, carrying the complete gains table ofob_gains_table()(31 metrics: WoE, IV, lift, cumulative KS, precision, recall, F1, KL and JS divergence, …) together with the interval bounds of numerical bins and the merged category lists of categorical ones.Metrics that describe the deployed score.
ks,aucandginiare computed on the WoE actually applied to the data, with bins ranked by that WoE and merged when tied.aucuses the tie-corrected Mann-Whitney form, which reproduces the rank-based AUC of the WoE-transformed observations to machine precision.ksis therefore the true KS of the transformed variable even when the binning is not monotonic.An honest treatment of ordering. For numerical variables the bin sequence is intrinsic, so monotonicity is a genuine constraint. For nominal categories the sequence is a free relabelling, so the default
require_monotonic = "numeric"applies the constraint only where it means something;"all"and"none"are available.Rejects suspicious variables by default.
iv_max = 0.50excludes the Siddiqi Suspicious band, where a single-variable IV is far more often a symptom of target leakage than of a dominant predictor. On the Statlog German Credit benchmark this is exactly what happens to the checking-account status (IV = 0.67).Further gates for
min_bins,max_bins, minimum bin population share, bins with no events or no non-events, and atop_ncut by IV, KS, Gini or AUC. AnExcellent/Good/Fair/Rejectedquality tier summarises how cleanly the algorithm categorised each variable.Returns a
data.tablewhen that package is installed and an identicaldata.frameotherwise; the table is assembled column-wise in a single pass, so 500 variables are screened in about a second.
obwoe_sql() — SQL code generation
Translates a fitted binning into executable SQL, so the WoE transformation runs inside the database with no round trip through R.
Exact interval semantics. Numerical bins are half-open on the right,
(lower, upper], exactly asobwoe_apply()assigns them. Boundaries come from the fittedcutpointsvector, never parsed back from bin labels, whose formatting varies between algorithms. By default each branch states both of its bounds (WHEN x > 7 AND x <= 10 THEN ...) so it is correct in isolation;explicit_bounds = FALSEemits the shorter cascading form.Cut points that survive the round trip. Literals are written as the shortest fixed-notation decimal that parses back to the identical IEEE 754 double, and never in scientific notation. Rounding a boundary such as
4049.5would silently move observations between bins, so exactness is the default;digitsmakes rounding an explicit choice.NULL cannot leak. In SQL,
NULL <= 5isNULL, notFALSE, so a missing value matches no comparison and would fall through toELSE. Every generated expression opens with an explicitWHEN <col> IS NULLbranch, and when the binner folded training missings into a category bin,null_to_na_bin = TRUEroutes databaseNULLs to that same bin.Escaping that holds. Single quotes are doubled per ANSI SQL; on MySQL, MariaDB and the Hive family backslashes are doubled too. Category names are matched byte for byte, whitespace included. Identifiers are quoted with the dialect’s own delimiters and, by default, only when the name needs it or collides with a reserved word — which keeps generated code readable on case-folding engines such as Oracle and Snowflake.
14 dialects (
ansi,postgres,mysql,mariadb,sqlserver,oracle,spark,hive,databricks,bigquery,snowflake,redshift,duckdb,sqlite), four assembly styles (select,case,cte,view), four output modes (woe,bin,index,both), an audit header recording package version and algorithm, and direct file output.Accepts an
obwoeobject, a preppedstep_obwoe()step, or a prepped recipe containing one, and composes withobwoe_select()throughfeatures = sel$feature[sel$selected].
Validation
Real benchmark data is bundled.
inst/extdata/germancredit.csv.gzholds the Statlog (German Credit) dataset from the UCI Machine Learning Repository (1000 applications, 7 numerical and 13 categorical attributes, CC BY 4.0), in its labelled form — category names carrying spaces, slashes and colons make it a realistic test bed for SQL escaping.obwoe_select()reproduces the published Information Values of that benchmark to four decimals for the 14 attributes the optimiser keeps ungrouped, and its IV, KS, AUC and Gini match an independent computation made from the raw observations to 1e-10.The generated SQL is validated by parsing and executing it: the test suite carries a small
CASEinterpreter that reads the emitted text the way an engine would, and the resulting bin assignment is checked against the counts the binning algorithm itself reported — across five algorithms and all 20 German Credit variables. During development the same statements were additionally run against a live SQLite engine, whose results were identical. Edge cases covered include observations sitting exactly on a cut point, cut points with no exact binary representation, magnitudes from1e-9to1e9, single-bin variables, degenerate bins, reserved-word column names, and categories containing quotes, backslashes, tabs, accented characters and significant whitespace.
Bug fixes
Both defects below were uncovered while validating the two new functions against real data, and both are pinned by tests in tests/testthat/test-regression-audit.R that fail on 1.11.0 — 31 assertions in total.
obwoe_apply()andbake.step_obwoe()silently failed to score any category carrying leading or trailing whitespace. Both rebuilt their category-to-bin lookup withtrimws(strsplit(bin_label, "%;%", fixed = TRUE)[[1]]). The binning engines join the original category strings with the separator and add no padding — verified here for all 15 categorical algorithms — so the split pieces are already the categories byte for byte, and the trimming turned a category such as" N/A "or"PENDING "into a key no observation could match. Those rows were assignedbin = NAandwoe = na_woe, i.e. scored as unseen, with no warning: on a base whose codes come from aCHAR(n)column or a hand-maintained code table, an entire segment could be dropped from the model without a trace. The trimming is gone; categories are now matched exactly, asobwoe_sql()already did.obwoe_gains()reported the same lift for every bin. The column was built withifelse(overall_rate > 0, df$pos_rate / overall_rate, 0);ifelse()returns a result shaped like its test, and that test is a scalar, so the expression collapsed to a length-one vector that R then recycled across the table. Every gains table with more than one bin reported the first bin’s lift throughout, andplot(type = "lift")drew a flat line. The R gains table now agrees with the independent C++ engine (ob_gains_table()) to machine precision onlift,woe,ivandks.-
ob_numerical_ir()reported counts that did not describe its own bins, and bins that were not monotonic.applyIsotonicRegression()ran the Pool Adjacent Violators algorithm over the bin event rates and then overwrote each bin withcount_pos <- round(fitted_rate * count). Pooling adjacent violators means those bins form one block; keeping them separate and back-solving synthetic counts produced two defects at once. First,count_posandcount_negno longer described the observations falling between the reportedcutpoints, so WoE, IV, KS and every gains table derived from them referred to a distribution that does not exist — on the German Creditamountattribute the reported and observed event rates differed in four of six bins. Second, because of the rounding the reported bins were not even monotonic, which is the one property an isotonic binner exists to guarantee.PAVA blocks are now merged into single bins. This reproduces the isotonic fit exactly — with the bin counts as weights, a block’s pooled rate is
sum(count_pos) / sum(count)over that block — whilecount,count_posandcount_negstay equal to what was observed. Features whose event rate is already monotone are unaffected and return bit-identical results; where PAVA had violators to pool, the binning is now coarser and genuinely rank-ordering.min_binsbecomes a target rather than a guarantee for this algorithm, since monotonicity cannot always be attained at that resolution.
Documentation
The README is a third of its former length. It now covers what the package is, the four steps of a run, how to choose an algorithm and where to read more; the extended worked examples moved into the vignettes, where an analyst can study them properly. Algorithm selection is presented as a decision graph (rendered by GitHub) with the family reference table kept below it.
-
Two vignettes replace the single kitchen-sink one.
Optimal Binning and Weight of Evidence: A Practical Guide is the working reference: what WoE and IV measure and why monotonicity of the event rate and of the WoE are the same statement, reading bin and gains tables, screening a base with
obwoe_select(), how the algorithm families differ, applying the transformation, exporting SQL, and preprocessing.An Industrial Scorecard Pipeline is new and runs an origination scorecard end to end: a wide synthetic base with the pathologies that matter (missing fields, rare dealer codes, near-duplicate vendor variables, pure noise and a leaky post-booking field), screening at scale, redundancy pruning with
obcorr()in the WoE space, arecipespipeline aroundstep_obwoe(), logistic regression with the coefficient sign check, PDO scorecard points, out-of-time validation with gains and PSI, deployment throughobwoe_sql(), tuning with tidymodels, and a governance checklist. Both vignettes run on the bundled German Credit benchmark or on data they generate, so they build from
recipesalone. The previous vignette required the Suggestedscorecardpackage and failed to build without it;scorecardandpROCare no longer used anywhere and have been dropped fromSuggests.
OptimalBinningWoE 1.11.0
C++ Engine — Runtime Audit (2026-08-12)
Follow-up to the 1.10.0 static audit, this time driven by instrumented builds (-fsanitize=address,undefined), a degenerate-input stress harness, and a golden-output regression suite covering all 37 exported algorithms (~3,200 result comparisons). All fixes below are covered by new tests in tests/testthat/test-regression-audit.R, each of which fails on 1.10.0.
Bug Fixes — crashes and hangs
ob_categorical_ivbcrashed the R session (segmentation fault, not a catchable error) for any feature with no more categories thanmax_bins— with the defaultmax_bins = 5this meant every 2-, 3-, 4- or 5-level predictor, i.e. sex, marital status, region, education. Inperform_binning()thencat <= max_binsfast path skippedinitialize_dp_structures(), the only placestats_cachewas created, while result assembly dereferenced it unconditionally. The cache is now built before the branch.ob_numerical_ir,ob_numerical_jedi,ob_numerical_jedi_mwoehung forever whenevermin_binsexceeded the number of distinct feature values — a routine situation when onemin_binsis applied across a whole feature set. The “ensure at leastmin_bins” loops called a split routine that silently declines to split unbounded intervals (and, in JEDI, ran before counts existed, so it always targeted the unsplittable(-Inf, e1]bin), making no progress and never terminating. Since these loops contained noR_CheckUserInterrupt(), the hang could not even be interrupted with Ctrl-C. The loops now consider only splittable bins and stop as soon as no progress is possible.
Reproducibility
-
ob_categorical_sabis now reproducible underset.seed(). It was seeded fromstd::random_device, so identical input returned a different binning on every call with no way to control it — unusable for auditable or regulated models, and unreliable on some MinGW toolchains. The simulated-annealing search is now seeded from R’s own RNG stream. This changesob_categorical_sabresults, which were previously random and therefore had no stable baseline to preserve.
Output consistency
ob_numerical_sketchnow returns thebinlabel field (plustotal_iv) like every other numerical algorithm. Its absence broke generic consumers, including this package’s own test helper.bin_lowerandbin_upperare retained, so the change is purely additive; no numeric output changed.The
ob_numerical_sketchtest disabled since 1.0.7 for a segfault has been re-enabled: the underlyingMergeCachedefect was removed in an earlier round, and the case was re-verified clean under-fsanitize=address,undefinedat n = 500/1000/2000/5000.
Parallelism (obcorr) — CRAN policy and determinism
obcorr()no longer seizes every core. With the defaultthreads = 0it calledomp_set_num_threads(std::thread::hardware_concurrency()), taking all available cores. CRAN Repository Policy requires a package never to use more than two cores simultaneously by default, since the check farm is a shared resource; this was an archival risk. The default is now at most 2 threads, honouring any lower limit already set throughOMP_NUM_THREADS, and capped byomp_get_num_procs(). An explicit positivethreadsis still respected.obcorr()returned its rows in a non-deterministic order. Per-thread result buffers were spliced together inside anomp criticalblock, so the row order depended on thread scheduling — two runs on the same data with the same thread count could return the pairs in different orders. Any caller usinghead(), positional indexing, or a positional join saw different numbers between runs. Each iteration now writes to its own slot in a pre-sized vector, which is deterministic, independent of the thread count, and removes the critical section. Correlation values are unchanged; only the row order is now stable.
Build hygiene
- Removed eight stale Dropbox conflict copies (
*Cópia em conflito*.cpp/.h) fromsrc/.R CMD buildwould have shipped and compiled them, producing duplicate symbols. Guards added to.gitignoreand.Rbuildignore.
Interval convention — standardised on (a, b] (changes numeric output)
The package previously disagreed with itself about what a bin is. Bin labels advertised (a;b], but many algorithms assigned observations as [a;b); ob_apply_woe_num() had its two binary searches swapped, so both settings of include_upper_bound did the opposite of what the argument documents; and several equal-frequency pre-binners split runs of tied values, so their reported counts could not be derived from their own reported cutpoints under any convention. A value landing exactly on a cutpoint — routine for integer, rounded or currency features — could be scored into a different bin than the one it was trained in.
Measured across 63 algorithm/dataset combinations, 31 violated the documented (a, b] convention before this release; 0 do now.
-
ob_apply_woe_num():include_upper_bound = TRUEnow really means(a, b](lower_boundsearch) andFALSEreally means[a, b)(upper_bound). The two were previously exchanged. -
ob_numerical_dp,_fetb,_ldb,_oslp: bin lookup switched fromupper_boundtolower_bound, so boundary values stay in the bin below. -
ob_numerical_bb,_dmiv: dropped an+ EPSILONadded to the search key, which silently turned the documentedupper >= valuetest into a strict one. -
ob_numerical_sketch: interval test changed from[lower, upper)to(lower, upper](first bin remains closed on the left, so the minimum is included). -
ob_numerical_mob,_mdlp,_mrblp: equal-frequency pre-binning is now tie-aware — a run of identical values is never split across two pre-bins — and boundaries are the last value in the bin rather than the first value of the next. Their labels changed from[a;b)to(a;b]to match. -
ob_numerical_fast_mdlp: theforce_min_bins()fallback picked a split at a raw index midpoint, which could land inside a run of tied observations. It now moves the split to a genuine value boundary, as the MDL recursion already did.
ob_numerical_cm silently discarded observations. Its equal-frequency pre-binner, on hitting a tie that straddled a bin boundary, advanced past the tied records without ever assigning them to a bin. On tied or discrete features this dropped a large share of the data — 26% on an integer feature and 40% on a coarse one in testing — and the reported WoE/IV were computed from the surviving subset. The tied records are now absorbed into the preceding bin.
Known issues (not yet fixed)
-
ob_categorical_dpandob_categorical_fetbreportWoE = IV = 0for perfectly separating bins instead of applying smoothing. - Return fields are not uniform across algorithms:
event_rateis present in only 13 of 37,total_ivin 28 of 37, andivis missing from both_dmivvariants. -
ob_apply_woe_num()does not support the multinomial*_jedi_mwoevariants, whose output carries per-class counts rather thancount_pos/count_neg.
OptimalBinningWoE 1.10.0
C++ Engine — Comprehensive Audit & Hardening (2026-05-17)
This release is the result of a full static audit of the C++ engine covering all 36 binning algorithms. No public R API was changed.
Bug Fixes
-
OB_LogisticRegression— Replaced exactdet != 0singularity guard with a threshold-based check (|det| > 1e-10 × ‖H‖); replacedhessian.inverse()withEigen::LDLTdecomposition for numerical stability; added.cwiseMax(0.0)beforesqrt()to preventNaNstandard errors from near-zero diagonal entries. -
OBN_MDLP— monotonicity direction bug —is_monotonic()andenforce_monotonicity()previously hardcoded ascending direction, causing unnecessary merges on negatively-correlated features. Both now auto-detect the dominant trend via Welford’s slope algorithm before checking/enforcing monotonicity. -
OBC_DP— DP backtracking out-of-bounds — Added guard beforestatic_cast<size_t>(prev_j)inbacktrack_optimal_bins(); invalid predecessor index now raises a descriptive runtime error instead of silent undefined behaviour. -
OBN_DP— push before validate — Target value validation inoptimal_binning_numerical_dp()now fires before the value is appended totarget_vec, preventing insertion of invalid data. -
OBN_MDLP—log2(0)in MDL cost — Guard added for the single-bin case wherelog2(k-1)would evaluate tolog2(0) = -Inf. -
NumericalBinconstructor invariant — The 7-arg constructor now derivescount = count_pos + count_negregardless of thecargument, enforcing thecount == total()invariant at construction time.
Performance Improvements
-
OBC_DP— DP outer loop removed — The deterministic DP inperform_dynamic_programming()was wrapped in a redundantmax_iterationsouter loop (default 1000). Removing it yields up to 1000× speedup for the categorical DP algorithm. -
OBC_DP::ensure_max_prebins()— O(m² log m) full re-sort per merge step replaced with O(m log m + m²)std::lower_bound + insert. -
OBN_MDLP::apply_mdl_merging()— O(k³) full-vector copy per candidate merge eliminated; MDL delta is now computed analytically from bin statistics in O(k²) per outer step. -
OBN_BB::quantile()— Per-call sort-copy O(n_prebins × n log n) eliminated;prebinning()sorts once and passes the sorted vector to a statelessquantile(). -
monotonicity_utils.h— Welford index allocation — Removed unnecessarystd::vector<double> indices(n)heap allocation; loop index cast directly todouble. -
OBN_DP— Pearson correlation instability — Replaced naive two-pass Pearson formula (catastrophic cancellation risk) withdetect_trend_from_correlation()using Welford’s online algorithm.
CRAN / ODR Safety
-
safe_math.h— All 6 functions changed fromconstexprtoinline;std::log,std::exp,std::absandstd::isfiniteare not guaranteedconstexprin C++11/14, risking compilation failure on SOLARIS/Studio. -
chi_square_utils.h—CHI_SQUARE_CRITICAL_VALUESnamespace-scopeconstreplaced with a function returning astaticlocal instance (one shared copy per process, C++11 thread-safe init). -
entropy_utils.h—ENTROPY_LUT(~81 KB) replaced withentropy_lut_instance()returning astaticlocal; eliminates one copy per translation unit. -
OBC_CM_v5— DuplicateChiSquareCacheclass (global namespace) removed; file now usesOptimalBinning::ChiSquareCachefromchi_square_utils.h. -
35
.cppfiles — Duplicateusing namespace Rcppappearing before#include "common/"headers removed, preventing potential name-resolution ordering issues.
Code Quality
-
OBC_DP— Dead commented-out code blocks (// struct CategoryStats,// Local CategoricalBin definition removed) deleted. -
OBN_DP— Local variabletotal_countrenamed torare_totalto fix shadowing of the class member with the same name. -
OBN_IR—[[Rcpp::plugins(cpp17)]]standardised tocpp11for consistency with the rest of the package. -
OBC_DP— Auto-detection of monotonicity direction (monotonic_trend = "auto") implemented incompute_and_sort_event_rates()viadetect_trend_welford_woe().
OptimalBinningWoE 1.0.9
-
CRAN Fix (2026-03-14) - Replaced
Rf_errorwithRcpp::stop:-
Fixed C++ Exception Handling: Addressed an issue reported by @Enchufa2 regarding the usage of
::Rf_errorinsidecatch(...)blocks. Updated all instances to useRcpp::stopto ensure proper C++ stack unwinding and avoid memory leaks. -
Affected Files:
src/OBN_LPDB_v5.cpp,src/OBN_EWB_v5.cpp,src/OBN_KMB_v5.cpp,src/OBN_LDB_v5.cpp,src/OBN_MBLP_v5.cpp.
-
Fixed C++ Exception Handling: Addressed an issue reported by @Enchufa2 regarding the usage of
OptimalBinningWoE 1.0.8
CRAN release: 2026-01-29
-
CRAN Fix (2026-01-28) - LTO/ODR Compliance:
-
Fixed One Definition Rule (ODR) violations: Wrapped internal helper classes
IVCacheandCumulativeStatsCachein anonymous namespaces withinOBC_GMB_v5.cpp,OBC_IVB_v5.cpp, andOBC_JEDI_v5.cpp. This resolves Link-Time Optimization (LTO) warnings/errors on CRAN checks.
-
Fixed One Definition Rule (ODR) violations: Wrapped internal helper classes
OptimalBinningWoE 1.0.7
-
UBSAN Investigation Fix (2026-01-27) - Addressing persistent memory safety errors:
Temporarily disabled
ob_categorical_sketchtests: The sketch-based categorical binning algorithm is under investigation for persistent UBSAN memory errors that appear to be related to cache invalidation timing in GitHub Actions CI environment.Removed
MergeCacheclass fromOBC_Sketch_v5.cpp: Completely removed the caching mechanism and implemented on-the-fly divergence calculation to eliminate potential memory corruption sources.
-
Affected Files:
-
src/OBC_Sketch_v5.cpp: MergeCache class removed, divergence calculated on-the-fly -
tests/testthat/test-categorical-all.R: Sketch tests temporarily commented out
-
No API Changes: Fully backward compatible with v1.0.6.
OptimalBinningWoE 1.0.6
-
CRAN Fix (2026-01-26) - Resolving AddressSanitizer memory safety errors:
Fixed heap-buffer-overflow in
OBN_CM_v5.cpp: Thecalculate_inconsistency_rate()function was accessingbins[j-1]whenj=0andbins.size()==1, causing invalid memory access. Restructured bin-finding loop to avoid negative index access.Fixed uninitialized bool in
OBC_MBA_v5.cpp: TheMergeCache::enabledmember was not explicitly initialized, causing “load of value 128, which is not a valid value for type ‘bool’” runtime error. Added explicitbool enabled = falseinitialization.
-
Affected Files:
-
src/OBN_CM_v5.cpp(lines 863-887): Safe bin-finding logic -
src/OBC_MBA_v5.cpp(line 26): Explicit bool initialization
-
No API Changes: Fully backward compatible with v1.0.5.
OptimalBinningWoE 1.0.5
-
CRAN Fix (2026-01-25) - Resolving ERROR on macOS platforms during vignette re-build:
Fixed
obwoe_apply()“breaks are not unique” error: Enhanced cutpoint deduplication logic to properly handle cases wheresort(unique(cutpoints))reduces the number of intervals. When the deduplicated cutpoint count doesn’t match the original bin count, the function now uses a fallback mapping with dynamically generated interval labels and mean WoE values, avoiding thecut.default()error.This addresses the vignette build failure reported on r-release-macos-arm64, r-release-macos-x86_64, r-oldrel-macos-arm64, and r-oldrel-macos-x86_64 platforms.
-
Internal Changes:
- Added interval count validation after cutpoint deduplication (R/obwoe.R)
- Fallback to mean WoE when bin/interval mismatch occurs
- Dynamic interval label generation for edge cases
OptimalBinningWoE 1.0.4
-
CRITICAL CRAN Fixes (2026-01-24) - Addressing ERROR and NOTE on macOS platforms:
Fixed macOS vignette ERROR: Added comprehensive validation for duplicate cutpoints in
obwoe_apply()andbake.step_obwoe(). The R basecut()function now receives guaranteed unique, sorted breaks, preventing the"'breaks' are not unique"error that was causing vignette build failures on macOS platforms.-
Attempted to reduce the package binary size with size optimization flags (
-Os,-fvisibility=hidden,-ffunction-sections,-fdata-sections), the-Wl,--gc-sectionslinker flag and acleanupscript for symbol stripping.This was reverted and no longer describes the package.
-fvisibility=hiddenand-Wl,--gc-sectionshid Rcpp symbols and broke the build on every platform, so they were removed in commitb74e95b;-Osand thecleanupscript did not survive either.src/Makevarsnow sets only the OpenMP and BLAS/LAPACK flags. The entry is corrected here rather than deleted so that nobody reinstates flags that are already known to break the build.The size itself is not a problem: the shared object is large only because it carries debug symbols. Measured on 1.13.3,
.debug_infoalone is 33Mb against 2.2Mb of.text, and stripping takes the library from 70.4Mb to 2.6Mb.R CMD check --as-cranreports the installed size as INFO, not as a NOTE.
-
Internal Changes:
Added
src/common/cutpoints_validator.h- new C++ utility header withvalidate_cutpoints()function to ensure cutpoint uniqueness across all numerical binning algorithms. Uses floating-point tolerance (1e-10) for safe duplicate detection.Modified
get_cutpoints()insrc/OBN_MOB_v5.cpp(line 180) to apply validation before returning cutpoints.Modified
update_cutpoints()insrc/OBN_UBSD_v5.cpp(line 874) to apply validation before storing cutpoints.Added R-level validation in
obwoe_apply()(R/obwoe.R, line 1550): cutpoints are now sorted and deduplicated usingsort(unique(cutpoints))before constructing breaks vector.Added R-level validation in
bake.step_obwoe()(R/step_obwoe.R, line 789): same deduplication logic for recipes integration.Enhanced vignette robustness (
vignettes/introduction.Rmd): Added try-catch error handling in scorecard workflow to prevent build failures on edge-case data distributions.
-
Affected Algorithms: All 21 numerical binning algorithms now validate cutpoints to prevent duplicate breaks:
- Monotonic Optimal Binning (MOB)
- Dynamic Programming (DP)
- Chi-Merge (CM)
- Unsupervised Binning with Standard Deviation (UBSD)
- And 17 other numerical algorithms
No API Changes: Fully backward compatible with v1.0.3. All existing code will continue to work without modification.
OptimalBinningWoE 1.0.3
CRAN release: 2026-01-23
-
Critical Bug Fixes - KLL Sketch Algorithm (2026-01-20):
- Fixed iterator invalidation in
KLLSketch::compact_level()- thecompactors.push_back()call was invalidating references to vector elements, causing crashes with datasets larger than ~200 observations. - Fixed parameter order bug in
calculate_metrics()calls - swapped(total_good, total_bad)to correct order(total_pos, total_neg), fixing incorrect WoE calculations. - Fixed half-open interval logic in bin assignment - added explicit closed interval
[lower, upper]check for the last bin to ensure boundary values are correctly assigned. - Fixed merge direction logic in
enforce_bin_cutoff()- corrected iterator invalidation when merging bins by always erasing the higher-indexed bin. - Added bounds safety checks in DP optimization - ensured
k >= 2andk < nto prevent undefined behavior with edge cases. - Added underflow guard in compaction loop - check for
compactor.size() < 2before iteration. - Added input validation for non-finite values (Inf, NaN) in sketch updates.
- Improved documentation in
ob_numerical_sketch()with clearer parameter descriptions and simplified examples. - Replaced
special_codesparameter withmax_n_prebinsfor consistency with other algorithms.
- Fixed iterator invalidation in
-
CRAN Reviewer Feedback (2026-01-17):
- Removed single quotes from author names (
Siddiqi,Navas-Palencia) in DESCRIPTION. - Removed commented-out code from examples in
obwoe_apply. - Replaced all
\dontrun{}with\donttest{}in 12 function examples. - Added proper
par()restoration in examples and vignettes.
- Removed single quotes from author names (
OptimalBinningWoE 1.0.2
-
CRAN Resubmission:
- Updated
inst/WORDLISTto include technical terms and author names (MILP, Navas, Palencia) to resolve spelling notes. - Fixed
README.mdlinks forCONTRIBUTING.mdandCODE_OF_CONDUCT.mdto use absolute GitHub URLs, ensuring compliance with CRAN URI checks for ignored files. - Added
Language: en-UStoDESCRIPTIONmetadata.
- Updated
OptimalBinningWoE 1.0.1
- CRAN Preparation: Comprehensive updates for CRAN submission compliance.
-
Documentation:
- Enhanced
README.Rmdwith detailed algorithm descriptions,tidymodelsintegration examples, and performance metrics. - Added
CODE_OF_CONDUCT.md(Contributor Covenant v2.1) andCONTRIBUTING.mdguidelines. - Added
inst/WORDLISTfor spell checking.
- Enhanced
-
Metadata:
- Updated
DESCRIPTIONwith corrected fields (Authors, BugReports, Depends, References). - Added
cran-comments.mdfor submission notes.
- Updated
OptimalBinningWoE 1.0.0
Initial Release
OptimalBinningWoE is a high-performance R package for optimal binning and Weight of Evidence (WoE) transformation, designed for credit scoring and predictive modeling.
Key Features
-
Comprehensive Algorithm Suite: Implementation of 36 binning algorithms:
- 20 Numerical Algorithms: Including MDLP (Minimum Description Length Principle), JEDI (Joint Entropy-Driven Information), MOB (Monotonic Optimal Binning), Sketch (KLL/Count-Min for large data), and more.
- 16 Categorical Algorithms: Including ChiMerge, Fisher’s Exact Test Binning (FETB), SBLP (Similarity-Based LP), JEDI-MWoE (Multinomial WoE), and others.
-
High Performance: Core algorithms are implemented in C++ using
RcppandRcppEigenfor maximum efficiency and scalability. -
Unified Interface:
-
obwoe(): Master function for optimal binning with automatic type detection and algorithm selection. -
ob_apply_woe_num()/ob_apply_woe_cat(): Functions to apply learned binning mappings to new data.
-
-
tidymodels Integration:
-
step_obwoe(): A completerecipesstep for integrating optimal binning into machine learning pipelines. - Supports
tune()for hyperparameter optimization of binning parameters (algorithm, min_bins, etc.).
-
-
Multinomial Support:
- Dedicated algorithms like
JEDI-MWoEfor handling multi-class target variables.
- Dedicated algorithms like
-
Robust Preprocessing:
-
ob_preprocess(): Utilities for missing value handling and outlier detection/treatment (IQR, Z-score, Grubbs).
-
-
Advanced Metrics:
-
ob_gains_table(): Computation of detailed gains tables including IV, WoE, KS, Gini, Lift, Precision, Recall, KL Divergence, and Jensen-Shannon Divergence.
-
-
Visualization:
- S3
plot()methods for visualizing binning results and WoE patterns.
- S3
usage
- See the package vignette (
vignette("introduction", package = "OptimalBinningWoE")) for detailed examples and theoretical background.
