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 766dca8..60c4d2b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,16 +1,18 @@
name: CI
on:
+ workflow_call:
workflow_dispatch:
push:
branches:
- main
+ - "release/**"
pull_request:
branches:
- main
concurrency:
- group: ci-${{ github.head_ref || github.sha }}
+ group: ci-${{ github.workflow }}-${{ github.head_ref || github.sha }}
cancel-in-progress: true
permissions:
@@ -150,6 +152,23 @@ jobs:
- name: Run unit tests
run: poetry run pytest tests/ -v
+ minimum-deps:
+ name: Minimum dependencies (Python 3.10)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.10"
+ - 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)
runs-on: ubuntu-latest
@@ -248,6 +267,8 @@ jobs:
module: sentence_transformers
- extra: lightgbm
module: lightgbm
+ - extra: polars
+ module: polars
steps:
- uses: actions/checkout@v4
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 1330fdf..a1e8e79 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.
@@ -15,11 +16,12 @@ on:
# which files changed in the tagged commit.
branches:
- main
+ - "release/**"
tags:
- - "v*"
+ - "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 f9ec093..9e81a9c 100644
--- a/.github/workflows/publish-pypi.yml
+++ b/.github/workflows/publish-pypi.yml
@@ -15,7 +15,20 @@ permissions:
id-token: write
jobs:
+ qa:
+ uses: ./.github/workflows/ci.yml
+ 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
environment: pypi-publish
# The "v*.*.*" trigger also matches RC tags (e.g. v2.0.0rc2), so guard
@@ -73,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 ae9c292..bac0de6 100644
--- a/.github/workflows/publish-testpypi.yml
+++ b/.github/workflows/publish-testpypi.yml
@@ -16,7 +16,18 @@ permissions:
id-token: write
jobs:
+ qa:
+ uses: ./.github/workflows/ci.yml
+ permissions:
+ contents: read
+
+ docs:
+ uses: ./.github/workflows/docs.yml
+ permissions:
+ contents: read
+
publish-rc:
+ needs: [qa, docs]
runs-on: ubuntu-latest
environment: testpypi-publish
@@ -70,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
@@ -110,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__)"
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/CHANGELOG.md b/CHANGELOG.md
index d8878f6..2d2f09a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,28 +7,102 @@ 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.0rc5 (2026-09-06)
-> **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.0rc4 (2026-09-06)
### 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
+- default Preprocessor output to a single array
+
+### Fix
+
+- 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)
+
+### Fix
+
+- **tests**: sort imports in test_adaptive_output_dim
+- add missing indicator
+- validate embedding during fit
+- remove unused ple parameters
+
+### Refactor
+
+- **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)
+
+### 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
+
+- 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
@@ -36,19 +110,18 @@ 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**: add random_state parameter
-- **preprocessor**: add numerical_imputation / categorical_imputation / add_missing_indicator parameters (replacing handle_missing)
+- **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
- **logging**: add verbose level, route warnings
- **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
@@ -68,6 +141,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 +161,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_
-

+

