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
24 changes: 24 additions & 0 deletions docs/core_concepts/outputs_and_inspection.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,30 @@ Every representation names its output columns, and the names are stable and desc
pre.get_feature_names_out()
```

As in scikit-learn, a `Preprocessor` fitted on string-labelled columns (a `DataFrame` or a
dict) records them as `feature_names_in_`, and `get_feature_names_out(input_features)` accepts
only those names. One fitted on a NumPy array has no feature names: `get_feature_names_out`
then accepts any `n_features_in_` names and uses them in place of `feature_0`, `feature_1`,
.... A frame with non-string column labels also has no `feature_names_in_`, so it accepts
any names of the right length, including its original labels. Non-string labels are
converted to strings in output names. This is what `Pipeline.get_feature_names_out()`
relies on when an upstream step, such as a `SimpleImputer`, outputs arrays:

```python
import numpy as np
import pandas as pd
from sklearn.impute import SimpleImputer
from sklearn.pipeline import make_pipeline

X = pd.DataFrame({"age": [25.0, np.nan, 47.0, 33.0], "income": [3.1e4, 5.2e4, np.nan, 4.4e4]})
pipe = make_pipeline(SimpleImputer(), Preprocessor(numerical_method="minmax"))
list(pipe.fit(X, [0.0, 1.0, 1.0, 0.0]).get_feature_names_out())
```

```text
['num_age', 'num_income']
```

Use `get_feature_info(verbose=True)` for a human-readable table of the resolved per-feature
pipeline, output width, and category count.

Expand Down
68 changes: 64 additions & 4 deletions pretab/compose/inspection.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@
:func:`build_transformer_summary` renders that metadata as an aligned table.
"""

import copy
from typing import Any

import numpy as np
from sklearn.pipeline import FeatureUnion
from sklearn.pipeline import FeatureUnion, Pipeline

from ..core.logging import get_logger
from ..core.representation import FeatureLineage
Expand All @@ -21,6 +24,7 @@
"build_feature_lineage",
"build_transformer_summary",
"clean_feature_names",
"feature_names_out",
"get_output_slices",
]

Expand Down Expand Up @@ -69,7 +73,59 @@ def get_output_slices(column_transformer):
return slices


def clean_feature_names(column_transformer, names):
def feature_names_out(column_transformer, input_features=None):
"""Return the cleaned output feature names of a fitted ColumnTransformer.

``input_features`` -- one name per input column, in input order -- replaces the
column labels the ColumnTransformer was fitted on (e.g. the synthetic
``feature_i`` labels of an array input), following scikit-learn's contract
for an estimator fitted without feature names. Each step's names are rebuilt
from its fitted transformer with the new labels, so names that embed a label
anywhere (e.g. polynomial terms) are renamed consistently.
"""
fitted_labels = getattr(column_transformer, "feature_names_in_", None)
if input_features is None or fitted_labels is None:
raw_names = column_transformer.get_feature_names_out(input_features)
return clean_feature_names(column_transformer, [str(name) for name in raw_names])

rename = {str(label): str(name) for label, name in zip(fitted_labels, input_features, strict=True)}
raw_names = []
for name, transformer, columns in column_transformer.transformers_:
if transformer == "drop" or len(columns) == 0:
continue
labels = [rename[str(column)] for column in columns]
if transformer == "passthrough":
inner = labels
else:
inner = _without_feature_names(transformer).get_feature_names_out(labels)
raw_names.extend(f"{name}__{inner_name}" for inner_name in inner)
return clean_feature_names(column_transformer, raw_names, rename=rename)


def _without_feature_names(estimator) -> Any:
"""Return a shallow copy of a fitted step that accepts any input feature names.

The ColumnTransformer hands each step a DataFrame column, so scikit-learn
estimators inside it record ``feature_names_in_`` and only accept those exact
names in ``get_feature_names_out``. Dropping the attribute on a shallow copy
(and on copies of any nested pipeline / union steps) makes them fall back to
the length check scikit-learn applies to estimators fitted without feature
names, while the fitted step itself is left untouched.
"""
if not hasattr(estimator, "get_params"):
return estimator
stripped = copy.copy(estimator)
stripped.__dict__.pop("feature_names_in_", None)
if isinstance(stripped, Pipeline):
stripped.steps = [(name, _without_feature_names(step)) for name, step in stripped.steps]
elif isinstance(stripped, FeatureUnion):
stripped.transformer_list = [
(name, _without_feature_names(transformer)) for name, transformer in stripped.transformer_list
]
return stripped


def clean_feature_names(column_transformer, names, *, rename=None):
"""Collapse the per-feature name that sklearn's ColumnTransformer duplicates.

Each per-column step is a ``num_*`` / ``cat_*`` block for one feature (see
Expand All @@ -78,10 +134,14 @@ def clean_feature_names(column_transformer, names):
column, so sklearn's default ``f"{step}__{inner}"`` naming doubles it, e.g.
``"num_age__age_bs0"``. This collapses that back to ``"num_age_bs0"``. Other
names of a block keep the public block name as their prefix, and
passthrough/remainder columns are left unchanged.
passthrough/remainder columns are left unchanged. ``rename`` maps fitted
column labels to the input feature names ``names`` were built from (see
:func:`feature_names_out`); a step of a renamed column is then reported under
the new name, e.g. ``num_feature_0`` as ``num_age``.
"""
rename = rename or {}
step_to_columns = {
name: columns
name: [rename.get(str(column), column) for column in columns]
for name, _transformer, columns in column_transformer.transformers_
if name != "remainder" and len(columns) == 1
}
Expand Down
45 changes: 37 additions & 8 deletions pretab/preprocessor.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
build_feature_info,
build_feature_lineage,
build_transformer_summary,
clean_feature_names,
feature_names_out,
get_output_slices,
)
from .compose.output import (
Expand Down Expand Up @@ -319,6 +319,10 @@ class Preprocessor(TransformerMixin, BaseEstimator):
labelled ``1`` appears there as ``"1"``.
n_features_in\_ : int
Number of input features seen during ``fit``.
feature_names_in\_ : ndarray of str
Names of the input features seen during ``fit``. Defined only when every
column label of ``X`` is a string (a DataFrame or dict); a NumPy array has
no feature names.
total_output_dim\_ : int
Total number of output columns produced across all input features
(equals the width of ``transform(..., return_array=True)``).
Expand Down Expand Up @@ -541,6 +545,7 @@ def fit(self, X, y=None, embeddings=None):
verbose=resolved["verbose"],
)

fitted_on_array = isinstance(X, np.ndarray)
X = to_dataframe(X)

if self.feature_preprocessing:
Expand Down Expand Up @@ -609,6 +614,11 @@ def fit(self, X, y=None, embeddings=None):
)
self.column_transformer_.fit(self._column_transformer_input(X), y)
self.n_features_in_ = X.shape[1]
# scikit-learn convention: feature names are recorded only for named input.
if not fitted_on_array and all(isinstance(label, str) for label in X.columns):
self.feature_names_in_ = np.asarray(X.columns, dtype=object)
elif hasattr(self, "feature_names_in_"):
del self.feature_names_in_

self._enforce_output_budget(X.shape[0])

Expand Down Expand Up @@ -790,22 +800,41 @@ def get_feature_names_out(self, input_features=None):
Parameters
----------
input_features : array-like or None, default=None
Input feature names. Passed through to the underlying column transformer.
Each name is compared by its string form, the form the column transformer is
fitted on, so the labels seen at fit are accepted as given (e.g. ``[0, 1]``
after fitting on ``pd.DataFrame(array)``).
Input feature names, following scikit-learn's convention. When the
preprocessor was fitted on named (string-labelled) columns they must equal
``feature_names_in_``. When it was fitted without feature names (a NumPy
array or a frame with non-string column labels), any
``n_features_in_`` names are accepted and used in place of the fitted
ones, so names propagate through a ``Pipeline`` whose upstream steps
output arrays. Non-string labels are converted to strings in the output
names, so the labels seen at fit (e.g. ``[0, 1]``) are accepted as given.

Returns
-------
feature_names_out : numpy.ndarray of str
Transformed feature names.

Raises
------
ValueError
If ``input_features`` does not have ``n_features_in_`` entries, or differs
from ``feature_names_in_`` when that is defined.
"""

check_is_fitted(self)
if input_features is not None:
input_features = [str(feature) for feature in input_features]
raw_names = self.column_transformer_.get_feature_names_out(input_features)
return np.array(clean_feature_names(self.column_transformer_, raw_names))
input_features = np.asarray(input_features, dtype=object)
if len(input_features) != self.n_features_in_:
raise ValueError(
f"input_features should have length equal to number of features "
f"({self.n_features_in_}), got {len(input_features)}"
)
fitted_names = getattr(self, "feature_names_in_", None)
if fitted_names is not None:
if not np.array_equal(fitted_names, input_features):
raise ValueError("input_features is not equal to feature_names_in_")
input_features = None
return np.array(feature_names_out(self.column_transformer_, input_features))

def get_feature_lineage(self):
"""Return per-output-column provenance for the fitted preprocessor.
Expand Down
20 changes: 20 additions & 0 deletions tests/integration/test_column_labels.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,26 @@ def test_get_feature_names_out_accepts_the_integer_labels_seen_at_fit():
np.testing.assert_array_equal(pre.get_feature_names_out([0, 1]), pre.get_feature_names_out())


@pytest.mark.parametrize("missing_policy", ["impute", "separate_state"])
def test_non_string_label_fit_renames_fallback_steps_without_mutating_the_fit(missing_policy):
frame = pd.DataFrame(np.random.default_rng(0).normal(size=(30, 3)), columns=pd.Index(["_score", 1, "col0"]))
pre = Preprocessor(numerical_method="minmax", missing_policy=missing_policy).fit(frame)
assert not hasattr(pre, "feature_names_in_")
assert pre.column_transformer_.transformers_[0][0] == "num_col0_1"
original_names = pre.get_feature_names_out()
original_output = pre.transform(frame)
np.testing.assert_array_equal(pre.get_feature_names_out(frame.columns), original_names)
input_features = ["renamed__score", "value", "_col0"]
expected = (
Preprocessor(numerical_method="minmax", missing_policy=missing_policy)
.fit(frame.set_axis(input_features, axis=1))
.get_feature_names_out()
)
np.testing.assert_array_equal(pre.get_feature_names_out(input_features), expected)
np.testing.assert_array_equal(pre.get_feature_names_out(), original_names)
np.testing.assert_array_equal(pre.transform(frame), original_output)


def test_underscore_and_integer_labels_preserve_routing_and_public_metadata():
frame = pd.DataFrame(
{
Expand Down
62 changes: 62 additions & 0 deletions tests/integration/test_preprocessor.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,68 @@ def test_unknown_feature_preprocessing_key_raises(sample_data):
Preprocessor(feature_preprocessing={"nnum1": "minmax"}).fit(X, y)


# --- get_feature_names_out(input_features) (issue #65) ----------------------------


@pytest.fixture
def named_numeric():
rng = np.random.default_rng(0)
return pd.DataFrame({"age": rng.normal(40, 10, 60), "income": rng.normal(50, 10, 60)}), rng.normal(size=60)


@pytest.mark.parametrize("input_features", [["age", "income"], ["_score", "income__log"], ["col0", "_score"]])
@pytest.mark.parametrize(
"options",
[
{"numerical_method": "minmax"},
{"numerical_method": "polynomial"},
{"numerical_method": "bspline", "output_dim": 5, "missing_policy": "separate_state"},
{"numerical_method": "ple", "output_dim": 3, "add_missing_indicator": True},
],
)
def test_array_fit_uses_the_given_input_features(named_numeric, options, input_features):
"""Regression guard for issue #65: input_features had to equal the synthetic names."""
X, y = named_numeric
X = X.set_axis(input_features, axis=1)
X.iloc[:3, 0] = np.nan
pre = Preprocessor(**options).fit(X.to_numpy(), y)
original_names = pre.get_feature_names_out()
original_output = pre.transform(X.to_numpy())
from_array = pre.get_feature_names_out(input_features)
from_frame = Preprocessor(**options).fit(X, y).get_feature_names_out()
assert from_array.tolist() == from_frame.tolist()
np.testing.assert_array_equal(pre.get_feature_names_out(), original_names)
np.testing.assert_array_equal(pre.transform(X.to_numpy()), original_output)


def test_array_fit_rejects_input_features_of_the_wrong_length(named_numeric):
X, y = named_numeric
pre = Preprocessor(numerical_method="minmax").fit(X.to_numpy(), y)
with pytest.raises(ValueError, match="length equal to number of features"):
pre.get_feature_names_out(["age"])


def test_named_fit_records_and_validates_feature_names_in(named_numeric):
X, y = named_numeric
pre = Preprocessor(numerical_method="minmax").fit(X, y)
assert pre.feature_names_in_.tolist() == ["age", "income"]
assert pre.get_feature_names_out(["age", "income"]).tolist() == ["num_age", "num_income"]
with pytest.raises(ValueError, match="not equal to feature_names_in_"):
pre.get_feature_names_out(["a", "b"])
assert not hasattr(pre.fit(X.to_numpy(), y), "feature_names_in_")


def test_pipeline_feature_names_propagate_through_an_array_step(named_numeric):
from sklearn.impute import SimpleImputer
from sklearn.pipeline import make_pipeline

X, y = named_numeric
pipe = make_pipeline(SimpleImputer(), Preprocessor(numerical_method="minmax")).fit(X, y)
names = pipe.get_feature_names_out()
assert names is not None
assert names.tolist() == ["num_age", "num_income"]


# --- boolean columns (issue #54) ------------------------------------------------


Expand Down
Loading