Skip to content

Commit fcd4b8a

Browse files
MaxGhenisclaude
andauthored
Fix OLS prediction SE and logistic l1_ratio silent ignore (#180)
* Fix OLS prediction SE and logistic l1_ratio silent ignore Two correctness bugs in microimpute/models/ols.py: 1. Prediction SE used residual std only (#6). _OLSModel scaled normal quantiles by se = sqrt(model.scale), ignoring the leverage term. The prediction SE for a new observation is sqrt(scale * (1 + x'(X'X)^-1 x)) = sqrt(var_pred_mean + scale). Test rows far from the training centroid were systematically under-dispersed; at extreme quantiles (0.01, 0.99) the under- dispersion is material. Switched to statsmodels' model.get_prediction(X).var_pred_mean + model.scale to obtain per-row prediction variance. Additionally clipped the mean_quantile to (1e-6, 1-1e-6) so q=0 or q=1 no longer produce ±inf via norm.ppf or a=inf in the beta distribution alpha formula. 2. Logistic l1_ratio silently ignored (#8). LogisticRegression uses l1_ratio only when penalty="elasticnet" (and solver supports it). Previously the classifier passed l1_ratio through with the default penalty="l2", and sklearn warned "l1_ratio parameter is only used when penalty is 'elasticnet'". Callers tuning l1_ratio saw no change. Now: when a non-zero l1_ratio is supplied, penalty is set to "elasticnet" and solver defaults to "saga". Tests - test_ols_quantile_uses_full_prediction_se: widths of prediction intervals must grow with leverage (centroid vs extrapolation). - test_ols_quantile_clips_q_away_from_zero_and_one: q=0 and q=1 return finite predictions. - test_logistic_l1_ratio_activates_elasticnet: l1_ratio=0.5 must result in penalty="elasticnet" and solver="saga". * Return indexed Series from OLS _predict_quantile to preserve test index Followup to the prior commit. _predict_quantile returned a bare ndarray for numeric targets. When OLSResults._predict assembled the output DataFrame, assigning the first (numeric) column as an ndarray caused pandas to anchor the DataFrame to a default RangeIndex (0..N). A subsequent categorical prediction — a pd.Series indexed by the real X_test index — then failed index alignment and all rows came back NaN. Downstream sklearn.log_loss then raised "Input contains NaN". The symptom was specific to mixed-type imputation (numeric + binary or categorical) with a non-zero-based test index. CI on 3.14 runs the full suite and caught it via tests/test_metrics.py::test_compare_metrics_mixed_types; 3.12 only runs smoke tests so it was hidden there. Fix: _predict_quantile now returns pd.Series(values, index=mean_preds.index, name=mean_preds.name). Added test_ols_mixed_targets_preserve_test_index as a regression. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5668e9f commit fcd4b8a

3 files changed

Lines changed: 193 additions & 23 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed three OLS imputation bugs: (#6) `_OLSModel.predict` previously used `se = sqrt(model.scale)` (residual std only) to scale normal quantiles, which under-dispersed imputations for test rows far from the training centroid; now uses `statsmodels`' `model.get_prediction(X).var_pred_mean + model.scale` to include the leverage term, producing a per-row prediction SE. The quantile is also clipped to `(1e-6, 1-1e-6)` so `q=0` / `q=1` no longer produce ±inf from `norm.ppf`. (#8) `_LogisticRegressionModel.fit` previously passed `l1_ratio` to `LogisticRegression` with the default `penalty="l2"`, which silently ignored the parameter; now sets `penalty="elasticnet"` and `solver="saga"` when a non-zero `l1_ratio` is supplied so the parameter actually takes effect. (followup) `OLSResults._predict_quantile` now returns a `pd.Series` indexed to `mean_preds.index` rather than a bare `ndarray`. When a mixed-type imputation built the output `DataFrame` by assigning a numeric column (ndarray) before a categorical column (indexed Series), pandas anchored the DataFrame to a default `RangeIndex` and the subsequent categorical assignment failed to align — silently producing all-NaN categorical columns, which later broke downstream `sklearn.log_loss`.

microimpute/models/ols.py

Lines changed: 61 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -59,17 +59,29 @@ def fit(
5959
)
6060
y_encoded = y_encoded.fillna(0) # Default to first category
6161

62-
# Extract relevant LR parameters from kwargs
63-
# Use l1_ratio instead of penalty (deprecated in sklearn 1.8)
62+
# Extract relevant LR parameters from kwargs.
63+
# sklearn's LogisticRegression ignores l1_ratio unless
64+
# penalty="elasticnet" (and solver="saga"). Previously we passed
65+
# l1_ratio through with the default penalty="l2", so tuning
66+
# l1_ratio had no effect — silent misconfiguration.
67+
l1_ratio = lr_kwargs.get("l1_ratio", None)
6468
classifier_params = {
65-
"l1_ratio": lr_kwargs.get("l1_ratio", 0),
6669
"C": lr_kwargs.get("C", 1.0),
6770
"max_iter": lr_kwargs.get("max_iter", 1000),
68-
"solver": lr_kwargs.get(
69-
"solver", "lbfgs" if len(self.categories) <= 2 else "saga"
70-
),
7171
"random_state": self.seed,
7272
}
73+
if l1_ratio is not None and l1_ratio != 0:
74+
# Caller explicitly supplied a non-zero l1_ratio: wire up the
75+
# elasticnet penalty and saga solver so it actually applies.
76+
classifier_params["penalty"] = "elasticnet"
77+
classifier_params["l1_ratio"] = float(l1_ratio)
78+
classifier_params["solver"] = lr_kwargs.get("solver", "saga")
79+
else:
80+
# No elasticnet requested — use the default L2 penalty and a
81+
# solver that supports it.
82+
classifier_params["solver"] = lr_kwargs.get(
83+
"solver", "lbfgs" if len(self.categories) <= 2 else "saga"
84+
)
7385

7486
self.classifier = LogisticRegression(**classifier_params)
7587
fit_kwargs = {}
@@ -216,12 +228,22 @@ def _predict_variable(
216228
X_test[self.predictors], return_probs=False, quantile=quantile
217229
)
218230
else:
219-
# Regression for numeric targets
231+
# Regression for numeric targets.
232+
# Use the full prediction SE (leverage + residual) rather than
233+
# just sqrt(model.scale). Previously se = sqrt(scale) used only
234+
# the residual std and under-dispersed imputations for test
235+
# rows far from the training centroid; at extreme quantiles
236+
# (0.01, 0.99) the under-dispersion is material.
220237
X_test_with_const = sm.add_constant(X_test[self.predictors])
221-
mean_preds = model.predict(X_test_with_const)
222-
se = np.sqrt(model.scale)
238+
prediction = model.model.get_prediction(X_test_with_const)
239+
# var_pred_mean is the leverage term (x' (X'X)^-1 x) * scale;
240+
# adding model.scale (residual variance) gives the prediction
241+
# variance for a new observation.
242+
pred_var = np.asarray(prediction.var_pred_mean) + model.scale
243+
mean_preds = np.asarray(prediction.predicted_mean)
244+
se = np.sqrt(np.maximum(pred_var, 0.0))
223245
imputed_values = self._predict_quantile(
224-
mean_preds=mean_preds,
246+
mean_preds=pd.Series(mean_preds, index=X_test.index, name=variable),
225247
se=se,
226248
mean_quantile=quantile,
227249
random_sample=random_sample,
@@ -362,37 +384,50 @@ def _predict(
362384
def _predict_quantile(
363385
self,
364386
mean_preds: pd.Series,
365-
se: float,
387+
se: Any,
366388
mean_quantile: float,
367389
random_sample: bool,
368390
count_samples: int = 10,
369-
) -> np.ndarray:
391+
) -> pd.Series:
370392
"""Predict values at a specified quantile.
371393
372394
Args:
373395
mean_preds: Mean predictions from the model.
374-
se: Standard error of the predictions.
396+
se: Standard error of the predictions. May be a scalar (legacy,
397+
residual std) or a per-row array (prediction SE including
398+
leverage).
375399
mean_quantile: Quantile to predict (the quantile affects the center
376400
of the beta distribution from which to sample when imputing each data point).
377401
random_sample: If True, use random quantile sampling for prediction.
378402
count_samples: Number of quantile samples to generate when
379403
random_sample is True.
380404
381405
Returns:
382-
Array of predicted values at the specified quantile.
406+
Series of predicted values at the specified quantile, indexed to
407+
match ``mean_preds``. Returning a Series (rather than a bare
408+
ndarray) preserves the test-row index so downstream
409+
``DataFrame[col] = series`` assignments align correctly when a
410+
numeric column is set before a categorical column whose
411+
predictions come back indexed.
383412
384413
Raises:
385414
RuntimeError: If prediction fails.
386415
"""
416+
# Clip q away from 0 and 1 to avoid ±inf from norm.ppf (and the
417+
# degenerate a=0/a=inf case in the beta-alpha formula).
418+
# Previously mean_quantile could be 0.0 or 1.0 with no guard.
419+
q_eps = 1e-6
420+
q_clipped = float(np.clip(mean_quantile, q_eps, 1.0 - q_eps))
387421
try:
388-
if random_sample == True:
422+
if random_sample:
389423
self.logger.info(
390-
f"Predicting at random quantiles sampled from a beta distribution with mean quantile {mean_quantile}"
424+
f"Predicting at random quantiles sampled from a beta distribution with mean quantile {q_clipped}"
391425
)
392426
random_generator = np.random.default_rng(self.seed)
393427

394-
# Calculate alpha parameter for beta distribution
395-
a = mean_quantile / (1 - mean_quantile)
428+
# Calculate alpha parameter for beta distribution (q is
429+
# safely in (0,1) after clipping).
430+
a = q_clipped / (1 - q_clipped)
396431

397432
# Generate count_samples beta distributed values with parameter a
398433
beta_samples = random_generator.beta(a, 1, size=count_samples)
@@ -406,12 +441,15 @@ def _predict_quantile(
406441
)
407442
selected_quantiles = normal_quantiles[sampled_indices]
408443

409-
# Adjust each mean prediction by corresponding sampled quantile times standard error
410-
return mean_preds + selected_quantiles * se
444+
# Adjust each mean prediction by the sampled quantile
445+
# times its per-row SE (or scalar SE if se is a float).
446+
values = mean_preds.values + selected_quantiles * np.asarray(se)
411447
else:
412-
self.logger.info(f"Predicting at specified quantile {mean_quantile}")
413-
specified_quantile = norm.ppf(mean_quantile)
414-
return mean_preds + specified_quantile * se
448+
self.logger.info(f"Predicting at specified quantile {q_clipped}")
449+
specified_quantile = norm.ppf(q_clipped)
450+
values = mean_preds.values + specified_quantile * np.asarray(se)
451+
452+
return pd.Series(values, index=mean_preds.index, name=mean_preds.name)
415453

416454
except Exception as e:
417455
if isinstance(e, ValueError):

tests/test_models/test_ols.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,3 +258,134 @@ def test_ols_prediction_quality(diabetes_data: pd.DataFrame) -> None:
258258

259259
# Check prediction variance is reasonable
260260
assert np.var(pred_values) > 0, "OLS predictions have no variance"
261+
262+
263+
def test_ols_quantile_uses_full_prediction_se() -> None:
264+
"""Regression test for #6: OLS quantile prediction must use the full
265+
prediction SE (leverage + residual) rather than sqrt(scale). The
266+
prediction SE for a new row is strictly greater than the residual
267+
std; the gap grows for rows far from the training centroid.
268+
269+
We verify the fix by checking that (a) at a fixed quantile, the
270+
prediction interval at an extreme x is WIDER than at the centroid
271+
(which the old implementation could not produce — both used the
272+
same residual std), and (b) at q=0.99 the quantile prediction for
273+
an extreme row is larger than the residual-std-only formulation
274+
would have given.
275+
"""
276+
rng = np.random.default_rng(0)
277+
n = 200
278+
x = rng.normal(size=n)
279+
y = 2.0 * x + rng.normal(size=n) * 0.3
280+
train = pd.DataFrame({"x": x, "y": y})
281+
282+
model = OLS()
283+
fitted = model.fit(train, ["x"], ["y"])
284+
285+
# One test row at the centroid, one far outside the support.
286+
x_test = pd.DataFrame({"x": [0.0, 10.0]})
287+
upper = fitted.predict(x_test, quantiles=[0.99])[0.99]["y"].values
288+
lower = fitted.predict(x_test, quantiles=[0.01])[0.01]["y"].values
289+
widths = upper - lower
290+
291+
# The extrapolated point must have a wider prediction interval than
292+
# the centroid (leverage effect). With the old se = sqrt(scale),
293+
# both rows had identical widths.
294+
assert widths[1] > widths[0], (
295+
"Prediction SE must grow with leverage; widths were "
296+
f"{widths}, indicating residual-std-only (pre-fix) behaviour"
297+
)
298+
299+
300+
def test_ols_quantile_clips_q_away_from_zero_and_one() -> None:
301+
"""Regression test for #6: q=0 and q=1 previously produced ±inf via
302+
norm.ppf; the clipped implementation should return finite values."""
303+
rng = np.random.default_rng(0)
304+
n = 100
305+
x = rng.normal(size=n)
306+
y = 2.0 * x + rng.normal(size=n) * 0.3
307+
train = pd.DataFrame({"x": x, "y": y})
308+
309+
model = OLS()
310+
fitted = model.fit(train, ["x"], ["y"])
311+
312+
x_test = pd.DataFrame({"x": [0.0]})
313+
preds_0 = fitted.predict(x_test, quantiles=[0.0])[0.0]["y"].values
314+
preds_1 = fitted.predict(x_test, quantiles=[1.0])[1.0]["y"].values
315+
316+
assert np.all(np.isfinite(preds_0)), "q=0 produced non-finite predictions"
317+
assert np.all(np.isfinite(preds_1)), "q=1 produced non-finite predictions"
318+
319+
320+
def test_ols_mixed_targets_preserve_test_index() -> None:
321+
"""Regression test: when OLS imputes a DataFrame containing both a
322+
numeric target (returned via the OLS path) and a categorical/boolean
323+
target (returned via the logistic-regression path), the predictions
324+
assembled into the output DataFrame must align by the X_test index.
325+
326+
The bug: the numeric path previously returned a bare ndarray. When
327+
that ndarray was assigned as the first column of a fresh empty
328+
DataFrame, the DataFrame took on a default RangeIndex (0..N). The
329+
subsequent categorical prediction (a pd.Series with the real
330+
X_test index, e.g. 160..199) then failed to align on assignment
331+
and the whole column came back as NaN — which later produced
332+
``Input contains NaN`` in sklearn's log_loss.
333+
"""
334+
rng = np.random.default_rng(42)
335+
n = 200
336+
df = pd.DataFrame(
337+
{
338+
"num_pred1": rng.normal(size=n),
339+
"num_pred2": rng.normal(size=n) * 2 + 1,
340+
"num_target": rng.normal(size=n) * 3,
341+
"binary_target": rng.choice([0, 1], size=n),
342+
}
343+
)
344+
# Split so the test slice has a non-zero-based index.
345+
train_data = df.iloc[:160].copy()
346+
test_data = df.iloc[160:].copy()
347+
348+
model = OLS()
349+
fitted = model.fit(
350+
train_data,
351+
predictors=["num_pred1", "num_pred2"],
352+
imputed_variables=["num_target", "binary_target"],
353+
)
354+
predictions = fitted.predict(test_data, quantiles=[0.5])
355+
out = predictions[0.5]
356+
357+
assert not out["num_target"].isna().any(), "num_target should not contain NaN"
358+
assert not out["binary_target"].isna().any(), (
359+
"binary_target should not be NaN; the output DataFrame index must "
360+
"align with the X_test index so the categorical Series assignment "
361+
"lines up with the numeric column"
362+
)
363+
# Index must match the test slice, not a fresh RangeIndex.
364+
assert list(out.index) == list(test_data.index)
365+
366+
367+
def test_logistic_l1_ratio_activates_elasticnet() -> None:
368+
"""Regression test for #8: passing l1_ratio must activate the
369+
elasticnet penalty (and saga solver). Previously l1_ratio was
370+
passed through with the default L2 penalty and was silently ignored.
371+
"""
372+
from microimpute.models.ols import _LogisticRegressionModel
373+
import logging
374+
375+
rng = np.random.default_rng(0)
376+
n = 100
377+
X = pd.DataFrame(rng.normal(size=(n, 3)), columns=["a", "b", "c"])
378+
y = pd.Series((X["a"] + rng.normal(size=n) > 0).astype(int), name="y")
379+
380+
model = _LogisticRegressionModel(seed=0, logger=logging.getLogger("test"))
381+
model.fit(X, y, var_type="boolean", l1_ratio=0.5)
382+
383+
# When l1_ratio=0.5, penalty must be "elasticnet" and solver "saga"
384+
# so l1_ratio actually has an effect.
385+
assert model.classifier.penalty == "elasticnet", (
386+
f"Expected penalty='elasticnet' with l1_ratio=0.5, got "
387+
f"penalty={model.classifier.penalty!r} (l1_ratio silently ignored)"
388+
)
389+
assert model.classifier.solver == "saga", (
390+
f"Expected solver='saga' for elasticnet, got {model.classifier.solver!r}"
391+
)

0 commit comments

Comments
 (0)