diff --git a/docs/guide.md b/docs/guide.md index a2857ad..9ed0469 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -89,6 +89,36 @@ clf = PoniardClassifier(custom_preprocessor=pp) `preprocess=False` skips preprocessing entirely (raw data goes straight to the estimators). +### Per-estimator preprocessors + +By default every estimator shares one preprocessor. Use `preprocessor_map` to +route specific estimators to different registered templates: + +```python +clf = PoniardClassifier( + estimators=[HistGradientBoostingClassifier(), LogisticRegression()], + preprocessor_map={"HistGradientBoostingClassifier": "native"}, +) +``` + +`"native"` is a built-in profile that leaves numeric and datetime features +untouched (tree models learn NaN split directions themselves) and +ordinal-encodes categoricals as pandas `category` dtype so the estimator splits +on them directly. It is only valid for `HistGradientBoosting*` estimators, which +get `categorical_features="from_dtype"` set automatically as a side effect. +Everything else falls back to `"default"`. + +Templates can be registered and assigned at runtime: + +```python +clf.add_preprocessor("sparse_friendly", my_pipeline) # register a custom template +clf.set_preprocessor("LogisticRegression", "sparse_friendly") # (re)assign +clf.preprocessor_map # inspect +``` + +`add_preprocessing_step` can target specific templates with +`preprocessor="name"` (default `"all"`). + --- ## Cross-validation and results diff --git a/poniard/estimators/core.py b/poniard/estimators/core.py index 461a43c..f549469 100644 --- a/poniard/estimators/core.py +++ b/poniard/estimators/core.py @@ -15,6 +15,7 @@ from sklearn.base import ClassifierMixin, RegressorMixin, TransformerMixin, clone from sklearn.compose import ColumnTransformer from sklearn.dummy import DummyClassifier, DummyRegressor +from sklearn.ensemble import HistGradientBoostingClassifier, HistGradientBoostingRegressor from sklearn.exceptions import UndefinedMetricWarning from sklearn.model_selection import ( cross_val_predict, @@ -218,6 +219,9 @@ def __init__( self.preprocessor = None self.feature_types = {} self._poniard_preprocessor = None + self._poniard_preprocessors = {} + self._configured_X = None + self._configured_y = None self.target_info = None self.show_info = None @@ -286,6 +290,8 @@ def _configure(self, X, y, show_info): X = coerce_input(X) y = coerce_input(y) self.show_info = show_info + self._configured_X = X + self._configured_y = y self.target_info = get_target_info(y, self.poniard_task) if self.target_info["type_"] == "multiclass-multioutput": raise NotImplementedError( @@ -404,14 +410,35 @@ def _build_preprocessor(self, X, y) -> Pipeline: self.feature_types = self._poniard_preprocessor.feature_types return self._poniard_preprocessor.preprocessor + @staticmethod + def _is_poniard_profile(name: str) -> bool: + """Whether `name` is a built-in PoniardPreprocessor profile.""" + return name == "native" + + def _build_profile_preprocessor(self, profile: str, X, y) -> Pipeline: + """Build a PoniardPreprocessor profile with the shared feature types.""" + pp = PoniardPreprocessor(profile=profile) + self._pass_instance_attrs(pp) + pp.build( + X=X, + y=y, + task=self.poniard_task, + target_info=self.target_info, + feature_types=self.feature_types, + ) + self._poniard_preprocessors[profile] = pp + return pp.preprocessor + def _build_preprocessors(self, X, y) -> dict[str, Pipeline]: """Build the registry of named preprocessors. - The ``"default"`` preprocessor is rebuilt exactly as before. Templates - registered via `add_preprocessor` are preserved across re-configuration. - Pipeline or Transformer instances given as ``preprocessor_map`` values - are auto-registered under a generated name; string values must reference - an existing registration. + The ``"default"`` preprocessor is rebuilt exactly as before, computing + the shared ``feature_types`` once. Referenced PoniardPreprocessor + profiles (e.g. ``"native"``) are rebuilt from those same types, so + ``reassign_types`` propagates to every profile. Pipeline or Transformer + instances given as ``preprocessor_map`` values are auto-registered under + a generated name; user-registered templates are preserved across + re-configuration; string values must reference an existing registration. """ if self.custom_preprocessor and not isinstance( self.custom_preprocessor, PoniardPreprocessor @@ -420,8 +447,18 @@ def _build_preprocessors(self, X, y) -> dict[str, Pipeline]: else: default = self._build_preprocessor(X, y) preprocessors = {"default": default} + self._poniard_preprocessors = {} + + referenced_profiles = { + value + for value in self.preprocessor_map.values() + if isinstance(value, str) and self._is_poniard_profile(value) + } + for profile in referenced_profiles: + preprocessors[profile] = self._build_profile_preprocessor(profile, X, y) + for name, template in self.preprocessors.items(): - if name != "default": + if name != "default" and name not in referenced_profiles: preprocessors[name] = template resolved_map = {} @@ -474,6 +511,8 @@ def _make_pipeline(self, name: str, estimator) -> Pipeline: if self.preprocess: prep_name = self.preprocessor_map.get(name, "default") preprocessor = self.preprocessors[prep_name] + if prep_name == "native": + self._configure_native_estimator(estimator, name) pipe = Pipeline( [("preprocessor", preprocessor), (name, estimator)], memory=getattr(preprocessor, "memory", self._memory), @@ -484,6 +523,26 @@ def _make_pipeline(self, name: str, estimator) -> Pipeline: pipe.set_output(transform="pandas") return pipe + @staticmethod + def _configure_native_estimator(estimator, name: str) -> None: + """Couple the ``"native"`` preprocessor to a HistGradientBoosting estimator. + + Only HistGradientBoosting estimators can consume the native categorical + and NaN handling; anything else is a misuse and is rejected here (the + single place every pipeline is built). Side effect: the cloned estimator + gets ``categorical_features="from_dtype"`` so pandas ``category`` columns + produced by the native preprocessor are split on directly. + """ + if not isinstance( + estimator, + (HistGradientBoostingClassifier, HistGradientBoostingRegressor), + ): + raise ValueError( + f"The 'native' preprocessor can only be mapped to HistGradientBoosting " + f"estimators, got '{name}' ({type(estimator).__name__})." + ) + estimator.set_params(categorical_features="from_dtype") + @staticmethod def _ensure_pandas_output(estimator) -> None: """Configure a preprocessor (Pipeline or ColumnTransformer) to output @@ -802,6 +861,9 @@ def reassign_types( self.feature_types = assigned_types self._poniard_preprocessor.build(feature_types=assigned_types) self.preprocessor = self._poniard_preprocessor.preprocessor + for profile, pp in self._poniard_preprocessors.items(): + pp.build(feature_types=assigned_types) + self.preprocessors[profile] = pp.preprocessor self.pipelines = self._build_pipelines() return self @@ -834,6 +896,8 @@ def set_preprocessor(self, estimator_name: str, preprocessor_name: str) -> Ponia Rebuilds that estimator's pipeline immediately via `_make_pipeline`. Must be called after `setup` (so pipelines exist) and before `fit`. + Mapping an estimator to ``"native"`` builds the native profile on demand + and requires the estimator to be a HistGradientBoosting model. Parameters ---------- @@ -851,6 +915,15 @@ def set_preprocessor(self, estimator_name: str, preprocessor_name: str) -> Ponia raise KeyError( f"Estimator '{estimator_name}' not found. Available: {list(self.pipelines)}" ) + if ( + self._is_poniard_profile(preprocessor_name) + and preprocessor_name not in self.preprocessors + ): + if self._configured_X is None: + raise ValueError("setup() must be called before set_preprocessor(..., 'native').") + self.preprocessors[preprocessor_name] = self._build_profile_preprocessor( + preprocessor_name, self._configured_X, self._configured_y + ) if preprocessor_name not in self.preprocessors: raise KeyError( f"Preprocessor '{preprocessor_name}' not registered. " diff --git a/poniard/preprocessing/core.py b/poniard/preprocessing/core.py index 738a6cf..61dcab9 100644 --- a/poniard/preprocessing/core.py +++ b/poniard/preprocessing/core.py @@ -8,7 +8,7 @@ import joblib import numpy as np import pandas as pd -from sklearn.base import TransformerMixin +from sklearn.base import BaseEstimator, TransformerMixin from sklearn.compose import ColumnTransformer from sklearn.feature_selection import VarianceThreshold from sklearn.impute import SimpleImputer @@ -21,6 +21,7 @@ StandardScaler, TargetEncoder, ) +from sklearn.utils.validation import _check_feature_names_in from ..utils.estimate import Task, coerce_input, get_target_info from ..utils.utils import non_default_repr @@ -29,6 +30,31 @@ __all__ = ["PoniardPreprocessor", "infer_feature_types"] +class _ToCategorical(BaseEstimator, TransformerMixin): + """Cast the output of a pandas-returning encoder to ``category`` dtype. + + Used by the ``"native"`` profile so HistGradientBoosting's + ``categorical_features="from_dtype"`` recognizes the ordinal-encoded + columns as categorical. Missing values (NaN) are preserved as missing. + """ + + def fit(self, X, y=None) -> _ToCategorical: + if isinstance(X, pd.DataFrame): + self.feature_names_in_ = np.asarray(X.columns, dtype=object) + self.n_features_in_ = X.shape[1] + return self + + def transform(self, X) -> pd.DataFrame: + if isinstance(X, pd.DataFrame): + return X.astype({col: "category" for col in X.columns}) + return X + + def get_feature_names_out(self, input_features=None) -> np.ndarray: + if input_features is None: + input_features = _check_feature_names_in(self, input_features) + return np.asarray(input_features, dtype=object) + + def infer_feature_types( X: pd.DataFrame | np.ndarray, numeric_threshold: int | float, @@ -112,6 +138,12 @@ class PoniardPreprocessor: Parameters ---------- + profile : + Which transformation profile to use. ``"default"`` imputes, scales and one-hot/ + target-encodes for generic estimators. ``"native"`` leaves numeric and datetime + features untouched and ordinal-encodes categoricals as pandas ``category`` dtype, + for estimators that handle missing values and categoricals natively (e.g. + HistGradientBoosting). No scaling, no imputation, no variance threshold. scaler : Numeric scaler method. Either "standard", "minmax", "robust" or scikit-learn Transformer. high_cardinality_encoder : @@ -159,6 +191,7 @@ class PoniardPreprocessor: def __init__( self, task: Task | None = None, + profile: Literal["default", "native"] = "default", scaler: Literal["standard", "minmax", "robust"] | TransformerMixin | None = None, high_cardinality_encoder: (Literal["target", "ordinal"] | TransformerMixin | None) = None, numeric_imputer: Literal["iterative", "mean", "median"] | TransformerMixin | None = None, @@ -175,6 +208,7 @@ def __init__( ): self._init_params = { "task": task, + "profile": profile, "scaler": scaler, "high_cardinality_encoder": high_cardinality_encoder, "numeric_imputer": numeric_imputer, @@ -190,6 +224,7 @@ def __init__( "cache_dir": cache_dir, } self.task = task + self.profile = profile self.scaler = scaler or "standard" self.high_cardinality_encoder = high_cardinality_encoder or "target" self.numeric_imputer = numeric_imputer or "median" @@ -309,11 +344,11 @@ def build( verbose_feature_names_out=False, ) type_preprocessor.set_output(transform="pandas") + preprocessor_steps = [("type_preprocessor", type_preprocessor)] + if self.profile != "native": + preprocessor_steps.append(("remove_invariant", VarianceThreshold())) preprocessor = Pipeline( - [ - ("type_preprocessor", type_preprocessor), - ("remove_invariant", VarianceThreshold()), - ], + preprocessor_steps, memory=self._memory, ) preprocessor.set_output(transform="pandas") @@ -335,6 +370,9 @@ def _setup_data( return self def _setup_transformers(self): + if self.profile == "native": + return self._setup_native_transformers() + if isinstance(self.scaler, TransformerMixin): scaler = self.scaler elif self.scaler == "standard": @@ -423,6 +461,30 @@ def _setup_transformers(self): datetime_preprocessor, ) + def _setup_native_transformers(self): + """Transformers for the ``"native"`` profile. + + Numeric and datetime features pass through untouched (tree models learn + NaN split directions themselves), and categoricals are ordinal-encoded + to pandas ``category`` dtype so estimators with + ``categorical_features="from_dtype"`` split on them directly. + """ + categorical_preprocessor = Pipeline( + [ + ( + "ordinal_encoder", + OrdinalEncoder(handle_unknown="use_encoded_value", unknown_value=np.nan), + ), + ("to_categorical", _ToCategorical()), + ] + ) + return ( + "passthrough", + categorical_preprocessor, + categorical_preprocessor, + DatetimeEncoder(cyclical=self.cyclical_datetime), + ) + def _infer_dtypes(self) -> tuple[list, list, list, list]: """Infer feature types for ``self.X`` and store them. diff --git a/tests/test_preprocessing.py b/tests/test_preprocessing.py index bbafb37..2d12ae2 100644 --- a/tests/test_preprocessing.py +++ b/tests/test_preprocessing.py @@ -306,6 +306,23 @@ def test_ohe_min_frequency_float_is_fraction_of_samples(): assert "cat_infrequent_sklearn" in out.columns +def test_native_profile_passthrough_and_category(): + X = pd.DataFrame( + { + "num": [1.0, 2.0, np.nan, 4.0, 5.0, 6.0], + "cat": ["a", "b", np.nan, "a", "b", "c"], + } + ) + y = np.array([0, 1, 0, 1, 0, 1]) + pp = PoniardPreprocessor(profile="native").build(X=X, y=y, task="classification") + assert [s for s, _ in pp.preprocessor.steps] == ["type_preprocessor"] + out = pp.preprocessor.fit_transform(X, y) + assert out["num"].dtype == np.float64 + assert out["num"].isna().any() + assert out["cat"].dtype.name == "category" + assert out["cat"].isna().any() + + def test_categorical_imputer_constant_creates_missing_category(): X = pd.DataFrame({"cat": ["a", "b", np.nan, "a", "b"]}) y = np.array([0, 1, 0, 1, 0]) diff --git a/tests/test_preprocessor_map.py b/tests/test_preprocessor_map.py index 06ae2f2..cf56627 100644 --- a/tests/test_preprocessor_map.py +++ b/tests/test_preprocessor_map.py @@ -1,6 +1,8 @@ +import numpy as np import pandas as pd import pytest from sklearn.datasets import make_classification +from sklearn.ensemble import HistGradientBoostingClassifier from sklearn.linear_model import LogisticRegression from sklearn.pipeline import make_pipeline from sklearn.preprocessing import MinMaxScaler, StandardScaler @@ -83,7 +85,7 @@ def test_preprocessor_map_unknown_name_raises(X_y): estimators=[LogisticRegression()], cv=3, random_state=0, - preprocessor_map={"LogisticRegression": "native"}, + preprocessor_map={"LogisticRegression": "nonexistent"}, ) with pytest.raises(KeyError, match="not registered"): clf.setup(X, y, show_info=False) @@ -158,3 +160,150 @@ def test_tuning_with_mapped_pipeline(X_y): grid={"preprocessor__minmaxscaler__feature_range": [(0, 1), (0, 2)]}, ) assert "LogisticRegression_tuned" in clf.pipelines + + +def test_native_mapped_hgb_uses_native(X_y): + X, y = X_y + clf = PoniardClassifier( + estimators=[ + HistGradientBoostingClassifier(max_iter=50, random_state=0), + LogisticRegression(), + ], + cv=2, + random_state=0, + preprocessor_map={"HistGradientBoostingClassifier": "native"}, + ) + clf.setup(X, y, show_info=False) + assert "native" in clf.preprocessors + assert clf.preprocessor_map == {"HistGradientBoostingClassifier": "native"} + hgb = clf.pipelines["HistGradientBoostingClassifier"] + assert hgb.named_steps["preprocessor"] is clf.preprocessors["native"] + estimator = hgb.named_steps["HistGradientBoostingClassifier"] + assert estimator.categorical_features == "from_dtype" + lr = clf.pipelines["LogisticRegression"] + assert lr.named_steps["preprocessor"] is clf.preprocessors["default"] + + +def test_native_set_preprocessor_on_demand(X_y): + X, y = X_y + clf = PoniardClassifier( + estimators=[HistGradientBoostingClassifier(max_iter=50, random_state=0)], + cv=2, + random_state=0, + ) + clf.setup(X, y, show_info=False) + assert "native" not in clf.preprocessors + clf.set_preprocessor("HistGradientBoostingClassifier", "native") + assert "native" in clf.preprocessors + assert clf.preprocessor_map == {"HistGradientBoostingClassifier": "native"} + clf.fit(X, y, show_info=False) + assert not clf.get_results().isna().any().any() + + +def test_native_non_hgb_raises(X_y): + X, y = X_y + clf = PoniardClassifier(estimators=[LogisticRegression()], cv=2, random_state=0) + clf.setup(X, y, show_info=False) + with pytest.raises(ValueError, match="only be mapped to HistGradientBoosting"): + clf.set_preprocessor("LogisticRegression", "native") + clf2 = PoniardClassifier( + estimators=[LogisticRegression()], + cv=2, + random_state=0, + preprocessor_map={"LogisticRegression": "native"}, + ) + with pytest.raises(ValueError, match="only be mapped to HistGradientBoosting"): + clf2.setup(X, y, show_info=False) + + +def test_native_e2e_with_nan_categorical(): + X = pd.DataFrame( + { + "num": [1.0, 2.0, np.nan, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0], + "cat": ["a", "b", np.nan, "a", "b", "c", "a", "b", "a", "c"], + } + ) + y = np.array([0, 1, 0, 1, 0, 1, 0, 1, 0, 1]) + clf = PoniardClassifier( + estimators=[HistGradientBoostingClassifier(max_iter=50, random_state=0)], + cv=2, + random_state=0, + preprocessor_map={"HistGradientBoostingClassifier": "native"}, + ) + clf.fit(X, y, show_info=False) + assert not clf.get_results().isna().any().any() + + +def test_native_reassign_types_shared_inference(X_y): + X, y = X_y + clf = PoniardClassifier( + estimators=[HistGradientBoostingClassifier(max_iter=50, random_state=0)], + cv=2, + random_state=0, + preprocessor_map={"HistGradientBoostingClassifier": "native"}, + ) + clf.setup(X, y, show_info=False) + native = clf.preprocessors["native"].named_steps["type_preprocessor"] + before = {name for name, _, cols in native.transformers if cols} + clf.reassign_types( + numeric=[], + categorical_high=[], + categorical_low=list(X.columns), + datetime=[], + keep_remainder=False, + ) + native = clf.preprocessors["native"].named_steps["type_preprocessor"] + after = {name for name, _, cols in native.transformers if cols} + assert before == {"numeric_preprocessor"} + assert after == {"categorical_low_preprocessor"} + assert clf.preprocessor_map == {"HistGradientBoostingClassifier": "native"} + + +def test_add_preprocessing_step_targets_native(X_y): + X, y = X_y + clf = PoniardClassifier( + estimators=[HistGradientBoostingClassifier(max_iter=50, random_state=0)], + cv=2, + random_state=0, + preprocessor_map={"HistGradientBoostingClassifier": "native"}, + ) + clf.setup(X, y, show_info=False) + clf.add_preprocessing_step(StandardScaler(), preprocessor="native") + assert any(isinstance(t, StandardScaler) for _, t in clf.preprocessors["native"].steps) + assert not any(isinstance(t, StandardScaler) for _, t in clf.preprocessors["default"].steps) + + +def test_persistence_round_trip_preserves_native(X_y, tmp_path): + X, y = X_y + clf = PoniardClassifier( + estimators=[HistGradientBoostingClassifier(max_iter=50, random_state=0)], + cv=2, + random_state=0, + preprocessor_map={"HistGradientBoostingClassifier": "native"}, + ) + clf.setup(X, y, show_info=False) + path = tmp_path / "est.joblib" + clf.save(path) + loaded = PoniardClassifier.load(path) + assert loaded.preprocessor_map == {"HistGradientBoostingClassifier": "native"} + assert "native" in loaded.preprocessors + hgb = loaded.pipelines["HistGradientBoostingClassifier"] + assert hgb.named_steps["preprocessor"] is loaded.preprocessors["native"] + + +def test_tuning_with_native_pipeline(X_y): + X, y = X_y + clf = PoniardClassifier( + estimators=[HistGradientBoostingClassifier(max_iter=50, random_state=0)], + cv=2, + random_state=0, + preprocessor_map={"HistGradientBoostingClassifier": "native"}, + ) + clf.setup(X, y, show_info=False) + clf.tune_estimator( + estimator_name="HistGradientBoostingClassifier", + X=X, + y=y, + grid={"HistGradientBoostingClassifier__learning_rate": [0.01, 0.1]}, + ) + assert "HistGradientBoostingClassifier_tuned" in clf.pipelines