diff --git a/docs/core_concepts/configuration.md b/docs/core_concepts/configuration.md
index c5fa21d..d2f8aad 100644
--- a/docs/core_concepts/configuration.md
+++ b/docs/core_concepts/configuration.md
@@ -51,6 +51,31 @@ example `{"feature_0": "rbf"}`. Check `numerical_features_` / `categorical_featu
overrides, rather than guessing the column order.
```
+## Polars DataFrame input
+
+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
+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
@@ -119,7 +144,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 +209,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/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/core_concepts/target_awareness.md b/docs/core_concepts/target_awareness.md
index d3eccf2..0b66c21 100644
--- a/docs/core_concepts/target_awareness.md
+++ b/docs/core_concepts/target_awareness.md
@@ -70,7 +70,14 @@ 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,
+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
@@ -158,6 +165,31 @@ 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.
+
+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/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/docs/representations/choosing_a_method.md b/docs/representations/choosing_a_method.md
index 2e457d5..654cb27 100644
--- a/docs/representations/choosing_a_method.md
+++ b/docs/representations/choosing_a_method.md
@@ -98,6 +98,15 @@ 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; 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
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/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/compose/factory.py b/pretab/compose/factory.py
index 9c14479..6054514 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.
@@ -90,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}
@@ -131,10 +144,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 +218,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/feature_detection.py b/pretab/compose/feature_detection.py
index 5f1cec3..9266126 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,60 @@
import numpy as np
import pandas as pd
+from ..core.validation import is_polars_frame, polars_to_pandas
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 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/inspection.py b/pretab/compose/inspection.py
index 8d53611..1b823a5 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
@@ -29,6 +30,7 @@
"clean_feature_names",
"feature_names_out",
"get_output_slices",
+ "refit_block_representation",
"representation_leaf",
]
@@ -281,8 +283,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,
@@ -412,6 +417,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/compose/search.py b/pretab/compose/search.py
index f01a140..7273e68 100644
--- a/pretab/compose/search.py
+++ b/pretab/compose/search.py
@@ -9,15 +9,19 @@
"""
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.validation import check_is_fitted
+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
+from ..core.validation import is_polars_frame, polars_to_pandas
from ..exceptions import InvalidParamError
from ..preprocessor import Preprocessor
@@ -25,15 +29,40 @@
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 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]
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
@@ -42,7 +71,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.
@@ -65,6 +96,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):
@@ -87,19 +125,51 @@ 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.")
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.
+ 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 +180,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,18 +196,67 @@ 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)
+ 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/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..b7126e3 100644
--- a/pretab/core/selectors.py
+++ b/pretab/core/selectors.py
@@ -27,6 +27,8 @@
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"]
@@ -72,8 +74,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,15 +85,20 @@ 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:
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.
@@ -235,20 +242,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 +355,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 +365,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 b221b10..57efbb6 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
@@ -31,7 +32,9 @@
PretabDataError,
)
from ._typing import TransformerLike
+from .parameters import validate_task
from .representation import RepresentationSpecMixin
+from .validation import is_polars_frame, polars_to_pandas
__all__ = ["CrossFittedTransformer", "in_controlled_context", "warn_target_leakage"]
@@ -93,9 +96,13 @@ def _as_2d(X):
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 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)
@@ -107,6 +114,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).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)
+ 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 +155,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
----------
@@ -166,8 +209,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):
@@ -208,10 +250,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,18 +276,32 @@ 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 "
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."
+ )
+ # 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."
)
- out[test_idx] = fold_out
+ 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/pretab/core/validation.py b/pretab/core/validation.py
index 8a271e7..8e455e3 100644
--- a/pretab/core/validation.py
+++ b/pretab/core/validation.py
@@ -6,15 +6,51 @@
``transform`` -- and so ``n_features_in_`` is recorded consistently.
"""
+import sys
import warnings
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
+from ..exceptions import DataWarning, IncompatibleParamsError, PretabDataError, invalid_param_error
-__all__ = ["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:
+ """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 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:
@@ -37,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 8594bd6..12b94e0 100644
--- a/pretab/encoding/numerical/ple.py
+++ b/pretab/encoding/numerical/ple.py
@@ -13,10 +13,10 @@
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
+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)}")
@@ -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 73cfa64..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 (
@@ -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"``.
@@ -82,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
@@ -131,6 +134,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
@@ -294,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)
@@ -307,8 +315,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.
@@ -323,6 +329,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]):
@@ -354,7 +364,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 +381,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/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/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/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/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/pretab/preprocessor.py b/pretab/preprocessor.py
index d11511b..9365720 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,
)
@@ -29,6 +30,7 @@
build_transformer_summary,
feature_names_out,
get_output_slices,
+ refit_block_representation,
representation_leaf,
)
from .compose.output import (
@@ -41,12 +43,14 @@
)
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,
+ DataWarning,
FrozenRepresentationError,
OutputBudgetError,
+ PretabConfigError,
PretabDataError,
PretabSerializationError,
invalid_param_error,
@@ -86,7 +90,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"
@@ -144,7 +149,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.
@@ -199,7 +205,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``.
@@ -232,8 +240,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.
@@ -371,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`` /
@@ -497,8 +512,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
@@ -544,7 +560,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:
@@ -577,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")
@@ -619,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.
@@ -657,7 +695,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
@@ -722,7 +760,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.
@@ -752,10 +790,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:
@@ -787,6 +832,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()
@@ -889,12 +939,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
------
@@ -910,7 +961,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
@@ -937,16 +988,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))
@@ -954,7 +1007,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,
@@ -1003,7 +1056,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
@@ -1023,7 +1076,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
@@ -1044,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_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/compose/test_factory.py b/tests/compose/test_factory.py
index c7b7488..2daa715 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,
@@ -10,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
@@ -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"]
@@ -120,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/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/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/compose/test_search.py b/tests/compose/test_search.py
index c3e065f..8122ae1 100644
--- a/tests/compose/test_search.py
+++ b/tests/compose/test_search.py
@@ -9,9 +9,19 @@
import numpy as np
import pandas as pd
import pytest
+from sklearn.base import BaseEstimator, RegressorMixin, is_classifier, is_regressor
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,
+ StratifiedKFold,
+ check_cv,
+ cross_val_score,
+)
+from sklearn.svm import SVC
from pretab import RepresentationSearchCV
from pretab.exceptions import InvalidParamError
@@ -154,3 +164,153 @@ 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_
+
+
+_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_")
diff --git a/tests/core/test_cross_fitted.py b/tests/core/test_cross_fitted.py
index 515ed70..b380d12 100644
--- a/tests/core/test_cross_fitted.py
+++ b/tests/core/test_cross_fitted.py
@@ -266,13 +266,187 @@ 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)
+
+
+# --- 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)
+
+
+# --- 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
+
+ # 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 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/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/expansion/spline/test_spline_expansions.py b/tests/expansion/spline/test_spline_expansions.py
index 4416d83..fa9767d 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])
@@ -291,3 +338,59 @@ 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])
+@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
+ 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/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)
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/extension/test_registration_discovery.py b/tests/extension/test_registration_discovery.py
index fad8f5e..e8279df 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(np.asarray(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
diff --git a/tests/integration/test_edge_case_contract.py b/tests/integration/test_edge_case_contract.py
index 97e8b07..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,
@@ -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",
@@ -258,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_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_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 --------------------------------------------------------------------
diff --git a/tests/integration/test_polars_input.py b/tests/integration/test_polars_input.py
new file mode 100644
index 0000000..6d8feed
--- /dev/null
+++ b/tests/integration/test_polars_input.py
@@ -0,0 +1,181 @@
+"""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)
+
+
+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),
+ )
+
+
+@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))
diff --git a/tests/integration/test_preprocessor.py b/tests/integration/test_preprocessor.py
index 04ba234..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
@@ -485,6 +489,40 @@ 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))))
+
+
+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) ----------------------------
@@ -595,3 +633,30 @@ 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)
+
+
+@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])
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 = {}
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")