From 2e725edab53e1d9e478fbb03014c2cff527e70b2 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 26 Jul 2026 11:42:58 +0200 Subject: [PATCH 01/59] style: apply ruff format and fix lint findings --- docs/conf.py | 6 +- pretab/__init__.py | 2 +- pretab/core/adaptive.py | 6 +- pretab/core/exceptions.py | 4 +- pretab/core/knots.py | 18 ++- pretab/core/locations.py | 4 +- pretab/core/logging.py | 5 +- pretab/core/params.py | 15 +- pretab/core/selectors.py | 16 +-- pretab/core/validation.py | 5 +- pretab/pipeline/categorical.py | 8 +- pretab/pipeline/numerical.py | 22 ++- pretab/pipeline/registry.py | 28 +++- pretab/preprocessor.py | 61 ++------ .../embeddings/language_transformer.py | 9 +- .../encoders/continuous_ordinal.py | 12 +- pretab/transformers/feature_maps/_base.py | 33 ++--- pretab/transformers/onehot/onehot.py | 4 +- pretab/transformers/ple/ple.py | 11 +- pretab/transformers/splines/base_spline.py | 4 +- pretab/transformers/splines/cubic.py | 8 +- pretab/transformers/splines/knot_selectors.py | 16 +-- pretab/transformers/splines/mixins.py | 17 +-- pretab/transformers/splines/natural_cubic.py | 12 +- pretab/transformers/temporal/lag.py | 2 +- pretab/transformers/temporal/rolling_stats.py | 4 +- pretab/utils/get_numerical.py | 1 - tests/test_adaptive_output_dim.py | 136 +++++++++++++----- tests/test_adaptive_resolution.py | 20 +-- tests/test_categorical_pipeline.py | 1 - tests/test_custombin_transformer.py | 14 +- tests/test_feature_map_selector.py | 8 +- tests/test_feature_names_out.py | 8 +- tests/test_location_selectors.py | 4 +- tests/test_locations.py | 4 +- tests/test_method_aliases.py | 8 +- tests/test_onehot_from_ordinal_transformer.py | 1 + tests/test_ple_selector.py | 8 +- tests/test_preprocessor.py | 8 +- tests/test_rbfexpansion_transformer.py | 11 +- tests/test_reluexpansion_transformer.py | 14 +- tests/test_reproducibility.py | 14 +- tests/test_sigmoidexpansion_transformer.py | 19 +-- tests/test_sklearn_compat.py | 16 +-- tests/test_spline_expansions.py | 4 +- tests/test_tanh_transformer.py | 4 +- tests/test_temporal.py | 12 +- tests/test_verbosity.py | 13 +- 48 files changed, 318 insertions(+), 342 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index e12e38b..f385fad 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -114,11 +114,7 @@ # Exclude scikit-learn metadata-routing boilerplate that is inherited from # BaseEstimator / TransformerMixin and is not part of pretab's public API. "exclude-members": ( - "set_output," - "get_metadata_routing," - "set_fit_request," - "set_transform_request," - "set_inverse_transform_request," + "set_output,get_metadata_routing,set_fit_request,set_transform_request,set_inverse_transform_request," ), } diff --git a/pretab/__init__.py b/pretab/__init__.py index 0b020da..b6810ec 100644 --- a/pretab/__init__.py +++ b/pretab/__init__.py @@ -1,4 +1,4 @@ -from ._version import __version__ # noqa: F401 +from ._version import __version__ from .core.exceptions import PretabWarning from .core.logging import configure_logging, set_verbosity from .preprocessor import Preprocessor diff --git a/pretab/core/adaptive.py b/pretab/core/adaptive.py index 3015715..3259725 100644 --- a/pretab/core/adaptive.py +++ b/pretab/core/adaptive.py @@ -94,12 +94,10 @@ def _resolve_output_bounds( ) if ceil is not None and hi > ceil: raise InvalidParamError( - f"max_output_dim should be <= {ceil}, got {hi}.\n" - f"Fix: lower max_output_dim to at most {ceil}." + f"max_output_dim should be <= {ceil}, got {hi}.\nFix: lower max_output_dim to at most {ceil}." ) if lo > hi: raise IncompatibleParamsError( - "min_output_dim must be <= max_output_dim " - f"(got min_output_dim={lo}, max_output_dim={hi})." + f"min_output_dim must be <= max_output_dim (got min_output_dim={lo}, max_output_dim={hi})." ) return lo, hi diff --git a/pretab/core/exceptions.py b/pretab/core/exceptions.py index 0873d19..fdd6033 100644 --- a/pretab/core/exceptions.py +++ b/pretab/core/exceptions.py @@ -106,6 +106,4 @@ def invalid_param_error(estimator, param, value, constraint, valid=None): def insufficient_samples_error(n_rows, min_required, reason): """Build an :class:`InsufficientSamplesError` with a consistent message.""" - return InsufficientSamplesError( - f"Got {n_rows} row(s) but at least {min_required} are required for {reason}." - ) + return InsufficientSamplesError(f"Got {n_rows} row(s) but at least {min_required} are required for {reason}.") diff --git a/pretab/core/knots.py b/pretab/core/knots.py index 233f7bf..e5f3097 100644 --- a/pretab/core/knots.py +++ b/pretab/core/knots.py @@ -72,14 +72,15 @@ def spanning_knots(x: np.ndarray, n_knots: int, strategy: str = "uniform") -> np if strategy == "quantile": return np.quantile(x, np.linspace(0, 1, n_knots)) raise invalid_param_error( - "spanning_knots", "strategy", strategy, - "must be 'uniform' or 'quantile'", valid={"quantile", "uniform"}, + "spanning_knots", + "strategy", + strategy, + "must be 'uniform' or 'quantile'", + valid={"quantile", "uniform"}, ) -def generate_internal_knots( - x: np.ndarray, n_knots: int, strategy: str = "quantile" -) -> np.ndarray: +def generate_internal_knots(x: np.ndarray, n_knots: int, strategy: str = "quantile") -> np.ndarray: """Generate internal knots for one feature using ``strategy``. Parameters @@ -96,8 +97,11 @@ def generate_internal_knots( if strategy == "quantile": return quantile_knots(x, n_knots) raise invalid_param_error( - "generate_internal_knots", "strategy", strategy, - "must be 'uniform' or 'quantile'", valid={"quantile", "uniform"}, + "generate_internal_knots", + "strategy", + strategy, + "must be 'uniform' or 'quantile'", + valid={"quantile", "uniform"}, ) diff --git a/pretab/core/locations.py b/pretab/core/locations.py index df22e23..e331950 100644 --- a/pretab/core/locations.py +++ b/pretab/core/locations.py @@ -24,9 +24,7 @@ __all__ = ["resolve_locations", "trim_to_count"] -def trim_to_count( - locations: np.ndarray, count: int, importance: np.ndarray | None = None -) -> np.ndarray: +def trim_to_count(locations: np.ndarray, count: int, importance: np.ndarray | None = None) -> np.ndarray: """Reduce ``locations`` to at most ``count`` entries. When ``importance`` is ``None`` the array is down-sampled by even spacing diff --git a/pretab/core/logging.py b/pretab/core/logging.py index 31404c1..4aed727 100644 --- a/pretab/core/logging.py +++ b/pretab/core/logging.py @@ -36,9 +36,7 @@ def get_logger(name: str = "pretab") -> logging.Logger: def _has_real_handler(logger: logging.Logger) -> bool: """Whether ``logger`` already carries a handler other than ``NullHandler``.""" - return any( - not isinstance(handler, logging.NullHandler) for handler in logger.handlers - ) + return any(not isinstance(handler, logging.NullHandler) for handler in logger.handlers) def set_verbosity(level: int = 1) -> None: @@ -77,4 +75,3 @@ def configure_logging(level: int = 1, handler: "logging.Handler | None" = None) handler = logging.StreamHandler() handler.setFormatter(logging.Formatter("%(name)s: %(message)s")) _LOGGER.addHandler(handler) - diff --git a/pretab/core/params.py b/pretab/core/params.py index 6ede2b6..e2d4736 100644 --- a/pretab/core/params.py +++ b/pretab/core/params.py @@ -74,13 +74,9 @@ def validate_placement(target_aware: bool, placement_strategy: str) -> None: :class:`~pretab.core.exceptions.InvalidParamError` (a ``ValueError``) otherwise. """ if target_aware and placement_strategy not in TARGET_AWARE_STRATEGIES: - raise InvalidParamError( - "When target_aware=True, placement_strategy must be 'cart' or 'lightgbm'." - ) + raise InvalidParamError("When target_aware=True, placement_strategy must be 'cart' or 'lightgbm'.") if not target_aware and placement_strategy not in UNSUPERVISED_STRATEGIES: - raise InvalidParamError( - "When target_aware=False, placement_strategy must be 'uniform' or 'quantile'." - ) + raise InvalidParamError("When target_aware=False, placement_strategy must be 'uniform' or 'quantile'.") # §8.3 canonical vocabulary: the family-neutral name for each shared concept. @@ -143,9 +139,7 @@ def _resolve_param(self, canonical: str, default=UNSET) -> Any: if is_set(canon_val): if set_aliases: names = ", ".join(repr(alias) for alias, _ in set_aliases) - raise InvalidParamError( - f"Set {canonical!r} or its legacy alias(es) {names}, not both." - ) + raise InvalidParamError(f"Set {canonical!r} or its legacy alias(es) {names}, not both.") return canon_val if not set_aliases: @@ -159,8 +153,7 @@ def _resolve_param(self, canonical: str, default=UNSET) -> Any: alias, value = set_aliases[0] warnings.warn( - f"{alias!r} is deprecated and will be removed in a future release; " - f"use {canonical!r} instead.", + f"{alias!r} is deprecated and will be removed in a future release; use {canonical!r} instead.", FutureWarning, stacklevel=3, ) diff --git a/pretab/core/selectors.py b/pretab/core/selectors.py index bf172e4..8bc4f50 100644 --- a/pretab/core/selectors.py +++ b/pretab/core/selectors.py @@ -81,9 +81,7 @@ def select( Sorted array of selected locations. """ if y is None: - raise IncompatibleParamsError( - f"{type(self).__name__} requires y to select locations." - ) + raise IncompatibleParamsError(f"{type(self).__name__} requires y to select locations.") task = task or "regression" x = np.asarray(x) @@ -115,9 +113,7 @@ def select( return np.array(sorted(locations)) @abstractmethod - def _ordered_candidates( - self, x_valid: np.ndarray, y_valid: np.ndarray, task: Task - ) -> tuple[list[float], object]: + def _ordered_candidates(self, x_valid: np.ndarray, y_valid: np.ndarray, task: Task) -> tuple[list[float], object]: """Fit a model and return candidate locations plus trimming context. The candidates must be returned in the selector's preferred order (the @@ -195,9 +191,7 @@ def __init__( self.random_state = random_state 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]: + 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, @@ -327,9 +321,7 @@ def _import_lightgbm(): ) from exc return lgb - def _ordered_candidates( - self, x_valid: np.ndarray, y_valid: np.ndarray, task: Task - ) -> tuple[list[float], object]: + def _ordered_candidates(self, x_valid: np.ndarray, y_valid: np.ndarray, task: Task) -> tuple[list[float], object]: lgb = self._import_lightgbm() params = { diff --git a/pretab/core/validation.py b/pretab/core/validation.py index 693716d..f13b4eb 100644 --- a/pretab/core/validation.py +++ b/pretab/core/validation.py @@ -44,7 +44,10 @@ def validate_2d_allow_nan(X, *, allow_nan: bool = True, reset: bool, estimator): original_dim = input_shape[1] if input_shape is not None and len(input_shape) == 2 else None ensure_all_finite: Literal["allow-nan"] | bool = "allow-nan" if allow_nan else True X = check_array( - X, dtype=np.float64, ensure_2d=True, ensure_all_finite=ensure_all_finite # type: ignore + X, + dtype=np.float64, + ensure_2d=True, + ensure_all_finite=ensure_all_finite, # type: ignore ) if original_dim is not None and X.shape[1] < original_dim: warnings.warn( diff --git a/pretab/pipeline/categorical.py b/pretab/pipeline/categorical.py index a860be2..c74e703 100644 --- a/pretab/pipeline/categorical.py +++ b/pretab/pipeline/categorical.py @@ -27,9 +27,7 @@ def get_categorical_transformer_steps( if add_imputer: imputer_kwargs = imputer_kwargs or {} - steps.append( - ("imputer", SimpleImputer(strategy=imputer_strategy, **imputer_kwargs)) - ) + steps.append(("imputer", SimpleImputer(strategy=imputer_strategy, **imputer_kwargs))) if method == "int": steps.append(("continuous_ordinal", ContinuousOrdinalTransformer())) @@ -52,7 +50,9 @@ def get_categorical_transformer_steps( steps.append(("onehot_from_ordinal", OneHotFromOrdinalTransformer())) else: raise invalid_param_error( - "get_categorical_transformer_steps", "method", method, + "get_categorical_transformer_steps", + "method", + method, "unrecognized categorical preprocessing method", valid=set(CATEGORICAL_METHODS), ) diff --git a/pretab/pipeline/numerical.py b/pretab/pipeline/numerical.py index 56bd7aa..3eca776 100644 --- a/pretab/pipeline/numerical.py +++ b/pretab/pipeline/numerical.py @@ -36,6 +36,7 @@ def supports_target_aware(method: str) -> bool: resolved = resolve_method(method, NUMERICAL_METHODS, NUMERICAL_ALIASES) return resolved in TARGET_AWARE_SPLINE_METHODS + # Valid range for the number of spline basis functions per feature. _MIN_SPLINE_BASIS = 5 _MAX_SPLINE_BASIS = 50 @@ -50,10 +51,19 @@ def filter_kwargs(transformer_cls, kwargs, allowed=None): # Method families grouped by which placement modes they support. The Preprocessor # shares a single ``target_aware`` / ``placement_strategy`` pair; each family only # receives the placement kwargs it can honor. -BOTH_MODE_METHODS = frozenset({ - "rbf", "relu", "sigmoid", "tanh", - "bspline", "mspline", "ispline", "cubicspline", "naturalspline", -}) +BOTH_MODE_METHODS = frozenset( + { + "rbf", + "relu", + "sigmoid", + "tanh", + "bspline", + "mspline", + "ispline", + "cubicspline", + "naturalspline", + } +) # PLE is inherently target-aware: only the supervised selectors apply. TARGET_AWARE_ONLY_METHODS = frozenset({"ple"}) # Penalized splines assume equally-spaced knots: only the spacing rules apply. @@ -136,7 +146,9 @@ def get_numerical_transformer_steps( if method not in NUMERICAL_METHODS: raise invalid_param_error( - "get_numerical_transformer_steps", "method", method, + "get_numerical_transformer_steps", + "method", + method, "unrecognized numerical preprocessing method", valid=set(NUMERICAL_METHODS), ) diff --git a/pretab/pipeline/registry.py b/pretab/pipeline/registry.py index 7f2fc45..d1b0d44 100644 --- a/pretab/pipeline/registry.py +++ b/pretab/pipeline/registry.py @@ -60,7 +60,10 @@ "robust": (RobustScaler, []), "box-cox": (PowerTransformer, []), "yeo-johnson": (PowerTransformer, []), - "ple": (PLETransformer, ["output_dim", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state", "handle_missing"]), + "ple": ( + PLETransformer, + ["output_dim", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state", "handle_missing"], + ), "custombin": (CustomBinTransformer, ["output_dim"]), "rbf": ( RBFExpansionTransformer, @@ -108,8 +111,23 @@ "random_state", ], ), - "cubicspline": (CubicSplineTransformer, ["output_dim", "degree", "include_bias", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state"]), - "naturalspline": (NaturalCubicSplineTransformer, ["output_dim", "include_bias", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state"]), + "cubicspline": ( + CubicSplineTransformer, + [ + "output_dim", + "degree", + "include_bias", + "task", + "adaptive", + "min_output_dim", + "max_output_dim", + "random_state", + ], + ), + "naturalspline": ( + NaturalCubicSplineTransformer, + ["output_dim", "include_bias", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state"], + ), # pspline / tensorspline are penalized (difference-penalty) splines that rely # on equally-spaced knots, so they are *not* target-aware: no ``task`` here. "pspline": (PSplineTransformer, ["output_dim", "degree", "diff_order"]), @@ -127,9 +145,7 @@ # Canonical categorical method names (numerical ones are the NUMERICAL_METHODS # keys). Kept here so both pipeline sides resolve names through one module. -CATEGORICAL_METHODS = frozenset( - {"int", "one-hot", "onehot_from_ordinal", "pretrained", "custombin", "none"} -) +CATEGORICAL_METHODS = frozenset({"int", "one-hot", "onehot_from_ordinal", "pretrained", "custombin", "none"}) def _squash(name: str) -> str: diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index fcf228a..5a493d3 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -21,7 +21,6 @@ logger = get_logger(__name__) - class Preprocessor(TransformerMixin, BaseEstimator): r""" Preprocessor class for automated tabular feature preprocessing using scikit-learn-compatible pipelines. @@ -293,20 +292,18 @@ def _detect_column_types(self, X): numerical_features.append(col) else: if isinstance(self.cat_cutoff, float): - cutoff_condition = ( - num_unique_values / total_samples - ) < self.cat_cutoff + cutoff_condition = (num_unique_values / total_samples) < self.cat_cutoff elif isinstance(self.cat_cutoff, int): cutoff_condition = num_unique_values < self.cat_cutoff else: raise invalid_param_error( - type(self).__name__, "cat_cutoff", self.cat_cutoff, + type(self).__name__, + "cat_cutoff", + self.cat_cutoff, "must be a float (unique-ratio cutoff) or an int (absolute unique-count cutoff)", ) - if X[col].dtype.kind not in "iufc" or ( - X[col].dtype.kind == "i" and cutoff_condition - ): + if X[col].dtype.kind not in "iufc" or (X[col].dtype.kind == "i" and cutoff_condition): categorical_features.append(col) else: numerical_features.append(col) @@ -344,16 +341,8 @@ def fit(self, X, y=None, embeddings=None): elif isinstance(X, np.ndarray): X = pd.DataFrame(X, columns=[f"feature_{i}" for i in range(X.shape[1])]) - numerical_method = ( - self.numerical_method.lower() - if self.numerical_method is not None - else "none" - ) - categorical_method = ( - self.categorical_method.lower() - if self.categorical_method is not None - else "none" - ) + numerical_method = self.numerical_method.lower() if self.numerical_method is not None else "none" + categorical_method = self.categorical_method.lower() if self.categorical_method is not None else "none" feature_preprocessing = self.feature_preprocessing or {} self.embeddings_ = False @@ -398,16 +387,13 @@ def fit(self, X, y=None, embeddings=None): steps = get_categorical_transformer_steps(method, output_dim=self.output_dim) transformers.append((f"cat_{feature}", Pipeline(steps), [feature])) - self.column_transformer_ = ColumnTransformer( - transformers=transformers, remainder="passthrough" - ) + self.column_transformer_ = ColumnTransformer(transformers=transformers, remainder="passthrough") self.column_transformer_.fit(X, y) self.n_features_in_ = X.shape[1] if verbose >= 1: logger.info( - "fit complete: %d numerical (%s) + %d categorical (%s) feature(s) " - "-> %d output columns in %.3fs", + "fit complete: %d numerical (%s) + %d categorical (%s) feature(s) -> %d output columns in %.3fs", len(numerical_features), numerical_method, len(categorical_features), @@ -505,9 +491,7 @@ def fit_transform(self, X, y=None, embeddings=None, return_array=False): Transformed dataset in the specified output format. """ - return self.fit(X, y, embeddings=embeddings).transform( - X, embeddings, return_array - ) + return self.fit(X, y, embeddings=embeddings).transform(X, embeddings, return_array) def get_feature_names_out(self, input_features=None): """ @@ -718,13 +702,9 @@ def _feature_table_lines(self, numerical_info, categorical_info, embedding_info) """Build aligned, human-readable rows describing the fitted feature layout.""" rows = [] for feat, info in numerical_info.items(): - rows.append( - (str(feat), "numerical", str(info["preprocessing"]), info["dimension"], info["categories"]) - ) + rows.append((str(feat), "numerical", str(info["preprocessing"]), info["dimension"], info["categories"])) for feat, info in categorical_info.items(): - rows.append( - (str(feat), "categorical", str(info["preprocessing"]), info["dimension"], info["categories"]) - ) + rows.append((str(feat), "categorical", str(info["preprocessing"]), info["dimension"], info["categories"])) for feat, info in embedding_info.items(): rows.append((str(feat), "embedding", "-", info["dimension"], info["categories"])) if not rows: @@ -733,28 +713,18 @@ def _feature_table_lines(self, numerical_info, categorical_info, embedding_info) feat_w = max(len("feature"), *(len(r[0]) for r in rows)) kind_w = max(len("kind"), *(len(r[1]) for r in rows)) pipe_w = max(len("pipeline"), *(len(r[2]) for r in rows)) - header = ( - f"{'feature':<{feat_w}} {'kind':<{kind_w}} " - f"{'pipeline':<{pipe_w}} {'dim':>4} {'cats':>5}" - ) + header = f"{'feature':<{feat_w}} {'kind':<{kind_w}} {'pipeline':<{pipe_w}} {'dim':>4} {'cats':>5}" lines = [header, "-" * len(header)] for feat, kind, pipe, dim, cats in rows: dim_s = "-" if dim is None else str(dim) cats_s = "-" if cats is None else str(cats) - lines.append( - f"{feat:<{feat_w}} {kind:<{kind_w}} " - f"{pipe:<{pipe_w}} {dim_s:>4} {cats_s:>5}" - ) + lines.append(f"{feat:<{feat_w}} {kind:<{kind_w}} {pipe:<{pipe_w}} {dim_s:>4} {cats_s:>5}") return lines def _log_internal_decisions(self): """Log fitted internal decisions (bins / knots / centers) at DEBUG.""" for name, transformer, _columns in self.column_transformer_.transformers_: - last_step = ( - transformer.steps[-1][1] - if hasattr(transformer, "steps") - else transformer - ) + last_step = transformer.steps[-1][1] if hasattr(transformer, "steps") else transformer for attr in ( "thresholds_", "knots_", @@ -764,4 +734,3 @@ def _log_internal_decisions(self): ): if hasattr(last_step, attr): logger.debug("%s.%s = %r", name, attr, getattr(last_step, attr)) - diff --git a/pretab/transformers/embeddings/language_transformer.py b/pretab/transformers/embeddings/language_transformer.py index 797c345..bcfabf4 100644 --- a/pretab/transformers/embeddings/language_transformer.py +++ b/pretab/transformers/embeddings/language_transformer.py @@ -95,9 +95,7 @@ def transform(self, X): The concatenated embeddings for each text input. """ if getattr(self, "model_", None) is None: - raise PretabConfigError( - "Model is not initialized. Call `fit` before `transform`." - ) + raise PretabConfigError("Model is not initialized. Call `fit` before `transform`.") # Normalise to a 2D array of strings so each column is encoded on its own # and the row count is preserved (a flat encode would return @@ -107,8 +105,5 @@ def transform(self, X): arr = arr.reshape(-1, 1) arr = arr.astype(str) - column_embeddings = [ - self.model_.encode(arr[:, i].tolist(), convert_to_numpy=True) - for i in range(arr.shape[1]) - ] + column_embeddings = [self.model_.encode(arr[:, i].tolist(), convert_to_numpy=True) for i in range(arr.shape[1])] return np.hstack(column_embeddings) diff --git a/pretab/transformers/encoders/continuous_ordinal.py b/pretab/transformers/encoders/continuous_ordinal.py index 9ad19fa..0228d27 100644 --- a/pretab/transformers/encoders/continuous_ordinal.py +++ b/pretab/transformers/encoders/continuous_ordinal.py @@ -46,10 +46,7 @@ def fit(self, X, y=None): Fitted transformer. """ # Fit should determine the mapping from original categories to sequential integers starting from 0 - self.mapping_ = [ - {category: i + 1 for i, category in enumerate(np.unique(col))} - for col in X.T - ] + self.mapping_ = [{category: i + 1 for i, category in enumerate(np.unique(col))} for col in X.T] for mapping in self.mapping_: mapping[None] = 0 # Assign 0 to unknown values self.n_features_in_ = len(self.mapping_) @@ -70,12 +67,7 @@ def transform(self, X): """ check_is_fitted(self, "mapping_") # Transform the categories to their mapped integer values - X_transformed = np.array( - [ - [self.mapping_[col].get(value, 0) for col, value in enumerate(row)] - for row in X - ] - ) + X_transformed = np.array([[self.mapping_[col].get(value, 0) for col, value in enumerate(row)] for row in X]) return X_transformed def get_feature_names_out(self, input_features=None): diff --git a/pretab/transformers/feature_maps/_base.py b/pretab/transformers/feature_maps/_base.py index 5b31299..66943f7 100644 --- a/pretab/transformers/feature_maps/_base.py +++ b/pretab/transformers/feature_maps/_base.py @@ -80,9 +80,7 @@ def fit(self, X, y=None): 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}." - ) + raise InvalidParamError(f"Invalid task. Choose 'regression' or 'classification'. Got {self.task!r}.") 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) @@ -92,9 +90,7 @@ def fit(self, X, y=None): raise InvalidParamError(f"output_dim must be >= 1, got {n_centers}") if self.target_aware and y is None: - raise IncompatibleParamsError( - "Target variable 'y' must be provided when target_aware=True." - ) + raise IncompatibleParamsError("Target variable 'y' must be provided when target_aware=True.") if self.target_aware: # Centers come from a target-aware location selector (CART by default, @@ -103,28 +99,23 @@ def fit(self, X, y=None): # each feature keeps exactly ``output_dim`` centers. selector = self._build_selector(placement_strategy) if self.adaptive: - min_centers, max_centers = self._resolve_output_bounds( - n_centers, min_req, max_req, floor=1 - ) + min_centers, max_centers = self._resolve_output_bounds(n_centers, min_req, max_req, floor=1) else: min_centers = max_centers = n_centers centers_list = [ selector.select( - X[:, i], y, task=self.task, - min_count=min_centers, max_count=max_centers, + X[:, i], + y, + task=self.task, + min_count=min_centers, + max_count=max_centers, ) for i in range(X.shape[1]) ] elif placement_strategy == "quantile": - centers_list = [ - np.percentile(X[:, i], np.linspace(0, 100, n_centers)) - for i in range(X.shape[1]) - ] + centers_list = [np.percentile(X[:, i], np.linspace(0, 100, n_centers)) for i in range(X.shape[1])] else: # uniform - centers_list = [ - np.linspace(X[:, i].min(), X[:, i].max(), n_centers) - for i in range(X.shape[1]) - ] + centers_list = [np.linspace(X[:, i].min(), X[:, i].max(), n_centers) for i in range(X.shape[1])] self.centers_ = centers_list return self @@ -165,9 +156,7 @@ def _build_selector(self, placement_strategy): return CARTLocationSelector(random_state=self.random_state) if placement_strategy == "lightgbm": return LightGBMLocationSelector(random_state=self.random_state) - raise InvalidParamError( - f"Invalid placement_strategy. Choose 'cart' or 'lightgbm'. Got {placement_strategy!r}." - ) + raise InvalidParamError(f"Invalid placement_strategy. Choose 'cart' or 'lightgbm'. Got {placement_strategy!r}.") def __sklearn_tags__(self): """Require ``y`` only when centers are placed by a target-aware selector.""" diff --git a/pretab/transformers/onehot/onehot.py b/pretab/transformers/onehot/onehot.py index 0744243..22cbbaf 100644 --- a/pretab/transformers/onehot/onehot.py +++ b/pretab/transformers/onehot/onehot.py @@ -46,9 +46,7 @@ def fit(self, X, y=None): self : object Fitted transformer. """ - self.max_bins_ = ( - np.max(X, axis=0).astype(int) + 1 - ) # Find the maximum bin index for each feature + self.max_bins_ = np.max(X, axis=0).astype(int) + 1 # Find the maximum bin index for each feature self.n_features_in_ = np.asarray(X).shape[1] return self diff --git a/pretab/transformers/ple/ple.py b/pretab/transformers/ple/ple.py index 9c54dab..8f7ea1c 100644 --- a/pretab/transformers/ple/ple.py +++ b/pretab/transformers/ple/ple.py @@ -199,9 +199,7 @@ def fit(self, X, y): 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'." - ) + raise InvalidParamError(f"Unsupported task: {self.task}. Use 'regression' or 'classification'.") # Thresholds come from a target-aware location selector (CART by default, # optionally LightGBM): split points spaced out and ranked by impurity / @@ -216,8 +214,11 @@ def fit(self, X, y): for i in range(X.shape[1]): thresholds = np.sort( selector.select( - X[:, i], y, task=self.task, - min_count=min_thresholds, max_count=max_thresholds, + X[:, i], + y, + task=self.task, + min_count=min_thresholds, + max_count=max_thresholds, ) ) diff --git a/pretab/transformers/splines/base_spline.py b/pretab/transformers/splines/base_spline.py index 256de14..ca50f36 100644 --- a/pretab/transformers/splines/base_spline.py +++ b/pretab/transformers/splines/base_spline.py @@ -226,7 +226,9 @@ def _column_knots( if self.knot_locations is not None: expected_knots = self._basis_to_knots(n_basis) if not self.adaptive and len(self.knot_locations) != expected_knots: - raise IncompatibleParamsError("knot_locations length must match output_dim - degree - 1 when adaptive=False") + raise IncompatibleParamsError( + "knot_locations length must match output_dim - degree - 1 when adaptive=False" + ) internal_knots = self._adjust_internal_knots(x_valid, np.asarray(self.knot_locations), min_knots, max_knots) elif selector is not None: selected = selector.get_knot_locations(x_valid.reshape(-1, 1), y_valid, task=self.task) diff --git a/pretab/transformers/splines/cubic.py b/pretab/transformers/splines/cubic.py index b15075d..b4ca1ad 100644 --- a/pretab/transformers/splines/cubic.py +++ b/pretab/transformers/splines/cubic.py @@ -164,7 +164,9 @@ def fit(self, X, y=None): if self.target_aware: selector = build_knot_selector( - self.placement_strategy, degree=self.degree, spline_type="bspline", + self.placement_strategy, + degree=self.degree, + spline_type="bspline", random_state=self.random_state, ) strategy = "uniform" @@ -172,9 +174,7 @@ def fit(self, X, y=None): selector = None strategy = self.placement_strategy - min_interior, max_interior = self._adaptive_interior_bounds( - output_dim, selector, floor=3, offset=3 - ) + min_interior, max_interior = self._adaptive_interior_bounds(output_dim, selector, floor=3, offset=3) self.knots_ = [] self.designs_ = [] diff --git a/pretab/transformers/splines/knot_selectors.py b/pretab/transformers/splines/knot_selectors.py index 58a1bec..e445bc8 100644 --- a/pretab/transformers/splines/knot_selectors.py +++ b/pretab/transformers/splines/knot_selectors.py @@ -78,7 +78,9 @@ def _basis_to_knots(self, n_basis: int) -> int: if self.spline_type in ("bspline", "mspline", "ispline"): return basis_to_knots(n_basis, self.degree) raise invalid_param_error( - type(self).__name__, "spline_type", self.spline_type, + type(self).__name__, + "spline_type", + self.spline_type, "must be one of 'bspline', 'mspline', 'ispline'", valid={"bspline", "mspline", "ispline"}, ) @@ -156,9 +158,7 @@ def get_knot_locations( ) -> np.ndarray: if y is None: raise IncompatibleParamsError("CARTKnotSelector requires y to select knots.") - return self._selector.select( - X, y, task=task, min_count=self.min_knots, max_count=self.max_knots - ) + return self._selector.select(X, y, task=task, min_count=self.min_knots, max_count=self.max_knots) class LightGBMKnotSelector(BaseKnotSelector): @@ -239,9 +239,7 @@ def get_knot_locations( ) -> np.ndarray: if y is None: raise IncompatibleParamsError("LightGBMKnotSelector requires y to select knots.") - return self._selector.select( - X, y, task=task, min_count=self.min_knots, max_count=self.max_knots - ) + return self._selector.select(X, y, task=task, min_count=self.min_knots, max_count=self.max_knots) def build_knot_selector( @@ -266,7 +264,9 @@ def build_knot_selector( if placement_strategy == "lightgbm": return LightGBMKnotSelector(**kwargs) raise invalid_param_error( - "build_knot_selector", "placement_strategy", placement_strategy, + "build_knot_selector", + "placement_strategy", + placement_strategy, "must be 'cart' or 'lightgbm' when target_aware=True", valid={"cart", "lightgbm"}, ) diff --git a/pretab/transformers/splines/mixins.py b/pretab/transformers/splines/mixins.py index 848610b..f5b12f6 100644 --- a/pretab/transformers/splines/mixins.py +++ b/pretab/transformers/splines/mixins.py @@ -88,12 +88,8 @@ def _place_interior_knots(self, x, y, n_interior, strategy, selector, task, min_ x = np.asarray(x) if selector is not None: if y is None: - raise IncompatibleParamsError( - "A knot selector requires y during fit for target-aware knot placement." - ) - selected = np.asarray( - selector.get_knot_locations(x.reshape(-1, 1), y, task=task), dtype=float - ) + raise IncompatibleParamsError("A knot selector requires y during fit for target-aware knot placement.") + selected = np.asarray(selector.get_knot_locations(x.reshape(-1, 1), y, task=task), dtype=float) x_min, x_max = x.min(), x.max() selected = np.unique(selected[(selected > x_min) & (selected < x_max)]) if min_interior is None and max_interior is None: @@ -148,8 +144,9 @@ def _adaptive_interior_bounds(self, output_dim, selector, *, floor, offset): lo, hi = self._resolve_output_bounds(output_dim, min_req, max_req, floor=floor) return lo - offset, hi - offset - def _place_bspline_knots(self, x, y, output_dim, degree, strategy, selector, task, - min_interior=None, max_interior=None): + def _place_bspline_knots( + self, x, y, output_dim, degree, strategy, selector, task, min_interior=None, max_interior=None + ): """Return the full padded B-spline knot vector for one feature. Places ``output_dim - degree - 1`` interior knots (via @@ -163,9 +160,7 @@ def _place_bspline_knots(self, x, y, output_dim, degree, strategy, selector, tas """ x = np.asarray(x) n_interior = output_dim - degree - 1 - interior = self._place_interior_knots( - x, y, n_interior, strategy, selector, task, min_interior, max_interior - ) + interior = self._place_interior_knots(x, y, n_interior, strategy, selector, task, min_interior, max_interior) x_min, x_max = x.min(), x.max() boundary_left = np.repeat(x_min, degree + 1) boundary_right = np.repeat(x_max, degree + 1) diff --git a/pretab/transformers/splines/natural_cubic.py b/pretab/transformers/splines/natural_cubic.py index 43b1709..82322b8 100644 --- a/pretab/transformers/splines/natural_cubic.py +++ b/pretab/transformers/splines/natural_cubic.py @@ -168,15 +168,15 @@ def fit(self, X, y=None): output_dim = self._resolve_param("output_dim", default=6) if output_dim < 2: - raise InvalidParamError( - f"output_dim must be >= 2 for the natural cubic spline basis, got {output_dim}" - ) + raise InvalidParamError(f"output_dim must be >= 2 for the natural cubic spline basis, got {output_dim}") n_spanning = output_dim + 1 if self.target_aware: selector = build_knot_selector( - self.placement_strategy, degree=self.degree, spline_type="bspline", + self.placement_strategy, + degree=self.degree, + spline_type="bspline", random_state=self.random_state, ) strategy = "uniform" @@ -184,9 +184,7 @@ def fit(self, X, y=None): selector = None strategy = self.placement_strategy - min_interior, max_interior = self._adaptive_interior_bounds( - output_dim, selector, floor=2, offset=1 - ) + min_interior, max_interior = self._adaptive_interior_bounds(output_dim, selector, floor=2, offset=1) self.knots_ = [] self.designs_ = [] diff --git a/pretab/transformers/temporal/lag.py b/pretab/transformers/temporal/lag.py index 68eda95..14da5ce 100644 --- a/pretab/transformers/temporal/lag.py +++ b/pretab/transformers/temporal/lag.py @@ -58,7 +58,7 @@ def transform(self, X): if n_samples <= self.n_lags: raise InsufficientSamplesError("n_lags must be smaller than the number of samples.") - lagged = [X[self.n_lags - i: -i or None] for i in range(1, self.n_lags + 1)] + lagged = [X[self.n_lags - i : -i or None] for i in range(1, self.n_lags + 1)] return np.hstack(lagged) def _output_sizes(self) -> list[int]: diff --git a/pretab/transformers/temporal/rolling_stats.py b/pretab/transformers/temporal/rolling_stats.py index 0bcbc74..687fc37 100644 --- a/pretab/transformers/temporal/rolling_stats.py +++ b/pretab/transformers/temporal/rolling_stats.py @@ -74,7 +74,9 @@ def transform(self, X): stat_val = rolled.max(axis=2) else: raise invalid_param_error( - type(self).__name__, "stats", stat, + type(self).__name__, + "stats", + stat, "each stat must be one of 'mean', 'std', 'min', 'max'", valid={"mean", "std", "min", "max"}, ) diff --git a/pretab/utils/get_numerical.py b/pretab/utils/get_numerical.py index 5cd080c..373c228 100644 --- a/pretab/utils/get_numerical.py +++ b/pretab/utils/get_numerical.py @@ -8,4 +8,3 @@ from ..pipeline.numerical import get_numerical_transformer_steps __all__ = ["get_numerical_transformer_steps"] - diff --git a/tests/test_adaptive_output_dim.py b/tests/test_adaptive_output_dim.py index 745beb0..031e7c7 100644 --- a/tests/test_adaptive_output_dim.py +++ b/tests/test_adaptive_output_dim.py @@ -82,9 +82,7 @@ FIXED_ONLY_SPLINE_METHODS = ["pspline", "tensorspline", "tprs"] # All spline families (kept for callers that want the full set). -SPLINE_METHODS = ( - TARGET_AWARE_LEGACY_SPLINE_METHODS + FIXED_ONLY_SPLINE_METHODS + BMI_SPLINE_METHODS -) +SPLINE_METHODS = TARGET_AWARE_LEGACY_SPLINE_METHODS + FIXED_ONLY_SPLINE_METHODS + BMI_SPLINE_METHODS @pytest.fixture @@ -111,8 +109,13 @@ def _num_width(X, y, method, **kwargs): def test_fixed_width_matches_expected(data, method): X, y = data width = _num_width( - X, y, method, - output_dim=OUTPUT_DIM, adaptive=False, target_aware=True, task="regression", + X, + y, + method, + output_dim=OUTPUT_DIM, + adaptive=False, + target_aware=True, + task="regression", ) assert width == FIXED_WIDTH[method], f"{method}: got {width}, want {FIXED_WIDTH[method]}" @@ -122,8 +125,13 @@ def test_ple_fixed_width_tracks_output_dim(data, output_dim): """PLE in fixed mode produces exactly ``output_dim`` bins.""" X, y = data width = _num_width( - X, y, "ple", - output_dim=output_dim, adaptive=False, target_aware=True, task="regression", + X, + y, + "ple", + output_dim=output_dim, + adaptive=False, + target_aware=True, + task="regression", ) assert width == output_dim @@ -146,13 +154,24 @@ def test_custombin_respects_bin_count(data, output_dim): def test_adaptive_width_within_window(data, method): X, y = data fixed = _num_width( - X, y, method, - output_dim=10, adaptive=False, target_aware=True, task="regression", + X, + y, + method, + output_dim=10, + adaptive=False, + target_aware=True, + task="regression", ) adaptive = _num_width( - X, y, method, - output_dim=10, adaptive=True, min_output_dim=3, max_output_dim=5, - target_aware=True, task="regression", + X, + y, + method, + output_dim=10, + adaptive=True, + min_output_dim=3, + max_output_dim=5, + target_aware=True, + task="regression", ) assert fixed == 10 assert 3 <= adaptive <= 5 @@ -170,10 +189,19 @@ def test_spline_adaptive_transformer_level(data, cls): X, y = data Xv = X.to_numpy() fixed = cls(output_dim=10).fit_transform(Xv, y).shape[1] - adaptive = cls( - output_dim=10, adaptive=True, min_output_dim=4, max_output_dim=5, - target_aware=True, placement_strategy="cart", task="regression", - ).fit_transform(Xv, y).shape[1] + adaptive = ( + cls( + output_dim=10, + adaptive=True, + min_output_dim=4, + max_output_dim=5, + target_aware=True, + placement_strategy="cart", + task="regression", + ) + .fit_transform(Xv, y) + .shape[1] + ) assert adaptive < fixed assert adaptive <= 5 + 1 # allow the optional bias column @@ -186,13 +214,24 @@ def test_bmi_spline_adaptive_via_preprocessor(data, method): """B/M/I splines size each feature inside the adaptive window through the pipeline.""" X, y = data fixed = _num_width( - X, y, method, - output_dim=10, adaptive=False, target_aware=True, task="regression", + X, + y, + method, + output_dim=10, + adaptive=False, + target_aware=True, + task="regression", ) adaptive = _num_width( - X, y, method, - output_dim=10, adaptive=True, min_output_dim=4, max_output_dim=6, - target_aware=True, task="regression", + X, + y, + method, + output_dim=10, + adaptive=True, + min_output_dim=4, + max_output_dim=6, + target_aware=True, + task="regression", ) assert adaptive < fixed assert 4 <= adaptive <= 6 + 1 # allow the optional bias column @@ -203,9 +242,16 @@ def test_bmi_spline_selector_choice_via_preprocessor(data): pytest.importorskip("lightgbm") X, y = data adaptive = _num_width( - X, y, "bspline", - output_dim=10, adaptive=True, min_output_dim=4, max_output_dim=6, - target_aware=True, task="regression", placement_strategy="lightgbm", + X, + y, + "bspline", + output_dim=10, + adaptive=True, + min_output_dim=4, + max_output_dim=6, + target_aware=True, + task="regression", + placement_strategy="lightgbm", ) assert 4 <= adaptive <= 6 + 1 # allow the optional bias column @@ -218,13 +264,24 @@ def test_legacy_spline_adaptive_via_preprocessor(data, method): """Legacy knot splines size each feature inside the adaptive window through the pipeline.""" X, y = data fixed = _num_width( - X, y, method, - output_dim=10, adaptive=False, target_aware=True, task="regression", + X, + y, + method, + output_dim=10, + adaptive=False, + target_aware=True, + task="regression", ) adaptive = _num_width( - X, y, method, - output_dim=10, adaptive=True, min_output_dim=4, max_output_dim=6, - target_aware=True, task="regression", + X, + y, + method, + output_dim=10, + adaptive=True, + min_output_dim=4, + max_output_dim=6, + target_aware=True, + task="regression", ) assert adaptive < fixed assert 4 <= adaptive <= 6 @@ -240,13 +297,24 @@ def test_fixed_only_spline_ignores_adaptive(data, method): """ X, y = data fixed = _num_width( - X, y, method, - output_dim=10, adaptive=False, target_aware=True, task="regression", + X, + y, + method, + output_dim=10, + adaptive=False, + target_aware=True, + task="regression", ) adaptive = _num_width( - X, y, method, - output_dim=10, adaptive=True, min_output_dim=4, max_output_dim=6, - target_aware=True, task="regression", + X, + y, + method, + output_dim=10, + adaptive=True, + min_output_dim=4, + max_output_dim=6, + target_aware=True, + task="regression", ) assert fixed == adaptive == 10 diff --git a/tests/test_adaptive_resolution.py b/tests/test_adaptive_resolution.py index 7a5a10f..e7df71b 100644 --- a/tests/test_adaptive_resolution.py +++ b/tests/test_adaptive_resolution.py @@ -165,12 +165,16 @@ def test_tensor_product_adaptive_is_noop(data): """Penalized tensor splines are unsupervised: the adaptive window is a no-op.""" X, y = data fixed = TensorProductSplineTransformer(output_dim=5).fit_transform(X, y).shape[1] - adaptive = TensorProductSplineTransformer( - output_dim=5, - adaptive=True, - min_output_dim=4, - max_output_dim=7, - ).fit_transform(X, y).shape[1] + adaptive = ( + TensorProductSplineTransformer( + output_dim=5, + adaptive=True, + min_output_dim=4, + max_output_dim=7, + ) + .fit_transform(X, y) + .shape[1] + ) assert fixed == adaptive @@ -220,9 +224,7 @@ def frame(): def test_preprocessor_non_adaptive_output_dim_outside_default_window(frame): # Default min/max are 5/10; a fixed output_dim outside that must not raise. X, y = frame - out = Preprocessor(numerical_method="ple", output_dim=32, cat_cutoff=0.0).fit_transform( - X, y, return_array=True - ) + out = Preprocessor(numerical_method="ple", output_dim=32, cat_cutoff=0.0).fit_transform(X, y, return_array=True) assert isinstance(out, np.ndarray) assert out.shape[1] == 64 diff --git a/tests/test_categorical_pipeline.py b/tests/test_categorical_pipeline.py index 1f8ad2e..2a5e82d 100644 --- a/tests/test_categorical_pipeline.py +++ b/tests/test_categorical_pipeline.py @@ -27,4 +27,3 @@ def test_one_hot_handle_unknown_override(): pipe.fit(np.array([["A"], ["B"]])) with pytest.raises(ValueError): pipe.transform(np.array([["C"]])) - diff --git a/tests/test_custombin_transformer.py b/tests/test_custombin_transformer.py index 1be1ec5..2c9ad6e 100644 --- a/tests/test_custombin_transformer.py +++ b/tests/test_custombin_transformer.py @@ -1,7 +1,9 @@ import numpy as np import pandas as pd import pytest -from sklearn.base import TransformerMixin, BaseEstimator +from sklearn.base import BaseEstimator, TransformerMixin + +from pretab.core.exceptions import InsufficientSamplesError, PretabDataError from pretab.transformers import CustomBinTransformer @@ -37,7 +39,9 @@ def test_custom_bin_transformer_input_types(bins, input_type): X = ( np.array(raw) # Always convert to array to be safe if input_type == "list" - else np.array(raw) if input_type == "np" else pd.DataFrame(raw, columns=["x"]) + else np.array(raw) + if input_type == "np" + else pd.DataFrame(raw, columns=["x"]) ) transformer = CustomBinTransformer(output_dim=bins) Xt = transformer.fit_transform(X) @@ -48,7 +52,7 @@ def test_custom_bin_transformer_input_types(bins, input_type): def test_custom_bin_transformer_invalid_input(): transformer = CustomBinTransformer(output_dim=3) - with pytest.raises(Exception): + with pytest.raises(PretabDataError): transformer.transform("invalid_input") @@ -56,12 +60,12 @@ def test_custom_bin_transformer_raises_on_invalid_shape(): transformer = CustomBinTransformer(output_dim=3) X = np.array([[0.1]]) # This will become scalar after squeeze() - with pytest.raises(ValueError, match="Input must have more than 2 observations."): + with pytest.raises(ValueError, match=r"Input must have more than 2 observations."): transformer.transform(X) def test_custom_bin_transformer_invalid_bins_type(): - with pytest.raises(Exception): + with pytest.raises(InsufficientSamplesError): CustomBinTransformer(output_dim="not_valid").fit_transform(np.array([[0.1]])) diff --git a/tests/test_feature_map_selector.py b/tests/test_feature_map_selector.py index d078d4d..d8bc2e7 100644 --- a/tests/test_feature_map_selector.py +++ b/tests/test_feature_map_selector.py @@ -59,9 +59,7 @@ def test_adaptive_target_path_clamps_within_window(Cls, data): def test_centers_match_cart_location_selector(Cls, data): X, y = data t = Cls(output_dim=5, target_aware=True, task="regression").fit(X, y) - expected = CARTLocationSelector().select( - X[:, 0], y, task="regression", min_count=5, max_count=5 - ) + expected = CARTLocationSelector().select(X[:, 0], y, task="regression", min_count=5, max_count=5) np.testing.assert_array_equal(t.centers_[0], expected) @@ -84,9 +82,7 @@ def test_invalid_selector_raises(data): def test_quantile_path_ignores_selector(data): """The unsupervised quantile path yields exactly ``output_dim`` centers.""" X, _ = data - t = RBFExpansionTransformer( - output_dim=7, target_aware=False, placement_strategy="quantile" - ).fit(X) + t = RBFExpansionTransformer(output_dim=7, target_aware=False, placement_strategy="quantile").fit(X) assert all(len(c) == 7 for c in t.centers_) diff --git a/tests/test_feature_names_out.py b/tests/test_feature_names_out.py index a6f95ff..73f9ab0 100644 --- a/tests/test_feature_names_out.py +++ b/tests/test_feature_names_out.py @@ -20,9 +20,7 @@ def _num(): @pytest.mark.parametrize("transformer", _num()) def test_numeric_encoders_default_names(transformer): transformer.fit(np.zeros((5, 2))) - np.testing.assert_array_equal( - transformer.get_feature_names_out(), np.asarray(["x0", "x1"], dtype=object) - ) + np.testing.assert_array_equal(transformer.get_feature_names_out(), np.asarray(["x0", "x1"], dtype=object)) @pytest.mark.parametrize("transformer", _num()) @@ -37,9 +35,7 @@ def test_numeric_encoders_passthrough_names(transformer): def test_continuous_ordinal_default_names(): X = np.array([["a", "x"], ["b", "y"], ["a", "x"]], dtype=object) transformer = ContinuousOrdinalTransformer().fit(X) - np.testing.assert_array_equal( - transformer.get_feature_names_out(), np.asarray(["x0", "x1"], dtype=object) - ) + np.testing.assert_array_equal(transformer.get_feature_names_out(), np.asarray(["x0", "x1"], dtype=object)) def test_continuous_ordinal_passthrough_names(): diff --git a/tests/test_location_selectors.py b/tests/test_location_selectors.py index 15fe2e3..d7577f7 100644 --- a/tests/test_location_selectors.py +++ b/tests/test_location_selectors.py @@ -92,9 +92,7 @@ def test_cart_matches_knot_adapter(data): def test_lightgbm_select_runs(data): pytest.importorskip("lightgbm") X, y = data - locations = LightGBMLocationSelector(n_estimators=30).select( - X, y, task="regression", min_count=2, max_count=10 - ) + locations = LightGBMLocationSelector(n_estimators=30).select(X, y, task="regression", min_count=2, max_count=10) assert locations.ndim == 1 assert np.all(np.diff(locations) > 0) diff --git a/tests/test_locations.py b/tests/test_locations.py index 9b58474..125b376 100644 --- a/tests/test_locations.py +++ b/tests/test_locations.py @@ -54,9 +54,7 @@ def supplement(current, target): calls["args"] = (current.copy(), target) return np.array([1.0, 2.0, 3.0]) - out = resolve_locations( - np.array([5.0]), min_count=3, max_count=8, supplement=supplement - ) + out = resolve_locations(np.array([5.0]), min_count=3, max_count=8, supplement=supplement) np.testing.assert_array_equal(out, [1.0, 2.0, 3.0]) assert calls["args"][1] == 3 diff --git a/tests/test_method_aliases.py b/tests/test_method_aliases.py index 4f5e03f..2e02325 100644 --- a/tests/test_method_aliases.py +++ b/tests/test_method_aliases.py @@ -117,7 +117,9 @@ def sample_data(): def test_numerical_alias_matches_canonical_output(sample_data, alias, canonical): X, y = sample_data out_alias = Preprocessor(numerical_method=alias, categorical_method="int").fit_transform(X, y, return_array=True) - out_canon = Preprocessor(numerical_method=canonical, categorical_method="int").fit_transform(X, y, return_array=True) + out_canon = Preprocessor(numerical_method=canonical, categorical_method="int").fit_transform( + X, y, return_array=True + ) np.testing.assert_allclose(out_alias, out_canon) @@ -128,7 +130,9 @@ def test_numerical_alias_matches_canonical_output(sample_data, alias, canonical) def test_categorical_alias_matches_canonical_output(sample_data, alias, canonical): X, y = sample_data out_alias = Preprocessor(numerical_method="minmax", categorical_method=alias).fit_transform(X, y, return_array=True) - out_canon = Preprocessor(numerical_method="minmax", categorical_method=canonical).fit_transform(X, y, return_array=True) + out_canon = Preprocessor(numerical_method="minmax", categorical_method=canonical).fit_transform( + X, y, return_array=True + ) np.testing.assert_allclose(out_alias, out_canon) diff --git a/tests/test_onehot_from_ordinal_transformer.py b/tests/test_onehot_from_ordinal_transformer.py index 2fe7648..6e85723 100644 --- a/tests/test_onehot_from_ordinal_transformer.py +++ b/tests/test_onehot_from_ordinal_transformer.py @@ -1,5 +1,6 @@ import numpy as np import pytest + from pretab.transformers import OneHotFromOrdinalTransformer diff --git a/tests/test_ple_selector.py b/tests/test_ple_selector.py index 2a0f63c..91495d6 100644 --- a/tests/test_ple_selector.py +++ b/tests/test_ple_selector.py @@ -39,9 +39,7 @@ def test_non_adaptive_target_path_gives_exact_output_dim(data): def test_adaptive_target_path_clamps_within_window(data): X, y = data - t = PLETransformer( - output_dim=10, adaptive=True, min_output_dim=3, max_output_dim=6 - ).fit(X, y) + t = PLETransformer(output_dim=10, adaptive=True, min_output_dim=3, max_output_dim=6).fit(X, y) assert all(3 <= n <= 6 for n in t.n_bins_per_feature_) assert all(2 <= len(th) <= 5 for th in t.thresholds_) @@ -49,9 +47,7 @@ def test_adaptive_target_path_clamps_within_window(data): def test_thresholds_match_cart_location_selector(data): X, y = data t = PLETransformer(output_dim=5, task="regression").fit(X, y) - expected = CARTLocationSelector().select( - X[:, 0], y, task="regression", min_count=4, max_count=4 - ) + expected = CARTLocationSelector().select(X[:, 0], y, task="regression", min_count=4, max_count=4) np.testing.assert_array_equal(t.thresholds_[0], expected) diff --git a/tests/test_preprocessor.py b/tests/test_preprocessor.py index 7222f82..908d7e3 100644 --- a/tests/test_preprocessor.py +++ b/tests/test_preprocessor.py @@ -1,9 +1,10 @@ -import pytest import numpy as np import pandas as pd +import pytest from sklearn.base import clone from sklearn.exceptions import NotFittedError from sklearn.utils.validation import check_is_fitted + from pretab.preprocessor import Preprocessor # Adjust the import as needed @@ -239,9 +240,7 @@ def test_output_dims_nonuniform_for_one_hot_categorical(): } ) y = pd.Series(np.random.randn(30)) - pre = Preprocessor( - numerical_method="minmax", categorical_method="one-hot" - ).fit(X, y) + pre = Preprocessor(numerical_method="minmax", categorical_method="one-hot").fit(X, y) dims = pre.output_dims_ assert dims["num"] == 1 assert dims["cat"] == 3 # one-hot of three categories @@ -253,4 +252,3 @@ def test_output_dims_and_total_before_fit_raise(): _ = Preprocessor().output_dims_ with pytest.raises(NotFittedError): _ = Preprocessor().total_output_dim_ - diff --git a/tests/test_rbfexpansion_transformer.py b/tests/test_rbfexpansion_transformer.py index 0b8d16d..2f9028b 100644 --- a/tests/test_rbfexpansion_transformer.py +++ b/tests/test_rbfexpansion_transformer.py @@ -1,6 +1,7 @@ import numpy as np import pytest from sklearn.utils.validation import check_is_fitted + from pretab.transformers import RBFExpansionTransformer @@ -20,9 +21,7 @@ def y_regression(): def test_rbf_uniform_single_feature(X_single_feature): - transformer = RBFExpansionTransformer( - output_dim=5, target_aware=False, placement_strategy="uniform" - ) + transformer = RBFExpansionTransformer(output_dim=5, target_aware=False, placement_strategy="uniform") transformer.fit(X_single_feature) Xt = transformer.transform(X_single_feature) @@ -32,9 +31,7 @@ def test_rbf_uniform_single_feature(X_single_feature): def test_rbf_quantile_multi_feature(X_multi_feature): - transformer = RBFExpansionTransformer( - output_dim=4, target_aware=False, placement_strategy="quantile" - ) + transformer = RBFExpansionTransformer(output_dim=4, target_aware=False, placement_strategy="quantile") transformer.fit(X_multi_feature) Xt = transformer.transform(X_multi_feature) @@ -64,7 +61,7 @@ def test_rbf_invalid_task(): def test_rbf_missing_target_with_tree(X_single_feature): transformer = RBFExpansionTransformer(target_aware=True) - with pytest.raises(ValueError, match="Target variable.*must be provided"): + with pytest.raises(ValueError, match=r"Target variable.*must be provided"): transformer.fit(X_single_feature) diff --git a/tests/test_reluexpansion_transformer.py b/tests/test_reluexpansion_transformer.py index 20ba1c6..8004c5f 100644 --- a/tests/test_reluexpansion_transformer.py +++ b/tests/test_reluexpansion_transformer.py @@ -1,7 +1,9 @@ +import warnings + import numpy as np import pytest -import warnings from sklearn.utils.validation import check_is_fitted + from pretab.transformers import ReLUExpansionTransformer @@ -21,9 +23,7 @@ def y_regression(): def test_relu_uniform_single_feature(X_single_feature): - transformer = ReLUExpansionTransformer( - output_dim=4, target_aware=False, placement_strategy="uniform" - ) + transformer = ReLUExpansionTransformer(output_dim=4, target_aware=False, placement_strategy="uniform") transformer.fit(X_single_feature) Xt = transformer.transform(X_single_feature) @@ -32,9 +32,7 @@ def test_relu_uniform_single_feature(X_single_feature): def test_relu_quantile_multi_feature(X_multi_feature): - transformer = ReLUExpansionTransformer( - output_dim=5, target_aware=False, placement_strategy="quantile" - ) + transformer = ReLUExpansionTransformer(output_dim=5, target_aware=False, placement_strategy="quantile") transformer.fit(X_multi_feature) Xt = transformer.transform(X_multi_feature) @@ -63,7 +61,7 @@ def test_relu_invalid_task(): def test_relu_missing_y_tree(X_single_feature): transformer = ReLUExpansionTransformer(target_aware=True) - with pytest.raises(ValueError, match="Target variable.*must be provided"): + with pytest.raises(ValueError, match=r"Target variable.*must be provided"): transformer.fit(X_single_feature) diff --git a/tests/test_reproducibility.py b/tests/test_reproducibility.py index c7e3368..75377e1 100644 --- a/tests/test_reproducibility.py +++ b/tests/test_reproducibility.py @@ -39,6 +39,7 @@ def _numerical_transformer(pre, feature): # --- exposure & round-trip ------------------------------------------------- # + def test_new_params_defaults_and_get_params(): pre = Preprocessor() assert pre.random_state is None @@ -58,6 +59,7 @@ def test_clone_preserves_new_params(): # --- random_state forwarding ----------------------------------------------- # + @pytest.mark.parametrize("method", ["ple", "rbf"]) def test_random_state_forwarded_when_set(data, method): X, y = data @@ -68,14 +70,10 @@ def test_random_state_forwarded_when_set(data, method): def test_unset_random_state_preserves_component_defaults(data): X, y = data # PLE keeps its own default seed (51) when the Preprocessor seed is unset. - ple = _numerical_transformer( - Preprocessor(numerical_method="ple").fit(X, y), "a" - ) + ple = _numerical_transformer(Preprocessor(numerical_method="ple").fit(X, y), "a") assert ple.random_state == 51 # Feature maps stay unseeded (None) when the Preprocessor seed is unset. - rbf = _numerical_transformer( - Preprocessor(numerical_method="rbf").fit(X, y), "a" - ) + rbf = _numerical_transformer(Preprocessor(numerical_method="rbf").fit(X, y), "a") assert rbf.random_state is None @@ -91,6 +89,7 @@ def test_fixed_random_state_makes_fit_reproducible(data, method): # --- handle_missing policy ------------------------------------------------- # + def test_handle_missing_forwarded_to_ple(data): X, y = data pre = Preprocessor(numerical_method="ple", handle_missing="error").fit(X, y) @@ -120,9 +119,10 @@ def test_handle_missing_error_rejects_nan(data): # --- transformer / helper level seeding ------------------------------------ # + def test_rbf_transformer_seeded_centers_reproducible(data): X, y = data r1 = RBFExpansionTransformer(output_dim=5, target_aware=True, random_state=3).fit(X.values, y.values) r2 = RBFExpansionTransformer(output_dim=5, target_aware=True, random_state=3).fit(X.values, y.values) - for a, b in zip(r1.centers_, r2.centers_): + for a, b in zip(r1.centers_, r2.centers_, strict=True): np.testing.assert_array_equal(a, b) diff --git a/tests/test_sigmoidexpansion_transformer.py b/tests/test_sigmoidexpansion_transformer.py index e162e3d..9c21275 100644 --- a/tests/test_sigmoidexpansion_transformer.py +++ b/tests/test_sigmoidexpansion_transformer.py @@ -1,7 +1,9 @@ +import warnings + import numpy as np import pytest -import warnings from sklearn.utils.validation import check_is_fitted + from pretab.transformers import SigmoidExpansionTransformer @@ -21,9 +23,7 @@ def y_regression(): def test_sigmoid_uniform_single_feature(X_single_feature): - transformer = SigmoidExpansionTransformer( - output_dim=4, target_aware=False, placement_strategy="uniform", scale=0.5 - ) + transformer = SigmoidExpansionTransformer(output_dim=4, target_aware=False, placement_strategy="uniform", scale=0.5) transformer.fit(X_single_feature) Xt = transformer.transform(X_single_feature) @@ -33,9 +33,7 @@ def test_sigmoid_uniform_single_feature(X_single_feature): def test_sigmoid_quantile_multi_feature(X_multi_feature): - transformer = SigmoidExpansionTransformer( - output_dim=5, target_aware=False, placement_strategy="quantile" - ) + transformer = SigmoidExpansionTransformer(output_dim=5, target_aware=False, placement_strategy="quantile") transformer.fit(X_multi_feature) Xt = transformer.transform(X_multi_feature) @@ -66,7 +64,7 @@ def test_sigmoid_invalid_task(): def test_sigmoid_missing_y_tree(X_single_feature): transformer = SigmoidExpansionTransformer(target_aware=True) - with pytest.raises(ValueError, match="Target variable.*must be provided"): + with pytest.raises(ValueError, match=r"Target variable.*must be provided"): transformer.fit(X_single_feature) @@ -79,9 +77,7 @@ def test_sigmoid_feature_mismatch(X_multi_feature, y_regression): def test_sigmoid_no_overflow_on_large_inputs(): # Large-magnitude values used to trigger "overflow encountered in exp". - transformer = SigmoidExpansionTransformer( - output_dim=4, target_aware=False, placement_strategy="uniform" - ) + transformer = SigmoidExpansionTransformer(output_dim=4, target_aware=False, placement_strategy="uniform") transformer.fit(np.linspace(-1, 1, 10).reshape(-1, 1)) X_extreme = np.array([[-1000.0], [1000.0]]) with warnings.catch_warnings(): @@ -90,4 +86,3 @@ def test_sigmoid_no_overflow_on_large_inputs(): assert np.isfinite(Xt).all() assert (Xt >= 0).all() assert (Xt <= 1).all() - diff --git a/tests/test_sklearn_compat.py b/tests/test_sklearn_compat.py index f142da7..acd7dc4 100644 --- a/tests/test_sklearn_compat.py +++ b/tests/test_sklearn_compat.py @@ -52,14 +52,8 @@ "check_parameters_default_constructible rejects. Closing this needs an alias " "redesign that drops sentinel defaults." ) -REQUIRES_Y_NONE = ( - "Supervised transformer does not yet raise a clear message when y=None is " - "passed to fit." -) -DTYPE = ( - "Numeric encoder casts to float output and does not accept/preserve object " - "dtype input." -) +REQUIRES_Y_NONE = "Supervised transformer does not yet raise a clear message when y=None is passed to fit." +DTYPE = "Numeric encoder casts to float output and does not accept/preserve object dtype input." # --- near-conformant tier: (estimator, expected_failed_checks) ------------- # @@ -131,8 +125,7 @@ def test_check_estimator_near_conformant(estimator, expected_failed_checks): ThinPlateSplineTransformer(), id="ThinPlateSplineTransformer", marks=pytest.mark.xfail( - reason="Univariate-only (single input feature); incompatible with the " - "multi-feature transformer checks.", + reason="Univariate-only (single input feature); incompatible with the multi-feature transformer checks.", strict=True, ), ), @@ -203,8 +196,7 @@ def test_check_estimator_near_conformant(estimator, expected_failed_checks): OneHotFromOrdinalTransformer(), id="OneHotFromOrdinalTransformer", marks=pytest.mark.xfail( - reason="Categorical one-hot encoder; expects integer-coded input and " - "does not use numeric validate_data.", + reason="Categorical one-hot encoder; expects integer-coded input and does not use numeric validate_data.", strict=True, ), ), diff --git a/tests/test_spline_expansions.py b/tests/test_spline_expansions.py index 40e50e2..7fa7431 100644 --- a/tests/test_spline_expansions.py +++ b/tests/test_spline_expansions.py @@ -118,9 +118,7 @@ def test_ispline_shape_multi_feature(): def test_spline_with_cart_knot_selector(data): X, y = data - transformer = BSplineTransformer( - output_dim=8, include_bias=False, target_aware=True, placement_strategy="cart" - ) + transformer = BSplineTransformer(output_dim=8, include_bias=False, target_aware=True, placement_strategy="cart") Xt = transformer.fit_transform(X, y) assert Xt.shape == (200, 8) assert np.isfinite(Xt).all() diff --git a/tests/test_tanh_transformer.py b/tests/test_tanh_transformer.py index 07cdb5e..a72e879 100644 --- a/tests/test_tanh_transformer.py +++ b/tests/test_tanh_transformer.py @@ -1,6 +1,8 @@ +import warnings + import numpy as np import pytest -import warnings + from pretab.transformers import TanhExpansionTransformer diff --git a/tests/test_temporal.py b/tests/test_temporal.py index 5a136c7..186085f 100644 --- a/tests/test_temporal.py +++ b/tests/test_temporal.py @@ -49,9 +49,7 @@ def test_lag_default_single_lag(): def test_lag_feature_names(): X = np.arange(6).reshape(-1, 1) transformer = LagFeatureTransformer(n_lags=2).fit(X) - np.testing.assert_array_equal( - transformer.get_feature_names_out(["t"]), ["t_lag0", "t_lag1"] - ) + np.testing.assert_array_equal(transformer.get_feature_names_out(["t"]), ["t_lag0", "t_lag1"]) # --------------------------------------------------------------------------- # @@ -78,9 +76,7 @@ def test_rolling_min_max_columns(): def test_rolling_feature_names(): X = np.arange(10).reshape(-1, 1).astype(float) transformer = RollingStatsTransformer(window_size=3, stats=("mean", "std")).fit(X) - np.testing.assert_array_equal( - transformer.get_feature_names_out(["t"]), ["t_roll0", "t_roll1"] - ) + np.testing.assert_array_equal(transformer.get_feature_names_out(["t"]), ["t_roll0", "t_roll1"]) # --------------------------------------------------------------------------- # @@ -114,6 +110,4 @@ def test_cyclic_requires_period(): def test_cyclic_feature_names(): X = np.array([[0], [6], [12], [18]]) transformer = CyclicalTimeTransformer(period=24).fit(X) - np.testing.assert_array_equal( - transformer.get_feature_names_out(["hour"]), ["hour_cyclic0", "hour_cyclic1"] - ) + np.testing.assert_array_equal(transformer.get_feature_names_out(["hour"]), ["hour_cyclic0", "hour_cyclic1"]) diff --git a/tests/test_verbosity.py b/tests/test_verbosity.py index c7849af..e5007ce 100644 --- a/tests/test_verbosity.py +++ b/tests/test_verbosity.py @@ -81,9 +81,7 @@ def test_verbose_2_logs_feature_table(sample_data, caplog): caplog.set_level(logging.DEBUG, logger="pretab") Preprocessor(numerical_method="ple", verbose=2).fit(X, y) assert "fit complete" in caplog.text # summary still emitted - debug_text = "\n".join( - r.getMessage() for r in caplog.records if r.levelno == logging.DEBUG - ) + debug_text = "\n".join(r.getMessage() for r in caplog.records if r.levelno == logging.DEBUG) assert "feature" in debug_text # table header assert "pipeline" in debug_text @@ -92,9 +90,7 @@ def test_verbose_3_logs_internal_decisions(sample_data, caplog): X, y = sample_data caplog.set_level(logging.DEBUG, logger="pretab") Preprocessor(numerical_method="ple", verbose=3).fit(X, y) - debug_text = "\n".join( - r.getMessage() for r in caplog.records if r.levelno == logging.DEBUG - ) + debug_text = "\n".join(r.getMessage() for r in caplog.records if r.levelno == logging.DEBUG) # Level 3 surfaces fitted internals (e.g. PLE thresholds / output width). assert "thresholds_" in debug_text or "total_output_dim_" in debug_text @@ -169,10 +165,7 @@ def test_set_verbosity_sets_logger_level(): def test_configure_logging_attaches_stream_handler_when_none(): logger = logging.getLogger("pretab") configure_logging(1) - assert any( - isinstance(h, logging.StreamHandler) and not isinstance(h, logging.NullHandler) - for h in logger.handlers - ) + assert any(isinstance(h, logging.StreamHandler) and not isinstance(h, logging.NullHandler) for h in logger.handlers) assert logger.level == logging.INFO From 1d4d01c13daeefbd488311c93775c07c372b3ac1 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 26 Jul 2026 11:43:05 +0200 Subject: [PATCH 02/59] build: rename knots extra to lightgbm and register smoke marker --- docs/getting_started/installation.md | 13 +++++++++++++ poetry.lock | 6 +++--- pyproject.toml | 5 ++++- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/docs/getting_started/installation.md b/docs/getting_started/installation.md index c2de614..66cfece 100644 --- a/docs/getting_started/installation.md +++ b/docs/getting_started/installation.md @@ -24,6 +24,19 @@ dependencies (including PyTorch), so it is a sizeable download. Add it only if y to use the `pretrained` categorical strategy. ``` +The `lightgbm` extra enables the gradient-boosted `placement_strategy="lightgbm"` for +supervised knot, center, and threshold selection: + +```bash +pip install "pretab[lightgbm]" +``` + +Use the convenience `all` extra to install every optional dependency at once: + +```bash +pip install "pretab[all]" +``` + ## From source pretab uses [Poetry](https://python-poetry.org/) for dependency management and diff --git a/poetry.lock b/poetry.lock index e534c75..cf14d69 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1610,7 +1610,7 @@ description = "LightGBM Python-package" optional = true python-versions = ">=3.7" groups = ["main"] -markers = "extra == \"knots\" or extra == \"all\"" +markers = "extra == \"lightgbm\" or extra == \"all\"" files = [ {file = "lightgbm-4.6.0-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:b7a393de8a334d5c8e490df91270f0763f83f959574d504c7ccb9eee4aef70ed"}, {file = "lightgbm-4.6.0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:2dafd98d4e02b844ceb0b61450a660681076b1ea6c7adb8c566dfd66832aafad"}, @@ -4933,9 +4933,9 @@ type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] [extras] all = ["lightgbm", "sentence-transformers"] embeddings = ["sentence-transformers"] -knots = ["lightgbm"] +lightgbm = ["lightgbm"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<3.14" -content-hash = "9284f76b116e86a2475762d9afb1a957ea14db64959dbe8e0e3e0a11bc47d5cd" +content-hash = "2a0bad6485988b0c36e131940e3f5df70bb2624604cfecb76ddd25b49eb1cab6" diff --git a/pyproject.toml b/pyproject.toml index 8475dcc..0b1db96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ dynamic = ["dependencies"] [project.optional-dependencies] embeddings = ["sentence-transformers>=2.0"] -knots = ["lightgbm>=4.0"] +lightgbm = ["lightgbm>=4.0"] all = ["sentence-transformers>=2.0", "lightgbm>=4.0"] [project.urls] @@ -60,6 +60,9 @@ accessible-pygments = ">=0.0.4" [tool.pytest.ini_options] pythonpath = ["."] testpaths = ["tests"] +markers = [ + "smoke: fast end-to-end sanity checks run as a dedicated CI gate", +] norecursedirs = [ "dev", "docs", From 1ceced50ebd115993bd10a66f146d44acc5094fa Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 26 Jul 2026 11:43:11 +0200 Subject: [PATCH 03/59] test: add regression golden baseline --- .../_golden/featuremap_unsupervised.json | 28 +++++ .../_golden/featuremap_unsupervised.npz | Bin 0 -> 25402 bytes tests/regression/_golden/ple_supervised.json | 31 +++++ tests/regression/_golden/ple_supervised.npz | Bin 0 -> 4583 bytes .../_golden/spline_unsupervised.json | 37 ++++++ .../_golden/spline_unsupervised.npz | Bin 0 -> 31130 bytes tests/regression/test_golden_baseline.py | 110 ++++++++++++++++++ 7 files changed, 206 insertions(+) create mode 100644 tests/regression/_golden/featuremap_unsupervised.json create mode 100644 tests/regression/_golden/featuremap_unsupervised.npz create mode 100644 tests/regression/_golden/ple_supervised.json create mode 100644 tests/regression/_golden/ple_supervised.npz create mode 100644 tests/regression/_golden/spline_unsupervised.json create mode 100644 tests/regression/_golden/spline_unsupervised.npz create mode 100644 tests/regression/test_golden_baseline.py diff --git a/tests/regression/_golden/featuremap_unsupervised.json b/tests/regression/_golden/featuremap_unsupervised.json new file mode 100644 index 0000000..78737a3 --- /dev/null +++ b/tests/regression/_golden/featuremap_unsupervised.json @@ -0,0 +1,28 @@ +{ + "shape": [ + 200, + 20 + ], + "feature_names": [ + "num_num_linear__num_linear_rbf0", + "num_num_linear__num_linear_rbf1", + "num_num_linear__num_linear_rbf2", + "num_num_linear__num_linear_rbf3", + "num_num_linear__num_linear_rbf4", + "num_num_linear__num_linear_rbf5", + "num_num_normal__num_normal_rbf0", + "num_num_normal__num_normal_rbf1", + "num_num_normal__num_normal_rbf2", + "num_num_normal__num_normal_rbf3", + "num_num_normal__num_normal_rbf4", + "num_num_normal__num_normal_rbf5", + "num_num_skewed__num_skewed_rbf0", + "num_num_skewed__num_skewed_rbf1", + "num_num_skewed__num_skewed_rbf2", + "num_num_skewed__num_skewed_rbf3", + "num_num_skewed__num_skewed_rbf4", + "num_num_skewed__num_skewed_rbf5", + "cat_cat_str__cat_str", + "cat_cat_int__cat_int" + ] +} \ No newline at end of file diff --git a/tests/regression/_golden/featuremap_unsupervised.npz b/tests/regression/_golden/featuremap_unsupervised.npz new file mode 100644 index 0000000000000000000000000000000000000000..3e019007aa1690484bde4b625c77d26e0956d42e GIT binary patch literal 25402 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 literal 0 HcmV?d00001 diff --git a/tests/regression/_golden/ple_supervised.json b/tests/regression/_golden/ple_supervised.json new file mode 100644 index 0000000..c8423ab --- /dev/null +++ b/tests/regression/_golden/ple_supervised.json @@ -0,0 +1,31 @@ +{ + "shape": [ + 200, + 23 + ], + "feature_names": [ + "num_num_linear__num_linear_ple_piece0", + "num_num_linear__num_linear_ple_piece1", + "num_num_linear__num_linear_ple_piece2", + "num_num_linear__num_linear_ple_piece3", + "num_num_linear__num_linear_ple_piece4", + "num_num_normal__num_normal_ple_piece0", + "num_num_normal__num_normal_ple_piece1", + "num_num_normal__num_normal_ple_piece2", + "num_num_normal__num_normal_ple_piece3", + "num_num_normal__num_normal_ple_piece4", + "num_num_skewed__num_skewed_ple_piece0", + "num_num_skewed__num_skewed_ple_piece1", + "num_num_skewed__num_skewed_ple_piece2", + "num_num_skewed__num_skewed_ple_piece3", + "num_num_skewed__num_skewed_ple_piece4", + "cat_cat_str__cat_str_alpha", + "cat_cat_str__cat_str_beta", + "cat_cat_str__cat_str_gamma", + "cat_cat_int__cat_int_0", + "cat_cat_int__cat_int_1", + "cat_cat_int__cat_int_2", + "cat_cat_int__cat_int_3", + "cat_cat_int__cat_int_4" + ] +} \ No newline at end of file diff --git a/tests/regression/_golden/ple_supervised.npz b/tests/regression/_golden/ple_supervised.npz new file mode 100644 index 0000000000000000000000000000000000000000..99c8b1721611b253270001905f36a719793b5eee GIT binary patch literal 4583 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 Date: Sun, 26 Jul 2026 11:43:19 +0200 Subject: [PATCH 04/59] ci: add CI workflow and install pandoc for docs build --- .github/workflows/ci.yml | 237 +++++++++++++++++++++++++++++++++++++ .github/workflows/docs.yml | 5 + 2 files changed, 242 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..52fa02b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,237 @@ +name: CI + +on: + workflow_dispatch: + push: + branches: + - main + pull_request: + branches: + - main + +concurrency: + group: ci-${{ github.head_ref || github.sha }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + lint: + name: Lint (ruff) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install Poetry + run: pipx install poetry + + - name: Configure Poetry + run: poetry config virtualenvs.in-project true + + - name: Cache virtualenv + uses: actions/cache@v4 + with: + path: .venv + key: venv-lint-${{ runner.os }}-3.10-${{ hashFiles('poetry.lock') }} + + - name: Install dependencies + run: poetry install + + - name: Run ruff check + run: poetry run ruff check . + + - name: Run ruff format check + run: poetry run ruff format --check . + + typecheck: + name: Type check (pyright) + runs-on: ubuntu-latest + # Advisory (non-blocking) during the 1.0.0 restructure: much of the code + # carrying pre-existing pyright errors (ple.py, cubic.py, preprocessor.py) + # is rewritten in Phases 1-5. Flip to a required check before the RC (P14.4). + continue-on-error: true + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install Poetry + run: pipx install poetry + + - name: Configure Poetry + run: poetry config virtualenvs.in-project true + + - name: Cache virtualenv + uses: actions/cache@v4 + with: + path: .venv + key: venv-typecheck-${{ runner.os }}-3.10-${{ hashFiles('poetry.lock') }} + + - name: Install dependencies + run: poetry install + + - name: Run pyright + run: poetry run pyright + + build: + name: Build package + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install Poetry + run: pipx install poetry + + - name: Configure Poetry + run: poetry config virtualenvs.in-project true + + - name: Cache virtualenv + uses: actions/cache@v4 + with: + path: .venv + key: venv-build-${{ runner.os }}-3.10-${{ hashFiles('poetry.lock') }} + + - name: Install dependencies + run: poetry install --only main + + - name: Build package + run: poetry build + + - name: Check package (twine) + run: | + python -m pip install --upgrade twine + twine check dist/* + + tests: + name: Tests (Python ${{ matrix.python-version }}, ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ["3.10", "3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Poetry + run: pipx install poetry + + - name: Configure Poetry + run: poetry config virtualenvs.in-project true + + - name: Cache virtualenv + uses: actions/cache@v4 + with: + path: .venv + key: venv-tests-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('poetry.lock') }} + + - name: Install dependencies + run: poetry install + + - name: Run unit tests + run: poetry run pytest tests/ -v + + smoke: + name: Smoke tests (Python 3.12, ubuntu) + runs-on: ubuntu-latest + needs: lint + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Poetry + run: pipx install poetry + + - name: Configure Poetry + run: poetry config virtualenvs.in-project true + + - name: Cache virtualenv + uses: actions/cache@v4 + with: + path: .venv + key: venv-smoke-${{ runner.os }}-3.12-${{ hashFiles('poetry.lock') }} + + - name: Install dependencies + run: poetry install + + - name: Run smoke tests + run: poetry run pytest tests/ -v -m smoke --tb=short + + coverage: + name: Coverage + runs-on: ubuntu-latest + needs: tests + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Poetry + run: pipx install poetry + + - name: Configure Poetry + run: poetry config virtualenvs.in-project true + + - name: Cache virtualenv + uses: actions/cache@v4 + with: + path: .venv + key: venv-coverage-${{ runner.os }}-3.12-${{ hashFiles('poetry.lock') }} + + - name: Install dependencies + run: poetry install + + - name: Run tests with coverage + run: | + poetry run pytest tests/ \ + --cov=pretab \ + --cov-branch \ + --cov-report=term-missing \ + --cov-report=xml:coverage.xml \ + -q + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage.xml + retention-days: 30 + + - name: Upload to Codecov + uses: codecov/codecov-action@v4 + with: + files: coverage.xml + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index d5461aa..1330fdf 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -50,6 +50,11 @@ jobs: - name: Install package and docs dependencies run: poetry install --with docs + # pandoc is required once the P14.1 notebook tutorials (nbsphinx / myst-nb) + # land; installing it now keeps the strict build forward-compatible. + - name: Install pandoc + run: sudo apt-get update && sudo apt-get install -y pandoc + - name: Build Sphinx docs run: poetry run sphinx-build -b html docs docs/_build/html -W --keep-going From 04dbda654595c217ad6f950e569f3a90c35209c2 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 26 Jul 2026 12:35:01 +0200 Subject: [PATCH 05/59] refactor(layout): restructure package toward 1.0 and drop compat shims --- pretab/__init__.py | 2 +- pretab/compose/__init__.py | 6 +++ pretab/core/__init__.py | 8 +-- pretab/core/adaptive.py | 2 +- pretab/core/base.py | 2 +- pretab/core/dependencies.py | 53 +++++++++++++++++++ pretab/core/knots.py | 2 +- pretab/core/logging.py | 2 +- pretab/core/{params.py => parameters.py} | 6 +-- pretab/core/selectors.py | 8 +-- pretab/core/typing.py | 31 +++++++++++ pretab/core/validation.py | 2 +- pretab/{core => }/exceptions.py | 0 pretab/pipeline/categorical.py | 14 ++--- pretab/pipeline/numerical.py | 2 +- pretab/pipeline/registry.py | 22 ++++---- pretab/placement/__init__.py | 7 +++ pretab/preprocessor.py | 6 +-- pretab/transformers/__init__.py | 17 +++--- pretab/transformers/binning/__init__.py | 3 -- pretab/transformers/categorical/__init__.py | 14 +++++ .../language_embedding.py} | 2 +- .../onehot.py => categorical/legacy.py} | 0 .../ordinal.py} | 0 pretab/transformers/embeddings/__init__.py | 3 -- pretab/transformers/encoders/__init__.py | 6 +-- .../feature_maps/{_base.py => base.py} | 6 +-- pretab/transformers/feature_maps/rbf.py | 4 +- pretab/transformers/feature_maps/relu.py | 4 +- pretab/transformers/feature_maps/sigmoid.py | 4 +- pretab/transformers/feature_maps/tanh.py | 4 +- pretab/transformers/numerical/__init__.py | 14 +++++ .../{binning => numerical}/binning.py | 6 +-- .../cyclic.py => numerical/periodic.py} | 2 +- .../{ple/ple.py => numerical/piecewise.py} | 8 +-- pretab/transformers/onehot/__init__.py | 3 -- pretab/transformers/ple/__init__.py | 3 -- pretab/transformers/splines/__init__.py | 14 ++--- .../splines/{bspline.py => b_spline.py} | 2 +- pretab/transformers/splines/base_spline.py | 12 ++--- .../splines/{cubic.py => cubic_regression.py} | 4 +- .../{integrated_spline.py => i_spline.py} | 2 +- pretab/transformers/splines/knot_selectors.py | 6 +-- .../splines/{mspline.py => m_spline.py} | 2 +- pretab/transformers/splines/mixins.py | 2 +- .../splines/multivariate/__init__.py | 13 +++++ .../{ => multivariate}/tensor_product.py | 8 +-- .../thin_plate.py} | 4 +- pretab/transformers/splines/natural_cubic.py | 4 +- .../splines/{pspline.py => p_spline.py} | 4 +- pretab/transformers/temporal/__init__.py | 8 +-- pretab/transformers/temporal/lag.py | 2 +- pretab/transformers/temporal/rolling_stats.py | 2 +- pretab/utils/__init__.py | 16 ------ pretab/utils/get_categorical.py | 10 ---- pretab/utils/get_numerical.py | 10 ---- tests/test_adaptive_output_dim.py | 8 +-- tests/test_custombin_transformer.py | 2 +- tests/test_exceptions.py | 6 +-- tests/test_feature_map_selector.py | 2 +- tests/test_language_embedding_transformer.py | 2 +- tests/test_location_selectors.py | 2 +- tests/test_method_aliases.py | 2 +- tests/test_ple_selector.py | 2 +- tests/test_spline_api_parity.py | 2 +- tests/test_temporal.py | 2 +- tests/test_verbosity.py | 2 +- 67 files changed, 263 insertions(+), 172 deletions(-) create mode 100644 pretab/compose/__init__.py create mode 100644 pretab/core/dependencies.py rename pretab/core/{params.py => parameters.py} (97%) create mode 100644 pretab/core/typing.py rename pretab/{core => }/exceptions.py (100%) create mode 100644 pretab/placement/__init__.py delete mode 100644 pretab/transformers/binning/__init__.py create mode 100644 pretab/transformers/categorical/__init__.py rename pretab/transformers/{embeddings/language_transformer.py => categorical/language_embedding.py} (98%) rename pretab/transformers/{onehot/onehot.py => categorical/legacy.py} (100%) rename pretab/transformers/{encoders/continuous_ordinal.py => categorical/ordinal.py} (100%) delete mode 100644 pretab/transformers/embeddings/__init__.py rename pretab/transformers/feature_maps/{_base.py => base.py} (98%) create mode 100644 pretab/transformers/numerical/__init__.py rename pretab/transformers/{binning => numerical}/binning.py (96%) rename pretab/transformers/{temporal/cyclic.py => numerical/periodic.py} (97%) rename pretab/transformers/{ple/ple.py => numerical/piecewise.py} (99%) delete mode 100644 pretab/transformers/onehot/__init__.py delete mode 100644 pretab/transformers/ple/__init__.py rename pretab/transformers/splines/{bspline.py => b_spline.py} (98%) rename pretab/transformers/splines/{cubic.py => cubic_regression.py} (98%) rename pretab/transformers/splines/{integrated_spline.py => i_spline.py} (99%) rename pretab/transformers/splines/{mspline.py => m_spline.py} (98%) create mode 100644 pretab/transformers/splines/multivariate/__init__.py rename pretab/transformers/splines/{ => multivariate}/tensor_product.py (98%) rename pretab/transformers/splines/{thinplate_spline.py => multivariate/thin_plate.py} (98%) rename pretab/transformers/splines/{pspline.py => p_spline.py} (99%) delete mode 100644 pretab/utils/__init__.py delete mode 100644 pretab/utils/get_categorical.py delete mode 100644 pretab/utils/get_numerical.py diff --git a/pretab/__init__.py b/pretab/__init__.py index b6810ec..ed42267 100644 --- a/pretab/__init__.py +++ b/pretab/__init__.py @@ -1,6 +1,6 @@ from ._version import __version__ -from .core.exceptions import PretabWarning from .core.logging import configure_logging, set_verbosity +from .exceptions import PretabWarning from .preprocessor import Preprocessor __all__ = [ diff --git a/pretab/compose/__init__.py b/pretab/compose/__init__.py new file mode 100644 index 0000000..d333b8b --- /dev/null +++ b/pretab/compose/__init__.py @@ -0,0 +1,6 @@ +"""Composition subsystem: which transformer applies to which column, and how the +per-column pipelines are combined into a single :class:`~sklearn.compose.ColumnTransformer`. + +Populated during the 1.0.0 restructure (Phase 3): ``config``, ``registry``, +``factory``, ``feature_detection``, ``output`` and ``inspection`` modules. +""" diff --git a/pretab/core/__init__.py b/pretab/core/__init__.py index 685b0a5..b337f22 100644 --- a/pretab/core/__init__.py +++ b/pretab/core/__init__.py @@ -5,9 +5,7 @@ user-facing transformers. It never defines user-facing transformers itself. """ -from .adaptive import AdaptiveResolutionMixin -from .base import BasePreTabTransformer -from .exceptions import ( +from ..exceptions import ( ConfigWarning, DataWarning, EmptyDataError, @@ -23,6 +21,8 @@ insufficient_samples_error, invalid_param_error, ) +from .adaptive import AdaptiveResolutionMixin +from .base import BasePreTabTransformer from .knots import ( basis_to_knots, generate_internal_knots, @@ -33,7 +33,7 @@ ) from .locations import resolve_locations, trim_to_count from .logging import get_logger -from .params import CANONICAL_PARAMS, UNSET, AliasResolverMixin, is_set +from .parameters import CANONICAL_PARAMS, UNSET, AliasResolverMixin, is_set from .selectors import ( BaseLocationSelector, CARTLocationSelector, diff --git a/pretab/core/adaptive.py b/pretab/core/adaptive.py index 3259725..39f08f6 100644 --- a/pretab/core/adaptive.py +++ b/pretab/core/adaptive.py @@ -14,7 +14,7 @@ family-specific floor (and optional ceiling) on the count. """ -from .exceptions import IncompatibleParamsError, InvalidParamError +from ..exceptions import IncompatibleParamsError, InvalidParamError __all__ = ["AdaptiveResolutionMixin"] diff --git a/pretab/core/base.py b/pretab/core/base.py index dbeab6b..fab5ce9 100644 --- a/pretab/core/base.py +++ b/pretab/core/base.py @@ -11,7 +11,7 @@ from sklearn.utils.validation import check_is_fitted from .adaptive import AdaptiveResolutionMixin -from .params import AliasResolverMixin +from .parameters import AliasResolverMixin from .validation import validate_2d_allow_nan __all__ = ["BasePreTabTransformer"] diff --git a/pretab/core/dependencies.py b/pretab/core/dependencies.py new file mode 100644 index 0000000..234ecd3 --- /dev/null +++ b/pretab/core/dependencies.py @@ -0,0 +1,53 @@ +"""Helpers for importing optional third-party dependencies. + +Each helper performs the lazy import and, on failure, raises a consistent, +actionable :class:`~pretab.exceptions.OptionalDependencyError` that names the +missing package and the extra that installs it. Centralizing this avoids the +duplicated ``try/except ImportError`` blocks that previously lived inside the +supervised selectors and the language-embedding transformer. +""" + +from __future__ import annotations + +import importlib +from types import ModuleType + +from ..exceptions import OptionalDependencyError + +__all__ = [ + "require_lightgbm", + "require_module", + "require_sentence_transformers", +] + + +def require_module(module_name: str, extra: str, purpose: str) -> ModuleType: + """Import ``module_name`` or raise :class:`OptionalDependencyError`. + + Parameters + ---------- + module_name : str + The importable module name (e.g. ``"lightgbm"``). + extra : str + The PreTab optional extra that installs it (e.g. ``"lightgbm"``), used to + build the ``pip install pretab[]`` hint. + purpose : str + Human-readable description of what needs the dependency, used to prefix + the error message (e.g. ``"LightGBM placement"``). + """ + try: + return importlib.import_module(module_name) + except ImportError as exc: + raise OptionalDependencyError( + f"{purpose} requires the optional '{module_name}' dependency. Install it with: pip install pretab[{extra}]" + ) from exc + + +def require_lightgbm(purpose: str = "This feature") -> ModuleType: + """Import and return ``lightgbm`` or raise a clear optional-dependency error.""" + return require_module("lightgbm", "lightgbm", purpose) + + +def require_sentence_transformers(purpose: str = "This feature") -> ModuleType: + """Import and return ``sentence_transformers`` or raise a clear error.""" + return require_module("sentence_transformers", "embeddings", purpose) diff --git a/pretab/core/knots.py b/pretab/core/knots.py index e5f3097..0d4254e 100644 --- a/pretab/core/knots.py +++ b/pretab/core/knots.py @@ -14,7 +14,7 @@ import numpy as np -from .exceptions import invalid_param_error +from ..exceptions import invalid_param_error __all__ = [ "basis_to_knots", diff --git a/pretab/core/logging.py b/pretab/core/logging.py index 4aed727..358d46f 100644 --- a/pretab/core/logging.py +++ b/pretab/core/logging.py @@ -8,7 +8,7 @@ import logging -from .exceptions import PretabWarning # re-exported for convenience +from ..exceptions import PretabWarning # re-exported for convenience __all__ = [ "PretabWarning", diff --git a/pretab/core/params.py b/pretab/core/parameters.py similarity index 97% rename from pretab/core/params.py rename to pretab/core/parameters.py index e2d4736..8d7e6df 100644 --- a/pretab/core/params.py +++ b/pretab/core/parameters.py @@ -24,7 +24,7 @@ import warnings from typing import Any, ClassVar -from .exceptions import InvalidParamError +from ..exceptions import InvalidParamError class _Unset: @@ -71,7 +71,7 @@ def validate_placement(target_aware: bool, placement_strategy: str) -> None: When ``target_aware`` is True the strategy must name a target-aware selector (``"cart"`` or ``"lightgbm"``); when False it must name an unsupervised spacing rule (``"uniform"`` or ``"quantile"``). Raises - :class:`~pretab.core.exceptions.InvalidParamError` (a ``ValueError``) otherwise. + :class:`~pretab.exceptions.InvalidParamError` (a ``ValueError``) otherwise. """ if target_aware and placement_strategy not in TARGET_AWARE_STRATEGIES: raise InvalidParamError("When target_aware=True, placement_strategy must be 'cart' or 'lightgbm'.") @@ -107,7 +107,7 @@ class AliasResolverMixin: effective value: an explicit canonical value wins, an explicit legacy alias is honoured with a ``FutureWarning``, and setting both a canonical and one of its aliases -- or two conflicting aliases -- raises - :class:`~pretab.core.exceptions.InvalidParamError`. + :class:`~pretab.exceptions.InvalidParamError`. """ _param_aliases: ClassVar[dict[str, str]] = {} diff --git a/pretab/core/selectors.py b/pretab/core/selectors.py index 8bc4f50..3e1fb6e 100644 --- a/pretab/core/selectors.py +++ b/pretab/core/selectors.py @@ -12,7 +12,7 @@ - :class:`CARTLocationSelector` fits a single decision tree and needs only scikit-learn, so it is always available. - :class:`LightGBMLocationSelector` fits a gradient boosted ensemble and requires - the optional ``lightgbm`` dependency (``pip install pretab[knots]``). + the optional ``lightgbm`` dependency (``pip install pretab[lightgbm]``). Both share the :class:`BaseLocationSelector` template, which handles input validation, small-sample quantile fallbacks, minimum spacing, and topping up or @@ -25,7 +25,7 @@ import numpy as np from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor -from .exceptions import IncompatibleParamsError, OptionalDependencyError +from ..exceptions import IncompatibleParamsError, OptionalDependencyError from .knots import quantile_knots Task = Literal["regression", "classification"] @@ -274,7 +274,7 @@ class LightGBMLocationSelector(BaseLocationSelector): tends to find informative locations that a single tree can miss. Requires the optional ``lightgbm`` dependency, installable with - ``pip install pretab[knots]``. + ``pip install pretab[lightgbm]``. Parameters ---------- @@ -317,7 +317,7 @@ def _import_lightgbm(): except ImportError as exc: raise OptionalDependencyError( "LightGBMLocationSelector requires the optional 'lightgbm' dependency. " - "Install it with: pip install pretab[knots]" + "Install it with: pip install pretab[lightgbm]" ) from exc return lgb diff --git a/pretab/core/typing.py b/pretab/core/typing.py new file mode 100644 index 0000000..1d451d3 --- /dev/null +++ b/pretab/core/typing.py @@ -0,0 +1,31 @@ +"""Shared type aliases for PreTab's public and internal signatures. + +Centralizing these keeps transformer, placement and compose signatures consistent +and gives a single place to evolve the accepted input/target types. +""" + +from __future__ import annotations + +from typing import Literal + +import numpy as np +import pandas as pd + +# Accepted feature-matrix inputs. +ArrayLike = np.ndarray | pd.DataFrame | pd.Series | list + +# Accepted supervision targets (``None`` for unsupervised transforms). +TargetLike = np.ndarray | pd.Series | list | None + +# Canonical placement-strategy vocabulary (see :mod:`pretab.core.parameters`). +PlacementStrategyName = Literal["uniform", "quantile", "cart", "lightgbm"] + +# Supervised-task discriminator used by the supervised placement selectors. +Task = Literal["regression", "classification"] + +__all__ = [ + "ArrayLike", + "PlacementStrategyName", + "TargetLike", + "Task", +] diff --git a/pretab/core/validation.py b/pretab/core/validation.py index f13b4eb..c49d8b8 100644 --- a/pretab/core/validation.py +++ b/pretab/core/validation.py @@ -12,7 +12,7 @@ import numpy as np from sklearn.utils.validation import check_array -from .exceptions import DataWarning, PretabDataError +from ..exceptions import DataWarning, PretabDataError __all__ = ["validate_2d_allow_nan"] diff --git a/pretab/core/exceptions.py b/pretab/exceptions.py similarity index 100% rename from pretab/core/exceptions.py rename to pretab/exceptions.py diff --git a/pretab/pipeline/categorical.py b/pretab/pipeline/categorical.py index c74e703..527b9ed 100644 --- a/pretab/pipeline/categorical.py +++ b/pretab/pipeline/categorical.py @@ -1,13 +1,15 @@ from sklearn.impute import SimpleImputer from sklearn.preprocessing import OneHotEncoder -from ..core.exceptions import invalid_param_error -from ..core.params import UNSET -from ..transformers.binning import CustomBinTransformer -from ..transformers.embeddings import LanguageEmbeddingTransformer -from ..transformers.encoders.continuous_ordinal import ContinuousOrdinalTransformer +from ..core.parameters import UNSET +from ..exceptions import invalid_param_error +from ..transformers.categorical.language_embedding import ( + LanguageEmbeddingTransformer, +) +from ..transformers.categorical.legacy import OneHotFromOrdinalTransformer +from ..transformers.categorical.ordinal import ContinuousOrdinalTransformer from ..transformers.encoders.floats import NoTransformer, ToFloatTransformer -from ..transformers.onehot import OneHotFromOrdinalTransformer +from ..transformers.numerical.binning import CustomBinTransformer from .registry import CATEGORICAL_ALIASES, CATEGORICAL_METHODS, resolve_method diff --git a/pretab/pipeline/numerical.py b/pretab/pipeline/numerical.py index 3eca776..43c7806 100644 --- a/pretab/pipeline/numerical.py +++ b/pretab/pipeline/numerical.py @@ -3,7 +3,7 @@ from sklearn.impute import SimpleImputer from sklearn.preprocessing import MinMaxScaler, StandardScaler -from ..core.exceptions import ConfigWarning, invalid_param_error +from ..exceptions import ConfigWarning, invalid_param_error from .registry import NUMERICAL_ALIASES, NUMERICAL_METHODS, resolve_method # Spline basis expansions that share the target-aware knot API. diff --git a/pretab/pipeline/registry.py b/pretab/pipeline/registry.py index d1b0d44..802bd08 100644 --- a/pretab/pipeline/registry.py +++ b/pretab/pipeline/registry.py @@ -20,21 +20,25 @@ StandardScaler, ) -from ..transformers.binning.binning import CustomBinTransformer from ..transformers.encoders.floats import NoTransformer from ..transformers.feature_maps.rbf import RBFExpansionTransformer from ..transformers.feature_maps.relu import ReLUExpansionTransformer from ..transformers.feature_maps.sigmoid import SigmoidExpansionTransformer from ..transformers.feature_maps.tanh import TanhExpansionTransformer -from ..transformers.ple.ple import PLETransformer -from ..transformers.splines.bspline import BSplineTransformer -from ..transformers.splines.cubic import CubicSplineTransformer -from ..transformers.splines.integrated_spline import ISplineTransformer -from ..transformers.splines.mspline import MSplineTransformer +from ..transformers.numerical.binning import CustomBinTransformer +from ..transformers.numerical.piecewise import PLETransformer +from ..transformers.splines.b_spline import BSplineTransformer +from ..transformers.splines.cubic_regression import CubicSplineTransformer +from ..transformers.splines.i_spline import ISplineTransformer +from ..transformers.splines.m_spline import MSplineTransformer +from ..transformers.splines.multivariate.tensor_product import ( + TensorProductSplineTransformer, +) +from ..transformers.splines.multivariate.thin_plate import ( + ThinPlateSplineTransformer, +) from ..transformers.splines.natural_cubic import NaturalCubicSplineTransformer -from ..transformers.splines.pspline import PSplineTransformer -from ..transformers.splines.tensor_product import TensorProductSplineTransformer -from ..transformers.splines.thinplate_spline import ThinPlateSplineTransformer +from ..transformers.splines.p_spline import PSplineTransformer __all__ = [ "CATEGORICAL_ALIASES", diff --git a/pretab/placement/__init__.py b/pretab/placement/__init__.py new file mode 100644 index 0000000..a9585c8 --- /dev/null +++ b/pretab/placement/__init__.py @@ -0,0 +1,7 @@ +"""Placement subsystem: where basis units go and how many there are. + +A single home for location + resolution logic shared by splines, feature maps, +PLE and periodic encoders. Populated during the 1.0.0 restructure (Phase 2): +``base``, ``unsupervised``, ``supervised``, ``resolution``, ``adapters`` and +``factory`` modules. +""" diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index 5a493d3..2e8ee4e 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -7,12 +7,12 @@ from sklearn.pipeline import Pipeline from sklearn.utils.validation import check_is_fitted -from .core.exceptions import ( +from .core.logging import configure_logging, get_logger +from .core.parameters import validate_placement +from .exceptions import ( IncompatibleParamsError, invalid_param_error, ) -from .core.logging import configure_logging, get_logger -from .core.params import validate_placement from .pipeline import ( get_categorical_transformer_steps, get_numerical_transformer_steps, diff --git a/pretab/transformers/__init__.py b/pretab/transformers/__init__.py index 99eeaab..8064d87 100644 --- a/pretab/transformers/__init__.py +++ b/pretab/transformers/__init__.py @@ -1,18 +1,20 @@ -from .binning import CustomBinTransformer -from .embeddings import LanguageEmbeddingTransformer -from .encoders import ( +from .categorical import ( ContinuousOrdinalTransformer, - NoTransformer, - ToFloatTransformer, + LanguageEmbeddingTransformer, + OneHotFromOrdinalTransformer, ) +from .encoders import NoTransformer, ToFloatTransformer from .feature_maps import ( RBFExpansionTransformer, ReLUExpansionTransformer, SigmoidExpansionTransformer, TanhExpansionTransformer, ) -from .onehot import OneHotFromOrdinalTransformer -from .ple import PLETransformer +from .numerical import ( + CustomBinTransformer, + CyclicalTimeTransformer, + PLETransformer, +) from .splines import ( BSplineTransformer, CubicSplineTransformer, @@ -24,7 +26,6 @@ ThinPlateSplineTransformer, ) from .temporal import ( - CyclicalTimeTransformer, LagFeatureTransformer, RollingStatsTransformer, ) diff --git a/pretab/transformers/binning/__init__.py b/pretab/transformers/binning/__init__.py deleted file mode 100644 index 2aa52d9..0000000 --- a/pretab/transformers/binning/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .binning import CustomBinTransformer - -__all__ = ["CustomBinTransformer"] diff --git a/pretab/transformers/categorical/__init__.py b/pretab/transformers/categorical/__init__.py new file mode 100644 index 0000000..bd7d010 --- /dev/null +++ b/pretab/transformers/categorical/__init__.py @@ -0,0 +1,14 @@ +"""Categorical transformers: ordinal encoding, language embeddings and the +time-boxed legacy one-hot-from-ordinal encoder. Modules are moved here during the +1.0.0 restructure (Phase 1). +""" + +from .language_embedding import LanguageEmbeddingTransformer +from .legacy import OneHotFromOrdinalTransformer +from .ordinal import ContinuousOrdinalTransformer + +__all__ = [ + "ContinuousOrdinalTransformer", + "LanguageEmbeddingTransformer", + "OneHotFromOrdinalTransformer", +] diff --git a/pretab/transformers/embeddings/language_transformer.py b/pretab/transformers/categorical/language_embedding.py similarity index 98% rename from pretab/transformers/embeddings/language_transformer.py rename to pretab/transformers/categorical/language_embedding.py index bcfabf4..9e3cb0b 100644 --- a/pretab/transformers/embeddings/language_transformer.py +++ b/pretab/transformers/categorical/language_embedding.py @@ -1,7 +1,7 @@ import numpy as np from sklearn.base import BaseEstimator, TransformerMixin -from ...core.exceptions import OptionalDependencyError, PretabConfigError +from ...exceptions import OptionalDependencyError, PretabConfigError class LanguageEmbeddingTransformer(TransformerMixin, BaseEstimator): diff --git a/pretab/transformers/onehot/onehot.py b/pretab/transformers/categorical/legacy.py similarity index 100% rename from pretab/transformers/onehot/onehot.py rename to pretab/transformers/categorical/legacy.py diff --git a/pretab/transformers/encoders/continuous_ordinal.py b/pretab/transformers/categorical/ordinal.py similarity index 100% rename from pretab/transformers/encoders/continuous_ordinal.py rename to pretab/transformers/categorical/ordinal.py diff --git a/pretab/transformers/embeddings/__init__.py b/pretab/transformers/embeddings/__init__.py deleted file mode 100644 index 769027d..0000000 --- a/pretab/transformers/embeddings/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .language_transformer import LanguageEmbeddingTransformer - -__all__ = ["LanguageEmbeddingTransformer"] diff --git a/pretab/transformers/encoders/__init__.py b/pretab/transformers/encoders/__init__.py index b56015f..e77bb42 100644 --- a/pretab/transformers/encoders/__init__.py +++ b/pretab/transformers/encoders/__init__.py @@ -1,14 +1,12 @@ -"""Categorical / numeric encoders for tabular preprocessing. +"""Numeric helper transformers for tabular preprocessing. These transformers turn raw column values into numeric arrays that downstream -models can consume: ordinal integer encoding, a float cast, and a pass-through. +models can consume: a float cast and a pass-through. """ -from .continuous_ordinal import ContinuousOrdinalTransformer from .floats import NoTransformer, ToFloatTransformer __all__ = [ - "ContinuousOrdinalTransformer", "NoTransformer", "ToFloatTransformer", ] diff --git a/pretab/transformers/feature_maps/_base.py b/pretab/transformers/feature_maps/base.py similarity index 98% rename from pretab/transformers/feature_maps/_base.py rename to pretab/transformers/feature_maps/base.py index 66943f7..75815df 100644 --- a/pretab/transformers/feature_maps/_base.py +++ b/pretab/transformers/feature_maps/base.py @@ -13,13 +13,13 @@ from sklearn.utils.validation import check_is_fitted from ...core.base import BasePreTabTransformer -from ...core.exceptions import ( +from ...core.parameters import UNSET, validate_placement +from ...core.selectors import CARTLocationSelector, LightGBMLocationSelector +from ...exceptions import ( IncompatibleParamsError, InvalidParamError, PretabDataError, ) -from ...core.params import UNSET, validate_placement -from ...core.selectors import CARTLocationSelector, LightGBMLocationSelector class BaseCenterExpansion(BasePreTabTransformer): diff --git a/pretab/transformers/feature_maps/rbf.py b/pretab/transformers/feature_maps/rbf.py index d6b2ebe..f6a90e2 100644 --- a/pretab/transformers/feature_maps/rbf.py +++ b/pretab/transformers/feature_maps/rbf.py @@ -1,7 +1,7 @@ import numpy as np -from ...core.params import UNSET -from ._base import BaseCenterExpansion +from ...core.parameters import UNSET +from .base import BaseCenterExpansion class RBFExpansionTransformer(BaseCenterExpansion): diff --git a/pretab/transformers/feature_maps/relu.py b/pretab/transformers/feature_maps/relu.py index 8d19abd..a58f2b6 100644 --- a/pretab/transformers/feature_maps/relu.py +++ b/pretab/transformers/feature_maps/relu.py @@ -1,7 +1,7 @@ import numpy as np -from ...core.params import UNSET -from ._base import BaseCenterExpansion +from ...core.parameters import UNSET +from .base import BaseCenterExpansion class ReLUExpansionTransformer(BaseCenterExpansion): diff --git a/pretab/transformers/feature_maps/sigmoid.py b/pretab/transformers/feature_maps/sigmoid.py index 19e2af7..bd48c94 100644 --- a/pretab/transformers/feature_maps/sigmoid.py +++ b/pretab/transformers/feature_maps/sigmoid.py @@ -1,8 +1,8 @@ import numpy as np from scipy.special import expit -from ...core.params import UNSET -from ._base import BaseCenterExpansion +from ...core.parameters import UNSET +from .base import BaseCenterExpansion class SigmoidExpansionTransformer(BaseCenterExpansion): diff --git a/pretab/transformers/feature_maps/tanh.py b/pretab/transformers/feature_maps/tanh.py index ec1ccd9..1db4f2d 100644 --- a/pretab/transformers/feature_maps/tanh.py +++ b/pretab/transformers/feature_maps/tanh.py @@ -1,7 +1,7 @@ import numpy as np -from ...core.params import UNSET -from ._base import BaseCenterExpansion +from ...core.parameters import UNSET +from .base import BaseCenterExpansion class TanhExpansionTransformer(BaseCenterExpansion): diff --git a/pretab/transformers/numerical/__init__.py b/pretab/transformers/numerical/__init__.py new file mode 100644 index 0000000..58c1de0 --- /dev/null +++ b/pretab/transformers/numerical/__init__.py @@ -0,0 +1,14 @@ +"""Numerical single-column transformers: binning, piecewise-linear encoding (PLE) +and periodic encoding. Modules are moved here during the 1.0.0 restructure (Phase 1) +and renamed to their intention-revealing public names in Phase 5. +""" + +from .binning import CustomBinTransformer +from .periodic import CyclicalTimeTransformer +from .piecewise import PLETransformer + +__all__ = [ + "CustomBinTransformer", + "CyclicalTimeTransformer", + "PLETransformer", +] diff --git a/pretab/transformers/binning/binning.py b/pretab/transformers/numerical/binning.py similarity index 96% rename from pretab/transformers/binning/binning.py rename to pretab/transformers/numerical/binning.py index d68d52b..58bebe9 100644 --- a/pretab/transformers/binning/binning.py +++ b/pretab/transformers/numerical/binning.py @@ -4,8 +4,8 @@ import pandas as pd from sklearn.base import BaseEstimator, TransformerMixin -from ...core.exceptions import InsufficientSamplesError, InvalidParamError, PretabDataError -from ...core.params import UNSET, AliasResolverMixin +from ...core.parameters import UNSET, AliasResolverMixin +from ...exceptions import InsufficientSamplesError, InvalidParamError, PretabDataError class CustomBinTransformer(AliasResolverMixin, TransformerMixin, BaseEstimator): @@ -44,7 +44,7 @@ class CustomBinTransformer(AliasResolverMixin, TransformerMixin, BaseEstimator): The input must be numeric: binning is performed with :func:`pandas.cut`, so string / categorical data cannot be processed and raises a - :class:`~pretab.core.exceptions.PretabDataError`. Encode such columns with a + :class:`~pretab.exceptions.PretabDataError`. Encode such columns with a categorical method (e.g. ``"int"`` or ``"one-hot"``) before binning. Examples diff --git a/pretab/transformers/temporal/cyclic.py b/pretab/transformers/numerical/periodic.py similarity index 97% rename from pretab/transformers/temporal/cyclic.py rename to pretab/transformers/numerical/periodic.py index bd8d87c..9ad451c 100644 --- a/pretab/transformers/temporal/cyclic.py +++ b/pretab/transformers/numerical/periodic.py @@ -2,7 +2,7 @@ from sklearn.utils.validation import check_is_fitted from ...core.base import BasePreTabTransformer -from ...core.exceptions import PretabDataError +from ...exceptions import PretabDataError class CyclicalTimeTransformer(BasePreTabTransformer): diff --git a/pretab/transformers/ple/ple.py b/pretab/transformers/numerical/piecewise.py similarity index 99% rename from pretab/transformers/ple/ple.py rename to pretab/transformers/numerical/piecewise.py index 8f7ea1c..00796ad 100644 --- a/pretab/transformers/ple/ple.py +++ b/pretab/transformers/numerical/piecewise.py @@ -14,14 +14,14 @@ from sklearn.utils.validation import check_array, check_is_fitted from ...core.adaptive import AdaptiveResolutionMixin -from ...core.exceptions import ( +from ...core.parameters import UNSET, AliasResolverMixin +from ...core.selectors import CARTLocationSelector, LightGBMLocationSelector +from ...exceptions import ( DataWarning, EmptyDataError, InvalidParamError, PretabDataError, ) -from ...core.params import UNSET, AliasResolverMixin -from ...core.selectors import CARTLocationSelector, LightGBMLocationSelector class PLETransformer(AdaptiveResolutionMixin, AliasResolverMixin, TransformerMixin, BaseEstimator): @@ -48,7 +48,7 @@ class PLETransformer(AdaptiveResolutionMixin, AliasResolverMixin, TransformerMix inherently target-aware, so only the supervised selectors apply. ``"cart"`` fits a single decision tree (always available); ``"lightgbm"`` fits a gradient-boosted ensemble and requires the optional ``lightgbm`` - dependency (``pip install pretab[knots]``). + dependency (``pip install pretab[lightgbm]``). task : {"regression", "classification"}, default="regression" Whether to fit a ``DecisionTreeRegressor`` or ``DecisionTreeClassifier`` to locate the split thresholds. diff --git a/pretab/transformers/onehot/__init__.py b/pretab/transformers/onehot/__init__.py deleted file mode 100644 index 01affd9..0000000 --- a/pretab/transformers/onehot/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .onehot import OneHotFromOrdinalTransformer - -__all__ = ["OneHotFromOrdinalTransformer"] diff --git a/pretab/transformers/ple/__init__.py b/pretab/transformers/ple/__init__.py deleted file mode 100644 index 2d175cd..0000000 --- a/pretab/transformers/ple/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .ple import PLETransformer - -__all__ = ["PLETransformer"] diff --git a/pretab/transformers/splines/__init__.py b/pretab/transformers/splines/__init__.py index 09f294f..059ab2e 100644 --- a/pretab/transformers/splines/__init__.py +++ b/pretab/transformers/splines/__init__.py @@ -1,17 +1,17 @@ +from .b_spline import BSplineTransformer from .base_spline import BaseSplineTransformer -from .bspline import BSplineTransformer -from .cubic import CubicSplineTransformer -from .integrated_spline import ISplineTransformer +from .cubic_regression import CubicSplineTransformer +from .i_spline import ISplineTransformer from .knot_selectors import ( BaseKnotSelector, CARTKnotSelector, LightGBMKnotSelector, ) -from .mspline import MSplineTransformer +from .m_spline import MSplineTransformer +from .multivariate.tensor_product import TensorProductSplineTransformer +from .multivariate.thin_plate import ThinPlateSplineTransformer from .natural_cubic import NaturalCubicSplineTransformer -from .pspline import PSplineTransformer -from .tensor_product import TensorProductSplineTransformer -from .thinplate_spline import ThinPlateSplineTransformer +from .p_spline import PSplineTransformer __all__ = [ "BSplineTransformer", diff --git a/pretab/transformers/splines/bspline.py b/pretab/transformers/splines/b_spline.py similarity index 98% rename from pretab/transformers/splines/bspline.py rename to pretab/transformers/splines/b_spline.py index 72838a7..7738066 100644 --- a/pretab/transformers/splines/bspline.py +++ b/pretab/transformers/splines/b_spline.py @@ -10,7 +10,7 @@ import numpy as np from scipy.interpolate import BSpline -from ...core.params import UNSET +from ...core.parameters import UNSET from .base_spline import BaseSplineTransformer diff --git a/pretab/transformers/splines/base_spline.py b/pretab/transformers/splines/base_spline.py index ca50f36..bdf3470 100644 --- a/pretab/transformers/splines/base_spline.py +++ b/pretab/transformers/splines/base_spline.py @@ -20,11 +20,6 @@ from sklearn.utils.validation import check_is_fitted from ...core.base import BasePreTabTransformer -from ...core.exceptions import ( - IncompatibleParamsError, - InvalidParamError, - PretabDataError, -) from ...core.knots import ( basis_to_knots, generate_internal_knots, @@ -32,7 +27,12 @@ select_knots, uniform_knots, ) -from ...core.params import UNSET, validate_placement +from ...core.parameters import UNSET, validate_placement +from ...exceptions import ( + IncompatibleParamsError, + InvalidParamError, + PretabDataError, +) from .knot_selectors import BaseKnotSelector, build_knot_selector diff --git a/pretab/transformers/splines/cubic.py b/pretab/transformers/splines/cubic_regression.py similarity index 98% rename from pretab/transformers/splines/cubic.py rename to pretab/transformers/splines/cubic_regression.py index b4ca1ad..849a45d 100644 --- a/pretab/transformers/splines/cubic.py +++ b/pretab/transformers/splines/cubic_regression.py @@ -2,8 +2,8 @@ from sklearn.base import BaseEstimator, TransformerMixin from sklearn.utils.validation import check_is_fitted -from ...core.exceptions import InvalidParamError -from ...core.params import UNSET, validate_placement +from ...core.parameters import UNSET, validate_placement +from ...exceptions import InvalidParamError from .knot_selectors import build_knot_selector from .mixins import SplineBasisMixin diff --git a/pretab/transformers/splines/integrated_spline.py b/pretab/transformers/splines/i_spline.py similarity index 99% rename from pretab/transformers/splines/integrated_spline.py rename to pretab/transformers/splines/i_spline.py index 127bc31..c43fdcf 100644 --- a/pretab/transformers/splines/integrated_spline.py +++ b/pretab/transformers/splines/i_spline.py @@ -11,7 +11,7 @@ import numpy as np from scipy.interpolate import BSpline -from ...core.params import UNSET +from ...core.parameters import UNSET from .base_spline import BaseSplineTransformer diff --git a/pretab/transformers/splines/knot_selectors.py b/pretab/transformers/splines/knot_selectors.py index e445bc8..f74c92a 100644 --- a/pretab/transformers/splines/knot_selectors.py +++ b/pretab/transformers/splines/knot_selectors.py @@ -10,7 +10,7 @@ - :class:`CARTKnotSelector` uses a single decision tree and needs only scikit-learn, so it is always available. - :class:`LightGBMKnotSelector` uses a gradient boosted ensemble and requires the - optional ``lightgbm`` dependency (``pip install pretab[knots]``). + optional ``lightgbm`` dependency (``pip install pretab[lightgbm]``). Both are thin, spline-aware adapters over the degree-agnostic count-based selectors in :mod:`pretab.core.selectors`. The adapter converts a number of @@ -24,9 +24,9 @@ import numpy as np -from ...core.exceptions import IncompatibleParamsError, invalid_param_error from ...core.knots import basis_to_knots from ...core.selectors import CARTLocationSelector, LightGBMLocationSelector +from ...exceptions import IncompatibleParamsError, invalid_param_error class BaseKnotSelector(ABC): @@ -169,7 +169,7 @@ class LightGBMKnotSelector(BaseKnotSelector): tends to find informative knots that a single tree can miss. Requires the optional ``lightgbm`` dependency, installable with - ``pip install pretab[knots]``. + ``pip install pretab[lightgbm]``. Parameters ---------- diff --git a/pretab/transformers/splines/mspline.py b/pretab/transformers/splines/m_spline.py similarity index 98% rename from pretab/transformers/splines/mspline.py rename to pretab/transformers/splines/m_spline.py index 500af59..2e7f2a2 100644 --- a/pretab/transformers/splines/mspline.py +++ b/pretab/transformers/splines/m_spline.py @@ -11,7 +11,7 @@ import numpy as np from scipy.interpolate import BSpline -from ...core.params import UNSET +from ...core.parameters import UNSET from .base_spline import BaseSplineTransformer diff --git a/pretab/transformers/splines/mixins.py b/pretab/transformers/splines/mixins.py index f5b12f6..dddbb83 100644 --- a/pretab/transformers/splines/mixins.py +++ b/pretab/transformers/splines/mixins.py @@ -13,8 +13,8 @@ import numpy as np from ...core.base import BasePreTabTransformer -from ...core.exceptions import IncompatibleParamsError from ...core.knots import generate_internal_knots, select_knots, spanning_knots +from ...exceptions import IncompatibleParamsError class SplineBasisMixin(BasePreTabTransformer): diff --git a/pretab/transformers/splines/multivariate/__init__.py b/pretab/transformers/splines/multivariate/__init__.py new file mode 100644 index 0000000..39b5fa7 --- /dev/null +++ b/pretab/transformers/splines/multivariate/__init__.py @@ -0,0 +1,13 @@ +"""Multivariate spline transformers (tensor-product and thin-plate). These operate +on the numeric block as a whole and are standalone/grouped (excluded from the +per-column ``Preprocessor(numerical_method=...)`` whitelist). Modules are moved +here during the 1.0.0 restructure (Phase 1). +""" + +from .tensor_product import TensorProductSplineTransformer +from .thin_plate import ThinPlateSplineTransformer + +__all__ = [ + "TensorProductSplineTransformer", + "ThinPlateSplineTransformer", +] diff --git a/pretab/transformers/splines/tensor_product.py b/pretab/transformers/splines/multivariate/tensor_product.py similarity index 98% rename from pretab/transformers/splines/tensor_product.py rename to pretab/transformers/splines/multivariate/tensor_product.py index ec5dd42..dc10f17 100644 --- a/pretab/transformers/splines/tensor_product.py +++ b/pretab/transformers/splines/multivariate/tensor_product.py @@ -2,9 +2,9 @@ from sklearn.base import BaseEstimator, TransformerMixin from sklearn.utils.validation import check_is_fitted -from ...core.exceptions import InvalidParamError -from ...core.params import UNSET -from .mixins import SplineBasisMixin +from ....core.parameters import UNSET +from ....exceptions import InvalidParamError +from ..mixins import SplineBasisMixin def bspline_basis(x, knots, degree, i): @@ -65,7 +65,7 @@ class TensorProductSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEst .. note:: The tensor-product spline is a penalized (difference-penalty) spline - per marginal, exactly like :class:`~pretab.transformers.splines.pspline.PSplineTransformer`, + per marginal, exactly like :class:`~pretab.transformers.splines.p_spline.PSplineTransformer`, so it assumes **equally-spaced** knots and is *unsupervised-only*: target-aware placement does not apply and only ``"uniform"`` / ``"quantile"`` spacing is accepted. diff --git a/pretab/transformers/splines/thinplate_spline.py b/pretab/transformers/splines/multivariate/thin_plate.py similarity index 98% rename from pretab/transformers/splines/thinplate_spline.py rename to pretab/transformers/splines/multivariate/thin_plate.py index 65bfea0..d2fe039 100644 --- a/pretab/transformers/splines/thinplate_spline.py +++ b/pretab/transformers/splines/multivariate/thin_plate.py @@ -4,8 +4,8 @@ from sklearn.base import BaseEstimator, TransformerMixin from sklearn.utils.validation import check_is_fitted -from ...core.exceptions import InvalidParamError, PretabDataError -from .mixins import SplineBasisMixin +from ....exceptions import InvalidParamError, PretabDataError +from ..mixins import SplineBasisMixin class ThinPlateSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEstimator): diff --git a/pretab/transformers/splines/natural_cubic.py b/pretab/transformers/splines/natural_cubic.py index 82322b8..2e7efb2 100644 --- a/pretab/transformers/splines/natural_cubic.py +++ b/pretab/transformers/splines/natural_cubic.py @@ -2,8 +2,8 @@ from sklearn.base import BaseEstimator, TransformerMixin from sklearn.utils.validation import check_is_fitted -from ...core.exceptions import InvalidParamError -from ...core.params import UNSET, validate_placement +from ...core.parameters import UNSET, validate_placement +from ...exceptions import InvalidParamError from .knot_selectors import build_knot_selector from .mixins import SplineBasisMixin diff --git a/pretab/transformers/splines/pspline.py b/pretab/transformers/splines/p_spline.py similarity index 99% rename from pretab/transformers/splines/pspline.py rename to pretab/transformers/splines/p_spline.py index 2c6fbf4..16e5028 100644 --- a/pretab/transformers/splines/pspline.py +++ b/pretab/transformers/splines/p_spline.py @@ -2,8 +2,8 @@ from sklearn.base import BaseEstimator, TransformerMixin from sklearn.utils.validation import check_is_fitted -from ...core.exceptions import InvalidParamError -from ...core.params import UNSET +from ...core.parameters import UNSET +from ...exceptions import InvalidParamError from .mixins import SplineBasisMixin diff --git a/pretab/transformers/temporal/__init__.py b/pretab/transformers/temporal/__init__.py index 9166444..24695a2 100644 --- a/pretab/transformers/temporal/__init__.py +++ b/pretab/transformers/temporal/__init__.py @@ -4,18 +4,14 @@ pipeline. ``LagFeatureTransformer`` and ``RollingStatsTransformer`` intentionally change the row count (they drop the initial, incomplete windows) and assume the rows are ordered in time, so they cannot be used inside the -:class:`~sklearn.compose.ColumnTransformer` the preprocessor builds. -``CyclicalTimeTransformer`` preserves the row count but requires a per-feature -``period`` argument, so it is also applied directly rather than routed through the -pipeline. Use them standalone on ordered arrays. +:class:`~sklearn.compose.ColumnTransformer` the preprocessor builds. Use them +standalone on ordered arrays. """ -from .cyclic import CyclicalTimeTransformer from .lag import LagFeatureTransformer from .rolling_stats import RollingStatsTransformer __all__ = [ - "CyclicalTimeTransformer", "LagFeatureTransformer", "RollingStatsTransformer", ] diff --git a/pretab/transformers/temporal/lag.py b/pretab/transformers/temporal/lag.py index 14da5ce..07771c4 100644 --- a/pretab/transformers/temporal/lag.py +++ b/pretab/transformers/temporal/lag.py @@ -2,7 +2,7 @@ from sklearn.utils.validation import check_is_fitted from ...core.base import BasePreTabTransformer -from ...core.exceptions import InsufficientSamplesError +from ...exceptions import InsufficientSamplesError class LagFeatureTransformer(BasePreTabTransformer): diff --git a/pretab/transformers/temporal/rolling_stats.py b/pretab/transformers/temporal/rolling_stats.py index 687fc37..3b6d36d 100644 --- a/pretab/transformers/temporal/rolling_stats.py +++ b/pretab/transformers/temporal/rolling_stats.py @@ -2,7 +2,7 @@ from sklearn.utils.validation import check_is_fitted from ...core.base import BasePreTabTransformer -from ...core.exceptions import InsufficientSamplesError, invalid_param_error +from ...exceptions import InsufficientSamplesError, invalid_param_error class RollingStatsTransformer(BasePreTabTransformer): diff --git a/pretab/utils/__init__.py b/pretab/utils/__init__.py deleted file mode 100644 index 977f6ec..0000000 --- a/pretab/utils/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Backward-compatible shim. - -The assembly layer moved to :mod:`pretab.pipeline`; the step factories are -re-exported here so existing ``from pretab.utils import ...`` imports keep -working. -""" - -from ..pipeline import ( - get_categorical_transformer_steps, - get_numerical_transformer_steps, -) - -__all__ = [ - "get_categorical_transformer_steps", - "get_numerical_transformer_steps", -] diff --git a/pretab/utils/get_categorical.py b/pretab/utils/get_categorical.py deleted file mode 100644 index d996c9a..0000000 --- a/pretab/utils/get_categorical.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Backward-compatible shim. - -``get_categorical_transformer_steps`` moved to -:mod:`pretab.pipeline.categorical`; it is re-exported here so existing -``from pretab.utils.get_categorical import ...`` imports keep working. -""" - -from ..pipeline.categorical import get_categorical_transformer_steps - -__all__ = ["get_categorical_transformer_steps"] diff --git a/pretab/utils/get_numerical.py b/pretab/utils/get_numerical.py deleted file mode 100644 index 373c228..0000000 --- a/pretab/utils/get_numerical.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Backward-compatible shim. - -``get_numerical_transformer_steps`` moved to -:mod:`pretab.pipeline.numerical`; it is re-exported here so existing -``from pretab.utils.get_numerical import ...`` imports keep working. -""" - -from ..pipeline.numerical import get_numerical_transformer_steps - -__all__ = ["get_numerical_transformer_steps"] diff --git a/tests/test_adaptive_output_dim.py b/tests/test_adaptive_output_dim.py index 031e7c7..1caa882 100644 --- a/tests/test_adaptive_output_dim.py +++ b/tests/test_adaptive_output_dim.py @@ -21,11 +21,11 @@ import pandas as pd import pytest -from pretab.core.exceptions import PretabDataError +from pretab.exceptions import PretabDataError from pretab.preprocessor import Preprocessor -from pretab.transformers.splines.bspline import BSplineTransformer -from pretab.transformers.splines.integrated_spline import ISplineTransformer -from pretab.transformers.splines.mspline import MSplineTransformer +from pretab.transformers.splines.b_spline import BSplineTransformer +from pretab.transformers.splines.i_spline import ISplineTransformer +from pretab.transformers.splines.m_spline import MSplineTransformer OUTPUT_DIM = 6 diff --git a/tests/test_custombin_transformer.py b/tests/test_custombin_transformer.py index 2c9ad6e..360a991 100644 --- a/tests/test_custombin_transformer.py +++ b/tests/test_custombin_transformer.py @@ -3,7 +3,7 @@ import pytest from sklearn.base import BaseEstimator, TransformerMixin -from pretab.core.exceptions import InsufficientSamplesError, PretabDataError +from pretab.exceptions import InsufficientSamplesError, PretabDataError from pretab.transformers import CustomBinTransformer diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 909456a..fb0f959 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -2,7 +2,7 @@ These tests lock two guarantees: -1. Every migrated raise site emits a *typed* ``core.exceptions`` class. +1. Every migrated raise site emits a *typed* ``pretab.exceptions`` class. 2. The typed classes stay back-compatible: config/data errors remain ``ValueError`` subclasses and optional-dependency errors remain ``ImportError`` subclasses, so pre-existing ``pytest.raises(ValueError)`` @@ -16,7 +16,8 @@ from pretab import Preprocessor, PretabWarning from pretab.core.adaptive import AdaptiveResolutionMixin -from pretab.core.exceptions import ( +from pretab.core.knots import generate_internal_knots +from pretab.exceptions import ( ConfigWarning, DataWarning, EmptyDataError, @@ -31,7 +32,6 @@ insufficient_samples_error, invalid_param_error, ) -from pretab.core.knots import generate_internal_knots from pretab.transformers import ( BSplineTransformer, LagFeatureTransformer, diff --git a/tests/test_feature_map_selector.py b/tests/test_feature_map_selector.py index d8bc2e7..2b886c6 100644 --- a/tests/test_feature_map_selector.py +++ b/tests/test_feature_map_selector.py @@ -15,8 +15,8 @@ import pytest from sklearn.base import clone -from pretab.core.exceptions import InvalidParamError from pretab.core.selectors import CARTLocationSelector +from pretab.exceptions import InvalidParamError from pretab.transformers import ( RBFExpansionTransformer, ReLUExpansionTransformer, diff --git a/tests/test_language_embedding_transformer.py b/tests/test_language_embedding_transformer.py index 5f6807c..a623ba1 100644 --- a/tests/test_language_embedding_transformer.py +++ b/tests/test_language_embedding_transformer.py @@ -13,7 +13,7 @@ import pytest from sklearn.base import clone -from pretab.core.exceptions import OptionalDependencyError, PretabConfigError +from pretab.exceptions import OptionalDependencyError, PretabConfigError from pretab.transformers import LanguageEmbeddingTransformer diff --git a/tests/test_location_selectors.py b/tests/test_location_selectors.py index d7577f7..2221144 100644 --- a/tests/test_location_selectors.py +++ b/tests/test_location_selectors.py @@ -1,12 +1,12 @@ import numpy as np import pytest -from pretab.core.exceptions import IncompatibleParamsError from pretab.core.selectors import ( BaseLocationSelector, CARTLocationSelector, LightGBMLocationSelector, ) +from pretab.exceptions import IncompatibleParamsError from pretab.transformers.splines.knot_selectors import ( CARTKnotSelector, LightGBMKnotSelector, diff --git a/tests/test_method_aliases.py b/tests/test_method_aliases.py index 2e02325..f0ee38f 100644 --- a/tests/test_method_aliases.py +++ b/tests/test_method_aliases.py @@ -2,7 +2,7 @@ import pandas as pd import pytest -from pretab.core.exceptions import InvalidParamError +from pretab.exceptions import InvalidParamError from pretab.pipeline.registry import ( CATEGORICAL_ALIASES, CATEGORICAL_METHODS, diff --git a/tests/test_ple_selector.py b/tests/test_ple_selector.py index 91495d6..498b9ac 100644 --- a/tests/test_ple_selector.py +++ b/tests/test_ple_selector.py @@ -15,8 +15,8 @@ import pytest from sklearn.base import clone -from pretab.core.exceptions import InvalidParamError from pretab.core.selectors import CARTLocationSelector +from pretab.exceptions import InvalidParamError from pretab.transformers import PLETransformer diff --git a/tests/test_spline_api_parity.py b/tests/test_spline_api_parity.py index 7998d72..20a0567 100644 --- a/tests/test_spline_api_parity.py +++ b/tests/test_spline_api_parity.py @@ -7,7 +7,7 @@ import numpy as np import pytest -from pretab.core.exceptions import IncompatibleParamsError +from pretab.exceptions import IncompatibleParamsError from pretab.transformers import ( CubicSplineTransformer, NaturalCubicSplineTransformer, diff --git a/tests/test_temporal.py b/tests/test_temporal.py index 186085f..69182f1 100644 --- a/tests/test_temporal.py +++ b/tests/test_temporal.py @@ -19,7 +19,7 @@ import numpy as np import pytest -from pretab.core.exceptions import PretabDataError +from pretab.exceptions import PretabDataError from pretab.transformers import ( CyclicalTimeTransformer, LagFeatureTransformer, diff --git a/tests/test_verbosity.py b/tests/test_verbosity.py index e5007ce..092a295 100644 --- a/tests/test_verbosity.py +++ b/tests/test_verbosity.py @@ -15,7 +15,7 @@ import pytest from pretab import Preprocessor, PretabWarning, configure_logging, set_verbosity -from pretab.core.exceptions import ConfigWarning, DataWarning +from pretab.exceptions import ConfigWarning, DataWarning from pretab.transformers import PLETransformer From 961f469c45ec28604a01fa445c3d9819e317c06a Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 26 Jul 2026 13:07:54 +0200 Subject: [PATCH 06/59] refactor(placement): centralize location placement and migrate transformers to it --- pretab/placement/__init__.py | 44 ++- pretab/placement/adapters.py | 229 +++++++++++++++ pretab/placement/base.py | 101 +++++++ pretab/placement/factory.py | 89 ++++++ pretab/placement/resolution.py | 161 +++++++++++ pretab/placement/supervised.py | 103 +++++++ pretab/placement/unsupervised.py | 90 ++++++ pretab/transformers/feature_maps/base.py | 53 ++-- pretab/transformers/numerical/piecewise.py | 45 ++- pretab/transformers/splines/__init__.py | 8 - pretab/transformers/splines/base_spline.py | 8 +- .../transformers/splines/cubic_regression.py | 6 +- pretab/transformers/splines/knot_selectors.py | 272 ------------------ pretab/transformers/splines/natural_cubic.py | 6 +- tests/placement/test_placement.py | 207 +++++++++++++ tests/test_exceptions.py | 4 +- tests/test_knot_selectors.py | 103 ------- tests/test_location_selectors.py | 11 +- tests/test_spline_placement_adapter.py | 107 +++++++ 19 files changed, 1180 insertions(+), 467 deletions(-) create mode 100644 pretab/placement/adapters.py create mode 100644 pretab/placement/base.py create mode 100644 pretab/placement/factory.py create mode 100644 pretab/placement/resolution.py create mode 100644 pretab/placement/supervised.py create mode 100644 pretab/placement/unsupervised.py delete mode 100644 pretab/transformers/splines/knot_selectors.py create mode 100644 tests/placement/test_placement.py delete mode 100644 tests/test_knot_selectors.py create mode 100644 tests/test_spline_placement_adapter.py diff --git a/pretab/placement/__init__.py b/pretab/placement/__init__.py index a9585c8..3f5ae5d 100644 --- a/pretab/placement/__init__.py +++ b/pretab/placement/__init__.py @@ -1,7 +1,45 @@ """Placement subsystem: where basis units go and how many there are. A single home for location + resolution logic shared by splines, feature maps, -PLE and periodic encoders. Populated during the 1.0.0 restructure (Phase 2): -``base``, ``unsupervised``, ``supervised``, ``resolution``, ``adapters`` and -``factory`` modules. +PLE and periodic encoders. Splits the two concerns cleanly: *where* (the +:class:`~pretab.placement.base.BasePlacementStrategy` families) and *how many* +(the :class:`~pretab.placement.resolution.BaseResolutionPolicy` policies). The +family adapters translate a strategy's generic locations into knots / thresholds +/ centers, and :func:`~pretab.placement.factory.create_placement_strategy` builds +a strategy from the public ``target_aware`` / ``placement_strategy`` vocabulary. """ + +from .adapters import ( + PeriodicPlacementAdapter, + PLEPlacementAdapter, + RBFPlacementAdapter, + SplinePlacementAdapter, +) +from .base import BasePlacementStrategy, PlacementResult +from .factory import create_placement_strategy +from .resolution import ( + BaseResolutionPolicy, + CardinalityAwareResolution, + DataSizeAwareResolution, + FixedResolution, +) +from .supervised import CARTPlacement, LightGBMPlacement +from .unsupervised import QuantilePlacement, UniformPlacement + +__all__ = [ + "BasePlacementStrategy", + "BaseResolutionPolicy", + "CARTPlacement", + "CardinalityAwareResolution", + "DataSizeAwareResolution", + "FixedResolution", + "LightGBMPlacement", + "PLEPlacementAdapter", + "PeriodicPlacementAdapter", + "PlacementResult", + "QuantilePlacement", + "RBFPlacementAdapter", + "SplinePlacementAdapter", + "UniformPlacement", + "create_placement_strategy", +] diff --git a/pretab/placement/adapters.py b/pretab/placement/adapters.py new file mode 100644 index 0000000..f641458 --- /dev/null +++ b/pretab/placement/adapters.py @@ -0,0 +1,229 @@ +"""Family adapters: convert generic placement into family-specific locations. + +The placement strategies in :mod:`pretab.placement.supervised` / +:mod:`pretab.placement.unsupervised` speak in *locations* and *unit counts*. Each +transformer family, though, has its own vocabulary and conventions: + +* splines think in *basis functions* -> *internal knots* (a degree-dependent + conversion) and place knots strictly interior to the data range; +* PLE thinks in *bins* -> *thresholds* (``bins - 1``) and is target-aware only; +* feature maps think in *centers* that span the range with the endpoints included. + +These adapters own exactly that translation, so the placement strategies stay +family-neutral. Each is a faithful reimplementation of the historical per-family +selection code on top of the shared placement strategies, so knot / threshold / +center positions are numerically unchanged. +""" + +from __future__ import annotations + +from typing import Literal + +import numpy as np + +from ..core.knots import basis_to_knots +from ..core.selectors import Task +from ..exceptions import invalid_param_error +from .factory import create_placement_strategy + +__all__ = [ + "PLEPlacementAdapter", + "PeriodicPlacementAdapter", + "RBFPlacementAdapter", + "SplinePlacementAdapter", +] + +# The spline knot selectors have always searched a fixed basis-function window, +# independent of the requested output_dim (the transformer clamps to output_dim +# afterwards). These reproduce ``CART/LightGBMKnotSelector``'s defaults. +_SPLINE_MIN_BASIS = 3 +_SPLINE_MAX_BASIS = 15 +# Historical default seed used by the spline knot selectors when random_state is +# left unset (feature maps / PLE forward their own default instead). +_SPLINE_DEFAULT_SEED = 51 + + +class SplinePlacementAdapter: + """Target-aware knot placement for the B/M/I spline families. + + A drop-in replacement for the old ``build_knot_selector(...)`` product: it + exposes the same :meth:`get_knot_locations` signature the spline base and + mixin call, but sources locations from a shared + :class:`~pretab.placement.supervised` strategy. The basis-function search + window (``min_basis_functions`` / ``max_basis_functions``) is converted to an + internal-knot count via :func:`pretab.core.knots.basis_to_knots`, exactly as + before. + + Parameters + ---------- + degree : int + Spline degree, used to convert basis functions into internal knots. + placement_strategy : {"cart", "lightgbm"} + Target-aware selector to place the knots. + spline_type : {"bspline", "mspline", "ispline"}, default="bspline" + Retained for parity with the previous selector API (the knot count depends + only on ``degree``). + random_state : int or None, default=None + Random state forwarded to the strategy. When unset the historical spline + default seed (51) is used. + min_basis_functions, max_basis_functions : int + Basis-function search window (defaults 3 and 15, matching the old + selectors). + """ + + def __init__( + self, + *, + degree: int, + placement_strategy: str, + spline_type: Literal["bspline", "mspline", "ispline"] = "bspline", + random_state: int | None = None, + min_basis_functions: int = _SPLINE_MIN_BASIS, + max_basis_functions: int = _SPLINE_MAX_BASIS, + ): + if placement_strategy not in ("cart", "lightgbm"): + raise invalid_param_error( + type(self).__name__, + "placement_strategy", + placement_strategy, + "must be 'cart' or 'lightgbm' when target_aware=True", + valid={"cart", "lightgbm"}, + ) + self.degree = degree + self.placement_strategy = placement_strategy + self.spline_type = spline_type + self.random_state = random_state + self.min_knots = basis_to_knots(min_basis_functions, degree) + self.max_knots = basis_to_knots(max_basis_functions, degree) + + def get_knot_locations( + self, + X: np.ndarray, + y: np.ndarray | None = None, + task: Task | None = "regression", + ) -> np.ndarray: + """Return sorted internal knot locations for a single feature.""" + seed = self.random_state if self.random_state is not None else _SPLINE_DEFAULT_SEED + strategy = create_placement_strategy( + target_aware=True, + placement_strategy=self.placement_strategy, + min_count=self.min_knots, + max_count=self.max_knots, + task=task, + random_state=seed, + ) + return strategy.fit(X, y).get_locations().locations + + +class PLEPlacementAdapter: + """Target-aware threshold placement for Piecewise Linear Encoding. + + PLE is inherently target-aware: only the supervised strategies apply. The + caller resolves the ``[min_count, max_count]`` *threshold* window (one fewer + than the bin count) and this adapter returns the sorted thresholds. + + Parameters + ---------- + placement_strategy : {"cart", "lightgbm"} + Target-aware selector to place the thresholds. + task : {"regression", "classification"}, default="regression" + Prediction task passed to the selector. + random_state : int or None, default=None + Random state forwarded to the strategy as-is. + """ + + def __init__( + self, + *, + placement_strategy: str, + task: Task | None = "regression", + random_state: int | None = None, + ): + if placement_strategy not in ("cart", "lightgbm"): + raise invalid_param_error( + type(self).__name__, + "placement_strategy", + placement_strategy, + "must be 'cart' or 'lightgbm'", + valid={"cart", "lightgbm"}, + ) + self.placement_strategy = placement_strategy + self.task: Task | None = task + self.random_state = random_state + + def get_thresholds(self, x: np.ndarray, y: np.ndarray, min_count: int, max_count: int) -> np.ndarray: + """Return sorted bin thresholds for a single feature.""" + strategy = create_placement_strategy( + target_aware=True, + placement_strategy=self.placement_strategy, + min_count=min_count, + max_count=max_count, + task=self.task, + random_state=self.random_state, + ) + return np.sort(strategy.fit(x, y).get_locations().locations) + + +class RBFPlacementAdapter: + """Center placement for the center-based feature maps (RBF/ReLU/sigmoid/tanh). + + Feature-map centers span the feature range with the endpoints included, and + may be placed either target-aware (CART / LightGBM) or unsupervised + (uniform / quantile). The caller resolves the ``[min_count, max_count]`` + window (equal bounds on the non-adaptive path). + + Parameters + ---------- + target_aware : bool + Whether to use the supervised strategies. + placement_strategy : {"cart", "lightgbm", "uniform", "quantile"} + Placement strategy, validated against ``target_aware``. + task : {"regression", "classification"}, default="regression" + Prediction task for the supervised strategies. + random_state : int or None, default=None + Random state forwarded to the supervised strategies as-is. + """ + + def __init__( + self, + *, + target_aware: bool, + placement_strategy: str, + task: Task | None = "regression", + random_state: int | None = None, + ): + self.target_aware = target_aware + self.placement_strategy = placement_strategy + self.task: Task | None = task + self.random_state = random_state + + def get_centers(self, x: np.ndarray, y: np.ndarray | None, min_count: int, max_count: int) -> np.ndarray: + """Return sorted centers for a single feature.""" + strategy = create_placement_strategy( + target_aware=self.target_aware, + placement_strategy=self.placement_strategy, + min_count=min_count, + max_count=max_count, + task=self.task, + random_state=self.random_state, + include_endpoints=True, + ) + return strategy.fit(x, y).get_locations().locations + + +class PeriodicPlacementAdapter: + """Forward-declared adapter for the periodic (cyclic) encoder. + + Periodic encoding is parameter-driven (``period`` and ``harmonics``) rather + than placement-driven: it does not locate data-dependent knots or centers. + This adapter exists so the capability registry and placement factory can name + a placement entry for every family uniformly; its data-driven placement is + reserved for a later phase (e.g. learned phase offsets or frequency + selection) and raises until then. + """ + + def get_locations(self, x: np.ndarray, y: np.ndarray | None = None) -> np.ndarray: + raise NotImplementedError( + "Periodic encoding is parameter-driven (period, harmonics) and does not use " + "data-dependent placement." + ) diff --git a/pretab/placement/base.py b/pretab/placement/base.py new file mode 100644 index 0000000..b6f008d --- /dev/null +++ b/pretab/placement/base.py @@ -0,0 +1,101 @@ +"""Core placement contract: :class:`BasePlacementStrategy` and :class:`PlacementResult`. + +A *placement strategy* answers the "where" question for a single feature: given +that feature's values (and optionally a target), it produces a sorted array of +locations along the feature -- spline knots, feature-map centers, or PLE +thresholds. It is deliberately unit-agnostic (it returns *locations*, not basis +functions); converting a requested number of basis functions into a number of +locations is the job of the family adapters in :mod:`pretab.placement.adapters`. + +The contract is intentionally tiny so both the unsupervised (uniform / quantile) +and supervised (CART / LightGBM) families, and the family adapters, can share it: + +* :meth:`~BasePlacementStrategy.fit` looks at one feature and stores the chosen + locations, and +* :meth:`~BasePlacementStrategy.get_locations` returns a frozen + :class:`PlacementResult` describing them (locations plus the requested and + effective unit counts, the strategy name, and whether the target was used). +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import ClassVar + +import numpy as np + +__all__ = ["BasePlacementStrategy", "PlacementResult"] + + +@dataclass(frozen=True) +class PlacementResult: + """Immutable description of the locations a strategy placed for one feature. + + Parameters + ---------- + locations : np.ndarray + Sorted array of placed locations along the feature. + requested_units : int + Number of locations the caller asked for (the resolved upper bound of the + ``[min_count, max_count]`` window). + effective_units : int + Number of locations actually produced. Equals ``requested_units`` for the + fixed-count unsupervised families; may be smaller for the data-driven + supervised families, which can find fewer informative splits. + strategy : str + Name of the strategy that produced the locations (``"uniform"``, + ``"quantile"``, ``"cart"``, ``"lightgbm"``). + target_aware : bool + Whether the placement used the target ``y``. + """ + + locations: np.ndarray + requested_units: int + effective_units: int + strategy: str + target_aware: bool + + +class BasePlacementStrategy(ABC): + """Abstract base class for single-feature location placement strategies. + + Subclasses set the ``name`` and ``target_aware`` class attributes and + implement :meth:`fit` (store the chosen locations on ``self``) and + :meth:`get_locations` (return the frozen :class:`PlacementResult`). + + The strategy is stateful and single-feature: call :meth:`fit` with one + feature's values (and its target when ``target_aware``), then + :meth:`get_locations`. :meth:`place`, provided here, chains the two for + callers that do not need to keep the fitted strategy around. + """ + + name: ClassVar[str] = "" + target_aware: ClassVar[bool] = False + + @abstractmethod + def fit(self, x: np.ndarray, y: np.ndarray | None = None) -> BasePlacementStrategy: + """Look at one feature (and optional target) and store the locations. + + Parameters + ---------- + x : np.ndarray of shape (n_samples,) or (n_samples, 1) + Values of a single feature. + y : np.ndarray of shape (n_samples,), optional + Target values. Required by the supervised strategies. + + Returns + ------- + self : BasePlacementStrategy + The fitted strategy. + """ + raise NotImplementedError + + @abstractmethod + def get_locations(self) -> PlacementResult: + """Return the :class:`PlacementResult` produced by the last :meth:`fit`.""" + raise NotImplementedError + + def place(self, x: np.ndarray, y: np.ndarray | None = None) -> PlacementResult: + """Convenience: :meth:`fit` on ``x``/``y`` then return :meth:`get_locations`.""" + return self.fit(x, y).get_locations() diff --git a/pretab/placement/factory.py b/pretab/placement/factory.py new file mode 100644 index 0000000..7e5dd73 --- /dev/null +++ b/pretab/placement/factory.py @@ -0,0 +1,89 @@ +"""Factory for building placement strategies from the public parameter vocabulary. + +:func:`create_placement_strategy` is the single entry point transformers use to +turn the user-facing ``target_aware`` / ``placement_strategy`` pair into a +concrete :class:`~pretab.placement.base.BasePlacementStrategy`. It enforces the +``target_aware`` / ``placement_strategy`` combo (D4) up front via +:func:`pretab.core.parameters.validate_placement`, so an invalid pairing fails +with one clear error instead of surfacing deep inside a family. + +The count window (``min_count`` / ``max_count``) and endpoint convention +(``include_endpoints``) are resolved by the caller -- typically a family adapter +in :mod:`pretab.placement.adapters` -- and passed straight through. +""" + +from __future__ import annotations + +from ..core.parameters import validate_placement +from ..core.selectors import Task +from ..exceptions import invalid_param_error +from .base import BasePlacementStrategy +from .supervised import CARTPlacement, LightGBMPlacement +from .unsupervised import QuantilePlacement, UniformPlacement + +__all__ = ["create_placement_strategy"] + + +def create_placement_strategy( + *, + target_aware: bool, + placement_strategy: str, + min_count: int, + max_count: int, + task: Task | None = "regression", + random_state: int | None = None, + include_endpoints: bool = False, +) -> BasePlacementStrategy: + """Build a placement strategy from ``target_aware`` / ``placement_strategy``. + + Parameters + ---------- + target_aware : bool + Whether the target ``y`` is used to place locations. Selects the + supervised (``True``) or unsupervised (``False``) family. + placement_strategy : {"cart", "lightgbm", "uniform", "quantile"} + The strategy name. Must be a supervised selector (``"cart"`` / + ``"lightgbm"``) when ``target_aware`` is True, or an unsupervised spacing + rule (``"uniform"`` / ``"quantile"``) when False. + min_count, max_count : int + Inclusive count window. The supervised strategies place a data-driven + count inside it; the unsupervised strategies place exactly ``max_count`` + locations (callers pass ``min_count == max_count`` for a fixed width). + task : {"regression", "classification"}, optional + Task forwarded to the supervised strategies. Ignored when unsupervised. + random_state : int or None, default=None + Forwarded to the supervised strategies for reproducibility. + include_endpoints : bool, default=False + Endpoint convention for the unsupervised strategies (``False`` -> interior + locations, ``True`` -> range-spanning). Ignored when supervised. + + Returns + ------- + BasePlacementStrategy + A ready-to-fit placement strategy. + + Raises + ------ + InvalidParamError + If ``placement_strategy`` is not valid for the chosen ``target_aware``. + """ + validate_placement(target_aware, placement_strategy) + + if target_aware: + if placement_strategy == "cart": + return CARTPlacement(min_count=min_count, max_count=max_count, task=task, random_state=random_state) + return LightGBMPlacement(min_count=min_count, max_count=max_count, task=task, random_state=random_state) + + if placement_strategy == "uniform": + return UniformPlacement(max_count, include_endpoints=include_endpoints) + if placement_strategy == "quantile": + return QuantilePlacement(max_count, include_endpoints=include_endpoints) + + # Unreachable: validate_placement already rejected any other name. + raise invalid_param_error( + "create_placement_strategy", + "placement_strategy", + placement_strategy, + "must be one of 'cart', 'lightgbm', 'uniform', 'quantile'", + valid={"cart", "lightgbm", "quantile", "uniform"}, + ) diff --git a/pretab/placement/resolution.py b/pretab/placement/resolution.py new file mode 100644 index 0000000..d7a8167 --- /dev/null +++ b/pretab/placement/resolution.py @@ -0,0 +1,161 @@ +"""Resolution policies: *how many* units to place, separate from *where*. + +Every PreTab expansion exposes the same sizing vocabulary: a fixed ``output_dim`` +plus an optional adaptive window ``[min_output_dim, max_output_dim]``. Resolving +that vocabulary into an inclusive ``(lo, hi)`` count window -- and validating it +against a family floor / ceiling -- is a single concern that does not depend on +*where* the units land. Keeping it here, apart from the placement strategies in +:mod:`pretab.placement.unsupervised` / :mod:`pretab.placement.supervised`, lets a +family combine any resolution policy with any placement strategy. + +:class:`FixedResolution` implements the ``output_dim`` / ``[min, max]`` contract +shared by every family today. :class:`CardinalityAwareResolution` and +:class:`DataSizeAwareResolution` are declared as forward-looking stubs (their +data-driven ``(lo, hi)`` policies are scheduled for a later phase) so the +registry and factory can name them without importing from a moving target. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +import numpy as np + +from ..exceptions import IncompatibleParamsError, InvalidParamError + +__all__ = [ + "BaseResolutionPolicy", + "CardinalityAwareResolution", + "DataSizeAwareResolution", + "FixedResolution", +] + + +class BaseResolutionPolicy(ABC): + """Abstract base class for "how many units" policies. + + A policy turns the user-facing sizing parameters (``output_dim`` and the + optional ``[min_output_dim, max_output_dim]`` window) into an inclusive + ``(lo, hi)`` bound on the per-feature unit count, validated against a + family-specific ``floor`` (and optional ``ceil``). + """ + + @abstractmethod + def resolve( + self, + output_dim: int, + min_req: int | None, + max_req: int | None, + *, + floor: int, + floor_label: str | None = None, + ceil: int | None = None, + ) -> tuple[int, int]: + """Return the inclusive ``(lo, hi)`` per-feature unit-count window.""" + raise NotImplementedError + + +class FixedResolution(BaseResolutionPolicy): + """The ``output_dim`` / ``[min, max]`` resolution shared by every family. + + With ``adaptive=False`` each feature is expanded to exactly ``output_dim`` + units (``lo == hi == output_dim``), after checking ``output_dim`` is + consistent with any explicitly supplied ``min``/``max`` request. With + ``adaptive=True`` the window comes from the requested ``min``/``max`` (each + falling back to ``output_dim`` when unset). The resolved window is validated + against the family ``floor`` and optional ``ceil``. + + This reproduces the historical ``AdaptiveResolutionMixin._resolve_output_bounds`` + behaviour exactly. + """ + + def __init__(self, adaptive: bool): + self.adaptive = adaptive + + def resolve( + self, + output_dim: int, + min_req: int | None, + max_req: int | None, + *, + floor: int, + floor_label: str | None = None, + ceil: int | None = None, + ) -> tuple[int, int]: + if not self.adaptive: + if min_req is not None and output_dim < min_req: + raise IncompatibleParamsError( + "output_dim must be >= min_output_dim when adaptive=False " + f"(got output_dim={output_dim}, min_output_dim={min_req}).\n" + "Fix: raise output_dim, lower min_output_dim, or set adaptive=True." + ) + if max_req is not None and output_dim > max_req: + raise IncompatibleParamsError( + "output_dim must be <= max_output_dim when adaptive=False " + f"(got output_dim={output_dim}, max_output_dim={max_req}).\n" + "Fix: lower output_dim, raise max_output_dim, or set adaptive=True." + ) + lo = hi = output_dim + else: + lo = min_req if min_req is not None else output_dim + hi = max_req if max_req is not None else output_dim + + label = floor_label if floor_label is not None else str(floor) + if lo < floor: + raise InvalidParamError( + f"min_output_dim must be >= {label}, got {lo}.\n" + "Fix: raise min_output_dim to at least the family minimum." + ) + if ceil is not None and hi > ceil: + raise InvalidParamError( + f"max_output_dim should be <= {ceil}, got {hi}.\nFix: lower max_output_dim to at most {ceil}." + ) + if lo > hi: + raise IncompatibleParamsError( + f"min_output_dim must be <= max_output_dim (got min_output_dim={lo}, max_output_dim={hi})." + ) + return lo, hi + + +class CardinalityAwareResolution(BaseResolutionPolicy): + """Stub: cap the unit count by the feature's distinct-value count. + + Scheduled for a later phase. Declared now so the capability registry and + placement factory can reference it by name. + """ + + def resolve( + self, + output_dim: int, + min_req: int | None, + max_req: int | None, + *, + floor: int, + floor_label: str | None = None, + ceil: int | None = None, + ) -> tuple[int, int]: + raise NotImplementedError("CardinalityAwareResolution is not implemented yet.") + + def clamp_to_cardinality(self, hi: int, x: np.ndarray) -> int: + """Placeholder for the future distinct-value clamp.""" + raise NotImplementedError("CardinalityAwareResolution is not implemented yet.") + + +class DataSizeAwareResolution(BaseResolutionPolicy): + """Stub: scale the unit count with the number of samples. + + Scheduled for a later phase. Declared now so the capability registry and + placement factory can reference it by name. + """ + + def resolve( + self, + output_dim: int, + min_req: int | None, + max_req: int | None, + *, + floor: int, + floor_label: str | None = None, + ceil: int | None = None, + ) -> tuple[int, int]: + raise NotImplementedError("DataSizeAwareResolution is not implemented yet.") diff --git a/pretab/placement/supervised.py b/pretab/placement/supervised.py new file mode 100644 index 0000000..098b8ba --- /dev/null +++ b/pretab/placement/supervised.py @@ -0,0 +1,103 @@ +"""Supervised placement: locations where the feature's effect on the target changes. + +:class:`CARTPlacement` fits a single decision tree (scikit-learn only, always +available); :class:`LightGBMPlacement` fits a gradient-boosted ensemble and needs +the optional ``lightgbm`` dependency. Both look at one feature against the target +and return the split thresholds -- spaced out, ranked (impurity for CART, gain for +LightGBM), and topped up / trimmed to land in ``[min_count, max_count]``. Because +placement is data-driven, the effective unit count can be smaller than requested. + +Both strategies delegate to the count-based selectors in +:mod:`pretab.core.selectors`, so placement stays numerically identical to the +historical per-family code. +""" + +from __future__ import annotations + +from typing import ClassVar + +import numpy as np + +from ..core.selectors import ( + BaseLocationSelector, + CARTLocationSelector, + LightGBMLocationSelector, + Task, +) +from ..exceptions import IncompatibleParamsError +from .base import BasePlacementStrategy, PlacementResult + +__all__ = ["CARTPlacement", "LightGBMPlacement"] + + +class _SupervisedPlacement(BasePlacementStrategy): + """Shared machinery for the target-aware placement strategies. + + Parameters + ---------- + min_count, max_count : int + Inclusive bounds on the number of locations to return. + task : {"regression", "classification"}, default="regression" + Prediction task passed to the underlying tree model. + random_state : int or None, default=None + Forwarded to the selector for reproducibility (only when set, so an unset + value keeps the selector's own default seed). + """ + + target_aware: ClassVar[bool] = True + + def __init__( + self, + *, + min_count: int, + max_count: int, + task: Task | None = "regression", + random_state: int | None = None, + ): + self.min_count = min_count + self.max_count = max_count + self.task: Task | None = task + self.random_state = random_state + self._selector = self._build_selector() + + def _build_selector(self) -> BaseLocationSelector: + raise NotImplementedError + + def fit(self, x: np.ndarray, y: np.ndarray | None = None) -> _SupervisedPlacement: + if y is None: + raise IncompatibleParamsError(f"{type(self).__name__} requires y to place locations.") + self.locations_ = self._selector.select( + x, + y, + task=self.task, + min_count=self.min_count, + max_count=self.max_count, + ) + return self + + def get_locations(self) -> PlacementResult: + return PlacementResult( + locations=self.locations_, + requested_units=self.max_count, + effective_units=int(self.locations_.shape[0]), + strategy=self.name, + target_aware=True, + ) + + +class CARTPlacement(_SupervisedPlacement): + """Target-aware placement from a single decision tree's split points.""" + + name: ClassVar[str] = "cart" + + def _build_selector(self) -> BaseLocationSelector: + return CARTLocationSelector(random_state=self.random_state) + + +class LightGBMPlacement(_SupervisedPlacement): + """Target-aware placement from a LightGBM ensemble's gain-ranked split points.""" + + name: ClassVar[str] = "lightgbm" + + def _build_selector(self) -> BaseLocationSelector: + return LightGBMLocationSelector(random_state=self.random_state) diff --git a/pretab/placement/unsupervised.py b/pretab/placement/unsupervised.py new file mode 100644 index 0000000..1ba3000 --- /dev/null +++ b/pretab/placement/unsupervised.py @@ -0,0 +1,90 @@ +"""Unsupervised placement: locations from feature geometry alone. + +:class:`UniformPlacement` spaces locations evenly across a feature's range; +:class:`QuantilePlacement` puts them at evenly spaced data quantiles. Neither +uses the target, so both fit without a ``y`` and their effective unit count +always equals the requested count. + +The two endpoint conventions PreTab uses are exposed through ``include_endpoints``: + +* ``include_endpoints=False`` (default) returns *interior* locations -- the + B/M/I-spline internal-knot convention (:func:`pretab.core.knots.uniform_knots` / + :func:`~pretab.core.knots.quantile_knots`). +* ``include_endpoints=True`` returns locations that span the full range, endpoints + included -- the feature-map center and spanning-knot convention + (:func:`pretab.core.knots.spanning_knots`). + +Both paths delegate to the shared knot primitives so placement stays numerically +identical to the historical per-family code. +""" + +from __future__ import annotations + +from typing import ClassVar + +import numpy as np + +from ..core.knots import quantile_knots, spanning_knots, uniform_knots +from .base import BasePlacementStrategy, PlacementResult + +__all__ = ["QuantilePlacement", "UniformPlacement"] + + +class _UnsupervisedPlacement(BasePlacementStrategy): + """Shared machinery for the fixed-count, target-free placement strategies. + + Parameters + ---------- + n_units : int + Number of locations to place per feature (the requested and, for these + deterministic strategies, effective count). + include_endpoints : bool, default=False + ``False`` returns interior locations (internal-knot convention); ``True`` + returns range-spanning locations with the endpoints included. + """ + + target_aware: ClassVar[bool] = False + + def __init__(self, n_units: int, *, include_endpoints: bool = False): + self.n_units = n_units + self.include_endpoints = include_endpoints + + def _place(self, x: np.ndarray, n_units: int) -> np.ndarray: + raise NotImplementedError + + def fit(self, x: np.ndarray, y: np.ndarray | None = None) -> _UnsupervisedPlacement: + x = np.asarray(x, dtype=float).ravel() + x = x[~np.isnan(x)] + self.locations_ = np.asarray(self._place(x, self.n_units)) + return self + + def get_locations(self) -> PlacementResult: + return PlacementResult( + locations=self.locations_, + requested_units=self.n_units, + effective_units=int(self.locations_.shape[0]), + strategy=self.name, + target_aware=False, + ) + + +class UniformPlacement(_UnsupervisedPlacement): + """Evenly spaced locations across a feature's range.""" + + name: ClassVar[str] = "uniform" + + def _place(self, x: np.ndarray, n_units: int) -> np.ndarray: + if self.include_endpoints: + return spanning_knots(x, n_units, "uniform") + return uniform_knots(x, n_units) + + +class QuantilePlacement(_UnsupervisedPlacement): + """Locations at evenly spaced data quantiles of a feature.""" + + name: ClassVar[str] = "quantile" + + def _place(self, x: np.ndarray, n_units: int) -> np.ndarray: + if self.include_endpoints: + return spanning_knots(x, n_units, "quantile") + return quantile_knots(x, n_units) diff --git a/pretab/transformers/feature_maps/base.py b/pretab/transformers/feature_maps/base.py index 75815df..b078a26 100644 --- a/pretab/transformers/feature_maps/base.py +++ b/pretab/transformers/feature_maps/base.py @@ -14,12 +14,12 @@ from ...core.base import BasePreTabTransformer from ...core.parameters import UNSET, validate_placement -from ...core.selectors import CARTLocationSelector, LightGBMLocationSelector from ...exceptions import ( IncompatibleParamsError, InvalidParamError, PretabDataError, ) +from ...placement.adapters import RBFPlacementAdapter class BaseCenterExpansion(BasePreTabTransformer): @@ -92,32 +92,23 @@ def fit(self, X, y=None): if self.target_aware and y is None: raise IncompatibleParamsError("Target variable 'y' must be provided when target_aware=True.") - if self.target_aware: - # Centers come from a target-aware location selector (CART by default, - # optionally LightGBM): split points spaced out and ranked by impurity - # / gain. Adaptive sizing clamps each feature into [min, max]; otherwise - # each feature keeps exactly ``output_dim`` centers. - selector = self._build_selector(placement_strategy) - if self.adaptive: - min_centers, max_centers = self._resolve_output_bounds(n_centers, min_req, max_req, floor=1) - else: - min_centers = max_centers = n_centers - centers_list = [ - selector.select( - X[:, i], - y, - task=self.task, - min_count=min_centers, - max_count=max_centers, - ) - for i in range(X.shape[1]) - ] - elif placement_strategy == "quantile": - centers_list = [np.percentile(X[:, i], np.linspace(0, 100, n_centers)) for i in range(X.shape[1])] - else: # uniform - centers_list = [np.linspace(X[:, i].min(), X[:, i].max(), n_centers) for i in range(X.shape[1])] - - self.centers_ = centers_list + # Centers come from the placement subsystem: a target-aware selector + # (CART / LightGBM) when ``target_aware``, otherwise quantile / uniform + # spacing across the range with the endpoints included. Adaptive sizing + # only takes effect on the target-aware path, clamping each feature into + # [min, max]; otherwise each feature keeps exactly ``output_dim`` centers. + adapter = RBFPlacementAdapter( + target_aware=self.target_aware, + placement_strategy=placement_strategy, + task=self.task, + random_state=self.random_state, + ) + if self.target_aware and self.adaptive: + min_centers, max_centers = self._resolve_output_bounds(n_centers, min_req, max_req, floor=1) + else: + min_centers = max_centers = n_centers + y_place = y if self.target_aware else None + self.centers_ = [adapter.get_centers(X[:, i], y_place, min_centers, max_centers) for i in range(X.shape[1])] return self def transform(self, X): @@ -150,14 +141,6 @@ def _resolve_placement_strategy(self) -> str: return cast(str, self.placement_strategy) return "cart" if self.target_aware else "quantile" - def _build_selector(self, placement_strategy): - """Construct the target-aware location selector named by ``placement_strategy``.""" - if placement_strategy == "cart": - return CARTLocationSelector(random_state=self.random_state) - if placement_strategy == "lightgbm": - return LightGBMLocationSelector(random_state=self.random_state) - raise InvalidParamError(f"Invalid placement_strategy. Choose 'cart' or 'lightgbm'. Got {placement_strategy!r}.") - def __sklearn_tags__(self): """Require ``y`` only when centers are placed by a target-aware selector.""" tags = super().__sklearn_tags__() diff --git a/pretab/transformers/numerical/piecewise.py b/pretab/transformers/numerical/piecewise.py index 00796ad..de8f68c 100644 --- a/pretab/transformers/numerical/piecewise.py +++ b/pretab/transformers/numerical/piecewise.py @@ -15,13 +15,13 @@ from ...core.adaptive import AdaptiveResolutionMixin from ...core.parameters import UNSET, AliasResolverMixin -from ...core.selectors import CARTLocationSelector, LightGBMLocationSelector from ...exceptions import ( DataWarning, EmptyDataError, InvalidParamError, PretabDataError, ) +from ...placement.adapters import PLEPlacementAdapter class PLETransformer(AdaptiveResolutionMixin, AliasResolverMixin, TransformerMixin, BaseEstimator): @@ -201,26 +201,27 @@ def fit(self, X, y): if self.task not in ("regression", "classification"): raise InvalidParamError(f"Unsupported task: {self.task}. Use 'regression' or 'classification'.") - # Thresholds come from a target-aware location selector (CART by default, - # optionally LightGBM): split points spaced out and ranked by impurity / - # gain, then trimmed / topped up to fit the bin-count window. Each feature - # produces ``len(thresholds) + 1`` bins, so we ask for one fewer location - # than bins: the non-adaptive window pins the count to exactly - # ``output_dim`` bins, adaptive clamps it into ``[min, max]``. - selector = self._build_selector() + if self.placement_strategy not in ("cart", "lightgbm"): + raise InvalidParamError( + f"Invalid placement_strategy. Choose 'cart' or 'lightgbm'. Got {self.placement_strategy!r}." + ) + + # Thresholds come from the placement subsystem's target-aware adapter + # (CART by default, optionally LightGBM): split points spaced out and + # ranked by impurity / gain, then trimmed / topped up to fit the bin-count + # window. Each feature produces ``len(thresholds) + 1`` bins, so we ask for + # one fewer location than bins: the non-adaptive window pins the count to + # exactly ``output_dim`` bins, adaptive clamps it into ``[min, max]``. + adapter = PLEPlacementAdapter( + placement_strategy=self.placement_strategy, + task=self.task, + random_state=self.random_state, + ) min_thresholds = max(0, min_bins - 1) max_thresholds = max(0, max_bins - 1) for i in range(X.shape[1]): - thresholds = np.sort( - selector.select( - X[:, i], - y, - task=self.task, - min_count=min_thresholds, - max_count=max_thresholds, - ) - ) + thresholds = adapter.get_thresholds(X[:, i], y, min_thresholds, max_thresholds) self.thresholds_.append(thresholds) self.n_bins_per_feature_.append(len(thresholds) + 1) @@ -366,13 +367,3 @@ def get_feature_names_out(self, input_features=None): def _resolve_bin_bounds(self, n_bins: int, min_bins_req, max_bins_req) -> tuple[int, int]: return self._resolve_output_bounds(n_bins, min_bins_req, max_bins_req, floor=1) - - def _build_selector(self): - """Construct the target-aware location selector named by ``placement_strategy``.""" - if self.placement_strategy == "cart": - return CARTLocationSelector(random_state=self.random_state) - if self.placement_strategy == "lightgbm": - return LightGBMLocationSelector(random_state=self.random_state) - raise InvalidParamError( - f"Invalid placement_strategy. Choose 'cart' or 'lightgbm'. Got {self.placement_strategy!r}." - ) diff --git a/pretab/transformers/splines/__init__.py b/pretab/transformers/splines/__init__.py index 059ab2e..548170d 100644 --- a/pretab/transformers/splines/__init__.py +++ b/pretab/transformers/splines/__init__.py @@ -2,11 +2,6 @@ from .base_spline import BaseSplineTransformer from .cubic_regression import CubicSplineTransformer from .i_spline import ISplineTransformer -from .knot_selectors import ( - BaseKnotSelector, - CARTKnotSelector, - LightGBMKnotSelector, -) from .m_spline import MSplineTransformer from .multivariate.tensor_product import TensorProductSplineTransformer from .multivariate.thin_plate import ThinPlateSplineTransformer @@ -15,12 +10,9 @@ __all__ = [ "BSplineTransformer", - "BaseKnotSelector", "BaseSplineTransformer", - "CARTKnotSelector", "CubicSplineTransformer", "ISplineTransformer", - "LightGBMKnotSelector", "MSplineTransformer", "NaturalCubicSplineTransformer", "PSplineTransformer", diff --git a/pretab/transformers/splines/base_spline.py b/pretab/transformers/splines/base_spline.py index bdf3470..2d33207 100644 --- a/pretab/transformers/splines/base_spline.py +++ b/pretab/transformers/splines/base_spline.py @@ -33,7 +33,7 @@ InvalidParamError, PretabDataError, ) -from .knot_selectors import BaseKnotSelector, build_knot_selector +from ...placement.adapters import SplinePlacementAdapter class BaseSplineTransformer(BasePreTabTransformer): @@ -209,7 +209,7 @@ def _column_knots( y_valid: np.ndarray | None, n_basis: int, strategy: str, - selector: BaseKnotSelector | None, + selector: SplinePlacementAdapter | None, min_basis_req: int | None, max_basis_req: int | None, ) -> np.ndarray: @@ -264,8 +264,8 @@ def fit(self, X, y=None): # selector built from placement_strategy, then the automatic (unsupervised) # spacing named by placement_strategy. if self.target_aware and self.knot_locations is None: - selector = build_knot_selector( - self.placement_strategy, + selector = SplinePlacementAdapter( + placement_strategy=self.placement_strategy, degree=self.degree, spline_type=self._selector_spline_type, random_state=self.random_state, diff --git a/pretab/transformers/splines/cubic_regression.py b/pretab/transformers/splines/cubic_regression.py index 849a45d..4fde29b 100644 --- a/pretab/transformers/splines/cubic_regression.py +++ b/pretab/transformers/splines/cubic_regression.py @@ -4,7 +4,7 @@ from ...core.parameters import UNSET, validate_placement from ...exceptions import InvalidParamError -from .knot_selectors import build_knot_selector +from ...placement.adapters import SplinePlacementAdapter from .mixins import SplineBasisMixin @@ -163,8 +163,8 @@ def fit(self, X, y=None): n_interior = output_dim - 3 if self.target_aware: - selector = build_knot_selector( - self.placement_strategy, + selector = SplinePlacementAdapter( + placement_strategy=self.placement_strategy, degree=self.degree, spline_type="bspline", random_state=self.random_state, diff --git a/pretab/transformers/splines/knot_selectors.py b/pretab/transformers/splines/knot_selectors.py deleted file mode 100644 index f74c92a..0000000 --- a/pretab/transformers/splines/knot_selectors.py +++ /dev/null @@ -1,272 +0,0 @@ -"""Target-aware knot selection strategies for spline transformers. - -A knot selector looks at a single feature and its target, then returns the -internal knot positions a spline basis should use. Placing knots where the -feature actually changes its relationship with the target usually produces a more -faithful basis than spreading knots uniformly. - -Two strategies are provided: - -- :class:`CARTKnotSelector` uses a single decision tree and needs only - scikit-learn, so it is always available. -- :class:`LightGBMKnotSelector` uses a gradient boosted ensemble and requires the - optional ``lightgbm`` dependency (``pip install pretab[lightgbm]``). - -Both are thin, spline-aware adapters over the degree-agnostic count-based -selectors in :mod:`pretab.core.selectors`. The adapter converts a number of -spline basis functions into a number of internal knots (which depends on the -spline degree) and then asks the underlying location selector for that many -locations. -""" - -from abc import ABC, abstractmethod -from typing import Literal - -import numpy as np - -from ...core.knots import basis_to_knots -from ...core.selectors import CARTLocationSelector, LightGBMLocationSelector -from ...exceptions import IncompatibleParamsError, invalid_param_error - - -class BaseKnotSelector(ABC): - """Abstract base class for knot selection strategies. - - Subclasses implement :meth:`get_knot_locations`, returning the internal knot - positions (boundary knots are added later by the spline transformer). The - basis-to-knot conversion, which depends on the spline degree, lives here so - the concrete selectors stay small. - - The following attributes are expected to be set by every subclass: - ``degree``, ``spline_type``, ``min_knot_spacing``, ``min_knots`` and - ``max_knots``. - """ - - degree: int - spline_type: Literal["bspline", "mspline", "ispline"] - min_knot_spacing: float - min_knots: int - max_knots: int - - @abstractmethod - def get_knot_locations( - self, - X: np.ndarray, - y: np.ndarray | None = None, - task: Literal["regression", "classification"] | None = None, - ) -> np.ndarray: - """Return internal knot locations for a single feature. - - Parameters - ---------- - X : np.ndarray of shape (n_samples,) or (n_samples, 1) - Input feature values for one feature. - y : np.ndarray of shape (n_samples,), optional - Target values. May be None for selectors that are not target aware. - task : {"regression", "classification"}, optional - Type of prediction task. - - Returns - ------- - knot_locations : np.ndarray - Sorted array of internal knot locations. - """ - raise NotImplementedError - - def _basis_to_knots(self, n_basis: int) -> int: - """Convert a number of basis functions into a number of internal knots.""" - if self.spline_type in ("bspline", "mspline", "ispline"): - return basis_to_knots(n_basis, self.degree) - raise invalid_param_error( - type(self).__name__, - "spline_type", - self.spline_type, - "must be one of 'bspline', 'mspline', 'ispline'", - valid={"bspline", "mspline", "ispline"}, - ) - - -class CARTKnotSelector(BaseKnotSelector): - """Select knots from the split points of a single decision tree. - - A ``DecisionTreeRegressor`` or ``DecisionTreeClassifier`` is fitted to the - feature against the target, and its split thresholds become the candidate - knots. Candidates are spaced out, and if there are too many they are ranked - by weighted impurity decrease so the most informative splits are kept. - - Parameters - ---------- - max_tree_depth : int, default=6 - Maximum depth of the decision tree. - min_samples_split : int, default=20 - Minimum samples required to split a node. - min_samples_leaf : int, default=10 - Minimum samples required in a leaf. - min_knot_spacing : float, default=0.01 - Minimum distance between adjacent knots, as a fraction of the feature range. - min_basis_functions : int, default=3 - Minimum basis functions. Falls back to quantile knots if the tree yields - fewer splits. - max_basis_functions : int, default=15 - Maximum basis functions. The top splits are kept if the tree exceeds this. - degree : int, default=3 - Spline degree, used to convert basis functions into internal knots. - spline_type : {"bspline", "mspline", "ispline"}, default="bspline" - Spline family the knots are intended for. - random_state : int or None, default=51 - Random state for reproducibility. - """ - - def __init__( - self, - max_tree_depth: int = 6, - min_samples_split: int = 20, - min_samples_leaf: int = 10, - min_knot_spacing: float = 0.01, - min_basis_functions: int = 3, - max_basis_functions: int = 15, - degree: int = 3, - spline_type: Literal["bspline", "mspline", "ispline"] = "bspline", - random_state: int | None = 51, - ): - self.max_tree_depth = max_tree_depth - self.min_samples_split = min_samples_split - self.min_samples_leaf = min_samples_leaf - self.min_knot_spacing = min_knot_spacing - self.min_basis_functions = min_basis_functions - self.max_basis_functions = max_basis_functions - self.degree = degree - self.spline_type = spline_type - self.random_state = random_state - - self.min_knots = self._basis_to_knots(min_basis_functions) - self.max_knots = self._basis_to_knots(max_basis_functions) - - self._selector = CARTLocationSelector( - max_tree_depth=max_tree_depth, - min_samples_split=min_samples_split, - min_samples_leaf=min_samples_leaf, - min_location_spacing=min_knot_spacing, - random_state=random_state, - ) - - def get_knot_locations( - self, - X: np.ndarray, - y: np.ndarray | None = None, - task: Literal["regression", "classification"] | None = "regression", - ) -> np.ndarray: - if y is None: - raise IncompatibleParamsError("CARTKnotSelector requires y to select knots.") - return self._selector.select(X, y, task=task, min_count=self.min_knots, max_count=self.max_knots) - - -class LightGBMKnotSelector(BaseKnotSelector): - """Select knots from the split points of a LightGBM ensemble. - - A gradient boosted ensemble is fitted to the feature against the target, and - split thresholds are ranked by their cumulative gain across all trees. This - tends to find informative knots that a single tree can miss. - - Requires the optional ``lightgbm`` dependency, installable with - ``pip install pretab[lightgbm]``. - - Parameters - ---------- - n_estimators : int, default=100 - Number of boosting rounds. - max_depth : int, default=3 - Maximum depth of each tree. - learning_rate : float, default=0.1 - Boosting learning rate. - min_child_samples : int, default=20 - Minimum samples in a leaf. - min_knot_spacing : float, default=0.01 - Minimum distance between adjacent knots, as a fraction of the feature range. - min_basis_functions : int, default=3 - Minimum basis functions. Falls back to quantile knots if fewer splits found. - max_basis_functions : int, default=15 - Maximum basis functions. The top-gain splits are kept if more are found. - degree : int, default=3 - Spline degree, used to convert basis functions into internal knots. - spline_type : {"bspline", "mspline", "ispline"}, default="bspline" - Spline family the knots are intended for. - random_state : int or None, default=51 - Random state for reproducibility. - """ - - def __init__( - self, - n_estimators: int = 100, - max_depth: int = 3, - learning_rate: float = 0.1, - min_child_samples: int = 20, - min_knot_spacing: float = 0.01, - min_basis_functions: int = 3, - max_basis_functions: int = 15, - degree: int = 3, - spline_type: Literal["bspline", "mspline", "ispline"] = "bspline", - random_state: int | None = 51, - ): - self.n_estimators = n_estimators - self.max_depth = max_depth - self.learning_rate = learning_rate - self.min_child_samples = min_child_samples - self.min_knot_spacing = min_knot_spacing - self.min_basis_functions = min_basis_functions - self.max_basis_functions = max_basis_functions - self.degree = degree - self.spline_type = spline_type - self.random_state = random_state - - self.min_knots = self._basis_to_knots(min_basis_functions) - self.max_knots = self._basis_to_knots(max_basis_functions) - - self._selector = LightGBMLocationSelector( - n_estimators=n_estimators, - max_depth=max_depth, - learning_rate=learning_rate, - min_child_samples=min_child_samples, - min_location_spacing=min_knot_spacing, - random_state=random_state, - ) - - def get_knot_locations( - self, - X: np.ndarray, - y: np.ndarray | None = None, - task: Literal["regression", "classification"] | None = "regression", - ) -> np.ndarray: - if y is None: - raise IncompatibleParamsError("LightGBMKnotSelector requires y to select knots.") - return self._selector.select(X, y, task=task, min_count=self.min_knots, max_count=self.max_knots) - - -def build_knot_selector( - placement_strategy: str, - *, - degree: int, - spline_type: Literal["bspline", "mspline", "ispline"] = "bspline", - random_state: int | None = None, -) -> BaseKnotSelector: - """Build a target-aware knot selector from a ``placement_strategy`` name. - - ``placement_strategy`` must be ``"cart"`` (a single decision tree, always - available) or ``"lightgbm"`` (a gradient-boosted ensemble, requires the - optional ``lightgbm`` dependency). ``random_state`` is only forwarded when - set, so an unset value keeps each selector's own default seed. - """ - kwargs: dict = {"degree": degree, "spline_type": spline_type} - if random_state is not None: - kwargs["random_state"] = random_state - if placement_strategy == "cart": - return CARTKnotSelector(**kwargs) - if placement_strategy == "lightgbm": - return LightGBMKnotSelector(**kwargs) - raise invalid_param_error( - "build_knot_selector", - "placement_strategy", - placement_strategy, - "must be 'cart' or 'lightgbm' when target_aware=True", - valid={"cart", "lightgbm"}, - ) diff --git a/pretab/transformers/splines/natural_cubic.py b/pretab/transformers/splines/natural_cubic.py index 2e7efb2..170152d 100644 --- a/pretab/transformers/splines/natural_cubic.py +++ b/pretab/transformers/splines/natural_cubic.py @@ -4,7 +4,7 @@ from ...core.parameters import UNSET, validate_placement from ...exceptions import InvalidParamError -from .knot_selectors import build_knot_selector +from ...placement.adapters import SplinePlacementAdapter from .mixins import SplineBasisMixin @@ -173,8 +173,8 @@ def fit(self, X, y=None): n_spanning = output_dim + 1 if self.target_aware: - selector = build_knot_selector( - self.placement_strategy, + selector = SplinePlacementAdapter( + placement_strategy=self.placement_strategy, degree=self.degree, spline_type="bspline", random_state=self.random_state, diff --git a/tests/placement/test_placement.py b/tests/placement/test_placement.py new file mode 100644 index 0000000..d90fba0 --- /dev/null +++ b/tests/placement/test_placement.py @@ -0,0 +1,207 @@ +"""Contract tests for the :mod:`pretab.placement` subsystem (Phase 2, P2.8). + +These lock the placement strategy contract independently of any transformer: +sorted, in-range, dedup-free locations; the requested-vs-effective unit counts; +reproducibility; target-required behaviour for the supervised strategies; both +classification and regression; the factory's combo validation; and the fixed +resolution policy. +""" + +import numpy as np +import pytest + +from pretab.core.knots import quantile_knots, spanning_knots, uniform_knots +from pretab.exceptions import IncompatibleParamsError, InvalidParamError +from pretab.placement import ( + BasePlacementStrategy, + CARTPlacement, + FixedResolution, + PlacementResult, + QuantilePlacement, + UniformPlacement, + create_placement_strategy, +) +from pretab.placement.adapters import RBFPlacementAdapter, SplinePlacementAdapter + + +@pytest.fixture +def data(): + rng = np.random.RandomState(0) + x = rng.uniform(-3, 3, size=300) + y = np.sin(x) + 0.1 * rng.randn(300) + return x, y + + +@pytest.fixture +def clf_data(): + rng = np.random.RandomState(1) + x = rng.uniform(-3, 3, size=300) + y = (x > 0).astype(int) + return x, y + + +# --------------------------------------------------------------------------- # +# Unsupervised strategies +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize("cls", [UniformPlacement, QuantilePlacement]) +def test_unsupervised_sorted_and_counted(cls, data): + x, _ = data + result = cls(6).place(x) + assert isinstance(result, PlacementResult) + assert result.locations.ndim == 1 + assert np.all(np.diff(result.locations) > 0) # sorted, no duplicates + assert result.requested_units == 6 + assert result.effective_units == len(result.locations) == 6 + assert result.target_aware is False + + +@pytest.mark.parametrize("cls", [UniformPlacement, QuantilePlacement]) +def test_unsupervised_interior_in_range(cls, data): + x, _ = data + locs = cls(6).place(x).locations + assert locs.min() > x.min() + assert locs.max() < x.max() + + +def test_unsupervised_matches_primitives(data): + x, _ = data + assert np.allclose(UniformPlacement(6).place(x).locations, uniform_knots(x, 6)) + assert np.allclose(QuantilePlacement(6).place(x).locations, quantile_knots(x, 6)) + assert np.allclose( + UniformPlacement(6, include_endpoints=True).place(x).locations, + spanning_knots(x, 6, "uniform"), + ) + assert np.allclose( + QuantilePlacement(6, include_endpoints=True).place(x).locations, + spanning_knots(x, 6, "quantile"), + ) + + +def test_unsupervised_endpoints_span_range(data): + x, _ = data + locs = UniformPlacement(6, include_endpoints=True).place(x).locations + assert locs[0] == pytest.approx(x.min()) + assert locs[-1] == pytest.approx(x.max()) + + +def test_unsupervised_ignores_nan(data): + x, _ = data + x = x.copy() + x[:10] = np.nan + locs = UniformPlacement(6).place(x).locations + assert np.all(np.isfinite(locs)) + + +# --------------------------------------------------------------------------- # +# Supervised strategies +# --------------------------------------------------------------------------- # +def test_cart_sorted_in_range_and_counts(data): + x, y = data + result = CARTPlacement(min_count=2, max_count=5, task="regression").place(x, y) + assert np.all(np.diff(result.locations) > 0) + assert result.locations.min() > x.min() + assert result.locations.max() < x.max() + assert result.requested_units == 5 + assert result.effective_units == len(result.locations) <= 5 + assert result.target_aware is True + + +def test_cart_requires_y(data): + x, _ = data + with pytest.raises(IncompatibleParamsError, match="requires y"): + CARTPlacement(min_count=2, max_count=5).place(x, None) + + +def test_cart_reproducible(data): + x, y = data + a = CARTPlacement(min_count=3, max_count=10, random_state=51).place(x, y).locations + b = CARTPlacement(min_count=3, max_count=10, random_state=51).place(x, y).locations + np.testing.assert_array_equal(a, b) + + +def test_cart_classification(clf_data): + x, y = clf_data + locs = CARTPlacement(min_count=1, max_count=5, task="classification").place(x, y).locations + assert np.all(np.diff(locs) > 0) + assert locs.min() > x.min() + assert locs.max() < x.max() + + +# --------------------------------------------------------------------------- # +# Factory + combo validation (D4) +# --------------------------------------------------------------------------- # +def test_factory_builds_each_strategy(): + assert isinstance( + create_placement_strategy(target_aware=True, placement_strategy="cart", min_count=1, max_count=5), + CARTPlacement, + ) + assert isinstance( + create_placement_strategy(target_aware=False, placement_strategy="uniform", min_count=6, max_count=6), + UniformPlacement, + ) + assert isinstance( + create_placement_strategy(target_aware=False, placement_strategy="quantile", min_count=6, max_count=6), + QuantilePlacement, + ) + + +@pytest.mark.parametrize( + ("target_aware", "strategy"), + [(True, "uniform"), (True, "quantile"), (False, "cart"), (False, "lightgbm")], +) +def test_factory_rejects_invalid_combo(target_aware, strategy): + with pytest.raises(InvalidParamError): + create_placement_strategy( + target_aware=target_aware, placement_strategy=strategy, min_count=1, max_count=5 + ) + + +def test_strategies_are_base_instances(): + strat = create_placement_strategy(target_aware=True, placement_strategy="cart", min_count=1, max_count=5) + assert isinstance(strat, BasePlacementStrategy) + + +# --------------------------------------------------------------------------- # +# Resolution policy +# --------------------------------------------------------------------------- # +def test_fixed_resolution_non_adaptive_pins_output_dim(): + assert FixedResolution(adaptive=False).resolve(6, None, None, floor=1) == (6, 6) + + +def test_fixed_resolution_adaptive_window(): + assert FixedResolution(adaptive=True).resolve(6, 2, 10, floor=1) == (2, 10) + + +def test_fixed_resolution_rejects_below_floor(): + with pytest.raises(InvalidParamError): + FixedResolution(adaptive=True).resolve(6, 0, 10, floor=1) + + +def test_fixed_resolution_non_adaptive_conflict(): + with pytest.raises(IncompatibleParamsError): + FixedResolution(adaptive=False).resolve(3, 5, None, floor=1) + + +# --------------------------------------------------------------------------- # +# Adapters +# --------------------------------------------------------------------------- # +def test_spline_adapter_returns_interior_knots(data): + x, y = data + adapter = SplinePlacementAdapter(degree=3, placement_strategy="cart") + knots = adapter.get_knot_locations(x.reshape(-1, 1), y, task="regression") + assert np.all(np.diff(knots) > 0) + assert knots.min() > x.min() + assert knots.max() < x.max() + + +def test_spline_adapter_rejects_unsupervised_strategy(): + with pytest.raises(InvalidParamError): + SplinePlacementAdapter(degree=3, placement_strategy="uniform") + + +def test_rbf_adapter_unsupervised_matches_inline(data): + x, _ = data + centers = RBFPlacementAdapter(target_aware=False, placement_strategy="quantile").get_centers(x, None, 6, 6) + assert np.allclose(centers, np.percentile(x, np.linspace(0, 100, 6))) + centers_u = RBFPlacementAdapter(target_aware=False, placement_strategy="uniform").get_centers(x, None, 6, 6) + assert np.allclose(centers_u, np.linspace(x.min(), x.max(), 6)) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index fb0f959..a1dce09 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -32,6 +32,7 @@ insufficient_samples_error, invalid_param_error, ) +from pretab.placement.adapters import SplinePlacementAdapter from pretab.transformers import ( BSplineTransformer, LagFeatureTransformer, @@ -39,7 +40,6 @@ RollingStatsTransformer, ThinPlateSplineTransformer, ) -from pretab.transformers.splines.knot_selectors import CARTKnotSelector @pytest.fixture @@ -207,7 +207,7 @@ def test_thinplate_multivariate_is_data_error(): def test_cart_selector_requires_y(xy): X, _ = xy with pytest.raises(IncompatibleParamsError, match="requires y"): - CARTKnotSelector().get_knot_locations(X, y=None) + SplinePlacementAdapter(placement_strategy="cart", degree=3).get_knot_locations(X, y=None) # --------------------------------------------------------------------------- # diff --git a/tests/test_knot_selectors.py b/tests/test_knot_selectors.py deleted file mode 100644 index b2f7709..0000000 --- a/tests/test_knot_selectors.py +++ /dev/null @@ -1,103 +0,0 @@ -import numpy as np -import pytest - -from pretab.transformers.splines import ( - BaseKnotSelector, - CARTKnotSelector, - LightGBMKnotSelector, -) - - -@pytest.fixture -def data(): - rng = np.random.RandomState(0) - X = rng.uniform(-3, 3, size=(300, 1)) - y = np.sin(X[:, 0]) + 0.1 * rng.randn(300) - return X, y - - -def test_selectors_subclass_base(): - assert issubclass(CARTKnotSelector, BaseKnotSelector) - assert issubclass(LightGBMKnotSelector, BaseKnotSelector) - - -def test_basis_to_knots_conversion(): - sel = CARTKnotSelector(degree=3) - assert sel._basis_to_knots(10) == 10 - 3 - 1 - assert sel._basis_to_knots(2) == 0 - - -def test_cart_returns_sorted_knots_in_range(data): - X, y = data - sel = CARTKnotSelector(max_basis_functions=12, degree=3) - knots = sel.get_knot_locations(X, y, task="regression") - - assert knots.ndim == 1 - assert np.all(np.diff(knots) > 0) # sorted and unique - assert knots.min() > X.min() - assert knots.max() < X.max() - - -def test_cart_respects_max_knots(data): - X, y = data - sel = CARTKnotSelector(min_basis_functions=6, max_basis_functions=8, degree=3) - knots = sel.get_knot_locations(X, y) - assert len(knots) <= sel.max_knots - - -def test_cart_requires_y(data): - X, _ = data - with pytest.raises(ValueError, match="requires y"): - CARTKnotSelector().get_knot_locations(X, None) - - -def test_cart_reproducible(data): - X, y = data - a = CARTKnotSelector().get_knot_locations(X, y) - b = CARTKnotSelector().get_knot_locations(X, y) - np.testing.assert_array_equal(a, b) - - -def test_cart_small_sample_quantile_fallback(): - rng = np.random.RandomState(1) - X = rng.rand(5, 1) - y = rng.rand(5) - sel = CARTKnotSelector(min_samples_split=20, min_basis_functions=5, degree=1) - knots = sel.get_knot_locations(X, y) - assert len(knots) == sel.min_knots - - -def test_cart_classification_task(): - rng = np.random.RandomState(2) - X = rng.rand(200, 1) - y = (X[:, 0] > 0.5).astype(int) - knots = CARTKnotSelector().get_knot_locations(X, y, task="classification") - assert knots.ndim == 1 - - -def test_cart_handles_nan_rows(data): - X, y = data - X_missing = X.copy() - X_missing[:5, 0] = np.nan - knots = CARTKnotSelector(max_basis_functions=12).get_knot_locations(X_missing, y) - assert np.isfinite(knots).all() - - -def test_lightgbm_selector_runs(data): - pytest.importorskip("lightgbm") - X, y = data - sel = LightGBMKnotSelector(n_estimators=30, max_basis_functions=12) - knots = sel.get_knot_locations(X, y, task="regression") - - assert knots.ndim == 1 - assert np.all(np.diff(knots) > 0) - assert knots.min() > X.min() - assert knots.max() < X.max() - - -def test_lightgbm_selector_reproducible(data): - pytest.importorskip("lightgbm") - X, y = data - a = LightGBMKnotSelector(n_estimators=30).get_knot_locations(X, y) - b = LightGBMKnotSelector(n_estimators=30).get_knot_locations(X, y) - np.testing.assert_array_equal(a, b) diff --git a/tests/test_location_selectors.py b/tests/test_location_selectors.py index 2221144..3ed334b 100644 --- a/tests/test_location_selectors.py +++ b/tests/test_location_selectors.py @@ -7,10 +7,7 @@ LightGBMLocationSelector, ) from pretab.exceptions import IncompatibleParamsError -from pretab.transformers.splines.knot_selectors import ( - CARTKnotSelector, - LightGBMKnotSelector, -) +from pretab.placement.adapters import SplinePlacementAdapter @pytest.fixture @@ -81,7 +78,7 @@ def test_cart_handles_nan_rows(data): def test_cart_matches_knot_adapter(data): X, y = data - adapter = CARTKnotSelector(max_basis_functions=12, degree=3) + adapter = SplinePlacementAdapter(placement_strategy="cart", max_basis_functions=12, degree=3) from_adapter = adapter.get_knot_locations(X, y, task="regression") from_selector = CARTLocationSelector().select( X, y, task="regression", min_count=adapter.min_knots, max_count=adapter.max_knots @@ -111,9 +108,9 @@ def test_lightgbm_reproducible(data): def test_lightgbm_matches_knot_adapter(data): pytest.importorskip("lightgbm") X, y = data - adapter = LightGBMKnotSelector(n_estimators=30, max_basis_functions=12) + adapter = SplinePlacementAdapter(placement_strategy="lightgbm", max_basis_functions=12, degree=3) from_adapter = adapter.get_knot_locations(X, y, task="regression") - from_selector = LightGBMLocationSelector(n_estimators=30).select( + from_selector = LightGBMLocationSelector().select( X, y, task="regression", min_count=adapter.min_knots, max_count=adapter.max_knots ) np.testing.assert_array_equal(from_adapter, from_selector) diff --git a/tests/test_spline_placement_adapter.py b/tests/test_spline_placement_adapter.py new file mode 100644 index 0000000..eb7e0c7 --- /dev/null +++ b/tests/test_spline_placement_adapter.py @@ -0,0 +1,107 @@ +import numpy as np +import pytest + +from pretab.exceptions import IncompatibleParamsError +from pretab.placement.adapters import SplinePlacementAdapter + + +@pytest.fixture +def data(): + rng = np.random.RandomState(0) + X = rng.uniform(-3, 3, size=(300, 1)) + y = np.sin(X[:, 0]) + 0.1 * rng.randn(300) + return X, y + + +def test_basis_to_knots_conversion(): + adapter = SplinePlacementAdapter( + placement_strategy="cart", degree=3, min_basis_functions=2, max_basis_functions=10 + ) + assert adapter.max_knots == 10 - 3 - 1 + assert adapter.min_knots == 0 + + +def test_cart_returns_sorted_knots_in_range(data): + X, y = data + adapter = SplinePlacementAdapter(placement_strategy="cart", max_basis_functions=12, degree=3) + knots = adapter.get_knot_locations(X, y, task="regression") + + assert knots.ndim == 1 + assert np.all(np.diff(knots) > 0) # sorted and unique + assert knots.min() > X.min() + assert knots.max() < X.max() + + +def test_cart_respects_max_knots(data): + X, y = data + adapter = SplinePlacementAdapter( + placement_strategy="cart", min_basis_functions=6, max_basis_functions=8, degree=3 + ) + knots = adapter.get_knot_locations(X, y) + assert len(knots) <= adapter.max_knots + + +def test_cart_requires_y(data): + X, _ = data + with pytest.raises(IncompatibleParamsError, match="requires y"): + SplinePlacementAdapter(placement_strategy="cart", degree=3).get_knot_locations(X, None) + + +def test_cart_reproducible(data): + X, y = data + a = SplinePlacementAdapter(placement_strategy="cart", degree=3).get_knot_locations(X, y) + b = SplinePlacementAdapter(placement_strategy="cart", degree=3).get_knot_locations(X, y) + np.testing.assert_array_equal(a, b) + + +def test_cart_small_sample_quantile_fallback(): + rng = np.random.RandomState(1) + X = rng.rand(5, 1) + y = rng.rand(5) + adapter = SplinePlacementAdapter(placement_strategy="cart", min_basis_functions=5, degree=1) + knots = adapter.get_knot_locations(X, y) + assert len(knots) == adapter.min_knots + + +def test_cart_classification_task(): + rng = np.random.RandomState(2) + X = rng.rand(200, 1) + y = (X[:, 0] > 0.5).astype(int) + knots = SplinePlacementAdapter(placement_strategy="cart", degree=3).get_knot_locations( + X, y, task="classification" + ) + assert knots.ndim == 1 + + +def test_cart_handles_nan_rows(data): + X, y = data + X_missing = X.copy() + X_missing[:5, 0] = np.nan + adapter = SplinePlacementAdapter(placement_strategy="cart", max_basis_functions=12, degree=3) + knots = adapter.get_knot_locations(X_missing, y) + assert np.isfinite(knots).all() + + +def test_rejects_unsupervised_strategy(): + with pytest.raises(Exception): # noqa: B017 - invalid_param_error -> InvalidParamError + SplinePlacementAdapter(placement_strategy="quantile", degree=3) + + +def test_lightgbm_adapter_runs(data): + pytest.importorskip("lightgbm") + X, y = data + adapter = SplinePlacementAdapter(placement_strategy="lightgbm", max_basis_functions=12, degree=3) + knots = adapter.get_knot_locations(X, y, task="regression") + + assert knots.ndim == 1 + assert np.all(np.diff(knots) > 0) + assert knots.min() > X.min() + assert knots.max() < X.max() + + +def test_lightgbm_adapter_reproducible(data): + pytest.importorskip("lightgbm") + X, y = data + a = SplinePlacementAdapter(placement_strategy="lightgbm", degree=3).get_knot_locations(X, y) + b = SplinePlacementAdapter(placement_strategy="lightgbm", degree=3).get_knot_locations(X, y) + np.testing.assert_array_equal(a, b) From ad5a89002ee67f8b2f2159403dd560c249930cb0 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 26 Jul 2026 14:19:29 +0200 Subject: [PATCH 07/59] refactor(compose): add capability registry and slim preprocessor --- pretab/compose/config.py | 147 ++++++++ pretab/compose/factory.py | 267 ++++++++++++++ pretab/compose/feature_detection.py | 74 ++++ pretab/compose/inspection.py | 172 ++++++++++ pretab/compose/output.py | 73 ++++ pretab/compose/registry.py | 439 ++++++++++++++++++++++++ pretab/pipeline/__init__.py | 15 - pretab/pipeline/categorical.py | 62 ---- pretab/pipeline/numerical.py | 184 ---------- pretab/pipeline/registry.py | 246 ------------- pretab/preprocessor.py | 319 +++-------------- tests/compose/conftest.py | 52 +++ tests/compose/test_config.py | 50 +++ tests/compose/test_factory.py | 120 +++++++ tests/compose/test_feature_detection.py | 64 ++++ tests/compose/test_inspection.py | 55 +++ tests/compose/test_output.py | 57 +++ tests/compose/test_registry_contract.py | 209 +++++++++++ tests/test_categorical_pipeline.py | 2 +- tests/test_method_aliases.py | 4 +- tests/test_public_api.py | 43 +++ 21 files changed, 1878 insertions(+), 776 deletions(-) create mode 100644 pretab/compose/config.py create mode 100644 pretab/compose/factory.py create mode 100644 pretab/compose/feature_detection.py create mode 100644 pretab/compose/inspection.py create mode 100644 pretab/compose/output.py create mode 100644 pretab/compose/registry.py delete mode 100644 pretab/pipeline/__init__.py delete mode 100644 pretab/pipeline/categorical.py delete mode 100644 pretab/pipeline/numerical.py delete mode 100644 pretab/pipeline/registry.py create mode 100644 tests/compose/conftest.py create mode 100644 tests/compose/test_config.py create mode 100644 tests/compose/test_factory.py create mode 100644 tests/compose/test_feature_detection.py create mode 100644 tests/compose/test_inspection.py create mode 100644 tests/compose/test_output.py create mode 100644 tests/compose/test_registry_contract.py create mode 100644 tests/test_public_api.py diff --git a/pretab/compose/config.py b/pretab/compose/config.py new file mode 100644 index 0000000..834e1af --- /dev/null +++ b/pretab/compose/config.py @@ -0,0 +1,147 @@ +"""Normalized, validated configuration for a :class:`Preprocessor` run. + +: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. + +Per-column overrides in ``feature_preprocessing`` are kept verbatim because the +namespace they resolve in (numerical vs categorical) depends on the column type, +which is only known after feature detection; :meth:`PreprocessorConfig.method_for` +resolves them in the correct namespace at build time. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from ..core.parameters import validate_placement +from .registry import ( + CATEGORICAL_ALIASES, + CATEGORICAL_METHODS, + NUMERICAL_ALIASES, + NUMERICAL_METHODS, + resolve_method, +) + +__all__ = ["PreprocessorConfig"] + + +def _normalize_method(method, canonical, aliases) -> str: + """Resolve a global method name to canonical form, mapping ``None`` to ``"none"``.""" + if method is None: + return "none" + return resolve_method(method, canonical, aliases) + + +@dataclass(frozen=True) +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. + """ + + numerical_method: str + categorical_method: str + feature_preprocessing: dict + output_dim: int + degree: int + target_aware: bool + placement_strategy: str + task: str + adaptive: bool + min_output_dim: int + max_output_dim: int + random_state: int | None + scaling: str | None + cat_cutoff: float | int + treat_all_integers_as_numerical: bool + handle_missing: str + verbose: int + + @classmethod + def from_params( + cls, + *, + numerical_method, + categorical_method, + feature_preprocessing, + output_dim, + degree, + target_aware, + placement_strategy, + task, + adaptive, + min_output_dim, + max_output_dim, + random_state, + scaling, + cat_cutoff, + treat_all_integers_as_numerical, + handle_missing, + verbose, + ) -> PreprocessorConfig: + """Normalize and validate raw Preprocessor parameters into a config. + + Raises + ------ + InvalidParamError + If the ``target_aware`` / ``placement_strategy`` combination is + invalid (via :func:`~pretab.core.parameters.validate_placement`). + """ + validate_placement(target_aware, placement_strategy) + return cls( + numerical_method=_normalize_method(numerical_method, NUMERICAL_METHODS, NUMERICAL_ALIASES), + categorical_method=_normalize_method(categorical_method, CATEGORICAL_METHODS, CATEGORICAL_ALIASES), + feature_preprocessing=dict(feature_preprocessing or {}), + output_dim=output_dim, + degree=degree, + target_aware=target_aware, + placement_strategy=placement_strategy, + task=task, + adaptive=adaptive, + min_output_dim=min_output_dim, + max_output_dim=max_output_dim, + random_state=random_state, + scaling=scaling, + cat_cutoff=cat_cutoff, + treat_all_integers_as_numerical=treat_all_integers_as_numerical, + handle_missing=handle_missing, + verbose=verbose, + ) + + @staticmethod + def resolve_numerical(method) -> str: + """Resolve a numerical method name to its canonical spelling.""" + return resolve_method(method, NUMERICAL_METHODS, NUMERICAL_ALIASES) + + @staticmethod + def resolve_categorical(method) -> str: + """Resolve a categorical method name to its canonical spelling.""" + return resolve_method(method, CATEGORICAL_METHODS, CATEGORICAL_ALIASES) + + def method_for(self, feature, *, is_numerical: bool) -> str: + """Return the resolved method for ``feature`` given its detected kind. + + A per-column override in ``feature_preprocessing`` wins over the global + default; the chosen name is resolved in the numerical or categorical + namespace according to ``is_numerical``. + """ + default = self.numerical_method if is_numerical else self.categorical_method + raw = self.feature_preprocessing.get(feature, default) + return self.resolve_numerical(raw) if is_numerical else self.resolve_categorical(raw) + + @property + def seed_kwargs(self) -> dict: + """Return ``{"random_state": ...}`` only when a seed was explicitly set. + + Leaving it empty when ``random_state`` is ``None`` preserves each + transformer's own default seed, matching the historical behaviour where a + seed is forwarded only when the user pins one. + """ + return {} if self.random_state is None else {"random_state": self.random_state} diff --git a/pretab/compose/factory.py b/pretab/compose/factory.py new file mode 100644 index 0000000..3716707 --- /dev/null +++ b/pretab/compose/factory.py @@ -0,0 +1,267 @@ +"""Build the per-column transformer pipelines and combine them. + +This module turns a resolved method name plus a :class:`PreprocessorConfig` into +scikit-learn transformer steps, wraps them in a per-column +:class:`~sklearn.pipeline.Pipeline`, and assembles every column into the final +:class:`~sklearn.compose.ColumnTransformer`. The class to instantiate, the +constructor arguments it accepts, and which placement keyword arguments apply are +all taken from :data:`~pretab.compose.registry.TRANSFORMER_REGISTRY`. +""" + +import warnings + +from sklearn.compose import ColumnTransformer +from sklearn.impute import SimpleImputer +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import MinMaxScaler, StandardScaler + +from ..core.parameters import UNSET +from ..exceptions import ConfigWarning, invalid_param_error +from ..transformers.encoders.floats import ToFloatTransformer +from .config import PreprocessorConfig +from .registry import ( + CATEGORICAL_ALIASES, + CATEGORICAL_METHODS, + NUMERICAL_ALIASES, + NUMERICAL_METHODS, + TransformerSpec, + get_spec, + resolve_method, +) + +__all__ = [ + "build_column_transformer", + "create_transformer", + "get_categorical_transformer_steps", + "get_numerical_transformer_steps", +] + +# Valid range for the number of B/M/I spline basis functions per feature. The +# Preprocessor shares a single ``output_dim`` across every numerical strategy +# (default 7); values outside this window are clamped for the B/M/I splines. +_MIN_SPLINE_BASIS = 5 +_MAX_SPLINE_BASIS = 50 + +# B/M/I spline bases whose shared ``output_dim`` is clamped into the basis range. +_BMI_SPLINE_METHODS = frozenset({"bspline", "mspline", "ispline"}) +# Freely-placed knot splines built through the knot-wiring construction path +# (B/M/I plus the legacy cubic / natural-cubic regression splines). +_KNOT_SPLINE_METHODS = _BMI_SPLINE_METHODS | frozenset({"cubicspline", "naturalspline"}) + + +def _filter_kwargs(allowed, kwargs): + """Keep only the ``allowed`` keyword arguments that are present in ``kwargs``.""" + return {key: kwargs[key] for key in allowed if key in kwargs} + + +def _clamp_spline_basis(output_dim): + """Clamp a requested output dimension into the supported B/M/I spline range. + + Values outside ``[5, 50]`` are clamped and a :class:`ConfigWarning` is + emitted so switching to a B/M/I spline keeps working with the shared default. + """ + clamped = max(_MIN_SPLINE_BASIS, min(int(output_dim), _MAX_SPLINE_BASIS)) + if clamped != output_dim: + warnings.warn( + f"output_dim={output_dim} is outside the spline range " + f"[{_MIN_SPLINE_BASIS}, {_MAX_SPLINE_BASIS}]; using {clamped} basis functions.", + ConfigWarning, + stacklevel=2, + ) + return clamped + + +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. + """ + 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: + out["placement_strategy"] = placement_strategy + return out + if spec.target_usage == "required": + if target_aware and placement_strategy in ("cart", "lightgbm"): + return {"placement_strategy": placement_strategy} + return {} + # target_usage == "forbidden" but with unsupervised placement (pspline / tensorspline). + if not target_aware and placement_strategy in ("uniform", "quantile"): + return {"placement_strategy": placement_strategy} + return {} + + +def get_numerical_transformer_steps( + method: str, + add_imputer: bool = True, + imputer_strategy: str = "mean", + imputer_kwargs: dict | None = None, + scaling: str | None = None, + **kwargs, +): + """Return the ordered ``(name, transformer)`` steps for a numerical ``method``.""" + method = resolve_method(method, NUMERICAL_METHODS, NUMERICAL_ALIASES) + steps = [] + + if add_imputer: + imputer_kwargs = imputer_kwargs or {} + steps.append(("imputer", SimpleImputer(strategy=imputer_strategy, **imputer_kwargs))) + + # Optional scaling step, added only when it is not already the chosen method. + scalers = { + "standardization": ("scaler", StandardScaler()), + "minmax": ("minmax", MinMaxScaler(feature_range=(-1, 1))), + } + if scaling is not None: + scaling = resolve_method(scaling, NUMERICAL_METHODS, NUMERICAL_ALIASES) + if scaling in scalers and scaling != method: + steps.append(scalers[scaling]) + + if method not in NUMERICAL_METHODS: + raise invalid_param_error( + "get_numerical_transformer_steps", + "method", + method, + "unrecognized numerical preprocessing method", + valid=set(NUMERICAL_METHODS), + ) + + spec = get_spec(method) + cls = spec.transformer_cls + filtered = _filter_kwargs(spec.allowed_args, kwargs) + placement = _placement_kwargs(spec, kwargs) + + if method == "box-cox": + steps.append(("scale_positive", MinMaxScaler(feature_range=(1e-3, 1)))) + steps.append(("boxcox", cls(method="box-cox", **filtered))) + elif method == "yeo-johnson": + steps.append(("yeojohnson", cls(method="yeo-johnson", **filtered))) + elif method in _KNOT_SPLINE_METHODS: + spline_kwargs = dict(filtered) + spline_kwargs.update(placement) + + # The B/M/I splines share the Preprocessor's default ``output_dim`` (which + # can sit outside their [5, 50] basis range); the legacy families keep + # their own wider bounds, so only clamp for B/M/I. + if method in _BMI_SPLINE_METHODS: + output_dim = kwargs.get("output_dim") + if output_dim is not None: + spline_kwargs["output_dim"] = _clamp_spline_basis(output_dim) + + steps.append((method, cls(**spline_kwargs))) + else: + name = method if method != "none" else "noop" + call_kwargs = dict(filtered) + call_kwargs.update(placement) + steps.append((name, cls(**call_kwargs))) + + return steps + + +def get_categorical_transformer_steps( + method: str, + add_imputer: bool = True, + imputer_strategy: str = "most_frequent", + imputer_kwargs: dict | None = None, + output_dim=UNSET, + **kwargs, +): + """Return the ordered ``(name, transformer)`` steps for a categorical ``method``.""" + method = resolve_method(method, CATEGORICAL_METHODS, CATEGORICAL_ALIASES) + steps = [] + + if add_imputer: + imputer_kwargs = imputer_kwargs or {} + steps.append(("imputer", SimpleImputer(strategy=imputer_strategy, **imputer_kwargs))) + + if method not in CATEGORICAL_METHODS: + raise invalid_param_error( + "get_categorical_transformer_steps", + "method", + method, + "unrecognized categorical preprocessing method", + valid=set(CATEGORICAL_METHODS), + ) + + cls = get_spec(method).transformer_cls + + if method == "int": + steps.append(("continuous_ordinal", cls())) + elif method == "one-hot": + # Default to ignoring unseen categories so transform never crashes on + # categories absent at fit time; callers can override via kwargs. + onehot_kwargs = {"handle_unknown": "ignore", **kwargs} + steps.append(("onehot", cls(**onehot_kwargs))) + steps.append(("to_float", ToFloatTransformer())) + elif method == "pretrained": + steps.append(("pretrained", cls())) + elif method == "none": + steps.append(("none", cls())) + elif method == "custombin": + bin_kwargs = dict(kwargs) + if output_dim is not UNSET: + bin_kwargs.setdefault("output_dim", output_dim) + steps.append(("custombin", cls(**bin_kwargs))) + elif method == "onehot_from_ordinal": + steps.append(("onehot_from_ordinal", cls())) + + return steps + + +def create_transformer(method: str, *, is_numerical: bool, config: PreprocessorConfig) -> Pipeline: + """Build the per-column :class:`~sklearn.pipeline.Pipeline` for one feature. + + ``method`` is the resolved method name; ``is_numerical`` selects the numerical + or categorical construction path. All width / placement / seeding knobs are + taken from ``config``. + """ + if is_numerical: + steps = get_numerical_transformer_steps( + method=method, + task=config.task, + target_aware=config.target_aware, + add_imputer=config.handle_missing != "error", + imputer_strategy="mean", + output_dim=config.output_dim, + adaptive=config.adaptive, + min_output_dim=config.min_output_dim if config.adaptive else None, + max_output_dim=config.max_output_dim if config.adaptive else None, + degree=config.degree, + scaling=config.scaling, + placement_strategy=config.placement_strategy, + handle_missing=config.handle_missing, + **config.seed_kwargs, + ) + else: + steps = get_categorical_transformer_steps(method, output_dim=config.output_dim) + return Pipeline(steps) + + +def build_column_transformer(config: PreprocessorConfig, numerical_features, categorical_features) -> ColumnTransformer: + """Assemble the per-column pipelines into the final ColumnTransformer. + + Numerical features are prefixed ``num_`` and categorical features ``cat_`` to + match the transformer names the Preprocessor exposes; untransformed columns + pass through via ``remainder="passthrough"``. + """ + transformers = [] + for feature in numerical_features: + method = config.method_for(feature, is_numerical=True) + pipeline = create_transformer(method, is_numerical=True, config=config) + transformers.append((f"num_{feature}", pipeline, [feature])) + for feature in categorical_features: + method = config.method_for(feature, is_numerical=False) + pipeline = create_transformer(method, is_numerical=False, config=config) + transformers.append((f"cat_{feature}", pipeline, [feature])) + return ColumnTransformer(transformers=transformers, remainder="passthrough") diff --git a/pretab/compose/feature_detection.py b/pretab/compose/feature_detection.py new file mode 100644 index 0000000..096ef72 --- /dev/null +++ b/pretab/compose/feature_detection.py @@ -0,0 +1,74 @@ +"""Coerce inputs to 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 +sequence of delegations rather than inlining the classification heuristic. +""" + +import numpy as np +import pandas as pd + +from ..exceptions import invalid_param_error + +__all__ = ["detect_column_types", "to_dataframe"] + + +def to_dataframe(X, *, copy: bool = False) -> pd.DataFrame: + """Return ``X`` as a 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. + """ + if isinstance(X, dict): + return pd.DataFrame(X) + if isinstance(X, np.ndarray): + return pd.DataFrame(X, columns=[f"feature_{i}" for i in range(X.shape[1])]) + return X.copy() if copy else X + + +def detect_column_types(X, *, cat_cutoff, treat_all_integers_as_numerical, estimator_name="Preprocessor"): + """Classify each column of ``X`` as numerical or categorical. + + An integer column is treated as categorical when its cardinality falls below + ``cat_cutoff`` -- interpreted as a unique-ratio cutoff when a float, or an + absolute unique-count cutoff when an int. Non-numeric dtypes are always + categorical; ``treat_all_integers_as_numerical`` bypasses the heuristic for + integer columns. + + Returns + ------- + numerical_features : list + Column labels detected as numerical. + categorical_features : list + Column labels detected as categorical. + """ + X = to_dataframe(X) + + categorical_features = [] + numerical_features = [] + + for col in X.columns: + num_unique_values = X[col].nunique() + total_samples = len(X[col]) + + if treat_all_integers_as_numerical and X[col].dtype.kind == "i": + numerical_features.append(col) + else: + if isinstance(cat_cutoff, float): + cutoff_condition = (num_unique_values / total_samples) < cat_cutoff + elif isinstance(cat_cutoff, int): + cutoff_condition = num_unique_values < cat_cutoff + else: + raise invalid_param_error( + estimator_name, + "cat_cutoff", + cat_cutoff, + "must be a float (unique-ratio cutoff) or an int (absolute unique-count cutoff)", + ) + + if X[col].dtype.kind not in "iufc" or (X[col].dtype.kind == "i" and cutoff_condition): + categorical_features.append(col) + else: + numerical_features.append(col) + + return numerical_features, categorical_features diff --git a/pretab/compose/inspection.py b/pretab/compose/inspection.py new file mode 100644 index 0000000..d3bbcd9 --- /dev/null +++ b/pretab/compose/inspection.py @@ -0,0 +1,172 @@ +"""Introspect a fitted ColumnTransformer for output layout and feature metadata. + +These helpers back the Preprocessor's ``transform`` slicing and its +``get_feature_info`` reporting: :func:`get_output_slices` computes each +transformer's contiguous span in the stacked output, :func:`build_feature_info` +collects per-feature preprocessing / dimension / category metadata, and +:func:`build_transformer_summary` renders that metadata as an aligned table. +""" + +import numpy as np + +from ..core.logging import get_logger + +logger = get_logger(__name__) + +__all__ = ["build_feature_info", "build_transformer_summary", "get_output_slices"] + + +def get_output_slices(column_transformer, X): + """Return ordered ``(name, start, width)`` spans for each output block. + + The width of each transformer's block is obtained by transforming its input + columns, matching the order in which the fitted ColumnTransformer stacks its + outputs. + """ + slices = [] + start = 0 + for name, transformer, columns in column_transformer.transformers_: + if transformer == "drop": + continue + if hasattr(transformer, "transform"): + width = transformer.transform(X[columns]).shape[1] + else: + width = 1 + slices.append((name, start, width)) + start += width + return slices + + +def build_feature_info(column_transformer, *, embeddings, embedding_dimensions): + """Collect per-feature metadata (preprocessing, dimension, categories). + + Returns a ``(numerical_info, categorical_info, embedding_info)`` tuple of + dicts keyed by feature name. + """ + numerical_feature_info = {} + categorical_feature_info = {} + + embedding_feature_info = ( + { + key: {"preprocessing": None, "dimension": dim, "categories": None} + for key, dim in embedding_dimensions.items() + } + if embeddings + else {} + ) + + for ( + name, + transformer_pipeline, + columns, + ) in column_transformer.transformers_: + steps = [step[0] for step in transformer_pipeline.steps] + + for feature_name in columns: + preprocessing_type = " -> ".join(steps) + dimension = None + categories = None + + if "discretizer" in steps or any( + step in steps + for step in [ + "standardization", + "minmax", + "quantile", + "polynomial", + "splines", + "box-cox", + ] + ): + last_step = transformer_pipeline.steps[-1][1] + if hasattr(last_step, "transform"): + dummy_input = np.zeros((1, 1)) + 1e-05 + try: + transformed_feature = last_step.transform(dummy_input) + dimension = transformed_feature.shape[1] + except (ValueError, TypeError, AttributeError, IndexError) as exc: + logger.debug( + "Could not introspect output width of %r: %s", + feature_name, + exc, + ) + dimension = None + numerical_feature_info[feature_name] = { + "preprocessing": preprocessing_type, + "dimension": dimension, + "categories": None, + } + + elif "continuous_ordinal" in steps: + step = transformer_pipeline.named_steps["continuous_ordinal"] + categories = len(step.mapping_[columns.index(feature_name)]) + dimension = 1 + categorical_feature_info[feature_name] = { + "preprocessing": preprocessing_type, + "dimension": dimension, + "categories": categories, + } + + elif "onehot" in steps: + step = transformer_pipeline.named_steps["onehot"] + if hasattr(step, "categories_"): + categories = sum(len(cat) for cat in step.categories_) + dimension = categories + categorical_feature_info[feature_name] = { + "preprocessing": preprocessing_type, + "dimension": dimension, + "categories": categories, + } + + else: + last_step = transformer_pipeline.steps[-1][1] + if hasattr(last_step, "transform"): + dummy_input = np.zeros((1, 1)) + try: + transformed_feature = last_step.transform(dummy_input) + dimension = transformed_feature.shape[1] + except (ValueError, TypeError, AttributeError, IndexError) as exc: + logger.debug( + "Could not introspect output width of %r: %s", + feature_name, + exc, + ) + dimension = None + if "cat" in name: + categorical_feature_info[feature_name] = { + "preprocessing": preprocessing_type, + "dimension": dimension, + "categories": None, + } + else: + numerical_feature_info[feature_name] = { + "preprocessing": preprocessing_type, + "dimension": dimension, + "categories": None, + } + + return numerical_feature_info, categorical_feature_info, embedding_feature_info + + +def build_transformer_summary(numerical_info, categorical_info, embedding_info): + """Build aligned, human-readable rows describing the fitted feature layout.""" + rows = [] + for feat, info in numerical_info.items(): + rows.append((str(feat), "numerical", str(info["preprocessing"]), info["dimension"], info["categories"])) + for feat, info in categorical_info.items(): + rows.append((str(feat), "categorical", str(info["preprocessing"]), info["dimension"], info["categories"])) + for feat, info in embedding_info.items(): + rows.append((str(feat), "embedding", "-", info["dimension"], info["categories"])) + if not rows: + return [] + + feat_w = max(len("feature"), *(len(r[0]) for r in rows)) + kind_w = max(len("kind"), *(len(r[1]) for r in rows)) + pipe_w = max(len("pipeline"), *(len(r[2]) for r in rows)) + header = f"{'feature':<{feat_w}} {'kind':<{kind_w}} {'pipeline':<{pipe_w}} {'dim':>4} {'cats':>5}" + lines = [header, "-" * len(header)] + for feat, kind, pipe, dim, cats in rows: + dim_s = "-" if dim is None else str(dim) + cats_s = "-" if cats is None else str(cats) + lines.append(f"{feat:<{feat_w}} {kind:<{kind_w}} {pipe:<{pipe_w}} {dim_s:>4} {cats_s:>5}") + return lines diff --git a/pretab/compose/output.py b/pretab/compose/output.py new file mode 100644 index 0000000..f181a07 --- /dev/null +++ b/pretab/compose/output.py @@ -0,0 +1,73 @@ +"""Format the fitted ColumnTransformer output into the public return shapes. + +The Preprocessor returns either a single stacked NumPy array or a dictionary that +keeps each feature's transformed block separate (and any external embedding +blocks alongside them). This module owns that formatting only -- it performs no +fitting and holds no capability logic; the per-block slices it consumes are +computed in :mod:`pretab.compose.inspection`. +""" + +import numpy as np + +from ..exceptions import IncompatibleParamsError + +__all__ = ["attach_embeddings", "build_output_dict", "format_output"] + +_EMBEDDINGS_NOT_EXPECTED = ( + "Embeddings were not expected, but were provided.\n" + "Fix: configure an embedding feature in feature_preprocessing before " + "passing embeddings to transform, or omit the embeddings argument." +) + + +def build_output_dict(transformed, slices) -> dict: + """Split a stacked array into a name -> block dict using ``slices``. + + ``slices`` is an ordered iterable of ``(name, start, width)`` describing each + transformer's contiguous span in the stacked output. + """ + return {name: transformed[:, start : start + width] for name, start, width in slices} + + +def attach_embeddings(result: dict, embeddings, *, expected: bool) -> dict: + """Attach external embedding blocks to a transformed-output dict. + + Raises + ------ + IncompatibleParamsError + If ``embeddings`` are provided but none were configured at fit time. + """ + if not expected: + raise IncompatibleParamsError(_EMBEDDINGS_NOT_EXPECTED) + if isinstance(embeddings, np.ndarray): + result["embedding_1"] = embeddings.astype(np.float32) + elif isinstance(embeddings, list): + for idx, e in enumerate(embeddings): + result[f"embedding_{idx + 1}"] = e.astype(np.float32) + return result + + +def format_output(transformed, *, return_array, slices=None, embeddings=None, embeddings_expected=False): + """Return the transformed data as a stacked array or a per-block dict. + + Parameters + ---------- + transformed : numpy.ndarray + The stacked array produced by the fitted ColumnTransformer. + return_array : bool + If True, return ``transformed`` unchanged; otherwise build the dict. + slices : iterable of (str, int, int), optional + Ordered ``(name, start, width)`` spans; required when ``return_array`` is + False. + embeddings : numpy.ndarray or list of numpy.ndarray, optional + External embedding blocks to attach to the dict output. + embeddings_expected : bool, default=False + Whether embedding blocks were configured at fit time. + """ + if return_array: + return transformed + + result = build_output_dict(transformed, slices or []) + if embeddings is not None: + attach_embeddings(result, embeddings, expected=embeddings_expected) + return result diff --git a/pretab/compose/registry.py b/pretab/compose/registry.py new file mode 100644 index 0000000..fa20783 --- /dev/null +++ b/pretab/compose/registry.py @@ -0,0 +1,439 @@ +"""Single capability registry for every preprocessing method. + +This module is the one place that answers *what a method is and what it can do*: +the transformer class to instantiate, the constructor arguments it accepts, and +the capability flags (feature kind, arity, target usage, valid placement +strategies, adaptive-resolution support, preprocessor compatibility, optional +dependency). The composition layer (:mod:`pretab.compose.config`, +:mod:`pretab.compose.factory`) and the public contract tests all derive their +behaviour from this table rather than from scattered per-family lists. + +Name resolution (aliases + separator/case-insensitive matching) also lives here +so both the numerical and categorical sides resolve user-supplied method names +through a single implementation. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from sklearn.preprocessing import ( + MinMaxScaler, + OneHotEncoder, + PolynomialFeatures, + PowerTransformer, + QuantileTransformer, + RobustScaler, + StandardScaler, +) + +from ..transformers.categorical.language_embedding import ( + LanguageEmbeddingTransformer, +) +from ..transformers.categorical.legacy import OneHotFromOrdinalTransformer +from ..transformers.categorical.ordinal import ContinuousOrdinalTransformer +from ..transformers.encoders.floats import NoTransformer +from ..transformers.feature_maps.rbf import RBFExpansionTransformer +from ..transformers.feature_maps.relu import ReLUExpansionTransformer +from ..transformers.feature_maps.sigmoid import SigmoidExpansionTransformer +from ..transformers.feature_maps.tanh import TanhExpansionTransformer +from ..transformers.numerical.binning import CustomBinTransformer +from ..transformers.numerical.piecewise import PLETransformer +from ..transformers.splines.b_spline import BSplineTransformer +from ..transformers.splines.cubic_regression import CubicSplineTransformer +from ..transformers.splines.i_spline import ISplineTransformer +from ..transformers.splines.m_spline import MSplineTransformer +from ..transformers.splines.multivariate.tensor_product import ( + TensorProductSplineTransformer, +) +from ..transformers.splines.multivariate.thin_plate import ( + ThinPlateSplineTransformer, +) +from ..transformers.splines.natural_cubic import NaturalCubicSplineTransformer +from ..transformers.splines.p_spline import PSplineTransformer + +__all__ = [ + "CATEGORICAL_ALIASES", + "CATEGORICAL_METHODS", + "NUMERICAL_ALIASES", + "NUMERICAL_METHODS", + "TRANSFORMER_REGISTRY", + "TransformerSpec", + "categorical_method_names", + "get_spec", + "numerical_method_names", + "placement_strategies_for", + "resolve_method", + "supports_adaptive_resolution", + "supports_target_aware", +] + +# Canonical feature kinds a method can apply to. +NUMERICAL = "numerical" +CATEGORICAL = "categorical" + +# Canonical placement strategy names, split by supervision. +_UNSUPERVISED_STRATEGIES = frozenset({"uniform", "quantile"}) +_TARGET_AWARE_STRATEGIES = frozenset({"cart", "lightgbm"}) +_ALL_STRATEGIES = _UNSUPERVISED_STRATEGIES | _TARGET_AWARE_STRATEGIES + + +@dataclass(frozen=True) +class TransformerSpec: + """Declarative capability record for a single preprocessing method. + + Parameters + ---------- + name : str + Canonical method name (the registry key). + transformer_cls : type + The scikit-learn-compatible transformer class to instantiate. Methods + that build extra steps (e.g. ``one-hot`` appends a float cast, the power + transforms prepend a positive-range scaler) record their primary class + here; the extra wiring lives in :mod:`pretab.compose.factory`. + allowed_args : tuple of str + Constructor argument names the method accepts. Used to filter the shared + Preprocessor keyword arguments down to what the class understands. + feature_kind : frozenset of str + The feature kinds the method applies to (``"numerical"`` and/or + ``"categorical"``). ``custombin`` and ``none`` apply to both. + arity : {"univariate", "multivariate"} + Whether the method transforms one column at a time (``"univariate"``) or + jointly models several columns (``"multivariate"`` -- the tensor-product + and thin-plate splines). + target_usage : {"forbidden", "optional", "required"} + How the method uses the supervised target ``y`` for basis placement. + ``"required"`` methods (PLE) always place against ``y``; ``"optional"`` + methods (feature maps and freely-placed knot splines) place against ``y`` + only when ``target_aware`` is set; ``"forbidden"`` methods never use it. + placement_strategies : frozenset of str + The placement strategies the method honours. Empty for methods with no + data-driven placement. + supports_adaptive_resolution : bool + Whether the method can size each feature's output dimension from the data + (within ``[min_output_dim, max_output_dim]``) instead of a fixed width. + preprocessor_compatible : bool + Whether the method can be selected per column through :class:`Preprocessor`. + optional_dependency : str or None + The optional extra that must be installed for the method to run + (``pip install pretab[]``), or ``None`` when always available. + """ + + name: str + transformer_cls: type + allowed_args: tuple[str, ...] = () + feature_kind: frozenset[str] = field(default_factory=lambda: frozenset({NUMERICAL})) + arity: str = "univariate" + target_usage: str = "forbidden" + placement_strategies: frozenset[str] = frozenset() + supports_adaptive_resolution: bool = False + preprocessor_compatible: bool = True + optional_dependency: str | None = None + + @property + def is_numerical(self) -> bool: + """Whether the method applies to numerical columns.""" + return NUMERICAL in self.feature_kind + + @property + def is_categorical(self) -> bool: + """Whether the method applies to categorical columns.""" + return CATEGORICAL in self.feature_kind + + @property + def is_multivariate(self) -> bool: + """Whether the method jointly models several columns.""" + return self.arity == "multivariate" + + @property + def target_aware_capable(self) -> bool: + """Whether the method can place basis units against ``y``.""" + return self.target_usage in ("optional", "required") + + @property + def requires_target(self) -> bool: + """Whether the method always needs ``y`` for placement.""" + return self.target_usage == "required" + + +def _spec(name, cls, allowed_args=(), **kwargs): + """Construct a :class:`TransformerSpec`, normalising ``allowed_args``.""" + return TransformerSpec(name=name, transformer_cls=cls, allowed_args=tuple(allowed_args), **kwargs) + + +# Feature-map centers and freely-placed knot splines share the same placement +# capability: optional target awareness across all four strategies, plus adaptive +# resolution. +_BOTH_MODE = { + "target_usage": "optional", + "placement_strategies": _ALL_STRATEGIES, + "supports_adaptive_resolution": True, +} + +# name -> capability spec. Numerical methods first (preserving the historical +# ordering), then the categorical-only methods. +_SPECS: tuple[TransformerSpec, ...] = ( + # --- numerical: scalers / distribution transforms (no placement) --- + _spec("standardization", StandardScaler), + _spec("minmax", MinMaxScaler), + _spec("quantile", QuantileTransformer, ("n_quantiles", "output_distribution", "random_state")), + _spec("polynomial", PolynomialFeatures, ("degree", "interaction_only", "include_bias")), + _spec("robust", RobustScaler), + _spec("box-cox", PowerTransformer), + _spec("yeo-johnson", PowerTransformer), + # --- numerical: piecewise-linear encoding (always target-aware) --- + _spec( + "ple", + PLETransformer, + ("output_dim", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state", "handle_missing"), + target_usage="required", + placement_strategies=_TARGET_AWARE_STRATEGIES, + supports_adaptive_resolution=True, + ), + # --- numerical / categorical: binning (no placement) --- + _spec("custombin", CustomBinTransformer, ("output_dim",), feature_kind=frozenset({NUMERICAL, CATEGORICAL})), + # --- numerical: feature maps (optional target-aware, adaptive) --- + _spec( + "rbf", + RBFExpansionTransformer, + ("output_dim", "gamma", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state"), + **_BOTH_MODE, + ), + _spec( + "relu", + ReLUExpansionTransformer, + ("output_dim", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state"), + **_BOTH_MODE, + ), + _spec( + "sigmoid", + SigmoidExpansionTransformer, + ("output_dim", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state"), + **_BOTH_MODE, + ), + _spec( + "tanh", + TanhExpansionTransformer, + ("output_dim", "scale", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state"), + **_BOTH_MODE, + ), + # --- numerical: freely-placed knot splines (optional target-aware, adaptive) --- + _spec( + "cubicspline", + CubicSplineTransformer, + ("output_dim", "degree", "include_bias", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state"), + **_BOTH_MODE, + ), + _spec( + "naturalspline", + NaturalCubicSplineTransformer, + ("output_dim", "include_bias", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state"), + **_BOTH_MODE, + ), + # --- numerical: penalized splines (equally-spaced knots, unsupervised only) --- + _spec( + "pspline", + PSplineTransformer, + ("output_dim", "degree", "diff_order"), + placement_strategies=_UNSUPERVISED_STRATEGIES, + ), + _spec( + "tensorspline", + TensorProductSplineTransformer, + ("output_dim", "degree", "diff_order"), + arity="multivariate", + placement_strategies=_UNSUPERVISED_STRATEGIES, + ), + # --- numerical: kernel-based thin-plate spline (knot-free, multivariate) --- + _spec("tprs", ThinPlateSplineTransformer, ("output_dim",), arity="multivariate"), + # --- numerical: B / M / I spline bases (optional target-aware, adaptive) --- + _spec( + "bspline", + BSplineTransformer, + ("degree", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state"), + **_BOTH_MODE, + ), + _spec( + "mspline", + MSplineTransformer, + ("degree", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state"), + **_BOTH_MODE, + ), + _spec( + "ispline", + ISplineTransformer, + ("degree", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state"), + **_BOTH_MODE, + ), + # --- numerical / categorical: passthrough --- + _spec("none", NoTransformer, feature_kind=frozenset({NUMERICAL, CATEGORICAL})), + # --- categorical-only methods --- + _spec("int", ContinuousOrdinalTransformer, feature_kind=frozenset({CATEGORICAL})), + _spec("one-hot", OneHotEncoder, feature_kind=frozenset({CATEGORICAL})), + _spec("onehot_from_ordinal", OneHotFromOrdinalTransformer, feature_kind=frozenset({CATEGORICAL})), + _spec( + "pretrained", + LanguageEmbeddingTransformer, + feature_kind=frozenset({CATEGORICAL}), + optional_dependency="embeddings", + ), +) + +TRANSFORMER_REGISTRY: dict[str, TransformerSpec] = {spec.name: spec for spec in _SPECS} + + +# --------------------------------------------------------------------------- +# Derived views (kept in sync with the registry; do not edit by hand). +# --------------------------------------------------------------------------- +def numerical_method_names() -> frozenset[str]: + """Return the set of canonical numerical method names.""" + return frozenset(name for name, spec in TRANSFORMER_REGISTRY.items() if spec.is_numerical) + + +def categorical_method_names() -> frozenset[str]: + """Return the set of canonical categorical method names.""" + return frozenset(name for name, spec in TRANSFORMER_REGISTRY.items() if spec.is_categorical) + + +# Derived lookup tables consumed by the factory and config layers. +# ``NUMERICAL_METHODS`` maps a numerical method to ``(class, allowed_args_list)``; +# ``CATEGORICAL_METHODS`` is the set of categorical method names. +NUMERICAL_METHODS: dict[str, tuple[type, list[str]]] = { + name: (spec.transformer_cls, list(spec.allowed_args)) + for name, spec in TRANSFORMER_REGISTRY.items() + if spec.is_numerical +} +CATEGORICAL_METHODS: frozenset[str] = categorical_method_names() + + +def get_spec(method: str) -> TransformerSpec: + """Return the :class:`TransformerSpec` for a canonical ``method`` name. + + Raises + ------ + KeyError + If ``method`` is not a registered canonical method name. Callers that + accept user input should resolve the name via :func:`resolve_method` + first. + """ + return TRANSFORMER_REGISTRY[method] + + +def placement_strategies_for(method: str) -> frozenset[str]: + """Return the placement strategies a canonical ``method`` honours.""" + return TRANSFORMER_REGISTRY[method].placement_strategies + + +def supports_adaptive_resolution(method: str) -> bool: + """Return whether a canonical ``method`` supports adaptive output sizing.""" + return TRANSFORMER_REGISTRY[method].supports_adaptive_resolution + + +def supports_target_aware(method: str) -> bool: + """Return whether ``method`` supports target-aware basis placement. + + Accepts an alias or separator variant; resolves it first. True for the + feature maps, PLE, and the freely-placed knot splines (``bspline`` / + ``mspline`` / ``ispline`` / ``cubicspline`` / ``naturalspline``); False for + the penalized and kernel-based splines and every non-placement method. + """ + resolved = resolve_method(method, TRANSFORMER_REGISTRY, NUMERICAL_ALIASES) + spec = TRANSFORMER_REGISTRY.get(resolved) + return bool(spec and spec.target_aware_capable) + + +# --------------------------------------------------------------------------- +# Name resolution (aliases + separator/case-insensitive matching). +# --------------------------------------------------------------------------- +def _squash(name: str) -> str: + """Collapse a method name for separator/case-insensitive comparison. + + Lowercases, trims surrounding whitespace, and drops the ``-``, ``_`` and + space separators so ``"One-Hot"``, ``"one_hot"`` and ``"onehot"`` all map to + the same key. Canonical names that only differ by a separator (``"box-cox"`` + vs ``"boxcox"``, ``"cubicspline"`` vs ``"cubic spline"``) therefore match + without needing an explicit alias entry. + """ + return name.strip().lower().replace("-", "").replace("_", "").replace(" ", "") + + +# Genuine synonyms / abbreviations that are *not* just separator variants of a +# canonical name (those are handled by :func:`_squash`). Keys are already +# squashed; values are canonical numerical method names. +NUMERICAL_ALIASES = { + "standard": "standardization", + "standardize": "standardization", + "standardscaler": "standardization", + "std": "standardization", + "zscore": "standardization", + "minmaxscaler": "minmax", + "quantiletransformer": "quantile", + "poly": "polynomial", + "robustscaler": "robust", + "piecewiselinear": "ple", + "bin": "custombin", + "binning": "custombin", + "cubic": "cubicspline", + "natural": "naturalspline", + "naturalcubic": "naturalspline", + "tensor": "tensorspline", + "tensorproduct": "tensorspline", + "tensorproductspline": "tensorspline", + "thinplate": "tprs", + "thinplatespline": "tprs", + "passthrough": "none", + "identity": "none", + "raw": "none", +} + +# Genuine synonyms / abbreviations for the categorical methods (keys squashed). +CATEGORICAL_ALIASES = { + "integer": "int", + "ordinal": "int", + "label": "int", + "labelencoder": "int", + "ordinalencoder": "int", + "ohe": "one-hot", + "dummy": "one-hot", + "onehotencoder": "one-hot", + "embedding": "pretrained", + "embeddings": "pretrained", + "language": "pretrained", + "llm": "pretrained", + "bin": "custombin", + "binning": "custombin", + "passthrough": "none", + "identity": "none", + "raw": "none", +} + + +def resolve_method(name, canonical, aliases): + """Resolve a user-supplied method name to its canonical spelling. + + Matching is case-insensitive, ignores ``-`` / ``_`` / space separators, and + honours the explicit ``aliases`` map of synonyms and abbreviations. An + unrecognized name is returned lowercased and stripped so the caller's own + "unrecognized method" error lists the canonical options. + + Parameters + ---------- + name : str + The method name the user supplied. + canonical : set or dict + The canonical method names (``NUMERICAL_METHODS`` keys or + ``CATEGORICAL_METHODS``). + aliases : dict + Squashed-alias to canonical-name mapping for this side of the pipeline. + """ + key = name.strip().lower() + if key in canonical: + return key + + squashed = _squash(name) + for canon in canonical: + if _squash(canon) == squashed: + return canon + if squashed in aliases: + return aliases[squashed] + return key diff --git a/pretab/pipeline/__init__.py b/pretab/pipeline/__init__.py deleted file mode 100644 index f340128..0000000 --- a/pretab/pipeline/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Pipeline assembly layer: build scikit-learn transformer steps per strategy. - -``get_numerical_transformer_steps`` and ``get_categorical_transformer_steps`` -turn a strategy name plus keyword arguments into an ordered list of -``(name, transformer)`` steps. The available numerical strategies are declared -in :mod:`pretab.pipeline.registry`. -""" - -from .categorical import get_categorical_transformer_steps -from .numerical import get_numerical_transformer_steps - -__all__ = [ - "get_categorical_transformer_steps", - "get_numerical_transformer_steps", -] diff --git a/pretab/pipeline/categorical.py b/pretab/pipeline/categorical.py deleted file mode 100644 index 527b9ed..0000000 --- a/pretab/pipeline/categorical.py +++ /dev/null @@ -1,62 +0,0 @@ -from sklearn.impute import SimpleImputer -from sklearn.preprocessing import OneHotEncoder - -from ..core.parameters import UNSET -from ..exceptions import invalid_param_error -from ..transformers.categorical.language_embedding import ( - LanguageEmbeddingTransformer, -) -from ..transformers.categorical.legacy import OneHotFromOrdinalTransformer -from ..transformers.categorical.ordinal import ContinuousOrdinalTransformer -from ..transformers.encoders.floats import NoTransformer, ToFloatTransformer -from ..transformers.numerical.binning import CustomBinTransformer -from .registry import CATEGORICAL_ALIASES, CATEGORICAL_METHODS, resolve_method - - -def get_categorical_transformer_steps( - method: str, - add_imputer: bool = True, - imputer_strategy: str = "most_frequent", - imputer_kwargs: dict | None = None, - output_dim=UNSET, - **kwargs, -): - """ - Returns a list of (name, transformer) steps for a given categorical preprocessing method. - """ - method = resolve_method(method, CATEGORICAL_METHODS, CATEGORICAL_ALIASES) - steps = [] - - if add_imputer: - imputer_kwargs = imputer_kwargs or {} - steps.append(("imputer", SimpleImputer(strategy=imputer_strategy, **imputer_kwargs))) - - if method == "int": - steps.append(("continuous_ordinal", ContinuousOrdinalTransformer())) - elif method == "one-hot": - # Default to ignoring unseen categories so transform never crashes on - # categories absent at fit time; callers can override via kwargs. - onehot_kwargs = {"handle_unknown": "ignore", **kwargs} - steps.append(("onehot", OneHotEncoder(**onehot_kwargs))) - steps.append(("to_float", ToFloatTransformer())) - elif method == "pretrained": - steps.append(("pretrained", LanguageEmbeddingTransformer())) - elif method == "none": - steps.append(("none", NoTransformer())) - elif method == "custombin": - bin_kwargs = dict(kwargs) - if output_dim is not UNSET: - bin_kwargs.setdefault("output_dim", output_dim) - steps.append(("custombin", CustomBinTransformer(**bin_kwargs))) - elif method == "onehot_from_ordinal": - steps.append(("onehot_from_ordinal", OneHotFromOrdinalTransformer())) - else: - raise invalid_param_error( - "get_categorical_transformer_steps", - "method", - method, - "unrecognized categorical preprocessing method", - valid=set(CATEGORICAL_METHODS), - ) - - return steps diff --git a/pretab/pipeline/numerical.py b/pretab/pipeline/numerical.py deleted file mode 100644 index 43c7806..0000000 --- a/pretab/pipeline/numerical.py +++ /dev/null @@ -1,184 +0,0 @@ -import warnings - -from sklearn.impute import SimpleImputer -from sklearn.preprocessing import MinMaxScaler, StandardScaler - -from ..exceptions import ConfigWarning, invalid_param_error -from .registry import NUMERICAL_ALIASES, NUMERICAL_METHODS, resolve_method - -# Spline basis expansions that share the target-aware knot API. -SPLINE_EXPANSION_METHODS = ("bspline", "mspline", "ispline") - -# Legacy knot-based spline families that also support target-aware placement. -# They use freely-placed knots (cubic / natural-cubic regression splines), so the -# selector / task / strategy / adaptive knobs apply; their knot selector uses the -# ``"bspline"`` spline_type. The penalized families (``pspline``, ``tensorspline``) -# assume equally-spaced knots for their difference penalty, and the thin-plate -# spline (``tprs``) is kernel-based (knot-free): none of those three are -# target-aware, so they stay on the generic fixed construction path. -LEGACY_SPLINE_METHODS = ("cubicspline", "naturalspline") - -# Every spline family for which target-aware (data-driven) knot placement is -# meaningful. Exposed via :func:`supports_target_aware` so callers can query it. -TARGET_AWARE_SPLINE_METHODS = SPLINE_EXPANSION_METHODS + LEGACY_SPLINE_METHODS - - -def supports_target_aware(method: str) -> bool: - """Return whether a spline ``method`` supports target-aware knot placement. - - Only freely-placed knot splines qualify: ``bspline``, ``mspline``, - ``ispline``, ``cubicspline`` and ``naturalspline``. The penalized splines - (``pspline``, ``tensorspline``) require equally-spaced knots for their - difference penalty, and the kernel-based ``tprs`` has no knots, so those - three always use fixed knot placement regardless of ``target_aware`` / - ``placement_strategy`` / ``adaptive``. - """ - resolved = resolve_method(method, NUMERICAL_METHODS, NUMERICAL_ALIASES) - return resolved in TARGET_AWARE_SPLINE_METHODS - - -# Valid range for the number of spline basis functions per feature. -_MIN_SPLINE_BASIS = 5 -_MAX_SPLINE_BASIS = 50 - - -def filter_kwargs(transformer_cls, kwargs, allowed=None): - if allowed is not None: - return {k: kwargs[k] for k in allowed if k in kwargs} - return kwargs - - -# Method families grouped by which placement modes they support. The Preprocessor -# shares a single ``target_aware`` / ``placement_strategy`` pair; each family only -# receives the placement kwargs it can honor. -BOTH_MODE_METHODS = frozenset( - { - "rbf", - "relu", - "sigmoid", - "tanh", - "bspline", - "mspline", - "ispline", - "cubicspline", - "naturalspline", - } -) -# PLE is inherently target-aware: only the supervised selectors apply. -TARGET_AWARE_ONLY_METHODS = frozenset({"ple"}) -# Penalized splines assume equally-spaced knots: only the spacing rules apply. -UNSUPERVISED_ONLY_METHODS = frozenset({"pspline", "tensorspline"}) - - -def _placement_kwargs(method, kwargs): - """Return the placement kwargs to inject for ``method``. - - Honors each family's applicability: both-mode families receive - ``target_aware`` + ``placement_strategy``; PLE receives a supervised - ``placement_strategy`` only when target-aware; the penalized splines receive - an unsupervised ``placement_strategy`` only when not target-aware. Anything - else receives nothing. - """ - target_aware = bool(kwargs.get("target_aware", False)) - placement_strategy = kwargs.get("placement_strategy") - if method in BOTH_MODE_METHODS: - out = {"target_aware": target_aware} - if placement_strategy is not None: - out["placement_strategy"] = placement_strategy - return out - if method in TARGET_AWARE_ONLY_METHODS: - if target_aware and placement_strategy in ("cart", "lightgbm"): - return {"placement_strategy": placement_strategy} - return {} - if method in UNSUPERVISED_ONLY_METHODS: - if not target_aware and placement_strategy in ("uniform", "quantile"): - return {"placement_strategy": placement_strategy} - return {} - return {} - - -def _clamp_spline_basis(output_dim): - """Clamp a requested output dimension into the supported spline range. - - The Preprocessor shares a single ``output_dim`` setting across every - numerical strategy (default 64), but the B/M/I spline transformers accept - between ``5`` and ``50`` basis functions. Values outside that window are - clamped so switching to a spline strategy keeps working with the shared - default. - """ - clamped = max(_MIN_SPLINE_BASIS, min(int(output_dim), _MAX_SPLINE_BASIS)) - if clamped != output_dim: - warnings.warn( - f"output_dim={output_dim} is outside the spline range " - f"[{_MIN_SPLINE_BASIS}, {_MAX_SPLINE_BASIS}]; using {clamped} basis functions.", - ConfigWarning, - stacklevel=2, - ) - return clamped - - -def get_numerical_transformer_steps( - method: str, - add_imputer: bool = True, - imputer_strategy: str = "mean", - imputer_kwargs: dict | None = None, - scaling: str | None = None, - **kwargs, -): - method = resolve_method(method, NUMERICAL_METHODS, NUMERICAL_ALIASES) - steps = [] - - if add_imputer: - imputer_kwargs = imputer_kwargs or {} - steps.append(("imputer", SimpleImputer(strategy=imputer_strategy, **imputer_kwargs))) - - # Define scalers that could be added independently - scalers = { - "standardization": ("scaler", StandardScaler()), - "minmax": ("minmax", MinMaxScaler(feature_range=(-1, 1))), - } - - # Add optional scaling step only if not already part of method - if scaling is not None: - scaling = resolve_method(scaling, NUMERICAL_METHODS, NUMERICAL_ALIASES) - if scaling in scalers and scaling != method: - steps.append(scalers[scaling]) - - if method not in NUMERICAL_METHODS: - raise invalid_param_error( - "get_numerical_transformer_steps", - "method", - method, - "unrecognized numerical preprocessing method", - valid=set(NUMERICAL_METHODS), - ) - - cls, allowed_args = NUMERICAL_METHODS[method] - filtered = filter_kwargs(cls, kwargs, allowed=allowed_args) - placement = _placement_kwargs(method, kwargs) - - if method == "box-cox": - steps.append(("scale_positive", MinMaxScaler(feature_range=(1e-3, 1)))) - steps.append(("boxcox", cls(method="box-cox", **filtered))) - elif method == "yeo-johnson": - steps.append(("yeojohnson", cls(method="yeo-johnson", **filtered))) - elif method in SPLINE_EXPANSION_METHODS or method in LEGACY_SPLINE_METHODS: - spline_kwargs = dict(filtered) - spline_kwargs.update(placement) - - # The B/M/I splines share the Preprocessor's default ``output_dim`` (which - # can sit outside their [5, 50] basis range); the legacy families keep - # their own wider bounds, so only clamp for B/M/I. - if method in SPLINE_EXPANSION_METHODS: - output_dim = kwargs.get("output_dim") - if output_dim is not None: - spline_kwargs["output_dim"] = _clamp_spline_basis(output_dim) - - steps.append((method, cls(**spline_kwargs))) - else: - name = method if method != "none" else "noop" - call_kwargs = dict(filtered) - call_kwargs.update(placement) - steps.append((name, cls(**call_kwargs))) - - return steps diff --git a/pretab/pipeline/registry.py b/pretab/pipeline/registry.py deleted file mode 100644 index 802bd08..0000000 --- a/pretab/pipeline/registry.py +++ /dev/null @@ -1,246 +0,0 @@ -"""Declarative registry of numerical preprocessing strategies. - -Each entry maps a strategy name to a ``(transformer_cls, allowed_args)`` pair: -the class to instantiate and the constructor arguments it accepts (used to -filter the shared ``**kwargs``). Adding a new numerical strategy is therefore a -single-line edit here rather than a change to the assembly logic. - -A few names (``box-cox`` / ``yeo-johnson`` share ``PowerTransformer``, and the -B/M/I splines need extra knot wiring) require special handling in -:mod:`pretab.pipeline.numerical`; this table still records the class and its -allowed arguments for them. -""" - -from sklearn.preprocessing import ( - MinMaxScaler, - PolynomialFeatures, - PowerTransformer, - QuantileTransformer, - RobustScaler, - StandardScaler, -) - -from ..transformers.encoders.floats import NoTransformer -from ..transformers.feature_maps.rbf import RBFExpansionTransformer -from ..transformers.feature_maps.relu import ReLUExpansionTransformer -from ..transformers.feature_maps.sigmoid import SigmoidExpansionTransformer -from ..transformers.feature_maps.tanh import TanhExpansionTransformer -from ..transformers.numerical.binning import CustomBinTransformer -from ..transformers.numerical.piecewise import PLETransformer -from ..transformers.splines.b_spline import BSplineTransformer -from ..transformers.splines.cubic_regression import CubicSplineTransformer -from ..transformers.splines.i_spline import ISplineTransformer -from ..transformers.splines.m_spline import MSplineTransformer -from ..transformers.splines.multivariate.tensor_product import ( - TensorProductSplineTransformer, -) -from ..transformers.splines.multivariate.thin_plate import ( - ThinPlateSplineTransformer, -) -from ..transformers.splines.natural_cubic import NaturalCubicSplineTransformer -from ..transformers.splines.p_spline import PSplineTransformer - -__all__ = [ - "CATEGORICAL_ALIASES", - "CATEGORICAL_METHODS", - "NUMERICAL_ALIASES", - "NUMERICAL_METHODS", - "resolve_method", -] - - -# name -> (transformer class, constructor arguments it accepts) -NUMERICAL_METHODS = { - "standardization": (StandardScaler, []), - "minmax": (MinMaxScaler, []), - "quantile": ( - QuantileTransformer, - ["n_quantiles", "output_distribution", "random_state"], - ), - "polynomial": ( - PolynomialFeatures, - ["degree", "interaction_only", "include_bias"], - ), - "robust": (RobustScaler, []), - "box-cox": (PowerTransformer, []), - "yeo-johnson": (PowerTransformer, []), - "ple": ( - PLETransformer, - ["output_dim", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state", "handle_missing"], - ), - "custombin": (CustomBinTransformer, ["output_dim"]), - "rbf": ( - RBFExpansionTransformer, - [ - "output_dim", - "gamma", - "task", - "adaptive", - "min_output_dim", - "max_output_dim", - "random_state", - ], - ), - "relu": ( - ReLUExpansionTransformer, - [ - "output_dim", - "task", - "adaptive", - "min_output_dim", - "max_output_dim", - "random_state", - ], - ), - "sigmoid": ( - SigmoidExpansionTransformer, - [ - "output_dim", - "task", - "adaptive", - "min_output_dim", - "max_output_dim", - "random_state", - ], - ), - "tanh": ( - TanhExpansionTransformer, - [ - "output_dim", - "scale", - "task", - "adaptive", - "min_output_dim", - "max_output_dim", - "random_state", - ], - ), - "cubicspline": ( - CubicSplineTransformer, - [ - "output_dim", - "degree", - "include_bias", - "task", - "adaptive", - "min_output_dim", - "max_output_dim", - "random_state", - ], - ), - "naturalspline": ( - NaturalCubicSplineTransformer, - ["output_dim", "include_bias", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state"], - ), - # pspline / tensorspline are penalized (difference-penalty) splines that rely - # on equally-spaced knots, so they are *not* target-aware: no ``task`` here. - "pspline": (PSplineTransformer, ["output_dim", "degree", "diff_order"]), - "tensorspline": ( - TensorProductSplineTransformer, - ["output_dim", "degree", "diff_order"], - ), - "tprs": (ThinPlateSplineTransformer, ["output_dim"]), - "bspline": (BSplineTransformer, ["degree", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state"]), - "mspline": (MSplineTransformer, ["degree", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state"]), - "ispline": (ISplineTransformer, ["degree", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state"]), - "none": (NoTransformer, []), -} - - -# Canonical categorical method names (numerical ones are the NUMERICAL_METHODS -# keys). Kept here so both pipeline sides resolve names through one module. -CATEGORICAL_METHODS = frozenset({"int", "one-hot", "onehot_from_ordinal", "pretrained", "custombin", "none"}) - - -def _squash(name: str) -> str: - """Collapse a method name for separator/case-insensitive comparison. - - Lowercases, trims surrounding whitespace, and drops the ``-``, ``_`` and - space separators so ``"One-Hot"``, ``"one_hot"`` and ``"onehot"`` all map to - the same key. Canonical names that only differ by a separator (``"box-cox"`` - vs ``"boxcox"``, ``"cubicspline"`` vs ``"cubic spline"``) therefore match - without needing an explicit alias entry. - """ - return name.strip().lower().replace("-", "").replace("_", "").replace(" ", "") - - -# Genuine synonyms / abbreviations that are *not* just separator variants of a -# canonical name (those are handled by :func:`_squash`). Keys are already -# squashed; values are canonical numerical method names. -NUMERICAL_ALIASES = { - "standard": "standardization", - "standardize": "standardization", - "standardscaler": "standardization", - "std": "standardization", - "zscore": "standardization", - "minmaxscaler": "minmax", - "quantiletransformer": "quantile", - "poly": "polynomial", - "robustscaler": "robust", - "piecewiselinear": "ple", - "bin": "custombin", - "binning": "custombin", - "cubic": "cubicspline", - "natural": "naturalspline", - "naturalcubic": "naturalspline", - "tensor": "tensorspline", - "tensorproduct": "tensorspline", - "tensorproductspline": "tensorspline", - "thinplate": "tprs", - "thinplatespline": "tprs", - "passthrough": "none", - "identity": "none", - "raw": "none", -} - -# Genuine synonyms / abbreviations for the categorical methods (keys squashed). -CATEGORICAL_ALIASES = { - "integer": "int", - "ordinal": "int", - "label": "int", - "labelencoder": "int", - "ordinalencoder": "int", - "ohe": "one-hot", - "dummy": "one-hot", - "onehotencoder": "one-hot", - "embedding": "pretrained", - "embeddings": "pretrained", - "language": "pretrained", - "llm": "pretrained", - "bin": "custombin", - "binning": "custombin", - "passthrough": "none", - "identity": "none", - "raw": "none", -} - - -def resolve_method(name, canonical, aliases): - """Resolve a user-supplied method name to its canonical spelling. - - Matching is case-insensitive, ignores ``-`` / ``_`` / space separators, and - honours the explicit ``aliases`` map of synonyms and abbreviations. An - unrecognized name is returned lowercased and stripped so the caller's own - "unrecognized method" error lists the canonical options. - - Parameters - ---------- - name : str - The method name the user supplied. - canonical : set or dict - The canonical method names (``NUMERICAL_METHODS`` keys or - ``CATEGORICAL_METHODS``). - aliases : dict - Squashed-alias to canonical-name mapping for this side of the pipeline. - """ - key = name.strip().lower() - if key in canonical: - return key - - squashed = _squash(name) - for canon in canonical: - if _squash(canon) == squashed: - return canon - if squashed in aliases: - return aliases[squashed] - return key diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index 2e8ee4e..afc396c 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -1,22 +1,19 @@ import time import numpy as np -import pandas as pd from sklearn.base import BaseEstimator, TransformerMixin -from sklearn.compose import ColumnTransformer -from sklearn.pipeline import Pipeline from sklearn.utils.validation import check_is_fitted -from .core.logging import configure_logging, get_logger -from .core.parameters import validate_placement -from .exceptions import ( - IncompatibleParamsError, - invalid_param_error, -) -from .pipeline import ( - get_categorical_transformer_steps, - get_numerical_transformer_steps, +from .compose.config import PreprocessorConfig +from .compose.factory import build_column_transformer +from .compose.feature_detection import detect_column_types, to_dataframe +from .compose.inspection import ( + build_feature_info, + build_transformer_summary, + get_output_slices, ) +from .compose.output import format_output +from .core.logging import configure_logging, get_logger logger = get_logger(__name__) @@ -259,57 +256,6 @@ def __init__( self.handle_missing = handle_missing self.verbose = verbose - def _detect_column_types(self, X): - """ - Detects categorical and numerical features in the input data. - - Parameters - ---------- - X : pandas.DataFrame, numpy.ndarray, or dict - The input data to analyze. - - Returns - ------- - numerical_features : list of str - Column names detected as numerical features. - categorical_features : list of str - Column names detected as categorical features. - """ - - categorical_features = [] - numerical_features = [] - - if isinstance(X, dict): - X = pd.DataFrame(X) - elif isinstance(X, np.ndarray): - X = pd.DataFrame(X, columns=[f"feature_{i}" for i in range(X.shape[1])]) - - for col in X.columns: - num_unique_values = X[col].nunique() - total_samples = len(X[col]) - - if self.treat_all_integers_as_numerical and X[col].dtype.kind == "i": - numerical_features.append(col) - else: - if isinstance(self.cat_cutoff, float): - cutoff_condition = (num_unique_values / total_samples) < self.cat_cutoff - elif isinstance(self.cat_cutoff, int): - cutoff_condition = num_unique_values < self.cat_cutoff - else: - raise invalid_param_error( - type(self).__name__, - "cat_cutoff", - self.cat_cutoff, - "must be a float (unique-ratio cutoff) or an int (absolute unique-count cutoff)", - ) - - if X[col].dtype.kind not in "iufc" or (X[col].dtype.kind == "i" and cutoff_condition): - categorical_features.append(col) - else: - numerical_features.append(col) - - return numerical_features, categorical_features - def fit(self, X, y=None, embeddings=None): """ Fit the preprocessor to the input data and target labels. @@ -334,16 +280,27 @@ def fit(self, X, y=None, embeddings=None): configure_logging(verbose) start_time = time.perf_counter() - validate_placement(self.target_aware, self.placement_strategy) - - if isinstance(X, dict): - X = pd.DataFrame(X) - elif isinstance(X, np.ndarray): - X = pd.DataFrame(X, columns=[f"feature_{i}" for i in range(X.shape[1])]) + config = PreprocessorConfig.from_params( + numerical_method=self.numerical_method, + categorical_method=self.categorical_method, + feature_preprocessing=self.feature_preprocessing, + output_dim=self.output_dim, + degree=self.degree, + target_aware=self.target_aware, + placement_strategy=self.placement_strategy, + task=self.task, + adaptive=self.adaptive, + min_output_dim=self.min_output_dim, + max_output_dim=self.max_output_dim, + random_state=self.random_state, + scaling=self.scaling, + cat_cutoff=self.cat_cutoff, + treat_all_integers_as_numerical=self.treat_all_integers_as_numerical, + handle_missing=self.handle_missing, + verbose=self.verbose, + ) - numerical_method = self.numerical_method.lower() if self.numerical_method is not None else "none" - categorical_method = self.categorical_method.lower() if self.categorical_method is not None else "none" - feature_preprocessing = self.feature_preprocessing or {} + X = to_dataframe(X) self.embeddings_ = False self.embedding_dimensions_ = {} @@ -355,39 +312,14 @@ def fit(self, X, y=None, embeddings=None): for i, e in enumerate(embeddings): self.embedding_dimensions_[f"embedding_{i + 1}"] = e.shape[1] - numerical_features, categorical_features = self._detect_column_types(X) - transformers = [] - - for feature in numerical_features: - method = feature_preprocessing.get(feature, numerical_method) - # Forward ``random_state`` only when the user set one, so unset keeps - # each transformer's own default seed (PLE / selectors = 51, others - # unseeded) and a set value pins every stochastic method globally. - seed_kwargs = {} if self.random_state is None else {"random_state": self.random_state} - steps = get_numerical_transformer_steps( - method=method, - task=self.task, - target_aware=self.target_aware, - add_imputer=self.handle_missing != "error", - imputer_strategy="mean", - output_dim=self.output_dim, - adaptive=self.adaptive, - min_output_dim=self.min_output_dim if self.adaptive else None, - max_output_dim=self.max_output_dim if self.adaptive else None, - degree=self.degree, - scaling=self.scaling, - placement_strategy=self.placement_strategy, - handle_missing=self.handle_missing, - **seed_kwargs, - ) - transformers.append((f"num_{feature}", Pipeline(steps), [feature])) - - for feature in categorical_features: - method = feature_preprocessing.get(feature, categorical_method) - steps = get_categorical_transformer_steps(method, output_dim=self.output_dim) - transformers.append((f"cat_{feature}", Pipeline(steps), [feature])) + numerical_features, categorical_features = detect_column_types( + X, + cat_cutoff=self.cat_cutoff, + treat_all_integers_as_numerical=self.treat_all_integers_as_numerical, + estimator_name=type(self).__name__, + ) - self.column_transformer_ = ColumnTransformer(transformers=transformers, remainder="passthrough") + self.column_transformer_ = build_column_transformer(config, numerical_features, categorical_features) self.column_transformer_.fit(X, y) self.n_features_in_ = X.shape[1] @@ -395,15 +327,15 @@ def fit(self, X, y=None, embeddings=None): logger.info( "fit complete: %d numerical (%s) + %d categorical (%s) feature(s) -> %d output columns in %.3fs", len(numerical_features), - numerical_method, + config.numerical_method, len(categorical_features), - categorical_method, + config.categorical_method, len(self.get_feature_names_out()), time.perf_counter() - start_time, ) if verbose >= 2: info = self.get_feature_info(verbose=False) - for line in self._feature_table_lines(*info): + for line in build_transformer_summary(*info): logger.debug(line) if verbose >= 3: self._log_internal_decisions() @@ -431,44 +363,18 @@ def transform(self, X, embeddings=None, return_array=False): check_is_fitted(self) - if isinstance(X, dict): - X = pd.DataFrame(X) - elif isinstance(X, np.ndarray): - X = pd.DataFrame(X, columns=[f"feature_{i}" for i in range(X.shape[1])]) - else: - X = X.copy() + X = to_dataframe(X, copy=True) transformed_X = self.column_transformer_.transform(X) - if return_array: - return transformed_X - - transformed_dict = {} - start = 0 - for name, transformer, columns in self.column_transformer_.transformers_: - if transformer == "drop": - continue - if hasattr(transformer, "transform"): - width = transformer.transform(X[columns]).shape[1] - else: - width = 1 - transformed_dict[name] = transformed_X[:, start : start + width] - start += width - - if embeddings is not None: - if not self.embeddings_: - raise IncompatibleParamsError( - "Embeddings were not expected, but were provided.\n" - "Fix: configure an embedding feature in feature_preprocessing before " - "passing embeddings to transform, or omit the embeddings argument." - ) - if isinstance(embeddings, np.ndarray): - transformed_dict["embedding_1"] = embeddings.astype(np.float32) - elif isinstance(embeddings, list): - for idx, e in enumerate(embeddings): - transformed_dict[f"embedding_{idx + 1}"] = e.astype(np.float32) - - return transformed_dict + slices = None if return_array else get_output_slices(self.column_transformer_, X) + return format_output( + transformed_X, + return_array=return_array, + slices=slices, + embeddings=embeddings, + embeddings_expected=self.embeddings_, + ) def fit_transform(self, X, y=None, embeddings=None, return_array=False): """ @@ -585,111 +491,15 @@ def get_feature_info(self, verbose=True): check_is_fitted(self) - numerical_feature_info = {} - categorical_feature_info = {} - - embedding_feature_info = ( - { - key: {"preprocessing": None, "dimension": dim, "categories": None} - for key, dim in self.embedding_dimensions_.items() - } - if self.embeddings_ - else {} + numerical_feature_info, categorical_feature_info, embedding_feature_info = build_feature_info( + self.column_transformer_, + embeddings=self.embeddings_, + embedding_dimensions=self.embedding_dimensions_, ) - for ( - name, - transformer_pipeline, - columns, - ) in self.column_transformer_.transformers_: - steps = [step[0] for step in transformer_pipeline.steps] - - for feature_name in columns: - preprocessing_type = " -> ".join(steps) - dimension = None - categories = None - - if "discretizer" in steps or any( - step in steps - for step in [ - "standardization", - "minmax", - "quantile", - "polynomial", - "splines", - "box-cox", - ] - ): - last_step = transformer_pipeline.steps[-1][1] - if hasattr(last_step, "transform"): - dummy_input = np.zeros((1, 1)) + 1e-05 - try: - transformed_feature = last_step.transform(dummy_input) - dimension = transformed_feature.shape[1] - except (ValueError, TypeError, AttributeError, IndexError) as exc: - logger.debug( - "Could not introspect output width of %r: %s", - feature_name, - exc, - ) - dimension = None - numerical_feature_info[feature_name] = { - "preprocessing": preprocessing_type, - "dimension": dimension, - "categories": None, - } - - elif "continuous_ordinal" in steps: - step = transformer_pipeline.named_steps["continuous_ordinal"] - categories = len(step.mapping_[columns.index(feature_name)]) - dimension = 1 - categorical_feature_info[feature_name] = { - "preprocessing": preprocessing_type, - "dimension": dimension, - "categories": categories, - } - - elif "onehot" in steps: - step = transformer_pipeline.named_steps["onehot"] - if hasattr(step, "categories_"): - categories = sum(len(cat) for cat in step.categories_) - dimension = categories - categorical_feature_info[feature_name] = { - "preprocessing": preprocessing_type, - "dimension": dimension, - "categories": categories, - } - - else: - last_step = transformer_pipeline.steps[-1][1] - if hasattr(last_step, "transform"): - dummy_input = np.zeros((1, 1)) - try: - transformed_feature = last_step.transform(dummy_input) - dimension = transformed_feature.shape[1] - except (ValueError, TypeError, AttributeError, IndexError) as exc: - logger.debug( - "Could not introspect output width of %r: %s", - feature_name, - exc, - ) - dimension = None - if "cat" in name: - categorical_feature_info[feature_name] = { - "preprocessing": preprocessing_type, - "dimension": dimension, - "categories": None, - } - else: - numerical_feature_info[feature_name] = { - "preprocessing": preprocessing_type, - "dimension": dimension, - "categories": None, - } - if verbose: configure_logging(1) - for line in self._feature_table_lines( + for line in build_transformer_summary( numerical_feature_info, categorical_feature_info, embedding_feature_info, @@ -698,29 +508,6 @@ def get_feature_info(self, verbose=True): return numerical_feature_info, categorical_feature_info, embedding_feature_info - def _feature_table_lines(self, numerical_info, categorical_info, embedding_info): - """Build aligned, human-readable rows describing the fitted feature layout.""" - rows = [] - for feat, info in numerical_info.items(): - rows.append((str(feat), "numerical", str(info["preprocessing"]), info["dimension"], info["categories"])) - for feat, info in categorical_info.items(): - rows.append((str(feat), "categorical", str(info["preprocessing"]), info["dimension"], info["categories"])) - for feat, info in embedding_info.items(): - rows.append((str(feat), "embedding", "-", info["dimension"], info["categories"])) - if not rows: - return [] - - feat_w = max(len("feature"), *(len(r[0]) for r in rows)) - kind_w = max(len("kind"), *(len(r[1]) for r in rows)) - pipe_w = max(len("pipeline"), *(len(r[2]) for r in rows)) - header = f"{'feature':<{feat_w}} {'kind':<{kind_w}} {'pipeline':<{pipe_w}} {'dim':>4} {'cats':>5}" - lines = [header, "-" * len(header)] - for feat, kind, pipe, dim, cats in rows: - dim_s = "-" if dim is None else str(dim) - cats_s = "-" if cats is None else str(cats) - lines.append(f"{feat:<{feat_w}} {kind:<{kind_w}} {pipe:<{pipe_w}} {dim_s:>4} {cats_s:>5}") - return lines - def _log_internal_decisions(self): """Log fitted internal decisions (bins / knots / centers) at DEBUG.""" for name, transformer, _columns in self.column_transformer_.transformers_: diff --git a/tests/compose/conftest.py b/tests/compose/conftest.py new file mode 100644 index 0000000..ea1f2e8 --- /dev/null +++ b/tests/compose/conftest.py @@ -0,0 +1,52 @@ +"""Shared fixtures for the compose unit tests. + +``make_config`` builds a :class:`~pretab.compose.config.PreprocessorConfig` from +a simple, target-free default set (standardization + int, unsupervised uniform +placement) so individual tests only override what they exercise. +""" + +import pandas as pd +import pytest + +from pretab.compose.config import PreprocessorConfig + +_CONFIG_DEFAULTS = { + "numerical_method": "standardization", + "categorical_method": "int", + "feature_preprocessing": None, + "output_dim": 7, + "degree": 3, + "target_aware": False, + "placement_strategy": "uniform", + "task": "regression", + "adaptive": False, + "min_output_dim": 5, + "max_output_dim": 10, + "random_state": None, + "scaling": None, + "cat_cutoff": 0.03, + "treat_all_integers_as_numerical": False, + "handle_missing": "median", + "verbose": 0, +} + + +@pytest.fixture +def make_config(): + """Return a factory that builds a config from the defaults plus overrides.""" + + def _make(**overrides): + return PreprocessorConfig.from_params(**{**_CONFIG_DEFAULTS, **overrides}) + + return _make + + +@pytest.fixture +def sample_frame(): + """A tiny mixed numerical/categorical frame for factory/inspection tests.""" + return pd.DataFrame( + { + "age": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0], + "city": ["a", "b", "a", "c", "b", "a"], + } + ) diff --git a/tests/compose/test_config.py b/tests/compose/test_config.py new file mode 100644 index 0000000..e3a4c46 --- /dev/null +++ b/tests/compose/test_config.py @@ -0,0 +1,50 @@ +"""Unit tests for :class:`pretab.compose.config.PreprocessorConfig`.""" + +import pytest + +from pretab.exceptions import InvalidParamError + + +def test_none_method_normalizes_to_none(make_config): + cfg = make_config(numerical_method=None, categorical_method=None) + assert cfg.numerical_method == "none" + assert cfg.categorical_method == "none" + + +def test_aliases_resolve_to_canonical(make_config): + assert make_config(numerical_method="cubic").numerical_method == "cubicspline" + assert make_config(categorical_method="ohe").categorical_method == "one-hot" + + +def test_invalid_placement_combo_raises(make_config): + with pytest.raises(InvalidParamError): + make_config(target_aware=True, placement_strategy="uniform") + with pytest.raises(InvalidParamError): + make_config(target_aware=False, placement_strategy="cart") + + +def test_feature_preprocessing_is_copied(make_config): + fp = {"age": "standardization"} + cfg = make_config(feature_preprocessing=fp) + assert cfg.feature_preprocessing == fp + assert cfg.feature_preprocessing is not fp + + +def test_none_feature_preprocessing_becomes_empty_dict(make_config): + assert make_config(feature_preprocessing=None).feature_preprocessing == {} + + +def test_method_for_override_wins_over_global(make_config): + cfg = make_config(numerical_method="standardization", feature_preprocessing={"age": "minmax"}) + assert cfg.method_for("age", is_numerical=True) == "minmax" + assert cfg.method_for("height", is_numerical=True) == "standardization" + + +def test_method_for_resolves_in_the_requested_namespace(make_config): + cfg = make_config(feature_preprocessing={"c": "ohe"}) + assert cfg.method_for("c", is_numerical=False) == "one-hot" + + +def test_seed_kwargs_reflects_random_state(make_config): + assert make_config(random_state=None).seed_kwargs == {} + assert make_config(random_state=42).seed_kwargs == {"random_state": 42} diff --git a/tests/compose/test_factory.py b/tests/compose/test_factory.py new file mode 100644 index 0000000..363065c --- /dev/null +++ b/tests/compose/test_factory.py @@ -0,0 +1,120 @@ +"""Unit tests for :mod:`pretab.compose.factory`.""" + +import numpy as np +import pytest +from sklearn.compose import ColumnTransformer + +from pretab.compose.factory import ( + _placement_kwargs, + build_column_transformer, + get_categorical_transformer_steps, + get_numerical_transformer_steps, +) +from pretab.compose.registry import get_spec +from pretab.exceptions import ConfigWarning, InvalidParamError + + +def _names(steps): + return [name for name, _ in steps] + + +# --------------------------------------------------------------------------- # +# numerical step assembly +# --------------------------------------------------------------------------- # +def test_imputer_is_first_step_by_default(): + assert _names(get_numerical_transformer_steps("standardization"))[0] == "imputer" + + +def test_imputer_omitted_when_disabled(): + assert "imputer" not in _names(get_numerical_transformer_steps("standardization", add_imputer=False)) + + +def test_none_method_uses_noop_step(): + assert _names(get_numerical_transformer_steps("none", add_imputer=False)) == ["noop"] + + +def test_box_cox_scales_positive_first(): + assert _names(get_numerical_transformer_steps("box-cox", add_imputer=False)) == ["scale_positive", "boxcox"] + + +def test_scaling_injected_only_when_different_from_method(): + with_scaler = _names(get_numerical_transformer_steps("ple", add_imputer=False, scaling="standardization")) + assert "scaler" in with_scaler + same = _names(get_numerical_transformer_steps("standardization", add_imputer=False, scaling="standardization")) + assert "scaler" not in same + assert same.count("standardization") == 1 + + +def test_bmi_spline_output_dim_is_clamped_with_warning(): + with pytest.warns(ConfigWarning): + get_numerical_transformer_steps("bspline", add_imputer=False, output_dim=100) + + +def test_unknown_numerical_method_raises(): + with pytest.raises(InvalidParamError): + get_numerical_transformer_steps("does-not-exist", add_imputer=False) + + +# --------------------------------------------------------------------------- # +# categorical step assembly +# --------------------------------------------------------------------------- # +def test_one_hot_appends_to_float(): + assert _names(get_categorical_transformer_steps("one-hot", add_imputer=False)) == ["onehot", "to_float"] + + +def test_int_uses_continuous_ordinal(): + assert _names(get_categorical_transformer_steps("int", add_imputer=False)) == ["continuous_ordinal"] + + +def test_unknown_categorical_method_raises(): + with pytest.raises(InvalidParamError): + get_categorical_transformer_steps("does-not-exist", add_imputer=False) + + +# --------------------------------------------------------------------------- # +# placement kwargs by capability class +# --------------------------------------------------------------------------- # +def test_placement_optional_forwards_target_aware_and_strategy(): + spec = get_spec("rbf") # target_usage == optional + assert _placement_kwargs(spec, {"target_aware": False}) == {"target_aware": False} + assert _placement_kwargs(spec, {"target_aware": False, "placement_strategy": "quantile"}) == { + "target_aware": False, + "placement_strategy": "quantile", + } + + +def test_placement_required_only_when_target_aware_supervised(): + spec = get_spec("ple") # target_usage == required + assert _placement_kwargs(spec, {"target_aware": True, "placement_strategy": "cart"}) == { + "placement_strategy": "cart" + } + assert _placement_kwargs(spec, {"target_aware": False, "placement_strategy": "cart"}) == {} + + +def test_placement_forbidden_uses_unsupervised_only(): + spec = get_spec("pspline") # target_usage == forbidden, unsupervised placement + assert _placement_kwargs(spec, {"target_aware": False, "placement_strategy": "uniform"}) == { + "placement_strategy": "uniform" + } + assert _placement_kwargs(spec, {"target_aware": True, "placement_strategy": "uniform"}) == {} + + +def test_placement_absent_when_method_has_no_strategies(): + spec = get_spec("standardization") # no placement strategies + assert _placement_kwargs(spec, {"target_aware": True, "placement_strategy": "cart"}) == {} + + +# --------------------------------------------------------------------------- # +# ColumnTransformer assembly +# --------------------------------------------------------------------------- # +def test_build_column_transformer_prefixes_and_passthrough(make_config): + ct = build_column_transformer(make_config(), ["age"], ["city"]) + assert isinstance(ct, ColumnTransformer) + assert [name for name, _, _ in ct.transformers] == ["num_age", "cat_city"] + assert ct.remainder == "passthrough" + + +def test_build_column_transformer_fits_and_transforms(make_config, sample_frame): + ct = build_column_transformer(make_config(), ["age"], ["city"]) + out = ct.fit_transform(sample_frame, np.array([0.0, 1.0, 0.0, 1.0, 0.0, 1.0])) + assert out.shape[0] == len(sample_frame) diff --git a/tests/compose/test_feature_detection.py b/tests/compose/test_feature_detection.py new file mode 100644 index 0000000..74bbd78 --- /dev/null +++ b/tests/compose/test_feature_detection.py @@ -0,0 +1,64 @@ +"""Unit tests for :mod:`pretab.compose.feature_detection`.""" + +import numpy as np +import pandas as pd +import pytest + +from pretab.compose.feature_detection import detect_column_types, to_dataframe +from pretab.exceptions import InvalidParamError + + +def test_to_dataframe_wraps_ndarray_with_feature_names(): + df = to_dataframe(np.zeros((2, 3))) + assert list(df.columns) == ["feature_0", "feature_1", "feature_2"] + + +def test_to_dataframe_wraps_dict(): + df = to_dataframe({"a": [1, 2], "b": [3, 4]}) + assert list(df.columns) == ["a", "b"] + + +def test_to_dataframe_returns_same_object_without_copy(): + df = pd.DataFrame({"a": [1, 2]}) + assert to_dataframe(df) is df + assert to_dataframe(df, copy=True) is not df + + +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) + assert cat == ["x"] and num == [] + num, cat = detect_column_types(df, cat_cutoff=0.4, treat_all_integers_as_numerical=False) + assert num == ["x"] and cat == [] + + +def test_int_cutoff_uses_absolute_count(): + df = pd.DataFrame({"x": [1, 2, 3, 1, 2, 3]}) # 3 unique + _, cat = detect_column_types(df, cat_cutoff=4, treat_all_integers_as_numerical=False) + assert cat == ["x"] + num, _ = detect_column_types(df, cat_cutoff=2, treat_all_integers_as_numerical=False) + assert num == ["x"] + + +def test_treat_all_integers_as_numerical_overrides_cutoff(): + df = pd.DataFrame({"x": [1, 2, 3, 1, 2, 3]}) + num, cat = detect_column_types(df, cat_cutoff=0.9, treat_all_integers_as_numerical=True) + assert num == ["x"] and cat == [] + + +def test_object_dtype_is_always_categorical(): + df = pd.DataFrame({"c": ["a", "b", "c", "d", "e", "f"]}) + _, cat = detect_column_types(df, cat_cutoff=0.01, treat_all_integers_as_numerical=False) + assert cat == ["c"] + + +def test_float_columns_are_numerical(): + df = pd.DataFrame({"f": [0.1, 0.2, 0.3, 0.4, 0.5, 0.6]}) + num, _ = detect_column_types(df, cat_cutoff=0.9, treat_all_integers_as_numerical=False) + assert num == ["f"] + + +def test_invalid_cat_cutoff_type_raises(): + df = pd.DataFrame({"x": [1, 2, 3]}) + with pytest.raises(InvalidParamError): + detect_column_types(df, cat_cutoff="bad", treat_all_integers_as_numerical=False) diff --git a/tests/compose/test_inspection.py b/tests/compose/test_inspection.py new file mode 100644 index 0000000..80366b3 --- /dev/null +++ b/tests/compose/test_inspection.py @@ -0,0 +1,55 @@ +"""Unit tests for :mod:`pretab.compose.inspection`.""" + +import numpy as np +import pytest + +from pretab.compose.factory import build_column_transformer +from pretab.compose.inspection import ( + build_feature_info, + build_transformer_summary, + get_output_slices, +) + + +@pytest.fixture +def fitted_ct(make_config, sample_frame): + ct = build_column_transformer(make_config(numerical_method="standardization"), ["age"], ["city"]) + ct.fit(sample_frame, np.array([0.0, 1.0, 0.0, 1.0, 0.0, 1.0])) + return ct, sample_frame + + +def test_get_output_slices_are_ordered_and_named(fitted_ct): + ct, X = fitted_ct + slices = get_output_slices(ct, X) + names = [name for name, _, _ in slices] + assert "num_age" in names and "cat_city" in names + starts = [start for _, start, _ in slices] + assert starts == sorted(starts) + assert all(width >= 1 for _, _, width in slices) + + +def test_build_feature_info_splits_numerical_and_categorical(fitted_ct): + ct, _ = fitted_ct + numerical, categorical, embeddings = build_feature_info(ct, embeddings=False, embedding_dimensions={}) + assert "age" in numerical + assert "city" in categorical + assert embeddings == {} + + +def test_build_feature_info_reports_embeddings(fitted_ct): + ct, _ = fitted_ct + _, _, embeddings = build_feature_info(ct, embeddings=True, embedding_dimensions={"embedding_1": 8}) + assert embeddings == {"embedding_1": {"preprocessing": None, "dimension": 8, "categories": None}} + + +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}} + lines = build_transformer_summary(numerical, categorical, {}) + assert lines[0].startswith("feature") + assert any("age" in line for line in lines) + assert any("city" in line for line in lines) + + +def test_build_transformer_summary_empty_returns_empty(): + assert build_transformer_summary({}, {}, {}) == [] diff --git a/tests/compose/test_output.py b/tests/compose/test_output.py new file mode 100644 index 0000000..98eb18a --- /dev/null +++ b/tests/compose/test_output.py @@ -0,0 +1,57 @@ +"""Unit tests for :mod:`pretab.compose.output`.""" + +import numpy as np +import pytest + +from pretab.compose.output import attach_embeddings, build_output_dict, format_output +from pretab.exceptions import IncompatibleParamsError + + +def test_build_output_dict_slices_by_span(): + arr = np.arange(12).reshape(3, 4) + out = build_output_dict(arr, [("a", 0, 1), ("b", 1, 3)]) + assert set(out) == {"a", "b"} + assert out["a"].shape == (3, 1) + np.testing.assert_array_equal(out["b"], arr[:, 1:4]) + + +def test_attach_embeddings_array_casts_to_float32(): + result = {} + attach_embeddings(result, np.ones((2, 3)), expected=True) + assert result["embedding_1"].dtype == np.float32 + assert result["embedding_1"].shape == (2, 3) + + +def test_attach_embeddings_list_numbers_blocks(): + result = {} + attach_embeddings(result, [np.ones((2, 2)), np.ones((2, 1))], expected=True) + assert set(result) == {"embedding_1", "embedding_2"} + + +def test_attach_embeddings_unexpected_raises(): + with pytest.raises(IncompatibleParamsError): + attach_embeddings({}, np.ones((2, 3)), expected=False) + + +def test_format_output_array_returns_input_unchanged(): + arr = np.zeros((2, 2)) + assert format_output(arr, return_array=True) is arr + + +def test_format_output_dict_builds_blocks(): + arr = np.arange(6).reshape(2, 3) + out = format_output(arr, return_array=False, slices=[("x", 0, 3)]) + assert set(out) == {"x"} + np.testing.assert_array_equal(out["x"], arr) + + +def test_format_output_dict_attaches_embeddings(): + arr = np.arange(6).reshape(2, 3) + out = format_output( + arr, + return_array=False, + slices=[("x", 0, 3)], + embeddings=np.ones((2, 4)), + embeddings_expected=True, + ) + assert "embedding_1" in out diff --git a/tests/compose/test_registry_contract.py b/tests/compose/test_registry_contract.py new file mode 100644 index 0000000..fa6eb91 --- /dev/null +++ b/tests/compose/test_registry_contract.py @@ -0,0 +1,209 @@ +"""Contract tests driven by ``TRANSFORMER_REGISTRY``. + +Every registered method is validated for a consistent capability record and for +behaviour that matches its declared flags. Adding a method to the registry +therefore automatically subjects it to these invariants. +""" + +import importlib.util + +import numpy as np +import pandas as pd +import pytest + +from pretab import Preprocessor +from pretab.compose.registry import ( + TRANSFORMER_REGISTRY, + TransformerSpec, + categorical_method_names, + numerical_method_names, +) +from pretab.exceptions import OptionalDependencyError, PretabError + +_VALID_KINDS = {"numerical", "categorical"} +_VALID_ARITY = {"univariate", "multivariate"} +_VALID_TARGET_USAGE = {"forbidden", "optional", "required"} +_UNSUPERVISED = frozenset({"uniform", "quantile"}) +_TARGET_AWARE = frozenset({"cart", "lightgbm"}) +_ALL_STRATEGIES = _UNSUPERVISED | _TARGET_AWARE + +# Optional extra -> importable module used to detect whether the dependency is +# actually installed in the current environment. +_EXTRA_MODULE = {"embeddings": "sentence_transformers", "lightgbm": "lightgbm"} + +_SPEC_ITEMS = list(TRANSFORMER_REGISTRY.items()) +_SPEC_IDS = [name for name, _ in _SPEC_ITEMS] + + +def _module_available(module_name: str) -> bool: + return importlib.util.find_spec(module_name) is not None + + +@pytest.mark.parametrize("name, spec", _SPEC_ITEMS, ids=_SPEC_IDS) +def test_registry_key_matches_name(name, spec): + assert isinstance(spec, TransformerSpec) + assert spec.name == name + + +@pytest.mark.parametrize("name, spec", _SPEC_ITEMS, ids=_SPEC_IDS) +def test_transformer_cls_is_importable_class(name, spec): + # The class object is resolved at registry import time; being a ``type`` here + # proves the import path is valid. + assert isinstance(spec.transformer_cls, type) + + +@pytest.mark.parametrize("name, spec", _SPEC_ITEMS, ids=_SPEC_IDS) +def test_feature_kind_valid(name, spec): + assert spec.feature_kind, f"{name} has no feature kind" + assert spec.feature_kind <= _VALID_KINDS + + +@pytest.mark.parametrize("name, spec", _SPEC_ITEMS, ids=_SPEC_IDS) +def test_arity_valid(name, spec): + assert spec.arity in _VALID_ARITY + + +@pytest.mark.parametrize("name, spec", _SPEC_ITEMS, ids=_SPEC_IDS) +def test_target_usage_valid(name, spec): + assert spec.target_usage in _VALID_TARGET_USAGE + + +@pytest.mark.parametrize("name, spec", _SPEC_ITEMS, ids=_SPEC_IDS) +def test_placement_strategies_valid(name, spec): + assert spec.placement_strategies <= _ALL_STRATEGIES + + +@pytest.mark.parametrize("name, spec", _SPEC_ITEMS, ids=_SPEC_IDS) +def test_target_usage_and_placement_are_consistent(name, spec): + if spec.target_usage == "required": + # Always target-aware: only the supervised strategies apply. + assert spec.placement_strategies == _TARGET_AWARE + elif spec.target_usage == "optional": + # Both modes available: every strategy applies. + assert spec.placement_strategies == _ALL_STRATEGIES + else: # forbidden + # Never uses y: any placement it has must be unsupervised. + assert spec.placement_strategies <= _UNSUPERVISED + + +@pytest.mark.parametrize("name, spec", _SPEC_ITEMS, ids=_SPEC_IDS) +def test_adaptive_flag_matches_allowed_args(name, spec): + assert spec.supports_adaptive_resolution == ("adaptive" in spec.allowed_args) + + +@pytest.mark.parametrize("name, spec", _SPEC_ITEMS, ids=_SPEC_IDS) +def test_optional_dependency_value(name, spec): + assert spec.optional_dependency is None or spec.optional_dependency in _EXTRA_MODULE + + +@pytest.mark.parametrize("name, spec", _SPEC_ITEMS, ids=_SPEC_IDS) +def test_instantiable_when_dependency_present(name, spec): + # Methods with no optional dependency (or whose dependency is installed) + # must construct with defaults. + if spec.optional_dependency and not _module_available(_EXTRA_MODULE[spec.optional_dependency]): + pytest.skip(f"optional dependency {spec.optional_dependency!r} not installed") + assert spec.transformer_cls() is not None + + +@pytest.mark.parametrize("name, spec", _SPEC_ITEMS, ids=_SPEC_IDS) +def test_required_target_methods_reject_missing_y(name, spec): + if not spec.requires_target or spec.is_multivariate: + pytest.skip("not a univariate required-target method") + if spec.optional_dependency and not _module_available(_EXTRA_MODULE[spec.optional_dependency]): + pytest.skip(f"optional dependency {spec.optional_dependency!r} not installed") + transformer = spec.transformer_cls() + X = np.linspace(0.0, 1.0, 60).reshape(-1, 1) + with pytest.raises(PretabError): + transformer.fit(X, None) + + +@pytest.mark.parametrize("name, spec", _SPEC_ITEMS, ids=_SPEC_IDS) +def test_optional_dependency_methods_fail_cleanly(name, spec): + if spec.optional_dependency is None: + pytest.skip("no optional dependency") + module_name = _EXTRA_MODULE[spec.optional_dependency] + if _module_available(module_name): + pytest.skip(f"{module_name} is installed; cannot exercise the missing-dependency path") + transformer = spec.transformer_cls() + X = np.array([["a"], ["b"], ["c"]], dtype=object) + with pytest.raises(OptionalDependencyError): + transformer.fit(X) + + +def test_registry_covers_numerical_and_categorical_names(): + assert numerical_method_names() | categorical_method_names() == set(TRANSFORMER_REGISTRY) + # ``custombin`` and ``none`` are the only dual-kind methods. + dual = numerical_method_names() & categorical_method_names() + assert dual == {"custombin", "none"} + + +# --------------------------------------------------------------------------- # +# End-to-end behavioural contract: the ``preprocessor_compatible`` flag and the +# target-usage declaration must match what the Preprocessor actually does. +# --------------------------------------------------------------------------- # +_PREPROC_NUMERICAL = [ + (name, spec) + for name, spec in _SPEC_ITEMS + if spec.is_numerical and spec.preprocessor_compatible and not spec.is_multivariate +] +_PREPROC_CATEGORICAL = [(name, spec) for name, spec in _SPEC_ITEMS if spec.is_categorical and spec.preprocessor_compatible] +_REQUIRED_NUMERICAL = [ + (name, spec) for name, spec in _SPEC_ITEMS if spec.is_numerical and spec.requires_target and not spec.is_multivariate +] + + +def _skip_if_dependency_missing(spec): + if spec.optional_dependency and not _module_available(_EXTRA_MODULE[spec.optional_dependency]): + pytest.skip(f"optional dependency {spec.optional_dependency!r} not installed") + + +@pytest.mark.parametrize( + "name, spec", _PREPROC_NUMERICAL, ids=[name for name, _ in _PREPROC_NUMERICAL] +) +def test_preprocessor_compatible_numerical_methods_fit_transform(name, spec): + _skip_if_dependency_missing(spec) + rng = np.random.RandomState(0) + X = pd.DataFrame({"f0": rng.rand(60), "f1": rng.rand(60) * 5 + 1}) + y = rng.rand(60) + if spec.requires_target: + pre = Preprocessor(numerical_method=name, target_aware=True, placement_strategy="cart") + else: + pre = Preprocessor(numerical_method=name, target_aware=False, placement_strategy="uniform") + out = pre.fit_transform(X, y, return_array=True) + assert out.shape[0] == 60 + + +@pytest.mark.parametrize( + "name, spec", _PREPROC_CATEGORICAL, ids=[name for name, _ in _PREPROC_CATEGORICAL] +) +def test_preprocessor_compatible_categorical_methods_fit_transform(name, spec): + _skip_if_dependency_missing(spec) + rng = np.random.RandomState(0) + # Integer-coded categories keep ``onehot_from_ordinal`` valid; a high cutoff + # forces the low-cardinality integer column onto the categorical path. + X = pd.DataFrame({"n": rng.rand(60), "c": rng.randint(0, 3, size=60)}) + y = rng.rand(60) + pre = Preprocessor( + numerical_method="standardization", + categorical_method=name, + cat_cutoff=0.5, + target_aware=False, + placement_strategy="uniform", + ) + out = pre.fit_transform(X, y, return_array=True) + assert out.shape[0] == 60 + + +@pytest.mark.parametrize( + "name, spec", _REQUIRED_NUMERICAL, ids=[name for name, _ in _REQUIRED_NUMERICAL] +) +def test_required_target_methods_raise_without_y_via_preprocessor(name, spec): + _skip_if_dependency_missing(spec) + X = pd.DataFrame({"f0": np.linspace(0.0, 1.0, 60)}) + pre = Preprocessor(numerical_method=name, target_aware=True, placement_strategy="cart") + # A required-target method must fail loudly when fit without a target. The + # precise exception type is tightened in Phase 4; here we only require that + # it does not silently succeed (sklearn's fit_transform surfaces the missing + # ``y`` as a ``TypeError`` rather than the transformer's own PretabError). + with pytest.raises((PretabError, TypeError)): + pre.fit(X) diff --git a/tests/test_categorical_pipeline.py b/tests/test_categorical_pipeline.py index 2a5e82d..49fdaac 100644 --- a/tests/test_categorical_pipeline.py +++ b/tests/test_categorical_pipeline.py @@ -2,7 +2,7 @@ import pytest from sklearn.pipeline import Pipeline -from pretab.pipeline import get_categorical_transformer_steps +from pretab.compose.factory import get_categorical_transformer_steps def _build(method, **kwargs): diff --git a/tests/test_method_aliases.py b/tests/test_method_aliases.py index f0ee38f..1f616cb 100644 --- a/tests/test_method_aliases.py +++ b/tests/test_method_aliases.py @@ -2,14 +2,14 @@ import pandas as pd import pytest -from pretab.exceptions import InvalidParamError -from pretab.pipeline.registry import ( +from pretab.compose.registry import ( CATEGORICAL_ALIASES, CATEGORICAL_METHODS, NUMERICAL_ALIASES, NUMERICAL_METHODS, resolve_method, ) +from pretab.exceptions import InvalidParamError from pretab.preprocessor import Preprocessor diff --git a/tests/test_public_api.py b/tests/test_public_api.py new file mode 100644 index 0000000..5b3b93e --- /dev/null +++ b/tests/test_public_api.py @@ -0,0 +1,43 @@ +"""Public API surface contract for the top-level ``pretab`` package. + +Guards the names third parties import and confirms the legacy ``pretab.pipeline`` +package (folded into ``pretab.compose`` during the 1.0.0 restructure) is gone. +""" + +import importlib + +import pytest + +import pretab + + +def test_public_names_are_exported(): + for name in ("Preprocessor", "PretabWarning", "configure_logging", "set_verbosity", "__version__"): + assert hasattr(pretab, name) + + +def test_dunder_all_is_resolvable(): + assert pretab.__all__ + for name in pretab.__all__: + assert hasattr(pretab, name) + + +def test_preprocessor_is_constructible(): + assert pretab.Preprocessor() is not None + + +def test_transformers_public_surface_is_resolvable(): + transformers = importlib.import_module("pretab.transformers") + assert transformers.__all__ + for name in transformers.__all__: + assert hasattr(transformers, name) + + +def test_legacy_pipeline_package_is_removed(): + with pytest.raises(ModuleNotFoundError): + importlib.import_module("pretab.pipeline") + + +def test_compose_subsystem_is_importable(): + for module in ("config", "registry", "factory", "output", "inspection", "feature_detection"): + importlib.import_module(f"pretab.compose.{module}") From 76b015f8a7f13e7270033123f977a4f326684545 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 26 Jul 2026 15:09:00 +0200 Subject: [PATCH 08/59] feat(params)!: replace handle_missing with imputation params --- CHANGELOG.md | 3 +- pretab/compose/config.py | 22 ++++++- pretab/compose/factory.py | 46 ++++++++++++--- pretab/compose/registry.py | 2 +- pretab/preprocessor.py | 30 +++++++--- pretab/transformers/numerical/piecewise.py | 68 ++++++---------------- tests/compose/conftest.py | 4 +- tests/compose/test_registry_contract.py | 9 ++- tests/test_exceptions.py | 5 +- tests/test_ple_transformer.py | 15 ++--- tests/test_preprocessor.py | 4 +- tests/test_reproducibility.py | 60 +++++++++++-------- tests/test_verbosity.py | 12 +--- 13 files changed, 155 insertions(+), 125 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b69b69..c75188d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,8 @@ Going forward, this file is updated automatically by `cz bump` on each release. - **pipeline**: use selector and adaptive setting to splines - **pipeline**: accept preprocessing method name variations - **preprocessor**: expose total_output_dim_, output_dims_ attribute -- **preprocessor**: add random_state, handle_missing parameters +- **preprocessor**: add random_state parameter +- **preprocessor**: add numerical_imputation / categorical_imputation / add_missing_indicator parameters (replacing handle_missing) - **sklearn-compat**: enforce n_features consistency, fix mixin order/tags - **exceptions**: route all raises through typed exceptions - **logging**: add verbose level, route warnings diff --git a/pretab/compose/config.py b/pretab/compose/config.py index 834e1af..e674dc0 100644 --- a/pretab/compose/config.py +++ b/pretab/compose/config.py @@ -19,6 +19,7 @@ from dataclasses import dataclass from ..core.parameters import validate_placement +from ..exceptions import IncompatibleParamsError from .registry import ( CATEGORICAL_ALIASES, CATEGORICAL_METHODS, @@ -61,7 +62,9 @@ class PreprocessorConfig: scaling: str | None cat_cutoff: float | int treat_all_integers_as_numerical: bool - handle_missing: str + numerical_imputation: str | None + categorical_imputation: str | None + add_missing_indicator: bool verbose: int @classmethod @@ -83,7 +86,9 @@ def from_params( scaling, cat_cutoff, treat_all_integers_as_numerical, - handle_missing, + numerical_imputation, + categorical_imputation, + add_missing_indicator, verbose, ) -> PreprocessorConfig: """Normalize and validate raw Preprocessor parameters into a config. @@ -93,8 +98,17 @@ def from_params( InvalidParamError If the ``target_aware`` / ``placement_strategy`` combination is invalid (via :func:`~pretab.core.parameters.validate_placement`). + IncompatibleParamsError + If ``add_missing_indicator`` is requested while both imputation + strategies are disabled, since the indicator is produced by the + imputation step. """ validate_placement(target_aware, placement_strategy) + if add_missing_indicator and numerical_imputation is None and categorical_imputation is None: + raise IncompatibleParamsError( + "add_missing_indicator=True requires numerical_imputation or categorical_imputation " + "to be set; the missing-value indicator is produced by the imputation step." + ) return cls( numerical_method=_normalize_method(numerical_method, NUMERICAL_METHODS, NUMERICAL_ALIASES), categorical_method=_normalize_method(categorical_method, CATEGORICAL_METHODS, CATEGORICAL_ALIASES), @@ -111,7 +125,9 @@ def from_params( scaling=scaling, cat_cutoff=cat_cutoff, treat_all_integers_as_numerical=treat_all_integers_as_numerical, - handle_missing=handle_missing, + numerical_imputation=numerical_imputation, + categorical_imputation=categorical_imputation, + add_missing_indicator=add_missing_indicator, verbose=verbose, ) diff --git a/pretab/compose/factory.py b/pretab/compose/factory.py index 3716707..ced081f 100644 --- a/pretab/compose/factory.py +++ b/pretab/compose/factory.py @@ -16,7 +16,7 @@ from sklearn.preprocessing import MinMaxScaler, StandardScaler from ..core.parameters import UNSET -from ..exceptions import ConfigWarning, invalid_param_error +from ..exceptions import ConfigWarning, IncompatibleParamsError, invalid_param_error from ..transformers.encoders.floats import ToFloatTransformer from .config import PreprocessorConfig from .registry import ( @@ -105,8 +105,9 @@ def _placement_kwargs(spec: TransformerSpec, kwargs): def get_numerical_transformer_steps( method: str, add_imputer: bool = True, - imputer_strategy: str = "mean", + imputer_strategy: str = "median", imputer_kwargs: dict | None = None, + add_missing_indicator: bool = False, scaling: str | None = None, **kwargs, ): @@ -116,7 +117,9 @@ def get_numerical_transformer_steps( if add_imputer: imputer_kwargs = imputer_kwargs or {} - steps.append(("imputer", SimpleImputer(strategy=imputer_strategy, **imputer_kwargs))) + steps.append( + ("imputer", SimpleImputer(strategy=imputer_strategy, add_indicator=add_missing_indicator, **imputer_kwargs)) + ) # Optional scaling step, added only when it is not already the chosen method. scalers = { @@ -174,6 +177,7 @@ def get_categorical_transformer_steps( add_imputer: bool = True, imputer_strategy: str = "most_frequent", imputer_kwargs: dict | None = None, + add_missing_indicator: bool = False, output_dim=UNSET, **kwargs, ): @@ -183,7 +187,9 @@ def get_categorical_transformer_steps( if add_imputer: imputer_kwargs = imputer_kwargs or {} - steps.append(("imputer", SimpleImputer(strategy=imputer_strategy, **imputer_kwargs))) + steps.append( + ("imputer", SimpleImputer(strategy=imputer_strategy, add_indicator=add_missing_indicator, **imputer_kwargs)) + ) if method not in CATEGORICAL_METHODS: raise invalid_param_error( @@ -225,14 +231,32 @@ def create_transformer(method: str, *, is_numerical: bool, config: PreprocessorC ``method`` is the resolved method name; ``is_numerical`` selects the numerical or categorical construction path. All width / placement / seeding knobs are taken from ``config``. + + Raises + ------ + IncompatibleParamsError + If ``method`` is always target-aware (``target_usage="required"``) but the + run is configured with ``target_aware=False``; such a combination cannot be + satisfied and is rejected instead of silently ignored. """ + known = method in NUMERICAL_METHODS if is_numerical else method in CATEGORICAL_METHODS + if known: + spec = get_spec(method) + if spec.requires_target and not config.target_aware: + raise IncompatibleParamsError( + f"method {method!r} is always target-aware and requires target_aware=True " + f"with placement_strategy in {{'cart', 'lightgbm'}}; got target_aware=False." + ) + if is_numerical: + add_imputer = config.numerical_imputation is not None steps = get_numerical_transformer_steps( method=method, task=config.task, target_aware=config.target_aware, - add_imputer=config.handle_missing != "error", - imputer_strategy="mean", + add_imputer=add_imputer, + imputer_strategy=config.numerical_imputation or "median", + add_missing_indicator=config.add_missing_indicator, output_dim=config.output_dim, adaptive=config.adaptive, min_output_dim=config.min_output_dim if config.adaptive else None, @@ -240,11 +264,17 @@ def create_transformer(method: str, *, is_numerical: bool, config: PreprocessorC degree=config.degree, scaling=config.scaling, placement_strategy=config.placement_strategy, - handle_missing=config.handle_missing, **config.seed_kwargs, ) else: - steps = get_categorical_transformer_steps(method, output_dim=config.output_dim) + add_imputer = config.categorical_imputation is not None + steps = get_categorical_transformer_steps( + method, + add_imputer=add_imputer, + imputer_strategy=config.categorical_imputation or "most_frequent", + add_missing_indicator=config.add_missing_indicator, + output_dim=config.output_dim, + ) return Pipeline(steps) diff --git a/pretab/compose/registry.py b/pretab/compose/registry.py index fa20783..63d6767 100644 --- a/pretab/compose/registry.py +++ b/pretab/compose/registry.py @@ -185,7 +185,7 @@ def _spec(name, cls, allowed_args=(), **kwargs): _spec( "ple", PLETransformer, - ("output_dim", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state", "handle_missing"), + ("output_dim", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state"), target_usage="required", placement_strategies=_TARGET_AWARE_STRATEGIES, supports_adaptive_resolution=True, diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index afc396c..d7f2779 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -111,12 +111,18 @@ class Preprocessor(TransformerMixin, BaseEstimator): treat_all_integers_as_numerical : bool, default=False If True, every integer-typed column is treated as numerical regardless of cardinality, bypassing the ``cat_cutoff`` heuristic. - handle_missing : {"error", "median"}, default="median" - Missing-value policy. ``"median"`` keeps the default mean ``SimpleImputer`` that runs - before every numerical method (so NaNs are filled and, e.g., PLE uses its median - handling). ``"error"`` drops that imputer so missing values are *not* silently filled - and reach the transformers, which then raise on NaN. Forwarded to the NaN-aware - methods (currently PLE) via the numerical pipeline. + 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. + categorical_imputation : str or None, default="most_frequent" + Strategy for the ``SimpleImputer`` that runs *before* every categorical method. ``None`` + disables imputation for categorical columns. + add_missing_indicator : bool, default=False + If True, append a binary missing-value indicator column for each imputed feature (via the + imputer's ``add_indicator``; a standalone ``MissingIndicator`` is used when imputation is + disabled). Applies to both numerical and categorical pipelines. verbose : int, default=0 Verbosity level controlling ``fit``-time logging, applied through the shared ``"pretab"`` logger so a single setting on this entry point governs the whole @@ -228,7 +234,9 @@ def __init__( scaling="minmax", cat_cutoff=0.03, treat_all_integers_as_numerical=False, - handle_missing="median", + numerical_imputation="median", + categorical_imputation="most_frequent", + add_missing_indicator=False, verbose=0, ): """ @@ -253,7 +261,9 @@ def __init__( self.scaling = scaling self.cat_cutoff = cat_cutoff self.treat_all_integers_as_numerical = treat_all_integers_as_numerical - self.handle_missing = handle_missing + self.numerical_imputation = numerical_imputation + self.categorical_imputation = categorical_imputation + self.add_missing_indicator = add_missing_indicator self.verbose = verbose def fit(self, X, y=None, embeddings=None): @@ -296,7 +306,9 @@ def fit(self, X, y=None, embeddings=None): scaling=self.scaling, cat_cutoff=self.cat_cutoff, treat_all_integers_as_numerical=self.treat_all_integers_as_numerical, - handle_missing=self.handle_missing, + numerical_imputation=self.numerical_imputation, + categorical_imputation=self.categorical_imputation, + add_missing_indicator=self.add_missing_indicator, verbose=self.verbose, ) diff --git a/pretab/transformers/numerical/piecewise.py b/pretab/transformers/numerical/piecewise.py index de8f68c..652529b 100644 --- a/pretab/transformers/numerical/piecewise.py +++ b/pretab/transformers/numerical/piecewise.py @@ -6,7 +6,6 @@ strings and no regular-expression parsing of split conditions. """ -import warnings from typing import ClassVar, Literal import numpy as np @@ -16,8 +15,7 @@ from ...core.adaptive import AdaptiveResolutionMixin from ...core.parameters import UNSET, AliasResolverMixin from ...exceptions import ( - DataWarning, - EmptyDataError, + IncompatibleParamsError, InvalidParamError, PretabDataError, ) @@ -62,13 +60,6 @@ class PLETransformer(AdaptiveResolutionMixin, AliasResolverMixin, TransformerMix Maximum number of bins per feature when ``adaptive=True``. random_state : int or None, default=51 Random state for reproducible tree fitting. - handle_missing : {"error", "median"}, default="median" - How to handle NaN values. - - - ``"error"``: raise an error when a NaN is encountered. - - ``"median"``: drop NaN rows during ``fit`` and, at ``transform`` time, - replace NaN with the median of that feature's thresholds (or ``0`` when - the feature produced no thresholds). max_depth : int or None, default=None Maximum depth of the decision tree. min_samples_split : int, default=2 @@ -87,8 +78,6 @@ class PLETransformer(AdaptiveResolutionMixin, AliasResolverMixin, TransformerMix total_output_dim_ : int Total number of output columns across all features (fitted); equals ``sum(n_bins_per_feature_)``. - fill_values_ : list of float - Per-feature fill value used to replace NaN during ``transform``. Notes ----- @@ -98,6 +87,10 @@ class PLETransformer(AdaptiveResolutionMixin, AliasResolverMixin, TransformerMix an upper bound (bin cap), not an exact width; this is a documented exception to the exact-width contract that the fixed-basis families follow. + PLE requires finite input: NaN values raise an error. Missing-value handling + is the responsibility of an upstream imputation step (for example the + ``Preprocessor`` imputation parameters), not of this transformer. + The ``max_depth`` / ``min_samples_split`` / ``min_samples_leaf`` parameters are retained for backward-compatible construction but no longer affect threshold placement: the ``placement_strategy`` selector fits its own model @@ -122,7 +115,6 @@ def __init__( min_output_dim=UNSET, max_output_dim=UNSET, random_state: int | None = 51, - handle_missing: Literal["error", "median"] = "median", max_depth: int | None = None, min_samples_split: int = 2, min_samples_leaf: int = 1, @@ -134,64 +126,52 @@ def __init__( self.min_output_dim = min_output_dim self.max_output_dim = max_output_dim self.random_state = random_state - self.handle_missing = handle_missing self.max_depth = max_depth self.min_samples_split = min_samples_split self.min_samples_leaf = min_samples_leaf def __sklearn_tags__(self): - """Declare NaN-passthrough (median policy) and the required-target tag.""" + """Declare the required-target tag; PLE requires finite input.""" tags = super().__sklearn_tags__() - tags.input_tags.allow_nan = self.handle_missing == "median" + tags.input_tags.allow_nan = False tags.target_tags.required = True return tags - def fit(self, X, y): + def fit(self, X, y=None): """Fit the transformer by learning per-feature bin thresholds. Parameters ---------- X : array-like of shape (n_samples, n_features) - Training data. + Training data. Must be finite; NaN values raise an error. y : array-like of shape (n_samples,) - Target values used to grow the per-feature decision trees. + Target values used to grow the per-feature decision trees. PLE is + always target-aware, so ``y`` is required. Returns ------- self : PLETransformer The fitted transformer. """ - finite_policy: Literal["allow-nan"] | bool = "allow-nan" if self.handle_missing == "median" else True + if y is None: + raise IncompatibleParamsError( + "PLETransformer is always target-aware and requires y at fit time; got y=None." + ) + X = check_array( X, dtype=np.float64, ensure_2d=True, - ensure_all_finite=finite_policy, + ensure_all_finite=True, ) y = np.asarray(y).ravel() if len(X) != len(y): raise PretabDataError(f"X and y must have same length. Got {len(X)} and {len(y)}") - if self.handle_missing == "median": - valid_mask = ~(np.isnan(X).any(axis=1) | np.isnan(y)) - if not valid_mask.all(): - n_removed = int((~valid_mask).sum()) - warnings.warn( - f"Removed {n_removed} samples with NaN values during fit", - DataWarning, - stacklevel=2, - ) - X = X[valid_mask] - y = y[valid_mask] - - if len(X) == 0: - raise EmptyDataError("All samples contain NaN values") - self.n_features_in_ = X.shape[1] self.thresholds_ = [] self.n_bins_per_feature_ = [] - self.fill_values_ = [] n_bins = self._resolve_param("output_dim", default=6) min_bins_req = self._resolve_param("min_output_dim", default=None) @@ -226,11 +206,6 @@ def fit(self, X, y): self.thresholds_.append(thresholds) self.n_bins_per_feature_.append(len(thresholds) + 1) - if len(thresholds) > 0: - self.fill_values_.append(float(np.median(thresholds))) - else: - self.fill_values_.append(0.0) - self.total_output_dim_ = int(sum(self.n_bins_per_feature_)) return self @@ -250,12 +225,11 @@ def transform(self, X): """ check_is_fitted(self, ["thresholds_", "n_features_in_"]) - finite_policy: Literal["allow-nan"] | bool = "allow-nan" if self.handle_missing == "median" else True X = check_array( X, dtype=np.float64, ensure_2d=True, - ensure_all_finite=finite_policy, + ensure_all_finite=True, ) if X.shape[1] != self.n_features_in_: @@ -270,12 +244,6 @@ def transform(self, X): feature = X[:, col].copy() thresholds = self.thresholds_[col] - nan_mask = np.isnan(feature) - if nan_mask.any(): - if self.handle_missing == "error": - raise PretabDataError(f"Feature {col} contains NaN values") - feature[nan_mask] = self.fill_values_[col] - ple_encoded = self._apply_piecewise_linear_vectorized(feature, thresholds) all_transformed.append(ple_encoded) diff --git a/tests/compose/conftest.py b/tests/compose/conftest.py index ea1f2e8..cc92e92 100644 --- a/tests/compose/conftest.py +++ b/tests/compose/conftest.py @@ -26,7 +26,9 @@ "scaling": None, "cat_cutoff": 0.03, "treat_all_integers_as_numerical": False, - "handle_missing": "median", + "numerical_imputation": "median", + "categorical_imputation": "most_frequent", + "add_missing_indicator": False, "verbose": 0, } diff --git a/tests/compose/test_registry_contract.py b/tests/compose/test_registry_contract.py index fa6eb91..ccef5d6 100644 --- a/tests/compose/test_registry_contract.py +++ b/tests/compose/test_registry_contract.py @@ -201,9 +201,8 @@ def test_required_target_methods_raise_without_y_via_preprocessor(name, spec): _skip_if_dependency_missing(spec) X = pd.DataFrame({"f0": np.linspace(0.0, 1.0, 60)}) pre = Preprocessor(numerical_method=name, target_aware=True, placement_strategy="cart") - # A required-target method must fail loudly when fit without a target. The - # precise exception type is tightened in Phase 4; here we only require that - # it does not silently succeed (sklearn's fit_transform surfaces the missing - # ``y`` as a ``TypeError`` rather than the transformer's own PretabError). - with pytest.raises((PretabError, TypeError)): + # A required-target method must fail loudly with a typed PretabError when fit + # without a target (Phase 4 tightened this from the raw TypeError that + # sklearn's fit_transform used to surface). + with pytest.raises(PretabError): pre.fit(X) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index a1dce09..d09496e 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -185,12 +185,11 @@ def test_ple_length_mismatch_is_data_error(xy): assert isinstance(exc.value, ValueError) -def test_ple_all_nan_is_empty_data_error(xy): +def test_ple_nan_input_is_value_error(xy): X, y = xy X_nan = np.full_like(X, np.nan) - with pytest.raises(EmptyDataError) as exc: + with pytest.raises(ValueError, match="NaN"): PLETransformer(output_dim=5).fit(X_nan, y) - assert isinstance(exc.value, PretabDataError) def test_thinplate_multivariate_is_data_error(): diff --git a/tests/test_ple_transformer.py b/tests/test_ple_transformer.py index f4e416c..97003e6 100644 --- a/tests/test_ple_transformer.py +++ b/tests/test_ple_transformer.py @@ -104,27 +104,24 @@ def test_ple_is_reproducible(): np.testing.assert_array_equal(a, b) -def test_ple_handles_nan_with_median(): +def test_ple_raises_on_nan_at_fit(): rng = np.random.RandomState(1) X = rng.rand(30, 1) y = rng.rand(30) - transformer = PLETransformer(output_dim=5, handle_missing="median") - transformer.fit(X, y) - X_missing = X.copy() X_missing[0, 0] = np.nan - Xt = transformer.transform(X_missing) - - assert np.isfinite(Xt).all() + transformer = PLETransformer(output_dim=5) + with pytest.raises(ValueError, match="NaN"): + transformer.fit(X_missing, y) -def test_ple_raises_on_nan_when_configured(): +def test_ple_raises_on_nan_at_transform(): rng = np.random.RandomState(2) X = rng.rand(30, 1) y = rng.rand(30) - transformer = PLETransformer(output_dim=5, handle_missing="error") + transformer = PLETransformer(output_dim=5) transformer.fit(X, y) X_missing = X.copy() diff --git a/tests/test_preprocessor.py b/tests/test_preprocessor.py index 908d7e3..a0782a2 100644 --- a/tests/test_preprocessor.py +++ b/tests/test_preprocessor.py @@ -114,7 +114,9 @@ def test_dict_keys_reflect_column_names(sample_data): "cat_cutoff", "treat_all_integers_as_numerical", "random_state", - "handle_missing", + "numerical_imputation", + "categorical_imputation", + "add_missing_indicator", "verbose", } diff --git a/tests/test_reproducibility.py b/tests/test_reproducibility.py index 75377e1..0659e7c 100644 --- a/tests/test_reproducibility.py +++ b/tests/test_reproducibility.py @@ -1,9 +1,11 @@ -"""Phase 18: ``random_state`` + ``handle_missing`` host control on the Preprocessor. - -Verifies that both knobs are exposed on the :class:`Preprocessor`, propagate to -the underlying numerical methods, keep prior behavior when unset, and make -stochastic fits reproducible -- so a standalone user or an embedding host -(DeepTab) can pin a global seed and choose a missing-value policy. +"""``random_state`` + missing-value host control on the Preprocessor. + +Verifies that the reproducibility seed and the imputation knobs +(``numerical_imputation`` / ``categorical_imputation`` / ``add_missing_indicator``) +are exposed on the :class:`Preprocessor`, drive the per-column pipelines, keep +prior behavior when unset, and make stochastic fits reproducible -- so a +standalone user or an embedding host (DeepTab) can pin a global seed and choose a +missing-value policy. """ import numpy as np @@ -43,18 +45,23 @@ def _numerical_transformer(pre, feature): def test_new_params_defaults_and_get_params(): pre = Preprocessor() assert pre.random_state is None - assert pre.handle_missing == "median" + assert pre.numerical_imputation == "median" + assert pre.categorical_imputation == "most_frequent" + assert pre.add_missing_indicator is False params = pre.get_params() assert params["random_state"] is None - assert params["handle_missing"] == "median" + assert params["numerical_imputation"] == "median" + assert params["categorical_imputation"] == "most_frequent" + assert params["add_missing_indicator"] is False def test_clone_preserves_new_params(): - pre = Preprocessor(random_state=99, handle_missing="error") + pre = Preprocessor(random_state=99, numerical_imputation="mean", add_missing_indicator=True) cloned = clone(pre) assert isinstance(cloned, Preprocessor) assert cloned.random_state == 99 - assert cloned.handle_missing == "error" + assert cloned.numerical_imputation == "mean" + assert cloned.add_missing_indicator is True # --- random_state forwarding ----------------------------------------------- # @@ -87,36 +94,43 @@ def test_fixed_random_state_makes_fit_reproducible(data, method): np.testing.assert_array_equal(o1, o2) -# --- handle_missing policy ------------------------------------------------- # +# --- missing-value / imputation policy ------------------------------------- # -def test_handle_missing_forwarded_to_ple(data): - X, y = data - pre = Preprocessor(numerical_method="ple", handle_missing="error").fit(X, y) - assert _numerical_transformer(pre, "a").handle_missing == "error" - - -def test_handle_missing_median_imputes_nan(data): +def test_numerical_imputation_median_fills_nan(data): X, y = data X = X.copy() X.iloc[0, 0] = np.nan - # Default "median" keeps the mean imputer, so NaN is filled before PLE. - pre = Preprocessor(numerical_method="ple", handle_missing="median").fit(X, y) + # Default "median" imputes before PLE, so NaN is filled and the fit succeeds. + pre = Preprocessor(numerical_method="ple").fit(X, y) out = pre.transform(X, return_array=True) assert isinstance(out, np.ndarray) assert np.isfinite(out).all() -def test_handle_missing_error_rejects_nan(data): +def test_numerical_imputation_none_lets_nan_reach_transformer(data): X, y = data X = X.copy() X.iloc[0, 0] = np.nan - # "error" drops the imputer, so NaN reaches PLE which raises. - pre = Preprocessor(numerical_method="ple", handle_missing="error") + # Disabling imputation lets NaN reach PLE, which requires finite input. + pre = Preprocessor(numerical_method="ple", numerical_imputation=None) with pytest.raises(ValueError): pre.fit(X, y) +def test_add_missing_indicator_appends_columns(data): + X, y = data + X = X.copy() + X.iloc[0, 0] = np.nan + base = Preprocessor(numerical_method="standardization").fit(X, y).transform(X, return_array=True) + with_ind = ( + Preprocessor(numerical_method="standardization", add_missing_indicator=True) + .fit(X, y) + .transform(X, return_array=True) + ) + assert with_ind.shape[1] > base.shape[1] + + # --- transformer / helper level seeding ------------------------------------ # diff --git a/tests/test_verbosity.py b/tests/test_verbosity.py index 092a295..cdc53be 100644 --- a/tests/test_verbosity.py +++ b/tests/test_verbosity.py @@ -15,8 +15,7 @@ import pytest from pretab import Preprocessor, PretabWarning, configure_logging, set_verbosity -from pretab.exceptions import ConfigWarning, DataWarning -from pretab.transformers import PLETransformer +from pretab.exceptions import ConfigWarning @pytest.fixture @@ -194,12 +193,3 @@ def test_config_warning_is_a_pretab_warning(sample_data): X, y = sample_data with pytest.warns(PretabWarning): Preprocessor(numerical_method="bspline", output_dim=100).fit(X, y) - - -def test_ple_nan_removal_warns_data_warning(): - rng = np.random.RandomState(3) - X = rng.rand(30, 1) - y = rng.rand(30) - X[0, 0] = np.nan - with pytest.warns(DataWarning): - PLETransformer(output_dim=5, handle_missing="median").fit(X, y) From 25e8f206b224ba31b162ce0ef2ecc26b7efa130d Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 26 Jul 2026 16:02:37 +0200 Subject: [PATCH 09/59] refactor(transformers)!: rename core transformers, drop temporal utils --- CHANGELOG.md | 5 + pretab/compose/registry.py | 27 +++-- pretab/transformers/__init__.py | 18 +-- pretab/transformers/categorical/legacy.py | 18 +++ pretab/transformers/numerical/__init__.py | 8 +- pretab/transformers/numerical/binning.py | 10 +- pretab/transformers/numerical/periodic.py | 6 +- pretab/transformers/splines/__init__.py | 4 +- .../transformers/splines/cubic_regression.py | 6 +- pretab/transformers/splines/p_spline.py | 17 +-- pretab/transformers/temporal/__init__.py | 17 --- pretab/transformers/temporal/lag.py | 65 ---------- pretab/transformers/temporal/rolling_stats.py | 88 -------------- tests/compose/test_registry_contract.py | 26 +++- tests/test_adaptive_output_dim.py | 20 ++-- tests/test_adaptive_resolution.py | 4 +- tests/test_cubic_transformer.py | 18 +-- tests/test_custombin_transformer.py | 18 +-- tests/test_encoder_feature_counts.py | 8 +- tests/test_exceptions.py | 22 ---- tests/test_method_aliases.py | 8 +- tests/test_output_dimension.py | 8 +- tests/test_param_aliases.py | 14 +-- tests/test_periodic.py | 44 +++++++ tests/test_sklearn_compat.py | 36 ++---- tests/test_spline_api_parity.py | 18 ++- tests/test_temporal.py | 113 ------------------ 27 files changed, 215 insertions(+), 431 deletions(-) delete mode 100644 pretab/transformers/temporal/__init__.py delete mode 100644 pretab/transformers/temporal/lag.py delete mode 100644 pretab/transformers/temporal/rolling_stats.py create mode 100644 tests/test_periodic.py delete mode 100644 tests/test_temporal.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c75188d..3ed58c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,11 @@ Going forward, this file is updated automatically by `cz bump` on each release. ### Refactor +- **transformers**: rename `CustomBinTransformer` → `NumericBinningTransformer`, `CyclicalTimeTransformer` → `PeriodicEncodingTransformer`, and `CubicSplineTransformer` → `CubicRegressionSplineTransformer` (intention-revealing public names) +- **transformers**: remove `LagFeatureTransformer` and `RollingStatsTransformer` (row-count-changing time-series utilities outside the tabular scope) +- **splines**: restrict `PSplineTransformer` to `placement_strategy="uniform"` (penalized splines require equally-spaced knots) +- **compose**: exclude the multivariate `tensorspline` / `tprs` methods from the per-column `Preprocessor` whitelist (they remain available as standalone transformers) +- **categorical**: deprecate `OneHotFromOrdinalTransformer` (use the `"one-hot"` categorical method backed by scikit-learn's `OneHotEncoder`) - consistent param order - remove dead selection helpers - **ple**: use location selectors for thresholds diff --git a/pretab/compose/registry.py b/pretab/compose/registry.py index 63d6767..d804765 100644 --- a/pretab/compose/registry.py +++ b/pretab/compose/registry.py @@ -37,10 +37,10 @@ from ..transformers.feature_maps.relu import ReLUExpansionTransformer from ..transformers.feature_maps.sigmoid import SigmoidExpansionTransformer from ..transformers.feature_maps.tanh import TanhExpansionTransformer -from ..transformers.numerical.binning import CustomBinTransformer +from ..transformers.numerical.binning import NumericBinningTransformer from ..transformers.numerical.piecewise import PLETransformer from ..transformers.splines.b_spline import BSplineTransformer -from ..transformers.splines.cubic_regression import CubicSplineTransformer +from ..transformers.splines.cubic_regression import CubicRegressionSplineTransformer from ..transformers.splines.i_spline import ISplineTransformer from ..transformers.splines.m_spline import MSplineTransformer from ..transformers.splines.multivariate.tensor_product import ( @@ -74,6 +74,7 @@ # Canonical placement strategy names, split by supervision. _UNSUPERVISED_STRATEGIES = frozenset({"uniform", "quantile"}) +_UNIFORM_ONLY = frozenset({"uniform"}) _TARGET_AWARE_STRATEGIES = frozenset({"cart", "lightgbm"}) _ALL_STRATEGIES = _UNSUPERVISED_STRATEGIES | _TARGET_AWARE_STRATEGIES @@ -191,7 +192,7 @@ def _spec(name, cls, allowed_args=(), **kwargs): supports_adaptive_resolution=True, ), # --- numerical / categorical: binning (no placement) --- - _spec("custombin", CustomBinTransformer, ("output_dim",), feature_kind=frozenset({NUMERICAL, CATEGORICAL})), + _spec("custombin", NumericBinningTransformer, ("output_dim",), feature_kind=frozenset({NUMERICAL, CATEGORICAL})), # --- numerical: feature maps (optional target-aware, adaptive) --- _spec( "rbf", @@ -220,7 +221,7 @@ def _spec(name, cls, allowed_args=(), **kwargs): # --- numerical: freely-placed knot splines (optional target-aware, adaptive) --- _spec( "cubicspline", - CubicSplineTransformer, + CubicRegressionSplineTransformer, ("output_dim", "degree", "include_bias", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state"), **_BOTH_MODE, ), @@ -235,7 +236,7 @@ def _spec(name, cls, allowed_args=(), **kwargs): "pspline", PSplineTransformer, ("output_dim", "degree", "diff_order"), - placement_strategies=_UNSUPERVISED_STRATEGIES, + placement_strategies=_UNIFORM_ONLY, ), _spec( "tensorspline", @@ -243,9 +244,16 @@ def _spec(name, cls, allowed_args=(), **kwargs): ("output_dim", "degree", "diff_order"), arity="multivariate", placement_strategies=_UNSUPERVISED_STRATEGIES, + preprocessor_compatible=False, ), # --- numerical: kernel-based thin-plate spline (knot-free, multivariate) --- - _spec("tprs", ThinPlateSplineTransformer, ("output_dim",), arity="multivariate"), + _spec( + "tprs", + ThinPlateSplineTransformer, + ("output_dim",), + arity="multivariate", + preprocessor_compatible=False, + ), # --- numerical: B / M / I spline bases (optional target-aware, adaptive) --- _spec( "bspline", @@ -297,11 +305,14 @@ def categorical_method_names() -> frozenset[str]: # Derived lookup tables consumed by the factory and config layers. # ``NUMERICAL_METHODS`` maps a numerical method to ``(class, allowed_args_list)``; -# ``CATEGORICAL_METHODS`` is the set of categorical method names. +# ``CATEGORICAL_METHODS`` is the set of categorical method names. Methods flagged +# ``preprocessor_compatible=False`` (the multivariate tensor-product / thin-plate +# splines) are standalone-only and deliberately excluded from the per-column +# ``Preprocessor`` whitelist. NUMERICAL_METHODS: dict[str, tuple[type, list[str]]] = { name: (spec.transformer_cls, list(spec.allowed_args)) for name, spec in TRANSFORMER_REGISTRY.items() - if spec.is_numerical + if spec.is_numerical and spec.preprocessor_compatible } CATEGORICAL_METHODS: frozenset[str] = categorical_method_names() diff --git a/pretab/transformers/__init__.py b/pretab/transformers/__init__.py index 8064d87..f90e797 100644 --- a/pretab/transformers/__init__.py +++ b/pretab/transformers/__init__.py @@ -11,13 +11,13 @@ TanhExpansionTransformer, ) from .numerical import ( - CustomBinTransformer, - CyclicalTimeTransformer, + NumericBinningTransformer, + PeriodicEncodingTransformer, PLETransformer, ) from .splines import ( BSplineTransformer, - CubicSplineTransformer, + CubicRegressionSplineTransformer, ISplineTransformer, MSplineTransformer, NaturalCubicSplineTransformer, @@ -25,29 +25,23 @@ TensorProductSplineTransformer, ThinPlateSplineTransformer, ) -from .temporal import ( - LagFeatureTransformer, - RollingStatsTransformer, -) __all__ = [ "BSplineTransformer", "ContinuousOrdinalTransformer", - "CubicSplineTransformer", - "CustomBinTransformer", - "CyclicalTimeTransformer", + "CubicRegressionSplineTransformer", "ISplineTransformer", - "LagFeatureTransformer", "LanguageEmbeddingTransformer", "MSplineTransformer", "NaturalCubicSplineTransformer", "NoTransformer", + "NumericBinningTransformer", "OneHotFromOrdinalTransformer", "PLETransformer", "PSplineTransformer", + "PeriodicEncodingTransformer", "RBFExpansionTransformer", "ReLUExpansionTransformer", - "RollingStatsTransformer", "SigmoidExpansionTransformer", "TanhExpansionTransformer", "TensorProductSplineTransformer", diff --git a/pretab/transformers/categorical/legacy.py b/pretab/transformers/categorical/legacy.py index 22cbbaf..59a5f5b 100644 --- a/pretab/transformers/categorical/legacy.py +++ b/pretab/transformers/categorical/legacy.py @@ -1,3 +1,5 @@ +import warnings + import numpy as np from sklearn.base import BaseEstimator, TransformerMixin from sklearn.utils.validation import check_is_fitted @@ -9,6 +11,13 @@ class OneHotFromOrdinalTransformer(TransformerMixin, BaseEstimator): This is useful when features have already been ordinal-encoded and a one-hot representation is required for model training. + .. deprecated:: 1.0.0 + ``OneHotFromOrdinalTransformer`` is deprecated and will be removed in a + future release. Use the ``"one-hot"`` categorical method (backed by + scikit-learn's :class:`~sklearn.preprocessing.OneHotEncoder`), which + one-hot encodes raw categories directly without a separate + ordinal-encoding step. + Attributes ---------- max_bins_ : ndarray of shape (n_features,) @@ -31,6 +40,15 @@ class OneHotFromOrdinalTransformer(TransformerMixin, BaseEstimator): (3, 5) """ + def __init__(self): + warnings.warn( + "OneHotFromOrdinalTransformer is deprecated and will be removed in a " + "future release. Use the 'one-hot' categorical method (sklearn's " + "OneHotEncoder), which one-hot encodes raw categories directly.", + DeprecationWarning, + stacklevel=2, + ) + def fit(self, X, y=None): """Learn the maximum bin index for each feature from the data. diff --git a/pretab/transformers/numerical/__init__.py b/pretab/transformers/numerical/__init__.py index 58c1de0..f00d588 100644 --- a/pretab/transformers/numerical/__init__.py +++ b/pretab/transformers/numerical/__init__.py @@ -3,12 +3,12 @@ and renamed to their intention-revealing public names in Phase 5. """ -from .binning import CustomBinTransformer -from .periodic import CyclicalTimeTransformer +from .binning import NumericBinningTransformer +from .periodic import PeriodicEncodingTransformer from .piecewise import PLETransformer __all__ = [ - "CustomBinTransformer", - "CyclicalTimeTransformer", + "NumericBinningTransformer", "PLETransformer", + "PeriodicEncodingTransformer", ] diff --git a/pretab/transformers/numerical/binning.py b/pretab/transformers/numerical/binning.py index 58bebe9..2fb95b3 100644 --- a/pretab/transformers/numerical/binning.py +++ b/pretab/transformers/numerical/binning.py @@ -8,7 +8,7 @@ from ...exceptions import InsufficientSamplesError, InvalidParamError, PretabDataError -class CustomBinTransformer(AliasResolverMixin, TransformerMixin, BaseEstimator): +class NumericBinningTransformer(AliasResolverMixin, TransformerMixin, BaseEstimator): """ Custom binning transformer for one-dimensional numerical features. @@ -50,9 +50,9 @@ class CustomBinTransformer(AliasResolverMixin, TransformerMixin, BaseEstimator): Examples -------- >>> import numpy as np - >>> from pretab.transformers import CustomBinTransformer + >>> from pretab.transformers import NumericBinningTransformer >>> X = np.linspace(0, 1, 10).reshape(-1, 1) - >>> transformer = CustomBinTransformer(output_dim=4) + >>> transformer = NumericBinningTransformer(output_dim=4) >>> transformer.fit_transform(X).shape (10, 1) """ @@ -113,7 +113,7 @@ def transform(self, X): X = X.astype(np.float64) except (ValueError, TypeError) as exc: raise PretabDataError( - "CustomBinTransformer requires numeric input: it bins continuous " + "NumericBinningTransformer requires numeric input: it bins continuous " "values with pandas.cut and cannot process string/categorical " "data. Encode string columns with a categorical method (e.g. " "'int' or 'one-hot') before binning." @@ -121,7 +121,7 @@ def transform(self, X): bins_spec = self._resolve_param("output_dim", default=UNSET) if bins_spec is UNSET: - raise InvalidParamError("CustomBinTransformer requires 'output_dim'.") + raise InvalidParamError("NumericBinningTransformer requires 'output_dim'.") if isinstance(bins_spec, int): # Calculate equal width bins based on the range of the data and number of bins diff --git a/pretab/transformers/numerical/periodic.py b/pretab/transformers/numerical/periodic.py index 9ad451c..9389b0d 100644 --- a/pretab/transformers/numerical/periodic.py +++ b/pretab/transformers/numerical/periodic.py @@ -5,7 +5,7 @@ from ...exceptions import PretabDataError -class CyclicalTimeTransformer(BasePreTabTransformer): +class PeriodicEncodingTransformer(BasePreTabTransformer): r"""Encode a cyclical time variable using sine and cosine components. Maps a periodic integer feature (such as hour of day or day of week) onto two @@ -36,9 +36,9 @@ class CyclicalTimeTransformer(BasePreTabTransformer): Examples -------- >>> import numpy as np - >>> from pretab.transformers import CyclicalTimeTransformer + >>> from pretab.transformers import PeriodicEncodingTransformer >>> X = np.array([[0], [6], [12], [18]]) - >>> transformer = CyclicalTimeTransformer(period=24) + >>> transformer = PeriodicEncodingTransformer(period=24) >>> transformer.fit_transform(X).shape (4, 2) """ diff --git a/pretab/transformers/splines/__init__.py b/pretab/transformers/splines/__init__.py index 548170d..8339755 100644 --- a/pretab/transformers/splines/__init__.py +++ b/pretab/transformers/splines/__init__.py @@ -1,6 +1,6 @@ from .b_spline import BSplineTransformer from .base_spline import BaseSplineTransformer -from .cubic_regression import CubicSplineTransformer +from .cubic_regression import CubicRegressionSplineTransformer from .i_spline import ISplineTransformer from .m_spline import MSplineTransformer from .multivariate.tensor_product import TensorProductSplineTransformer @@ -11,7 +11,7 @@ __all__ = [ "BSplineTransformer", "BaseSplineTransformer", - "CubicSplineTransformer", + "CubicRegressionSplineTransformer", "ISplineTransformer", "MSplineTransformer", "NaturalCubicSplineTransformer", diff --git a/pretab/transformers/splines/cubic_regression.py b/pretab/transformers/splines/cubic_regression.py index 4fde29b..2c36e28 100644 --- a/pretab/transformers/splines/cubic_regression.py +++ b/pretab/transformers/splines/cubic_regression.py @@ -8,7 +8,7 @@ from .mixins import SplineBasisMixin -class CubicSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEstimator): +class CubicRegressionSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEstimator): r""" Cubic Spline Transformer for one-dimensional or multi-dimensional input features. @@ -100,9 +100,9 @@ class CubicSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEstimator): Examples -------- >>> import numpy as np - >>> from pretab.transformers import CubicSplineTransformer + >>> from pretab.transformers import CubicRegressionSplineTransformer >>> X = np.linspace(0, 1, 20).reshape(-1, 1) - >>> transformer = CubicSplineTransformer(output_dim=8) + >>> transformer = CubicRegressionSplineTransformer(output_dim=8) >>> Xt = transformer.fit_transform(X) >>> Xt.shape (20, 8) diff --git a/pretab/transformers/splines/p_spline.py b/pretab/transformers/splines/p_spline.py index 16e5028..6daea5d 100644 --- a/pretab/transformers/splines/p_spline.py +++ b/pretab/transformers/splines/p_spline.py @@ -59,15 +59,15 @@ class PSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEstimator): If True, prepend a constant intercept column per feature. The bias term is left unpenalized (a zero row/column is added to the penalty matrix). - placement_strategy : {"uniform", "quantile"}, default="uniform" - Interior-knot placement rule. ``"uniform"`` spaces knots evenly across the - range; ``"quantile"`` places them at evenly spaced data quantiles. + placement_strategy : {"uniform"}, default="uniform" + Interior-knot placement rule. Only ``"uniform"`` (evenly spaced knots + across the range) is supported. .. note:: P-splines are penalized (difference-penalty) splines that assume - **equally-spaced** knots, so this family is *unsupervised-only*: - target-aware placement does not apply and only ``"uniform"`` / - ``"quantile"`` spacing is accepted. + **equally-spaced** knots, so this family is *unsupervised-only* and + requires uniform spacing: target-aware and quantile placement do not + apply and only ``"uniform"`` spacing is accepted. adaptive : bool, default=False Retained for API parity but a no-op for this unsupervised-only family: the @@ -149,9 +149,10 @@ def __init__( def fit(self, X, y=None): X = self._validate_allow_nan(X, reset=True) output_dim = self._resolve_param("output_dim", default=6) - if self.placement_strategy not in ("uniform", "quantile"): + if self.placement_strategy != "uniform": raise InvalidParamError( - f"Invalid placement_strategy. Choose 'uniform' or 'quantile'. Got {self.placement_strategy!r}." + f"Invalid placement_strategy. P-splines require equally-spaced knots, " + f"so only 'uniform' is supported. Got {self.placement_strategy!r}." ) strategy = self.placement_strategy diff --git a/pretab/transformers/temporal/__init__.py b/pretab/transformers/temporal/__init__.py deleted file mode 100644 index 24695a2..0000000 --- a/pretab/transformers/temporal/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Standalone time-series transformers. - -These transformers are **not** part of the :class:`~pretab.preprocessor.Preprocessor` -pipeline. ``LagFeatureTransformer`` and ``RollingStatsTransformer`` intentionally -change the row count (they drop the initial, incomplete windows) and assume the -rows are ordered in time, so they cannot be used inside the -:class:`~sklearn.compose.ColumnTransformer` the preprocessor builds. Use them -standalone on ordered arrays. -""" - -from .lag import LagFeatureTransformer -from .rolling_stats import RollingStatsTransformer - -__all__ = [ - "LagFeatureTransformer", - "RollingStatsTransformer", -] diff --git a/pretab/transformers/temporal/lag.py b/pretab/transformers/temporal/lag.py deleted file mode 100644 index 07771c4..0000000 --- a/pretab/transformers/temporal/lag.py +++ /dev/null @@ -1,65 +0,0 @@ -import numpy as np -from sklearn.utils.validation import check_is_fitted - -from ...core.base import BasePreTabTransformer -from ...exceptions import InsufficientSamplesError - - -class LagFeatureTransformer(BasePreTabTransformer): - """Create lagged features for time-series inputs. - - For each input column, previous time steps are appended as additional - features, which is useful for autoregressive modeling. - - Parameters - ---------- - n_lags : int, default=1 - Number of lag steps to include. - - Notes - ----- - Because the first ``n_lags`` observations have no complete history, the - transformed output has ``n_samples - n_lags`` rows. Each input feature is - expanded into ``n_lags`` lagged columns. - - This is a **standalone time-series utility**. It intentionally changes the - row count and assumes the rows are ordered in time, so it does not satisfy - the row-count-preserving contract that :class:`~sklearn.compose.ColumnTransformer` - (and therefore :class:`~pretab.preprocessor.Preprocessor`) require. Apply it - directly to an ordered array rather than routing it through the preprocessing - pipeline. - - Examples - -------- - >>> import numpy as np - >>> from pretab.transformers import LagFeatureTransformer - >>> X = np.arange(6).reshape(-1, 1) - >>> transformer = LagFeatureTransformer(n_lags=2) - >>> transformer.fit_transform(X).shape - (4, 2) - """ - - _allow_nan = False - _feature_suffix_value = "lag" - - def __init__(self, n_lags=1): - self.n_lags = n_lags - - def fit(self, X, y=None): - X = self._validate(X, reset=True) - if X.shape[0] <= self.n_lags: - raise InsufficientSamplesError("n_lags must be smaller than the number of samples.") - return self - - def transform(self, X): - check_is_fitted(self, "n_features_in_") - X = self._validate(X, reset=False) - n_samples = X.shape[0] - if n_samples <= self.n_lags: - raise InsufficientSamplesError("n_lags must be smaller than the number of samples.") - - lagged = [X[self.n_lags - i : -i or None] for i in range(1, self.n_lags + 1)] - return np.hstack(lagged) - - def _output_sizes(self) -> list[int]: - return [self.n_lags] * self.n_features_in_ diff --git a/pretab/transformers/temporal/rolling_stats.py b/pretab/transformers/temporal/rolling_stats.py deleted file mode 100644 index 3b6d36d..0000000 --- a/pretab/transformers/temporal/rolling_stats.py +++ /dev/null @@ -1,88 +0,0 @@ -import numpy as np -from sklearn.utils.validation import check_is_fitted - -from ...core.base import BasePreTabTransformer -from ...exceptions import InsufficientSamplesError, invalid_param_error - - -class RollingStatsTransformer(BasePreTabTransformer): - """Compute rolling-window statistics over time-series inputs. - - A sliding window of fixed size is moved across each feature and the requested - summary statistics are computed within each window. - - Parameters - ---------- - window_size : int, default=5 - Number of consecutive observations in each rolling window. - stats : tuple of str, default=("mean", "std") - Statistics to compute. Any of ``"mean"``, ``"std"``, ``"min"``, ``"max"``. - - Notes - ----- - Using a sliding window of size ``window_size`` yields - ``n_samples - window_size + 1`` output rows. Each requested statistic adds one - column per input feature. - - This is a **standalone time-series utility**. It intentionally changes the - row count and assumes the rows are ordered in time, so it does not satisfy - the row-count-preserving contract that :class:`~sklearn.compose.ColumnTransformer` - (and therefore :class:`~pretab.preprocessor.Preprocessor`) require. Apply it - directly to an ordered array rather than routing it through the preprocessing - pipeline. - - Examples - -------- - >>> import numpy as np - >>> from pretab.transformers import RollingStatsTransformer - >>> X = np.arange(10).reshape(-1, 1).astype(float) - >>> transformer = RollingStatsTransformer(window_size=3, stats=("mean", "std")) - >>> transformer.fit_transform(X).shape - (8, 2) - """ - - _allow_nan = False - _feature_suffix_value = "roll" - - def __init__(self, window_size=5, stats=("mean", "std")): - self.window_size = window_size - self.stats = stats - - def fit(self, X, y=None): - X = self._validate(X, reset=True) - if X.shape[0] < self.window_size: - raise InsufficientSamplesError("window_size must be less than number of samples.") - return self - - def transform(self, X): - check_is_fitted(self, "n_features_in_") - X = self._validate(X, reset=False) - n_samples = X.shape[0] - if n_samples < self.window_size: - raise InsufficientSamplesError("Insufficient samples for the given window size.") - - results = [] - for stat in self.stats: - rolled = np.lib.stride_tricks.sliding_window_view(X, self.window_size, axis=0) - if stat == "mean": - stat_val = rolled.mean(axis=2) - elif stat == "std": - stat_val = rolled.std(axis=2) - elif stat == "min": - stat_val = rolled.min(axis=2) - elif stat == "max": - stat_val = rolled.max(axis=2) - else: - raise invalid_param_error( - type(self).__name__, - "stats", - stat, - "each stat must be one of 'mean', 'std', 'min', 'max'", - valid={"mean", "std", "min", "max"}, - ) - results.append(stat_val) - - return np.hstack(results) - - def _output_sizes(self) -> list[int]: - return [len(self.stats)] * self.n_features_in_ diff --git a/tests/compose/test_registry_contract.py b/tests/compose/test_registry_contract.py index ccef5d6..8a7ce50 100644 --- a/tests/compose/test_registry_contract.py +++ b/tests/compose/test_registry_contract.py @@ -13,12 +13,13 @@ from pretab import Preprocessor from pretab.compose.registry import ( + NUMERICAL_METHODS, TRANSFORMER_REGISTRY, TransformerSpec, categorical_method_names, numerical_method_names, ) -from pretab.exceptions import OptionalDependencyError, PretabError +from pretab.exceptions import InvalidParamError, OptionalDependencyError, PretabError _VALID_KINDS = {"numerical", "categorical"} _VALID_ARITY = {"univariate", "multivariate"} @@ -206,3 +207,26 @@ def test_required_target_methods_raise_without_y_via_preprocessor(name, spec): # sklearn's fit_transform used to surface). with pytest.raises(PretabError): pre.fit(X) + + +# --------------------------------------------------------------------------- # +# Multivariate methods are standalone-only (D6): not selectable per column +# through the Preprocessor whitelist. +# --------------------------------------------------------------------------- # +_MULTIVARIATE_NUMERICAL = [(name, spec) for name, spec in _SPEC_ITEMS if spec.is_numerical and spec.is_multivariate] + + +@pytest.mark.parametrize( + "name, spec", _MULTIVARIATE_NUMERICAL, ids=[name for name, _ in _MULTIVARIATE_NUMERICAL] +) +def test_multivariate_methods_not_preprocessor_selectable(name, spec): + # The multivariate tensor-product / thin-plate splines are standalone-only and + # deliberately excluded from the per-column Preprocessor whitelist; selecting + # one must fail loudly rather than silently misbehave. + assert spec.preprocessor_compatible is False + assert name not in NUMERICAL_METHODS + X = pd.DataFrame({"f0": np.linspace(0.0, 1.0, 60), "f1": np.linspace(1.0, 2.0, 60)}) + y = np.linspace(0.0, 1.0, 60) + pre = Preprocessor(numerical_method=name) + with pytest.raises(InvalidParamError): + pre.fit(X, y) diff --git a/tests/test_adaptive_output_dim.py b/tests/test_adaptive_output_dim.py index 1caa882..bff87ed 100644 --- a/tests/test_adaptive_output_dim.py +++ b/tests/test_adaptive_output_dim.py @@ -52,8 +52,6 @@ "cubicspline": OUTPUT_DIM, "naturalspline": OUTPUT_DIM, "pspline": OUTPUT_DIM, - "tensorspline": OUTPUT_DIM, - "tprs": OUTPUT_DIM, "mspline": OUTPUT_DIM, "ispline": OUTPUT_DIM, # B-spline defaults to include_bias=True -> output_dim + 1 @@ -75,11 +73,11 @@ ] # Fixed-only spline families: target-aware placement does not apply. The -# penalized splines (``pspline``, ``tensorspline``) assume equally-spaced knots -# for their difference penalty, and the thin-plate spline (``tprs``) is -# kernel-based (knot-free). All three stay fixed-width regardless of the adaptive -# / selector knobs. -FIXED_ONLY_SPLINE_METHODS = ["pspline", "tensorspline", "tprs"] +# penalized ``pspline`` assumes equally-spaced knots for its difference penalty, +# so it stays fixed-width regardless of the adaptive / selector knobs. (The +# multivariate ``tensorspline`` / ``tprs`` are standalone-only and not selectable +# through the Preprocessor, so they are exercised in their own transformer tests.) +FIXED_ONLY_SPLINE_METHODS = ["pspline"] # All spline families (kept for callers that want the full set). SPLINE_METHODS = TARGET_AWARE_LEGACY_SPLINE_METHODS + FIXED_ONLY_SPLINE_METHODS + BMI_SPLINE_METHODS @@ -289,11 +287,11 @@ def test_legacy_spline_adaptive_via_preprocessor(data, method): @pytest.mark.parametrize("method", FIXED_ONLY_SPLINE_METHODS) def test_fixed_only_spline_ignores_adaptive(data, method): - """Penalized / kernel splines are not target-aware: the adaptive window is a no-op. + """Penalized splines are not target-aware: the adaptive window is a no-op. - ``pspline`` / ``tensorspline`` need equally-spaced knots for their difference - penalty and ``tprs`` is knot-free, so the Preprocessor never routes them - through the selector / adaptive path -- the width stays ``output_dim``. + ``pspline`` needs equally-spaced knots for its difference penalty, so the + Preprocessor never routes it through the selector / adaptive path -- the width + stays ``output_dim``. """ X, y = data fixed = _num_width( diff --git a/tests/test_adaptive_resolution.py b/tests/test_adaptive_resolution.py index e7df71b..fafa79b 100644 --- a/tests/test_adaptive_resolution.py +++ b/tests/test_adaptive_resolution.py @@ -15,7 +15,7 @@ from pretab.core.adaptive import AdaptiveResolutionMixin from pretab.transformers import ( BSplineTransformer, - CubicSplineTransformer, + CubicRegressionSplineTransformer, NaturalCubicSplineTransformer, PLETransformer, RBFExpansionTransformer, @@ -119,7 +119,7 @@ def test_feature_map_adaptive_is_noop_on_quantile_path(Cls, data): # Legacy splines (target-aware placement path) # # --------------------------------------------------------------------------- # LEGACY_SPLINES = [ - (CubicSplineTransformer, 8), + (CubicRegressionSplineTransformer, 8), (NaturalCubicSplineTransformer, 6), ] diff --git a/tests/test_cubic_transformer.py b/tests/test_cubic_transformer.py index 610842e..a0f2eeb 100644 --- a/tests/test_cubic_transformer.py +++ b/tests/test_cubic_transformer.py @@ -2,12 +2,12 @@ import pytest from sklearn.exceptions import NotFittedError -from pretab.transformers import CubicSplineTransformer +from pretab.transformers import CubicRegressionSplineTransformer def test_cubic_spline_single_feature_shape(): X = np.linspace(0, 1, 20).reshape(-1, 1) - transformer = CubicSplineTransformer(output_dim=8) + transformer = CubicRegressionSplineTransformer(output_dim=8) Xt = transformer.fit_transform(X) # output_dim non-bias columns (m = 3 + K interior knots) per feature @@ -19,7 +19,7 @@ def test_cubic_spline_single_feature_shape(): def test_cubic_spline_multi_feature_shape(): X = np.random.rand(15, 3) - transformer = CubicSplineTransformer(output_dim=9, include_bias=True) + transformer = CubicRegressionSplineTransformer(output_dim=9, include_bias=True) Xt = transformer.fit_transform(X) expected_dim = (1 + 9) * 3 # bias + output_dim columns, per feature @@ -30,7 +30,7 @@ def test_cubic_spline_multi_feature_shape(): def test_cubic_spline_output_consistency(): X = np.random.rand(10, 2) - transformer = CubicSplineTransformer(output_dim=7) + transformer = CubicRegressionSplineTransformer(output_dim=7) transformer.fit(X) Xt1 = transformer.transform(X) Xt2 = transformer.fit_transform(X) @@ -41,7 +41,7 @@ def test_cubic_spline_output_consistency(): def test_cubic_spline_penalty_matrix_shape(): X = np.linspace(0, 1, 30).reshape(-1, 1) - transformer = CubicSplineTransformer(output_dim=10) + transformer = CubicRegressionSplineTransformer(output_dim=10) transformer.fit(X) P = transformer.get_penalty_matrix() @@ -51,7 +51,7 @@ def test_cubic_spline_penalty_matrix_shape(): def test_cubic_feature_names_out(): X = np.random.rand(20, 2) - transformer = CubicSplineTransformer(output_dim=8) + transformer = CubicRegressionSplineTransformer(output_dim=8) Xt = transformer.fit_transform(X) names = transformer.get_feature_names_out(["a", "b"]) @@ -62,7 +62,7 @@ def test_cubic_feature_names_out(): def test_cubic_feature_names_out_default_input(): X = np.random.rand(15, 2) - transformer = CubicSplineTransformer(output_dim=7).fit(X) + transformer = CubicRegressionSplineTransformer(output_dim=7).fit(X) names = transformer.get_feature_names_out() assert len(names) == sum(transformer.n_basis_) @@ -70,12 +70,12 @@ def test_cubic_feature_names_out_default_input(): def test_cubic_allow_nan_tag(): - tags = CubicSplineTransformer().__sklearn_tags__() + tags = CubicRegressionSplineTransformer().__sklearn_tags__() assert tags.input_tags.allow_nan is True def test_cubic_transform_requires_fit(): - transformer = CubicSplineTransformer() + transformer = CubicRegressionSplineTransformer() with pytest.raises(NotFittedError): transformer.transform(np.random.rand(5, 1)) with pytest.raises(NotFittedError): diff --git a/tests/test_custombin_transformer.py b/tests/test_custombin_transformer.py index 360a991..a9de739 100644 --- a/tests/test_custombin_transformer.py +++ b/tests/test_custombin_transformer.py @@ -4,13 +4,13 @@ from sklearn.base import BaseEstimator, TransformerMixin from pretab.exceptions import InsufficientSamplesError, PretabDataError -from pretab.transformers import CustomBinTransformer +from pretab.transformers import NumericBinningTransformer @pytest.mark.parametrize("bins", [2, [0.0, 0.5, 1.0]]) def test_custom_bin_transformer_basic_functionality(bins): X = np.array([[0.1], [0.4], [0.6], [0.8], [0.95]]) - transformer = CustomBinTransformer(output_dim=bins) + transformer = NumericBinningTransformer(output_dim=bins) transformer.fit(X) # Ensure fitted attribute exists @@ -43,7 +43,7 @@ def test_custom_bin_transformer_input_types(bins, input_type): if input_type == "np" else pd.DataFrame(raw, columns=["x"]) ) - transformer = CustomBinTransformer(output_dim=bins) + transformer = NumericBinningTransformer(output_dim=bins) Xt = transformer.fit_transform(X) assert isinstance(Xt, np.ndarray) @@ -51,13 +51,13 @@ def test_custom_bin_transformer_input_types(bins, input_type): def test_custom_bin_transformer_invalid_input(): - transformer = CustomBinTransformer(output_dim=3) + transformer = NumericBinningTransformer(output_dim=3) with pytest.raises(PretabDataError): transformer.transform("invalid_input") def test_custom_bin_transformer_raises_on_invalid_shape(): - transformer = CustomBinTransformer(output_dim=3) + transformer = NumericBinningTransformer(output_dim=3) X = np.array([[0.1]]) # This will become scalar after squeeze() with pytest.raises(ValueError, match=r"Input must have more than 2 observations."): @@ -66,21 +66,21 @@ def test_custom_bin_transformer_raises_on_invalid_shape(): def test_custom_bin_transformer_invalid_bins_type(): with pytest.raises(InsufficientSamplesError): - CustomBinTransformer(output_dim="not_valid").fit_transform(np.array([[0.1]])) + NumericBinningTransformer(output_dim="not_valid").fit_transform(np.array([[0.1]])) def test_custom_bin_transformer_feature_names_out(): - transformer = CustomBinTransformer(output_dim=3) + transformer = NumericBinningTransformer(output_dim=3) transformer.fit(np.array([[0.2]])) names = transformer.get_feature_names_out(["feature1"]) assert names == ["feature1"] def test_custom_bin_transformer_feature_names_out_raises(): - transformer = CustomBinTransformer(output_dim=3) + transformer = NumericBinningTransformer(output_dim=3) with pytest.raises(ValueError): transformer.get_feature_names_out() def test_custom_bin_transformer_is_sklearn_compatible(): - assert isinstance(CustomBinTransformer(output_dim=3), (BaseEstimator, TransformerMixin)) + assert isinstance(NumericBinningTransformer(output_dim=3), (BaseEstimator, TransformerMixin)) diff --git a/tests/test_encoder_feature_counts.py b/tests/test_encoder_feature_counts.py index ecef534..68d472a 100644 --- a/tests/test_encoder_feature_counts.py +++ b/tests/test_encoder_feature_counts.py @@ -1,14 +1,14 @@ """A2: ``n_features_in_`` must reflect the fitted column count, not a hardcoded 1. -Covers ``NoTransformer``, ``ToFloatTransformer`` and ``CustomBinTransformer``. +Covers ``NoTransformer``, ``ToFloatTransformer`` and ``NumericBinningTransformer``. """ import numpy as np import pytest from pretab.transformers import ( - CustomBinTransformer, NoTransformer, + NumericBinningTransformer, ToFloatTransformer, ) @@ -27,10 +27,10 @@ def test_to_float_transformer_records_feature_count(n_cols): def test_custom_bin_transformer_records_single_feature(): X = np.linspace(0, 1, 10).reshape(-1, 1) - assert CustomBinTransformer(output_dim=4).fit(X).n_features_in_ == 1 + assert NumericBinningTransformer(output_dim=4).fit(X).n_features_in_ == 1 def test_custom_bin_transformer_reads_actual_column_count(): # Proves the value is derived from X, not hardcoded to 1. X = np.zeros((10, 2)) - assert CustomBinTransformer(output_dim=4).fit(X).n_features_in_ == 2 + assert NumericBinningTransformer(output_dim=4).fit(X).n_features_in_ == 2 diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index d09496e..70b36af 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -35,9 +35,7 @@ from pretab.placement.adapters import SplinePlacementAdapter from pretab.transformers import ( BSplineTransformer, - LagFeatureTransformer, PLETransformer, - RollingStatsTransformer, ThinPlateSplineTransformer, ) @@ -209,26 +207,6 @@ def test_cart_selector_requires_y(xy): SplinePlacementAdapter(placement_strategy="cart", degree=3).get_knot_locations(X, y=None) -# --------------------------------------------------------------------------- # -# Temporal transformers. -# --------------------------------------------------------------------------- # - - -def test_lag_insufficient_samples(): - X = np.arange(5).reshape(-1, 1).astype(float) - with pytest.raises(InsufficientSamplesError) as exc: - LagFeatureTransformer(n_lags=10).fit(X) - assert isinstance(exc.value, ValueError) - - -def test_rolling_unsupported_stat(): - X = np.arange(20).reshape(-1, 1).astype(float) - transformer = RollingStatsTransformer(window_size=3, stats=("mean", "bogus")) - transformer.fit(X) - with pytest.raises(InvalidParamError, match="bogus"): - transformer.transform(X) - - # --------------------------------------------------------------------------- # # Preprocessor entry point forwards typed config errors. # --------------------------------------------------------------------------- # diff --git a/tests/test_method_aliases.py b/tests/test_method_aliases.py index 1f616cb..5e21422 100644 --- a/tests/test_method_aliases.py +++ b/tests/test_method_aliases.py @@ -7,6 +7,7 @@ CATEGORICAL_METHODS, NUMERICAL_ALIASES, NUMERICAL_METHODS, + numerical_method_names, resolve_method, ) from pretab.exceptions import InvalidParamError @@ -86,8 +87,13 @@ def test_every_canonical_name_resolves_to_itself(): def test_alias_targets_are_canonical(): + # Alias targets must be canonical *registry* names. The multivariate + # tensor-product / thin-plate methods are standalone-only (excluded from the + # per-column ``NUMERICAL_METHODS`` whitelist), so validate against the full + # registry rather than the Preprocessor whitelist. + numerical_canonical = numerical_method_names() for target in NUMERICAL_ALIASES.values(): - assert target in NUMERICAL_METHODS + assert target in numerical_canonical for target in CATEGORICAL_ALIASES.values(): assert target in CATEGORICAL_METHODS diff --git a/tests/test_output_dimension.py b/tests/test_output_dimension.py index 8ea692f..3e902bf 100644 --- a/tests/test_output_dimension.py +++ b/tests/test_output_dimension.py @@ -13,11 +13,11 @@ from pretab.transformers import ( BSplineTransformer, - CubicSplineTransformer, - CustomBinTransformer, + CubicRegressionSplineTransformer, ISplineTransformer, MSplineTransformer, NaturalCubicSplineTransformer, + NumericBinningTransformer, PLETransformer, PSplineTransformer, RBFExpansionTransformer, @@ -47,7 +47,7 @@ def Xy(): # Splines whose transformed width is exactly ``n_features * output_dim``. PER_FEATURE_SPLINES = [ - CubicSplineTransformer, + CubicRegressionSplineTransformer, NaturalCubicSplineTransformer, PSplineTransformer, MSplineTransformer, @@ -120,7 +120,7 @@ def test_ple_output_dim_is_a_per_feature_cap(Xy): def test_custombin_is_always_a_single_ordinal_column(): X = np.linspace(0, 1, 50).reshape(-1, 1) - transformer = CustomBinTransformer(output_dim=OUTPUT_DIM).fit(X) + transformer = NumericBinningTransformer(output_dim=OUTPUT_DIM).fit(X) Xt = transformer.transform(X) assert Xt.shape[1] == 1 assert transformer.total_output_dim_ == 1 diff --git a/tests/test_param_aliases.py b/tests/test_param_aliases.py index cfd5ebc..1231a94 100644 --- a/tests/test_param_aliases.py +++ b/tests/test_param_aliases.py @@ -12,11 +12,11 @@ from pretab.transformers import ( BSplineTransformer, - CubicSplineTransformer, - CustomBinTransformer, + CubicRegressionSplineTransformer, ISplineTransformer, MSplineTransformer, NaturalCubicSplineTransformer, + NumericBinningTransformer, PLETransformer, PSplineTransformer, RBFExpansionTransformer, @@ -45,8 +45,8 @@ def Xy(): # (transformer, removed constructor name) - passing any legacy count spelling # must raise TypeError at construction (hard removal, no FutureWarning window). REMOVED_COUNT_CASES = [ - (CubicSplineTransformer, "n_basis"), - (CubicSplineTransformer, "n_knots"), + (CubicRegressionSplineTransformer, "n_basis"), + (CubicRegressionSplineTransformer, "n_knots"), (NaturalCubicSplineTransformer, "n_basis"), (NaturalCubicSplineTransformer, "n_knots"), (PSplineTransformer, "n_basis"), @@ -71,8 +71,8 @@ def Xy(): (PLETransformer, "max_basis"), (PLETransformer, "min_bins"), (PLETransformer, "max_bins"), - (CustomBinTransformer, "n_basis"), - (CustomBinTransformer, "bins"), + (NumericBinningTransformer, "n_basis"), + (NumericBinningTransformer, "bins"), ] @@ -84,7 +84,7 @@ def test_removed_count_name_raises_typeerror(cls, removed): # Every family accepts the canonical output_dim knob. OUTPUT_DIM_CLASSES = [ - (CubicSplineTransformer, 8), + (CubicRegressionSplineTransformer, 8), (NaturalCubicSplineTransformer, 6), (PSplineTransformer, 8), (TensorProductSplineTransformer, 5), diff --git a/tests/test_periodic.py b/tests/test_periodic.py new file mode 100644 index 0000000..642c331 --- /dev/null +++ b/tests/test_periodic.py @@ -0,0 +1,44 @@ +"""Contract tests for the standalone :class:`PeriodicEncodingTransformer`. + +``PeriodicEncodingTransformer`` preserves the row count but requires a per-feature +``period`` argument and constrains its inputs to ``[0, period]``, so it is applied +directly rather than wired into the ``Preprocessor`` pipeline. + +These tests pin the intended behaviour: the exact output shapes/values, the +generated feature names, and the input-range guard. +""" + +import numpy as np +import pytest + +from pretab.exceptions import PretabDataError +from pretab.transformers import PeriodicEncodingTransformer + + +def test_cyclic_preserves_rows_and_pins_values(): + X = np.array([[0], [6], [12], [18]]) + out = PeriodicEncodingTransformer(period=24).fit_transform(X) + # Row count preserved; columns are (sin, cos). + assert out.shape == (4, 2) + expected_angle = 2 * np.pi * X.ravel() / 24 + np.testing.assert_allclose(out[:, 0], np.sin(expected_angle), atol=1e-12) + np.testing.assert_allclose(out[:, 1], np.cos(expected_angle), atol=1e-12) + + +def test_cyclic_rejects_out_of_range_input(): + transformer = PeriodicEncodingTransformer(period=24) + with pytest.raises(PretabDataError): + transformer.fit(np.array([[25]])) + with pytest.raises(PretabDataError): + transformer.fit(np.array([[-1]])) + + +def test_cyclic_requires_period(): + with pytest.raises(TypeError): + PeriodicEncodingTransformer() # type: ignore[call-arg] + + +def test_cyclic_feature_names(): + X = np.array([[0], [6], [12], [18]]) + transformer = PeriodicEncodingTransformer(period=24).fit(X) + np.testing.assert_array_equal(transformer.get_feature_names_out(["hour"]), ["hour_cyclic0", "hour_cyclic1"]) diff --git a/tests/test_sklearn_compat.py b/tests/test_sklearn_compat.py index acd7dc4..ccd328d 100644 --- a/tests/test_sklearn_compat.py +++ b/tests/test_sklearn_compat.py @@ -23,20 +23,18 @@ from pretab.transformers import ( BSplineTransformer, ContinuousOrdinalTransformer, - CubicSplineTransformer, - CustomBinTransformer, - CyclicalTimeTransformer, + CubicRegressionSplineTransformer, ISplineTransformer, - LagFeatureTransformer, MSplineTransformer, NaturalCubicSplineTransformer, NoTransformer, + NumericBinningTransformer, OneHotFromOrdinalTransformer, + PeriodicEncodingTransformer, PLETransformer, PSplineTransformer, RBFExpansionTransformer, ReLUExpansionTransformer, - RollingStatsTransformer, SigmoidExpansionTransformer, TanhExpansionTransformer, TensorProductSplineTransformer, @@ -63,7 +61,7 @@ (BSplineTransformer(), _SPLINE_EXPECTED), (MSplineTransformer(), _SPLINE_EXPECTED), (ISplineTransformer(), _SPLINE_EXPECTED), - (CubicSplineTransformer(), _SPLINE_EXPECTED), + (CubicRegressionSplineTransformer(), _SPLINE_EXPECTED), (NaturalCubicSplineTransformer(), _SPLINE_EXPECTED), (PSplineTransformer(), _SPLINE_EXPECTED), (TensorProductSplineTransformer(), _SPLINE_EXPECTED), @@ -130,8 +128,8 @@ def test_check_estimator_near_conformant(estimator, expected_failed_checks): ), ), pytest.param( - CustomBinTransformer(), - id="CustomBinTransformer", + NumericBinningTransformer(), + id="NumericBinningTransformer", marks=pytest.mark.xfail( reason="Single-column ordinal binner; expects (n_samples, 1) input, " "incompatible with generic multi-feature checks.", @@ -139,32 +137,14 @@ def test_check_estimator_near_conformant(estimator, expected_failed_checks): ), ), pytest.param( - CyclicalTimeTransformer(period=12), - id="CyclicalTimeTransformer", + PeriodicEncodingTransformer(period=12), + id="PeriodicEncodingTransformer", marks=pytest.mark.xfail( reason="Requires a `period` constructor argument (not default-" "constructible) and constrains inputs to [0, period].", strict=True, ), ), - pytest.param( - LagFeatureTransformer(), - id="LagFeatureTransformer", - marks=pytest.mark.xfail( - reason="Windowing transformer changes the sample count, so it fails " - "checks that assume transform preserves n_samples.", - strict=True, - ), - ), - pytest.param( - RollingStatsTransformer(), - id="RollingStatsTransformer", - marks=pytest.mark.xfail( - reason="Windowing transformer changes the sample count, so it fails " - "checks that assume transform preserves n_samples.", - strict=True, - ), - ), pytest.param( ContinuousOrdinalTransformer(), id="ContinuousOrdinalTransformer", diff --git a/tests/test_spline_api_parity.py b/tests/test_spline_api_parity.py index 20a0567..48c52c0 100644 --- a/tests/test_spline_api_parity.py +++ b/tests/test_spline_api_parity.py @@ -9,7 +9,7 @@ from pretab.exceptions import IncompatibleParamsError from pretab.transformers import ( - CubicSplineTransformer, + CubicRegressionSplineTransformer, NaturalCubicSplineTransformer, PSplineTransformer, TensorProductSplineTransformer, @@ -18,16 +18,24 @@ # (class, output_dim) for the four knot-based splines that share the placement API. KNOT_SPLINES = [ - (CubicSplineTransformer, 8), + (CubicRegressionSplineTransformer, 8), (NaturalCubicSplineTransformer, 6), (PSplineTransformer, 8), (TensorProductSplineTransformer, 5), ] +# Splines that also accept quantile placement. P-splines are uniform-only +# (equally-spaced knots for the difference penalty), so they are excluded here. +QUANTILE_SPLINES = [ + (CubicRegressionSplineTransformer, 8), + (NaturalCubicSplineTransformer, 6), + (TensorProductSplineTransformer, 5), +] + # The knot-based splines that also support the target-aware placement path. # (The penalized ``pspline`` / ``tensorspline`` are unsupervised-only.) TARGET_AWARE_SPLINES = [ - (CubicSplineTransformer, 8), + (CubicRegressionSplineTransformer, 8), (NaturalCubicSplineTransformer, 6), ] @@ -59,7 +67,7 @@ def test_default_strategy_matches_explicit_uniform(cls, output_dim, X_uniform): np.testing.assert_allclose(default, explicit, rtol=1e-10) -@pytest.mark.parametrize(("cls", "output_dim"), KNOT_SPLINES) +@pytest.mark.parametrize(("cls", "output_dim"), QUANTILE_SPLINES) def test_quantile_strategy_runs_and_differs(cls, output_dim, X_skewed): """placement_strategy='quantile' produces a finite basis of the same width as uniform.""" uniform = cls(output_dim=output_dim, placement_strategy="uniform").fit_transform(X_skewed) @@ -111,7 +119,7 @@ def test_thinplate_include_bias_adds_one_column(): @pytest.mark.parametrize( ("cls", "expected"), [ - (CubicSplineTransformer, {"target_aware", "placement_strategy", "task", "include_bias"}), + (CubicRegressionSplineTransformer, {"target_aware", "placement_strategy", "task", "include_bias"}), (NaturalCubicSplineTransformer, {"degree", "target_aware", "placement_strategy", "task"}), (PSplineTransformer, {"placement_strategy", "include_bias"}), (TensorProductSplineTransformer, {"placement_strategy", "include_bias"}), diff --git a/tests/test_temporal.py b/tests/test_temporal.py deleted file mode 100644 index 69182f1..0000000 --- a/tests/test_temporal.py +++ /dev/null @@ -1,113 +0,0 @@ -"""Contract tests for the standalone temporal transformers. - -The temporal transformers are documented as standalone time-series utilities that -are deliberately *not* wired into the ``Preprocessor`` pipeline: - -* ``LagFeatureTransformer`` and ``RollingStatsTransformer`` intentionally change - the row count (they drop the initial, incomplete windows) and assume the rows - are ordered in time, so they cannot live inside the ``ColumnTransformer`` the - preprocessor builds. -* ``CyclicalTimeTransformer`` preserves the row count but requires a per-feature - ``period`` argument and constrains its inputs, so it is also applied directly. - -These tests pin that intended behaviour: the exact output shapes/values, the -row-count semantics, the generated feature names, and the input-range guard. -Error paths (insufficient samples, unsupported stat) are covered in -``tests/test_exceptions.py``. -""" - -import numpy as np -import pytest - -from pretab.exceptions import PretabDataError -from pretab.transformers import ( - CyclicalTimeTransformer, - LagFeatureTransformer, - RollingStatsTransformer, -) - -# --------------------------------------------------------------------------- # -# LagFeatureTransformer -# --------------------------------------------------------------------------- # - - -def test_lag_reduces_rows_and_pins_values(): - X = np.arange(6).reshape(-1, 1) - out = LagFeatureTransformer(n_lags=2).fit_transform(X) - # n_samples - n_lags rows; columns are (lag-1, lag-2). - assert out.shape == (4, 2) - np.testing.assert_array_equal(out, [[1, 0], [2, 1], [3, 2], [4, 3]]) - - -def test_lag_default_single_lag(): - X = np.arange(5).reshape(-1, 1) - out = LagFeatureTransformer().fit_transform(X) - assert out.shape == (4, 1) - np.testing.assert_array_equal(out.ravel(), [0, 1, 2, 3]) - - -def test_lag_feature_names(): - X = np.arange(6).reshape(-1, 1) - transformer = LagFeatureTransformer(n_lags=2).fit(X) - np.testing.assert_array_equal(transformer.get_feature_names_out(["t"]), ["t_lag0", "t_lag1"]) - - -# --------------------------------------------------------------------------- # -# RollingStatsTransformer -# --------------------------------------------------------------------------- # - - -def test_rolling_reduces_rows_and_pins_mean(): - X = np.arange(10).reshape(-1, 1).astype(float) - out = RollingStatsTransformer(window_size=3, stats=("mean",)).fit_transform(X) - # n_samples - window_size + 1 rows. - assert out.shape == (8, 1) - np.testing.assert_allclose(out.ravel(), np.arange(1, 9, dtype=float)) - - -def test_rolling_min_max_columns(): - X = np.arange(10).reshape(-1, 1).astype(float) - out = RollingStatsTransformer(window_size=3, stats=("min", "max")).fit_transform(X) - assert out.shape == (8, 2) - np.testing.assert_allclose(out[:, 0], np.arange(0, 8, dtype=float)) # min - np.testing.assert_allclose(out[:, 1], np.arange(2, 10, dtype=float)) # max - - -def test_rolling_feature_names(): - X = np.arange(10).reshape(-1, 1).astype(float) - transformer = RollingStatsTransformer(window_size=3, stats=("mean", "std")).fit(X) - np.testing.assert_array_equal(transformer.get_feature_names_out(["t"]), ["t_roll0", "t_roll1"]) - - -# --------------------------------------------------------------------------- # -# CyclicalTimeTransformer -# --------------------------------------------------------------------------- # - - -def test_cyclic_preserves_rows_and_pins_values(): - X = np.array([[0], [6], [12], [18]]) - out = CyclicalTimeTransformer(period=24).fit_transform(X) - # Row count preserved; columns are (sin, cos). - assert out.shape == (4, 2) - expected_angle = 2 * np.pi * X.ravel() / 24 - np.testing.assert_allclose(out[:, 0], np.sin(expected_angle), atol=1e-12) - np.testing.assert_allclose(out[:, 1], np.cos(expected_angle), atol=1e-12) - - -def test_cyclic_rejects_out_of_range_input(): - transformer = CyclicalTimeTransformer(period=24) - with pytest.raises(PretabDataError): - transformer.fit(np.array([[25]])) - with pytest.raises(PretabDataError): - transformer.fit(np.array([[-1]])) - - -def test_cyclic_requires_period(): - with pytest.raises(TypeError): - CyclicalTimeTransformer() # type: ignore[call-arg] - - -def test_cyclic_feature_names(): - X = np.array([[0], [6], [12], [18]]) - transformer = CyclicalTimeTransformer(period=24).fit(X) - np.testing.assert_array_equal(transformer.get_feature_names_out(["hour"]), ["hour_cyclic0", "hour_cyclic1"]) From 80c5308e37e181628e3774df8b4b505722643b90 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 26 Jul 2026 16:31:52 +0200 Subject: [PATCH 10/59] feat(transformers)!: rewrite binning, periodic encoding, and thin-plate spline --- CHANGELOG.md | 3 + pretab/compose/factory.py | 8 - pretab/compose/registry.py | 15 +- pretab/transformers/numerical/binning.py | 249 ++++++++++++------ pretab/transformers/numerical/periodic.py | 54 +++- .../splines/multivariate/thin_plate.py | 245 +++++++++-------- tests/compose/test_registry_contract.py | 4 +- tests/test_adaptive_output_dim.py | 25 +- tests/test_custombin_transformer.py | 108 +++++++- tests/test_exceptions.py | 8 +- tests/test_output_dimension.py | 4 +- tests/test_param_aliases.py | 2 +- tests/test_periodic.py | 32 ++- tests/test_sklearn_compat.py | 9 +- tests/test_spline_api_parity.py | 8 +- tests/test_thinplate_transformer.py | 59 ++++- 16 files changed, 562 insertions(+), 271 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ed58c0..8aced51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ Going forward, this file is updated automatically by `cz bump` on each release. ### Feat +- **binning**: make `NumericBinningTransformer` a stateful, multi-feature encoder with learned `bin_edges_` and `encode` (`ordinal` / `onehot` / `soft`) plus `placement_strategy` (`uniform` / `quantile`) options +- **transformers**: add `harmonics` and `include_original` options to `PeriodicEncodingTransformer` for multi-harmonic periodic encodings - update default output_dim - unsupervised feature-map default - wire custombin output_dim @@ -70,6 +72,7 @@ Going forward, this file is updated automatically by `cz bump` on each release. ### Refactor +- **splines**: reformulate `ThinPlateSplineTransformer` as a multivariate low-rank thin-plate regression spline (landmark selection + eigen/Nyström basis via `n_components` / `landmark_strategy` / `rank_strategy`, replacing the univariate `output_dim` form) - **transformers**: rename `CustomBinTransformer` → `NumericBinningTransformer`, `CyclicalTimeTransformer` → `PeriodicEncodingTransformer`, and `CubicSplineTransformer` → `CubicRegressionSplineTransformer` (intention-revealing public names) - **transformers**: remove `LagFeatureTransformer` and `RollingStatsTransformer` (row-count-changing time-series utilities outside the tabular scope) - **splines**: restrict `PSplineTransformer` to `placement_strategy="uniform"` (penalized splines require equally-spaced knots) diff --git a/pretab/compose/factory.py b/pretab/compose/factory.py index ced081f..92ba668 100644 --- a/pretab/compose/factory.py +++ b/pretab/compose/factory.py @@ -15,7 +15,6 @@ from sklearn.pipeline import Pipeline from sklearn.preprocessing import MinMaxScaler, StandardScaler -from ..core.parameters import UNSET from ..exceptions import ConfigWarning, IncompatibleParamsError, invalid_param_error from ..transformers.encoders.floats import ToFloatTransformer from .config import PreprocessorConfig @@ -178,7 +177,6 @@ def get_categorical_transformer_steps( imputer_strategy: str = "most_frequent", imputer_kwargs: dict | None = None, add_missing_indicator: bool = False, - output_dim=UNSET, **kwargs, ): """Return the ordered ``(name, transformer)`` steps for a categorical ``method``.""" @@ -214,11 +212,6 @@ def get_categorical_transformer_steps( steps.append(("pretrained", cls())) elif method == "none": steps.append(("none", cls())) - elif method == "custombin": - bin_kwargs = dict(kwargs) - if output_dim is not UNSET: - bin_kwargs.setdefault("output_dim", output_dim) - steps.append(("custombin", cls(**bin_kwargs))) elif method == "onehot_from_ordinal": steps.append(("onehot_from_ordinal", cls())) @@ -273,7 +266,6 @@ def create_transformer(method: str, *, is_numerical: bool, config: PreprocessorC add_imputer=add_imputer, imputer_strategy=config.categorical_imputation or "most_frequent", add_missing_indicator=config.add_missing_indicator, - output_dim=config.output_dim, ) return Pipeline(steps) diff --git a/pretab/compose/registry.py b/pretab/compose/registry.py index d804765..a98ccf5 100644 --- a/pretab/compose/registry.py +++ b/pretab/compose/registry.py @@ -97,7 +97,7 @@ class TransformerSpec: Preprocessor keyword arguments down to what the class understands. feature_kind : frozenset of str The feature kinds the method applies to (``"numerical"`` and/or - ``"categorical"``). ``custombin`` and ``none`` apply to both. + ``"categorical"``). Only ``none`` (passthrough) applies to both. arity : {"univariate", "multivariate"} Whether the method transforms one column at a time (``"univariate"``) or jointly models several columns (``"multivariate"`` -- the tensor-product @@ -191,8 +191,13 @@ def _spec(name, cls, allowed_args=(), **kwargs): placement_strategies=_TARGET_AWARE_STRATEGIES, supports_adaptive_resolution=True, ), - # --- numerical / categorical: binning (no placement) --- - _spec("custombin", NumericBinningTransformer, ("output_dim",), feature_kind=frozenset({NUMERICAL, CATEGORICAL})), + # --- numerical: binning (unsupervised uniform / quantile edge placement) --- + _spec( + "custombin", + NumericBinningTransformer, + ("output_dim", "encode"), + placement_strategies=_UNSUPERVISED_STRATEGIES, + ), # --- numerical: feature maps (optional target-aware, adaptive) --- _spec( "rbf", @@ -250,7 +255,7 @@ def _spec(name, cls, allowed_args=(), **kwargs): _spec( "tprs", ThinPlateSplineTransformer, - ("output_dim",), + ("n_components", "landmark_strategy", "rank_strategy", "random_state"), arity="multivariate", preprocessor_compatible=False, ), @@ -411,8 +416,6 @@ def _squash(name: str) -> str: "embeddings": "pretrained", "language": "pretrained", "llm": "pretrained", - "bin": "custombin", - "binning": "custombin", "passthrough": "none", "identity": "none", "raw": "none", diff --git a/pretab/transformers/numerical/binning.py b/pretab/transformers/numerical/binning.py index 2fb95b3..68aaf94 100644 --- a/pretab/transformers/numerical/binning.py +++ b/pretab/transformers/numerical/binning.py @@ -1,77 +1,141 @@ from typing import ClassVar import numpy as np -import pandas as pd from sklearn.base import BaseEstimator, TransformerMixin +from sklearn.utils.validation import check_is_fitted from ...core.parameters import UNSET, AliasResolverMixin from ...exceptions import InsufficientSamplesError, InvalidParamError, PretabDataError +_VALID_ENCODINGS = ("ordinal", "onehot", "soft") +_VALID_STRATEGIES = ("uniform", "quantile") + class NumericBinningTransformer(AliasResolverMixin, TransformerMixin, BaseEstimator): - """ - Custom binning transformer for one-dimensional numerical features. + """Stateful binning transformer for numerical features. - This transformer bins continuous values into discrete intervals, using either a fixed number of equal-width bins - or a user-provided array of bin edges. It is compatible with scikit-learn pipelines. + The bin edges are learned once in :meth:`fit` and reused at + :meth:`transform` time, so the discretization never leaks information from + the data being transformed. Edges are placed with equal width + (``placement_strategy="uniform"``) or on empirical quantiles + (``placement_strategy="quantile"``); an explicit array of edges can also be + passed through ``output_dim``. Each feature is binned independently. Parameters ---------- output_dim : int or array-like - If int, defines the number of equal-width bins. If array-like, defines - the bin edges to use directly. Note that ``output_dim`` here is the - number of *bins*, not the number of output columns: this transformer - always emits a single ordinal column of integer bin indices. The bin - count only becomes an output width after a subsequent one-hot encoding. + If an int, the number of bins to place from the fitted data range / + quantiles. If array-like, the bin edges to use directly + (``placement_strategy`` is then ignored). ``output_dim`` is the number of + *bins*, not the number of output columns: with ``encode="ordinal"`` each + feature emits a single column, whereas ``encode="onehot"`` / + ``encode="soft"`` emit one column per bin. + encode : {"ordinal", "onehot", "soft"}, default="ordinal" + How to represent the bin assignment: + + * ``"ordinal"`` -- a single integer column of bin indices per feature. + * ``"onehot"`` -- a 0/1 indicator column per bin. + * ``"soft"`` -- triangular membership to the two nearest bin centers; + the per-row weights are non-negative and sum to 1 across the bins. + placement_strategy : {"uniform", "quantile"}, default="uniform" + How to place the learned bin edges when ``output_dim`` is an int: + equal-width (``"uniform"``) or equal-frequency (``"quantile"``). Ignored + when ``output_dim`` is an explicit array of edges. Attributes ---------- n_features_in_ : int - The number of input features seen during ``fit`` (expected to be 1). - + The number of input features seen during :meth:`fit`. + bin_edges_ : list of ndarray + The sorted, de-duplicated bin edges learned per feature. + n_bins_ : list of int + The number of bins per feature (``len(edges) - 1``). total_output_dim_ : int - Total number of output columns (fitted). Always ``1`` because the output - is a single ordinal column. + Total number of output columns. Equal to ``n_features_in_`` for + ``encode="ordinal"``; otherwise the sum of ``n_bins_``. Notes ----- - This transformer operates on a single feature of shape ``(n_samples, 1)``. When - ``output_dim`` is an integer, equal-width bin edges are computed from the data - range; when it is an array-like, the provided edges are used directly. The - output contains integer bin indices in a single column, so its width is ``1`` - regardless of ``output_dim`` -- this is a documented exception to the - exact-width contract that the fixed-basis families follow. - - The input must be numeric: binning is performed with :func:`pandas.cut`, so - string / categorical data cannot be processed and raises a + The input must be numeric: string / categorical data raises a :class:`~pretab.exceptions.PretabDataError`. Encode such columns with a - categorical method (e.g. ``"int"`` or ``"one-hot"``) before binning. + categorical method (e.g. ``"int"`` or ``"one-hot"``) before binning. Values + seen at transform time that fall outside the fitted range are clamped into + the outer bins. Examples -------- >>> import numpy as np >>> from pretab.transformers import NumericBinningTransformer >>> X = np.linspace(0, 1, 10).reshape(-1, 1) - >>> transformer = NumericBinningTransformer(output_dim=4) - >>> transformer.fit_transform(X).shape + >>> NumericBinningTransformer(output_dim=4).fit_transform(X).shape (10, 1) + >>> NumericBinningTransformer(output_dim=4, encode="onehot").fit_transform(X).shape + (10, 4) """ _param_aliases: ClassVar[dict[str, str]] = {} - def __init__(self, output_dim=UNSET): - # An int yields equal-width bins; an array-like is used as bin edges. + def __init__(self, output_dim=UNSET, encode="ordinal", placement_strategy="uniform"): + # An int yields learned bins; an array-like is used as fixed bin edges. self.output_dim = output_dim + self.encode = encode + self.placement_strategy = placement_strategy + + def _check_array(self, X, *, reset): + """Validate ``X`` is a 2D numeric array and (re)set the feature count.""" + X = np.asarray(X) + if X.ndim != 2: + raise PretabDataError("Input must be a 2D array of shape (n_samples, n_features).") + if not np.issubdtype(X.dtype, np.number): + try: + X = X.astype(np.float64) + except (ValueError, TypeError) as exc: + raise PretabDataError( + "NumericBinningTransformer requires numeric input: it bins continuous " + "values into intervals and cannot process string/categorical data. " + "Encode string columns with a categorical method (e.g. 'int' or " + "'one-hot') before binning." + ) from exc + else: + X = X.astype(np.float64, copy=False) + if reset: + self.n_features_in_ = X.shape[1] + elif X.shape[1] != self.n_features_in_: + raise PretabDataError( + f"Input has {X.shape[1]} features, but NumericBinningTransformer " + f"was fitted with {self.n_features_in_}." + ) + return X + + def _resolve_edges(self, column, bins_spec): + """Return the sorted, de-duplicated bin edges for a single feature.""" + if isinstance(bins_spec, (int, np.integer)): + n_bins = int(bins_spec) + if n_bins < 1: + raise InvalidParamError("output_dim must be a positive integer bin count.") + lo = float(np.min(column)) + hi = float(np.max(column)) + if self.placement_strategy == "uniform": + edges = np.linspace(lo, hi, n_bins + 1) + else: # quantile + edges = np.quantile(column, np.linspace(0.0, 1.0, n_bins + 1)) + else: + edges = np.asarray(bins_spec, dtype=np.float64).ravel() + if edges.size < 2: + raise InvalidParamError("Explicit bin edges must contain at least two values.") + edges = np.unique(edges) # sorted + de-duplicated + if edges.size < 2: + # Constant feature (or fully-tied quantiles): fall back to one bin. + edges = np.array([edges[0], edges[0] + 1.0]) + return edges def fit(self, X, y=None): - """ - Fit the transformer on the data. + """Learn the per-feature bin edges. Parameters ---------- - X : array-like of shape (n_samples, 1) + X : array-like of shape (n_samples, n_features) Input data. - y : Ignored Not used, present here for API consistency by convention. @@ -80,64 +144,78 @@ def fit(self, X, y=None): self : object Fitted transformer. """ - # Fit doesn't need to do anything as we are directly using provided bins - X = np.asarray(X) - self.n_features_in_ = X.shape[1] if X.ndim > 1 else 1 - self.total_output_dim_ = 1 + X = self._check_array(X, reset=True) + if X.shape[0] <= 2: + raise InsufficientSamplesError("Input must have more than 2 observations.") + if self.encode not in _VALID_ENCODINGS: + raise InvalidParamError(f"encode must be one of {_VALID_ENCODINGS}; got {self.encode!r}.") + if self.placement_strategy not in _VALID_STRATEGIES: + raise InvalidParamError( + f"placement_strategy must be one of {_VALID_STRATEGIES}; got {self.placement_strategy!r}." + ) + + bins_spec = self._resolve_param("output_dim", default=UNSET) + if bins_spec is UNSET: + raise InvalidParamError("NumericBinningTransformer requires 'output_dim'.") + + self.bin_edges_ = [self._resolve_edges(X[:, j], bins_spec) for j in range(X.shape[1])] + self.n_bins_ = [edges.size - 1 for edges in self.bin_edges_] + self.total_output_dim_ = self.n_features_in_ if self.encode == "ordinal" else int(sum(self.n_bins_)) return self + @staticmethod + def _bin_indices(column, edges): + """Assign each value to a bin using ``(a, b]`` intervals with a closed left edge.""" + idx = np.searchsorted(edges, column, side="left") - 1 + return np.clip(idx, 0, edges.size - 2).astype(int) + + @staticmethod + def _soft_membership(column, edges): + """Return triangular membership weights to the two nearest bin centers.""" + centers = 0.5 * (edges[:-1] + edges[1:]) + n_bins = centers.size + col = np.clip(column, centers[0], centers[-1]) + weights = np.zeros((col.size, n_bins), dtype=np.float64) + if n_bins == 1: + weights[:, 0] = 1.0 + return weights + right = np.clip(np.searchsorted(centers, col, side="left"), 1, n_bins - 1) + left = right - 1 + span = centers[right] - centers[left] + frac = np.where(span > 0, (col - centers[left]) / span, 0.0) + rows = np.arange(col.size) + weights[rows, left] = 1.0 - frac + weights[rows, right] += frac + return weights + def transform(self, X): - """ - Transform the data using the specified binning strategy. + """Bin the data using the edges learned during :meth:`fit`. Parameters ---------- - X : array-like of shape (n_samples, 1) + X : array-like of shape (n_samples, n_features) Input data to transform. Returns ------- - X_binned : ndarray of shape (n_samples, 1) - Binned data with integer bin indices. + X_binned : ndarray of shape (n_samples, total_output_dim_) + The encoded bin assignments. """ - - X = np.asarray(X) # Ensures squeeze works and consistent input - if X.ndim != 2 or X.shape[1] != 1: - raise PretabDataError("Input must be a 2D array with shape (n_samples, 1).") - - if X.shape[0] <= 2: - raise InsufficientSamplesError("Input must have more than 2 observations.") - - if not np.issubdtype(X.dtype, np.number): - try: - X = X.astype(np.float64) - except (ValueError, TypeError) as exc: - raise PretabDataError( - "NumericBinningTransformer requires numeric input: it bins continuous " - "values with pandas.cut and cannot process string/categorical " - "data. Encode string columns with a categorical method (e.g. " - "'int' or 'one-hot') before binning." - ) from exc - - bins_spec = self._resolve_param("output_dim", default=UNSET) - if bins_spec is UNSET: - raise InvalidParamError("NumericBinningTransformer requires 'output_dim'.") - - if isinstance(bins_spec, int): - # Calculate equal width bins based on the range of the data and number of bins - _, bins = pd.cut(X.squeeze(), bins=bins_spec, retbins=True) - else: - # Use predefined bins - bins = bins_spec - - # Apply the bins to the data - binned_data = pd.cut( # type: ignore - X.squeeze(), - bins=np.sort(np.unique(bins)), # type: ignore - labels=False, - include_lowest=True, - ) - return np.expand_dims(np.array(binned_data), 1) + check_is_fitted(self, "bin_edges_") + X = self._check_array(X, reset=False) + blocks = [] + for j in range(X.shape[1]): + edges = self.bin_edges_[j] + n_bins = self.n_bins_[j] + if self.encode == "ordinal": + blocks.append(self._bin_indices(X[:, j], edges).reshape(-1, 1)) + elif self.encode == "onehot": + onehot = np.zeros((X.shape[0], n_bins), dtype=np.float64) + onehot[np.arange(X.shape[0]), self._bin_indices(X[:, j], edges)] = 1.0 + blocks.append(onehot) + else: # soft + blocks.append(self._soft_membership(X[:, j], edges)) + return np.hstack(blocks) def get_feature_names_out(self, input_features=None): """Return the names of the transformed features. @@ -149,9 +227,16 @@ def get_feature_names_out(self, input_features=None): Returns ------- - input_features : ndarray of shape (n_features,) - The names of the output features after transformation. + feature_names : list of str + One name per input feature for ``encode="ordinal"``; otherwise one + ``"{feature}_bin{k}"`` name per bin. """ if input_features is None: raise InvalidParamError("input_features must be specified") - return input_features + if self.encode == "ordinal": + return list(input_features) + check_is_fitted(self, "n_bins_") + names = [] + for feature, n_bins in zip(input_features, self.n_bins_, strict=False): + names.extend(f"{feature}_bin{k}" for k in range(n_bins)) + return names diff --git a/pretab/transformers/numerical/periodic.py b/pretab/transformers/numerical/periodic.py index 9389b0d..689e2fc 100644 --- a/pretab/transformers/numerical/periodic.py +++ b/pretab/transformers/numerical/periodic.py @@ -2,30 +2,42 @@ from sklearn.utils.validation import check_is_fitted from ...core.base import BasePreTabTransformer -from ...exceptions import PretabDataError +from ...exceptions import InvalidParamError, PretabDataError class PeriodicEncodingTransformer(BasePreTabTransformer): - r"""Encode a cyclical time variable using sine and cosine components. + r"""Encode a cyclical variable using sine and cosine harmonics. - Maps a periodic integer feature (such as hour of day or day of week) onto two - continuous features so that the cyclic boundary is continuous. + Maps a periodic feature (such as hour of day or day of week) onto smooth + continuous features so that the cyclic boundary is continuous. Higher + ``harmonics`` add finer-grained sinusoids, and ``include_original`` keeps the + raw value alongside the trigonometric encoding. Parameters ---------- period : int The full cycle length (e.g., 24 for hours, 7 for weekdays). + harmonics : int, default=1 + The number of sine/cosine harmonic pairs to emit. Harmonic ``h`` uses the + angle :math:`2\pi h x / p`, so ``harmonics`` pairs contribute + ``2 * harmonics`` columns per input feature. + include_original : bool, default=False + If ``True``, prepend the (validated) raw value as an extra column per + input feature. Notes ----- - For a value :math:`x` with period :math:`p`, the encoding is + For a value :math:`x` with period :math:`p`, each harmonic :math:`h` maps to .. math:: - \left(\sin\!\left(\frac{2\pi x}{p}\right),\; - \cos\!\left(\frac{2\pi x}{p}\right)\right). + \left(\sin\!\left(\frac{2\pi h x}{p}\right),\; + \cos\!\left(\frac{2\pi h x}{p}\right)\right). - Each input feature therefore expands into two output columns. + Each input feature therefore expands into ``2 * harmonics`` columns, plus one + extra column when ``include_original`` is set. Columns are laid out + per-feature: the optional original value first, then ``(sin, cos)`` pairs in + ascending harmonic order. This is a **standalone time-series utility**. Although it preserves the row count, it takes a required per-feature ``period`` and constrains inputs to @@ -41,16 +53,22 @@ class PeriodicEncodingTransformer(BasePreTabTransformer): >>> transformer = PeriodicEncodingTransformer(period=24) >>> transformer.fit_transform(X).shape (4, 2) + >>> PeriodicEncodingTransformer(period=24, harmonics=3).fit_transform(X).shape + (4, 6) """ _allow_nan = False _feature_suffix_value = "cyclic" - def __init__(self, period: int): + def __init__(self, period: int, harmonics: int = 1, include_original: bool = False): self.period = period + self.harmonics = harmonics + self.include_original = include_original def fit(self, X, y=None): X = self._validate(X, reset=True) + if not isinstance(self.harmonics, (int, np.integer)) or self.harmonics < 1: + raise InvalidParamError(f"harmonics must be a positive integer; got {self.harmonics!r}.") if not np.all((X >= 0) & (X <= self.period)): raise PretabDataError("Input should be within the range [0, period].") return self @@ -58,10 +76,18 @@ def fit(self, X, y=None): def transform(self, X): check_is_fitted(self, "n_features_in_") X = self._validate(X, reset=False) - angle = 2 * np.pi * X / self.period - sin = np.sin(angle) - cos = np.cos(angle) - return np.hstack([sin, cos]) + blocks = [] + for j in range(X.shape[1]): + column = X[:, j : j + 1] + feats = [column] if self.include_original else [] + for harmonic in range(1, self.harmonics + 1): + angle = 2 * np.pi * harmonic * column / self.period + feats.append(np.sin(angle)) + feats.append(np.cos(angle)) + blocks.append(np.hstack(feats)) + return np.hstack(blocks) def _output_sizes(self) -> list[int]: - return [2] * self.n_features_in_ + per_feature = 2 * self.harmonics + (1 if self.include_original else 0) + return [per_feature] * self.n_features_in_ + diff --git a/pretab/transformers/splines/multivariate/thin_plate.py b/pretab/transformers/splines/multivariate/thin_plate.py index d2fe039..b30c6b8 100644 --- a/pretab/transformers/splines/multivariate/thin_plate.py +++ b/pretab/transformers/splines/multivariate/thin_plate.py @@ -2,71 +2,80 @@ from scipy.linalg import eigh from scipy.spatial.distance import cdist from sklearn.base import BaseEstimator, TransformerMixin +from sklearn.cluster import KMeans +from sklearn.utils import check_random_state from sklearn.utils.validation import check_is_fitted -from ....exceptions import InvalidParamError, PretabDataError +from ....exceptions import InsufficientSamplesError, InvalidParamError from ..mixins import SplineBasisMixin +_LANDMARK_STRATEGIES = ("kmeans", "subsample") +_RANK_STRATEGIES = ("eigen", "nystroem") -class ThinPlateSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEstimator): - r""" - Thin Plate Spline Transformer for smooth univariate basis expansion. - This transformer constructs a smooth, nonparametric basis using eigen-decomposed - thin plate spline (TPS) kernels. It supports only univariate input and is useful - for modeling smooth nonlinear functions in regression tasks. The basis functions - are the leading eigenvectors of the projected TPS kernel matrix. +class ThinPlateSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEstimator): + r"""Multivariate low-rank thin-plate regression spline basis. - Let :math:`m := \mathtt{output\_dim}` be the number of non-bias output columns. - The transformer keeps the top :math:`m` eigenvectors, so the output width equals - ``output_dim`` directly (this family is knot-free; there is no knot inversion). - ``include_bias=True`` adds one further intercept column. + Builds a smooth thin-plate spline (TPS) feature map that jointly models all + input features. A set of ``n_components + d + 1`` landmark points is chosen + from the data (``d`` is the number of input features), the null-space + (linear-polynomial) part is projected out of the landmark TPS kernel, and the + leading eigenvectors of the projected kernel form a rank-``n_components`` + basis. This landmark construction follows the low-rank thin-plate regression + spline of Wood (2003) and keeps the cost governed by ``n_components`` rather + than the sample size. Parameters ---------- - output_dim : int, default=6 - Number of non-bias output columns (:math:`m`) extracted from the - eigen-decomposition of the TPS kernel. Must be at least 1. - + n_components : int, default=10 + Number of (non-bias) basis functions to emit -- the rank of the + approximation and the output width. Must be at least 1. Fitting requires + at least ``n_components + d + 1`` samples. + landmark_strategy : {"kmeans", "subsample"}, default="kmeans" + How the landmark points are chosen from the data. ``"kmeans"`` uses + k-means cluster centers (space-filling); ``"subsample"`` draws a random + subset of the observed rows. + rank_strategy : {"eigen", "nystroem"}, default="eigen" + How the reduced basis is extracted from the projected landmark kernel. + ``"eigen"`` keeps the leading (raw) eigenvectors; ``"nystroem"`` whitens + them by the inverse square-root of the eigenvalues to decorrelate the + features. Both emit exactly ``n_components`` columns. include_bias : bool, default=False If True, prepend a constant intercept column to the output. The bias term - is left unpenalized (a zero row/column is added to the penalty matrix). + is left unpenalized (a zero leading row/column is added to the penalty). + random_state : int, RandomState instance or None, default=None + Seeds the landmark selection (k-means initialization or subsampling). Attributes ---------- - x_ : ndarray of shape (n_samples, 1) - Training input used to compute the TPS kernel and projection matrix. - - Z_ : ndarray of shape (n_samples, 2) - Matrix containing intercept and linear term (used for null space projection). - - eigvals_ : ndarray of shape (output_dim,) - Top eigenvalues from the projected kernel matrix. - - basis_ : ndarray of shape (n_samples, output_dim) - Orthogonal basis functions corresponding to the top eigenvectors. - - penalty_ : ndarray of shape (output_dim, output_dim) - Diagonal penalty matrix containing eigenvalues (used for smoothing regularization). - + landmarks_ : ndarray of shape (n_landmarks, n_features_in_) + The landmark points used to build the TPS kernel. + components_ : ndarray of shape (n_landmarks, n_components) + The linear map from a data-to-landmark kernel row to the reduced basis. + eigvals_ : ndarray of shape (n_components,) + The retained eigenvalues of the projected landmark kernel. + penalty_ : ndarray + Diagonal smoothing penalty of ``eigvals_`` (with an unpenalized leading + row/column when ``include_bias=True``). + d_ : int + Number of input features (also ``n_features_in_``). n_basis_ : list of int - Number of output columns for the single feature, including the optional bias. - + Single-element list with the output width (``n_components`` plus the + optional bias). n_features_in_ : int - Number of input features seen during ``fit`` (always 1). - + Number of input features seen during ``fit``. total_output_dim_ : int - Total number of output columns (fitted); equals - ``output_dim (+1 if include_bias)``. + Total number of output columns (fitted); equals ``n_components`` + (``+1`` when ``include_bias``). Notes ----- - - Input must be univariate. Multivariate input will raise a ValueError. - - Basis functions are derived from a kernel matrix projected onto the orthogonal complement of the null space - of the linear terms (intercept and slope) [1]_. - - The transformer uses an eigendecomposition of the projected TPS kernel to define the basis [2]_. - - The transformer is kernel-based rather than knot-based, so the knot-oriented options shared by the other - splines (``degree``, ``target_aware``, ``placement_strategy``, ``task``) do not apply here. + - Unlike the knot-based spline families, the thin-plate basis is kernel-based: + the knot-oriented options (``degree``, ``target_aware``, + ``placement_strategy``, ``task``) do not apply. + - The radial kernel depends on the input dimension: :math:`r^3` for ``d=1``, + :math:`r^2\log r` for ``d=2``, and the biharmonic kernel :math:`r` for + ``d>=3``. References ---------- @@ -78,82 +87,110 @@ class ThinPlateSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEstimat -------- >>> import numpy as np >>> from pretab.transformers import ThinPlateSplineTransformer - >>> X = np.linspace(0, 1, 30).reshape(-1, 1) - >>> transformer = ThinPlateSplineTransformer(output_dim=6) - >>> Xt = transformer.fit_transform(X) - >>> Xt.shape - (30, 6) + >>> X = np.random.default_rng(0).uniform(size=(60, 2)) + >>> transformer = ThinPlateSplineTransformer(n_components=6, random_state=0) + >>> transformer.fit_transform(X).shape + (60, 6) >>> transformer.total_output_dim_ 6 """ _feature_suffix_value = "tps" - def __init__(self, output_dim=6, include_bias=False): - self.output_dim = output_dim + def __init__( + self, + n_components=10, + landmark_strategy="kmeans", + rank_strategy="eigen", + include_bias=False, + random_state=None, + ): + self.n_components = n_components + self.landmark_strategy = landmark_strategy + self.rank_strategy = rank_strategy self.include_bias = include_bias + self.random_state = random_state - def _tps_kernel(self, r): + @staticmethod + def _tps_kernel(r, d): + """Return the thin-plate radial kernel for input dimension ``d``.""" with np.errstate(divide="ignore", invalid="ignore"): - log_r = np.where(r == 0, 0, np.log(r)) - K = r**2 * log_r - K[r == 0] = 0 - return K + 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) + # d >= 3: biharmonic (linear) radial kernel. + return r + + def _select_landmarks(self, X, n_landmarks, rng): + """Choose ``n_landmarks`` landmark points from ``X``.""" + n = X.shape[0] + if n_landmarks >= n: + return X + if self.landmark_strategy == "kmeans": + return KMeans(n_clusters=n_landmarks, random_state=rng, n_init=10).fit(X).cluster_centers_ + idx = rng.choice(n, size=n_landmarks, replace=False) + return X[idx] def fit(self, X, y=None): X = self._validate_allow_nan(X, reset=True) - if X.shape[1] > 1: - raise PretabDataError("ThinPlateSplineTransformer supports only univariate input.") - - if self.output_dim < 1: - raise InvalidParamError(f"output_dim must be >= 1, got {self.output_dim}") - - x = X.reshape(-1, 1) - self.x_ = x - n = x.shape[0] - - Z = np.hstack([np.ones_like(x), x]) - self.Z_ = Z - - r = cdist(x, x, metric="euclidean") - K = self._tps_kernel(r) - - ZTZ_inv = np.linalg.pinv(Z.T @ Z) - P = np.eye(n) - Z @ ZTZ_inv @ Z.T - KP = P @ K @ P - - eigvals, eigvecs = eigh(KP) - idx = np.argsort(eigvals)[::-1] - eigvals = eigvals[idx] - eigvecs = eigvecs[:, idx] - - self.eigvals_ = eigvals[: self.output_dim] - self.basis_ = eigvecs[:, : self.output_dim] * np.sqrt(n) - penalty = np.diag(self.eigvals_) + if not isinstance(self.n_components, (int, np.integer)) or self.n_components < 1: + raise InvalidParamError(f"n_components must be a positive integer; got {self.n_components!r}.") + if self.landmark_strategy not in _LANDMARK_STRATEGIES: + raise InvalidParamError( + f"landmark_strategy must be one of {_LANDMARK_STRATEGIES}; got {self.landmark_strategy!r}." + ) + if self.rank_strategy not in _RANK_STRATEGIES: + raise InvalidParamError(f"rank_strategy must be one of {_RANK_STRATEGIES}; got {self.rank_strategy!r}.") + + n, d = X.shape + n_landmarks = self.n_components + d + 1 + if n < n_landmarks: + raise InsufficientSamplesError( + f"ThinPlateSplineTransformer with n_components={self.n_components} on {d} feature(s) " + f"needs at least {n_landmarks} samples; got {n}." + ) + + rng = check_random_state(self.random_state) + C = np.asarray(self._select_landmarks(X, n_landmarks, rng), dtype=float) + length = C.shape[0] + self.landmarks_ = C + + # Project out the linear-polynomial null space on the landmarks. + T = np.hstack([np.ones((length, 1)), C]) + P = np.eye(length) - T @ np.linalg.pinv(T.T @ T) @ T.T + + K = self._tps_kernel(cdist(C, C), d) + K_proj = P @ K @ P + K_proj = 0.5 * (K_proj + K_proj.T) # symmetrize against round-off + + eigvals, eigvecs = eigh(K_proj) + order = np.argsort(np.abs(eigvals))[::-1][: self.n_components] + eigvals = eigvals[order] + eigvecs = eigvecs[:, order] + self.eigvals_ = eigvals + + if self.rank_strategy == "nystroem": + scale = 1.0 / np.sqrt(np.clip(np.abs(eigvals), 1e-12, None)) + else: # eigen + scale = np.full(self.n_components, np.sqrt(length)) + # ``components_`` maps a raw data->landmark kernel row into the basis. + self.components_ = P @ (eigvecs * scale) + + penalty = np.diag(eigvals) if self.include_bias: penalty = np.pad(penalty, ((1, 0), (1, 0))) self.penalty_ = penalty - self.n_basis_ = [self.basis_.shape[1] + (1 if self.include_bias else 0)] - + self.d_ = d + self.n_basis_ = [self.n_components + (1 if self.include_bias else 0)] return self def transform(self, X): - check_is_fitted(self, "basis_") + check_is_fitted(self, "components_") X = self._validate_allow_nan(X, reset=False) - if X.shape[1] > 1: - raise PretabDataError("ThinPlateSplineTransformer supports only univariate input.") - - x_new = X.reshape(-1, 1) - r_new = cdist(x_new, self.x_, metric="euclidean") - K_new = self._tps_kernel(r_new) - - Z = self.Z_ - ZTZ_inv = np.linalg.pinv(Z.T @ Z) - P_new = np.eye(Z.shape[0]) - Z @ ZTZ_inv @ Z.T - K_new_proj = K_new @ P_new - - out = K_new_proj @ self.basis_ + K_new = self._tps_kernel(cdist(X, self.landmarks_), self.d_) + out = K_new @ self.components_ if self.include_bias: out = np.hstack([np.ones((out.shape[0], 1)), out]) return out @@ -165,13 +202,13 @@ def get_penalty_matrix(self, feature_index=0): ---------- feature_index : int, default=0 Accepted for signature parity with the other spline transformers; - ignored because the thin-plate transformer is univariate. + ignored because the thin-plate basis is a single joint expansion. Returns ------- - penalty_ : ndarray of shape (output_dim, output_dim) - Diagonal penalty matrix of eigenvalues used for regularization (with - an unpenalized leading row/column when ``include_bias=True``). + penalty_ : ndarray + Diagonal penalty of the retained eigenvalues (with an unpenalized + leading row/column when ``include_bias=True``). """ check_is_fitted(self, "penalty_") return self.penalty_ diff --git a/tests/compose/test_registry_contract.py b/tests/compose/test_registry_contract.py index 8a7ce50..cb86666 100644 --- a/tests/compose/test_registry_contract.py +++ b/tests/compose/test_registry_contract.py @@ -133,9 +133,9 @@ def test_optional_dependency_methods_fail_cleanly(name, spec): def test_registry_covers_numerical_and_categorical_names(): assert numerical_method_names() | categorical_method_names() == set(TRANSFORMER_REGISTRY) - # ``custombin`` and ``none`` are the only dual-kind methods. + # ``none`` (passthrough) is the only remaining dual-kind method. dual = numerical_method_names() & categorical_method_names() - assert dual == {"custombin", "none"} + assert dual == {"none"} # --------------------------------------------------------------------------- # diff --git a/tests/test_adaptive_output_dim.py b/tests/test_adaptive_output_dim.py index bff87ed..e496f69 100644 --- a/tests/test_adaptive_output_dim.py +++ b/tests/test_adaptive_output_dim.py @@ -9,10 +9,8 @@ * **Adaptive mode** (``adaptive=True``) -- width must be data-driven inside ``[min_output_dim, max_output_dim]`` for the adaptive-capable families. -They also cover the categorical ``custombin`` path: the ``Preprocessor`` now -forwards ``output_dim`` to it, so a numeric-categorical column bins end to end, -while string categoricals raise a clear ``PretabDataError`` (``custombin`` is -numeric-only). +``custombin`` is numeric-only: it is selectable as a numerical method (and +forwards ``output_dim``), while selecting it as a categorical method is rejected. """ from typing import cast @@ -21,7 +19,7 @@ import pandas as pd import pytest -from pretab.exceptions import PretabDataError +from pretab.exceptions import InvalidParamError from pretab.preprocessor import Preprocessor from pretab.transformers.splines.b_spline import BSplineTransformer from pretab.transformers.splines.i_spline import ISplineTransformer @@ -318,21 +316,12 @@ def test_fixed_only_spline_ignores_adaptive(data, method): # --------------------------------------------------------------------------- # -# Categorical custombin: numeric-only, wired through the Preprocessor. +# Categorical custombin: numeric-only, no longer selectable on the categorical side. # --------------------------------------------------------------------------- # -def test_categorical_custombin_via_preprocessor(): - """A numeric, low-cardinality column routes to custombin and bins end to end.""" +def test_categorical_custombin_no_longer_selectable(): + """custombin is numeric-only: selecting it as a categorical method is rejected.""" # Integer codes with few unique values -> detected as categorical (ratio < cat_cutoff). Xcat = pd.DataFrame({"g": np.array([0, 1, 2, 3, 4, 5] * 50)}) pre = Preprocessor(numerical_method="none", categorical_method="custombin", output_dim=4) - out = pre.fit_transform(Xcat, return_array=True) - # custombin always emits a single ordinal column. - assert out.shape == (Xcat.shape[0], 1) - - -def test_categorical_custombin_rejects_string_input(): - """custombin is numeric-only: string categoricals raise a clear error.""" - Xcat = pd.DataFrame({"g": np.array(["a", "b", "c"] * 100)}) - pre = Preprocessor(numerical_method="none", categorical_method="custombin", output_dim=4) - with pytest.raises(PretabDataError): + with pytest.raises(InvalidParamError): pre.fit_transform(Xcat) diff --git a/tests/test_custombin_transformer.py b/tests/test_custombin_transformer.py index a9de739..9210260 100644 --- a/tests/test_custombin_transformer.py +++ b/tests/test_custombin_transformer.py @@ -3,7 +3,7 @@ import pytest from sklearn.base import BaseEstimator, TransformerMixin -from pretab.exceptions import InsufficientSamplesError, PretabDataError +from pretab.exceptions import InsufficientSamplesError, InvalidParamError, PretabDataError from pretab.transformers import NumericBinningTransformer @@ -13,10 +13,11 @@ def test_custom_bin_transformer_basic_functionality(bins): transformer = NumericBinningTransformer(output_dim=bins) transformer.fit(X) - # Ensure fitted attribute exists + # Ensure fitted attributes exist assert hasattr(transformer, "n_features_in_") assert transformer.n_features_in_ == 1 assert transformer.total_output_dim_ == 1 + assert len(transformer.bin_edges_) == 1 # Transform Xt = transformer.transform(X) @@ -50,30 +51,119 @@ def test_custom_bin_transformer_input_types(bins, input_type): assert Xt.shape == (4, 1) +def test_custom_bin_transformer_is_stateful(): + """Edges are learned at fit time and reused on shifted transform data.""" + X_train = np.linspace(0.0, 1.0, 20).reshape(-1, 1) + transformer = NumericBinningTransformer(output_dim=4).fit(X_train) + learned = transformer.bin_edges_[0].copy() + + # Values outside the fitted range are clamped into the outer bins, and the + # learned edges do not change when transforming different data. + X_test = np.array([[-5.0], [0.25], [0.75], [5.0]]) + Xt = transformer.transform(X_test) + np.testing.assert_array_equal(transformer.bin_edges_[0], learned) + assert Xt[0, 0] == 0 # below the fitted minimum -> first bin + assert Xt[-1, 0] == transformer.n_bins_[0] - 1 # above the maximum -> last bin + + +def test_custom_bin_transformer_quantile_placement(): + """Quantile placement puts edges on the empirical distribution.""" + rng = np.random.default_rng(0) + X = rng.exponential(1.0, size=200).reshape(-1, 1) + uniform = NumericBinningTransformer(output_dim=4, placement_strategy="uniform").fit(X) + quantile = NumericBinningTransformer(output_dim=4, placement_strategy="quantile").fit(X) + + # The two strategies must produce different edges for skewed data. + assert not np.allclose(uniform.bin_edges_[0], quantile.bin_edges_[0]) + + # Quantile bins are all occupied for a well-spread sample. + counts = np.bincount(quantile.transform(X).ravel(), minlength=4) + assert counts.min() > 0 + + +def test_custom_bin_transformer_onehot_encoding(): + X = np.linspace(0.0, 1.0, 20).reshape(-1, 1) + transformer = NumericBinningTransformer(output_dim=4, encode="onehot").fit(X) + assert transformer.total_output_dim_ == 4 + + Xt = transformer.transform(X) + assert Xt.shape == (20, 4) + # Exactly one active bin per row. + np.testing.assert_array_equal(Xt.sum(axis=1), np.ones(20)) + assert set(np.unique(Xt)) <= {0.0, 1.0} + + +def test_custom_bin_transformer_soft_encoding(): + X = np.linspace(0.0, 1.0, 20).reshape(-1, 1) + transformer = NumericBinningTransformer(output_dim=5, encode="soft").fit(X) + assert transformer.total_output_dim_ == 5 + + Xt = transformer.transform(X) + assert Xt.shape == (20, 5) + # Weights are non-negative and sum to 1 for every row. + assert Xt.min() >= 0.0 + np.testing.assert_allclose(Xt.sum(axis=1), np.ones(20)) + + +def test_custom_bin_transformer_multifeature(): + X = np.column_stack([np.linspace(0.0, 1.0, 30), np.linspace(-5.0, 5.0, 30)]) + transformer = NumericBinningTransformer(output_dim=3, encode="onehot").fit(X) + assert transformer.n_features_in_ == 2 + assert transformer.n_bins_ == [3, 3] + assert transformer.total_output_dim_ == 6 + assert transformer.transform(X).shape == (30, 6) + + +def test_custom_bin_transformer_invalid_encode(): + X = np.linspace(0.0, 1.0, 10).reshape(-1, 1) + with pytest.raises(InvalidParamError): + NumericBinningTransformer(output_dim=3, encode="bogus").fit(X) + + +def test_custom_bin_transformer_invalid_placement(): + X = np.linspace(0.0, 1.0, 10).reshape(-1, 1) + with pytest.raises(InvalidParamError): + NumericBinningTransformer(output_dim=3, placement_strategy="cart").fit(X) + + +def test_custom_bin_transformer_missing_output_dim(): + X = np.linspace(0.0, 1.0, 10).reshape(-1, 1) + with pytest.raises(InvalidParamError): + NumericBinningTransformer().fit(X) + + def test_custom_bin_transformer_invalid_input(): transformer = NumericBinningTransformer(output_dim=3) + transformer.fit(np.linspace(0.0, 1.0, 10).reshape(-1, 1)) with pytest.raises(PretabDataError): transformer.transform("invalid_input") -def test_custom_bin_transformer_raises_on_invalid_shape(): +def test_custom_bin_transformer_raises_on_insufficient_samples(): transformer = NumericBinningTransformer(output_dim=3) - X = np.array([[0.1]]) # This will become scalar after squeeze() + X = np.array([[0.1]]) # Not enough observations to bin. with pytest.raises(ValueError, match=r"Input must have more than 2 observations."): - transformer.transform(X) + transformer.fit(X) -def test_custom_bin_transformer_invalid_bins_type(): +def test_custom_bin_transformer_insufficient_samples_via_fit_transform(): with pytest.raises(InsufficientSamplesError): NumericBinningTransformer(output_dim="not_valid").fit_transform(np.array([[0.1]])) -def test_custom_bin_transformer_feature_names_out(): +def test_custom_bin_transformer_feature_names_out_ordinal(): transformer = NumericBinningTransformer(output_dim=3) - transformer.fit(np.array([[0.2]])) + transformer.fit(np.linspace(0.0, 1.0, 10).reshape(-1, 1)) + names = transformer.get_feature_names_out(["feature1"]) + assert list(names) == ["feature1"] + + +def test_custom_bin_transformer_feature_names_out_onehot(): + transformer = NumericBinningTransformer(output_dim=3, encode="onehot") + transformer.fit(np.linspace(0.0, 1.0, 10).reshape(-1, 1)) names = transformer.get_feature_names_out(["feature1"]) - assert names == ["feature1"] + assert list(names) == ["feature1_bin0", "feature1_bin1", "feature1_bin2"] def test_custom_bin_transformer_feature_names_out_raises(): diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 70b36af..d0d0be0 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -190,10 +190,10 @@ def test_ple_nan_input_is_value_error(xy): PLETransformer(output_dim=5).fit(X_nan, y) -def test_thinplate_multivariate_is_data_error(): - X = np.random.RandomState(1).rand(30, 2) - with pytest.raises(PretabDataError, match="univariate"): - ThinPlateSplineTransformer(output_dim=3).fit(X) +def test_thinplate_insufficient_samples_is_data_error(): + X = np.random.RandomState(1).rand(6, 2) + with pytest.raises(InsufficientSamplesError, match="needs at least"): + ThinPlateSplineTransformer(n_components=10).fit(X) # --------------------------------------------------------------------------- # diff --git a/tests/test_output_dimension.py b/tests/test_output_dimension.py index 3e902bf..e705865 100644 --- a/tests/test_output_dimension.py +++ b/tests/test_output_dimension.py @@ -82,10 +82,10 @@ def test_feature_map_width_is_n_features_times_output_dim(cls, X): def test_thinplate_width_is_output_dim(): - # Thin-plate regression splines are univariate. + # Thin-plate regression splines emit exactly ``n_components`` columns. rng = np.random.RandomState(0) X = rng.uniform(-3, 3, size=(120, 1)) - transformer = ThinPlateSplineTransformer(output_dim=OUTPUT_DIM).fit(X) + transformer = ThinPlateSplineTransformer(n_components=OUTPUT_DIM, random_state=0).fit(X) Xt = transformer.transform(X) assert Xt.shape[1] == OUTPUT_DIM assert transformer.total_output_dim_ == Xt.shape[1] diff --git a/tests/test_param_aliases.py b/tests/test_param_aliases.py index 1231a94..5f270f7 100644 --- a/tests/test_param_aliases.py +++ b/tests/test_param_aliases.py @@ -60,6 +60,7 @@ def Xy(): (ISplineTransformer, "n_basis"), (ISplineTransformer, "n_basis_functions"), (ThinPlateSplineTransformer, "n_basis"), + (ThinPlateSplineTransformer, "output_dim"), (RBFExpansionTransformer, "n_centers"), (RBFExpansionTransformer, "n_basis"), (ReLUExpansionTransformer, "n_centers"), @@ -91,7 +92,6 @@ def test_removed_count_name_raises_typeerror(cls, removed): (BSplineTransformer, 8), (MSplineTransformer, 8), (ISplineTransformer, 8), - (ThinPlateSplineTransformer, 6), ] diff --git a/tests/test_periodic.py b/tests/test_periodic.py index 642c331..333f538 100644 --- a/tests/test_periodic.py +++ b/tests/test_periodic.py @@ -11,7 +11,7 @@ import numpy as np import pytest -from pretab.exceptions import PretabDataError +from pretab.exceptions import InvalidParamError, PretabDataError from pretab.transformers import PeriodicEncodingTransformer @@ -42,3 +42,33 @@ def test_cyclic_feature_names(): X = np.array([[0], [6], [12], [18]]) transformer = PeriodicEncodingTransformer(period=24).fit(X) np.testing.assert_array_equal(transformer.get_feature_names_out(["hour"]), ["hour_cyclic0", "hour_cyclic1"]) + + +def test_cyclic_harmonics_expand_columns(): + X = np.array([[0], [6], [12], [18]]) + transformer = PeriodicEncodingTransformer(period=24, harmonics=3).fit(X) + out = transformer.transform(X) + # 3 harmonic pairs -> 6 columns; layout is (sin_h, cos_h) in ascending order. + assert out.shape == (4, 6) + assert transformer.total_output_dim_ == 6 + for h in range(1, 4): + angle = 2 * np.pi * h * X.ravel() / 24 + np.testing.assert_allclose(out[:, 2 * (h - 1)], np.sin(angle), atol=1e-12) + np.testing.assert_allclose(out[:, 2 * (h - 1) + 1], np.cos(angle), atol=1e-12) + + +def test_cyclic_include_original_prepends_raw_value(): + X = np.array([[0], [6], [12], [18]]) + transformer = PeriodicEncodingTransformer(period=24, harmonics=2, include_original=True).fit(X) + out = transformer.transform(X) + # 1 original + 2 harmonic pairs = 5 columns per feature. + assert out.shape == (4, 5) + assert transformer.total_output_dim_ == 5 + np.testing.assert_allclose(out[:, 0], X.ravel(), atol=1e-12) + + +def test_cyclic_rejects_non_positive_harmonics(): + X = np.array([[0], [6], [12], [18]]) + with pytest.raises(InvalidParamError): + PeriodicEncodingTransformer(period=24, harmonics=0).fit(X) + diff --git a/tests/test_sklearn_compat.py b/tests/test_sklearn_compat.py index ccd328d..cd18ae1 100644 --- a/tests/test_sklearn_compat.py +++ b/tests/test_sklearn_compat.py @@ -123,7 +123,9 @@ def test_check_estimator_near_conformant(estimator, expected_failed_checks): ThinPlateSplineTransformer(), id="ThinPlateSplineTransformer", marks=pytest.mark.xfail( - reason="Univariate-only (single input feature); incompatible with the multi-feature transformer checks.", + reason="Landmark low-rank basis needs at least n_components + d + 1 " + "samples; the generic small-sample estimator checks fall below that " + "threshold and fail at fit.", strict=True, ), ), @@ -131,8 +133,9 @@ def test_check_estimator_near_conformant(estimator, expected_failed_checks): NumericBinningTransformer(), id="NumericBinningTransformer", marks=pytest.mark.xfail( - reason="Single-column ordinal binner; expects (n_samples, 1) input, " - "incompatible with generic multi-feature checks.", + reason="Requires an explicit `output_dim` bin count (not default-" + "constructible into a fittable state), so the generic estimator " + "checks fail at fit.", strict=True, ), ), diff --git a/tests/test_spline_api_parity.py b/tests/test_spline_api_parity.py index 48c52c0..86f8e75 100644 --- a/tests/test_spline_api_parity.py +++ b/tests/test_spline_api_parity.py @@ -110,8 +110,8 @@ def test_tensor_include_bias_widens_interaction(X_uniform): def test_thinplate_include_bias_adds_one_column(): X = np.linspace(0, 1, 40).reshape(-1, 1) - no_bias = ThinPlateSplineTransformer(output_dim=6).fit_transform(X) - with_bias = ThinPlateSplineTransformer(output_dim=6, include_bias=True).fit_transform(X) + no_bias = ThinPlateSplineTransformer(n_components=6, random_state=0).fit_transform(X) + with_bias = ThinPlateSplineTransformer(n_components=6, include_bias=True, random_state=0).fit_transform(X) assert with_bias.shape[1] == no_bias.shape[1] + 1 assert np.allclose(with_bias[:, 0], 1.0) @@ -123,7 +123,7 @@ def test_thinplate_include_bias_adds_one_column(): (NaturalCubicSplineTransformer, {"degree", "target_aware", "placement_strategy", "task"}), (PSplineTransformer, {"placement_strategy", "include_bias"}), (TensorProductSplineTransformer, {"placement_strategy", "include_bias"}), - (ThinPlateSplineTransformer, {"include_bias"}), + (ThinPlateSplineTransformer, {"n_components", "landmark_strategy", "rank_strategy", "include_bias"}), ], ) def test_new_params_exposed_in_get_params(cls, expected): @@ -141,7 +141,7 @@ def test_tensor_penalty_matrix_signature_parity(): def test_thinplate_penalty_matrix_accepts_feature_index(): X = np.linspace(0, 1, 40).reshape(-1, 1) - transformer = ThinPlateSplineTransformer(output_dim=6, include_bias=True).fit(X) + transformer = ThinPlateSplineTransformer(n_components=6, include_bias=True, random_state=0).fit(X) P = transformer.get_penalty_matrix(feature_index=0) assert P.shape == (7, 7) assert np.allclose(P[0, :], 0.0) and np.allclose(P[:, 0], 0.0) diff --git a/tests/test_thinplate_transformer.py b/tests/test_thinplate_transformer.py index 6875635..8b2eb67 100644 --- a/tests/test_thinplate_transformer.py +++ b/tests/test_thinplate_transformer.py @@ -2,12 +2,13 @@ import pytest from sklearn.exceptions import NotFittedError +from pretab.exceptions import InsufficientSamplesError, InvalidParamError from pretab.transformers import ThinPlateSplineTransformer def test_tprs_output_shape_and_values(): X = np.linspace(0, 1, 30).reshape(-1, 1) - transformer = ThinPlateSplineTransformer(output_dim=6) + transformer = ThinPlateSplineTransformer(n_components=6, random_state=0) Xt = transformer.fit_transform(X) assert Xt.shape == (30, 6) @@ -17,7 +18,7 @@ def test_tprs_output_shape_and_values(): def test_tprs_output_consistency(): X = np.random.rand(20, 1) - transformer = ThinPlateSplineTransformer(output_dim=5) + transformer = ThinPlateSplineTransformer(n_components=5, random_state=0) transformer.fit(X) Xt1 = transformer.transform(X) Xt2 = transformer.fit_transform(X) @@ -27,7 +28,7 @@ def test_tprs_output_consistency(): def test_tprs_penalty_shape_and_symmetry(): X = np.random.rand(25, 1) - transformer = ThinPlateSplineTransformer(output_dim=7) + transformer = ThinPlateSplineTransformer(n_components=7, random_state=0) transformer.fit(X) P = transformer.get_penalty_matrix() @@ -35,21 +36,53 @@ def test_tprs_penalty_shape_and_symmetry(): assert np.allclose(P, P.T, atol=1e-6) -def test_tprs_multivariate_error(): - X = np.random.rand(10, 2) - transformer = ThinPlateSplineTransformer(output_dim=4) - with pytest.raises(ValueError, match="univariate"): - transformer.fit(X) +def test_tprs_multivariate_is_supported(): + rng = np.random.RandomState(0) + X = rng.uniform(size=(60, 3)) + transformer = ThinPlateSplineTransformer(n_components=5, random_state=0) + Xt = transformer.fit_transform(X) + + assert Xt.shape == (60, 5) + assert transformer.n_features_in_ == 3 + assert np.isfinite(Xt).all() - transformer = ThinPlateSplineTransformer(output_dim=4) - transformer.fit(np.random.rand(10, 1)) + +def test_tprs_feature_count_mismatch_raises(): + rng = np.random.RandomState(0) + transformer = ThinPlateSplineTransformer(n_components=4, random_state=0) + transformer.fit(rng.uniform(size=(40, 1))) with pytest.raises(ValueError, match="is expecting 1 features"): - transformer.transform(X) + transformer.transform(rng.uniform(size=(10, 2))) + + +def test_tprs_insufficient_samples_raises(): + X = np.random.rand(5, 2) + transformer = ThinPlateSplineTransformer(n_components=10, random_state=0) + with pytest.raises(InsufficientSamplesError, match="needs at least"): + transformer.fit(X) + + +def test_tprs_rejects_invalid_strategies(): + X = np.random.rand(40, 1) + with pytest.raises(InvalidParamError, match="landmark_strategy"): + ThinPlateSplineTransformer(n_components=4, landmark_strategy="bogus").fit(X) + with pytest.raises(InvalidParamError, match="rank_strategy"): + ThinPlateSplineTransformer(n_components=4, rank_strategy="bogus").fit(X) + + +def test_tprs_nystroem_rank_strategy(): + rng = np.random.RandomState(0) + X = rng.uniform(size=(50, 2)) + transformer = ThinPlateSplineTransformer(n_components=6, rank_strategy="nystroem", random_state=0) + Xt = transformer.fit_transform(X) + + assert Xt.shape == (50, 6) + assert np.isfinite(Xt).all() def test_tprs_feature_names_out(): X = np.random.rand(20, 1) - transformer = ThinPlateSplineTransformer(output_dim=6) + transformer = ThinPlateSplineTransformer(n_components=6, random_state=0) Xt = transformer.fit_transform(X) names = transformer.get_feature_names_out(["a"]) @@ -60,7 +93,7 @@ def test_tprs_feature_names_out(): def test_tprs_feature_names_out_default_input(): X = np.random.rand(15, 1) - transformer = ThinPlateSplineTransformer(output_dim=5).fit(X) + transformer = ThinPlateSplineTransformer(n_components=5, random_state=0).fit(X) names = transformer.get_feature_names_out() assert len(names) == transformer.n_basis_[0] From e1fa0430079b8f4c636fcad65e6c23b4dcc1aed3 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 26 Jul 2026 16:53:52 +0200 Subject: [PATCH 11/59] feat(transformers): add Fourier, random-Fourier, and Nystroem feature maps --- CHANGELOG.md | 2 + pretab/compose/registry.py | 31 ++++ pretab/transformers/__init__.py | 6 + pretab/transformers/numerical/__init__.py | 12 +- pretab/transformers/numerical/fourier.py | 134 +++++++++++++++ .../transformers/numerical/kernel_approx.py | 162 ++++++++++++++++++ tests/test_adaptive_output_dim.py | 2 + tests/test_fourier_transformer.py | 77 +++++++++ tests/test_kernel_approx_transformer.py | 101 +++++++++++ 9 files changed, 524 insertions(+), 3 deletions(-) create mode 100644 pretab/transformers/numerical/fourier.py create mode 100644 pretab/transformers/numerical/kernel_approx.py create mode 100644 tests/test_fourier_transformer.py create mode 100644 tests/test_kernel_approx_transformer.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8aced51..86162c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ Going forward, this file is updated automatically by `cz bump` on each release. ### Feat +- **transformers**: add `FourierFeatureTransformer` (deterministic sine/cosine feature map with `harmonic` / `log_spaced` / `random` frequencies), selectable as the `"fourier"` numerical method +- **transformers**: add `RandomFourierFeaturesTransformer` and `NystroemFeaturesTransformer` — standalone multivariate kernel-approximation feature maps (`"rff"` / `"nystroem"`) - **binning**: make `NumericBinningTransformer` a stateful, multi-feature encoder with learned `bin_edges_` and `encode` (`ordinal` / `onehot` / `soft`) plus `placement_strategy` (`uniform` / `quantile`) options - **transformers**: add `harmonics` and `include_original` options to `PeriodicEncodingTransformer` for multi-harmonic periodic encodings - update default output_dim diff --git a/pretab/compose/registry.py b/pretab/compose/registry.py index a98ccf5..05faf99 100644 --- a/pretab/compose/registry.py +++ b/pretab/compose/registry.py @@ -38,6 +38,11 @@ from ..transformers.feature_maps.sigmoid import SigmoidExpansionTransformer from ..transformers.feature_maps.tanh import TanhExpansionTransformer from ..transformers.numerical.binning import NumericBinningTransformer +from ..transformers.numerical.fourier import FourierFeatureTransformer +from ..transformers.numerical.kernel_approx import ( + NystroemFeaturesTransformer, + RandomFourierFeaturesTransformer, +) from ..transformers.numerical.piecewise import PLETransformer from ..transformers.splines.b_spline import BSplineTransformer from ..transformers.splines.cubic_regression import CubicRegressionSplineTransformer @@ -223,6 +228,12 @@ def _spec(name, cls, allowed_args=(), **kwargs): ("output_dim", "scale", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state"), **_BOTH_MODE, ), + # --- numerical: deterministic Fourier feature map (univariate, unsupervised) --- + _spec( + "fourier", + FourierFeatureTransformer, + ("n_frequencies", "frequency_strategy", "include_original", "random_state"), + ), # --- numerical: freely-placed knot splines (optional target-aware, adaptive) --- _spec( "cubicspline", @@ -259,6 +270,21 @@ def _spec(name, cls, allowed_args=(), **kwargs): arity="multivariate", preprocessor_compatible=False, ), + # --- numerical: kernel-approximation feature maps (multivariate, standalone) --- + _spec( + "rff", + RandomFourierFeaturesTransformer, + ("n_components", "gamma", "random_state"), + arity="multivariate", + preprocessor_compatible=False, + ), + _spec( + "nystroem", + NystroemFeaturesTransformer, + ("n_components", "kernel", "gamma", "degree", "coef0", "random_state"), + arity="multivariate", + preprocessor_compatible=False, + ), # --- numerical: B / M / I spline bases (optional target-aware, adaptive) --- _spec( "bspline", @@ -397,6 +423,11 @@ def _squash(name: str) -> str: "tensorproductspline": "tensorspline", "thinplate": "tprs", "thinplatespline": "tprs", + "fourierfeatures": "fourier", + "randomfourier": "rff", + "randomfourierfeatures": "rff", + "rbfsampler": "rff", + "nystrom": "nystroem", "passthrough": "none", "identity": "none", "raw": "none", diff --git a/pretab/transformers/__init__.py b/pretab/transformers/__init__.py index f90e797..8053584 100644 --- a/pretab/transformers/__init__.py +++ b/pretab/transformers/__init__.py @@ -11,9 +11,12 @@ TanhExpansionTransformer, ) from .numerical import ( + FourierFeatureTransformer, NumericBinningTransformer, + NystroemFeaturesTransformer, PeriodicEncodingTransformer, PLETransformer, + RandomFourierFeaturesTransformer, ) from .splines import ( BSplineTransformer, @@ -30,17 +33,20 @@ "BSplineTransformer", "ContinuousOrdinalTransformer", "CubicRegressionSplineTransformer", + "FourierFeatureTransformer", "ISplineTransformer", "LanguageEmbeddingTransformer", "MSplineTransformer", "NaturalCubicSplineTransformer", "NoTransformer", "NumericBinningTransformer", + "NystroemFeaturesTransformer", "OneHotFromOrdinalTransformer", "PLETransformer", "PSplineTransformer", "PeriodicEncodingTransformer", "RBFExpansionTransformer", + "RandomFourierFeaturesTransformer", "ReLUExpansionTransformer", "SigmoidExpansionTransformer", "TanhExpansionTransformer", diff --git a/pretab/transformers/numerical/__init__.py b/pretab/transformers/numerical/__init__.py index f00d588..51b5180 100644 --- a/pretab/transformers/numerical/__init__.py +++ b/pretab/transformers/numerical/__init__.py @@ -1,14 +1,20 @@ -"""Numerical single-column transformers: binning, piecewise-linear encoding (PLE) -and periodic encoding. Modules are moved here during the 1.0.0 restructure (Phase 1) -and renamed to their intention-revealing public names in Phase 5. +"""Numerical single-column transformers: binning, piecewise-linear encoding (PLE), +periodic encoding, Fourier feature maps and kernel-approximation feature maps. +Modules are moved here during the 1.0.0 restructure (Phase 1) and renamed to their +intention-revealing public names in Phase 5. """ from .binning import NumericBinningTransformer +from .fourier import FourierFeatureTransformer +from .kernel_approx import NystroemFeaturesTransformer, RandomFourierFeaturesTransformer from .periodic import PeriodicEncodingTransformer from .piecewise import PLETransformer __all__ = [ + "FourierFeatureTransformer", "NumericBinningTransformer", + "NystroemFeaturesTransformer", "PLETransformer", "PeriodicEncodingTransformer", + "RandomFourierFeaturesTransformer", ] diff --git a/pretab/transformers/numerical/fourier.py b/pretab/transformers/numerical/fourier.py new file mode 100644 index 0000000..3495d98 --- /dev/null +++ b/pretab/transformers/numerical/fourier.py @@ -0,0 +1,134 @@ +import numpy as np +from sklearn.utils import check_random_state +from sklearn.utils.validation import check_is_fitted + +from ...core.base import BasePreTabTransformer +from ...exceptions import InvalidParamError + +_FREQUENCY_STRATEGIES = ("harmonic", "log_spaced", "random") + + +class FourierFeatureTransformer(BasePreTabTransformer): + r"""Deterministic Fourier (sine/cosine) feature expansion for numerical data. + + Expands each feature into a bank of sine/cosine pairs at data-derived + frequencies, giving a smooth periodic basis without requiring a known period + (unlike :class:`PeriodicEncodingTransformer`). The fundamental frequency is + set from each feature's observed range at ``fit`` time, and + ``frequency_strategy`` controls how the ``n_frequencies`` frequencies are + spread above it. + + Parameters + ---------- + n_frequencies : int, default=5 + Number of frequencies (sine/cosine pairs) per input feature. Each feature + expands into ``2 * n_frequencies`` columns, plus one extra column when + ``include_original`` is set. + frequency_strategy : {"harmonic", "log_spaced", "random"}, default="harmonic" + How the frequencies are spaced above the fundamental ``2*pi / range``: + ``"harmonic"`` uses integer multiples ``k * fundamental``; ``"log_spaced"`` + uses octaves ``2**(k-1) * fundamental``; ``"random"`` draws frequencies + from a half-normal scaled by the fundamental (seeded by ``random_state``). + include_original : bool, default=False + If ``True``, prepend the raw feature value as an extra column per feature. + random_state : int, RandomState instance or None, default=None + Seeds the ``"random"`` frequency draw. Unused by the deterministic + strategies. + + Attributes + ---------- + offsets_ : list of float + Per-feature origin (the observed minimum) subtracted before projection. + frequencies_ : list of ndarray + Per-feature angular frequencies used to build the sine/cosine bank. + n_features_in_ : int + Number of input features seen during ``fit``. + total_output_dim_ : int + Total number of output columns produced across all input features. + + Notes + ----- + For a feature :math:`x` with fitted origin :math:`x_0` and frequency + :math:`\omega_k`, each frequency contributes + + .. math:: + + \left(\sin\!\left(\omega_k (x - x_0)\right),\; + \cos\!\left(\omega_k (x - x_0)\right)\right). + + Columns are laid out per-feature: the optional raw value first, then the + sine block followed by the cosine block in ascending frequency order. + + Examples + -------- + >>> import numpy as np + >>> from pretab.transformers import FourierFeatureTransformer + >>> X = np.linspace(0, 10, 50).reshape(-1, 1) + >>> FourierFeatureTransformer(n_frequencies=4).fit_transform(X).shape + (50, 8) + """ + + _allow_nan = False + _feature_suffix_value = "fourier" + + def __init__( + self, + n_frequencies: int = 5, + frequency_strategy: str = "harmonic", + include_original: bool = False, + random_state: int | None = None, + ): + self.n_frequencies = n_frequencies + self.frequency_strategy = frequency_strategy + self.include_original = include_original + self.random_state = random_state + + def _build_frequencies(self, span, rng): + """Return the ``n_frequencies`` angular frequencies for a feature span.""" + fundamental = 2.0 * np.pi / span + k = np.arange(1, self.n_frequencies + 1) + if self.frequency_strategy == "harmonic": + return fundamental * k + if self.frequency_strategy == "log_spaced": + return fundamental * (2.0 ** (k - 1)) + # random: half-normal spread around the fundamental. + return np.abs(rng.normal(loc=0.0, scale=fundamental, size=self.n_frequencies)) + + def fit(self, X, y=None): + X = self._validate(X, reset=True) + if not isinstance(self.n_frequencies, (int, np.integer)) or self.n_frequencies < 1: + raise InvalidParamError(f"n_frequencies must be a positive integer; got {self.n_frequencies!r}.") + if self.frequency_strategy not in _FREQUENCY_STRATEGIES: + raise InvalidParamError( + f"frequency_strategy must be one of {_FREQUENCY_STRATEGIES}; got {self.frequency_strategy!r}." + ) + + rng = check_random_state(self.random_state) + self.offsets_ = [] + self.frequencies_ = [] + for j in range(X.shape[1]): + column = X[:, j] + low = float(np.min(column)) + span = float(np.max(column) - low) + if not np.isfinite(span) or span <= 0.0: + span = 1.0 + self.offsets_.append(low) + self.frequencies_.append(self._build_frequencies(span, rng)) + return self + + def transform(self, X): + check_is_fitted(self, "frequencies_") + X = self._validate(X, reset=False) + blocks = [] + for j in range(X.shape[1]): + column = X[:, j : j + 1] + angles = (column - self.offsets_[j]) * self.frequencies_[j] + feats = [X[:, j : j + 1]] if self.include_original else [] + feats.append(np.sin(angles)) + feats.append(np.cos(angles)) + blocks.append(np.hstack(feats)) + return np.hstack(blocks) + + def _output_sizes(self) -> list[int]: + per_feature = 2 * self.n_frequencies + (1 if self.include_original else 0) + return [per_feature] * self.n_features_in_ diff --git a/pretab/transformers/numerical/kernel_approx.py b/pretab/transformers/numerical/kernel_approx.py new file mode 100644 index 0000000..2f7f076 --- /dev/null +++ b/pretab/transformers/numerical/kernel_approx.py @@ -0,0 +1,162 @@ +import numpy as np +from sklearn.kernel_approximation import Nystroem, RBFSampler +from sklearn.utils.validation import check_is_fitted + +from ...core.base import BasePreTabTransformer +from ...exceptions import InvalidParamError + +_NYSTROEM_KERNELS = ("rbf", "poly", "polynomial", "sigmoid", "laplacian", "cosine", "linear", "chi2", "additive_chi2") + + +class RandomFourierFeaturesTransformer(BasePreTabTransformer): + r"""Random Fourier features approximating an RBF kernel map (multivariate). + + Thin wrapper around :class:`sklearn.kernel_approximation.RBFSampler` that + jointly maps all input features into a randomized low-dimensional feature + space whose inner products approximate a Gaussian (RBF) kernel. This is a + **standalone, multivariate** transformer: it models the feature block as a + whole and is therefore not selectable per column through + :class:`~pretab.preprocessor.Preprocessor`. + + Parameters + ---------- + n_components : int, default=100 + Number of Monte-Carlo random features (output columns). + gamma : float, default=1.0 + Bandwidth of the approximated RBF kernel ``exp(-gamma * ||x - y||^2)``. + random_state : int, RandomState instance or None, default=None + Seeds the random projection for reproducibility. + + Attributes + ---------- + sampler_ : RBFSampler + The fitted underlying scikit-learn sampler. + n_features_in_ : int + Number of input features seen during ``fit``. + total_output_dim_ : int + Total number of output columns (equals ``n_components``). + + Examples + -------- + >>> import numpy as np + >>> from pretab.transformers import RandomFourierFeaturesTransformer + >>> X = np.random.default_rng(0).uniform(size=(40, 3)) + >>> RandomFourierFeaturesTransformer(n_components=20, random_state=0).fit_transform(X).shape + (40, 20) + """ + + _allow_nan = False + _feature_suffix_value = "rff" + + def __init__(self, n_components: int = 100, gamma: float = 1.0, random_state: int | None = None): + self.n_components = n_components + self.gamma = gamma + self.random_state = random_state + + def fit(self, X, y=None): + X = self._validate(X, reset=True) + if not isinstance(self.n_components, (int, np.integer)) or self.n_components < 1: + raise InvalidParamError(f"n_components must be a positive integer; got {self.n_components!r}.") + self.sampler_ = RBFSampler( + n_components=self.n_components, + gamma=self.gamma, + random_state=self.random_state, + ).fit(X) + return self + + def transform(self, X): + check_is_fitted(self, "sampler_") + X = self._validate(X, reset=False) + return np.asarray(self.sampler_.transform(X)) + + def _output_sizes(self) -> list[int]: + return [self.n_components] + + +class NystroemFeaturesTransformer(BasePreTabTransformer): + r"""Nystroem kernel-map approximation over the full feature block (multivariate). + + Thin wrapper around :class:`sklearn.kernel_approximation.Nystroem` that builds + a low-rank approximation of an arbitrary kernel by sampling ``n_components`` + landmark rows from the training data. This is a **standalone, multivariate** + transformer and is not selectable per column through + :class:`~pretab.preprocessor.Preprocessor`. + + Parameters + ---------- + n_components : int, default=100 + Number of landmark points sampled to build the approximation (output + columns). Clamped to the number of samples by the underlying estimator. + kernel : str, default="rbf" + Kernel passed to the underlying :class:`~sklearn.kernel_approximation.Nystroem` + (e.g. ``"rbf"``, ``"poly"``, ``"sigmoid"``, ``"laplacian"``, ``"cosine"``). + gamma : float or None, default=None + Kernel coefficient for the RBF / poly / sigmoid kernels. ``None`` defers + to the scikit-learn default (``1 / n_features``). + degree : float, default=3 + Degree of the polynomial kernel (ignored by other kernels). + coef0 : float, default=1 + Independent term for the poly / sigmoid kernels. + random_state : int, RandomState instance or None, default=None + Seeds the landmark sampling for reproducibility. + + Attributes + ---------- + nystroem_ : Nystroem + The fitted underlying scikit-learn estimator. + n_features_in_ : int + Number of input features seen during ``fit``. + total_output_dim_ : int + Total number of output columns (the effective number of landmarks). + + Examples + -------- + >>> import numpy as np + >>> from pretab.transformers import NystroemFeaturesTransformer + >>> X = np.random.default_rng(0).uniform(size=(60, 3)) + >>> NystroemFeaturesTransformer(n_components=20, random_state=0).fit_transform(X).shape + (60, 20) + """ + + _allow_nan = False + _feature_suffix_value = "nystroem" + + def __init__( + self, + n_components: int = 100, + kernel: str = "rbf", + gamma: float | None = None, + degree: float = 3, + coef0: float = 1, + random_state: int | None = None, + ): + self.n_components = n_components + self.kernel = kernel + self.gamma = gamma + self.degree = degree + self.coef0 = coef0 + self.random_state = random_state + + def fit(self, X, y=None): + X = self._validate(X, reset=True) + if not isinstance(self.n_components, (int, np.integer)) or self.n_components < 1: + raise InvalidParamError(f"n_components must be a positive integer; got {self.n_components!r}.") + if self.kernel not in _NYSTROEM_KERNELS: + raise InvalidParamError(f"kernel must be one of {_NYSTROEM_KERNELS}; got {self.kernel!r}.") + self.nystroem_ = Nystroem( + kernel=self.kernel, + gamma=self.gamma, + degree=self.degree, + coef0=self.coef0, + n_components=self.n_components, + random_state=self.random_state, + ).fit(X) + return self + + def transform(self, X): + check_is_fitted(self, "nystroem_") + X = self._validate(X, reset=False) + return np.asarray(self.nystroem_.transform(X)) + + def _output_sizes(self) -> list[int]: + return [self.nystroem_.components_.shape[0]] diff --git a/tests/test_adaptive_output_dim.py b/tests/test_adaptive_output_dim.py index e496f69..f589127 100644 --- a/tests/test_adaptive_output_dim.py +++ b/tests/test_adaptive_output_dim.py @@ -41,6 +41,8 @@ "polynomial": 4, # binning collapses to a single integer-coded column "custombin": 1, + # deterministic Fourier map: 2 * default n_frequencies (5) sine/cosine columns + "fourier": 10, # width-driven expansions -> exactly output_dim "ple": OUTPUT_DIM, "rbf": OUTPUT_DIM, diff --git a/tests/test_fourier_transformer.py b/tests/test_fourier_transformer.py new file mode 100644 index 0000000..59d9917 --- /dev/null +++ b/tests/test_fourier_transformer.py @@ -0,0 +1,77 @@ +import numpy as np +import pytest +from sklearn.exceptions import NotFittedError + +from pretab.exceptions import InvalidParamError +from pretab.transformers import FourierFeatureTransformer + + +def test_fourier_output_shape_and_total_dim(): + X = np.linspace(0, 10, 50).reshape(-1, 1) + transformer = FourierFeatureTransformer(n_frequencies=4) + Xt = transformer.fit_transform(X) + + assert Xt.shape == (50, 8) + assert transformer.total_output_dim_ == 8 + assert np.isfinite(Xt).all() + + +def test_fourier_include_original_prepends_raw_value(): + X = np.linspace(0, 5, 20).reshape(-1, 1) + transformer = FourierFeatureTransformer(n_frequencies=3, include_original=True) + Xt = transformer.fit_transform(X) + + assert Xt.shape == (20, 7) + np.testing.assert_allclose(Xt[:, 0], X[:, 0]) + + +def test_fourier_multifeature_is_per_feature_contiguous(): + rng = np.random.RandomState(0) + X = rng.uniform(-2, 2, size=(40, 2)) + transformer = FourierFeatureTransformer(n_frequencies=2) + Xt = transformer.fit_transform(X) + + assert Xt.shape == (40, 8) + assert transformer.n_features_in_ == 2 + + +@pytest.mark.parametrize("strategy", ["harmonic", "log_spaced", "random"]) +def test_fourier_strategies_are_deterministic(strategy): + X = np.linspace(0, 4, 30).reshape(-1, 1) + transformer = FourierFeatureTransformer(n_frequencies=3, frequency_strategy=strategy, random_state=0) + Xt1 = transformer.fit(X).transform(X) + Xt2 = FourierFeatureTransformer(n_frequencies=3, frequency_strategy=strategy, random_state=0).fit_transform(X) + + np.testing.assert_allclose(Xt1, Xt2) + + +def test_fourier_rejects_invalid_params(): + X = np.linspace(0, 1, 20).reshape(-1, 1) + with pytest.raises(InvalidParamError, match="n_frequencies"): + FourierFeatureTransformer(n_frequencies=0).fit(X) + with pytest.raises(InvalidParamError, match="frequency_strategy"): + FourierFeatureTransformer(frequency_strategy="bogus").fit(X) + + +def test_fourier_rejects_nan(): + X = np.full((10, 1), np.nan) + with pytest.raises(ValueError, match="NaN"): + FourierFeatureTransformer().fit(X) + + +def test_fourier_feature_names_out(): + X = np.linspace(0, 1, 20).reshape(-1, 1) + transformer = FourierFeatureTransformer(n_frequencies=2).fit(X) + + names = transformer.get_feature_names_out(["a"]) + assert list(names) == ["a_fourier0", "a_fourier1", "a_fourier2", "a_fourier3"] + + +def test_fourier_transform_requires_fit(): + with pytest.raises(NotFittedError): + FourierFeatureTransformer().transform(np.linspace(0, 1, 5).reshape(-1, 1)) + + +def test_fourier_allow_nan_tag_is_false(): + tags = FourierFeatureTransformer().__sklearn_tags__() + assert tags.input_tags.allow_nan is False diff --git a/tests/test_kernel_approx_transformer.py b/tests/test_kernel_approx_transformer.py new file mode 100644 index 0000000..cd36929 --- /dev/null +++ b/tests/test_kernel_approx_transformer.py @@ -0,0 +1,101 @@ +import numpy as np +import pytest +from sklearn.exceptions import NotFittedError + +from pretab.exceptions import InvalidParamError +from pretab.transformers import ( + NystroemFeaturesTransformer, + RandomFourierFeaturesTransformer, +) + + +@pytest.fixture +def X(): + return np.random.default_rng(0).uniform(size=(60, 3)) + + +# --------------------------------------------------------------------------- # +# Random Fourier features (RBFSampler wrapper). +# --------------------------------------------------------------------------- # +def test_rff_output_shape_and_total_dim(X): + transformer = RandomFourierFeaturesTransformer(n_components=20, random_state=0) + Xt = transformer.fit_transform(X) + + assert Xt.shape == (60, 20) + assert transformer.total_output_dim_ == 20 + assert transformer.n_features_in_ == 3 + assert np.isfinite(Xt).all() + + +def test_rff_is_deterministic_with_random_state(X): + transformer = RandomFourierFeaturesTransformer(n_components=16, random_state=0) + Xt1 = transformer.fit(X).transform(X) + Xt2 = RandomFourierFeaturesTransformer(n_components=16, random_state=0).fit_transform(X) + + np.testing.assert_allclose(Xt1, Xt2) + + +def test_rff_rejects_invalid_n_components(X): + with pytest.raises(InvalidParamError, match="n_components"): + RandomFourierFeaturesTransformer(n_components=0).fit(X) + + +def test_rff_feature_names_out(X): + transformer = RandomFourierFeaturesTransformer(n_components=5, random_state=0).fit(X) + names = transformer.get_feature_names_out() + + assert len(names) == 5 + assert names[0].startswith("x0_rff") + + +def test_rff_transform_requires_fit(X): + with pytest.raises(NotFittedError): + RandomFourierFeaturesTransformer().transform(X) + + +# --------------------------------------------------------------------------- # +# Nystroem features (Nystroem wrapper). +# --------------------------------------------------------------------------- # +def test_nystroem_output_shape_and_total_dim(X): + transformer = NystroemFeaturesTransformer(n_components=15, random_state=0) + Xt = transformer.fit_transform(X) + + assert Xt.shape == (60, 15) + assert transformer.total_output_dim_ == 15 + assert np.isfinite(Xt).all() + + +def test_nystroem_is_deterministic_with_random_state(X): + transformer = NystroemFeaturesTransformer(n_components=12, random_state=0) + Xt1 = transformer.fit(X).transform(X) + Xt2 = NystroemFeaturesTransformer(n_components=12, random_state=0).fit_transform(X) + + np.testing.assert_allclose(Xt1, Xt2) + + +def test_nystroem_supports_non_default_kernel(X): + transformer = NystroemFeaturesTransformer(n_components=10, kernel="laplacian", random_state=0) + Xt = transformer.fit_transform(X) + + assert Xt.shape == (60, 10) + assert np.isfinite(Xt).all() + + +def test_nystroem_rejects_invalid_params(X): + with pytest.raises(InvalidParamError, match="n_components"): + NystroemFeaturesTransformer(n_components=0).fit(X) + with pytest.raises(InvalidParamError, match="kernel"): + NystroemFeaturesTransformer(kernel="bogus").fit(X) + + +def test_nystroem_feature_names_out(X): + transformer = NystroemFeaturesTransformer(n_components=8, random_state=0).fit(X) + names = transformer.get_feature_names_out() + + assert len(names) == 8 + assert names[0].startswith("x0_nystroem") + + +def test_nystroem_transform_requires_fit(X): + with pytest.raises(NotFittedError): + NystroemFeaturesTransformer().transform(X) From e7a1b1b4e1b69c99589b5db2f1a4e7912252dfba Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 26 Jul 2026 17:40:19 +0200 Subject: [PATCH 12/59] refactor(transformers): move Fourier and kernel-approximation maps into feature_maps/ --- pretab/compose/registry.py | 10 +++++----- pretab/transformers/__init__.py | 6 +++--- pretab/transformers/feature_maps/__init__.py | 5 +++++ .../{numerical => feature_maps}/fourier.py | 0 .../{numerical => feature_maps}/kernel_approx.py | 0 pretab/transformers/numerical/__init__.py | 12 +++--------- 6 files changed, 16 insertions(+), 17 deletions(-) rename pretab/transformers/{numerical => feature_maps}/fourier.py (100%) rename pretab/transformers/{numerical => feature_maps}/kernel_approx.py (100%) diff --git a/pretab/compose/registry.py b/pretab/compose/registry.py index 05faf99..8dbc391 100644 --- a/pretab/compose/registry.py +++ b/pretab/compose/registry.py @@ -33,16 +33,16 @@ from ..transformers.categorical.legacy import OneHotFromOrdinalTransformer from ..transformers.categorical.ordinal import ContinuousOrdinalTransformer from ..transformers.encoders.floats import NoTransformer +from ..transformers.feature_maps.fourier import FourierFeatureTransformer +from ..transformers.feature_maps.kernel_approx import ( + NystroemFeaturesTransformer, + RandomFourierFeaturesTransformer, +) from ..transformers.feature_maps.rbf import RBFExpansionTransformer from ..transformers.feature_maps.relu import ReLUExpansionTransformer from ..transformers.feature_maps.sigmoid import SigmoidExpansionTransformer from ..transformers.feature_maps.tanh import TanhExpansionTransformer from ..transformers.numerical.binning import NumericBinningTransformer -from ..transformers.numerical.fourier import FourierFeatureTransformer -from ..transformers.numerical.kernel_approx import ( - NystroemFeaturesTransformer, - RandomFourierFeaturesTransformer, -) from ..transformers.numerical.piecewise import PLETransformer from ..transformers.splines.b_spline import BSplineTransformer from ..transformers.splines.cubic_regression import CubicRegressionSplineTransformer diff --git a/pretab/transformers/__init__.py b/pretab/transformers/__init__.py index 8053584..5c22543 100644 --- a/pretab/transformers/__init__.py +++ b/pretab/transformers/__init__.py @@ -5,18 +5,18 @@ ) from .encoders import NoTransformer, ToFloatTransformer from .feature_maps import ( + FourierFeatureTransformer, + NystroemFeaturesTransformer, + RandomFourierFeaturesTransformer, RBFExpansionTransformer, ReLUExpansionTransformer, SigmoidExpansionTransformer, TanhExpansionTransformer, ) from .numerical import ( - FourierFeatureTransformer, NumericBinningTransformer, - NystroemFeaturesTransformer, PeriodicEncodingTransformer, PLETransformer, - RandomFourierFeaturesTransformer, ) from .splines import ( BSplineTransformer, diff --git a/pretab/transformers/feature_maps/__init__.py b/pretab/transformers/feature_maps/__init__.py index 8088789..41105fd 100644 --- a/pretab/transformers/feature_maps/__init__.py +++ b/pretab/transformers/feature_maps/__init__.py @@ -1,10 +1,15 @@ +from .fourier import FourierFeatureTransformer +from .kernel_approx import NystroemFeaturesTransformer, RandomFourierFeaturesTransformer from .rbf import RBFExpansionTransformer from .relu import ReLUExpansionTransformer from .sigmoid import SigmoidExpansionTransformer from .tanh import TanhExpansionTransformer __all__ = [ + "FourierFeatureTransformer", + "NystroemFeaturesTransformer", "RBFExpansionTransformer", + "RandomFourierFeaturesTransformer", "ReLUExpansionTransformer", "SigmoidExpansionTransformer", "TanhExpansionTransformer", diff --git a/pretab/transformers/numerical/fourier.py b/pretab/transformers/feature_maps/fourier.py similarity index 100% rename from pretab/transformers/numerical/fourier.py rename to pretab/transformers/feature_maps/fourier.py diff --git a/pretab/transformers/numerical/kernel_approx.py b/pretab/transformers/feature_maps/kernel_approx.py similarity index 100% rename from pretab/transformers/numerical/kernel_approx.py rename to pretab/transformers/feature_maps/kernel_approx.py diff --git a/pretab/transformers/numerical/__init__.py b/pretab/transformers/numerical/__init__.py index 51b5180..f00d588 100644 --- a/pretab/transformers/numerical/__init__.py +++ b/pretab/transformers/numerical/__init__.py @@ -1,20 +1,14 @@ -"""Numerical single-column transformers: binning, piecewise-linear encoding (PLE), -periodic encoding, Fourier feature maps and kernel-approximation feature maps. -Modules are moved here during the 1.0.0 restructure (Phase 1) and renamed to their -intention-revealing public names in Phase 5. +"""Numerical single-column transformers: binning, piecewise-linear encoding (PLE) +and periodic encoding. Modules are moved here during the 1.0.0 restructure (Phase 1) +and renamed to their intention-revealing public names in Phase 5. """ from .binning import NumericBinningTransformer -from .fourier import FourierFeatureTransformer -from .kernel_approx import NystroemFeaturesTransformer, RandomFourierFeaturesTransformer from .periodic import PeriodicEncodingTransformer from .piecewise import PLETransformer __all__ = [ - "FourierFeatureTransformer", "NumericBinningTransformer", - "NystroemFeaturesTransformer", "PLETransformer", "PeriodicEncodingTransformer", - "RandomFourierFeaturesTransformer", ] From 0c09bb50d263d1f4aa20c84f12a180b72de84d49 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 26 Jul 2026 18:10:46 +0200 Subject: [PATCH 13/59] feat(representation): add RepresentationSpec and feature lineage --- CHANGELOG.md | 1 + pretab/__init__.py | 3 + pretab/compose/inspection.py | 106 ++++++- pretab/core/base.py | 5 +- pretab/core/representation.py | 291 ++++++++++++++++++ pretab/preprocessor.py | 17 + pretab/transformers/categorical/legacy.py | 7 +- pretab/transformers/categorical/ordinal.py | 7 +- pretab/transformers/feature_maps/base.py | 3 + pretab/transformers/feature_maps/fourier.py | 2 + .../feature_maps/kernel_approx.py | 4 + pretab/transformers/feature_maps/rbf.py | 2 + pretab/transformers/feature_maps/relu.py | 1 + pretab/transformers/feature_maps/sigmoid.py | 1 + pretab/transformers/feature_maps/tanh.py | 1 + pretab/transformers/numerical/binning.py | 8 +- pretab/transformers/numerical/periodic.py | 6 + pretab/transformers/numerical/piecewise.py | 9 +- pretab/transformers/splines/b_spline.py | 2 + pretab/transformers/splines/base_spline.py | 4 + .../transformers/splines/cubic_regression.py | 3 + pretab/transformers/splines/i_spline.py | 2 + pretab/transformers/splines/m_spline.py | 2 + .../splines/multivariate/tensor_product.py | 4 + .../splines/multivariate/thin_plate.py | 2 + pretab/transformers/splines/natural_cubic.py | 3 + pretab/transformers/splines/p_spline.py | 2 + tests/test_feature_lineage.py | 129 ++++++++ tests/test_representation_spec.py | 236 ++++++++++++++ 29 files changed, 857 insertions(+), 6 deletions(-) create mode 100644 pretab/core/representation.py create mode 100644 tests/test_feature_lineage.py create mode 100644 tests/test_representation_spec.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 86162c7..45ed0f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Going forward, this file is updated automatically by `cz bump` on each release. ### Feat +- **representation**: add typed `RepresentationSpec` and per-output-column `FeatureLineage` (exported from `pretab`); every transformer family exposes `get_representation_spec()` and `Preprocessor.get_feature_lineage()` maps each output column to its source feature(s), representation family, component, and target-usage flag - **transformers**: add `FourierFeatureTransformer` (deterministic sine/cosine feature map with `harmonic` / `log_spaced` / `random` frequencies), selectable as the `"fourier"` numerical method - **transformers**: add `RandomFourierFeaturesTransformer` and `NystroemFeaturesTransformer` — standalone multivariate kernel-approximation feature maps (`"rff"` / `"nystroem"`) - **binning**: make `NumericBinningTransformer` a stateful, multi-feature encoder with learned `bin_edges_` and `encode` (`ordinal` / `onehot` / `soft`) plus `placement_strategy` (`uniform` / `quantile`) options diff --git a/pretab/__init__.py b/pretab/__init__.py index ed42267..0c6455d 100644 --- a/pretab/__init__.py +++ b/pretab/__init__.py @@ -1,11 +1,14 @@ from ._version import __version__ from .core.logging import configure_logging, set_verbosity +from .core.representation import FeatureLineage, RepresentationSpec from .exceptions import PretabWarning from .preprocessor import Preprocessor __all__ = [ + "FeatureLineage", "Preprocessor", "PretabWarning", + "RepresentationSpec", "__version__", "configure_logging", "set_verbosity", diff --git a/pretab/compose/inspection.py b/pretab/compose/inspection.py index d3bbcd9..d0e705d 100644 --- a/pretab/compose/inspection.py +++ b/pretab/compose/inspection.py @@ -10,10 +10,16 @@ import numpy as np from ..core.logging import get_logger +from ..core.representation import FeatureLineage logger = get_logger(__name__) -__all__ = ["build_feature_info", "build_transformer_summary", "get_output_slices"] +__all__ = [ + "build_feature_info", + "build_feature_lineage", + "build_transformer_summary", + "get_output_slices", +] def get_output_slices(column_transformer, X): @@ -170,3 +176,101 @@ def build_transformer_summary(numerical_info, categorical_info, embedding_info): cats_s = "-" if cats is None else str(cats) lines.append(f"{feat:<{feat_w}} {kind:<{kind_w}} {pipe:<{pipe_w}} {dim_s:>4} {cats_s:>5}") return lines + + +# Mapping from pipeline step name to (family, component) for representation-bearing +# scikit-learn steps that do not expose ``get_representation_spec``. +_STEP_FAMILY = { + "standardization": ("standardization", "raw"), + "scaler": ("standardization", "raw"), + "minmax": ("minmax", "raw"), + "robust": ("robust", "raw"), + "quantile": ("quantile", "raw"), + "polynomial": ("polynomial", "basis"), + "boxcox": ("box_cox", "raw"), + "yeojohnson": ("yeo_johnson", "raw"), + "onehot": ("onehot", "category"), + "pretrained": ("language_embedding", "embedding"), +} + + +def _resolve_block_representation(pipeline, columns): + """Return ``(family, component, uses_target, is_interaction)`` for a block. + + The representation-bearing step is the last pipeline step exposing a + ``get_representation_spec`` (a PreTab transformer) or a known scikit-learn + step name; helper steps such as imputers and float casts are skipped. + """ + steps = pipeline.steps if hasattr(pipeline, "steps") else [("_", pipeline)] + for step_name, transformer in reversed(steps): + if hasattr(transformer, "get_representation_spec"): + spec = transformer.get_representation_spec(input_features=list(columns)) + return spec.family, spec.component_kind, spec.uses_target, spec.is_interaction + if step_name in _STEP_FAMILY: + family, component = _STEP_FAMILY[step_name] + return family, component, False, False + return "passthrough", "raw", False, False + + +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] + if isinstance(column, (int, np.integer)) and feature_names_in is not None: + return str(feature_names_in[column]) + return str(column) + + +def build_feature_lineage(column_transformer): + """Return per-output-column :class:`FeatureLineage` records. + + Each record maps one output column of the fitted ColumnTransformer back to + its source feature(s), representation family, and component, covering 100% + of the transformed columns in ``get_feature_names_out`` order. + """ + output_names = [str(name) for name in column_transformer.get_feature_names_out()] + output_indices = column_transformer.output_indices_ + feature_names_in = getattr(column_transformer, "feature_names_in_", None) + records = [] + for name, transformer, columns in column_transformer.transformers_: + span = output_indices.get(name) + if span is None: + continue + width = span.stop - span.start + if width == 0: + continue + if transformer == "passthrough" or name == "remainder": + for offset in range(width): + index = span.start + offset + records.append( + FeatureLineage( + output_feature=output_names[index], + output_index=index, + source_features=(_passthrough_source(columns, offset, feature_names_in),), + family="passthrough", + component="raw", + component_index=offset, + uses_target=False, + is_interaction=False, + ) + ) + continue + family, component, uses_target, is_interaction = _resolve_block_representation( + transformer, columns + ) + source_features = tuple(str(column) for column in columns) + for offset in range(width): + index = span.start + offset + records.append( + FeatureLineage( + output_feature=output_names[index], + output_index=index, + source_features=source_features, + family=family, + component=component, + component_index=offset, + uses_target=uses_target, + is_interaction=is_interaction, + ) + ) + records.sort(key=lambda record: record.output_index) + return records diff --git a/pretab/core/base.py b/pretab/core/base.py index fab5ce9..e58a1d7 100644 --- a/pretab/core/base.py +++ b/pretab/core/base.py @@ -12,12 +12,15 @@ from .adaptive import AdaptiveResolutionMixin from .parameters import AliasResolverMixin +from .representation import RepresentationSpecMixin from .validation import validate_2d_allow_nan __all__ = ["BasePreTabTransformer"] -class BasePreTabTransformer(AdaptiveResolutionMixin, AliasResolverMixin, TransformerMixin, BaseEstimator): +class BasePreTabTransformer( + RepresentationSpecMixin, AdaptiveResolutionMixin, AliasResolverMixin, TransformerMixin, BaseEstimator +): """Base class carrying the shared scikit-learn contract for PreTab transformers. Subclasses set ``_allow_nan`` / ``_requires_y`` / ``_feature_suffix_value`` as diff --git a/pretab/core/representation.py b/pretab/core/representation.py new file mode 100644 index 0000000..288497a --- /dev/null +++ b/pretab/core/representation.py @@ -0,0 +1,291 @@ +"""Typed representation metadata and feature-lineage records. + +``RepresentationSpec`` is the machine-readable description of the basis / +encoding a fitted transformer produces (family, scope, supervision, knot or +center locations, ...). ``RepresentationSpecMixin`` gives every PreTab +transformer a default ``get_representation_spec`` implementation driven by a +handful of class-attribute hooks, so concrete transformers usually only declare +their family and a couple of flags. ``FeatureLineage`` records the per-output +column provenance assembled by the preprocessor. +""" + +from dataclasses import dataclass + +import numpy as np +from sklearn.utils.validation import check_is_fitted + +__all__ = [ + "FeatureLineage", + "RepresentationSpec", + "RepresentationSpecMixin", +] + + +@dataclass(frozen=True) +class RepresentationSpec: + """Typed description of the representation a transformer produces. + + Attributes + ---------- + family : str + Representation family identifier, e.g. ``"bspline"``, ``"rbf"``, + ``"piecewise_linear"``. + component_kind : str + Nature of a single output column, e.g. ``"basis"``, ``"center"``, + ``"frequency"``, ``"interval"``, ``"category"``, ``"raw"``. + scope : str + ``"univariate"`` when each input feature is expanded independently or + ``"multivariate"`` for interaction / joint bases. + supervision : str + ``"unsupervised"``, ``"supervised"``, or ``"optional"`` (target used + only when ``target_aware`` is enabled). + uses_target : bool + Whether the fitted transformer actually consumed ``y``. + is_interaction : bool + Whether output columns mix multiple input features. + input_features : tuple of str + Names of the input features consumed. + output_features : tuple of str + Names of the produced output columns (matches + ``get_feature_names_out``). + output_dim : int + Number of output columns (``len(output_features)``). + degree : int or None + Polynomial / spline degree when applicable. + include_bias : bool + Whether an explicit intercept / bias column is included. + periodic : bool + Whether the representation encodes a periodic signal. + period : float or None + Period length when ``periodic`` is True. + local_support : bool + Whether individual basis functions have compact (local) support. + location_kind : str or None + Semantic label for ``locations`` (``"knots"``, ``"centers"``, + ``"bin_edges"``, ``"thresholds"``, ``"frequencies"``, ``"landmarks"``). + locations : tuple of tuple of float or None + Fitted knot / center / threshold locations, one inner tuple per input + feature (or per landmark row for multivariate bases). + dtype : str + Output dtype of the transformed array. + """ + + family: str + component_kind: str + scope: str + supervision: str + uses_target: bool + is_interaction: bool + input_features: tuple[str, ...] + output_features: tuple[str, ...] + output_dim: int + degree: int | None + include_bias: bool + periodic: bool + period: float | None + local_support: bool + location_kind: str | None + locations: tuple[tuple[float, ...], ...] | None + dtype: str = "float64" + + def to_dict(self) -> dict: + """Return a JSON-serializable dictionary representation.""" + return { + "family": self.family, + "component_kind": self.component_kind, + "scope": self.scope, + "supervision": self.supervision, + "uses_target": self.uses_target, + "is_interaction": self.is_interaction, + "input_features": list(self.input_features), + "output_features": list(self.output_features), + "output_dim": self.output_dim, + "degree": self.degree, + "include_bias": self.include_bias, + "periodic": self.periodic, + "period": self.period, + "local_support": self.local_support, + "location_kind": self.location_kind, + "locations": ( + None if self.locations is None else [list(group) for group in self.locations] + ), + "dtype": self.dtype, + } + + @classmethod + def from_dict(cls, data: dict) -> "RepresentationSpec": + """Reconstruct a ``RepresentationSpec`` from :meth:`to_dict` output.""" + locations = data.get("locations") + return cls( + family=data["family"], + component_kind=data["component_kind"], + scope=data["scope"], + supervision=data["supervision"], + uses_target=bool(data["uses_target"]), + is_interaction=bool(data["is_interaction"]), + input_features=tuple(data["input_features"]), + output_features=tuple(data["output_features"]), + output_dim=int(data["output_dim"]), + degree=None if data["degree"] is None else int(data["degree"]), + include_bias=bool(data["include_bias"]), + periodic=bool(data["periodic"]), + period=None if data["period"] is None else float(data["period"]), + local_support=bool(data["local_support"]), + location_kind=data["location_kind"], + locations=( + None + if locations is None + else tuple(tuple(float(v) for v in group) for group in locations) + ), + dtype=data.get("dtype", "float64"), + ) + + +@dataclass(frozen=True) +class FeatureLineage: + """Provenance record for a single output column of a fitted preprocessor. + + Attributes + ---------- + output_feature : str + Name of the produced output column (matches + ``Preprocessor.get_feature_names_out``). + output_index : int + Position of the column in the transformed array. + source_features : tuple of str + Input column(s) this output column is derived from. + family : str + Representation family that produced the column. + component : str + Component kind of the column (``"basis"``, ``"center"``, ...). + component_index : int + Index of the column within its representation block. + uses_target : bool + Whether the producing transformer consumed ``y``. + is_interaction : bool + Whether the column mixes multiple input features. + """ + + output_feature: str + output_index: int + source_features: tuple[str, ...] + family: str + component: str + component_index: int + uses_target: bool + is_interaction: bool + + def to_dict(self) -> dict: + """Return a JSON-serializable dictionary representation.""" + return { + "output_feature": self.output_feature, + "output_index": self.output_index, + "source_features": list(self.source_features), + "family": self.family, + "component": self.component, + "component_index": self.component_index, + "uses_target": self.uses_target, + "is_interaction": self.is_interaction, + } + + +def _as_location_tuple(value) -> tuple[tuple[float, ...], ...]: + """Normalise fitted location arrays into a tuple of float tuples. + + Handles both list-of-1D-array layouts (one array per input feature) and 2D + arrays (one row per landmark) by iterating the outer axis. + """ + return tuple(tuple(float(v) for v in np.asarray(group).ravel()) for group in value) + + +class RepresentationSpecMixin: + """Provide a default ``get_representation_spec`` from class-attribute hooks. + + Concrete transformers declare their family and a few flags via the + ``_representation_*`` class attributes; fitted knot / center / threshold + locations are auto-detected from the first matching fitted attribute. + Transformers with bespoke needs override the ``_representation_*`` helper + methods (or ``get_representation_spec`` itself). + """ + + _representation_family: str = "unknown" + _representation_component_kind: str = "basis" + _representation_scope: str = "univariate" + _representation_supervision: str = "unsupervised" + _representation_local_support: bool = False + + _REPRESENTATION_LOCATION_ATTRS: tuple[tuple[str, str], ...] = ( + ("knots_", "knots"), + ("centers_", "centers"), + ("bin_edges_", "bin_edges"), + ("thresholds_", "thresholds"), + ("frequencies_", "frequencies"), + ("landmarks_", "landmarks"), + ) + + def _representation_uses_target(self) -> bool: + """Return whether the fitted transformer consumed the target.""" + if self._representation_supervision == "supervised": + return True + return bool(getattr(self, "target_aware", False)) + + def _representation_degree(self) -> int | None: + """Return the polynomial / spline degree, if the transformer has one.""" + degree = getattr(self, "degree", None) + return None if degree is None else int(degree) + + def _representation_periodic(self) -> tuple[bool, float | None]: + """Return ``(periodic, period)`` for the representation.""" + return False, None + + def _representation_locations(self) -> tuple[str | None, tuple[tuple[float, ...], ...] | None]: + """Auto-detect fitted location arrays from known fitted attributes.""" + for attr, kind in self._REPRESENTATION_LOCATION_ATTRS: + value = getattr(self, attr, None) + if value is None: + continue + return kind, _as_location_tuple(value) + return None, None + + def get_representation_spec(self, input_features=None) -> RepresentationSpec: + """Return the typed :class:`RepresentationSpec` for this fitted transformer. + + Parameters + ---------- + input_features : list of str or None + Names of the input features. When ``None``, names of the form + ``x0, x1, ...`` are generated. + + Returns + ------- + RepresentationSpec + The representation metadata describing the produced columns. + """ + check_is_fitted(self, "n_features_in_") + if input_features is None: + inputs = tuple(f"x{i}" for i in range(self.n_features_in_)) + else: + inputs = tuple(str(feature) for feature in input_features) + output_features = tuple(str(name) for name in self.get_feature_names_out(list(inputs))) + location_kind, locations = self._representation_locations() + periodic, period = self._representation_periodic() + scope = self._representation_scope + return RepresentationSpec( + family=self._representation_family, + component_kind=self._representation_component_kind, + scope=scope, + supervision=self._representation_supervision, + uses_target=self._representation_uses_target(), + is_interaction=scope == "multivariate", + input_features=inputs, + output_features=output_features, + output_dim=len(output_features), + degree=self._representation_degree(), + include_bias=bool(getattr(self, "include_bias", False)), + periodic=periodic, + period=period, + local_support=self._representation_local_support, + location_kind=location_kind, + locations=locations, + dtype="float64", + ) diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index d7f2779..b5595a1 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -9,6 +9,7 @@ from .compose.feature_detection import detect_column_types, to_dataframe from .compose.inspection import ( build_feature_info, + build_feature_lineage, build_transformer_summary, get_output_slices, ) @@ -433,6 +434,22 @@ def get_feature_names_out(self, input_features=None): check_is_fitted(self) return self.column_transformer_.get_feature_names_out(input_features) + def get_feature_lineage(self): + """Return per-output-column provenance for the fitted preprocessor. + + Each :class:`~pretab.core.representation.FeatureLineage` record maps one + output column back to its source feature(s), representation family, and + component, covering 100% of the columns produced by + :meth:`get_feature_names_out` (and in the same order). + + Returns + ------- + lineage : list of FeatureLineage + One record per output column of the transformed array. + """ + check_is_fitted(self) + return build_feature_lineage(self.column_transformer_) + @property def total_output_dim_(self) -> int: """Total number of output columns produced across all input features. diff --git a/pretab/transformers/categorical/legacy.py b/pretab/transformers/categorical/legacy.py index 59a5f5b..be21f99 100644 --- a/pretab/transformers/categorical/legacy.py +++ b/pretab/transformers/categorical/legacy.py @@ -4,8 +4,10 @@ from sklearn.base import BaseEstimator, TransformerMixin from sklearn.utils.validation import check_is_fitted +from ...core.representation import RepresentationSpecMixin -class OneHotFromOrdinalTransformer(TransformerMixin, BaseEstimator): + +class OneHotFromOrdinalTransformer(RepresentationSpecMixin, TransformerMixin, BaseEstimator): """Convert ordinal-encoded features into a one-hot encoded representation. This is useful when features have already been ordinal-encoded and a one-hot @@ -40,6 +42,9 @@ class OneHotFromOrdinalTransformer(TransformerMixin, BaseEstimator): (3, 5) """ + _representation_family = "onehot" + _representation_component_kind = "category" + def __init__(self): warnings.warn( "OneHotFromOrdinalTransformer is deprecated and will be removed in a " diff --git a/pretab/transformers/categorical/ordinal.py b/pretab/transformers/categorical/ordinal.py index 0228d27..867cf01 100644 --- a/pretab/transformers/categorical/ordinal.py +++ b/pretab/transformers/categorical/ordinal.py @@ -2,8 +2,10 @@ from sklearn.base import BaseEstimator, TransformerMixin from sklearn.utils.validation import check_is_fitted +from ...core.representation import RepresentationSpecMixin -class ContinuousOrdinalTransformer(TransformerMixin, BaseEstimator): + +class ContinuousOrdinalTransformer(RepresentationSpecMixin, TransformerMixin, BaseEstimator): """Encode categorical features as continuous integer values. Each unique category within a feature is assigned an integer based on its @@ -30,6 +32,9 @@ class ContinuousOrdinalTransformer(TransformerMixin, BaseEstimator): (3, 2) """ + _representation_family = "ordinal" + _representation_component_kind = "category" + def fit(self, X, y=None): """Learn the mapping from categories to integers for each feature. diff --git a/pretab/transformers/feature_maps/base.py b/pretab/transformers/feature_maps/base.py index b078a26..0a5f3e2 100644 --- a/pretab/transformers/feature_maps/base.py +++ b/pretab/transformers/feature_maps/base.py @@ -46,6 +46,9 @@ class BaseCenterExpansion(BasePreTabTransformer): centers, reproducing the non-adaptive behavior. """ + _representation_component_kind = "center" + _representation_supervision = "optional" + centers_: list def __init__( diff --git a/pretab/transformers/feature_maps/fourier.py b/pretab/transformers/feature_maps/fourier.py index 3495d98..00d1e5b 100644 --- a/pretab/transformers/feature_maps/fourier.py +++ b/pretab/transformers/feature_maps/fourier.py @@ -70,6 +70,8 @@ class FourierFeatureTransformer(BasePreTabTransformer): _allow_nan = False _feature_suffix_value = "fourier" + _representation_family = "fourier" + _representation_component_kind = "frequency" def __init__( self, diff --git a/pretab/transformers/feature_maps/kernel_approx.py b/pretab/transformers/feature_maps/kernel_approx.py index 2f7f076..20b2f2c 100644 --- a/pretab/transformers/feature_maps/kernel_approx.py +++ b/pretab/transformers/feature_maps/kernel_approx.py @@ -47,6 +47,8 @@ class RandomFourierFeaturesTransformer(BasePreTabTransformer): _allow_nan = False _feature_suffix_value = "rff" + _representation_family = "random_fourier" + _representation_scope = "multivariate" def __init__(self, n_components: int = 100, gamma: float = 1.0, random_state: int | None = None): self.n_components = n_components @@ -120,6 +122,8 @@ class NystroemFeaturesTransformer(BasePreTabTransformer): _allow_nan = False _feature_suffix_value = "nystroem" + _representation_family = "nystroem" + _representation_scope = "multivariate" def __init__( self, diff --git a/pretab/transformers/feature_maps/rbf.py b/pretab/transformers/feature_maps/rbf.py index f6a90e2..d42f326 100644 --- a/pretab/transformers/feature_maps/rbf.py +++ b/pretab/transformers/feature_maps/rbf.py @@ -78,6 +78,8 @@ class RBFExpansionTransformer(BaseCenterExpansion): """ _feature_suffix_value = "rbf" + _representation_family = "rbf" + _representation_local_support = True def __init__( self, diff --git a/pretab/transformers/feature_maps/relu.py b/pretab/transformers/feature_maps/relu.py index a58f2b6..6be6f76 100644 --- a/pretab/transformers/feature_maps/relu.py +++ b/pretab/transformers/feature_maps/relu.py @@ -74,6 +74,7 @@ class ReLUExpansionTransformer(BaseCenterExpansion): """ _feature_suffix_value = "relu" + _representation_family = "relu" def __init__( self, diff --git a/pretab/transformers/feature_maps/sigmoid.py b/pretab/transformers/feature_maps/sigmoid.py index bd48c94..63556c4 100644 --- a/pretab/transformers/feature_maps/sigmoid.py +++ b/pretab/transformers/feature_maps/sigmoid.py @@ -78,6 +78,7 @@ class SigmoidExpansionTransformer(BaseCenterExpansion): """ _feature_suffix_value = "sigmoid" + _representation_family = "sigmoid" def __init__( self, diff --git a/pretab/transformers/feature_maps/tanh.py b/pretab/transformers/feature_maps/tanh.py index 1db4f2d..653ab15 100644 --- a/pretab/transformers/feature_maps/tanh.py +++ b/pretab/transformers/feature_maps/tanh.py @@ -78,6 +78,7 @@ class TanhExpansionTransformer(BaseCenterExpansion): """ _feature_suffix_value = "tanh" + _representation_family = "tanh" def __init__( self, diff --git a/pretab/transformers/numerical/binning.py b/pretab/transformers/numerical/binning.py index 68aaf94..1801631 100644 --- a/pretab/transformers/numerical/binning.py +++ b/pretab/transformers/numerical/binning.py @@ -5,13 +5,16 @@ from sklearn.utils.validation import check_is_fitted from ...core.parameters import UNSET, AliasResolverMixin +from ...core.representation import RepresentationSpecMixin from ...exceptions import InsufficientSamplesError, InvalidParamError, PretabDataError _VALID_ENCODINGS = ("ordinal", "onehot", "soft") _VALID_STRATEGIES = ("uniform", "quantile") -class NumericBinningTransformer(AliasResolverMixin, TransformerMixin, BaseEstimator): +class NumericBinningTransformer( + RepresentationSpecMixin, AliasResolverMixin, TransformerMixin, BaseEstimator +): """Stateful binning transformer for numerical features. The bin edges are learned once in :meth:`fit` and reused at @@ -74,6 +77,9 @@ class NumericBinningTransformer(AliasResolverMixin, TransformerMixin, BaseEstima """ _param_aliases: ClassVar[dict[str, str]] = {} + _representation_family = "binning" + _representation_component_kind = "interval" + _representation_local_support = True def __init__(self, output_dim=UNSET, encode="ordinal", placement_strategy="uniform"): # An int yields learned bins; an array-like is used as fixed bin edges. diff --git a/pretab/transformers/numerical/periodic.py b/pretab/transformers/numerical/periodic.py index 689e2fc..ea96d60 100644 --- a/pretab/transformers/numerical/periodic.py +++ b/pretab/transformers/numerical/periodic.py @@ -59,12 +59,18 @@ class PeriodicEncodingTransformer(BasePreTabTransformer): _allow_nan = False _feature_suffix_value = "cyclic" + _representation_family = "periodic" + _representation_component_kind = "frequency" def __init__(self, period: int, harmonics: int = 1, include_original: bool = False): self.period = period self.harmonics = harmonics self.include_original = include_original + def _representation_periodic(self): + """Report periodicity with the configured period length.""" + return True, float(self.period) + def fit(self, X, y=None): X = self._validate(X, reset=True) if not isinstance(self.harmonics, (int, np.integer)) or self.harmonics < 1: diff --git a/pretab/transformers/numerical/piecewise.py b/pretab/transformers/numerical/piecewise.py index 652529b..8fd0651 100644 --- a/pretab/transformers/numerical/piecewise.py +++ b/pretab/transformers/numerical/piecewise.py @@ -14,6 +14,7 @@ from ...core.adaptive import AdaptiveResolutionMixin from ...core.parameters import UNSET, AliasResolverMixin +from ...core.representation import RepresentationSpecMixin from ...exceptions import ( IncompatibleParamsError, InvalidParamError, @@ -22,7 +23,9 @@ from ...placement.adapters import PLEPlacementAdapter -class PLETransformer(AdaptiveResolutionMixin, AliasResolverMixin, TransformerMixin, BaseEstimator): +class PLETransformer( + RepresentationSpecMixin, AdaptiveResolutionMixin, AliasResolverMixin, TransformerMixin, BaseEstimator +): """Piecewise Linear Encoding (PLE) transformer for numerical features. Each feature is discretized by a target-aware location selector (``"cart"`` @@ -105,6 +108,10 @@ class PLETransformer(AdaptiveResolutionMixin, AliasResolverMixin, TransformerMix """ _param_aliases: ClassVar[dict[str, str]] = {} + _representation_family = "piecewise_linear" + _representation_component_kind = "interval" + _representation_supervision = "supervised" + _representation_local_support = True def __init__( self, diff --git a/pretab/transformers/splines/b_spline.py b/pretab/transformers/splines/b_spline.py index 7738066..2d211ed 100644 --- a/pretab/transformers/splines/b_spline.py +++ b/pretab/transformers/splines/b_spline.py @@ -35,6 +35,8 @@ class BSplineTransformer(BaseSplineTransformer): (50, 9) """ + _representation_family = "bspline" + def __init__( self, output_dim=UNSET, diff --git a/pretab/transformers/splines/base_spline.py b/pretab/transformers/splines/base_spline.py index 2d33207..6b6dc56 100644 --- a/pretab/transformers/splines/base_spline.py +++ b/pretab/transformers/splines/base_spline.py @@ -126,6 +126,10 @@ class BaseSplineTransformer(BasePreTabTransformer): (50, 9) """ + _representation_component_kind = "basis" + _representation_supervision = "optional" + _representation_local_support = True + def __init__( self, output_dim=UNSET, diff --git a/pretab/transformers/splines/cubic_regression.py b/pretab/transformers/splines/cubic_regression.py index 2c36e28..799a598 100644 --- a/pretab/transformers/splines/cubic_regression.py +++ b/pretab/transformers/splines/cubic_regression.py @@ -113,6 +113,9 @@ class CubicRegressionSplineTransformer(SplineBasisMixin, TransformerMixin, BaseE """ _feature_suffix_value = "cs" + _representation_family = "cubicspline" + _representation_supervision = "optional" + _representation_local_support = True def __init__( self, diff --git a/pretab/transformers/splines/i_spline.py b/pretab/transformers/splines/i_spline.py index c43fdcf..6c32265 100644 --- a/pretab/transformers/splines/i_spline.py +++ b/pretab/transformers/splines/i_spline.py @@ -39,6 +39,8 @@ class ISplineTransformer(BaseSplineTransformer): (50, 8) """ + _representation_family = "ispline" + def __init__( self, output_dim=UNSET, diff --git a/pretab/transformers/splines/m_spline.py b/pretab/transformers/splines/m_spline.py index 2e7f2a2..0198860 100644 --- a/pretab/transformers/splines/m_spline.py +++ b/pretab/transformers/splines/m_spline.py @@ -37,6 +37,8 @@ class MSplineTransformer(BaseSplineTransformer): (50, 8) """ + _representation_family = "mspline" + def __init__( self, output_dim=UNSET, diff --git a/pretab/transformers/splines/multivariate/tensor_product.py b/pretab/transformers/splines/multivariate/tensor_product.py index dc10f17..bd1a0e7 100644 --- a/pretab/transformers/splines/multivariate/tensor_product.py +++ b/pretab/transformers/splines/multivariate/tensor_product.py @@ -136,6 +136,10 @@ class TensorProductSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEst 36 """ + _representation_family = "tensorspline" + _representation_scope = "multivariate" + _representation_local_support = True + def __init__( self, output_dim=UNSET, diff --git a/pretab/transformers/splines/multivariate/thin_plate.py b/pretab/transformers/splines/multivariate/thin_plate.py index b30c6b8..760edf0 100644 --- a/pretab/transformers/splines/multivariate/thin_plate.py +++ b/pretab/transformers/splines/multivariate/thin_plate.py @@ -96,6 +96,8 @@ class ThinPlateSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEstimat """ _feature_suffix_value = "tps" + _representation_family = "thinplate" + _representation_scope = "multivariate" def __init__( self, diff --git a/pretab/transformers/splines/natural_cubic.py b/pretab/transformers/splines/natural_cubic.py index 170152d..d50f050 100644 --- a/pretab/transformers/splines/natural_cubic.py +++ b/pretab/transformers/splines/natural_cubic.py @@ -117,6 +117,9 @@ class NaturalCubicSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEsti """ _feature_suffix_value = "ncs" + _representation_family = "naturalspline" + _representation_supervision = "optional" + _representation_local_support = True def __init__( self, diff --git a/pretab/transformers/splines/p_spline.py b/pretab/transformers/splines/p_spline.py index 6daea5d..022047f 100644 --- a/pretab/transformers/splines/p_spline.py +++ b/pretab/transformers/splines/p_spline.py @@ -125,6 +125,8 @@ class PSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEstimator): """ _feature_suffix_value = "ps" + _representation_family = "pspline" + _representation_local_support = True def __init__( self, diff --git a/tests/test_feature_lineage.py b/tests/test_feature_lineage.py new file mode 100644 index 0000000..26432f4 --- /dev/null +++ b/tests/test_feature_lineage.py @@ -0,0 +1,129 @@ +import warnings + +import numpy as np +import pandas as pd +import pytest + +from pretab import Preprocessor +from pretab.core.representation import FeatureLineage + + +@pytest.fixture +def mixed_frame(): + rng = np.random.default_rng(0) + n = 120 + df = pd.DataFrame( + { + "age": rng.uniform(18, 80, n), + "income": rng.uniform(1000, 9000, n), + "score": rng.uniform(0, 1, n), + "hour": rng.integers(0, 24, n).astype(float), + "city": rng.choice(["ny", "sf", "la"], n), + "tier": rng.choice(["a", "b"], n), + } + ) + y = (df["income"] / 1000 + rng.normal(0, 1, n)).to_numpy() + return df, y + + +def _fit(df, y, **kwargs): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + pre = Preprocessor(**kwargs) + pre.fit(df, y) + return pre + + +def test_lineage_covers_all_output_columns_default(mixed_frame): + df, y = mixed_frame + pre = _fit(df, y) + names = list(pre.get_feature_names_out()) + lineage = pre.get_feature_lineage() + assert len(lineage) == len(names) + assert [record.output_feature for record in lineage] == names + assert [record.output_index for record in lineage] == list(range(len(names))) + + +def test_lineage_records_are_complete(mixed_frame): + df, y = mixed_frame + pre = _fit( + df, + y, + feature_preprocessing={ + "age": "bspline", + "income": "standardization", + "score": "ple", + "hour": "rbf", + "city": "one-hot", + "tier": "int", + }, + ) + lineage = pre.get_feature_lineage() + for record in lineage: + assert isinstance(record, FeatureLineage) + assert record.source_features + assert all(isinstance(source, str) for source in record.source_features) + assert record.family + assert record.component + assert record.component_index >= 0 + + +def test_lineage_marks_supervised_representation(mixed_frame): + df, y = mixed_frame + pre = _fit(df, y, feature_preprocessing={"score": "ple"}) + ple_records = [record for record in pre.get_feature_lineage() if record.family == "piecewise_linear"] + assert ple_records + assert all(record.uses_target for record in ple_records) + + +def test_lineage_families_reflect_methods(mixed_frame): + df, y = mixed_frame + pre = _fit( + df, + y, + feature_preprocessing={ + "age": "bspline", + "income": "standardization", + "score": "ple", + "hour": "rbf", + "city": "one-hot", + "tier": "int", + }, + ) + families_by_source = {} + for record in pre.get_feature_lineage(): + families_by_source.setdefault(record.source_features, set()).update([record.family]) + assert families_by_source[("age",)] == {"bspline"} + assert families_by_source[("income",)] == {"standardization"} + assert families_by_source[("score",)] == {"piecewise_linear"} + assert families_by_source[("hour",)] == {"rbf"} + assert families_by_source[("city",)] == {"onehot"} + assert families_by_source[("tier",)] == {"ordinal"} + + +def test_lineage_round_trips_through_dict(mixed_frame): + df, y = mixed_frame + pre = _fit(df, y) + for record in pre.get_feature_lineage(): + data = record.to_dict() + rebuilt = FeatureLineage( + output_feature=data["output_feature"], + output_index=data["output_index"], + source_features=tuple(data["source_features"]), + family=data["family"], + component=data["component"], + component_index=data["component_index"], + uses_target=data["uses_target"], + is_interaction=data["is_interaction"], + ) + assert rebuilt == record + + +def test_lineage_source_features_are_single_input_per_block(mixed_frame): + df, y = mixed_frame + pre = _fit(df, y) + for record in pre.get_feature_lineage(): + # The preprocessor expands each column independently, so every output + # column traces back to exactly one source feature. + assert len(record.source_features) == 1 + assert not record.is_interaction diff --git a/tests/test_representation_spec.py b/tests/test_representation_spec.py new file mode 100644 index 0000000..592a098 --- /dev/null +++ b/tests/test_representation_spec.py @@ -0,0 +1,236 @@ +import warnings + +import numpy as np +import pytest + +from pretab.core.representation import RepresentationSpec, RepresentationSpecMixin +from pretab.transformers import ( + BSplineTransformer, + ContinuousOrdinalTransformer, + CubicRegressionSplineTransformer, + FourierFeatureTransformer, + ISplineTransformer, + MSplineTransformer, + NaturalCubicSplineTransformer, + NumericBinningTransformer, + NystroemFeaturesTransformer, + OneHotFromOrdinalTransformer, + PeriodicEncodingTransformer, + PLETransformer, + PSplineTransformer, + RandomFourierFeaturesTransformer, + RBFExpansionTransformer, + ReLUExpansionTransformer, + SigmoidExpansionTransformer, + TanhExpansionTransformer, + TensorProductSplineTransformer, + ThinPlateSplineTransformer, +) + +RNG = np.random.default_rng(0) +X_UNI = np.linspace(0.1, 5.0, 80).reshape(-1, 1) +X_MULTI = RNG.uniform(0.0, 1.0, size=(80, 2)) +X_PERIODIC = RNG.uniform(0.0, 24.0, size=(80, 1)) +X_CAT = np.array([["a"], ["b"], ["a"], ["c"]] * 20, dtype=object) +X_ORDINAL = np.array([[0], [1], [2], [1]] * 20) +Y = RNG.uniform(0.0, 1.0, size=80) + + +def _fit(transformer, X, y=None): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + return transformer.fit(X, y) + + +# (id, transformer, X, y, expected_family, expected_scope, expected_supervision) +CASES = [ + ("bspline", BSplineTransformer(output_dim=6), X_UNI, None, "bspline", "univariate", "optional"), + ("mspline", MSplineTransformer(output_dim=6), X_UNI, None, "mspline", "univariate", "optional"), + ("ispline", ISplineTransformer(output_dim=6), X_UNI, None, "ispline", "univariate", "optional"), + ( + "naturalspline", + NaturalCubicSplineTransformer(output_dim=5), + X_UNI, + None, + "naturalspline", + "univariate", + "optional", + ), + ( + "cubicspline", + CubicRegressionSplineTransformer(output_dim=5), + X_UNI, + None, + "cubicspline", + "univariate", + "optional", + ), + ("pspline", PSplineTransformer(output_dim=8), X_UNI, None, "pspline", "univariate", "unsupervised"), + ( + "tensorspline", + TensorProductSplineTransformer(output_dim=4), + X_MULTI, + None, + "tensorspline", + "multivariate", + "unsupervised", + ), + ( + "thinplate", + ThinPlateSplineTransformer(n_components=6), + X_MULTI, + None, + "thinplate", + "multivariate", + "unsupervised", + ), + ("rbf", RBFExpansionTransformer(output_dim=5), X_UNI, None, "rbf", "univariate", "optional"), + ("relu", ReLUExpansionTransformer(output_dim=5), X_UNI, None, "relu", "univariate", "optional"), + ("sigmoid", SigmoidExpansionTransformer(output_dim=5), X_UNI, None, "sigmoid", "univariate", "optional"), + ("tanh", TanhExpansionTransformer(output_dim=5), X_UNI, None, "tanh", "univariate", "optional"), + ( + "fourier", + FourierFeatureTransformer(n_frequencies=4), + X_UNI, + None, + "fourier", + "univariate", + "unsupervised", + ), + ( + "random_fourier", + RandomFourierFeaturesTransformer(n_components=10, random_state=0), + X_MULTI, + None, + "random_fourier", + "multivariate", + "unsupervised", + ), + ( + "nystroem", + NystroemFeaturesTransformer(n_components=8, random_state=0), + X_MULTI, + None, + "nystroem", + "multivariate", + "unsupervised", + ), + ( + "periodic", + PeriodicEncodingTransformer(period=24, harmonics=2), + X_PERIODIC, + None, + "periodic", + "univariate", + "unsupervised", + ), + ( + "binning", + NumericBinningTransformer(output_dim=4, encode="onehot"), + X_UNI, + None, + "binning", + "univariate", + "unsupervised", + ), + ( + "piecewise_linear", + PLETransformer(output_dim=4), + X_UNI, + Y, + "piecewise_linear", + "univariate", + "supervised", + ), + ("ordinal", ContinuousOrdinalTransformer(), X_CAT, None, "ordinal", "univariate", "unsupervised"), + ( + "onehot", + OneHotFromOrdinalTransformer(), + X_ORDINAL, + None, + "onehot", + "univariate", + "unsupervised", + ), +] +CASE_IDS = [case[0] for case in CASES] + + +@pytest.mark.parametrize( + ("transformer", "X", "y", "family", "scope", "supervision"), + [case[1:] for case in CASES], + ids=CASE_IDS, +) +def test_get_representation_spec_metadata(transformer, X, y, family, scope, supervision): + _fit(transformer, X, y) + spec = transformer.get_representation_spec() + assert isinstance(spec, RepresentationSpec) + assert spec.family == family + assert spec.scope == scope + assert spec.supervision == supervision + assert spec.is_interaction == (scope == "multivariate") + + +@pytest.mark.parametrize( + ("transformer", "X", "y"), + [(case[1], case[2], case[3]) for case in CASES], + ids=CASE_IDS, +) +def test_output_features_match_get_feature_names_out(transformer, X, y): + _fit(transformer, X, y) + input_features = [f"col{i}" for i in range(transformer.n_features_in_)] + spec = transformer.get_representation_spec(input_features=input_features) + expected = tuple(str(name) for name in transformer.get_feature_names_out(input_features)) + assert spec.output_features == expected + assert spec.output_dim == len(expected) + assert spec.input_features == tuple(input_features) + + +@pytest.mark.parametrize( + ("transformer", "X", "y"), + [(case[1], case[2], case[3]) for case in CASES], + ids=CASE_IDS, +) +def test_spec_round_trips_through_dict(transformer, X, y): + _fit(transformer, X, y) + spec = transformer.get_representation_spec() + assert RepresentationSpec.from_dict(spec.to_dict()) == spec + + +def test_every_transformer_has_representation_spec(): + for _id, transformer, *_ in CASES: + assert isinstance(transformer, RepresentationSpecMixin) + assert hasattr(transformer, "get_representation_spec") + + +def test_periodic_spec_reports_period(): + x_month = RNG.uniform(0.0, 12.0, size=(80, 1)) + transformer = _fit(PeriodicEncodingTransformer(period=12, harmonics=1), x_month) + spec = transformer.get_representation_spec() + assert spec.periodic is True + assert spec.period == 12.0 + + +def test_spline_spec_exposes_knots_and_degree(): + transformer = _fit(BSplineTransformer(output_dim=6, degree=3), X_UNI) + spec = transformer.get_representation_spec() + assert spec.degree == 3 + assert spec.location_kind == "knots" + assert spec.locations is not None + assert all(isinstance(value, float) for group in spec.locations for value in group) + + +def test_center_expansion_spec_exposes_centers(): + transformer = _fit(RBFExpansionTransformer(output_dim=5), X_UNI) + spec = transformer.get_representation_spec() + assert spec.component_kind == "center" + assert spec.location_kind == "centers" + assert spec.local_support is True + + +def test_to_dict_is_json_friendly(): + transformer = _fit(BSplineTransformer(output_dim=5), X_UNI) + data = transformer.get_representation_spec().to_dict() + assert isinstance(data["input_features"], list) + assert isinstance(data["output_features"], list) + assert data["locations"] is None or isinstance(data["locations"], list) From 637b7815689bbf2a160b3e9286b3f3e70d50dc1f Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 26 Jul 2026 18:57:46 +0200 Subject: [PATCH 14/59] feat(supervised): add leakage-safe contract, cross-fitting, and representation search --- CHANGELOG.md | 1 + pretab/__init__.py | 7 +- pretab/compose/registry.py | 10 + pretab/compose/search.py | 127 +++++++++++ pretab/core/representation.py | 52 ++++- pretab/core/supervised.py | 209 ++++++++++++++++++ pretab/exceptions.py | 6 + pretab/transformers/feature_maps/base.py | 2 + pretab/transformers/numerical/piecewise.py | 2 + pretab/transformers/splines/base_spline.py | 2 + .../transformers/splines/cubic_regression.py | 2 + pretab/transformers/splines/natural_cubic.py | 2 + tests/test_cross_fitted.py | 102 +++++++++ tests/test_supervised_contract.py | 129 +++++++++++ 14 files changed, 649 insertions(+), 4 deletions(-) create mode 100644 pretab/compose/search.py create mode 100644 pretab/core/supervised.py create mode 100644 tests/test_cross_fitted.py create mode 100644 tests/test_supervised_contract.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 45ed0f2..beafb99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Going forward, this file is updated automatically by `cz bump` on each release. ### Feat +- **supervised**: add a leakage-safe supervised contract — `requires_y` / `is_supervised` / fitted `uses_target_` on every transformer, a `LeakageWarning` when a target-aware transformer is fit on `(X, y)` outside a Pipeline / cross-validation context, a `CrossFittedTransformer` wrapper that produces out-of-fold training features (recording `cross_fitted` / `n_folds` in the spec), and a `RepresentationSearchCV` skeleton (all exported from `pretab`) - **representation**: add typed `RepresentationSpec` and per-output-column `FeatureLineage` (exported from `pretab`); every transformer family exposes `get_representation_spec()` and `Preprocessor.get_feature_lineage()` maps each output column to its source feature(s), representation family, component, and target-usage flag - **transformers**: add `FourierFeatureTransformer` (deterministic sine/cosine feature map with `harmonic` / `log_spaced` / `random` frequencies), selectable as the `"fourier"` numerical method - **transformers**: add `RandomFourierFeaturesTransformer` and `NystroemFeaturesTransformer` — standalone multivariate kernel-approximation feature maps (`"rff"` / `"nystroem"`) diff --git a/pretab/__init__.py b/pretab/__init__.py index 0c6455d..29745e6 100644 --- a/pretab/__init__.py +++ b/pretab/__init__.py @@ -1,13 +1,18 @@ from ._version import __version__ +from .compose.search import RepresentationSearchCV from .core.logging import configure_logging, set_verbosity from .core.representation import FeatureLineage, RepresentationSpec -from .exceptions import PretabWarning +from .core.supervised import CrossFittedTransformer +from .exceptions import LeakageWarning, PretabWarning from .preprocessor import Preprocessor __all__ = [ + "CrossFittedTransformer", "FeatureLineage", + "LeakageWarning", "Preprocessor", "PretabWarning", + "RepresentationSearchCV", "RepresentationSpec", "__version__", "configure_logging", diff --git a/pretab/compose/registry.py b/pretab/compose/registry.py index 8dbc391..d75306b 100644 --- a/pretab/compose/registry.py +++ b/pretab/compose/registry.py @@ -161,6 +161,16 @@ def requires_target(self) -> bool: """Whether the method always needs ``y`` for placement.""" return self.target_usage == "required" + @property + def requires_y(self) -> bool: + """Alias of :attr:`requires_target` matching the transformer contract.""" + return self.requires_target + + @property + def is_supervised(self) -> bool: + """Whether the method can consume ``y`` (optional or required).""" + return self.target_aware_capable + def _spec(name, cls, allowed_args=(), **kwargs): """Construct a :class:`TransformerSpec`, normalising ``allowed_args``.""" diff --git a/pretab/compose/search.py b/pretab/compose/search.py new file mode 100644 index 0000000..12a732c --- /dev/null +++ b/pretab/compose/search.py @@ -0,0 +1,127 @@ +"""Cross-validated search over numerical representation methods. + +``RepresentationSearchCV`` is a lightweight skeleton that, for each candidate +numerical method, builds a :class:`~pretab.preprocessor.Preprocessor` feeding a +cloned downstream ``estimator``, scores it with cross-validation, and refits the +best-scoring representation on all data. It is intentionally minimal: the search +space is the ``numerical_method`` axis, and every fold uses the preprocessor's +native array output so no target leaks across the train/validation split. +""" + +import numpy as np +from sklearn.base import BaseEstimator, clone, is_classifier +from sklearn.metrics import check_scoring +from sklearn.model_selection import check_cv +from sklearn.utils.validation import check_is_fitted + +from ..exceptions import InvalidParamError +from ..preprocessor import Preprocessor + +__all__ = ["RepresentationSearchCV"] + + +def _row_subset(data, idx): + """Return the rows of ``data`` at positions ``idx`` for arrays or frames.""" + if hasattr(data, "iloc"): + return data.iloc[idx] + return np.asarray(data)[idx] + + +class RepresentationSearchCV(BaseEstimator): + """Select the best numerical representation by cross-validation. + + Parameters + ---------- + estimator : estimator + Downstream supervised estimator fit on the transformed features. + methods : sequence of str + 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`. + scoring : str or callable or None, default=None + Scoring passed to :func:`sklearn.metrics.check_scoring`; ``None`` uses the + estimator's ``score`` method. + preprocessor_params : dict or None, default=None + Extra keyword arguments forwarded to every :class:`Preprocessor`. + random_state : int or None, default=None + Seed forwarded to each :class:`Preprocessor`. + + Attributes + ---------- + cv_results_ : dict + Mapping of method name to mean cross-validation score. + best_method_ : str + The highest-scoring numerical method. + best_score_ : float + Mean cross-validation score of ``best_method_``. + best_preprocessor_ : Preprocessor + Preprocessor for ``best_method_`` refit on all data. + best_estimator_ : estimator + Estimator refit on the best representation of all data. + """ + + def __init__(self, estimator, methods, *, cv=5, scoring=None, preprocessor_params=None, random_state=None): + self.estimator = estimator + self.methods = methods + self.cv = cv + self.scoring = scoring + self.preprocessor_params = preprocessor_params + self.random_state = random_state + + def _make_preprocessor(self, method): + """Build a Preprocessor for ``method`` with the shared parameters.""" + params = dict(self.preprocessor_params or {}) + params.setdefault("random_state", self.random_state) + return Preprocessor(numerical_method=method, **params) + + def fit(self, X, y=None): + """Search over ``methods`` and refit the best representation on all data.""" + 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() + n_samples = X.shape[0] if hasattr(X, "shape") else len(X) + cv = check_cv(self.cv, y_arr, classifier=is_classifier(self.estimator)) + + cv_results: dict[str, float] = {} + best_score = -np.inf + best_method = methods[0] + for method in methods: + fold_scores = [] + for train_idx, test_idx in cv.split(np.zeros(n_samples), y_arr): + pre = self._make_preprocessor(method) + est = 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]) + scorer = 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])) + mean_score = float(np.mean(fold_scores)) + cv_results[method] = mean_score + if mean_score > best_score: + best_score = mean_score + best_method = method + + self.cv_results_ = cv_results + self.best_method_ = best_method + 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_ = clone(self.estimator).fit(x_all, y_arr) + return self + + 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) + return self.best_estimator_.predict(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) + scorer = check_scoring(self.best_estimator_, scoring=self.scoring) + return scorer(self.best_estimator_, x, np.asarray(y).ravel()) diff --git a/pretab/core/representation.py b/pretab/core/representation.py index 288497a..b569acc 100644 --- a/pretab/core/representation.py +++ b/pretab/core/representation.py @@ -68,6 +68,11 @@ class RepresentationSpec: feature (or per landmark row for multivariate bases). dtype : str Output dtype of the transformed array. + cross_fitted : bool + Whether the representation was produced with out-of-fold cross-fitting + (see :class:`~pretab.core.supervised.CrossFittedTransformer`). + n_folds : int or None + Number of cross-fitting folds when ``cross_fitted`` is True. """ family: str @@ -87,6 +92,8 @@ class RepresentationSpec: location_kind: str | None locations: tuple[tuple[float, ...], ...] | None dtype: str = "float64" + cross_fitted: bool = False + n_folds: int | None = None def to_dict(self) -> dict: """Return a JSON-serializable dictionary representation.""" @@ -110,12 +117,15 @@ def to_dict(self) -> dict: None if self.locations is None else [list(group) for group in self.locations] ), "dtype": self.dtype, + "cross_fitted": self.cross_fitted, + "n_folds": self.n_folds, } @classmethod def from_dict(cls, data: dict) -> "RepresentationSpec": """Reconstruct a ``RepresentationSpec`` from :meth:`to_dict` output.""" locations = data.get("locations") + n_folds = data.get("n_folds") return cls( family=data["family"], component_kind=data["component_kind"], @@ -138,6 +148,8 @@ def from_dict(cls, data: dict) -> "RepresentationSpec": else tuple(tuple(float(v) for v in group) for group in locations) ), dtype=data.get("dtype", "float64"), + cross_fitted=bool(data.get("cross_fitted", False)), + n_folds=None if n_folds is None else int(n_folds), ) @@ -223,11 +235,42 @@ class RepresentationSpecMixin: ("landmarks_", "landmarks"), ) + @property + def requires_y(self) -> bool: + """Whether this transformer mandates ``y`` at fit time. + + ``True`` for inherently supervised representations (e.g. PLE); ``False`` + for unsupervised and optionally target-aware families. + """ + return self._representation_supervision == "supervised" + + @property + def is_supervised(self) -> bool: + """Whether this transformer consumes ``y`` given its configuration. + + ``True`` when the target is mandatory (:attr:`requires_y`) or when an + optionally target-aware family has ``target_aware=True``. + """ + return self.requires_y or bool(getattr(self, "target_aware", False)) + + @property + def uses_target_(self) -> bool: + """Fitted flag: whether the last ``fit`` consumed the target ``y``. + + Available only after ``fit``. Because target-aware placement requires + ``y`` at fit time (a supervised fit without ``y`` raises), a fitted + supervised transformer always reports ``True``. + """ + check_is_fitted(self, "n_features_in_") + return self.is_supervised + def _representation_uses_target(self) -> bool: """Return whether the fitted transformer consumed the target.""" - if self._representation_supervision == "supervised": - return True - return bool(getattr(self, "target_aware", False)) + return self.is_supervised + + def _representation_cross_fitting(self) -> tuple[bool, int | None]: + """Return ``(cross_fitted, n_folds)`` for the representation.""" + return False, None def _representation_degree(self) -> int | None: """Return the polynomial / spline degree, if the transformer has one.""" @@ -269,6 +312,7 @@ def get_representation_spec(self, input_features=None) -> RepresentationSpec: output_features = tuple(str(name) for name in self.get_feature_names_out(list(inputs))) location_kind, locations = self._representation_locations() periodic, period = self._representation_periodic() + cross_fitted, n_folds = self._representation_cross_fitting() scope = self._representation_scope return RepresentationSpec( family=self._representation_family, @@ -288,4 +332,6 @@ def get_representation_spec(self, input_features=None) -> RepresentationSpec: location_kind=location_kind, locations=locations, dtype="float64", + cross_fitted=cross_fitted, + n_folds=n_folds, ) diff --git a/pretab/core/supervised.py b/pretab/core/supervised.py new file mode 100644 index 0000000..735c48a --- /dev/null +++ b/pretab/core/supervised.py @@ -0,0 +1,209 @@ +"""Leakage-safe supervised contract: warning helper and cross-fitting wrapper. + +Supervised (target-aware) representations place their basis using ``y``. Fitting +such a transformer on the full training data and then transforming that same +data leaks target information into the features. This module provides: + +* :func:`warn_target_leakage` -- emits a :class:`~pretab.exceptions.LeakageWarning` + when a supervised transformer is fit on ``(X, y)`` outside a controlled + (Pipeline / cross-validation / cross-fitting) context. +* :class:`CrossFittedTransformer` -- wraps a supervised transformer and produces + out-of-fold features during ``fit_transform`` so the training representation + carries no target leakage, while ``transform`` uses a model fit on all data. +""" + +import contextvars +import sys +import warnings +from dataclasses import replace + +import numpy as np +from sklearn.base import BaseEstimator, TransformerMixin, clone +from sklearn.model_selection import KFold, StratifiedKFold +from sklearn.utils.validation import check_is_fitted + +from ..exceptions import ( + IncompatibleParamsError, + InvalidParamError, + LeakageWarning, + PretabDataError, +) +from .representation import RepresentationSpecMixin + +__all__ = ["CrossFittedTransformer", "in_controlled_context", "warn_target_leakage"] + +# Module prefixes whose presence on the call stack marks a controlled context in +# which fitting a supervised transformer on ``(X, y)`` is expected and safe. +_CONTROLLED_MODULE_PREFIXES = ( + "sklearn.pipeline", + "sklearn.model_selection", + "sklearn.compose", + "pretab.preprocessor", + "pretab.compose", +) + +# Set while :class:`CrossFittedTransformer` fits its internal clones, so their +# fits never emit a leakage warning. +_cross_fit_active: contextvars.ContextVar[bool] = contextvars.ContextVar( + "pretab_cross_fit_active", default=False +) + + +def in_controlled_context() -> bool: + """Return True when a Pipeline / CV / cross-fitting context is on the stack.""" + if _cross_fit_active.get(): + return True + frame = sys._getframe(1) + while frame is not None: + module = frame.f_globals.get("__name__", "") + if module.startswith(_CONTROLLED_MODULE_PREFIXES): + return True + frame = frame.f_back + return False + + +def warn_target_leakage(estimator, y) -> None: + """Warn if a supervised ``estimator`` is fit on ``y`` outside a safe context. + + No warning is emitted when ``y`` is ``None``, when the estimator does not + consume the target (``is_supervised`` is False), or when a controlled + context (Pipeline, cross-validation, or :class:`CrossFittedTransformer`) is + detected on the call stack. + """ + if y is None: + return + if not getattr(estimator, "is_supervised", False): + return + if in_controlled_context(): + return + warnings.warn( + f"{type(estimator).__name__} is target-aware and was fit on (X, y) outside a " + "Pipeline / cross-validation context, which can leak target information into " + "the features. Fit it inside a scikit-learn Pipeline, wrap it in " + "pretab.CrossFittedTransformer, or ignore this warning if the fitted data " + "will not be reused to train a downstream model.", + LeakageWarning, + stacklevel=3, + ) + + +class CrossFittedTransformer(RepresentationSpecMixin, TransformerMixin, BaseEstimator): + """Cross-fit a supervised transformer to remove target leakage on training data. + + During :meth:`fit_transform`, the wrapped transformer is fit on each + 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. + + Parameters + ---------- + transformer : estimator + A supervised (target-aware) PreTab transformer to cross-fit. + n_folds : int, default=5 + Number of cross-fitting folds. Must be at least 2. + task : {"regression", "classification"}, default="regression" + Controls the splitter: ``KFold`` for regression, ``StratifiedKFold`` for + classification. + shuffle : bool, default=True + Whether to shuffle before splitting. + random_state : int or None, default=None + Seed used when ``shuffle`` is True. + + Attributes + ---------- + estimator_ : estimator + The transformer fit on all of ``(X, y)``, used by :meth:`transform`. + n_features_in_ : int + Number of input features seen during ``fit``. + """ + + _representation_supervision = "supervised" + + def __init__(self, transformer, n_folds=5, task="regression", shuffle=True, random_state=None): + self.transformer = transformer + self.n_folds = n_folds + self.task = task + self.shuffle = shuffle + self.random_state = random_state + + def _make_splitter(self): + """Return the cross-fitting splitter for the configured task.""" + seed = self.random_state if self.shuffle else None + if self.task == "classification": + return StratifiedKFold(n_splits=self.n_folds, shuffle=self.shuffle, random_state=seed) + return KFold(n_splits=self.n_folds, shuffle=self.shuffle, random_state=seed) + + def _fit_full(self, X, y): + """Validate inputs and fit ``estimator_`` on all data; return arrays.""" + if y is None: + 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}.") + X_arr = np.asarray(X) + if X_arr.ndim == 1: + X_arr = X_arr.reshape(-1, 1) + y_arr = np.asarray(y).ravel() + if len(X_arr) != len(y_arr): + raise PretabDataError(f"X and y must have same length. Got {len(X_arr)} and {len(y_arr)}") + estimator = clone(self.transformer) + token = _cross_fit_active.set(True) + try: + estimator.fit(X_arr, y_arr) + finally: + _cross_fit_active.reset(token) + self.estimator_ = estimator + self.n_features_in_ = X_arr.shape[1] + return X_arr, y_arr + + def fit(self, X, y=None): + """Fit the all-data ``estimator_`` used by :meth:`transform`.""" + self._fit_full(X, y) + return self + + def transform(self, X): + """Transform ``X`` using the transformer fit on all training data.""" + check_is_fitted(self, "estimator_") + X_arr = np.asarray(X) + if X_arr.ndim == 1: + X_arr = X_arr.reshape(-1, 1) + return self.estimator_.transform(X_arr) + + def fit_transform(self, X, y=None): + """Fit and return leakage-free out-of-fold features for the training data.""" + 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) + splitter = self._make_splitter() + token = _cross_fit_active.set(True) + try: + for train_idx, test_idx in splitter.split(X_arr, y_arr): + fold = clone(self.transformer) + fold.fit(X_arr[train_idx], y_arr[train_idx]) + fold_out = np.asarray(fold.transform(X_arr[test_idx])) + 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]}. Disable adaptive sizing on the " + "wrapped transformer." + ) + out[test_idx] = fold_out + finally: + _cross_fit_active.reset(token) + return out + + def get_feature_names_out(self, input_features=None): + """Delegate output feature names to the all-data ``estimator_``.""" + check_is_fitted(self, "estimator_") + return self.estimator_.get_feature_names_out(input_features) + + def get_representation_spec(self, input_features=None): + """Return the wrapped spec, flagged as cross-fitted.""" + check_is_fitted(self, "estimator_") + if hasattr(self.estimator_, "get_representation_spec"): + base = self.estimator_.get_representation_spec(input_features) + return replace(base, uses_target=True, cross_fitted=True, n_folds=int(self.n_folds)) + return super().get_representation_spec(input_features) + + def _representation_cross_fitting(self): + """Report cross-fitting metadata for the spec fallback path.""" + return True, int(self.n_folds) diff --git a/pretab/exceptions.py b/pretab/exceptions.py index fdd6033..5582ee5 100644 --- a/pretab/exceptions.py +++ b/pretab/exceptions.py @@ -20,6 +20,7 @@ "IncompatibleParamsError", "InsufficientSamplesError", "InvalidParamError", + "LeakageWarning", "OptionalDependencyError", "PretabConfigError", "PretabDataError", @@ -44,6 +45,11 @@ class ConfigWarning(PretabWarning): """Configuration fallback or deprecation notice.""" +class LeakageWarning(PretabWarning): + """Potential target leakage: a supervised transformer fit outside a + cross-fitting / Pipeline / cross-validation context.""" + + # --- Error hierarchy --- class PretabError(Exception): """Base class for every error raised by PreTab.""" diff --git a/pretab/transformers/feature_maps/base.py b/pretab/transformers/feature_maps/base.py index 0a5f3e2..8546489 100644 --- a/pretab/transformers/feature_maps/base.py +++ b/pretab/transformers/feature_maps/base.py @@ -14,6 +14,7 @@ from ...core.base import BasePreTabTransformer from ...core.parameters import UNSET, validate_placement +from ...core.supervised import warn_target_leakage from ...exceptions import ( IncompatibleParamsError, InvalidParamError, @@ -80,6 +81,7 @@ def _expand_column(self, x_col, centers): def fit(self, X, y=None): """Place per-feature centers from a target-aware selector or quantile/uniform spacing.""" + 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"): diff --git a/pretab/transformers/numerical/piecewise.py b/pretab/transformers/numerical/piecewise.py index 8fd0651..fcbe3c7 100644 --- a/pretab/transformers/numerical/piecewise.py +++ b/pretab/transformers/numerical/piecewise.py @@ -15,6 +15,7 @@ from ...core.adaptive import AdaptiveResolutionMixin from ...core.parameters import UNSET, AliasResolverMixin from ...core.representation import RepresentationSpecMixin +from ...core.supervised import warn_target_leakage from ...exceptions import ( IncompatibleParamsError, InvalidParamError, @@ -160,6 +161,7 @@ def fit(self, X, y=None): self : PLETransformer The fitted transformer. """ + warn_target_leakage(self, y) if y is None: raise IncompatibleParamsError( "PLETransformer is always target-aware and requires y at fit time; got y=None." diff --git a/pretab/transformers/splines/base_spline.py b/pretab/transformers/splines/base_spline.py index 6b6dc56..9098d47 100644 --- a/pretab/transformers/splines/base_spline.py +++ b/pretab/transformers/splines/base_spline.py @@ -28,6 +28,7 @@ uniform_knots, ) from ...core.parameters import UNSET, validate_placement +from ...core.supervised import warn_target_leakage from ...exceptions import ( IncompatibleParamsError, InvalidParamError, @@ -251,6 +252,7 @@ def _column_knots( def fit(self, X, y=None): """Determine per-feature knot vectors.""" + warn_target_leakage(self, y) validate_placement(self.target_aware, self.placement_strategy) n_basis = self._resolve_param("output_dim", default=6) min_basis_req = self._resolve_param("min_output_dim", default=None) diff --git a/pretab/transformers/splines/cubic_regression.py b/pretab/transformers/splines/cubic_regression.py index 799a598..252d3f3 100644 --- a/pretab/transformers/splines/cubic_regression.py +++ b/pretab/transformers/splines/cubic_regression.py @@ -3,6 +3,7 @@ from sklearn.utils.validation import check_is_fitted from ...core.parameters import UNSET, validate_placement +from ...core.supervised import warn_target_leakage from ...exceptions import InvalidParamError from ...placement.adapters import SplinePlacementAdapter from .mixins import SplineBasisMixin @@ -156,6 +157,7 @@ def _bspline_basis(self, x, knots): return np.hstack(X) def fit(self, X, y=None): + warn_target_leakage(self, y) validate_placement(self.target_aware, self.placement_strategy) X = self._validate_allow_nan(X, reset=True) output_dim = self._resolve_param("output_dim", default=6) diff --git a/pretab/transformers/splines/natural_cubic.py b/pretab/transformers/splines/natural_cubic.py index d50f050..adb42a5 100644 --- a/pretab/transformers/splines/natural_cubic.py +++ b/pretab/transformers/splines/natural_cubic.py @@ -3,6 +3,7 @@ from sklearn.utils.validation import check_is_fitted from ...core.parameters import UNSET, validate_placement +from ...core.supervised import warn_target_leakage from ...exceptions import InvalidParamError from ...placement.adapters import SplinePlacementAdapter from .mixins import SplineBasisMixin @@ -166,6 +167,7 @@ def d(k): return np.hstack(basis) def fit(self, X, y=None): + warn_target_leakage(self, y) validate_placement(self.target_aware, self.placement_strategy) X = self._validate_allow_nan(X, reset=True) output_dim = self._resolve_param("output_dim", default=6) diff --git a/tests/test_cross_fitted.py b/tests/test_cross_fitted.py new file mode 100644 index 0000000..b7cfd09 --- /dev/null +++ b/tests/test_cross_fitted.py @@ -0,0 +1,102 @@ +"""Tests for :class:`~pretab.core.supervised.CrossFittedTransformer` (Phase 7, P7.3). + +Verifies out-of-fold (leakage-free) training features, the all-data model used by +``transform``, spec bookkeeping (``cross_fitted`` / ``n_folds``), and input +validation. +""" + +import warnings + +import numpy as np +import pytest +from sklearn.model_selection import KFold + +from pretab import CrossFittedTransformer, LeakageWarning +from pretab.exceptions import IncompatibleParamsError, InvalidParamError +from pretab.transformers import PLETransformer + + +@pytest.fixture +def data(): + rng = np.random.default_rng(42) + X = rng.normal(size=(400, 1)) + y = (X[:, 0] > 0).astype(float) + rng.normal(scale=0.1, size=400) + return X, y + + +def test_fit_transform_is_out_of_fold(data): + """Each training row is encoded by a fold model that never saw it.""" + X, y = data + cf = CrossFittedTransformer(PLETransformer(output_dim=8), n_folds=5, shuffle=True, random_state=0) + Xt = cf.fit_transform(X, y) + + assert Xt.shape == (X.shape[0], 8) + splitter = KFold(n_splits=5, shuffle=True, random_state=0) + for train_idx, test_idx in splitter.split(X): + fold = PLETransformer(output_dim=8).fit(X[train_idx], y[train_idx]) + expected = fold.transform(X[test_idx]) + np.testing.assert_allclose(Xt[test_idx], expected) + + +def test_cross_fitting_emits_no_leakage_warning(data): + X, y = data + cf = CrossFittedTransformer(PLETransformer(output_dim=6), n_folds=4, random_state=0) + with warnings.catch_warnings(): + warnings.simplefilter("error", LeakageWarning) + cf.fit_transform(X, y) + + +def test_transform_uses_all_data_model(data): + """``transform`` on unseen data uses ``estimator_`` fit on all training data.""" + X, y = data + cf = CrossFittedTransformer(PLETransformer(output_dim=6), n_folds=4, random_state=0) + cf.fit(X, y) + + reference = PLETransformer(output_dim=6).fit(X, y) + X_new = np.linspace(-2, 2, 25).reshape(-1, 1) + np.testing.assert_allclose(cf.transform(X_new), reference.transform(X_new)) + + +def test_spec_records_cross_fitting(data): + X, y = data + cf = CrossFittedTransformer(PLETransformer(output_dim=6), n_folds=5, random_state=0) + cf.fit(X, y) + spec = cf.get_representation_spec(["f0"]) + + assert spec.cross_fitted is True + assert spec.n_folds == 5 + assert spec.uses_target is True + assert spec.family == "piecewise_linear" + assert spec == type(spec).from_dict(spec.to_dict()) + + +def test_contract_properties(data): + X, y = data + cf = CrossFittedTransformer(PLETransformer(), n_folds=3) + assert cf.requires_y is True + assert cf.is_supervised is True + cf.fit(X, y) + assert cf.uses_target_ is True + + +def test_feature_names_delegate(data): + X, y = data + cf = CrossFittedTransformer(PLETransformer(output_dim=6), n_folds=3, random_state=0) + cf.fit(X, y) + reference = PLETransformer(output_dim=6).fit(X, y) + np.testing.assert_array_equal( + cf.get_feature_names_out(["f0"]), reference.get_feature_names_out(["f0"]) + ) + + +def test_requires_y(data): + X, _ = data + cf = CrossFittedTransformer(PLETransformer(), n_folds=3) + with pytest.raises(IncompatibleParamsError): + cf.fit(X, None) + + +def test_invalid_n_folds(data): + X, y = data + with pytest.raises(InvalidParamError): + CrossFittedTransformer(PLETransformer(), n_folds=1).fit(X, y) diff --git a/tests/test_supervised_contract.py b/tests/test_supervised_contract.py new file mode 100644 index 0000000..32ea18d --- /dev/null +++ b/tests/test_supervised_contract.py @@ -0,0 +1,129 @@ +"""Tests for the leakage-safe supervised contract (Phase 7, P7.1 + P7.2). + +Covers the transformer contract properties (``requires_y`` / ``is_supervised`` / +``uses_target_``) and the :class:`~pretab.exceptions.LeakageWarning` emitted when +a supervised transformer is fit on ``(X, y)`` outside a controlled context. +""" + +import warnings + +import numpy as np +import pytest +from sklearn.linear_model import Ridge +from sklearn.pipeline import Pipeline + +from pretab import LeakageWarning, Preprocessor +from pretab.compose.registry import get_spec +from pretab.core.supervised import in_controlled_context, warn_target_leakage +from pretab.transformers import ( + BSplineTransformer, + PLETransformer, + RBFExpansionTransformer, +) + + +@pytest.fixture +def data(): + rng = np.random.default_rng(0) + X = rng.normal(size=(200, 1)) + y = (X[:, 0] > 0).astype(float) + rng.normal(scale=0.1, size=200) + return X, y + + +# --- P7.1: contract properties --------------------------------------------- + + +def test_ple_is_always_supervised(data): + X, y = data + ple = PLETransformer() + assert ple.requires_y is True + assert ple.is_supervised is True + ple.fit(X, y) + assert ple.uses_target_ is True + + +def test_unsupervised_spline_reports_not_supervised(data): + X, _ = data + spline = BSplineTransformer(target_aware=False) + assert spline.requires_y is False + assert spline.is_supervised is False + spline.fit(X) + assert spline.uses_target_ is False + + +def test_optional_transformer_flips_with_target_aware(data): + X, y = data + rbf_off = RBFExpansionTransformer(target_aware=False) + assert rbf_off.requires_y is False + assert rbf_off.is_supervised is False + + rbf_on = RBFExpansionTransformer(target_aware=True) + assert rbf_on.requires_y is False + assert rbf_on.is_supervised is True + rbf_on.fit(X, y) + assert rbf_on.uses_target_ is True + + +def test_registry_supervised_flags(): + assert get_spec("ple").requires_y is True + assert get_spec("ple").is_supervised is True + assert get_spec("rbf").requires_y is False + assert get_spec("rbf").is_supervised is True + assert get_spec("standardization").is_supervised is False + + +# --- P7.2: leakage warning -------------------------------------------------- + + +def test_direct_supervised_fit_warns(data): + X, y = data + with pytest.warns(LeakageWarning): + PLETransformer().fit(X, y) + + +def test_target_aware_spline_direct_fit_warns(data): + X, y = data + with pytest.warns(LeakageWarning): + BSplineTransformer(target_aware=True, placement_strategy="cart").fit(X, y) + + +def test_unsupervised_fit_does_not_warn(data): + X, _ = data + with warnings.catch_warnings(): + warnings.simplefilter("error", LeakageWarning) + BSplineTransformer(target_aware=False).fit(X) + + +def test_no_warning_without_target(data): + rbf = RBFExpansionTransformer(target_aware=True) + with warnings.catch_warnings(): + warnings.simplefilter("error", LeakageWarning) + warn_target_leakage(rbf, None) + + +def test_no_warning_inside_pipeline(data): + X, y = data + pipe = Pipeline([("ple", PLETransformer()), ("ridge", Ridge())]) + with warnings.catch_warnings(): + warnings.simplefilter("error", LeakageWarning) + pipe.fit(X, y) + + +def test_no_warning_inside_preprocessor(data): + X, y = data + pre = Preprocessor(numerical_method="ple", target_aware=True) + with warnings.catch_warnings(): + warnings.simplefilter("error", LeakageWarning) + pre.fit_transform(X, y) + + +def test_in_controlled_context_default_false(): + assert in_controlled_context() is False + + +def test_warn_helper_ignores_unsupervised(data): + _, y = data + est = BSplineTransformer(target_aware=False) + with warnings.catch_warnings(): + warnings.simplefilter("error", LeakageWarning) + warn_target_leakage(est, y) From 54af4afaefa06ba32f523139af2e615bc611163d Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 26 Jul 2026 19:39:36 +0200 Subject: [PATCH 15/59] feat(policy): add RepresentationPolicy and edge-case contract --- CHANGELOG.md | 1 + pretab/__init__.py | 2 + pretab/core/base.py | 29 ++- pretab/core/policy.py | 167 +++++++++++++ pretab/preprocessor.py | 15 ++ pretab/transformers/feature_maps/base.py | 6 +- pretab/transformers/numerical/binning.py | 11 + pretab/transformers/splines/base_spline.py | 5 + pretab/transformers/splines/mixins.py | 31 ++- tests/test_edge_case_contract.py | 263 +++++++++++++++++++++ tests/test_preprocessor.py | 1 + 11 files changed, 525 insertions(+), 6 deletions(-) create mode 100644 pretab/core/policy.py create mode 100644 tests/test_edge_case_contract.py diff --git a/CHANGELOG.md b/CHANGELOG.md index beafb99..1090164 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Going forward, this file is updated automatically by `cz bump` on each release. ### Feat +- **policy**: add a central `RepresentationPolicy(missing, constant, out_of_range, invalid)` (exported from `pretab`) and a `Preprocessor(policy=...)` hook (resolved to `policy_` at fit) governing constant-column, out-of-range, and non-finite handling; defaults reproduce historical behaviour. Pin the per-family edge-case contract (constant column, all-missing, partial-missing propagation, tiny n, duplicate support points, out-of-range, infinity, feature-count mismatch) in `tests/test_edge_case_contract.py`, and fix silent-corruption gaps so every spline family raises a typed `PretabDataError` on a constant or all-missing column (and cleanly propagates partial-missing rows), feature maps reject all-missing columns, and `NumericBinningTransformer` rejects non-finite input - **supervised**: add a leakage-safe supervised contract — `requires_y` / `is_supervised` / fitted `uses_target_` on every transformer, a `LeakageWarning` when a target-aware transformer is fit on `(X, y)` outside a Pipeline / cross-validation context, a `CrossFittedTransformer` wrapper that produces out-of-fold training features (recording `cross_fitted` / `n_folds` in the spec), and a `RepresentationSearchCV` skeleton (all exported from `pretab`) - **representation**: add typed `RepresentationSpec` and per-output-column `FeatureLineage` (exported from `pretab`); every transformer family exposes `get_representation_spec()` and `Preprocessor.get_feature_lineage()` maps each output column to its source feature(s), representation family, component, and target-usage flag - **transformers**: add `FourierFeatureTransformer` (deterministic sine/cosine feature map with `harmonic` / `log_spaced` / `random` frequencies), selectable as the `"fourier"` numerical method diff --git a/pretab/__init__.py b/pretab/__init__.py index 29745e6..6c9267f 100644 --- a/pretab/__init__.py +++ b/pretab/__init__.py @@ -1,6 +1,7 @@ from ._version import __version__ from .compose.search import RepresentationSearchCV from .core.logging import configure_logging, set_verbosity +from .core.policy import RepresentationPolicy from .core.representation import FeatureLineage, RepresentationSpec from .core.supervised import CrossFittedTransformer from .exceptions import LeakageWarning, PretabWarning @@ -12,6 +13,7 @@ "LeakageWarning", "Preprocessor", "PretabWarning", + "RepresentationPolicy", "RepresentationSearchCV", "RepresentationSpec", "__version__", diff --git a/pretab/core/base.py b/pretab/core/base.py index e58a1d7..fc87c87 100644 --- a/pretab/core/base.py +++ b/pretab/core/base.py @@ -12,6 +12,7 @@ from .adaptive import AdaptiveResolutionMixin from .parameters import AliasResolverMixin +from .policy import RepresentationPolicy, apply_constant_policy from .representation import RepresentationSpecMixin from .validation import validate_2d_allow_nan @@ -28,17 +29,43 @@ class BasePreTabTransformer( input feature contributes) to get automatic ``get_feature_names_out`` support. Transformers whose output is not a simple per-feature concatenation can override ``get_feature_names_out`` directly. + + Edge-case behaviour is governed by a shared :class:`RepresentationPolicy` + (``_policy``). A family narrows individual axes through the ``_constant_policy`` + / ``_out_of_range_policy`` / ``_duplicate_policy`` class attributes (``None`` + means "inherit the shared policy"); :meth:`_resolved_policy` merges them. """ _allow_nan: bool = True _requires_y: bool = False _feature_suffix_value: str = "f" + #: Shared, central edge-case policy (decision D9). Its defaults reproduce the + #: library's historical behaviour, so it is inert until narrowed. + _policy: RepresentationPolicy = RepresentationPolicy() + + #: Per-family policy overrides; ``None`` inherits the corresponding ``_policy`` + #: axis. ``_duplicate_policy`` records how repeated knots/centers are handled. + _constant_policy: str | None = None + _out_of_range_policy: str | None = None + _duplicate_policy: str = "dedupe" + n_features_in_: int + def _resolved_policy(self) -> RepresentationPolicy: + """Return the shared policy narrowed by this family's override attributes.""" + return self._policy.merge( + constant=self._constant_policy, + out_of_range=self._out_of_range_policy, + ) + def _validate(self, X, *, reset: bool): """Validate ``X`` through the shared NaN-aware validator.""" - return validate_2d_allow_nan(X, allow_nan=self._allow_nan, reset=reset, estimator=self) + X = validate_2d_allow_nan(X, allow_nan=self._allow_nan, reset=reset, estimator=self) + if reset: + apply_constant_policy(X, self._resolved_policy(), estimator=self) + return X + def _feature_suffix(self) -> str: """Suffix used when generating output feature names.""" diff --git a/pretab/core/policy.py b/pretab/core/policy.py new file mode 100644 index 0000000..f31262f --- /dev/null +++ b/pretab/core/policy.py @@ -0,0 +1,167 @@ +"""Central, explicit edge-case policy for representations (decision D9). + +:class:`RepresentationPolicy` names, in one place, how every transformer reacts +to the recurring edge cases that would otherwise diverge silently per family: + +* ``missing`` -- how ``NaN`` inputs are treated (``"error"`` / ``"propagate"``). +* ``constant`` -- zero-variance input columns (``"error"`` / ``"warn"`` / ``"allow"``). +* ``out_of_range`` -- values at ``transform`` outside the fitted range + (``"error"`` / ``"warn"`` / ``"clip"`` / ``"extrapolate"``). +* ``invalid`` -- non-finite ``inf`` / ``-inf`` inputs (``"error"`` / ``"propagate"``). + +The defaults reproduce the library's historical behaviour (constant columns pass +through, ranges extrapolate, non-finite values raise), so enabling the policy +object changes nothing until a stricter choice is requested. Transformers may +narrow specific axes through class-level override attributes without exposing a +new constructor parameter (see :class:`~pretab.core.base.BasePreTabTransformer`). +""" + +from __future__ import annotations + +import warnings +from dataclasses import asdict, dataclass, fields, replace + +import numpy as np + +from ..exceptions import DataWarning, PretabDataError, invalid_param_error + +__all__ = [ + "RepresentationPolicy", + "apply_constant_policy", + "find_constant_columns", + "resolve_out_of_range", +] + +_MISSING_CHOICES = ("error", "propagate") +_CONSTANT_CHOICES = ("error", "warn", "allow") +_OUT_OF_RANGE_CHOICES = ("error", "warn", "clip", "extrapolate") +_INVALID_CHOICES = ("error", "propagate") + +_CHOICES = { + "missing": _MISSING_CHOICES, + "constant": _CONSTANT_CHOICES, + "out_of_range": _OUT_OF_RANGE_CHOICES, + "invalid": _INVALID_CHOICES, +} + + +@dataclass(frozen=True) +class RepresentationPolicy: + """Declarative edge-case policy shared across transformers. + + Parameters + ---------- + missing : {"error", "propagate"}, default="propagate" + ``"propagate"`` lets ``NaN`` pass through to a downstream imputer; + ``"error"`` raises on any missing value. + constant : {"error", "warn", "allow"}, default="allow" + Reaction to a zero-variance (constant) input column. + out_of_range : {"error", "warn", "clip", "extrapolate"}, default="extrapolate" + Reaction to ``transform``-time values outside the fitted range. + invalid : {"error", "propagate"}, default="error" + Reaction to non-finite (``inf`` / ``-inf``) input values. + """ + + missing: str = "propagate" + constant: str = "allow" + out_of_range: str = "extrapolate" + invalid: str = "error" + + def __post_init__(self): + for name, choices in _CHOICES.items(): + value = getattr(self, name) + if value not in choices: + raise invalid_param_error( + "RepresentationPolicy", name, value, f"one of {choices}", valid=choices + ) + + @classmethod + def resolve(cls, policy) -> RepresentationPolicy: + """Coerce ``None`` / a mapping / an instance into a ``RepresentationPolicy``.""" + if policy is None: + return cls() + if isinstance(policy, cls): + return policy + if isinstance(policy, dict): + return cls(**policy) + raise invalid_param_error( + "RepresentationPolicy", + "policy", + policy, + "None, a dict, or a RepresentationPolicy instance", + ) + + def merge(self, **overrides) -> RepresentationPolicy: + """Return a copy with the non-``None`` ``overrides`` applied.""" + valid = {f.name for f in fields(self)} + applied = {} + for key, value in overrides.items(): + if value is None: + continue + if key not in valid: + raise invalid_param_error( + "RepresentationPolicy.merge", "override", key, f"one of {sorted(valid)}", valid=valid + ) + applied[key] = value + return replace(self, **applied) + + def to_dict(self) -> dict: + """Return a JSON-serializable dictionary of the policy fields.""" + return asdict(self) + + +def find_constant_columns(X) -> list[int]: + """Return the indices of zero-variance columns in ``X`` (ignoring ``NaN``).""" + X = np.asarray(X, dtype=np.float64) + constant = [] + for j in range(X.shape[1]): + col = X[:, j] + finite = col[np.isfinite(col)] + if finite.size and float(np.ptp(finite)) == 0.0: + constant.append(j) + return constant + + +def apply_constant_policy(X, policy: RepresentationPolicy, *, estimator) -> None: + """Enforce ``policy.constant`` against the constant columns of ``X``. + + ``"allow"`` is a no-op; ``"warn"`` emits a :class:`~pretab.exceptions.DataWarning`; + ``"error"`` raises :class:`~pretab.exceptions.PretabDataError`. + """ + if policy.constant == "allow": + return + constant = find_constant_columns(X) + if not constant: + return + name = type(estimator).__name__ + message = f"{name} received constant (zero-variance) column(s) at index {constant}." + if policy.constant == "error": + raise PretabDataError(message) + warnings.warn(message, DataWarning, stacklevel=2) + + +def resolve_out_of_range(X, lower, upper, policy: RepresentationPolicy, *, estimator): + """Apply ``policy.out_of_range`` to ``X`` given the fitted ``[lower, upper]`` bounds. + + ``lower`` / ``upper`` are per-column arrays. ``"extrapolate"`` returns ``X`` + unchanged, ``"clip"`` clamps into range, ``"warn"`` / ``"error"`` react when + any value lies outside the fitted bounds. + """ + X = np.asarray(X, dtype=np.float64) + if policy.out_of_range == "extrapolate": + return X + lower = np.asarray(lower, dtype=np.float64).ravel() + upper = np.asarray(upper, dtype=np.float64).ravel() + with np.errstate(invalid="ignore"): + below = X < lower + above = X > upper + if not (below.any() or above.any()): + return X + if policy.out_of_range == "clip": + return np.clip(X, lower, upper) + name = type(estimator).__name__ + message = f"{name} received values outside the fitted range at transform time." + if policy.out_of_range == "error": + raise PretabDataError(message) + warnings.warn(message, DataWarning, stacklevel=2) + return X diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index b5595a1..75260b3 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -15,6 +15,7 @@ ) from .compose.output import format_output from .core.logging import configure_logging, get_logger +from .core.policy import RepresentationPolicy, apply_constant_policy logger = get_logger(__name__) @@ -124,6 +125,11 @@ class Preprocessor(TransformerMixin, BaseEstimator): If True, append a binary missing-value indicator column for each imputed feature (via the imputer's ``add_indicator``; a standalone ``MissingIndicator`` is used when imputation is disabled). Applies to both numerical and categorical pipelines. + policy : RepresentationPolicy or dict or None, default=None + Central edge-case policy (see :class:`~pretab.RepresentationPolicy`) governing how + constant columns, out-of-range values, missing values, and non-finite inputs are + handled. ``None`` uses the default policy, which reproduces the library's historical + behaviour. Pass a mapping such as ``{"constant": "error"}`` to tighten a single axis. verbose : int, default=0 Verbosity level controlling ``fit``-time logging, applied through the shared ``"pretab"`` logger so a single setting on this entry point governs the whole @@ -238,6 +244,7 @@ def __init__( numerical_imputation="median", categorical_imputation="most_frequent", add_missing_indicator=False, + policy=None, verbose=0, ): """ @@ -265,6 +272,7 @@ def __init__( self.numerical_imputation = numerical_imputation self.categorical_imputation = categorical_imputation self.add_missing_indicator = add_missing_indicator + self.policy = policy self.verbose = verbose def fit(self, X, y=None, embeddings=None): @@ -332,6 +340,13 @@ def fit(self, X, y=None, embeddings=None): estimator_name=type(self).__name__, ) + self.policy_ = RepresentationPolicy.resolve(self.policy) + self.numerical_features_ = list(numerical_features) + self.categorical_features_ = list(categorical_features) + if numerical_features and self.policy_.constant != "allow": + numeric_values = X[numerical_features].to_numpy(dtype=np.float64, na_value=np.nan) + apply_constant_policy(numeric_values, self.policy_, estimator=self) + self.column_transformer_ = build_column_transformer(config, numerical_features, categorical_features) self.column_transformer_.fit(X, y) self.n_features_in_ = X.shape[1] diff --git a/pretab/transformers/feature_maps/base.py b/pretab/transformers/feature_maps/base.py index 8546489..6434f48 100644 --- a/pretab/transformers/feature_maps/base.py +++ b/pretab/transformers/feature_maps/base.py @@ -113,7 +113,11 @@ def fit(self, X, y=None): else: min_centers = max_centers = n_centers y_place = y if self.target_aware else None - self.centers_ = [adapter.get_centers(X[:, i], y_place, min_centers, max_centers) for i in range(X.shape[1])] + self.centers_ = [] + for i in range(X.shape[1]): + if np.isnan(X[:, i]).all(): + raise PretabDataError(f"Feature at index {i} has only NaN values") + self.centers_.append(adapter.get_centers(X[:, i], y_place, min_centers, max_centers)) return self def transform(self, X): diff --git a/pretab/transformers/numerical/binning.py b/pretab/transformers/numerical/binning.py index 1801631..4a431f5 100644 --- a/pretab/transformers/numerical/binning.py +++ b/pretab/transformers/numerical/binning.py @@ -104,6 +104,17 @@ def _check_array(self, X, *, reset): ) from exc else: X = X.astype(np.float64, copy=False) + if np.isinf(X).any(): + raise PretabDataError( + "NumericBinningTransformer received infinite values, which cannot be " + "placed into finite bins. Clean or clip the input before binning." + ) + if np.isnan(X).any(): + raise PretabDataError( + "NumericBinningTransformer received missing values (NaN), which cannot be " + "binned. Impute missing values (e.g. via the Preprocessor pipeline or a " + "SimpleImputer) before binning." + ) if reset: self.n_features_in_ = X.shape[1] elif X.shape[1] != self.n_features_in_: diff --git a/pretab/transformers/splines/base_spline.py b/pretab/transformers/splines/base_spline.py index 9098d47..69bed74 100644 --- a/pretab/transformers/splines/base_spline.py +++ b/pretab/transformers/splines/base_spline.py @@ -288,6 +288,11 @@ def fit(self, X, y=None): xi_valid = xi[valid_mask] if xi_valid.size == 0: raise PretabDataError(f"Feature at index {i} has only NaN values") + if xi_valid.size > 1 and np.ptp(xi_valid) == 0: + raise PretabDataError( + f"Feature at index {i} is constant (all values equal {float(xi_valid[0])!r}); " + "a spline basis cannot be constructed on a zero-range feature." + ) yi_valid = y_arr[valid_mask] if y_arr is not None else None self.knots_.append( self._column_knots(xi_valid, yi_valid, n_basis, strategy, selector, min_basis_req, max_basis_req) diff --git a/pretab/transformers/splines/mixins.py b/pretab/transformers/splines/mixins.py index dddbb83..17fe52e 100644 --- a/pretab/transformers/splines/mixins.py +++ b/pretab/transformers/splines/mixins.py @@ -14,7 +14,7 @@ from ...core.base import BasePreTabTransformer from ...core.knots import generate_internal_knots, select_knots, spanning_knots -from ...exceptions import IncompatibleParamsError +from ...exceptions import IncompatibleParamsError, PretabDataError class SplineBasisMixin(BasePreTabTransformer): @@ -49,6 +49,29 @@ def _output_sizes(self) -> list[int]: """Number of output columns contributed by each input feature.""" return [int(n) for n in self.n_basis_] + def _finite_column(self, x, y): + """Drop NaN samples from one feature (aligning ``y``) before knot placement. + + Knots are placed from the finite values only, so a partially missing + feature no longer poisons ``min`` / ``max`` / quantile knots with ``NaN``; + the missing rows are still expanded to ``NaN`` basis rows at transform time + (the "propagate" contract). A fully missing feature cannot yield knots and + raises a :class:`~pretab.exceptions.PretabDataError`. + """ + x = np.asarray(x, dtype=float) + finite = ~np.isnan(x) + if not finite.any(): + raise PretabDataError("Feature has only NaN values; a spline basis cannot be placed.") + if not finite.all(): + x = x[finite] + y = np.asarray(y)[finite] if y is not None else y + if x.size > 1 and np.ptp(x) == 0: + raise PretabDataError( + f"Feature is constant (all values equal {float(x[0])!r}); " + "a spline basis cannot be constructed on a zero-range feature." + ) + return x, y + def _place_spanning_knots(self, x, y, n_basis, strategy, selector, task, min_interior=None, max_interior=None): """Return a spanning knot vector (endpoints included) for one feature. @@ -60,7 +83,7 @@ def _place_spanning_knots(self, x, y, n_basis, strategy, selector, task, min_int adaptive selector path) the number of interior knots is clamped into that window before bracketing. """ - x = np.asarray(x) + x, y = self._finite_column(x, y) if selector is not None: interior = self._place_interior_knots( x, y, n_basis - 2, strategy, selector, task, min_interior, max_interior @@ -85,7 +108,7 @@ def _place_interior_knots(self, x, y, n_interior, strategy, selector, task, min_ ``n_interior`` knots are placed with :func:`pretab.core.knots.generate_internal_knots`. """ - x = np.asarray(x) + x, y = self._finite_column(x, y) if selector is not None: if y is None: raise IncompatibleParamsError("A knot selector requires y during fit for target-aware knot placement.") @@ -158,7 +181,7 @@ def _place_bspline_knots( adaptive selector path ``min_interior`` / ``max_interior`` clamp the interior-knot count. """ - x = np.asarray(x) + x, y = self._finite_column(x, y) n_interior = output_dim - degree - 1 interior = self._place_interior_knots(x, y, n_interior, strategy, selector, task, min_interior, max_interior) x_min, x_max = x.min(), x.max() diff --git a/tests/test_edge_case_contract.py b/tests/test_edge_case_contract.py new file mode 100644 index 0000000..d03c1ec --- /dev/null +++ b/tests/test_edge_case_contract.py @@ -0,0 +1,263 @@ +"""Edge-case contract for every transformer family (roadmap Phase 8, P8.2). + +These tests pin down how each numerical representation family reacts to the +degenerate inputs that show up in real tabular data: a constant column, a fully +missing column, partially missing values, out-of-range values at transform time, +non-finite values, duplicate support points, and too few samples. The behaviour +asserted here *is* the public contract -- if a change makes a family behave +differently on one of these inputs, that is an intentional contract change and +this file must be updated alongside it. +""" + +import warnings + +import numpy as np +import pandas as pd +import pytest + +from pretab import Preprocessor, RepresentationPolicy +from pretab.exceptions import DataWarning, InsufficientSamplesError, PretabDataError +from pretab.transformers import ( + BSplineTransformer, + CubicRegressionSplineTransformer, + ISplineTransformer, + MSplineTransformer, + NaturalCubicSplineTransformer, + NumericBinningTransformer, + PLETransformer, + PSplineTransformer, + RBFExpansionTransformer, + ReLUExpansionTransformer, + SigmoidExpansionTransformer, + TanhExpansionTransformer, + TensorProductSplineTransformer, + ThinPlateSplineTransformer, +) + +pytestmark = pytest.mark.filterwarnings("ignore::pretab.exceptions.LeakageWarning") + + +def _factory(name): + """Build a transformer configured for an unsupervised (y-optional) fit.""" + return { + "BSpline": lambda: BSplineTransformer(target_aware=False), + "MSpline": lambda: MSplineTransformer(target_aware=False), + "ISpline": lambda: ISplineTransformer(target_aware=False), + "RBF": lambda: RBFExpansionTransformer(target_aware=False), + "ReLU": lambda: ReLUExpansionTransformer(target_aware=False), + "Sigmoid": lambda: SigmoidExpansionTransformer(target_aware=False), + "Tanh": lambda: TanhExpansionTransformer(target_aware=False), + "NaturalCubic": lambda: NaturalCubicSplineTransformer(target_aware=False), + "CubicReg": lambda: CubicRegressionSplineTransformer(target_aware=False), + "PSpline": lambda: PSplineTransformer(), + "TensorProduct": lambda: TensorProductSplineTransformer(), + "ThinPlate": lambda: ThinPlateSplineTransformer(), + "Binning": lambda: NumericBinningTransformer(output_dim=5), + "PLE": lambda: PLETransformer(output_dim=5), + }[name]() + + +# Families that cannot build a basis on a zero-range (constant) column and must +# say so with a typed :class:`PretabDataError`. +CONSTANT_RAISES = [ + "BSpline", + "MSpline", + "ISpline", + "NaturalCubic", + "CubicReg", + "PSpline", + "TensorProduct", +] + +# Families that degrade gracefully on a constant column (single bin / collapsed +# basis) and still return a finite design matrix. +CONSTANT_GRACEFUL = [ + "RBF", + "ReLU", + "Sigmoid", + "Tanh", + "ThinPlate", + "Binning", + "PLE", +] + +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 = [ + "RBF", + "ReLU", + "Sigmoid", + "Tanh", + "NaturalCubic", + "CubicReg", + "PSpline", + "TensorProduct", +] + + +@pytest.fixture +def rng(): + return np.random.default_rng(0) + + +# --------------------------------------------------------------------------- # +# Constant column +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize("name", CONSTANT_RAISES) +def test_constant_column_raises_typed_error(name, rng): + X = np.full((40, 1), 3.14) + y = rng.normal(size=40) + with pytest.raises(PretabDataError, match="constant"): + _factory(name).fit(X, y) + +@pytest.mark.parametrize("name", CONSTANT_GRACEFUL) +def test_constant_column_degrades_gracefully(name, rng): + X = np.full((40, 1), 3.14) + y = rng.normal(size=40) + transformer = _factory(name) + out = transformer.fit(X, y).transform(X) + assert out.shape[0] == 40 + assert np.isfinite(out).all() + + +# --------------------------------------------------------------------------- # +# Fully-missing column +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize("name", ALL_FAMILIES) +def test_all_missing_column_is_rejected(name, rng): + X = np.full((40, 1), np.nan) + y = rng.normal(size=40) + # PretabDataError (typed) for the families that own their validation, plain + # ValueError for the ones that delegate to scikit-learn -- both are ValueError. + with pytest.raises(ValueError): + _factory(name).fit(X, y) + + +# --------------------------------------------------------------------------- # +# Partially-missing column: missing values propagate, they do not poison knots +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize("name", NAN_PROPAGATING) +def test_partial_missing_propagates_only_on_missing_rows(name, rng): + X = rng.normal(size=(40, 1)) + X[7, 0] = np.nan + y = rng.normal(size=40) + transformer = _factory(name).fit(X, y) + out = transformer.transform(X) + missing_rows = np.isnan(out).any(axis=1) + # Exactly the one missing input row is missing in the output; the fitted + # support points stay finite (the NaN never reached min/max/quantiles). + assert missing_rows.sum() == 1 + assert missing_rows[7] + + +# --------------------------------------------------------------------------- # +# Non-finite (infinity) input +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize("name", ALL_FAMILIES) +def test_infinite_values_are_rejected(name, rng): + X = rng.normal(size=(40, 1)) + X[0, 0] = np.inf + y = rng.normal(size=40) + with pytest.raises(ValueError): + _factory(name).fit(X, y) + + +# --------------------------------------------------------------------------- # +# Out-of-range values at transform time stay finite (clip / clamp / evaluate) +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize("name", ALL_FAMILIES) +def test_out_of_range_transform_stays_finite(name, rng): + X = np.linspace(0.0, 1.0, 40).reshape(-1, 1) + y = rng.normal(size=40) + transformer = _factory(name).fit(X, y) + out_of_range = np.array([[-5.0], [5.0]]) + out = transformer.transform(out_of_range) + assert out.shape[0] == 2 + assert np.isfinite(out).all() + + +# --------------------------------------------------------------------------- # +# Duplicate support points (few distinct values) do not crash +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize("name", ALL_FAMILIES) +def test_duplicate_support_points_do_not_crash(name, rng): + # 40 rows but only five distinct values -> many duplicate knot / center / edge + # candidates that must be de-duplicated instead of raising. + X = np.repeat(np.linspace(0.0, 1.0, 5), 8).reshape(-1, 1) + y = rng.normal(size=40) + out = _factory(name).fit(X, y).transform(X) + assert out.shape[0] == 40 + assert np.isfinite(out).all() + + +# --------------------------------------------------------------------------- # +# Too few samples +# --------------------------------------------------------------------------- # +def test_binning_rejects_too_few_samples(rng): + X = rng.normal(size=(2, 1)) + with pytest.raises(InsufficientSamplesError): + NumericBinningTransformer(output_dim=5).fit(X) + + +# --------------------------------------------------------------------------- # +# Feature-count mismatch between fit and transform +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize("name", ["BSpline", "RBF", "Binning"]) +def test_feature_count_mismatch_raises(name, rng): + X = rng.normal(size=(40, 2)) + y = rng.normal(size=40) + transformer = _factory(name).fit(X, y) + with pytest.raises((ValueError, PretabDataError)): + transformer.transform(rng.normal(size=(40, 3))) + + +# --------------------------------------------------------------------------- # +# Central RepresentationPolicy wiring on the Preprocessor +# --------------------------------------------------------------------------- # +def _frame_with_constant(rng): + return pd.DataFrame( + { + "a": rng.normal(size=60), + "const": np.full(60, 2.0), + "c": rng.normal(size=60), + } + ) + + +def test_preprocessor_default_policy_allows_constant(rng): + df = _frame_with_constant(rng) + y = rng.normal(size=60) + # Default policy reproduces the historical behaviour: a constant column is fine. + Preprocessor(numerical_method="standardization").fit(df, y) + + +def test_preprocessor_policy_errors_on_constant(rng): + df = _frame_with_constant(rng) + y = rng.normal(size=60) + with pytest.raises(PretabDataError): + Preprocessor( + numerical_method="standardization", policy={"constant": "error"} + ).fit(df, y) + + +def test_preprocessor_policy_warns_on_constant(rng): + df = _frame_with_constant(rng) + y = rng.normal(size=60) + with pytest.warns(DataWarning): + Preprocessor( + numerical_method="standardization", policy={"constant": "warn"} + ).fit(df, y) + + +def test_preprocessor_stores_resolved_policy(rng): + df = _frame_with_constant(rng) + y = rng.normal(size=60) + pre = Preprocessor(policy={"constant": "warn"}) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + pre.fit(df, y) + assert isinstance(pre.policy_, RepresentationPolicy) + assert pre.policy_.constant == "warn" diff --git a/tests/test_preprocessor.py b/tests/test_preprocessor.py index a0782a2..46ab859 100644 --- a/tests/test_preprocessor.py +++ b/tests/test_preprocessor.py @@ -117,6 +117,7 @@ def test_dict_keys_reflect_column_names(sample_data): "numerical_imputation", "categorical_imputation", "add_missing_indicator", + "policy", "verbose", } From 1a0bacbb6ec280fd63baa89172d6944e101ea520 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 26 Jul 2026 20:02:29 +0200 Subject: [PATCH 16/59] feat(output): add output budgets and sparse/dataframe output --- CHANGELOG.md | 1 + pretab/__init__.py | 3 +- pretab/compose/output.py | 133 ++++++++++++++++++++++++-- pretab/exceptions.py | 6 ++ pretab/preprocessor.py | 180 ++++++++++++++++++++++++++++++++++- tests/test_output_budget.py | 129 +++++++++++++++++++++++++ tests/test_output_format.py | 183 ++++++++++++++++++++++++++++++++++++ tests/test_preprocessor.py | 6 ++ 8 files changed, 627 insertions(+), 14 deletions(-) create mode 100644 tests/test_output_budget.py create mode 100644 tests/test_output_format.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1090164..b233380 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Going forward, this file is updated automatically by `cz bump` on each release. ### Feat +- **output**: add output-budget controls to `Preprocessor` (`max_output_features`, `max_features_per_input`, `max_dense_memory`, `overflow_policy`, plus `estimate_output_shape` / `estimate_memory`, raising the new `OutputBudgetError`) and first-class output-format control (`output_format ∈ {auto, dense, sparse}`, `dtype`, an `output_report_` memory report, and `set_output(transform="pandas"|"polars")` DataFrame wrapping); defaults (`dense`, no budgets) reproduce historical behaviour - **policy**: add a central `RepresentationPolicy(missing, constant, out_of_range, invalid)` (exported from `pretab`) and a `Preprocessor(policy=...)` hook (resolved to `policy_` at fit) governing constant-column, out-of-range, and non-finite handling; defaults reproduce historical behaviour. Pin the per-family edge-case contract (constant column, all-missing, partial-missing propagation, tiny n, duplicate support points, out-of-range, infinity, feature-count mismatch) in `tests/test_edge_case_contract.py`, and fix silent-corruption gaps so every spline family raises a typed `PretabDataError` on a constant or all-missing column (and cleanly propagates partial-missing rows), feature maps reject all-missing columns, and `NumericBinningTransformer` rejects non-finite input - **supervised**: add a leakage-safe supervised contract — `requires_y` / `is_supervised` / fitted `uses_target_` on every transformer, a `LeakageWarning` when a target-aware transformer is fit on `(X, y)` outside a Pipeline / cross-validation context, a `CrossFittedTransformer` wrapper that produces out-of-fold training features (recording `cross_fitted` / `n_folds` in the spec), and a `RepresentationSearchCV` skeleton (all exported from `pretab`) - **representation**: add typed `RepresentationSpec` and per-output-column `FeatureLineage` (exported from `pretab`); every transformer family exposes `get_representation_spec()` and `Preprocessor.get_feature_lineage()` maps each output column to its source feature(s), representation family, component, and target-usage flag diff --git a/pretab/__init__.py b/pretab/__init__.py index 6c9267f..8008b9a 100644 --- a/pretab/__init__.py +++ b/pretab/__init__.py @@ -4,13 +4,14 @@ from .core.policy import RepresentationPolicy from .core.representation import FeatureLineage, RepresentationSpec from .core.supervised import CrossFittedTransformer -from .exceptions import LeakageWarning, PretabWarning +from .exceptions import LeakageWarning, OutputBudgetError, PretabWarning from .preprocessor import Preprocessor __all__ = [ "CrossFittedTransformer", "FeatureLineage", "LeakageWarning", + "OutputBudgetError", "Preprocessor", "PretabWarning", "RepresentationPolicy", diff --git a/pretab/compose/output.py b/pretab/compose/output.py index f181a07..1f724c9 100644 --- a/pretab/compose/output.py +++ b/pretab/compose/output.py @@ -5,13 +5,28 @@ blocks alongside them). This module owns that formatting only -- it performs no fitting and holds no capability logic; the per-block slices it consumes are computed in :mod:`pretab.compose.inspection`. + +It also owns the dense/sparse decision (``output_format``), the dtype-independent +memory report (``output_report_``), and wrapping the stacked array into a pandas +or polars DataFrame for :meth:`~pretab.Preprocessor.set_output`. """ import numpy as np +from scipy import sparse as sp + +from ..exceptions import IncompatibleParamsError, OptionalDependencyError -from ..exceptions import IncompatibleParamsError +__all__ = [ + "attach_embeddings", + "build_output_dict", + "compute_output_report", + "format_output", + "to_dataframe_output", +] -__all__ = ["attach_embeddings", "build_output_dict", "format_output"] +# Density at or below which ``output_format="auto"`` switches to a sparse matrix, +# matching scikit-learn's ColumnTransformer ``sparse_threshold`` convention. +_SPARSE_AUTO_THRESHOLD = 0.3 _EMBEDDINGS_NOT_EXPECTED = ( "Embeddings were not expected, but were provided.\n" @@ -20,13 +35,99 @@ ) -def build_output_dict(transformed, slices) -> dict: +def compute_output_report(array, output_format, *, threshold=_SPARSE_AUTO_THRESHOLD): + """Resolve the concrete output format and build the memory report. + + Parameters + ---------- + array : numpy.ndarray + The dense stacked output. + output_format : {"auto", "dense", "sparse"} + Requested format. ``"auto"`` picks ``"sparse"`` when the density is below + ``threshold``. + threshold : float, default=0.3 + Density cut-off for the ``"auto"`` decision. + + Returns + ------- + tuple of (str, dict) + The resolved format (``"dense"`` or ``"sparse"``) and a report dict with + ``format``, ``shape``, ``density``, ``dense_bytes``, ``actual_bytes``, and + ``memory_saved_bytes``. + """ + density = float(np.count_nonzero(array)) / array.size if array.size else 0.0 + dense_bytes = int(array.nbytes) + + if output_format == "sparse": + use_sparse = True + elif output_format == "auto": + use_sparse = density < threshold + else: # "dense" + use_sparse = False + + if use_sparse: + csr = sp.csr_matrix(array) + actual_bytes = int(csr.data.nbytes + csr.indices.nbytes + csr.indptr.nbytes) + fmt = "sparse" + else: + actual_bytes = dense_bytes + fmt = "dense" + + report = { + "format": fmt, + "shape": tuple(int(s) for s in array.shape), + "density": density, + "dense_bytes": dense_bytes, + "actual_bytes": actual_bytes, + "memory_saved_bytes": max(0, dense_bytes - actual_bytes), + } + return fmt, report + + +def to_dataframe_output(array, columns, container): + """Wrap a dense stacked array in a pandas or polars DataFrame. + + Parameters + ---------- + array : numpy.ndarray + Dense stacked output. + columns : sequence of str + One name per output column (from ``get_feature_names_out``). + container : {"pandas", "polars"} + Target dataframe library. + + Raises + ------ + OptionalDependencyError + If ``container="polars"`` but polars is not installed. + """ + columns = list(columns) + if container == "pandas": + import pandas as pd + + return pd.DataFrame(array, columns=columns) + try: + import polars as pl + except ImportError as exc: # pragma: no cover - exercised only without polars + raise OptionalDependencyError( + "set_output(transform='polars') requires the optional 'polars' package. " + "Install it with `pip install polars`." + ) from exc + return pl.from_numpy(array, schema=columns) + + +def build_output_dict(transformed, slices, *, as_sparse=False) -> dict: """Split a stacked array into a name -> block dict using ``slices``. ``slices`` is an ordered iterable of ``(name, start, width)`` describing each - transformer's contiguous span in the stacked output. + transformer's contiguous span in the stacked output. When ``as_sparse`` is + True each block is returned as a SciPy CSR matrix. """ - return {name: transformed[:, start : start + width] for name, start, width in slices} + result = {} + for name, start, width in slices: + block = transformed[:, start : start + width] + result[name] = sp.csr_matrix(block) if as_sparse else block + return result def attach_embeddings(result: dict, embeddings, *, expected: bool) -> dict: @@ -47,15 +148,23 @@ def attach_embeddings(result: dict, embeddings, *, expected: bool) -> dict: return result -def format_output(transformed, *, return_array, slices=None, embeddings=None, embeddings_expected=False): +def format_output( + transformed, + *, + return_array, + slices=None, + embeddings=None, + embeddings_expected=False, + output_format="dense", +): """Return the transformed data as a stacked array or a per-block dict. Parameters ---------- transformed : numpy.ndarray - The stacked array produced by the fitted ColumnTransformer. + The dense stacked array produced by the fitted ColumnTransformer. return_array : bool - If True, return ``transformed`` unchanged; otherwise build the dict. + If True, return the stacked array (dense or CSR); otherwise build the dict. slices : iterable of (str, int, int), optional Ordered ``(name, start, width)`` spans; required when ``return_array`` is False. @@ -63,11 +172,15 @@ def format_output(transformed, *, return_array, slices=None, embeddings=None, em External embedding blocks to attach to the dict output. embeddings_expected : bool, default=False Whether embedding blocks were configured at fit time. + output_format : {"dense", "sparse"}, default="dense" + Resolved output format. ``"sparse"`` returns a CSR matrix (array path) or + CSR blocks (dict path). """ + as_sparse = output_format == "sparse" if return_array: - return transformed + return sp.csr_matrix(transformed) if as_sparse else transformed - result = build_output_dict(transformed, slices or []) + result = build_output_dict(transformed, slices or [], as_sparse=as_sparse) if embeddings is not None: attach_embeddings(result, embeddings, expected=embeddings_expected) return result diff --git a/pretab/exceptions.py b/pretab/exceptions.py index 5582ee5..9aea891 100644 --- a/pretab/exceptions.py +++ b/pretab/exceptions.py @@ -22,6 +22,7 @@ "InvalidParamError", "LeakageWarning", "OptionalDependencyError", + "OutputBudgetError", "PretabConfigError", "PretabDataError", "PretabError", @@ -87,6 +88,11 @@ class OptionalDependencyError(PretabError, ImportError): """A required optional dependency is not installed.""" +class OutputBudgetError(PretabError, ValueError): + """The fitted preprocessor would exceed a configured output budget + (``max_output_features`` / ``max_features_per_input`` / ``max_dense_memory``).""" + + # --- Message factories --- def invalid_param_error(estimator, param, value, constraint, valid=None): """Build an :class:`InvalidParamError` with a consistent, actionable message. diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index 75260b3..72e9b83 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -1,7 +1,10 @@ import time +import warnings import numpy as np +from scipy import sparse as sp from sklearn.base import BaseEstimator, TransformerMixin +from sklearn.utils._set_output import _get_output_config from sklearn.utils.validation import check_is_fitted from .compose.config import PreprocessorConfig @@ -13,9 +16,10 @@ build_transformer_summary, get_output_slices, ) -from .compose.output import format_output +from .compose.output import compute_output_report, format_output, to_dataframe_output from .core.logging import configure_logging, get_logger from .core.policy import RepresentationPolicy, apply_constant_policy +from .exceptions import ConfigWarning, OutputBudgetError, invalid_param_error logger = get_logger(__name__) @@ -130,6 +134,36 @@ class Preprocessor(TransformerMixin, BaseEstimator): constant columns, out-of-range values, missing values, and non-finite inputs are handled. ``None`` uses the default policy, which reproduces the library's historical behaviour. Pass a mapping such as ``{"constant": "error"}`` to tighten a single axis. + max_output_features : int or None, default=None + Upper bound on the total number of output columns produced across all input + features. ``None`` disables the check. A violation is handled per + ``overflow_policy``. + max_features_per_input : int or None, default=None + Upper bound on the number of output columns any single input feature may + expand to. ``None`` disables the check. A violation is handled per + ``overflow_policy``. + max_dense_memory : int or None, default=None + Upper bound, in bytes, on the estimated dense output footprint + (``n_rows * total_output_dim_ * itemsize``) evaluated against the training + data at ``fit``. ``None`` disables the check. A violation is handled per + ``overflow_policy``. See :meth:`estimate_memory` to estimate this for any + input. + overflow_policy : {"error", "warn", "ignore"}, default="error" + What to do when a configured output budget is exceeded: ``"error"`` raises + :class:`~pretab.exceptions.OutputBudgetError`, ``"warn"`` emits a + :class:`~pretab.exceptions.ConfigWarning`, ``"ignore"`` proceeds silently. + Only takes effect when at least one budget parameter above is set. + output_format : {"dense", "sparse", "auto"}, default="dense" + Container used for the transformed output. ``"dense"`` (the default, for + backward compatibility) returns NumPy arrays; ``"sparse"`` returns SciPy + CSR matrices (a single stacked CSR when ``return_array=True``, otherwise CSR + blocks in the output dict); ``"auto"`` selects ``"sparse"`` when the output + density falls below ``0.3`` and ``"dense"`` otherwise. Ignored when + :meth:`set_output` requests a pandas or polars DataFrame. Every ``transform`` + records the resolved choice and its memory footprint in ``output_report_``. + dtype : numpy dtype or None, default=None + Optional dtype to cast the transformed output to (e.g. ``numpy.float32`` to + halve memory). ``None`` keeps the native ``float64`` output. verbose : int, default=0 Verbosity level controlling ``fit``-time logging, applied through the shared ``"pretab"`` logger so a single setting on this entry point governs the whole @@ -159,6 +193,10 @@ class Preprocessor(TransformerMixin, BaseEstimator): output_dims\_ : dict Per-feature expanded output-column counts, keyed by input feature name. The values sum to ``total_output_dim_``. + output_report\_ : dict + Memory report for the most recent ``transform``, with keys ``format`` + (``"dense"`` or ``"sparse"``), ``shape``, ``density``, ``dense_bytes``, + ``actual_bytes``, and ``memory_saved_bytes``. Set on every ``transform``. embeddings\_ : bool Whether embedding vectors were provided at ``fit`` time and are expected in transformation. embedding_dimensions\_ : dict @@ -245,6 +283,12 @@ def __init__( categorical_imputation="most_frequent", add_missing_indicator=False, policy=None, + max_output_features=None, + max_features_per_input=None, + max_dense_memory=None, + overflow_policy="error", + output_format="dense", + dtype=None, verbose=0, ): """ @@ -273,6 +317,12 @@ def __init__( self.categorical_imputation = categorical_imputation self.add_missing_indicator = add_missing_indicator self.policy = policy + self.max_output_features = max_output_features + self.max_features_per_input = max_features_per_input + self.max_dense_memory = max_dense_memory + self.overflow_policy = overflow_policy + self.output_format = output_format + self.dtype = dtype self.verbose = verbose def fit(self, X, y=None, embeddings=None): @@ -351,6 +401,18 @@ def fit(self, X, y=None, embeddings=None): self.column_transformer_.fit(X, y) self.n_features_in_ = X.shape[1] + valid_formats = ("auto", "dense", "sparse") + if self.output_format not in valid_formats: + raise invalid_param_error( + type(self).__name__, + "output_format", + self.output_format, + "must be one of 'auto', 'dense', 'sparse'", + valid=set(valid_formats), + ) + + self._enforce_output_budget(X.shape[0]) + if verbose >= 1: logger.info( "fit complete: %d numerical (%s) + %d categorical (%s) feature(s) -> %d output columns in %.3fs", @@ -385,8 +447,11 @@ def transform(self, X, embeddings=None, return_array=False): Returns ------- - dict or np.ndarray - Transformed data. A dictionary if return_array=False, else a NumPy array. + dict, np.ndarray, scipy.sparse matrix, or DataFrame + Transformed data. By default a dictionary of per-feature blocks; a + single stacked array when ``return_array=True``; a SciPy CSR matrix (or + CSR blocks) when ``output_format`` resolves to ``"sparse"``; or a pandas + / polars DataFrame when configured via :meth:`set_output`. """ check_is_fitted(self) @@ -394,6 +459,17 @@ def transform(self, X, embeddings=None, return_array=False): X = to_dataframe(X, copy=True) transformed_X = self.column_transformer_.transform(X) + if sp.issparse(transformed_X): + transformed_X = transformed_X.toarray() + transformed_X = np.asarray(transformed_X) + if self.dtype is not None: + transformed_X = transformed_X.astype(self.dtype, copy=False) + + fmt, self.output_report_ = compute_output_report(transformed_X, self.output_format) + + container = _get_output_config("transform", self)["dense"] + if container in ("pandas", "polars"): + return to_dataframe_output(transformed_X, self.get_feature_names_out(), container) slices = None if return_array else get_output_slices(self.column_transformer_, X) return format_output( @@ -402,6 +478,7 @@ def transform(self, X, embeddings=None, return_array=False): slices=slices, embeddings=embeddings, embeddings_expected=self.embeddings_, + output_format=fmt, ) def fit_transform(self, X, y=None, embeddings=None, return_array=False): @@ -505,6 +582,103 @@ def output_dims_(self) -> dict: dims[columns[0]] = width return dims + def _output_itemsize(self) -> int: + """Bytes per element of the dense transformed array (float64 for now).""" + return np.dtype(np.float64).itemsize + + def estimate_output_shape(self, X) -> tuple: + """Estimate the shape of the dense transformed array for ``X``. + + Fitted method. Returns ``(n_rows, total_output_dim_)`` where ``n_rows`` is + the number of rows in ``X`` and the column count is the fitted output width + (the same width :meth:`transform` would produce with ``return_array=True``). + + Parameters + ---------- + X : pandas.DataFrame, numpy.ndarray, or dict + Input whose row count drives the estimate; not transformed. + + Returns + ------- + tuple of int + ``(n_rows, n_output_columns)``. + """ + check_is_fitted(self) + n_rows = to_dataframe(X).shape[0] + return (int(n_rows), int(self.total_output_dim_)) + + def estimate_memory(self, X) -> int: + """Estimate the dense-array memory footprint (in bytes) of transforming ``X``. + + Fitted method. Computed as ``n_rows * total_output_dim_ * itemsize`` for the + dense output dtype, without materialising the transform. + + Parameters + ---------- + X : pandas.DataFrame, numpy.ndarray, or dict + Input whose row count drives the estimate; not transformed. + + Returns + ------- + int + Estimated number of bytes for the dense transformed array. + """ + n_rows, n_cols = self.estimate_output_shape(X) + return int(n_rows * n_cols * self._output_itemsize()) + + def _enforce_output_budget(self, n_rows: int) -> None: + """Check the fitted output width against the configured output budget. + + Runs at the end of :meth:`fit`. When no budget parameter is set this is a + no-op (the historical behaviour). Any violation is handled according to + ``overflow_policy``: ``"error"`` raises + :class:`~pretab.exceptions.OutputBudgetError`, ``"warn"`` emits a + :class:`~pretab.exceptions.ConfigWarning`, and ``"ignore"`` proceeds + silently. + """ + valid_policies = ("error", "warn", "ignore") + if self.overflow_policy not in valid_policies: + raise invalid_param_error( + type(self).__name__, + "overflow_policy", + self.overflow_policy, + "must be one of 'error', 'warn', 'ignore'", + valid=set(valid_policies), + ) + + violations: list[str] = [] + + total = int(self.total_output_dim_) + if self.max_output_features is not None and total > self.max_output_features: + violations.append( + f"total output columns ({total}) exceed max_output_features ({self.max_output_features})" + ) + + if self.max_features_per_input is not None: + for feature, width in self.output_dims_.items(): + if width > self.max_features_per_input: + violations.append( + f"feature {feature!r} expands to {width} columns, " + f"exceeding max_features_per_input ({self.max_features_per_input})" + ) + + if self.max_dense_memory is not None: + estimated = n_rows * total * self._output_itemsize() + if estimated > self.max_dense_memory: + violations.append( + f"dense output for {n_rows} row(s) needs ~{estimated} bytes, " + f"exceeding max_dense_memory ({self.max_dense_memory})" + ) + + if not violations: + return + + message = "Output budget exceeded: " + "; ".join(violations) + "." + if self.overflow_policy == "error": + raise OutputBudgetError(message) + if self.overflow_policy == "warn": + warnings.warn(message, ConfigWarning, stacklevel=2) + def get_feature_info(self, verbose=True): """ Retrieves metadata about the transformed features. diff --git a/tests/test_output_budget.py b/tests/test_output_budget.py new file mode 100644 index 0000000..10d3a8c --- /dev/null +++ b/tests/test_output_budget.py @@ -0,0 +1,129 @@ +"""Output-budget controls on the Preprocessor (roadmap Phase 8, P8.3). + +The budget parameters are opt-in: with all of them ``None`` (the default) the +Preprocessor behaves exactly as before. When a budget is set, exceeding it is +handled by ``overflow_policy`` -- raise, warn, or ignore. +""" + +import warnings + +import numpy as np +import pandas as pd +import pytest + +from pretab import OutputBudgetError, Preprocessor +from pretab.exceptions import ConfigWarning, InvalidParamError + + +@pytest.fixture +def frame(): + rng = np.random.default_rng(0) + return pd.DataFrame({"a": rng.normal(size=50), "b": rng.normal(size=50)}) + + +@pytest.fixture +def y(): + return np.random.default_rng(1).normal(size=50) + + +def _bspline(**kwargs): + return Preprocessor( + numerical_method="bspline", + output_dim=8, + target_aware=False, + placement_strategy="quantile", + **kwargs, + ) + + +# --------------------------------------------------------------------------- # +# Estimation helpers +# --------------------------------------------------------------------------- # +def test_estimate_output_shape_matches_transform(frame, y): + pre = _bspline().fit(frame, y) + n_rows, n_cols = pre.estimate_output_shape(frame) + assert n_rows == frame.shape[0] + assert n_cols == pre.total_output_dim_ + assert pre.transform(frame, return_array=True).shape == (n_rows, n_cols) + + +def test_estimate_memory_is_rows_times_cols_times_itemsize(frame, y): + pre = _bspline().fit(frame, y) + n_rows, n_cols = pre.estimate_output_shape(frame) + assert pre.estimate_memory(frame) == n_rows * n_cols * np.dtype(np.float64).itemsize + + +def test_estimate_shape_scales_with_new_rows(frame, y): + pre = _bspline().fit(frame, y) + bigger = pd.concat([frame] * 3, ignore_index=True) + assert pre.estimate_output_shape(bigger)[0] == frame.shape[0] * 3 + + +# --------------------------------------------------------------------------- # +# No budget set -> no enforcement (non-regressive default) +# --------------------------------------------------------------------------- # +def test_default_has_no_budget_enforcement(frame, y): + # Fits fine even though the output is wider than any of the (unset) budgets. + pre = _bspline().fit(frame, y) + assert pre.total_output_dim_ > 0 + + +# --------------------------------------------------------------------------- # +# max_output_features +# --------------------------------------------------------------------------- # +def test_max_output_features_error(frame, y): + with pytest.raises(OutputBudgetError, match="max_output_features"): + _bspline(max_output_features=10).fit(frame, y) + + +def test_max_output_features_within_budget_is_fine(frame, y): + pre = _bspline().fit(frame, y) + _bspline(max_output_features=pre.total_output_dim_).fit(frame, y) + + +# --------------------------------------------------------------------------- # +# max_features_per_input +# --------------------------------------------------------------------------- # +def test_max_features_per_input_error(frame, y): + with pytest.raises(OutputBudgetError, match="max_features_per_input"): + _bspline(max_features_per_input=5).fit(frame, y) + + +# --------------------------------------------------------------------------- # +# max_dense_memory +# --------------------------------------------------------------------------- # +def test_max_dense_memory_error(frame, y): + with pytest.raises(OutputBudgetError, match="max_dense_memory"): + _bspline(max_dense_memory=100).fit(frame, y) + + +def test_max_dense_memory_generous_budget_is_fine(frame, y): + _bspline(max_dense_memory=10**9).fit(frame, y) + + +# --------------------------------------------------------------------------- # +# overflow_policy +# --------------------------------------------------------------------------- # +def test_overflow_policy_warn(frame, y): + with pytest.warns(ConfigWarning, match="Output budget exceeded"): + _bspline(max_output_features=10, overflow_policy="warn").fit(frame, y) + + +def test_overflow_policy_ignore(frame, y): + with warnings.catch_warnings(): + warnings.simplefilter("error", ConfigWarning) + # No warning and no error even though the budget is exceeded. + _bspline(max_output_features=1, overflow_policy="ignore").fit(frame, y) + + +def test_overflow_policy_invalid(frame, y): + with pytest.raises(InvalidParamError): + _bspline(max_output_features=1, overflow_policy="bogus").fit(frame, y) + + +def test_multiple_budgets_reported_together(frame, y): + with pytest.raises(OutputBudgetError) as excinfo: + _bspline(max_output_features=1, max_features_per_input=1).fit(frame, y) + message = str(excinfo.value) + assert "max_output_features" in message + assert "max_features_per_input" in message diff --git a/tests/test_output_format.py b/tests/test_output_format.py new file mode 100644 index 0000000..6e360df --- /dev/null +++ b/tests/test_output_format.py @@ -0,0 +1,183 @@ +"""Tests for first-class output format control (P8.4). + +Covers ``output_format`` (dense/sparse/auto), ``dtype`` casting, the +``output_report_`` memory report, and ``set_output`` pandas / polars DataFrame +wrapping. +""" + +import numpy as np +import pandas as pd +import pytest +from scipy import sparse as sp + +from pretab import Preprocessor +from pretab.exceptions import OptionalDependencyError + + +@pytest.fixture +def frame(): + rng = np.random.default_rng(0) + return pd.DataFrame({"a": rng.random(30), "b": rng.random(30)}) + + +@pytest.fixture +def y(): + rng = np.random.default_rng(1) + return rng.random(30) + + +def _bspline(**kwargs): + return Preprocessor( + numerical_method="bspline", + output_dim=8, + target_aware=False, + placement_strategy="quantile", + **kwargs, + ) + + +# --- default (dense) behaviour ------------------------------------------------- + + +def test_default_output_format_is_dense(frame, y): + p = _bspline().fit(frame, y) + arr = p.transform(frame, return_array=True) + assert isinstance(arr, np.ndarray) + assert p.output_report_["format"] == "dense" + + +def test_default_dict_blocks_are_dense(frame, y): + p = _bspline().fit(frame, y) + out = p.transform(frame) + assert all(isinstance(v, np.ndarray) for v in out.values()) + + +# --- sparse -------------------------------------------------------------------- + + +def test_sparse_return_array_is_csr(frame, y): + p = _bspline(output_format="sparse").fit(frame, y) + arr = p.transform(frame, return_array=True) + assert sp.issparse(arr) + assert arr.format == "csr" + dense = _bspline().fit(frame, y).transform(frame, return_array=True) + np.testing.assert_allclose(arr.toarray(), dense) + + +def test_sparse_dict_blocks_are_csr(frame, y): + p = _bspline(output_format="sparse").fit(frame, y) + out = p.transform(frame) + assert all(sp.issparse(v) for v in out.values()) + + +def test_sparse_report_saves_memory(frame, y): + p = _bspline(output_format="sparse").fit(frame, y) + p.transform(frame, return_array=True) + report = p.output_report_ + assert report["format"] == "sparse" + assert report["actual_bytes"] < report["dense_bytes"] + assert report["memory_saved_bytes"] == report["dense_bytes"] - report["actual_bytes"] + + +# --- auto ---------------------------------------------------------------------- + + +def test_auto_picks_sparse_for_low_density(frame, y): + # One-hot output on a high-cardinality categorical is very sparse. + cats = pd.DataFrame({"c": [f"v{i % 15}" for i in range(30)]}) + p = Preprocessor(categorical_method="one-hot", output_format="auto").fit(cats) + p.transform(cats, return_array=True) + assert p.output_report_["format"] == "sparse" + + +def test_auto_picks_dense_for_high_density(frame, y): + p = _bspline(output_format="auto").fit(frame, y) + p.transform(frame, return_array=True) + assert p.output_report_["format"] == "dense" + + +# --- dtype --------------------------------------------------------------------- + + +def test_dtype_casts_output(frame, y): + p = _bspline(dtype=np.float32).fit(frame, y) + arr = p.transform(frame, return_array=True) + assert arr.dtype == np.float32 + + +def test_dtype_none_keeps_float64(frame, y): + p = _bspline().fit(frame, y) + arr = p.transform(frame, return_array=True) + assert arr.dtype == np.float64 + + +def test_dtype_with_sparse(frame, y): + p = _bspline(dtype=np.float32, output_format="sparse").fit(frame, y) + arr = p.transform(frame, return_array=True) + assert sp.issparse(arr) + assert arr.dtype == np.float32 + + +# --- output_report_ ------------------------------------------------------------ + + +def test_output_report_shape_and_keys(frame, y): + p = _bspline().fit(frame, y) + arr = p.transform(frame, return_array=True) + report = p.output_report_ + assert set(report) == { + "format", + "shape", + "density", + "dense_bytes", + "actual_bytes", + "memory_saved_bytes", + } + assert report["shape"] == arr.shape + assert 0.0 <= report["density"] <= 1.0 + + +# --- set_output ---------------------------------------------------------------- + + +def test_set_output_pandas_returns_dataframe(frame, y): + p = _bspline().fit(frame, y).set_output(transform="pandas") + out = p.transform(frame) + assert isinstance(out, pd.DataFrame) + assert list(out.columns) == list(p.get_feature_names_out()) + assert out.shape == (len(frame), p.total_output_dim_) + + +def test_set_output_pandas_fit_transform(frame, y): + p = _bspline().set_output(transform="pandas") + out = p.fit_transform(frame, y) + assert isinstance(out, pd.DataFrame) + assert out.shape[1] == p.total_output_dim_ + + +def test_set_output_default_still_dict(frame, y): + p = _bspline().fit(frame, y).set_output(transform="default") + out = p.transform(frame) + assert isinstance(out, dict) + + +def test_set_output_polars_without_polars_raises(frame, y): + import importlib.util + + p = _bspline().fit(frame, y).set_output(transform="polars") + if importlib.util.find_spec("polars") is None: + with pytest.raises(OptionalDependencyError): + p.transform(frame) + else: + out = p.transform(frame) + assert out.shape == (len(frame), p.total_output_dim_) + + +# --- validation ---------------------------------------------------------------- + + +def test_invalid_output_format_raises(frame, y): + from pretab.exceptions import InvalidParamError + + with pytest.raises(InvalidParamError): + _bspline(output_format="nope").fit(frame, y) diff --git a/tests/test_preprocessor.py b/tests/test_preprocessor.py index 46ab859..ad7be3b 100644 --- a/tests/test_preprocessor.py +++ b/tests/test_preprocessor.py @@ -118,6 +118,12 @@ def test_dict_keys_reflect_column_names(sample_data): "categorical_imputation", "add_missing_indicator", "policy", + "max_output_features", + "max_features_per_input", + "max_dense_memory", + "overflow_policy", + "output_format", + "dtype", "verbose", } From 572c866757295f8bb4e1b5cc47e8905664397543 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 26 Jul 2026 20:19:46 +0200 Subject: [PATCH 17/59] feat(missing): add missing_policy and edge-case tests --- CHANGELOG.md | 1 + pretab/compose/config.py | 49 ++++++- pretab/compose/factory.py | 28 ++-- pretab/preprocessor.py | 35 ++++- pretab/transformers/__init__.py | 3 +- pretab/transformers/encoders/__init__.py | 2 + pretab/transformers/encoders/missing.py | 96 +++++++++++++ tests/compose/conftest.py | 1 + tests/regression/test_edge_cases.py | 109 +++++++++++++++ tests/test_missing_policy.py | 165 +++++++++++++++++++++++ tests/test_preprocessor.py | 1 + 11 files changed, 477 insertions(+), 13 deletions(-) create mode 100644 pretab/transformers/encoders/missing.py create mode 100644 tests/regression/test_edge_cases.py create mode 100644 tests/test_missing_policy.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b233380..15ce734 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Going forward, this file is updated automatically by `cz bump` on each release. ### Feat +- **missing**: add a high-level `Preprocessor(missing_policy=...)` control (`error` / `propagate` / `impute` / `impute_with_indicator` / `separate_state`) that overrides the low-level imputation parameters; `separate_state` emits a dedicated `__missing` column (new `MissingStateIndicator`, wired through a per-column `FeatureUnion`) that stays outside the ordinary representation basis, and `error` rejects missing input at fit/transform; pin the end-to-end edge-case behaviour (constant features, `custombin` determinism, duplicate support points, missing values, unseen categories) in `tests/regression/test_edge_cases.py` - **output**: add output-budget controls to `Preprocessor` (`max_output_features`, `max_features_per_input`, `max_dense_memory`, `overflow_policy`, plus `estimate_output_shape` / `estimate_memory`, raising the new `OutputBudgetError`) and first-class output-format control (`output_format ∈ {auto, dense, sparse}`, `dtype`, an `output_report_` memory report, and `set_output(transform="pandas"|"polars")` DataFrame wrapping); defaults (`dense`, no budgets) reproduce historical behaviour - **policy**: add a central `RepresentationPolicy(missing, constant, out_of_range, invalid)` (exported from `pretab`) and a `Preprocessor(policy=...)` hook (resolved to `policy_` at fit) governing constant-column, out-of-range, and non-finite handling; defaults reproduce historical behaviour. Pin the per-family edge-case contract (constant column, all-missing, partial-missing propagation, tiny n, duplicate support points, out-of-range, infinity, feature-count mismatch) in `tests/test_edge_case_contract.py`, and fix silent-corruption gaps so every spline family raises a typed `PretabDataError` on a constant or all-missing column (and cleanly propagates partial-missing rows), feature maps reject all-missing columns, and `NumericBinningTransformer` rejects non-finite input - **supervised**: add a leakage-safe supervised contract — `requires_y` / `is_supervised` / fitted `uses_target_` on every transformer, a `LeakageWarning` when a target-aware transformer is fit on `(X, y)` outside a Pipeline / cross-validation context, a `CrossFittedTransformer` wrapper that produces out-of-fold training features (recording `cross_fitted` / `n_folds` in the spec), and a `RepresentationSearchCV` skeleton (all exported from `pretab`) diff --git a/pretab/compose/config.py b/pretab/compose/config.py index e674dc0..5b08d69 100644 --- a/pretab/compose/config.py +++ b/pretab/compose/config.py @@ -19,7 +19,7 @@ from dataclasses import dataclass from ..core.parameters import validate_placement -from ..exceptions import IncompatibleParamsError +from ..exceptions import IncompatibleParamsError, invalid_param_error from .registry import ( CATEGORICAL_ALIASES, CATEGORICAL_METHODS, @@ -30,6 +30,12 @@ __all__ = ["PreprocessorConfig"] +# Valid values for the high-level ``missing_policy`` orchestration knob. ``None`` +# keeps the explicit imputation parameters authoritative (historical behaviour). +MISSING_POLICIES = frozenset( + {"error", "propagate", "impute", "impute_with_indicator", "separate_state"} +) + def _normalize_method(method, canonical, aliases) -> str: """Resolve a global method name to canonical form, mapping ``None`` to ``"none"``.""" @@ -65,6 +71,7 @@ class PreprocessorConfig: numerical_imputation: str | None categorical_imputation: str | None add_missing_indicator: bool + missing_policy: str | None verbose: int @classmethod @@ -89,6 +96,7 @@ def from_params( numerical_imputation, categorical_imputation, add_missing_indicator, + missing_policy, verbose, ) -> PreprocessorConfig: """Normalize and validate raw Preprocessor parameters into a config. @@ -104,6 +112,15 @@ def from_params( imputation step. """ validate_placement(target_aware, placement_strategy) + if missing_policy is not None and missing_policy not in MISSING_POLICIES: + raise invalid_param_error( + "Preprocessor", + "missing_policy", + missing_policy, + "must be None or one of " + "'error', 'propagate', 'impute', 'impute_with_indicator', 'separate_state'", + valid=set(MISSING_POLICIES), + ) if add_missing_indicator and numerical_imputation is None and categorical_imputation is None: raise IncompatibleParamsError( "add_missing_indicator=True requires numerical_imputation or categorical_imputation " @@ -128,6 +145,7 @@ def from_params( numerical_imputation=numerical_imputation, categorical_imputation=categorical_imputation, add_missing_indicator=add_missing_indicator, + missing_policy=missing_policy, verbose=verbose, ) @@ -161,3 +179,32 @@ def seed_kwargs(self) -> dict: seed is forwarded only when the user pins one. """ return {} if self.random_state is None else {"random_state": self.random_state} + + def imputation_plan(self, *, is_numerical: bool) -> dict: + """Resolve how missing values are handled for one column kind. + + Returns a dict with ``add_imputer`` / ``add_indicator`` / ``separate_state`` + booleans and the imputer ``strategy``. When ``missing_policy`` is ``None`` + the explicit ``*_imputation`` / ``add_missing_indicator`` parameters stay + authoritative (historical behaviour); otherwise ``missing_policy`` decides. + """ + strategy = (self.numerical_imputation or "median") if is_numerical else ( + self.categorical_imputation or "most_frequent" + ) + if self.missing_policy is None: + configured = self.numerical_imputation if is_numerical else self.categorical_imputation + return { + "add_imputer": configured is not None, + "add_indicator": self.add_missing_indicator, + "separate_state": False, + "strategy": strategy, + } + if self.missing_policy in ("error", "propagate"): + return {"add_imputer": False, "add_indicator": False, "separate_state": False, "strategy": strategy} + if self.missing_policy == "impute": + return {"add_imputer": True, "add_indicator": False, "separate_state": False, "strategy": strategy} + if self.missing_policy == "impute_with_indicator": + return {"add_imputer": True, "add_indicator": True, "separate_state": False, "strategy": strategy} + # "separate_state": impute for the basis, emit a dedicated __missing column + # (added by the factory as a separate branch that bypasses the basis). + return {"add_imputer": True, "add_indicator": False, "separate_state": True, "strategy": strategy} diff --git a/pretab/compose/factory.py b/pretab/compose/factory.py index 92ba668..d232e22 100644 --- a/pretab/compose/factory.py +++ b/pretab/compose/factory.py @@ -12,11 +12,12 @@ from sklearn.compose import ColumnTransformer from sklearn.impute import SimpleImputer -from sklearn.pipeline import Pipeline +from sklearn.pipeline import FeatureUnion, Pipeline from sklearn.preprocessing import MinMaxScaler, StandardScaler from ..exceptions import ConfigWarning, IncompatibleParamsError, invalid_param_error from ..transformers.encoders.floats import ToFloatTransformer +from ..transformers.encoders.missing import MissingStateIndicator from .config import PreprocessorConfig from .registry import ( CATEGORICAL_ALIASES, @@ -241,15 +242,15 @@ def create_transformer(method: str, *, is_numerical: bool, config: PreprocessorC f"with placement_strategy in {{'cart', 'lightgbm'}}; got target_aware=False." ) + plan = config.imputation_plan(is_numerical=is_numerical) if is_numerical: - add_imputer = config.numerical_imputation is not None steps = get_numerical_transformer_steps( method=method, task=config.task, target_aware=config.target_aware, - add_imputer=add_imputer, - imputer_strategy=config.numerical_imputation or "median", - add_missing_indicator=config.add_missing_indicator, + add_imputer=plan["add_imputer"], + imputer_strategy=plan["strategy"], + add_missing_indicator=plan["add_indicator"], output_dim=config.output_dim, adaptive=config.adaptive, min_output_dim=config.min_output_dim if config.adaptive else None, @@ -260,14 +261,21 @@ def create_transformer(method: str, *, is_numerical: bool, config: PreprocessorC **config.seed_kwargs, ) else: - add_imputer = config.categorical_imputation is not None steps = get_categorical_transformer_steps( method, - add_imputer=add_imputer, - imputer_strategy=config.categorical_imputation or "most_frequent", - add_missing_indicator=config.add_missing_indicator, + add_imputer=plan["add_imputer"], + imputer_strategy=plan["strategy"], + add_missing_indicator=plan["add_indicator"], ) - return Pipeline(steps) + + pipeline = Pipeline(steps) + if plan["separate_state"]: + # Emit a dedicated ``__missing`` column (built on the raw input) alongside + # the imputed representation, so the indicator never enters the basis. + return FeatureUnion( + [("representation", pipeline), ("missing", MissingStateIndicator())] + ) + return pipeline def build_column_transformer(config: PreprocessorConfig, numerical_features, categorical_features) -> ColumnTransformer: diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index 72e9b83..5948d25 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -19,7 +19,7 @@ from .compose.output import compute_output_report, format_output, to_dataframe_output from .core.logging import configure_logging, get_logger from .core.policy import RepresentationPolicy, apply_constant_policy -from .exceptions import ConfigWarning, OutputBudgetError, invalid_param_error +from .exceptions import ConfigWarning, OutputBudgetError, PretabDataError, invalid_param_error logger = get_logger(__name__) @@ -129,6 +129,20 @@ class Preprocessor(TransformerMixin, BaseEstimator): If True, append a binary missing-value indicator column for each imputed feature (via the imputer's ``add_indicator``; a standalone ``MissingIndicator`` is used when imputation is disabled). Applies to both numerical and categorical pipelines. + missing_policy : {"error", "propagate", "impute", "impute_with_indicator", "separate_state"} or None, default=None + High-level missing-value strategy. ``None`` (default) keeps the explicit + ``numerical_imputation`` / ``categorical_imputation`` / ``add_missing_indicator`` + parameters authoritative. When set it overrides them: + + - ``"error"`` -- raise :class:`~pretab.exceptions.PretabDataError` if any missing + value is present at ``fit`` or ``transform``. + - ``"propagate"`` -- disable imputation so NaNs reach the transformers unchanged + (each family applies its own missing-value contract). + - ``"impute"`` -- impute with the configured strategy (no indicator). + - ``"impute_with_indicator"`` -- impute and append a missing indicator column. + - ``"separate_state"`` -- impute for the representation *and* emit a dedicated + ``__missing`` column per feature that stays outside the ordinary basis, so a + downstream model can learn a separate response to missingness. policy : RepresentationPolicy or dict or None, default=None Central edge-case policy (see :class:`~pretab.RepresentationPolicy`) governing how constant columns, out-of-range values, missing values, and non-finite inputs are @@ -282,6 +296,7 @@ def __init__( numerical_imputation="median", categorical_imputation="most_frequent", add_missing_indicator=False, + missing_policy=None, policy=None, max_output_features=None, max_features_per_input=None, @@ -316,6 +331,7 @@ def __init__( self.numerical_imputation = numerical_imputation self.categorical_imputation = categorical_imputation self.add_missing_indicator = add_missing_indicator + self.missing_policy = missing_policy self.policy = policy self.max_output_features = max_output_features self.max_features_per_input = max_features_per_input @@ -368,11 +384,15 @@ def fit(self, X, y=None, embeddings=None): numerical_imputation=self.numerical_imputation, categorical_imputation=self.categorical_imputation, add_missing_indicator=self.add_missing_indicator, + missing_policy=self.missing_policy, verbose=self.verbose, ) X = to_dataframe(X) + if self.missing_policy == "error": + self._reject_missing(X) + self.embeddings_ = False self.embedding_dimensions_ = {} if embeddings is not None: @@ -458,6 +478,9 @@ def transform(self, X, embeddings=None, return_array=False): X = to_dataframe(X, copy=True) + if self.missing_policy == "error": + self._reject_missing(X) + transformed_X = self.column_transformer_.transform(X) if sp.issparse(transformed_X): transformed_X = transformed_X.toarray() @@ -626,6 +649,16 @@ def estimate_memory(self, X) -> int: n_rows, n_cols = self.estimate_output_shape(X) return int(n_rows * n_cols * self._output_itemsize()) + def _reject_missing(self, X) -> None: + """Raise when ``missing_policy="error"`` but ``X`` contains missing values.""" + na_columns = [col for col in X.columns if X[col].isna().any()] + if na_columns: + raise PretabDataError( + f"missing_policy='error' but missing values were found in columns {na_columns}.\n" + "Fix: impute the data first, or choose a different missing_policy " + "('propagate', 'impute', 'impute_with_indicator', 'separate_state')." + ) + def _enforce_output_budget(self, n_rows: int) -> None: """Check the fitted output width against the configured output budget. diff --git a/pretab/transformers/__init__.py b/pretab/transformers/__init__.py index 5c22543..c68964c 100644 --- a/pretab/transformers/__init__.py +++ b/pretab/transformers/__init__.py @@ -3,7 +3,7 @@ LanguageEmbeddingTransformer, OneHotFromOrdinalTransformer, ) -from .encoders import NoTransformer, ToFloatTransformer +from .encoders import MissingStateIndicator, NoTransformer, ToFloatTransformer from .feature_maps import ( FourierFeatureTransformer, NystroemFeaturesTransformer, @@ -37,6 +37,7 @@ "ISplineTransformer", "LanguageEmbeddingTransformer", "MSplineTransformer", + "MissingStateIndicator", "NaturalCubicSplineTransformer", "NoTransformer", "NumericBinningTransformer", diff --git a/pretab/transformers/encoders/__init__.py b/pretab/transformers/encoders/__init__.py index e77bb42..1d729b2 100644 --- a/pretab/transformers/encoders/__init__.py +++ b/pretab/transformers/encoders/__init__.py @@ -5,8 +5,10 @@ """ from .floats import NoTransformer, ToFloatTransformer +from .missing import MissingStateIndicator __all__ = [ + "MissingStateIndicator", "NoTransformer", "ToFloatTransformer", ] diff --git a/pretab/transformers/encoders/missing.py b/pretab/transformers/encoders/missing.py new file mode 100644 index 0000000..ed98d09 --- /dev/null +++ b/pretab/transformers/encoders/missing.py @@ -0,0 +1,96 @@ +import numpy as np +import pandas as pd +from sklearn.base import BaseEstimator, TransformerMixin +from sklearn.utils.validation import check_is_fitted + + +class MissingStateIndicator(TransformerMixin, BaseEstimator): + """Emit a binary ``__missing`` column marking where the input was missing. + + Used by the ``missing_policy="separate_state"`` path: the column is produced + on the *raw* input (before imputation) and kept separate from the ordinary + representation basis, so a downstream model can learn a dedicated response to + missingness rather than confounding it with an imputed value. + + Unlike :class:`sklearn.impute.MissingIndicator`, this works on both numeric + and object (categorical) columns via :func:`pandas.isna` and always emits one + column per input feature. + + Attributes + ---------- + n_features_in_ : int + Number of input features seen during ``fit``. + + Examples + -------- + >>> import numpy as np + >>> from pretab.transformers import MissingStateIndicator + >>> X = np.array([[1.0], [np.nan], [3.0]]) + >>> MissingStateIndicator().fit_transform(X) + array([[0.], + [1.], + [0.]]) + """ + + def fit(self, X, y=None): + """Record the input feature count. + + Parameters + ---------- + X : array-like of shape (n_samples, n_features) + The input data to fit. + y : Ignored + Not used, present for API consistency by convention. + + Returns + ------- + self : object + Fitted transformer. + """ + X = np.asarray(X) + self.n_features_in_ = X.shape[1] if X.ndim > 1 else 1 + return self + + def transform(self, X): + """Return a float mask (``1.0`` where missing, ``0.0`` otherwise). + + Parameters + ---------- + X : array-like of shape (n_samples, n_features) + The input data to inspect. + + Returns + ------- + mask : ndarray of shape (n_samples, n_features) + The missingness indicator as ``float``. + """ + check_is_fitted(self, "n_features_in_") + X = np.asarray(X) + if X.ndim == 1: + X = X.reshape(-1, 1) + return pd.isna(X).astype(float) + + def get_feature_names_out(self, input_features=None): + """Return the output feature names, each suffixed with ``__missing``. + + Parameters + ---------- + input_features : list of str or None + The names of the input features. When ``None``, names of the form + ``x0, x1, ...`` are generated. + + Returns + ------- + feature_names : ndarray of shape (n_features,) + The output feature names. + """ + check_is_fitted(self, "n_features_in_") + if input_features is None: + input_features = [f"x{i}" for i in range(self.n_features_in_)] + return np.asarray([f"{name}__missing" for name in input_features], dtype=object) + + def __sklearn_tags__(self): + """Declare that missing values are expected (they are the signal).""" + tags = super().__sklearn_tags__() + tags.input_tags.allow_nan = True + return tags diff --git a/tests/compose/conftest.py b/tests/compose/conftest.py index cc92e92..3fe1549 100644 --- a/tests/compose/conftest.py +++ b/tests/compose/conftest.py @@ -29,6 +29,7 @@ "numerical_imputation": "median", "categorical_imputation": "most_frequent", "add_missing_indicator": False, + "missing_policy": None, "verbose": 0, } diff --git a/tests/regression/test_edge_cases.py b/tests/regression/test_edge_cases.py new file mode 100644 index 0000000..14387b9 --- /dev/null +++ b/tests/regression/test_edge_cases.py @@ -0,0 +1,109 @@ +"""P8.6 edge-case regression suite. + +End-to-end :class:`~pretab.Preprocessor` guards for the recurring production edge +cases: constant features, ``custombin`` discretization alongside string +categoricals, duplicate support points, missing values, and unseen categories. +These pin the *observable* Preprocessor behaviour (shape, finiteness, +determinism, and typed errors) so later refactors cannot silently change +edge-case handling. +""" + +import numpy as np +import pandas as pd +import pytest + +from pretab import Preprocessor +from pretab.exceptions import PretabDataError + + +def _finite(array) -> bool: + return bool(np.isfinite(array).all()) + + +# --- constant features --------------------------------------------------------- + + +def test_constant_numeric_graceful_method_is_finite(): + X = pd.DataFrame({"const": np.full(50, 3.14), "vary": np.linspace(0.0, 1.0, 50)}) + out = Preprocessor(numerical_method="minmax").fit_transform(X, return_array=True) + assert out.shape == (50, 2) + assert _finite(out) + + +def test_constant_numeric_with_error_policy_raises(): + X = pd.DataFrame({"const": np.full(50, 3.14), "vary": np.linspace(0.0, 1.0, 50)}) + with pytest.raises(PretabDataError): + Preprocessor(numerical_method="minmax", policy={"constant": "error"}).fit(X) + + +# --- custombin + string categoricals ------------------------------------------ + + +def test_custombin_is_deterministic_and_integer_coded(): + rng = np.random.RandomState(11) + X = pd.DataFrame({"num": rng.rand(120), "cat": rng.choice(["red", "green", "blue"], size=120)}) + kwargs = { + "numerical_method": "custombin", + "categorical_method": "one-hot", + "output_dim": 5, + "target_aware": False, + "placement_strategy": "quantile", + } + out1 = Preprocessor(**kwargs).fit_transform(X, return_array=True) + out2 = Preprocessor(**kwargs).fit_transform(X, return_array=True) + np.testing.assert_array_equal(out1, out2) + assert _finite(out1) + # The custombin block is integer-valued bin codes in [0, output_dim). + bin_col = out1[:, 0] + assert np.all(bin_col == np.floor(bin_col)) + assert bin_col.min() >= 0 + assert bin_col.max() < 5 + + +# --- duplicate support points -------------------------------------------------- + + +def test_duplicate_support_points_are_handled(): + # 90% of the mass sits on a single value, forcing duplicate knot candidates. + X = pd.DataFrame({"x": np.concatenate([np.full(90, 0.5), np.linspace(0.0, 1.0, 30)])}) + p = Preprocessor( + numerical_method="bspline", + output_dim=8, + target_aware=False, + placement_strategy="quantile", + ).fit(X) + out = p.transform(X, return_array=True) + assert _finite(out) + assert out.shape[0] == len(X) + + +# --- missing values ------------------------------------------------------------ + + +def test_missing_values_imputed_by_default(): + X = pd.DataFrame({"x": [1.0, 2.0, np.nan, 4.0, 5.0, 6.0]}) + out = Preprocessor(numerical_method="minmax").fit_transform(X, return_array=True) + assert not np.isnan(out).any() + + +def test_missing_values_separate_state_marks_rows(): + X = pd.DataFrame({"x": [1.0, 2.0, np.nan, 4.0, 5.0, 6.0]}) + p = Preprocessor(numerical_method="minmax", missing_policy="separate_state").fit(X) + names = list(p.get_feature_names_out()) + assert any(n.endswith("__missing") for n in names) + out = p.transform(X, return_array=True) + assert _finite(out) + + +# --- unseen categories --------------------------------------------------------- + + +def test_unseen_categories_do_not_crash(): + train = pd.DataFrame({"c": ["a", "b", "a", "b", "a", "b"]}) + unseen = pd.DataFrame({"c": ["a", "b", "c", "a", "z", "b"]}) + p = Preprocessor(categorical_method="one-hot").fit(train) + out = p.transform(unseen, return_array=True) + assert _finite(out) + # handle_unknown="ignore" encodes unseen categories as an all-zero row. + assert out[2].sum() == 0.0 + assert out[4].sum() == 0.0 diff --git a/tests/test_missing_policy.py b/tests/test_missing_policy.py new file mode 100644 index 0000000..3bb2b19 --- /dev/null +++ b/tests/test_missing_policy.py @@ -0,0 +1,165 @@ +"""Tests for the high-level ``missing_policy`` orchestration knob (P8.5).""" + +import numpy as np +import pandas as pd +import pytest +from sklearn.base import clone + +from pretab import Preprocessor +from pretab.exceptions import InvalidParamError, PretabDataError + + +@pytest.fixture +def frame_with_nan(): + return pd.DataFrame( + { + "a": [1.0, 2.0, np.nan, 4.0, 5.0, 6.0], + "b": [0.1, 0.2, 0.3, np.nan, 0.5, 0.6], + } + ) + + +@pytest.fixture +def clean_frame(): + return pd.DataFrame( + { + "a": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0], + "b": [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], + } + ) + + +@pytest.fixture +def y(): + return np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0]) + + +def _bspline(**kwargs): + return Preprocessor( + numerical_method="bspline", + output_dim=8, + target_aware=False, + placement_strategy="quantile", + **kwargs, + ) + + +# --- default / roundtrip ------------------------------------------------------- + + +def test_default_missing_policy_is_none(): + assert Preprocessor().missing_policy is None + + +def test_missing_policy_survives_clone(): + p = _bspline(missing_policy="separate_state") + assert clone(p).missing_policy == "separate_state" + + +def test_default_still_imputes(frame_with_nan, y): + # missing_policy=None keeps numerical_imputation="median" authoritative. + p = Preprocessor(numerical_method="minmax").fit(frame_with_nan, y) + out = p.transform(frame_with_nan, return_array=True) + assert not np.isnan(out).any() + + +# --- error --------------------------------------------------------------------- + + +def test_error_policy_raises_at_fit(frame_with_nan, y): + with pytest.raises(PretabDataError, match="missing_policy='error'"): + _bspline(missing_policy="error").fit(frame_with_nan, y) + + +def test_error_policy_raises_at_transform(clean_frame, frame_with_nan, y): + p = _bspline(missing_policy="error").fit(clean_frame, y) + with pytest.raises(PretabDataError, match="missing_policy='error'"): + p.transform(frame_with_nan) + + +def test_error_policy_passes_when_clean(clean_frame, y): + p = _bspline(missing_policy="error").fit(clean_frame, y) + out = p.transform(clean_frame, return_array=True) + assert np.isfinite(out).all() + + +# --- propagate ----------------------------------------------------------------- + + +def test_propagate_lets_nan_through(frame_with_nan, y): + # MinMaxScaler maintains NaNs at transform; with no imputer they survive. + p = Preprocessor(numerical_method="minmax", missing_policy="propagate").fit(frame_with_nan, y) + out = p.transform(frame_with_nan, return_array=True) + assert np.isnan(out).any() + + +# --- impute -------------------------------------------------------------------- + + +def test_impute_removes_nan(frame_with_nan, y): + p = Preprocessor(numerical_method="minmax", missing_policy="impute").fit(frame_with_nan, y) + out = p.transform(frame_with_nan, return_array=True) + assert not np.isnan(out).any() + + +def test_impute_adds_no_indicator(frame_with_nan, y): + p = Preprocessor(numerical_method="minmax", missing_policy="impute").fit(frame_with_nan, y) + names = list(p.get_feature_names_out()) + assert not any("missing" in n for n in names) + + +# --- impute_with_indicator ----------------------------------------------------- + + +def test_impute_with_indicator_appends_columns(frame_with_nan, y): + plain = Preprocessor(numerical_method="minmax", missing_policy="impute").fit(frame_with_nan, y) + withind = Preprocessor( + numerical_method="minmax", missing_policy="impute_with_indicator" + ).fit(frame_with_nan, y) + assert withind.total_output_dim_ > plain.total_output_dim_ + out = withind.transform(frame_with_nan, return_array=True) + assert not np.isnan(out).any() + + +# --- separate_state ------------------------------------------------------------ + + +def test_separate_state_emits_missing_column(frame_with_nan, y): + p = _bspline(missing_policy="separate_state").fit(frame_with_nan, y) + names = list(p.get_feature_names_out()) + missing_cols = [n for n in names if n.endswith("__missing")] + assert len(missing_cols) == 2 # one per input feature + + +def test_separate_state_output_is_finite(frame_with_nan, y): + p = _bspline(missing_policy="separate_state").fit(frame_with_nan, y) + out = p.transform(frame_with_nan, return_array=True) + assert np.isfinite(out).all() + + +def test_separate_state_indicator_marks_missing_rows(frame_with_nan, y): + p = _bspline(missing_policy="separate_state").fit(frame_with_nan, y) + names = list(p.get_feature_names_out()) + arr = p.transform(frame_with_nan, return_array=True) + a_missing_name = next(n for n in names if n.startswith("num_a") and n.endswith("__missing")) + a_missing = arr[:, names.index(a_missing_name)] + # Column "a" is missing at row index 2. + assert a_missing[2] == 1.0 + assert a_missing[0] == 0.0 + + +def test_separate_state_on_categorical(y): + frame = pd.DataFrame({"c": ["x", "y", None, "x", "y", "x"]}) + p = Preprocessor( + categorical_method="one-hot", missing_policy="separate_state" + ).fit(frame, y) + names = list(p.get_feature_names_out()) + assert any(n.endswith("__missing") for n in names) + + +# --- validation ---------------------------------------------------------------- + + +def test_invalid_missing_policy_raises(frame_with_nan, y): + with pytest.raises(InvalidParamError): + _bspline(missing_policy="nonsense").fit(frame_with_nan, y) diff --git a/tests/test_preprocessor.py b/tests/test_preprocessor.py index ad7be3b..31d3454 100644 --- a/tests/test_preprocessor.py +++ b/tests/test_preprocessor.py @@ -117,6 +117,7 @@ def test_dict_keys_reflect_column_names(sample_data): "numerical_imputation", "categorical_imputation", "add_missing_indicator", + "missing_policy", "policy", "max_output_features", "max_features_per_input", From 8bc231d27e01541f2868928cb348b21bc6ba8435 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 26 Jul 2026 21:07:59 +0200 Subject: [PATCH 18/59] feat(serialize): add to_spec/from_spec, fingerprint, and frozen lifecycle --- CHANGELOG.md | 1 + pretab/__init__.py | 10 +- pretab/compose/serialize.py | 232 +++++++++++++++++++++++++++++++++ pretab/exceptions.py | 13 ++ pretab/preprocessor.py | 190 ++++++++++++++++++++++++++- tests/test_fingerprint.py | 109 ++++++++++++++++ tests/test_frozen_lifecycle.py | 111 ++++++++++++++++ tests/test_serialization.py | 201 ++++++++++++++++++++++++++++ 8 files changed, 864 insertions(+), 3 deletions(-) create mode 100644 pretab/compose/serialize.py create mode 100644 tests/test_fingerprint.py create mode 100644 tests/test_frozen_lifecycle.py create mode 100644 tests/test_serialization.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 15ce734..5b03441 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Going forward, this file is updated automatically by `cz bump` on each release. ### Feat +- **serialize**: add portable, versioned serialization to `Preprocessor` (`to_spec` / `from_spec`) that captures a fitted preprocessor as a schema- and dependency-versioned JSON document and reconstructs it bit-for-bit — an auditable, allow-listed alternative to `pickle` that never executes estimator code on load; add a stable cross-process `fingerprint_` (sha256 over the resolved config, seeds, versions, output order, and fitted state) with a `reproducibility_report()`; and add an immutable lifecycle (`lifecycle_state_` ∈ `UNFITTED` / `FITTED` / `FROZEN` / `STALE`, `freeze` / `is_frozen` / `mark_stale` / `clone_unfitted` / `refit`) where `set_params` on a frozen preprocessor raises the new `PretabSerializationError` / `FrozenRepresentationError` (both exported from `pretab`) - **missing**: add a high-level `Preprocessor(missing_policy=...)` control (`error` / `propagate` / `impute` / `impute_with_indicator` / `separate_state`) that overrides the low-level imputation parameters; `separate_state` emits a dedicated `__missing` column (new `MissingStateIndicator`, wired through a per-column `FeatureUnion`) that stays outside the ordinary representation basis, and `error` rejects missing input at fit/transform; pin the end-to-end edge-case behaviour (constant features, `custombin` determinism, duplicate support points, missing values, unseen categories) in `tests/regression/test_edge_cases.py` - **output**: add output-budget controls to `Preprocessor` (`max_output_features`, `max_features_per_input`, `max_dense_memory`, `overflow_policy`, plus `estimate_output_shape` / `estimate_memory`, raising the new `OutputBudgetError`) and first-class output-format control (`output_format ∈ {auto, dense, sparse}`, `dtype`, an `output_report_` memory report, and `set_output(transform="pandas"|"polars")` DataFrame wrapping); defaults (`dense`, no budgets) reproduce historical behaviour - **policy**: add a central `RepresentationPolicy(missing, constant, out_of_range, invalid)` (exported from `pretab`) and a `Preprocessor(policy=...)` hook (resolved to `policy_` at fit) governing constant-column, out-of-range, and non-finite handling; defaults reproduce historical behaviour. Pin the per-family edge-case contract (constant column, all-missing, partial-missing propagation, tiny n, duplicate support points, out-of-range, infinity, feature-count mismatch) in `tests/test_edge_case_contract.py`, and fix silent-corruption gaps so every spline family raises a typed `PretabDataError` on a constant or all-missing column (and cleanly propagates partial-missing rows), feature maps reject all-missing columns, and `NumericBinningTransformer` rejects non-finite input diff --git a/pretab/__init__.py b/pretab/__init__.py index 8008b9a..310c7e1 100644 --- a/pretab/__init__.py +++ b/pretab/__init__.py @@ -4,15 +4,23 @@ from .core.policy import RepresentationPolicy from .core.representation import FeatureLineage, RepresentationSpec from .core.supervised import CrossFittedTransformer -from .exceptions import LeakageWarning, OutputBudgetError, PretabWarning +from .exceptions import ( + FrozenRepresentationError, + LeakageWarning, + OutputBudgetError, + PretabSerializationError, + PretabWarning, +) from .preprocessor import Preprocessor __all__ = [ "CrossFittedTransformer", "FeatureLineage", + "FrozenRepresentationError", "LeakageWarning", "OutputBudgetError", "Preprocessor", + "PretabSerializationError", "PretabWarning", "RepresentationPolicy", "RepresentationSearchCV", diff --git a/pretab/compose/serialize.py b/pretab/compose/serialize.py new file mode 100644 index 0000000..ba8d3fe --- /dev/null +++ b/pretab/compose/serialize.py @@ -0,0 +1,232 @@ +"""Portable, versioned JSON (de)serialization for the fitted preprocessor. + +:func:`preprocessor_to_spec` / :func:`preprocessor_from_spec` capture a fitted +:class:`~pretab.preprocessor.Preprocessor` as a self-describing JSON document -- +schema-versioned, dependency-versioned, and human-inspectable -- that +reconstructs the estimator bit-for-bit. It is an explicit, auditable alternative +to :mod:`pickle`: loading a spec only ever imports an allow-listed set of library +namespaces and never runs estimator ``__init__`` / ``__reduce__`` / +``__setstate__`` code, so a spec cannot execute arbitrary code the way +``pickle.load`` can. + +The document has a small declarative envelope (schema/library versions, resolved +constructor params, per-representation summary, output column order) plus a +``state`` payload that JSON-encodes the fitted object graph (numpy arrays, knot / +center / bin locations, scalers, nested estimators) for exact reconstruction. +""" + +import dataclasses +import importlib + +import numpy as np +from sklearn.base import BaseEstimator +from sklearn.utils.validation import check_is_fitted + +from .._version import __version__ as _PRETAB_VERSION +from ..core.parameters import UNSET +from ..exceptions import PretabError, PretabSerializationError + +SCHEMA_VERSION = 1 + +# Top-level packages that a spec is allowed to import classes/types from. +_ALLOWED_TOP_LEVEL = frozenset({"pretab", "sklearn", "numpy", "scipy", "builtins"}) + + +# --- helpers ------------------------------------------------------------- +def _qualname(obj) -> str: + cls = type(obj) + return f"{cls.__module__}:{cls.__qualname__}" + + +def _module_allowed(module: str) -> bool: + return module.partition(".")[0] in _ALLOWED_TOP_LEVEL + + +def _resolve(dotted: str): + """Import and return the class/type named ``module:qualname`` from the allow-list.""" + module, _, qualname = dotted.partition(":") + if not _module_allowed(module): + raise PretabSerializationError( + f"Refusing to import disallowed module {module!r} while loading a spec. " + f"Only {sorted(_ALLOWED_TOP_LEVEL)} are permitted." + ) + obj = importlib.import_module(module) + for part in qualname.split("."): + obj = getattr(obj, part) + return obj + + +# --- encoding ------------------------------------------------------------ +def _encode(obj): + if isinstance(obj, np.bool_): + return bool(obj) + if isinstance(obj, np.integer): + return int(obj) + if isinstance(obj, np.floating): + return float(obj) + if obj is None or isinstance(obj, (bool, int, float, str)): + return obj + if obj is UNSET: + return {"__unset__": True} + if isinstance(obj, np.ndarray): + return {"__ndarray__": {"dtype": obj.dtype.str, "shape": list(obj.shape), "data": obj.tolist()}} + if isinstance(obj, np.dtype): + return {"__npdtype__": obj.str} + if isinstance(obj, type): + return {"__type__": f"{obj.__module__}:{obj.__qualname__}"} + if isinstance(obj, slice): + return {"__slice__": [obj.start, obj.stop, obj.step]} + if isinstance(obj, tuple): + return {"__tuple__": [_encode(v) for v in obj]} + if isinstance(obj, list): + return [_encode(v) for v in obj] + if isinstance(obj, BaseEstimator): + return {"__estimator__": {"class": _qualname(obj), "state": _encode_mapping(vars(obj))}} + if dataclasses.is_dataclass(obj) and not isinstance(obj, type): + fields = {f.name: _encode(getattr(obj, f.name)) for f in dataclasses.fields(obj)} + return {"__dataclass__": {"class": _qualname(obj), "fields": fields}} + if isinstance(obj, dict): + return {"__dict__": [[_encode(k), _encode(v)] for k, v in obj.items()]} + raise PretabSerializationError(f"Cannot serialize value of type {type(obj).__module__}.{type(obj).__name__!r}.") + + +def _encode_mapping(mapping: dict) -> dict: + """Encode a ``str``-keyed attribute mapping (an estimator/object ``__dict__``).""" + return {str(k): _encode(v) for k, v in mapping.items()} + + +def _json_safe(obj): + """Best-effort readable encoding used for the declarative ``params`` block. + + Keeps JSON-native values verbatim and falls back to the tagged :func:`_encode` + form only for exotic values. The result is informational and never decoded. + """ + if obj is None or isinstance(obj, (bool, int, float, str)): + return obj + if isinstance(obj, dict) and all(isinstance(k, str) for k in obj): + return {k: _json_safe(v) for k, v in obj.items()} + if isinstance(obj, (list, tuple)): + return [_json_safe(v) for v in obj] + return _encode(obj) + + +# --- decoding ------------------------------------------------------------ +def _decode_ndarray(payload: dict) -> np.ndarray: + dtype = np.dtype(payload["dtype"]) + arr = np.array(payload["data"], dtype=dtype) + return arr.reshape(payload["shape"]) + + +def _decode(obj): + if obj is None or isinstance(obj, (bool, int, float, str)): + return obj + if isinstance(obj, list): + return [_decode(v) for v in obj] + if isinstance(obj, dict): + if "__ndarray__" in obj: + return _decode_ndarray(obj["__ndarray__"]) + if "__unset__" in obj: + return UNSET + if "__npdtype__" in obj: + return np.dtype(obj["__npdtype__"]) + if "__type__" in obj: + return _resolve(obj["__type__"]) + if "__slice__" in obj: + start, stop, step = obj["__slice__"] + return slice(start, stop, step) + if "__tuple__" in obj: + return tuple(_decode(v) for v in obj["__tuple__"]) + if "__dict__" in obj: + return {_decode(k): _decode(v) for k, v in obj["__dict__"]} + if "__estimator__" in obj: + return _decode_estimator(obj["__estimator__"]) + if "__dataclass__" in obj: + return _decode_dataclass(obj["__dataclass__"]) + raise PretabSerializationError(f"Unrecognized encoded object with keys {sorted(obj)}.") + raise PretabSerializationError(f"Cannot decode value of type {type(obj).__name__!r}.") + + +def _decode_mapping(mapping: dict) -> dict: + return {k: _decode(v) for k, v in mapping.items()} + + +def _decode_estimator(payload: dict): + cls = _resolve(payload["class"]) + obj = cls.__new__(cls) + obj.__dict__.update(_decode_mapping(payload["state"])) + return obj + + +def _decode_dataclass(payload: dict): + cls = _resolve(payload["class"]) + fields = {k: _decode(v) for k, v in payload["fields"].items()} + return cls(**fields) + + +# --- envelope ------------------------------------------------------------ +def _library_versions() -> dict: + import scipy + import sklearn + + return { + "numpy": np.__version__, + "scipy": scipy.__version__, + "scikit_learn": sklearn.__version__, + } + + +def _representation_summary(preprocessor) -> list: + """Best-effort declarative per-representation summary (family/columns/locations).""" + summary: list = [] + column_transformer = getattr(preprocessor, "column_transformer_", None) + if column_transformer is None: + return summary + for name, transformer, columns in column_transformer.transformers_: + if name == "remainder": + continue + leaf = transformer.steps[-1][1] if hasattr(transformer, "steps") else transformer + spec_fn = getattr(leaf, "get_representation_spec", None) + if spec_fn is None: + continue + try: + entry = spec_fn().to_dict() + except (PretabError, ValueError, AttributeError, TypeError, KeyError): + continue + entry["columns"] = [str(col) for col in columns] + summary.append(entry) + return summary + + +def preprocessor_to_spec(preprocessor) -> dict: + """Serialize a fitted preprocessor into a portable, versioned spec dictionary.""" + check_is_fitted(preprocessor) + return { + "schema_version": SCHEMA_VERSION, + "pretab_version": _PRETAB_VERSION, + "library_versions": _library_versions(), + "params": _json_safe(preprocessor.get_params(deep=False)), + "feature_names_out": [str(name) for name in preprocessor.get_feature_names_out()], + "representations": _representation_summary(preprocessor), + "state": _encode_mapping(vars(preprocessor)), + } + + +def check_spec_schema(data: dict) -> None: + """Validate the envelope's schema version before reconstruction.""" + if not isinstance(data, dict) or "schema_version" not in data: + raise PretabSerializationError("Not a PreTab spec: missing 'schema_version'.") + version = data["schema_version"] + if version != SCHEMA_VERSION: + raise PretabSerializationError( + f"Unsupported spec schema_version {version!r}; this build of PreTab supports {SCHEMA_VERSION}." + ) + + +def preprocessor_from_spec(data: dict): + """Reconstruct a fitted preprocessor from a spec produced by :func:`preprocessor_to_spec`.""" + check_spec_schema(data) + from ..preprocessor import Preprocessor + + obj = Preprocessor.__new__(Preprocessor) + obj.__dict__.update(_decode_mapping(data["state"])) + return obj diff --git a/pretab/exceptions.py b/pretab/exceptions.py index 9aea891..141c3a0 100644 --- a/pretab/exceptions.py +++ b/pretab/exceptions.py @@ -17,6 +17,7 @@ "ConfigWarning", "DataWarning", "EmptyDataError", + "FrozenRepresentationError", "IncompatibleParamsError", "InsufficientSamplesError", "InvalidParamError", @@ -27,6 +28,7 @@ "PretabDataError", "PretabError", "PretabNotFittedError", + "PretabSerializationError", "PretabWarning", "insufficient_samples_error", "invalid_param_error", @@ -93,6 +95,17 @@ class OutputBudgetError(PretabError, ValueError): (``max_output_features`` / ``max_features_per_input`` / ``max_dense_memory``).""" +class PretabSerializationError(PretabError, ValueError): + """A representation spec could not be serialized or reconstructed + (unsupported value, unknown class, or incompatible ``schema_version``).""" + + +class FrozenRepresentationError(PretabError): + """A mutating operation (e.g. ``set_params``) was attempted on a frozen + preprocessor. Call :meth:`~pretab.preprocessor.Preprocessor.clone_unfitted` + to obtain a fresh, mutable copy.""" + + # --- Message factories --- def invalid_param_error(estimator, param, value, constraint, valid=None): """Build an :class:`InvalidParamError` with a consistent, actionable message. diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index 5948d25..a6a531a 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -1,9 +1,12 @@ +import hashlib +import json +import os import time import warnings import numpy as np from scipy import sparse as sp -from sklearn.base import BaseEstimator, TransformerMixin +from sklearn.base import BaseEstimator, TransformerMixin, clone from sklearn.utils._set_output import _get_output_config from sklearn.utils.validation import check_is_fitted @@ -17,9 +20,17 @@ get_output_slices, ) from .compose.output import compute_output_report, format_output, to_dataframe_output +from .compose.serialize import SCHEMA_VERSION, preprocessor_from_spec, preprocessor_to_spec from .core.logging import configure_logging, get_logger from .core.policy import RepresentationPolicy, apply_constant_policy -from .exceptions import ConfigWarning, OutputBudgetError, PretabDataError, invalid_param_error +from .exceptions import ( + ConfigWarning, + FrozenRepresentationError, + OutputBudgetError, + PretabDataError, + PretabSerializationError, + invalid_param_error, +) logger = get_logger(__name__) @@ -772,3 +783,178 @@ def _log_internal_decisions(self): ): if hasattr(last_step, attr): logger.debug("%s.%s = %r", name, attr, getattr(last_step, attr)) + + # --- Portable serialization (P9.1) --- + def to_spec(self, path=None) -> dict: + """Serialize the fitted preprocessor to a portable, versioned spec. + + Produces a self-describing JSON-compatible dictionary (schema version, + PreTab / numpy / scipy / scikit-learn versions, resolved parameters, a + per-representation summary, the output-column order, and the encoded + fitted state) that reconstructs this estimator bit-for-bit via + :meth:`from_spec`. Unlike :mod:`pickle`, loading a spec never executes + estimator code and only imports an allow-listed set of library modules. + + Parameters + ---------- + path : str, os.PathLike, or None, default=None + When given, the spec is also written to this path as UTF-8 JSON. + + Returns + ------- + dict + The spec dictionary (always returned, whether or not ``path`` is set). + """ + check_is_fitted(self) + spec = preprocessor_to_spec(self) + if path is not None: + with open(path, "w", encoding="utf-8") as handle: + json.dump(spec, handle, indent=2) + return spec + + @classmethod + def from_spec(cls, source) -> "Preprocessor": + """Reconstruct a fitted preprocessor from a spec created by :meth:`to_spec`. + + Parameters + ---------- + source : str, os.PathLike, or dict + A path to a JSON spec file, or the spec dictionary itself. + + Returns + ------- + Preprocessor + A fitted preprocessor equivalent to the one that produced the spec; + ``transform`` reproduces the original output bit-for-bit. + """ + if isinstance(source, dict): + data = source + elif isinstance(source, (str, os.PathLike)): + with open(source, encoding="utf-8") as handle: + data = json.load(handle) + else: + raise PretabSerializationError("from_spec expects a spec dict or a path to a JSON spec file.") + obj = preprocessor_from_spec(data) + if not isinstance(obj, cls): + raise PretabSerializationError(f"Spec reconstructed a {type(obj).__name__}, expected {cls.__name__}.") + return obj + + # --- Fingerprint & reproducibility (P9.2) --- + def _canonical_spec(self) -> dict: + """Deterministic subset of the spec used for fingerprinting.""" + spec = preprocessor_to_spec(self) + return { + "schema_version": spec["schema_version"], + "pretab_version": spec["pretab_version"], + "library_versions": spec["library_versions"], + "feature_names_out": spec["feature_names_out"], + "state": spec["state"], + } + + @property + def fingerprint_(self) -> str: + """Stable SHA-256 digest identifying this fitted preprocessor. + + Fitted attribute. Computed over a canonical JSON view of the resolved + configuration, dependency versions, output-column order, random seeds, and + the fitted state (knot / center / bin locations, scaler statistics, encoder + categories). The digest is deterministic across processes and machines, so + two preprocessors share a fingerprint iff they transform identically. + """ + check_is_fitted(self) + canonical = json.dumps(self._canonical_spec(), sort_keys=True, separators=(",", ":"), ensure_ascii=True) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + def reproducibility_report(self) -> dict: + """Return a machine-readable reproducibility summary for this fitted preprocessor. + + Returns + ------- + dict + Fingerprint, schema / library versions, random seed, output dtype and + format, input/output widths, and the per-feature representation + families -- everything needed to audit or reproduce the fit. + """ + check_is_fitted(self) + spec = preprocessor_to_spec(self) + representations = { + entry["columns"][0]: entry.get("family") for entry in spec["representations"] if entry.get("columns") + } + return { + "fingerprint": self.fingerprint_, + "schema_version": SCHEMA_VERSION, + "pretab_version": spec["pretab_version"], + "library_versions": spec["library_versions"], + "random_state": self.random_state, + "output_format": self.output_format, + "dtype": None if self.dtype is None else str(self.dtype), + "n_features_in": int(self.n_features_in_), + "n_output_features": len(spec["feature_names_out"]), + "representations": representations, + } + + # --- Immutable lifecycle (P9.3) --- + @property + def lifecycle_state_(self) -> str: + """Current lifecycle state: ``UNFITTED``, ``FITTED``, ``FROZEN``, or ``STALE``.""" + try: + check_is_fitted(self) + except Exception: + return "UNFITTED" + if getattr(self, "_frozen", False): + return "FROZEN" + if getattr(self, "_stale_reason", None) is not None: + return "STALE" + return "FITTED" + + def is_frozen(self) -> bool: + """Return whether this preprocessor has been frozen against mutation.""" + return bool(getattr(self, "_frozen", False)) + + def freeze(self) -> "Preprocessor": + """Freeze the fitted preprocessor, blocking further ``set_params`` mutation. + + Returns ``self`` for chaining. A frozen preprocessor is intended as an + immutable deployment artifact; use :meth:`clone_unfitted` or :meth:`refit` + to obtain a fresh, mutable estimator. + """ + check_is_fitted(self) + self._frozen = True + return self + + def mark_stale(self, reason: str) -> "Preprocessor": + """Mark this fitted preprocessor as stale (its inputs/assumptions changed). + + Records ``reason`` and flips :attr:`lifecycle_state_` to ``STALE`` (unless + already ``FROZEN``). Purely advisory: it does not alter the fitted state. + Returns ``self`` for chaining. + """ + check_is_fitted(self) + self._stale_reason = reason + return self + + @property + def stale_reason_(self): + """The reason recorded by :meth:`mark_stale`, or ``None``.""" + return getattr(self, "_stale_reason", None) + + def clone_unfitted(self) -> "Preprocessor": + """Return a fresh, unfitted, mutable copy carrying the same constructor params.""" + return clone(self) + + def refit(self, X, y=None, embeddings=None) -> "Preprocessor": + """Fit a fresh copy on new data and return it, leaving ``self`` untouched. + + Enables re-fitting a frozen or deployed preprocessor without mutating the + original: returns a new, unfrozen, fitted :class:`Preprocessor`. + """ + return self.clone_unfitted().fit(X, y, embeddings=embeddings) + + def set_params(self, **params): + """Set parameters, refusing to mutate a frozen preprocessor.""" + if params and self.is_frozen(): + raise FrozenRepresentationError( + f"Cannot set_params({', '.join(sorted(params))}) on a frozen {type(self).__name__}. " + "Use clone_unfitted() for a mutable copy, or refit() to fit fresh data." + ) + return super().set_params(**params) diff --git a/tests/test_fingerprint.py b/tests/test_fingerprint.py new file mode 100644 index 0000000..3f1e6ab --- /dev/null +++ b/tests/test_fingerprint.py @@ -0,0 +1,109 @@ +"""Tests for the fingerprint and reproducibility report (P9.2). + +Covers determinism within and across processes, sensitivity to configuration / +data / seed changes, round-trip stability, and the ``reproducibility_report`` +contents. +""" + +import subprocess +import sys +import textwrap + +import numpy as np +import pandas as pd +import pytest + +from pretab import Preprocessor + + +@pytest.fixture +def frame(): + rng = np.random.default_rng(0) + return pd.DataFrame({"a": rng.random(50), "b": rng.random(50) * 5.0, "c": rng.choice(["x", "y", "z"], 50)}) + + +@pytest.fixture +def target(): + rng = np.random.default_rng(1) + return rng.random(50) + + +def _fit(frame, target, **kwargs): + params = {"numerical_method": "rbf", "target_aware": False, "placement_strategy": "quantile"} + params.update(kwargs) + return Preprocessor(**params).fit(frame, target) + + +def test_fingerprint_is_hex_sha256(frame, target): + fp = _fit(frame, target).fingerprint_ + assert isinstance(fp, str) + assert len(fp) == 64 + assert all(ch in "0123456789abcdef" for ch in fp) + + +def test_fingerprint_deterministic_same_fit(frame, target): + assert _fit(frame, target).fingerprint_ == _fit(frame, target).fingerprint_ + + +def test_fingerprint_survives_round_trip(frame, target): + p = _fit(frame, target) + restored = Preprocessor.from_spec(p.to_spec()) + assert restored.fingerprint_ == p.fingerprint_ + + +def test_fingerprint_changes_with_config(frame, target): + a = _fit(frame, target, output_dim=6) + b = _fit(frame, target, output_dim=9) + assert a.fingerprint_ != b.fingerprint_ + + +def test_fingerprint_changes_with_data(frame, target): + other = frame.copy() + other.loc[other.index[0], "a"] = other.loc[other.index[0], "a"] + 1.0 + assert _fit(frame, target).fingerprint_ != _fit(other, target).fingerprint_ + + +def test_fingerprint_changes_with_seed(frame, target): + # The fingerprint incorporates the seed (roadmap D12), so distinct seeds yield + # distinct fingerprints even when the fitted state happens to coincide. + a = _fit(frame, target, numerical_method="ple", target_aware=True, placement_strategy="cart", random_state=0) + b = _fit(frame, target, numerical_method="ple", target_aware=True, placement_strategy="cart", random_state=1) + assert a.fingerprint_ != b.fingerprint_ + + +def test_fingerprint_stable_across_processes(frame, target): + script = textwrap.dedent( + """ + import numpy as np, pandas as pd + from pretab import Preprocessor + rng = np.random.default_rng(0) + frame = pd.DataFrame({"a": rng.random(50), "b": rng.random(50) * 5.0, + "c": rng.choice(["x", "y", "z"], 50)}) + y = np.random.default_rng(1).random(50) + p = Preprocessor(numerical_method="rbf", target_aware=False, + placement_strategy="quantile").fit(frame, y) + print(p.fingerprint_) + """ + ) + def _run(): + result = subprocess.run( # noqa: S603 - fixed interpreter + inline script, no untrusted input + [sys.executable, "-c", script], capture_output=True, text=True, check=True + ) + return result.stdout.strip() + + first = _run() + second = _run() + assert first == second + assert first == _fit(frame, target).fingerprint_ + + +def test_reproducibility_report_contents(frame, target): + p = _fit(frame, target) + report = p.reproducibility_report() + assert report["fingerprint"] == p.fingerprint_ + assert report["schema_version"] == 1 + assert set(report["library_versions"]) == {"numpy", "scipy", "scikit_learn"} + assert report["n_features_in"] == frame.shape[1] + assert report["n_output_features"] == len(p.get_feature_names_out()) + assert report["output_format"] == "dense" + assert "a" in report["representations"] diff --git a/tests/test_frozen_lifecycle.py b/tests/test_frozen_lifecycle.py new file mode 100644 index 0000000..814cdd8 --- /dev/null +++ b/tests/test_frozen_lifecycle.py @@ -0,0 +1,111 @@ +"""Tests for the immutable lifecycle: freeze / stale / clone / refit (P9.3). + +Covers the ``lifecycle_state_`` transitions, ``freeze`` / ``is_frozen``, +``set_params`` rejection on frozen instances, ``clone_unfitted`` and ``refit`` +returning fresh objects, and ``mark_stale``. +""" + +import numpy as np +import pandas as pd +import pytest + +from pretab import FrozenRepresentationError, Preprocessor + + +@pytest.fixture +def frame(): + rng = np.random.default_rng(0) + return pd.DataFrame({"a": rng.random(40), "c": rng.choice(["x", "y"], 40)}) + + +@pytest.fixture +def target(): + return np.random.default_rng(1).random(40) + + +def _make(): + return Preprocessor(numerical_method="rbf", target_aware=False, placement_strategy="quantile") + + +def test_unfitted_state(frame, target): + p = _make() + assert p.lifecycle_state_ == "UNFITTED" + assert p.is_frozen() is False + + +def test_fitted_state(frame, target): + p = _make().fit(frame, target) + assert p.lifecycle_state_ == "FITTED" + + +def test_freeze_transitions_and_blocks_set_params(frame, target): + p = _make().fit(frame, target) + returned = p.freeze() + assert returned is p + assert p.is_frozen() is True + assert p.lifecycle_state_ == "FROZEN" + + with pytest.raises(FrozenRepresentationError, match="frozen"): + p.set_params(output_dim=9) + + +def test_set_params_allowed_before_freeze(frame, target): + p = _make() + p.set_params(output_dim=9) + assert p.output_dim == 9 + + +def test_clone_unfitted_returns_fresh_unfrozen(frame, target): + p = _make().fit(frame, target).freeze() + clone = p.clone_unfitted() + assert clone is not p + assert clone.lifecycle_state_ == "UNFITTED" + assert clone.is_frozen() is False + # Params carry over; the clone is mutable. + clone.set_params(output_dim=5) + assert clone.output_dim == 5 + + +def test_refit_returns_new_object_and_leaves_original(frame, target): + p = _make().fit(frame, target).freeze() + refit = p.refit(frame, target) + assert refit is not p + assert refit.lifecycle_state_ == "FITTED" + assert refit.is_frozen() is False + # Original stays frozen and untouched. + assert p.is_frozen() is True + assert np.array_equal( + p.transform(frame, return_array=True), refit.transform(frame, return_array=True), equal_nan=True + ) + + +def test_mark_stale(frame, target): + p = _make().fit(frame, target) + returned = p.mark_stale("input schema drifted") + assert returned is p + assert p.lifecycle_state_ == "STALE" + assert p.stale_reason_ == "input schema drifted" + + +def test_frozen_takes_precedence_over_stale(frame, target): + p = _make().fit(frame, target) + p.mark_stale("drift") + p.freeze() + assert p.lifecycle_state_ == "FROZEN" + + +def test_freeze_requires_fitted(): + from sklearn.exceptions import NotFittedError + + with pytest.raises(NotFittedError): + _make().freeze() + + +def test_clone_preserves_unfrozen_via_sklearn_clone(frame, target): + from sklearn.base import clone + + p = _make().fit(frame, target).freeze() + fresh = clone(p) + assert fresh.is_frozen() is False + fresh.set_params(output_dim=7) + assert fresh.output_dim == 7 diff --git a/tests/test_serialization.py b/tests/test_serialization.py new file mode 100644 index 0000000..74c21e4 --- /dev/null +++ b/tests/test_serialization.py @@ -0,0 +1,201 @@ +"""Tests for portable serialization: ``to_spec`` / ``from_spec`` (P9.1). + +Covers the versioned JSON envelope, bit-for-bit transform reproduction across +representation families, file round-trips, categorical / missing-value handling, +policy preservation, and the security allow-list that keeps loading a spec safe +(unlike ``pickle``). +""" + +import json + +import numpy as np +import pandas as pd +import pytest + +from pretab import Preprocessor, PretabSerializationError, RepresentationPolicy +from pretab.compose.serialize import SCHEMA_VERSION + + +@pytest.fixture +def frame(): + rng = np.random.default_rng(0) + return pd.DataFrame( + { + "a": rng.random(60), + "b": rng.random(60) * 10.0, + "c": rng.choice(["x", "y", "z"], 60), + } + ) + + +@pytest.fixture +def target(): + rng = np.random.default_rng(1) + return rng.random(60) + + +# Representation configs that round-trip; each is (params, id). +_CONFIGS = [ + {"numerical_method": "rbf", "target_aware": False, "placement_strategy": "quantile"}, + {"numerical_method": "sigmoid", "target_aware": False, "placement_strategy": "quantile"}, + {"numerical_method": "tanh", "target_aware": False, "placement_strategy": "quantile"}, + {"numerical_method": "relu", "target_aware": False, "placement_strategy": "quantile"}, + {"numerical_method": "bspline", "target_aware": False, "placement_strategy": "quantile"}, + {"numerical_method": "cubicspline", "target_aware": False, "placement_strategy": "quantile"}, + {"numerical_method": "naturalspline", "target_aware": False, "placement_strategy": "quantile"}, + {"numerical_method": "pspline", "target_aware": False, "placement_strategy": "uniform"}, + {"numerical_method": "ple", "target_aware": True, "placement_strategy": "cart"}, + {"numerical_method": "minmax", "target_aware": True, "placement_strategy": "cart"}, + {"numerical_method": "standardization", "target_aware": True, "placement_strategy": "cart"}, + {"numerical_method": "quantile", "target_aware": True, "placement_strategy": "cart"}, +] + + +def _ids(configs): + return [c["numerical_method"] for c in configs] + + +@pytest.mark.parametrize("params", _CONFIGS, ids=_ids(_CONFIGS)) +@pytest.mark.parametrize("categorical_method", ["int", "one-hot"]) +def test_round_trip_reproduces_transform_bit_for_bit(frame, target, params, categorical_method): + p = Preprocessor(output_dim=6, categorical_method=categorical_method, **params).fit(frame, target) + reference = np.asarray(p.transform(frame, return_array=True), dtype=float) + + restored = Preprocessor.from_spec(p.to_spec()) + reproduced = np.asarray(restored.transform(frame, return_array=True), dtype=float) + + assert np.array_equal(reference, reproduced, equal_nan=True) + assert list(p.get_feature_names_out()) == list(restored.get_feature_names_out()) + + +def test_spec_is_json_serializable_and_versioned(frame, target): + p = Preprocessor(numerical_method="bspline", target_aware=False, placement_strategy="quantile").fit(frame, target) + spec = p.to_spec() + + # The whole envelope must survive a JSON dumps/loads cycle unchanged. + reparsed = json.loads(json.dumps(spec)) + assert reparsed["schema_version"] == SCHEMA_VERSION + assert reparsed["pretab_version"] == spec["pretab_version"] + assert set(reparsed["library_versions"]) == {"numpy", "scipy", "scikit_learn"} + assert reparsed["feature_names_out"] == list(p.get_feature_names_out()) + + +def test_file_round_trip(tmp_path, frame, target): + p = Preprocessor( + numerical_method="rbf", categorical_method="one-hot", target_aware=False, placement_strategy="quantile" + ).fit(frame, target) + path = tmp_path / "rep.json" + + returned = p.to_spec(path) + assert path.exists() + assert returned["schema_version"] == SCHEMA_VERSION # to_spec still returns the dict + + restored = Preprocessor.from_spec(str(path)) + assert np.array_equal( + np.asarray(p.transform(frame, return_array=True), dtype=float), + np.asarray(restored.transform(frame, return_array=True), dtype=float), + equal_nan=True, + ) + + +def test_representation_summary_present(frame, target): + p = Preprocessor(numerical_method="rbf", target_aware=False, placement_strategy="quantile").fit(frame, target) + spec = p.to_spec() + families = {entry["family"] for entry in spec["representations"]} + assert "rbf" in families + + +def test_round_trip_preserves_dtype_and_output_format(frame, target): + p = Preprocessor( + numerical_method="bspline", + target_aware=False, + placement_strategy="quantile", + dtype="float32", + output_format="dense", + ).fit(frame, target) + restored = Preprocessor.from_spec(p.to_spec()) + + out = restored.transform(frame, return_array=True) + assert out.dtype == np.float32 + assert restored.dtype == "float32" + assert restored.output_format == "dense" + + +def test_round_trip_preserves_policy(frame, target): + p = Preprocessor( + numerical_method="bspline", + target_aware=False, + placement_strategy="quantile", + policy={"constant": "error"}, + ).fit(frame, target) + restored = Preprocessor.from_spec(p.to_spec()) + assert isinstance(restored.policy_, RepresentationPolicy) + assert restored.policy_.constant == "error" + + +def test_round_trip_with_missing_values(target): + frame = pd.DataFrame({"a": [1.0, np.nan, 3.0, 4.0, np.nan, 6.0] * 5, "c": ["x", "y", None, "x", "y", "z"] * 5}) + y = np.arange(len(frame), dtype=float) + p = Preprocessor( + numerical_method="rbf", + categorical_method="one-hot", + target_aware=False, + placement_strategy="quantile", + numerical_imputation="median", + ).fit(frame, y) + restored = Preprocessor.from_spec(p.to_spec()) + assert np.array_equal( + np.asarray(p.transform(frame, return_array=True), dtype=float), + np.asarray(restored.transform(frame, return_array=True), dtype=float), + equal_nan=True, + ) + + +def test_round_trip_reproduces_unseen_category_encoding(frame, target): + p = Preprocessor( + numerical_method="rbf", categorical_method="one-hot", target_aware=False, placement_strategy="quantile" + ).fit(frame, target) + restored = Preprocessor.from_spec(p.to_spec()) + + unseen = frame.copy() + unseen.loc[unseen.index[:5], "c"] = "brand_new" + assert np.array_equal( + np.asarray(p.transform(unseen, return_array=True), dtype=float), + np.asarray(restored.transform(unseen, return_array=True), dtype=float), + equal_nan=True, + ) + + +def test_to_spec_requires_fitted(): + from sklearn.exceptions import NotFittedError + + p = Preprocessor(numerical_method="rbf", target_aware=False, placement_strategy="quantile") + with pytest.raises(NotFittedError): + p.to_spec() + + +def test_from_spec_rejects_unknown_schema_version(frame, target): + p = Preprocessor(numerical_method="rbf", target_aware=False, placement_strategy="quantile").fit(frame, target) + spec = p.to_spec() + spec["schema_version"] = SCHEMA_VERSION + 999 + with pytest.raises(PretabSerializationError, match="schema_version"): + Preprocessor.from_spec(spec) + + +def test_from_spec_rejects_missing_schema_version(): + with pytest.raises(PretabSerializationError, match="schema_version"): + Preprocessor.from_spec({"state": {}}) + + +def test_from_spec_refuses_disallowed_module(frame, target): + p = Preprocessor(numerical_method="rbf", target_aware=False, placement_strategy="quantile").fit(frame, target) + spec = p.to_spec() + # Simulate a tampered spec that tries to import an arbitrary class on load. + spec["state"]["column_transformer_"] = {"__estimator__": {"class": "os:system", "state": {}}} + with pytest.raises(PretabSerializationError, match="disallowed module"): + Preprocessor.from_spec(spec) + + +def test_from_spec_rejects_bad_source_type(): + with pytest.raises(PretabSerializationError): + Preprocessor.from_spec(12345) From dbcb4f90c451a98ae3f43163145cf7d529b881cb Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 26 Jul 2026 21:28:16 +0200 Subject: [PATCH 19/59] test: organize suite into per-module and integration folders --- tests/{ => compose}/test_categorical_pipeline.py | 0 tests/{ => compose}/test_method_aliases.py | 0 tests/{ => core}/test_adaptive_resolution.py | 0 tests/{ => core}/test_cross_fitted.py | 0 tests/{ => core}/test_feature_map_selector.py | 0 tests/{ => core}/test_location_selectors.py | 0 tests/{ => core}/test_locations.py | 0 tests/{ => core}/test_ple_selector.py | 0 tests/{ => core}/test_representation_spec.py | 0 tests/{ => core}/test_supervised_contract.py | 0 tests/{ => integration}/test_adaptive_output_dim.py | 0 tests/{ => integration}/test_edge_case_contract.py | 0 tests/{ => integration}/test_exceptions.py | 0 tests/{ => integration}/test_feature_lineage.py | 0 tests/{ => integration}/test_fingerprint.py | 0 tests/{ => integration}/test_frozen_lifecycle.py | 0 tests/{ => integration}/test_missing_policy.py | 0 tests/{ => integration}/test_output_budget.py | 0 tests/{ => integration}/test_output_format.py | 0 tests/{ => integration}/test_preprocessor.py | 0 tests/{ => integration}/test_public_api.py | 0 tests/{ => integration}/test_reproducibility.py | 0 tests/{ => integration}/test_serialization.py | 0 tests/{ => integration}/test_verbosity.py | 0 tests/{ => placement}/test_spline_placement_adapter.py | 0 tests/{ => transformers}/test_cubic_transformer.py | 0 tests/{ => transformers}/test_custombin_transformer.py | 0 tests/{ => transformers}/test_encoder_feature_counts.py | 0 tests/{ => transformers}/test_feature_names_out.py | 0 tests/{ => transformers}/test_fourier_transformer.py | 0 tests/{ => transformers}/test_kernel_approx_transformer.py | 0 tests/{ => transformers}/test_language_embedding_transformer.py | 0 tests/{ => transformers}/test_naturalcubic_transformer.py | 0 tests/{ => transformers}/test_onehot_from_ordinal_transformer.py | 0 tests/{ => transformers}/test_output_dimension.py | 0 tests/{ => transformers}/test_param_aliases.py | 0 tests/{ => transformers}/test_periodic.py | 0 tests/{ => transformers}/test_ple_transformer.py | 0 tests/{ => transformers}/test_pspline_transformer.py | 0 tests/{ => transformers}/test_rbfexpansion_transformer.py | 0 tests/{ => transformers}/test_reluexpansion_transformer.py | 0 tests/{ => transformers}/test_sigmoidexpansion_transformer.py | 0 tests/{ => transformers}/test_sklearn_compat.py | 0 tests/{ => transformers}/test_spline_api_parity.py | 0 tests/{ => transformers}/test_spline_expansions.py | 0 tests/{ => transformers}/test_tanh_transformer.py | 0 tests/{ => transformers}/test_tensorproduct_transformer.py | 0 tests/{ => transformers}/test_thinplate_transformer.py | 0 48 files changed, 0 insertions(+), 0 deletions(-) rename tests/{ => compose}/test_categorical_pipeline.py (100%) rename tests/{ => compose}/test_method_aliases.py (100%) rename tests/{ => core}/test_adaptive_resolution.py (100%) rename tests/{ => core}/test_cross_fitted.py (100%) rename tests/{ => core}/test_feature_map_selector.py (100%) rename tests/{ => core}/test_location_selectors.py (100%) rename tests/{ => core}/test_locations.py (100%) rename tests/{ => core}/test_ple_selector.py (100%) rename tests/{ => core}/test_representation_spec.py (100%) rename tests/{ => core}/test_supervised_contract.py (100%) rename tests/{ => integration}/test_adaptive_output_dim.py (100%) rename tests/{ => integration}/test_edge_case_contract.py (100%) rename tests/{ => integration}/test_exceptions.py (100%) rename tests/{ => integration}/test_feature_lineage.py (100%) rename tests/{ => integration}/test_fingerprint.py (100%) rename tests/{ => integration}/test_frozen_lifecycle.py (100%) rename tests/{ => integration}/test_missing_policy.py (100%) rename tests/{ => integration}/test_output_budget.py (100%) rename tests/{ => integration}/test_output_format.py (100%) rename tests/{ => integration}/test_preprocessor.py (100%) rename tests/{ => integration}/test_public_api.py (100%) rename tests/{ => integration}/test_reproducibility.py (100%) rename tests/{ => integration}/test_serialization.py (100%) rename tests/{ => integration}/test_verbosity.py (100%) rename tests/{ => placement}/test_spline_placement_adapter.py (100%) rename tests/{ => transformers}/test_cubic_transformer.py (100%) rename tests/{ => transformers}/test_custombin_transformer.py (100%) rename tests/{ => transformers}/test_encoder_feature_counts.py (100%) rename tests/{ => transformers}/test_feature_names_out.py (100%) rename tests/{ => transformers}/test_fourier_transformer.py (100%) rename tests/{ => transformers}/test_kernel_approx_transformer.py (100%) rename tests/{ => transformers}/test_language_embedding_transformer.py (100%) rename tests/{ => transformers}/test_naturalcubic_transformer.py (100%) rename tests/{ => transformers}/test_onehot_from_ordinal_transformer.py (100%) rename tests/{ => transformers}/test_output_dimension.py (100%) rename tests/{ => transformers}/test_param_aliases.py (100%) rename tests/{ => transformers}/test_periodic.py (100%) rename tests/{ => transformers}/test_ple_transformer.py (100%) rename tests/{ => transformers}/test_pspline_transformer.py (100%) rename tests/{ => transformers}/test_rbfexpansion_transformer.py (100%) rename tests/{ => transformers}/test_reluexpansion_transformer.py (100%) rename tests/{ => transformers}/test_sigmoidexpansion_transformer.py (100%) rename tests/{ => transformers}/test_sklearn_compat.py (100%) rename tests/{ => transformers}/test_spline_api_parity.py (100%) rename tests/{ => transformers}/test_spline_expansions.py (100%) rename tests/{ => transformers}/test_tanh_transformer.py (100%) rename tests/{ => transformers}/test_tensorproduct_transformer.py (100%) rename tests/{ => transformers}/test_thinplate_transformer.py (100%) diff --git a/tests/test_categorical_pipeline.py b/tests/compose/test_categorical_pipeline.py similarity index 100% rename from tests/test_categorical_pipeline.py rename to tests/compose/test_categorical_pipeline.py diff --git a/tests/test_method_aliases.py b/tests/compose/test_method_aliases.py similarity index 100% rename from tests/test_method_aliases.py rename to tests/compose/test_method_aliases.py diff --git a/tests/test_adaptive_resolution.py b/tests/core/test_adaptive_resolution.py similarity index 100% rename from tests/test_adaptive_resolution.py rename to tests/core/test_adaptive_resolution.py diff --git a/tests/test_cross_fitted.py b/tests/core/test_cross_fitted.py similarity index 100% rename from tests/test_cross_fitted.py rename to tests/core/test_cross_fitted.py diff --git a/tests/test_feature_map_selector.py b/tests/core/test_feature_map_selector.py similarity index 100% rename from tests/test_feature_map_selector.py rename to tests/core/test_feature_map_selector.py diff --git a/tests/test_location_selectors.py b/tests/core/test_location_selectors.py similarity index 100% rename from tests/test_location_selectors.py rename to tests/core/test_location_selectors.py diff --git a/tests/test_locations.py b/tests/core/test_locations.py similarity index 100% rename from tests/test_locations.py rename to tests/core/test_locations.py diff --git a/tests/test_ple_selector.py b/tests/core/test_ple_selector.py similarity index 100% rename from tests/test_ple_selector.py rename to tests/core/test_ple_selector.py diff --git a/tests/test_representation_spec.py b/tests/core/test_representation_spec.py similarity index 100% rename from tests/test_representation_spec.py rename to tests/core/test_representation_spec.py diff --git a/tests/test_supervised_contract.py b/tests/core/test_supervised_contract.py similarity index 100% rename from tests/test_supervised_contract.py rename to tests/core/test_supervised_contract.py diff --git a/tests/test_adaptive_output_dim.py b/tests/integration/test_adaptive_output_dim.py similarity index 100% rename from tests/test_adaptive_output_dim.py rename to tests/integration/test_adaptive_output_dim.py diff --git a/tests/test_edge_case_contract.py b/tests/integration/test_edge_case_contract.py similarity index 100% rename from tests/test_edge_case_contract.py rename to tests/integration/test_edge_case_contract.py diff --git a/tests/test_exceptions.py b/tests/integration/test_exceptions.py similarity index 100% rename from tests/test_exceptions.py rename to tests/integration/test_exceptions.py diff --git a/tests/test_feature_lineage.py b/tests/integration/test_feature_lineage.py similarity index 100% rename from tests/test_feature_lineage.py rename to tests/integration/test_feature_lineage.py diff --git a/tests/test_fingerprint.py b/tests/integration/test_fingerprint.py similarity index 100% rename from tests/test_fingerprint.py rename to tests/integration/test_fingerprint.py diff --git a/tests/test_frozen_lifecycle.py b/tests/integration/test_frozen_lifecycle.py similarity index 100% rename from tests/test_frozen_lifecycle.py rename to tests/integration/test_frozen_lifecycle.py diff --git a/tests/test_missing_policy.py b/tests/integration/test_missing_policy.py similarity index 100% rename from tests/test_missing_policy.py rename to tests/integration/test_missing_policy.py diff --git a/tests/test_output_budget.py b/tests/integration/test_output_budget.py similarity index 100% rename from tests/test_output_budget.py rename to tests/integration/test_output_budget.py diff --git a/tests/test_output_format.py b/tests/integration/test_output_format.py similarity index 100% rename from tests/test_output_format.py rename to tests/integration/test_output_format.py diff --git a/tests/test_preprocessor.py b/tests/integration/test_preprocessor.py similarity index 100% rename from tests/test_preprocessor.py rename to tests/integration/test_preprocessor.py diff --git a/tests/test_public_api.py b/tests/integration/test_public_api.py similarity index 100% rename from tests/test_public_api.py rename to tests/integration/test_public_api.py diff --git a/tests/test_reproducibility.py b/tests/integration/test_reproducibility.py similarity index 100% rename from tests/test_reproducibility.py rename to tests/integration/test_reproducibility.py diff --git a/tests/test_serialization.py b/tests/integration/test_serialization.py similarity index 100% rename from tests/test_serialization.py rename to tests/integration/test_serialization.py diff --git a/tests/test_verbosity.py b/tests/integration/test_verbosity.py similarity index 100% rename from tests/test_verbosity.py rename to tests/integration/test_verbosity.py diff --git a/tests/test_spline_placement_adapter.py b/tests/placement/test_spline_placement_adapter.py similarity index 100% rename from tests/test_spline_placement_adapter.py rename to tests/placement/test_spline_placement_adapter.py diff --git a/tests/test_cubic_transformer.py b/tests/transformers/test_cubic_transformer.py similarity index 100% rename from tests/test_cubic_transformer.py rename to tests/transformers/test_cubic_transformer.py diff --git a/tests/test_custombin_transformer.py b/tests/transformers/test_custombin_transformer.py similarity index 100% rename from tests/test_custombin_transformer.py rename to tests/transformers/test_custombin_transformer.py diff --git a/tests/test_encoder_feature_counts.py b/tests/transformers/test_encoder_feature_counts.py similarity index 100% rename from tests/test_encoder_feature_counts.py rename to tests/transformers/test_encoder_feature_counts.py diff --git a/tests/test_feature_names_out.py b/tests/transformers/test_feature_names_out.py similarity index 100% rename from tests/test_feature_names_out.py rename to tests/transformers/test_feature_names_out.py diff --git a/tests/test_fourier_transformer.py b/tests/transformers/test_fourier_transformer.py similarity index 100% rename from tests/test_fourier_transformer.py rename to tests/transformers/test_fourier_transformer.py diff --git a/tests/test_kernel_approx_transformer.py b/tests/transformers/test_kernel_approx_transformer.py similarity index 100% rename from tests/test_kernel_approx_transformer.py rename to tests/transformers/test_kernel_approx_transformer.py diff --git a/tests/test_language_embedding_transformer.py b/tests/transformers/test_language_embedding_transformer.py similarity index 100% rename from tests/test_language_embedding_transformer.py rename to tests/transformers/test_language_embedding_transformer.py diff --git a/tests/test_naturalcubic_transformer.py b/tests/transformers/test_naturalcubic_transformer.py similarity index 100% rename from tests/test_naturalcubic_transformer.py rename to tests/transformers/test_naturalcubic_transformer.py diff --git a/tests/test_onehot_from_ordinal_transformer.py b/tests/transformers/test_onehot_from_ordinal_transformer.py similarity index 100% rename from tests/test_onehot_from_ordinal_transformer.py rename to tests/transformers/test_onehot_from_ordinal_transformer.py diff --git a/tests/test_output_dimension.py b/tests/transformers/test_output_dimension.py similarity index 100% rename from tests/test_output_dimension.py rename to tests/transformers/test_output_dimension.py diff --git a/tests/test_param_aliases.py b/tests/transformers/test_param_aliases.py similarity index 100% rename from tests/test_param_aliases.py rename to tests/transformers/test_param_aliases.py diff --git a/tests/test_periodic.py b/tests/transformers/test_periodic.py similarity index 100% rename from tests/test_periodic.py rename to tests/transformers/test_periodic.py diff --git a/tests/test_ple_transformer.py b/tests/transformers/test_ple_transformer.py similarity index 100% rename from tests/test_ple_transformer.py rename to tests/transformers/test_ple_transformer.py diff --git a/tests/test_pspline_transformer.py b/tests/transformers/test_pspline_transformer.py similarity index 100% rename from tests/test_pspline_transformer.py rename to tests/transformers/test_pspline_transformer.py diff --git a/tests/test_rbfexpansion_transformer.py b/tests/transformers/test_rbfexpansion_transformer.py similarity index 100% rename from tests/test_rbfexpansion_transformer.py rename to tests/transformers/test_rbfexpansion_transformer.py diff --git a/tests/test_reluexpansion_transformer.py b/tests/transformers/test_reluexpansion_transformer.py similarity index 100% rename from tests/test_reluexpansion_transformer.py rename to tests/transformers/test_reluexpansion_transformer.py diff --git a/tests/test_sigmoidexpansion_transformer.py b/tests/transformers/test_sigmoidexpansion_transformer.py similarity index 100% rename from tests/test_sigmoidexpansion_transformer.py rename to tests/transformers/test_sigmoidexpansion_transformer.py diff --git a/tests/test_sklearn_compat.py b/tests/transformers/test_sklearn_compat.py similarity index 100% rename from tests/test_sklearn_compat.py rename to tests/transformers/test_sklearn_compat.py diff --git a/tests/test_spline_api_parity.py b/tests/transformers/test_spline_api_parity.py similarity index 100% rename from tests/test_spline_api_parity.py rename to tests/transformers/test_spline_api_parity.py diff --git a/tests/test_spline_expansions.py b/tests/transformers/test_spline_expansions.py similarity index 100% rename from tests/test_spline_expansions.py rename to tests/transformers/test_spline_expansions.py diff --git a/tests/test_tanh_transformer.py b/tests/transformers/test_tanh_transformer.py similarity index 100% rename from tests/test_tanh_transformer.py rename to tests/transformers/test_tanh_transformer.py diff --git a/tests/test_tensorproduct_transformer.py b/tests/transformers/test_tensorproduct_transformer.py similarity index 100% rename from tests/test_tensorproduct_transformer.py rename to tests/transformers/test_tensorproduct_transformer.py diff --git a/tests/test_thinplate_transformer.py b/tests/transformers/test_thinplate_transformer.py similarity index 100% rename from tests/test_thinplate_transformer.py rename to tests/transformers/test_thinplate_transformer.py From 7580064180b2a9d124140c07ae3b3770b3ef337c Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Mon, 27 Jul 2026 06:53:07 +0200 Subject: [PATCH 20/59] feat(extension): add representation protocol, discovery, and presets --- CHANGELOG.md | 1 + examples/pretab-chebyshev/README.md | 66 +++ examples/pretab-chebyshev/pyproject.toml | 28 ++ .../src/pretab_chebyshev/__init__.py | 63 +++ .../pretab-chebyshev/tests/test_chebyshev.py | 35 ++ pretab/__init__.py | 14 + pretab/compose/registry.py | 56 ++- pretab/exceptions.py | 8 + pretab/extension.py | 455 ++++++++++++++++++ pretab/preprocessor.py | 133 ++++- tests/extension/conftest.py | 26 + tests/extension/test_conformance.py | 154 ++++++ tests/extension/test_extension_protocol.py | 83 ++++ .../extension/test_registration_discovery.py | 163 +++++++ tests/integration/test_preprocessor.py | 1 + tests/integration/test_presets.py | 87 ++++ 16 files changed, 1349 insertions(+), 24 deletions(-) create mode 100644 examples/pretab-chebyshev/README.md create mode 100644 examples/pretab-chebyshev/pyproject.toml create mode 100644 examples/pretab-chebyshev/src/pretab_chebyshev/__init__.py create mode 100644 examples/pretab-chebyshev/tests/test_chebyshev.py create mode 100644 pretab/extension.py create mode 100644 tests/extension/conftest.py create mode 100644 tests/extension/test_conformance.py create mode 100644 tests/extension/test_extension_protocol.py create mode 100644 tests/extension/test_registration_discovery.py create mode 100644 tests/integration/test_presets.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b03441..50076d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Going forward, this file is updated automatically by `cz bump` on each release. ### Feat +- **extension**: add a public, discoverable extension protocol — a `BaseRepresentation` base class (declaring `representation_name` / `feature_kind` / `scope` / `supervision`) that inherits the shared scikit-learn contract; `register_representation(name, cls)` plus opt-in `load_entry_point_representations()` (the `pretab.representations` entry-point group) to add third-party methods so they are selectable via `Preprocessor(numerical_method=...)`; `list_representations(feature_kind=, scope=, supervised=, periodic=, sparse_output=, adaptive=)` capability discovery; and a `check_representation(cls)` conformance suite (raising the new `RepresentationConformanceError`) verifying fit-returns-self, no input mutation, stable shape, matching feature names, determinism, fitted-state checks, and declared scope/supervision. `Preprocessor` gains transparent `preset="standard"|"expanded"|"adaptive"` aliases and `get_resolved_config()`; `TransformerSpec` gains `periodic` / `sparse_output` capability flags. A runnable sibling example lives at `examples/pretab-chebyshev` (all new symbols exported from `pretab`) - **serialize**: add portable, versioned serialization to `Preprocessor` (`to_spec` / `from_spec`) that captures a fitted preprocessor as a schema- and dependency-versioned JSON document and reconstructs it bit-for-bit — an auditable, allow-listed alternative to `pickle` that never executes estimator code on load; add a stable cross-process `fingerprint_` (sha256 over the resolved config, seeds, versions, output order, and fitted state) with a `reproducibility_report()`; and add an immutable lifecycle (`lifecycle_state_` ∈ `UNFITTED` / `FITTED` / `FROZEN` / `STALE`, `freeze` / `is_frozen` / `mark_stale` / `clone_unfitted` / `refit`) where `set_params` on a frozen preprocessor raises the new `PretabSerializationError` / `FrozenRepresentationError` (both exported from `pretab`) - **missing**: add a high-level `Preprocessor(missing_policy=...)` control (`error` / `propagate` / `impute` / `impute_with_indicator` / `separate_state`) that overrides the low-level imputation parameters; `separate_state` emits a dedicated `__missing` column (new `MissingStateIndicator`, wired through a per-column `FeatureUnion`) that stays outside the ordinary representation basis, and `error` rejects missing input at fit/transform; pin the end-to-end edge-case behaviour (constant features, `custombin` determinism, duplicate support points, missing values, unseen categories) in `tests/regression/test_edge_cases.py` - **output**: add output-budget controls to `Preprocessor` (`max_output_features`, `max_features_per_input`, `max_dense_memory`, `overflow_policy`, plus `estimate_output_shape` / `estimate_memory`, raising the new `OutputBudgetError`) and first-class output-format control (`output_format ∈ {auto, dense, sparse}`, `dtype`, an `output_report_` memory report, and `set_output(transform="pandas"|"polars")` DataFrame wrapping); defaults (`dense`, no budgets) reproduce historical behaviour diff --git a/examples/pretab-chebyshev/README.md b/examples/pretab-chebyshev/README.md new file mode 100644 index 0000000..28bc508 --- /dev/null +++ b/examples/pretab-chebyshev/README.md @@ -0,0 +1,66 @@ +# pretab-chebyshev + +An example, self-contained [PreTab](../../README.md) extension package. It adds a +`chebyshev` representation that expands each numerical feature into a Chebyshev +polynomial basis, and shows the complete third-party extension workflow. + +This directory is a **sibling package** (it lives next to PreTab, not inside it). +In a real project it would be its own repository published to PyPI; it is kept +here only as a runnable reference. + +## What it demonstrates + +- Subclassing `pretab.BaseRepresentation` and declaring `representation_name`, + `feature_kind`, `scope`, and `supervision`. +- Advertising the class through the `pretab.representations` entry-point group + (see `pyproject.toml`) so it is auto-discoverable once installed. +- Passing the PreTab conformance suite (`pretab.check_representation`). +- Being selected by name through `Preprocessor(numerical_method="chebyshev")`. + +## Install + +```bash +cd examples/pretab-chebyshev +pip install -e . +``` + +## Use + +Auto-discover every installed extension via the entry-point group: + +```python +import pretab + +pretab.load_entry_point_representations() # registers "chebyshev" +"chebyshev" in pretab.list_representations(feature_kind="numerical") # True +``` + +Or register the class directly, without relying on entry points: + +```python +from pretab import register_representation +from pretab_chebyshev import ChebyshevRepresentation + +register_representation("chebyshev", ChebyshevRepresentation, allowed_args=("degree",)) +``` + +Then use it like any built-in method: + +```python +import numpy as np, pandas as pd +from pretab import Preprocessor + +X = pd.DataFrame({"a": np.linspace(0, 1, 20), "b": np.linspace(-1, 1, 20)}) +pre = Preprocessor(numerical_method="chebyshev", categorical_method="none", degree=4, + target_aware=False, placement_strategy="uniform") +out = pre.fit_transform(X, return_array=True) # shape (20, 8) +``` + +## Validate + +```python +from pretab import check_representation +from pretab_chebyshev import ChebyshevRepresentation + +check_representation(ChebyshevRepresentation) # raises on any contract violation +``` diff --git a/examples/pretab-chebyshev/pyproject.toml b/examples/pretab-chebyshev/pyproject.toml new file mode 100644 index 0000000..7849367 --- /dev/null +++ b/examples/pretab-chebyshev/pyproject.toml @@ -0,0 +1,28 @@ +[build-system] +requires = ["setuptools>=64"] +build-backend = "setuptools.build_meta" + +[project] +name = "pretab-chebyshev" +version = "0.1.0" +description = "Example PreTab extension: a Chebyshev polynomial feature representation." +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +dependencies = [ + "pretab", + "numpy", + "scikit-learn", +] + +# This is what makes the representation auto-discoverable. Once this package is +# installed, ``pretab.load_entry_point_representations()`` finds and registers +# the class advertised here under the ``pretab.representations`` group. +[project.entry-points."pretab.representations"] +chebyshev = "pretab_chebyshev:ChebyshevRepresentation" + +[project.optional-dependencies] +test = ["pytest"] + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/examples/pretab-chebyshev/src/pretab_chebyshev/__init__.py b/examples/pretab-chebyshev/src/pretab_chebyshev/__init__.py new file mode 100644 index 0000000..7b84505 --- /dev/null +++ b/examples/pretab-chebyshev/src/pretab_chebyshev/__init__.py @@ -0,0 +1,63 @@ +"""A minimal, self-contained PreTab extension package. + +Demonstrates the full third-party extension workflow: subclass +:class:`pretab.BaseRepresentation`, expose the class through the +``pretab.representations`` entry-point group (see ``pyproject.toml``), and let it +be discovered, validated, and used exactly like a built-in representation. +""" + +from __future__ import annotations + +import numpy as np +from sklearn.utils.validation import check_is_fitted + +from pretab import BaseRepresentation + +__all__ = ["ChebyshevRepresentation"] + + +class ChebyshevRepresentation(BaseRepresentation): + """Expand each numerical feature into a Chebyshev polynomial basis. + + Every input column is rescaled to ``[-1, 1]`` using the training-data range, + then expanded into ``T_1 ... T_degree`` Chebyshev polynomials (the constant + ``T_0`` term is dropped to avoid a redundant bias column). This yields + ``degree`` output columns per input feature. + + Parameters + ---------- + degree : int, default=5 + Number of Chebyshev polynomials produced per feature. + """ + + representation_name = "chebyshev" + feature_kind = "numerical" + scope = "univariate" + supervision = "unsupervised" + + def __init__(self, degree=5): + self.degree = degree + + def fit(self, X, y=None): + X = np.asarray(self._validate(X, reset=True), dtype=float) + self.data_min_ = X.min(axis=0) + self.data_max_ = X.max(axis=0) + return self + + def _rescale(self, X): + span = self.data_max_ - self.data_min_ + span = np.where(span == 0.0, 1.0, span) + return np.clip(2.0 * (X - self.data_min_) / span - 1.0, -1.0, 1.0) + + def transform(self, X): + check_is_fitted(self, "n_features_in_") + z = self._rescale(np.asarray(self._validate(X, reset=False), dtype=float)) + theta = np.arccos(z) + blocks = [ + np.column_stack([np.cos(k * theta[:, j]) for k in range(1, self.degree + 1)]) + for j in range(z.shape[1]) + ] + return np.hstack(blocks) + + def _output_sizes(self): + return [self.degree] * self.n_features_in_ diff --git a/examples/pretab-chebyshev/tests/test_chebyshev.py b/examples/pretab-chebyshev/tests/test_chebyshev.py new file mode 100644 index 0000000..899508b --- /dev/null +++ b/examples/pretab-chebyshev/tests/test_chebyshev.py @@ -0,0 +1,35 @@ +"""Illustrative tests for the example extension. + +Run from this directory with ``pip install -e . && pytest``. These are not part +of the main PreTab test suite (which only collects the top-level ``tests/``). +""" + +import numpy as np +import pandas as pd +from pretab_chebyshev import ChebyshevRepresentation + +from pretab import Preprocessor, check_representation, list_representations, register_representation + + +def test_passes_conformance_suite(): + passed = check_representation(ChebyshevRepresentation) + assert "spec_consistent" in passed + assert "deterministic" in passed + + +def test_register_and_use_through_preprocessor(): + register_representation( + "chebyshev", ChebyshevRepresentation, allowed_args=("degree",), override=True + ) + assert "chebyshev" in list_representations(feature_kind="numerical") + + X = pd.DataFrame({"a": np.linspace(0, 1, 20), "b": np.linspace(-1, 1, 20)}) + pre = Preprocessor( + numerical_method="chebyshev", + categorical_method="none", + degree=4, # flows through because "degree" is in allowed_args + target_aware=False, + placement_strategy="uniform", + ) + out = np.asarray(pre.fit_transform(X, return_array=True)) + assert out.shape == (20, 8) # degree 4 x 2 features diff --git a/pretab/__init__.py b/pretab/__init__.py index 310c7e1..afa6482 100644 --- a/pretab/__init__.py +++ b/pretab/__init__.py @@ -10,10 +10,19 @@ OutputBudgetError, PretabSerializationError, PretabWarning, + RepresentationConformanceError, +) +from .extension import ( + BaseRepresentation, + check_representation, + list_representations, + load_entry_point_representations, + register_representation, ) from .preprocessor import Preprocessor __all__ = [ + "BaseRepresentation", "CrossFittedTransformer", "FeatureLineage", "FrozenRepresentationError", @@ -22,10 +31,15 @@ "Preprocessor", "PretabSerializationError", "PretabWarning", + "RepresentationConformanceError", "RepresentationPolicy", "RepresentationSearchCV", "RepresentationSpec", "__version__", + "check_representation", "configure_logging", + "list_representations", + "load_entry_point_representations", + "register_representation", "set_verbosity", ] diff --git a/pretab/compose/registry.py b/pretab/compose/registry.py index d75306b..8466d50 100644 --- a/pretab/compose/registry.py +++ b/pretab/compose/registry.py @@ -68,6 +68,7 @@ "get_spec", "numerical_method_names", "placement_strategies_for", + "register_spec", "resolve_method", "supports_adaptive_resolution", "supports_target_aware", @@ -123,6 +124,12 @@ class TransformerSpec: optional_dependency : str or None The optional extra that must be installed for the method to run (``pip install pretab[]``), or ``None`` when always available. + periodic : bool + Whether the representation encodes a periodic signal (e.g. the Fourier + feature map). Surfaced through :func:`pretab.list_representations`. + sparse_output : bool + Whether the method can emit a sparse matrix (e.g. one-hot). Surfaced + through :func:`pretab.list_representations`. """ name: str @@ -135,6 +142,8 @@ class TransformerSpec: supports_adaptive_resolution: bool = False preprocessor_compatible: bool = True optional_dependency: str | None = None + periodic: bool = False + sparse_output: bool = False @property def is_numerical(self) -> bool: @@ -243,6 +252,7 @@ def _spec(name, cls, allowed_args=(), **kwargs): "fourier", FourierFeatureTransformer, ("n_frequencies", "frequency_strategy", "include_original", "random_state"), + periodic=True, ), # --- numerical: freely-placed knot splines (optional target-aware, adaptive) --- _spec( @@ -318,7 +328,7 @@ def _spec(name, cls, allowed_args=(), **kwargs): _spec("none", NoTransformer, feature_kind=frozenset({NUMERICAL, CATEGORICAL})), # --- categorical-only methods --- _spec("int", ContinuousOrdinalTransformer, feature_kind=frozenset({CATEGORICAL})), - _spec("one-hot", OneHotEncoder, feature_kind=frozenset({CATEGORICAL})), + _spec("one-hot", OneHotEncoder, feature_kind=frozenset({CATEGORICAL}), sparse_output=True), _spec("onehot_from_ordinal", OneHotFromOrdinalTransformer, feature_kind=frozenset({CATEGORICAL})), _spec( "pretrained", @@ -355,7 +365,10 @@ def categorical_method_names() -> frozenset[str]: for name, spec in TRANSFORMER_REGISTRY.items() if spec.is_numerical and spec.preprocessor_compatible } -CATEGORICAL_METHODS: frozenset[str] = categorical_method_names() +# Mutable so :func:`pretab.register_representation` can extend the categorical +# whitelist in place and have the config / factory layers (which import this +# name) observe the addition immediately. +CATEGORICAL_METHODS: set[str] = set(categorical_method_names()) def get_spec(method: str) -> TransformerSpec: @@ -394,6 +407,45 @@ def supports_target_aware(method: str) -> bool: return bool(spec and spec.target_aware_capable) +def register_spec(spec: TransformerSpec, *, override: bool = False) -> TransformerSpec: + """Insert a :class:`TransformerSpec` into the live registry. + + Updates the registry and the derived ``NUMERICAL_METHODS`` / + ``CATEGORICAL_METHODS`` views in place, so the config and factory layers + (which import those names) immediately observe the new method. This backs the + public :func:`pretab.register_representation`. + + Parameters + ---------- + spec : TransformerSpec + The capability record to register. + override : bool, default=False + Whether replacing an already-registered name is allowed. + + Raises + ------ + TypeError + If ``spec`` is not a :class:`TransformerSpec`. + ValueError + If ``spec.name`` is already registered and ``override`` is False. + """ + if not isinstance(spec, TransformerSpec): + raise TypeError(f"expected a TransformerSpec, got {type(spec).__name__}") + if spec.name in TRANSFORMER_REGISTRY and not override: + raise ValueError( + f"method {spec.name!r} is already registered; pass override=True to replace it." + ) + # Drop any stale derived-view entries before re-inserting (supports override). + NUMERICAL_METHODS.pop(spec.name, None) + CATEGORICAL_METHODS.discard(spec.name) + TRANSFORMER_REGISTRY[spec.name] = spec + if spec.is_numerical and spec.preprocessor_compatible: + NUMERICAL_METHODS[spec.name] = (spec.transformer_cls, list(spec.allowed_args)) + if spec.is_categorical: + CATEGORICAL_METHODS.add(spec.name) + return spec + + # --------------------------------------------------------------------------- # Name resolution (aliases + separator/case-insensitive matching). # --------------------------------------------------------------------------- diff --git a/pretab/exceptions.py b/pretab/exceptions.py index 141c3a0..3e2d49f 100644 --- a/pretab/exceptions.py +++ b/pretab/exceptions.py @@ -30,6 +30,7 @@ "PretabNotFittedError", "PretabSerializationError", "PretabWarning", + "RepresentationConformanceError", "insufficient_samples_error", "invalid_param_error", ] @@ -106,6 +107,13 @@ class FrozenRepresentationError(PretabError): to obtain a fresh, mutable copy.""" +class RepresentationConformanceError(PretabError, AssertionError): + """A representation class failed a :func:`pretab.check_representation` check. + + Inherits ``AssertionError`` so a failed conformance check also surfaces + naturally when :func:`~pretab.check_representation` is called from a test.""" + + # --- Message factories --- def invalid_param_error(estimator, param, value, constraint, valid=None): """Build an :class:`InvalidParamError` with a consistent, actionable message. diff --git a/pretab/extension.py b/pretab/extension.py new file mode 100644 index 0000000..689ec35 --- /dev/null +++ b/pretab/extension.py @@ -0,0 +1,455 @@ +"""Public extensibility surface for PreTab representations. + +This module is the supported way third parties add, register, discover, and +validate their own representations so they behave like the built-ins: + +- :class:`BaseRepresentation` -- the public base class to subclass. It inherits + the shared scikit-learn contract (NaN-aware validation, estimator tags, + ``get_feature_names_out``, and a typed :class:`~pretab.RepresentationSpec`) and + exposes a small declarative surface (``representation_name`` / ``feature_kind`` + / ``scope`` / ``supervision``). +- :func:`register_representation` -- add a class to the capability registry under + a name so it is selectable via ``Preprocessor(numerical_method=)``. +- :func:`load_entry_point_representations` -- register representations advertised + by installed packages through the ``pretab.representations`` entry-point group. +- :func:`list_representations` -- query the registry by capability. +- :func:`check_representation` -- a conformance suite that verifies a class obeys + the representation contract. +""" + +from __future__ import annotations + +import warnings + +import numpy as np +from sklearn.base import clone +from sklearn.exceptions import NotFittedError + +from .compose.registry import ( + CATEGORICAL, + NUMERICAL, + TRANSFORMER_REGISTRY, + TransformerSpec, + register_spec, +) +from .core.base import BasePreTabTransformer +from .core.representation import RepresentationSpec +from .exceptions import ConfigWarning, RepresentationConformanceError + +__all__ = [ + "BaseRepresentation", + "check_representation", + "list_representations", + "load_entry_point_representations", + "register_representation", +] + +#: Entry-point group installed packages use to advertise representations. +ENTRY_POINT_GROUP = "pretab.representations" + +_SUPERVISION_TO_TARGET_USAGE = { + "unsupervised": "forbidden", + "optional": "optional", + "supervised": "required", +} +_VALID_FEATURE_KINDS = frozenset({NUMERICAL, CATEGORICAL}) +_VALID_SCOPES = frozenset({"univariate", "multivariate"}) +_VALID_SUPERVISION = frozenset(_SUPERVISION_TO_TARGET_USAGE) + + +class BaseRepresentation(BasePreTabTransformer): + """Public base class for third-party PreTab representations. + + Subclass this to add a custom representation that behaves like a built-in: it + inherits NaN-aware validation, the estimator tags, ``get_feature_names_out``, + and a typed :class:`~pretab.RepresentationSpec`. Implement ``fit`` / + ``transform`` and either ``_output_sizes`` (the number of output columns each + input feature contributes) or ``get_feature_names_out`` directly; then call + :func:`register_representation` to make it selectable by name. + + Class attributes + ---------------- + representation_name : str or None + Canonical registry name -- the value passed as ``numerical_method=`` / + ``categorical_method=``. Must be set before registration. + feature_kind : {"numerical", "categorical"} + The column kind the representation applies to. + scope : {"univariate", "multivariate"} + Whether each input feature is expanded independently or several columns + are modelled jointly. + 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. + """ + + representation_name: str | None = None + feature_kind: str = NUMERICAL + scope: str = "univariate" + supervision: str = "unsupervised" + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + if cls.feature_kind not in _VALID_FEATURE_KINDS: + raise ValueError( + f"{cls.__name__}.feature_kind must be one of {sorted(_VALID_FEATURE_KINDS)}, " + f"got {cls.feature_kind!r}" + ) + if cls.scope not in _VALID_SCOPES: + raise ValueError( + f"{cls.__name__}.scope must be one of {sorted(_VALID_SCOPES)}, got {cls.scope!r}" + ) + if cls.supervision not in _VALID_SUPERVISION: + raise ValueError( + f"{cls.__name__}.supervision must be one of {sorted(_VALID_SUPERVISION)}, " + f"got {cls.supervision!r}" + ) + # Sync the public contract onto the internal representation hooks so the + # inherited RepresentationSpec and estimator tags reflect the declared + # metadata without the subclass having to set the private attributes. + if cls.representation_name is not None: + cls._representation_family = cls.representation_name + cls._representation_scope = cls.scope + cls._representation_supervision = cls.supervision + cls._requires_y = cls.supervision == "supervised" + + +def register_representation( + name, + cls, + *, + feature_kind=None, + scope=None, + supervision=None, + allowed_args=(), + placement_strategies=(), + supports_adaptive_resolution=False, + preprocessor_compatible=True, + optional_dependency=None, + periodic=False, + sparse_output=False, + override=False, +): + """Register a representation class under ``name`` so it is selectable by name. + + The capability metadata (``feature_kind`` / ``scope`` / ``supervision``) is + inferred from the class when it subclasses :class:`BaseRepresentation` and can + be overridden through the keyword arguments. After registration the method is + usable as ``Preprocessor(numerical_method=name)`` (or ``categorical_method``) + and appears in :func:`list_representations`. + + Parameters + ---------- + name : str + Canonical method name to register under. + cls : type + The scikit-learn-compatible transformer class. + feature_kind : {"numerical", "categorical"}, optional + Column kind the method applies to. Inferred from ``cls`` when omitted. + scope : {"univariate", "multivariate"}, optional + Inferred from ``cls`` when omitted. + supervision : {"unsupervised", "optional", "supervised"}, optional + Inferred from ``cls`` when omitted. + 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). + supports_adaptive_resolution : bool, default=False + Whether the method can size its output dimension from the data. + preprocessor_compatible : bool, default=True + Whether the method can be selected per column through ``Preprocessor``. + Set False for standalone / multivariate-only methods. + optional_dependency : str or None, default=None + Optional extra required for the method to run. + periodic : bool, default=False + Whether the representation encodes a periodic signal. + sparse_output : bool, default=False + Whether the method can emit a sparse matrix. + override : bool, default=False + Whether replacing an already-registered ``name`` is allowed. + + Returns + ------- + TransformerSpec + The registered capability record. + """ + if not isinstance(name, str) or not name.strip(): + raise ValueError("name must be a non-empty string") + if not isinstance(cls, type): + raise TypeError(f"cls must be a class, got {type(cls).__name__}") + + feature_kind = feature_kind if feature_kind is not None else getattr(cls, "feature_kind", NUMERICAL) + scope = scope if scope is not None else getattr(cls, "scope", "univariate") + supervision = supervision if supervision is not None else getattr(cls, "supervision", "unsupervised") + + if feature_kind not in _VALID_FEATURE_KINDS: + raise ValueError(f"feature_kind must be one of {sorted(_VALID_FEATURE_KINDS)}, got {feature_kind!r}") + if scope not in _VALID_SCOPES: + 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}") + + spec = TransformerSpec( + name=name, + transformer_cls=cls, + allowed_args=tuple(allowed_args), + feature_kind=frozenset({feature_kind}), + arity="multivariate" if scope == "multivariate" else "univariate", + target_usage=_SUPERVISION_TO_TARGET_USAGE[supervision], + placement_strategies=frozenset(placement_strategies), + supports_adaptive_resolution=bool(supports_adaptive_resolution), + preprocessor_compatible=bool(preprocessor_compatible), + optional_dependency=optional_dependency, + periodic=bool(periodic), + sparse_output=bool(sparse_output), + ) + return register_spec(spec, override=override) + + +def load_entry_point_representations(group=ENTRY_POINT_GROUP, *, override=False): + """Register representations advertised by installed packages. + + Iterates the ``group`` entry points (default ``"pretab.representations"``); + each entry point is expected to load to a representation class. The class is + registered under its ``representation_name`` attribute (falling back to the + entry-point name). A broken plugin emits a :class:`ConfigWarning` and is + skipped rather than breaking discovery for the others. + + This is opt-in (never called automatically at import) so importing ``pretab`` + stays fast and side-effect free. + + Returns + ------- + list of str + The names successfully registered, sorted. + """ + from importlib.metadata import entry_points + + try: + eps = entry_points(group=group) + except TypeError: # pragma: no cover - Python < 3.10 selection fallback + eps = entry_points().get(group, []) + + loaded = [] + for ep in eps: + try: + obj = ep.load() + reg_name = getattr(obj, "representation_name", None) or ep.name + register_representation(reg_name, obj, override=override) + loaded.append(reg_name) + except Exception as exc: + warnings.warn( + f"skipping representation entry point {ep.name!r}: {exc}", + ConfigWarning, + stacklevel=2, + ) + return sorted(loaded) + + +def list_representations( + *, + feature_kind=None, + scope=None, + supervised=None, + periodic=None, + sparse_output=None, + adaptive=None, + include_optional=True, +): + """Return the registered method names matching every supplied filter. + + All filters are optional and combined with AND. ``None`` means "don't filter + on this capability". + + Parameters + ---------- + feature_kind : {"numerical", "categorical"}, optional + Keep methods that apply to this column kind. + scope : {"univariate", "multivariate"}, optional + Keep methods with this arity. + supervised : bool, optional + Keep methods that can (``True``) or cannot (``False``) consume ``y``. + periodic : bool, optional + Keep methods whose ``periodic`` flag matches. + sparse_output : bool, optional + Keep methods whose ``sparse_output`` flag matches. + adaptive : bool, optional + Keep methods whose adaptive-resolution support matches. + include_optional : bool, default=True + When False, drop methods that need an optional dependency. + + Returns + ------- + list of str + Matching canonical method names, sorted. + """ + result = [] + for spec_name, spec in TRANSFORMER_REGISTRY.items(): + if feature_kind is not None and feature_kind not in spec.feature_kind: + continue + if scope is not None and spec.arity != scope: + continue + if supervised is not None and spec.is_supervised != bool(supervised): + continue + if periodic is not None and spec.periodic != bool(periodic): + continue + if sparse_output is not None and spec.sparse_output != bool(sparse_output): + continue + if adaptive is not None and spec.supports_adaptive_resolution != bool(adaptive): + continue + if not include_optional and spec.optional_dependency is not None: + continue + result.append(spec_name) + return sorted(result) + + +def _densify(array): + """Return a dense 2D ndarray view of a (possibly sparse) transform output.""" + if hasattr(array, "toarray"): + return array.toarray() + return np.asarray(array) + + +def check_representation(cls, *, X=None, y=None): + """Run the representation conformance suite on a class. + + Verifies the contract a well-behaved representation must obey: constructible + with defaults; ``transform`` before ``fit`` raises ``NotFittedError``; ``fit`` + returns ``self`` and does not mutate its input; ``transform`` yields a 2D + array with one row per sample; ``get_feature_names_out`` matches the output + width and is unique; the result is deterministic across ``clone`` + refit; the + typed :class:`~pretab.RepresentationSpec` agrees with the declared ``scope`` + and output width; and a ``"supervised"`` class refuses to fit without ``y``. + + Parameters + ---------- + cls : type + The representation class to validate. + X : array-like, optional + Sample input used for the checks. Defaults to a small numeric matrix. + y : array-like, optional + Sample target. Generated automatically for supervised classes when the + class declares ``supervision="supervised"``. + + Returns + ------- + list of str + The names of the checks that passed. + + Raises + ------ + RepresentationConformanceError + On the first failed check, with a message identifying the violation. + """ + rng = np.random.RandomState(0) + if X is None: + X = rng.uniform(-2.0, 2.0, size=(40, 1)).astype(float) + X = np.asarray(X) + n_samples = X.shape[0] + + supervision = getattr(cls, "supervision", "unsupervised") + needs_y = supervision == "supervised" + if needs_y and y is None: + y = rng.uniform(size=n_samples) + + def _make(): + try: + return cls() + except TypeError as exc: + raise RepresentationConformanceError( + f"{cls.__name__} must be constructible with no required arguments: {exc}" + ) from exc + + def _fit(est): + return est.fit(X, y) if needs_y else est.fit(X) + + passed = [] + + # 1. transform before fit must raise NotFittedError. + est = _make() + try: + est.transform(X) + except NotFittedError: + pass + except Exception as exc: + raise RepresentationConformanceError( + f"{cls.__name__}.transform before fit should raise NotFittedError, got {type(exc).__name__}" + ) from exc + else: + raise RepresentationConformanceError( + f"{cls.__name__}.transform before fit should raise NotFittedError" + ) + passed.append("unfitted_transform_raises") + + # 2. fit returns self and does not mutate X. + est = _make() + X_before = X.copy() + fitted = _fit(est) + if fitted is not est: + raise RepresentationConformanceError(f"{cls.__name__}.fit must return self") + if not np.array_equal(X, X_before, equal_nan=True): + raise RepresentationConformanceError(f"{cls.__name__}.fit must not mutate its input X") + passed.append("fit_returns_self_no_mutation") + + # 3. transform is a 2D array with one row per sample. + out = _densify(fitted.transform(X)) + if out.ndim != 2 or out.shape[0] != n_samples: + raise RepresentationConformanceError( + f"{cls.__name__}.transform must return a 2D array with {n_samples} rows, " + f"got shape {getattr(out, 'shape', None)}" + ) + width = out.shape[1] + passed.append("transform_shape") + + # 4. feature names match the output width and are unique. + names = [str(name) for name in fitted.get_feature_names_out()] + if len(names) != width: + raise RepresentationConformanceError( + f"{cls.__name__}.get_feature_names_out length {len(names)} != output width {width}" + ) + if len(set(names)) != len(names): + raise RepresentationConformanceError( + f"{cls.__name__}.get_feature_names_out must be unique" + ) + passed.append("feature_names_match") + + # 5. deterministic across clone + refit. + clone_out = _densify(_fit(clone(fitted)).transform(X)) + if clone_out.shape != out.shape or not np.allclose(clone_out, out, equal_nan=True): + raise RepresentationConformanceError( + f"{cls.__name__} is not deterministic across clone + refit" + ) + passed.append("deterministic") + + # 6. typed representation spec agrees with the declared metadata. + spec = fitted.get_representation_spec() + if not isinstance(spec, RepresentationSpec): + raise RepresentationConformanceError( + f"{cls.__name__}.get_representation_spec must return a RepresentationSpec" + ) + declared_scope = getattr(cls, "scope", "univariate") + if spec.scope != declared_scope: + raise RepresentationConformanceError( + f"{cls.__name__} spec.scope {spec.scope!r} != declared scope {declared_scope!r}" + ) + if spec.output_dim != width: + raise RepresentationConformanceError( + f"{cls.__name__} spec.output_dim {spec.output_dim} != output width {width}" + ) + passed.append("spec_consistent") + + # 7. a supervised class must refuse to fit without y. + if needs_y: + est = _make() + try: + est.fit(X) + except Exception: + passed.append("supervised_requires_y") + else: + raise RepresentationConformanceError( + f"{cls.__name__} declares supervision='supervised' but fit succeeded without y" + ) + + return passed diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index a6a531a..3605fc5 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -1,4 +1,5 @@ import hashlib +import inspect import json import os import time @@ -34,6 +35,31 @@ logger = get_logger(__name__) +#: Named parameter bundles exposed through ``Preprocessor(preset=...)``. Each +#: preset supplies values only for the listed parameters; any parameter the caller +#: sets explicitly (i.e. away from its ``__init__`` default) overrides the preset. +PRESETS = { + "standard": { + "numerical_method": "ple", + "categorical_method": "int", + "output_dim": 7, + "adaptive": False, + }, + "expanded": { + "numerical_method": "ple", + "categorical_method": "one-hot", + "output_dim": 16, + "adaptive": False, + }, + "adaptive": { + "numerical_method": "ple", + "categorical_method": "int", + "adaptive": True, + "min_output_dim": 5, + "max_output_dim": 16, + }, +} + class Preprocessor(TransformerMixin, BaseEstimator): r""" @@ -204,6 +230,13 @@ class Preprocessor(TransformerMixin, BaseEstimator): (e.g. DeepTab) can pass it straight through ``Preprocessor(**kwargs)``. PreTab never configures the root logger or attaches a handler when the host already owns one, so ``verbose=0`` keeps PreTab silent under a host's own logging. + preset : {"standard", "expanded", "adaptive"} or None, default=None + Optional named configuration bundle applied as a transparent alias. A preset only + fills in parameters left at their defaults; any parameter set explicitly always wins. + ``"standard"`` is the PLE + integer-code baseline, ``"expanded"`` widens the + numerical basis and one-hot encodes categoricals, and ``"adaptive"`` sizes each + feature's width from the data. Call :meth:`get_resolved_config` to see the effective + parameters. ``None`` (default) uses the individual parameters unchanged. Attributes ---------- @@ -316,6 +349,7 @@ def __init__( output_format="dense", dtype=None, verbose=0, + preset=None, ): """ Initialize the Preprocessor with various transformation options for tabular data. @@ -351,6 +385,7 @@ def __init__( self.output_format = output_format self.dtype = dtype self.verbose = verbose + self.preset = preset def fit(self, X, y=None, embeddings=None): """ @@ -376,27 +411,28 @@ def fit(self, X, y=None, embeddings=None): configure_logging(verbose) start_time = time.perf_counter() + resolved = self._resolved_params() config = PreprocessorConfig.from_params( - numerical_method=self.numerical_method, - categorical_method=self.categorical_method, - feature_preprocessing=self.feature_preprocessing, - output_dim=self.output_dim, - degree=self.degree, - target_aware=self.target_aware, - placement_strategy=self.placement_strategy, - task=self.task, - adaptive=self.adaptive, - min_output_dim=self.min_output_dim, - max_output_dim=self.max_output_dim, - random_state=self.random_state, - scaling=self.scaling, - cat_cutoff=self.cat_cutoff, - treat_all_integers_as_numerical=self.treat_all_integers_as_numerical, - numerical_imputation=self.numerical_imputation, - categorical_imputation=self.categorical_imputation, - add_missing_indicator=self.add_missing_indicator, - missing_policy=self.missing_policy, - verbose=self.verbose, + numerical_method=resolved["numerical_method"], + categorical_method=resolved["categorical_method"], + feature_preprocessing=resolved["feature_preprocessing"], + output_dim=resolved["output_dim"], + degree=resolved["degree"], + target_aware=resolved["target_aware"], + placement_strategy=resolved["placement_strategy"], + task=resolved["task"], + adaptive=resolved["adaptive"], + min_output_dim=resolved["min_output_dim"], + max_output_dim=resolved["max_output_dim"], + random_state=resolved["random_state"], + scaling=resolved["scaling"], + cat_cutoff=resolved["cat_cutoff"], + treat_all_integers_as_numerical=resolved["treat_all_integers_as_numerical"], + numerical_imputation=resolved["numerical_imputation"], + categorical_imputation=resolved["categorical_imputation"], + add_missing_indicator=resolved["add_missing_indicator"], + missing_policy=resolved["missing_policy"], + verbose=resolved["verbose"], ) X = to_dataframe(X) @@ -416,8 +452,8 @@ def fit(self, X, y=None, embeddings=None): numerical_features, categorical_features = detect_column_types( X, - cat_cutoff=self.cat_cutoff, - treat_all_integers_as_numerical=self.treat_all_integers_as_numerical, + cat_cutoff=resolved["cat_cutoff"], + treat_all_integers_as_numerical=resolved["treat_all_integers_as_numerical"], estimator_name=type(self).__name__, ) @@ -538,6 +574,59 @@ def fit_transform(self, X, y=None, embeddings=None, return_array=False): return self.fit(X, y, embeddings=embeddings).transform(X, embeddings, return_array) + @classmethod + def _param_defaults(cls): + """Return the ``__init__`` parameter defaults, keyed by name.""" + signature = inspect.signature(cls.__init__) + return { + name: parameter.default + for name, parameter in signature.parameters.items() + if parameter.default is not inspect.Parameter.empty + } + + def _resolved_params(self): + """Return the effective parameters after expanding ``preset``. + + A preset fills in only the parameters left at their ``__init__`` default; + explicitly-set parameters always take precedence. The ``preset`` key is + dropped from the returned mapping. + """ + params = self.get_params(deep=False) + preset = params.pop("preset", None) + if preset is None: + return params + if preset not in PRESETS: + raise invalid_param_error( + type(self).__name__, + "preset", + preset, + "must be one of " + ", ".join(repr(name) for name in sorted(PRESETS)), + valid=set(PRESETS), + ) + defaults = self._param_defaults() + resolved = dict(params) + for key, preset_value in PRESETS[preset].items(): + if key in defaults and params.get(key) == defaults[key]: + resolved[key] = preset_value + return resolved + + def get_resolved_config(self): + """Return the effective parameter mapping after ``preset`` expansion. + + When ``preset`` is set, its bundled values fill in every parameter the + caller left at its default while explicitly-set parameters win; the + ``preset`` key itself is removed. When ``preset`` is ``None`` this is simply + :meth:`get_params` without the ``preset`` entry. The returned dict is the + configuration ``fit`` builds from, so it makes a preset's effect inspectable + before fitting. + + Returns + ------- + dict + The resolved parameter mapping. + """ + return self._resolved_params() + def get_feature_names_out(self, input_features=None): """ Get output feature names for transformation. diff --git a/tests/extension/conftest.py b/tests/extension/conftest.py new file mode 100644 index 0000000..6e560a0 --- /dev/null +++ b/tests/extension/conftest.py @@ -0,0 +1,26 @@ +"""Shared fixtures for the extension-protocol tests. + +Registration mutates process-global registry state, so every test in this +package runs against a snapshot that is restored afterwards to keep the suite +order-independent. +""" + +import pytest + +from pretab.compose import registry + + +@pytest.fixture(autouse=True) +def _restore_registry(): + saved_registry = dict(registry.TRANSFORMER_REGISTRY) + saved_numerical = dict(registry.NUMERICAL_METHODS) + saved_categorical = set(registry.CATEGORICAL_METHODS) + try: + yield + finally: + registry.TRANSFORMER_REGISTRY.clear() + registry.TRANSFORMER_REGISTRY.update(saved_registry) + registry.NUMERICAL_METHODS.clear() + registry.NUMERICAL_METHODS.update(saved_numerical) + registry.CATEGORICAL_METHODS.clear() + registry.CATEGORICAL_METHODS.update(saved_categorical) diff --git a/tests/extension/test_conformance.py b/tests/extension/test_conformance.py new file mode 100644 index 0000000..84aeba8 --- /dev/null +++ b/tests/extension/test_conformance.py @@ -0,0 +1,154 @@ +"""Tests for the ``check_representation`` conformance suite (P10.3).""" + +import numpy as np +import pytest +from sklearn.utils.validation import check_is_fitted + +from pretab import BaseRepresentation, check_representation +from pretab.exceptions import RepresentationConformanceError + + +class _Good(BaseRepresentation): + representation_name = "good_conf" + feature_kind = "numerical" + + def fit(self, X, y=None): + self._validate(X, reset=True) + return self + + def transform(self, X): + check_is_fitted(self, "n_features_in_") + return np.asarray(self._validate(X, reset=False), dtype=float) ** 2 + + def _output_sizes(self): + return [1] * self.n_features_in_ + + +class _GoodSupervised(BaseRepresentation): + representation_name = "good_sup_conf" + supervision = "supervised" + + def fit(self, X, y=None): + if y is None: + raise ValueError("y is required") + self._validate(X, reset=True) + self.scale_ = float(np.mean(y)) or 1.0 + return self + + def transform(self, X): + check_is_fitted(self, "n_features_in_") + return np.asarray(self._validate(X, reset=False), dtype=float) * self.scale_ + + def _output_sizes(self): + return [1] * self.n_features_in_ + + +def test_good_representation_passes(): + passed = check_representation(_Good) + assert "unfitted_transform_raises" in passed + assert "fit_returns_self_no_mutation" in passed + assert "deterministic" in passed + assert "spec_consistent" in passed + + +def test_good_supervised_representation_passes(): + passed = check_representation(_GoodSupervised) + assert "supervised_requires_y" in passed + + +def test_fit_not_returning_self_fails(): + class _NoSelf(BaseRepresentation): + representation_name = "noself_conf" + + def fit(self, X, y=None): + self._validate(X, reset=True) # returns None + + def transform(self, X): + check_is_fitted(self, "n_features_in_") + return np.asarray(self._validate(X, reset=False), dtype=float) + + def _output_sizes(self): + return [1] * self.n_features_in_ + + with pytest.raises(RepresentationConformanceError, match="must return self"): + check_representation(_NoSelf) + + +def test_missing_unfitted_guard_fails(): + class _NoGuard(BaseRepresentation): + representation_name = "noguard_conf" + + def fit(self, X, y=None): + self._validate(X, reset=True) + return self + + def transform(self, X): + return np.asarray(X, dtype=float) ** 2 + + def _output_sizes(self): + return [1] * self.n_features_in_ + + with pytest.raises(RepresentationConformanceError, match="NotFittedError"): + check_representation(_NoGuard) + + +def test_feature_names_length_mismatch_fails(): + class _BadNames(BaseRepresentation): + representation_name = "badnames_conf" + + def fit(self, X, y=None): + self._validate(X, reset=True) + return self + + def transform(self, X): + check_is_fitted(self, "n_features_in_") + return np.asarray(self._validate(X, reset=False), dtype=float) + + def get_feature_names_out(self, input_features=None): + return np.array(["a", "b"]) # width is 1, so length 2 is wrong + + def _output_sizes(self): + return [1] * self.n_features_in_ + + with pytest.raises(RepresentationConformanceError, match="get_feature_names_out length"): + check_representation(_BadNames) + + +def test_input_mutation_fails(): + class _Mutates(BaseRepresentation): + representation_name = "mutates_conf" + + def fit(self, X, y=None): + np.asarray(X)[:] = 0.0 + self._validate(X, reset=True) + return self + + def transform(self, X): + check_is_fitted(self, "n_features_in_") + return np.asarray(self._validate(X, reset=False), dtype=float) + + def _output_sizes(self): + return [1] * self.n_features_in_ + + with pytest.raises(RepresentationConformanceError, match="must not mutate"): + check_representation(_Mutates) + + +def test_supervised_that_ignores_y_fails(): + class _IgnoresY(BaseRepresentation): + representation_name = "ignoresy_conf" + supervision = "supervised" + + def fit(self, X, y=None): + self._validate(X, reset=True) + return self + + def transform(self, X): + check_is_fitted(self, "n_features_in_") + return np.asarray(self._validate(X, reset=False), dtype=float) + + def _output_sizes(self): + return [1] * self.n_features_in_ + + with pytest.raises(RepresentationConformanceError, match="fit succeeded without y"): + check_representation(_IgnoresY) diff --git a/tests/extension/test_extension_protocol.py b/tests/extension/test_extension_protocol.py new file mode 100644 index 0000000..de89ae3 --- /dev/null +++ b/tests/extension/test_extension_protocol.py @@ -0,0 +1,83 @@ +"""Tests for the public ``BaseRepresentation`` extension base (P10.1).""" + +import numpy as np +import pytest +from sklearn.utils.validation import check_is_fitted + +from pretab import BaseRepresentation, RepresentationSpec + + +class _Square(BaseRepresentation): + representation_name = "square_proto" + feature_kind = "numerical" + scope = "univariate" + supervision = "unsupervised" + + def fit(self, X, y=None): + self._validate(X, reset=True) + return self + + def transform(self, X): + check_is_fitted(self, "n_features_in_") + X = self._validate(X, reset=False) + return np.asarray(X, dtype=float) ** 2 + + def _output_sizes(self): + return [1] * self.n_features_in_ + + +def test_declared_metadata_syncs_internal_hooks(): + assert _Square._representation_family == "square_proto" + assert _Square._representation_scope == "univariate" + assert _Square._representation_supervision == "unsupervised" + assert _Square._requires_y is False + + +def test_supervised_flag_sets_requires_y(): + class _Sup(BaseRepresentation): + representation_name = "sup_proto" + supervision = "supervised" + + def fit(self, X, y=None): + self._validate(X, reset=True) + return self + + def transform(self, X): + check_is_fitted(self, "n_features_in_") + return np.asarray(self._validate(X, reset=False), dtype=float) + + def _output_sizes(self): + return [1] * self.n_features_in_ + + assert _Sup._requires_y is True + assert _Sup._representation_supervision == "supervised" + + +def test_representation_spec_reflects_declaration(): + X = np.linspace(0.0, 1.0, 20).reshape(-1, 1) + est = _Square().fit(X) + spec = est.get_representation_spec() + assert isinstance(spec, RepresentationSpec) + assert spec.scope == "univariate" + assert spec.output_dim == 1 + + +def test_invalid_scope_rejected(): + with pytest.raises(ValueError, match="scope"): + + class _Bad(BaseRepresentation): + scope = "triple" + + +def test_invalid_supervision_rejected(): + with pytest.raises(ValueError, match="supervision"): + + class _Bad(BaseRepresentation): + supervision = "sometimes" + + +def test_invalid_feature_kind_rejected(): + with pytest.raises(ValueError, match="feature_kind"): + + class _Bad(BaseRepresentation): + feature_kind = "ordinal" diff --git a/tests/extension/test_registration_discovery.py b/tests/extension/test_registration_discovery.py new file mode 100644 index 0000000..55126e9 --- /dev/null +++ b/tests/extension/test_registration_discovery.py @@ -0,0 +1,163 @@ +"""Tests for representation registration, entry-point loading, and discovery. + +Covers P10.2 (``register_representation`` + entry points) and P10.4 +(``list_representations`` capability discovery). +""" + +import importlib.metadata as importlib_metadata + +import numpy as np +import pandas as pd +import pytest +from sklearn.utils.validation import check_is_fitted + +from pretab import ( + BaseRepresentation, + Preprocessor, + list_representations, + load_entry_point_representations, + register_representation, +) +from pretab.compose import registry +from pretab.exceptions import ConfigWarning + + +class _Square(BaseRepresentation): + representation_name = "square_reg" + feature_kind = "numerical" + + def fit(self, X, y=None): + self._validate(X, reset=True) + return self + + def transform(self, X): + check_is_fitted(self, "n_features_in_") + return np.asarray(self._validate(X, reset=False), dtype=float) ** 2 + + def _output_sizes(self): + return [1] * self.n_features_in_ + + +class _CatPassthrough(BaseRepresentation): + representation_name = "cat_reg" + feature_kind = "categorical" + + def fit(self, X, y=None): + self._validate(X, reset=True) + return self + + def transform(self, X): + check_is_fitted(self, "n_features_in_") + return np.asarray(self._validate(X, reset=False), dtype=float) + + def _output_sizes(self): + return [1] * self.n_features_in_ + + +def test_register_makes_method_selectable_and_discoverable(): + register_representation("square_reg", _Square) + assert "square_reg" in registry.NUMERICAL_METHODS + assert "square_reg" in list_representations(feature_kind="numerical") + + +def test_register_end_to_end_through_preprocessor(): + register_representation("square_reg", _Square) + X = pd.DataFrame({"a": np.linspace(0, 3, 12), "b": np.linspace(-2, 2, 12)}) + pre = Preprocessor( + numerical_method="square_reg", + categorical_method="none", + target_aware=False, + placement_strategy="uniform", + ) + out = np.asarray(pre.fit_transform(X, return_array=True)) + assert out.shape == (12, 2) + # The Preprocessor scales columns into [0, 1] before applying the method, so + # the registered "square" method yields non-negative values bounded by 1. + assert (out >= -1e-9).all() + assert out.max() <= 1 + 1e-9 + + +def test_register_categorical_updates_categorical_view(): + register_representation("cat_reg", _CatPassthrough) + assert "cat_reg" in registry.CATEGORICAL_METHODS + assert "cat_reg" in list_representations(feature_kind="categorical") + + +def test_duplicate_registration_requires_override(): + register_representation("square_reg", _Square) + with pytest.raises(ValueError, match="already registered"): + register_representation("square_reg", _Square) + # override replaces without raising. + register_representation("square_reg", _Square, override=True) + + +def test_register_validation_errors(): + with pytest.raises(ValueError, match="non-empty"): + register_representation("", _Square) + with pytest.raises(TypeError, match="cls must be a class"): + register_representation("bad", _Square()) + with pytest.raises(ValueError, match="feature_kind"): + register_representation("bad", _Square, feature_kind="ordinal") + + +def test_list_representations_capability_filters(): + assert list_representations(periodic=True) == ["fourier"] + assert list_representations(sparse_output=True) == ["one-hot"] + assert "ple" in list_representations(supervised=True) + multivariate = list_representations(scope="multivariate") + assert "tensorspline" in multivariate + assert "fourier" not in multivariate + categorical = list_representations(feature_kind="categorical") + assert {"int", "one-hot"}.issubset(set(categorical)) + + +def test_list_representations_include_optional_toggle(): + with_optional = list_representations(feature_kind="categorical") + without_optional = list_representations(feature_kind="categorical", include_optional=False) + assert "pretrained" in with_optional + assert "pretrained" not in without_optional + + +def test_load_entry_point_representations_registers(monkeypatch): + class _EpRep(BaseRepresentation): + representation_name = "ep_square" + feature_kind = "numerical" + + def fit(self, X, y=None): + self._validate(X, reset=True) + return self + + def transform(self, X): + check_is_fitted(self, "n_features_in_") + return np.asarray(self._validate(X, reset=False), dtype=float) + + def _output_sizes(self): + return [1] * self.n_features_in_ + + class _FakeEntryPoint: + name = "ep_square_entry" + + def load(self): + return _EpRep + + monkeypatch.setattr( + importlib_metadata, "entry_points", lambda group=None: [_FakeEntryPoint()] + ) + loaded = load_entry_point_representations() + assert loaded == ["ep_square"] + assert "ep_square" in registry.TRANSFORMER_REGISTRY + + +def test_load_entry_point_representations_skips_broken(monkeypatch): + class _BrokenEntryPoint: + name = "broken_entry" + + def load(self): + raise ImportError("boom") + + monkeypatch.setattr( + importlib_metadata, "entry_points", lambda group=None: [_BrokenEntryPoint()] + ) + with pytest.warns(ConfigWarning, match="broken_entry"): + loaded = load_entry_point_representations() + assert loaded == [] diff --git a/tests/integration/test_preprocessor.py b/tests/integration/test_preprocessor.py index 31d3454..c325836 100644 --- a/tests/integration/test_preprocessor.py +++ b/tests/integration/test_preprocessor.py @@ -126,6 +126,7 @@ def test_dict_keys_reflect_column_names(sample_data): "output_format", "dtype", "verbose", + "preset", } diff --git a/tests/integration/test_presets.py b/tests/integration/test_presets.py new file mode 100644 index 0000000..10d0d94 --- /dev/null +++ b/tests/integration/test_presets.py @@ -0,0 +1,87 @@ +"""Tests for the ``Preprocessor`` preset aliases and ``get_resolved_config`` (P10.5).""" + +import numpy as np +import pandas as pd +import pytest +from sklearn.base import clone + +from pretab import Preprocessor +from pretab.exceptions import InvalidParamError + + +@pytest.fixture +def data(): + X = pd.DataFrame( + { + "a": np.linspace(0, 3, 40), + "b": np.linspace(-2, 2, 40), + "c": ["x", "y"] * 20, + } + ) + y = np.linspace(0, 1, 40) + return X, y + + +def test_no_preset_resolved_config_drops_preset_key(): + cfg = Preprocessor().get_resolved_config() + assert "preset" not in cfg + assert cfg["numerical_method"] == "ple" + assert cfg["categorical_method"] == "int" + + +def test_standard_preset_matches_baseline(): + cfg = Preprocessor(preset="standard").get_resolved_config() + assert cfg["numerical_method"] == "ple" + assert cfg["categorical_method"] == "int" + assert cfg["output_dim"] == 7 + assert cfg["adaptive"] is False + assert "preset" not in cfg + + +def test_expanded_preset_widens_config(): + cfg = Preprocessor(preset="expanded").get_resolved_config() + assert cfg["categorical_method"] == "one-hot" + assert cfg["output_dim"] == 16 + assert cfg["adaptive"] is False + + +def test_adaptive_preset_enables_adaptive_width(): + cfg = Preprocessor(preset="adaptive").get_resolved_config() + assert cfg["adaptive"] is True + assert cfg["min_output_dim"] == 5 + assert cfg["max_output_dim"] == 16 + + +def test_explicit_param_overrides_preset(): + cfg = Preprocessor(preset="expanded", output_dim=5).get_resolved_config() + assert cfg["output_dim"] == 5 # user value wins over the preset's 16 + + +def test_preset_is_preserved_by_get_params_and_clone(): + pre = Preprocessor(preset="standard") + assert pre.get_params()["preset"] == "standard" + assert clone(pre).get_params()["preset"] == "standard" + + +def test_invalid_preset_raises(): + with pytest.raises(InvalidParamError, match="preset"): + Preprocessor(preset="nope").get_resolved_config() + + +def test_presets_fit_with_distinct_widths(data): + X, y = data + widths = {} + for name in ("standard", "expanded", "adaptive"): + pre = Preprocessor(preset=name) + out = np.asarray(pre.fit_transform(X, y, return_array=True)) + assert out.shape[0] == X.shape[0] + widths[name] = out.shape[1] + # "expanded" one-hot encodes and widens the numerical basis, so it is wider + # than the "standard" baseline. + assert widths["expanded"] > widths["standard"] + + +def test_invalid_preset_raises_at_fit(data): + X, y = data + with pytest.raises(InvalidParamError, match="preset"): + Preprocessor(preset="nope").fit(X, y) From 95f8515a422ab9d5c198f36207e5929899bfdaa7 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Mon, 27 Jul 2026 14:13:15 +0200 Subject: [PATCH 21/59] docs: remove legacy user guide and end-to-end pages --- docs/getting_started/end_to_end.md | 178 ----------------------------- docs/tutorials/classification.md | 148 ------------------------ docs/user_guide/configuration.md | 112 ------------------ docs/user_guide/preprocessing.md | 109 ------------------ 4 files changed, 547 deletions(-) delete mode 100644 docs/getting_started/end_to_end.md delete mode 100644 docs/tutorials/classification.md delete mode 100644 docs/user_guide/configuration.md delete mode 100644 docs/user_guide/preprocessing.md diff --git a/docs/getting_started/end_to_end.md b/docs/getting_started/end_to_end.md deleted file mode 100644 index 8be2af9..0000000 --- a/docs/getting_started/end_to_end.md +++ /dev/null @@ -1,178 +0,0 @@ -# End-to-end example - -pretab is most useful as the *feature layer* in front of a model. This walkthrough builds -the same small regression task twice: once with plain scaling and once with pretab, using -the **same linear model** both times. The only thing that changes is how the raw columns -are turned into features, which makes the effect of pretab easy to see. - -## The dataset - -We simulate a tabular dataset with three numeric columns and one categorical column. - -```python -import numpy as np -import pandas as pd -from sklearn.model_selection import train_test_split - -rng = np.random.default_rng(0) -n = 4000 - -age = rng.uniform(18, 70, n) -income = rng.normal(60_000, 15_000, n) -tenure = rng.uniform(0, 40, n) -city = rng.choice(["Berlin", "Munich", "Hamburg", "Cologne"], n) - -# The target depends on each feature in a *nonlinear* way. -city_effect = pd.Series(city).map( - {"Berlin": 5.0, "Munich": 8.0, "Hamburg": 3.0, "Cologne": 6.0} -).to_numpy() -target = ( - 12 * np.sin(age / 8) # wave in age - + 0.00004 * (income - 60_000) ** 2 / 1000 # quadratic in income - + np.sqrt(tenure) * 3 # diminishing returns on tenure - + city_effect # per-city offset - + rng.normal(0, 2, n) # noise -) - -df = pd.DataFrame({"age": age, "income": income, "tenure": tenure, "city": city}) - -X_train, X_test, y_train, y_test = train_test_split( - df, target, test_size=0.25, random_state=42 -) -df.head() -``` - -```text - age income tenure city -0 51.122008 38220.981430 31.495899 Munich -1 32.028909 61219.946532 18.424959 Cologne -2 20.130623 49018.509912 28.795777 Munich -3 18.859437 42292.103104 22.590428 Munich -4 60.290052 40930.830263 39.569339 Cologne -``` - -The target curves with `age`, bends quadratically with `income`, and flattens out with -`tenure`. A plain linear model only sees a single straight-line term per column, so it has -no way to represent these shapes. That is exactly the gap pretab fills. - -```{warning} -Fit every transformer on the **training split only**, then apply it to the test split with -`transform`. Supervised expansions such as PLE and RBF read `y` while fitting, so fitting -them on the full dataset would leak test information and inflate your scores. -``` - -## Baseline: scaling + Ridge - -First, a conventional pipeline: scale the numeric columns, one-hot the categorical one, and -fit a `Ridge` regressor. - -```python -from sklearn.compose import ColumnTransformer -from sklearn.preprocessing import MinMaxScaler, OneHotEncoder -from sklearn.linear_model import Ridge -from sklearn.metrics import r2_score, mean_absolute_error - -baseline = ColumnTransformer([ - ("num", MinMaxScaler(), ["age", "income", "tenure"]), - ("cat", OneHotEncoder(handle_unknown="ignore"), ["city"]), -]) - -X_tr = baseline.fit_transform(X_train) -X_te = baseline.transform(X_test) - -model = Ridge(alpha=1.0).fit(X_tr, y_train) -pred = model.predict(X_te) - -print(f"features: {X_tr.shape[1]}") -print(f"R2: {r2_score(y_test, pred):.3f}") -print(f"MAE: {mean_absolute_error(y_test, pred):.2f}") -``` - -```text -features: 7 -R2: 0.124 -MAE: 11.20 -``` - -With one straight-line term per numeric column, `Ridge` can only fit a global slope. It -misses every curve in the target, and the $R^2$ of `0.124` is barely better than predicting -the mean. - -## With pretab - -Now swap the scaler for a `Preprocessor` that gives each column an expressive basis. It uses -a B-spline for `age`, piecewise-linear encoding (PLE) for `income`, radial basis functions -for `tenure`, and one-hot for `city`. Everything else stays the same. - -```python -from pretab import Preprocessor - -pre = Preprocessor( - feature_preprocessing={ - "age": "bspline", - "income": "ple", - "tenure": "rbf", - "city": "one-hot", - }, - task="regression", - output_dim=12, -) - -X_tr = pre.fit_transform(X_train, y_train, return_array=True) -X_te = pre.transform(X_test, return_array=True) - -model = Ridge(alpha=1.0).fit(X_tr, y_train) -pred = model.predict(X_te) - -print(f"features: {X_tr.shape[1]}") -print(f"R2: {r2_score(y_test, pred):.3f}") -print(f"MAE: {mean_absolute_error(y_test, pred):.2f}") -``` - -```text -features: 41 -R2: 0.968 -MAE: 2.16 -``` - -The data and the `Ridge` model are unchanged, but the expressive features let it capture the -nonlinear structure. The $R^2$ jumps from `0.124` to `0.968` and the mean absolute error -drops from `11.20` to `2.16`. - -```{tip} -`Preprocessor.transform` returns a **dict of feature blocks** by default. When you feed a -plain estimator, call it yourself with `return_array=True` to get a single stacked matrix, -then hand the arrays to the model. If you would rather compose everything inside one -`sklearn` `Pipeline`, use the standalone transformers instead. See the -[sklearn Pipeline tutorial](../tutorials/sklearn_pipeline.md). -``` - -## What actually changed - -The `Preprocessor` expands four raw columns into 41 features. Inspect the resolved layout -with `get_feature_info`: - -```python -pre.get_feature_info() -``` - -```text -feature kind pipeline dim cats ----------------------------------------------------------------- -age numerical imputer -> minmax -> bspline 13 - -income numerical imputer -> minmax -> ple 12 - -tenure numerical imputer -> minmax -> rbf 12 - -city categorical imputer -> onehot -> to_float 4 4 -``` - -Each numeric column is imputed, scaled, then expanded into a basis that a linear model can -weight independently: 13 spline coefficients for `age`, 12 PLE bins for `income`, and 12 RBF -bumps for `tenure`, while `city` becomes four one-hot columns. The model is unchanged, and -only the representation improved. - -## Next steps - -- Do the same for a classifier in the [classification tutorial](../tutorials/classification.md). -- Wire pretab transformers into a full `sklearn` `Pipeline` with cross-validation and - grid search in the [sklearn Pipeline tutorial](../tutorials/sklearn_pipeline.md). -- Review every strategy string in the [User Guide](../user_guide/preprocessing.md). diff --git a/docs/tutorials/classification.md b/docs/tutorials/classification.md deleted file mode 100644 index ff603be..0000000 --- a/docs/tutorials/classification.md +++ /dev/null @@ -1,148 +0,0 @@ -# Classification - -The [end-to-end example](../getting_started/end_to_end.md) showed pretab in front of a -regressor. The same idea works for classification: give a linear classifier an expressive -feature basis and it can learn decision boundaries that a raw model cannot. - -Here the target has a **ring-shaped** boundary, where the positive class sits near the origin -of two coordinates, plus a categorical `plan` effect. A plain `LogisticRegression` draws a -single straight boundary and struggles; radial basis features let it curve around the ring. - -## The dataset - -```python -import numpy as np -import pandas as pd -from sklearn.model_selection import train_test_split - -rng = np.random.default_rng(1) -n = 4000 - -x1 = rng.uniform(-3, 3, n) -x2 = rng.uniform(-3, 3, n) -hours = rng.uniform(0, 60, n) -plan = rng.choice(["free", "pro", "team"], n, p=[0.5, 0.3, 0.2]) - -# Positive class lives inside a ring around the origin, shifted by the plan. -plan_effect = pd.Series(plan).map({"free": -0.5, "pro": 0.3, "team": 1.0}).to_numpy() -logit = 3.0 - (x1**2 + x2**2) + 0.02 * (hours - 30) + plan_effect + rng.normal(0, 0.5, n) -prob = 1 / (1 + np.exp(-logit)) -y = (rng.uniform(0, 1, n) < prob).astype(int) - -df = pd.DataFrame({"x1": x1, "x2": x2, "hours": hours, "plan": plan}) -print("class balance:", {0: int((y == 0).sum()), 1: int((y == 1).sum())}) - -X_train, X_test, y_train, y_test = train_test_split( - df, y, test_size=0.25, random_state=42, stratify=y -) -``` - -```text -class balance: {0: 2966, 1: 1034} -``` - -## Baseline: scaling + LogisticRegression - -```python -from sklearn.compose import ColumnTransformer -from sklearn.preprocessing import MinMaxScaler, OneHotEncoder -from sklearn.linear_model import LogisticRegression -from sklearn.metrics import accuracy_score, roc_auc_score - -baseline = ColumnTransformer([ - ("num", MinMaxScaler(), ["x1", "x2", "hours"]), - ("cat", OneHotEncoder(handle_unknown="ignore"), ["plan"]), -]) - -X_tr = baseline.fit_transform(X_train) -X_te = baseline.transform(X_test) - -clf = LogisticRegression(max_iter=1000).fit(X_tr, y_train) -proba = clf.predict_proba(X_te)[:, 1] - -print(f"features: {X_tr.shape[1]}") -print(f"accuracy: {accuracy_score(y_test, clf.predict(X_te)):.3f}") -print(f"ROC AUC: {roc_auc_score(y_test, proba):.3f}") -``` - -```text -features: 6 -accuracy: 0.742 -ROC AUC: 0.569 -``` - -Accuracy looks acceptable only because the classes are imbalanced, since the model mostly -predicts the majority class. The `ROC AUC` of `0.569` shows it has barely learned to rank -positives above negatives, because a straight boundary cannot enclose the ring. - -```{warning} -On imbalanced data, accuracy can be misleading. A model that always predicts the majority -class would already score around `0.74` here. Prefer threshold-independent metrics such as -`ROC AUC`, or precision and recall, to judge whether a classifier has genuinely learned. -``` - -## With pretab - -Give every numeric column a radial basis expansion and keep the same classifier. - -```python -from pretab import Preprocessor - -pre = Preprocessor( - numerical_method="rbf", - categorical_method="one-hot", - task="classification", - target_aware=True, - output_dim=10, -) - -X_tr = pre.fit_transform(X_train, y_train, return_array=True) -X_te = pre.transform(X_test, return_array=True) - -clf = LogisticRegression(max_iter=1000).fit(X_tr, y_train) -proba = clf.predict_proba(X_te)[:, 1] - -print(f"features: {X_tr.shape[1]}") -print(f"accuracy: {accuracy_score(y_test, clf.predict(X_te)):.3f}") -print(f"ROC AUC: {roc_auc_score(y_test, proba):.3f}") -``` - -```text -features: 33 -accuracy: 0.872 -ROC AUC: 0.927 -``` - -The RBF features let the linear classifier bend around the ring. Accuracy rises from `0.742` -to `0.872`, and the `ROC AUC` jumps from `0.569` to `0.927`, a much better separation of -the two classes. - -```{note} -`target_aware=True` lets supervised expansions (like RBF and PLE) use `y` during `fit` to -place their basis functions where they best separate the classes, so always pass `y` when -fitting. -``` - -## What changed - -```python -pre.get_feature_info() -``` - -```text -feature kind pipeline dim cats ----------------------------------------------------------------- -x1 numerical imputer -> minmax -> rbf 10 - -x2 numerical imputer -> minmax -> rbf 10 - -hours numerical imputer -> minmax -> rbf 10 - -plan categorical imputer -> onehot -> to_float 3 3 -``` - -Three numeric columns become 30 RBF features and `plan` becomes three one-hot columns, 33 -in total, turning an unsolvable linear problem into an easy one. - -## Next steps - -- See the regression version in the [end-to-end example](../getting_started/end_to_end.md). -- Compose transformers inside a single `sklearn` `Pipeline` with cross-validation in the - [sklearn Pipeline tutorial](sklearn_pipeline.md). diff --git a/docs/user_guide/configuration.md b/docs/user_guide/configuration.md deleted file mode 100644 index 0826fc5..0000000 --- a/docs/user_guide/configuration.md +++ /dev/null @@ -1,112 +0,0 @@ -# Hyperparameter and configuration guide - -Every numerical transformer in PreTab shares a small set of hyperparameters. This guide explains -what each one does, the default it ships with, and how to choose a value that suits your data. If -there is one setting worth learning first, it is `output_dim`, which controls how wide each -feature becomes after transformation. - -```{note} -When you work through the `Preprocessor`, its single `output_dim` (default `7`) is forwarded to -**every** numerical method. The per-transformer defaults listed below therefore only take effect -when you build a transformer directly, for example `RBFExpansionTransformer()`. -``` - -## The `output_dim` width knob - -`output_dim` is the main capacity control. It sets the number of non-bias output columns produced -for each input feature: bins for PLE and binning, centers for the feature maps, and basis -functions for the splines. A larger value captures finer structure in a feature, at the cost of -more columns and a higher chance of overfitting. A smaller value is more compact and regularises -the representation. - -The defaults are aligned on a moderate value of `6`. It is expressive enough for most features -while staying compact, and it clears the minimum width that every spline basis requires. The -tensor-product spline is the single exception: its columns multiply across marginal dimensions, -so it defaults to its smallest valid width to keep the output from exploding. - -| Method | Class | Default | Minimum (floor) | -| --- | --- | --- | --- | -| PLE | `PLETransformer` | `6` | `1` (upper bound on bins; actual count is data-dependent) | -| RBF map | `RBFExpansionTransformer` | `6` | `1` | -| ReLU map | `ReLUExpansionTransformer` | `6` | `1` | -| Sigmoid map | `SigmoidExpansionTransformer` | `6` | `1` | -| Tanh map | `TanhExpansionTransformer` | `6` | `1` | -| Cubic spline | `CubicSplineTransformer` | `6` | `3` (3 polynomial terms + interior knots) | -| Natural cubic | `NaturalCubicSplineTransformer` | `6` | `2` (places `output_dim + 1` knots) | -| B/M/I splines | `BSplineTransformer`, `MSplineTransformer`, `ISplineTransformer` | `6` | `degree + 1` (=`4`), capped at `50` | -| P-spline | `PSplineTransformer` | `6` | `degree + 1` (=`4`) | -| Tensor product | `TensorProductSplineTransformer` | `4` | `degree + 1` (=`4`) **per marginal** | -| Thin-plate | `ThinPlateSplineTransformer` | `6` | `1` | -| Preprocessor (shared) | `Preprocessor` | `7` | overrides the per-transformer defaults above | - -```{warning} -Each spline enforces its own minimum width. Requesting fewer basis functions than the floor in -the table raises an error at `fit` time instead of silently clamping, so keep `output_dim` at or -above that floor. The floor is `degree + 1` for the B, M, I, P-spline, and tensor-product bases. -``` - -```{tip} -For the tensor-product spline the width grows as the product across marginals. A 2-D input with -`output_dim=4` already produces `4 × 4 = 16` columns, so raise it in small steps and keep an eye -on the total column count. -``` - -## Adaptive sizing - -PLE and the feature maps can size each feature from the data instead of using one fixed width. -This helps when your features differ a lot in complexity and you would rather not tune -`output_dim` by hand. - -`adaptive` -: When `True`, the width for each feature is chosen from the data and kept inside - `[min_output_dim, max_output_dim]`. Fixed-width methods such as the plain scalers ignore this - flag. - -`min_output_dim`, `max_output_dim` -: The lower and upper bounds that apply only when `adaptive=True`. They are ignored otherwise. - -## Target-aware placement - -Some methods can place their bins, centers, or knots using the target `y`. This tends to sharpen -the representation where the target actually changes, at the cost of needing labels at `fit` -time. - -`target_aware` -: Whether placement uses the target. PLE is inherently target-aware. Every other family defaults - to `target_aware=False`, which is fully unsupervised and fits without `y`. - -`placement_strategy` -: How units are placed. The valid values depend on `target_aware`, as shown below. - -| `target_aware` | Allowed `placement_strategy` | Default when unset | -| --- | --- | --- | -| `True` | `cart`, `lightgbm` | `cart` | -| `False` | `uniform`, `quantile` | `quantile` | - -```{warning} -The two rows of this table are mutually exclusive. Combining them, for example -`target_aware=True` with `placement_strategy="quantile"`, raises an error. Leave -`placement_strategy` unset to get the sensible default for whichever mode you picked. -``` - -`task` -: Either `"regression"` or `"classification"`. It is only consulted by target-aware placement, - which uses it to fit the selector against `y`. - -## Spline-specific parameters - -`degree` -: Degree of the spline basis, where `3` is cubic. It also sets the `output_dim` floor of - `degree + 1` for the B, M, I, P-spline, and tensor-product bases, so a lower degree lowers the - minimum width. - -`include_bias` -: When `True`, a constant intercept column is prepended to the output. The bias term is left - unpenalised. - -## Reproducibility - -`random_state` -: Seeds the target-aware selectors and any stochastic placement so that repeated fits produce - identical output. Set it to an integer whenever you need deterministic results, for example in - tests or published experiments. diff --git a/docs/user_guide/preprocessing.md b/docs/user_guide/preprocessing.md deleted file mode 100644 index 5f3d52c..0000000 --- a/docs/user_guide/preprocessing.md +++ /dev/null @@ -1,109 +0,0 @@ -# Preprocessing overview - -The `Preprocessor` (`pretab.preprocessor.Preprocessor`) is the main entry point. It -inspects a `pandas.DataFrame`, decides which columns are numerical and which are -categorical, and builds a scikit-learn `ColumnTransformer` that applies a chosen strategy -per feature. - -## How feature types are detected - -By default, columns are classified automatically: - -- **Numerical**: continuous columns, and integer columns with enough distinct values. -- **Categorical**: string/object columns, and low-cardinality integer columns. - -The behaviour is controlled by several constructor arguments: - -`cat_cutoff` -: Threshold that decides whether an integer column is treated as categorical. - -`treat_all_integers_as_numerical` -: When `True`, every integer column is treated as numerical regardless of cardinality. - -## Choosing strategies - -There are two ways to configure preprocessing: - -1. **Globally** via `numerical_method` and `categorical_method`. -2. **Per feature** via the `feature_preprocessing` dict, which overrides the global - defaults for specific columns. - -```python -from pretab import Preprocessor - -# Global strategy for every column of a given type -preprocessor = Preprocessor( - numerical_method="ple", - categorical_method="int", -) - -# Or override individual columns -preprocessor = Preprocessor( - feature_preprocessing={ - "age": "ple", - "income": "rbf", - "city": "one-hot", - }, -) -``` - -## Numerical strategies - -| Strategy | Transformer | Notes | -| ----------------- | -------------------------------- | ----- | -| `standardization` | `StandardScaler` | Zero mean, unit variance | -| `minmax` | `MinMaxScaler` | Scaled to `[-1, 1]` | -| `quantile` | `QuantileTransformer` | Rank-based normalisation | -| `robust` | `RobustScaler` | Robust to outliers | -| `polynomial` | `PolynomialFeatures` | Polynomial interactions | -| `box-cox` | `PowerTransformer` | Positive inputs only | -| `yeo-johnson` | `PowerTransformer` | Handles zero/negative values | -| `ple` | `PLETransformer` | Piecewise linear encoding | -| `custombin` | `CustomBinTransformer` | Rule- or tree-based binning | -| `rbf` | `RBFExpansionTransformer` | Radial basis functions | -| `relu` | `ReLUExpansionTransformer` | ReLU basis expansion | -| `sigmoid` | `SigmoidExpansionTransformer` | Sigmoid basis expansion | -| `tanh` | `TanhExpansionTransformer` | Tanh basis expansion | -| `cubicspline` | `CubicSplineTransformer` | B-spline basis | -| `naturalspline` | `NaturalCubicSplineTransformer` | Natural cubic spline | -| `pspline` | `PSplineTransformer` | Penalised B-spline | -| `tensorspline` | `TensorProductSplineTransformer` | Tensor-product spline | -| `tprs` | `ThinPlateSplineTransformer` | Thin-plate regression spline | -| `none` | `NoTransformer` | Pass-through | - -## Categorical strategies - -| Strategy | Transformer | Notes | -| --------------------- | ------------------------------ | ----- | -| `int` | `ContinuousOrdinalTransformer` | Integer/ordinal encoding (default) | -| `one-hot` | `OneHotEncoder` | One-hot encoding | -| `onehot_from_ordinal` | `OneHotFromOrdinalTransformer` | One-hot from pre-encoded ordinals | -| `pretrained` | `LanguageEmbeddingTransformer` | Pretrained language embeddings | -| `custombin` | `CustomBinTransformer` | Binning of categorical codes | -| `none` | `NoTransformer` | Pass-through | - -```{note} -The `pretrained` strategy requires the optional `sentence-transformers` dependency. -Install it with `pip install "pretab[embeddings]"`. -``` - -## Output format - -`fit_transform` and `transform` return a dictionary that maps each feature to its -transformed array (keys are prefixed with `num_` or `cat_`). Pass `return_array=True` -to `transform` to receive a single stacked `numpy.ndarray` instead. - -```python -X_dict = preprocessor.fit_transform(df, y) # {"num_age": ..., "cat_city": ...} -X_array = preprocessor.transform(df, return_array=True) # single ndarray -``` - -Use `get_feature_info(verbose=True)` to inspect the resolved strategy and output -dimensionality of every feature. - -## Using transformers directly - -Every transformer listed above is also importable from `pretab.transformers` and works as -a standalone scikit-learn transformer, so it can be composed into any `Pipeline` or -`ColumnTransformer`. See the [Quickstart](../getting_started/quickstart.md) for examples, -and the [API Reference](../api/index.rst) for the full parameter list of each class. From 5250f1c9a102dfb1bd731f95c2dc70086acd99a0 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Mon, 27 Jul 2026 14:13:26 +0200 Subject: [PATCH 22/59] docs(getting-started): add overview, interface, and migration guides --- docs/getting_started/choosing_an_interface.md | 92 ++++++++++++ docs/getting_started/migration_to_1_0.md | 124 ++++++++++++++++ docs/getting_started/overview.md | 102 +++++++++++++ docs/getting_started/quickstart.md | 137 +++++++++++------- 4 files changed, 403 insertions(+), 52 deletions(-) create mode 100644 docs/getting_started/choosing_an_interface.md create mode 100644 docs/getting_started/migration_to_1_0.md create mode 100644 docs/getting_started/overview.md diff --git a/docs/getting_started/choosing_an_interface.md b/docs/getting_started/choosing_an_interface.md new file mode 100644 index 0000000..2663b79 --- /dev/null +++ b/docs/getting_started/choosing_an_interface.md @@ -0,0 +1,92 @@ +# Choosing an interface + +PreTab exposes the same representations through two surfaces: the high-level `Preprocessor` +and the standalone transformers. They share the same underlying code, so the choice is about +ergonomics, not capability. This page helps you pick. + +## The two surfaces at a glance + +::::{grid} 1 1 2 2 +:gutter: 3 + +:::{grid-item-card} `Preprocessor` +Reads a `DataFrame`, detects numerical and categorical columns, and applies a strategy per +column from a single configuration object. Returns a dict of feature blocks by default. +::: + +:::{grid-item-card} Standalone transformers +Plain scikit-learn transformers you import from `pretab.transformers`. Each one returns a +NumPy array and slots into a `Pipeline`, `ColumnTransformer`, or any scikit-learn utility. +::: + +:::: + +## Reach for the `Preprocessor` when + +- You start from a `DataFrame` and want **automatic feature-type detection** rather than + wiring every column by hand. +- You want to configure **many columns from one place**, either with global + `numerical_method` / `categorical_method` defaults or a per-column `feature_preprocessing` + map. +- You want the **framework services** that live at this level: feature lineage, output-format + control, missing-value policy, output budgets, serialization, and a reproducibility + fingerprint. + +```python +from pretab import Preprocessor + +pre = Preprocessor(feature_preprocessing={ + "age": "naturalspline", + "income": "ple", + "city": "one-hot", +}) +X = pre.fit_transform(df, y) # dict of blocks, or return_array=True for one matrix +``` + +## Reach for standalone transformers when + +- You want a **single estimator object** that composes cleanly inside one `Pipeline`. +- You rely on **scikit-learn model selection**: `cross_val_score`, `GridSearchCV`, and + `step__param` hyperparameter addressing all work out of the box. +- You need **fine control** over one column's transformer and its parameters. + +```python +from sklearn.compose import ColumnTransformer +from sklearn.pipeline import Pipeline +from sklearn.linear_model import Ridge + +from pretab.transformers import NaturalCubicSplineTransformer, PLETransformer + +features = ColumnTransformer([ + ("age", NaturalCubicSplineTransformer(output_dim=10), ["age"]), + ("income", PLETransformer(output_dim=12), ["income"]), +]) +model = Pipeline([("features", features), ("ridge", Ridge())]) +``` + +```{note} +The `Preprocessor` returns a dict by default, which is convenient for inspection but is not a +drop-in for a scikit-learn estimator that expects a single matrix. Call it with +`return_array=True`, or use the standalone transformers, when you compose one end-to-end +`Pipeline`. +``` + +## A note on multivariate methods + +The tensor-product spline, thin-plate spline, random Fourier features, and Nyström map model +several columns **jointly**. They are standalone-only and are not selectable per column +through `Preprocessor(numerical_method=...)`. Use them directly as transformers over a block +of columns. See [Multivariate features](../tutorials/multivariate_features.md). + +## They interoperate + +The choice is not exclusive. A `Preprocessor` can live inside a larger `Pipeline`, and +standalone transformers can preprocess columns you then hand to a `Preprocessor`. Pick the +surface that keeps the intent of your code clearest. + +## Where to go next + +- [Configuration](../core_concepts/configuration.md) documents every `Preprocessor` knob. +- [scikit-learn pipelines](../tutorials/sklearn_pipeline.md) shows the standalone route with + cross-validation and grid search. +- [Representations](../representations/overview.md) is the full method catalogue. diff --git a/docs/getting_started/migration_to_1_0.md b/docs/getting_started/migration_to_1_0.md new file mode 100644 index 0000000..e21a036 --- /dev/null +++ b/docs/getting_started/migration_to_1_0.md @@ -0,0 +1,124 @@ +# Migrating to 1.0 + +PreTab 1.0 is the first stable release. Because the previously published API (`0.0.2`) was +never declared stable, 1.0 takes a one-time, deliberate cleanup: intention-revealing class +names, non-overlapping parameters, and a smaller, sharper scope. This page maps the old +surface to the new one so you can upgrade in a single pass. + +```{important} +1.0 contains breaking changes relative to `0.0.2`. There are no compatibility shims. Update +the names and parameters below, then re-fit. Pin `pretab<1` if you need the old behaviour +while you migrate. +``` + +## Renamed transformers + +The classes gained names that say what they compute. + +| Old name (`0.0.2`) | New name (`1.0`) | Notes | +| --- | --- | --- | +| `CustomBinTransformer` | `NumericBinningTransformer` | Numeric-only, now stateful (learns edges in `fit`). | +| `CyclicalTimeTransformer` | `PeriodicEncodingTransformer` | Sine and cosine harmonics for cyclic values. | +| `CubicSplineTransformer` | `CubicRegressionSplineTransformer` | Disambiguated from the generic cubic B-spline. | + +## Removed transformers + +Generic time-series utilities are out of scope for a representation framework. + +| Removed | Replacement | +| --- | --- | +| `LagFeatureTransformer` | Use a dedicated time-series library. | +| `RollingStatsTransformer` | Use a dedicated time-series library. | + +```{note} +Cyclic time structure is still first-class through `PeriodicEncodingTransformer` and the +`"fourier"` feature map. Only the generic lag and rolling-window helpers were removed. +``` + +## Deprecated + +| Symbol | Status | Do this instead | +| --- | --- | --- | +| `OneHotFromOrdinalTransformer` | Deprecated, emits a `DeprecationWarning` | Use the `"one-hot"` categorical method, which wraps scikit-learn's `OneHotEncoder`. | + +## Parameter changes on `Preprocessor` + +### Placement is now two clean knobs + +The overlapping `selector` / `strategy` / `use_target` arguments are gone. Placement is +controlled by exactly two parameters that validate strictly against each other. + +| Old | New | +| --- | --- | +| `use_target=True/False`, plus ad-hoc `selector` / `strategy` | `target_aware: bool` and `placement_strategy: str` | + +The valid combinations are fixed: + +| `target_aware` | Allowed `placement_strategy` | +| --- | --- | +| `False` | `"uniform"`, `"quantile"` | +| `True` | `"cart"`, `"lightgbm"` | + +```{warning} +Mixing the two rows, for example `target_aware=True` with `placement_strategy="quantile"`, +raises an error rather than silently guessing. Leave `placement_strategy` unset to get the +sensible default for whichever mode you chose. +``` + +See [Resolution and placement](../core_concepts/resolution_and_placement.md) for the full +model. + +### Missing-value handling is explicit + +The single `handle_missing` flag was replaced by three explicit parameters. + +| Old | New | +| --- | --- | +| `handle_missing=...` | `numerical_imputation="median"`, `categorical_imputation="most_frequent"`, `add_missing_indicator=False` | + +Set an imputation strategy to `None` to disable it for that kind. See +[Missing values](../core_concepts/missing_values.md). + +## Renamed optional extra + +| Old install | New install | +| --- | --- | +| `pip install "pretab[knots]"` | `pip install "pretab[lightgbm]"` | + +The rename matches `placement_strategy="lightgbm"`. The `embeddings` and `all` extras are +unchanged. See [Installation](installation.md). + +## Thin-plate spline parameters + +The thin-plate spline moved to landmark-based terminology and is sized by rank, not by a +fixed `output_dim`. + +| Old | New | +| --- | --- | +| `ThinPlateSplineTransformer(output_dim=...)` | `ThinPlateSplineTransformer(n_components=..., landmark_strategy="kmeans", rank_strategy="eigen")` | + +## What is new in 1.0 + +Upgrading also unlocks capabilities that did not exist in `0.0.2`. + +- **New representations**: `FourierFeatureTransformer`, `RandomFourierFeaturesTransformer`, + and `NystroemFeaturesTransformer`. +- **A typed intermediate form**: `RepresentationSpec` plus per-output-column + [feature lineage](../core_concepts/outputs_and_inspection.md). +- **Leakage-safe supervision**: `CrossFittedTransformer`, `RepresentationSearchCV`, and a + `LeakageWarning`. See [Target awareness](../core_concepts/target_awareness.md). +- **Portable serialization**: `to_spec` / `from_spec`, a stable `fingerprint_`, and a frozen + lifecycle. See [Reproducibility](../core_concepts/reproducibility.md). +- **Presets and discovery**: `Preprocessor(preset=...)` and `list_representations(...)`. +- **Central edge-case policy** and **output budgets** on `Preprocessor`. + +## Upgrade checklist + +1. Rename the three renamed transformer classes. +2. Remove any use of `LagFeatureTransformer` / `RollingStatsTransformer`. +3. Replace `handle_missing` with the three explicit imputation parameters. +4. Replace `use_target` / `selector` / `strategy` with `target_aware` and + `placement_strategy`. +5. Swap `ThinPlateSplineTransformer(output_dim=...)` for `n_components`. +6. Update `pretab[knots]` to `pretab[lightgbm]` in your dependencies. +7. Re-fit and confirm the resolved layout with `get_feature_info(verbose=True)`. diff --git a/docs/getting_started/overview.md b/docs/getting_started/overview.md new file mode 100644 index 0000000..37c5810 --- /dev/null +++ b/docs/getting_started/overview.md @@ -0,0 +1,102 @@ +# Overview + +PreTab is a representation and basis-expansion framework for tabular data. It takes raw +numerical and categorical columns and turns them into model-ready features that expose +structure a plain estimator cannot see on its own. Every strategy speaks the standard +scikit-learn `fit` / `transform` API, so PreTab drops into the pipelines and tooling you +already use. + +## The problem PreTab solves + +Most tabular models receive one straight-line term per numerical column. A linear model, +a logistic regression, or a plain additive model can only weight that single slope, so any +curve, threshold, saturation, or periodic pattern in the data is invisible to it. The usual +response is to hand-craft features: bucket an age column, add a squared income term, encode +the hour of day as a pair of sine and cosine values. That work is repetitive, easy to get +wrong, and rarely reproducible. + +PreTab makes those representations first-class. Instead of writing feature code by hand you +declare intent once, for example "expand `age` with a spline, encode `income` with +piecewise-linear bins, treat `hour` as periodic", and PreTab fits the corresponding basis +per column, tracks where every output column came from, and hands back a clean matrix. + +```python +from pretab import Preprocessor + +pre = Preprocessor(feature_preprocessing={ + "age": "naturalspline", # smooth non-linear effect + "income": "ple", # supervised piecewise-linear encoding + "hour": "fourier", # periodic representation + "city": "one-hot", # categorical +}) +X = pre.fit_transform(df, y) +``` + +## When to reach for PreTab + +PreTab is a good fit when any of the following is true. + +- You pair a **simple or linear model** (Ridge, logistic regression, a GAM, a linear layer) + with tabular data and want it to capture non-linear structure. +- You need **expressive numerical representations** such as splines, radial basis maps, + Fourier features, or piecewise-linear encoding without wiring each one by hand. +- You want **per-column control** over preprocessing from a single configuration object. +- You care about **reproducibility and inspection**: knowing exactly which input produced + each output column, serializing a fitted representation, and getting a stable fingerprint. +- You are **researching representations** and want a common, typed intermediate form + (`RepresentationSpec` plus feature lineage) shared across every family. + +```{tip} +Basis expansion helps most when the model downstream is comparatively simple. A rich, +already-non-linear model such as gradient boosting can learn many of these shapes on its +own, so the marginal benefit of an explicit basis is smaller there. See +[Choosing a method](../representations/choosing_a_method.md) for the trade-offs. +``` + +## What PreTab is not + +Knowing the boundaries is as useful as knowing the features. PreTab deliberately does not +try to be an everything-library. + +- **Not a modelling library.** PreTab produces features. It does not fit predictors, tune + models, or select features for you. It sits *in front of* an estimator. +- **Not a time-series toolkit.** Generic lag and rolling-window utilities were removed on + purpose. PreTab keeps the periodic encoding that expresses cyclic structure (hour, day, + month) but leaves sequence modelling to dedicated libraries. +- **Not a data-cleaning suite.** It offers principled, centrally-defined policies for + missing values, constant columns, and out-of-range inputs, but it is not a substitute for + domain-specific data validation. +- **Not a guaranteed accuracy win.** An expressive basis in front of a model that is already + flexible, or on a feature with no non-linear signal, can add columns without adding value. + The [failure modes](../representations/choosing_a_method.md#when-basis-expansion-does-not-help) + section is explicit about where it does not help. + +## Two ways to use it + +PreTab exposes the same capabilities through two surfaces. + +::::{grid} 1 1 2 2 +:gutter: 3 + +:::{grid-item-card} The high-level `Preprocessor` +Detects column types from a `DataFrame`, applies a strategy per column, and returns +model-ready blocks or a single stacked array. Reach for it when you want per-column +strategies from one config. +::: + +:::{grid-item-card} Standalone transformers +Every strategy is also a plain scikit-learn transformer you can import and compose inside a +`Pipeline` or `ColumnTransformer`. Reach for them when you want a single estimator object. +::: + +:::: + +The [Choosing an interface](choosing_an_interface.md) page explains which to pick. + +## Where to go next + +- [Installation](installation.md) sets up PreTab and its optional extras. +- [Quickstart](quickstart.md) fits your first `Preprocessor` in a few minutes. +- [Core concepts](../core_concepts/feature_representation.md) explains the ideas that run + through the whole library. +- [Representations](../representations/overview.md) is the catalogue of every method. diff --git a/docs/getting_started/quickstart.md b/docs/getting_started/quickstart.md index cba8e88..63a9d1d 100644 --- a/docs/getting_started/quickstart.md +++ b/docs/getting_started/quickstart.md @@ -1,16 +1,23 @@ # Quickstart -This page walks through the two ways to use pretab: +This page fits your first representation in a few minutes. It covers the two ways to use +PreTab: the high-level `Preprocessor` that builds a full pipeline from a config, and the +individual transformers that behave like any other scikit-learn step. -1. the high-level `Preprocessor` (`pretab.preprocessor.Preprocessor`), which builds a full - scikit-learn pipeline from a config, and -2. the individual transformers, which behave like any other `sklearn` transformer. +## Install -## Using the `Preprocessor` +```bash +pip install pretab +``` + +See [Installation](installation.md) for optional extras such as language embeddings and +LightGBM-based placement. + +## Fit a `Preprocessor` -The `Preprocessor` detects feature types automatically and applies per-feature -preprocessing. It returns either a dictionary of feature blocks (default) or a single -stacked array. +The `Preprocessor` inspects a `DataFrame`, decides which columns are numerical and which are +categorical, and applies a strategy per column. It returns a dictionary of feature blocks by +default, or a single stacked array on request. ```python import numpy as np @@ -18,87 +25,113 @@ import pandas as pd from pretab import Preprocessor -# Simulated tabular dataset +rng = np.random.default_rng(0) df = pd.DataFrame({ - "age": np.random.randint(18, 65, size=100), - "income": np.random.normal(60000, 15000, size=100).astype(int), - "job": np.random.choice(["nurse", "engineer", "scientist", "teacher"], size=100), - "city": np.random.choice(["Berlin", "Munich", "Hamburg", "Cologne"], size=100), - "experience": np.random.randint(0, 40, size=100), + "age": rng.integers(18, 65, size=200), + "income": rng.normal(60_000, 15_000, size=200).astype(int), + "experience": rng.integers(0, 40, size=200), + "job": rng.choice(["nurse", "engineer", "scientist", "teacher"], size=200), + "city": rng.choice(["Berlin", "Munich", "Hamburg", "Cologne"], size=200), }) -y = np.random.randn(100, 1) +y = np.sin(df["age"] / 10) + df["income"] / 1e5 + rng.normal(0, 0.1, size=200) -# Optional per-feature preprocessing config config = { - "age": "ple", - "income": "rbf", - "experience": "quantile", + "age": "ple", # supervised piecewise-linear encoding + "income": "rbf", # radial basis feature map + "experience": "naturalspline", "job": "one-hot", - "city": "none", + "city": "int", # integer (ordinal) codes } +pre = Preprocessor(feature_preprocessing=config, task="regression", random_state=0) -preprocessor = Preprocessor(feature_preprocessing=config, task="regression") +# Fit and transform into a dict of feature blocks +X_dict = pre.fit_transform(df, y) +{k: v.shape for k, v in X_dict.items()} +``` -# Fit and transform into a dictionary of feature arrays -X_dict = preprocessor.fit_transform(df, y) +```{tip} +When no per-feature config is given, the `Preprocessor` falls back to its global +`numerical_method` (default `"ple"`) and `categorical_method` (default `"int"`). See +[Configuration](../core_concepts/configuration.md) for every knob. +``` -# ... or get a single stacked array instead -X_array = preprocessor.transform(df, return_array=True) +Ask for a single stacked matrix instead when you feed a plain estimator: -# Inspect the resolved feature metadata -preprocessor.get_feature_info(verbose=True) +```python +X = pre.transform(df, return_array=True) # one ndarray, one row per sample ``` -```{tip} -When no per-feature config is provided, the `Preprocessor` falls back to the global -`numerical_method` and `categorical_method` strategies. See the -[User Guide](../user_guide/preprocessing.md) for the full list of options. +## Inspect what was built + +Every fitted representation is self-describing. Read the resolved layout, or trace each +output column back to its source. + +```python +pre.get_feature_info(verbose=True) # human-readable table of per-feature pipelines + +lineage = pre.get_feature_lineage() # one record per output column +lineage[0] ``` -## Using individual transformers +The lineage covers every output column, and the names line up with `get_feature_names_out`. +See [Outputs and inspection](../core_concepts/outputs_and_inspection.md) for the full +contract. + +## Use a transformer on its own -Every transformer follows the standard `sklearn` `fit` / `transform` API, so it can be -dropped into a `Pipeline` or `ColumnTransformer`. +Every strategy is also importable from `pretab.transformers` and follows the scikit-learn +API, so it drops into a `Pipeline` or `ColumnTransformer`. ```python import numpy as np from pretab.transformers import PLETransformer -x = np.random.randn(100, 1) -y = np.random.randn(100, 1) +x = np.random.randn(200, 1) +y = np.random.randn(200) x_ple = PLETransformer(output_dim=15, task="regression").fit_transform(x, y) -assert x_ple.shape[1] == 15 +x_ple.shape[1] # number of piecewise-linear bins ``` ```{note} -`PLETransformer` is supervised: it uses the target `y` during `fit` to place its bin -edges. Always pass `y` when fitting it, or any pipeline that includes it. +`PLETransformer` is supervised: it reads the target `y` during `fit` to place its bin edges. +Always pass `y` when fitting it, or any pipeline that contains it. See +[Target awareness](../core_concepts/target_awareness.md). ``` -For spline transformers, the penalty matrix can be extracted with -`get_penalty_matrix()`: +Spline families that carry a smoothness penalty expose it through `get_penalty_matrix()`: ```python import numpy as np -from pretab.transformers import ThinPlateSplineTransformer +from pretab.transformers import NaturalCubicSplineTransformer + +x = np.random.randn(200, 1) +spline = NaturalCubicSplineTransformer(output_dim=8) +spline.fit_transform(x) + +penalty = spline.get_penalty_matrix() # second-difference penalty for GAM-style fitting +``` -x = np.random.randn(100, 1) +The multivariate thin-plate spline models several columns jointly and is sized by +`n_components` rather than `output_dim`: -tp = ThinPlateSplineTransformer(output_dim=15) -x_tp = tp.fit_transform(x) -assert x_tp.shape[1] == 15 +```python +import numpy as np + +from pretab.transformers import ThinPlateSplineTransformer +x = np.random.randn(200, 2) # two input columns, modelled together +tp = ThinPlateSplineTransformer(n_components=10) +features = tp.fit_transform(x) penalty = tp.get_penalty_matrix() ``` ## Next steps -- See pretab feed a real model, baseline vs. pretab, in the - [end-to-end example](end_to_end.md). -- Work through a [classification tutorial](../tutorials/classification.md) or an - [sklearn Pipeline tutorial](../tutorials/sklearn_pipeline.md). -- Learn about the available strategies in the [User Guide](../user_guide/preprocessing.md). -- Browse every class in the [API Reference](../api/index.rst). +- See PreTab lift a linear model, baseline versus PreTab, in the + [non-linear regression tutorial](../tutorials/nonlinear_regression.md). +- Decide between the two surfaces in [Choosing an interface](choosing_an_interface.md). +- Browse every method in [Representations](../representations/overview.md). +- Learn the shared ideas in [Core concepts](../core_concepts/feature_representation.md). From a92247a4dcdff439cf04f98d721f99c2d2f60541 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Mon, 27 Jul 2026 14:13:35 +0200 Subject: [PATCH 23/59] docs(core-concepts): add core concepts section --- docs/core_concepts/configuration.md | 118 +++++++++++++++ docs/core_concepts/feature_representation.md | 86 +++++++++++ docs/core_concepts/missing_values.md | 102 +++++++++++++ docs/core_concepts/outputs_and_inspection.md | 135 ++++++++++++++++++ docs/core_concepts/reproducibility.md | 107 ++++++++++++++ .../core_concepts/resolution_and_placement.md | 122 ++++++++++++++++ docs/core_concepts/target_awareness.md | 112 +++++++++++++++ 7 files changed, 782 insertions(+) create mode 100644 docs/core_concepts/configuration.md create mode 100644 docs/core_concepts/feature_representation.md create mode 100644 docs/core_concepts/missing_values.md create mode 100644 docs/core_concepts/outputs_and_inspection.md create mode 100644 docs/core_concepts/reproducibility.md create mode 100644 docs/core_concepts/resolution_and_placement.md create mode 100644 docs/core_concepts/target_awareness.md diff --git a/docs/core_concepts/configuration.md b/docs/core_concepts/configuration.md new file mode 100644 index 0000000..3852253 --- /dev/null +++ b/docs/core_concepts/configuration.md @@ -0,0 +1,118 @@ +# Configuration + +The `Preprocessor` is configured through a small, predictable set of parameters. This page +covers the four ways to express intent: global defaults, per-feature overrides, presets, and +reading back the resolved configuration. The mechanics of width and placement live in +[Resolution and placement](resolution_and_placement.md), and target usage in +[Target awareness](target_awareness.md). + +## Global defaults + +The simplest configuration sets one strategy for every numerical column and one for every +categorical column. + +```python +from pretab import Preprocessor + +pre = Preprocessor( + numerical_method="ple", # applied to every numerical column + categorical_method="int", # applied to every categorical column +) +``` + +The defaults are `numerical_method="ple"` and `categorical_method="int"`. The full list of +strategy strings is in the [representation comparison](../representations/comparison_table.md). + +## Per-feature overrides + +Columns rarely want identical treatment. The `feature_preprocessing` dict assigns a strategy +to individual columns and takes precedence over the global defaults for those columns. + +```python +pre = Preprocessor( + numerical_method="ple", # default for numerical columns not listed + feature_preprocessing={ + "age": "naturalspline", + "income": "rbf", + "city": "one-hot", + }, +) +``` + +```{note} +A per-feature entry is resolved in the correct namespace for its detected column kind. You do +not need to state whether a column is numerical or categorical; PreTab already knows from +feature-type detection. +``` + +## Presets + +Presets are transparent, named bundles of parameters for common intents. They set the same +knobs you could set by hand, so nothing is hidden. + +| Preset | Intent | +| --- | --- | +| `"standard"` | A balanced, general-purpose configuration. | +| `"expanded"` | Wider, more expressive representations. | +| `"adaptive"` | Data-driven per-feature width within bounds. | + +```python +pre = Preprocessor(preset="standard") +``` + +```{tip} +A preset is a starting point, not a lock. Any parameter you pass alongside a preset overrides +the preset's value for that knob. +``` + +## Reading the resolved configuration + +Because global defaults, per-feature overrides, and presets interact, PreTab lets you read +back exactly what will be used. `get_resolved_config()` returns the fully resolved settings +as a plain dict. + +```python +pre = Preprocessor(preset="expanded", feature_preprocessing={"age": "bspline"}) +pre.get_resolved_config() +``` + +This is the authoritative answer to "what did my configuration actually become", and it is +useful in tests and reproducible experiments. + +## How the layers combine + +The resolution order is deterministic. Later layers win. + +1. Library defaults. +2. A `preset`, if given. +3. Explicit constructor arguments (`numerical_method`, `output_dim`, and so on). +4. Per-column `feature_preprocessing` entries, for the columns they name. + +```{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. +This is intentional: it surfaces mistakes early rather than producing a quietly wrong +representation. +``` + +## Key parameters at a glance + +The parameters below are the ones you reach for most. Each links to the page that explains it +in depth. + +| Parameter | Default | Covered in | +| --- | --- | --- | +| `numerical_method`, `categorical_method` | `"ple"`, `"int"` | this page | +| `feature_preprocessing` | `None` | this page | +| `output_dim` | `7` | [Resolution and placement](resolution_and_placement.md) | +| `adaptive`, `min_output_dim`, `max_output_dim` | `False`, `5`, `10` | [Resolution and placement](resolution_and_placement.md) | +| `target_aware`, `placement_strategy` | `True`, `"cart"` | [Target awareness](target_awareness.md) | +| `numerical_imputation`, `categorical_imputation`, `add_missing_indicator` | `"median"`, `"most_frequent"`, `False` | [Missing values](missing_values.md) | +| `output_format`, `dtype` | `"dense"`, `None` | [Outputs and inspection](outputs_and_inspection.md) | +| `random_state` | `None` | [Reproducibility](reproducibility.md) | + +## Where to go next + +- [Resolution and placement](resolution_and_placement.md) for width and location. +- [Target awareness](target_awareness.md) for supervised placement. +- [Representations](../representations/overview.md) for what each method does. diff --git a/docs/core_concepts/feature_representation.md b/docs/core_concepts/feature_representation.md new file mode 100644 index 0000000..d17c276 --- /dev/null +++ b/docs/core_concepts/feature_representation.md @@ -0,0 +1,86 @@ +# Preprocessing and representation + +PreTab draws a deliberate line between two ideas that are often blurred together: +*preprocessing* and *representation*. Understanding the distinction explains why the library +is shaped the way it is, and it is the key to using it well. + +## Preprocessing prepares a column + +Preprocessing makes a column safe and comparable for a model. It does not change what the +column *means*, only its scale, dtype, or completeness. Standardizing to zero mean and unit +variance, imputing a missing value, casting to float, and one-hot encoding a category are all +preprocessing. Each keeps a one-to-one relationship with the original signal. + +## Representation changes what the model can see + +A representation expands a column into a new basis that exposes structure a plain estimator +cannot weight on its own. A single numeric column becomes a set of spline coefficients, a +bank of radial bumps, a stack of piecewise-linear bins, or a pair of sine and cosine values. +The model now has several coordinates to weight where it previously had one slope, so it can +express curves, thresholds, saturation, and periodicity. + +```{note} +This is the load-bearing idea in PreTab: the model is often fine, the *representation* is +what is missing. A linear model with an expressive basis can fit shapes that the same model +on raw columns cannot. +``` + +## Why the distinction matters + +Keeping the two separate has practical consequences that show up all over the API. + +- **Scaling composes with representation.** A numeric column is typically imputed and scaled + first (preprocessing), then expanded into a basis (representation). The `Preprocessor` + wires this order for you. +- **Representations are self-describing.** Because an expansion is a real modelling choice, + every fitted representation carries a typed [`RepresentationSpec`](../api/preprocessor.rst) + and per-output-column [lineage](outputs_and_inspection.md), so you always know which input + and which component produced each output column. +- **Some representations use the target.** Placing bins or knots where the target actually + changes is a supervised decision, which is why leakage safety is a first-class concern. See + [Target awareness](target_awareness.md). + +## The shared vocabulary + +Every representation family in PreTab is described with the same small set of terms. Learning +them once pays off across the whole catalogue. + +`family` +: The kind of representation, for example spline, feature map, binning, periodic, or + categorical. + +`scope` +: Whether the representation transforms one column at a time (`univariate`) or models several + columns jointly (`multivariate`), such as the tensor-product and thin-plate splines. + +`supervision` +: Whether placement can (`optional`) or must (`required`) use the target, or never does + (`forbidden`). + +`output_dim` +: The width of the expansion, that is the number of basis functions, centers, or bins per + input feature. See [Resolution and placement](resolution_and_placement.md). + +`locations` +: The data-driven positions the basis is anchored at: knots for splines, centers for feature + maps, edges for bins. + +## The intermediate representation + +All of this is captured in one typed object, the `RepresentationSpec`, which is the common +intermediate form across every family. It records the family, input and output features, +scope, supervision, width, degree, and locations, and it round-trips to and from a plain +dict. Feature lineage then maps each individual output column back to its source. Together +they make a fitted PreTab pipeline fully inspectable and serializable. + +```python +spec = transformer.get_representation_spec() +spec.family, spec.output_features, spec.locations +``` + +## Where to go next + +- [Configuration](configuration.md) covers how you request representations. +- [Resolution and placement](resolution_and_placement.md) explains width and location. +- [Outputs and inspection](outputs_and_inspection.md) covers lineage and output formats. +- [Representations](../representations/overview.md) is the full catalogue of families. diff --git a/docs/core_concepts/missing_values.md b/docs/core_concepts/missing_values.md new file mode 100644 index 0000000..cf7ca07 --- /dev/null +++ b/docs/core_concepts/missing_values.md @@ -0,0 +1,102 @@ +# Missing values + +Missing data is handled explicitly in PreTab, never silently. You control it with a small set +of imputation parameters and, when you need finer behaviour, a single `missing_policy`. This +page explains both and the rule that ties them together: imputers are fit on the training +data only, and no rows are ever dropped. + +## Imputation parameters + +Three parameters on `Preprocessor` control the common case. + +`numerical_imputation` +: Strategy for numerical columns. Default `"median"`. Set to `None` to disable. + +`categorical_imputation` +: Strategy for categorical columns. Default `"most_frequent"`. Set to `None` to disable. + +`add_missing_indicator` +: When `True`, adds a binary indicator column marking where a value was missing. Default + `False`. + +```python +from pretab import Preprocessor + +pre = Preprocessor( + numerical_imputation="median", + categorical_imputation="most_frequent", + add_missing_indicator=True, +) +``` + +```{note} +Setting an imputation strategy to `None` disables imputation for that column kind. The +missing values then reach the transformer directly: scikit-learn scalers tolerate `NaN`, +while finite-only representations such as PLE, the splines, the feature maps, and binning +raise a typed error. That is intentional, an expansion of an undefined value has no meaning. +``` + +```{warning} +Requesting `add_missing_indicator=True` while both imputation strategies are disabled raises +`IncompatibleParamsError`. An indicator without a filled value leaves the basis with nothing +to expand. +``` + +## Fit on train, apply to test + +Imputers learn their fill values from the data passed to `fit`, and only that data. When you +later call `transform` on new rows, the stored fill values are reused. This keeps the split +clean and prevents test statistics from leaking into training. + +```{important} +PreTab never drops rows to deal with missing values. Every input row produces an output row. +This preserves alignment with your target and any parallel arrays. +``` + +## The `missing_policy` control + +For finer control, `missing_policy` selects one of five behaviours for the whole +preprocessor. + +| Policy | Behaviour | +| --- | --- | +| `"error"` | Reject any missing value at `fit` and `transform`. | +| `"propagate"` | Pass missing values through to the transformer unchanged. | +| `"impute"` | Fill using the imputation parameters above. | +| `"impute_with_indicator"` | Impute and add a missing indicator column. | +| `"separate_state"` | Impute the basis, and add a dedicated `__missing` column that does not activate the ordinary basis. | + +```python +pre = Preprocessor(missing_policy="separate_state") +``` + +### Separate state + +`"separate_state"` is the most expressive option. For each affected column it keeps the +imputed value flowing into the normal basis and, in parallel, emits a `__missing` indicator +that a model can weight on its own. This lets the model learn a distinct effect for +"missing" without corrupting the shape learned on observed values. + +```{tip} +Reach for `"separate_state"` when missingness is itself informative, for example a field that +users leave blank for a meaningful reason. Reach for plain `"impute"` when a value is missing +purely at random. +``` + +## Choosing an approach + +- **Missing at random, not informative**: `numerical_imputation` / `categorical_imputation` + (the default), no indicator. +- **Missingness may carry signal**: add `add_missing_indicator=True`, or use + `missing_policy="separate_state"`. +- **Missing values are a data error you want to catch**: `missing_policy="error"`. +- **You will handle missingness upstream**: `missing_policy="propagate"` with imputation + disabled. + +## Where to go next + +- [Configuration](configuration.md) for how these parameters combine with the rest. +- [Edge-case behaviour](../representations/choosing_a_method.md) for constant columns, + out-of-range inputs, and unseen categories. +- [Outputs and inspection](outputs_and_inspection.md) to see indicator columns in the + lineage. diff --git a/docs/core_concepts/outputs_and_inspection.md b/docs/core_concepts/outputs_and_inspection.md new file mode 100644 index 0000000..26950fb --- /dev/null +++ b/docs/core_concepts/outputs_and_inspection.md @@ -0,0 +1,135 @@ +# Outputs and inspection + +A representation is only useful if you can read what it produced. PreTab returns model-ready +output in the format you ask for, names every column, and can trace each output column back to +the exact input and component that created it. This page covers output shapes, formats, +feature names, lineage, and the output budget. + +## Output shapes + +`fit_transform` and `transform` return a dictionary that maps each feature to its transformed +block, with keys prefixed `num_` or `cat_`. Pass `return_array=True` to receive a single +stacked `numpy.ndarray` instead. + +```python +X_dict = pre.fit_transform(df, y) # {"num_age": ..., "cat_city": ...} +X_array = pre.transform(df, return_array=True) # one stacked ndarray +``` + +```{note} +The dict form is convenient for inspection and for feeding blocks to different model heads. +The array form is what a plain scikit-learn estimator expects. Choose per call. +``` + +## Output format and dtype + +Two parameters control the physical layout of the stacked output. + +`output_format` +: One of `"dense"`, `"sparse"`, or `"auto"`. `"auto"` picks sparse when it saves memory (for + example wide one-hot blocks) and dense otherwise. Default `"dense"`. + +`dtype` +: The floating-point precision of the output, for example `numpy.float32` to halve memory. + +```python +pre = Preprocessor(output_format="auto", dtype="float32") +``` + +After fitting, `output_report_` summarizes what was produced: the chosen format, dimensions, +density, and memory saved. + +```python +pre.fit(df, y) +pre.output_report_ +``` + +### DataFrame output + +PreTab honours the scikit-learn output API, so you can request pandas or polars frames. + +```python +pre.set_output(transform="pandas") # or "polars" +``` + +```{note} +Polars output is loaded lazily. If polars is not installed, requesting it raises a clear +`OptionalDependencyError` rather than failing deep in the call stack. +``` + +## Feature names + +Every representation names its output columns, and the names are stable and descriptive. +`get_feature_names_out()` returns them in output order. + +```python +pre.get_feature_names_out() +``` + +Use `get_feature_info(verbose=True)` for a human-readable table of the resolved per-feature +pipeline, output width, and category count. + +```text +feature kind pipeline dim cats +---------------------------------------------------------------- +age numerical imputer -> minmax -> bspline 13 - +income numerical imputer -> minmax -> ple 12 - +city categorical imputer -> onehot -> to_float 4 4 +``` + +## Feature lineage + +Lineage is the flagship inspection feature. `get_feature_lineage()` returns one record per +output column, mapping it back to its origin. + +```python +lineage = pre.get_feature_lineage() +lineage[0] +``` + +Each `FeatureLineage` record carries: + +- the **source input column(s)** the output came from, +- the **representation** family that produced it, +- the **component** it corresponds to (a basis function, knot, center, frequency, interval, + or category), +- whether the **target was used** to fit it, +- whether it is an **interaction** across several inputs. + +```{tip} +Lineage covers every output column and the names line up with `get_feature_names_out()`. This +makes a fitted `Preprocessor` fully auditable, which is invaluable when you interpret a linear +model fit on top of the expansion. +``` + +## Output budget + +Expansions can multiply columns quickly, especially wide splines or high-cardinality one-hot. +The output budget lets you cap the blast radius and estimate cost before committing. + +| Parameter | Effect | +| --- | --- | +| `max_output_features` | Cap on total output columns. | +| `max_features_per_input` | Cap on columns produced from any single input. | +| `max_dense_memory` | Cap on dense output memory. | +| `overflow_policy` | What to do on overflow, default `"error"`. | + +```python +pre = Preprocessor(max_output_features=500, overflow_policy="error") + +pre.estimate_output_shape(df) # predicted (n_rows, n_cols) without transforming +pre.estimate_memory(df) # predicted dense memory in bytes +``` + +```{warning} +With `overflow_policy="error"`, exceeding a budget raises `OutputBudgetError` at `fit`. Use +`estimate_output_shape` and `estimate_memory` first when you work with wide expansions or +large data. +``` + +## Where to go next + +- [Reproducibility](reproducibility.md) to serialize and fingerprint the fitted output. +- [Representations](../representations/overview.md) for what each family emits. +- [Comparing representations](../tutorials/comparing_representations.md) to measure width and + memory. diff --git a/docs/core_concepts/reproducibility.md b/docs/core_concepts/reproducibility.md new file mode 100644 index 0000000..6b0e08e --- /dev/null +++ b/docs/core_concepts/reproducibility.md @@ -0,0 +1,107 @@ +# Reproducibility + +A representation you cannot reproduce is a representation you cannot trust in production or in +a paper. PreTab treats reproducibility as a contract: deterministic fitting, a portable +declarative spec, a stable fingerprint, and an immutable lifecycle. This page covers all four. + +## Deterministic fitting + +`random_state` seeds every stochastic step: the target-aware selectors, k-means landmark +placement, and the randomized feature maps. Set it to an integer whenever you need repeatable +output, for example in tests or published experiments. + +```python +from pretab import Preprocessor + +pre = Preprocessor(numerical_method="rff", random_state=0) +``` + +```{note} +With a fixed `random_state`, repeated fits on the same data produce identical output. Methods +with no stochastic component ignore the seed. +``` + +## Portable serialization + +`to_spec` writes a fitted `Preprocessor` to a versioned, declarative schema, and `from_spec` +reconstructs it. The spec records the schema and library versions, the resolved parameters, +and the per-representation fitted state (parameters, knots, centers, columns, scaling). + +```python +spec = pre.to_spec() # returns a dict +pre.to_spec("representation.json") # or writes JSON to a path + +restored = Preprocessor.from_spec("representation.json") +``` + +```{important} +`from_spec` is a safe alternative to pickle. Reconstruction imports only from `pretab`, +`scikit-learn`, `numpy`, `scipy`, and builtins, and it never executes arbitrary estimator +code. A spec from an untrusted source cannot run code the way an untrusted pickle can. +``` + +A round-trip reproduces `transform` bit-for-bit, so a spec is a faithful, human-readable +record of a fitted representation. + +## Fingerprint + +`fingerprint_` is a SHA-256 hash over a canonical view of the fitted representation: the +resolved config, the schema, the fitted parameters, the output-column order, the seeds, the +library versions, and the output precision. + +```python +pre.fit(df, y) +pre.fingerprint_ +``` + +The fingerprint is deterministic within a process and across processes, and it survives a +`to_spec` / `from_spec` round-trip. Two preprocessors with the same fingerprint will produce +the same output; a change to config, data, seed, or version changes the fingerprint. + +```{tip} +Log the fingerprint alongside model metrics. If it changes unexpectedly between runs, your +representation changed, which is exactly the signal you want before you chase a metric +regression. +``` + +`reproducibility_report()` returns a structured summary for logging: the fingerprint, +versions, seed, output dtype and format, output widths, and the per-feature families. + +```python +pre.reproducibility_report() +``` + +## Immutable lifecycle + +A fitted representation moves through a small set of explicit states, which prevents +accidental mutation of something you intend to deploy. + +| State | Meaning | +| --- | --- | +| `UNFITTED` | Constructed, not yet fit. | +| `FITTED` | Fit and ready to transform. | +| `FROZEN` | Locked against parameter changes. | +| `STALE` | Marked as no longer current, with a reason. | + +```python +pre.freeze() # lock it +pre.is_frozen() # True +pre.set_params(...) # raises FrozenRepresentationError while frozen +``` + +Freezing is useful when a representation is validated and about to ship. To make a fresh, +unfrozen copy, use `clone_unfitted()`. To retrain, `refit(X, y)` returns a **new** fitted +object and leaves the original untouched, and `mark_stale(reason)` records why an existing one +should no longer be used. + +```{warning} +`set_params` on a frozen preprocessor raises `FrozenRepresentationError`. This is deliberate: +a deployed representation should not silently change shape. Use `refit` to produce a new +object instead of mutating the old one. +``` + +## Where to go next + +- [Outputs and inspection](outputs_and_inspection.md) for the output the fingerprint covers. +- [Target awareness](target_awareness.md) for how supervised state is recorded. +- [Production lifecycle](../developer_guide/release.md) for versioning and release discipline. diff --git a/docs/core_concepts/resolution_and_placement.md b/docs/core_concepts/resolution_and_placement.md new file mode 100644 index 0000000..ebeeb63 --- /dev/null +++ b/docs/core_concepts/resolution_and_placement.md @@ -0,0 +1,122 @@ +# Resolution and placement + +Two questions define any basis expansion: *how many* units to use, and *where* to put them. +PreTab keeps these separate on purpose. Resolution answers "how many" (the output width), and +placement answers "where" (the knots, centers, or bin edges). This page explains both and how +they combine. + +## Resolution: the `output_dim` width + +`output_dim` is the main capacity control. It sets the number of non-bias output columns per +input feature: bins for PLE and binning, centers for the feature maps, and basis functions +for the splines. A larger value captures finer structure at the cost of more columns and a +higher chance of overfitting. A smaller value is more compact and regularizes the +representation. + +```{note} +When you configure through the `Preprocessor`, its single `output_dim` (default `7`) is +forwarded to **every** numerical method. Per-transformer defaults only apply when you build a +transformer directly, for example `RBFExpansionTransformer()`. +``` + +### Spline width has a floor + +Each spline enforces a minimum width tied to its degree. Requesting fewer basis functions +than the floor raises an error at `fit` time rather than silently clamping, so keep +`output_dim` at or above the floor. + +| Family | Minimum width (floor) | +| --- | --- | +| B, M, I, P-spline, tensor-product | `degree + 1` (so `4` at the default cubic degree) | +| Cubic regression spline | `3` (three polynomial terms plus interior knots) | +| Natural cubic spline | `2` (places `output_dim + 1` knots) | +| Feature maps, PLE, binning | `1` | + +```{warning} +For the tensor-product spline the width grows as the **product** across marginal dimensions. +A 2-D input with `output_dim=4` already produces `4 x 4 = 16` columns, so raise it in small +steps and watch the total column count. +``` + +## Adaptive sizing + +Some features are simple and some are complex, and one fixed width rarely suits all of them. +PLE, the feature maps, and the freely-placed knot splines can size each feature from the data +instead. + +`adaptive` +: When `True`, the width for each feature is chosen from the data and kept inside + `[min_output_dim, max_output_dim]`. Fixed-width methods such as the plain scalers ignore + this flag. + +`min_output_dim`, `max_output_dim` +: The lower and upper bounds that apply only when `adaptive=True` (defaults `5` and `10`). + They are ignored otherwise. + +```python +from pretab import Preprocessor + +pre = Preprocessor( + numerical_method="rbf", + adaptive=True, + min_output_dim=4, + max_output_dim=12, +) +``` + +See the [adaptive resolution tutorial](../tutorials/adaptive_resolution.md) for a worked +example. + +## Placement: where the units go + +Placement decides the actual positions of the basis units. PreTab centralizes this in one +placement subsystem so no transformer re-implements it, and it is driven by two parameters. + +`target_aware` +: Whether placement uses the target `y`. + +`placement_strategy` +: How the positions are chosen. Valid values depend on `target_aware`. + +| `target_aware` | Allowed `placement_strategy` | Meaning | +| --- | --- | --- | +| `False` | `"uniform"` | Evenly spaced across the observed range. | +| `False` | `"quantile"` | Spaced by data density, more units where data is dense. | +| `True` | `"cart"` | Split points from a per-feature decision tree fit against `y`. | +| `True` | `"lightgbm"` | Split points aggregated from gradient-boosted trees (needs the `lightgbm` extra). | + +```{warning} +The unsupervised and target-aware rows are mutually exclusive. Combining them, for example +`target_aware=True` with `placement_strategy="quantile"`, raises an error. Leave +`placement_strategy` unset to get the sensible default for whichever mode you picked. +``` + +Target-aware placement is a supervised decision and carries leakage considerations. See +[Target awareness](target_awareness.md). + +## Resolution and placement are independent + +Keeping the two axes separate is what makes the system predictable. You choose a width +(resolution) and, separately, a rule for positions (placement). The same `"quantile"` +placement works at width `5` or width `20`; the same width works with uniform or +target-aware placement. Not every method honours every strategy: the penalized P-spline +assumes a regular geometry and is `"uniform"` only, PLE always places against the target, and +the thin-plate spline uses landmark points rather than ordinary knots. These per-method rules +are enforced from the capability registry, so an invalid request fails loudly. + +## Method-specific placement rules + +| Method | Placement behaviour | +| --- | --- | +| PLE | Target-aware always (`"cart"` or `"lightgbm"`). | +| P-spline | `"uniform"` only, unsupervised (the difference penalty assumes regular knots). | +| Feature maps, freely-placed knot splines | Any of the four strategies. | +| Thin-plate spline | Landmark points (k-means), not ordinary knots. | +| Fourier features | Frequencies derived from the data, not placement knots. | + +## Where to go next + +- [Target awareness](target_awareness.md) for supervised placement and leakage safety. +- [Representations](../representations/overview.md) for how each family uses its locations. +- [Comparing representations](../tutorials/comparing_representations.md) to see width and + strategy trade-offs measured. diff --git a/docs/core_concepts/target_awareness.md b/docs/core_concepts/target_awareness.md new file mode 100644 index 0000000..b3ae479 --- /dev/null +++ b/docs/core_concepts/target_awareness.md @@ -0,0 +1,112 @@ +# Target awareness + +Some representations place their bins, centers, or knots using the target `y`. Positioning +units where the target actually changes sharpens the representation, but it also reads labels +at `fit` time, which introduces a leakage risk if done carelessly. PreTab makes target usage +explicit and gives you leakage-safe tools. This page explains the contract. + +## Which methods use the target + +Every method declares how it uses `y` through three levels. + +`forbidden` +: The method never uses the target. The scalers, one-hot, ordinal encoding, the Fourier map, + and the P-spline are all unsupervised. + +`optional` +: The method uses the target only when `target_aware=True`. The feature maps (RBF, ReLU, + sigmoid, tanh) and the freely-placed knot splines (B, M, I, cubic, natural) are in this + group. + +`required` +: The method always places against the target. Piecewise-linear encoding (PLE) is the primary + example and needs `y` at every fit. + +```python +from pretab.transformers import PLETransformer + +t = PLETransformer() +t.requires_y # True: PLE always needs y +t.is_supervised # True +``` + +```{warning} +A `required` method fitted without `y`, or with `target_aware=False`, raises a typed error +rather than silently producing an unsupervised result. Always pass `y` to a pipeline that +contains PLE. +``` + +## The fitted-usage flag + +After fitting, a transformer reports whether it actually consumed the target through +`uses_target_`. This is the ground truth for an individual fit, and it flows into the +[`RepresentationSpec`](outputs_and_inspection.md) so a serialized representation records +whether it was supervised. + +```python +t = PLETransformer().fit(x, y) +t.uses_target_ # True +``` + +## Leakage safety + +Fitting a supervised transformer on your full dataset and then evaluating on part of it leaks +target information and inflates scores. PreTab warns when it detects this pattern. + +```{important} +A supervised transformer emits a `LeakageWarning` when it is fit with a target **outside** a +cross-validation or `Pipeline` context. Inside a scikit-learn `Pipeline`, `ColumnTransformer`, +`Preprocessor`, or a cross-fitting wrapper, the warning is suppressed because those contexts +already keep the fit confined to the training fold. +``` + +The safe patterns are: + +- Put the supervised transformer **inside a `Pipeline`**, so `cross_val_score` and + `GridSearchCV` fit it on the training fold only. +- Use the `Preprocessor`, which fits its imputers and supervised expansions on the training + data you pass to `fit`. +- Wrap it in a `CrossFittedTransformer` when you want out-of-fold training features. + +## Cross-fitted features + +`CrossFittedTransformer` removes leakage from the training features themselves. It produces +out-of-fold values for the training rows (each row is transformed by a model that did not see +it) while `transform` on new data uses a model fit on all the training data. + +```python +from pretab import CrossFittedTransformer +from pretab.transformers import PLETransformer + +cf = CrossFittedTransformer(PLETransformer(), n_folds=5) +X_train_features = cf.fit_transform(x_train, y_train) # out-of-fold, leakage-free +X_test_features = cf.transform(x_test) # uses the all-data model +``` + +The fitted spec records `cross_fitted=True` and the number of folds, so the choice is +visible and serializable. + +```{note} +Cross-fitting matters most for strongly supervised encodings such as PLE, where the target +directly determines the bins. For unsupervised methods it is unnecessary. +``` + +## Searching over representations + +`RepresentationSearchCV` cross-validates a downstream estimator over a set of candidate +numerical methods and refits the best one. It is a convenient way to let the data choose the +representation without leaking through the selection. + +```python +from pretab import RepresentationSearchCV +``` + +See the [target-aware classification tutorial](../tutorials/target_aware_classification.md) +for an end-to-end, leakage-safe evaluation. + +## Where to go next + +- [Resolution and placement](resolution_and_placement.md) for the placement strategies. +- [Reproducibility](reproducibility.md) for how supervised state is recorded and serialized. +- [Leakage-safe classification](../tutorials/target_aware_classification.md) for a worked + example. From 038e60b8d0d2921d5dbd57ef65fe01c63a03f055 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Mon, 27 Jul 2026 14:13:44 +0200 Subject: [PATCH 24/59] docs(representations): add method catalogue and comparison --- docs/representations/binning_and_ple.md | 90 +++++++++++++ docs/representations/categorical.md | 86 ++++++++++++ docs/representations/choosing_a_method.md | 116 ++++++++++++++++ docs/representations/comparison_table.md | 102 ++++++++++++++ docs/representations/feature_maps.md | 128 ++++++++++++++++++ docs/representations/overview.md | 91 +++++++++++++ docs/representations/references.md | 57 ++++++++ docs/representations/splines.md | 156 ++++++++++++++++++++++ 8 files changed, 826 insertions(+) create mode 100644 docs/representations/binning_and_ple.md create mode 100644 docs/representations/categorical.md create mode 100644 docs/representations/choosing_a_method.md create mode 100644 docs/representations/comparison_table.md create mode 100644 docs/representations/feature_maps.md create mode 100644 docs/representations/overview.md create mode 100644 docs/representations/references.md create mode 100644 docs/representations/splines.md diff --git a/docs/representations/binning_and_ple.md b/docs/representations/binning_and_ple.md new file mode 100644 index 0000000..48852b9 --- /dev/null +++ b/docs/representations/binning_and_ple.md @@ -0,0 +1,90 @@ +# Binning and PLE + +Discretization turns a continuous feature into regions. It captures sharp, threshold-like +effects that smooth bases blur, and it is the natural representation when a feature acts in +steps. PreTab offers unsupervised numeric binning and supervised piecewise-linear encoding +(PLE). + +## Numeric binning + +Numeric binning splits a feature into intervals and encodes which interval each value falls +into. You choose how the edges are placed and how the result is encoded. + +```python +from pretab.transformers import NumericBinningTransformer + +t = NumericBinningTransformer(output_dim=8, encode="onehot", placement_strategy="quantile") +``` + +The `encode` parameter selects the output form. + +`"ordinal"` +: A single integer column giving the bin index. + +`"onehot"` +: One indicator column per bin. + +`"soft"` +: A soft assignment that spreads each value across neighbouring bins, so the boundaries are not + hard. This keeps a little of the smoothness that hard binning discards. + +Edge placement follows `placement_strategy`: `"uniform"` for equal-width bins, `"quantile"` +for equal-frequency bins. See +[Resolution and placement](../core_concepts/resolution_and_placement.md). + +```{tip} +Quantile edges give every bin a similar number of samples, which is usually more stable than +equal-width bins when the feature is skewed. +``` + +## Piecewise-linear encoding + +PLE is the flagship supervised representation. It fits a decision tree of the feature against +the target, reads the split points as bin edges, and encodes each value as its **linear +position within its bin**. The result is a piecewise-linear function that bends exactly where +the target changes, following the tabular deep-learning work of Gorishniy and colleagues. + +```python +from pretab.transformers import PLETransformer + +t = PLETransformer(output_dim=12, task="regression") +X2 = t.fit_transform(x, y) # y is required +``` + +Constructor highlights: `output_dim`, `placement_strategy="cart"`, `task="regression"`, +`adaptive`, `random_state=51`, and the tree controls `max_depth`, `min_samples_split`, +`min_samples_leaf`. + +```{important} +PLE **requires** the target. It places its edges using `y`, so it must be fit with a target +and should be fit leakage-safely, ideally with cross-fitting. See +[Target awareness](../core_concepts/target_awareness.md). +``` + +### Why piecewise-linear rather than one-hot + +Plain binning throws away where a value sits inside its bin; two values in the same interval +become identical. PLE keeps the within-bin position as a linear ramp, so it retains fine +resolution while still capturing the sharp transitions the tree found. That combination is why +it works so well as a front-end for both linear models and neural networks. + +```{tip} +PLE is a strong default for numerical features, and it is the default `numerical_method` on +`Preprocessor`. Reach for it first when you have a supervised task and want the representation +to follow the target. +``` + +## Binning versus PLE + +| | Numeric binning | PLE | +| --- | --- | --- | +| Uses the target | No | Yes (required) | +| Within-bin resolution | Lost (hard) or blurred (soft) | Preserved (linear) | +| Edge placement | Uniform or quantile | Target-driven (tree splits) | +| Best for | Unsupervised, known step structure | Supervised sharp effects | + +## Where to go next + +- [Target awareness](../core_concepts/target_awareness.md) for fitting PLE safely. +- [Splines](splines.md) for smooth alternatives to binning. +- [References](references.md) for the PLE source. diff --git a/docs/representations/categorical.md b/docs/representations/categorical.md new file mode 100644 index 0000000..c1eaa88 --- /dev/null +++ b/docs/representations/categorical.md @@ -0,0 +1,86 @@ +# Categorical + +Categorical features range from a handful of labels to free text with thousands of distinct +values. PreTab covers the spectrum: compact integer encoding, explicit one-hot, and pretrained +language embeddings for high-cardinality text. All of them handle unseen categories without +raising. + +## Integer (ordinal) encoding + +The default categorical method maps each category to an integer. It is compact and works well +as an input to models that consume category indices, such as embedding layers. + +```python +from pretab.transformers import ContinuousOrdinalTransformer + +t = ContinuousOrdinalTransformer() +X2 = t.fit_transform(x) +``` + +Unseen categories at transform time map to a reserved slot rather than raising, so a model in +production never crashes on a new label. + +```{note} +Integer encoding imposes an order on the codes. Feed it to models that treat the code as an +index (trees, embedding layers), not to a plain linear model that would read the codes as +magnitudes. +``` + +## One-hot encoding + +One-hot encoding produces one indicator column per category, the right choice when the +downstream model should treat categories as unordered. + +```python +pre = Preprocessor(categorical_method="one-hot") +``` + +The alias `ohe` resolves to `one-hot`. There is also `onehot_from_ordinal`, which one-hot +encodes an already integer-coded column. + +```{warning} +One-hot width grows with cardinality. A column with thousands of categories produces thousands +of columns. Use the [output budget](../core_concepts/outputs_and_inspection.md) to cap it, or +prefer integer encoding or embeddings for high-cardinality columns. +``` + +## Language embeddings + +For high-cardinality text categories (product titles, free-text tags, descriptions), a +pretrained sentence embedding captures semantic similarity that integer or one-hot encoding +cannot. Similar labels land near each other in the embedding space. + +```python +from pretab.transformers import LanguageEmbeddingTransformer + +t = LanguageEmbeddingTransformer(model_name="paraphrase-MiniLM-L3-v2") +X2 = t.fit_transform(x) +``` + +Constructor highlights: `model_name="paraphrase-MiniLM-L3-v2"`, or pass a preloaded `model`. +The registry key is `pretrained`. + +```{important} +Language embeddings require the optional `embeddings` extra, which pulls in +`sentence-transformers`. Install it with `pip install "pretab[embeddings]"`. Without it, +requesting `pretrained` raises a clear `OptionalDependencyError`. +``` + +```{tip} +Embeddings shine when category labels carry meaning as text. If the labels are opaque codes +with no semantic content, integer encoding is simpler and just as effective. +``` + +## Choosing a categorical method + +| If the column is... | Reach for... | +| --- | --- | +| Low cardinality, unordered | One-hot | +| Fed to a tree or embedding layer | Integer | +| High-cardinality meaningful text | Language embedding | + +## Where to go next + +- [Missing values](../core_concepts/missing_values.md) for categorical imputation. +- [Configuration](../core_concepts/configuration.md) to set categorical methods per column. +- [Installation](../getting_started/installation.md) for the `embeddings` extra. diff --git a/docs/representations/choosing_a_method.md b/docs/representations/choosing_a_method.md new file mode 100644 index 0000000..7f8834d --- /dev/null +++ b/docs/representations/choosing_a_method.md @@ -0,0 +1,116 @@ +# Choosing a method + +This page gives practical guidance for picking a representation, and it is honest about where +representations do not help. If you read only one page in this section, read this one. + +## Start from the model + +The right representation depends on what sits downstream. + +Linear and additive models +: These gain the most from expansion. A linear model on top of a spline or PLE basis can fit + smooth nonlinearities while staying interpretable. This is the primary use case for PreTab. + +Gradient-boosted trees +: Trees already partition each feature, so raw or lightly-scaled inputs are usually enough. + Expansion rarely helps and often adds noise. See + [when it does not help](#when-basis-expansion-does-not-help). + +Neural networks +: PLE and learned embeddings are effective front-ends, echoing the tabular deep-learning + literature. Splines can help shallow networks. + +## Match the method to the signal + +| If the relationship is... | Reach for... | +| --- | --- | +| Smooth and curved | B-spline, natural cubic spline, P-spline | +| Monotone (must not reverse) | I-spline | +| Sharp, threshold-like | PLE, numeric binning, ReLU expansion | +| Local bumps around centers | RBF expansion | +| Periodic (known period) | Periodic encoding, Fourier features | +| A smooth surface over two inputs | Tensor-product or thin-plate spline | +| A general kernel over many inputs | Random Fourier features, Nyström | + +```{tip} +When unsure, start with the `"standard"` preset (min-max scaling, PLE for numericals, integer +categoricals) and compare against a spline. The +[comparing representations tutorial](../tutorials/comparing_representations.md) shows how to +measure the difference instead of guessing. +``` + +## Match the method to the target + +- If the relationship between a feature and the target is what you want to capture, a + **target-aware** method (PLE, or a spline with `target_aware=True`) places its units where + the target changes. Always fit these leakage-safely, see + [Target awareness](../core_concepts/target_awareness.md). +- If you only want a flexible unsupervised basis, an **unsupervised** method (P-spline, + Fourier, quantile-placed spline) avoids target usage entirely. + +## Control the width + +More columns means more flexibility and more overfitting risk. Start narrow and widen only if +validation improves. Turn on `adaptive=True` to let the data choose a width between +`min_output_dim` and `max_output_dim`. See +[Resolution and placement](../core_concepts/resolution_and_placement.md). + +## When basis expansion does not help + +Expansion is a tool, not a default. There are clear cases where it adds cost without value, +and pretending otherwise would be dishonest. + +Tree ensembles already handle nonlinearity +: Gradient-boosted trees and random forests split each feature into regions on their own. + Feeding them a spline or binning basis usually leaves accuracy unchanged while multiplying + the column count. Prefer raw or scaled inputs for these models. + +Truly linear relationships +: If a feature enters the target linearly, scaling is enough. A spline will fit the same line + with extra parameters and a little more variance. + +Very small samples +: A wide expansion on a few hundred rows overfits. Keep `output_dim` small, or skip expansion + and rely on a scaled input. + +Extrapolation beyond the fitted range +: Bases are fitted on the training range. Splines, PLE, and feature maps are undefined or flat + outside it, so they do not extrapolate. If your test data lies well beyond training, no + expansion recovers the missing signal. See the edge-case behaviour below. + +Pure noise features +: Expanding a feature that carries no signal only gives the model more ways to fit noise. Drop + the feature instead. + +```{warning} +Basis expansion changes the geometry of your features, not the information in them. If a +feature does not carry the signal, no representation will create it. Measure, do not assume. +``` + +## Edge-case behaviour + +PreTab is explicit about degenerate inputs rather than failing silently. + +- **Constant column**: methods that need spread degrade gracefully to a trivial, valid output + rather than raising. +- **Out-of-range input at transform**: values beyond the fitted range are clamped or produce a + flat response, consistent with the fitted basis, never an extrapolated fantasy. +- **Unseen category**: unknown categories map to a reserved slot rather than an error. +- **NaN into a finite-only method**: raises a typed error unless imputation is configured. See + [Missing values](../core_concepts/missing_values.md). + +## Non-goals + +To set expectations, PreTab deliberately does not do the following. + +- It is **not** a feature-selection library. It represents the features you give it; it does + not decide which features to keep. +- It is **not** a modelling library. It produces representations; you bring the estimator. +- It does **not** invent signal. It reshapes existing information into a more learnable form. + +## Where to go next + +- [Comparison table](comparison_table.md) to filter by capability. +- [Splines](splines.md), [Feature maps](feature_maps.md), + [Binning and PLE](binning_and_ple.md), [Categorical](categorical.md) for the details. +- [Comparing representations](../tutorials/comparing_representations.md) to measure the choice. diff --git a/docs/representations/comparison_table.md b/docs/representations/comparison_table.md new file mode 100644 index 0000000..4e93bbf --- /dev/null +++ b/docs/representations/comparison_table.md @@ -0,0 +1,102 @@ +# Comparison table + +Use this page to filter representations by capability. It is a static reference; for a live, +queryable view use `list_representations(...)` against the registry. The registry is the single +source of truth, and these tables mirror it. + +## Reading the columns + +`Key` +: The string you pass to `numerical_method`, `categorical_method`, or per-feature config. + +`Scope` +: `univariate` (one column) or `multivariate` (several columns jointly). + +`Target` +: `forbidden`, `optional` (used when `target_aware=True`), or `required`. + +`Adaptive` +: Supports data-driven width selection between `min_output_dim` and `max_output_dim`. + +`Penalty` +: Exposes `get_penalty_matrix()` for smoothing penalties. + +`Selectable` +: Can be chosen through `Preprocessor` as a per-column method. + +## Numerical: scalers and simple transforms + +| Method | Key | Scope | Target | Selectable | +| --- | --- | --- | --- | --- | +| Standardization | `standardization` | univariate | forbidden | yes | +| Min-max scaling | `minmax` | univariate | forbidden | yes | +| Robust scaling | `robust` | univariate | forbidden | yes | +| Quantile transform | `quantile` | univariate | forbidden | yes | +| Polynomial features | `polynomial` | univariate | forbidden | yes | +| Box-Cox | `box-cox` | univariate | forbidden | yes | +| Yeo-Johnson | `yeo-johnson` | univariate | forbidden | yes | +| Passthrough | `none` | univariate | forbidden | yes | + +## Numerical: splines + +| Method | Key | Scope | Target | Adaptive | Penalty | Selectable | +| --- | --- | --- | --- | --- | --- | --- | +| B-spline | `bspline` | univariate | optional | yes | no | yes | +| M-spline | `mspline` | univariate | optional | yes | no | yes | +| I-spline | `ispline` | univariate | optional | yes | no | yes | +| Cubic regression spline | `cubicspline` | univariate | optional | yes | yes | yes | +| Natural cubic spline | `naturalspline` | univariate | optional | yes | yes | yes | +| Penalized spline (P-spline) | `pspline` | univariate | forbidden | yes | yes | yes | +| Tensor-product spline | `tensorspline` | multivariate | forbidden | yes | yes | no | +| Thin-plate spline | `tprs` | multivariate | forbidden | no | yes | no | + +```{note} +The multivariate splines (`tensorspline`, `tprs`) model several inputs jointly and are used +standalone, not selected per column through `Preprocessor`. The alias `thinplate` resolves to +`tprs`. +``` + +## Numerical: feature maps + +| Method | Key | Scope | Target | Adaptive | Selectable | +| --- | --- | --- | --- | --- | --- | +| RBF expansion | `rbf` | univariate | optional | yes | yes | +| ReLU expansion | `relu` | univariate | optional | yes | yes | +| Sigmoid expansion | `sigmoid` | univariate | optional | yes | yes | +| Tanh expansion | `tanh` | univariate | optional | yes | yes | +| Fourier features | `fourier` | univariate | forbidden | no | yes | +| Random Fourier features | `rff` | multivariate | forbidden | no | no | +| Nyström kernel map | `nystroem` | multivariate | forbidden | no | no | + +## Numerical: discretization + +| Method | Key | Scope | Target | Adaptive | Selectable | +| --- | --- | --- | --- | --- | --- | +| Numeric binning | `custombin` | univariate | forbidden | no | yes | +| Piecewise-linear encoding (PLE) | `ple` | univariate | required | yes | yes | + +```{important} +PLE is the only numerical method that **requires** the target. It always places its bins +against `y`, so it must be fit with a target and is best used with cross-fitting. See +[Target awareness](../core_concepts/target_awareness.md). +``` + +## Categorical + +| Method | Key | Scope | Target | Selectable | +| --- | --- | --- | --- | --- | +| Ordinal (integer) encoding | `int` | univariate | forbidden | yes | +| One-hot encoding | `one-hot` | univariate | forbidden | yes | +| One-hot from ordinal | `onehot_from_ordinal` | univariate | forbidden | yes | +| Pretrained language embedding | `pretrained` | univariate | forbidden | yes | +| Passthrough | `none` | univariate | forbidden | yes | + +```{note} +`pretrained` requires the optional `embeddings` extra. The alias `ohe` resolves to `one-hot`. +``` + +## Where to go next + +- [Choosing a method](choosing_a_method.md) for guidance on which of these to reach for. +- [Splines](splines.md), [Feature maps](feature_maps.md), + [Binning and PLE](binning_and_ple.md), [Categorical](categorical.md) for the details. diff --git a/docs/representations/feature_maps.md b/docs/representations/feature_maps.md new file mode 100644 index 0000000..8179ca1 --- /dev/null +++ b/docs/representations/feature_maps.md @@ -0,0 +1,128 @@ +# Feature maps + +Feature maps are basis functions borrowed from machine learning rather than classical +statistics. They spread a feature across a set of activation functions (radial bumps, ReLU +ramps, sigmoids) or project it onto a Fourier basis, and they include the two standard +kernel approximations. Together they cover local, threshold, and periodic structure. + +## Radial basis functions + +The RBF expansion places centers along the feature range and measures Gaussian similarity to +each, + +$$ +\phi_k(x) = \exp\!\big(-\gamma\,(x - c_k)^2\big). +$$ + +Each output is a smooth bump around a center, so a linear model on top can build up a curve +from local pieces. + +```python +from pretab.transformers import RBFExpansionTransformer + +t = RBFExpansionTransformer(output_dim=10, gamma=1.0) +``` + +Constructor highlights: `output_dim`, `gamma=1.0` (bump width; larger is narrower), +`target_aware=False`, `placement_strategy`, `adaptive`, `random_state`. + +```{tip} +`gamma` trades locality for coverage. Large `gamma` gives narrow, sharply local bumps; small +`gamma` gives broad, overlapping ones. Tune it alongside `output_dim`. +``` + +## ReLU, sigmoid, and tanh expansions + +These place a set of thresholds along the range and apply an activation at each, mirroring a +single hidden layer. + +ReLU +: Piecewise-linear ramps. Excellent for sharp, threshold-like effects. + +Sigmoid and Tanh +: Smooth saturating steps. `scale` controls the steepness of the transition. + +```python +from pretab.transformers import ReLUExpansionTransformer, TanhExpansionTransformer + +relu = ReLUExpansionTransformer(output_dim=10) +tanh = TanhExpansionTransformer(output_dim=10, scale=1.0) +``` + +```{note} +ReLU expansions are a natural fit when the effect of a feature turns on past a threshold, for +example a fee that applies only above a limit. +``` + +## Fourier features + +The Fourier map represents a feature with sines and cosines at a set of frequencies, ideal for +signals with cyclical structure. + +```python +from pretab.transformers import FourierFeatureTransformer + +t = FourierFeatureTransformer(n_frequencies=5, frequency_strategy="harmonic") +``` + +Constructor highlights: `n_frequencies=5`, `frequency_strategy="harmonic"`, +`include_original=False`, `random_state`. + +### Periodic encoding + +When you know the period, the periodic encoder is the direct choice. It maps a value onto its +position in a cycle of known length, so December and January sit next to each other. + +```python +from pretab.transformers import PeriodicEncodingTransformer + +t = PeriodicEncodingTransformer(period=12, harmonics=2) # e.g. month of year +``` + +```{tip} +Use `PeriodicEncodingTransformer` when the period is known (hour of day, month of year). Use +`FourierFeatureTransformer` when you want the model to work across a set of frequencies. +``` + +## Kernel approximations + +Two multivariate maps approximate a kernel machine without forming the full kernel matrix. +They are standalone transformers, not per-column methods. + +### Random Fourier features + +Approximates a shift-invariant kernel (by default the RBF kernel) with random projections, +following Rahimi and Recht. This makes kernel-style models scale to large datasets. + +```python +from pretab.transformers import RandomFourierFeaturesTransformer + +t = RandomFourierFeaturesTransformer(n_components=100, gamma=1.0) +X2 = t.fit_transform(X) +``` + +### Nyström + +Approximates a kernel by sampling landmark points and projecting onto them, following Williams +and Seeger. It supports several kernels through `kernel`. + +```python +from pretab.transformers import NystroemFeaturesTransformer + +t = NystroemFeaturesTransformer(n_components=100, kernel="rbf") +X2 = t.fit_transform(X) +``` + +Constructor highlights: `n_components=100`, `kernel="rbf"`, `gamma=None`, `degree=3`, +`coef0=1`, `random_state`. + +```{warning} +Random Fourier features and Nyström are multivariate and operate on the whole input matrix. +They are not available as a per-column `numerical_method`; fit them standalone. +``` + +## Where to go next + +- [Splines](splines.md) for smooth statistical bases. +- [Binning and PLE](binning_and_ple.md) for discretization. +- [References](references.md) for the kernel-approximation literature. diff --git a/docs/representations/overview.md b/docs/representations/overview.md new file mode 100644 index 0000000..aed5c1e --- /dev/null +++ b/docs/representations/overview.md @@ -0,0 +1,91 @@ +# Representations overview + +This section is the catalogue of every representation PreTab ships. Each family turns raw +columns into an expressive basis, and they all share the same vocabulary and the same +scikit-learn API. Start here to see the landscape, then dive into the family that fits your +data. + +## The families + +::::{grid} 1 1 2 2 +:gutter: 3 + +:::{grid-item-card} Splines +:link: splines +:link-type: doc +Smooth, locally-supported bases: B, M, I, cubic regression, natural cubic, penalized +(P-spline), and the multivariate tensor-product and thin-plate splines. +::: + +:::{grid-item-card} Feature maps +:link: feature_maps +:link-type: doc +Basis functions from machine learning: radial (RBF), ReLU, sigmoid, tanh, deterministic +Fourier, and the kernel approximations (random Fourier features, Nyström). +::: + +:::{grid-item-card} Binning and PLE +:link: binning_and_ple +:link-type: doc +Discretization: numeric binning with several encodings, and supervised piecewise-linear +encoding (PLE). +::: + +:::{grid-item-card} Categorical +:link: categorical +:link-type: doc +Ordinal and one-hot encoding, plus pretrained language embeddings for high-cardinality text. +::: + +:::: + +## Shared terminology + +Every family is described with the same terms, introduced in +[Preprocessing and representation](../core_concepts/feature_representation.md). + +`scope` +: `univariate` methods transform one column at a time. `multivariate` methods (tensor-product + spline, thin-plate spline, random Fourier features, Nyström) model several columns jointly + and are used standalone, not per column through `Preprocessor`. + +`supervision` +: `forbidden`, `optional`, or `required` target usage. See + [Target awareness](../core_concepts/target_awareness.md). + +`output_dim` +: The width of the expansion. See + [Resolution and placement](../core_concepts/resolution_and_placement.md). + +`placement` +: Where the knots, centers, or edges go, chosen by `target_aware` and `placement_strategy`. + +## How to select a method + +There are two ways to pick. + +- **By intent**: read [Choosing a method](choosing_a_method.md) for practical guidance, + including where basis expansion does not help. +- **By capability**: read the [comparison table](comparison_table.md) to filter families by + feature kind, scope, supervision, and adaptivity. + +You can also query the registry in code: + +```python +from pretab import list_representations + +list_representations(feature_kind="numerical", supervised=True) +``` + +## A note on scientific grounding + +Every family rests on established theory, from B-splines and P-splines to thin-plate +regression splines and random Fourier features. The [references](references.md) page collects +the primary sources for each, so the representations are traceable to their literature. + +## Where to go next + +- [Splines](splines.md), [Feature maps](feature_maps.md), [Binning and PLE](binning_and_ple.md), + [Categorical](categorical.md) for the families. +- [Comparison table](comparison_table.md) to filter by capability. +- [Choosing a method](choosing_a_method.md) for guidance and failure modes. diff --git a/docs/representations/references.md b/docs/representations/references.md new file mode 100644 index 0000000..7f92170 --- /dev/null +++ b/docs/representations/references.md @@ -0,0 +1,57 @@ +# References + +The representations in PreTab rest on established literature. This page collects the primary +sources for each family, so every method is traceable to its origin. Citations are grouped by +representation. + +## Splines and penalized splines + +Eilers, P. H. C., and Marx, B. D. (1996). Flexible smoothing with B-splines and penalties. +*Statistical Science*, 11(2), 89-121. + +Eilers, P. H. C., and Marx, B. D. (2003). Multivariate calibration with temperature +interaction using two-dimensional penalized signal regression. *Chemometrics and Intelligent +Laboratory Systems*, 66(2), 159-174. + +These two papers introduce the P-spline (B-spline basis with a difference penalty) and its +tensor-product extension, which underpin `PSplineTransformer` and +`TensorProductSplineTransformer`. + +## Thin-plate and generalized additive models + +Wahba, G. (1990). *Spline Models for Observational Data*. Society for Industrial and Applied +Mathematics. + +Wood, S. N. (2003). Thin plate regression splines. *Journal of the Royal Statistical Society: +Series B*, 65(1), 95-114. + +Wood, S. N. (2017). *Generalized Additive Models: An Introduction with R* (2nd ed.). Chapman +and Hall/CRC. + +Wahba's monograph is the foundation for thin-plate splines; Wood's work gives the low-rank +thin-plate regression spline and the GAM framing that `ThinPlateSplineTransformer` follows. + +## Kernel approximations + +Williams, C. K. I., and Seeger, M. (2001). Using the Nyström method to speed up kernel +machines. *Advances in Neural Information Processing Systems*, 13. + +Rahimi, A., and Recht, B. (2007). Random features for large-scale kernel machines. *Advances +in Neural Information Processing Systems*, 20. + +These introduce the Nyström method and random Fourier features, implemented as +`NystroemFeaturesTransformer` and `RandomFourierFeaturesTransformer`. + +## Piecewise-linear encoding + +Gorishniy, Y., Rubachev, I., and Babenko, A. (2022). On embeddings for numerical features in +tabular deep learning. *Advances in Neural Information Processing Systems*, 35. + +This paper motivates piecewise-linear encoding of numerical features for tabular models, the +basis for `PLETransformer`. + +## Where to go next + +- [Representations overview](overview.md) to return to the catalogue. +- [Splines](splines.md), [Feature maps](feature_maps.md), + [Binning and PLE](binning_and_ple.md) for the methods these sources describe. diff --git a/docs/representations/splines.md b/docs/representations/splines.md new file mode 100644 index 0000000..28b315b --- /dev/null +++ b/docs/representations/splines.md @@ -0,0 +1,156 @@ +# Splines + +Splines are piecewise-polynomial bases with local support. They turn a single numerical column +into a set of smooth, overlapping basis functions, so a linear model on top can bend to follow +the data while staying stable. PreTab ships the full family, from the workhorse B-spline to the +multivariate thin-plate spline. + +## The idea + +A spline places a set of **knots** along the range of a feature and builds basis functions +between them. The transformed feature is the vector of basis values, + +$$ +x \mapsto \big(B_1(x),\ B_2(x),\ \dots,\ B_K(x)\big), +$$ + +where each $B_k$ is nonzero only near a few knots. Local support is what keeps splines stable: +a point in one region does not disturb the fit in another. Width is set by `output_dim` and +knot positions by `placement_strategy` (see +[Resolution and placement](../core_concepts/resolution_and_placement.md)). + +## B-spline + +The B-spline is the default general-purpose smooth basis. Its functions are non-negative, +sum to one, and each spans only `degree + 1` knot intervals. + +```python +from pretab.transformers import BSplineTransformer + +t = BSplineTransformer(output_dim=13, degree=3, placement_strategy="quantile") +``` + +Constructor highlights: `output_dim`, `degree=3`, `include_bias=True`, `knot_locations=None` +(pass explicit knots to override placement), `target_aware=False`, `placement_strategy="quantile"`, +`adaptive`, `random_state`. + +```{tip} +Cubic (`degree=3`) B-splines with quantile knots are a strong default for smooth regression. +Increase `output_dim` for more wiggle, decrease it to regularize. +``` + +## M-spline and I-spline + +These two share the B-spline machinery but target special shapes. + +M-spline +: A non-negative spline basis (`include_bias=False`). Useful when the components themselves + should be non-negative, for example as a density-like basis. + +I-spline +: The integral of an M-spline, giving a **monotone** basis. A model with non-negative + coefficients on an I-spline basis is guaranteed monotone in the input, which is valuable when + domain knowledge says a relationship cannot reverse. + +```python +from pretab.transformers import ISplineTransformer + +t = ISplineTransformer(output_dim=10, degree=3) # monotone basis +``` + +```{note} +I-splines only guarantee monotonicity when the downstream coefficients are constrained to be +non-negative. Pair them with a non-negative linear model. +``` + +## Cubic regression and natural cubic splines + +These are penalized-ready cubic bases with a clear knot interpretation, and both expose a +smoothing penalty through `get_penalty_matrix()`. + +Cubic regression spline +: A cubic basis parameterized at the knots (`cubicspline`), convenient for GAM-style additive + models. + +Natural cubic spline +: A cubic spline constrained to be **linear beyond the boundary knots** (`naturalspline`). + The linear tails reduce the wild behaviour ordinary cubics show near the edges of the data. + +```python +from pretab.transformers import NaturalCubicSplineTransformer + +t = NaturalCubicSplineTransformer(output_dim=12) +penalty = t.get_penalty_matrix() # for smoothing penalties +``` + +```{tip} +Prefer the natural cubic spline when your feature has sparse data near its extremes; the linear +tails behave far better than an unconstrained cubic there. +``` + +## Penalized spline (P-spline) + +The P-spline combines a B-spline basis with a difference penalty on adjacent coefficients, +following Eilers and Marx. Instead of controlling smoothness only through the number of knots, +it uses many knots and a penalty of order `diff_order` to keep the fit smooth. + +```python +from pretab.transformers import PSplineTransformer + +t = PSplineTransformer(output_dim=20, degree=3, diff_order=2) +penalty = t.get_penalty_matrix() +``` + +Constructor highlights: `output_dim`, `degree=3`, `diff_order=2`, `include_bias=False`, +`placement_strategy="uniform"`, `adaptive`. The P-spline is unsupervised; it does not read the +target. + +```{note} +The P-spline decouples smoothness from knot count. Use a generous `output_dim` and let the +penalty do the regularizing. Its penalty matrix plugs directly into penalized linear models. +``` + +## Multivariate splines + +Two families model several inputs jointly. They are used standalone, not selected per column +through `Preprocessor`. + +### Tensor-product spline + +Builds a joint basis over multiple inputs as the tensor product of per-axis bases, capturing +interactions on a smooth grid. It exposes an anisotropic penalty. + +```python +from pretab.transformers import TensorProductSplineTransformer + +t = TensorProductSplineTransformer(output_dim=8, degree=3, diff_order=2) +X2 = t.fit_transform(X[["lat", "lon"]]) +``` + +### Thin-plate spline + +A thin-plate regression spline, the smooth-surface method from generalized additive models. It +places landmarks (by default with k-means) and forms a low-rank basis. + +```python +from pretab.transformers import ThinPlateSplineTransformer + +t = ThinPlateSplineTransformer(n_components=10, landmark_strategy="kmeans") +X2 = t.fit_transform(X[["lat", "lon"]]) +``` + +Constructor highlights: `n_components=10`, `landmark_strategy="kmeans"`, `rank_strategy="eigen"`, +`include_bias=False`, `random_state`. + +```{warning} +The tensor-product and thin-plate splines are multivariate. They are standalone transformers +and are not available as a per-column `numerical_method`. Fit them directly on the columns you +want to model jointly. +``` + +## Where to go next + +- [Feature maps](feature_maps.md) for non-spline bases. +- [Multivariate features tutorial](../tutorials/multivariate_features.md) for a worked joint + model. +- [References](references.md) for the primary spline literature. From 110904fd65f4c8f65070ddbc0c4af30e8e248ee4 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Mon, 27 Jul 2026 14:13:57 +0200 Subject: [PATCH 25/59] docs(tutorials): add task-oriented tutorials --- docs/tutorials/adaptive_resolution.md | 93 ++++++++++ docs/tutorials/comparing_representations.md | 104 +++++++++++ docs/tutorials/custom_representation.md | 150 ++++++++++++++++ docs/tutorials/multivariate_features.md | 117 ++++++++++++ docs/tutorials/nonlinear_regression.md | 169 ++++++++++++++++++ docs/tutorials/sklearn_pipeline.md | 8 +- docs/tutorials/target_aware_classification.md | 156 ++++++++++++++++ 7 files changed, 793 insertions(+), 4 deletions(-) create mode 100644 docs/tutorials/adaptive_resolution.md create mode 100644 docs/tutorials/comparing_representations.md create mode 100644 docs/tutorials/custom_representation.md create mode 100644 docs/tutorials/multivariate_features.md create mode 100644 docs/tutorials/nonlinear_regression.md create mode 100644 docs/tutorials/target_aware_classification.md diff --git a/docs/tutorials/adaptive_resolution.md b/docs/tutorials/adaptive_resolution.md new file mode 100644 index 0000000..98434ca --- /dev/null +++ b/docs/tutorials/adaptive_resolution.md @@ -0,0 +1,93 @@ +# Adaptive resolution + +Picking the width of an expansion by hand is guesswork. Adaptive resolution lets the data +choose it for you, within bounds you set. This tutorial shows how to turn it on and how to read +the width that was selected. + +## The idea + +Every adaptive-capable method accepts three parameters that turn a fixed width into a searched +one. + +`adaptive=True` +: Enables data-driven width selection. + +`min_output_dim` and `max_output_dim` +: The lower and upper bounds of the search. The method picks a width in this range. + +When adaptive is on, `output_dim` becomes a hint rather than a fixed value; the fitted width is +chosen from the data and stored on the transformer. See +[Resolution and placement](../core_concepts/resolution_and_placement.md) for the mechanics. + +## A worked example + +We fit a spline with adaptive width on two signals of different complexity and inspect what each +one chose. + +```python +import numpy as np +import pandas as pd +from pretab.transformers import BSplineTransformer + +rng = np.random.default_rng(0) +n = 3000 +x = rng.uniform(0, 10, n) + +simple = 0.5 * x + rng.normal(0, 0.3, n) # nearly linear +wiggly = np.sin(x * 2) * 3 + rng.normal(0, 0.3, n) # high-frequency + +for name, y in [("simple", simple), ("wiggly", wiggly)]: + t = BSplineTransformer(adaptive=True, min_output_dim=5, max_output_dim=20) + t.fit(x.reshape(-1, 1), y) + print(f"{name:8s} -> selected width {t.total_output_dim_}") +``` + +```text +simple -> selected width 5 +wiggly -> selected width 17 +``` + +The nearly-linear signal needs few basis functions, so adaptive resolution keeps the width at +the floor. The high-frequency signal needs many, so it climbs toward the ceiling. You get an +appropriately-sized representation for each without tuning by hand. + +```{tip} +Set `min_output_dim` and `max_output_dim` to a range you consider reasonable, then let the data +place the width inside it. This is more robust than committing to a single `output_dim` across +features of different complexity. +``` + +## Adaptive across a whole preprocessor + +The same switch works at the `Preprocessor` level, so every eligible column adapts +independently. + +```python +from pretab import Preprocessor + +pre = Preprocessor( + numerical_method="bspline", + adaptive=True, + min_output_dim=5, + max_output_dim=15, +) +pre.fit(df, y) +pre.get_feature_info() +``` + +Each numerical column receives a width suited to its own complexity, visible in the resolved +feature info. + +```{note} +Adaptive resolution is available for the splines, PLE, and the RBF, ReLU, sigmoid, and tanh +feature maps. Methods with a fixed structure (Fourier, binning, the kernel approximations) +ignore the adaptive flag. The [comparison table](../representations/comparison_table.md) marks +which methods adapt. +``` + +## Where to go next + +- [Resolution and placement](../core_concepts/resolution_and_placement.md) for how width and + placement interact. +- [Comparing representations](comparing_representations.md) to measure adaptive against fixed. +- [Choosing a method](../representations/choosing_a_method.md) for width guidance. diff --git a/docs/tutorials/comparing_representations.md b/docs/tutorials/comparing_representations.md new file mode 100644 index 0000000..33fb192 --- /dev/null +++ b/docs/tutorials/comparing_representations.md @@ -0,0 +1,104 @@ +# Comparing representations + +Choosing a representation should be an experiment, not a guess. This tutorial evaluates several +numerical methods on the same task with the same model, so the only thing that varies is the +basis. The pattern generalizes to any dataset you have. + +## The setup + +We reuse a simple nonlinear regression target and hold the model fixed at a `Ridge` regressor. +Each candidate method is fit leakage-safely inside cross-validation. + +```python +import numpy as np +import pandas as pd +from sklearn.pipeline import Pipeline +from sklearn.compose import ColumnTransformer +from sklearn.linear_model import Ridge +from sklearn.model_selection import cross_val_score + +rng = np.random.default_rng(0) +n = 3000 +x = rng.uniform(0, 10, n) +y = np.sin(x) * 3 + 0.3 * x + rng.normal(0, 0.4, n) +df = pd.DataFrame({"x": x}) +``` + +## Sweep the candidates + +We compare a scaled baseline against a spline, a feature map, and PLE. Each transformer goes +inside a `Pipeline` so cross-validation fits it per fold. + +```python +from sklearn.preprocessing import MinMaxScaler +from pretab.transformers import ( + BSplineTransformer, + RBFExpansionTransformer, + PLETransformer, +) + +candidates = { + "minmax (baseline)": MinMaxScaler(), + "bspline": BSplineTransformer(output_dim=12), + "rbf": RBFExpansionTransformer(output_dim=12), + "ple": PLETransformer(output_dim=12, task="regression"), +} + +results = {} +for name, transformer in candidates.items(): + features = ColumnTransformer([("x", transformer, ["x"])]) + model = Pipeline([("features", features), ("ridge", Ridge(alpha=1.0))]) + scores = cross_val_score(model, df, y, cv=5, scoring="r2") + results[name] = (scores.mean(), scores.std()) + +for name, (mean, std) in results.items(): + print(f"{name:20s} R2 = {mean:.3f} +/- {std:.3f}") +``` + +```text +minmax (baseline) R2 = 0.081 +/- 0.010 +bspline R2 = 0.972 +/- 0.004 +rbf R2 = 0.964 +/- 0.006 +ple R2 = 0.958 +/- 0.005 +``` + +The scaled baseline fits a straight line and cannot follow the sine. Every expansion captures +it, with the spline slightly ahead on this smooth signal. + +```{tip} +Fix everything except the representation. The same model, the same folds, the same metric. +That isolates the effect of the basis so the comparison is fair. +``` + +## Weigh width against accuracy + +More columns can buy accuracy, but they also cost memory and overfitting headroom. Estimate the +output width before you commit, using the `Preprocessor` budget tools. + +```python +from pretab import Preprocessor + +for method in ["bspline", "rbf", "ple"]: + pre = Preprocessor(numerical_method=method, output_dim=12).fit(df, y) + shape = pre.estimate_output_shape(df) + print(f"{method:8s} -> {shape[1]} columns") +``` + +```{note} +A method that wins by a hair but doubles the column count may not be worth it. Read the width +from `estimate_output_shape` and factor it into the decision. See +[Outputs and inspection](../core_concepts/outputs_and_inspection.md). +``` + +## When nothing beats the baseline + +If every expansion ties the scaled baseline, the relationship is probably already linear, or +the feature carries little signal. That is a real and useful result. Do not add columns that do +not earn their place, see +[when basis expansion does not help](../representations/choosing_a_method.md#when-basis-expansion-does-not-help). + +## Where to go next + +- [Adaptive resolution](adaptive_resolution.md) to let the data pick the width. +- [Choosing a method](../representations/choosing_a_method.md) for guidance behind the numbers. +- [Comparison table](../representations/comparison_table.md) to filter candidates by capability. diff --git a/docs/tutorials/custom_representation.md b/docs/tutorials/custom_representation.md new file mode 100644 index 0000000..92de2a8 --- /dev/null +++ b/docs/tutorials/custom_representation.md @@ -0,0 +1,150 @@ +# Writing a custom representation + +PreTab is registry-driven, and the registry is open. You can add your own representation, have +it validated against the same contract as the built-ins, and select it by name through +`Preprocessor`. This tutorial walks the full extension workflow using a Chebyshev polynomial +basis as the running example. + +```{note} +A complete, installable version of this example lives in the repository under +`examples/pretab-chebyshev/`. Use it as a template for a standalone extension package. +``` + +## Subclass `BaseRepresentation` + +`BaseRepresentation` gives you the shared scikit-learn contract: NaN-aware validation, estimator +tags, `get_feature_names_out`, and a typed `RepresentationSpec`. You implement `fit`, +`transform`, and one sizing hook, and declare a small amount of metadata. + +```python +import numpy as np +from sklearn.utils.validation import check_is_fitted +from pretab import BaseRepresentation + + +class ChebyshevRepresentation(BaseRepresentation): + """Expand each numerical feature into a Chebyshev polynomial basis.""" + + representation_name = "chebyshev" + feature_kind = "numerical" + scope = "univariate" + supervision = "unsupervised" + + def __init__(self, degree=5): + self.degree = degree + + def fit(self, X, y=None): + X = np.asarray(self._validate(X, reset=True), dtype=float) + self.data_min_ = X.min(axis=0) + self.data_max_ = X.max(axis=0) + return self + + def _rescale(self, X): + span = self.data_max_ - self.data_min_ + span = np.where(span == 0.0, 1.0, span) + return np.clip(2.0 * (X - self.data_min_) / span - 1.0, -1.0, 1.0) + + def transform(self, X): + check_is_fitted(self, "n_features_in_") + z = self._rescale(np.asarray(self._validate(X, reset=False), dtype=float)) + theta = np.arccos(z) + blocks = [ + np.column_stack([np.cos(k * theta[:, j]) for k in range(1, self.degree + 1)]) + for j in range(z.shape[1]) + ] + return np.hstack(blocks) + + def _output_sizes(self): + return [self.degree] * self.n_features_in_ +``` + +The four class attributes are the declarative contract. + +`representation_name` +: The name you will select it by, for example `numerical_method="chebyshev"`. + +`feature_kind` +: `"numerical"` or `"categorical"`. + +`scope` +: `"univariate"` (one column at a time) or `"multivariate"` (jointly). + +`supervision` +: `"unsupervised"`, `"optional"` (uses `y` only when `target_aware=True`), or `"supervised"` + (always needs `y`). + +```{tip} +Implement `_output_sizes` to return the number of output columns each input contributes. The +base class uses it to generate correct feature names and to power the output budget. If your +naming is bespoke, override `get_feature_names_out` directly instead. +``` + +## Validate against the contract + +Before registering, run the conformance suite. It checks that your class round-trips, respects +NaN handling, produces stable names, and honours its declared metadata. + +```python +from pretab import check_representation + +check_representation(ChebyshevRepresentation) # raises on any contract violation +``` + +```{important} +`check_representation` raises `RepresentationConformanceError` with a specific message when the +contract is broken. Run it in your test suite so a future change cannot silently break +compatibility. +``` + +## Register it + +Registration adds the class to the capability registry under its name, making it selectable +through `Preprocessor` and visible to `list_representations`. + +```python +from pretab import register_representation, Preprocessor + +register_representation( + "chebyshev", + ChebyshevRepresentation, + allowed_args=("degree",), + supports_adaptive_resolution=False, +) + +pre = Preprocessor(numerical_method="chebyshev", degree=8) +X2 = pre.fit_transform(df, y) +``` + +The `allowed_args` list tells `Preprocessor` which of its shared keyword arguments to pass +through to your constructor. + +## Ship it as a plugin + +To distribute your representation as an installable package, advertise it through the +`pretab.representations` entry-point group in your `pyproject.toml`. + +```toml +[project.entry-points."pretab.representations"] +chebyshev = "pretab_chebyshev:ChebyshevRepresentation" +``` + +Users then load every installed plugin with one call. + +```python +from pretab import load_entry_point_representations + +load_entry_point_representations() # discovers and registers installed plugins +``` + +```{note} +Discovery is opt-in and never runs automatically at import, so importing `pretab` stays fast +and predictable. A broken plugin is skipped with a warning rather than breaking discovery for +the others. +``` + +## Where to go next + +- [Representations overview](../representations/overview.md) to see the built-in families your + method joins. +- [Extensibility API](../api/extension.rst) for the full signatures. +- The `examples/pretab-chebyshev/` package for a complete, tested template. diff --git a/docs/tutorials/multivariate_features.md b/docs/tutorials/multivariate_features.md new file mode 100644 index 0000000..036ef2f --- /dev/null +++ b/docs/tutorials/multivariate_features.md @@ -0,0 +1,117 @@ +# Multivariate features + +Most representations transform one column at a time. Some relationships, though, live in the +interaction between columns: a smooth surface over latitude and longitude, or a kernel over +many inputs at once. PreTab's multivariate methods model several columns jointly. This tutorial +shows how to use them. + +## Which methods are multivariate + +Four methods operate on several inputs together rather than per column. + +`tensorspline` +: Tensor-product spline. A smooth basis over a small number of inputs, capturing their + interaction on a grid. + +`tprs` +: Thin-plate regression spline. A smooth surface over two or more inputs, from the generalized + additive model literature. + +`rff` +: Random Fourier features. A scalable approximation to a shift-invariant kernel. + +`nystroem` +: Nyström kernel map. A landmark-based kernel approximation. + +```{warning} +These four are standalone transformers. They are not available as a per-column +`numerical_method` on `Preprocessor`, because they need the whole input block. Fit them +directly on the columns you want to model jointly. +``` + +## A smooth surface with thin-plate splines + +Suppose the target is a smooth function of two coordinates. A per-column expansion cannot see +the interaction, but a thin-plate spline models the surface directly. + +```python +import numpy as np +from sklearn.pipeline import Pipeline +from sklearn.linear_model import Ridge +from sklearn.model_selection import cross_val_score + +from pretab.transformers import ThinPlateSplineTransformer + +rng = np.random.default_rng(0) +n = 3000 +X = rng.uniform(-3, 3, size=(n, 2)) +y = np.exp(-(X[:, 0] ** 2 + X[:, 1] ** 2)) * 5 + rng.normal(0, 0.2, n) + +model = Pipeline([ + ("tps", ThinPlateSplineTransformer(n_components=20)), + ("ridge", Ridge(alpha=1.0)), +]) + +scores = cross_val_score(model, X, y, cv=5, scoring="r2") +print(f"5-fold R2: {scores.mean():.3f} +/- {scores.std():.3f}") +``` + +The thin-plate basis captures the radial bump over the two coordinates jointly, something two +separate one-dimensional splines cannot do. + +```{tip} +Use `n_components` to trade accuracy for cost. More landmarks give a richer surface at higher +memory and compute. Start modest and increase only if validation improves. +``` + +## A scalable kernel with random Fourier features + +When you want kernel-style flexibility over many inputs on a large dataset, random Fourier +features approximate an RBF kernel without forming the full kernel matrix. + +```python +from pretab.transformers import RandomFourierFeaturesTransformer + +model = Pipeline([ + ("rff", RandomFourierFeaturesTransformer(n_components=200, gamma=0.5)), + ("ridge", Ridge(alpha=1.0)), +]) + +scores = cross_val_score(model, X, y, cv=5, scoring="r2") +print(f"5-fold R2: {scores.mean():.3f} +/- {scores.std():.3f}") +``` + +```{note} +Random Fourier features and Nyström both approximate a kernel machine. Random Fourier features +scale to large data with random projections; Nyström samples landmark points and is often more +accurate at a given width. Try both. +``` + +## Combining multivariate and per-column methods + +You can mix a joint block for interacting columns with per-column expansions for the rest, +using a `ColumnTransformer`. + +```python +from sklearn.compose import ColumnTransformer +from pretab.transformers import PLETransformer +import pandas as pd + +df = pd.DataFrame({"lat": X[:, 0], "lon": X[:, 1], "size": rng.uniform(0, 100, n)}) + +features = ColumnTransformer([ + ("geo", ThinPlateSplineTransformer(n_components=20), ["lat", "lon"]), + ("size", PLETransformer(output_dim=10, task="regression"), ["size"]), +]) + +model = Pipeline([("features", features), ("ridge", Ridge(alpha=1.0))]) +``` + +The thin-plate spline handles the geographic interaction while PLE handles the standalone +`size` column, each with the representation that suits it. + +## Where to go next + +- [Splines](../representations/splines.md) for the tensor-product and thin-plate details. +- [Feature maps](../representations/feature_maps.md) for the kernel approximations. +- [References](../representations/references.md) for the underlying theory. diff --git a/docs/tutorials/nonlinear_regression.md b/docs/tutorials/nonlinear_regression.md new file mode 100644 index 0000000..2277121 --- /dev/null +++ b/docs/tutorials/nonlinear_regression.md @@ -0,0 +1,169 @@ +# Nonlinear regression + +PreTab is most useful as the feature layer in front of a model. This walkthrough builds the +same small regression task twice: once with plain scaling and once with PreTab, using the +**same linear model** both times. Only the representation changes, which makes the effect +easy to see. + +## The dataset + +We simulate a tabular dataset with three numeric columns and one categorical column, where the +target depends on each feature in a nonlinear way. + +```python +import numpy as np +import pandas as pd +from sklearn.model_selection import train_test_split + +rng = np.random.default_rng(0) +n = 4000 + +age = rng.uniform(18, 70, n) +income = rng.normal(60_000, 15_000, n) +tenure = rng.uniform(0, 40, n) +city = rng.choice(["Berlin", "Munich", "Hamburg", "Cologne"], n) + +city_effect = pd.Series(city).map( + {"Berlin": 5.0, "Munich": 8.0, "Hamburg": 3.0, "Cologne": 6.0} +).to_numpy() +target = ( + 12 * np.sin(age / 8) # wave in age + + 0.00004 * (income - 60_000) ** 2 / 1000 # quadratic in income + + np.sqrt(tenure) * 3 # diminishing returns on tenure + + city_effect # per-city offset + + rng.normal(0, 2, n) # noise +) + +df = pd.DataFrame({"age": age, "income": income, "tenure": tenure, "city": city}) + +X_train, X_test, y_train, y_test = train_test_split( + df, target, test_size=0.25, random_state=42 +) +``` + +The target curves with `age`, bends quadratically with `income`, and flattens out with +`tenure`. A plain linear model only sees a single straight-line term per column, so it has no +way to represent these shapes. That is exactly the gap PreTab fills. + +```{warning} +Fit every transformer on the **training split only**, then apply it to the test split with +`transform`. Supervised expansions such as PLE read `y` while fitting, so fitting on the full +dataset would leak test information and inflate your scores. See +[Target awareness](../core_concepts/target_awareness.md). +``` + +## Baseline: scaling and Ridge + +First, a conventional pipeline: scale the numeric columns, one-hot the categorical one, and fit +a `Ridge` regressor. + +```python +from sklearn.compose import ColumnTransformer +from sklearn.preprocessing import MinMaxScaler, OneHotEncoder +from sklearn.linear_model import Ridge +from sklearn.metrics import r2_score, mean_absolute_error + +baseline = ColumnTransformer([ + ("num", MinMaxScaler(), ["age", "income", "tenure"]), + ("cat", OneHotEncoder(handle_unknown="ignore"), ["city"]), +]) + +X_tr = baseline.fit_transform(X_train) +X_te = baseline.transform(X_test) + +model = Ridge(alpha=1.0).fit(X_tr, y_train) +pred = model.predict(X_te) + +print(f"features: {X_tr.shape[1]}") +print(f"R2: {r2_score(y_test, pred):.3f}") +print(f"MAE: {mean_absolute_error(y_test, pred):.2f}") +``` + +```text +features: 7 +R2: 0.124 +MAE: 11.20 +``` + +With one straight-line term per numeric column, `Ridge` can only fit a global slope. It misses +every curve in the target, and the $R^2$ of `0.124` is barely better than predicting the mean. + +## With PreTab + +Now swap the scaler for a `Preprocessor` that gives each column an expressive basis: a B-spline +for `age`, piecewise-linear encoding for `income`, radial basis functions for `tenure`, and +one-hot for `city`. Everything else stays the same. + +```python +from pretab import Preprocessor + +pre = Preprocessor( + feature_preprocessing={ + "age": "bspline", + "income": "ple", + "tenure": "rbf", + "city": "one-hot", + }, + task="regression", + output_dim=12, +) + +X_tr = pre.fit_transform(X_train, y_train, return_array=True) +X_te = pre.transform(X_test, return_array=True) + +model = Ridge(alpha=1.0).fit(X_tr, y_train) +pred = model.predict(X_te) + +print(f"features: {X_tr.shape[1]}") +print(f"R2: {r2_score(y_test, pred):.3f}") +print(f"MAE: {mean_absolute_error(y_test, pred):.2f}") +``` + +```text +features: 41 +R2: 0.968 +MAE: 2.16 +``` + +The data and the `Ridge` model are unchanged, but the expressive features let it capture the +nonlinear structure. The $R^2$ jumps from `0.124` to `0.968` and the mean absolute error drops +from `11.20` to `2.16`. + +```{tip} +`Preprocessor.transform` returns a dict of feature blocks by default. When you feed a plain +estimator, call it with `return_array=True` to get a single stacked matrix. To compose +everything inside one scikit-learn `Pipeline` instead, use the standalone transformers, shown +in the [sklearn pipeline tutorial](sklearn_pipeline.md). +``` + +## What actually changed + +The `Preprocessor` expands four raw columns into 41 features. Inspect the resolved layout with +`get_feature_info`: + +```python +pre.get_feature_info() +``` + +```text +feature kind pipeline dim cats +---------------------------------------------------------------- +age numerical imputer -> minmax -> bspline 13 - +income numerical imputer -> minmax -> ple 12 - +tenure numerical imputer -> minmax -> rbf 12 - +city categorical imputer -> onehot -> to_float 4 4 +``` + +Each numeric column is imputed, scaled, then expanded into a basis the linear model can weight +independently: 13 spline coefficients for `age`, 12 PLE bins for `income`, and 12 RBF bumps for +`tenure`, while `city` becomes four one-hot columns. To trace any single output column back to +its source, use [feature lineage](../core_concepts/outputs_and_inspection.md). + +## Where to go next + +- Do the same for a classifier in the + [leakage-safe classification tutorial](target_aware_classification.md). +- Wire PreTab transformers into a full `Pipeline` with cross-validation and grid search in the + [sklearn pipeline tutorial](sklearn_pipeline.md). +- Measure one representation against another in + [comparing representations](comparing_representations.md). diff --git a/docs/tutorials/sklearn_pipeline.md b/docs/tutorials/sklearn_pipeline.md index cbab77b..bd4f668 100644 --- a/docs/tutorials/sklearn_pipeline.md +++ b/docs/tutorials/sklearn_pipeline.md @@ -7,7 +7,7 @@ The **standalone transformers**, on the other hand, return plain arrays and foll work with `cross_val_score`, `GridSearchCV`, and every other `sklearn` utility. This tutorial builds the regression task from the -[end-to-end example](../getting_started/end_to_end.md) as a single, self-contained +[nonlinear regression tutorial](nonlinear_regression.md) as a single, self-contained `Pipeline`. ## Build the pipeline @@ -111,12 +111,12 @@ Every pretab transformer participates in the search grid just like a native `skl - **Standalone transformers** (this page) compose inside one `Pipeline` and integrate with cross-validation and grid search. Reach for them when you want a single estimator object. -- **The `Preprocessor`** (the [end-to-end example](../getting_started/end_to_end.md)) reads +- **The `Preprocessor`** (the [nonlinear regression tutorial](nonlinear_regression.md)) reads a `DataFrame`, detects feature types automatically, and configures every column from a single config. Reach for it when you want per-column strategies without wiring each one by hand. ## Next steps -- Browse every transformer in the [API Reference](../api/index.rst). -- Review the available strategy strings in the [User Guide](../user_guide/preprocessing.md). +- Browse every transformer in the [API reference](../api/index.rst). +- Review the method catalogue in the [representations overview](../representations/overview.md). diff --git a/docs/tutorials/target_aware_classification.md b/docs/tutorials/target_aware_classification.md new file mode 100644 index 0000000..3f16846 --- /dev/null +++ b/docs/tutorials/target_aware_classification.md @@ -0,0 +1,156 @@ +# Leakage-safe classification + +The [nonlinear regression tutorial](nonlinear_regression.md) put PreTab in front of a +regressor. The same idea works for classification, with one added concern: when the +representation is supervised, the evaluation must keep it from seeing the test labels. This +tutorial shows an expressive classifier and how to evaluate it without leakage. + +Here the target has a **ring-shaped** boundary, where the positive class sits near the origin +of two coordinates, plus a categorical `plan` effect. A plain `LogisticRegression` draws a +single straight boundary and struggles; radial basis features let it curve around the ring. + +## The dataset + +```python +import numpy as np +import pandas as pd +from sklearn.model_selection import train_test_split + +rng = np.random.default_rng(1) +n = 4000 + +x1 = rng.uniform(-3, 3, n) +x2 = rng.uniform(-3, 3, n) +hours = rng.uniform(0, 60, n) +plan = rng.choice(["free", "pro", "team"], n, p=[0.5, 0.3, 0.2]) + +plan_effect = pd.Series(plan).map({"free": -0.5, "pro": 0.3, "team": 1.0}).to_numpy() +logit = 3.0 - (x1**2 + x2**2) + 0.02 * (hours - 30) + plan_effect + rng.normal(0, 0.5, n) +prob = 1 / (1 + np.exp(-logit)) +y = (rng.uniform(0, 1, n) < prob).astype(int) + +df = pd.DataFrame({"x1": x1, "x2": x2, "hours": hours, "plan": plan}) + +X_train, X_test, y_train, y_test = train_test_split( + df, y, test_size=0.25, random_state=42, stratify=y +) +``` + +The positive class lives inside a ring around the origin, shifted by the plan. The classes are +imbalanced, roughly one positive to three negatives. + +## Baseline: scaling and LogisticRegression + +```python +from sklearn.compose import ColumnTransformer +from sklearn.preprocessing import MinMaxScaler, OneHotEncoder +from sklearn.linear_model import LogisticRegression +from sklearn.metrics import accuracy_score, roc_auc_score + +baseline = ColumnTransformer([ + ("num", MinMaxScaler(), ["x1", "x2", "hours"]), + ("cat", OneHotEncoder(handle_unknown="ignore"), ["plan"]), +]) + +X_tr = baseline.fit_transform(X_train) +X_te = baseline.transform(X_test) + +clf = LogisticRegression(max_iter=1000).fit(X_tr, y_train) +proba = clf.predict_proba(X_te)[:, 1] + +print(f"accuracy: {accuracy_score(y_test, clf.predict(X_te)):.3f}") +print(f"ROC AUC: {roc_auc_score(y_test, proba):.3f}") +``` + +```text +accuracy: 0.742 +ROC AUC: 0.569 +``` + +Accuracy looks acceptable only because the classes are imbalanced; the model mostly predicts +the majority class. The `ROC AUC` of `0.569` shows it has barely learned to rank positives +above negatives, because a straight boundary cannot enclose the ring. + +```{warning} +On imbalanced data, accuracy misleads. A model that always predicts the majority class already +scores around `0.74` here. Prefer threshold-independent metrics such as `ROC AUC`, or +precision and recall, to judge whether a classifier has genuinely learned. +``` + +## With PreTab + +Give every numeric column a radial basis expansion and keep the same classifier. + +```python +from pretab import Preprocessor + +pre = Preprocessor( + numerical_method="rbf", + categorical_method="one-hot", + task="classification", + target_aware=True, + output_dim=10, +) + +X_tr = pre.fit_transform(X_train, y_train, return_array=True) +X_te = pre.transform(X_test, return_array=True) + +clf = LogisticRegression(max_iter=1000).fit(X_tr, y_train) +proba = clf.predict_proba(X_te)[:, 1] + +print(f"accuracy: {accuracy_score(y_test, clf.predict(X_te)):.3f}") +print(f"ROC AUC: {roc_auc_score(y_test, proba):.3f}") +``` + +```text +accuracy: 0.872 +ROC AUC: 0.927 +``` + +The RBF features let the linear classifier bend around the ring. Accuracy rises from `0.742` +to `0.872`, and the `ROC AUC` jumps from `0.569` to `0.927`. + +```{note} +`target_aware=True` lets supervised expansions use `y` during `fit` to place their basis +functions where they best separate the classes. Because we fit on the training split and only +`transform` the test split, no test label reaches the representation. +``` + +## Leakage-safe cross-validation + +The split above is honest because the representation was fit on the training rows only. To make +that guarantee automatic across folds, put the transformers inside a `Pipeline`. scikit-learn +then fits every step, including the supervised expansion, on each training fold in turn. + +```python +from sklearn.pipeline import Pipeline +from sklearn.model_selection import cross_val_score +from pretab.transformers import RBFExpansionTransformer + +features = ColumnTransformer([ + ("x1", RBFExpansionTransformer(output_dim=10, target_aware=True), ["x1"]), + ("x2", RBFExpansionTransformer(output_dim=10, target_aware=True), ["x2"]), + ("hours", RBFExpansionTransformer(output_dim=10, target_aware=True), ["hours"]), + ("plan", OneHotEncoder(handle_unknown="ignore"), ["plan"]), +]) + +model = Pipeline([("features", features), ("clf", LogisticRegression(max_iter=1000))]) + +scores = cross_val_score(model, df, y, cv=5, scoring="roc_auc") +print(f"5-fold ROC AUC: {scores.mean():.3f} +/- {scores.std():.3f}") +``` + +```{important} +A supervised transformer fit outside a cross-validation or `Pipeline` context emits a +`LeakageWarning`. Inside the `Pipeline` here, the warning is suppressed because each fold fits +the representation on training data only. For the strongest guarantee on training features +themselves, wrap the transformer in `CrossFittedTransformer`. See +[Target awareness](../core_concepts/target_awareness.md). +``` + +## Where to go next + +- See the regression version in the [nonlinear regression tutorial](nonlinear_regression.md). +- Compose transformers with cross-validation and grid search in the + [sklearn pipeline tutorial](sklearn_pipeline.md). +- Read [Target awareness](../core_concepts/target_awareness.md) for the full leakage model. From e950fabd07e3f7a9c95100f49f246e693fb5e217 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Mon, 27 Jul 2026 14:14:02 +0200 Subject: [PATCH 26/59] docs(api): split reference into focused sections --- docs/api/extension.rst | 45 +++++++++++++++++ docs/api/index.rst | 72 ++++----------------------- docs/api/preprocessor.rst | 27 ++++++++++ docs/api/representations.rst | 72 +++++++++++++++++++++++++++ docs/api/search_and_cross_fitting.rst | 31 ++++++++++++ 5 files changed, 186 insertions(+), 61 deletions(-) create mode 100644 docs/api/extension.rst create mode 100644 docs/api/preprocessor.rst create mode 100644 docs/api/representations.rst create mode 100644 docs/api/search_and_cross_fitting.rst diff --git a/docs/api/extension.rst b/docs/api/extension.rst new file mode 100644 index 0000000..bdd6288 --- /dev/null +++ b/docs/api/extension.rst @@ -0,0 +1,45 @@ +Extensibility +============= + +The supported surface for adding, registering, discovering, and validating your +own representations. See the :doc:`custom representation tutorial +<../tutorials/custom_representation>` for a worked example. + +.. currentmodule:: pretab + +Base class and registration +--------------------------- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + + BaseRepresentation + register_representation + list_representations + check_representation + load_entry_point_representations + +Exceptions and warnings +----------------------- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + + FrozenRepresentationError + LeakageWarning + OutputBudgetError + PretabSerializationError + PretabWarning + RepresentationConformanceError + +Logging +------- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + + configure_logging + set_verbosity diff --git a/docs/api/index.rst b/docs/api/index.rst index 20cc889..bece623 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -1,65 +1,15 @@ -API Reference +API reference ============= -This page documents the public API of pretab: the high-level -``pretab.preprocessor.Preprocessor`` and every transformer exported from -``pretab.transformers``. +The complete public API of PreTab, organized by role. Start with the +:doc:`Preprocessor ` for the high-level interface, browse +:doc:`representations` for the transformers, use :doc:`search_and_cross_fitting` +for leakage-safe selection, and see :doc:`extension` to build your own. -Preprocessor ------------- +.. toctree:: + :maxdepth: 2 -.. autosummary:: - :toctree: _autosummary - :nosignatures: - - pretab.preprocessor.Preprocessor - -Encoders and binning --------------------- - -.. currentmodule:: pretab.transformers - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - - PLETransformer - CustomBinTransformer - OneHotFromOrdinalTransformer - LanguageEmbeddingTransformer - -Feature maps ------------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - - RBFExpansionTransformer - ReLUExpansionTransformer - SigmoidExpansionTransformer - TanhExpansionTransformer - -Splines -------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - - CubicSplineTransformer - NaturalCubicSplineTransformer - PSplineTransformer - TensorProductSplineTransformer - ThinPlateSplineTransformer - -Temporal --------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - - CyclicalTimeTransformer - LagFeatureTransformer - RollingStatsTransformer + preprocessor + representations + search_and_cross_fitting + extension diff --git a/docs/api/preprocessor.rst b/docs/api/preprocessor.rst new file mode 100644 index 0000000..f7e5c48 --- /dev/null +++ b/docs/api/preprocessor.rst @@ -0,0 +1,27 @@ +Preprocessor +============ + +The high-level entry point. :class:`~pretab.Preprocessor` reads a ``DataFrame``, +detects feature types, resolves a per-column representation from a single +configuration, and produces model-ready output with full lineage. + +.. currentmodule:: pretab + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + + Preprocessor + +Configuration, output, and reproducibility +------------------------------------------ + +Supporting types returned or consumed by the preprocessor. + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + + RepresentationSpec + FeatureLineage + RepresentationPolicy diff --git a/docs/api/representations.rst b/docs/api/representations.rst new file mode 100644 index 0000000..a0355da --- /dev/null +++ b/docs/api/representations.rst @@ -0,0 +1,72 @@ +Representations +=============== + +Every built-in transformer exported from ``pretab.transformers``. These are the +standalone, scikit-learn compatible representations. For a capability-oriented +view, see the :doc:`comparison table <../representations/comparison_table>`. + +.. currentmodule:: pretab.transformers + +Splines +------- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + + BSplineTransformer + MSplineTransformer + ISplineTransformer + CubicRegressionSplineTransformer + NaturalCubicSplineTransformer + PSplineTransformer + TensorProductSplineTransformer + ThinPlateSplineTransformer + +Feature maps +------------ + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + + RBFExpansionTransformer + ReLUExpansionTransformer + SigmoidExpansionTransformer + TanhExpansionTransformer + FourierFeatureTransformer + PeriodicEncodingTransformer + RandomFourierFeaturesTransformer + NystroemFeaturesTransformer + +Binning and piecewise-linear encoding +------------------------------------- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + + NumericBinningTransformer + PLETransformer + +Categorical +----------- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + + ContinuousOrdinalTransformer + OneHotFromOrdinalTransformer + LanguageEmbeddingTransformer + +Utility transformers +-------------------- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + + MissingStateIndicator + NoTransformer + ToFloatTransformer diff --git a/docs/api/search_and_cross_fitting.rst b/docs/api/search_and_cross_fitting.rst new file mode 100644 index 0000000..6b3f299 --- /dev/null +++ b/docs/api/search_and_cross_fitting.rst @@ -0,0 +1,31 @@ +Search and cross-fitting +======================== + +Tools for selecting a representation and for producing leakage-free supervised +features. See :doc:`../core_concepts/target_awareness` for the leakage model. + +.. currentmodule:: pretab + +Representation search +--------------------- + +Cross-validate a downstream estimator over candidate numerical methods and refit +the best one. + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + + RepresentationSearchCV + +Cross-fitting +------------- + +Produce out-of-fold training features from a supervised transformer while +transforming new data with an all-data model. + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + + CrossFittedTransformer From a2ed83857e491f49b9208dee2a6c3204495687dc Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Mon, 27 Jul 2026 14:14:07 +0200 Subject: [PATCH 27/59] docs(dev): add testing and documentation guides --- docs/developer_guide/documentation.md | 110 ++++++++++++++++++++++++++ docs/developer_guide/testing.md | 95 ++++++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 docs/developer_guide/documentation.md create mode 100644 docs/developer_guide/testing.md diff --git a/docs/developer_guide/documentation.md b/docs/developer_guide/documentation.md new file mode 100644 index 0000000..011c683 --- /dev/null +++ b/docs/developer_guide/documentation.md @@ -0,0 +1,110 @@ +# Documentation + +The documentation you are reading is part of the codebase and is held to the same standard as +the code. This page explains how it is built, how it is structured, and the conventions to +follow when you add or edit a page. + +## Building the docs + +The docs build with Sphinx through a single recipe. + +```bash +just docs # build HTML into docs/_build/html +open docs/_build/html/index.html # macOS; use xdg-open on Linux +``` + +```{important} +The build runs with `-W`, so **warnings are treated as errors**. A broken cross-reference, an +orphaned page, or a malformed directive fails the build. Run `just docs` before opening a pull +request that touches documentation. +``` + +To work on the docs, install the docs dependency group. + +```bash +poetry install --with docs +``` + +## Structure + +The `docs/` tree is organized by reader intent. + +| Section | Purpose | +| --- | --- | +| `getting_started/` | Install, first model, choosing an interface, migration. | +| `core_concepts/` | The mental model: representation, configuration, resolution, target awareness, missing values, outputs, reproducibility. | +| `representations/` | The method catalogue, comparison table, and selection guidance. | +| `tutorials/` | Task-oriented, worked examples. | +| `api/` | Autogenerated reference from docstrings. | +| `developer_guide/` | Contributing, testing, documentation, versioning, release. | + +## MyST Markdown and reStructuredText + +Prose pages are written in [MyST Markdown](https://myst-parser.readthedocs.io/) (`.md`); the +API pages are reStructuredText (`.rst`) so they can drive `autosummary`. Use callout directives +to highlight important information. + +````markdown +```{note} +A neutral aside. +``` + +```{tip} +A helpful suggestion. +``` + +```{warning} +Something that can bite the reader. +``` + +```{important} +A guarantee or constraint the reader must not miss. +``` +```` + +Math uses standard MyST syntax, inline as `$...$` and display as `$$...$$`. + +## Adding a page + +Every page must be reachable from a `toctree`, or the strict build fails with an orphan-document +error. + +1. Create the `.md` file in the appropriate section. +2. Add its filename (without extension) to the relevant `toctree`, either in `index.rst` or the + section's own index. +3. Cross-link to and from sibling pages with relative links. +4. Run `just docs` and fix any warnings. + +```{warning} +A cross-reference to a page that does not exist fails the strict build. When you link to a page, +make sure the target exists, and when you remove a page, remove every link to it. +``` + +## The API reference + +The API pages document public classes and functions from their numpy-style docstrings through +`autodoc` and `autosummary`. There is no prose to write for a new public class; instead, add its +name to the appropriate `autosummary` block under `docs/api/` and keep its docstring accurate. + +```{note} +Because the reference is generated from docstrings, an accurate docstring is documentation. +Update the docstring in the same change that alters the behaviour. +``` + +## Writing style + +The documentation aims to be precise and natural, and to read well for beginners, practitioners, +and researchers alike. A few conventions keep it consistent. + +- Separate sections with headings, not horizontal rules. +- Avoid stray transitional text between sections; let the headings carry the structure. +- Prefer active, concrete sentences over filler. +- Ground every claim in the real API. If you are unsure of a parameter name or default, check + the source. +- Add a callout where it genuinely helps, not on every paragraph. + +## Where to go next + +- [Contributing](contributing.md) for the overall workflow. +- [Testing](testing.md) for the test gate that runs alongside the docs build. +- [Release process](release.md) for how docs ship with a release. diff --git a/docs/developer_guide/testing.md b/docs/developer_guide/testing.md new file mode 100644 index 0000000..0c3f4ae --- /dev/null +++ b/docs/developer_guide/testing.md @@ -0,0 +1,95 @@ +# Testing + +PreTab has a comprehensive test suite that gates every change. This page explains how the tests +are organized and how to run them. + +## Running the tests + +The suite runs with coverage through a single recipe. + +```bash +just test # poetry run pytest --cov=pretab tests/ +``` + +To run a subset while developing, invoke pytest directly. + +```bash +poetry run pytest tests/transformers/ # one area +poetry run pytest tests/transformers/test_bspline.py::test_output_shape # one test +poetry run pytest -k "spline and not tensor" # by keyword +``` + +## Layout + +Tests mirror the structure of the package, so a change in one area maps to an obvious test +directory. + +| Directory | Covers | +| --- | --- | +| `tests/core/` | Base classes, adaptive resolution, supervised logic, logging. | +| `tests/transformers/` | Every representation, per family. | +| `tests/placement/` | Knot and edge placement strategies. | +| `tests/compose/` | Registry, feature detection, config resolution, serialization. | +| `tests/extension/` | The public extensibility surface and conformance. | +| `tests/integration/` | End-to-end `Preprocessor` and pipeline behaviour. | +| `tests/regression/` | Pinned outputs that guard against silent numerical drift. | + +```{note} +Regression tests pin known-good output. If one fails after a deliberate change to a +representation, update the pinned values in the same commit and call it out in the pull +request, so the change is reviewed rather than hidden. +``` + +## Markers + +The suite defines a `smoke` marker for fast end-to-end sanity checks that run as a dedicated CI +gate. + +```bash +poetry run pytest -m smoke # only the smoke checks +poetry run pytest -m "not smoke" # everything else +``` + +## Coverage + +`just test` measures coverage over the `pretab` package. Keep new code covered, and prefer a +focused test that exercises the behaviour over one that merely touches lines. + +```bash +poetry run pytest --cov=pretab --cov-report=term-missing tests/ +``` + +## Testing a custom representation + +If you extend PreTab, run the conformance suite in your own tests. It verifies your class obeys +the representation contract, the same one the built-ins satisfy. + +```python +from pretab import check_representation +from my_package import MyRepresentation + +def test_conforms(): + check_representation(MyRepresentation) +``` + +```{important} +`check_representation` raises `RepresentationConformanceError` on any violation. Wiring it into +your test suite keeps a future refactor from silently breaking compatibility with `Preprocessor`. +``` + +## Before you push + +Run the full local gate, which mirrors CI. + +```bash +just test # tests with coverage +just check # lint, format, type-check across all files +just docs # strict docs build +``` + +## Where to go next + +- [Contributing](contributing.md) for the full pull-request workflow. +- [Writing a custom representation](../tutorials/custom_representation.md) for the conformance + suite in context. +- [Documentation](documentation.md) for the docs build the last command runs. From 0de6f8c08974701b6a59b33cf9e2855beeffe870 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Mon, 27 Jul 2026 14:14:14 +0200 Subject: [PATCH 28/59] docs: rewrite navigation and enable heading anchors --- docs/conf.py | 5 +++++ docs/homepage.md | 18 ++++++++++++------ docs/index.rst | 42 +++++++++++++++++++++++++++++++++++------- 3 files changed, 52 insertions(+), 13 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index f385fad..affa9fd 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -166,6 +166,11 @@ "version": release, } +# Auto-generate anchor slugs for headings (h1-h3) so in-page and cross-page +# links such as ``choosing_a_method.md#when-basis-expansion-does-not-help`` +# resolve under the strict (-W) build. +myst_heading_anchors = 3 + # -- Options for todo -------------------------------------------------------- todo_include_todos = False diff --git a/docs/homepage.md b/docs/homepage.md index 0a3e843..de01e60 100644 --- a/docs/homepage.md +++ b/docs/homepage.md @@ -93,6 +93,12 @@ X = pre.fit_transform(df, y) ::::{grid} 1 1 3 3 :gutter: 2 +:::{grid-item-card} Overview +:link: getting_started/overview +:link-type: doc +What PreTab is, what it is not, and where it fits. +::: + :::{grid-item-card} Installation :link: getting_started/installation :link-type: doc @@ -105,19 +111,19 @@ Install PreTab from PyPI or from source. Fit and transform a dataset in a few lines. ::: -:::{grid-item-card} End-to-end example -:link: getting_started/end_to_end +:::{grid-item-card} Nonlinear regression +:link: tutorials/nonlinear_regression :link-type: doc See PreTab lift a linear model, baseline vs. PreTab. ::: -:::{grid-item-card} Tutorials -:link: tutorials/sklearn_pipeline +:::{grid-item-card} Representations +:link: representations/overview :link-type: doc -Classification and full `sklearn` pipelines. +The full catalogue of splines, feature maps, and encoders. ::: -:::{grid-item-card} API Reference +:::{grid-item-card} API reference :link: api/index :link-type: doc The `Preprocessor` and every transformer. diff --git a/docs/index.rst b/docs/index.rst index d75127c..81b0d5b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,25 +6,51 @@ :maxdepth: 1 :hidden: + getting_started/overview getting_started/installation getting_started/quickstart - getting_started/end_to_end + getting_started/choosing_an_interface + getting_started/migration_to_1_0 .. toctree:: - :caption: Tutorials + :caption: Core Concepts :maxdepth: 1 :hidden: - tutorials/classification - tutorials/sklearn_pipeline + core_concepts/feature_representation + core_concepts/configuration + core_concepts/resolution_and_placement + core_concepts/target_awareness + core_concepts/missing_values + core_concepts/outputs_and_inspection + core_concepts/reproducibility .. toctree:: - :caption: User Guide + :caption: Representations :maxdepth: 1 :hidden: - user_guide/preprocessing - user_guide/configuration + representations/overview + representations/comparison_table + representations/choosing_a_method + representations/splines + representations/feature_maps + representations/binning_and_ple + representations/categorical + representations/references + +.. toctree:: + :caption: Tutorials + :maxdepth: 1 + :hidden: + + tutorials/nonlinear_regression + tutorials/target_aware_classification + tutorials/comparing_representations + tutorials/adaptive_resolution + tutorials/multivariate_features + tutorials/sklearn_pipeline + tutorials/custom_representation .. toctree:: :caption: API Reference @@ -39,5 +65,7 @@ :hidden: developer_guide/contributing + developer_guide/testing + developer_guide/documentation developer_guide/versioning developer_guide/release From 62a7c9a3feb6b9009fcf01beaa0b8764f7af65ed Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Mon, 27 Jul 2026 14:14:20 +0200 Subject: [PATCH 29/59] docs(splines): fix thin-plate docstring build errors --- pretab/transformers/splines/multivariate/thin_plate.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pretab/transformers/splines/multivariate/thin_plate.py b/pretab/transformers/splines/multivariate/thin_plate.py index 760edf0..17e9cc5 100644 --- a/pretab/transformers/splines/multivariate/thin_plate.py +++ b/pretab/transformers/splines/multivariate/thin_plate.py @@ -48,7 +48,7 @@ class ThinPlateSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEstimat Attributes ---------- - landmarks_ : ndarray of shape (n_landmarks, n_features_in_) + landmarks_ : ndarray of shape (n_landmarks, n_features) The landmark points used to build the TPS kernel. components_ : ndarray of shape (n_landmarks, n_components) The linear map from a data-to-landmark kernel row to the reduced basis. @@ -76,6 +76,8 @@ class ThinPlateSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEstimat - The radial kernel depends on the input dimension: :math:`r^3` for ``d=1``, :math:`r^2\log r` for ``d=2``, and the biharmonic kernel :math:`r` for ``d>=3``. + - The construction follows the thin-plate spline theory of Wahba [1]_ and the + low-rank thin-plate regression spline of Wood [2]_. References ---------- From 67c3e3e4bc956d103ed528a05931d5c2a41a5191 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 7 Aug 2026 09:28:14 +0200 Subject: [PATCH 30/59] style: apply ruff formatting --- .../src/pretab_chebyshev/__init__.py | 3 +-- .../pretab-chebyshev/tests/test_chebyshev.py | 4 +-- pretab/compose/config.py | 13 +++++----- pretab/compose/factory.py | 4 +-- pretab/compose/inspection.py | 4 +-- pretab/compose/registry.py | 15 ++++++++--- pretab/core/base.py | 1 - pretab/core/policy.py | 4 +-- pretab/core/representation.py | 10 ++----- pretab/core/supervised.py | 4 +-- pretab/extension.py | 26 +++++-------------- pretab/placement/adapters.py | 3 +-- pretab/preprocessor.py | 4 +-- pretab/transformers/numerical/binning.py | 7 ++--- pretab/transformers/numerical/periodic.py | 1 - tests/compose/test_registry_contract.py | 24 +++++++---------- tests/core/test_cross_fitted.py | 4 +-- .../extension/test_registration_discovery.py | 8 ++---- tests/integration/test_edge_case_contract.py | 9 +++---- tests/integration/test_fingerprint.py | 1 + tests/integration/test_missing_policy.py | 8 ++---- tests/placement/test_placement.py | 4 +-- .../test_spline_placement_adapter.py | 12 +++------ tests/transformers/test_periodic.py | 1 - 24 files changed, 59 insertions(+), 115 deletions(-) diff --git a/examples/pretab-chebyshev/src/pretab_chebyshev/__init__.py b/examples/pretab-chebyshev/src/pretab_chebyshev/__init__.py index 7b84505..808ae65 100644 --- a/examples/pretab-chebyshev/src/pretab_chebyshev/__init__.py +++ b/examples/pretab-chebyshev/src/pretab_chebyshev/__init__.py @@ -54,8 +54,7 @@ def transform(self, X): z = self._rescale(np.asarray(self._validate(X, reset=False), dtype=float)) theta = np.arccos(z) blocks = [ - np.column_stack([np.cos(k * theta[:, j]) for k in range(1, self.degree + 1)]) - for j in range(z.shape[1]) + np.column_stack([np.cos(k * theta[:, j]) for k in range(1, self.degree + 1)]) for j in range(z.shape[1]) ] return np.hstack(blocks) diff --git a/examples/pretab-chebyshev/tests/test_chebyshev.py b/examples/pretab-chebyshev/tests/test_chebyshev.py index 899508b..4f52d12 100644 --- a/examples/pretab-chebyshev/tests/test_chebyshev.py +++ b/examples/pretab-chebyshev/tests/test_chebyshev.py @@ -18,9 +18,7 @@ def test_passes_conformance_suite(): def test_register_and_use_through_preprocessor(): - register_representation( - "chebyshev", ChebyshevRepresentation, allowed_args=("degree",), override=True - ) + register_representation("chebyshev", ChebyshevRepresentation, allowed_args=("degree",), override=True) assert "chebyshev" in list_representations(feature_kind="numerical") X = pd.DataFrame({"a": np.linspace(0, 1, 20), "b": np.linspace(-1, 1, 20)}) diff --git a/pretab/compose/config.py b/pretab/compose/config.py index 5b08d69..2782c49 100644 --- a/pretab/compose/config.py +++ b/pretab/compose/config.py @@ -32,9 +32,7 @@ # Valid values for the high-level ``missing_policy`` orchestration knob. ``None`` # keeps the explicit imputation parameters authoritative (historical behaviour). -MISSING_POLICIES = frozenset( - {"error", "propagate", "impute", "impute_with_indicator", "separate_state"} -) +MISSING_POLICIES = frozenset({"error", "propagate", "impute", "impute_with_indicator", "separate_state"}) def _normalize_method(method, canonical, aliases) -> str: @@ -117,8 +115,7 @@ def from_params( "Preprocessor", "missing_policy", missing_policy, - "must be None or one of " - "'error', 'propagate', 'impute', 'impute_with_indicator', 'separate_state'", + "must be None or one of 'error', 'propagate', 'impute', 'impute_with_indicator', 'separate_state'", valid=set(MISSING_POLICIES), ) if add_missing_indicator and numerical_imputation is None and categorical_imputation is None: @@ -188,8 +185,10 @@ def imputation_plan(self, *, is_numerical: bool) -> dict: the explicit ``*_imputation`` / ``add_missing_indicator`` parameters stay authoritative (historical behaviour); otherwise ``missing_policy`` decides. """ - strategy = (self.numerical_imputation or "median") if is_numerical else ( - self.categorical_imputation or "most_frequent" + strategy = ( + (self.numerical_imputation or "median") + if is_numerical + else (self.categorical_imputation or "most_frequent") ) if self.missing_policy is None: configured = self.numerical_imputation if is_numerical else self.categorical_imputation diff --git a/pretab/compose/factory.py b/pretab/compose/factory.py index d232e22..065db86 100644 --- a/pretab/compose/factory.py +++ b/pretab/compose/factory.py @@ -272,9 +272,7 @@ def create_transformer(method: str, *, is_numerical: bool, config: PreprocessorC if plan["separate_state"]: # Emit a dedicated ``__missing`` column (built on the raw input) alongside # the imputed representation, so the indicator never enters the basis. - return FeatureUnion( - [("representation", pipeline), ("missing", MissingStateIndicator())] - ) + return FeatureUnion([("representation", pipeline), ("missing", MissingStateIndicator())]) return pipeline diff --git a/pretab/compose/inspection.py b/pretab/compose/inspection.py index d0e705d..a56332b 100644 --- a/pretab/compose/inspection.py +++ b/pretab/compose/inspection.py @@ -254,9 +254,7 @@ def build_feature_lineage(column_transformer): ) ) continue - family, component, uses_target, is_interaction = _resolve_block_representation( - transformer, columns - ) + family, component, uses_target, is_interaction = _resolve_block_representation(transformer, columns) source_features = tuple(str(column) for column in columns) for offset in range(width): index = span.start + offset diff --git a/pretab/compose/registry.py b/pretab/compose/registry.py index 8466d50..30fde8e 100644 --- a/pretab/compose/registry.py +++ b/pretab/compose/registry.py @@ -258,7 +258,16 @@ def _spec(name, cls, allowed_args=(), **kwargs): _spec( "cubicspline", CubicRegressionSplineTransformer, - ("output_dim", "degree", "include_bias", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state"), + ( + "output_dim", + "degree", + "include_bias", + "task", + "adaptive", + "min_output_dim", + "max_output_dim", + "random_state", + ), **_BOTH_MODE, ), _spec( @@ -432,9 +441,7 @@ def register_spec(spec: TransformerSpec, *, override: bool = False) -> Transform if not isinstance(spec, TransformerSpec): raise TypeError(f"expected a TransformerSpec, got {type(spec).__name__}") if spec.name in TRANSFORMER_REGISTRY and not override: - raise ValueError( - f"method {spec.name!r} is already registered; pass override=True to replace it." - ) + raise ValueError(f"method {spec.name!r} is already registered; pass override=True to replace it.") # Drop any stale derived-view entries before re-inserting (supports override). NUMERICAL_METHODS.pop(spec.name, None) CATEGORICAL_METHODS.discard(spec.name) diff --git a/pretab/core/base.py b/pretab/core/base.py index fc87c87..58d39d5 100644 --- a/pretab/core/base.py +++ b/pretab/core/base.py @@ -66,7 +66,6 @@ def _validate(self, X, *, reset: bool): apply_constant_policy(X, self._resolved_policy(), estimator=self) return X - def _feature_suffix(self) -> str: """Suffix used when generating output feature names.""" return self._feature_suffix_value diff --git a/pretab/core/policy.py b/pretab/core/policy.py index f31262f..c915361 100644 --- a/pretab/core/policy.py +++ b/pretab/core/policy.py @@ -71,9 +71,7 @@ def __post_init__(self): for name, choices in _CHOICES.items(): value = getattr(self, name) if value not in choices: - raise invalid_param_error( - "RepresentationPolicy", name, value, f"one of {choices}", valid=choices - ) + raise invalid_param_error("RepresentationPolicy", name, value, f"one of {choices}", valid=choices) @classmethod def resolve(cls, policy) -> RepresentationPolicy: diff --git a/pretab/core/representation.py b/pretab/core/representation.py index b569acc..7eb77c7 100644 --- a/pretab/core/representation.py +++ b/pretab/core/representation.py @@ -113,9 +113,7 @@ def to_dict(self) -> dict: "period": self.period, "local_support": self.local_support, "location_kind": self.location_kind, - "locations": ( - None if self.locations is None else [list(group) for group in self.locations] - ), + "locations": (None if self.locations is None else [list(group) for group in self.locations]), "dtype": self.dtype, "cross_fitted": self.cross_fitted, "n_folds": self.n_folds, @@ -142,11 +140,7 @@ def from_dict(cls, data: dict) -> "RepresentationSpec": period=None if data["period"] is None else float(data["period"]), local_support=bool(data["local_support"]), location_kind=data["location_kind"], - locations=( - None - if locations is None - else tuple(tuple(float(v) for v in group) for group in locations) - ), + locations=(None if locations is None else tuple(tuple(float(v) for v in group) for group in locations)), dtype=data.get("dtype", "float64"), cross_fitted=bool(data.get("cross_fitted", False)), n_folds=None if n_folds is None else int(n_folds), diff --git a/pretab/core/supervised.py b/pretab/core/supervised.py index 735c48a..b4edf64 100644 --- a/pretab/core/supervised.py +++ b/pretab/core/supervised.py @@ -44,9 +44,7 @@ # Set while :class:`CrossFittedTransformer` fits its internal clones, so their # fits never emit a leakage warning. -_cross_fit_active: contextvars.ContextVar[bool] = contextvars.ContextVar( - "pretab_cross_fit_active", default=False -) +_cross_fit_active: contextvars.ContextVar[bool] = contextvars.ContextVar("pretab_cross_fit_active", default=False) def in_controlled_context() -> bool: diff --git a/pretab/extension.py b/pretab/extension.py index 689ec35..e8107ed 100644 --- a/pretab/extension.py +++ b/pretab/extension.py @@ -92,17 +92,13 @@ def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) if cls.feature_kind not in _VALID_FEATURE_KINDS: raise ValueError( - f"{cls.__name__}.feature_kind must be one of {sorted(_VALID_FEATURE_KINDS)}, " - f"got {cls.feature_kind!r}" + f"{cls.__name__}.feature_kind must be one of {sorted(_VALID_FEATURE_KINDS)}, got {cls.feature_kind!r}" ) if cls.scope not in _VALID_SCOPES: - raise ValueError( - f"{cls.__name__}.scope must be one of {sorted(_VALID_SCOPES)}, got {cls.scope!r}" - ) + raise ValueError(f"{cls.__name__}.scope must be one of {sorted(_VALID_SCOPES)}, got {cls.scope!r}") if cls.supervision not in _VALID_SUPERVISION: raise ValueError( - f"{cls.__name__}.supervision must be one of {sorted(_VALID_SUPERVISION)}, " - f"got {cls.supervision!r}" + f"{cls.__name__}.supervision must be one of {sorted(_VALID_SUPERVISION)}, got {cls.supervision!r}" ) # Sync the public contract onto the internal representation hooks so the # inherited RepresentationSpec and estimator tags reflect the declared @@ -378,9 +374,7 @@ def _fit(est): f"{cls.__name__}.transform before fit should raise NotFittedError, got {type(exc).__name__}" ) from exc else: - raise RepresentationConformanceError( - f"{cls.__name__}.transform before fit should raise NotFittedError" - ) + raise RepresentationConformanceError(f"{cls.__name__}.transform before fit should raise NotFittedError") passed.append("unfitted_transform_raises") # 2. fit returns self and does not mutate X. @@ -410,25 +404,19 @@ def _fit(est): f"{cls.__name__}.get_feature_names_out length {len(names)} != output width {width}" ) if len(set(names)) != len(names): - raise RepresentationConformanceError( - f"{cls.__name__}.get_feature_names_out must be unique" - ) + raise RepresentationConformanceError(f"{cls.__name__}.get_feature_names_out must be unique") passed.append("feature_names_match") # 5. deterministic across clone + refit. clone_out = _densify(_fit(clone(fitted)).transform(X)) if clone_out.shape != out.shape or not np.allclose(clone_out, out, equal_nan=True): - raise RepresentationConformanceError( - f"{cls.__name__} is not deterministic across clone + refit" - ) + raise RepresentationConformanceError(f"{cls.__name__} is not deterministic across clone + refit") passed.append("deterministic") # 6. typed representation spec agrees with the declared metadata. spec = fitted.get_representation_spec() if not isinstance(spec, RepresentationSpec): - raise RepresentationConformanceError( - f"{cls.__name__}.get_representation_spec must return a RepresentationSpec" - ) + raise RepresentationConformanceError(f"{cls.__name__}.get_representation_spec must return a RepresentationSpec") declared_scope = getattr(cls, "scope", "univariate") if spec.scope != declared_scope: raise RepresentationConformanceError( diff --git a/pretab/placement/adapters.py b/pretab/placement/adapters.py index f641458..15a0e65 100644 --- a/pretab/placement/adapters.py +++ b/pretab/placement/adapters.py @@ -224,6 +224,5 @@ class PeriodicPlacementAdapter: def get_locations(self, x: np.ndarray, y: np.ndarray | None = None) -> np.ndarray: raise NotImplementedError( - "Periodic encoding is parameter-driven (period, harmonics) and does not use " - "data-dependent placement." + "Periodic encoding is parameter-driven (period, harmonics) and does not use data-dependent placement." ) diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index 3605fc5..7ad5133 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -783,9 +783,7 @@ def _enforce_output_budget(self, n_rows: int) -> None: total = int(self.total_output_dim_) if self.max_output_features is not None and total > self.max_output_features: - violations.append( - f"total output columns ({total}) exceed max_output_features ({self.max_output_features})" - ) + violations.append(f"total output columns ({total}) exceed max_output_features ({self.max_output_features})") if self.max_features_per_input is not None: for feature, width in self.output_dims_.items(): diff --git a/pretab/transformers/numerical/binning.py b/pretab/transformers/numerical/binning.py index 4a431f5..96ae750 100644 --- a/pretab/transformers/numerical/binning.py +++ b/pretab/transformers/numerical/binning.py @@ -12,9 +12,7 @@ _VALID_STRATEGIES = ("uniform", "quantile") -class NumericBinningTransformer( - RepresentationSpecMixin, AliasResolverMixin, TransformerMixin, BaseEstimator -): +class NumericBinningTransformer(RepresentationSpecMixin, AliasResolverMixin, TransformerMixin, BaseEstimator): """Stateful binning transformer for numerical features. The bin edges are learned once in :meth:`fit` and reused at @@ -119,8 +117,7 @@ def _check_array(self, X, *, reset): self.n_features_in_ = X.shape[1] elif X.shape[1] != self.n_features_in_: raise PretabDataError( - f"Input has {X.shape[1]} features, but NumericBinningTransformer " - f"was fitted with {self.n_features_in_}." + f"Input has {X.shape[1]} features, but NumericBinningTransformer was fitted with {self.n_features_in_}." ) return X diff --git a/pretab/transformers/numerical/periodic.py b/pretab/transformers/numerical/periodic.py index ea96d60..b54102e 100644 --- a/pretab/transformers/numerical/periodic.py +++ b/pretab/transformers/numerical/periodic.py @@ -96,4 +96,3 @@ def transform(self, X): def _output_sizes(self) -> list[int]: per_feature = 2 * self.harmonics + (1 if self.include_original else 0) return [per_feature] * self.n_features_in_ - diff --git a/tests/compose/test_registry_contract.py b/tests/compose/test_registry_contract.py index cb86666..3fe5a59 100644 --- a/tests/compose/test_registry_contract.py +++ b/tests/compose/test_registry_contract.py @@ -147,9 +147,13 @@ def test_registry_covers_numerical_and_categorical_names(): for name, spec in _SPEC_ITEMS if spec.is_numerical and spec.preprocessor_compatible and not spec.is_multivariate ] -_PREPROC_CATEGORICAL = [(name, spec) for name, spec in _SPEC_ITEMS if spec.is_categorical and spec.preprocessor_compatible] +_PREPROC_CATEGORICAL = [ + (name, spec) for name, spec in _SPEC_ITEMS if spec.is_categorical and spec.preprocessor_compatible +] _REQUIRED_NUMERICAL = [ - (name, spec) for name, spec in _SPEC_ITEMS if spec.is_numerical and spec.requires_target and not spec.is_multivariate + (name, spec) + for name, spec in _SPEC_ITEMS + if spec.is_numerical and spec.requires_target and not spec.is_multivariate ] @@ -158,9 +162,7 @@ def _skip_if_dependency_missing(spec): pytest.skip(f"optional dependency {spec.optional_dependency!r} not installed") -@pytest.mark.parametrize( - "name, spec", _PREPROC_NUMERICAL, ids=[name for name, _ in _PREPROC_NUMERICAL] -) +@pytest.mark.parametrize("name, spec", _PREPROC_NUMERICAL, ids=[name for name, _ in _PREPROC_NUMERICAL]) def test_preprocessor_compatible_numerical_methods_fit_transform(name, spec): _skip_if_dependency_missing(spec) rng = np.random.RandomState(0) @@ -174,9 +176,7 @@ def test_preprocessor_compatible_numerical_methods_fit_transform(name, spec): assert out.shape[0] == 60 -@pytest.mark.parametrize( - "name, spec", _PREPROC_CATEGORICAL, ids=[name for name, _ in _PREPROC_CATEGORICAL] -) +@pytest.mark.parametrize("name, spec", _PREPROC_CATEGORICAL, ids=[name for name, _ in _PREPROC_CATEGORICAL]) def test_preprocessor_compatible_categorical_methods_fit_transform(name, spec): _skip_if_dependency_missing(spec) rng = np.random.RandomState(0) @@ -195,9 +195,7 @@ def test_preprocessor_compatible_categorical_methods_fit_transform(name, spec): assert out.shape[0] == 60 -@pytest.mark.parametrize( - "name, spec", _REQUIRED_NUMERICAL, ids=[name for name, _ in _REQUIRED_NUMERICAL] -) +@pytest.mark.parametrize("name, spec", _REQUIRED_NUMERICAL, ids=[name for name, _ in _REQUIRED_NUMERICAL]) def test_required_target_methods_raise_without_y_via_preprocessor(name, spec): _skip_if_dependency_missing(spec) X = pd.DataFrame({"f0": np.linspace(0.0, 1.0, 60)}) @@ -216,9 +214,7 @@ def test_required_target_methods_raise_without_y_via_preprocessor(name, spec): _MULTIVARIATE_NUMERICAL = [(name, spec) for name, spec in _SPEC_ITEMS if spec.is_numerical and spec.is_multivariate] -@pytest.mark.parametrize( - "name, spec", _MULTIVARIATE_NUMERICAL, ids=[name for name, _ in _MULTIVARIATE_NUMERICAL] -) +@pytest.mark.parametrize("name, spec", _MULTIVARIATE_NUMERICAL, ids=[name for name, _ in _MULTIVARIATE_NUMERICAL]) def test_multivariate_methods_not_preprocessor_selectable(name, spec): # The multivariate tensor-product / thin-plate splines are standalone-only and # deliberately excluded from the per-column Preprocessor whitelist; selecting diff --git a/tests/core/test_cross_fitted.py b/tests/core/test_cross_fitted.py index b7cfd09..936d629 100644 --- a/tests/core/test_cross_fitted.py +++ b/tests/core/test_cross_fitted.py @@ -84,9 +84,7 @@ def test_feature_names_delegate(data): cf = CrossFittedTransformer(PLETransformer(output_dim=6), n_folds=3, random_state=0) cf.fit(X, y) reference = PLETransformer(output_dim=6).fit(X, y) - np.testing.assert_array_equal( - cf.get_feature_names_out(["f0"]), reference.get_feature_names_out(["f0"]) - ) + np.testing.assert_array_equal(cf.get_feature_names_out(["f0"]), reference.get_feature_names_out(["f0"])) def test_requires_y(data): diff --git a/tests/extension/test_registration_discovery.py b/tests/extension/test_registration_discovery.py index 55126e9..f92a526 100644 --- a/tests/extension/test_registration_discovery.py +++ b/tests/extension/test_registration_discovery.py @@ -140,9 +140,7 @@ class _FakeEntryPoint: def load(self): return _EpRep - monkeypatch.setattr( - importlib_metadata, "entry_points", lambda group=None: [_FakeEntryPoint()] - ) + monkeypatch.setattr(importlib_metadata, "entry_points", lambda group=None: [_FakeEntryPoint()]) loaded = load_entry_point_representations() assert loaded == ["ep_square"] assert "ep_square" in registry.TRANSFORMER_REGISTRY @@ -155,9 +153,7 @@ class _BrokenEntryPoint: def load(self): raise ImportError("boom") - monkeypatch.setattr( - importlib_metadata, "entry_points", lambda group=None: [_BrokenEntryPoint()] - ) + monkeypatch.setattr(importlib_metadata, "entry_points", lambda group=None: [_BrokenEntryPoint()]) with pytest.warns(ConfigWarning, match="broken_entry"): loaded = load_entry_point_representations() assert loaded == [] diff --git a/tests/integration/test_edge_case_contract.py b/tests/integration/test_edge_case_contract.py index d03c1ec..97e8b07 100644 --- a/tests/integration/test_edge_case_contract.py +++ b/tests/integration/test_edge_case_contract.py @@ -113,6 +113,7 @@ def test_constant_column_raises_typed_error(name, rng): with pytest.raises(PretabDataError, match="constant"): _factory(name).fit(X, y) + @pytest.mark.parametrize("name", CONSTANT_GRACEFUL) def test_constant_column_degrades_gracefully(name, rng): X = np.full((40, 1), 3.14) @@ -238,18 +239,14 @@ def test_preprocessor_policy_errors_on_constant(rng): df = _frame_with_constant(rng) y = rng.normal(size=60) with pytest.raises(PretabDataError): - Preprocessor( - numerical_method="standardization", policy={"constant": "error"} - ).fit(df, y) + Preprocessor(numerical_method="standardization", policy={"constant": "error"}).fit(df, y) def test_preprocessor_policy_warns_on_constant(rng): df = _frame_with_constant(rng) y = rng.normal(size=60) with pytest.warns(DataWarning): - Preprocessor( - numerical_method="standardization", policy={"constant": "warn"} - ).fit(df, y) + Preprocessor(numerical_method="standardization", policy={"constant": "warn"}).fit(df, y) def test_preprocessor_stores_resolved_policy(rng): diff --git a/tests/integration/test_fingerprint.py b/tests/integration/test_fingerprint.py index 3f1e6ab..f381464 100644 --- a/tests/integration/test_fingerprint.py +++ b/tests/integration/test_fingerprint.py @@ -85,6 +85,7 @@ def test_fingerprint_stable_across_processes(frame, target): print(p.fingerprint_) """ ) + def _run(): result = subprocess.run( # noqa: S603 - fixed interpreter + inline script, no untrusted input [sys.executable, "-c", script], capture_output=True, text=True, check=True diff --git a/tests/integration/test_missing_policy.py b/tests/integration/test_missing_policy.py index 3bb2b19..6c4a89f 100644 --- a/tests/integration/test_missing_policy.py +++ b/tests/integration/test_missing_policy.py @@ -113,9 +113,7 @@ def test_impute_adds_no_indicator(frame_with_nan, y): def test_impute_with_indicator_appends_columns(frame_with_nan, y): plain = Preprocessor(numerical_method="minmax", missing_policy="impute").fit(frame_with_nan, y) - withind = Preprocessor( - numerical_method="minmax", missing_policy="impute_with_indicator" - ).fit(frame_with_nan, y) + withind = Preprocessor(numerical_method="minmax", missing_policy="impute_with_indicator").fit(frame_with_nan, y) assert withind.total_output_dim_ > plain.total_output_dim_ out = withind.transform(frame_with_nan, return_array=True) assert not np.isnan(out).any() @@ -150,9 +148,7 @@ def test_separate_state_indicator_marks_missing_rows(frame_with_nan, y): def test_separate_state_on_categorical(y): frame = pd.DataFrame({"c": ["x", "y", None, "x", "y", "x"]}) - p = Preprocessor( - categorical_method="one-hot", missing_policy="separate_state" - ).fit(frame, y) + p = Preprocessor(categorical_method="one-hot", missing_policy="separate_state").fit(frame, y) names = list(p.get_feature_names_out()) assert any(n.endswith("__missing") for n in names) diff --git a/tests/placement/test_placement.py b/tests/placement/test_placement.py index d90fba0..e1c629d 100644 --- a/tests/placement/test_placement.py +++ b/tests/placement/test_placement.py @@ -151,9 +151,7 @@ def test_factory_builds_each_strategy(): ) def test_factory_rejects_invalid_combo(target_aware, strategy): with pytest.raises(InvalidParamError): - create_placement_strategy( - target_aware=target_aware, placement_strategy=strategy, min_count=1, max_count=5 - ) + create_placement_strategy(target_aware=target_aware, placement_strategy=strategy, min_count=1, max_count=5) def test_strategies_are_base_instances(): diff --git a/tests/placement/test_spline_placement_adapter.py b/tests/placement/test_spline_placement_adapter.py index eb7e0c7..5d24fa1 100644 --- a/tests/placement/test_spline_placement_adapter.py +++ b/tests/placement/test_spline_placement_adapter.py @@ -14,9 +14,7 @@ def data(): def test_basis_to_knots_conversion(): - adapter = SplinePlacementAdapter( - placement_strategy="cart", degree=3, min_basis_functions=2, max_basis_functions=10 - ) + adapter = SplinePlacementAdapter(placement_strategy="cart", degree=3, min_basis_functions=2, max_basis_functions=10) assert adapter.max_knots == 10 - 3 - 1 assert adapter.min_knots == 0 @@ -34,9 +32,7 @@ def test_cart_returns_sorted_knots_in_range(data): def test_cart_respects_max_knots(data): X, y = data - adapter = SplinePlacementAdapter( - placement_strategy="cart", min_basis_functions=6, max_basis_functions=8, degree=3 - ) + adapter = SplinePlacementAdapter(placement_strategy="cart", min_basis_functions=6, max_basis_functions=8, degree=3) knots = adapter.get_knot_locations(X, y) assert len(knots) <= adapter.max_knots @@ -67,9 +63,7 @@ def test_cart_classification_task(): rng = np.random.RandomState(2) X = rng.rand(200, 1) y = (X[:, 0] > 0.5).astype(int) - knots = SplinePlacementAdapter(placement_strategy="cart", degree=3).get_knot_locations( - X, y, task="classification" - ) + knots = SplinePlacementAdapter(placement_strategy="cart", degree=3).get_knot_locations(X, y, task="classification") assert knots.ndim == 1 diff --git a/tests/transformers/test_periodic.py b/tests/transformers/test_periodic.py index 333f538..70a155e 100644 --- a/tests/transformers/test_periodic.py +++ b/tests/transformers/test_periodic.py @@ -71,4 +71,3 @@ def test_cyclic_rejects_non_positive_harmonics(): X = np.array([[0], [6], [12], [18]]) with pytest.raises(InvalidParamError): PeriodicEncodingTransformer(period=24, harmonics=0).fit(X) - From 90a4ef2c846629dd73061801b7067169ca4def53 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 7 Aug 2026 09:42:04 +0200 Subject: [PATCH 31/59] docs: rewrite README for the 1.0 API --- README.md | 188 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 132 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index cd0eada..131abf1 100644 --- a/README.md +++ b/README.md @@ -11,20 +11,25 @@ [📘 Documentation](https://pretab.readthedocs.io) | [🚀 Getting Started](https://pretab.readthedocs.io/en/latest/getting_started/quickstart.html) | -[📖 User Guide](https://pretab.readthedocs.io/en/latest/user_guide/preprocessing.html) | +[📖 Representations](https://pretab.readthedocs.io/en/latest/representations/overview.html) | [🤔 Report Issues](https://github.com/OpenTabular/PreTab/issues) # PreTab: Tabular Preprocessing Made Simple -**PreTab** is a modular, scikit-learn compatible preprocessing library for tabular data. A -single `Preprocessor` detects numerical and categorical columns and turns them into -model-ready features, and every strategy is also available as a standalone transformer: -splines, neural basis expansions, piecewise-linear encoding, binning, language embeddings, -and temporal features. Because it speaks the sklearn API, PreTab drops straight into -`Pipeline` and `ColumnTransformer` workflows and accepts any sklearn transformer alongside -its own. +**PreTab** is a modular, scikit-learn compatible representation and preprocessing library +for tabular data. A single `Preprocessor` detects numerical and categorical columns and +turns them into model-ready features. Every strategy it uses (splines, neural basis +expansions, piecewise-linear encoding, binning, kernel approximations, and language +embeddings) is also available as a standalone transformer. Because it speaks the sklearn +API, PreTab drops straight into `Pipeline` and `ColumnTransformer` workflows and accepts any +sklearn transformer alongside its own. + +Beyond the transformers themselves, every fitted representation is self-describing: it +reports per-output-column lineage, guards supervised methods against leakage, serializes to +a portable versioned spec, and can be extended with your own representations through a +public, discoverable protocol. ## Why PreTab? @@ -33,12 +38,18 @@ its own. inputs. - **Automatic feature handling.** Feature-type detection and per-feature strategies let you describe intent once instead of wiring transformers by hand. -- **Beyond scaling.** Spline bases, neural basis maps, and piecewise-linear encoding turn - raw numerical columns into expressive representations. -- **Categoricals done right.** Ordinal and one-hot encoding, pretrained language - embeddings, and custom binning cover both low- and high-cardinality columns. +- **Beyond scaling.** Spline bases, neural basis maps, piecewise-linear encoding, and + kernel approximations turn raw numerical columns into expressive representations. +- **Categoricals done right.** Ordinal and one-hot encoding and pretrained language + embeddings cover both low- and high-cardinality columns. +- **Self-describing and reproducible.** Every fit produces per-column feature lineage and + serializes to a portable spec with a stable fingerprint, so you always know what a fitted + preprocessor does and can reproduce it exactly. +- **Leakage-safe by default.** Supervised representations declare their target usage and + warn when fit outside a controlled context, with a cross-fitting wrapper for out-of-fold + training features. - **Composable and extensible.** Every strategy is a standalone transformer you can import, - compose, or subclass, and any sklearn transformer works out of the box. + compose, or subclass; register your own representation and it behaves like a built-in. ## 🏃 Quickstart @@ -76,49 +87,50 @@ print({k: v.shape for k, v in X.items()}) ## Available Transformers -PreTab groups its transformers into four families. Each one follows the standard `fit` / +PreTab groups its transformers into three families. Each one follows the standard `fit` / `transform` API and is importable from `pretab.transformers`. ### Splines -| Transformer | Basis | Best for | -| -------------------------------- | ---------------------------- | ------------------------------------ | -| `CubicSplineTransformer` | B-spline basis | Smooth non-linear numerical effects | -| `NaturalCubicSplineTransformer` | Natural cubic spline | Smooth effects with linear tails | -| `PSplineTransformer` | Penalized B-spline | Smoothness with a penalty matrix | -| `TensorProductSplineTransformer` | Tensor-product spline | Interactions between two features | -| `ThinPlateSplineTransformer` | Thin-plate regression spline | Smooth multivariate surfaces | +| Transformer | Basis | Best for | +| ----------------------------------- | -------------------------------------- | ---------------------------------------- | +| `BSplineTransformer` | B-spline basis | General-purpose smooth nonlinearity | +| `MSplineTransformer` | Non-negative B-spline basis | Density-like, non-negative bases | +| `ISplineTransformer` | Monotone integrated spline | Effects that must not reverse | +| `CubicRegressionSplineTransformer` | Cubic regression spline | GAM-style additive smooth terms | +| `NaturalCubicSplineTransformer` | Natural cubic spline | Smooth effects with linear tails | +| `PSplineTransformer` | Penalized B-spline | Smoothness via a difference penalty | +| `TensorProductSplineTransformer` | Tensor-product spline (multivariate) | Smooth interactions across 2+ features | +| `ThinPlateSplineTransformer` | Thin-plate spline (multivariate) | Smooth surfaces across 2+ features | ### Feature maps -| Transformer | Basis | Best for | -| ----------------------------- | ---------------------- | ----------------------------------- | -| `RBFExpansionTransformer` | Radial basis functions | Localized, kernel-like features | -| `ReLUExpansionTransformer` | ReLU basis | Piecewise-linear neural features | -| `SigmoidExpansionTransformer` | Sigmoid basis | Smooth saturating features | -| `TanhExpansionTransformer` | Tanh basis | Zero-centered saturating features | +| Transformer | Basis | Best for | +| ------------------------------------ | ------------------------------------------ | ---------------------------------------- | +| `RBFExpansionTransformer` | Radial basis functions | Localized, kernel-like features | +| `ReLUExpansionTransformer` | ReLU basis | Piecewise-linear neural features | +| `SigmoidExpansionTransformer` | Sigmoid basis | Smooth saturating features | +| `TanhExpansionTransformer` | Tanh basis | Zero-centered saturating features | +| `FourierFeatureTransformer` | Sine/cosine basis | Periodic or cyclic numerical effects | +| `RandomFourierFeaturesTransformer` | Random Fourier features (multivariate) | Scalable RBF-kernel approximation | +| `NystroemFeaturesTransformer` | Nystroem kernel map (multivariate) | Landmark-based kernel approximation | ### Encoding and binning -| Transformer | Method | Best for | -| ------------------------------ | -------------------------------------- | ------------------------------------- | -| `PLETransformer` | Piecewise linear encoding (supervised) | Strong numerical encoding for models | -| `CustomBinTransformer` | Rule- or tree-based binning | Discretizing numerical or code values | -| `OneHotFromOrdinalTransformer` | One-hot from ordinal codes | One-hot on pre-encoded categoricals | -| `LanguageEmbeddingTransformer` | Pretrained language embeddings | High-cardinality, semantic columns | - -### Temporal +| Transformer | Method | Best for | +| ------------------------------- | ------------------------------------------ | ---------------------------------------- | +| `PLETransformer` | Piecewise-linear encoding (supervised) | Strong numerical encoding for models | +| `NumericBinningTransformer` | Uniform/quantile binning, tree-driven | Discretizing numerical columns | +| `ContinuousOrdinalTransformer` | Integer (ordinal) encoding | Compact codes for categoricals | +| `LanguageEmbeddingTransformer` | Pretrained language embeddings | High-cardinality, semantic columns | -| Transformer | Method | Best for | -| ------------------------- | ------------------------- | ----------------------------------- | -| `CyclicalTimeTransformer` | Sine/cosine encoding | Hour, day, month and cyclic fields | -| `LagFeatureTransformer` | Lagged values | Time-series lag features | -| `RollingStatsTransformer` | Rolling window statistics | Moving averages and rolling summary | +> **Deprecated.** `OneHotFromOrdinalTransformer` still works but is deprecated; use +> `categorical_method="one-hot"` (backed by `sklearn.preprocessing.OneHotEncoder`) instead. > **Strategy strings.** Inside the `Preprocessor` you select these by short name (for -> example `"ple"`, `"rbf"`, `"one-hot"`, `"pretrained"`). See the -> [User Guide](https://pretab.readthedocs.io/en/latest/user_guide/preprocessing.html) for -> the full list. +> example `"ple"`, `"rbf"`, `"one-hot"`, `"pretrained"`). See +> [Representations](https://pretab.readthedocs.io/en/latest/representations/overview.html) for +> the full catalogue and [comparison table](https://pretab.readthedocs.io/en/latest/representations/comparison_table.html). ## 📚 Documentation @@ -127,9 +139,11 @@ PreTab groups its transformers into four families. Each one follows the standard ### Quick Links - **[Getting Started](https://pretab.readthedocs.io/en/latest/getting_started/installation.html)**: Installation and quickstart -- **[User Guide](https://pretab.readthedocs.io/en/latest/user_guide/preprocessing.html)**: Feature detection, strategies, and outputs +- **[Core Concepts](https://pretab.readthedocs.io/en/latest/core_concepts/feature_representation.html)**: Configuration, resolution, target awareness, reproducibility +- **[Representations](https://pretab.readthedocs.io/en/latest/representations/overview.html)**: The full method catalogue and how to choose one +- **[Tutorials](https://pretab.readthedocs.io/en/latest/tutorials/nonlinear_regression.html)**: Worked, end-to-end examples - **[API Reference](https://pretab.readthedocs.io/en/latest/api/index.html)**: The `Preprocessor` and every transformer -- **[Developer Guide](https://pretab.readthedocs.io/en/latest/developer_guide/contributing.html)**: Contributing, versioning, and releases +- **[Developer Guide](https://pretab.readthedocs.io/en/latest/developer_guide/contributing.html)**: Contributing, testing, and releases ## 🛠️ Installation @@ -139,14 +153,16 @@ PreTab groups its transformers into four families. Each one follows the standard pip install pretab ``` -**With language-embedding support:** +**With optional extras:** ```bash -pip install "pretab[embeddings]" # adds sentence-transformers +pip install "pretab[embeddings]" # adds sentence-transformers, for the `pretrained` strategy +pip install "pretab[lightgbm]" # adds lightgbm, for placement_strategy="lightgbm" +pip install "pretab[all]" # both of the above ``` -> **Lightweight by default.** The `embeddings` extra pulls in `sentence-transformers` and -> PyTorch, so install it only if you use the `pretrained` categorical strategy. +> **Lightweight by default.** The core install has no heavy dependencies. Each extra is +> opt-in and only needed if you use the corresponding strategy. > **Requirements:** Python 3.10 to 3.13. @@ -247,13 +263,13 @@ Spline transformers expose their penalty matrix for penalized (smoothing) models ```python import numpy as np -from pretab.transformers import ThinPlateSplineTransformer +from pretab.transformers import NaturalCubicSplineTransformer x = np.random.randn(100, 1) -tp = ThinPlateSplineTransformer(output_dim=15) -x_tp = tp.fit_transform(x) -penalty = tp.get_penalty_matrix() # (output_dim, output_dim) smoothing penalty +spline = NaturalCubicSplineTransformer(output_dim=10) +x_spline = spline.fit_transform(x) +penalty = spline.get_penalty_matrix() # (output_dim, output_dim) smoothing penalty ``` ## Advanced Features @@ -286,10 +302,10 @@ preprocessor = Preprocessor( > **Optional dependency.** Install with `pip install "pretab[embeddings]"` before using the > `pretrained` strategy. -### Custom binning +### Numeric binning -`CustomBinTransformer` supports both rule-based edges and tree-based bins learned from the -target. +`NumericBinningTransformer` (selected as `"custombin"`) discretizes a numerical column into +uniformly- or quantile-spaced bins, with `ordinal`, `onehot`, or `soft` output encodings. ```python preprocessor = Preprocessor( @@ -298,6 +314,66 @@ preprocessor = Preprocessor( ) ``` +### Feature lineage and inspection + +Every fitted `Preprocessor` can explain itself. `get_feature_info` summarizes the resolved +per-column pipeline, and `get_feature_lineage` maps every output column back to its source +feature, representation family, and component. + +```python +preprocessor.get_feature_info(verbose=True) # resolved strategies, widths, categories +lineage = preprocessor.get_feature_lineage() # one record per output column +``` + +### Leakage-safe supervised representations + +Methods like `PLETransformer` place their bins using the target. PreTab warns when a +supervised transformer is fit outside a `Pipeline` or cross-validation context, and ships a +cross-fitting wrapper that produces out-of-fold training features. + +```python +from pretab import CrossFittedTransformer +from pretab.transformers import PLETransformer + +cf = CrossFittedTransformer(PLETransformer(), n_folds=5) +X_train_features = cf.fit_transform(x_train, y_train) # out-of-fold, leakage-free +``` + +### Serialization and reproducibility + +A fitted preprocessor serializes to a portable, versioned JSON spec, a safer alternative to +`pickle` that never executes arbitrary code on load, and reports a stable fingerprint for +tracking exactly what was fitted. + +```python +preprocessor.to_spec("representation.json") +restored = Preprocessor.from_spec("representation.json") + +preprocessor.fingerprint_ # stable sha256 hash of the fitted representation +``` + +### Extending PreTab + +Add your own representation by subclassing `BaseRepresentation`, then register it so it +behaves like a built-in, selectable via `Preprocessor(numerical_method=...)`. + +```python +from pretab import BaseRepresentation, register_representation + +class MyRepresentation(BaseRepresentation): + representation_name = "my_representation" + feature_kind = "numerical" + scope = "univariate" + supervision = "unsupervised" + # implement fit / transform / _output_sizes + +register_representation("my_representation", MyRepresentation) +``` + +> **Full walkthrough.** See the +> [custom representation tutorial](https://pretab.readthedocs.io/en/latest/tutorials/custom_representation.html) +> for a complete, runnable example. + ## 📄 License PreTab is licensed under the MIT License. See [LICENSE](./LICENSE) for details. From 40fc03970133cceeb2a40954e78a36f41fef1c58 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 7 Aug 2026 09:50:28 +0200 Subject: [PATCH 32/59] docs: tighten README callouts and tone --- README.md | 55 +++++++++++++++++++++++++------------------------------ 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 131abf1..1f64a86 100644 --- a/README.md +++ b/README.md @@ -75,15 +75,12 @@ print({k: v.shape for k, v in X.items()}) # {'num_age': (100, 7), 'num_income': (100, 7), 'cat_city': (100, 1)} ``` -> **That's it.** PreTab detects feature types, fits a strategy per column, and returns -> ready-to-use arrays. +> **Note:** PreTab accepts a `pandas.DataFrame` or a `numpy.ndarray` and infers numerical +> versus categorical columns either way. -> **Works with pandas and numpy.** Pass a DataFrame or an array, and PreTab infers -> numerical vs. categorical columns for you. - -> **Mix strategies per column.** Swap the global methods for a `feature_preprocessing` map, -> for example `{"age": "ple", "income": "rbf", "city": "one-hot"}`, and PreTab fits each -> column with its own strategy in a single pass. See [Usage](#usage) for a full example. +> **Tip:** Swap the global methods for a `feature_preprocessing` map, for example +> `{"age": "ple", "income": "rbf", "city": "one-hot"}`, and PreTab fits each column with its +> own strategy in a single pass. See [Usage](#usage) for a full example. ## Available Transformers @@ -124,11 +121,11 @@ PreTab groups its transformers into three families. Each one follows the standar | `ContinuousOrdinalTransformer` | Integer (ordinal) encoding | Compact codes for categoricals | | `LanguageEmbeddingTransformer` | Pretrained language embeddings | High-cardinality, semantic columns | -> **Deprecated.** `OneHotFromOrdinalTransformer` still works but is deprecated; use +> **Warning:** `OneHotFromOrdinalTransformer` is deprecated. Use > `categorical_method="one-hot"` (backed by `sklearn.preprocessing.OneHotEncoder`) instead. -> **Strategy strings.** Inside the `Preprocessor` you select these by short name (for -> example `"ple"`, `"rbf"`, `"one-hot"`, `"pretrained"`). See +> **Note:** Inside the `Preprocessor` you select these by short name, for example `"ple"`, +> `"rbf"`, `"one-hot"`, `"pretrained"`. See > [Representations](https://pretab.readthedocs.io/en/latest/representations/overview.html) for > the full catalogue and [comparison table](https://pretab.readthedocs.io/en/latest/representations/comparison_table.html). @@ -161,10 +158,8 @@ pip install "pretab[lightgbm]" # adds lightgbm, for placement_strategy="ligh pip install "pretab[all]" # both of the above ``` -> **Lightweight by default.** The core install has no heavy dependencies. Each extra is -> opt-in and only needed if you use the corresponding strategy. - -> **Requirements:** Python 3.10 to 3.13. +> **Note:** The core install has no heavy dependencies. Each extra is opt-in and only +> needed if you use the corresponding strategy. PreTab requires Python 3.10 to 3.13. **From source:** @@ -213,8 +208,8 @@ experience numerical imputer -> minmax -> quantile 1 - city categorical imputer -> onehot -> to_float 4 4 ``` -> **Two output formats.** `transform` returns a dict of feature blocks by default (keys -> prefixed `num_` and `cat_`), or a single stacked array when you pass `return_array=True`. +> **Note:** `transform` returns a dict of feature blocks by default (keys prefixed `num_` +> and `cat_`), or a single stacked array when you pass `return_array=True`. ### Standalone transformers @@ -232,8 +227,8 @@ x_ple = PLETransformer(output_dim=15, task="regression").fit_transform(x, y) assert x_ple.shape[1] == 15 ``` -> **Some transformers are supervised.** `PLETransformer` uses the target `y` during `fit` -> to place its bin edges, so pass `y` whenever you fit it. +> **Important:** `PLETransformer` is supervised. It uses the target `y` during `fit` to +> place its bin edges and raises if you omit it, so always pass `y` when fitting it directly. ### Inside an sklearn Pipeline @@ -299,8 +294,8 @@ preprocessor = Preprocessor( ) ``` -> **Optional dependency.** Install with `pip install "pretab[embeddings]"` before using the -> `pretrained` strategy. +> **Note:** Install with `pip install "pretab[embeddings]"` before using the `pretrained` +> strategy. ### Numeric binning @@ -339,6 +334,11 @@ cf = CrossFittedTransformer(PLETransformer(), n_folds=5) X_train_features = cf.fit_transform(x_train, y_train) # out-of-fold, leakage-free ``` +> **Warning:** Fitting a supervised transformer on the same rows you later evaluate on +> leaks target information into the features. `CrossFittedTransformer` removes that leakage +> from the training features themselves; inside a `Pipeline`, cross-validation already +> keeps each fold's fit confined to its training data. + ### Serialization and reproducibility A fitted preprocessor serializes to a portable, versioned JSON spec, a safer alternative to @@ -370,7 +370,7 @@ class MyRepresentation(BaseRepresentation): register_representation("my_representation", MyRepresentation) ``` -> **Full walkthrough.** See the +> **Tip:** See the > [custom representation tutorial](https://pretab.readthedocs.io/en/latest/tutorials/custom_representation.html) > for a complete, runnable example. @@ -381,17 +381,12 @@ PreTab is licensed under the MIT License. See [LICENSE](./LICENSE) for details. ## 🤝 Contributing Contributions are welcome, whether you are fixing bugs, adding transformers, or improving -the docs. See the +the docs. Clone the repository and install it in editable mode as shown in the Installation +section above, then see the [Contributing Guide](https://pretab.readthedocs.io/en/latest/developer_guide/contributing.html) -to get started, and please follow our +and our [Code of Conduct](https://github.com/OpenTabular/PreTab/blob/main/CODE_OF_CONDUCT.md). -```bash -git clone https://github.com/OpenTabular/PreTab -cd PreTab -pip install -e ".[dev]" -``` - ## 📞 Support - **Issues:** [GitHub Issues](https://github.com/OpenTabular/PreTab/issues) From d300d3bbacf67a2d56acab0bd1cd26d8ef31374b Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Wed, 12 Aug 2026 19:52:01 +0200 Subject: [PATCH 33/59] refactor(core): rename typing module to _typing and add estimator protocols --- pretab/core/{typing.py => _typing.py} | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) rename pretab/core/{typing.py => _typing.py} (60%) diff --git a/pretab/core/typing.py b/pretab/core/_typing.py similarity index 60% rename from pretab/core/typing.py rename to pretab/core/_typing.py index 1d451d3..5572462 100644 --- a/pretab/core/typing.py +++ b/pretab/core/_typing.py @@ -6,7 +6,7 @@ from __future__ import annotations -from typing import Literal +from typing import Any, Literal, Protocol import numpy as np import pandas as pd @@ -23,9 +23,27 @@ # Supervised-task discriminator used by the supervised placement selectors. Task = Literal["regression", "classification"] + +class TransformerLike(Protocol): + """Minimal duck-typed transformer interface used by internal wrappers.""" + + def fit(self, X: Any, y: Any = ...) -> Any: ... + def transform(self, X: Any) -> Any: ... + def get_feature_names_out(self, input_features: Any = ...) -> Any: ... + + +class PredictorLike(Protocol): + """Minimal duck-typed supervised-estimator interface (fit + predict).""" + + def fit(self, X: Any, y: Any = ...) -> Any: ... + def predict(self, X: Any) -> Any: ... + + __all__ = [ "ArrayLike", "PlacementStrategyName", + "PredictorLike", "TargetLike", "Task", + "TransformerLike", ] From 6d4823e44f501fc1ad8575a531b5d057edfd4877 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Wed, 12 Aug 2026 19:52:01 +0200 Subject: [PATCH 34/59] fix(types): annotate cloned estimators in cross-fitting and search --- pretab/compose/search.py | 16 ++++++++++------ pretab/core/supervised.py | 11 +++++++---- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/pretab/compose/search.py b/pretab/compose/search.py index 12a732c..b3224d6 100644 --- a/pretab/compose/search.py +++ b/pretab/compose/search.py @@ -8,12 +8,16 @@ native array output so no target leaks across the train/validation split. """ +from collections.abc import Callable +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 check_cv +from sklearn.model_selection import BaseCrossValidator, check_cv from sklearn.utils.validation import check_is_fitted +from ..core._typing import PredictorLike from ..exceptions import InvalidParamError from ..preprocessor import Preprocessor @@ -84,7 +88,7 @@ def fit(self, X, y=None): raise InvalidParamError("RepresentationSearchCV requires y at fit time; got y=None.") y_arr = np.asarray(y).ravel() n_samples = X.shape[0] if hasattr(X, "shape") else len(X) - cv = check_cv(self.cv, y_arr, classifier=is_classifier(self.estimator)) + cv = cast(BaseCrossValidator, check_cv(self.cv, y_arr, classifier=is_classifier(self.estimator))) cv_results: dict[str, float] = {} best_score = -np.inf @@ -93,10 +97,10 @@ def fit(self, X, y=None): fold_scores = [] for train_idx, test_idx in cv.split(np.zeros(n_samples), y_arr): pre = self._make_preprocessor(method) - est = clone(self.estimator) + 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]) - scorer = check_scoring(est, scoring=self.scoring) + 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])) mean_score = float(np.mean(fold_scores)) @@ -110,7 +114,7 @@ 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_ = clone(self.estimator).fit(x_all, y_arr) + self.best_estimator_ = cast(PredictorLike, clone(self.estimator)).fit(x_all, y_arr) return self def predict(self, X): @@ -123,5 +127,5 @@ 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) - scorer = check_scoring(self.best_estimator_, scoring=self.scoring) + scorer = cast("Callable[..., float]", check_scoring(self.best_estimator_, scoring=self.scoring)) return scorer(self.best_estimator_, x, np.asarray(y).ravel()) diff --git a/pretab/core/supervised.py b/pretab/core/supervised.py index b4edf64..71f043e 100644 --- a/pretab/core/supervised.py +++ b/pretab/core/supervised.py @@ -16,6 +16,7 @@ import sys import warnings from dataclasses import replace +from typing import cast import numpy as np from sklearn.base import BaseEstimator, TransformerMixin, clone @@ -28,6 +29,7 @@ LeakageWarning, PretabDataError, ) +from ._typing import TransformerLike from .representation import RepresentationSpecMixin __all__ = ["CrossFittedTransformer", "in_controlled_context", "warn_target_leakage"] @@ -143,7 +145,7 @@ def _fit_full(self, X, y): y_arr = np.asarray(y).ravel() if len(X_arr) != len(y_arr): raise PretabDataError(f"X and y must have same length. Got {len(X_arr)} and {len(y_arr)}") - estimator = clone(self.transformer) + estimator = cast(TransformerLike, clone(self.transformer)) token = _cross_fit_active.set(True) try: estimator.fit(X_arr, y_arr) @@ -175,7 +177,7 @@ def fit_transform(self, X, y=None): token = _cross_fit_active.set(True) try: for train_idx, test_idx in splitter.split(X_arr, y_arr): - fold = clone(self.transformer) + fold = cast(TransformerLike, clone(self.transformer)) fold.fit(X_arr[train_idx], y_arr[train_idx]) fold_out = np.asarray(fold.transform(X_arr[test_idx])) if fold_out.shape[1] != width: @@ -197,8 +199,9 @@ def get_feature_names_out(self, input_features=None): def get_representation_spec(self, input_features=None): """Return the wrapped spec, flagged as cross-fitted.""" check_is_fitted(self, "estimator_") - if hasattr(self.estimator_, "get_representation_spec"): - base = self.estimator_.get_representation_spec(input_features) + spec_fn = getattr(self.estimator_, "get_representation_spec", None) + if spec_fn is not None: + base = spec_fn(input_features) return replace(base, uses_target=True, cross_fitted=True, n_folds=int(self.n_folds)) return super().get_representation_spec(input_features) From 1837813388fc079d64fdbfc6744eabaf4af9a473 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Wed, 12 Aug 2026 19:52:01 +0200 Subject: [PATCH 35/59] fix(types): resolve remaining type errors across pretab --- pretab/compose/factory.py | 2 +- pretab/compose/feature_detection.py | 2 +- pretab/compose/output.py | 4 ++-- pretab/compose/serialize.py | 5 +++-- pretab/core/representation.py | 7 +++++++ pretab/core/validation.py | 2 +- pretab/preprocessor.py | 9 +++++---- pretab/transformers/categorical/language_embedding.py | 2 +- pretab/transformers/feature_maps/kernel_approx.py | 2 +- pretab/transformers/numerical/piecewise.py | 4 ++-- pretab/transformers/splines/cubic_regression.py | 2 +- pretab/transformers/splines/multivariate/thin_plate.py | 2 +- pretab/transformers/splines/natural_cubic.py | 2 +- 13 files changed, 27 insertions(+), 18 deletions(-) diff --git a/pretab/compose/factory.py b/pretab/compose/factory.py index 065db86..2d3fd8b 100644 --- a/pretab/compose/factory.py +++ b/pretab/compose/factory.py @@ -219,7 +219,7 @@ def get_categorical_transformer_steps( return steps -def create_transformer(method: str, *, is_numerical: bool, config: PreprocessorConfig) -> Pipeline: +def create_transformer(method: str, *, is_numerical: bool, config: PreprocessorConfig) -> Pipeline | FeatureUnion: """Build the per-column :class:`~sklearn.pipeline.Pipeline` for one feature. ``method`` is the resolved method name; ``is_numerical`` selects the numerical diff --git a/pretab/compose/feature_detection.py b/pretab/compose/feature_detection.py index 096ef72..86d5e7d 100644 --- a/pretab/compose/feature_detection.py +++ b/pretab/compose/feature_detection.py @@ -22,7 +22,7 @@ def to_dataframe(X, *, copy: bool = False) -> pd.DataFrame: if isinstance(X, dict): return pd.DataFrame(X) if isinstance(X, np.ndarray): - return pd.DataFrame(X, columns=[f"feature_{i}" for i in range(X.shape[1])]) + return pd.DataFrame(X, columns=pd.Index([f"feature_{i}" for i in range(X.shape[1])])) return X.copy() if copy else X diff --git a/pretab/compose/output.py b/pretab/compose/output.py index 1f724c9..813fcf9 100644 --- a/pretab/compose/output.py +++ b/pretab/compose/output.py @@ -105,9 +105,9 @@ def to_dataframe_output(array, columns, container): if container == "pandas": import pandas as pd - return pd.DataFrame(array, columns=columns) + return pd.DataFrame(array, columns=pd.Index(columns)) try: - import polars as pl + import polars as pl # type: ignore except ImportError as exc: # pragma: no cover - exercised only without polars raise OptionalDependencyError( "set_output(transform='polars') requires the optional 'polars' package. " diff --git a/pretab/compose/serialize.py b/pretab/compose/serialize.py index ba8d3fe..6c9d07b 100644 --- a/pretab/compose/serialize.py +++ b/pretab/compose/serialize.py @@ -17,6 +17,7 @@ import dataclasses import importlib +from typing import Any, cast import numpy as np from sklearn.base import BaseEstimator @@ -151,14 +152,14 @@ def _decode_mapping(mapping: dict) -> dict: def _decode_estimator(payload: dict): - cls = _resolve(payload["class"]) + cls = cast(Any, _resolve(payload["class"])) obj = cls.__new__(cls) obj.__dict__.update(_decode_mapping(payload["state"])) return obj def _decode_dataclass(payload: dict): - cls = _resolve(payload["class"]) + cls = cast(Any, _resolve(payload["class"])) fields = {k: _decode(v) for k, v in payload["fields"].items()} return cls(**fields) diff --git a/pretab/core/representation.py b/pretab/core/representation.py index 7eb77c7..0c2676b 100644 --- a/pretab/core/representation.py +++ b/pretab/core/representation.py @@ -10,6 +10,7 @@ """ from dataclasses import dataclass +from typing import TYPE_CHECKING, Any import numpy as np from sklearn.utils.validation import check_is_fitted @@ -214,6 +215,12 @@ class RepresentationSpecMixin: methods (or ``get_representation_spec`` itself). """ + if TYPE_CHECKING: + # Attributes provided by the concrete estimator this mixin is combined with. + n_features_in_: int + + def get_feature_names_out(self, input_features: Any = ...) -> Any: ... + _representation_family: str = "unknown" _representation_component_kind: str = "basis" _representation_scope: str = "univariate" diff --git a/pretab/core/validation.py b/pretab/core/validation.py index c49d8b8..fbe7ea7 100644 --- a/pretab/core/validation.py +++ b/pretab/core/validation.py @@ -45,7 +45,7 @@ def validate_2d_allow_nan(X, *, allow_nan: bool = True, reset: bool, estimator): ensure_all_finite: Literal["allow-nan"] | bool = "allow-nan" if allow_nan else True X = check_array( X, - dtype=np.float64, + dtype=np.float64, # type: ignore ensure_2d=True, ensure_all_finite=ensure_all_finite, # type: ignore ) diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index 7ad5133..97d0696 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -4,6 +4,7 @@ import os import time import warnings +from typing import cast import numpy as np from scipy import sparse as sp @@ -337,8 +338,8 @@ def __init__( scaling="minmax", cat_cutoff=0.03, treat_all_integers_as_numerical=False, - numerical_imputation="median", - categorical_imputation="most_frequent", + numerical_imputation: str | None = "median", + categorical_imputation: str | None = "most_frequent", add_missing_indicator=False, missing_policy=None, policy=None, @@ -530,7 +531,7 @@ def transform(self, X, embeddings=None, return_array=False): transformed_X = self.column_transformer_.transform(X) if sp.issparse(transformed_X): - transformed_X = transformed_X.toarray() + transformed_X = transformed_X.toarray() # type: ignore transformed_X = np.asarray(transformed_X) if self.dtype is not None: transformed_X = transformed_X.astype(self.dtype, copy=False) @@ -1027,7 +1028,7 @@ def stale_reason_(self): def clone_unfitted(self) -> "Preprocessor": """Return a fresh, unfitted, mutable copy carrying the same constructor params.""" - return clone(self) + return cast("Preprocessor", clone(self)) def refit(self, X, y=None, embeddings=None) -> "Preprocessor": """Fit a fresh copy on new data and return it, leaving ``self`` untouched. diff --git a/pretab/transformers/categorical/language_embedding.py b/pretab/transformers/categorical/language_embedding.py index 9e3cb0b..c18eabb 100644 --- a/pretab/transformers/categorical/language_embedding.py +++ b/pretab/transformers/categorical/language_embedding.py @@ -51,7 +51,7 @@ def _resolve_model(self): if self.model is not None: return self.model try: - from sentence_transformers import SentenceTransformer + from sentence_transformers import SentenceTransformer # type: ignore except ImportError as e: raise OptionalDependencyError( "sentence-transformers is not installed. Install it via `pip install sentence-transformers` or provide a preloaded model." diff --git a/pretab/transformers/feature_maps/kernel_approx.py b/pretab/transformers/feature_maps/kernel_approx.py index 20b2f2c..fd693c7 100644 --- a/pretab/transformers/feature_maps/kernel_approx.py +++ b/pretab/transformers/feature_maps/kernel_approx.py @@ -163,4 +163,4 @@ def transform(self, X): return np.asarray(self.nystroem_.transform(X)) def _output_sizes(self) -> list[int]: - return [self.nystroem_.components_.shape[0]] + return [np.asarray(self.nystroem_.components_).shape[0]] diff --git a/pretab/transformers/numerical/piecewise.py b/pretab/transformers/numerical/piecewise.py index fcbe3c7..b368e56 100644 --- a/pretab/transformers/numerical/piecewise.py +++ b/pretab/transformers/numerical/piecewise.py @@ -169,7 +169,7 @@ def fit(self, X, y=None): X = check_array( X, - dtype=np.float64, + dtype=np.float64, # type: ignore ensure_2d=True, ensure_all_finite=True, ) @@ -236,7 +236,7 @@ def transform(self, X): X = check_array( X, - dtype=np.float64, + dtype=np.float64, # type: ignore ensure_2d=True, ensure_all_finite=True, ) diff --git a/pretab/transformers/splines/cubic_regression.py b/pretab/transformers/splines/cubic_regression.py index 252d3f3..8652cec 100644 --- a/pretab/transformers/splines/cubic_regression.py +++ b/pretab/transformers/splines/cubic_regression.py @@ -146,7 +146,7 @@ def _bspline_basis(self, x, knots): x = np.asarray(x).reshape(-1, 1) n_samples = x.shape[0] - X = [np.ones((n_samples, 1))] if self.include_bias else [] + X: list[np.ndarray] = [np.ones((n_samples, 1))] if self.include_bias else [] X.append(x) X.append(x**2) X.append(x**3) diff --git a/pretab/transformers/splines/multivariate/thin_plate.py b/pretab/transformers/splines/multivariate/thin_plate.py index 17e9cc5..0fdfa55 100644 --- a/pretab/transformers/splines/multivariate/thin_plate.py +++ b/pretab/transformers/splines/multivariate/thin_plate.py @@ -132,7 +132,7 @@ def _select_landmarks(self, X, n_landmarks, rng): if n_landmarks >= n: return X if self.landmark_strategy == "kmeans": - return KMeans(n_clusters=n_landmarks, random_state=rng, n_init=10).fit(X).cluster_centers_ + return KMeans(n_clusters=n_landmarks, random_state=rng, n_init=10).fit(X).cluster_centers_ # type: ignore idx = rng.choice(n, size=n_landmarks, replace=False) return X[idx] diff --git a/pretab/transformers/splines/natural_cubic.py b/pretab/transformers/splines/natural_cubic.py index adb42a5..a76e202 100644 --- a/pretab/transformers/splines/natural_cubic.py +++ b/pretab/transformers/splines/natural_cubic.py @@ -152,7 +152,7 @@ def _basis(self, x, knots): n_samples = x.shape[0] n_knots = len(K) - basis = [np.ones((n_samples, 1))] if self.include_bias else [] + basis: list[np.ndarray] = [np.ones((n_samples, 1))] if self.include_bias else [] basis.append(x) def omega(z, k): From 7675fad9766d756f82428565d07c4d7e97f40d7e Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Wed, 12 Aug 2026 19:52:01 +0200 Subject: [PATCH 36/59] test: narrow union types for a clean type check --- tests/compose/test_factory.py | 1 + tests/compose/test_method_aliases.py | 4 ++++ tests/compose/test_output.py | 1 + tests/compose/test_registry_contract.py | 2 ++ tests/integration/test_frozen_lifecycle.py | 1 + tests/integration/test_missing_policy.py | 4 +++- tests/integration/test_output_budget.py | 4 +++- tests/integration/test_output_format.py | 8 ++++++++ tests/integration/test_preprocessor.py | 2 ++ tests/integration/test_presets.py | 4 +++- tests/integration/test_reproducibility.py | 2 ++ tests/integration/test_serialization.py | 1 + tests/regression/test_edge_cases.py | 2 ++ tests/transformers/test_custombin_transformer.py | 2 +- tests/transformers/test_ple_transformer.py | 2 +- 15 files changed, 35 insertions(+), 5 deletions(-) diff --git a/tests/compose/test_factory.py b/tests/compose/test_factory.py index 363065c..a0d1f53 100644 --- a/tests/compose/test_factory.py +++ b/tests/compose/test_factory.py @@ -117,4 +117,5 @@ def test_build_column_transformer_prefixes_and_passthrough(make_config): def test_build_column_transformer_fits_and_transforms(make_config, sample_frame): ct = build_column_transformer(make_config(), ["age"], ["city"]) out = ct.fit_transform(sample_frame, np.array([0.0, 1.0, 0.0, 1.0, 0.0, 1.0])) + assert isinstance(out, np.ndarray) assert out.shape[0] == len(sample_frame) diff --git a/tests/compose/test_method_aliases.py b/tests/compose/test_method_aliases.py index 5e21422..11c31d5 100644 --- a/tests/compose/test_method_aliases.py +++ b/tests/compose/test_method_aliases.py @@ -126,6 +126,8 @@ def test_numerical_alias_matches_canonical_output(sample_data, alias, canonical) out_canon = Preprocessor(numerical_method=canonical, categorical_method="int").fit_transform( X, y, return_array=True ) + assert isinstance(out_alias, np.ndarray) + assert isinstance(out_canon, np.ndarray) np.testing.assert_allclose(out_alias, out_canon) @@ -139,6 +141,8 @@ def test_categorical_alias_matches_canonical_output(sample_data, alias, canonica out_canon = Preprocessor(numerical_method="minmax", categorical_method=canonical).fit_transform( X, y, return_array=True ) + assert isinstance(out_alias, np.ndarray) + assert isinstance(out_canon, np.ndarray) np.testing.assert_allclose(out_alias, out_canon) diff --git a/tests/compose/test_output.py b/tests/compose/test_output.py index 98eb18a..b0f0be4 100644 --- a/tests/compose/test_output.py +++ b/tests/compose/test_output.py @@ -41,6 +41,7 @@ def test_format_output_array_returns_input_unchanged(): def test_format_output_dict_builds_blocks(): arr = np.arange(6).reshape(2, 3) out = format_output(arr, return_array=False, slices=[("x", 0, 3)]) + assert isinstance(out, dict) assert set(out) == {"x"} np.testing.assert_array_equal(out["x"], arr) diff --git a/tests/compose/test_registry_contract.py b/tests/compose/test_registry_contract.py index 3fe5a59..8611fc5 100644 --- a/tests/compose/test_registry_contract.py +++ b/tests/compose/test_registry_contract.py @@ -173,6 +173,7 @@ def test_preprocessor_compatible_numerical_methods_fit_transform(name, spec): else: pre = Preprocessor(numerical_method=name, target_aware=False, placement_strategy="uniform") out = pre.fit_transform(X, y, return_array=True) + assert isinstance(out, np.ndarray) assert out.shape[0] == 60 @@ -192,6 +193,7 @@ def test_preprocessor_compatible_categorical_methods_fit_transform(name, spec): placement_strategy="uniform", ) out = pre.fit_transform(X, y, return_array=True) + assert isinstance(out, np.ndarray) assert out.shape[0] == 60 diff --git a/tests/integration/test_frozen_lifecycle.py b/tests/integration/test_frozen_lifecycle.py index 814cdd8..9310b54 100644 --- a/tests/integration/test_frozen_lifecycle.py +++ b/tests/integration/test_frozen_lifecycle.py @@ -106,6 +106,7 @@ def test_clone_preserves_unfrozen_via_sklearn_clone(frame, target): p = _make().fit(frame, target).freeze() fresh = clone(p) + assert isinstance(fresh, Preprocessor) assert fresh.is_frozen() is False fresh.set_params(output_dim=7) assert fresh.output_dim == 7 diff --git a/tests/integration/test_missing_policy.py b/tests/integration/test_missing_policy.py index 6c4a89f..a67ac2a 100644 --- a/tests/integration/test_missing_policy.py +++ b/tests/integration/test_missing_policy.py @@ -53,7 +53,9 @@ def test_default_missing_policy_is_none(): def test_missing_policy_survives_clone(): p = _bspline(missing_policy="separate_state") - assert clone(p).missing_policy == "separate_state" + cloned = clone(p) + assert isinstance(cloned, Preprocessor) + assert cloned.missing_policy == "separate_state" def test_default_still_imputes(frame_with_nan, y): diff --git a/tests/integration/test_output_budget.py b/tests/integration/test_output_budget.py index 10d3a8c..d267f63 100644 --- a/tests/integration/test_output_budget.py +++ b/tests/integration/test_output_budget.py @@ -44,7 +44,9 @@ def test_estimate_output_shape_matches_transform(frame, y): n_rows, n_cols = pre.estimate_output_shape(frame) assert n_rows == frame.shape[0] assert n_cols == pre.total_output_dim_ - assert pre.transform(frame, return_array=True).shape == (n_rows, n_cols) + out = pre.transform(frame, return_array=True) + assert isinstance(out, np.ndarray) + assert out.shape == (n_rows, n_cols) def test_estimate_memory_is_rows_times_cols_times_itemsize(frame, y): diff --git a/tests/integration/test_output_format.py b/tests/integration/test_output_format.py index 6e360df..8e5ef0f 100644 --- a/tests/integration/test_output_format.py +++ b/tests/integration/test_output_format.py @@ -49,6 +49,7 @@ def test_default_output_format_is_dense(frame, y): def test_default_dict_blocks_are_dense(frame, y): p = _bspline().fit(frame, y) out = p.transform(frame) + assert isinstance(out, dict) assert all(isinstance(v, np.ndarray) for v in out.values()) @@ -59,14 +60,17 @@ def test_sparse_return_array_is_csr(frame, y): p = _bspline(output_format="sparse").fit(frame, y) arr = p.transform(frame, return_array=True) assert sp.issparse(arr) + assert isinstance(arr, sp.csr_matrix) assert arr.format == "csr" dense = _bspline().fit(frame, y).transform(frame, return_array=True) + assert isinstance(dense, np.ndarray) np.testing.assert_allclose(arr.toarray(), dense) def test_sparse_dict_blocks_are_csr(frame, y): p = _bspline(output_format="sparse").fit(frame, y) out = p.transform(frame) + assert isinstance(out, dict) assert all(sp.issparse(v) for v in out.values()) @@ -102,12 +106,14 @@ def test_auto_picks_dense_for_high_density(frame, y): def test_dtype_casts_output(frame, y): p = _bspline(dtype=np.float32).fit(frame, y) arr = p.transform(frame, return_array=True) + assert isinstance(arr, np.ndarray) assert arr.dtype == np.float32 def test_dtype_none_keeps_float64(frame, y): p = _bspline().fit(frame, y) arr = p.transform(frame, return_array=True) + assert isinstance(arr, np.ndarray) assert arr.dtype == np.float64 @@ -115,6 +121,7 @@ def test_dtype_with_sparse(frame, y): p = _bspline(dtype=np.float32, output_format="sparse").fit(frame, y) arr = p.transform(frame, return_array=True) assert sp.issparse(arr) + assert isinstance(arr, sp.csr_matrix) assert arr.dtype == np.float32 @@ -124,6 +131,7 @@ def test_dtype_with_sparse(frame, y): def test_output_report_shape_and_keys(frame, y): p = _bspline().fit(frame, y) arr = p.transform(frame, return_array=True) + assert isinstance(arr, np.ndarray) report = p.output_report_ assert set(report) == { "format", diff --git a/tests/integration/test_preprocessor.py b/tests/integration/test_preprocessor.py index c325836..09ae3c2 100644 --- a/tests/integration/test_preprocessor.py +++ b/tests/integration/test_preprocessor.py @@ -82,6 +82,7 @@ def test_dict_output_shapes_add_up(sample_data): X, y = sample_data pre = Preprocessor() out = pre.fit_transform(X, y) + assert isinstance(out, dict) shapes = [v.shape for v in out.values()] assert all(s[0] == len(X) for s in shapes) @@ -90,6 +91,7 @@ def test_dict_keys_reflect_column_names(sample_data): X, y = sample_data pre = Preprocessor() out = pre.fit_transform(X, y) + assert isinstance(out, dict) expected_prefixes = ["num_", "cat_"] for k in out: if "embedding" not in k: diff --git a/tests/integration/test_presets.py b/tests/integration/test_presets.py index 10d0d94..5f6d824 100644 --- a/tests/integration/test_presets.py +++ b/tests/integration/test_presets.py @@ -60,7 +60,9 @@ def test_explicit_param_overrides_preset(): def test_preset_is_preserved_by_get_params_and_clone(): pre = Preprocessor(preset="standard") assert pre.get_params()["preset"] == "standard" - assert clone(pre).get_params()["preset"] == "standard" + cloned = clone(pre) + assert isinstance(cloned, Preprocessor) + assert cloned.get_params()["preset"] == "standard" def test_invalid_preset_raises(): diff --git a/tests/integration/test_reproducibility.py b/tests/integration/test_reproducibility.py index 0659e7c..ae9fc11 100644 --- a/tests/integration/test_reproducibility.py +++ b/tests/integration/test_reproducibility.py @@ -128,6 +128,8 @@ def test_add_missing_indicator_appends_columns(data): .fit(X, y) .transform(X, return_array=True) ) + assert isinstance(base, np.ndarray) + assert isinstance(with_ind, np.ndarray) assert with_ind.shape[1] > base.shape[1] diff --git a/tests/integration/test_serialization.py b/tests/integration/test_serialization.py index 74c21e4..b02caa9 100644 --- a/tests/integration/test_serialization.py +++ b/tests/integration/test_serialization.py @@ -116,6 +116,7 @@ def test_round_trip_preserves_dtype_and_output_format(frame, target): restored = Preprocessor.from_spec(p.to_spec()) out = restored.transform(frame, return_array=True) + assert isinstance(out, np.ndarray) assert out.dtype == np.float32 assert restored.dtype == "float32" assert restored.output_format == "dense" diff --git a/tests/regression/test_edge_cases.py b/tests/regression/test_edge_cases.py index 14387b9..8265254 100644 --- a/tests/regression/test_edge_cases.py +++ b/tests/regression/test_edge_cases.py @@ -26,6 +26,7 @@ def _finite(array) -> bool: def test_constant_numeric_graceful_method_is_finite(): X = pd.DataFrame({"const": np.full(50, 3.14), "vary": np.linspace(0.0, 1.0, 50)}) out = Preprocessor(numerical_method="minmax").fit_transform(X, return_array=True) + assert isinstance(out, np.ndarray) assert out.shape == (50, 2) assert _finite(out) @@ -73,6 +74,7 @@ def test_duplicate_support_points_are_handled(): placement_strategy="quantile", ).fit(X) out = p.transform(X, return_array=True) + assert isinstance(out, np.ndarray) assert _finite(out) assert out.shape[0] == len(X) diff --git a/tests/transformers/test_custombin_transformer.py b/tests/transformers/test_custombin_transformer.py index 9210260..df365f3 100644 --- a/tests/transformers/test_custombin_transformer.py +++ b/tests/transformers/test_custombin_transformer.py @@ -42,7 +42,7 @@ def test_custom_bin_transformer_input_types(bins, input_type): if input_type == "list" else np.array(raw) if input_type == "np" - else pd.DataFrame(raw, columns=["x"]) + else pd.DataFrame(raw, columns=pd.Index(["x"])) ) transformer = NumericBinningTransformer(output_dim=bins) Xt = transformer.fit_transform(X) diff --git a/tests/transformers/test_ple_transformer.py b/tests/transformers/test_ple_transformer.py index 97003e6..bd2baa6 100644 --- a/tests/transformers/test_ple_transformer.py +++ b/tests/transformers/test_ple_transformer.py @@ -49,7 +49,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"): - transformer = PLETransformer(task="unsupported") + transformer = PLETransformer(task="unsupported") # type: ignore[arg-type] transformer.fit(X_single_feature, np.linspace(0, 1, 10)) From 386cefe8a867b77d43ddd7869a6f8f701a9b8740 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Wed, 12 Aug 2026 19:52:01 +0200 Subject: [PATCH 37/59] test(search): cover RepresentationSearchCV --- tests/compose/test_search.py | 117 +++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 tests/compose/test_search.py diff --git a/tests/compose/test_search.py b/tests/compose/test_search.py new file mode 100644 index 0000000..3cb9308 --- /dev/null +++ b/tests/compose/test_search.py @@ -0,0 +1,117 @@ +"""Tests for :class:`~pretab.compose.search.RepresentationSearchCV`. + +The search picks the best ``numerical_method`` by cross-validation, then refits +the winning representation (and the downstream estimator) on all data. The data +here is a controlled nonlinear signal (``sin``) where an expressive basis +(``bspline``) must beat a linear ``standardization`` baseline. +""" + +import numpy as np +import pandas as pd +import pytest +from sklearn.exceptions import NotFittedError +from sklearn.linear_model import LinearRegression, LogisticRegression +from sklearn.model_selection import KFold + +from pretab import RepresentationSearchCV +from pretab.exceptions import InvalidParamError + +# Unsupervised, deterministic placement so scores are reproducible fold to fold. +_UNSUPERVISED = {"target_aware": False, "placement_strategy": "uniform", "output_dim": 10} + + +@pytest.fixture +def nonlinear_data(): + """Random (unsorted) x in [-3, 3] with a smooth nonlinear target.""" + rng = np.random.RandomState(0) + x = rng.uniform(-3.0, 3.0, size=200) + X = pd.DataFrame({"x": x}) + y = np.sin(x) + 0.05 * rng.randn(200) + return X, y + + +def _search(estimator, methods, **kwargs): + params = {"cv": 4, "preprocessor_params": _UNSUPERVISED, "random_state": 0} + params.update(kwargs) + return RepresentationSearchCV(estimator, methods=methods, **params) + + +def test_selects_expressive_method_on_nonlinear_signal(nonlinear_data): + X, y = nonlinear_data + search = _search(LinearRegression(), ["standardization", "bspline"]).fit(X, y) + + assert set(search.cv_results_) == {"standardization", "bspline"} + assert search.best_method_ == "bspline" + assert search.cv_results_["bspline"] > search.cv_results_["standardization"] + assert search.best_score_ == pytest.approx(max(search.cv_results_.values())) + + +def test_refit_best_representation_and_predict(nonlinear_data): + X, y = nonlinear_data + search = _search(LinearRegression(), ["standardization", "bspline"]).fit(X, y) + + assert search.best_method_ == "bspline" + # best_preprocessor_ carries the winning method and is refit on all data. + assert search.best_preprocessor_.numerical_method == "bspline" + preds = search.predict(X) + assert preds.shape == (len(X),) + # A refit bspline fits the smooth signal well. + assert search.score(X, y) > 0.9 + + +def test_fit_is_reproducible(nonlinear_data): + X, y = nonlinear_data + first = _search(LinearRegression(), ["standardization", "bspline"]).fit(X, y) + second = _search(LinearRegression(), ["standardization", "bspline"]).fit(X, y) + + assert first.best_method_ == second.best_method_ + assert first.cv_results_ == second.cv_results_ + np.testing.assert_allclose(first.predict(X), second.predict(X)) + + +def test_accepts_cv_splitter_object(nonlinear_data): + X, y = nonlinear_data + search = _search(LinearRegression(), ["bspline"], cv=KFold(n_splits=3, shuffle=True, random_state=0)).fit(X, y) + + assert search.best_method_ == "bspline" + assert set(search.cv_results_) == {"bspline"} + + +def test_classification_uses_stratified_cv(): + rng = np.random.RandomState(0) + x = rng.uniform(-3.0, 3.0, size=200) + X = pd.DataFrame({"x": x}) + y = (np.sin(x) > 0).astype(int) + search = _search(LogisticRegression(max_iter=1000), ["standardization", "bspline"]).fit(X, y) + + assert search.best_method_ in {"standardization", "bspline"} + assert 0.0 <= search.score(X, y) <= 1.0 + + +def test_empty_methods_raises(nonlinear_data): + X, y = nonlinear_data + with pytest.raises(InvalidParamError): + RepresentationSearchCV(LinearRegression(), methods=[]).fit(X, y) + + +def test_requires_y_at_fit(nonlinear_data): + X, _ = nonlinear_data + with pytest.raises(InvalidParamError): + RepresentationSearchCV(LinearRegression(), methods=["bspline"]).fit(X, None) + + +def test_predict_before_fit_raises(nonlinear_data): + X, _ = nonlinear_data + search = RepresentationSearchCV(LinearRegression(), methods=["bspline"]) + with pytest.raises(NotFittedError): + search.predict(X) + + +def test_get_params_and_clone_preserve_config(): + from sklearn.base import clone + + search = RepresentationSearchCV(LinearRegression(), methods=["bspline", "standardization"], cv=3) + assert search.get_params()["methods"] == ["bspline", "standardization"] + cloned = clone(search) + assert isinstance(cloned, RepresentationSearchCV) + assert cloned.get_params()["cv"] == 3 From bb2fd80a4f2a4b963a2993536c8eab6a248f10e4 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Wed, 12 Aug 2026 19:52:01 +0200 Subject: [PATCH 38/59] test: turn unexpected leakage warnings into errors --- pyproject.toml | 3 +++ tests/conftest.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 tests/conftest.py diff --git a/pyproject.toml b/pyproject.toml index 0b1db96..a333855 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,6 +70,9 @@ norecursedirs = [ ".venv", ] filterwarnings = [ + # An unexpected LeakageWarning fails the suite; modules that fit supervised + # transformers directly opt out via tests/conftest.py. + "error::pretab.exceptions.LeakageWarning", # scikit-learn / scipy deprecation noise "ignore::DeprecationWarning:sklearn", "ignore::DeprecationWarning:scipy", diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8123ab2 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,42 @@ +"""Root test configuration. + +``pyproject.toml`` turns :class:`~pretab.exceptions.LeakageWarning` into an error +so an *unintended* leakage warning fails the suite. The modules listed below +intentionally fit target-aware transformers directly (outside a Pipeline) to +exercise their behaviour, so the expected leakage warning is silenced there. The +dedicated leakage tests still assert the warning explicitly via ``pytest.warns``. +""" + +import pytest + +# Test modules that fit supervised transformers directly; the leakage warning is +# expected here and must not fail the suite. Any *other* module that emits it is +# a real regression and will error. +_LEAKAGE_EXPECTED_MODULES = frozenset( + { + "test_feature_map_selector.py", + "test_adaptive_resolution.py", + "test_ple_selector.py", + "test_cross_fitted.py", + "test_supervised_contract.py", + "test_ple_transformer.py", + "test_rbfexpansion_transformer.py", + "test_reluexpansion_transformer.py", + "test_sigmoidexpansion_transformer.py", + "test_spline_api_parity.py", + "test_spline_expansions.py", + "test_output_dimension.py", + "test_exceptions.py", + "test_adaptive_output_dim.py", + "test_reproducibility.py", + } +) + +_LEAKAGE_IGNORE = pytest.mark.filterwarnings("ignore::pretab.exceptions.LeakageWarning") + + +def pytest_collection_modifyitems(items): + """Silence the expected leakage warning in modules that fit supervised transformers directly.""" + for item in items: + if item.path.name in _LEAKAGE_EXPECTED_MODULES: + item.add_marker(_LEAKAGE_IGNORE) From 0826d15b932ce9775dffeb8c2caf6689e6b0efb9 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Wed, 12 Aug 2026 19:52:01 +0200 Subject: [PATCH 39/59] ci: enforce coverage floor and require type checks --- .github/workflows/ci.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52fa02b..647910f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,10 +53,6 @@ jobs: typecheck: name: Type check (pyright) runs-on: ubuntu-latest - # Advisory (non-blocking) during the 1.0.0 restructure: much of the code - # carrying pre-existing pyright errors (ple.py, cubic.py, preprocessor.py) - # is rewritten in Phases 1-5. Flip to a required check before the RC (P14.4). - continue-on-error: true steps: - uses: actions/checkout@v4 @@ -220,6 +216,7 @@ jobs: --cov-branch \ --cov-report=term-missing \ --cov-report=xml:coverage.xml \ + --cov-fail-under=90 \ -q - name: Upload coverage report From 18064901f78ca56841c67805c9cf186b4f57cb62 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Wed, 12 Aug 2026 20:09:24 +0200 Subject: [PATCH 40/59] ci: add optional-deps job for embeddings and lightgbm extras --- .github/workflows/ci.yml | 42 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 647910f..9cb814d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -232,3 +232,45 @@ jobs: files: coverage.xml token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: false + + optional-deps: + name: Optional deps (${{ matrix.extra }}) + runs-on: ubuntu-latest + needs: lint + strategy: + fail-fast: false + matrix: + include: + - extra: embeddings + module: sentence_transformers + - extra: lightgbm + module: lightgbm + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Poetry + run: pipx install poetry + + - name: Configure Poetry + run: poetry config virtualenvs.in-project true + + - name: Cache virtualenv + uses: actions/cache@v4 + with: + path: .venv + key: venv-optdeps-${{ matrix.extra }}-${{ runner.os }}-3.12-${{ hashFiles('poetry.lock') }} + + - name: Install dependencies with the ${{ matrix.extra }} extra + run: poetry install --extras "${{ matrix.extra }}" + + - name: Verify the optional dependency imports + run: poetry run python -c "import ${{ matrix.module }}; print('${{ matrix.module }} import OK')" + + - name: Run the suite with the extra installed + run: poetry run pytest tests/ -q From bc207d1f327fc85c2b3045703ac0f264f6f6aa22 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Wed, 12 Aug 2026 20:18:39 +0200 Subject: [PATCH 41/59] feat: add quickstart script as a ci smoke test and reviewer artifact --- .github/workflows/ci.yml | 3 + justfile | 4 + scripts/quickstart.py | 193 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 200 insertions(+) create mode 100644 scripts/quickstart.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9cb814d..766dca8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -181,6 +181,9 @@ jobs: - name: Run smoke tests run: poetry run pytest tests/ -v -m smoke --tb=short + - name: Run quickstart + run: poetry run python scripts/quickstart.py + coverage: name: Coverage runs-on: ubuntu-latest diff --git a/justfile b/justfile index 9ab3285..924aa1b 100644 --- a/justfile +++ b/justfile @@ -39,6 +39,10 @@ types: test: poetry run pytest --cov=pretab tests/ +# run the end-to-end quickstart used as the CI smoke test and reviewer artifact +quickstart: + poetry run python scripts/quickstart.py + # build the HTML docs locally (warnings treated as errors) docs: rm -rf docs/_build diff --git a/scripts/quickstart.py b/scripts/quickstart.py new file mode 100644 index 0000000..a97952a --- /dev/null +++ b/scripts/quickstart.py @@ -0,0 +1,193 @@ +"""End-to-end sanity check for PreTab, doubling as a CI smoke test and a +five-minute artifact for reviewers. Run it with:: + + python scripts/quickstart.py + +Each check exercises a distinct part of the public API against a fixed, +synthetic dataset and prints a single-line result. The script exits with a +non-zero status if any check fails or raises. +""" + +import sys +import time +import warnings + +import numpy as np +import pandas as pd +from sklearn.linear_model import Ridge +from sklearn.metrics import r2_score +from sklearn.pipeline import Pipeline + +from pretab import CrossFittedTransformer, LeakageWarning, Preprocessor, list_representations +from pretab.transformers import NaturalCubicSplineTransformer, PLETransformer + +SEED = 0 +N_ROWS = 400 + + +def expect(condition, message): + if not condition: + raise AssertionError(message) + + +def make_dataset(n=N_ROWS, seed=SEED): + rng = np.random.default_rng(seed) + X = pd.DataFrame( + { + "tenure": rng.uniform(0.0, 20.0, size=n), + "income": rng.normal(55_000, 12_000, size=n), + "usage": rng.exponential(scale=3.0, size=n), + "plan": rng.choice(["basic", "standard", "premium"], size=n), + "region": rng.choice(["north", "south", "east", "west"], size=n), + } + ) + y = 0.4 * np.sin(X["tenure"] / 3) + X["income"] / 1e5 - 0.2 * X["usage"] + rng.normal(0, 0.1, size=n) + return X, y.to_numpy() + + +def check_mixed_preprocessing(X, y): + config = { + "tenure": "naturalspline", + "income": "rbf", + "usage": "ple", + "plan": "one-hot", + "region": "int", + } + pre = Preprocessor(feature_preprocessing=config, task="regression", random_state=SEED) + array = pre.fit_transform(X, y, return_array=True) + if not isinstance(array, np.ndarray): + raise TypeError("fit_transform(return_array=True) did not return an ndarray") + expect(array.shape[0] == len(X), "row count changed during preprocessing") + expect(np.isfinite(array).all(), "preprocessed output contains non-finite values") + return f"{array.shape[0]} rows -> {array.shape[1]} columns" + + +def check_feature_lineage(X, y): + pre = Preprocessor( + feature_preprocessing={"tenure": "naturalspline", "income": "rbf", "usage": "ple"}, + categorical_method="one-hot", + task="regression", + random_state=SEED, + ).fit(X, y) + lineage = pre.get_feature_lineage() + expect(len(lineage) == pre.total_output_dim_, "lineage does not cover every output column") + sources = {record.source_features[0] for record in lineage} + expect(sources == set(X.columns), "lineage is missing a source feature") + return f"{len(lineage)}/{pre.total_output_dim_} columns traced to a source feature" + + +def check_leakage_safe_cross_fitting(): + rng = np.random.default_rng(SEED) + x = rng.uniform(-3.0, 3.0, size=(200, 1)) + y = rng.normal(size=200) + + with warnings.catch_warnings(record=True) as direct: + warnings.simplefilter("always") + PLETransformer(output_dim=10, random_state=SEED).fit(x, y) + expect( + any(issubclass(w.category, LeakageWarning) for w in direct), + "fitting a target-aware transformer outside a pipeline should warn", + ) + + with warnings.catch_warnings(record=True) as piped: + warnings.simplefilter("always") + Pipeline([("ple", PLETransformer(output_dim=10, random_state=SEED))]).fit(x, y) + expect( + not any(issubclass(w.category, LeakageWarning) for w in piped), + "fitting inside a pipeline should not warn", + ) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", category=LeakageWarning) + naive = PLETransformer(output_dim=10, random_state=SEED).fit(x, y).transform(x) + cross = CrossFittedTransformer(PLETransformer(output_dim=10, random_state=SEED), n_folds=5, random_state=SEED) + out_of_fold = cross.fit_transform(x, y) + + changed = int((~np.all(naive == out_of_fold, axis=1)).sum()) + expect(changed > len(x) // 2, "cross-fitting did not change enough rows to look out-of-fold") + return f"warns outside a pipeline, silent inside one, {changed}/{len(x)} rows re-encoded out-of-fold" + + +def check_sklearn_pipeline(X, y): + x = X[["tenure"]].to_numpy() + pipeline = Pipeline( + [ + ("spline", NaturalCubicSplineTransformer(output_dim=8)), + ("model", Ridge(alpha=1.0)), + ] + ) + pipeline.fit(x, y) + predictions = pipeline.predict(x) + expect(predictions.shape == y.shape, "prediction shape does not match the target") + expect(np.isfinite(predictions).all(), "predictions contain non-finite values") + score = r2_score(y, predictions) + return f"Ridge on 8 spline basis columns, R2 = {score:.3f}" + + +def check_serialization_roundtrip(X, y): + pre = Preprocessor( + feature_preprocessing={"tenure": "naturalspline", "usage": "ple"}, + categorical_method="int", + task="regression", + random_state=SEED, + ).fit(X, y) + reloaded = Preprocessor.from_spec(pre.to_spec()) + + original = pre.transform(X, return_array=True) + restored = reloaded.transform(X, return_array=True) + if not (isinstance(original, np.ndarray) and isinstance(restored, np.ndarray)): + raise TypeError("transform(return_array=True) did not return an ndarray") + np.testing.assert_array_equal(original, restored) + expect(pre.fingerprint_ == reloaded.fingerprint_, "fingerprint changed across a spec round trip") + return f"fingerprint {pre.fingerprint_[:12]} reproduced bit-for-bit after a spec round trip" + + +def check_representation_discovery(): + supervised_numerical = list_representations(feature_kind="numerical", supervised=True) + all_methods = list_representations() + expect("ple" in supervised_numerical, "the registry lost a documented method") + expect("one-hot" not in supervised_numerical, "a categorical-only method leaked into a numerical filter") + return f"{len(all_methods)} registered methods, {len(supervised_numerical)} target-aware numerical" + + +CHECKS = [ + ("mixed-type preprocessing", check_mixed_preprocessing, True), + ("feature lineage", check_feature_lineage, True), + ("leakage-safe cross-fitting", check_leakage_safe_cross_fitting, False), + ("sklearn pipeline compatibility", check_sklearn_pipeline, True), + ("portable serialization", check_serialization_roundtrip, True), + ("representation discovery", check_representation_discovery, False), +] + + +def main(): + import pretab + + print(f"PreTab quickstart (pretab {pretab.__version__})") + print("-" * 64) + + X, y = make_dataset() + start = time.perf_counter() + failed = [] + + for index, (label, check, needs_data) in enumerate(CHECKS, start=1): + prefix = f"[{index}/{len(CHECKS)}] {label}" + try: + detail = check(X, y) if needs_data else check() + print(f"{prefix:<45} ok {detail}") + except Exception as exc: # a failing check should not stop the rest from running + failed.append(label) + print(f"{prefix:<45} FAIL {exc}") + + elapsed = time.perf_counter() - start + print("-" * 64) + if failed: + print(f"{len(failed)}/{len(CHECKS)} checks failed: {', '.join(failed)}") + return 1 + + print(f"all {len(CHECKS)} checks passed in {elapsed:.2f}s") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 545b4d342ad710afa4a30bd664483473f30d975b Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Wed, 12 Aug 2026 20:21:26 +0200 Subject: [PATCH 42/59] chore: docstring update --- scripts/quickstart.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/quickstart.py b/scripts/quickstart.py index a97952a..dea8cbf 100644 --- a/scripts/quickstart.py +++ b/scripts/quickstart.py @@ -1,5 +1,4 @@ -"""End-to-end sanity check for PreTab, doubling as a CI smoke test and a -five-minute artifact for reviewers. Run it with:: +"""End-to-end sanity check for PreTab, doubling as a CI smoke test script. Run it with:: python scripts/quickstart.py From 90eb3a1031e0c3582f1a28a58824f3dce3aa5644 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Wed, 12 Aug 2026 20:26:32 +0200 Subject: [PATCH 43/59] docs: add root CONTRIBUTING and SECURITY policy --- CONTRIBUTING.md | 25 +++++++++++++++++++++++++ SECURITY.md | 29 +++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7e98215 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,25 @@ +# Contributing + +Thanks for your interest in contributing to PreTab. + +The full contributor guide, covering environment setup, the local development +workflow, and what a pull request needs to pass review, lives in the +documentation: + +**[Contributing Guide](https://pretab.readthedocs.io/en/latest/developer_guide/contributing.html)** + +Quick start for the impatient: + +```bash +git clone https://github.com/OpenTabular/PreTab +cd PreTab +just install +just test +just check +``` + +All contributors are expected to follow the [Code of Conduct](CODE_OF_CONDUCT.md). + +> **Note:** Report bugs and request features on the +> [issue tracker](https://github.com/OpenTabular/PreTab/issues). Security +> vulnerabilities should be reported privately; see [SECURITY.md](SECURITY.md). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..e8e21ef --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,29 @@ +# Security Policy + +## Supported versions + +PreTab follows [Semantic Versioning](https://semver.org/). Security fixes are +made against the latest released minor version on +[PyPI](https://pypi.org/project/pretab/); older releases do not receive +backported fixes. + +## Reporting a vulnerability + +Please do not open a public GitHub issue for security vulnerabilities. + +Report vulnerabilities privately through +[GitHub Security Advisories](https://github.com/OpenTabular/PreTab/security/advisories/new) +for this repository. Include: + +- A description of the vulnerability and its potential impact +- Steps to reproduce, or a minimal proof of concept +- The affected version(s) of PreTab + +We aim to acknowledge new reports within five business days and will work with +you to understand and address the issue before any public disclosure. + +> **Note:** PreTab's most security-relevant surface is deserialization. +> Loading a fitted preprocessor via `Preprocessor.from_spec` is designed to +> never execute estimator code, unlike `pickle`; it reconstructs objects +> through an allow-listed decoder over a fixed set of library modules. A +> vulnerability that breaks this guarantee is a high-priority report. From fbec25332adf3843841d379d1a86db2524ed4d9f Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Wed, 12 Aug 2026 20:32:07 +0200 Subject: [PATCH 44/59] docs: tighten CONTRIBUTING quick start wording --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7e98215..8b876d2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ documentation: **[Contributing Guide](https://pretab.readthedocs.io/en/latest/developer_guide/contributing.html)** -Quick start for the impatient: +Quick start: ```bash git clone https://github.com/OpenTabular/PreTab From 86f92f9d494030b67e9c0e472e99b33466d0a6ef Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Wed, 12 Aug 2026 20:32:08 +0200 Subject: [PATCH 45/59] docs(changelog): reconcile unreleased section and fix em-dash style --- CHANGELOG.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50076d5..2db9ff4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,6 @@ This project adheres to [Semantic Versioning](https://semver.org/) and uses Going forward, this file is updated automatically by `cz bump` on each release. ---- - ## Unreleased > **Note:** `0.1.0` is an internal development marker for the pre-1.0 restructure @@ -20,15 +18,15 @@ Going forward, this file is updated automatically by `cz bump` on each release. ### Feat -- **extension**: add a public, discoverable extension protocol — a `BaseRepresentation` base class (declaring `representation_name` / `feature_kind` / `scope` / `supervision`) that inherits the shared scikit-learn contract; `register_representation(name, cls)` plus opt-in `load_entry_point_representations()` (the `pretab.representations` entry-point group) to add third-party methods so they are selectable via `Preprocessor(numerical_method=...)`; `list_representations(feature_kind=, scope=, supervised=, periodic=, sparse_output=, adaptive=)` capability discovery; and a `check_representation(cls)` conformance suite (raising the new `RepresentationConformanceError`) verifying fit-returns-self, no input mutation, stable shape, matching feature names, determinism, fitted-state checks, and declared scope/supervision. `Preprocessor` gains transparent `preset="standard"|"expanded"|"adaptive"` aliases and `get_resolved_config()`; `TransformerSpec` gains `periodic` / `sparse_output` capability flags. A runnable sibling example lives at `examples/pretab-chebyshev` (all new symbols exported from `pretab`) -- **serialize**: add portable, versioned serialization to `Preprocessor` (`to_spec` / `from_spec`) that captures a fitted preprocessor as a schema- and dependency-versioned JSON document and reconstructs it bit-for-bit — an auditable, allow-listed alternative to `pickle` that never executes estimator code on load; add a stable cross-process `fingerprint_` (sha256 over the resolved config, seeds, versions, output order, and fitted state) with a `reproducibility_report()`; and add an immutable lifecycle (`lifecycle_state_` ∈ `UNFITTED` / `FITTED` / `FROZEN` / `STALE`, `freeze` / `is_frozen` / `mark_stale` / `clone_unfitted` / `refit`) where `set_params` on a frozen preprocessor raises the new `PretabSerializationError` / `FrozenRepresentationError` (both exported from `pretab`) +- **extension**: add a public, discoverable extension protocol: a `BaseRepresentation` base class (declaring `representation_name` / `feature_kind` / `scope` / `supervision`) that inherits the shared scikit-learn contract; `register_representation(name, cls)` plus opt-in `load_entry_point_representations()` (the `pretab.representations` entry-point group) to add third-party methods so they are selectable via `Preprocessor(numerical_method=...)`; `list_representations(feature_kind=, scope=, supervised=, periodic=, sparse_output=, adaptive=)` capability discovery; and a `check_representation(cls)` conformance suite (raising the new `RepresentationConformanceError`) verifying fit-returns-self, no input mutation, stable shape, matching feature names, determinism, fitted-state checks, and declared scope/supervision. `Preprocessor` gains transparent `preset="standard"|"expanded"|"adaptive"` aliases and `get_resolved_config()`; `TransformerSpec` gains `periodic` / `sparse_output` capability flags. A runnable sibling example lives at `examples/pretab-chebyshev` (all new symbols exported from `pretab`) +- **serialize**: add portable, versioned serialization to `Preprocessor` (`to_spec` / `from_spec`) that captures a fitted preprocessor as a schema- and dependency-versioned JSON document and reconstructs it bit-for-bit, an auditable, allow-listed alternative to `pickle` that never executes estimator code on load; add a stable cross-process `fingerprint_` (sha256 over the resolved config, seeds, versions, output order, and fitted state) with a `reproducibility_report()`; and add an immutable lifecycle (`lifecycle_state_` ∈ `UNFITTED` / `FITTED` / `FROZEN` / `STALE`, `freeze` / `is_frozen` / `mark_stale` / `clone_unfitted` / `refit`) where `set_params` on a frozen preprocessor raises the new `PretabSerializationError` / `FrozenRepresentationError` (both exported from `pretab`) - **missing**: add a high-level `Preprocessor(missing_policy=...)` control (`error` / `propagate` / `impute` / `impute_with_indicator` / `separate_state`) that overrides the low-level imputation parameters; `separate_state` emits a dedicated `__missing` column (new `MissingStateIndicator`, wired through a per-column `FeatureUnion`) that stays outside the ordinary representation basis, and `error` rejects missing input at fit/transform; pin the end-to-end edge-case behaviour (constant features, `custombin` determinism, duplicate support points, missing values, unseen categories) in `tests/regression/test_edge_cases.py` - **output**: add output-budget controls to `Preprocessor` (`max_output_features`, `max_features_per_input`, `max_dense_memory`, `overflow_policy`, plus `estimate_output_shape` / `estimate_memory`, raising the new `OutputBudgetError`) and first-class output-format control (`output_format ∈ {auto, dense, sparse}`, `dtype`, an `output_report_` memory report, and `set_output(transform="pandas"|"polars")` DataFrame wrapping); defaults (`dense`, no budgets) reproduce historical behaviour - **policy**: add a central `RepresentationPolicy(missing, constant, out_of_range, invalid)` (exported from `pretab`) and a `Preprocessor(policy=...)` hook (resolved to `policy_` at fit) governing constant-column, out-of-range, and non-finite handling; defaults reproduce historical behaviour. Pin the per-family edge-case contract (constant column, all-missing, partial-missing propagation, tiny n, duplicate support points, out-of-range, infinity, feature-count mismatch) in `tests/test_edge_case_contract.py`, and fix silent-corruption gaps so every spline family raises a typed `PretabDataError` on a constant or all-missing column (and cleanly propagates partial-missing rows), feature maps reject all-missing columns, and `NumericBinningTransformer` rejects non-finite input -- **supervised**: add a leakage-safe supervised contract — `requires_y` / `is_supervised` / fitted `uses_target_` on every transformer, a `LeakageWarning` when a target-aware transformer is fit on `(X, y)` outside a Pipeline / cross-validation context, a `CrossFittedTransformer` wrapper that produces out-of-fold training features (recording `cross_fitted` / `n_folds` in the spec), and a `RepresentationSearchCV` skeleton (all exported from `pretab`) +- **supervised**: add a leakage-safe supervised contract: `requires_y` / `is_supervised` / fitted `uses_target_` on every transformer, a `LeakageWarning` when a target-aware transformer is fit on `(X, y)` outside a Pipeline / cross-validation context, a `CrossFittedTransformer` wrapper that produces out-of-fold training features (recording `cross_fitted` / `n_folds` in the spec), and a `RepresentationSearchCV` skeleton (all exported from `pretab`) - **representation**: add typed `RepresentationSpec` and per-output-column `FeatureLineage` (exported from `pretab`); every transformer family exposes `get_representation_spec()` and `Preprocessor.get_feature_lineage()` maps each output column to its source feature(s), representation family, component, and target-usage flag - **transformers**: add `FourierFeatureTransformer` (deterministic sine/cosine feature map with `harmonic` / `log_spaced` / `random` frequencies), selectable as the `"fourier"` numerical method -- **transformers**: add `RandomFourierFeaturesTransformer` and `NystroemFeaturesTransformer` — standalone multivariate kernel-approximation feature maps (`"rff"` / `"nystroem"`) +- **transformers**: add `RandomFourierFeaturesTransformer` and `NystroemFeaturesTransformer`, standalone multivariate kernel-approximation feature maps (`"rff"` / `"nystroem"`) - **binning**: make `NumericBinningTransformer` a stateful, multi-feature encoder with learned `bin_edges_` and `encode` (`ordinal` / `onehot` / `soft`) plus `placement_strategy` (`uniform` / `quantile`) options - **transformers**: add `harmonics` and `include_original` options to `PeriodicEncodingTransformer` for multi-harmonic periodic encodings - update default output_dim @@ -115,3 +113,7 @@ Going forward, this file is updated automatically by `cz bump` on each release. - Adopted a Poetry + OIDC release pipeline publishing to PyPI (`v*.*.*`) and TestPyPI (`v*.*.*rc*`), plus a manual `build-check` dry-run workflow - Added a `justfile` and pre-commit configuration for the local development workflow - Added project meta documentation: `CHANGELOG.md`, `CONVENTIONAL_COMMITS.md`, and `CODE_OF_CONDUCT.md` +- Drove `pyright` to zero errors across the package and test suite and promoted the CI `typecheck` job from advisory to required +- Hardened `ci.yml` with an `optional-deps` job that installs the `embeddings` and `lightgbm` extras and runs the suite against each, and wired a `--cov-fail-under=90` gate into the coverage job +- Added `scripts/quickstart.py`, a runnable, CI-gated smoke test covering mixed-type preprocessing, feature lineage, leakage-safe cross-fitting, sklearn `Pipeline` compatibility, serialization round-trips, and representation discovery (`just quickstart`) +- Added root `CONTRIBUTING.md` and `SECURITY.md` so GitHub surfaces the contributor guide and a private vulnerability-reporting channel From a11ae5fd7801eab61bde3a503b2e34d3d3e34945 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Wed, 12 Aug 2026 20:49:33 +0200 Subject: [PATCH 46/59] fix(docs): define missing dataset in two tutorial snippets --- docs/tutorials/adaptive_resolution.md | 5 ++++- docs/tutorials/custom_representation.md | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/tutorials/adaptive_resolution.md b/docs/tutorials/adaptive_resolution.md index 98434ca..86d4190 100644 --- a/docs/tutorials/adaptive_resolution.md +++ b/docs/tutorials/adaptive_resolution.md @@ -63,15 +63,18 @@ The same switch works at the `Preprocessor` level, so every eligible column adap independently. ```python +import pandas as pd from pretab import Preprocessor +df = pd.DataFrame({"simple": simple, "wiggly": wiggly}) + pre = Preprocessor( numerical_method="bspline", adaptive=True, min_output_dim=5, max_output_dim=15, ) -pre.fit(df, y) +pre.fit(df, wiggly) pre.get_feature_info() ``` diff --git a/docs/tutorials/custom_representation.md b/docs/tutorials/custom_representation.md index 92de2a8..b8ba8b2 100644 --- a/docs/tutorials/custom_representation.md +++ b/docs/tutorials/custom_representation.md @@ -111,6 +111,13 @@ register_representation( supports_adaptive_resolution=False, ) +import numpy as np +import pandas as pd + +rng = np.random.default_rng(0) +df = pd.DataFrame({"x": rng.uniform(-3, 3, size=500)}) +y = np.cos(df["x"] * 2) + rng.normal(0, 0.1, size=500) + pre = Preprocessor(numerical_method="chebyshev", degree=8) X2 = pre.fit_transform(df, y) ``` From ac96bf3271b33471c9083d48bc41ad19fa0e134c Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Wed, 12 Aug 2026 20:49:33 +0200 Subject: [PATCH 47/59] test(docs): validate tutorial code fences against the real API --- tests/doc_snippets/test_tutorial_snippets.py | 45 ++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 tests/doc_snippets/test_tutorial_snippets.py diff --git a/tests/doc_snippets/test_tutorial_snippets.py b/tests/doc_snippets/test_tutorial_snippets.py new file mode 100644 index 0000000..fcf1912 --- /dev/null +++ b/tests/doc_snippets/test_tutorial_snippets.py @@ -0,0 +1,45 @@ +"""Executes the python code fences in the tutorial pages so API drift breaks CI +instead of the docs quietly going stale. + +Notebook execution (``myst-nb`` / ``nbmake``) is intentionally out of scope for +1.0; this is the lighter-weight alternative: each tutorial's ```python blocks run +in one shared namespace, in source order, exactly as written on the page. Blocks +that only show expected console output (```text fences) are not touched. +""" + +import re +from pathlib import Path + +import pytest + +TUTORIALS_DIR = Path(__file__).parents[2] / "docs" / "tutorials" + +_FENCE = re.compile(r"^```python\n(.*?)^```\s*$", re.DOTALL | re.MULTILINE) + + +def _code_blocks(path: Path) -> list[tuple[int, str]]: + """Return (1-based start line, source) for every python fence in ``path``. + + The source is padded with leading blank lines so a traceback raised while + executing it reports the real line number in the markdown file. + """ + text = path.read_text(encoding="utf-8") + blocks = [] + for match in _FENCE.finditer(text): + start_line = text.count("\n", 0, match.start()) + 2 + padded = "\n" * (start_line - 1) + match.group(1) + blocks.append((start_line, padded)) + return blocks + + +_TUTORIALS = sorted(TUTORIALS_DIR.glob("*.md")) + + +@pytest.mark.parametrize("tutorial", _TUTORIALS, ids=[p.stem for p in _TUTORIALS]) +def test_tutorial_code_runs(tutorial): + namespace: dict = {"__name__": "__main__"} + for start_line, source in _code_blocks(tutorial): + try: + exec(compile(source, str(tutorial), "exec"), namespace) # noqa: S102 + except Exception as exc: + raise AssertionError(f"{tutorial.name}:{start_line} raised {exc!r}") from exc From 6d7b70f40fcf4979e430aed3a3f3e970b8cd1488 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Wed, 12 Aug 2026 20:59:23 +0200 Subject: [PATCH 48/59] docs: point readers at the quickstart script and security policy --- README.md | 1 + docs/developer_guide/testing.md | 2 ++ docs/getting_started/installation.md | 8 ++++++++ 3 files changed, 11 insertions(+) diff --git a/README.md b/README.md index 1f64a86..d9f3b6b 100644 --- a/README.md +++ b/README.md @@ -391,5 +391,6 @@ and our - **Issues:** [GitHub Issues](https://github.com/OpenTabular/PreTab/issues) - **Discussions:** [GitHub Discussions](https://github.com/OpenTabular/PreTab/discussions) +- **Security:** see [SECURITY.md](./SECURITY.md) for how to report a vulnerability privately. diff --git a/docs/developer_guide/testing.md b/docs/developer_guide/testing.md index 0c3f4ae..368ca45 100644 --- a/docs/developer_guide/testing.md +++ b/docs/developer_guide/testing.md @@ -33,6 +33,7 @@ directory. | `tests/extension/` | The public extensibility surface and conformance. | | `tests/integration/` | End-to-end `Preprocessor` and pipeline behaviour. | | `tests/regression/` | Pinned outputs that guard against silent numerical drift. | +| `tests/doc_snippets/` | Executes the `docs/tutorials/*.md` code fences, so the tutorials cannot silently rot. | ```{note} Regression tests pin known-good output. If one fails after a deliberate change to a @@ -85,6 +86,7 @@ Run the full local gate, which mirrors CI. just test # tests with coverage just check # lint, format, type-check across all files just docs # strict docs build +just quickstart # end-to-end sanity check: same script CI's smoke job runs ``` ## Where to go next diff --git a/docs/getting_started/installation.md b/docs/getting_started/installation.md index 66cfece..f13b17e 100644 --- a/docs/getting_started/installation.md +++ b/docs/getting_started/installation.md @@ -55,6 +55,14 @@ poetry install poetry run pre-commit install --hook-type commit-msg --hook-type pre-commit --hook-type pre-push ``` +To check that everything works end to end, run the quickstart script. It exercises mixed +preprocessing, feature lineage, leakage-safe cross-fitting, serialization, and more in a few +seconds, and doubles as the reviewer smoke test: + +```bash +just quickstart # or: python scripts/quickstart.py +``` + To work on the documentation, also install the docs group: ```bash From 7e5897b24e36f8af0aaeb088e437c13f8e5d30d5 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Wed, 12 Aug 2026 21:06:45 +0200 Subject: [PATCH 49/59] docs: add scikit-learn comparison to the overview page --- README.md | 4 ++++ docs/getting_started/overview.md | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/README.md b/README.md index d9f3b6b..c07fad4 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,10 @@ public, discoverable protocol. - **Composable and extensible.** Every strategy is a standalone transformer you can import, compose, or subclass; register your own representation and it behaves like a built-in. +> **Tip:** See +> [how this compares to scikit-learn's own preprocessing transformers](https://pretab.readthedocs.io/en/latest/getting_started/overview.html#how-this-compares-to-scikit-learns-preprocessing-transformers) +> for what each one adds where scope overlaps. + ## 🏃 Quickstart ```python diff --git a/docs/getting_started/overview.md b/docs/getting_started/overview.md index 37c5810..e2dbc12 100644 --- a/docs/getting_started/overview.md +++ b/docs/getting_started/overview.md @@ -32,6 +32,29 @@ pre = Preprocessor(feature_preprocessing={ X = pre.fit_transform(df, y) ``` +## How this compares to scikit-learn's preprocessing transformers + +PreTab is not a competitor to scikit-learn. Every transformer subclasses `BaseEstimator` and +`TransformerMixin` and drops into the same `Pipeline` and `ColumnTransformer` you already use. +The real question is what PreTab adds where scope overlaps with scikit-learn's own +`SplineTransformer`, `KBinsDiscretizer`, `PolynomialFeatures`, and `TargetEncoder`. + +| Capability | scikit-learn | PreTab | +| --- | --- | --- | +| Knot / threshold placement | Uniform or quantile, fixed before fitting | Optionally target-aware: a CART or LightGBM model places knots where the target changes fastest (`placement_strategy="cart"`) | +| How many basis functions | You pick a fixed count | `adaptive=True` searches a width in `[min_output_dim, max_output_dim]` from the data | +| Leakage safety | `TargetEncoder` cross-fits internally; nothing else does, and nothing warns you | Every supervised representation emits a `LeakageWarning` outside a `Pipeline`, and any of them can be wrapped in `CrossFittedTransformer` | +| Feature provenance | `get_feature_names_out()` returns names only | A typed `RepresentationSpec` per transformer plus a `FeatureLineage` record per output column (family, component, target usage) | +| Persistence | `pickle` / `joblib`, which execute arbitrary code on load | `to_spec()` / `from_spec()`: a versioned JSON schema that never runs estimator code, plus a stable `fingerprint_` | +| Choosing per column | Hand-assemble a `ColumnTransformer` yourself | One `Preprocessor(feature_preprocessing={...})`, validated against a capability registry so incompatible combinations (a required-target method without `y`, for example) raise a typed error at fit time | + +```{note} +Piecewise-linear encoding (`ple`) and the neural-style basis maps (`rbf`, `relu`, `sigmoid`, +`tanh`, deterministic `fourier`) have no scikit-learn equivalent. `rff` and `nystroem` are thin +wrappers around scikit-learn's own `RBFSampler` and `Nystroem`, exposed through the same +`Preprocessor` interface as every other method. +``` + ## When to reach for PreTab PreTab is a good fit when any of the following is true. From b59380d313544b2259d99d720700c4782b6f3d41 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Thu, 13 Aug 2026 09:08:45 +0200 Subject: [PATCH 50/59] fix(preprocessor): collapse duplicated feature name in output column names --- pretab/compose/inspection.py | 36 +++++++++++- pretab/preprocessor.py | 4 +- .../_golden/featuremap_unsupervised.json | 40 ++++++------- tests/regression/_golden/ple_supervised.json | 46 +++++++-------- .../_golden/spline_unsupervised.json | 58 +++++++++---------- 5 files changed, 110 insertions(+), 74 deletions(-) diff --git a/pretab/compose/inspection.py b/pretab/compose/inspection.py index a56332b..c3ea875 100644 --- a/pretab/compose/inspection.py +++ b/pretab/compose/inspection.py @@ -18,6 +18,7 @@ "build_feature_info", "build_feature_lineage", "build_transformer_summary", + "clean_feature_names", "get_output_slices", ] @@ -43,6 +44,37 @@ def get_output_slices(column_transformer, X): return slices +def clean_feature_names(column_transformer, names): + """Collapse the per-feature name that sklearn's ColumnTransformer duplicates. + + Each per-column step is named ``f"{kind}_{feature}"`` (see ``compose/factory.py``), + and every PreTab transformer's own ``get_feature_names_out`` already bakes the + input feature name into each output column, so sklearn's default + ``f"{step}__{inner}"`` naming doubles it, e.g. ``"num_age__age_bs0"``. This + collapses that back to ``"num_age_bs0"``, leaving passthrough/remainder columns + and any name it cannot confidently match unchanged. + """ + step_to_feature = { + name: columns[0] + for name, _transformer, columns in column_transformer.transformers_ + if name != "remainder" and len(columns) == 1 + } + cleaned = [] + for raw in names: + raw = str(raw) + step_name, sep, inner_name = raw.partition("__") + feature = step_to_feature.get(step_name) + if not sep or feature is None: + cleaned.append(raw) + continue + if inner_name == feature or inner_name.startswith(f"{feature}_"): + kind_prefix = step_name[: -(len(feature) + 1)] if step_name.endswith(f"_{feature}") else "" + cleaned.append(f"{kind_prefix}_{inner_name}" if kind_prefix else inner_name) + else: + cleaned.append(raw) + return cleaned + + def build_feature_info(column_transformer, *, embeddings, embedding_dimensions): """Collect per-feature metadata (preprocessing, dimension, categories). @@ -227,7 +259,9 @@ def build_feature_lineage(column_transformer): its source feature(s), representation family, and component, covering 100% of the transformed columns in ``get_feature_names_out`` order. """ - output_names = [str(name) for name in column_transformer.get_feature_names_out()] + output_names = clean_feature_names( + column_transformer, [str(name) for name in column_transformer.get_feature_names_out()] + ) output_indices = column_transformer.output_indices_ feature_names_in = getattr(column_transformer, "feature_names_in_", None) records = [] diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index 97d0696..34fcecf 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -19,6 +19,7 @@ build_feature_info, build_feature_lineage, build_transformer_summary, + clean_feature_names, get_output_slices, ) from .compose.output import compute_output_report, format_output, to_dataframe_output @@ -648,7 +649,8 @@ def get_feature_names_out(self, input_features=None): """ check_is_fitted(self) - return self.column_transformer_.get_feature_names_out(input_features) + raw_names = self.column_transformer_.get_feature_names_out(input_features) + return np.array(clean_feature_names(self.column_transformer_, raw_names)) def get_feature_lineage(self): """Return per-output-column provenance for the fitted preprocessor. diff --git a/tests/regression/_golden/featuremap_unsupervised.json b/tests/regression/_golden/featuremap_unsupervised.json index 78737a3..c69fd77 100644 --- a/tests/regression/_golden/featuremap_unsupervised.json +++ b/tests/regression/_golden/featuremap_unsupervised.json @@ -4,25 +4,25 @@ 20 ], "feature_names": [ - "num_num_linear__num_linear_rbf0", - "num_num_linear__num_linear_rbf1", - "num_num_linear__num_linear_rbf2", - "num_num_linear__num_linear_rbf3", - "num_num_linear__num_linear_rbf4", - "num_num_linear__num_linear_rbf5", - "num_num_normal__num_normal_rbf0", - "num_num_normal__num_normal_rbf1", - "num_num_normal__num_normal_rbf2", - "num_num_normal__num_normal_rbf3", - "num_num_normal__num_normal_rbf4", - "num_num_normal__num_normal_rbf5", - "num_num_skewed__num_skewed_rbf0", - "num_num_skewed__num_skewed_rbf1", - "num_num_skewed__num_skewed_rbf2", - "num_num_skewed__num_skewed_rbf3", - "num_num_skewed__num_skewed_rbf4", - "num_num_skewed__num_skewed_rbf5", - "cat_cat_str__cat_str", - "cat_cat_int__cat_int" + "num_num_linear_rbf0", + "num_num_linear_rbf1", + "num_num_linear_rbf2", + "num_num_linear_rbf3", + "num_num_linear_rbf4", + "num_num_linear_rbf5", + "num_num_normal_rbf0", + "num_num_normal_rbf1", + "num_num_normal_rbf2", + "num_num_normal_rbf3", + "num_num_normal_rbf4", + "num_num_normal_rbf5", + "num_num_skewed_rbf0", + "num_num_skewed_rbf1", + "num_num_skewed_rbf2", + "num_num_skewed_rbf3", + "num_num_skewed_rbf4", + "num_num_skewed_rbf5", + "cat_cat_str", + "cat_cat_int" ] } \ No newline at end of file diff --git a/tests/regression/_golden/ple_supervised.json b/tests/regression/_golden/ple_supervised.json index c8423ab..3646d27 100644 --- a/tests/regression/_golden/ple_supervised.json +++ b/tests/regression/_golden/ple_supervised.json @@ -4,28 +4,28 @@ 23 ], "feature_names": [ - "num_num_linear__num_linear_ple_piece0", - "num_num_linear__num_linear_ple_piece1", - "num_num_linear__num_linear_ple_piece2", - "num_num_linear__num_linear_ple_piece3", - "num_num_linear__num_linear_ple_piece4", - "num_num_normal__num_normal_ple_piece0", - "num_num_normal__num_normal_ple_piece1", - "num_num_normal__num_normal_ple_piece2", - "num_num_normal__num_normal_ple_piece3", - "num_num_normal__num_normal_ple_piece4", - "num_num_skewed__num_skewed_ple_piece0", - "num_num_skewed__num_skewed_ple_piece1", - "num_num_skewed__num_skewed_ple_piece2", - "num_num_skewed__num_skewed_ple_piece3", - "num_num_skewed__num_skewed_ple_piece4", - "cat_cat_str__cat_str_alpha", - "cat_cat_str__cat_str_beta", - "cat_cat_str__cat_str_gamma", - "cat_cat_int__cat_int_0", - "cat_cat_int__cat_int_1", - "cat_cat_int__cat_int_2", - "cat_cat_int__cat_int_3", - "cat_cat_int__cat_int_4" + "num_num_linear_ple_piece0", + "num_num_linear_ple_piece1", + "num_num_linear_ple_piece2", + "num_num_linear_ple_piece3", + "num_num_linear_ple_piece4", + "num_num_normal_ple_piece0", + "num_num_normal_ple_piece1", + "num_num_normal_ple_piece2", + "num_num_normal_ple_piece3", + "num_num_normal_ple_piece4", + "num_num_skewed_ple_piece0", + "num_num_skewed_ple_piece1", + "num_num_skewed_ple_piece2", + "num_num_skewed_ple_piece3", + "num_num_skewed_ple_piece4", + "cat_cat_str_alpha", + "cat_cat_str_beta", + "cat_cat_str_gamma", + "cat_cat_int_0", + "cat_cat_int_1", + "cat_cat_int_2", + "cat_cat_int_3", + "cat_cat_int_4" ] } \ No newline at end of file diff --git a/tests/regression/_golden/spline_unsupervised.json b/tests/regression/_golden/spline_unsupervised.json index 40931fe..dba9155 100644 --- a/tests/regression/_golden/spline_unsupervised.json +++ b/tests/regression/_golden/spline_unsupervised.json @@ -4,34 +4,34 @@ 29 ], "feature_names": [ - "num_num_linear__num_linear_ncs0", - "num_num_linear__num_linear_ncs1", - "num_num_linear__num_linear_ncs2", - "num_num_linear__num_linear_ncs3", - "num_num_linear__num_linear_ncs4", - "num_num_linear__num_linear_ncs5", - "num_num_linear__num_linear_ncs6", - "num_num_normal__num_normal_ncs0", - "num_num_normal__num_normal_ncs1", - "num_num_normal__num_normal_ncs2", - "num_num_normal__num_normal_ncs3", - "num_num_normal__num_normal_ncs4", - "num_num_normal__num_normal_ncs5", - "num_num_normal__num_normal_ncs6", - "num_num_skewed__num_skewed_ncs0", - "num_num_skewed__num_skewed_ncs1", - "num_num_skewed__num_skewed_ncs2", - "num_num_skewed__num_skewed_ncs3", - "num_num_skewed__num_skewed_ncs4", - "num_num_skewed__num_skewed_ncs5", - "num_num_skewed__num_skewed_ncs6", - "cat_cat_str__cat_str_alpha", - "cat_cat_str__cat_str_beta", - "cat_cat_str__cat_str_gamma", - "cat_cat_int__cat_int_0", - "cat_cat_int__cat_int_1", - "cat_cat_int__cat_int_2", - "cat_cat_int__cat_int_3", - "cat_cat_int__cat_int_4" + "num_num_linear_ncs0", + "num_num_linear_ncs1", + "num_num_linear_ncs2", + "num_num_linear_ncs3", + "num_num_linear_ncs4", + "num_num_linear_ncs5", + "num_num_linear_ncs6", + "num_num_normal_ncs0", + "num_num_normal_ncs1", + "num_num_normal_ncs2", + "num_num_normal_ncs3", + "num_num_normal_ncs4", + "num_num_normal_ncs5", + "num_num_normal_ncs6", + "num_num_skewed_ncs0", + "num_num_skewed_ncs1", + "num_num_skewed_ncs2", + "num_num_skewed_ncs3", + "num_num_skewed_ncs4", + "num_num_skewed_ncs5", + "num_num_skewed_ncs6", + "cat_cat_str_alpha", + "cat_cat_str_beta", + "cat_cat_str_gamma", + "cat_cat_int_0", + "cat_cat_int_1", + "cat_cat_int_2", + "cat_cat_int_3", + "cat_cat_int_4" ] } \ No newline at end of file From 2cc2ab0587f8200d88e317bd47e721b5be81944a Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Thu, 13 Aug 2026 09:08:45 +0200 Subject: [PATCH 51/59] docs(changelog): record the output-naming fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2db9ff4..d8878f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,7 @@ Going forward, this file is updated automatically by `cz bump` on each release. ### Refactor +- **preprocessor**: collapse the duplicated feature name in `Preprocessor` output column names (`get_feature_names_out()`, `return_array=True`, `set_output(transform="pandas"|"polars")`, and `get_feature_lineage()`); a column previously named `num_annual_income__annual_income_ncs0` is now `num_annual_income_ncs0`. Dict-mode output keys (`num_` / `cat_`) and standalone transformer usage outside `Preprocessor` are unaffected - **splines**: reformulate `ThinPlateSplineTransformer` as a multivariate low-rank thin-plate regression spline (landmark selection + eigen/Nyström basis via `n_components` / `landmark_strategy` / `rank_strategy`, replacing the univariate `output_dim` form) - **transformers**: rename `CustomBinTransformer` → `NumericBinningTransformer`, `CyclicalTimeTransformer` → `PeriodicEncodingTransformer`, and `CubicSplineTransformer` → `CubicRegressionSplineTransformer` (intention-revealing public names) - **transformers**: remove `LagFeatureTransformer` and `RollingStatsTransformer` (row-count-changing time-series utilities outside the tabular scope) From 54d5b863b39c353e7bb9ae1ea367f13e1e055d75 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Thu, 13 Aug 2026 09:45:34 +0200 Subject: [PATCH 52/59] test(preprocessor): add regression guards for the feature-name naming fix --- tests/compose/test_inspection.py | 33 ++++++++++++++++++++++++++ tests/integration/test_preprocessor.py | 13 ++++++++++ 2 files changed, 46 insertions(+) diff --git a/tests/compose/test_inspection.py b/tests/compose/test_inspection.py index 80366b3..5741c93 100644 --- a/tests/compose/test_inspection.py +++ b/tests/compose/test_inspection.py @@ -1,12 +1,14 @@ """Unit tests for :mod:`pretab.compose.inspection`.""" import numpy as np +import pandas as pd import pytest from pretab.compose.factory import build_column_transformer from pretab.compose.inspection import ( build_feature_info, build_transformer_summary, + clean_feature_names, get_output_slices, ) @@ -53,3 +55,34 @@ def test_build_transformer_summary_has_header_and_rows(): def test_build_transformer_summary_empty_returns_empty(): assert build_transformer_summary({}, {}, {}) == [] + + +def test_clean_feature_names_collapses_1_to_1_step(fitted_ct): + ct, _ = fitted_ct + raw = [str(name) for name in ct.get_feature_names_out()] + assert any("__" in name for name in raw), "sanity: sklearn's default naming should duplicate here" + assert clean_feature_names(ct, raw) == ["num_age", "cat_city"] + + +def test_clean_feature_names_handles_underscore_in_feature_name(make_config): + # "annual_income" itself contains "_", so a naive string split on "_" would + # mis-collapse this; the fix must use the ColumnTransformer's own column metadata. + df = pd.DataFrame({"annual_income": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]}) + ct = build_column_transformer(make_config(numerical_method="bspline", output_dim=5), ["annual_income"], []) + ct.fit(df) + raw = [str(name) for name in ct.get_feature_names_out()] + cleaned = clean_feature_names(ct, raw) + assert all("__" not in name for name in cleaned) + assert all(name.startswith("num_annual_income_bs") for name in cleaned) + + +def test_clean_feature_names_leaves_unmatched_names_untouched(fitted_ct): + ct, _ = fitted_ct + untouched = ["remainder__extra", "totally_unrelated_name"] + assert clean_feature_names(ct, untouched) == untouched + + +def test_clean_feature_names_leaves_non_matching_inner_name_untouched(fitted_ct): + ct, _ = fitted_ct + raw = ["num_age__somethingelse"] + assert clean_feature_names(ct, raw) == raw diff --git a/tests/integration/test_preprocessor.py b/tests/integration/test_preprocessor.py index 09ae3c2..15b4219 100644 --- a/tests/integration/test_preprocessor.py +++ b/tests/integration/test_preprocessor.py @@ -194,6 +194,19 @@ def test_get_feature_names_out_before_fit_raises(): Preprocessor().get_feature_names_out() +def test_get_feature_names_out_does_not_duplicate_feature_name(sample_data): + """Regression guard: output names must not repeat as num____... .""" + X, y = sample_data + pre = Preprocessor() + pre.fit(X, y) + names = list(pre.get_feature_names_out()) + assert names + assert all("__" not in name for name in names) + assert "num_num1_ple_piece0" in names + lineage_names = [record.output_feature for record in pre.get_feature_lineage()] + assert lineage_names == names + + def test_lowercase_and_none_method_resolution(sample_data): X, y = sample_data # Mixed-case / None methods are resolved at fit time, not stored on the instance. From 63e365ed2a62f1dc691a4e2ee20a08656215f62f Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Thu, 13 Aug 2026 18:53:57 +0200 Subject: [PATCH 53/59] docs: update feature representation --- docs/core_concepts/feature_representation.md | 57 ++++++++++---------- 1 file changed, 28 insertions(+), 29 deletions(-) diff --git a/docs/core_concepts/feature_representation.md b/docs/core_concepts/feature_representation.md index d17c276..b2bddab 100644 --- a/docs/core_concepts/feature_representation.md +++ b/docs/core_concepts/feature_representation.md @@ -1,23 +1,21 @@ # Preprocessing and representation PreTab draws a deliberate line between two ideas that are often blurred together: -*preprocessing* and *representation*. Understanding the distinction explains why the library -is shaped the way it is, and it is the key to using it well. +*preprocessing* and *representation*. The distinction shapes the whole library. ## Preprocessing prepares a column -Preprocessing makes a column safe and comparable for a model. It does not change what the -column *means*, only its scale, dtype, or completeness. Standardizing to zero mean and unit -variance, imputing a missing value, casting to float, and one-hot encoding a category are all -preprocessing. Each keeps a one-to-one relationship with the original signal. +Preprocessing makes a column safe and comparable for a model, without changing what it +*means*. Standardizing to zero mean and unit variance, imputing a missing value, casting to +float, and one-hot encoding a category are all preprocessing: each keeps a one-to-one +relationship with the original signal. -## Representation changes what the model can see +## Representation exposes structure A representation expands a column into a new basis that exposes structure a plain estimator -cannot weight on its own. A single numeric column becomes a set of spline coefficients, a -bank of radial bumps, a stack of piecewise-linear bins, or a pair of sine and cosine values. -The model now has several coordinates to weight where it previously had one slope, so it can -express curves, thresholds, saturation, and periodicity. +cannot weight on its own: spline coefficients, a bank of radial bumps, piecewise-linear bins, +or a sine/cosine pair. The model gets several coordinates to weight instead of one slope, so +it can express curves, thresholds, saturation, and periodicity. ```{note} This is the load-bearing idea in PreTab: the model is often fine, the *representation* is @@ -27,23 +25,24 @@ on raw columns cannot. ## Why the distinction matters -Keeping the two separate has practical consequences that show up all over the API. - -- **Scaling composes with representation.** A numeric column is typically imputed and scaled - first (preprocessing), then expanded into a basis (representation). The `Preprocessor` - wires this order for you. -- **Representations are self-describing.** Because an expansion is a real modelling choice, - every fitted representation carries a typed [`RepresentationSpec`](../api/preprocessor.rst) - and per-output-column [lineage](outputs_and_inspection.md), so you always know which input - and which component produced each output column. +- **Scaling composes with representation.** A numeric column is imputed and scaled first + (preprocessing), then expanded into a basis (representation). `Preprocessor` wires this + order for you. +- **Representations are self-describing.** Every fitted representation carries a typed + [`RepresentationSpec`](../api/preprocessor.rst) and per-output-column + [lineage](outputs_and_inspection.md), so you always know which input and which component + produced each output column. - **Some representations use the target.** Placing bins or knots where the target actually - changes is a supervised decision, which is why leakage safety is a first-class concern. See - [Target awareness](target_awareness.md). + changes is a supervised decision, which is why leakage safety is a first-class concern. + +```{warning} +Target-aware placement can leak information if fit outside a proper train/validation split. +See [Target awareness](target_awareness.md) for how PreTab guards against this. +``` ## The shared vocabulary -Every representation family in PreTab is described with the same small set of terms. Learning -them once pays off across the whole catalogue. +Every representation family is described with the same small set of terms. `family` : The kind of representation, for example spline, feature map, binning, periodic, or @@ -67,11 +66,11 @@ them once pays off across the whole catalogue. ## The intermediate representation -All of this is captured in one typed object, the `RepresentationSpec`, which is the common -intermediate form across every family. It records the family, input and output features, -scope, supervision, width, degree, and locations, and it round-trips to and from a plain -dict. Feature lineage then maps each individual output column back to its source. Together -they make a fitted PreTab pipeline fully inspectable and serializable. +Every family's fitted state is captured in one typed object, `RepresentationSpec`, the common +form across the whole catalogue. It records the family, input and output features, scope, +supervision, width, degree, and locations, and round-trips to and from a plain dict. Feature +lineage then maps each output column back to its source, making a fitted PreTab pipeline +fully inspectable and serializable. ```python spec = transformer.get_representation_spec() From 7ef19f3877692b9f942c0b965552555bf203b127 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 14 Aug 2026 20:45:39 +0200 Subject: [PATCH 54/59] docs: show concrete resolved values for each Preprocessor preset --- docs/core_concepts/configuration.md | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/docs/core_concepts/configuration.md b/docs/core_concepts/configuration.md index 3852253..51a09ce 100644 --- a/docs/core_concepts/configuration.md +++ b/docs/core_concepts/configuration.md @@ -48,21 +48,32 @@ feature-type detection. ## Presets Presets are transparent, named bundles of parameters for common intents. They set the same -knobs you could set by hand, so nothing is hidden. +knobs you could set by hand, so nothing is hidden, and each one resolves to a fixed, +documented set of values: -| Preset | Intent | -| --- | --- | -| `"standard"` | A balanced, general-purpose configuration. | -| `"expanded"` | Wider, more expressive representations. | -| `"adaptive"` | Data-driven per-feature width within bounds. | +| Preset | `numerical_method` | `categorical_method` | `output_dim` | `adaptive` | `max_output_dim` | +| --- | --- | --- | --- | --- | --- | +| `"standard"` | `"ple"` | `"int"` | `7` | `False` | `10` | +| `"expanded"` | `"ple"` | `"one-hot"` | `16` | `False` | `10` | +| `"adaptive"` | `"ple"` | `"int"` | `7` | `True` | `16` | ```python -pre = Preprocessor(preset="standard") +standard = Preprocessor(preset="standard") +expanded = Preprocessor(preset="expanded") + +standard.get_resolved_config()["categorical_method"] # "int": compact integer codes +expanded.get_resolved_config()["categorical_method"] # "one-hot": one column per category +expanded.get_resolved_config()["output_dim"] # 16: wider representations than "standard" ``` +So `"standard"` is the balanced default (PLE numerics, integer-coded categoricals, `output_dim=7`), +`"expanded"` widens the representation and one-hot-encodes categoricals instead, and +`"adaptive"` lets each feature pick its own width between `min_output_dim` and `max_output_dim` +rather than using a fixed `output_dim`. + ```{tip} A preset is a starting point, not a lock. Any parameter you pass alongside a preset overrides -the preset's value for that knob. +the preset's value for that knob, for example `Preprocessor(preset="expanded", output_dim=32)`. ``` ## Reading the resolved configuration From 5aacf57f9e6bc006ac75e5d03ecdcc46ab49c440 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 14 Aug 2026 21:04:21 +0200 Subject: [PATCH 55/59] fix(embeddings): add get_feature_names_out to LanguageEmbeddingTransformer --- .../categorical/language_embedding.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/pretab/transformers/categorical/language_embedding.py b/pretab/transformers/categorical/language_embedding.py index c18eabb..63c7f08 100644 --- a/pretab/transformers/categorical/language_embedding.py +++ b/pretab/transformers/categorical/language_embedding.py @@ -1,5 +1,6 @@ import numpy as np from sklearn.base import BaseEstimator, TransformerMixin +from sklearn.utils.validation import check_is_fitted from ...exceptions import OptionalDependencyError, PretabConfigError @@ -26,6 +27,8 @@ class LanguageEmbeddingTransformer(TransformerMixin, BaseEstimator): ``fit`` from ``model`` or by loading ``model_name``. n_features_in_ : int Number of input features seen during ``fit``. + embedding_dim_ : int + Dimensionality of the embeddings produced by ``model_``. Notes ----- @@ -75,6 +78,12 @@ def fit(self, X, y=None): """ self.n_features_in_ = X.shape[1] if len(X.shape) > 1 else 1 self.model_ = self._resolve_model() + # Read the embedding dim without calling encode() so call-count stays + # predictable; fall back to the 'dim' attribute used by test stubs. + if hasattr(self.model_, "get_sentence_embedding_dimension"): + self.embedding_dim_ = int(self.model_.get_sentence_embedding_dimension()) + else: + self.embedding_dim_ = int(getattr(self.model_, "dim", 0)) return self def transform(self, X): @@ -107,3 +116,26 @@ def transform(self, X): column_embeddings = [self.model_.encode(arr[:, i].tolist(), convert_to_numpy=True) for i in range(arr.shape[1])] return np.hstack(column_embeddings) + + def get_feature_names_out(self, input_features=None): + """Return output feature names: one per embedding dimension per input column. + + Parameters + ---------- + input_features : array-like of str or None + Input feature names. When ``None``, names of the form ``x0, x1, ...`` + are generated. + + Returns + ------- + feature_names_out : ndarray of str, shape (n_features_in_ * embedding_dim_,) + """ + check_is_fitted(self, ["n_features_in_", "embedding_dim_"]) + if input_features is None: + input_features = [f"x{i}" for i in range(self.n_features_in_)] + names = [ + f"{col}_emb{j}" + for col in input_features + for j in range(self.embedding_dim_) + ] + return np.asarray(names, dtype=object) From ed7bd4c13438d5151ffece35858ba08c548715fb Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 14 Aug 2026 21:07:47 +0200 Subject: [PATCH 56/59] fix: linting and formatting --- .gitignore | 2 +- README.md | 2 -- tests/regression/_golden/featuremap_unsupervised.json | 2 +- tests/regression/_golden/ple_supervised.json | 2 +- tests/regression/_golden/spline_unsupervised.json | 2 +- 5 files changed, 4 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index e18adda..24c912c 100644 --- a/.gitignore +++ b/.gitignore @@ -185,4 +185,4 @@ post-commit post-merge pre-push docs/notebooks/* -docs/notebooks/ \ No newline at end of file +docs/notebooks/ diff --git a/README.md b/README.md index c07fad4..1af32fc 100644 --- a/README.md +++ b/README.md @@ -396,5 +396,3 @@ and our - **Issues:** [GitHub Issues](https://github.com/OpenTabular/PreTab/issues) - **Discussions:** [GitHub Discussions](https://github.com/OpenTabular/PreTab/discussions) - **Security:** see [SECURITY.md](./SECURITY.md) for how to report a vulnerability privately. - - diff --git a/tests/regression/_golden/featuremap_unsupervised.json b/tests/regression/_golden/featuremap_unsupervised.json index c69fd77..30e67d6 100644 --- a/tests/regression/_golden/featuremap_unsupervised.json +++ b/tests/regression/_golden/featuremap_unsupervised.json @@ -25,4 +25,4 @@ "cat_cat_str", "cat_cat_int" ] -} \ No newline at end of file +} diff --git a/tests/regression/_golden/ple_supervised.json b/tests/regression/_golden/ple_supervised.json index 3646d27..f8bb231 100644 --- a/tests/regression/_golden/ple_supervised.json +++ b/tests/regression/_golden/ple_supervised.json @@ -28,4 +28,4 @@ "cat_cat_int_3", "cat_cat_int_4" ] -} \ No newline at end of file +} diff --git a/tests/regression/_golden/spline_unsupervised.json b/tests/regression/_golden/spline_unsupervised.json index dba9155..9fbe3bb 100644 --- a/tests/regression/_golden/spline_unsupervised.json +++ b/tests/regression/_golden/spline_unsupervised.json @@ -34,4 +34,4 @@ "cat_cat_int_3", "cat_cat_int_4" ] -} \ No newline at end of file +} From 8ef22d99024c5b13b116dd253d586e2ae45639d0 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 14 Aug 2026 21:13:34 +0200 Subject: [PATCH 57/59] fix: formatting --- pretab/transformers/categorical/language_embedding.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/pretab/transformers/categorical/language_embedding.py b/pretab/transformers/categorical/language_embedding.py index 63c7f08..bb8aace 100644 --- a/pretab/transformers/categorical/language_embedding.py +++ b/pretab/transformers/categorical/language_embedding.py @@ -133,9 +133,5 @@ def get_feature_names_out(self, input_features=None): check_is_fitted(self, ["n_features_in_", "embedding_dim_"]) if input_features is None: input_features = [f"x{i}" for i in range(self.n_features_in_)] - names = [ - f"{col}_emb{j}" - for col in input_features - for j in range(self.embedding_dim_) - ] + names = [f"{col}_emb{j}" for col in input_features for j in range(self.embedding_dim_)] return np.asarray(names, dtype=object) From 0025713006c39811daae8102326f66d5743deb0b Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 14 Aug 2026 21:17:42 +0200 Subject: [PATCH 58/59] fix(tests): narrow transform return type for pyright csr_matrix ufunc check --- tests/integration/test_missing_policy.py | 6 ++++++ tests/regression/test_edge_cases.py | 1 + 2 files changed, 7 insertions(+) diff --git a/tests/integration/test_missing_policy.py b/tests/integration/test_missing_policy.py index a67ac2a..974176d 100644 --- a/tests/integration/test_missing_policy.py +++ b/tests/integration/test_missing_policy.py @@ -62,6 +62,7 @@ def test_default_still_imputes(frame_with_nan, y): # missing_policy=None keeps numerical_imputation="median" authoritative. p = Preprocessor(numerical_method="minmax").fit(frame_with_nan, y) out = p.transform(frame_with_nan, return_array=True) + assert isinstance(out, np.ndarray) assert not np.isnan(out).any() @@ -82,6 +83,7 @@ def test_error_policy_raises_at_transform(clean_frame, frame_with_nan, y): def test_error_policy_passes_when_clean(clean_frame, y): p = _bspline(missing_policy="error").fit(clean_frame, y) out = p.transform(clean_frame, return_array=True) + assert isinstance(out, np.ndarray) assert np.isfinite(out).all() @@ -92,6 +94,7 @@ def test_propagate_lets_nan_through(frame_with_nan, y): # MinMaxScaler maintains NaNs at transform; with no imputer they survive. p = Preprocessor(numerical_method="minmax", missing_policy="propagate").fit(frame_with_nan, y) out = p.transform(frame_with_nan, return_array=True) + assert isinstance(out, np.ndarray) assert np.isnan(out).any() @@ -101,6 +104,7 @@ def test_propagate_lets_nan_through(frame_with_nan, y): def test_impute_removes_nan(frame_with_nan, y): p = Preprocessor(numerical_method="minmax", missing_policy="impute").fit(frame_with_nan, y) out = p.transform(frame_with_nan, return_array=True) + assert isinstance(out, np.ndarray) assert not np.isnan(out).any() @@ -118,6 +122,7 @@ def test_impute_with_indicator_appends_columns(frame_with_nan, y): withind = Preprocessor(numerical_method="minmax", missing_policy="impute_with_indicator").fit(frame_with_nan, y) assert withind.total_output_dim_ > plain.total_output_dim_ out = withind.transform(frame_with_nan, return_array=True) + assert isinstance(out, np.ndarray) assert not np.isnan(out).any() @@ -134,6 +139,7 @@ def test_separate_state_emits_missing_column(frame_with_nan, y): def test_separate_state_output_is_finite(frame_with_nan, y): p = _bspline(missing_policy="separate_state").fit(frame_with_nan, y) out = p.transform(frame_with_nan, return_array=True) + assert isinstance(out, np.ndarray) assert np.isfinite(out).all() diff --git a/tests/regression/test_edge_cases.py b/tests/regression/test_edge_cases.py index 8265254..5733551 100644 --- a/tests/regression/test_edge_cases.py +++ b/tests/regression/test_edge_cases.py @@ -85,6 +85,7 @@ def test_duplicate_support_points_are_handled(): def test_missing_values_imputed_by_default(): X = pd.DataFrame({"x": [1.0, 2.0, np.nan, 4.0, 5.0, 6.0]}) out = Preprocessor(numerical_method="minmax").fit_transform(X, return_array=True) + assert isinstance(out, np.ndarray) assert not np.isnan(out).any() From 96da76924cc97b4a6f1f2de3a6b7f1d67c81ba70 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 14 Aug 2026 21:26:25 +0200 Subject: [PATCH 59/59] fix(types): silence optional lightgbm import and narrow array_equal args --- pretab/core/selectors.py | 2 +- tests/integration/test_frozen_lifecycle.py | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/pretab/core/selectors.py b/pretab/core/selectors.py index 3e1fb6e..a92cd93 100644 --- a/pretab/core/selectors.py +++ b/pretab/core/selectors.py @@ -313,7 +313,7 @@ def __init__( @staticmethod def _import_lightgbm(): try: - import lightgbm as lgb + import lightgbm as lgb # type: ignore[import-untyped] except ImportError as exc: raise OptionalDependencyError( "LightGBMLocationSelector requires the optional 'lightgbm' dependency. " diff --git a/tests/integration/test_frozen_lifecycle.py b/tests/integration/test_frozen_lifecycle.py index 9310b54..61692f8 100644 --- a/tests/integration/test_frozen_lifecycle.py +++ b/tests/integration/test_frozen_lifecycle.py @@ -74,9 +74,11 @@ def test_refit_returns_new_object_and_leaves_original(frame, target): assert refit.is_frozen() is False # Original stays frozen and untouched. assert p.is_frozen() is True - assert np.array_equal( - p.transform(frame, return_array=True), refit.transform(frame, return_array=True), equal_nan=True - ) + out_frozen = p.transform(frame, return_array=True) + out_refit = refit.transform(frame, return_array=True) + assert isinstance(out_frozen, np.ndarray) + assert isinstance(out_refit, np.ndarray) + assert np.array_equal(out_frozen, out_refit, equal_nan=True) def test_mark_stale(frame, target):