Skip to content

Commit 366bff0

Browse files
authored
Merge pull request #192 from PolicyEngine/codex/regime-auto-support-20260602
Make zero-inflated regime detection presence-based
2 parents 8f04311 + 27d2309 commit 366bff0

6 files changed

Lines changed: 29 additions & 62 deletions

File tree

changelog.d/192.breaking

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Removed caller-supplied rare-class thresholds from `ZeroInflatedImputer`; regime detection is now based solely on observed negative, zero, and positive support in the training data.

changelog.d/192.fixed

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Capped `scikit-learn` below 1.9 while `quantile-forest` depends on the pre-1.9 sklearn tree extension API.

microimpute/models/zero_inflated.py

Lines changed: 13 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -35,28 +35,23 @@
3535
The wrapper is generic over the base imputer — ``QRF`` is the obvious
3636
default, but ``MDN``, ``OLS``, or ``Matching`` all compose the same way.
3737
38-
Regime detection is parameterized by ``min_class_count`` and
39-
``min_class_fraction``: a class with fewer observations than both
40-
thresholds collapses into the closest adjacent regime. This avoids
41-
fitting a full three-sign split on a variable whose negative tail is
42-
five outlier rows — the cost-benefit flips toward the simpler
43-
architecture.
38+
Regime detection is based only on observed support. If the training data
39+
contains negative, zero, and positive values, the imputer uses the
40+
three-sign architecture. Callers do not provide sign/regime metadata.
4441
"""
4542

4643
from __future__ import annotations
4744

48-
import logging
49-
from typing import Any, Dict, List, Optional, Tuple, Type, Union
45+
from typing import Any, Dict, List, Optional, Type, Union
5046

5147
import numpy as np
5248
import pandas as pd
53-
from pydantic import SkipValidation, validate_call
49+
from pydantic import validate_call
5450

5551
from microimpute.config import RANDOM_STATE, VALIDATE_CONFIG
5652
from microimpute.models.imputer import (
5753
Imputer,
5854
ImputerResults,
59-
_ConstantValueModel,
6055
)
6156
from microimpute.models.qrf import QRF
6257

@@ -95,19 +90,14 @@ def _make_classifier(kind: str, seed: int):
9590
def _detect_regime(
9691
y: np.ndarray,
9792
*,
98-
min_class_count: int,
99-
min_class_fraction: float,
10093
zero_atol: float,
10194
) -> str:
10295
"""Classify the training distribution into one of seven regimes.
10396
104-
A class (neg/zero/pos) counts as present iff its count is at least
105-
``min_class_count`` AND its fraction of total rows is at least
106-
``min_class_fraction``. Below both thresholds, the class collapses
107-
into its closest adjacent regime (minority negatives merge into
108-
zero → ZI_POSITIVE; minority zeros merge into the majority sign;
109-
etc.). This keeps the gate architecture stable in the presence of
110-
measurement-error outliers.
97+
A class (neg/zero/pos) counts as present when it appears at least
98+
once in the training data. Sign support is inferred from donor data;
99+
callers cannot force a variable to be positive-only, negative-only,
100+
or signed.
111101
"""
112102
n = len(y)
113103
if n == 0:
@@ -121,22 +111,12 @@ def _detect_regime(
121111
n_pos = int(is_pos.sum())
122112
n_neg = int(is_neg.sum())
123113

124-
# Apply both thresholds.
125-
def _meaningful(count: int) -> bool:
126-
return count >= min_class_count and (count / n) >= min_class_fraction
127-
128-
has_zero = _meaningful(n_zero)
129-
has_pos = _meaningful(n_pos)
130-
has_neg = _meaningful(n_neg)
114+
has_zero = n_zero > 0
115+
has_pos = n_pos > 0
116+
has_neg = n_neg > 0
131117

132118
if not (has_zero or has_pos or has_neg):
133-
# All three classes are below threshold. Pick the one with the
134-
# largest raw count as a degenerate fallback.
135-
counts = {"zero": n_zero, "pos": n_pos, "neg": n_neg}
136-
majority = max(counts, key=counts.get)
137-
if majority == "zero":
138-
return REGIME_DEGENERATE_ZERO
139-
return REGIME_POSITIVE_ONLY if majority == "pos" else REGIME_NEGATIVE_ONLY
119+
return REGIME_DEGENERATE_ZERO
140120

141121
if has_pos and has_neg and has_zero:
142122
return REGIME_THREE_SIGN
@@ -161,11 +141,6 @@ class ZeroInflatedImputer(Imputer):
161141
regression step. Defaults to ``QRF``.
162142
base_imputer_kwargs: Keyword arguments forwarded to the base
163143
imputer constructor. ``{}`` by default.
164-
min_class_count: Minimum raw count per class (neg/0/pos) for
165-
that class to be considered present. Below this, the class
166-
collapses into an adjacent regime. Defaults to 10.
167-
min_class_fraction: Minimum fraction of total rows per class
168-
for that class to be considered present. Defaults to 0.01.
169144
zero_atol: Absolute tolerance for "equals zero" in the regime
170145
detector. Defaults to 1e-6, matching the upstream
171146
``_MultiSourceBase`` convention.
@@ -179,8 +154,6 @@ def __init__(
179154
self,
180155
base_imputer_class: Optional[Type[Imputer]] = None,
181156
base_imputer_kwargs: Optional[Dict[str, Any]] = None,
182-
min_class_count: int = 10,
183-
min_class_fraction: float = 0.01,
184157
zero_atol: float = 1e-6,
185158
classifier_type: str = "hist_gb",
186159
seed: Optional[int] = RANDOM_STATE,
@@ -189,8 +162,6 @@ def __init__(
189162
super().__init__(seed=seed, log_level=log_level)
190163
self.base_imputer_class = base_imputer_class or QRF
191164
self.base_imputer_kwargs = dict(base_imputer_kwargs or {})
192-
self.min_class_count = int(min_class_count)
193-
self.min_class_fraction = float(min_class_fraction)
194165
self.zero_atol = float(zero_atol)
195166
self.classifier_type = classifier_type
196167

@@ -267,8 +238,6 @@ def fit(
267238
y = X_train[var].to_numpy(dtype=float, copy=False)
268239
regime = _detect_regime(
269240
y,
270-
min_class_count=self.min_class_count,
271-
min_class_fraction=self.min_class_fraction,
272241
zero_atol=self.zero_atol,
273242
)
274243
self._regimes[var] = regime

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ dependencies = [
1919
"numpy>=2.0.0,<3.0.0",
2020
"pandas>=2.2.0,<4.0.0",
2121
"plotly>=5.24.0,<7.0.0",
22-
"scikit-learn>=1.7.0,<2.0.0",
22+
"scikit-learn>=1.7.0,<1.9.0",
2323
"scipy>=1.16.0,<2.0.0",
2424
"requests>=2.32.0,<3.0.0",
2525
"tqdm>=4.65.0,<5.0.0",

tests/test_models/test_zero_inflated.py

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,14 @@
2525
2. Predictions respect the detected regime (no zero leaks, no
2626
sign-interpolation between positive and negative regimes).
2727
3. Fit/predict lifecycle matches the base `Imputer` contract.
28-
4. Rare-class thresholds: tiny negative tails don't trigger a full
29-
three-sign split unless above a configurable minimum.
28+
4. Pure support detection: any observed negative, zero, or positive
29+
support participates in regime selection.
3030
"""
3131

