Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,37 @@ inside each artifact (`schema_version`).

## [Unreleased]

## [0.9.1] - 2026-09-27
## [0.9.2] - 2026-10-11

The WoE floor no longer ignores rare 0/1 columns. No change to the runtime,
the exporters, the artifact schema, or the benchmark, whose features are all
continuous.

`fit_reference` bins each feature with a shallow tree whose leaves must hold
at least 2% of rows. A two-valued column whose rarer value fell below 2%
could not be split, so it landed in one bin and carried no information: the
floor silently ignored it. This surfaced on public hospital-readmission data
(#76), where one-hot columns for rare discharge outcomes were lost, including
patients who died, 1.5% of rows and never readmitted. The floor's Gini there
rises from 0.235 to 0.300.

It matters beyond the reference itself. `compile_selected`'s floor gate
compares against this model, and so does validation check 10 when it is given
a fitted reference. A weakened floor let
through artifacts a fair one would have stopped. And 0.9.1 advises adding
0/1 missing-indicator columns, exactly the kind of column the floor dropped.

### Fixed
- A column with exactly two values is already binned. The floor now cuts it
between the two values, where the tree would, whatever the size of the
rarer one. The 2% minimum still governs the cut points the tree chooses on
columns with more values, which is what it exists for. A 0/1 column the
tree could already split gets the same cut as before.
- On data with rare two-valued columns, floor figures change and the floor
gets stronger: a floor gate or check 10 that passed may now fail. That is
the fix working.

## [0.9.1] - 2026-10-10

Missing values are refused instead of mis-compiled. No change to the runtime,
the exporters, the artifact schema, or any artifact built from data without
Expand Down
4 changes: 2 additions & 2 deletions CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ authors:
repository-code: "https://github.com/orgoca/CompileML"
url: "https://github.com/orgoca/CompileML"
license: Apache-2.0
version: 0.9.1
date-released: "2026-09-27"
version: 0.9.2
date-released: "2026-10-11"
keywords:
- credit risk
- credit scoring
Expand Down
2 changes: 1 addition & 1 deletion src/compileml/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
# this attribute at build time (setuptools dynamic version), so the wheel
# metadata, `compileml.__version__`, `compileml inspect`, and the
# `compileml_version` recorded inside every artifact can never disagree.
__version__ = "0.9.1"
__version__ = "0.9.2"

__all__ = ["compile_selected", "decide", "load_artifact", "verify_artifact", "__version__"]

Expand Down
14 changes: 13 additions & 1 deletion src/compileml/reference/woe.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,17 @@ def fit(self, X, y):
min_samples_leaf=self.min_samples_leaf,
random_state=self.random_state,
)
values = np.unique(column)
edges: np.ndarray
if np.unique(column).size < 2:
if values.size < 2:
edges = np.empty(0, dtype=float) # constant feature: one bin
elif values.size == 2:
# Already binned: the one possible cut is between the two
# values, where the tree would put it. The leaf minimum stops
# a tree choosing cut points on noise; with no choice to make,
# a rare value (a 0/1 indicator at 1%) keeps its own bin
# instead of silently merging into the common one.
edges = np.array([(values[0] + values[1]) / 2.0])
else:
tree.fit(column.reshape(-1, 1), y)
inner = tree.tree_.feature != -2 # -2 is sklearn's leaf sentinel
Expand Down Expand Up @@ -162,6 +170,10 @@ def fit_reference(
kind: Only ``"woe_logit"`` today — supervised binning,
weight-of-evidence encoding, then logistic regression.
max_bins: Maximum bins per feature (shallow by design).
min_samples_leaf: Smallest bin, as a share of rows, when a tree
chooses the cut points. A column with exactly two values is
already binned and keeps both bins whatever their sizes, so a
rare 0/1 indicator still reaches the floor.
cv: Folds used to choose ``C``. The WOE tables are refit inside each
fold, so the choice is not made against leaked encodings.

Expand Down
19 changes: 19 additions & 0 deletions tests/test_reference_floor.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,25 @@ def test_fit_reference_rejects_bad_input(data):
fit_reference(X[:500], np.zeros(500, dtype=int))


def test_a_rare_two_valued_column_keeps_its_bin():
"""A 0/1 indicator below the 2% leaf minimum still reaches the floor.

Before 0.9.2 the binning tree could not split it, so the floor ignored a
column that decides 1% of rows outright — and the floor gate got lenient.
"""
rng = np.random.default_rng(5)
n = 20000
x = rng.standard_normal(n)
rare = (rng.random(n) < 0.01).astype(float) # 1% of rows, never an event
common = (rng.random(n) < 0.3).astype(float)
y = np.where(rare == 1, 0, (rng.random(n) < 1 / (1 + np.exp(-x))).astype(int))
ref = fit_reference(np.column_stack([x, rare, common]), y, feature_names=["x", "rare", "c"])
assert ref.thresholds[1] == [0.5] and len(ref.woe[1]) == 2
assert ref.information_value["rare"] > 0.05
assert ref.coefficients["rare"] != 0
assert ref.thresholds[2] == [0.5] # a common 0/1 column: the cut the tree always made


# ------------------------------------------------------ the target axis (4.2)
def test_alpha_axis_sweeps_the_target(data, reference):
X, teacher, y = data
Expand Down
Loading