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")