Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
85 changes: 79 additions & 6 deletions poniard/estimators/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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 = {}
Expand Down Expand Up @@ -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),
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
----------
Expand All @@ -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. "
Expand Down
72 changes: 67 additions & 5 deletions poniard/preprocessing/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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 :
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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"
Expand Down Expand Up @@ -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")
Expand All @@ -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":
Expand Down Expand Up @@ -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.

Expand Down
17 changes: 17 additions & 0 deletions tests/test_preprocessing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
Loading
Loading