From 08fc63e0ace0f051d8a40df3e2923b39cb55878a Mon Sep 17 00:00:00 2001 From: ChrisW09 <50968720+ChrisW09@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:48:44 +0200 Subject: [PATCH 01/16] fix(splines): expand a missing value to a NaN row in the B/M/I-spline basis The B-, M- and I-spline transformers turned a NaN input into a finite all-zero basis row (np.nan_to_num in each design matrix, a leftover from the old extrapolate=False evaluation). For the I-spline that row is exactly the encoding of the training minimum, so "missing" and "minimum" became indistinguishable; for the B/M-spline it is impossible data. This was reachable standalone and from the Preprocessor with missing_policy="propagate" or numerical_imputation=None, while every other spline family and feature map propagates NaN. The shared transform now evaluates only the observed rows and leaves the rows of missing values NaN in that feature's basis block (an optional bias column stays 1). Finite rows are unchanged bit for bit under every out_of_range policy, and a missing value does not trigger the "warn" / "error" policies. The Preprocessor numerical_imputation docstring no longer claims that every numerical method raises on missing values. Fixes #87 --- pretab/expansion/spline/base.py | 18 ++++++- pretab/preprocessor.py | 6 ++- .../spline/test_spline_expansions.py | 49 ++++++++++++++++++- tests/integration/test_edge_case_contract.py | 5 +- tests/integration/test_missing_policy.py | 15 ++++++ 5 files changed, 86 insertions(+), 7 deletions(-) diff --git a/pretab/expansion/spline/base.py b/pretab/expansion/spline/base.py index 73cfa64..070ca9f 100644 --- a/pretab/expansion/spline/base.py +++ b/pretab/expansion/spline/base.py @@ -131,6 +131,10 @@ class BaseSplineTransformer(BasePreTabTransformer): takes precedence over the automatic ``output_dim`` strategy. Multi-column input is expanded column by column and stacked horizontally. + Missing values are ignored for knot placement; at transform time a missing value + expands to a row of NaN in its feature's basis block (an optional bias column + stays 1), as in the other spline families. + Examples -------- >>> import numpy as np @@ -354,7 +358,12 @@ def transform(self, X): for i in range(X.shape[1]): knots = self.knots_[i] xi = resolve_out_of_range(X[:, i], knots[0], knots[-1], self._resolved_policy(), estimator=self) - design = self._design_matrix(xi, knots) + # A missing value has no position on the basis: only the observed rows + # are evaluated and the missing ones stay NaN (the "propagate" contract + # of the other spline families) instead of becoming a finite basis row. + observed = ~np.isnan(xi) + design = np.full((len(xi), self.n_basis_[i]), np.nan) + design[observed] = self._design_matrix(xi[observed], knots) if self.include_bias: design = np.hstack([np.ones((design.shape[0], 1)), design]) transformed.append(design) @@ -366,7 +375,12 @@ def fit_transform(self, X, y=None): return self.fit(X, y).transform(X) def _design_matrix(self, x: np.ndarray, knots: np.ndarray) -> np.ndarray: - """Return the basis matrix for a single feature (without the bias column).""" + """Return the basis matrix for a single feature (without the bias column). + + ``x`` holds only the observed (non-missing) values, already resolved + against the out-of-range policy; :meth:`transform` fills the rows of + missing values with NaN. + """ raise NotImplementedError def _feature_suffix(self) -> str: diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index d11511b..fb4b201 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -232,8 +232,10 @@ class Preprocessor(TransformerMixin, BaseEstimator): numerical_imputation : str or None, default="median" Strategy for the ``SimpleImputer`` that runs *before* every numerical method. Accepts any ``sklearn`` strategy (``"median"``, ``"mean"``, ``"most_frequent"``, ``"constant"``). - ``None`` disables imputation, so NaNs reach the numerical transformers unchanged and the - finite-input methods (all numerical methods, including PLE) raise on missing values. + ``None`` disables imputation, so NaNs reach the numerical transformers unchanged: the + scalers, splines and ``rbf`` / ``relu`` / ``sigmoid`` / ``tanh`` feature maps propagate a + missing value as NaN in that feature's output, while the finite-input methods + (``"ple"``, ``"custombin"``, ``"fourier"``, ``"polynomial"``) raise. categorical_imputation : str or None, default="most_frequent" Strategy for the ``SimpleImputer`` that runs *before* every categorical method. ``None`` disables imputation for categorical columns. diff --git a/tests/expansion/spline/test_spline_expansions.py b/tests/expansion/spline/test_spline_expansions.py index 4416d83..6843465 100644 --- a/tests/expansion/spline/test_spline_expansions.py +++ b/tests/expansion/spline/test_spline_expansions.py @@ -1,3 +1,5 @@ +import warnings + import numpy as np import pytest @@ -110,12 +112,57 @@ def test_mspline_non_negative(data): def test_mspline_handles_nan(): + """A missing value is left out of knot placement and expands to a NaN row.""" X = np.linspace(0, 1, 50).reshape(-1, 1) X[5] = np.nan transformer = MSplineTransformer(output_dim=6) Xt = transformer.fit_transform(X) assert Xt.shape == (50, 6) - assert np.isfinite(Xt).all() + assert np.isnan(Xt[5]).all() + assert np.isfinite(np.delete(Xt, 5, axis=0)).all() + + +# --- missing values expand to NaN basis rows ------------------------------------------- + + +@pytest.mark.parametrize("cls", [BSplineTransformer, MSplineTransformer, ISplineTransformer]) +@pytest.mark.parametrize("include_bias", [False, True]) +@pytest.mark.parametrize("out_of_range", ["clip", "extrapolate", "warn", "error"]) +def test_bmi_spline_missing_value_expands_to_a_nan_row(cls, include_bias, out_of_range): + """A missing value became a finite all-zero basis row: impossible data for the + B/M-spline and, for the I-spline, the exact encoding of the training minimum.""" + X = np.linspace(0.0, 10.0, 50).reshape(-1, 1) + transformer = cls(output_dim=5, include_bias=include_bias, policy={"out_of_range": out_of_range}).fit(X) + observed = np.array([[0.0], [2.5], [10.0]]) + if out_of_range in ("clip", "extrapolate"): + observed = np.vstack([observed, [[-3.0], [14.0]]]) + expected = transformer.transform(observed) + + probe = np.insert(observed, [0, 2], np.nan, axis=0) + with warnings.catch_warnings(): + # A missing value is not out of range: it must not trigger "warn" / "error". + warnings.simplefilter("error") + out = transformer.transform(probe) + + missing = np.isnan(probe[:, 0]) + basis = slice(1, None) if include_bias else slice(None) + assert np.isnan(out[missing, basis]).all() + if include_bias: + np.testing.assert_array_equal(out[:, 0], 1.0) + np.testing.assert_array_equal(out[~missing], expected) + + +@pytest.mark.parametrize("cls", [BSplineTransformer, MSplineTransformer, ISplineTransformer]) +def test_bmi_spline_missing_value_only_affects_its_own_feature_block(cls): + rng = np.random.default_rng(0) + X = rng.uniform(0.0, 10.0, size=(60, 2)) + X[4, 0] = np.nan + X[9, 1] = np.nan + transformer = cls(output_dim=6).fit(X) + out = transformer.transform(X) + np.testing.assert_array_equal(np.isnan(out[:, :6]).any(axis=1), np.isnan(X[:, 0])) + np.testing.assert_array_equal(np.isnan(out[:, 6:]).any(axis=1), np.isnan(X[:, 1])) + assert np.isnan(out[4, :6]).all() and np.isnan(out[9, 6:]).all() @pytest.mark.parametrize("scale", [1.0, 1e-2, 1e-3, 1e-7]) diff --git a/tests/integration/test_edge_case_contract.py b/tests/integration/test_edge_case_contract.py index 97e8b07..1cedf8b 100644 --- a/tests/integration/test_edge_case_contract.py +++ b/tests/integration/test_edge_case_contract.py @@ -84,9 +84,10 @@ def _factory(name): ALL_FAMILIES = CONSTANT_RAISES + CONSTANT_GRACEFUL # Families that let missing values pass through the basis (NaN in -> NaN row out). -# The B/M/I splines instead clip a missing value to the fitted boundary, so they -# are intentionally excluded here. NAN_PROPAGATING = [ + "BSpline", + "MSpline", + "ISpline", "RBF", "ReLU", "Sigmoid", diff --git a/tests/integration/test_missing_policy.py b/tests/integration/test_missing_policy.py index 483d9c3..79738c8 100644 --- a/tests/integration/test_missing_policy.py +++ b/tests/integration/test_missing_policy.py @@ -98,6 +98,21 @@ def test_propagate_lets_nan_through(frame_with_nan, y): assert np.isnan(out).any() +@pytest.mark.parametrize("method", ["bspline", "mspline", "ispline"]) +def test_propagate_expands_a_missing_value_to_a_nan_basis_row(method): + """The B/M/I splines encoded a missing value as a finite all-zero row, which for + the I-spline is exactly the row of the smallest observed value.""" + rng = np.random.default_rng(0) + frame = pd.DataFrame({"x": rng.uniform(0, 10, 300)}) + frame.loc[0, "x"] = np.nan + pre = Preprocessor( + numerical_method=method, missing_policy="propagate", target_aware=False, placement_strategy="uniform" + ).fit(frame, rng.normal(size=300)) + out = np.asarray(pre.transform(frame, return_array=True)) + assert np.isnan(out[0]).all() + assert np.isfinite(out[1:]).all() + + # --- impute -------------------------------------------------------------------- From 78664a5bb5c2bb412d611d46dab29d19dc6e84fb Mon Sep 17 00:00:00 2001 From: ChrisW09 <50968720+ChrisW09@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:53:14 +0200 Subject: [PATCH 02/16] fix(splines): ignore y on the unsupervised B/M/I-spline path The B-, M- and I-spline transformers flattened y and masked it with each feature's missing-value mask even when no target-aware selector used it, so a multi-output target of n rows (2n values after ravel) raised an IndexError in fit. make_pipeline(BSplineTransformer(), Ridge()).fit(X, Y) with a 2-D Y failed, as did the Preprocessor with numerical_method="bspline" / "mspline" / "ispline" and target_aware=False, while scikit-learn's SplineTransformer and the other spline families and feature maps ignore y there. y is now read only when the target-aware selector places the knots; the unsupervised and explicit knot_locations paths ignore it. On the selector path y is sliced by rows like X instead of being flattened, matching the other target-aware families; knots are unchanged for 1-D and column-vector targets. Fixes #88 --- pretab/expansion/spline/base.py | 9 ++-- .../spline/test_spline_expansions.py | 46 +++++++++++++++++++ tests/integration/test_preprocessor.py | 12 +++++ 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/pretab/expansion/spline/base.py b/pretab/expansion/spline/base.py index 070ca9f..e6a4cf1 100644 --- a/pretab/expansion/spline/base.py +++ b/pretab/expansion/spline/base.py @@ -74,7 +74,8 @@ class BaseSplineTransformer(BasePreTabTransformer): target_aware : bool, default=False If True, knots are placed by a target-aware selector built from ``placement_strategy`` (requires ``y`` during fit). If False, knots are - placed by the unsupervised ``placement_strategy`` spacing. + placed by the unsupervised ``placement_strategy`` spacing and ``y`` is + ignored (as it is with explicit ``knot_locations``). placement_strategy : {"cart", "lightgbm", "uniform", "quantile"}, default="quantile" When ``target_aware=True``, the selector: ``"cart"`` or ``"lightgbm"``. @@ -311,8 +312,6 @@ def fit(self, X, y=None): X = self._validate(X, reset=True) - y_arr = None if y is None else np.asarray(y).ravel() - # Knot placement priority: explicit knot_locations win, then a target-aware # selector built from placement_strategy, then the automatic (unsupervised) # spacing named by placement_strategy. @@ -327,6 +326,10 @@ def fit(self, X, y=None): else: selector = None strategy = self.placement_strategy if not self.target_aware else "quantile" + # Only the target-aware selector reads y; the unsupervised and explicit-knot + # paths ignore it, so any target shape (e.g. multi-output) can pass through + # a Pipeline. For the selector, y is sliced by rows like X, not flattened. + y_arr = None if y is None or selector is None else np.asarray(y) self.knots_ = [] for i in range(X.shape[1]): diff --git a/tests/expansion/spline/test_spline_expansions.py b/tests/expansion/spline/test_spline_expansions.py index 6843465..333f567 100644 --- a/tests/expansion/spline/test_spline_expansions.py +++ b/tests/expansion/spline/test_spline_expansions.py @@ -338,3 +338,49 @@ def test_invalid_knot_locations_raise_a_typed_error(knots): with pytest.raises(InvalidParamError, match="knot_locations"): BSplineTransformer(knot_locations=knots).fit(np.linspace(0, 10, 50).reshape(-1, 1)) + + +# --- y is only read by the target-aware selector ------------------------------------- + + +@pytest.mark.parametrize("cls", [BSplineTransformer, MSplineTransformer, ISplineTransformer]) +@pytest.mark.parametrize( + "params", + [ + {}, + {"placement_strategy": "uniform"}, + {"target_aware": True, "placement_strategy": "cart", "knot_locations": [-1.0, 0.0, 1.0]}, + ], +) +def test_unsupervised_bmi_spline_ignores_a_multi_output_target(cls, params): + """The target was flattened and masked even when no selector used it, so a 2-D y + of n rows (2n values) raised an IndexError in fit.""" + rng = np.random.default_rng(0) + X = rng.normal(size=(200, 2)) + X[3, 0] = np.nan + Y = np.column_stack([np.sin(X[:, 0]), np.cos(X[:, 1])]) + reference = cls(**params).fit(X) + fitted = cls(**params).fit(X, Y) + for knots, expected in zip(fitted.knots_, reference.knots_, strict=True): + np.testing.assert_array_equal(knots, expected) + np.testing.assert_array_equal(fitted.transform(X), reference.transform(X)) + + +@pytest.mark.parametrize("cls", [BSplineTransformer, MSplineTransformer, ISplineTransformer]) +def test_bmi_spline_fits_in_a_multi_output_pipeline(cls): + from sklearn.linear_model import Ridge + from sklearn.pipeline import make_pipeline + + rng = np.random.default_rng(0) + X = rng.normal(size=(200, 1)) + Y = np.column_stack([np.sin(X[:, 0]), np.cos(X[:, 0])]) + assert make_pipeline(cls(), Ridge()).fit(X, Y).predict(X).shape == (200, 2) + + +@pytest.mark.parametrize("cls", [BSplineTransformer, MSplineTransformer, ISplineTransformer]) +def test_target_aware_bmi_spline_accepts_a_column_vector_target(cls): + X, y = _step_data() + X[::50, 0] = np.nan + flat = cls(output_dim=6, target_aware=True, placement_strategy="cart").fit(X, y) + column = cls(output_dim=6, target_aware=True, placement_strategy="cart").fit(X, y.reshape(-1, 1)) + np.testing.assert_array_equal(column.knots_[0], flat.knots_[0]) diff --git a/tests/integration/test_preprocessor.py b/tests/integration/test_preprocessor.py index 04ba234..bb6c9dd 100644 --- a/tests/integration/test_preprocessor.py +++ b/tests/integration/test_preprocessor.py @@ -595,3 +595,15 @@ def test_positional_input_of_the_wrong_width_is_rejected(mixed_named_frame): array_fit = Preprocessor(numerical_method="minmax").fit(numeric.to_numpy(), y) with pytest.raises(PretabDataError, match="X has 1 features"): array_fit.transform(X[["age"]]) + + +@pytest.mark.parametrize("method", ["bspline", "mspline", "ispline", "pspline", "naturalspline", "rbf"]) +def test_unsupervised_numerical_method_accepts_a_multi_output_target(method): + """The B/M/I splines flattened y even when target_aware=False, so a 2-D target + raised an IndexError in fit while the other methods ignored it.""" + rng = np.random.default_rng(0) + frame = pd.DataFrame({"a": rng.normal(size=200), "b": rng.normal(size=200)}) + Y = np.column_stack([np.sin(frame["a"]), np.cos(frame["b"])]) + options = {"numerical_method": method, "target_aware": False, "placement_strategy": "uniform"} + reference = Preprocessor(**options).fit(frame).transform(frame) + np.testing.assert_array_equal(Preprocessor(**options).fit(frame, Y).transform(frame), reference) From f3dda377cdcbbc84ab829318e240129f23f07b83 Mon Sep 17 00:00:00 2001 From: ChrisW09 <50968720+ChrisW09@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:46:22 +0200 Subject: [PATCH 03/16] fix(cross-fitting): keep a wrapped Preprocessor's missing indicators across folds CrossFittedTransformer(Preprocessor(add_missing_indicator=True)) and missing_policy="impute_with_indicator" raised a width-mismatch error whenever a fold's training rows held no missing value for a target-aware column with missing values (always for a column with a single NaN). Each fold refit the whole per-column block, including its MissingIndicator, which only emits a column for features that had missing values at fit. A target-aware block that is a representation + missing union now keeps its all-data-fitted missing branch and refits only the representation branch on the fold's training rows, so the out-of-fold features have the columns of transform and out-of-fold rows still never see their own target. The width-mismatch message now describes the general cause instead of adaptive sizing only. Fixes #89 --- docs/core_concepts/target_awareness.md | 5 +++ pretab/compose/inspection.py | 24 ++++++++++- pretab/core/supervised.py | 5 ++- pretab/preprocessor.py | 23 +++++----- tests/core/test_cross_fitted.py | 58 ++++++++++++++++++++++++++ 5 files changed, 102 insertions(+), 13 deletions(-) diff --git a/docs/core_concepts/target_awareness.md b/docs/core_concepts/target_awareness.md index d3eccf2..ffcb214 100644 --- a/docs/core_concepts/target_awareness.md +++ b/docs/core_concepts/target_awareness.md @@ -72,6 +72,11 @@ held-out fold, so every training row is transformed by a model that never saw it `transform` on genuinely new data (a validation or test set) instead uses one model fit on all the training data, since there is no leakage risk there. +A wrapped `Preprocessor` is not refit from scratch on each fold: every fold reuses its all-data +fit and refits only the target-aware representations. Column types, category codes, +missing-value indicators and the blocks that do not use `y` are shared, so the out-of-fold +features have exactly the columns and encoding `transform` produces. + ```python import numpy as np diff --git a/pretab/compose/inspection.py b/pretab/compose/inspection.py index 1f27c05..d8e163a 100644 --- a/pretab/compose/inspection.py +++ b/pretab/compose/inspection.py @@ -8,10 +8,11 @@ """ import copy -from typing import Any +from typing import Any, cast import numpy as np import pandas as pd +from sklearn.base import clone from sklearn.pipeline import FeatureUnion, Pipeline from ..core.logging import get_logger @@ -28,6 +29,7 @@ "clean_feature_names", "feature_names_out", "get_output_slices", + "refit_block_representation", "representation_leaf", ] @@ -402,6 +404,26 @@ def block_uses_target(transformer, columns) -> bool: return bool(_resolve_block_representation(representation, columns)[2]) +def refit_block_representation(transformer, X, y): + """Return a copy of a fitted per-column block whose representation is refit on ``(X, y)``. + + For a separate-state / missing-indicator union only the representation + branch is refit; the fitted missing branch is kept, since it never uses the + target and a ``MissingIndicator`` refit on rows without missing values would + drop its column (and so change the block's width). Any other block is cloned + and refit as a whole. The fitted ``transformer`` itself is left untouched. + """ + separate_state = _separate_state_branches(transformer) + if separate_state is None: + return cast(Any, clone(transformer)).fit(X, y) + representation = cast(Any, clone(separate_state[0])).fit(X, y) + union = copy.copy(transformer) + union.transformer_list = [ + (name, representation if name == "representation" else branch) for name, branch in transformer.transformer_list + ] + return union + + def _passthrough_source(columns, offset, feature_names_in): """Resolve the source feature name for a passthrough / remainder column.""" column = columns[offset] if offset < len(columns) else columns[-1] diff --git a/pretab/core/supervised.py b/pretab/core/supervised.py index b221b10..e15f9b9 100644 --- a/pretab/core/supervised.py +++ b/pretab/core/supervised.py @@ -229,8 +229,9 @@ def fit_transform(self, X, y=None): raise IncompatibleParamsError( "Cross-fitting requires a fixed output width across folds; expected " f"{width}, got {fold_out.shape[1]}. The wrapped transformer's width " - "depends on the training rows (e.g. adaptive sizing); fix it, for " - "example by disabling adaptive sizing." + "depends on the rows it is fit on (e.g. adaptive sizing, or category / " + "indicator columns learned from the data); configure it to produce the " + "same columns on every fold." ) out[test_idx] = fold_out finally: diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index fb4b201..40276aa 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -5,7 +5,7 @@ import os import time import warnings -from typing import Any, cast +from typing import cast import numpy as np from scipy import sparse as sp @@ -29,6 +29,7 @@ build_transformer_summary, feature_names_out, get_output_slices, + refit_block_representation, representation_leaf, ) from .compose.output import ( @@ -939,16 +940,18 @@ def _align_input(self, X): return X.infer_objects() if is_array else X def _cross_fit_fold(self, X, y): - """Return a copy of this fit whose target-aware blocks are refit on ``(X, y)``. + """Return a copy of this fit whose target-aware representations are refit on ``(X, y)``. Used by :class:`~pretab.CrossFittedTransformer` for its out-of-fold - features. Only the blocks that consume the target are refit on the fold; - column types, category vocabularies and every other block are shared - with this all-data fit, so an out-of-fold row is encoded exactly like - ``transform`` encodes it, except that no target-aware placement has seen - that row's target. Refitting the whole preprocessor per fold would - re-detect column types and relearn categories on fewer rows, shifting - integer codes and one-hot columns between the folds and ``transform``. + features. Only the representations that consume the target are refit on + the fold; column types, category vocabularies, missing-value indicators + and every other block are shared with this all-data fit, so an + out-of-fold row is encoded exactly like ``transform`` encodes it, except + that no target-aware placement has seen that row's target. Refitting the + whole preprocessor per fold would re-detect column types and relearn + categories on fewer rows, shifting integer codes and one-hot columns + between the folds and ``transform``; refitting a block's missing + indicator would drop its column on a fold without missing values. """ check_is_fitted(self) X_ct = self._column_transformer_input(self._align_input(X)) @@ -956,7 +959,7 @@ def _cross_fit_fold(self, X, y): column_transformer.transformers_ = [ ( name, - cast(Any, clone(transformer)).fit(X_ct[list(columns)], y) + refit_block_representation(transformer, X_ct[list(columns)], y) if name != "remainder" and block_uses_target(transformer, columns) else transformer, columns, diff --git a/tests/core/test_cross_fitted.py b/tests/core/test_cross_fitted.py index 515ed70..fe83b83 100644 --- a/tests/core/test_cross_fitted.py +++ b/tests/core/test_cross_fitted.py @@ -276,3 +276,61 @@ def test_cross_fitting_densifies_sparse_output_and_rejects_blocks(frame_with_rar blocks = Preprocessor(output_structure="blocks", random_state=0) with pytest.raises(IncompatibleParamsError, match="dict of blocks"): CrossFittedTransformer(blocks, n_folds=3, random_state=0).fit_transform(X, y) + + +# --- a wrapped Preprocessor shares its missing indicators across folds -------------- + + +@pytest.fixture +def frame_with_one_missing_value(): + import pandas as pd + + rng = np.random.default_rng(0) + X = pd.DataFrame({"a": rng.normal(size=200), "b": rng.normal(size=200)}) + X.loc[7, "a"] = np.nan # the fold holding out row 7 trains on no missing value + y = X["a"].fillna(0.0).to_numpy() + rng.normal(scale=0.1, size=200) + return X, y + + +@pytest.mark.parametrize("missing", [{"add_missing_indicator": True}, {"missing_policy": "impute_with_indicator"}]) +def test_wrapped_preprocessor_keeps_its_missing_indicator_columns(frame_with_one_missing_value, missing): + """Each fold refit the block's MissingIndicator, which emits no column when the + fold's training rows contain no missing value, so the width check failed.""" + from pretab import Preprocessor + + X, y = frame_with_one_missing_value + wrapped = Preprocessor(numerical_method="ple", output_dim=4, random_state=0, **missing) + cross_fitted = CrossFittedTransformer(wrapped, n_folds=5, random_state=0) + out = cross_fitted.fit_transform(X, y) + + assert out.shape == np.asarray(cross_fitted.transform(X)).shape + indicator = _split_columns(cross_fitted, "num_a__missingindicator") + assert len(indicator) == 1 + np.testing.assert_array_equal(out[:, indicator[0]], X["a"].isna().to_numpy(dtype=float)) + + +def test_wrapped_preprocessor_refits_only_the_representation_next_to_an_indicator(frame_with_one_missing_value): + from pretab import Preprocessor + + X, y = frame_with_one_missing_value + + def cross_fit(missing_policy): + wrapped = Preprocessor(numerical_method="ple", output_dim=4, missing_policy=missing_policy, random_state=0) + cross_fitted = CrossFittedTransformer(wrapped, n_folds=5, random_state=0) + return cross_fitted, cross_fitted.fit_transform(X, y) + + with_indicator, out = cross_fit("impute_with_indicator") + _, imputed_only = cross_fit("impute") + names = list(with_indicator.get_feature_names_out()) + representation = [i for i, name in enumerate(names) if "missingindicator" not in name] + np.testing.assert_allclose(out[:, representation], imputed_only) + + +def test_width_mismatch_names_the_general_cause(): + from sklearn.preprocessing import OneHotEncoder + + x = np.array(["a"] * 99 + ["b"]).reshape(-1, 1) # the fold holding out "b" never sees it + y = np.arange(100.0) + cross_fitted = CrossFittedTransformer(OneHotEncoder(handle_unknown="ignore"), n_folds=5, random_state=0) + with pytest.raises(IncompatibleParamsError, match="same columns on every fold"): + cross_fitted.fit_transform(x, y) From 96bcd7c4e3273fdc27268a7f159c9e598aca73e2 Mon Sep 17 00:00:00 2001 From: ChrisW09 <50968720+ChrisW09@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:28:50 +0200 Subject: [PATCH 04/16] fix(cross-fitting): return the kind of output transform returns from fit_transform CrossFittedTransformer.fit_transform wrote the out-of-fold rows into a float64 buffer and densified sparse fold output, so it disagreed with transform: a wrapped Preprocessor(categorical_method=None) raised on its unchanged string columns, output_format="sparse" gave a dense array (bypassing the memory the sparse format saves) where transform gave CSR, dtype=np.float32 gave float64, and a DataFrame-returning estimator gave an ndarray. The fold outputs are now collected and stacked in the original row order with their own container: sparse blocks are stacked with scipy.sparse.vstack in the transform format, pandas / polars DataFrames are concatenated (keeping the input index when the blocks carry it, renumbered otherwise), and dense blocks take their common dtype. The width check still runs on every fold, a DataFrame fold whose columns differ from the all-data fit raises instead of being misaligned by label, and dict output still raises. Fixes #90 --- docs/core_concepts/target_awareness.md | 4 +- pretab/core/supervised.py | 74 +++++++++++++-- tests/core/test_cross_fitted.py | 122 ++++++++++++++++++++++++- 3 files changed, 188 insertions(+), 12 deletions(-) diff --git a/docs/core_concepts/target_awareness.md b/docs/core_concepts/target_awareness.md index ffcb214..d99902f 100644 --- a/docs/core_concepts/target_awareness.md +++ b/docs/core_concepts/target_awareness.md @@ -70,7 +70,9 @@ this for the training features specifically. It splits the training data into fo fresh copy of the transformer on all folds _except_ one, and uses that copy to transform the held-out fold, so every training row is transformed by a model that never saw its own target. `transform` on genuinely new data (a validation or test set) instead uses one model fit on all -the training data, since there is no leakage risk there. +the training data, since there is no leakage risk there. Both return the same kind of output, +the one the wrapped transformer produces: a dense array of the same dtype, a sparse matrix in +the same format, or a DataFrame. A wrapped `Preprocessor` is not refit from scratch on each fold: every fold reuses its all-data fit and refits only the target-aware representations. Column types, category codes, diff --git a/pretab/core/supervised.py b/pretab/core/supervised.py index e15f9b9..6801767 100644 --- a/pretab/core/supervised.py +++ b/pretab/core/supervised.py @@ -16,9 +16,10 @@ import sys import warnings from dataclasses import replace -from typing import cast +from typing import Any, cast import numpy as np +import pandas as pd from scipy import sparse as sp from sklearn.base import BaseEstimator, TransformerMixin, clone from sklearn.model_selection import KFold, StratifiedKFold @@ -107,6 +108,40 @@ def _take_rows(X, indices): return X.iloc[indices] if hasattr(X, "iloc") else X[indices] +def _stack_folds(blocks, test_indices, X) -> Any: + """Stack the out-of-fold ``blocks`` and put their rows back in the order of ``X``. + + ``blocks[i]`` holds the transformed rows ``test_indices[i]``. The result keeps + the kind of output the wrapped transformer returns: sparse blocks stay sparse + (in the format of the first block), pandas / polars DataFrames keep their + columns and dtypes, and dense blocks take their common dtype (``object`` for + strings left unchanged). A pandas DataFrame keeps the index of ``X`` when the + blocks carry it (scikit-learn's ``set_output`` copies the input index); + otherwise it is renumbered from 0, as ``transform`` numbers the rows of a + transformer that builds its own index (such as a Preprocessor). + """ + rows = np.concatenate(test_indices) + # order[i] is the position of input row i among the stacked fold rows. + order = np.empty_like(rows) + order[rows] = np.arange(len(rows)) + first = blocks[0] + if all(sp.issparse(block) for block in blocks): + return sp.vstack(blocks, format="csr")[order].asformat(first.format) + if all(isinstance(block, pd.DataFrame) for block in blocks): + stacked = pd.concat(blocks).iloc[order] + index = getattr(X, "index", None) + return stacked if index is not None and stacked.index.equals(index) else stacked.reset_index(drop=True) + if type(first).__module__.partition(".")[0] == "polars": + import polars as pl # type: ignore + + return pl.concat(blocks)[order] + dense = [block.toarray() if sp.issparse(block) else np.asarray(block) for block in blocks] + out = np.empty((len(rows), first.shape[1]), dtype=np.result_type(*dense)) + for block, test_idx in zip(dense, test_indices, strict=True): + out[test_idx] = block + return out + + class CrossFittedTransformer(RepresentationSpecMixin, TransformerMixin, BaseEstimator): """Cross-fit a supervised transformer to remove target leakage on training data. @@ -114,6 +149,8 @@ class CrossFittedTransformer(RepresentationSpecMixin, TransformerMixin, BaseEsti training fold and used to transform the held-out fold, so every training row is encoded by a model that never saw its own target. :meth:`transform` (for unseen data) uses ``estimator_``, a single transformer fit on all data. + Both return the wrapped transformer's kind of output: a dense array of the + same dtype, a sparse matrix in the same format, or a DataFrame. Parameters ---------- @@ -208,10 +245,20 @@ def _fit_fold(self, X, y): return fold def fit_transform(self, X, y=None): - """Fit and return leakage-free out-of-fold features for the training data.""" + """Fit and return leakage-free out-of-fold features for the training data. + + Returns + ------- + array-like of shape (n_samples, n_features_out) + The out-of-fold features, in the row order of ``X`` and in the kind of + output :meth:`transform` returns: a sparse matrix in the same format, a + DataFrame, or a dense array with the folds' dtype (``object`` when the + wrapped transformer leaves strings unchanged). + """ X_arr, y_arr = self._fit_full(X, y) - width = len(self.estimator_.get_feature_names_out()) - out = np.empty((X_arr.shape[0], width), dtype=float) + names = [str(name) for name in self.estimator_.get_feature_names_out()] + width = len(names) + blocks, test_indices = [], [] splitter = self._make_splitter() token = _cross_fit_active.set(True) try: @@ -224,7 +271,8 @@ def fit_transform(self, X, y=None): "wrapped transformer returned a dict of blocks. Use output_structure=" "'matrix' on a wrapped Preprocessor." ) - fold_out = fold_out.toarray() if sp.issparse(fold_out) else np.asarray(fold_out) + if not hasattr(fold_out, "shape"): + fold_out = np.asarray(fold_out) if fold_out.shape[1] != width: raise IncompatibleParamsError( "Cross-fitting requires a fixed output width across folds; expected " @@ -233,10 +281,22 @@ def fit_transform(self, X, y=None): "indicator columns learned from the data); configure it to produce the " "same columns on every fold." ) - out[test_idx] = fold_out + # DataFrames are stacked by column label, so a fold that kept other + # columns at the same width (e.g. other top categories) cannot be aligned. + columns = getattr(fold_out, "columns", None) + if columns is not None and [str(column) for column in columns] != names: + differing = sorted(set(map(str, columns)).symmetric_difference(names)) + raise IncompatibleParamsError( + "Cross-fitting requires the same output columns across folds; a fold's " + f"columns differ from the all-data fit in {differing}. The wrapped " + "transformer learns its columns from the rows it is fit on; configure it " + "to produce the same columns on every fold." + ) + blocks.append(fold_out) + test_indices.append(test_idx) finally: _cross_fit_active.reset(token) - return out + return _stack_folds(blocks, test_indices, X_arr) def get_feature_names_out(self, input_features=None): """Delegate output feature names to the all-data ``estimator_``.""" diff --git a/tests/core/test_cross_fitted.py b/tests/core/test_cross_fitted.py index fe83b83..c845fa7 100644 --- a/tests/core/test_cross_fitted.py +++ b/tests/core/test_cross_fitted.py @@ -266,13 +266,10 @@ def test_wrapped_unsupervised_preprocessor_matches_transform(frame_with_rare_cat np.testing.assert_array_equal(cross_fitted.fit_transform(X, y), np.asarray(cross_fitted.transform(X))) -def test_cross_fitting_densifies_sparse_output_and_rejects_blocks(frame_with_rare_category): +def test_cross_fitting_rejects_blocks(frame_with_rare_category): from pretab import Preprocessor X, y = frame_with_rare_category - sparse = Preprocessor(categorical_method="one-hot", output_format="sparse", random_state=0) - out = CrossFittedTransformer(sparse, n_folds=3, random_state=0).fit_transform(X, y) - assert isinstance(out, np.ndarray) blocks = Preprocessor(output_structure="blocks", random_state=0) with pytest.raises(IncompatibleParamsError, match="dict of blocks"): CrossFittedTransformer(blocks, n_folds=3, random_state=0).fit_transform(X, y) @@ -334,3 +331,120 @@ def test_width_mismatch_names_the_general_cause(): cross_fitted = CrossFittedTransformer(OneHotEncoder(handle_unknown="ignore"), n_folds=5, random_state=0) with pytest.raises(IncompatibleParamsError, match="same columns on every fold"): cross_fitted.fit_transform(x, y) + + +# --- fit_transform returns the kind of output transform returns --------------------- + + +def _cross_fit(wrapped, X, y): + cross_fitted = CrossFittedTransformer(wrapped, n_folds=3, random_state=0) + return cross_fitted, cross_fitted.fit_transform(X, y) + + +def test_fit_transform_keeps_strings_left_unchanged(frame_with_rare_category): + """The out-of-fold rows were written into a float buffer, so categorical_method=None + (strings passed through unchanged) raised in fit_transform but not in transform.""" + from pretab import Preprocessor + + X, y = frame_with_rare_category + cross_fitted, out = _cross_fit(Preprocessor(categorical_method=None, output_dim=4, random_state=0), X, y) + _, encoded = _cross_fit(Preprocessor(output_dim=4, random_state=0), X, y) + + assert out.dtype == cross_fitted.transform(X).dtype == object + np.testing.assert_array_equal(out[:, _split_columns(cross_fitted, "cat_city")[0]], X["city"].to_numpy()) + supervised = _split_columns(cross_fitted, "num_x_ple") + np.testing.assert_allclose(out[:, supervised].astype(float), encoded[:, supervised]) + + +def test_fit_transform_keeps_sparse_output(frame_with_rare_category): + """Sparse fold output was densified, while transform returned a CSR matrix.""" + from scipy import sparse as sp + + from pretab import Preprocessor + + X, y = frame_with_rare_category + params = {"categorical_method": "one-hot", "output_dim": 4, "random_state": 0} + cross_fitted, out = _cross_fit(Preprocessor(output_format="sparse", **params), X, y) + _, dense = _cross_fit(Preprocessor(**params), X, y) + + assert sp.issparse(out) + assert out.format == cross_fitted.transform(X).format == "csr" + np.testing.assert_allclose(out.toarray(), dense) + + +def test_fit_transform_keeps_the_configured_dtype(frame_with_rare_category): + from pretab import Preprocessor + + X, y = frame_with_rare_category + cross_fitted, out = _cross_fit(Preprocessor(dtype=np.float32, output_dim=4, random_state=0), X, y) + _, native = _cross_fit(Preprocessor(output_dim=4, random_state=0), X, y) + + assert out.dtype == cross_fitted.transform(X).dtype == np.float32 + np.testing.assert_allclose(out, native, rtol=1e-6) + + +def test_fit_transform_returns_the_dataframe_transform_returns(mixed_frame): + import pandas as pd + + from pretab import Preprocessor + + X, y = mixed_frame + cross_fitted, out = _cross_fit(Preprocessor(output_dim=4, random_state=0).set_output(transform="pandas"), X, y) + full = cross_fitted.transform(X) + _, array = _cross_fit(Preprocessor(output_dim=4, random_state=0), X, y) + + assert isinstance(out, pd.DataFrame) + pd.testing.assert_index_equal(out.columns, full.columns) + pd.testing.assert_index_equal(out.index, full.index) # the Preprocessor numbers its rows + np.testing.assert_allclose(out.to_numpy(), array) + + +def test_fit_transform_keeps_the_index_of_scikit_learn_dataframe_output(mixed_frame): + import pandas as pd + from sklearn.compose import ColumnTransformer + + X, y = mixed_frame + + def ple_columns(output): + return ColumnTransformer([("ple", PLETransformer(output_dim=4), ["num"])]).set_output(transform=output) + + _, out = _cross_fit(ple_columns("pandas"), X, y) + _, array = _cross_fit(ple_columns("default"), X, y) + + assert isinstance(out, pd.DataFrame) + pd.testing.assert_index_equal(out.index, X.index) + np.testing.assert_allclose(out.to_numpy(), array) + + +def test_fit_transform_returns_polars_like_transform(mixed_frame): + pl = pytest.importorskip("polars") + from pretab import Preprocessor + + X, y = mixed_frame + cross_fitted, out = _cross_fit(Preprocessor(output_dim=4, random_state=0).set_output(transform="polars"), X, y) + _, array = _cross_fit(Preprocessor(output_dim=4, random_state=0), X, y) + + assert isinstance(out, pl.DataFrame) + assert out.columns == cross_fitted.transform(X).columns + np.testing.assert_allclose(out.to_numpy(), array) + + +def test_fit_transform_rejects_folds_with_other_dataframe_columns(): + """Frames are stacked by column label: a fold that keeps other columns at the + same width (here another top category) must raise, not widen the result with + NaN-padded columns.""" + import pandas as pd + from sklearn.compose import ColumnTransformer + from sklearn.preprocessing import OneHotEncoder + + rng = np.random.default_rng(0) + # "b" and "c" tie, so which one is kept as a top category depends on the fold. + X = pd.DataFrame({"num": rng.normal(size=90), "cat": rng.permutation(["a"] * 50 + ["b"] * 20 + ["c"] * 20)}) + y = X["num"].to_numpy() + rng.normal(scale=0.3, size=90) + encoder = OneHotEncoder(max_categories=3, handle_unknown="infrequent_if_exist", sparse_output=False) + transformer = ColumnTransformer( + [("ple", PLETransformer(output_dim=3), ["num"]), ("oh", encoder, ["cat"])] + ).set_output(transform="pandas") + + with pytest.raises(IncompatibleParamsError, match="same output columns across folds"): + CrossFittedTransformer(transformer, n_folds=3, random_state=0).fit_transform(X, y) From 88065e321aeadac42472424dfc963d6faa954555 Mon Sep 17 00:00:00 2001 From: ChrisW09 <50968720+ChrisW09@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:49:40 +0200 Subject: [PATCH 05/16] fix(search): pass groups to the CV splitter and fit params to the estimator RepresentationSearchCV.fit took only (X, y) and called cv.split without groups, so a group splitter (GroupKFold, LeaveOneGroupOut, GroupShuffleSplit, StratifiedGroupKFold) always raised "The 'groups' parameter should not be None" and fit(X, y, groups=g) raised a TypeError. Grouped data such as several rows per patient could not be searched with a leakage-safe group split, and there was no way to pass sample_weight or other fit parameters. fit now takes a keyword-only groups, forwarded to cv.split, and **fit_params, forwarded to the estimator's fit on every fold (per-sample values restricted to the fold's training rows, as in scikit-learn's searches) and on the final refit. The Preprocessor and the scorer do not receive them. Splits without groups are unchanged. Fixes #91 --- docs/core_concepts/target_awareness.md | 19 ++++++ pretab/compose/search.py | 45 +++++++++++--- tests/compose/test_search.py | 86 +++++++++++++++++++++++++- 3 files changed, 141 insertions(+), 9 deletions(-) diff --git a/docs/core_concepts/target_awareness.md b/docs/core_concepts/target_awareness.md index d99902f..c1f57a8 100644 --- a/docs/core_concepts/target_awareness.md +++ b/docs/core_concepts/target_awareness.md @@ -165,6 +165,25 @@ search.best_method_ best_preprocessor_` and `search.best_estimator_` are refit on all the data with `bspline` and ready to call `.predict(X_new)`. +When several rows belong to the same unit, such as repeated measurements of one patient, a +row-level split validates each candidate on units it has already seen in training. Pass a +group splitter as `cv` and the group labels to `fit`, so no group is on both sides of a +train/validation split: + +```python +from sklearn.model_selection import GroupKFold + +patient_id = np.repeat(np.arange(30), 10) # 30 patients with 10 rows each + +search = RepresentationSearchCV(Ridge(), methods=["minmax", "bspline"], cv=GroupKFold(5)) +search.fit(X, y, groups=patient_id) +``` + +Any other keyword argument to `fit`, such as `sample_weight`, is passed to the estimator's +`fit` on every fold (restricted to that fold's training rows) and on the final refit. The +`Preprocessor` and the scorer do not see it, so placement and validation scores are +unweighted. + ```{note} This is deliberately narrow: it only searches the single `numerical_method` axis with one global method for every numerical column, not a per-column `feature_preprocessing` search. diff --git a/pretab/compose/search.py b/pretab/compose/search.py index f01a140..02bcb64 100644 --- a/pretab/compose/search.py +++ b/pretab/compose/search.py @@ -15,7 +15,7 @@ from sklearn.base import BaseEstimator, clone, is_classifier from sklearn.metrics import check_scoring from sklearn.model_selection import BaseCrossValidator, check_cv -from sklearn.utils.validation import check_is_fitted +from sklearn.utils.validation import _check_method_params, check_is_fitted from ..core._typing import PredictorLike from ..exceptions import InvalidParamError @@ -42,7 +42,9 @@ class RepresentationSearchCV(BaseEstimator): Candidate ``numerical_method`` values to search over. cv : int or cross-validation generator, default=5 Cross-validation splitting strategy passed to - :func:`sklearn.model_selection.check_cv`. + :func:`sklearn.model_selection.check_cv`. A group splitter such as + :class:`~sklearn.model_selection.GroupKFold` needs ``groups`` at + :meth:`fit`. scoring : str or callable or None, default=None Scoring passed to :func:`sklearn.metrics.check_scoring`; ``None`` uses the estimator's ``score`` method. @@ -87,8 +89,33 @@ def _make_preprocessor(self, method): params.setdefault("task", "classification") return Preprocessor(numerical_method=method, **params) - def fit(self, X, y=None): - """Search over ``methods`` and refit the best representation on all data.""" + def fit(self, X, y=None, *, groups=None, **fit_params): + """Search over ``methods`` and refit the best representation on all data. + + Parameters + ---------- + X : pandas.DataFrame or array-like of shape (n_samples, n_features) + Input features, transformed by each candidate :class:`Preprocessor`. + y : array-like of shape (n_samples,) + Target values. Required. + groups : array-like of shape (n_samples,), default=None + Group labels passed to ``cv.split``, so a group splitter (for example + :class:`~sklearn.model_selection.GroupKFold`) keeps every group on one + side of each train/validation split. Ignored by splitters that do not + use groups, and not passed to the estimator. + **fit_params : dict + Parameters passed to the ``fit`` method of ``estimator`` on every fold + and on the final refit, for example ``sample_weight``. Array-like values + of length ``n_samples`` are restricted to the training rows of each fold. + They are not passed to the :class:`Preprocessor` (its target-aware + placement is unweighted) or to the scorer (validation scores are + unweighted). + + Returns + ------- + self : RepresentationSearchCV + Fitted search. + """ methods = list(self.methods) if not methods: raise InvalidParamError("methods must be a non-empty sequence of numerical methods.") @@ -96,10 +123,13 @@ def fit(self, X, y=None): raise InvalidParamError("RepresentationSearchCV requires y at fit time; got y=None.") y_arr = np.asarray(y).ravel() n_samples = X.shape[0] if hasattr(X, "shape") else len(X) + # As in scikit-learn's searches: per-sample fit params are made indexable + # so each fold can take its training rows. + fit_params = _check_method_params(X, params=fit_params) cv = cast(BaseCrossValidator, check_cv(self.cv, y_arr, classifier=is_classifier(self.estimator))) # Reuse the same held-out rows for every candidate, including splitters # whose random state advances on each call to split(). - splits = list(cv.split(np.zeros(n_samples), y_arr)) + splits = list(cv.split(np.zeros(n_samples), y_arr, groups)) cv_results: dict[str, float] = {} best_score = -np.inf @@ -110,7 +140,8 @@ def fit(self, X, y=None): pre = self._make_preprocessor(method) est = cast(PredictorLike, clone(self.estimator)) x_train = pre.fit_transform(_row_subset(X, train_idx), y_arr[train_idx], return_array=True) - est.fit(x_train, y_arr[train_idx]) + fold_params = _check_method_params(X, params=fit_params, indices=train_idx) + est.fit(x_train, y_arr[train_idx], **fold_params) scorer = cast("Callable[..., float]", check_scoring(est, scoring=self.scoring)) x_test = pre.transform(_row_subset(X, test_idx), return_array=True) fold_scores.append(scorer(est, x_test, y_arr[test_idx])) @@ -125,7 +156,7 @@ def fit(self, X, y=None): self.best_score_ = best_score self.best_preprocessor_ = self._make_preprocessor(best_method) x_all = self.best_preprocessor_.fit_transform(X, y_arr, return_array=True) - self.best_estimator_ = cast(PredictorLike, clone(self.estimator)).fit(x_all, y_arr) + self.best_estimator_ = cast(PredictorLike, clone(self.estimator)).fit(x_all, y_arr, **fit_params) return self def predict(self, X): diff --git a/tests/compose/test_search.py b/tests/compose/test_search.py index c3e065f..936e1ab 100644 --- a/tests/compose/test_search.py +++ b/tests/compose/test_search.py @@ -9,9 +9,10 @@ import numpy as np import pandas as pd import pytest +from sklearn.base import BaseEstimator, RegressorMixin from sklearn.exceptions import NotFittedError -from sklearn.linear_model import LinearRegression, LogisticRegression -from sklearn.model_selection import KFold +from sklearn.linear_model import LinearRegression, LogisticRegression, Ridge +from sklearn.model_selection import GroupKFold, GroupShuffleSplit, KFold, LeaveOneGroupOut from pretab import RepresentationSearchCV from pretab.exceptions import InvalidParamError @@ -154,3 +155,84 @@ def test_regressor_search_keeps_the_regression_task(nonlinear_data): X, y = nonlinear_data search = RepresentationSearchCV(LinearRegression(), methods=["minmax"], cv=3).fit(X, y) assert search.best_preprocessor_.task == "regression" + + +class _RowRecorder(RegressorMixin, BaseEstimator): + """Mean regressor whose ``fit`` records the ``row_id`` fit parameter it gets.""" + + def fit(self, X, y, row_id=None): + self.row_id_ = row_id + self.mean_ = float(np.mean(y)) + return self + + def predict(self, X): + return np.full(len(X), self.mean_) + + +def _recording_scorer(fold_rows): + """Scorer that stores the ``row_id`` of each fold's fitted estimator.""" + + def score(est, x_test, y_test): + fold_rows.append(np.asarray(est.row_id_)) + return 0.0 + + return score + + +@pytest.fixture +def grouped_data(nonlinear_data): + """``nonlinear_data`` with 20 groups of 10 rows (e.g. 20 patients).""" + X, y = nonlinear_data + return X, y, np.repeat(np.arange(20), 10) + + +@pytest.mark.parametrize( + "cv", + [GroupKFold(n_splits=4), LeaveOneGroupOut(), GroupShuffleSplit(n_splits=3, test_size=0.25, random_state=0)], + ids=["group_kfold", "leave_one_group_out", "group_shuffle_split"], +) +def test_group_splitter_receives_groups(grouped_data, cv): + """Group splitters always failed: fit() took no groups and split() got none.""" + X, y, groups = grouped_data + fold_rows = [] + search = _search(_RowRecorder(), ["minmax"], cv=cv, scoring=_recording_scorer(fold_rows)) + search.fit(X, y, groups=groups, row_id=np.arange(len(X))) + + for rows, (train, test) in zip(fold_rows, cv.split(X, y, groups), strict=True): + np.testing.assert_array_equal(rows, train) + # No group is on both sides of a train/validation split. + assert set(groups[rows]).isdisjoint(groups[test]) + + +@pytest.mark.filterwarnings("ignore:The groups parameter is ignored:UserWarning") +def test_groups_leave_non_group_splits_unchanged(grouped_data): + X, y, groups = grouped_data + plain = _search(LinearRegression(), ["standardization", "bspline"]).fit(X, y) + grouped = _search(LinearRegression(), ["standardization", "bspline"]).fit(X, y, groups=groups) + assert grouped.cv_results_ == plain.cv_results_ + + +def test_fit_params_are_split_per_fold_and_passed_to_the_refit(nonlinear_data): + X, y = nonlinear_data + row_id = np.arange(len(X)) + cv = KFold(n_splits=4, shuffle=True, random_state=0) + fold_rows = [] + search = _search(_RowRecorder(), ["minmax"], cv=cv, scoring=_recording_scorer(fold_rows)) + search.fit(X, y, row_id=row_id) + + # Each fold's estimator sees only its training rows; the refit sees all rows. + for rows, (train, _) in zip(fold_rows, cv.split(X), strict=True): + np.testing.assert_array_equal(rows, train) + np.testing.assert_array_equal(search.best_estimator_.row_id_, row_id) + + +def test_sample_weight_reaches_the_estimator(nonlinear_data): + X, y = nonlinear_data + weights = np.linspace(0.1, 2.0, len(X)) + search = _search(Ridge(), ["bspline"]).fit(X, y, sample_weight=weights) + + x_all = search.best_preprocessor_.transform(X, return_array=True) + expected = Ridge().fit(x_all, y, sample_weight=weights) + np.testing.assert_allclose(search.best_estimator_.coef_, expected.coef_) + # The fold fits are weighted as well, so the validation scores change. + assert search.cv_results_ != _search(Ridge(), ["bspline"]).fit(X, y).cv_results_ From 4a8417c16e5f5174caffe1a75cb4982ee1be585f Mon Sep 17 00:00:00 2001 From: ChrisW09 <50968720+ChrisW09@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:54:43 +0200 Subject: [PATCH 06/16] fix(search): make RepresentationSearchCV a classifier around a classifier RepresentationSearchCV subclassed plain BaseEstimator and offered only predict and score, so a search over a classifier had no classes_, predict_proba, predict_log_proba or decision_function and is_classifier() was False. Nested evaluation therefore broke: cross_val_score(search, X, y, scoring="roc_auc") returned nan for every fold (with only a warning), and the outer split was a plain KFold instead of a StratifiedKFold. __sklearn_tags__ now takes the estimator type and classifier / regressor tags of the wrapped estimator, as scikit-learn's search estimators do. predict_proba, predict_log_proba and decision_function transform with best_preprocessor_ and delegate to best_estimator_; they are available only when the estimator provides them (judged from the estimator before fit and from best_estimator_ after). classes_ is exposed from best_estimator_, and fit records n_features_in_ / feature_names_in_ from best_preprocessor_. Regression usage is unchanged. Fixes #92 --- docs/core_concepts/target_awareness.md | 6 ++ pretab/compose/search.py | 88 ++++++++++++++++++++++++-- tests/compose/test_search.py | 82 +++++++++++++++++++++++- 3 files changed, 170 insertions(+), 6 deletions(-) diff --git a/docs/core_concepts/target_awareness.md b/docs/core_concepts/target_awareness.md index c1f57a8..0b66c21 100644 --- a/docs/core_concepts/target_awareness.md +++ b/docs/core_concepts/target_awareness.md @@ -184,6 +184,12 @@ Any other keyword argument to `fit`, such as `sample_weight`, is passed to the e `Preprocessor` and the scorer do not see it, so placement and validation scores are unweighted. +The search takes the estimator type of the estimator it wraps. Around a classifier it is a +classifier itself and delegates `classes_`, `predict_proba`, `predict_log_proba` and +`decision_function` (whichever the estimator provides) to the refit `best_estimator_`. It can +therefore be evaluated as a whole in an outer, stratified cross-validation, for example with +`cross_val_score(search, X, y_class, scoring="roc_auc")`. + ```{note} This is deliberately narrow: it only searches the single `numerical_method` axis with one global method for every numerical column, not a per-column `feature_preprocessing` search. diff --git a/pretab/compose/search.py b/pretab/compose/search.py index 02bcb64..445ad67 100644 --- a/pretab/compose/search.py +++ b/pretab/compose/search.py @@ -9,12 +9,15 @@ """ from collections.abc import Callable +from copy import deepcopy from typing import cast import numpy as np from sklearn.base import BaseEstimator, clone, is_classifier from sklearn.metrics import check_scoring from sklearn.model_selection import BaseCrossValidator, check_cv +from sklearn.utils import get_tags +from sklearn.utils.metaestimators import available_if from sklearn.utils.validation import _check_method_params, check_is_fitted from ..core._typing import PredictorLike @@ -31,9 +34,30 @@ def _row_subset(data, idx): return np.asarray(data)[idx] +def _estimator_has(attr): + """Check whether ``attr`` can be delegated to the downstream estimator. + + As in scikit-learn's search estimators, the refit ``best_estimator_`` is + checked after fit and the unfitted ``estimator`` before, so ``hasattr`` on an + unfitted search already reflects the estimator it wraps. + """ + + def check(self): + # getattr raises AttributeError when the estimator does not provide attr. + getattr(self.best_estimator_ if hasattr(self, "best_estimator_") else self.estimator, attr) + return True + + return check + + class RepresentationSearchCV(BaseEstimator): """Select the best numerical representation by cross-validation. + The search takes the estimator type of ``estimator``: wrapping a classifier + makes it a classifier (``is_classifier``, stratified outer cross-validation), + and ``predict_proba``, ``predict_log_proba`` and ``decision_function`` are + available whenever ``estimator`` provides them. + Parameters ---------- estimator : estimator @@ -67,6 +91,13 @@ class RepresentationSearchCV(BaseEstimator): Preprocessor for ``best_method_`` refit on all data. best_estimator_ : estimator Estimator refit on the best representation of all data. + classes_ : ndarray of shape (n_classes,) + Class labels of ``best_estimator_``. Only available for a classifier. + n_features_in_ : int + Number of features seen during :meth:`fit`. + feature_names_in_ : ndarray of shape (n_features_in_,) + Names of the features seen during :meth:`fit`. Only defined when ``X`` has + string column names. """ def __init__(self, estimator, methods, *, cv=5, scoring=None, preprocessor_params=None, random_state=None): @@ -157,17 +188,66 @@ def fit(self, X, y=None, *, groups=None, **fit_params): self.best_preprocessor_ = self._make_preprocessor(best_method) x_all = self.best_preprocessor_.fit_transform(X, y_arr, return_array=True) self.best_estimator_ = cast(PredictorLike, clone(self.estimator)).fit(x_all, y_arr, **fit_params) + self.n_features_in_ = self.best_preprocessor_.n_features_in_ + if hasattr(self.best_preprocessor_, "feature_names_in_"): + self.feature_names_in_ = self.best_preprocessor_.feature_names_in_ + elif hasattr(self, "feature_names_in_"): + del self.feature_names_in_ return self + @property + def classes_(self): + """Class labels of the refit ``best_estimator_``, for a classifier.""" + return self.best_estimator_.classes_ + + def _best_representation(self, X): + """Transform ``X`` with the refit ``best_preprocessor_``.""" + check_is_fitted(self, "best_estimator_") + return self.best_preprocessor_.transform(X, return_array=True) + def predict(self, X): """Predict with the best refit estimator on the best representation.""" - check_is_fitted(self, "best_estimator_") - x = self.best_preprocessor_.transform(X, return_array=True) + x = self._best_representation(X) return self.best_estimator_.predict(x) + @available_if(_estimator_has("predict_proba")) + def predict_proba(self, X): + """Predict class probabilities with the best refit estimator. + + Only available when ``estimator`` implements ``predict_proba``. + """ + x = self._best_representation(X) + return self.best_estimator_.predict_proba(x) + + @available_if(_estimator_has("predict_log_proba")) + def predict_log_proba(self, X): + """Predict class log-probabilities with the best refit estimator. + + Only available when ``estimator`` implements ``predict_log_proba``. + """ + x = self._best_representation(X) + return self.best_estimator_.predict_log_proba(x) + + @available_if(_estimator_has("decision_function")) + def decision_function(self, X): + """Compute the decision function of the best refit estimator. + + Only available when ``estimator`` implements ``decision_function``. + """ + x = self._best_representation(X) + return self.best_estimator_.decision_function(x) + def score(self, X, y): """Score the best refit estimator on ``(X, y)``.""" - check_is_fitted(self, "best_estimator_") - x = self.best_preprocessor_.transform(X, return_array=True) + x = self._best_representation(X) scorer = cast("Callable[..., float]", check_scoring(self.best_estimator_, scoring=self.scoring)) return scorer(self.best_estimator_, x, np.asarray(y).ravel()) + + def __sklearn_tags__(self): + """Take the estimator type and classifier / regressor tags of ``estimator``.""" + tags = super().__sklearn_tags__() # type: ignore[attr-defined] + estimator_tags = get_tags(self.estimator) + tags.estimator_type = estimator_tags.estimator_type + tags.classifier_tags = deepcopy(estimator_tags.classifier_tags) + tags.regressor_tags = deepcopy(estimator_tags.regressor_tags) + return tags diff --git a/tests/compose/test_search.py b/tests/compose/test_search.py index 936e1ab..8122ae1 100644 --- a/tests/compose/test_search.py +++ b/tests/compose/test_search.py @@ -9,10 +9,19 @@ import numpy as np import pandas as pd import pytest -from sklearn.base import BaseEstimator, RegressorMixin +from sklearn.base import BaseEstimator, RegressorMixin, is_classifier, is_regressor from sklearn.exceptions import NotFittedError from sklearn.linear_model import LinearRegression, LogisticRegression, Ridge -from sklearn.model_selection import GroupKFold, GroupShuffleSplit, KFold, LeaveOneGroupOut +from sklearn.model_selection import ( + GroupKFold, + GroupShuffleSplit, + KFold, + LeaveOneGroupOut, + StratifiedKFold, + check_cv, + cross_val_score, +) +from sklearn.svm import SVC from pretab import RepresentationSearchCV from pretab.exceptions import InvalidParamError @@ -236,3 +245,72 @@ def test_sample_weight_reaches_the_estimator(nonlinear_data): np.testing.assert_allclose(search.best_estimator_.coef_, expected.coef_) # The fold fits are weighted as well, so the validation scores change. assert search.cv_results_ != _search(Ridge(), ["bspline"]).fit(X, y).cv_results_ + + +_DELEGATED = ("classes_", "predict_proba", "predict_log_proba", "decision_function") + + +def test_search_takes_the_estimator_type(class_data): + """The search was neither a classifier nor a regressor, so nested cross-validation + of a classifier search used KFold instead of StratifiedKFold.""" + _, y = class_data + classifier_search = RepresentationSearchCV(LogisticRegression(), methods=["minmax"]) + regressor_search = RepresentationSearchCV(Ridge(), methods=["minmax"]) + + assert is_classifier(classifier_search) and not is_regressor(classifier_search) + assert is_regressor(regressor_search) and not is_classifier(regressor_search) + assert isinstance(check_cv(3, y, classifier=is_classifier(classifier_search)), StratifiedKFold) + + +def test_classifier_search_delegates_to_the_best_estimator(class_data): + X, y = class_data + search = RepresentationSearchCV(LogisticRegression(), methods=["minmax", "ple"], cv=3, random_state=0).fit(X, y) + x = search.best_preprocessor_.transform(X, return_array=True) + + np.testing.assert_array_equal(search.classes_, ["neg", "pos"]) + np.testing.assert_allclose(search.predict_proba(X), search.best_estimator_.predict_proba(x)) + np.testing.assert_allclose(search.predict_log_proba(X), search.best_estimator_.predict_log_proba(x)) + np.testing.assert_allclose(search.decision_function(X), search.best_estimator_.decision_function(x)) + + +def test_delegated_methods_follow_the_wrapped_estimator(class_data, nonlinear_data): + X, y = class_data + # SVC without probability=True has a decision_function but no predict_proba. + svc_search = RepresentationSearchCV(SVC(), methods=["minmax"], cv=3) + assert hasattr(svc_search, "decision_function") and not hasattr(svc_search, "predict_proba") + svc_search.fit(X, y) + assert hasattr(svc_search, "decision_function") and not hasattr(svc_search, "predict_proba") + + X_reg, y_reg = nonlinear_data + regressor_search = _search(Ridge(), ["minmax"]).fit(X_reg, y_reg) + assert not any(hasattr(regressor_search, attr) for attr in _DELEGATED) + + +def test_unfitted_classifier_search_raises_not_fitted(class_data): + X, _ = class_data + search = RepresentationSearchCV(LogisticRegression(), methods=["minmax"]) + assert not hasattr(search, "classes_") + with pytest.raises(NotFittedError): + search.predict_proba(X) + + +def test_nested_cross_val_score_with_roc_auc(class_data): + """roc_auc needs classes_ and predict_proba / decision_function: every outer fold + scored nan.""" + X, y = class_data + search = RepresentationSearchCV(LogisticRegression(), methods=["minmax", "ple"], cv=3, random_state=0) + scores = cross_val_score(search, X, y, scoring="roc_auc", cv=3) + assert np.all(np.isfinite(scores)) + assert scores.min() > 0.9 + + +def test_search_records_the_input_features(nonlinear_data): + X, y = nonlinear_data + search = _search(LinearRegression(), ["bspline"]).fit(X, y) + assert search.n_features_in_ == 1 + np.testing.assert_array_equal(search.feature_names_in_, ["x"]) + + # Refitting on an array drops the stale names. + search.fit(X.to_numpy(), y) + assert search.n_features_in_ == 1 + assert not hasattr(search, "feature_names_in_") From 0e192f693f601503fd38c2c42724a75d8499f585 Mon Sep 17 00:00:00 2001 From: ChrisW09 <50968720+ChrisW09@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:51:06 +0200 Subject: [PATCH 07/16] fix(validation): reject an unknown task instead of silently placing for classification The knot splines, the CART / LightGBM location selectors and the Preprocessor config never checked task, and the selectors treated every value other than "regression" as classification. A typo such as task="Regression" therefore gave classification-tree knots, failed deep inside scikit-learn on a float target ("Unknown label type: continuous"), fit a continuous target as a many-class LightGBM problem, and made every preset resolve numerical_method to "ple". A shared validate_task helper in pretab.core.parameters now checks task at fit in the B/M/I, natural cubic and cubic regression splines (None still means "regression"), PLE, the feature maps, CrossFittedTransformer, the location selectors and PreprocessorConfig.from_params. Preprocessor validates it before preset resolution, so fit and get_resolved_config raise for any method or preset; the selectors dispatch explicitly on "classification". All of them raise InvalidParamError with one message (".task = 'Regression' is invalid. ..."), replacing the differing PLE and feature-map wording. Fixes #93 --- docs/core_concepts/configuration.md | 7 ++- pretab/compose/config.py | 19 ++++---- pretab/core/parameters.py | 47 ++++++++++++++++++- pretab/core/selectors.py | 39 ++++++++------- pretab/core/supervised.py | 4 +- pretab/encoding/numerical/ple.py | 7 ++- pretab/expansion/functional/base.py | 7 ++- pretab/expansion/spline/base.py | 7 ++- pretab/expansion/spline/cubic_regression.py | 7 ++- pretab/expansion/spline/natural_cubic.py | 7 ++- pretab/preprocessor.py | 21 +++++++-- tests/compose/test_config.py | 6 +++ tests/core/test_location_selectors.py | 19 +++++++- .../numerical/test_ple_transformer.py | 2 +- .../test_rbfexpansion_transformer.py | 2 +- .../test_reluexpansion_transformer.py | 2 +- .../test_sigmoidexpansion_transformer.py | 2 +- .../functional/test_tanh_transformer.py | 2 +- tests/integration/test_exceptions.py | 39 ++++++++++++++- tests/integration/test_presets.py | 8 ++++ 20 files changed, 197 insertions(+), 57 deletions(-) diff --git a/docs/core_concepts/configuration.md b/docs/core_concepts/configuration.md index c5fa21d..9eaa85c 100644 --- a/docs/core_concepts/configuration.md +++ b/docs/core_concepts/configuration.md @@ -119,7 +119,9 @@ regression and classification differently. | `"adaptive"` | task-dependent | `"int"` | - | `True` | `7` | `15` | `numerical_method` resolves to `"bspline"` when `task="regression"` (the default) and to -`"ple"` when `task="classification"`, for every preset: +`"ple"` when `task="classification"`, for every preset. `task` is matched exactly: any other +value, such as `"Regression"`, raises an `InvalidParamError` from `fit` and +`get_resolved_config()` instead of falling back to the classification preset. ```python standard_regression = Preprocessor(preset="standard", task="regression") @@ -182,7 +184,8 @@ The resolution order is deterministic. Later layers win. ```{warning} Configuration is validated at `fit` time, not silently coerced. An invalid combination, such -as a method that requires the target used with `target_aware=False`, raises a typed error. +as a method that requires the target used with `target_aware=False`, or a `task` other than +`"regression"` / `"classification"`, raises a typed error. This is intentional: it surfaces mistakes early rather than producing a quietly wrong representation. ``` diff --git a/pretab/compose/config.py b/pretab/compose/config.py index a9c5ff2..4408cb2 100644 --- a/pretab/compose/config.py +++ b/pretab/compose/config.py @@ -3,10 +3,10 @@ :class:`PreprocessorConfig` is the frozen, canonical view of the user-supplied Preprocessor parameters. It normalizes the global method names (resolving aliases and separator/case variants, mapping ``None`` to ``"none"``) and -validates the global ``target_aware`` / ``placement_strategy`` contract up front. -The user's original constructor arguments stay untouched on the estimator (as -scikit-learn requires); this object is the internal, normalized counterpart the -composition layer consumes. +validates the global ``target_aware`` / ``placement_strategy`` contract and the +``task`` up front. The user's original constructor arguments stay untouched on +the estimator (as scikit-learn requires); this object is the internal, normalized +counterpart the composition layer consumes. Per-column overrides in ``feature_preprocessing`` are kept verbatim because the namespace they resolve in (numerical vs categorical) depends on the column type, @@ -18,7 +18,7 @@ from dataclasses import dataclass -from ..core.parameters import validate_placement +from ..core.parameters import validate_placement, validate_task from ..exceptions import invalid_param_error from .registry import ( CATEGORICAL_ALIASES, @@ -47,8 +47,8 @@ class PreprocessorConfig: """Frozen, normalized configuration derived from Preprocessor parameters. Built via :meth:`from_params`, which normalizes the global method names and - validates the placement contract. All other knobs are carried through as-is - for the factory and orchestration layers. + validates the placement contract and the task. All other knobs are carried + through as-is for the factory and orchestration layers. """ numerical_method: str @@ -103,9 +103,12 @@ def from_params( ------ InvalidParamError If the ``target_aware`` / ``placement_strategy`` combination is - invalid (via :func:`~pretab.core.parameters.validate_placement`). + invalid (via :func:`~pretab.core.parameters.validate_placement`), or + ``task`` is not ``"regression"`` / ``"classification"`` (via + :func:`~pretab.core.parameters.validate_task`). """ validate_placement(target_aware, placement_strategy) + validate_task(task, "Preprocessor") if missing_policy is not None and missing_policy not in MISSING_POLICIES: raise invalid_param_error( "Preprocessor", diff --git a/pretab/core/parameters.py b/pretab/core/parameters.py index 990183e..6d495a7 100644 --- a/pretab/core/parameters.py +++ b/pretab/core/parameters.py @@ -17,7 +17,8 @@ import warnings from typing import Any, ClassVar -from ..exceptions import InvalidParamError +from ..exceptions import InvalidParamError, invalid_param_error +from ._typing import Task class _Unset: @@ -76,6 +77,50 @@ def validate_placement(target_aware: bool, placement_strategy: str) -> None: raise InvalidParamError("When target_aware=False, placement_strategy must be 'uniform' or 'quantile'.") +#: Supervised tasks accepted by every ``task`` parameter. +TASKS: tuple[Task, ...] = ("regression", "classification") + + +def validate_task(task, estimator: str, *, allow_none: bool = False) -> Task | None: + """Check that ``task`` names a supported supervised task and return it. + + The match is exact, so a near miss such as ``"Regression"`` is rejected + instead of being treated as classification by the target-aware placement. + Like :func:`validate_placement`, this is called at ``fit`` time, not at + construction. + + Parameters + ---------- + task : object + The ``task`` value to check. + estimator : str + Name of the estimator owning the parameter, used in the error message. + allow_none : bool, default=False + Whether ``None`` is accepted (the spline families and location selectors + treat it as ``"regression"``). + + Returns + ------- + {"regression", "classification"} or None + The validated ``task``. + + Raises + ------ + InvalidParamError + If ``task`` is not ``"regression"`` or ``"classification"`` (or ``None`` + when ``allow_none`` is True). + """ + if task is None and allow_none: + return None + if isinstance(task, str) and task in TASKS: + return task + if allow_none: + constraint = "must be 'regression', 'classification', or None (treated as 'regression')" + else: + constraint = "must be 'regression' or 'classification'" + raise invalid_param_error(estimator, "task", task, constraint, valid=TASKS) + + #: Shared parameter names with a short description of what each one controls. CANONICAL_PARAMS: dict[str, str] = { "output_dim": "Number of non-bias output columns produced per input feature.", diff --git a/pretab/core/selectors.py b/pretab/core/selectors.py index ffe86b7..6903bf3 100644 --- a/pretab/core/selectors.py +++ b/pretab/core/selectors.py @@ -27,6 +27,7 @@ from ..exceptions import IncompatibleParamsError, OptionalDependencyError from .knots import quantile_knots, select_knots, uniform_knots +from .parameters import validate_task Task = Literal["regression", "classification"] @@ -72,8 +73,8 @@ def select( Input feature values for one feature. y : np.ndarray of shape (n_samples,) Target values. Required -- these selectors are target aware. - task : {"regression", "classification"}, optional - Type of prediction task. Defaults to ``"regression"``. + task : {"regression", "classification"} or None, default="regression" + Type of prediction task. ``None`` is treated as ``"regression"``. min_count : int Minimum number of locations to return. max_count : int @@ -83,10 +84,15 @@ def select( ------- locations : np.ndarray Sorted array of selected locations. + + Raises + ------ + InvalidParamError + If ``task`` is not ``"regression"``, ``"classification"`` or ``None``. """ if y is None: raise IncompatibleParamsError(f"{type(self).__name__} requires y to select locations.") - task = task or "regression" + task = validate_task(task, type(self).__name__, allow_none=True) or "regression" x = np.asarray(x) if x.ndim == 1: @@ -235,20 +241,13 @@ def __init__( self.min_samples_floor = min_samples_split def _ordered_candidates(self, x_valid: np.ndarray, y_valid: np.ndarray, task: Task) -> tuple[list[float], object]: - if task == "regression": - tree = DecisionTreeRegressor( - max_depth=self.max_tree_depth, - min_samples_split=self.min_samples_split, - min_samples_leaf=self.min_samples_leaf, - random_state=self.random_state, - ) - else: - tree = DecisionTreeClassifier( - max_depth=self.max_tree_depth, - min_samples_split=self.min_samples_split, - min_samples_leaf=self.min_samples_leaf, - random_state=self.random_state, - ) + tree_class = DecisionTreeClassifier if task == "classification" else DecisionTreeRegressor + tree = tree_class( + max_depth=self.max_tree_depth, + min_samples_split=self.min_samples_split, + min_samples_leaf=self.min_samples_leaf, + random_state=self.random_state, + ) tree.fit(x_valid, y_valid) importance = self._split_importance(tree, x_valid) @@ -355,9 +354,7 @@ def _import_lightgbm(): def _ordered_candidates(self, x_valid: np.ndarray, y_valid: np.ndarray, task: Task) -> tuple[list[float], object]: lgb = self._import_lightgbm() - if task == "regression": - objective = {"objective": "regression", "metric": "rmse"} - else: + if task == "classification": # LightGBM needs integer class codes: its binary objective treats every # label > 0 as positive and it cannot read string labels. classes, y_valid = np.unique(y_valid, return_inverse=True) @@ -367,6 +364,8 @@ def _ordered_candidates(self, x_valid: np.ndarray, y_valid: np.ndarray, task: Ta objective = {"objective": "binary", "metric": "binary_logloss"} else: objective = {"objective": "multiclass", "metric": "multi_logloss", "num_class": len(classes)} + else: + objective = {"objective": "regression", "metric": "rmse"} params = { **objective, diff --git a/pretab/core/supervised.py b/pretab/core/supervised.py index 6801767..4fd6780 100644 --- a/pretab/core/supervised.py +++ b/pretab/core/supervised.py @@ -32,6 +32,7 @@ PretabDataError, ) from ._typing import TransformerLike +from .parameters import validate_task from .representation import RepresentationSpecMixin __all__ = ["CrossFittedTransformer", "in_controlled_context", "warn_target_leakage"] @@ -203,8 +204,7 @@ def _fit_full(self, X, y): raise IncompatibleParamsError("CrossFittedTransformer requires y at fit time; got y=None.") if not isinstance(self.n_folds, (int, np.integer)) or self.n_folds < 2: raise InvalidParamError(f"n_folds must be an integer >= 2; got {self.n_folds!r}.") - if self.task not in ("regression", "classification"): - raise InvalidParamError(f"task must be 'regression' or 'classification'; got {self.task!r}.") + validate_task(self.task, type(self).__name__) X_arr = _as_2d(X) y_arr = np.asarray(y).ravel() if len(X_arr) != len(y_arr): diff --git a/pretab/encoding/numerical/ple.py b/pretab/encoding/numerical/ple.py index 8594bd6..f0bb3d0 100644 --- a/pretab/encoding/numerical/ple.py +++ b/pretab/encoding/numerical/ple.py @@ -13,7 +13,7 @@ from sklearn.utils.validation import _check_feature_names, check_array, check_is_fitted from ...core.adaptive import AdaptiveResolutionMixin -from ...core.parameters import UNSET, AliasResolverMixin +from ...core.parameters import UNSET, AliasResolverMixin, validate_task from ...core.representation import RepresentationSpecMixin from ...core.supervised import warn_target_leakage from ...core.validation import resolve_input_features @@ -184,8 +184,7 @@ def fit(self, X, y=None): max_bins_req = self._resolve_param("max_output_dim", default=None) min_bins, max_bins = self._resolve_bin_bounds(n_bins, min_bins_req, max_bins_req) - if self.task not in ("regression", "classification"): - raise InvalidParamError(f"Unsupported task: {self.task}. Use 'regression' or 'classification'.") + task = validate_task(self.task, type(self).__name__) if self.placement_strategy not in ("cart", "lightgbm"): raise InvalidParamError( @@ -200,7 +199,7 @@ def fit(self, X, y=None): # exactly ``output_dim`` bins, adaptive clamps it into ``[min, max]``. adapter = PLEPlacementAdapter( placement_strategy=self.placement_strategy, - task=self.task, + task=task, random_state=self.random_state, ) min_thresholds = max(0, min_bins - 1) diff --git a/pretab/expansion/functional/base.py b/pretab/expansion/functional/base.py index 81ce176..80c7d69 100644 --- a/pretab/expansion/functional/base.py +++ b/pretab/expansion/functional/base.py @@ -13,7 +13,7 @@ from sklearn.utils.validation import check_is_fitted from ...core.base import BasePreTabTransformer -from ...core.parameters import UNSET, validate_placement +from ...core.parameters import UNSET, validate_placement, validate_task from ...core.supervised import warn_target_leakage from ...exceptions import ( IncompatibleParamsError, @@ -84,8 +84,7 @@ def fit(self, X, y=None): warn_target_leakage(self, y) placement_strategy = self._resolve_placement_strategy() validate_placement(self.target_aware, placement_strategy) - if self.task not in ("regression", "classification"): - raise InvalidParamError(f"Invalid task. Choose 'regression' or 'classification'. Got {self.task!r}.") + task = validate_task(self.task, type(self).__name__) n_centers = self._resolve_param("output_dim", default=6) min_req = self._resolve_param("min_output_dim", default=None) max_req = self._resolve_param("max_output_dim", default=None) @@ -105,7 +104,7 @@ def fit(self, X, y=None): adapter = RBFPlacementAdapter( target_aware=self.target_aware, placement_strategy=placement_strategy, - task=self.task, + task=task, random_state=self.random_state, ) if self.target_aware and self.adaptive: diff --git a/pretab/expansion/spline/base.py b/pretab/expansion/spline/base.py index e6a4cf1..6bdb1a8 100644 --- a/pretab/expansion/spline/base.py +++ b/pretab/expansion/spline/base.py @@ -27,7 +27,7 @@ select_knots, supplement_interior_knots, ) -from ...core.parameters import UNSET, validate_placement +from ...core.parameters import UNSET, validate_placement, validate_task from ...core.policy import RepresentationPolicy, resolve_out_of_range from ...core.supervised import warn_target_leakage from ...exceptions import ( @@ -83,7 +83,9 @@ class BaseSplineTransformer(BasePreTabTransformer): evenly across the range, ``"quantile"`` places them at data quantiles. task : {"regression", "classification"} or None, default=None - Task passed to the target-aware selector when ``target_aware=True``. + Task passed to the target-aware selector when ``target_aware=True``. ``None`` is + treated as ``"regression"``; any other value raises + :class:`~pretab.exceptions.InvalidParamError` at ``fit``. adaptive : bool, default=False If True, the per-feature output dimension may vary within @@ -299,6 +301,7 @@ def fit(self, X, y=None): """Determine per-feature knot vectors.""" warn_target_leakage(self, y) validate_placement(self.target_aware, self.placement_strategy) + validate_task(self.task, type(self).__name__, allow_none=True) n_basis = self._resolve_param("output_dim", default=6) min_basis_req = self._resolve_param("min_output_dim", default=None) max_basis_req = self._resolve_param("max_output_dim", default=None) diff --git a/pretab/expansion/spline/cubic_regression.py b/pretab/expansion/spline/cubic_regression.py index 392a99d..70d8c7b 100644 --- a/pretab/expansion/spline/cubic_regression.py +++ b/pretab/expansion/spline/cubic_regression.py @@ -4,7 +4,7 @@ from sklearn.base import BaseEstimator, TransformerMixin from sklearn.utils.validation import check_is_fitted -from ...core.parameters import UNSET, validate_placement +from ...core.parameters import UNSET, validate_placement, validate_task from ...core.policy import RepresentationPolicy, resolve_out_of_range from ...core.supervised import warn_target_leakage from ...exceptions import InvalidParamError @@ -56,7 +56,9 @@ class CubicRegressionSplineTransformer(SplineBasisMixin, TransformerMixin, BaseE across the range, ``"quantile"`` places them at evenly spaced data quantiles. task : {"regression", "classification"} or None, default=None - Task forwarded to the target-aware selector when ``target_aware=True``. + Task forwarded to the target-aware selector when ``target_aware=True``. ``None`` is + treated as ``"regression"``; any other value raises + :class:`~pretab.exceptions.InvalidParamError` at ``fit``. adaptive : bool, default=False If True (with ``target_aware=True``), the per-feature output dimension may @@ -166,6 +168,7 @@ def _bspline_basis(self, x, knots): def fit(self, X, y=None): warn_target_leakage(self, y) validate_placement(self.target_aware, self.placement_strategy) + validate_task(self.task, type(self).__name__, allow_none=True) X = self._validate_allow_nan(X, reset=True) output_dim = self._resolve_param("output_dim", default=6) diff --git a/pretab/expansion/spline/natural_cubic.py b/pretab/expansion/spline/natural_cubic.py index 21c3f45..89309fe 100644 --- a/pretab/expansion/spline/natural_cubic.py +++ b/pretab/expansion/spline/natural_cubic.py @@ -3,7 +3,7 @@ from sklearn.base import BaseEstimator, TransformerMixin from sklearn.utils.validation import check_is_fitted -from ...core.parameters import UNSET, validate_placement +from ...core.parameters import UNSET, validate_placement, validate_task from ...core.policy import RepresentationPolicy, resolve_out_of_range from ...core.supervised import warn_target_leakage from ...exceptions import InvalidParamError @@ -58,7 +58,9 @@ class NaturalCubicSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEsti data quantiles. task : {"regression", "classification"} or None, default=None - Task forwarded to the target-aware selector when ``target_aware=True``. + Task forwarded to the target-aware selector when ``target_aware=True``. ``None`` is + treated as ``"regression"``; any other value raises + :class:`~pretab.exceptions.InvalidParamError` at ``fit``. adaptive : bool, default=False If True (with ``target_aware=True``), the per-feature output dimension may @@ -176,6 +178,7 @@ def d(k): def fit(self, X, y=None): warn_target_leakage(self, y) validate_placement(self.target_aware, self.placement_strategy) + validate_task(self.task, type(self).__name__, allow_none=True) X = self._validate_allow_nan(X, reset=True) output_dim = self._resolve_param("output_dim", default=6) diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index 40276aa..54d59cb 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -42,7 +42,7 @@ ) from .compose.serialize import SCHEMA_VERSION, preprocessor_from_spec, preprocessor_to_spec from .core.logging import configure_logging, get_logger -from .core.parameters import UNSET +from .core.parameters import UNSET, validate_task from .core.policy import RepresentationPolicy, apply_constant_policy from .exceptions import ( ConfigWarning, @@ -87,7 +87,8 @@ def _preset_numerical_method(task) -> str: """Return the preset numerical method for a given ``task``. Splines (``"bspline"``) are the preset default for regression, and piecewise-linear - encoding (``"ple"``) remains the default for classification. + encoding (``"ple"``) remains the default for classification. ``task`` must already + be validated (see :meth:`Preprocessor._resolved_params`). """ return "bspline" if task == "regression" else "ple" @@ -200,7 +201,9 @@ class Preprocessor(TransformerMixin, BaseEstimator): Multivariate tensor-product and thin-plate splines are available as standalone transformers. task : str, default="regression" Supervised task (``"regression"`` or ``"classification"``) used by target-aware methods to - place basis units / knots against ``y``. Only consulted when ``target_aware`` is True. + place basis units / knots against ``y``. Only consulted when ``target_aware`` is True (and + by ``preset`` to pick ``numerical_method``). Matched exactly: any other value, e.g. + ``"Regression"``, raises :class:`~pretab.exceptions.InvalidParamError` at ``fit``. adaptive : bool, default=False Whether adaptive-capable methods size each feature's output dimension from the data (within ``[min_output_dim, max_output_dim]``) instead of using the fixed ``output_dim``. @@ -755,10 +758,17 @@ def _resolved_params(self): back to the preset's value, or to their ordinary default when no preset supplies one. The ``preset`` key is dropped from the returned mapping. + + Raises + ------ + InvalidParamError + If ``task`` or ``preset`` is invalid. ``task`` is validated before a + preset derives ``numerical_method`` from it. """ params = self.get_params(deep=False) preset = params.pop("preset", None) resolved = {key: (_PRESET_PARAM_DEFAULTS[key] if value is UNSET else value) for key, value in params.items()} + validate_task(resolved["task"], type(self).__name__) if preset is None: return resolved if preset not in PRESETS: @@ -790,6 +800,11 @@ def get_resolved_config(self): ------- dict The resolved parameter mapping. + + Raises + ------ + InvalidParamError + If ``task`` or ``preset`` is invalid, exactly as ``fit`` would raise. """ return self._resolved_params() diff --git a/tests/compose/test_config.py b/tests/compose/test_config.py index e3a4c46..657be4f 100644 --- a/tests/compose/test_config.py +++ b/tests/compose/test_config.py @@ -23,6 +23,12 @@ def test_invalid_placement_combo_raises(make_config): make_config(target_aware=False, placement_strategy="cart") +@pytest.mark.parametrize("task", ["Regression", "classif", None]) +def test_invalid_task_raises(make_config, task): + with pytest.raises(InvalidParamError, match=r"Preprocessor\.task = .* is invalid"): + make_config(task=task) + + def test_feature_preprocessing_is_copied(make_config): fp = {"age": "standardization"} cfg = make_config(feature_preprocessing=fp) diff --git a/tests/core/test_location_selectors.py b/tests/core/test_location_selectors.py index 423969b..3405745 100644 --- a/tests/core/test_location_selectors.py +++ b/tests/core/test_location_selectors.py @@ -8,7 +8,7 @@ CARTLocationSelector, LightGBMLocationSelector, ) -from pretab.exceptions import IncompatibleParamsError +from pretab.exceptions import IncompatibleParamsError, InvalidParamError from pretab.placement.adapters import SplinePlacementAdapter @@ -47,6 +47,23 @@ def test_cart_requires_y(data): CARTLocationSelector().select(X, None, min_count=2, max_count=5) +@pytest.mark.parametrize("selector_cls", [CARTLocationSelector, LightGBMLocationSelector]) +@pytest.mark.parametrize("task", ["Regression", "regresion", "", 1]) +def test_selectors_reject_an_unknown_task(data, selector_cls, task): + """Any task other than "regression" used to fall through to a classifier.""" + X, y = data + with pytest.raises(InvalidParamError, match="task"): + selector_cls().select(X, y, task=task, min_count=2, max_count=10) + + +def test_cart_task_none_means_regression(data): + X, y = data + np.testing.assert_array_equal( + CARTLocationSelector().select(X, y, task=None, min_count=2, max_count=10), + CARTLocationSelector().select(X, y, task="regression", min_count=2, max_count=10), + ) + + def test_cart_reproducible(data): X, y = data a = CARTLocationSelector().select(X, y, min_count=3, max_count=10) diff --git a/tests/encoding/numerical/test_ple_transformer.py b/tests/encoding/numerical/test_ple_transformer.py index e5c7cfa..b50e792 100644 --- a/tests/encoding/numerical/test_ple_transformer.py +++ b/tests/encoding/numerical/test_ple_transformer.py @@ -48,7 +48,7 @@ def test_ple_transformer_multi_feature_shape(X_multi_feature, y_regression): def test_ple_invalid_task_raises(X_single_feature): - with pytest.raises(ValueError, match="Unsupported task"): + with pytest.raises(ValueError, match=r"PLETransformer\.task = 'unsupported' is invalid"): transformer = PLETransformer(task="unsupported") # type: ignore[arg-type] transformer.fit(X_single_feature, np.linspace(0, 1, 10)) diff --git a/tests/expansion/functional/test_rbfexpansion_transformer.py b/tests/expansion/functional/test_rbfexpansion_transformer.py index 2f9028b..9b178d6 100644 --- a/tests/expansion/functional/test_rbfexpansion_transformer.py +++ b/tests/expansion/functional/test_rbfexpansion_transformer.py @@ -55,7 +55,7 @@ def test_rbf_invalid_strategy(): def test_rbf_invalid_task(): - with pytest.raises(ValueError, match="Invalid task"): + with pytest.raises(ValueError, match=r"RBFExpansionTransformer\.task = 'invalid' is invalid"): RBFExpansionTransformer(task="invalid").fit(np.random.rand(5, 1)) diff --git a/tests/expansion/functional/test_reluexpansion_transformer.py b/tests/expansion/functional/test_reluexpansion_transformer.py index 7430410..2be3027 100644 --- a/tests/expansion/functional/test_reluexpansion_transformer.py +++ b/tests/expansion/functional/test_reluexpansion_transformer.py @@ -55,7 +55,7 @@ def test_relu_invalid_strategy(): def test_relu_invalid_task(): - with pytest.raises(ValueError, match="Invalid task"): + with pytest.raises(ValueError, match=r"ReLUExpansionTransformer\.task = 'nonsense' is invalid"): ReLUExpansionTransformer(task="nonsense").fit(np.random.rand(5, 1)) diff --git a/tests/expansion/functional/test_sigmoidexpansion_transformer.py b/tests/expansion/functional/test_sigmoidexpansion_transformer.py index 9c21275..c592d72 100644 --- a/tests/expansion/functional/test_sigmoidexpansion_transformer.py +++ b/tests/expansion/functional/test_sigmoidexpansion_transformer.py @@ -58,7 +58,7 @@ def test_sigmoid_invalid_strategy(): def test_sigmoid_invalid_task(): - with pytest.raises(ValueError, match="Invalid task"): + with pytest.raises(ValueError, match=r"SigmoidExpansionTransformer\.task = 'nonsense' is invalid"): SigmoidExpansionTransformer(task="nonsense").fit(np.random.rand(5, 1)) diff --git a/tests/expansion/functional/test_tanh_transformer.py b/tests/expansion/functional/test_tanh_transformer.py index a72e879..7301688 100644 --- a/tests/expansion/functional/test_tanh_transformer.py +++ b/tests/expansion/functional/test_tanh_transformer.py @@ -50,5 +50,5 @@ def test_tanh_invalid_strategy_raises(): def test_tanh_invalid_task_raises(): - with pytest.raises(ValueError, match="Invalid task"): + with pytest.raises(ValueError, match=r"TanhExpansionTransformer\.task = 'invalid' is invalid"): TanhExpansionTransformer(task="invalid").fit(np.random.rand(5, 1)) diff --git a/tests/integration/test_exceptions.py b/tests/integration/test_exceptions.py index d0d0be0..3838bc3 100644 --- a/tests/integration/test_exceptions.py +++ b/tests/integration/test_exceptions.py @@ -15,6 +15,7 @@ from sklearn.exceptions import NotFittedError from pretab import Preprocessor, PretabWarning +from pretab.compose.registry import TRANSFORMER_REGISTRY from pretab.core.adaptive import AdaptiveResolutionMixin from pretab.core.knots import generate_internal_knots from pretab.exceptions import ( @@ -172,10 +173,37 @@ def test_bspline_error_is_catchable_as_pretab_error(xy): def test_ple_unsupported_task(xy): X, y = xy - with pytest.raises(InvalidParamError, match="Unsupported task"): + with pytest.raises(InvalidParamError, match=r"PLETransformer\.task = 'bogus' is invalid"): PLETransformer(output_dim=5, task="bogus").fit(X, y) # type: ignore[arg-type] +# Every registered method that takes a ``task`` parameter. +_TASK_METHODS = sorted(name for name, spec in TRANSFORMER_REGISTRY.items() if "task" in spec.allowed_args) + + +@pytest.mark.parametrize("method", _TASK_METHODS) +def test_unknown_task_raises_at_fit(method): + """A near-miss ``task`` used to switch the knot splines to classification placement.""" + spec = TRANSFORMER_REGISTRY[method] + X = np.linspace(0, 10, 200).reshape(-1, 1) + y = np.round(X[:, 0] ** 2) # integer-valued regression target, so a classifier would also fit + params: dict[str, object] = {"task": "Regression"} + if spec.target_usage == "optional": + params.update(target_aware=True, placement_strategy="cart") + transformer = spec.transformer_cls(**params) # validated at fit, not at construction + with pytest.raises(InvalidParamError, match=rf"{spec.transformer_cls.__name__}\.task = 'Regression' is invalid"): + transformer.fit(X, y) + + +def test_spline_task_none_places_knots_for_regression(xy): + X, y = xy + knots = { + task: BSplineTransformer(target_aware=True, placement_strategy="cart", task=task).fit(X, y).knots_[0] + for task in (None, "regression") + } + np.testing.assert_array_equal(knots[None], knots["regression"]) + + def test_ple_length_mismatch_is_data_error(xy): X, y = xy with pytest.raises(PretabDataError) as exc: @@ -220,6 +248,15 @@ def test_preprocessor_unknown_numerical_method(): assert isinstance(exc.value, ValueError) +@pytest.mark.parametrize("method", ["bspline", "naturalspline", "rbf", "ple", "minmax"]) +def test_preprocessor_unknown_task(method): + """Before, ``task="Regression"`` gave classification knots, or failed deep in scikit-learn.""" + df = pd.DataFrame({"a": np.linspace(0, 10, 200)}) + y = np.sin(df["a"].to_numpy()) + with pytest.raises(InvalidParamError, match=r"Preprocessor\.task = 'Regression' is invalid"): + Preprocessor(numerical_method=method, task="Regression").fit(df, y) + + def test_preprocessor_unknown_categorical_method(): df = pd.DataFrame({"c": ["a", "b", "a", "c"] * 10}) y = np.arange(40) diff --git a/tests/integration/test_presets.py b/tests/integration/test_presets.py index 827e8a3..646d3d8 100644 --- a/tests/integration/test_presets.py +++ b/tests/integration/test_presets.py @@ -96,6 +96,14 @@ def test_invalid_preset_raises(): Preprocessor(preset="nope").get_resolved_config() +@pytest.mark.parametrize("preset", [None, "standard", "expanded", "adaptive"]) +def test_invalid_task_raises_instead_of_resolving(preset): + """``task="Regression"`` used to resolve the classification preset method "ple".""" + pre = Preprocessor(preset=preset, task="Regression") # validated lazily, like fit + with pytest.raises(InvalidParamError, match=r"Preprocessor\.task = 'Regression' is invalid"): + pre.get_resolved_config() + + def test_presets_fit_with_distinct_widths(data): X, y = data widths = {} From ccaa103121c5c0456e695cd2880d276592b2d681 Mon Sep 17 00:00:00 2001 From: ChrisW09 <50968720+ChrisW09@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:30:21 +0200 Subject: [PATCH 08/16] fix(preprocessor): accept polars DataFrames as input to_dataframe treated every input that was not a dict or NumPy array as a pandas frame, so Preprocessor.fit / transform on a polars DataFrame failed with "AttributeError: 'list' object has no attribute 'duplicated'". A scikit-learn Pipeline with set_output(transform="polars") therefore crashed as soon as it handed its polars output to the Preprocessor. A polars frame (detected via sys.modules, so polars is never imported) is now converted to pandas column by column with Series.to_numpy, which needs no pyarrow: names, order and numeric / boolean / temporal dtypes are kept, string, categorical and enum columns become object columns, and nulls become NaN so imputation and missing_policy see them as missing. Column-type detection, get_feature_names_out, label matching at transform and polars output work as for pandas input. RepresentationSearchCV and CrossFittedTransformer pass a polars frame through like a pandas frame, so each fold sees the same column types as the final fit. Input without column labels is now read with np.asarray, so a list of rows works like an array (positional, no feature_names_in_), while a pandas Series or 1D input raises a PretabDataError instead of an AttributeError. Fixes #94 --- docs/core_concepts/configuration.md | 24 ++++ docs/getting_started/installation.md | 4 +- docs/homepage.md | 4 +- pretab/compose/feature_detection.py | 67 +++++++++-- pretab/compose/search.py | 9 +- pretab/core/supervised.py | 7 +- pretab/core/validation.py | 13 ++- pretab/preprocessor.py | 34 +++--- tests/compose/test_feature_detection.py | 77 +++++++++++++ tests/integration/test_polars_input.py | 146 ++++++++++++++++++++++++ tests/integration/test_preprocessor.py | 7 ++ 11 files changed, 362 insertions(+), 30 deletions(-) create mode 100644 tests/integration/test_polars_input.py diff --git a/docs/core_concepts/configuration.md b/docs/core_concepts/configuration.md index 9eaa85c..a2271c5 100644 --- a/docs/core_concepts/configuration.md +++ b/docs/core_concepts/configuration.md @@ -51,6 +51,30 @@ example `{"feature_0": "rbf"}`. Check `numerical_features_` / `categorical_featu overrides, rather than guessing the column order. ``` +## Polars DataFrame input + +A `polars.DataFrame` is accepted wherever a pandas `DataFrame` is, so a scikit-learn +`Pipeline` configured with `set_output(transform="polars")` can hand its polars output straight +to the `Preprocessor`. PreTab reads the frame through pandas with the same column names, order +and dtypes, then detects column types as usual: `Float*` columns are numerical, `Int*` / +`UInt*` columns follow the integer cardinality rule (an integer column with nulls becomes +float, as in pandas), and `String`, `Categorical`, `Enum` and `Boolean` columns are +categorical. Polars nulls become missing values, so imputation and `missing_policy` treat them +like `NaN` in a pandas frame. The conversion does not need `pyarrow`. + +```python +import polars as pl +from sklearn.impute import SimpleImputer +from sklearn.pipeline import Pipeline + +X = pl.DataFrame({"age": [25.0, None, 47.0, 33.0], "income": [3.1e4, 5.2e4, None, 4.4e4]}) +y = [0.0, 1.0, 1.0, 0.0] + +pipe = Pipeline([("impute", SimpleImputer()), ("pretab", Preprocessor(numerical_method="minmax"))]) +pipe.set_output(transform="polars") +pipe.fit_transform(X, y) # a polars.DataFrame with columns num_age, num_income +``` + ## Per-feature overrides Columns rarely want identical treatment. The `feature_preprocessing` dict assigns a strategy diff --git a/docs/getting_started/installation.md b/docs/getting_started/installation.md index 924416f..e6b43ba 100644 --- a/docs/getting_started/installation.md +++ b/docs/getting_started/installation.md @@ -67,7 +67,9 @@ pip install "pretab[polars]" `polars` is only needed for the `set_output(transform="polars")` output path; every other output (`output_structure="matrix"`/`"blocks"`, `output_format="dense"`/`"sparse"`, `set_output(transform="pandas")`) works without it. Requesting `"polars"` output without the -extra installed raises a clear `OptionalDependencyError`. +extra installed raises a clear `OptionalDependencyError`. A `polars.DataFrame` is also accepted +as input to `fit` / `transform` (see +[Polars DataFrame input](../core_concepts/configuration.md#polars-dataframe-input)). ``` Use the convenience `all` extra to install every optional dependency at once: diff --git a/docs/homepage.md b/docs/homepage.md index 907ee97..4112bd8 100644 --- a/docs/homepage.md +++ b/docs/homepage.md @@ -34,8 +34,8 @@ compose standalone representations with scikit-learn transformers and estimators ::: :::{grid-item-card} 🧠 Smart defaults -Automatic feature-type detection (numerical vs. categorical) with support for both -`pandas.DataFrame` and `numpy.ndarray` inputs. +Automatic feature-type detection (numerical vs. categorical) with support for +`pandas.DataFrame`, `polars.DataFrame` and `numpy.ndarray` inputs. ::: :::: diff --git a/pretab/compose/feature_detection.py b/pretab/compose/feature_detection.py index 5f1cec3..11f59aa 100644 --- a/pretab/compose/feature_detection.py +++ b/pretab/compose/feature_detection.py @@ -1,4 +1,4 @@ -"""Coerce inputs to DataFrames and classify columns as numerical or categorical. +"""Coerce inputs to pandas DataFrames and classify columns as numerical or categorical. Feature-type detection decides which construction path each column takes. It is kept here, separate from orchestration, so the Preprocessor's ``fit`` reads as a @@ -10,27 +10,80 @@ import numpy as np import pandas as pd +from ..core.validation import is_polars_frame from ..exceptions import PretabDataError, invalid_param_error -__all__ = ["bool_columns_as_object", "detect_column_types", "to_dataframe", "with_string_labels"] +__all__ = [ + "bool_columns_as_object", + "detect_column_types", + "has_column_labels", + "to_dataframe", + "with_string_labels", +] + + +def _polars_to_pandas(X) -> pd.DataFrame: + """Convert a polars DataFrame to pandas, keeping column names, order and dtypes. + + ``polars.DataFrame.to_pandas`` requires pyarrow, which neither polars nor + PreTab depends on, so each column goes through ``Series.to_numpy`` instead: + integer, unsigned, float, boolean and temporal columns keep their NumPy + dtype (an integer column with nulls becomes float with NaN, as in pandas), + while string, categorical and enum columns -- and boolean columns with nulls + -- become ``object`` columns. Their nulls arrive as ``None`` and are mapped + to ``NaN``, the missing marker the imputers and missing indicators recognize. + """ + columns = {} + for column in X.get_columns(): + values = column.to_numpy() + if values.dtype == object: + values = np.where(pd.isna(values), np.nan, values) + columns[column.name] = values + return pd.DataFrame(columns) + + +def has_column_labels(X) -> bool: + """Return True if ``X`` carries its own column labels. + + DataFrames (pandas or polars) and dicts of columns are matched by label; + anything else (a NumPy array, a list of rows, ...) is matched by position. + """ + return isinstance(X, dict) or hasattr(X, "columns") def to_dataframe(X, *, copy: bool = False) -> pd.DataFrame: - """Return ``X`` as a DataFrame, naming array columns ``feature_0``, ``feature_1`` .... + """Return ``X`` as a pandas DataFrame, naming array columns ``feature_0``, ``feature_1`` .... - Dicts and NumPy arrays are wrapped in a fresh DataFrame; an existing - DataFrame is returned as-is, or copied when ``copy`` is True. + Dicts are wrapped in a fresh DataFrame and a polars DataFrame is converted to + one with the same column names, order and (as far as possible) dtypes. Input + without column labels -- a NumPy array, a list of rows -- is read as a 2D + array. An existing pandas DataFrame is returned as-is, or copied when + ``copy`` is True. Raises ------ PretabDataError - If the resulting columns contain a duplicate label. A + If ``X`` is a pandas Series or array-like input that is not 2D, or if the + resulting columns contain a duplicate label. A :class:`~sklearn.compose.ColumnTransformer` keys its per-column steps by name, so a duplicate cannot be routed unambiguously. """ + if isinstance(X, pd.Series): + raise PretabDataError( + "Expected 2D input (a DataFrame or a 2D array), got a pandas Series.\n" + "Fix: pass X.to_frame() to use the Series as a single column." + ) if isinstance(X, dict): X = pd.DataFrame(X) - elif isinstance(X, np.ndarray): + elif is_polars_frame(X): + X = _polars_to_pandas(X) + elif not has_column_labels(X): + X = np.asarray(X) + if X.ndim != 2: + raise PretabDataError( + f"Expected 2D input (a DataFrame or a 2D array), got an array with {X.ndim} dimension(s).\n" + "Fix: reshape a single feature to one column, e.g. X.reshape(-1, 1)." + ) X = pd.DataFrame(X, columns=pd.Index([f"feature_{i}" for i in range(X.shape[1])])) else: X = X.copy() if copy else X diff --git a/pretab/compose/search.py b/pretab/compose/search.py index 445ad67..7a15bdf 100644 --- a/pretab/compose/search.py +++ b/pretab/compose/search.py @@ -21,6 +21,7 @@ from sklearn.utils.validation import _check_method_params, check_is_fitted from ..core._typing import PredictorLike +from ..core.validation import is_polars_frame from ..exceptions import InvalidParamError from ..preprocessor import Preprocessor @@ -28,9 +29,15 @@ def _row_subset(data, idx): - """Return the rows of ``data`` at positions ``idx`` for arrays or frames.""" + """Return the rows of ``data`` at positions ``idx`` for arrays or frames. + + A pandas or polars frame stays a frame, so each fold's Preprocessor detects + the same column types as the final refit on all of ``X``. + """ if hasattr(data, "iloc"): return data.iloc[idx] + if is_polars_frame(data): + return data[idx] return np.asarray(data)[idx] diff --git a/pretab/core/supervised.py b/pretab/core/supervised.py index 4fd6780..0e247e8 100644 --- a/pretab/core/supervised.py +++ b/pretab/core/supervised.py @@ -34,6 +34,7 @@ from ._typing import TransformerLike from .parameters import validate_task from .representation import RepresentationSpecMixin +from .validation import is_polars_frame __all__ = ["CrossFittedTransformer", "in_controlled_context", "warn_target_leakage"] @@ -93,19 +94,19 @@ def warn_target_leakage(estimator, y) -> None: def _as_2d(X): """Return ``X`` as 2D input for the wrapped transformer. - A pandas DataFrame is passed through unchanged, so column names and per-column + A pandas or polars DataFrame is passed through unchanged, so column names and per-column dtypes reach the wrapped estimator (e.g. a Preprocessor detecting numerical vs categorical columns, or a name-based ColumnTransformer); any other input is converted to a NumPy array, with 1D input reshaped to a single column. """ - if hasattr(X, "iloc") and getattr(X, "ndim", None) == 2: + if (hasattr(X, "iloc") and getattr(X, "ndim", None) == 2) or is_polars_frame(X): return X X = np.asarray(X) return X.reshape(-1, 1) if X.ndim == 1 else X def _take_rows(X, indices): - """Select rows of a DataFrame or array by position.""" + """Select rows of a DataFrame (pandas or polars) or array by position.""" return X.iloc[indices] if hasattr(X, "iloc") else X[indices] diff --git a/pretab/core/validation.py b/pretab/core/validation.py index 8a271e7..eb3401d 100644 --- a/pretab/core/validation.py +++ b/pretab/core/validation.py @@ -6,6 +6,7 @@ ``transform`` -- and so ``n_features_in_`` is recorded consistently. """ +import sys import warnings from typing import Literal, cast @@ -14,7 +15,17 @@ from ..exceptions import DataWarning, PretabDataError, invalid_param_error -__all__ = ["resolve_input_features", "validate_2d_allow_nan"] +__all__ = ["is_polars_frame", "resolve_input_features", "validate_2d_allow_nan"] + + +def is_polars_frame(X) -> bool: + """Return True if ``X`` is a polars DataFrame, without importing polars. + + A polars object can only exist once polars has been imported, so when the + module is absent from ``sys.modules`` ``X`` cannot be a polars frame. + """ + polars = sys.modules.get("polars") + return polars is not None and isinstance(X, polars.DataFrame) def resolve_input_features(estimator, input_features) -> list: diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index 54d59cb..516d0b2 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -18,6 +18,7 @@ from .compose.feature_detection import ( bool_columns_as_object, detect_column_types, + has_column_labels, to_dataframe, with_string_labels, ) @@ -146,7 +147,8 @@ class Preprocessor(TransformerMixin, BaseEstimator): -------- - Supports a wide range of preprocessing methods for numerical and categorical features. - Automatically detects feature types (numerical vs. categorical). - - Compatible with both pandas DataFrames and NumPy arrays. + - Compatible with pandas and polars DataFrames and NumPy arrays (a polars frame is read + through pandas, so it gets the same column-type detection). - Handles external embedding arrays for models that require learned representations. - Returns either a dictionary of transformed feature blocks or a single NumPy array. - Fully compatible with scikit-learn transformers and pipelines. @@ -503,8 +505,9 @@ def fit(self, X, y=None, embeddings=None): Parameters ---------- - X : pandas.DataFrame, numpy.ndarray, or dict - The input features. + X : pandas.DataFrame, polars.DataFrame, numpy.ndarray, or dict + The input features. A frame or dict is matched by column name, an array (or a list + of rows) by position. y : array-like, default=None Target values (used for decision tree-based methods). embeddings : np.ndarray or list of np.ndarray, optional @@ -550,7 +553,7 @@ def fit(self, X, y=None, embeddings=None): verbose=resolved["verbose"], ) - fitted_on_array = isinstance(X, np.ndarray) + fitted_on_array = not has_column_labels(X) X = to_dataframe(X) if self.feature_preprocessing: @@ -663,7 +666,7 @@ def transform(self, X, embeddings=None, return_array=None): Parameters ---------- - X : pandas.DataFrame, numpy.ndarray, or dict + X : pandas.DataFrame, polars.DataFrame, numpy.ndarray, or dict Input features to transform. embeddings : np.ndarray or list of np.ndarray, optional External embeddings to attach to dictionary output. Required when @@ -728,7 +731,7 @@ def fit_transform(self, X, y=None, embeddings=None, return_array=None): Parameters ---------- - X : pandas.DataFrame, numpy.ndarray, or dict + X : pandas.DataFrame, polars.DataFrame, numpy.ndarray, or dict Input features. y : array-like, optional Target values. @@ -907,12 +910,13 @@ def output_dims_(self) -> dict: def _align_input(self, X): """Return ``X`` as a DataFrame whose columns line up with the fitted columns. - A NumPy array is matched by position. Once the preprocessor was fitted on - named columns, an array of the fitted width takes those column labels - (numeric columns of an object array are re-inferred), and a DataFrame - passed to a preprocessor fitted on an array is matched by position too -- - both with scikit-learn's usual warning about the missing or unexpected - feature names. Any other DataFrame is matched by column label. + A NumPy array (or a list of rows) is matched by position. Once the + preprocessor was fitted on named columns, an array of the fitted width + takes those column labels (numeric columns of an object array are + re-inferred), and a DataFrame passed to a preprocessor fitted on an array + is matched by position too -- both with scikit-learn's usual warning about + the missing or unexpected feature names. Any other DataFrame (pandas or + polars) is matched by column label. Raises ------ @@ -928,7 +932,7 @@ def _align_input(self, X): and list(fitted_labels) == positional_labels ) - is_array = isinstance(X, np.ndarray) + is_array = not has_column_labels(X) X = to_dataframe(X, copy=True) if not (is_array or (fitted_on_array and list(X.columns) != positional_labels)): return X @@ -1023,7 +1027,7 @@ def estimate_output_shape(self, X) -> tuple: Parameters ---------- - X : pandas.DataFrame, numpy.ndarray, or dict + X : pandas.DataFrame, polars.DataFrame, numpy.ndarray, or dict Input whose row count drives the estimate; not transformed. Returns @@ -1043,7 +1047,7 @@ def estimate_memory(self, X) -> int: Parameters ---------- - X : pandas.DataFrame, numpy.ndarray, or dict + X : pandas.DataFrame, polars.DataFrame, numpy.ndarray, or dict Input whose row count drives the estimate; not transformed. Returns diff --git a/tests/compose/test_feature_detection.py b/tests/compose/test_feature_detection.py index c04986d..011b555 100644 --- a/tests/compose/test_feature_detection.py +++ b/tests/compose/test_feature_detection.py @@ -40,6 +40,83 @@ def test_to_dataframe_rejects_duplicate_columns(): to_dataframe(df) +def test_to_dataframe_converts_polars_frame_with_names_order_and_dtypes(): + """A polars frame used to be treated as pandas and fail on ``columns.duplicated``.""" + pl = pytest.importorskip("polars") + frame = pl.DataFrame( + { + "z_float": [0.5, None, 2.5], + "count": [3, 1, 2], + "count_with_null": [3, None, 2], + "flag": [True, False, True], + "flag_with_null": [True, None, False], + "city": ["b", "a", None], + } + ).with_columns(pl.col("city").cast(pl.Categorical).alias("city_cat")) + + df = to_dataframe(frame) + + assert isinstance(df, pd.DataFrame) + assert list(df.columns) == frame.columns + assert df.dtypes.to_dict() == { + "z_float": np.float64, + "count": np.int64, + "count_with_null": np.float64, + "flag": bool, + "flag_with_null": object, + "city": object, + "city_cat": object, + } + assert df["count"].tolist() == [3, 1, 2] + assert df["city"].tolist()[:2] == ["b", "a"] and df["city_cat"].tolist()[:2] == ["b", "a"] + # Polars nulls become NaN, the missing marker pandas and the imputers use. + assert df.isna().sum().to_dict() == { + "z_float": 1, + "count": 0, + "count_with_null": 1, + "flag": 0, + "flag_with_null": 1, + "city": 1, + "city_cat": 1, + } + assert np.isnan(df["city"][2]) and np.isnan(df["flag_with_null"][1]) + + +def test_detect_column_types_on_polars_frame(): + pl = pytest.importorskip("polars") + frame = pl.DataFrame( + { + "f": [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], + "many": [1, 2, 3, 4, 5, 6], + "few": [1, 2, 1, 2, 1, 2], + "s": ["a", "b", "a", "b", "a", "b"], + "flag": [True, False, True, False, True, False], + } + ).with_columns(pl.col("s").cast(pl.Categorical).alias("cat")) + num, cat = detect_column_types(frame, cat_cutoff=0.5, treat_all_integers_as_numerical=False) + assert num == ["f", "many"] + assert cat == ["few", "s", "flag", "cat"] + + +def test_to_dataframe_reads_a_list_of_rows_as_an_array(): + df = to_dataframe([[1.0, 2.0], [3.0, 4.0]]) + assert list(df.columns) == ["feature_0", "feature_1"] + np.testing.assert_array_equal(df.to_numpy(), [[1.0, 2.0], [3.0, 4.0]]) + + +@pytest.mark.parametrize( + "X, match", + [ + (pd.Series([1.0, 2.0]), "got a pandas Series"), + (np.arange(3.0), "got an array with 1 dimension"), + ([1.0, 2.0], "got an array with 1 dimension"), + ], +) +def test_to_dataframe_rejects_input_that_is_not_2d(X, match): + with pytest.raises(PretabDataError, match=match): + to_dataframe(X) + + def test_float_cutoff_uses_unique_ratio(): df = pd.DataFrame({"x": [1, 2, 3, 1, 2, 3]}) # 3 unique of 6 -> ratio 0.5 num, cat = detect_column_types(df, cat_cutoff=0.6, treat_all_integers_as_numerical=False) diff --git a/tests/integration/test_polars_input.py b/tests/integration/test_polars_input.py new file mode 100644 index 0000000..482aa08 --- /dev/null +++ b/tests/integration/test_polars_input.py @@ -0,0 +1,146 @@ +"""Polars DataFrames as Preprocessor input. + +Regression guard: a polars frame used to be treated as a pandas frame and failed +with ``AttributeError: 'list' object has no attribute 'duplicated'``, so a +scikit-learn ``Pipeline`` with ``set_output(transform="polars")`` crashed when it +handed its polars output to the Preprocessor. +""" + +import numpy as np +import pandas as pd +import pytest +from sklearn.impute import SimpleImputer +from sklearn.pipeline import Pipeline + +from pretab import Preprocessor + +pl = pytest.importorskip("polars") + + +@pytest.fixture +def frames(): + """The same mixed-type data as a pandas and as a polars frame.""" + rng = np.random.default_rng(0) + n = 120 + pandas_frame = pd.DataFrame( + { + "income": rng.normal(5e4, 1e4, n), + "visits": rng.integers(0, 1000, n), + "rooms": rng.integers(1, 4, n), + "member": rng.random(n) > 0.5, + "city": rng.choice(["a", "b", "c"], n), + } + ) + polars_frame = pl.DataFrame( + { + "income": pandas_frame["income"].to_numpy(), + "visits": pandas_frame["visits"].to_numpy(), + "rooms": pandas_frame["rooms"].to_numpy(), + "member": pandas_frame["member"].to_numpy(), + "city": pandas_frame["city"].tolist(), + } + ).with_columns(pl.col("city").cast(pl.Categorical)) + return pandas_frame, polars_frame, rng.normal(size=n) + + +@pytest.mark.parametrize( + "params", + [ + {"numerical_method": "minmax"}, + {"numerical_method": "bspline", "random_state": 0}, + {"numerical_method": "ple", "categorical_method": "one-hot", "random_state": 0}, + ], +) +def test_polars_input_matches_pandas_input(frames, params): + pandas_frame, polars_frame, y = frames + from_pandas = Preprocessor(**params).fit(pandas_frame, y) + from_polars = Preprocessor(**params).fit(polars_frame, y) + + assert from_polars.numerical_features_ == ["income", "visits"] + assert from_polars.categorical_features_ == ["rooms", "member", "city"] + assert from_polars.categorical_features_ == from_pandas.categorical_features_ + assert list(from_polars.feature_names_in_) == polars_frame.columns + np.testing.assert_array_equal(from_polars.get_feature_names_out(), from_pandas.get_feature_names_out()) + + expected = np.asarray(from_pandas.transform(pandas_frame)) + np.testing.assert_allclose(np.asarray(from_polars.transform(polars_frame)), expected) + np.testing.assert_allclose(np.asarray(Preprocessor(**params).fit_transform(polars_frame, y)), expected) + # Columns are matched by name, so a reordered polars frame transforms the same. + reordered = polars_frame.select(polars_frame.columns[::-1]) + np.testing.assert_allclose(np.asarray(from_polars.transform(reordered)), expected) + + +def test_polars_input_with_polars_output(frames): + _, polars_frame, y = frames + pre = Preprocessor(numerical_method="minmax").set_output(transform="polars").fit(polars_frame, y) + out = pre.transform(polars_frame) + assert isinstance(out, pl.DataFrame) + assert out.columns == list(pre.get_feature_names_out()) + assert out.shape == (polars_frame.height, pre.total_output_dim_) + + +def test_polars_nulls_are_missing_values(): + frame = pl.DataFrame({"x": [1.0, None, 3.0, 4.0] * 10, "city": ["a", None, "b", "a"] * 10}) + pre = Preprocessor(numerical_method="minmax", categorical_method="one-hot", missing_policy="impute_with_indicator") + pre.fit(frame) + names = list(pre.get_feature_names_out()) + # The null is imputed and flagged, not encoded as a "None" category. + assert any("missingindicator_city" in name for name in names) + assert not any("None" in name for name in names) + + +@pytest.mark.parametrize("container", ["pandas", "polars"]) +def test_pipeline_set_output_hands_frames_to_the_preprocessor(frames, container): + pandas_frame, _, y = frames + numeric = pandas_frame[["income", "visits"]] + pipe = Pipeline([("impute", SimpleImputer()), ("pretab", Preprocessor(numerical_method="minmax"))]) + pipe.set_output(transform=container) + + out = pipe.fit_transform(numeric, y) + + assert type(out).__module__.split(".")[0] == container + assert out.shape == (len(numeric), 2) + assert list(out.columns) == ["num_income", "num_visits"] + np.testing.assert_allclose(np.asarray(out), np.asarray(pipe.transform(numeric))) + + +def test_representation_search_on_polars_matches_pandas(frames): + """Each fold's Preprocessor gets a polars frame too, not an object array that + would turn every column categorical (and score a different model than the + final refit).""" + from sklearn.linear_model import Ridge + + from pretab import RepresentationSearchCV + + pandas_frame, polars_frame, y = frames + y = np.asarray(pandas_frame["income"]) / 1e4 + y + + def search(X): + return RepresentationSearchCV(Ridge(), ["minmax", "bspline"], cv=3, random_state=0).fit(X, y) + + from_pandas, from_polars = search(pandas_frame), search(polars_frame) + + assert from_polars.cv_results_ == pytest.approx(from_pandas.cv_results_) + assert from_polars.best_method_ == from_pandas.best_method_ + np.testing.assert_allclose(from_polars.predict(polars_frame), from_pandas.predict(pandas_frame)) + + +def test_cross_fitted_preprocessor_on_polars_matches_pandas(frames): + """A polars frame reaches the wrapped Preprocessor as a frame, so its column + types are detected as for the equivalent pandas frame.""" + from pretab import CrossFittedTransformer + + pandas_frame, polars_frame, y = frames + + def cross_fit(X): + cross_fitted = CrossFittedTransformer(Preprocessor(output_dim=4, random_state=0), n_folds=3, random_state=0) + return cross_fitted, cross_fitted.fit_transform(X, y) + + (from_pandas, expected), (from_polars, out) = cross_fit(pandas_frame), cross_fit(polars_frame) + + np.testing.assert_array_equal(from_polars.get_feature_names_out(), from_pandas.get_feature_names_out()) + np.testing.assert_allclose(np.asarray(out, dtype=float), np.asarray(expected, dtype=float)) + np.testing.assert_allclose( + np.asarray(from_polars.transform(polars_frame), dtype=float), + np.asarray(from_pandas.transform(pandas_frame), dtype=float), + ) diff --git a/tests/integration/test_preprocessor.py b/tests/integration/test_preprocessor.py index bb6c9dd..a24ba71 100644 --- a/tests/integration/test_preprocessor.py +++ b/tests/integration/test_preprocessor.py @@ -485,6 +485,13 @@ def test_transform_accepts_an_ndarray_of_the_fitted_width(): assert np.asarray(pre.transform(X)).shape[0] == 60 +def test_a_list_of_rows_is_read_like_an_array(): + rows = [[1.0, 2.0], [3.0, 5.0], [4.0, 9.0]] + pre = Preprocessor(numerical_method="minmax").fit(rows) + assert not hasattr(pre, "feature_names_in_") + np.testing.assert_allclose(np.asarray(pre.transform(rows)), np.asarray(pre.transform(np.asarray(rows)))) + + # --- get_feature_names_out(input_features) (issue #65) ---------------------------- From 76830995d4aee921810821a47c14c319c65c01e8 Mon Sep 17 00:00:00 2001 From: ChrisW09 <50968720+ChrisW09@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:58:09 +0200 Subject: [PATCH 09/16] fix(preprocessor): keep a block for columns with no observed value at fit A column missing on every fit row (e.g. an optional field that is empty in a training window or CV fold) was dropped by the SimpleImputer, scikit-learn's default for such a column. The rest of its pipeline then got no input: numerical methods and one-hot failed with "Found array with 0 feature(s)", while "int" and "none" silently removed the column from the output, output_dims_, blocks and lineage, so get_feature_info() and verbose=2 fits raised an IndexError. The imputers now keep such a column (keep_empty_features=True) and fill it with 0, or fill_value for strategy="constant", so every feature keeps its block and later data where it is observed transforms with the fitted width. fit emits a DataWarning naming the column, the constant policy treats an empty numerical column as constant, and a method that cannot be fitted on a constant column (splines, Box-Cox) raises a PretabDataError naming it. get_feature_info no longer raises on a 0-width int block. Fixes #95 --- docs/core_concepts/missing_values.md | 9 + docs/representations/choosing_a_method.md | 7 + pretab/compose/factory.py | 22 ++- pretab/compose/inspection.py | 7 +- pretab/preprocessor.py | 79 ++++++++- tests/compose/test_factory.py | 10 ++ tests/compose/test_inspection.py | 18 +- tests/integration/test_edge_case_contract.py | 172 ++++++++++++++++++- tests/integration/test_verbosity.py | 12 +- 9 files changed, 320 insertions(+), 16 deletions(-) diff --git a/docs/core_concepts/missing_values.md b/docs/core_concepts/missing_values.md index 4729edb..4cda03d 100644 --- a/docs/core_concepts/missing_values.md +++ b/docs/core_concepts/missing_values.md @@ -36,6 +36,15 @@ unchanged at `transform`. PreTab never drops a row for missing values: every inp produces an output row. ``` +```{note} +A column with no observed value at `fit` (for example an optional field that is empty in a +training window or a cross-validation fold) still keeps its block, so the output layout does not +depend on which rows were seen. The imputers fill it with `0` (or `fill_value` for +`strategy="constant"`), which makes it a constant column, and `fit` emits a `DataWarning` +naming it. See [Edge-case behaviour](../representations/choosing_a_method.md) for the methods +that cannot be fitted on a constant column. +``` + ## The `missing_policy` control For finer control, `missing_policy` selects one of five behaviours for the whole diff --git a/docs/representations/choosing_a_method.md b/docs/representations/choosing_a_method.md index 2e457d5..1f3f3ee 100644 --- a/docs/representations/choosing_a_method.md +++ b/docs/representations/choosing_a_method.md @@ -98,6 +98,13 @@ PreTab is explicit about degenerate inputs rather than failing silently. row for it. - **NaN into a finite-only method**: raises a typed error unless imputation is configured. See [Missing values](../core_concepts/missing_values.md). +- **Column with no observed value at fit** (missing on every row, e.g. an optional field that is + empty in a training window or a cross-validation fold): `Preprocessor` keeps the column's + block and emits a `DataWarning` naming it. The imputers fill it with `0` (or `fill_value` + for `strategy="constant"`), so it is fitted as a constant column; with imputation disabled + it stays missing. A method that cannot be fitted on such a column (the splines above, or + Box-Cox) raises a typed error naming the column, and `policy={"constant": "error"}` rejects an + empty numerical column like any other constant one. ## Non-goals diff --git a/pretab/compose/factory.py b/pretab/compose/factory.py index 9c14479..2ffe070 100644 --- a/pretab/compose/factory.py +++ b/pretab/compose/factory.py @@ -69,6 +69,18 @@ def _filter_kwargs(allowed, kwargs): return {key: kwargs[key] for key in allowed if key in kwargs} +def _imputer(strategy, add_indicator, imputer_kwargs): + """Build the leading :class:`~sklearn.impute.SimpleImputer` of a per-column pipeline. + + A column with no observed value at fit is kept (``keep_empty_features=True``) + and filled with ``0``, or with ``fill_value`` for ``strategy="constant"``. + scikit-learn's default drops it, which leaves the rest of the pipeline an + input without columns: the block then fails to fit or vanishes from the output. + """ + imputer_kwargs = {"keep_empty_features": True, **(imputer_kwargs or {})} + return SimpleImputer(strategy=strategy, add_indicator=add_indicator, **imputer_kwargs) + + def _clamp_spline_basis(output_dim): """Clamp a requested output dimension into the supported B/M/I spline range. @@ -131,10 +143,7 @@ def get_numerical_transformer_steps( steps = [] if add_imputer: - imputer_kwargs = imputer_kwargs or {} - steps.append( - ("imputer", SimpleImputer(strategy=imputer_strategy, add_indicator=add_missing_indicator, **imputer_kwargs)) - ) + steps.append(("imputer", _imputer(imputer_strategy, add_missing_indicator, imputer_kwargs))) # Optional scaling step, added only when it is not already the chosen method. scalers = { @@ -208,10 +217,7 @@ def get_categorical_transformer_steps( steps = [] if add_imputer: - imputer_kwargs = imputer_kwargs or {} - steps.append( - ("imputer", SimpleImputer(strategy=imputer_strategy, add_indicator=add_missing_indicator, **imputer_kwargs)) - ) + steps.append(("imputer", _imputer(imputer_strategy, add_missing_indicator, imputer_kwargs))) if method not in CATEGORICAL_METHODS: raise invalid_param_error( diff --git a/pretab/compose/inspection.py b/pretab/compose/inspection.py index d8e163a..4192b43 100644 --- a/pretab/compose/inspection.py +++ b/pretab/compose/inspection.py @@ -282,8 +282,11 @@ def build_feature_info(column_transformer, *, embeddings, embedding_dimensions): elif "continuous_ordinal" in steps: step = representation_pipeline.named_steps["continuous_ordinal"] - categories = len(step.mapping_[columns.index(feature_name)]) - dimension = separate_state_dimension if separate_state_dimension is not None else 1 + position = columns.index(feature_name) + # An encoder that received no column (a 0-width block) has no mapping. + has_mapping = position < len(step.mapping_) + categories = len(step.mapping_[position]) if has_mapping else None + dimension = separate_state_dimension if separate_state_dimension is not None else int(has_mapping) categorical_feature_info[feature_name] = { "preprocessing": preprocessing_type, "dimension": dimension, diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index 516d0b2..9365720 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -5,7 +5,7 @@ import os import time import warnings -from typing import cast +from typing import Any, cast import numpy as np from scipy import sparse as sp @@ -47,8 +47,10 @@ from .core.policy import RepresentationPolicy, apply_constant_policy from .exceptions import ( ConfigWarning, + DataWarning, FrozenRepresentationError, OutputBudgetError, + PretabConfigError, PretabDataError, PretabSerializationError, invalid_param_error, @@ -379,6 +381,11 @@ class Preprocessor(TransformerMixin, BaseEstimator): ``"dummy"`` -> ``"one-hot"``, ``"ordinal"`` / ``"label"`` -> ``"int"``, ``"poly"`` -> ``"polynomial"``, and ``"passthrough"`` -> ``"none"``. + A column with no observed value at ``fit`` keeps its block: the imputers fill it with ``0`` + (or ``fill_value`` for ``strategy="constant"``), so it is fitted as a constant column, and + ``fit`` emits a :class:`~pretab.exceptions.DataWarning` naming it. A method that cannot be + fitted on such a column raises a :class:`~pretab.exceptions.PretabDataError` naming it. + ``transform`` returns a single stacked array by default (``output_structure="matrix"``), or a dict of per-feature blocks keyed ``num_`` / ``cat_`` when ``output_structure="blocks"``. Passing ``return_array`` explicitly to ``transform`` / @@ -586,8 +593,24 @@ def fit(self, X, y=None, embeddings=None): self.policy_ = RepresentationPolicy.resolve(self.policy) self.numerical_features_ = list(numerical_features) self.categorical_features_ = list(categorical_features) + # A feature without any observed value (e.g. an optional field that is empty + # in this training window or CV fold) still gets its block, fitted on a + # column that is missing throughout (a single constant once imputed). + empty_features = list(X.columns[X.isna().to_numpy().all(axis=0)]) if len(X) else [] + if empty_features: + warnings.warn( + f"{type(self).__name__} received column(s) {empty_features} with no observed (non-missing) " + "value at fit. Their blocks are kept but fitted on an entirely missing column (a single " + "constant value once imputed), so they carry no information until the preprocessor is " + "refit on data where those columns are observed.", + DataWarning, + stacklevel=2, + ) if numerical_features and self.policy_.constant != "allow": - numeric_values = X[numerical_features].to_numpy(dtype=np.float64, na_value=np.nan) + numeric_values = X[numerical_features].to_numpy(dtype=np.float64, na_value=np.nan, copy=True) + # An empty column is represented by one constant value, so the policy + # treats it like any other constant column. + numeric_values[:, np.isnan(numeric_values).all(axis=0)] = 0.0 apply_constant_policy(numeric_values, self.policy_, estimator=self) valid_formats = ("auto", "dense", "sparse") @@ -628,7 +651,13 @@ def fit(self, X, y=None, embeddings=None): self.column_transformer_.set_output(transform="default") # ColumnTransformer.fit runs fit_transform internally; keep the training # output to resolve output_format="auto" once, from the training density. - training_output = self.column_transformer_.fit_transform(self._column_transformer_input(X), y) + X_ct = self._column_transformer_input(X) + try: + training_output = self.column_transformer_.fit_transform(X_ct, y) + except ValueError as exc: + if empty_features and not isinstance(exc, PretabConfigError): + self._raise_for_empty_features(X_ct, y, empty_features, config) + raise self.output_format_ = resolve_output_format(training_output, self.output_format) self.n_features_in_ = X.shape[1] # scikit-learn convention: feature names are recorded only for named input. @@ -1068,6 +1097,50 @@ def _reject_missing(self, X) -> None: "('propagate', 'impute', 'impute_with_indicator', 'separate_state')." ) + def _raise_for_empty_features(self, X, y, empty_features, config) -> None: + """Name the empty feature behind a failed ``fit``, if one is to blame. + + Called when fitting the ColumnTransformer failed. The block of each feature + without an observed value is refitted on its own, and the first that fails + raises a :class:`~pretab.exceptions.PretabDataError` naming that feature, + chained to the block's own error: methods that cannot be fitted on a + constant column (e.g. the B/M/I and cubic splines, Box-Cox) otherwise only + report "the feature at index 0". A block is only blamed when it fits a + stand-in column of distinct observed values with the same ``y``, so a + failure with another cause (e.g. a ``y`` of the wrong length) is not + attributed to the empty column. Returns without raising when no empty + column is to blame, so the caller re-raises the original error. + """ + empty = {str(feature): feature for feature in empty_features} + for _name, transformer, columns in self.column_transformer_.transformers: + feature = empty.get(columns[0]) + if feature is None: + continue + is_numerical = feature in self.numerical_features_ + try: + cast(Any, clone(transformer)).fit(X[columns], y) + except PretabConfigError: + continue # a configuration problem, not one of the empty column + except ValueError as exc: + stand_in = X[columns].copy() + stand_in[columns[0]] = ( + np.linspace(1.0, 2.0, len(X)) + if is_numerical + else np.resize(np.array(["a", "b"], dtype=object), len(X)) + ) + try: + cast(Any, clone(transformer)).fit(stand_in, y) + except ValueError: + continue # the block fails with observed values too: not the emptiness + method = config.method_for(feature, is_numerical=is_numerical) + raise PretabDataError( + f"Column {feature!r} has no observed (non-missing) value at fit, and its {method!r} " + "representation cannot be fitted on such a column (entirely missing, or a single " + f"constant once imputed): {exc}\n" + "Fix: drop the column, fit on data where it is observed, or select a method that " + "accepts a constant column for it via feature_preprocessing." + ) from exc + def _enforce_output_budget(self, n_rows: int) -> None: """Check the fitted output width against the configured output budget. diff --git a/tests/compose/test_factory.py b/tests/compose/test_factory.py index c7b7488..466aeea 100644 --- a/tests/compose/test_factory.py +++ b/tests/compose/test_factory.py @@ -3,6 +3,7 @@ import numpy as np import pytest from sklearn.compose import ColumnTransformer +from sklearn.pipeline import Pipeline from pretab.compose.factory import ( _placement_kwargs, @@ -29,6 +30,15 @@ def test_imputer_omitted_when_disabled(): assert "imputer" not in _names(get_numerical_transformer_steps("standardization", add_imputer=False)) +def test_imputer_keeps_a_column_without_observed_values(): + empty = np.full((4, 1), np.nan) + assert Pipeline(get_numerical_transformer_steps("minmax")).fit_transform(empty).shape == (4, 1) + assert Pipeline(get_categorical_transformer_steps("one-hot")).fit_transform(empty.astype(object)).shape == (4, 1) + # imputer_kwargs can still restore scikit-learn's default of dropping it. + steps = dict(get_numerical_transformer_steps("minmax", imputer_kwargs={"keep_empty_features": False})) + assert steps["imputer"].keep_empty_features is False + + def test_none_method_uses_noop_step(): assert _names(get_numerical_transformer_steps("none", add_imputer=False)) == ["noop"] diff --git a/tests/compose/test_inspection.py b/tests/compose/test_inspection.py index 0d4f950..a76ee31 100644 --- a/tests/compose/test_inspection.py +++ b/tests/compose/test_inspection.py @@ -5,8 +5,10 @@ import numpy as np import pandas as pd import pytest +from sklearn.compose import ColumnTransformer +from sklearn.pipeline import Pipeline -from pretab.compose.factory import build_column_transformer +from pretab.compose.factory import build_column_transformer, get_categorical_transformer_steps from pretab.compose.inspection import ( build_feature_info, build_transformer_summary, @@ -75,6 +77,20 @@ def test_build_feature_info_probes_a_step_fitted_on_the_frame_without_warning( assert numerical["age"]["dimension"] == dimension +def test_build_feature_info_reports_a_zero_width_block(): + # scikit-learn's imputer default drops a column without observed values, so + # the encoder after it receives no column at all. + steps = get_categorical_transformer_steps("int", imputer_kwargs={"keep_empty_features": False}) + ct = ColumnTransformer([("cat_city", Pipeline(steps), ["city"])]) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", "Skipping features without any observed values", UserWarning) + ct.fit(pd.DataFrame({"city": pd.Series([np.nan] * 4, dtype=object)})) + + _, categorical, _ = build_feature_info(ct, embeddings=False, embedding_dimensions={}) + + assert categorical["city"]["dimension"] == 0 + + def test_build_transformer_summary_has_header_and_rows(): numerical = {"age": {"preprocessing": "imputer -> standardization", "dimension": 1, "categories": None}} categorical = {"city": {"preprocessing": "imputer -> continuous_ordinal", "dimension": 1, "categories": 3}} diff --git a/tests/integration/test_edge_case_contract.py b/tests/integration/test_edge_case_contract.py index 1cedf8b..2c450be 100644 --- a/tests/integration/test_edge_case_contract.py +++ b/tests/integration/test_edge_case_contract.py @@ -15,7 +15,7 @@ import pandas as pd import pytest -from pretab import Preprocessor, RepresentationPolicy +from pretab import CrossFittedTransformer, Preprocessor, RepresentationPolicy from pretab.exceptions import DataWarning, InsufficientSamplesError, PretabDataError from pretab.transformers import ( BSplineTransformer, @@ -259,3 +259,173 @@ def test_preprocessor_stores_resolved_policy(rng): pre.fit(df, y) assert isinstance(pre.policy_, RepresentationPolicy) assert pre.policy_.constant == "warn" + + +# --------------------------------------------------------------------------- # +# Column with no observed value at fit, through the Preprocessor +# --------------------------------------------------------------------------- # +def _frame_with_empty_column(rng, kind, n=60): + """A frame whose ``empty`` column (numerical or categorical) is missing on every row.""" + empty = np.full(n, np.nan) if kind == "numerical" else pd.Series([np.nan] * n, dtype=object) + return pd.DataFrame({"age": rng.normal(40, 10, n), "city": rng.choice(["a", "b", "c"], n), "empty": empty}) + + +def _observed(X, kind, rng): + """``X`` with its ``empty`` column observed on every row (as later data may be).""" + values = rng.normal(size=len(X)) if kind == "numerical" else rng.choice(["p", "q"], len(X)) + return X.assign(empty=values) + + +def _fit_empty(pre, X, y): + with pytest.warns(DataWarning, match=r"\['empty'\] with no observed"): + return pre.fit(X, y) + + +def _assert_metadata_matches_output(pre, X, X_later): + """Every detected feature has a block, and every metadata view agrees with the output.""" + out = pre.transform(X, return_array=True) + width = out.shape[1] + assert pre.n_features_in_ == 3 + assert set(pre.output_dims_) == {"age", "city", "empty"} + assert pre.output_dims_["empty"] >= 1 + assert sum(pre.output_dims_.values()) == width == pre.total_output_dim_ + assert len(pre.get_feature_names_out()) == width + lineage = pre.get_feature_lineage() + assert len(lineage) == width + assert sum(record.source_features == ("empty",) for record in lineage) == pre.output_dims_["empty"] + blocks = pre.transform(X, return_array=False) + assert {name.split("_", 1)[1] for name in blocks} == {"age", "city", "empty"} + numerical, categorical, _ = pre.get_feature_info(verbose=False) + assert set(numerical) | set(categorical) == {"age", "city", "empty"} + info = {**numerical, **categorical}["empty"] + if info["dimension"] is not None: + assert info["dimension"] == pre.output_dims_["empty"] + # Data where the column is observed later is transformed with the fitted width. + assert pre.transform(X_later, return_array=True).shape == (len(X_later), width) + + +# Numerical methods that fit a constant column, with (target_aware, placement_strategy). +_EMPTY_NUMERICAL_CASES = [ + (method, target_aware, "cart" if target_aware else "uniform") + for method in [ + "ple", + "minmax", + "standardization", + "robust", + "quantile", + "yeo-johnson", + "rbf", + "relu", + "sigmoid", + "tanh", + "binning", + "fourier", + "polynomial", + "none", + ] + for target_aware in (True, False) + if not (method == "ple" and not target_aware) +] + + +@pytest.mark.parametrize(("method", "target_aware", "placement"), _EMPTY_NUMERICAL_CASES) +def test_empty_numerical_column_keeps_its_block(method, target_aware, placement, rng): + X = _frame_with_empty_column(rng, "numerical") + y = rng.normal(size=len(X)) + pre = Preprocessor(numerical_method=method, target_aware=target_aware, placement_strategy=placement) + _fit_empty(pre, X, y) + _assert_metadata_matches_output(pre, X, _observed(X, "numerical", rng)) + + +# The methods that cannot be fitted on a constant column (see CONSTANT_RAISES). +@pytest.mark.parametrize( + "method", ["bspline", "mspline", "ispline", "pspline", "naturalspline", "cubicspline", "box-cox"] +) +def test_empty_numerical_column_names_the_column_when_the_method_needs_spread(method, rng): + X = _frame_with_empty_column(rng, "numerical") + y = rng.normal(size=len(X)) + pre = Preprocessor(numerical_method=method, target_aware=False, placement_strategy="uniform") + with pytest.warns(DataWarning), pytest.raises(PretabDataError, match=f"Column 'empty' .* {method!r}") as info: + pre.fit(X, y) + assert info.value.__cause__ is not None # chained to the method's own error + + +@pytest.mark.parametrize( + ("method", "y_kind", "message"), + [ + ("ple", "short", "same length"), + ("bspline", "strings", "could not convert string to float"), + ("rbf", "strings", "could not convert string to float"), + ], +) +def test_empty_column_is_not_blamed_for_an_unrelated_fit_failure(method, y_kind, message, rng): + """A failure the block also hits with observed values (here a bad ``y``) keeps + its own error instead of being attributed to the empty column.""" + X = _frame_with_empty_column(rng, "numerical") + y = rng.normal(size=len(X) - 3) if y_kind == "short" else rng.choice(["p", "q"], len(X)) + params = {"target_aware": True, "placement_strategy": "cart"} if method != "ple" else {} + with pytest.warns(DataWarning), pytest.raises(ValueError, match=message) as info: + Preprocessor(numerical_method=method, **params).fit(X, y) + assert "has no observed" not in str(info.value) + + +@pytest.mark.parametrize( + "params", + [ + {"missing_policy": "impute"}, + {"missing_policy": "impute_with_indicator"}, + {"missing_policy": "separate_state"}, + {"missing_policy": "propagate"}, + {"add_missing_indicator": True}, + {"numerical_imputation": None}, + {"numerical_imputation": None, "add_missing_indicator": True}, + {"numerical_imputation": "constant"}, + ], +) +def test_empty_numerical_column_under_missing_value_settings(params, rng): + X = _frame_with_empty_column(rng, "numerical") + y = rng.normal(size=len(X)) + pre = Preprocessor(numerical_method="minmax", **params) + with warnings.catch_warnings(): + # The all-NaN column reaches MinMaxScaler when imputation is disabled. + warnings.filterwarnings("ignore", "All-NaN slice", RuntimeWarning) + _fit_empty(pre, X, y) + _assert_metadata_matches_output(pre, X, _observed(X, "numerical", rng)) + + +@pytest.mark.parametrize("method", ["int", "one-hot", "none"]) +@pytest.mark.parametrize( + "params", + [ + {}, + {"missing_policy": "impute_with_indicator"}, + {"missing_policy": "separate_state"}, + {"missing_policy": "propagate"}, + {"add_missing_indicator": True}, + {"categorical_imputation": None}, + {"categorical_imputation": "constant"}, + ], +) +def test_empty_categorical_column_keeps_its_block(method, params, rng): + X = _frame_with_empty_column(rng, "categorical") + y = rng.normal(size=len(X)) + pre = Preprocessor(numerical_method="minmax", categorical_method=method, **params) + _fit_empty(pre, X, y) + _assert_metadata_matches_output(pre, X, _observed(X, "categorical", rng)) + + +def test_empty_numerical_column_counts_as_constant_for_the_policy(rng): + X = _frame_with_empty_column(rng, "numerical") + y = rng.normal(size=len(X)) + with pytest.warns(DataWarning), pytest.raises(PretabDataError, match="constant"): + Preprocessor(numerical_method="minmax", policy={"constant": "error"}).fit(X, y) + + +def test_column_empty_in_a_cross_fitting_fold_keeps_the_width(rng): + n = 60 + X = _frame_with_empty_column(rng, "numerical", n=n) + X.loc[n - 6 :, "empty"] = rng.normal(size=6) # observed only in the last fold + y = rng.normal(size=n) + cross_fitted = CrossFittedTransformer(Preprocessor(numerical_method="rbf"), n_folds=10, shuffle=False) + out_of_fold = np.asarray(cross_fitted.fit_transform(X, y)) + assert out_of_fold.shape == cross_fitted.transform(X).shape diff --git a/tests/integration/test_verbosity.py b/tests/integration/test_verbosity.py index 3797978..7c570e5 100644 --- a/tests/integration/test_verbosity.py +++ b/tests/integration/test_verbosity.py @@ -16,7 +16,7 @@ import pytest from pretab import Preprocessor, PretabWarning, configure_logging, set_verbosity -from pretab.exceptions import ConfigWarning +from pretab.exceptions import ConfigWarning, DataWarning @pytest.fixture @@ -111,6 +111,16 @@ def test_verbose_2_logs_feature_table(sample_data, caplog): assert "pipeline" in debug_text +def test_verbose_2_lists_a_column_without_observed_values(sample_data, caplog): + X, y = sample_data + X = X.assign(empty=pd.Series([np.nan] * len(X), dtype=object)) + caplog.set_level(logging.DEBUG, logger="pretab") + with pytest.warns(DataWarning, match="no observed"): + Preprocessor(numerical_method="ple", verbose=2).fit(X, y) + rows = [r.getMessage() for r in caplog.records if r.levelno == logging.DEBUG] + assert any(row.startswith("empty ") for row in rows) + + def test_verbose_3_logs_internal_decisions(sample_data, caplog): X, y = sample_data caplog.set_level(logging.DEBUG, logger="pretab") From f425095474979c44fa5b3d828e4d3a7cd09509cd Mon Sep 17 00:00:00 2001 From: ChrisW09 <50968720+ChrisW09@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:01:19 +0200 Subject: [PATCH 10/16] fix(compose): pass target_aware to optionally supervised representations The Preprocessor injected target_aware only into methods that declare placement strategies, and register_representation defaults placement_strategies to (). A representation registered with supervision="optional" therefore kept its own target_aware default while y was still forwarded to it: with target_aware=False it could fit on y (target leakage despite the "y-free fit" contract), and with target_aware=True a class defaulting to False ignored it and lineage reported uses_target=False. target_aware is now passed to every optional method, and placement_strategy only to methods that declare strategies, so built-ins are unchanged. As the docs say an optional representation uses y only when target_aware=True, register_representation now rejects an optional class without a target_aware constructor parameter with a TypeError instead of letting it see y unchecked. Fixes #96 --- docs/tutorials/custom_representation.md | 4 +- pretab/compose/factory.py | 19 ++++--- pretab/extension.py | 28 ++++++++- tests/compose/test_factory.py | 10 +++- .../extension/test_registration_discovery.py | 57 +++++++++++++++++++ 5 files changed, 104 insertions(+), 14 deletions(-) diff --git a/docs/tutorials/custom_representation.md b/docs/tutorials/custom_representation.md index 978f8c4..e791aac 100644 --- a/docs/tutorials/custom_representation.md +++ b/docs/tutorials/custom_representation.md @@ -71,7 +71,9 @@ The four class attributes are the declarative contract. `supervision` : `"unsupervised"`, `"optional"` (uses `y` only when `target_aware=True`), or `"supervised"` -(always needs `y`). +(always needs `y`). An `"optional"` class must take a `target_aware` constructor parameter: +`Preprocessor` sets it from its own `target_aware`, and registration rejects a class without +one. ```{tip} Implement `_output_sizes` to return the number of output columns each input contributes. The diff --git a/pretab/compose/factory.py b/pretab/compose/factory.py index 2ffe070..6054514 100644 --- a/pretab/compose/factory.py +++ b/pretab/compose/factory.py @@ -102,23 +102,24 @@ def _placement_kwargs(spec: TransformerSpec, kwargs): """Return the placement kwargs to inject for a method, honouring its capability. Mirrors the shared-placement contract: methods with optional target awareness - (feature maps and freely-placed knot splines) receive ``target_aware`` plus - the ``placement_strategy`` (when set); the always-target-aware ``ple`` receives - a supervised ``placement_strategy`` only when target-aware; the unsupervised-only - penalized splines receive an unsupervised ``placement_strategy`` only when not - target-aware. Methods without data-driven placement receive nothing. + (feature maps, freely-placed knot splines, and registered ``"optional"`` + representations) always receive ``target_aware``, which decides whether they + use ``y`` at all, plus the ``placement_strategy`` (when set) if they declare + placement strategies; the always-target-aware ``ple`` receives a supervised + ``placement_strategy`` only when target-aware; the unsupervised-only penalized + splines receive an unsupervised ``placement_strategy`` only when not + target-aware. Other methods without data-driven placement receive nothing. """ - if not spec.placement_strategies: - return {} - target_aware = bool(kwargs.get("target_aware", False)) placement_strategy = kwargs.get("placement_strategy") if spec.target_usage == "optional": out = {"target_aware": target_aware} - if placement_strategy is not None: + if placement_strategy is not None and spec.placement_strategies: out["placement_strategy"] = placement_strategy return out + if not spec.placement_strategies: + return {} if spec.target_usage == "required": if target_aware and placement_strategy in ("cart", "lightgbm"): return {"placement_strategy": placement_strategy} diff --git a/pretab/extension.py b/pretab/extension.py index e8107ed..64af86e 100644 --- a/pretab/extension.py +++ b/pretab/extension.py @@ -19,6 +19,7 @@ from __future__ import annotations +import inspect import warnings import numpy as np @@ -80,7 +81,9 @@ class BaseRepresentation(BasePreTabTransformer): supervision : {"unsupervised", "optional", "supervised"} How the representation uses the target ``y``. ``"supervised"`` mandates ``y`` at fit time; ``"optional"`` consumes it only when ``target_aware`` - is enabled. + is enabled, so an optional class must take a ``target_aware`` constructor + parameter (:class:`~pretab.Preprocessor` sets it from its own + ``target_aware``). """ representation_name: str | None = None @@ -110,6 +113,14 @@ def __init_subclass__(cls, **kwargs): cls._requires_y = cls.supervision == "supervised" +def _accepts_target_aware(cls) -> bool: + """Whether ``cls`` takes a ``target_aware`` constructor parameter.""" + try: + return "target_aware" in inspect.signature(cls.__init__).parameters + except (TypeError, ValueError): # no introspectable signature + return False + + def register_representation( name, cls, @@ -145,13 +156,17 @@ def register_representation( scope : {"univariate", "multivariate"}, optional Inferred from ``cls`` when omitted. supervision : {"unsupervised", "optional", "supervised"}, optional - Inferred from ``cls`` when omitted. + Inferred from ``cls`` when omitted. An ``"optional"`` class must take a + ``target_aware`` constructor parameter: ``Preprocessor`` always passes its + own ``target_aware``, so the class uses ``y`` only when that is True. A + class without one raises ``TypeError``. allowed_args : iterable of str, default=() Constructor argument names the shared Preprocessor keyword arguments are filtered down to for this method. placement_strategies : iterable of str, default=() Placement strategies the method honours (empty for methods without - data-driven placement). + data-driven placement). ``Preprocessor`` passes its ``placement_strategy`` + only to a method that declares strategies. supports_adaptive_resolution : bool, default=False Whether the method can size its output dimension from the data. preprocessor_compatible : bool, default=True @@ -186,6 +201,13 @@ def register_representation( raise ValueError(f"scope must be one of {sorted(_VALID_SCOPES)}, got {scope!r}") if supervision not in _VALID_SUPERVISION: raise ValueError(f"supervision must be one of {sorted(_VALID_SUPERVISION)}, got {supervision!r}") + if supervision == "optional" and not _accepts_target_aware(cls): + raise TypeError( + f"{cls.__name__} is registered with supervision='optional' but its constructor has no " + "target_aware parameter. An optionally supervised representation uses y only when " + "target_aware=True, and Preprocessor sets it from its own target_aware: add the " + "parameter, or register the class as 'unsupervised' or 'supervised'." + ) spec = TransformerSpec( name=name, diff --git a/tests/compose/test_factory.py b/tests/compose/test_factory.py index 466aeea..2daa715 100644 --- a/tests/compose/test_factory.py +++ b/tests/compose/test_factory.py @@ -11,7 +11,7 @@ get_categorical_transformer_steps, get_numerical_transformer_steps, ) -from pretab.compose.registry import get_spec +from pretab.compose.registry import TransformerSpec, get_spec from pretab.exceptions import ConfigWarning, InvalidParamError @@ -130,6 +130,14 @@ def test_placement_absent_when_method_has_no_strategies(): assert _placement_kwargs(spec, {"target_aware": True, "placement_strategy": "cart"}) == {} +def test_placement_optional_without_strategies_still_forwards_target_aware(): + # e.g. a registered supervision="optional" representation with no placement + # strategies: target_aware decides whether it uses y, placement does not apply. + spec = TransformerSpec(name="custom", transformer_cls=object, target_usage="optional") + assert _placement_kwargs(spec, {"target_aware": False, "placement_strategy": "uniform"}) == {"target_aware": False} + assert _placement_kwargs(spec, {"target_aware": True, "placement_strategy": "cart"}) == {"target_aware": True} + + # --------------------------------------------------------------------------- # # ColumnTransformer assembly # --------------------------------------------------------------------------- # diff --git a/tests/extension/test_registration_discovery.py b/tests/extension/test_registration_discovery.py index fad8f5e..f260e3d 100644 --- a/tests/extension/test_registration_discovery.py +++ b/tests/extension/test_registration_discovery.py @@ -59,6 +59,63 @@ def _output_sizes(self): return [1] * self.n_features_in_ +class _SplitAtTarget(BaseRepresentation): + """Optionally supervised: split at the median, or (target-aware) where ``y`` peaks.""" + + representation_name = "split_reg" + feature_kind = "numerical" + supervision = "optional" + + def __init__(self, target_aware=True): + self.target_aware = target_aware + + def fit(self, X, y=None): + X = self._validate(X, reset=True) + self.used_y_ = bool(self.target_aware and y is not None) + self.threshold_ = X[np.argmax(y), 0] if self.used_y_ else np.median(X[:, 0]) + return self + + def transform(self, X): + check_is_fitted(self, "threshold_") + return (self._validate(X, reset=False) > self.threshold_).astype(float) + + def _output_sizes(self): + return [1] * self.n_features_in_ + + +class _SplitAtTargetOffByDefault(_SplitAtTarget): + def __init__(self, target_aware=False): + super().__init__(target_aware=target_aware) + + +@pytest.mark.parametrize("cls", [_SplitAtTarget, _SplitAtTargetOffByDefault]) +def test_register_optional_representation_follows_preprocessor_target_aware(cls): + # Whatever the class default, the Preprocessor's target_aware decides whether + # the representation uses y (no placement strategies are declared). + register_representation("split_reg", cls) + X = pd.DataFrame({"x": np.linspace(0.0, 1.0, 50)}) + y = np.sin(X["x"] * 6) + for target_aware, placement in [(False, "uniform"), (True, "cart")]: + pre = Preprocessor(numerical_method="split_reg", target_aware=target_aware, placement_strategy=placement) + pre.fit(X, y) + + step = pre.column_transformer_.named_transformers_["num_x"].named_steps["split_reg"] + assert step.target_aware is target_aware + assert step.used_y_ is target_aware + assert [record.uses_target for record in pre.get_feature_lineage()] == [target_aware] + + +def test_register_optional_representation_requires_target_aware_parameter(): + class _NoTargetAware(_Square): + supervision = "optional" + + with pytest.raises(TypeError, match="target_aware"): + register_representation("optional_reg", _NoTargetAware) + with pytest.raises(TypeError, match="target_aware"): + register_representation("optional_reg", _Square, supervision="optional") + assert "optional_reg" not in registry.TRANSFORMER_REGISTRY + + def test_register_makes_method_selectable_and_discoverable(): register_representation("square_reg", _Square) assert "square_reg" in registry.NUMERICAL_METHODS From 8253313b2545d86ae087fabe6fe56d82c1bb1d0d Mon Sep 17 00:00:00 2001 From: ChrisW09 <50968720+ChrisW09@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:44:48 +0200 Subject: [PATCH 11/16] chore: typecheck correction for the sparse fold stacking and the optional-representation test --- pretab/core/supervised.py | 2 +- tests/extension/test_registration_discovery.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pretab/core/supervised.py b/pretab/core/supervised.py index 0e247e8..87cd99f 100644 --- a/pretab/core/supervised.py +++ b/pretab/core/supervised.py @@ -128,7 +128,7 @@ def _stack_folds(blocks, test_indices, X) -> Any: order[rows] = np.arange(len(rows)) first = blocks[0] if all(sp.issparse(block) for block in blocks): - return sp.vstack(blocks, format="csr")[order].asformat(first.format) + return sp.vstack(blocks).tocsr()[order].asformat(first.format) if all(isinstance(block, pd.DataFrame) for block in blocks): stacked = pd.concat(blocks).iloc[order] index = getattr(X, "index", None) diff --git a/tests/extension/test_registration_discovery.py b/tests/extension/test_registration_discovery.py index f260e3d..e8279df 100644 --- a/tests/extension/test_registration_discovery.py +++ b/tests/extension/test_registration_discovery.py @@ -72,7 +72,7 @@ def __init__(self, target_aware=True): def fit(self, X, y=None): X = self._validate(X, reset=True) self.used_y_ = bool(self.target_aware and y is not None) - self.threshold_ = X[np.argmax(y), 0] if self.used_y_ else np.median(X[:, 0]) + self.threshold_ = X[np.argmax(np.asarray(y)), 0] if self.used_y_ else np.median(X[:, 0]) return self def transform(self, X): From 32a55731178974cd84776d7bcef38de6ca97f63c Mon Sep 17 00:00:00 2001 From: ChrisW09 <50968720+ChrisW09@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:27:37 +0200 Subject: [PATCH 12/16] fix(search): convert a polars frame once before taking fold rows RepresentationSearchCV and the clone-and-refit path of CrossFittedTransformer took each fold's rows from the polars frame and let every subset be converted to pandas on its own. A polars integer column with a null converts to float as a whole frame but to int in a subset without the null, so the fold that missed the null detected the column as categorical while the other folds and the refit treated it as numerical: the search scored a different model than it refit, and cross-fitting emitted raw codes for one fold. Both now convert a polars frame to pandas once, before splitting, exactly as a pandas frame is used, and the conversion moves to core so the cross-fitting wrapper can share it. The docs now name the entry points that accept polars input and that temporal columns are not supported. Fixes #94 --- docs/core_concepts/configuration.md | 7 ++-- pretab/compose/feature_detection.py | 24 ++----------- pretab/compose/search.py | 12 ++++--- pretab/core/supervised.py | 16 +++++---- pretab/core/validation.py | 27 +++++++++++++- tests/integration/test_polars_input.py | 50 ++++++++++++++++++++++++++ 6 files changed, 99 insertions(+), 37 deletions(-) diff --git a/docs/core_concepts/configuration.md b/docs/core_concepts/configuration.md index a2271c5..d2f8aad 100644 --- a/docs/core_concepts/configuration.md +++ b/docs/core_concepts/configuration.md @@ -53,9 +53,10 @@ overrides, rather than guessing the column order. ## Polars DataFrame input -A `polars.DataFrame` is accepted wherever a pandas `DataFrame` is, so a scikit-learn -`Pipeline` configured with `set_output(transform="polars")` can hand its polars output straight -to the `Preprocessor`. PreTab reads the frame through pandas with the same column names, order +A `polars.DataFrame` is accepted as input to the `Preprocessor`, `RepresentationSearchCV` and +`CrossFittedTransformer` wherever a pandas `DataFrame` is (temporal columns are not supported), +so a scikit-learn `Pipeline` configured with `set_output(transform="polars")` can hand its +polars output straight to the `Preprocessor`. PreTab reads the frame through pandas with the same column names, order and dtypes, then detects column types as usual: `Float*` columns are numerical, `Int*` / `UInt*` columns follow the integer cardinality rule (an integer column with nulls becomes float, as in pandas), and `String`, `Categorical`, `Enum` and `Boolean` columns are diff --git a/pretab/compose/feature_detection.py b/pretab/compose/feature_detection.py index 11f59aa..9266126 100644 --- a/pretab/compose/feature_detection.py +++ b/pretab/compose/feature_detection.py @@ -10,7 +10,7 @@ import numpy as np import pandas as pd -from ..core.validation import is_polars_frame +from ..core.validation import is_polars_frame, polars_to_pandas from ..exceptions import PretabDataError, invalid_param_error __all__ = [ @@ -22,26 +22,6 @@ ] -def _polars_to_pandas(X) -> pd.DataFrame: - """Convert a polars DataFrame to pandas, keeping column names, order and dtypes. - - ``polars.DataFrame.to_pandas`` requires pyarrow, which neither polars nor - PreTab depends on, so each column goes through ``Series.to_numpy`` instead: - integer, unsigned, float, boolean and temporal columns keep their NumPy - dtype (an integer column with nulls becomes float with NaN, as in pandas), - while string, categorical and enum columns -- and boolean columns with nulls - -- become ``object`` columns. Their nulls arrive as ``None`` and are mapped - to ``NaN``, the missing marker the imputers and missing indicators recognize. - """ - columns = {} - for column in X.get_columns(): - values = column.to_numpy() - if values.dtype == object: - values = np.where(pd.isna(values), np.nan, values) - columns[column.name] = values - return pd.DataFrame(columns) - - def has_column_labels(X) -> bool: """Return True if ``X`` carries its own column labels. @@ -76,7 +56,7 @@ def to_dataframe(X, *, copy: bool = False) -> pd.DataFrame: if isinstance(X, dict): X = pd.DataFrame(X) elif is_polars_frame(X): - X = _polars_to_pandas(X) + X = polars_to_pandas(X) elif not has_column_labels(X): X = np.asarray(X) if X.ndim != 2: diff --git a/pretab/compose/search.py b/pretab/compose/search.py index 7a15bdf..7273e68 100644 --- a/pretab/compose/search.py +++ b/pretab/compose/search.py @@ -21,7 +21,7 @@ from sklearn.utils.validation import _check_method_params, check_is_fitted from ..core._typing import PredictorLike -from ..core.validation import is_polars_frame +from ..core.validation import is_polars_frame, polars_to_pandas from ..exceptions import InvalidParamError from ..preprocessor import Preprocessor @@ -31,13 +31,11 @@ def _row_subset(data, idx): """Return the rows of ``data`` at positions ``idx`` for arrays or frames. - A pandas or polars frame stays a frame, so each fold's Preprocessor detects - the same column types as the final refit on all of ``X``. + A pandas frame stays a frame, so each fold's Preprocessor detects the same + column types as the final refit on all of ``X``. """ if hasattr(data, "iloc"): return data.iloc[idx] - if is_polars_frame(data): - return data[idx] return np.asarray(data)[idx] @@ -160,6 +158,10 @@ def fit(self, X, y=None, *, groups=None, **fit_params): if y is None: raise InvalidParamError("RepresentationSearchCV requires y at fit time; got y=None.") y_arr = np.asarray(y).ravel() + if is_polars_frame(X): + # Converted once, so every fold's rows carry the dtypes of the whole frame, + # as a pandas frame's do (see polars_to_pandas). + X = polars_to_pandas(X) n_samples = X.shape[0] if hasattr(X, "shape") else len(X) # As in scikit-learn's searches: per-sample fit params are made indexable # so each fold can take its training rows. diff --git a/pretab/core/supervised.py b/pretab/core/supervised.py index 87cd99f..57efbb6 100644 --- a/pretab/core/supervised.py +++ b/pretab/core/supervised.py @@ -34,7 +34,7 @@ from ._typing import TransformerLike from .parameters import validate_task from .representation import RepresentationSpecMixin -from .validation import is_polars_frame +from .validation import is_polars_frame, polars_to_pandas __all__ = ["CrossFittedTransformer", "in_controlled_context", "warn_target_leakage"] @@ -94,19 +94,23 @@ def warn_target_leakage(estimator, y) -> None: def _as_2d(X): """Return ``X`` as 2D input for the wrapped transformer. - A pandas or polars DataFrame is passed through unchanged, so column names and per-column + A pandas DataFrame is passed through unchanged, so column names and per-column dtypes reach the wrapped estimator (e.g. a Preprocessor detecting numerical - vs categorical columns, or a name-based ColumnTransformer); any other input - is converted to a NumPy array, with 1D input reshaped to a single column. + vs categorical columns, or a name-based ColumnTransformer). A polars + DataFrame is converted to pandas once, so every fold's rows carry the dtypes + of the whole frame. Any other input is converted to a NumPy array, with 1D + input reshaped to a single column. """ - if (hasattr(X, "iloc") and getattr(X, "ndim", None) == 2) or is_polars_frame(X): + if is_polars_frame(X): + return polars_to_pandas(X) + if hasattr(X, "iloc") and getattr(X, "ndim", None) == 2: return X X = np.asarray(X) return X.reshape(-1, 1) if X.ndim == 1 else X def _take_rows(X, indices): - """Select rows of a DataFrame (pandas or polars) or array by position.""" + """Select rows of a DataFrame or array by position.""" return X.iloc[indices] if hasattr(X, "iloc") else X[indices] diff --git a/pretab/core/validation.py b/pretab/core/validation.py index eb3401d..0696e3a 100644 --- a/pretab/core/validation.py +++ b/pretab/core/validation.py @@ -11,11 +11,12 @@ from typing import Literal, cast import numpy as np +import pandas as pd from sklearn.utils.validation import _check_feature_names, _check_feature_names_in, check_array from ..exceptions import DataWarning, PretabDataError, invalid_param_error -__all__ = ["is_polars_frame", "resolve_input_features", "validate_2d_allow_nan"] +__all__ = ["is_polars_frame", "polars_to_pandas", "resolve_input_features", "validate_2d_allow_nan"] def is_polars_frame(X) -> bool: @@ -28,6 +29,30 @@ def is_polars_frame(X) -> bool: return polars is not None and isinstance(X, polars.DataFrame) +def polars_to_pandas(X) -> pd.DataFrame: + """Convert a polars DataFrame to pandas, keeping column names, order and dtypes. + + ``polars.DataFrame.to_pandas`` requires pyarrow, which neither polars nor + PreTab depends on, so each column goes through ``Series.to_numpy`` instead: + integer, unsigned, float, boolean and temporal columns keep their NumPy + dtype (an integer column with nulls becomes float with NaN, as in pandas), + while string, categorical and enum columns -- and boolean columns with nulls + -- become ``object`` columns. Their nulls arrive as ``None`` and are mapped + to ``NaN``, the missing marker the imputers and missing indicators recognize. + + Convert a frame once and take row subsets of the result: converted on its + own, a subset can get another dtype (an integer column with a null is float + as a whole, but int in a subset without the null). + """ + columns = {} + for column in X.get_columns(): + values = column.to_numpy() + if values.dtype == object: + values = np.where(pd.isna(values), np.nan, values) + columns[column.name] = values + return pd.DataFrame(columns) + + def resolve_input_features(estimator, input_features) -> list: """Return the input feature names that a transformer's output names are built from. diff --git a/tests/integration/test_polars_input.py b/tests/integration/test_polars_input.py index 482aa08..0c11071 100644 --- a/tests/integration/test_polars_input.py +++ b/tests/integration/test_polars_input.py @@ -144,3 +144,53 @@ def cross_fit(X): np.asarray(from_polars.transform(polars_frame), dtype=float), np.asarray(from_pandas.transform(pandas_frame), dtype=float), ) + + +@pytest.fixture +def frames_with_one_null_integer(): + """An integer column with a single null, as polars and as the pandas frame it converts to.""" + rng = np.random.default_rng(0) + kids = rng.integers(0, 5, 300).astype(float) + kids[7] = np.nan + x = rng.normal(size=300) + polars_frame = pl.DataFrame( + {"x": x, "kids": pl.Series([None if np.isnan(v) else int(v) for v in kids], dtype=pl.Int64)} + ) + # As a whole the column converts to float (with NaN), so it is numerical. + pandas_frame = pd.DataFrame({"x": x, "kids": kids}) + return pandas_frame, polars_frame, x + np.nan_to_num(kids) + rng.normal(scale=0.1, size=300) + + +def test_search_converts_a_polars_frame_once(frames_with_one_null_integer): + """Rows of a fold without the null would convert to an int column and be + detected as categorical, unlike the refit on the whole frame.""" + from sklearn.linear_model import Ridge + from sklearn.model_selection import KFold + + from pretab import RepresentationSearchCV + + pandas_frame, polars_frame, y = frames_with_one_null_integer + + def search(X): + params = {"cv": KFold(5, shuffle=True, random_state=0), "random_state": 0} + return RepresentationSearchCV(Ridge(), ["minmax"], **params).fit(X, y) + + assert search(polars_frame).cv_results_ == pytest.approx(search(pandas_frame).cv_results_) + + +def test_cross_fitting_converts_a_polars_frame_once(frames_with_one_null_integer): + """The clone-and-refit path takes every fold's rows from one conversion.""" + from sklearn.pipeline import make_pipeline + from sklearn.preprocessing import FunctionTransformer + + from pretab import CrossFittedTransformer + + pandas_frame, polars_frame, y = frames_with_one_null_integer + + def cross_fit(X): + pipeline = make_pipeline( + FunctionTransformer(feature_names_out="one-to-one"), Preprocessor(numerical_method="minmax") + ) + return np.asarray(CrossFittedTransformer(pipeline, n_folds=5, random_state=0).fit_transform(X, y), dtype=float) + + np.testing.assert_allclose(cross_fit(polars_frame), cross_fit(pandas_frame)) From 2fe0ebcf256db3e26c8d93e7b5094ab9a07c5d36 Mon Sep 17 00:00:00 2001 From: ChrisW09 <50968720+ChrisW09@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:28:27 +0200 Subject: [PATCH 13/16] fix(splines): expand a missing value to a NaN row in the two-feature thin-plate basis ThinPlateSplineTransformer evaluates its two-feature kernel as np.where(r > 0, r**2 * log(r), 0.0). A missing coordinate gives a NaN distance, which fails r > 0 and so took the zero branch: the row became an all-zero kernel row, the same silent fake data the B/M/I splines produced for a missing value. One and three or more features already propagated NaN. The zero branch is now selected by r == 0, so a NaN distance stays NaN and the row is NaN like in every other spline family. Finite rows are unchanged. Fixes #87 --- .../expansion/spline/multivariate/thin_plate.py | 4 +++- .../spline/test_thinplate_transformer.py | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/pretab/expansion/spline/multivariate/thin_plate.py b/pretab/expansion/spline/multivariate/thin_plate.py index a9aaa84..ed7db65 100644 --- a/pretab/expansion/spline/multivariate/thin_plate.py +++ b/pretab/expansion/spline/multivariate/thin_plate.py @@ -125,7 +125,9 @@ def _tps_kernel(r, d): if d == 1: return r**3 if d == 2: - return np.where(r > 0, r**2 * np.log(np.where(r > 0, r, 1.0)), 0.0) + # r == 0 (not r > 0) selects the zero branch, so a missing + # coordinate keeps its NaN distance instead of becoming 0. + return np.where(r == 0, 0.0, r**2 * np.log(np.where(r > 0, r, 1.0))) # d >= 3: biharmonic (linear) radial kernel. return r diff --git a/tests/expansion/spline/test_thinplate_transformer.py b/tests/expansion/spline/test_thinplate_transformer.py index 5d4c9d8..4b94b30 100644 --- a/tests/expansion/spline/test_thinplate_transformer.py +++ b/tests/expansion/spline/test_thinplate_transformer.py @@ -124,3 +124,20 @@ def test_tprs_transform_requires_fit(): transformer.transform(np.random.rand(5, 1)) with pytest.raises(NotFittedError): transformer.get_penalty_matrix() + + +@pytest.mark.parametrize("n_features", [1, 2, 3]) +def test_tprs_expands_a_missing_value_to_a_nan_row(n_features): + """The two-feature kernel used to map a NaN distance to 0, turning a missing + coordinate into an all-zero row; every input dimension now gives NaN, as the + other spline families do.""" + rng = np.random.default_rng(0) + X = rng.uniform(size=(80, n_features)) + transformer = ThinPlateSplineTransformer(n_components=5, random_state=0).fit(X) + probe = X[:3].copy() + probe[1, 0] = np.nan + + out = transformer.transform(probe) + + assert np.isnan(out[1]).all() + np.testing.assert_allclose(out[[0, 2]], transformer.transform(X[[0, 2]]), rtol=1e-12, atol=1e-12) From 53fd3a1fac8295a2163121cc621eca9dd5421951 Mon Sep 17 00:00:00 2001 From: ChrisW09 <50968720+ChrisW09@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:29:28 +0200 Subject: [PATCH 14/16] fix(placement): reject a multi-output target in target-aware placement with a clear error Target-aware placement fits one decision tree or boosting model on one target, but the shared location selector and PLE flattened y. A multi-output target of n rows became 2n values and failed with an unrelated IndexError or NumPy broadcasting error; this is what the Preprocessor's default (target-aware) settings and the regression presets hit with a 2-D y, after the unsupervised path was fixed to ignore y. A shared single_target() check now flattens a column vector as before and raises an IncompatibleParamsError for more than one output column, pointing to target_aware=False. Fixes #88 --- pretab/core/selectors.py | 3 ++- pretab/core/validation.py | 26 ++++++++++++++++++++++++-- pretab/encoding/numerical/ple.py | 4 ++-- tests/integration/test_preprocessor.py | 15 +++++++++++++++ 4 files changed, 43 insertions(+), 5 deletions(-) diff --git a/pretab/core/selectors.py b/pretab/core/selectors.py index 6903bf3..b7126e3 100644 --- a/pretab/core/selectors.py +++ b/pretab/core/selectors.py @@ -28,6 +28,7 @@ from ..exceptions import IncompatibleParamsError, OptionalDependencyError from .knots import quantile_knots, select_knots, uniform_knots from .parameters import validate_task +from .validation import single_target Task = Literal["regression", "classification"] @@ -97,7 +98,7 @@ def select( x = np.asarray(x) if x.ndim == 1: x = x.reshape(-1, 1) - y = np.asarray(y).ravel() + y = single_target(y, type(self).__name__) # Only float targets can carry NaN; for integer / object (e.g. string # class label) targets, leave the target validation to the fitted model. diff --git a/pretab/core/validation.py b/pretab/core/validation.py index 0696e3a..8e455e3 100644 --- a/pretab/core/validation.py +++ b/pretab/core/validation.py @@ -14,9 +14,9 @@ import pandas as pd from sklearn.utils.validation import _check_feature_names, _check_feature_names_in, check_array -from ..exceptions import DataWarning, PretabDataError, invalid_param_error +from ..exceptions import DataWarning, IncompatibleParamsError, PretabDataError, invalid_param_error -__all__ = ["is_polars_frame", "polars_to_pandas", "resolve_input_features", "validate_2d_allow_nan"] +__all__ = ["is_polars_frame", "polars_to_pandas", "resolve_input_features", "single_target", "validate_2d_allow_nan"] def is_polars_frame(X) -> bool: @@ -73,6 +73,28 @@ def resolve_input_features(estimator, input_features) -> list: return [str(name) for name in names] +def single_target(y, estimator: str) -> np.ndarray: + """Return the target that target-aware placement fits on, as a 1D array. + + Locations are placed by one decision tree or boosting model fitted on a single + target: a column vector is flattened, while a multi-output ``y`` raises instead + of being flattened into ``n_samples * n_outputs`` values. + + Raises + ------ + IncompatibleParamsError + If ``y`` has more than one output column. + """ + y = np.asarray(y) + if y.ndim > 1 and int(np.prod(y.shape[1:])) != 1: + raise IncompatibleParamsError( + f"{estimator} places locations against a single target, but y has shape {y.shape}.\n" + "Fix: fit on one target column, or use unsupervised placement (target_aware=False) " + "for a multi-output target." + ) + return y.ravel() + + def validate_2d_allow_nan(X, *, allow_nan: bool = True, reset: bool, estimator): """Coerce ``X`` to a 2D float array, optionally letting NaNs pass through. diff --git a/pretab/encoding/numerical/ple.py b/pretab/encoding/numerical/ple.py index f0bb3d0..12b94e0 100644 --- a/pretab/encoding/numerical/ple.py +++ b/pretab/encoding/numerical/ple.py @@ -16,7 +16,7 @@ from ...core.parameters import UNSET, AliasResolverMixin, validate_task from ...core.representation import RepresentationSpecMixin from ...core.supervised import warn_target_leakage -from ...core.validation import resolve_input_features +from ...core.validation import resolve_input_features, single_target from ...exceptions import ( IncompatibleParamsError, InvalidParamError, @@ -169,7 +169,7 @@ def fit(self, X, y=None): ensure_2d=True, ensure_all_finite=True, # type: ignore ) - y = np.asarray(y).ravel() + y = single_target(y, type(self).__name__) if len(X) != len(y): raise PretabDataError(f"X and y must have same length. Got {len(X)} and {len(y)}") diff --git a/tests/integration/test_preprocessor.py b/tests/integration/test_preprocessor.py index a24ba71..506ee48 100644 --- a/tests/integration/test_preprocessor.py +++ b/tests/integration/test_preprocessor.py @@ -614,3 +614,18 @@ def test_unsupervised_numerical_method_accepts_a_multi_output_target(method): options = {"numerical_method": method, "target_aware": False, "placement_strategy": "uniform"} reference = Preprocessor(**options).fit(frame).transform(frame) np.testing.assert_array_equal(Preprocessor(**options).fit(frame, Y).transform(frame), reference) + + +@pytest.mark.parametrize("method", ["bspline", "naturalspline", "rbf", "ple"]) +def test_target_aware_numerical_method_rejects_a_multi_output_target(method): + """Target-aware placement fits one model on one target. A 2-D target was + flattened into twice as many values and failed with an unrelated IndexError / + broadcasting error; it now raises a clear error pointing to target_aware=False.""" + rng = np.random.default_rng(0) + frame = pd.DataFrame({"a": rng.normal(size=200), "b": rng.normal(size=200)}) + Y = np.column_stack([np.sin(frame["a"]), np.cos(frame["b"])]) + + with pytest.raises(IncompatibleParamsError, match=r"single target, but y has shape \(200, 2\)"): + Preprocessor(numerical_method=method).fit(frame, Y) + # A column vector is a single target. + Preprocessor(numerical_method=method).fit(frame, Y[:, :1]) From c6db1c9ceb692fa032dc25e659f59ec3abd19b5e Mon Sep 17 00:00:00 2001 From: ChrisW09 <50968720+ChrisW09@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:30:00 +0200 Subject: [PATCH 15/16] docs: list the methods that cannot fit an all-missing column without imputation The edge-case note said only the splines and Box-Cox reject a column with no observed value at fit. That holds with imputation; with imputation disabled the column stays missing, and every method except the scalers, "quantile" and "none" raises the typed error naming it. Refs #95 --- docs/representations/choosing_a_method.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/representations/choosing_a_method.md b/docs/representations/choosing_a_method.md index 1f3f3ee..654cb27 100644 --- a/docs/representations/choosing_a_method.md +++ b/docs/representations/choosing_a_method.md @@ -101,10 +101,12 @@ PreTab is explicit about degenerate inputs rather than failing silently. - **Column with no observed value at fit** (missing on every row, e.g. an optional field that is empty in a training window or a cross-validation fold): `Preprocessor` keeps the column's block and emits a `DataWarning` naming it. The imputers fill it with `0` (or `fill_value` - for `strategy="constant"`), so it is fitted as a constant column; with imputation disabled - it stays missing. A method that cannot be fitted on such a column (the splines above, or - Box-Cox) raises a typed error naming the column, and `policy={"constant": "error"}` rejects an - empty numerical column like any other constant one. + for `strategy="constant"`), so it is fitted as a constant column; a method that cannot be + fitted on a constant column (the splines above, or Box-Cox) raises a typed error naming the + column. With imputation disabled the column stays missing: the scalers, `"quantile"` and + `"none"` keep its block as missing values, and every other method raises the same typed + error. `policy={"constant": "error"}` rejects an empty numerical column like any other + constant one. ## Non-goals From c74543851cfccbd654cb9dd96d4896a1e22232ae Mon Sep 17 00:00:00 2001 From: ChrisW09 <50968720+ChrisW09@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:31:32 +0200 Subject: [PATCH 16/16] test: pin the remaining review cases of the new fixes - A list of rows passed to transform after a DataFrame fit is matched by position (#94). - The fold-column check of cross-fitting is tested with deterministic folds instead of a frequency tie that a future scikit-learn could break differently (#90). - The unsupervised B/M/I splines never read y, even a y that cannot be aligned with X (#88). - The pipeline set_output test moves out of the polars-only module, so its pandas case also runs where polars is not installed (#94). - An optional registered class that defaults to target_aware=False follows the Preprocessor's target_aware and is then refit on every fold (#96 with the cross-fitting fix from #86). --- tests/core/test_cross_fitted.py | 16 +++++---- .../spline/test_spline_expansions.py | 10 ++++++ .../test_registered_cross_fitting.py | 35 +++++++++++++++++++ tests/integration/test_polars_input.py | 15 -------- tests/integration/test_preprocessor.py | 31 ++++++++++++++++ 5 files changed, 85 insertions(+), 22 deletions(-) diff --git a/tests/core/test_cross_fitted.py b/tests/core/test_cross_fitted.py index c845fa7..b380d12 100644 --- a/tests/core/test_cross_fitted.py +++ b/tests/core/test_cross_fitted.py @@ -437,14 +437,16 @@ def test_fit_transform_rejects_folds_with_other_dataframe_columns(): from sklearn.compose import ColumnTransformer from sklearn.preprocessing import OneHotEncoder - rng = np.random.default_rng(0) - # "b" and "c" tie, so which one is kept as a top category depends on the fold. - X = pd.DataFrame({"num": rng.normal(size=90), "cat": rng.permutation(["a"] * 50 + ["b"] * 20 + ["c"] * 20)}) - y = X["num"].to_numpy() + rng.normal(scale=0.3, size=90) - encoder = OneHotEncoder(max_categories=3, handle_unknown="infrequent_if_exist", sparse_output=False) + # Unshuffled folds: fold 0 holds out rows 0-29 (all "a"), so its training rows keep + # "b" as the frequent category while the all-data fit keeps "a" -- same width, other labels. + X = pd.DataFrame({"num": np.linspace(-1.0, 1.0, 90), "cat": ["a"] * 30 + ["b"] * 30 + ["c"] * 20 + ["a"] * 10}) + y = X["num"].to_numpy() ** 2 + encoder = OneHotEncoder(max_categories=2, handle_unknown="infrequent_if_exist", sparse_output=False) transformer = ColumnTransformer( [("ple", PLETransformer(output_dim=3), ["num"]), ("oh", encoder, ["cat"])] ).set_output(transform="pandas") - with pytest.raises(IncompatibleParamsError, match="same output columns across folds"): - CrossFittedTransformer(transformer, n_folds=3, random_state=0).fit_transform(X, y) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", LeakageWarning) + with pytest.raises(IncompatibleParamsError, match=r"same output columns across folds.*cat_a"): + CrossFittedTransformer(transformer, n_folds=3, shuffle=False).fit_transform(X, y) diff --git a/tests/expansion/spline/test_spline_expansions.py b/tests/expansion/spline/test_spline_expansions.py index 333f567..fa9767d 100644 --- a/tests/expansion/spline/test_spline_expansions.py +++ b/tests/expansion/spline/test_spline_expansions.py @@ -366,6 +366,16 @@ def test_unsupervised_bmi_spline_ignores_a_multi_output_target(cls, params): np.testing.assert_array_equal(fitted.transform(X), reference.transform(X)) +@pytest.mark.parametrize("cls", [BSplineTransformer, MSplineTransformer, ISplineTransformer]) +@pytest.mark.parametrize("params", [{}, {"placement_strategy": "uniform"}]) +def test_unsupervised_bmi_spline_never_reads_y(cls, params): + X = np.linspace(0.0, 1.0, 50).reshape(-1, 1) + X[3, 0] = np.nan + reference = cls(**params).fit(X) + fitted = cls(**params).fit(X, np.zeros(10)) # a y that cannot be row-aligned with X + np.testing.assert_array_equal(fitted.knots_[0], reference.knots_[0]) + + @pytest.mark.parametrize("cls", [BSplineTransformer, MSplineTransformer, ISplineTransformer]) def test_bmi_spline_fits_in_a_multi_output_pipeline(cls): from sklearn.linear_model import Ridge diff --git a/tests/extension/test_registered_cross_fitting.py b/tests/extension/test_registered_cross_fitting.py index 3e8a039..514b8c9 100644 --- a/tests/extension/test_registered_cross_fitting.py +++ b/tests/extension/test_registered_cross_fitting.py @@ -67,3 +67,38 @@ def test_registered_target_encoder_is_refit_per_fold(noise_target_data, cls, sup # all-data fit correlated at about 0.5. assert abs(np.corrcoef(out_of_fold[:, -1], y)[0, 1]) < 0.2 assert lineage["cat_id"] is True + + +class _MeanEncoderOffByDefault(_MeanEncoder): + """The same optional encoder, defaulting to target_aware=False.""" + + def __init__(self, target_aware=False): + super().__init__(target_aware=target_aware) + + +@pytest.mark.parametrize(("target_aware", "placement_strategy"), [(True, "cart"), (False, "uniform")]) +def test_optional_class_follows_the_preprocessor_target_aware_across_folds( + noise_target_data, target_aware, placement_strategy +): + """The Preprocessor passes its target_aware to an optional class, so a class that + defaults to False becomes target-aware under target_aware=True -- and must then be + refit on every fold, while under target_aware=False it never sees y.""" + X, y = noise_target_data + register_representation( + "registered_encoder", _MeanEncoderOffByDefault, feature_kind="categorical", supervision="optional" + ) + pre = Preprocessor( + numerical_method="minmax", + categorical_method="registered_encoder", + target_aware=target_aware, + placement_strategy=placement_strategy, + random_state=0, + ) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + out_of_fold = np.asarray(CrossFittedTransformer(pre, n_folds=5, random_state=0).fit_transform(X, y)) + lineage = {item.output_feature: item.uses_target for item in pre.fit(X, y).get_feature_lineage()} + + assert abs(np.corrcoef(out_of_fold[:, -1], y)[0, 1]) < 0.2 + assert lineage["cat_id"] is target_aware diff --git a/tests/integration/test_polars_input.py b/tests/integration/test_polars_input.py index 0c11071..6d8feed 100644 --- a/tests/integration/test_polars_input.py +++ b/tests/integration/test_polars_input.py @@ -89,21 +89,6 @@ def test_polars_nulls_are_missing_values(): assert not any("None" in name for name in names) -@pytest.mark.parametrize("container", ["pandas", "polars"]) -def test_pipeline_set_output_hands_frames_to_the_preprocessor(frames, container): - pandas_frame, _, y = frames - numeric = pandas_frame[["income", "visits"]] - pipe = Pipeline([("impute", SimpleImputer()), ("pretab", Preprocessor(numerical_method="minmax"))]) - pipe.set_output(transform=container) - - out = pipe.fit_transform(numeric, y) - - assert type(out).__module__.split(".")[0] == container - assert out.shape == (len(numeric), 2) - assert list(out.columns) == ["num_income", "num_visits"] - np.testing.assert_allclose(np.asarray(out), np.asarray(pipe.transform(numeric))) - - def test_representation_search_on_polars_matches_pandas(frames): """Each fold's Preprocessor gets a polars frame too, not an object array that would turn every column categorical (and score a different model than the diff --git a/tests/integration/test_preprocessor.py b/tests/integration/test_preprocessor.py index 506ee48..0cb9fc0 100644 --- a/tests/integration/test_preprocessor.py +++ b/tests/integration/test_preprocessor.py @@ -1,8 +1,12 @@ +import importlib.util + import numpy as np import pandas as pd import pytest from sklearn.base import clone from sklearn.exceptions import NotFittedError +from sklearn.impute import SimpleImputer +from sklearn.pipeline import Pipeline from sklearn.utils.validation import check_is_fitted from pretab.exceptions import IncompatibleParamsError, InvalidParamError, PretabDataError @@ -492,6 +496,33 @@ def test_a_list_of_rows_is_read_like_an_array(): np.testing.assert_allclose(np.asarray(pre.transform(rows)), np.asarray(pre.transform(np.asarray(rows)))) +def test_a_list_of_rows_is_matched_by_position_after_a_frame_fit(): + X = pd.DataFrame({"a": [1.0, 3.0, 4.0, 7.0], "b": [2.0, 5.0, 9.0, 1.0]}) + pre = Preprocessor(numerical_method="minmax").fit(X) + with pytest.warns(UserWarning, match="valid feature names"): + out = pre.transform(X.to_numpy().tolist()) + np.testing.assert_allclose(np.asarray(out), np.asarray(pre.transform(X))) + + +_needs_polars = pytest.mark.skipif(importlib.util.find_spec("polars") is None, reason="polars is not installed") + + +@pytest.mark.parametrize("container", ["pandas", pytest.param("polars", marks=_needs_polars)]) +def test_pipeline_set_output_hands_frames_to_the_preprocessor(container): + """A Pipeline configured with set_output hands its frames (pandas or polars) + from one step to the next, so the Preprocessor must accept both.""" + rng = np.random.default_rng(0) + numeric = pd.DataFrame({"income": rng.normal(5e4, 1e4, 120), "visits": rng.integers(0, 1000, 120)}) + pipe = Pipeline([("impute", SimpleImputer()), ("pretab", Preprocessor(numerical_method="minmax"))]) + pipe.set_output(transform=container) + + out = pipe.fit_transform(numeric, rng.normal(size=120)) + + assert type(out).__module__.split(".")[0] == container + assert list(out.columns) == ["num_income", "num_visits"] + np.testing.assert_allclose(np.asarray(out), np.asarray(pipe.transform(numeric))) + + # --- get_feature_names_out(input_features) (issue #65) ----------------------------