diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ab0bb7..3487d26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CITATION.cff b/CITATION.cff index 0f7fc3c..8e70470 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -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 diff --git a/src/compileml/__init__.py b/src/compileml/__init__.py index dfbc672..9ae62a2 100644 --- a/src/compileml/__init__.py +++ b/src/compileml/__init__.py @@ -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__"] diff --git a/src/compileml/reference/woe.py b/src/compileml/reference/woe.py index 231eea9..49c5866 100644 --- a/src/compileml/reference/woe.py +++ b/src/compileml/reference/woe.py @@ -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 @@ -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. diff --git a/tests/test_reference_floor.py b/tests/test_reference_floor.py index 6ceaddd..80423fc 100644 --- a/tests/test_reference_floor.py +++ b/tests/test_reference_floor.py @@ -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