3232
from __future__ import annotations
3333

34-
from typing import Dict, List
35-
3634
import numpy as np
3735
import pandas as pd
38-
import pytest
3936

4037
from microimpute.models.qrf import QRF
4138

@@ -136,14 +133,12 @@ def test_constant_zero_is_degenerate(self) -> None:
136133
imputer.fit(data, predictors=["age", "income_bin"], imputed_variables=["y"])
137134
assert imputer.get_regime("y") == "DEGENERATE_ZERO"
138135

139-
def test_rare_negative_tail_stays_zi_positive(self) -> None:
140-
"""If negative-class count is below min_class_count, treat as ZI_POSITIVE.
136+
def test_rare_negative_tail_triggers_three_sign(self) -> None:
137+
"""Any observed negative support participates in auto detection.
141138
142139
Capital gains example: 97% zero, 2.9% positive, 0.1% negative.
143-
The negative mass is real but below the 10-sample threshold on
144-
a 500-record fixture. Should NOT trigger three-sign; instead
145-
collapses to ZI_POSITIVE with the few negatives discarded from
146-
the base imputer's fit (and a warning).
140+
The negative mass is real and should trigger THREE_SIGN without
141+
caller-supplied support/sign metadata.
147142
"""
148143
from microimpute.models.zero_inflated import ZeroInflatedImputer
149144

@@ -152,15 +147,16 @@ def test_rare_negative_tail_stays_zi_positive(self) -> None:
152147
u = rng.random(n)
153148
y = np.zeros(n)
154149
pos_mask = u > 0.971
155-
neg_mask = (u > 0.970) & (u <= 0.971)
156150
y[pos_mask] = rng.exponential(100, size=pos_mask.sum())
157-
y[neg_mask] = -rng.exponential(50, size=neg_mask.sum())
158-
assert (y < 0).sum() < 10, "fixture precondition"
151+
y[0] = -50.0
152+
assert (y < 0).sum() == 1, "fixture precondition"
153+
assert (y > 0).sum() > 0, "fixture precondition"
154+
assert (y == 0).sum() > 0, "fixture precondition"
159155

160156
data = _deterministic_frame(n, y)
161-
imputer = ZeroInflatedImputer(base_imputer_class=QRF, min_class_count=10)
157+
imputer = ZeroInflatedImputer(base_imputer_class=QRF)
162158
imputer.fit(data, predictors=["age", "income_bin"], imputed_variables=["y"])
163-
assert imputer.get_regime("y") == "ZI_POSITIVE"
159+
assert imputer.get_regime("y") == "THREE_SIGN"
164160

165161

166162
class TestPredictionsRespectRegime:

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)