[](https://pypi.org/project/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,16 +67,17 @@ 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)
# 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, 22)
```
> **Note:** PreTab accepts a `pandas.DataFrame` or a `numpy.ndarray` and infers numerical
@@ -88,42 +89,55 @@ 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`.
-
-### Splines
-
-| Transformer | Basis | Best for |
-| ----------------------------------- | -------------------------------------- | ---------------------------------------- |
-| `BSplineTransformer` | B-spline basis | General-purpose smooth nonlinearity |
-| `MSplineTransformer` | Non-negative B-spline basis | Density-like, non-negative bases |
-| `ISplineTransformer` | Monotone integrated spline | Effects that must not reverse |
-| `CubicRegressionSplineTransformer` | Cubic regression spline | GAM-style additive smooth terms |
-| `NaturalCubicSplineTransformer` | Natural cubic spline | Smooth effects with linear tails |
-| `PSplineTransformer` | Penalized B-spline | Smoothness via a difference penalty |
-| `TensorProductSplineTransformer` | Tensor-product spline (multivariate) | Smooth interactions across 2+ features |
-| `ThinPlateSplineTransformer` | Thin-plate spline (multivariate) | Smooth surfaces across 2+ features |
-
-### Feature maps
-
-| Transformer | Basis | Best for |
-| ------------------------------------ | ------------------------------------------ | ---------------------------------------- |
-| `RBFExpansionTransformer` | Radial basis functions | Localized, kernel-like features |
-| `ReLUExpansionTransformer` | ReLU basis | Piecewise-linear neural features |
-| `SigmoidExpansionTransformer` | Sigmoid basis | Smooth saturating features |
-| `TanhExpansionTransformer` | Tanh basis | Zero-centered saturating features |
-| `FourierFeatureTransformer` | Sine/cosine basis | Periodic or cyclic numerical effects |
-| `RandomFourierFeaturesTransformer` | Random Fourier features (multivariate) | Scalable RBF-kernel approximation |
-| `NystroemFeaturesTransformer` | Nystroem kernel map (multivariate) | Landmark-based kernel approximation |
-
-### Encoding and binning
-
-| Transformer | Method | Best for |
-| ------------------------------- | ------------------------------------------ | ---------------------------------------- |
-| `PLETransformer` | Piecewise-linear encoding (supervised) | Strong numerical encoding for models |
-| `NumericBinningTransformer` | Uniform/quantile binning, tree-driven | Discretizing numerical columns |
-| `ContinuousOrdinalTransformer` | Integer (ordinal) encoding | Compact codes for categoricals |
-| `LanguageEmbeddingTransformer` | Pretrained language embeddings | High-cardinality, semantic columns |
+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).
+
+### 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 |
+
+### 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 |
+
+### 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 |
+
+### Categorical encoding and embeddings
+
+| 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.
@@ -131,7 +145,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
@@ -170,7 +186,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
@@ -194,8 +210,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
```
@@ -209,11 +225,13 @@ 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 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
@@ -273,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.
@@ -320,10 +353,17 @@ 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
```
+> **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
@@ -334,7 +374,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
```
@@ -343,11 +385,45 @@ 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 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/_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/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/api/representations.rst b/docs/api/representations.rst
index a0355da..2b7f8b6 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 piecewise-linear encoding
--------------------------------------
+Canonical import: ``pretab.kernel_approximation``.
+
+Numerical encoding
+--------------------
.. autosummary::
:toctree: _autosummary
@@ -48,9 +71,12 @@ Binning and piecewise-linear encoding
NumericBinningTransformer
PLETransformer
+ PeriodicEncodingTransformer
-Categorical
------------
+Canonical import: ``pretab.encoding.numerical``.
+
+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,5 @@ Utility transformers
MissingStateIndicator
NoTransformer
ToFloatTransformer
+
+Canonical import: ``pretab.preprocessing``.
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.
diff --git a/docs/core_concepts/configuration.md b/docs/core_concepts/configuration.md
index 51a09ce..c5fa21d 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
@@ -45,17 +73,69 @@ 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
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 [representations overview](../representations/overview.md) for the full
+citation list.
+```
```python
standard = Preprocessor(preset="standard")
@@ -63,17 +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`.
+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
@@ -111,16 +192,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) |
-| `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/feature_representation.md b/docs/core_concepts/feature_representation.md
index b2bddab..8941844 100644
--- a/docs/core_concepts/feature_representation.md
+++ b/docs/core_concepts/feature_representation.md
@@ -1,21 +1,52 @@
# 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 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
@@ -28,10 +59,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.
@@ -44,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
@@ -73,6 +94,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..4729edb 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,27 +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 tolerate `NaN`,
-while finite-only representations such as PLE, the splines, the feature maps, and binning
-raise a typed error. That is intentional, an expansion of an undefined value has no meaning.
-```
-
-```{warning}
-Requesting `add_missing_indicator=True` while both imputation strategies are disabled raises
-`IncompatibleParamsError`. An indicator without a filled value leaves the basis with nothing
-to expand.
+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
@@ -58,13 +41,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")
@@ -72,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.
diff --git a/docs/core_concepts/outputs_and_inspection.md b/docs/core_concepts/outputs_and_inspection.md
index 26950fb..b8b4d80 100644
--- a/docs/core_concepts/outputs_and_inspection.md
+++ b/docs/core_concepts/outputs_and_inspection.md
@@ -7,18 +7,85 @@ 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
@@ -27,7 +94,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.
@@ -53,8 +120,165 @@ 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
+
+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
@@ -77,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
@@ -107,12 +399,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/reproducibility.md b/docs/core_concepts/reproducibility.md
index 6b0e08e..ec6f1e3 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
@@ -35,13 +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`, `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.
+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
@@ -54,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.
@@ -76,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
@@ -95,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/docs/core_concepts/resolution_and_placement.md b/docs/core_concepts/resolution_and_placement.md
index ebeeb63..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.
@@ -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,18 +18,25 @@ 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
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.
@@ -46,12 +52,18 @@ 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
+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
@@ -78,17 +90,19 @@ 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
-`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
@@ -106,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..d3eccf2 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)
diff --git a/docs/developer_guide/contributing.md b/docs/developer_guide/contributing.md
index 1b36cc4..5054dfd 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 # ruff format, ruff lint, and pyright, via the pre-commit and pre-push hooks
+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`.
@@ -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:
@@ -85,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.
+```
-## Release workflow
+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, 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
-For the end-to-end release procedure (version bump, tags, PyPI publishing) see:
+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:`) |
+
+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 011c683..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 aa34381..30cbe1a 100644
--- a/docs/developer_guide/release.md
+++ b/docs/developer_guide/release.md
@@ -8,15 +8,47 @@ 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
-| 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 |
+
+## 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
@@ -73,8 +105,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
@@ -96,6 +136,9 @@ 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.
+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
```bash
diff --git a/docs/developer_guide/testing.md b/docs/developer_guide/testing.md
deleted file mode 100644
index 368ca45..0000000
--- a/docs/developer_guide/testing.md
+++ /dev/null
@@ -1,97 +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 tests/
-```
-
-To run a subset while developing, invoke pytest directly.
-
-```bash
-poetry run pytest tests/transformers/ # one area
-poetry run pytest tests/transformers/test_bspline.py::test_output_shape # one test
-poetry run pytest -k "spline and not tensor" # by keyword
-```
-
-## Layout
-
-Tests mirror the structure of the package, so a change in one area maps to an obvious test
-directory.
-
-| Directory | Covers |
-| --- | --- |
-| `tests/core/` | Base classes, adaptive resolution, supervised logic, logging. |
-| `tests/transformers/` | Every representation, per family. |
-| `tests/placement/` | Knot and edge placement strategies. |
-| `tests/compose/` | Registry, feature detection, config resolution, serialization. |
-| `tests/extension/` | The public extensibility surface and conformance. |
-| `tests/integration/` | End-to-end `Preprocessor` and pipeline behaviour. |
-| `tests/regression/` | Pinned outputs that guard against silent numerical drift. |
-| `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.
-```
-
-## Markers
-
-The suite defines a `smoke` marker for fast end-to-end sanity checks that run as a dedicated CI
-gate.
-
-```bash
-poetry run pytest -m smoke # only the smoke checks
-poetry run pytest -m "not smoke" # everything else
-```
-
-## Coverage
-
-`just test` measures coverage over the `pretab` package. Keep new code covered, and prefer a
-focused test that exercises the behaviour over one that merely touches lines.
-
-```bash
-poetry run pytest --cov=pretab --cov-report=term-missing tests/
-```
-
-## Testing a custom representation
-
-If you extend PreTab, run the conformance suite in your own tests. It verifies your class obeys
-the representation contract, the same one the built-ins satisfy.
-
-```python
-from pretab import check_representation
-from my_package import MyRepresentation
-
-def test_conforms():
- check_representation(MyRepresentation)
-```
-
-```{important}
-`check_representation` raises `RepresentationConformanceError` on any violation. Wiring it into
-your test suite keeps a future refactor from silently breaking compatibility with `Preprocessor`.
-```
-
-## Before you push
-
-Run the full local gate, which mirrors CI.
-
-```bash
-just test # tests with coverage
-just check # lint, format, type-check across all files
-just docs # strict docs build
-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 697b9f5..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/).
-
-While the major version is `0`, the public API may change between minor releases.
-
-## 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. `0.1.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.
-```
-
-## 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 n_bins > 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/getting_started/choosing_an_interface.md b/docs/getting_started/choosing_an_interface.md
deleted file mode 100644
index 2663b79..0000000
--- a/docs/getting_started/choosing_an_interface.md
+++ /dev/null
@@ -1,92 +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`, detects numerical and categorical columns, and applies a strategy per
-column from a single configuration object. Returns a dict of feature blocks by default.
-:::
-
-:::{grid-item-card} Standalone transformers
-Plain scikit-learn transformers you import from `pretab.transformers`. Each one returns a
-NumPy array and slots into a `Pipeline`, `ColumnTransformer`, or any scikit-learn utility.
-:::
-
-::::
-
-## Reach for the `Preprocessor` when
-
-- You start from a `DataFrame` and want **automatic feature-type detection** rather than
- wiring every column by hand.
-- You want to configure **many columns from one place**, either with global
- `numerical_method` / `categorical_method` defaults or a per-column `feature_preprocessing`
- map.
-- You want the **framework services** that live at this level: feature lineage, output-format
- control, missing-value policy, output budgets, serialization, and a reproducibility
- fingerprint.
-
-```python
-from pretab import Preprocessor
-
-pre = Preprocessor(feature_preprocessing={
- "age": "naturalspline",
- "income": "ple",
- "city": "one-hot",
-})
-X = pre.fit_transform(df, y) # dict of blocks, or return_array=True for one matrix
-```
-
-## Reach for standalone transformers when
-
-- You want a **single estimator object** that composes cleanly inside one `Pipeline`.
-- You rely on **scikit-learn model selection**: `cross_val_score`, `GridSearchCV`, and
- `step__param` hyperparameter addressing all work out of the box.
-- You need **fine control** over one column's transformer and its parameters.
-
-```python
-from sklearn.compose import ColumnTransformer
-from sklearn.pipeline import Pipeline
-from sklearn.linear_model import Ridge
-
-from pretab.transformers import NaturalCubicSplineTransformer, PLETransformer
-
-features = ColumnTransformer([
- ("age", NaturalCubicSplineTransformer(output_dim=10), ["age"]),
- ("income", PLETransformer(output_dim=12), ["income"]),
-])
-model = Pipeline([("features", features), ("ridge", Ridge())])
-```
-
-```{note}
-The `Preprocessor` returns a dict by default, which is convenient for inspection but is not a
-drop-in for a scikit-learn estimator that expects a single matrix. Call it with
-`return_array=True`, or use the standalone transformers, when you compose one end-to-end
-`Pipeline`.
-```
-
-## A note on multivariate methods
-
-The tensor-product spline, thin-plate spline, random Fourier features, and Nyström map model
-several columns **jointly**. They are standalone-only and are not selectable per column
-through `Preprocessor(numerical_method=...)`. Use them directly as transformers over a block
-of columns. See [Multivariate features](../tutorials/multivariate_features.md).
-
-## They interoperate
-
-The choice is not exclusive. A `Preprocessor` can live inside a larger `Pipeline`, and
-standalone transformers can preprocess columns you then hand to a `Preprocessor`. Pick the
-surface that keeps the intent of your code clearest.
-
-## Where to go next
-
-- [Configuration](../core_concepts/configuration.md) documents every `Preprocessor` knob.
-- [scikit-learn pipelines](../tutorials/sklearn_pipeline.md) shows the standalone route with
- cross-validation and grid search.
-- [Representations](../representations/overview.md) is the full method catalogue.
diff --git a/docs/getting_started/installation.md b/docs/getting_started/installation.md
index f13b17e..924416f 100644
--- a/docs/getting_started/installation.md
+++ b/docs/getting_started/installation.md
@@ -1,6 +1,23 @@
# 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.
+```
+
+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
@@ -25,12 +42,34 @@ 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:
+
+```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/docs/getting_started/migration_to_1_0.md b/docs/getting_started/migration_to_1_0.md
index e21a036..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,21 +48,22 @@ 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"`,
-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
@@ -72,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
@@ -81,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
@@ -93,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 e2dbc12..ef35a8b 100644
--- a/docs/getting_started/overview.md
+++ b/docs/getting_started/overview.md
@@ -32,6 +32,83 @@ pre = Preprocessor(feature_preprocessing={
X = pre.fit_transform(df, y)
```
+## Two ways to use it
+
+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
+
+:::{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.
+:::
+
+::::
+
+**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
PreTab is not a competitor to scikit-learn. Every transformer subclasses `BaseEstimator` and
@@ -39,28 +116,30 @@ 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 | 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`,
`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
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.
@@ -70,10 +149,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
@@ -82,7 +163,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.
@@ -94,28 +175,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.
diff --git a/docs/getting_started/quickstart.md b/docs/getting_started/quickstart.md
index 63a9d1d..010660a 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,13 @@ 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
+```
+
+```text
+(200, 26)
```
```{tip}
@@ -55,10 +59,15 @@ 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": ...}
+```
+
+```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
@@ -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
@@ -111,7 +140,12 @@ 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
+```
+
+```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
@@ -128,10 +162,21 @@ 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
[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/homepage.md b/docs/homepage.md
index de01e60..907ee97 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
@@ -21,13 +24,13 @@ 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
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
@@ -53,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
@@ -84,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
@@ -120,7 +123,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/index.rst b/docs/index.rst
index 81b0d5b..43e660c 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::
@@ -33,11 +32,12 @@
representations/overview
representations/comparison_table
representations/choosing_a_method
- representations/splines
- representations/feature_maps
- representations/binning_and_ple
- representations/categorical
- representations/references
+ representations/spline_expansions
+ representations/functional_expansions
+ representations/kernel_approximation
+ representations/numerical_encoding
+ representations/categorical_encoding
+ representations/embeddings
.. toctree::
:caption: Tutorials
@@ -57,7 +57,10 @@
:maxdepth: 2
:hidden:
- api/index
+ api/preprocessor
+ api/representations
+ api/search_and_cross_fitting
+ api/extension
.. toctree::
:caption: Developer Guide
@@ -65,7 +68,4 @@
:hidden:
developer_guide/contributing
- developer_guide/testing
- developer_guide/documentation
- developer_guide/versioning
developer_guide/release
diff --git a/docs/representations/binning_and_ple.md b/docs/representations/binning_and_ple.md
deleted file mode 100644
index 48852b9..0000000
--- a/docs/representations/binning_and_ple.md
+++ /dev/null
@@ -1,90 +0,0 @@
-# Binning and PLE
-
-Discretization turns a continuous feature into regions. It captures sharp, threshold-like
-effects that smooth bases blur, and it is the natural representation when a feature acts in
-steps. PreTab offers unsupervised numeric binning and supervised piecewise-linear encoding
-(PLE).
-
-## Numeric binning
-
-Numeric binning splits a feature into intervals and encodes which interval each value falls
-into. You choose how the edges are placed and how the result is encoded.
-
-```python
-from pretab.transformers import NumericBinningTransformer
-
-t = NumericBinningTransformer(output_dim=8, encode="onehot", placement_strategy="quantile")
-```
-
-The `encode` parameter selects the output form.
-
-`"ordinal"`
-: A single integer column giving the bin index.
-
-`"onehot"`
-: One indicator column per bin.
-
-`"soft"`
-: A soft assignment that spreads each value across neighbouring bins, so the boundaries are not
- hard. This keeps a little of the smoothness that hard binning discards.
-
-Edge placement follows `placement_strategy`: `"uniform"` for equal-width bins, `"quantile"`
-for equal-frequency bins. See
-[Resolution and placement](../core_concepts/resolution_and_placement.md).
-
-```{tip}
-Quantile edges give every bin a similar number of samples, which is usually more stable than
-equal-width bins when the feature is skewed.
-```
-
-## Piecewise-linear encoding
-
-PLE is the flagship supervised representation. It fits a decision tree of the feature against
-the target, reads the split points as bin edges, and encodes each value as its **linear
-position within its bin**. The result is a piecewise-linear function that bends exactly where
-the target changes, following the tabular deep-learning work of Gorishniy and colleagues.
-
-```python
-from pretab.transformers import PLETransformer
-
-t = PLETransformer(output_dim=12, task="regression")
-X2 = t.fit_transform(x, y) # y is required
-```
-
-Constructor highlights: `output_dim`, `placement_strategy="cart"`, `task="regression"`,
-`adaptive`, `random_state=51`, and the tree controls `max_depth`, `min_samples_split`,
-`min_samples_leaf`.
-
-```{important}
-PLE **requires** the target. It places its edges using `y`, so it must be fit with a target
-and should be fit leakage-safely, ideally with cross-fitting. See
-[Target awareness](../core_concepts/target_awareness.md).
-```
-
-### Why piecewise-linear rather than one-hot
-
-Plain binning throws away where a value sits inside its bin; two values in the same interval
-become identical. PLE keeps the within-bin position as a linear ramp, so it retains fine
-resolution while still capturing the sharp transitions the tree found. That combination is why
-it works so well as a front-end for both linear models and neural networks.
-
-```{tip}
-PLE is a strong default for numerical features, and it is the default `numerical_method` on
-`Preprocessor`. Reach for it first when you have a supervised task and want the representation
-to follow the target.
-```
-
-## Binning versus PLE
-
-| | Numeric binning | PLE |
-| --- | --- | --- |
-| Uses the target | No | Yes (required) |
-| Within-bin resolution | Lost (hard) or blurred (soft) | Preserved (linear) |
-| Edge placement | Uniform or quantile | Target-driven (tree splits) |
-| Best for | Unsupervised, known step structure | Supervised sharp effects |
-
-## Where to go next
-
-- [Target awareness](../core_concepts/target_awareness.md) for fitting PLE safely.
-- [Splines](splines.md) for smooth alternatives to binning.
-- [References](references.md) for the PLE source.
diff --git a/docs/representations/categorical.md b/docs/representations/categorical.md
deleted file mode 100644
index c1eaa88..0000000
--- a/docs/representations/categorical.md
+++ /dev/null
@@ -1,86 +0,0 @@
-# Categorical
-
-Categorical features range from a handful of labels to free text with thousands of distinct
-values. PreTab covers the spectrum: compact integer encoding, explicit one-hot, and pretrained
-language embeddings for high-cardinality text. All of them handle unseen categories without
-raising.
-
-## Integer (ordinal) encoding
-
-The default categorical method maps each category to an integer. It is compact and works well
-as an input to models that consume category indices, such as embedding layers.
-
-```python
-from pretab.transformers import ContinuousOrdinalTransformer
-
-t = ContinuousOrdinalTransformer()
-X2 = t.fit_transform(x)
-```
-
-Unseen categories at transform time map to a reserved slot rather than raising, so a model in
-production never crashes on a new label.
-
-```{note}
-Integer encoding imposes an order on the codes. Feed it to models that treat the code as an
-index (trees, embedding layers), not to a plain linear model that would read the codes as
-magnitudes.
-```
-
-## One-hot encoding
-
-One-hot encoding produces one indicator column per category, the right choice when the
-downstream model should treat categories as unordered.
-
-```python
-pre = Preprocessor(categorical_method="one-hot")
-```
-
-The alias `ohe` resolves to `one-hot`. There is also `onehot_from_ordinal`, which one-hot
-encodes an already integer-coded column.
-
-```{warning}
-One-hot width grows with cardinality. A column with thousands of categories produces thousands
-of columns. Use the [output budget](../core_concepts/outputs_and_inspection.md) to cap it, or
-prefer integer encoding or embeddings for high-cardinality columns.
-```
-
-## Language embeddings
-
-For high-cardinality text categories (product titles, free-text tags, descriptions), a
-pretrained sentence embedding captures semantic similarity that integer or one-hot encoding
-cannot. Similar labels land near each other in the embedding space.
-
-```python
-from pretab.transformers import LanguageEmbeddingTransformer
-
-t = LanguageEmbeddingTransformer(model_name="paraphrase-MiniLM-L3-v2")
-X2 = t.fit_transform(x)
-```
-
-Constructor highlights: `model_name="paraphrase-MiniLM-L3-v2"`, or pass a preloaded `model`.
-The registry key is `pretrained`.
-
-```{important}
-Language embeddings require the optional `embeddings` extra, which pulls in
-`sentence-transformers`. Install it with `pip install "pretab[embeddings]"`. Without it,
-requesting `pretrained` raises a clear `OptionalDependencyError`.
-```
-
-```{tip}
-Embeddings shine when category labels carry meaning as text. If the labels are opaque codes
-with no semantic content, integer encoding is simpler and just as effective.
-```
-
-## Choosing a categorical method
-
-| If the column is... | Reach for... |
-| --- | --- |
-| Low cardinality, unordered | One-hot |
-| Fed to a tree or embedding layer | Integer |
-| High-cardinality meaningful text | Language embedding |
-
-## Where to go next
-
-- [Missing values](../core_concepts/missing_values.md) for categorical imputation.
-- [Configuration](../core_concepts/configuration.md) to set categorical methods per column.
-- [Installation](../getting_started/installation.md) for the `embeddings` extra.
diff --git a/docs/representations/categorical_encoding.md b/docs/representations/categorical_encoding.md
new file mode 100644
index 0000000..acda62a
--- /dev/null
+++ b/docs/representations/categorical_encoding.md
@@ -0,0 +1,77 @@
+# Categorical encoding
+
+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
+
+The default categorical method maps each category to an integer. It is compact and works well
+as an input to models that consume category indices, such as embedding layers.
+
+```python
+import numpy as np
+from pretab.transformers import ContinuousOrdinalTransformer
+
+X = np.array([["a"], ["b"], ["a"], ["c"]]) # (4, 1)
+t = ContinuousOrdinalTransformer()
+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 (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
+index (trees, embedding layers), not to a plain linear model that would read the codes as
+magnitudes.
+```
+
+## One-hot encoding
+
+One-hot encoding produces one indicator column per category, the right choice when the
+downstream model should treat categories as unordered.
+
+```python
+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`.
+
+```{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`
+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
+
+| 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
+
+- [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.
diff --git a/docs/representations/choosing_a_method.md b/docs/representations/choosing_a_method.md
index 7f8834d..2e457d5 100644
--- a/docs/representations/choosing_a_method.md
+++ b/docs/representations/choosing_a_method.md
@@ -7,34 +7,28 @@ 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
-| 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
-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.
```
@@ -60,27 +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. Splines, PLE, and feature maps are undefined or flat
- outside it, so they do not extrapolate. If your test data lies well beyond training, no
- expansion recovers the missing signal. See the edge-case behaviour below.
-
-Pure noise features
-: Expanding a feature that carries no signal only gives the model more ways to fit noise. Drop
- the feature instead.
+- **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
@@ -91,11 +83,19 @@ 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**: 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.
- **NaN into a finite-only method**: raises a typed error unless imputation is configured. See
[Missing values](../core_concepts/missing_values.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..8d81a3c 100644
--- a/docs/representations/comparison_table.md
+++ b/docs/representations/comparison_table.md
@@ -6,74 +6,79 @@ 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
-| Method | Key | Scope | Target | Selectable |
-| --- | --- | --- | --- | --- |
-| Standardization | `standardization` | univariate | forbidden | yes |
-| Min-max scaling | `minmax` | univariate | forbidden | yes |
-| Robust scaling | `robust` | univariate | forbidden | yes |
-| Quantile transform | `quantile` | univariate | forbidden | yes |
-| Polynomial features | `polynomial` | univariate | forbidden | yes |
-| Box-Cox | `box-cox` | univariate | forbidden | yes |
-| Yeo-Johnson | `yeo-johnson` | univariate | forbidden | yes |
-| Passthrough | `none` | univariate | forbidden | yes |
-
-## Numerical: splines
-
-| Method | Key | Scope | Target | Adaptive | Penalty | Selectable |
-| --- | --- | --- | --- | --- | --- | --- |
-| B-spline | `bspline` | univariate | optional | yes | no | yes |
-| M-spline | `mspline` | univariate | optional | yes | no | yes |
-| I-spline | `ispline` | univariate | optional | yes | no | yes |
-| Cubic regression spline | `cubicspline` | univariate | optional | yes | yes | yes |
-| Natural cubic spline | `naturalspline` | univariate | optional | yes | yes | yes |
-| Penalized spline (P-spline) | `pspline` | univariate | forbidden | yes | yes | yes |
-| Tensor-product spline | `tensorspline` | multivariate | forbidden | yes | yes | no |
-| Thin-plate spline | `tprs` | multivariate | forbidden | no | yes | no |
+| 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 |
```{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.
```
-## Numerical: feature maps
+## 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 |
-| Random Fourier features | `rff` | multivariate | forbidden | no | no |
-| Nyström kernel map | `nystroem` | multivariate | forbidden | no | no |
+| 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 |
+
+```{note}
+Random Fourier features and Nyström model the whole input matrix jointly and are used
+standalone, not selected per column through `Preprocessor`.
+```
-## Numerical: discretization
+## Numerical encoding
-| Method | Key | Scope | Target | Adaptive | Selectable |
-| --- | --- | --- | --- | --- | --- |
-| Numeric binning | `custombin` | univariate | forbidden | no | yes |
-| Piecewise-linear encoding (PLE) | `ple` | univariate | required | yes | yes |
+| 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 +86,43 @@ 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 |
+| Passthrough | `none` | univariate | forbidden | yes |
+
+```{note}
+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 |
-| --- | --- | --- | --- | --- |
-| 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 |
+| Method | Key | Scope | Target | Selectable |
+| ----------------------------- | ------------ | ---------- | --------- | ---------- |
+| Pretrained language embedding | `pretrained` | univariate | forbidden | yes |
```{note}
-`pretrained` requires the optional `embeddings` extra. The alias `ohe` resolves to `one-hot`.
+`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..b4e48d9
--- /dev/null
+++ b/docs/representations/embeddings.md
@@ -0,0 +1,51 @@
+# 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")
+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`
+(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, 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
+`sentence-transformers`. Install it with `pip install "pretab[embeddings]"`. Without it,
+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)
+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..35a5586
--- /dev/null
+++ b/docs/representations/functional_expansions.md
@@ -0,0 +1,131 @@
+# 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.
+
+```{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
+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
+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),
+`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`.
+```
+
+```{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
+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.
+
+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.
+
+```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}
+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. 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
+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
+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.
+- [Representations overview](overview.md) for the literature and supporting utilities.
diff --git a/docs/representations/kernel_approximation.md b/docs/representations/kernel_approximation.md
new file mode 100644
index 0000000..bf155aa
--- /dev/null
+++ b/docs/representations/kernel_approximation.md
@@ -0,0 +1,109 @@
+# 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. 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
+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)
+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`.
+
+```{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. 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 pretab.transformers import NystroemFeaturesTransformer
+
+X = np.random.default_rng(0).uniform(size=(200, 3))
+t = NystroemFeaturesTransformer(n_components=100, kernel="rbf")
+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.
+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
+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.
+- [Representations overview](overview.md) for the supporting notes and literature.
diff --git a/docs/representations/numerical_encoding.md b/docs/representations/numerical_encoding.md
new file mode 100644
index 0000000..0799fcd
--- /dev/null
+++ b/docs/representations/numerical_encoding.md
@@ -0,0 +1,163 @@
+# Numerical encoding
+
+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
+
+Numeric binning splits a feature into intervals and encodes which interval each value falls
+into. You choose how the edges are placed and how the result is encoded.
+
+```python
+import numpy as np
+from pretab.transformers import NumericBinningTransformer
+
+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, 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 (output width is always 1, independent of
+`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).
+
+Edge placement follows `placement_strategy`: `"uniform"` for equal-width bins, `"quantile"`
+for equal-frequency bins. See
+[Resolution and placement](../core_concepts/resolution_and_placement.md).
+
+```{tip}
+Quantile edges give every bin a similar number of samples, which is usually more stable than
+equal-width bins when the feature is skewed.
+```
+
+## Piecewise-linear encoding
+
+PLE is the flagship supervised representation. It fits a decision tree of the feature against
+the target, reads the split points as bin edges, and encodes each value as its **linear
+position within its bin**. The result is a piecewise-linear function that bends exactly where
+the target changes, following the tabular deep-learning work of Gorishniy and colleagues.
+
+```python
+import numpy as np
+from pretab.transformers import PLETransformer
+
+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"`,
+`adaptive`, and `random_state=51`.
+
+```{important}
+PLE **requires** the target. It places its edges using `y`, so it must be fit with a target
+and should be fit leakage-safely, ideally with cross-fitting. See
+[Target awareness](../core_concepts/target_awareness.md).
+```
+
+```{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
+become identical. PLE keeps the within-bin position as a linear ramp, so it retains fine
+resolution while still capturing the sharp transitions the tree found. That combination is why
+it works so well as a front-end for both linear models and neural networks.
+
+```{tip}
+PLE is a strong default for numerical features, and it is the default `numerical_method` on
+`Preprocessor`. Reach for it first when you have a supervised task and want the representation
+to follow the target.
+```
+
+## 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
+import numpy as np
+from pretab.transformers import PeriodicEncodingTransformer
+
+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.
+
+```{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
+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
+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 |
+| --------------------- | ---------------------------------- | --------------------------- |
+| Uses the target | No | Yes (required) |
+| Within-bin resolution | Lost (hard) or blurred (soft) | Preserved (linear) |
+| Edge placement | Uniform or quantile | Target-driven (tree splits) |
+| Best for | Unsupervised, known step structure | Supervised sharp effects |
+
+## Where to go next
+
+- [Target awareness](../core_concepts/target_awareness.md) for fitting PLE safely.
+- [Spline expansions](spline_expansions.md) for smooth alternatives to binning.
+- [Functional expansions](functional_expansions.md) for Fourier features, the deterministic
+ alternative to periodic encoding.
+- [Representations overview](overview.md) for the literature and supporting notes.
diff --git a/docs/representations/overview.md b/docs/representations/overview.md
index aed5c1e..13474b2 100644
--- a/docs/representations/overview.md
+++ b/docs/representations/overview.md
@@ -10,31 +10,44 @@ 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.
:::
::::
@@ -44,21 +57,12 @@ Ordinal and one-hot encoding, plus pretrained language embeddings for high-cardi
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
@@ -77,15 +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
-- [Splines](splines.md), [Feature maps](feature_maps.md), [Binning and PLE](binning_and_ple.md),
- [Categorical](categorical.md) for the families.
-- [Comparison table](comparison_table.md) to filter by capability.
+- [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
+ [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/references.md b/docs/representations/references.md
deleted file mode 100644
index 7f92170..0000000
--- a/docs/representations/references.md
+++ /dev/null
@@ -1,57 +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`.
-
-## Thin-plate and generalized additive models
-
-Wahba, G. (1990). *Spline Models for Observational Data*. Society for Industrial and Applied
-Mathematics.
-
-Wood, S. N. (2003). Thin plate regression splines. *Journal of the Royal Statistical Society:
-Series B*, 65(1), 95-114.
-
-Wood, S. N. (2017). *Generalized Additive Models: An Introduction with R* (2nd ed.). Chapman
-and Hall/CRC.
-
-Wahba's monograph is the foundation for thin-plate splines; Wood's work gives the low-rank
-thin-plate regression spline and the GAM framing that `ThinPlateSplineTransformer` follows.
-
-## Kernel approximations
-
-Williams, C. K. I., and Seeger, M. (2001). Using the Nyström method to speed up kernel
-machines. *Advances in Neural Information Processing Systems*, 13.
-
-Rahimi, A., and Recht, B. (2007). Random features for large-scale kernel machines. *Advances
-in Neural Information Processing Systems*, 20.
-
-These introduce the Nyström method and random Fourier features, implemented as
-`NystroemFeaturesTransformer` and `RandomFourierFeaturesTransformer`.
-
-## Piecewise-linear encoding
-
-Gorishniy, Y., Rubachev, I., and Babenko, A. (2022). On embeddings for numerical features in
-tabular deep learning. *Advances in Neural Information Processing Systems*, 35.
-
-This paper motivates piecewise-linear encoding of numerical features for tabular models, the
-basis for `PLETransformer`.
-
-## Where to go next
-
-- [Representations overview](overview.md) to return to the catalogue.
-- [Splines](splines.md), [Feature maps](feature_maps.md),
- [Binning and PLE](binning_and_ple.md) for the methods these sources describe.
diff --git a/docs/representations/spline_expansions.md b/docs/representations/spline_expansions.md
new file mode 100644
index 0000000..d6b28d7
--- /dev/null
+++ b/docs/representations/spline_expansions.md
@@ -0,0 +1,292 @@
+# 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
+the data while staying stable. PreTab ships the full family, from the workhorse B-spline to the
+multivariate thin-plate spline.
+
+## The idea
+
+A spline places a set of **knots** along the range of a feature and builds basis functions
+between them. The transformed feature is the vector of basis values,
+
+$$
+x \mapsto \big(B_1(x),\ B_2(x),\ \dots,\ B_K(x)\big),
+$$
+
+where each $B_k$ is nonzero only near a few knots. Local support is what keeps splines stable:
+a point in one region does not disturb the fit in another. Width is set by `output_dim` and
+knot positions by `placement_strategy` (see
+[Resolution and placement](../core_concepts/resolution_and_placement.md)).
+
+```{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. 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
+from pretab.transformers import BSplineTransformer
+
+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 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. 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
+
+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,
+
+$$
+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
+
+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.
+```
+
+## Cubic regression and natural cubic splines
+
+These are penalized-ready cubic bases with a clear knot interpretation, and both expose a
+smoothing penalty through `get_penalty_matrix()`.
+
+Cubic regression spline
+: A cubic basis parameterized at the knots (`cubicspline`), convenient for GAM-style additive
+models. 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`. 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
+from pretab.transformers import NaturalCubicSplineTransformer
+
+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}
+Prefer the natural cubic spline when your feature has sparse data near its extremes; the linear
+tails behave far better than an unconstrained cubic there.
+```
+
+## Penalized spline (P-spline)
+
+The P-spline combines a B-spline basis with a difference penalty on adjacent coefficients,
+following Eilers and Marx. Instead of controlling smoothness only through the number of knots,
+it uses many knots and a penalty of order `diff_order` to keep the fit smooth. 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
+from pretab.transformers import PSplineTransformer
+
+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.
+```
+
+## Multivariate splines
+
+Two families model several inputs jointly. They are used standalone, not selected per column
+through `Preprocessor`.
+
+### Tensor-product spline
+
+Builds a joint basis over multiple inputs as the tensor product of per-axis bases, capturing
+interactions on a smooth grid. 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
+import numpy as np
+from pretab.transformers import TensorProductSplineTransformer
+
+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
+
+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 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 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")
+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"`,
+`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
+want to model jointly.
+```
+
+## Where to go next
+
+- [Functional expansions](functional_expansions.md) for non-spline bases.
+- [Multivariate features tutorial](../tutorials/multivariate_features.md) for a worked joint
+ model.
+- [Representations overview](overview.md) for the primary spline literature and supporting notes.
diff --git a/docs/representations/splines.md b/docs/representations/splines.md
deleted file mode 100644
index 28b315b..0000000
--- a/docs/representations/splines.md
+++ /dev/null
@@ -1,156 +0,0 @@
-# Splines
-
-Splines are piecewise-polynomial bases with local support. They turn a single numerical column
-into a set of smooth, overlapping basis functions, so a linear model on top can bend to follow
-the data while staying stable. PreTab ships the full family, from the workhorse B-spline to the
-multivariate thin-plate spline.
-
-## The idea
-
-A spline places a set of **knots** along the range of a feature and builds basis functions
-between them. The transformed feature is the vector of basis values,
-
-$$
-x \mapsto \big(B_1(x),\ B_2(x),\ \dots,\ B_K(x)\big),
-$$
-
-where each $B_k$ is nonzero only near a few knots. Local support is what keeps splines stable:
-a point in one region does not disturb the fit in another. Width is set by `output_dim` and
-knot positions by `placement_strategy` (see
-[Resolution and placement](../core_concepts/resolution_and_placement.md)).
-
-## B-spline
-
-The B-spline is the default general-purpose smooth basis. Its functions are non-negative,
-sum to one, and each spans only `degree + 1` knot intervals.
-
-```python
-from pretab.transformers import BSplineTransformer
-
-t = BSplineTransformer(output_dim=13, degree=3, placement_strategy="quantile")
-```
-
-Constructor highlights: `output_dim`, `degree=3`, `include_bias=True`, `knot_locations=None`
-(pass explicit knots to override placement), `target_aware=False`, `placement_strategy="quantile"`,
-`adaptive`, `random_state`.
-
-```{tip}
-Cubic (`degree=3`) B-splines with quantile knots are a strong default for smooth regression.
-Increase `output_dim` for more wiggle, decrease it to regularize.
-```
-
-## M-spline and I-spline
-
-These two share the B-spline machinery but target special shapes.
-
-M-spline
-: A non-negative spline basis (`include_bias=False`). Useful when the components themselves
- should be non-negative, for example as a density-like basis.
-
-I-spline
-: The integral of an M-spline, giving a **monotone** basis. A model with non-negative
- coefficients on an I-spline basis is guaranteed monotone in the input, which is valuable when
- domain knowledge says a relationship cannot reverse.
-
-```python
-from pretab.transformers import ISplineTransformer
-
-t = ISplineTransformer(output_dim=10, degree=3) # monotone basis
-```
-
-```{note}
-I-splines only guarantee monotonicity when the downstream coefficients are constrained to be
-non-negative. Pair them with a non-negative linear model.
-```
-
-## Cubic regression and natural cubic splines
-
-These are penalized-ready cubic bases with a clear knot interpretation, and both expose a
-smoothing penalty through `get_penalty_matrix()`.
-
-Cubic regression spline
-: A cubic basis parameterized at the knots (`cubicspline`), convenient for GAM-style additive
- models.
-
-Natural cubic spline
-: A cubic spline constrained to be **linear beyond the boundary knots** (`naturalspline`).
- The linear tails reduce the wild behaviour ordinary cubics show near the edges of the data.
-
-```python
-from pretab.transformers import NaturalCubicSplineTransformer
-
-t = NaturalCubicSplineTransformer(output_dim=12)
-penalty = t.get_penalty_matrix() # for smoothing penalties
-```
-
-```{tip}
-Prefer the natural cubic spline when your feature has sparse data near its extremes; the linear
-tails behave far better than an unconstrained cubic there.
-```
-
-## Penalized spline (P-spline)
-
-The P-spline combines a B-spline basis with a difference penalty on adjacent coefficients,
-following Eilers and Marx. Instead of controlling smoothness only through the number of knots,
-it uses many knots and a penalty of order `diff_order` to keep the fit smooth.
-
-```python
-from pretab.transformers import PSplineTransformer
-
-t = PSplineTransformer(output_dim=20, degree=3, diff_order=2)
-penalty = t.get_penalty_matrix()
-```
-
-Constructor highlights: `output_dim`, `degree=3`, `diff_order=2`, `include_bias=False`,
-`placement_strategy="uniform"`, `adaptive`. The P-spline is unsupervised; it does not read the
-target.
-
-```{note}
-The P-spline decouples smoothness from knot count. Use a generous `output_dim` and let the
-penalty do the regularizing. Its penalty matrix plugs directly into penalized linear models.
-```
-
-## Multivariate splines
-
-Two families model several inputs jointly. They are used standalone, not selected per column
-through `Preprocessor`.
-
-### Tensor-product spline
-
-Builds a joint basis over multiple inputs as the tensor product of per-axis bases, capturing
-interactions on a smooth grid. It exposes an anisotropic penalty.
-
-```python
-from pretab.transformers import TensorProductSplineTransformer
-
-t = TensorProductSplineTransformer(output_dim=8, degree=3, diff_order=2)
-X2 = t.fit_transform(X[["lat", "lon"]])
-```
-
-### Thin-plate spline
-
-A thin-plate regression spline, the smooth-surface method from generalized additive models. It
-places landmarks (by default with k-means) and forms a low-rank basis.
-
-```python
-from pretab.transformers import ThinPlateSplineTransformer
-
-t = ThinPlateSplineTransformer(n_components=10, landmark_strategy="kmeans")
-X2 = t.fit_transform(X[["lat", "lon"]])
-```
-
-Constructor highlights: `n_components=10`, `landmark_strategy="kmeans"`, `rank_strategy="eigen"`,
-`include_bias=False`, `random_state`.
-
-```{warning}
-The tensor-product and thin-plate splines are multivariate. They are standalone transformers
-and are not available as a per-column `numerical_method`. Fit them directly on the columns you
-want to model jointly.
-```
-
-## Where to go next
-
-- [Feature maps](feature_maps.md) for non-spline bases.
-- [Multivariate features tutorial](../tutorials/multivariate_features.md) for a worked joint
- model.
-- [References](references.md) for the primary spline literature.
diff --git a/docs/tutorials/adaptive_resolution.md b/docs/tutorials/adaptive_resolution.md
index 86d4190..9159e01 100644
--- a/docs/tutorials/adaptive_resolution.md
+++ b/docs/tutorials/adaptive_resolution.md
@@ -15,16 +15,25 @@ 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
one chose.
```python
+import warnings
import numpy as np
import pandas as pd
from pretab.transformers import BSplineTransformer
@@ -37,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 5
-wiggly -> selected width 17
+simple -> selected width 15
+wiggly -> selected width 15
+```
+
+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.
```
-The nearly-linear signal needs few basis functions, so adaptive resolution keeps the width at
-the floor. The high-frequency signal needs many, so it climbs toward the ceiling. You get an
-appropriately-sized representation for each without tuning by hand.
+```{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
@@ -82,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/comparing_representations.md b/docs/tutorials/comparing_representations.md
index 33fb192..4d4d10b 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.955 +/- 0.005
```
The scaled baseline fits a straight line and cannot follow the sine. Every expansion captures
diff --git a/docs/tutorials/custom_representation.md b/docs/tutorials/custom_representation.md
index b8ba8b2..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
@@ -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 036ef2f..4fba8ad 100644
--- a/docs/tutorials/multivariate_features.md
+++ b/docs/tutorials/multivariate_features.md
@@ -11,11 +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.
+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.
@@ -112,6 +113,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.
-- [References](../representations/references.md) for the underlying theory.
+- [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.
+- [Representations overview](../representations/overview.md) for the underlying theory and supporting notes.
diff --git a/docs/tutorials/nonlinear_regression.md b/docs/tutorials/nonlinear_regression.md
index 2277121..2c47964 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)
@@ -120,25 +120,25 @@ print(f"MAE: {mean_absolute_error(y_test, pred):.2f}")
```
```text
-features: 41
-R2: 0.968
-MAE: 2.16
+features: 40
+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.968` and the mean absolute error drops
-from `11.20` to `2.16`.
+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 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
-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/sklearn_pipeline.md b/docs/tutorials/sklearn_pipeline.md
index bd4f668..0f5f343 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
@@ -76,7 +77,7 @@ print(f"5-fold R2: {scores.mean():.3f} +/- {scores.std():.3f}")
```
```text
-5-fold R2: 0.920 +/- 0.007
+5-fold R2: 0.948 +/- 0.005
```
## Tune with GridSearchCV
@@ -102,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.921
+best CV R2: 0.949
```
Every pretab transformer participates in the search grid just like a native `sklearn` step.
@@ -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 3f16846..f3d7fc0 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
@@ -92,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]
@@ -103,12 +104,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`.
+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
@@ -128,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"]),
])
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:
diff --git a/poetry.lock b/poetry.lock
index cf14d69..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 = "2a0bad6485988b0c36e131940e3f5df70bb2624604cfecb76ddd25b49eb1cab6"
+content-hash = "72350f56d6631ce5895d0ccc60dc374cd09526ccb2068569acc2c4204489589f"
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/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/compose/factory.py b/pretab/compose/factory.py
index 2d3fd8b..8ced4d6 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,
@@ -128,7 +128,15 @@ def get_numerical_transformer_steps(
}
if scaling is not None:
scaling = resolve_method(scaling, NUMERICAL_METHODS, NUMERICAL_ALIASES)
- if scaling in scalers and scaling != method:
+ 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 and method != "none":
steps.append(scalers[scaling])
if method not in NUMERICAL_METHODS:
@@ -199,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()))
@@ -215,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
@@ -261,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)
@@ -276,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
@@ -292,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/feature_detection.py b/pretab/compose/feature_detection.py
index 86d5e7d..ebdb1cc 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"):
@@ -51,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):
@@ -66,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/pretab/compose/inspection.py b/pretab/compose/inspection.py
index c3ea875..7f3b9b3 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
@@ -23,24 +24,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
@@ -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,7 +195,9 @@ def build_feature_info(column_transformer, *, embeddings, embedding_dimensions):
exc,
)
dimension = None
- if "cat" in name:
+ if separate_state_dimension is not None:
+ dimension = separate_state_dimension
+ if name.startswith("cat_"):
categorical_feature_info[feature_name] = {
"preprocessing": preprocessing_type,
"dimension": dimension,
@@ -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 813fcf9..5a0223c 100644
--- a/pretab/compose/output.py
+++ b/pretab/compose/output.py
@@ -14,14 +14,16 @@
import numpy as np
from scipy import sparse as sp
-from ..exceptions import IncompatibleParamsError, OptionalDependencyError
+from ..exceptions import IncompatibleParamsError, OptionalDependencyError, PretabDataError
__all__ = [
"attach_embeddings",
"build_output_dict",
"compute_output_report",
"format_output",
+ "resolve_embedding_dimensions",
"to_dataframe_output",
+ "validate_embedding_request",
]
# Density at or below which ``output_format="auto"`` switches to a sparse matrix,
@@ -33,6 +35,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):
@@ -40,8 +67,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 +83,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 +96,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 +119,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 +133,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 +150,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,25 +159,86 @@ 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
-def attach_embeddings(result: dict, embeddings, *, expected: bool) -> dict:
+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.
+ 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)
+ 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(
+ 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}"
+ expected_width = embedding_dimensions.get(name) if embedding_dimensions is not None else None
+ arr = _validate_embedding_array(arr, name, expected_width=expected_width, n_samples=n_samples)
+ result[name] = arr.astype(np.float32)
return result
@@ -155,14 +249,15 @@ 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.
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
@@ -172,15 +267,35 @@ 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).
"""
+ 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)
+ 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:
- attach_embeddings(result, embeddings, expected=embeddings_expected)
+ attach_embeddings(
+ result,
+ embeddings,
+ expected=embeddings_expected,
+ embedding_dimensions=embedding_dimensions,
+ n_samples=transformed.shape[0] if transformed.shape else None,
+ )
return result
diff --git a/pretab/compose/registry.py b/pretab/compose/registry.py
index 30fde8e..22d168b 100644
--- a/pretab/compose/registry.py
+++ b/pretab/compose/registry.py
@@ -27,35 +27,31 @@
StandardScaler,
)
-from ..transformers.categorical.language_embedding import (
- LanguageEmbeddingTransformer,
-)
-from ..transformers.categorical.legacy import OneHotFromOrdinalTransformer
-from ..transformers.categorical.ordinal import ContinuousOrdinalTransformer
-from ..transformers.encoders.floats import NoTransformer
-from ..transformers.feature_maps.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
-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 (
+from ..embedding.language import LanguageEmbeddingTransformer
+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
+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
+from ..expansion.spline.m_spline import MSplineTransformer
+from ..expansion.spline.multivariate.tensor_product import (
TensorProductSplineTransformer,
)
-from ..transformers.splines.multivariate.thin_plate import (
+from ..expansion.spline.multivariate.thin_plate import (
ThinPlateSplineTransformer,
)
-from ..transformers.splines.natural_cubic import NaturalCubicSplineTransformer
-from ..transformers.splines.p_spline import PSplineTransformer
+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 ..preprocessing.floats import NoTransformer
__all__ = [
"CATEGORICAL_ALIASES",
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/pretab/compose/serialize.py b/pretab/compose/serialize.py
index 6c9d07b..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
@@ -25,12 +25,31 @@
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 -------------------------------------------------------------
@@ -79,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):
@@ -152,14 +176,40 @@ 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"]))
- obj = cls.__new__(cls)
+ 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 = object.__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/pretab/core/adaptive.py b/pretab/core/adaptive.py
index 39f08f6..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)
@@ -88,14 +90,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/core/base.py b/pretab/core/base.py
index 58d39d5..284a0da 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
@@ -34,14 +35,16 @@ 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
_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``
@@ -53,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,
@@ -79,6 +92,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):
@@ -101,7 +121,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/core/knots.py b/pretab/core/knots.py
index 0d4254e..3a5b7b4 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,36 @@
]
+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/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/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..10a27ec 100644
--- a/pretab/core/policy.py
+++ b/pretab/core/policy.py
@@ -1,19 +1,35 @@
-"""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:
-* ``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
+* ``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`).
+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
+``inf`` / ``-inf`` always raise during input validation regardless of any
+policy (there is no configurable axis for it).
+
+.. note::
+ ``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
@@ -32,16 +48,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 +63,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/core/selectors.py b/pretab/core/selectors.py
index a92cd93..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"]
@@ -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,19 +143,28 @@ 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
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/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/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 77%
rename from pretab/transformers/categorical/language_embedding.py
rename to pretab/embedding/language.py
index bb8aace..e8cded9 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
+from ..exceptions import OptionalDependencyError, PretabConfigError, PretabDataError
class LanguageEmbeddingTransformer(TransformerMixin, BaseEstimator):
@@ -76,14 +76,24 @@ 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.
+ # 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):
@@ -112,11 +122,22 @@ 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])]
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/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/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 83%
rename from pretab/transformers/categorical/legacy.py
rename to pretab/encoding/categorical/one_hot.py
index be21f99..3aa569c 100644
--- a/pretab/transformers/categorical/legacy.py
+++ b/pretab/encoding/categorical/one_hot.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)
@@ -130,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/categorical/ordinal.py b/pretab/encoding/categorical/ordinal.py
similarity index 58%
rename from pretab/transformers/categorical/ordinal.py
rename to pretab/encoding/categorical/ordinal.py
index 867cf01..24621ef 100644
--- a/pretab/transformers/categorical/ordinal.py
+++ b/pretab/encoding/categorical/ordinal.py
@@ -3,14 +3,26 @@
from sklearn.utils.validation import check_is_fitted
from ...core.representation import RepresentationSpecMixin
+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
----------
@@ -20,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
--------
@@ -50,10 +62,20 @@ 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]
- for mapping in self.mapping_:
- mapping[None] = 0 # Assign 0 to unknown values
+ # 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_ = []
+ 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
@@ -71,9 +93,19 @@ 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)
+ 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]]
+ return out
def get_feature_names_out(self, input_features=None):
"""Return the output feature names (unchanged from the input).
@@ -96,6 +128,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
diff --git a/pretab/encoding/numerical/__init__.py b/pretab/encoding/numerical/__init__.py
new file mode 100644
index 0000000..630e3da
--- /dev/null
+++ b/pretab/encoding/numerical/__init__.py
@@ -0,0 +1,13 @@
+"""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 .ple import PLETransformer
+
+__all__ = [
+ "NumericBinningTransformer",
+ "PLETransformer",
+ "PeriodicEncodingTransformer",
+]
diff --git a/pretab/transformers/numerical/binning.py b/pretab/encoding/numerical/binning.py
similarity index 90%
rename from pretab/transformers/numerical/binning.py
rename to pretab/encoding/numerical/binning.py
index 96ae750..4b1fcbf 100644
--- a/pretab/transformers/numerical/binning.py
+++ b/pretab/encoding/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
--------
@@ -179,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)
@@ -236,21 +239,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/pretab/transformers/numerical/periodic.py b/pretab/encoding/numerical/periodic.py
similarity index 89%
rename from pretab/transformers/numerical/periodic.py
rename to pretab/encoding/numerical/periodic.py
index b54102e..75aaf31 100644
--- a/pretab/transformers/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/pretab/transformers/numerical/piecewise.py b/pretab/encoding/numerical/ple.py
similarity index 77%
rename from pretab/transformers/numerical/piecewise.py
rename to pretab/encoding/numerical/ple.py
index b368e56..81a639c 100644
--- a/pretab/transformers/numerical/piecewise.py
+++ b/pretab/encoding/numerical/ple.py
@@ -57,24 +57,23 @@ 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
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
----------
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
@@ -85,20 +84,21 @@ 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.
Examples
--------
@@ -123,9 +123,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
@@ -134,13 +131,10 @@ 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."""
- 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
@@ -171,7 +165,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()
@@ -180,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)
@@ -213,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_))
@@ -238,7 +234,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_:
@@ -252,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``.
@@ -274,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)
@@ -287,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
@@ -328,7 +323,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 +333,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/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/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 98%
rename from pretab/transformers/feature_maps/base.py
rename to pretab/expansion/functional/base.py
index 6434f48..95432f9 100644
--- a/pretab/transformers/feature_maps/base.py
+++ b/pretab/expansion/functional/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/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 97%
rename from pretab/transformers/feature_maps/rbf.py
rename to pretab/expansion/functional/rbf.py
index d42f326..bbf7b1b 100644
--- a/pretab/transformers/feature_maps/rbf.py
+++ b/pretab/expansion/functional/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/expansion/functional/relu.py
similarity index 96%
rename from pretab/transformers/feature_maps/relu.py
rename to pretab/expansion/functional/relu.py
index 6be6f76..6b2496a 100644
--- a/pretab/transformers/feature_maps/relu.py
+++ b/pretab/expansion/functional/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/expansion/functional/sigmoid.py
similarity index 97%
rename from pretab/transformers/feature_maps/sigmoid.py
rename to pretab/expansion/functional/sigmoid.py
index 63556c4..f831320 100644
--- a/pretab/transformers/feature_maps/sigmoid.py
+++ b/pretab/expansion/functional/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/expansion/functional/tanh.py
similarity index 97%
rename from pretab/transformers/feature_maps/tanh.py
rename to pretab/expansion/functional/tanh.py
index 653ab15..0c7c941 100644
--- a/pretab/transformers/feature_maps/tanh.py
+++ b/pretab/expansion/functional/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/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 80%
rename from pretab/transformers/splines/b_spline.py
rename to pretab/expansion/spline/b_spline.py
index 2d211ed..293471c 100644
--- a/pretab/transformers/splines/b_spline.py
+++ b/pretab/expansion/spline/b_spline.py
@@ -11,7 +11,8 @@
from scipy.interpolate import BSpline
from ...core.parameters import UNSET
-from .base_spline import BaseSplineTransformer
+from ...core.policy import RepresentationPolicy
+from .base import BaseSplineTransformer
class BSplineTransformer(BaseSplineTransformer):
@@ -23,8 +24,12 @@ 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`
- for the full parameter description. ``include_bias`` defaults to True here.
+ 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
+ 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 +37,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 +46,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",
@@ -50,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,
@@ -63,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/transformers/splines/base_spline.py b/pretab/expansion/spline/base.py
similarity index 94%
rename from pretab/transformers/splines/base_spline.py
rename to pretab/expansion/spline/base.py
index 69bed74..ab365bf 100644
--- a/pretab/transformers/splines/base_spline.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,
@@ -81,7 +82,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.
@@ -92,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
@@ -124,12 +133,13 @@ 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"
_representation_supervision = "optional"
_representation_local_support = True
+ _out_of_range_policy: ClassVar[str | None] = "clip"
def __init__(
self,
@@ -144,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
@@ -156,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"
@@ -310,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/transformers/splines/cubic_regression.py b/pretab/expansion/spline/cubic_regression.py
similarity index 73%
rename from pretab/transformers/splines/cubic_regression.py
rename to pretab/expansion/spline/cubic_regression.py
index 8652cec..392a99d 100644
--- a/pretab/transformers/splines/cubic_regression.py
+++ b/pretab/expansion/spline/cubic_regression.py
@@ -1,8 +1,11 @@
+import itertools
+
import numpy as np
from sklearn.base import BaseEstimator, TransformerMixin
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
@@ -58,7 +61,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.
@@ -69,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
@@ -78,10 +87,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.
@@ -130,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
@@ -141,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)
@@ -182,16 +189,20 @@ 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_ = []
+ self.x_min_ = []
+ self.x_max_ = []
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])
+ 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_basis_ = [design.shape[1] for design in self.designs_]
self.n_knots_ = [len(knots) for knots in self.knots_]
return self
@@ -199,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)
@@ -213,6 +225,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
@@ -224,20 +241,37 @@ 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]
+ 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/transformers/splines/i_spline.py b/pretab/expansion/spline/i_spline.py
similarity index 94%
rename from pretab/transformers/splines/i_spline.py
rename to pretab/expansion/spline/i_spline.py
index 6c32265..85f374c 100644
--- a/pretab/transformers/splines/i_spline.py
+++ b/pretab/expansion/spline/i_spline.py
@@ -12,7 +12,8 @@
from scipy.interpolate import BSpline
from ...core.parameters import UNSET
-from .base_spline import BaseSplineTransformer
+from ...core.policy import RepresentationPolicy
+from .base import BaseSplineTransformer
class ISplineTransformer(BaseSplineTransformer):
@@ -25,7 +26,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.
@@ -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/transformers/splines/m_spline.py b/pretab/expansion/spline/m_spline.py
similarity index 92%
rename from pretab/transformers/splines/m_spline.py
rename to pretab/expansion/spline/m_spline.py
index 0198860..f98682e 100644
--- a/pretab/transformers/splines/m_spline.py
+++ b/pretab/expansion/spline/m_spline.py
@@ -12,7 +12,8 @@
from scipy.interpolate import BSpline
from ...core.parameters import UNSET
-from .base_spline import BaseSplineTransformer
+from ...core.policy import RepresentationPolicy
+from .base import BaseSplineTransformer
class MSplineTransformer(BaseSplineTransformer):
@@ -25,7 +26,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
@@ -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/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 74%
rename from pretab/transformers/splines/multivariate/__init__.py
rename to pretab/expansion/spline/multivariate/__init__.py
index 39b5fa7..24bf4f9 100644
--- a/pretab/transformers/splines/multivariate/__init__.py
+++ b/pretab/expansion/spline/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
diff --git a/pretab/transformers/splines/multivariate/tensor_product.py b/pretab/expansion/spline/multivariate/tensor_product.py
similarity index 77%
rename from pretab/transformers/splines/multivariate/tensor_product.py
rename to pretab/expansion/spline/multivariate/tensor_product.py
index bd1a0e7..2be256b 100644
--- a/pretab/transformers/splines/multivariate/tensor_product.py
+++ b/pretab/expansion/spline/multivariate/tensor_product.py
@@ -2,24 +2,13 @@
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 ....core.policy import RepresentationPolicy, resolve_out_of_range
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.
@@ -54,6 +43,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
@@ -65,7 +56,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.
@@ -80,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
@@ -92,16 +89,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``.
@@ -139,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,
@@ -150,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
@@ -159,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)))
@@ -192,10 +189,12 @@ 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_ = []
- self.bases_ = []
+ self.marginal_sizes_ = []
self.penalties_ = []
self.n_knots_ = []
@@ -208,29 +207,34 @@ 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)
- 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)
+ 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]
@@ -241,10 +245,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_)]
@@ -259,13 +263,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(b.shape[1]) for j, b in enumerate(self.bases_) 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/transformers/splines/multivariate/thin_plate.py b/pretab/expansion/spline/multivariate/thin_plate.py
similarity index 89%
rename from pretab/transformers/splines/multivariate/thin_plate.py
rename to pretab/expansion/spline/multivariate/thin_plate.py
index 0fdfa55..a9aaa84 100644
--- a/pretab/transformers/splines/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/transformers/splines/natural_cubic.py b/pretab/expansion/spline/natural_cubic.py
similarity index 87%
rename from pretab/transformers/splines/natural_cubic.py
rename to pretab/expansion/spline/natural_cubic.py
index a76e202..21c3f45 100644
--- a/pretab/transformers/splines/natural_cubic.py
+++ b/pretab/expansion/spline/natural_cubic.py
@@ -1,8 +1,10 @@
import numpy as np
+from scipy.integrate import trapezoid
from sklearn.base import BaseEstimator, TransformerMixin
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
@@ -61,7 +63,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.
@@ -72,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
@@ -81,10 +90,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.
@@ -134,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
@@ -145,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)
@@ -192,7 +199,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 +207,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
@@ -210,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)
@@ -237,8 +245,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))
@@ -247,6 +257,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] = trapezoid(integrand, x_grid)
return P
diff --git a/pretab/transformers/splines/p_spline.py b/pretab/expansion/spline/p_spline.py
similarity index 82%
rename from pretab/transformers/splines/p_spline.py
rename to pretab/expansion/spline/p_spline.py
index 022047f..8328484 100644
--- a/pretab/transformers/splines/p_spline.py
+++ b/pretab/expansion/spline/p_spline.py
@@ -2,26 +2,13 @@
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 ...core.policy import RepresentationPolicy, resolve_out_of_range
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.
@@ -54,6 +41,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
@@ -79,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
@@ -127,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,
@@ -138,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
@@ -147,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)
@@ -162,6 +160,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_ = []
@@ -178,6 +178,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)
@@ -197,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/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/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/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/placement/resolution.py b/pretab/placement/resolution.py
index d7a8167..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",
]
@@ -102,14 +101,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})."
@@ -120,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(
@@ -144,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/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 96%
rename from pretab/transformers/encoders/floats.py
rename to pretab/preprocessing/floats.py
index e9a73ca..d6cdb0d 100644
--- a/pretab/transformers/encoders/floats.py
+++ b/pretab/preprocessing/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/preprocessing/missing.py
similarity index 97%
rename from pretab/transformers/encoders/missing.py
rename to pretab/preprocessing/missing.py
index ed98d09..bc6d6d1 100644
--- a/pretab/transformers/encoders/missing.py
+++ b/pretab/preprocessing/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/preprocessor.py b/pretab/preprocessor.py
index 34fcecf..a8193b8 100644
--- a/pretab/preprocessor.py
+++ b/pretab/preprocessor.py
@@ -1,5 +1,4 @@
import hashlib
-import inspect
import json
import os
import time
@@ -22,9 +21,16 @@
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,
+ 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
from .core.policy import RepresentationPolicy, apply_constant_policy
from .exceptions import (
ConfigWarning,
@@ -39,30 +45,69 @@
#: 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`. ``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`.
+_PRESET_PARAM_DEFAULTS = {
+ "numerical_method": "ple",
+ "categorical_method": "int",
+ "output_dim": 7,
+ "adaptive": False,
+ "min_output_dim": 7,
+ "max_output_dim": 10,
+}
+
+
+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.
@@ -93,9 +138,10 @@ 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
- ``"none"``) to leave categorical columns unchanged.
+ ``"onehot_from_ordinal"`` (one-hot from an already integer-coded column; raises if the
+ 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.
@@ -106,6 +152,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.
@@ -123,16 +172,17 @@ 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.
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.
- min_output_dim : int, default=5
+ 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=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
@@ -166,8 +216,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``
@@ -206,6 +257,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
@@ -235,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
----------
@@ -267,21 +328,23 @@ 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"``, ``"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
``"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 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
--------
@@ -293,8 +356,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:
@@ -313,28 +376,35 @@ 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__(
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,
@@ -348,6 +418,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,
@@ -384,6 +455,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
@@ -408,6 +480,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)
@@ -439,6 +516,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)
@@ -446,11 +534,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,
@@ -466,10 +550,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(
@@ -480,15 +560,40 @@ 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.
+ 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:
+ 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,
)
@@ -501,7 +606,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.
@@ -510,17 +615,23 @@ 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.
- return_array : bool, default=False
- If True, return a single stacked NumPy array. If False, return a dict of transformed arrays.
+ External embeddings to attach to dictionary output. Required when
+ 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)
@@ -530,30 +641,35 @@ 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 resolved_return_array else "dict")
+ validate_embedding_request(embeddings, expected=self.embeddings_, output_kind=output_kind)
+
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)
fmt, self.output_report_ = compute_output_report(transformed_X, self.output_format)
- container = _get_output_config("transform", self)["dense"]
if container in ("pandas", "polars"):
return to_dataframe_output(transformed_X, self.get_feature_names_out(), container)
- slices = None if return_array else get_output_slices(self.column_transformer_, X)
+ 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_,
+ embedding_dimensions=self.embedding_dimensions_,
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.
@@ -565,8 +681,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
-------
@@ -576,27 +693,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__,
@@ -605,11 +719,11 @@ 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
+ if params.get("numerical_method", UNSET) is UNSET:
+ resolved["numerical_method"] = _preset_numerical_method(resolved["task"])
return resolved
def get_resolved_config(self):
@@ -709,8 +823,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``.
@@ -874,7 +993,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.
@@ -882,8 +1001,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
----------
@@ -929,16 +1050,23 @@ 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)
+ # 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
@@ -949,7 +1077,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)
@@ -983,7 +1111,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``."""
@@ -1002,7 +1130,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/pretab/py.typed b/pretab/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/pretab/transformers/__init__.py b/pretab/transformers/__init__.py
index c68964c..6a1d593 100644
--- a/pretab/transformers/__init__.py
+++ b/pretab/transformers/__init__.py
@@ -1,24 +1,21 @@
-from .categorical import (
+from ..embedding import LanguageEmbeddingTransformer
+from ..encoding.categorical import (
ContinuousOrdinalTransformer,
- LanguageEmbeddingTransformer,
OneHotFromOrdinalTransformer,
)
-from .encoders import MissingStateIndicator, NoTransformer, ToFloatTransformer
-from .feature_maps import (
+from ..encoding.numerical import (
+ NumericBinningTransformer,
+ PeriodicEncodingTransformer,
+ PLETransformer,
+)
+from ..expansion.functional import (
FourierFeatureTransformer,
- NystroemFeaturesTransformer,
- RandomFourierFeaturesTransformer,
RBFExpansionTransformer,
ReLUExpansionTransformer,
SigmoidExpansionTransformer,
TanhExpansionTransformer,
)
-from .numerical import (
- NumericBinningTransformer,
- PeriodicEncodingTransformer,
- PLETransformer,
-)
-from .splines import (
+from ..expansion.spline import (
BSplineTransformer,
CubicRegressionSplineTransformer,
ISplineTransformer,
@@ -28,6 +25,11 @@
TensorProductSplineTransformer,
ThinPlateSplineTransformer,
)
+from ..kernel_approximation import (
+ NystroemFeaturesTransformer,
+ RandomFourierFeaturesTransformer,
+)
+from ..preprocessing import MissingStateIndicator, NoTransformer, ToFloatTransformer
__all__ = [
"BSplineTransformer",
diff --git a/pretab/transformers/categorical/__init__.py b/pretab/transformers/categorical/__init__.py
deleted file mode 100644
index bd7d010..0000000
--- a/pretab/transformers/categorical/__init__.py
+++ /dev/null
@@ -1,14 +0,0 @@
-"""Categorical transformers: ordinal encoding, language embeddings and the
-time-boxed legacy one-hot-from-ordinal encoder. Modules are moved here during the
-1.0.0 restructure (Phase 1).
-"""
-
-from .language_embedding import LanguageEmbeddingTransformer
-from .legacy import OneHotFromOrdinalTransformer
-from .ordinal import ContinuousOrdinalTransformer
-
-__all__ = [
- "ContinuousOrdinalTransformer",
- "LanguageEmbeddingTransformer",
- "OneHotFromOrdinalTransformer",
-]
diff --git a/pretab/transformers/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",
-]
diff --git a/pretab/transformers/feature_maps/__init__.py b/pretab/transformers/feature_maps/__init__.py
deleted file mode 100644
index 41105fd..0000000
--- a/pretab/transformers/feature_maps/__init__.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from .fourier import FourierFeatureTransformer
-from .kernel_approx import NystroemFeaturesTransformer, RandomFourierFeaturesTransformer
-from .rbf import RBFExpansionTransformer
-from .relu import ReLUExpansionTransformer
-from .sigmoid import SigmoidExpansionTransformer
-from .tanh import TanhExpansionTransformer
-
-__all__ = [
- "FourierFeatureTransformer",
- "NystroemFeaturesTransformer",
- "RBFExpansionTransformer",
- "RandomFourierFeaturesTransformer",
- "ReLUExpansionTransformer",
- "SigmoidExpansionTransformer",
- "TanhExpansionTransformer",
-]
diff --git a/pretab/transformers/numerical/__init__.py b/pretab/transformers/numerical/__init__.py
deleted file mode 100644
index f00d588..0000000
--- a/pretab/transformers/numerical/__init__.py
+++ /dev/null
@@ -1,14 +0,0 @@
-"""Numerical single-column transformers: binning, piecewise-linear encoding (PLE)
-and periodic encoding. Modules are moved here during the 1.0.0 restructure (Phase 1)
-and renamed to their intention-revealing public names in Phase 5.
-"""
-
-from .binning import NumericBinningTransformer
-from .periodic import PeriodicEncodingTransformer
-from .piecewise import PLETransformer
-
-__all__ = [
- "NumericBinningTransformer",
- "PLETransformer",
- "PeriodicEncodingTransformer",
-]
diff --git a/pyproject.toml b/pyproject.toml
index a333855..b922f01 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.0rc5"
+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"
@@ -14,12 +15,15 @@ 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"
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" }]
@@ -31,7 +35,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]
@@ -81,6 +85,7 @@ filterwarnings = [
# code quality tools
[tool.pyright]
include = ["pretab", "tests"]
+reportImplicitOverride = false
exclude = [
"**/__pycache__",
".venv",
@@ -149,6 +154,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
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)
diff --git a/tests/compose/test_feature_detection.py b/tests/compose/test_feature_detection.py
index 74bbd78..670e8c4 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=pd.Index(["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)
@@ -46,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)
diff --git a/tests/compose/test_inspection.py b/tests/compose/test_inspection.py
index 5741c93..1921e62 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]
@@ -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}}
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/compose/test_output.py b/tests/compose/test_output.py
index b0f0be4..5d583ff 100644
--- a/tests/compose/test_output.py
+++ b/tests/compose/test_output.py
@@ -3,8 +3,8 @@
import numpy as np
import pytest
-from pretab.compose.output import attach_embeddings, build_output_dict, format_output
-from pretab.exceptions import IncompatibleParamsError
+from pretab.compose.output import attach_embeddings, build_output_dict, format_output, resolve_embedding_dimensions
+from pretab.exceptions import IncompatibleParamsError, PretabDataError
def test_build_output_dict_slices_by_span():
@@ -33,6 +33,44 @@ 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_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
@@ -56,3 +94,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/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):
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 #
# --------------------------------------------------------------------------- #
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
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)
diff --git a/tests/core/test_location_selectors.py b/tests/core/test_location_selectors.py
index 3ed334b..e1e6e7f 100644
--- a/tests/core/test_location_selectors.py
+++ b/tests/core/test_location_selectors.py
@@ -114,3 +114,49 @@ 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)"
+ )
+
+
+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}"
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])
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/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):
diff --git a/tests/transformers/test_language_embedding_transformer.py b/tests/embedding/test_language_embedding_transformer.py
similarity index 60%
rename from tests/transformers/test_language_embedding_transformer.py
rename to tests/embedding/test_language_embedding_transformer.py
index a623ba1..9142e3c 100644
--- a/tests/transformers/test_language_embedding_transformer.py
+++ b/tests/embedding/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
@@ -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,39 @@ 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(
+ "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)
+ # 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):
@@ -91,3 +125,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
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]
diff --git a/tests/transformers/test_onehot_from_ordinal_transformer.py b/tests/encoding/categorical/test_onehot_from_ordinal_transformer.py
similarity index 69%
rename from tests/transformers/test_onehot_from_ordinal_transformer.py
rename to tests/encoding/categorical/test_onehot_from_ordinal_transformer.py
index 6e85723..8156471 100644
--- a/tests/transformers/test_onehot_from_ordinal_transformer.py
+++ b/tests/encoding/categorical/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)
diff --git a/tests/transformers/test_custombin_transformer.py b/tests/encoding/numerical/test_custombin_transformer.py
similarity index 86%
rename from tests/transformers/test_custombin_transformer.py
rename to tests/encoding/numerical/test_custombin_transformer.py
index df365f3..4c7cf04 100644
--- a/tests/transformers/test_custombin_transformer.py
+++ b/tests/encoding/numerical/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))
diff --git a/tests/transformers/test_periodic.py b/tests/encoding/numerical/test_periodic.py
similarity index 85%
rename from tests/transformers/test_periodic.py
rename to tests/encoding/numerical/test_periodic.py
index 70a155e..e0a7270 100644
--- a/tests/transformers/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)
diff --git a/tests/transformers/test_ple_transformer.py b/tests/encoding/numerical/test_ple_transformer.py
similarity index 61%
rename from tests/transformers/test_ple_transformer.py
rename to tests/encoding/numerical/test_ple_transformer.py
index bd2baa6..e5c7cfa 100644
--- a/tests/transformers/test_ple_transformer.py
+++ b/tests/encoding/numerical/test_ple_transformer.py
@@ -140,5 +140,61 @@ 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")
+
+
+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/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 62%
rename from tests/transformers/test_cubic_transformer.py
rename to tests/expansion/spline/test_cubic_transformer.py
index a0f2eeb..925f21c 100644
--- a/tests/transformers/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)
@@ -80,3 +105,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/expansion/spline/test_naturalcubic_transformer.py
similarity index 64%
rename from tests/transformers/test_naturalcubic_transformer.py
rename to tests/expansion/spline/test_naturalcubic_transformer.py
index 5dee3e7..ccd4269 100644
--- a/tests/transformers/test_naturalcubic_transformer.py
+++ b/tests/expansion/spline/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()
@@ -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)
@@ -79,3 +95,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/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]
diff --git a/tests/transformers/test_pspline_transformer.py b/tests/expansion/spline/test_pspline_transformer.py
similarity index 66%
rename from tests/transformers/test_pspline_transformer.py
rename to tests/expansion/spline/test_pspline_transformer.py
index 8036e75..c7ebac1 100644
--- a/tests/transformers/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)
@@ -81,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/transformers/test_spline_api_parity.py b/tests/expansion/spline/test_spline_api_parity.py
similarity index 96%
rename from tests/transformers/test_spline_api_parity.py
rename to tests/expansion/spline/test_spline_api_parity.py
index 86f8e75..53d170f 100644
--- a/tests/transformers/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/transformers/test_spline_expansions.py b/tests/expansion/spline/test_spline_expansions.py
similarity index 82%
rename from tests/transformers/test_spline_expansions.py
rename to tests/expansion/spline/test_spline_expansions.py
index 7fa7431..a55d9ca 100644
--- a/tests/transformers/test_spline_expansions.py
+++ b/tests/expansion/spline/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)
diff --git a/tests/expansion/spline/test_tensorproduct_transformer.py b/tests/expansion/spline/test_tensorproduct_transformer.py
new file mode 100644
index 0000000..617b9a5
--- /dev/null
+++ b/tests/expansion/spline/test_tensorproduct_transformer.py
@@ -0,0 +1,139 @@
+import numpy as np
+import pytest
+from sklearn.exceptions import NotFittedError
+
+from pretab.exceptions import InvalidParamError
+from pretab.transformers import TensorProductSplineTransformer
+
+
+def test_tensorproduct_spline_output_shape():
+ X = np.random.rand(20, 2)
+ transformer = TensorProductSplineTransformer(output_dim=4)
+ Xt = transformer.fit_transform(X)
+
+ 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)
+ assert transformer.total_output_dim_ == 4**2
+ assert np.isfinite(Xt).all()
+
+
+def test_tensorproduct_spline_output_consistency():
+ X = np.random.rand(30, 2)
+ transformer = TensorProductSplineTransformer(output_dim=5)
+ transformer.fit(X)
+ Xt1 = transformer.transform(X)
+ Xt2 = transformer.fit_transform(X)
+
+ np.testing.assert_allclose(Xt1, Xt2, rtol=1e-5)
+
+
+def test_tensorproduct_spline_penalty_matrices():
+ X = np.random.rand(25, 2)
+ transformer = TensorProductSplineTransformer(output_dim=4)
+ transformer.fit(X)
+ penalties = transformer.get_penalty_matrices()
+
+ assert len(penalties) == 2
+ for P in penalties:
+ assert P.shape[0] == P.shape[1]
+ 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}"
+
+
+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))
+ 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)
+ Xt = transformer.fit_transform(X)
+
+ names = transformer.get_feature_names_out(["a", "b"])
+ assert len(names) == Xt.shape[1]
+ assert names[0] == "tp_a0_b0"
+ assert all(name.startswith("tp_") for name in names)
+
+
+def test_tensorproduct_feature_names_out_default_input():
+ X = np.random.rand(15, 2)
+ transformer = TensorProductSplineTransformer(output_dim=4).fit(X)
+
+ names = transformer.get_feature_names_out()
+ n_expected = transformer.marginal_sizes_[0] * transformer.marginal_sizes_[1]
+ assert len(names) == n_expected
+ assert names[0].startswith("tp_")
+
+
+def test_tensorproduct_allow_nan_tag():
+ tags = TensorProductSplineTransformer().__sklearn_tags__()
+ assert tags.input_tags.allow_nan is True
+
+
+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))
diff --git a/tests/transformers/test_thinplate_transformer.py b/tests/expansion/spline/test_thinplate_transformer.py
similarity index 81%
rename from tests/transformers/test_thinplate_transformer.py
rename to tests/expansion/spline/test_thinplate_transformer.py
index 8b2eb67..5d4c9d8 100644
--- a/tests/transformers/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))
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"):
diff --git a/tests/integration/test_adaptive_output_dim.py b/tests/integration/test_adaptive_output_dim.py
index f589127..3721e13 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.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
-from pretab.transformers.splines.b_spline import BSplineTransformer
-from pretab.transformers.splines.i_spline import ISplineTransformer
-from pretab.transformers.splines.m_spline import MSplineTransformer
OUTPUT_DIM = 6
@@ -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.
@@ -96,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]
@@ -139,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_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/integration/test_missing_policy.py b/tests/integration/test_missing_policy.py
index 974176d..c7ec70e 100644
--- a/tests/integration/test_missing_policy.py
+++ b/tests/integration/test_missing_policy.py
@@ -161,9 +161,104 @@ 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 ----------------------------------------------------------------
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 = np.asarray(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)
+ 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
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)
diff --git a/tests/integration/test_output_format.py b/tests/integration/test_output_format.py
index 8e5ef0f..abe2613 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())
@@ -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 isinstance(out, sp.csr_matrix)
+ assert out.shape == (100, 100)
+ assert out.nnz == 100
+ assert p.output_report_["density"] == pytest.approx(0.01)
+
+
# --- auto ----------------------------------------------------------------------
@@ -149,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())
@@ -157,28 +179,58 @@ 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_
-def test_set_output_default_still_dict(frame, y):
- p = _bspline().fit(frame, y).set_output(transform="default")
+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)
+ 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)
+ p.set_output(transform="default")
out = p.transform(frame)
assert isinstance(out, dict)
def test_set_output_polars_without_polars_raises(frame, y):
- import importlib.util
-
- p = _bspline().fit(frame, y).set_output(transform="polars")
- if importlib.util.find_spec("polars") is None:
- with pytest.raises(OptionalDependencyError):
- p.transform(frame)
- else:
- out = p.transform(frame)
- assert out.shape == (len(frame), p.total_output_dim_)
+ # 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):
+ 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)
+ p.set_output(transform="polars")
+ out = p.transform(frame)
+
+ assert isinstance(out, pl.DataFrame)
+ 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 ----------------------------------------------------------------
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 15b4219..df9d224 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, InvalidParamError, PretabDataError
from pretab.preprocessor import Preprocessor # Adjust the import as needed
@@ -22,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())
@@ -52,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)
@@ -61,13 +71,67 @@ 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
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)
+ 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()
@@ -80,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()]
@@ -89,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_"]
@@ -125,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",
@@ -202,7 +267,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
@@ -210,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
@@ -278,3 +343,37 @@ 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)
+
+
+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"):
+ Preprocessor(feature_preprocessing={"nnum1": "minmax"}).fit(X, y)
diff --git a/tests/integration/test_presets.py b/tests/integration/test_presets.py
index 5f6d824..827e8a3 100644
--- a/tests/integration/test_presets.py
+++ b/tests/integration/test_presets.py
@@ -30,31 +30,57 @@ 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():
+ # 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 10 must not apply
def test_preset_is_preserved_by_get_params_and_clone():
diff --git a/tests/integration/test_public_api.py b/tests/integration/test_public_api.py
index 5b3b93e..095f723 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")
@@ -41,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()
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)
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")
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
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
# --------------------------------------------------------------------------- #
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 f8bb231..bad52ff 100644
--- a/tests/regression/_golden/ple_supervised.json
+++ b/tests/regression/_golden/ple_supervised.json
@@ -1,24 +1,21 @@
{
- "shape": [
- 200,
- 23
- ],
+ "shape": [200, 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/regression/_golden/ple_supervised.npz b/tests/regression/_golden/ple_supervised.npz
index 99c8b17..913f440 100644
Binary files a/tests/regression/_golden/ple_supervised.npz and b/tests/regression/_golden/ple_supervised.npz differ
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",
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_tensorproduct_transformer.py b/tests/transformers/test_tensorproduct_transformer.py
deleted file mode 100644
index 9a5e31c..0000000
--- a/tests/transformers/test_tensorproduct_transformer.py
+++ /dev/null
@@ -1,73 +0,0 @@
-import numpy as np
-import pytest
-from sklearn.exceptions import NotFittedError
-
-from pretab.transformers import TensorProductSplineTransformer
-
-
-def test_tensorproduct_spline_output_shape():
- X = np.random.rand(20, 2)
- 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]
- 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)
- assert transformer.total_output_dim_ == 4**2
- assert np.isfinite(Xt).all()
-
-
-def test_tensorproduct_spline_output_consistency():
- X = np.random.rand(30, 2)
- transformer = TensorProductSplineTransformer(output_dim=5)
- transformer.fit(X)
- Xt1 = transformer.transform(X)
- Xt2 = transformer.fit_transform(X)
-
- np.testing.assert_allclose(Xt1, Xt2, rtol=1e-5)
-
-
-def test_tensorproduct_spline_penalty_matrices():
- X = np.random.rand(25, 2)
- transformer = TensorProductSplineTransformer(output_dim=4)
- transformer.fit(X)
- penalties = transformer.get_penalty_matrices()
-
- assert len(penalties) == 2
- for P in penalties:
- assert P.shape[0] == P.shape[1]
- assert np.allclose(P, P.T, atol=1e-6)
-
-
-def test_tensorproduct_feature_names_out():
- X = np.random.rand(20, 2)
- transformer = TensorProductSplineTransformer(output_dim=4)
- Xt = transformer.fit_transform(X)
-
- names = transformer.get_feature_names_out(["a", "b"])
- assert len(names) == Xt.shape[1]
- assert names[0] == "tp_a0_b0"
- assert all(name.startswith("tp_") for name in names)
-
-
-def test_tensorproduct_feature_names_out_default_input():
- X = np.random.rand(15, 2)
- 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]
- assert len(names) == n_expected
- assert names[0].startswith("tp_")
-
-
-def test_tensorproduct_allow_nan_tag():
- tags = TensorProductSplineTransformer().__sklearn_tags__()
- assert tags.input_tags.allow_nan is True
-
-
-def test_tensorproduct_transform_requires_fit():
- transformer = TensorProductSplineTransformer()
- with pytest.raises(NotFittedError):
- transformer.transform(np.random.rand(5, 2))