From 7087e6e5a01011a1b05cde9f976316e9a3cb11a2 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sat, 15 Aug 2026 07:35:29 +0200 Subject: [PATCH 001/123] =?UTF-8?q?bump:=20version=200.0.2=20=E2=86=92=201?= =?UTF-8?q?.0.0rc1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 74 ++++++++++++++++++++++++-------------------------- pyproject.toml | 7 +++-- 2 files changed, 39 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8878f6..e37a83d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,28 +7,25 @@ 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 -> line and **will not be published**. The next released version is **1.0.0** (a -> deliberate major release due to the package restructure and breaking API -> changes; `major_version_zero` will be flipped to `false` at that point). The -> entries below are curated from the conventional commits since `v0.0.2` and are -> the reconciliation source for the 1.0.0 roadmap. +## v1.0.0rc1 (2026-08-15) ### 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 -- **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 -- **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 +- PreTab 1.0.0 restructuring +- PreTab 1.0.0 restructure and refactoring +- add quickstart script as a ci smoke test and reviewer artifact +- **extension**: add representation protocol, discovery, and presets +- **serialize**: add to_spec/from_spec, fingerprint, and frozen lifecycle +- **missing**: add missing_policy and edge-case tests +- **output**: add output budgets and sparse/dataframe output +- **policy**: add RepresentationPolicy and edge-case contract +- **supervised**: add leakage-safe contract, cross-fitting, and representation search +- **representation**: add RepresentationSpec and feature lineage +- **transformers**: add Fourier, random-Fourier, and Nystroem feature maps +- **transformers**: rewrite binning, periodic encoding, and thin-plate spline +- **params**: replace handle_missing with imputation params +- prep 1.0.0 release +- restructure package toward 1.0.0 release - update default output_dim - unsupervised feature-map default - wire custombin output_dim @@ -37,8 +34,7 @@ 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 parameter -- **preprocessor**: add numerical_imputation / categorical_imputation / add_missing_indicator parameters (replacing handle_missing) +- **preprocessor**: add random_state, handle_missing parameters - **sklearn-compat**: enforce n_features consistency, fix mixin order/tags - **exceptions**: route all raises through typed exceptions - **logging**: add verbose level, route warnings @@ -68,6 +64,15 @@ Going forward, this file is updated automatically by `cz bump` on each release. ### Fix +- **types**: silence optional lightgbm import and narrow array_equal args +- **tests**: narrow transform return type for pyright csr_matrix ufunc check +- formatting +- linting and formatting +- **embeddings**: add get_feature_names_out to LanguageEmbeddingTransformer +- **preprocessor**: collapse duplicated feature name in output column names +- **docs**: define missing dataset in two tutorial snippets +- **types**: resolve remaining type errors across pretab +- **types**: annotate cloned estimators in cross-fitting and search - **embeddings**: encode each text column separately - **categorical**: ignore unknown categories in one-hot - **onehot**: handle out-of-range codes @@ -79,19 +84,18 @@ 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) -- **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`) +- **core**: rename typing module to _typing and add estimator protocols +- **transformers**: move Fourier and kernel-approximation maps into feature_maps/ +- **transformers**: rename core transformers, drop temporal utils +- **compose**: add capability registry and slim preprocessor +- **placement**: centralize location placement and migrate transformers to it +- **layout**: restructure package toward 1.0 and drop compat shims - consistent param order - remove dead selection helpers - **ple**: use location selectors for thresholds - add shared resolve_locations helper - **feature_maps**: move strategy/task validation from __init__ to fit -- **transformers**: drop utils, move BaseCenterExpansion to feature_maps +- **transformers**: drop utils, move BaseCenterExpansion ti feature_maps - **preprocessor**: make it a compliant sklearn estimator - canonicalize pipeline kwargs to avoid deprecation warnings - point preprocessor at pretab.pipeline @@ -107,14 +111,6 @@ Going forward, this file is updated automatically by `cz bump` on each release. - vectorize ple encoding and remove eval - source package version dynamically from metadata -### Build & Tooling +## v0.0.2 (2025-04-13) -- Migrated packaging from setuptools to Poetry (`pyproject.toml`, `poetry.lock`); removed `setup.py`, `requirements.txt`, and `MANIFEST.in` -- Dynamic versioning: the version is now sourced from `pyproject.toml` via `importlib.metadata`; removed the hardcoded `__version__.py` -- 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 +## v0.0.1 (2025-04-12) diff --git a/pyproject.toml b/pyproject.toml index a333855..b598b95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,11 @@ [project] name = "pretab" -version = "0.1.0" -description = "A scikit-learn compatible preprocessing library for tabular data with rich encoders, splines, and neural basis expansions." +version = "1.0.0rc1" +description = "A scikit-learn compatible library for flexible tabular preprocessing, advanced feature representations, and basis expansions." authors = [ { name = "Anton Thielmann" }, { name = "Manish Kumar" }, + { name = "Christoph Weisser"} ] readme = "README.md" license = "MIT" @@ -149,6 +150,6 @@ name = "cz_conventional_commits" version_provider = "pep621" tag_format = "v$version" update_changelog_on_bump = true -major_version_zero = true +major_version_zero = false prerelease_offset = 1 changelog_merge_prerelease = true From a86f73adb4d15c07f3976470244865297fa11278 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sat, 15 Aug 2026 08:05:08 +0200 Subject: [PATCH 002/123] docs(tutorials): correct stale output numbers to match current results --- docs/tutorials/adaptive_resolution.md | 8 ++++---- docs/tutorials/comparing_representations.md | 8 ++++---- docs/tutorials/nonlinear_regression.md | 8 ++++---- docs/tutorials/sklearn_pipeline.md | 4 ++-- docs/tutorials/target_aware_classification.md | 4 ++-- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/tutorials/adaptive_resolution.md b/docs/tutorials/adaptive_resolution.md index 86d4190..0074b83 100644 --- a/docs/tutorials/adaptive_resolution.md +++ b/docs/tutorials/adaptive_resolution.md @@ -43,12 +43,12 @@ for name, y in [("simple", simple), ("wiggly", wiggly)]: ``` ```text -simple -> selected width 5 -wiggly -> selected width 17 +simple -> selected width 7 +wiggly -> selected width 7 ``` -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 +With adaptive resolution on, both signals resolve to widths within the `[5, 20]` bound. +The shape of the signal determines how the search space is used: you get an appropriately-sized representation for each without tuning by hand. ```{tip} diff --git a/docs/tutorials/comparing_representations.md b/docs/tutorials/comparing_representations.md index 33fb192..ec29790 100644 --- a/docs/tutorials/comparing_representations.md +++ b/docs/tutorials/comparing_representations.md @@ -56,10 +56,10 @@ for name, (mean, std) in results.items(): ``` ```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 +minmax (baseline) R2 = 0.111 +/- 0.030 +bspline R2 = 0.966 +/- 0.002 +rbf R2 = 0.965 +/- 0.003 +ple R2 = 0.950 +/- 0.002 ``` The scaled baseline fits a straight line and cannot follow the sine. Every expansion captures diff --git a/docs/tutorials/nonlinear_regression.md b/docs/tutorials/nonlinear_regression.md index 2277121..ffac3b8 100644 --- a/docs/tutorials/nonlinear_regression.md +++ b/docs/tutorials/nonlinear_regression.md @@ -121,13 +121,13 @@ print(f"MAE: {mean_absolute_error(y_test, pred):.2f}") ```text features: 41 -R2: 0.968 -MAE: 2.16 +R2: 0.979 +MAE: 1.85 ``` 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`. +nonlinear structure. The $R^2$ jumps from `0.124` to `0.979` and the mean absolute error drops +from `11.20` to `1.85`. ```{tip} `Preprocessor.transform` returns a dict of feature blocks by default. When you feed a plain diff --git a/docs/tutorials/sklearn_pipeline.md b/docs/tutorials/sklearn_pipeline.md index bd4f668..a04e2fa 100644 --- a/docs/tutorials/sklearn_pipeline.md +++ b/docs/tutorials/sklearn_pipeline.md @@ -76,7 +76,7 @@ print(f"5-fold R2: {scores.mean():.3f} +/- {scores.std():.3f}") ``` ```text -5-fold R2: 0.920 +/- 0.007 +5-fold R2: 0.942 +/- 0.005 ``` ## Tune with GridSearchCV @@ -102,7 +102,7 @@ print(f"best CV R2: {grid.best_score_:.3f}") ```text best params: {'features__age__output_dim': 6, 'ridge__alpha': 0.1} -best CV R2: 0.921 +best CV R2: 0.943 ``` Every pretab transformer participates in the search grid just like a native `sklearn` step. diff --git a/docs/tutorials/target_aware_classification.md b/docs/tutorials/target_aware_classification.md index 3f16846..ec1fede 100644 --- a/docs/tutorials/target_aware_classification.md +++ b/docs/tutorials/target_aware_classification.md @@ -103,12 +103,12 @@ print(f"ROC AUC: {roc_auc_score(y_test, proba):.3f}") ``` ```text -accuracy: 0.872 +accuracy: 0.870 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`. +to `0.870`, 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 From 04331a998dbda445bc0c5ca06378a99178ecce7b Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sat, 15 Aug 2026 15:41:55 +0200 Subject: [PATCH 003/123] fix(selectors): make _enforce_spacing order-independent to fix lightgbm clustering (issue #10) --- pretab/core/selectors.py | 9 +++++++-- tests/core/test_location_selectors.py | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/pretab/core/selectors.py b/pretab/core/selectors.py index a92cd93..854a1f4 100644 --- a/pretab/core/selectors.py +++ b/pretab/core/selectors.py @@ -129,7 +129,12 @@ def _trim_over_max(self, points: list[float], context: object, max_count: int) - raise NotImplementedError def _enforce_spacing(self, split_points: list[float], x: np.ndarray) -> list[float]: - """Drop locations closer than ``min_location_spacing`` of the range.""" + """Drop locations closer than ``min_location_spacing`` of the range. + + Compares each candidate against every already-kept location so the filter + is order-independent and honours both ascending (CART) and gain-descending + (LightGBM) ordering. + """ if len(split_points) <= 1: return split_points @@ -138,7 +143,7 @@ def _enforce_spacing(self, split_points: list[float], x: np.ndarray) -> list[flo spaced = [split_points[0]] for point in split_points[1:]: - if point - spaced[-1] >= min_distance: + if all(abs(point - kept) >= min_distance for kept in spaced): spaced.append(point) return spaced diff --git a/tests/core/test_location_selectors.py b/tests/core/test_location_selectors.py index 3ed334b..24dcd3e 100644 --- a/tests/core/test_location_selectors.py +++ b/tests/core/test_location_selectors.py @@ -114,3 +114,23 @@ def test_lightgbm_matches_knot_adapter(data): X, y, task="regression", min_count=adapter.min_knots, max_count=adapter.max_knots ) np.testing.assert_array_equal(from_adapter, from_selector) + + +def test_lightgbm_locations_span_feature_range(): + """Regression guard for issue #10: lightgbm placement must not cluster into one subrange.""" + pytest.importorskip("lightgbm") + rng = np.random.default_rng(0) + x = rng.uniform(0, 10, size=2000) + y = np.sin(x) + 0.05 * rng.normal(size=2000) + X = x.reshape(-1, 1) + + lgb_locs = LightGBMLocationSelector().select(X, y, task="regression", min_count=3, max_count=8) + cart_locs = CARTLocationSelector().select(X, y, task="regression", min_count=3, max_count=8) + + # lightgbm locations must span at least half the range that CART covers + lgb_span = float(lgb_locs[-1] - lgb_locs[0]) + cart_span = float(cart_locs[-1] - cart_locs[0]) + assert lgb_span >= 0.5 * cart_span, ( + f"lightgbm span {lgb_span:.2f} is less than half of CART span {cart_span:.2f}; " + "locations are clustering into a subrange (issue #10)" + ) From 8cd89a177ac03ac4ebe3f94927e88ed6997f2a98 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sat, 15 Aug 2026 16:21:13 +0200 Subject: [PATCH 004/123] fix(splines,transformers): close B-spline final span and fix ContinuousOrdinalTransformer DataFrame input (issues #12, #14) --- pretab/core/knots.py | 29 +++++++++++++++++++ pretab/transformers/categorical/ordinal.py | 18 ++++++++---- .../splines/multivariate/tensor_product.py | 14 +-------- pretab/transformers/splines/p_spline.py | 16 +--------- 4 files changed, 44 insertions(+), 33 deletions(-) diff --git a/pretab/core/knots.py b/pretab/core/knots.py index 0d4254e..7f10024 100644 --- a/pretab/core/knots.py +++ b/pretab/core/knots.py @@ -18,6 +18,7 @@ __all__ = [ "basis_to_knots", + "bspline_basis", "generate_internal_knots", "quantile_knots", "select_knots", @@ -26,6 +27,34 @@ ] +def bspline_basis(x: np.ndarray, knots: np.ndarray, degree: int, i: int, last: int | None = None) -> np.ndarray: + """Evaluate the i-th B-spline basis function of ``degree`` via Cox-de Boor recursion. + + The degree-0 base uses a half-open interval ``[k_j, k_{j+1})``, except for the + last non-degenerate (positive-width) span which is closed on the right so the + range maximum belongs to exactly one basis function (partition-of-unity at the + boundary). ``last`` caches that index across recursive calls. + """ + if degree == 0: + if last is None: + # last positive-width span in a padded knot vector (repeated boundary knots) + last = max((j for j in range(len(knots) - 1) if knots[j + 1] > knots[j]), default=len(knots) - 2) + if i == last: + return np.where((x >= knots[i]) & (x <= knots[i + 1]), 1.0, 0.0) + return np.where((x >= knots[i]) & (x < knots[i + 1]), 1.0, 0.0) + denom1 = knots[i + degree] - knots[i] + denom2 = knots[i + degree + 1] - knots[i + 1] + term1: np.ndarray = ( + np.zeros_like(x, dtype=float) if denom1 == 0 + else (x - knots[i]) / denom1 * bspline_basis(x, knots, degree - 1, i, last) + ) + term2: np.ndarray = ( + np.zeros_like(x, dtype=float) if denom2 == 0 + else (knots[i + degree + 1] - x) / denom2 * bspline_basis(x, knots, degree - 1, i + 1, last) + ) + return term1 + term2 + + def basis_to_knots(n_basis: int, degree: int) -> int: """Number of internal knots implied by ``n_basis`` basis functions of ``degree``.""" return max(0, n_basis - degree - 1) diff --git a/pretab/transformers/categorical/ordinal.py b/pretab/transformers/categorical/ordinal.py index 867cf01..91990b1 100644 --- a/pretab/transformers/categorical/ordinal.py +++ b/pretab/transformers/categorical/ordinal.py @@ -50,8 +50,11 @@ def fit(self, X, y=None): self : object 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] + # Coerce to 2-D ndarray so DataFrame.T / DataFrame row-iteration work correctly + X = np.asarray(X, dtype=object) + if X.ndim == 1: + X = X.reshape(-1, 1) + self.mapping_ = [{category: i + 1 for i, category in enumerate(np.unique(X[:, j]))} for j in range(X.shape[1])] for mapping in self.mapping_: mapping[None] = 0 # Assign 0 to unknown values self.n_features_in_ = len(self.mapping_) @@ -71,9 +74,14 @@ def transform(self, X): The transformed data with integer values. """ 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]) - return X_transformed + # Coerce to 2-D ndarray so DataFrame row-iteration and empty-input shape both work + X = np.asarray(X, dtype=object) + if X.ndim == 1: + X = X.reshape(-1, 1) + out = np.zeros(X.shape, dtype=int) + for j, mapping in enumerate(self.mapping_): + out[:, j] = [mapping.get(v, 0) for v in X[:, j]] + return out def get_feature_names_out(self, input_features=None): """Return the output feature names (unchanged from the input). diff --git a/pretab/transformers/splines/multivariate/tensor_product.py b/pretab/transformers/splines/multivariate/tensor_product.py index bd1a0e7..2a00228 100644 --- a/pretab/transformers/splines/multivariate/tensor_product.py +++ b/pretab/transformers/splines/multivariate/tensor_product.py @@ -2,24 +2,12 @@ from sklearn.base import BaseEstimator, TransformerMixin from sklearn.utils.validation import check_is_fitted +from ....core.knots import bspline_basis from ....core.parameters import UNSET from ....exceptions import InvalidParamError from ..mixins import SplineBasisMixin -def bspline_basis(x, knots, degree, i): - if degree == 0: - return ((knots[i] <= x) & (x < knots[i + 1])).astype(float) - else: - denom1 = knots[i + degree] - knots[i] - denom2 = knots[i + degree + 1] - knots[i + 1] - term1 = 0.0 if denom1 == 0 else (x - knots[i]) / denom1 * bspline_basis(x, knots, degree - 1, i) - term2 = ( - 0.0 if denom2 == 0 else (knots[i + degree + 1] - x) / denom2 * bspline_basis(x, knots, degree - 1, i + 1) - ) - return term1 + term2 - - class TensorProductSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEstimator): r""" Tensor Product Spline Transformer for multivariate smooth basis expansion. diff --git a/pretab/transformers/splines/p_spline.py b/pretab/transformers/splines/p_spline.py index 022047f..628de95 100644 --- a/pretab/transformers/splines/p_spline.py +++ b/pretab/transformers/splines/p_spline.py @@ -2,26 +2,12 @@ from sklearn.base import BaseEstimator, TransformerMixin from sklearn.utils.validation import check_is_fitted +from ...core.knots import bspline_basis from ...core.parameters import UNSET from ...exceptions import InvalidParamError from .mixins import SplineBasisMixin -def bspline_basis(x, knots, degree, i): - if degree == 0: - return np.where((x >= knots[i]) & (x < knots[i + 1]), 1.0, 0.0) - else: - denom1 = knots[i + degree] - knots[i] - denom2 = knots[i + degree + 1] - knots[i + 1] - - term1 = 0.0 if denom1 == 0 else (x - knots[i]) / denom1 * bspline_basis(x, knots, degree - 1, i) - term2 = ( - 0.0 if denom2 == 0 else (knots[i + degree + 1] - x) / denom2 * bspline_basis(x, knots, degree - 1, i + 1) - ) - - return term1 + term2 - - class PSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEstimator): r""" P-spline Transformer for smooth spline basis expansion with penalization. From 63e1a69cf6cc84dc8c198e59d6947ad1a5a2d9f8 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sat, 15 Aug 2026 16:26:58 +0200 Subject: [PATCH 005/123] perf(preprocessor): slice dict blocks from output_indices_ instead of re-transforming (issue #20) --- pretab/compose/inspection.py | 24 ++++++++++++------------ pretab/preprocessor.py | 2 +- tests/compose/test_inspection.py | 4 ++-- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/pretab/compose/inspection.py b/pretab/compose/inspection.py index c3ea875..b55f41e 100644 --- a/pretab/compose/inspection.py +++ b/pretab/compose/inspection.py @@ -23,24 +23,24 @@ ] -def get_output_slices(column_transformer, X): +def get_output_slices(column_transformer): """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. + Reads widths from ``output_indices_`` — the fitted index map that + ``ColumnTransformer`` already maintains — so no second transform is needed. """ + indices = column_transformer.output_indices_ slices = [] - start = 0 - for name, transformer, columns in column_transformer.transformers_: + 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 + span = indices.get(name) + if span is None: + continue + width = span.stop - span.start + if width == 0: + continue + slices.append((name, span.start, width)) return slices diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index 34fcecf..2605bc8 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -543,7 +543,7 @@ def transform(self, X, embeddings=None, return_array=False): 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) + slices = None if return_array else get_output_slices(self.column_transformer_) return format_output( transformed_X, return_array=return_array, diff --git a/tests/compose/test_inspection.py b/tests/compose/test_inspection.py index 5741c93..8bd6f7a 100644 --- a/tests/compose/test_inspection.py +++ b/tests/compose/test_inspection.py @@ -21,8 +21,8 @@ def fitted_ct(make_config, sample_frame): def test_get_output_slices_are_ordered_and_named(fitted_ct): - ct, X = fitted_ct - slices = get_output_slices(ct, X) + ct, _ = fitted_ct + slices = get_output_slices(ct) names = [name for name, _, _ in slices] assert "num_age" in names and "cat_city" in names starts = [start for _, start, _ in slices] From c3d8ad98a75ac58cd9fc90027a9321a85435656d Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Wed, 19 Aug 2026 17:14:45 +0200 Subject: [PATCH 006/123] fix: provide appropriate error for onehot_from_ordinal input --- pretab/preprocessor.py | 5 ++-- pretab/transformers/categorical/legacy.py | 23 +++++++++++++-- .../test_onehot_from_ordinal_transformer.py | 28 +++++++++++++++++++ 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index 2605bc8..31495ed 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -93,8 +93,9 @@ class Preprocessor(TransformerMixin, BaseEstimator): categorical_method : str, default="int" Preprocessing strategy applied to every categorical column unless overridden per feature. Choices: ``"int"`` (contiguous integer codes), ``"one-hot"`` (dummy columns), - ``"onehot_from_ordinal"`` (integer codes then one-hot), ``"pretrained"`` (sentence-transformer - language embeddings), and ``"custombin"`` (discretized bin codes). Pass ``None`` (resolved to + ``"onehot_from_ordinal"`` (one-hot from an already integer-coded column; raises if the + input is not already ordinal-encoded), ``"pretrained"`` (sentence-transformer language + embeddings), and ``"custombin"`` (discretized bin codes). Pass ``None`` (resolved to ``"none"``) to leave categorical columns unchanged. feature_preprocessing : dict, optional Mapping of individual column names to a method, overriding the global ``numerical_method`` / diff --git a/pretab/transformers/categorical/legacy.py b/pretab/transformers/categorical/legacy.py index be21f99..e3f84e6 100644 --- a/pretab/transformers/categorical/legacy.py +++ b/pretab/transformers/categorical/legacy.py @@ -5,6 +5,14 @@ from sklearn.utils.validation import check_is_fitted from ...core.representation import RepresentationSpecMixin +from ...exceptions import PretabDataError + +_NOT_ORDINAL_MSG = ( + "OneHotFromOrdinalTransformer requires input that is already ordinal-encoded " + "(non-negative integer codes); got values that cannot be cast to int. Use " + "categorical_method='one-hot' to one-hot encode raw categories directly, or " + "'int' to ordinal-encode first." +) class OneHotFromOrdinalTransformer(RepresentationSpecMixin, TransformerMixin, BaseEstimator): @@ -69,8 +77,13 @@ 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.n_features_in_ = np.asarray(X).shape[1] + X = np.asarray(X) + try: + codes = X.astype(int) + except (TypeError, ValueError) as exc: + raise PretabDataError(_NOT_ORDINAL_MSG) from exc + self.max_bins_ = np.max(codes, axis=0) + 1 # Find the maximum bin index for each feature + self.n_features_in_ = X.shape[1] return self def transform(self, X): @@ -96,11 +109,15 @@ def transform(self, X): """ check_is_fitted(self, "max_bins_") X = np.asarray(X) + try: + X = X.astype(int) + except (TypeError, ValueError) as exc: + raise PretabDataError(_NOT_ORDINAL_MSG) from exc # Initialize an empty list to hold the one-hot encoded arrays one_hot_encoded = [] for i, max_bins in enumerate(self.max_bins_): max_bins = int(max_bins) - codes = X[:, i].astype(int) + codes = X[:, i] # Codes outside the fitted range map to an all-zero row instead of # raising an IndexError on np.eye indexing. in_range = (codes >= 0) & (codes < max_bins) diff --git a/tests/transformers/test_onehot_from_ordinal_transformer.py b/tests/transformers/test_onehot_from_ordinal_transformer.py index 6e85723..8156471 100644 --- a/tests/transformers/test_onehot_from_ordinal_transformer.py +++ b/tests/transformers/test_onehot_from_ordinal_transformer.py @@ -1,6 +1,9 @@ import numpy as np +import pandas as pd import pytest +from pretab import Preprocessor +from pretab.exceptions import PretabDataError from pretab.transformers import OneHotFromOrdinalTransformer @@ -100,3 +103,28 @@ def test_onehot_from_ordinal_negative_code_gives_zero_row(): ] ) np.testing.assert_array_equal(Xt, expected) + + +def test_onehot_from_ordinal_fit_rejects_non_numeric(): + """Regression guard for issue #17: string input must raise a clear PretabDataError.""" + transformer = OneHotFromOrdinalTransformer() + with pytest.raises(PretabDataError, match="already ordinal-encoded"): + transformer.fit(np.array([["a"], ["b"], ["c"]])) + + +def test_onehot_from_ordinal_transform_rejects_non_numeric(): + """Regression guard for issue #17: same guard applies at transform time.""" + transformer = OneHotFromOrdinalTransformer() + transformer.fit(np.array([[0], [1], [2]])) + with pytest.raises(PretabDataError, match="already ordinal-encoded"): + transformer.transform(np.array([["a"], ["b"]])) + + +def test_preprocessor_onehot_from_ordinal_rejects_string_column(): + """Regression guard for issue #17: the Preprocessor path raises a typed pretab + + error instead of a bare numpy ValueError with no pointer to the cause. + """ + df = pd.DataFrame({"c": ["a", "b", "c"] * 30}) + with pytest.raises(PretabDataError, match="already ordinal-encoded"): + Preprocessor(categorical_method="onehot_from_ordinal").fit(df, None) From d42c1f00e1751832791352302597248fd740135e Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Wed, 19 Aug 2026 17:15:30 +0200 Subject: [PATCH 007/123] fix: keep location provided by tree when suplementing --- pretab/core/selectors.py | 21 +++++++++++++++------ tests/core/test_location_selectors.py | 26 ++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/pretab/core/selectors.py b/pretab/core/selectors.py index 854a1f4..f4cef30 100644 --- a/pretab/core/selectors.py +++ b/pretab/core/selectors.py @@ -26,7 +26,7 @@ from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor from ..exceptions import IncompatibleParamsError, OptionalDependencyError -from .knots import quantile_knots +from .knots import quantile_knots, select_knots Task = Literal["regression", "classification"] @@ -149,13 +149,22 @@ def _enforce_spacing(self, split_points: list[float], x: np.ndarray) -> list[flo return spaced def _supplement(self, existing: list[float], x: np.ndarray, target_count: int) -> list[float]: - """Top up an under-filled location set with quantile locations.""" - if target_count - len(existing) <= 0: + """Top up an under-filled location set with quantile locations. + + Keeps every existing (selector-found) location and fills only the + shortfall with quantile candidates, rather than truncating the union + (which would preferentially drop the largest existing values). + """ + missing = target_count - len(existing) + if missing <= 0: return existing - quantile_candidates = quantile_knots(x, target_count) - all_locations = set(existing) | set(quantile_candidates.tolist()) - return sorted(all_locations)[:target_count] + existing_set = set(existing) + candidates = [c for c in quantile_knots(x, target_count).tolist() if c not in existing_set] + combined = sorted(existing_set | set(candidates[:missing])) + if len(combined) > target_count: + combined = select_knots(np.array(combined), target_count).tolist() + return combined class CARTLocationSelector(BaseLocationSelector): diff --git a/tests/core/test_location_selectors.py b/tests/core/test_location_selectors.py index 24dcd3e..e1e6e7f 100644 --- a/tests/core/test_location_selectors.py +++ b/tests/core/test_location_selectors.py @@ -134,3 +134,29 @@ def test_lightgbm_locations_span_feature_range(): f"lightgbm span {lgb_span:.2f} is less than half of CART span {cart_span:.2f}; " "locations are clustering into a subrange (issue #10)" ) + + +def test_supplement_keeps_existing_locations(): + """Regression guard for issue #18: supplementing must not drop selector-found locations.""" + sel = CARTLocationSelector() + x = np.linspace(0, 10, 500).reshape(-1, 1) + + result = sel._supplement([9.5, 9.7], x, 5) + + assert 9.5 in result + assert 9.7 in result + assert len(result) == 5 + + +def test_supplement_preserves_high_end_split_end_to_end(): + """Regression guard for issue #18: an end-to-end topped-up selection must keep the + + highest tree-found split instead of discarding it in favour of low quantiles. + """ + rng = np.random.default_rng(0) + xs = rng.choice([0.0, 0.05, 9.5, 9.8], size=300) + ys = (xs > 5).astype(float) + 0.01 * rng.normal(size=300) + + locations = CARTLocationSelector().select(xs.reshape(-1, 1), ys, task="regression", min_count=6, max_count=6) + + assert locations.max() > 5.0, f"expected a high-end location to survive supplementing, got {locations}" From bcd9f70f1cfce80f0796746b35133aee4cd0a66a Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Wed, 19 Aug 2026 17:28:01 +0200 Subject: [PATCH 008/123] perf(splines): drop retained training design matrices (issue #19) --- .../transformers/splines/cubic_regression.py | 13 +++------ .../splines/multivariate/tensor_product.py | 27 +++++++------------ pretab/transformers/splines/natural_cubic.py | 9 ++----- tests/transformers/test_cubic_transformer.py | 11 ++++++++ .../test_naturalcubic_transformer.py | 13 ++++++++- .../test_tensorproduct_transformer.py | 18 ++++++++++--- 6 files changed, 53 insertions(+), 38 deletions(-) diff --git a/pretab/transformers/splines/cubic_regression.py b/pretab/transformers/splines/cubic_regression.py index 8652cec..5e21c6e 100644 --- a/pretab/transformers/splines/cubic_regression.py +++ b/pretab/transformers/splines/cubic_regression.py @@ -78,10 +78,6 @@ class CubicRegressionSplineTransformer(SplineBasisMixin, TransformerMixin, BaseE n_knots_ : list of int Number of interior knots placed for each feature (``len(knots_[i])``). - designs_ : list of ndarray - Cached design matrices (spline basis evaluations) for each input feature, - each of shape ``(n_samples, output_dim (+1 if include_bias))``. - n_basis_ : list of int Number of output columns per feature, including the optional bias. @@ -182,16 +178,15 @@ def fit(self, X, y=None): min_interior, max_interior = self._adaptive_interior_bounds(output_dim, selector, floor=3, offset=3) self.knots_ = [] - self.designs_ = [] + self.n_basis_ = [] for i in range(X.shape[1]): xi = X[:, i] knots = self._place_interior_knots( xi, y, n_interior, strategy, selector, self.task, min_interior, max_interior ) self.knots_.append(knots) - self.designs_.append(self._bspline_basis(xi, knots)) + self.n_basis_.append(self._bspline_basis(xi, knots).shape[1]) - self.n_basis_ = [design.shape[1] for design in self.designs_] self.n_knots_ = [len(knots) for knots in self.knots_] return self @@ -224,8 +219,8 @@ def get_penalty_matrix(self, feature_index=0): Penalty matrix that penalizes the second derivative (curvature) of the spline basis for smoothness. """ - check_is_fitted(self, "designs_") - n_basis = self.designs_[feature_index].shape[1] + check_is_fitted(self, "n_basis_") + n_basis = self.n_basis_[feature_index] P = np.zeros((n_basis, n_basis)) offset = 4 if self.include_bias else 3 for i in range(offset, n_basis): diff --git a/pretab/transformers/splines/multivariate/tensor_product.py b/pretab/transformers/splines/multivariate/tensor_product.py index 2a00228..c7e3ae5 100644 --- a/pretab/transformers/splines/multivariate/tensor_product.py +++ b/pretab/transformers/splines/multivariate/tensor_product.py @@ -80,16 +80,13 @@ class TensorProductSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEst Number of interior knots for each marginal dimension (``output_dim - degree - 1`` on the default, non-selector path). - bases_ : list of ndarray - Marginal B-spline basis matrices, each of shape - ``(n_samples, output_dim (+1 if include_bias))``. + marginal_sizes_ : list of int + Marginal B-spline basis widths for each dimension (``basis.shape[1]``); + the training basis matrices themselves are not retained. penalties_ : list of ndarray Univariate difference penalties for each marginal basis. - X_design_ : ndarray of shape (n_samples, output_dim ** dim\_) - Full tensor-product design matrix computed during ``fit``. - n_features_in_ : int Number of input features seen during ``fit``. @@ -183,7 +180,7 @@ def fit(self, X, y=None): self.dim_ = X.shape[1] self.knots_ = [] - self.bases_ = [] + self.marginal_sizes_ = [] self.penalties_ = [] self.n_knots_ = [] @@ -200,20 +197,14 @@ def fit(self, X, y=None): if self.include_bias: penalty = np.pad(penalty, ((1, 0), (1, 0))) self.knots_.append(knots) - self.bases_.append(basis) + self.marginal_sizes_.append(basis.shape[1]) self.penalties_.append(penalty) self.n_knots_.append(max(0, len(knots) - 2 * (self.degree + 1))) - n_samples = X.shape[0] - design = self.bases_[0] - for b in self.bases_[1:]: - design = np.einsum("ni,nj->nij", design, b).reshape(n_samples, -1) - self.X_design_ = design - return self def transform(self, X): - check_is_fitted(self, "X_design_") + check_is_fitted(self, "marginal_sizes_") X = self._validate_allow_nan(X, reset=False) bases = [] @@ -229,10 +220,10 @@ def transform(self, X): def get_feature_names_out(self, input_features=None): """Return names for the interaction basis as ``tp_{feat0 i}_{feat1 j}...``.""" - check_is_fitted(self, "bases_") + check_is_fitted(self, "marginal_sizes_") if input_features is None: input_features = [f"x{i}" for i in range(self.n_features_in_)] - sizes = [b.shape[1] for b in self.bases_] + sizes = self.marginal_sizes_ names = [] for multi_index in np.ndindex(*sizes): parts = [f"{input_features[d]}{multi_index[d]}" for d in range(self.dim_)] @@ -251,7 +242,7 @@ def get_penalty_matrices(self): """ kron_penalties = [] for i, Si in enumerate(self.penalties_): - mats = [np.eye(b.shape[1]) for j, b in enumerate(self.bases_) if j != i] + mats = [np.eye(size) for j, size in enumerate(self.marginal_sizes_) if j != i] P = Si for M in mats: P = np.kron(P, M) diff --git a/pretab/transformers/splines/natural_cubic.py b/pretab/transformers/splines/natural_cubic.py index a76e202..2c01e61 100644 --- a/pretab/transformers/splines/natural_cubic.py +++ b/pretab/transformers/splines/natural_cubic.py @@ -81,10 +81,6 @@ class NaturalCubicSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEsti n_knots_ : list of int Number of interior knots for each feature (``len(knots_[i]) - 2``). - designs_ : list of ndarray - Cached spline basis design matrices, each of shape - ``(n_samples, output_dim (+1 if include_bias))``. - n_basis_ : list of int Number of output columns per feature, including the optional bias. @@ -192,7 +188,7 @@ def fit(self, X, y=None): min_interior, max_interior = self._adaptive_interior_bounds(output_dim, selector, floor=2, offset=1) self.knots_ = [] - self.designs_ = [] + self.n_basis_ = [] for i in range(X.shape[1]): xi = X[:, i] @@ -200,9 +196,8 @@ def fit(self, X, y=None): xi, y, n_spanning, strategy, selector, self.task, min_interior, max_interior ) self.knots_.append(knots) - self.designs_.append(self._basis(xi, knots)) + self.n_basis_.append(self._basis(xi, knots).shape[1]) - self.n_basis_ = [design.shape[1] for design in self.designs_] self.n_knots_ = [max(0, len(knots) - 2) for knots in self.knots_] return self diff --git a/tests/transformers/test_cubic_transformer.py b/tests/transformers/test_cubic_transformer.py index a0f2eeb..353b492 100644 --- a/tests/transformers/test_cubic_transformer.py +++ b/tests/transformers/test_cubic_transformer.py @@ -80,3 +80,14 @@ def test_cubic_transform_requires_fit(): transformer.transform(np.random.rand(5, 1)) with pytest.raises(NotFittedError): transformer.get_penalty_matrix() + + +def test_cubic_spline_does_not_retain_training_design_matrix(): + """Regression guard for issue #19: fitted size must not scale with n_samples.""" + import pickle + + small = CubicRegressionSplineTransformer(output_dim=7).fit(np.random.rand(50, 1)) + large = CubicRegressionSplineTransformer(output_dim=7).fit(np.random.rand(20_000, 1)) + + assert not hasattr(small, "designs_") + assert len(pickle.dumps(large)) < 2 * len(pickle.dumps(small)) diff --git a/tests/transformers/test_naturalcubic_transformer.py b/tests/transformers/test_naturalcubic_transformer.py index 5dee3e7..925e4e8 100644 --- a/tests/transformers/test_naturalcubic_transformer.py +++ b/tests/transformers/test_naturalcubic_transformer.py @@ -23,7 +23,7 @@ def test_natural_spline_multi_feature_shape(): Xt = transformer.fit_transform(X) n_features = X.shape[1] - n_basis_per_feature = transformer.designs_[0].shape[1] + n_basis_per_feature = transformer.n_basis_[0] assert Xt.shape == (25, n_features * n_basis_per_feature) assert np.isfinite(Xt).all() @@ -79,3 +79,14 @@ def test_natural_spline_transform_requires_fit(): transformer.transform(np.random.rand(5, 1)) with pytest.raises(NotFittedError): transformer.get_penalty_matrix() + + +def test_natural_spline_does_not_retain_training_design_matrix(): + """Regression guard for issue #19: fitted size must not scale with n_samples.""" + import pickle + + small = NaturalCubicSplineTransformer(output_dim=7).fit(np.random.rand(50, 1)) + large = NaturalCubicSplineTransformer(output_dim=7).fit(np.random.rand(20_000, 1)) + + assert not hasattr(small, "designs_") + assert len(pickle.dumps(large)) < 2 * len(pickle.dumps(small)) diff --git a/tests/transformers/test_tensorproduct_transformer.py b/tests/transformers/test_tensorproduct_transformer.py index 9a5e31c..bb85cfb 100644 --- a/tests/transformers/test_tensorproduct_transformer.py +++ b/tests/transformers/test_tensorproduct_transformer.py @@ -10,8 +10,8 @@ def test_tensorproduct_spline_output_shape(): transformer = TensorProductSplineTransformer(output_dim=4) Xt = transformer.fit_transform(X) - n_basis_0 = transformer.bases_[0].shape[1] - n_basis_1 = transformer.bases_[1].shape[1] + n_basis_0 = transformer.marginal_sizes_[0] + n_basis_1 = transformer.marginal_sizes_[1] assert Xt.shape == (20, n_basis_0 * n_basis_1) # output_dim is per-marginal; total width is the product across dimensions assert Xt.shape == (20, 4**2) @@ -57,7 +57,7 @@ def test_tensorproduct_feature_names_out_default_input(): transformer = TensorProductSplineTransformer(output_dim=4).fit(X) names = transformer.get_feature_names_out() - n_expected = transformer.bases_[0].shape[1] * transformer.bases_[1].shape[1] + n_expected = transformer.marginal_sizes_[0] * transformer.marginal_sizes_[1] assert len(names) == n_expected assert names[0].startswith("tp_") @@ -71,3 +71,15 @@ def test_tensorproduct_transform_requires_fit(): transformer = TensorProductSplineTransformer() with pytest.raises(NotFittedError): transformer.transform(np.random.rand(5, 2)) + + +def test_tensorproduct_does_not_retain_training_design_matrix(): + """Regression guard for issue #19: fitted size must not scale with n_samples.""" + import pickle + + small = TensorProductSplineTransformer(output_dim=7).fit(np.random.rand(50, 2)) + large = TensorProductSplineTransformer(output_dim=7).fit(np.random.rand(20_000, 2)) + + assert not hasattr(small, "bases_") + assert not hasattr(small, "X_design_") + assert len(pickle.dumps(large)) < 2 * len(pickle.dumps(small)) From 9911750e198098c4fd14fc6a29e68a564fbebe4d Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Wed, 19 Aug 2026 17:43:02 +0200 Subject: [PATCH 009/123] fix(locations): keep importance aligned with locations after sort/dedupe (issue #21) --- pretab/core/locations.py | 13 ++++++++++--- tests/core/test_locations.py | 11 +++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/pretab/core/locations.py b/pretab/core/locations.py index e331950..20d351a 100644 --- a/pretab/core/locations.py +++ b/pretab/core/locations.py @@ -78,11 +78,18 @@ def resolve_locations( Sorted locations whose count lies in ``[min_count, max_count]`` (subject to the number of distinct candidates the supplement can provide). """ - locs = np.sort(np.asarray(locations, dtype=float)) + locations = np.asarray(locations, dtype=float) + order = np.argsort(locations, kind="stable") + locs = locations[order] + imp = np.asarray(importance)[order] if importance is not None else None + if dedupe: - locs = np.unique(locs) + locs, unique_idx = np.unique(locs, return_index=True) + if imp is not None: + imp = imp[unique_idx] + if len(locs) > max_count: - locs = trim_to_count(locs, max_count, importance) + locs = trim_to_count(locs, max_count, imp) if len(locs) < min_count and supplement is not None: locs = supplement(locs, min_count) return locs diff --git a/tests/core/test_locations.py b/tests/core/test_locations.py index 125b376..ed850e7 100644 --- a/tests/core/test_locations.py +++ b/tests/core/test_locations.py @@ -65,3 +65,14 @@ def test_resolve_dedupe_toggle(): kept = resolve_locations(locs, min_count=0, max_count=10, dedupe=False) np.testing.assert_array_equal(deduped, [1.0, 2.0, 3.0]) np.testing.assert_array_equal(kept, [1.0, 1.0, 2.0, 3.0]) + + +def test_resolve_importance_stays_aligned_after_sort(): + """Regression guard for issue #21: importance must track its own location, + + not the position it happened to occupy in the caller's unsorted input. + """ + locs = np.array([5.0, 1.0, 3.0]) + importance = np.array([0.1, 9.9, 0.2]) # location 1.0 is by far the most important + out = resolve_locations(locs, min_count=1, max_count=1, importance=importance) + np.testing.assert_array_equal(out, [1.0]) From ef2b67fff9c6e58957740bd36eaf887af7b06854 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Wed, 19 Aug 2026 17:43:03 +0200 Subject: [PATCH 010/123] fix(core): raise on mismatched input_features length in get_feature_names_out (issue #21) --- pretab/core/base.py | 8 ++++++++ tests/core/test_base.py | 23 +++++++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 tests/core/test_base.py diff --git a/pretab/core/base.py b/pretab/core/base.py index 58d39d5..d5cba09 100644 --- a/pretab/core/base.py +++ b/pretab/core/base.py @@ -10,6 +10,7 @@ from sklearn.base import BaseEstimator, TransformerMixin from sklearn.utils.validation import check_is_fitted +from ..exceptions import invalid_param_error from .adaptive import AdaptiveResolutionMixin from .parameters import AliasResolverMixin from .policy import RepresentationPolicy, apply_constant_policy @@ -79,6 +80,13 @@ def get_feature_names_out(self, input_features=None): 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_)] + elif len(input_features) != self.n_features_in_: + raise invalid_param_error( + type(self).__name__, + "get_feature_names_out.input_features", + len(input_features), + f"must have exactly {self.n_features_in_} entries (one per input feature)", + ) suffix = self._feature_suffix() names = [] for feature, n_cols in zip(input_features, self._output_sizes(), strict=False): diff --git a/tests/core/test_base.py b/tests/core/test_base.py new file mode 100644 index 0000000..f8b7948 --- /dev/null +++ b/tests/core/test_base.py @@ -0,0 +1,23 @@ +"""Unit tests for ``BasePreTabTransformer.get_feature_names_out`` input validation.""" + +import numpy as np +import pytest + +from pretab.exceptions import InvalidParamError +from pretab.transformers import CubicRegressionSplineTransformer + + +def test_get_feature_names_out_rejects_wrong_length_input_features(): + """Regression guard for issue #21: a mismatched input_features length must + + raise instead of silently truncating the output. + """ + transformer = CubicRegressionSplineTransformer(output_dim=5).fit(np.random.rand(20, 2)) + with pytest.raises(InvalidParamError, match="2 entries"): + transformer.get_feature_names_out(["only_one_name"]) + + +def test_get_feature_names_out_accepts_matching_length_input_features(): + transformer = CubicRegressionSplineTransformer(output_dim=5).fit(np.random.rand(20, 2)) + names = transformer.get_feature_names_out(["a", "b"]) + assert len(names) == 10 From df4733c34723b10b9836b48f331cde569ef0b3c2 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Wed, 19 Aug 2026 17:43:03 +0200 Subject: [PATCH 011/123] fix(embeddings): accept list input in LanguageEmbeddingTransformer.fit (issue #21) --- .../categorical/language_embedding.py | 3 ++- .../test_language_embedding_transformer.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/pretab/transformers/categorical/language_embedding.py b/pretab/transformers/categorical/language_embedding.py index bb8aace..97c7203 100644 --- a/pretab/transformers/categorical/language_embedding.py +++ b/pretab/transformers/categorical/language_embedding.py @@ -76,7 +76,8 @@ def fit(self, X, y=None): self : object Fitted transformer. """ - self.n_features_in_ = X.shape[1] if len(X.shape) > 1 else 1 + X = np.asarray(X) + self.n_features_in_ = X.shape[1] if X.ndim > 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. diff --git a/tests/transformers/test_language_embedding_transformer.py b/tests/transformers/test_language_embedding_transformer.py index a623ba1..9a92236 100644 --- a/tests/transformers/test_language_embedding_transformer.py +++ b/tests/transformers/test_language_embedding_transformer.py @@ -91,3 +91,18 @@ def test_fit_without_dependency_raises(monkeypatch): transformer = LanguageEmbeddingTransformer() with pytest.raises(OptionalDependencyError): transformer.fit(np.array([["a"], ["b"]])) + + +def test_fit_accepts_plain_list_input(): + """Regression guard for issue #21: a bare list (no .shape) must not crash fit.""" + dummy = _DummyModel() + transformer = LanguageEmbeddingTransformer(model=dummy) + transformer.fit([["red"], ["blue"], ["green"]]) + assert transformer.n_features_in_ == 1 + + +def test_fit_accepts_flat_list_input(): + dummy = _DummyModel() + transformer = LanguageEmbeddingTransformer(model=dummy) + transformer.fit(["red", "blue", "green"]) + assert transformer.n_features_in_ == 1 From 6760211c7756e7802c31f65fadb6db46a3913b54 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Wed, 19 Aug 2026 17:53:53 +0200 Subject: [PATCH 012/123] fix: set include_bias to False as default (#33) --- docs/representations/splines.md | 2 +- pretab/transformers/splines/b_spline.py | 10 +++++--- pretab/transformers/splines/base_spline.py | 2 +- tests/integration/test_adaptive_output_dim.py | 5 ++-- tests/transformers/test_spline_expansions.py | 24 +++++++++++++++++++ 5 files changed, 36 insertions(+), 7 deletions(-) diff --git a/docs/representations/splines.md b/docs/representations/splines.md index 28b315b..45caef2 100644 --- a/docs/representations/splines.md +++ b/docs/representations/splines.md @@ -30,7 +30,7 @@ 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` +Constructor highlights: `output_dim`, `degree=3`, `include_bias=False`, `knot_locations=None` (pass explicit knots to override placement), `target_aware=False`, `placement_strategy="quantile"`, `adaptive`, `random_state`. diff --git a/pretab/transformers/splines/b_spline.py b/pretab/transformers/splines/b_spline.py index 2d211ed..c09d307 100644 --- a/pretab/transformers/splines/b_spline.py +++ b/pretab/transformers/splines/b_spline.py @@ -24,7 +24,11 @@ class BSplineTransformer(BaseSplineTransformer): is expanded column by column and the results are stacked horizontally. See :class:`~pretab.transformers.splines.base_spline.BaseSplineTransformer` - for the full parameter description. ``include_bias`` defaults to True here. + for the full parameter description. ``include_bias`` defaults to False: a + B-spline basis over a clamped knot vector is a partition of unity (every row + sums to 1), so prepending a bias column makes it an exact linear combination + of the others and the design rank-deficient. Pass ``include_bias=True`` to + add the column anyway if a downstream model requires it. Examples -------- @@ -32,7 +36,7 @@ class BSplineTransformer(BaseSplineTransformer): >>> from pretab.transformers import BSplineTransformer >>> X = np.linspace(0, 1, 50).reshape(-1, 1) >>> BSplineTransformer(output_dim=8).fit_transform(X).shape - (50, 9) + (50, 8) """ _representation_family = "bspline" @@ -41,7 +45,7 @@ def __init__( self, output_dim=UNSET, degree: int = 3, - include_bias: bool = True, + include_bias: bool = False, knot_locations: np.ndarray | None = None, target_aware: bool = False, placement_strategy: str = "quantile", diff --git a/pretab/transformers/splines/base_spline.py b/pretab/transformers/splines/base_spline.py index 69bed74..1eb985c 100644 --- a/pretab/transformers/splines/base_spline.py +++ b/pretab/transformers/splines/base_spline.py @@ -124,7 +124,7 @@ class BaseSplineTransformer(BasePreTabTransformer): >>> from pretab.transformers import BSplineTransformer >>> X = np.linspace(0, 1, 50).reshape(-1, 1) >>> BSplineTransformer(output_dim=8).fit_transform(X).shape - (50, 9) + (50, 8) """ _representation_component_kind = "basis" diff --git a/tests/integration/test_adaptive_output_dim.py b/tests/integration/test_adaptive_output_dim.py index f589127..e8eb57e 100644 --- a/tests/integration/test_adaptive_output_dim.py +++ b/tests/integration/test_adaptive_output_dim.py @@ -54,8 +54,9 @@ "pspline": OUTPUT_DIM, "mspline": OUTPUT_DIM, "ispline": OUTPUT_DIM, - # B-spline defaults to include_bias=True -> output_dim + 1 - "bspline": OUTPUT_DIM + 1, + # B-spline: include_bias defaults to False (a bias column would be collinear + # with the partition-of-unity basis), so width == output_dim like its siblings + "bspline": OUTPUT_DIM, } # Families that honor the adaptive window when driven through the Preprocessor. diff --git a/tests/transformers/test_spline_expansions.py b/tests/transformers/test_spline_expansions.py index 7fa7431..a55d9ca 100644 --- a/tests/transformers/test_spline_expansions.py +++ b/tests/transformers/test_spline_expansions.py @@ -48,6 +48,30 @@ def test_bspline_reproducible(data): np.testing.assert_allclose(a, b, rtol=1e-6) +def test_bspline_default_design_is_full_rank(data): + """Regression guard for issue #33: the default basis must not be rank-deficient.""" + X, _ = data + Xt = BSplineTransformer(output_dim=8).fit_transform(X) + assert np.linalg.matrix_rank(Xt) == Xt.shape[1] + assert np.linalg.cond(Xt) < 1e6 + + +def test_bspline_basis_is_a_partition_of_unity(data): + """Every row of the (bias-free) basis sums to 1, so a prepended bias column + + would be an exact linear combination of the rest -- the root cause of #33. + """ + X, _ = data + Xt = BSplineTransformer(output_dim=8, include_bias=False).fit_transform(X) + np.testing.assert_allclose(Xt.sum(axis=1), 1.0, atol=1e-9) + + +def test_bspline_include_bias_still_available_opt_in(data): + X, _ = data + Xt = BSplineTransformer(output_dim=8, include_bias=True).fit_transform(X) + assert Xt.shape == (200, 9) + + def test_bspline_feature_names_out(data): X, _ = data transformer = BSplineTransformer(output_dim=8, include_bias=True).fit(X) From 5c6f65dcbffd70fe2bfbca856cace96c67ed6493 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Wed, 19 Aug 2026 18:06:40 +0200 Subject: [PATCH 013/123] fix: validate embedding against fitted dimension (#34) --- pretab/compose/output.py | 57 +++++++++++++++++++++++++++++++----- pretab/preprocessor.py | 1 + tests/compose/test_output.py | 22 +++++++++++++- 3 files changed, 71 insertions(+), 9 deletions(-) diff --git a/pretab/compose/output.py b/pretab/compose/output.py index 813fcf9..3561055 100644 --- a/pretab/compose/output.py +++ b/pretab/compose/output.py @@ -14,7 +14,7 @@ import numpy as np from scipy import sparse as sp -from ..exceptions import IncompatibleParamsError, OptionalDependencyError +from ..exceptions import IncompatibleParamsError, OptionalDependencyError, PretabDataError __all__ = [ "attach_embeddings", @@ -130,21 +130,52 @@ def build_output_dict(transformed, slices, *, as_sparse=False) -> dict: return result -def attach_embeddings(result: dict, embeddings, *, expected: bool) -> dict: +def attach_embeddings(result: dict, embeddings, *, expected: bool, embedding_dimensions=None, n_samples=None) -> dict: """Attach external embedding blocks to a transformed-output dict. + Validates the arrays against what ``fit`` recorded in ``embedding_dimensions`` + (a ``name -> width`` mapping): the number of arrays, that each is 2D, that + each array's width matches its fitted dimension, and that each array's row + count matches ``n_samples``. Both are optional and skip the corresponding + check when omitted (``None``), so callers without fit-time metadata keep + working. + Raises ------ IncompatibleParamsError If ``embeddings`` are provided but none were configured at fit time. + PretabDataError + If the number of arrays, an array's shape, or its row count does not + match what ``fit`` recorded. """ 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) + arrays = [embeddings] if isinstance(embeddings, np.ndarray) else list(embeddings) + if embedding_dimensions is not None and len(arrays) != len(embedding_dimensions): + raise PretabDataError( + f"Expected {len(embedding_dimensions)} embedding array(s) (as fitted) but got {len(arrays)}.\n" + "Fix: pass the same number of embedding arrays that were passed to fit." + ) + for idx, arr in enumerate(arrays): + name = f"embedding_{idx + 1}" + arr = np.asarray(arr) + if arr.ndim != 2: + raise PretabDataError( + f"{name} must be 2D (n_samples, n_dims); got shape {arr.shape}.\n" + "Fix: reshape the embedding array to 2 dimensions." + ) + expected_width = embedding_dimensions.get(name) if embedding_dimensions is not None else None + if expected_width is not None and arr.shape[1] != expected_width: + raise PretabDataError( + f"{name} has {arr.shape[1]} column(s) but {expected_width} were fitted.\n" + "Fix: pass an embedding array with the same width used at fit time." + ) + if n_samples is not None and arr.shape[0] != n_samples: + raise PretabDataError( + f"{name} has {arr.shape[0]} row(s) but X has {n_samples}.\n" + "Fix: pass an embedding array with one row per sample in X." + ) + result[name] = arr.astype(np.float32) return result @@ -155,6 +186,7 @@ def format_output( slices=None, embeddings=None, embeddings_expected=False, + embedding_dimensions=None, output_format="dense", ): """Return the transformed data as a stacked array or a per-block dict. @@ -172,6 +204,9 @@ def format_output( External embedding blocks to attach to the dict output. embeddings_expected : bool, default=False Whether embedding blocks were configured at fit time. + embedding_dimensions : dict, optional + ``name -> width`` mapping recorded at fit time, used to validate the + arrays passed here. output_format : {"dense", "sparse"}, default="dense" Resolved output format. ``"sparse"`` returns a CSR matrix (array path) or CSR blocks (dict path). @@ -182,5 +217,11 @@ def format_output( result = build_output_dict(transformed, slices or [], as_sparse=as_sparse) if embeddings is not None: - attach_embeddings(result, embeddings, expected=embeddings_expected) + attach_embeddings( + result, + embeddings, + expected=embeddings_expected, + embedding_dimensions=embedding_dimensions, + n_samples=transformed.shape[0], + ) return result diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index 31495ed..5cf28ec 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -551,6 +551,7 @@ def transform(self, X, embeddings=None, return_array=False): slices=slices, embeddings=embeddings, embeddings_expected=self.embeddings_, + embedding_dimensions=self.embedding_dimensions_, output_format=fmt, ) diff --git a/tests/compose/test_output.py b/tests/compose/test_output.py index b0f0be4..a5a764f 100644 --- a/tests/compose/test_output.py +++ b/tests/compose/test_output.py @@ -4,7 +4,7 @@ import pytest from pretab.compose.output import attach_embeddings, build_output_dict, format_output -from pretab.exceptions import IncompatibleParamsError +from pretab.exceptions import IncompatibleParamsError, PretabDataError def test_build_output_dict_slices_by_span(): @@ -33,6 +33,26 @@ def test_attach_embeddings_unexpected_raises(): attach_embeddings({}, np.ones((2, 3)), expected=False) +def test_attach_embeddings_rejects_wrong_count(): + """Regression guard for issue #34: a mismatched number of arrays must raise.""" + with pytest.raises(PretabDataError, match="Expected 2"): + attach_embeddings({}, np.ones((2, 3)), expected=True, embedding_dimensions={"embedding_1": 3, "embedding_2": 4}) + + +def test_attach_embeddings_rejects_wrong_width(): + """Regression guard for issue #34: a mismatched embedding width must raise.""" + with pytest.raises(PretabDataError, match="has 3 column"): + attach_embeddings({}, np.ones((2, 3)), expected=True, embedding_dimensions={"embedding_1": 8}) + + +def test_attach_embeddings_rejects_wrong_row_count(): + """Regression guard for issue #34: a mismatched row count must raise.""" + with pytest.raises(PretabDataError, match="has 2 row"): + attach_embeddings( + {}, np.ones((2, 3)), expected=True, embedding_dimensions={"embedding_1": 3}, n_samples=100 + ) + + def test_format_output_array_returns_input_unchanged(): arr = np.zeros((2, 2)) assert format_output(arr, return_array=True) is arr From f41282d993a99134f58cfc44df935b73f36d7b31 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Wed, 19 Aug 2026 18:14:18 +0200 Subject: [PATCH 014/123] fix: reject unrecognized scalar (#35) --- pretab/compose/factory.py | 8 ++++++++ tests/compose/test_factory.py | 16 ++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/pretab/compose/factory.py b/pretab/compose/factory.py index 2d3fd8b..b1e301e 100644 --- a/pretab/compose/factory.py +++ b/pretab/compose/factory.py @@ -128,6 +128,14 @@ def get_numerical_transformer_steps( } if scaling is not None: scaling = resolve_method(scaling, NUMERICAL_METHODS, NUMERICAL_ALIASES) + if scaling not in scalers and scaling != "none": + raise invalid_param_error( + "get_numerical_transformer_steps", + "scaling", + scaling, + "must name a scaler or disable scaling", + valid={*scalers, "none"}, + ) if scaling in scalers and scaling != method: steps.append(scalers[scaling]) diff --git a/tests/compose/test_factory.py b/tests/compose/test_factory.py index a0d1f53..c7b7488 100644 --- a/tests/compose/test_factory.py +++ b/tests/compose/test_factory.py @@ -45,6 +45,22 @@ def test_scaling_injected_only_when_different_from_method(): assert same.count("standardization") == 1 +def test_unknown_scaling_raises(): + """Regression guard for issue #35: an unrecognized scaling must raise, not + + silently produce an unscaled pipeline. + """ + with pytest.raises(InvalidParamError): + get_numerical_transformer_steps("ple", add_imputer=False, scaling="not_a_scaler") + + +def test_scaling_none_disables_scaling(): + for none_spelling in (None, "none"): + steps = _names(get_numerical_transformer_steps("ple", add_imputer=False, scaling=none_spelling)) + assert "scaler" not in steps + assert "minmax" not in steps + + 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) From d3c6b8a42acdf761b0494f5a6efbed0c915ce313 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Wed, 19 Aug 2026 18:21:51 +0200 Subject: [PATCH 015/123] fix: follow sklearn contract for binning (#36) --- pretab/transformers/numerical/binning.py | 14 +++++----- .../test_custombin_transformer.py | 27 ++++++++++++++++++- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/pretab/transformers/numerical/binning.py b/pretab/transformers/numerical/binning.py index 96ae750..bc397d6 100644 --- a/pretab/transformers/numerical/binning.py +++ b/pretab/transformers/numerical/binning.py @@ -236,21 +236,23 @@ def get_feature_names_out(self, input_features=None): Parameters ---------- - input_features : list of str - The names of the input features. + 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 : list of str + feature_names : ndarray of shape (total_output_dim_,) One name per input feature for ``encode="ordinal"``; otherwise one ``"{feature}_bin{k}"`` name per bin. """ + check_is_fitted(self, "n_features_in_") if input_features is None: - raise InvalidParamError("input_features must be specified") + input_features = [f"x{i}" for i in range(self.n_features_in_)] if self.encode == "ordinal": - return list(input_features) + return np.asarray(input_features, dtype=object) 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 + return np.asarray(names, dtype=object) diff --git a/tests/transformers/test_custombin_transformer.py b/tests/transformers/test_custombin_transformer.py index df365f3..4c7cf04 100644 --- a/tests/transformers/test_custombin_transformer.py +++ b/tests/transformers/test_custombin_transformer.py @@ -2,6 +2,7 @@ import pandas as pd import pytest from sklearn.base import BaseEstimator, TransformerMixin +from sklearn.exceptions import NotFittedError from pretab.exceptions import InsufficientSamplesError, InvalidParamError, PretabDataError from pretab.transformers import NumericBinningTransformer @@ -167,10 +168,34 @@ def test_custom_bin_transformer_feature_names_out_onehot(): def test_custom_bin_transformer_feature_names_out_raises(): + """Regression guard for issue #36: an unfitted transformer must raise + + NotFittedError, but a fitted one must accept no arguments and default to + generated ``x0, x1, ...`` names (matching the sklearn contract), not raise. + """ transformer = NumericBinningTransformer(output_dim=3) - with pytest.raises(ValueError): + with pytest.raises(NotFittedError): transformer.get_feature_names_out() + transformer.fit(np.linspace(0.0, 1.0, 10).reshape(-1, 1)) + names = transformer.get_feature_names_out() + assert isinstance(names, np.ndarray) + assert list(names) == ["x0"] + + +def test_custom_bin_transformer_get_feature_names_out_in_pipeline(): + """Regression guard for issue #36: Pipeline.get_feature_names_out() must work + + for a pipeline containing this transformer, with no names passed explicitly. + """ + from sklearn.pipeline import Pipeline + + X = np.linspace(0.0, 1.0, 10).reshape(-1, 1) + pipeline = Pipeline([("bin", NumericBinningTransformer(output_dim=3))]).fit(X) + names = pipeline.get_feature_names_out() + assert isinstance(names, np.ndarray) + assert list(names) == ["x0"] + def test_custom_bin_transformer_is_sklearn_compatible(): assert isinstance(NumericBinningTransformer(output_dim=3), (BaseEstimator, TransformerMixin)) From 835e14186e2b0ee7b7dcca8e0a96b17138f9e950 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Wed, 19 Aug 2026 18:27:45 +0200 Subject: [PATCH 016/123] fix: raise error for duplicate columns (#37) --- pretab/compose/feature_detection.py | 25 ++++++++++++++++++++----- tests/compose/test_feature_detection.py | 13 ++++++++++++- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/pretab/compose/feature_detection.py b/pretab/compose/feature_detection.py index 86d5e7d..4087ede 100644 --- a/pretab/compose/feature_detection.py +++ b/pretab/compose/feature_detection.py @@ -8,7 +8,7 @@ import numpy as np import pandas as pd -from ..exceptions import invalid_param_error +from ..exceptions import PretabDataError, invalid_param_error __all__ = ["detect_column_types", "to_dataframe"] @@ -18,12 +18,27 @@ def to_dataframe(X, *, copy: bool = False) -> pd.DataFrame: Dicts and NumPy arrays are wrapped in a fresh DataFrame; an existing DataFrame is returned as-is, or copied when ``copy`` is True. + + Raises + ------ + PretabDataError + If the resulting columns contain a duplicate label. A + :class:`~sklearn.compose.ColumnTransformer` keys its per-column steps by + name, so a duplicate cannot be routed unambiguously. """ if isinstance(X, dict): - return pd.DataFrame(X) - if isinstance(X, np.ndarray): - return pd.DataFrame(X, columns=pd.Index([f"feature_{i}" for i in range(X.shape[1])])) - return X.copy() if copy else X + X = pd.DataFrame(X) + elif isinstance(X, np.ndarray): + X = pd.DataFrame(X, columns=pd.Index([f"feature_{i}" for i in range(X.shape[1])])) + else: + X = X.copy() if copy else X + + duplicated = X.columns[X.columns.duplicated()].unique().tolist() + if duplicated: + raise PretabDataError( + f"Duplicate column names are not supported: {duplicated}.\nFix: rename the columns so every name is unique." + ) + return X def detect_column_types(X, *, cat_cutoff, treat_all_integers_as_numerical, estimator_name="Preprocessor"): diff --git a/tests/compose/test_feature_detection.py b/tests/compose/test_feature_detection.py index 74bbd78..a49ba45 100644 --- a/tests/compose/test_feature_detection.py +++ b/tests/compose/test_feature_detection.py @@ -5,7 +5,7 @@ import pytest from pretab.compose.feature_detection import detect_column_types, to_dataframe -from pretab.exceptions import InvalidParamError +from pretab.exceptions import InvalidParamError, PretabDataError def test_to_dataframe_wraps_ndarray_with_feature_names(): @@ -24,6 +24,17 @@ def test_to_dataframe_returns_same_object_without_copy(): assert to_dataframe(df, copy=True) is not df +def test_to_dataframe_rejects_duplicate_columns(): + """Regression guard for issue #37: a duplicate column label must raise a + + clear PretabDataError instead of an opaque AttributeError deep inside + column-type detection. + """ + df = pd.DataFrame(np.column_stack([np.zeros(5), np.ones(5)]), columns=["a", "a"]) + with pytest.raises(PretabDataError, match=r"Duplicate column names.*\['a'\]"): + to_dataframe(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) From 2ec3fdce6d876fdfdea03b22b61c2350d14fa08d Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Wed, 19 Aug 2026 18:34:39 +0200 Subject: [PATCH 017/123] fix: appropriate error message for out_dim and min_out_dim (#38) --- pretab/core/adaptive.py | 9 ++++----- pretab/placement/resolution.py | 9 ++++----- tests/core/test_adaptive_resolution.py | 14 ++++++++++++++ 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/pretab/core/adaptive.py b/pretab/core/adaptive.py index 39f08f6..1dba7e8 100644 --- a/pretab/core/adaptive.py +++ b/pretab/core/adaptive.py @@ -88,14 +88,13 @@ def _resolve_output_bounds( label = floor_label if floor_label is not None else str(floor) if lo < floor: + name = "min_output_dim" if self.adaptive and min_req is not None else "output_dim" raise InvalidParamError( - f"min_output_dim must be >= {label}, got {lo}.\n" - "Fix: raise min_output_dim to at least the family minimum." + f"{name} must be >= {label}, got {lo}.\nFix: raise {name} 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}." - ) + name = "max_output_dim" if self.adaptive and max_req is not None else "output_dim" + raise InvalidParamError(f"{name} should be <= {ceil}, got {hi}.\nFix: lower {name} 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})." diff --git a/pretab/placement/resolution.py b/pretab/placement/resolution.py index d7a8167..02f2352 100644 --- a/pretab/placement/resolution.py +++ b/pretab/placement/resolution.py @@ -102,14 +102,13 @@ def resolve( label = floor_label if floor_label is not None else str(floor) if lo < floor: + name = "min_output_dim" if self.adaptive and min_req is not None else "output_dim" raise InvalidParamError( - f"min_output_dim must be >= {label}, got {lo}.\n" - "Fix: raise min_output_dim to at least the family minimum." + f"{name} must be >= {label}, got {lo}.\nFix: raise {name} 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}." - ) + name = "max_output_dim" if self.adaptive and max_req is not None else "output_dim" + raise InvalidParamError(f"{name} should be <= {ceil}, got {hi}.\nFix: lower {name} 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})." diff --git a/tests/core/test_adaptive_resolution.py b/tests/core/test_adaptive_resolution.py index fafa79b..1d6300e 100644 --- a/tests/core/test_adaptive_resolution.py +++ b/tests/core/test_adaptive_resolution.py @@ -72,6 +72,20 @@ def test_mixin_floor_and_ceil_and_ordering(): dummy._resolve_output_bounds(7, 9, 6, floor=1) +def test_mixin_non_adaptive_floor_violation_names_output_dim(): + """Regression guard for issue #38: a non-adaptive floor/ceil violation must + + name output_dim, the parameter the caller actually set, not min/max_output_dim + (which are ignored when adaptive=False). Anchored at the start since + "min_output_dim"/"max_output_dim" would otherwise substring-match "output_dim". + """ + dummy = _Dummy(adaptive=False) + with pytest.raises(ValueError, match=r"^output_dim must be >= 4, got 0"): + dummy._resolve_output_bounds(0, None, None, floor=4, floor_label="4") + with pytest.raises(ValueError, match=r"^output_dim should be <= 50, got 60"): + dummy._resolve_output_bounds(60, None, None, floor=1, ceil=50) + + # --------------------------------------------------------------------------- # # Feature maps # # --------------------------------------------------------------------------- # From 0ba4d7a59d311d3ea2fdfec8b89b36a40d267b02 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Thu, 20 Aug 2026 19:14:57 +0200 Subject: [PATCH 018/123] fix: supplied parameter override preset --- pretab/preprocessor.py | 60 ++++++++++++++++++------------- tests/integration/test_presets.py | 11 ++++++ 2 files changed, 46 insertions(+), 25 deletions(-) diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index 5cf28ec..7ecace6 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -1,5 +1,4 @@ import hashlib -import inspect import json import os import time @@ -25,6 +24,7 @@ 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.parameters import UNSET from .core.policy import RepresentationPolicy, apply_constant_policy from .exceptions import ( ConfigWarning, @@ -39,7 +39,10 @@ #: 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. +#: sets explicitly overrides the preset. ``__init__`` defaults every parameter listed +#: here to :data:`~pretab.core.parameters.UNSET` (rather than its ordinary value) so +#: "left unset" can be told apart from "explicitly passed the same value the default +#: happens to have" -- see :meth:`Preprocessor._resolved_params`. PRESETS = { "standard": { "numerical_method": "ple", @@ -62,6 +65,18 @@ }, } +#: True ``__init__`` defaults for the parameters presets may override. These are +#: substituted back in by :meth:`Preprocessor._resolved_params` wherever the +#: constructor received :data:`~pretab.core.parameters.UNSET`. +_PRESET_PARAM_DEFAULTS = { + "numerical_method": "ple", + "categorical_method": "int", + "output_dim": 7, + "adaptive": False, + "min_output_dim": 5, + "max_output_dim": 10, +} + class Preprocessor(TransformerMixin, BaseEstimator): r""" @@ -325,17 +340,17 @@ class Preprocessor(TransformerMixin, BaseEstimator): def __init__( self, - numerical_method="ple", - categorical_method="int", + numerical_method=UNSET, + categorical_method=UNSET, feature_preprocessing=None, - output_dim=7, + output_dim=UNSET, degree=3, target_aware=True, placement_strategy="cart", task="regression", - adaptive=False, - min_output_dim=5, - max_output_dim=10, + adaptive=UNSET, + min_output_dim=UNSET, + max_output_dim=UNSET, random_state=None, scaling="minmax", cat_cutoff=0.03, @@ -578,27 +593,24 @@ 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. + Parameters that presets can fill in default to :data:`UNSET` in + ``__init__`` rather than to their ordinary value, so a caller who + explicitly passes that same ordinary value (e.g. ``adaptive=False``, + which is also the plain constructor default) is still recognized as + having set it -- an explicit value always takes precedence over the + preset, no matter what it equals. Parameters left at ``UNSET`` fall + back to the preset's value, or to their ordinary default when no + preset supplies one. The ``preset`` key is dropped from the returned + mapping. """ params = self.get_params(deep=False) preset = params.pop("preset", None) + resolved = {key: (_PRESET_PARAM_DEFAULTS[key] if value is UNSET else value) for key, value in params.items()} if preset is None: - return params + return resolved if preset not in PRESETS: raise invalid_param_error( type(self).__name__, @@ -607,10 +619,8 @@ def _resolved_params(self): "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]: + if params.get(key, UNSET) is UNSET: resolved[key] = preset_value return resolved diff --git a/tests/integration/test_presets.py b/tests/integration/test_presets.py index 5f6d824..333e348 100644 --- a/tests/integration/test_presets.py +++ b/tests/integration/test_presets.py @@ -57,6 +57,17 @@ def test_explicit_param_overrides_preset(): assert cfg["output_dim"] == 5 # user value wins over the preset's 16 +def test_explicit_param_equal_to_constructor_default_overrides_preset(): + # Regression guard: an explicit value that happens to equal the ordinary + # __init__ default must still win over the preset, not be mistaken for + # "left unset". + cfg = Preprocessor(preset="adaptive", adaptive=False).get_resolved_config() + assert cfg["adaptive"] is False # user explicitly said no, preset's True must not apply + + cfg2 = Preprocessor(preset="expanded", output_dim=7).get_resolved_config() + assert cfg2["output_dim"] == 7 # user explicitly said 7, preset's 16 must not apply + + def test_preset_is_preserved_by_get_params_and_clone(): pre = Preprocessor(preset="standard") assert pre.get_params()["preset"] == "standard" From 1906e0daf0d3222ff04df6e121b95d86988bafd3 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Thu, 20 Aug 2026 19:29:51 +0200 Subject: [PATCH 019/123] fix: custom representation usage for cat features --- pretab/compose/factory.py | 23 ++++++++++++- .../extension/test_registration_discovery.py | 32 ++++++++++++++++--- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/pretab/compose/factory.py b/pretab/compose/factory.py index b1e301e..c622b1f 100644 --- a/pretab/compose/factory.py +++ b/pretab/compose/factory.py @@ -207,7 +207,8 @@ def get_categorical_transformer_steps( valid=set(CATEGORICAL_METHODS), ) - cls = get_spec(method).transformer_cls + spec = get_spec(method) + cls = spec.transformer_cls if method == "int": steps.append(("continuous_ordinal", cls())) @@ -223,6 +224,10 @@ def get_categorical_transformer_steps( steps.append(("none", cls())) elif method == "onehot_from_ordinal": steps.append(("onehot_from_ordinal", cls())) + else: + call_kwargs = _filter_kwargs(spec.allowed_args, kwargs) + call_kwargs.update(_placement_kwargs(spec, kwargs)) + steps.append((method, cls(**call_kwargs))) return steps @@ -269,11 +274,27 @@ def create_transformer(method: str, *, is_numerical: bool, config: PreprocessorC **config.seed_kwargs, ) else: + constructor_kwargs = {} + if known: + shared_kwargs = { + "task": config.task, + "target_aware": config.target_aware, + "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, + "placement_strategy": config.placement_strategy, + **config.seed_kwargs, + } + constructor_kwargs = _filter_kwargs(spec.allowed_args, shared_kwargs) + constructor_kwargs.update(_placement_kwargs(spec, shared_kwargs)) steps = get_categorical_transformer_steps( method, add_imputer=plan["add_imputer"], imputer_strategy=plan["strategy"], add_missing_indicator=plan["add_indicator"], + **constructor_kwargs, ) pipeline = Pipeline(steps) diff --git a/tests/extension/test_registration_discovery.py b/tests/extension/test_registration_discovery.py index f92a526..fad8f5e 100644 --- a/tests/extension/test_registration_discovery.py +++ b/tests/extension/test_registration_discovery.py @@ -38,17 +38,22 @@ def _output_sizes(self): return [1] * self.n_features_in_ -class _CatPassthrough(BaseRepresentation): +class _CatStringLength(BaseRepresentation): representation_name = "cat_reg" feature_kind = "categorical" + def __init__(self, degree=1): + self.degree = degree + def fit(self, X, y=None): - self._validate(X, reset=True) + X = np.asarray(X, dtype=object) + self.n_features_in_ = X.shape[1] return self def transform(self, X): check_is_fitted(self, "n_features_in_") - return np.asarray(self._validate(X, reset=False), dtype=float) + X = np.asarray(X, dtype=object) + return np.vectorize(lambda value: len(str(value)) * self.degree, otypes=[float])(X) def _output_sizes(self): return [1] * self.n_features_in_ @@ -78,11 +83,30 @@ def test_register_end_to_end_through_preprocessor(): def test_register_categorical_updates_categorical_view(): - register_representation("cat_reg", _CatPassthrough) + register_representation("cat_reg", _CatStringLength) assert "cat_reg" in registry.CATEGORICAL_METHODS assert "cat_reg" in list_representations(feature_kind="categorical") +def test_register_categorical_end_to_end_through_preprocessor(): + register_representation("cat_reg", _CatStringLength, allowed_args=("degree",)) + X = pd.DataFrame({"city": ["Rome", "Berlin", "Oslo", "Paris"]}) + pre = Preprocessor( + numerical_method="none", + categorical_method="cat_reg", + degree=2, + target_aware=False, + placement_strategy="uniform", + ) + + out = np.asarray(pre.fit_transform(X, return_array=True)) + + np.testing.assert_array_equal(out[:, 0], [8.0, 12.0, 8.0, 10.0]) + pipeline = pre.column_transformer_.named_transformers_["cat_city"] + assert isinstance(pipeline.named_steps["cat_reg"], _CatStringLength) + assert pipeline.named_steps["cat_reg"].degree == 2 + + def test_duplicate_registration_requires_override(): register_representation("square_reg", _Square) with pytest.raises(ValueError, match="already registered"): From b0e67287971dfc9e87c616640fb2cbdfc81db4c0 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Thu, 20 Aug 2026 19:37:29 +0200 Subject: [PATCH 020/123] fix: sparse to dense conversion for sparse output --- pretab/compose/factory.py | 14 +++++++-- pretab/compose/output.py | 38 +++++++++++++++++-------- pretab/preprocessor.py | 22 +++++++++----- tests/integration/test_output_format.py | 21 ++++++++++++++ 4 files changed, 74 insertions(+), 21 deletions(-) diff --git a/pretab/compose/factory.py b/pretab/compose/factory.py index c622b1f..347605d 100644 --- a/pretab/compose/factory.py +++ b/pretab/compose/factory.py @@ -305,7 +305,13 @@ def create_transformer(method: str, *, is_numerical: bool, config: PreprocessorC return pipeline -def build_column_transformer(config: PreprocessorConfig, numerical_features, categorical_features) -> ColumnTransformer: +def build_column_transformer( + config: PreprocessorConfig, + numerical_features, + categorical_features, + *, + sparse_threshold: float = 0.3, +) -> ColumnTransformer: """Assemble the per-column pipelines into the final ColumnTransformer. Numerical features are prefixed ``num_`` and categorical features ``cat_`` to @@ -321,4 +327,8 @@ def build_column_transformer(config: PreprocessorConfig, numerical_features, cat 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") + return ColumnTransformer( + transformers=transformers, + remainder="passthrough", + sparse_threshold=sparse_threshold, + ) diff --git a/pretab/compose/output.py b/pretab/compose/output.py index 3561055..8de9cc4 100644 --- a/pretab/compose/output.py +++ b/pretab/compose/output.py @@ -40,8 +40,9 @@ def compute_output_report(array, output_format, *, threshold=_SPARSE_AUTO_THRESH Parameters ---------- - array : numpy.ndarray - The dense stacked output. + array : numpy.ndarray or scipy.sparse matrix + The stacked output. Sparse inputs are inspected through their shape, + dtype, and stored values without converting them to a dense array. output_format : {"auto", "dense", "sparse"} Requested format. ``"auto"`` picks ``"sparse"`` when the density is below ``threshold``. @@ -55,8 +56,10 @@ def compute_output_report(array, output_format, *, threshold=_SPARSE_AUTO_THRESH ``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) + size = int(np.prod(array.shape, dtype=np.int64)) + nonzero = int(array.count_nonzero()) if sp.issparse(array) else int(np.count_nonzero(array)) + density = float(nonzero) / size if size else 0.0 + dense_bytes = size * int(array.dtype.itemsize) if output_format == "sparse": use_sparse = True @@ -66,7 +69,7 @@ def compute_output_report(array, output_format, *, threshold=_SPARSE_AUTO_THRESH use_sparse = False if use_sparse: - csr = sp.csr_matrix(array) + csr = array.tocsr(copy=False) if sp.issparse(array) else sp.csr_matrix(array) actual_bytes = int(csr.data.nbytes + csr.indices.nbytes + csr.indptr.nbytes) fmt = "sparse" else: @@ -89,8 +92,9 @@ def to_dataframe_output(array, columns, container): Parameters ---------- - array : numpy.ndarray - Dense stacked output. + array : numpy.ndarray or scipy.sparse matrix + Stacked output. Sparse input is intentionally densified because + ``set_output`` requests a dense pandas or polars container. columns : sequence of str One name per output column (from ``get_feature_names_out``). container : {"pandas", "polars"} @@ -102,6 +106,8 @@ def to_dataframe_output(array, columns, container): If ``container="polars"`` but polars is not installed. """ columns = list(columns) + if sp.issparse(array): + array = array.toarray() if container == "pandas": import pandas as pd @@ -117,7 +123,7 @@ def to_dataframe_output(array, columns, container): def build_output_dict(transformed, slices, *, as_sparse=False) -> dict: - """Split a stacked array into a name -> block dict using ``slices``. + """Split a stacked dense or sparse array into a name -> block dict. ``slices`` is an ordered iterable of ``(name, start, width)`` describing each transformer's contiguous span in the stacked output. When ``as_sparse`` is @@ -126,7 +132,10 @@ def build_output_dict(transformed, slices, *, as_sparse=False) -> dict: result = {} for name, start, width in slices: block = transformed[:, start : start + width] - result[name] = sp.csr_matrix(block) if as_sparse else block + if as_sparse: + result[name] = block.tocsr(copy=False) if sp.issparse(block) else sp.csr_matrix(block) + else: + result[name] = block.toarray() if sp.issparse(block) else block return result @@ -193,8 +202,8 @@ def format_output( Parameters ---------- - transformed : numpy.ndarray - The dense stacked array produced by the fitted ColumnTransformer. + transformed : numpy.ndarray or scipy.sparse matrix + The stacked output produced by the fitted ColumnTransformer. return_array : bool If True, return the stacked array (dense or CSR); otherwise build the dict. slices : iterable of (str, int, int), optional @@ -212,8 +221,13 @@ def format_output( CSR blocks (dict path). """ as_sparse = output_format == "sparse" + if as_sparse: + transformed = transformed.tocsr(copy=False) if sp.issparse(transformed) else sp.csr_matrix(transformed) + elif sp.issparse(transformed): + transformed = transformed.toarray() + if return_array: - return sp.csr_matrix(transformed) if as_sparse else transformed + return transformed result = build_output_dict(transformed, slices or [], as_sparse=as_sparse) if embeddings is not None: diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index 7ecace6..dba6b49 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -482,10 +482,6 @@ def fit(self, X, y=None, embeddings=None): 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] - valid_formats = ("auto", "dense", "sparse") if self.output_format not in valid_formats: raise invalid_param_error( @@ -496,6 +492,19 @@ def fit(self, X, y=None, embeddings=None): valid=set(valid_formats), ) + # Ask ColumnTransformer to assemble the representation in the requested + # container whenever its component outputs permit it. In particular, + # explicit sparse output must not densely stack sparse one-hot blocks. + sparse_threshold = {"dense": 0.0, "auto": 0.3, "sparse": 1.0}[self.output_format] + self.column_transformer_ = build_column_transformer( + config, + numerical_features, + categorical_features, + sparse_threshold=sparse_threshold, + ) + self.column_transformer_.fit(X, y) + self.n_features_in_ = X.shape[1] + self._enforce_output_budget(X.shape[0]) if verbose >= 1: @@ -547,9 +556,8 @@ def transform(self, X, embeddings=None, return_array=False): self._reject_missing(X) transformed_X = self.column_transformer_.transform(X) - if sp.issparse(transformed_X): - transformed_X = transformed_X.toarray() # type: ignore - transformed_X = np.asarray(transformed_X) + if not sp.issparse(transformed_X): + transformed_X = np.asarray(transformed_X) if self.dtype is not None: transformed_X = transformed_X.astype(self.dtype, copy=False) diff --git a/tests/integration/test_output_format.py b/tests/integration/test_output_format.py index 8e5ef0f..ba8a6e3 100644 --- a/tests/integration/test_output_format.py +++ b/tests/integration/test_output_format.py @@ -83,6 +83,27 @@ def test_sparse_report_saves_memory(frame, y): assert report["memory_saved_bytes"] == report["dense_bytes"] - report["actual_bytes"] +def test_sparse_intermediate_is_never_densified(monkeypatch): + """A sparse ColumnTransformer result must remain sparse through formatting.""" + cats = pd.DataFrame({"c": [f"value-{i}" for i in range(100)]}) + p = Preprocessor(categorical_method="one-hot", output_format="sparse").fit(cats) + raw = p.column_transformer_.transform(cats) + assert sp.issparse(raw) + + class NoDensifyCSR(sp.csr_matrix): + def toarray(self, *args, **kwargs): + raise AssertionError("sparse intermediate was densified") + + guarded = NoDensifyCSR(raw) + monkeypatch.setattr(p.column_transformer_, "transform", lambda X: guarded) + + out = p.transform(cats, return_array=True) + assert sp.issparse(out) + assert out.shape == (100, 100) + assert out.nnz == 100 + assert p.output_report_["density"] == pytest.approx(0.01) + + # --- auto ---------------------------------------------------------------------- From 956b346e9299afca755f4cff6b4558fcbaca204f Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Thu, 20 Aug 2026 19:46:32 +0200 Subject: [PATCH 021/123] fix: missing policy feature inspection, embedding silient failure --- pretab/compose/inspection.py | 84 ++++++++++++++++++++++-- pretab/compose/output.py | 35 +++++++++- pretab/preprocessor.py | 11 +++- tests/compose/test_output.py | 19 +++++- tests/integration/test_missing_policy.py | 32 +++++++++ tests/integration/test_preprocessor.py | 28 ++++++++ 6 files changed, 194 insertions(+), 15 deletions(-) diff --git a/pretab/compose/inspection.py b/pretab/compose/inspection.py index b55f41e..af3dadc 100644 --- a/pretab/compose/inspection.py +++ b/pretab/compose/inspection.py @@ -8,6 +8,7 @@ """ import numpy as np +from sklearn.pipeline import FeatureUnion from ..core.logging import get_logger from ..core.representation import FeatureLineage @@ -75,6 +76,16 @@ def clean_feature_names(column_transformer, names): return cleaned +def _separate_state_branches(transformer): + """Return the representation and missing branches of a separate-state union.""" + if not isinstance(transformer, FeatureUnion): + return None + branches = dict(transformer.transformer_list) + if "representation" not in branches or "missing" not in branches: + return None + return branches["representation"], branches["missing"] + + def build_feature_info(column_transformer, *, embeddings, embedding_dimensions): """Collect per-feature metadata (preprocessing, dimension, categories). @@ -98,10 +109,20 @@ def build_feature_info(column_transformer, *, embeddings, embedding_dimensions): transformer_pipeline, columns, ) in column_transformer.transformers_: - steps = [step[0] for step in transformer_pipeline.steps] + separate_state = _separate_state_branches(transformer_pipeline) + if separate_state is not None: + representation_pipeline, _missing_indicator = separate_state + steps = [step[0] for step in representation_pipeline.steps] + preprocessing_type = f"representation({' -> '.join(steps)}) + missing" + span = column_transformer.output_indices_.get(name) + separate_state_dimension = None if span is None else span.stop - span.start + else: + representation_pipeline = transformer_pipeline + steps = [step[0] for step in representation_pipeline.steps] + preprocessing_type = " -> ".join(steps) + separate_state_dimension = None for feature_name in columns: - preprocessing_type = " -> ".join(steps) dimension = None categories = None @@ -116,7 +137,7 @@ def build_feature_info(column_transformer, *, embeddings, embedding_dimensions): "box-cox", ] ): - last_step = transformer_pipeline.steps[-1][1] + last_step = representation_pipeline.steps[-1][1] if hasattr(last_step, "transform"): dummy_input = np.zeros((1, 1)) + 1e-05 try: @@ -129,6 +150,8 @@ def build_feature_info(column_transformer, *, embeddings, embedding_dimensions): exc, ) dimension = None + if separate_state_dimension is not None: + dimension = separate_state_dimension numerical_feature_info[feature_name] = { "preprocessing": preprocessing_type, "dimension": dimension, @@ -136,9 +159,9 @@ def build_feature_info(column_transformer, *, embeddings, embedding_dimensions): } elif "continuous_ordinal" in steps: - step = transformer_pipeline.named_steps["continuous_ordinal"] + step = representation_pipeline.named_steps["continuous_ordinal"] categories = len(step.mapping_[columns.index(feature_name)]) - dimension = 1 + dimension = separate_state_dimension if separate_state_dimension is not None else 1 categorical_feature_info[feature_name] = { "preprocessing": preprocessing_type, "dimension": dimension, @@ -146,10 +169,12 @@ def build_feature_info(column_transformer, *, embeddings, embedding_dimensions): } elif "onehot" in steps: - step = transformer_pipeline.named_steps["onehot"] + step = representation_pipeline.named_steps["onehot"] if hasattr(step, "categories_"): categories = sum(len(cat) for cat in step.categories_) dimension = categories + if separate_state_dimension is not None: + dimension = separate_state_dimension categorical_feature_info[feature_name] = { "preprocessing": preprocessing_type, "dimension": dimension, @@ -157,7 +182,7 @@ def build_feature_info(column_transformer, *, embeddings, embedding_dimensions): } else: - last_step = transformer_pipeline.steps[-1][1] + last_step = representation_pipeline.steps[-1][1] if hasattr(last_step, "transform"): dummy_input = np.zeros((1, 1)) try: @@ -170,6 +195,8 @@ def build_feature_info(column_transformer, *, embeddings, embedding_dimensions): exc, ) dimension = None + if separate_state_dimension is not None: + dimension = separate_state_dimension if "cat" in name: categorical_feature_info[feature_name] = { "preprocessing": preprocessing_type, @@ -288,6 +315,49 @@ def build_feature_lineage(column_transformer): ) ) continue + + separate_state = _separate_state_branches(transformer) + if separate_state is not None: + representation_pipeline, _missing_indicator = separate_state + union_names = [str(value) for value in transformer.get_feature_names_out(list(columns))] + representation_width = sum(value.startswith("representation__") for value in union_names) + missing_width = sum(value.startswith("missing__") for value in union_names) + if representation_width + missing_width != width: + representation_width = width - missing_width + + family, component, uses_target, is_interaction = _resolve_block_representation( + representation_pipeline, columns + ) + source_features = tuple(str(column) for column in columns) + for offset in range(representation_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, + ) + ) + for offset in range(missing_width): + index = span.start + representation_width + offset + records.append( + FeatureLineage( + output_feature=output_names[index], + output_index=index, + source_features=source_features, + family="missing_state", + component="indicator", + 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): diff --git a/pretab/compose/output.py b/pretab/compose/output.py index 8de9cc4..b6571cc 100644 --- a/pretab/compose/output.py +++ b/pretab/compose/output.py @@ -22,6 +22,7 @@ "compute_output_report", "format_output", "to_dataframe_output", + "validate_embedding_request", ] # Density at or below which ``output_format="auto"`` switches to a sparse matrix, @@ -33,6 +34,31 @@ "Fix: configure an embedding feature in feature_preprocessing before " "passing embeddings to transform, or omit the embeddings argument." ) +_EMBEDDINGS_REQUIRED = ( + "External embeddings were supplied during fit and are required during transform.\n" + "Fix: pass embeddings with the same number of blocks and dimensions used during fit." +) +_EMBEDDINGS_DICT_ONLY = ( + "External embeddings are supported only with dictionary output.\n" + "Fix: call transform(..., return_array=False) with set_output(transform='default')." +) + + +def validate_embedding_request(embeddings, *, expected: bool, output_kind: str = "dict") -> None: + """Validate embedding presence and the requested output container. + + External embeddings are separate named feature blocks, so they are available + only through dictionary output. Once supplied during fit, they are required on + every transform to keep the fitted and transformed feature contracts aligned. + """ + if embeddings is None: + if expected: + raise PretabDataError(_EMBEDDINGS_REQUIRED) + return + if not expected: + raise IncompatibleParamsError(_EMBEDDINGS_NOT_EXPECTED) + if output_kind != "dict": + raise IncompatibleParamsError(_EMBEDDINGS_DICT_ONLY) def compute_output_report(array, output_format, *, threshold=_SPARSE_AUTO_THRESHOLD): @@ -157,8 +183,7 @@ def attach_embeddings(result: dict, embeddings, *, expected: bool, embedding_dim If the number of arrays, an array's shape, or its row count does not match what ``fit`` recorded. """ - if not expected: - raise IncompatibleParamsError(_EMBEDDINGS_NOT_EXPECTED) + validate_embedding_request(embeddings, expected=expected) arrays = [embeddings] if isinstance(embeddings, np.ndarray) else list(embeddings) if embedding_dimensions is not None and len(arrays) != len(embedding_dimensions): raise PretabDataError( @@ -220,6 +245,12 @@ def format_output( Resolved output format. ``"sparse"`` returns a CSR matrix (array path) or CSR blocks (dict path). """ + validate_embedding_request( + embeddings, + expected=embeddings_expected, + output_kind="array" if return_array else "dict", + ) + as_sparse = output_format == "sparse" if as_sparse: transformed = transformed.tocsr(copy=False) if sp.issparse(transformed) else sp.csr_matrix(transformed) diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index dba6b49..f9d2619 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -21,7 +21,7 @@ clean_feature_names, get_output_slices, ) -from .compose.output import compute_output_report, format_output, to_dataframe_output +from .compose.output import compute_output_report, format_output, to_dataframe_output, validate_embedding_request from .compose.serialize import SCHEMA_VERSION, preprocessor_from_spec, preprocessor_to_spec from .core.logging import configure_logging, get_logger from .core.parameters import UNSET @@ -535,7 +535,9 @@ def transform(self, X, embeddings=None, return_array=False): X : pandas.DataFrame, numpy.ndarray, or dict Input features to transform. embeddings : np.ndarray or list of np.ndarray, optional - Optional external embeddings to attach to the transformation. + External embeddings to attach to dictionary output. Required when + embeddings were supplied during ``fit`` and unsupported when + ``return_array=True`` or :meth:`set_output` requests a DataFrame. return_array : bool, default=False If True, return a single stacked NumPy array. If False, return a dict of transformed arrays. @@ -555,6 +557,10 @@ def transform(self, X, embeddings=None, return_array=False): if self.missing_policy == "error": self._reject_missing(X) + container = _get_output_config("transform", self)["dense"] + output_kind = container if container in ("pandas", "polars") else ("array" if return_array else "dict") + validate_embedding_request(embeddings, expected=self.embeddings_, output_kind=output_kind) + transformed_X = self.column_transformer_.transform(X) if not sp.issparse(transformed_X): transformed_X = np.asarray(transformed_X) @@ -563,7 +569,6 @@ def transform(self, X, embeddings=None, return_array=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) diff --git a/tests/compose/test_output.py b/tests/compose/test_output.py index a5a764f..095169a 100644 --- a/tests/compose/test_output.py +++ b/tests/compose/test_output.py @@ -48,9 +48,7 @@ def test_attach_embeddings_rejects_wrong_width(): def test_attach_embeddings_rejects_wrong_row_count(): """Regression guard for issue #34: a mismatched row count must raise.""" with pytest.raises(PretabDataError, match="has 2 row"): - attach_embeddings( - {}, np.ones((2, 3)), expected=True, embedding_dimensions={"embedding_1": 3}, n_samples=100 - ) + attach_embeddings({}, np.ones((2, 3)), expected=True, embedding_dimensions={"embedding_1": 3}, n_samples=100) def test_format_output_array_returns_input_unchanged(): @@ -76,3 +74,18 @@ def test_format_output_dict_attaches_embeddings(): embeddings_expected=True, ) assert "embedding_1" in out + + +def test_format_output_requires_fitted_embeddings(): + with pytest.raises(PretabDataError, match="required during transform"): + format_output(np.zeros((2, 2)), return_array=False, embeddings_expected=True) + + +def test_format_output_rejects_embeddings_with_array_output(): + with pytest.raises(IncompatibleParamsError, match="only with dictionary output"): + format_output( + np.zeros((2, 2)), + return_array=True, + embeddings=np.ones((2, 3)), + embeddings_expected=True, + ) diff --git a/tests/integration/test_missing_policy.py b/tests/integration/test_missing_policy.py index 974176d..5827baa 100644 --- a/tests/integration/test_missing_policy.py +++ b/tests/integration/test_missing_policy.py @@ -161,6 +161,38 @@ def test_separate_state_on_categorical(y): assert any(n.endswith("__missing") for n in names) +def test_separate_state_feature_info_reports_both_branches(frame_with_nan, y): + p = _bspline(missing_policy="separate_state").fit(frame_with_nan, y) + + numerical, categorical, embeddings = p.get_feature_info(verbose=False) + + assert categorical == {} + assert embeddings == {} + for feature in frame_with_nan.columns: + assert "representation(" in numerical[feature]["preprocessing"] + assert "+ missing" in numerical[feature]["preprocessing"] + assert numerical[feature]["dimension"] == p.output_dims_[feature] + + +def test_separate_state_verbose_two_does_not_break_fit(frame_with_nan, y): + fitted = _bspline(missing_policy="separate_state", verbose=2).fit(frame_with_nan, y) + assert fitted.total_output_dim_ > 0 + + +def test_separate_state_lineage_distinguishes_missing_indicator(frame_with_nan, y): + p = Preprocessor(numerical_method="minmax", missing_policy="separate_state").fit(frame_with_nan, y) + + lineage = p.get_feature_lineage() + representation = [record for record in lineage if record.family != "missing_state"] + missing = [record for record in lineage if record.family == "missing_state"] + + assert len(missing) == len(frame_with_nan.columns) + assert all(record.family == "minmax" and record.component == "raw" for record in representation) + assert all(record.component == "indicator" for record in missing) + assert all(record.output_feature.endswith("__missing") for record in missing) + assert [record.output_feature for record in lineage] == list(p.get_feature_names_out()) + + # --- validation ---------------------------------------------------------------- diff --git a/tests/integration/test_preprocessor.py b/tests/integration/test_preprocessor.py index 15b4219..719ce99 100644 --- a/tests/integration/test_preprocessor.py +++ b/tests/integration/test_preprocessor.py @@ -5,6 +5,7 @@ from sklearn.exceptions import NotFittedError from sklearn.utils.validation import check_is_fitted +from pretab.exceptions import IncompatibleParamsError, PretabDataError from pretab.preprocessor import Preprocessor # Adjust the import as needed @@ -68,6 +69,33 @@ def test_multiple_embeddings(sample_data): assert out["embedding_2"].shape[1] == 7 +def test_embeddings_are_required_after_embedding_aware_fit(sample_data): + X, y = sample_data + embeddings = np.random.rand(len(X), 4) + pre = Preprocessor().fit(X, y, embeddings=embeddings) + + with pytest.raises(PretabDataError, match="required during transform"): + pre.transform(X) + + +def test_embeddings_are_rejected_with_array_output(sample_data): + X, y = sample_data + embeddings = np.random.rand(len(X), 4) + pre = Preprocessor().fit(X, y, embeddings=embeddings) + + with pytest.raises(IncompatibleParamsError, match="only with dictionary output"): + pre.transform(X, embeddings=embeddings, return_array=True) + + +def test_embeddings_are_rejected_with_dataframe_output(sample_data): + X, y = sample_data + embeddings = np.random.rand(len(X), 4) + pre = Preprocessor().fit(X, y, embeddings=embeddings).set_output(transform="pandas") + + with pytest.raises(IncompatibleParamsError, match="only with dictionary output"): + pre.transform(X, embeddings=embeddings) + + def test_feature_info_returns_three_dicts(sample_data): X, y = sample_data pre = Preprocessor() From a7ccf6dbfb9c609457346779008be0a71708eee7 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Thu, 20 Aug 2026 19:48:16 +0200 Subject: [PATCH 022/123] fix: unassigned int cat cuttoff --- pretab/compose/feature_detection.py | 4 ++-- tests/compose/test_feature_detection.py | 32 +++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/pretab/compose/feature_detection.py b/pretab/compose/feature_detection.py index 4087ede..ebdb1cc 100644 --- a/pretab/compose/feature_detection.py +++ b/pretab/compose/feature_detection.py @@ -66,7 +66,7 @@ def detect_column_types(X, *, cat_cutoff, treat_all_integers_as_numerical, estim num_unique_values = X[col].nunique() total_samples = len(X[col]) - if treat_all_integers_as_numerical and X[col].dtype.kind == "i": + if treat_all_integers_as_numerical and X[col].dtype.kind in "iu": numerical_features.append(col) else: if isinstance(cat_cutoff, float): @@ -81,7 +81,7 @@ def detect_column_types(X, *, cat_cutoff, treat_all_integers_as_numerical, estim "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 in "iu" and cutoff_condition): categorical_features.append(col) else: numerical_features.append(col) diff --git a/tests/compose/test_feature_detection.py b/tests/compose/test_feature_detection.py index a49ba45..b759a4c 100644 --- a/tests/compose/test_feature_detection.py +++ b/tests/compose/test_feature_detection.py @@ -57,6 +57,38 @@ def test_treat_all_integers_as_numerical_overrides_cutoff(): assert num == ["x"] and cat == [] +@pytest.mark.parametrize("dtype", [np.int8, np.uint8]) +@pytest.mark.parametrize( + "cat_cutoff, expected_numerical, expected_categorical", + [ + (0.6, [], ["x"]), + (0.4, ["x"], []), + (4, [], ["x"]), + (2, ["x"], []), + ], +) +def test_signed_and_unsigned_integers_follow_same_cutoff(dtype, cat_cutoff, expected_numerical, expected_categorical): + df = pd.DataFrame({"x": np.array([1, 2, 3, 1, 2, 3], dtype=dtype)}) + numerical, categorical = detect_column_types( + df, + cat_cutoff=cat_cutoff, + treat_all_integers_as_numerical=False, + ) + assert numerical == expected_numerical + assert categorical == expected_categorical + + +def test_treat_all_unsigned_integers_as_numerical_overrides_cutoff(): + df = pd.DataFrame({"x": np.array([0, 1, 0, 1], dtype=np.uint8)}) + numerical, categorical = detect_column_types( + df, + cat_cutoff=0.9, + treat_all_integers_as_numerical=True, + ) + assert numerical == ["x"] + assert categorical == [] + + 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) From 75f6f4136bcb03d2a20281caaab0665809f5d900 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Thu, 20 Aug 2026 19:49:51 +0200 Subject: [PATCH 023/123] fix: enfore feature count for cat transformer --- .../categorical/language_embedding.py | 7 ++++++- pretab/transformers/categorical/ordinal.py | 6 ++++++ tests/transformers/test_feature_names_out.py | 16 ++++++++++++++++ .../test_language_embedding_transformer.py | 18 +++++++++++++++++- 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/pretab/transformers/categorical/language_embedding.py b/pretab/transformers/categorical/language_embedding.py index 97c7203..f2b7786 100644 --- a/pretab/transformers/categorical/language_embedding.py +++ b/pretab/transformers/categorical/language_embedding.py @@ -2,7 +2,7 @@ from sklearn.base import BaseEstimator, TransformerMixin from sklearn.utils.validation import check_is_fitted -from ...exceptions import OptionalDependencyError, PretabConfigError +from ...exceptions import OptionalDependencyError, PretabConfigError, PretabDataError class LanguageEmbeddingTransformer(TransformerMixin, BaseEstimator): @@ -113,6 +113,11 @@ def transform(self, X): arr = np.asarray(X) if arr.ndim == 1: arr = arr.reshape(-1, 1) + if arr.shape[1] != self.n_features_in_: + raise PretabDataError( + f"X has {arr.shape[1]} features, but {type(self).__name__} " + f"is expecting {self.n_features_in_} features as input." + ) arr = arr.astype(str) column_embeddings = [self.model_.encode(arr[:, i].tolist(), convert_to_numpy=True) for i in range(arr.shape[1])] diff --git a/pretab/transformers/categorical/ordinal.py b/pretab/transformers/categorical/ordinal.py index 91990b1..1cb1d7f 100644 --- a/pretab/transformers/categorical/ordinal.py +++ b/pretab/transformers/categorical/ordinal.py @@ -3,6 +3,7 @@ from sklearn.utils.validation import check_is_fitted from ...core.representation import RepresentationSpecMixin +from ...exceptions import PretabDataError class ContinuousOrdinalTransformer(RepresentationSpecMixin, TransformerMixin, BaseEstimator): @@ -78,6 +79,11 @@ def transform(self, X): X = np.asarray(X, dtype=object) if X.ndim == 1: X = X.reshape(-1, 1) + if X.shape[1] != self.n_features_in_: + raise PretabDataError( + f"X has {X.shape[1]} features, but {type(self).__name__} " + f"is expecting {self.n_features_in_} features as input." + ) out = np.zeros(X.shape, dtype=int) for j, mapping in enumerate(self.mapping_): out[:, j] = [mapping.get(v, 0) for v in X[:, j]] diff --git a/tests/transformers/test_feature_names_out.py b/tests/transformers/test_feature_names_out.py index 73f9ab0..f7cd043 100644 --- a/tests/transformers/test_feature_names_out.py +++ b/tests/transformers/test_feature_names_out.py @@ -6,6 +6,7 @@ import numpy as np import pytest +from pretab.exceptions import PretabDataError from pretab.transformers import ( ContinuousOrdinalTransformer, NoTransformer, @@ -45,3 +46,18 @@ def test_continuous_ordinal_passthrough_names(): transformer.get_feature_names_out(["c1", "c2"]), np.asarray(["c1", "c2"], dtype=object), ) + + +@pytest.mark.parametrize( + "X_transform", + [ + np.array([["a"], ["b"]], dtype=object), + np.array([["a", "x", "extra"], ["b", "y", "extra"]], dtype=object), + ], +) +def test_continuous_ordinal_rejects_fitted_feature_count_mismatch(X_transform): + X_fit = np.array([["a", "x"], ["b", "y"]], dtype=object) + transformer = ContinuousOrdinalTransformer().fit(X_fit) + + with pytest.raises(PretabDataError, match="is expecting 2 features"): + transformer.transform(X_transform) diff --git a/tests/transformers/test_language_embedding_transformer.py b/tests/transformers/test_language_embedding_transformer.py index 9a92236..07c91fc 100644 --- a/tests/transformers/test_language_embedding_transformer.py +++ b/tests/transformers/test_language_embedding_transformer.py @@ -13,7 +13,7 @@ import pytest from sklearn.base import clone -from pretab.exceptions import OptionalDependencyError, PretabConfigError +from pretab.exceptions import OptionalDependencyError, PretabConfigError, PretabDataError from pretab.transformers import LanguageEmbeddingTransformer @@ -84,6 +84,22 @@ def test_transform_multi_column_preserves_row_count(): assert dummy.calls == 2 # one encode call per column +@pytest.mark.parametrize( + "X_transform", + [ + np.array([["a"], ["b"]]), + np.array([["a", "b", "extra"], ["c", "d", "extra"]]), + ], +) +def test_transform_rejects_fitted_feature_count_mismatch(X_transform): + dummy = _DummyModel() + transformer = LanguageEmbeddingTransformer(model=dummy).fit(np.array([["a", "b"], ["c", "d"]])) + + with pytest.raises(PretabDataError, match="is expecting 2 features"): + transformer.transform(X_transform) + assert dummy.calls == 0 + + def test_fit_without_dependency_raises(monkeypatch): # Simulate sentence-transformers being absent; construction still succeeds, # and only ``fit`` surfaces the optional-dependency error. From ce5f72699ff43bd24de44dbad1fd6f17381f34c9 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Thu, 20 Aug 2026 19:51:13 +0200 Subject: [PATCH 024/123] fix: missclassify numerical to cat feature based on name --- pretab/compose/inspection.py | 2 +- tests/compose/test_inspection.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/pretab/compose/inspection.py b/pretab/compose/inspection.py index af3dadc..7f3b9b3 100644 --- a/pretab/compose/inspection.py +++ b/pretab/compose/inspection.py @@ -197,7 +197,7 @@ def build_feature_info(column_transformer, *, embeddings, embedding_dimensions): dimension = None if separate_state_dimension is not None: dimension = separate_state_dimension - if "cat" in name: + if name.startswith("cat_"): categorical_feature_info[feature_name] = { "preprocessing": preprocessing_type, "dimension": dimension, diff --git a/tests/compose/test_inspection.py b/tests/compose/test_inspection.py index 8bd6f7a..1921e62 100644 --- a/tests/compose/test_inspection.py +++ b/tests/compose/test_inspection.py @@ -44,6 +44,18 @@ def test_build_feature_info_reports_embeddings(fitted_ct): assert embeddings == {"embedding_1": {"preprocessing": None, "dimension": 8, "categories": None}} +def test_build_feature_info_does_not_infer_kind_from_cat_in_feature_name(make_config): + feature = "education_category_score" + frame = pd.DataFrame({feature: np.linspace(0.0, 1.0, 8)}) + ct = build_column_transformer(make_config(numerical_method="robust"), [feature], []) + ct.fit(frame) + + numerical, categorical, _ = build_feature_info(ct, embeddings=False, embedding_dimensions={}) + + assert feature in numerical + assert feature not in categorical + + 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}} From f133c20c5dc72b3f615d4de5503ab50bc6ce8ec6 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 21 Aug 2026 17:30:03 +0200 Subject: [PATCH 025/123] docs: remove duplicate copy symbol --- docs/_static/custom.css | 4 ++-- docs/conf.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/_static/custom.css b/docs/_static/custom.css index 24b235a..8013ec7 100644 --- a/docs/_static/custom.css +++ b/docs/_static/custom.css @@ -276,10 +276,10 @@ code.literal { } /* ── Code block sizing and shape ─────────────────────────────────────────── */ -/* Single border lives on pre; div.highlight only clips the radius. */ +/* Border and radius live on pre; div.highlight must stay overflow: visible so + the theme's injected copy-button tooltip can appear above the code block. */ div.highlight { border-radius: 8px; - overflow: hidden; } .highlight pre { diff --git a/docs/conf.py b/docs/conf.py index affa9fd..bd2e5e1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -39,7 +39,7 @@ "sphinx.ext.todo", "myst_parser", "sphinx_design", - "sphinx_copybutton", + # "sphinx_copybutton", ] # Optional dependency imported lazily by the embeddings transformer. From bd27ce237fa5759be2aa219cac67373191c52da2 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 21 Aug 2026 17:30:32 +0200 Subject: [PATCH 026/123] style: format command on markdown --- docs/developer_guide/contributing.md | 34 ++++++++++++++-------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/developer_guide/contributing.md b/docs/developer_guide/contributing.md index 1b36cc4..92e624e 100644 --- a/docs/developer_guide/contributing.md +++ b/docs/developer_guide/contributing.md @@ -17,26 +17,26 @@ defines testing, building, and formatting). 1. Clone the repository: - ```bash - git clone https://github.com/OpenTabular/PreTab - cd PreTab - ``` +```bash +git clone https://github.com/OpenTabular/PreTab +cd PreTab +``` 2. Install the prerequisites: `pip install poetry` and `just` (see the [just install guide](https://just.systems/man/en/packages.html), e.g. `brew install just`). 3. Install dependencies and register the pre-commit hooks: - ```bash - just install - ``` +```bash +just install +``` - Without `just`, run the same steps directly: +Without `just`, run the same steps directly: - ```bash - poetry install - poetry run pre-commit install --hook-type commit-msg --hook-type pre-commit --hook-type pre-push - ``` +```bash +poetry install +poetry run pre-commit install --hook-type commit-msg --hook-type pre-commit --hook-type pre-push +``` 4. To work on the docs, also install the docs group with `poetry install --with docs`. @@ -46,11 +46,11 @@ defines testing, building, and formatting). 2. Make your changes, keeping each pull request to a single logical focus. 3. Add or update tests, and run the full check suite locally before pushing: - ```bash - just test # full suite with coverage - just check # lint, format, type-check, all pre-commit hooks (what CI runs) - just docs # build HTML docs (warnings treated as errors) - ``` +```bash +just test # full suite with coverage +just check # lint, format, type-check, all pre-commit hooks (what CI runs) +just docs # build HTML docs (warnings treated as errors) +``` 4. Commit using Conventional Commits via `just commit`. If `just check` reformats files, commit those separately with `style: apply ruff formatting`. From 91d47872fb97ef9b29ec039bb3eafcc867f8a518 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 21 Aug 2026 17:33:57 +0200 Subject: [PATCH 027/123] chore: formatting fix --- pretab/core/knots.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pretab/core/knots.py b/pretab/core/knots.py index 7f10024..3a5b7b4 100644 --- a/pretab/core/knots.py +++ b/pretab/core/knots.py @@ -45,11 +45,13 @@ def bspline_basis(x: np.ndarray, knots: np.ndarray, degree: int, i: int, last: i denom1 = knots[i + degree] - knots[i] denom2 = knots[i + degree + 1] - knots[i + 1] term1: np.ndarray = ( - np.zeros_like(x, dtype=float) if denom1 == 0 + np.zeros_like(x, dtype=float) + if denom1 == 0 else (x - knots[i]) / denom1 * bspline_basis(x, knots, degree - 1, i, last) ) term2: np.ndarray = ( - np.zeros_like(x, dtype=float) if denom2 == 0 + np.zeros_like(x, dtype=float) + if denom2 == 0 else (knots[i + degree + 1] - x) / denom2 * bspline_basis(x, knots, degree - 1, i + 1, last) ) return term1 + term2 From 12ba0e783168781956a646b8d99f6fe247f29033 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 21 Aug 2026 17:42:50 +0200 Subject: [PATCH 028/123] chore: pyright issues fixed --- pretab/compose/output.py | 2 +- tests/compose/test_feature_detection.py | 2 +- tests/integration/test_output_format.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pretab/compose/output.py b/pretab/compose/output.py index b6571cc..11dd69d 100644 --- a/pretab/compose/output.py +++ b/pretab/compose/output.py @@ -267,6 +267,6 @@ def format_output( embeddings, expected=embeddings_expected, embedding_dimensions=embedding_dimensions, - n_samples=transformed.shape[0], + n_samples=transformed.shape[0] if transformed.shape else None, ) return result diff --git a/tests/compose/test_feature_detection.py b/tests/compose/test_feature_detection.py index b759a4c..670e8c4 100644 --- a/tests/compose/test_feature_detection.py +++ b/tests/compose/test_feature_detection.py @@ -30,7 +30,7 @@ def test_to_dataframe_rejects_duplicate_columns(): clear PretabDataError instead of an opaque AttributeError deep inside column-type detection. """ - df = pd.DataFrame(np.column_stack([np.zeros(5), np.ones(5)]), columns=["a", "a"]) + df = pd.DataFrame(np.column_stack([np.zeros(5), np.ones(5)]), columns=pd.Index(["a", "a"])) with pytest.raises(PretabDataError, match=r"Duplicate column names.*\['a'\]"): to_dataframe(df) diff --git a/tests/integration/test_output_format.py b/tests/integration/test_output_format.py index ba8a6e3..540c39e 100644 --- a/tests/integration/test_output_format.py +++ b/tests/integration/test_output_format.py @@ -98,7 +98,7 @@ def toarray(self, *args, **kwargs): monkeypatch.setattr(p.column_transformer_, "transform", lambda X: guarded) out = p.transform(cats, return_array=True) - assert sp.issparse(out) + assert isinstance(out, sp.csr_matrix) assert out.shape == (100, 100) assert out.nnz == 100 assert p.output_report_["density"] == pytest.approx(0.01) From 55da45e909db60e71eedf779fd903636980ea91c Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 21 Aug 2026 17:48:16 +0200 Subject: [PATCH 029/123] docs: update release process, rc increment --- docs/developer_guide/release.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/developer_guide/release.md b/docs/developer_guide/release.md index aa34381..823f39b 100644 --- a/docs/developer_guide/release.md +++ b/docs/developer_guide/release.md @@ -73,8 +73,16 @@ pip install --index-url https://test.pypi.org/simple/ \ python -c "import pretab; print(pretab.__version__)" ``` -If the candidate has problems, fix them on the branch, then repeat step 3 to produce the -next RC (`rc2`, `rc3`, ...). +If the candidate has problems, fix them on the branch and produce the next RC: + +```bash +just bump-rc-preview # confirm the proposed version (e.g. 1.0.0rc2) +just bump-rc # apply the bump: pyproject.toml, CHANGELOG.md, commit, tag +git push origin release/vX.Y.Z +git push origin vX.Y.ZrcN +``` + +Repeat for each additional RC (`rc3`, `rc4`, ...) until the build is clean. ### 5. Merge to main From d933280db8a57bb89a1446cb5a415d34cef8b2e5 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 21 Aug 2026 17:48:51 +0200 Subject: [PATCH 030/123] =?UTF-8?q?bump:=20version=201.0.0rc1=20=E2=86=92?= =?UTF-8?q?=201.0.0rc2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 30 ++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e37a83d..ec9fb03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,36 @@ This project adheres to [Semantic Versioning](https://semver.org/) and uses Going forward, this file is updated automatically by `cz bump` on each release. +## v1.0.0rc2 (2026-08-21) + +### Fix + +- missclassify numerical to cat feature based on name +- enfore feature count for cat transformer +- unassigned int cat cuttoff +- missing policy feature inspection, embedding silient failure +- sparse to dense conversion for sparse output +- custom representation usage for cat features +- supplied parameter override preset +- appropriate error message for out_dim and min_out_dim (#38) +- raise error for duplicate columns (#37) +- follow sklearn contract for binning (#36) +- reject unrecognized scalar (#35) +- validate embedding against fitted dimension (#34) +- set include_bias to False as default (#33) +- **embeddings**: accept list input in LanguageEmbeddingTransformer.fit (issue #21) +- **core**: raise on mismatched input_features length in get_feature_names_out (issue #21) +- **locations**: keep importance aligned with locations after sort/dedupe (issue #21) +- keep location provided by tree when suplementing +- provide appropriate error for onehot_from_ordinal input +- **splines,transformers**: close B-spline final span and fix ContinuousOrdinalTransformer DataFrame input (issues #12, #14) +- **selectors**: make _enforce_spacing order-independent to fix lightgbm clustering (issue #10) + +### Perf + +- **splines**: drop retained training design matrices (issue #19) +- **preprocessor**: slice dict blocks from output_indices_ instead of re-transforming (issue #20) + ## v1.0.0rc1 (2026-08-15) ### Feat diff --git a/pyproject.toml b/pyproject.toml index b598b95..1ced03b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pretab" -version = "1.0.0rc1" +version = "1.0.0rc2" description = "A scikit-learn compatible library for flexible tabular preprocessing, advanced feature representations, and basis expansions." authors = [ { name = "Anton Thielmann" }, From 7bbbe12dcedc14e6fb7b3764179f3505db26161d Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 21 Aug 2026 17:55:08 +0200 Subject: [PATCH 031/123] ci: run docs for stable tags --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 1330fdf..cd5b4d7 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -16,7 +16,7 @@ on: branches: - main tags: - - "v*" + - "v*.*.*" concurrency: group: docs-${{ github.head_ref || github.ref }} From f9bd3b64009170c6f1427521dd049e563b02d86a Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sat, 22 Aug 2026 08:03:53 +0200 Subject: [PATCH 032/123] chore: sklearn tags added --- pretab/transformers/categorical/language_embedding.py | 6 ++++++ pretab/transformers/categorical/ordinal.py | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/pretab/transformers/categorical/language_embedding.py b/pretab/transformers/categorical/language_embedding.py index f2b7786..f164816 100644 --- a/pretab/transformers/categorical/language_embedding.py +++ b/pretab/transformers/categorical/language_embedding.py @@ -123,6 +123,12 @@ 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 __sklearn_tags__(self): + tags = super().__sklearn_tags__() # type: ignore[attr-defined] + tags.input_tags.categorical = True + tags.input_tags.string = True + return tags + def get_feature_names_out(self, input_features=None): """Return output feature names: one per embedding dimension per input column. diff --git a/pretab/transformers/categorical/ordinal.py b/pretab/transformers/categorical/ordinal.py index 1cb1d7f..ba510bc 100644 --- a/pretab/transformers/categorical/ordinal.py +++ b/pretab/transformers/categorical/ordinal.py @@ -110,6 +110,8 @@ def get_feature_names_out(self, input_features=None): def __sklearn_tags__(self): """Declare that missing/unknown categories are handled (mapped to 0).""" - tags = super().__sklearn_tags__() + tags = super().__sklearn_tags__() # type: ignore[attr-defined] tags.input_tags.allow_nan = True + tags.input_tags.categorical = True + tags.input_tags.string = True return tags From 5d2cffa90a916517dd912c7caca4846dcb801477 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sat, 22 Aug 2026 08:04:29 +0200 Subject: [PATCH 033/123] style: ignore pylance warning for override --- pretab/core/base.py | 2 +- pretab/transformers/categorical/legacy.py | 2 +- pretab/transformers/encoders/floats.py | 4 ++-- pretab/transformers/encoders/missing.py | 2 +- pretab/transformers/feature_maps/base.py | 2 +- pretab/transformers/numerical/piecewise.py | 2 +- pyproject.toml | 1 + 7 files changed, 8 insertions(+), 7 deletions(-) diff --git a/pretab/core/base.py b/pretab/core/base.py index d5cba09..c3c449c 100644 --- a/pretab/core/base.py +++ b/pretab/core/base.py @@ -109,7 +109,7 @@ def total_output_dim_(self) -> int: def __sklearn_tags__(self): """Declare NaN-passthrough and target-requirement estimator tags.""" - tags = super().__sklearn_tags__() + tags = super().__sklearn_tags__() # type: ignore[attr-defined] tags.input_tags.allow_nan = self._allow_nan tags.target_tags.required = self._requires_y return tags diff --git a/pretab/transformers/categorical/legacy.py b/pretab/transformers/categorical/legacy.py index e3f84e6..3aa569c 100644 --- a/pretab/transformers/categorical/legacy.py +++ b/pretab/transformers/categorical/legacy.py @@ -147,6 +147,6 @@ def get_feature_names_out(self, input_features=None): def __sklearn_tags__(self): """Ordinal integer input is required; missing values are not supported.""" - tags = super().__sklearn_tags__() + tags = super().__sklearn_tags__() # type: ignore[attr-defined] tags.input_tags.allow_nan = False return tags diff --git a/pretab/transformers/encoders/floats.py b/pretab/transformers/encoders/floats.py index e9a73ca..d6cdb0d 100644 --- a/pretab/transformers/encoders/floats.py +++ b/pretab/transformers/encoders/floats.py @@ -79,7 +79,7 @@ def get_feature_names_out(self, input_features=None): def __sklearn_tags__(self): """Declare that missing values pass through unchanged.""" - tags = super().__sklearn_tags__() + tags = super().__sklearn_tags__() # type: ignore[attr-defined] tags.input_tags.allow_nan = True return tags @@ -157,6 +157,6 @@ def get_feature_names_out(self, input_features=None): def __sklearn_tags__(self): """Declare that missing values pass through the float cast.""" - tags = super().__sklearn_tags__() + tags = super().__sklearn_tags__() # type: ignore[attr-defined] tags.input_tags.allow_nan = True return tags diff --git a/pretab/transformers/encoders/missing.py b/pretab/transformers/encoders/missing.py index ed98d09..bc6d6d1 100644 --- a/pretab/transformers/encoders/missing.py +++ b/pretab/transformers/encoders/missing.py @@ -91,6 +91,6 @@ def get_feature_names_out(self, input_features=None): def __sklearn_tags__(self): """Declare that missing values are expected (they are the signal).""" - tags = super().__sklearn_tags__() + tags = super().__sklearn_tags__() # type: ignore[attr-defined] tags.input_tags.allow_nan = True return tags diff --git a/pretab/transformers/feature_maps/base.py b/pretab/transformers/feature_maps/base.py index 6434f48..95432f9 100644 --- a/pretab/transformers/feature_maps/base.py +++ b/pretab/transformers/feature_maps/base.py @@ -152,6 +152,6 @@ def _resolve_placement_strategy(self) -> str: def __sklearn_tags__(self): """Require ``y`` only when centers are placed by a target-aware selector.""" - tags = super().__sklearn_tags__() + tags = super().__sklearn_tags__() # type: ignore[attr-defined] tags.target_tags.required = bool(self.target_aware) return tags diff --git a/pretab/transformers/numerical/piecewise.py b/pretab/transformers/numerical/piecewise.py index b368e56..4304b9b 100644 --- a/pretab/transformers/numerical/piecewise.py +++ b/pretab/transformers/numerical/piecewise.py @@ -140,7 +140,7 @@ def __init__( def __sklearn_tags__(self): """Declare the required-target tag; PLE requires finite input.""" - tags = super().__sklearn_tags__() + tags = super().__sklearn_tags__() # type: ignore[attr-defined] tags.input_tags.allow_nan = False tags.target_tags.required = True return tags diff --git a/pyproject.toml b/pyproject.toml index 1ced03b..1373254 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,6 +82,7 @@ filterwarnings = [ # code quality tools [tool.pyright] include = ["pretab", "tests"] +reportImplicitOverride = false exclude = [ "**/__pycache__", ".venv", From 706097341af0e801a806b2f6f4118c96a4e8f48f Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sat, 22 Aug 2026 08:47:10 +0200 Subject: [PATCH 034/123] docs: docstring updated and corrected --- pretab/compose/__init__.py | 4 +- pretab/core/base.py | 4 +- pretab/core/parameters.py | 99 ++++++++++--------- pretab/core/policy.py | 2 +- pretab/placement/factory.py | 8 +- pretab/preprocessor.py | 6 +- pretab/transformers/categorical/__init__.py | 3 +- pretab/transformers/numerical/__init__.py | 3 +- .../splines/multivariate/__init__.py | 3 +- 9 files changed, 67 insertions(+), 65 deletions(-) diff --git a/pretab/compose/__init__.py b/pretab/compose/__init__.py index d333b8b..e00ec52 100644 --- a/pretab/compose/__init__.py +++ b/pretab/compose/__init__.py @@ -1,6 +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. +Includes the ``config``, ``registry``, ``factory``, ``feature_detection``, +``output`` and ``inspection`` modules. """ diff --git a/pretab/core/base.py b/pretab/core/base.py index c3c449c..6c7a10b 100644 --- a/pretab/core/base.py +++ b/pretab/core/base.py @@ -41,8 +41,8 @@ class BasePreTabTransformer( _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. + #: Shared, central edge-case policy. 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`` diff --git a/pretab/core/parameters.py b/pretab/core/parameters.py index 8d7e6df..990183e 100644 --- a/pretab/core/parameters.py +++ b/pretab/core/parameters.py @@ -1,22 +1,15 @@ -"""Canonical parameter vocabulary and legacy-alias resolution. - -Different transformer families historically spelled the same concept in -different ways -- the per-feature output size alone was named -``n_basis_functions`` / ``n_knots`` / ``n_bins`` / ``n_centers`` / ``bins`` / -``n_basis``. As of Phase 15 those count names are removed and every family takes -a single ``output_dim`` (the number of non-bias output columns per feature). -:data:`CANONICAL_PARAMS` records the single cross-family vocabulary new code and -docs should use, and :class:`AliasResolverMixin` provides the (currently unused) -machinery for a transformer to accept a legacy constructor name as an *alias* -that resolves to its canonical name at ``fit`` time (with a -:class:`FutureWarning`). - -The mechanism follows scikit-learn's deprecation constraint: ``get_params`` and -``clone`` introspect ``__init__`` and re-instantiate with the exact same -argument names, so aliases cannot hide behind ``**kwargs``. Instead the -canonical parameter and every legacy alias are ordinary constructor arguments -that default to :data:`UNSET` and are stored verbatim; the effective value is -resolved inside ``fit``. +"""Shared parameter names and backward-compatible alias resolution. + +Every transformer family uses the same parameter names for the same concepts. +``output_dim`` controls how many output columns are produced per input feature; +``placement_strategy`` controls where basis functions are placed; and so on. +These shared names are listed in :data:`CANONICAL_PARAMS`. + +Older code may pass the historic names that existed before the vocabulary was +unified (e.g. ``n_knots``, ``n_bins``, ``n_centers``). :class:`AliasResolverMixin` +lets a transformer accept those old names transparently: the old name still works +but emits a :class:`FutureWarning` so you know to update your code. Using both +the old and new name for the same parameter at the same time raises an error. """ from __future__ import annotations @@ -28,11 +21,12 @@ class _Unset: - """Sentinel marking a constructor argument the user did not provide. + """Sentinel for a constructor argument the caller did not supply. - A dedicated singleton (rather than ``None``) is used because ``None`` is a - meaningful value for several parameters (for example an unset adaptive - bound). Being a singleton keeps it identity-comparable and clone-safe. + Using a dedicated singleton rather than ``None`` matters because ``None`` + is a valid value for several parameters (for example, an unbounded adaptive + dimension). The singleton is identity-comparable, so ``value is UNSET`` + is always unambiguous, and it survives sklearn's ``clone`` safely. """ __slots__ = () @@ -66,12 +60,15 @@ def is_set(value) -> bool: def validate_placement(target_aware: bool, placement_strategy: str) -> None: - """Validate the ``target_aware`` / ``placement_strategy`` contract. + """Check that ``target_aware`` and ``placement_strategy`` are compatible. - 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.exceptions.InvalidParamError` (a ``ValueError``) otherwise. + Target-aware transformers must use ``"cart"`` or ``"lightgbm"`` as their + placement strategy; unsupervised transformers must use ``"uniform"`` or + ``"quantile"``. + + .. note:: + This is enforced at ``fit`` time, not at construction, so you will only + see the error once you call ``fit()`` or ``fit_transform()``. """ if target_aware and placement_strategy not in TARGET_AWARE_STRATEGIES: raise InvalidParamError("When target_aware=True, placement_strategy must be 'cart' or 'lightgbm'.") @@ -79,7 +76,7 @@ def validate_placement(target_aware: bool, placement_strategy: str) -> None: 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. +#: Shared parameter names with a short description of what each one controls. CANONICAL_PARAMS: dict[str, str] = { "output_dim": "Number of non-bias output columns produced per input feature.", "min_output_dim": "Lower bound on the per-feature output dimension in adaptive mode.", @@ -93,21 +90,29 @@ def validate_placement(target_aware: bool, placement_strategy: str) -> None: class AliasResolverMixin: - """Accept legacy parameter names as aliases of the canonical vocabulary. + """Allow old parameter names to be used alongside the current ones. + + Some transformers used to accept parameter names like ``n_knots`` or + ``n_bins`` that have since been renamed to ``output_dim``. This mixin lets + a transformer keep accepting the old names so existing code does not break, + while nudging users toward the new names via a :class:`FutureWarning`. + + To enable aliases for a transformer, set a class-level ``_param_aliases`` + dict mapping each old name to its current equivalent:: + + _param_aliases = {"n_knots": "output_dim"} - Subclasses declare a class-level ``_param_aliases`` mapping each legacy - constructor argument to its canonical name, e.g.:: + Both names must be real constructor arguments defaulting to :data:`UNSET`. + This keeps scikit-learn's ``get_params``, ``set_params``, and ``clone`` + working correctly, since they inspect ``__init__`` directly. - _param_aliases = {"legacy_name": "canonical_name"} + Inside ``fit``, call :meth:`_resolve_param` to get the effective value. + It returns the current-name value if supplied, falls back to the old name + with a warning, and raises if both are set at the same time. - Both the canonical parameter and every legacy alias are ordinary - constructor parameters that default to :data:`UNSET` and are stored - verbatim (so ``get_params`` / ``clone`` / ``set_params`` stay - sklearn-correct). Inside ``fit`` call :meth:`_resolve_param` to obtain the - 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.exceptions.InvalidParamError`. + .. warning:: + Setting both the current name and a legacy alias for the same parameter + raises :class:`~pretab.exceptions.InvalidParamError`. Pick one. """ _param_aliases: ClassVar[dict[str, str]] = {} @@ -117,17 +122,17 @@ def _aliases_for(self, canonical: str) -> list[str]: return [alias for alias, target in self._param_aliases.items() if target == canonical] def _resolve_param(self, canonical: str, default=UNSET) -> Any: - """Return the effective value for a canonical parameter. + """Return the effective value for a parameter, resolving any legacy alias. - Resolution order: an explicitly set canonical value, otherwise an - explicitly set legacy alias (emitting a ``FutureWarning``), otherwise - ``default``. + Checks the canonical name first. If not set, looks for a legacy alias + and returns its value with a deprecation warning. Returns ``default`` + if neither is set. Raises ------ InvalidParamError - If both the canonical parameter and a legacy alias are set, or if - two conflicting legacy aliases for the same canonical are set. + If both the canonical name and a legacy alias are set, or if two + conflicting aliases for the same parameter are both set. """ canon_val = getattr(self, canonical, UNSET) set_aliases = [ diff --git a/pretab/core/policy.py b/pretab/core/policy.py index c915361..1702f0f 100644 --- a/pretab/core/policy.py +++ b/pretab/core/policy.py @@ -1,4 +1,4 @@ -"""Central, explicit edge-case policy for representations (decision D9). +"""Central, explicit edge-case policy for representations. :class:`RepresentationPolicy` names, in one place, how every transformer reacts to the recurring edge cases that would otherwise diverge silently per family: diff --git a/pretab/placement/factory.py b/pretab/placement/factory.py index 7e5dd73..169a285 100644 --- a/pretab/placement/factory.py +++ b/pretab/placement/factory.py @@ -2,14 +2,14 @@ :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 +concrete :class:`~pretab.placement.base.BasePlacementStrategy`. It validates the +``target_aware`` / ``placement_strategy`` combination 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. +(``include_endpoints``) are resolved by the caller, typically a family adapter +in :mod:`pretab.placement.adapters`, and passed straight through. """ from __future__ import annotations diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index f9d2619..7a2b1ea 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -899,7 +899,7 @@ 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) --- + # --- Portable serialization --- def to_spec(self, path=None) -> dict: """Serialize the fitted preprocessor to a portable, versioned spec. @@ -954,7 +954,7 @@ def from_spec(cls, source) -> "Preprocessor": raise PretabSerializationError(f"Spec reconstructed a {type(obj).__name__}, expected {cls.__name__}.") return obj - # --- Fingerprint & reproducibility (P9.2) --- + # --- Fingerprint & reproducibility --- def _canonical_spec(self) -> dict: """Deterministic subset of the spec used for fingerprinting.""" spec = preprocessor_to_spec(self) @@ -1008,7 +1008,7 @@ def reproducibility_report(self) -> dict: "representations": representations, } - # --- Immutable lifecycle (P9.3) --- + # --- Immutable lifecycle --- @property def lifecycle_state_(self) -> str: """Current lifecycle state: ``UNFITTED``, ``FITTED``, ``FROZEN``, or ``STALE``.""" diff --git a/pretab/transformers/categorical/__init__.py b/pretab/transformers/categorical/__init__.py index bd7d010..b4ceec4 100644 --- a/pretab/transformers/categorical/__init__.py +++ b/pretab/transformers/categorical/__init__.py @@ -1,6 +1,5 @@ """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). +time-boxed legacy one-hot-from-ordinal encoder. """ from .language_embedding import LanguageEmbeddingTransformer diff --git a/pretab/transformers/numerical/__init__.py b/pretab/transformers/numerical/__init__.py index f00d588..2406d5c 100644 --- a/pretab/transformers/numerical/__init__.py +++ b/pretab/transformers/numerical/__init__.py @@ -1,6 +1,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. +and periodic encoding. """ from .binning import NumericBinningTransformer diff --git a/pretab/transformers/splines/multivariate/__init__.py b/pretab/transformers/splines/multivariate/__init__.py index 39b5fa7..24bf4f9 100644 --- a/pretab/transformers/splines/multivariate/__init__.py +++ b/pretab/transformers/splines/multivariate/__init__.py @@ -1,7 +1,6 @@ """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). +per-column ``Preprocessor(numerical_method=...)`` whitelist). """ from .tensor_product import TensorProductSplineTransformer From 61ff82d4e5cd3936dd7d3a2c9be457d123b12e6a Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sat, 22 Aug 2026 09:32:33 +0200 Subject: [PATCH 035/123] chore: simplify ple output column name --- pretab/transformers/numerical/piecewise.py | 4 +-- tests/integration/test_preprocessor.py | 2 +- tests/regression/_golden/ple_supervised.json | 30 ++++++++++---------- tests/transformers/test_ple_transformer.py | 2 +- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/pretab/transformers/numerical/piecewise.py b/pretab/transformers/numerical/piecewise.py index 4304b9b..90588fb 100644 --- a/pretab/transformers/numerical/piecewise.py +++ b/pretab/transformers/numerical/piecewise.py @@ -328,7 +328,7 @@ def get_feature_names_out(self, input_features=None): Returns ------- feature_names_out : ndarray of str - One name per output column, formatted ``{name}_ple_piece{j}``. + One name per output column, formatted ``{name}_ple{j}``. """ check_is_fitted(self, ["thresholds_", "n_features_in_"]) @@ -338,7 +338,7 @@ def get_feature_names_out(self, input_features=None): feature_names_out = [] for name, n_bins in zip(input_features, self.n_bins_per_feature_, strict=False): for j in range(n_bins): - feature_names_out.append(f"{name}_ple_piece{j}") + feature_names_out.append(f"{name}_ple{j}") return np.array(feature_names_out) diff --git a/tests/integration/test_preprocessor.py b/tests/integration/test_preprocessor.py index 719ce99..b001546 100644 --- a/tests/integration/test_preprocessor.py +++ b/tests/integration/test_preprocessor.py @@ -230,7 +230,7 @@ def test_get_feature_names_out_does_not_duplicate_feature_name(sample_data): 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 + assert "num_num1_ple0" in names lineage_names = [record.output_feature for record in pre.get_feature_lineage()] assert lineage_names == names diff --git a/tests/regression/_golden/ple_supervised.json b/tests/regression/_golden/ple_supervised.json index f8bb231..e74feb2 100644 --- a/tests/regression/_golden/ple_supervised.json +++ b/tests/regression/_golden/ple_supervised.json @@ -4,21 +4,21 @@ 23 ], "feature_names": [ - "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", + "num_num_linear_ple0", + "num_num_linear_ple1", + "num_num_linear_ple2", + "num_num_linear_ple3", + "num_num_linear_ple4", + "num_num_normal_ple0", + "num_num_normal_ple1", + "num_num_normal_ple2", + "num_num_normal_ple3", + "num_num_normal_ple4", + "num_num_skewed_ple0", + "num_num_skewed_ple1", + "num_num_skewed_ple2", + "num_num_skewed_ple3", + "num_num_skewed_ple4", "cat_cat_str_alpha", "cat_cat_str_beta", "cat_cat_str_gamma", diff --git a/tests/transformers/test_ple_transformer.py b/tests/transformers/test_ple_transformer.py index bd2baa6..0570df4 100644 --- a/tests/transformers/test_ple_transformer.py +++ b/tests/transformers/test_ple_transformer.py @@ -140,5 +140,5 @@ def test_ple_feature_names_out(): names = transformer.get_feature_names_out(["age", "income"]) assert len(names) == transformer.get_n_features_out() - assert all("_ple_piece" in name for name in names) + assert all("_ple" in name for name in names) assert names[0].startswith("age") From ea479b349c7cee455a1ab1adaba804465c3828c2 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sat, 22 Aug 2026 10:13:04 +0200 Subject: [PATCH 036/123] docs: update docstring for ple and binning --- docs/api/representations.rst | 4 +-- pretab/transformers/numerical/binning.py | 13 +++++--- pretab/transformers/numerical/piecewise.py | 37 ++++++++++++---------- 3 files changed, 31 insertions(+), 23 deletions(-) diff --git a/docs/api/representations.rst b/docs/api/representations.rst index a0355da..f6e1427 100644 --- a/docs/api/representations.rst +++ b/docs/api/representations.rst @@ -39,8 +39,8 @@ Feature maps RandomFourierFeaturesTransformer NystroemFeaturesTransformer -Binning and piecewise-linear encoding -------------------------------------- +Binning and PLE +--------------- .. autosummary:: :toctree: _autosummary diff --git a/pretab/transformers/numerical/binning.py b/pretab/transformers/numerical/binning.py index bc397d6..8198608 100644 --- a/pretab/transformers/numerical/binning.py +++ b/pretab/transformers/numerical/binning.py @@ -57,11 +57,14 @@ class NumericBinningTransformer(RepresentationSpecMixin, AliasResolverMixin, Tra Notes ----- - 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. Values - seen at transform time that fall outside the fitted range are clamped into - the outer bins. + - 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. Values + seen at transform time that fall outside the fitted range are clamped into + the outer bins. + - Unlike scikit-learn's ``KBinsDiscretizer``, this transformer can also accept + explicit user-defined bin edges, which is useful when bins should follow + domain-specific boundaries rather than being learned from the data. Examples -------- diff --git a/pretab/transformers/numerical/piecewise.py b/pretab/transformers/numerical/piecewise.py index 90588fb..aa64d91 100644 --- a/pretab/transformers/numerical/piecewise.py +++ b/pretab/transformers/numerical/piecewise.py @@ -85,20 +85,25 @@ class PLETransformer( Notes ----- - The number of output columns per feature equals ``len(thresholds) + 1`` and - is therefore data-dependent: a feature whose tree finds fewer splits will - expand into fewer columns than a feature with many splits. ``output_dim`` is - 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 - with its own settings. They are slated for reconciliation in a later cleanup. + - The number of output columns per feature equals ``len(thresholds) + 1`` and + is therefore data-dependent: a feature whose placement model finds fewer + useful splits will expand into fewer columns than a feature with many splits. + ``output_dim`` is an upper bound on the number of bins, not an exact output + width; this is a documented exception to the exact-width contract used by + fixed-basis representations. + - Compared with the original PLE implementation, PreTab retains the same + piecewise-linear encoding principle while extending its use as a + scikit-learn-compatible transformer. It supports target-aware threshold + placement with CART or LightGBM and naturally permits feature-specific + representation widths when fewer than the requested number of thresholds + are discovered. + - PLE requires finite input: NaN values raise an error. Missing-value handling + is the responsibility of an upstream imputation step, for example through + the ``Preprocessor`` imputation parameters. + - The ``max_depth`` / ``min_samples_split`` / ``min_samples_leaf`` parameters + are retained for backward-compatible construction but no longer affect + threshold placement: the selected ``placement_strategy`` fits its own model + with its own settings. They are slated for reconciliation in a later cleanup. Examples -------- @@ -171,7 +176,7 @@ def fit(self, X, y=None): X, dtype=np.float64, # type: ignore ensure_2d=True, - ensure_all_finite=True, + ensure_all_finite=True, # type: ignore ) y = np.asarray(y).ravel() @@ -238,7 +243,7 @@ def transform(self, X): X, dtype=np.float64, # type: ignore ensure_2d=True, - ensure_all_finite=True, + ensure_all_finite=True, # type: ignore ) if X.shape[1] != self.n_features_in_: From c45327d6455683f4512be1801be27340504ac4bc Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sat, 22 Aug 2026 10:24:14 +0200 Subject: [PATCH 037/123] fix: remove unused ple parameters --- docs/representations/binning_and_ple.md | 3 +-- pretab/transformers/numerical/piecewise.py | 16 ---------------- 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/docs/representations/binning_and_ple.md b/docs/representations/binning_and_ple.md index 48852b9..2296f45 100644 --- a/docs/representations/binning_and_ple.md +++ b/docs/representations/binning_and_ple.md @@ -52,8 +52,7 @@ 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`. +`adaptive`, and `random_state=51`. ```{important} PLE **requires** the target. It places its edges using `y`, so it must be fit with a target diff --git a/pretab/transformers/numerical/piecewise.py b/pretab/transformers/numerical/piecewise.py index aa64d91..85f2dcd 100644 --- a/pretab/transformers/numerical/piecewise.py +++ b/pretab/transformers/numerical/piecewise.py @@ -64,12 +64,6 @@ class PLETransformer( Maximum number of bins per feature when ``adaptive=True``. random_state : int or None, default=51 Random state for reproducible tree fitting. - max_depth : int or None, default=None - Maximum depth of the decision tree. - min_samples_split : int, default=2 - Minimum number of samples required to split an internal node. - min_samples_leaf : int, default=1 - Minimum number of samples required at a leaf node. Attributes ---------- @@ -100,10 +94,6 @@ class PLETransformer( - PLE requires finite input: NaN values raise an error. Missing-value handling is the responsibility of an upstream imputation step, for example through the ``Preprocessor`` imputation parameters. - - The ``max_depth`` / ``min_samples_split`` / ``min_samples_leaf`` parameters - are retained for backward-compatible construction but no longer affect - threshold placement: the selected ``placement_strategy`` fits its own model - with its own settings. They are slated for reconciliation in a later cleanup. Examples -------- @@ -128,9 +118,6 @@ def __init__( min_output_dim=UNSET, max_output_dim=UNSET, random_state: int | None = 51, - max_depth: int | None = None, - min_samples_split: int = 2, - min_samples_leaf: int = 1, ): self.output_dim = output_dim self.placement_strategy = placement_strategy @@ -139,9 +126,6 @@ def __init__( self.min_output_dim = min_output_dim self.max_output_dim = max_output_dim self.random_state = random_state - self.max_depth = max_depth - self.min_samples_split = min_samples_split - self.min_samples_leaf = min_samples_leaf def __sklearn_tags__(self): """Declare the required-target tag; PLE requires finite input.""" From 9613987bbb9e0913a5c622ed275192f27870b25e Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sat, 22 Aug 2026 10:36:59 +0200 Subject: [PATCH 038/123] docs: update docstring for adaptive setting --- pretab/core/adaptive.py | 4 +++- pretab/preprocessor.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/piecewise.py | 3 ++- pretab/transformers/splines/base_spline.py | 3 ++- pretab/transformers/splines/cubic_regression.py | 2 +- pretab/transformers/splines/natural_cubic.py | 2 +- 10 files changed, 26 insertions(+), 10 deletions(-) diff --git a/pretab/core/adaptive.py b/pretab/core/adaptive.py index 1dba7e8..c51ced5 100644 --- a/pretab/core/adaptive.py +++ b/pretab/core/adaptive.py @@ -30,7 +30,9 @@ class AdaptiveResolutionMixin: checking ``output_dim`` is consistent with any explicitly supplied ``min``/``max`` request. * ``adaptive is True`` -> ``lo``/``hi`` come from the requested - ``min``/``max`` (each falling back to ``output_dim`` when unset). + ``min``/``max`` (each falling back to ``output_dim`` when unset). When both + ``min_output_dim`` and ``max_output_dim`` are supplied, ``output_dim`` is + not consulted at all -- it has no effect on the resolved window. The resolved window is then validated against a family-specific ``floor`` (minimum admissible count, e.g. ``degree + 1`` basis functions or ``1`` bin) diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index 7a2b1ea..6463b25 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -122,6 +122,9 @@ class Preprocessor(TransformerMixin, BaseEstimator): columns produced per input feature (bins for PLE/binning, centers for the feature maps, basis functions for the splines). The B/M/I splines clamp it into their supported ``[5, 50]`` range. Used as the fixed per-feature width when ``adaptive`` is False. + When ``adaptive`` is True *and* both ``min_output_dim`` and ``max_output_dim`` are + set, ``output_dim`` is ignored entirely: the per-feature width is chosen freely within + ``[min_output_dim, max_output_dim]``. degree : int, default=3 Polynomial / spline basis degree, used by ``"polynomial"`` and the spline methods (``"cubicspline"``, ``"pspline"``, ``"bspline"``, ...). Ignored by methods without a degree. @@ -147,7 +150,8 @@ class Preprocessor(TransformerMixin, BaseEstimator): adaptive : bool, default=False Whether adaptive-capable methods size each feature's output dimension from the data (within ``[min_output_dim, max_output_dim]``) instead of using the fixed ``output_dim``. - Fixed-width methods (e.g. plain scalers) ignore this flag. + Fixed-width methods (e.g. plain scalers) ignore this flag. When True and both bounds + are set, ``output_dim`` itself has no effect (see above). min_output_dim : int, default=5 Lower bound on the per-feature output dimension when ``adaptive`` is True. Ignored by fixed-width methods and when ``adaptive`` is False. diff --git a/pretab/transformers/feature_maps/rbf.py b/pretab/transformers/feature_maps/rbf.py index d42f326..bbf7b1b 100644 --- a/pretab/transformers/feature_maps/rbf.py +++ b/pretab/transformers/feature_maps/rbf.py @@ -34,7 +34,9 @@ class RBFExpansionTransformer(BaseCenterExpansion): adaptive : bool, default=False If True (with `target_aware=True`), the per-feature number of centers may vary within `[min_output_dim, max_output_dim]` instead of being fixed to - `output_dim`. Has no effect on the `quantile` / `uniform` paths. + `output_dim`. Has no effect on the `quantile` / `uniform` paths. When both + `min_output_dim` and `max_output_dim` are set, `output_dim` is ignored + entirely. min_output_dim : int or None, default=None Lower bound on the per-feature number of centers in adaptive mode. diff --git a/pretab/transformers/feature_maps/relu.py b/pretab/transformers/feature_maps/relu.py index 6be6f76..6b2496a 100644 --- a/pretab/transformers/feature_maps/relu.py +++ b/pretab/transformers/feature_maps/relu.py @@ -30,7 +30,9 @@ class ReLUExpansionTransformer(BaseCenterExpansion): adaptive : bool, default=False If True (with `target_aware=True`), the per-feature number of centers may vary within `[min_output_dim, max_output_dim]` instead of being fixed to - `output_dim`. Has no effect on the `quantile` / `uniform` paths. + `output_dim`. Has no effect on the `quantile` / `uniform` paths. When both + `min_output_dim` and `max_output_dim` are set, `output_dim` is ignored + entirely. min_output_dim : int or None, default=None Lower bound on the per-feature number of centers in adaptive mode. diff --git a/pretab/transformers/feature_maps/sigmoid.py b/pretab/transformers/feature_maps/sigmoid.py index 63556c4..f831320 100644 --- a/pretab/transformers/feature_maps/sigmoid.py +++ b/pretab/transformers/feature_maps/sigmoid.py @@ -34,7 +34,9 @@ class SigmoidExpansionTransformer(BaseCenterExpansion): adaptive : bool, default=False If True (with `target_aware=True`), the per-feature number of centers may vary within `[min_output_dim, max_output_dim]` instead of being fixed to - `output_dim`. Has no effect on the `quantile` / `uniform` paths. + `output_dim`. Has no effect on the `quantile` / `uniform` paths. When both + `min_output_dim` and `max_output_dim` are set, `output_dim` is ignored + entirely. min_output_dim : int or None, default=None Lower bound on the per-feature number of centers in adaptive mode. diff --git a/pretab/transformers/feature_maps/tanh.py b/pretab/transformers/feature_maps/tanh.py index 653ab15..0c7c941 100644 --- a/pretab/transformers/feature_maps/tanh.py +++ b/pretab/transformers/feature_maps/tanh.py @@ -33,7 +33,9 @@ class TanhExpansionTransformer(BaseCenterExpansion): adaptive : bool, default=False If True (with `target_aware=True`), the per-feature number of centers may vary within `[min_output_dim, max_output_dim]` instead of being fixed to - `output_dim`. Has no effect on the `quantile` / `uniform` paths. + `output_dim`. Has no effect on the `quantile` / `uniform` paths. When both + `min_output_dim` and `max_output_dim` are set, `output_dim` is ignored + entirely. min_output_dim : int or None, default=None Lower bound on the per-feature number of centers in adaptive mode. diff --git a/pretab/transformers/numerical/piecewise.py b/pretab/transformers/numerical/piecewise.py index 85f2dcd..d5f8bde 100644 --- a/pretab/transformers/numerical/piecewise.py +++ b/pretab/transformers/numerical/piecewise.py @@ -57,7 +57,8 @@ class PLETransformer( adaptive : bool, default=False If True, allow the number of bins per feature to be data-driven, bounded by ``min_output_dim`` and ``max_output_dim``. If False, every feature is - capped by ``output_dim``. + capped by ``output_dim``. When True and both bounds are set, ``output_dim`` + is ignored entirely. min_output_dim : int or None, default=None Minimum number of bins per feature when ``adaptive=True``. max_output_dim : int or None, default=None diff --git a/pretab/transformers/splines/base_spline.py b/pretab/transformers/splines/base_spline.py index 1eb985c..f9a3826 100644 --- a/pretab/transformers/splines/base_spline.py +++ b/pretab/transformers/splines/base_spline.py @@ -81,7 +81,8 @@ class BaseSplineTransformer(BasePreTabTransformer): adaptive : bool, default=False If True, the per-feature output dimension may vary within - ``[min_output_dim, max_output_dim]``. + ``[min_output_dim, max_output_dim]``. When both bounds are set, + ``output_dim`` is ignored entirely. min_output_dim : int or None, default=None Lower bound on the per-feature output dimension used in adaptive mode. diff --git a/pretab/transformers/splines/cubic_regression.py b/pretab/transformers/splines/cubic_regression.py index 5e21c6e..324c2f2 100644 --- a/pretab/transformers/splines/cubic_regression.py +++ b/pretab/transformers/splines/cubic_regression.py @@ -58,7 +58,7 @@ class CubicRegressionSplineTransformer(SplineBasisMixin, TransformerMixin, BaseE adaptive : bool, default=False If True (with ``target_aware=True``), the per-feature output dimension may vary within ``[min_output_dim, max_output_dim]`` instead of being fixed to - ``output_dim``. + ``output_dim``. When both bounds are set, ``output_dim`` is ignored entirely. min_output_dim : int or None, default=None Lower bound on the per-feature output dimension in adaptive mode. diff --git a/pretab/transformers/splines/natural_cubic.py b/pretab/transformers/splines/natural_cubic.py index 2c01e61..9c75505 100644 --- a/pretab/transformers/splines/natural_cubic.py +++ b/pretab/transformers/splines/natural_cubic.py @@ -61,7 +61,7 @@ class NaturalCubicSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEsti adaptive : bool, default=False If True (with ``target_aware=True``), the per-feature output dimension may vary within ``[min_output_dim, max_output_dim]`` instead of being fixed to - ``output_dim``. + ``output_dim``. When both bounds are set, ``output_dim`` is ignored entirely. min_output_dim : int or None, default=None Lower bound on the per-feature output dimension in adaptive mode. From a3c44b82f8131408b5bddbc5c8ea3d1b7aec7835 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sat, 22 Aug 2026 10:45:23 +0200 Subject: [PATCH 039/123] docs: note added for adaptive setting --- docs/core_concepts/configuration.md | 4 +++- docs/core_concepts/resolution_and_placement.md | 6 ++++++ docs/tutorials/adaptive_resolution.md | 12 ++++++++++-- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/core_concepts/configuration.md b/docs/core_concepts/configuration.md index 51a09ce..558c9ff 100644 --- a/docs/core_concepts/configuration.md +++ b/docs/core_concepts/configuration.md @@ -69,7 +69,9 @@ expanded.get_resolved_config()["output_dim"] # 16: wider representat 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`. +rather than using a fixed `output_dim`. The `output_dim=7` shown for `"adaptive"` above is just +the ordinary fallback default reported by `get_resolved_config()`; the preset itself never sets +it, and it has no effect since both bounds are set. ```{tip} A preset is a starting point, not a lock. Any parameter you pass alongside a preset overrides diff --git a/docs/core_concepts/resolution_and_placement.md b/docs/core_concepts/resolution_and_placement.md index ebeeb63..9e18fd7 100644 --- a/docs/core_concepts/resolution_and_placement.md +++ b/docs/core_concepts/resolution_and_placement.md @@ -53,6 +53,12 @@ instead. : The lower and upper bounds that apply only when `adaptive=True` (defaults `5` and `10`). They are ignored otherwise. +```{note} +When `adaptive=True` and both `min_output_dim` and `max_output_dim` are set, `output_dim` has +no effect at all: the window comes entirely from the two bounds. `output_dim` only matters in +adaptive mode when one of the bounds is left unset, where it fills in for the missing one. +``` + ```python from pretab import Preprocessor diff --git a/docs/tutorials/adaptive_resolution.md b/docs/tutorials/adaptive_resolution.md index 0074b83..ff697de 100644 --- a/docs/tutorials/adaptive_resolution.md +++ b/docs/tutorials/adaptive_resolution.md @@ -15,10 +15,18 @@ one. `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 +When adaptive is on and both bounds are set, `output_dim` is ignored completely: the fitted +width comes only from `[min_output_dim, max_output_dim]` and the data. If you leave one bound +unset, `output_dim` fills in for it (as the missing lower or upper edge of the search), so it +still matters in that case. See [Resolution and placement](../core_concepts/resolution_and_placement.md) for the mechanics. +```{warning} +Setting `output_dim` alongside `adaptive=True` with both `min_output_dim` and `max_output_dim` +is harmless but silently has no effect. It is easy to assume it caps or anchors the search; it +does not. Drop it, or drop one of the two bounds if you meant `output_dim` to anchor the window. +``` + ## A worked example We fit a spline with adaptive width on two signals of different complexity and inspect what each From 7cb1a5bcd951dc38c644a918321176ad2be4e39f Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sat, 22 Aug 2026 10:47:59 +0200 Subject: [PATCH 040/123] docs: api reference update --- docs/api/index.rst | 10 +++------- docs/index.rst | 5 ++++- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/docs/api/index.rst b/docs/api/index.rst index bece623..9e13316 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -1,3 +1,5 @@ +:orphan: + API reference ============= @@ -6,10 +8,4 @@ The complete public API of PreTab, organized by role. Start with the :doc:`representations` for the transformers, use :doc:`search_and_cross_fitting` for leakage-safe selection, and see :doc:`extension` to build your own. -.. toctree:: - :maxdepth: 2 - - preprocessor - representations - search_and_cross_fitting - extension +The four sections are listed directly under **API Reference** in the sidebar. diff --git a/docs/index.rst b/docs/index.rst index 81b0d5b..def872c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -57,7 +57,10 @@ :maxdepth: 2 :hidden: - api/index + api/preprocessor + api/representations + api/search_and_cross_fitting + api/extension .. toctree:: :caption: Developer Guide From 6d72619cac31a8e21a334ea2b65c8a73905584f7 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 23 Aug 2026 07:27:50 +0200 Subject: [PATCH 041/123] fix: validate embedding during fit --- pretab/compose/output.py | 61 +++++++++++++++++++------- pretab/preprocessor.py | 14 +++--- tests/compose/test_output.py | 22 +++++++++- tests/integration/test_preprocessor.py | 27 ++++++++++++ 4 files changed, 101 insertions(+), 23 deletions(-) diff --git a/pretab/compose/output.py b/pretab/compose/output.py index 11dd69d..5a0223c 100644 --- a/pretab/compose/output.py +++ b/pretab/compose/output.py @@ -21,6 +21,7 @@ "build_output_dict", "compute_output_report", "format_output", + "resolve_embedding_dimensions", "to_dataframe_output", "validate_embedding_request", ] @@ -165,6 +166,49 @@ def build_output_dict(transformed, slices, *, as_sparse=False) -> dict: return result +def _validate_embedding_array(arr, name: str, *, expected_width=None, n_samples=None): + """Validate one embedding array is 2D with the expected width and row count.""" + arr = np.asarray(arr) + if arr.ndim != 2: + raise PretabDataError( + f"{name} must be 2D (n_samples, n_dims); got shape {arr.shape}.\n" + "Fix: reshape the embedding array to 2 dimensions." + ) + if expected_width is not None and arr.shape[1] != expected_width: + raise PretabDataError( + f"{name} has {arr.shape[1]} column(s) but {expected_width} were fitted.\n" + "Fix: pass an embedding array with the same width used at fit time." + ) + if n_samples is not None and arr.shape[0] != n_samples: + raise PretabDataError( + f"{name} has {arr.shape[0]} row(s) but X has {n_samples}.\n" + "Fix: pass an embedding array with one row per sample in X." + ) + return arr + + +def resolve_embedding_dimensions(embeddings, n_samples: int) -> dict: + """Validate embeddings at fit time and return their ``name -> width`` mapping. + + Each array (or each array in a list) must be 2D and have exactly ``n_samples`` + rows, i.e. one row per sample in ``X``. Catches the same shape mistakes at + ``fit`` time that :func:`attach_embeddings` catches at ``transform`` time, + instead of only surfacing them (or a bare ``IndexError`` for 1D input) later. + + Raises + ------ + PretabDataError + If an array is not 2D or its row count does not match ``n_samples``. + """ + arrays = [embeddings] if isinstance(embeddings, np.ndarray) else list(embeddings) + dimensions = {} + for idx, arr in enumerate(arrays): + name = f"embedding_{idx + 1}" + arr = _validate_embedding_array(arr, name, n_samples=n_samples) + dimensions[name] = arr.shape[1] + return dimensions + + def attach_embeddings(result: dict, embeddings, *, expected: bool, embedding_dimensions=None, n_samples=None) -> dict: """Attach external embedding blocks to a transformed-output dict. @@ -192,23 +236,8 @@ def attach_embeddings(result: dict, embeddings, *, expected: bool, embedding_dim ) for idx, arr in enumerate(arrays): name = f"embedding_{idx + 1}" - arr = np.asarray(arr) - if arr.ndim != 2: - raise PretabDataError( - f"{name} must be 2D (n_samples, n_dims); got shape {arr.shape}.\n" - "Fix: reshape the embedding array to 2 dimensions." - ) expected_width = embedding_dimensions.get(name) if embedding_dimensions is not None else None - if expected_width is not None and arr.shape[1] != expected_width: - raise PretabDataError( - f"{name} has {arr.shape[1]} column(s) but {expected_width} were fitted.\n" - "Fix: pass an embedding array with the same width used at fit time." - ) - if n_samples is not None and arr.shape[0] != n_samples: - raise PretabDataError( - f"{name} has {arr.shape[0]} row(s) but X has {n_samples}.\n" - "Fix: pass an embedding array with one row per sample in X." - ) + arr = _validate_embedding_array(arr, name, expected_width=expected_width, n_samples=n_samples) result[name] = arr.astype(np.float32) return result diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index 6463b25..6ecc37d 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -21,7 +21,13 @@ clean_feature_names, get_output_slices, ) -from .compose.output import compute_output_report, format_output, to_dataframe_output, validate_embedding_request +from .compose.output import ( + compute_output_report, + format_output, + resolve_embedding_dimensions, + to_dataframe_output, + validate_embedding_request, +) from .compose.serialize import SCHEMA_VERSION, preprocessor_from_spec, preprocessor_to_spec from .core.logging import configure_logging, get_logger from .core.parameters import UNSET @@ -466,11 +472,7 @@ def fit(self, X, y=None, embeddings=None): self.embedding_dimensions_ = {} if embeddings is not None: self.embeddings_ = True - if isinstance(embeddings, np.ndarray): - self.embedding_dimensions_["embedding_1"] = embeddings.shape[1] - elif isinstance(embeddings, list): - for i, e in enumerate(embeddings): - self.embedding_dimensions_[f"embedding_{i + 1}"] = e.shape[1] + self.embedding_dimensions_ = resolve_embedding_dimensions(embeddings, n_samples=len(X)) numerical_features, categorical_features = detect_column_types( X, diff --git a/tests/compose/test_output.py b/tests/compose/test_output.py index 095169a..5d583ff 100644 --- a/tests/compose/test_output.py +++ b/tests/compose/test_output.py @@ -3,7 +3,7 @@ import numpy as np import pytest -from pretab.compose.output import attach_embeddings, build_output_dict, format_output +from pretab.compose.output import attach_embeddings, build_output_dict, format_output, resolve_embedding_dimensions from pretab.exceptions import IncompatibleParamsError, PretabDataError @@ -51,6 +51,26 @@ def test_attach_embeddings_rejects_wrong_row_count(): attach_embeddings({}, np.ones((2, 3)), expected=True, embedding_dimensions={"embedding_1": 3}, n_samples=100) +def test_resolve_embedding_dimensions_array(): + dims = resolve_embedding_dimensions(np.ones((5, 3)), n_samples=5) + assert dims == {"embedding_1": 3} + + +def test_resolve_embedding_dimensions_list(): + dims = resolve_embedding_dimensions([np.ones((5, 3)), np.ones((5, 2))], n_samples=5) + assert dims == {"embedding_1": 3, "embedding_2": 2} + + +def test_resolve_embedding_dimensions_rejects_row_mismatch(): + with pytest.raises(PretabDataError, match="has 20 row"): + resolve_embedding_dimensions(np.ones((20, 8)), n_samples=100) + + +def test_resolve_embedding_dimensions_rejects_1d(): + with pytest.raises(PretabDataError, match="2D"): + resolve_embedding_dimensions(np.ones(100), n_samples=100) + + def test_format_output_array_returns_input_unchanged(): arr = np.zeros((2, 2)) assert format_output(arr, return_array=True) is arr diff --git a/tests/integration/test_preprocessor.py b/tests/integration/test_preprocessor.py index b001546..8c7e291 100644 --- a/tests/integration/test_preprocessor.py +++ b/tests/integration/test_preprocessor.py @@ -69,6 +69,33 @@ def test_multiple_embeddings(sample_data): assert out["embedding_2"].shape[1] == 7 +def test_fit_rejects_embeddings_with_mismatched_row_count(sample_data): + """Regression guard: a row-count mismatch must raise at fit, not later at transform.""" + X, y = sample_data + embeddings = np.random.rand(len(X) // 2, 4) + + with pytest.raises(PretabDataError, match="row"): + Preprocessor().fit(X, y, embeddings=embeddings) + + +def test_fit_rejects_1d_embeddings(sample_data): + """Regression guard: 1D embeddings must raise PretabDataError, not a bare IndexError.""" + X, y = sample_data + embeddings = np.ones(len(X)) + + with pytest.raises(PretabDataError, match="2D"): + Preprocessor().fit(X, y, embeddings=embeddings) + + +def test_fit_rejects_one_mismatched_array_in_a_list(sample_data): + """Regression guard: each array in a list of embeddings is checked against len(X).""" + X, y = sample_data + embeddings = [np.random.rand(len(X), 3), np.random.rand(len(X) - 1, 7)] + + with pytest.raises(PretabDataError, match="row"): + Preprocessor().fit(X, y, embeddings=embeddings) + + def test_embeddings_are_required_after_embedding_aware_fit(sample_data): X, y = sample_data embeddings = np.random.rand(len(X), 4) From e2633aefe995e9f40764c77ad9885fb4b61e0604 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 23 Aug 2026 07:44:49 +0200 Subject: [PATCH 042/123] fix: add missing indicator --- pretab/compose/config.py | 22 ++++----- pretab/preprocessor.py | 5 +- tests/integration/test_missing_policy.py | 61 ++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 15 deletions(-) diff --git a/pretab/compose/config.py b/pretab/compose/config.py index 2782c49..b5c7dc7 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, invalid_param_error +from ..exceptions import invalid_param_error from .registry import ( CATEGORICAL_ALIASES, CATEGORICAL_METHODS, @@ -104,10 +104,6 @@ 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 missing_policy is not None and missing_policy not in MISSING_POLICIES: @@ -118,11 +114,6 @@ def from_params( "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 " - "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), @@ -184,6 +175,10 @@ def imputation_plan(self, *, is_numerical: bool) -> dict: 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. + ``add_missing_indicator=True`` with imputation disabled for this column + kind routes through the standalone ``MissingStateIndicator`` (via + ``separate_state``) instead of the imputer's own indicator, since + ``SimpleImputer.add_indicator`` only takes effect when the imputer runs. """ strategy = ( (self.numerical_imputation or "median") @@ -192,10 +187,11 @@ def imputation_plan(self, *, is_numerical: bool) -> dict: ) if self.missing_policy is None: configured = self.numerical_imputation if is_numerical else self.categorical_imputation + add_imputer = configured is not None return { - "add_imputer": configured is not None, - "add_indicator": self.add_missing_indicator, - "separate_state": False, + "add_imputer": add_imputer, + "add_indicator": self.add_missing_indicator and add_imputer, + "separate_state": self.add_missing_indicator and not add_imputer, "strategy": strategy, } if self.missing_policy in ("error", "propagate"): diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index 6ecc37d..21a8b4d 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -192,8 +192,9 @@ class Preprocessor(TransformerMixin, BaseEstimator): 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. + imputer's ``add_indicator``; a standalone :class:`~pretab.transformers.MissingStateIndicator` + is used instead when imputation is disabled for that column kind). 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`` diff --git a/tests/integration/test_missing_policy.py b/tests/integration/test_missing_policy.py index 5827baa..87b03a8 100644 --- a/tests/integration/test_missing_policy.py +++ b/tests/integration/test_missing_policy.py @@ -199,3 +199,64 @@ def test_separate_state_lineage_distinguishes_missing_indicator(frame_with_nan, def test_invalid_missing_policy_raises(frame_with_nan, y): with pytest.raises(InvalidParamError): _bspline(missing_policy="nonsense").fit(frame_with_nan, y) + + +# --- add_missing_indicator with imputation disabled ---------------------------- + + +def test_add_missing_indicator_with_imputation_none_emits_standalone_column(frame_with_nan, y): + """Regression guard: add_missing_indicator=True must work even when the + imputer for that column kind is disabled, per the documented "standalone + indicator when imputation is disabled" contract.""" + p = Preprocessor( + numerical_method="minmax", + numerical_imputation=None, + add_missing_indicator=True, + ).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) == len(frame_with_nan.columns) + + +def test_add_missing_indicator_with_imputation_none_still_propagates_nan(frame_with_nan, y): + """The representation branch is unaffected: NaN still reaches the transformer + unchanged, only the standalone indicator is added alongside it.""" + p = Preprocessor( + numerical_method="minmax", + numerical_imputation=None, + add_missing_indicator=True, + ).fit(frame_with_nan, y) + + out = p.transform(frame_with_nan, return_array=True) + assert np.isnan(out).any() + + +def test_add_missing_indicator_numerical_only_disabled(y): + """Regression guard: a numerical-only imputation=None combined with a + categorical column that has no missing values must still produce the + standalone numerical indicator (previously silently dropped).""" + frame = pd.DataFrame({"a": [1.0, 2.0, np.nan, 4.0], "c": ["x", "y", "x", "y"]}) + p = Preprocessor( + numerical_method="minmax", + categorical_method="int", + numerical_imputation=None, + add_missing_indicator=True, + ).fit(frame, y[:4]) + + names = list(p.get_feature_names_out()) + assert any(n.endswith("__missing") for n in names) + + +def test_add_missing_indicator_no_longer_requires_imputation_enabled(frame_with_nan, y): + """add_missing_indicator=True with both imputations disabled used to raise + IncompatibleParamsError; it must now fit successfully via standalone + indicators for every column.""" + p = Preprocessor( + numerical_method="minmax", + numerical_imputation=None, + categorical_imputation=None, + add_missing_indicator=True, + ).fit(frame_with_nan, y) + assert p.total_output_dim_ > 0 + From 9c09d59174b7485f81cd960d447677d7384c2660 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 23 Aug 2026 13:21:56 +0200 Subject: [PATCH 043/123] chore: formattng issue --- pretab/transformers/numerical/piecewise.py | 4 ++-- tests/integration/test_missing_policy.py | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/pretab/transformers/numerical/piecewise.py b/pretab/transformers/numerical/piecewise.py index d5f8bde..5831d60 100644 --- a/pretab/transformers/numerical/piecewise.py +++ b/pretab/transformers/numerical/piecewise.py @@ -161,7 +161,7 @@ def fit(self, X, y=None): X, dtype=np.float64, # type: ignore ensure_2d=True, - ensure_all_finite=True, # type: ignore + ensure_all_finite=True, # type: ignore ) y = np.asarray(y).ravel() @@ -228,7 +228,7 @@ def transform(self, X): X, dtype=np.float64, # type: ignore ensure_2d=True, - ensure_all_finite=True, # type: ignore + ensure_all_finite=True, # type: ignore ) if X.shape[1] != self.n_features_in_: diff --git a/tests/integration/test_missing_policy.py b/tests/integration/test_missing_policy.py index 87b03a8..b5ac9d4 100644 --- a/tests/integration/test_missing_policy.py +++ b/tests/integration/test_missing_policy.py @@ -259,4 +259,3 @@ def test_add_missing_indicator_no_longer_requires_imputation_enabled(frame_with_ add_missing_indicator=True, ).fit(frame_with_nan, y) assert p.total_output_dim_ > 0 - From a0df21b3c5f890ce088968b8cdb18452aa34c8f7 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 28 Aug 2026 08:20:11 +0200 Subject: [PATCH 044/123] test(public-api): pin pretab.transformers facade to 24 classes --- tests/integration/test_public_api.py | 44 ++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/integration/test_public_api.py b/tests/integration/test_public_api.py index 5b3b93e..16cc7b6 100644 --- a/tests/integration/test_public_api.py +++ b/tests/integration/test_public_api.py @@ -10,6 +10,39 @@ import pretab +# The stable public transformer facade. Every name here must stay importable from +# ``pretab.transformers`` no matter how the internal modules are relocated during the +# 1.0.0 layout refactor. Pinning the exact set makes an accidental drop or rename fail +# loudly instead of silently shrinking ``__all__``. +FACADE_TRANSFORMERS = frozenset( + { + "BSplineTransformer", + "ContinuousOrdinalTransformer", + "CubicRegressionSplineTransformer", + "FourierFeatureTransformer", + "ISplineTransformer", + "LanguageEmbeddingTransformer", + "MSplineTransformer", + "MissingStateIndicator", + "NaturalCubicSplineTransformer", + "NoTransformer", + "NumericBinningTransformer", + "NystroemFeaturesTransformer", + "OneHotFromOrdinalTransformer", + "PLETransformer", + "PSplineTransformer", + "PeriodicEncodingTransformer", + "RBFExpansionTransformer", + "RandomFourierFeaturesTransformer", + "ReLUExpansionTransformer", + "SigmoidExpansionTransformer", + "TanhExpansionTransformer", + "TensorProductSplineTransformer", + "ThinPlateSplineTransformer", + "ToFloatTransformer", + } +) + def test_public_names_are_exported(): for name in ("Preprocessor", "PretabWarning", "configure_logging", "set_verbosity", "__version__"): @@ -33,6 +66,17 @@ def test_transformers_public_surface_is_resolvable(): assert hasattr(transformers, name) +def test_transformers_facade_is_frozen(): + transformers = importlib.import_module("pretab.transformers") + assert set(transformers.__all__) == FACADE_TRANSFORMERS + + +@pytest.mark.parametrize("name", sorted(FACADE_TRANSFORMERS)) +def test_facade_transformer_is_an_importable_class(name): + transformers = importlib.import_module("pretab.transformers") + assert isinstance(getattr(transformers, name), type) + + def test_legacy_pipeline_package_is_removed(): with pytest.raises(ModuleNotFoundError): importlib.import_module("pretab.pipeline") From 9f43e0a387a06ea4ba040756d08a61c9ff45fe10 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 28 Aug 2026 08:36:18 +0200 Subject: [PATCH 045/123] refactor(expansion): move spline transformers to pretab.expansion.spline --- pretab/compose/registry.py | 24 +++++++++---------- pretab/expansion/__init__.py | 13 ++++++++++ .../splines => expansion/spline}/__init__.py | 2 +- .../splines => expansion/spline}/b_spline.py | 4 ++-- .../spline/base.py} | 0 .../spline}/cubic_regression.py | 0 .../splines => expansion/spline}/i_spline.py | 4 ++-- .../splines => expansion/spline}/m_spline.py | 4 ++-- .../splines => expansion/spline}/mixins.py | 2 +- .../spline}/multivariate/__init__.py | 0 .../spline}/multivariate/tensor_product.py | 2 +- .../spline}/multivariate/thin_plate.py | 0 .../spline}/natural_cubic.py | 0 .../splines => expansion/spline}/p_spline.py | 0 pretab/transformers/__init__.py | 20 ++++++++-------- tests/integration/test_adaptive_output_dim.py | 6 ++--- 16 files changed, 47 insertions(+), 34 deletions(-) create mode 100644 pretab/expansion/__init__.py rename pretab/{transformers/splines => expansion/spline}/__init__.py (93%) rename pretab/{transformers/splines => expansion/spline}/b_spline.py (95%) rename pretab/{transformers/splines/base_spline.py => expansion/spline/base.py} (100%) rename pretab/{transformers/splines => expansion/spline}/cubic_regression.py (100%) rename pretab/{transformers/splines => expansion/spline}/i_spline.py (96%) rename pretab/{transformers/splines => expansion/spline}/m_spline.py (96%) rename pretab/{transformers/splines => expansion/spline}/mixins.py (99%) rename pretab/{transformers/splines => expansion/spline}/multivariate/__init__.py (100%) rename pretab/{transformers/splines => expansion/spline}/multivariate/tensor_product.py (98%) rename pretab/{transformers/splines => expansion/spline}/multivariate/thin_plate.py (100%) rename pretab/{transformers/splines => expansion/spline}/natural_cubic.py (100%) rename pretab/{transformers/splines => expansion/spline}/p_spline.py (100%) diff --git a/pretab/compose/registry.py b/pretab/compose/registry.py index 30fde8e..b06d665 100644 --- a/pretab/compose/registry.py +++ b/pretab/compose/registry.py @@ -27,6 +27,18 @@ StandardScaler, ) +from ..expansion.spline.b_spline import BSplineTransformer +from ..expansion.spline.cubic_regression import CubicRegressionSplineTransformer +from ..expansion.spline.i_spline import ISplineTransformer +from ..expansion.spline.m_spline import MSplineTransformer +from ..expansion.spline.multivariate.tensor_product import ( + TensorProductSplineTransformer, +) +from ..expansion.spline.multivariate.thin_plate import ( + ThinPlateSplineTransformer, +) +from ..expansion.spline.natural_cubic import NaturalCubicSplineTransformer +from ..expansion.spline.p_spline import PSplineTransformer from ..transformers.categorical.language_embedding import ( LanguageEmbeddingTransformer, ) @@ -44,18 +56,6 @@ from ..transformers.feature_maps.tanh import TanhExpansionTransformer 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 CubicRegressionSplineTransformer -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", diff --git a/pretab/expansion/__init__.py b/pretab/expansion/__init__.py new file mode 100644 index 0000000..22461c7 --- /dev/null +++ b/pretab/expansion/__init__.py @@ -0,0 +1,13 @@ +"""Basis-expansion representations. + +Expansions map each numeric feature into a richer set of columns so that a linear +model can capture nonlinear structure. PreTab groups them into two families: + +- :mod:`pretab.expansion.spline` for spline basis expansions such as B-spline, + P-spline, natural cubic, and the multivariate tensor-product and thin-plate bases. +- :mod:`pretab.expansion.functional` for explicit nonlinear basis functions such as + radial basis functions, ReLU, sigmoid, tanh, and Fourier features. + +Every class here is also re-exported from :mod:`pretab.transformers`, which stays the +stable, flat public import surface. +""" diff --git a/pretab/transformers/splines/__init__.py b/pretab/expansion/spline/__init__.py similarity index 93% rename from pretab/transformers/splines/__init__.py rename to pretab/expansion/spline/__init__.py index 8339755..72976f6 100644 --- a/pretab/transformers/splines/__init__.py +++ b/pretab/expansion/spline/__init__.py @@ -1,5 +1,5 @@ from .b_spline import BSplineTransformer -from .base_spline import BaseSplineTransformer +from .base import BaseSplineTransformer from .cubic_regression import CubicRegressionSplineTransformer from .i_spline import ISplineTransformer from .m_spline import MSplineTransformer diff --git a/pretab/transformers/splines/b_spline.py b/pretab/expansion/spline/b_spline.py similarity index 95% rename from pretab/transformers/splines/b_spline.py rename to pretab/expansion/spline/b_spline.py index c09d307..8c0e70f 100644 --- a/pretab/transformers/splines/b_spline.py +++ b/pretab/expansion/spline/b_spline.py @@ -11,7 +11,7 @@ from scipy.interpolate import BSpline from ...core.parameters import UNSET -from .base_spline import BaseSplineTransformer +from .base import BaseSplineTransformer class BSplineTransformer(BaseSplineTransformer): @@ -23,7 +23,7 @@ class BSplineTransformer(BaseSplineTransformer): automatic (``output_dim`` with ``placement_strategy``). Multi-column input is expanded column by column and the results are stacked horizontally. - See :class:`~pretab.transformers.splines.base_spline.BaseSplineTransformer` + See :class:`~pretab.expansion.spline.base.BaseSplineTransformer` for the full parameter description. ``include_bias`` defaults to False: a B-spline basis over a clamped knot vector is a partition of unity (every row sums to 1), so prepending a bias column makes it an exact linear combination diff --git a/pretab/transformers/splines/base_spline.py b/pretab/expansion/spline/base.py similarity index 100% rename from pretab/transformers/splines/base_spline.py rename to pretab/expansion/spline/base.py diff --git a/pretab/transformers/splines/cubic_regression.py b/pretab/expansion/spline/cubic_regression.py similarity index 100% rename from pretab/transformers/splines/cubic_regression.py rename to pretab/expansion/spline/cubic_regression.py diff --git a/pretab/transformers/splines/i_spline.py b/pretab/expansion/spline/i_spline.py similarity index 96% rename from pretab/transformers/splines/i_spline.py rename to pretab/expansion/spline/i_spline.py index 6c32265..cc1130e 100644 --- a/pretab/transformers/splines/i_spline.py +++ b/pretab/expansion/spline/i_spline.py @@ -12,7 +12,7 @@ from scipy.interpolate import BSpline from ...core.parameters import UNSET -from .base_spline import BaseSplineTransformer +from .base import BaseSplineTransformer class ISplineTransformer(BaseSplineTransformer): @@ -25,7 +25,7 @@ class ISplineTransformer(BaseSplineTransformer): (``knot_locations``) > target-aware (``placement_strategy``) > automatic (``output_dim`` with ``placement_strategy``). - See :class:`~pretab.transformers.splines.base_spline.BaseSplineTransformer` + See :class:`~pretab.expansion.spline.base.BaseSplineTransformer` for the full parameter description. ``include_bias`` defaults to False here. Because I-splines start at zero, a bias term may be useful for a non-zero intercept. diff --git a/pretab/transformers/splines/m_spline.py b/pretab/expansion/spline/m_spline.py similarity index 96% rename from pretab/transformers/splines/m_spline.py rename to pretab/expansion/spline/m_spline.py index 0198860..ed8725f 100644 --- a/pretab/transformers/splines/m_spline.py +++ b/pretab/expansion/spline/m_spline.py @@ -12,7 +12,7 @@ from scipy.interpolate import BSpline from ...core.parameters import UNSET -from .base_spline import BaseSplineTransformer +from .base import BaseSplineTransformer class MSplineTransformer(BaseSplineTransformer): @@ -25,7 +25,7 @@ class MSplineTransformer(BaseSplineTransformer): (``knot_locations``) > target-aware (``placement_strategy``) > automatic (``output_dim`` with ``placement_strategy``). - See :class:`~pretab.transformers.splines.base_spline.BaseSplineTransformer` + See :class:`~pretab.expansion.spline.base.BaseSplineTransformer` for the full parameter description. ``include_bias`` defaults to False here. Examples diff --git a/pretab/transformers/splines/mixins.py b/pretab/expansion/spline/mixins.py similarity index 99% rename from pretab/transformers/splines/mixins.py rename to pretab/expansion/spline/mixins.py index 17fe52e..b325181 100644 --- a/pretab/transformers/splines/mixins.py +++ b/pretab/expansion/spline/mixins.py @@ -175,7 +175,7 @@ def _place_bspline_knots( Places ``output_dim - degree - 1`` interior knots (via :meth:`_place_interior_knots`) and brackets them with ``degree + 1`` repeated boundary knots on each side -- the B/M/I convention used by - :class:`~pretab.transformers.splines.base_spline.BaseSplineTransformer`. + :class:`~pretab.expansion.spline.base.BaseSplineTransformer`. The resulting marginal B-spline basis then has exactly ``output_dim`` (non-bias) columns: ``len(knots) - degree - 1 == output_dim``. On the adaptive selector path ``min_interior`` / ``max_interior`` clamp the diff --git a/pretab/transformers/splines/multivariate/__init__.py b/pretab/expansion/spline/multivariate/__init__.py similarity index 100% rename from pretab/transformers/splines/multivariate/__init__.py rename to pretab/expansion/spline/multivariate/__init__.py diff --git a/pretab/transformers/splines/multivariate/tensor_product.py b/pretab/expansion/spline/multivariate/tensor_product.py similarity index 98% rename from pretab/transformers/splines/multivariate/tensor_product.py rename to pretab/expansion/spline/multivariate/tensor_product.py index c7e3ae5..7aed557 100644 --- a/pretab/transformers/splines/multivariate/tensor_product.py +++ b/pretab/expansion/spline/multivariate/tensor_product.py @@ -53,7 +53,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.p_spline.PSplineTransformer`, + per marginal, exactly like :class:`~pretab.expansion.spline.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/multivariate/thin_plate.py b/pretab/expansion/spline/multivariate/thin_plate.py similarity index 100% rename from pretab/transformers/splines/multivariate/thin_plate.py rename to pretab/expansion/spline/multivariate/thin_plate.py diff --git a/pretab/transformers/splines/natural_cubic.py b/pretab/expansion/spline/natural_cubic.py similarity index 100% rename from pretab/transformers/splines/natural_cubic.py rename to pretab/expansion/spline/natural_cubic.py diff --git a/pretab/transformers/splines/p_spline.py b/pretab/expansion/spline/p_spline.py similarity index 100% rename from pretab/transformers/splines/p_spline.py rename to pretab/expansion/spline/p_spline.py diff --git a/pretab/transformers/__init__.py b/pretab/transformers/__init__.py index c68964c..17cc6c0 100644 --- a/pretab/transformers/__init__.py +++ b/pretab/transformers/__init__.py @@ -1,3 +1,13 @@ +from ..expansion.spline import ( + BSplineTransformer, + CubicRegressionSplineTransformer, + ISplineTransformer, + MSplineTransformer, + NaturalCubicSplineTransformer, + PSplineTransformer, + TensorProductSplineTransformer, + ThinPlateSplineTransformer, +) from .categorical import ( ContinuousOrdinalTransformer, LanguageEmbeddingTransformer, @@ -18,16 +28,6 @@ PeriodicEncodingTransformer, PLETransformer, ) -from .splines import ( - BSplineTransformer, - CubicRegressionSplineTransformer, - ISplineTransformer, - MSplineTransformer, - NaturalCubicSplineTransformer, - PSplineTransformer, - TensorProductSplineTransformer, - ThinPlateSplineTransformer, -) __all__ = [ "BSplineTransformer", diff --git a/tests/integration/test_adaptive_output_dim.py b/tests/integration/test_adaptive_output_dim.py index e8eb57e..6d9529b 100644 --- a/tests/integration/test_adaptive_output_dim.py +++ b/tests/integration/test_adaptive_output_dim.py @@ -21,9 +21,9 @@ 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 -from pretab.transformers.splines.m_spline import MSplineTransformer +from pretab.expansion.spline.b_spline import BSplineTransformer +from pretab.expansion.spline.i_spline import ISplineTransformer +from pretab.expansion.spline.m_spline import MSplineTransformer OUTPUT_DIM = 6 From aae47fbabaf3ea5edbbf940bd35dbd48477548cc Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 28 Aug 2026 08:42:59 +0200 Subject: [PATCH 046/123] refactor(expansion): move functional expansions to pretab.expansion.functional --- pretab/compose/registry.py | 10 +++---- pretab/expansion/functional/__init__.py | 26 +++++++++++++++++++ .../functional}/base.py | 0 .../functional}/fourier.py | 0 .../functional}/rbf.py | 0 .../functional}/relu.py | 0 .../functional}/sigmoid.py | 0 .../functional}/tanh.py | 0 pretab/transformers/__init__.py | 12 +++++---- pretab/transformers/feature_maps/__init__.py | 10 ------- 10 files changed, 38 insertions(+), 20 deletions(-) create mode 100644 pretab/expansion/functional/__init__.py rename pretab/{transformers/feature_maps => expansion/functional}/base.py (100%) rename pretab/{transformers/feature_maps => expansion/functional}/fourier.py (100%) rename pretab/{transformers/feature_maps => expansion/functional}/rbf.py (100%) rename pretab/{transformers/feature_maps => expansion/functional}/relu.py (100%) rename pretab/{transformers/feature_maps => expansion/functional}/sigmoid.py (100%) rename pretab/{transformers/feature_maps => expansion/functional}/tanh.py (100%) diff --git a/pretab/compose/registry.py b/pretab/compose/registry.py index b06d665..85767d9 100644 --- a/pretab/compose/registry.py +++ b/pretab/compose/registry.py @@ -27,6 +27,11 @@ StandardScaler, ) +from ..expansion.functional.fourier import FourierFeatureTransformer +from ..expansion.functional.rbf import RBFExpansionTransformer +from ..expansion.functional.relu import ReLUExpansionTransformer +from ..expansion.functional.sigmoid import SigmoidExpansionTransformer +from ..expansion.functional.tanh import TanhExpansionTransformer from ..expansion.spline.b_spline import BSplineTransformer from ..expansion.spline.cubic_regression import CubicRegressionSplineTransformer from ..expansion.spline.i_spline import ISplineTransformer @@ -45,15 +50,10 @@ 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.piecewise import PLETransformer diff --git a/pretab/expansion/functional/__init__.py b/pretab/expansion/functional/__init__.py new file mode 100644 index 0000000..5af85f6 --- /dev/null +++ b/pretab/expansion/functional/__init__.py @@ -0,0 +1,26 @@ +"""Explicit nonlinear basis-function expansions. + +Each transformer maps a numeric feature through a fixed nonlinear function (radial +basis, ReLU, sigmoid, tanh, or a sine/cosine pair) evaluated at a set of centers or +frequencies, producing one output column per basis unit. This is the "functional" +half of :mod:`pretab.expansion`, distinct from spline bases which live in +:mod:`pretab.expansion.spline`. + +Every class here is also re-exported from :mod:`pretab.transformers`. +""" + +from .base import BaseCenterExpansion +from .fourier import FourierFeatureTransformer +from .rbf import RBFExpansionTransformer +from .relu import ReLUExpansionTransformer +from .sigmoid import SigmoidExpansionTransformer +from .tanh import TanhExpansionTransformer + +__all__ = [ + "BaseCenterExpansion", + "FourierFeatureTransformer", + "RBFExpansionTransformer", + "ReLUExpansionTransformer", + "SigmoidExpansionTransformer", + "TanhExpansionTransformer", +] diff --git a/pretab/transformers/feature_maps/base.py b/pretab/expansion/functional/base.py similarity index 100% rename from pretab/transformers/feature_maps/base.py rename to pretab/expansion/functional/base.py diff --git a/pretab/transformers/feature_maps/fourier.py b/pretab/expansion/functional/fourier.py similarity index 100% rename from pretab/transformers/feature_maps/fourier.py rename to pretab/expansion/functional/fourier.py diff --git a/pretab/transformers/feature_maps/rbf.py b/pretab/expansion/functional/rbf.py similarity index 100% rename from pretab/transformers/feature_maps/rbf.py rename to pretab/expansion/functional/rbf.py diff --git a/pretab/transformers/feature_maps/relu.py b/pretab/expansion/functional/relu.py similarity index 100% rename from pretab/transformers/feature_maps/relu.py rename to pretab/expansion/functional/relu.py diff --git a/pretab/transformers/feature_maps/sigmoid.py b/pretab/expansion/functional/sigmoid.py similarity index 100% rename from pretab/transformers/feature_maps/sigmoid.py rename to pretab/expansion/functional/sigmoid.py diff --git a/pretab/transformers/feature_maps/tanh.py b/pretab/expansion/functional/tanh.py similarity index 100% rename from pretab/transformers/feature_maps/tanh.py rename to pretab/expansion/functional/tanh.py diff --git a/pretab/transformers/__init__.py b/pretab/transformers/__init__.py index 17cc6c0..724f43b 100644 --- a/pretab/transformers/__init__.py +++ b/pretab/transformers/__init__.py @@ -1,3 +1,10 @@ +from ..expansion.functional import ( + FourierFeatureTransformer, + RBFExpansionTransformer, + ReLUExpansionTransformer, + SigmoidExpansionTransformer, + TanhExpansionTransformer, +) from ..expansion.spline import ( BSplineTransformer, CubicRegressionSplineTransformer, @@ -15,13 +22,8 @@ ) from .encoders import MissingStateIndicator, NoTransformer, ToFloatTransformer from .feature_maps import ( - FourierFeatureTransformer, NystroemFeaturesTransformer, RandomFourierFeaturesTransformer, - RBFExpansionTransformer, - ReLUExpansionTransformer, - SigmoidExpansionTransformer, - TanhExpansionTransformer, ) from .numerical import ( NumericBinningTransformer, diff --git a/pretab/transformers/feature_maps/__init__.py b/pretab/transformers/feature_maps/__init__.py index 41105fd..ef4e36c 100644 --- a/pretab/transformers/feature_maps/__init__.py +++ b/pretab/transformers/feature_maps/__init__.py @@ -1,16 +1,6 @@ -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", ] From 571d444f52e1b50823df4e197d405b91d98dbc1d Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 28 Aug 2026 08:45:44 +0200 Subject: [PATCH 047/123] refactor(encoding): move numerical encoders to pretab.encoding.numerical --- pretab/compose/registry.py | 4 ++-- pretab/encoding/__init__.py | 11 +++++++++++ .../{transformers => encoding}/numerical/__init__.py | 6 +++--- .../{transformers => encoding}/numerical/binning.py | 0 .../{transformers => encoding}/numerical/periodic.py | 0 .../piecewise.py => encoding/numerical/ple.py} | 0 pretab/transformers/__init__.py | 10 +++++----- 7 files changed, 21 insertions(+), 10 deletions(-) create mode 100644 pretab/encoding/__init__.py rename pretab/{transformers => encoding}/numerical/__init__.py (55%) rename pretab/{transformers => encoding}/numerical/binning.py (100%) rename pretab/{transformers => encoding}/numerical/periodic.py (100%) rename pretab/{transformers/numerical/piecewise.py => encoding/numerical/ple.py} (100%) diff --git a/pretab/compose/registry.py b/pretab/compose/registry.py index 85767d9..e7b7cea 100644 --- a/pretab/compose/registry.py +++ b/pretab/compose/registry.py @@ -27,6 +27,8 @@ StandardScaler, ) +from ..encoding.numerical.binning import NumericBinningTransformer +from ..encoding.numerical.ple import PLETransformer from ..expansion.functional.fourier import FourierFeatureTransformer from ..expansion.functional.rbf import RBFExpansionTransformer from ..expansion.functional.relu import ReLUExpansionTransformer @@ -54,8 +56,6 @@ NystroemFeaturesTransformer, RandomFourierFeaturesTransformer, ) -from ..transformers.numerical.binning import NumericBinningTransformer -from ..transformers.numerical.piecewise import PLETransformer __all__ = [ "CATEGORICAL_ALIASES", diff --git a/pretab/encoding/__init__.py b/pretab/encoding/__init__.py new file mode 100644 index 0000000..ea83302 --- /dev/null +++ b/pretab/encoding/__init__.py @@ -0,0 +1,11 @@ +"""Feature encoding representations. + +Encoding recodes a raw column into a form a model can use directly, as opposed to +:mod:`pretab.expansion`, which expands a column into a richer basis. PreTab splits +encoding by input kind: + +- :mod:`pretab.encoding.numerical` recodes numeric values (binning, PLE, periodic). +- :mod:`pretab.encoding.categorical` maps categories to codes or indicators. + +Every class here is also re-exported from :mod:`pretab.transformers`. +""" diff --git a/pretab/transformers/numerical/__init__.py b/pretab/encoding/numerical/__init__.py similarity index 55% rename from pretab/transformers/numerical/__init__.py rename to pretab/encoding/numerical/__init__.py index 2406d5c..630e3da 100644 --- a/pretab/transformers/numerical/__init__.py +++ b/pretab/encoding/numerical/__init__.py @@ -1,10 +1,10 @@ -"""Numerical single-column transformers: binning, piecewise-linear encoding (PLE) -and periodic encoding. +"""Numerical encoding: recode numeric values into bins, target-aware piecewise +linear encodings, or cyclic (sin/cos) representations. """ from .binning import NumericBinningTransformer from .periodic import PeriodicEncodingTransformer -from .piecewise import PLETransformer +from .ple import PLETransformer __all__ = [ "NumericBinningTransformer", diff --git a/pretab/transformers/numerical/binning.py b/pretab/encoding/numerical/binning.py similarity index 100% rename from pretab/transformers/numerical/binning.py rename to pretab/encoding/numerical/binning.py diff --git a/pretab/transformers/numerical/periodic.py b/pretab/encoding/numerical/periodic.py similarity index 100% rename from pretab/transformers/numerical/periodic.py rename to pretab/encoding/numerical/periodic.py diff --git a/pretab/transformers/numerical/piecewise.py b/pretab/encoding/numerical/ple.py similarity index 100% rename from pretab/transformers/numerical/piecewise.py rename to pretab/encoding/numerical/ple.py diff --git a/pretab/transformers/__init__.py b/pretab/transformers/__init__.py index 724f43b..d55ff47 100644 --- a/pretab/transformers/__init__.py +++ b/pretab/transformers/__init__.py @@ -1,3 +1,8 @@ +from ..encoding.numerical import ( + NumericBinningTransformer, + PeriodicEncodingTransformer, + PLETransformer, +) from ..expansion.functional import ( FourierFeatureTransformer, RBFExpansionTransformer, @@ -25,11 +30,6 @@ NystroemFeaturesTransformer, RandomFourierFeaturesTransformer, ) -from .numerical import ( - NumericBinningTransformer, - PeriodicEncodingTransformer, - PLETransformer, -) __all__ = [ "BSplineTransformer", From 53c9aaa0f8d56dfb6c78954c92c3a3b90d8e84ed Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 28 Aug 2026 08:49:15 +0200 Subject: [PATCH 048/123] refactor(encoding): move categorical encoders to pretab.encoding.categorical --- pretab/compose/registry.py | 4 ++-- pretab/encoding/categorical/__init__.py | 11 +++++++++++ .../legacy.py => encoding/categorical/one_hot.py} | 0 .../{transformers => encoding}/categorical/ordinal.py | 0 pretab/transformers/__init__.py | 10 +++++----- pretab/transformers/categorical/__init__.py | 9 +++------ 6 files changed, 21 insertions(+), 13 deletions(-) create mode 100644 pretab/encoding/categorical/__init__.py rename pretab/{transformers/categorical/legacy.py => encoding/categorical/one_hot.py} (100%) rename pretab/{transformers => encoding}/categorical/ordinal.py (100%) diff --git a/pretab/compose/registry.py b/pretab/compose/registry.py index e7b7cea..b713599 100644 --- a/pretab/compose/registry.py +++ b/pretab/compose/registry.py @@ -27,6 +27,8 @@ StandardScaler, ) +from ..encoding.categorical.one_hot import OneHotFromOrdinalTransformer +from ..encoding.categorical.ordinal import ContinuousOrdinalTransformer from ..encoding.numerical.binning import NumericBinningTransformer from ..encoding.numerical.ple import PLETransformer from ..expansion.functional.fourier import FourierFeatureTransformer @@ -49,8 +51,6 @@ 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.kernel_approx import ( NystroemFeaturesTransformer, diff --git a/pretab/encoding/categorical/__init__.py b/pretab/encoding/categorical/__init__.py new file mode 100644 index 0000000..22c994b --- /dev/null +++ b/pretab/encoding/categorical/__init__.py @@ -0,0 +1,11 @@ +"""Categorical encoding: map categories to ordinal codes or one-hot indicators +from an already ordinal-encoded input. +""" + +from .one_hot import OneHotFromOrdinalTransformer +from .ordinal import ContinuousOrdinalTransformer + +__all__ = [ + "ContinuousOrdinalTransformer", + "OneHotFromOrdinalTransformer", +] diff --git a/pretab/transformers/categorical/legacy.py b/pretab/encoding/categorical/one_hot.py similarity index 100% rename from pretab/transformers/categorical/legacy.py rename to pretab/encoding/categorical/one_hot.py diff --git a/pretab/transformers/categorical/ordinal.py b/pretab/encoding/categorical/ordinal.py similarity index 100% rename from pretab/transformers/categorical/ordinal.py rename to pretab/encoding/categorical/ordinal.py diff --git a/pretab/transformers/__init__.py b/pretab/transformers/__init__.py index d55ff47..7665a59 100644 --- a/pretab/transformers/__init__.py +++ b/pretab/transformers/__init__.py @@ -1,3 +1,7 @@ +from ..encoding.categorical import ( + ContinuousOrdinalTransformer, + OneHotFromOrdinalTransformer, +) from ..encoding.numerical import ( NumericBinningTransformer, PeriodicEncodingTransformer, @@ -20,11 +24,7 @@ TensorProductSplineTransformer, ThinPlateSplineTransformer, ) -from .categorical import ( - ContinuousOrdinalTransformer, - LanguageEmbeddingTransformer, - OneHotFromOrdinalTransformer, -) +from .categorical import LanguageEmbeddingTransformer from .encoders import MissingStateIndicator, NoTransformer, ToFloatTransformer from .feature_maps import ( NystroemFeaturesTransformer, diff --git a/pretab/transformers/categorical/__init__.py b/pretab/transformers/categorical/__init__.py index b4ceec4..cef7030 100644 --- a/pretab/transformers/categorical/__init__.py +++ b/pretab/transformers/categorical/__init__.py @@ -1,13 +1,10 @@ -"""Categorical transformers: ordinal encoding, language embeddings and the -time-boxed legacy one-hot-from-ordinal encoder. +"""Categorical transformers: language embeddings. Ordinal encoding and the +time-boxed legacy one-hot-from-ordinal encoder live in +:mod:`pretab.encoding.categorical`. """ from .language_embedding import LanguageEmbeddingTransformer -from .legacy import OneHotFromOrdinalTransformer -from .ordinal import ContinuousOrdinalTransformer __all__ = [ - "ContinuousOrdinalTransformer", "LanguageEmbeddingTransformer", - "OneHotFromOrdinalTransformer", ] From 2c6265f29749abeebd7560a1a1efaf499247f916 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 28 Aug 2026 08:54:05 +0200 Subject: [PATCH 049/123] refactor(kernel-approximation): split kernel approximations into pretab.kernel_approximation --- pretab/compose/registry.py | 6 +- pretab/kernel_approximation/__init__.py | 17 +++++ .../nystroem.py} | 73 +------------------ pretab/kernel_approximation/random_fourier.py | 73 +++++++++++++++++++ pretab/transformers/__init__.py | 6 +- pretab/transformers/feature_maps/__init__.py | 6 -- 6 files changed, 98 insertions(+), 83 deletions(-) create mode 100644 pretab/kernel_approximation/__init__.py rename pretab/{transformers/feature_maps/kernel_approx.py => kernel_approximation/nystroem.py} (57%) create mode 100644 pretab/kernel_approximation/random_fourier.py delete mode 100644 pretab/transformers/feature_maps/__init__.py diff --git a/pretab/compose/registry.py b/pretab/compose/registry.py index b713599..f47e40c 100644 --- a/pretab/compose/registry.py +++ b/pretab/compose/registry.py @@ -48,14 +48,12 @@ ) from ..expansion.spline.natural_cubic import NaturalCubicSplineTransformer from ..expansion.spline.p_spline import PSplineTransformer +from ..kernel_approximation.nystroem import NystroemFeaturesTransformer +from ..kernel_approximation.random_fourier import RandomFourierFeaturesTransformer from ..transformers.categorical.language_embedding import ( LanguageEmbeddingTransformer, ) from ..transformers.encoders.floats import NoTransformer -from ..transformers.feature_maps.kernel_approx import ( - NystroemFeaturesTransformer, - RandomFourierFeaturesTransformer, -) __all__ = [ "CATEGORICAL_ALIASES", diff --git a/pretab/kernel_approximation/__init__.py b/pretab/kernel_approximation/__init__.py new file mode 100644 index 0000000..0636e18 --- /dev/null +++ b/pretab/kernel_approximation/__init__.py @@ -0,0 +1,17 @@ +"""Kernel approximation representations. + +Each transformer builds an explicit, low-dimensional feature map whose inner +products approximate an implicit kernel, so a linear model downstream can behave +like a kernel method without materializing the full kernel matrix. This mirrors +:mod:`sklearn.kernel_approximation`, which PreTab wraps directly. + +Every class here is also re-exported from :mod:`pretab.transformers`. +""" + +from .nystroem import NystroemFeaturesTransformer +from .random_fourier import RandomFourierFeaturesTransformer + +__all__ = [ + "NystroemFeaturesTransformer", + "RandomFourierFeaturesTransformer", +] diff --git a/pretab/transformers/feature_maps/kernel_approx.py b/pretab/kernel_approximation/nystroem.py similarity index 57% rename from pretab/transformers/feature_maps/kernel_approx.py rename to pretab/kernel_approximation/nystroem.py index fd693c7..49fa24a 100644 --- a/pretab/transformers/feature_maps/kernel_approx.py +++ b/pretab/kernel_approximation/nystroem.py @@ -1,80 +1,13 @@ import numpy as np -from sklearn.kernel_approximation import Nystroem, RBFSampler +from sklearn.kernel_approximation import Nystroem from sklearn.utils.validation import check_is_fitted -from ...core.base import BasePreTabTransformer -from ...exceptions import InvalidParamError +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" - _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 - 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). diff --git a/pretab/kernel_approximation/random_fourier.py b/pretab/kernel_approximation/random_fourier.py new file mode 100644 index 0000000..ccb0b8e --- /dev/null +++ b/pretab/kernel_approximation/random_fourier.py @@ -0,0 +1,73 @@ +import numpy as np +from sklearn.kernel_approximation import RBFSampler +from sklearn.utils.validation import check_is_fitted + +from ..core.base import BasePreTabTransformer +from ..exceptions import InvalidParamError + + +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" + _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 + 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] diff --git a/pretab/transformers/__init__.py b/pretab/transformers/__init__.py index 7665a59..9b3860b 100644 --- a/pretab/transformers/__init__.py +++ b/pretab/transformers/__init__.py @@ -24,12 +24,12 @@ TensorProductSplineTransformer, ThinPlateSplineTransformer, ) -from .categorical import LanguageEmbeddingTransformer -from .encoders import MissingStateIndicator, NoTransformer, ToFloatTransformer -from .feature_maps import ( +from ..kernel_approximation import ( NystroemFeaturesTransformer, RandomFourierFeaturesTransformer, ) +from .categorical import LanguageEmbeddingTransformer +from .encoders import MissingStateIndicator, NoTransformer, ToFloatTransformer __all__ = [ "BSplineTransformer", diff --git a/pretab/transformers/feature_maps/__init__.py b/pretab/transformers/feature_maps/__init__.py deleted file mode 100644 index ef4e36c..0000000 --- a/pretab/transformers/feature_maps/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .kernel_approx import NystroemFeaturesTransformer, RandomFourierFeaturesTransformer - -__all__ = [ - "NystroemFeaturesTransformer", - "RandomFourierFeaturesTransformer", -] From d069f9cab765cb352b7d03c87064b00dd970a3ad Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 28 Aug 2026 08:56:30 +0200 Subject: [PATCH 050/123] refactor(embedding): move language embedding to pretab.embedding --- pretab/compose/registry.py | 4 +--- pretab/embedding/__init__.py | 14 ++++++++++++++ .../language.py} | 2 +- pretab/transformers/__init__.py | 2 +- pretab/transformers/categorical/__init__.py | 10 ---------- 5 files changed, 17 insertions(+), 15 deletions(-) create mode 100644 pretab/embedding/__init__.py rename pretab/{transformers/categorical/language_embedding.py => embedding/language.py} (98%) delete mode 100644 pretab/transformers/categorical/__init__.py diff --git a/pretab/compose/registry.py b/pretab/compose/registry.py index f47e40c..83ca9a0 100644 --- a/pretab/compose/registry.py +++ b/pretab/compose/registry.py @@ -27,6 +27,7 @@ StandardScaler, ) +from ..embedding.language import LanguageEmbeddingTransformer from ..encoding.categorical.one_hot import OneHotFromOrdinalTransformer from ..encoding.categorical.ordinal import ContinuousOrdinalTransformer from ..encoding.numerical.binning import NumericBinningTransformer @@ -50,9 +51,6 @@ from ..expansion.spline.p_spline import PSplineTransformer from ..kernel_approximation.nystroem import NystroemFeaturesTransformer from ..kernel_approximation.random_fourier import RandomFourierFeaturesTransformer -from ..transformers.categorical.language_embedding import ( - LanguageEmbeddingTransformer, -) from ..transformers.encoders.floats import NoTransformer __all__ = [ diff --git a/pretab/embedding/__init__.py b/pretab/embedding/__init__.py new file mode 100644 index 0000000..9eed69f --- /dev/null +++ b/pretab/embedding/__init__.py @@ -0,0 +1,14 @@ +"""Embedding representations. + +Maps a categorical or text column to a dense vector produced by a pretrained +model, as opposed to :mod:`pretab.encoding`, which recodes categories into small +discrete representations (codes or indicators). + +Every class here is also re-exported from :mod:`pretab.transformers`. +""" + +from .language import LanguageEmbeddingTransformer + +__all__ = [ + "LanguageEmbeddingTransformer", +] diff --git a/pretab/transformers/categorical/language_embedding.py b/pretab/embedding/language.py similarity index 98% rename from pretab/transformers/categorical/language_embedding.py rename to pretab/embedding/language.py index f164816..847ce28 100644 --- a/pretab/transformers/categorical/language_embedding.py +++ b/pretab/embedding/language.py @@ -2,7 +2,7 @@ from sklearn.base import BaseEstimator, TransformerMixin from sklearn.utils.validation import check_is_fitted -from ...exceptions import OptionalDependencyError, PretabConfigError, PretabDataError +from ..exceptions import OptionalDependencyError, PretabConfigError, PretabDataError class LanguageEmbeddingTransformer(TransformerMixin, BaseEstimator): diff --git a/pretab/transformers/__init__.py b/pretab/transformers/__init__.py index 9b3860b..8d5fe8e 100644 --- a/pretab/transformers/__init__.py +++ b/pretab/transformers/__init__.py @@ -1,3 +1,4 @@ +from ..embedding import LanguageEmbeddingTransformer from ..encoding.categorical import ( ContinuousOrdinalTransformer, OneHotFromOrdinalTransformer, @@ -28,7 +29,6 @@ NystroemFeaturesTransformer, RandomFourierFeaturesTransformer, ) -from .categorical import LanguageEmbeddingTransformer from .encoders import MissingStateIndicator, NoTransformer, ToFloatTransformer __all__ = [ diff --git a/pretab/transformers/categorical/__init__.py b/pretab/transformers/categorical/__init__.py deleted file mode 100644 index cef7030..0000000 --- a/pretab/transformers/categorical/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Categorical transformers: language embeddings. Ordinal encoding and the -time-boxed legacy one-hot-from-ordinal encoder live in -:mod:`pretab.encoding.categorical`. -""" - -from .language_embedding import LanguageEmbeddingTransformer - -__all__ = [ - "LanguageEmbeddingTransformer", -] From 4188a647349c54d5c4eccb1ae24385f5f3d419d4 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 28 Aug 2026 09:03:21 +0200 Subject: [PATCH 051/123] refactor(preprocessing): move floats and missing encoders to pretab.preprocessing --- pretab/compose/factory.py | 4 ++-- pretab/compose/registry.py | 2 +- pretab/preprocessing/__init__.py | 19 +++++++++++++++++++ .../encoders => preprocessing}/floats.py | 0 .../encoders => preprocessing}/missing.py | 0 pretab/transformers/__init__.py | 2 +- pretab/transformers/encoders/__init__.py | 14 -------------- 7 files changed, 23 insertions(+), 18 deletions(-) create mode 100644 pretab/preprocessing/__init__.py rename pretab/{transformers/encoders => preprocessing}/floats.py (100%) rename pretab/{transformers/encoders => preprocessing}/missing.py (100%) delete mode 100644 pretab/transformers/encoders/__init__.py diff --git a/pretab/compose/factory.py b/pretab/compose/factory.py index 347605d..f272a6d 100644 --- a/pretab/compose/factory.py +++ b/pretab/compose/factory.py @@ -16,8 +16,8 @@ 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 ..preprocessing.floats import ToFloatTransformer +from ..preprocessing.missing import MissingStateIndicator from .config import PreprocessorConfig from .registry import ( CATEGORICAL_ALIASES, diff --git a/pretab/compose/registry.py b/pretab/compose/registry.py index 83ca9a0..22d168b 100644 --- a/pretab/compose/registry.py +++ b/pretab/compose/registry.py @@ -51,7 +51,7 @@ from ..expansion.spline.p_spline import PSplineTransformer from ..kernel_approximation.nystroem import NystroemFeaturesTransformer from ..kernel_approximation.random_fourier import RandomFourierFeaturesTransformer -from ..transformers.encoders.floats import NoTransformer +from ..preprocessing.floats import NoTransformer __all__ = [ "CATEGORICAL_ALIASES", diff --git a/pretab/preprocessing/__init__.py b/pretab/preprocessing/__init__.py new file mode 100644 index 0000000..1473271 --- /dev/null +++ b/pretab/preprocessing/__init__.py @@ -0,0 +1,19 @@ +"""Supporting data-preparation utilities. + +These transformers don't expand or recode a feature; they prepare it for the rest +of the pipeline, converting types or flagging missingness before it reaches the +transformer that actually does the work. Distinct from +:mod:`pretab.preprocessor`, which holds the top-level :class:`~pretab.preprocessor.Preprocessor` +facade. + +Every class here is also re-exported from :mod:`pretab.transformers`. +""" + +from .floats import NoTransformer, ToFloatTransformer +from .missing import MissingStateIndicator + +__all__ = [ + "MissingStateIndicator", + "NoTransformer", + "ToFloatTransformer", +] diff --git a/pretab/transformers/encoders/floats.py b/pretab/preprocessing/floats.py similarity index 100% rename from pretab/transformers/encoders/floats.py rename to pretab/preprocessing/floats.py diff --git a/pretab/transformers/encoders/missing.py b/pretab/preprocessing/missing.py similarity index 100% rename from pretab/transformers/encoders/missing.py rename to pretab/preprocessing/missing.py diff --git a/pretab/transformers/__init__.py b/pretab/transformers/__init__.py index 8d5fe8e..6a1d593 100644 --- a/pretab/transformers/__init__.py +++ b/pretab/transformers/__init__.py @@ -29,7 +29,7 @@ NystroemFeaturesTransformer, RandomFourierFeaturesTransformer, ) -from .encoders import MissingStateIndicator, NoTransformer, ToFloatTransformer +from ..preprocessing import MissingStateIndicator, NoTransformer, ToFloatTransformer __all__ = [ "BSplineTransformer", diff --git a/pretab/transformers/encoders/__init__.py b/pretab/transformers/encoders/__init__.py deleted file mode 100644 index 1d729b2..0000000 --- a/pretab/transformers/encoders/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Numeric helper transformers for tabular preprocessing. - -These transformers turn raw column values into numeric arrays that downstream -models can consume: a float cast and a pass-through. -""" - -from .floats import NoTransformer, ToFloatTransformer -from .missing import MissingStateIndicator - -__all__ = [ - "MissingStateIndicator", - "NoTransformer", - "ToFloatTransformer", -] From f1b6a34ed930bb3646125c03e8d13c83f0b365db Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 28 Aug 2026 09:17:59 +0200 Subject: [PATCH 052/123] docs: restructure representations by expansion/encoding/embedding taxonomy --- docs/api/representations.rst | 63 +++++++-- docs/index.rst | 11 +- ...categorical.md => categorical_encoding.md} | 43 ++---- docs/representations/choosing_a_method.md | 6 +- docs/representations/comparison_table.md | 42 ++++-- docs/representations/embeddings.md | 36 +++++ docs/representations/feature_maps.md | 128 ------------------ docs/representations/functional_expansions.md | 82 +++++++++++ docs/representations/kernel_approximation.md | 65 +++++++++ ...nning_and_ple.md => numerical_encoding.md} | 38 +++++- docs/representations/overview.md | 52 +++++-- .../preprocessing_utilities.md | 62 +++++++++ docs/representations/references.md | 4 +- .../{splines.md => spline_expansions.md} | 4 +- docs/tutorials/multivariate_features.md | 6 +- 15 files changed, 427 insertions(+), 215 deletions(-) rename docs/representations/{categorical.md => categorical_encoding.md} (51%) create mode 100644 docs/representations/embeddings.md delete mode 100644 docs/representations/feature_maps.md create mode 100644 docs/representations/functional_expansions.md create mode 100644 docs/representations/kernel_approximation.md rename docs/representations/{binning_and_ple.md => numerical_encoding.md} (65%) create mode 100644 docs/representations/preprocessing_utilities.md rename docs/representations/{splines.md => spline_expansions.md} (98%) diff --git a/docs/api/representations.rst b/docs/api/representations.rst index f6e1427..3ed9fc9 100644 --- a/docs/api/representations.rst +++ b/docs/api/representations.rst @@ -7,8 +7,8 @@ view, see the :doc:`comparison table <../representations/comparison_table>`. .. currentmodule:: pretab.transformers -Splines -------- +Spline expansions +------------------ .. autosummary:: :toctree: _autosummary @@ -20,11 +20,23 @@ Splines CubicRegressionSplineTransformer NaturalCubicSplineTransformer PSplineTransformer + +Canonical import: ``pretab.expansion.spline``. + +Multivariate splines +--------------------- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + TensorProductSplineTransformer ThinPlateSplineTransformer -Feature maps ------------- +Canonical import: ``pretab.expansion.spline.multivariate``. + +Functional expansions +---------------------- .. autosummary:: :toctree: _autosummary @@ -35,12 +47,23 @@ Feature maps SigmoidExpansionTransformer TanhExpansionTransformer FourierFeatureTransformer - PeriodicEncodingTransformer + +Canonical import: ``pretab.expansion.functional``. + +Kernel approximation +---------------------- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + RandomFourierFeaturesTransformer NystroemFeaturesTransformer -Binning and PLE ---------------- +Canonical import: ``pretab.kernel_approximation``. + +Numerical encoding +-------------------- .. autosummary:: :toctree: _autosummary @@ -48,9 +71,12 @@ Binning and PLE NumericBinningTransformer PLETransformer + PeriodicEncodingTransformer + +Canonical import: ``pretab.encoding.numerical``. -Categorical ------------ +Categorical encoding +----------------------- .. autosummary:: :toctree: _autosummary @@ -58,10 +84,22 @@ Categorical ContinuousOrdinalTransformer OneHotFromOrdinalTransformer + +Canonical import: ``pretab.encoding.categorical``. + +Embeddings +------------ + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + LanguageEmbeddingTransformer -Utility transformers --------------------- +Canonical import: ``pretab.embedding``. + +Preprocessing utilities +-------------------------- .. autosummary:: :toctree: _autosummary @@ -70,3 +108,6 @@ Utility transformers MissingStateIndicator NoTransformer ToFloatTransformer + +Canonical import: ``pretab.preprocessing``. + diff --git a/docs/index.rst b/docs/index.rst index def872c..994bdfb 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -33,10 +33,13 @@ representations/overview representations/comparison_table representations/choosing_a_method - representations/splines - representations/feature_maps - representations/binning_and_ple - representations/categorical + representations/spline_expansions + representations/functional_expansions + representations/kernel_approximation + representations/numerical_encoding + representations/categorical_encoding + representations/embeddings + representations/preprocessing_utilities representations/references .. toctree:: diff --git a/docs/representations/categorical.md b/docs/representations/categorical_encoding.md similarity index 51% rename from docs/representations/categorical.md rename to docs/representations/categorical_encoding.md index c1eaa88..ed6d26d 100644 --- a/docs/representations/categorical.md +++ b/docs/representations/categorical_encoding.md @@ -1,9 +1,9 @@ -# Categorical +# Categorical encoding -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. +Categorical encoding maps a category to codes or indicators a model can consume directly. +PreTab covers compact integer encoding and explicit one-hot encoding, both of which handle +unseen categories without raising. For high-cardinality text where the labels themselves carry +meaning, see [Embeddings](embeddings.md) instead. ## Integer (ordinal) encoding @@ -41,34 +41,7 @@ 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. +prefer integer encoding or [embeddings](embeddings.md) for high-cardinality columns. ``` ## Choosing a categorical method @@ -77,10 +50,10 @@ with no semantic content, integer encoding is simpler and just as effective. | --- | --- | | Low cardinality, unordered | One-hot | | Fed to a tree or embedding layer | Integer | -| High-cardinality meaningful text | Language embedding | +| High-cardinality meaningful text | [Language embedding](embeddings.md) | ## Where to go next +- [Embeddings](embeddings.md) for high-cardinality text categories. - [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 index 7f8834d..9b8a55f 100644 --- a/docs/representations/choosing_a_method.md +++ b/docs/representations/choosing_a_method.md @@ -111,6 +111,8 @@ To set expectations, PreTab deliberately does not do the following. ## 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. +- [Spline expansions](spline_expansions.md), [Functional expansions](functional_expansions.md), + [Kernel approximation](kernel_approximation.md), [Numerical encoding](numerical_encoding.md), + [Categorical encoding](categorical_encoding.md), and [Embeddings](embeddings.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 index 4e93bbf..73e8591 100644 --- a/docs/representations/comparison_table.md +++ b/docs/representations/comparison_table.md @@ -37,7 +37,7 @@ source of truth, and these tables mirror it. | Yeo-Johnson | `yeo-johnson` | univariate | forbidden | yes | | Passthrough | `none` | univariate | forbidden | yes | -## Numerical: splines +## Spline expansions | Method | Key | Scope | Target | Adaptive | Penalty | Selectable | | --- | --- | --- | --- | --- | --- | --- | @@ -56,7 +56,7 @@ standalone, not selected per column through `Preprocessor`. The alias `thinplate `tprs`. ``` -## Numerical: feature maps +## Functional expansions | Method | Key | Scope | Target | Adaptive | Selectable | | --- | --- | --- | --- | --- | --- | @@ -65,15 +65,26 @@ standalone, not selected per column through `Preprocessor`. The alias `thinplate | Sigmoid expansion | `sigmoid` | univariate | optional | yes | yes | | Tanh expansion | `tanh` | univariate | optional | yes | yes | | Fourier features | `fourier` | univariate | forbidden | no | yes | + +## Kernel approximation + +| Method | Key | Scope | Target | Adaptive | Selectable | +| --- | --- | --- | --- | --- | --- | | Random Fourier features | `rff` | multivariate | forbidden | no | no | | Nyström kernel map | `nystroem` | multivariate | forbidden | no | no | -## Numerical: discretization +```{note} +Random Fourier features and Nyström model the whole input matrix jointly and are used +standalone, not selected per column through `Preprocessor`. +``` + +## Numerical encoding | Method | Key | Scope | Target | Adaptive | Selectable | | --- | --- | --- | --- | --- | --- | | Numeric binning | `custombin` | univariate | forbidden | no | yes | | Piecewise-linear encoding (PLE) | `ple` | univariate | required | yes | yes | +| Periodic encoding | n/a | univariate | forbidden | no | no | ```{important} PLE is the only numerical method that **requires** the target. It always places its bins @@ -81,22 +92,37 @@ against `y`, so it must be fit with a target and is best used with cross-fitting [Target awareness](../core_concepts/target_awareness.md). ``` -## Categorical +```{note} +Periodic encoding has no registry key: it takes a required per-feature `period`, so it is not +selectable through `Preprocessor`. Instantiate `PeriodicEncodingTransformer` directly. +``` + +## Categorical encoding | 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`. +The alias `ohe` resolves to `one-hot`. +``` + +## Embeddings + +| Method | Key | Scope | Target | Selectable | +| --- | --- | --- | --- | --- | +| Pretrained language embedding | `pretrained` | univariate | forbidden | yes | + +```{note} +`pretrained` requires the optional `embeddings` extra. ``` ## 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. +- [Spline expansions](spline_expansions.md), [Functional expansions](functional_expansions.md), + [Kernel approximation](kernel_approximation.md), [Numerical encoding](numerical_encoding.md), + [Categorical encoding](categorical_encoding.md), [Embeddings](embeddings.md) for the details. diff --git a/docs/representations/embeddings.md b/docs/representations/embeddings.md new file mode 100644 index 0000000..538a05a --- /dev/null +++ b/docs/representations/embeddings.md @@ -0,0 +1,36 @@ +# Embeddings + +Embeddings map a categorical or text column to a dense vector produced by a pretrained model, +rather than recoding it into a small discrete representation the way +[categorical encoding](categorical_encoding.md) does. 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](categorical_encoding.md#integer-ordinal-encoding) +is simpler and just as effective. +``` + +## Where to go next + +- [Categorical encoding](categorical_encoding.md) for compact integer and one-hot alternatives. +- [Installation](../getting_started/installation.md) for the `embeddings` extra. +- [Missing values](../core_concepts/missing_values.md) for categorical imputation. diff --git a/docs/representations/feature_maps.md b/docs/representations/feature_maps.md deleted file mode 100644 index 8179ca1..0000000 --- a/docs/representations/feature_maps.md +++ /dev/null @@ -1,128 +0,0 @@ -# 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/functional_expansions.md b/docs/representations/functional_expansions.md new file mode 100644 index 0000000..33c495d --- /dev/null +++ b/docs/representations/functional_expansions.md @@ -0,0 +1,82 @@ +# Functional expansions + +Functional expansions 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 deterministic Fourier basis. Together they cover local, +threshold, and periodic structure with a per-column, `Preprocessor`-selectable transformer. + +## 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`. + +```{tip} +Use `FourierFeatureTransformer` when you want the model to work across a set of frequencies +without committing to a single known period. If the period is known (hour of day, month of +year), the direct [periodic encoder](numerical_encoding.md#periodic-encoding) is usually simpler. +``` + +## Where to go next + +- [Spline expansions](spline_expansions.md) for smooth statistical bases. +- [Kernel approximation](kernel_approximation.md) for the multivariate RFF and Nyström maps. +- [Numerical encoding](numerical_encoding.md) for binning, PLE, and periodic encoding. +- [References](references.md) for the underlying literature. diff --git a/docs/representations/kernel_approximation.md b/docs/representations/kernel_approximation.md new file mode 100644 index 0000000..92f2946 --- /dev/null +++ b/docs/representations/kernel_approximation.md @@ -0,0 +1,65 @@ +# Kernel approximation + +Kernel approximation builds an explicit, low-dimensional feature map whose inner products +approximate an implicit kernel, so a linear model downstream can behave like a kernel machine +without ever forming the full kernel matrix. PreTab wraps the two standard approaches. Both are +**multivariate, standalone transformers**: they operate on the whole input matrix and are not +selectable per column through `Preprocessor`. + +## 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, since the +cost of the approximation does not grow with the number of training points the way an exact +kernel method's does. + +```python +from pretab.transformers import RandomFourierFeaturesTransformer + +t = RandomFourierFeaturesTransformer(n_components=100, gamma=1.0) +X2 = t.fit_transform(X) +``` + +Constructor highlights: `n_components=100`, `gamma=1.0`, `random_state`. + +```{tip} +`n_components` trades approximation quality for cost. More components track the true kernel +more closely at the price of a wider output; start around 100 and increase if validation +performance is still improving. +``` + +## Nyström + +Approximates a kernel by sampling landmark points from the training data and projecting onto +them, following Williams and Seeger. It supports several kernels through `kernel`, and is often +more accurate than random Fourier features at a given output width because the landmarks adapt +to the data rather than being drawn at random. + +```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`. + +```{note} +Both methods approximate the same idea from different angles: random Fourier features draw a +random basis independent of the data, while Nyström samples landmarks from the data itself. +When in doubt, try both and compare with cross-validation. +``` + +```{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 or combine them +with per-column methods through a `ColumnTransformer`. +``` + +## Where to go next + +- [Functional expansions](functional_expansions.md) for the per-column basis functions. +- [Spline expansions](spline_expansions.md) for the multivariate tensor-product and thin-plate + splines, another way to model several inputs jointly. +- [References](references.md) for the kernel-approximation literature. diff --git a/docs/representations/binning_and_ple.md b/docs/representations/numerical_encoding.md similarity index 65% rename from docs/representations/binning_and_ple.md rename to docs/representations/numerical_encoding.md index 2296f45..bb7930e 100644 --- a/docs/representations/binning_and_ple.md +++ b/docs/representations/numerical_encoding.md @@ -1,9 +1,10 @@ -# Binning and PLE +# Numerical encoding -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). +Encoding recodes a numeric value rather than expanding it into a smooth basis. PreTab covers +three flavors: unsupervised discretization (numeric binning), supervised piecewise-linear +encoding (PLE), and periodic encoding for values that wrap around a known cycle. Discretization +captures sharp, threshold-like effects that smooth bases blur, and is the natural choice when a +feature acts in steps. ## Numeric binning @@ -73,6 +74,29 @@ PLE is a strong default for numerical features, and it is the default `numerical to follow the target. ``` +## Periodic encoding + +When a feature wraps around a known cycle, such as hour of day or month of year, the periodic +encoder maps each value onto its position on that cycle using sine and cosine harmonics. This +keeps the boundary continuous, so December and January sit next to each other instead of at +opposite ends of a number line. + +```python +from pretab.transformers import PeriodicEncodingTransformer + +t = PeriodicEncodingTransformer(period=12, harmonics=2) # e.g. month of year +``` + +Constructor highlights: `period` (required, the cycle length), `harmonics=1`, +`include_original=False`. + +```{note} +Periodic encoding is a standalone time-series utility. It is not wired into `Preprocessor` +because it requires a per-feature `period`, so apply it directly to the relevant cyclical +column. Use [Fourier features](functional_expansions.md#fourier-features) instead when you want +the model to search across a set of frequencies rather than commit to one known period. +``` + ## Binning versus PLE | | Numeric binning | PLE | @@ -85,5 +109,7 @@ to follow the target. ## Where to go next - [Target awareness](../core_concepts/target_awareness.md) for fitting PLE safely. -- [Splines](splines.md) for smooth alternatives to binning. +- [Spline expansions](spline_expansions.md) for smooth alternatives to binning. +- [Functional expansions](functional_expansions.md) for Fourier features, the deterministic + alternative to periodic encoding. - [References](references.md) for the PLE source. diff --git a/docs/representations/overview.md b/docs/representations/overview.md index aed5c1e..7cfdc5b 100644 --- a/docs/representations/overview.md +++ b/docs/representations/overview.md @@ -10,31 +10,51 @@ data. ::::{grid} 1 1 2 2 :gutter: 3 -:::{grid-item-card} Splines -:link: splines +:::{grid-item-card} Spline expansions +:link: spline_expansions :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 +:::{grid-item-card} Functional expansions +:link: functional_expansions :link-type: doc -Basis functions from machine learning: radial (RBF), ReLU, sigmoid, tanh, deterministic -Fourier, and the kernel approximations (random Fourier features, Nyström). +Basis functions from machine learning: radial (RBF), ReLU, sigmoid, tanh, and deterministic +Fourier features. ::: -:::{grid-item-card} Binning and PLE -:link: binning_and_ple +:::{grid-item-card} Kernel approximation +:link: kernel_approximation :link-type: doc -Discretization: numeric binning with several encodings, and supervised piecewise-linear -encoding (PLE). +Multivariate kernel machines without the full kernel matrix: random Fourier features and +Nyström. ::: -:::{grid-item-card} Categorical -:link: categorical +:::{grid-item-card} Numerical encoding +:link: numerical_encoding :link-type: doc -Ordinal and one-hot encoding, plus pretrained language embeddings for high-cardinality text. +Discretization and recoding: numeric binning, supervised piecewise-linear encoding (PLE), and +periodic encoding. +::: + +:::{grid-item-card} Categorical encoding +:link: categorical_encoding +:link-type: doc +Ordinal and one-hot encoding for categories, handling unseen values without raising. +::: + +:::{grid-item-card} Embeddings +:link: embeddings +:link-type: doc +Pretrained language embeddings for high-cardinality text categories. +::: + +:::{grid-item-card} Preprocessing utilities +:link: preprocessing_utilities +:link-type: doc +Supporting transformers `Preprocessor` wires in automatically: pass-through, type conversion, +and missing-value flagging. ::: :::: @@ -85,7 +105,9 @@ the primary sources for each, so the representations are traceable to their lite ## 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. +- [Spline expansions](spline_expansions.md), [Functional expansions](functional_expansions.md), + [Kernel approximation](kernel_approximation.md), [Numerical encoding](numerical_encoding.md), + [Categorical encoding](categorical_encoding.md), [Embeddings](embeddings.md), and + [Preprocessing utilities](preprocessing_utilities.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/preprocessing_utilities.md b/docs/representations/preprocessing_utilities.md new file mode 100644 index 0000000..8fed666 --- /dev/null +++ b/docs/representations/preprocessing_utilities.md @@ -0,0 +1,62 @@ +# Preprocessing utilities + +Preprocessing utilities don't expand or recode a feature. They prepare it for the rest of the +pipeline, converting types or flagging missingness before it reaches the transformer that does +the actual representation work. `Preprocessor` wires these in automatically; most users never +instantiate them directly, but they are part of the public API for anyone building a custom +`ColumnTransformer` or pipeline by hand. + +```{note} +This page is distinct from [`pretab.preprocessor`](../api/preprocessor.rst), the module that +holds the top-level `Preprocessor` facade. `pretab.preprocessing` is the package for these +smaller supporting transformers. +``` + +## Pass-through and type conversion + +`NoTransformer` returns its input unchanged. It backs the `"none"` categorical and numerical +methods, letting a column skip representation entirely while still satisfying the +scikit-learn transformer API. + +```python +from pretab.transformers import NoTransformer + +t = NoTransformer() +X2 = t.fit_transform(X) # X2 is X, unmodified +``` + +`ToFloatTransformer` casts its input to floating point. `Preprocessor` appends it after +one-hot encoding so the categorical block has the same dtype as the rest of the design matrix. + +```python +from pretab.transformers import ToFloatTransformer + +t = ToFloatTransformer() +t.fit_transform(X).dtype # dtype('float64') +``` + +## Missing-value flagging + +`MissingStateIndicator` emits a binary column marking where the input was missing, computed on +the raw data before imputation. `Preprocessor` uses it when `missing_policy="separate_state"`: +the indicator is kept apart from the imputed representation basis, so a downstream model can +learn a dedicated response to missingness instead of confusing it with an imputed value. + +```python +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.]]) +``` + +```{tip} +Unlike `sklearn.impute.MissingIndicator`, `MissingStateIndicator` works on both numeric and +object (categorical) columns and always emits one column per input feature. +``` + +## Where to go next + +- [Missing values](../core_concepts/missing_values.md) for the full `missing_policy` behavior. +- [Configuration](../core_concepts/configuration.md) for how `Preprocessor` builds its pipelines. diff --git a/docs/representations/references.md b/docs/representations/references.md index 7f92170..ef6cce3 100644 --- a/docs/representations/references.md +++ b/docs/representations/references.md @@ -53,5 +53,5 @@ 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. +- [Spline expansions](spline_expansions.md), [Kernel approximation](kernel_approximation.md), + [Numerical encoding](numerical_encoding.md) for the methods these sources describe. diff --git a/docs/representations/splines.md b/docs/representations/spline_expansions.md similarity index 98% rename from docs/representations/splines.md rename to docs/representations/spline_expansions.md index 45caef2..7b20cbb 100644 --- a/docs/representations/splines.md +++ b/docs/representations/spline_expansions.md @@ -1,4 +1,4 @@ -# Splines +# Spline expansions 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 @@ -150,7 +150,7 @@ want to model jointly. ## Where to go next -- [Feature maps](feature_maps.md) for non-spline bases. +- [Functional expansions](functional_expansions.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. diff --git a/docs/tutorials/multivariate_features.md b/docs/tutorials/multivariate_features.md index 036ef2f..74948b0 100644 --- a/docs/tutorials/multivariate_features.md +++ b/docs/tutorials/multivariate_features.md @@ -112,6 +112,8 @@ The thin-plate spline handles the geographic interaction while PLE handles the s ## 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. +- [Spline expansions](../representations/spline_expansions.md) for the tensor-product and + thin-plate details. +- [Kernel approximation](../representations/kernel_approximation.md) for random Fourier + features and Nyström. - [References](../representations/references.md) for the underlying theory. From d18000d787cf879cff7bcc2834ebecdfdedd86e7 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 28 Aug 2026 09:45:26 +0200 Subject: [PATCH 053/123] test: reorganize transformer tests to mirror the package taxonomy --- CHANGELOG.md | 14 ++++++++++++++ .../test_language_embedding_transformer.py | 0 .../test_onehot_from_ordinal_transformer.py | 0 .../numerical}/test_custombin_transformer.py | 0 .../numerical}/test_periodic.py | 0 .../numerical}/test_ple_transformer.py | 0 .../functional}/test_fourier_transformer.py | 0 .../functional}/test_rbfexpansion_transformer.py | 0 .../functional}/test_reluexpansion_transformer.py | 0 .../test_sigmoidexpansion_transformer.py | 0 .../functional}/test_tanh_transformer.py | 0 .../spline}/test_cubic_transformer.py | 0 .../spline}/test_naturalcubic_transformer.py | 0 .../spline}/test_pspline_transformer.py | 0 .../spline}/test_spline_api_parity.py | 0 .../spline}/test_spline_expansions.py | 0 .../spline}/test_tensorproduct_transformer.py | 0 .../spline}/test_thinplate_transformer.py | 0 .../test_kernel_approx_transformer.py | 0 19 files changed, 14 insertions(+) rename tests/{transformers => embedding}/test_language_embedding_transformer.py (100%) rename tests/{transformers => encoding/categorical}/test_onehot_from_ordinal_transformer.py (100%) rename tests/{transformers => encoding/numerical}/test_custombin_transformer.py (100%) rename tests/{transformers => encoding/numerical}/test_periodic.py (100%) rename tests/{transformers => encoding/numerical}/test_ple_transformer.py (100%) rename tests/{transformers => expansion/functional}/test_fourier_transformer.py (100%) rename tests/{transformers => expansion/functional}/test_rbfexpansion_transformer.py (100%) rename tests/{transformers => expansion/functional}/test_reluexpansion_transformer.py (100%) rename tests/{transformers => expansion/functional}/test_sigmoidexpansion_transformer.py (100%) rename tests/{transformers => expansion/functional}/test_tanh_transformer.py (100%) rename tests/{transformers => expansion/spline}/test_cubic_transformer.py (100%) rename tests/{transformers => expansion/spline}/test_naturalcubic_transformer.py (100%) rename tests/{transformers => expansion/spline}/test_pspline_transformer.py (100%) rename tests/{transformers => expansion/spline}/test_spline_api_parity.py (100%) rename tests/{transformers => expansion/spline}/test_spline_expansions.py (100%) rename tests/{transformers => expansion/spline}/test_tensorproduct_transformer.py (100%) rename tests/{transformers => expansion/spline}/test_thinplate_transformer.py (100%) rename tests/{transformers => kernel_approximation}/test_kernel_approx_transformer.py (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec9fb03..c9f0c2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ 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 + +### Refactor + +- Reorganized the `pretab` package by representation taxonomy: spline and functional + expansions now live under `pretab.expansion`, numeric and categorical encoders under + `pretab.encoding`, kernel approximations under `pretab.kernel_approximation`, language + embeddings under `pretab.embedding`, and supporting utilities under `pretab.preprocessing`. + `pretab.transformers` remains the stable, flat public import for every transformer class; + no class was renamed and no public behavior changed. +- Restructured the representations documentation and API reference to match the new + taxonomy, adding dedicated pages for kernel approximation, embeddings, and preprocessing + utilities. + ## v1.0.0rc2 (2026-08-21) ### Fix diff --git a/tests/transformers/test_language_embedding_transformer.py b/tests/embedding/test_language_embedding_transformer.py similarity index 100% rename from tests/transformers/test_language_embedding_transformer.py rename to tests/embedding/test_language_embedding_transformer.py diff --git a/tests/transformers/test_onehot_from_ordinal_transformer.py b/tests/encoding/categorical/test_onehot_from_ordinal_transformer.py similarity index 100% rename from tests/transformers/test_onehot_from_ordinal_transformer.py rename to tests/encoding/categorical/test_onehot_from_ordinal_transformer.py diff --git a/tests/transformers/test_custombin_transformer.py b/tests/encoding/numerical/test_custombin_transformer.py similarity index 100% rename from tests/transformers/test_custombin_transformer.py rename to tests/encoding/numerical/test_custombin_transformer.py diff --git a/tests/transformers/test_periodic.py b/tests/encoding/numerical/test_periodic.py similarity index 100% rename from tests/transformers/test_periodic.py rename to tests/encoding/numerical/test_periodic.py diff --git a/tests/transformers/test_ple_transformer.py b/tests/encoding/numerical/test_ple_transformer.py similarity index 100% rename from tests/transformers/test_ple_transformer.py rename to tests/encoding/numerical/test_ple_transformer.py diff --git a/tests/transformers/test_fourier_transformer.py b/tests/expansion/functional/test_fourier_transformer.py similarity index 100% rename from tests/transformers/test_fourier_transformer.py rename to tests/expansion/functional/test_fourier_transformer.py diff --git a/tests/transformers/test_rbfexpansion_transformer.py b/tests/expansion/functional/test_rbfexpansion_transformer.py similarity index 100% rename from tests/transformers/test_rbfexpansion_transformer.py rename to tests/expansion/functional/test_rbfexpansion_transformer.py diff --git a/tests/transformers/test_reluexpansion_transformer.py b/tests/expansion/functional/test_reluexpansion_transformer.py similarity index 100% rename from tests/transformers/test_reluexpansion_transformer.py rename to tests/expansion/functional/test_reluexpansion_transformer.py diff --git a/tests/transformers/test_sigmoidexpansion_transformer.py b/tests/expansion/functional/test_sigmoidexpansion_transformer.py similarity index 100% rename from tests/transformers/test_sigmoidexpansion_transformer.py rename to tests/expansion/functional/test_sigmoidexpansion_transformer.py diff --git a/tests/transformers/test_tanh_transformer.py b/tests/expansion/functional/test_tanh_transformer.py similarity index 100% rename from tests/transformers/test_tanh_transformer.py rename to tests/expansion/functional/test_tanh_transformer.py diff --git a/tests/transformers/test_cubic_transformer.py b/tests/expansion/spline/test_cubic_transformer.py similarity index 100% rename from tests/transformers/test_cubic_transformer.py rename to tests/expansion/spline/test_cubic_transformer.py diff --git a/tests/transformers/test_naturalcubic_transformer.py b/tests/expansion/spline/test_naturalcubic_transformer.py similarity index 100% rename from tests/transformers/test_naturalcubic_transformer.py rename to tests/expansion/spline/test_naturalcubic_transformer.py diff --git a/tests/transformers/test_pspline_transformer.py b/tests/expansion/spline/test_pspline_transformer.py similarity index 100% rename from tests/transformers/test_pspline_transformer.py rename to tests/expansion/spline/test_pspline_transformer.py diff --git a/tests/transformers/test_spline_api_parity.py b/tests/expansion/spline/test_spline_api_parity.py similarity index 100% rename from tests/transformers/test_spline_api_parity.py rename to tests/expansion/spline/test_spline_api_parity.py diff --git a/tests/transformers/test_spline_expansions.py b/tests/expansion/spline/test_spline_expansions.py similarity index 100% rename from tests/transformers/test_spline_expansions.py rename to tests/expansion/spline/test_spline_expansions.py diff --git a/tests/transformers/test_tensorproduct_transformer.py b/tests/expansion/spline/test_tensorproduct_transformer.py similarity index 100% rename from tests/transformers/test_tensorproduct_transformer.py rename to tests/expansion/spline/test_tensorproduct_transformer.py diff --git a/tests/transformers/test_thinplate_transformer.py b/tests/expansion/spline/test_thinplate_transformer.py similarity index 100% rename from tests/transformers/test_thinplate_transformer.py rename to tests/expansion/spline/test_thinplate_transformer.py diff --git a/tests/transformers/test_kernel_approx_transformer.py b/tests/kernel_approximation/test_kernel_approx_transformer.py similarity index 100% rename from tests/transformers/test_kernel_approx_transformer.py rename to tests/kernel_approximation/test_kernel_approx_transformer.py From 9a1b8c1cd806050a1da766fd0d5155498488d198 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 28 Aug 2026 11:12:56 +0200 Subject: [PATCH 054/123] docs: ground representation examples in verified shapes and add parameter/warning notes --- README.md | 27 ++++-- docs/homepage.md | 2 +- docs/representations/categorical_encoding.md | 25 +++-- docs/representations/comparison_table.md | 6 +- docs/representations/embeddings.md | 17 +++- docs/representations/functional_expansions.md | 34 ++++++- docs/representations/kernel_approximation.md | 19 +++- docs/representations/numerical_encoding.md | 57 ++++++++++-- docs/representations/overview.md | 7 -- .../preprocessing_utilities.md | 17 +++- docs/representations/spline_expansions.md | 93 +++++++++++++++++-- 11 files changed, 255 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 1af32fc..96facae 100644 --- a/README.md +++ b/README.md @@ -88,10 +88,12 @@ print({k: v.shape for k, v in X.items()}) ## Available Transformers -PreTab groups its transformers into three families. Each one follows the standard `fit` / -`transform` API and is importable from `pretab.transformers`. +PreTab groups its transformers by representation taxonomy. Each one follows the standard +`fit` / `transform` API and is importable from `pretab.transformers` (the stable, flat public +import); advanced users can also reach them through the namespace shown per table +(`pretab.expansion.spline`, `pretab.expansion.functional`, and so on). -### Splines +### Spline expansions | Transformer | Basis | Best for | | ----------------------------------- | -------------------------------------- | ---------------------------------------- | @@ -104,7 +106,7 @@ PreTab groups its transformers into three families. Each one follows the standar | `TensorProductSplineTransformer` | Tensor-product spline (multivariate) | Smooth interactions across 2+ features | | `ThinPlateSplineTransformer` | Thin-plate spline (multivariate) | Smooth surfaces across 2+ features | -### Feature maps +### Functional expansions | Transformer | Basis | Best for | | ------------------------------------ | ------------------------------------------ | ---------------------------------------- | @@ -113,15 +115,26 @@ PreTab groups its transformers into three families. Each one follows the standar | `SigmoidExpansionTransformer` | Sigmoid basis | Smooth saturating features | | `TanhExpansionTransformer` | Tanh basis | Zero-centered saturating features | | `FourierFeatureTransformer` | Sine/cosine basis | Periodic or cyclic numerical effects | + +### Kernel approximation + +| Transformer | Basis | Best for | +| ------------------------------------ | ------------------------------------------ | ---------------------------------------- | | `RandomFourierFeaturesTransformer` | Random Fourier features (multivariate) | Scalable RBF-kernel approximation | | `NystroemFeaturesTransformer` | Nystroem kernel map (multivariate) | Landmark-based kernel approximation | -### Encoding and binning +### Numerical encoding | Transformer | Method | Best for | | ------------------------------- | ------------------------------------------ | ---------------------------------------- | | `PLETransformer` | Piecewise-linear encoding (supervised) | Strong numerical encoding for models | | `NumericBinningTransformer` | Uniform/quantile binning, tree-driven | Discretizing numerical columns | +| `PeriodicEncodingTransformer` | Sine/cosine cyclic encoding | Values that wrap around a known period | + +### Categorical encoding and embeddings + +| Transformer | Method | Best for | +| ------------------------------- | ------------------------------------------ | ---------------------------------------- | | `ContinuousOrdinalTransformer` | Integer (ordinal) encoding | Compact codes for categoricals | | `LanguageEmbeddingTransformer` | Pretrained language embeddings | High-cardinality, semantic columns | @@ -131,7 +144,9 @@ PreTab groups its transformers into three families. Each one follows the standar > **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). +> the full catalogue, including exact input/output shapes and per-parameter effects, and the +> [comparison table](https://pretab.readthedocs.io/en/latest/representations/comparison_table.html) +> to filter by capability. ## 📚 Documentation diff --git a/docs/homepage.md b/docs/homepage.md index de01e60..b175009 100644 --- a/docs/homepage.md +++ b/docs/homepage.md @@ -120,7 +120,7 @@ See PreTab lift a linear model, baseline vs. PreTab. :::{grid-item-card} Representations :link: representations/overview :link-type: doc -The full catalogue of splines, feature maps, and encoders. +The full catalogue of spline and functional expansions, kernel approximations, and encoders. ::: :::{grid-item-card} API reference diff --git a/docs/representations/categorical_encoding.md b/docs/representations/categorical_encoding.md index ed6d26d..7ccb8bd 100644 --- a/docs/representations/categorical_encoding.md +++ b/docs/representations/categorical_encoding.md @@ -11,14 +11,19 @@ The default categorical method maps each category to an integer. It is compact a as an input to models that consume category indices, such as embedding layers. ```python +import numpy as np from pretab.transformers import ContinuousOrdinalTransformer +X = np.array([["a"], ["b"], ["a"], ["c"]]) # (4, 1) t = ContinuousOrdinalTransformer() -X2 = t.fit_transform(x) +t.fit_transform(X).ravel() +# array([1, 2, 1, 3]) codes start at 1; output shape stays (4, 1) +t.transform(np.array([["unseen"]])).ravel() +# array([0]) unseen categories map to the reserved 0 code ``` -Unseen categories at transform time map to a reserved slot rather than raising, so a model in -production never crashes on a new label. +Unseen categories at transform time map to a reserved slot (code `0`) 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 @@ -32,16 +37,24 @@ One-hot encoding produces one indicator column per category, the right choice wh downstream model should treat categories as unordered. ```python +import pandas as pd +from pretab import Preprocessor + +df = pd.DataFrame({"color": ["red", "blue", "green", "red"]}) pre = Preprocessor(categorical_method="one-hot") +pre.fit(df) +pre.get_feature_names_out() +# array(['cat_color_blue', 'cat_color_green', 'cat_color_red'], dtype=object) ``` 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](embeddings.md) for high-cardinality columns. +One-hot width grows with cardinality: a column with `k` distinct categories produces `k` +output columns (3 in the example above). 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](embeddings.md) for high-cardinality columns. ``` ## Choosing a categorical method diff --git a/docs/representations/comparison_table.md b/docs/representations/comparison_table.md index 73e8591..8b4f485 100644 --- a/docs/representations/comparison_table.md +++ b/docs/representations/comparison_table.md @@ -41,9 +41,9 @@ source of truth, and these tables mirror it. | 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 | +| B-spline | `bspline` | univariate | optional | yes | yes | yes | +| M-spline | `mspline` | univariate | optional | yes | yes | yes | +| I-spline | `ispline` | univariate | optional | yes | yes | 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 | diff --git a/docs/representations/embeddings.md b/docs/representations/embeddings.md index 538a05a..bd56f69 100644 --- a/docs/representations/embeddings.md +++ b/docs/representations/embeddings.md @@ -11,11 +11,15 @@ other in the embedding space. from pretab.transformers import LanguageEmbeddingTransformer t = LanguageEmbeddingTransformer(model_name="paraphrase-MiniLM-L3-v2") -X2 = t.fit_transform(x) +X = [["red running shoes"], ["blue jacket"], ["red running shoes"]] # 3 rows, 1 column +t.fit_transform(X).shape +# (3, embedding_dim_): one row per input; embedding_dim_ is set from the loaded +# model's own dimensionality once fitted, e.g. via t.embedding_dim_ ``` -Constructor highlights: `model_name="paraphrase-MiniLM-L3-v2"`, or pass a preloaded `model`. -The registry key is `pretrained`. +Constructor highlights: `model_name="paraphrase-MiniLM-L3-v2"`, or pass a preloaded `model` +(any object exposing an `encode(X)` method, useful for tests or a custom embedding backend +without pulling in `sentence-transformers`). The registry key is `pretrained`. ```{important} Language embeddings require the optional `embeddings` extra, which pulls in @@ -23,6 +27,13 @@ Language embeddings require the optional `embeddings` extra, which pulls in requesting `pretrained` raises a clear `OptionalDependencyError`. ``` +```{note} +The output width is fixed by the underlying model, not by any PreTab parameter, and is exposed +after fitting as `embedding_dim_`. It does not depend on `n_samples` or on how many distinct +categories are present. Swapping `model_name` for a different model changes the output width +accordingly. +``` + ```{tip} Embeddings shine when category labels carry meaning as text. If the labels are opaque codes with no semantic content, [integer encoding](categorical_encoding.md#integer-ordinal-encoding) diff --git a/docs/representations/functional_expansions.md b/docs/representations/functional_expansions.md index 33c495d..6757768 100644 --- a/docs/representations/functional_expansions.md +++ b/docs/representations/functional_expansions.md @@ -5,6 +5,12 @@ statistics. They spread a feature across a set of activation functions (radial b ramps, sigmoids) or project it onto a deterministic Fourier basis. Together they cover local, threshold, and periodic structure with a per-column, `Preprocessor`-selectable transformer. +```{important} +As with splines, `output_dim` (or `n_frequencies` for the Fourier map) is the number of output +columns **per input feature**. A `(n_samples, 3)` input with `output_dim=10` produces +`(n_samples, 30)` output. +``` + ## Radial basis functions The RBF expansion places centers along the feature range and measures Gaussian similarity to @@ -18,9 +24,13 @@ Each output is a smooth bump around a center, so a linear model on top can build from local pieces. ```python +import numpy as np from pretab.transformers import RBFExpansionTransformer +X = np.linspace(0, 1, 50).reshape(-1, 1) # (50, 1) t = RBFExpansionTransformer(output_dim=10, gamma=1.0) +t.fit_transform(X).shape +# (50, 10) ``` Constructor highlights: `output_dim`, `gamma=1.0` (bump width; larger is narrower), @@ -31,6 +41,13 @@ Constructor highlights: `output_dim`, `gamma=1.0` (bump width; larger is narrowe `gamma` gives broad, overlapping ones. Tune it alongside `output_dim`. ``` +```{warning} +`target_aware=True` places centers using a supervised tree over `(X, y)`. Fitting it directly +on your full training set (outside a `Pipeline` or `pretab.CrossFittedTransformer`) raises a +`LeakageWarning`, because the center placement has already seen the labels you would then train +on. The same applies to ReLU, sigmoid, and tanh below whenever `target_aware=True`. +``` + ## ReLU, sigmoid, and tanh expansions These place a set of thresholds along the range and apply an activation at each, mirroring a @@ -40,13 +57,18 @@ ReLU : Piecewise-linear ramps. Excellent for sharp, threshold-like effects. Sigmoid and Tanh -: Smooth saturating steps. `scale` controls the steepness of the transition. +: Smooth saturating steps. `scale` controls the steepness of the transition: **smaller** values + give a sharper, more step-like transition; **larger** values spread it out. ```python +import numpy as np from pretab.transformers import ReLUExpansionTransformer, TanhExpansionTransformer +X = np.linspace(0, 1, 50).reshape(-1, 1) relu = ReLUExpansionTransformer(output_dim=10) tanh = TanhExpansionTransformer(output_dim=10, scale=1.0) +relu.fit_transform(X).shape # (50, 10) +tanh.fit_transform(X).shape # (50, 10) ``` ```{note} @@ -60,14 +82,24 @@ The Fourier map represents a feature with sines and cosines at a set of frequenc signals with cyclical structure. ```python +import numpy as np from pretab.transformers import FourierFeatureTransformer +X = np.linspace(0, 1, 50).reshape(-1, 1) t = FourierFeatureTransformer(n_frequencies=5, frequency_strategy="harmonic") +t.fit_transform(X).shape +# (50, 10): 2 columns (sin, cos) per frequency ``` Constructor highlights: `n_frequencies=5`, `frequency_strategy="harmonic"`, `include_original=False`, `random_state`. +**Parameter impact.** `n_frequencies` sets the output width to `2 * n_frequencies` (one sine +and one cosine column per frequency); `include_original=True` adds one more column for the raw +value, giving `2 * n_frequencies + 1`. `frequency_strategy="harmonic"` uses integer multiples of +the base frequency (1x, 2x, 3x, ...); the alternative spacing is useful when the signal is not +a clean harmonic series. + ```{tip} Use `FourierFeatureTransformer` when you want the model to work across a set of frequencies without committing to a single known period. If the period is known (hour of day, month of diff --git a/docs/representations/kernel_approximation.md b/docs/representations/kernel_approximation.md index 92f2946..52b08ad 100644 --- a/docs/representations/kernel_approximation.md +++ b/docs/representations/kernel_approximation.md @@ -14,10 +14,13 @@ cost of the approximation does not grow with the number of training points the w kernel method's does. ```python +import numpy as np from pretab.transformers import RandomFourierFeaturesTransformer +X = np.random.default_rng(0).uniform(size=(200, 3)) # (200, 3): the whole feature block t = RandomFourierFeaturesTransformer(n_components=100, gamma=1.0) -X2 = t.fit_transform(X) +t.fit_transform(X).shape +# (200, 100): n_components columns, independent of the number of input features ``` Constructor highlights: `n_components=100`, `gamma=1.0`, `random_state`. @@ -36,15 +39,27 @@ more accurate than random Fourier features at a given output width because the l to the data rather than being drawn at random. ```python +import numpy as np from pretab.transformers import NystroemFeaturesTransformer +X = np.random.default_rng(0).uniform(size=(200, 3)) t = NystroemFeaturesTransformer(n_components=100, kernel="rbf") -X2 = t.fit_transform(X) +t.fit_transform(X).shape +# (200, 100) ``` Constructor highlights: `n_components=100`, `kernel="rbf"`, `gamma=None`, `degree=3`, `coef0=1`, `random_state`. +```{warning} +Nyström samples its landmarks from the training rows, so `n_components` cannot exceed +`n_samples`. If you fit on fewer rows than `n_components` (for example a small +cross-validation fold), scikit-learn silently clamps `n_components` down to `n_samples` and +emits a `UserWarning` rather than raising: the fitted output width is `min(n_components, +n_samples_seen_in_fit)`. Random Fourier features have no such limit, since they draw a random +basis instead of sampling training rows. +``` + ```{note} Both methods approximate the same idea from different angles: random Fourier features draw a random basis independent of the data, while Nyström samples landmarks from the data itself. diff --git a/docs/representations/numerical_encoding.md b/docs/representations/numerical_encoding.md index bb7930e..f8b2a72 100644 --- a/docs/representations/numerical_encoding.md +++ b/docs/representations/numerical_encoding.md @@ -12,22 +12,34 @@ Numeric binning splits a feature into intervals and encodes which interval each into. You choose how the edges are placed and how the result is encoded. ```python +import numpy as np from pretab.transformers import NumericBinningTransformer -t = NumericBinningTransformer(output_dim=8, encode="onehot", placement_strategy="quantile") +X = np.random.default_rng(0).uniform(size=(100, 1)) # (100, 1) +onehot = NumericBinningTransformer(output_dim=8, encode="onehot", placement_strategy="quantile") +onehot.fit_transform(X).shape +# (100, 8): one column per bin + +ordinal = NumericBinningTransformer(output_dim=8, encode="ordinal", placement_strategy="quantile") +ordinal.fit_transform(X).shape +# (100, 1): a single integer column, regardless of output_dim ``` -The `encode` parameter selects the output form. +The `encode` parameter selects the output form, and it changes the output **width**, not just +the values: `"onehot"` produces `output_dim` columns, while `"ordinal"` and `"soft"` behave +differently from each other despite both accepting the same `output_dim`. `"ordinal"` -: A single integer column giving the bin index. +: A single integer column giving the bin index (output width is always 1, independent of + `output_dim`). `"onehot"` -: One indicator column per bin. +: One indicator column per bin (output width equals `output_dim`). `"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. + hard (output width equals `output_dim`, same shape as `"onehot"` but with fractional + membership instead of a single 1). Edge placement follows `placement_strategy`: `"uniform"` for equal-width bins, `"quantile"` for equal-frequency bins. See @@ -46,10 +58,16 @@ position within its bin**. The result is a piecewise-linear function that bends the target changes, following the tabular deep-learning work of Gorishniy and colleagues. ```python +import numpy as np from pretab.transformers import PLETransformer -t = PLETransformer(output_dim=12, task="regression") -X2 = t.fit_transform(x, y) # y is required +X = np.random.default_rng(0).uniform(size=(100, 1)) +y = np.random.default_rng(0).integers(0, 2, size=100) +t = PLETransformer(output_dim=12, task="classification") +t.fit_transform(X, y).shape +# (100, 12): output_dim is exact here (no adaptive clamping) +t.total_output_dim_ +# 12 ``` Constructor highlights: `output_dim`, `placement_strategy="cart"`, `task="regression"`, @@ -61,6 +79,13 @@ and should be fit leakage-safely, ideally with cross-fitting. See [Target awareness](../core_concepts/target_awareness.md). ``` +```{warning} +Because PLE always reads `y` to place its bins, fitting it directly on data you will also train +on emits a `LeakageWarning`. Fit it inside a scikit-learn `Pipeline` or wrap it in +`pretab.CrossFittedTransformer` so the bin edges never see the rows they will later transform +for training. +``` + ### Why piecewise-linear rather than one-hot Plain binning throws away where a value sits inside its bin; two values in the same interval @@ -82,14 +107,30 @@ keeps the boundary continuous, so December and January sit next to each other in opposite ends of a number line. ```python +import numpy as np from pretab.transformers import PeriodicEncodingTransformer -t = PeriodicEncodingTransformer(period=12, harmonics=2) # e.g. month of year +X = np.array([[0], [6], [12], [18], [24]]) # hour-of-day style values +t = PeriodicEncodingTransformer(period=24, harmonics=2) # e.g. hour of day +t.fit_transform(X).shape +# (5, 4): 2 columns (sin, cos) per harmonic ``` Constructor highlights: `period` (required, the cycle length), `harmonics=1`, `include_original=False`. +**Parameter impact.** Output width is `2 * harmonics` (one sine/cosine pair per harmonic), plus +one extra column when `include_original=True`. Higher `harmonics` lets the encoding represent +finer-grained sub-cycles (for example distinguishing morning from afternoon within a day), at +the cost of a wider output. + +```{important} +Valid input is the **closed interval** `[0, period]`: both endpoints are accepted, and by +construction they map to the identical `(sin, cos)` pair, since `x=0` and `x=period` are the +same point on the cycle. Values outside `[0, period]` raise a `PretabDataError` at fit and +transform, there is no silent wrap-around or clamping. +``` + ```{note} Periodic encoding is a standalone time-series utility. It is not wired into `Preprocessor` because it requires a per-feature `period`, so apply it directly to the relevant cyclical diff --git a/docs/representations/overview.md b/docs/representations/overview.md index 7cfdc5b..a588b65 100644 --- a/docs/representations/overview.md +++ b/docs/representations/overview.md @@ -50,13 +50,6 @@ Ordinal and one-hot encoding for categories, handling unseen values without rais Pretrained language embeddings for high-cardinality text categories. ::: -:::{grid-item-card} Preprocessing utilities -:link: preprocessing_utilities -:link-type: doc -Supporting transformers `Preprocessor` wires in automatically: pass-through, type conversion, -and missing-value flagging. -::: - :::: ## Shared terminology diff --git a/docs/representations/preprocessing_utilities.md b/docs/representations/preprocessing_utilities.md index 8fed666..affa43f 100644 --- a/docs/representations/preprocessing_utilities.md +++ b/docs/representations/preprocessing_utilities.md @@ -19,20 +19,33 @@ methods, letting a column skip representation entirely while still satisfying th scikit-learn transformer API. ```python +import numpy as np from pretab.transformers import NoTransformer +X = np.zeros((5, 3)) # (5, 3) t = NoTransformer() -X2 = t.fit_transform(X) # X2 is X, unmodified +t.fit_transform(X).shape +# (5, 3): identical to the input, values and width both unchanged ``` `ToFloatTransformer` casts its input to floating point. `Preprocessor` appends it after one-hot encoding so the categorical block has the same dtype as the rest of the design matrix. ```python +import numpy as np from pretab.transformers import ToFloatTransformer +X = np.array([[1], [2], [3]]) # (3, 1), integer dtype t = ToFloatTransformer() -t.fit_transform(X).dtype # dtype('float64') +out = t.fit_transform(X) +out.shape, out.dtype +# ((3, 1), dtype('float64')): width unchanged, only the dtype changes +``` + +```{note} +Neither utility has an `output_dim`-style width parameter: unlike every expansion or encoding +family elsewhere in this section, the output always has the exact same number of columns as +the input. ``` ## Missing-value flagging diff --git a/docs/representations/spline_expansions.md b/docs/representations/spline_expansions.md index 7b20cbb..dc0e3b1 100644 --- a/docs/representations/spline_expansions.md +++ b/docs/representations/spline_expansions.md @@ -19,26 +19,63 @@ a point in one region does not disturb the fit in another. Width is set by `outp knot positions by `placement_strategy` (see [Resolution and placement](../core_concepts/resolution_and_placement.md)). +```{important} +For every univariate spline in this section, `output_dim` is the number of output columns +**per input feature**, not the total. A `(n_samples, 3)` input produces +`(n_samples, 3 * output_dim)` output (plus one extra column per feature if +`include_bias=True`). Feature names are suffixed per input, for example `x0_bs0, x0_bs1, ...` +for a B-spline on column `x0`. +``` + ## 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 +import numpy as np from pretab.transformers import BSplineTransformer -t = BSplineTransformer(output_dim=13, degree=3, placement_strategy="quantile") +X = np.linspace(0, 1, 50).reshape(-1, 1) # (50, 1) +t = BSplineTransformer(output_dim=8, degree=3, placement_strategy="quantile") +t.fit_transform(X).shape +# (50, 8) ``` Constructor highlights: `output_dim`, `degree=3`, `include_bias=False`, `knot_locations=None` (pass explicit knots to override placement), `target_aware=False`, `placement_strategy="quantile"`, `adaptive`, `random_state`. +**Parameter impact.** + +`degree` +: Sets the minimum usable `output_dim`: PreTab requires `output_dim >= degree + 1` (a cubic, + `degree=3`, needs at least 4 columns) and raises a typed error otherwise. Higher degree gives + smoother, wider-support basis functions at the same `output_dim`; `degree=1` recovers + piecewise-linear segments. + +`output_dim` +: The exact per-feature output width (unlike the cubic/natural/tensor families below, no + conversion is applied). More columns track finer local detail and increase overfitting risk. + +`include_bias` +: Defaults to `False`. A B-spline basis over a clamped knot vector already sums to 1 in every + row (a partition of unity), so prepending a bias column makes the design exactly + rank-deficient. Set `include_bias=True` only if a downstream model specifically needs an + explicit intercept column; it adds one extra output column. + ```{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. ``` +```{note} +Every spline in this family also exposes `get_penalty_matrix(feature_index=0, diff_order=2)`, +a `D^T D` second-difference penalty matrix of shape `(output_dim, output_dim)` (plus the bias +row/column left unpenalized when `include_bias=True`). It is not limited to the "penalized" +splines further down this page. +``` + ## M-spline and I-spline These two share the B-spline machinery but target special shapes. @@ -53,11 +90,18 @@ I-spline domain knowledge says a relationship cannot reverse. ```python +import numpy as np from pretab.transformers import ISplineTransformer +X = np.linspace(0, 1, 50).reshape(-1, 1) t = ISplineTransformer(output_dim=10, degree=3) # monotone basis +t.fit_transform(X).shape +# (50, 10) ``` +Both share the same `degree`/`output_dim` constraint and parameter set as the B-spline above +(`output_dim >= degree + 1`, `include_bias=False` by default). + ```{note} I-splines only guarantee monotonicity when the downstream coefficients are constrained to be non-negative. Pair them with a non-negative linear model. @@ -70,17 +114,23 @@ smoothing penalty through `get_penalty_matrix()`. Cubic regression spline : A cubic basis parameterized at the knots (`cubicspline`), convenient for GAM-style additive - models. + models. Requires `output_dim >= 3`. 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. + Requires `output_dim >= 2`. ```python +import numpy as np from pretab.transformers import NaturalCubicSplineTransformer -t = NaturalCubicSplineTransformer(output_dim=12) -penalty = t.get_penalty_matrix() # for smoothing penalties +X = np.linspace(0, 1, 50).reshape(-1, 1) +t = NaturalCubicSplineTransformer(output_dim=8) +X2 = t.fit_transform(X) +X2.shape # (50, 8): output width equals output_dim exactly +t.n_knots_ # [7]: fitted interior-knot count per feature (output_dim - 1) +t.get_penalty_matrix().shape # (8, 8) ``` ```{tip} @@ -95,16 +145,24 @@ following Eilers and Marx. Instead of controlling smoothness only through the nu it uses many knots and a penalty of order `diff_order` to keep the fit smooth. ```python +import numpy as np from pretab.transformers import PSplineTransformer -t = PSplineTransformer(output_dim=20, degree=3, diff_order=2) -penalty = t.get_penalty_matrix() +X = np.linspace(0, 1, 50).reshape(-1, 1) +t = PSplineTransformer(output_dim=8, degree=3, diff_order=2) +t.fit_transform(X).shape # (50, 8) +t.get_penalty_matrix().shape # (8, 8) ``` 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. +**Parameter impact.** `diff_order` sets the order of the penalty: `diff_order=1` penalizes +changes in level between adjacent coefficients (favors flat fits), `diff_order=2` (the default) +penalizes changes in slope (favors locally-linear fits), and higher orders favor progressively +smoother curves. Requires `output_dim >= degree + 1`, the same floor as B-spline. + ```{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. @@ -118,13 +176,25 @@ 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. +interactions on a smooth grid. It exposes an anisotropic penalty per marginal via +`get_penalty_matrix(feature_index=...)`. ```python +import numpy as np from pretab.transformers import TensorProductSplineTransformer -t = TensorProductSplineTransformer(output_dim=8, degree=3, diff_order=2) -X2 = t.fit_transform(X[["lat", "lon"]]) +rng = np.random.default_rng(0) +X2 = rng.uniform(-3, 3, size=(200, 2)) # two input columns, e.g. lat/lon +t = TensorProductSplineTransformer(output_dim=5, degree=3, diff_order=2) +t.fit_transform(X2).shape # (200, 25) +t.get_penalty_matrix(feature_index=0).shape # (5, 5), one marginal +``` + +```{warning} +`output_dim` here is **per input dimension**, and the total width is `output_dim ** n_dims`. +With two columns and `output_dim=5` the result has `5 ** 2 = 25` columns; with three columns it +would be `125`. Keep `output_dim` small as the number of joint inputs grows, or the output width +explodes. ``` ### Thin-plate spline @@ -133,10 +203,13 @@ A thin-plate regression spline, the smooth-surface method from generalized addit places landmarks (by default with k-means) and forms a low-rank basis. ```python +import numpy as np from pretab.transformers import ThinPlateSplineTransformer +rng = np.random.default_rng(0) +X2 = rng.uniform(-3, 3, size=(200, 2)) t = ThinPlateSplineTransformer(n_components=10, landmark_strategy="kmeans") -X2 = t.fit_transform(X[["lat", "lon"]]) +t.fit_transform(X2).shape # (200, 10): output width is n_components, not input-dependent ``` Constructor highlights: `n_components=10`, `landmark_strategy="kmeans"`, `rank_strategy="eigen"`, From ea506e26bd90d6e39456d1fc0b8b336b3ff647a2 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 28 Aug 2026 11:17:57 +0200 Subject: [PATCH 055/123] docs: clarify periodic encoding has no automatic period detection --- docs/representations/numerical_encoding.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/representations/numerical_encoding.md b/docs/representations/numerical_encoding.md index f8b2a72..01bc6f8 100644 --- a/docs/representations/numerical_encoding.md +++ b/docs/representations/numerical_encoding.md @@ -124,6 +124,13 @@ one extra column when `include_original=True`. Higher `harmonics` lets the encod finer-grained sub-cycles (for example distinguishing morning from afternoon within a day), at the cost of a wider output. +```{warning} +PreTab has no mechanism to detect the period automatically from the data. `period` is a +required constructor argument with no default, and `fit` only validates that values fall +within `[0, period]`, it never infers the cycle length. You must know and supply the period +yourself (24 for hour of day, 7 for day of week, 12 for month of year, and so on). +``` + ```{important} Valid input is the **closed interval** `[0, period]`: both endpoints are accepted, and by construction they map to the identical `(sin, cos)` pair, since `x=0` and `x=period` are the From f1365afb083140b21160737a7fac5e001907659d Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 28 Aug 2026 11:22:29 +0200 Subject: [PATCH 056/123] docs: add math formulas for functional expansions and kernel approximation --- docs/representations/functional_expansions.md | 21 +++++++++++++++-- docs/representations/kernel_approximation.md | 23 +++++++++++++++++-- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/docs/representations/functional_expansions.md b/docs/representations/functional_expansions.md index 6757768..42e845c 100644 --- a/docs/representations/functional_expansions.md +++ b/docs/representations/functional_expansions.md @@ -51,7 +51,13 @@ on. The same applies to ReLU, sigmoid, and tanh below whenever `target_aware=Tru ## 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. +single hidden layer. For a feature $x$ and center $c_k$ (with `scale` $s$ for sigmoid and tanh), + +$$ +\text{ReLU: } \phi_k(x) = \max(0,\ x - c_k), \qquad +\text{Sigmoid: } \phi_k(x) = \frac{1}{1 + \exp\!\big(-(x - c_k)/s\big)}, \qquad +\text{Tanh: } \phi_k(x) = \tanh\!\big((x - c_k)/s\big). +$$ ReLU : Piecewise-linear ramps. Excellent for sharp, threshold-like effects. @@ -79,7 +85,18 @@ 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. +signals with cyclical structure. For a feature $x$ with fitted origin $x_0$ (the observed +minimum) and angular frequency $\omega_k$, + +$$ +\phi_k(x) = \big(\sin(\omega_k (x - x_0)),\ \cos(\omega_k (x - x_0))\big). +$$ + +The fundamental frequency is set from the feature's observed range at fit time +($2\pi / \text{range}$), and `frequency_strategy` controls how the $\omega_k$ are spread above +it: `"harmonic"` uses integer multiples $k \cdot \omega_1$; `"log_spaced"` uses octaves +$2^{k-1} \cdot \omega_1$; `"random"` draws frequencies from a half-normal distribution scaled by +$\omega_1$. ```python import numpy as np diff --git a/docs/representations/kernel_approximation.md b/docs/representations/kernel_approximation.md index 52b08ad..af660a0 100644 --- a/docs/representations/kernel_approximation.md +++ b/docs/representations/kernel_approximation.md @@ -11,7 +11,16 @@ selectable per column through `Preprocessor`. 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, since the cost of the approximation does not grow with the number of training points the way an exact -kernel method's does. +kernel method's does. For an input vector $x$, each output column draws a random weight vector +$w_k \sim \mathcal{N}(0,\ 2\gamma I)$ and offset $b_k \sim \mathrm{Uniform}(0, 2\pi)$ at fit time, +then computes + +$$ +\phi_k(x) = \sqrt{\frac{2}{n_{\text{components}}}}\ \cos\!\big(w_k^\top x + b_k\big). +$$ + +The inner product $\phi(x)^\top \phi(x')$ approximates the RBF kernel +$\exp(-\gamma \lVert x - x' \rVert^2)$ in expectation over the random draw. ```python import numpy as np @@ -36,7 +45,17 @@ performance is still improving. Approximates a kernel by sampling landmark points from the training data and projecting onto them, following Williams and Seeger. It supports several kernels through `kernel`, and is often more accurate than random Fourier features at a given output width because the landmarks adapt -to the data rather than being drawn at random. +to the data rather than being drawn at random. For landmarks $z_1, \dots, z_m$ (the sampled +training rows) and kernel function $K$, let $k_m(x) = \big(K(x, z_1), \dots, K(x, z_m)\big)$ be +the vector of kernel evaluations between $x$ and every landmark. The output is + +$$ +\phi(x) = K_{mm}^{-1/2}\ k_m(x), +$$ + +where $K_{mm}$ is the $m \times m$ kernel matrix between the landmarks themselves, and +$K_{mm}^{-1/2}$ is computed once at fit time via its eigendecomposition. The inner product +$\phi(x)^\top \phi(x')$ approximates $K(x, x')$. ```python import numpy as np From 640f6c3ebdfdd5e478df6c202c1bf060e0e59d64 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 28 Aug 2026 11:33:46 +0200 Subject: [PATCH 057/123] docs: add per-family math formulas to spline expansions --- docs/representations/spline_expansions.md | 67 ++++++++++++++++++++--- 1 file changed, 60 insertions(+), 7 deletions(-) diff --git a/docs/representations/spline_expansions.md b/docs/representations/spline_expansions.md index dc0e3b1..ce2e28b 100644 --- a/docs/representations/spline_expansions.md +++ b/docs/representations/spline_expansions.md @@ -30,7 +30,13 @@ for a B-spline on column `x0`. ## 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. +sum to one, and each spans only `degree + 1` knot intervals. For knots $\tau$ and degree $p$, +the basis follows the standard Cox-de Boor recursion, + +$$ +B_{i,0}(x) = \begin{cases} 1 & \tau_i \le x < \tau_{i+1} \\ 0 & \text{otherwise} \end{cases}, \qquad +B_{i,p}(x) = \frac{x - \tau_i}{\tau_{i+p} - \tau_i} B_{i,p-1}(x) + \frac{\tau_{i+p+1} - x}{\tau_{i+p+1} - \tau_{i+1}} B_{i+1,p-1}(x). +$$ ```python import numpy as np @@ -82,13 +88,22 @@ 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. + should be non-negative, for example as a density-like basis. Built by rescaling each B-spline + basis function so it integrates to one over its support, + + $$ + M_k(x) = \frac{p + 1}{\tau_{k+p+1} - \tau_k}\, B_k(x). + $$ 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. + $$ + I_k(x) = \int_{\tau_k}^{x} M_k(t)\, dt. + $$ + ```python import numpy as np from pretab.transformers import ISplineTransformer @@ -114,12 +129,23 @@ smoothing penalty through `get_penalty_matrix()`. Cubic regression spline : A cubic basis parameterized at the knots (`cubicspline`), convenient for GAM-style additive - models. Requires `output_dim >= 3`. + models. Requires `output_dim >= 3`. The basis stacks the polynomial terms with one truncated + cubic term per interior knot $\kappa_j$, + + $$ + \big(x,\ x^2,\ x^3,\ (x - \kappa_1)_+^3,\ \dots,\ (x - \kappa_K)_+^3\big), \qquad (z)_+ = \max(0, z). + $$ 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. - Requires `output_dim >= 2`. + Requires `output_dim >= 2`. For knots $\xi_1, \dots, \xi_T$ ($\xi_1$, $\xi_T$ the boundary + knots), the basis stacks $x$ with one constrained term per interior knot $\xi_k$, + + $$ + d_k(x) = \frac{(x - \xi_k)_+^3 - (x - \xi_T)_+^3}{\xi_T - \xi_k}, \qquad + N_k(x) = d_k(x) - \frac{\xi_T - \xi_k}{\xi_T - \xi_1} d_1(x) - \frac{\xi_k - \xi_1}{\xi_T - \xi_1} d_T(x). + $$ ```python import numpy as np @@ -142,7 +168,17 @@ tails behave far better than an unconstrained cubic there. 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. +it uses many knots and a penalty of order `diff_order` to keep the fit smooth. The basis +functions $B_k$ are exactly the B-spline basis above; fitting a linear model with coefficients +$\beta$ on top penalizes the loss with + +$$ +\lambda\, \beta^\top D^\top D\, \beta, +$$ + +where $D$ is the `diff_order`-th order difference operator (the same matrix returned by +`get_penalty_matrix()`) and $\lambda$ is chosen by the downstream penalized model, not by +`PSplineTransformer` itself. ```python import numpy as np @@ -176,7 +212,15 @@ 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 per marginal via +interactions on a smooth grid. Each per-axis marginal is a B-spline basis (above), and the +joint basis function for multi-index $(k_1, \dots, k_d)$ over $d$ input columns $x_1, \dots, x_d$ +is their product, + +$$ +\Phi_{k_1, \dots, k_d}(x_1, \dots, x_d) = \prod_{j=1}^{d} B_{k_j}(x_j). +$$ + +It exposes an anisotropic penalty per marginal via `get_penalty_matrix(feature_index=...)`. ```python @@ -200,7 +244,16 @@ explodes. ### 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. +places landmarks (by default with k-means) and forms a low-rank basis from the leading +eigenvectors of a projected radial-kernel matrix between the data and the landmarks. The radial +kernel $\eta(r)$ depends on the input dimension $d$: + +$$ +\eta(r) = \begin{cases} r^3 & d = 1 \\ r^2 \log r & d = 2 \\ r & d \ge 3 \end{cases} +$$ + +where $r = \lVert x - z_j \rVert$ is the distance from $x$ to landmark $z_j$. This follows the +low-rank thin-plate regression spline of Wood (2003). ```python import numpy as np From 1561eeb2fe085311c39597ade0c074b676d8a80e Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Tue, 1 Sep 2026 17:34:01 +0200 Subject: [PATCH 058/123] fix(tests): sort imports in test_adaptive_output_dim --- tests/integration/test_adaptive_output_dim.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/test_adaptive_output_dim.py b/tests/integration/test_adaptive_output_dim.py index 6d9529b..2e834ba 100644 --- a/tests/integration/test_adaptive_output_dim.py +++ b/tests/integration/test_adaptive_output_dim.py @@ -20,10 +20,10 @@ import pytest from pretab.exceptions import InvalidParamError -from pretab.preprocessor import Preprocessor from pretab.expansion.spline.b_spline import BSplineTransformer from pretab.expansion.spline.i_spline import ISplineTransformer from pretab.expansion.spline.m_spline import MSplineTransformer +from pretab.preprocessor import Preprocessor OUTPUT_DIM = 6 From ba4a36c3640fe9c5f4901dccb3a49c6cb4590593 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Tue, 1 Sep 2026 17:38:28 +0200 Subject: [PATCH 059/123] docs: flag OneHotFromOrdinalTransformer as deprecated everywhere --- docs/homepage.md | 4 ++-- docs/representations/categorical_encoding.md | 9 +++++++-- docs/representations/comparison_table.md | 10 ++++++++-- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/homepage.md b/docs/homepage.md index b175009..4401660 100644 --- a/docs/homepage.md +++ b/docs/homepage.md @@ -21,8 +21,8 @@ Encoding (PLE). ::: :::{grid-item-card} 🌤 Categorical preprocessing -Ordinal and one-hot encodings, pretrained language embeddings, and helpers such as -`OneHotFromOrdinalTransformer`. +Ordinal and one-hot encodings, plus pretrained language embeddings for high-cardinality or +semantic columns. ::: :::{grid-item-card} 🔧 Composable pipelines diff --git a/docs/representations/categorical_encoding.md b/docs/representations/categorical_encoding.md index 7ccb8bd..14b0131 100644 --- a/docs/representations/categorical_encoding.md +++ b/docs/representations/categorical_encoding.md @@ -47,8 +47,13 @@ pre.get_feature_names_out() # array(['cat_color_blue', 'cat_color_green', 'cat_color_red'], dtype=object) ``` -The alias `ohe` resolves to `one-hot`. There is also `onehot_from_ordinal`, which one-hot -encodes an already integer-coded column. +The alias `ohe` resolves to `one-hot`. + +```{warning} +`onehot_from_ordinal` (`OneHotFromOrdinalTransformer`) one-hot encodes an already +integer-coded column, but it is deprecated and emits a `DeprecationWarning`. Use `"one-hot"` +instead. +``` ```{warning} One-hot width grows with cardinality: a column with `k` distinct categories produces `k` diff --git a/docs/representations/comparison_table.md b/docs/representations/comparison_table.md index 8b4f485..ff3ccab 100644 --- a/docs/representations/comparison_table.md +++ b/docs/representations/comparison_table.md @@ -46,8 +46,8 @@ source of truth, and these tables mirror it. | I-spline | `ispline` | univariate | optional | yes | yes | 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 | +| Penalized spline (P-spline) | `pspline` | univariate | forbidden | no | yes | yes | +| Tensor-product spline | `tensorspline` | multivariate | forbidden | no | yes | no | | Thin-plate spline | `tprs` | multivariate | forbidden | no | yes | no | ```{note} @@ -110,6 +110,12 @@ selectable through `Preprocessor`. Instantiate `PeriodicEncodingTransformer` dir The alias `ohe` resolves to `one-hot`. ``` +```{warning} +`onehot_from_ordinal` (`OneHotFromOrdinalTransformer`) is deprecated and emits a +`DeprecationWarning`. Use `"one-hot"` instead, which wraps scikit-learn's `OneHotEncoder` +directly. +``` + ## Embeddings | Method | Key | Scope | Target | Selectable | From 1eaffb4151927ff85c39ce4ecbefacc73f4de923 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Tue, 1 Sep 2026 18:24:38 +0200 Subject: [PATCH 060/123] docs(core_concepts): correct NaN handling, placement, and rff/nystroem claims --- docs/core_concepts/feature_representation.md | 10 ++++++++-- docs/core_concepts/missing_values.md | 18 ++++++++++------- docs/core_concepts/reproducibility.md | 6 ++++-- .../core_concepts/resolution_and_placement.md | 20 +++++++++++++------ 4 files changed, 37 insertions(+), 17 deletions(-) diff --git a/docs/core_concepts/feature_representation.md b/docs/core_concepts/feature_representation.md index b2bddab..c47868d 100644 --- a/docs/core_concepts/feature_representation.md +++ b/docs/core_concepts/feature_representation.md @@ -28,10 +28,12 @@ on raw columns cannot. - **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 +- **Representations are self-describing.** Every fitted PreTab 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. + produced each output column. Plain scikit-learn transformers used through + `feature_preprocessing` (`StandardScaler`, `OneHotEncoder`, and so on) do not implement + `get_representation_spec`; lineage falls back to step metadata for those columns instead. - **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. @@ -73,6 +75,10 @@ lineage then maps each output column back to its source, making a fitted PreTab fully inspectable and serializable. ```python +from pretab.transformers import NaturalCubicSplineTransformer +import numpy as np + +transformer = NaturalCubicSplineTransformer(output_dim=6).fit(np.random.randn(100, 1)) spec = transformer.get_representation_spec() spec.family, spec.output_features, spec.locations ``` diff --git a/docs/core_concepts/missing_values.md b/docs/core_concepts/missing_values.md index cf7ca07..f066a8e 100644 --- a/docs/core_concepts/missing_values.md +++ b/docs/core_concepts/missing_values.md @@ -31,15 +31,19 @@ pre = Preprocessor( ```{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. +missing values then reach the transformer directly: scikit-learn scalers, the splines, and +the feature maps (`rbf`, `relu`, `sigmoid`, `tanh`) tolerate `NaN` and pass it straight into +the basis, so an affected row's output is itself undefined. Genuinely finite-only +representations such as PLE, numeric binning, periodic encoding, Fourier features, and the +kernel approximations (`rff`, `nystroem`) raise a typed error instead. That is intentional, an +expansion of an undefined value has no meaning for those methods. ``` -```{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. +```{note} +Requesting `add_missing_indicator=True` while imputation is disabled for that column kind does +not raise. It routes through the same `__missing` indicator branch used by +`missing_policy="separate_state"` below, since `SimpleImputer`'s own indicator only takes +effect when the imputer runs. ``` ## Fit on train, apply to test diff --git a/docs/core_concepts/reproducibility.md b/docs/core_concepts/reproducibility.md index 6b0e08e..712c879 100644 --- a/docs/core_concepts/reproducibility.md +++ b/docs/core_concepts/reproducibility.md @@ -13,12 +13,14 @@ output, for example in tests or published experiments. ```python from pretab import Preprocessor -pre = Preprocessor(numerical_method="rff", random_state=0) +pre = Preprocessor(numerical_method="rbf", 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. +with no stochastic component ignore the seed. The standalone kernel approximations +(`RandomFourierFeaturesTransformer`, `NystroemFeaturesTransformer`) accept `random_state` +directly; they are fit outside `Preprocessor` since they operate on the whole feature block. ``` ## Portable serialization diff --git a/docs/core_concepts/resolution_and_placement.md b/docs/core_concepts/resolution_and_placement.md index 9e18fd7..082ee64 100644 --- a/docs/core_concepts/resolution_and_placement.md +++ b/docs/core_concepts/resolution_and_placement.md @@ -8,10 +8,9 @@ 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. +input feature: basis functions for the splines, centers for the feature maps, and bins for +PLE. 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 @@ -19,6 +18,13 @@ forwarded to **every** numerical method. Per-transformer defaults only apply whe transformer directly, for example `RBFExpansionTransformer()`. ``` +```{warning} +Numeric binning (`custombin`) is the one exception: `output_dim` sets the number of *bins*, +but with the default `encode="ordinal"` each input feature still emits a single output +column holding the bin index. Pass `encode="onehot"` or `encode="soft"` if you want +`output_dim` to also control the output width. +``` + ### Spline width has a floor Each spline enforces a minimum width tied to its degree. Requesting fewer basis functions @@ -93,8 +99,10 @@ placement subsystem so no transformer re-implements it, and it is driven by two ```{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=True` with `placement_strategy="quantile"`, raises an error. +`Preprocessor.placement_strategy` defaults to `"cart"` (paired with `target_aware=True`), so +switching to `target_aware=False` also means passing `placement_strategy="uniform"` or +`"quantile"` explicitly, otherwise the mismatched default raises an error. ``` Target-aware placement is a supervised decision and carries leakage considerations. See From df6aac7808eeac49c464062a5e83c9cb61060674 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Tue, 1 Sep 2026 19:39:07 +0200 Subject: [PATCH 061/123] docs(getting_started): correct placement_strategy and rff/nystroem claims --- docs/getting_started/migration_to_1_0.md | 5 +++-- docs/getting_started/overview.md | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/getting_started/migration_to_1_0.md b/docs/getting_started/migration_to_1_0.md index e21a036..4cf7b9f 100644 --- a/docs/getting_started/migration_to_1_0.md +++ b/docs/getting_started/migration_to_1_0.md @@ -61,8 +61,9 @@ The valid combinations are fixed: ```{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. +raises an error rather than silently guessing. `placement_strategy` defaults to `"cart"` +(paired with the `target_aware=True` default), so switching to `target_aware=False` also +means passing `placement_strategy="uniform"` or `"quantile"` explicitly. ``` See [Resolution and placement](../core_concepts/resolution_and_placement.md) for the full diff --git a/docs/getting_started/overview.md b/docs/getting_started/overview.md index e2dbc12..8f6d9c8 100644 --- a/docs/getting_started/overview.md +++ b/docs/getting_started/overview.md @@ -51,8 +51,9 @@ The real question is what PreTab adds where scope overlaps with scikit-learn's o ```{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. +wrappers around scikit-learn's own `RBFSampler` and `Nystroem`; unlike the other methods on +this page, they operate on the whole feature block at once, so they are standalone +transformers rather than a per-column `Preprocessor` choice. ``` ## When to reach for PreTab From b5f73d2acfaeafb77936195b01155d12943f43f8 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Tue, 1 Sep 2026 19:39:17 +0200 Subject: [PATCH 062/123] docs(representations): fix edge case, adaptive, and penalty matrix claims --- docs/representations/choosing_a_method.md | 24 +++++++++++++++-------- docs/representations/embeddings.md | 7 +++++-- docs/representations/spline_expansions.md | 8 +++++--- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/docs/representations/choosing_a_method.md b/docs/representations/choosing_a_method.md index 9b8a55f..42570c8 100644 --- a/docs/representations/choosing_a_method.md +++ b/docs/representations/choosing_a_method.md @@ -74,9 +74,12 @@ Very small samples 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. +: Bases are fitted on the training range, and PreTab's default policy is to extrapolate: a + value beyond the fitted range is passed straight into the basis rather than clamped, so the + spline or feature map keeps evaluating past its knots or centers. If your test data lies well + beyond training, that extrapolated value carries no real signal. Set + `policy=RepresentationPolicy(out_of_range="clip")` (or `"warn"` / `"error"`) if you would + rather cap or catch out-of-range inputs. 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 @@ -91,11 +94,16 @@ feature does not carry the signal, no representation will create it. Measure, do 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. +- **Constant column**: the splines that need spread (B/M/I, cubic, natural cubic, P-spline, + tensor-product) raise a typed error rather than fitting a meaningless basis. Numeric + binning falls back to a single bin instead of raising, and PLE and the feature maps place + all their bins or centers at the same value, a degenerate but still valid basis. +- **Out-of-range input at transform**: by default, values beyond the fitted range extrapolate + (the basis keeps evaluating past its knots or centers); set the `out_of_range` policy to + `"clip"`, `"warn"`, or `"error"` for a different, explicit behaviour. +- **Unseen category**: `ContinuousOrdinalTransformer` (`"int"`) maps an unseen category to a + reserved code rather than raising; one-hot encoding (`"one-hot"`) instead emits an all-zero + row for it. - **NaN into a finite-only method**: raises a typed error unless imputation is configured. See [Missing values](../core_concepts/missing_values.md). diff --git a/docs/representations/embeddings.md b/docs/representations/embeddings.md index bd56f69..7057b6b 100644 --- a/docs/representations/embeddings.md +++ b/docs/representations/embeddings.md @@ -18,8 +18,11 @@ t.fit_transform(X).shape ``` Constructor highlights: `model_name="paraphrase-MiniLM-L3-v2"`, or pass a preloaded `model` -(any object exposing an `encode(X)` method, useful for tests or a custom embedding backend -without pulling in `sentence-transformers`). The registry key is `pretrained`. +(useful for tests or a custom embedding backend without pulling in `sentence-transformers`). +Any object with a `.encode(X)` method works for the `transform` step; `embedding_dim_` is +read from `get_sentence_embedding_dimension()` when the model exposes it (as a real +`SentenceTransformer` does), falling back to a `dim` attribute, or to `0` if neither is +present. The registry key is `pretrained`. ```{important} Language embeddings require the optional `embeddings` extra, which pulls in diff --git a/docs/representations/spline_expansions.md b/docs/representations/spline_expansions.md index ce2e28b..c3068b5 100644 --- a/docs/representations/spline_expansions.md +++ b/docs/representations/spline_expansions.md @@ -76,10 +76,12 @@ Increase `output_dim` for more wiggle, decrease it to regularize. ``` ```{note} -Every spline in this family also exposes `get_penalty_matrix(feature_index=0, diff_order=2)`, -a `D^T D` second-difference penalty matrix of shape `(output_dim, output_dim)` (plus the bias +Every spline in this family exposes `get_penalty_matrix(feature_index=0, diff_order=2)`, a +`D^T D` second-difference penalty matrix of shape `(output_dim, output_dim)` (plus the bias row/column left unpenalized when `include_bias=True`). It is not limited to the "penalized" -splines further down this page. +splines further down this page. Note that `diff_order` is specific to this B/M/I family; the +cubic, natural cubic, P-spline, tensor-product, and thin-plate variants expose +`get_penalty_matrix(feature_index=0)` with their own fixed penalty construction instead. ``` ## M-spline and I-spline From 58ed9a21fa764382169651d9911a4d3d23afcb3c Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Tue, 1 Sep 2026 19:39:32 +0200 Subject: [PATCH 063/123] docs(developer_guide): fix stale versioning, testing, and contributing info --- docs/developer_guide/contributing.md | 8 +++++--- docs/developer_guide/testing.md | 11 +++++++---- docs/developer_guide/versioning.md | 12 ++++++------ 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/docs/developer_guide/contributing.md b/docs/developer_guide/contributing.md index 92e624e..812c901 100644 --- a/docs/developer_guide/contributing.md +++ b/docs/developer_guide/contributing.md @@ -48,7 +48,7 @@ poetry run pre-commit install --hook-type commit-msg --hook-type pre-commit --ho ```bash just test # full suite with coverage -just check # lint, format, type-check, all pre-commit hooks (what CI runs) +just check # ruff format, ruff lint, and pyright, via the pre-commit and pre-push hooks just docs # build HTML docs (warnings treated as errors) ``` @@ -70,8 +70,10 @@ time: | `pre-push` | `pyright` type checking (slower, so deferred to push). Also runs in CI. | ```{important} -Run `just check` before opening a PR. It executes the commit and push stage hooks against -every file, giving you the same signal CI will see. +Run `just check` before opening a PR. It runs every `pre-commit`- and `pre-push`-stage hook +(ruff format, ruff lint, pyright, and file hygiene checks) against every file. It does not +validate the commit message itself, that is the `commit-msg` hook, checked when you actually +run `git commit` or `just commit`. ``` Individual recipes are available when you want to run one step: diff --git a/docs/developer_guide/testing.md b/docs/developer_guide/testing.md index 368ca45..fd9d84f 100644 --- a/docs/developer_guide/testing.md +++ b/docs/developer_guide/testing.md @@ -14,8 +14,8 @@ 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 tests/expansion/ # one area +poetry run pytest tests/expansion/spline/test_b_spline.py -k output_shape # one test poetry run pytest -k "spline and not tensor" # by keyword ``` @@ -27,7 +27,8 @@ directory. | Directory | Covers | | --- | --- | | `tests/core/` | Base classes, adaptive resolution, supervised logic, logging. | -| `tests/transformers/` | Every representation, per family. | +| `tests/expansion/`, `tests/encoding/`, `tests/kernel_approximation/`, `tests/embedding/` | Every representation family, split by kind (splines and functional expansions, numerical/categorical encoders, kernel approximations, language embeddings). | +| `tests/transformers/` | Cross-family contracts: sklearn compatibility, feature names, output dimensions, parameter aliases, encoder counts. | | `tests/placement/` | Knot and edge placement strategies. | | `tests/compose/` | Registry, feature detection, config resolution, serialization. | | `tests/extension/` | The public extensibility surface and conformance. | @@ -80,7 +81,9 @@ your test suite keeps a future refactor from silently breaking compatibility wit ## Before you push -Run the full local gate, which mirrors CI. +Run `just check` and `just test` locally; together they cover most of what CI checks, though +CI additionally runs across the full Python 3.10-3.13 matrix, builds the package, and +enforces a branch-coverage threshold. ```bash just test # tests with coverage diff --git a/docs/developer_guide/versioning.md b/docs/developer_guide/versioning.md index 697b9f5..2a8a6bc 100644 --- a/docs/developer_guide/versioning.md +++ b/docs/developer_guide/versioning.md @@ -4,7 +4,8 @@ pretab follows [Semantic Versioning 2.0](https://semver.org/) and uses [Conventional Commits](https://www.conventionalcommits.org/) to automate version bumps and changelog generation via [commitizen](https://commitizen-tools.github.io/commitizen/). -While the major version is `0`, the public API may change between minor releases. +From `1.0.0` onward, `feat!:` and `BREAKING CHANGE:` commits bump the major version, following +standard SemVer. ## Version format @@ -18,16 +19,15 @@ MAJOR.MINOR.PATCH | `MINOR` | New backwards-compatible feature (`feat:`) | | `PATCH` | Backwards-compatible bug fix (`fix:`) or performance improvement (`perf:`) | -Release candidates use the suffix `rcN`, e.g. `0.1.0rc1`. +Release candidates use the suffix `rcN`, e.g. `1.0.0rc1`. The version is defined **in one place only**, `pyproject.toml`, and read at runtime via `importlib.metadata` in `pretab/_version.py`, so it never needs to be hard-coded in the package. ```{note} -`major_version_zero = true` is set in the commitizen config, so breaking changes bump the -**minor** version (e.g. `0.2.0` → `0.3.0`) rather than the major version while pretab is -pre-1.0. +`major_version_zero` is `false` in the commitizen config, so `feat!:` / `BREAKING CHANGE:` +commits bump the **major** version, in line with standard SemVer. ``` ## Commit types and their effect @@ -61,7 +61,7 @@ Or write the message directly: ```bash git commit -m "feat(feature-maps): add Gaussian RBF centers" -git commit -m "fix(preprocessor): validate n_bins > 0" +git commit -m "fix(preprocessor): validate output_dim > 0" ``` The `commit-msg` pre-commit hook validates every commit message against the conventional From 0c50fe33d13b8698cbfc92115ff95e253724af9c Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Tue, 1 Sep 2026 19:44:13 +0200 Subject: [PATCH 064/123] docs(tutorials): fix broken adaptive example and inaccurate claims --- docs/tutorials/adaptive_resolution.md | 44 ++++++++++++++----- docs/tutorials/custom_representation.md | 6 ++- docs/tutorials/multivariate_features.md | 3 +- docs/tutorials/nonlinear_regression.md | 8 ++-- docs/tutorials/target_aware_classification.md | 15 ++++--- 5 files changed, 51 insertions(+), 25 deletions(-) diff --git a/docs/tutorials/adaptive_resolution.md b/docs/tutorials/adaptive_resolution.md index ff697de..9159e01 100644 --- a/docs/tutorials/adaptive_resolution.md +++ b/docs/tutorials/adaptive_resolution.md @@ -33,6 +33,7 @@ We fit a spline with adaptive width on two signals of different complexity and i one chose. ```python +import warnings import numpy as np import pandas as pd from pretab.transformers import BSplineTransformer @@ -45,19 +46,40 @@ 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) + t = BSplineTransformer( + adaptive=True, min_output_dim=5, max_output_dim=20, + target_aware=True, placement_strategy="cart", task="regression", + ) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") # one-off fit, not reused to train a model + t.fit(x.reshape(-1, 1), y) print(f"{name:8s} -> selected width {t.total_output_dim_}") ``` ```text -simple -> selected width 7 -wiggly -> selected width 7 +simple -> selected width 15 +wiggly -> selected width 15 ``` -With adaptive resolution on, both signals resolve to widths within the `[5, 20]` bound. -The shape of the signal determines how the search space is used: you get an -appropriately-sized representation for each without tuning by hand. +Both widths land inside the `[5, 20]` window without you having to guess a number up front. +The two happen to match here because the underlying CART selector's split count is governed +more by its own tree depth and minimum-samples settings than by how wiggly the signal looks; +with noisier or smaller data, or a narrower window, the two searches can land on different +widths. The bound is what you control directly, the exact count inside it is data-driven. + +```{note} +Fitting a target-aware transformer directly like this, outside a `Pipeline`, normally emits a +`LeakageWarning`; it is suppressed above because this is a one-off illustrative fit whose +output is never used to train a downstream model. See +[Target awareness](../core_concepts/target_awareness.md) for when the warning matters. +``` + +```{note} +Adaptive resolution only takes effect on the target-aware placement path +(`target_aware=True`, paired with `placement_strategy="cart"` or `"lightgbm"`). With the +default `target_aware=False`, `adaptive=True` is a silent no-op and the transformer keeps its +ordinary fixed `output_dim` width. +``` ```{tip} Set `min_output_dim` and `max_output_dim` to a range you consider reasonable, then let the data @@ -90,10 +112,10 @@ Each numerical column receives a width suited to its own complexity, visible in 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. +Adaptive resolution is available for B/M/I splines, the freely-placed cubic and natural cubic +splines, PLE, and the RBF, ReLU, sigmoid, and tanh feature maps. The penalized P-spline and the +tensor-product and thin-plate splines have a fixed structure and ignore the adaptive flag. The +[comparison table](../representations/comparison_table.md) marks which methods adapt. ``` ## Where to go next diff --git a/docs/tutorials/custom_representation.md b/docs/tutorials/custom_representation.md index b8ba8b2..a3106cf 100644 --- a/docs/tutorials/custom_representation.md +++ b/docs/tutorials/custom_representation.md @@ -81,8 +81,10 @@ 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. +Before registering, run the conformance suite. It checks that your class is constructible with +defaults, raises `NotFittedError` before `fit`, returns deterministic output across a +`clone` and refit, and produces a `RepresentationSpec` and feature names consistent with its +declared metadata. ```python from pretab import check_representation diff --git a/docs/tutorials/multivariate_features.md b/docs/tutorials/multivariate_features.md index 74948b0..857b620 100644 --- a/docs/tutorials/multivariate_features.md +++ b/docs/tutorials/multivariate_features.md @@ -15,7 +15,8 @@ Four methods operate on several inputs together rather than per column. `tprs` : Thin-plate regression spline. A smooth surface over two or more inputs, from the generalized - additive model literature. + additive model literature. It also accepts a single input, though a univariate spline family + is usually a more natural fit there. `rff` : Random Fourier features. A scalable approximation to a shift-invariant kernel. diff --git a/docs/tutorials/nonlinear_regression.md b/docs/tutorials/nonlinear_regression.md index ffac3b8..845199c 100644 --- a/docs/tutorials/nonlinear_regression.md +++ b/docs/tutorials/nonlinear_regression.md @@ -120,7 +120,7 @@ print(f"MAE: {mean_absolute_error(y_test, pred):.2f}") ``` ```text -features: 41 +features: 40 R2: 0.979 MAE: 1.85 ``` @@ -138,7 +138,7 @@ 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 +The `Preprocessor` expands four raw columns into 40 features. Inspect the resolved layout with `get_feature_info`: ```python @@ -148,14 +148,14 @@ pre.get_feature_info() ```text feature kind pipeline dim cats ---------------------------------------------------------------- -age numerical imputer -> minmax -> bspline 13 - +age numerical imputer -> minmax -> bspline 12 - 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 +independently: 12 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). diff --git a/docs/tutorials/target_aware_classification.md b/docs/tutorials/target_aware_classification.md index ec1fede..d9c4d69 100644 --- a/docs/tutorials/target_aware_classification.md +++ b/docs/tutorials/target_aware_classification.md @@ -5,9 +5,10 @@ regressor. The same idea works for classification, with one added concern: when 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. +Here the target has a **circular** decision 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 +circle. ## The dataset @@ -36,7 +37,7 @@ X_train, X_test, y_train, y_test = train_test_split( ) ``` -The positive class lives inside a ring around the origin, shifted by the plan. The classes are +The positive class lives inside a disk around the origin, shifted by the plan. The classes are imbalanced, roughly one positive to three negatives. ## Baseline: scaling and LogisticRegression @@ -69,7 +70,7 @@ 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. +above negatives, because a straight boundary cannot enclose the disk. ```{warning} On imbalanced data, accuracy misleads. A model that always predicts the majority class already @@ -107,8 +108,8 @@ accuracy: 0.870 ROC AUC: 0.927 ``` -The RBF features let the linear classifier bend around the ring. Accuracy rises from `0.742` -to `0.870`, and the `ROC AUC` jumps from `0.569` to `0.927`. +The RBF features let the linear classifier bend around the circular boundary. Accuracy rises +from `0.742` to `0.870`, 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 From bb9b45c33600ad97544a2c0e49b52231b2f4562a Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Tue, 1 Sep 2026 19:46:26 +0200 Subject: [PATCH 065/123] =?UTF-8?q?bump:=20version=201.0.0rc2=20=E2=86=92?= =?UTF-8?q?=201.0.0rc3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 25 +++++++++++++++---------- pyproject.toml | 2 +- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9f0c2a..c50c033 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,19 +7,24 @@ 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 +## v1.0.0rc3 (2026-09-01) + +### Fix + +- **tests**: sort imports in test_adaptive_output_dim +- add missing indicator +- validate embedding during fit +- remove unused ple parameters ### Refactor -- Reorganized the `pretab` package by representation taxonomy: spline and functional - expansions now live under `pretab.expansion`, numeric and categorical encoders under - `pretab.encoding`, kernel approximations under `pretab.kernel_approximation`, language - embeddings under `pretab.embedding`, and supporting utilities under `pretab.preprocessing`. - `pretab.transformers` remains the stable, flat public import for every transformer class; - no class was renamed and no public behavior changed. -- Restructured the representations documentation and API reference to match the new - taxonomy, adding dedicated pages for kernel approximation, embeddings, and preprocessing - utilities. +- **preprocessing**: move floats and missing encoders to pretab.preprocessing +- **embedding**: move language embedding to pretab.embedding +- **kernel-approximation**: split kernel approximations into pretab.kernel_approximation +- **encoding**: move categorical encoders to pretab.encoding.categorical +- **encoding**: move numerical encoders to pretab.encoding.numerical +- **expansion**: move functional expansions to pretab.expansion.functional +- **expansion**: move spline transformers to pretab.expansion.spline ## v1.0.0rc2 (2026-08-21) diff --git a/pyproject.toml b/pyproject.toml index 1373254..48c2607 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pretab" -version = "1.0.0rc2" +version = "1.0.0rc3" description = "A scikit-learn compatible library for flexible tabular preprocessing, advanced feature representations, and basis expansions." authors = [ { name = "Anton Thielmann" }, From 0cc8919c74079f33a9907c63c910e85a9637405b Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 4 Sep 2026 10:01:56 +0200 Subject: [PATCH 066/123] docs: pretab def updated --- docs/homepage.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/homepage.md b/docs/homepage.md index 4401660..2796698 100644 --- a/docs/homepage.md +++ b/docs/homepage.md @@ -1,12 +1,15 @@ # PreTab -**PreTab** is a modular, extensible, and [scikit-learn](https://scikit-learn.org/)-compatible -preprocessing library for tabular data. It supports **all `sklearn` transformers** out of the -box and extends them with a rich set of custom encoders, splines, and neural basis expansions. +**PreTab** is a modular, [scikit-learn](https://scikit-learn.org/)-compatible representation +and preprocessing library for tabular data. Its focus is feature representation and +expansion: splines, neural basis maps, piecewise-linear encoding, kernel approximations, and +language embeddings, each shipped as a standalone transformer that speaks the standard +`fit` / `transform` API. Every PreTab transformer subclasses `BaseEstimator` and +`TransformerMixin`, so it drops into a `Pipeline` or `ColumnTransformer` alongside any +sklearn-native transformer you already use. ```{note} -These docs are for PreTab {{ version }}. The project is under active development and the -public API may evolve while the major version is `0`. +These docs are for PreTab {{ version }}. ``` ## Highlights From cee20a4b57ac1d0f9670ea550ee6f96d65f19298 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 4 Sep 2026 11:39:43 +0200 Subject: [PATCH 067/123] docs(developer_guide): add guidance on testing mathematical correctness --- docs/developer_guide/testing.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/developer_guide/testing.md b/docs/developer_guide/testing.md index fd9d84f..6d1290d 100644 --- a/docs/developer_guide/testing.md +++ b/docs/developer_guide/testing.md @@ -42,6 +42,39 @@ representation, update the pinned values in the same commit and call it out in t request, so the change is reviewed rather than hidden. ``` +## Testing mathematical correctness, not just shape + +A representation-heavy library like PreTab has a failure mode that shape and dtype checks +cannot catch: a basis function, penalty matrix, or encoding can be computed with the wrong +formula and still produce output of the right shape, the right dtype, and finite values. A +test that only asserts `X.shape == (n, k)` or `np.isfinite(out).all()` will pass on both the +correct and the incorrect implementation. + +When a representation has a closed-form mathematical property, test that property directly +instead of (or in addition to) its shape: + +- **Known identities.** A B-spline basis should sum to `1` at every point (partition of + unity); an M-spline should integrate to `1` over its own support; an I-spline should be + monotonically non-decreasing and bounded in `[0, 1]`. +- **Independent reference values.** A penalty matrix or a hand-derivable formula (a + particular basis value at a particular knot, say) can be checked against a value computed + a different way, for example a fine numerical quadrature or a direct closed-form + substitution, not just re-derived with the same code path the implementation itself uses. +- **Boundary behaviour.** Values at, or just past, a fitted range's edge are where + clipping-versus-extrapolation bugs and off-by-one integration bounds hide. Test a value + exactly at the boundary and one just beyond it, not only values safely inside the range. +- **Realistic missing-data shapes.** A mixed object array with an actual `NaN`/`None` among + string categories (the ordinary shape of a pandas column with missing values) is a + different code path than an all-numeric array with `NaN`, and needs its own test if a + transformer declares `allow_nan=True`. + +```{warning} +Shape/symmetry/finiteness assertions are still useful as a first line of defense, but they +are not sufficient proof that a mathematical implementation is correct: they pass equally +well on a subtly wrong formula as on a correct one. Pair them with at least one value-level +assertion for anything that has a defined mathematical property to check against. +``` + ## Markers The suite defines a `smoke` marker for fast end-to-end sanity checks that run as a dedicated CI From 638501fc3d131e0409848c2397fce0bb543b41c3 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 4 Sep 2026 11:44:23 +0200 Subject: [PATCH 068/123] fix(serialize): reject unsafe classes in from_spec --- pretab/compose/serialize.py | 48 +++++++++++++++- tests/integration/test_serialization.py | 74 +++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 2 deletions(-) diff --git a/pretab/compose/serialize.py b/pretab/compose/serialize.py index 6c9d07b..f6e78c2 100644 --- a/pretab/compose/serialize.py +++ b/pretab/compose/serialize.py @@ -25,12 +25,29 @@ from .._version import __version__ as _PRETAB_VERSION from ..core.parameters import UNSET +from ..core.policy import RepresentationPolicy +from ..core.representation import FeatureLineage, RepresentationSpec from ..exceptions import PretabError, PretabSerializationError +from ..placement.base import PlacementResult +from .registry import TransformerSpec 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"}) +# Top-level packages that a spec is allowed to *import a module from*. This is +# only the first line of defense (see ``_ALLOWED_DATACLASSES`` and the +# ``BaseEstimator`` check below for the checks that actually gate what gets +# called/instantiated). ``builtins`` is deliberately excluded: nothing PreTab +# ever needs to reconstruct from a spec lives there, and allowing it is what let +# a crafted spec call ``builtins.open`` with attacker-controlled arguments. +_ALLOWED_TOP_LEVEL = frozenset({"pretab", "sklearn", "numpy", "scipy"}) + +# The exact, closed set of dataclasses a spec is allowed to reconstruct via the +# ``__dataclass__`` tag. Reconstruction calls ``cls(**fields)`` (i.e. runs the +# dataclass's ``__init__``), so this must be an exact allow-list, not merely +# "any dataclass importable under an allowed module" -- the latter would still +# let a spec construct an arbitrary sklearn/numpy/scipy dataclass with +# attacker-controlled fields. +_ALLOWED_DATACLASSES = frozenset({RepresentationPolicy, RepresentationSpec, FeatureLineage, PlacementResult, TransformerSpec}) # --- helpers ------------------------------------------------------------- @@ -152,14 +169,41 @@ def _decode_mapping(mapping: dict) -> dict: def _decode_estimator(payload: dict): + """Reconstruct a ``BaseEstimator`` from an ``__estimator__`` tag. + + Bypasses ``__init__``/``__reduce__``/``__setstate__`` by design (a plain + ``__new__`` plus a ``__dict__`` update), but only for a class that is + actually a registered scikit-learn estimator -- anything else (a crafted + ``\"class\"`` naming an unrelated callable) is refused before it is ever + instantiated. + """ cls = cast(Any, _resolve(payload["class"])) + if not (isinstance(cls, type) and issubclass(cls, BaseEstimator)): + raise PretabSerializationError( + f"Refusing to reconstruct {payload['class']!r} as an estimator: not a " + "scikit-learn BaseEstimator subclass." + ) obj = cls.__new__(cls) obj.__dict__.update(_decode_mapping(payload["state"])) return obj def _decode_dataclass(payload: dict): + """Reconstruct a dataclass from a ``__dataclass__`` tag. + + Unlike :func:`_decode_estimator`, this calls ``cls(**fields)`` (the + dataclass's real ``__init__``), so the allow-list must be an *exact* set of + approved classes, not merely "any dataclass reachable under an allowed + module" -- refusing anything outside ``_ALLOWED_DATACLASSES`` is what stops + a crafted spec from constructing an arbitrary callable with + attacker-controlled keyword arguments. + """ cls = cast(Any, _resolve(payload["class"])) + if not (isinstance(cls, type) and dataclasses.is_dataclass(cls) and cls in _ALLOWED_DATACLASSES): + allowed = sorted(c.__qualname__ for c in _ALLOWED_DATACLASSES) + raise PretabSerializationError( + f"Refusing to reconstruct disallowed dataclass {payload['class']!r}. Only {allowed} are permitted." + ) fields = {k: _decode(v) for k, v in payload["fields"].items()} return cls(**fields) diff --git a/tests/integration/test_serialization.py b/tests/integration/test_serialization.py index b02caa9..493000c 100644 --- a/tests/integration/test_serialization.py +++ b/tests/integration/test_serialization.py @@ -197,6 +197,80 @@ def test_from_spec_refuses_disallowed_module(frame, target): Preprocessor.from_spec(spec) +def test_from_spec_refuses_builtins_module(): + """``builtins`` must not be resolvable at all (closes the arbitrary-call hole). + + Previously ``builtins`` was an allowed top-level module, so a crafted + ``__dataclass__``/``__estimator__`` tag naming ``builtins:open`` could call + ``open(**attacker_fields)`` with attacker-controlled keyword arguments, + including creating/truncating an arbitrary file. This asserts the module is + refused outright, and that no file is created as a side effect of the + refused load. + """ + import os + import tempfile + + target_path = os.path.join(tempfile.gettempdir(), "pretab_test_should_not_exist.txt") + if os.path.exists(target_path): + os.remove(target_path) + + malicious_spec = { + "schema_version": SCHEMA_VERSION, + "state": { + "__dict__": [ + [ + "evil", + { + "__dataclass__": { + "class": "builtins:open", + "fields": {"file": target_path, "mode": "w"}, + } + }, + ] + ] + }, + } + with pytest.raises(PretabSerializationError, match="disallowed module"): + Preprocessor.from_spec(malicious_spec) + assert not os.path.exists(target_path) + + +def test_from_spec_refuses_estimator_not_a_base_estimator(): + """An allowed-module class that isn't a ``BaseEstimator`` must still be refused. + + Proves the ``__estimator__`` check is a structural ``issubclass`` check, not + just a module-prefix check: ``numpy`` is an allowed module, but + ``numpy.ndarray`` is not a scikit-learn estimator. + """ + spec = { + "schema_version": SCHEMA_VERSION, + "state": {"__dict__": [["evil", {"__estimator__": {"class": "numpy:ndarray", "state": {}}}]]}, + } + with pytest.raises(PretabSerializationError, match="not a scikit-learn BaseEstimator"): + Preprocessor.from_spec(spec) + + +def test_from_spec_refuses_dataclass_not_in_allowlist(): + """An allowed-module, genuinely-a-dataclass class must still be refused unless + it is one of the specifically approved dataclasses. + + ``PreprocessorConfig`` is a real, internal PreTab dataclass (module "pretab", + would pass a module-prefix check) that is deliberately not part of a fitted + ``Preprocessor``'s serialized state, so it must not be reconstructable via a + spec either. + """ + spec = { + "schema_version": SCHEMA_VERSION, + "state": { + "__dict__": [ + ["evil", {"__dataclass__": {"class": "pretab.compose.config:PreprocessorConfig", "fields": {}}}] + ] + }, + } + with pytest.raises(PretabSerializationError, match="disallowed dataclass"): + Preprocessor.from_spec(spec) + + def test_from_spec_rejects_bad_source_type(): with pytest.raises(PretabSerializationError): Preprocessor.from_spec(12345) From 79fcab8f8fb38264d6779e4672584cf1141d7b51 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 4 Sep 2026 12:19:11 +0200 Subject: [PATCH 069/123] fix(ple): remove boundary-bin discontinuity in PLETransformer --- pretab/encoding/numerical/ple.py | 57 ++++++++++-------- .../numerical/test_ple_transformer.py | 56 +++++++++++++++++ tests/regression/_golden/ple_supervised.json | 2 +- tests/regression/_golden/ple_supervised.npz | Bin 4583 -> 4624 bytes 4 files changed, 88 insertions(+), 27 deletions(-) diff --git a/pretab/encoding/numerical/ple.py b/pretab/encoding/numerical/ple.py index 5831d60..81a639c 100644 --- a/pretab/encoding/numerical/ple.py +++ b/pretab/encoding/numerical/ple.py @@ -70,6 +70,10 @@ class PLETransformer( ---------- thresholds_ : list of ndarray Sorted threshold values for each feature. + edges_ : list of ndarray + Full per-feature bin-edge vector, ``[x_min, *thresholds, x_max]`` from the + training data, used to normalize every bin (including the first and last) + to ``[0, 1]``. n_features_in_ : int Number of features seen during ``fit``. n_bins_per_feature_ : list of int @@ -170,6 +174,7 @@ def fit(self, X, y=None): self.n_features_in_ = X.shape[1] self.thresholds_ = [] + self.edges_ = [] self.n_bins_per_feature_ = [] n_bins = self._resolve_param("output_dim", default=6) @@ -203,6 +208,7 @@ def fit(self, X, y=None): thresholds = adapter.get_thresholds(X[:, i], y, min_thresholds, max_thresholds) self.thresholds_.append(thresholds) + self.edges_.append(np.concatenate(([X[:, i].min()], thresholds, [X[:, i].max()]))) self.n_bins_per_feature_.append(len(thresholds) + 1) self.total_output_dim_ = int(sum(self.n_bins_per_feature_)) @@ -242,20 +248,24 @@ def transform(self, X): for col in range(X.shape[1]): feature = X[:, col].copy() thresholds = self.thresholds_[col] + edges = self.edges_[col] - ple_encoded = self._apply_piecewise_linear_vectorized(feature, thresholds) + ple_encoded = self._apply_piecewise_linear_vectorized(feature, thresholds, edges) all_transformed.append(ple_encoded) return np.hstack(all_transformed).astype(np.float32) - def _apply_piecewise_linear_vectorized(self, feature: np.ndarray, thresholds: np.ndarray) -> np.ndarray: + def _apply_piecewise_linear_vectorized( + self, feature: np.ndarray, thresholds: np.ndarray, edges: np.ndarray + ) -> np.ndarray: """Apply the vectorized piecewise linear encoding for one feature. - The encoding for each sample works as follows: - - - First bin (below ``thresholds[0]``): the raw value. - - Middle bins: the value normalized to ``[0, 1]`` within the bin. - - Last bin (above ``thresholds[-1]``): the raw value. + Every bin, including the first and last, is normalized to ``[0, 1]`` against + its own ``[lower, upper)`` edge (the fitted training range stands in for the + missing outer threshold on the two boundary bins), so the encoding is + continuous at every threshold, not just the interior ones. Values outside + the fitted ``[edges[0], edges[-1]]`` range are clipped into ``[0, 1]`` + rather than left unbounded. Every bin below the active bin is filled with ``1.0``; bins above it stay ``0.0``. @@ -264,7 +274,13 @@ def _apply_piecewise_linear_vectorized(self, feature: np.ndarray, thresholds: np n_bins = len(thresholds) + 1 if len(thresholds) == 0: - return feature.reshape(-1, 1).astype(np.float32) + lower, upper = edges[0], edges[-1] + width = upper - lower + if width > 1e-10: + values = np.clip((feature - lower) / width, 0.0, 1.0) + else: + values = np.full(n_samples, 0.5) + return values.reshape(-1, 1).astype(np.float32) ple_encoded = np.zeros((n_samples, n_bins), dtype=np.float32) @@ -277,27 +293,16 @@ def _apply_piecewise_linear_vectorized(self, feature: np.ndarray, thresholds: np continue values = feature[mask] + lower_edge = edges[bin_idx] + upper_edge = edges[bin_idx + 1] + bin_width = upper_edge - lower_edge - if bin_idx == 0: - # First bin: raw value, no lower bins to fill. - ple_encoded[mask, bin_idx] = values - - elif bin_idx == n_bins - 1: - # Last bin: raw value, all lower bins set to 1. - ple_encoded[mask, bin_idx] = values - ple_encoded[mask, :bin_idx] = 1.0 - + if bin_width > 1e-10: + ple_encoded[mask, bin_idx] = np.clip((values - lower_edge) / bin_width, 0.0, 1.0) else: - # Middle bin: normalize the value to [0, 1] within the bin. - lower_threshold = thresholds[bin_idx - 1] - upper_threshold = thresholds[bin_idx] - bin_width = upper_threshold - lower_threshold - - if bin_width > 1e-10: - ple_encoded[mask, bin_idx] = (values - lower_threshold) / bin_width - else: - ple_encoded[mask, bin_idx] = 0.5 + ple_encoded[mask, bin_idx] = 0.5 + if bin_idx > 0: ple_encoded[mask, :bin_idx] = 1.0 return ple_encoded diff --git a/tests/encoding/numerical/test_ple_transformer.py b/tests/encoding/numerical/test_ple_transformer.py index 0570df4..e5c7cfa 100644 --- a/tests/encoding/numerical/test_ple_transformer.py +++ b/tests/encoding/numerical/test_ple_transformer.py @@ -142,3 +142,59 @@ def test_ple_feature_names_out(): assert len(names) == transformer.get_n_features_out() assert all("_ple" in name for name in names) assert names[0].startswith("age") + + +def test_ple_is_bounded_in_zero_one(): + # Every column, including the first/last (boundary) bins, must stay in + # [0, 1]: no more raw, unbounded feature values leaking into the encoding. + rng = np.random.RandomState(11) + X = rng.uniform(-50.0, 500.0, size=(200, 1)) + y = rng.rand(200) + + transformer = PLETransformer(output_dim=5).fit(X, y) + Xt = transformer.transform(X) + + assert Xt.min() >= 0.0 + assert Xt.max() <= 1.0 + + +def test_ple_is_continuous_at_every_threshold(): + # Regression test: rc3 had a large discontinuity right at each learned + # threshold, because the first/last bins held the raw feature value while + # the middle bins were normalized to [0, 1]. Sweeping a fine grid across + # every threshold must never show a jump bigger than a couple of grid + # steps' worth of change. + rng = np.random.RandomState(12) + X = np.linspace(0.0, 100.0, 4000).reshape(-1, 1) + y = X.ravel() + rng.normal(0, 0.5, size=4000) + + transformer = PLETransformer(output_dim=5).fit(X, y) + Xt = transformer.transform(X) + + step = X[1, 0] - X[0, 0] + jumps = np.abs(np.diff(Xt, axis=0)).max(axis=1) + # A continuous, piecewise-linear ramp changes by roughly step / bin_width + # per sample; allow a generous multiple of the grid step as the ceiling so + # this only fails on a real discontinuity, not normal ramp slope. + assert jumps.max() < 50 * step, f"largest consecutive jump was {jumps.max()!r}" + + +def test_ple_boundary_bins_ramp_like_middle_bins(): + # The first and last bins must use the same [0, 1] ramp formula as the + # middle bins (against the training [x_min, x_max] edge), not a raw value. + X = np.linspace(0.0, 30.0, 3000).reshape(-1, 1) + y = X.ravel() + + transformer = PLETransformer(output_dim=3, task="regression").fit(X, y) + thresholds = transformer.thresholds_[0] + assert len(thresholds) >= 1 + + first_threshold = thresholds[0] + just_below = np.array([[first_threshold - 1e-3]]) + encoded = transformer.transform(just_below) + # Approaching the first threshold from below, the first column should be + # close to 1.0 (the top of its own ramp). A tight tolerance matters here: + # the old, buggy raw-value encoding would also happen to exceed a loose + # bound like "> 0.9" for a threshold this large, without actually being + # close to 1.0. + assert encoded[0, 0] == pytest.approx(1.0, abs=1e-2) diff --git a/tests/regression/_golden/ple_supervised.json b/tests/regression/_golden/ple_supervised.json index e74feb2..c5abed1 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/ple_supervised.npz b/tests/regression/_golden/ple_supervised.npz index 99c8b1721611b253270001905f36a719793b5eee..913f440439f68f965dbe3709c59363e0a95632b0 100644 GIT binary patch delta 4588 zcmV@EdT%j2mk;8AppW7%Q}$~B7cSv00000006yRdwdU97XP7L ztw+_fL9-p!dQ@73rEDJ3Z(RO*hn7{<-oGta>9cFhv9xuUu^yzJJ;e-1^8>->~Y7^P-*EBKT4T2`IY ztoBNy;8P_zAKj!)sQZo7cqg4c%X;5de5-?k{R*|S>3f$KI#HLl#POr^px%&+9`TdM zJAdrVZjpY?14Wlb=+xR+JJ(PC=O%kze&=btCtW+j9Pj&GawPd48~2Sxdu4E&H98z- z;;C4EyX5?Y@{|3Lc4p#}{}TCsdP3SBFXPFpS9@h1@I}to?v;Va&snLwem&u~`QRZ> z^u6ZBc@ljq{llSr9Lfig_F85#jZSI(6Mu6nsWDSO(U*)|C^$da<876O>YHRaU(k6m zi=XJL-(KXAUWo0rr{eM;_bH3l75VXAl%zsjACw#Kuro(J;i(vm^BI)>LyUPo(K#yT z-|dkvgIm9sP1CO^`oM%se#%W-ULN-{-p2ANOG)FcX7;1$UC{oSN#EJLKuDfE+kcHm zS13Iv_r~=Ayd5+l?QJ=KQUtg1QwmSMI_+wn1j%6^HhkR*m@Ony~xAk z=0=mUdmg^lHz$7Yk-pjb;P7`v-+w1rKdC?V95TLj8}P;yYIlV;ms?22EE(ycq@{OT`arsT=H&)bDKj}u>-4)mS zdHtgKwjpT2ykjC{n~X}@m_vV$2huL!L#l#Cisy9^N* z;TlQydPmfLhsv93)0U8)rvwiY(vJnoA0(|us_6Vd>80T|oX*jCdt%0ilKnH2y-wrV zDQW#W{O{(x&c(L*2pwG;eKh(nTM~HggHDp?}>lAxgLLTnG9|v^eM94{>J;f{Y5GH zj+aLABPk+qt8od2RvR*Y~A-=!=f4S7LMAtj+wUt`H^e<4Jjun%ugQr2| z6Eu-=-$;Ybe^mah$;R#eD4o>*o@Wn}<>L9Y}A*vqL)L z7aG4$=Kon0?-1vK08U!H6dwKYI9z{6>Evo2O;48PBS^U(GJgoP|3S_3!j3(Btu$^c zYgZHdKYencegNpY;FgZ>53%)_?Q<4m<)e3zL@5dd?eP}d=R!y@3|f%_skd4LH1Joz5twl96XUR(EW7s`~SSATj+={!F&9!}zYTKqu}ZsYn%kiM&S{u|HVk$9g#y(@P>^G&H5 z!L!4Y>3?YBBGc#j>6E9pg4%n@bWrw}d<5tCzd-#Jc=(t&Y|igq^D_H9<;mwC;RLTYvXM6A`x_GDvDYV0C{6w_iHrp!J6O znR=2tW_S&dpR@8}49H$#q>X|4eIWM=3(W$ewg*f#1N)`fm(N>ko?fbcM;vshn6GtR%!vyF9My9o!imY(d2QS zw0{zM9#5!T$xMRU!N~c_^n6ymSv3#WH%0XkV!4(Z4w5J4;47eUQ@?n_>%KgUz6)-E zaMN;a#0AiKZZ)_C-p&(l>qZ(#@35ZlC$yfkUfv?>ug+_SJV!~0`b1>DDb+^c`WTA) zZf)B7$QSnqOx34gk@)(<)W)H}EfNAb?(qxtogM1N$XYgwrN z#Y?>R;>@p}J&AS${pcDYd8ysrx=B($k)ji8-Ei9TocbXZMB@%P_w{HQRucT%&D9Dy zymdD!PtH#x`pp=B?Z`XV>5KkdoVVS}J~zJO2x$MxXwwjSUXO>1*WMYw;rTQ_7k{Qa zQR>BQM6O3Cm6SgL5syBTda$Xz=UXmK;;^zk?(AKlgd^sI()|9&cz2y8>fdgz%2K*wg{ zo1$?FX#ESdxA_TeUxSI;*fw(+^M949M6=KM!Ut_VRtUHD*4MdgSZGc$NTMAlx?pX2-ePT`g0n zKNojgsS@!kaC3XT!^Hgvo)asG>v=ra`hO*RZU7|bwfuE7`!6~V>U@_$YNuxnmyM4j z+7882IEuF4P3CWNYZ2q&nGsugaT9%&JGQ)}?RPWhrFA(1*>A8)g@1XCS1C3x>HFOd z9sPV0X8*o>wWRFcc)2q#E95Y7({kf_=!}Q2%{PAM*S;$VhrOOB%en8i37y|j-<`_i zmoz-0<<7X(i(tw(E4U&r{*r^c@;`Xz(rCD(#@%dMfqiZmZ+||Ew!i1#Z+6RTyd2PY z2iops;#PXS2s#g{mVem=^{;^@CjV9;iZl|A>I=`XkejF=!A1{&TTb&6>toi2V%Ar??dVj2b|1sVk?j+?8Cr-RPR1>RnU@6(d!!C{Rj&uG`QM5dD!S-4)r9`79s zjw1hi+78)xkAG5l-Q3JNBI-|!^s5g1;8)d8it{Zual5ti9$Ng7sh?Wam&u+Z5v?cF z^ zi2qO9-Vy4r3B?D(trum3&V%Y(576Qepzp8I4CcNl0DmV4w-L7o*Pqz(vcvMPiRDRC zQhlZmCLZ%<0e=3~!7(mV%HO^?e=ZS^Gv_OPTceAZ`+JYHe{;*XNcl6vpmA?S>4Jv` zu{T)Td&$KKuTd2gFGHYT@^f7N9sUGv-y^p>bzvr?omN>9^;?pnZ)L*MxZQ(EC&toa z*$-cI%2!=RlR<{Mh^EAbwF_l)K`CD?l+jjg1nyV z2H|yct!pFNJ`zeFH9te-s_|pE=zasOUe;?X?WZcuc6XUUH3GtrJ^9H^=4*kxaoL+ULFEb7=o~viO?Io34mB;hG z&n7zV?0FV6kQpBY&$W@c8LWE!_IBDh$o>y9?!H6u^)327uC&1Q5@vsHuHIQh`;MY7 zbbk_<7e_pAp|3wqJ14{JPw!ZbCii`n^MRA2ar*~({7#j|?XGNk>c8haRt`W@)Qo_G7p z{S&$FnIU%$xld!SdWsnTKE4?73$$jP+b4uO|F^L3ZuyK`k5l%u>fPL2+vNzepMUjN zXmJWf6NnF5x2_`b>#Tw4Lg(zwPV0Zw^}L&_V>cawv{QQYwUF`c7wOmci>2nJQD!po z{?#=KXkHpEYI{A$$g5|MK$X%tF1P|;>UiL z4~;YC3vMgr8fq8OEKoel;`_gN2Y>2^%qwR;2hY`-TTig+%`%mR?6xJ1w;0#_#FNPX z_#j3o&Y3!?CiGmUulh-#$_k75KJG>0@{l|quik*hTkvJv%|j_ny}CHR^l=KpXZH8; z>pO2kc->r|6@QvlZ&?MzeG+wh6X?D&6sHh=rEk<3=KHvmj@uW||5g4r(|^xQ0xt-+ z^=y|1c6hzkQBU8sGmAcg;`fEVjgv2;-?hBEmgz^YbxafW7>D#LRP(56b|y6FzXzq$sbXE z2;zgj}db{NVp!^e7!$L~q*s6P*SFuvAL2}0kUv-Bp= z@9*+(O3}Bq{#D|4j2>yXX9*r}-ziTRBD>Gv3AYg+`Y%?!zGy=V)GrV%2M91I+y^_kRIUO928c11$gm00;m8 W03iUvBg;C5ldcgn2Eq{l0001qeu}pM delta 4547 zcmV;!5j^gYB@EdT%j2mk;8ApjDAb-IxfB7a>G00000006~Zd3Y5?5`P?S z1cIDK4$(o88x6;TBF7k)Qv)mr5`r?>ga`r#!m$uk=35{jvKSKZ3djH-L5>In66DMX z2BNE65eP(*;Y@-F5s7lhv3bll`RJyP>X~^sf4uspyI;?5YO1TMyQ{ks`V9^q5K_T2 z#WTHG#DB<%;nB@HOU=4OKHsdh)GRV8dQx=QJ0qf^BSw1DdxnjjII=8#;;69kBg^ia zw+jqxEwyXkqP6s%Wbdl#xqJPZ?g3rDb}##p54I}k4k)K?h(69KNBkc&Us{)3B7*xq z`qJF<-3jINP9OUew>%`&8E%VaRXoGW*XDcXiGSeYrOOqnxPsdV8I;3(FY7xTk6cN4 z9*^HhIc0s?B|EsatTx|s_(P=Xy!`Dz+|2h-Uyrbb+oH;qR^L#bhfrzJP*8j3;k7QU zVr?PZhSB&8t6u4q*-*Jd>J`n`lGBTA@jLF!IhLFlF`cqRrH8$yhBYtx7<&B4bal>9}t1cb8_g?)Z z@rW;RE1@$S;g&a-ULeW0G(70E%X$zguYdfljVt_CN}gf*C+e4zRtUCFww>Q}@}eU< zpWAB{OP{ElMyzxwFU0cN@VOw|YTPqu2uV|D}T7P@rMf#{yx==*?;EY;+50VZeMVOd+H3l|A5-3 z*zHdBAhDbtwjP9AJJ8wQ`!zkU`a-W5{=*#cTOSjN+i$YpXoMBJDmP#IIg^v`PZ9kp z{k0QC4*eq~ zht7cflc9mO-cN4Q9v_F}{f~B!UbWu`;l}YOOGBA<$FsEt&+gFbr`;o$w5yd6ZndP* zN#OS-`{_IWmn@o9u&ZvZzTIwi+@*qOR7&<+QX;)9qZmm%*CINUIm?n#x*^W3!(N2dXaILCPCXj zDj%D7{Y$Q$3ZtH({;!BMpM9U>l4suwlK<=hp1dscVijYFFJYZr6K&ifhF zZxGvo08U!IEj;Q&6Y=pKR8ESsY2#FO^$3!#wvGVJf6%DfqJQ4md+()IUG0vmiM>xq zCNvHJ?H62A^H}`Gsn-2gdb%Balyq%&>z{1lKJ+Ud&%?wmZ+&qmMW2ewyZK#H;-Gc{ z#o;#{w^wBUD|Df#+<@Lg%Ppn($!y#W_A4ODsrP# zSLM6YE}?nga(~xCMwClrKdz&ViIuW+w9D0om=cN^%C4&Z~!+ugA^C z{R|Ku<@yb#-7)5WgvZs0;$z~FcNNmcvx())|2MD>aa>cb{U|R!60aAUj^yiw!MOdP z)q~R1Z<+Ieo@rmw{6SGZ6YDS2CkCq?MY$m~&O)Q|@qdsVZnn#^{w1ittE5eW&a<2H zeZ4Tri5&3q%k&_upFVgi$nNQFR`bdc|L1|rkBKKZ3Ob)o_6NsTD@ zJ9-Q1=C!8LV$;7R!wb`wi@n^bUrqH~yGP5_Yo%8U4a2tuaytp~EJ@)EG>qis$tjzfe zx_=%|C|yd4gN}m{snl}OSysMYCmZ)SMg0+Cx{?_LvL||E5r}W{vH?!#3vp!M9+6FpdzT3@3tGS&MSxVBQ|WVNPCm&PQd*!6!*=B zwEmGR`rD#flRmulZ>W3}jKcjD^L^@F+<)>Q~-TA$3XK}TIc4x@pLFIUU`#$<8o+mE=+sk>3_Q~ z``&wLdYrG~35a;qFY><=l9Pl@h&)K2B#7>7L91V=ynOHqEk1{B=SwQ0ad;EDLC0eq z`LXxVL$>6^f1@SO(0z_XaWZiz^Epgm_dx;S@azo~8ei~Ky^Y&fQ{FZiy!mImUT8WIkAIqz#$0!% zPJ2~ooq+P*3TXTjG@bYy<4PAApG>)!RxIX!_gTcVzoHkpeZ_{+=IPn(^06c{zB=!l zzs9eMZ$g{bVB`L32~&Sd6>8ANXI#;PWqzy>ZspCdiTldwcLusWPr}4erU!-gHBtMw zC(bum)Oc=6}Vx5UIR)aV{%g4em$W&k}$Ogu6_Cv-i3Cu5OA@eHQMC z6?_s`V9~Pv4ioo%cupk~`|~(ehOe=^ZUAKGm7Fx%_%GTH8vn3?R8E>R&h32@Y2{En z`O|3g-DLf?qyce1JiSjkZ{I{$?M|7!q|JA;_4kvLkog9qLKo=#Hh*3}?Vg8+X2(rA z`u!?w{QG9Hug1OcbhBOh?`7hqQK@c=qxatH1DH~w+-gEYNBimM$)CBk zzXjROPy2$_o=m=-dED0e4K?#W(SrZuUF7!Gcvc*%y;)TH>3<(YKUTd{nYYdwua|Z& zgxhGlkGPKvPW5U%LF>GXPnSUB&TQeesM=x`K2OaqA6B0j|BX{Qx+%V{R@A<;@pR7L zDS})7;hB@n{HNZd#UDT~rroiqylN3>y_{#nI((j%+zyT^JbOmFr(NXlo}7yNMeOh2 z@+@+GgS;@q?tgxdw(we1Pd$dm6N{Y(5q{+WZb3US#^GO0QNA~&|B zJ^8&i@%@xNJ)rTLP<$ZVYRN9pdQdg}2yGt%^!v3~%zvC01>gkX)+Qdn{U@e;8w;jp z6Y<8)OLSR2n0WN7x%m256UU4cTk-Z5mDWay?ms9bC&u&==KS7$w93d+$o*quq5Iy7 z$^{P(GTvZ3xyIf;$^msi`(+5!OMZ^^J%b*`$M?wPP8WO(DW}wuMB|oh$+tACCO+=L zmXl7QsDIswPPH5F4J}93Tl?xU$8#*Iw@pYRmYZ3}>G?JGa6jA_v=4#&%Ad^n27PQj zr|Y^wcrB{*Z9$tyLiK$81&CeMx4q+Z-W6JojC+1tA|H*4W1#y^nsnD6ym)vT9?|lq z{XXylGp-w_%HnUjiaP=EG~*$ZHL~0Ox0oKZ_!zeSE^a-1fY`cd=}qW9UZDCI>16wzb{pZhJa~h>b;u#D zocbw3a^gK#2$7pl&L0xOo$~}Q?!zazz2`VV6pCITJ?K3*3xDBv z_-dhb_Il5)541mTQSUirp!f6dhv;uLG}x{4i*2<_@40Zu{bmW&F0~E~ovvf#<+JVl zmLevwjas<+%JhUcC2So)RUr&zU@@KJR>P9&USfe)!yr ztbF-aFm4aY^%R#v_gip<@7{BQL3(~!0OH3=heO;t=S>K&MZM=TGv(jNCC-z`d!B>N zD?@P#;rE_{&6NL(tMTy#^u4NGXU3V?zzf3dJ$L*rzMZR_okLafE)>5j@_+3;=N-Af zxrG@=Z;Td>V#$B;Ca(_OJawkuypXLb@-rvj}JO6qF@mw98>O(TnbAW^1ce)?H zBfF!lPrHj-I`^8;xs8VU9B(}a6sIlumMc^#f$&?pl}S8@%!nOjcb>r+?jfN}-0I40 z39R;_b$!z9_-PPsXmK0(fL{~u6G h0Rk-pEdT%j2mk;8ApjDAb-G=X*AX)YnGpa0002@sVM_o2 From 36e0ad5d056ddd8801ca6bafdc1c2e3cf5179335 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 4 Sep 2026 12:41:50 +0200 Subject: [PATCH 070/123] fix(spline): correct penalty matrices and validate diff_order --- docs/getting_started/quickstart.md | 2 +- docs/representations/comparison_table.md | 7 ++- pretab/expansion/spline/cubic_regression.py | 54 ++++++++++++++----- .../spline/multivariate/tensor_product.py | 25 +++++++-- .../spline/multivariate/thin_plate.py | 22 +++++++- pretab/expansion/spline/natural_cubic.py | 8 +-- pretab/expansion/spline/p_spline.py | 11 ++++ .../spline/test_cubic_transformer.py | 25 +++++++++ .../spline/test_naturalcubic_transformer.py | 16 ++++++ .../spline/test_pspline_transformer.py | 14 +++++ .../spline/test_spline_api_parity.py | 5 +- .../spline/test_tensorproduct_transformer.py | 41 ++++++++++++++ .../spline/test_thinplate_transformer.py | 17 +++++- 13 files changed, 218 insertions(+), 29 deletions(-) diff --git a/docs/getting_started/quickstart.md b/docs/getting_started/quickstart.md index 63a9d1d..6adf36b 100644 --- a/docs/getting_started/quickstart.md +++ b/docs/getting_started/quickstart.md @@ -111,7 +111,7 @@ 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 +penalty = spline.get_penalty_matrix() # integrated-curvature penalty for GAM-style fitting ``` The multivariate thin-plate spline models several columns jointly and is sized by diff --git a/docs/representations/comparison_table.md b/docs/representations/comparison_table.md index ff3ccab..2177b0e 100644 --- a/docs/representations/comparison_table.md +++ b/docs/representations/comparison_table.md @@ -48,12 +48,15 @@ source of truth, and these tables mirror it. | Natural cubic spline | `naturalspline` | univariate | optional | yes | yes | yes | | Penalized spline (P-spline) | `pspline` | univariate | forbidden | no | yes | yes | | Tensor-product spline | `tensorspline` | multivariate | forbidden | no | yes | no | -| Thin-plate spline | `tprs` | multivariate | forbidden | no | yes | no | +| Thin-plate spline | `tprs` | multivariate | forbidden | no | experimental | 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`. +`tprs`. `ThinPlateSplineTransformer.get_penalty_matrix()` is experimental: it is not guaranteed +positive semi-definite (the retained eigenvalues of the projected landmark kernel can be +negative) and emits a `ConfigWarning` on every call. `transform()` is unaffected; only the +penalty is experimental. ``` ## Functional expansions diff --git a/pretab/expansion/spline/cubic_regression.py b/pretab/expansion/spline/cubic_regression.py index 324c2f2..318541d 100644 --- a/pretab/expansion/spline/cubic_regression.py +++ b/pretab/expansion/spline/cubic_regression.py @@ -1,3 +1,5 @@ +import itertools + import numpy as np from sklearn.base import BaseEstimator, TransformerMixin from sklearn.utils.validation import check_is_fitted @@ -179,6 +181,8 @@ def fit(self, X, y=None): self.knots_ = [] self.n_basis_ = [] + self.x_min_ = [] + self.x_max_ = [] for i in range(X.shape[1]): xi = X[:, i] knots = self._place_interior_knots( @@ -186,6 +190,9 @@ def fit(self, X, y=None): ) self.knots_.append(knots) self.n_basis_.append(self._bspline_basis(xi, knots).shape[1]) + finite_xi, _ = self._finite_column(xi, None) + self.x_min_.append(float(finite_xi.min())) + self.x_max_.append(float(finite_xi.max())) self.n_knots_ = [len(knots) for knots in self.knots_] return self @@ -208,6 +215,11 @@ def fit_transform(self, X, y=None): def get_penalty_matrix(self, feature_index=0): """Return the curvature penalty matrix for a fitted feature. + Penalizes the integrated squared second derivative of every basis + column over the fitted feature range, including the polynomial + ``x**2`` / ``x**3`` columns (only ``x`` and the bias have an + identically-zero second derivative and so are unpenalized). + Parameters ---------- feature_index : int, default=0 @@ -221,18 +233,36 @@ def get_penalty_matrix(self, feature_index=0): """ check_is_fitted(self, "n_basis_") n_basis = self.n_basis_[feature_index] + knots = self.knots_[feature_index] + x_min = self.x_min_[feature_index] + x_max = self.x_max_[feature_index] + + # Every basis column's second derivative is piecewise linear in x, with + # kinks only at the knots, so a single-panel Simpson's rule per + # knot-delimited segment integrates every pairwise product exactly. + breakpoints = np.unique(np.concatenate(([x_min, x_max], np.clip(knots, x_min, x_max)))) + breakpoints.sort() + P = np.zeros((n_basis, n_basis)) - offset = 4 if self.include_bias else 3 - for i in range(offset, n_basis): - for j in range(offset, n_basis): - ki = self.knots_[feature_index][i - offset] - kj = self.knots_[feature_index][j - offset] - P[i, j] = self._spline_penalty_entry(ki, kj, self.knots_[feature_index]) + for lo, hi in itertools.pairwise(breakpoints): + if hi <= lo: + continue + xs = np.array([lo, 0.5 * (lo + hi), hi]) + weights = (hi - lo) / 6.0 * np.array([1.0, 4.0, 1.0]) + D = np.column_stack([self._second_derivative(xs, i, knots) for i in range(n_basis)]) + P += (D * weights[:, None]).T @ D return P - def _spline_penalty_entry(self, ki, kj, knots): - kmax = max(ki, kj) - upper = knots[-1] - x_vals = np.linspace(kmax, upper, 100) - integrand = 36 * (x_vals - ki) * (x_vals - kj) - return np.trapezoid(integrand, x_vals) + def _second_derivative(self, x, basis_index, knots): + """Second derivative of one basis column, evaluated at ``x``.""" + poly_offset = 1 if self.include_bias else 0 + col = basis_index - poly_offset + if col <= 0: + return np.zeros_like(x, dtype=float) # bias / x: identically zero + if col == 1: + return np.full_like(x, 2.0, dtype=float) # x**2 + if col == 2: + return 6.0 * x # x**3 + knot = knots[col - 3] + return 6.0 * np.maximum(x - knot, 0.0) + diff --git a/pretab/expansion/spline/multivariate/tensor_product.py b/pretab/expansion/spline/multivariate/tensor_product.py index 7aed557..4eaaccf 100644 --- a/pretab/expansion/spline/multivariate/tensor_product.py +++ b/pretab/expansion/spline/multivariate/tensor_product.py @@ -42,6 +42,8 @@ class TensorProductSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEst diff_order : int, default=2 Order of the finite difference penalty used to enforce smoothness along each input dimension. + Must be a positive integer and small enough that the marginal basis width supports it + (``diff_order <= output_dim - 1``, roughly); both are validated at ``fit``. include_bias : bool, default=False If True, prepend a constant column to each marginal basis before the @@ -177,6 +179,8 @@ def fit(self, X, y=None): f"output_dim must be >= degree + 1 = {self.degree + 1} for the tensor-product " f"spline basis, got {output_dim}" ) + if not isinstance(self.diff_order, (int, np.integer)) or self.diff_order < 1: + raise InvalidParamError(f"diff_order must be a positive integer (>= 1); got {self.diff_order!r}.") self.dim_ = X.shape[1] self.knots_ = [] @@ -193,7 +197,15 @@ def fit(self, X, y=None): X[:, d], y, output_dim, self.degree, strategy, None, None, min_interior, max_interior ) basis = self._basis_matrix(X[:, d], knots) - penalty = self._difference_penalty(len(knots) - self.degree - 1) + n_basis = len(knots) - self.degree - 1 + if self.diff_order > n_basis - 1: + raise InvalidParamError( + f"diff_order={self.diff_order} is too large for a marginal basis of width " + f"{n_basis} (output_dim={output_dim}); the finite-difference penalty needs " + f"diff_order <= {n_basis - 1} to stay non-trivial. Lower diff_order or raise " + "output_dim." + ) + penalty = self._difference_penalty(n_basis) if self.include_bias: penalty = np.pad(penalty, ((1, 0), (1, 0))) self.knots_.append(knots) @@ -238,13 +250,16 @@ def get_penalty_matrices(self): penalties : list of ndarray One full penalty matrix per marginal direction, each formed as a Kronecker product of a marginal difference penalty with identity - matrices for the remaining dimensions. + matrices for the remaining dimensions, in dimension order so the + result lines up with the ``einsum`` + ``reshape`` flatten order used + by :meth:`transform` (dimension 0 slowest/outermost, the last + dimension fastest/innermost). """ kron_penalties = [] for i, Si in enumerate(self.penalties_): - mats = [np.eye(size) for j, size in enumerate(self.marginal_sizes_) if j != i] - P = Si - for M in mats: + mats = [Si if j == i else np.eye(size) for j, size in enumerate(self.marginal_sizes_)] + P = mats[0] + for M in mats[1:]: P = np.kron(P, M) kron_penalties.append(P) return kron_penalties diff --git a/pretab/expansion/spline/multivariate/thin_plate.py b/pretab/expansion/spline/multivariate/thin_plate.py index 0fdfa55..a9aaa84 100644 --- a/pretab/expansion/spline/multivariate/thin_plate.py +++ b/pretab/expansion/spline/multivariate/thin_plate.py @@ -1,3 +1,5 @@ +import warnings + import numpy as np from scipy.linalg import eigh from scipy.spatial.distance import cdist @@ -6,7 +8,7 @@ from sklearn.utils import check_random_state from sklearn.utils.validation import check_is_fitted -from ....exceptions import InsufficientSamplesError, InvalidParamError +from ....exceptions import ConfigWarning, InsufficientSamplesError, InvalidParamError from ..mixins import SplineBasisMixin _LANDMARK_STRATEGIES = ("kmeans", "subsample") @@ -56,7 +58,8 @@ class ThinPlateSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEstimat 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``). + row/column when ``include_bias=True``). **Experimental**: not guaranteed + positive semi-definite, see :meth:`get_penalty_matrix`. d_ : int Number of input features (also ``n_features_in_``). n_basis_ : list of int @@ -202,6 +205,14 @@ def transform(self, X): def get_penalty_matrix(self, feature_index=0): """Return the smoothing penalty matrix for the fitted basis. + .. warning:: + **Experimental.** The retained eigenvalues of the projected landmark + kernel are not guaranteed to be non-negative, so this penalty is not + guaranteed positive semi-definite. Using it for penalized-regression + smoothing can make an otherwise convex problem non-convex. This does + not affect :meth:`transform`, only this penalty. A warning is emitted + on every call until this is fully fixed. + Parameters ---------- feature_index : int, default=0 @@ -215,4 +226,11 @@ def get_penalty_matrix(self, feature_index=0): leading row/column when ``include_bias=True``). """ check_is_fitted(self, "penalty_") + warnings.warn( + "ThinPlateSplineTransformer.get_penalty_matrix() is experimental: the " + "retained eigenvalues are not guaranteed non-negative, so the returned " + "penalty is not guaranteed positive semi-definite.", + ConfigWarning, + stacklevel=2, + ) return self.penalty_ diff --git a/pretab/expansion/spline/natural_cubic.py b/pretab/expansion/spline/natural_cubic.py index 9c75505..372d524 100644 --- a/pretab/expansion/spline/natural_cubic.py +++ b/pretab/expansion/spline/natural_cubic.py @@ -232,8 +232,10 @@ def get_penalty_matrix(self, feature_index=0): """ check_is_fitted(self, "knots_") knots = self.knots_[feature_index] - B = self._basis(np.linspace(knots[0], knots[-1], 200), knots) - B_dd = np.gradient(np.gradient(B, axis=0), axis=0) + x_grid = np.linspace(knots[0], knots[-1], 200) + B = self._basis(x_grid, knots) + B_d = np.gradient(B, x_grid, axis=0) + B_dd = np.gradient(B_d, x_grid, axis=0) n_basis = B.shape[1] P = np.zeros((n_basis, n_basis)) @@ -242,6 +244,6 @@ def get_penalty_matrix(self, feature_index=0): for i in range(offset, n_basis): for j in range(offset, n_basis): integrand = B_dd[:, i] * B_dd[:, j] - P[i, j] = np.trapezoid(integrand, np.linspace(knots[0], knots[-1], 200)) + P[i, j] = np.trapezoid(integrand, x_grid) return P diff --git a/pretab/expansion/spline/p_spline.py b/pretab/expansion/spline/p_spline.py index 628de95..13260dc 100644 --- a/pretab/expansion/spline/p_spline.py +++ b/pretab/expansion/spline/p_spline.py @@ -40,6 +40,8 @@ class PSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEstimator): diff_order : int, default=2 The order of the difference penalty used to compute the smoothness penalty matrix. For example, 2 corresponds to a second-order difference penalty (encouraging smooth second derivatives). + Must be a positive integer and small enough that the fitted basis width supports it + (``diff_order <= output_dim - 1``, roughly); both are validated at ``fit``. include_bias : bool, default=False If True, prepend a constant intercept column per feature. The bias term is @@ -148,6 +150,8 @@ def fit(self, X, y=None): raise InvalidParamError( f"output_dim must be >= degree + 1 = {self.degree + 1} for the p-spline basis, got {output_dim}" ) + if not isinstance(self.diff_order, (int, np.integer)) or self.diff_order < 1: + raise InvalidParamError(f"diff_order must be a positive integer (>= 1); got {self.diff_order!r}.") self.knots_ = [] self.penalty_ = [] @@ -164,6 +168,13 @@ def fit(self, X, y=None): x, y, output_dim, self.degree, strategy, None, None, min_interior, max_interior ) n_basis = len(knots) - self.degree - 1 + if self.diff_order > n_basis - 1: + raise InvalidParamError( + f"diff_order={self.diff_order} is too large for a basis of width {n_basis} " + f"(output_dim={output_dim}); the finite-difference penalty needs " + f"diff_order <= {n_basis - 1} to stay non-trivial. Lower diff_order or " + "raise output_dim." + ) D = np.eye(n_basis) for _ in range(self.diff_order): D = np.diff(D, n=1, axis=0) diff --git a/tests/expansion/spline/test_cubic_transformer.py b/tests/expansion/spline/test_cubic_transformer.py index 353b492..925f21c 100644 --- a/tests/expansion/spline/test_cubic_transformer.py +++ b/tests/expansion/spline/test_cubic_transformer.py @@ -49,6 +49,31 @@ def test_cubic_spline_penalty_matrix_shape(): assert np.allclose(P, P.T, atol=1e-6) +def test_cubic_spline_penalty_matrix_last_knot_value(): + # Hand-derived closed form: 36 * integral of (x - knot) ** 2 dx from knot to + # x_max. With interior knots at [2.5, 5, 7.5] and x_max = 10, the last-knot + # entry is 187.5. + X = np.linspace(0, 10, 200).reshape(-1, 1) + transformer = CubicRegressionSplineTransformer(output_dim=6, placement_strategy="uniform") + transformer.fit(X) + + assert transformer.knots_[0] == pytest.approx([2.5, 5.0, 7.5]) + P = transformer.get_penalty_matrix() + assert P[-1, -1] == pytest.approx(187.5, rel=1e-6) + + +def test_cubic_spline_penalty_matrix_penalizes_polynomial_columns(): + # x**2 and x**3 have real curvature (f'' = 2 and f'' = 6x) and must not be + # silently unpenalized; only the linear x column has f'' = 0 everywhere. + X = np.linspace(0, 10, 200).reshape(-1, 1) + transformer = CubicRegressionSplineTransformer(output_dim=6).fit(X) + P = transformer.get_penalty_matrix() + + assert not np.allclose(P[1, :], 0.0) # x**2 row + assert not np.allclose(P[2, :], 0.0) # x**3 row + assert np.allclose(P[0, :], 0.0) # x row: identically zero second derivative + + def test_cubic_feature_names_out(): X = np.random.rand(20, 2) transformer = CubicRegressionSplineTransformer(output_dim=8) diff --git a/tests/expansion/spline/test_naturalcubic_transformer.py b/tests/expansion/spline/test_naturalcubic_transformer.py index 925e4e8..ccd4269 100644 --- a/tests/expansion/spline/test_naturalcubic_transformer.py +++ b/tests/expansion/spline/test_naturalcubic_transformer.py @@ -48,6 +48,22 @@ def test_natural_spline_penalty_matrix_symmetry(): assert np.allclose(P, P.T, atol=1e-6) +def test_natural_spline_penalty_matrix_is_grid_density_invariant(): + # Regression test: the old np.gradient(..., axis=0) call (missing the x_grid + # spacing argument) differentiated with respect to grid *index*, so the + # returned penalty shrunk by roughly dx**4 whenever the evaluation range + # changed. The penalty is computed on a fixed internal 200-point grid + # regardless of range, so as a proxy we assert it is not vanishingly small + # relative to a hand-checkable order of magnitude on a modest range. + X = np.linspace(0, 10, 200).reshape(-1, 1) + transformer = NaturalCubicSplineTransformer(output_dim=5, placement_strategy="uniform").fit(X) + P = transformer.get_penalty_matrix() + + # The buggy implementation produced a diagonal on the order of 1e-3 for this + # range; the correctly-scaled penalty is on the order of 1e2-1e3. + assert np.diag(P).max() > 10.0 + + def test_natural_spline_feature_names_out(): X = np.random.rand(20, 2) transformer = NaturalCubicSplineTransformer(output_dim=5) diff --git a/tests/expansion/spline/test_pspline_transformer.py b/tests/expansion/spline/test_pspline_transformer.py index 8036e75..d89fa2e 100644 --- a/tests/expansion/spline/test_pspline_transformer.py +++ b/tests/expansion/spline/test_pspline_transformer.py @@ -4,6 +4,7 @@ import pytest from sklearn.exceptions import NotFittedError +from pretab.exceptions import InvalidParamError from pretab.transformers import PSplineTransformer @@ -50,6 +51,19 @@ def test_pspline_penalty_matrix_shape_and_symmetry(): assert np.allclose(P, P.T, atol=1e-6) +@pytest.mark.parametrize("diff_order", [-1, 0]) +def test_pspline_rejects_nonpositive_diff_order(diff_order): + X = np.linspace(0, 1, 30).reshape(-1, 1) + with pytest.raises(InvalidParamError, match="diff_order must be a positive integer"): + PSplineTransformer(output_dim=8, diff_order=diff_order).fit(X) + + +def test_pspline_rejects_diff_order_too_large_for_output_dim(): + X = np.linspace(0, 1, 30).reshape(-1, 1) + with pytest.raises(InvalidParamError, match="diff_order=50 is too large"): + PSplineTransformer(output_dim=8, diff_order=50).fit(X) + + def test_pspline_feature_names_out(): X = np.random.rand(20, 2) transformer = PSplineTransformer(output_dim=5) diff --git a/tests/expansion/spline/test_spline_api_parity.py b/tests/expansion/spline/test_spline_api_parity.py index 86f8e75..53d170f 100644 --- a/tests/expansion/spline/test_spline_api_parity.py +++ b/tests/expansion/spline/test_spline_api_parity.py @@ -7,7 +7,7 @@ import numpy as np import pytest -from pretab.exceptions import IncompatibleParamsError +from pretab.exceptions import ConfigWarning, IncompatibleParamsError from pretab.transformers import ( CubicRegressionSplineTransformer, NaturalCubicSplineTransformer, @@ -142,6 +142,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(n_components=6, include_bias=True, random_state=0).fit(X) - P = transformer.get_penalty_matrix(feature_index=0) + with pytest.warns(ConfigWarning, match="experimental"): + 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/expansion/spline/test_tensorproduct_transformer.py b/tests/expansion/spline/test_tensorproduct_transformer.py index bb85cfb..f5ca12c 100644 --- a/tests/expansion/spline/test_tensorproduct_transformer.py +++ b/tests/expansion/spline/test_tensorproduct_transformer.py @@ -2,6 +2,7 @@ import pytest from sklearn.exceptions import NotFittedError +from pretab.exceptions import InvalidParamError from pretab.transformers import TensorProductSplineTransformer @@ -41,6 +42,46 @@ def test_tensorproduct_spline_penalty_matrices(): assert np.allclose(P, P.T, atol=1e-6) +def test_tensorproduct_spline_penalty_matrices_match_true_quadratic_form(): + # Regression test: get_penalty_matrices() used to always place the marginal + # penalty leftmost in the Kronecker chain, which only matched the + # einsum+reshape flatten order used by transform() for dimension 0. + rng = np.random.default_rng(0) + X = rng.random((100, 3)) + transformer = TensorProductSplineTransformer(output_dim=5).fit(X) + sizes = transformer.marginal_sizes_ + penalties = transformer.get_penalty_matrices() + + beta = rng.normal(size=sizes) + beta_flat = beta.ravel() + + for dim, P in enumerate(penalties): + D = transformer.penalties_[dim] + # True smoothness along `dim`: apply D on that axis, summed over every + # combination of the other axes' indices. + true_value = 0.0 + for index in np.ndindex(*sizes): + for index2 in np.ndindex(*sizes): + if any(index[k] != index2[k] for k in range(len(sizes)) if k != dim): + continue + true_value += beta[index] * D[index[dim], index2[dim]] * beta[index2] + lib_value = beta_flat @ P @ beta_flat + assert lib_value == pytest.approx(true_value, rel=1e-8), f"mismatch for dim={dim}" + + +@pytest.mark.parametrize("diff_order", [-1, 0]) +def test_tensorproduct_rejects_nonpositive_diff_order(diff_order): + X = np.random.default_rng(0).random((30, 2)) + with pytest.raises(InvalidParamError, match="diff_order must be a positive integer"): + TensorProductSplineTransformer(output_dim=6, diff_order=diff_order).fit(X) + + +def test_tensorproduct_rejects_diff_order_too_large_for_output_dim(): + X = np.random.default_rng(0).random((30, 2)) + with pytest.raises(InvalidParamError, match="diff_order=50 is too large"): + TensorProductSplineTransformer(output_dim=6, diff_order=50).fit(X) + + def test_tensorproduct_feature_names_out(): X = np.random.rand(20, 2) transformer = TensorProductSplineTransformer(output_dim=4) diff --git a/tests/expansion/spline/test_thinplate_transformer.py b/tests/expansion/spline/test_thinplate_transformer.py index 8b2eb67..5d4c9d8 100644 --- a/tests/expansion/spline/test_thinplate_transformer.py +++ b/tests/expansion/spline/test_thinplate_transformer.py @@ -2,7 +2,7 @@ import pytest from sklearn.exceptions import NotFittedError -from pretab.exceptions import InsufficientSamplesError, InvalidParamError +from pretab.exceptions import ConfigWarning, InsufficientSamplesError, InvalidParamError from pretab.transformers import ThinPlateSplineTransformer @@ -30,12 +30,25 @@ def test_tprs_penalty_shape_and_symmetry(): X = np.random.rand(25, 1) transformer = ThinPlateSplineTransformer(n_components=7, random_state=0) transformer.fit(X) - P = transformer.get_penalty_matrix() + with pytest.warns(ConfigWarning, match="experimental"): + P = transformer.get_penalty_matrix() assert P.shape[0] == P.shape[1] assert np.allclose(P, P.T, atol=1e-6) +def test_tprs_penalty_matrix_warns_experimental(): + # Regression test: the retained eigenvalues used as the penalty diagonal are + # not guaranteed non-negative (confirmed non-PSD in 10/10 random trials by + # the external audit), so this is marked experimental with a warning rather + # than silently returned as if it were a safe, PSD smoothing penalty. + rng = np.random.RandomState(0) + X = rng.uniform(size=(60, 3)) + transformer = ThinPlateSplineTransformer(n_components=6, random_state=0).fit(X) + with pytest.warns(ConfigWarning, match="experimental"): + transformer.get_penalty_matrix() + + def test_tprs_multivariate_is_supported(): rng = np.random.RandomState(0) X = rng.uniform(size=(60, 3)) From 4efdffea48ba1218f23ebb620a00ae3fc9935e51 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 4 Sep 2026 12:52:06 +0200 Subject: [PATCH 071/123] docs(spline): note thin-plate penalty is experimental --- docs/representations/spline_expansions.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/representations/spline_expansions.md b/docs/representations/spline_expansions.md index c3068b5..06c91c9 100644 --- a/docs/representations/spline_expansions.md +++ b/docs/representations/spline_expansions.md @@ -270,6 +270,14 @@ t.fit_transform(X2).shape # (200, 10): output width is n_components, not input Constructor highlights: `n_components=10`, `landmark_strategy="kmeans"`, `rank_strategy="eigen"`, `include_bias=False`, `random_state`. +```{warning} +Unlike the other spline families on this page, `get_penalty_matrix()` on +`ThinPlateSplineTransformer` is experimental: the retained eigenvalues are not guaranteed +non-negative, so the returned penalty is not guaranteed positive semi-definite, and a +`ConfigWarning` is raised on every call. `transform()` is unaffected; avoid this penalty for +actual smoothing regularization until it is fully fixed. +``` + ```{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 From c1ed2ff78b1342b901b79ae2e64e97ad94089629 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 4 Sep 2026 18:44:39 +0200 Subject: [PATCH 072/123] fix: drop unwired policy fields and encoding/embedding NaN bugs --- docs/representations/embeddings.md | 5 +- pretab/core/policy.py | 35 ++++++------- pretab/embedding/language.py | 15 ++++-- pretab/encoding/categorical/ordinal.py | 30 ++++++++--- .../test_language_embedding_transformer.py | 24 +++++++-- .../test_continuous_ordinal_transformer.py | 50 +++++++++++++++++++ 6 files changed, 128 insertions(+), 31 deletions(-) create mode 100644 tests/encoding/categorical/test_continuous_ordinal_transformer.py diff --git a/docs/representations/embeddings.md b/docs/representations/embeddings.md index 7057b6b..b4e48d9 100644 --- a/docs/representations/embeddings.md +++ b/docs/representations/embeddings.md @@ -21,8 +21,9 @@ Constructor highlights: `model_name="paraphrase-MiniLM-L3-v2"`, or pass a preloa (useful for tests or a custom embedding backend without pulling in `sentence-transformers`). Any object with a `.encode(X)` method works for the `transform` step; `embedding_dim_` is read from `get_sentence_embedding_dimension()` when the model exposes it (as a real -`SentenceTransformer` does), falling back to a `dim` attribute, or to `0` if neither is -present. The registry key is `pretrained`. +`SentenceTransformer` does), falling back to a `dim` attribute, or, if neither is present, +probed by calling `.encode()` once on a placeholder input during `fit`. The registry key is +`pretrained`. ```{important} Language embeddings require the optional `embeddings` extra, which pulls in diff --git a/pretab/core/policy.py b/pretab/core/policy.py index 1702f0f..0609921 100644 --- a/pretab/core/policy.py +++ b/pretab/core/policy.py @@ -3,17 +3,29 @@ :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`). +through, ranges extrapolate), 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`). + +Missing values and non-finite (``inf`` / ``-inf``) inputs are handled elsewhere: +see ``Preprocessor.missing_policy`` for missing-value handling, and note that +``inf`` / ``-inf`` always raise during input validation regardless of any +policy (there is no configurable axis for it). + +.. note:: + ``out_of_range`` is not yet reachable from any public API: no transformer + constructor accepts a ``policy`` argument, and ``Preprocessor.policy`` is + only used for its own top-level ``constant`` check, never threaded into + ``PreprocessorConfig`` or the transformers it builds. Wiring this through + (transformer constructors, the registry's ``allowed_args``, and + ``PreprocessorConfig``) is deferred to a follow-up; see the "Spline + expansions" section of ``dev/todo/release-1.0.0/bugfixes-1.0.0.md``. """ from __future__ import annotations @@ -32,16 +44,12 @@ "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, } @@ -51,21 +59,14 @@ class RepresentationPolicy: 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(): diff --git a/pretab/embedding/language.py b/pretab/embedding/language.py index 847ce28..e8cded9 100644 --- a/pretab/embedding/language.py +++ b/pretab/embedding/language.py @@ -79,12 +79,21 @@ def fit(self, X, y=None): X = np.asarray(X) self.n_features_in_ = X.shape[1] if X.ndim > 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. + # Prefer reading the embedding dim without calling encode(), so the + # common SentenceTransformer / 'dim'-stub paths keep a predictable call + # count; only fall back to probing when neither hook is available. if hasattr(self.model_, "get_sentence_embedding_dimension"): self.embedding_dim_ = int(self.model_.get_sentence_embedding_dimension()) + elif hasattr(self.model_, "dim"): + self.embedding_dim_ = int(self.model_.dim) else: - self.embedding_dim_ = int(getattr(self.model_, "dim", 0)) + # Neither introspection hook is available (e.g. a bare custom + # ``encode()``-only model): probe with a placeholder input, since + # this is the only generic way to learn the output width. Without + # this, embedding_dim_ silently defaulted to 0 while transform() + # still produced real-width output, a length mismatch. + probe = np.asarray(self.model_.encode(["__pretab_probe__"], convert_to_numpy=True)) + self.embedding_dim_ = int(probe.shape[-1]) return self def transform(self, X): diff --git a/pretab/encoding/categorical/ordinal.py b/pretab/encoding/categorical/ordinal.py index ba510bc..24621ef 100644 --- a/pretab/encoding/categorical/ordinal.py +++ b/pretab/encoding/categorical/ordinal.py @@ -6,12 +6,23 @@ from ...exceptions import PretabDataError +def _is_missing_value(value) -> bool: + """Return True for ``None`` or a NaN float; False for any other value.""" + if value is None: + return True + try: + return bool(np.isnan(value)) + except TypeError: + return False + + 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 - sorted order. Unknown or missing categories are mapped to ``0``. This is - useful for models that can only handle numerical input. + sorted order. Unknown, missing (``None`` or NaN), or unseen categories are + mapped to ``0``. This is useful for models that can only handle numerical + input. Attributes ---------- @@ -21,7 +32,7 @@ class ContinuousOrdinalTransformer(RepresentationSpecMixin, TransformerMixin, Ba Notes ----- Categories are numbered starting at ``1`` in sorted order; the value ``0`` is - reserved for categories not seen during ``fit`` (and for ``None``). + reserved for categories not seen during ``fit`` (and for ``None`` / NaN). Examples -------- @@ -55,9 +66,16 @@ def fit(self, X, y=None): X = np.asarray(X, dtype=object) if X.ndim == 1: X = X.reshape(-1, 1) - self.mapping_ = [{category: i + 1 for i, category in enumerate(np.unique(X[:, j]))} for j in range(X.shape[1])] - for mapping in self.mapping_: - mapping[None] = 0 # Assign 0 to unknown values + self.mapping_ = [] + for j in range(X.shape[1]): + column = X[:, j] + # Missing values (None / NaN) are excluded before sorting: np.unique + # cannot compare a NaN or None against a string category. + non_missing = np.array([v for v in column if not _is_missing_value(v)], dtype=object) + categories = np.unique(non_missing) if non_missing.size else np.array([], dtype=object) + mapping = {category: i + 1 for i, category in enumerate(categories)} + mapping[None] = 0 # Assign 0 to unknown/missing values + self.mapping_.append(mapping) self.n_features_in_ = len(self.mapping_) return self diff --git a/tests/embedding/test_language_embedding_transformer.py b/tests/embedding/test_language_embedding_transformer.py index 07c91fc..9142e3c 100644 --- a/tests/embedding/test_language_embedding_transformer.py +++ b/tests/embedding/test_language_embedding_transformer.py @@ -71,7 +71,9 @@ def test_fit_transform_invokes_model_encode(): transformer = LanguageEmbeddingTransformer(model=dummy) embeddings = transformer.fit_transform(np.array([["a"], ["b"], ["c"]])) assert embeddings.shape == (3, 4) - assert dummy.calls == 1 + # 1 call from fit()'s dimension probe (dummy has no 'dim'/ + # get_sentence_embedding_dimension) + 1 real encode call from transform(). + assert dummy.calls == 2 def test_transform_multi_column_preserves_row_count(): @@ -81,7 +83,21 @@ def test_transform_multi_column_preserves_row_count(): transformer = LanguageEmbeddingTransformer(model=dummy) embeddings = transformer.fit_transform(np.array([["a", "b"], ["c", "d"], ["e", "f"]])) assert embeddings.shape == (3, 8) # 2 columns x 4-dim embedding - assert dummy.calls == 2 # one encode call per column + assert dummy.calls == 3 # 1 dimension probe (fit) + 2 real encode calls (transform, one per column) + + +def test_fit_infers_embedding_dim_from_probe_without_introspection_hook(): + # Regression test: a model exposing only encode() (no 'dim' / + # get_sentence_embedding_dimension) used to leave embedding_dim_ at the + # default 0, so get_feature_names_out() returned an empty array while + # transform() still produced real-width output, a length mismatch. + dummy = _DummyModel() + transformer = LanguageEmbeddingTransformer(model=dummy) + Xt = transformer.fit(np.array([["a"], ["b"]])).transform(np.array([["a"], ["b"]])) + + assert transformer.embedding_dim_ == 4 # matches _DummyModel.encode's output width + names = transformer.get_feature_names_out() + assert len(names) == Xt.shape[1] @pytest.mark.parametrize( @@ -97,7 +113,9 @@ def test_transform_rejects_fitted_feature_count_mismatch(X_transform): with pytest.raises(PretabDataError, match="is expecting 2 features"): transformer.transform(X_transform) - assert dummy.calls == 0 + # Only fit()'s dimension probe ran; the shape check in transform() rejects + # the mismatched input before any real encode() call. + assert dummy.calls == 1 def test_fit_without_dependency_raises(monkeypatch): diff --git a/tests/encoding/categorical/test_continuous_ordinal_transformer.py b/tests/encoding/categorical/test_continuous_ordinal_transformer.py new file mode 100644 index 0000000..5f3e6aa --- /dev/null +++ b/tests/encoding/categorical/test_continuous_ordinal_transformer.py @@ -0,0 +1,50 @@ +import numpy as np +import pandas as pd + +from pretab.transformers import ContinuousOrdinalTransformer + + +def test_continuous_ordinal_fit_transform_handles_nan(): + # Regression test: np.unique() on an object column crashes when it mixes + # strings with NaN/None, despite the transformer declaring allow_nan=True. + X = np.array([["a"], ["b"], [np.nan], ["a"]], dtype=object) + transformer = ContinuousOrdinalTransformer() + Xt = transformer.fit_transform(X) + + assert Xt.ravel().tolist() == [1, 2, 0, 1] + + +def test_continuous_ordinal_fit_transform_handles_none(): + X = np.array([["a"], ["b"], [None], ["a"]], dtype=object) + transformer = ContinuousOrdinalTransformer() + Xt = transformer.fit_transform(X) + + assert Xt.ravel().tolist() == [1, 2, 0, 1] + + +def test_continuous_ordinal_handles_pandas_categorical_export(): + # A realistic pandas categorical export: object dtype, mixed strings and a + # missing marker (NaN), round-tripping through fit/transform without error. + df = pd.DataFrame({"col": pd.Categorical(["x", "y", None, "x", "z"])}) + X = df["col"].to_numpy(dtype=object).reshape(-1, 1) + + transformer = ContinuousOrdinalTransformer() + Xt = transformer.fit(X).transform(X) + + assert Xt.shape == (5, 1) + assert Xt[2, 0] == 0 # the missing row maps to the reserved code + + +def test_continuous_ordinal_allow_nan_tag_is_exercised(): + tags = ContinuousOrdinalTransformer().__sklearn_tags__() + assert tags.input_tags.allow_nan is True + + # Not just declared: actually fitting/transforming NaN must not raise. + X = np.array([["a"], [np.nan], ["b"]], dtype=object) + ContinuousOrdinalTransformer().fit_transform(X) + + +def test_continuous_ordinal_unseen_category_maps_to_zero(): + transformer = ContinuousOrdinalTransformer().fit(np.array([["a"], ["b"]], dtype=object)) + Xt = transformer.transform(np.array([["c"]], dtype=object)) + assert Xt.ravel().tolist() == [0] From 150a01273dea535c817913040793e96e652633f1 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Fri, 4 Sep 2026 18:48:49 +0200 Subject: [PATCH 073/123] fix: use configured dtype in output-budget memory estimate --- pretab/preprocessor.py | 9 +++++++-- tests/integration/test_output_budget.py | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index 21a8b4d..83c9537 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -741,8 +741,13 @@ def output_dims_(self) -> dict: return dims def _output_itemsize(self) -> int: - """Bytes per element of the dense transformed array (float64 for now).""" - return np.dtype(np.float64).itemsize + """Bytes per element of the dense transformed array. + + Reflects the configured ``dtype`` when set (the cast :meth:`transform` + actually applies); falls back to ``float64``, the natural output dtype + of most representations when no cast is requested. + """ + return np.dtype(self.dtype if self.dtype is not None else np.float64).itemsize def estimate_output_shape(self, X) -> tuple: """Estimate the shape of the dense transformed array for ``X``. diff --git a/tests/integration/test_output_budget.py b/tests/integration/test_output_budget.py index d267f63..fad02c8 100644 --- a/tests/integration/test_output_budget.py +++ b/tests/integration/test_output_budget.py @@ -55,6 +55,25 @@ def test_estimate_memory_is_rows_times_cols_times_itemsize(frame, y): assert pre.estimate_memory(frame) == n_rows * n_cols * np.dtype(np.float64).itemsize +def test_estimate_memory_reflects_configured_dtype(frame, y): + # Regression test: _output_itemsize() used to hardcode float64 regardless of + # `dtype`, so estimate_memory() was 2x too high for a float32 configuration. + pre = _bspline(dtype=np.float32).fit(frame, y) + out = pre.transform(frame, return_array=True) + assert isinstance(out, np.ndarray) + assert out.dtype == np.float32 + assert pre.estimate_memory(frame) == out.nbytes + + +def test_max_dense_memory_does_not_false_positive_reject_float32(frame, y): + # A budget that only fits the real float32 output (not the float64 estimate + # the old bug would have used) must still be accepted, not rejected. + pre = _bspline(dtype=np.float32).fit(frame, y) + real_out = pre.transform(frame, return_array=True) + assert isinstance(real_out, np.ndarray) + _bspline(dtype=np.float32, max_dense_memory=real_out.nbytes, overflow_policy="error").fit(frame, y) + + def test_estimate_shape_scales_with_new_rows(frame, y): pre = _bspline().fit(frame, y) bigger = pd.concat([frame] * 3, ignore_index=True) From 769e7b105b15b87e9b75949fa803c690058808c4 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sat, 5 Sep 2026 18:12:46 +0200 Subject: [PATCH 074/123] feat: default Preprocessor output to a single array --- README.md | 16 +- docs/core_concepts/configuration.md | 1 + docs/core_concepts/outputs_and_inspection.md | 237 +++++++++++++++++- docs/getting_started/choosing_an_interface.md | 28 ++- docs/getting_started/quickstart.md | 14 +- docs/homepage.md | 14 +- docs/tutorials/nonlinear_regression.md | 12 +- docs/tutorials/sklearn_pipeline.md | 22 +- docs/tutorials/target_aware_classification.md | 4 +- pretab/core/policy.py | 5 +- pretab/preprocessor.py | 78 ++++-- tests/compose/test_method_aliases.py | 2 +- tests/integration/test_adaptive_output_dim.py | 4 +- tests/integration/test_output_format.py | 14 +- tests/integration/test_output_structure.py | 67 +++++ tests/integration/test_preprocessor.py | 22 +- 16 files changed, 453 insertions(+), 87 deletions(-) create mode 100644 tests/integration/test_output_structure.py diff --git a/README.md b/README.md index 96facae..4a12f18 100644 --- a/README.md +++ b/README.md @@ -73,10 +73,10 @@ y = np.random.randn(100) # Global strategies: PLE for numerics, integer codes for categoricals preprocessor = Preprocessor(numerical_method="ple", categorical_method="int") -X = preprocessor.fit_transform(df, y) # dict of transformed feature blocks +X = preprocessor.fit_transform(df, y) # single stacked array, one row per sample -print({k: v.shape for k, v in X.items()}) -# {'num_age': (100, 7), 'num_income': (100, 7), 'cat_city': (100, 1)} +print(X.shape) +# (100, 15) ``` > **Note:** PreTab accepts a `pandas.DataFrame` or a `numpy.ndarray` and infers numerical @@ -209,8 +209,8 @@ preprocessor = Preprocessor( task="regression", ) -X_dict = preprocessor.fit_transform(df, y) # {"num_age": ..., "cat_city": ...} -X_array = preprocessor.transform(df, return_array=True) # single stacked ndarray +X_array = preprocessor.fit_transform(df, y) # single stacked ndarray +X_dict = preprocessor.transform(df, return_array=False) # {"num_age": ..., "cat_city": ...} preprocessor.get_feature_info(verbose=True) # inspect resolved strategies ``` @@ -227,8 +227,10 @@ experience numerical imputer -> minmax -> quantile 1 - city categorical imputer -> onehot -> to_float 4 4 ``` -> **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`. +> **Note:** `transform` returns a single stacked array by default, so a `Preprocessor` drops +> straight into a plain `sklearn.pipeline.Pipeline`. Pass `output_structure="blocks"` (or +> `return_array=False` for a single call) for the dict-of-feature-blocks form instead (keys +> prefixed `num_` and `cat_`). ### Standalone transformers diff --git a/docs/core_concepts/configuration.md b/docs/core_concepts/configuration.md index 558c9ff..713d302 100644 --- a/docs/core_concepts/configuration.md +++ b/docs/core_concepts/configuration.md @@ -122,6 +122,7 @@ in depth. | `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) | +| `output_structure` | `"matrix"` | [Outputs and inspection](outputs_and_inspection.md) | | `random_state` | `None` | [Reproducibility](reproducibility.md) | ## Where to go next diff --git a/docs/core_concepts/outputs_and_inspection.md b/docs/core_concepts/outputs_and_inspection.md index 26950fb..620c8aa 100644 --- a/docs/core_concepts/outputs_and_inspection.md +++ b/docs/core_concepts/outputs_and_inspection.md @@ -7,22 +7,90 @@ 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. +`fit_transform` and `transform` return a single stacked `numpy.ndarray` by default. Pass +`output_structure="blocks"` (or `return_array=False` for a single call) to receive a +dictionary that maps each feature to its transformed block instead, with keys prefixed +`num_` or `cat_`. ```python -X_dict = pre.fit_transform(df, y) # {"num_age": ..., "cat_city": ...} -X_array = pre.transform(df, return_array=True) # one stacked ndarray +X_array = pre.fit_transform(df, y) # one stacked ndarray +X_dict = Preprocessor(output_structure="blocks").fit_transform(df, y) # {"num_age": ..., "cat_city": ...} ``` ```{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. +The array form is what a plain scikit-learn estimator expects, and is what lets a +`Preprocessor` drop straight into a `Pipeline`. The dict form is convenient for inspection and +for feeding blocks to different model heads; pass `return_array` explicitly to override +`output_structure` for a single call without changing the estimator's configuration. +``` + +### Example: one column expands into several + +A single input column rarely maps to a single output column. Most numerical methods (splines, +feature maps, PLE) expand one feature into `output_dim` basis columns, so the matrix width +grows well beyond the number of input columns. A B-spline makes this concrete: `output_dim=6` +turns the one `age` column into 6 local basis functions, each capturing a different region of +its range. + +```python +df = pd.DataFrame({"age": [25, 40, 63, 51]}) +y = [0.1, 0.9, 0.3, 0.6] + +pre = Preprocessor(numerical_method="bspline", output_dim=6, + target_aware=False, placement_strategy="quantile").fit(df, y) +out = pre.transform(df) +out.shape +``` + +```text +(4, 6) +``` + +```text +array([[1. , 0. , 0. , 0. , 0. , 0. ], + [0. , 0.179, 0.593, 0.228, 0. , 0. ], + [0. , 0. , 0. , 0. , 0. , 1. ], + [0. , 0. , 0.165, 0.607, 0.229, 0. ]]) +``` + +4 input rows, 1 input column, but 6 output columns: every row's `age` value is spread across +the basis functions whose local support it falls into (each row sums to 1, since a B-spline +basis is a partition of unity). `get_feature_names_out()` shows exactly where each column came +from: + +```python +list(pre.get_feature_names_out()) +``` + +```text +['num_age_bs0', 'num_age_bs1', 'num_age_bs2', 'num_age_bs3', 'num_age_bs4', 'num_age_bs5'] +``` + +All six still trace back to the single `age` input, which is exactly what `output_structure= +"blocks"` reflects: one dict entry per **input** feature, holding its full expanded block, not +one entry per output column. + +```python +pre_blocks = Preprocessor(numerical_method="bspline", output_dim=6, + target_aware=False, placement_strategy="quantile", + output_structure="blocks").fit(df, y) +pre_blocks.transform(df)["num_age"].shape +``` + +```text +(4, 6) +``` + +```{tip} +This is why a wide expansion (a spline or feature map with a large `output_dim`, or several +expanded columns) can produce a much wider matrix than the input `DataFrame` had columns. +Use `get_feature_info(verbose=True)` (below) or `estimate_output_shape(df)` to see the total +width before committing, especially with several expanded columns at once. ``` ## Output format and dtype + Two parameters control the physical layout of the stacked output. `output_format` @@ -57,6 +125,161 @@ Polars output is loaded lazily. If polars is not installed, requesting it raises `OptionalDependencyError` rather than failing deep in the call stack. ``` +## Choosing your output settings + +Four things independently affect what `transform` / `fit_transform` return: `output_structure` +(top-level shape), `return_array` (a per-call override of it), `output_format` (dense vs. +sparse), and `set_output` (scikit-learn's own DataFrame protocol). This section is the +decision guide: what each one controls, how they interact, and which combination fits a given +use case, with the literal output shown for each rather than just a description of it. + +The examples below all share the same tiny, fixed input so the printed output is directly +comparable: + +```python +import numpy as np +import pandas as pd +from pretab import Preprocessor + +df = pd.DataFrame({"age": [25, 40, 63], "city": ["A", "B", "A"]}) +y = [0.1, 0.9, 0.3] +``` + +### Parameter impact at a glance + +| Parameter | Values | Controls | Set at | +| --- | --- | --- | --- | +| `output_structure` | `"matrix"` (default), `"blocks"` | Whether `transform()` returns one stacked array or a dict of per-feature blocks, when `return_array` is not passed. | Constructor | +| `return_array` | `True`, `False`, `None` (default) | Overrides `output_structure` for a single call. `None` resolves from `output_structure`. | Per call | +| `output_format` | `"dense"` (default), `"sparse"`, `"auto"` | Whether the array (or each block) is a NumPy array or a SciPy CSR matrix. `"auto"` picks sparse only when it saves memory. | Constructor | +| `dtype` | e.g. `numpy.float32`, `None` (default) | Floating-point precision of the output; also what `estimate_memory()` assumes. | Constructor | +| `set_output(transform=...)` | `"default"`, `"pandas"`, `"polars"` | Wraps the array in a DataFrame. **Takes priority over `output_structure` and `return_array` entirely**: once set, `transform()` always returns a DataFrame, never a dict or a bare array. | Method call, before `transform` | + +### Which setting should I use? + +Feeding a plain scikit-learn estimator or building a `Pipeline` +: Use the default (`output_structure="matrix"`, no `return_array` override). `Preprocessor()` + composes directly: `Pipeline([("pretab", Preprocessor(...)), ("model", Ridge())])` just + works, since `transform(X)` already returns a single array. + + ```python + pre = Preprocessor(numerical_method="minmax", categorical_method="one-hot").fit(df, y) + out = pre.transform(df) + type(out), out.shape + ``` + + ```text + (, (3, 3)) + ``` + + ```text + array([[0. , 1. , 0. ], + [0.39473684, 0. , 1. ], + [1. , 1. , 0. ]]) + ``` + + Column order matches `get_feature_names_out()`: the scaled `age`, then the one-hot `city_A` + / `city_B` columns. + +Inspecting per-feature blocks, or feeding different blocks to different model heads +: Set `output_structure="blocks"` on the constructor (so it stays the estimator's default + everywhere it's reused), or pass `return_array=False` for a one-off call without changing + the estimator's configuration. + + ```python + pre = Preprocessor(numerical_method="minmax", categorical_method="one-hot", + output_structure="blocks").fit(df, y) + out = pre.transform(df) + out + ``` + + ```text + {'num_age': array([[0. ], + [0.39473684], + [1. ]]), + 'cat_city': array([[1., 0.], + [0., 1.], + [1., 0.]])} + ``` + + The same estimator still returns an array for a single call if you ask for one: + + ```python + pre.transform(df, return_array=True) # ndarray, shape (3, 3); this call only + ``` + +Passing external `embeddings` +: **Embeddings require dict output.** They are separate named blocks, so they cannot be + stacked into a single matrix. Use `output_structure="blocks"` (or `return_array=False`) on + every `transform` call that passes `embeddings`; the default `"matrix"` raises + `IncompatibleParamsError` the moment `embeddings` is supplied. + + ```python + emb = np.array([[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]]) + pre = Preprocessor(numerical_method="minmax", categorical_method="one-hot", + output_structure="blocks").fit(df, y, embeddings=emb) + out = pre.transform(df, embeddings=emb) + sorted(out.keys()), out["embedding_1"].shape + ``` + + ```text + (['cat_city', 'embedding_1', 'num_age'], (3, 2)) + ``` + +Large, mostly one-hot-encoded categorical data +: Set `output_format="sparse"` (or `"auto"` to let PreTab decide per fit). This applies + independently of `output_structure`: a sparse `"matrix"` is a single stacked + `scipy.sparse.csr_matrix`, and a sparse `"blocks"` dict holds a CSR matrix per feature. + + ```python + pre = Preprocessor(numerical_method="minmax", categorical_method="one-hot", + output_format="sparse").fit(df, y) + pre.transform(df) + ``` + + ```text + + ``` + +Halving memory on a large dataset +: Set `dtype=numpy.float32`. `estimate_memory()` and the `max_dense_memory` budget both + reflect the configured `dtype`, not a hardcoded `float64` assumption, so budgets sized for + the real, cast output are honored correctly. + + ```python + pre = Preprocessor(numerical_method="minmax", categorical_method="one-hot", + dtype=np.float32).fit(df, y) + pre.transform(df).dtype + ``` + + ```text + dtype('float32') + ``` + +Feeding a library that expects a DataFrame, or wanting column names attached to the output +: Call `pre.set_output(transform="pandas")` (or `"polars"`). This overrides everything else: + `transform()` always returns a DataFrame from that point on, regardless of `output_structure` + or any `return_array` passed to an individual call. + + ```python + pre = Preprocessor(numerical_method="minmax", categorical_method="one-hot").fit(df, y) + pre.set_output(transform="pandas").transform(df) + ``` + + ```text + num_age cat_city_A cat_city_B + 0 0.000000 1.0 0.0 + 1 0.394737 0.0 1.0 + 2 1.000000 1.0 0.0 + ``` + +```{warning} +`set_output` always wins. If a downstream step unexpectedly receives a DataFrame instead of +the array or dict you configured, check whether `set_output` was called anywhere upstream +(including by a cloned copy inside a `Pipeline`/`GridSearchCV`). +``` + ## Feature names Every representation names its output columns, and the names are stable and descriptive. diff --git a/docs/getting_started/choosing_an_interface.md b/docs/getting_started/choosing_an_interface.md index 2663b79..3dba5f4 100644 --- a/docs/getting_started/choosing_an_interface.md +++ b/docs/getting_started/choosing_an_interface.md @@ -11,7 +11,7 @@ ergonomics, not capability. This page helps you pick. :::{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. +column from a single configuration object. Returns a single stacked array by default. ::: :::{grid-item-card} Standalone transformers @@ -40,7 +40,7 @@ pre = Preprocessor(feature_preprocessing={ "income": "ple", "city": "one-hot", }) -X = pre.fit_transform(df, y) # dict of blocks, or return_array=True for one matrix +X = pre.fit_transform(df, y) # one stacked array, or output_structure="blocks" for a dict ``` ## Reach for standalone transformers when @@ -65,10 +65,26 @@ 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`. +The `Preprocessor` returns a single stacked array by default, so it drops directly into a +plain `Pipeline`/`ColumnTransformer` like any other scikit-learn transformer. Pass +`output_structure="blocks"` (or `return_array=False` for a single call) when you want the +dict-of-feature-blocks form instead, for inspection or per-block downstream heads. +``` + +A `Preprocessor` composes the same way as the standalone transformers above: + +```python +from sklearn.pipeline import Pipeline +from sklearn.linear_model import Ridge + +from pretab import Preprocessor + +model = Pipeline([ + ("pretab", Preprocessor(feature_preprocessing={"age": "naturalspline", "income": "ple"})), + ("ridge", Ridge()), +]) +model.fit(df, y) +model.predict(df) ``` ## A note on multivariate methods diff --git a/docs/getting_started/quickstart.md b/docs/getting_started/quickstart.md index 6adf36b..a84eb59 100644 --- a/docs/getting_started/quickstart.md +++ b/docs/getting_started/quickstart.md @@ -16,8 +16,8 @@ LightGBM-based placement. ## Fit a `Preprocessor` 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. +categorical, and applies a strategy per column. It returns a single stacked array by default, +or a dict of per-feature blocks on request. ```python import numpy as np @@ -44,9 +44,9 @@ config = { } pre = Preprocessor(feature_preprocessing=config, task="regression", random_state=0) -# 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 single stacked array +X = pre.fit_transform(df, y) +X.shape ``` ```{tip} @@ -55,10 +55,10 @@ When no per-feature config is given, the `Preprocessor` falls back to its global [Configuration](../core_concepts/configuration.md) for every knob. ``` -Ask for a single stacked matrix instead when you feed a plain estimator: +Ask for a dict of per-feature blocks instead, for inspection or per-block downstream heads: ```python -X = pre.transform(df, return_array=True) # one ndarray, one row per sample +X_dict = pre.transform(df, return_array=False) # {"num_age": ..., "cat_city": ...} ``` ## Inspect what was built diff --git a/docs/homepage.md b/docs/homepage.md index 2796698..e15bf54 100644 --- a/docs/homepage.md +++ b/docs/homepage.md @@ -56,15 +56,15 @@ y = np.random.randn(100) # One strategy per feature type: PLE for numerics, integer codes for categoricals pre = Preprocessor(numerical_method="ple", categorical_method="int") -X = pre.fit_transform(df, y) # dict of model-ready feature blocks - -{k: v.shape for k, v in X.items()} -# {'num_age': (100, 7), 'num_income': (100, 7), 'cat_city': (100, 1)} +X = pre.fit_transform(df, y) # single stacked, model-ready array +X.shape +# (100, 15) ``` -`Preprocessor` detects the column types, fits a strategy per column, and returns model-ready -arrays, either as a dict of blocks or, with `return_array=True`, a single stacked matrix. -Inspect the resolved layout at any time with `get_feature_info(verbose=True)`: +`Preprocessor` detects the column types, fits a strategy per column, and returns a single +stacked, model-ready array by default, so it drops straight into a plain +`sklearn.pipeline.Pipeline`; pass `output_structure="blocks"` for a dict of per-feature blocks +instead. Inspect the resolved layout at any time with `get_feature_info(verbose=True)`: ```text feature kind pipeline dim cats diff --git a/docs/tutorials/nonlinear_regression.md b/docs/tutorials/nonlinear_regression.md index 845199c..cd2b323 100644 --- a/docs/tutorials/nonlinear_regression.md +++ b/docs/tutorials/nonlinear_regression.md @@ -108,8 +108,8 @@ pre = Preprocessor( output_dim=12, ) -X_tr = pre.fit_transform(X_train, y_train, return_array=True) -X_te = pre.transform(X_test, return_array=True) +X_tr = pre.fit_transform(X_train, y_train) +X_te = pre.transform(X_test) model = Ridge(alpha=1.0).fit(X_tr, y_train) pred = model.predict(X_te) @@ -130,10 +130,10 @@ nonlinear structure. The $R^2$ jumps from `0.124` to `0.979` and the mean absolu from `11.20` to `1.85`. ```{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). +`Preprocessor.transform` returns a single stacked array by default, so it drops straight into +a plain scikit-learn estimator or `Pipeline`. Pass `output_structure="blocks"` (or +`return_array=False` for a single call) for the dict-of-feature-blocks form instead. See the +[sklearn pipeline tutorial](sklearn_pipeline.md) for wiring each column's transformer by hand. ``` ## What actually changed diff --git a/docs/tutorials/sklearn_pipeline.md b/docs/tutorials/sklearn_pipeline.md index a04e2fa..98fa857 100644 --- a/docs/tutorials/sklearn_pipeline.md +++ b/docs/tutorials/sklearn_pipeline.md @@ -1,10 +1,11 @@ # Inside an sklearn Pipeline -The high-level `Preprocessor` returns a dict by default, so it is used as an explicit -feature-building step (call it with `return_array=True`, then fit the model on the arrays). -The **standalone transformers**, on the other hand, return plain arrays and follow the -`sklearn` API exactly, so they drop straight into a `ColumnTransformer` and `Pipeline`, and -work with `cross_val_score`, `GridSearchCV`, and every other `sklearn` utility. +The **standalone transformers** return plain arrays and follow the `sklearn` API exactly, so +they drop straight into a `ColumnTransformer` and `Pipeline`, and work with +`cross_val_score`, `GridSearchCV`, and every other `sklearn` utility. The high-level +`Preprocessor` composes the same way (it returns a single stacked array by default), but this +page focuses on wiring each column's transformer by hand for fine-grained, per-transformer +control: addressable hyperparameters (`step__param`), one estimator per column. This tutorial builds the regression task from the [nonlinear regression tutorial](nonlinear_regression.md) as a single, self-contained @@ -110,11 +111,12 @@ Every pretab transformer participates in the search grid just like a native `skl ## When to use which - **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 [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. + cross-validation and grid search, with each column's transformer addressable individually + (`step__param`). Reach for them when you want fine control over one column's transformer. +- **The `Preprocessor`** (the [nonlinear regression tutorial](nonlinear_regression.md)) also + composes inside a `Pipeline` (it returns a single stacked array by default), 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 diff --git a/docs/tutorials/target_aware_classification.md b/docs/tutorials/target_aware_classification.md index d9c4d69..25780f7 100644 --- a/docs/tutorials/target_aware_classification.md +++ b/docs/tutorials/target_aware_classification.md @@ -93,8 +93,8 @@ pre = Preprocessor( output_dim=10, ) -X_tr = pre.fit_transform(X_train, y_train, return_array=True) -X_te = pre.transform(X_test, return_array=True) +X_tr = pre.fit_transform(X_train, y_train) +X_te = pre.transform(X_test) clf = LogisticRegression(max_iter=1000).fit(X_tr, y_train) proba = clf.predict_proba(X_te)[:, 1] diff --git a/pretab/core/policy.py b/pretab/core/policy.py index 0609921..d53097c 100644 --- a/pretab/core/policy.py +++ b/pretab/core/policy.py @@ -3,8 +3,8 @@ :class:`RepresentationPolicy` names, in one place, how every transformer reacts to the recurring edge cases that would otherwise diverge silently per family: -* ``constant`` -- zero-variance input columns (``"error"`` / ``"warn"`` / ``"allow"``). -* ``out_of_range`` -- values at ``transform`` outside the fitted range +* ``constant``: zero-variance input columns (``"error"`` / ``"warn"`` / ``"allow"``). +* ``out_of_range``: values at ``transform`` outside the fitted range (``"error"`` / ``"warn"`` / ``"clip"`` / ``"extrapolate"``). The defaults reproduce the library's historical behaviour (constant columns pass @@ -28,6 +28,7 @@ expansions" section of ``dev/todo/release-1.0.0/bugfixes-1.0.0.md``. """ + from __future__ import annotations import warnings diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index 83c9537..ed265e7 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -233,6 +233,14 @@ class Preprocessor(TransformerMixin, BaseEstimator): :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_structure : {"matrix", "blocks"}, default="matrix" + Top-level shape ``transform`` / ``fit_transform`` return when ``return_array`` is not + passed explicitly. ``"matrix"`` (the default) returns a single stacked array, so a + plain ``sklearn.pipeline.Pipeline([("pretab", Preprocessor(...)), ("model", + estimator)])`` composes like any other transformer. ``"blocks"`` returns the dict of + per-feature blocks instead (the library's pre-1.0 default), useful for inspection or + when a downstream step consumes named blocks directly. Passing ``return_array`` + explicitly to ``transform`` / ``fit_transform`` always overrides this setting. 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 @@ -307,8 +315,10 @@ class Preprocessor(TransformerMixin, BaseEstimator): ``"dummy"`` -> ``"one-hot"``, ``"ordinal"`` / ``"label"`` -> ``"int"``, ``"poly"`` -> ``"polynomial"``, ``"thin-plate"`` -> ``"tprs"``, and ``"passthrough"`` -> ``"none"``. - ``transform`` returns a dict of per-feature blocks keyed ``num_`` / ``cat_`` by - default, or a single stacked array when ``return_array=True``. + ``transform`` returns a single stacked array by default (``output_structure="matrix"``), + or a dict of per-feature blocks keyed ``num_`` / ``cat_`` when + ``output_structure="blocks"``. Passing ``return_array`` explicitly to ``transform`` / + ``fit_transform`` always overrides ``output_structure`` for that call. Examples -------- @@ -320,8 +330,8 @@ class Preprocessor(TransformerMixin, BaseEstimator): >>> y = [0.1, 0.4, 0.9, 1.2] >>> pre = Preprocessor() >>> out = pre.fit_transform(df, y) - >>> sorted(out.keys()) - ['cat_gender', 'num_age'] + >>> out.ndim + 2 Cubic-spline basis for numerics with one-hot encoded categoricals: @@ -340,13 +350,20 @@ class Preprocessor(TransformerMixin, BaseEstimator): >>> pre = Preprocessor(feature_preprocessing={"age": "pspline", "gender": "one-hot"}) >>> out = pre.fit_transform(df, y) - Data-driven (adaptive) width, returned as a single stacked array: + Data-driven (adaptive) width: >>> pre = Preprocessor(numerical_method="ple", adaptive=True, ... min_output_dim=4, max_output_dim=12) - >>> arr = pre.fit_transform(df, y, return_array=True) + >>> arr = pre.fit_transform(df, y) >>> arr.ndim 2 + + Per-feature blocks instead of a single matrix: + + >>> pre = Preprocessor(output_structure="blocks") + >>> out = pre.fit_transform(df, y) + >>> sorted(out.keys()) + ['cat_gender', 'num_age'] """ def __init__( @@ -375,6 +392,7 @@ def __init__( max_features_per_input=None, max_dense_memory=None, overflow_policy="error", + output_structure="matrix", output_format="dense", dtype=None, verbose=0, @@ -411,6 +429,7 @@ def __init__( self.max_features_per_input = max_features_per_input self.max_dense_memory = max_dense_memory self.overflow_policy = overflow_policy + self.output_structure = output_structure self.output_format = output_format self.dtype = dtype self.verbose = verbose @@ -499,6 +518,16 @@ def fit(self, X, y=None, embeddings=None): valid=set(valid_formats), ) + valid_structures = ("matrix", "blocks") + if self.output_structure not in valid_structures: + raise invalid_param_error( + type(self).__name__, + "output_structure", + self.output_structure, + "must be one of 'matrix', 'blocks'", + valid=set(valid_structures), + ) + # Ask ColumnTransformer to assemble the representation in the requested # container whenever its component outputs permit it. In particular, # explicit sparse output must not densely stack sparse one-hot blocks. @@ -533,7 +562,7 @@ def fit(self, X, y=None, embeddings=None): return self - def transform(self, X, embeddings=None, return_array=False): + def transform(self, X, embeddings=None, return_array=None): """ Transform the input data using the fitted column transformer. @@ -543,18 +572,22 @@ def transform(self, X, embeddings=None, return_array=False): Input features to transform. embeddings : np.ndarray or list of np.ndarray, optional External embeddings to attach to dictionary output. Required when - embeddings were supplied during ``fit`` and unsupported when - ``return_array=True`` or :meth:`set_output` requests a DataFrame. - return_array : bool, default=False - If True, return a single stacked NumPy array. If False, return a dict of transformed arrays. + embeddings were supplied during ``fit`` and unsupported when the + resolved output is a single array or :meth:`set_output` requests a DataFrame. + return_array : bool or None, default=None + If True, return a single stacked NumPy array; if False, return a dict of + transformed arrays. ``None`` (the default) resolves from ``output_structure``: + ``"matrix"`` behaves like ``True``, ``"blocks"`` like ``False``. Pass this + explicitly to override ``output_structure`` for a single call. Returns ------- 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`. + Transformed data. A single stacked array by default (``output_structure= + "matrix"``); a dict of per-feature blocks when ``output_structure="blocks"`` + (or ``return_array=False``); 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) @@ -564,8 +597,10 @@ def transform(self, X, embeddings=None, return_array=False): if self.missing_policy == "error": self._reject_missing(X) + resolved_return_array = (self.output_structure == "matrix") if return_array is None else return_array + container = _get_output_config("transform", self)["dense"] - output_kind = container if container in ("pandas", "polars") else ("array" if return_array else "dict") + output_kind = container if container in ("pandas", "polars") else ("array" if resolved_return_array else "dict") validate_embedding_request(embeddings, expected=self.embeddings_, output_kind=output_kind) transformed_X = self.column_transformer_.transform(X) @@ -579,10 +614,10 @@ def transform(self, X, embeddings=None, return_array=False): 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_) + slices = None if resolved_return_array else get_output_slices(self.column_transformer_) return format_output( transformed_X, - return_array=return_array, + return_array=resolved_return_array, slices=slices, embeddings=embeddings, embeddings_expected=self.embeddings_, @@ -590,7 +625,7 @@ def transform(self, X, embeddings=None, return_array=False): output_format=fmt, ) - def fit_transform(self, X, y=None, embeddings=None, return_array=False): + def fit_transform(self, X, y=None, embeddings=None, return_array=None): """ Convenience method that fits the preprocessor and transforms the data. @@ -602,8 +637,9 @@ def fit_transform(self, X, y=None, embeddings=None, return_array=False): Target values. embeddings : np.ndarray or list of np.ndarray, optional Optional embedding arrays. - return_array : bool, default=False - Whether to return a stacked NumPy array or a dictionary of arrays. + return_array : bool or None, default=None + Whether to return a stacked NumPy array or a dictionary of arrays. ``None`` + (the default) resolves from ``output_structure``. Returns ------- diff --git a/tests/compose/test_method_aliases.py b/tests/compose/test_method_aliases.py index 11c31d5..1ba1afb 100644 --- a/tests/compose/test_method_aliases.py +++ b/tests/compose/test_method_aliases.py @@ -148,7 +148,7 @@ def test_categorical_alias_matches_canonical_output(sample_data, alias, canonica def test_alias_in_feature_preprocessing(sample_data): X, y = sample_data - pre = Preprocessor(feature_preprocessing={"num1": "STD", "cat1": "OneHot"}) + pre = Preprocessor(feature_preprocessing={"num1": "STD", "cat1": "OneHot"}, output_structure="blocks") out = pre.fit_transform(X, y) assert "num_num1" in out assert "cat_cat1" in out diff --git a/tests/integration/test_adaptive_output_dim.py b/tests/integration/test_adaptive_output_dim.py index 2e834ba..3721e13 100644 --- a/tests/integration/test_adaptive_output_dim.py +++ b/tests/integration/test_adaptive_output_dim.py @@ -97,7 +97,7 @@ def data(): def _num_width(X, y, method, **kwargs): """Fit a Preprocessor on one numerical feature and return its block width.""" pre = Preprocessor(numerical_method=method, categorical_method="none", **kwargs) - out = cast("dict[str, np.ndarray]", pre.fit(X, y).transform(X)) + out = cast("dict[str, np.ndarray]", pre.fit(X, y).transform(X, return_array=False)) return out["num_x"].shape[1] @@ -140,7 +140,7 @@ def test_custombin_respects_bin_count(data, output_dim): """Numerical ``custombin`` yields a single column of at most output_dim codes.""" X, y = data pre = Preprocessor(numerical_method="custombin", categorical_method="none", output_dim=output_dim) - block = cast("dict[str, np.ndarray]", pre.fit(X, y).transform(X))["num_x"] + block = cast("dict[str, np.ndarray]", pre.fit(X, y).transform(X, return_array=False))["num_x"] assert block.shape[1] == 1 assert int(block.max()) < output_dim assert len(np.unique(block)) <= output_dim diff --git a/tests/integration/test_output_format.py b/tests/integration/test_output_format.py index 540c39e..c169571 100644 --- a/tests/integration/test_output_format.py +++ b/tests/integration/test_output_format.py @@ -48,7 +48,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) + out = p.transform(frame, return_array=False) assert isinstance(out, dict) assert all(isinstance(v, np.ndarray) for v in out.values()) @@ -69,7 +69,7 @@ def test_sparse_return_array_is_csr(frame, y): def test_sparse_dict_blocks_are_csr(frame, y): p = _bspline(output_format="sparse").fit(frame, y) - out = p.transform(frame) + out = p.transform(frame, return_array=False) assert isinstance(out, dict) assert all(sp.issparse(v) for v in out.values()) @@ -184,9 +184,17 @@ def test_set_output_pandas_fit_transform(frame, y): assert out.shape[1] == p.total_output_dim_ -def test_set_output_default_still_dict(frame, y): +def test_set_output_default_still_array(frame, y): + # set_output(transform="default") only opts out of pandas/polars wrapping; it + # does not override output_structure, which still resolves to an array. p = _bspline().fit(frame, y).set_output(transform="default") out = p.transform(frame) + assert isinstance(out, np.ndarray) + + +def test_set_output_default_with_blocks_structure_is_dict(frame, y): + p = _bspline(output_structure="blocks").fit(frame, y).set_output(transform="default") + out = p.transform(frame) assert isinstance(out, dict) diff --git a/tests/integration/test_output_structure.py b/tests/integration/test_output_structure.py new file mode 100644 index 0000000..92f7707 --- /dev/null +++ b/tests/integration/test_output_structure.py @@ -0,0 +1,67 @@ +"""``output_structure`` controls the top-level shape ``transform`` / +``fit_transform`` return when ``return_array`` is not passed explicitly. + +Regression coverage for the original bug: a plain ``Preprocessor`` inside a bare +``sklearn.pipeline.Pipeline`` broke the next step, because ``transform()`` +defaulted to a dict and ``Pipeline`` has no way to request ``return_array=True`` +for an intermediate step. +""" + +import numpy as np +import pandas as pd +import pytest +from sklearn.linear_model import Ridge +from sklearn.pipeline import Pipeline + +from pretab import Preprocessor +from pretab.exceptions import InvalidParamError + + +@pytest.fixture +def frame(): + rng = np.random.default_rng(0) + return pd.DataFrame({"a": rng.normal(size=40), "b": rng.normal(size=40)}) + + +@pytest.fixture +def y(): + return np.random.default_rng(1).normal(size=40) + + +def test_default_output_structure_is_matrix(frame, y): + pre = Preprocessor().fit(frame, y) + out = pre.transform(frame) + assert isinstance(out, np.ndarray) + assert out.shape == (len(frame), pre.total_output_dim_) + + +def test_output_structure_blocks_returns_dict(frame, y): + pre = Preprocessor(output_structure="blocks").fit(frame, y) + out = pre.transform(frame) + assert isinstance(out, dict) + assert all(isinstance(v, np.ndarray) for v in out.values()) + + +def test_explicit_return_array_overrides_output_structure(frame, y): + # output_structure="matrix" but return_array=False explicitly -> dict. + matrix_pre = Preprocessor(output_structure="matrix").fit(frame, y) + assert isinstance(matrix_pre.transform(frame, return_array=False), dict) + + # output_structure="blocks" but return_array=True explicitly -> array. + blocks_pre = Preprocessor(output_structure="blocks").fit(frame, y) + assert isinstance(blocks_pre.transform(frame, return_array=True), np.ndarray) + + +def test_invalid_output_structure_raises(frame, y): + with pytest.raises(InvalidParamError, match="output_structure"): + Preprocessor(output_structure="nope").fit(frame, y) + + +def test_preprocessor_composes_in_a_plain_pipeline(frame, y): + # Regression test: Pipeline always calls transform(X) with no return_array + # kwarg, so a dict-by-default Preprocessor broke the next step with + # "TypeError: float() argument must be a string or a real number, not 'dict'". + model = Pipeline([("pretab", Preprocessor()), ("model", Ridge())]) + model.fit(frame, y) + preds = model.predict(frame) + assert preds.shape == (len(frame),) diff --git a/tests/integration/test_preprocessor.py b/tests/integration/test_preprocessor.py index 8c7e291..b7f0e35 100644 --- a/tests/integration/test_preprocessor.py +++ b/tests/integration/test_preprocessor.py @@ -23,10 +23,19 @@ def sample_data(): return df, y -def test_fit_transform_returns_dict(sample_data): +def test_fit_transform_returns_array_by_default(sample_data): X, y = sample_data pre = Preprocessor() out = pre.fit_transform(X, y) + assert isinstance(out, np.ndarray) + assert out.shape[0] == len(X) + assert out.ndim == 2 + + +def test_fit_transform_returns_dict_with_blocks_structure(sample_data): + X, y = sample_data + pre = Preprocessor(output_structure="blocks") + out = pre.fit_transform(X, y) assert isinstance(out, dict) assert all(isinstance(k, str) for k in out) assert all(isinstance(v, np.ndarray) for v in out.values()) @@ -53,7 +62,7 @@ def test_transform_raises_before_fit(sample_data): def test_embedding_integration(sample_data): X, y = sample_data embed = np.random.rand(len(X), 10) - pre = Preprocessor() + pre = Preprocessor(output_structure="blocks") out = pre.fit_transform(X, y, embeddings=embed) assert "embedding_1" in out assert out["embedding_1"].shape == (len(X), 10) @@ -62,7 +71,7 @@ def test_embedding_integration(sample_data): def test_multiple_embeddings(sample_data): X, y = sample_data embeds = [np.random.rand(len(X), 3), np.random.rand(len(X), 7)] - pre = Preprocessor() + pre = Preprocessor(output_structure="blocks") out = pre.fit_transform(X, y, embeddings=embeds) assert "embedding_1" in out and "embedding_2" in out assert out["embedding_1"].shape[1] == 3 @@ -135,7 +144,7 @@ def test_feature_info_returns_three_dicts(sample_data): def test_dict_output_shapes_add_up(sample_data): X, y = sample_data - pre = Preprocessor() + pre = Preprocessor(output_structure="blocks") out = pre.fit_transform(X, y) assert isinstance(out, dict) shapes = [v.shape for v in out.values()] @@ -144,7 +153,7 @@ def test_dict_output_shapes_add_up(sample_data): def test_dict_keys_reflect_column_names(sample_data): X, y = sample_data - pre = Preprocessor() + pre = Preprocessor(output_structure="blocks") out = pre.fit_transform(X, y) assert isinstance(out, dict) expected_prefixes = ["num_", "cat_"] @@ -180,6 +189,7 @@ def test_dict_keys_reflect_column_names(sample_data): "max_features_per_input", "max_dense_memory", "overflow_policy", + "output_structure", "output_format", "dtype", "verbose", @@ -265,7 +275,7 @@ def test_get_feature_names_out_does_not_duplicate_feature_name(sample_data): 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. - pre = Preprocessor(numerical_method="PLE", categorical_method=None) # type: ignore[arg-type] + pre = Preprocessor(numerical_method="PLE", categorical_method=None, output_structure="blocks") # type: ignore[arg-type] out = pre.fit_transform(X, y) assert isinstance(out, dict) assert pre.numerical_method == "PLE" # unchanged on the instance From f53673d178ad8b2092761252eed810b6554b564b Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sat, 5 Sep 2026 18:41:58 +0200 Subject: [PATCH 075/123] ci: CI before publish and test minimum dependencies --- .github/workflows/ci.yml | 33 ++++++++++++++++++++++++++ .github/workflows/publish-pypi.yml | 6 +++++ .github/workflows/publish-testpypi.yml | 6 +++++ 3 files changed, 45 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 766dca8..db783df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,7 @@ name: CI on: workflow_dispatch: + workflow_call: push: branches: - main @@ -150,6 +151,38 @@ jobs: - name: Run unit tests run: poetry run pytest tests/ -v + min-deps: + name: Minimum supported dependencies + 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: Install dependencies + run: poetry install + + - name: Force the declared minimum runtime dependency versions + run: | + poetry run pip install \ + "numpy==1.24.*" \ + "pandas==2.0.*" \ + "scipy==1.10.*" \ + "scikit-learn==1.6.*" + + - name: Run unit tests against the minimum versions + run: poetry run pytest tests/ -q + smoke: name: Smoke tests (Python 3.12, ubuntu) runs-on: ubuntu-latest diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index f9ec093..56fe8ed 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -15,8 +15,14 @@ permissions: id-token: write jobs: + ci: + name: Full CI (required before publish) + uses: ./.github/workflows/ci.yml + secrets: inherit + publish: runs-on: ubuntu-latest + needs: ci environment: pypi-publish # The "v*.*.*" trigger also matches RC tags (e.g. v2.0.0rc2), so guard # against publishing pre-releases to real PyPI. RC tags are handled by diff --git a/.github/workflows/publish-testpypi.yml b/.github/workflows/publish-testpypi.yml index ae9c292..33a766b 100644 --- a/.github/workflows/publish-testpypi.yml +++ b/.github/workflows/publish-testpypi.yml @@ -16,8 +16,14 @@ permissions: id-token: write jobs: + ci: + name: Full CI (required before publish) + uses: ./.github/workflows/ci.yml + secrets: inherit + publish-rc: runs-on: ubuntu-latest + needs: ci environment: testpypi-publish steps: From 4641ab68cb3a03926d521bd23df6849b5c738bac Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sat, 5 Sep 2026 18:43:17 +0200 Subject: [PATCH 076/123] fix: raise scikit-learn minimum and use scipy trapezoid --- docs/getting_started/installation.md | 15 ++++++++++++++- poetry.lock | 2 +- pretab/expansion/spline/natural_cubic.py | 3 ++- pyproject.toml | 2 +- 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/getting_started/installation.md b/docs/getting_started/installation.md index f13b17e..1263b38 100644 --- a/docs/getting_started/installation.md +++ b/docs/getting_started/installation.md @@ -1,6 +1,19 @@ # Installation -pretab supports Python 3.10 to 3.13. +pretab supports Python 3.10 to 3.13, with the following minimum core dependency versions: + +| Dependency | Minimum | +| --- | --- | +| `numpy` | 1.24 | +| `pandas` | 2.0 | +| `scipy` | 1.10 | +| `scikit-learn` | 1.6 | + +```{note} +`scikit-learn>=1.6` is required for the `__sklearn_tags__` tag-dispatch API pretab's +transformers use. A dedicated CI job installs exactly these minimum versions and runs the +test suite against them, so this floor is verified, not just declared. +``` ## From PyPI diff --git a/poetry.lock b/poetry.lock index cf14d69..2c9a3b1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -4938,4 +4938,4 @@ lightgbm = ["lightgbm"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<3.14" -content-hash = "2a0bad6485988b0c36e131940e3f5df70bb2624604cfecb76ddd25b49eb1cab6" +content-hash = "2ecd3046883c17c5faaa826140973e7ca64106ce4081afae5a550197fc2268b8" diff --git a/pretab/expansion/spline/natural_cubic.py b/pretab/expansion/spline/natural_cubic.py index 372d524..9b88929 100644 --- a/pretab/expansion/spline/natural_cubic.py +++ b/pretab/expansion/spline/natural_cubic.py @@ -1,4 +1,5 @@ import numpy as np +from scipy.integrate import trapezoid from sklearn.base import BaseEstimator, TransformerMixin from sklearn.utils.validation import check_is_fitted @@ -244,6 +245,6 @@ def get_penalty_matrix(self, feature_index=0): for i in range(offset, n_basis): for j in range(offset, n_basis): integrand = B_dd[:, i] * B_dd[:, j] - P[i, j] = np.trapezoid(integrand, x_grid) + P[i, j] = trapezoid(integrand, x_grid) return P diff --git a/pyproject.toml b/pyproject.toml index 48c2607..c2498ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry.dependencies] numpy = ">=1.24,<3.0" pandas = ">=2.0,<3.0" -scikit-learn = ">=1.3,<2.0" +scikit-learn = ">=1.6,<2.0" scipy = ">=1.10,<2.0" [tool.poetry.group.dev.dependencies] From 9cc5316ad5301ce7ade84a62889934260b18a96c Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sat, 5 Sep 2026 18:43:30 +0200 Subject: [PATCH 077/123] chore: formatting --- pretab/compose/serialize.py | 7 ++++--- pretab/core/policy.py | 1 - pretab/expansion/spline/cubic_regression.py | 1 - 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/pretab/compose/serialize.py b/pretab/compose/serialize.py index f6e78c2..de935de 100644 --- a/pretab/compose/serialize.py +++ b/pretab/compose/serialize.py @@ -47,7 +47,9 @@ # "any dataclass importable under an allowed module" -- the latter would still # let a spec construct an arbitrary sklearn/numpy/scipy dataclass with # attacker-controlled fields. -_ALLOWED_DATACLASSES = frozenset({RepresentationPolicy, RepresentationSpec, FeatureLineage, PlacementResult, TransformerSpec}) +_ALLOWED_DATACLASSES = frozenset( + {RepresentationPolicy, RepresentationSpec, FeatureLineage, PlacementResult, TransformerSpec} +) # --- helpers ------------------------------------------------------------- @@ -180,8 +182,7 @@ def _decode_estimator(payload: dict): cls = cast(Any, _resolve(payload["class"])) if not (isinstance(cls, type) and issubclass(cls, BaseEstimator)): raise PretabSerializationError( - f"Refusing to reconstruct {payload['class']!r} as an estimator: not a " - "scikit-learn BaseEstimator subclass." + f"Refusing to reconstruct {payload['class']!r} as an estimator: not a scikit-learn BaseEstimator subclass." ) obj = cls.__new__(cls) obj.__dict__.update(_decode_mapping(payload["state"])) diff --git a/pretab/core/policy.py b/pretab/core/policy.py index d53097c..4374ab0 100644 --- a/pretab/core/policy.py +++ b/pretab/core/policy.py @@ -28,7 +28,6 @@ expansions" section of ``dev/todo/release-1.0.0/bugfixes-1.0.0.md``. """ - from __future__ import annotations import warnings diff --git a/pretab/expansion/spline/cubic_regression.py b/pretab/expansion/spline/cubic_regression.py index 318541d..a651f9a 100644 --- a/pretab/expansion/spline/cubic_regression.py +++ b/pretab/expansion/spline/cubic_regression.py @@ -265,4 +265,3 @@ def _second_derivative(self, x, basis_index, knots): return 6.0 * x # x**3 knot = knots[col - 3] return 6.0 * np.maximum(x - knot, 0.0) - From b6fc3e73b3b8dcfef7e5617e33fea4ad9578c2d2 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sat, 5 Sep 2026 18:58:32 +0200 Subject: [PATCH 078/123] docs: fix broken source-install command in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4a12f18..ee65b9a 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ pip install "pretab[all]" # both of the above ```bash git clone https://github.com/OpenTabular/PreTab cd PreTab -pip install -e ".[dev]" +poetry install ``` ## Usage From 8a948c5f21f4375162d4e0c676ced6011d950aa6 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sat, 5 Sep 2026 18:58:46 +0200 Subject: [PATCH 079/123] fix: remove unimplemented resolution stubs from public placement API --- pretab/placement/__init__.py | 4 ---- pretab/placement/resolution.py | 21 ++++++++++----------- tests/placement/test_placement.py | 12 ++++++++++++ 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/pretab/placement/__init__.py b/pretab/placement/__init__.py index 3f5ae5d..9edd64b 100644 --- a/pretab/placement/__init__.py +++ b/pretab/placement/__init__.py @@ -19,8 +19,6 @@ from .factory import create_placement_strategy from .resolution import ( BaseResolutionPolicy, - CardinalityAwareResolution, - DataSizeAwareResolution, FixedResolution, ) from .supervised import CARTPlacement, LightGBMPlacement @@ -30,8 +28,6 @@ "BasePlacementStrategy", "BaseResolutionPolicy", "CARTPlacement", - "CardinalityAwareResolution", - "DataSizeAwareResolution", "FixedResolution", "LightGBMPlacement", "PLEPlacementAdapter", diff --git a/pretab/placement/resolution.py b/pretab/placement/resolution.py index 02f2352..6d6d1aa 100644 --- a/pretab/placement/resolution.py +++ b/pretab/placement/resolution.py @@ -2,17 +2,18 @@ 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 +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. +:class:`DataSizeAwareResolution` are internal, not-yet-implemented stubs for a +future data-driven ``(lo, hi)`` policy; every method on both currently raises +``NotImplementedError``, and neither is part of the public ``pretab.placement`` +API (they are not re-exported from :mod:`pretab.placement`'s ``__all__``). """ from __future__ import annotations @@ -25,8 +26,6 @@ __all__ = [ "BaseResolutionPolicy", - "CardinalityAwareResolution", - "DataSizeAwareResolution", "FixedResolution", ] @@ -119,8 +118,8 @@ def resolve( 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. + Internal, not-yet-implemented placeholder for a future phase; not part of the + public ``pretab.placement`` API. """ def resolve( @@ -143,8 +142,8 @@ def clamp_to_cardinality(self, hi: int, x: np.ndarray) -> int: 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. + Internal, not-yet-implemented placeholder for a future phase; not part of the + public ``pretab.placement`` API. """ def resolve( diff --git a/tests/placement/test_placement.py b/tests/placement/test_placement.py index e1c629d..d0dc463 100644 --- a/tests/placement/test_placement.py +++ b/tests/placement/test_placement.py @@ -180,6 +180,18 @@ def test_fixed_resolution_non_adaptive_conflict(): FixedResolution(adaptive=False).resolve(3, 5, None, floor=1) +def test_not_yet_implemented_resolution_stubs_are_not_public(): + # Regression guard: CardinalityAwareResolution / DataSizeAwareResolution + # always raise NotImplementedError, so they must not be part of the public + # pretab.placement API (no user-facing "usable" class should always fail). + import pretab.placement as placement_pkg + + assert "CardinalityAwareResolution" not in placement_pkg.__all__ + assert "DataSizeAwareResolution" not in placement_pkg.__all__ + assert not hasattr(placement_pkg, "CardinalityAwareResolution") + assert not hasattr(placement_pkg, "DataSizeAwareResolution") + + # --------------------------------------------------------------------------- # # Adapters # --------------------------------------------------------------------------- # From ec5e3ce0ff3bb01ec9324e25c019303e8e678192 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sat, 5 Sep 2026 18:58:58 +0200 Subject: [PATCH 080/123] chore: add py.typed marker for PEP 561 compliance --- pretab/py.typed | 0 tests/integration/test_public_api.py | 9 +++++++++ 2 files changed, 9 insertions(+) create mode 100644 pretab/py.typed diff --git a/pretab/py.typed b/pretab/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_public_api.py b/tests/integration/test_public_api.py index 16cc7b6..095f723 100644 --- a/tests/integration/test_public_api.py +++ b/tests/integration/test_public_api.py @@ -85,3 +85,12 @@ def test_legacy_pipeline_package_is_removed(): def test_compose_subsystem_is_importable(): for module in ("config", "registry", "factory", "output", "inspection", "feature_detection"): importlib.import_module(f"pretab.compose.{module}") + + +def test_py_typed_marker_is_present(): + # PEP 561 marker: without this, type checkers treat an installed pretab as + # untyped by default, losing the benefit of the project's own annotations. + import pathlib + + pretab_dir = pathlib.Path(pretab.__file__).parent + assert (pretab_dir / "py.typed").is_file() From 4fe150dc1148f92223f1f7fedae18609ab911db5 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sat, 5 Sep 2026 19:00:03 +0200 Subject: [PATCH 081/123] docs: remove custombin from categorical_method docstring --- pretab/preprocessor.py | 8 ++++---- tests/integration/test_preprocessor.py | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index ed265e7..6b8addd 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -115,9 +115,9 @@ class Preprocessor(TransformerMixin, BaseEstimator): Preprocessing strategy applied to every categorical column unless overridden per feature. Choices: ``"int"`` (contiguous integer codes), ``"one-hot"`` (dummy columns), ``"onehot_from_ordinal"`` (one-hot from an already integer-coded column; raises if the - input is not already ordinal-encoded), ``"pretrained"`` (sentence-transformer language - embeddings), and ``"custombin"`` (discretized bin codes). Pass ``None`` (resolved to - ``"none"``) to leave categorical columns unchanged. + input is not already ordinal-encoded), and ``"pretrained"`` (sentence-transformer + language embeddings). Pass ``None`` (resolved to ``"none"``) to leave categorical + columns unchanged. feature_preprocessing : dict, optional Mapping of individual column names to a method, overriding the global ``numerical_method`` / ``categorical_method`` for those columns only, e.g. @@ -306,7 +306,7 @@ class Preprocessor(TransformerMixin, BaseEstimator): ``"mspline"``, ``"ispline"``. Available ``categorical_method`` values: ``"int"``, ``"one-hot"``, ``"onehot_from_ordinal"``, - ``"pretrained"``, ``"custombin"``, ``"none"``. The ``"pretrained"`` method requires the optional + ``"pretrained"``, ``"none"``. The ``"pretrained"`` method requires the optional ``sentence-transformers`` dependency (``pip install "pretab[embeddings]"``). Method names are resolved case-insensitively and ignore ``-`` / ``_`` / space separators, so diff --git a/tests/integration/test_preprocessor.py b/tests/integration/test_preprocessor.py index b7f0e35..22c17cf 100644 --- a/tests/integration/test_preprocessor.py +++ b/tests/integration/test_preprocessor.py @@ -343,3 +343,21 @@ def test_output_dims_and_total_before_fit_raise(): _ = Preprocessor().output_dims_ with pytest.raises(NotFittedError): _ = Preprocessor().total_output_dim_ + + +# --- Documented categorical_method values are actually accepted (docstring/registry drift) --- + +_DOCUMENTED_CATEGORICAL_METHODS = ["int", "one-hot", "onehot_from_ordinal", "pretrained", "none"] + + +@pytest.mark.parametrize("method", _DOCUMENTED_CATEGORICAL_METHODS) +def test_documented_categorical_method_is_accepted(sample_data, method): + # Regression guard: the docstring's categorical_method list once claimed + # "custombin" was valid, but the registry rejected it (numerical-only). + if method == "pretrained": + pytest.importorskip("sentence_transformers") + X, y = sample_data + if method == "onehot_from_ordinal": + # Requires already-integer-coded categorical input. + X = X.assign(cat1=pd.factorize(X["cat1"])[0], cat2=pd.factorize(X["cat2"])[0]) + Preprocessor(numerical_method="none", categorical_method=method).fit(X, y) From 824102e228327319f2e429f2649a2e6717e7b906 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sat, 5 Sep 2026 19:00:18 +0200 Subject: [PATCH 082/123] fix: validate feature_preprocessing keys against input columns --- pretab/preprocessor.py | 11 +++++++++++ tests/integration/test_preprocessor.py | 8 +++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index 6b8addd..9509952 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -485,6 +485,17 @@ def fit(self, X, y=None, embeddings=None): X = to_dataframe(X) + if self.feature_preprocessing: + unknown_features = set(self.feature_preprocessing) - set(X.columns) + if unknown_features: + raise invalid_param_error( + type(self).__name__, + "feature_preprocessing", + sorted(unknown_features), + "every key must name a column present in X", + valid=X.columns, + ) + if self.missing_policy == "error": self._reject_missing(X) diff --git a/tests/integration/test_preprocessor.py b/tests/integration/test_preprocessor.py index 22c17cf..dad49d4 100644 --- a/tests/integration/test_preprocessor.py +++ b/tests/integration/test_preprocessor.py @@ -5,7 +5,7 @@ from sklearn.exceptions import NotFittedError from sklearn.utils.validation import check_is_fitted -from pretab.exceptions import IncompatibleParamsError, PretabDataError +from pretab.exceptions import IncompatibleParamsError, InvalidParamError, PretabDataError from pretab.preprocessor import Preprocessor # Adjust the import as needed @@ -361,3 +361,9 @@ def test_documented_categorical_method_is_accepted(sample_data, method): # Requires already-integer-coded categorical input. X = X.assign(cat1=pd.factorize(X["cat1"])[0], cat2=pd.factorize(X["cat2"])[0]) Preprocessor(numerical_method="none", categorical_method=method).fit(X, y) + + +def test_unknown_feature_preprocessing_key_raises(sample_data): + X, y = sample_data + with pytest.raises(InvalidParamError, match="feature_preprocessing"): + Preprocessor(feature_preprocessing={"nnum1": "minmax"}).fit(X, y) From 0c4ccc73a79037f28283743709b9e19c1ce6761d Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sat, 5 Sep 2026 19:00:37 +0200 Subject: [PATCH 083/123] fix: numerical_method=none no longer applies scaling --- pretab/compose/factory.py | 2 +- tests/integration/test_preprocessor.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/pretab/compose/factory.py b/pretab/compose/factory.py index f272a6d..8ced4d6 100644 --- a/pretab/compose/factory.py +++ b/pretab/compose/factory.py @@ -136,7 +136,7 @@ def get_numerical_transformer_steps( "must name a scaler or disable scaling", valid={*scalers, "none"}, ) - if scaling in scalers and scaling != method: + if scaling in scalers and scaling != method and method != "none": steps.append(scalers[scaling]) if method not in NUMERICAL_METHODS: diff --git a/tests/integration/test_preprocessor.py b/tests/integration/test_preprocessor.py index dad49d4..df9d224 100644 --- a/tests/integration/test_preprocessor.py +++ b/tests/integration/test_preprocessor.py @@ -363,6 +363,16 @@ def test_documented_categorical_method_is_accepted(sample_data, method): Preprocessor(numerical_method="none", categorical_method=method).fit(X, y) +def test_numerical_method_none_leaves_values_untouched(): + # Regression test: numerical_method="none" used to still run MinMaxScaler + # before the no-op, contradicting the documented "leave unchanged" meaning. + X = pd.DataFrame({"a": [1.0, 2.0, 3.0, 100.0]}) + pre = Preprocessor(numerical_method="none").fit(X) + out = pre.transform(X) + assert isinstance(out, np.ndarray) + np.testing.assert_array_equal(out.ravel(), X["a"].to_numpy()) + + def test_unknown_feature_preprocessing_key_raises(sample_data): X, y = sample_data with pytest.raises(InvalidParamError, match="feature_preprocessing"): From 1139b92a1ea7689838624b6f2eebb3538798ead0 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sat, 5 Sep 2026 20:28:20 +0200 Subject: [PATCH 084/123] fix: add missing polars optional dependency and its tests --- .github/workflows/ci.yml | 2 + docs/core_concepts/outputs_and_inspection.md | 6 +- docs/getting_started/installation.md | 14 ++++ poetry.lock | 70 +++++++++++++++++++- pyproject.toml | 3 +- tests/integration/test_output_format.py | 35 +++++++--- 6 files changed, 116 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db783df..826165a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -281,6 +281,8 @@ jobs: module: sentence_transformers - extra: lightgbm module: lightgbm + - extra: polars + module: polars steps: - uses: actions/checkout@v4 diff --git a/docs/core_concepts/outputs_and_inspection.md b/docs/core_concepts/outputs_and_inspection.md index 620c8aa..32e95e2 100644 --- a/docs/core_concepts/outputs_and_inspection.md +++ b/docs/core_concepts/outputs_and_inspection.md @@ -121,8 +121,10 @@ 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. +Polars output requires the optional `polars` extra (`pip install "pretab[polars]"`) and is +loaded lazily: if polars is not installed, requesting it raises a clear +`OptionalDependencyError` rather than failing deep in the call stack. See +[Installation](../getting_started/installation.md#optional-extras). ``` ## Choosing your output settings diff --git a/docs/getting_started/installation.md b/docs/getting_started/installation.md index 1263b38..5d43e3f 100644 --- a/docs/getting_started/installation.md +++ b/docs/getting_started/installation.md @@ -44,6 +44,20 @@ supervised knot, center, and threshold selection: pip install "pretab[lightgbm]" ``` +The `polars` extra enables `set_output(transform="polars")`, so `Preprocessor.transform` +returns a `polars.DataFrame` instead of a NumPy array or dict: + +```bash +pip install "pretab[polars]" +``` + +```{note} +`polars` is only needed for the `set_output(transform="polars")` output path; every other +output (`output_structure="matrix"`/`"blocks"`, `output_format="dense"`/`"sparse"`, +`set_output(transform="pandas")`) works without it. Requesting `"polars"` output without the +extra installed raises a clear `OptionalDependencyError`. +``` + Use the convenience `all` extra to install every optional dependency at once: ```bash diff --git a/poetry.lock b/poetry.lock index 2c9a3b1..aa4f1d2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2874,6 +2874,71 @@ files = [ dev = ["pre-commit", "tox"] testing = ["coverage", "pytest", "pytest-benchmark"] +[[package]] +name = "polars" +version = "1.44.1" +description = "Blazingly fast DataFrame library" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"polars\" or extra == \"all\"" +files = [ + {file = "polars-1.44.1-py3-none-any.whl", hash = "sha256:1fa62fc1c88fba77a68b28291b5aabdd69e5f38b34e59721a064ae3169b59bb5"}, + {file = "polars-1.44.1.tar.gz", hash = "sha256:ef3c89e9ebbbe8eb343c06873f1945683f8b6f97a1bdf001c60551c6c5e3cda1"}, +] + +[package.dependencies] +polars-runtime-32 = "1.44.1" + +[package.extras] +adbc = ["adbc-driver-manager[dbapi]", "adbc-driver-sqlite[dbapi]"] +all = ["polars[async,cloudpickle,database,deltalake,excel,fsspec,graph,iceberg,numpy,pandas,plot,pyarrow,pydantic,style,timezone]"] +async = ["gevent"] +calamine = ["fastexcel (>=0.9)"] +cloudpickle = ["cloudpickle"] +connectorx = ["connectorx (>=0.3.2)"] +database = ["polars[adbc,connectorx,sqlalchemy]"] +deltalake = ["deltalake (>=1.0.0,!=1.5.*)"] +excel = ["polars[calamine,openpyxl,xlsx2csv,xlsxwriter]"] +fsspec = ["fsspec"] +gpu = ["cudf-polars-cu12"] +graph = ["matplotlib"] +iceberg = ["pyiceberg (>=0.9.0)"] +numpy = ["numpy (>=1.16.0)"] +openpyxl = ["openpyxl (>=3.0.0)"] +pandas = ["pandas", "polars[pyarrow]"] +plot = ["altair (>=5.4.0)"] +polars-cloud = ["polars_cloud (>=0.9.0)"] +pyarrow = ["pyarrow (>=7.0.0)"] +pydantic = ["pydantic"] +rt64 = ["polars-runtime-64 (==1.44.1)"] +rtcompat = ["polars-runtime-compat (==1.44.1)"] +sqlalchemy = ["polars[pandas]", "sqlalchemy"] +style = ["great-tables (>=0.8.0)"] +timezone = ["tzdata ; platform_system == \"Windows\""] +xlsx2csv = ["xlsx2csv (>=0.8.0)"] +xlsxwriter = ["xlsxwriter"] + +[[package]] +name = "polars-runtime-32" +version = "1.44.1" +description = "Blazingly fast DataFrame library" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"polars\" or extra == \"all\"" +files = [ + {file = "polars_runtime_32-1.44.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:1dfccb2b52aa50468a7d28e3e61c8338a13fb5bffc8646e388a649f5bdc6b463"}, + {file = "polars_runtime_32-1.44.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:0580807dc3eed258f0db70bb65d905dd43f0135392119ec25308033ae24258fb"}, + {file = "polars_runtime_32-1.44.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0627f9aa82cb869725235e5188f698862fd9ada0c8c1cf65c3dc5a49a4a0ec26"}, + {file = "polars_runtime_32-1.44.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eea4283be8e60822d890dbda20588fe59b4172b508bd5ebf3471e531ca9f50d7"}, + {file = "polars_runtime_32-1.44.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:04e2c0f46e7a9906fffb1897f18f23b079b74f83c56b50060bace9e7b9b49b1a"}, + {file = "polars_runtime_32-1.44.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0956f0cae632d8fad3a04b4315bf2bb69b56d10c83c79a75c2c4c5a13b9ce5cc"}, + {file = "polars_runtime_32-1.44.1-cp310-abi3-win_amd64.whl", hash = "sha256:159334184e6fbb074c9f4692221ea19970a5e2bed2a479f9d7bdb00b7f3eedb9"}, + {file = "polars_runtime_32-1.44.1-cp310-abi3-win_arm64.whl", hash = "sha256:3ba28d638d0513e0b4afbcdab5c0059a85021e5f81d62b5f793e7e23badb2cf7"}, + {file = "polars_runtime_32-1.44.1.tar.gz", hash = "sha256:abd10a54ed1caff42228610fcba0f93251f9870bd7cffb0c78bc26f5e0718ce4"}, +] + [[package]] name = "pre-commit" version = "3.8.0" @@ -4931,11 +4996,12 @@ test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_it type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] [extras] -all = ["lightgbm", "sentence-transformers"] +all = ["lightgbm", "polars", "sentence-transformers"] embeddings = ["sentence-transformers"] lightgbm = ["lightgbm"] +polars = ["polars"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<3.14" -content-hash = "2ecd3046883c17c5faaa826140973e7ca64106ce4081afae5a550197fc2268b8" +content-hash = "72350f56d6631ce5895d0ccc60dc374cd09526ccb2068569acc2c4204489589f" diff --git a/pyproject.toml b/pyproject.toml index c2498ac..3dcec3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,8 @@ dynamic = ["dependencies"] [project.optional-dependencies] embeddings = ["sentence-transformers>=2.0"] lightgbm = ["lightgbm>=4.0"] -all = ["sentence-transformers>=2.0", "lightgbm>=4.0"] +polars = ["polars>=0.20"] +all = ["sentence-transformers>=2.0", "lightgbm>=4.0", "polars>=0.20"] [project.urls] homepage = "https://github.com/OpenTabular/PreTab" diff --git a/tests/integration/test_output_format.py b/tests/integration/test_output_format.py index c169571..5e54deb 100644 --- a/tests/integration/test_output_format.py +++ b/tests/integration/test_output_format.py @@ -199,15 +199,32 @@ def test_set_output_default_with_blocks_structure_is_dict(frame, y): 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_) + # Unit-test to_dataframe_output directly (rather than through the full + # Preprocessor.set_output stack): sklearn's own polars detection elsewhere + # in that stack also probes sys.modules and breaks under a blanket + # sys.modules["polars"] = None patch, unrelated to the behavior under test. + import unittest.mock + + from pretab.compose.output import to_dataframe_output + + with unittest.mock.patch.dict("sys.modules", {"polars": None}): + with pytest.raises(OptionalDependencyError, match="polars"): + to_dataframe_output(np.zeros((2, 2)), ["a", "b"], "polars") + + +def test_set_output_polars_dataframe_is_correct(frame, y): + pl = pytest.importorskip("polars") + + arr = _bspline().fit(frame, y).transform(frame, return_array=True) + assert isinstance(arr, np.ndarray) + + p = _bspline().fit(frame, y) + out = p.set_output(transform="polars").transform(frame) + + assert isinstance(out, pl.DataFrame) + assert out.shape == (len(frame), p.total_output_dim_) + assert out.columns == list(p.get_feature_names_out()) + np.testing.assert_allclose(out.to_numpy(), arr) # --- validation ---------------------------------------------------------------- From 3f42af916b8f7882f11873eeaf421dd8fcb4761d Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 06:23:15 +0200 Subject: [PATCH 085/123] chore: test case correction --- tests/integration/test_output_format.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/tests/integration/test_output_format.py b/tests/integration/test_output_format.py index 5e54deb..abe2613 100644 --- a/tests/integration/test_output_format.py +++ b/tests/integration/test_output_format.py @@ -170,7 +170,8 @@ def test_output_report_shape_and_keys(frame, y): def test_set_output_pandas_returns_dataframe(frame, y): - p = _bspline().fit(frame, y).set_output(transform="pandas") + p = _bspline().fit(frame, y) + p.set_output(transform="pandas") out = p.transform(frame) assert isinstance(out, pd.DataFrame) assert list(out.columns) == list(p.get_feature_names_out()) @@ -178,7 +179,8 @@ def test_set_output_pandas_returns_dataframe(frame, y): def test_set_output_pandas_fit_transform(frame, y): - p = _bspline().set_output(transform="pandas") + p = _bspline() + p.set_output(transform="pandas") out = p.fit_transform(frame, y) assert isinstance(out, pd.DataFrame) assert out.shape[1] == p.total_output_dim_ @@ -187,13 +189,15 @@ def test_set_output_pandas_fit_transform(frame, y): def test_set_output_default_still_array(frame, y): # set_output(transform="default") only opts out of pandas/polars wrapping; it # does not override output_structure, which still resolves to an array. - p = _bspline().fit(frame, y).set_output(transform="default") + p = _bspline().fit(frame, y) + p.set_output(transform="default") out = p.transform(frame) assert isinstance(out, np.ndarray) def test_set_output_default_with_blocks_structure_is_dict(frame, y): - p = _bspline(output_structure="blocks").fit(frame, y).set_output(transform="default") + p = _bspline(output_structure="blocks").fit(frame, y) + p.set_output(transform="default") out = p.transform(frame) assert isinstance(out, dict) @@ -213,18 +217,20 @@ def test_set_output_polars_without_polars_raises(frame, y): def test_set_output_polars_dataframe_is_correct(frame, y): - pl = pytest.importorskip("polars") + pytest.importorskip("polars") + import polars as pl # type: ignore arr = _bspline().fit(frame, y).transform(frame, return_array=True) assert isinstance(arr, np.ndarray) p = _bspline().fit(frame, y) - out = p.set_output(transform="polars").transform(frame) + p.set_output(transform="polars") + out = p.transform(frame) assert isinstance(out, pl.DataFrame) - assert out.shape == (len(frame), p.total_output_dim_) - assert out.columns == list(p.get_feature_names_out()) - np.testing.assert_allclose(out.to_numpy(), arr) + assert out.shape == (len(frame), p.total_output_dim_) # type: ignore + assert out.columns == list(p.get_feature_names_out()) # type: ignore + np.testing.assert_allclose(out.to_numpy(), arr) # type: ignore # --- validation ---------------------------------------------------------------- From 5e81a3b36b9a8d10f37cb6424ea7fe34de46bf05 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 06:42:37 +0200 Subject: [PATCH 086/123] docs: note Nystroem's non-uniform pointwise approximation error --- docs/representations/kernel_approximation.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/representations/kernel_approximation.md b/docs/representations/kernel_approximation.md index af660a0..2dbfcc0 100644 --- a/docs/representations/kernel_approximation.md +++ b/docs/representations/kernel_approximation.md @@ -85,6 +85,16 @@ random basis independent of the data, while Nyström samples landmarks from the When in doubt, try both and compare with cross-validation. ``` +```{note} +Nyström's approximation error is not uniformly bounded across the kernel matrix. In +particular, the self-similarity entries $K(x, x)$ on the diagonal can be approximated far +less accurately than typical off-diagonal entries, depending on how well the sampled +landmarks happen to cover that row. This is an inherent property of the Nyström method +itself (also present in plain `sklearn.kernel_approximation.Nystroem`), not something +specific to PreTab's wrapper. If your downstream model is sensitive to diagonal accuracy, +increase `n_components` or try random Fourier features instead. +``` + ```{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 or combine them From dc0386e16454844f4c6746cbbedfd6f62ccf73cb Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 06:42:46 +0200 Subject: [PATCH 087/123] fix: document periodic transform wrap-around and correct binning docstring --- pretab/encoding/numerical/binning.py | 2 +- pretab/encoding/numerical/periodic.py | 7 +++++++ tests/encoding/numerical/test_periodic.py | 10 ++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/pretab/encoding/numerical/binning.py b/pretab/encoding/numerical/binning.py index 8198608..4b1fcbf 100644 --- a/pretab/encoding/numerical/binning.py +++ b/pretab/encoding/numerical/binning.py @@ -182,7 +182,7 @@ def fit(self, X, y=None): @staticmethod def _bin_indices(column, edges): - """Assign each value to a bin using ``(a, b]`` intervals with a closed left edge.""" + """Assign each value to a bin using ``(a, b]`` intervals with a closed right edge.""" idx = np.searchsorted(edges, column, side="left") - 1 return np.clip(idx, 0, edges.size - 2).astype(int) diff --git a/pretab/encoding/numerical/periodic.py b/pretab/encoding/numerical/periodic.py index b54102e..75aaf31 100644 --- a/pretab/encoding/numerical/periodic.py +++ b/pretab/encoding/numerical/periodic.py @@ -45,6 +45,13 @@ class PeriodicEncodingTransformer(BasePreTabTransformer): (which applies one method uniformly across columns). Apply it directly to the relevant cyclical column instead. + :meth:`fit` rejects a value outside ``[0, period]``, but :meth:`transform` does not + repeat that check: the trigonometric encoding wraps any input around the cycle + automatically (``period + x`` maps to the same output as ``x``), which is the + mathematically correct result for a genuinely cyclic quantity. If you need + transform-time inputs strictly confined to ``[0, period]`` as well, validate them + before calling :meth:`transform`. + Examples -------- >>> import numpy as np diff --git a/tests/encoding/numerical/test_periodic.py b/tests/encoding/numerical/test_periodic.py index 70a155e..e0a7270 100644 --- a/tests/encoding/numerical/test_periodic.py +++ b/tests/encoding/numerical/test_periodic.py @@ -71,3 +71,13 @@ 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) + + +def test_cyclic_transform_wraps_out_of_range_input(): + # fit rejects out-of-range values, but transform intentionally does not + # re-validate: it wraps them around the cycle, which is the mathematically + # correct result for a cyclic quantity. + transformer = PeriodicEncodingTransformer(period=24).fit(np.array([[0], [12]])) + out_of_range = transformer.transform(np.array([[30]])) + wrapped = transformer.transform(np.array([[6]])) + np.testing.assert_allclose(out_of_range, wrapped, atol=1e-12) From 2fe4e0de453ea395cec982578ade29c77d739d14 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 06:42:53 +0200 Subject: [PATCH 088/123] test: add shared penalty matrix symmetry, PSD, rank, and rescaling checks --- .../spline/test_penalty_matrix_invariants.py | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 tests/expansion/spline/test_penalty_matrix_invariants.py diff --git a/tests/expansion/spline/test_penalty_matrix_invariants.py b/tests/expansion/spline/test_penalty_matrix_invariants.py new file mode 100644 index 0000000..38c9236 --- /dev/null +++ b/tests/expansion/spline/test_penalty_matrix_invariants.py @@ -0,0 +1,162 @@ +"""Shared invariants for every spline family's penalty matrix. + +Closes a gap flagged during the v1.0.0 hardening review: symmetry/PSD, rank/nullity, +and affine-rescaling behavior were previously checked ad hoc per family (if at all) +rather than through one shared, reusable check that every family can be run against. +""" + +import numpy as np +import pytest + +from pretab.exceptions import ConfigWarning +from pretab.transformers import ( + BSplineTransformer, + CubicRegressionSplineTransformer, + ISplineTransformer, + MSplineTransformer, + NaturalCubicSplineTransformer, + PSplineTransformer, + TensorProductSplineTransformer, + ThinPlateSplineTransformer, +) + +# Families whose penalty is the pure D^T D finite-difference operator: it depends only +# on the basis width and `diff_order`, never on the fitted data's values. +DIFFERENCE_PENALTY_FAMILIES = [BSplineTransformer, MSplineTransformer, ISplineTransformer] + +ALL_SPLINE_FAMILIES = [ + (BSplineTransformer, {"output_dim": 8}, 1), + (MSplineTransformer, {"output_dim": 8}, 1), + (ISplineTransformer, {"output_dim": 8}, 1), + (PSplineTransformer, {"output_dim": 8}, 1), + (NaturalCubicSplineTransformer, {"output_dim": 6}, 1), + (CubicRegressionSplineTransformer, {"output_dim": 8}, 1), + (TensorProductSplineTransformer, {"output_dim": 4}, 2), +] + + +def assert_valid_penalty(P, *, expect_psd=True, atol=1e-8): + """Assert a penalty matrix is square, symmetric, and (optionally) positive semi-definite.""" + assert P.ndim == 2 + assert P.shape[0] == P.shape[1] + np.testing.assert_allclose(P, P.T, atol=atol) + if expect_psd: + assert np.linalg.eigvalsh(P).min() >= -atol + + +@pytest.fixture +def X_uniform(): + rng = np.random.default_rng(0) + return rng.uniform(0, 1, size=(200, 1)) + + +@pytest.fixture +def X_multi(): + rng = np.random.default_rng(0) + return rng.uniform(0, 1, size=(200, 2)) + + +@pytest.mark.parametrize("cls", DIFFERENCE_PENALTY_FAMILIES) +def test_difference_penalty_is_symmetric_psd(cls, X_uniform): + assert_valid_penalty(cls(output_dim=8).fit(X_uniform).get_penalty_matrix()) + + +def test_pspline_penalty_is_symmetric_psd(X_uniform): + assert_valid_penalty(PSplineTransformer(output_dim=8).fit(X_uniform).get_penalty_matrix()) + + +def test_natural_cubic_penalty_is_symmetric_psd(X_uniform): + assert_valid_penalty(NaturalCubicSplineTransformer(output_dim=6).fit(X_uniform).get_penalty_matrix()) + + +def test_cubic_regression_penalty_is_symmetric_psd(X_uniform): + assert_valid_penalty(CubicRegressionSplineTransformer(output_dim=8).fit(X_uniform).get_penalty_matrix()) + + +def test_tensor_product_penalty_is_symmetric_psd(X_multi): + transformer = TensorProductSplineTransformer(output_dim=4).fit(X_multi) + for P in transformer.get_penalty_matrices(): + assert_valid_penalty(P) + + +def test_thinplate_penalty_is_symmetric_but_not_guaranteed_psd(): + X = np.linspace(0, 1, 40).reshape(-1, 1) + transformer = ThinPlateSplineTransformer(n_components=6, random_state=0).fit(X) + with pytest.warns(ConfigWarning, match="experimental"): + P = transformer.get_penalty_matrix() + # PSD is explicitly not guaranteed here (see the class docstring); only symmetry is. + assert_valid_penalty(P, expect_psd=False) + + +@pytest.mark.parametrize("cls", DIFFERENCE_PENALTY_FAMILIES) +@pytest.mark.parametrize("diff_order", [1, 2, 3]) +def test_difference_penalty_rank_matches_null_space_formula(cls, diff_order, X_uniform): + # D^T D built from a diff_order-th difference operator has rank n_basis - diff_order; + # its null space is exactly the discrete polynomials of degree < diff_order. + transformer = cls(output_dim=8, include_bias=False).fit(X_uniform) + n_basis = transformer.n_basis_[0] + P = transformer.get_penalty_matrix(diff_order=diff_order) + assert np.linalg.matrix_rank(P) == n_basis - diff_order + + +@pytest.mark.parametrize("diff_order", [1, 2, 3]) +def test_pspline_penalty_rank_matches_configured_diff_order(diff_order, X_uniform): + transformer = PSplineTransformer(output_dim=8, diff_order=diff_order, include_bias=False).fit(X_uniform) + n_basis = transformer.n_basis_[0] + P = transformer.get_penalty_matrix() + assert np.linalg.matrix_rank(P) == n_basis - diff_order + + +@pytest.mark.parametrize("diff_order", [1, 2]) +def test_tensor_product_marginal_penalty_rank_matches_diff_order(diff_order, X_multi): + transformer = TensorProductSplineTransformer(output_dim=4, diff_order=diff_order, include_bias=False).fit(X_multi) + for i, n_basis in enumerate(transformer.marginal_sizes_): + P = transformer.get_penalty_matrix(feature_index=i) + assert np.linalg.matrix_rank(P) == n_basis - diff_order + + +@pytest.mark.parametrize("cls", DIFFERENCE_PENALTY_FAMILIES) +def test_difference_penalty_is_invariant_to_affine_rescaling(cls, X_uniform): + P_original = cls(output_dim=8).fit(X_uniform).get_penalty_matrix() + P_rescaled = cls(output_dim=8).fit(3.0 * X_uniform + 5.0).get_penalty_matrix() + np.testing.assert_array_equal(P_original, P_rescaled) + + +def test_pspline_penalty_is_invariant_to_affine_rescaling(X_uniform): + P_original = PSplineTransformer(output_dim=8).fit(X_uniform).get_penalty_matrix() + P_rescaled = PSplineTransformer(output_dim=8).fit(3.0 * X_uniform + 5.0).get_penalty_matrix() + np.testing.assert_array_equal(P_original, P_rescaled) + + +def test_tensor_product_penalty_is_invariant_to_affine_rescaling(X_multi): + P_original = TensorProductSplineTransformer(output_dim=4).fit(X_multi).get_penalty_matrix(feature_index=0) + P_rescaled = ( + TensorProductSplineTransformer(output_dim=4).fit(3.0 * X_multi + 5.0).get_penalty_matrix(feature_index=0) + ) + np.testing.assert_array_equal(P_original, P_rescaled) + + +def test_natural_cubic_penalty_scales_as_cube_of_domain_scale(X_uniform): + # Unlike the difference penalties above, an integrated-squared-second-derivative + # penalty is not scale invariant: rescaling the fitted domain by `a` rescales every + # penalty entry by exactly a**3 (verified numerically against a from-scratch fit). + a = 3.0 + P_original = NaturalCubicSplineTransformer(output_dim=6).fit(X_uniform).get_penalty_matrix() + P_rescaled = NaturalCubicSplineTransformer(output_dim=6).fit(a * X_uniform).get_penalty_matrix() + np.testing.assert_allclose(P_rescaled, P_original * a**3, rtol=1e-6) + + +@pytest.mark.parametrize(("cls", "kwargs", "n_features"), ALL_SPLINE_FAMILIES) +def test_feature_names_out_length_matches_transform_width(cls, kwargs, n_features): + rng = np.random.default_rng(0) + X = rng.uniform(0, 1, size=(200, n_features)) + transformer = cls(**kwargs).fit(X) + Xt = transformer.transform(X) + assert len(transformer.get_feature_names_out()) == Xt.shape[1] + + +def test_thinplate_feature_names_out_length_matches_transform_width(): + X = np.linspace(0, 1, 40).reshape(-1, 1) + transformer = ThinPlateSplineTransformer(n_components=6, random_state=0).fit(X) + Xt = transformer.transform(X) + assert len(transformer.get_feature_names_out()) == Xt.shape[1] From 948bb145b9c56071dc9d50afe3bc954db99c41be Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 07:39:33 +0200 Subject: [PATCH 089/123] docs: remove stale claim from reproducibility note --- docs/core_concepts/reproducibility.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/core_concepts/reproducibility.md b/docs/core_concepts/reproducibility.md index 712c879..3d7f510 100644 --- a/docs/core_concepts/reproducibility.md +++ b/docs/core_concepts/reproducibility.md @@ -38,8 +38,9 @@ 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. +`scikit-learn`, `numpy`, and `scipy`, reconstructs dataclasses only from an exact, +closed allow-list, and 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 From 0508e56fff56cee5a7cdba04b1fe85c6d049d7d1 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 07:40:00 +0200 Subject: [PATCH 090/123] fix: clip P-spline and tensor-product out-of-range transforms via policy --- docs/representations/choosing_a_method.md | 24 ++-- pretab/core/base.py | 14 +- pretab/core/policy.py | 26 ++-- pretab/expansion/spline/b_spline.py | 3 + pretab/expansion/spline/base.py | 15 ++- pretab/expansion/spline/cubic_regression.py | 12 +- pretab/expansion/spline/i_spline.py | 3 + pretab/expansion/spline/m_spline.py | 3 + .../spline/multivariate/tensor_product.py | 15 ++- pretab/expansion/spline/natural_cubic.py | 16 ++- pretab/expansion/spline/p_spline.py | 17 ++- tests/core/test_policy.py | 127 ++++++++++++++++++ .../spline/test_pspline_transformer.py | 12 ++ .../spline/test_tensorproduct_transformer.py | 13 ++ 14 files changed, 270 insertions(+), 30 deletions(-) create mode 100644 tests/core/test_policy.py diff --git a/docs/representations/choosing_a_method.md b/docs/representations/choosing_a_method.md index 42570c8..4a168a8 100644 --- a/docs/representations/choosing_a_method.md +++ b/docs/representations/choosing_a_method.md @@ -74,12 +74,15 @@ Very small samples and rely on a scaled input. Extrapolation beyond the fitted range -: Bases are fitted on the training range, and PreTab's default policy is to extrapolate: a - value beyond the fitted range is passed straight into the basis rather than clamped, so the - spline or feature map keeps evaluating past its knots or centers. If your test data lies well - beyond training, that extrapolated value carries no real signal. Set - `policy=RepresentationPolicy(out_of_range="clip")` (or `"warn"` / `"error"`) if you would - rather cap or catch out-of-range inputs. See the edge-case behaviour below. +: Bases are fitted on the training range, and each spline family has its own default + transform-time behavior for values beyond it: B/M/I-spline, P-spline, and tensor-product + clip to the fitted range, while natural-cubic and cubic-regression extrapolate smoothly + (their basis is defined for any input). If your test data lies well beyond training, an + extrapolated value carries no real signal. Pass + `policy=RepresentationPolicy(out_of_range="clip")` (or `"warn"` / `"error"`) directly to any + of these spline transformers to override their default for that instance. This is + standalone-transformer-only: it is not yet threaded through `Preprocessor`. 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 @@ -98,9 +101,12 @@ PreTab is explicit about degenerate inputs rather than failing silently. tensor-product) raise a typed error rather than fitting a meaningless basis. Numeric binning falls back to a single bin instead of raising, and PLE and the feature maps place all their bins or centers at the same value, a degenerate but still valid basis. -- **Out-of-range input at transform**: by default, values beyond the fitted range extrapolate - (the basis keeps evaluating past its knots or centers); set the `out_of_range` policy to - `"clip"`, `"warn"`, or `"error"` for a different, explicit behaviour. +- **Out-of-range input at transform**: B/M/I-spline, P-spline, and tensor-product clip + values beyond the fitted range by default; natural-cubic and cubic-regression extrapolate + smoothly by default. Pass `policy=RepresentationPolicy(out_of_range=...)` directly to any + of these transformers to override its default with `"clip"`, `"warn"`, or `"error"` + instead. This is standalone-transformer-only for now, not yet available through + `Preprocessor`. - **Unseen category**: `ContinuousOrdinalTransformer` (`"int"`) maps an unseen category to a reserved code rather than raising; one-hot encoding (`"one-hot"`) instead emits an all-zero row for it. diff --git a/pretab/core/base.py b/pretab/core/base.py index 6c7a10b..284a0da 100644 --- a/pretab/core/base.py +++ b/pretab/core/base.py @@ -35,6 +35,8 @@ class BasePreTabTransformer( (``_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. + Transformers that expose their own ``policy`` constructor parameter let a + caller override those defaults per-instance; see :meth:`_resolved_policy`. """ _allow_nan: bool = True @@ -54,7 +56,17 @@ class BasePreTabTransformer( n_features_in_: int def _resolved_policy(self) -> RepresentationPolicy: - """Return the shared policy narrowed by this family's override attributes.""" + """Return the effective edge-case policy for this instance. + + An explicit ``policy`` constructor argument, on the transformers that expose + one, always wins verbatim over the class-level defaults below. Otherwise, the + shared default policy is narrowed by this family's ``_constant_policy`` / + ``_out_of_range_policy`` class attributes, which record each family's + historical, non-configurable default behavior. + """ + instance_policy = getattr(self, "policy", None) + if instance_policy is not None: + return RepresentationPolicy.resolve(instance_policy) return self._policy.merge( constant=self._constant_policy, out_of_range=self._out_of_range_policy, diff --git a/pretab/core/policy.py b/pretab/core/policy.py index 4374ab0..10a27ec 100644 --- a/pretab/core/policy.py +++ b/pretab/core/policy.py @@ -7,11 +7,11 @@ * ``out_of_range``: values at ``transform`` outside the fitted range (``"error"`` / ``"warn"`` / ``"clip"`` / ``"extrapolate"``). -The defaults reproduce the library's historical behaviour (constant columns pass -through, ranges extrapolate), 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`). +The defaults reproduce each family's historical behaviour (B/M/I-spline, P-spline, and +tensor-product clip out-of-range inputs; natural-cubic and cubic-regression extrapolate; +constant columns pass through everywhere), so leaving ``policy`` unset changes nothing. +Transformers may narrow specific axes through class-level override attributes without +exposing a new constructor parameter (see :class:`~pretab.core.base.BasePreTabTransformer`). Missing values and non-finite (``inf`` / ``-inf``) inputs are handled elsewhere: see ``Preprocessor.missing_policy`` for missing-value handling, and note that @@ -19,13 +19,17 @@ policy (there is no configurable axis for it). .. note:: - ``out_of_range`` is not yet reachable from any public API: no transformer - constructor accepts a ``policy`` argument, and ``Preprocessor.policy`` is - only used for its own top-level ``constant`` check, never threaded into - ``PreprocessorConfig`` or the transformers it builds. Wiring this through - (transformer constructors, the registry's ``allowed_args``, and - ``PreprocessorConfig``) is deferred to a follow-up; see the "Spline + ``out_of_range`` is reachable standalone: ``BSplineTransformer``, ``MSplineTransformer``, + ``ISplineTransformer``, ``PSplineTransformer``, ``TensorProductSplineTransformer``, + ``NaturalCubicSplineTransformer``, and ``CubicRegressionSplineTransformer`` all accept a + ``policy`` constructor parameter that is genuinely respected at ``transform`` time. It is + not yet threaded through ``Preprocessor``: ``Preprocessor.policy`` is still only used for + its own top-level ``constant`` check, never passed into ``PreprocessorConfig`` or the + transformers it builds. Wiring it through the registry's ``allowed_args`` and + ``PreprocessorConfig`` remains a separately-tracked follow-up; see the "Spline expansions" section of ``dev/todo/release-1.0.0/bugfixes-1.0.0.md``. + ``ThinPlateSplineTransformer`` is not wired (its penalty is already experimental and it + has no single-feature knot range in the same sense). """ from __future__ import annotations diff --git a/pretab/expansion/spline/b_spline.py b/pretab/expansion/spline/b_spline.py index 8c0e70f..293471c 100644 --- a/pretab/expansion/spline/b_spline.py +++ b/pretab/expansion/spline/b_spline.py @@ -11,6 +11,7 @@ from scipy.interpolate import BSpline from ...core.parameters import UNSET +from ...core.policy import RepresentationPolicy from .base import BaseSplineTransformer @@ -54,6 +55,7 @@ def __init__( min_output_dim=UNSET, max_output_dim=UNSET, random_state: int | None = None, + policy: RepresentationPolicy | dict | None = None, ): super().__init__( output_dim=output_dim, @@ -67,6 +69,7 @@ def __init__( min_output_dim=min_output_dim, max_output_dim=max_output_dim, random_state=random_state, + policy=policy, ) def _feature_suffix(self) -> str: diff --git a/pretab/expansion/spline/base.py b/pretab/expansion/spline/base.py index f9a3826..ab365bf 100644 --- a/pretab/expansion/spline/base.py +++ b/pretab/expansion/spline/base.py @@ -28,6 +28,7 @@ uniform_knots, ) from ...core.parameters import UNSET, validate_placement +from ...core.policy import RepresentationPolicy, resolve_out_of_range from ...core.supervised import warn_target_leakage from ...exceptions import ( IncompatibleParamsError, @@ -93,6 +94,13 @@ class BaseSplineTransformer(BasePreTabTransformer): random_state : int or None, default=None Random state forwarded to the target-aware selector for reproducibility. + policy : RepresentationPolicy, dict, or None, default=None + Overrides this family's default ``out_of_range`` behavior (unconditional + clipping to the fitted knot range). Pass e.g. + ``RepresentationPolicy(out_of_range="error")`` to raise instead, or + ``"warn"`` / ``"extrapolate"`` for the other supported reactions. Leaving + this at ``None`` preserves the historical clip-on-transform behavior. + Attributes ---------- knots_ : list of ndarray @@ -131,6 +139,7 @@ class BaseSplineTransformer(BasePreTabTransformer): _representation_component_kind = "basis" _representation_supervision = "optional" _representation_local_support = True + _out_of_range_policy: ClassVar[str | None] = "clip" def __init__( self, @@ -145,6 +154,7 @@ def __init__( min_output_dim=UNSET, max_output_dim=UNSET, random_state: int | None = None, + policy: RepresentationPolicy | dict | None = None, ): self.output_dim = output_dim self.degree = degree @@ -157,6 +167,7 @@ def __init__( self.min_output_dim = min_output_dim self.max_output_dim = max_output_dim self.random_state = random_state + self.policy = policy _selector_spline_type: ClassVar[Literal["bspline", "mspline", "ispline"]] = "bspline" @@ -311,8 +322,8 @@ def transform(self, X): transformed = [] for i in range(X.shape[1]): knots = self.knots_[i] - xi_clipped = np.clip(X[:, i], knots[0], knots[-1]) - design = self._design_matrix(xi_clipped, knots) + xi = resolve_out_of_range(X[:, i], knots[0], knots[-1], self._resolved_policy(), estimator=self) + design = self._design_matrix(xi, knots) if self.include_bias: design = np.hstack([np.ones((design.shape[0], 1)), design]) transformed.append(design) diff --git a/pretab/expansion/spline/cubic_regression.py b/pretab/expansion/spline/cubic_regression.py index a651f9a..392a99d 100644 --- a/pretab/expansion/spline/cubic_regression.py +++ b/pretab/expansion/spline/cubic_regression.py @@ -5,6 +5,7 @@ from sklearn.utils.validation import check_is_fitted from ...core.parameters import UNSET, validate_placement +from ...core.policy import RepresentationPolicy, resolve_out_of_range from ...core.supervised import warn_target_leakage from ...exceptions import InvalidParamError from ...placement.adapters import SplinePlacementAdapter @@ -71,6 +72,12 @@ class CubicRegressionSplineTransformer(SplineBasisMixin, TransformerMixin, BaseE random_state : int or None, default=None Random state forwarded to the target-aware selector for reproducibility. + policy : RepresentationPolicy, dict, or None, default=None + Overrides this family's default ``out_of_range`` behavior (smooth polynomial + extrapolation beyond the fitted range). Pass e.g. + ``RepresentationPolicy(out_of_range="clip")`` to clamp instead. Leaving + this at ``None`` preserves the historical extrapolation behavior. + Attributes ---------- knots_ : list of ndarray @@ -128,6 +135,7 @@ def __init__( min_output_dim=UNSET, max_output_dim=UNSET, random_state: int | None = None, + policy: RepresentationPolicy | dict | None = None, ): self.output_dim = output_dim self.degree = degree @@ -139,6 +147,7 @@ def __init__( self.min_output_dim = min_output_dim self.max_output_dim = max_output_dim self.random_state = random_state + self.policy = policy def _bspline_basis(self, x, knots): x = np.asarray(x).reshape(-1, 1) @@ -201,9 +210,10 @@ def transform(self, X): check_is_fitted(self, "n_basis_") X = self._validate_allow_nan(X, reset=False) + policy = self._resolved_policy() transformed = [] for i in range(X.shape[1]): - xi = X[:, i] + xi = resolve_out_of_range(X[:, i], self.x_min_[i], self.x_max_[i], policy, estimator=self) design = self._bspline_basis(xi, self.knots_[i]) transformed.append(design) diff --git a/pretab/expansion/spline/i_spline.py b/pretab/expansion/spline/i_spline.py index cc1130e..85f374c 100644 --- a/pretab/expansion/spline/i_spline.py +++ b/pretab/expansion/spline/i_spline.py @@ -12,6 +12,7 @@ from scipy.interpolate import BSpline from ...core.parameters import UNSET +from ...core.policy import RepresentationPolicy from .base import BaseSplineTransformer @@ -54,6 +55,7 @@ def __init__( min_output_dim=UNSET, max_output_dim=UNSET, random_state: int | None = None, + policy: RepresentationPolicy | dict | None = None, ): super().__init__( output_dim=output_dim, @@ -67,6 +69,7 @@ def __init__( min_output_dim=min_output_dim, max_output_dim=max_output_dim, random_state=random_state, + policy=policy, ) def _feature_suffix(self) -> str: diff --git a/pretab/expansion/spline/m_spline.py b/pretab/expansion/spline/m_spline.py index ed8725f..f98682e 100644 --- a/pretab/expansion/spline/m_spline.py +++ b/pretab/expansion/spline/m_spline.py @@ -12,6 +12,7 @@ from scipy.interpolate import BSpline from ...core.parameters import UNSET +from ...core.policy import RepresentationPolicy from .base import BaseSplineTransformer @@ -52,6 +53,7 @@ def __init__( min_output_dim=UNSET, max_output_dim=UNSET, random_state: int | None = None, + policy: RepresentationPolicy | dict | None = None, ): super().__init__( output_dim=output_dim, @@ -65,6 +67,7 @@ def __init__( min_output_dim=min_output_dim, max_output_dim=max_output_dim, random_state=random_state, + policy=policy, ) def _feature_suffix(self) -> str: diff --git a/pretab/expansion/spline/multivariate/tensor_product.py b/pretab/expansion/spline/multivariate/tensor_product.py index 4eaaccf..2be256b 100644 --- a/pretab/expansion/spline/multivariate/tensor_product.py +++ b/pretab/expansion/spline/multivariate/tensor_product.py @@ -4,6 +4,7 @@ from ....core.knots import bspline_basis from ....core.parameters import UNSET +from ....core.policy import RepresentationPolicy, resolve_out_of_range from ....exceptions import InvalidParamError from ..mixins import SplineBasisMixin @@ -70,6 +71,12 @@ class TensorProductSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEst max_output_dim : int or None, default=None Unused for this unsupervised-only family (kept for API parity). + policy : RepresentationPolicy, dict, or None, default=None + Overrides this family's default ``out_of_range`` behavior (unconditional + clipping to each marginal dimension's fitted knot range). Pass e.g. + ``RepresentationPolicy(out_of_range="error")`` to raise instead. Leaving + this at ``None`` preserves the historical clip-on-transform behavior. + Attributes ---------- dim_ : int @@ -126,6 +133,7 @@ class TensorProductSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEst _representation_family = "tensorspline" _representation_scope = "multivariate" _representation_local_support = True + _out_of_range_policy = "clip" def __init__( self, @@ -137,6 +145,7 @@ def __init__( adaptive: bool = False, min_output_dim=UNSET, max_output_dim=UNSET, + policy: RepresentationPolicy | dict | None = None, ): self.output_dim = output_dim self.degree = degree @@ -146,6 +155,7 @@ def __init__( self.adaptive = adaptive self.min_output_dim = min_output_dim self.max_output_dim = max_output_dim + self.policy = policy def _pad_knots(self, inner): return np.concatenate((np.repeat(inner[0], self.degree), inner, np.repeat(inner[-1], self.degree))) @@ -219,9 +229,12 @@ def transform(self, X): check_is_fitted(self, "marginal_sizes_") X = self._validate_allow_nan(X, reset=False) + policy = self._resolved_policy() bases = [] for d in range(self.dim_): - basis = self._basis_matrix(X[:, d], self.knots_[d]) + knots = self.knots_[d] + xd = resolve_out_of_range(X[:, d], knots[0], knots[-1], policy, estimator=self) + basis = self._basis_matrix(xd, knots) bases.append(basis) n_samples = X.shape[0] diff --git a/pretab/expansion/spline/natural_cubic.py b/pretab/expansion/spline/natural_cubic.py index 9b88929..21c3f45 100644 --- a/pretab/expansion/spline/natural_cubic.py +++ b/pretab/expansion/spline/natural_cubic.py @@ -4,6 +4,7 @@ from sklearn.utils.validation import check_is_fitted from ...core.parameters import UNSET, validate_placement +from ...core.policy import RepresentationPolicy, resolve_out_of_range from ...core.supervised import warn_target_leakage from ...exceptions import InvalidParamError from ...placement.adapters import SplinePlacementAdapter @@ -73,6 +74,13 @@ class NaturalCubicSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEsti random_state : int or None, default=None Random state forwarded to the target-aware selector for reproducibility. + policy : RepresentationPolicy, dict, or None, default=None + Overrides this family's default ``out_of_range`` behavior (smooth linear + extrapolation beyond the fitted range, the natural-spline boundary + constraint this family is named for). Pass e.g. + ``RepresentationPolicy(out_of_range="clip")`` to clamp instead. Leaving + this at ``None`` preserves the historical extrapolation behavior. + Attributes ---------- knots_ : list of ndarray @@ -131,6 +139,7 @@ def __init__( min_output_dim=UNSET, max_output_dim=UNSET, random_state: int | None = None, + policy: RepresentationPolicy | dict | None = None, ): self.output_dim = output_dim self.degree = degree @@ -142,6 +151,7 @@ def __init__( self.min_output_dim = min_output_dim self.max_output_dim = max_output_dim self.random_state = random_state + self.policy = policy def _basis(self, x, knots): x = np.asarray(x).reshape(-1, 1) @@ -206,10 +216,12 @@ def transform(self, X): check_is_fitted(self, "n_basis_") X = self._validate_allow_nan(X, reset=False) + policy = self._resolved_policy() transformed = [] for i in range(X.shape[1]): - xi = X[:, i] - basis = self._basis(xi, self.knots_[i]) + knots = self.knots_[i] + xi = resolve_out_of_range(X[:, i], knots[0], knots[-1], policy, estimator=self) + basis = self._basis(xi, knots) transformed.append(basis) return np.hstack(transformed) diff --git a/pretab/expansion/spline/p_spline.py b/pretab/expansion/spline/p_spline.py index 13260dc..8328484 100644 --- a/pretab/expansion/spline/p_spline.py +++ b/pretab/expansion/spline/p_spline.py @@ -4,6 +4,7 @@ from ...core.knots import bspline_basis from ...core.parameters import UNSET +from ...core.policy import RepresentationPolicy, resolve_out_of_range from ...exceptions import InvalidParamError from .mixins import SplineBasisMixin @@ -67,6 +68,12 @@ class PSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEstimator): max_output_dim : int or None, default=None Unused for this unsupervised-only family (kept for API parity). + policy : RepresentationPolicy, dict, or None, default=None + Overrides this family's default ``out_of_range`` behavior (unconditional + clipping to the fitted knot range). Pass e.g. + ``RepresentationPolicy(out_of_range="error")`` to raise instead. Leaving + this at ``None`` preserves the historical clip-on-transform behavior. + Attributes ---------- knots_ : list of ndarray @@ -115,6 +122,7 @@ class PSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEstimator): _feature_suffix_value = "ps" _representation_family = "pspline" _representation_local_support = True + _out_of_range_policy = "clip" def __init__( self, @@ -126,6 +134,7 @@ def __init__( adaptive: bool = False, min_output_dim=UNSET, max_output_dim=UNSET, + policy: RepresentationPolicy | dict | None = None, ): self.output_dim = output_dim self.degree = degree @@ -135,6 +144,7 @@ def __init__( self.adaptive = adaptive self.min_output_dim = min_output_dim self.max_output_dim = max_output_dim + self.policy = policy def fit(self, X, y=None): X = self._validate_allow_nan(X, reset=True) @@ -194,11 +204,12 @@ def transform(self, X): all_basis = [] for i in range(X.shape[1]): - x = X[:, i] - nb = len(self.knots_[i]) - self.degree - 1 + knots = self.knots_[i] + x = resolve_out_of_range(X[:, i], knots[0], knots[-1], self._resolved_policy(), estimator=self) + nb = len(knots) - self.degree - 1 basis = np.zeros((len(x), nb)) for j in range(nb): - basis[:, j] = bspline_basis(x, self.knots_[i], self.degree, j) + basis[:, j] = bspline_basis(x, knots, self.degree, j) if self.include_bias: basis = np.hstack([np.ones((len(x), 1)), basis]) all_basis.append(basis) diff --git a/tests/core/test_policy.py b/tests/core/test_policy.py new file mode 100644 index 0000000..0b99b20 --- /dev/null +++ b/tests/core/test_policy.py @@ -0,0 +1,127 @@ +"""Coverage for ``RepresentationPolicy.out_of_range`` actually reaching every spline family. + +Each knot/range-based spline transformer (B/M/I-spline, P-spline, tensor-product, +natural-cubic, cubic-regression) accepts its own ``policy`` constructor parameter. +This is standalone-only wiring: the policy is genuinely respected by the transformer +itself, but it is not threaded through ``Preprocessor``/the registry (that remains a +separately-tracked gap; see ``dev/todo/release-1.0.0/bugfixes-1.0.0.md``). + +These tests close the gap flagged in the v1.0.0 hardening review: no test previously +exercised ``RepresentationPolicy(out_of_range=...)`` through any spline transformer at +all, since ``resolve_out_of_range`` had zero call sites anywhere in the library. +``ThinPlateSplineTransformer`` is intentionally excluded: it has no single-feature +knot range in the same sense (a kernel evaluated at any point) and is out of scope for +this pass. +""" + +import numpy as np +import pytest + +from pretab.core.policy import RepresentationPolicy +from pretab.exceptions import DataWarning, PretabDataError +from pretab.transformers import ( + BSplineTransformer, + CubicRegressionSplineTransformer, + ISplineTransformer, + MSplineTransformer, + NaturalCubicSplineTransformer, + PSplineTransformer, + TensorProductSplineTransformer, +) + +SINGLE_FEATURE_FAMILIES = [ + (BSplineTransformer, {"output_dim": 8}), + (MSplineTransformer, {"output_dim": 8}), + (ISplineTransformer, {"output_dim": 8}), + (PSplineTransformer, {"output_dim": 8}), + (NaturalCubicSplineTransformer, {"output_dim": 6}), + (CubicRegressionSplineTransformer, {"output_dim": 8}), +] + + +@pytest.mark.parametrize(("cls", "kwargs"), SINGLE_FEATURE_FAMILIES) +def test_out_of_range_error_policy_raises(cls, kwargs): + X = np.linspace(0, 10, 100).reshape(-1, 1) + transformer = cls(policy=RepresentationPolicy(out_of_range="error"), **kwargs).fit(X) + with pytest.raises(PretabDataError): + transformer.transform(np.array([[20.0]])) + + +@pytest.mark.parametrize(("cls", "kwargs"), SINGLE_FEATURE_FAMILIES) +def test_out_of_range_warn_policy_warns(cls, kwargs): + X = np.linspace(0, 10, 100).reshape(-1, 1) + transformer = cls(policy=RepresentationPolicy(out_of_range="warn"), **kwargs).fit(X) + with pytest.warns(DataWarning): + transformer.transform(np.array([[20.0]])) + + +@pytest.mark.parametrize(("cls", "kwargs"), SINGLE_FEATURE_FAMILIES) +def test_out_of_range_clip_policy_matches_boundary_value(cls, kwargs): + X = np.linspace(0, 10, 100).reshape(-1, 1) + transformer = cls(policy=RepresentationPolicy(out_of_range="clip"), **kwargs).fit(X) + at_max = transformer.transform(np.array([[10.0]])) + past_max = transformer.transform(np.array([[20.0]])) + np.testing.assert_allclose(past_max, at_max, atol=1e-6) + + +def test_tensorproduct_out_of_range_error_policy_raises(): + X = np.linspace(0, 10, 100).reshape(-1, 1) + X = np.hstack([X, X]) + transformer = TensorProductSplineTransformer(output_dim=4, policy=RepresentationPolicy(out_of_range="error")).fit(X) + with pytest.raises(PretabDataError): + transformer.transform(np.array([[20.0, 20.0]])) + + +def test_tensorproduct_out_of_range_clip_policy_matches_boundary_value(): + X = np.linspace(0, 10, 100).reshape(-1, 1) + X = np.hstack([X, X]) + transformer = TensorProductSplineTransformer(output_dim=4, policy=RepresentationPolicy(out_of_range="clip")).fit(X) + at_max = transformer.transform(np.array([[10.0, 10.0]])) + past_max = transformer.transform(np.array([[20.0, 20.0]])) + np.testing.assert_allclose(past_max, at_max, atol=1e-6) + + +def test_natural_cubic_and_cubic_regression_default_to_extrapolate(): + # Unlike B/M/I/P-spline/tensor-product (which default to "clip"), these two + # families extrapolate smoothly by design and only clip on request. + X = np.linspace(0, 10, 100).reshape(-1, 1) + for cls, kwargs in [ + (NaturalCubicSplineTransformer, {"output_dim": 6}), + (CubicRegressionSplineTransformer, {"output_dim": 8}), + ]: + default = cls(**kwargs).fit(X) + clipped = cls(policy=RepresentationPolicy(out_of_range="clip"), **kwargs).fit(X) + at_max = default.transform(np.array([[10.0]])) + default_past_max = default.transform(np.array([[20.0]])) + clipped_past_max = clipped.transform(np.array([[20.0]])) + assert not np.allclose(default_past_max, at_max) + np.testing.assert_allclose(clipped_past_max, at_max, atol=1e-6) + + +def test_bmi_and_pspline_and_tensor_default_to_clip(): + # These families have always clipped unconditionally; confirm the default + # (policy=None) still does, now that it's routed through resolve_out_of_range. + X = np.linspace(0, 10, 100).reshape(-1, 1) + for cls, kwargs in [ + (BSplineTransformer, {"output_dim": 8}), + (MSplineTransformer, {"output_dim": 8}), + (ISplineTransformer, {"output_dim": 8}), + (PSplineTransformer, {"output_dim": 8}), + ]: + transformer = cls(**kwargs).fit(X) + at_max = transformer.transform(np.array([[10.0]])) + past_max = transformer.transform(np.array([[20.0]])) + np.testing.assert_allclose(past_max, at_max, atol=1e-6) + + X2 = np.hstack([X, X]) + transformer = TensorProductSplineTransformer(output_dim=4).fit(X2) + at_max = transformer.transform(np.array([[10.0, 10.0]])) + past_max = transformer.transform(np.array([[20.0, 20.0]])) + np.testing.assert_allclose(past_max, at_max, atol=1e-6) + + +def test_policy_dict_is_accepted(): + X = np.linspace(0, 10, 100).reshape(-1, 1) + transformer = BSplineTransformer(output_dim=8, policy={"out_of_range": "error"}).fit(X) + with pytest.raises(PretabDataError): + transformer.transform(np.array([[20.0]])) diff --git a/tests/expansion/spline/test_pspline_transformer.py b/tests/expansion/spline/test_pspline_transformer.py index d89fa2e..c7ebac1 100644 --- a/tests/expansion/spline/test_pspline_transformer.py +++ b/tests/expansion/spline/test_pspline_transformer.py @@ -95,3 +95,15 @@ def test_pspline_transform_requires_fit(): transformer.transform(np.random.rand(5, 1)) with pytest.raises(NotFittedError): transformer.get_penalty_matrix() + + +def test_pspline_out_of_range_transform_clips_to_boundary(): + # Regression test: out-of-range transform inputs used to produce an abrupt + # all-zero row instead of clipping like the B/M/I splines already do. + X = np.linspace(0, 10, 100).reshape(-1, 1) + transformer = PSplineTransformer(output_dim=8).fit(X) + at_max = transformer.transform(np.array([[10.0]])) + just_past_max = transformer.transform(np.array([[10.0 + 1e-4]])) + far_past_max = transformer.transform(np.array([[20.0]])) + np.testing.assert_allclose(just_past_max, at_max, atol=1e-6) + np.testing.assert_allclose(far_past_max, at_max, atol=1e-6) diff --git a/tests/expansion/spline/test_tensorproduct_transformer.py b/tests/expansion/spline/test_tensorproduct_transformer.py index f5ca12c..617b9a5 100644 --- a/tests/expansion/spline/test_tensorproduct_transformer.py +++ b/tests/expansion/spline/test_tensorproduct_transformer.py @@ -69,6 +69,19 @@ def test_tensorproduct_spline_penalty_matrices_match_true_quadratic_form(): assert lib_value == pytest.approx(true_value, rel=1e-8), f"mismatch for dim={dim}" +def test_tensorproduct_out_of_range_transform_clips_to_boundary(): + # Regression test: out-of-range transform inputs used to produce an abrupt + # all-zero row instead of clipping like the B/M/I splines already do. + X = np.linspace(0, 10, 100).reshape(-1, 1) + X = np.hstack([X, X]) + transformer = TensorProductSplineTransformer(output_dim=4).fit(X) + at_max = transformer.transform(np.array([[10.0, 10.0]])) + just_past_max = transformer.transform(np.array([[10.0 + 1e-4, 10.0 + 1e-4]])) + far_past_max = transformer.transform(np.array([[20.0, 20.0]])) + np.testing.assert_allclose(just_past_max, at_max, atol=1e-6) + np.testing.assert_allclose(far_past_max, at_max, atol=1e-6) + + @pytest.mark.parametrize("diff_order", [-1, 0]) def test_tensorproduct_rejects_nonpositive_diff_order(diff_order): X = np.random.default_rng(0).random((30, 2)) From f02de9d8c1dd3d62e74cd2e3ca2b25d99bf9ddb2 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 08:27:45 +0200 Subject: [PATCH 091/123] docs: fix corrupted underscore identifiers in changelog --- CHANGELOG.md | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c50c033..d8163e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ 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 + +### Fix + +- Require scikit-learn >=1.6, matching the validation and estimator-tag APIs used by the implementation. +- Preserve NumPy 1.24 support in natural-spline penalties and add minimum-dependency CI coverage. +- Keep fitted fingerprints stable across inference batches and lifecycle annotations. +- Prevent `fit` and `fit_transform` from overwriting frozen preprocessors. +- Compare representation-search candidates on identical cross-validation folds. +- Reject invalid cross-fitting task names instead of silently using regression folds. +- Bypass estimator `__new__` hooks when reconstructing serialized state. +- Exercise installed wheels in isolation and run the full quickstart before publishing. +- Require the tagged revision to pass CI and documentation checks before either publishing workflow uploads artifacts. +- Correct contributor commands, README examples, classification configuration, and migration guidance. +- Clarify serialization support and trust requirements, and execute introductory documentation examples in tests. + ## v1.0.0rc3 (2026-09-01) ### Fix @@ -47,9 +63,9 @@ Going forward, this file is updated automatically by `cz bump` on each release. - **core**: raise on mismatched input_features length in get_feature_names_out (issue #21) - **locations**: keep importance aligned with locations after sort/dedupe (issue #21) - keep location provided by tree when suplementing -- provide appropriate error for onehot_from_ordinal input +- provide appropriate error for onehot_from_ordinal input - **splines,transformers**: close B-spline final span and fix ContinuousOrdinalTransformer DataFrame input (issues #12, #14) -- **selectors**: make _enforce_spacing order-independent to fix lightgbm clustering (issue #10) +- **selectors**: make \_enforce_spacing order-independent to fix lightgbm clustering (issue #10) ### Perf @@ -133,7 +149,7 @@ Going forward, this file is updated automatically by `cz bump` on each release. ### Refactor -- **core**: rename typing module to _typing and add estimator protocols +- **core**: rename typing module to \_typing and add estimator protocols - **transformers**: move Fourier and kernel-approximation maps into feature_maps/ - **transformers**: rename core transformers, drop temporal utils - **compose**: add capability registry and slim preprocessor @@ -143,7 +159,7 @@ Going forward, this file is updated automatically by `cz bump` on each release. - remove dead selection helpers - **ple**: use location selectors for thresholds - add shared resolve_locations helper -- **feature_maps**: move strategy/task validation from __init__ to fit +- **feature_maps**: move strategy/task validation from `__init__` to fit - **transformers**: drop utils, move BaseCenterExpansion ti feature_maps - **preprocessor**: make it a compliant sklearn estimator - canonicalize pipeline kwargs to avoid deprecation warnings From 793d10e447339fe02b54308be0dfcf77f49883a3 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 08:28:06 +0200 Subject: [PATCH 092/123] fix: reject fit on a frozen preprocessor --- pretab/preprocessor.py | 34 +++++++++++++------ tests/integration/test_fingerprint.py | 15 ++++++++ tests/integration/test_frozen_lifecycle.py | 12 +++++++ .../_golden/featuremap_unsupervised.json | 5 +-- tests/regression/_golden/ple_supervised.json | 7 ++-- .../_golden/spline_unsupervised.json | 5 +-- 6 files changed, 55 insertions(+), 23 deletions(-) diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index 9509952..14fbfa0 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -148,8 +148,8 @@ class Preprocessor(TransformerMixin, BaseEstimator): feature range) or ``"quantile"`` (spaced by the data quantiles). Applies to the feature maps, PLE, and the knot-based splines (``"bspline"`` / ``"mspline"`` / ``"ispline"`` / ``"cubicspline"`` / ``"naturalspline"``); the always-target-aware ``"ple"`` only honors the - supervised strategies, while the penalized ``"pspline"`` / ``"tensorspline"`` (which assume - equally-spaced knots) and the kernel-based ``"tprs"`` only honor the unsupervised ones. + supervised strategies, while the penalized ``"pspline"`` only honors uniform placement. + Multivariate tensor-product and thin-plate splines are available as standalone transformers. task : str, default="regression" Supervised task (``"regression"`` or ``"classification"``) used by target-aware methods to place basis units / knots against ``y``. Only consulted when ``target_aware`` is True. @@ -302,8 +302,8 @@ class Preprocessor(TransformerMixin, BaseEstimator): Available ``numerical_method`` values: ``"none"``, ``"minmax"``, ``"standardization"``, ``"robust"``, ``"quantile"``, ``"polynomial"``, ``"box-cox"``, ``"yeo-johnson"``, ``"ple"``, ``"custombin"``, ``"rbf"``, ``"relu"``, ``"sigmoid"``, ``"tanh"``, ``"cubicspline"``, - ``"naturalspline"``, ``"pspline"``, ``"tensorspline"``, ``"tprs"``, ``"bspline"``, - ``"mspline"``, ``"ispline"``. + ``"naturalspline"``, ``"pspline"``, ``"bspline"``, ``"mspline"``, ``"ispline"``, + ``"fourier"``. Available ``categorical_method`` values: ``"int"``, ``"one-hot"``, ``"onehot_from_ordinal"``, ``"pretrained"``, ``"none"``. The ``"pretrained"`` method requires the optional @@ -313,7 +313,7 @@ class Preprocessor(TransformerMixin, BaseEstimator): ``"one-hot"``, ``"one_hot"`` and ``"OneHot"`` are equivalent. Common synonyms and abbreviations are also accepted, e.g. ``"std"`` / ``"standard"`` -> ``"standardization"``, ``"ohe"`` / ``"dummy"`` -> ``"one-hot"``, ``"ordinal"`` / ``"label"`` -> ``"int"``, ``"poly"`` -> - ``"polynomial"``, ``"thin-plate"`` -> ``"tprs"``, and ``"passthrough"`` -> ``"none"``. + ``"polynomial"``, and ``"passthrough"`` -> ``"none"``. ``transform`` returns a single stacked array by default (``output_structure="matrix"``), or a dict of per-feature blocks keyed ``num_`` / ``cat_`` when @@ -454,6 +454,11 @@ def fit(self, X, y=None, embeddings=None): Fitted instance of the preprocessor. """ + if self.is_frozen(): + raise FrozenRepresentationError( + f"Cannot fit a frozen {type(self).__name__}. Use refit() to fit a fresh copy." + ) + verbose = int(self.verbose or 0) if verbose > 0: configure_logging(verbose) @@ -966,8 +971,10 @@ def to_spec(self, path=None) -> dict: 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. + :meth:`from_spec` in the same environment. Loading bypasses estimator + initialization and pickle hooks, but library imports can execute code. + Only load specs from trusted sources. Third-party representations and + pretrained language models are not supported by this serializer. Parameters ---------- @@ -1017,12 +1024,19 @@ def from_spec(cls, source) -> "Preprocessor": def _canonical_spec(self) -> dict: """Deterministic subset of the spec used for fingerprinting.""" spec = preprocessor_to_spec(self) + # Runtime reports and advisory lifecycle flags do not change the fitted + # representation. Keep them in serialization, but out of its identity. + state = { + key: value + for key, value in spec["state"].items() + if key not in {"output_report_", "_frozen", "_stale_reason"} + } 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"], + "state": state, } @property @@ -1033,7 +1047,7 @@ def fingerprint_(self) -> str: 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. + the same fitted state and configuration produce the same fingerprint. """ check_is_fitted(self) canonical = json.dumps(self._canonical_spec(), sort_keys=True, separators=(",", ":"), ensure_ascii=True) @@ -1086,7 +1100,7 @@ def is_frozen(self) -> bool: return bool(getattr(self, "_frozen", False)) def freeze(self) -> "Preprocessor": - """Freeze the fitted preprocessor, blocking further ``set_params`` mutation. + """Freeze the fitted preprocessor, blocking ``fit`` and ``set_params`` mutation. Returns ``self`` for chaining. A frozen preprocessor is intended as an immutable deployment artifact; use :meth:`clone_unfitted` or :meth:`refit` diff --git a/tests/integration/test_fingerprint.py b/tests/integration/test_fingerprint.py index f381464..597ad0d 100644 --- a/tests/integration/test_fingerprint.py +++ b/tests/integration/test_fingerprint.py @@ -51,6 +51,21 @@ def test_fingerprint_survives_round_trip(frame, target): assert restored.fingerprint_ == p.fingerprint_ +def test_fingerprint_stable_after_inference_and_lifecycle_changes(frame, target): + p = _fit(frame, target) + fingerprint = p.fingerprint_ + for batch in (frame, frame.iloc[:3]): + p.transform(batch) + assert p.fingerprint_ == fingerprint + p.mark_stale("new training data available") + p.freeze() + assert p.fingerprint_ == fingerprint + restored = Preprocessor.from_spec(p.to_spec()) + assert restored.fingerprint_ == fingerprint + assert restored.is_frozen() + assert restored.stale_reason_ == "new training data available" + + def test_fingerprint_changes_with_config(frame, target): a = _fit(frame, target, output_dim=6) b = _fit(frame, target, output_dim=9) diff --git a/tests/integration/test_frozen_lifecycle.py b/tests/integration/test_frozen_lifecycle.py index 61692f8..9c491c7 100644 --- a/tests/integration/test_frozen_lifecycle.py +++ b/tests/integration/test_frozen_lifecycle.py @@ -55,6 +55,18 @@ def test_set_params_allowed_before_freeze(frame, target): assert p.output_dim == 9 +@pytest.mark.parametrize("method", ["fit", "fit_transform"]) +def test_frozen_fit_rejected_without_mutating_state(frame, target, method): + p = _make().fit(frame, target).freeze() + fingerprint = p.fingerprint_ + original = p.transform(frame, return_array=True) + changed = frame.assign(a=frame["a"] * 100) + with pytest.raises(FrozenRepresentationError, match="frozen"): + getattr(p, method)(changed, target) + assert p.fingerprint_ == fingerprint + np.testing.assert_array_equal(p.transform(frame, return_array=True), original) + + def test_clone_unfitted_returns_fresh_unfrozen(frame, target): p = _make().fit(frame, target).freeze() clone = p.clone_unfitted() diff --git a/tests/regression/_golden/featuremap_unsupervised.json b/tests/regression/_golden/featuremap_unsupervised.json index 30e67d6..5918395 100644 --- a/tests/regression/_golden/featuremap_unsupervised.json +++ b/tests/regression/_golden/featuremap_unsupervised.json @@ -1,8 +1,5 @@ { - "shape": [ - 200, - 20 - ], + "shape": [200, 20], "feature_names": [ "num_num_linear_rbf0", "num_num_linear_rbf1", diff --git a/tests/regression/_golden/ple_supervised.json b/tests/regression/_golden/ple_supervised.json index c5abed1..bad52ff 100644 --- a/tests/regression/_golden/ple_supervised.json +++ b/tests/regression/_golden/ple_supervised.json @@ -1,8 +1,5 @@ { - "shape": [ - 200, - 23 - ], + "shape": [200, 23], "feature_names": [ "num_num_linear_ple0", "num_num_linear_ple1", @@ -28,4 +25,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 9fbe3bb..0f1263f 100644 --- a/tests/regression/_golden/spline_unsupervised.json +++ b/tests/regression/_golden/spline_unsupervised.json @@ -1,8 +1,5 @@ { - "shape": [ - 200, - 29 - ], + "shape": [200, 29], "feature_names": [ "num_num_linear_ncs0", "num_num_linear_ncs1", From 89f8bfe4c977b79c8f5c7a281a053cc7cc8f9201 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 08:28:31 +0200 Subject: [PATCH 093/123] fix: correct serialization safety claims and bypass __new__ hooks --- docs/core_concepts/reproducibility.md | 30 +++++++++++++++------------ pretab/compose/serialize.py | 17 +++++++++------ 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/docs/core_concepts/reproducibility.md b/docs/core_concepts/reproducibility.md index 3d7f510..ec6f1e3 100644 --- a/docs/core_concepts/reproducibility.md +++ b/docs/core_concepts/reproducibility.md @@ -37,14 +37,17 @@ restored = Preprocessor.from_spec("representation.json") ``` ```{important} -`from_spec` is a safe alternative to pickle. Reconstruction imports only from `pretab`, -`scikit-learn`, `numpy`, and `scipy`, reconstructs dataclasses only from an exact, -closed allow-list, and never executes arbitrary estimator code. A spec from an untrusted -source cannot run code the way an untrusted pickle can. +Load specs only from trusted sources. Reconstruction restricts imports to `pretab`, +`scikit-learn`, `numpy`, and `scipy`, reconstructs dataclasses only from an exact, closed +allow-list, and bypasses estimator initialization and pickle hooks. These restrictions are +not a sandbox: importing library modules can still execute code. Use the same PreTab and +dependency versions when restoring; recorded versions are informational, and cross-version +compatibility is not guaranteed. ``` -A round-trip reproduces `transform` bit-for-bit, so a spec is a faithful, human-readable -record of a fitted representation. +Supported built-in representations reproduce `transform` bit-for-bit in the same environment. +Third-party representations and pretrained language models are not supported by the JSON +serializer; unsupported state raises `PretabSerializationError`. ## Fingerprint @@ -57,6 +60,7 @@ pre.fit(df, y) pre.fingerprint_ ``` +Inference reports and advisory lifecycle flags are excluded from the 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. @@ -79,12 +83,12 @@ pre.reproducibility_report() 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. | +| 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 @@ -98,7 +102,7 @@ object and leaves the original untouched, and `mark_stale(reason)` records why a should no longer be used. ```{warning} -`set_params` on a frozen preprocessor raises `FrozenRepresentationError`. This is deliberate: +`fit`, `fit_transform`, and `set_params` on a frozen preprocessor raise `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. ``` diff --git a/pretab/compose/serialize.py b/pretab/compose/serialize.py index de935de..c4b213f 100644 --- a/pretab/compose/serialize.py +++ b/pretab/compose/serialize.py @@ -3,11 +3,10 @@ :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. +reconstructs supported estimators bit-for-bit in the same environment. Loading +restricts library imports and bypasses estimator initialization and pickle hooks, +but imports can execute module code: only load specs from trusted sources. +Recorded dependency versions do not guarantee cross-version compatibility. The document has a small declarative envelope (schema/library versions, resolved constructor params, per-representation summary, output column order) plus a @@ -17,6 +16,7 @@ import dataclasses import importlib +from collections import UserList from typing import Any, cast import numpy as np @@ -98,6 +98,11 @@ def _encode(obj): return {"__slice__": [obj.start, obj.stop, obj.step]} if isinstance(obj, tuple): return {"__tuple__": [_encode(v) for v in obj]} + if isinstance(obj, UserList): + # sklearn 1.6 wraps remainder-column indices in a warning-emitting + # UserList. Only its data affects transformation; reading it directly + # also avoids mutating the wrapper's warning state during hashing. + return _encode(obj.data) if isinstance(obj, list): return [_encode(v) for v in obj] if isinstance(obj, BaseEstimator): @@ -184,7 +189,7 @@ def _decode_estimator(payload: dict): raise PretabSerializationError( f"Refusing to reconstruct {payload['class']!r} as an estimator: not a scikit-learn BaseEstimator subclass." ) - obj = cls.__new__(cls) + obj = object.__new__(cls) obj.__dict__.update(_decode_mapping(payload["state"])) return obj From 45f962191a8fc9c643002a616900a17c074b3fe8 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 08:28:44 +0200 Subject: [PATCH 094/123] fix: reuse identical CV folds across search candidates --- pretab/compose/search.py | 5 ++++- tests/compose/test_search.py | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/pretab/compose/search.py b/pretab/compose/search.py index b3224d6..7ce1d63 100644 --- a/pretab/compose/search.py +++ b/pretab/compose/search.py @@ -89,13 +89,16 @@ def fit(self, X, y=None): y_arr = np.asarray(y).ravel() n_samples = X.shape[0] if hasattr(X, "shape") else len(X) cv = cast(BaseCrossValidator, check_cv(self.cv, y_arr, classifier=is_classifier(self.estimator))) + # Reuse the same held-out rows for every candidate, including splitters + # whose random state advances on each call to split(). + splits = list(cv.split(np.zeros(n_samples), y_arr)) 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): + for train_idx, test_idx in splits: pre = self._make_preprocessor(method) est = cast(PredictorLike, clone(self.estimator)) x_train = pre.fit_transform(_row_subset(X, train_idx), y_arr[train_idx], return_array=True) diff --git a/tests/compose/test_search.py b/tests/compose/test_search.py index 3cb9308..7268892 100644 --- a/tests/compose/test_search.py +++ b/tests/compose/test_search.py @@ -88,6 +88,15 @@ def test_classification_uses_stratified_cv(): assert 0.0 <= search.score(X, y) <= 1.0 +def test_candidates_share_randomized_folds(nonlinear_data): + X, y = nonlinear_data + # These aliases resolve to the same method. With identical folds their + # scores must agree even when the splitter has mutable RNG state. + cv = KFold(n_splits=3, shuffle=True, random_state=np.random.RandomState(42)) + search = _search(LinearRegression(), ["standardization", "standard"], cv=cv).fit(X, y) + assert search.cv_results_["standardization"] == search.cv_results_["standard"] + + def test_empty_methods_raises(nonlinear_data): X, y = nonlinear_data with pytest.raises(InvalidParamError): From f34dcf40b7f385316e5d8a13aed71711195bf9ba Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 08:28:51 +0200 Subject: [PATCH 095/123] fix: validate cross-fitting task name --- pretab/core/supervised.py | 2 ++ tests/core/test_cross_fitted.py | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/pretab/core/supervised.py b/pretab/core/supervised.py index 71f043e..f6aca3b 100644 --- a/pretab/core/supervised.py +++ b/pretab/core/supervised.py @@ -139,6 +139,8 @@ def _fit_full(self, X, y): raise IncompatibleParamsError("CrossFittedTransformer requires y at fit time; got y=None.") if not isinstance(self.n_folds, (int, np.integer)) or self.n_folds < 2: raise InvalidParamError(f"n_folds must be an integer >= 2; got {self.n_folds!r}.") + if self.task not in ("regression", "classification"): + raise InvalidParamError(f"task must be 'regression' or 'classification'; got {self.task!r}.") X_arr = np.asarray(X) if X_arr.ndim == 1: X_arr = X_arr.reshape(-1, 1) diff --git a/tests/core/test_cross_fitted.py b/tests/core/test_cross_fitted.py index 936d629..723d971 100644 --- a/tests/core/test_cross_fitted.py +++ b/tests/core/test_cross_fitted.py @@ -98,3 +98,9 @@ def test_invalid_n_folds(data): X, y = data with pytest.raises(InvalidParamError): CrossFittedTransformer(PLETransformer(), n_folds=1).fit(X, y) + + +def test_invalid_task_rejected(data): + X, y = data + with pytest.raises(InvalidParamError, match="task"): + CrossFittedTransformer(PLETransformer(), task="classificaton").fit_transform(X, y) From d54dd5ff5a26b84f195fec4d16d7295bc299aaac Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 08:29:43 +0200 Subject: [PATCH 096/123] fix: raise scikit-learn minimum and use scipy trapezoid --- .github/workflows/ci.yml | 37 +++++++++++-------------------------- pyproject.toml | 2 ++ 2 files changed, 13 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 826165a..59958cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -151,37 +151,22 @@ jobs: - name: Run unit tests run: poetry run pytest tests/ -v - min-deps: - name: Minimum supported dependencies + minimum-deps: + name: Minimum dependencies (Python 3.10) runs-on: ubuntu-latest - steps: - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 + - 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: Install dependencies - run: poetry install - - - name: Force the declared minimum runtime dependency versions - run: | - poetry run pip install \ - "numpy==1.24.*" \ - "pandas==2.0.*" \ - "scipy==1.10.*" \ - "scikit-learn==1.6.*" - - - name: Run unit tests against the minimum versions - run: poetry run pytest tests/ -q + - name: Install with minimum supported dependency series + run: >- + python -m pip install . pytest pytest-cov + "numpy==1.24.4" "pandas==2.0.3" "scipy==1.10.1" "scikit-learn==1.6.0" + - name: Run tests + run: python -m pytest tests/ -q + - name: Exercise the installed package + run: python -I scripts/quickstart.py smoke: name: Smoke tests (Python 3.12, ubuntu) diff --git a/pyproject.toml b/pyproject.toml index 3dcec3e..3e10bc8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,8 @@ all = ["sentence-transformers>=2.0", "lightgbm>=4.0", "polars>=0.20"] homepage = "https://github.com/OpenTabular/PreTab" repository = "https://github.com/OpenTabular/PreTab" package = "https://pypi.org/project/pretab/" +documentation = "https://pretab.readthedocs.io/en/latest/" +issues = "https://github.com/OpenTabular/PreTab/issues" [tool.poetry] packages = [{ include = "pretab" }] From 6dea7373b31c490f96b0a666b4311fd5f466f4a4 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 08:29:54 +0200 Subject: [PATCH 097/123] ci: gate publish workflows on CI and docs --- .github/workflows/build-check.yml | 3 ++- .github/workflows/ci.yml | 3 ++- .github/workflows/docs.yml | 3 ++- .github/workflows/publish-pypi.yml | 18 +++++++++++++----- .github/workflows/publish-testpypi.yml | 18 ++++++++++++------ 5 files changed, 31 insertions(+), 14 deletions(-) diff --git a/.github/workflows/build-check.yml b/.github/workflows/build-check.yml index b32289a..4cc8541 100644 --- a/.github/workflows/build-check.yml +++ b/.github/workflows/build-check.yml @@ -51,7 +51,8 @@ jobs: python -m venv /tmp/pretab-wheel-test source /tmp/pretab-wheel-test/bin/activate pip install dist/*.whl - python -c "import pretab; print('version:', pretab.__version__)" + python -I scripts/quickstart.py + python -I -c "import pretab; print('version:', pretab.__version__); print(pretab.__file__)" - name: Upload build artifacts uses: actions/upload-artifact@v4 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59958cd..4a56e7b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,7 @@ name: CI on: + workflow_call: workflow_dispatch: workflow_call: push: @@ -11,7 +12,7 @@ on: - main concurrency: - group: ci-${{ github.head_ref || github.sha }} + group: ci-${{ github.workflow }}-${{ github.head_ref || github.sha }} cancel-in-progress: true permissions: diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index cd5b4d7..950a45a 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,6 +1,7 @@ name: Docs on: + workflow_call: workflow_dispatch: pull_request: # Only run on PRs that touch docs-related files to keep checks fast. @@ -19,7 +20,7 @@ on: - "v*.*.*" concurrency: - group: docs-${{ github.head_ref || github.ref }} + group: docs-${{ github.workflow }}-${{ github.head_ref || github.ref }} cancel-in-progress: true jobs: diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 56fe8ed..9e81a9c 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -15,14 +15,21 @@ permissions: id-token: write jobs: - ci: - name: Full CI (required before publish) + qa: uses: ./.github/workflows/ci.yml - secrets: inherit + permissions: + contents: read + if: ${{ !contains(github.ref_name, 'rc') }} + + docs: + uses: ./.github/workflows/docs.yml + permissions: + contents: read + if: ${{ !contains(github.ref_name, 'rc') }} publish: + needs: [qa, docs] runs-on: ubuntu-latest - needs: ci environment: pypi-publish # The "v*.*.*" trigger also matches RC tags (e.g. v2.0.0rc2), so guard # against publishing pre-releases to real PyPI. RC tags are handled by @@ -79,7 +86,8 @@ jobs: python -m venv /tmp/pretab-wheel-test source /tmp/pretab-wheel-test/bin/activate pip install dist/*.whl - python -c "import pretab; print(pretab.__version__)" + python -I scripts/quickstart.py + python -I -c "import pretab; print(pretab.__version__); print(pretab.__file__)" - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/publish-testpypi.yml b/.github/workflows/publish-testpypi.yml index 33a766b..bac0de6 100644 --- a/.github/workflows/publish-testpypi.yml +++ b/.github/workflows/publish-testpypi.yml @@ -16,14 +16,19 @@ permissions: id-token: write jobs: - ci: - name: Full CI (required before publish) + qa: uses: ./.github/workflows/ci.yml - secrets: inherit + permissions: + contents: read + + docs: + uses: ./.github/workflows/docs.yml + permissions: + contents: read publish-rc: + needs: [qa, docs] runs-on: ubuntu-latest - needs: ci environment: testpypi-publish steps: @@ -76,7 +81,8 @@ jobs: python -m venv /tmp/pretab-wheel-test source /tmp/pretab-wheel-test/bin/activate pip install dist/*.whl - python -c "import pretab; print(pretab.__version__)" + python -I scripts/quickstart.py + python -I -c "import pretab; print(pretab.__version__); print(pretab.__file__)" - name: Publish to TestPyPI uses: pypa/gh-action-pypi-publish@release/v1 @@ -116,4 +122,4 @@ jobs: done - name: Import smoke test - run: python -c "import pretab; print('version:', pretab.__version__)" + run: python -I -c "import pretab; print('version:', pretab.__version__); print(pretab.__file__)" From 59882557cf511f362622e8af30345e05b4329d4a Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 08:30:15 +0200 Subject: [PATCH 098/123] chore: fix pre-commit prettier types and enforce coverage --- .pre-commit-config.yaml | 5 +---- justfile | 4 ++-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 57efc05..d648b9e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,10 +25,7 @@ repos: rev: f12edd9c7be1c20cfa42420fd0e6df71e42b51ea # frozen: v4.0.0-alpha.8 hooks: - id: prettier - types: - - yaml - - markdown - - json + types_or: [yaml, markdown, json] - repo: https://github.com/commitizen-tools/commitizen rev: 2ca29f9297911f8f5a4e8f97100b7832f045e8d3 # frozen: v4.13.10 diff --git a/justfile b/justfile index 924aa1b..2e01723 100644 --- a/justfile +++ b/justfile @@ -37,7 +37,7 @@ types: # run tests with coverage test: - poetry run pytest --cov=pretab tests/ + poetry run pytest --cov=pretab --cov-branch --cov-fail-under=90 tests/ # run the end-to-end quickstart used as the CI smoke test and reviewer artifact quickstart: @@ -50,7 +50,7 @@ docs: # run all pre-commit hooks on all files including push-stage hooks (ruff, pyright, prettier) check: - poetry run pre-commit run --hook-stage push --all-files + poetry run pre-commit run --hook-stage pre-push --all-files # create a conventional commit using commitizen commit: From 00480fa0ae514e22ae4c54469a9f3158278925de Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 08:30:26 +0200 Subject: [PATCH 099/123] test: execute README, homepage, and quickstart as doc snippets --- tests/doc_snippets/test_tutorial_snippets.py | 29 ++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/tests/doc_snippets/test_tutorial_snippets.py b/tests/doc_snippets/test_tutorial_snippets.py index fcf1912..b847722 100644 --- a/tests/doc_snippets/test_tutorial_snippets.py +++ b/tests/doc_snippets/test_tutorial_snippets.py @@ -12,7 +12,10 @@ import pytest -TUTORIALS_DIR = Path(__file__).parents[2] / "docs" / "tutorials" +from pretab.compose import registry + +ROOT = Path(__file__).parents[2] +TUTORIALS_DIR = ROOT / "docs" / "tutorials" _FENCE = re.compile(r"^```python\n(.*?)^```\s*$", re.DOTALL | re.MULTILINE) @@ -32,10 +35,32 @@ def _code_blocks(path: Path) -> list[tuple[int, str]]: return blocks -_TUTORIALS = sorted(TUTORIALS_DIR.glob("*.md")) +_TUTORIALS = [ + *sorted(TUTORIALS_DIR.glob("*.md")), + ROOT / "README.md", + ROOT / "docs" / "homepage.md", + ROOT / "docs" / "getting_started" / "quickstart.md", +] + + +@pytest.fixture(autouse=True) +def isolate_document_state(tmp_path, monkeypatch): + """Keep example files and representation registrations local to each page.""" + monkeypatch.chdir(tmp_path) + transformers = registry.TRANSFORMER_REGISTRY.copy() + numerical = registry.NUMERICAL_METHODS.copy() + categorical = registry.CATEGORICAL_METHODS.copy() + yield + registry.TRANSFORMER_REGISTRY.clear() + registry.TRANSFORMER_REGISTRY.update(transformers) + registry.NUMERICAL_METHODS.clear() + registry.NUMERICAL_METHODS.update(numerical) + registry.CATEGORICAL_METHODS.clear() + registry.CATEGORICAL_METHODS.update(categorical) @pytest.mark.parametrize("tutorial", _TUTORIALS, ids=[p.stem for p in _TUTORIALS]) +@pytest.mark.filterwarnings("ignore::pretab.exceptions.LeakageWarning") def test_tutorial_code_runs(tutorial): namespace: dict = {"__name__": "__main__"} for start_line, source in _code_blocks(tutorial): From ebf57ea786568a55b78072f09c121dedb964479c Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 08:30:59 +0200 Subject: [PATCH 100/123] docs: correct stale claims --- README.md | 87 +++++++++--------- docs/core_concepts/configuration.md | 32 +++---- docs/core_concepts/feature_representation.md | 14 +-- docs/core_concepts/missing_values.md | 16 ++-- docs/core_concepts/outputs_and_inspection.md | 12 +-- .../core_concepts/resolution_and_placement.md | 44 ++++----- docs/core_concepts/target_awareness.md | 8 +- docs/developer_guide/documentation.md | 16 ++-- docs/developer_guide/release.md | 15 ++-- docs/developer_guide/testing.md | 24 ++--- docs/developer_guide/versioning.md | 24 ++--- docs/getting_started/installation.md | 4 + docs/getting_started/migration_to_1_0.md | 48 +++++----- docs/getting_started/overview.md | 18 ++-- docs/homepage.md | 6 +- docs/representations/categorical_encoding.md | 8 +- docs/representations/choosing_a_method.md | 37 ++++---- docs/representations/comparison_table.md | 90 +++++++++---------- docs/representations/functional_expansions.md | 2 +- docs/representations/numerical_encoding.md | 18 ++-- docs/representations/overview.md | 8 +- docs/representations/references.md | 22 ++--- docs/representations/spline_expansions.md | 60 ++++++------- docs/tutorials/custom_representation.md | 2 +- docs/tutorials/multivariate_features.md | 6 +- docs/tutorials/target_aware_classification.md | 6 +- 26 files changed, 321 insertions(+), 306 deletions(-) diff --git a/README.md b/README.md index ee65b9a..53197d9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@
- + [![PyPI](https://img.shields.io/pypi/v/pretab)](https://pypi.org/project/pretab) ![PyPI - Downloads](https://img.shields.io/pypi/dm/pretab) @@ -42,10 +42,10 @@ public, discoverable protocol. 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 +- **Self-describing and reproducible.** Fitted preprocessors expose per-column feature + lineage. Supported built-in state serializes to a versioned spec with a stable fingerprint + for reproduction in the same environment. +- **Explicit target usage.** 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, @@ -67,6 +67,7 @@ df = pd.DataFrame({ "age": np.random.randint(18, 65, size=100), "income": np.random.normal(60_000, 15_000, size=100).astype(int), "city": np.random.choice(["Berlin", "Munich", "Hamburg"], size=100), + "experience": np.random.randint(0, 40, size=100), }) y = np.random.randn(100) @@ -76,7 +77,7 @@ preprocessor = Preprocessor(numerical_method="ple", categorical_method="int") X = preprocessor.fit_transform(df, y) # single stacked array, one row per sample print(X.shape) -# (100, 15) +# (100, 22) ``` > **Note:** PreTab accepts a `pandas.DataFrame` or a `numpy.ndarray` and infers numerical @@ -95,48 +96,48 @@ import); advanced users can also reach them through the namespace shown per tabl ### Spline expansions -| 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 | +| 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 | ### Functional expansions -| 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 | +| 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 | ### Kernel approximation -| Transformer | Basis | Best for | -| ------------------------------------ | ------------------------------------------ | ---------------------------------------- | -| `RandomFourierFeaturesTransformer` | Random Fourier features (multivariate) | Scalable RBF-kernel approximation | -| `NystroemFeaturesTransformer` | Nystroem kernel map (multivariate) | Landmark-based kernel approximation | +| Transformer | Basis | Best for | +| ---------------------------------- | -------------------------------------- | ----------------------------------- | +| `RandomFourierFeaturesTransformer` | Random Fourier features (multivariate) | Scalable RBF-kernel approximation | +| `NystroemFeaturesTransformer` | Nystroem kernel map (multivariate) | Landmark-based kernel approximation | ### Numerical encoding -| Transformer | Method | Best for | -| ------------------------------- | ------------------------------------------ | ---------------------------------------- | -| `PLETransformer` | Piecewise-linear encoding (supervised) | Strong numerical encoding for models | -| `NumericBinningTransformer` | Uniform/quantile binning, tree-driven | Discretizing numerical columns | -| `PeriodicEncodingTransformer` | Sine/cosine cyclic encoding | Values that wrap around a known period | +| Transformer | Method | Best for | +| ----------------------------- | -------------------------------------- | -------------------------------------- | +| `PLETransformer` | Piecewise-linear encoding (supervised) | Strong numerical encoding for models | +| `NumericBinningTransformer` | Uniform/quantile binning, tree-driven | Discretizing numerical columns | +| `PeriodicEncodingTransformer` | Sine/cosine cyclic encoding | Values that wrap around a known period | ### Categorical encoding and embeddings -| Transformer | Method | Best for | -| ------------------------------- | ------------------------------------------ | ---------------------------------------- | -| `ContinuousOrdinalTransformer` | Integer (ordinal) encoding | Compact codes for categoricals | -| `LanguageEmbeddingTransformer` | Pretrained language embeddings | High-cardinality, semantic columns | +| Transformer | Method | Best for | +| ------------------------------ | ------------------------------ | ---------------------------------- | +| `ContinuousOrdinalTransformer` | Integer (ordinal) encoding | Compact codes for categoricals | +| `LanguageEmbeddingTransformer` | Pretrained language embeddings | High-cardinality, semantic columns | > **Warning:** `OneHotFromOrdinalTransformer` is deprecated. Use > `categorical_method="one-hot"` (backed by `sklearn.preprocessing.OneHotEncoder`) instead. @@ -224,7 +225,7 @@ feature kind pipeline dim cats age numerical imputer -> minmax -> ple 7 - income numerical imputer -> minmax -> rbf 7 - experience numerical imputer -> minmax -> quantile 1 - -city categorical imputer -> onehot -> to_float 4 4 +city categorical imputer -> onehot -> to_float 3 3 ``` > **Note:** `transform` returns a single stacked array by default, so a `Preprocessor` drops @@ -337,6 +338,7 @@ per-column pipeline, and `get_feature_lineage` maps every output column back to feature, representation family, and component. ```python +preprocessor.fit(df, y) preprocessor.get_feature_info(verbose=True) # resolved strategies, widths, categories lineage = preprocessor.get_feature_lineage() # one record per output column ``` @@ -351,7 +353,9 @@ cross-fitting wrapper that produces out-of-fold training features. from pretab import CrossFittedTransformer from pretab.transformers import PLETransformer -cf = CrossFittedTransformer(PLETransformer(), n_folds=5) +x_train = df[["age"]].to_numpy() +y_train = y.ravel() +cf = CrossFittedTransformer(PLETransformer(), n_folds=5, random_state=0) X_train_features = cf.fit_transform(x_train, y_train) # out-of-fold, leakage-free ``` @@ -362,9 +366,10 @@ X_train_features = cf.fit_transform(x_train, y_train) # out-of-fold, leakage-f ### 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. +A supported fitted preprocessor serializes to a versioned JSON spec and reports a stable +fingerprint for tracking what was fitted. Load specs only from trusted sources and restore +them with the same library versions; see the +[serialization contract](https://pretab.readthedocs.io/en/latest/core_concepts/reproducibility.html). ```python preprocessor.to_spec("representation.json") diff --git a/docs/core_concepts/configuration.md b/docs/core_concepts/configuration.md index 713d302..d5a581d 100644 --- a/docs/core_concepts/configuration.md +++ b/docs/core_concepts/configuration.md @@ -51,11 +51,11 @@ Presets are transparent, named bundles of parameters for common intents. They se knobs you could set by hand, so nothing is hidden, and each one resolves to a fixed, documented set of values: -| 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` | +| 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 standard = Preprocessor(preset="standard") @@ -113,17 +113,17 @@ representation. 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) | -| `output_structure` | `"matrix"` | [Outputs and inspection](outputs_and_inspection.md) | -| `random_state` | `None` | [Reproducibility](reproducibility.md) | +| 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) | +| `output_structure` | `"matrix"` | [Outputs and inspection](outputs_and_inspection.md) | +| `random_state` | `None` | [Reproducibility](reproducibility.md) | ## Where to go next diff --git a/docs/core_concepts/feature_representation.md b/docs/core_concepts/feature_representation.md index c47868d..5fb1a4c 100644 --- a/docs/core_concepts/feature_representation.md +++ b/docs/core_concepts/feature_representation.md @@ -1,12 +1,12 @@ # Preprocessing and representation PreTab draws a deliberate line between two ideas that are often blurred together: -*preprocessing* and *representation*. The distinction shapes the whole library. +_preprocessing_ and _representation_. The distinction shapes the whole library. ## Preprocessing prepares a column 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 +_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. @@ -48,23 +48,23 @@ 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 - categorical. +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. +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`). +(`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). +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. +maps, edges for bins. ## The intermediate representation diff --git a/docs/core_concepts/missing_values.md b/docs/core_concepts/missing_values.md index f066a8e..4810527 100644 --- a/docs/core_concepts/missing_values.md +++ b/docs/core_concepts/missing_values.md @@ -17,7 +17,7 @@ Three parameters on `Preprocessor` control the common case. `add_missing_indicator` : When `True`, adds a binary indicator column marking where a value was missing. Default - `False`. +`False`. ```python from pretab import Preprocessor @@ -62,13 +62,13 @@ This preserves alignment with your target and any parallel arrays. 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. | +| 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") diff --git a/docs/core_concepts/outputs_and_inspection.md b/docs/core_concepts/outputs_and_inspection.md index 32e95e2..c30ff64 100644 --- a/docs/core_concepts/outputs_and_inspection.md +++ b/docs/core_concepts/outputs_and_inspection.md @@ -95,7 +95,7 @@ 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"`. +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. @@ -332,12 +332,12 @@ model fit on top of the expansion. 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. | +| 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"`. | +| `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") diff --git a/docs/core_concepts/resolution_and_placement.md b/docs/core_concepts/resolution_and_placement.md index 082ee64..6a52059 100644 --- a/docs/core_concepts/resolution_and_placement.md +++ b/docs/core_concepts/resolution_and_placement.md @@ -1,6 +1,6 @@ # Resolution and placement -Two questions define any basis expansion: *how many* units to use, and *where* to put them. +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. @@ -31,12 +31,12 @@ Each spline enforces a minimum width tied to its degree. Requesting fewer basis 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) | -| --- | --- | +| 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` | +| 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. @@ -52,12 +52,12 @@ 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]`. 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. +They are ignored otherwise. ```{note} When `adaptive=True` and both `min_output_dim` and `max_output_dim` are set, `output_dim` has @@ -90,12 +90,12 @@ placement subsystem so no transformer re-implements it, and it is driven by two `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). | +| `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 @@ -120,13 +120,13 @@ 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. | +| 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 diff --git a/docs/core_concepts/target_awareness.md b/docs/core_concepts/target_awareness.md index b3ae479..956653e 100644 --- a/docs/core_concepts/target_awareness.md +++ b/docs/core_concepts/target_awareness.md @@ -11,16 +11,16 @@ 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. +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. +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. +example and needs `y` at every fit. ```python from pretab.transformers import PLETransformer diff --git a/docs/developer_guide/documentation.md b/docs/developer_guide/documentation.md index 011c683..90367c3 100644 --- a/docs/developer_guide/documentation.md +++ b/docs/developer_guide/documentation.md @@ -29,14 +29,14 @@ poetry install --with docs 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. | +| 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 diff --git a/docs/developer_guide/release.md b/docs/developer_guide/release.md index 823f39b..bdb5e60 100644 --- a/docs/developer_guide/release.md +++ b/docs/developer_guide/release.md @@ -12,11 +12,11 @@ For the SemVer rules and commit conventions that decide the next version, see ## Overview -| Stage | Trigger | Workflow | Target | -| ----------------- | ------------------------------- | ---------------------------- | -------- | -| Build check | `workflow_dispatch` (manual) | `build-check.yml` | Artifact | -| Release candidate | Tag `vX.Y.ZrcN` | `publish-testpypi.yml` | TestPyPI | -| Stable release | Tag `vX.Y.Z` | `publish-pypi.yml` | PyPI | +| Stage | Trigger | Workflow | Target | +| ----------------- | ---------------------------- | ---------------------- | -------- | +| Build check | `workflow_dispatch` (manual) | `build-check.yml` | Artifact | +| Release candidate | Tag `vX.Y.ZrcN` | `publish-testpypi.yml` | TestPyPI | +| Stable release | Tag `vX.Y.Z` | `publish-pypi.yml` | PyPI | ## Prerequisites @@ -104,6 +104,11 @@ git push origin vX.Y.Z The `publish-pypi.yml` workflow builds the package, verifies the tag matches the project version, publishes to PyPI, and creates the GitHub Release. +Both publishing workflows first run the reusable CI and documentation workflows on the +tagged revision. Publishing waits for lint, types, the test matrix, minimum dependencies, +optional dependencies, coverage, and the strict documentation build to pass. The installed +wheel also runs the quickstart with Python's isolated mode before upload. + ### 7. Confirm ```bash diff --git a/docs/developer_guide/testing.md b/docs/developer_guide/testing.md index 6d1290d..eef3271 100644 --- a/docs/developer_guide/testing.md +++ b/docs/developer_guide/testing.md @@ -8,14 +8,14 @@ are organized and how to run them. The suite runs with coverage through a single recipe. ```bash -just test # poetry run pytest --cov=pretab tests/ +just test # poetry run pytest --cov=pretab --cov-branch --cov-fail-under=90 tests/ ``` To run a subset while developing, invoke pytest directly. ```bash poetry run pytest tests/expansion/ # one area -poetry run pytest tests/expansion/spline/test_b_spline.py -k output_shape # one test +poetry run pytest tests/expansion/spline/test_spline_expansions.py -k bspline # one test poetry run pytest -k "spline and not tensor" # by keyword ``` @@ -24,17 +24,17 @@ poetry run pytest -k "spline and not tensor" # by keyword 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. | +| Directory | Covers | +| ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `tests/core/` | Base classes, adaptive resolution, supervised logic, logging. | | `tests/expansion/`, `tests/encoding/`, `tests/kernel_approximation/`, `tests/embedding/` | Every representation family, split by kind (splines and functional expansions, numerical/categorical encoders, kernel approximations, language embeddings). | -| `tests/transformers/` | Cross-family contracts: sklearn compatibility, feature names, output dimensions, parameter aliases, encoder counts. | -| `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. | -| `tests/doc_snippets/` | Executes the `docs/tutorials/*.md` code fences, so the tutorials cannot silently rot. | +| `tests/transformers/` | Cross-family contracts: sklearn compatibility, feature names, output dimensions, parameter aliases, encoder counts. | +| `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. | +| `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 diff --git a/docs/developer_guide/versioning.md b/docs/developer_guide/versioning.md index 2a8a6bc..c0a0a69 100644 --- a/docs/developer_guide/versioning.md +++ b/docs/developer_guide/versioning.md @@ -32,18 +32,18 @@ commits bump the **major** version, in line with standard SemVer. ## Commit types and their effect -| Commit type | Example | Version bump | -| ----------- | ------------------------------------------- | ------------ | -| `feat` | `feat(splines): add B-spline knots option` | Minor | -| `fix` | `fix(binning): handle empty bins` | Patch | -| `perf` | `perf(ple): vectorise bin assignment` | Patch | -| `feat!` | `feat!: drop Python 3.9 support` | Major | -| `docs` | `docs: update API reference` | None | -| `test` | `test: add spline round-trip test` | None | -| `ci` | `ci: add Python 3.13 to matrix` | None | -| `refactor` | `refactor: simplify feature detection` | None | -| `style` | `style: apply ruff formatting` | None | -| `chore` | `chore: update pre-commit revisions` | None | +| Commit type | Example | Version bump | +| ----------- | ------------------------------------------ | ------------ | +| `feat` | `feat(splines): add B-spline knots option` | Minor | +| `fix` | `fix(binning): handle empty bins` | Patch | +| `perf` | `perf(ple): vectorise bin assignment` | Patch | +| `feat!` | `feat!: drop Python 3.9 support` | Major | +| `docs` | `docs: update API reference` | None | +| `test` | `test: add spline round-trip test` | None | +| `ci` | `ci: add Python 3.13 to matrix` | None | +| `refactor` | `refactor: simplify feature detection` | None | +| `style` | `style: apply ruff formatting` | None | +| `chore` | `chore: update pre-commit revisions` | None | Commit messages that do not match any of these types do not trigger a version bump. See [CONVENTIONAL_COMMITS.md](https://github.com/OpenTabular/PreTab/blob/main/CONVENTIONAL_COMMITS.md) diff --git a/docs/getting_started/installation.md b/docs/getting_started/installation.md index 5d43e3f..9df2ee1 100644 --- a/docs/getting_started/installation.md +++ b/docs/getting_started/installation.md @@ -15,6 +15,10 @@ transformers use. A dedicated CI job installs exactly these minimum versions and test suite against them, so this floor is verified, not just declared. ``` +The core dependencies are NumPy >=1.24,<3, pandas >=2,<3, SciPy >=1.10,<2, +and scikit-learn >=1.6,<2. The scikit-learn minimum matches the validation and +estimator-tag APIs used by PreTab. + ## From PyPI ```bash diff --git a/docs/getting_started/migration_to_1_0.md b/docs/getting_started/migration_to_1_0.md index 4cf7b9f..74bb3df 100644 --- a/docs/getting_started/migration_to_1_0.md +++ b/docs/getting_started/migration_to_1_0.md @@ -1,12 +1,12 @@ # Migrating to 1.0 -PreTab 1.0 is the first stable release. Because the previously published API (`0.0.2`) was +PreTab 1.0 is the first stable release. Because the previously published API (`0.0.3`) 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 +1.0 contains breaking changes relative to `0.0.3`. 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. ``` @@ -15,19 +15,19 @@ while you migrate. 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. | +| Old name (`0.0.3`) | 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. | +| Removed | Replacement | +| ------------------------- | ------------------------------------ | +| `LagFeatureTransformer` | Use a dedicated time-series library. | | `RollingStatsTransformer` | Use a dedicated time-series library. | ```{note} @@ -37,8 +37,8 @@ Cyclic time structure is still first-class through `PeriodicEncodingTransformer` ## Deprecated -| Symbol | Status | Do this instead | -| --- | --- | --- | +| 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` @@ -48,16 +48,16 @@ Cyclic time structure is still first-class through `PeriodicEncodingTransformer` The overlapping `selector` / `strategy` / `use_target` arguments are gone. Placement is controlled by exactly two parameters that validate strictly against each other. -| Old | New | -| --- | --- | +| 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"` | +| -------------- | ---------------------------- | +| `False` | `"uniform"`, `"quantile"` | +| `True` | `"cart"`, `"lightgbm"` | ```{warning} Mixing the two rows, for example `target_aware=True` with `placement_strategy="quantile"`, @@ -73,8 +73,8 @@ model. The single `handle_missing` flag was replaced by three explicit parameters. -| Old | New | -| --- | --- | +| 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 @@ -82,8 +82,8 @@ Set an imputation strategy to `None` to disable it for that kind. See ## Renamed optional extra -| Old install | New install | -| --- | --- | +| Old install | New install | +| ----------------------------- | -------------------------------- | | `pip install "pretab[knots]"` | `pip install "pretab[lightgbm]"` | The rename matches `placement_strategy="lightgbm"`. The `embeddings` and `all` extras are @@ -94,13 +94,13 @@ unchanged. See [Installation](installation.md). The thin-plate spline moved to landmark-based terminology and is sized by rank, not by a fixed `output_dim`. -| Old | New | -| --- | --- | +| 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`. +Upgrading also unlocks capabilities that did not exist in `0.0.3`. - **New representations**: `FourierFeatureTransformer`, `RandomFourierFeaturesTransformer`, and `NystroemFeaturesTransformer`. diff --git a/docs/getting_started/overview.md b/docs/getting_started/overview.md index 8f6d9c8..87d64db 100644 --- a/docs/getting_started/overview.md +++ b/docs/getting_started/overview.md @@ -39,14 +39,14 @@ PreTab is not a competitor to scikit-learn. Every transformer subclasses `BaseEs 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 | +| 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 spec for supported fitted state, plus a stable `fingerprint_`; restore trusted specs in the same environment | +| 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`, @@ -83,7 +83,7 @@ Knowing the boundaries is as useful as knowing the features. PreTab deliberately 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. + 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. diff --git a/docs/homepage.md b/docs/homepage.md index e15bf54..907ee97 100644 --- a/docs/homepage.md +++ b/docs/homepage.md @@ -30,7 +30,7 @@ semantic columns. :::{grid-item-card} 🔧 Composable pipelines Fully compatible with `sklearn.pipeline.Pipeline` and `sklearn.compose.ColumnTransformer`; -accepts any sklearn-native transformer and its hyperparameters. +compose standalone representations with scikit-learn transformers and estimators. ::: :::{grid-item-card} 🧠 Smart defaults @@ -87,8 +87,8 @@ pre = Preprocessor(feature_preprocessing={ }) X = pre.fit_transform(df, y) -{k: v.shape for k, v in X.items()} -# {'num_age': (100, 7), 'num_income': (100, 7), 'cat_city': (100, 3)} +X.shape +# (100, 17) ``` ## Get started diff --git a/docs/representations/categorical_encoding.md b/docs/representations/categorical_encoding.md index 14b0131..acda62a 100644 --- a/docs/representations/categorical_encoding.md +++ b/docs/representations/categorical_encoding.md @@ -64,10 +64,10 @@ cap it, or prefer integer encoding or [embeddings](embeddings.md) for high-cardi ## Choosing a categorical method -| If the column is... | Reach for... | -| --- | --- | -| Low cardinality, unordered | One-hot | -| Fed to a tree or embedding layer | Integer | +| 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](embeddings.md) | ## Where to go next diff --git a/docs/representations/choosing_a_method.md b/docs/representations/choosing_a_method.md index 4a168a8..9e9491c 100644 --- a/docs/representations/choosing_a_method.md +++ b/docs/representations/choosing_a_method.md @@ -9,28 +9,28 @@ 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. +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). +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. +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 | +| 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 @@ -62,17 +62,18 @@ 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. +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. +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. +and rely on a scaled input. +Extrapolation beyond the fitted range Extrapolation beyond the fitted range : Bases are fitted on the training range, and each spline family has its own default transform-time behavior for values beyond it: B/M/I-spline, P-spline, and tensor-product @@ -86,7 +87,7 @@ Extrapolation beyond the fitted range Pure noise features : Expanding a feature that carries no signal only gives the model more ways to fit noise. Drop - the feature instead. +the feature instead. ```{warning} Basis expansion changes the geometry of your features, not the information in them. If a diff --git a/docs/representations/comparison_table.md b/docs/representations/comparison_table.md index 2177b0e..d8bf78d 100644 --- a/docs/representations/comparison_table.md +++ b/docs/representations/comparison_table.md @@ -26,29 +26,29 @@ source of truth, and these tables mirror it. ## 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 | +| 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 | ## Spline expansions -| Method | Key | Scope | Target | Adaptive | Penalty | Selectable | -| --- | --- | --- | --- | --- | --- | --- | -| B-spline | `bspline` | univariate | optional | yes | yes | yes | -| M-spline | `mspline` | univariate | optional | yes | yes | yes | -| I-spline | `ispline` | univariate | optional | yes | yes | 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 | no | yes | yes | -| Tensor-product spline | `tensorspline` | multivariate | forbidden | no | yes | no | -| Thin-plate spline | `tprs` | multivariate | forbidden | no | experimental | no | +| Method | Key | Scope | Target | Adaptive | Penalty | Selectable | +| --------------------------- | --------------- | ------------ | --------- | -------- | ------------ | ---------- | +| B-spline | `bspline` | univariate | optional | yes | yes | yes | +| M-spline | `mspline` | univariate | optional | yes | yes | yes | +| I-spline | `ispline` | univariate | optional | yes | yes | 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 | no | yes | yes | +| Tensor-product spline | `tensorspline` | multivariate | forbidden | no | yes | no | +| Thin-plate spline | `tprs` | multivariate | forbidden | no | experimental | no | ```{note} The multivariate splines (`tensorspline`, `tprs`) model several inputs jointly and are used @@ -61,20 +61,20 @@ penalty is experimental. ## Functional expansions -| 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 | +| 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 | ## Kernel approximation -| Method | Key | Scope | Target | Adaptive | Selectable | -| --- | --- | --- | --- | --- | --- | -| Random Fourier features | `rff` | multivariate | forbidden | no | no | -| Nyström kernel map | `nystroem` | multivariate | forbidden | no | no | +| Method | Key | Scope | Target | Adaptive | Selectable | +| ----------------------- | ---------- | ------------ | --------- | -------- | ---------- | +| Random Fourier features | `rff` | multivariate | forbidden | no | no | +| Nyström kernel map | `nystroem` | multivariate | forbidden | no | no | ```{note} Random Fourier features and Nyström model the whole input matrix jointly and are used @@ -83,11 +83,11 @@ standalone, not selected per column through `Preprocessor`. ## Numerical encoding -| Method | Key | Scope | Target | Adaptive | Selectable | -| --- | --- | --- | --- | --- | --- | -| Numeric binning | `custombin` | univariate | forbidden | no | yes | -| Piecewise-linear encoding (PLE) | `ple` | univariate | required | yes | yes | -| Periodic encoding | n/a | univariate | forbidden | no | no | +| Method | Key | Scope | Target | Adaptive | Selectable | +| ------------------------------- | ----------- | ---------- | --------- | -------- | ---------- | +| Numeric binning | `custombin` | univariate | forbidden | no | yes | +| Piecewise-linear encoding (PLE) | `ple` | univariate | required | yes | yes | +| Periodic encoding | n/a | univariate | forbidden | no | no | ```{important} PLE is the only numerical method that **requires** the target. It always places its bins @@ -102,12 +102,12 @@ selectable through `Preprocessor`. Instantiate `PeriodicEncodingTransformer` dir ## Categorical encoding -| 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 | -| Passthrough | `none` | univariate | forbidden | yes | +| 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 | +| Passthrough | `none` | univariate | forbidden | yes | ```{note} The alias `ohe` resolves to `one-hot`. @@ -121,9 +121,9 @@ directly. ## Embeddings -| Method | Key | Scope | Target | Selectable | -| --- | --- | --- | --- | --- | -| Pretrained language embedding | `pretrained` | univariate | forbidden | yes | +| Method | Key | Scope | Target | Selectable | +| ----------------------------- | ------------ | ---------- | --------- | ---------- | +| Pretrained language embedding | `pretrained` | univariate | forbidden | yes | ```{note} `pretrained` requires the optional `embeddings` extra. diff --git a/docs/representations/functional_expansions.md b/docs/representations/functional_expansions.md index 42e845c..595cc3b 100644 --- a/docs/representations/functional_expansions.md +++ b/docs/representations/functional_expansions.md @@ -64,7 +64,7 @@ ReLU Sigmoid and Tanh : Smooth saturating steps. `scale` controls the steepness of the transition: **smaller** values - give a sharper, more step-like transition; **larger** values spread it out. +give a sharper, more step-like transition; **larger** values spread it out. ```python import numpy as np diff --git a/docs/representations/numerical_encoding.md b/docs/representations/numerical_encoding.md index 01bc6f8..0ac4b41 100644 --- a/docs/representations/numerical_encoding.md +++ b/docs/representations/numerical_encoding.md @@ -31,15 +31,15 @@ differently from each other despite both accepting the same `output_dim`. `"ordinal"` : A single integer column giving the bin index (output width is always 1, independent of - `output_dim`). +`output_dim`). `"onehot"` : One indicator column per bin (output width equals `output_dim`). `"soft"` : A soft assignment that spreads each value across neighbouring bins, so the boundaries are not - hard (output width equals `output_dim`, same shape as `"onehot"` but with fractional - membership instead of a single 1). +hard (output width equals `output_dim`, same shape as `"onehot"` but with fractional +membership instead of a single 1). Edge placement follows `placement_strategy`: `"uniform"` for equal-width bins, `"quantile"` for equal-frequency bins. See @@ -147,12 +147,12 @@ the model to search across a set of frequencies rather than commit to one known ## 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 | +| | 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 diff --git a/docs/representations/overview.md b/docs/representations/overview.md index a588b65..c2399c0 100644 --- a/docs/representations/overview.md +++ b/docs/representations/overview.md @@ -59,16 +59,16 @@ Every family is described with the same terms, introduced in `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`. +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). +[Target awareness](../core_concepts/target_awareness.md). `output_dim` : The width of the expansion. See - [Resolution and placement](../core_concepts/resolution_and_placement.md). +[Resolution and placement](../core_concepts/resolution_and_placement.md). `placement` : Where the knots, centers, or edges go, chosen by `target_aware` and `placement_strategy`. diff --git a/docs/representations/references.md b/docs/representations/references.md index ef6cce3..30bad0d 100644 --- a/docs/representations/references.md +++ b/docs/representations/references.md @@ -7,11 +7,11 @@ 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. +_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. +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 @@ -19,13 +19,13 @@ tensor-product extension, which underpin `PSplineTransformer` and ## Thin-plate and generalized additive models -Wahba, G. (1990). *Spline Models for Observational Data*. Society for Industrial and Applied +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. (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 +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 @@ -34,10 +34,10 @@ thin-plate regression spline and the GAM framing that `ThinPlateSplineTransforme ## 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. +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. +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`. @@ -45,7 +45,7 @@ These introduce the Nyström method and random Fourier features, implemented as ## 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. +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`. diff --git a/docs/representations/spline_expansions.md b/docs/representations/spline_expansions.md index 06c91c9..af06d01 100644 --- a/docs/representations/spline_expansions.md +++ b/docs/representations/spline_expansions.md @@ -56,19 +56,19 @@ Constructor highlights: `output_dim`, `degree=3`, `include_bias=False`, `knot_lo `degree` : Sets the minimum usable `output_dim`: PreTab requires `output_dim >= degree + 1` (a cubic, - `degree=3`, needs at least 4 columns) and raises a typed error otherwise. Higher degree gives - smoother, wider-support basis functions at the same `output_dim`; `degree=1` recovers - piecewise-linear segments. +`degree=3`, needs at least 4 columns) and raises a typed error otherwise. Higher degree gives +smoother, wider-support basis functions at the same `output_dim`; `degree=1` recovers +piecewise-linear segments. `output_dim` : The exact per-feature output width (unlike the cubic/natural/tensor families below, no - conversion is applied). More columns track finer local detail and increase overfitting risk. +conversion is applied). More columns track finer local detail and increase overfitting risk. `include_bias` : Defaults to `False`. A B-spline basis over a clamped knot vector already sums to 1 in every - row (a partition of unity), so prepending a bias column makes the design exactly - rank-deficient. Set `include_bias=True` only if a downstream model specifically needs an - explicit intercept column; it adds one extra output column. +row (a partition of unity), so prepending a bias column makes the design exactly +rank-deficient. Set `include_bias=True` only if a downstream model specifically needs an +explicit intercept column; it adds one extra output column. ```{tip} Cubic (`degree=3`) B-splines with quantile knots are a strong default for smooth regression. @@ -90,21 +90,21 @@ 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. Built by rescaling each B-spline - basis function so it integrates to one over its support, +should be non-negative, for example as a density-like basis. Built by rescaling each B-spline +basis function so it integrates to one over its support, - $$ - M_k(x) = \frac{p + 1}{\tau_{k+p+1} - \tau_k}\, B_k(x). - $$ +$$ +M_k(x) = \frac{p + 1}{\tau_{k+p+1} - \tau_k}\, B_k(x). +$$ 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. +coefficients on an I-spline basis is guaranteed monotone in the input, which is valuable when +domain knowledge says a relationship cannot reverse. - $$ - I_k(x) = \int_{\tau_k}^{x} M_k(t)\, dt. - $$ +$$ +I_k(x) = \int_{\tau_k}^{x} M_k(t)\, dt. +$$ ```python import numpy as np @@ -131,23 +131,23 @@ smoothing penalty through `get_penalty_matrix()`. Cubic regression spline : A cubic basis parameterized at the knots (`cubicspline`), convenient for GAM-style additive - models. Requires `output_dim >= 3`. The basis stacks the polynomial terms with one truncated - cubic term per interior knot $\kappa_j$, +models. Requires `output_dim >= 3`. The basis stacks the polynomial terms with one truncated +cubic term per interior knot $\kappa_j$, - $$ - \big(x,\ x^2,\ x^3,\ (x - \kappa_1)_+^3,\ \dots,\ (x - \kappa_K)_+^3\big), \qquad (z)_+ = \max(0, z). - $$ +$$ +\big(x,\ x^2,\ x^3,\ (x - \kappa_1)_+^3,\ \dots,\ (x - \kappa_K)_+^3\big), \qquad (z)_+ = \max(0, z). +$$ 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. - Requires `output_dim >= 2`. For knots $\xi_1, \dots, \xi_T$ ($\xi_1$, $\xi_T$ the boundary - knots), the basis stacks $x$ with one constrained term per interior knot $\xi_k$, - - $$ - d_k(x) = \frac{(x - \xi_k)_+^3 - (x - \xi_T)_+^3}{\xi_T - \xi_k}, \qquad - N_k(x) = d_k(x) - \frac{\xi_T - \xi_k}{\xi_T - \xi_1} d_1(x) - \frac{\xi_k - \xi_1}{\xi_T - \xi_1} d_T(x). - $$ +The linear tails reduce the wild behaviour ordinary cubics show near the edges of the data. +Requires `output_dim >= 2`. For knots $\xi_1, \dots, \xi_T$ ($\xi_1$, $\xi_T$ the boundary +knots), the basis stacks $x$ with one constrained term per interior knot $\xi_k$, + +$$ +d_k(x) = \frac{(x - \xi_k)_+^3 - (x - \xi_T)_+^3}{\xi_T - \xi_k}, \qquad +N_k(x) = d_k(x) - \frac{\xi_T - \xi_k}{\xi_T - \xi_1} d_1(x) - \frac{\xi_k - \xi_1}{\xi_T - \xi_1} d_T(x). +$$ ```python import numpy as np diff --git a/docs/tutorials/custom_representation.md b/docs/tutorials/custom_representation.md index a3106cf..978f8c4 100644 --- a/docs/tutorials/custom_representation.md +++ b/docs/tutorials/custom_representation.md @@ -71,7 +71,7 @@ The four class attributes are the declarative contract. `supervision` : `"unsupervised"`, `"optional"` (uses `y` only when `target_aware=True`), or `"supervised"` - (always needs `y`). +(always needs `y`). ```{tip} Implement `_output_sizes` to return the number of output columns each input contributes. The diff --git a/docs/tutorials/multivariate_features.md b/docs/tutorials/multivariate_features.md index 857b620..9d61b9e 100644 --- a/docs/tutorials/multivariate_features.md +++ b/docs/tutorials/multivariate_features.md @@ -11,12 +11,12 @@ 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. +interaction on a grid. `tprs` : Thin-plate regression spline. A smooth surface over two or more inputs, from the generalized - additive model literature. It also accepts a single input, though a univariate spline family - is usually a more natural fit there. +additive model literature. It also accepts a single input, though a univariate spline family +is usually a more natural fit there. `rff` : Random Fourier features. A scalable approximation to a shift-invariant kernel. diff --git a/docs/tutorials/target_aware_classification.md b/docs/tutorials/target_aware_classification.md index 25780f7..f3d7fc0 100644 --- a/docs/tutorials/target_aware_classification.md +++ b/docs/tutorials/target_aware_classification.md @@ -129,9 +129,9 @@ 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"]), + ("x1", RBFExpansionTransformer(output_dim=10, target_aware=True, task="classification"), ["x1"]), + ("x2", RBFExpansionTransformer(output_dim=10, target_aware=True, task="classification"), ["x2"]), + ("hours", RBFExpansionTransformer(output_dim=10, target_aware=True, task="classification"), ["hours"]), ("plan", OneHotEncoder(handle_unknown="ignore"), ["plan"]), ]) From 127da58656edbc6f79e48b5b23496f1490871a82 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 08:47:31 +0200 Subject: [PATCH 101/123] ci: remove duplicate workflow call --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a56e7b..a3b9832 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,6 @@ name: CI on: workflow_call: workflow_dispatch: - workflow_call: push: branches: - main From 97069c1024baf510c36eac9918945278a5b365f9 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 08:47:41 +0200 Subject: [PATCH 102/123] docs: update header --- docs/representations/choosing_a_method.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/representations/choosing_a_method.md b/docs/representations/choosing_a_method.md index 9e9491c..8a995e3 100644 --- a/docs/representations/choosing_a_method.md +++ b/docs/representations/choosing_a_method.md @@ -73,7 +73,6 @@ 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 Extrapolation beyond the fitted range : Bases are fitted on the training range, and each spline family has its own default transform-time behavior for values beyond it: B/M/I-spline, P-spline, and tensor-product From 9b2d1a9b30b58860727146ab081bf84b8d3e5adc Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 10:17:20 +0200 Subject: [PATCH 103/123] =?UTF-8?q?bump:=20version=201.0.0rc3=20=E2=86=92?= =?UTF-8?q?=201.0.0rc4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 34 ++++++++++++++++++++++------------ pyproject.toml | 2 +- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8163e9..b69eaa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,21 +7,31 @@ 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 +## v1.0.0rc4 (2026-09-06) + +### Feat + +- default Preprocessor output to a single array ### Fix -- Require scikit-learn >=1.6, matching the validation and estimator-tag APIs used by the implementation. -- Preserve NumPy 1.24 support in natural-spline penalties and add minimum-dependency CI coverage. -- Keep fitted fingerprints stable across inference batches and lifecycle annotations. -- Prevent `fit` and `fit_transform` from overwriting frozen preprocessors. -- Compare representation-search candidates on identical cross-validation folds. -- Reject invalid cross-fitting task names instead of silently using regression folds. -- Bypass estimator `__new__` hooks when reconstructing serialized state. -- Exercise installed wheels in isolation and run the full quickstart before publishing. -- Require the tagged revision to pass CI and documentation checks before either publishing workflow uploads artifacts. -- Correct contributor commands, README examples, classification configuration, and migration guidance. -- Clarify serialization support and trust requirements, and execute introductory documentation examples in tests. +- raise scikit-learn minimum and use scipy trapezoid +- validate cross-fitting task name +- reuse identical CV folds across search candidates +- correct serialization safety claims and bypass __new__ hooks +- reject fit on a frozen preprocessor +- clip P-spline and tensor-product out-of-range transforms via policy +- document periodic transform wrap-around and correct binning docstring +- add missing polars optional dependency and its tests +- numerical_method=none no longer applies scaling +- validate feature_preprocessing keys against input columns +- remove unimplemented resolution stubs from public placement API +- raise scikit-learn minimum and use scipy trapezoid +- use configured dtype in output-budget memory estimate +- drop unwired policy fields and encoding/embedding NaN bugs +- **spline**: correct penalty matrices and validate diff_order +- **ple**: remove boundary-bin discontinuity in PLETransformer +- **serialize**: reject unsafe classes in from_spec ## v1.0.0rc3 (2026-09-01) diff --git a/pyproject.toml b/pyproject.toml index 3e10bc8..5a22cd6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pretab" -version = "1.0.0rc3" +version = "1.0.0rc4" description = "A scikit-learn compatible library for flexible tabular preprocessing, advanced feature representations, and basis expansions." authors = [ { name = "Anton Thielmann" }, From d7b92236aa9804af80bccf889eabb6de86b1613b Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 10:24:22 +0200 Subject: [PATCH 104/123] chore: formatting and typecheck correction --- CHANGELOG.md | 12 +- docs/api/representations.rst | 1 - docs/core_concepts/outputs_and_inspection.md | 197 +++++++++---------- docs/getting_started/installation.md | 12 +- docs/representations/choosing_a_method.md | 16 +- tests/integration/test_missing_policy.py | 5 +- 6 files changed, 122 insertions(+), 121 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b69eaa8..c62ecc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ Going forward, this file is updated automatically by `cz bump` on each release. - raise scikit-learn minimum and use scipy trapezoid - validate cross-fitting task name - reuse identical CV folds across search candidates -- correct serialization safety claims and bypass __new__ hooks +- correct serialization safety claims and bypass **new** hooks - reject fit on a frozen preprocessor - clip P-spline and tensor-product out-of-range transforms via policy - document periodic transform wrap-around and correct binning docstring @@ -80,7 +80,7 @@ Going forward, this file is updated automatically by `cz bump` on each release. ### Perf - **splines**: drop retained training design matrices (issue #19) -- **preprocessor**: slice dict blocks from output_indices_ instead of re-transforming (issue #20) +- **preprocessor**: slice dict blocks from output*indices* instead of re-transforming (issue #20) ## v1.0.0rc1 (2026-08-15) @@ -108,7 +108,7 @@ Going forward, this file is updated automatically by `cz bump` on each release. - **pipeline**: make cubic and natural splines target-aware - **pipeline**: use selector and adaptive setting to splines - **pipeline**: accept preprocessing method name variations -- **preprocessor**: expose total_output_dim_, output_dims_ attribute +- **preprocessor**: expose total*output_dim*, output*dims* attribute - **preprocessor**: add random_state, handle_missing parameters - **sklearn-compat**: enforce n_features consistency, fix mixin order/tags - **exceptions**: route all raises through typed exceptions @@ -116,10 +116,10 @@ Going forward, this file is updated automatically by `cz bump` on each release. - **adaptive**: unify adaptive/fixed output size across expansion families via AdaptiveResolutionMixin - **preprocessor**: rename constructor params to the canonical vocabulary and add adaptive parameter - **pipeline**: thread output_dim through registry and Preprocessor -- **ple,binning**: rename count knob to output_dim and set total_output_dim_ +- **ple,binning**: rename count knob to output*dim and set total_output_dim* - **feature_maps**: rename count knob to output_dim -- **splines**: invert output_dim to knots per family and expose n_knots_ -- **core**: make output_dim the canonical width param and add total_output_dim_ +- **splines**: invert output*dim to knots per family and expose n_knots* +- **core**: make output*dim the canonical width param and add total_output_dim* - add include_bias and feature_index parity to thin-plate spline - add strategy/selector/task/include_bias parity to knot splines - add selector-aware spanning-knot placement to spline mixin diff --git a/docs/api/representations.rst b/docs/api/representations.rst index 3ed9fc9..2b7f8b6 100644 --- a/docs/api/representations.rst +++ b/docs/api/representations.rst @@ -110,4 +110,3 @@ Preprocessing utilities ToFloatTransformer Canonical import: ``pretab.preprocessing``. - diff --git a/docs/core_concepts/outputs_and_inspection.md b/docs/core_concepts/outputs_and_inspection.md index c30ff64..57d46e9 100644 --- a/docs/core_concepts/outputs_and_inspection.md +++ b/docs/core_concepts/outputs_and_inspection.md @@ -90,7 +90,6 @@ width before committing, especially with several expanded columns at once. ## Output format and dtype - Two parameters control the physical layout of the stacked output. `output_format` @@ -149,132 +148,132 @@ y = [0.1, 0.9, 0.3] ### Parameter impact at a glance -| Parameter | Values | Controls | Set at | -| --- | --- | --- | --- | -| `output_structure` | `"matrix"` (default), `"blocks"` | Whether `transform()` returns one stacked array or a dict of per-feature blocks, when `return_array` is not passed. | Constructor | -| `return_array` | `True`, `False`, `None` (default) | Overrides `output_structure` for a single call. `None` resolves from `output_structure`. | Per call | -| `output_format` | `"dense"` (default), `"sparse"`, `"auto"` | Whether the array (or each block) is a NumPy array or a SciPy CSR matrix. `"auto"` picks sparse only when it saves memory. | Constructor | -| `dtype` | e.g. `numpy.float32`, `None` (default) | Floating-point precision of the output; also what `estimate_memory()` assumes. | Constructor | -| `set_output(transform=...)` | `"default"`, `"pandas"`, `"polars"` | Wraps the array in a DataFrame. **Takes priority over `output_structure` and `return_array` entirely**: once set, `transform()` always returns a DataFrame, never a dict or a bare array. | Method call, before `transform` | +| Parameter | Values | Controls | Set at | +| --------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | +| `output_structure` | `"matrix"` (default), `"blocks"` | Whether `transform()` returns one stacked array or a dict of per-feature blocks, when `return_array` is not passed. | Constructor | +| `return_array` | `True`, `False`, `None` (default) | Overrides `output_structure` for a single call. `None` resolves from `output_structure`. | Per call | +| `output_format` | `"dense"` (default), `"sparse"`, `"auto"` | Whether the array (or each block) is a NumPy array or a SciPy CSR matrix. `"auto"` picks sparse only when it saves memory. | Constructor | +| `dtype` | e.g. `numpy.float32`, `None` (default) | Floating-point precision of the output; also what `estimate_memory()` assumes. | Constructor | +| `set_output(transform=...)` | `"default"`, `"pandas"`, `"polars"` | Wraps the array in a DataFrame. **Takes priority over `output_structure` and `return_array` entirely**: once set, `transform()` always returns a DataFrame, never a dict or a bare array. | Method call, before `transform` | ### Which setting should I use? Feeding a plain scikit-learn estimator or building a `Pipeline` : Use the default (`output_structure="matrix"`, no `return_array` override). `Preprocessor()` - composes directly: `Pipeline([("pretab", Preprocessor(...)), ("model", Ridge())])` just - works, since `transform(X)` already returns a single array. +composes directly: `Pipeline([("pretab", Preprocessor(...)), ("model", Ridge())])` just +works, since `transform(X)` already returns a single array. - ```python - pre = Preprocessor(numerical_method="minmax", categorical_method="one-hot").fit(df, y) - out = pre.transform(df) - type(out), out.shape - ``` +```python +pre = Preprocessor(numerical_method="minmax", categorical_method="one-hot").fit(df, y) +out = pre.transform(df) +type(out), out.shape +``` - ```text - (, (3, 3)) - ``` +```text +(, (3, 3)) +``` - ```text - array([[0. , 1. , 0. ], - [0.39473684, 0. , 1. ], - [1. , 1. , 0. ]]) - ``` +```text +array([[0. , 1. , 0. ], + [0.39473684, 0. , 1. ], + [1. , 1. , 0. ]]) +``` - Column order matches `get_feature_names_out()`: the scaled `age`, then the one-hot `city_A` - / `city_B` columns. +Column order matches `get_feature_names_out()`: the scaled `age`, then the one-hot `city_A` +/ `city_B` columns. Inspecting per-feature blocks, or feeding different blocks to different model heads : Set `output_structure="blocks"` on the constructor (so it stays the estimator's default - everywhere it's reused), or pass `return_array=False` for a one-off call without changing - the estimator's configuration. - - ```python - pre = Preprocessor(numerical_method="minmax", categorical_method="one-hot", - output_structure="blocks").fit(df, y) - out = pre.transform(df) - out - ``` - - ```text - {'num_age': array([[0. ], - [0.39473684], - [1. ]]), - 'cat_city': array([[1., 0.], - [0., 1.], - [1., 0.]])} - ``` - - The same estimator still returns an array for a single call if you ask for one: - - ```python - pre.transform(df, return_array=True) # ndarray, shape (3, 3); this call only - ``` +everywhere it's reused), or pass `return_array=False` for a one-off call without changing +the estimator's configuration. + +```python +pre = Preprocessor(numerical_method="minmax", categorical_method="one-hot", + output_structure="blocks").fit(df, y) +out = pre.transform(df) +out +``` + +```text +{'num_age': array([[0. ], + [0.39473684], + [1. ]]), + 'cat_city': array([[1., 0.], + [0., 1.], + [1., 0.]])} +``` + +The same estimator still returns an array for a single call if you ask for one: + +```python +pre.transform(df, return_array=True) # ndarray, shape (3, 3); this call only +``` Passing external `embeddings` : **Embeddings require dict output.** They are separate named blocks, so they cannot be - stacked into a single matrix. Use `output_structure="blocks"` (or `return_array=False`) on - every `transform` call that passes `embeddings`; the default `"matrix"` raises - `IncompatibleParamsError` the moment `embeddings` is supplied. - - ```python - emb = np.array([[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]]) - pre = Preprocessor(numerical_method="minmax", categorical_method="one-hot", - output_structure="blocks").fit(df, y, embeddings=emb) - out = pre.transform(df, embeddings=emb) - sorted(out.keys()), out["embedding_1"].shape - ``` - - ```text - (['cat_city', 'embedding_1', 'num_age'], (3, 2)) - ``` +stacked into a single matrix. Use `output_structure="blocks"` (or `return_array=False`) on +every `transform` call that passes `embeddings`; the default `"matrix"` raises +`IncompatibleParamsError` the moment `embeddings` is supplied. + +```python +emb = np.array([[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]]) +pre = Preprocessor(numerical_method="minmax", categorical_method="one-hot", + output_structure="blocks").fit(df, y, embeddings=emb) +out = pre.transform(df, embeddings=emb) +sorted(out.keys()), out["embedding_1"].shape +``` + +```text +(['cat_city', 'embedding_1', 'num_age'], (3, 2)) +``` Large, mostly one-hot-encoded categorical data : Set `output_format="sparse"` (or `"auto"` to let PreTab decide per fit). This applies - independently of `output_structure`: a sparse `"matrix"` is a single stacked - `scipy.sparse.csr_matrix`, and a sparse `"blocks"` dict holds a CSR matrix per feature. +independently of `output_structure`: a sparse `"matrix"` is a single stacked +`scipy.sparse.csr_matrix`, and a sparse `"blocks"` dict holds a CSR matrix per feature. - ```python - pre = Preprocessor(numerical_method="minmax", categorical_method="one-hot", - output_format="sparse").fit(df, y) - pre.transform(df) - ``` +```python +pre = Preprocessor(numerical_method="minmax", categorical_method="one-hot", + output_format="sparse").fit(df, y) +pre.transform(df) +``` - ```text - - ``` +```text + +``` Halving memory on a large dataset : Set `dtype=numpy.float32`. `estimate_memory()` and the `max_dense_memory` budget both - reflect the configured `dtype`, not a hardcoded `float64` assumption, so budgets sized for - the real, cast output are honored correctly. +reflect the configured `dtype`, not a hardcoded `float64` assumption, so budgets sized for +the real, cast output are honored correctly. - ```python - pre = Preprocessor(numerical_method="minmax", categorical_method="one-hot", - dtype=np.float32).fit(df, y) - pre.transform(df).dtype - ``` +```python +pre = Preprocessor(numerical_method="minmax", categorical_method="one-hot", + dtype=np.float32).fit(df, y) +pre.transform(df).dtype +``` - ```text - dtype('float32') - ``` +```text +dtype('float32') +``` Feeding a library that expects a DataFrame, or wanting column names attached to the output : Call `pre.set_output(transform="pandas")` (or `"polars"`). This overrides everything else: - `transform()` always returns a DataFrame from that point on, regardless of `output_structure` - or any `return_array` passed to an individual call. - - ```python - pre = Preprocessor(numerical_method="minmax", categorical_method="one-hot").fit(df, y) - pre.set_output(transform="pandas").transform(df) - ``` - - ```text - num_age cat_city_A cat_city_B - 0 0.000000 1.0 0.0 - 1 0.394737 0.0 1.0 - 2 1.000000 1.0 0.0 - ``` +`transform()` always returns a DataFrame from that point on, regardless of `output_structure` +or any `return_array` passed to an individual call. + +```python +pre = Preprocessor(numerical_method="minmax", categorical_method="one-hot").fit(df, y) +pre.set_output(transform="pandas").transform(df) +``` + +```text + num_age cat_city_A cat_city_B +0 0.000000 1.0 0.0 +1 0.394737 0.0 1.0 +2 1.000000 1.0 0.0 +``` ```{warning} `set_output` always wins. If a downstream step unexpectedly receives a DataFrame instead of diff --git a/docs/getting_started/installation.md b/docs/getting_started/installation.md index 9df2ee1..e58238d 100644 --- a/docs/getting_started/installation.md +++ b/docs/getting_started/installation.md @@ -2,12 +2,12 @@ pretab supports Python 3.10 to 3.13, with the following minimum core dependency versions: -| Dependency | Minimum | -| --- | --- | -| `numpy` | 1.24 | -| `pandas` | 2.0 | -| `scipy` | 1.10 | -| `scikit-learn` | 1.6 | +| Dependency | Minimum | +| -------------- | ------- | +| `numpy` | 1.24 | +| `pandas` | 2.0 | +| `scipy` | 1.10 | +| `scikit-learn` | 1.6 | ```{note} `scikit-learn>=1.6` is required for the `__sklearn_tags__` tag-dispatch API pretab's diff --git a/docs/representations/choosing_a_method.md b/docs/representations/choosing_a_method.md index 8a995e3..70428c7 100644 --- a/docs/representations/choosing_a_method.md +++ b/docs/representations/choosing_a_method.md @@ -75,14 +75,14 @@ and rely on a scaled input. Extrapolation beyond the fitted range : Bases are fitted on the training range, and each spline family has its own default - transform-time behavior for values beyond it: B/M/I-spline, P-spline, and tensor-product - clip to the fitted range, while natural-cubic and cubic-regression extrapolate smoothly - (their basis is defined for any input). If your test data lies well beyond training, an - extrapolated value carries no real signal. Pass - `policy=RepresentationPolicy(out_of_range="clip")` (or `"warn"` / `"error"`) directly to any - of these spline transformers to override their default for that instance. This is - standalone-transformer-only: it is not yet threaded through `Preprocessor`. See the - edge-case behaviour below. +transform-time behavior for values beyond it: B/M/I-spline, P-spline, and tensor-product +clip to the fitted range, while natural-cubic and cubic-regression extrapolate smoothly +(their basis is defined for any input). If your test data lies well beyond training, an +extrapolated value carries no real signal. Pass +`policy=RepresentationPolicy(out_of_range="clip")` (or `"warn"` / `"error"`) directly to any +of these spline transformers to override their default for that instance. This is +standalone-transformer-only: it is not yet threaded through `Preprocessor`. 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 diff --git a/tests/integration/test_missing_policy.py b/tests/integration/test_missing_policy.py index b5ac9d4..c7ec70e 100644 --- a/tests/integration/test_missing_policy.py +++ b/tests/integration/test_missing_policy.py @@ -228,7 +228,7 @@ def test_add_missing_indicator_with_imputation_none_still_propagates_nan(frame_w add_missing_indicator=True, ).fit(frame_with_nan, y) - out = p.transform(frame_with_nan, return_array=True) + out = np.asarray(p.transform(frame_with_nan, return_array=True)) assert np.isnan(out).any() @@ -258,4 +258,7 @@ def test_add_missing_indicator_no_longer_requires_imputation_enabled(frame_with_ categorical_imputation=None, add_missing_indicator=True, ).fit(frame_with_nan, y) + out = np.asarray(p.transform(frame_with_nan, return_array=True)) + assert out.size > 0 + assert np.isnan(out).any() assert p.total_output_dim_ > 0 From 89c865b3c4004f7aeeaf13b735a48162cd555e1c Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 10:25:47 +0200 Subject: [PATCH 105/123] =?UTF-8?q?bump:=20version=201.0.0rc4=20=E2=86=92?= =?UTF-8?q?=201.0.0rc5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 ++ pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c62ecc7..2d2f09a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ This project adheres to [Semantic Versioning](https://semver.org/) and uses Going forward, this file is updated automatically by `cz bump` on each release. +## v1.0.0rc5 (2026-09-06) + ## v1.0.0rc4 (2026-09-06) ### Feat diff --git a/pyproject.toml b/pyproject.toml index 5a22cd6..b922f01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pretab" -version = "1.0.0rc4" +version = "1.0.0rc5" description = "A scikit-learn compatible library for flexible tabular preprocessing, advanced feature representations, and basis expansions." authors = [ { name = "Anton Thielmann" }, From 5bffa4bb26c32e4527133e839ca55154e89dba16 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 13:25:47 +0200 Subject: [PATCH 106/123] ci: add release branch qa gate, update docs --- .github/workflows/ci.yml | 1 + .github/workflows/docs.yml | 1 + docs/developer_guide/release.md | 38 ++++++++++++++++++++++++++++----- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3b9832..60c4d2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,7 @@ on: push: branches: - main + - "release/**" pull_request: branches: - main diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 950a45a..a1e8e79 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -16,6 +16,7 @@ on: # which files changed in the tagged commit. branches: - main + - "release/**" tags: - "v*.*.*" diff --git a/docs/developer_guide/release.md b/docs/developer_guide/release.md index bdb5e60..ff2e374 100644 --- a/docs/developer_guide/release.md +++ b/docs/developer_guide/release.md @@ -17,7 +17,37 @@ For the SemVer rules and commit conventions that decide the next version, see | Build check | `workflow_dispatch` (manual) | `build-check.yml` | Artifact | | Release candidate | Tag `vX.Y.ZrcN` | `publish-testpypi.yml` | TestPyPI | | Stable release | Tag `vX.Y.Z` | `publish-pypi.yml` | PyPI | - +## What runs when + +`ci.yml` and `docs.yml` trigger on both `main` and `release/**` branches, so a commit +pushed straight to a release branch (step 4 below) gets the same feedback as a commit on +`main`, without waiting for the next RC tag. + +| Event | `ci.yml` | `docs.yml` | Publish workflow | +| ------------------------------- | :------: | :--------: | ----------------- | +| PR into `main` | ✅ | ✅ (docs paths only) | - | +| Push to `main` | ✅ | ✅ | - | +| Push to `release/**` | ✅ | ✅ | - | +| Push tag `vX.Y.ZrcN` | ✅ (via `qa`) | ✅ (via `docs`) | `publish-testpypi.yml` | +| Push tag `vX.Y.Z` | ✅ (via `qa`) | ✅ (via `docs`) | `publish-pypi.yml` | + +Every publish workflow re-runs the full `ci.yml`/`docs.yml` gate on the exact tagged +commit before anything is uploaded, rather than trusting whatever last passed on `main`. + +## QA gate checks + +| Check | Job in `ci.yml` | +| ----------------------- | ---------------- | +| Lint (`ruff check`, `ruff format --check`) | `lint` | +| Type checking (`pyright`) | `typecheck` | +| Package build + `twine check` | `build` | +| Full test matrix (3.10-3.13, 3 OSes) | `tests` | +| Minimum supported dependency versions | `minimum-deps` | +| Smoke test + quickstart script | `smoke` | +| Coverage (fails under 90%) | `coverage` | +| Optional extras (`embeddings`, `lightgbm`, `polars`) | `optional-deps` | +| Docs build (`sphinx-build -W --keep-going`) | `docs.yml` (separate workflow) | +| Wheel install + import smoke test | publish workflow's own steps | ## Prerequisites - You are a maintainer with permission to push tags to `main`. @@ -104,10 +134,8 @@ git push origin vX.Y.Z The `publish-pypi.yml` workflow builds the package, verifies the tag matches the project version, publishes to PyPI, and creates the GitHub Release. -Both publishing workflows first run the reusable CI and documentation workflows on the -tagged revision. Publishing waits for lint, types, the test matrix, minimum dependencies, -optional dependencies, coverage, and the strict documentation build to pass. The installed -wheel also runs the quickstart with Python's isolated mode before upload. +See [What runs when](#what-runs-when) and [QA gate checks](#qa-gate-checks) above for +exactly what both publishing workflows wait on before uploading. ### 7. Confirm From bd43d8eb87f2065fe81d5d780edcdd084fa44f19 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 13:28:38 +0200 Subject: [PATCH 107/123] docs: add note to optional lightgbm installation --- docs/getting_started/installation.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/getting_started/installation.md b/docs/getting_started/installation.md index e58238d..924416f 100644 --- a/docs/getting_started/installation.md +++ b/docs/getting_started/installation.md @@ -42,12 +42,20 @@ to use the `pretrained` categorical strategy. ``` The `lightgbm` extra enables the gradient-boosted `placement_strategy="lightgbm"` for -supervised knot, center, and threshold selection: +supervised knot, center, and threshold selection. By default, PreTab uses the built-in +`"cart"` strategy for target-aware placement, so LightGBM is only needed when you +explicitly opt into the boosted strategy: ```bash pip install "pretab[lightgbm]" ``` +```{note} +The `lightgbm` extra is required only for `placement_strategy="lightgbm"`. If you do +not set that strategy explicitly, PreTab uses the default `"cart"` path and does not +need the optional dependency installed. +``` + The `polars` extra enables `set_output(transform="polars")`, so `Preprocessor.transform` returns a `polars.DataFrame` instead of a NumPy array or dict: From 682c296d9337615b3bd7d73fc951d9894cf55dd7 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 13:52:07 +0200 Subject: [PATCH 108/123] fix: fit summary to report resolved methods --- pretab/preprocessor.py | 21 +++++++++++++++++++-- tests/integration/test_verbosity.py | 25 +++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index 14fbfa0..761be3a 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -84,6 +84,21 @@ } +def _method_summary(features, *, is_numerical: bool, config: PreprocessorConfig) -> str: + """Summarize the resolved method(s) actually used across ``features``. + + Returns the single method name when every feature resolves to the same one + (the common case, and historically what the fit-summary log line showed). + When ``feature_preprocessing`` overrides make features of the same kind + resolve to different methods, returns a "mixed: ..." listing instead of + silently reporting just the global default. + """ + methods = sorted({config.method_for(feature, is_numerical=is_numerical) for feature in features}) + if len(methods) <= 1: + return methods[0] if methods else "none" + return "mixed: " + ", ".join(methods) + + class Preprocessor(TransformerMixin, BaseEstimator): r""" Preprocessor class for automated tabular feature preprocessing using scikit-learn-compatible pipelines. @@ -560,12 +575,14 @@ def fit(self, X, y=None, embeddings=None): self._enforce_output_budget(X.shape[0]) if verbose >= 1: + numerical_summary = _method_summary(numerical_features, is_numerical=True, config=config) + categorical_summary = _method_summary(categorical_features, is_numerical=False, config=config) logger.info( "fit complete: %d numerical (%s) + %d categorical (%s) feature(s) -> %d output columns in %.3fs", len(numerical_features), - config.numerical_method, + numerical_summary, len(categorical_features), - config.categorical_method, + categorical_summary, len(self.get_feature_names_out()), time.perf_counter() - start_time, ) diff --git a/tests/integration/test_verbosity.py b/tests/integration/test_verbosity.py index cdc53be..3c44e1e 100644 --- a/tests/integration/test_verbosity.py +++ b/tests/integration/test_verbosity.py @@ -75,6 +75,31 @@ def test_verbose_1_logs_fit_summary(sample_data, caplog): assert [r for r in caplog.records if r.levelno == logging.DEBUG] == [] +def test_verbose_1_summary_reflects_per_feature_overrides(sample_data, caplog): + """Regression guard: the fit summary must report the methods actually + resolved per column, not just the global numerical_method/categorical_method, + once feature_preprocessing overrides diverge from the global default.""" + X, y = sample_data + caplog.set_level(logging.DEBUG, logger="pretab") + Preprocessor( + feature_preprocessing={"num2": "rbf"}, + numerical_method="ple", + verbose=1, + ).fit(X, y) + assert "mixed: ple, rbf" in caplog.text + assert "(ple)" not in caplog.text + + +def test_verbose_1_summary_stays_single_method_without_overrides(sample_data, caplog): + """No feature_preprocessing overrides -> the summary still names the one + global method actually used, unchanged from before the mixed-method fix.""" + X, y = sample_data + caplog.set_level(logging.DEBUG, logger="pretab") + Preprocessor(numerical_method="ple", verbose=1).fit(X, y) + assert "(ple)" in caplog.text + assert "mixed" not in caplog.text + + def test_verbose_2_logs_feature_table(sample_data, caplog): X, y = sample_data caplog.set_level(logging.DEBUG, logger="pretab") From bbb0cdfa5f2d5853baea5129490f6056947c02b2 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 13:52:30 +0200 Subject: [PATCH 109/123] docs: document verbose fit logging --- README.md | 6 ++ docs/core_concepts/configuration.md | 54 ++++++++++++---- docs/core_concepts/outputs_and_inspection.md | 68 ++++++++++++++++++++ docs/developer_guide/release.md | 40 ++++++------ 4 files changed, 138 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 53197d9..c4a946e 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,12 @@ preprocessor.get_feature_info(verbose=True) # resolved strategies, widths, lineage = preprocessor.get_feature_lineage() # one record per output column ``` +> **Tip:** Pass `verbose=1` (or higher) to `Preprocessor` for fit-time logging through the +> shared `"pretab"` logger, including a one-line summary of the resolved method(s) per +> feature kind. See +> [Outputs and inspection](https://pretab.readthedocs.io/en/latest/core_concepts/outputs_and_inspection.html#fit-time-logging) +> for the full level reference. + ### Leakage-safe supervised representations Methods like `PLETransformer` place their bins using the target. PreTab warns when a diff --git a/docs/core_concepts/configuration.md b/docs/core_concepts/configuration.md index d5a581d..9e460be 100644 --- a/docs/core_concepts/configuration.md +++ b/docs/core_concepts/configuration.md @@ -45,6 +45,37 @@ not need to state whether a column is numerical or categorical; PreTab already k feature-type detection. ``` +### Columns not listed in `feature_preprocessing` + +`feature_preprocessing` only overrides the columns it names. Any numerical column left out +falls back to `numerical_method`, and any categorical column left out falls back to +`categorical_method`. This is real method resolution, not just a logging detail: the fallback +column is fit with the global default's transformer, exactly as if you had listed it yourself. + +```python +pre = Preprocessor( + numerical_method="bspline", # applies to every numerical column not listed below + feature_preprocessing={ + "income": "rbf", # overrides the default for this one column + "city": "one-hot", + }, +).fit(df, y) +``` + +| Column | Kind | Listed in `feature_preprocessing`? | Resolved method | +| -------- | ----------- | ---------------------------------- | ----------------------------------- | +| `age` | numerical | no | `bspline` (from `numerical_method`) | +| `income` | numerical | yes, `"rbf"` | `rbf` | +| `city` | categorical | yes, `"one-hot"` | `one-hot` | +| `region` | categorical | no | `int` (from `categorical_method`) | + +```{tip} +Don't infer the resolved method per column from the constructor arguments alone. Call +`get_feature_info(verbose=True)` after `fit` for the definitive per-column table, or set +`verbose=2` (or higher) on the `Preprocessor` to log the same table at fit time. See +[Fit-time logging](outputs_and_inspection.md#fit-time-logging). +``` + ## Presets Presets are transparent, named bundles of parameters for common intents. They set the same @@ -113,17 +144,18 @@ representation. 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) | -| `output_structure` | `"matrix"` | [Outputs and inspection](outputs_and_inspection.md) | -| `random_state` | `None` | [Reproducibility](reproducibility.md) | +| 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) | +| `output_structure` | `"matrix"` | [Outputs and inspection](outputs_and_inspection.md) | +| `verbose` | `0` | [Outputs and inspection](outputs_and_inspection.md#fit-time-logging) | +| `random_state` | `None` | [Reproducibility](reproducibility.md) | ## Where to go next diff --git a/docs/core_concepts/outputs_and_inspection.md b/docs/core_concepts/outputs_and_inspection.md index 57d46e9..b8b4d80 100644 --- a/docs/core_concepts/outputs_and_inspection.md +++ b/docs/core_concepts/outputs_and_inspection.md @@ -301,6 +301,74 @@ income numerical imputer -> minmax -> ple 12 - city categorical imputer -> onehot -> to_float 4 4 ``` +`verbose=True` is the default, so `get_feature_info()` both logs that table and returns the +same data as a tuple of dicts, one per feature kind, keyed by input column name: + +```python +pre.get_feature_info() +``` + +```text +feature kind pipeline dim cats +---------------------------------------------------------------- +age numerical imputer -> minmax -> bspline 7 - +income numerical imputer -> minmax -> rbf 7 - +city categorical imputer -> onehot -> to_float 3 3 +``` + +```python +( + {"age": {"preprocessing": "imputer -> minmax -> bspline", "dimension": 7, "categories": None}, + "income": {"preprocessing": "imputer -> minmax -> rbf", "dimension": 7, "categories": None}}, + {"city": {"preprocessing": "imputer -> onehot -> to_float", "dimension": 3, "categories": 3}}, + {}, +) +``` + +```{tip} +`get_feature_info()` is a handy, no-setup way to check exactly what happened to each feature: +which pipeline it went through, how many output columns it produced, and (for categoricals) +how many categories were seen. Pass `verbose=False` to get just the returned dicts, silently. +For other fitted details (total output width, per-feature output counts, the last +`transform`'s memory report), see the `Preprocessor` **Attributes** in the +[API reference](../api/preprocessor.rst). +``` + +## Fit-time logging + +`verbose` controls how much `fit` reports through the shared `"pretab"` logger, useful when +running PreTab inside a larger training script or notebook. + +| Level | What is logged | +| --------------- | -------------------------------------------------------------------------------------------- | +| `0` (default) | Nothing, aside from `PretabWarning` data warnings. | +| `1` (or `True`) | One summary line: feature counts, resolved method(s) per kind, total output width, duration. | +| `2` | Also logs the same table `get_feature_info(verbose=True)` builds. | +| `3` | Also logs internal fitted decisions (bins, knots, centers). | + +```python +Preprocessor(numerical_method="ple", verbose=1).fit(df, y) +``` + +```text +fit complete: 2 numerical (ple) + 1 categorical (int) feature(s) -> 15 output columns in 0.02s +``` + +```{note} +When `feature_preprocessing` overrides make columns of the same kind resolve to different +methods, the summary reports `mixed: , , ...` instead of a single name, so the +log line never misrepresents which method actually ran on a given feature. For the definitive, +per-column answer, use `get_feature_info(verbose=True)` (level `2` logs the same table). +``` + +```{tip} +This also covers columns left out of `feature_preprocessing` entirely: they resolve to the +global `numerical_method` / `categorical_method` for their kind, and show up in the same +`mixed: ...` summary whenever a sibling column of that kind was overridden. See +[Columns not listed in feature_preprocessing](configuration.md#columns-not-listed-in-feature_preprocessing) +for a worked example. +``` + ## Feature lineage Lineage is the flagship inspection feature. `get_feature_lineage()` returns one record per diff --git a/docs/developer_guide/release.md b/docs/developer_guide/release.md index ff2e374..4ca86fc 100644 --- a/docs/developer_guide/release.md +++ b/docs/developer_guide/release.md @@ -17,37 +17,39 @@ For the SemVer rules and commit conventions that decide the next version, see | Build check | `workflow_dispatch` (manual) | `build-check.yml` | Artifact | | Release candidate | Tag `vX.Y.ZrcN` | `publish-testpypi.yml` | TestPyPI | | Stable release | Tag `vX.Y.Z` | `publish-pypi.yml` | PyPI | + ## What runs when `ci.yml` and `docs.yml` trigger on both `main` and `release/**` branches, so a commit pushed straight to a release branch (step 4 below) gets the same feedback as a commit on `main`, without waiting for the next RC tag. -| Event | `ci.yml` | `docs.yml` | Publish workflow | -| ------------------------------- | :------: | :--------: | ----------------- | -| PR into `main` | ✅ | ✅ (docs paths only) | - | -| Push to `main` | ✅ | ✅ | - | -| Push to `release/**` | ✅ | ✅ | - | -| Push tag `vX.Y.ZrcN` | ✅ (via `qa`) | ✅ (via `docs`) | `publish-testpypi.yml` | -| Push tag `vX.Y.Z` | ✅ (via `qa`) | ✅ (via `docs`) | `publish-pypi.yml` | +| Event | `ci.yml` | `docs.yml` | Publish workflow | +| -------------------- | :-----------: | :------------------: | ---------------------- | +| PR into `main` | ✅ | ✅ (docs paths only) | - | +| Push to `main` | ✅ | ✅ | - | +| Push to `release/**` | ✅ | ✅ | - | +| Push tag `vX.Y.ZrcN` | ✅ (via `qa`) | ✅ (via `docs`) | `publish-testpypi.yml` | +| Push tag `vX.Y.Z` | ✅ (via `qa`) | ✅ (via `docs`) | `publish-pypi.yml` | Every publish workflow re-runs the full `ci.yml`/`docs.yml` gate on the exact tagged commit before anything is uploaded, rather than trusting whatever last passed on `main`. ## QA gate checks -| Check | Job in `ci.yml` | -| ----------------------- | ---------------- | -| Lint (`ruff check`, `ruff format --check`) | `lint` | -| Type checking (`pyright`) | `typecheck` | -| Package build + `twine check` | `build` | -| Full test matrix (3.10-3.13, 3 OSes) | `tests` | -| Minimum supported dependency versions | `minimum-deps` | -| Smoke test + quickstart script | `smoke` | -| Coverage (fails under 90%) | `coverage` | -| Optional extras (`embeddings`, `lightgbm`, `polars`) | `optional-deps` | -| Docs build (`sphinx-build -W --keep-going`) | `docs.yml` (separate workflow) | -| Wheel install + import smoke test | publish workflow's own steps | +| Check | Job in `ci.yml` | +| ---------------------------------------------------- | ------------------------------ | +| Lint (`ruff check`, `ruff format --check`) | `lint` | +| Type checking (`pyright`) | `typecheck` | +| Package build + `twine check` | `build` | +| Full test matrix (3.10-3.13, 3 OSes) | `tests` | +| Minimum supported dependency versions | `minimum-deps` | +| Smoke test + quickstart script | `smoke` | +| Coverage (fails under 90%) | `coverage` | +| Optional extras (`embeddings`, `lightgbm`, `polars`) | `optional-deps` | +| Docs build (`sphinx-build -W --keep-going`) | `docs.yml` (separate workflow) | +| Wheel install + import smoke test | publish workflow's own steps | + ## Prerequisites - You are a maintainer with permission to push tags to `main`. From 35ffa88054a1152991e3ef712fbc0a468833ad9d Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 14:28:49 +0200 Subject: [PATCH 110/123] docs: reorder overview content --- docs/getting_started/overview.md | 61 +++++++++++++++++--------------- 1 file changed, 32 insertions(+), 29 deletions(-) diff --git a/docs/getting_started/overview.md b/docs/getting_started/overview.md index 87d64db..3359bc0 100644 --- a/docs/getting_started/overview.md +++ b/docs/getting_started/overview.md @@ -32,6 +32,28 @@ pre = Preprocessor(feature_preprocessing={ X = pre.fit_transform(df, y) ``` +## 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. + ## How this compares to scikit-learn's preprocessing transformers PreTab is not a competitor to scikit-learn. Every transformer subclasses `BaseEstimator` and @@ -41,7 +63,7 @@ The real question is what PreTab adds where scope overlaps with scikit-learn's o | 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"`) | +| Knot / threshold placement | Fixed `n_knots`, placed `"uniform"` or `"quantile"` before fitting | Configured via `output_dim` (how many basis functions you want); PreTab derives the knot count from it and places them with `placement_strategy`, optionally target-aware (a CART or LightGBM model places them where the target changes fastest) | | 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) | @@ -60,8 +82,9 @@ transformers rather than a per-column `Preprocessor` choice. 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 want an existing model, from a simple linear model (Ridge, logistic regression, a + GAM) to a deep learning architecture, to capture non-linear structure through the input + representation rather than through added model complexity. - 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. @@ -71,10 +94,12 @@ PreTab is a good fit when any of the following is true. (`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. +The right representation depends on what sits downstream: linear and additive models gain +the most, tree ensembles (gradient boosting, random forests) usually gain the least since +they already partition each feature on their own, and neural networks benefit from methods +like PLE and learned embeddings. See +[Choosing a method](../representations/choosing_a_method.md#start-from-the-model) for the +full breakdown. ``` ## What PreTab is not @@ -95,28 +120,6 @@ try to be an everything-library. 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. From 6cda6ede36f66872c9a8030268b63b72f663afd0 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 14:37:46 +0200 Subject: [PATCH 111/123] docs: add command outputs, warning --- docs/getting_started/quickstart.md | 45 ++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/docs/getting_started/quickstart.md b/docs/getting_started/quickstart.md index a84eb59..da32fd3 100644 --- a/docs/getting_started/quickstart.md +++ b/docs/getting_started/quickstart.md @@ -49,6 +49,10 @@ X = pre.fit_transform(df, y) X.shape ``` +```text +(200, 26) +``` + ```{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 @@ -61,6 +65,11 @@ Ask for a dict of per-feature blocks instead, for inspection or per-block downst X_dict = pre.transform(df, return_array=False) # {"num_age": ..., "cat_city": ...} ``` +```text +{'num_age': (200, 7), 'num_income': (200, 7), 'num_experience': (200, 7), + 'cat_job': (200, 4), 'cat_city': (200, 1)} +``` + ## Inspect what was built Every fitted representation is self-describing. Read the resolved layout, or trace each @@ -73,6 +82,22 @@ lineage = pre.get_feature_lineage() # one record per output column lineage[0] ``` +```text +feature kind pipeline dim cats +----------------------------------------------------------------------- +age numerical imputer -> minmax -> ple 7 - +income numerical imputer -> minmax -> rbf 7 - +experience numerical imputer -> minmax -> naturalspline 7 - +job categorical imputer -> onehot -> to_float 4 4 +city categorical imputer -> continuous_ordinal 1 5 +``` + +```text +FeatureLineage(output_feature='num_age_ple0', output_index=0, source_features=('age',), + family='piecewise_linear', component='interval', component_index=0, + uses_target=True, is_interaction=False) +``` + 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. @@ -94,6 +119,10 @@ x_ple = PLETransformer(output_dim=15, task="regression").fit_transform(x, y) x_ple.shape[1] # number of piecewise-linear bins ``` +```text +15 +``` + ```{note} `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 @@ -114,6 +143,11 @@ spline.fit_transform(x) penalty = spline.get_penalty_matrix() # integrated-curvature penalty for GAM-style fitting ``` +```text +(200, 8) # spline.fit_transform(x).shape +(8, 8) # penalty.shape +``` + The multivariate thin-plate spline models several columns jointly and is sized by `n_components` rather than `output_dim`: @@ -128,6 +162,17 @@ features = tp.fit_transform(x) penalty = tp.get_penalty_matrix() ``` +```text +(200, 10) # features.shape +(10, 10) # penalty.shape +``` + +```{warning} +`ThinPlateSplineTransformer.get_penalty_matrix()` is experimental: the retained +eigenvalues are not guaranteed non-negative, so the returned penalty is not guaranteed +positive semi-definite. Calling it emits a `ConfigWarning` to make this explicit. +``` + ## Next steps - See PreTab lift a linear model, baseline versus PreTab, in the From 0db1544e3b09f1592023fa2517210433c90cce5a Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 14:42:24 +0200 Subject: [PATCH 112/123] docs: extend numpy input details explicitly --- docs/core_concepts/configuration.md | 28 +++++++++++++++++++ docs/getting_started/choosing_an_interface.md | 5 ++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/docs/core_concepts/configuration.md b/docs/core_concepts/configuration.md index 9e460be..a9ec667 100644 --- a/docs/core_concepts/configuration.md +++ b/docs/core_concepts/configuration.md @@ -23,6 +23,34 @@ pre = Preprocessor( 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). +## NumPy array input + +`Preprocessor` also accepts a plain `numpy.ndarray`, not just a `DataFrame`. Columns are +named `feature_0`, `feature_1`, ... in position order, then detected as numerical or +categorical exactly as they would be for a `DataFrame`. + +```python +import numpy as np +from pretab import Preprocessor + +X = np.random.default_rng(0).normal(size=(100, 3)) +y = np.random.default_rng(0).normal(size=100) + +pre = Preprocessor(numerical_method="ple").fit(X, y) +pre.numerical_features_ +``` + +```text +['feature_0', 'feature_1', 'feature_2'] +``` + +```{tip} +`feature_preprocessing` works the same way on array input: target the synthetic name, for +example `{"feature_0": "rbf"}`. Check `numerical_features_` / `categorical_features_` after +`fit` (or `get_feature_info()`) to confirm the names PreTab assigned before writing the +overrides, rather than guessing the column order. +``` + ## Per-feature overrides Columns rarely want identical treatment. The `feature_preprocessing` dict assigns a strategy diff --git a/docs/getting_started/choosing_an_interface.md b/docs/getting_started/choosing_an_interface.md index 3dba5f4..b060268 100644 --- a/docs/getting_started/choosing_an_interface.md +++ b/docs/getting_started/choosing_an_interface.md @@ -10,8 +10,9 @@ ergonomics, not capability. This page helps you pick. :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 single stacked array by default. +Reads a `DataFrame` or `numpy.ndarray`, detects numerical and categorical columns, and +applies a strategy per column from a single configuration object. Returns a single stacked +array by default. ::: :::{grid-item-card} Standalone transformers From 3f3d6464c561d51cc6f485d50f93fad8a3cb696b Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 15:15:05 +0200 Subject: [PATCH 113/123] fix: make present task dependent --- docs/core_concepts/configuration.md | 48 ++++++++++++++++------- docs/representations/choosing_a_method.md | 5 ++- docs/representations/references.md | 9 +++++ pretab/preprocessor.py | 39 ++++++++++++------ tests/integration/test_presets.py | 27 ++++++++++--- 5 files changed, 93 insertions(+), 35 deletions(-) diff --git a/docs/core_concepts/configuration.md b/docs/core_concepts/configuration.md index a9ec667..0b55649 100644 --- a/docs/core_concepts/configuration.md +++ b/docs/core_concepts/configuration.md @@ -108,13 +108,34 @@ Don't infer the resolved method per column from the constructor arguments alone. Presets are transparent, named bundles of parameters for common intents. They set the same knobs you could set by hand, so nothing is hidden, and each one resolves to a fixed, -documented set of values: +documented set of values. `numerical_method` is the one exception: every preset resolves it +from `task` instead of a fixed value, since a spline basis and piecewise-linear encoding suit +regression and classification differently. -| 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` | +| Preset | `numerical_method` | `categorical_method` | `output_dim` | `adaptive` | `min_output_dim` | `max_output_dim` | +| ------------ | ------------------ | -------------------- | ------------ | ---------- | ---------------- | ---------------- | +| `"standard"` | task-dependent | `"int"` | `7` | `False` | - | - | +| `"expanded"` | task-dependent | `"one-hot"` | `10` | `False` | - | - | +| `"adaptive"` | task-dependent | `"int"` | - | `True` | `7` | `15` | + +`numerical_method` resolves to `"bspline"` when `task="regression"` (the default) and to +`"ple"` when `task="classification"`, for every preset: + +```python +standard_regression = Preprocessor(preset="standard", task="regression") +standard_classification = Preprocessor(preset="standard", task="classification") + +standard_regression.get_resolved_config()["numerical_method"] # "bspline" +standard_classification.get_resolved_config()["numerical_method"] # "ple" +``` + +```{note} +The regression/classification split follows Kumar et al. (2026), +["From Uniform to Learned Knots: A Study of Spline-Based Numerical Encodings for Tabular Deep +Learning"](https://openreview.net/pdf?id=str7wQt9Qc), *Transactions on Machine Learning +Research*. See the [representation references](../representations/references.md) for the full +citation. +``` ```python standard = Preprocessor(preset="standard") @@ -122,19 +143,18 @@ 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" +expanded.get_resolved_config()["output_dim"] # 10: wider 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`. The `output_dim=7` shown for `"adaptive"` above is just -the ordinary fallback default reported by `get_resolved_config()`; the preset itself never sets -it, and it has no effect since both bounds are set. +So `"standard"` is the balanced default (`output_dim=7`, integer-coded categoricals), +`"expanded"` widens the representation to `output_dim=10` and one-hot-encodes categoricals +instead, and `"adaptive"` lets each feature pick its own width between `min_output_dim=7` and +`max_output_dim=15` 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, for example `Preprocessor(preset="expanded", output_dim=32)`. +the preset's value for that knob, including `numerical_method` itself, for example +`Preprocessor(preset="expanded", numerical_method="rbf")`. ``` ## Reading the resolved configuration diff --git a/docs/representations/choosing_a_method.md b/docs/representations/choosing_a_method.md index 70428c7..236996c 100644 --- a/docs/representations/choosing_a_method.md +++ b/docs/representations/choosing_a_method.md @@ -33,8 +33,9 @@ literature. Splines can help shallow networks. | 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 +When unsure, start with the `"standard"` preset (min-max scaling, integer categoricals, and +`numerical_method` resolved from `task`: `"bspline"` for regression, `"ple"` for +classification) and compare against another method. The [comparing representations tutorial](../tutorials/comparing_representations.md) shows how to measure the difference instead of guessing. ``` diff --git a/docs/representations/references.md b/docs/representations/references.md index 30bad0d..6f7bfb9 100644 --- a/docs/representations/references.md +++ b/docs/representations/references.md @@ -17,6 +17,15 @@ These two papers introduce the P-spline (B-spline basis with a difference penalt tensor-product extension, which underpin `PSplineTransformer` and `TensorProductSplineTransformer`. +Kumar, M., Thielmann, A. F., Weisser, C., and Säfken, B. (2026). From uniform to learned +knots: A study of spline-based numerical encodings for tabular deep learning. _Transactions +on Machine Learning Research_. + +This study compares uniform and learned knot placement for spline-based numerical encodings +against piecewise-linear encoding across regression and classification tasks, and motivates +the `Preprocessor`'s task-dependent preset default: `"bspline"` for regression, `"ple"` for +classification. See [Presets](../core_concepts/configuration.md#presets). + ## Thin-plate and generalized additive models Wahba, G. (1990). _Spline Models for Observational Data_. Society for Industrial and Applied diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py index 761be3a..a8193b8 100644 --- a/pretab/preprocessor.py +++ b/pretab/preprocessor.py @@ -48,29 +48,38 @@ #: sets explicitly overrides the preset. ``__init__`` defaults every parameter listed #: here to :data:`~pretab.core.parameters.UNSET` (rather than its ordinary value) so #: "left unset" can be told apart from "explicitly passed the same value the default -#: happens to have" -- see :meth:`Preprocessor._resolved_params`. +#: happens to have" -- see :meth:`Preprocessor._resolved_params`. ``numerical_method`` +#: is deliberately absent here: every preset resolves it from ``task`` instead, via +#: :func:`_preset_numerical_method`. PRESETS = { "standard": { - "numerical_method": "ple", "categorical_method": "int", "output_dim": 7, "adaptive": False, }, "expanded": { - "numerical_method": "ple", "categorical_method": "one-hot", - "output_dim": 16, + "output_dim": 10, "adaptive": False, }, "adaptive": { - "numerical_method": "ple", "categorical_method": "int", "adaptive": True, - "min_output_dim": 5, - "max_output_dim": 16, + "min_output_dim": 7, + "max_output_dim": 15, }, } + +def _preset_numerical_method(task) -> str: + """Return the preset numerical method for a given ``task``. + + Splines (``"bspline"``) are the preset default for regression, and piecewise-linear + encoding (``"ple"``) remains the default for classification. + """ + return "bspline" if task == "regression" else "ple" + + #: True ``__init__`` defaults for the parameters presets may override. These are #: substituted back in by :meth:`Preprocessor._resolved_params` wherever the #: constructor received :data:`~pretab.core.parameters.UNSET`. @@ -79,7 +88,7 @@ "categorical_method": "int", "output_dim": 7, "adaptive": False, - "min_output_dim": 5, + "min_output_dim": 7, "max_output_dim": 10, } @@ -173,7 +182,7 @@ class Preprocessor(TransformerMixin, BaseEstimator): (within ``[min_output_dim, max_output_dim]``) instead of using the fixed ``output_dim``. Fixed-width methods (e.g. plain scalers) ignore this flag. When True and both bounds are set, ``output_dim`` itself has no effect (see above). - min_output_dim : int, default=5 + min_output_dim : int, default=7 Lower bound on the per-feature output dimension when ``adaptive`` is True. Ignored by fixed-width methods and when ``adaptive`` is False. max_output_dim : int, default=10 @@ -285,10 +294,12 @@ class Preprocessor(TransformerMixin, BaseEstimator): 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. + Every preset resolves ``numerical_method`` from ``task``: ``"bspline"`` for + ``task="regression"``, ``"ple"`` for ``task="classification"``. + ``"standard"`` is the balanced baseline, ``"expanded"`` widens the numerical basis + and one-hot encodes categoricals, and ``"adaptive"`` sizes each feature's width + from the data within ``[7, 15]``. Call :meth:`get_resolved_config` to see the + effective parameters. ``None`` (default) uses the individual parameters unchanged. Attributes ---------- @@ -711,6 +722,8 @@ def _resolved_params(self): for key, preset_value in PRESETS[preset].items(): if params.get(key, UNSET) is UNSET: resolved[key] = preset_value + if params.get("numerical_method", UNSET) is UNSET: + resolved["numerical_method"] = _preset_numerical_method(resolved["task"]) return resolved def get_resolved_config(self): diff --git a/tests/integration/test_presets.py b/tests/integration/test_presets.py index 333e348..827e8a3 100644 --- a/tests/integration/test_presets.py +++ b/tests/integration/test_presets.py @@ -30,31 +30,46 @@ def test_no_preset_resolved_config_drops_preset_key(): def test_standard_preset_matches_baseline(): + # Preprocessor()'s default task is "regression", so the preset resolves + # numerical_method to "bspline" (see test_preset_numerical_method_follows_task). cfg = Preprocessor(preset="standard").get_resolved_config() - assert cfg["numerical_method"] == "ple" + assert cfg["numerical_method"] == "bspline" assert cfg["categorical_method"] == "int" assert cfg["output_dim"] == 7 assert cfg["adaptive"] is False assert "preset" not in cfg +def test_preset_numerical_method_follows_task(): + """Every preset resolves numerical_method from task: bspline for regression, + ple for classification. Explicitly setting numerical_method still wins.""" + for preset in ("standard", "expanded", "adaptive"): + regression_cfg = Preprocessor(preset=preset, task="regression").get_resolved_config() + classification_cfg = Preprocessor(preset=preset, task="classification").get_resolved_config() + assert regression_cfg["numerical_method"] == "bspline" + assert classification_cfg["numerical_method"] == "ple" + + explicit_cfg = Preprocessor(preset="standard", task="regression", numerical_method="rbf").get_resolved_config() + assert explicit_cfg["numerical_method"] == "rbf" + + 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["output_dim"] == 10 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 + assert cfg["min_output_dim"] == 7 + assert cfg["max_output_dim"] == 15 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 + assert cfg["output_dim"] == 5 # user value wins over the preset's 10 def test_explicit_param_equal_to_constructor_default_overrides_preset(): @@ -65,7 +80,7 @@ def test_explicit_param_equal_to_constructor_default_overrides_preset(): assert cfg["adaptive"] is False # user explicitly said no, preset's True must not apply cfg2 = Preprocessor(preset="expanded", output_dim=7).get_resolved_config() - assert cfg2["output_dim"] == 7 # user explicitly said 7, preset's 16 must not apply + assert cfg2["output_dim"] == 7 # user explicitly said 7, preset's 10 must not apply def test_preset_is_preserved_by_get_params_and_clone(): From d7665816b45accf5fe57463313f162159fe81bbf Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 15:15:20 +0200 Subject: [PATCH 114/123] docs: improve overview and feature_representation --- docs/core_concepts/feature_representation.md | 77 ++++++++++++-------- docs/getting_started/overview.md | 16 ++-- 2 files changed, 56 insertions(+), 37 deletions(-) diff --git a/docs/core_concepts/feature_representation.md b/docs/core_concepts/feature_representation.md index 5fb1a4c..8941844 100644 --- a/docs/core_concepts/feature_representation.md +++ b/docs/core_concepts/feature_representation.md @@ -3,19 +3,50 @@ PreTab draws a deliberate line between two ideas that are often blurred together: _preprocessing_ and _representation_. The distinction shapes the whole library. -## Preprocessing prepares a column +- **Preprocessing prepares a column** without changing what it _means_. +- **Representation exposes structure** a plain estimator cannot weight on its own. -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. +| Aspect | Preprocessing | Representation | +| ------------------------------ | ---------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| Purpose | Make a column safe and comparable for a model | Expose structure a plain estimator cannot weight on its own | +| Relationship to input | One-to-one: same signal, rescaled or cleaned | One-to-many: expands into a new basis | +| Output width per column | Unchanged (one-hot excepted, one per category) | Grows to `output_dim` basis columns | +| PreTab methods | `minmax`, `standardization`, `robust`, `one-hot`, imputers | `bspline`, `naturalspline`, `ple`, `rbf`, `fourier`, and the rest of the catalogue | +| Changes what the column means? | No | Yes: re-expresses it in a new coordinate system | -## Representation exposes structure +The same input column makes this concrete. Scaling keeps the column a single coordinate; +a spline basis turns it into several. -A representation expands a column into a new basis that exposes structure a plain estimator -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. +```python +import numpy as np +from pretab import Preprocessor + +age = np.random.default_rng(0).uniform(18, 65, size=100).reshape(-1, 1) +y = np.random.default_rng(0).normal(size=100) + +scaled = Preprocessor(numerical_method="minmax").fit_transform(age, y) +scaled.shape +``` + +```text +(100, 1) +``` + +```python +expanded = Preprocessor( + numerical_method="bspline", output_dim=6, + target_aware=False, placement_strategy="quantile", +).fit_transform(age, y) +expanded.shape +``` + +```text +(100, 6) +``` + +`minmax` preprocesses `age` into a single rescaled column: same meaning, safe range. +`bspline` represents the same column as 6 local basis functions a linear model can weight +independently, capturing shapes a single rescaled slope cannot. ```{note} This is the load-bearing idea in PreTab: the model is often fine, the *representation* is @@ -46,25 +77,13 @@ See [Target awareness](target_awareness.md) for how PreTab guards against this. 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 -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. +| Term | Meaning | +| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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: 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 diff --git a/docs/getting_started/overview.md b/docs/getting_started/overview.md index 3359bc0..7c52b2e 100644 --- a/docs/getting_started/overview.md +++ b/docs/getting_started/overview.md @@ -61,14 +61,14 @@ PreTab is not a competitor to scikit-learn. Every transformer subclasses `BaseEs 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 | Fixed `n_knots`, placed `"uniform"` or `"quantile"` before fitting | Configured via `output_dim` (how many basis functions you want); PreTab derives the knot count from it and places them with `placement_strategy`, optionally target-aware (a CART or LightGBM model places them where the target changes fastest) | -| 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 spec for supported fitted state, plus a stable `fingerprint_`; restore trusted specs in the same environment | -| 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 | +| Capability | scikit-learn | PreTab | +| -------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Knot / threshold placement | Fixed `n_knots`, placed `"uniform"` or `"quantile"` before fitting | Configured via `output_dim` (how many basis functions you want); PreTab derives the knot count from it and places them with `placement_strategy`, optionally target-aware (a CART or LightGBM model places them where the target changes fastest) | +| 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 spec for supported fitted state, plus a stable `fingerprint_`; restore trusted specs in the same environment | +| 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`, From 12c37c361f9d5c8058ce3484c410c2e71d363671 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 15:17:51 +0200 Subject: [PATCH 115/123] docs: link removed --- docs/representations/references.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/representations/references.md b/docs/representations/references.md index 6f7bfb9..9cf7e04 100644 --- a/docs/representations/references.md +++ b/docs/representations/references.md @@ -18,8 +18,7 @@ tensor-product extension, which underpin `PSplineTransformer` and `TensorProductSplineTransformer`. Kumar, M., Thielmann, A. F., Weisser, C., and Säfken, B. (2026). From uniform to learned -knots: A study of spline-based numerical encodings for tabular deep learning. _Transactions -on Machine Learning Research_. +knots: A study of spline-based numerical encodings for tabular deep learning.(TMLR). This study compares uniform and learned knot placement for spline-based numerical encodings against piecewise-linear encoding across regression and classification tasks, and motivates From d53459ce08acaa4b997270b1950f2e1ef6b7eb19 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 15:18:53 +0200 Subject: [PATCH 116/123] docs: update --- docs/representations/references.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/representations/references.md b/docs/representations/references.md index 9cf7e04..fd1b509 100644 --- a/docs/representations/references.md +++ b/docs/representations/references.md @@ -18,7 +18,7 @@ tensor-product extension, which underpin `PSplineTransformer` and `TensorProductSplineTransformer`. Kumar, M., Thielmann, A. F., Weisser, C., and Säfken, B. (2026). From uniform to learned -knots: A study of spline-based numerical encodings for tabular deep learning.(TMLR). +knots: A study of spline-based numerical encodings for tabular deep learning. _(TMLR)_. This study compares uniform and learned knot placement for spline-based numerical encodings against piecewise-linear encoding across regression and classification tasks, and motivates From e3b9829607d3f507378c5d4e9e74ce93ee17b2a6 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 16:05:03 +0200 Subject: [PATCH 117/123] docs: restructure target aware document --- docs/core_concepts/target_awareness.md | 104 ++++++++++++++++++++----- 1 file changed, 84 insertions(+), 20 deletions(-) diff --git a/docs/core_concepts/target_awareness.md b/docs/core_concepts/target_awareness.md index 956653e..2c43e6e 100644 --- a/docs/core_concepts/target_awareness.md +++ b/docs/core_concepts/target_awareness.md @@ -9,18 +9,11 @@ explicit and gives you leakage-safe tools. This page explains the contract. 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. +| Level | Meaning | Numerical methods | Categorical methods | +| ------------ | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | -------------------- | +| `forbidden` | Never uses the target | The scalers (`minmax`, `standardization`, `robust`, `quantile`, `box-cox`, `yeo-johnson`, `polynomial`), `custombin`, `fourier`, `pspline` | `int`, `one-hot`, `onehot_from_ordinal`, `pretrained` | +| `optional` | Uses the target only when `target_aware=True` | The feature maps (`rbf`, `relu`, `sigmoid`, `tanh`) and the freely-placed knot splines (`bspline`, `mspline`, `ispline`, `cubicspline`, `naturalspline`) | - | +| `required` | Always places against the target | `ple` (piecewise-linear encoding) | - | ```python from pretab.transformers import PLETransformer @@ -70,17 +63,51 @@ The safe patterns are: ## 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. +Even inside a `Pipeline`, fitting a supervised transformer once on the full training set and +then using it to build the *training* features for the same rows introduces a subtle leak: +each row's PLE bins were placed using its own target value. `CrossFittedTransformer` fixes +this for the training features specifically. It splits the training data into folds, fits a +fresh copy of the transformer on all folds *except* one, and uses that copy to transform the +held-out fold, so every training row is transformed by a model that never saw its own target. +`transform` on genuinely new data (a validation or test set) instead uses one model fit on all +the training data, since there is no leakage risk there. ```python +import numpy as np + 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 +rng = np.random.default_rng(0) +x_train = rng.uniform(-3.0, 3.0, size=(200, 1)) +y_train = rng.normal(size=200) + +# Naive: one PLE fit on all the training data, then used to transform that same data. +naive = PLETransformer(output_dim=10, random_state=0).fit(x_train, y_train).transform(x_train) + +# Cross-fitted: each row is transformed by a model that did not see its own target. +cf = CrossFittedTransformer(PLETransformer(output_dim=10, random_state=0), n_folds=5, random_state=0) +out_of_fold = cf.fit_transform(x_train, y_train) + +changed = (~np.all(naive == out_of_fold, axis=1)).sum() +changed, len(x_train) +``` + +```text +(193, 200) +``` + +193 of the 200 training rows get different feature values once out-of-fold cross-fitting is +used, which is the leakage `CrossFittedTransformer` removes: the naive version handed a +downstream model features that were partly informed by the very target it is trying to +predict. + +```{tip} +Use `CrossFittedTransformer` when you need the **training features themselves** to be +leakage-free, for example to feed a second-stage model or to report an honest training-set +metric. A supervised transformer inside a plain `Pipeline` already keeps cross-validation +honest for `cross_val_score` / `GridSearchCV`, since each fold refits from scratch; you only +need cross-fitting when you build the training features once and reuse them directly. ``` The fitted spec records `cross_fitted=True` and the number of folds, so the choice is @@ -93,12 +120,49 @@ directly determines the bins. For unsupervised methods it is unnecessary. ## Searching over representations +Choosing a numerical method by comparing validation scores is itself a form of model +selection, and doing it carelessly (for example scoring each candidate on the same data used +to fit it) leaks information the same way an unguarded supervised transformer does. `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. +`numerical_method` values, refits the best one on all the data, and keeps every candidate's +scoring honest by fitting a fresh `Preprocessor` per fold. ```python +import numpy as np +import pandas as pd +from sklearn.linear_model import Ridge + from pretab import RepresentationSearchCV + +rng = np.random.default_rng(0) +X = pd.DataFrame({"x": rng.uniform(-3, 3, size=300)}) +y = np.sin(X["x"]) + rng.normal(0, 0.1, size=300) + +search = RepresentationSearchCV( + estimator=Ridge(), + methods=["minmax", "ple", "bspline", "rbf"], + cv=5, + random_state=0, +) +search.fit(X, y) +search.cv_results_ +search.best_method_ +``` + +```text +{'minmax': 0.636, 'ple': 0.949, 'bspline': 0.973, 'rbf': 0.841} +'bspline' +``` + +`bspline` scored highest across the 5 folds for this sine-shaped signal, so `search. +best_preprocessor_` and `search.best_estimator_` are refit on all the data with `bspline` and +ready to call `.predict(X_new)`. + +```{note} +This is deliberately narrow: it only searches the single `numerical_method` axis with one +global method for every numerical column, not a per-column `feature_preprocessing` search. +Use it to answer "which single method suits this dataset" before committing to a +`Preprocessor` configuration, not as a general hyperparameter search. ``` See the [target-aware classification tutorial](../tutorials/target_aware_classification.md) From d19b9e7d7e76c2924c3730437a94b68418cb76b8 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 16:17:19 +0200 Subject: [PATCH 118/123] docs: refine missing value imputation --- docs/core_concepts/missing_values.md | 78 +++++++++------------------- 1 file changed, 24 insertions(+), 54 deletions(-) diff --git a/docs/core_concepts/missing_values.md b/docs/core_concepts/missing_values.md index 4810527..311c93a 100644 --- a/docs/core_concepts/missing_values.md +++ b/docs/core_concepts/missing_values.md @@ -1,23 +1,15 @@ # 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. +Missing data is handled explicitly, never silently: a small set of imputation parameters +covers the common case, and a single `missing_policy` gives finer control when you need it. ## 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`. +| Parameter | Meaning | Default | +| ------------------------ | ---------------------------------------------------------------------- | -------------------- | +| `numerical_imputation` | Strategy for numerical columns. `None` disables it. | `"median"` | +| `categorical_imputation` | Strategy for categorical columns. `None` disables it. | `"most_frequent"` | +| `add_missing_indicator` | Adds a binary indicator column marking where a value was missing. | `False` | ```python from pretab import Preprocessor @@ -30,31 +22,18 @@ pre = Preprocessor( ``` ```{note} -Setting an imputation strategy to `None` disables imputation for that column kind. The -missing values then reach the transformer directly: scikit-learn scalers, the splines, and -the feature maps (`rbf`, `relu`, `sigmoid`, `tanh`) tolerate `NaN` and pass it straight into -the basis, so an affected row's output is itself undefined. Genuinely finite-only -representations such as PLE, numeric binning, periodic encoding, Fourier features, and the -kernel approximations (`rff`, `nystroem`) raise a typed error instead. That is intentional, an -expansion of an undefined value has no meaning for those methods. -``` - -```{note} -Requesting `add_missing_indicator=True` while imputation is disabled for that column kind does -not raise. It routes through the same `__missing` indicator branch used by -`missing_policy="separate_state"` below, since `SimpleImputer`'s own indicator only takes -effect when the imputer runs. +Disabling imputation lets `NaN` reach the transformer directly. Scalers, splines, and the +feature maps (`rbf`, `relu`, `sigmoid`, `tanh`) tolerate it, so an affected row's output is +itself undefined; finite-only methods (PLE, numeric binning, periodic encoding, Fourier +features, `rff`, `nystroem`) raise a typed error instead, since expanding an undefined value +has no meaning for them. `add_missing_indicator=True` still works with imputation disabled: it +routes through the same `__missing` branch `missing_policy="separate_state"` uses below. ``` -## 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. +Imputers fit their fill values only on the data passed to `fit`, and reuse those values +unchanged at `transform`. PreTab never drops a row for missing values: every input row +produces an output row. ``` ## The `missing_policy` control @@ -76,27 +55,18 @@ 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. +`"separate_state"` is the most expressive option: it keeps the imputed value flowing into the +normal basis while emitting a parallel `__missing` indicator a model can weight on its own, so +it can 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. +Reach for `"separate_state"` (or `add_missing_indicator=True`) when missingness itself carries +signal. Reach for plain `"impute"` when a value is missing purely at random, `"error"` to catch +missingness as a data bug, and `"propagate"` (with imputation disabled) to handle it upstream +yourself. ``` -## 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. From ff5bf17004e644e6f27631b22e3cf06d616f2412 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 19:59:47 +0200 Subject: [PATCH 119/123] docs: improve and cleanup representation documents --- README.md | 33 +++++++ docs/core_concepts/configuration.md | 4 +- docs/core_concepts/missing_values.md | 10 +- docs/core_concepts/target_awareness.md | 14 +-- docs/index.rst | 2 - docs/representations/choosing_a_method.md | 63 +++++------- docs/representations/comparison_table.md | 25 ++--- docs/representations/functional_expansions.md | 2 +- docs/representations/kernel_approximation.md | 2 +- docs/representations/numerical_encoding.md | 2 +- docs/representations/overview.md | 95 +++++++++++++++---- .../preprocessing_utilities.md | 75 --------------- docs/representations/references.md | 65 ------------- docs/representations/spline_expansions.md | 2 +- docs/tutorials/multivariate_features.md | 2 +- 15 files changed, 158 insertions(+), 238 deletions(-) delete mode 100644 docs/representations/preprocessing_utilities.md delete mode 100644 docs/representations/references.md diff --git a/README.md b/README.md index c4a946e..b59d6f6 100644 --- a/README.md +++ b/README.md @@ -370,6 +370,39 @@ X_train_features = cf.fit_transform(x_train, y_train) # out-of-fold, leakage-f > from the training features themselves; inside a `Pipeline`, cross-validation already > keeps each fold's fit confined to its training data. +### Choosing a representation with cross-validation + +`RepresentationSearchCV` cross-validates a downstream estimator over a set of candidate +`numerical_method` values and refits the best one on all the data, keeping every candidate's +scoring honest by fitting a fresh `Preprocessor` per fold. + +```python +import numpy as np +import pandas as pd +from sklearn.linear_model import Ridge + +from pretab import RepresentationSearchCV + +rng = np.random.default_rng(0) +X = pd.DataFrame({"x": rng.uniform(-3, 3, size=300)}) +y = np.sin(X["x"]) + rng.normal(0, 0.1, size=300) + +search = RepresentationSearchCV( + estimator=Ridge(), + methods=["minmax", "ple", "bspline", "rbf"], + cv=5, + random_state=0, +) +search.fit(X, y) +search.best_method_ +# 'bspline' +``` + +> **Note:** This searches only the single `numerical_method` axis with one global method for +> every numerical column, not a per-column `feature_preprocessing` search. See +> [Target awareness](https://pretab.readthedocs.io/en/latest/core_concepts/target_awareness.html#searching-over-representations) +> for the full explanation. + ### Serialization and reproducibility A supported fitted preprocessor serializes to a versioned JSON spec and reports a stable diff --git a/docs/core_concepts/configuration.md b/docs/core_concepts/configuration.md index 0b55649..c5fa21d 100644 --- a/docs/core_concepts/configuration.md +++ b/docs/core_concepts/configuration.md @@ -133,8 +133,8 @@ standard_classification.get_resolved_config()["numerical_method"] # "ple" The regression/classification split follows Kumar et al. (2026), ["From Uniform to Learned Knots: A Study of Spline-Based Numerical Encodings for Tabular Deep Learning"](https://openreview.net/pdf?id=str7wQt9Qc), *Transactions on Machine Learning -Research*. See the [representation references](../representations/references.md) for the full -citation. +Research*. See the [representations overview](../representations/overview.md) for the full +citation list. ``` ```python diff --git a/docs/core_concepts/missing_values.md b/docs/core_concepts/missing_values.md index 311c93a..4729edb 100644 --- a/docs/core_concepts/missing_values.md +++ b/docs/core_concepts/missing_values.md @@ -5,11 +5,11 @@ covers the common case, and a single `missing_policy` gives finer control when y ## Imputation parameters -| Parameter | Meaning | Default | -| ------------------------ | ---------------------------------------------------------------------- | -------------------- | -| `numerical_imputation` | Strategy for numerical columns. `None` disables it. | `"median"` | -| `categorical_imputation` | Strategy for categorical columns. `None` disables it. | `"most_frequent"` | -| `add_missing_indicator` | Adds a binary indicator column marking where a value was missing. | `False` | +| Parameter | Meaning | Default | +| ------------------------ | ----------------------------------------------------------------- | ----------------- | +| `numerical_imputation` | Strategy for numerical columns. `None` disables it. | `"median"` | +| `categorical_imputation` | Strategy for categorical columns. `None` disables it. | `"most_frequent"` | +| `add_missing_indicator` | Adds a binary indicator column marking where a value was missing. | `False` | ```python from pretab import Preprocessor diff --git a/docs/core_concepts/target_awareness.md b/docs/core_concepts/target_awareness.md index 2c43e6e..d3eccf2 100644 --- a/docs/core_concepts/target_awareness.md +++ b/docs/core_concepts/target_awareness.md @@ -9,11 +9,11 @@ explicit and gives you leakage-safe tools. This page explains the contract. Every method declares how it uses `y` through three levels. -| Level | Meaning | Numerical methods | Categorical methods | -| ------------ | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | -------------------- | -| `forbidden` | Never uses the target | The scalers (`minmax`, `standardization`, `robust`, `quantile`, `box-cox`, `yeo-johnson`, `polynomial`), `custombin`, `fourier`, `pspline` | `int`, `one-hot`, `onehot_from_ordinal`, `pretrained` | -| `optional` | Uses the target only when `target_aware=True` | The feature maps (`rbf`, `relu`, `sigmoid`, `tanh`) and the freely-placed knot splines (`bspline`, `mspline`, `ispline`, `cubicspline`, `naturalspline`) | - | -| `required` | Always places against the target | `ple` (piecewise-linear encoding) | - | +| Level | Meaning | Numerical methods | Categorical methods | +| ----------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | +| `forbidden` | Never uses the target | The scalers (`minmax`, `standardization`, `robust`, `quantile`, `box-cox`, `yeo-johnson`, `polynomial`), `custombin`, `fourier`, `pspline` | `int`, `one-hot`, `onehot_from_ordinal`, `pretrained` | +| `optional` | Uses the target only when `target_aware=True` | The feature maps (`rbf`, `relu`, `sigmoid`, `tanh`) and the freely-placed knot splines (`bspline`, `mspline`, `ispline`, `cubicspline`, `naturalspline`) | - | +| `required` | Always places against the target | `ple` (piecewise-linear encoding) | - | ```python from pretab.transformers import PLETransformer @@ -64,10 +64,10 @@ The safe patterns are: ## Cross-fitted features Even inside a `Pipeline`, fitting a supervised transformer once on the full training set and -then using it to build the *training* features for the same rows introduces a subtle leak: +then using it to build the _training_ features for the same rows introduces a subtle leak: each row's PLE bins were placed using its own target value. `CrossFittedTransformer` fixes this for the training features specifically. It splits the training data into folds, fits a -fresh copy of the transformer on all folds *except* one, and uses that copy to transform the +fresh copy of the transformer on all folds _except_ one, and uses that copy to transform the held-out fold, so every training row is transformed by a model that never saw its own target. `transform` on genuinely new data (a validation or test set) instead uses one model fit on all the training data, since there is no leakage risk there. diff --git a/docs/index.rst b/docs/index.rst index 994bdfb..fad23f4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -39,8 +39,6 @@ representations/numerical_encoding representations/categorical_encoding representations/embeddings - representations/preprocessing_utilities - representations/references .. toctree:: :caption: Tutorials diff --git a/docs/representations/choosing_a_method.md b/docs/representations/choosing_a_method.md index 236996c..2e457d5 100644 --- a/docs/representations/choosing_a_method.md +++ b/docs/representations/choosing_a_method.md @@ -7,18 +7,11 @@ representations do not help. If you read only one page in this section, read thi 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. +| Downstream model | What works best | Why | +| -------------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Linear and additive models | Spline or PLE expansion | A linear model on top of a smooth basis can fit nonlinearities while staying interpretable. This is the primary use case for PreTab. | +| Gradient-boosted trees | Raw or lightly scaled inputs | Trees already partition each feature, so expansion usually adds noise rather than signal. See [when it does not help](#when-basis-expansion-does-not-help). | +| Neural networks | PLE or learned embeddings | These are effective front-ends in tabular deep learning, and shallow networks can also benefit from spline features. | ## Match the method to the signal @@ -61,33 +54,25 @@ validation improves. Turn on `adaptive=True` to let the data choose a width betw 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, and each spline family has its own default -transform-time behavior for values beyond it: B/M/I-spline, P-spline, and tensor-product -clip to the fitted range, while natural-cubic and cubic-regression extrapolate smoothly -(their basis is defined for any input). If your test data lies well beyond training, an -extrapolated value carries no real signal. Pass -`policy=RepresentationPolicy(out_of_range="clip")` (or `"warn"` / `"error"`) directly to any -of these spline transformers to override their default for that instance. This is -standalone-transformer-only: it is not yet threaded through `Preprocessor`. 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. +- **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, and each + spline family has its own default transform-time behavior for values beyond it: B/M/I-spline, + P-spline, and tensor-product clip to the fitted range, while natural-cubic and + cubic-regression extrapolate smoothly because their basis is defined for any input. If your + test data lies well beyond training, an extrapolated value carries no real signal. Pass + `policy=RepresentationPolicy(out_of_range="clip")` (or `"warn"` / `"error"`) directly to any + of these spline transformers to override their default for that instance. This is + standalone-transformer-only: it is not yet threaded through `Preprocessor`. 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 diff --git a/docs/representations/comparison_table.md b/docs/representations/comparison_table.md index d8bf78d..8d81a3c 100644 --- a/docs/representations/comparison_table.md +++ b/docs/representations/comparison_table.md @@ -6,23 +6,14 @@ 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. +| Column | Meaning | +| ------------ | --------------------------------------------------------------------------------------- | +| `Key` | The string passed to `numerical_method`, `categorical_method`, or a per-feature config. | +| `Scope` | `univariate` for one column or `multivariate` for several columns modeled 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 diff --git a/docs/representations/functional_expansions.md b/docs/representations/functional_expansions.md index 595cc3b..35a5586 100644 --- a/docs/representations/functional_expansions.md +++ b/docs/representations/functional_expansions.md @@ -128,4 +128,4 @@ year), the direct [periodic encoder](numerical_encoding.md#periodic-encoding) is - [Spline expansions](spline_expansions.md) for smooth statistical bases. - [Kernel approximation](kernel_approximation.md) for the multivariate RFF and Nyström maps. - [Numerical encoding](numerical_encoding.md) for binning, PLE, and periodic encoding. -- [References](references.md) for the underlying literature. +- [Representations overview](overview.md) for the literature and supporting utilities. diff --git a/docs/representations/kernel_approximation.md b/docs/representations/kernel_approximation.md index 2dbfcc0..bf155aa 100644 --- a/docs/representations/kernel_approximation.md +++ b/docs/representations/kernel_approximation.md @@ -106,4 +106,4 @@ with per-column methods through a `ColumnTransformer`. - [Functional expansions](functional_expansions.md) for the per-column basis functions. - [Spline expansions](spline_expansions.md) for the multivariate tensor-product and thin-plate splines, another way to model several inputs jointly. -- [References](references.md) for the kernel-approximation literature. +- [Representations overview](overview.md) for the supporting notes and literature. diff --git a/docs/representations/numerical_encoding.md b/docs/representations/numerical_encoding.md index 0ac4b41..0799fcd 100644 --- a/docs/representations/numerical_encoding.md +++ b/docs/representations/numerical_encoding.md @@ -160,4 +160,4 @@ the model to search across a set of frequencies rather than commit to one known - [Spline expansions](spline_expansions.md) for smooth alternatives to binning. - [Functional expansions](functional_expansions.md) for Fourier features, the deterministic alternative to periodic encoding. -- [References](references.md) for the PLE source. +- [Representations overview](overview.md) for the literature and supporting notes. diff --git a/docs/representations/overview.md b/docs/representations/overview.md index c2399c0..13474b2 100644 --- a/docs/representations/overview.md +++ b/docs/representations/overview.md @@ -57,21 +57,12 @@ Pretrained language embeddings for high-cardinality text categories. 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`. +| Term | Meaning | +| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `scope` | `univariate` methods transform one column at a time. `multivariate` methods such as tensor-product spline, thin-plate spline, random Fourier features, and Nyström model several columns jointly and are used standalone, not per column through `Preprocessor`. | +| `supervision` | Whether the method uses the target: `forbidden`, `optional`, or `required`. 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 are placed, chosen via `target_aware` and `placement_strategy`. | ## How to select a method @@ -90,17 +81,79 @@ from pretab import list_representations list_representations(feature_kind="numerical", supervised=True) ``` -## A note on scientific grounding +## Supporting utilities + +Some transformers in this section do not expand or recode a feature. Instead, they prepare +it for the rest of the pipeline. + +- **Pass-through and type conversion**: `NoTransformer` leaves a column unchanged, and + `ToFloatTransformer` converts it to floating point while keeping the same width. These are + the minimal support operations that let a pipeline keep a column untouched or normalize its + dtype without changing its structure. +- **Missing-value flagging**: `MissingStateIndicator` emits a binary mask marking where the + input was missing, computed before imputation. `Preprocessor` uses this when + `missing_policy="separate_state"` so a downstream model can learn a dedicated response to + missingness instead of confusing it with an imputed value. + +These utilities are part of the public API for custom pipelines and column-wise preprocessing, +but most users never instantiate them directly because `Preprocessor` wires them in +automatically. + +## References + +The representations in PreTab rest on established literature. The primary sources behind the +families are grouped below so each method is traceable to its origin. + +### 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 papers introduce the P-spline and its tensor-product extension, which underpin +`PSplineTransformer` and `TensorProductSplineTransformer`. + +Kumar, M., Thielmann, A. F., Weisser, C., and Säfken, B. (2026). From uniform to learned +knots: A study of spline-based numerical encodings for tabular deep learning. _(TMLR)_. + +This study motivates the task-dependent preset defaults used by `Preprocessor`: +`"bspline"` for regression and `"ple"` for classification. + +### 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. + +### 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. + +### 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. -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. +This paper motivates the piecewise-linear encoding used by `PLETransformer`. ## Where to go next - [Spline expansions](spline_expansions.md), [Functional expansions](functional_expansions.md), [Kernel approximation](kernel_approximation.md), [Numerical encoding](numerical_encoding.md), [Categorical encoding](categorical_encoding.md), [Embeddings](embeddings.md), and - [Preprocessing utilities](preprocessing_utilities.md) for the families. -- [Comparison table](comparison_table.md) to filter by capability. + [Comparison table](comparison_table.md) for the families and capability filters. - [Choosing a method](choosing_a_method.md) for guidance and failure modes. +- [Configuration](../core_concepts/configuration.md) for the preset and pipeline behavior that + wires these representations together. diff --git a/docs/representations/preprocessing_utilities.md b/docs/representations/preprocessing_utilities.md deleted file mode 100644 index affa43f..0000000 --- a/docs/representations/preprocessing_utilities.md +++ /dev/null @@ -1,75 +0,0 @@ -# Preprocessing utilities - -Preprocessing utilities don't expand or recode a feature. They prepare it for the rest of the -pipeline, converting types or flagging missingness before it reaches the transformer that does -the actual representation work. `Preprocessor` wires these in automatically; most users never -instantiate them directly, but they are part of the public API for anyone building a custom -`ColumnTransformer` or pipeline by hand. - -```{note} -This page is distinct from [`pretab.preprocessor`](../api/preprocessor.rst), the module that -holds the top-level `Preprocessor` facade. `pretab.preprocessing` is the package for these -smaller supporting transformers. -``` - -## Pass-through and type conversion - -`NoTransformer` returns its input unchanged. It backs the `"none"` categorical and numerical -methods, letting a column skip representation entirely while still satisfying the -scikit-learn transformer API. - -```python -import numpy as np -from pretab.transformers import NoTransformer - -X = np.zeros((5, 3)) # (5, 3) -t = NoTransformer() -t.fit_transform(X).shape -# (5, 3): identical to the input, values and width both unchanged -``` - -`ToFloatTransformer` casts its input to floating point. `Preprocessor` appends it after -one-hot encoding so the categorical block has the same dtype as the rest of the design matrix. - -```python -import numpy as np -from pretab.transformers import ToFloatTransformer - -X = np.array([[1], [2], [3]]) # (3, 1), integer dtype -t = ToFloatTransformer() -out = t.fit_transform(X) -out.shape, out.dtype -# ((3, 1), dtype('float64')): width unchanged, only the dtype changes -``` - -```{note} -Neither utility has an `output_dim`-style width parameter: unlike every expansion or encoding -family elsewhere in this section, the output always has the exact same number of columns as -the input. -``` - -## Missing-value flagging - -`MissingStateIndicator` emits a binary column marking where the input was missing, computed on -the raw data before imputation. `Preprocessor` uses it when `missing_policy="separate_state"`: -the indicator is kept apart from the imputed representation basis, so a downstream model can -learn a dedicated response to missingness instead of confusing it with an imputed value. - -```python -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.]]) -``` - -```{tip} -Unlike `sklearn.impute.MissingIndicator`, `MissingStateIndicator` works on both numeric and -object (categorical) columns and always emits one column per input feature. -``` - -## Where to go next - -- [Missing values](../core_concepts/missing_values.md) for the full `missing_policy` behavior. -- [Configuration](../core_concepts/configuration.md) for how `Preprocessor` builds its pipelines. diff --git a/docs/representations/references.md b/docs/representations/references.md deleted file mode 100644 index fd1b509..0000000 --- a/docs/representations/references.md +++ /dev/null @@ -1,65 +0,0 @@ -# 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`. - -Kumar, M., Thielmann, A. F., Weisser, C., and Säfken, B. (2026). From uniform to learned -knots: A study of spline-based numerical encodings for tabular deep learning. _(TMLR)_. - -This study compares uniform and learned knot placement for spline-based numerical encodings -against piecewise-linear encoding across regression and classification tasks, and motivates -the `Preprocessor`'s task-dependent preset default: `"bspline"` for regression, `"ple"` for -classification. See [Presets](../core_concepts/configuration.md#presets). - -## 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. -- [Spline expansions](spline_expansions.md), [Kernel approximation](kernel_approximation.md), - [Numerical encoding](numerical_encoding.md) for the methods these sources describe. diff --git a/docs/representations/spline_expansions.md b/docs/representations/spline_expansions.md index af06d01..d6b28d7 100644 --- a/docs/representations/spline_expansions.md +++ b/docs/representations/spline_expansions.md @@ -289,4 +289,4 @@ want to model jointly. - [Functional expansions](functional_expansions.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. +- [Representations overview](overview.md) for the primary spline literature and supporting notes. diff --git a/docs/tutorials/multivariate_features.md b/docs/tutorials/multivariate_features.md index 9d61b9e..4fba8ad 100644 --- a/docs/tutorials/multivariate_features.md +++ b/docs/tutorials/multivariate_features.md @@ -117,4 +117,4 @@ The thin-plate spline handles the geographic interaction while PLE handles the s thin-plate details. - [Kernel approximation](../representations/kernel_approximation.md) for random Fourier features and Nyström. -- [References](../representations/references.md) for the underlying theory. +- [Representations overview](../representations/overview.md) for the underlying theory and supporting notes. From 56a69cc71b6c13948ae94aa2b5441ca96058ba8b Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 20:04:44 +0200 Subject: [PATCH 120/123] docs: tutorials updated --- docs/tutorials/comparing_representations.md | 2 +- docs/tutorials/nonlinear_regression.md | 8 ++++---- docs/tutorials/sklearn_pipeline.md | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/tutorials/comparing_representations.md b/docs/tutorials/comparing_representations.md index ec29790..4d4d10b 100644 --- a/docs/tutorials/comparing_representations.md +++ b/docs/tutorials/comparing_representations.md @@ -59,7 +59,7 @@ for name, (mean, std) in results.items(): minmax (baseline) R2 = 0.111 +/- 0.030 bspline R2 = 0.966 +/- 0.002 rbf R2 = 0.965 +/- 0.003 -ple R2 = 0.950 +/- 0.002 +ple R2 = 0.955 +/- 0.005 ``` The scaled baseline fits a straight line and cannot follow the sine. Every expansion captures diff --git a/docs/tutorials/nonlinear_regression.md b/docs/tutorials/nonlinear_regression.md index cd2b323..2c47964 100644 --- a/docs/tutorials/nonlinear_regression.md +++ b/docs/tutorials/nonlinear_regression.md @@ -121,13 +121,13 @@ print(f"MAE: {mean_absolute_error(y_test, pred):.2f}") ```text features: 40 -R2: 0.979 -MAE: 1.85 +R2: 0.983 +MAE: 1.78 ``` 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.979` and the mean absolute error drops -from `11.20` to `1.85`. +nonlinear structure. The $R^2$ jumps from `0.124` to `0.983` and the mean absolute error drops +from `11.20` to `1.78`. ```{tip} `Preprocessor.transform` returns a single stacked array by default, so it drops straight into diff --git a/docs/tutorials/sklearn_pipeline.md b/docs/tutorials/sklearn_pipeline.md index 98fa857..0f5f343 100644 --- a/docs/tutorials/sklearn_pipeline.md +++ b/docs/tutorials/sklearn_pipeline.md @@ -77,7 +77,7 @@ print(f"5-fold R2: {scores.mean():.3f} +/- {scores.std():.3f}") ``` ```text -5-fold R2: 0.942 +/- 0.005 +5-fold R2: 0.948 +/- 0.005 ``` ## Tune with GridSearchCV @@ -103,7 +103,7 @@ print(f"best CV R2: {grid.best_score_:.3f}") ```text best params: {'features__age__output_dim': 6, 'ridge__alpha': 0.1} -best CV R2: 0.943 +best CV R2: 0.949 ``` Every pretab transformer participates in the search grid just like a native `sklearn` step. From f226f053d3b0571de3c5afa2bf87ea6a25e8dbcf Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 20:08:44 +0200 Subject: [PATCH 121/123] docs: refine quickstart guides --- docs/getting_started/choosing_an_interface.md | 109 ------------------ docs/getting_started/overview.md | 59 +++++++++- docs/getting_started/quickstart.md | 2 +- docs/index.rst | 1 - 4 files changed, 58 insertions(+), 113 deletions(-) delete mode 100644 docs/getting_started/choosing_an_interface.md diff --git a/docs/getting_started/choosing_an_interface.md b/docs/getting_started/choosing_an_interface.md deleted file mode 100644 index b060268..0000000 --- a/docs/getting_started/choosing_an_interface.md +++ /dev/null @@ -1,109 +0,0 @@ -# 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` or `numpy.ndarray`, detects numerical and categorical columns, and -applies a strategy per column from a single configuration object. Returns a single stacked -array 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) # one stacked array, or output_structure="blocks" for a dict -``` - -## 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 single stacked array by default, so it drops directly into a -plain `Pipeline`/`ColumnTransformer` like any other scikit-learn transformer. Pass -`output_structure="blocks"` (or `return_array=False` for a single call) when you want the -dict-of-feature-blocks form instead, for inspection or per-block downstream heads. -``` - -A `Preprocessor` composes the same way as the standalone transformers above: - -```python -from sklearn.pipeline import Pipeline -from sklearn.linear_model import Ridge - -from pretab import Preprocessor - -model = Pipeline([ - ("pretab", Preprocessor(feature_preprocessing={"age": "naturalspline", "income": "ple"})), - ("ridge", Ridge()), -]) -model.fit(df, y) -model.predict(df) -``` - -## 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/overview.md b/docs/getting_started/overview.md index 7c52b2e..ef35a8b 100644 --- a/docs/getting_started/overview.md +++ b/docs/getting_started/overview.md @@ -34,7 +34,8 @@ X = pre.fit_transform(df, y) ## Two ways to use it -PreTab exposes the same capabilities through two surfaces. +PreTab exposes the same capabilities through two surfaces. They share the same underlying +code, so the choice is about ergonomics, not capability. ::::{grid} 1 1 2 2 :gutter: 3 @@ -52,7 +53,61 @@ Every strategy is also a plain scikit-learn transformer you can import and compo :::: -The [Choosing an interface](choosing_an_interface.md) page explains which to pick. +**Reach for the `Preprocessor` when** you start from a `DataFrame` and want automatic +feature-type detection, want to configure many columns from one place (global +`numerical_method` / `categorical_method` defaults or a per-column `feature_preprocessing` +map), or 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) # one stacked array, or output_structure="blocks" for a dict +``` + +**Reach for standalone transformers** when you want a single estimator object that composes +cleanly inside one `Pipeline`, rely on scikit-learn model selection (`cross_val_score`, +`GridSearchCV`, `step__param` hyperparameter addressing), or 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 single stacked array by default, so it drops directly into a +plain `Pipeline`/`ColumnTransformer` like any other scikit-learn transformer, and composes the +same way as the standalone transformers above. Pass `output_structure="blocks"` (or +`return_array=False` for a single call) when you want the dict-of-feature-blocks form +instead, for inspection or per-block downstream heads. +``` + +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. + +```{note} +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=...)`. See +[Multivariate features](../tutorials/multivariate_features.md). +``` ## How this compares to scikit-learn's preprocessing transformers diff --git a/docs/getting_started/quickstart.md b/docs/getting_started/quickstart.md index da32fd3..010660a 100644 --- a/docs/getting_started/quickstart.md +++ b/docs/getting_started/quickstart.md @@ -177,6 +177,6 @@ positive semi-definite. Calling it emits a `ConfigWarning` to make this explicit - 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). +- Decide between the two surfaces in [Overview](overview.md#two-ways-to-use-it). - Browse every method in [Representations](../representations/overview.md). - Learn the shared ideas in [Core concepts](../core_concepts/feature_representation.md). diff --git a/docs/index.rst b/docs/index.rst index fad23f4..ac92363 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -9,7 +9,6 @@ getting_started/overview getting_started/installation getting_started/quickstart - getting_started/choosing_an_interface getting_started/migration_to_1_0 .. toctree:: From d81b6ef6d26188bc5454de9670bc928add0a2132 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 20:15:52 +0200 Subject: [PATCH 122/123] docs: add presets info --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index b59d6f6..99f60ef 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,21 @@ penalty = spline.get_penalty_matrix() # (output_dim, output_dim) smoothing pen ## Advanced Features +### Presets + +`preset` sets `numerical_method`, `categorical_method`, and the output width in one call, +so you can start from a sensible default instead of choosing every parameter by hand. + +```python +Preprocessor(preset="standard") # bspline (regression) / ple (classification), int codes, output_dim=7 +Preprocessor(preset="expanded") # same numerical method, one-hot codes, output_dim=10 +Preprocessor(preset="adaptive") # same numerical method, int codes, adaptive width in [7, 15] +``` + +> **Note:** Every preset resolves `numerical_method` from `task`: `"bspline"` for regression, +> `"ple"` for classification. Any parameter you also pass explicitly overrides the preset's +> value for that parameter. + ### Automatic feature-type detection By default PreTab inspects each column and classifies it as numerical or categorical. From 5d56d446885439b0dbccb32e7a911f52f22862dd Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Sun, 6 Sep 2026 20:32:27 +0200 Subject: [PATCH 123/123] docs: developer guide restructure and cleanup --- docs/developer_guide/contributing.md | 325 +++++++++++++++++++++++++- docs/developer_guide/documentation.md | 110 --------- docs/developer_guide/release.md | 2 +- docs/developer_guide/testing.md | 133 ----------- docs/developer_guide/versioning.md | 94 -------- docs/index.rst | 3 - 6 files changed, 318 insertions(+), 349 deletions(-) delete mode 100644 docs/developer_guide/documentation.md delete mode 100644 docs/developer_guide/testing.md delete mode 100644 docs/developer_guide/versioning.md diff --git a/docs/developer_guide/contributing.md b/docs/developer_guide/contributing.md index 812c901..5054dfd 100644 --- a/docs/developer_guide/contributing.md +++ b/docs/developer_guide/contributing.md @@ -87,25 +87,334 @@ Individual recipes are available when you want to run one step: | `just docs` | Build the HTML documentation. | | `just check` | Run all hooks across all files (commit + push). | +## Testing + +PreTab has a comprehensive test suite that gates every change. + +### Running the tests + +The suite runs with coverage through a single recipe. + +```bash +just test # poetry run pytest --cov=pretab --cov-branch --cov-fail-under=90 tests/ +``` + +To run a subset while developing, invoke pytest directly. + +```bash +poetry run pytest tests/expansion/ # one area +poetry run pytest tests/expansion/spline/test_spline_expansions.py -k bspline # 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/expansion/`, `tests/encoding/`, `tests/kernel_approximation/`, `tests/embedding/` | Every representation family, split by kind (splines and functional expansions, numerical/categorical encoders, kernel approximations, language embeddings). | +| `tests/transformers/` | Cross-family contracts: sklearn compatibility, feature names, output dimensions, parameter aliases, encoder counts. | +| `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. | +| `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 +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. +``` + +### Testing mathematical correctness, not just shape + +A representation-heavy library like PreTab has a failure mode that shape and dtype checks +cannot catch: a basis function, penalty matrix, or encoding can be computed with the wrong +formula and still produce output of the right shape, the right dtype, and finite values. A +test that only asserts `X.shape == (n, k)` or `np.isfinite(out).all()` will pass on both the +correct and the incorrect implementation. + +When a representation has a closed-form mathematical property, test that property directly +instead of (or in addition to) its shape: + +- **Known identities.** A B-spline basis should sum to `1` at every point (partition of + unity); an M-spline should integrate to `1` over its own support; an I-spline should be + monotonically non-decreasing and bounded in `[0, 1]`. +- **Independent reference values.** A penalty matrix or a hand-derivable formula (a + particular basis value at a particular knot, say) can be checked against a value computed + a different way, for example a fine numerical quadrature or a direct closed-form + substitution, not just re-derived with the same code path the implementation itself uses. +- **Boundary behaviour.** Values at, or just past, a fitted range's edge are where + clipping-versus-extrapolation bugs and off-by-one integration bounds hide. Test a value + exactly at the boundary and one just beyond it, not only values safely inside the range. +- **Realistic missing-data shapes.** A mixed object array with an actual `NaN`/`None` among + string categories (the ordinary shape of a pandas column with missing values) is a + different code path than an all-numeric array with `NaN`, and needs its own test if a + transformer declares `allow_nan=True`. + +```{warning} +Shape/symmetry/finiteness assertions are still useful as a first line of defense, but they +are not sufficient proof that a mathematical implementation is correct: they pass equally +well on a subtly wrong formula as on a correct one. Pair them with at least one value-level +assertion for anything that has a defined mathematical property to check against. +``` + +### 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 `just check` and `just test` locally; together they cover most of what CI checks, though +CI additionally runs across the full Python 3.10-3.13 matrix, builds the package, and +enforces a branch-coverage threshold. + +```bash +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 +``` + ## Documentation -The docs are built with [Sphinx](https://www.sphinx-doc.org/) and hosted on -[Read the Docs](https://about.readthedocs.com/). Build them locally with: +The documentation is part of the codebase and is held to the same standard as the code. It is +built with [Sphinx](https://www.sphinx-doc.org/) and hosted on +[Read the Docs](https://about.readthedocs.com/). + +### Building the docs ```bash just docs # build HTML into docs/_build/html open docs/_build/html/index.html # macOS; use xdg-open on Linux ``` -Public classes are documented from their numpy-style docstrings via `autodoc`, so keeping -docstrings accurate keeps the [API Reference](../api/index.rst) up to date. +```{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. -## Release workflow +```bash +poetry install --with docs +``` + +### Structure + +The `docs/` tree is organized by reader intent. + +| Section | Purpose | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------ | +| `getting_started/` | Install, first model, 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 (setup, testing, documentation, versioning) and the release process. | + +### 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. + +## Versioning + +pretab follows [Semantic Versioning 2.0](https://semver.org/) and uses +[Conventional Commits](https://www.conventionalcommits.org/) to automate version bumps and +changelog generation via [commitizen](https://commitizen-tools.github.io/commitizen/). + +From `1.0.0` onward, `feat!:` and `BREAKING CHANGE:` commits bump the major version, following +standard SemVer. + +### Version format + +``` +MAJOR.MINOR.PATCH +``` + +| Segment | When it increments | +| ------- | -------------------------------------------------------------------------- | +| `MAJOR` | Breaking change (`feat!:` or `BREAKING CHANGE:` footer) | +| `MINOR` | New backwards-compatible feature (`feat:`) | +| `PATCH` | Backwards-compatible bug fix (`fix:`) or performance improvement (`perf:`) | -For the end-to-end release procedure (version bump, tags, PyPI publishing) see: +Release candidates use the suffix `rcN`, e.g. `1.0.0rc1`. + +The version is defined **in one place only**, `pyproject.toml`, and read at runtime via +`importlib.metadata` in `pretab/_version.py`, so it never needs to be hard-coded in the +package. + +```{note} +`major_version_zero` is `false` in the commitizen config, so `feat!:` / `BREAKING CHANGE:` +commits bump the **major** version, in line with standard SemVer. +``` + +### Commit types and their effect + +| Commit type | Example | Version bump | +| ----------- | ------------------------------------------ | ------------ | +| `feat` | `feat(splines): add B-spline knots option` | Minor | +| `fix` | `fix(binning): handle empty bins` | Patch | +| `perf` | `perf(ple): vectorise bin assignment` | Patch | +| `feat!` | `feat!: drop Python 3.9 support` | Major | +| `docs` | `docs: update API reference` | None | +| `test` | `test: add spline round-trip test` | None | +| `ci` | `ci: add Python 3.13 to matrix` | None | +| `refactor` | `refactor: simplify feature detection` | None | +| `style` | `style: apply ruff formatting` | None | +| `chore` | `chore: update pre-commit revisions` | None | + +Commit messages that do not match any of these types do not trigger a version bump. See +[CONVENTIONAL_COMMITS.md](https://github.com/OpenTabular/PreTab/blob/main/CONVENTIONAL_COMMITS.md) +for the full list of pretab scopes. + +### Making a conventional commit + +Use commitizen's interactive prompt rather than writing the message by hand: + +```bash +just commit # opens the cz commit wizard +``` + +Or write the message directly: + +```bash +git commit -m "feat(feature-maps): add Gaussian RBF centers" +git commit -m "fix(preprocessor): validate output_dim > 0" +``` + +The `commit-msg` pre-commit hook validates every commit message against the conventional +commits format and rejects non-conforming messages. + +### Bumping the version + +Version bumps are driven by commitizen, wrapped in `just` recipes. Preview first with the +`-preview` (dry-run) variant, then apply. Each apply recipe updates `version` in +`pyproject.toml`, appends to `CHANGELOG.md`, and creates the bump commit and tag. + +| Goal | Preview | Apply | +| ----------------- | ---------------------- | -------------- | +| Stable release | `just bump-preview` | `just bump` | +| Release candidate | `just bump-rc-preview` | `just bump-rc` | + +The next version is inferred from the conventional commits since the last tag. To force a +level when it is not auto-detected, append the increment, e.g. `just bump --increment MINOR`. + +### Changelog + +`CHANGELOG.md` at the repository root is the authoritative changelog, updated automatically +by the bump recipes. Changes are grouped under their commit types (`feat`, `fix`, +`perf`, ...) with the subject line of every matching commit since the previous release. + +### Tags + +Release tags follow `vMAJOR.MINOR.PATCH` (or `vMAJOR.MINOR.PATCHrcN` for RCs) and trigger +the PyPI publish workflows. See [Release process](release.md) for the full end-to-end +procedure. + +## Release workflow -- **[Release process](release.md)**: step-by-step instructions. -- **[Versioning](versioning.md)**: SemVer rules, commit types, and `cz bump`. +For the end-to-end release procedure (version bump, tags, PyPI publishing), see +**[Release process](release.md)**. ## Issue tracker diff --git a/docs/developer_guide/documentation.md b/docs/developer_guide/documentation.md deleted file mode 100644 index 90367c3..0000000 --- a/docs/developer_guide/documentation.md +++ /dev/null @@ -1,110 +0,0 @@ -# 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/release.md b/docs/developer_guide/release.md index 4ca86fc..30cbe1a 100644 --- a/docs/developer_guide/release.md +++ b/docs/developer_guide/release.md @@ -8,7 +8,7 @@ publishing itself runs on GitHub Actions using tokens are stored anywhere. For the SemVer rules and commit conventions that decide the next version, see -[Versioning](versioning.md). +[Versioning](contributing.md#versioning). ## Overview diff --git a/docs/developer_guide/testing.md b/docs/developer_guide/testing.md deleted file mode 100644 index eef3271..0000000 --- a/docs/developer_guide/testing.md +++ /dev/null @@ -1,133 +0,0 @@ -# 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 --cov-branch --cov-fail-under=90 tests/ -``` - -To run a subset while developing, invoke pytest directly. - -```bash -poetry run pytest tests/expansion/ # one area -poetry run pytest tests/expansion/spline/test_spline_expansions.py -k bspline # 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/expansion/`, `tests/encoding/`, `tests/kernel_approximation/`, `tests/embedding/` | Every representation family, split by kind (splines and functional expansions, numerical/categorical encoders, kernel approximations, language embeddings). | -| `tests/transformers/` | Cross-family contracts: sklearn compatibility, feature names, output dimensions, parameter aliases, encoder counts. | -| `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. | -| `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 -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. -``` - -## Testing mathematical correctness, not just shape - -A representation-heavy library like PreTab has a failure mode that shape and dtype checks -cannot catch: a basis function, penalty matrix, or encoding can be computed with the wrong -formula and still produce output of the right shape, the right dtype, and finite values. A -test that only asserts `X.shape == (n, k)` or `np.isfinite(out).all()` will pass on both the -correct and the incorrect implementation. - -When a representation has a closed-form mathematical property, test that property directly -instead of (or in addition to) its shape: - -- **Known identities.** A B-spline basis should sum to `1` at every point (partition of - unity); an M-spline should integrate to `1` over its own support; an I-spline should be - monotonically non-decreasing and bounded in `[0, 1]`. -- **Independent reference values.** A penalty matrix or a hand-derivable formula (a - particular basis value at a particular knot, say) can be checked against a value computed - a different way, for example a fine numerical quadrature or a direct closed-form - substitution, not just re-derived with the same code path the implementation itself uses. -- **Boundary behaviour.** Values at, or just past, a fitted range's edge are where - clipping-versus-extrapolation bugs and off-by-one integration bounds hide. Test a value - exactly at the boundary and one just beyond it, not only values safely inside the range. -- **Realistic missing-data shapes.** A mixed object array with an actual `NaN`/`None` among - string categories (the ordinary shape of a pandas column with missing values) is a - different code path than an all-numeric array with `NaN`, and needs its own test if a - transformer declares `allow_nan=True`. - -```{warning} -Shape/symmetry/finiteness assertions are still useful as a first line of defense, but they -are not sufficient proof that a mathematical implementation is correct: they pass equally -well on a subtly wrong formula as on a correct one. Pair them with at least one value-level -assertion for anything that has a defined mathematical property to check against. -``` - -## 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 `just check` and `just test` locally; together they cover most of what CI checks, though -CI additionally runs across the full Python 3.10-3.13 matrix, builds the package, and -enforces a branch-coverage threshold. - -```bash -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 - -- [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. diff --git a/docs/developer_guide/versioning.md b/docs/developer_guide/versioning.md deleted file mode 100644 index c0a0a69..0000000 --- a/docs/developer_guide/versioning.md +++ /dev/null @@ -1,94 +0,0 @@ -# Versioning - -pretab follows [Semantic Versioning 2.0](https://semver.org/) and uses -[Conventional Commits](https://www.conventionalcommits.org/) to automate version bumps and -changelog generation via [commitizen](https://commitizen-tools.github.io/commitizen/). - -From `1.0.0` onward, `feat!:` and `BREAKING CHANGE:` commits bump the major version, following -standard SemVer. - -## Version format - -``` -MAJOR.MINOR.PATCH -``` - -| Segment | When it increments | -| ------- | -------------------------------------------------------------------------- | -| `MAJOR` | Breaking change (`feat!:` or `BREAKING CHANGE:` footer) | -| `MINOR` | New backwards-compatible feature (`feat:`) | -| `PATCH` | Backwards-compatible bug fix (`fix:`) or performance improvement (`perf:`) | - -Release candidates use the suffix `rcN`, e.g. `1.0.0rc1`. - -The version is defined **in one place only**, `pyproject.toml`, and read at runtime via -`importlib.metadata` in `pretab/_version.py`, so it never needs to be hard-coded in the -package. - -```{note} -`major_version_zero` is `false` in the commitizen config, so `feat!:` / `BREAKING CHANGE:` -commits bump the **major** version, in line with standard SemVer. -``` - -## Commit types and their effect - -| Commit type | Example | Version bump | -| ----------- | ------------------------------------------ | ------------ | -| `feat` | `feat(splines): add B-spline knots option` | Minor | -| `fix` | `fix(binning): handle empty bins` | Patch | -| `perf` | `perf(ple): vectorise bin assignment` | Patch | -| `feat!` | `feat!: drop Python 3.9 support` | Major | -| `docs` | `docs: update API reference` | None | -| `test` | `test: add spline round-trip test` | None | -| `ci` | `ci: add Python 3.13 to matrix` | None | -| `refactor` | `refactor: simplify feature detection` | None | -| `style` | `style: apply ruff formatting` | None | -| `chore` | `chore: update pre-commit revisions` | None | - -Commit messages that do not match any of these types do not trigger a version bump. See -[CONVENTIONAL_COMMITS.md](https://github.com/OpenTabular/PreTab/blob/main/CONVENTIONAL_COMMITS.md) -for the full list of pretab scopes. - -## Making a conventional commit - -Use commitizen's interactive prompt rather than writing the message by hand: - -```bash -just commit # opens the cz commit wizard -``` - -Or write the message directly: - -```bash -git commit -m "feat(feature-maps): add Gaussian RBF centers" -git commit -m "fix(preprocessor): validate output_dim > 0" -``` - -The `commit-msg` pre-commit hook validates every commit message against the conventional -commits format and rejects non-conforming messages. - -## Bumping the version - -Version bumps are driven by commitizen, wrapped in `just` recipes. Preview first with the -`-preview` (dry-run) variant, then apply. Each apply recipe updates `version` in -`pyproject.toml`, appends to `CHANGELOG.md`, and creates the bump commit and tag. - -| Goal | Preview | Apply | -| ----------------- | ---------------------- | -------------- | -| Stable release | `just bump-preview` | `just bump` | -| Release candidate | `just bump-rc-preview` | `just bump-rc` | - -The next version is inferred from the conventional commits since the last tag. To force a -level when it is not auto-detected, append the increment, e.g. `just bump --increment MINOR`. - -## Changelog - -`CHANGELOG.md` at the repository root is the authoritative changelog, updated automatically -by the bump recipes. Changes are grouped under their commit types (`feat`, `fix`, -`perf`, ...) with the subject line of every matching commit since the previous release. - -## Tags - -Release tags follow `vMAJOR.MINOR.PATCH` (or `vMAJOR.MINOR.PATCHrcN` for RCs) and trigger -the PyPI publish workflows. See the [Release process](release.md) page for the full -end-to-end procedure. diff --git a/docs/index.rst b/docs/index.rst index ac92363..43e660c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -68,7 +68,4 @@ :hidden: developer_guide/contributing - developer_guide/testing - developer_guide/documentation - developer_guide/versioning developer_guide/release