diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..766dca8
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,279 @@
+name: CI
+
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+
+concurrency:
+ group: ci-${{ github.head_ref || github.sha }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ lint:
+ name: Lint (ruff)
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.10"
+
+ - name: Install Poetry
+ run: pipx install poetry
+
+ - name: Configure Poetry
+ run: poetry config virtualenvs.in-project true
+
+ - name: Cache virtualenv
+ uses: actions/cache@v4
+ with:
+ path: .venv
+ key: venv-lint-${{ runner.os }}-3.10-${{ hashFiles('poetry.lock') }}
+
+ - name: Install dependencies
+ run: poetry install
+
+ - name: Run ruff check
+ run: poetry run ruff check .
+
+ - name: Run ruff format check
+ run: poetry run ruff format --check .
+
+ typecheck:
+ name: Type check (pyright)
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.10"
+
+ - name: Install Poetry
+ run: pipx install poetry
+
+ - name: Configure Poetry
+ run: poetry config virtualenvs.in-project true
+
+ - name: Cache virtualenv
+ uses: actions/cache@v4
+ with:
+ path: .venv
+ key: venv-typecheck-${{ runner.os }}-3.10-${{ hashFiles('poetry.lock') }}
+
+ - name: Install dependencies
+ run: poetry install
+
+ - name: Run pyright
+ run: poetry run pyright
+
+ build:
+ name: Build package
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.10"
+
+ - name: Install Poetry
+ run: pipx install poetry
+
+ - name: Configure Poetry
+ run: poetry config virtualenvs.in-project true
+
+ - name: Cache virtualenv
+ uses: actions/cache@v4
+ with:
+ path: .venv
+ key: venv-build-${{ runner.os }}-3.10-${{ hashFiles('poetry.lock') }}
+
+ - name: Install dependencies
+ run: poetry install --only main
+
+ - name: Build package
+ run: poetry build
+
+ - name: Check package (twine)
+ run: |
+ python -m pip install --upgrade twine
+ twine check dist/*
+
+ tests:
+ name: Tests (Python ${{ matrix.python-version }}, ${{ matrix.os }})
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, windows-latest, macos-latest]
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install Poetry
+ run: pipx install poetry
+
+ - name: Configure Poetry
+ run: poetry config virtualenvs.in-project true
+
+ - name: Cache virtualenv
+ uses: actions/cache@v4
+ with:
+ path: .venv
+ key: venv-tests-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('poetry.lock') }}
+
+ - name: Install dependencies
+ run: poetry install
+
+ - name: Run unit tests
+ run: poetry run pytest tests/ -v
+
+ smoke:
+ name: Smoke tests (Python 3.12, ubuntu)
+ runs-on: ubuntu-latest
+ needs: lint
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Install Poetry
+ run: pipx install poetry
+
+ - name: Configure Poetry
+ run: poetry config virtualenvs.in-project true
+
+ - name: Cache virtualenv
+ uses: actions/cache@v4
+ with:
+ path: .venv
+ key: venv-smoke-${{ runner.os }}-3.12-${{ hashFiles('poetry.lock') }}
+
+ - name: Install dependencies
+ run: poetry install
+
+ - name: Run smoke tests
+ run: poetry run pytest tests/ -v -m smoke --tb=short
+
+ - name: Run quickstart
+ run: poetry run python scripts/quickstart.py
+
+ coverage:
+ name: Coverage
+ runs-on: ubuntu-latest
+ needs: tests
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Install Poetry
+ run: pipx install poetry
+
+ - name: Configure Poetry
+ run: poetry config virtualenvs.in-project true
+
+ - name: Cache virtualenv
+ uses: actions/cache@v4
+ with:
+ path: .venv
+ key: venv-coverage-${{ runner.os }}-3.12-${{ hashFiles('poetry.lock') }}
+
+ - name: Install dependencies
+ run: poetry install
+
+ - name: Run tests with coverage
+ run: |
+ poetry run pytest tests/ \
+ --cov=pretab \
+ --cov-branch \
+ --cov-report=term-missing \
+ --cov-report=xml:coverage.xml \
+ --cov-fail-under=90 \
+ -q
+
+ - name: Upload coverage report
+ uses: actions/upload-artifact@v4
+ with:
+ name: coverage-report
+ path: coverage.xml
+ retention-days: 30
+
+ - name: Upload to Codecov
+ uses: codecov/codecov-action@v4
+ with:
+ files: coverage.xml
+ token: ${{ secrets.CODECOV_TOKEN }}
+ fail_ci_if_error: false
+
+ optional-deps:
+ name: Optional deps (${{ matrix.extra }})
+ runs-on: ubuntu-latest
+ needs: lint
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - extra: embeddings
+ module: sentence_transformers
+ - extra: lightgbm
+ module: lightgbm
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Install Poetry
+ run: pipx install poetry
+
+ - name: Configure Poetry
+ run: poetry config virtualenvs.in-project true
+
+ - name: Cache virtualenv
+ uses: actions/cache@v4
+ with:
+ path: .venv
+ key: venv-optdeps-${{ matrix.extra }}-${{ runner.os }}-3.12-${{ hashFiles('poetry.lock') }}
+
+ - name: Install dependencies with the ${{ matrix.extra }} extra
+ run: poetry install --extras "${{ matrix.extra }}"
+
+ - name: Verify the optional dependency imports
+ run: poetry run python -c "import ${{ matrix.module }}; print('${{ matrix.module }} import OK')"
+
+ - name: Run the suite with the extra installed
+ run: poetry run pytest tests/ -q
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index d5461aa..1330fdf 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -50,6 +50,11 @@ jobs:
- name: Install package and docs dependencies
run: poetry install --with docs
+ # pandoc is required once the P14.1 notebook tutorials (nbsphinx / myst-nb)
+ # land; installing it now keeps the strict build forward-compatible.
+ - name: Install pandoc
+ run: sudo apt-get update && sudo apt-get install -y pandoc
+
- name: Build Sphinx docs
run: poetry run sphinx-build -b html docs docs/_build/html -W --keep-going
diff --git a/.gitignore b/.gitignore
index e18adda..24c912c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -185,4 +185,4 @@ post-commit
post-merge
pre-push
docs/notebooks/*
-docs/notebooks/
\ No newline at end of file
+docs/notebooks/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1b69b69..d8878f6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,8 +7,6 @@ This project adheres to [Semantic Versioning](https://semver.org/) and uses
Going forward, this file is updated automatically by `cz bump` on each release.
----
-
## Unreleased
> **Note:** `0.1.0` is an internal development marker for the pre-1.0 restructure
@@ -20,6 +18,17 @@ Going forward, this file is updated automatically by `cz bump` on each release.
### Feat
+- **extension**: add a public, discoverable extension protocol: a `BaseRepresentation` base class (declaring `representation_name` / `feature_kind` / `scope` / `supervision`) that inherits the shared scikit-learn contract; `register_representation(name, cls)` plus opt-in `load_entry_point_representations()` (the `pretab.representations` entry-point group) to add third-party methods so they are selectable via `Preprocessor(numerical_method=...)`; `list_representations(feature_kind=, scope=, supervised=, periodic=, sparse_output=, adaptive=)` capability discovery; and a `check_representation(cls)` conformance suite (raising the new `RepresentationConformanceError`) verifying fit-returns-self, no input mutation, stable shape, matching feature names, determinism, fitted-state checks, and declared scope/supervision. `Preprocessor` gains transparent `preset="standard"|"expanded"|"adaptive"` aliases and `get_resolved_config()`; `TransformerSpec` gains `periodic` / `sparse_output` capability flags. A runnable sibling example lives at `examples/pretab-chebyshev` (all new symbols exported from `pretab`)
+- **serialize**: add portable, versioned serialization to `Preprocessor` (`to_spec` / `from_spec`) that captures a fitted preprocessor as a schema- and dependency-versioned JSON document and reconstructs it bit-for-bit, an auditable, allow-listed alternative to `pickle` that never executes estimator code on load; add a stable cross-process `fingerprint_` (sha256 over the resolved config, seeds, versions, output order, and fitted state) with a `reproducibility_report()`; and add an immutable lifecycle (`lifecycle_state_` ∈ `UNFITTED` / `FITTED` / `FROZEN` / `STALE`, `freeze` / `is_frozen` / `mark_stale` / `clone_unfitted` / `refit`) where `set_params` on a frozen preprocessor raises the new `PretabSerializationError` / `FrozenRepresentationError` (both exported from `pretab`)
+- **missing**: add a high-level `Preprocessor(missing_policy=...)` control (`error` / `propagate` / `impute` / `impute_with_indicator` / `separate_state`) that overrides the low-level imputation parameters; `separate_state` emits a dedicated `__missing` column (new `MissingStateIndicator`, wired through a per-column `FeatureUnion`) that stays outside the ordinary representation basis, and `error` rejects missing input at fit/transform; pin the end-to-end edge-case behaviour (constant features, `custombin` determinism, duplicate support points, missing values, unseen categories) in `tests/regression/test_edge_cases.py`
+- **output**: add output-budget controls to `Preprocessor` (`max_output_features`, `max_features_per_input`, `max_dense_memory`, `overflow_policy`, plus `estimate_output_shape` / `estimate_memory`, raising the new `OutputBudgetError`) and first-class output-format control (`output_format ∈ {auto, dense, sparse}`, `dtype`, an `output_report_` memory report, and `set_output(transform="pandas"|"polars")` DataFrame wrapping); defaults (`dense`, no budgets) reproduce historical behaviour
+- **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
- update default output_dim
- unsupervised feature-map default
- wire custombin output_dim
@@ -28,7 +37,8 @@ Going forward, this file is updated automatically by `cz bump` on each release.
- **pipeline**: use selector and adaptive setting to splines
- **pipeline**: accept preprocessing method name variations
- **preprocessor**: expose total_output_dim_, output_dims_ attribute
-- **preprocessor**: add random_state, handle_missing parameters
+- **preprocessor**: add random_state parameter
+- **preprocessor**: add numerical_imputation / categorical_imputation / add_missing_indicator parameters (replacing handle_missing)
- **sklearn-compat**: enforce n_features consistency, fix mixin order/tags
- **exceptions**: route all raises through typed exceptions
- **logging**: add verbose level, route warnings
@@ -69,6 +79,13 @@ Going forward, this file is updated automatically by `cz bump` on each release.
### Refactor
+- **preprocessor**: collapse the duplicated feature name in `Preprocessor` output column names (`get_feature_names_out()`, `return_array=True`, `set_output(transform="pandas"|"polars")`, and `get_feature_lineage()`); a column previously named `num_annual_income__annual_income_ncs0` is now `num_annual_income_ncs0`. Dict-mode output keys (`num_
` / `cat_`) and standalone transformer usage outside `Preprocessor` are unaffected
+- **splines**: reformulate `ThinPlateSplineTransformer` as a multivariate low-rank thin-plate regression spline (landmark selection + eigen/Nyström basis via `n_components` / `landmark_strategy` / `rank_strategy`, replacing the univariate `output_dim` form)
+- **transformers**: rename `CustomBinTransformer` → `NumericBinningTransformer`, `CyclicalTimeTransformer` → `PeriodicEncodingTransformer`, and `CubicSplineTransformer` → `CubicRegressionSplineTransformer` (intention-revealing public names)
+- **transformers**: remove `LagFeatureTransformer` and `RollingStatsTransformer` (row-count-changing time-series utilities outside the tabular scope)
+- **splines**: restrict `PSplineTransformer` to `placement_strategy="uniform"` (penalized splines require equally-spaced knots)
+- **compose**: exclude the multivariate `tensorspline` / `tprs` methods from the per-column `Preprocessor` whitelist (they remain available as standalone transformers)
+- **categorical**: deprecate `OneHotFromOrdinalTransformer` (use the `"one-hot"` categorical method backed by scikit-learn's `OneHotEncoder`)
- consistent param order
- remove dead selection helpers
- **ple**: use location selectors for thresholds
@@ -97,3 +114,7 @@ Going forward, this file is updated automatically by `cz bump` on each release.
- Adopted a Poetry + OIDC release pipeline publishing to PyPI (`v*.*.*`) and TestPyPI (`v*.*.*rc*`), plus a manual `build-check` dry-run workflow
- Added a `justfile` and pre-commit configuration for the local development workflow
- Added project meta documentation: `CHANGELOG.md`, `CONVENTIONAL_COMMITS.md`, and `CODE_OF_CONDUCT.md`
+- Drove `pyright` to zero errors across the package and test suite and promoted the CI `typecheck` job from advisory to required
+- Hardened `ci.yml` with an `optional-deps` job that installs the `embeddings` and `lightgbm` extras and runs the suite against each, and wired a `--cov-fail-under=90` gate into the coverage job
+- Added `scripts/quickstart.py`, a runnable, CI-gated smoke test covering mixed-type preprocessing, feature lineage, leakage-safe cross-fitting, sklearn `Pipeline` compatibility, serialization round-trips, and representation discovery (`just quickstart`)
+- Added root `CONTRIBUTING.md` and `SECURITY.md` so GitHub surfaces the contributor guide and a private vulnerability-reporting channel
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..8b876d2
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,25 @@
+# Contributing
+
+Thanks for your interest in contributing to PreTab.
+
+The full contributor guide, covering environment setup, the local development
+workflow, and what a pull request needs to pass review, lives in the
+documentation:
+
+**[Contributing Guide](https://pretab.readthedocs.io/en/latest/developer_guide/contributing.html)**
+
+Quick start:
+
+```bash
+git clone https://github.com/OpenTabular/PreTab
+cd PreTab
+just install
+just test
+just check
+```
+
+All contributors are expected to follow the [Code of Conduct](CODE_OF_CONDUCT.md).
+
+> **Note:** Report bugs and request features on the
+> [issue tracker](https://github.com/OpenTabular/PreTab/issues). Security
+> vulnerabilities should be reported privately; see [SECURITY.md](SECURITY.md).
diff --git a/README.md b/README.md
index cd0eada..1af32fc 100644
--- a/README.md
+++ b/README.md
@@ -11,20 +11,25 @@
[📘 Documentation](https://pretab.readthedocs.io) |
[🚀 Getting Started](https://pretab.readthedocs.io/en/latest/getting_started/quickstart.html) |
-[📖 User Guide](https://pretab.readthedocs.io/en/latest/user_guide/preprocessing.html) |
+[📖 Representations](https://pretab.readthedocs.io/en/latest/representations/overview.html) |
[🤔 Report Issues](https://github.com/OpenTabular/PreTab/issues)
# PreTab: Tabular Preprocessing Made Simple
-**PreTab** is a modular, scikit-learn compatible preprocessing library for tabular data. A
-single `Preprocessor` detects numerical and categorical columns and turns them into
-model-ready features, and every strategy is also available as a standalone transformer:
-splines, neural basis expansions, piecewise-linear encoding, binning, language embeddings,
-and temporal features. Because it speaks the sklearn API, PreTab drops straight into
-`Pipeline` and `ColumnTransformer` workflows and accepts any sklearn transformer alongside
-its own.
+**PreTab** is a modular, scikit-learn compatible representation and preprocessing library
+for tabular data. A single `Preprocessor` detects numerical and categorical columns and
+turns them into model-ready features. Every strategy it uses (splines, neural basis
+expansions, piecewise-linear encoding, binning, kernel approximations, and language
+embeddings) is also available as a standalone transformer. Because it speaks the sklearn
+API, PreTab drops straight into `Pipeline` and `ColumnTransformer` workflows and accepts any
+sklearn transformer alongside its own.
+
+Beyond the transformers themselves, every fitted representation is self-describing: it
+reports per-output-column lineage, guards supervised methods against leakage, serializes to
+a portable versioned spec, and can be extended with your own representations through a
+public, discoverable protocol.
## Why PreTab?
@@ -33,12 +38,22 @@ its own.
inputs.
- **Automatic feature handling.** Feature-type detection and per-feature strategies let you
describe intent once instead of wiring transformers by hand.
-- **Beyond scaling.** Spline bases, neural basis maps, and piecewise-linear encoding turn
- raw numerical columns into expressive representations.
-- **Categoricals done right.** Ordinal and one-hot encoding, pretrained language
- embeddings, and custom binning cover both low- and high-cardinality columns.
+- **Beyond scaling.** Spline bases, neural basis maps, piecewise-linear encoding, and
+ kernel approximations turn raw numerical columns into expressive representations.
+- **Categoricals done right.** Ordinal and one-hot encoding and pretrained language
+ embeddings cover both low- and high-cardinality columns.
+- **Self-describing and reproducible.** Every fit produces per-column feature lineage and
+ serializes to a portable spec with a stable fingerprint, so you always know what a fitted
+ preprocessor does and can reproduce it exactly.
+- **Leakage-safe by default.** Supervised representations declare their target usage and
+ warn when fit outside a controlled context, with a cross-fitting wrapper for out-of-fold
+ training features.
- **Composable and extensible.** Every strategy is a standalone transformer you can import,
- compose, or subclass, and any sklearn transformer works out of the box.
+ compose, or subclass; register your own representation and it behaves like a built-in.
+
+> **Tip:** See
+> [how this compares to scikit-learn's own preprocessing transformers](https://pretab.readthedocs.io/en/latest/getting_started/overview.html#how-this-compares-to-scikit-learns-preprocessing-transformers)
+> for what each one adds where scope overlaps.
## 🏃 Quickstart
@@ -64,61 +79,59 @@ print({k: v.shape for k, v in X.items()})
# {'num_age': (100, 7), 'num_income': (100, 7), 'cat_city': (100, 1)}
```
-> **That's it.** PreTab detects feature types, fits a strategy per column, and returns
-> ready-to-use arrays.
-
-> **Works with pandas and numpy.** Pass a DataFrame or an array, and PreTab infers
-> numerical vs. categorical columns for you.
+> **Note:** PreTab accepts a `pandas.DataFrame` or a `numpy.ndarray` and infers numerical
+> versus categorical columns either way.
-> **Mix strategies per column.** Swap the global methods for a `feature_preprocessing` map,
-> for example `{"age": "ple", "income": "rbf", "city": "one-hot"}`, and PreTab fits each
-> column with its own strategy in a single pass. See [Usage](#usage) for a full example.
+> **Tip:** Swap the global methods for a `feature_preprocessing` map, for example
+> `{"age": "ple", "income": "rbf", "city": "one-hot"}`, and PreTab fits each column with its
+> own strategy in a single pass. See [Usage](#usage) for a full example.
## Available Transformers
-PreTab groups its transformers into four families. Each one follows the standard `fit` /
+PreTab groups its transformers into three families. Each one follows the standard `fit` /
`transform` API and is importable from `pretab.transformers`.
### Splines
-| Transformer | Basis | Best for |
-| -------------------------------- | ---------------------------- | ------------------------------------ |
-| `CubicSplineTransformer` | B-spline basis | Smooth non-linear numerical effects |
-| `NaturalCubicSplineTransformer` | Natural cubic spline | Smooth effects with linear tails |
-| `PSplineTransformer` | Penalized B-spline | Smoothness with a penalty matrix |
-| `TensorProductSplineTransformer` | Tensor-product spline | Interactions between two features |
-| `ThinPlateSplineTransformer` | Thin-plate regression spline | Smooth multivariate surfaces |
+| Transformer | Basis | Best for |
+| ----------------------------------- | -------------------------------------- | ---------------------------------------- |
+| `BSplineTransformer` | B-spline basis | General-purpose smooth nonlinearity |
+| `MSplineTransformer` | Non-negative B-spline basis | Density-like, non-negative bases |
+| `ISplineTransformer` | Monotone integrated spline | Effects that must not reverse |
+| `CubicRegressionSplineTransformer` | Cubic regression spline | GAM-style additive smooth terms |
+| `NaturalCubicSplineTransformer` | Natural cubic spline | Smooth effects with linear tails |
+| `PSplineTransformer` | Penalized B-spline | Smoothness via a difference penalty |
+| `TensorProductSplineTransformer` | Tensor-product spline (multivariate) | Smooth interactions across 2+ features |
+| `ThinPlateSplineTransformer` | Thin-plate spline (multivariate) | Smooth surfaces across 2+ features |
### Feature maps
-| Transformer | Basis | Best for |
-| ----------------------------- | ---------------------- | ----------------------------------- |
-| `RBFExpansionTransformer` | Radial basis functions | Localized, kernel-like features |
-| `ReLUExpansionTransformer` | ReLU basis | Piecewise-linear neural features |
-| `SigmoidExpansionTransformer` | Sigmoid basis | Smooth saturating features |
-| `TanhExpansionTransformer` | Tanh basis | Zero-centered saturating features |
+| Transformer | Basis | Best for |
+| ------------------------------------ | ------------------------------------------ | ---------------------------------------- |
+| `RBFExpansionTransformer` | Radial basis functions | Localized, kernel-like features |
+| `ReLUExpansionTransformer` | ReLU basis | Piecewise-linear neural features |
+| `SigmoidExpansionTransformer` | Sigmoid basis | Smooth saturating features |
+| `TanhExpansionTransformer` | Tanh basis | Zero-centered saturating features |
+| `FourierFeatureTransformer` | Sine/cosine basis | Periodic or cyclic numerical effects |
+| `RandomFourierFeaturesTransformer` | Random Fourier features (multivariate) | Scalable RBF-kernel approximation |
+| `NystroemFeaturesTransformer` | Nystroem kernel map (multivariate) | Landmark-based kernel approximation |
### Encoding and binning
-| Transformer | Method | Best for |
-| ------------------------------ | -------------------------------------- | ------------------------------------- |
-| `PLETransformer` | Piecewise linear encoding (supervised) | Strong numerical encoding for models |
-| `CustomBinTransformer` | Rule- or tree-based binning | Discretizing numerical or code values |
-| `OneHotFromOrdinalTransformer` | One-hot from ordinal codes | One-hot on pre-encoded categoricals |
-| `LanguageEmbeddingTransformer` | Pretrained language embeddings | High-cardinality, semantic columns |
+| 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 |
-### Temporal
+> **Warning:** `OneHotFromOrdinalTransformer` is deprecated. Use
+> `categorical_method="one-hot"` (backed by `sklearn.preprocessing.OneHotEncoder`) instead.
-| Transformer | Method | Best for |
-| ------------------------- | ------------------------- | ----------------------------------- |
-| `CyclicalTimeTransformer` | Sine/cosine encoding | Hour, day, month and cyclic fields |
-| `LagFeatureTransformer` | Lagged values | Time-series lag features |
-| `RollingStatsTransformer` | Rolling window statistics | Moving averages and rolling summary |
-
-> **Strategy strings.** Inside the `Preprocessor` you select these by short name (for
-> example `"ple"`, `"rbf"`, `"one-hot"`, `"pretrained"`). See the
-> [User Guide](https://pretab.readthedocs.io/en/latest/user_guide/preprocessing.html) for
-> the full list.
+> **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).
## 📚 Documentation
@@ -127,9 +140,11 @@ PreTab groups its transformers into four families. Each one follows the standard
### Quick Links
- **[Getting Started](https://pretab.readthedocs.io/en/latest/getting_started/installation.html)**: Installation and quickstart
-- **[User Guide](https://pretab.readthedocs.io/en/latest/user_guide/preprocessing.html)**: Feature detection, strategies, and outputs
+- **[Core Concepts](https://pretab.readthedocs.io/en/latest/core_concepts/feature_representation.html)**: Configuration, resolution, target awareness, reproducibility
+- **[Representations](https://pretab.readthedocs.io/en/latest/representations/overview.html)**: The full method catalogue and how to choose one
+- **[Tutorials](https://pretab.readthedocs.io/en/latest/tutorials/nonlinear_regression.html)**: Worked, end-to-end examples
- **[API Reference](https://pretab.readthedocs.io/en/latest/api/index.html)**: The `Preprocessor` and every transformer
-- **[Developer Guide](https://pretab.readthedocs.io/en/latest/developer_guide/contributing.html)**: Contributing, versioning, and releases
+- **[Developer Guide](https://pretab.readthedocs.io/en/latest/developer_guide/contributing.html)**: Contributing, testing, and releases
## 🛠️ Installation
@@ -139,16 +154,16 @@ PreTab groups its transformers into four families. Each one follows the standard
pip install pretab
```
-**With language-embedding support:**
+**With optional extras:**
```bash
-pip install "pretab[embeddings]" # adds sentence-transformers
+pip install "pretab[embeddings]" # adds sentence-transformers, for the `pretrained` strategy
+pip install "pretab[lightgbm]" # adds lightgbm, for placement_strategy="lightgbm"
+pip install "pretab[all]" # both of the above
```
-> **Lightweight by default.** The `embeddings` extra pulls in `sentence-transformers` and
-> PyTorch, so install it only if you use the `pretrained` categorical strategy.
-
-> **Requirements:** Python 3.10 to 3.13.
+> **Note:** The core install has no heavy dependencies. Each extra is opt-in and only
+> needed if you use the corresponding strategy. PreTab requires Python 3.10 to 3.13.
**From source:**
@@ -197,8 +212,8 @@ experience numerical imputer -> minmax -> quantile 1 -
city categorical imputer -> onehot -> to_float 4 4
```
-> **Two output formats.** `transform` returns a dict of feature blocks by default (keys
-> prefixed `num_` and `cat_`), or a single stacked array when you pass `return_array=True`.
+> **Note:** `transform` returns a dict of feature blocks by default (keys prefixed `num_`
+> and `cat_`), or a single stacked array when you pass `return_array=True`.
### Standalone transformers
@@ -216,8 +231,8 @@ x_ple = PLETransformer(output_dim=15, task="regression").fit_transform(x, y)
assert x_ple.shape[1] == 15
```
-> **Some transformers are supervised.** `PLETransformer` uses the target `y` during `fit`
-> to place its bin edges, so pass `y` whenever you fit it.
+> **Important:** `PLETransformer` is supervised. It uses the target `y` during `fit` to
+> place its bin edges and raises if you omit it, so always pass `y` when fitting it directly.
### Inside an sklearn Pipeline
@@ -247,13 +262,13 @@ Spline transformers expose their penalty matrix for penalized (smoothing) models
```python
import numpy as np
-from pretab.transformers import ThinPlateSplineTransformer
+from pretab.transformers import NaturalCubicSplineTransformer
x = np.random.randn(100, 1)
-tp = ThinPlateSplineTransformer(output_dim=15)
-x_tp = tp.fit_transform(x)
-penalty = tp.get_penalty_matrix() # (output_dim, output_dim) smoothing penalty
+spline = NaturalCubicSplineTransformer(output_dim=10)
+x_spline = spline.fit_transform(x)
+penalty = spline.get_penalty_matrix() # (output_dim, output_dim) smoothing penalty
```
## Advanced Features
@@ -283,13 +298,13 @@ preprocessor = Preprocessor(
)
```
-> **Optional dependency.** Install with `pip install "pretab[embeddings]"` before using the
-> `pretrained` strategy.
+> **Note:** Install with `pip install "pretab[embeddings]"` before using the `pretrained`
+> strategy.
-### Custom binning
+### Numeric binning
-`CustomBinTransformer` supports both rule-based edges and tree-based bins learned from the
-target.
+`NumericBinningTransformer` (selected as `"custombin"`) discretizes a numerical column into
+uniformly- or quantile-spaced bins, with `ordinal`, `onehot`, or `soft` output encodings.
```python
preprocessor = Preprocessor(
@@ -298,6 +313,71 @@ preprocessor = Preprocessor(
)
```
+### Feature lineage and inspection
+
+Every fitted `Preprocessor` can explain itself. `get_feature_info` summarizes the resolved
+per-column pipeline, and `get_feature_lineage` maps every output column back to its source
+feature, representation family, and component.
+
+```python
+preprocessor.get_feature_info(verbose=True) # resolved strategies, widths, categories
+lineage = preprocessor.get_feature_lineage() # one record per output column
+```
+
+### Leakage-safe supervised representations
+
+Methods like `PLETransformer` place their bins using the target. PreTab warns when a
+supervised transformer is fit outside a `Pipeline` or cross-validation context, and ships a
+cross-fitting wrapper that produces out-of-fold training features.
+
+```python
+from pretab import CrossFittedTransformer
+from pretab.transformers import PLETransformer
+
+cf = CrossFittedTransformer(PLETransformer(), n_folds=5)
+X_train_features = cf.fit_transform(x_train, y_train) # out-of-fold, leakage-free
+```
+
+> **Warning:** Fitting a supervised transformer on the same rows you later evaluate on
+> leaks target information into the features. `CrossFittedTransformer` removes that leakage
+> from the training features themselves; inside a `Pipeline`, cross-validation already
+> keeps each fold's fit confined to its training data.
+
+### Serialization and reproducibility
+
+A fitted preprocessor serializes to a portable, versioned JSON spec, a safer alternative to
+`pickle` that never executes arbitrary code on load, and reports a stable fingerprint for
+tracking exactly what was fitted.
+
+```python
+preprocessor.to_spec("representation.json")
+restored = Preprocessor.from_spec("representation.json")
+
+preprocessor.fingerprint_ # stable sha256 hash of the fitted representation
+```
+
+### Extending PreTab
+
+Add your own representation by subclassing `BaseRepresentation`, then register it so it
+behaves like a built-in, selectable via `Preprocessor(numerical_method=...)`.
+
+```python
+from pretab import BaseRepresentation, register_representation
+
+class MyRepresentation(BaseRepresentation):
+ representation_name = "my_representation"
+ feature_kind = "numerical"
+ scope = "univariate"
+ supervision = "unsupervised"
+ # implement fit / transform / _output_sizes
+
+register_representation("my_representation", MyRepresentation)
+```
+
+> **Tip:** See the
+> [custom representation tutorial](https://pretab.readthedocs.io/en/latest/tutorials/custom_representation.html)
+> for a complete, runnable example.
+
## 📄 License
PreTab is licensed under the MIT License. See [LICENSE](./LICENSE) for details.
@@ -305,20 +385,14 @@ PreTab is licensed under the MIT License. See [LICENSE](./LICENSE) for details.
## 🤝 Contributing
Contributions are welcome, whether you are fixing bugs, adding transformers, or improving
-the docs. See the
+the docs. Clone the repository and install it in editable mode as shown in the Installation
+section above, then see the
[Contributing Guide](https://pretab.readthedocs.io/en/latest/developer_guide/contributing.html)
-to get started, and please follow our
+and our
[Code of Conduct](https://github.com/OpenTabular/PreTab/blob/main/CODE_OF_CONDUCT.md).
-```bash
-git clone https://github.com/OpenTabular/PreTab
-cd PreTab
-pip install -e ".[dev]"
-```
-
## 📞 Support
- **Issues:** [GitHub Issues](https://github.com/OpenTabular/PreTab/issues)
- **Discussions:** [GitHub Discussions](https://github.com/OpenTabular/PreTab/discussions)
-
-
+- **Security:** see [SECURITY.md](./SECURITY.md) for how to report a vulnerability privately.
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..e8e21ef
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,29 @@
+# Security Policy
+
+## Supported versions
+
+PreTab follows [Semantic Versioning](https://semver.org/). Security fixes are
+made against the latest released minor version on
+[PyPI](https://pypi.org/project/pretab/); older releases do not receive
+backported fixes.
+
+## Reporting a vulnerability
+
+Please do not open a public GitHub issue for security vulnerabilities.
+
+Report vulnerabilities privately through
+[GitHub Security Advisories](https://github.com/OpenTabular/PreTab/security/advisories/new)
+for this repository. Include:
+
+- A description of the vulnerability and its potential impact
+- Steps to reproduce, or a minimal proof of concept
+- The affected version(s) of PreTab
+
+We aim to acknowledge new reports within five business days and will work with
+you to understand and address the issue before any public disclosure.
+
+> **Note:** PreTab's most security-relevant surface is deserialization.
+> Loading a fitted preprocessor via `Preprocessor.from_spec` is designed to
+> never execute estimator code, unlike `pickle`; it reconstructs objects
+> through an allow-listed decoder over a fixed set of library modules. A
+> vulnerability that breaks this guarantee is a high-priority report.
diff --git a/docs/api/extension.rst b/docs/api/extension.rst
new file mode 100644
index 0000000..bdd6288
--- /dev/null
+++ b/docs/api/extension.rst
@@ -0,0 +1,45 @@
+Extensibility
+=============
+
+The supported surface for adding, registering, discovering, and validating your
+own representations. See the :doc:`custom representation tutorial
+<../tutorials/custom_representation>` for a worked example.
+
+.. currentmodule:: pretab
+
+Base class and registration
+---------------------------
+
+.. autosummary::
+ :toctree: _autosummary
+ :nosignatures:
+
+ BaseRepresentation
+ register_representation
+ list_representations
+ check_representation
+ load_entry_point_representations
+
+Exceptions and warnings
+-----------------------
+
+.. autosummary::
+ :toctree: _autosummary
+ :nosignatures:
+
+ FrozenRepresentationError
+ LeakageWarning
+ OutputBudgetError
+ PretabSerializationError
+ PretabWarning
+ RepresentationConformanceError
+
+Logging
+-------
+
+.. autosummary::
+ :toctree: _autosummary
+ :nosignatures:
+
+ configure_logging
+ set_verbosity
diff --git a/docs/api/index.rst b/docs/api/index.rst
index 20cc889..bece623 100644
--- a/docs/api/index.rst
+++ b/docs/api/index.rst
@@ -1,65 +1,15 @@
-API Reference
+API reference
=============
-This page documents the public API of pretab: the high-level
-``pretab.preprocessor.Preprocessor`` and every transformer exported from
-``pretab.transformers``.
+The complete public API of PreTab, organized by role. Start with the
+:doc:`Preprocessor ` for the high-level interface, browse
+:doc:`representations` for the transformers, use :doc:`search_and_cross_fitting`
+for leakage-safe selection, and see :doc:`extension` to build your own.
-Preprocessor
-------------
+.. toctree::
+ :maxdepth: 2
-.. autosummary::
- :toctree: _autosummary
- :nosignatures:
-
- pretab.preprocessor.Preprocessor
-
-Encoders and binning
---------------------
-
-.. currentmodule:: pretab.transformers
-
-.. autosummary::
- :toctree: _autosummary
- :nosignatures:
-
- PLETransformer
- CustomBinTransformer
- OneHotFromOrdinalTransformer
- LanguageEmbeddingTransformer
-
-Feature maps
-------------
-
-.. autosummary::
- :toctree: _autosummary
- :nosignatures:
-
- RBFExpansionTransformer
- ReLUExpansionTransformer
- SigmoidExpansionTransformer
- TanhExpansionTransformer
-
-Splines
--------
-
-.. autosummary::
- :toctree: _autosummary
- :nosignatures:
-
- CubicSplineTransformer
- NaturalCubicSplineTransformer
- PSplineTransformer
- TensorProductSplineTransformer
- ThinPlateSplineTransformer
-
-Temporal
---------
-
-.. autosummary::
- :toctree: _autosummary
- :nosignatures:
-
- CyclicalTimeTransformer
- LagFeatureTransformer
- RollingStatsTransformer
+ preprocessor
+ representations
+ search_and_cross_fitting
+ extension
diff --git a/docs/api/preprocessor.rst b/docs/api/preprocessor.rst
new file mode 100644
index 0000000..f7e5c48
--- /dev/null
+++ b/docs/api/preprocessor.rst
@@ -0,0 +1,27 @@
+Preprocessor
+============
+
+The high-level entry point. :class:`~pretab.Preprocessor` reads a ``DataFrame``,
+detects feature types, resolves a per-column representation from a single
+configuration, and produces model-ready output with full lineage.
+
+.. currentmodule:: pretab
+
+.. autosummary::
+ :toctree: _autosummary
+ :nosignatures:
+
+ Preprocessor
+
+Configuration, output, and reproducibility
+------------------------------------------
+
+Supporting types returned or consumed by the preprocessor.
+
+.. autosummary::
+ :toctree: _autosummary
+ :nosignatures:
+
+ RepresentationSpec
+ FeatureLineage
+ RepresentationPolicy
diff --git a/docs/api/representations.rst b/docs/api/representations.rst
new file mode 100644
index 0000000..a0355da
--- /dev/null
+++ b/docs/api/representations.rst
@@ -0,0 +1,72 @@
+Representations
+===============
+
+Every built-in transformer exported from ``pretab.transformers``. These are the
+standalone, scikit-learn compatible representations. For a capability-oriented
+view, see the :doc:`comparison table <../representations/comparison_table>`.
+
+.. currentmodule:: pretab.transformers
+
+Splines
+-------
+
+.. autosummary::
+ :toctree: _autosummary
+ :nosignatures:
+
+ BSplineTransformer
+ MSplineTransformer
+ ISplineTransformer
+ CubicRegressionSplineTransformer
+ NaturalCubicSplineTransformer
+ PSplineTransformer
+ TensorProductSplineTransformer
+ ThinPlateSplineTransformer
+
+Feature maps
+------------
+
+.. autosummary::
+ :toctree: _autosummary
+ :nosignatures:
+
+ RBFExpansionTransformer
+ ReLUExpansionTransformer
+ SigmoidExpansionTransformer
+ TanhExpansionTransformer
+ FourierFeatureTransformer
+ PeriodicEncodingTransformer
+ RandomFourierFeaturesTransformer
+ NystroemFeaturesTransformer
+
+Binning and piecewise-linear encoding
+-------------------------------------
+
+.. autosummary::
+ :toctree: _autosummary
+ :nosignatures:
+
+ NumericBinningTransformer
+ PLETransformer
+
+Categorical
+-----------
+
+.. autosummary::
+ :toctree: _autosummary
+ :nosignatures:
+
+ ContinuousOrdinalTransformer
+ OneHotFromOrdinalTransformer
+ LanguageEmbeddingTransformer
+
+Utility transformers
+--------------------
+
+.. autosummary::
+ :toctree: _autosummary
+ :nosignatures:
+
+ MissingStateIndicator
+ NoTransformer
+ ToFloatTransformer
diff --git a/docs/api/search_and_cross_fitting.rst b/docs/api/search_and_cross_fitting.rst
new file mode 100644
index 0000000..6b3f299
--- /dev/null
+++ b/docs/api/search_and_cross_fitting.rst
@@ -0,0 +1,31 @@
+Search and cross-fitting
+========================
+
+Tools for selecting a representation and for producing leakage-free supervised
+features. See :doc:`../core_concepts/target_awareness` for the leakage model.
+
+.. currentmodule:: pretab
+
+Representation search
+---------------------
+
+Cross-validate a downstream estimator over candidate numerical methods and refit
+the best one.
+
+.. autosummary::
+ :toctree: _autosummary
+ :nosignatures:
+
+ RepresentationSearchCV
+
+Cross-fitting
+-------------
+
+Produce out-of-fold training features from a supervised transformer while
+transforming new data with an all-data model.
+
+.. autosummary::
+ :toctree: _autosummary
+ :nosignatures:
+
+ CrossFittedTransformer
diff --git a/docs/conf.py b/docs/conf.py
index e12e38b..affa9fd 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -114,11 +114,7 @@
# Exclude scikit-learn metadata-routing boilerplate that is inherited from
# BaseEstimator / TransformerMixin and is not part of pretab's public API.
"exclude-members": (
- "set_output,"
- "get_metadata_routing,"
- "set_fit_request,"
- "set_transform_request,"
- "set_inverse_transform_request,"
+ "set_output,get_metadata_routing,set_fit_request,set_transform_request,set_inverse_transform_request,"
),
}
@@ -170,6 +166,11 @@
"version": release,
}
+# Auto-generate anchor slugs for headings (h1-h3) so in-page and cross-page
+# links such as ``choosing_a_method.md#when-basis-expansion-does-not-help``
+# resolve under the strict (-W) build.
+myst_heading_anchors = 3
+
# -- Options for todo --------------------------------------------------------
todo_include_todos = False
diff --git a/docs/core_concepts/configuration.md b/docs/core_concepts/configuration.md
new file mode 100644
index 0000000..51a09ce
--- /dev/null
+++ b/docs/core_concepts/configuration.md
@@ -0,0 +1,129 @@
+# Configuration
+
+The `Preprocessor` is configured through a small, predictable set of parameters. This page
+covers the four ways to express intent: global defaults, per-feature overrides, presets, and
+reading back the resolved configuration. The mechanics of width and placement live in
+[Resolution and placement](resolution_and_placement.md), and target usage in
+[Target awareness](target_awareness.md).
+
+## Global defaults
+
+The simplest configuration sets one strategy for every numerical column and one for every
+categorical column.
+
+```python
+from pretab import Preprocessor
+
+pre = Preprocessor(
+ numerical_method="ple", # applied to every numerical column
+ categorical_method="int", # applied to every categorical column
+)
+```
+
+The defaults are `numerical_method="ple"` and `categorical_method="int"`. The full list of
+strategy strings is in the [representation comparison](../representations/comparison_table.md).
+
+## Per-feature overrides
+
+Columns rarely want identical treatment. The `feature_preprocessing` dict assigns a strategy
+to individual columns and takes precedence over the global defaults for those columns.
+
+```python
+pre = Preprocessor(
+ numerical_method="ple", # default for numerical columns not listed
+ feature_preprocessing={
+ "age": "naturalspline",
+ "income": "rbf",
+ "city": "one-hot",
+ },
+)
+```
+
+```{note}
+A per-feature entry is resolved in the correct namespace for its detected column kind. You do
+not need to state whether a column is numerical or categorical; PreTab already knows from
+feature-type detection.
+```
+
+## Presets
+
+Presets are transparent, named bundles of parameters for common intents. They set the same
+knobs you could set by hand, so nothing is hidden, and each one resolves to a fixed,
+documented set of values:
+
+| Preset | `numerical_method` | `categorical_method` | `output_dim` | `adaptive` | `max_output_dim` |
+| --- | --- | --- | --- | --- | --- |
+| `"standard"` | `"ple"` | `"int"` | `7` | `False` | `10` |
+| `"expanded"` | `"ple"` | `"one-hot"` | `16` | `False` | `10` |
+| `"adaptive"` | `"ple"` | `"int"` | `7` | `True` | `16` |
+
+```python
+standard = Preprocessor(preset="standard")
+expanded = Preprocessor(preset="expanded")
+
+standard.get_resolved_config()["categorical_method"] # "int": compact integer codes
+expanded.get_resolved_config()["categorical_method"] # "one-hot": one column per category
+expanded.get_resolved_config()["output_dim"] # 16: wider representations than "standard"
+```
+
+So `"standard"` is the balanced default (PLE numerics, integer-coded categoricals, `output_dim=7`),
+`"expanded"` widens the representation and one-hot-encodes categoricals instead, and
+`"adaptive"` lets each feature pick its own width between `min_output_dim` and `max_output_dim`
+rather than using a fixed `output_dim`.
+
+```{tip}
+A preset is a starting point, not a lock. Any parameter you pass alongside a preset overrides
+the preset's value for that knob, for example `Preprocessor(preset="expanded", output_dim=32)`.
+```
+
+## Reading the resolved configuration
+
+Because global defaults, per-feature overrides, and presets interact, PreTab lets you read
+back exactly what will be used. `get_resolved_config()` returns the fully resolved settings
+as a plain dict.
+
+```python
+pre = Preprocessor(preset="expanded", feature_preprocessing={"age": "bspline"})
+pre.get_resolved_config()
+```
+
+This is the authoritative answer to "what did my configuration actually become", and it is
+useful in tests and reproducible experiments.
+
+## How the layers combine
+
+The resolution order is deterministic. Later layers win.
+
+1. Library defaults.
+2. A `preset`, if given.
+3. Explicit constructor arguments (`numerical_method`, `output_dim`, and so on).
+4. Per-column `feature_preprocessing` entries, for the columns they name.
+
+```{warning}
+Configuration is validated at `fit` time, not silently coerced. An invalid combination, such
+as a method that requires the target used with `target_aware=False`, raises a typed error.
+This is intentional: it surfaces mistakes early rather than producing a quietly wrong
+representation.
+```
+
+## Key parameters at a glance
+
+The parameters below are the ones you reach for most. Each links to the page that explains it
+in depth.
+
+| Parameter | Default | Covered in |
+| --- | --- | --- |
+| `numerical_method`, `categorical_method` | `"ple"`, `"int"` | this page |
+| `feature_preprocessing` | `None` | this page |
+| `output_dim` | `7` | [Resolution and placement](resolution_and_placement.md) |
+| `adaptive`, `min_output_dim`, `max_output_dim` | `False`, `5`, `10` | [Resolution and placement](resolution_and_placement.md) |
+| `target_aware`, `placement_strategy` | `True`, `"cart"` | [Target awareness](target_awareness.md) |
+| `numerical_imputation`, `categorical_imputation`, `add_missing_indicator` | `"median"`, `"most_frequent"`, `False` | [Missing values](missing_values.md) |
+| `output_format`, `dtype` | `"dense"`, `None` | [Outputs and inspection](outputs_and_inspection.md) |
+| `random_state` | `None` | [Reproducibility](reproducibility.md) |
+
+## Where to go next
+
+- [Resolution and placement](resolution_and_placement.md) for width and location.
+- [Target awareness](target_awareness.md) for supervised placement.
+- [Representations](../representations/overview.md) for what each method does.
diff --git a/docs/core_concepts/feature_representation.md b/docs/core_concepts/feature_representation.md
new file mode 100644
index 0000000..b2bddab
--- /dev/null
+++ b/docs/core_concepts/feature_representation.md
@@ -0,0 +1,85 @@
+# 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 prepares a column
+
+Preprocessing makes a column safe and comparable for a model, without changing what it
+*means*. Standardizing to zero mean and unit variance, imputing a missing value, casting to
+float, and one-hot encoding a category are all preprocessing: each keeps a one-to-one
+relationship with the original signal.
+
+## Representation exposes structure
+
+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.
+
+```{note}
+This is the load-bearing idea in PreTab: the model is often fine, the *representation* is
+what is missing. A linear model with an expressive basis can fit shapes that the same model
+on raw columns cannot.
+```
+
+## Why the distinction matters
+
+- **Scaling composes with representation.** A numeric column is imputed and scaled first
+ (preprocessing), then expanded into a basis (representation). `Preprocessor` wires this
+ order for you.
+- **Representations are self-describing.** Every fitted representation carries a typed
+ [`RepresentationSpec`](../api/preprocessor.rst) and per-output-column
+ [lineage](outputs_and_inspection.md), so you always know which input and which component
+ produced each output column.
+- **Some representations use the target.** Placing bins or knots where the target actually
+ changes is a supervised decision, which is why leakage safety is a first-class concern.
+
+```{warning}
+Target-aware placement can leak information if fit outside a proper train/validation split.
+See [Target awareness](target_awareness.md) for how PreTab guards against this.
+```
+
+## The shared vocabulary
+
+Every representation family 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.
+
+## The intermediate representation
+
+Every family's fitted state is captured in one typed object, `RepresentationSpec`, the common
+form across the whole catalogue. It records the family, input and output features, scope,
+supervision, width, degree, and locations, and round-trips to and from a plain dict. Feature
+lineage then maps each output column back to its source, making a fitted PreTab pipeline
+fully inspectable and serializable.
+
+```python
+spec = transformer.get_representation_spec()
+spec.family, spec.output_features, spec.locations
+```
+
+## Where to go next
+
+- [Configuration](configuration.md) covers how you request representations.
+- [Resolution and placement](resolution_and_placement.md) explains width and location.
+- [Outputs and inspection](outputs_and_inspection.md) covers lineage and output formats.
+- [Representations](../representations/overview.md) is the full catalogue of families.
diff --git a/docs/core_concepts/missing_values.md b/docs/core_concepts/missing_values.md
new file mode 100644
index 0000000..cf7ca07
--- /dev/null
+++ b/docs/core_concepts/missing_values.md
@@ -0,0 +1,102 @@
+# Missing values
+
+Missing data is handled explicitly in PreTab, never silently. You control it with a small set
+of imputation parameters and, when you need finer behaviour, a single `missing_policy`. This
+page explains both and the rule that ties them together: imputers are fit on the training
+data only, and no rows are ever dropped.
+
+## Imputation parameters
+
+Three parameters on `Preprocessor` control the common case.
+
+`numerical_imputation`
+: Strategy for numerical columns. Default `"median"`. Set to `None` to disable.
+
+`categorical_imputation`
+: Strategy for categorical columns. Default `"most_frequent"`. Set to `None` to disable.
+
+`add_missing_indicator`
+: When `True`, adds a binary indicator column marking where a value was missing. Default
+ `False`.
+
+```python
+from pretab import Preprocessor
+
+pre = Preprocessor(
+ numerical_imputation="median",
+ categorical_imputation="most_frequent",
+ add_missing_indicator=True,
+)
+```
+
+```{note}
+Setting an imputation strategy to `None` disables imputation for that column kind. The
+missing values then reach the transformer directly: scikit-learn scalers tolerate `NaN`,
+while finite-only representations such as PLE, the splines, the feature maps, and binning
+raise a typed error. That is intentional, an expansion of an undefined value has no meaning.
+```
+
+```{warning}
+Requesting `add_missing_indicator=True` while both imputation strategies are disabled raises
+`IncompatibleParamsError`. An indicator without a filled value leaves the basis with nothing
+to expand.
+```
+
+## Fit on train, apply to test
+
+Imputers learn their fill values from the data passed to `fit`, and only that data. When you
+later call `transform` on new rows, the stored fill values are reused. This keeps the split
+clean and prevents test statistics from leaking into training.
+
+```{important}
+PreTab never drops rows to deal with missing values. Every input row produces an output row.
+This preserves alignment with your target and any parallel arrays.
+```
+
+## The `missing_policy` control
+
+For finer control, `missing_policy` selects one of five behaviours for the whole
+preprocessor.
+
+| Policy | Behaviour |
+| --- | --- |
+| `"error"` | Reject any missing value at `fit` and `transform`. |
+| `"propagate"` | Pass missing values through to the transformer unchanged. |
+| `"impute"` | Fill using the imputation parameters above. |
+| `"impute_with_indicator"` | Impute and add a missing indicator column. |
+| `"separate_state"` | Impute the basis, and add a dedicated `__missing` column that does not activate the ordinary basis. |
+
+```python
+pre = Preprocessor(missing_policy="separate_state")
+```
+
+### Separate state
+
+`"separate_state"` is the most expressive option. For each affected column it keeps the
+imputed value flowing into the normal basis and, in parallel, emits a `__missing` indicator
+that a model can weight on its own. This lets the model learn a distinct effect for
+"missing" without corrupting the shape learned on observed values.
+
+```{tip}
+Reach for `"separate_state"` when missingness is itself informative, for example a field that
+users leave blank for a meaningful reason. Reach for plain `"impute"` when a value is missing
+purely at random.
+```
+
+## Choosing an approach
+
+- **Missing at random, not informative**: `numerical_imputation` / `categorical_imputation`
+ (the default), no indicator.
+- **Missingness may carry signal**: add `add_missing_indicator=True`, or use
+ `missing_policy="separate_state"`.
+- **Missing values are a data error you want to catch**: `missing_policy="error"`.
+- **You will handle missingness upstream**: `missing_policy="propagate"` with imputation
+ disabled.
+
+## Where to go next
+
+- [Configuration](configuration.md) for how these parameters combine with the rest.
+- [Edge-case behaviour](../representations/choosing_a_method.md) for constant columns,
+ out-of-range inputs, and unseen categories.
+- [Outputs and inspection](outputs_and_inspection.md) to see indicator columns in the
+ lineage.
diff --git a/docs/core_concepts/outputs_and_inspection.md b/docs/core_concepts/outputs_and_inspection.md
new file mode 100644
index 0000000..26950fb
--- /dev/null
+++ b/docs/core_concepts/outputs_and_inspection.md
@@ -0,0 +1,135 @@
+# Outputs and inspection
+
+A representation is only useful if you can read what it produced. PreTab returns model-ready
+output in the format you ask for, names every column, and can trace each output column back to
+the exact input and component that created it. This page covers output shapes, formats,
+feature names, lineage, and the output budget.
+
+## Output shapes
+
+`fit_transform` and `transform` return a dictionary that maps each feature to its transformed
+block, with keys prefixed `num_` or `cat_`. Pass `return_array=True` to receive a single
+stacked `numpy.ndarray` instead.
+
+```python
+X_dict = pre.fit_transform(df, y) # {"num_age": ..., "cat_city": ...}
+X_array = pre.transform(df, return_array=True) # one stacked ndarray
+```
+
+```{note}
+The dict form is convenient for inspection and for feeding blocks to different model heads.
+The array form is what a plain scikit-learn estimator expects. Choose per call.
+```
+
+## Output format and dtype
+
+Two parameters control the physical layout of the stacked output.
+
+`output_format`
+: One of `"dense"`, `"sparse"`, or `"auto"`. `"auto"` picks sparse when it saves memory (for
+ example wide one-hot blocks) and dense otherwise. Default `"dense"`.
+
+`dtype`
+: The floating-point precision of the output, for example `numpy.float32` to halve memory.
+
+```python
+pre = Preprocessor(output_format="auto", dtype="float32")
+```
+
+After fitting, `output_report_` summarizes what was produced: the chosen format, dimensions,
+density, and memory saved.
+
+```python
+pre.fit(df, y)
+pre.output_report_
+```
+
+### DataFrame output
+
+PreTab honours the scikit-learn output API, so you can request pandas or polars frames.
+
+```python
+pre.set_output(transform="pandas") # or "polars"
+```
+
+```{note}
+Polars output is loaded lazily. If polars is not installed, requesting it raises a clear
+`OptionalDependencyError` rather than failing deep in the call stack.
+```
+
+## Feature names
+
+Every representation names its output columns, and the names are stable and descriptive.
+`get_feature_names_out()` returns them in output order.
+
+```python
+pre.get_feature_names_out()
+```
+
+Use `get_feature_info(verbose=True)` for a human-readable table of the resolved per-feature
+pipeline, output width, and category count.
+
+```text
+feature kind pipeline dim cats
+----------------------------------------------------------------
+age numerical imputer -> minmax -> bspline 13 -
+income numerical imputer -> minmax -> ple 12 -
+city categorical imputer -> onehot -> to_float 4 4
+```
+
+## Feature lineage
+
+Lineage is the flagship inspection feature. `get_feature_lineage()` returns one record per
+output column, mapping it back to its origin.
+
+```python
+lineage = pre.get_feature_lineage()
+lineage[0]
+```
+
+Each `FeatureLineage` record carries:
+
+- the **source input column(s)** the output came from,
+- the **representation** family that produced it,
+- the **component** it corresponds to (a basis function, knot, center, frequency, interval,
+ or category),
+- whether the **target was used** to fit it,
+- whether it is an **interaction** across several inputs.
+
+```{tip}
+Lineage covers every output column and the names line up with `get_feature_names_out()`. This
+makes a fitted `Preprocessor` fully auditable, which is invaluable when you interpret a linear
+model fit on top of the expansion.
+```
+
+## Output budget
+
+Expansions can multiply columns quickly, especially wide splines or high-cardinality one-hot.
+The output budget lets you cap the blast radius and estimate cost before committing.
+
+| Parameter | Effect |
+| --- | --- |
+| `max_output_features` | Cap on total output columns. |
+| `max_features_per_input` | Cap on columns produced from any single input. |
+| `max_dense_memory` | Cap on dense output memory. |
+| `overflow_policy` | What to do on overflow, default `"error"`. |
+
+```python
+pre = Preprocessor(max_output_features=500, overflow_policy="error")
+
+pre.estimate_output_shape(df) # predicted (n_rows, n_cols) without transforming
+pre.estimate_memory(df) # predicted dense memory in bytes
+```
+
+```{warning}
+With `overflow_policy="error"`, exceeding a budget raises `OutputBudgetError` at `fit`. Use
+`estimate_output_shape` and `estimate_memory` first when you work with wide expansions or
+large data.
+```
+
+## Where to go next
+
+- [Reproducibility](reproducibility.md) to serialize and fingerprint the fitted output.
+- [Representations](../representations/overview.md) for what each family emits.
+- [Comparing representations](../tutorials/comparing_representations.md) to measure width and
+ memory.
diff --git a/docs/core_concepts/reproducibility.md b/docs/core_concepts/reproducibility.md
new file mode 100644
index 0000000..6b0e08e
--- /dev/null
+++ b/docs/core_concepts/reproducibility.md
@@ -0,0 +1,107 @@
+# Reproducibility
+
+A representation you cannot reproduce is a representation you cannot trust in production or in
+a paper. PreTab treats reproducibility as a contract: deterministic fitting, a portable
+declarative spec, a stable fingerprint, and an immutable lifecycle. This page covers all four.
+
+## Deterministic fitting
+
+`random_state` seeds every stochastic step: the target-aware selectors, k-means landmark
+placement, and the randomized feature maps. Set it to an integer whenever you need repeatable
+output, for example in tests or published experiments.
+
+```python
+from pretab import Preprocessor
+
+pre = Preprocessor(numerical_method="rff", random_state=0)
+```
+
+```{note}
+With a fixed `random_state`, repeated fits on the same data produce identical output. Methods
+with no stochastic component ignore the seed.
+```
+
+## Portable serialization
+
+`to_spec` writes a fitted `Preprocessor` to a versioned, declarative schema, and `from_spec`
+reconstructs it. The spec records the schema and library versions, the resolved parameters,
+and the per-representation fitted state (parameters, knots, centers, columns, scaling).
+
+```python
+spec = pre.to_spec() # returns a dict
+pre.to_spec("representation.json") # or writes JSON to a path
+
+restored = Preprocessor.from_spec("representation.json")
+```
+
+```{important}
+`from_spec` is a safe alternative to pickle. Reconstruction imports only from `pretab`,
+`scikit-learn`, `numpy`, `scipy`, and builtins, and it never executes arbitrary estimator
+code. A spec from an untrusted source cannot run code the way an untrusted pickle can.
+```
+
+A round-trip reproduces `transform` bit-for-bit, so a spec is a faithful, human-readable
+record of a fitted representation.
+
+## Fingerprint
+
+`fingerprint_` is a SHA-256 hash over a canonical view of the fitted representation: the
+resolved config, the schema, the fitted parameters, the output-column order, the seeds, the
+library versions, and the output precision.
+
+```python
+pre.fit(df, y)
+pre.fingerprint_
+```
+
+The fingerprint is deterministic within a process and across processes, and it survives a
+`to_spec` / `from_spec` round-trip. Two preprocessors with the same fingerprint will produce
+the same output; a change to config, data, seed, or version changes the fingerprint.
+
+```{tip}
+Log the fingerprint alongside model metrics. If it changes unexpectedly between runs, your
+representation changed, which is exactly the signal you want before you chase a metric
+regression.
+```
+
+`reproducibility_report()` returns a structured summary for logging: the fingerprint,
+versions, seed, output dtype and format, output widths, and the per-feature families.
+
+```python
+pre.reproducibility_report()
+```
+
+## Immutable lifecycle
+
+A fitted representation moves through a small set of explicit states, which prevents
+accidental mutation of something you intend to deploy.
+
+| State | Meaning |
+| --- | --- |
+| `UNFITTED` | Constructed, not yet fit. |
+| `FITTED` | Fit and ready to transform. |
+| `FROZEN` | Locked against parameter changes. |
+| `STALE` | Marked as no longer current, with a reason. |
+
+```python
+pre.freeze() # lock it
+pre.is_frozen() # True
+pre.set_params(...) # raises FrozenRepresentationError while frozen
+```
+
+Freezing is useful when a representation is validated and about to ship. To make a fresh,
+unfrozen copy, use `clone_unfitted()`. To retrain, `refit(X, y)` returns a **new** fitted
+object and leaves the original untouched, and `mark_stale(reason)` records why an existing one
+should no longer be used.
+
+```{warning}
+`set_params` on a frozen preprocessor raises `FrozenRepresentationError`. This is deliberate:
+a deployed representation should not silently change shape. Use `refit` to produce a new
+object instead of mutating the old one.
+```
+
+## Where to go next
+
+- [Outputs and inspection](outputs_and_inspection.md) for the output the fingerprint covers.
+- [Target awareness](target_awareness.md) for how supervised state is recorded.
+- [Production lifecycle](../developer_guide/release.md) for versioning and release discipline.
diff --git a/docs/core_concepts/resolution_and_placement.md b/docs/core_concepts/resolution_and_placement.md
new file mode 100644
index 0000000..ebeeb63
--- /dev/null
+++ b/docs/core_concepts/resolution_and_placement.md
@@ -0,0 +1,122 @@
+# Resolution and placement
+
+Two questions define any basis expansion: *how many* units to use, and *where* to put them.
+PreTab keeps these separate on purpose. Resolution answers "how many" (the output width), and
+placement answers "where" (the knots, centers, or bin edges). This page explains both and how
+they combine.
+
+## Resolution: the `output_dim` width
+
+`output_dim` is the main capacity control. It sets the number of non-bias output columns per
+input feature: bins for PLE and binning, centers for the feature maps, and basis functions
+for the splines. A larger value captures finer structure at the cost of more columns and a
+higher chance of overfitting. A smaller value is more compact and regularizes the
+representation.
+
+```{note}
+When you configure through the `Preprocessor`, its single `output_dim` (default `7`) is
+forwarded to **every** numerical method. Per-transformer defaults only apply when you build a
+transformer directly, for example `RBFExpansionTransformer()`.
+```
+
+### Spline width has a floor
+
+Each spline enforces a minimum width tied to its degree. Requesting fewer basis functions
+than the floor raises an error at `fit` time rather than silently clamping, so keep
+`output_dim` at or above the floor.
+
+| Family | Minimum width (floor) |
+| --- | --- |
+| B, M, I, P-spline, tensor-product | `degree + 1` (so `4` at the default cubic degree) |
+| Cubic regression spline | `3` (three polynomial terms plus interior knots) |
+| Natural cubic spline | `2` (places `output_dim + 1` knots) |
+| Feature maps, PLE, binning | `1` |
+
+```{warning}
+For the tensor-product spline the width grows as the **product** across marginal dimensions.
+A 2-D input with `output_dim=4` already produces `4 x 4 = 16` columns, so raise it in small
+steps and watch the total column count.
+```
+
+## Adaptive sizing
+
+Some features are simple and some are complex, and one fixed width rarely suits all of them.
+PLE, the feature maps, and the freely-placed knot splines can size each feature from the data
+instead.
+
+`adaptive`
+: When `True`, the width for each feature is chosen from the data and kept inside
+ `[min_output_dim, max_output_dim]`. Fixed-width methods such as the plain scalers ignore
+ this flag.
+
+`min_output_dim`, `max_output_dim`
+: The lower and upper bounds that apply only when `adaptive=True` (defaults `5` and `10`).
+ They are ignored otherwise.
+
+```python
+from pretab import Preprocessor
+
+pre = Preprocessor(
+ numerical_method="rbf",
+ adaptive=True,
+ min_output_dim=4,
+ max_output_dim=12,
+)
+```
+
+See the [adaptive resolution tutorial](../tutorials/adaptive_resolution.md) for a worked
+example.
+
+## Placement: where the units go
+
+Placement decides the actual positions of the basis units. PreTab centralizes this in one
+placement subsystem so no transformer re-implements it, and it is driven by two parameters.
+
+`target_aware`
+: Whether placement uses the target `y`.
+
+`placement_strategy`
+: How the positions are chosen. Valid values depend on `target_aware`.
+
+| `target_aware` | Allowed `placement_strategy` | Meaning |
+| --- | --- | --- |
+| `False` | `"uniform"` | Evenly spaced across the observed range. |
+| `False` | `"quantile"` | Spaced by data density, more units where data is dense. |
+| `True` | `"cart"` | Split points from a per-feature decision tree fit against `y`. |
+| `True` | `"lightgbm"` | Split points aggregated from gradient-boosted trees (needs the `lightgbm` extra). |
+
+```{warning}
+The unsupervised and target-aware rows are mutually exclusive. Combining them, for example
+`target_aware=True` with `placement_strategy="quantile"`, raises an error. Leave
+`placement_strategy` unset to get the sensible default for whichever mode you picked.
+```
+
+Target-aware placement is a supervised decision and carries leakage considerations. See
+[Target awareness](target_awareness.md).
+
+## Resolution and placement are independent
+
+Keeping the two axes separate is what makes the system predictable. You choose a width
+(resolution) and, separately, a rule for positions (placement). The same `"quantile"`
+placement works at width `5` or width `20`; the same width works with uniform or
+target-aware placement. Not every method honours every strategy: the penalized P-spline
+assumes a regular geometry and is `"uniform"` only, PLE always places against the target, and
+the thin-plate spline uses landmark points rather than ordinary knots. These per-method rules
+are enforced from the capability registry, so an invalid request fails loudly.
+
+## Method-specific placement rules
+
+| Method | Placement behaviour |
+| --- | --- |
+| PLE | Target-aware always (`"cart"` or `"lightgbm"`). |
+| P-spline | `"uniform"` only, unsupervised (the difference penalty assumes regular knots). |
+| Feature maps, freely-placed knot splines | Any of the four strategies. |
+| Thin-plate spline | Landmark points (k-means), not ordinary knots. |
+| Fourier features | Frequencies derived from the data, not placement knots. |
+
+## Where to go next
+
+- [Target awareness](target_awareness.md) for supervised placement and leakage safety.
+- [Representations](../representations/overview.md) for how each family uses its locations.
+- [Comparing representations](../tutorials/comparing_representations.md) to see width and
+ strategy trade-offs measured.
diff --git a/docs/core_concepts/target_awareness.md b/docs/core_concepts/target_awareness.md
new file mode 100644
index 0000000..b3ae479
--- /dev/null
+++ b/docs/core_concepts/target_awareness.md
@@ -0,0 +1,112 @@
+# Target awareness
+
+Some representations place their bins, centers, or knots using the target `y`. Positioning
+units where the target actually changes sharpens the representation, but it also reads labels
+at `fit` time, which introduces a leakage risk if done carelessly. PreTab makes target usage
+explicit and gives you leakage-safe tools. This page explains the contract.
+
+## Which methods use the target
+
+Every method declares how it uses `y` through three levels.
+
+`forbidden`
+: The method never uses the target. The scalers, one-hot, ordinal encoding, the Fourier map,
+ and the P-spline are all unsupervised.
+
+`optional`
+: The method uses the target only when `target_aware=True`. The feature maps (RBF, ReLU,
+ sigmoid, tanh) and the freely-placed knot splines (B, M, I, cubic, natural) are in this
+ group.
+
+`required`
+: The method always places against the target. Piecewise-linear encoding (PLE) is the primary
+ example and needs `y` at every fit.
+
+```python
+from pretab.transformers import PLETransformer
+
+t = PLETransformer()
+t.requires_y # True: PLE always needs y
+t.is_supervised # True
+```
+
+```{warning}
+A `required` method fitted without `y`, or with `target_aware=False`, raises a typed error
+rather than silently producing an unsupervised result. Always pass `y` to a pipeline that
+contains PLE.
+```
+
+## The fitted-usage flag
+
+After fitting, a transformer reports whether it actually consumed the target through
+`uses_target_`. This is the ground truth for an individual fit, and it flows into the
+[`RepresentationSpec`](outputs_and_inspection.md) so a serialized representation records
+whether it was supervised.
+
+```python
+t = PLETransformer().fit(x, y)
+t.uses_target_ # True
+```
+
+## Leakage safety
+
+Fitting a supervised transformer on your full dataset and then evaluating on part of it leaks
+target information and inflates scores. PreTab warns when it detects this pattern.
+
+```{important}
+A supervised transformer emits a `LeakageWarning` when it is fit with a target **outside** a
+cross-validation or `Pipeline` context. Inside a scikit-learn `Pipeline`, `ColumnTransformer`,
+`Preprocessor`, or a cross-fitting wrapper, the warning is suppressed because those contexts
+already keep the fit confined to the training fold.
+```
+
+The safe patterns are:
+
+- Put the supervised transformer **inside a `Pipeline`**, so `cross_val_score` and
+ `GridSearchCV` fit it on the training fold only.
+- Use the `Preprocessor`, which fits its imputers and supervised expansions on the training
+ data you pass to `fit`.
+- Wrap it in a `CrossFittedTransformer` when you want out-of-fold training features.
+
+## Cross-fitted features
+
+`CrossFittedTransformer` removes leakage from the training features themselves. It produces
+out-of-fold values for the training rows (each row is transformed by a model that did not see
+it) while `transform` on new data uses a model fit on all the training data.
+
+```python
+from pretab import CrossFittedTransformer
+from pretab.transformers import PLETransformer
+
+cf = CrossFittedTransformer(PLETransformer(), n_folds=5)
+X_train_features = cf.fit_transform(x_train, y_train) # out-of-fold, leakage-free
+X_test_features = cf.transform(x_test) # uses the all-data model
+```
+
+The fitted spec records `cross_fitted=True` and the number of folds, so the choice is
+visible and serializable.
+
+```{note}
+Cross-fitting matters most for strongly supervised encodings such as PLE, where the target
+directly determines the bins. For unsupervised methods it is unnecessary.
+```
+
+## Searching over representations
+
+`RepresentationSearchCV` cross-validates a downstream estimator over a set of candidate
+numerical methods and refits the best one. It is a convenient way to let the data choose the
+representation without leaking through the selection.
+
+```python
+from pretab import RepresentationSearchCV
+```
+
+See the [target-aware classification tutorial](../tutorials/target_aware_classification.md)
+for an end-to-end, leakage-safe evaluation.
+
+## Where to go next
+
+- [Resolution and placement](resolution_and_placement.md) for the placement strategies.
+- [Reproducibility](reproducibility.md) for how supervised state is recorded and serialized.
+- [Leakage-safe classification](../tutorials/target_aware_classification.md) for a worked
+ example.
diff --git a/docs/developer_guide/documentation.md b/docs/developer_guide/documentation.md
new file mode 100644
index 0000000..011c683
--- /dev/null
+++ b/docs/developer_guide/documentation.md
@@ -0,0 +1,110 @@
+# Documentation
+
+The documentation you are reading is part of the codebase and is held to the same standard as
+the code. This page explains how it is built, how it is structured, and the conventions to
+follow when you add or edit a page.
+
+## Building the docs
+
+The docs build with Sphinx through a single recipe.
+
+```bash
+just docs # build HTML into docs/_build/html
+open docs/_build/html/index.html # macOS; use xdg-open on Linux
+```
+
+```{important}
+The build runs with `-W`, so **warnings are treated as errors**. A broken cross-reference, an
+orphaned page, or a malformed directive fails the build. Run `just docs` before opening a pull
+request that touches documentation.
+```
+
+To work on the docs, install the docs dependency group.
+
+```bash
+poetry install --with docs
+```
+
+## Structure
+
+The `docs/` tree is organized by reader intent.
+
+| Section | Purpose |
+| --- | --- |
+| `getting_started/` | Install, first model, choosing an interface, migration. |
+| `core_concepts/` | The mental model: representation, configuration, resolution, target awareness, missing values, outputs, reproducibility. |
+| `representations/` | The method catalogue, comparison table, and selection guidance. |
+| `tutorials/` | Task-oriented, worked examples. |
+| `api/` | Autogenerated reference from docstrings. |
+| `developer_guide/` | Contributing, testing, documentation, versioning, release. |
+
+## MyST Markdown and reStructuredText
+
+Prose pages are written in [MyST Markdown](https://myst-parser.readthedocs.io/) (`.md`); the
+API pages are reStructuredText (`.rst`) so they can drive `autosummary`. Use callout directives
+to highlight important information.
+
+````markdown
+```{note}
+A neutral aside.
+```
+
+```{tip}
+A helpful suggestion.
+```
+
+```{warning}
+Something that can bite the reader.
+```
+
+```{important}
+A guarantee or constraint the reader must not miss.
+```
+````
+
+Math uses standard MyST syntax, inline as `$...$` and display as `$$...$$`.
+
+## Adding a page
+
+Every page must be reachable from a `toctree`, or the strict build fails with an orphan-document
+error.
+
+1. Create the `.md` file in the appropriate section.
+2. Add its filename (without extension) to the relevant `toctree`, either in `index.rst` or the
+ section's own index.
+3. Cross-link to and from sibling pages with relative links.
+4. Run `just docs` and fix any warnings.
+
+```{warning}
+A cross-reference to a page that does not exist fails the strict build. When you link to a page,
+make sure the target exists, and when you remove a page, remove every link to it.
+```
+
+## The API reference
+
+The API pages document public classes and functions from their numpy-style docstrings through
+`autodoc` and `autosummary`. There is no prose to write for a new public class; instead, add its
+name to the appropriate `autosummary` block under `docs/api/` and keep its docstring accurate.
+
+```{note}
+Because the reference is generated from docstrings, an accurate docstring is documentation.
+Update the docstring in the same change that alters the behaviour.
+```
+
+## Writing style
+
+The documentation aims to be precise and natural, and to read well for beginners, practitioners,
+and researchers alike. A few conventions keep it consistent.
+
+- Separate sections with headings, not horizontal rules.
+- Avoid stray transitional text between sections; let the headings carry the structure.
+- Prefer active, concrete sentences over filler.
+- Ground every claim in the real API. If you are unsure of a parameter name or default, check
+ the source.
+- Add a callout where it genuinely helps, not on every paragraph.
+
+## Where to go next
+
+- [Contributing](contributing.md) for the overall workflow.
+- [Testing](testing.md) for the test gate that runs alongside the docs build.
+- [Release process](release.md) for how docs ship with a release.
diff --git a/docs/developer_guide/testing.md b/docs/developer_guide/testing.md
new file mode 100644
index 0000000..368ca45
--- /dev/null
+++ b/docs/developer_guide/testing.md
@@ -0,0 +1,97 @@
+# 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/getting_started/choosing_an_interface.md b/docs/getting_started/choosing_an_interface.md
new file mode 100644
index 0000000..2663b79
--- /dev/null
+++ b/docs/getting_started/choosing_an_interface.md
@@ -0,0 +1,92 @@
+# Choosing an interface
+
+PreTab exposes the same representations through two surfaces: the high-level `Preprocessor`
+and the standalone transformers. They share the same underlying code, so the choice is about
+ergonomics, not capability. This page helps you pick.
+
+## The two surfaces at a glance
+
+::::{grid} 1 1 2 2
+:gutter: 3
+
+:::{grid-item-card} `Preprocessor`
+Reads a `DataFrame`, detects numerical and categorical columns, and applies a strategy per
+column from a single configuration object. Returns a dict of feature blocks by default.
+:::
+
+:::{grid-item-card} Standalone transformers
+Plain scikit-learn transformers you import from `pretab.transformers`. Each one returns a
+NumPy array and slots into a `Pipeline`, `ColumnTransformer`, or any scikit-learn utility.
+:::
+
+::::
+
+## Reach for the `Preprocessor` when
+
+- You start from a `DataFrame` and want **automatic feature-type detection** rather than
+ wiring every column by hand.
+- You want to configure **many columns from one place**, either with global
+ `numerical_method` / `categorical_method` defaults or a per-column `feature_preprocessing`
+ map.
+- You want the **framework services** that live at this level: feature lineage, output-format
+ control, missing-value policy, output budgets, serialization, and a reproducibility
+ fingerprint.
+
+```python
+from pretab import Preprocessor
+
+pre = Preprocessor(feature_preprocessing={
+ "age": "naturalspline",
+ "income": "ple",
+ "city": "one-hot",
+})
+X = pre.fit_transform(df, y) # dict of blocks, or return_array=True for one matrix
+```
+
+## Reach for standalone transformers when
+
+- You want a **single estimator object** that composes cleanly inside one `Pipeline`.
+- You rely on **scikit-learn model selection**: `cross_val_score`, `GridSearchCV`, and
+ `step__param` hyperparameter addressing all work out of the box.
+- You need **fine control** over one column's transformer and its parameters.
+
+```python
+from sklearn.compose import ColumnTransformer
+from sklearn.pipeline import Pipeline
+from sklearn.linear_model import Ridge
+
+from pretab.transformers import NaturalCubicSplineTransformer, PLETransformer
+
+features = ColumnTransformer([
+ ("age", NaturalCubicSplineTransformer(output_dim=10), ["age"]),
+ ("income", PLETransformer(output_dim=12), ["income"]),
+])
+model = Pipeline([("features", features), ("ridge", Ridge())])
+```
+
+```{note}
+The `Preprocessor` returns a dict by default, which is convenient for inspection but is not a
+drop-in for a scikit-learn estimator that expects a single matrix. Call it with
+`return_array=True`, or use the standalone transformers, when you compose one end-to-end
+`Pipeline`.
+```
+
+## A note on multivariate methods
+
+The tensor-product spline, thin-plate spline, random Fourier features, and Nyström map model
+several columns **jointly**. They are standalone-only and are not selectable per column
+through `Preprocessor(numerical_method=...)`. Use them directly as transformers over a block
+of columns. See [Multivariate features](../tutorials/multivariate_features.md).
+
+## They interoperate
+
+The choice is not exclusive. A `Preprocessor` can live inside a larger `Pipeline`, and
+standalone transformers can preprocess columns you then hand to a `Preprocessor`. Pick the
+surface that keeps the intent of your code clearest.
+
+## Where to go next
+
+- [Configuration](../core_concepts/configuration.md) documents every `Preprocessor` knob.
+- [scikit-learn pipelines](../tutorials/sklearn_pipeline.md) shows the standalone route with
+ cross-validation and grid search.
+- [Representations](../representations/overview.md) is the full method catalogue.
diff --git a/docs/getting_started/installation.md b/docs/getting_started/installation.md
index c2de614..f13b17e 100644
--- a/docs/getting_started/installation.md
+++ b/docs/getting_started/installation.md
@@ -24,6 +24,19 @@ dependencies (including PyTorch), so it is a sizeable download. Add it only if y
to use the `pretrained` categorical strategy.
```
+The `lightgbm` extra enables the gradient-boosted `placement_strategy="lightgbm"` for
+supervised knot, center, and threshold selection:
+
+```bash
+pip install "pretab[lightgbm]"
+```
+
+Use the convenience `all` extra to install every optional dependency at once:
+
+```bash
+pip install "pretab[all]"
+```
+
## From source
pretab uses [Poetry](https://python-poetry.org/) for dependency management and
@@ -42,6 +55,14 @@ poetry install
poetry run pre-commit install --hook-type commit-msg --hook-type pre-commit --hook-type pre-push
```
+To check that everything works end to end, run the quickstart script. It exercises mixed
+preprocessing, feature lineage, leakage-safe cross-fitting, serialization, and more in a few
+seconds, and doubles as the reviewer smoke test:
+
+```bash
+just quickstart # or: python scripts/quickstart.py
+```
+
To work on the documentation, also install the docs group:
```bash
diff --git a/docs/getting_started/migration_to_1_0.md b/docs/getting_started/migration_to_1_0.md
new file mode 100644
index 0000000..e21a036
--- /dev/null
+++ b/docs/getting_started/migration_to_1_0.md
@@ -0,0 +1,124 @@
+# Migrating to 1.0
+
+PreTab 1.0 is the first stable release. Because the previously published API (`0.0.2`) was
+never declared stable, 1.0 takes a one-time, deliberate cleanup: intention-revealing class
+names, non-overlapping parameters, and a smaller, sharper scope. This page maps the old
+surface to the new one so you can upgrade in a single pass.
+
+```{important}
+1.0 contains breaking changes relative to `0.0.2`. There are no compatibility shims. Update
+the names and parameters below, then re-fit. Pin `pretab<1` if you need the old behaviour
+while you migrate.
+```
+
+## Renamed transformers
+
+The classes gained names that say what they compute.
+
+| Old name (`0.0.2`) | New name (`1.0`) | Notes |
+| --- | --- | --- |
+| `CustomBinTransformer` | `NumericBinningTransformer` | Numeric-only, now stateful (learns edges in `fit`). |
+| `CyclicalTimeTransformer` | `PeriodicEncodingTransformer` | Sine and cosine harmonics for cyclic values. |
+| `CubicSplineTransformer` | `CubicRegressionSplineTransformer` | Disambiguated from the generic cubic B-spline. |
+
+## Removed transformers
+
+Generic time-series utilities are out of scope for a representation framework.
+
+| Removed | Replacement |
+| --- | --- |
+| `LagFeatureTransformer` | Use a dedicated time-series library. |
+| `RollingStatsTransformer` | Use a dedicated time-series library. |
+
+```{note}
+Cyclic time structure is still first-class through `PeriodicEncodingTransformer` and the
+`"fourier"` feature map. Only the generic lag and rolling-window helpers were removed.
+```
+
+## Deprecated
+
+| Symbol | Status | Do this instead |
+| --- | --- | --- |
+| `OneHotFromOrdinalTransformer` | Deprecated, emits a `DeprecationWarning` | Use the `"one-hot"` categorical method, which wraps scikit-learn's `OneHotEncoder`. |
+
+## Parameter changes on `Preprocessor`
+
+### Placement is now two clean knobs
+
+The overlapping `selector` / `strategy` / `use_target` arguments are gone. Placement is
+controlled by exactly two parameters that validate strictly against each other.
+
+| Old | New |
+| --- | --- |
+| `use_target=True/False`, plus ad-hoc `selector` / `strategy` | `target_aware: bool` and `placement_strategy: str` |
+
+The valid combinations are fixed:
+
+| `target_aware` | Allowed `placement_strategy` |
+| --- | --- |
+| `False` | `"uniform"`, `"quantile"` |
+| `True` | `"cart"`, `"lightgbm"` |
+
+```{warning}
+Mixing the two rows, for example `target_aware=True` with `placement_strategy="quantile"`,
+raises an error rather than silently guessing. Leave `placement_strategy` unset to get the
+sensible default for whichever mode you chose.
+```
+
+See [Resolution and placement](../core_concepts/resolution_and_placement.md) for the full
+model.
+
+### Missing-value handling is explicit
+
+The single `handle_missing` flag was replaced by three explicit parameters.
+
+| Old | New |
+| --- | --- |
+| `handle_missing=...` | `numerical_imputation="median"`, `categorical_imputation="most_frequent"`, `add_missing_indicator=False` |
+
+Set an imputation strategy to `None` to disable it for that kind. See
+[Missing values](../core_concepts/missing_values.md).
+
+## Renamed optional extra
+
+| Old install | New install |
+| --- | --- |
+| `pip install "pretab[knots]"` | `pip install "pretab[lightgbm]"` |
+
+The rename matches `placement_strategy="lightgbm"`. The `embeddings` and `all` extras are
+unchanged. See [Installation](installation.md).
+
+## Thin-plate spline parameters
+
+The thin-plate spline moved to landmark-based terminology and is sized by rank, not by a
+fixed `output_dim`.
+
+| Old | New |
+| --- | --- |
+| `ThinPlateSplineTransformer(output_dim=...)` | `ThinPlateSplineTransformer(n_components=..., landmark_strategy="kmeans", rank_strategy="eigen")` |
+
+## What is new in 1.0
+
+Upgrading also unlocks capabilities that did not exist in `0.0.2`.
+
+- **New representations**: `FourierFeatureTransformer`, `RandomFourierFeaturesTransformer`,
+ and `NystroemFeaturesTransformer`.
+- **A typed intermediate form**: `RepresentationSpec` plus per-output-column
+ [feature lineage](../core_concepts/outputs_and_inspection.md).
+- **Leakage-safe supervision**: `CrossFittedTransformer`, `RepresentationSearchCV`, and a
+ `LeakageWarning`. See [Target awareness](../core_concepts/target_awareness.md).
+- **Portable serialization**: `to_spec` / `from_spec`, a stable `fingerprint_`, and a frozen
+ lifecycle. See [Reproducibility](../core_concepts/reproducibility.md).
+- **Presets and discovery**: `Preprocessor(preset=...)` and `list_representations(...)`.
+- **Central edge-case policy** and **output budgets** on `Preprocessor`.
+
+## Upgrade checklist
+
+1. Rename the three renamed transformer classes.
+2. Remove any use of `LagFeatureTransformer` / `RollingStatsTransformer`.
+3. Replace `handle_missing` with the three explicit imputation parameters.
+4. Replace `use_target` / `selector` / `strategy` with `target_aware` and
+ `placement_strategy`.
+5. Swap `ThinPlateSplineTransformer(output_dim=...)` for `n_components`.
+6. Update `pretab[knots]` to `pretab[lightgbm]` in your dependencies.
+7. Re-fit and confirm the resolved layout with `get_feature_info(verbose=True)`.
diff --git a/docs/getting_started/overview.md b/docs/getting_started/overview.md
new file mode 100644
index 0000000..e2dbc12
--- /dev/null
+++ b/docs/getting_started/overview.md
@@ -0,0 +1,125 @@
+# Overview
+
+PreTab is a representation and basis-expansion framework for tabular data. It takes raw
+numerical and categorical columns and turns them into model-ready features that expose
+structure a plain estimator cannot see on its own. Every strategy speaks the standard
+scikit-learn `fit` / `transform` API, so PreTab drops into the pipelines and tooling you
+already use.
+
+## The problem PreTab solves
+
+Most tabular models receive one straight-line term per numerical column. A linear model,
+a logistic regression, or a plain additive model can only weight that single slope, so any
+curve, threshold, saturation, or periodic pattern in the data is invisible to it. The usual
+response is to hand-craft features: bucket an age column, add a squared income term, encode
+the hour of day as a pair of sine and cosine values. That work is repetitive, easy to get
+wrong, and rarely reproducible.
+
+PreTab makes those representations first-class. Instead of writing feature code by hand you
+declare intent once, for example "expand `age` with a spline, encode `income` with
+piecewise-linear bins, treat `hour` as periodic", and PreTab fits the corresponding basis
+per column, tracks where every output column came from, and hands back a clean matrix.
+
+```python
+from pretab import Preprocessor
+
+pre = Preprocessor(feature_preprocessing={
+ "age": "naturalspline", # smooth non-linear effect
+ "income": "ple", # supervised piecewise-linear encoding
+ "hour": "fourier", # periodic representation
+ "city": "one-hot", # categorical
+})
+X = pre.fit_transform(df, y)
+```
+
+## How this compares to scikit-learn's preprocessing transformers
+
+PreTab is not a competitor to scikit-learn. Every transformer subclasses `BaseEstimator` and
+`TransformerMixin` and drops into the same `Pipeline` and `ColumnTransformer` you already use.
+The real question is what PreTab adds where scope overlaps with scikit-learn's own
+`SplineTransformer`, `KBinsDiscretizer`, `PolynomialFeatures`, and `TargetEncoder`.
+
+| Capability | scikit-learn | PreTab |
+| --- | --- | --- |
+| Knot / threshold placement | Uniform or quantile, fixed before fitting | Optionally target-aware: a CART or LightGBM model places knots where the target changes fastest (`placement_strategy="cart"`) |
+| How many basis functions | You pick a fixed count | `adaptive=True` searches a width in `[min_output_dim, max_output_dim]` from the data |
+| Leakage safety | `TargetEncoder` cross-fits internally; nothing else does, and nothing warns you | Every supervised representation emits a `LeakageWarning` outside a `Pipeline`, and any of them can be wrapped in `CrossFittedTransformer` |
+| Feature provenance | `get_feature_names_out()` returns names only | A typed `RepresentationSpec` per transformer plus a `FeatureLineage` record per output column (family, component, target usage) |
+| Persistence | `pickle` / `joblib`, which execute arbitrary code on load | `to_spec()` / `from_spec()`: a versioned JSON schema that never runs estimator code, plus a stable `fingerprint_` |
+| Choosing per column | Hand-assemble a `ColumnTransformer` yourself | One `Preprocessor(feature_preprocessing={...})`, validated against a capability registry so incompatible combinations (a required-target method without `y`, for example) raise a typed error at fit time |
+
+```{note}
+Piecewise-linear encoding (`ple`) and the neural-style basis maps (`rbf`, `relu`, `sigmoid`,
+`tanh`, deterministic `fourier`) have no scikit-learn equivalent. `rff` and `nystroem` are thin
+wrappers around scikit-learn's own `RBFSampler` and `Nystroem`, exposed through the same
+`Preprocessor` interface as every other method.
+```
+
+## When to reach for PreTab
+
+PreTab is a good fit when any of the following is true.
+
+- You pair a **simple or linear model** (Ridge, logistic regression, a GAM, a linear layer)
+ with tabular data and want it to capture non-linear structure.
+- You need **expressive numerical representations** such as splines, radial basis maps,
+ Fourier features, or piecewise-linear encoding without wiring each one by hand.
+- You want **per-column control** over preprocessing from a single configuration object.
+- You care about **reproducibility and inspection**: knowing exactly which input produced
+ each output column, serializing a fitted representation, and getting a stable fingerprint.
+- You are **researching representations** and want a common, typed intermediate form
+ (`RepresentationSpec` plus feature lineage) shared across every family.
+
+```{tip}
+Basis expansion helps most when the model downstream is comparatively simple. A rich,
+already-non-linear model such as gradient boosting can learn many of these shapes on its
+own, so the marginal benefit of an explicit basis is smaller there. See
+[Choosing a method](../representations/choosing_a_method.md) for the trade-offs.
+```
+
+## What PreTab is not
+
+Knowing the boundaries is as useful as knowing the features. PreTab deliberately does not
+try to be an everything-library.
+
+- **Not a modelling library.** PreTab produces features. It does not fit predictors, tune
+ models, or select features for you. It sits *in front of* an estimator.
+- **Not a time-series toolkit.** Generic lag and rolling-window utilities were removed on
+ purpose. PreTab keeps the periodic encoding that expresses cyclic structure (hour, day,
+ month) but leaves sequence modelling to dedicated libraries.
+- **Not a data-cleaning suite.** It offers principled, centrally-defined policies for
+ missing values, constant columns, and out-of-range inputs, but it is not a substitute for
+ domain-specific data validation.
+- **Not a guaranteed accuracy win.** An expressive basis in front of a model that is already
+ flexible, or on a feature with no non-linear signal, can add columns without adding value.
+ The [failure modes](../representations/choosing_a_method.md#when-basis-expansion-does-not-help)
+ section is explicit about where it does not help.
+
+## Two ways to use it
+
+PreTab exposes the same capabilities through two surfaces.
+
+::::{grid} 1 1 2 2
+:gutter: 3
+
+:::{grid-item-card} The high-level `Preprocessor`
+Detects column types from a `DataFrame`, applies a strategy per column, and returns
+model-ready blocks or a single stacked array. Reach for it when you want per-column
+strategies from one config.
+:::
+
+:::{grid-item-card} Standalone transformers
+Every strategy is also a plain scikit-learn transformer you can import and compose inside a
+`Pipeline` or `ColumnTransformer`. Reach for them when you want a single estimator object.
+:::
+
+::::
+
+The [Choosing an interface](choosing_an_interface.md) page explains which to pick.
+
+## Where to go next
+
+- [Installation](installation.md) sets up PreTab and its optional extras.
+- [Quickstart](quickstart.md) fits your first `Preprocessor` in a few minutes.
+- [Core concepts](../core_concepts/feature_representation.md) explains the ideas that run
+ through the whole library.
+- [Representations](../representations/overview.md) is the catalogue of every method.
diff --git a/docs/getting_started/quickstart.md b/docs/getting_started/quickstart.md
index cba8e88..63a9d1d 100644
--- a/docs/getting_started/quickstart.md
+++ b/docs/getting_started/quickstart.md
@@ -1,16 +1,23 @@
# Quickstart
-This page walks through the two ways to use pretab:
+This page fits your first representation in a few minutes. It covers the two ways to use
+PreTab: the high-level `Preprocessor` that builds a full pipeline from a config, and the
+individual transformers that behave like any other scikit-learn step.
-1. the high-level `Preprocessor` (`pretab.preprocessor.Preprocessor`), which builds a full
- scikit-learn pipeline from a config, and
-2. the individual transformers, which behave like any other `sklearn` transformer.
+## Install
-## Using the `Preprocessor`
+```bash
+pip install pretab
+```
+
+See [Installation](installation.md) for optional extras such as language embeddings and
+LightGBM-based placement.
+
+## Fit a `Preprocessor`
-The `Preprocessor` detects feature types automatically and applies per-feature
-preprocessing. It returns either a dictionary of feature blocks (default) or a single
-stacked array.
+The `Preprocessor` inspects a `DataFrame`, decides which columns are numerical and which are
+categorical, and applies a strategy per column. It returns a dictionary of feature blocks by
+default, or a single stacked array on request.
```python
import numpy as np
@@ -18,87 +25,113 @@ import pandas as pd
from pretab import Preprocessor
-# Simulated tabular dataset
+rng = np.random.default_rng(0)
df = pd.DataFrame({
- "age": np.random.randint(18, 65, size=100),
- "income": np.random.normal(60000, 15000, size=100).astype(int),
- "job": np.random.choice(["nurse", "engineer", "scientist", "teacher"], size=100),
- "city": np.random.choice(["Berlin", "Munich", "Hamburg", "Cologne"], size=100),
- "experience": np.random.randint(0, 40, size=100),
+ "age": rng.integers(18, 65, size=200),
+ "income": rng.normal(60_000, 15_000, size=200).astype(int),
+ "experience": rng.integers(0, 40, size=200),
+ "job": rng.choice(["nurse", "engineer", "scientist", "teacher"], size=200),
+ "city": rng.choice(["Berlin", "Munich", "Hamburg", "Cologne"], size=200),
})
-y = np.random.randn(100, 1)
+y = np.sin(df["age"] / 10) + df["income"] / 1e5 + rng.normal(0, 0.1, size=200)
-# Optional per-feature preprocessing config
config = {
- "age": "ple",
- "income": "rbf",
- "experience": "quantile",
+ "age": "ple", # supervised piecewise-linear encoding
+ "income": "rbf", # radial basis feature map
+ "experience": "naturalspline",
"job": "one-hot",
- "city": "none",
+ "city": "int", # integer (ordinal) codes
}
+pre = Preprocessor(feature_preprocessing=config, task="regression", random_state=0)
-preprocessor = Preprocessor(feature_preprocessing=config, task="regression")
+# Fit and transform into a dict of feature blocks
+X_dict = pre.fit_transform(df, y)
+{k: v.shape for k, v in X_dict.items()}
+```
-# Fit and transform into a dictionary of feature arrays
-X_dict = preprocessor.fit_transform(df, y)
+```{tip}
+When no per-feature config is given, the `Preprocessor` falls back to its global
+`numerical_method` (default `"ple"`) and `categorical_method` (default `"int"`). See
+[Configuration](../core_concepts/configuration.md) for every knob.
+```
-# ... or get a single stacked array instead
-X_array = preprocessor.transform(df, return_array=True)
+Ask for a single stacked matrix instead when you feed a plain estimator:
-# Inspect the resolved feature metadata
-preprocessor.get_feature_info(verbose=True)
+```python
+X = pre.transform(df, return_array=True) # one ndarray, one row per sample
```
-```{tip}
-When no per-feature config is provided, the `Preprocessor` falls back to the global
-`numerical_method` and `categorical_method` strategies. See the
-[User Guide](../user_guide/preprocessing.md) for the full list of options.
+## Inspect what was built
+
+Every fitted representation is self-describing. Read the resolved layout, or trace each
+output column back to its source.
+
+```python
+pre.get_feature_info(verbose=True) # human-readable table of per-feature pipelines
+
+lineage = pre.get_feature_lineage() # one record per output column
+lineage[0]
```
-## Using individual transformers
+The lineage covers every output column, and the names line up with `get_feature_names_out`.
+See [Outputs and inspection](../core_concepts/outputs_and_inspection.md) for the full
+contract.
+
+## Use a transformer on its own
-Every transformer follows the standard `sklearn` `fit` / `transform` API, so it can be
-dropped into a `Pipeline` or `ColumnTransformer`.
+Every strategy is also importable from `pretab.transformers` and follows the scikit-learn
+API, so it drops into a `Pipeline` or `ColumnTransformer`.
```python
import numpy as np
from pretab.transformers import PLETransformer
-x = np.random.randn(100, 1)
-y = np.random.randn(100, 1)
+x = np.random.randn(200, 1)
+y = np.random.randn(200)
x_ple = PLETransformer(output_dim=15, task="regression").fit_transform(x, y)
-assert x_ple.shape[1] == 15
+x_ple.shape[1] # number of piecewise-linear bins
```
```{note}
-`PLETransformer` is supervised: it uses the target `y` during `fit` to place its bin
-edges. Always pass `y` when fitting it, or any pipeline that includes it.
+`PLETransformer` is supervised: it reads the target `y` during `fit` to place its bin edges.
+Always pass `y` when fitting it, or any pipeline that contains it. See
+[Target awareness](../core_concepts/target_awareness.md).
```
-For spline transformers, the penalty matrix can be extracted with
-`get_penalty_matrix()`:
+Spline families that carry a smoothness penalty expose it through `get_penalty_matrix()`:
```python
import numpy as np
-from pretab.transformers import ThinPlateSplineTransformer
+from pretab.transformers import NaturalCubicSplineTransformer
+
+x = np.random.randn(200, 1)
+spline = NaturalCubicSplineTransformer(output_dim=8)
+spline.fit_transform(x)
+
+penalty = spline.get_penalty_matrix() # second-difference penalty for GAM-style fitting
+```
-x = np.random.randn(100, 1)
+The multivariate thin-plate spline models several columns jointly and is sized by
+`n_components` rather than `output_dim`:
-tp = ThinPlateSplineTransformer(output_dim=15)
-x_tp = tp.fit_transform(x)
-assert x_tp.shape[1] == 15
+```python
+import numpy as np
+
+from pretab.transformers import ThinPlateSplineTransformer
+x = np.random.randn(200, 2) # two input columns, modelled together
+tp = ThinPlateSplineTransformer(n_components=10)
+features = tp.fit_transform(x)
penalty = tp.get_penalty_matrix()
```
## Next steps
-- See pretab feed a real model, baseline vs. pretab, in the
- [end-to-end example](end_to_end.md).
-- Work through a [classification tutorial](../tutorials/classification.md) or an
- [sklearn Pipeline tutorial](../tutorials/sklearn_pipeline.md).
-- Learn about the available strategies in the [User Guide](../user_guide/preprocessing.md).
-- Browse every class in the [API Reference](../api/index.rst).
+- See PreTab lift a linear model, baseline versus PreTab, in the
+ [non-linear regression tutorial](../tutorials/nonlinear_regression.md).
+- Decide between the two surfaces in [Choosing an interface](choosing_an_interface.md).
+- Browse every method in [Representations](../representations/overview.md).
+- Learn the shared ideas in [Core concepts](../core_concepts/feature_representation.md).
diff --git a/docs/homepage.md b/docs/homepage.md
index 0a3e843..de01e60 100644
--- a/docs/homepage.md
+++ b/docs/homepage.md
@@ -93,6 +93,12 @@ X = pre.fit_transform(df, y)
::::{grid} 1 1 3 3
:gutter: 2
+:::{grid-item-card} Overview
+:link: getting_started/overview
+:link-type: doc
+What PreTab is, what it is not, and where it fits.
+:::
+
:::{grid-item-card} Installation
:link: getting_started/installation
:link-type: doc
@@ -105,19 +111,19 @@ Install PreTab from PyPI or from source.
Fit and transform a dataset in a few lines.
:::
-:::{grid-item-card} End-to-end example
-:link: getting_started/end_to_end
+:::{grid-item-card} Nonlinear regression
+:link: tutorials/nonlinear_regression
:link-type: doc
See PreTab lift a linear model, baseline vs. PreTab.
:::
-:::{grid-item-card} Tutorials
-:link: tutorials/sklearn_pipeline
+:::{grid-item-card} Representations
+:link: representations/overview
:link-type: doc
-Classification and full `sklearn` pipelines.
+The full catalogue of splines, feature maps, and encoders.
:::
-:::{grid-item-card} API Reference
+:::{grid-item-card} API reference
:link: api/index
:link-type: doc
The `Preprocessor` and every transformer.
diff --git a/docs/index.rst b/docs/index.rst
index d75127c..81b0d5b 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -6,25 +6,51 @@
:maxdepth: 1
:hidden:
+ getting_started/overview
getting_started/installation
getting_started/quickstart
- getting_started/end_to_end
+ getting_started/choosing_an_interface
+ getting_started/migration_to_1_0
.. toctree::
- :caption: Tutorials
+ :caption: Core Concepts
:maxdepth: 1
:hidden:
- tutorials/classification
- tutorials/sklearn_pipeline
+ core_concepts/feature_representation
+ core_concepts/configuration
+ core_concepts/resolution_and_placement
+ core_concepts/target_awareness
+ core_concepts/missing_values
+ core_concepts/outputs_and_inspection
+ core_concepts/reproducibility
.. toctree::
- :caption: User Guide
+ :caption: Representations
:maxdepth: 1
:hidden:
- user_guide/preprocessing
- user_guide/configuration
+ representations/overview
+ representations/comparison_table
+ representations/choosing_a_method
+ representations/splines
+ representations/feature_maps
+ representations/binning_and_ple
+ representations/categorical
+ representations/references
+
+.. toctree::
+ :caption: Tutorials
+ :maxdepth: 1
+ :hidden:
+
+ tutorials/nonlinear_regression
+ tutorials/target_aware_classification
+ tutorials/comparing_representations
+ tutorials/adaptive_resolution
+ tutorials/multivariate_features
+ tutorials/sklearn_pipeline
+ tutorials/custom_representation
.. toctree::
:caption: API Reference
@@ -39,5 +65,7 @@
:hidden:
developer_guide/contributing
+ developer_guide/testing
+ developer_guide/documentation
developer_guide/versioning
developer_guide/release
diff --git a/docs/representations/binning_and_ple.md b/docs/representations/binning_and_ple.md
new file mode 100644
index 0000000..48852b9
--- /dev/null
+++ b/docs/representations/binning_and_ple.md
@@ -0,0 +1,90 @@
+# Binning and PLE
+
+Discretization turns a continuous feature into regions. It captures sharp, threshold-like
+effects that smooth bases blur, and it is the natural representation when a feature acts in
+steps. PreTab offers unsupervised numeric binning and supervised piecewise-linear encoding
+(PLE).
+
+## Numeric binning
+
+Numeric binning splits a feature into intervals and encodes which interval each value falls
+into. You choose how the edges are placed and how the result is encoded.
+
+```python
+from pretab.transformers import NumericBinningTransformer
+
+t = NumericBinningTransformer(output_dim=8, encode="onehot", placement_strategy="quantile")
+```
+
+The `encode` parameter selects the output form.
+
+`"ordinal"`
+: A single integer column giving the bin index.
+
+`"onehot"`
+: One indicator column per bin.
+
+`"soft"`
+: A soft assignment that spreads each value across neighbouring bins, so the boundaries are not
+ hard. This keeps a little of the smoothness that hard binning discards.
+
+Edge placement follows `placement_strategy`: `"uniform"` for equal-width bins, `"quantile"`
+for equal-frequency bins. See
+[Resolution and placement](../core_concepts/resolution_and_placement.md).
+
+```{tip}
+Quantile edges give every bin a similar number of samples, which is usually more stable than
+equal-width bins when the feature is skewed.
+```
+
+## Piecewise-linear encoding
+
+PLE is the flagship supervised representation. It fits a decision tree of the feature against
+the target, reads the split points as bin edges, and encodes each value as its **linear
+position within its bin**. The result is a piecewise-linear function that bends exactly where
+the target changes, following the tabular deep-learning work of Gorishniy and colleagues.
+
+```python
+from pretab.transformers import PLETransformer
+
+t = PLETransformer(output_dim=12, task="regression")
+X2 = t.fit_transform(x, y) # y is required
+```
+
+Constructor highlights: `output_dim`, `placement_strategy="cart"`, `task="regression"`,
+`adaptive`, `random_state=51`, and the tree controls `max_depth`, `min_samples_split`,
+`min_samples_leaf`.
+
+```{important}
+PLE **requires** the target. It places its edges using `y`, so it must be fit with a target
+and should be fit leakage-safely, ideally with cross-fitting. See
+[Target awareness](../core_concepts/target_awareness.md).
+```
+
+### Why piecewise-linear rather than one-hot
+
+Plain binning throws away where a value sits inside its bin; two values in the same interval
+become identical. PLE keeps the within-bin position as a linear ramp, so it retains fine
+resolution while still capturing the sharp transitions the tree found. That combination is why
+it works so well as a front-end for both linear models and neural networks.
+
+```{tip}
+PLE is a strong default for numerical features, and it is the default `numerical_method` on
+`Preprocessor`. Reach for it first when you have a supervised task and want the representation
+to follow the target.
+```
+
+## Binning versus PLE
+
+| | Numeric binning | PLE |
+| --- | --- | --- |
+| Uses the target | No | Yes (required) |
+| Within-bin resolution | Lost (hard) or blurred (soft) | Preserved (linear) |
+| Edge placement | Uniform or quantile | Target-driven (tree splits) |
+| Best for | Unsupervised, known step structure | Supervised sharp effects |
+
+## Where to go next
+
+- [Target awareness](../core_concepts/target_awareness.md) for fitting PLE safely.
+- [Splines](splines.md) for smooth alternatives to binning.
+- [References](references.md) for the PLE source.
diff --git a/docs/representations/categorical.md b/docs/representations/categorical.md
new file mode 100644
index 0000000..c1eaa88
--- /dev/null
+++ b/docs/representations/categorical.md
@@ -0,0 +1,86 @@
+# Categorical
+
+Categorical features range from a handful of labels to free text with thousands of distinct
+values. PreTab covers the spectrum: compact integer encoding, explicit one-hot, and pretrained
+language embeddings for high-cardinality text. All of them handle unseen categories without
+raising.
+
+## Integer (ordinal) encoding
+
+The default categorical method maps each category to an integer. It is compact and works well
+as an input to models that consume category indices, such as embedding layers.
+
+```python
+from pretab.transformers import ContinuousOrdinalTransformer
+
+t = ContinuousOrdinalTransformer()
+X2 = t.fit_transform(x)
+```
+
+Unseen categories at transform time map to a reserved slot rather than raising, so a model in
+production never crashes on a new label.
+
+```{note}
+Integer encoding imposes an order on the codes. Feed it to models that treat the code as an
+index (trees, embedding layers), not to a plain linear model that would read the codes as
+magnitudes.
+```
+
+## One-hot encoding
+
+One-hot encoding produces one indicator column per category, the right choice when the
+downstream model should treat categories as unordered.
+
+```python
+pre = Preprocessor(categorical_method="one-hot")
+```
+
+The alias `ohe` resolves to `one-hot`. There is also `onehot_from_ordinal`, which one-hot
+encodes an already integer-coded column.
+
+```{warning}
+One-hot width grows with cardinality. A column with thousands of categories produces thousands
+of columns. Use the [output budget](../core_concepts/outputs_and_inspection.md) to cap it, or
+prefer integer encoding or embeddings for high-cardinality columns.
+```
+
+## Language embeddings
+
+For high-cardinality text categories (product titles, free-text tags, descriptions), a
+pretrained sentence embedding captures semantic similarity that integer or one-hot encoding
+cannot. Similar labels land near each other in the embedding space.
+
+```python
+from pretab.transformers import LanguageEmbeddingTransformer
+
+t = LanguageEmbeddingTransformer(model_name="paraphrase-MiniLM-L3-v2")
+X2 = t.fit_transform(x)
+```
+
+Constructor highlights: `model_name="paraphrase-MiniLM-L3-v2"`, or pass a preloaded `model`.
+The registry key is `pretrained`.
+
+```{important}
+Language embeddings require the optional `embeddings` extra, which pulls in
+`sentence-transformers`. Install it with `pip install "pretab[embeddings]"`. Without it,
+requesting `pretrained` raises a clear `OptionalDependencyError`.
+```
+
+```{tip}
+Embeddings shine when category labels carry meaning as text. If the labels are opaque codes
+with no semantic content, integer encoding is simpler and just as effective.
+```
+
+## Choosing a categorical method
+
+| If the column is... | Reach for... |
+| --- | --- |
+| Low cardinality, unordered | One-hot |
+| Fed to a tree or embedding layer | Integer |
+| High-cardinality meaningful text | Language embedding |
+
+## Where to go next
+
+- [Missing values](../core_concepts/missing_values.md) for categorical imputation.
+- [Configuration](../core_concepts/configuration.md) to set categorical methods per column.
+- [Installation](../getting_started/installation.md) for the `embeddings` extra.
diff --git a/docs/representations/choosing_a_method.md b/docs/representations/choosing_a_method.md
new file mode 100644
index 0000000..7f8834d
--- /dev/null
+++ b/docs/representations/choosing_a_method.md
@@ -0,0 +1,116 @@
+# Choosing a method
+
+This page gives practical guidance for picking a representation, and it is honest about where
+representations do not help. If you read only one page in this section, read this one.
+
+## Start from the model
+
+The right representation depends on what sits downstream.
+
+Linear and additive models
+: These gain the most from expansion. A linear model on top of a spline or PLE basis can fit
+ smooth nonlinearities while staying interpretable. This is the primary use case for PreTab.
+
+Gradient-boosted trees
+: Trees already partition each feature, so raw or lightly-scaled inputs are usually enough.
+ Expansion rarely helps and often adds noise. See
+ [when it does not help](#when-basis-expansion-does-not-help).
+
+Neural networks
+: PLE and learned embeddings are effective front-ends, echoing the tabular deep-learning
+ literature. Splines can help shallow networks.
+
+## Match the method to the signal
+
+| If the relationship is... | Reach for... |
+| --- | --- |
+| Smooth and curved | B-spline, natural cubic spline, P-spline |
+| Monotone (must not reverse) | I-spline |
+| Sharp, threshold-like | PLE, numeric binning, ReLU expansion |
+| Local bumps around centers | RBF expansion |
+| Periodic (known period) | Periodic encoding, Fourier features |
+| A smooth surface over two inputs | Tensor-product or thin-plate spline |
+| A general kernel over many inputs | Random Fourier features, Nyström |
+
+```{tip}
+When unsure, start with the `"standard"` preset (min-max scaling, PLE for numericals, integer
+categoricals) and compare against a spline. The
+[comparing representations tutorial](../tutorials/comparing_representations.md) shows how to
+measure the difference instead of guessing.
+```
+
+## Match the method to the target
+
+- If the relationship between a feature and the target is what you want to capture, a
+ **target-aware** method (PLE, or a spline with `target_aware=True`) places its units where
+ the target changes. Always fit these leakage-safely, see
+ [Target awareness](../core_concepts/target_awareness.md).
+- If you only want a flexible unsupervised basis, an **unsupervised** method (P-spline,
+ Fourier, quantile-placed spline) avoids target usage entirely.
+
+## Control the width
+
+More columns means more flexibility and more overfitting risk. Start narrow and widen only if
+validation improves. Turn on `adaptive=True` to let the data choose a width between
+`min_output_dim` and `max_output_dim`. See
+[Resolution and placement](../core_concepts/resolution_and_placement.md).
+
+## When basis expansion does not help
+
+Expansion is a tool, not a default. There are clear cases where it adds cost without value,
+and pretending otherwise would be dishonest.
+
+Tree ensembles already handle nonlinearity
+: Gradient-boosted trees and random forests split each feature into regions on their own.
+ Feeding them a spline or binning basis usually leaves accuracy unchanged while multiplying
+ the column count. Prefer raw or scaled inputs for these models.
+
+Truly linear relationships
+: If a feature enters the target linearly, scaling is enough. A spline will fit the same line
+ with extra parameters and a little more variance.
+
+Very small samples
+: A wide expansion on a few hundred rows overfits. Keep `output_dim` small, or skip expansion
+ and rely on a scaled input.
+
+Extrapolation beyond the fitted range
+: Bases are fitted on the training range. Splines, PLE, and feature maps are undefined or flat
+ outside it, so they do not extrapolate. If your test data lies well beyond training, no
+ expansion recovers the missing signal. See the edge-case behaviour below.
+
+Pure noise features
+: Expanding a feature that carries no signal only gives the model more ways to fit noise. Drop
+ the feature instead.
+
+```{warning}
+Basis expansion changes the geometry of your features, not the information in them. If a
+feature does not carry the signal, no representation will create it. Measure, do not assume.
+```
+
+## Edge-case behaviour
+
+PreTab is explicit about degenerate inputs rather than failing silently.
+
+- **Constant column**: methods that need spread degrade gracefully to a trivial, valid output
+ rather than raising.
+- **Out-of-range input at transform**: values beyond the fitted range are clamped or produce a
+ flat response, consistent with the fitted basis, never an extrapolated fantasy.
+- **Unseen category**: unknown categories map to a reserved slot rather than an error.
+- **NaN into a finite-only method**: raises a typed error unless imputation is configured. See
+ [Missing values](../core_concepts/missing_values.md).
+
+## Non-goals
+
+To set expectations, PreTab deliberately does not do the following.
+
+- It is **not** a feature-selection library. It represents the features you give it; it does
+ not decide which features to keep.
+- It is **not** a modelling library. It produces representations; you bring the estimator.
+- It does **not** invent signal. It reshapes existing information into a more learnable form.
+
+## Where to go next
+
+- [Comparison table](comparison_table.md) to filter by capability.
+- [Splines](splines.md), [Feature maps](feature_maps.md),
+ [Binning and PLE](binning_and_ple.md), [Categorical](categorical.md) for the details.
+- [Comparing representations](../tutorials/comparing_representations.md) to measure the choice.
diff --git a/docs/representations/comparison_table.md b/docs/representations/comparison_table.md
new file mode 100644
index 0000000..4e93bbf
--- /dev/null
+++ b/docs/representations/comparison_table.md
@@ -0,0 +1,102 @@
+# Comparison table
+
+Use this page to filter representations by capability. It is a static reference; for a live,
+queryable view use `list_representations(...)` against the registry. The registry is the single
+source of truth, and these tables mirror it.
+
+## Reading the columns
+
+`Key`
+: The string you pass to `numerical_method`, `categorical_method`, or per-feature config.
+
+`Scope`
+: `univariate` (one column) or `multivariate` (several columns jointly).
+
+`Target`
+: `forbidden`, `optional` (used when `target_aware=True`), or `required`.
+
+`Adaptive`
+: Supports data-driven width selection between `min_output_dim` and `max_output_dim`.
+
+`Penalty`
+: Exposes `get_penalty_matrix()` for smoothing penalties.
+
+`Selectable`
+: Can be chosen through `Preprocessor` as a per-column method.
+
+## Numerical: scalers and simple transforms
+
+| Method | Key | Scope | Target | Selectable |
+| --- | --- | --- | --- | --- |
+| Standardization | `standardization` | univariate | forbidden | yes |
+| Min-max scaling | `minmax` | univariate | forbidden | yes |
+| Robust scaling | `robust` | univariate | forbidden | yes |
+| Quantile transform | `quantile` | univariate | forbidden | yes |
+| Polynomial features | `polynomial` | univariate | forbidden | yes |
+| Box-Cox | `box-cox` | univariate | forbidden | yes |
+| Yeo-Johnson | `yeo-johnson` | univariate | forbidden | yes |
+| Passthrough | `none` | univariate | forbidden | yes |
+
+## Numerical: splines
+
+| Method | Key | Scope | Target | Adaptive | Penalty | Selectable |
+| --- | --- | --- | --- | --- | --- | --- |
+| B-spline | `bspline` | univariate | optional | yes | no | yes |
+| M-spline | `mspline` | univariate | optional | yes | no | yes |
+| I-spline | `ispline` | univariate | optional | yes | no | yes |
+| Cubic regression spline | `cubicspline` | univariate | optional | yes | yes | yes |
+| Natural cubic spline | `naturalspline` | univariate | optional | yes | yes | yes |
+| Penalized spline (P-spline) | `pspline` | univariate | forbidden | yes | yes | yes |
+| Tensor-product spline | `tensorspline` | multivariate | forbidden | yes | yes | no |
+| Thin-plate spline | `tprs` | multivariate | forbidden | no | yes | no |
+
+```{note}
+The multivariate splines (`tensorspline`, `tprs`) model several inputs jointly and are used
+standalone, not selected per column through `Preprocessor`. The alias `thinplate` resolves to
+`tprs`.
+```
+
+## Numerical: feature maps
+
+| Method | Key | Scope | Target | Adaptive | Selectable |
+| --- | --- | --- | --- | --- | --- |
+| RBF expansion | `rbf` | univariate | optional | yes | yes |
+| ReLU expansion | `relu` | univariate | optional | yes | yes |
+| Sigmoid expansion | `sigmoid` | univariate | optional | yes | yes |
+| Tanh expansion | `tanh` | univariate | optional | yes | yes |
+| Fourier features | `fourier` | univariate | forbidden | no | yes |
+| Random Fourier features | `rff` | multivariate | forbidden | no | no |
+| Nyström kernel map | `nystroem` | multivariate | forbidden | no | no |
+
+## Numerical: discretization
+
+| Method | Key | Scope | Target | Adaptive | Selectable |
+| --- | --- | --- | --- | --- | --- |
+| Numeric binning | `custombin` | univariate | forbidden | no | yes |
+| Piecewise-linear encoding (PLE) | `ple` | univariate | required | yes | yes |
+
+```{important}
+PLE is the only numerical method that **requires** the target. It always places its bins
+against `y`, so it must be fit with a target and is best used with cross-fitting. See
+[Target awareness](../core_concepts/target_awareness.md).
+```
+
+## Categorical
+
+| Method | Key | Scope | Target | Selectable |
+| --- | --- | --- | --- | --- |
+| Ordinal (integer) encoding | `int` | univariate | forbidden | yes |
+| One-hot encoding | `one-hot` | univariate | forbidden | yes |
+| One-hot from ordinal | `onehot_from_ordinal` | univariate | forbidden | yes |
+| Pretrained language embedding | `pretrained` | univariate | forbidden | yes |
+| Passthrough | `none` | univariate | forbidden | yes |
+
+```{note}
+`pretrained` requires the optional `embeddings` extra. The alias `ohe` resolves to `one-hot`.
+```
+
+## Where to go next
+
+- [Choosing a method](choosing_a_method.md) for guidance on which of these to reach for.
+- [Splines](splines.md), [Feature maps](feature_maps.md),
+ [Binning and PLE](binning_and_ple.md), [Categorical](categorical.md) for the details.
diff --git a/docs/representations/feature_maps.md b/docs/representations/feature_maps.md
new file mode 100644
index 0000000..8179ca1
--- /dev/null
+++ b/docs/representations/feature_maps.md
@@ -0,0 +1,128 @@
+# Feature maps
+
+Feature maps are basis functions borrowed from machine learning rather than classical
+statistics. They spread a feature across a set of activation functions (radial bumps, ReLU
+ramps, sigmoids) or project it onto a Fourier basis, and they include the two standard
+kernel approximations. Together they cover local, threshold, and periodic structure.
+
+## Radial basis functions
+
+The RBF expansion places centers along the feature range and measures Gaussian similarity to
+each,
+
+$$
+\phi_k(x) = \exp\!\big(-\gamma\,(x - c_k)^2\big).
+$$
+
+Each output is a smooth bump around a center, so a linear model on top can build up a curve
+from local pieces.
+
+```python
+from pretab.transformers import RBFExpansionTransformer
+
+t = RBFExpansionTransformer(output_dim=10, gamma=1.0)
+```
+
+Constructor highlights: `output_dim`, `gamma=1.0` (bump width; larger is narrower),
+`target_aware=False`, `placement_strategy`, `adaptive`, `random_state`.
+
+```{tip}
+`gamma` trades locality for coverage. Large `gamma` gives narrow, sharply local bumps; small
+`gamma` gives broad, overlapping ones. Tune it alongside `output_dim`.
+```
+
+## ReLU, sigmoid, and tanh expansions
+
+These place a set of thresholds along the range and apply an activation at each, mirroring a
+single hidden layer.
+
+ReLU
+: Piecewise-linear ramps. Excellent for sharp, threshold-like effects.
+
+Sigmoid and Tanh
+: Smooth saturating steps. `scale` controls the steepness of the transition.
+
+```python
+from pretab.transformers import ReLUExpansionTransformer, TanhExpansionTransformer
+
+relu = ReLUExpansionTransformer(output_dim=10)
+tanh = TanhExpansionTransformer(output_dim=10, scale=1.0)
+```
+
+```{note}
+ReLU expansions are a natural fit when the effect of a feature turns on past a threshold, for
+example a fee that applies only above a limit.
+```
+
+## Fourier features
+
+The Fourier map represents a feature with sines and cosines at a set of frequencies, ideal for
+signals with cyclical structure.
+
+```python
+from pretab.transformers import FourierFeatureTransformer
+
+t = FourierFeatureTransformer(n_frequencies=5, frequency_strategy="harmonic")
+```
+
+Constructor highlights: `n_frequencies=5`, `frequency_strategy="harmonic"`,
+`include_original=False`, `random_state`.
+
+### Periodic encoding
+
+When you know the period, the periodic encoder is the direct choice. It maps a value onto its
+position in a cycle of known length, so December and January sit next to each other.
+
+```python
+from pretab.transformers import PeriodicEncodingTransformer
+
+t = PeriodicEncodingTransformer(period=12, harmonics=2) # e.g. month of year
+```
+
+```{tip}
+Use `PeriodicEncodingTransformer` when the period is known (hour of day, month of year). Use
+`FourierFeatureTransformer` when you want the model to work across a set of frequencies.
+```
+
+## Kernel approximations
+
+Two multivariate maps approximate a kernel machine without forming the full kernel matrix.
+They are standalone transformers, not per-column methods.
+
+### Random Fourier features
+
+Approximates a shift-invariant kernel (by default the RBF kernel) with random projections,
+following Rahimi and Recht. This makes kernel-style models scale to large datasets.
+
+```python
+from pretab.transformers import RandomFourierFeaturesTransformer
+
+t = RandomFourierFeaturesTransformer(n_components=100, gamma=1.0)
+X2 = t.fit_transform(X)
+```
+
+### Nyström
+
+Approximates a kernel by sampling landmark points and projecting onto them, following Williams
+and Seeger. It supports several kernels through `kernel`.
+
+```python
+from pretab.transformers import NystroemFeaturesTransformer
+
+t = NystroemFeaturesTransformer(n_components=100, kernel="rbf")
+X2 = t.fit_transform(X)
+```
+
+Constructor highlights: `n_components=100`, `kernel="rbf"`, `gamma=None`, `degree=3`,
+`coef0=1`, `random_state`.
+
+```{warning}
+Random Fourier features and Nyström are multivariate and operate on the whole input matrix.
+They are not available as a per-column `numerical_method`; fit them standalone.
+```
+
+## Where to go next
+
+- [Splines](splines.md) for smooth statistical bases.
+- [Binning and PLE](binning_and_ple.md) for discretization.
+- [References](references.md) for the kernel-approximation literature.
diff --git a/docs/representations/overview.md b/docs/representations/overview.md
new file mode 100644
index 0000000..aed5c1e
--- /dev/null
+++ b/docs/representations/overview.md
@@ -0,0 +1,91 @@
+# Representations overview
+
+This section is the catalogue of every representation PreTab ships. Each family turns raw
+columns into an expressive basis, and they all share the same vocabulary and the same
+scikit-learn API. Start here to see the landscape, then dive into the family that fits your
+data.
+
+## The families
+
+::::{grid} 1 1 2 2
+:gutter: 3
+
+:::{grid-item-card} Splines
+:link: splines
+:link-type: doc
+Smooth, locally-supported bases: B, M, I, cubic regression, natural cubic, penalized
+(P-spline), and the multivariate tensor-product and thin-plate splines.
+:::
+
+:::{grid-item-card} Feature maps
+:link: feature_maps
+:link-type: doc
+Basis functions from machine learning: radial (RBF), ReLU, sigmoid, tanh, deterministic
+Fourier, and the kernel approximations (random Fourier features, Nyström).
+:::
+
+:::{grid-item-card} Binning and PLE
+:link: binning_and_ple
+:link-type: doc
+Discretization: numeric binning with several encodings, and supervised piecewise-linear
+encoding (PLE).
+:::
+
+:::{grid-item-card} Categorical
+:link: categorical
+:link-type: doc
+Ordinal and one-hot encoding, plus pretrained language embeddings for high-cardinality text.
+:::
+
+::::
+
+## Shared terminology
+
+Every family is described with the same terms, introduced in
+[Preprocessing and representation](../core_concepts/feature_representation.md).
+
+`scope`
+: `univariate` methods transform one column at a time. `multivariate` methods (tensor-product
+ spline, thin-plate spline, random Fourier features, Nyström) model several columns jointly
+ and are used standalone, not per column through `Preprocessor`.
+
+`supervision`
+: `forbidden`, `optional`, or `required` target usage. See
+ [Target awareness](../core_concepts/target_awareness.md).
+
+`output_dim`
+: The width of the expansion. See
+ [Resolution and placement](../core_concepts/resolution_and_placement.md).
+
+`placement`
+: Where the knots, centers, or edges go, chosen by `target_aware` and `placement_strategy`.
+
+## How to select a method
+
+There are two ways to pick.
+
+- **By intent**: read [Choosing a method](choosing_a_method.md) for practical guidance,
+ including where basis expansion does not help.
+- **By capability**: read the [comparison table](comparison_table.md) to filter families by
+ feature kind, scope, supervision, and adaptivity.
+
+You can also query the registry in code:
+
+```python
+from pretab import list_representations
+
+list_representations(feature_kind="numerical", supervised=True)
+```
+
+## A note on scientific grounding
+
+Every family rests on established theory, from B-splines and P-splines to thin-plate
+regression splines and random Fourier features. The [references](references.md) page collects
+the primary sources for each, so the representations are traceable to their literature.
+
+## Where to go next
+
+- [Splines](splines.md), [Feature maps](feature_maps.md), [Binning and PLE](binning_and_ple.md),
+ [Categorical](categorical.md) for the families.
+- [Comparison table](comparison_table.md) to filter by capability.
+- [Choosing a method](choosing_a_method.md) for guidance and failure modes.
diff --git a/docs/representations/references.md b/docs/representations/references.md
new file mode 100644
index 0000000..7f92170
--- /dev/null
+++ b/docs/representations/references.md
@@ -0,0 +1,57 @@
+# References
+
+The representations in PreTab rest on established literature. This page collects the primary
+sources for each family, so every method is traceable to its origin. Citations are grouped by
+representation.
+
+## Splines and penalized splines
+
+Eilers, P. H. C., and Marx, B. D. (1996). Flexible smoothing with B-splines and penalties.
+*Statistical Science*, 11(2), 89-121.
+
+Eilers, P. H. C., and Marx, B. D. (2003). Multivariate calibration with temperature
+interaction using two-dimensional penalized signal regression. *Chemometrics and Intelligent
+Laboratory Systems*, 66(2), 159-174.
+
+These two papers introduce the P-spline (B-spline basis with a difference penalty) and its
+tensor-product extension, which underpin `PSplineTransformer` and
+`TensorProductSplineTransformer`.
+
+## Thin-plate and generalized additive models
+
+Wahba, G. (1990). *Spline Models for Observational Data*. Society for Industrial and Applied
+Mathematics.
+
+Wood, S. N. (2003). Thin plate regression splines. *Journal of the Royal Statistical Society:
+Series B*, 65(1), 95-114.
+
+Wood, S. N. (2017). *Generalized Additive Models: An Introduction with R* (2nd ed.). Chapman
+and Hall/CRC.
+
+Wahba's monograph is the foundation for thin-plate splines; Wood's work gives the low-rank
+thin-plate regression spline and the GAM framing that `ThinPlateSplineTransformer` follows.
+
+## Kernel approximations
+
+Williams, C. K. I., and Seeger, M. (2001). Using the Nyström method to speed up kernel
+machines. *Advances in Neural Information Processing Systems*, 13.
+
+Rahimi, A., and Recht, B. (2007). Random features for large-scale kernel machines. *Advances
+in Neural Information Processing Systems*, 20.
+
+These introduce the Nyström method and random Fourier features, implemented as
+`NystroemFeaturesTransformer` and `RandomFourierFeaturesTransformer`.
+
+## Piecewise-linear encoding
+
+Gorishniy, Y., Rubachev, I., and Babenko, A. (2022). On embeddings for numerical features in
+tabular deep learning. *Advances in Neural Information Processing Systems*, 35.
+
+This paper motivates piecewise-linear encoding of numerical features for tabular models, the
+basis for `PLETransformer`.
+
+## Where to go next
+
+- [Representations overview](overview.md) to return to the catalogue.
+- [Splines](splines.md), [Feature maps](feature_maps.md),
+ [Binning and PLE](binning_and_ple.md) for the methods these sources describe.
diff --git a/docs/representations/splines.md b/docs/representations/splines.md
new file mode 100644
index 0000000..28b315b
--- /dev/null
+++ b/docs/representations/splines.md
@@ -0,0 +1,156 @@
+# Splines
+
+Splines are piecewise-polynomial bases with local support. They turn a single numerical column
+into a set of smooth, overlapping basis functions, so a linear model on top can bend to follow
+the data while staying stable. PreTab ships the full family, from the workhorse B-spline to the
+multivariate thin-plate spline.
+
+## The idea
+
+A spline places a set of **knots** along the range of a feature and builds basis functions
+between them. The transformed feature is the vector of basis values,
+
+$$
+x \mapsto \big(B_1(x),\ B_2(x),\ \dots,\ B_K(x)\big),
+$$
+
+where each $B_k$ is nonzero only near a few knots. Local support is what keeps splines stable:
+a point in one region does not disturb the fit in another. Width is set by `output_dim` and
+knot positions by `placement_strategy` (see
+[Resolution and placement](../core_concepts/resolution_and_placement.md)).
+
+## B-spline
+
+The B-spline is the default general-purpose smooth basis. Its functions are non-negative,
+sum to one, and each spans only `degree + 1` knot intervals.
+
+```python
+from pretab.transformers import BSplineTransformer
+
+t = BSplineTransformer(output_dim=13, degree=3, placement_strategy="quantile")
+```
+
+Constructor highlights: `output_dim`, `degree=3`, `include_bias=True`, `knot_locations=None`
+(pass explicit knots to override placement), `target_aware=False`, `placement_strategy="quantile"`,
+`adaptive`, `random_state`.
+
+```{tip}
+Cubic (`degree=3`) B-splines with quantile knots are a strong default for smooth regression.
+Increase `output_dim` for more wiggle, decrease it to regularize.
+```
+
+## M-spline and I-spline
+
+These two share the B-spline machinery but target special shapes.
+
+M-spline
+: A non-negative spline basis (`include_bias=False`). Useful when the components themselves
+ should be non-negative, for example as a density-like basis.
+
+I-spline
+: The integral of an M-spline, giving a **monotone** basis. A model with non-negative
+ coefficients on an I-spline basis is guaranteed monotone in the input, which is valuable when
+ domain knowledge says a relationship cannot reverse.
+
+```python
+from pretab.transformers import ISplineTransformer
+
+t = ISplineTransformer(output_dim=10, degree=3) # monotone basis
+```
+
+```{note}
+I-splines only guarantee monotonicity when the downstream coefficients are constrained to be
+non-negative. Pair them with a non-negative linear model.
+```
+
+## Cubic regression and natural cubic splines
+
+These are penalized-ready cubic bases with a clear knot interpretation, and both expose a
+smoothing penalty through `get_penalty_matrix()`.
+
+Cubic regression spline
+: A cubic basis parameterized at the knots (`cubicspline`), convenient for GAM-style additive
+ models.
+
+Natural cubic spline
+: A cubic spline constrained to be **linear beyond the boundary knots** (`naturalspline`).
+ The linear tails reduce the wild behaviour ordinary cubics show near the edges of the data.
+
+```python
+from pretab.transformers import NaturalCubicSplineTransformer
+
+t = NaturalCubicSplineTransformer(output_dim=12)
+penalty = t.get_penalty_matrix() # for smoothing penalties
+```
+
+```{tip}
+Prefer the natural cubic spline when your feature has sparse data near its extremes; the linear
+tails behave far better than an unconstrained cubic there.
+```
+
+## Penalized spline (P-spline)
+
+The P-spline combines a B-spline basis with a difference penalty on adjacent coefficients,
+following Eilers and Marx. Instead of controlling smoothness only through the number of knots,
+it uses many knots and a penalty of order `diff_order` to keep the fit smooth.
+
+```python
+from pretab.transformers import PSplineTransformer
+
+t = PSplineTransformer(output_dim=20, degree=3, diff_order=2)
+penalty = t.get_penalty_matrix()
+```
+
+Constructor highlights: `output_dim`, `degree=3`, `diff_order=2`, `include_bias=False`,
+`placement_strategy="uniform"`, `adaptive`. The P-spline is unsupervised; it does not read the
+target.
+
+```{note}
+The P-spline decouples smoothness from knot count. Use a generous `output_dim` and let the
+penalty do the regularizing. Its penalty matrix plugs directly into penalized linear models.
+```
+
+## Multivariate splines
+
+Two families model several inputs jointly. They are used standalone, not selected per column
+through `Preprocessor`.
+
+### Tensor-product spline
+
+Builds a joint basis over multiple inputs as the tensor product of per-axis bases, capturing
+interactions on a smooth grid. It exposes an anisotropic penalty.
+
+```python
+from pretab.transformers import TensorProductSplineTransformer
+
+t = TensorProductSplineTransformer(output_dim=8, degree=3, diff_order=2)
+X2 = t.fit_transform(X[["lat", "lon"]])
+```
+
+### Thin-plate spline
+
+A thin-plate regression spline, the smooth-surface method from generalized additive models. It
+places landmarks (by default with k-means) and forms a low-rank basis.
+
+```python
+from pretab.transformers import ThinPlateSplineTransformer
+
+t = ThinPlateSplineTransformer(n_components=10, landmark_strategy="kmeans")
+X2 = t.fit_transform(X[["lat", "lon"]])
+```
+
+Constructor highlights: `n_components=10`, `landmark_strategy="kmeans"`, `rank_strategy="eigen"`,
+`include_bias=False`, `random_state`.
+
+```{warning}
+The tensor-product and thin-plate splines are multivariate. They are standalone transformers
+and are not available as a per-column `numerical_method`. Fit them directly on the columns you
+want to model jointly.
+```
+
+## Where to go next
+
+- [Feature maps](feature_maps.md) for non-spline bases.
+- [Multivariate features tutorial](../tutorials/multivariate_features.md) for a worked joint
+ model.
+- [References](references.md) for the primary spline literature.
diff --git a/docs/tutorials/adaptive_resolution.md b/docs/tutorials/adaptive_resolution.md
new file mode 100644
index 0000000..86d4190
--- /dev/null
+++ b/docs/tutorials/adaptive_resolution.md
@@ -0,0 +1,96 @@
+# Adaptive resolution
+
+Picking the width of an expansion by hand is guesswork. Adaptive resolution lets the data
+choose it for you, within bounds you set. This tutorial shows how to turn it on and how to read
+the width that was selected.
+
+## The idea
+
+Every adaptive-capable method accepts three parameters that turn a fixed width into a searched
+one.
+
+`adaptive=True`
+: Enables data-driven width selection.
+
+`min_output_dim` and `max_output_dim`
+: The lower and upper bounds of the search. The method picks a width in this range.
+
+When adaptive is on, `output_dim` becomes a hint rather than a fixed value; the fitted width is
+chosen from the data and stored on the transformer. See
+[Resolution and placement](../core_concepts/resolution_and_placement.md) for the mechanics.
+
+## A worked example
+
+We fit a spline with adaptive width on two signals of different complexity and inspect what each
+one chose.
+
+```python
+import numpy as np
+import pandas as pd
+from pretab.transformers import BSplineTransformer
+
+rng = np.random.default_rng(0)
+n = 3000
+x = rng.uniform(0, 10, n)
+
+simple = 0.5 * x + rng.normal(0, 0.3, n) # nearly linear
+wiggly = np.sin(x * 2) * 3 + rng.normal(0, 0.3, n) # high-frequency
+
+for name, y in [("simple", simple), ("wiggly", wiggly)]:
+ t = BSplineTransformer(adaptive=True, min_output_dim=5, max_output_dim=20)
+ t.fit(x.reshape(-1, 1), y)
+ print(f"{name:8s} -> selected width {t.total_output_dim_}")
+```
+
+```text
+simple -> selected width 5
+wiggly -> selected width 17
+```
+
+The nearly-linear signal needs few basis functions, so adaptive resolution keeps the width at
+the floor. The high-frequency signal needs many, so it climbs toward the ceiling. You get an
+appropriately-sized representation for each without tuning by hand.
+
+```{tip}
+Set `min_output_dim` and `max_output_dim` to a range you consider reasonable, then let the data
+place the width inside it. This is more robust than committing to a single `output_dim` across
+features of different complexity.
+```
+
+## Adaptive across a whole preprocessor
+
+The same switch works at the `Preprocessor` level, so every eligible column adapts
+independently.
+
+```python
+import pandas as pd
+from pretab import Preprocessor
+
+df = pd.DataFrame({"simple": simple, "wiggly": wiggly})
+
+pre = Preprocessor(
+ numerical_method="bspline",
+ adaptive=True,
+ min_output_dim=5,
+ max_output_dim=15,
+)
+pre.fit(df, wiggly)
+pre.get_feature_info()
+```
+
+Each numerical column receives a width suited to its own complexity, visible in the resolved
+feature info.
+
+```{note}
+Adaptive resolution is available for the splines, PLE, and the RBF, ReLU, sigmoid, and tanh
+feature maps. Methods with a fixed structure (Fourier, binning, the kernel approximations)
+ignore the adaptive flag. The [comparison table](../representations/comparison_table.md) marks
+which methods adapt.
+```
+
+## Where to go next
+
+- [Resolution and placement](../core_concepts/resolution_and_placement.md) for how width and
+ placement interact.
+- [Comparing representations](comparing_representations.md) to measure adaptive against fixed.
+- [Choosing a method](../representations/choosing_a_method.md) for width guidance.
diff --git a/docs/tutorials/classification.md b/docs/tutorials/classification.md
deleted file mode 100644
index ff603be..0000000
--- a/docs/tutorials/classification.md
+++ /dev/null
@@ -1,148 +0,0 @@
-# Classification
-
-The [end-to-end example](../getting_started/end_to_end.md) showed pretab in front of a
-regressor. The same idea works for classification: give a linear classifier an expressive
-feature basis and it can learn decision boundaries that a raw model cannot.
-
-Here the target has a **ring-shaped** boundary, where the positive class sits near the origin
-of two coordinates, plus a categorical `plan` effect. A plain `LogisticRegression` draws a
-single straight boundary and struggles; radial basis features let it curve around the ring.
-
-## The dataset
-
-```python
-import numpy as np
-import pandas as pd
-from sklearn.model_selection import train_test_split
-
-rng = np.random.default_rng(1)
-n = 4000
-
-x1 = rng.uniform(-3, 3, n)
-x2 = rng.uniform(-3, 3, n)
-hours = rng.uniform(0, 60, n)
-plan = rng.choice(["free", "pro", "team"], n, p=[0.5, 0.3, 0.2])
-
-# Positive class lives inside a ring around the origin, shifted by the plan.
-plan_effect = pd.Series(plan).map({"free": -0.5, "pro": 0.3, "team": 1.0}).to_numpy()
-logit = 3.0 - (x1**2 + x2**2) + 0.02 * (hours - 30) + plan_effect + rng.normal(0, 0.5, n)
-prob = 1 / (1 + np.exp(-logit))
-y = (rng.uniform(0, 1, n) < prob).astype(int)
-
-df = pd.DataFrame({"x1": x1, "x2": x2, "hours": hours, "plan": plan})
-print("class balance:", {0: int((y == 0).sum()), 1: int((y == 1).sum())})
-
-X_train, X_test, y_train, y_test = train_test_split(
- df, y, test_size=0.25, random_state=42, stratify=y
-)
-```
-
-```text
-class balance: {0: 2966, 1: 1034}
-```
-
-## Baseline: scaling + LogisticRegression
-
-```python
-from sklearn.compose import ColumnTransformer
-from sklearn.preprocessing import MinMaxScaler, OneHotEncoder
-from sklearn.linear_model import LogisticRegression
-from sklearn.metrics import accuracy_score, roc_auc_score
-
-baseline = ColumnTransformer([
- ("num", MinMaxScaler(), ["x1", "x2", "hours"]),
- ("cat", OneHotEncoder(handle_unknown="ignore"), ["plan"]),
-])
-
-X_tr = baseline.fit_transform(X_train)
-X_te = baseline.transform(X_test)
-
-clf = LogisticRegression(max_iter=1000).fit(X_tr, y_train)
-proba = clf.predict_proba(X_te)[:, 1]
-
-print(f"features: {X_tr.shape[1]}")
-print(f"accuracy: {accuracy_score(y_test, clf.predict(X_te)):.3f}")
-print(f"ROC AUC: {roc_auc_score(y_test, proba):.3f}")
-```
-
-```text
-features: 6
-accuracy: 0.742
-ROC AUC: 0.569
-```
-
-Accuracy looks acceptable only because the classes are imbalanced, since the model mostly
-predicts the majority class. The `ROC AUC` of `0.569` shows it has barely learned to rank
-positives above negatives, because a straight boundary cannot enclose the ring.
-
-```{warning}
-On imbalanced data, accuracy can be misleading. A model that always predicts the majority
-class would already score around `0.74` here. Prefer threshold-independent metrics such as
-`ROC AUC`, or precision and recall, to judge whether a classifier has genuinely learned.
-```
-
-## With pretab
-
-Give every numeric column a radial basis expansion and keep the same classifier.
-
-```python
-from pretab import Preprocessor
-
-pre = Preprocessor(
- numerical_method="rbf",
- categorical_method="one-hot",
- task="classification",
- target_aware=True,
- output_dim=10,
-)
-
-X_tr = pre.fit_transform(X_train, y_train, return_array=True)
-X_te = pre.transform(X_test, return_array=True)
-
-clf = LogisticRegression(max_iter=1000).fit(X_tr, y_train)
-proba = clf.predict_proba(X_te)[:, 1]
-
-print(f"features: {X_tr.shape[1]}")
-print(f"accuracy: {accuracy_score(y_test, clf.predict(X_te)):.3f}")
-print(f"ROC AUC: {roc_auc_score(y_test, proba):.3f}")
-```
-
-```text
-features: 33
-accuracy: 0.872
-ROC AUC: 0.927
-```
-
-The RBF features let the linear classifier bend around the ring. Accuracy rises from `0.742`
-to `0.872`, and the `ROC AUC` jumps from `0.569` to `0.927`, a much better separation of
-the two classes.
-
-```{note}
-`target_aware=True` lets supervised expansions (like RBF and PLE) use `y` during `fit` to
-place their basis functions where they best separate the classes, so always pass `y` when
-fitting.
-```
-
-## What changed
-
-```python
-pre.get_feature_info()
-```
-
-```text
-feature kind pipeline dim cats
-----------------------------------------------------------------
-x1 numerical imputer -> minmax -> rbf 10 -
-x2 numerical imputer -> minmax -> rbf 10 -
-hours numerical imputer -> minmax -> rbf 10 -
-plan categorical imputer -> onehot -> to_float 3 3
-```
-
-Three numeric columns become 30 RBF features and `plan` becomes three one-hot columns, 33
-in total, turning an unsolvable linear problem into an easy one.
-
-## Next steps
-
-- See the regression version in the [end-to-end example](../getting_started/end_to_end.md).
-- Compose transformers inside a single `sklearn` `Pipeline` with cross-validation in the
- [sklearn Pipeline tutorial](sklearn_pipeline.md).
diff --git a/docs/tutorials/comparing_representations.md b/docs/tutorials/comparing_representations.md
new file mode 100644
index 0000000..33fb192
--- /dev/null
+++ b/docs/tutorials/comparing_representations.md
@@ -0,0 +1,104 @@
+# Comparing representations
+
+Choosing a representation should be an experiment, not a guess. This tutorial evaluates several
+numerical methods on the same task with the same model, so the only thing that varies is the
+basis. The pattern generalizes to any dataset you have.
+
+## The setup
+
+We reuse a simple nonlinear regression target and hold the model fixed at a `Ridge` regressor.
+Each candidate method is fit leakage-safely inside cross-validation.
+
+```python
+import numpy as np
+import pandas as pd
+from sklearn.pipeline import Pipeline
+from sklearn.compose import ColumnTransformer
+from sklearn.linear_model import Ridge
+from sklearn.model_selection import cross_val_score
+
+rng = np.random.default_rng(0)
+n = 3000
+x = rng.uniform(0, 10, n)
+y = np.sin(x) * 3 + 0.3 * x + rng.normal(0, 0.4, n)
+df = pd.DataFrame({"x": x})
+```
+
+## Sweep the candidates
+
+We compare a scaled baseline against a spline, a feature map, and PLE. Each transformer goes
+inside a `Pipeline` so cross-validation fits it per fold.
+
+```python
+from sklearn.preprocessing import MinMaxScaler
+from pretab.transformers import (
+ BSplineTransformer,
+ RBFExpansionTransformer,
+ PLETransformer,
+)
+
+candidates = {
+ "minmax (baseline)": MinMaxScaler(),
+ "bspline": BSplineTransformer(output_dim=12),
+ "rbf": RBFExpansionTransformer(output_dim=12),
+ "ple": PLETransformer(output_dim=12, task="regression"),
+}
+
+results = {}
+for name, transformer in candidates.items():
+ features = ColumnTransformer([("x", transformer, ["x"])])
+ model = Pipeline([("features", features), ("ridge", Ridge(alpha=1.0))])
+ scores = cross_val_score(model, df, y, cv=5, scoring="r2")
+ results[name] = (scores.mean(), scores.std())
+
+for name, (mean, std) in results.items():
+ print(f"{name:20s} R2 = {mean:.3f} +/- {std:.3f}")
+```
+
+```text
+minmax (baseline) R2 = 0.081 +/- 0.010
+bspline R2 = 0.972 +/- 0.004
+rbf R2 = 0.964 +/- 0.006
+ple R2 = 0.958 +/- 0.005
+```
+
+The scaled baseline fits a straight line and cannot follow the sine. Every expansion captures
+it, with the spline slightly ahead on this smooth signal.
+
+```{tip}
+Fix everything except the representation. The same model, the same folds, the same metric.
+That isolates the effect of the basis so the comparison is fair.
+```
+
+## Weigh width against accuracy
+
+More columns can buy accuracy, but they also cost memory and overfitting headroom. Estimate the
+output width before you commit, using the `Preprocessor` budget tools.
+
+```python
+from pretab import Preprocessor
+
+for method in ["bspline", "rbf", "ple"]:
+ pre = Preprocessor(numerical_method=method, output_dim=12).fit(df, y)
+ shape = pre.estimate_output_shape(df)
+ print(f"{method:8s} -> {shape[1]} columns")
+```
+
+```{note}
+A method that wins by a hair but doubles the column count may not be worth it. Read the width
+from `estimate_output_shape` and factor it into the decision. See
+[Outputs and inspection](../core_concepts/outputs_and_inspection.md).
+```
+
+## When nothing beats the baseline
+
+If every expansion ties the scaled baseline, the relationship is probably already linear, or
+the feature carries little signal. That is a real and useful result. Do not add columns that do
+not earn their place, see
+[when basis expansion does not help](../representations/choosing_a_method.md#when-basis-expansion-does-not-help).
+
+## Where to go next
+
+- [Adaptive resolution](adaptive_resolution.md) to let the data pick the width.
+- [Choosing a method](../representations/choosing_a_method.md) for guidance behind the numbers.
+- [Comparison table](../representations/comparison_table.md) to filter candidates by capability.
diff --git a/docs/tutorials/custom_representation.md b/docs/tutorials/custom_representation.md
new file mode 100644
index 0000000..b8ba8b2
--- /dev/null
+++ b/docs/tutorials/custom_representation.md
@@ -0,0 +1,157 @@
+# Writing a custom representation
+
+PreTab is registry-driven, and the registry is open. You can add your own representation, have
+it validated against the same contract as the built-ins, and select it by name through
+`Preprocessor`. This tutorial walks the full extension workflow using a Chebyshev polynomial
+basis as the running example.
+
+```{note}
+A complete, installable version of this example lives in the repository under
+`examples/pretab-chebyshev/`. Use it as a template for a standalone extension package.
+```
+
+## Subclass `BaseRepresentation`
+
+`BaseRepresentation` gives you the shared scikit-learn contract: NaN-aware validation, estimator
+tags, `get_feature_names_out`, and a typed `RepresentationSpec`. You implement `fit`,
+`transform`, and one sizing hook, and declare a small amount of metadata.
+
+```python
+import numpy as np
+from sklearn.utils.validation import check_is_fitted
+from pretab import BaseRepresentation
+
+
+class ChebyshevRepresentation(BaseRepresentation):
+ """Expand each numerical feature into a Chebyshev polynomial basis."""
+
+ representation_name = "chebyshev"
+ feature_kind = "numerical"
+ scope = "univariate"
+ supervision = "unsupervised"
+
+ def __init__(self, degree=5):
+ self.degree = degree
+
+ def fit(self, X, y=None):
+ X = np.asarray(self._validate(X, reset=True), dtype=float)
+ self.data_min_ = X.min(axis=0)
+ self.data_max_ = X.max(axis=0)
+ return self
+
+ def _rescale(self, X):
+ span = self.data_max_ - self.data_min_
+ span = np.where(span == 0.0, 1.0, span)
+ return np.clip(2.0 * (X - self.data_min_) / span - 1.0, -1.0, 1.0)
+
+ def transform(self, X):
+ check_is_fitted(self, "n_features_in_")
+ z = self._rescale(np.asarray(self._validate(X, reset=False), dtype=float))
+ theta = np.arccos(z)
+ blocks = [
+ np.column_stack([np.cos(k * theta[:, j]) for k in range(1, self.degree + 1)])
+ for j in range(z.shape[1])
+ ]
+ return np.hstack(blocks)
+
+ def _output_sizes(self):
+ return [self.degree] * self.n_features_in_
+```
+
+The four class attributes are the declarative contract.
+
+`representation_name`
+: The name you will select it by, for example `numerical_method="chebyshev"`.
+
+`feature_kind`
+: `"numerical"` or `"categorical"`.
+
+`scope`
+: `"univariate"` (one column at a time) or `"multivariate"` (jointly).
+
+`supervision`
+: `"unsupervised"`, `"optional"` (uses `y` only when `target_aware=True`), or `"supervised"`
+ (always needs `y`).
+
+```{tip}
+Implement `_output_sizes` to return the number of output columns each input contributes. The
+base class uses it to generate correct feature names and to power the output budget. If your
+naming is bespoke, override `get_feature_names_out` directly instead.
+```
+
+## Validate against the contract
+
+Before registering, run the conformance suite. It checks that your class round-trips, respects
+NaN handling, produces stable names, and honours its declared metadata.
+
+```python
+from pretab import check_representation
+
+check_representation(ChebyshevRepresentation) # raises on any contract violation
+```
+
+```{important}
+`check_representation` raises `RepresentationConformanceError` with a specific message when the
+contract is broken. Run it in your test suite so a future change cannot silently break
+compatibility.
+```
+
+## Register it
+
+Registration adds the class to the capability registry under its name, making it selectable
+through `Preprocessor` and visible to `list_representations`.
+
+```python
+from pretab import register_representation, Preprocessor
+
+register_representation(
+ "chebyshev",
+ ChebyshevRepresentation,
+ allowed_args=("degree",),
+ supports_adaptive_resolution=False,
+)
+
+import numpy as np
+import pandas as pd
+
+rng = np.random.default_rng(0)
+df = pd.DataFrame({"x": rng.uniform(-3, 3, size=500)})
+y = np.cos(df["x"] * 2) + rng.normal(0, 0.1, size=500)
+
+pre = Preprocessor(numerical_method="chebyshev", degree=8)
+X2 = pre.fit_transform(df, y)
+```
+
+The `allowed_args` list tells `Preprocessor` which of its shared keyword arguments to pass
+through to your constructor.
+
+## Ship it as a plugin
+
+To distribute your representation as an installable package, advertise it through the
+`pretab.representations` entry-point group in your `pyproject.toml`.
+
+```toml
+[project.entry-points."pretab.representations"]
+chebyshev = "pretab_chebyshev:ChebyshevRepresentation"
+```
+
+Users then load every installed plugin with one call.
+
+```python
+from pretab import load_entry_point_representations
+
+load_entry_point_representations() # discovers and registers installed plugins
+```
+
+```{note}
+Discovery is opt-in and never runs automatically at import, so importing `pretab` stays fast
+and predictable. A broken plugin is skipped with a warning rather than breaking discovery for
+the others.
+```
+
+## Where to go next
+
+- [Representations overview](../representations/overview.md) to see the built-in families your
+ method joins.
+- [Extensibility API](../api/extension.rst) for the full signatures.
+- The `examples/pretab-chebyshev/` package for a complete, tested template.
diff --git a/docs/tutorials/multivariate_features.md b/docs/tutorials/multivariate_features.md
new file mode 100644
index 0000000..036ef2f
--- /dev/null
+++ b/docs/tutorials/multivariate_features.md
@@ -0,0 +1,117 @@
+# Multivariate features
+
+Most representations transform one column at a time. Some relationships, though, live in the
+interaction between columns: a smooth surface over latitude and longitude, or a kernel over
+many inputs at once. PreTab's multivariate methods model several columns jointly. This tutorial
+shows how to use them.
+
+## Which methods are multivariate
+
+Four methods operate on several inputs together rather than per column.
+
+`tensorspline`
+: Tensor-product spline. A smooth basis over a small number of inputs, capturing their
+ interaction on a grid.
+
+`tprs`
+: Thin-plate regression spline. A smooth surface over two or more inputs, from the generalized
+ additive model literature.
+
+`rff`
+: Random Fourier features. A scalable approximation to a shift-invariant kernel.
+
+`nystroem`
+: Nyström kernel map. A landmark-based kernel approximation.
+
+```{warning}
+These four are standalone transformers. They are not available as a per-column
+`numerical_method` on `Preprocessor`, because they need the whole input block. Fit them
+directly on the columns you want to model jointly.
+```
+
+## A smooth surface with thin-plate splines
+
+Suppose the target is a smooth function of two coordinates. A per-column expansion cannot see
+the interaction, but a thin-plate spline models the surface directly.
+
+```python
+import numpy as np
+from sklearn.pipeline import Pipeline
+from sklearn.linear_model import Ridge
+from sklearn.model_selection import cross_val_score
+
+from pretab.transformers import ThinPlateSplineTransformer
+
+rng = np.random.default_rng(0)
+n = 3000
+X = rng.uniform(-3, 3, size=(n, 2))
+y = np.exp(-(X[:, 0] ** 2 + X[:, 1] ** 2)) * 5 + rng.normal(0, 0.2, n)
+
+model = Pipeline([
+ ("tps", ThinPlateSplineTransformer(n_components=20)),
+ ("ridge", Ridge(alpha=1.0)),
+])
+
+scores = cross_val_score(model, X, y, cv=5, scoring="r2")
+print(f"5-fold R2: {scores.mean():.3f} +/- {scores.std():.3f}")
+```
+
+The thin-plate basis captures the radial bump over the two coordinates jointly, something two
+separate one-dimensional splines cannot do.
+
+```{tip}
+Use `n_components` to trade accuracy for cost. More landmarks give a richer surface at higher
+memory and compute. Start modest and increase only if validation improves.
+```
+
+## A scalable kernel with random Fourier features
+
+When you want kernel-style flexibility over many inputs on a large dataset, random Fourier
+features approximate an RBF kernel without forming the full kernel matrix.
+
+```python
+from pretab.transformers import RandomFourierFeaturesTransformer
+
+model = Pipeline([
+ ("rff", RandomFourierFeaturesTransformer(n_components=200, gamma=0.5)),
+ ("ridge", Ridge(alpha=1.0)),
+])
+
+scores = cross_val_score(model, X, y, cv=5, scoring="r2")
+print(f"5-fold R2: {scores.mean():.3f} +/- {scores.std():.3f}")
+```
+
+```{note}
+Random Fourier features and Nyström both approximate a kernel machine. Random Fourier features
+scale to large data with random projections; Nyström samples landmark points and is often more
+accurate at a given width. Try both.
+```
+
+## Combining multivariate and per-column methods
+
+You can mix a joint block for interacting columns with per-column expansions for the rest,
+using a `ColumnTransformer`.
+
+```python
+from sklearn.compose import ColumnTransformer
+from pretab.transformers import PLETransformer
+import pandas as pd
+
+df = pd.DataFrame({"lat": X[:, 0], "lon": X[:, 1], "size": rng.uniform(0, 100, n)})
+
+features = ColumnTransformer([
+ ("geo", ThinPlateSplineTransformer(n_components=20), ["lat", "lon"]),
+ ("size", PLETransformer(output_dim=10, task="regression"), ["size"]),
+])
+
+model = Pipeline([("features", features), ("ridge", Ridge(alpha=1.0))])
+```
+
+The thin-plate spline handles the geographic interaction while PLE handles the standalone
+`size` column, each with the representation that suits it.
+
+## Where to go next
+
+- [Splines](../representations/splines.md) for the tensor-product and thin-plate details.
+- [Feature maps](../representations/feature_maps.md) for the kernel approximations.
+- [References](../representations/references.md) for the underlying theory.
diff --git a/docs/getting_started/end_to_end.md b/docs/tutorials/nonlinear_regression.md
similarity index 58%
rename from docs/getting_started/end_to_end.md
rename to docs/tutorials/nonlinear_regression.md
index 8be2af9..2277121 100644
--- a/docs/getting_started/end_to_end.md
+++ b/docs/tutorials/nonlinear_regression.md
@@ -1,13 +1,14 @@
-# End-to-end example
+# Nonlinear regression
-pretab is most useful as the *feature layer* in front of a model. This walkthrough builds
-the same small regression task twice: once with plain scaling and once with pretab, using
-the **same linear model** both times. The only thing that changes is how the raw columns
-are turned into features, which makes the effect of pretab easy to see.
+PreTab is most useful as the feature layer in front of a model. This walkthrough builds the
+same small regression task twice: once with plain scaling and once with PreTab, using the
+**same linear model** both times. Only the representation changes, which makes the effect
+easy to see.
## The dataset
-We simulate a tabular dataset with three numeric columns and one categorical column.
+We simulate a tabular dataset with three numeric columns and one categorical column, where the
+target depends on each feature in a nonlinear way.
```python
import numpy as np
@@ -22,7 +23,6 @@ income = rng.normal(60_000, 15_000, n)
tenure = rng.uniform(0, 40, n)
city = rng.choice(["Berlin", "Munich", "Hamburg", "Cologne"], n)
-# The target depends on each feature in a *nonlinear* way.
city_effect = pd.Series(city).map(
{"Berlin": 5.0, "Munich": 8.0, "Hamburg": 3.0, "Cologne": 6.0}
).to_numpy()
@@ -39,32 +39,23 @@ df = pd.DataFrame({"age": age, "income": income, "tenure": tenure, "city": city}
X_train, X_test, y_train, y_test = train_test_split(
df, target, test_size=0.25, random_state=42
)
-df.head()
-```
-
-```text
- age income tenure city
-0 51.122008 38220.981430 31.495899 Munich
-1 32.028909 61219.946532 18.424959 Cologne
-2 20.130623 49018.509912 28.795777 Munich
-3 18.859437 42292.103104 22.590428 Munich
-4 60.290052 40930.830263 39.569339 Cologne
```
The target curves with `age`, bends quadratically with `income`, and flattens out with
-`tenure`. A plain linear model only sees a single straight-line term per column, so it has
-no way to represent these shapes. That is exactly the gap pretab fills.
+`tenure`. A plain linear model only sees a single straight-line term per column, so it has no
+way to represent these shapes. That is exactly the gap PreTab fills.
```{warning}
Fit every transformer on the **training split only**, then apply it to the test split with
-`transform`. Supervised expansions such as PLE and RBF read `y` while fitting, so fitting
-them on the full dataset would leak test information and inflate your scores.
+`transform`. Supervised expansions such as PLE read `y` while fitting, so fitting on the full
+dataset would leak test information and inflate your scores. See
+[Target awareness](../core_concepts/target_awareness.md).
```
-## Baseline: scaling + Ridge
+## Baseline: scaling and Ridge
-First, a conventional pipeline: scale the numeric columns, one-hot the categorical one, and
-fit a `Ridge` regressor.
+First, a conventional pipeline: scale the numeric columns, one-hot the categorical one, and fit
+a `Ridge` regressor.
```python
from sklearn.compose import ColumnTransformer
@@ -94,15 +85,14 @@ R2: 0.124
MAE: 11.20
```
-With one straight-line term per numeric column, `Ridge` can only fit a global slope. It
-misses every curve in the target, and the $R^2$ of `0.124` is barely better than predicting
-the mean.
+With one straight-line term per numeric column, `Ridge` can only fit a global slope. It misses
+every curve in the target, and the $R^2$ of `0.124` is barely better than predicting the mean.
-## With pretab
+## With PreTab
-Now swap the scaler for a `Preprocessor` that gives each column an expressive basis. It uses
-a B-spline for `age`, piecewise-linear encoding (PLE) for `income`, radial basis functions
-for `tenure`, and one-hot for `city`. Everything else stays the same.
+Now swap the scaler for a `Preprocessor` that gives each column an expressive basis: a B-spline
+for `age`, piecewise-linear encoding for `income`, radial basis functions for `tenure`, and
+one-hot for `city`. Everything else stays the same.
```python
from pretab import Preprocessor
@@ -136,21 +126,20 @@ MAE: 2.16
```
The data and the `Ridge` model are unchanged, but the expressive features let it capture the
-nonlinear structure. The $R^2$ jumps from `0.124` to `0.968` and the mean absolute error
-drops from `11.20` to `2.16`.
+nonlinear structure. The $R^2$ jumps from `0.124` to `0.968` and the mean absolute error drops
+from `11.20` to `2.16`.
```{tip}
-`Preprocessor.transform` returns a **dict of feature blocks** by default. When you feed a
-plain estimator, call it yourself with `return_array=True` to get a single stacked matrix,
-then hand the arrays to the model. If you would rather compose everything inside one
-`sklearn` `Pipeline`, use the standalone transformers instead. See the
-[sklearn Pipeline tutorial](../tutorials/sklearn_pipeline.md).
+`Preprocessor.transform` returns a dict of feature blocks by default. When you feed a plain
+estimator, call it with `return_array=True` to get a single stacked matrix. To compose
+everything inside one scikit-learn `Pipeline` instead, use the standalone transformers, shown
+in the [sklearn pipeline tutorial](sklearn_pipeline.md).
```
## What actually changed
-The `Preprocessor` expands four raw columns into 41 features. Inspect the resolved layout
-with `get_feature_info`:
+The `Preprocessor` expands four raw columns into 41 features. Inspect the resolved layout with
+`get_feature_info`:
```python
pre.get_feature_info()
@@ -165,14 +154,16 @@ tenure numerical imputer -> minmax -> rbf 12 -
city categorical imputer -> onehot -> to_float 4 4
```
-Each numeric column is imputed, scaled, then expanded into a basis that a linear model can
-weight independently: 13 spline coefficients for `age`, 12 PLE bins for `income`, and 12 RBF
-bumps for `tenure`, while `city` becomes four one-hot columns. The model is unchanged, and
-only the representation improved.
+Each numeric column is imputed, scaled, then expanded into a basis the linear model can weight
+independently: 13 spline coefficients for `age`, 12 PLE bins for `income`, and 12 RBF bumps for
+`tenure`, while `city` becomes four one-hot columns. To trace any single output column back to
+its source, use [feature lineage](../core_concepts/outputs_and_inspection.md).
-## Next steps
+## Where to go next
-- Do the same for a classifier in the [classification tutorial](../tutorials/classification.md).
-- Wire pretab transformers into a full `sklearn` `Pipeline` with cross-validation and
- grid search in the [sklearn Pipeline tutorial](../tutorials/sklearn_pipeline.md).
-- Review every strategy string in the [User Guide](../user_guide/preprocessing.md).
+- Do the same for a classifier in the
+ [leakage-safe classification tutorial](target_aware_classification.md).
+- Wire PreTab transformers into a full `Pipeline` with cross-validation and grid search in the
+ [sklearn pipeline tutorial](sklearn_pipeline.md).
+- Measure one representation against another in
+ [comparing representations](comparing_representations.md).
diff --git a/docs/tutorials/sklearn_pipeline.md b/docs/tutorials/sklearn_pipeline.md
index cbab77b..bd4f668 100644
--- a/docs/tutorials/sklearn_pipeline.md
+++ b/docs/tutorials/sklearn_pipeline.md
@@ -7,7 +7,7 @@ The **standalone transformers**, on the other hand, return plain arrays and foll
work with `cross_val_score`, `GridSearchCV`, and every other `sklearn` utility.
This tutorial builds the regression task from the
-[end-to-end example](../getting_started/end_to_end.md) as a single, self-contained
+[nonlinear regression tutorial](nonlinear_regression.md) as a single, self-contained
`Pipeline`.
## Build the pipeline
@@ -111,12 +111,12 @@ Every pretab transformer participates in the search grid just like a native `skl
- **Standalone transformers** (this page) compose inside one `Pipeline` and integrate with
cross-validation and grid search. Reach for them when you want a single estimator object.
-- **The `Preprocessor`** (the [end-to-end example](../getting_started/end_to_end.md)) reads
+- **The `Preprocessor`** (the [nonlinear regression tutorial](nonlinear_regression.md)) reads
a `DataFrame`, detects feature types automatically, and configures every column from a
single config. Reach for it when you want per-column strategies without wiring each one by
hand.
## Next steps
-- Browse every transformer in the [API Reference](../api/index.rst).
-- Review the available strategy strings in the [User Guide](../user_guide/preprocessing.md).
+- Browse every transformer in the [API reference](../api/index.rst).
+- Review the method catalogue in the [representations overview](../representations/overview.md).
diff --git a/docs/tutorials/target_aware_classification.md b/docs/tutorials/target_aware_classification.md
new file mode 100644
index 0000000..3f16846
--- /dev/null
+++ b/docs/tutorials/target_aware_classification.md
@@ -0,0 +1,156 @@
+# Leakage-safe classification
+
+The [nonlinear regression tutorial](nonlinear_regression.md) put PreTab in front of a
+regressor. The same idea works for classification, with one added concern: when the
+representation is supervised, the evaluation must keep it from seeing the test labels. This
+tutorial shows an expressive classifier and how to evaluate it without leakage.
+
+Here the target has a **ring-shaped** boundary, where the positive class sits near the origin
+of two coordinates, plus a categorical `plan` effect. A plain `LogisticRegression` draws a
+single straight boundary and struggles; radial basis features let it curve around the ring.
+
+## The dataset
+
+```python
+import numpy as np
+import pandas as pd
+from sklearn.model_selection import train_test_split
+
+rng = np.random.default_rng(1)
+n = 4000
+
+x1 = rng.uniform(-3, 3, n)
+x2 = rng.uniform(-3, 3, n)
+hours = rng.uniform(0, 60, n)
+plan = rng.choice(["free", "pro", "team"], n, p=[0.5, 0.3, 0.2])
+
+plan_effect = pd.Series(plan).map({"free": -0.5, "pro": 0.3, "team": 1.0}).to_numpy()
+logit = 3.0 - (x1**2 + x2**2) + 0.02 * (hours - 30) + plan_effect + rng.normal(0, 0.5, n)
+prob = 1 / (1 + np.exp(-logit))
+y = (rng.uniform(0, 1, n) < prob).astype(int)
+
+df = pd.DataFrame({"x1": x1, "x2": x2, "hours": hours, "plan": plan})
+
+X_train, X_test, y_train, y_test = train_test_split(
+ df, y, test_size=0.25, random_state=42, stratify=y
+)
+```
+
+The positive class lives inside a ring around the origin, shifted by the plan. The classes are
+imbalanced, roughly one positive to three negatives.
+
+## Baseline: scaling and LogisticRegression
+
+```python
+from sklearn.compose import ColumnTransformer
+from sklearn.preprocessing import MinMaxScaler, OneHotEncoder
+from sklearn.linear_model import LogisticRegression
+from sklearn.metrics import accuracy_score, roc_auc_score
+
+baseline = ColumnTransformer([
+ ("num", MinMaxScaler(), ["x1", "x2", "hours"]),
+ ("cat", OneHotEncoder(handle_unknown="ignore"), ["plan"]),
+])
+
+X_tr = baseline.fit_transform(X_train)
+X_te = baseline.transform(X_test)
+
+clf = LogisticRegression(max_iter=1000).fit(X_tr, y_train)
+proba = clf.predict_proba(X_te)[:, 1]
+
+print(f"accuracy: {accuracy_score(y_test, clf.predict(X_te)):.3f}")
+print(f"ROC AUC: {roc_auc_score(y_test, proba):.3f}")
+```
+
+```text
+accuracy: 0.742
+ROC AUC: 0.569
+```
+
+Accuracy looks acceptable only because the classes are imbalanced; the model mostly predicts
+the majority class. The `ROC AUC` of `0.569` shows it has barely learned to rank positives
+above negatives, because a straight boundary cannot enclose the ring.
+
+```{warning}
+On imbalanced data, accuracy misleads. A model that always predicts the majority class already
+scores around `0.74` here. Prefer threshold-independent metrics such as `ROC AUC`, or
+precision and recall, to judge whether a classifier has genuinely learned.
+```
+
+## With PreTab
+
+Give every numeric column a radial basis expansion and keep the same classifier.
+
+```python
+from pretab import Preprocessor
+
+pre = Preprocessor(
+ numerical_method="rbf",
+ categorical_method="one-hot",
+ task="classification",
+ target_aware=True,
+ output_dim=10,
+)
+
+X_tr = pre.fit_transform(X_train, y_train, return_array=True)
+X_te = pre.transform(X_test, return_array=True)
+
+clf = LogisticRegression(max_iter=1000).fit(X_tr, y_train)
+proba = clf.predict_proba(X_te)[:, 1]
+
+print(f"accuracy: {accuracy_score(y_test, clf.predict(X_te)):.3f}")
+print(f"ROC AUC: {roc_auc_score(y_test, proba):.3f}")
+```
+
+```text
+accuracy: 0.872
+ROC AUC: 0.927
+```
+
+The RBF features let the linear classifier bend around the ring. Accuracy rises from `0.742`
+to `0.872`, and the `ROC AUC` jumps from `0.569` to `0.927`.
+
+```{note}
+`target_aware=True` lets supervised expansions use `y` during `fit` to place their basis
+functions where they best separate the classes. Because we fit on the training split and only
+`transform` the test split, no test label reaches the representation.
+```
+
+## Leakage-safe cross-validation
+
+The split above is honest because the representation was fit on the training rows only. To make
+that guarantee automatic across folds, put the transformers inside a `Pipeline`. scikit-learn
+then fits every step, including the supervised expansion, on each training fold in turn.
+
+```python
+from sklearn.pipeline import Pipeline
+from sklearn.model_selection import cross_val_score
+from pretab.transformers import RBFExpansionTransformer
+
+features = ColumnTransformer([
+ ("x1", RBFExpansionTransformer(output_dim=10, target_aware=True), ["x1"]),
+ ("x2", RBFExpansionTransformer(output_dim=10, target_aware=True), ["x2"]),
+ ("hours", RBFExpansionTransformer(output_dim=10, target_aware=True), ["hours"]),
+ ("plan", OneHotEncoder(handle_unknown="ignore"), ["plan"]),
+])
+
+model = Pipeline([("features", features), ("clf", LogisticRegression(max_iter=1000))])
+
+scores = cross_val_score(model, df, y, cv=5, scoring="roc_auc")
+print(f"5-fold ROC AUC: {scores.mean():.3f} +/- {scores.std():.3f}")
+```
+
+```{important}
+A supervised transformer fit outside a cross-validation or `Pipeline` context emits a
+`LeakageWarning`. Inside the `Pipeline` here, the warning is suppressed because each fold fits
+the representation on training data only. For the strongest guarantee on training features
+themselves, wrap the transformer in `CrossFittedTransformer`. See
+[Target awareness](../core_concepts/target_awareness.md).
+```
+
+## Where to go next
+
+- See the regression version in the [nonlinear regression tutorial](nonlinear_regression.md).
+- Compose transformers with cross-validation and grid search in the
+ [sklearn pipeline tutorial](sklearn_pipeline.md).
+- Read [Target awareness](../core_concepts/target_awareness.md) for the full leakage model.
diff --git a/docs/user_guide/configuration.md b/docs/user_guide/configuration.md
deleted file mode 100644
index 0826fc5..0000000
--- a/docs/user_guide/configuration.md
+++ /dev/null
@@ -1,112 +0,0 @@
-# Hyperparameter and configuration guide
-
-Every numerical transformer in PreTab shares a small set of hyperparameters. This guide explains
-what each one does, the default it ships with, and how to choose a value that suits your data. If
-there is one setting worth learning first, it is `output_dim`, which controls how wide each
-feature becomes after transformation.
-
-```{note}
-When you work through the `Preprocessor`, its single `output_dim` (default `7`) is forwarded to
-**every** numerical method. The per-transformer defaults listed below therefore only take effect
-when you build a transformer directly, for example `RBFExpansionTransformer()`.
-```
-
-## The `output_dim` width knob
-
-`output_dim` is the main capacity control. It sets the number of non-bias output columns produced
-for each input feature: bins for PLE and binning, centers for the feature maps, and basis
-functions for the splines. A larger value captures finer structure in a feature, at the cost of
-more columns and a higher chance of overfitting. A smaller value is more compact and regularises
-the representation.
-
-The defaults are aligned on a moderate value of `6`. It is expressive enough for most features
-while staying compact, and it clears the minimum width that every spline basis requires. The
-tensor-product spline is the single exception: its columns multiply across marginal dimensions,
-so it defaults to its smallest valid width to keep the output from exploding.
-
-| Method | Class | Default | Minimum (floor) |
-| --- | --- | --- | --- |
-| PLE | `PLETransformer` | `6` | `1` (upper bound on bins; actual count is data-dependent) |
-| RBF map | `RBFExpansionTransformer` | `6` | `1` |
-| ReLU map | `ReLUExpansionTransformer` | `6` | `1` |
-| Sigmoid map | `SigmoidExpansionTransformer` | `6` | `1` |
-| Tanh map | `TanhExpansionTransformer` | `6` | `1` |
-| Cubic spline | `CubicSplineTransformer` | `6` | `3` (3 polynomial terms + interior knots) |
-| Natural cubic | `NaturalCubicSplineTransformer` | `6` | `2` (places `output_dim + 1` knots) |
-| B/M/I splines | `BSplineTransformer`, `MSplineTransformer`, `ISplineTransformer` | `6` | `degree + 1` (=`4`), capped at `50` |
-| P-spline | `PSplineTransformer` | `6` | `degree + 1` (=`4`) |
-| Tensor product | `TensorProductSplineTransformer` | `4` | `degree + 1` (=`4`) **per marginal** |
-| Thin-plate | `ThinPlateSplineTransformer` | `6` | `1` |
-| Preprocessor (shared) | `Preprocessor` | `7` | overrides the per-transformer defaults above |
-
-```{warning}
-Each spline enforces its own minimum width. Requesting fewer basis functions than the floor in
-the table raises an error at `fit` time instead of silently clamping, so keep `output_dim` at or
-above that floor. The floor is `degree + 1` for the B, M, I, P-spline, and tensor-product bases.
-```
-
-```{tip}
-For the tensor-product spline the width grows as the product across marginals. A 2-D input with
-`output_dim=4` already produces `4 × 4 = 16` columns, so raise it in small steps and keep an eye
-on the total column count.
-```
-
-## Adaptive sizing
-
-PLE and the feature maps can size each feature from the data instead of using one fixed width.
-This helps when your features differ a lot in complexity and you would rather not tune
-`output_dim` by hand.
-
-`adaptive`
-: When `True`, the width for each feature is chosen from the data and kept inside
- `[min_output_dim, max_output_dim]`. Fixed-width methods such as the plain scalers ignore this
- flag.
-
-`min_output_dim`, `max_output_dim`
-: The lower and upper bounds that apply only when `adaptive=True`. They are ignored otherwise.
-
-## Target-aware placement
-
-Some methods can place their bins, centers, or knots using the target `y`. This tends to sharpen
-the representation where the target actually changes, at the cost of needing labels at `fit`
-time.
-
-`target_aware`
-: Whether placement uses the target. PLE is inherently target-aware. Every other family defaults
- to `target_aware=False`, which is fully unsupervised and fits without `y`.
-
-`placement_strategy`
-: How units are placed. The valid values depend on `target_aware`, as shown below.
-
-| `target_aware` | Allowed `placement_strategy` | Default when unset |
-| --- | --- | --- |
-| `True` | `cart`, `lightgbm` | `cart` |
-| `False` | `uniform`, `quantile` | `quantile` |
-
-```{warning}
-The two rows of this table are mutually exclusive. Combining them, for example
-`target_aware=True` with `placement_strategy="quantile"`, raises an error. Leave
-`placement_strategy` unset to get the sensible default for whichever mode you picked.
-```
-
-`task`
-: Either `"regression"` or `"classification"`. It is only consulted by target-aware placement,
- which uses it to fit the selector against `y`.
-
-## Spline-specific parameters
-
-`degree`
-: Degree of the spline basis, where `3` is cubic. It also sets the `output_dim` floor of
- `degree + 1` for the B, M, I, P-spline, and tensor-product bases, so a lower degree lowers the
- minimum width.
-
-`include_bias`
-: When `True`, a constant intercept column is prepended to the output. The bias term is left
- unpenalised.
-
-## Reproducibility
-
-`random_state`
-: Seeds the target-aware selectors and any stochastic placement so that repeated fits produce
- identical output. Set it to an integer whenever you need deterministic results, for example in
- tests or published experiments.
diff --git a/docs/user_guide/preprocessing.md b/docs/user_guide/preprocessing.md
deleted file mode 100644
index 5f3d52c..0000000
--- a/docs/user_guide/preprocessing.md
+++ /dev/null
@@ -1,109 +0,0 @@
-# Preprocessing overview
-
-The `Preprocessor` (`pretab.preprocessor.Preprocessor`) is the main entry point. It
-inspects a `pandas.DataFrame`, decides which columns are numerical and which are
-categorical, and builds a scikit-learn `ColumnTransformer` that applies a chosen strategy
-per feature.
-
-## How feature types are detected
-
-By default, columns are classified automatically:
-
-- **Numerical**: continuous columns, and integer columns with enough distinct values.
-- **Categorical**: string/object columns, and low-cardinality integer columns.
-
-The behaviour is controlled by several constructor arguments:
-
-`cat_cutoff`
-: Threshold that decides whether an integer column is treated as categorical.
-
-`treat_all_integers_as_numerical`
-: When `True`, every integer column is treated as numerical regardless of cardinality.
-
-## Choosing strategies
-
-There are two ways to configure preprocessing:
-
-1. **Globally** via `numerical_method` and `categorical_method`.
-2. **Per feature** via the `feature_preprocessing` dict, which overrides the global
- defaults for specific columns.
-
-```python
-from pretab import Preprocessor
-
-# Global strategy for every column of a given type
-preprocessor = Preprocessor(
- numerical_method="ple",
- categorical_method="int",
-)
-
-# Or override individual columns
-preprocessor = Preprocessor(
- feature_preprocessing={
- "age": "ple",
- "income": "rbf",
- "city": "one-hot",
- },
-)
-```
-
-## Numerical strategies
-
-| Strategy | Transformer | Notes |
-| ----------------- | -------------------------------- | ----- |
-| `standardization` | `StandardScaler` | Zero mean, unit variance |
-| `minmax` | `MinMaxScaler` | Scaled to `[-1, 1]` |
-| `quantile` | `QuantileTransformer` | Rank-based normalisation |
-| `robust` | `RobustScaler` | Robust to outliers |
-| `polynomial` | `PolynomialFeatures` | Polynomial interactions |
-| `box-cox` | `PowerTransformer` | Positive inputs only |
-| `yeo-johnson` | `PowerTransformer` | Handles zero/negative values |
-| `ple` | `PLETransformer` | Piecewise linear encoding |
-| `custombin` | `CustomBinTransformer` | Rule- or tree-based binning |
-| `rbf` | `RBFExpansionTransformer` | Radial basis functions |
-| `relu` | `ReLUExpansionTransformer` | ReLU basis expansion |
-| `sigmoid` | `SigmoidExpansionTransformer` | Sigmoid basis expansion |
-| `tanh` | `TanhExpansionTransformer` | Tanh basis expansion |
-| `cubicspline` | `CubicSplineTransformer` | B-spline basis |
-| `naturalspline` | `NaturalCubicSplineTransformer` | Natural cubic spline |
-| `pspline` | `PSplineTransformer` | Penalised B-spline |
-| `tensorspline` | `TensorProductSplineTransformer` | Tensor-product spline |
-| `tprs` | `ThinPlateSplineTransformer` | Thin-plate regression spline |
-| `none` | `NoTransformer` | Pass-through |
-
-## Categorical strategies
-
-| Strategy | Transformer | Notes |
-| --------------------- | ------------------------------ | ----- |
-| `int` | `ContinuousOrdinalTransformer` | Integer/ordinal encoding (default) |
-| `one-hot` | `OneHotEncoder` | One-hot encoding |
-| `onehot_from_ordinal` | `OneHotFromOrdinalTransformer` | One-hot from pre-encoded ordinals |
-| `pretrained` | `LanguageEmbeddingTransformer` | Pretrained language embeddings |
-| `custombin` | `CustomBinTransformer` | Binning of categorical codes |
-| `none` | `NoTransformer` | Pass-through |
-
-```{note}
-The `pretrained` strategy requires the optional `sentence-transformers` dependency.
-Install it with `pip install "pretab[embeddings]"`.
-```
-
-## Output format
-
-`fit_transform` and `transform` return a dictionary that maps each feature to its
-transformed array (keys are prefixed with `num_` or `cat_`). Pass `return_array=True`
-to `transform` to receive a single stacked `numpy.ndarray` instead.
-
-```python
-X_dict = preprocessor.fit_transform(df, y) # {"num_age": ..., "cat_city": ...}
-X_array = preprocessor.transform(df, return_array=True) # single ndarray
-```
-
-Use `get_feature_info(verbose=True)` to inspect the resolved strategy and output
-dimensionality of every feature.
-
-## Using transformers directly
-
-Every transformer listed above is also importable from `pretab.transformers` and works as
-a standalone scikit-learn transformer, so it can be composed into any `Pipeline` or
-`ColumnTransformer`. See the [Quickstart](../getting_started/quickstart.md) for examples,
-and the [API Reference](../api/index.rst) for the full parameter list of each class.
diff --git a/examples/pretab-chebyshev/README.md b/examples/pretab-chebyshev/README.md
new file mode 100644
index 0000000..28bc508
--- /dev/null
+++ b/examples/pretab-chebyshev/README.md
@@ -0,0 +1,66 @@
+# pretab-chebyshev
+
+An example, self-contained [PreTab](../../README.md) extension package. It adds a
+`chebyshev` representation that expands each numerical feature into a Chebyshev
+polynomial basis, and shows the complete third-party extension workflow.
+
+This directory is a **sibling package** (it lives next to PreTab, not inside it).
+In a real project it would be its own repository published to PyPI; it is kept
+here only as a runnable reference.
+
+## What it demonstrates
+
+- Subclassing `pretab.BaseRepresentation` and declaring `representation_name`,
+ `feature_kind`, `scope`, and `supervision`.
+- Advertising the class through the `pretab.representations` entry-point group
+ (see `pyproject.toml`) so it is auto-discoverable once installed.
+- Passing the PreTab conformance suite (`pretab.check_representation`).
+- Being selected by name through `Preprocessor(numerical_method="chebyshev")`.
+
+## Install
+
+```bash
+cd examples/pretab-chebyshev
+pip install -e .
+```
+
+## Use
+
+Auto-discover every installed extension via the entry-point group:
+
+```python
+import pretab
+
+pretab.load_entry_point_representations() # registers "chebyshev"
+"chebyshev" in pretab.list_representations(feature_kind="numerical") # True
+```
+
+Or register the class directly, without relying on entry points:
+
+```python
+from pretab import register_representation
+from pretab_chebyshev import ChebyshevRepresentation
+
+register_representation("chebyshev", ChebyshevRepresentation, allowed_args=("degree",))
+```
+
+Then use it like any built-in method:
+
+```python
+import numpy as np, pandas as pd
+from pretab import Preprocessor
+
+X = pd.DataFrame({"a": np.linspace(0, 1, 20), "b": np.linspace(-1, 1, 20)})
+pre = Preprocessor(numerical_method="chebyshev", categorical_method="none", degree=4,
+ target_aware=False, placement_strategy="uniform")
+out = pre.fit_transform(X, return_array=True) # shape (20, 8)
+```
+
+## Validate
+
+```python
+from pretab import check_representation
+from pretab_chebyshev import ChebyshevRepresentation
+
+check_representation(ChebyshevRepresentation) # raises on any contract violation
+```
diff --git a/examples/pretab-chebyshev/pyproject.toml b/examples/pretab-chebyshev/pyproject.toml
new file mode 100644
index 0000000..7849367
--- /dev/null
+++ b/examples/pretab-chebyshev/pyproject.toml
@@ -0,0 +1,28 @@
+[build-system]
+requires = ["setuptools>=64"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "pretab-chebyshev"
+version = "0.1.0"
+description = "Example PreTab extension: a Chebyshev polynomial feature representation."
+readme = "README.md"
+requires-python = ">=3.10"
+license = { text = "MIT" }
+dependencies = [
+ "pretab",
+ "numpy",
+ "scikit-learn",
+]
+
+# This is what makes the representation auto-discoverable. Once this package is
+# installed, ``pretab.load_entry_point_representations()`` finds and registers
+# the class advertised here under the ``pretab.representations`` group.
+[project.entry-points."pretab.representations"]
+chebyshev = "pretab_chebyshev:ChebyshevRepresentation"
+
+[project.optional-dependencies]
+test = ["pytest"]
+
+[tool.setuptools.packages.find]
+where = ["src"]
diff --git a/examples/pretab-chebyshev/src/pretab_chebyshev/__init__.py b/examples/pretab-chebyshev/src/pretab_chebyshev/__init__.py
new file mode 100644
index 0000000..808ae65
--- /dev/null
+++ b/examples/pretab-chebyshev/src/pretab_chebyshev/__init__.py
@@ -0,0 +1,62 @@
+"""A minimal, self-contained PreTab extension package.
+
+Demonstrates the full third-party extension workflow: subclass
+:class:`pretab.BaseRepresentation`, expose the class through the
+``pretab.representations`` entry-point group (see ``pyproject.toml``), and let it
+be discovered, validated, and used exactly like a built-in representation.
+"""
+
+from __future__ import annotations
+
+import numpy as np
+from sklearn.utils.validation import check_is_fitted
+
+from pretab import BaseRepresentation
+
+__all__ = ["ChebyshevRepresentation"]
+
+
+class ChebyshevRepresentation(BaseRepresentation):
+ """Expand each numerical feature into a Chebyshev polynomial basis.
+
+ Every input column is rescaled to ``[-1, 1]`` using the training-data range,
+ then expanded into ``T_1 ... T_degree`` Chebyshev polynomials (the constant
+ ``T_0`` term is dropped to avoid a redundant bias column). This yields
+ ``degree`` output columns per input feature.
+
+ Parameters
+ ----------
+ degree : int, default=5
+ Number of Chebyshev polynomials produced per feature.
+ """
+
+ representation_name = "chebyshev"
+ feature_kind = "numerical"
+ scope = "univariate"
+ supervision = "unsupervised"
+
+ def __init__(self, degree=5):
+ self.degree = degree
+
+ def fit(self, X, y=None):
+ X = np.asarray(self._validate(X, reset=True), dtype=float)
+ self.data_min_ = X.min(axis=0)
+ self.data_max_ = X.max(axis=0)
+ return self
+
+ def _rescale(self, X):
+ span = self.data_max_ - self.data_min_
+ span = np.where(span == 0.0, 1.0, span)
+ return np.clip(2.0 * (X - self.data_min_) / span - 1.0, -1.0, 1.0)
+
+ def transform(self, X):
+ check_is_fitted(self, "n_features_in_")
+ z = self._rescale(np.asarray(self._validate(X, reset=False), dtype=float))
+ theta = np.arccos(z)
+ blocks = [
+ np.column_stack([np.cos(k * theta[:, j]) for k in range(1, self.degree + 1)]) for j in range(z.shape[1])
+ ]
+ return np.hstack(blocks)
+
+ def _output_sizes(self):
+ return [self.degree] * self.n_features_in_
diff --git a/examples/pretab-chebyshev/tests/test_chebyshev.py b/examples/pretab-chebyshev/tests/test_chebyshev.py
new file mode 100644
index 0000000..4f52d12
--- /dev/null
+++ b/examples/pretab-chebyshev/tests/test_chebyshev.py
@@ -0,0 +1,33 @@
+"""Illustrative tests for the example extension.
+
+Run from this directory with ``pip install -e . && pytest``. These are not part
+of the main PreTab test suite (which only collects the top-level ``tests/``).
+"""
+
+import numpy as np
+import pandas as pd
+from pretab_chebyshev import ChebyshevRepresentation
+
+from pretab import Preprocessor, check_representation, list_representations, register_representation
+
+
+def test_passes_conformance_suite():
+ passed = check_representation(ChebyshevRepresentation)
+ assert "spec_consistent" in passed
+ assert "deterministic" in passed
+
+
+def test_register_and_use_through_preprocessor():
+ register_representation("chebyshev", ChebyshevRepresentation, allowed_args=("degree",), override=True)
+ assert "chebyshev" in list_representations(feature_kind="numerical")
+
+ X = pd.DataFrame({"a": np.linspace(0, 1, 20), "b": np.linspace(-1, 1, 20)})
+ pre = Preprocessor(
+ numerical_method="chebyshev",
+ categorical_method="none",
+ degree=4, # flows through because "degree" is in allowed_args
+ target_aware=False,
+ placement_strategy="uniform",
+ )
+ out = np.asarray(pre.fit_transform(X, return_array=True))
+ assert out.shape == (20, 8) # degree 4 x 2 features
diff --git a/justfile b/justfile
index 9ab3285..924aa1b 100644
--- a/justfile
+++ b/justfile
@@ -39,6 +39,10 @@ types:
test:
poetry run pytest --cov=pretab tests/
+# run the end-to-end quickstart used as the CI smoke test and reviewer artifact
+quickstart:
+ poetry run python scripts/quickstart.py
+
# build the HTML docs locally (warnings treated as errors)
docs:
rm -rf docs/_build
diff --git a/poetry.lock b/poetry.lock
index e534c75..cf14d69 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1610,7 +1610,7 @@ description = "LightGBM Python-package"
optional = true
python-versions = ">=3.7"
groups = ["main"]
-markers = "extra == \"knots\" or extra == \"all\""
+markers = "extra == \"lightgbm\" or extra == \"all\""
files = [
{file = "lightgbm-4.6.0-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:b7a393de8a334d5c8e490df91270f0763f83f959574d504c7ccb9eee4aef70ed"},
{file = "lightgbm-4.6.0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:2dafd98d4e02b844ceb0b61450a660681076b1ea6c7adb8c566dfd66832aafad"},
@@ -4933,9 +4933,9 @@ type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""]
[extras]
all = ["lightgbm", "sentence-transformers"]
embeddings = ["sentence-transformers"]
-knots = ["lightgbm"]
+lightgbm = ["lightgbm"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.10,<3.14"
-content-hash = "9284f76b116e86a2475762d9afb1a957ea14db64959dbe8e0e3e0a11bc47d5cd"
+content-hash = "2a0bad6485988b0c36e131940e3f5df70bb2624604cfecb76ddd25b49eb1cab6"
diff --git a/pretab/__init__.py b/pretab/__init__.py
index 0b020da..afa6482 100644
--- a/pretab/__init__.py
+++ b/pretab/__init__.py
@@ -1,12 +1,45 @@
-from ._version import __version__ # noqa: F401
-from .core.exceptions import PretabWarning
+from ._version import __version__
+from .compose.search import RepresentationSearchCV
from .core.logging import configure_logging, set_verbosity
+from .core.policy import RepresentationPolicy
+from .core.representation import FeatureLineage, RepresentationSpec
+from .core.supervised import CrossFittedTransformer
+from .exceptions import (
+ FrozenRepresentationError,
+ LeakageWarning,
+ OutputBudgetError,
+ PretabSerializationError,
+ PretabWarning,
+ RepresentationConformanceError,
+)
+from .extension import (
+ BaseRepresentation,
+ check_representation,
+ list_representations,
+ load_entry_point_representations,
+ register_representation,
+)
from .preprocessor import Preprocessor
__all__ = [
+ "BaseRepresentation",
+ "CrossFittedTransformer",
+ "FeatureLineage",
+ "FrozenRepresentationError",
+ "LeakageWarning",
+ "OutputBudgetError",
"Preprocessor",
+ "PretabSerializationError",
"PretabWarning",
+ "RepresentationConformanceError",
+ "RepresentationPolicy",
+ "RepresentationSearchCV",
+ "RepresentationSpec",
"__version__",
+ "check_representation",
"configure_logging",
+ "list_representations",
+ "load_entry_point_representations",
+ "register_representation",
"set_verbosity",
]
diff --git a/pretab/compose/__init__.py b/pretab/compose/__init__.py
new file mode 100644
index 0000000..d333b8b
--- /dev/null
+++ b/pretab/compose/__init__.py
@@ -0,0 +1,6 @@
+"""Composition subsystem: which transformer applies to which column, and how the
+per-column pipelines are combined into a single :class:`~sklearn.compose.ColumnTransformer`.
+
+Populated during the 1.0.0 restructure (Phase 3): ``config``, ``registry``,
+``factory``, ``feature_detection``, ``output`` and ``inspection`` modules.
+"""
diff --git a/pretab/compose/config.py b/pretab/compose/config.py
new file mode 100644
index 0000000..2782c49
--- /dev/null
+++ b/pretab/compose/config.py
@@ -0,0 +1,209 @@
+"""Normalized, validated configuration for a :class:`Preprocessor` run.
+
+:class:`PreprocessorConfig` is the frozen, canonical view of the user-supplied
+Preprocessor parameters. It normalizes the global method names (resolving
+aliases and separator/case variants, mapping ``None`` to ``"none"``) and
+validates the global ``target_aware`` / ``placement_strategy`` contract up front.
+The user's original constructor arguments stay untouched on the estimator (as
+scikit-learn requires); this object is the internal, normalized counterpart the
+composition layer consumes.
+
+Per-column overrides in ``feature_preprocessing`` are kept verbatim because the
+namespace they resolve in (numerical vs categorical) depends on the column type,
+which is only known after feature detection; :meth:`PreprocessorConfig.method_for`
+resolves them in the correct namespace at build time.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from ..core.parameters import validate_placement
+from ..exceptions import IncompatibleParamsError, invalid_param_error
+from .registry import (
+ CATEGORICAL_ALIASES,
+ CATEGORICAL_METHODS,
+ NUMERICAL_ALIASES,
+ NUMERICAL_METHODS,
+ resolve_method,
+)
+
+__all__ = ["PreprocessorConfig"]
+
+# Valid values for the high-level ``missing_policy`` orchestration knob. ``None``
+# keeps the explicit imputation parameters authoritative (historical behaviour).
+MISSING_POLICIES = frozenset({"error", "propagate", "impute", "impute_with_indicator", "separate_state"})
+
+
+def _normalize_method(method, canonical, aliases) -> str:
+ """Resolve a global method name to canonical form, mapping ``None`` to ``"none"``."""
+ if method is None:
+ return "none"
+ return resolve_method(method, canonical, aliases)
+
+
+@dataclass(frozen=True)
+class PreprocessorConfig:
+ """Frozen, normalized configuration derived from Preprocessor parameters.
+
+ Built via :meth:`from_params`, which normalizes the global method names and
+ validates the placement contract. All other knobs are carried through as-is
+ for the factory and orchestration layers.
+ """
+
+ numerical_method: str
+ categorical_method: str
+ feature_preprocessing: dict
+ output_dim: int
+ degree: int
+ target_aware: bool
+ placement_strategy: str
+ task: str
+ adaptive: bool
+ min_output_dim: int
+ max_output_dim: int
+ random_state: int | None
+ scaling: str | None
+ cat_cutoff: float | int
+ treat_all_integers_as_numerical: bool
+ numerical_imputation: str | None
+ categorical_imputation: str | None
+ add_missing_indicator: bool
+ missing_policy: str | None
+ verbose: int
+
+ @classmethod
+ def from_params(
+ cls,
+ *,
+ numerical_method,
+ categorical_method,
+ feature_preprocessing,
+ output_dim,
+ degree,
+ target_aware,
+ placement_strategy,
+ task,
+ adaptive,
+ min_output_dim,
+ max_output_dim,
+ random_state,
+ scaling,
+ cat_cutoff,
+ treat_all_integers_as_numerical,
+ numerical_imputation,
+ categorical_imputation,
+ add_missing_indicator,
+ missing_policy,
+ verbose,
+ ) -> PreprocessorConfig:
+ """Normalize and validate raw Preprocessor parameters into a config.
+
+ Raises
+ ------
+ InvalidParamError
+ If the ``target_aware`` / ``placement_strategy`` combination is
+ invalid (via :func:`~pretab.core.parameters.validate_placement`).
+ 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:
+ raise invalid_param_error(
+ "Preprocessor",
+ "missing_policy",
+ missing_policy,
+ "must be None or one of 'error', 'propagate', 'impute', 'impute_with_indicator', 'separate_state'",
+ valid=set(MISSING_POLICIES),
+ )
+ if add_missing_indicator and numerical_imputation is None and categorical_imputation is None:
+ raise IncompatibleParamsError(
+ "add_missing_indicator=True requires numerical_imputation or categorical_imputation "
+ "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),
+ feature_preprocessing=dict(feature_preprocessing or {}),
+ output_dim=output_dim,
+ degree=degree,
+ target_aware=target_aware,
+ placement_strategy=placement_strategy,
+ task=task,
+ adaptive=adaptive,
+ min_output_dim=min_output_dim,
+ max_output_dim=max_output_dim,
+ random_state=random_state,
+ scaling=scaling,
+ cat_cutoff=cat_cutoff,
+ treat_all_integers_as_numerical=treat_all_integers_as_numerical,
+ numerical_imputation=numerical_imputation,
+ categorical_imputation=categorical_imputation,
+ add_missing_indicator=add_missing_indicator,
+ missing_policy=missing_policy,
+ verbose=verbose,
+ )
+
+ @staticmethod
+ def resolve_numerical(method) -> str:
+ """Resolve a numerical method name to its canonical spelling."""
+ return resolve_method(method, NUMERICAL_METHODS, NUMERICAL_ALIASES)
+
+ @staticmethod
+ def resolve_categorical(method) -> str:
+ """Resolve a categorical method name to its canonical spelling."""
+ return resolve_method(method, CATEGORICAL_METHODS, CATEGORICAL_ALIASES)
+
+ def method_for(self, feature, *, is_numerical: bool) -> str:
+ """Return the resolved method for ``feature`` given its detected kind.
+
+ A per-column override in ``feature_preprocessing`` wins over the global
+ default; the chosen name is resolved in the numerical or categorical
+ namespace according to ``is_numerical``.
+ """
+ default = self.numerical_method if is_numerical else self.categorical_method
+ raw = self.feature_preprocessing.get(feature, default)
+ return self.resolve_numerical(raw) if is_numerical else self.resolve_categorical(raw)
+
+ @property
+ def seed_kwargs(self) -> dict:
+ """Return ``{"random_state": ...}`` only when a seed was explicitly set.
+
+ Leaving it empty when ``random_state`` is ``None`` preserves each
+ transformer's own default seed, matching the historical behaviour where a
+ seed is forwarded only when the user pins one.
+ """
+ return {} if self.random_state is None else {"random_state": self.random_state}
+
+ def imputation_plan(self, *, is_numerical: bool) -> dict:
+ """Resolve how missing values are handled for one column kind.
+
+ Returns a dict with ``add_imputer`` / ``add_indicator`` / ``separate_state``
+ booleans and the imputer ``strategy``. When ``missing_policy`` is ``None``
+ the explicit ``*_imputation`` / ``add_missing_indicator`` parameters stay
+ authoritative (historical behaviour); otherwise ``missing_policy`` decides.
+ """
+ strategy = (
+ (self.numerical_imputation or "median")
+ if is_numerical
+ else (self.categorical_imputation or "most_frequent")
+ )
+ if self.missing_policy is None:
+ configured = self.numerical_imputation if is_numerical else self.categorical_imputation
+ return {
+ "add_imputer": configured is not None,
+ "add_indicator": self.add_missing_indicator,
+ "separate_state": False,
+ "strategy": strategy,
+ }
+ if self.missing_policy in ("error", "propagate"):
+ return {"add_imputer": False, "add_indicator": False, "separate_state": False, "strategy": strategy}
+ if self.missing_policy == "impute":
+ return {"add_imputer": True, "add_indicator": False, "separate_state": False, "strategy": strategy}
+ if self.missing_policy == "impute_with_indicator":
+ return {"add_imputer": True, "add_indicator": True, "separate_state": False, "strategy": strategy}
+ # "separate_state": impute for the basis, emit a dedicated __missing column
+ # (added by the factory as a separate branch that bypasses the basis).
+ return {"add_imputer": True, "add_indicator": False, "separate_state": True, "strategy": strategy}
diff --git a/pretab/compose/factory.py b/pretab/compose/factory.py
new file mode 100644
index 0000000..2d3fd8b
--- /dev/null
+++ b/pretab/compose/factory.py
@@ -0,0 +1,295 @@
+"""Build the per-column transformer pipelines and combine them.
+
+This module turns a resolved method name plus a :class:`PreprocessorConfig` into
+scikit-learn transformer steps, wraps them in a per-column
+:class:`~sklearn.pipeline.Pipeline`, and assembles every column into the final
+:class:`~sklearn.compose.ColumnTransformer`. The class to instantiate, the
+constructor arguments it accepts, and which placement keyword arguments apply are
+all taken from :data:`~pretab.compose.registry.TRANSFORMER_REGISTRY`.
+"""
+
+import warnings
+
+from sklearn.compose import ColumnTransformer
+from sklearn.impute import SimpleImputer
+from sklearn.pipeline import FeatureUnion, Pipeline
+from sklearn.preprocessing import MinMaxScaler, StandardScaler
+
+from ..exceptions import ConfigWarning, IncompatibleParamsError, invalid_param_error
+from ..transformers.encoders.floats import ToFloatTransformer
+from ..transformers.encoders.missing import MissingStateIndicator
+from .config import PreprocessorConfig
+from .registry import (
+ CATEGORICAL_ALIASES,
+ CATEGORICAL_METHODS,
+ NUMERICAL_ALIASES,
+ NUMERICAL_METHODS,
+ TransformerSpec,
+ get_spec,
+ resolve_method,
+)
+
+__all__ = [
+ "build_column_transformer",
+ "create_transformer",
+ "get_categorical_transformer_steps",
+ "get_numerical_transformer_steps",
+]
+
+# Valid range for the number of B/M/I spline basis functions per feature. The
+# Preprocessor shares a single ``output_dim`` across every numerical strategy
+# (default 7); values outside this window are clamped for the B/M/I splines.
+_MIN_SPLINE_BASIS = 5
+_MAX_SPLINE_BASIS = 50
+
+# B/M/I spline bases whose shared ``output_dim`` is clamped into the basis range.
+_BMI_SPLINE_METHODS = frozenset({"bspline", "mspline", "ispline"})
+# Freely-placed knot splines built through the knot-wiring construction path
+# (B/M/I plus the legacy cubic / natural-cubic regression splines).
+_KNOT_SPLINE_METHODS = _BMI_SPLINE_METHODS | frozenset({"cubicspline", "naturalspline"})
+
+
+def _filter_kwargs(allowed, kwargs):
+ """Keep only the ``allowed`` keyword arguments that are present in ``kwargs``."""
+ return {key: kwargs[key] for key in allowed if key in kwargs}
+
+
+def _clamp_spline_basis(output_dim):
+ """Clamp a requested output dimension into the supported B/M/I spline range.
+
+ Values outside ``[5, 50]`` are clamped and a :class:`ConfigWarning` is
+ emitted so switching to a B/M/I spline keeps working with the shared default.
+ """
+ clamped = max(_MIN_SPLINE_BASIS, min(int(output_dim), _MAX_SPLINE_BASIS))
+ if clamped != output_dim:
+ warnings.warn(
+ f"output_dim={output_dim} is outside the spline range "
+ f"[{_MIN_SPLINE_BASIS}, {_MAX_SPLINE_BASIS}]; using {clamped} basis functions.",
+ ConfigWarning,
+ stacklevel=2,
+ )
+ return clamped
+
+
+def _placement_kwargs(spec: TransformerSpec, kwargs):
+ """Return the placement kwargs to inject for a method, honouring its capability.
+
+ Mirrors the shared-placement contract: methods with optional target awareness
+ (feature maps and freely-placed knot splines) receive ``target_aware`` plus
+ the ``placement_strategy`` (when set); the always-target-aware ``ple`` receives
+ a supervised ``placement_strategy`` only when target-aware; the unsupervised-only
+ penalized splines receive an unsupervised ``placement_strategy`` only when not
+ target-aware. Methods without data-driven placement receive nothing.
+ """
+ if not spec.placement_strategies:
+ return {}
+
+ target_aware = bool(kwargs.get("target_aware", False))
+ placement_strategy = kwargs.get("placement_strategy")
+
+ if spec.target_usage == "optional":
+ out = {"target_aware": target_aware}
+ if placement_strategy is not None:
+ out["placement_strategy"] = placement_strategy
+ return out
+ if spec.target_usage == "required":
+ if target_aware and placement_strategy in ("cart", "lightgbm"):
+ return {"placement_strategy": placement_strategy}
+ return {}
+ # target_usage == "forbidden" but with unsupervised placement (pspline / tensorspline).
+ if not target_aware and placement_strategy in ("uniform", "quantile"):
+ return {"placement_strategy": placement_strategy}
+ return {}
+
+
+def get_numerical_transformer_steps(
+ method: str,
+ add_imputer: bool = True,
+ imputer_strategy: str = "median",
+ imputer_kwargs: dict | None = None,
+ add_missing_indicator: bool = False,
+ scaling: str | None = None,
+ **kwargs,
+):
+ """Return the ordered ``(name, transformer)`` steps for a numerical ``method``."""
+ method = resolve_method(method, NUMERICAL_METHODS, NUMERICAL_ALIASES)
+ steps = []
+
+ if add_imputer:
+ imputer_kwargs = imputer_kwargs or {}
+ steps.append(
+ ("imputer", SimpleImputer(strategy=imputer_strategy, add_indicator=add_missing_indicator, **imputer_kwargs))
+ )
+
+ # Optional scaling step, added only when it is not already the chosen method.
+ scalers = {
+ "standardization": ("scaler", StandardScaler()),
+ "minmax": ("minmax", MinMaxScaler(feature_range=(-1, 1))),
+ }
+ if scaling is not None:
+ scaling = resolve_method(scaling, NUMERICAL_METHODS, NUMERICAL_ALIASES)
+ if scaling in scalers and scaling != method:
+ steps.append(scalers[scaling])
+
+ if method not in NUMERICAL_METHODS:
+ raise invalid_param_error(
+ "get_numerical_transformer_steps",
+ "method",
+ method,
+ "unrecognized numerical preprocessing method",
+ valid=set(NUMERICAL_METHODS),
+ )
+
+ spec = get_spec(method)
+ cls = spec.transformer_cls
+ filtered = _filter_kwargs(spec.allowed_args, kwargs)
+ placement = _placement_kwargs(spec, kwargs)
+
+ if method == "box-cox":
+ steps.append(("scale_positive", MinMaxScaler(feature_range=(1e-3, 1))))
+ steps.append(("boxcox", cls(method="box-cox", **filtered)))
+ elif method == "yeo-johnson":
+ steps.append(("yeojohnson", cls(method="yeo-johnson", **filtered)))
+ elif method in _KNOT_SPLINE_METHODS:
+ spline_kwargs = dict(filtered)
+ spline_kwargs.update(placement)
+
+ # The B/M/I splines share the Preprocessor's default ``output_dim`` (which
+ # can sit outside their [5, 50] basis range); the legacy families keep
+ # their own wider bounds, so only clamp for B/M/I.
+ if method in _BMI_SPLINE_METHODS:
+ output_dim = kwargs.get("output_dim")
+ if output_dim is not None:
+ spline_kwargs["output_dim"] = _clamp_spline_basis(output_dim)
+
+ steps.append((method, cls(**spline_kwargs)))
+ else:
+ name = method if method != "none" else "noop"
+ call_kwargs = dict(filtered)
+ call_kwargs.update(placement)
+ steps.append((name, cls(**call_kwargs)))
+
+ return steps
+
+
+def get_categorical_transformer_steps(
+ method: str,
+ add_imputer: bool = True,
+ imputer_strategy: str = "most_frequent",
+ imputer_kwargs: dict | None = None,
+ add_missing_indicator: bool = False,
+ **kwargs,
+):
+ """Return the ordered ``(name, transformer)`` steps for a categorical ``method``."""
+ method = resolve_method(method, CATEGORICAL_METHODS, CATEGORICAL_ALIASES)
+ steps = []
+
+ if add_imputer:
+ imputer_kwargs = imputer_kwargs or {}
+ steps.append(
+ ("imputer", SimpleImputer(strategy=imputer_strategy, add_indicator=add_missing_indicator, **imputer_kwargs))
+ )
+
+ if method not in CATEGORICAL_METHODS:
+ raise invalid_param_error(
+ "get_categorical_transformer_steps",
+ "method",
+ method,
+ "unrecognized categorical preprocessing method",
+ valid=set(CATEGORICAL_METHODS),
+ )
+
+ cls = get_spec(method).transformer_cls
+
+ if method == "int":
+ steps.append(("continuous_ordinal", cls()))
+ elif method == "one-hot":
+ # Default to ignoring unseen categories so transform never crashes on
+ # categories absent at fit time; callers can override via kwargs.
+ onehot_kwargs = {"handle_unknown": "ignore", **kwargs}
+ steps.append(("onehot", cls(**onehot_kwargs)))
+ steps.append(("to_float", ToFloatTransformer()))
+ elif method == "pretrained":
+ steps.append(("pretrained", cls()))
+ elif method == "none":
+ steps.append(("none", cls()))
+ elif method == "onehot_from_ordinal":
+ steps.append(("onehot_from_ordinal", cls()))
+
+ return steps
+
+
+def create_transformer(method: str, *, is_numerical: bool, config: PreprocessorConfig) -> Pipeline | FeatureUnion:
+ """Build the per-column :class:`~sklearn.pipeline.Pipeline` for one feature.
+
+ ``method`` is the resolved method name; ``is_numerical`` selects the numerical
+ or categorical construction path. All width / placement / seeding knobs are
+ taken from ``config``.
+
+ Raises
+ ------
+ IncompatibleParamsError
+ If ``method`` is always target-aware (``target_usage="required"``) but the
+ run is configured with ``target_aware=False``; such a combination cannot be
+ satisfied and is rejected instead of silently ignored.
+ """
+ known = method in NUMERICAL_METHODS if is_numerical else method in CATEGORICAL_METHODS
+ if known:
+ spec = get_spec(method)
+ if spec.requires_target and not config.target_aware:
+ raise IncompatibleParamsError(
+ f"method {method!r} is always target-aware and requires target_aware=True "
+ f"with placement_strategy in {{'cart', 'lightgbm'}}; got target_aware=False."
+ )
+
+ plan = config.imputation_plan(is_numerical=is_numerical)
+ if is_numerical:
+ steps = get_numerical_transformer_steps(
+ method=method,
+ task=config.task,
+ target_aware=config.target_aware,
+ add_imputer=plan["add_imputer"],
+ imputer_strategy=plan["strategy"],
+ add_missing_indicator=plan["add_indicator"],
+ output_dim=config.output_dim,
+ adaptive=config.adaptive,
+ min_output_dim=config.min_output_dim if config.adaptive else None,
+ max_output_dim=config.max_output_dim if config.adaptive else None,
+ degree=config.degree,
+ scaling=config.scaling,
+ placement_strategy=config.placement_strategy,
+ **config.seed_kwargs,
+ )
+ else:
+ steps = get_categorical_transformer_steps(
+ method,
+ add_imputer=plan["add_imputer"],
+ imputer_strategy=plan["strategy"],
+ add_missing_indicator=plan["add_indicator"],
+ )
+
+ pipeline = Pipeline(steps)
+ if plan["separate_state"]:
+ # Emit a dedicated ``__missing`` column (built on the raw input) alongside
+ # the imputed representation, so the indicator never enters the basis.
+ return FeatureUnion([("representation", pipeline), ("missing", MissingStateIndicator())])
+ return pipeline
+
+
+def build_column_transformer(config: PreprocessorConfig, numerical_features, categorical_features) -> ColumnTransformer:
+ """Assemble the per-column pipelines into the final ColumnTransformer.
+
+ Numerical features are prefixed ``num_`` and categorical features ``cat_`` to
+ match the transformer names the Preprocessor exposes; untransformed columns
+ pass through via ``remainder="passthrough"``.
+ """
+ transformers = []
+ for feature in numerical_features:
+ method = config.method_for(feature, is_numerical=True)
+ pipeline = create_transformer(method, is_numerical=True, config=config)
+ transformers.append((f"num_{feature}", pipeline, [feature]))
+ for feature in categorical_features:
+ method = config.method_for(feature, is_numerical=False)
+ pipeline = create_transformer(method, is_numerical=False, config=config)
+ transformers.append((f"cat_{feature}", pipeline, [feature]))
+ return ColumnTransformer(transformers=transformers, remainder="passthrough")
diff --git a/pretab/compose/feature_detection.py b/pretab/compose/feature_detection.py
new file mode 100644
index 0000000..86d5e7d
--- /dev/null
+++ b/pretab/compose/feature_detection.py
@@ -0,0 +1,74 @@
+"""Coerce inputs to DataFrames and classify columns as numerical or categorical.
+
+Feature-type detection decides which construction path each column takes. It is
+kept here, separate from orchestration, so the Preprocessor's ``fit`` reads as a
+sequence of delegations rather than inlining the classification heuristic.
+"""
+
+import numpy as np
+import pandas as pd
+
+from ..exceptions import invalid_param_error
+
+__all__ = ["detect_column_types", "to_dataframe"]
+
+
+def to_dataframe(X, *, copy: bool = False) -> pd.DataFrame:
+ """Return ``X`` as a DataFrame, naming array columns ``feature_0``, ``feature_1`` ....
+
+ Dicts and NumPy arrays are wrapped in a fresh DataFrame; an existing
+ DataFrame is returned as-is, or copied when ``copy`` is True.
+ """
+ if isinstance(X, dict):
+ return pd.DataFrame(X)
+ if isinstance(X, np.ndarray):
+ return pd.DataFrame(X, columns=pd.Index([f"feature_{i}" for i in range(X.shape[1])]))
+ return X.copy() if copy else X
+
+
+def detect_column_types(X, *, cat_cutoff, treat_all_integers_as_numerical, estimator_name="Preprocessor"):
+ """Classify each column of ``X`` as numerical or categorical.
+
+ An integer column is treated as categorical when its cardinality falls below
+ ``cat_cutoff`` -- interpreted as a unique-ratio cutoff when a float, or an
+ absolute unique-count cutoff when an int. Non-numeric dtypes are always
+ categorical; ``treat_all_integers_as_numerical`` bypasses the heuristic for
+ integer columns.
+
+ Returns
+ -------
+ numerical_features : list
+ Column labels detected as numerical.
+ categorical_features : list
+ Column labels detected as categorical.
+ """
+ X = to_dataframe(X)
+
+ categorical_features = []
+ numerical_features = []
+
+ for col in X.columns:
+ num_unique_values = X[col].nunique()
+ total_samples = len(X[col])
+
+ if treat_all_integers_as_numerical and X[col].dtype.kind == "i":
+ numerical_features.append(col)
+ else:
+ if isinstance(cat_cutoff, float):
+ cutoff_condition = (num_unique_values / total_samples) < cat_cutoff
+ elif isinstance(cat_cutoff, int):
+ cutoff_condition = num_unique_values < cat_cutoff
+ else:
+ raise invalid_param_error(
+ estimator_name,
+ "cat_cutoff",
+ cat_cutoff,
+ "must be a float (unique-ratio cutoff) or an int (absolute unique-count cutoff)",
+ )
+
+ if X[col].dtype.kind not in "iufc" or (X[col].dtype.kind == "i" and cutoff_condition):
+ categorical_features.append(col)
+ else:
+ numerical_features.append(col)
+
+ return numerical_features, categorical_features
diff --git a/pretab/compose/inspection.py b/pretab/compose/inspection.py
new file mode 100644
index 0000000..c3ea875
--- /dev/null
+++ b/pretab/compose/inspection.py
@@ -0,0 +1,308 @@
+"""Introspect a fitted ColumnTransformer for output layout and feature metadata.
+
+These helpers back the Preprocessor's ``transform`` slicing and its
+``get_feature_info`` reporting: :func:`get_output_slices` computes each
+transformer's contiguous span in the stacked output, :func:`build_feature_info`
+collects per-feature preprocessing / dimension / category metadata, and
+:func:`build_transformer_summary` renders that metadata as an aligned table.
+"""
+
+import numpy as np
+
+from ..core.logging import get_logger
+from ..core.representation import FeatureLineage
+
+logger = get_logger(__name__)
+
+__all__ = [
+ "build_feature_info",
+ "build_feature_lineage",
+ "build_transformer_summary",
+ "clean_feature_names",
+ "get_output_slices",
+]
+
+
+def get_output_slices(column_transformer, X):
+ """Return ordered ``(name, start, width)`` spans for each output block.
+
+ The width of each transformer's block is obtained by transforming its input
+ columns, matching the order in which the fitted ColumnTransformer stacks its
+ outputs.
+ """
+ slices = []
+ start = 0
+ for name, transformer, columns in column_transformer.transformers_:
+ if transformer == "drop":
+ continue
+ if hasattr(transformer, "transform"):
+ width = transformer.transform(X[columns]).shape[1]
+ else:
+ width = 1
+ slices.append((name, start, width))
+ start += width
+ return slices
+
+
+def clean_feature_names(column_transformer, names):
+ """Collapse the per-feature name that sklearn's ColumnTransformer duplicates.
+
+ Each per-column step is named ``f"{kind}_{feature}"`` (see ``compose/factory.py``),
+ and every PreTab transformer's own ``get_feature_names_out`` already bakes the
+ input feature name into each output column, so sklearn's default
+ ``f"{step}__{inner}"`` naming doubles it, e.g. ``"num_age__age_bs0"``. This
+ collapses that back to ``"num_age_bs0"``, leaving passthrough/remainder columns
+ and any name it cannot confidently match unchanged.
+ """
+ step_to_feature = {
+ name: columns[0]
+ for name, _transformer, columns in column_transformer.transformers_
+ if name != "remainder" and len(columns) == 1
+ }
+ cleaned = []
+ for raw in names:
+ raw = str(raw)
+ step_name, sep, inner_name = raw.partition("__")
+ feature = step_to_feature.get(step_name)
+ if not sep or feature is None:
+ cleaned.append(raw)
+ continue
+ if inner_name == feature or inner_name.startswith(f"{feature}_"):
+ kind_prefix = step_name[: -(len(feature) + 1)] if step_name.endswith(f"_{feature}") else ""
+ cleaned.append(f"{kind_prefix}_{inner_name}" if kind_prefix else inner_name)
+ else:
+ cleaned.append(raw)
+ return cleaned
+
+
+def build_feature_info(column_transformer, *, embeddings, embedding_dimensions):
+ """Collect per-feature metadata (preprocessing, dimension, categories).
+
+ Returns a ``(numerical_info, categorical_info, embedding_info)`` tuple of
+ dicts keyed by feature name.
+ """
+ numerical_feature_info = {}
+ categorical_feature_info = {}
+
+ embedding_feature_info = (
+ {
+ key: {"preprocessing": None, "dimension": dim, "categories": None}
+ for key, dim in embedding_dimensions.items()
+ }
+ if embeddings
+ else {}
+ )
+
+ for (
+ name,
+ transformer_pipeline,
+ columns,
+ ) in column_transformer.transformers_:
+ steps = [step[0] for step in transformer_pipeline.steps]
+
+ for feature_name in columns:
+ preprocessing_type = " -> ".join(steps)
+ dimension = None
+ categories = None
+
+ if "discretizer" in steps or any(
+ step in steps
+ for step in [
+ "standardization",
+ "minmax",
+ "quantile",
+ "polynomial",
+ "splines",
+ "box-cox",
+ ]
+ ):
+ last_step = transformer_pipeline.steps[-1][1]
+ if hasattr(last_step, "transform"):
+ dummy_input = np.zeros((1, 1)) + 1e-05
+ try:
+ transformed_feature = last_step.transform(dummy_input)
+ dimension = transformed_feature.shape[1]
+ except (ValueError, TypeError, AttributeError, IndexError) as exc:
+ logger.debug(
+ "Could not introspect output width of %r: %s",
+ feature_name,
+ exc,
+ )
+ dimension = None
+ numerical_feature_info[feature_name] = {
+ "preprocessing": preprocessing_type,
+ "dimension": dimension,
+ "categories": None,
+ }
+
+ elif "continuous_ordinal" in steps:
+ step = transformer_pipeline.named_steps["continuous_ordinal"]
+ categories = len(step.mapping_[columns.index(feature_name)])
+ dimension = 1
+ categorical_feature_info[feature_name] = {
+ "preprocessing": preprocessing_type,
+ "dimension": dimension,
+ "categories": categories,
+ }
+
+ elif "onehot" in steps:
+ step = transformer_pipeline.named_steps["onehot"]
+ if hasattr(step, "categories_"):
+ categories = sum(len(cat) for cat in step.categories_)
+ dimension = categories
+ categorical_feature_info[feature_name] = {
+ "preprocessing": preprocessing_type,
+ "dimension": dimension,
+ "categories": categories,
+ }
+
+ else:
+ last_step = transformer_pipeline.steps[-1][1]
+ if hasattr(last_step, "transform"):
+ dummy_input = np.zeros((1, 1))
+ try:
+ transformed_feature = last_step.transform(dummy_input)
+ dimension = transformed_feature.shape[1]
+ except (ValueError, TypeError, AttributeError, IndexError) as exc:
+ logger.debug(
+ "Could not introspect output width of %r: %s",
+ feature_name,
+ exc,
+ )
+ dimension = None
+ if "cat" in name:
+ categorical_feature_info[feature_name] = {
+ "preprocessing": preprocessing_type,
+ "dimension": dimension,
+ "categories": None,
+ }
+ else:
+ numerical_feature_info[feature_name] = {
+ "preprocessing": preprocessing_type,
+ "dimension": dimension,
+ "categories": None,
+ }
+
+ return numerical_feature_info, categorical_feature_info, embedding_feature_info
+
+
+def build_transformer_summary(numerical_info, categorical_info, embedding_info):
+ """Build aligned, human-readable rows describing the fitted feature layout."""
+ rows = []
+ for feat, info in numerical_info.items():
+ rows.append((str(feat), "numerical", str(info["preprocessing"]), info["dimension"], info["categories"]))
+ for feat, info in categorical_info.items():
+ rows.append((str(feat), "categorical", str(info["preprocessing"]), info["dimension"], info["categories"]))
+ for feat, info in embedding_info.items():
+ rows.append((str(feat), "embedding", "-", info["dimension"], info["categories"]))
+ if not rows:
+ return []
+
+ feat_w = max(len("feature"), *(len(r[0]) for r in rows))
+ kind_w = max(len("kind"), *(len(r[1]) for r in rows))
+ pipe_w = max(len("pipeline"), *(len(r[2]) for r in rows))
+ header = f"{'feature':<{feat_w}} {'kind':<{kind_w}} {'pipeline':<{pipe_w}} {'dim':>4} {'cats':>5}"
+ lines = [header, "-" * len(header)]
+ for feat, kind, pipe, dim, cats in rows:
+ dim_s = "-" if dim is None else str(dim)
+ cats_s = "-" if cats is None else str(cats)
+ lines.append(f"{feat:<{feat_w}} {kind:<{kind_w}} {pipe:<{pipe_w}} {dim_s:>4} {cats_s:>5}")
+ return lines
+
+
+# Mapping from pipeline step name to (family, component) for representation-bearing
+# scikit-learn steps that do not expose ``get_representation_spec``.
+_STEP_FAMILY = {
+ "standardization": ("standardization", "raw"),
+ "scaler": ("standardization", "raw"),
+ "minmax": ("minmax", "raw"),
+ "robust": ("robust", "raw"),
+ "quantile": ("quantile", "raw"),
+ "polynomial": ("polynomial", "basis"),
+ "boxcox": ("box_cox", "raw"),
+ "yeojohnson": ("yeo_johnson", "raw"),
+ "onehot": ("onehot", "category"),
+ "pretrained": ("language_embedding", "embedding"),
+}
+
+
+def _resolve_block_representation(pipeline, columns):
+ """Return ``(family, component, uses_target, is_interaction)`` for a block.
+
+ The representation-bearing step is the last pipeline step exposing a
+ ``get_representation_spec`` (a PreTab transformer) or a known scikit-learn
+ step name; helper steps such as imputers and float casts are skipped.
+ """
+ steps = pipeline.steps if hasattr(pipeline, "steps") else [("_", pipeline)]
+ for step_name, transformer in reversed(steps):
+ if hasattr(transformer, "get_representation_spec"):
+ spec = transformer.get_representation_spec(input_features=list(columns))
+ return spec.family, spec.component_kind, spec.uses_target, spec.is_interaction
+ if step_name in _STEP_FAMILY:
+ family, component = _STEP_FAMILY[step_name]
+ return family, component, False, False
+ return "passthrough", "raw", False, False
+
+
+def _passthrough_source(columns, offset, feature_names_in):
+ """Resolve the source feature name for a passthrough / remainder column."""
+ column = columns[offset] if offset < len(columns) else columns[-1]
+ if isinstance(column, (int, np.integer)) and feature_names_in is not None:
+ return str(feature_names_in[column])
+ return str(column)
+
+
+def build_feature_lineage(column_transformer):
+ """Return per-output-column :class:`FeatureLineage` records.
+
+ Each record maps one output column of the fitted ColumnTransformer back to
+ its source feature(s), representation family, and component, covering 100%
+ of the transformed columns in ``get_feature_names_out`` order.
+ """
+ output_names = clean_feature_names(
+ column_transformer, [str(name) for name in column_transformer.get_feature_names_out()]
+ )
+ output_indices = column_transformer.output_indices_
+ feature_names_in = getattr(column_transformer, "feature_names_in_", None)
+ records = []
+ for name, transformer, columns in column_transformer.transformers_:
+ span = output_indices.get(name)
+ if span is None:
+ continue
+ width = span.stop - span.start
+ if width == 0:
+ continue
+ if transformer == "passthrough" or name == "remainder":
+ for offset in range(width):
+ index = span.start + offset
+ records.append(
+ FeatureLineage(
+ output_feature=output_names[index],
+ output_index=index,
+ source_features=(_passthrough_source(columns, offset, feature_names_in),),
+ family="passthrough",
+ component="raw",
+ component_index=offset,
+ uses_target=False,
+ is_interaction=False,
+ )
+ )
+ continue
+ family, component, uses_target, is_interaction = _resolve_block_representation(transformer, columns)
+ source_features = tuple(str(column) for column in columns)
+ for offset in range(width):
+ index = span.start + offset
+ records.append(
+ FeatureLineage(
+ output_feature=output_names[index],
+ output_index=index,
+ source_features=source_features,
+ family=family,
+ component=component,
+ component_index=offset,
+ uses_target=uses_target,
+ is_interaction=is_interaction,
+ )
+ )
+ records.sort(key=lambda record: record.output_index)
+ return records
diff --git a/pretab/compose/output.py b/pretab/compose/output.py
new file mode 100644
index 0000000..813fcf9
--- /dev/null
+++ b/pretab/compose/output.py
@@ -0,0 +1,186 @@
+"""Format the fitted ColumnTransformer output into the public return shapes.
+
+The Preprocessor returns either a single stacked NumPy array or a dictionary that
+keeps each feature's transformed block separate (and any external embedding
+blocks alongside them). This module owns that formatting only -- it performs no
+fitting and holds no capability logic; the per-block slices it consumes are
+computed in :mod:`pretab.compose.inspection`.
+
+It also owns the dense/sparse decision (``output_format``), the dtype-independent
+memory report (``output_report_``), and wrapping the stacked array into a pandas
+or polars DataFrame for :meth:`~pretab.Preprocessor.set_output`.
+"""
+
+import numpy as np
+from scipy import sparse as sp
+
+from ..exceptions import IncompatibleParamsError, OptionalDependencyError
+
+__all__ = [
+ "attach_embeddings",
+ "build_output_dict",
+ "compute_output_report",
+ "format_output",
+ "to_dataframe_output",
+]
+
+# Density at or below which ``output_format="auto"`` switches to a sparse matrix,
+# matching scikit-learn's ColumnTransformer ``sparse_threshold`` convention.
+_SPARSE_AUTO_THRESHOLD = 0.3
+
+_EMBEDDINGS_NOT_EXPECTED = (
+ "Embeddings were not expected, but were provided.\n"
+ "Fix: configure an embedding feature in feature_preprocessing before "
+ "passing embeddings to transform, or omit the embeddings argument."
+)
+
+
+def compute_output_report(array, output_format, *, threshold=_SPARSE_AUTO_THRESHOLD):
+ """Resolve the concrete output format and build the memory report.
+
+ Parameters
+ ----------
+ array : numpy.ndarray
+ The dense stacked output.
+ output_format : {"auto", "dense", "sparse"}
+ Requested format. ``"auto"`` picks ``"sparse"`` when the density is below
+ ``threshold``.
+ threshold : float, default=0.3
+ Density cut-off for the ``"auto"`` decision.
+
+ Returns
+ -------
+ tuple of (str, dict)
+ The resolved format (``"dense"`` or ``"sparse"``) and a report dict with
+ ``format``, ``shape``, ``density``, ``dense_bytes``, ``actual_bytes``, and
+ ``memory_saved_bytes``.
+ """
+ density = float(np.count_nonzero(array)) / array.size if array.size else 0.0
+ dense_bytes = int(array.nbytes)
+
+ if output_format == "sparse":
+ use_sparse = True
+ elif output_format == "auto":
+ use_sparse = density < threshold
+ else: # "dense"
+ use_sparse = False
+
+ if use_sparse:
+ csr = sp.csr_matrix(array)
+ actual_bytes = int(csr.data.nbytes + csr.indices.nbytes + csr.indptr.nbytes)
+ fmt = "sparse"
+ else:
+ actual_bytes = dense_bytes
+ fmt = "dense"
+
+ report = {
+ "format": fmt,
+ "shape": tuple(int(s) for s in array.shape),
+ "density": density,
+ "dense_bytes": dense_bytes,
+ "actual_bytes": actual_bytes,
+ "memory_saved_bytes": max(0, dense_bytes - actual_bytes),
+ }
+ return fmt, report
+
+
+def to_dataframe_output(array, columns, container):
+ """Wrap a dense stacked array in a pandas or polars DataFrame.
+
+ Parameters
+ ----------
+ array : numpy.ndarray
+ Dense stacked output.
+ columns : sequence of str
+ One name per output column (from ``get_feature_names_out``).
+ container : {"pandas", "polars"}
+ Target dataframe library.
+
+ Raises
+ ------
+ OptionalDependencyError
+ If ``container="polars"`` but polars is not installed.
+ """
+ columns = list(columns)
+ if container == "pandas":
+ import pandas as pd
+
+ return pd.DataFrame(array, columns=pd.Index(columns))
+ try:
+ import polars as pl # type: ignore
+ except ImportError as exc: # pragma: no cover - exercised only without polars
+ raise OptionalDependencyError(
+ "set_output(transform='polars') requires the optional 'polars' package. "
+ "Install it with `pip install polars`."
+ ) from exc
+ return pl.from_numpy(array, schema=columns)
+
+
+def build_output_dict(transformed, slices, *, as_sparse=False) -> dict:
+ """Split a stacked array into a name -> block dict using ``slices``.
+
+ ``slices`` is an ordered iterable of ``(name, start, width)`` describing each
+ transformer's contiguous span in the stacked output. When ``as_sparse`` is
+ True each block is returned as a SciPy CSR matrix.
+ """
+ result = {}
+ for name, start, width in slices:
+ block = transformed[:, start : start + width]
+ result[name] = sp.csr_matrix(block) if as_sparse else block
+ return result
+
+
+def attach_embeddings(result: dict, embeddings, *, expected: bool) -> dict:
+ """Attach external embedding blocks to a transformed-output dict.
+
+ Raises
+ ------
+ IncompatibleParamsError
+ If ``embeddings`` are provided but none were configured at fit time.
+ """
+ if not expected:
+ raise IncompatibleParamsError(_EMBEDDINGS_NOT_EXPECTED)
+ if isinstance(embeddings, np.ndarray):
+ result["embedding_1"] = embeddings.astype(np.float32)
+ elif isinstance(embeddings, list):
+ for idx, e in enumerate(embeddings):
+ result[f"embedding_{idx + 1}"] = e.astype(np.float32)
+ return result
+
+
+def format_output(
+ transformed,
+ *,
+ return_array,
+ slices=None,
+ embeddings=None,
+ embeddings_expected=False,
+ 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.
+ return_array : bool
+ If True, return the stacked array (dense or CSR); otherwise build the dict.
+ slices : iterable of (str, int, int), optional
+ Ordered ``(name, start, width)`` spans; required when ``return_array`` is
+ False.
+ embeddings : numpy.ndarray or list of numpy.ndarray, optional
+ External embedding blocks to attach to the dict output.
+ embeddings_expected : bool, default=False
+ Whether embedding blocks were configured at fit time.
+ output_format : {"dense", "sparse"}, default="dense"
+ Resolved output format. ``"sparse"`` returns a CSR matrix (array path) or
+ CSR blocks (dict path).
+ """
+ as_sparse = output_format == "sparse"
+ if return_array:
+ return sp.csr_matrix(transformed) if as_sparse else transformed
+
+ result = build_output_dict(transformed, slices or [], as_sparse=as_sparse)
+ if embeddings is not None:
+ attach_embeddings(result, embeddings, expected=embeddings_expected)
+ return result
diff --git a/pretab/compose/registry.py b/pretab/compose/registry.py
new file mode 100644
index 0000000..30fde8e
--- /dev/null
+++ b/pretab/compose/registry.py
@@ -0,0 +1,553 @@
+"""Single capability registry for every preprocessing method.
+
+This module is the one place that answers *what a method is and what it can do*:
+the transformer class to instantiate, the constructor arguments it accepts, and
+the capability flags (feature kind, arity, target usage, valid placement
+strategies, adaptive-resolution support, preprocessor compatibility, optional
+dependency). The composition layer (:mod:`pretab.compose.config`,
+:mod:`pretab.compose.factory`) and the public contract tests all derive their
+behaviour from this table rather than from scattered per-family lists.
+
+Name resolution (aliases + separator/case-insensitive matching) also lives here
+so both the numerical and categorical sides resolve user-supplied method names
+through a single implementation.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+
+from sklearn.preprocessing import (
+ MinMaxScaler,
+ OneHotEncoder,
+ PolynomialFeatures,
+ PowerTransformer,
+ QuantileTransformer,
+ RobustScaler,
+ StandardScaler,
+)
+
+from ..transformers.categorical.language_embedding import (
+ LanguageEmbeddingTransformer,
+)
+from ..transformers.categorical.legacy import OneHotFromOrdinalTransformer
+from ..transformers.categorical.ordinal import ContinuousOrdinalTransformer
+from ..transformers.encoders.floats import NoTransformer
+from ..transformers.feature_maps.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 (
+ TensorProductSplineTransformer,
+)
+from ..transformers.splines.multivariate.thin_plate import (
+ ThinPlateSplineTransformer,
+)
+from ..transformers.splines.natural_cubic import NaturalCubicSplineTransformer
+from ..transformers.splines.p_spline import PSplineTransformer
+
+__all__ = [
+ "CATEGORICAL_ALIASES",
+ "CATEGORICAL_METHODS",
+ "NUMERICAL_ALIASES",
+ "NUMERICAL_METHODS",
+ "TRANSFORMER_REGISTRY",
+ "TransformerSpec",
+ "categorical_method_names",
+ "get_spec",
+ "numerical_method_names",
+ "placement_strategies_for",
+ "register_spec",
+ "resolve_method",
+ "supports_adaptive_resolution",
+ "supports_target_aware",
+]
+
+# Canonical feature kinds a method can apply to.
+NUMERICAL = "numerical"
+CATEGORICAL = "categorical"
+
+# Canonical placement strategy names, split by supervision.
+_UNSUPERVISED_STRATEGIES = frozenset({"uniform", "quantile"})
+_UNIFORM_ONLY = frozenset({"uniform"})
+_TARGET_AWARE_STRATEGIES = frozenset({"cart", "lightgbm"})
+_ALL_STRATEGIES = _UNSUPERVISED_STRATEGIES | _TARGET_AWARE_STRATEGIES
+
+
+@dataclass(frozen=True)
+class TransformerSpec:
+ """Declarative capability record for a single preprocessing method.
+
+ Parameters
+ ----------
+ name : str
+ Canonical method name (the registry key).
+ transformer_cls : type
+ The scikit-learn-compatible transformer class to instantiate. Methods
+ that build extra steps (e.g. ``one-hot`` appends a float cast, the power
+ transforms prepend a positive-range scaler) record their primary class
+ here; the extra wiring lives in :mod:`pretab.compose.factory`.
+ allowed_args : tuple of str
+ Constructor argument names the method accepts. Used to filter the shared
+ Preprocessor keyword arguments down to what the class understands.
+ feature_kind : frozenset of str
+ The feature kinds the method applies to (``"numerical"`` and/or
+ ``"categorical"``). Only ``none`` (passthrough) applies to both.
+ arity : {"univariate", "multivariate"}
+ Whether the method transforms one column at a time (``"univariate"``) or
+ jointly models several columns (``"multivariate"`` -- the tensor-product
+ and thin-plate splines).
+ target_usage : {"forbidden", "optional", "required"}
+ How the method uses the supervised target ``y`` for basis placement.
+ ``"required"`` methods (PLE) always place against ``y``; ``"optional"``
+ methods (feature maps and freely-placed knot splines) place against ``y``
+ only when ``target_aware`` is set; ``"forbidden"`` methods never use it.
+ placement_strategies : frozenset of str
+ The placement strategies the method honours. Empty for methods with no
+ data-driven placement.
+ supports_adaptive_resolution : bool
+ Whether the method can size each feature's output dimension from the data
+ (within ``[min_output_dim, max_output_dim]``) instead of a fixed width.
+ preprocessor_compatible : bool
+ Whether the method can be selected per column through :class:`Preprocessor`.
+ optional_dependency : str or None
+ The optional extra that must be installed for the method to run
+ (``pip install pretab[]``), or ``None`` when always available.
+ periodic : bool
+ Whether the representation encodes a periodic signal (e.g. the Fourier
+ feature map). Surfaced through :func:`pretab.list_representations`.
+ sparse_output : bool
+ Whether the method can emit a sparse matrix (e.g. one-hot). Surfaced
+ through :func:`pretab.list_representations`.
+ """
+
+ name: str
+ transformer_cls: type
+ allowed_args: tuple[str, ...] = ()
+ feature_kind: frozenset[str] = field(default_factory=lambda: frozenset({NUMERICAL}))
+ arity: str = "univariate"
+ target_usage: str = "forbidden"
+ placement_strategies: frozenset[str] = frozenset()
+ supports_adaptive_resolution: bool = False
+ preprocessor_compatible: bool = True
+ optional_dependency: str | None = None
+ periodic: bool = False
+ sparse_output: bool = False
+
+ @property
+ def is_numerical(self) -> bool:
+ """Whether the method applies to numerical columns."""
+ return NUMERICAL in self.feature_kind
+
+ @property
+ def is_categorical(self) -> bool:
+ """Whether the method applies to categorical columns."""
+ return CATEGORICAL in self.feature_kind
+
+ @property
+ def is_multivariate(self) -> bool:
+ """Whether the method jointly models several columns."""
+ return self.arity == "multivariate"
+
+ @property
+ def target_aware_capable(self) -> bool:
+ """Whether the method can place basis units against ``y``."""
+ return self.target_usage in ("optional", "required")
+
+ @property
+ def requires_target(self) -> bool:
+ """Whether the method always needs ``y`` for placement."""
+ return self.target_usage == "required"
+
+ @property
+ def requires_y(self) -> bool:
+ """Alias of :attr:`requires_target` matching the transformer contract."""
+ return self.requires_target
+
+ @property
+ def is_supervised(self) -> bool:
+ """Whether the method can consume ``y`` (optional or required)."""
+ return self.target_aware_capable
+
+
+def _spec(name, cls, allowed_args=(), **kwargs):
+ """Construct a :class:`TransformerSpec`, normalising ``allowed_args``."""
+ return TransformerSpec(name=name, transformer_cls=cls, allowed_args=tuple(allowed_args), **kwargs)
+
+
+# Feature-map centers and freely-placed knot splines share the same placement
+# capability: optional target awareness across all four strategies, plus adaptive
+# resolution.
+_BOTH_MODE = {
+ "target_usage": "optional",
+ "placement_strategies": _ALL_STRATEGIES,
+ "supports_adaptive_resolution": True,
+}
+
+# name -> capability spec. Numerical methods first (preserving the historical
+# ordering), then the categorical-only methods.
+_SPECS: tuple[TransformerSpec, ...] = (
+ # --- numerical: scalers / distribution transforms (no placement) ---
+ _spec("standardization", StandardScaler),
+ _spec("minmax", MinMaxScaler),
+ _spec("quantile", QuantileTransformer, ("n_quantiles", "output_distribution", "random_state")),
+ _spec("polynomial", PolynomialFeatures, ("degree", "interaction_only", "include_bias")),
+ _spec("robust", RobustScaler),
+ _spec("box-cox", PowerTransformer),
+ _spec("yeo-johnson", PowerTransformer),
+ # --- numerical: piecewise-linear encoding (always target-aware) ---
+ _spec(
+ "ple",
+ PLETransformer,
+ ("output_dim", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state"),
+ target_usage="required",
+ placement_strategies=_TARGET_AWARE_STRATEGIES,
+ supports_adaptive_resolution=True,
+ ),
+ # --- numerical: binning (unsupervised uniform / quantile edge placement) ---
+ _spec(
+ "custombin",
+ NumericBinningTransformer,
+ ("output_dim", "encode"),
+ placement_strategies=_UNSUPERVISED_STRATEGIES,
+ ),
+ # --- numerical: feature maps (optional target-aware, adaptive) ---
+ _spec(
+ "rbf",
+ RBFExpansionTransformer,
+ ("output_dim", "gamma", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state"),
+ **_BOTH_MODE,
+ ),
+ _spec(
+ "relu",
+ ReLUExpansionTransformer,
+ ("output_dim", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state"),
+ **_BOTH_MODE,
+ ),
+ _spec(
+ "sigmoid",
+ SigmoidExpansionTransformer,
+ ("output_dim", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state"),
+ **_BOTH_MODE,
+ ),
+ _spec(
+ "tanh",
+ TanhExpansionTransformer,
+ ("output_dim", "scale", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state"),
+ **_BOTH_MODE,
+ ),
+ # --- numerical: deterministic Fourier feature map (univariate, unsupervised) ---
+ _spec(
+ "fourier",
+ FourierFeatureTransformer,
+ ("n_frequencies", "frequency_strategy", "include_original", "random_state"),
+ periodic=True,
+ ),
+ # --- numerical: freely-placed knot splines (optional target-aware, adaptive) ---
+ _spec(
+ "cubicspline",
+ CubicRegressionSplineTransformer,
+ (
+ "output_dim",
+ "degree",
+ "include_bias",
+ "task",
+ "adaptive",
+ "min_output_dim",
+ "max_output_dim",
+ "random_state",
+ ),
+ **_BOTH_MODE,
+ ),
+ _spec(
+ "naturalspline",
+ NaturalCubicSplineTransformer,
+ ("output_dim", "include_bias", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state"),
+ **_BOTH_MODE,
+ ),
+ # --- numerical: penalized splines (equally-spaced knots, unsupervised only) ---
+ _spec(
+ "pspline",
+ PSplineTransformer,
+ ("output_dim", "degree", "diff_order"),
+ placement_strategies=_UNIFORM_ONLY,
+ ),
+ _spec(
+ "tensorspline",
+ TensorProductSplineTransformer,
+ ("output_dim", "degree", "diff_order"),
+ arity="multivariate",
+ placement_strategies=_UNSUPERVISED_STRATEGIES,
+ preprocessor_compatible=False,
+ ),
+ # --- numerical: kernel-based thin-plate spline (knot-free, multivariate) ---
+ _spec(
+ "tprs",
+ ThinPlateSplineTransformer,
+ ("n_components", "landmark_strategy", "rank_strategy", "random_state"),
+ arity="multivariate",
+ preprocessor_compatible=False,
+ ),
+ # --- numerical: kernel-approximation feature maps (multivariate, standalone) ---
+ _spec(
+ "rff",
+ RandomFourierFeaturesTransformer,
+ ("n_components", "gamma", "random_state"),
+ arity="multivariate",
+ preprocessor_compatible=False,
+ ),
+ _spec(
+ "nystroem",
+ NystroemFeaturesTransformer,
+ ("n_components", "kernel", "gamma", "degree", "coef0", "random_state"),
+ arity="multivariate",
+ preprocessor_compatible=False,
+ ),
+ # --- numerical: B / M / I spline bases (optional target-aware, adaptive) ---
+ _spec(
+ "bspline",
+ BSplineTransformer,
+ ("degree", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state"),
+ **_BOTH_MODE,
+ ),
+ _spec(
+ "mspline",
+ MSplineTransformer,
+ ("degree", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state"),
+ **_BOTH_MODE,
+ ),
+ _spec(
+ "ispline",
+ ISplineTransformer,
+ ("degree", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state"),
+ **_BOTH_MODE,
+ ),
+ # --- numerical / categorical: passthrough ---
+ _spec("none", NoTransformer, feature_kind=frozenset({NUMERICAL, CATEGORICAL})),
+ # --- categorical-only methods ---
+ _spec("int", ContinuousOrdinalTransformer, feature_kind=frozenset({CATEGORICAL})),
+ _spec("one-hot", OneHotEncoder, feature_kind=frozenset({CATEGORICAL}), sparse_output=True),
+ _spec("onehot_from_ordinal", OneHotFromOrdinalTransformer, feature_kind=frozenset({CATEGORICAL})),
+ _spec(
+ "pretrained",
+ LanguageEmbeddingTransformer,
+ feature_kind=frozenset({CATEGORICAL}),
+ optional_dependency="embeddings",
+ ),
+)
+
+TRANSFORMER_REGISTRY: dict[str, TransformerSpec] = {spec.name: spec for spec in _SPECS}
+
+
+# ---------------------------------------------------------------------------
+# Derived views (kept in sync with the registry; do not edit by hand).
+# ---------------------------------------------------------------------------
+def numerical_method_names() -> frozenset[str]:
+ """Return the set of canonical numerical method names."""
+ return frozenset(name for name, spec in TRANSFORMER_REGISTRY.items() if spec.is_numerical)
+
+
+def categorical_method_names() -> frozenset[str]:
+ """Return the set of canonical categorical method names."""
+ return frozenset(name for name, spec in TRANSFORMER_REGISTRY.items() if spec.is_categorical)
+
+
+# Derived lookup tables consumed by the factory and config layers.
+# ``NUMERICAL_METHODS`` maps a numerical method to ``(class, allowed_args_list)``;
+# ``CATEGORICAL_METHODS`` is the set of categorical method names. Methods flagged
+# ``preprocessor_compatible=False`` (the multivariate tensor-product / thin-plate
+# splines) are standalone-only and deliberately excluded from the per-column
+# ``Preprocessor`` whitelist.
+NUMERICAL_METHODS: dict[str, tuple[type, list[str]]] = {
+ name: (spec.transformer_cls, list(spec.allowed_args))
+ for name, spec in TRANSFORMER_REGISTRY.items()
+ if spec.is_numerical and spec.preprocessor_compatible
+}
+# Mutable so :func:`pretab.register_representation` can extend the categorical
+# whitelist in place and have the config / factory layers (which import this
+# name) observe the addition immediately.
+CATEGORICAL_METHODS: set[str] = set(categorical_method_names())
+
+
+def get_spec(method: str) -> TransformerSpec:
+ """Return the :class:`TransformerSpec` for a canonical ``method`` name.
+
+ Raises
+ ------
+ KeyError
+ If ``method`` is not a registered canonical method name. Callers that
+ accept user input should resolve the name via :func:`resolve_method`
+ first.
+ """
+ return TRANSFORMER_REGISTRY[method]
+
+
+def placement_strategies_for(method: str) -> frozenset[str]:
+ """Return the placement strategies a canonical ``method`` honours."""
+ return TRANSFORMER_REGISTRY[method].placement_strategies
+
+
+def supports_adaptive_resolution(method: str) -> bool:
+ """Return whether a canonical ``method`` supports adaptive output sizing."""
+ return TRANSFORMER_REGISTRY[method].supports_adaptive_resolution
+
+
+def supports_target_aware(method: str) -> bool:
+ """Return whether ``method`` supports target-aware basis placement.
+
+ Accepts an alias or separator variant; resolves it first. True for the
+ feature maps, PLE, and the freely-placed knot splines (``bspline`` /
+ ``mspline`` / ``ispline`` / ``cubicspline`` / ``naturalspline``); False for
+ the penalized and kernel-based splines and every non-placement method.
+ """
+ resolved = resolve_method(method, TRANSFORMER_REGISTRY, NUMERICAL_ALIASES)
+ spec = TRANSFORMER_REGISTRY.get(resolved)
+ return bool(spec and spec.target_aware_capable)
+
+
+def register_spec(spec: TransformerSpec, *, override: bool = False) -> TransformerSpec:
+ """Insert a :class:`TransformerSpec` into the live registry.
+
+ Updates the registry and the derived ``NUMERICAL_METHODS`` /
+ ``CATEGORICAL_METHODS`` views in place, so the config and factory layers
+ (which import those names) immediately observe the new method. This backs the
+ public :func:`pretab.register_representation`.
+
+ Parameters
+ ----------
+ spec : TransformerSpec
+ The capability record to register.
+ override : bool, default=False
+ Whether replacing an already-registered name is allowed.
+
+ Raises
+ ------
+ TypeError
+ If ``spec`` is not a :class:`TransformerSpec`.
+ ValueError
+ If ``spec.name`` is already registered and ``override`` is False.
+ """
+ if not isinstance(spec, TransformerSpec):
+ raise TypeError(f"expected a TransformerSpec, got {type(spec).__name__}")
+ if spec.name in TRANSFORMER_REGISTRY and not override:
+ raise ValueError(f"method {spec.name!r} is already registered; pass override=True to replace it.")
+ # Drop any stale derived-view entries before re-inserting (supports override).
+ NUMERICAL_METHODS.pop(spec.name, None)
+ CATEGORICAL_METHODS.discard(spec.name)
+ TRANSFORMER_REGISTRY[spec.name] = spec
+ if spec.is_numerical and spec.preprocessor_compatible:
+ NUMERICAL_METHODS[spec.name] = (spec.transformer_cls, list(spec.allowed_args))
+ if spec.is_categorical:
+ CATEGORICAL_METHODS.add(spec.name)
+ return spec
+
+
+# ---------------------------------------------------------------------------
+# Name resolution (aliases + separator/case-insensitive matching).
+# ---------------------------------------------------------------------------
+def _squash(name: str) -> str:
+ """Collapse a method name for separator/case-insensitive comparison.
+
+ Lowercases, trims surrounding whitespace, and drops the ``-``, ``_`` and
+ space separators so ``"One-Hot"``, ``"one_hot"`` and ``"onehot"`` all map to
+ the same key. Canonical names that only differ by a separator (``"box-cox"``
+ vs ``"boxcox"``, ``"cubicspline"`` vs ``"cubic spline"``) therefore match
+ without needing an explicit alias entry.
+ """
+ return name.strip().lower().replace("-", "").replace("_", "").replace(" ", "")
+
+
+# Genuine synonyms / abbreviations that are *not* just separator variants of a
+# canonical name (those are handled by :func:`_squash`). Keys are already
+# squashed; values are canonical numerical method names.
+NUMERICAL_ALIASES = {
+ "standard": "standardization",
+ "standardize": "standardization",
+ "standardscaler": "standardization",
+ "std": "standardization",
+ "zscore": "standardization",
+ "minmaxscaler": "minmax",
+ "quantiletransformer": "quantile",
+ "poly": "polynomial",
+ "robustscaler": "robust",
+ "piecewiselinear": "ple",
+ "bin": "custombin",
+ "binning": "custombin",
+ "cubic": "cubicspline",
+ "natural": "naturalspline",
+ "naturalcubic": "naturalspline",
+ "tensor": "tensorspline",
+ "tensorproduct": "tensorspline",
+ "tensorproductspline": "tensorspline",
+ "thinplate": "tprs",
+ "thinplatespline": "tprs",
+ "fourierfeatures": "fourier",
+ "randomfourier": "rff",
+ "randomfourierfeatures": "rff",
+ "rbfsampler": "rff",
+ "nystrom": "nystroem",
+ "passthrough": "none",
+ "identity": "none",
+ "raw": "none",
+}
+
+# Genuine synonyms / abbreviations for the categorical methods (keys squashed).
+CATEGORICAL_ALIASES = {
+ "integer": "int",
+ "ordinal": "int",
+ "label": "int",
+ "labelencoder": "int",
+ "ordinalencoder": "int",
+ "ohe": "one-hot",
+ "dummy": "one-hot",
+ "onehotencoder": "one-hot",
+ "embedding": "pretrained",
+ "embeddings": "pretrained",
+ "language": "pretrained",
+ "llm": "pretrained",
+ "passthrough": "none",
+ "identity": "none",
+ "raw": "none",
+}
+
+
+def resolve_method(name, canonical, aliases):
+ """Resolve a user-supplied method name to its canonical spelling.
+
+ Matching is case-insensitive, ignores ``-`` / ``_`` / space separators, and
+ honours the explicit ``aliases`` map of synonyms and abbreviations. An
+ unrecognized name is returned lowercased and stripped so the caller's own
+ "unrecognized method" error lists the canonical options.
+
+ Parameters
+ ----------
+ name : str
+ The method name the user supplied.
+ canonical : set or dict
+ The canonical method names (``NUMERICAL_METHODS`` keys or
+ ``CATEGORICAL_METHODS``).
+ aliases : dict
+ Squashed-alias to canonical-name mapping for this side of the pipeline.
+ """
+ key = name.strip().lower()
+ if key in canonical:
+ return key
+
+ squashed = _squash(name)
+ for canon in canonical:
+ if _squash(canon) == squashed:
+ return canon
+ if squashed in aliases:
+ return aliases[squashed]
+ return key
diff --git a/pretab/compose/search.py b/pretab/compose/search.py
new file mode 100644
index 0000000..b3224d6
--- /dev/null
+++ b/pretab/compose/search.py
@@ -0,0 +1,131 @@
+"""Cross-validated search over numerical representation methods.
+
+``RepresentationSearchCV`` is a lightweight skeleton that, for each candidate
+numerical method, builds a :class:`~pretab.preprocessor.Preprocessor` feeding a
+cloned downstream ``estimator``, scores it with cross-validation, and refits the
+best-scoring representation on all data. It is intentionally minimal: the search
+space is the ``numerical_method`` axis, and every fold uses the preprocessor's
+native array output so no target leaks across the train/validation split.
+"""
+
+from collections.abc import Callable
+from typing import cast
+
+import numpy as np
+from sklearn.base import BaseEstimator, clone, is_classifier
+from sklearn.metrics import check_scoring
+from sklearn.model_selection import BaseCrossValidator, check_cv
+from sklearn.utils.validation import check_is_fitted
+
+from ..core._typing import PredictorLike
+from ..exceptions import InvalidParamError
+from ..preprocessor import Preprocessor
+
+__all__ = ["RepresentationSearchCV"]
+
+
+def _row_subset(data, idx):
+ """Return the rows of ``data`` at positions ``idx`` for arrays or frames."""
+ if hasattr(data, "iloc"):
+ return data.iloc[idx]
+ return np.asarray(data)[idx]
+
+
+class RepresentationSearchCV(BaseEstimator):
+ """Select the best numerical representation by cross-validation.
+
+ Parameters
+ ----------
+ estimator : estimator
+ Downstream supervised estimator fit on the transformed features.
+ methods : sequence of str
+ Candidate ``numerical_method`` values to search over.
+ cv : int or cross-validation generator, default=5
+ Cross-validation splitting strategy passed to
+ :func:`sklearn.model_selection.check_cv`.
+ scoring : str or callable or None, default=None
+ Scoring passed to :func:`sklearn.metrics.check_scoring`; ``None`` uses the
+ estimator's ``score`` method.
+ preprocessor_params : dict or None, default=None
+ Extra keyword arguments forwarded to every :class:`Preprocessor`.
+ random_state : int or None, default=None
+ Seed forwarded to each :class:`Preprocessor`.
+
+ Attributes
+ ----------
+ cv_results_ : dict
+ Mapping of method name to mean cross-validation score.
+ best_method_ : str
+ The highest-scoring numerical method.
+ best_score_ : float
+ Mean cross-validation score of ``best_method_``.
+ best_preprocessor_ : Preprocessor
+ Preprocessor for ``best_method_`` refit on all data.
+ best_estimator_ : estimator
+ Estimator refit on the best representation of all data.
+ """
+
+ def __init__(self, estimator, methods, *, cv=5, scoring=None, preprocessor_params=None, random_state=None):
+ self.estimator = estimator
+ self.methods = methods
+ self.cv = cv
+ self.scoring = scoring
+ self.preprocessor_params = preprocessor_params
+ self.random_state = random_state
+
+ def _make_preprocessor(self, method):
+ """Build a Preprocessor for ``method`` with the shared parameters."""
+ params = dict(self.preprocessor_params or {})
+ params.setdefault("random_state", self.random_state)
+ return Preprocessor(numerical_method=method, **params)
+
+ def fit(self, X, y=None):
+ """Search over ``methods`` and refit the best representation on all data."""
+ methods = list(self.methods)
+ if not methods:
+ raise InvalidParamError("methods must be a non-empty sequence of numerical methods.")
+ if y is None:
+ raise InvalidParamError("RepresentationSearchCV requires y at fit time; got y=None.")
+ y_arr = np.asarray(y).ravel()
+ n_samples = X.shape[0] if hasattr(X, "shape") else len(X)
+ cv = cast(BaseCrossValidator, check_cv(self.cv, y_arr, classifier=is_classifier(self.estimator)))
+
+ cv_results: dict[str, float] = {}
+ best_score = -np.inf
+ best_method = methods[0]
+ for method in methods:
+ fold_scores = []
+ for train_idx, test_idx in cv.split(np.zeros(n_samples), y_arr):
+ pre = self._make_preprocessor(method)
+ est = cast(PredictorLike, clone(self.estimator))
+ x_train = pre.fit_transform(_row_subset(X, train_idx), y_arr[train_idx], return_array=True)
+ est.fit(x_train, y_arr[train_idx])
+ scorer = cast("Callable[..., float]", check_scoring(est, scoring=self.scoring))
+ x_test = pre.transform(_row_subset(X, test_idx), return_array=True)
+ fold_scores.append(scorer(est, x_test, y_arr[test_idx]))
+ mean_score = float(np.mean(fold_scores))
+ cv_results[method] = mean_score
+ if mean_score > best_score:
+ best_score = mean_score
+ best_method = method
+
+ self.cv_results_ = cv_results
+ self.best_method_ = best_method
+ self.best_score_ = best_score
+ self.best_preprocessor_ = self._make_preprocessor(best_method)
+ x_all = self.best_preprocessor_.fit_transform(X, y_arr, return_array=True)
+ self.best_estimator_ = cast(PredictorLike, clone(self.estimator)).fit(x_all, y_arr)
+ return self
+
+ def predict(self, X):
+ """Predict with the best refit estimator on the best representation."""
+ check_is_fitted(self, "best_estimator_")
+ x = self.best_preprocessor_.transform(X, return_array=True)
+ return self.best_estimator_.predict(x)
+
+ def score(self, X, y):
+ """Score the best refit estimator on ``(X, y)``."""
+ check_is_fitted(self, "best_estimator_")
+ x = self.best_preprocessor_.transform(X, return_array=True)
+ scorer = cast("Callable[..., float]", check_scoring(self.best_estimator_, scoring=self.scoring))
+ return scorer(self.best_estimator_, x, np.asarray(y).ravel())
diff --git a/pretab/compose/serialize.py b/pretab/compose/serialize.py
new file mode 100644
index 0000000..6c9d07b
--- /dev/null
+++ b/pretab/compose/serialize.py
@@ -0,0 +1,233 @@
+"""Portable, versioned JSON (de)serialization for the fitted preprocessor.
+
+:func:`preprocessor_to_spec` / :func:`preprocessor_from_spec` capture a fitted
+:class:`~pretab.preprocessor.Preprocessor` as a self-describing JSON document --
+schema-versioned, dependency-versioned, and human-inspectable -- that
+reconstructs the estimator bit-for-bit. It is an explicit, auditable alternative
+to :mod:`pickle`: loading a spec only ever imports an allow-listed set of library
+namespaces and never runs estimator ``__init__`` / ``__reduce__`` /
+``__setstate__`` code, so a spec cannot execute arbitrary code the way
+``pickle.load`` can.
+
+The document has a small declarative envelope (schema/library versions, resolved
+constructor params, per-representation summary, output column order) plus a
+``state`` payload that JSON-encodes the fitted object graph (numpy arrays, knot /
+center / bin locations, scalers, nested estimators) for exact reconstruction.
+"""
+
+import dataclasses
+import importlib
+from typing import Any, cast
+
+import numpy as np
+from sklearn.base import BaseEstimator
+from sklearn.utils.validation import check_is_fitted
+
+from .._version import __version__ as _PRETAB_VERSION
+from ..core.parameters import UNSET
+from ..exceptions import PretabError, PretabSerializationError
+
+SCHEMA_VERSION = 1
+
+# Top-level packages that a spec is allowed to import classes/types from.
+_ALLOWED_TOP_LEVEL = frozenset({"pretab", "sklearn", "numpy", "scipy", "builtins"})
+
+
+# --- helpers -------------------------------------------------------------
+def _qualname(obj) -> str:
+ cls = type(obj)
+ return f"{cls.__module__}:{cls.__qualname__}"
+
+
+def _module_allowed(module: str) -> bool:
+ return module.partition(".")[0] in _ALLOWED_TOP_LEVEL
+
+
+def _resolve(dotted: str):
+ """Import and return the class/type named ``module:qualname`` from the allow-list."""
+ module, _, qualname = dotted.partition(":")
+ if not _module_allowed(module):
+ raise PretabSerializationError(
+ f"Refusing to import disallowed module {module!r} while loading a spec. "
+ f"Only {sorted(_ALLOWED_TOP_LEVEL)} are permitted."
+ )
+ obj = importlib.import_module(module)
+ for part in qualname.split("."):
+ obj = getattr(obj, part)
+ return obj
+
+
+# --- encoding ------------------------------------------------------------
+def _encode(obj):
+ if isinstance(obj, np.bool_):
+ return bool(obj)
+ if isinstance(obj, np.integer):
+ return int(obj)
+ if isinstance(obj, np.floating):
+ return float(obj)
+ if obj is None or isinstance(obj, (bool, int, float, str)):
+ return obj
+ if obj is UNSET:
+ return {"__unset__": True}
+ if isinstance(obj, np.ndarray):
+ return {"__ndarray__": {"dtype": obj.dtype.str, "shape": list(obj.shape), "data": obj.tolist()}}
+ if isinstance(obj, np.dtype):
+ return {"__npdtype__": obj.str}
+ if isinstance(obj, type):
+ return {"__type__": f"{obj.__module__}:{obj.__qualname__}"}
+ if isinstance(obj, slice):
+ return {"__slice__": [obj.start, obj.stop, obj.step]}
+ if isinstance(obj, tuple):
+ return {"__tuple__": [_encode(v) for v in obj]}
+ if isinstance(obj, list):
+ return [_encode(v) for v in obj]
+ if isinstance(obj, BaseEstimator):
+ return {"__estimator__": {"class": _qualname(obj), "state": _encode_mapping(vars(obj))}}
+ if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
+ fields = {f.name: _encode(getattr(obj, f.name)) for f in dataclasses.fields(obj)}
+ return {"__dataclass__": {"class": _qualname(obj), "fields": fields}}
+ if isinstance(obj, dict):
+ return {"__dict__": [[_encode(k), _encode(v)] for k, v in obj.items()]}
+ raise PretabSerializationError(f"Cannot serialize value of type {type(obj).__module__}.{type(obj).__name__!r}.")
+
+
+def _encode_mapping(mapping: dict) -> dict:
+ """Encode a ``str``-keyed attribute mapping (an estimator/object ``__dict__``)."""
+ return {str(k): _encode(v) for k, v in mapping.items()}
+
+
+def _json_safe(obj):
+ """Best-effort readable encoding used for the declarative ``params`` block.
+
+ Keeps JSON-native values verbatim and falls back to the tagged :func:`_encode`
+ form only for exotic values. The result is informational and never decoded.
+ """
+ if obj is None or isinstance(obj, (bool, int, float, str)):
+ return obj
+ if isinstance(obj, dict) and all(isinstance(k, str) for k in obj):
+ return {k: _json_safe(v) for k, v in obj.items()}
+ if isinstance(obj, (list, tuple)):
+ return [_json_safe(v) for v in obj]
+ return _encode(obj)
+
+
+# --- decoding ------------------------------------------------------------
+def _decode_ndarray(payload: dict) -> np.ndarray:
+ dtype = np.dtype(payload["dtype"])
+ arr = np.array(payload["data"], dtype=dtype)
+ return arr.reshape(payload["shape"])
+
+
+def _decode(obj):
+ if obj is None or isinstance(obj, (bool, int, float, str)):
+ return obj
+ if isinstance(obj, list):
+ return [_decode(v) for v in obj]
+ if isinstance(obj, dict):
+ if "__ndarray__" in obj:
+ return _decode_ndarray(obj["__ndarray__"])
+ if "__unset__" in obj:
+ return UNSET
+ if "__npdtype__" in obj:
+ return np.dtype(obj["__npdtype__"])
+ if "__type__" in obj:
+ return _resolve(obj["__type__"])
+ if "__slice__" in obj:
+ start, stop, step = obj["__slice__"]
+ return slice(start, stop, step)
+ if "__tuple__" in obj:
+ return tuple(_decode(v) for v in obj["__tuple__"])
+ if "__dict__" in obj:
+ return {_decode(k): _decode(v) for k, v in obj["__dict__"]}
+ if "__estimator__" in obj:
+ return _decode_estimator(obj["__estimator__"])
+ if "__dataclass__" in obj:
+ return _decode_dataclass(obj["__dataclass__"])
+ raise PretabSerializationError(f"Unrecognized encoded object with keys {sorted(obj)}.")
+ raise PretabSerializationError(f"Cannot decode value of type {type(obj).__name__!r}.")
+
+
+def _decode_mapping(mapping: dict) -> dict:
+ return {k: _decode(v) for k, v in mapping.items()}
+
+
+def _decode_estimator(payload: dict):
+ cls = cast(Any, _resolve(payload["class"]))
+ obj = cls.__new__(cls)
+ obj.__dict__.update(_decode_mapping(payload["state"]))
+ return obj
+
+
+def _decode_dataclass(payload: dict):
+ cls = cast(Any, _resolve(payload["class"]))
+ fields = {k: _decode(v) for k, v in payload["fields"].items()}
+ return cls(**fields)
+
+
+# --- envelope ------------------------------------------------------------
+def _library_versions() -> dict:
+ import scipy
+ import sklearn
+
+ return {
+ "numpy": np.__version__,
+ "scipy": scipy.__version__,
+ "scikit_learn": sklearn.__version__,
+ }
+
+
+def _representation_summary(preprocessor) -> list:
+ """Best-effort declarative per-representation summary (family/columns/locations)."""
+ summary: list = []
+ column_transformer = getattr(preprocessor, "column_transformer_", None)
+ if column_transformer is None:
+ return summary
+ for name, transformer, columns in column_transformer.transformers_:
+ if name == "remainder":
+ continue
+ leaf = transformer.steps[-1][1] if hasattr(transformer, "steps") else transformer
+ spec_fn = getattr(leaf, "get_representation_spec", None)
+ if spec_fn is None:
+ continue
+ try:
+ entry = spec_fn().to_dict()
+ except (PretabError, ValueError, AttributeError, TypeError, KeyError):
+ continue
+ entry["columns"] = [str(col) for col in columns]
+ summary.append(entry)
+ return summary
+
+
+def preprocessor_to_spec(preprocessor) -> dict:
+ """Serialize a fitted preprocessor into a portable, versioned spec dictionary."""
+ check_is_fitted(preprocessor)
+ return {
+ "schema_version": SCHEMA_VERSION,
+ "pretab_version": _PRETAB_VERSION,
+ "library_versions": _library_versions(),
+ "params": _json_safe(preprocessor.get_params(deep=False)),
+ "feature_names_out": [str(name) for name in preprocessor.get_feature_names_out()],
+ "representations": _representation_summary(preprocessor),
+ "state": _encode_mapping(vars(preprocessor)),
+ }
+
+
+def check_spec_schema(data: dict) -> None:
+ """Validate the envelope's schema version before reconstruction."""
+ if not isinstance(data, dict) or "schema_version" not in data:
+ raise PretabSerializationError("Not a PreTab spec: missing 'schema_version'.")
+ version = data["schema_version"]
+ if version != SCHEMA_VERSION:
+ raise PretabSerializationError(
+ f"Unsupported spec schema_version {version!r}; this build of PreTab supports {SCHEMA_VERSION}."
+ )
+
+
+def preprocessor_from_spec(data: dict):
+ """Reconstruct a fitted preprocessor from a spec produced by :func:`preprocessor_to_spec`."""
+ check_spec_schema(data)
+ from ..preprocessor import Preprocessor
+
+ obj = Preprocessor.__new__(Preprocessor)
+ obj.__dict__.update(_decode_mapping(data["state"]))
+ return obj
diff --git a/pretab/core/__init__.py b/pretab/core/__init__.py
index 685b0a5..b337f22 100644
--- a/pretab/core/__init__.py
+++ b/pretab/core/__init__.py
@@ -5,9 +5,7 @@
user-facing transformers. It never defines user-facing transformers itself.
"""
-from .adaptive import AdaptiveResolutionMixin
-from .base import BasePreTabTransformer
-from .exceptions import (
+from ..exceptions import (
ConfigWarning,
DataWarning,
EmptyDataError,
@@ -23,6 +21,8 @@
insufficient_samples_error,
invalid_param_error,
)
+from .adaptive import AdaptiveResolutionMixin
+from .base import BasePreTabTransformer
from .knots import (
basis_to_knots,
generate_internal_knots,
@@ -33,7 +33,7 @@
)
from .locations import resolve_locations, trim_to_count
from .logging import get_logger
-from .params import CANONICAL_PARAMS, UNSET, AliasResolverMixin, is_set
+from .parameters import CANONICAL_PARAMS, UNSET, AliasResolverMixin, is_set
from .selectors import (
BaseLocationSelector,
CARTLocationSelector,
diff --git a/pretab/core/_typing.py b/pretab/core/_typing.py
new file mode 100644
index 0000000..5572462
--- /dev/null
+++ b/pretab/core/_typing.py
@@ -0,0 +1,49 @@
+"""Shared type aliases for PreTab's public and internal signatures.
+
+Centralizing these keeps transformer, placement and compose signatures consistent
+and gives a single place to evolve the accepted input/target types.
+"""
+
+from __future__ import annotations
+
+from typing import Any, Literal, Protocol
+
+import numpy as np
+import pandas as pd
+
+# Accepted feature-matrix inputs.
+ArrayLike = np.ndarray | pd.DataFrame | pd.Series | list
+
+# Accepted supervision targets (``None`` for unsupervised transforms).
+TargetLike = np.ndarray | pd.Series | list | None
+
+# Canonical placement-strategy vocabulary (see :mod:`pretab.core.parameters`).
+PlacementStrategyName = Literal["uniform", "quantile", "cart", "lightgbm"]
+
+# Supervised-task discriminator used by the supervised placement selectors.
+Task = Literal["regression", "classification"]
+
+
+class TransformerLike(Protocol):
+ """Minimal duck-typed transformer interface used by internal wrappers."""
+
+ def fit(self, X: Any, y: Any = ...) -> Any: ...
+ def transform(self, X: Any) -> Any: ...
+ def get_feature_names_out(self, input_features: Any = ...) -> Any: ...
+
+
+class PredictorLike(Protocol):
+ """Minimal duck-typed supervised-estimator interface (fit + predict)."""
+
+ def fit(self, X: Any, y: Any = ...) -> Any: ...
+ def predict(self, X: Any) -> Any: ...
+
+
+__all__ = [
+ "ArrayLike",
+ "PlacementStrategyName",
+ "PredictorLike",
+ "TargetLike",
+ "Task",
+ "TransformerLike",
+]
diff --git a/pretab/core/adaptive.py b/pretab/core/adaptive.py
index 3015715..39f08f6 100644
--- a/pretab/core/adaptive.py
+++ b/pretab/core/adaptive.py
@@ -14,7 +14,7 @@
family-specific floor (and optional ceiling) on the count.
"""
-from .exceptions import IncompatibleParamsError, InvalidParamError
+from ..exceptions import IncompatibleParamsError, InvalidParamError
__all__ = ["AdaptiveResolutionMixin"]
@@ -94,12 +94,10 @@ def _resolve_output_bounds(
)
if ceil is not None and hi > ceil:
raise InvalidParamError(
- f"max_output_dim should be <= {ceil}, got {hi}.\n"
- f"Fix: lower max_output_dim to at most {ceil}."
+ f"max_output_dim should be <= {ceil}, got {hi}.\nFix: lower max_output_dim to at most {ceil}."
)
if lo > hi:
raise IncompatibleParamsError(
- "min_output_dim must be <= max_output_dim "
- f"(got min_output_dim={lo}, max_output_dim={hi})."
+ f"min_output_dim must be <= max_output_dim (got min_output_dim={lo}, max_output_dim={hi})."
)
return lo, hi
diff --git a/pretab/core/base.py b/pretab/core/base.py
index dbeab6b..58d39d5 100644
--- a/pretab/core/base.py
+++ b/pretab/core/base.py
@@ -11,13 +11,17 @@
from sklearn.utils.validation import check_is_fitted
from .adaptive import AdaptiveResolutionMixin
-from .params import AliasResolverMixin
+from .parameters import AliasResolverMixin
+from .policy import RepresentationPolicy, apply_constant_policy
+from .representation import RepresentationSpecMixin
from .validation import validate_2d_allow_nan
__all__ = ["BasePreTabTransformer"]
-class BasePreTabTransformer(AdaptiveResolutionMixin, AliasResolverMixin, TransformerMixin, BaseEstimator):
+class BasePreTabTransformer(
+ RepresentationSpecMixin, AdaptiveResolutionMixin, AliasResolverMixin, TransformerMixin, BaseEstimator
+):
"""Base class carrying the shared scikit-learn contract for PreTab transformers.
Subclasses set ``_allow_nan`` / ``_requires_y`` / ``_feature_suffix_value`` as
@@ -25,17 +29,42 @@ class BasePreTabTransformer(AdaptiveResolutionMixin, AliasResolverMixin, Transfo
input feature contributes) to get automatic ``get_feature_names_out`` support.
Transformers whose output is not a simple per-feature concatenation can
override ``get_feature_names_out`` directly.
+
+ Edge-case behaviour is governed by a shared :class:`RepresentationPolicy`
+ (``_policy``). A family narrows individual axes through the ``_constant_policy``
+ / ``_out_of_range_policy`` / ``_duplicate_policy`` class attributes (``None``
+ means "inherit the shared policy"); :meth:`_resolved_policy` merges them.
"""
_allow_nan: bool = True
_requires_y: bool = False
_feature_suffix_value: str = "f"
+ #: Shared, central edge-case policy (decision D9). Its defaults reproduce the
+ #: library's historical behaviour, so it is inert until narrowed.
+ _policy: RepresentationPolicy = RepresentationPolicy()
+
+ #: Per-family policy overrides; ``None`` inherits the corresponding ``_policy``
+ #: axis. ``_duplicate_policy`` records how repeated knots/centers are handled.
+ _constant_policy: str | None = None
+ _out_of_range_policy: str | None = None
+ _duplicate_policy: str = "dedupe"
+
n_features_in_: int
+ def _resolved_policy(self) -> RepresentationPolicy:
+ """Return the shared policy narrowed by this family's override attributes."""
+ return self._policy.merge(
+ constant=self._constant_policy,
+ out_of_range=self._out_of_range_policy,
+ )
+
def _validate(self, X, *, reset: bool):
"""Validate ``X`` through the shared NaN-aware validator."""
- return validate_2d_allow_nan(X, allow_nan=self._allow_nan, reset=reset, estimator=self)
+ X = validate_2d_allow_nan(X, allow_nan=self._allow_nan, reset=reset, estimator=self)
+ if reset:
+ apply_constant_policy(X, self._resolved_policy(), estimator=self)
+ return X
def _feature_suffix(self) -> str:
"""Suffix used when generating output feature names."""
diff --git a/pretab/core/dependencies.py b/pretab/core/dependencies.py
new file mode 100644
index 0000000..234ecd3
--- /dev/null
+++ b/pretab/core/dependencies.py
@@ -0,0 +1,53 @@
+"""Helpers for importing optional third-party dependencies.
+
+Each helper performs the lazy import and, on failure, raises a consistent,
+actionable :class:`~pretab.exceptions.OptionalDependencyError` that names the
+missing package and the extra that installs it. Centralizing this avoids the
+duplicated ``try/except ImportError`` blocks that previously lived inside the
+supervised selectors and the language-embedding transformer.
+"""
+
+from __future__ import annotations
+
+import importlib
+from types import ModuleType
+
+from ..exceptions import OptionalDependencyError
+
+__all__ = [
+ "require_lightgbm",
+ "require_module",
+ "require_sentence_transformers",
+]
+
+
+def require_module(module_name: str, extra: str, purpose: str) -> ModuleType:
+ """Import ``module_name`` or raise :class:`OptionalDependencyError`.
+
+ Parameters
+ ----------
+ module_name : str
+ The importable module name (e.g. ``"lightgbm"``).
+ extra : str
+ The PreTab optional extra that installs it (e.g. ``"lightgbm"``), used to
+ build the ``pip install pretab[]`` hint.
+ purpose : str
+ Human-readable description of what needs the dependency, used to prefix
+ the error message (e.g. ``"LightGBM placement"``).
+ """
+ try:
+ return importlib.import_module(module_name)
+ except ImportError as exc:
+ raise OptionalDependencyError(
+ f"{purpose} requires the optional '{module_name}' dependency. Install it with: pip install pretab[{extra}]"
+ ) from exc
+
+
+def require_lightgbm(purpose: str = "This feature") -> ModuleType:
+ """Import and return ``lightgbm`` or raise a clear optional-dependency error."""
+ return require_module("lightgbm", "lightgbm", purpose)
+
+
+def require_sentence_transformers(purpose: str = "This feature") -> ModuleType:
+ """Import and return ``sentence_transformers`` or raise a clear error."""
+ return require_module("sentence_transformers", "embeddings", purpose)
diff --git a/pretab/core/knots.py b/pretab/core/knots.py
index 233f7bf..0d4254e 100644
--- a/pretab/core/knots.py
+++ b/pretab/core/knots.py
@@ -14,7 +14,7 @@
import numpy as np
-from .exceptions import invalid_param_error
+from ..exceptions import invalid_param_error
__all__ = [
"basis_to_knots",
@@ -72,14 +72,15 @@ def spanning_knots(x: np.ndarray, n_knots: int, strategy: str = "uniform") -> np
if strategy == "quantile":
return np.quantile(x, np.linspace(0, 1, n_knots))
raise invalid_param_error(
- "spanning_knots", "strategy", strategy,
- "must be 'uniform' or 'quantile'", valid={"quantile", "uniform"},
+ "spanning_knots",
+ "strategy",
+ strategy,
+ "must be 'uniform' or 'quantile'",
+ valid={"quantile", "uniform"},
)
-def generate_internal_knots(
- x: np.ndarray, n_knots: int, strategy: str = "quantile"
-) -> np.ndarray:
+def generate_internal_knots(x: np.ndarray, n_knots: int, strategy: str = "quantile") -> np.ndarray:
"""Generate internal knots for one feature using ``strategy``.
Parameters
@@ -96,8 +97,11 @@ def generate_internal_knots(
if strategy == "quantile":
return quantile_knots(x, n_knots)
raise invalid_param_error(
- "generate_internal_knots", "strategy", strategy,
- "must be 'uniform' or 'quantile'", valid={"quantile", "uniform"},
+ "generate_internal_knots",
+ "strategy",
+ strategy,
+ "must be 'uniform' or 'quantile'",
+ valid={"quantile", "uniform"},
)
diff --git a/pretab/core/locations.py b/pretab/core/locations.py
index df22e23..e331950 100644
--- a/pretab/core/locations.py
+++ b/pretab/core/locations.py
@@ -24,9 +24,7 @@
__all__ = ["resolve_locations", "trim_to_count"]
-def trim_to_count(
- locations: np.ndarray, count: int, importance: np.ndarray | None = None
-) -> np.ndarray:
+def trim_to_count(locations: np.ndarray, count: int, importance: np.ndarray | None = None) -> np.ndarray:
"""Reduce ``locations`` to at most ``count`` entries.
When ``importance`` is ``None`` the array is down-sampled by even spacing
diff --git a/pretab/core/logging.py b/pretab/core/logging.py
index 31404c1..358d46f 100644
--- a/pretab/core/logging.py
+++ b/pretab/core/logging.py
@@ -8,7 +8,7 @@
import logging
-from .exceptions import PretabWarning # re-exported for convenience
+from ..exceptions import PretabWarning # re-exported for convenience
__all__ = [
"PretabWarning",
@@ -36,9 +36,7 @@ def get_logger(name: str = "pretab") -> logging.Logger:
def _has_real_handler(logger: logging.Logger) -> bool:
"""Whether ``logger`` already carries a handler other than ``NullHandler``."""
- return any(
- not isinstance(handler, logging.NullHandler) for handler in logger.handlers
- )
+ return any(not isinstance(handler, logging.NullHandler) for handler in logger.handlers)
def set_verbosity(level: int = 1) -> None:
@@ -77,4 +75,3 @@ def configure_logging(level: int = 1, handler: "logging.Handler | None" = None)
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("%(name)s: %(message)s"))
_LOGGER.addHandler(handler)
-
diff --git a/pretab/core/params.py b/pretab/core/parameters.py
similarity index 90%
rename from pretab/core/params.py
rename to pretab/core/parameters.py
index 6ede2b6..8d7e6df 100644
--- a/pretab/core/params.py
+++ b/pretab/core/parameters.py
@@ -24,7 +24,7 @@
import warnings
from typing import Any, ClassVar
-from .exceptions import InvalidParamError
+from ..exceptions import InvalidParamError
class _Unset:
@@ -71,16 +71,12 @@ def validate_placement(target_aware: bool, placement_strategy: str) -> None:
When ``target_aware`` is True the strategy must name a target-aware selector
(``"cart"`` or ``"lightgbm"``); when False it must name an unsupervised
spacing rule (``"uniform"`` or ``"quantile"``). Raises
- :class:`~pretab.core.exceptions.InvalidParamError` (a ``ValueError``) otherwise.
+ :class:`~pretab.exceptions.InvalidParamError` (a ``ValueError``) otherwise.
"""
if target_aware and placement_strategy not in TARGET_AWARE_STRATEGIES:
- raise InvalidParamError(
- "When target_aware=True, placement_strategy must be 'cart' or 'lightgbm'."
- )
+ raise InvalidParamError("When target_aware=True, placement_strategy must be 'cart' or 'lightgbm'.")
if not target_aware and placement_strategy not in UNSUPERVISED_STRATEGIES:
- raise InvalidParamError(
- "When target_aware=False, placement_strategy must be 'uniform' or 'quantile'."
- )
+ raise InvalidParamError("When target_aware=False, placement_strategy must be 'uniform' or 'quantile'.")
# §8.3 canonical vocabulary: the family-neutral name for each shared concept.
@@ -111,7 +107,7 @@ class AliasResolverMixin:
effective value: an explicit canonical value wins, an explicit legacy alias
is honoured with a ``FutureWarning``, and setting both a canonical and one
of its aliases -- or two conflicting aliases -- raises
- :class:`~pretab.core.exceptions.InvalidParamError`.
+ :class:`~pretab.exceptions.InvalidParamError`.
"""
_param_aliases: ClassVar[dict[str, str]] = {}
@@ -143,9 +139,7 @@ def _resolve_param(self, canonical: str, default=UNSET) -> Any:
if is_set(canon_val):
if set_aliases:
names = ", ".join(repr(alias) for alias, _ in set_aliases)
- raise InvalidParamError(
- f"Set {canonical!r} or its legacy alias(es) {names}, not both."
- )
+ raise InvalidParamError(f"Set {canonical!r} or its legacy alias(es) {names}, not both.")
return canon_val
if not set_aliases:
@@ -159,8 +153,7 @@ def _resolve_param(self, canonical: str, default=UNSET) -> Any:
alias, value = set_aliases[0]
warnings.warn(
- f"{alias!r} is deprecated and will be removed in a future release; "
- f"use {canonical!r} instead.",
+ f"{alias!r} is deprecated and will be removed in a future release; use {canonical!r} instead.",
FutureWarning,
stacklevel=3,
)
diff --git a/pretab/core/policy.py b/pretab/core/policy.py
new file mode 100644
index 0000000..c915361
--- /dev/null
+++ b/pretab/core/policy.py
@@ -0,0 +1,165 @@
+"""Central, explicit edge-case policy for representations (decision D9).
+
+:class:`RepresentationPolicy` names, in one place, how every transformer reacts
+to the recurring edge cases that would otherwise diverge silently per family:
+
+* ``missing`` -- how ``NaN`` inputs are treated (``"error"`` / ``"propagate"``).
+* ``constant`` -- zero-variance input columns (``"error"`` / ``"warn"`` / ``"allow"``).
+* ``out_of_range`` -- values at ``transform`` outside the fitted range
+ (``"error"`` / ``"warn"`` / ``"clip"`` / ``"extrapolate"``).
+* ``invalid`` -- non-finite ``inf`` / ``-inf`` inputs (``"error"`` / ``"propagate"``).
+
+The defaults reproduce the library's historical behaviour (constant columns pass
+through, ranges extrapolate, non-finite values raise), so enabling the policy
+object changes nothing until a stricter choice is requested. Transformers may
+narrow specific axes through class-level override attributes without exposing a
+new constructor parameter (see :class:`~pretab.core.base.BasePreTabTransformer`).
+"""
+
+from __future__ import annotations
+
+import warnings
+from dataclasses import asdict, dataclass, fields, replace
+
+import numpy as np
+
+from ..exceptions import DataWarning, PretabDataError, invalid_param_error
+
+__all__ = [
+ "RepresentationPolicy",
+ "apply_constant_policy",
+ "find_constant_columns",
+ "resolve_out_of_range",
+]
+
+_MISSING_CHOICES = ("error", "propagate")
+_CONSTANT_CHOICES = ("error", "warn", "allow")
+_OUT_OF_RANGE_CHOICES = ("error", "warn", "clip", "extrapolate")
+_INVALID_CHOICES = ("error", "propagate")
+
+_CHOICES = {
+ "missing": _MISSING_CHOICES,
+ "constant": _CONSTANT_CHOICES,
+ "out_of_range": _OUT_OF_RANGE_CHOICES,
+ "invalid": _INVALID_CHOICES,
+}
+
+
+@dataclass(frozen=True)
+class RepresentationPolicy:
+ """Declarative edge-case policy shared across transformers.
+
+ Parameters
+ ----------
+ missing : {"error", "propagate"}, default="propagate"
+ ``"propagate"`` lets ``NaN`` pass through to a downstream imputer;
+ ``"error"`` raises on any missing value.
+ constant : {"error", "warn", "allow"}, default="allow"
+ Reaction to a zero-variance (constant) input column.
+ out_of_range : {"error", "warn", "clip", "extrapolate"}, default="extrapolate"
+ Reaction to ``transform``-time values outside the fitted range.
+ invalid : {"error", "propagate"}, default="error"
+ Reaction to non-finite (``inf`` / ``-inf``) input values.
+ """
+
+ missing: str = "propagate"
+ constant: str = "allow"
+ out_of_range: str = "extrapolate"
+ invalid: str = "error"
+
+ def __post_init__(self):
+ for name, choices in _CHOICES.items():
+ value = getattr(self, name)
+ if value not in choices:
+ raise invalid_param_error("RepresentationPolicy", name, value, f"one of {choices}", valid=choices)
+
+ @classmethod
+ def resolve(cls, policy) -> RepresentationPolicy:
+ """Coerce ``None`` / a mapping / an instance into a ``RepresentationPolicy``."""
+ if policy is None:
+ return cls()
+ if isinstance(policy, cls):
+ return policy
+ if isinstance(policy, dict):
+ return cls(**policy)
+ raise invalid_param_error(
+ "RepresentationPolicy",
+ "policy",
+ policy,
+ "None, a dict, or a RepresentationPolicy instance",
+ )
+
+ def merge(self, **overrides) -> RepresentationPolicy:
+ """Return a copy with the non-``None`` ``overrides`` applied."""
+ valid = {f.name for f in fields(self)}
+ applied = {}
+ for key, value in overrides.items():
+ if value is None:
+ continue
+ if key not in valid:
+ raise invalid_param_error(
+ "RepresentationPolicy.merge", "override", key, f"one of {sorted(valid)}", valid=valid
+ )
+ applied[key] = value
+ return replace(self, **applied)
+
+ def to_dict(self) -> dict:
+ """Return a JSON-serializable dictionary of the policy fields."""
+ return asdict(self)
+
+
+def find_constant_columns(X) -> list[int]:
+ """Return the indices of zero-variance columns in ``X`` (ignoring ``NaN``)."""
+ X = np.asarray(X, dtype=np.float64)
+ constant = []
+ for j in range(X.shape[1]):
+ col = X[:, j]
+ finite = col[np.isfinite(col)]
+ if finite.size and float(np.ptp(finite)) == 0.0:
+ constant.append(j)
+ return constant
+
+
+def apply_constant_policy(X, policy: RepresentationPolicy, *, estimator) -> None:
+ """Enforce ``policy.constant`` against the constant columns of ``X``.
+
+ ``"allow"`` is a no-op; ``"warn"`` emits a :class:`~pretab.exceptions.DataWarning`;
+ ``"error"`` raises :class:`~pretab.exceptions.PretabDataError`.
+ """
+ if policy.constant == "allow":
+ return
+ constant = find_constant_columns(X)
+ if not constant:
+ return
+ name = type(estimator).__name__
+ message = f"{name} received constant (zero-variance) column(s) at index {constant}."
+ if policy.constant == "error":
+ raise PretabDataError(message)
+ warnings.warn(message, DataWarning, stacklevel=2)
+
+
+def resolve_out_of_range(X, lower, upper, policy: RepresentationPolicy, *, estimator):
+ """Apply ``policy.out_of_range`` to ``X`` given the fitted ``[lower, upper]`` bounds.
+
+ ``lower`` / ``upper`` are per-column arrays. ``"extrapolate"`` returns ``X``
+ unchanged, ``"clip"`` clamps into range, ``"warn"`` / ``"error"`` react when
+ any value lies outside the fitted bounds.
+ """
+ X = np.asarray(X, dtype=np.float64)
+ if policy.out_of_range == "extrapolate":
+ return X
+ lower = np.asarray(lower, dtype=np.float64).ravel()
+ upper = np.asarray(upper, dtype=np.float64).ravel()
+ with np.errstate(invalid="ignore"):
+ below = X < lower
+ above = X > upper
+ if not (below.any() or above.any()):
+ return X
+ if policy.out_of_range == "clip":
+ return np.clip(X, lower, upper)
+ name = type(estimator).__name__
+ message = f"{name} received values outside the fitted range at transform time."
+ if policy.out_of_range == "error":
+ raise PretabDataError(message)
+ warnings.warn(message, DataWarning, stacklevel=2)
+ return X
diff --git a/pretab/core/representation.py b/pretab/core/representation.py
new file mode 100644
index 0000000..0c2676b
--- /dev/null
+++ b/pretab/core/representation.py
@@ -0,0 +1,338 @@
+"""Typed representation metadata and feature-lineage records.
+
+``RepresentationSpec`` is the machine-readable description of the basis /
+encoding a fitted transformer produces (family, scope, supervision, knot or
+center locations, ...). ``RepresentationSpecMixin`` gives every PreTab
+transformer a default ``get_representation_spec`` implementation driven by a
+handful of class-attribute hooks, so concrete transformers usually only declare
+their family and a couple of flags. ``FeatureLineage`` records the per-output
+column provenance assembled by the preprocessor.
+"""
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
+
+import numpy as np
+from sklearn.utils.validation import check_is_fitted
+
+__all__ = [
+ "FeatureLineage",
+ "RepresentationSpec",
+ "RepresentationSpecMixin",
+]
+
+
+@dataclass(frozen=True)
+class RepresentationSpec:
+ """Typed description of the representation a transformer produces.
+
+ Attributes
+ ----------
+ family : str
+ Representation family identifier, e.g. ``"bspline"``, ``"rbf"``,
+ ``"piecewise_linear"``.
+ component_kind : str
+ Nature of a single output column, e.g. ``"basis"``, ``"center"``,
+ ``"frequency"``, ``"interval"``, ``"category"``, ``"raw"``.
+ scope : str
+ ``"univariate"`` when each input feature is expanded independently or
+ ``"multivariate"`` for interaction / joint bases.
+ supervision : str
+ ``"unsupervised"``, ``"supervised"``, or ``"optional"`` (target used
+ only when ``target_aware`` is enabled).
+ uses_target : bool
+ Whether the fitted transformer actually consumed ``y``.
+ is_interaction : bool
+ Whether output columns mix multiple input features.
+ input_features : tuple of str
+ Names of the input features consumed.
+ output_features : tuple of str
+ Names of the produced output columns (matches
+ ``get_feature_names_out``).
+ output_dim : int
+ Number of output columns (``len(output_features)``).
+ degree : int or None
+ Polynomial / spline degree when applicable.
+ include_bias : bool
+ Whether an explicit intercept / bias column is included.
+ periodic : bool
+ Whether the representation encodes a periodic signal.
+ period : float or None
+ Period length when ``periodic`` is True.
+ local_support : bool
+ Whether individual basis functions have compact (local) support.
+ location_kind : str or None
+ Semantic label for ``locations`` (``"knots"``, ``"centers"``,
+ ``"bin_edges"``, ``"thresholds"``, ``"frequencies"``, ``"landmarks"``).
+ locations : tuple of tuple of float or None
+ Fitted knot / center / threshold locations, one inner tuple per input
+ feature (or per landmark row for multivariate bases).
+ dtype : str
+ Output dtype of the transformed array.
+ cross_fitted : bool
+ Whether the representation was produced with out-of-fold cross-fitting
+ (see :class:`~pretab.core.supervised.CrossFittedTransformer`).
+ n_folds : int or None
+ Number of cross-fitting folds when ``cross_fitted`` is True.
+ """
+
+ family: str
+ component_kind: str
+ scope: str
+ supervision: str
+ uses_target: bool
+ is_interaction: bool
+ input_features: tuple[str, ...]
+ output_features: tuple[str, ...]
+ output_dim: int
+ degree: int | None
+ include_bias: bool
+ periodic: bool
+ period: float | None
+ local_support: bool
+ location_kind: str | None
+ locations: tuple[tuple[float, ...], ...] | None
+ dtype: str = "float64"
+ cross_fitted: bool = False
+ n_folds: int | None = None
+
+ def to_dict(self) -> dict:
+ """Return a JSON-serializable dictionary representation."""
+ return {
+ "family": self.family,
+ "component_kind": self.component_kind,
+ "scope": self.scope,
+ "supervision": self.supervision,
+ "uses_target": self.uses_target,
+ "is_interaction": self.is_interaction,
+ "input_features": list(self.input_features),
+ "output_features": list(self.output_features),
+ "output_dim": self.output_dim,
+ "degree": self.degree,
+ "include_bias": self.include_bias,
+ "periodic": self.periodic,
+ "period": self.period,
+ "local_support": self.local_support,
+ "location_kind": self.location_kind,
+ "locations": (None if self.locations is None else [list(group) for group in self.locations]),
+ "dtype": self.dtype,
+ "cross_fitted": self.cross_fitted,
+ "n_folds": self.n_folds,
+ }
+
+ @classmethod
+ def from_dict(cls, data: dict) -> "RepresentationSpec":
+ """Reconstruct a ``RepresentationSpec`` from :meth:`to_dict` output."""
+ locations = data.get("locations")
+ n_folds = data.get("n_folds")
+ return cls(
+ family=data["family"],
+ component_kind=data["component_kind"],
+ scope=data["scope"],
+ supervision=data["supervision"],
+ uses_target=bool(data["uses_target"]),
+ is_interaction=bool(data["is_interaction"]),
+ input_features=tuple(data["input_features"]),
+ output_features=tuple(data["output_features"]),
+ output_dim=int(data["output_dim"]),
+ degree=None if data["degree"] is None else int(data["degree"]),
+ include_bias=bool(data["include_bias"]),
+ periodic=bool(data["periodic"]),
+ period=None if data["period"] is None else float(data["period"]),
+ local_support=bool(data["local_support"]),
+ location_kind=data["location_kind"],
+ locations=(None if locations is None else tuple(tuple(float(v) for v in group) for group in locations)),
+ dtype=data.get("dtype", "float64"),
+ cross_fitted=bool(data.get("cross_fitted", False)),
+ n_folds=None if n_folds is None else int(n_folds),
+ )
+
+
+@dataclass(frozen=True)
+class FeatureLineage:
+ """Provenance record for a single output column of a fitted preprocessor.
+
+ Attributes
+ ----------
+ output_feature : str
+ Name of the produced output column (matches
+ ``Preprocessor.get_feature_names_out``).
+ output_index : int
+ Position of the column in the transformed array.
+ source_features : tuple of str
+ Input column(s) this output column is derived from.
+ family : str
+ Representation family that produced the column.
+ component : str
+ Component kind of the column (``"basis"``, ``"center"``, ...).
+ component_index : int
+ Index of the column within its representation block.
+ uses_target : bool
+ Whether the producing transformer consumed ``y``.
+ is_interaction : bool
+ Whether the column mixes multiple input features.
+ """
+
+ output_feature: str
+ output_index: int
+ source_features: tuple[str, ...]
+ family: str
+ component: str
+ component_index: int
+ uses_target: bool
+ is_interaction: bool
+
+ def to_dict(self) -> dict:
+ """Return a JSON-serializable dictionary representation."""
+ return {
+ "output_feature": self.output_feature,
+ "output_index": self.output_index,
+ "source_features": list(self.source_features),
+ "family": self.family,
+ "component": self.component,
+ "component_index": self.component_index,
+ "uses_target": self.uses_target,
+ "is_interaction": self.is_interaction,
+ }
+
+
+def _as_location_tuple(value) -> tuple[tuple[float, ...], ...]:
+ """Normalise fitted location arrays into a tuple of float tuples.
+
+ Handles both list-of-1D-array layouts (one array per input feature) and 2D
+ arrays (one row per landmark) by iterating the outer axis.
+ """
+ return tuple(tuple(float(v) for v in np.asarray(group).ravel()) for group in value)
+
+
+class RepresentationSpecMixin:
+ """Provide a default ``get_representation_spec`` from class-attribute hooks.
+
+ Concrete transformers declare their family and a few flags via the
+ ``_representation_*`` class attributes; fitted knot / center / threshold
+ locations are auto-detected from the first matching fitted attribute.
+ Transformers with bespoke needs override the ``_representation_*`` helper
+ methods (or ``get_representation_spec`` itself).
+ """
+
+ if TYPE_CHECKING:
+ # Attributes provided by the concrete estimator this mixin is combined with.
+ n_features_in_: int
+
+ def get_feature_names_out(self, input_features: Any = ...) -> Any: ...
+
+ _representation_family: str = "unknown"
+ _representation_component_kind: str = "basis"
+ _representation_scope: str = "univariate"
+ _representation_supervision: str = "unsupervised"
+ _representation_local_support: bool = False
+
+ _REPRESENTATION_LOCATION_ATTRS: tuple[tuple[str, str], ...] = (
+ ("knots_", "knots"),
+ ("centers_", "centers"),
+ ("bin_edges_", "bin_edges"),
+ ("thresholds_", "thresholds"),
+ ("frequencies_", "frequencies"),
+ ("landmarks_", "landmarks"),
+ )
+
+ @property
+ def requires_y(self) -> bool:
+ """Whether this transformer mandates ``y`` at fit time.
+
+ ``True`` for inherently supervised representations (e.g. PLE); ``False``
+ for unsupervised and optionally target-aware families.
+ """
+ return self._representation_supervision == "supervised"
+
+ @property
+ def is_supervised(self) -> bool:
+ """Whether this transformer consumes ``y`` given its configuration.
+
+ ``True`` when the target is mandatory (:attr:`requires_y`) or when an
+ optionally target-aware family has ``target_aware=True``.
+ """
+ return self.requires_y or bool(getattr(self, "target_aware", False))
+
+ @property
+ def uses_target_(self) -> bool:
+ """Fitted flag: whether the last ``fit`` consumed the target ``y``.
+
+ Available only after ``fit``. Because target-aware placement requires
+ ``y`` at fit time (a supervised fit without ``y`` raises), a fitted
+ supervised transformer always reports ``True``.
+ """
+ check_is_fitted(self, "n_features_in_")
+ return self.is_supervised
+
+ def _representation_uses_target(self) -> bool:
+ """Return whether the fitted transformer consumed the target."""
+ return self.is_supervised
+
+ def _representation_cross_fitting(self) -> tuple[bool, int | None]:
+ """Return ``(cross_fitted, n_folds)`` for the representation."""
+ return False, None
+
+ def _representation_degree(self) -> int | None:
+ """Return the polynomial / spline degree, if the transformer has one."""
+ degree = getattr(self, "degree", None)
+ return None if degree is None else int(degree)
+
+ def _representation_periodic(self) -> tuple[bool, float | None]:
+ """Return ``(periodic, period)`` for the representation."""
+ return False, None
+
+ def _representation_locations(self) -> tuple[str | None, tuple[tuple[float, ...], ...] | None]:
+ """Auto-detect fitted location arrays from known fitted attributes."""
+ for attr, kind in self._REPRESENTATION_LOCATION_ATTRS:
+ value = getattr(self, attr, None)
+ if value is None:
+ continue
+ return kind, _as_location_tuple(value)
+ return None, None
+
+ def get_representation_spec(self, input_features=None) -> RepresentationSpec:
+ """Return the typed :class:`RepresentationSpec` for this fitted transformer.
+
+ Parameters
+ ----------
+ input_features : list of str or None
+ Names of the input features. When ``None``, names of the form
+ ``x0, x1, ...`` are generated.
+
+ Returns
+ -------
+ RepresentationSpec
+ The representation metadata describing the produced columns.
+ """
+ check_is_fitted(self, "n_features_in_")
+ if input_features is None:
+ inputs = tuple(f"x{i}" for i in range(self.n_features_in_))
+ else:
+ inputs = tuple(str(feature) for feature in input_features)
+ output_features = tuple(str(name) for name in self.get_feature_names_out(list(inputs)))
+ location_kind, locations = self._representation_locations()
+ periodic, period = self._representation_periodic()
+ cross_fitted, n_folds = self._representation_cross_fitting()
+ scope = self._representation_scope
+ return RepresentationSpec(
+ family=self._representation_family,
+ component_kind=self._representation_component_kind,
+ scope=scope,
+ supervision=self._representation_supervision,
+ uses_target=self._representation_uses_target(),
+ is_interaction=scope == "multivariate",
+ input_features=inputs,
+ output_features=output_features,
+ output_dim=len(output_features),
+ degree=self._representation_degree(),
+ include_bias=bool(getattr(self, "include_bias", False)),
+ periodic=periodic,
+ period=period,
+ local_support=self._representation_local_support,
+ location_kind=location_kind,
+ locations=locations,
+ dtype="float64",
+ cross_fitted=cross_fitted,
+ n_folds=n_folds,
+ )
diff --git a/pretab/core/selectors.py b/pretab/core/selectors.py
index bf172e4..a92cd93 100644
--- a/pretab/core/selectors.py
+++ b/pretab/core/selectors.py
@@ -12,7 +12,7 @@
- :class:`CARTLocationSelector` fits a single decision tree and needs only
scikit-learn, so it is always available.
- :class:`LightGBMLocationSelector` fits a gradient boosted ensemble and requires
- the optional ``lightgbm`` dependency (``pip install pretab[knots]``).
+ the optional ``lightgbm`` dependency (``pip install pretab[lightgbm]``).
Both share the :class:`BaseLocationSelector` template, which handles input
validation, small-sample quantile fallbacks, minimum spacing, and topping up or
@@ -25,7 +25,7 @@
import numpy as np
from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor
-from .exceptions import IncompatibleParamsError, OptionalDependencyError
+from ..exceptions import IncompatibleParamsError, OptionalDependencyError
from .knots import quantile_knots
Task = Literal["regression", "classification"]
@@ -81,9 +81,7 @@ def select(
Sorted array of selected locations.
"""
if y is None:
- raise IncompatibleParamsError(
- f"{type(self).__name__} requires y to select locations."
- )
+ raise IncompatibleParamsError(f"{type(self).__name__} requires y to select locations.")
task = task or "regression"
x = np.asarray(x)
@@ -115,9 +113,7 @@ def select(
return np.array(sorted(locations))
@abstractmethod
- def _ordered_candidates(
- self, x_valid: np.ndarray, y_valid: np.ndarray, task: Task
- ) -> tuple[list[float], object]:
+ def _ordered_candidates(self, x_valid: np.ndarray, y_valid: np.ndarray, task: Task) -> tuple[list[float], object]:
"""Fit a model and return candidate locations plus trimming context.
The candidates must be returned in the selector's preferred order (the
@@ -195,9 +191,7 @@ def __init__(
self.random_state = random_state
self.min_samples_floor = min_samples_split
- def _ordered_candidates(
- self, x_valid: np.ndarray, y_valid: np.ndarray, task: Task
- ) -> tuple[list[float], object]:
+ def _ordered_candidates(self, x_valid: np.ndarray, y_valid: np.ndarray, task: Task) -> tuple[list[float], object]:
if task == "regression":
tree = DecisionTreeRegressor(
max_depth=self.max_tree_depth,
@@ -280,7 +274,7 @@ class LightGBMLocationSelector(BaseLocationSelector):
tends to find informative locations that a single tree can miss.
Requires the optional ``lightgbm`` dependency, installable with
- ``pip install pretab[knots]``.
+ ``pip install pretab[lightgbm]``.
Parameters
----------
@@ -319,17 +313,15 @@ def __init__(
@staticmethod
def _import_lightgbm():
try:
- import lightgbm as lgb
+ import lightgbm as lgb # type: ignore[import-untyped]
except ImportError as exc:
raise OptionalDependencyError(
"LightGBMLocationSelector requires the optional 'lightgbm' dependency. "
- "Install it with: pip install pretab[knots]"
+ "Install it with: pip install pretab[lightgbm]"
) from exc
return lgb
- def _ordered_candidates(
- self, x_valid: np.ndarray, y_valid: np.ndarray, task: Task
- ) -> tuple[list[float], object]:
+ def _ordered_candidates(self, x_valid: np.ndarray, y_valid: np.ndarray, task: Task) -> tuple[list[float], object]:
lgb = self._import_lightgbm()
params = {
diff --git a/pretab/core/supervised.py b/pretab/core/supervised.py
new file mode 100644
index 0000000..71f043e
--- /dev/null
+++ b/pretab/core/supervised.py
@@ -0,0 +1,210 @@
+"""Leakage-safe supervised contract: warning helper and cross-fitting wrapper.
+
+Supervised (target-aware) representations place their basis using ``y``. Fitting
+such a transformer on the full training data and then transforming that same
+data leaks target information into the features. This module provides:
+
+* :func:`warn_target_leakage` -- emits a :class:`~pretab.exceptions.LeakageWarning`
+ when a supervised transformer is fit on ``(X, y)`` outside a controlled
+ (Pipeline / cross-validation / cross-fitting) context.
+* :class:`CrossFittedTransformer` -- wraps a supervised transformer and produces
+ out-of-fold features during ``fit_transform`` so the training representation
+ carries no target leakage, while ``transform`` uses a model fit on all data.
+"""
+
+import contextvars
+import sys
+import warnings
+from dataclasses import replace
+from typing import cast
+
+import numpy as np
+from sklearn.base import BaseEstimator, TransformerMixin, clone
+from sklearn.model_selection import KFold, StratifiedKFold
+from sklearn.utils.validation import check_is_fitted
+
+from ..exceptions import (
+ IncompatibleParamsError,
+ InvalidParamError,
+ LeakageWarning,
+ PretabDataError,
+)
+from ._typing import TransformerLike
+from .representation import RepresentationSpecMixin
+
+__all__ = ["CrossFittedTransformer", "in_controlled_context", "warn_target_leakage"]
+
+# Module prefixes whose presence on the call stack marks a controlled context in
+# which fitting a supervised transformer on ``(X, y)`` is expected and safe.
+_CONTROLLED_MODULE_PREFIXES = (
+ "sklearn.pipeline",
+ "sklearn.model_selection",
+ "sklearn.compose",
+ "pretab.preprocessor",
+ "pretab.compose",
+)
+
+# Set while :class:`CrossFittedTransformer` fits its internal clones, so their
+# fits never emit a leakage warning.
+_cross_fit_active: contextvars.ContextVar[bool] = contextvars.ContextVar("pretab_cross_fit_active", default=False)
+
+
+def in_controlled_context() -> bool:
+ """Return True when a Pipeline / CV / cross-fitting context is on the stack."""
+ if _cross_fit_active.get():
+ return True
+ frame = sys._getframe(1)
+ while frame is not None:
+ module = frame.f_globals.get("__name__", "")
+ if module.startswith(_CONTROLLED_MODULE_PREFIXES):
+ return True
+ frame = frame.f_back
+ return False
+
+
+def warn_target_leakage(estimator, y) -> None:
+ """Warn if a supervised ``estimator`` is fit on ``y`` outside a safe context.
+
+ No warning is emitted when ``y`` is ``None``, when the estimator does not
+ consume the target (``is_supervised`` is False), or when a controlled
+ context (Pipeline, cross-validation, or :class:`CrossFittedTransformer`) is
+ detected on the call stack.
+ """
+ if y is None:
+ return
+ if not getattr(estimator, "is_supervised", False):
+ return
+ if in_controlled_context():
+ return
+ warnings.warn(
+ f"{type(estimator).__name__} is target-aware and was fit on (X, y) outside a "
+ "Pipeline / cross-validation context, which can leak target information into "
+ "the features. Fit it inside a scikit-learn Pipeline, wrap it in "
+ "pretab.CrossFittedTransformer, or ignore this warning if the fitted data "
+ "will not be reused to train a downstream model.",
+ LeakageWarning,
+ stacklevel=3,
+ )
+
+
+class CrossFittedTransformer(RepresentationSpecMixin, TransformerMixin, BaseEstimator):
+ """Cross-fit a supervised transformer to remove target leakage on training data.
+
+ During :meth:`fit_transform`, the wrapped transformer is fit on each
+ training fold and used to transform the held-out fold, so every training row
+ is encoded by a model that never saw its own target. :meth:`transform`
+ (for unseen data) uses ``estimator_``, a single transformer fit on all data.
+
+ Parameters
+ ----------
+ transformer : estimator
+ A supervised (target-aware) PreTab transformer to cross-fit.
+ n_folds : int, default=5
+ Number of cross-fitting folds. Must be at least 2.
+ task : {"regression", "classification"}, default="regression"
+ Controls the splitter: ``KFold`` for regression, ``StratifiedKFold`` for
+ classification.
+ shuffle : bool, default=True
+ Whether to shuffle before splitting.
+ random_state : int or None, default=None
+ Seed used when ``shuffle`` is True.
+
+ Attributes
+ ----------
+ estimator_ : estimator
+ The transformer fit on all of ``(X, y)``, used by :meth:`transform`.
+ n_features_in_ : int
+ Number of input features seen during ``fit``.
+ """
+
+ _representation_supervision = "supervised"
+
+ def __init__(self, transformer, n_folds=5, task="regression", shuffle=True, random_state=None):
+ self.transformer = transformer
+ self.n_folds = n_folds
+ self.task = task
+ self.shuffle = shuffle
+ self.random_state = random_state
+
+ def _make_splitter(self):
+ """Return the cross-fitting splitter for the configured task."""
+ seed = self.random_state if self.shuffle else None
+ if self.task == "classification":
+ return StratifiedKFold(n_splits=self.n_folds, shuffle=self.shuffle, random_state=seed)
+ return KFold(n_splits=self.n_folds, shuffle=self.shuffle, random_state=seed)
+
+ def _fit_full(self, X, y):
+ """Validate inputs and fit ``estimator_`` on all data; return arrays."""
+ if y is None:
+ raise IncompatibleParamsError("CrossFittedTransformer requires y at fit time; got y=None.")
+ if not isinstance(self.n_folds, (int, np.integer)) or self.n_folds < 2:
+ raise InvalidParamError(f"n_folds must be an integer >= 2; got {self.n_folds!r}.")
+ X_arr = np.asarray(X)
+ if X_arr.ndim == 1:
+ X_arr = X_arr.reshape(-1, 1)
+ y_arr = np.asarray(y).ravel()
+ if len(X_arr) != len(y_arr):
+ raise PretabDataError(f"X and y must have same length. Got {len(X_arr)} and {len(y_arr)}")
+ estimator = cast(TransformerLike, clone(self.transformer))
+ token = _cross_fit_active.set(True)
+ try:
+ estimator.fit(X_arr, y_arr)
+ finally:
+ _cross_fit_active.reset(token)
+ self.estimator_ = estimator
+ self.n_features_in_ = X_arr.shape[1]
+ return X_arr, y_arr
+
+ def fit(self, X, y=None):
+ """Fit the all-data ``estimator_`` used by :meth:`transform`."""
+ self._fit_full(X, y)
+ return self
+
+ def transform(self, X):
+ """Transform ``X`` using the transformer fit on all training data."""
+ check_is_fitted(self, "estimator_")
+ X_arr = np.asarray(X)
+ if X_arr.ndim == 1:
+ X_arr = X_arr.reshape(-1, 1)
+ return self.estimator_.transform(X_arr)
+
+ def fit_transform(self, X, y=None):
+ """Fit and return leakage-free out-of-fold features for the training data."""
+ X_arr, y_arr = self._fit_full(X, y)
+ width = len(self.estimator_.get_feature_names_out())
+ out = np.empty((X_arr.shape[0], width), dtype=float)
+ splitter = self._make_splitter()
+ token = _cross_fit_active.set(True)
+ try:
+ for train_idx, test_idx in splitter.split(X_arr, y_arr):
+ fold = cast(TransformerLike, clone(self.transformer))
+ fold.fit(X_arr[train_idx], y_arr[train_idx])
+ fold_out = np.asarray(fold.transform(X_arr[test_idx]))
+ if fold_out.shape[1] != width:
+ raise IncompatibleParamsError(
+ "Cross-fitting requires a fixed output width across folds; expected "
+ f"{width}, got {fold_out.shape[1]}. Disable adaptive sizing on the "
+ "wrapped transformer."
+ )
+ out[test_idx] = fold_out
+ finally:
+ _cross_fit_active.reset(token)
+ return out
+
+ def get_feature_names_out(self, input_features=None):
+ """Delegate output feature names to the all-data ``estimator_``."""
+ check_is_fitted(self, "estimator_")
+ return self.estimator_.get_feature_names_out(input_features)
+
+ def get_representation_spec(self, input_features=None):
+ """Return the wrapped spec, flagged as cross-fitted."""
+ check_is_fitted(self, "estimator_")
+ spec_fn = getattr(self.estimator_, "get_representation_spec", None)
+ if spec_fn is not None:
+ base = spec_fn(input_features)
+ return replace(base, uses_target=True, cross_fitted=True, n_folds=int(self.n_folds))
+ return super().get_representation_spec(input_features)
+
+ def _representation_cross_fitting(self):
+ """Report cross-fitting metadata for the spec fallback path."""
+ return True, int(self.n_folds)
diff --git a/pretab/core/validation.py b/pretab/core/validation.py
index 693716d..fbe7ea7 100644
--- a/pretab/core/validation.py
+++ b/pretab/core/validation.py
@@ -12,7 +12,7 @@
import numpy as np
from sklearn.utils.validation import check_array
-from .exceptions import DataWarning, PretabDataError
+from ..exceptions import DataWarning, PretabDataError
__all__ = ["validate_2d_allow_nan"]
@@ -44,7 +44,10 @@ def validate_2d_allow_nan(X, *, allow_nan: bool = True, reset: bool, estimator):
original_dim = input_shape[1] if input_shape is not None and len(input_shape) == 2 else None
ensure_all_finite: Literal["allow-nan"] | bool = "allow-nan" if allow_nan else True
X = check_array(
- X, dtype=np.float64, ensure_2d=True, ensure_all_finite=ensure_all_finite # type: ignore
+ X,
+ dtype=np.float64, # type: ignore
+ ensure_2d=True,
+ ensure_all_finite=ensure_all_finite, # type: ignore
)
if original_dim is not None and X.shape[1] < original_dim:
warnings.warn(
diff --git a/pretab/core/exceptions.py b/pretab/exceptions.py
similarity index 69%
rename from pretab/core/exceptions.py
rename to pretab/exceptions.py
index 0873d19..3e2d49f 100644
--- a/pretab/core/exceptions.py
+++ b/pretab/exceptions.py
@@ -17,15 +17,20 @@
"ConfigWarning",
"DataWarning",
"EmptyDataError",
+ "FrozenRepresentationError",
"IncompatibleParamsError",
"InsufficientSamplesError",
"InvalidParamError",
+ "LeakageWarning",
"OptionalDependencyError",
+ "OutputBudgetError",
"PretabConfigError",
"PretabDataError",
"PretabError",
"PretabNotFittedError",
+ "PretabSerializationError",
"PretabWarning",
+ "RepresentationConformanceError",
"insufficient_samples_error",
"invalid_param_error",
]
@@ -44,6 +49,11 @@ class ConfigWarning(PretabWarning):
"""Configuration fallback or deprecation notice."""
+class LeakageWarning(PretabWarning):
+ """Potential target leakage: a supervised transformer fit outside a
+ cross-fitting / Pipeline / cross-validation context."""
+
+
# --- Error hierarchy ---
class PretabError(Exception):
"""Base class for every error raised by PreTab."""
@@ -81,6 +91,29 @@ class OptionalDependencyError(PretabError, ImportError):
"""A required optional dependency is not installed."""
+class OutputBudgetError(PretabError, ValueError):
+ """The fitted preprocessor would exceed a configured output budget
+ (``max_output_features`` / ``max_features_per_input`` / ``max_dense_memory``)."""
+
+
+class PretabSerializationError(PretabError, ValueError):
+ """A representation spec could not be serialized or reconstructed
+ (unsupported value, unknown class, or incompatible ``schema_version``)."""
+
+
+class FrozenRepresentationError(PretabError):
+ """A mutating operation (e.g. ``set_params``) was attempted on a frozen
+ preprocessor. Call :meth:`~pretab.preprocessor.Preprocessor.clone_unfitted`
+ to obtain a fresh, mutable copy."""
+
+
+class RepresentationConformanceError(PretabError, AssertionError):
+ """A representation class failed a :func:`pretab.check_representation` check.
+
+ Inherits ``AssertionError`` so a failed conformance check also surfaces
+ naturally when :func:`~pretab.check_representation` is called from a test."""
+
+
# --- Message factories ---
def invalid_param_error(estimator, param, value, constraint, valid=None):
"""Build an :class:`InvalidParamError` with a consistent, actionable message.
@@ -106,6 +139,4 @@ def invalid_param_error(estimator, param, value, constraint, valid=None):
def insufficient_samples_error(n_rows, min_required, reason):
"""Build an :class:`InsufficientSamplesError` with a consistent message."""
- return InsufficientSamplesError(
- f"Got {n_rows} row(s) but at least {min_required} are required for {reason}."
- )
+ return InsufficientSamplesError(f"Got {n_rows} row(s) but at least {min_required} are required for {reason}.")
diff --git a/pretab/extension.py b/pretab/extension.py
new file mode 100644
index 0000000..e8107ed
--- /dev/null
+++ b/pretab/extension.py
@@ -0,0 +1,443 @@
+"""Public extensibility surface for PreTab representations.
+
+This module is the supported way third parties add, register, discover, and
+validate their own representations so they behave like the built-ins:
+
+- :class:`BaseRepresentation` -- the public base class to subclass. It inherits
+ the shared scikit-learn contract (NaN-aware validation, estimator tags,
+ ``get_feature_names_out``, and a typed :class:`~pretab.RepresentationSpec`) and
+ exposes a small declarative surface (``representation_name`` / ``feature_kind``
+ / ``scope`` / ``supervision``).
+- :func:`register_representation` -- add a class to the capability registry under
+ a name so it is selectable via ``Preprocessor(numerical_method=)``.
+- :func:`load_entry_point_representations` -- register representations advertised
+ by installed packages through the ``pretab.representations`` entry-point group.
+- :func:`list_representations` -- query the registry by capability.
+- :func:`check_representation` -- a conformance suite that verifies a class obeys
+ the representation contract.
+"""
+
+from __future__ import annotations
+
+import warnings
+
+import numpy as np
+from sklearn.base import clone
+from sklearn.exceptions import NotFittedError
+
+from .compose.registry import (
+ CATEGORICAL,
+ NUMERICAL,
+ TRANSFORMER_REGISTRY,
+ TransformerSpec,
+ register_spec,
+)
+from .core.base import BasePreTabTransformer
+from .core.representation import RepresentationSpec
+from .exceptions import ConfigWarning, RepresentationConformanceError
+
+__all__ = [
+ "BaseRepresentation",
+ "check_representation",
+ "list_representations",
+ "load_entry_point_representations",
+ "register_representation",
+]
+
+#: Entry-point group installed packages use to advertise representations.
+ENTRY_POINT_GROUP = "pretab.representations"
+
+_SUPERVISION_TO_TARGET_USAGE = {
+ "unsupervised": "forbidden",
+ "optional": "optional",
+ "supervised": "required",
+}
+_VALID_FEATURE_KINDS = frozenset({NUMERICAL, CATEGORICAL})
+_VALID_SCOPES = frozenset({"univariate", "multivariate"})
+_VALID_SUPERVISION = frozenset(_SUPERVISION_TO_TARGET_USAGE)
+
+
+class BaseRepresentation(BasePreTabTransformer):
+ """Public base class for third-party PreTab representations.
+
+ Subclass this to add a custom representation that behaves like a built-in: it
+ inherits NaN-aware validation, the estimator tags, ``get_feature_names_out``,
+ and a typed :class:`~pretab.RepresentationSpec`. Implement ``fit`` /
+ ``transform`` and either ``_output_sizes`` (the number of output columns each
+ input feature contributes) or ``get_feature_names_out`` directly; then call
+ :func:`register_representation` to make it selectable by name.
+
+ Class attributes
+ ----------------
+ representation_name : str or None
+ Canonical registry name -- the value passed as ``numerical_method=`` /
+ ``categorical_method=``. Must be set before registration.
+ feature_kind : {"numerical", "categorical"}
+ The column kind the representation applies to.
+ scope : {"univariate", "multivariate"}
+ Whether each input feature is expanded independently or several columns
+ are modelled jointly.
+ supervision : {"unsupervised", "optional", "supervised"}
+ How the representation uses the target ``y``. ``"supervised"`` mandates
+ ``y`` at fit time; ``"optional"`` consumes it only when ``target_aware``
+ is enabled.
+ """
+
+ representation_name: str | None = None
+ feature_kind: str = NUMERICAL
+ scope: str = "univariate"
+ supervision: str = "unsupervised"
+
+ def __init_subclass__(cls, **kwargs):
+ super().__init_subclass__(**kwargs)
+ if cls.feature_kind not in _VALID_FEATURE_KINDS:
+ raise ValueError(
+ f"{cls.__name__}.feature_kind must be one of {sorted(_VALID_FEATURE_KINDS)}, got {cls.feature_kind!r}"
+ )
+ if cls.scope not in _VALID_SCOPES:
+ raise ValueError(f"{cls.__name__}.scope must be one of {sorted(_VALID_SCOPES)}, got {cls.scope!r}")
+ if cls.supervision not in _VALID_SUPERVISION:
+ raise ValueError(
+ f"{cls.__name__}.supervision must be one of {sorted(_VALID_SUPERVISION)}, got {cls.supervision!r}"
+ )
+ # Sync the public contract onto the internal representation hooks so the
+ # inherited RepresentationSpec and estimator tags reflect the declared
+ # metadata without the subclass having to set the private attributes.
+ if cls.representation_name is not None:
+ cls._representation_family = cls.representation_name
+ cls._representation_scope = cls.scope
+ cls._representation_supervision = cls.supervision
+ cls._requires_y = cls.supervision == "supervised"
+
+
+def register_representation(
+ name,
+ cls,
+ *,
+ feature_kind=None,
+ scope=None,
+ supervision=None,
+ allowed_args=(),
+ placement_strategies=(),
+ supports_adaptive_resolution=False,
+ preprocessor_compatible=True,
+ optional_dependency=None,
+ periodic=False,
+ sparse_output=False,
+ override=False,
+):
+ """Register a representation class under ``name`` so it is selectable by name.
+
+ The capability metadata (``feature_kind`` / ``scope`` / ``supervision``) is
+ inferred from the class when it subclasses :class:`BaseRepresentation` and can
+ be overridden through the keyword arguments. After registration the method is
+ usable as ``Preprocessor(numerical_method=name)`` (or ``categorical_method``)
+ and appears in :func:`list_representations`.
+
+ Parameters
+ ----------
+ name : str
+ Canonical method name to register under.
+ cls : type
+ The scikit-learn-compatible transformer class.
+ feature_kind : {"numerical", "categorical"}, optional
+ Column kind the method applies to. Inferred from ``cls`` when omitted.
+ scope : {"univariate", "multivariate"}, optional
+ Inferred from ``cls`` when omitted.
+ supervision : {"unsupervised", "optional", "supervised"}, optional
+ Inferred from ``cls`` when omitted.
+ allowed_args : iterable of str, default=()
+ Constructor argument names the shared Preprocessor keyword arguments are
+ filtered down to for this method.
+ placement_strategies : iterable of str, default=()
+ Placement strategies the method honours (empty for methods without
+ data-driven placement).
+ supports_adaptive_resolution : bool, default=False
+ Whether the method can size its output dimension from the data.
+ preprocessor_compatible : bool, default=True
+ Whether the method can be selected per column through ``Preprocessor``.
+ Set False for standalone / multivariate-only methods.
+ optional_dependency : str or None, default=None
+ Optional extra required for the method to run.
+ periodic : bool, default=False
+ Whether the representation encodes a periodic signal.
+ sparse_output : bool, default=False
+ Whether the method can emit a sparse matrix.
+ override : bool, default=False
+ Whether replacing an already-registered ``name`` is allowed.
+
+ Returns
+ -------
+ TransformerSpec
+ The registered capability record.
+ """
+ if not isinstance(name, str) or not name.strip():
+ raise ValueError("name must be a non-empty string")
+ if not isinstance(cls, type):
+ raise TypeError(f"cls must be a class, got {type(cls).__name__}")
+
+ feature_kind = feature_kind if feature_kind is not None else getattr(cls, "feature_kind", NUMERICAL)
+ scope = scope if scope is not None else getattr(cls, "scope", "univariate")
+ supervision = supervision if supervision is not None else getattr(cls, "supervision", "unsupervised")
+
+ if feature_kind not in _VALID_FEATURE_KINDS:
+ raise ValueError(f"feature_kind must be one of {sorted(_VALID_FEATURE_KINDS)}, got {feature_kind!r}")
+ if scope not in _VALID_SCOPES:
+ raise ValueError(f"scope must be one of {sorted(_VALID_SCOPES)}, got {scope!r}")
+ if supervision not in _VALID_SUPERVISION:
+ raise ValueError(f"supervision must be one of {sorted(_VALID_SUPERVISION)}, got {supervision!r}")
+
+ spec = TransformerSpec(
+ name=name,
+ transformer_cls=cls,
+ allowed_args=tuple(allowed_args),
+ feature_kind=frozenset({feature_kind}),
+ arity="multivariate" if scope == "multivariate" else "univariate",
+ target_usage=_SUPERVISION_TO_TARGET_USAGE[supervision],
+ placement_strategies=frozenset(placement_strategies),
+ supports_adaptive_resolution=bool(supports_adaptive_resolution),
+ preprocessor_compatible=bool(preprocessor_compatible),
+ optional_dependency=optional_dependency,
+ periodic=bool(periodic),
+ sparse_output=bool(sparse_output),
+ )
+ return register_spec(spec, override=override)
+
+
+def load_entry_point_representations(group=ENTRY_POINT_GROUP, *, override=False):
+ """Register representations advertised by installed packages.
+
+ Iterates the ``group`` entry points (default ``"pretab.representations"``);
+ each entry point is expected to load to a representation class. The class is
+ registered under its ``representation_name`` attribute (falling back to the
+ entry-point name). A broken plugin emits a :class:`ConfigWarning` and is
+ skipped rather than breaking discovery for the others.
+
+ This is opt-in (never called automatically at import) so importing ``pretab``
+ stays fast and side-effect free.
+
+ Returns
+ -------
+ list of str
+ The names successfully registered, sorted.
+ """
+ from importlib.metadata import entry_points
+
+ try:
+ eps = entry_points(group=group)
+ except TypeError: # pragma: no cover - Python < 3.10 selection fallback
+ eps = entry_points().get(group, [])
+
+ loaded = []
+ for ep in eps:
+ try:
+ obj = ep.load()
+ reg_name = getattr(obj, "representation_name", None) or ep.name
+ register_representation(reg_name, obj, override=override)
+ loaded.append(reg_name)
+ except Exception as exc:
+ warnings.warn(
+ f"skipping representation entry point {ep.name!r}: {exc}",
+ ConfigWarning,
+ stacklevel=2,
+ )
+ return sorted(loaded)
+
+
+def list_representations(
+ *,
+ feature_kind=None,
+ scope=None,
+ supervised=None,
+ periodic=None,
+ sparse_output=None,
+ adaptive=None,
+ include_optional=True,
+):
+ """Return the registered method names matching every supplied filter.
+
+ All filters are optional and combined with AND. ``None`` means "don't filter
+ on this capability".
+
+ Parameters
+ ----------
+ feature_kind : {"numerical", "categorical"}, optional
+ Keep methods that apply to this column kind.
+ scope : {"univariate", "multivariate"}, optional
+ Keep methods with this arity.
+ supervised : bool, optional
+ Keep methods that can (``True``) or cannot (``False``) consume ``y``.
+ periodic : bool, optional
+ Keep methods whose ``periodic`` flag matches.
+ sparse_output : bool, optional
+ Keep methods whose ``sparse_output`` flag matches.
+ adaptive : bool, optional
+ Keep methods whose adaptive-resolution support matches.
+ include_optional : bool, default=True
+ When False, drop methods that need an optional dependency.
+
+ Returns
+ -------
+ list of str
+ Matching canonical method names, sorted.
+ """
+ result = []
+ for spec_name, spec in TRANSFORMER_REGISTRY.items():
+ if feature_kind is not None and feature_kind not in spec.feature_kind:
+ continue
+ if scope is not None and spec.arity != scope:
+ continue
+ if supervised is not None and spec.is_supervised != bool(supervised):
+ continue
+ if periodic is not None and spec.periodic != bool(periodic):
+ continue
+ if sparse_output is not None and spec.sparse_output != bool(sparse_output):
+ continue
+ if adaptive is not None and spec.supports_adaptive_resolution != bool(adaptive):
+ continue
+ if not include_optional and spec.optional_dependency is not None:
+ continue
+ result.append(spec_name)
+ return sorted(result)
+
+
+def _densify(array):
+ """Return a dense 2D ndarray view of a (possibly sparse) transform output."""
+ if hasattr(array, "toarray"):
+ return array.toarray()
+ return np.asarray(array)
+
+
+def check_representation(cls, *, X=None, y=None):
+ """Run the representation conformance suite on a class.
+
+ Verifies the contract a well-behaved representation must obey: constructible
+ with defaults; ``transform`` before ``fit`` raises ``NotFittedError``; ``fit``
+ returns ``self`` and does not mutate its input; ``transform`` yields a 2D
+ array with one row per sample; ``get_feature_names_out`` matches the output
+ width and is unique; the result is deterministic across ``clone`` + refit; the
+ typed :class:`~pretab.RepresentationSpec` agrees with the declared ``scope``
+ and output width; and a ``"supervised"`` class refuses to fit without ``y``.
+
+ Parameters
+ ----------
+ cls : type
+ The representation class to validate.
+ X : array-like, optional
+ Sample input used for the checks. Defaults to a small numeric matrix.
+ y : array-like, optional
+ Sample target. Generated automatically for supervised classes when the
+ class declares ``supervision="supervised"``.
+
+ Returns
+ -------
+ list of str
+ The names of the checks that passed.
+
+ Raises
+ ------
+ RepresentationConformanceError
+ On the first failed check, with a message identifying the violation.
+ """
+ rng = np.random.RandomState(0)
+ if X is None:
+ X = rng.uniform(-2.0, 2.0, size=(40, 1)).astype(float)
+ X = np.asarray(X)
+ n_samples = X.shape[0]
+
+ supervision = getattr(cls, "supervision", "unsupervised")
+ needs_y = supervision == "supervised"
+ if needs_y and y is None:
+ y = rng.uniform(size=n_samples)
+
+ def _make():
+ try:
+ return cls()
+ except TypeError as exc:
+ raise RepresentationConformanceError(
+ f"{cls.__name__} must be constructible with no required arguments: {exc}"
+ ) from exc
+
+ def _fit(est):
+ return est.fit(X, y) if needs_y else est.fit(X)
+
+ passed = []
+
+ # 1. transform before fit must raise NotFittedError.
+ est = _make()
+ try:
+ est.transform(X)
+ except NotFittedError:
+ pass
+ except Exception as exc:
+ raise RepresentationConformanceError(
+ f"{cls.__name__}.transform before fit should raise NotFittedError, got {type(exc).__name__}"
+ ) from exc
+ else:
+ raise RepresentationConformanceError(f"{cls.__name__}.transform before fit should raise NotFittedError")
+ passed.append("unfitted_transform_raises")
+
+ # 2. fit returns self and does not mutate X.
+ est = _make()
+ X_before = X.copy()
+ fitted = _fit(est)
+ if fitted is not est:
+ raise RepresentationConformanceError(f"{cls.__name__}.fit must return self")
+ if not np.array_equal(X, X_before, equal_nan=True):
+ raise RepresentationConformanceError(f"{cls.__name__}.fit must not mutate its input X")
+ passed.append("fit_returns_self_no_mutation")
+
+ # 3. transform is a 2D array with one row per sample.
+ out = _densify(fitted.transform(X))
+ if out.ndim != 2 or out.shape[0] != n_samples:
+ raise RepresentationConformanceError(
+ f"{cls.__name__}.transform must return a 2D array with {n_samples} rows, "
+ f"got shape {getattr(out, 'shape', None)}"
+ )
+ width = out.shape[1]
+ passed.append("transform_shape")
+
+ # 4. feature names match the output width and are unique.
+ names = [str(name) for name in fitted.get_feature_names_out()]
+ if len(names) != width:
+ raise RepresentationConformanceError(
+ f"{cls.__name__}.get_feature_names_out length {len(names)} != output width {width}"
+ )
+ if len(set(names)) != len(names):
+ raise RepresentationConformanceError(f"{cls.__name__}.get_feature_names_out must be unique")
+ passed.append("feature_names_match")
+
+ # 5. deterministic across clone + refit.
+ clone_out = _densify(_fit(clone(fitted)).transform(X))
+ if clone_out.shape != out.shape or not np.allclose(clone_out, out, equal_nan=True):
+ raise RepresentationConformanceError(f"{cls.__name__} is not deterministic across clone + refit")
+ passed.append("deterministic")
+
+ # 6. typed representation spec agrees with the declared metadata.
+ spec = fitted.get_representation_spec()
+ if not isinstance(spec, RepresentationSpec):
+ raise RepresentationConformanceError(f"{cls.__name__}.get_representation_spec must return a RepresentationSpec")
+ declared_scope = getattr(cls, "scope", "univariate")
+ if spec.scope != declared_scope:
+ raise RepresentationConformanceError(
+ f"{cls.__name__} spec.scope {spec.scope!r} != declared scope {declared_scope!r}"
+ )
+ if spec.output_dim != width:
+ raise RepresentationConformanceError(
+ f"{cls.__name__} spec.output_dim {spec.output_dim} != output width {width}"
+ )
+ passed.append("spec_consistent")
+
+ # 7. a supervised class must refuse to fit without y.
+ if needs_y:
+ est = _make()
+ try:
+ est.fit(X)
+ except Exception:
+ passed.append("supervised_requires_y")
+ else:
+ raise RepresentationConformanceError(
+ f"{cls.__name__} declares supervision='supervised' but fit succeeded without y"
+ )
+
+ return passed
diff --git a/pretab/pipeline/__init__.py b/pretab/pipeline/__init__.py
deleted file mode 100644
index f340128..0000000
--- a/pretab/pipeline/__init__.py
+++ /dev/null
@@ -1,15 +0,0 @@
-"""Pipeline assembly layer: build scikit-learn transformer steps per strategy.
-
-``get_numerical_transformer_steps`` and ``get_categorical_transformer_steps``
-turn a strategy name plus keyword arguments into an ordered list of
-``(name, transformer)`` steps. The available numerical strategies are declared
-in :mod:`pretab.pipeline.registry`.
-"""
-
-from .categorical import get_categorical_transformer_steps
-from .numerical import get_numerical_transformer_steps
-
-__all__ = [
- "get_categorical_transformer_steps",
- "get_numerical_transformer_steps",
-]
diff --git a/pretab/pipeline/categorical.py b/pretab/pipeline/categorical.py
deleted file mode 100644
index a860be2..0000000
--- a/pretab/pipeline/categorical.py
+++ /dev/null
@@ -1,60 +0,0 @@
-from sklearn.impute import SimpleImputer
-from sklearn.preprocessing import OneHotEncoder
-
-from ..core.exceptions import invalid_param_error
-from ..core.params import UNSET
-from ..transformers.binning import CustomBinTransformer
-from ..transformers.embeddings import LanguageEmbeddingTransformer
-from ..transformers.encoders.continuous_ordinal import ContinuousOrdinalTransformer
-from ..transformers.encoders.floats import NoTransformer, ToFloatTransformer
-from ..transformers.onehot import OneHotFromOrdinalTransformer
-from .registry import CATEGORICAL_ALIASES, CATEGORICAL_METHODS, resolve_method
-
-
-def get_categorical_transformer_steps(
- method: str,
- add_imputer: bool = True,
- imputer_strategy: str = "most_frequent",
- imputer_kwargs: dict | None = None,
- output_dim=UNSET,
- **kwargs,
-):
- """
- Returns a list of (name, transformer) steps for a given categorical preprocessing method.
- """
- method = resolve_method(method, CATEGORICAL_METHODS, CATEGORICAL_ALIASES)
- steps = []
-
- if add_imputer:
- imputer_kwargs = imputer_kwargs or {}
- steps.append(
- ("imputer", SimpleImputer(strategy=imputer_strategy, **imputer_kwargs))
- )
-
- if method == "int":
- steps.append(("continuous_ordinal", ContinuousOrdinalTransformer()))
- elif method == "one-hot":
- # Default to ignoring unseen categories so transform never crashes on
- # categories absent at fit time; callers can override via kwargs.
- onehot_kwargs = {"handle_unknown": "ignore", **kwargs}
- steps.append(("onehot", OneHotEncoder(**onehot_kwargs)))
- steps.append(("to_float", ToFloatTransformer()))
- elif method == "pretrained":
- steps.append(("pretrained", LanguageEmbeddingTransformer()))
- elif method == "none":
- steps.append(("none", NoTransformer()))
- elif method == "custombin":
- bin_kwargs = dict(kwargs)
- if output_dim is not UNSET:
- bin_kwargs.setdefault("output_dim", output_dim)
- steps.append(("custombin", CustomBinTransformer(**bin_kwargs)))
- elif method == "onehot_from_ordinal":
- steps.append(("onehot_from_ordinal", OneHotFromOrdinalTransformer()))
- else:
- raise invalid_param_error(
- "get_categorical_transformer_steps", "method", method,
- "unrecognized categorical preprocessing method",
- valid=set(CATEGORICAL_METHODS),
- )
-
- return steps
diff --git a/pretab/pipeline/numerical.py b/pretab/pipeline/numerical.py
deleted file mode 100644
index 56bd7aa..0000000
--- a/pretab/pipeline/numerical.py
+++ /dev/null
@@ -1,172 +0,0 @@
-import warnings
-
-from sklearn.impute import SimpleImputer
-from sklearn.preprocessing import MinMaxScaler, StandardScaler
-
-from ..core.exceptions import ConfigWarning, invalid_param_error
-from .registry import NUMERICAL_ALIASES, NUMERICAL_METHODS, resolve_method
-
-# Spline basis expansions that share the target-aware knot API.
-SPLINE_EXPANSION_METHODS = ("bspline", "mspline", "ispline")
-
-# Legacy knot-based spline families that also support target-aware placement.
-# They use freely-placed knots (cubic / natural-cubic regression splines), so the
-# selector / task / strategy / adaptive knobs apply; their knot selector uses the
-# ``"bspline"`` spline_type. The penalized families (``pspline``, ``tensorspline``)
-# assume equally-spaced knots for their difference penalty, and the thin-plate
-# spline (``tprs``) is kernel-based (knot-free): none of those three are
-# target-aware, so they stay on the generic fixed construction path.
-LEGACY_SPLINE_METHODS = ("cubicspline", "naturalspline")
-
-# Every spline family for which target-aware (data-driven) knot placement is
-# meaningful. Exposed via :func:`supports_target_aware` so callers can query it.
-TARGET_AWARE_SPLINE_METHODS = SPLINE_EXPANSION_METHODS + LEGACY_SPLINE_METHODS
-
-
-def supports_target_aware(method: str) -> bool:
- """Return whether a spline ``method`` supports target-aware knot placement.
-
- Only freely-placed knot splines qualify: ``bspline``, ``mspline``,
- ``ispline``, ``cubicspline`` and ``naturalspline``. The penalized splines
- (``pspline``, ``tensorspline``) require equally-spaced knots for their
- difference penalty, and the kernel-based ``tprs`` has no knots, so those
- three always use fixed knot placement regardless of ``target_aware`` /
- ``placement_strategy`` / ``adaptive``.
- """
- resolved = resolve_method(method, NUMERICAL_METHODS, NUMERICAL_ALIASES)
- return resolved in TARGET_AWARE_SPLINE_METHODS
-
-# Valid range for the number of spline basis functions per feature.
-_MIN_SPLINE_BASIS = 5
-_MAX_SPLINE_BASIS = 50
-
-
-def filter_kwargs(transformer_cls, kwargs, allowed=None):
- if allowed is not None:
- return {k: kwargs[k] for k in allowed if k in kwargs}
- return kwargs
-
-
-# Method families grouped by which placement modes they support. The Preprocessor
-# shares a single ``target_aware`` / ``placement_strategy`` pair; each family only
-# receives the placement kwargs it can honor.
-BOTH_MODE_METHODS = frozenset({
- "rbf", "relu", "sigmoid", "tanh",
- "bspline", "mspline", "ispline", "cubicspline", "naturalspline",
-})
-# PLE is inherently target-aware: only the supervised selectors apply.
-TARGET_AWARE_ONLY_METHODS = frozenset({"ple"})
-# Penalized splines assume equally-spaced knots: only the spacing rules apply.
-UNSUPERVISED_ONLY_METHODS = frozenset({"pspline", "tensorspline"})
-
-
-def _placement_kwargs(method, kwargs):
- """Return the placement kwargs to inject for ``method``.
-
- Honors each family's applicability: both-mode families receive
- ``target_aware`` + ``placement_strategy``; PLE receives a supervised
- ``placement_strategy`` only when target-aware; the penalized splines receive
- an unsupervised ``placement_strategy`` only when not target-aware. Anything
- else receives nothing.
- """
- target_aware = bool(kwargs.get("target_aware", False))
- placement_strategy = kwargs.get("placement_strategy")
- if method in BOTH_MODE_METHODS:
- out = {"target_aware": target_aware}
- if placement_strategy is not None:
- out["placement_strategy"] = placement_strategy
- return out
- if method in TARGET_AWARE_ONLY_METHODS:
- if target_aware and placement_strategy in ("cart", "lightgbm"):
- return {"placement_strategy": placement_strategy}
- return {}
- if method in UNSUPERVISED_ONLY_METHODS:
- if not target_aware and placement_strategy in ("uniform", "quantile"):
- return {"placement_strategy": placement_strategy}
- return {}
- return {}
-
-
-def _clamp_spline_basis(output_dim):
- """Clamp a requested output dimension into the supported spline range.
-
- The Preprocessor shares a single ``output_dim`` setting across every
- numerical strategy (default 64), but the B/M/I spline transformers accept
- between ``5`` and ``50`` basis functions. Values outside that window are
- clamped so switching to a spline strategy keeps working with the shared
- default.
- """
- clamped = max(_MIN_SPLINE_BASIS, min(int(output_dim), _MAX_SPLINE_BASIS))
- if clamped != output_dim:
- warnings.warn(
- f"output_dim={output_dim} is outside the spline range "
- f"[{_MIN_SPLINE_BASIS}, {_MAX_SPLINE_BASIS}]; using {clamped} basis functions.",
- ConfigWarning,
- stacklevel=2,
- )
- return clamped
-
-
-def get_numerical_transformer_steps(
- method: str,
- add_imputer: bool = True,
- imputer_strategy: str = "mean",
- imputer_kwargs: dict | None = None,
- scaling: str | None = None,
- **kwargs,
-):
- method = resolve_method(method, NUMERICAL_METHODS, NUMERICAL_ALIASES)
- steps = []
-
- if add_imputer:
- imputer_kwargs = imputer_kwargs or {}
- steps.append(("imputer", SimpleImputer(strategy=imputer_strategy, **imputer_kwargs)))
-
- # Define scalers that could be added independently
- scalers = {
- "standardization": ("scaler", StandardScaler()),
- "minmax": ("minmax", MinMaxScaler(feature_range=(-1, 1))),
- }
-
- # Add optional scaling step only if not already part of method
- if scaling is not None:
- scaling = resolve_method(scaling, NUMERICAL_METHODS, NUMERICAL_ALIASES)
- if scaling in scalers and scaling != method:
- steps.append(scalers[scaling])
-
- if method not in NUMERICAL_METHODS:
- raise invalid_param_error(
- "get_numerical_transformer_steps", "method", method,
- "unrecognized numerical preprocessing method",
- valid=set(NUMERICAL_METHODS),
- )
-
- cls, allowed_args = NUMERICAL_METHODS[method]
- filtered = filter_kwargs(cls, kwargs, allowed=allowed_args)
- placement = _placement_kwargs(method, kwargs)
-
- if method == "box-cox":
- steps.append(("scale_positive", MinMaxScaler(feature_range=(1e-3, 1))))
- steps.append(("boxcox", cls(method="box-cox", **filtered)))
- elif method == "yeo-johnson":
- steps.append(("yeojohnson", cls(method="yeo-johnson", **filtered)))
- elif method in SPLINE_EXPANSION_METHODS or method in LEGACY_SPLINE_METHODS:
- spline_kwargs = dict(filtered)
- spline_kwargs.update(placement)
-
- # The B/M/I splines share the Preprocessor's default ``output_dim`` (which
- # can sit outside their [5, 50] basis range); the legacy families keep
- # their own wider bounds, so only clamp for B/M/I.
- if method in SPLINE_EXPANSION_METHODS:
- output_dim = kwargs.get("output_dim")
- if output_dim is not None:
- spline_kwargs["output_dim"] = _clamp_spline_basis(output_dim)
-
- steps.append((method, cls(**spline_kwargs)))
- else:
- name = method if method != "none" else "noop"
- call_kwargs = dict(filtered)
- call_kwargs.update(placement)
- steps.append((name, cls(**call_kwargs)))
-
- return steps
diff --git a/pretab/pipeline/registry.py b/pretab/pipeline/registry.py
deleted file mode 100644
index 7f2fc45..0000000
--- a/pretab/pipeline/registry.py
+++ /dev/null
@@ -1,226 +0,0 @@
-"""Declarative registry of numerical preprocessing strategies.
-
-Each entry maps a strategy name to a ``(transformer_cls, allowed_args)`` pair:
-the class to instantiate and the constructor arguments it accepts (used to
-filter the shared ``**kwargs``). Adding a new numerical strategy is therefore a
-single-line edit here rather than a change to the assembly logic.
-
-A few names (``box-cox`` / ``yeo-johnson`` share ``PowerTransformer``, and the
-B/M/I splines need extra knot wiring) require special handling in
-:mod:`pretab.pipeline.numerical`; this table still records the class and its
-allowed arguments for them.
-"""
-
-from sklearn.preprocessing import (
- MinMaxScaler,
- PolynomialFeatures,
- PowerTransformer,
- QuantileTransformer,
- RobustScaler,
- StandardScaler,
-)
-
-from ..transformers.binning.binning import CustomBinTransformer
-from ..transformers.encoders.floats import NoTransformer
-from ..transformers.feature_maps.rbf import RBFExpansionTransformer
-from ..transformers.feature_maps.relu import ReLUExpansionTransformer
-from ..transformers.feature_maps.sigmoid import SigmoidExpansionTransformer
-from ..transformers.feature_maps.tanh import TanhExpansionTransformer
-from ..transformers.ple.ple import PLETransformer
-from ..transformers.splines.bspline import BSplineTransformer
-from ..transformers.splines.cubic import CubicSplineTransformer
-from ..transformers.splines.integrated_spline import ISplineTransformer
-from ..transformers.splines.mspline import MSplineTransformer
-from ..transformers.splines.natural_cubic import NaturalCubicSplineTransformer
-from ..transformers.splines.pspline import PSplineTransformer
-from ..transformers.splines.tensor_product import TensorProductSplineTransformer
-from ..transformers.splines.thinplate_spline import ThinPlateSplineTransformer
-
-__all__ = [
- "CATEGORICAL_ALIASES",
- "CATEGORICAL_METHODS",
- "NUMERICAL_ALIASES",
- "NUMERICAL_METHODS",
- "resolve_method",
-]
-
-
-# name -> (transformer class, constructor arguments it accepts)
-NUMERICAL_METHODS = {
- "standardization": (StandardScaler, []),
- "minmax": (MinMaxScaler, []),
- "quantile": (
- QuantileTransformer,
- ["n_quantiles", "output_distribution", "random_state"],
- ),
- "polynomial": (
- PolynomialFeatures,
- ["degree", "interaction_only", "include_bias"],
- ),
- "robust": (RobustScaler, []),
- "box-cox": (PowerTransformer, []),
- "yeo-johnson": (PowerTransformer, []),
- "ple": (PLETransformer, ["output_dim", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state", "handle_missing"]),
- "custombin": (CustomBinTransformer, ["output_dim"]),
- "rbf": (
- RBFExpansionTransformer,
- [
- "output_dim",
- "gamma",
- "task",
- "adaptive",
- "min_output_dim",
- "max_output_dim",
- "random_state",
- ],
- ),
- "relu": (
- ReLUExpansionTransformer,
- [
- "output_dim",
- "task",
- "adaptive",
- "min_output_dim",
- "max_output_dim",
- "random_state",
- ],
- ),
- "sigmoid": (
- SigmoidExpansionTransformer,
- [
- "output_dim",
- "task",
- "adaptive",
- "min_output_dim",
- "max_output_dim",
- "random_state",
- ],
- ),
- "tanh": (
- TanhExpansionTransformer,
- [
- "output_dim",
- "scale",
- "task",
- "adaptive",
- "min_output_dim",
- "max_output_dim",
- "random_state",
- ],
- ),
- "cubicspline": (CubicSplineTransformer, ["output_dim", "degree", "include_bias", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state"]),
- "naturalspline": (NaturalCubicSplineTransformer, ["output_dim", "include_bias", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state"]),
- # pspline / tensorspline are penalized (difference-penalty) splines that rely
- # on equally-spaced knots, so they are *not* target-aware: no ``task`` here.
- "pspline": (PSplineTransformer, ["output_dim", "degree", "diff_order"]),
- "tensorspline": (
- TensorProductSplineTransformer,
- ["output_dim", "degree", "diff_order"],
- ),
- "tprs": (ThinPlateSplineTransformer, ["output_dim"]),
- "bspline": (BSplineTransformer, ["degree", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state"]),
- "mspline": (MSplineTransformer, ["degree", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state"]),
- "ispline": (ISplineTransformer, ["degree", "task", "adaptive", "min_output_dim", "max_output_dim", "random_state"]),
- "none": (NoTransformer, []),
-}
-
-
-# Canonical categorical method names (numerical ones are the NUMERICAL_METHODS
-# keys). Kept here so both pipeline sides resolve names through one module.
-CATEGORICAL_METHODS = frozenset(
- {"int", "one-hot", "onehot_from_ordinal", "pretrained", "custombin", "none"}
-)
-
-
-def _squash(name: str) -> str:
- """Collapse a method name for separator/case-insensitive comparison.
-
- Lowercases, trims surrounding whitespace, and drops the ``-``, ``_`` and
- space separators so ``"One-Hot"``, ``"one_hot"`` and ``"onehot"`` all map to
- the same key. Canonical names that only differ by a separator (``"box-cox"``
- vs ``"boxcox"``, ``"cubicspline"`` vs ``"cubic spline"``) therefore match
- without needing an explicit alias entry.
- """
- return name.strip().lower().replace("-", "").replace("_", "").replace(" ", "")
-
-
-# Genuine synonyms / abbreviations that are *not* just separator variants of a
-# canonical name (those are handled by :func:`_squash`). Keys are already
-# squashed; values are canonical numerical method names.
-NUMERICAL_ALIASES = {
- "standard": "standardization",
- "standardize": "standardization",
- "standardscaler": "standardization",
- "std": "standardization",
- "zscore": "standardization",
- "minmaxscaler": "minmax",
- "quantiletransformer": "quantile",
- "poly": "polynomial",
- "robustscaler": "robust",
- "piecewiselinear": "ple",
- "bin": "custombin",
- "binning": "custombin",
- "cubic": "cubicspline",
- "natural": "naturalspline",
- "naturalcubic": "naturalspline",
- "tensor": "tensorspline",
- "tensorproduct": "tensorspline",
- "tensorproductspline": "tensorspline",
- "thinplate": "tprs",
- "thinplatespline": "tprs",
- "passthrough": "none",
- "identity": "none",
- "raw": "none",
-}
-
-# Genuine synonyms / abbreviations for the categorical methods (keys squashed).
-CATEGORICAL_ALIASES = {
- "integer": "int",
- "ordinal": "int",
- "label": "int",
- "labelencoder": "int",
- "ordinalencoder": "int",
- "ohe": "one-hot",
- "dummy": "one-hot",
- "onehotencoder": "one-hot",
- "embedding": "pretrained",
- "embeddings": "pretrained",
- "language": "pretrained",
- "llm": "pretrained",
- "bin": "custombin",
- "binning": "custombin",
- "passthrough": "none",
- "identity": "none",
- "raw": "none",
-}
-
-
-def resolve_method(name, canonical, aliases):
- """Resolve a user-supplied method name to its canonical spelling.
-
- Matching is case-insensitive, ignores ``-`` / ``_`` / space separators, and
- honours the explicit ``aliases`` map of synonyms and abbreviations. An
- unrecognized name is returned lowercased and stripped so the caller's own
- "unrecognized method" error lists the canonical options.
-
- Parameters
- ----------
- name : str
- The method name the user supplied.
- canonical : set or dict
- The canonical method names (``NUMERICAL_METHODS`` keys or
- ``CATEGORICAL_METHODS``).
- aliases : dict
- Squashed-alias to canonical-name mapping for this side of the pipeline.
- """
- key = name.strip().lower()
- if key in canonical:
- return key
-
- squashed = _squash(name)
- for canon in canonical:
- if _squash(canon) == squashed:
- return canon
- if squashed in aliases:
- return aliases[squashed]
- return key
diff --git a/pretab/placement/__init__.py b/pretab/placement/__init__.py
new file mode 100644
index 0000000..3f5ae5d
--- /dev/null
+++ b/pretab/placement/__init__.py
@@ -0,0 +1,45 @@
+"""Placement subsystem: where basis units go and how many there are.
+
+A single home for location + resolution logic shared by splines, feature maps,
+PLE and periodic encoders. Splits the two concerns cleanly: *where* (the
+:class:`~pretab.placement.base.BasePlacementStrategy` families) and *how many*
+(the :class:`~pretab.placement.resolution.BaseResolutionPolicy` policies). The
+family adapters translate a strategy's generic locations into knots / thresholds
+/ centers, and :func:`~pretab.placement.factory.create_placement_strategy` builds
+a strategy from the public ``target_aware`` / ``placement_strategy`` vocabulary.
+"""
+
+from .adapters import (
+ PeriodicPlacementAdapter,
+ PLEPlacementAdapter,
+ RBFPlacementAdapter,
+ SplinePlacementAdapter,
+)
+from .base import BasePlacementStrategy, PlacementResult
+from .factory import create_placement_strategy
+from .resolution import (
+ BaseResolutionPolicy,
+ CardinalityAwareResolution,
+ DataSizeAwareResolution,
+ FixedResolution,
+)
+from .supervised import CARTPlacement, LightGBMPlacement
+from .unsupervised import QuantilePlacement, UniformPlacement
+
+__all__ = [
+ "BasePlacementStrategy",
+ "BaseResolutionPolicy",
+ "CARTPlacement",
+ "CardinalityAwareResolution",
+ "DataSizeAwareResolution",
+ "FixedResolution",
+ "LightGBMPlacement",
+ "PLEPlacementAdapter",
+ "PeriodicPlacementAdapter",
+ "PlacementResult",
+ "QuantilePlacement",
+ "RBFPlacementAdapter",
+ "SplinePlacementAdapter",
+ "UniformPlacement",
+ "create_placement_strategy",
+]
diff --git a/pretab/placement/adapters.py b/pretab/placement/adapters.py
new file mode 100644
index 0000000..15a0e65
--- /dev/null
+++ b/pretab/placement/adapters.py
@@ -0,0 +1,228 @@
+"""Family adapters: convert generic placement into family-specific locations.
+
+The placement strategies in :mod:`pretab.placement.supervised` /
+:mod:`pretab.placement.unsupervised` speak in *locations* and *unit counts*. Each
+transformer family, though, has its own vocabulary and conventions:
+
+* splines think in *basis functions* -> *internal knots* (a degree-dependent
+ conversion) and place knots strictly interior to the data range;
+* PLE thinks in *bins* -> *thresholds* (``bins - 1``) and is target-aware only;
+* feature maps think in *centers* that span the range with the endpoints included.
+
+These adapters own exactly that translation, so the placement strategies stay
+family-neutral. Each is a faithful reimplementation of the historical per-family
+selection code on top of the shared placement strategies, so knot / threshold /
+center positions are numerically unchanged.
+"""
+
+from __future__ import annotations
+
+from typing import Literal
+
+import numpy as np
+
+from ..core.knots import basis_to_knots
+from ..core.selectors import Task
+from ..exceptions import invalid_param_error
+from .factory import create_placement_strategy
+
+__all__ = [
+ "PLEPlacementAdapter",
+ "PeriodicPlacementAdapter",
+ "RBFPlacementAdapter",
+ "SplinePlacementAdapter",
+]
+
+# The spline knot selectors have always searched a fixed basis-function window,
+# independent of the requested output_dim (the transformer clamps to output_dim
+# afterwards). These reproduce ``CART/LightGBMKnotSelector``'s defaults.
+_SPLINE_MIN_BASIS = 3
+_SPLINE_MAX_BASIS = 15
+# Historical default seed used by the spline knot selectors when random_state is
+# left unset (feature maps / PLE forward their own default instead).
+_SPLINE_DEFAULT_SEED = 51
+
+
+class SplinePlacementAdapter:
+ """Target-aware knot placement for the B/M/I spline families.
+
+ A drop-in replacement for the old ``build_knot_selector(...)`` product: it
+ exposes the same :meth:`get_knot_locations` signature the spline base and
+ mixin call, but sources locations from a shared
+ :class:`~pretab.placement.supervised` strategy. The basis-function search
+ window (``min_basis_functions`` / ``max_basis_functions``) is converted to an
+ internal-knot count via :func:`pretab.core.knots.basis_to_knots`, exactly as
+ before.
+
+ Parameters
+ ----------
+ degree : int
+ Spline degree, used to convert basis functions into internal knots.
+ placement_strategy : {"cart", "lightgbm"}
+ Target-aware selector to place the knots.
+ spline_type : {"bspline", "mspline", "ispline"}, default="bspline"
+ Retained for parity with the previous selector API (the knot count depends
+ only on ``degree``).
+ random_state : int or None, default=None
+ Random state forwarded to the strategy. When unset the historical spline
+ default seed (51) is used.
+ min_basis_functions, max_basis_functions : int
+ Basis-function search window (defaults 3 and 15, matching the old
+ selectors).
+ """
+
+ def __init__(
+ self,
+ *,
+ degree: int,
+ placement_strategy: str,
+ spline_type: Literal["bspline", "mspline", "ispline"] = "bspline",
+ random_state: int | None = None,
+ min_basis_functions: int = _SPLINE_MIN_BASIS,
+ max_basis_functions: int = _SPLINE_MAX_BASIS,
+ ):
+ if placement_strategy not in ("cart", "lightgbm"):
+ raise invalid_param_error(
+ type(self).__name__,
+ "placement_strategy",
+ placement_strategy,
+ "must be 'cart' or 'lightgbm' when target_aware=True",
+ valid={"cart", "lightgbm"},
+ )
+ self.degree = degree
+ self.placement_strategy = placement_strategy
+ self.spline_type = spline_type
+ self.random_state = random_state
+ self.min_knots = basis_to_knots(min_basis_functions, degree)
+ self.max_knots = basis_to_knots(max_basis_functions, degree)
+
+ def get_knot_locations(
+ self,
+ X: np.ndarray,
+ y: np.ndarray | None = None,
+ task: Task | None = "regression",
+ ) -> np.ndarray:
+ """Return sorted internal knot locations for a single feature."""
+ seed = self.random_state if self.random_state is not None else _SPLINE_DEFAULT_SEED
+ strategy = create_placement_strategy(
+ target_aware=True,
+ placement_strategy=self.placement_strategy,
+ min_count=self.min_knots,
+ max_count=self.max_knots,
+ task=task,
+ random_state=seed,
+ )
+ return strategy.fit(X, y).get_locations().locations
+
+
+class PLEPlacementAdapter:
+ """Target-aware threshold placement for Piecewise Linear Encoding.
+
+ PLE is inherently target-aware: only the supervised strategies apply. The
+ caller resolves the ``[min_count, max_count]`` *threshold* window (one fewer
+ than the bin count) and this adapter returns the sorted thresholds.
+
+ Parameters
+ ----------
+ placement_strategy : {"cart", "lightgbm"}
+ Target-aware selector to place the thresholds.
+ task : {"regression", "classification"}, default="regression"
+ Prediction task passed to the selector.
+ random_state : int or None, default=None
+ Random state forwarded to the strategy as-is.
+ """
+
+ def __init__(
+ self,
+ *,
+ placement_strategy: str,
+ task: Task | None = "regression",
+ random_state: int | None = None,
+ ):
+ if placement_strategy not in ("cart", "lightgbm"):
+ raise invalid_param_error(
+ type(self).__name__,
+ "placement_strategy",
+ placement_strategy,
+ "must be 'cart' or 'lightgbm'",
+ valid={"cart", "lightgbm"},
+ )
+ self.placement_strategy = placement_strategy
+ self.task: Task | None = task
+ self.random_state = random_state
+
+ def get_thresholds(self, x: np.ndarray, y: np.ndarray, min_count: int, max_count: int) -> np.ndarray:
+ """Return sorted bin thresholds for a single feature."""
+ strategy = create_placement_strategy(
+ target_aware=True,
+ placement_strategy=self.placement_strategy,
+ min_count=min_count,
+ max_count=max_count,
+ task=self.task,
+ random_state=self.random_state,
+ )
+ return np.sort(strategy.fit(x, y).get_locations().locations)
+
+
+class RBFPlacementAdapter:
+ """Center placement for the center-based feature maps (RBF/ReLU/sigmoid/tanh).
+
+ Feature-map centers span the feature range with the endpoints included, and
+ may be placed either target-aware (CART / LightGBM) or unsupervised
+ (uniform / quantile). The caller resolves the ``[min_count, max_count]``
+ window (equal bounds on the non-adaptive path).
+
+ Parameters
+ ----------
+ target_aware : bool
+ Whether to use the supervised strategies.
+ placement_strategy : {"cart", "lightgbm", "uniform", "quantile"}
+ Placement strategy, validated against ``target_aware``.
+ task : {"regression", "classification"}, default="regression"
+ Prediction task for the supervised strategies.
+ random_state : int or None, default=None
+ Random state forwarded to the supervised strategies as-is.
+ """
+
+ def __init__(
+ self,
+ *,
+ target_aware: bool,
+ placement_strategy: str,
+ task: Task | None = "regression",
+ random_state: int | None = None,
+ ):
+ self.target_aware = target_aware
+ self.placement_strategy = placement_strategy
+ self.task: Task | None = task
+ self.random_state = random_state
+
+ def get_centers(self, x: np.ndarray, y: np.ndarray | None, min_count: int, max_count: int) -> np.ndarray:
+ """Return sorted centers for a single feature."""
+ strategy = create_placement_strategy(
+ target_aware=self.target_aware,
+ placement_strategy=self.placement_strategy,
+ min_count=min_count,
+ max_count=max_count,
+ task=self.task,
+ random_state=self.random_state,
+ include_endpoints=True,
+ )
+ return strategy.fit(x, y).get_locations().locations
+
+
+class PeriodicPlacementAdapter:
+ """Forward-declared adapter for the periodic (cyclic) encoder.
+
+ Periodic encoding is parameter-driven (``period`` and ``harmonics``) rather
+ than placement-driven: it does not locate data-dependent knots or centers.
+ This adapter exists so the capability registry and placement factory can name
+ a placement entry for every family uniformly; its data-driven placement is
+ reserved for a later phase (e.g. learned phase offsets or frequency
+ selection) and raises until then.
+ """
+
+ def get_locations(self, x: np.ndarray, y: np.ndarray | None = None) -> np.ndarray:
+ raise NotImplementedError(
+ "Periodic encoding is parameter-driven (period, harmonics) and does not use data-dependent placement."
+ )
diff --git a/pretab/placement/base.py b/pretab/placement/base.py
new file mode 100644
index 0000000..b6f008d
--- /dev/null
+++ b/pretab/placement/base.py
@@ -0,0 +1,101 @@
+"""Core placement contract: :class:`BasePlacementStrategy` and :class:`PlacementResult`.
+
+A *placement strategy* answers the "where" question for a single feature: given
+that feature's values (and optionally a target), it produces a sorted array of
+locations along the feature -- spline knots, feature-map centers, or PLE
+thresholds. It is deliberately unit-agnostic (it returns *locations*, not basis
+functions); converting a requested number of basis functions into a number of
+locations is the job of the family adapters in :mod:`pretab.placement.adapters`.
+
+The contract is intentionally tiny so both the unsupervised (uniform / quantile)
+and supervised (CART / LightGBM) families, and the family adapters, can share it:
+
+* :meth:`~BasePlacementStrategy.fit` looks at one feature and stores the chosen
+ locations, and
+* :meth:`~BasePlacementStrategy.get_locations` returns a frozen
+ :class:`PlacementResult` describing them (locations plus the requested and
+ effective unit counts, the strategy name, and whether the target was used).
+"""
+
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from dataclasses import dataclass
+from typing import ClassVar
+
+import numpy as np
+
+__all__ = ["BasePlacementStrategy", "PlacementResult"]
+
+
+@dataclass(frozen=True)
+class PlacementResult:
+ """Immutable description of the locations a strategy placed for one feature.
+
+ Parameters
+ ----------
+ locations : np.ndarray
+ Sorted array of placed locations along the feature.
+ requested_units : int
+ Number of locations the caller asked for (the resolved upper bound of the
+ ``[min_count, max_count]`` window).
+ effective_units : int
+ Number of locations actually produced. Equals ``requested_units`` for the
+ fixed-count unsupervised families; may be smaller for the data-driven
+ supervised families, which can find fewer informative splits.
+ strategy : str
+ Name of the strategy that produced the locations (``"uniform"``,
+ ``"quantile"``, ``"cart"``, ``"lightgbm"``).
+ target_aware : bool
+ Whether the placement used the target ``y``.
+ """
+
+ locations: np.ndarray
+ requested_units: int
+ effective_units: int
+ strategy: str
+ target_aware: bool
+
+
+class BasePlacementStrategy(ABC):
+ """Abstract base class for single-feature location placement strategies.
+
+ Subclasses set the ``name`` and ``target_aware`` class attributes and
+ implement :meth:`fit` (store the chosen locations on ``self``) and
+ :meth:`get_locations` (return the frozen :class:`PlacementResult`).
+
+ The strategy is stateful and single-feature: call :meth:`fit` with one
+ feature's values (and its target when ``target_aware``), then
+ :meth:`get_locations`. :meth:`place`, provided here, chains the two for
+ callers that do not need to keep the fitted strategy around.
+ """
+
+ name: ClassVar[str] = ""
+ target_aware: ClassVar[bool] = False
+
+ @abstractmethod
+ def fit(self, x: np.ndarray, y: np.ndarray | None = None) -> BasePlacementStrategy:
+ """Look at one feature (and optional target) and store the locations.
+
+ Parameters
+ ----------
+ x : np.ndarray of shape (n_samples,) or (n_samples, 1)
+ Values of a single feature.
+ y : np.ndarray of shape (n_samples,), optional
+ Target values. Required by the supervised strategies.
+
+ Returns
+ -------
+ self : BasePlacementStrategy
+ The fitted strategy.
+ """
+ raise NotImplementedError
+
+ @abstractmethod
+ def get_locations(self) -> PlacementResult:
+ """Return the :class:`PlacementResult` produced by the last :meth:`fit`."""
+ raise NotImplementedError
+
+ def place(self, x: np.ndarray, y: np.ndarray | None = None) -> PlacementResult:
+ """Convenience: :meth:`fit` on ``x``/``y`` then return :meth:`get_locations`."""
+ return self.fit(x, y).get_locations()
diff --git a/pretab/placement/factory.py b/pretab/placement/factory.py
new file mode 100644
index 0000000..7e5dd73
--- /dev/null
+++ b/pretab/placement/factory.py
@@ -0,0 +1,89 @@
+"""Factory for building placement strategies from the public parameter vocabulary.
+
+:func:`create_placement_strategy` is the single entry point transformers use to
+turn the user-facing ``target_aware`` / ``placement_strategy`` pair into a
+concrete :class:`~pretab.placement.base.BasePlacementStrategy`. It enforces the
+``target_aware`` / ``placement_strategy`` combo (D4) up front via
+:func:`pretab.core.parameters.validate_placement`, so an invalid pairing fails
+with one clear error instead of surfacing deep inside a family.
+
+The count window (``min_count`` / ``max_count``) and endpoint convention
+(``include_endpoints``) are resolved by the caller -- typically a family adapter
+in :mod:`pretab.placement.adapters` -- and passed straight through.
+"""
+
+from __future__ import annotations
+
+from ..core.parameters import validate_placement
+from ..core.selectors import Task
+from ..exceptions import invalid_param_error
+from .base import BasePlacementStrategy
+from .supervised import CARTPlacement, LightGBMPlacement
+from .unsupervised import QuantilePlacement, UniformPlacement
+
+__all__ = ["create_placement_strategy"]
+
+
+def create_placement_strategy(
+ *,
+ target_aware: bool,
+ placement_strategy: str,
+ min_count: int,
+ max_count: int,
+ task: Task | None = "regression",
+ random_state: int | None = None,
+ include_endpoints: bool = False,
+) -> BasePlacementStrategy:
+ """Build a placement strategy from ``target_aware`` / ``placement_strategy``.
+
+ Parameters
+ ----------
+ target_aware : bool
+ Whether the target ``y`` is used to place locations. Selects the
+ supervised (``True``) or unsupervised (``False``) family.
+ placement_strategy : {"cart", "lightgbm", "uniform", "quantile"}
+ The strategy name. Must be a supervised selector (``"cart"`` /
+ ``"lightgbm"``) when ``target_aware`` is True, or an unsupervised spacing
+ rule (``"uniform"`` / ``"quantile"``) when False.
+ min_count, max_count : int
+ Inclusive count window. The supervised strategies place a data-driven
+ count inside it; the unsupervised strategies place exactly ``max_count``
+ locations (callers pass ``min_count == max_count`` for a fixed width).
+ task : {"regression", "classification"}, optional
+ Task forwarded to the supervised strategies. Ignored when unsupervised.
+ random_state : int or None, default=None
+ Forwarded to the supervised strategies for reproducibility.
+ include_endpoints : bool, default=False
+ Endpoint convention for the unsupervised strategies (``False`` -> interior
+ locations, ``True`` -> range-spanning). Ignored when supervised.
+
+ Returns
+ -------
+ BasePlacementStrategy
+ A ready-to-fit placement strategy.
+
+ Raises
+ ------
+ InvalidParamError
+ If ``placement_strategy`` is not valid for the chosen ``target_aware``.
+ """
+ validate_placement(target_aware, placement_strategy)
+
+ if target_aware:
+ if placement_strategy == "cart":
+ return CARTPlacement(min_count=min_count, max_count=max_count, task=task, random_state=random_state)
+ return LightGBMPlacement(min_count=min_count, max_count=max_count, task=task, random_state=random_state)
+
+ if placement_strategy == "uniform":
+ return UniformPlacement(max_count, include_endpoints=include_endpoints)
+ if placement_strategy == "quantile":
+ return QuantilePlacement(max_count, include_endpoints=include_endpoints)
+
+ # Unreachable: validate_placement already rejected any other name.
+ raise invalid_param_error(
+ "create_placement_strategy",
+ "placement_strategy",
+ placement_strategy,
+ "must be one of 'cart', 'lightgbm', 'uniform', 'quantile'",
+ valid={"cart", "lightgbm", "quantile", "uniform"},
+ )
diff --git a/pretab/placement/resolution.py b/pretab/placement/resolution.py
new file mode 100644
index 0000000..d7a8167
--- /dev/null
+++ b/pretab/placement/resolution.py
@@ -0,0 +1,161 @@
+"""Resolution policies: *how many* units to place, separate from *where*.
+
+Every PreTab expansion exposes the same sizing vocabulary: a fixed ``output_dim``
+plus an optional adaptive window ``[min_output_dim, max_output_dim]``. Resolving
+that vocabulary into an inclusive ``(lo, hi)`` count window -- and validating it
+against a family floor / ceiling -- is a single concern that does not depend on
+*where* the units land. Keeping it here, apart from the placement strategies in
+:mod:`pretab.placement.unsupervised` / :mod:`pretab.placement.supervised`, lets a
+family combine any resolution policy with any placement strategy.
+
+:class:`FixedResolution` implements the ``output_dim`` / ``[min, max]`` contract
+shared by every family today. :class:`CardinalityAwareResolution` and
+:class:`DataSizeAwareResolution` are declared as forward-looking stubs (their
+data-driven ``(lo, hi)`` policies are scheduled for a later phase) so the
+registry and factory can name them without importing from a moving target.
+"""
+
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+
+import numpy as np
+
+from ..exceptions import IncompatibleParamsError, InvalidParamError
+
+__all__ = [
+ "BaseResolutionPolicy",
+ "CardinalityAwareResolution",
+ "DataSizeAwareResolution",
+ "FixedResolution",
+]
+
+
+class BaseResolutionPolicy(ABC):
+ """Abstract base class for "how many units" policies.
+
+ A policy turns the user-facing sizing parameters (``output_dim`` and the
+ optional ``[min_output_dim, max_output_dim]`` window) into an inclusive
+ ``(lo, hi)`` bound on the per-feature unit count, validated against a
+ family-specific ``floor`` (and optional ``ceil``).
+ """
+
+ @abstractmethod
+ def resolve(
+ self,
+ output_dim: int,
+ min_req: int | None,
+ max_req: int | None,
+ *,
+ floor: int,
+ floor_label: str | None = None,
+ ceil: int | None = None,
+ ) -> tuple[int, int]:
+ """Return the inclusive ``(lo, hi)`` per-feature unit-count window."""
+ raise NotImplementedError
+
+
+class FixedResolution(BaseResolutionPolicy):
+ """The ``output_dim`` / ``[min, max]`` resolution shared by every family.
+
+ With ``adaptive=False`` each feature is expanded to exactly ``output_dim``
+ units (``lo == hi == output_dim``), after checking ``output_dim`` is
+ consistent with any explicitly supplied ``min``/``max`` request. With
+ ``adaptive=True`` the window comes from the requested ``min``/``max`` (each
+ falling back to ``output_dim`` when unset). The resolved window is validated
+ against the family ``floor`` and optional ``ceil``.
+
+ This reproduces the historical ``AdaptiveResolutionMixin._resolve_output_bounds``
+ behaviour exactly.
+ """
+
+ def __init__(self, adaptive: bool):
+ self.adaptive = adaptive
+
+ def resolve(
+ self,
+ output_dim: int,
+ min_req: int | None,
+ max_req: int | None,
+ *,
+ floor: int,
+ floor_label: str | None = None,
+ ceil: int | None = None,
+ ) -> tuple[int, int]:
+ if not self.adaptive:
+ if min_req is not None and output_dim < min_req:
+ raise IncompatibleParamsError(
+ "output_dim must be >= min_output_dim when adaptive=False "
+ f"(got output_dim={output_dim}, min_output_dim={min_req}).\n"
+ "Fix: raise output_dim, lower min_output_dim, or set adaptive=True."
+ )
+ if max_req is not None and output_dim > max_req:
+ raise IncompatibleParamsError(
+ "output_dim must be <= max_output_dim when adaptive=False "
+ f"(got output_dim={output_dim}, max_output_dim={max_req}).\n"
+ "Fix: lower output_dim, raise max_output_dim, or set adaptive=True."
+ )
+ lo = hi = output_dim
+ else:
+ lo = min_req if min_req is not None else output_dim
+ hi = max_req if max_req is not None else output_dim
+
+ label = floor_label if floor_label is not None else str(floor)
+ if lo < floor:
+ raise InvalidParamError(
+ f"min_output_dim must be >= {label}, got {lo}.\n"
+ "Fix: raise min_output_dim to at least the family minimum."
+ )
+ if ceil is not None and hi > ceil:
+ raise InvalidParamError(
+ f"max_output_dim should be <= {ceil}, got {hi}.\nFix: lower max_output_dim to at most {ceil}."
+ )
+ if lo > hi:
+ raise IncompatibleParamsError(
+ f"min_output_dim must be <= max_output_dim (got min_output_dim={lo}, max_output_dim={hi})."
+ )
+ return lo, hi
+
+
+class CardinalityAwareResolution(BaseResolutionPolicy):
+ """Stub: cap the unit count by the feature's distinct-value count.
+
+ Scheduled for a later phase. Declared now so the capability registry and
+ placement factory can reference it by name.
+ """
+
+ def resolve(
+ self,
+ output_dim: int,
+ min_req: int | None,
+ max_req: int | None,
+ *,
+ floor: int,
+ floor_label: str | None = None,
+ ceil: int | None = None,
+ ) -> tuple[int, int]:
+ raise NotImplementedError("CardinalityAwareResolution is not implemented yet.")
+
+ def clamp_to_cardinality(self, hi: int, x: np.ndarray) -> int:
+ """Placeholder for the future distinct-value clamp."""
+ raise NotImplementedError("CardinalityAwareResolution is not implemented yet.")
+
+
+class DataSizeAwareResolution(BaseResolutionPolicy):
+ """Stub: scale the unit count with the number of samples.
+
+ Scheduled for a later phase. Declared now so the capability registry and
+ placement factory can reference it by name.
+ """
+
+ def resolve(
+ self,
+ output_dim: int,
+ min_req: int | None,
+ max_req: int | None,
+ *,
+ floor: int,
+ floor_label: str | None = None,
+ ceil: int | None = None,
+ ) -> tuple[int, int]:
+ raise NotImplementedError("DataSizeAwareResolution is not implemented yet.")
diff --git a/pretab/placement/supervised.py b/pretab/placement/supervised.py
new file mode 100644
index 0000000..098b8ba
--- /dev/null
+++ b/pretab/placement/supervised.py
@@ -0,0 +1,103 @@
+"""Supervised placement: locations where the feature's effect on the target changes.
+
+:class:`CARTPlacement` fits a single decision tree (scikit-learn only, always
+available); :class:`LightGBMPlacement` fits a gradient-boosted ensemble and needs
+the optional ``lightgbm`` dependency. Both look at one feature against the target
+and return the split thresholds -- spaced out, ranked (impurity for CART, gain for
+LightGBM), and topped up / trimmed to land in ``[min_count, max_count]``. Because
+placement is data-driven, the effective unit count can be smaller than requested.
+
+Both strategies delegate to the count-based selectors in
+:mod:`pretab.core.selectors`, so placement stays numerically identical to the
+historical per-family code.
+"""
+
+from __future__ import annotations
+
+from typing import ClassVar
+
+import numpy as np
+
+from ..core.selectors import (
+ BaseLocationSelector,
+ CARTLocationSelector,
+ LightGBMLocationSelector,
+ Task,
+)
+from ..exceptions import IncompatibleParamsError
+from .base import BasePlacementStrategy, PlacementResult
+
+__all__ = ["CARTPlacement", "LightGBMPlacement"]
+
+
+class _SupervisedPlacement(BasePlacementStrategy):
+ """Shared machinery for the target-aware placement strategies.
+
+ Parameters
+ ----------
+ min_count, max_count : int
+ Inclusive bounds on the number of locations to return.
+ task : {"regression", "classification"}, default="regression"
+ Prediction task passed to the underlying tree model.
+ random_state : int or None, default=None
+ Forwarded to the selector for reproducibility (only when set, so an unset
+ value keeps the selector's own default seed).
+ """
+
+ target_aware: ClassVar[bool] = True
+
+ def __init__(
+ self,
+ *,
+ min_count: int,
+ max_count: int,
+ task: Task | None = "regression",
+ random_state: int | None = None,
+ ):
+ self.min_count = min_count
+ self.max_count = max_count
+ self.task: Task | None = task
+ self.random_state = random_state
+ self._selector = self._build_selector()
+
+ def _build_selector(self) -> BaseLocationSelector:
+ raise NotImplementedError
+
+ def fit(self, x: np.ndarray, y: np.ndarray | None = None) -> _SupervisedPlacement:
+ if y is None:
+ raise IncompatibleParamsError(f"{type(self).__name__} requires y to place locations.")
+ self.locations_ = self._selector.select(
+ x,
+ y,
+ task=self.task,
+ min_count=self.min_count,
+ max_count=self.max_count,
+ )
+ return self
+
+ def get_locations(self) -> PlacementResult:
+ return PlacementResult(
+ locations=self.locations_,
+ requested_units=self.max_count,
+ effective_units=int(self.locations_.shape[0]),
+ strategy=self.name,
+ target_aware=True,
+ )
+
+
+class CARTPlacement(_SupervisedPlacement):
+ """Target-aware placement from a single decision tree's split points."""
+
+ name: ClassVar[str] = "cart"
+
+ def _build_selector(self) -> BaseLocationSelector:
+ return CARTLocationSelector(random_state=self.random_state)
+
+
+class LightGBMPlacement(_SupervisedPlacement):
+ """Target-aware placement from a LightGBM ensemble's gain-ranked split points."""
+
+ name: ClassVar[str] = "lightgbm"
+
+ def _build_selector(self) -> BaseLocationSelector:
+ return LightGBMLocationSelector(random_state=self.random_state)
diff --git a/pretab/placement/unsupervised.py b/pretab/placement/unsupervised.py
new file mode 100644
index 0000000..1ba3000
--- /dev/null
+++ b/pretab/placement/unsupervised.py
@@ -0,0 +1,90 @@
+"""Unsupervised placement: locations from feature geometry alone.
+
+:class:`UniformPlacement` spaces locations evenly across a feature's range;
+:class:`QuantilePlacement` puts them at evenly spaced data quantiles. Neither
+uses the target, so both fit without a ``y`` and their effective unit count
+always equals the requested count.
+
+The two endpoint conventions PreTab uses are exposed through ``include_endpoints``:
+
+* ``include_endpoints=False`` (default) returns *interior* locations -- the
+ B/M/I-spline internal-knot convention (:func:`pretab.core.knots.uniform_knots` /
+ :func:`~pretab.core.knots.quantile_knots`).
+* ``include_endpoints=True`` returns locations that span the full range, endpoints
+ included -- the feature-map center and spanning-knot convention
+ (:func:`pretab.core.knots.spanning_knots`).
+
+Both paths delegate to the shared knot primitives so placement stays numerically
+identical to the historical per-family code.
+"""
+
+from __future__ import annotations
+
+from typing import ClassVar
+
+import numpy as np
+
+from ..core.knots import quantile_knots, spanning_knots, uniform_knots
+from .base import BasePlacementStrategy, PlacementResult
+
+__all__ = ["QuantilePlacement", "UniformPlacement"]
+
+
+class _UnsupervisedPlacement(BasePlacementStrategy):
+ """Shared machinery for the fixed-count, target-free placement strategies.
+
+ Parameters
+ ----------
+ n_units : int
+ Number of locations to place per feature (the requested and, for these
+ deterministic strategies, effective count).
+ include_endpoints : bool, default=False
+ ``False`` returns interior locations (internal-knot convention); ``True``
+ returns range-spanning locations with the endpoints included.
+ """
+
+ target_aware: ClassVar[bool] = False
+
+ def __init__(self, n_units: int, *, include_endpoints: bool = False):
+ self.n_units = n_units
+ self.include_endpoints = include_endpoints
+
+ def _place(self, x: np.ndarray, n_units: int) -> np.ndarray:
+ raise NotImplementedError
+
+ def fit(self, x: np.ndarray, y: np.ndarray | None = None) -> _UnsupervisedPlacement:
+ x = np.asarray(x, dtype=float).ravel()
+ x = x[~np.isnan(x)]
+ self.locations_ = np.asarray(self._place(x, self.n_units))
+ return self
+
+ def get_locations(self) -> PlacementResult:
+ return PlacementResult(
+ locations=self.locations_,
+ requested_units=self.n_units,
+ effective_units=int(self.locations_.shape[0]),
+ strategy=self.name,
+ target_aware=False,
+ )
+
+
+class UniformPlacement(_UnsupervisedPlacement):
+ """Evenly spaced locations across a feature's range."""
+
+ name: ClassVar[str] = "uniform"
+
+ def _place(self, x: np.ndarray, n_units: int) -> np.ndarray:
+ if self.include_endpoints:
+ return spanning_knots(x, n_units, "uniform")
+ return uniform_knots(x, n_units)
+
+
+class QuantilePlacement(_UnsupervisedPlacement):
+ """Locations at evenly spaced data quantiles of a feature."""
+
+ name: ClassVar[str] = "quantile"
+
+ def _place(self, x: np.ndarray, n_units: int) -> np.ndarray:
+ if self.include_endpoints:
+ return spanning_knots(x, n_units, "quantile")
+ return quantile_knots(x, n_units)
diff --git a/pretab/preprocessor.py b/pretab/preprocessor.py
index fcf228a..34fcecf 100644
--- a/pretab/preprocessor.py
+++ b/pretab/preprocessor.py
@@ -1,25 +1,66 @@
+import hashlib
+import inspect
+import json
+import os
import time
+import warnings
+from typing import cast
import numpy as np
-import pandas as pd
-from sklearn.base import BaseEstimator, TransformerMixin
-from sklearn.compose import ColumnTransformer
-from sklearn.pipeline import Pipeline
+from scipy import sparse as sp
+from sklearn.base import BaseEstimator, TransformerMixin, clone
+from sklearn.utils._set_output import _get_output_config
from sklearn.utils.validation import check_is_fitted
-from .core.exceptions import (
- IncompatibleParamsError,
- invalid_param_error,
+from .compose.config import PreprocessorConfig
+from .compose.factory import build_column_transformer
+from .compose.feature_detection import detect_column_types, to_dataframe
+from .compose.inspection import (
+ build_feature_info,
+ build_feature_lineage,
+ build_transformer_summary,
+ clean_feature_names,
+ get_output_slices,
)
+from .compose.output import compute_output_report, format_output, to_dataframe_output
+from .compose.serialize import SCHEMA_VERSION, preprocessor_from_spec, preprocessor_to_spec
from .core.logging import configure_logging, get_logger
-from .core.params import validate_placement
-from .pipeline import (
- get_categorical_transformer_steps,
- get_numerical_transformer_steps,
+from .core.policy import RepresentationPolicy, apply_constant_policy
+from .exceptions import (
+ ConfigWarning,
+ FrozenRepresentationError,
+ OutputBudgetError,
+ PretabDataError,
+ PretabSerializationError,
+ invalid_param_error,
)
logger = get_logger(__name__)
+#: Named parameter bundles exposed through ``Preprocessor(preset=...)``. Each
+#: preset supplies values only for the listed parameters; any parameter the caller
+#: sets explicitly (i.e. away from its ``__init__`` default) overrides the preset.
+PRESETS = {
+ "standard": {
+ "numerical_method": "ple",
+ "categorical_method": "int",
+ "output_dim": 7,
+ "adaptive": False,
+ },
+ "expanded": {
+ "numerical_method": "ple",
+ "categorical_method": "one-hot",
+ "output_dim": 16,
+ "adaptive": False,
+ },
+ "adaptive": {
+ "numerical_method": "ple",
+ "categorical_method": "int",
+ "adaptive": True,
+ "min_output_dim": 5,
+ "max_output_dim": 16,
+ },
+}
class Preprocessor(TransformerMixin, BaseEstimator):
@@ -115,12 +156,67 @@ class Preprocessor(TransformerMixin, BaseEstimator):
treat_all_integers_as_numerical : bool, default=False
If True, every integer-typed column is treated as numerical regardless of cardinality,
bypassing the ``cat_cutoff`` heuristic.
- handle_missing : {"error", "median"}, default="median"
- Missing-value policy. ``"median"`` keeps the default mean ``SimpleImputer`` that runs
- before every numerical method (so NaNs are filled and, e.g., PLE uses its median
- handling). ``"error"`` drops that imputer so missing values are *not* silently filled
- and reach the transformers, which then raise on NaN. Forwarded to the NaN-aware
- methods (currently PLE) via the numerical pipeline.
+ numerical_imputation : str or None, default="median"
+ Strategy for the ``SimpleImputer`` that runs *before* every numerical method. Accepts
+ any ``sklearn`` strategy (``"median"``, ``"mean"``, ``"most_frequent"``, ``"constant"``).
+ ``None`` disables imputation, so NaNs reach the numerical transformers unchanged and the
+ finite-input methods (all numerical methods, including PLE) raise on missing values.
+ categorical_imputation : str or None, default="most_frequent"
+ Strategy for the ``SimpleImputer`` that runs *before* every categorical method. ``None``
+ disables imputation for categorical columns.
+ add_missing_indicator : bool, default=False
+ If True, append a binary missing-value indicator column for each imputed feature (via the
+ imputer's ``add_indicator``; a standalone ``MissingIndicator`` is used when imputation is
+ disabled). Applies to both numerical and categorical pipelines.
+ missing_policy : {"error", "propagate", "impute", "impute_with_indicator", "separate_state"} or None, default=None
+ High-level missing-value strategy. ``None`` (default) keeps the explicit
+ ``numerical_imputation`` / ``categorical_imputation`` / ``add_missing_indicator``
+ parameters authoritative. When set it overrides them:
+
+ - ``"error"`` -- raise :class:`~pretab.exceptions.PretabDataError` if any missing
+ value is present at ``fit`` or ``transform``.
+ - ``"propagate"`` -- disable imputation so NaNs reach the transformers unchanged
+ (each family applies its own missing-value contract).
+ - ``"impute"`` -- impute with the configured strategy (no indicator).
+ - ``"impute_with_indicator"`` -- impute and append a missing indicator column.
+ - ``"separate_state"`` -- impute for the representation *and* emit a dedicated
+ ``__missing`` column per feature that stays outside the ordinary basis, so a
+ downstream model can learn a separate response to missingness.
+ policy : RepresentationPolicy or dict or None, default=None
+ Central edge-case policy (see :class:`~pretab.RepresentationPolicy`) governing how
+ constant columns, out-of-range values, missing values, and non-finite inputs are
+ handled. ``None`` uses the default policy, which reproduces the library's historical
+ behaviour. Pass a mapping such as ``{"constant": "error"}`` to tighten a single axis.
+ max_output_features : int or None, default=None
+ Upper bound on the total number of output columns produced across all input
+ features. ``None`` disables the check. A violation is handled per
+ ``overflow_policy``.
+ max_features_per_input : int or None, default=None
+ Upper bound on the number of output columns any single input feature may
+ expand to. ``None`` disables the check. A violation is handled per
+ ``overflow_policy``.
+ max_dense_memory : int or None, default=None
+ Upper bound, in bytes, on the estimated dense output footprint
+ (``n_rows * total_output_dim_ * itemsize``) evaluated against the training
+ data at ``fit``. ``None`` disables the check. A violation is handled per
+ ``overflow_policy``. See :meth:`estimate_memory` to estimate this for any
+ input.
+ overflow_policy : {"error", "warn", "ignore"}, default="error"
+ What to do when a configured output budget is exceeded: ``"error"`` raises
+ :class:`~pretab.exceptions.OutputBudgetError`, ``"warn"`` emits a
+ :class:`~pretab.exceptions.ConfigWarning`, ``"ignore"`` proceeds silently.
+ Only takes effect when at least one budget parameter above is set.
+ output_format : {"dense", "sparse", "auto"}, default="dense"
+ Container used for the transformed output. ``"dense"`` (the default, for
+ backward compatibility) returns NumPy arrays; ``"sparse"`` returns SciPy
+ CSR matrices (a single stacked CSR when ``return_array=True``, otherwise CSR
+ blocks in the output dict); ``"auto"`` selects ``"sparse"`` when the output
+ density falls below ``0.3`` and ``"dense"`` otherwise. Ignored when
+ :meth:`set_output` requests a pandas or polars DataFrame. Every ``transform``
+ records the resolved choice and its memory footprint in ``output_report_``.
+ dtype : numpy dtype or None, default=None
+ Optional dtype to cast the transformed output to (e.g. ``numpy.float32`` to
+ halve memory). ``None`` keeps the native ``float64`` output.
verbose : int, default=0
Verbosity level controlling ``fit``-time logging, applied through the shared
``"pretab"`` logger so a single setting on this entry point governs the whole
@@ -136,6 +232,13 @@ class Preprocessor(TransformerMixin, BaseEstimator):
(e.g. DeepTab) can pass it straight through ``Preprocessor(**kwargs)``. PreTab never
configures the root logger or attaches a handler when the host already owns one, so
``verbose=0`` keeps PreTab silent under a host's own logging.
+ preset : {"standard", "expanded", "adaptive"} or None, default=None
+ Optional named configuration bundle applied as a transparent alias. A preset only
+ fills in parameters left at their defaults; any parameter set explicitly always wins.
+ ``"standard"`` is the PLE + integer-code baseline, ``"expanded"`` widens the
+ numerical basis and one-hot encodes categoricals, and ``"adaptive"`` sizes each
+ feature's width from the data. Call :meth:`get_resolved_config` to see the effective
+ parameters. ``None`` (default) uses the individual parameters unchanged.
Attributes
----------
@@ -150,6 +253,10 @@ class Preprocessor(TransformerMixin, BaseEstimator):
output_dims\_ : dict
Per-feature expanded output-column counts, keyed by input feature name.
The values sum to ``total_output_dim_``.
+ output_report\_ : dict
+ Memory report for the most recent ``transform``, with keys ``format``
+ (``"dense"`` or ``"sparse"``), ``shape``, ``density``, ``dense_bytes``,
+ ``actual_bytes``, and ``memory_saved_bytes``. Set on every ``transform``.
embeddings\_ : bool
Whether embedding vectors were provided at ``fit`` time and are expected in transformation.
embedding_dimensions\_ : dict
@@ -232,8 +339,19 @@ def __init__(
scaling="minmax",
cat_cutoff=0.03,
treat_all_integers_as_numerical=False,
- handle_missing="median",
+ numerical_imputation: str | None = "median",
+ categorical_imputation: str | None = "most_frequent",
+ add_missing_indicator=False,
+ missing_policy=None,
+ policy=None,
+ max_output_features=None,
+ max_features_per_input=None,
+ max_dense_memory=None,
+ overflow_policy="error",
+ output_format="dense",
+ dtype=None,
verbose=0,
+ preset=None,
):
"""
Initialize the Preprocessor with various transformation options for tabular data.
@@ -257,61 +375,19 @@ def __init__(
self.scaling = scaling
self.cat_cutoff = cat_cutoff
self.treat_all_integers_as_numerical = treat_all_integers_as_numerical
- self.handle_missing = handle_missing
+ self.numerical_imputation = numerical_imputation
+ self.categorical_imputation = categorical_imputation
+ self.add_missing_indicator = add_missing_indicator
+ self.missing_policy = missing_policy
+ self.policy = policy
+ self.max_output_features = max_output_features
+ self.max_features_per_input = max_features_per_input
+ self.max_dense_memory = max_dense_memory
+ self.overflow_policy = overflow_policy
+ self.output_format = output_format
+ self.dtype = dtype
self.verbose = verbose
-
- def _detect_column_types(self, X):
- """
- Detects categorical and numerical features in the input data.
-
- Parameters
- ----------
- X : pandas.DataFrame, numpy.ndarray, or dict
- The input data to analyze.
-
- Returns
- -------
- numerical_features : list of str
- Column names detected as numerical features.
- categorical_features : list of str
- Column names detected as categorical features.
- """
-
- categorical_features = []
- numerical_features = []
-
- if isinstance(X, dict):
- X = pd.DataFrame(X)
- elif isinstance(X, np.ndarray):
- X = pd.DataFrame(X, columns=[f"feature_{i}" for i in range(X.shape[1])])
-
- for col in X.columns:
- num_unique_values = X[col].nunique()
- total_samples = len(X[col])
-
- if self.treat_all_integers_as_numerical and X[col].dtype.kind == "i":
- numerical_features.append(col)
- else:
- if isinstance(self.cat_cutoff, float):
- cutoff_condition = (
- num_unique_values / total_samples
- ) < self.cat_cutoff
- elif isinstance(self.cat_cutoff, int):
- cutoff_condition = num_unique_values < self.cat_cutoff
- else:
- raise invalid_param_error(
- type(self).__name__, "cat_cutoff", self.cat_cutoff,
- "must be a float (unique-ratio cutoff) or an int (absolute unique-count cutoff)",
- )
-
- if X[col].dtype.kind not in "iufc" or (
- X[col].dtype.kind == "i" and cutoff_condition
- ):
- categorical_features.append(col)
- else:
- numerical_features.append(col)
-
- return numerical_features, categorical_features
+ self.preset = preset
def fit(self, X, y=None, embeddings=None):
"""
@@ -337,24 +413,34 @@ def fit(self, X, y=None, embeddings=None):
configure_logging(verbose)
start_time = time.perf_counter()
- validate_placement(self.target_aware, self.placement_strategy)
+ resolved = self._resolved_params()
+ config = PreprocessorConfig.from_params(
+ numerical_method=resolved["numerical_method"],
+ categorical_method=resolved["categorical_method"],
+ feature_preprocessing=resolved["feature_preprocessing"],
+ output_dim=resolved["output_dim"],
+ degree=resolved["degree"],
+ target_aware=resolved["target_aware"],
+ placement_strategy=resolved["placement_strategy"],
+ task=resolved["task"],
+ adaptive=resolved["adaptive"],
+ min_output_dim=resolved["min_output_dim"],
+ max_output_dim=resolved["max_output_dim"],
+ random_state=resolved["random_state"],
+ scaling=resolved["scaling"],
+ cat_cutoff=resolved["cat_cutoff"],
+ treat_all_integers_as_numerical=resolved["treat_all_integers_as_numerical"],
+ numerical_imputation=resolved["numerical_imputation"],
+ categorical_imputation=resolved["categorical_imputation"],
+ add_missing_indicator=resolved["add_missing_indicator"],
+ missing_policy=resolved["missing_policy"],
+ verbose=resolved["verbose"],
+ )
- if isinstance(X, dict):
- X = pd.DataFrame(X)
- elif isinstance(X, np.ndarray):
- X = pd.DataFrame(X, columns=[f"feature_{i}" for i in range(X.shape[1])])
+ X = to_dataframe(X)
- numerical_method = (
- self.numerical_method.lower()
- if self.numerical_method is not None
- else "none"
- )
- categorical_method = (
- self.categorical_method.lower()
- if self.categorical_method is not None
- else "none"
- )
- feature_preprocessing = self.feature_preprocessing or {}
+ if self.missing_policy == "error":
+ self._reject_missing(X)
self.embeddings_ = False
self.embedding_dimensions_ = {}
@@ -366,58 +452,49 @@ def fit(self, X, y=None, embeddings=None):
for i, e in enumerate(embeddings):
self.embedding_dimensions_[f"embedding_{i + 1}"] = e.shape[1]
- numerical_features, categorical_features = self._detect_column_types(X)
- transformers = []
-
- for feature in numerical_features:
- method = feature_preprocessing.get(feature, numerical_method)
- # Forward ``random_state`` only when the user set one, so unset keeps
- # each transformer's own default seed (PLE / selectors = 51, others
- # unseeded) and a set value pins every stochastic method globally.
- seed_kwargs = {} if self.random_state is None else {"random_state": self.random_state}
- steps = get_numerical_transformer_steps(
- method=method,
- task=self.task,
- target_aware=self.target_aware,
- add_imputer=self.handle_missing != "error",
- imputer_strategy="mean",
- output_dim=self.output_dim,
- adaptive=self.adaptive,
- min_output_dim=self.min_output_dim if self.adaptive else None,
- max_output_dim=self.max_output_dim if self.adaptive else None,
- degree=self.degree,
- scaling=self.scaling,
- placement_strategy=self.placement_strategy,
- handle_missing=self.handle_missing,
- **seed_kwargs,
- )
- transformers.append((f"num_{feature}", Pipeline(steps), [feature]))
+ numerical_features, categorical_features = detect_column_types(
+ X,
+ cat_cutoff=resolved["cat_cutoff"],
+ treat_all_integers_as_numerical=resolved["treat_all_integers_as_numerical"],
+ estimator_name=type(self).__name__,
+ )
- for feature in categorical_features:
- method = feature_preprocessing.get(feature, categorical_method)
- steps = get_categorical_transformer_steps(method, output_dim=self.output_dim)
- transformers.append((f"cat_{feature}", Pipeline(steps), [feature]))
+ self.policy_ = RepresentationPolicy.resolve(self.policy)
+ self.numerical_features_ = list(numerical_features)
+ self.categorical_features_ = list(categorical_features)
+ if numerical_features and self.policy_.constant != "allow":
+ numeric_values = X[numerical_features].to_numpy(dtype=np.float64, na_value=np.nan)
+ apply_constant_policy(numeric_values, self.policy_, estimator=self)
- self.column_transformer_ = ColumnTransformer(
- transformers=transformers, remainder="passthrough"
- )
+ self.column_transformer_ = build_column_transformer(config, numerical_features, categorical_features)
self.column_transformer_.fit(X, y)
self.n_features_in_ = X.shape[1]
+ valid_formats = ("auto", "dense", "sparse")
+ if self.output_format not in valid_formats:
+ raise invalid_param_error(
+ type(self).__name__,
+ "output_format",
+ self.output_format,
+ "must be one of 'auto', 'dense', 'sparse'",
+ valid=set(valid_formats),
+ )
+
+ self._enforce_output_budget(X.shape[0])
+
if verbose >= 1:
logger.info(
- "fit complete: %d numerical (%s) + %d categorical (%s) feature(s) "
- "-> %d output columns in %.3fs",
+ "fit complete: %d numerical (%s) + %d categorical (%s) feature(s) -> %d output columns in %.3fs",
len(numerical_features),
- numerical_method,
+ config.numerical_method,
len(categorical_features),
- categorical_method,
+ config.categorical_method,
len(self.get_feature_names_out()),
time.perf_counter() - start_time,
)
if verbose >= 2:
info = self.get_feature_info(verbose=False)
- for line in self._feature_table_lines(*info):
+ for line in build_transformer_summary(*info):
logger.debug(line)
if verbose >= 3:
self._log_internal_decisions()
@@ -439,50 +516,42 @@ def transform(self, X, embeddings=None, return_array=False):
Returns
-------
- dict or np.ndarray
- Transformed data. A dictionary if return_array=False, else a NumPy array.
+ dict, np.ndarray, scipy.sparse matrix, or DataFrame
+ Transformed data. By default a dictionary of per-feature blocks; a
+ single stacked array when ``return_array=True``; a SciPy CSR matrix (or
+ CSR blocks) when ``output_format`` resolves to ``"sparse"``; or a pandas
+ / polars DataFrame when configured via :meth:`set_output`.
"""
check_is_fitted(self)
- if isinstance(X, dict):
- X = pd.DataFrame(X)
- elif isinstance(X, np.ndarray):
- X = pd.DataFrame(X, columns=[f"feature_{i}" for i in range(X.shape[1])])
- else:
- X = X.copy()
-
- transformed_X = self.column_transformer_.transform(X)
-
- if return_array:
- return transformed_X
-
- transformed_dict = {}
- start = 0
- for name, transformer, columns in self.column_transformer_.transformers_:
- if transformer == "drop":
- continue
- if hasattr(transformer, "transform"):
- width = transformer.transform(X[columns]).shape[1]
- else:
- width = 1
- transformed_dict[name] = transformed_X[:, start : start + width]
- start += width
+ X = to_dataframe(X, copy=True)
- if embeddings is not None:
- if not self.embeddings_:
- raise IncompatibleParamsError(
- "Embeddings were not expected, but were provided.\n"
- "Fix: configure an embedding feature in feature_preprocessing before "
- "passing embeddings to transform, or omit the embeddings argument."
- )
- if isinstance(embeddings, np.ndarray):
- transformed_dict["embedding_1"] = embeddings.astype(np.float32)
- elif isinstance(embeddings, list):
- for idx, e in enumerate(embeddings):
- transformed_dict[f"embedding_{idx + 1}"] = e.astype(np.float32)
+ if self.missing_policy == "error":
+ self._reject_missing(X)
- return transformed_dict
+ 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 self.dtype is not None:
+ transformed_X = transformed_X.astype(self.dtype, copy=False)
+
+ fmt, self.output_report_ = compute_output_report(transformed_X, self.output_format)
+
+ container = _get_output_config("transform", self)["dense"]
+ if container in ("pandas", "polars"):
+ return to_dataframe_output(transformed_X, self.get_feature_names_out(), container)
+
+ slices = None if return_array else get_output_slices(self.column_transformer_, X)
+ return format_output(
+ transformed_X,
+ return_array=return_array,
+ slices=slices,
+ embeddings=embeddings,
+ embeddings_expected=self.embeddings_,
+ output_format=fmt,
+ )
def fit_transform(self, X, y=None, embeddings=None, return_array=False):
"""
@@ -505,9 +574,60 @@ def fit_transform(self, X, y=None, embeddings=None, return_array=False):
Transformed dataset in the specified output format.
"""
- return self.fit(X, y, embeddings=embeddings).transform(
- X, embeddings, return_array
- )
+ return self.fit(X, y, embeddings=embeddings).transform(X, embeddings, return_array)
+
+ @classmethod
+ def _param_defaults(cls):
+ """Return the ``__init__`` parameter defaults, keyed by name."""
+ signature = inspect.signature(cls.__init__)
+ return {
+ name: parameter.default
+ for name, parameter in signature.parameters.items()
+ if parameter.default is not inspect.Parameter.empty
+ }
+
+ def _resolved_params(self):
+ """Return the effective parameters after expanding ``preset``.
+
+ A preset fills in only the parameters left at their ``__init__`` default;
+ explicitly-set parameters always take precedence. The ``preset`` key is
+ dropped from the returned mapping.
+ """
+ params = self.get_params(deep=False)
+ preset = params.pop("preset", None)
+ if preset is None:
+ return params
+ if preset not in PRESETS:
+ raise invalid_param_error(
+ type(self).__name__,
+ "preset",
+ preset,
+ "must be one of " + ", ".join(repr(name) for name in sorted(PRESETS)),
+ valid=set(PRESETS),
+ )
+ defaults = self._param_defaults()
+ resolved = dict(params)
+ for key, preset_value in PRESETS[preset].items():
+ if key in defaults and params.get(key) == defaults[key]:
+ resolved[key] = preset_value
+ return resolved
+
+ def get_resolved_config(self):
+ """Return the effective parameter mapping after ``preset`` expansion.
+
+ When ``preset`` is set, its bundled values fill in every parameter the
+ caller left at its default while explicitly-set parameters win; the
+ ``preset`` key itself is removed. When ``preset`` is ``None`` this is simply
+ :meth:`get_params` without the ``preset`` entry. The returned dict is the
+ configuration ``fit`` builds from, so it makes a preset's effect inspectable
+ before fitting.
+
+ Returns
+ -------
+ dict
+ The resolved parameter mapping.
+ """
+ return self._resolved_params()
def get_feature_names_out(self, input_features=None):
"""
@@ -529,7 +649,24 @@ def get_feature_names_out(self, input_features=None):
"""
check_is_fitted(self)
- return self.column_transformer_.get_feature_names_out(input_features)
+ raw_names = self.column_transformer_.get_feature_names_out(input_features)
+ return np.array(clean_feature_names(self.column_transformer_, raw_names))
+
+ def get_feature_lineage(self):
+ """Return per-output-column provenance for the fitted preprocessor.
+
+ Each :class:`~pretab.core.representation.FeatureLineage` record maps one
+ output column back to its source feature(s), representation family, and
+ component, covering 100% of the columns produced by
+ :meth:`get_feature_names_out` (and in the same order).
+
+ Returns
+ -------
+ lineage : list of FeatureLineage
+ One record per output column of the transformed array.
+ """
+ check_is_fitted(self)
+ return build_feature_lineage(self.column_transformer_)
@property
def total_output_dim_(self) -> int:
@@ -571,6 +708,111 @@ def output_dims_(self) -> dict:
dims[columns[0]] = width
return dims
+ def _output_itemsize(self) -> int:
+ """Bytes per element of the dense transformed array (float64 for now)."""
+ return np.dtype(np.float64).itemsize
+
+ def estimate_output_shape(self, X) -> tuple:
+ """Estimate the shape of the dense transformed array for ``X``.
+
+ Fitted method. Returns ``(n_rows, total_output_dim_)`` where ``n_rows`` is
+ the number of rows in ``X`` and the column count is the fitted output width
+ (the same width :meth:`transform` would produce with ``return_array=True``).
+
+ Parameters
+ ----------
+ X : pandas.DataFrame, numpy.ndarray, or dict
+ Input whose row count drives the estimate; not transformed.
+
+ Returns
+ -------
+ tuple of int
+ ``(n_rows, n_output_columns)``.
+ """
+ check_is_fitted(self)
+ n_rows = to_dataframe(X).shape[0]
+ return (int(n_rows), int(self.total_output_dim_))
+
+ def estimate_memory(self, X) -> int:
+ """Estimate the dense-array memory footprint (in bytes) of transforming ``X``.
+
+ Fitted method. Computed as ``n_rows * total_output_dim_ * itemsize`` for the
+ dense output dtype, without materialising the transform.
+
+ Parameters
+ ----------
+ X : pandas.DataFrame, numpy.ndarray, or dict
+ Input whose row count drives the estimate; not transformed.
+
+ Returns
+ -------
+ int
+ Estimated number of bytes for the dense transformed array.
+ """
+ n_rows, n_cols = self.estimate_output_shape(X)
+ return int(n_rows * n_cols * self._output_itemsize())
+
+ def _reject_missing(self, X) -> None:
+ """Raise when ``missing_policy="error"`` but ``X`` contains missing values."""
+ na_columns = [col for col in X.columns if X[col].isna().any()]
+ if na_columns:
+ raise PretabDataError(
+ f"missing_policy='error' but missing values were found in columns {na_columns}.\n"
+ "Fix: impute the data first, or choose a different missing_policy "
+ "('propagate', 'impute', 'impute_with_indicator', 'separate_state')."
+ )
+
+ def _enforce_output_budget(self, n_rows: int) -> None:
+ """Check the fitted output width against the configured output budget.
+
+ Runs at the end of :meth:`fit`. When no budget parameter is set this is a
+ no-op (the historical behaviour). Any violation is handled according to
+ ``overflow_policy``: ``"error"`` raises
+ :class:`~pretab.exceptions.OutputBudgetError`, ``"warn"`` emits a
+ :class:`~pretab.exceptions.ConfigWarning`, and ``"ignore"`` proceeds
+ silently.
+ """
+ valid_policies = ("error", "warn", "ignore")
+ if self.overflow_policy not in valid_policies:
+ raise invalid_param_error(
+ type(self).__name__,
+ "overflow_policy",
+ self.overflow_policy,
+ "must be one of 'error', 'warn', 'ignore'",
+ valid=set(valid_policies),
+ )
+
+ violations: list[str] = []
+
+ total = int(self.total_output_dim_)
+ if self.max_output_features is not None and total > self.max_output_features:
+ violations.append(f"total output columns ({total}) exceed max_output_features ({self.max_output_features})")
+
+ if self.max_features_per_input is not None:
+ for feature, width in self.output_dims_.items():
+ if width > self.max_features_per_input:
+ violations.append(
+ f"feature {feature!r} expands to {width} columns, "
+ f"exceeding max_features_per_input ({self.max_features_per_input})"
+ )
+
+ if self.max_dense_memory is not None:
+ estimated = n_rows * total * self._output_itemsize()
+ if estimated > self.max_dense_memory:
+ violations.append(
+ f"dense output for {n_rows} row(s) needs ~{estimated} bytes, "
+ f"exceeding max_dense_memory ({self.max_dense_memory})"
+ )
+
+ if not violations:
+ return
+
+ message = "Output budget exceeded: " + "; ".join(violations) + "."
+ if self.overflow_policy == "error":
+ raise OutputBudgetError(message)
+ if self.overflow_policy == "warn":
+ warnings.warn(message, ConfigWarning, stacklevel=2)
+
def get_feature_info(self, verbose=True):
"""
Retrieves metadata about the transformed features.
@@ -601,111 +843,15 @@ def get_feature_info(self, verbose=True):
check_is_fitted(self)
- numerical_feature_info = {}
- categorical_feature_info = {}
-
- embedding_feature_info = (
- {
- key: {"preprocessing": None, "dimension": dim, "categories": None}
- for key, dim in self.embedding_dimensions_.items()
- }
- if self.embeddings_
- else {}
+ numerical_feature_info, categorical_feature_info, embedding_feature_info = build_feature_info(
+ self.column_transformer_,
+ embeddings=self.embeddings_,
+ embedding_dimensions=self.embedding_dimensions_,
)
- for (
- name,
- transformer_pipeline,
- columns,
- ) in self.column_transformer_.transformers_:
- steps = [step[0] for step in transformer_pipeline.steps]
-
- for feature_name in columns:
- preprocessing_type = " -> ".join(steps)
- dimension = None
- categories = None
-
- if "discretizer" in steps or any(
- step in steps
- for step in [
- "standardization",
- "minmax",
- "quantile",
- "polynomial",
- "splines",
- "box-cox",
- ]
- ):
- last_step = transformer_pipeline.steps[-1][1]
- if hasattr(last_step, "transform"):
- dummy_input = np.zeros((1, 1)) + 1e-05
- try:
- transformed_feature = last_step.transform(dummy_input)
- dimension = transformed_feature.shape[1]
- except (ValueError, TypeError, AttributeError, IndexError) as exc:
- logger.debug(
- "Could not introspect output width of %r: %s",
- feature_name,
- exc,
- )
- dimension = None
- numerical_feature_info[feature_name] = {
- "preprocessing": preprocessing_type,
- "dimension": dimension,
- "categories": None,
- }
-
- elif "continuous_ordinal" in steps:
- step = transformer_pipeline.named_steps["continuous_ordinal"]
- categories = len(step.mapping_[columns.index(feature_name)])
- dimension = 1
- categorical_feature_info[feature_name] = {
- "preprocessing": preprocessing_type,
- "dimension": dimension,
- "categories": categories,
- }
-
- elif "onehot" in steps:
- step = transformer_pipeline.named_steps["onehot"]
- if hasattr(step, "categories_"):
- categories = sum(len(cat) for cat in step.categories_)
- dimension = categories
- categorical_feature_info[feature_name] = {
- "preprocessing": preprocessing_type,
- "dimension": dimension,
- "categories": categories,
- }
-
- else:
- last_step = transformer_pipeline.steps[-1][1]
- if hasattr(last_step, "transform"):
- dummy_input = np.zeros((1, 1))
- try:
- transformed_feature = last_step.transform(dummy_input)
- dimension = transformed_feature.shape[1]
- except (ValueError, TypeError, AttributeError, IndexError) as exc:
- logger.debug(
- "Could not introspect output width of %r: %s",
- feature_name,
- exc,
- )
- dimension = None
- if "cat" in name:
- categorical_feature_info[feature_name] = {
- "preprocessing": preprocessing_type,
- "dimension": dimension,
- "categories": None,
- }
- else:
- numerical_feature_info[feature_name] = {
- "preprocessing": preprocessing_type,
- "dimension": dimension,
- "categories": None,
- }
-
if verbose:
configure_logging(1)
- for line in self._feature_table_lines(
+ for line in build_transformer_summary(
numerical_feature_info,
categorical_feature_info,
embedding_feature_info,
@@ -714,47 +860,10 @@ def get_feature_info(self, verbose=True):
return numerical_feature_info, categorical_feature_info, embedding_feature_info
- def _feature_table_lines(self, numerical_info, categorical_info, embedding_info):
- """Build aligned, human-readable rows describing the fitted feature layout."""
- rows = []
- for feat, info in numerical_info.items():
- rows.append(
- (str(feat), "numerical", str(info["preprocessing"]), info["dimension"], info["categories"])
- )
- for feat, info in categorical_info.items():
- rows.append(
- (str(feat), "categorical", str(info["preprocessing"]), info["dimension"], info["categories"])
- )
- for feat, info in embedding_info.items():
- rows.append((str(feat), "embedding", "-", info["dimension"], info["categories"]))
- if not rows:
- return []
-
- feat_w = max(len("feature"), *(len(r[0]) for r in rows))
- kind_w = max(len("kind"), *(len(r[1]) for r in rows))
- pipe_w = max(len("pipeline"), *(len(r[2]) for r in rows))
- header = (
- f"{'feature':<{feat_w}} {'kind':<{kind_w}} "
- f"{'pipeline':<{pipe_w}} {'dim':>4} {'cats':>5}"
- )
- lines = [header, "-" * len(header)]
- for feat, kind, pipe, dim, cats in rows:
- dim_s = "-" if dim is None else str(dim)
- cats_s = "-" if cats is None else str(cats)
- lines.append(
- f"{feat:<{feat_w}} {kind:<{kind_w}} "
- f"{pipe:<{pipe_w}} {dim_s:>4} {cats_s:>5}"
- )
- return lines
-
def _log_internal_decisions(self):
"""Log fitted internal decisions (bins / knots / centers) at DEBUG."""
for name, transformer, _columns in self.column_transformer_.transformers_:
- last_step = (
- transformer.steps[-1][1]
- if hasattr(transformer, "steps")
- else transformer
- )
+ last_step = transformer.steps[-1][1] if hasattr(transformer, "steps") else transformer
for attr in (
"thresholds_",
"knots_",
@@ -765,3 +874,177 @@ def _log_internal_decisions(self):
if hasattr(last_step, attr):
logger.debug("%s.%s = %r", name, attr, getattr(last_step, attr))
+ # --- Portable serialization (P9.1) ---
+ def to_spec(self, path=None) -> dict:
+ """Serialize the fitted preprocessor to a portable, versioned spec.
+
+ Produces a self-describing JSON-compatible dictionary (schema version,
+ PreTab / numpy / scipy / scikit-learn versions, resolved parameters, a
+ per-representation summary, the output-column order, and the encoded
+ fitted state) that reconstructs this estimator bit-for-bit via
+ :meth:`from_spec`. Unlike :mod:`pickle`, loading a spec never executes
+ estimator code and only imports an allow-listed set of library modules.
+
+ Parameters
+ ----------
+ path : str, os.PathLike, or None, default=None
+ When given, the spec is also written to this path as UTF-8 JSON.
+
+ Returns
+ -------
+ dict
+ The spec dictionary (always returned, whether or not ``path`` is set).
+ """
+ check_is_fitted(self)
+ spec = preprocessor_to_spec(self)
+ if path is not None:
+ with open(path, "w", encoding="utf-8") as handle:
+ json.dump(spec, handle, indent=2)
+ return spec
+
+ @classmethod
+ def from_spec(cls, source) -> "Preprocessor":
+ """Reconstruct a fitted preprocessor from a spec created by :meth:`to_spec`.
+
+ Parameters
+ ----------
+ source : str, os.PathLike, or dict
+ A path to a JSON spec file, or the spec dictionary itself.
+
+ Returns
+ -------
+ Preprocessor
+ A fitted preprocessor equivalent to the one that produced the spec;
+ ``transform`` reproduces the original output bit-for-bit.
+ """
+ if isinstance(source, dict):
+ data = source
+ elif isinstance(source, (str, os.PathLike)):
+ with open(source, encoding="utf-8") as handle:
+ data = json.load(handle)
+ else:
+ raise PretabSerializationError("from_spec expects a spec dict or a path to a JSON spec file.")
+ obj = preprocessor_from_spec(data)
+ if not isinstance(obj, cls):
+ raise PretabSerializationError(f"Spec reconstructed a {type(obj).__name__}, expected {cls.__name__}.")
+ return obj
+
+ # --- Fingerprint & reproducibility (P9.2) ---
+ def _canonical_spec(self) -> dict:
+ """Deterministic subset of the spec used for fingerprinting."""
+ spec = preprocessor_to_spec(self)
+ return {
+ "schema_version": spec["schema_version"],
+ "pretab_version": spec["pretab_version"],
+ "library_versions": spec["library_versions"],
+ "feature_names_out": spec["feature_names_out"],
+ "state": spec["state"],
+ }
+
+ @property
+ def fingerprint_(self) -> str:
+ """Stable SHA-256 digest identifying this fitted preprocessor.
+
+ Fitted attribute. Computed over a canonical JSON view of the resolved
+ configuration, dependency versions, output-column order, random seeds, and
+ the fitted state (knot / center / bin locations, scaler statistics, encoder
+ categories). The digest is deterministic across processes and machines, so
+ two preprocessors share a fingerprint iff they transform identically.
+ """
+ check_is_fitted(self)
+ canonical = json.dumps(self._canonical_spec(), sort_keys=True, separators=(",", ":"), ensure_ascii=True)
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
+
+ def reproducibility_report(self) -> dict:
+ """Return a machine-readable reproducibility summary for this fitted preprocessor.
+
+ Returns
+ -------
+ dict
+ Fingerprint, schema / library versions, random seed, output dtype and
+ format, input/output widths, and the per-feature representation
+ families -- everything needed to audit or reproduce the fit.
+ """
+ check_is_fitted(self)
+ spec = preprocessor_to_spec(self)
+ representations = {
+ entry["columns"][0]: entry.get("family") for entry in spec["representations"] if entry.get("columns")
+ }
+ return {
+ "fingerprint": self.fingerprint_,
+ "schema_version": SCHEMA_VERSION,
+ "pretab_version": spec["pretab_version"],
+ "library_versions": spec["library_versions"],
+ "random_state": self.random_state,
+ "output_format": self.output_format,
+ "dtype": None if self.dtype is None else str(self.dtype),
+ "n_features_in": int(self.n_features_in_),
+ "n_output_features": len(spec["feature_names_out"]),
+ "representations": representations,
+ }
+
+ # --- Immutable lifecycle (P9.3) ---
+ @property
+ def lifecycle_state_(self) -> str:
+ """Current lifecycle state: ``UNFITTED``, ``FITTED``, ``FROZEN``, or ``STALE``."""
+ try:
+ check_is_fitted(self)
+ except Exception:
+ return "UNFITTED"
+ if getattr(self, "_frozen", False):
+ return "FROZEN"
+ if getattr(self, "_stale_reason", None) is not None:
+ return "STALE"
+ return "FITTED"
+
+ def is_frozen(self) -> bool:
+ """Return whether this preprocessor has been frozen against mutation."""
+ return bool(getattr(self, "_frozen", False))
+
+ def freeze(self) -> "Preprocessor":
+ """Freeze the fitted preprocessor, blocking further ``set_params`` mutation.
+
+ Returns ``self`` for chaining. A frozen preprocessor is intended as an
+ immutable deployment artifact; use :meth:`clone_unfitted` or :meth:`refit`
+ to obtain a fresh, mutable estimator.
+ """
+ check_is_fitted(self)
+ self._frozen = True
+ return self
+
+ def mark_stale(self, reason: str) -> "Preprocessor":
+ """Mark this fitted preprocessor as stale (its inputs/assumptions changed).
+
+ Records ``reason`` and flips :attr:`lifecycle_state_` to ``STALE`` (unless
+ already ``FROZEN``). Purely advisory: it does not alter the fitted state.
+ Returns ``self`` for chaining.
+ """
+ check_is_fitted(self)
+ self._stale_reason = reason
+ return self
+
+ @property
+ def stale_reason_(self):
+ """The reason recorded by :meth:`mark_stale`, or ``None``."""
+ return getattr(self, "_stale_reason", None)
+
+ def clone_unfitted(self) -> "Preprocessor":
+ """Return a fresh, unfitted, mutable copy carrying the same constructor params."""
+ return cast("Preprocessor", clone(self))
+
+ def refit(self, X, y=None, embeddings=None) -> "Preprocessor":
+ """Fit a fresh copy on new data and return it, leaving ``self`` untouched.
+
+ Enables re-fitting a frozen or deployed preprocessor without mutating the
+ original: returns a new, unfrozen, fitted :class:`Preprocessor`.
+ """
+ return self.clone_unfitted().fit(X, y, embeddings=embeddings)
+
+ def set_params(self, **params):
+ """Set parameters, refusing to mutate a frozen preprocessor."""
+ if params and self.is_frozen():
+ raise FrozenRepresentationError(
+ f"Cannot set_params({', '.join(sorted(params))}) on a frozen {type(self).__name__}. "
+ "Use clone_unfitted() for a mutable copy, or refit() to fit fresh data."
+ )
+ return super().set_params(**params)
diff --git a/pretab/transformers/__init__.py b/pretab/transformers/__init__.py
index 99eeaab..c68964c 100644
--- a/pretab/transformers/__init__.py
+++ b/pretab/transformers/__init__.py
@@ -1,21 +1,26 @@
-from .binning import CustomBinTransformer
-from .embeddings import LanguageEmbeddingTransformer
-from .encoders import (
+from .categorical import (
ContinuousOrdinalTransformer,
- NoTransformer,
- ToFloatTransformer,
+ LanguageEmbeddingTransformer,
+ OneHotFromOrdinalTransformer,
)
+from .encoders import MissingStateIndicator, NoTransformer, ToFloatTransformer
from .feature_maps import (
+ FourierFeatureTransformer,
+ NystroemFeaturesTransformer,
+ RandomFourierFeaturesTransformer,
RBFExpansionTransformer,
ReLUExpansionTransformer,
SigmoidExpansionTransformer,
TanhExpansionTransformer,
)
-from .onehot import OneHotFromOrdinalTransformer
-from .ple import PLETransformer
+from .numerical import (
+ NumericBinningTransformer,
+ PeriodicEncodingTransformer,
+ PLETransformer,
+)
from .splines import (
BSplineTransformer,
- CubicSplineTransformer,
+ CubicRegressionSplineTransformer,
ISplineTransformer,
MSplineTransformer,
NaturalCubicSplineTransformer,
@@ -23,30 +28,27 @@
TensorProductSplineTransformer,
ThinPlateSplineTransformer,
)
-from .temporal import (
- CyclicalTimeTransformer,
- LagFeatureTransformer,
- RollingStatsTransformer,
-)
__all__ = [
"BSplineTransformer",
"ContinuousOrdinalTransformer",
- "CubicSplineTransformer",
- "CustomBinTransformer",
- "CyclicalTimeTransformer",
+ "CubicRegressionSplineTransformer",
+ "FourierFeatureTransformer",
"ISplineTransformer",
- "LagFeatureTransformer",
"LanguageEmbeddingTransformer",
"MSplineTransformer",
+ "MissingStateIndicator",
"NaturalCubicSplineTransformer",
"NoTransformer",
+ "NumericBinningTransformer",
+ "NystroemFeaturesTransformer",
"OneHotFromOrdinalTransformer",
"PLETransformer",
"PSplineTransformer",
+ "PeriodicEncodingTransformer",
"RBFExpansionTransformer",
+ "RandomFourierFeaturesTransformer",
"ReLUExpansionTransformer",
- "RollingStatsTransformer",
"SigmoidExpansionTransformer",
"TanhExpansionTransformer",
"TensorProductSplineTransformer",
diff --git a/pretab/transformers/binning/__init__.py b/pretab/transformers/binning/__init__.py
deleted file mode 100644
index 2aa52d9..0000000
--- a/pretab/transformers/binning/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from .binning import CustomBinTransformer
-
-__all__ = ["CustomBinTransformer"]
diff --git a/pretab/transformers/binning/binning.py b/pretab/transformers/binning/binning.py
deleted file mode 100644
index d68d52b..0000000
--- a/pretab/transformers/binning/binning.py
+++ /dev/null
@@ -1,157 +0,0 @@
-from typing import ClassVar
-
-import numpy as np
-import pandas as pd
-from sklearn.base import BaseEstimator, TransformerMixin
-
-from ...core.exceptions import InsufficientSamplesError, InvalidParamError, PretabDataError
-from ...core.params import UNSET, AliasResolverMixin
-
-
-class CustomBinTransformer(AliasResolverMixin, TransformerMixin, BaseEstimator):
- """
- Custom binning transformer for one-dimensional numerical features.
-
- This transformer bins continuous values into discrete intervals, using either a fixed number of equal-width bins
- or a user-provided array of bin edges. It is compatible with scikit-learn pipelines.
-
- Parameters
- ----------
- output_dim : int or array-like
- If int, defines the number of equal-width bins. If array-like, defines
- the bin edges to use directly. Note that ``output_dim`` here is the
- number of *bins*, not the number of output columns: this transformer
- always emits a single ordinal column of integer bin indices. The bin
- count only becomes an output width after a subsequent one-hot encoding.
-
- Attributes
- ----------
- n_features_in_ : int
- The number of input features seen during ``fit`` (expected to be 1).
-
- total_output_dim_ : int
- Total number of output columns (fitted). Always ``1`` because the output
- is a single ordinal column.
-
- Notes
- -----
- This transformer operates on a single feature of shape ``(n_samples, 1)``. When
- ``output_dim`` is an integer, equal-width bin edges are computed from the data
- range; when it is an array-like, the provided edges are used directly. The
- output contains integer bin indices in a single column, so its width is ``1``
- regardless of ``output_dim`` -- this is a documented exception to the
- exact-width contract that the fixed-basis families follow.
-
- The input must be numeric: binning is performed with :func:`pandas.cut`, so
- string / categorical data cannot be processed and raises a
- :class:`~pretab.core.exceptions.PretabDataError`. Encode such columns with a
- categorical method (e.g. ``"int"`` or ``"one-hot"``) before binning.
-
- Examples
- --------
- >>> import numpy as np
- >>> from pretab.transformers import CustomBinTransformer
- >>> X = np.linspace(0, 1, 10).reshape(-1, 1)
- >>> transformer = CustomBinTransformer(output_dim=4)
- >>> transformer.fit_transform(X).shape
- (10, 1)
- """
-
- _param_aliases: ClassVar[dict[str, str]] = {}
-
- def __init__(self, output_dim=UNSET):
- # An int yields equal-width bins; an array-like is used as bin edges.
- self.output_dim = output_dim
-
- def fit(self, X, y=None):
- """
- Fit the transformer on the data.
-
- Parameters
- ----------
- X : array-like of shape (n_samples, 1)
- Input data.
-
- y : Ignored
- Not used, present here for API consistency by convention.
-
- Returns
- -------
- self : object
- Fitted transformer.
- """
- # Fit doesn't need to do anything as we are directly using provided bins
- X = np.asarray(X)
- self.n_features_in_ = X.shape[1] if X.ndim > 1 else 1
- self.total_output_dim_ = 1
- return self
-
- def transform(self, X):
- """
- Transform the data using the specified binning strategy.
-
- Parameters
- ----------
- X : array-like of shape (n_samples, 1)
- Input data to transform.
-
- Returns
- -------
- X_binned : ndarray of shape (n_samples, 1)
- Binned data with integer bin indices.
- """
-
- X = np.asarray(X) # Ensures squeeze works and consistent input
- if X.ndim != 2 or X.shape[1] != 1:
- raise PretabDataError("Input must be a 2D array with shape (n_samples, 1).")
-
- if X.shape[0] <= 2:
- raise InsufficientSamplesError("Input must have more than 2 observations.")
-
- if not np.issubdtype(X.dtype, np.number):
- try:
- X = X.astype(np.float64)
- except (ValueError, TypeError) as exc:
- raise PretabDataError(
- "CustomBinTransformer requires numeric input: it bins continuous "
- "values with pandas.cut and cannot process string/categorical "
- "data. Encode string columns with a categorical method (e.g. "
- "'int' or 'one-hot') before binning."
- ) from exc
-
- bins_spec = self._resolve_param("output_dim", default=UNSET)
- if bins_spec is UNSET:
- raise InvalidParamError("CustomBinTransformer requires 'output_dim'.")
-
- if isinstance(bins_spec, int):
- # Calculate equal width bins based on the range of the data and number of bins
- _, bins = pd.cut(X.squeeze(), bins=bins_spec, retbins=True)
- else:
- # Use predefined bins
- bins = bins_spec
-
- # Apply the bins to the data
- binned_data = pd.cut( # type: ignore
- X.squeeze(),
- bins=np.sort(np.unique(bins)), # type: ignore
- labels=False,
- include_lowest=True,
- )
- return np.expand_dims(np.array(binned_data), 1)
-
- def get_feature_names_out(self, input_features=None):
- """Return the names of the transformed features.
-
- Parameters
- ----------
- input_features : list of str
- The names of the input features.
-
- Returns
- -------
- input_features : ndarray of shape (n_features,)
- The names of the output features after transformation.
- """
- if input_features is None:
- raise InvalidParamError("input_features must be specified")
- return input_features
diff --git a/pretab/transformers/categorical/__init__.py b/pretab/transformers/categorical/__init__.py
new file mode 100644
index 0000000..bd7d010
--- /dev/null
+++ b/pretab/transformers/categorical/__init__.py
@@ -0,0 +1,14 @@
+"""Categorical transformers: ordinal encoding, language embeddings and the
+time-boxed legacy one-hot-from-ordinal encoder. Modules are moved here during the
+1.0.0 restructure (Phase 1).
+"""
+
+from .language_embedding import LanguageEmbeddingTransformer
+from .legacy import OneHotFromOrdinalTransformer
+from .ordinal import ContinuousOrdinalTransformer
+
+__all__ = [
+ "ContinuousOrdinalTransformer",
+ "LanguageEmbeddingTransformer",
+ "OneHotFromOrdinalTransformer",
+]
diff --git a/pretab/transformers/embeddings/language_transformer.py b/pretab/transformers/categorical/language_embedding.py
similarity index 70%
rename from pretab/transformers/embeddings/language_transformer.py
rename to pretab/transformers/categorical/language_embedding.py
index 797c345..bb8aace 100644
--- a/pretab/transformers/embeddings/language_transformer.py
+++ b/pretab/transformers/categorical/language_embedding.py
@@ -1,7 +1,8 @@
import numpy as np
from sklearn.base import BaseEstimator, TransformerMixin
+from sklearn.utils.validation import check_is_fitted
-from ...core.exceptions import OptionalDependencyError, PretabConfigError
+from ...exceptions import OptionalDependencyError, PretabConfigError
class LanguageEmbeddingTransformer(TransformerMixin, BaseEstimator):
@@ -26,6 +27,8 @@ class LanguageEmbeddingTransformer(TransformerMixin, BaseEstimator):
``fit`` from ``model`` or by loading ``model_name``.
n_features_in_ : int
Number of input features seen during ``fit``.
+ embedding_dim_ : int
+ Dimensionality of the embeddings produced by ``model_``.
Notes
-----
@@ -51,7 +54,7 @@ def _resolve_model(self):
if self.model is not None:
return self.model
try:
- from sentence_transformers import SentenceTransformer
+ from sentence_transformers import SentenceTransformer # type: ignore
except ImportError as e:
raise OptionalDependencyError(
"sentence-transformers is not installed. Install it via `pip install sentence-transformers` or provide a preloaded model."
@@ -75,6 +78,12 @@ def fit(self, X, y=None):
"""
self.n_features_in_ = X.shape[1] if len(X.shape) > 1 else 1
self.model_ = self._resolve_model()
+ # Read the embedding dim without calling encode() so call-count stays
+ # predictable; fall back to the 'dim' attribute used by test stubs.
+ if hasattr(self.model_, "get_sentence_embedding_dimension"):
+ self.embedding_dim_ = int(self.model_.get_sentence_embedding_dimension())
+ else:
+ self.embedding_dim_ = int(getattr(self.model_, "dim", 0))
return self
def transform(self, X):
@@ -95,9 +104,7 @@ def transform(self, X):
The concatenated embeddings for each text input.
"""
if getattr(self, "model_", None) is None:
- raise PretabConfigError(
- "Model is not initialized. Call `fit` before `transform`."
- )
+ raise PretabConfigError("Model is not initialized. Call `fit` before `transform`.")
# Normalise to a 2D array of strings so each column is encoded on its own
# and the row count is preserved (a flat encode would return
@@ -107,8 +114,24 @@ def transform(self, X):
arr = arr.reshape(-1, 1)
arr = arr.astype(str)
- column_embeddings = [
- self.model_.encode(arr[:, i].tolist(), convert_to_numpy=True)
- for i in range(arr.shape[1])
- ]
+ column_embeddings = [self.model_.encode(arr[:, i].tolist(), convert_to_numpy=True) for i in range(arr.shape[1])]
return np.hstack(column_embeddings)
+
+ def get_feature_names_out(self, input_features=None):
+ """Return output feature names: one per embedding dimension per input column.
+
+ Parameters
+ ----------
+ input_features : array-like of str or None
+ Input feature names. When ``None``, names of the form ``x0, x1, ...``
+ are generated.
+
+ Returns
+ -------
+ feature_names_out : ndarray of str, shape (n_features_in_ * embedding_dim_,)
+ """
+ check_is_fitted(self, ["n_features_in_", "embedding_dim_"])
+ if input_features is None:
+ input_features = [f"x{i}" for i in range(self.n_features_in_)]
+ names = [f"{col}_emb{j}" for col in input_features for j in range(self.embedding_dim_)]
+ return np.asarray(names, dtype=object)
diff --git a/pretab/transformers/onehot/onehot.py b/pretab/transformers/categorical/legacy.py
similarity index 78%
rename from pretab/transformers/onehot/onehot.py
rename to pretab/transformers/categorical/legacy.py
index 0744243..be21f99 100644
--- a/pretab/transformers/onehot/onehot.py
+++ b/pretab/transformers/categorical/legacy.py
@@ -1,14 +1,25 @@
+import warnings
+
import numpy as np
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.utils.validation import check_is_fitted
+from ...core.representation import RepresentationSpecMixin
+
-class OneHotFromOrdinalTransformer(TransformerMixin, BaseEstimator):
+class OneHotFromOrdinalTransformer(RepresentationSpecMixin, TransformerMixin, BaseEstimator):
"""Convert ordinal-encoded features into a one-hot encoded representation.
This is useful when features have already been ordinal-encoded and a one-hot
representation is required for model training.
+ .. deprecated:: 1.0.0
+ ``OneHotFromOrdinalTransformer`` is deprecated and will be removed in a
+ future release. Use the ``"one-hot"`` categorical method (backed by
+ scikit-learn's :class:`~sklearn.preprocessing.OneHotEncoder`), which
+ one-hot encodes raw categories directly without a separate
+ ordinal-encoding step.
+
Attributes
----------
max_bins_ : ndarray of shape (n_features,)
@@ -31,6 +42,18 @@ class OneHotFromOrdinalTransformer(TransformerMixin, BaseEstimator):
(3, 5)
"""
+ _representation_family = "onehot"
+ _representation_component_kind = "category"
+
+ def __init__(self):
+ warnings.warn(
+ "OneHotFromOrdinalTransformer is deprecated and will be removed in a "
+ "future release. Use the 'one-hot' categorical method (sklearn's "
+ "OneHotEncoder), which one-hot encodes raw categories directly.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+
def fit(self, X, y=None):
"""Learn the maximum bin index for each feature from the data.
@@ -46,9 +69,7 @@ def fit(self, X, y=None):
self : object
Fitted transformer.
"""
- self.max_bins_ = (
- np.max(X, axis=0).astype(int) + 1
- ) # Find the maximum bin index for each feature
+ self.max_bins_ = np.max(X, axis=0).astype(int) + 1 # Find the maximum bin index for each feature
self.n_features_in_ = np.asarray(X).shape[1]
return self
diff --git a/pretab/transformers/encoders/continuous_ordinal.py b/pretab/transformers/categorical/ordinal.py
similarity index 87%
rename from pretab/transformers/encoders/continuous_ordinal.py
rename to pretab/transformers/categorical/ordinal.py
index 9ad19fa..867cf01 100644
--- a/pretab/transformers/encoders/continuous_ordinal.py
+++ b/pretab/transformers/categorical/ordinal.py
@@ -2,8 +2,10 @@
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.utils.validation import check_is_fitted
+from ...core.representation import RepresentationSpecMixin
-class ContinuousOrdinalTransformer(TransformerMixin, BaseEstimator):
+
+class ContinuousOrdinalTransformer(RepresentationSpecMixin, TransformerMixin, BaseEstimator):
"""Encode categorical features as continuous integer values.
Each unique category within a feature is assigned an integer based on its
@@ -30,6 +32,9 @@ class ContinuousOrdinalTransformer(TransformerMixin, BaseEstimator):
(3, 2)
"""
+ _representation_family = "ordinal"
+ _representation_component_kind = "category"
+
def fit(self, X, y=None):
"""Learn the mapping from categories to integers for each feature.
@@ -46,10 +51,7 @@ def fit(self, X, y=None):
Fitted transformer.
"""
# Fit should determine the mapping from original categories to sequential integers starting from 0
- self.mapping_ = [
- {category: i + 1 for i, category in enumerate(np.unique(col))}
- for col in X.T
- ]
+ self.mapping_ = [{category: i + 1 for i, category in enumerate(np.unique(col))} for col in X.T]
for mapping in self.mapping_:
mapping[None] = 0 # Assign 0 to unknown values
self.n_features_in_ = len(self.mapping_)
@@ -70,12 +72,7 @@ def transform(self, X):
"""
check_is_fitted(self, "mapping_")
# Transform the categories to their mapped integer values
- X_transformed = np.array(
- [
- [self.mapping_[col].get(value, 0) for col, value in enumerate(row)]
- for row in X
- ]
- )
+ X_transformed = np.array([[self.mapping_[col].get(value, 0) for col, value in enumerate(row)] for row in X])
return X_transformed
def get_feature_names_out(self, input_features=None):
diff --git a/pretab/transformers/embeddings/__init__.py b/pretab/transformers/embeddings/__init__.py
deleted file mode 100644
index 769027d..0000000
--- a/pretab/transformers/embeddings/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from .language_transformer import LanguageEmbeddingTransformer
-
-__all__ = ["LanguageEmbeddingTransformer"]
diff --git a/pretab/transformers/encoders/__init__.py b/pretab/transformers/encoders/__init__.py
index b56015f..1d729b2 100644
--- a/pretab/transformers/encoders/__init__.py
+++ b/pretab/transformers/encoders/__init__.py
@@ -1,14 +1,14 @@
-"""Categorical / numeric encoders for tabular preprocessing.
+"""Numeric helper transformers for tabular preprocessing.
These transformers turn raw column values into numeric arrays that downstream
-models can consume: ordinal integer encoding, a float cast, and a pass-through.
+models can consume: a float cast and a pass-through.
"""
-from .continuous_ordinal import ContinuousOrdinalTransformer
from .floats import NoTransformer, ToFloatTransformer
+from .missing import MissingStateIndicator
__all__ = [
- "ContinuousOrdinalTransformer",
+ "MissingStateIndicator",
"NoTransformer",
"ToFloatTransformer",
]
diff --git a/pretab/transformers/encoders/missing.py b/pretab/transformers/encoders/missing.py
new file mode 100644
index 0000000..ed98d09
--- /dev/null
+++ b/pretab/transformers/encoders/missing.py
@@ -0,0 +1,96 @@
+import numpy as np
+import pandas as pd
+from sklearn.base import BaseEstimator, TransformerMixin
+from sklearn.utils.validation import check_is_fitted
+
+
+class MissingStateIndicator(TransformerMixin, BaseEstimator):
+ """Emit a binary ``__missing`` column marking where the input was missing.
+
+ Used by the ``missing_policy="separate_state"`` path: the column is produced
+ on the *raw* input (before imputation) and kept separate from the ordinary
+ representation basis, so a downstream model can learn a dedicated response to
+ missingness rather than confounding it with an imputed value.
+
+ Unlike :class:`sklearn.impute.MissingIndicator`, this works on both numeric
+ and object (categorical) columns via :func:`pandas.isna` and always emits one
+ column per input feature.
+
+ Attributes
+ ----------
+ n_features_in_ : int
+ Number of input features seen during ``fit``.
+
+ Examples
+ --------
+ >>> import numpy as np
+ >>> from pretab.transformers import MissingStateIndicator
+ >>> X = np.array([[1.0], [np.nan], [3.0]])
+ >>> MissingStateIndicator().fit_transform(X)
+ array([[0.],
+ [1.],
+ [0.]])
+ """
+
+ def fit(self, X, y=None):
+ """Record the input feature count.
+
+ Parameters
+ ----------
+ X : array-like of shape (n_samples, n_features)
+ The input data to fit.
+ y : Ignored
+ Not used, present for API consistency by convention.
+
+ Returns
+ -------
+ self : object
+ Fitted transformer.
+ """
+ X = np.asarray(X)
+ self.n_features_in_ = X.shape[1] if X.ndim > 1 else 1
+ return self
+
+ def transform(self, X):
+ """Return a float mask (``1.0`` where missing, ``0.0`` otherwise).
+
+ Parameters
+ ----------
+ X : array-like of shape (n_samples, n_features)
+ The input data to inspect.
+
+ Returns
+ -------
+ mask : ndarray of shape (n_samples, n_features)
+ The missingness indicator as ``float``.
+ """
+ check_is_fitted(self, "n_features_in_")
+ X = np.asarray(X)
+ if X.ndim == 1:
+ X = X.reshape(-1, 1)
+ return pd.isna(X).astype(float)
+
+ def get_feature_names_out(self, input_features=None):
+ """Return the output feature names, each suffixed with ``__missing``.
+
+ Parameters
+ ----------
+ input_features : list of str or None
+ The names of the input features. When ``None``, names of the form
+ ``x0, x1, ...`` are generated.
+
+ Returns
+ -------
+ feature_names : ndarray of shape (n_features,)
+ The output feature names.
+ """
+ check_is_fitted(self, "n_features_in_")
+ if input_features is None:
+ input_features = [f"x{i}" for i in range(self.n_features_in_)]
+ return np.asarray([f"{name}__missing" for name in input_features], dtype=object)
+
+ def __sklearn_tags__(self):
+ """Declare that missing values are expected (they are the signal)."""
+ tags = super().__sklearn_tags__()
+ tags.input_tags.allow_nan = True
+ return tags
diff --git a/pretab/transformers/feature_maps/__init__.py b/pretab/transformers/feature_maps/__init__.py
index 8088789..41105fd 100644
--- a/pretab/transformers/feature_maps/__init__.py
+++ b/pretab/transformers/feature_maps/__init__.py
@@ -1,10 +1,15 @@
+from .fourier import FourierFeatureTransformer
+from .kernel_approx import NystroemFeaturesTransformer, RandomFourierFeaturesTransformer
from .rbf import RBFExpansionTransformer
from .relu import ReLUExpansionTransformer
from .sigmoid import SigmoidExpansionTransformer
from .tanh import TanhExpansionTransformer
__all__ = [
+ "FourierFeatureTransformer",
+ "NystroemFeaturesTransformer",
"RBFExpansionTransformer",
+ "RandomFourierFeaturesTransformer",
"ReLUExpansionTransformer",
"SigmoidExpansionTransformer",
"TanhExpansionTransformer",
diff --git a/pretab/transformers/feature_maps/_base.py b/pretab/transformers/feature_maps/base.py
similarity index 69%
rename from pretab/transformers/feature_maps/_base.py
rename to pretab/transformers/feature_maps/base.py
index 5b31299..6434f48 100644
--- a/pretab/transformers/feature_maps/_base.py
+++ b/pretab/transformers/feature_maps/base.py
@@ -13,13 +13,14 @@
from sklearn.utils.validation import check_is_fitted
from ...core.base import BasePreTabTransformer
-from ...core.exceptions import (
+from ...core.parameters import UNSET, validate_placement
+from ...core.supervised import warn_target_leakage
+from ...exceptions import (
IncompatibleParamsError,
InvalidParamError,
PretabDataError,
)
-from ...core.params import UNSET, validate_placement
-from ...core.selectors import CARTLocationSelector, LightGBMLocationSelector
+from ...placement.adapters import RBFPlacementAdapter
class BaseCenterExpansion(BasePreTabTransformer):
@@ -46,6 +47,9 @@ class BaseCenterExpansion(BasePreTabTransformer):
centers, reproducing the non-adaptive behavior.
"""
+ _representation_component_kind = "center"
+ _representation_supervision = "optional"
+
centers_: list
def __init__(
@@ -77,12 +81,11 @@ def _expand_column(self, x_col, centers):
def fit(self, X, y=None):
"""Place per-feature centers from a target-aware selector or quantile/uniform spacing."""
+ warn_target_leakage(self, y)
placement_strategy = self._resolve_placement_strategy()
validate_placement(self.target_aware, placement_strategy)
if self.task not in ("regression", "classification"):
- raise InvalidParamError(
- f"Invalid task. Choose 'regression' or 'classification'. Got {self.task!r}."
- )
+ raise InvalidParamError(f"Invalid task. Choose 'regression' or 'classification'. Got {self.task!r}.")
n_centers = self._resolve_param("output_dim", default=6)
min_req = self._resolve_param("min_output_dim", default=None)
max_req = self._resolve_param("max_output_dim", default=None)
@@ -92,41 +95,29 @@ def fit(self, X, y=None):
raise InvalidParamError(f"output_dim must be >= 1, got {n_centers}")
if self.target_aware and y is None:
- raise IncompatibleParamsError(
- "Target variable 'y' must be provided when target_aware=True."
- )
-
- if self.target_aware:
- # Centers come from a target-aware location selector (CART by default,
- # optionally LightGBM): split points spaced out and ranked by impurity
- # / gain. Adaptive sizing clamps each feature into [min, max]; otherwise
- # each feature keeps exactly ``output_dim`` centers.
- selector = self._build_selector(placement_strategy)
- if self.adaptive:
- min_centers, max_centers = self._resolve_output_bounds(
- n_centers, min_req, max_req, floor=1
- )
- else:
- min_centers = max_centers = n_centers
- centers_list = [
- selector.select(
- X[:, i], y, task=self.task,
- min_count=min_centers, max_count=max_centers,
- )
- for i in range(X.shape[1])
- ]
- elif placement_strategy == "quantile":
- centers_list = [
- np.percentile(X[:, i], np.linspace(0, 100, n_centers))
- for i in range(X.shape[1])
- ]
- else: # uniform
- centers_list = [
- np.linspace(X[:, i].min(), X[:, i].max(), n_centers)
- for i in range(X.shape[1])
- ]
-
- self.centers_ = centers_list
+ raise IncompatibleParamsError("Target variable 'y' must be provided when target_aware=True.")
+
+ # Centers come from the placement subsystem: a target-aware selector
+ # (CART / LightGBM) when ``target_aware``, otherwise quantile / uniform
+ # spacing across the range with the endpoints included. Adaptive sizing
+ # only takes effect on the target-aware path, clamping each feature into
+ # [min, max]; otherwise each feature keeps exactly ``output_dim`` centers.
+ adapter = RBFPlacementAdapter(
+ target_aware=self.target_aware,
+ placement_strategy=placement_strategy,
+ task=self.task,
+ random_state=self.random_state,
+ )
+ if self.target_aware and self.adaptive:
+ min_centers, max_centers = self._resolve_output_bounds(n_centers, min_req, max_req, floor=1)
+ else:
+ min_centers = max_centers = n_centers
+ y_place = y if self.target_aware else None
+ self.centers_ = []
+ for i in range(X.shape[1]):
+ if np.isnan(X[:, i]).all():
+ raise PretabDataError(f"Feature at index {i} has only NaN values")
+ self.centers_.append(adapter.get_centers(X[:, i], y_place, min_centers, max_centers))
return self
def transform(self, X):
@@ -159,16 +150,6 @@ def _resolve_placement_strategy(self) -> str:
return cast(str, self.placement_strategy)
return "cart" if self.target_aware else "quantile"
- def _build_selector(self, placement_strategy):
- """Construct the target-aware location selector named by ``placement_strategy``."""
- if placement_strategy == "cart":
- return CARTLocationSelector(random_state=self.random_state)
- if placement_strategy == "lightgbm":
- return LightGBMLocationSelector(random_state=self.random_state)
- raise InvalidParamError(
- f"Invalid placement_strategy. Choose 'cart' or 'lightgbm'. Got {placement_strategy!r}."
- )
-
def __sklearn_tags__(self):
"""Require ``y`` only when centers are placed by a target-aware selector."""
tags = super().__sklearn_tags__()
diff --git a/pretab/transformers/feature_maps/fourier.py b/pretab/transformers/feature_maps/fourier.py
new file mode 100644
index 0000000..00d1e5b
--- /dev/null
+++ b/pretab/transformers/feature_maps/fourier.py
@@ -0,0 +1,136 @@
+import numpy as np
+from sklearn.utils import check_random_state
+from sklearn.utils.validation import check_is_fitted
+
+from ...core.base import BasePreTabTransformer
+from ...exceptions import InvalidParamError
+
+_FREQUENCY_STRATEGIES = ("harmonic", "log_spaced", "random")
+
+
+class FourierFeatureTransformer(BasePreTabTransformer):
+ r"""Deterministic Fourier (sine/cosine) feature expansion for numerical data.
+
+ Expands each feature into a bank of sine/cosine pairs at data-derived
+ frequencies, giving a smooth periodic basis without requiring a known period
+ (unlike :class:`PeriodicEncodingTransformer`). The fundamental frequency is
+ set from each feature's observed range at ``fit`` time, and
+ ``frequency_strategy`` controls how the ``n_frequencies`` frequencies are
+ spread above it.
+
+ Parameters
+ ----------
+ n_frequencies : int, default=5
+ Number of frequencies (sine/cosine pairs) per input feature. Each feature
+ expands into ``2 * n_frequencies`` columns, plus one extra column when
+ ``include_original`` is set.
+ frequency_strategy : {"harmonic", "log_spaced", "random"}, default="harmonic"
+ How the frequencies are spaced above the fundamental ``2*pi / range``:
+ ``"harmonic"`` uses integer multiples ``k * fundamental``; ``"log_spaced"``
+ uses octaves ``2**(k-1) * fundamental``; ``"random"`` draws frequencies
+ from a half-normal scaled by the fundamental (seeded by ``random_state``).
+ include_original : bool, default=False
+ If ``True``, prepend the raw feature value as an extra column per feature.
+ random_state : int, RandomState instance or None, default=None
+ Seeds the ``"random"`` frequency draw. Unused by the deterministic
+ strategies.
+
+ Attributes
+ ----------
+ offsets_ : list of float
+ Per-feature origin (the observed minimum) subtracted before projection.
+ frequencies_ : list of ndarray
+ Per-feature angular frequencies used to build the sine/cosine bank.
+ n_features_in_ : int
+ Number of input features seen during ``fit``.
+ total_output_dim_ : int
+ Total number of output columns produced across all input features.
+
+ Notes
+ -----
+ For a feature :math:`x` with fitted origin :math:`x_0` and frequency
+ :math:`\omega_k`, each frequency contributes
+
+ .. math::
+
+ \left(\sin\!\left(\omega_k (x - x_0)\right),\;
+ \cos\!\left(\omega_k (x - x_0)\right)\right).
+
+ Columns are laid out per-feature: the optional raw value first, then the
+ sine block followed by the cosine block in ascending frequency order.
+
+ Examples
+ --------
+ >>> import numpy as np
+ >>> from pretab.transformers import FourierFeatureTransformer
+ >>> X = np.linspace(0, 10, 50).reshape(-1, 1)
+ >>> FourierFeatureTransformer(n_frequencies=4).fit_transform(X).shape
+ (50, 8)
+ """
+
+ _allow_nan = False
+ _feature_suffix_value = "fourier"
+ _representation_family = "fourier"
+ _representation_component_kind = "frequency"
+
+ def __init__(
+ self,
+ n_frequencies: int = 5,
+ frequency_strategy: str = "harmonic",
+ include_original: bool = False,
+ random_state: int | None = None,
+ ):
+ self.n_frequencies = n_frequencies
+ self.frequency_strategy = frequency_strategy
+ self.include_original = include_original
+ self.random_state = random_state
+
+ def _build_frequencies(self, span, rng):
+ """Return the ``n_frequencies`` angular frequencies for a feature span."""
+ fundamental = 2.0 * np.pi / span
+ k = np.arange(1, self.n_frequencies + 1)
+ if self.frequency_strategy == "harmonic":
+ return fundamental * k
+ if self.frequency_strategy == "log_spaced":
+ return fundamental * (2.0 ** (k - 1))
+ # random: half-normal spread around the fundamental.
+ return np.abs(rng.normal(loc=0.0, scale=fundamental, size=self.n_frequencies))
+
+ def fit(self, X, y=None):
+ X = self._validate(X, reset=True)
+ if not isinstance(self.n_frequencies, (int, np.integer)) or self.n_frequencies < 1:
+ raise InvalidParamError(f"n_frequencies must be a positive integer; got {self.n_frequencies!r}.")
+ if self.frequency_strategy not in _FREQUENCY_STRATEGIES:
+ raise InvalidParamError(
+ f"frequency_strategy must be one of {_FREQUENCY_STRATEGIES}; got {self.frequency_strategy!r}."
+ )
+
+ rng = check_random_state(self.random_state)
+ self.offsets_ = []
+ self.frequencies_ = []
+ for j in range(X.shape[1]):
+ column = X[:, j]
+ low = float(np.min(column))
+ span = float(np.max(column) - low)
+ if not np.isfinite(span) or span <= 0.0:
+ span = 1.0
+ self.offsets_.append(low)
+ self.frequencies_.append(self._build_frequencies(span, rng))
+ return self
+
+ def transform(self, X):
+ check_is_fitted(self, "frequencies_")
+ X = self._validate(X, reset=False)
+ blocks = []
+ for j in range(X.shape[1]):
+ column = X[:, j : j + 1]
+ angles = (column - self.offsets_[j]) * self.frequencies_[j]
+ feats = [X[:, j : j + 1]] if self.include_original else []
+ feats.append(np.sin(angles))
+ feats.append(np.cos(angles))
+ blocks.append(np.hstack(feats))
+ return np.hstack(blocks)
+
+ def _output_sizes(self) -> list[int]:
+ per_feature = 2 * self.n_frequencies + (1 if self.include_original else 0)
+ return [per_feature] * self.n_features_in_
diff --git a/pretab/transformers/feature_maps/kernel_approx.py b/pretab/transformers/feature_maps/kernel_approx.py
new file mode 100644
index 0000000..fd693c7
--- /dev/null
+++ b/pretab/transformers/feature_maps/kernel_approx.py
@@ -0,0 +1,166 @@
+import numpy as np
+from sklearn.kernel_approximation import Nystroem, RBFSampler
+from sklearn.utils.validation import check_is_fitted
+
+from ...core.base import BasePreTabTransformer
+from ...exceptions import InvalidParamError
+
+_NYSTROEM_KERNELS = ("rbf", "poly", "polynomial", "sigmoid", "laplacian", "cosine", "linear", "chi2", "additive_chi2")
+
+
+class RandomFourierFeaturesTransformer(BasePreTabTransformer):
+ r"""Random Fourier features approximating an RBF kernel map (multivariate).
+
+ Thin wrapper around :class:`sklearn.kernel_approximation.RBFSampler` that
+ jointly maps all input features into a randomized low-dimensional feature
+ space whose inner products approximate a Gaussian (RBF) kernel. This is a
+ **standalone, multivariate** transformer: it models the feature block as a
+ whole and is therefore not selectable per column through
+ :class:`~pretab.preprocessor.Preprocessor`.
+
+ Parameters
+ ----------
+ n_components : int, default=100
+ Number of Monte-Carlo random features (output columns).
+ gamma : float, default=1.0
+ Bandwidth of the approximated RBF kernel ``exp(-gamma * ||x - y||^2)``.
+ random_state : int, RandomState instance or None, default=None
+ Seeds the random projection for reproducibility.
+
+ Attributes
+ ----------
+ sampler_ : RBFSampler
+ The fitted underlying scikit-learn sampler.
+ n_features_in_ : int
+ Number of input features seen during ``fit``.
+ total_output_dim_ : int
+ Total number of output columns (equals ``n_components``).
+
+ Examples
+ --------
+ >>> import numpy as np
+ >>> from pretab.transformers import RandomFourierFeaturesTransformer
+ >>> X = np.random.default_rng(0).uniform(size=(40, 3))
+ >>> RandomFourierFeaturesTransformer(n_components=20, random_state=0).fit_transform(X).shape
+ (40, 20)
+ """
+
+ _allow_nan = False
+ _feature_suffix_value = "rff"
+ _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).
+
+ Thin wrapper around :class:`sklearn.kernel_approximation.Nystroem` that builds
+ a low-rank approximation of an arbitrary kernel by sampling ``n_components``
+ landmark rows from the training data. This is a **standalone, multivariate**
+ transformer and is not selectable per column through
+ :class:`~pretab.preprocessor.Preprocessor`.
+
+ Parameters
+ ----------
+ n_components : int, default=100
+ Number of landmark points sampled to build the approximation (output
+ columns). Clamped to the number of samples by the underlying estimator.
+ kernel : str, default="rbf"
+ Kernel passed to the underlying :class:`~sklearn.kernel_approximation.Nystroem`
+ (e.g. ``"rbf"``, ``"poly"``, ``"sigmoid"``, ``"laplacian"``, ``"cosine"``).
+ gamma : float or None, default=None
+ Kernel coefficient for the RBF / poly / sigmoid kernels. ``None`` defers
+ to the scikit-learn default (``1 / n_features``).
+ degree : float, default=3
+ Degree of the polynomial kernel (ignored by other kernels).
+ coef0 : float, default=1
+ Independent term for the poly / sigmoid kernels.
+ random_state : int, RandomState instance or None, default=None
+ Seeds the landmark sampling for reproducibility.
+
+ Attributes
+ ----------
+ nystroem_ : Nystroem
+ The fitted underlying scikit-learn estimator.
+ n_features_in_ : int
+ Number of input features seen during ``fit``.
+ total_output_dim_ : int
+ Total number of output columns (the effective number of landmarks).
+
+ Examples
+ --------
+ >>> import numpy as np
+ >>> from pretab.transformers import NystroemFeaturesTransformer
+ >>> X = np.random.default_rng(0).uniform(size=(60, 3))
+ >>> NystroemFeaturesTransformer(n_components=20, random_state=0).fit_transform(X).shape
+ (60, 20)
+ """
+
+ _allow_nan = False
+ _feature_suffix_value = "nystroem"
+ _representation_family = "nystroem"
+ _representation_scope = "multivariate"
+
+ def __init__(
+ self,
+ n_components: int = 100,
+ kernel: str = "rbf",
+ gamma: float | None = None,
+ degree: float = 3,
+ coef0: float = 1,
+ random_state: int | None = None,
+ ):
+ self.n_components = n_components
+ self.kernel = kernel
+ self.gamma = gamma
+ self.degree = degree
+ self.coef0 = coef0
+ self.random_state = random_state
+
+ def fit(self, X, y=None):
+ X = self._validate(X, reset=True)
+ if not isinstance(self.n_components, (int, np.integer)) or self.n_components < 1:
+ raise InvalidParamError(f"n_components must be a positive integer; got {self.n_components!r}.")
+ if self.kernel not in _NYSTROEM_KERNELS:
+ raise InvalidParamError(f"kernel must be one of {_NYSTROEM_KERNELS}; got {self.kernel!r}.")
+ self.nystroem_ = Nystroem(
+ kernel=self.kernel,
+ gamma=self.gamma,
+ degree=self.degree,
+ coef0=self.coef0,
+ n_components=self.n_components,
+ random_state=self.random_state,
+ ).fit(X)
+ return self
+
+ def transform(self, X):
+ check_is_fitted(self, "nystroem_")
+ X = self._validate(X, reset=False)
+ return np.asarray(self.nystroem_.transform(X))
+
+ def _output_sizes(self) -> list[int]:
+ return [np.asarray(self.nystroem_.components_).shape[0]]
diff --git a/pretab/transformers/feature_maps/rbf.py b/pretab/transformers/feature_maps/rbf.py
index d6b2ebe..d42f326 100644
--- a/pretab/transformers/feature_maps/rbf.py
+++ b/pretab/transformers/feature_maps/rbf.py
@@ -1,7 +1,7 @@
import numpy as np
-from ...core.params import UNSET
-from ._base import BaseCenterExpansion
+from ...core.parameters import UNSET
+from .base import BaseCenterExpansion
class RBFExpansionTransformer(BaseCenterExpansion):
@@ -78,6 +78,8 @@ class RBFExpansionTransformer(BaseCenterExpansion):
"""
_feature_suffix_value = "rbf"
+ _representation_family = "rbf"
+ _representation_local_support = True
def __init__(
self,
diff --git a/pretab/transformers/feature_maps/relu.py b/pretab/transformers/feature_maps/relu.py
index 8d19abd..6be6f76 100644
--- a/pretab/transformers/feature_maps/relu.py
+++ b/pretab/transformers/feature_maps/relu.py
@@ -1,7 +1,7 @@
import numpy as np
-from ...core.params import UNSET
-from ._base import BaseCenterExpansion
+from ...core.parameters import UNSET
+from .base import BaseCenterExpansion
class ReLUExpansionTransformer(BaseCenterExpansion):
@@ -74,6 +74,7 @@ class ReLUExpansionTransformer(BaseCenterExpansion):
"""
_feature_suffix_value = "relu"
+ _representation_family = "relu"
def __init__(
self,
diff --git a/pretab/transformers/feature_maps/sigmoid.py b/pretab/transformers/feature_maps/sigmoid.py
index 19e2af7..63556c4 100644
--- a/pretab/transformers/feature_maps/sigmoid.py
+++ b/pretab/transformers/feature_maps/sigmoid.py
@@ -1,8 +1,8 @@
import numpy as np
from scipy.special import expit
-from ...core.params import UNSET
-from ._base import BaseCenterExpansion
+from ...core.parameters import UNSET
+from .base import BaseCenterExpansion
class SigmoidExpansionTransformer(BaseCenterExpansion):
@@ -78,6 +78,7 @@ class SigmoidExpansionTransformer(BaseCenterExpansion):
"""
_feature_suffix_value = "sigmoid"
+ _representation_family = "sigmoid"
def __init__(
self,
diff --git a/pretab/transformers/feature_maps/tanh.py b/pretab/transformers/feature_maps/tanh.py
index ec1ccd9..653ab15 100644
--- a/pretab/transformers/feature_maps/tanh.py
+++ b/pretab/transformers/feature_maps/tanh.py
@@ -1,7 +1,7 @@
import numpy as np
-from ...core.params import UNSET
-from ._base import BaseCenterExpansion
+from ...core.parameters import UNSET
+from .base import BaseCenterExpansion
class TanhExpansionTransformer(BaseCenterExpansion):
@@ -78,6 +78,7 @@ class TanhExpansionTransformer(BaseCenterExpansion):
"""
_feature_suffix_value = "tanh"
+ _representation_family = "tanh"
def __init__(
self,
diff --git a/pretab/transformers/numerical/__init__.py b/pretab/transformers/numerical/__init__.py
new file mode 100644
index 0000000..f00d588
--- /dev/null
+++ b/pretab/transformers/numerical/__init__.py
@@ -0,0 +1,14 @@
+"""Numerical single-column transformers: binning, piecewise-linear encoding (PLE)
+and periodic encoding. Modules are moved here during the 1.0.0 restructure (Phase 1)
+and renamed to their intention-revealing public names in Phase 5.
+"""
+
+from .binning import NumericBinningTransformer
+from .periodic import PeriodicEncodingTransformer
+from .piecewise import PLETransformer
+
+__all__ = [
+ "NumericBinningTransformer",
+ "PLETransformer",
+ "PeriodicEncodingTransformer",
+]
diff --git a/pretab/transformers/numerical/binning.py b/pretab/transformers/numerical/binning.py
new file mode 100644
index 0000000..96ae750
--- /dev/null
+++ b/pretab/transformers/numerical/binning.py
@@ -0,0 +1,256 @@
+from typing import ClassVar
+
+import numpy as np
+from sklearn.base import BaseEstimator, TransformerMixin
+from sklearn.utils.validation import check_is_fitted
+
+from ...core.parameters import UNSET, AliasResolverMixin
+from ...core.representation import RepresentationSpecMixin
+from ...exceptions import InsufficientSamplesError, InvalidParamError, PretabDataError
+
+_VALID_ENCODINGS = ("ordinal", "onehot", "soft")
+_VALID_STRATEGIES = ("uniform", "quantile")
+
+
+class NumericBinningTransformer(RepresentationSpecMixin, AliasResolverMixin, TransformerMixin, BaseEstimator):
+ """Stateful binning transformer for numerical features.
+
+ The bin edges are learned once in :meth:`fit` and reused at
+ :meth:`transform` time, so the discretization never leaks information from
+ the data being transformed. Edges are placed with equal width
+ (``placement_strategy="uniform"``) or on empirical quantiles
+ (``placement_strategy="quantile"``); an explicit array of edges can also be
+ passed through ``output_dim``. Each feature is binned independently.
+
+ Parameters
+ ----------
+ output_dim : int or array-like
+ If an int, the number of bins to place from the fitted data range /
+ quantiles. If array-like, the bin edges to use directly
+ (``placement_strategy`` is then ignored). ``output_dim`` is the number of
+ *bins*, not the number of output columns: with ``encode="ordinal"`` each
+ feature emits a single column, whereas ``encode="onehot"`` /
+ ``encode="soft"`` emit one column per bin.
+ encode : {"ordinal", "onehot", "soft"}, default="ordinal"
+ How to represent the bin assignment:
+
+ * ``"ordinal"`` -- a single integer column of bin indices per feature.
+ * ``"onehot"`` -- a 0/1 indicator column per bin.
+ * ``"soft"`` -- triangular membership to the two nearest bin centers;
+ the per-row weights are non-negative and sum to 1 across the bins.
+ placement_strategy : {"uniform", "quantile"}, default="uniform"
+ How to place the learned bin edges when ``output_dim`` is an int:
+ equal-width (``"uniform"``) or equal-frequency (``"quantile"``). Ignored
+ when ``output_dim`` is an explicit array of edges.
+
+ Attributes
+ ----------
+ n_features_in_ : int
+ The number of input features seen during :meth:`fit`.
+ bin_edges_ : list of ndarray
+ The sorted, de-duplicated bin edges learned per feature.
+ n_bins_ : list of int
+ The number of bins per feature (``len(edges) - 1``).
+ total_output_dim_ : int
+ Total number of output columns. Equal to ``n_features_in_`` for
+ ``encode="ordinal"``; otherwise the sum of ``n_bins_``.
+
+ 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.
+
+ Examples
+ --------
+ >>> import numpy as np
+ >>> from pretab.transformers import NumericBinningTransformer
+ >>> X = np.linspace(0, 1, 10).reshape(-1, 1)
+ >>> NumericBinningTransformer(output_dim=4).fit_transform(X).shape
+ (10, 1)
+ >>> NumericBinningTransformer(output_dim=4, encode="onehot").fit_transform(X).shape
+ (10, 4)
+ """
+
+ _param_aliases: ClassVar[dict[str, str]] = {}
+ _representation_family = "binning"
+ _representation_component_kind = "interval"
+ _representation_local_support = True
+
+ def __init__(self, output_dim=UNSET, encode="ordinal", placement_strategy="uniform"):
+ # An int yields learned bins; an array-like is used as fixed bin edges.
+ self.output_dim = output_dim
+ self.encode = encode
+ self.placement_strategy = placement_strategy
+
+ def _check_array(self, X, *, reset):
+ """Validate ``X`` is a 2D numeric array and (re)set the feature count."""
+ X = np.asarray(X)
+ if X.ndim != 2:
+ raise PretabDataError("Input must be a 2D array of shape (n_samples, n_features).")
+ if not np.issubdtype(X.dtype, np.number):
+ try:
+ X = X.astype(np.float64)
+ except (ValueError, TypeError) as exc:
+ raise PretabDataError(
+ "NumericBinningTransformer requires numeric input: it bins continuous "
+ "values into intervals and cannot process string/categorical data. "
+ "Encode string columns with a categorical method (e.g. 'int' or "
+ "'one-hot') before binning."
+ ) from exc
+ else:
+ X = X.astype(np.float64, copy=False)
+ if np.isinf(X).any():
+ raise PretabDataError(
+ "NumericBinningTransformer received infinite values, which cannot be "
+ "placed into finite bins. Clean or clip the input before binning."
+ )
+ if np.isnan(X).any():
+ raise PretabDataError(
+ "NumericBinningTransformer received missing values (NaN), which cannot be "
+ "binned. Impute missing values (e.g. via the Preprocessor pipeline or a "
+ "SimpleImputer) before binning."
+ )
+ if reset:
+ self.n_features_in_ = X.shape[1]
+ elif X.shape[1] != self.n_features_in_:
+ raise PretabDataError(
+ f"Input has {X.shape[1]} features, but NumericBinningTransformer was fitted with {self.n_features_in_}."
+ )
+ return X
+
+ def _resolve_edges(self, column, bins_spec):
+ """Return the sorted, de-duplicated bin edges for a single feature."""
+ if isinstance(bins_spec, (int, np.integer)):
+ n_bins = int(bins_spec)
+ if n_bins < 1:
+ raise InvalidParamError("output_dim must be a positive integer bin count.")
+ lo = float(np.min(column))
+ hi = float(np.max(column))
+ if self.placement_strategy == "uniform":
+ edges = np.linspace(lo, hi, n_bins + 1)
+ else: # quantile
+ edges = np.quantile(column, np.linspace(0.0, 1.0, n_bins + 1))
+ else:
+ edges = np.asarray(bins_spec, dtype=np.float64).ravel()
+ if edges.size < 2:
+ raise InvalidParamError("Explicit bin edges must contain at least two values.")
+ edges = np.unique(edges) # sorted + de-duplicated
+ if edges.size < 2:
+ # Constant feature (or fully-tied quantiles): fall back to one bin.
+ edges = np.array([edges[0], edges[0] + 1.0])
+ return edges
+
+ def fit(self, X, y=None):
+ """Learn the per-feature bin edges.
+
+ Parameters
+ ----------
+ X : array-like of shape (n_samples, n_features)
+ Input data.
+ y : Ignored
+ Not used, present here for API consistency by convention.
+
+ Returns
+ -------
+ self : object
+ Fitted transformer.
+ """
+ X = self._check_array(X, reset=True)
+ if X.shape[0] <= 2:
+ raise InsufficientSamplesError("Input must have more than 2 observations.")
+ if self.encode not in _VALID_ENCODINGS:
+ raise InvalidParamError(f"encode must be one of {_VALID_ENCODINGS}; got {self.encode!r}.")
+ if self.placement_strategy not in _VALID_STRATEGIES:
+ raise InvalidParamError(
+ f"placement_strategy must be one of {_VALID_STRATEGIES}; got {self.placement_strategy!r}."
+ )
+
+ bins_spec = self._resolve_param("output_dim", default=UNSET)
+ if bins_spec is UNSET:
+ raise InvalidParamError("NumericBinningTransformer requires 'output_dim'.")
+
+ self.bin_edges_ = [self._resolve_edges(X[:, j], bins_spec) for j in range(X.shape[1])]
+ self.n_bins_ = [edges.size - 1 for edges in self.bin_edges_]
+ self.total_output_dim_ = self.n_features_in_ if self.encode == "ordinal" else int(sum(self.n_bins_))
+ return self
+
+ @staticmethod
+ def _bin_indices(column, edges):
+ """Assign each value to a bin using ``(a, b]`` intervals with a closed left edge."""
+ idx = np.searchsorted(edges, column, side="left") - 1
+ return np.clip(idx, 0, edges.size - 2).astype(int)
+
+ @staticmethod
+ def _soft_membership(column, edges):
+ """Return triangular membership weights to the two nearest bin centers."""
+ centers = 0.5 * (edges[:-1] + edges[1:])
+ n_bins = centers.size
+ col = np.clip(column, centers[0], centers[-1])
+ weights = np.zeros((col.size, n_bins), dtype=np.float64)
+ if n_bins == 1:
+ weights[:, 0] = 1.0
+ return weights
+ right = np.clip(np.searchsorted(centers, col, side="left"), 1, n_bins - 1)
+ left = right - 1
+ span = centers[right] - centers[left]
+ frac = np.where(span > 0, (col - centers[left]) / span, 0.0)
+ rows = np.arange(col.size)
+ weights[rows, left] = 1.0 - frac
+ weights[rows, right] += frac
+ return weights
+
+ def transform(self, X):
+ """Bin the data using the edges learned during :meth:`fit`.
+
+ Parameters
+ ----------
+ X : array-like of shape (n_samples, n_features)
+ Input data to transform.
+
+ Returns
+ -------
+ X_binned : ndarray of shape (n_samples, total_output_dim_)
+ The encoded bin assignments.
+ """
+ check_is_fitted(self, "bin_edges_")
+ X = self._check_array(X, reset=False)
+ blocks = []
+ for j in range(X.shape[1]):
+ edges = self.bin_edges_[j]
+ n_bins = self.n_bins_[j]
+ if self.encode == "ordinal":
+ blocks.append(self._bin_indices(X[:, j], edges).reshape(-1, 1))
+ elif self.encode == "onehot":
+ onehot = np.zeros((X.shape[0], n_bins), dtype=np.float64)
+ onehot[np.arange(X.shape[0]), self._bin_indices(X[:, j], edges)] = 1.0
+ blocks.append(onehot)
+ else: # soft
+ blocks.append(self._soft_membership(X[:, j], edges))
+ return np.hstack(blocks)
+
+ def get_feature_names_out(self, input_features=None):
+ """Return the names of the transformed features.
+
+ Parameters
+ ----------
+ input_features : list of str
+ The names of the input features.
+
+ Returns
+ -------
+ feature_names : list of str
+ One name per input feature for ``encode="ordinal"``; otherwise one
+ ``"{feature}_bin{k}"`` name per bin.
+ """
+ if input_features is None:
+ raise InvalidParamError("input_features must be specified")
+ if self.encode == "ordinal":
+ return list(input_features)
+ check_is_fitted(self, "n_bins_")
+ names = []
+ for feature, n_bins in zip(input_features, self.n_bins_, strict=False):
+ names.extend(f"{feature}_bin{k}" for k in range(n_bins))
+ return names
diff --git a/pretab/transformers/numerical/periodic.py b/pretab/transformers/numerical/periodic.py
new file mode 100644
index 0000000..b54102e
--- /dev/null
+++ b/pretab/transformers/numerical/periodic.py
@@ -0,0 +1,98 @@
+import numpy as np
+from sklearn.utils.validation import check_is_fitted
+
+from ...core.base import BasePreTabTransformer
+from ...exceptions import InvalidParamError, PretabDataError
+
+
+class PeriodicEncodingTransformer(BasePreTabTransformer):
+ r"""Encode a cyclical variable using sine and cosine harmonics.
+
+ Maps a periodic feature (such as hour of day or day of week) onto smooth
+ continuous features so that the cyclic boundary is continuous. Higher
+ ``harmonics`` add finer-grained sinusoids, and ``include_original`` keeps the
+ raw value alongside the trigonometric encoding.
+
+ Parameters
+ ----------
+ period : int
+ The full cycle length (e.g., 24 for hours, 7 for weekdays).
+ harmonics : int, default=1
+ The number of sine/cosine harmonic pairs to emit. Harmonic ``h`` uses the
+ angle :math:`2\pi h x / p`, so ``harmonics`` pairs contribute
+ ``2 * harmonics`` columns per input feature.
+ include_original : bool, default=False
+ If ``True``, prepend the (validated) raw value as an extra column per
+ input feature.
+
+ Notes
+ -----
+ For a value :math:`x` with period :math:`p`, each harmonic :math:`h` maps to
+
+ .. math::
+
+ \left(\sin\!\left(\frac{2\pi h x}{p}\right),\;
+ \cos\!\left(\frac{2\pi h x}{p}\right)\right).
+
+ Each input feature therefore expands into ``2 * harmonics`` columns, plus one
+ extra column when ``include_original`` is set. Columns are laid out
+ per-feature: the optional original value first, then ``(sin, cos)`` pairs in
+ ascending harmonic order.
+
+ This is a **standalone time-series utility**. Although it preserves the row
+ count, it takes a required per-feature ``period`` and constrains inputs to
+ ``[0, period]``, so it is not wired into :class:`~pretab.preprocessor.Preprocessor`
+ (which applies one method uniformly across columns). Apply it directly to the
+ relevant cyclical column instead.
+
+ Examples
+ --------
+ >>> import numpy as np
+ >>> from pretab.transformers import PeriodicEncodingTransformer
+ >>> X = np.array([[0], [6], [12], [18]])
+ >>> transformer = PeriodicEncodingTransformer(period=24)
+ >>> transformer.fit_transform(X).shape
+ (4, 2)
+ >>> PeriodicEncodingTransformer(period=24, harmonics=3).fit_transform(X).shape
+ (4, 6)
+ """
+
+ _allow_nan = False
+ _feature_suffix_value = "cyclic"
+ _representation_family = "periodic"
+ _representation_component_kind = "frequency"
+
+ def __init__(self, period: int, harmonics: int = 1, include_original: bool = False):
+ self.period = period
+ self.harmonics = harmonics
+ self.include_original = include_original
+
+ def _representation_periodic(self):
+ """Report periodicity with the configured period length."""
+ return True, float(self.period)
+
+ def fit(self, X, y=None):
+ X = self._validate(X, reset=True)
+ if not isinstance(self.harmonics, (int, np.integer)) or self.harmonics < 1:
+ raise InvalidParamError(f"harmonics must be a positive integer; got {self.harmonics!r}.")
+ if not np.all((X >= 0) & (X <= self.period)):
+ raise PretabDataError("Input should be within the range [0, period].")
+ return self
+
+ def transform(self, X):
+ check_is_fitted(self, "n_features_in_")
+ X = self._validate(X, reset=False)
+ blocks = []
+ for j in range(X.shape[1]):
+ column = X[:, j : j + 1]
+ feats = [column] if self.include_original else []
+ for harmonic in range(1, self.harmonics + 1):
+ angle = 2 * np.pi * harmonic * column / self.period
+ feats.append(np.sin(angle))
+ feats.append(np.cos(angle))
+ blocks.append(np.hstack(feats))
+ return np.hstack(blocks)
+
+ def _output_sizes(self) -> list[int]:
+ per_feature = 2 * self.harmonics + (1 if self.include_original else 0)
+ return [per_feature] * self.n_features_in_
diff --git a/pretab/transformers/ple/ple.py b/pretab/transformers/numerical/piecewise.py
similarity index 74%
rename from pretab/transformers/ple/ple.py
rename to pretab/transformers/numerical/piecewise.py
index 9c54dab..b368e56 100644
--- a/pretab/transformers/ple/ple.py
+++ b/pretab/transformers/numerical/piecewise.py
@@ -6,7 +6,6 @@
strings and no regular-expression parsing of split conditions.
"""
-import warnings
from typing import ClassVar, Literal
import numpy as np
@@ -14,17 +13,20 @@
from sklearn.utils.validation import check_array, check_is_fitted
from ...core.adaptive import AdaptiveResolutionMixin
-from ...core.exceptions import (
- DataWarning,
- EmptyDataError,
+from ...core.parameters import UNSET, AliasResolverMixin
+from ...core.representation import RepresentationSpecMixin
+from ...core.supervised import warn_target_leakage
+from ...exceptions import (
+ IncompatibleParamsError,
InvalidParamError,
PretabDataError,
)
-from ...core.params import UNSET, AliasResolverMixin
-from ...core.selectors import CARTLocationSelector, LightGBMLocationSelector
+from ...placement.adapters import PLEPlacementAdapter
-class PLETransformer(AdaptiveResolutionMixin, AliasResolverMixin, TransformerMixin, BaseEstimator):
+class PLETransformer(
+ RepresentationSpecMixin, AdaptiveResolutionMixin, AliasResolverMixin, TransformerMixin, BaseEstimator
+):
"""Piecewise Linear Encoding (PLE) transformer for numerical features.
Each feature is discretized by a target-aware location selector (``"cart"``
@@ -48,7 +50,7 @@ class PLETransformer(AdaptiveResolutionMixin, AliasResolverMixin, TransformerMix
inherently target-aware, so only the supervised selectors apply.
``"cart"`` fits a single decision tree (always available); ``"lightgbm"``
fits a gradient-boosted ensemble and requires the optional ``lightgbm``
- dependency (``pip install pretab[knots]``).
+ dependency (``pip install pretab[lightgbm]``).
task : {"regression", "classification"}, default="regression"
Whether to fit a ``DecisionTreeRegressor`` or ``DecisionTreeClassifier``
to locate the split thresholds.
@@ -62,13 +64,6 @@ class PLETransformer(AdaptiveResolutionMixin, AliasResolverMixin, TransformerMix
Maximum number of bins per feature when ``adaptive=True``.
random_state : int or None, default=51
Random state for reproducible tree fitting.
- handle_missing : {"error", "median"}, default="median"
- How to handle NaN values.
-
- - ``"error"``: raise an error when a NaN is encountered.
- - ``"median"``: drop NaN rows during ``fit`` and, at ``transform`` time,
- replace NaN with the median of that feature's thresholds (or ``0`` when
- the feature produced no thresholds).
max_depth : int or None, default=None
Maximum depth of the decision tree.
min_samples_split : int, default=2
@@ -87,8 +82,6 @@ class PLETransformer(AdaptiveResolutionMixin, AliasResolverMixin, TransformerMix
total_output_dim_ : int
Total number of output columns across all features (fitted); equals
``sum(n_bins_per_feature_)``.
- fill_values_ : list of float
- Per-feature fill value used to replace NaN during ``transform``.
Notes
-----
@@ -98,6 +91,10 @@ class PLETransformer(AdaptiveResolutionMixin, AliasResolverMixin, TransformerMix
an upper bound (bin cap), not an exact width; this is a documented exception
to the exact-width contract that the fixed-basis families follow.
+ PLE requires finite input: NaN values raise an error. Missing-value handling
+ is the responsibility of an upstream imputation step (for example the
+ ``Preprocessor`` imputation parameters), not of this transformer.
+
The ``max_depth`` / ``min_samples_split`` / ``min_samples_leaf`` parameters
are retained for backward-compatible construction but no longer affect
threshold placement: the ``placement_strategy`` selector fits its own model
@@ -112,6 +109,10 @@ class PLETransformer(AdaptiveResolutionMixin, AliasResolverMixin, TransformerMix
"""
_param_aliases: ClassVar[dict[str, str]] = {}
+ _representation_family = "piecewise_linear"
+ _representation_component_kind = "interval"
+ _representation_supervision = "supervised"
+ _representation_local_support = True
def __init__(
self,
@@ -122,7 +123,6 @@ def __init__(
min_output_dim=UNSET,
max_output_dim=UNSET,
random_state: int | None = 51,
- handle_missing: Literal["error", "median"] = "median",
max_depth: int | None = None,
min_samples_split: int = 2,
min_samples_leaf: int = 1,
@@ -134,64 +134,53 @@ def __init__(
self.min_output_dim = min_output_dim
self.max_output_dim = max_output_dim
self.random_state = random_state
- self.handle_missing = handle_missing
self.max_depth = max_depth
self.min_samples_split = min_samples_split
self.min_samples_leaf = min_samples_leaf
def __sklearn_tags__(self):
- """Declare NaN-passthrough (median policy) and the required-target tag."""
+ """Declare the required-target tag; PLE requires finite input."""
tags = super().__sklearn_tags__()
- tags.input_tags.allow_nan = self.handle_missing == "median"
+ tags.input_tags.allow_nan = False
tags.target_tags.required = True
return tags
- def fit(self, X, y):
+ def fit(self, X, y=None):
"""Fit the transformer by learning per-feature bin thresholds.
Parameters
----------
X : array-like of shape (n_samples, n_features)
- Training data.
+ Training data. Must be finite; NaN values raise an error.
y : array-like of shape (n_samples,)
- Target values used to grow the per-feature decision trees.
+ Target values used to grow the per-feature decision trees. PLE is
+ always target-aware, so ``y`` is required.
Returns
-------
self : PLETransformer
The fitted transformer.
"""
- finite_policy: Literal["allow-nan"] | bool = "allow-nan" if self.handle_missing == "median" else True
+ warn_target_leakage(self, y)
+ if y is None:
+ raise IncompatibleParamsError(
+ "PLETransformer is always target-aware and requires y at fit time; got y=None."
+ )
+
X = check_array(
X,
- dtype=np.float64,
+ dtype=np.float64, # type: ignore
ensure_2d=True,
- ensure_all_finite=finite_policy,
+ ensure_all_finite=True,
)
y = np.asarray(y).ravel()
if len(X) != len(y):
raise PretabDataError(f"X and y must have same length. Got {len(X)} and {len(y)}")
- if self.handle_missing == "median":
- valid_mask = ~(np.isnan(X).any(axis=1) | np.isnan(y))
- if not valid_mask.all():
- n_removed = int((~valid_mask).sum())
- warnings.warn(
- f"Removed {n_removed} samples with NaN values during fit",
- DataWarning,
- stacklevel=2,
- )
- X = X[valid_mask]
- y = y[valid_mask]
-
- if len(X) == 0:
- raise EmptyDataError("All samples contain NaN values")
-
self.n_features_in_ = X.shape[1]
self.thresholds_ = []
self.n_bins_per_feature_ = []
- self.fill_values_ = []
n_bins = self._resolve_param("output_dim", default=6)
min_bins_req = self._resolve_param("min_output_dim", default=None)
@@ -199,36 +188,33 @@ def fit(self, X, y):
min_bins, max_bins = self._resolve_bin_bounds(n_bins, min_bins_req, max_bins_req)
if self.task not in ("regression", "classification"):
+ raise InvalidParamError(f"Unsupported task: {self.task}. Use 'regression' or 'classification'.")
+
+ if self.placement_strategy not in ("cart", "lightgbm"):
raise InvalidParamError(
- f"Unsupported task: {self.task}. Use 'regression' or 'classification'."
+ f"Invalid placement_strategy. Choose 'cart' or 'lightgbm'. Got {self.placement_strategy!r}."
)
- # Thresholds come from a target-aware location selector (CART by default,
- # optionally LightGBM): split points spaced out and ranked by impurity /
- # gain, then trimmed / topped up to fit the bin-count window. Each feature
- # produces ``len(thresholds) + 1`` bins, so we ask for one fewer location
- # than bins: the non-adaptive window pins the count to exactly
- # ``output_dim`` bins, adaptive clamps it into ``[min, max]``.
- selector = self._build_selector()
+ # Thresholds come from the placement subsystem's target-aware adapter
+ # (CART by default, optionally LightGBM): split points spaced out and
+ # ranked by impurity / gain, then trimmed / topped up to fit the bin-count
+ # window. Each feature produces ``len(thresholds) + 1`` bins, so we ask for
+ # one fewer location than bins: the non-adaptive window pins the count to
+ # exactly ``output_dim`` bins, adaptive clamps it into ``[min, max]``.
+ adapter = PLEPlacementAdapter(
+ placement_strategy=self.placement_strategy,
+ task=self.task,
+ random_state=self.random_state,
+ )
min_thresholds = max(0, min_bins - 1)
max_thresholds = max(0, max_bins - 1)
for i in range(X.shape[1]):
- thresholds = np.sort(
- selector.select(
- X[:, i], y, task=self.task,
- min_count=min_thresholds, max_count=max_thresholds,
- )
- )
+ thresholds = adapter.get_thresholds(X[:, i], y, min_thresholds, max_thresholds)
self.thresholds_.append(thresholds)
self.n_bins_per_feature_.append(len(thresholds) + 1)
- if len(thresholds) > 0:
- self.fill_values_.append(float(np.median(thresholds)))
- else:
- self.fill_values_.append(0.0)
-
self.total_output_dim_ = int(sum(self.n_bins_per_feature_))
return self
@@ -248,12 +234,11 @@ def transform(self, X):
"""
check_is_fitted(self, ["thresholds_", "n_features_in_"])
- finite_policy: Literal["allow-nan"] | bool = "allow-nan" if self.handle_missing == "median" else True
X = check_array(
X,
- dtype=np.float64,
+ dtype=np.float64, # type: ignore
ensure_2d=True,
- ensure_all_finite=finite_policy,
+ ensure_all_finite=True,
)
if X.shape[1] != self.n_features_in_:
@@ -268,12 +253,6 @@ def transform(self, X):
feature = X[:, col].copy()
thresholds = self.thresholds_[col]
- nan_mask = np.isnan(feature)
- if nan_mask.any():
- if self.handle_missing == "error":
- raise PretabDataError(f"Feature {col} contains NaN values")
- feature[nan_mask] = self.fill_values_[col]
-
ple_encoded = self._apply_piecewise_linear_vectorized(feature, thresholds)
all_transformed.append(ple_encoded)
@@ -365,13 +344,3 @@ def get_feature_names_out(self, input_features=None):
def _resolve_bin_bounds(self, n_bins: int, min_bins_req, max_bins_req) -> tuple[int, int]:
return self._resolve_output_bounds(n_bins, min_bins_req, max_bins_req, floor=1)
-
- def _build_selector(self):
- """Construct the target-aware location selector named by ``placement_strategy``."""
- if self.placement_strategy == "cart":
- return CARTLocationSelector(random_state=self.random_state)
- if self.placement_strategy == "lightgbm":
- return LightGBMLocationSelector(random_state=self.random_state)
- raise InvalidParamError(
- f"Invalid placement_strategy. Choose 'cart' or 'lightgbm'. Got {self.placement_strategy!r}."
- )
diff --git a/pretab/transformers/onehot/__init__.py b/pretab/transformers/onehot/__init__.py
deleted file mode 100644
index 01affd9..0000000
--- a/pretab/transformers/onehot/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from .onehot import OneHotFromOrdinalTransformer
-
-__all__ = ["OneHotFromOrdinalTransformer"]
diff --git a/pretab/transformers/ple/__init__.py b/pretab/transformers/ple/__init__.py
deleted file mode 100644
index 2d175cd..0000000
--- a/pretab/transformers/ple/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from .ple import PLETransformer
-
-__all__ = ["PLETransformer"]
diff --git a/pretab/transformers/splines/__init__.py b/pretab/transformers/splines/__init__.py
index 09f294f..8339755 100644
--- a/pretab/transformers/splines/__init__.py
+++ b/pretab/transformers/splines/__init__.py
@@ -1,26 +1,18 @@
+from .b_spline import BSplineTransformer
from .base_spline import BaseSplineTransformer
-from .bspline import BSplineTransformer
-from .cubic import CubicSplineTransformer
-from .integrated_spline import ISplineTransformer
-from .knot_selectors import (
- BaseKnotSelector,
- CARTKnotSelector,
- LightGBMKnotSelector,
-)
-from .mspline import MSplineTransformer
+from .cubic_regression import CubicRegressionSplineTransformer
+from .i_spline import ISplineTransformer
+from .m_spline import MSplineTransformer
+from .multivariate.tensor_product import TensorProductSplineTransformer
+from .multivariate.thin_plate import ThinPlateSplineTransformer
from .natural_cubic import NaturalCubicSplineTransformer
-from .pspline import PSplineTransformer
-from .tensor_product import TensorProductSplineTransformer
-from .thinplate_spline import ThinPlateSplineTransformer
+from .p_spline import PSplineTransformer
__all__ = [
"BSplineTransformer",
- "BaseKnotSelector",
"BaseSplineTransformer",
- "CARTKnotSelector",
- "CubicSplineTransformer",
+ "CubicRegressionSplineTransformer",
"ISplineTransformer",
- "LightGBMKnotSelector",
"MSplineTransformer",
"NaturalCubicSplineTransformer",
"PSplineTransformer",
diff --git a/pretab/transformers/splines/bspline.py b/pretab/transformers/splines/b_spline.py
similarity index 97%
rename from pretab/transformers/splines/bspline.py
rename to pretab/transformers/splines/b_spline.py
index 72838a7..2d211ed 100644
--- a/pretab/transformers/splines/bspline.py
+++ b/pretab/transformers/splines/b_spline.py
@@ -10,7 +10,7 @@
import numpy as np
from scipy.interpolate import BSpline
-from ...core.params import UNSET
+from ...core.parameters import UNSET
from .base_spline import BaseSplineTransformer
@@ -35,6 +35,8 @@ class BSplineTransformer(BaseSplineTransformer):
(50, 9)
"""
+ _representation_family = "bspline"
+
def __init__(
self,
output_dim=UNSET,
diff --git a/pretab/transformers/splines/base_spline.py b/pretab/transformers/splines/base_spline.py
index 256de14..69bed74 100644
--- a/pretab/transformers/splines/base_spline.py
+++ b/pretab/transformers/splines/base_spline.py
@@ -20,11 +20,6 @@
from sklearn.utils.validation import check_is_fitted
from ...core.base import BasePreTabTransformer
-from ...core.exceptions import (
- IncompatibleParamsError,
- InvalidParamError,
- PretabDataError,
-)
from ...core.knots import (
basis_to_knots,
generate_internal_knots,
@@ -32,8 +27,14 @@
select_knots,
uniform_knots,
)
-from ...core.params import UNSET, validate_placement
-from .knot_selectors import BaseKnotSelector, build_knot_selector
+from ...core.parameters import UNSET, validate_placement
+from ...core.supervised import warn_target_leakage
+from ...exceptions import (
+ IncompatibleParamsError,
+ InvalidParamError,
+ PretabDataError,
+)
+from ...placement.adapters import SplinePlacementAdapter
class BaseSplineTransformer(BasePreTabTransformer):
@@ -126,6 +127,10 @@ class BaseSplineTransformer(BasePreTabTransformer):
(50, 9)
"""
+ _representation_component_kind = "basis"
+ _representation_supervision = "optional"
+ _representation_local_support = True
+
def __init__(
self,
output_dim=UNSET,
@@ -209,7 +214,7 @@ def _column_knots(
y_valid: np.ndarray | None,
n_basis: int,
strategy: str,
- selector: BaseKnotSelector | None,
+ selector: SplinePlacementAdapter | None,
min_basis_req: int | None,
max_basis_req: int | None,
) -> np.ndarray:
@@ -226,7 +231,9 @@ def _column_knots(
if self.knot_locations is not None:
expected_knots = self._basis_to_knots(n_basis)
if not self.adaptive and len(self.knot_locations) != expected_knots:
- raise IncompatibleParamsError("knot_locations length must match output_dim - degree - 1 when adaptive=False")
+ raise IncompatibleParamsError(
+ "knot_locations length must match output_dim - degree - 1 when adaptive=False"
+ )
internal_knots = self._adjust_internal_knots(x_valid, np.asarray(self.knot_locations), min_knots, max_knots)
elif selector is not None:
selected = selector.get_knot_locations(x_valid.reshape(-1, 1), y_valid, task=self.task)
@@ -245,6 +252,7 @@ def _column_knots(
def fit(self, X, y=None):
"""Determine per-feature knot vectors."""
+ warn_target_leakage(self, y)
validate_placement(self.target_aware, self.placement_strategy)
n_basis = self._resolve_param("output_dim", default=6)
min_basis_req = self._resolve_param("min_output_dim", default=None)
@@ -262,8 +270,8 @@ def fit(self, X, y=None):
# selector built from placement_strategy, then the automatic (unsupervised)
# spacing named by placement_strategy.
if self.target_aware and self.knot_locations is None:
- selector = build_knot_selector(
- self.placement_strategy,
+ selector = SplinePlacementAdapter(
+ placement_strategy=self.placement_strategy,
degree=self.degree,
spline_type=self._selector_spline_type,
random_state=self.random_state,
@@ -280,6 +288,11 @@ def fit(self, X, y=None):
xi_valid = xi[valid_mask]
if xi_valid.size == 0:
raise PretabDataError(f"Feature at index {i} has only NaN values")
+ if xi_valid.size > 1 and np.ptp(xi_valid) == 0:
+ raise PretabDataError(
+ f"Feature at index {i} is constant (all values equal {float(xi_valid[0])!r}); "
+ "a spline basis cannot be constructed on a zero-range feature."
+ )
yi_valid = y_arr[valid_mask] if y_arr is not None else None
self.knots_.append(
self._column_knots(xi_valid, yi_valid, n_basis, strategy, selector, min_basis_req, max_basis_req)
diff --git a/pretab/transformers/splines/cubic.py b/pretab/transformers/splines/cubic_regression.py
similarity index 89%
rename from pretab/transformers/splines/cubic.py
rename to pretab/transformers/splines/cubic_regression.py
index b15075d..8652cec 100644
--- a/pretab/transformers/splines/cubic.py
+++ b/pretab/transformers/splines/cubic_regression.py
@@ -2,13 +2,14 @@
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.utils.validation import check_is_fitted
-from ...core.exceptions import InvalidParamError
-from ...core.params import UNSET, validate_placement
-from .knot_selectors import build_knot_selector
+from ...core.parameters import UNSET, validate_placement
+from ...core.supervised import warn_target_leakage
+from ...exceptions import InvalidParamError
+from ...placement.adapters import SplinePlacementAdapter
from .mixins import SplineBasisMixin
-class CubicSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEstimator):
+class CubicRegressionSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEstimator):
r"""
Cubic Spline Transformer for one-dimensional or multi-dimensional input features.
@@ -100,9 +101,9 @@ class CubicSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEstimator):
Examples
--------
>>> import numpy as np
- >>> from pretab.transformers import CubicSplineTransformer
+ >>> from pretab.transformers import CubicRegressionSplineTransformer
>>> X = np.linspace(0, 1, 20).reshape(-1, 1)
- >>> transformer = CubicSplineTransformer(output_dim=8)
+ >>> transformer = CubicRegressionSplineTransformer(output_dim=8)
>>> Xt = transformer.fit_transform(X)
>>> Xt.shape
(20, 8)
@@ -113,6 +114,9 @@ class CubicSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEstimator):
"""
_feature_suffix_value = "cs"
+ _representation_family = "cubicspline"
+ _representation_supervision = "optional"
+ _representation_local_support = True
def __init__(
self,
@@ -142,7 +146,7 @@ def _bspline_basis(self, x, knots):
x = np.asarray(x).reshape(-1, 1)
n_samples = x.shape[0]
- X = [np.ones((n_samples, 1))] if self.include_bias else []
+ X: list[np.ndarray] = [np.ones((n_samples, 1))] if self.include_bias else []
X.append(x)
X.append(x**2)
X.append(x**3)
@@ -153,6 +157,7 @@ def _bspline_basis(self, x, knots):
return np.hstack(X)
def fit(self, X, y=None):
+ warn_target_leakage(self, y)
validate_placement(self.target_aware, self.placement_strategy)
X = self._validate_allow_nan(X, reset=True)
output_dim = self._resolve_param("output_dim", default=6)
@@ -163,8 +168,10 @@ def fit(self, X, y=None):
n_interior = output_dim - 3
if self.target_aware:
- selector = build_knot_selector(
- self.placement_strategy, degree=self.degree, spline_type="bspline",
+ selector = SplinePlacementAdapter(
+ placement_strategy=self.placement_strategy,
+ degree=self.degree,
+ spline_type="bspline",
random_state=self.random_state,
)
strategy = "uniform"
@@ -172,9 +179,7 @@ def fit(self, X, y=None):
selector = None
strategy = self.placement_strategy
- min_interior, max_interior = self._adaptive_interior_bounds(
- output_dim, selector, floor=3, offset=3
- )
+ min_interior, max_interior = self._adaptive_interior_bounds(output_dim, selector, floor=3, offset=3)
self.knots_ = []
self.designs_ = []
diff --git a/pretab/transformers/splines/integrated_spline.py b/pretab/transformers/splines/i_spline.py
similarity index 98%
rename from pretab/transformers/splines/integrated_spline.py
rename to pretab/transformers/splines/i_spline.py
index 127bc31..6c32265 100644
--- a/pretab/transformers/splines/integrated_spline.py
+++ b/pretab/transformers/splines/i_spline.py
@@ -11,7 +11,7 @@
import numpy as np
from scipy.interpolate import BSpline
-from ...core.params import UNSET
+from ...core.parameters import UNSET
from .base_spline import BaseSplineTransformer
@@ -39,6 +39,8 @@ class ISplineTransformer(BaseSplineTransformer):
(50, 8)
"""
+ _representation_family = "ispline"
+
def __init__(
self,
output_dim=UNSET,
diff --git a/pretab/transformers/splines/knot_selectors.py b/pretab/transformers/splines/knot_selectors.py
deleted file mode 100644
index 58a1bec..0000000
--- a/pretab/transformers/splines/knot_selectors.py
+++ /dev/null
@@ -1,272 +0,0 @@
-"""Target-aware knot selection strategies for spline transformers.
-
-A knot selector looks at a single feature and its target, then returns the
-internal knot positions a spline basis should use. Placing knots where the
-feature actually changes its relationship with the target usually produces a more
-faithful basis than spreading knots uniformly.
-
-Two strategies are provided:
-
-- :class:`CARTKnotSelector` uses a single decision tree and needs only
- scikit-learn, so it is always available.
-- :class:`LightGBMKnotSelector` uses a gradient boosted ensemble and requires the
- optional ``lightgbm`` dependency (``pip install pretab[knots]``).
-
-Both are thin, spline-aware adapters over the degree-agnostic count-based
-selectors in :mod:`pretab.core.selectors`. The adapter converts a number of
-spline basis functions into a number of internal knots (which depends on the
-spline degree) and then asks the underlying location selector for that many
-locations.
-"""
-
-from abc import ABC, abstractmethod
-from typing import Literal
-
-import numpy as np
-
-from ...core.exceptions import IncompatibleParamsError, invalid_param_error
-from ...core.knots import basis_to_knots
-from ...core.selectors import CARTLocationSelector, LightGBMLocationSelector
-
-
-class BaseKnotSelector(ABC):
- """Abstract base class for knot selection strategies.
-
- Subclasses implement :meth:`get_knot_locations`, returning the internal knot
- positions (boundary knots are added later by the spline transformer). The
- basis-to-knot conversion, which depends on the spline degree, lives here so
- the concrete selectors stay small.
-
- The following attributes are expected to be set by every subclass:
- ``degree``, ``spline_type``, ``min_knot_spacing``, ``min_knots`` and
- ``max_knots``.
- """
-
- degree: int
- spline_type: Literal["bspline", "mspline", "ispline"]
- min_knot_spacing: float
- min_knots: int
- max_knots: int
-
- @abstractmethod
- def get_knot_locations(
- self,
- X: np.ndarray,
- y: np.ndarray | None = None,
- task: Literal["regression", "classification"] | None = None,
- ) -> np.ndarray:
- """Return internal knot locations for a single feature.
-
- Parameters
- ----------
- X : np.ndarray of shape (n_samples,) or (n_samples, 1)
- Input feature values for one feature.
- y : np.ndarray of shape (n_samples,), optional
- Target values. May be None for selectors that are not target aware.
- task : {"regression", "classification"}, optional
- Type of prediction task.
-
- Returns
- -------
- knot_locations : np.ndarray
- Sorted array of internal knot locations.
- """
- raise NotImplementedError
-
- def _basis_to_knots(self, n_basis: int) -> int:
- """Convert a number of basis functions into a number of internal knots."""
- if self.spline_type in ("bspline", "mspline", "ispline"):
- return basis_to_knots(n_basis, self.degree)
- raise invalid_param_error(
- type(self).__name__, "spline_type", self.spline_type,
- "must be one of 'bspline', 'mspline', 'ispline'",
- valid={"bspline", "mspline", "ispline"},
- )
-
-
-class CARTKnotSelector(BaseKnotSelector):
- """Select knots from the split points of a single decision tree.
-
- A ``DecisionTreeRegressor`` or ``DecisionTreeClassifier`` is fitted to the
- feature against the target, and its split thresholds become the candidate
- knots. Candidates are spaced out, and if there are too many they are ranked
- by weighted impurity decrease so the most informative splits are kept.
-
- Parameters
- ----------
- max_tree_depth : int, default=6
- Maximum depth of the decision tree.
- min_samples_split : int, default=20
- Minimum samples required to split a node.
- min_samples_leaf : int, default=10
- Minimum samples required in a leaf.
- min_knot_spacing : float, default=0.01
- Minimum distance between adjacent knots, as a fraction of the feature range.
- min_basis_functions : int, default=3
- Minimum basis functions. Falls back to quantile knots if the tree yields
- fewer splits.
- max_basis_functions : int, default=15
- Maximum basis functions. The top splits are kept if the tree exceeds this.
- degree : int, default=3
- Spline degree, used to convert basis functions into internal knots.
- spline_type : {"bspline", "mspline", "ispline"}, default="bspline"
- Spline family the knots are intended for.
- random_state : int or None, default=51
- Random state for reproducibility.
- """
-
- def __init__(
- self,
- max_tree_depth: int = 6,
- min_samples_split: int = 20,
- min_samples_leaf: int = 10,
- min_knot_spacing: float = 0.01,
- min_basis_functions: int = 3,
- max_basis_functions: int = 15,
- degree: int = 3,
- spline_type: Literal["bspline", "mspline", "ispline"] = "bspline",
- random_state: int | None = 51,
- ):
- self.max_tree_depth = max_tree_depth
- self.min_samples_split = min_samples_split
- self.min_samples_leaf = min_samples_leaf
- self.min_knot_spacing = min_knot_spacing
- self.min_basis_functions = min_basis_functions
- self.max_basis_functions = max_basis_functions
- self.degree = degree
- self.spline_type = spline_type
- self.random_state = random_state
-
- self.min_knots = self._basis_to_knots(min_basis_functions)
- self.max_knots = self._basis_to_knots(max_basis_functions)
-
- self._selector = CARTLocationSelector(
- max_tree_depth=max_tree_depth,
- min_samples_split=min_samples_split,
- min_samples_leaf=min_samples_leaf,
- min_location_spacing=min_knot_spacing,
- random_state=random_state,
- )
-
- def get_knot_locations(
- self,
- X: np.ndarray,
- y: np.ndarray | None = None,
- task: Literal["regression", "classification"] | None = "regression",
- ) -> np.ndarray:
- if y is None:
- raise IncompatibleParamsError("CARTKnotSelector requires y to select knots.")
- return self._selector.select(
- X, y, task=task, min_count=self.min_knots, max_count=self.max_knots
- )
-
-
-class LightGBMKnotSelector(BaseKnotSelector):
- """Select knots from the split points of a LightGBM ensemble.
-
- A gradient boosted ensemble is fitted to the feature against the target, and
- split thresholds are ranked by their cumulative gain across all trees. This
- tends to find informative knots that a single tree can miss.
-
- Requires the optional ``lightgbm`` dependency, installable with
- ``pip install pretab[knots]``.
-
- Parameters
- ----------
- n_estimators : int, default=100
- Number of boosting rounds.
- max_depth : int, default=3
- Maximum depth of each tree.
- learning_rate : float, default=0.1
- Boosting learning rate.
- min_child_samples : int, default=20
- Minimum samples in a leaf.
- min_knot_spacing : float, default=0.01
- Minimum distance between adjacent knots, as a fraction of the feature range.
- min_basis_functions : int, default=3
- Minimum basis functions. Falls back to quantile knots if fewer splits found.
- max_basis_functions : int, default=15
- Maximum basis functions. The top-gain splits are kept if more are found.
- degree : int, default=3
- Spline degree, used to convert basis functions into internal knots.
- spline_type : {"bspline", "mspline", "ispline"}, default="bspline"
- Spline family the knots are intended for.
- random_state : int or None, default=51
- Random state for reproducibility.
- """
-
- def __init__(
- self,
- n_estimators: int = 100,
- max_depth: int = 3,
- learning_rate: float = 0.1,
- min_child_samples: int = 20,
- min_knot_spacing: float = 0.01,
- min_basis_functions: int = 3,
- max_basis_functions: int = 15,
- degree: int = 3,
- spline_type: Literal["bspline", "mspline", "ispline"] = "bspline",
- random_state: int | None = 51,
- ):
- self.n_estimators = n_estimators
- self.max_depth = max_depth
- self.learning_rate = learning_rate
- self.min_child_samples = min_child_samples
- self.min_knot_spacing = min_knot_spacing
- self.min_basis_functions = min_basis_functions
- self.max_basis_functions = max_basis_functions
- self.degree = degree
- self.spline_type = spline_type
- self.random_state = random_state
-
- self.min_knots = self._basis_to_knots(min_basis_functions)
- self.max_knots = self._basis_to_knots(max_basis_functions)
-
- self._selector = LightGBMLocationSelector(
- n_estimators=n_estimators,
- max_depth=max_depth,
- learning_rate=learning_rate,
- min_child_samples=min_child_samples,
- min_location_spacing=min_knot_spacing,
- random_state=random_state,
- )
-
- def get_knot_locations(
- self,
- X: np.ndarray,
- y: np.ndarray | None = None,
- task: Literal["regression", "classification"] | None = "regression",
- ) -> np.ndarray:
- if y is None:
- raise IncompatibleParamsError("LightGBMKnotSelector requires y to select knots.")
- return self._selector.select(
- X, y, task=task, min_count=self.min_knots, max_count=self.max_knots
- )
-
-
-def build_knot_selector(
- placement_strategy: str,
- *,
- degree: int,
- spline_type: Literal["bspline", "mspline", "ispline"] = "bspline",
- random_state: int | None = None,
-) -> BaseKnotSelector:
- """Build a target-aware knot selector from a ``placement_strategy`` name.
-
- ``placement_strategy`` must be ``"cart"`` (a single decision tree, always
- available) or ``"lightgbm"`` (a gradient-boosted ensemble, requires the
- optional ``lightgbm`` dependency). ``random_state`` is only forwarded when
- set, so an unset value keeps each selector's own default seed.
- """
- kwargs: dict = {"degree": degree, "spline_type": spline_type}
- if random_state is not None:
- kwargs["random_state"] = random_state
- if placement_strategy == "cart":
- return CARTKnotSelector(**kwargs)
- if placement_strategy == "lightgbm":
- return LightGBMKnotSelector(**kwargs)
- raise invalid_param_error(
- "build_knot_selector", "placement_strategy", placement_strategy,
- "must be 'cart' or 'lightgbm' when target_aware=True",
- valid={"cart", "lightgbm"},
- )
diff --git a/pretab/transformers/splines/mspline.py b/pretab/transformers/splines/m_spline.py
similarity index 97%
rename from pretab/transformers/splines/mspline.py
rename to pretab/transformers/splines/m_spline.py
index 500af59..0198860 100644
--- a/pretab/transformers/splines/mspline.py
+++ b/pretab/transformers/splines/m_spline.py
@@ -11,7 +11,7 @@
import numpy as np
from scipy.interpolate import BSpline
-from ...core.params import UNSET
+from ...core.parameters import UNSET
from .base_spline import BaseSplineTransformer
@@ -37,6 +37,8 @@ class MSplineTransformer(BaseSplineTransformer):
(50, 8)
"""
+ _representation_family = "mspline"
+
def __init__(
self,
output_dim=UNSET,
diff --git a/pretab/transformers/splines/mixins.py b/pretab/transformers/splines/mixins.py
index 848610b..17fe52e 100644
--- a/pretab/transformers/splines/mixins.py
+++ b/pretab/transformers/splines/mixins.py
@@ -13,8 +13,8 @@
import numpy as np
from ...core.base import BasePreTabTransformer
-from ...core.exceptions import IncompatibleParamsError
from ...core.knots import generate_internal_knots, select_knots, spanning_knots
+from ...exceptions import IncompatibleParamsError, PretabDataError
class SplineBasisMixin(BasePreTabTransformer):
@@ -49,6 +49,29 @@ def _output_sizes(self) -> list[int]:
"""Number of output columns contributed by each input feature."""
return [int(n) for n in self.n_basis_]
+ def _finite_column(self, x, y):
+ """Drop NaN samples from one feature (aligning ``y``) before knot placement.
+
+ Knots are placed from the finite values only, so a partially missing
+ feature no longer poisons ``min`` / ``max`` / quantile knots with ``NaN``;
+ the missing rows are still expanded to ``NaN`` basis rows at transform time
+ (the "propagate" contract). A fully missing feature cannot yield knots and
+ raises a :class:`~pretab.exceptions.PretabDataError`.
+ """
+ x = np.asarray(x, dtype=float)
+ finite = ~np.isnan(x)
+ if not finite.any():
+ raise PretabDataError("Feature has only NaN values; a spline basis cannot be placed.")
+ if not finite.all():
+ x = x[finite]
+ y = np.asarray(y)[finite] if y is not None else y
+ if x.size > 1 and np.ptp(x) == 0:
+ raise PretabDataError(
+ f"Feature is constant (all values equal {float(x[0])!r}); "
+ "a spline basis cannot be constructed on a zero-range feature."
+ )
+ return x, y
+
def _place_spanning_knots(self, x, y, n_basis, strategy, selector, task, min_interior=None, max_interior=None):
"""Return a spanning knot vector (endpoints included) for one feature.
@@ -60,7 +83,7 @@ def _place_spanning_knots(self, x, y, n_basis, strategy, selector, task, min_int
adaptive selector path) the number of interior knots is clamped into that
window before bracketing.
"""
- x = np.asarray(x)
+ x, y = self._finite_column(x, y)
if selector is not None:
interior = self._place_interior_knots(
x, y, n_basis - 2, strategy, selector, task, min_interior, max_interior
@@ -85,15 +108,11 @@ def _place_interior_knots(self, x, y, n_interior, strategy, selector, task, min_
``n_interior`` knots are placed with
:func:`pretab.core.knots.generate_internal_knots`.
"""
- x = np.asarray(x)
+ x, y = self._finite_column(x, y)
if selector is not None:
if y is None:
- raise IncompatibleParamsError(
- "A knot selector requires y during fit for target-aware knot placement."
- )
- selected = np.asarray(
- selector.get_knot_locations(x.reshape(-1, 1), y, task=task), dtype=float
- )
+ raise IncompatibleParamsError("A knot selector requires y during fit for target-aware knot placement.")
+ selected = np.asarray(selector.get_knot_locations(x.reshape(-1, 1), y, task=task), dtype=float)
x_min, x_max = x.min(), x.max()
selected = np.unique(selected[(selected > x_min) & (selected < x_max)])
if min_interior is None and max_interior is None:
@@ -148,8 +167,9 @@ def _adaptive_interior_bounds(self, output_dim, selector, *, floor, offset):
lo, hi = self._resolve_output_bounds(output_dim, min_req, max_req, floor=floor)
return lo - offset, hi - offset
- def _place_bspline_knots(self, x, y, output_dim, degree, strategy, selector, task,
- min_interior=None, max_interior=None):
+ def _place_bspline_knots(
+ self, x, y, output_dim, degree, strategy, selector, task, min_interior=None, max_interior=None
+ ):
"""Return the full padded B-spline knot vector for one feature.
Places ``output_dim - degree - 1`` interior knots (via
@@ -161,11 +181,9 @@ def _place_bspline_knots(self, x, y, output_dim, degree, strategy, selector, tas
adaptive selector path ``min_interior`` / ``max_interior`` clamp the
interior-knot count.
"""
- x = np.asarray(x)
+ x, y = self._finite_column(x, y)
n_interior = output_dim - degree - 1
- interior = self._place_interior_knots(
- x, y, n_interior, strategy, selector, task, min_interior, max_interior
- )
+ interior = self._place_interior_knots(x, y, n_interior, strategy, selector, task, min_interior, max_interior)
x_min, x_max = x.min(), x.max()
boundary_left = np.repeat(x_min, degree + 1)
boundary_right = np.repeat(x_max, degree + 1)
diff --git a/pretab/transformers/splines/multivariate/__init__.py b/pretab/transformers/splines/multivariate/__init__.py
new file mode 100644
index 0000000..39b5fa7
--- /dev/null
+++ b/pretab/transformers/splines/multivariate/__init__.py
@@ -0,0 +1,13 @@
+"""Multivariate spline transformers (tensor-product and thin-plate). These operate
+on the numeric block as a whole and are standalone/grouped (excluded from the
+per-column ``Preprocessor(numerical_method=...)`` whitelist). Modules are moved
+here during the 1.0.0 restructure (Phase 1).
+"""
+
+from .tensor_product import TensorProductSplineTransformer
+from .thin_plate import ThinPlateSplineTransformer
+
+__all__ = [
+ "TensorProductSplineTransformer",
+ "ThinPlateSplineTransformer",
+]
diff --git a/pretab/transformers/splines/tensor_product.py b/pretab/transformers/splines/multivariate/tensor_product.py
similarity index 97%
rename from pretab/transformers/splines/tensor_product.py
rename to pretab/transformers/splines/multivariate/tensor_product.py
index ec5dd42..bd1a0e7 100644
--- a/pretab/transformers/splines/tensor_product.py
+++ b/pretab/transformers/splines/multivariate/tensor_product.py
@@ -2,9 +2,9 @@
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.utils.validation import check_is_fitted
-from ...core.exceptions import InvalidParamError
-from ...core.params import UNSET
-from .mixins import SplineBasisMixin
+from ....core.parameters import UNSET
+from ....exceptions import InvalidParamError
+from ..mixins import SplineBasisMixin
def bspline_basis(x, knots, degree, i):
@@ -65,7 +65,7 @@ class TensorProductSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEst
.. note::
The tensor-product spline is a penalized (difference-penalty) spline
- per marginal, exactly like :class:`~pretab.transformers.splines.pspline.PSplineTransformer`,
+ per marginal, exactly like :class:`~pretab.transformers.splines.p_spline.PSplineTransformer`,
so it assumes **equally-spaced** knots and is *unsupervised-only*:
target-aware placement does not apply and only ``"uniform"`` /
``"quantile"`` spacing is accepted.
@@ -136,6 +136,10 @@ class TensorProductSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEst
36
"""
+ _representation_family = "tensorspline"
+ _representation_scope = "multivariate"
+ _representation_local_support = True
+
def __init__(
self,
output_dim=UNSET,
diff --git a/pretab/transformers/splines/multivariate/thin_plate.py b/pretab/transformers/splines/multivariate/thin_plate.py
new file mode 100644
index 0000000..0fdfa55
--- /dev/null
+++ b/pretab/transformers/splines/multivariate/thin_plate.py
@@ -0,0 +1,218 @@
+import numpy as np
+from scipy.linalg import eigh
+from scipy.spatial.distance import cdist
+from sklearn.base import BaseEstimator, TransformerMixin
+from sklearn.cluster import KMeans
+from sklearn.utils import check_random_state
+from sklearn.utils.validation import check_is_fitted
+
+from ....exceptions import InsufficientSamplesError, InvalidParamError
+from ..mixins import SplineBasisMixin
+
+_LANDMARK_STRATEGIES = ("kmeans", "subsample")
+_RANK_STRATEGIES = ("eigen", "nystroem")
+
+
+class ThinPlateSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEstimator):
+ r"""Multivariate low-rank thin-plate regression spline basis.
+
+ Builds a smooth thin-plate spline (TPS) feature map that jointly models all
+ input features. A set of ``n_components + d + 1`` landmark points is chosen
+ from the data (``d`` is the number of input features), the null-space
+ (linear-polynomial) part is projected out of the landmark TPS kernel, and the
+ leading eigenvectors of the projected kernel form a rank-``n_components``
+ basis. This landmark construction follows the low-rank thin-plate regression
+ spline of Wood (2003) and keeps the cost governed by ``n_components`` rather
+ than the sample size.
+
+ Parameters
+ ----------
+ n_components : int, default=10
+ Number of (non-bias) basis functions to emit -- the rank of the
+ approximation and the output width. Must be at least 1. Fitting requires
+ at least ``n_components + d + 1`` samples.
+ landmark_strategy : {"kmeans", "subsample"}, default="kmeans"
+ How the landmark points are chosen from the data. ``"kmeans"`` uses
+ k-means cluster centers (space-filling); ``"subsample"`` draws a random
+ subset of the observed rows.
+ rank_strategy : {"eigen", "nystroem"}, default="eigen"
+ How the reduced basis is extracted from the projected landmark kernel.
+ ``"eigen"`` keeps the leading (raw) eigenvectors; ``"nystroem"`` whitens
+ them by the inverse square-root of the eigenvalues to decorrelate the
+ features. Both emit exactly ``n_components`` columns.
+ include_bias : bool, default=False
+ If True, prepend a constant intercept column to the output. The bias term
+ is left unpenalized (a zero leading row/column is added to the penalty).
+ random_state : int, RandomState instance or None, default=None
+ Seeds the landmark selection (k-means initialization or subsampling).
+
+ Attributes
+ ----------
+ landmarks_ : ndarray of shape (n_landmarks, n_features)
+ The landmark points used to build the TPS kernel.
+ components_ : ndarray of shape (n_landmarks, n_components)
+ The linear map from a data-to-landmark kernel row to the reduced basis.
+ eigvals_ : ndarray of shape (n_components,)
+ The retained eigenvalues of the projected landmark kernel.
+ penalty_ : ndarray
+ Diagonal smoothing penalty of ``eigvals_`` (with an unpenalized leading
+ row/column when ``include_bias=True``).
+ d_ : int
+ Number of input features (also ``n_features_in_``).
+ n_basis_ : list of int
+ Single-element list with the output width (``n_components`` plus the
+ optional bias).
+ n_features_in_ : int
+ Number of input features seen during ``fit``.
+ total_output_dim_ : int
+ Total number of output columns (fitted); equals ``n_components``
+ (``+1`` when ``include_bias``).
+
+ Notes
+ -----
+ - Unlike the knot-based spline families, the thin-plate basis is kernel-based:
+ the knot-oriented options (``degree``, ``target_aware``,
+ ``placement_strategy``, ``task``) do not apply.
+ - The radial kernel depends on the input dimension: :math:`r^3` for ``d=1``,
+ :math:`r^2\log r` for ``d=2``, and the biharmonic kernel :math:`r` for
+ ``d>=3``.
+ - The construction follows the thin-plate spline theory of Wahba [1]_ and the
+ low-rank thin-plate regression spline of Wood [2]_.
+
+ References
+ ----------
+ .. [1] Wahba, G. (1990). "Spline Models for Observational Data". SIAM.
+ .. [2] Wood, S.N. (2003). "Thin plate regression splines". Journal of the
+ Royal Statistical Society: Series B.
+
+ Examples
+ --------
+ >>> import numpy as np
+ >>> from pretab.transformers import ThinPlateSplineTransformer
+ >>> X = np.random.default_rng(0).uniform(size=(60, 2))
+ >>> transformer = ThinPlateSplineTransformer(n_components=6, random_state=0)
+ >>> transformer.fit_transform(X).shape
+ (60, 6)
+ >>> transformer.total_output_dim_
+ 6
+ """
+
+ _feature_suffix_value = "tps"
+ _representation_family = "thinplate"
+ _representation_scope = "multivariate"
+
+ def __init__(
+ self,
+ n_components=10,
+ landmark_strategy="kmeans",
+ rank_strategy="eigen",
+ include_bias=False,
+ random_state=None,
+ ):
+ self.n_components = n_components
+ self.landmark_strategy = landmark_strategy
+ self.rank_strategy = rank_strategy
+ self.include_bias = include_bias
+ self.random_state = random_state
+
+ @staticmethod
+ def _tps_kernel(r, d):
+ """Return the thin-plate radial kernel for input dimension ``d``."""
+ with np.errstate(divide="ignore", invalid="ignore"):
+ if d == 1:
+ return r**3
+ if d == 2:
+ return np.where(r > 0, r**2 * np.log(np.where(r > 0, r, 1.0)), 0.0)
+ # d >= 3: biharmonic (linear) radial kernel.
+ return r
+
+ def _select_landmarks(self, X, n_landmarks, rng):
+ """Choose ``n_landmarks`` landmark points from ``X``."""
+ n = X.shape[0]
+ if n_landmarks >= n:
+ return X
+ if self.landmark_strategy == "kmeans":
+ return KMeans(n_clusters=n_landmarks, random_state=rng, n_init=10).fit(X).cluster_centers_ # type: ignore
+ idx = rng.choice(n, size=n_landmarks, replace=False)
+ return X[idx]
+
+ def fit(self, X, y=None):
+ X = self._validate_allow_nan(X, reset=True)
+
+ if not isinstance(self.n_components, (int, np.integer)) or self.n_components < 1:
+ raise InvalidParamError(f"n_components must be a positive integer; got {self.n_components!r}.")
+ if self.landmark_strategy not in _LANDMARK_STRATEGIES:
+ raise InvalidParamError(
+ f"landmark_strategy must be one of {_LANDMARK_STRATEGIES}; got {self.landmark_strategy!r}."
+ )
+ if self.rank_strategy not in _RANK_STRATEGIES:
+ raise InvalidParamError(f"rank_strategy must be one of {_RANK_STRATEGIES}; got {self.rank_strategy!r}.")
+
+ n, d = X.shape
+ n_landmarks = self.n_components + d + 1
+ if n < n_landmarks:
+ raise InsufficientSamplesError(
+ f"ThinPlateSplineTransformer with n_components={self.n_components} on {d} feature(s) "
+ f"needs at least {n_landmarks} samples; got {n}."
+ )
+
+ rng = check_random_state(self.random_state)
+ C = np.asarray(self._select_landmarks(X, n_landmarks, rng), dtype=float)
+ length = C.shape[0]
+ self.landmarks_ = C
+
+ # Project out the linear-polynomial null space on the landmarks.
+ T = np.hstack([np.ones((length, 1)), C])
+ P = np.eye(length) - T @ np.linalg.pinv(T.T @ T) @ T.T
+
+ K = self._tps_kernel(cdist(C, C), d)
+ K_proj = P @ K @ P
+ K_proj = 0.5 * (K_proj + K_proj.T) # symmetrize against round-off
+
+ eigvals, eigvecs = eigh(K_proj)
+ order = np.argsort(np.abs(eigvals))[::-1][: self.n_components]
+ eigvals = eigvals[order]
+ eigvecs = eigvecs[:, order]
+ self.eigvals_ = eigvals
+
+ if self.rank_strategy == "nystroem":
+ scale = 1.0 / np.sqrt(np.clip(np.abs(eigvals), 1e-12, None))
+ else: # eigen
+ scale = np.full(self.n_components, np.sqrt(length))
+ # ``components_`` maps a raw data->landmark kernel row into the basis.
+ self.components_ = P @ (eigvecs * scale)
+
+ penalty = np.diag(eigvals)
+ if self.include_bias:
+ penalty = np.pad(penalty, ((1, 0), (1, 0)))
+ self.penalty_ = penalty
+ self.d_ = d
+ self.n_basis_ = [self.n_components + (1 if self.include_bias else 0)]
+ return self
+
+ def transform(self, X):
+ check_is_fitted(self, "components_")
+ X = self._validate_allow_nan(X, reset=False)
+ K_new = self._tps_kernel(cdist(X, self.landmarks_), self.d_)
+ out = K_new @ self.components_
+ if self.include_bias:
+ out = np.hstack([np.ones((out.shape[0], 1)), out])
+ return out
+
+ def get_penalty_matrix(self, feature_index=0):
+ """Return the smoothing penalty matrix for the fitted basis.
+
+ Parameters
+ ----------
+ feature_index : int, default=0
+ Accepted for signature parity with the other spline transformers;
+ ignored because the thin-plate basis is a single joint expansion.
+
+ Returns
+ -------
+ penalty_ : ndarray
+ Diagonal penalty of the retained eigenvalues (with an unpenalized
+ leading row/column when ``include_bias=True``).
+ """
+ check_is_fitted(self, "penalty_")
+ return self.penalty_
diff --git a/pretab/transformers/splines/natural_cubic.py b/pretab/transformers/splines/natural_cubic.py
index 43b1709..a76e202 100644
--- a/pretab/transformers/splines/natural_cubic.py
+++ b/pretab/transformers/splines/natural_cubic.py
@@ -2,9 +2,10 @@
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.utils.validation import check_is_fitted
-from ...core.exceptions import InvalidParamError
-from ...core.params import UNSET, validate_placement
-from .knot_selectors import build_knot_selector
+from ...core.parameters import UNSET, validate_placement
+from ...core.supervised import warn_target_leakage
+from ...exceptions import InvalidParamError
+from ...placement.adapters import SplinePlacementAdapter
from .mixins import SplineBasisMixin
@@ -117,6 +118,9 @@ class NaturalCubicSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEsti
"""
_feature_suffix_value = "ncs"
+ _representation_family = "naturalspline"
+ _representation_supervision = "optional"
+ _representation_local_support = True
def __init__(
self,
@@ -148,7 +152,7 @@ def _basis(self, x, knots):
n_samples = x.shape[0]
n_knots = len(K)
- basis = [np.ones((n_samples, 1))] if self.include_bias else []
+ basis: list[np.ndarray] = [np.ones((n_samples, 1))] if self.include_bias else []
basis.append(x)
def omega(z, k):
@@ -163,20 +167,21 @@ def d(k):
return np.hstack(basis)
def fit(self, X, y=None):
+ warn_target_leakage(self, y)
validate_placement(self.target_aware, self.placement_strategy)
X = self._validate_allow_nan(X, reset=True)
output_dim = self._resolve_param("output_dim", default=6)
if output_dim < 2:
- raise InvalidParamError(
- f"output_dim must be >= 2 for the natural cubic spline basis, got {output_dim}"
- )
+ raise InvalidParamError(f"output_dim must be >= 2 for the natural cubic spline basis, got {output_dim}")
n_spanning = output_dim + 1
if self.target_aware:
- selector = build_knot_selector(
- self.placement_strategy, degree=self.degree, spline_type="bspline",
+ selector = SplinePlacementAdapter(
+ placement_strategy=self.placement_strategy,
+ degree=self.degree,
+ spline_type="bspline",
random_state=self.random_state,
)
strategy = "uniform"
@@ -184,9 +189,7 @@ def fit(self, X, y=None):
selector = None
strategy = self.placement_strategy
- min_interior, max_interior = self._adaptive_interior_bounds(
- output_dim, selector, floor=2, offset=1
- )
+ min_interior, max_interior = self._adaptive_interior_bounds(output_dim, selector, floor=2, offset=1)
self.knots_ = []
self.designs_ = []
diff --git a/pretab/transformers/splines/pspline.py b/pretab/transformers/splines/p_spline.py
similarity index 91%
rename from pretab/transformers/splines/pspline.py
rename to pretab/transformers/splines/p_spline.py
index 2c6fbf4..022047f 100644
--- a/pretab/transformers/splines/pspline.py
+++ b/pretab/transformers/splines/p_spline.py
@@ -2,8 +2,8 @@
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.utils.validation import check_is_fitted
-from ...core.exceptions import InvalidParamError
-from ...core.params import UNSET
+from ...core.parameters import UNSET
+from ...exceptions import InvalidParamError
from .mixins import SplineBasisMixin
@@ -59,15 +59,15 @@ class PSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEstimator):
If True, prepend a constant intercept column per feature. The bias term is
left unpenalized (a zero row/column is added to the penalty matrix).
- placement_strategy : {"uniform", "quantile"}, default="uniform"
- Interior-knot placement rule. ``"uniform"`` spaces knots evenly across the
- range; ``"quantile"`` places them at evenly spaced data quantiles.
+ placement_strategy : {"uniform"}, default="uniform"
+ Interior-knot placement rule. Only ``"uniform"`` (evenly spaced knots
+ across the range) is supported.
.. note::
P-splines are penalized (difference-penalty) splines that assume
- **equally-spaced** knots, so this family is *unsupervised-only*:
- target-aware placement does not apply and only ``"uniform"`` /
- ``"quantile"`` spacing is accepted.
+ **equally-spaced** knots, so this family is *unsupervised-only* and
+ requires uniform spacing: target-aware and quantile placement do not
+ apply and only ``"uniform"`` spacing is accepted.
adaptive : bool, default=False
Retained for API parity but a no-op for this unsupervised-only family: the
@@ -125,6 +125,8 @@ class PSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEstimator):
"""
_feature_suffix_value = "ps"
+ _representation_family = "pspline"
+ _representation_local_support = True
def __init__(
self,
@@ -149,9 +151,10 @@ def __init__(
def fit(self, X, y=None):
X = self._validate_allow_nan(X, reset=True)
output_dim = self._resolve_param("output_dim", default=6)
- if self.placement_strategy not in ("uniform", "quantile"):
+ if self.placement_strategy != "uniform":
raise InvalidParamError(
- f"Invalid placement_strategy. Choose 'uniform' or 'quantile'. Got {self.placement_strategy!r}."
+ f"Invalid placement_strategy. P-splines require equally-spaced knots, "
+ f"so only 'uniform' is supported. Got {self.placement_strategy!r}."
)
strategy = self.placement_strategy
diff --git a/pretab/transformers/splines/thinplate_spline.py b/pretab/transformers/splines/thinplate_spline.py
deleted file mode 100644
index 65bfea0..0000000
--- a/pretab/transformers/splines/thinplate_spline.py
+++ /dev/null
@@ -1,177 +0,0 @@
-import numpy as np
-from scipy.linalg import eigh
-from scipy.spatial.distance import cdist
-from sklearn.base import BaseEstimator, TransformerMixin
-from sklearn.utils.validation import check_is_fitted
-
-from ...core.exceptions import InvalidParamError, PretabDataError
-from .mixins import SplineBasisMixin
-
-
-class ThinPlateSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEstimator):
- r"""
- Thin Plate Spline Transformer for smooth univariate basis expansion.
-
- This transformer constructs a smooth, nonparametric basis using eigen-decomposed
- thin plate spline (TPS) kernels. It supports only univariate input and is useful
- for modeling smooth nonlinear functions in regression tasks. The basis functions
- are the leading eigenvectors of the projected TPS kernel matrix.
-
- Let :math:`m := \mathtt{output\_dim}` be the number of non-bias output columns.
- The transformer keeps the top :math:`m` eigenvectors, so the output width equals
- ``output_dim`` directly (this family is knot-free; there is no knot inversion).
- ``include_bias=True`` adds one further intercept column.
-
- Parameters
- ----------
- output_dim : int, default=6
- Number of non-bias output columns (:math:`m`) extracted from the
- eigen-decomposition of the TPS kernel. Must be at least 1.
-
- include_bias : bool, default=False
- If True, prepend a constant intercept column to the output. The bias term
- is left unpenalized (a zero row/column is added to the penalty matrix).
-
- Attributes
- ----------
- x_ : ndarray of shape (n_samples, 1)
- Training input used to compute the TPS kernel and projection matrix.
-
- Z_ : ndarray of shape (n_samples, 2)
- Matrix containing intercept and linear term (used for null space projection).
-
- eigvals_ : ndarray of shape (output_dim,)
- Top eigenvalues from the projected kernel matrix.
-
- basis_ : ndarray of shape (n_samples, output_dim)
- Orthogonal basis functions corresponding to the top eigenvectors.
-
- penalty_ : ndarray of shape (output_dim, output_dim)
- Diagonal penalty matrix containing eigenvalues (used for smoothing regularization).
-
- n_basis_ : list of int
- Number of output columns for the single feature, including the optional bias.
-
- n_features_in_ : int
- Number of input features seen during ``fit`` (always 1).
-
- total_output_dim_ : int
- Total number of output columns (fitted); equals
- ``output_dim (+1 if include_bias)``.
-
- Notes
- -----
- - Input must be univariate. Multivariate input will raise a ValueError.
- - Basis functions are derived from a kernel matrix projected onto the orthogonal complement of the null space
- of the linear terms (intercept and slope) [1]_.
- - The transformer uses an eigendecomposition of the projected TPS kernel to define the basis [2]_.
- - The transformer is kernel-based rather than knot-based, so the knot-oriented options shared by the other
- splines (``degree``, ``target_aware``, ``placement_strategy``, ``task``) do not apply here.
-
- References
- ----------
- .. [1] Wahba, G. (1990). "Spline Models for Observational Data". SIAM.
- .. [2] Wood, S.N. (2003). "Thin plate regression splines". Journal of the
- Royal Statistical Society: Series B.
-
- Examples
- --------
- >>> import numpy as np
- >>> from pretab.transformers import ThinPlateSplineTransformer
- >>> X = np.linspace(0, 1, 30).reshape(-1, 1)
- >>> transformer = ThinPlateSplineTransformer(output_dim=6)
- >>> Xt = transformer.fit_transform(X)
- >>> Xt.shape
- (30, 6)
- >>> transformer.total_output_dim_
- 6
- """
-
- _feature_suffix_value = "tps"
-
- def __init__(self, output_dim=6, include_bias=False):
- self.output_dim = output_dim
- self.include_bias = include_bias
-
- def _tps_kernel(self, r):
- with np.errstate(divide="ignore", invalid="ignore"):
- log_r = np.where(r == 0, 0, np.log(r))
- K = r**2 * log_r
- K[r == 0] = 0
- return K
-
- def fit(self, X, y=None):
- X = self._validate_allow_nan(X, reset=True)
-
- if X.shape[1] > 1:
- raise PretabDataError("ThinPlateSplineTransformer supports only univariate input.")
-
- if self.output_dim < 1:
- raise InvalidParamError(f"output_dim must be >= 1, got {self.output_dim}")
-
- x = X.reshape(-1, 1)
- self.x_ = x
- n = x.shape[0]
-
- Z = np.hstack([np.ones_like(x), x])
- self.Z_ = Z
-
- r = cdist(x, x, metric="euclidean")
- K = self._tps_kernel(r)
-
- ZTZ_inv = np.linalg.pinv(Z.T @ Z)
- P = np.eye(n) - Z @ ZTZ_inv @ Z.T
- KP = P @ K @ P
-
- eigvals, eigvecs = eigh(KP)
- idx = np.argsort(eigvals)[::-1]
- eigvals = eigvals[idx]
- eigvecs = eigvecs[:, idx]
-
- self.eigvals_ = eigvals[: self.output_dim]
- self.basis_ = eigvecs[:, : self.output_dim] * np.sqrt(n)
- penalty = np.diag(self.eigvals_)
- if self.include_bias:
- penalty = np.pad(penalty, ((1, 0), (1, 0)))
- self.penalty_ = penalty
- self.n_basis_ = [self.basis_.shape[1] + (1 if self.include_bias else 0)]
-
- return self
-
- def transform(self, X):
- check_is_fitted(self, "basis_")
- X = self._validate_allow_nan(X, reset=False)
- if X.shape[1] > 1:
- raise PretabDataError("ThinPlateSplineTransformer supports only univariate input.")
-
- x_new = X.reshape(-1, 1)
- r_new = cdist(x_new, self.x_, metric="euclidean")
- K_new = self._tps_kernel(r_new)
-
- Z = self.Z_
- ZTZ_inv = np.linalg.pinv(Z.T @ Z)
- P_new = np.eye(Z.shape[0]) - Z @ ZTZ_inv @ Z.T
- K_new_proj = K_new @ P_new
-
- out = K_new_proj @ self.basis_
- if self.include_bias:
- out = np.hstack([np.ones((out.shape[0], 1)), out])
- return out
-
- def get_penalty_matrix(self, feature_index=0):
- """Return the smoothing penalty matrix for the fitted basis.
-
- Parameters
- ----------
- feature_index : int, default=0
- Accepted for signature parity with the other spline transformers;
- ignored because the thin-plate transformer is univariate.
-
- Returns
- -------
- penalty_ : ndarray of shape (output_dim, output_dim)
- Diagonal penalty matrix of eigenvalues used for regularization (with
- an unpenalized leading row/column when ``include_bias=True``).
- """
- check_is_fitted(self, "penalty_")
- return self.penalty_
diff --git a/pretab/transformers/temporal/__init__.py b/pretab/transformers/temporal/__init__.py
deleted file mode 100644
index 9166444..0000000
--- a/pretab/transformers/temporal/__init__.py
+++ /dev/null
@@ -1,21 +0,0 @@
-"""Standalone time-series transformers.
-
-These transformers are **not** part of the :class:`~pretab.preprocessor.Preprocessor`
-pipeline. ``LagFeatureTransformer`` and ``RollingStatsTransformer`` intentionally
-change the row count (they drop the initial, incomplete windows) and assume the
-rows are ordered in time, so they cannot be used inside the
-:class:`~sklearn.compose.ColumnTransformer` the preprocessor builds.
-``CyclicalTimeTransformer`` preserves the row count but requires a per-feature
-``period`` argument, so it is also applied directly rather than routed through the
-pipeline. Use them standalone on ordered arrays.
-"""
-
-from .cyclic import CyclicalTimeTransformer
-from .lag import LagFeatureTransformer
-from .rolling_stats import RollingStatsTransformer
-
-__all__ = [
- "CyclicalTimeTransformer",
- "LagFeatureTransformer",
- "RollingStatsTransformer",
-]
diff --git a/pretab/transformers/temporal/cyclic.py b/pretab/transformers/temporal/cyclic.py
deleted file mode 100644
index bd8d87c..0000000
--- a/pretab/transformers/temporal/cyclic.py
+++ /dev/null
@@ -1,67 +0,0 @@
-import numpy as np
-from sklearn.utils.validation import check_is_fitted
-
-from ...core.base import BasePreTabTransformer
-from ...core.exceptions import PretabDataError
-
-
-class CyclicalTimeTransformer(BasePreTabTransformer):
- r"""Encode a cyclical time variable using sine and cosine components.
-
- Maps a periodic integer feature (such as hour of day or day of week) onto two
- continuous features so that the cyclic boundary is continuous.
-
- Parameters
- ----------
- period : int
- The full cycle length (e.g., 24 for hours, 7 for weekdays).
-
- Notes
- -----
- For a value :math:`x` with period :math:`p`, the encoding is
-
- .. math::
-
- \left(\sin\!\left(\frac{2\pi x}{p}\right),\;
- \cos\!\left(\frac{2\pi x}{p}\right)\right).
-
- Each input feature therefore expands into two output columns.
-
- This is a **standalone time-series utility**. Although it preserves the row
- count, it takes a required per-feature ``period`` and constrains inputs to
- ``[0, period]``, so it is not wired into :class:`~pretab.preprocessor.Preprocessor`
- (which applies one method uniformly across columns). Apply it directly to the
- relevant cyclical column instead.
-
- Examples
- --------
- >>> import numpy as np
- >>> from pretab.transformers import CyclicalTimeTransformer
- >>> X = np.array([[0], [6], [12], [18]])
- >>> transformer = CyclicalTimeTransformer(period=24)
- >>> transformer.fit_transform(X).shape
- (4, 2)
- """
-
- _allow_nan = False
- _feature_suffix_value = "cyclic"
-
- def __init__(self, period: int):
- self.period = period
-
- def fit(self, X, y=None):
- X = self._validate(X, reset=True)
- if not np.all((X >= 0) & (X <= self.period)):
- raise PretabDataError("Input should be within the range [0, period].")
- return self
-
- def transform(self, X):
- check_is_fitted(self, "n_features_in_")
- X = self._validate(X, reset=False)
- angle = 2 * np.pi * X / self.period
- sin = np.sin(angle)
- cos = np.cos(angle)
- return np.hstack([sin, cos])
-
- def _output_sizes(self) -> list[int]:
- return [2] * self.n_features_in_
diff --git a/pretab/transformers/temporal/lag.py b/pretab/transformers/temporal/lag.py
deleted file mode 100644
index 68eda95..0000000
--- a/pretab/transformers/temporal/lag.py
+++ /dev/null
@@ -1,65 +0,0 @@
-import numpy as np
-from sklearn.utils.validation import check_is_fitted
-
-from ...core.base import BasePreTabTransformer
-from ...core.exceptions import InsufficientSamplesError
-
-
-class LagFeatureTransformer(BasePreTabTransformer):
- """Create lagged features for time-series inputs.
-
- For each input column, previous time steps are appended as additional
- features, which is useful for autoregressive modeling.
-
- Parameters
- ----------
- n_lags : int, default=1
- Number of lag steps to include.
-
- Notes
- -----
- Because the first ``n_lags`` observations have no complete history, the
- transformed output has ``n_samples - n_lags`` rows. Each input feature is
- expanded into ``n_lags`` lagged columns.
-
- This is a **standalone time-series utility**. It intentionally changes the
- row count and assumes the rows are ordered in time, so it does not satisfy
- the row-count-preserving contract that :class:`~sklearn.compose.ColumnTransformer`
- (and therefore :class:`~pretab.preprocessor.Preprocessor`) require. Apply it
- directly to an ordered array rather than routing it through the preprocessing
- pipeline.
-
- Examples
- --------
- >>> import numpy as np
- >>> from pretab.transformers import LagFeatureTransformer
- >>> X = np.arange(6).reshape(-1, 1)
- >>> transformer = LagFeatureTransformer(n_lags=2)
- >>> transformer.fit_transform(X).shape
- (4, 2)
- """
-
- _allow_nan = False
- _feature_suffix_value = "lag"
-
- def __init__(self, n_lags=1):
- self.n_lags = n_lags
-
- def fit(self, X, y=None):
- X = self._validate(X, reset=True)
- if X.shape[0] <= self.n_lags:
- raise InsufficientSamplesError("n_lags must be smaller than the number of samples.")
- return self
-
- def transform(self, X):
- check_is_fitted(self, "n_features_in_")
- X = self._validate(X, reset=False)
- n_samples = X.shape[0]
- if n_samples <= self.n_lags:
- raise InsufficientSamplesError("n_lags must be smaller than the number of samples.")
-
- lagged = [X[self.n_lags - i: -i or None] for i in range(1, self.n_lags + 1)]
- return np.hstack(lagged)
-
- def _output_sizes(self) -> list[int]:
- return [self.n_lags] * self.n_features_in_
diff --git a/pretab/transformers/temporal/rolling_stats.py b/pretab/transformers/temporal/rolling_stats.py
deleted file mode 100644
index 0bcbc74..0000000
--- a/pretab/transformers/temporal/rolling_stats.py
+++ /dev/null
@@ -1,86 +0,0 @@
-import numpy as np
-from sklearn.utils.validation import check_is_fitted
-
-from ...core.base import BasePreTabTransformer
-from ...core.exceptions import InsufficientSamplesError, invalid_param_error
-
-
-class RollingStatsTransformer(BasePreTabTransformer):
- """Compute rolling-window statistics over time-series inputs.
-
- A sliding window of fixed size is moved across each feature and the requested
- summary statistics are computed within each window.
-
- Parameters
- ----------
- window_size : int, default=5
- Number of consecutive observations in each rolling window.
- stats : tuple of str, default=("mean", "std")
- Statistics to compute. Any of ``"mean"``, ``"std"``, ``"min"``, ``"max"``.
-
- Notes
- -----
- Using a sliding window of size ``window_size`` yields
- ``n_samples - window_size + 1`` output rows. Each requested statistic adds one
- column per input feature.
-
- This is a **standalone time-series utility**. It intentionally changes the
- row count and assumes the rows are ordered in time, so it does not satisfy
- the row-count-preserving contract that :class:`~sklearn.compose.ColumnTransformer`
- (and therefore :class:`~pretab.preprocessor.Preprocessor`) require. Apply it
- directly to an ordered array rather than routing it through the preprocessing
- pipeline.
-
- Examples
- --------
- >>> import numpy as np
- >>> from pretab.transformers import RollingStatsTransformer
- >>> X = np.arange(10).reshape(-1, 1).astype(float)
- >>> transformer = RollingStatsTransformer(window_size=3, stats=("mean", "std"))
- >>> transformer.fit_transform(X).shape
- (8, 2)
- """
-
- _allow_nan = False
- _feature_suffix_value = "roll"
-
- def __init__(self, window_size=5, stats=("mean", "std")):
- self.window_size = window_size
- self.stats = stats
-
- def fit(self, X, y=None):
- X = self._validate(X, reset=True)
- if X.shape[0] < self.window_size:
- raise InsufficientSamplesError("window_size must be less than number of samples.")
- return self
-
- def transform(self, X):
- check_is_fitted(self, "n_features_in_")
- X = self._validate(X, reset=False)
- n_samples = X.shape[0]
- if n_samples < self.window_size:
- raise InsufficientSamplesError("Insufficient samples for the given window size.")
-
- results = []
- for stat in self.stats:
- rolled = np.lib.stride_tricks.sliding_window_view(X, self.window_size, axis=0)
- if stat == "mean":
- stat_val = rolled.mean(axis=2)
- elif stat == "std":
- stat_val = rolled.std(axis=2)
- elif stat == "min":
- stat_val = rolled.min(axis=2)
- elif stat == "max":
- stat_val = rolled.max(axis=2)
- else:
- raise invalid_param_error(
- type(self).__name__, "stats", stat,
- "each stat must be one of 'mean', 'std', 'min', 'max'",
- valid={"mean", "std", "min", "max"},
- )
- results.append(stat_val)
-
- return np.hstack(results)
-
- def _output_sizes(self) -> list[int]:
- return [len(self.stats)] * self.n_features_in_
diff --git a/pretab/utils/__init__.py b/pretab/utils/__init__.py
deleted file mode 100644
index 977f6ec..0000000
--- a/pretab/utils/__init__.py
+++ /dev/null
@@ -1,16 +0,0 @@
-"""Backward-compatible shim.
-
-The assembly layer moved to :mod:`pretab.pipeline`; the step factories are
-re-exported here so existing ``from pretab.utils import ...`` imports keep
-working.
-"""
-
-from ..pipeline import (
- get_categorical_transformer_steps,
- get_numerical_transformer_steps,
-)
-
-__all__ = [
- "get_categorical_transformer_steps",
- "get_numerical_transformer_steps",
-]
diff --git a/pretab/utils/get_categorical.py b/pretab/utils/get_categorical.py
deleted file mode 100644
index d996c9a..0000000
--- a/pretab/utils/get_categorical.py
+++ /dev/null
@@ -1,10 +0,0 @@
-"""Backward-compatible shim.
-
-``get_categorical_transformer_steps`` moved to
-:mod:`pretab.pipeline.categorical`; it is re-exported here so existing
-``from pretab.utils.get_categorical import ...`` imports keep working.
-"""
-
-from ..pipeline.categorical import get_categorical_transformer_steps
-
-__all__ = ["get_categorical_transformer_steps"]
diff --git a/pretab/utils/get_numerical.py b/pretab/utils/get_numerical.py
deleted file mode 100644
index 5cd080c..0000000
--- a/pretab/utils/get_numerical.py
+++ /dev/null
@@ -1,11 +0,0 @@
-"""Backward-compatible shim.
-
-``get_numerical_transformer_steps`` moved to
-:mod:`pretab.pipeline.numerical`; it is re-exported here so existing
-``from pretab.utils.get_numerical import ...`` imports keep working.
-"""
-
-from ..pipeline.numerical import get_numerical_transformer_steps
-
-__all__ = ["get_numerical_transformer_steps"]
-
diff --git a/pyproject.toml b/pyproject.toml
index 8475dcc..a333855 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -13,7 +13,7 @@ dynamic = ["dependencies"]
[project.optional-dependencies]
embeddings = ["sentence-transformers>=2.0"]
-knots = ["lightgbm>=4.0"]
+lightgbm = ["lightgbm>=4.0"]
all = ["sentence-transformers>=2.0", "lightgbm>=4.0"]
[project.urls]
@@ -60,6 +60,9 @@ accessible-pygments = ">=0.0.4"
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
+markers = [
+ "smoke: fast end-to-end sanity checks run as a dedicated CI gate",
+]
norecursedirs = [
"dev",
"docs",
@@ -67,6 +70,9 @@ norecursedirs = [
".venv",
]
filterwarnings = [
+ # An unexpected LeakageWarning fails the suite; modules that fit supervised
+ # transformers directly opt out via tests/conftest.py.
+ "error::pretab.exceptions.LeakageWarning",
# scikit-learn / scipy deprecation noise
"ignore::DeprecationWarning:sklearn",
"ignore::DeprecationWarning:scipy",
diff --git a/scripts/quickstart.py b/scripts/quickstart.py
new file mode 100644
index 0000000..dea8cbf
--- /dev/null
+++ b/scripts/quickstart.py
@@ -0,0 +1,192 @@
+"""End-to-end sanity check for PreTab, doubling as a CI smoke test script. Run it with::
+
+ python scripts/quickstart.py
+
+Each check exercises a distinct part of the public API against a fixed,
+synthetic dataset and prints a single-line result. The script exits with a
+non-zero status if any check fails or raises.
+"""
+
+import sys
+import time
+import warnings
+
+import numpy as np
+import pandas as pd
+from sklearn.linear_model import Ridge
+from sklearn.metrics import r2_score
+from sklearn.pipeline import Pipeline
+
+from pretab import CrossFittedTransformer, LeakageWarning, Preprocessor, list_representations
+from pretab.transformers import NaturalCubicSplineTransformer, PLETransformer
+
+SEED = 0
+N_ROWS = 400
+
+
+def expect(condition, message):
+ if not condition:
+ raise AssertionError(message)
+
+
+def make_dataset(n=N_ROWS, seed=SEED):
+ rng = np.random.default_rng(seed)
+ X = pd.DataFrame(
+ {
+ "tenure": rng.uniform(0.0, 20.0, size=n),
+ "income": rng.normal(55_000, 12_000, size=n),
+ "usage": rng.exponential(scale=3.0, size=n),
+ "plan": rng.choice(["basic", "standard", "premium"], size=n),
+ "region": rng.choice(["north", "south", "east", "west"], size=n),
+ }
+ )
+ y = 0.4 * np.sin(X["tenure"] / 3) + X["income"] / 1e5 - 0.2 * X["usage"] + rng.normal(0, 0.1, size=n)
+ return X, y.to_numpy()
+
+
+def check_mixed_preprocessing(X, y):
+ config = {
+ "tenure": "naturalspline",
+ "income": "rbf",
+ "usage": "ple",
+ "plan": "one-hot",
+ "region": "int",
+ }
+ pre = Preprocessor(feature_preprocessing=config, task="regression", random_state=SEED)
+ array = pre.fit_transform(X, y, return_array=True)
+ if not isinstance(array, np.ndarray):
+ raise TypeError("fit_transform(return_array=True) did not return an ndarray")
+ expect(array.shape[0] == len(X), "row count changed during preprocessing")
+ expect(np.isfinite(array).all(), "preprocessed output contains non-finite values")
+ return f"{array.shape[0]} rows -> {array.shape[1]} columns"
+
+
+def check_feature_lineage(X, y):
+ pre = Preprocessor(
+ feature_preprocessing={"tenure": "naturalspline", "income": "rbf", "usage": "ple"},
+ categorical_method="one-hot",
+ task="regression",
+ random_state=SEED,
+ ).fit(X, y)
+ lineage = pre.get_feature_lineage()
+ expect(len(lineage) == pre.total_output_dim_, "lineage does not cover every output column")
+ sources = {record.source_features[0] for record in lineage}
+ expect(sources == set(X.columns), "lineage is missing a source feature")
+ return f"{len(lineage)}/{pre.total_output_dim_} columns traced to a source feature"
+
+
+def check_leakage_safe_cross_fitting():
+ rng = np.random.default_rng(SEED)
+ x = rng.uniform(-3.0, 3.0, size=(200, 1))
+ y = rng.normal(size=200)
+
+ with warnings.catch_warnings(record=True) as direct:
+ warnings.simplefilter("always")
+ PLETransformer(output_dim=10, random_state=SEED).fit(x, y)
+ expect(
+ any(issubclass(w.category, LeakageWarning) for w in direct),
+ "fitting a target-aware transformer outside a pipeline should warn",
+ )
+
+ with warnings.catch_warnings(record=True) as piped:
+ warnings.simplefilter("always")
+ Pipeline([("ple", PLETransformer(output_dim=10, random_state=SEED))]).fit(x, y)
+ expect(
+ not any(issubclass(w.category, LeakageWarning) for w in piped),
+ "fitting inside a pipeline should not warn",
+ )
+
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore", category=LeakageWarning)
+ naive = PLETransformer(output_dim=10, random_state=SEED).fit(x, y).transform(x)
+ cross = CrossFittedTransformer(PLETransformer(output_dim=10, random_state=SEED), n_folds=5, random_state=SEED)
+ out_of_fold = cross.fit_transform(x, y)
+
+ changed = int((~np.all(naive == out_of_fold, axis=1)).sum())
+ expect(changed > len(x) // 2, "cross-fitting did not change enough rows to look out-of-fold")
+ return f"warns outside a pipeline, silent inside one, {changed}/{len(x)} rows re-encoded out-of-fold"
+
+
+def check_sklearn_pipeline(X, y):
+ x = X[["tenure"]].to_numpy()
+ pipeline = Pipeline(
+ [
+ ("spline", NaturalCubicSplineTransformer(output_dim=8)),
+ ("model", Ridge(alpha=1.0)),
+ ]
+ )
+ pipeline.fit(x, y)
+ predictions = pipeline.predict(x)
+ expect(predictions.shape == y.shape, "prediction shape does not match the target")
+ expect(np.isfinite(predictions).all(), "predictions contain non-finite values")
+ score = r2_score(y, predictions)
+ return f"Ridge on 8 spline basis columns, R2 = {score:.3f}"
+
+
+def check_serialization_roundtrip(X, y):
+ pre = Preprocessor(
+ feature_preprocessing={"tenure": "naturalspline", "usage": "ple"},
+ categorical_method="int",
+ task="regression",
+ random_state=SEED,
+ ).fit(X, y)
+ reloaded = Preprocessor.from_spec(pre.to_spec())
+
+ original = pre.transform(X, return_array=True)
+ restored = reloaded.transform(X, return_array=True)
+ if not (isinstance(original, np.ndarray) and isinstance(restored, np.ndarray)):
+ raise TypeError("transform(return_array=True) did not return an ndarray")
+ np.testing.assert_array_equal(original, restored)
+ expect(pre.fingerprint_ == reloaded.fingerprint_, "fingerprint changed across a spec round trip")
+ return f"fingerprint {pre.fingerprint_[:12]} reproduced bit-for-bit after a spec round trip"
+
+
+def check_representation_discovery():
+ supervised_numerical = list_representations(feature_kind="numerical", supervised=True)
+ all_methods = list_representations()
+ expect("ple" in supervised_numerical, "the registry lost a documented method")
+ expect("one-hot" not in supervised_numerical, "a categorical-only method leaked into a numerical filter")
+ return f"{len(all_methods)} registered methods, {len(supervised_numerical)} target-aware numerical"
+
+
+CHECKS = [
+ ("mixed-type preprocessing", check_mixed_preprocessing, True),
+ ("feature lineage", check_feature_lineage, True),
+ ("leakage-safe cross-fitting", check_leakage_safe_cross_fitting, False),
+ ("sklearn pipeline compatibility", check_sklearn_pipeline, True),
+ ("portable serialization", check_serialization_roundtrip, True),
+ ("representation discovery", check_representation_discovery, False),
+]
+
+
+def main():
+ import pretab
+
+ print(f"PreTab quickstart (pretab {pretab.__version__})")
+ print("-" * 64)
+
+ X, y = make_dataset()
+ start = time.perf_counter()
+ failed = []
+
+ for index, (label, check, needs_data) in enumerate(CHECKS, start=1):
+ prefix = f"[{index}/{len(CHECKS)}] {label}"
+ try:
+ detail = check(X, y) if needs_data else check()
+ print(f"{prefix:<45} ok {detail}")
+ except Exception as exc: # a failing check should not stop the rest from running
+ failed.append(label)
+ print(f"{prefix:<45} FAIL {exc}")
+
+ elapsed = time.perf_counter() - start
+ print("-" * 64)
+ if failed:
+ print(f"{len(failed)}/{len(CHECKS)} checks failed: {', '.join(failed)}")
+ return 1
+
+ print(f"all {len(CHECKS)} checks passed in {elapsed:.2f}s")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tests/compose/conftest.py b/tests/compose/conftest.py
new file mode 100644
index 0000000..3fe1549
--- /dev/null
+++ b/tests/compose/conftest.py
@@ -0,0 +1,55 @@
+"""Shared fixtures for the compose unit tests.
+
+``make_config`` builds a :class:`~pretab.compose.config.PreprocessorConfig` from
+a simple, target-free default set (standardization + int, unsupervised uniform
+placement) so individual tests only override what they exercise.
+"""
+
+import pandas as pd
+import pytest
+
+from pretab.compose.config import PreprocessorConfig
+
+_CONFIG_DEFAULTS = {
+ "numerical_method": "standardization",
+ "categorical_method": "int",
+ "feature_preprocessing": None,
+ "output_dim": 7,
+ "degree": 3,
+ "target_aware": False,
+ "placement_strategy": "uniform",
+ "task": "regression",
+ "adaptive": False,
+ "min_output_dim": 5,
+ "max_output_dim": 10,
+ "random_state": None,
+ "scaling": None,
+ "cat_cutoff": 0.03,
+ "treat_all_integers_as_numerical": False,
+ "numerical_imputation": "median",
+ "categorical_imputation": "most_frequent",
+ "add_missing_indicator": False,
+ "missing_policy": None,
+ "verbose": 0,
+}
+
+
+@pytest.fixture
+def make_config():
+ """Return a factory that builds a config from the defaults plus overrides."""
+
+ def _make(**overrides):
+ return PreprocessorConfig.from_params(**{**_CONFIG_DEFAULTS, **overrides})
+
+ return _make
+
+
+@pytest.fixture
+def sample_frame():
+ """A tiny mixed numerical/categorical frame for factory/inspection tests."""
+ return pd.DataFrame(
+ {
+ "age": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
+ "city": ["a", "b", "a", "c", "b", "a"],
+ }
+ )
diff --git a/tests/test_categorical_pipeline.py b/tests/compose/test_categorical_pipeline.py
similarity index 92%
rename from tests/test_categorical_pipeline.py
rename to tests/compose/test_categorical_pipeline.py
index 1f8ad2e..49fdaac 100644
--- a/tests/test_categorical_pipeline.py
+++ b/tests/compose/test_categorical_pipeline.py
@@ -2,7 +2,7 @@
import pytest
from sklearn.pipeline import Pipeline
-from pretab.pipeline import get_categorical_transformer_steps
+from pretab.compose.factory import get_categorical_transformer_steps
def _build(method, **kwargs):
@@ -27,4 +27,3 @@ def test_one_hot_handle_unknown_override():
pipe.fit(np.array([["A"], ["B"]]))
with pytest.raises(ValueError):
pipe.transform(np.array([["C"]]))
-
diff --git a/tests/compose/test_config.py b/tests/compose/test_config.py
new file mode 100644
index 0000000..e3a4c46
--- /dev/null
+++ b/tests/compose/test_config.py
@@ -0,0 +1,50 @@
+"""Unit tests for :class:`pretab.compose.config.PreprocessorConfig`."""
+
+import pytest
+
+from pretab.exceptions import InvalidParamError
+
+
+def test_none_method_normalizes_to_none(make_config):
+ cfg = make_config(numerical_method=None, categorical_method=None)
+ assert cfg.numerical_method == "none"
+ assert cfg.categorical_method == "none"
+
+
+def test_aliases_resolve_to_canonical(make_config):
+ assert make_config(numerical_method="cubic").numerical_method == "cubicspline"
+ assert make_config(categorical_method="ohe").categorical_method == "one-hot"
+
+
+def test_invalid_placement_combo_raises(make_config):
+ with pytest.raises(InvalidParamError):
+ make_config(target_aware=True, placement_strategy="uniform")
+ with pytest.raises(InvalidParamError):
+ make_config(target_aware=False, placement_strategy="cart")
+
+
+def test_feature_preprocessing_is_copied(make_config):
+ fp = {"age": "standardization"}
+ cfg = make_config(feature_preprocessing=fp)
+ assert cfg.feature_preprocessing == fp
+ assert cfg.feature_preprocessing is not fp
+
+
+def test_none_feature_preprocessing_becomes_empty_dict(make_config):
+ assert make_config(feature_preprocessing=None).feature_preprocessing == {}
+
+
+def test_method_for_override_wins_over_global(make_config):
+ cfg = make_config(numerical_method="standardization", feature_preprocessing={"age": "minmax"})
+ assert cfg.method_for("age", is_numerical=True) == "minmax"
+ assert cfg.method_for("height", is_numerical=True) == "standardization"
+
+
+def test_method_for_resolves_in_the_requested_namespace(make_config):
+ cfg = make_config(feature_preprocessing={"c": "ohe"})
+ assert cfg.method_for("c", is_numerical=False) == "one-hot"
+
+
+def test_seed_kwargs_reflects_random_state(make_config):
+ assert make_config(random_state=None).seed_kwargs == {}
+ assert make_config(random_state=42).seed_kwargs == {"random_state": 42}
diff --git a/tests/compose/test_factory.py b/tests/compose/test_factory.py
new file mode 100644
index 0000000..a0d1f53
--- /dev/null
+++ b/tests/compose/test_factory.py
@@ -0,0 +1,121 @@
+"""Unit tests for :mod:`pretab.compose.factory`."""
+
+import numpy as np
+import pytest
+from sklearn.compose import ColumnTransformer
+
+from pretab.compose.factory import (
+ _placement_kwargs,
+ build_column_transformer,
+ get_categorical_transformer_steps,
+ get_numerical_transformer_steps,
+)
+from pretab.compose.registry import get_spec
+from pretab.exceptions import ConfigWarning, InvalidParamError
+
+
+def _names(steps):
+ return [name for name, _ in steps]
+
+
+# --------------------------------------------------------------------------- #
+# numerical step assembly
+# --------------------------------------------------------------------------- #
+def test_imputer_is_first_step_by_default():
+ assert _names(get_numerical_transformer_steps("standardization"))[0] == "imputer"
+
+
+def test_imputer_omitted_when_disabled():
+ assert "imputer" not in _names(get_numerical_transformer_steps("standardization", add_imputer=False))
+
+
+def test_none_method_uses_noop_step():
+ assert _names(get_numerical_transformer_steps("none", add_imputer=False)) == ["noop"]
+
+
+def test_box_cox_scales_positive_first():
+ assert _names(get_numerical_transformer_steps("box-cox", add_imputer=False)) == ["scale_positive", "boxcox"]
+
+
+def test_scaling_injected_only_when_different_from_method():
+ with_scaler = _names(get_numerical_transformer_steps("ple", add_imputer=False, scaling="standardization"))
+ assert "scaler" in with_scaler
+ same = _names(get_numerical_transformer_steps("standardization", add_imputer=False, scaling="standardization"))
+ assert "scaler" not in same
+ assert same.count("standardization") == 1
+
+
+def test_bmi_spline_output_dim_is_clamped_with_warning():
+ with pytest.warns(ConfigWarning):
+ get_numerical_transformer_steps("bspline", add_imputer=False, output_dim=100)
+
+
+def test_unknown_numerical_method_raises():
+ with pytest.raises(InvalidParamError):
+ get_numerical_transformer_steps("does-not-exist", add_imputer=False)
+
+
+# --------------------------------------------------------------------------- #
+# categorical step assembly
+# --------------------------------------------------------------------------- #
+def test_one_hot_appends_to_float():
+ assert _names(get_categorical_transformer_steps("one-hot", add_imputer=False)) == ["onehot", "to_float"]
+
+
+def test_int_uses_continuous_ordinal():
+ assert _names(get_categorical_transformer_steps("int", add_imputer=False)) == ["continuous_ordinal"]
+
+
+def test_unknown_categorical_method_raises():
+ with pytest.raises(InvalidParamError):
+ get_categorical_transformer_steps("does-not-exist", add_imputer=False)
+
+
+# --------------------------------------------------------------------------- #
+# placement kwargs by capability class
+# --------------------------------------------------------------------------- #
+def test_placement_optional_forwards_target_aware_and_strategy():
+ spec = get_spec("rbf") # target_usage == optional
+ assert _placement_kwargs(spec, {"target_aware": False}) == {"target_aware": False}
+ assert _placement_kwargs(spec, {"target_aware": False, "placement_strategy": "quantile"}) == {
+ "target_aware": False,
+ "placement_strategy": "quantile",
+ }
+
+
+def test_placement_required_only_when_target_aware_supervised():
+ spec = get_spec("ple") # target_usage == required
+ assert _placement_kwargs(spec, {"target_aware": True, "placement_strategy": "cart"}) == {
+ "placement_strategy": "cart"
+ }
+ assert _placement_kwargs(spec, {"target_aware": False, "placement_strategy": "cart"}) == {}
+
+
+def test_placement_forbidden_uses_unsupervised_only():
+ spec = get_spec("pspline") # target_usage == forbidden, unsupervised placement
+ assert _placement_kwargs(spec, {"target_aware": False, "placement_strategy": "uniform"}) == {
+ "placement_strategy": "uniform"
+ }
+ assert _placement_kwargs(spec, {"target_aware": True, "placement_strategy": "uniform"}) == {}
+
+
+def test_placement_absent_when_method_has_no_strategies():
+ spec = get_spec("standardization") # no placement strategies
+ assert _placement_kwargs(spec, {"target_aware": True, "placement_strategy": "cart"}) == {}
+
+
+# --------------------------------------------------------------------------- #
+# ColumnTransformer assembly
+# --------------------------------------------------------------------------- #
+def test_build_column_transformer_prefixes_and_passthrough(make_config):
+ ct = build_column_transformer(make_config(), ["age"], ["city"])
+ assert isinstance(ct, ColumnTransformer)
+ assert [name for name, _, _ in ct.transformers] == ["num_age", "cat_city"]
+ assert ct.remainder == "passthrough"
+
+
+def test_build_column_transformer_fits_and_transforms(make_config, sample_frame):
+ ct = build_column_transformer(make_config(), ["age"], ["city"])
+ out = ct.fit_transform(sample_frame, np.array([0.0, 1.0, 0.0, 1.0, 0.0, 1.0]))
+ assert isinstance(out, np.ndarray)
+ assert out.shape[0] == len(sample_frame)
diff --git a/tests/compose/test_feature_detection.py b/tests/compose/test_feature_detection.py
new file mode 100644
index 0000000..74bbd78
--- /dev/null
+++ b/tests/compose/test_feature_detection.py
@@ -0,0 +1,64 @@
+"""Unit tests for :mod:`pretab.compose.feature_detection`."""
+
+import numpy as np
+import pandas as pd
+import pytest
+
+from pretab.compose.feature_detection import detect_column_types, to_dataframe
+from pretab.exceptions import InvalidParamError
+
+
+def test_to_dataframe_wraps_ndarray_with_feature_names():
+ df = to_dataframe(np.zeros((2, 3)))
+ assert list(df.columns) == ["feature_0", "feature_1", "feature_2"]
+
+
+def test_to_dataframe_wraps_dict():
+ df = to_dataframe({"a": [1, 2], "b": [3, 4]})
+ assert list(df.columns) == ["a", "b"]
+
+
+def test_to_dataframe_returns_same_object_without_copy():
+ df = pd.DataFrame({"a": [1, 2]})
+ assert to_dataframe(df) is df
+ assert to_dataframe(df, copy=True) is not df
+
+
+def test_float_cutoff_uses_unique_ratio():
+ df = pd.DataFrame({"x": [1, 2, 3, 1, 2, 3]}) # 3 unique of 6 -> ratio 0.5
+ num, cat = detect_column_types(df, cat_cutoff=0.6, treat_all_integers_as_numerical=False)
+ assert cat == ["x"] and num == []
+ num, cat = detect_column_types(df, cat_cutoff=0.4, treat_all_integers_as_numerical=False)
+ assert num == ["x"] and cat == []
+
+
+def test_int_cutoff_uses_absolute_count():
+ df = pd.DataFrame({"x": [1, 2, 3, 1, 2, 3]}) # 3 unique
+ _, cat = detect_column_types(df, cat_cutoff=4, treat_all_integers_as_numerical=False)
+ assert cat == ["x"]
+ num, _ = detect_column_types(df, cat_cutoff=2, treat_all_integers_as_numerical=False)
+ assert num == ["x"]
+
+
+def test_treat_all_integers_as_numerical_overrides_cutoff():
+ df = pd.DataFrame({"x": [1, 2, 3, 1, 2, 3]})
+ num, cat = detect_column_types(df, cat_cutoff=0.9, treat_all_integers_as_numerical=True)
+ assert num == ["x"] and cat == []
+
+
+def test_object_dtype_is_always_categorical():
+ df = pd.DataFrame({"c": ["a", "b", "c", "d", "e", "f"]})
+ _, cat = detect_column_types(df, cat_cutoff=0.01, treat_all_integers_as_numerical=False)
+ assert cat == ["c"]
+
+
+def test_float_columns_are_numerical():
+ df = pd.DataFrame({"f": [0.1, 0.2, 0.3, 0.4, 0.5, 0.6]})
+ num, _ = detect_column_types(df, cat_cutoff=0.9, treat_all_integers_as_numerical=False)
+ assert num == ["f"]
+
+
+def test_invalid_cat_cutoff_type_raises():
+ df = pd.DataFrame({"x": [1, 2, 3]})
+ with pytest.raises(InvalidParamError):
+ detect_column_types(df, cat_cutoff="bad", treat_all_integers_as_numerical=False)
diff --git a/tests/compose/test_inspection.py b/tests/compose/test_inspection.py
new file mode 100644
index 0000000..5741c93
--- /dev/null
+++ b/tests/compose/test_inspection.py
@@ -0,0 +1,88 @@
+"""Unit tests for :mod:`pretab.compose.inspection`."""
+
+import numpy as np
+import pandas as pd
+import pytest
+
+from pretab.compose.factory import build_column_transformer
+from pretab.compose.inspection import (
+ build_feature_info,
+ build_transformer_summary,
+ clean_feature_names,
+ get_output_slices,
+)
+
+
+@pytest.fixture
+def fitted_ct(make_config, sample_frame):
+ ct = build_column_transformer(make_config(numerical_method="standardization"), ["age"], ["city"])
+ ct.fit(sample_frame, np.array([0.0, 1.0, 0.0, 1.0, 0.0, 1.0]))
+ return ct, sample_frame
+
+
+def test_get_output_slices_are_ordered_and_named(fitted_ct):
+ ct, X = fitted_ct
+ slices = get_output_slices(ct, X)
+ names = [name for name, _, _ in slices]
+ assert "num_age" in names and "cat_city" in names
+ starts = [start for _, start, _ in slices]
+ assert starts == sorted(starts)
+ assert all(width >= 1 for _, _, width in slices)
+
+
+def test_build_feature_info_splits_numerical_and_categorical(fitted_ct):
+ ct, _ = fitted_ct
+ numerical, categorical, embeddings = build_feature_info(ct, embeddings=False, embedding_dimensions={})
+ assert "age" in numerical
+ assert "city" in categorical
+ assert embeddings == {}
+
+
+def test_build_feature_info_reports_embeddings(fitted_ct):
+ ct, _ = fitted_ct
+ _, _, embeddings = build_feature_info(ct, embeddings=True, embedding_dimensions={"embedding_1": 8})
+ assert embeddings == {"embedding_1": {"preprocessing": None, "dimension": 8, "categories": None}}
+
+
+def test_build_transformer_summary_has_header_and_rows():
+ numerical = {"age": {"preprocessing": "imputer -> standardization", "dimension": 1, "categories": None}}
+ categorical = {"city": {"preprocessing": "imputer -> continuous_ordinal", "dimension": 1, "categories": 3}}
+ lines = build_transformer_summary(numerical, categorical, {})
+ assert lines[0].startswith("feature")
+ assert any("age" in line for line in lines)
+ assert any("city" in line for line in lines)
+
+
+def test_build_transformer_summary_empty_returns_empty():
+ assert build_transformer_summary({}, {}, {}) == []
+
+
+def test_clean_feature_names_collapses_1_to_1_step(fitted_ct):
+ ct, _ = fitted_ct
+ raw = [str(name) for name in ct.get_feature_names_out()]
+ assert any("__" in name for name in raw), "sanity: sklearn's default naming should duplicate here"
+ assert clean_feature_names(ct, raw) == ["num_age", "cat_city"]
+
+
+def test_clean_feature_names_handles_underscore_in_feature_name(make_config):
+ # "annual_income" itself contains "_", so a naive string split on "_" would
+ # mis-collapse this; the fix must use the ColumnTransformer's own column metadata.
+ df = pd.DataFrame({"annual_income": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]})
+ ct = build_column_transformer(make_config(numerical_method="bspline", output_dim=5), ["annual_income"], [])
+ ct.fit(df)
+ raw = [str(name) for name in ct.get_feature_names_out()]
+ cleaned = clean_feature_names(ct, raw)
+ assert all("__" not in name for name in cleaned)
+ assert all(name.startswith("num_annual_income_bs") for name in cleaned)
+
+
+def test_clean_feature_names_leaves_unmatched_names_untouched(fitted_ct):
+ ct, _ = fitted_ct
+ untouched = ["remainder__extra", "totally_unrelated_name"]
+ assert clean_feature_names(ct, untouched) == untouched
+
+
+def test_clean_feature_names_leaves_non_matching_inner_name_untouched(fitted_ct):
+ ct, _ = fitted_ct
+ raw = ["num_age__somethingelse"]
+ assert clean_feature_names(ct, raw) == raw
diff --git a/tests/test_method_aliases.py b/tests/compose/test_method_aliases.py
similarity index 84%
rename from tests/test_method_aliases.py
rename to tests/compose/test_method_aliases.py
index 4f5e03f..11c31d5 100644
--- a/tests/test_method_aliases.py
+++ b/tests/compose/test_method_aliases.py
@@ -2,14 +2,15 @@
import pandas as pd
import pytest
-from pretab.core.exceptions import InvalidParamError
-from pretab.pipeline.registry import (
+from pretab.compose.registry import (
CATEGORICAL_ALIASES,
CATEGORICAL_METHODS,
NUMERICAL_ALIASES,
NUMERICAL_METHODS,
+ numerical_method_names,
resolve_method,
)
+from pretab.exceptions import InvalidParamError
from pretab.preprocessor import Preprocessor
@@ -86,8 +87,13 @@ def test_every_canonical_name_resolves_to_itself():
def test_alias_targets_are_canonical():
+ # Alias targets must be canonical *registry* names. The multivariate
+ # tensor-product / thin-plate methods are standalone-only (excluded from the
+ # per-column ``NUMERICAL_METHODS`` whitelist), so validate against the full
+ # registry rather than the Preprocessor whitelist.
+ numerical_canonical = numerical_method_names()
for target in NUMERICAL_ALIASES.values():
- assert target in NUMERICAL_METHODS
+ assert target in numerical_canonical
for target in CATEGORICAL_ALIASES.values():
assert target in CATEGORICAL_METHODS
@@ -117,7 +123,11 @@ def sample_data():
def test_numerical_alias_matches_canonical_output(sample_data, alias, canonical):
X, y = sample_data
out_alias = Preprocessor(numerical_method=alias, categorical_method="int").fit_transform(X, y, return_array=True)
- out_canon = Preprocessor(numerical_method=canonical, categorical_method="int").fit_transform(X, y, return_array=True)
+ out_canon = Preprocessor(numerical_method=canonical, categorical_method="int").fit_transform(
+ X, y, return_array=True
+ )
+ assert isinstance(out_alias, np.ndarray)
+ assert isinstance(out_canon, np.ndarray)
np.testing.assert_allclose(out_alias, out_canon)
@@ -128,7 +138,11 @@ def test_numerical_alias_matches_canonical_output(sample_data, alias, canonical)
def test_categorical_alias_matches_canonical_output(sample_data, alias, canonical):
X, y = sample_data
out_alias = Preprocessor(numerical_method="minmax", categorical_method=alias).fit_transform(X, y, return_array=True)
- out_canon = Preprocessor(numerical_method="minmax", categorical_method=canonical).fit_transform(X, y, return_array=True)
+ out_canon = Preprocessor(numerical_method="minmax", categorical_method=canonical).fit_transform(
+ X, y, return_array=True
+ )
+ assert isinstance(out_alias, np.ndarray)
+ assert isinstance(out_canon, np.ndarray)
np.testing.assert_allclose(out_alias, out_canon)
diff --git a/tests/compose/test_output.py b/tests/compose/test_output.py
new file mode 100644
index 0000000..b0f0be4
--- /dev/null
+++ b/tests/compose/test_output.py
@@ -0,0 +1,58 @@
+"""Unit tests for :mod:`pretab.compose.output`."""
+
+import numpy as np
+import pytest
+
+from pretab.compose.output import attach_embeddings, build_output_dict, format_output
+from pretab.exceptions import IncompatibleParamsError
+
+
+def test_build_output_dict_slices_by_span():
+ arr = np.arange(12).reshape(3, 4)
+ out = build_output_dict(arr, [("a", 0, 1), ("b", 1, 3)])
+ assert set(out) == {"a", "b"}
+ assert out["a"].shape == (3, 1)
+ np.testing.assert_array_equal(out["b"], arr[:, 1:4])
+
+
+def test_attach_embeddings_array_casts_to_float32():
+ result = {}
+ attach_embeddings(result, np.ones((2, 3)), expected=True)
+ assert result["embedding_1"].dtype == np.float32
+ assert result["embedding_1"].shape == (2, 3)
+
+
+def test_attach_embeddings_list_numbers_blocks():
+ result = {}
+ attach_embeddings(result, [np.ones((2, 2)), np.ones((2, 1))], expected=True)
+ assert set(result) == {"embedding_1", "embedding_2"}
+
+
+def test_attach_embeddings_unexpected_raises():
+ with pytest.raises(IncompatibleParamsError):
+ attach_embeddings({}, np.ones((2, 3)), expected=False)
+
+
+def test_format_output_array_returns_input_unchanged():
+ arr = np.zeros((2, 2))
+ assert format_output(arr, return_array=True) is arr
+
+
+def test_format_output_dict_builds_blocks():
+ arr = np.arange(6).reshape(2, 3)
+ out = format_output(arr, return_array=False, slices=[("x", 0, 3)])
+ assert isinstance(out, dict)
+ assert set(out) == {"x"}
+ np.testing.assert_array_equal(out["x"], arr)
+
+
+def test_format_output_dict_attaches_embeddings():
+ arr = np.arange(6).reshape(2, 3)
+ out = format_output(
+ arr,
+ return_array=False,
+ slices=[("x", 0, 3)],
+ embeddings=np.ones((2, 4)),
+ embeddings_expected=True,
+ )
+ assert "embedding_1" in out
diff --git a/tests/compose/test_registry_contract.py b/tests/compose/test_registry_contract.py
new file mode 100644
index 0000000..8611fc5
--- /dev/null
+++ b/tests/compose/test_registry_contract.py
@@ -0,0 +1,230 @@
+"""Contract tests driven by ``TRANSFORMER_REGISTRY``.
+
+Every registered method is validated for a consistent capability record and for
+behaviour that matches its declared flags. Adding a method to the registry
+therefore automatically subjects it to these invariants.
+"""
+
+import importlib.util
+
+import numpy as np
+import pandas as pd
+import pytest
+
+from pretab import Preprocessor
+from pretab.compose.registry import (
+ NUMERICAL_METHODS,
+ TRANSFORMER_REGISTRY,
+ TransformerSpec,
+ categorical_method_names,
+ numerical_method_names,
+)
+from pretab.exceptions import InvalidParamError, OptionalDependencyError, PretabError
+
+_VALID_KINDS = {"numerical", "categorical"}
+_VALID_ARITY = {"univariate", "multivariate"}
+_VALID_TARGET_USAGE = {"forbidden", "optional", "required"}
+_UNSUPERVISED = frozenset({"uniform", "quantile"})
+_TARGET_AWARE = frozenset({"cart", "lightgbm"})
+_ALL_STRATEGIES = _UNSUPERVISED | _TARGET_AWARE
+
+# Optional extra -> importable module used to detect whether the dependency is
+# actually installed in the current environment.
+_EXTRA_MODULE = {"embeddings": "sentence_transformers", "lightgbm": "lightgbm"}
+
+_SPEC_ITEMS = list(TRANSFORMER_REGISTRY.items())
+_SPEC_IDS = [name for name, _ in _SPEC_ITEMS]
+
+
+def _module_available(module_name: str) -> bool:
+ return importlib.util.find_spec(module_name) is not None
+
+
+@pytest.mark.parametrize("name, spec", _SPEC_ITEMS, ids=_SPEC_IDS)
+def test_registry_key_matches_name(name, spec):
+ assert isinstance(spec, TransformerSpec)
+ assert spec.name == name
+
+
+@pytest.mark.parametrize("name, spec", _SPEC_ITEMS, ids=_SPEC_IDS)
+def test_transformer_cls_is_importable_class(name, spec):
+ # The class object is resolved at registry import time; being a ``type`` here
+ # proves the import path is valid.
+ assert isinstance(spec.transformer_cls, type)
+
+
+@pytest.mark.parametrize("name, spec", _SPEC_ITEMS, ids=_SPEC_IDS)
+def test_feature_kind_valid(name, spec):
+ assert spec.feature_kind, f"{name} has no feature kind"
+ assert spec.feature_kind <= _VALID_KINDS
+
+
+@pytest.mark.parametrize("name, spec", _SPEC_ITEMS, ids=_SPEC_IDS)
+def test_arity_valid(name, spec):
+ assert spec.arity in _VALID_ARITY
+
+
+@pytest.mark.parametrize("name, spec", _SPEC_ITEMS, ids=_SPEC_IDS)
+def test_target_usage_valid(name, spec):
+ assert spec.target_usage in _VALID_TARGET_USAGE
+
+
+@pytest.mark.parametrize("name, spec", _SPEC_ITEMS, ids=_SPEC_IDS)
+def test_placement_strategies_valid(name, spec):
+ assert spec.placement_strategies <= _ALL_STRATEGIES
+
+
+@pytest.mark.parametrize("name, spec", _SPEC_ITEMS, ids=_SPEC_IDS)
+def test_target_usage_and_placement_are_consistent(name, spec):
+ if spec.target_usage == "required":
+ # Always target-aware: only the supervised strategies apply.
+ assert spec.placement_strategies == _TARGET_AWARE
+ elif spec.target_usage == "optional":
+ # Both modes available: every strategy applies.
+ assert spec.placement_strategies == _ALL_STRATEGIES
+ else: # forbidden
+ # Never uses y: any placement it has must be unsupervised.
+ assert spec.placement_strategies <= _UNSUPERVISED
+
+
+@pytest.mark.parametrize("name, spec", _SPEC_ITEMS, ids=_SPEC_IDS)
+def test_adaptive_flag_matches_allowed_args(name, spec):
+ assert spec.supports_adaptive_resolution == ("adaptive" in spec.allowed_args)
+
+
+@pytest.mark.parametrize("name, spec", _SPEC_ITEMS, ids=_SPEC_IDS)
+def test_optional_dependency_value(name, spec):
+ assert spec.optional_dependency is None or spec.optional_dependency in _EXTRA_MODULE
+
+
+@pytest.mark.parametrize("name, spec", _SPEC_ITEMS, ids=_SPEC_IDS)
+def test_instantiable_when_dependency_present(name, spec):
+ # Methods with no optional dependency (or whose dependency is installed)
+ # must construct with defaults.
+ if spec.optional_dependency and not _module_available(_EXTRA_MODULE[spec.optional_dependency]):
+ pytest.skip(f"optional dependency {spec.optional_dependency!r} not installed")
+ assert spec.transformer_cls() is not None
+
+
+@pytest.mark.parametrize("name, spec", _SPEC_ITEMS, ids=_SPEC_IDS)
+def test_required_target_methods_reject_missing_y(name, spec):
+ if not spec.requires_target or spec.is_multivariate:
+ pytest.skip("not a univariate required-target method")
+ if spec.optional_dependency and not _module_available(_EXTRA_MODULE[spec.optional_dependency]):
+ pytest.skip(f"optional dependency {spec.optional_dependency!r} not installed")
+ transformer = spec.transformer_cls()
+ X = np.linspace(0.0, 1.0, 60).reshape(-1, 1)
+ with pytest.raises(PretabError):
+ transformer.fit(X, None)
+
+
+@pytest.mark.parametrize("name, spec", _SPEC_ITEMS, ids=_SPEC_IDS)
+def test_optional_dependency_methods_fail_cleanly(name, spec):
+ if spec.optional_dependency is None:
+ pytest.skip("no optional dependency")
+ module_name = _EXTRA_MODULE[spec.optional_dependency]
+ if _module_available(module_name):
+ pytest.skip(f"{module_name} is installed; cannot exercise the missing-dependency path")
+ transformer = spec.transformer_cls()
+ X = np.array([["a"], ["b"], ["c"]], dtype=object)
+ with pytest.raises(OptionalDependencyError):
+ transformer.fit(X)
+
+
+def test_registry_covers_numerical_and_categorical_names():
+ assert numerical_method_names() | categorical_method_names() == set(TRANSFORMER_REGISTRY)
+ # ``none`` (passthrough) is the only remaining dual-kind method.
+ dual = numerical_method_names() & categorical_method_names()
+ assert dual == {"none"}
+
+
+# --------------------------------------------------------------------------- #
+# End-to-end behavioural contract: the ``preprocessor_compatible`` flag and the
+# target-usage declaration must match what the Preprocessor actually does.
+# --------------------------------------------------------------------------- #
+_PREPROC_NUMERICAL = [
+ (name, spec)
+ for name, spec in _SPEC_ITEMS
+ if spec.is_numerical and spec.preprocessor_compatible and not spec.is_multivariate
+]
+_PREPROC_CATEGORICAL = [
+ (name, spec) for name, spec in _SPEC_ITEMS if spec.is_categorical and spec.preprocessor_compatible
+]
+_REQUIRED_NUMERICAL = [
+ (name, spec)
+ for name, spec in _SPEC_ITEMS
+ if spec.is_numerical and spec.requires_target and not spec.is_multivariate
+]
+
+
+def _skip_if_dependency_missing(spec):
+ if spec.optional_dependency and not _module_available(_EXTRA_MODULE[spec.optional_dependency]):
+ pytest.skip(f"optional dependency {spec.optional_dependency!r} not installed")
+
+
+@pytest.mark.parametrize("name, spec", _PREPROC_NUMERICAL, ids=[name for name, _ in _PREPROC_NUMERICAL])
+def test_preprocessor_compatible_numerical_methods_fit_transform(name, spec):
+ _skip_if_dependency_missing(spec)
+ rng = np.random.RandomState(0)
+ X = pd.DataFrame({"f0": rng.rand(60), "f1": rng.rand(60) * 5 + 1})
+ y = rng.rand(60)
+ if spec.requires_target:
+ pre = Preprocessor(numerical_method=name, target_aware=True, placement_strategy="cart")
+ else:
+ pre = Preprocessor(numerical_method=name, target_aware=False, placement_strategy="uniform")
+ out = pre.fit_transform(X, y, return_array=True)
+ assert isinstance(out, np.ndarray)
+ assert out.shape[0] == 60
+
+
+@pytest.mark.parametrize("name, spec", _PREPROC_CATEGORICAL, ids=[name for name, _ in _PREPROC_CATEGORICAL])
+def test_preprocessor_compatible_categorical_methods_fit_transform(name, spec):
+ _skip_if_dependency_missing(spec)
+ rng = np.random.RandomState(0)
+ # Integer-coded categories keep ``onehot_from_ordinal`` valid; a high cutoff
+ # forces the low-cardinality integer column onto the categorical path.
+ X = pd.DataFrame({"n": rng.rand(60), "c": rng.randint(0, 3, size=60)})
+ y = rng.rand(60)
+ pre = Preprocessor(
+ numerical_method="standardization",
+ categorical_method=name,
+ cat_cutoff=0.5,
+ target_aware=False,
+ placement_strategy="uniform",
+ )
+ out = pre.fit_transform(X, y, return_array=True)
+ assert isinstance(out, np.ndarray)
+ assert out.shape[0] == 60
+
+
+@pytest.mark.parametrize("name, spec", _REQUIRED_NUMERICAL, ids=[name for name, _ in _REQUIRED_NUMERICAL])
+def test_required_target_methods_raise_without_y_via_preprocessor(name, spec):
+ _skip_if_dependency_missing(spec)
+ X = pd.DataFrame({"f0": np.linspace(0.0, 1.0, 60)})
+ pre = Preprocessor(numerical_method=name, target_aware=True, placement_strategy="cart")
+ # A required-target method must fail loudly with a typed PretabError when fit
+ # without a target (Phase 4 tightened this from the raw TypeError that
+ # sklearn's fit_transform used to surface).
+ with pytest.raises(PretabError):
+ pre.fit(X)
+
+
+# --------------------------------------------------------------------------- #
+# Multivariate methods are standalone-only (D6): not selectable per column
+# through the Preprocessor whitelist.
+# --------------------------------------------------------------------------- #
+_MULTIVARIATE_NUMERICAL = [(name, spec) for name, spec in _SPEC_ITEMS if spec.is_numerical and spec.is_multivariate]
+
+
+@pytest.mark.parametrize("name, spec", _MULTIVARIATE_NUMERICAL, ids=[name for name, _ in _MULTIVARIATE_NUMERICAL])
+def test_multivariate_methods_not_preprocessor_selectable(name, spec):
+ # The multivariate tensor-product / thin-plate splines are standalone-only and
+ # deliberately excluded from the per-column Preprocessor whitelist; selecting
+ # one must fail loudly rather than silently misbehave.
+ assert spec.preprocessor_compatible is False
+ assert name not in NUMERICAL_METHODS
+ X = pd.DataFrame({"f0": np.linspace(0.0, 1.0, 60), "f1": np.linspace(1.0, 2.0, 60)})
+ y = np.linspace(0.0, 1.0, 60)
+ pre = Preprocessor(numerical_method=name)
+ with pytest.raises(InvalidParamError):
+ pre.fit(X, y)
diff --git a/tests/compose/test_search.py b/tests/compose/test_search.py
new file mode 100644
index 0000000..3cb9308
--- /dev/null
+++ b/tests/compose/test_search.py
@@ -0,0 +1,117 @@
+"""Tests for :class:`~pretab.compose.search.RepresentationSearchCV`.
+
+The search picks the best ``numerical_method`` by cross-validation, then refits
+the winning representation (and the downstream estimator) on all data. The data
+here is a controlled nonlinear signal (``sin``) where an expressive basis
+(``bspline``) must beat a linear ``standardization`` baseline.
+"""
+
+import numpy as np
+import pandas as pd
+import pytest
+from sklearn.exceptions import NotFittedError
+from sklearn.linear_model import LinearRegression, LogisticRegression
+from sklearn.model_selection import KFold
+
+from pretab import RepresentationSearchCV
+from pretab.exceptions import InvalidParamError
+
+# Unsupervised, deterministic placement so scores are reproducible fold to fold.
+_UNSUPERVISED = {"target_aware": False, "placement_strategy": "uniform", "output_dim": 10}
+
+
+@pytest.fixture
+def nonlinear_data():
+ """Random (unsorted) x in [-3, 3] with a smooth nonlinear target."""
+ rng = np.random.RandomState(0)
+ x = rng.uniform(-3.0, 3.0, size=200)
+ X = pd.DataFrame({"x": x})
+ y = np.sin(x) + 0.05 * rng.randn(200)
+ return X, y
+
+
+def _search(estimator, methods, **kwargs):
+ params = {"cv": 4, "preprocessor_params": _UNSUPERVISED, "random_state": 0}
+ params.update(kwargs)
+ return RepresentationSearchCV(estimator, methods=methods, **params)
+
+
+def test_selects_expressive_method_on_nonlinear_signal(nonlinear_data):
+ X, y = nonlinear_data
+ search = _search(LinearRegression(), ["standardization", "bspline"]).fit(X, y)
+
+ assert set(search.cv_results_) == {"standardization", "bspline"}
+ assert search.best_method_ == "bspline"
+ assert search.cv_results_["bspline"] > search.cv_results_["standardization"]
+ assert search.best_score_ == pytest.approx(max(search.cv_results_.values()))
+
+
+def test_refit_best_representation_and_predict(nonlinear_data):
+ X, y = nonlinear_data
+ search = _search(LinearRegression(), ["standardization", "bspline"]).fit(X, y)
+
+ assert search.best_method_ == "bspline"
+ # best_preprocessor_ carries the winning method and is refit on all data.
+ assert search.best_preprocessor_.numerical_method == "bspline"
+ preds = search.predict(X)
+ assert preds.shape == (len(X),)
+ # A refit bspline fits the smooth signal well.
+ assert search.score(X, y) > 0.9
+
+
+def test_fit_is_reproducible(nonlinear_data):
+ X, y = nonlinear_data
+ first = _search(LinearRegression(), ["standardization", "bspline"]).fit(X, y)
+ second = _search(LinearRegression(), ["standardization", "bspline"]).fit(X, y)
+
+ assert first.best_method_ == second.best_method_
+ assert first.cv_results_ == second.cv_results_
+ np.testing.assert_allclose(first.predict(X), second.predict(X))
+
+
+def test_accepts_cv_splitter_object(nonlinear_data):
+ X, y = nonlinear_data
+ search = _search(LinearRegression(), ["bspline"], cv=KFold(n_splits=3, shuffle=True, random_state=0)).fit(X, y)
+
+ assert search.best_method_ == "bspline"
+ assert set(search.cv_results_) == {"bspline"}
+
+
+def test_classification_uses_stratified_cv():
+ rng = np.random.RandomState(0)
+ x = rng.uniform(-3.0, 3.0, size=200)
+ X = pd.DataFrame({"x": x})
+ y = (np.sin(x) > 0).astype(int)
+ search = _search(LogisticRegression(max_iter=1000), ["standardization", "bspline"]).fit(X, y)
+
+ assert search.best_method_ in {"standardization", "bspline"}
+ assert 0.0 <= search.score(X, y) <= 1.0
+
+
+def test_empty_methods_raises(nonlinear_data):
+ X, y = nonlinear_data
+ with pytest.raises(InvalidParamError):
+ RepresentationSearchCV(LinearRegression(), methods=[]).fit(X, y)
+
+
+def test_requires_y_at_fit(nonlinear_data):
+ X, _ = nonlinear_data
+ with pytest.raises(InvalidParamError):
+ RepresentationSearchCV(LinearRegression(), methods=["bspline"]).fit(X, None)
+
+
+def test_predict_before_fit_raises(nonlinear_data):
+ X, _ = nonlinear_data
+ search = RepresentationSearchCV(LinearRegression(), methods=["bspline"])
+ with pytest.raises(NotFittedError):
+ search.predict(X)
+
+
+def test_get_params_and_clone_preserve_config():
+ from sklearn.base import clone
+
+ search = RepresentationSearchCV(LinearRegression(), methods=["bspline", "standardization"], cv=3)
+ assert search.get_params()["methods"] == ["bspline", "standardization"]
+ cloned = clone(search)
+ assert isinstance(cloned, RepresentationSearchCV)
+ assert cloned.get_params()["cv"] == 3
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..8123ab2
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,42 @@
+"""Root test configuration.
+
+``pyproject.toml`` turns :class:`~pretab.exceptions.LeakageWarning` into an error
+so an *unintended* leakage warning fails the suite. The modules listed below
+intentionally fit target-aware transformers directly (outside a Pipeline) to
+exercise their behaviour, so the expected leakage warning is silenced there. The
+dedicated leakage tests still assert the warning explicitly via ``pytest.warns``.
+"""
+
+import pytest
+
+# Test modules that fit supervised transformers directly; the leakage warning is
+# expected here and must not fail the suite. Any *other* module that emits it is
+# a real regression and will error.
+_LEAKAGE_EXPECTED_MODULES = frozenset(
+ {
+ "test_feature_map_selector.py",
+ "test_adaptive_resolution.py",
+ "test_ple_selector.py",
+ "test_cross_fitted.py",
+ "test_supervised_contract.py",
+ "test_ple_transformer.py",
+ "test_rbfexpansion_transformer.py",
+ "test_reluexpansion_transformer.py",
+ "test_sigmoidexpansion_transformer.py",
+ "test_spline_api_parity.py",
+ "test_spline_expansions.py",
+ "test_output_dimension.py",
+ "test_exceptions.py",
+ "test_adaptive_output_dim.py",
+ "test_reproducibility.py",
+ }
+)
+
+_LEAKAGE_IGNORE = pytest.mark.filterwarnings("ignore::pretab.exceptions.LeakageWarning")
+
+
+def pytest_collection_modifyitems(items):
+ """Silence the expected leakage warning in modules that fit supervised transformers directly."""
+ for item in items:
+ if item.path.name in _LEAKAGE_EXPECTED_MODULES:
+ item.add_marker(_LEAKAGE_IGNORE)
diff --git a/tests/test_adaptive_resolution.py b/tests/core/test_adaptive_resolution.py
similarity index 95%
rename from tests/test_adaptive_resolution.py
rename to tests/core/test_adaptive_resolution.py
index 7a5a10f..fafa79b 100644
--- a/tests/test_adaptive_resolution.py
+++ b/tests/core/test_adaptive_resolution.py
@@ -15,7 +15,7 @@
from pretab.core.adaptive import AdaptiveResolutionMixin
from pretab.transformers import (
BSplineTransformer,
- CubicSplineTransformer,
+ CubicRegressionSplineTransformer,
NaturalCubicSplineTransformer,
PLETransformer,
RBFExpansionTransformer,
@@ -119,7 +119,7 @@ def test_feature_map_adaptive_is_noop_on_quantile_path(Cls, data):
# Legacy splines (target-aware placement path) #
# --------------------------------------------------------------------------- #
LEGACY_SPLINES = [
- (CubicSplineTransformer, 8),
+ (CubicRegressionSplineTransformer, 8),
(NaturalCubicSplineTransformer, 6),
]
@@ -165,12 +165,16 @@ def test_tensor_product_adaptive_is_noop(data):
"""Penalized tensor splines are unsupervised: the adaptive window is a no-op."""
X, y = data
fixed = TensorProductSplineTransformer(output_dim=5).fit_transform(X, y).shape[1]
- adaptive = TensorProductSplineTransformer(
- output_dim=5,
- adaptive=True,
- min_output_dim=4,
- max_output_dim=7,
- ).fit_transform(X, y).shape[1]
+ adaptive = (
+ TensorProductSplineTransformer(
+ output_dim=5,
+ adaptive=True,
+ min_output_dim=4,
+ max_output_dim=7,
+ )
+ .fit_transform(X, y)
+ .shape[1]
+ )
assert fixed == adaptive
@@ -220,9 +224,7 @@ def frame():
def test_preprocessor_non_adaptive_output_dim_outside_default_window(frame):
# Default min/max are 5/10; a fixed output_dim outside that must not raise.
X, y = frame
- out = Preprocessor(numerical_method="ple", output_dim=32, cat_cutoff=0.0).fit_transform(
- X, y, return_array=True
- )
+ out = Preprocessor(numerical_method="ple", output_dim=32, cat_cutoff=0.0).fit_transform(X, y, return_array=True)
assert isinstance(out, np.ndarray)
assert out.shape[1] == 64
diff --git a/tests/core/test_cross_fitted.py b/tests/core/test_cross_fitted.py
new file mode 100644
index 0000000..936d629
--- /dev/null
+++ b/tests/core/test_cross_fitted.py
@@ -0,0 +1,100 @@
+"""Tests for :class:`~pretab.core.supervised.CrossFittedTransformer` (Phase 7, P7.3).
+
+Verifies out-of-fold (leakage-free) training features, the all-data model used by
+``transform``, spec bookkeeping (``cross_fitted`` / ``n_folds``), and input
+validation.
+"""
+
+import warnings
+
+import numpy as np
+import pytest
+from sklearn.model_selection import KFold
+
+from pretab import CrossFittedTransformer, LeakageWarning
+from pretab.exceptions import IncompatibleParamsError, InvalidParamError
+from pretab.transformers import PLETransformer
+
+
+@pytest.fixture
+def data():
+ rng = np.random.default_rng(42)
+ X = rng.normal(size=(400, 1))
+ y = (X[:, 0] > 0).astype(float) + rng.normal(scale=0.1, size=400)
+ return X, y
+
+
+def test_fit_transform_is_out_of_fold(data):
+ """Each training row is encoded by a fold model that never saw it."""
+ X, y = data
+ cf = CrossFittedTransformer(PLETransformer(output_dim=8), n_folds=5, shuffle=True, random_state=0)
+ Xt = cf.fit_transform(X, y)
+
+ assert Xt.shape == (X.shape[0], 8)
+ splitter = KFold(n_splits=5, shuffle=True, random_state=0)
+ for train_idx, test_idx in splitter.split(X):
+ fold = PLETransformer(output_dim=8).fit(X[train_idx], y[train_idx])
+ expected = fold.transform(X[test_idx])
+ np.testing.assert_allclose(Xt[test_idx], expected)
+
+
+def test_cross_fitting_emits_no_leakage_warning(data):
+ X, y = data
+ cf = CrossFittedTransformer(PLETransformer(output_dim=6), n_folds=4, random_state=0)
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", LeakageWarning)
+ cf.fit_transform(X, y)
+
+
+def test_transform_uses_all_data_model(data):
+ """``transform`` on unseen data uses ``estimator_`` fit on all training data."""
+ X, y = data
+ cf = CrossFittedTransformer(PLETransformer(output_dim=6), n_folds=4, random_state=0)
+ cf.fit(X, y)
+
+ reference = PLETransformer(output_dim=6).fit(X, y)
+ X_new = np.linspace(-2, 2, 25).reshape(-1, 1)
+ np.testing.assert_allclose(cf.transform(X_new), reference.transform(X_new))
+
+
+def test_spec_records_cross_fitting(data):
+ X, y = data
+ cf = CrossFittedTransformer(PLETransformer(output_dim=6), n_folds=5, random_state=0)
+ cf.fit(X, y)
+ spec = cf.get_representation_spec(["f0"])
+
+ assert spec.cross_fitted is True
+ assert spec.n_folds == 5
+ assert spec.uses_target is True
+ assert spec.family == "piecewise_linear"
+ assert spec == type(spec).from_dict(spec.to_dict())
+
+
+def test_contract_properties(data):
+ X, y = data
+ cf = CrossFittedTransformer(PLETransformer(), n_folds=3)
+ assert cf.requires_y is True
+ assert cf.is_supervised is True
+ cf.fit(X, y)
+ assert cf.uses_target_ is True
+
+
+def test_feature_names_delegate(data):
+ X, y = data
+ cf = CrossFittedTransformer(PLETransformer(output_dim=6), n_folds=3, random_state=0)
+ cf.fit(X, y)
+ reference = PLETransformer(output_dim=6).fit(X, y)
+ np.testing.assert_array_equal(cf.get_feature_names_out(["f0"]), reference.get_feature_names_out(["f0"]))
+
+
+def test_requires_y(data):
+ X, _ = data
+ cf = CrossFittedTransformer(PLETransformer(), n_folds=3)
+ with pytest.raises(IncompatibleParamsError):
+ cf.fit(X, None)
+
+
+def test_invalid_n_folds(data):
+ X, y = data
+ with pytest.raises(InvalidParamError):
+ CrossFittedTransformer(PLETransformer(), n_folds=1).fit(X, y)
diff --git a/tests/test_feature_map_selector.py b/tests/core/test_feature_map_selector.py
similarity index 92%
rename from tests/test_feature_map_selector.py
rename to tests/core/test_feature_map_selector.py
index d078d4d..2b886c6 100644
--- a/tests/test_feature_map_selector.py
+++ b/tests/core/test_feature_map_selector.py
@@ -15,8 +15,8 @@
import pytest
from sklearn.base import clone
-from pretab.core.exceptions import InvalidParamError
from pretab.core.selectors import CARTLocationSelector
+from pretab.exceptions import InvalidParamError
from pretab.transformers import (
RBFExpansionTransformer,
ReLUExpansionTransformer,
@@ -59,9 +59,7 @@ def test_adaptive_target_path_clamps_within_window(Cls, data):
def test_centers_match_cart_location_selector(Cls, data):
X, y = data
t = Cls(output_dim=5, target_aware=True, task="regression").fit(X, y)
- expected = CARTLocationSelector().select(
- X[:, 0], y, task="regression", min_count=5, max_count=5
- )
+ expected = CARTLocationSelector().select(X[:, 0], y, task="regression", min_count=5, max_count=5)
np.testing.assert_array_equal(t.centers_[0], expected)
@@ -84,9 +82,7 @@ def test_invalid_selector_raises(data):
def test_quantile_path_ignores_selector(data):
"""The unsupervised quantile path yields exactly ``output_dim`` centers."""
X, _ = data
- t = RBFExpansionTransformer(
- output_dim=7, target_aware=False, placement_strategy="quantile"
- ).fit(X)
+ t = RBFExpansionTransformer(output_dim=7, target_aware=False, placement_strategy="quantile").fit(X)
assert all(len(c) == 7 for c in t.centers_)
diff --git a/tests/test_location_selectors.py b/tests/core/test_location_selectors.py
similarity index 88%
rename from tests/test_location_selectors.py
rename to tests/core/test_location_selectors.py
index 15fe2e3..3ed334b 100644
--- a/tests/test_location_selectors.py
+++ b/tests/core/test_location_selectors.py
@@ -1,16 +1,13 @@
import numpy as np
import pytest
-from pretab.core.exceptions import IncompatibleParamsError
from pretab.core.selectors import (
BaseLocationSelector,
CARTLocationSelector,
LightGBMLocationSelector,
)
-from pretab.transformers.splines.knot_selectors import (
- CARTKnotSelector,
- LightGBMKnotSelector,
-)
+from pretab.exceptions import IncompatibleParamsError
+from pretab.placement.adapters import SplinePlacementAdapter
@pytest.fixture
@@ -81,7 +78,7 @@ def test_cart_handles_nan_rows(data):
def test_cart_matches_knot_adapter(data):
X, y = data
- adapter = CARTKnotSelector(max_basis_functions=12, degree=3)
+ adapter = SplinePlacementAdapter(placement_strategy="cart", max_basis_functions=12, degree=3)
from_adapter = adapter.get_knot_locations(X, y, task="regression")
from_selector = CARTLocationSelector().select(
X, y, task="regression", min_count=adapter.min_knots, max_count=adapter.max_knots
@@ -92,9 +89,7 @@ def test_cart_matches_knot_adapter(data):
def test_lightgbm_select_runs(data):
pytest.importorskip("lightgbm")
X, y = data
- locations = LightGBMLocationSelector(n_estimators=30).select(
- X, y, task="regression", min_count=2, max_count=10
- )
+ locations = LightGBMLocationSelector(n_estimators=30).select(X, y, task="regression", min_count=2, max_count=10)
assert locations.ndim == 1
assert np.all(np.diff(locations) > 0)
@@ -113,9 +108,9 @@ def test_lightgbm_reproducible(data):
def test_lightgbm_matches_knot_adapter(data):
pytest.importorskip("lightgbm")
X, y = data
- adapter = LightGBMKnotSelector(n_estimators=30, max_basis_functions=12)
+ adapter = SplinePlacementAdapter(placement_strategy="lightgbm", max_basis_functions=12, degree=3)
from_adapter = adapter.get_knot_locations(X, y, task="regression")
- from_selector = LightGBMLocationSelector(n_estimators=30).select(
+ from_selector = LightGBMLocationSelector().select(
X, y, task="regression", min_count=adapter.min_knots, max_count=adapter.max_knots
)
np.testing.assert_array_equal(from_adapter, from_selector)
diff --git a/tests/test_locations.py b/tests/core/test_locations.py
similarity index 95%
rename from tests/test_locations.py
rename to tests/core/test_locations.py
index 9b58474..125b376 100644
--- a/tests/test_locations.py
+++ b/tests/core/test_locations.py
@@ -54,9 +54,7 @@ def supplement(current, target):
calls["args"] = (current.copy(), target)
return np.array([1.0, 2.0, 3.0])
- out = resolve_locations(
- np.array([5.0]), min_count=3, max_count=8, supplement=supplement
- )
+ out = resolve_locations(np.array([5.0]), min_count=3, max_count=8, supplement=supplement)
np.testing.assert_array_equal(out, [1.0, 2.0, 3.0])
assert calls["args"][1] == 3
diff --git a/tests/test_ple_selector.py b/tests/core/test_ple_selector.py
similarity index 91%
rename from tests/test_ple_selector.py
rename to tests/core/test_ple_selector.py
index 2a0f63c..498b9ac 100644
--- a/tests/test_ple_selector.py
+++ b/tests/core/test_ple_selector.py
@@ -15,8 +15,8 @@
import pytest
from sklearn.base import clone
-from pretab.core.exceptions import InvalidParamError
from pretab.core.selectors import CARTLocationSelector
+from pretab.exceptions import InvalidParamError
from pretab.transformers import PLETransformer
@@ -39,9 +39,7 @@ def test_non_adaptive_target_path_gives_exact_output_dim(data):
def test_adaptive_target_path_clamps_within_window(data):
X, y = data
- t = PLETransformer(
- output_dim=10, adaptive=True, min_output_dim=3, max_output_dim=6
- ).fit(X, y)
+ t = PLETransformer(output_dim=10, adaptive=True, min_output_dim=3, max_output_dim=6).fit(X, y)
assert all(3 <= n <= 6 for n in t.n_bins_per_feature_)
assert all(2 <= len(th) <= 5 for th in t.thresholds_)
@@ -49,9 +47,7 @@ def test_adaptive_target_path_clamps_within_window(data):
def test_thresholds_match_cart_location_selector(data):
X, y = data
t = PLETransformer(output_dim=5, task="regression").fit(X, y)
- expected = CARTLocationSelector().select(
- X[:, 0], y, task="regression", min_count=4, max_count=4
- )
+ expected = CARTLocationSelector().select(X[:, 0], y, task="regression", min_count=4, max_count=4)
np.testing.assert_array_equal(t.thresholds_[0], expected)
diff --git a/tests/core/test_representation_spec.py b/tests/core/test_representation_spec.py
new file mode 100644
index 0000000..592a098
--- /dev/null
+++ b/tests/core/test_representation_spec.py
@@ -0,0 +1,236 @@
+import warnings
+
+import numpy as np
+import pytest
+
+from pretab.core.representation import RepresentationSpec, RepresentationSpecMixin
+from pretab.transformers import (
+ BSplineTransformer,
+ ContinuousOrdinalTransformer,
+ CubicRegressionSplineTransformer,
+ FourierFeatureTransformer,
+ ISplineTransformer,
+ MSplineTransformer,
+ NaturalCubicSplineTransformer,
+ NumericBinningTransformer,
+ NystroemFeaturesTransformer,
+ OneHotFromOrdinalTransformer,
+ PeriodicEncodingTransformer,
+ PLETransformer,
+ PSplineTransformer,
+ RandomFourierFeaturesTransformer,
+ RBFExpansionTransformer,
+ ReLUExpansionTransformer,
+ SigmoidExpansionTransformer,
+ TanhExpansionTransformer,
+ TensorProductSplineTransformer,
+ ThinPlateSplineTransformer,
+)
+
+RNG = np.random.default_rng(0)
+X_UNI = np.linspace(0.1, 5.0, 80).reshape(-1, 1)
+X_MULTI = RNG.uniform(0.0, 1.0, size=(80, 2))
+X_PERIODIC = RNG.uniform(0.0, 24.0, size=(80, 1))
+X_CAT = np.array([["a"], ["b"], ["a"], ["c"]] * 20, dtype=object)
+X_ORDINAL = np.array([[0], [1], [2], [1]] * 20)
+Y = RNG.uniform(0.0, 1.0, size=80)
+
+
+def _fit(transformer, X, y=None):
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore")
+ return transformer.fit(X, y)
+
+
+# (id, transformer, X, y, expected_family, expected_scope, expected_supervision)
+CASES = [
+ ("bspline", BSplineTransformer(output_dim=6), X_UNI, None, "bspline", "univariate", "optional"),
+ ("mspline", MSplineTransformer(output_dim=6), X_UNI, None, "mspline", "univariate", "optional"),
+ ("ispline", ISplineTransformer(output_dim=6), X_UNI, None, "ispline", "univariate", "optional"),
+ (
+ "naturalspline",
+ NaturalCubicSplineTransformer(output_dim=5),
+ X_UNI,
+ None,
+ "naturalspline",
+ "univariate",
+ "optional",
+ ),
+ (
+ "cubicspline",
+ CubicRegressionSplineTransformer(output_dim=5),
+ X_UNI,
+ None,
+ "cubicspline",
+ "univariate",
+ "optional",
+ ),
+ ("pspline", PSplineTransformer(output_dim=8), X_UNI, None, "pspline", "univariate", "unsupervised"),
+ (
+ "tensorspline",
+ TensorProductSplineTransformer(output_dim=4),
+ X_MULTI,
+ None,
+ "tensorspline",
+ "multivariate",
+ "unsupervised",
+ ),
+ (
+ "thinplate",
+ ThinPlateSplineTransformer(n_components=6),
+ X_MULTI,
+ None,
+ "thinplate",
+ "multivariate",
+ "unsupervised",
+ ),
+ ("rbf", RBFExpansionTransformer(output_dim=5), X_UNI, None, "rbf", "univariate", "optional"),
+ ("relu", ReLUExpansionTransformer(output_dim=5), X_UNI, None, "relu", "univariate", "optional"),
+ ("sigmoid", SigmoidExpansionTransformer(output_dim=5), X_UNI, None, "sigmoid", "univariate", "optional"),
+ ("tanh", TanhExpansionTransformer(output_dim=5), X_UNI, None, "tanh", "univariate", "optional"),
+ (
+ "fourier",
+ FourierFeatureTransformer(n_frequencies=4),
+ X_UNI,
+ None,
+ "fourier",
+ "univariate",
+ "unsupervised",
+ ),
+ (
+ "random_fourier",
+ RandomFourierFeaturesTransformer(n_components=10, random_state=0),
+ X_MULTI,
+ None,
+ "random_fourier",
+ "multivariate",
+ "unsupervised",
+ ),
+ (
+ "nystroem",
+ NystroemFeaturesTransformer(n_components=8, random_state=0),
+ X_MULTI,
+ None,
+ "nystroem",
+ "multivariate",
+ "unsupervised",
+ ),
+ (
+ "periodic",
+ PeriodicEncodingTransformer(period=24, harmonics=2),
+ X_PERIODIC,
+ None,
+ "periodic",
+ "univariate",
+ "unsupervised",
+ ),
+ (
+ "binning",
+ NumericBinningTransformer(output_dim=4, encode="onehot"),
+ X_UNI,
+ None,
+ "binning",
+ "univariate",
+ "unsupervised",
+ ),
+ (
+ "piecewise_linear",
+ PLETransformer(output_dim=4),
+ X_UNI,
+ Y,
+ "piecewise_linear",
+ "univariate",
+ "supervised",
+ ),
+ ("ordinal", ContinuousOrdinalTransformer(), X_CAT, None, "ordinal", "univariate", "unsupervised"),
+ (
+ "onehot",
+ OneHotFromOrdinalTransformer(),
+ X_ORDINAL,
+ None,
+ "onehot",
+ "univariate",
+ "unsupervised",
+ ),
+]
+CASE_IDS = [case[0] for case in CASES]
+
+
+@pytest.mark.parametrize(
+ ("transformer", "X", "y", "family", "scope", "supervision"),
+ [case[1:] for case in CASES],
+ ids=CASE_IDS,
+)
+def test_get_representation_spec_metadata(transformer, X, y, family, scope, supervision):
+ _fit(transformer, X, y)
+ spec = transformer.get_representation_spec()
+ assert isinstance(spec, RepresentationSpec)
+ assert spec.family == family
+ assert spec.scope == scope
+ assert spec.supervision == supervision
+ assert spec.is_interaction == (scope == "multivariate")
+
+
+@pytest.mark.parametrize(
+ ("transformer", "X", "y"),
+ [(case[1], case[2], case[3]) for case in CASES],
+ ids=CASE_IDS,
+)
+def test_output_features_match_get_feature_names_out(transformer, X, y):
+ _fit(transformer, X, y)
+ input_features = [f"col{i}" for i in range(transformer.n_features_in_)]
+ spec = transformer.get_representation_spec(input_features=input_features)
+ expected = tuple(str(name) for name in transformer.get_feature_names_out(input_features))
+ assert spec.output_features == expected
+ assert spec.output_dim == len(expected)
+ assert spec.input_features == tuple(input_features)
+
+
+@pytest.mark.parametrize(
+ ("transformer", "X", "y"),
+ [(case[1], case[2], case[3]) for case in CASES],
+ ids=CASE_IDS,
+)
+def test_spec_round_trips_through_dict(transformer, X, y):
+ _fit(transformer, X, y)
+ spec = transformer.get_representation_spec()
+ assert RepresentationSpec.from_dict(spec.to_dict()) == spec
+
+
+def test_every_transformer_has_representation_spec():
+ for _id, transformer, *_ in CASES:
+ assert isinstance(transformer, RepresentationSpecMixin)
+ assert hasattr(transformer, "get_representation_spec")
+
+
+def test_periodic_spec_reports_period():
+ x_month = RNG.uniform(0.0, 12.0, size=(80, 1))
+ transformer = _fit(PeriodicEncodingTransformer(period=12, harmonics=1), x_month)
+ spec = transformer.get_representation_spec()
+ assert spec.periodic is True
+ assert spec.period == 12.0
+
+
+def test_spline_spec_exposes_knots_and_degree():
+ transformer = _fit(BSplineTransformer(output_dim=6, degree=3), X_UNI)
+ spec = transformer.get_representation_spec()
+ assert spec.degree == 3
+ assert spec.location_kind == "knots"
+ assert spec.locations is not None
+ assert all(isinstance(value, float) for group in spec.locations for value in group)
+
+
+def test_center_expansion_spec_exposes_centers():
+ transformer = _fit(RBFExpansionTransformer(output_dim=5), X_UNI)
+ spec = transformer.get_representation_spec()
+ assert spec.component_kind == "center"
+ assert spec.location_kind == "centers"
+ assert spec.local_support is True
+
+
+def test_to_dict_is_json_friendly():
+ transformer = _fit(BSplineTransformer(output_dim=5), X_UNI)
+ data = transformer.get_representation_spec().to_dict()
+ assert isinstance(data["input_features"], list)
+ assert isinstance(data["output_features"], list)
+ assert data["locations"] is None or isinstance(data["locations"], list)
diff --git a/tests/core/test_supervised_contract.py b/tests/core/test_supervised_contract.py
new file mode 100644
index 0000000..32ea18d
--- /dev/null
+++ b/tests/core/test_supervised_contract.py
@@ -0,0 +1,129 @@
+"""Tests for the leakage-safe supervised contract (Phase 7, P7.1 + P7.2).
+
+Covers the transformer contract properties (``requires_y`` / ``is_supervised`` /
+``uses_target_``) and the :class:`~pretab.exceptions.LeakageWarning` emitted when
+a supervised transformer is fit on ``(X, y)`` outside a controlled context.
+"""
+
+import warnings
+
+import numpy as np
+import pytest
+from sklearn.linear_model import Ridge
+from sklearn.pipeline import Pipeline
+
+from pretab import LeakageWarning, Preprocessor
+from pretab.compose.registry import get_spec
+from pretab.core.supervised import in_controlled_context, warn_target_leakage
+from pretab.transformers import (
+ BSplineTransformer,
+ PLETransformer,
+ RBFExpansionTransformer,
+)
+
+
+@pytest.fixture
+def data():
+ rng = np.random.default_rng(0)
+ X = rng.normal(size=(200, 1))
+ y = (X[:, 0] > 0).astype(float) + rng.normal(scale=0.1, size=200)
+ return X, y
+
+
+# --- P7.1: contract properties ---------------------------------------------
+
+
+def test_ple_is_always_supervised(data):
+ X, y = data
+ ple = PLETransformer()
+ assert ple.requires_y is True
+ assert ple.is_supervised is True
+ ple.fit(X, y)
+ assert ple.uses_target_ is True
+
+
+def test_unsupervised_spline_reports_not_supervised(data):
+ X, _ = data
+ spline = BSplineTransformer(target_aware=False)
+ assert spline.requires_y is False
+ assert spline.is_supervised is False
+ spline.fit(X)
+ assert spline.uses_target_ is False
+
+
+def test_optional_transformer_flips_with_target_aware(data):
+ X, y = data
+ rbf_off = RBFExpansionTransformer(target_aware=False)
+ assert rbf_off.requires_y is False
+ assert rbf_off.is_supervised is False
+
+ rbf_on = RBFExpansionTransformer(target_aware=True)
+ assert rbf_on.requires_y is False
+ assert rbf_on.is_supervised is True
+ rbf_on.fit(X, y)
+ assert rbf_on.uses_target_ is True
+
+
+def test_registry_supervised_flags():
+ assert get_spec("ple").requires_y is True
+ assert get_spec("ple").is_supervised is True
+ assert get_spec("rbf").requires_y is False
+ assert get_spec("rbf").is_supervised is True
+ assert get_spec("standardization").is_supervised is False
+
+
+# --- P7.2: leakage warning --------------------------------------------------
+
+
+def test_direct_supervised_fit_warns(data):
+ X, y = data
+ with pytest.warns(LeakageWarning):
+ PLETransformer().fit(X, y)
+
+
+def test_target_aware_spline_direct_fit_warns(data):
+ X, y = data
+ with pytest.warns(LeakageWarning):
+ BSplineTransformer(target_aware=True, placement_strategy="cart").fit(X, y)
+
+
+def test_unsupervised_fit_does_not_warn(data):
+ X, _ = data
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", LeakageWarning)
+ BSplineTransformer(target_aware=False).fit(X)
+
+
+def test_no_warning_without_target(data):
+ rbf = RBFExpansionTransformer(target_aware=True)
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", LeakageWarning)
+ warn_target_leakage(rbf, None)
+
+
+def test_no_warning_inside_pipeline(data):
+ X, y = data
+ pipe = Pipeline([("ple", PLETransformer()), ("ridge", Ridge())])
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", LeakageWarning)
+ pipe.fit(X, y)
+
+
+def test_no_warning_inside_preprocessor(data):
+ X, y = data
+ pre = Preprocessor(numerical_method="ple", target_aware=True)
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", LeakageWarning)
+ pre.fit_transform(X, y)
+
+
+def test_in_controlled_context_default_false():
+ assert in_controlled_context() is False
+
+
+def test_warn_helper_ignores_unsupervised(data):
+ _, y = data
+ est = BSplineTransformer(target_aware=False)
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", LeakageWarning)
+ warn_target_leakage(est, y)
diff --git a/tests/doc_snippets/test_tutorial_snippets.py b/tests/doc_snippets/test_tutorial_snippets.py
new file mode 100644
index 0000000..fcf1912
--- /dev/null
+++ b/tests/doc_snippets/test_tutorial_snippets.py
@@ -0,0 +1,45 @@
+"""Executes the python code fences in the tutorial pages so API drift breaks CI
+instead of the docs quietly going stale.
+
+Notebook execution (``myst-nb`` / ``nbmake``) is intentionally out of scope for
+1.0; this is the lighter-weight alternative: each tutorial's ```python blocks run
+in one shared namespace, in source order, exactly as written on the page. Blocks
+that only show expected console output (```text fences) are not touched.
+"""
+
+import re
+from pathlib import Path
+
+import pytest
+
+TUTORIALS_DIR = Path(__file__).parents[2] / "docs" / "tutorials"
+
+_FENCE = re.compile(r"^```python\n(.*?)^```\s*$", re.DOTALL | re.MULTILINE)
+
+
+def _code_blocks(path: Path) -> list[tuple[int, str]]:
+ """Return (1-based start line, source) for every python fence in ``path``.
+
+ The source is padded with leading blank lines so a traceback raised while
+ executing it reports the real line number in the markdown file.
+ """
+ text = path.read_text(encoding="utf-8")
+ blocks = []
+ for match in _FENCE.finditer(text):
+ start_line = text.count("\n", 0, match.start()) + 2
+ padded = "\n" * (start_line - 1) + match.group(1)
+ blocks.append((start_line, padded))
+ return blocks
+
+
+_TUTORIALS = sorted(TUTORIALS_DIR.glob("*.md"))
+
+
+@pytest.mark.parametrize("tutorial", _TUTORIALS, ids=[p.stem for p in _TUTORIALS])
+def test_tutorial_code_runs(tutorial):
+ namespace: dict = {"__name__": "__main__"}
+ for start_line, source in _code_blocks(tutorial):
+ try:
+ exec(compile(source, str(tutorial), "exec"), namespace) # noqa: S102
+ except Exception as exc:
+ raise AssertionError(f"{tutorial.name}:{start_line} raised {exc!r}") from exc
diff --git a/tests/extension/conftest.py b/tests/extension/conftest.py
new file mode 100644
index 0000000..6e560a0
--- /dev/null
+++ b/tests/extension/conftest.py
@@ -0,0 +1,26 @@
+"""Shared fixtures for the extension-protocol tests.
+
+Registration mutates process-global registry state, so every test in this
+package runs against a snapshot that is restored afterwards to keep the suite
+order-independent.
+"""
+
+import pytest
+
+from pretab.compose import registry
+
+
+@pytest.fixture(autouse=True)
+def _restore_registry():
+ saved_registry = dict(registry.TRANSFORMER_REGISTRY)
+ saved_numerical = dict(registry.NUMERICAL_METHODS)
+ saved_categorical = set(registry.CATEGORICAL_METHODS)
+ try:
+ yield
+ finally:
+ registry.TRANSFORMER_REGISTRY.clear()
+ registry.TRANSFORMER_REGISTRY.update(saved_registry)
+ registry.NUMERICAL_METHODS.clear()
+ registry.NUMERICAL_METHODS.update(saved_numerical)
+ registry.CATEGORICAL_METHODS.clear()
+ registry.CATEGORICAL_METHODS.update(saved_categorical)
diff --git a/tests/extension/test_conformance.py b/tests/extension/test_conformance.py
new file mode 100644
index 0000000..84aeba8
--- /dev/null
+++ b/tests/extension/test_conformance.py
@@ -0,0 +1,154 @@
+"""Tests for the ``check_representation`` conformance suite (P10.3)."""
+
+import numpy as np
+import pytest
+from sklearn.utils.validation import check_is_fitted
+
+from pretab import BaseRepresentation, check_representation
+from pretab.exceptions import RepresentationConformanceError
+
+
+class _Good(BaseRepresentation):
+ representation_name = "good_conf"
+ feature_kind = "numerical"
+
+ def fit(self, X, y=None):
+ self._validate(X, reset=True)
+ return self
+
+ def transform(self, X):
+ check_is_fitted(self, "n_features_in_")
+ return np.asarray(self._validate(X, reset=False), dtype=float) ** 2
+
+ def _output_sizes(self):
+ return [1] * self.n_features_in_
+
+
+class _GoodSupervised(BaseRepresentation):
+ representation_name = "good_sup_conf"
+ supervision = "supervised"
+
+ def fit(self, X, y=None):
+ if y is None:
+ raise ValueError("y is required")
+ self._validate(X, reset=True)
+ self.scale_ = float(np.mean(y)) or 1.0
+ return self
+
+ def transform(self, X):
+ check_is_fitted(self, "n_features_in_")
+ return np.asarray(self._validate(X, reset=False), dtype=float) * self.scale_
+
+ def _output_sizes(self):
+ return [1] * self.n_features_in_
+
+
+def test_good_representation_passes():
+ passed = check_representation(_Good)
+ assert "unfitted_transform_raises" in passed
+ assert "fit_returns_self_no_mutation" in passed
+ assert "deterministic" in passed
+ assert "spec_consistent" in passed
+
+
+def test_good_supervised_representation_passes():
+ passed = check_representation(_GoodSupervised)
+ assert "supervised_requires_y" in passed
+
+
+def test_fit_not_returning_self_fails():
+ class _NoSelf(BaseRepresentation):
+ representation_name = "noself_conf"
+
+ def fit(self, X, y=None):
+ self._validate(X, reset=True) # returns None
+
+ def transform(self, X):
+ check_is_fitted(self, "n_features_in_")
+ return np.asarray(self._validate(X, reset=False), dtype=float)
+
+ def _output_sizes(self):
+ return [1] * self.n_features_in_
+
+ with pytest.raises(RepresentationConformanceError, match="must return self"):
+ check_representation(_NoSelf)
+
+
+def test_missing_unfitted_guard_fails():
+ class _NoGuard(BaseRepresentation):
+ representation_name = "noguard_conf"
+
+ def fit(self, X, y=None):
+ self._validate(X, reset=True)
+ return self
+
+ def transform(self, X):
+ return np.asarray(X, dtype=float) ** 2
+
+ def _output_sizes(self):
+ return [1] * self.n_features_in_
+
+ with pytest.raises(RepresentationConformanceError, match="NotFittedError"):
+ check_representation(_NoGuard)
+
+
+def test_feature_names_length_mismatch_fails():
+ class _BadNames(BaseRepresentation):
+ representation_name = "badnames_conf"
+
+ def fit(self, X, y=None):
+ self._validate(X, reset=True)
+ return self
+
+ def transform(self, X):
+ check_is_fitted(self, "n_features_in_")
+ return np.asarray(self._validate(X, reset=False), dtype=float)
+
+ def get_feature_names_out(self, input_features=None):
+ return np.array(["a", "b"]) # width is 1, so length 2 is wrong
+
+ def _output_sizes(self):
+ return [1] * self.n_features_in_
+
+ with pytest.raises(RepresentationConformanceError, match="get_feature_names_out length"):
+ check_representation(_BadNames)
+
+
+def test_input_mutation_fails():
+ class _Mutates(BaseRepresentation):
+ representation_name = "mutates_conf"
+
+ def fit(self, X, y=None):
+ np.asarray(X)[:] = 0.0
+ self._validate(X, reset=True)
+ return self
+
+ def transform(self, X):
+ check_is_fitted(self, "n_features_in_")
+ return np.asarray(self._validate(X, reset=False), dtype=float)
+
+ def _output_sizes(self):
+ return [1] * self.n_features_in_
+
+ with pytest.raises(RepresentationConformanceError, match="must not mutate"):
+ check_representation(_Mutates)
+
+
+def test_supervised_that_ignores_y_fails():
+ class _IgnoresY(BaseRepresentation):
+ representation_name = "ignoresy_conf"
+ supervision = "supervised"
+
+ def fit(self, X, y=None):
+ self._validate(X, reset=True)
+ return self
+
+ def transform(self, X):
+ check_is_fitted(self, "n_features_in_")
+ return np.asarray(self._validate(X, reset=False), dtype=float)
+
+ def _output_sizes(self):
+ return [1] * self.n_features_in_
+
+ with pytest.raises(RepresentationConformanceError, match="fit succeeded without y"):
+ check_representation(_IgnoresY)
diff --git a/tests/extension/test_extension_protocol.py b/tests/extension/test_extension_protocol.py
new file mode 100644
index 0000000..de89ae3
--- /dev/null
+++ b/tests/extension/test_extension_protocol.py
@@ -0,0 +1,83 @@
+"""Tests for the public ``BaseRepresentation`` extension base (P10.1)."""
+
+import numpy as np
+import pytest
+from sklearn.utils.validation import check_is_fitted
+
+from pretab import BaseRepresentation, RepresentationSpec
+
+
+class _Square(BaseRepresentation):
+ representation_name = "square_proto"
+ feature_kind = "numerical"
+ scope = "univariate"
+ supervision = "unsupervised"
+
+ def fit(self, X, y=None):
+ self._validate(X, reset=True)
+ return self
+
+ def transform(self, X):
+ check_is_fitted(self, "n_features_in_")
+ X = self._validate(X, reset=False)
+ return np.asarray(X, dtype=float) ** 2
+
+ def _output_sizes(self):
+ return [1] * self.n_features_in_
+
+
+def test_declared_metadata_syncs_internal_hooks():
+ assert _Square._representation_family == "square_proto"
+ assert _Square._representation_scope == "univariate"
+ assert _Square._representation_supervision == "unsupervised"
+ assert _Square._requires_y is False
+
+
+def test_supervised_flag_sets_requires_y():
+ class _Sup(BaseRepresentation):
+ representation_name = "sup_proto"
+ supervision = "supervised"
+
+ def fit(self, X, y=None):
+ self._validate(X, reset=True)
+ return self
+
+ def transform(self, X):
+ check_is_fitted(self, "n_features_in_")
+ return np.asarray(self._validate(X, reset=False), dtype=float)
+
+ def _output_sizes(self):
+ return [1] * self.n_features_in_
+
+ assert _Sup._requires_y is True
+ assert _Sup._representation_supervision == "supervised"
+
+
+def test_representation_spec_reflects_declaration():
+ X = np.linspace(0.0, 1.0, 20).reshape(-1, 1)
+ est = _Square().fit(X)
+ spec = est.get_representation_spec()
+ assert isinstance(spec, RepresentationSpec)
+ assert spec.scope == "univariate"
+ assert spec.output_dim == 1
+
+
+def test_invalid_scope_rejected():
+ with pytest.raises(ValueError, match="scope"):
+
+ class _Bad(BaseRepresentation):
+ scope = "triple"
+
+
+def test_invalid_supervision_rejected():
+ with pytest.raises(ValueError, match="supervision"):
+
+ class _Bad(BaseRepresentation):
+ supervision = "sometimes"
+
+
+def test_invalid_feature_kind_rejected():
+ with pytest.raises(ValueError, match="feature_kind"):
+
+ class _Bad(BaseRepresentation):
+ feature_kind = "ordinal"
diff --git a/tests/extension/test_registration_discovery.py b/tests/extension/test_registration_discovery.py
new file mode 100644
index 0000000..f92a526
--- /dev/null
+++ b/tests/extension/test_registration_discovery.py
@@ -0,0 +1,159 @@
+"""Tests for representation registration, entry-point loading, and discovery.
+
+Covers P10.2 (``register_representation`` + entry points) and P10.4
+(``list_representations`` capability discovery).
+"""
+
+import importlib.metadata as importlib_metadata
+
+import numpy as np
+import pandas as pd
+import pytest
+from sklearn.utils.validation import check_is_fitted
+
+from pretab import (
+ BaseRepresentation,
+ Preprocessor,
+ list_representations,
+ load_entry_point_representations,
+ register_representation,
+)
+from pretab.compose import registry
+from pretab.exceptions import ConfigWarning
+
+
+class _Square(BaseRepresentation):
+ representation_name = "square_reg"
+ feature_kind = "numerical"
+
+ def fit(self, X, y=None):
+ self._validate(X, reset=True)
+ return self
+
+ def transform(self, X):
+ check_is_fitted(self, "n_features_in_")
+ return np.asarray(self._validate(X, reset=False), dtype=float) ** 2
+
+ def _output_sizes(self):
+ return [1] * self.n_features_in_
+
+
+class _CatPassthrough(BaseRepresentation):
+ representation_name = "cat_reg"
+ feature_kind = "categorical"
+
+ def fit(self, X, y=None):
+ self._validate(X, reset=True)
+ return self
+
+ def transform(self, X):
+ check_is_fitted(self, "n_features_in_")
+ return np.asarray(self._validate(X, reset=False), dtype=float)
+
+ def _output_sizes(self):
+ return [1] * self.n_features_in_
+
+
+def test_register_makes_method_selectable_and_discoverable():
+ register_representation("square_reg", _Square)
+ assert "square_reg" in registry.NUMERICAL_METHODS
+ assert "square_reg" in list_representations(feature_kind="numerical")
+
+
+def test_register_end_to_end_through_preprocessor():
+ register_representation("square_reg", _Square)
+ X = pd.DataFrame({"a": np.linspace(0, 3, 12), "b": np.linspace(-2, 2, 12)})
+ pre = Preprocessor(
+ numerical_method="square_reg",
+ categorical_method="none",
+ target_aware=False,
+ placement_strategy="uniform",
+ )
+ out = np.asarray(pre.fit_transform(X, return_array=True))
+ assert out.shape == (12, 2)
+ # The Preprocessor scales columns into [0, 1] before applying the method, so
+ # the registered "square" method yields non-negative values bounded by 1.
+ assert (out >= -1e-9).all()
+ assert out.max() <= 1 + 1e-9
+
+
+def test_register_categorical_updates_categorical_view():
+ register_representation("cat_reg", _CatPassthrough)
+ assert "cat_reg" in registry.CATEGORICAL_METHODS
+ assert "cat_reg" in list_representations(feature_kind="categorical")
+
+
+def test_duplicate_registration_requires_override():
+ register_representation("square_reg", _Square)
+ with pytest.raises(ValueError, match="already registered"):
+ register_representation("square_reg", _Square)
+ # override replaces without raising.
+ register_representation("square_reg", _Square, override=True)
+
+
+def test_register_validation_errors():
+ with pytest.raises(ValueError, match="non-empty"):
+ register_representation("", _Square)
+ with pytest.raises(TypeError, match="cls must be a class"):
+ register_representation("bad", _Square())
+ with pytest.raises(ValueError, match="feature_kind"):
+ register_representation("bad", _Square, feature_kind="ordinal")
+
+
+def test_list_representations_capability_filters():
+ assert list_representations(periodic=True) == ["fourier"]
+ assert list_representations(sparse_output=True) == ["one-hot"]
+ assert "ple" in list_representations(supervised=True)
+ multivariate = list_representations(scope="multivariate")
+ assert "tensorspline" in multivariate
+ assert "fourier" not in multivariate
+ categorical = list_representations(feature_kind="categorical")
+ assert {"int", "one-hot"}.issubset(set(categorical))
+
+
+def test_list_representations_include_optional_toggle():
+ with_optional = list_representations(feature_kind="categorical")
+ without_optional = list_representations(feature_kind="categorical", include_optional=False)
+ assert "pretrained" in with_optional
+ assert "pretrained" not in without_optional
+
+
+def test_load_entry_point_representations_registers(monkeypatch):
+ class _EpRep(BaseRepresentation):
+ representation_name = "ep_square"
+ feature_kind = "numerical"
+
+ def fit(self, X, y=None):
+ self._validate(X, reset=True)
+ return self
+
+ def transform(self, X):
+ check_is_fitted(self, "n_features_in_")
+ return np.asarray(self._validate(X, reset=False), dtype=float)
+
+ def _output_sizes(self):
+ return [1] * self.n_features_in_
+
+ class _FakeEntryPoint:
+ name = "ep_square_entry"
+
+ def load(self):
+ return _EpRep
+
+ monkeypatch.setattr(importlib_metadata, "entry_points", lambda group=None: [_FakeEntryPoint()])
+ loaded = load_entry_point_representations()
+ assert loaded == ["ep_square"]
+ assert "ep_square" in registry.TRANSFORMER_REGISTRY
+
+
+def test_load_entry_point_representations_skips_broken(monkeypatch):
+ class _BrokenEntryPoint:
+ name = "broken_entry"
+
+ def load(self):
+ raise ImportError("boom")
+
+ monkeypatch.setattr(importlib_metadata, "entry_points", lambda group=None: [_BrokenEntryPoint()])
+ with pytest.warns(ConfigWarning, match="broken_entry"):
+ loaded = load_entry_point_representations()
+ assert loaded == []
diff --git a/tests/test_adaptive_output_dim.py b/tests/integration/test_adaptive_output_dim.py
similarity index 67%
rename from tests/test_adaptive_output_dim.py
rename to tests/integration/test_adaptive_output_dim.py
index 745beb0..f589127 100644
--- a/tests/test_adaptive_output_dim.py
+++ b/tests/integration/test_adaptive_output_dim.py
@@ -9,10 +9,8 @@
* **Adaptive mode** (``adaptive=True``) -- width must be data-driven inside
``[min_output_dim, max_output_dim]`` for the adaptive-capable families.
-They also cover the categorical ``custombin`` path: the ``Preprocessor`` now
-forwards ``output_dim`` to it, so a numeric-categorical column bins end to end,
-while string categoricals raise a clear ``PretabDataError`` (``custombin`` is
-numeric-only).
+``custombin`` is numeric-only: it is selectable as a numerical method (and
+forwards ``output_dim``), while selecting it as a categorical method is rejected.
"""
from typing import cast
@@ -21,11 +19,11 @@
import pandas as pd
import pytest
-from pretab.core.exceptions import PretabDataError
+from pretab.exceptions import InvalidParamError
from pretab.preprocessor import Preprocessor
-from pretab.transformers.splines.bspline import BSplineTransformer
-from pretab.transformers.splines.integrated_spline import ISplineTransformer
-from pretab.transformers.splines.mspline import MSplineTransformer
+from pretab.transformers.splines.b_spline import BSplineTransformer
+from pretab.transformers.splines.i_spline import ISplineTransformer
+from pretab.transformers.splines.m_spline import MSplineTransformer
OUTPUT_DIM = 6
@@ -43,6 +41,8 @@
"polynomial": 4,
# binning collapses to a single integer-coded column
"custombin": 1,
+ # deterministic Fourier map: 2 * default n_frequencies (5) sine/cosine columns
+ "fourier": 10,
# width-driven expansions -> exactly output_dim
"ple": OUTPUT_DIM,
"rbf": OUTPUT_DIM,
@@ -52,8 +52,6 @@
"cubicspline": OUTPUT_DIM,
"naturalspline": OUTPUT_DIM,
"pspline": OUTPUT_DIM,
- "tensorspline": OUTPUT_DIM,
- "tprs": OUTPUT_DIM,
"mspline": OUTPUT_DIM,
"ispline": OUTPUT_DIM,
# B-spline defaults to include_bias=True -> output_dim + 1
@@ -75,16 +73,14 @@
]
# Fixed-only spline families: target-aware placement does not apply. The
-# penalized splines (``pspline``, ``tensorspline``) assume equally-spaced knots
-# for their difference penalty, and the thin-plate spline (``tprs``) is
-# kernel-based (knot-free). All three stay fixed-width regardless of the adaptive
-# / selector knobs.
-FIXED_ONLY_SPLINE_METHODS = ["pspline", "tensorspline", "tprs"]
+# penalized ``pspline`` assumes equally-spaced knots for its difference penalty,
+# so it stays fixed-width regardless of the adaptive / selector knobs. (The
+# multivariate ``tensorspline`` / ``tprs`` are standalone-only and not selectable
+# through the Preprocessor, so they are exercised in their own transformer tests.)
+FIXED_ONLY_SPLINE_METHODS = ["pspline"]
# All spline families (kept for callers that want the full set).
-SPLINE_METHODS = (
- TARGET_AWARE_LEGACY_SPLINE_METHODS + FIXED_ONLY_SPLINE_METHODS + BMI_SPLINE_METHODS
-)
+SPLINE_METHODS = TARGET_AWARE_LEGACY_SPLINE_METHODS + FIXED_ONLY_SPLINE_METHODS + BMI_SPLINE_METHODS
@pytest.fixture
@@ -111,8 +107,13 @@ def _num_width(X, y, method, **kwargs):
def test_fixed_width_matches_expected(data, method):
X, y = data
width = _num_width(
- X, y, method,
- output_dim=OUTPUT_DIM, adaptive=False, target_aware=True, task="regression",
+ X,
+ y,
+ method,
+ output_dim=OUTPUT_DIM,
+ adaptive=False,
+ target_aware=True,
+ task="regression",
)
assert width == FIXED_WIDTH[method], f"{method}: got {width}, want {FIXED_WIDTH[method]}"
@@ -122,8 +123,13 @@ def test_ple_fixed_width_tracks_output_dim(data, output_dim):
"""PLE in fixed mode produces exactly ``output_dim`` bins."""
X, y = data
width = _num_width(
- X, y, "ple",
- output_dim=output_dim, adaptive=False, target_aware=True, task="regression",
+ X,
+ y,
+ "ple",
+ output_dim=output_dim,
+ adaptive=False,
+ target_aware=True,
+ task="regression",
)
assert width == output_dim
@@ -146,13 +152,24 @@ def test_custombin_respects_bin_count(data, output_dim):
def test_adaptive_width_within_window(data, method):
X, y = data
fixed = _num_width(
- X, y, method,
- output_dim=10, adaptive=False, target_aware=True, task="regression",
+ X,
+ y,
+ method,
+ output_dim=10,
+ adaptive=False,
+ target_aware=True,
+ task="regression",
)
adaptive = _num_width(
- X, y, method,
- output_dim=10, adaptive=True, min_output_dim=3, max_output_dim=5,
- target_aware=True, task="regression",
+ X,
+ y,
+ method,
+ output_dim=10,
+ adaptive=True,
+ min_output_dim=3,
+ max_output_dim=5,
+ target_aware=True,
+ task="regression",
)
assert fixed == 10
assert 3 <= adaptive <= 5
@@ -170,10 +187,19 @@ def test_spline_adaptive_transformer_level(data, cls):
X, y = data
Xv = X.to_numpy()
fixed = cls(output_dim=10).fit_transform(Xv, y).shape[1]
- adaptive = cls(
- output_dim=10, adaptive=True, min_output_dim=4, max_output_dim=5,
- target_aware=True, placement_strategy="cart", task="regression",
- ).fit_transform(Xv, y).shape[1]
+ adaptive = (
+ cls(
+ output_dim=10,
+ adaptive=True,
+ min_output_dim=4,
+ max_output_dim=5,
+ target_aware=True,
+ placement_strategy="cart",
+ task="regression",
+ )
+ .fit_transform(Xv, y)
+ .shape[1]
+ )
assert adaptive < fixed
assert adaptive <= 5 + 1 # allow the optional bias column
@@ -186,13 +212,24 @@ def test_bmi_spline_adaptive_via_preprocessor(data, method):
"""B/M/I splines size each feature inside the adaptive window through the pipeline."""
X, y = data
fixed = _num_width(
- X, y, method,
- output_dim=10, adaptive=False, target_aware=True, task="regression",
+ X,
+ y,
+ method,
+ output_dim=10,
+ adaptive=False,
+ target_aware=True,
+ task="regression",
)
adaptive = _num_width(
- X, y, method,
- output_dim=10, adaptive=True, min_output_dim=4, max_output_dim=6,
- target_aware=True, task="regression",
+ X,
+ y,
+ method,
+ output_dim=10,
+ adaptive=True,
+ min_output_dim=4,
+ max_output_dim=6,
+ target_aware=True,
+ task="regression",
)
assert adaptive < fixed
assert 4 <= adaptive <= 6 + 1 # allow the optional bias column
@@ -203,9 +240,16 @@ def test_bmi_spline_selector_choice_via_preprocessor(data):
pytest.importorskip("lightgbm")
X, y = data
adaptive = _num_width(
- X, y, "bspline",
- output_dim=10, adaptive=True, min_output_dim=4, max_output_dim=6,
- target_aware=True, task="regression", placement_strategy="lightgbm",
+ X,
+ y,
+ "bspline",
+ output_dim=10,
+ adaptive=True,
+ min_output_dim=4,
+ max_output_dim=6,
+ target_aware=True,
+ task="regression",
+ placement_strategy="lightgbm",
)
assert 4 <= adaptive <= 6 + 1 # allow the optional bias column
@@ -218,13 +262,24 @@ def test_legacy_spline_adaptive_via_preprocessor(data, method):
"""Legacy knot splines size each feature inside the adaptive window through the pipeline."""
X, y = data
fixed = _num_width(
- X, y, method,
- output_dim=10, adaptive=False, target_aware=True, task="regression",
+ X,
+ y,
+ method,
+ output_dim=10,
+ adaptive=False,
+ target_aware=True,
+ task="regression",
)
adaptive = _num_width(
- X, y, method,
- output_dim=10, adaptive=True, min_output_dim=4, max_output_dim=6,
- target_aware=True, task="regression",
+ X,
+ y,
+ method,
+ output_dim=10,
+ adaptive=True,
+ min_output_dim=4,
+ max_output_dim=6,
+ target_aware=True,
+ task="regression",
)
assert adaptive < fixed
assert 4 <= adaptive <= 6
@@ -232,41 +287,43 @@ def test_legacy_spline_adaptive_via_preprocessor(data, method):
@pytest.mark.parametrize("method", FIXED_ONLY_SPLINE_METHODS)
def test_fixed_only_spline_ignores_adaptive(data, method):
- """Penalized / kernel splines are not target-aware: the adaptive window is a no-op.
+ """Penalized splines are not target-aware: the adaptive window is a no-op.
- ``pspline`` / ``tensorspline`` need equally-spaced knots for their difference
- penalty and ``tprs`` is knot-free, so the Preprocessor never routes them
- through the selector / adaptive path -- the width stays ``output_dim``.
+ ``pspline`` needs equally-spaced knots for its difference penalty, so the
+ Preprocessor never routes it through the selector / adaptive path -- the width
+ stays ``output_dim``.
"""
X, y = data
fixed = _num_width(
- X, y, method,
- output_dim=10, adaptive=False, target_aware=True, task="regression",
+ X,
+ y,
+ method,
+ output_dim=10,
+ adaptive=False,
+ target_aware=True,
+ task="regression",
)
adaptive = _num_width(
- X, y, method,
- output_dim=10, adaptive=True, min_output_dim=4, max_output_dim=6,
- target_aware=True, task="regression",
+ X,
+ y,
+ method,
+ output_dim=10,
+ adaptive=True,
+ min_output_dim=4,
+ max_output_dim=6,
+ target_aware=True,
+ task="regression",
)
assert fixed == adaptive == 10
# --------------------------------------------------------------------------- #
-# Categorical custombin: numeric-only, wired through the Preprocessor.
+# Categorical custombin: numeric-only, no longer selectable on the categorical side.
# --------------------------------------------------------------------------- #
-def test_categorical_custombin_via_preprocessor():
- """A numeric, low-cardinality column routes to custombin and bins end to end."""
+def test_categorical_custombin_no_longer_selectable():
+ """custombin is numeric-only: selecting it as a categorical method is rejected."""
# Integer codes with few unique values -> detected as categorical (ratio < cat_cutoff).
Xcat = pd.DataFrame({"g": np.array([0, 1, 2, 3, 4, 5] * 50)})
pre = Preprocessor(numerical_method="none", categorical_method="custombin", output_dim=4)
- out = pre.fit_transform(Xcat, return_array=True)
- # custombin always emits a single ordinal column.
- assert out.shape == (Xcat.shape[0], 1)
-
-
-def test_categorical_custombin_rejects_string_input():
- """custombin is numeric-only: string categoricals raise a clear error."""
- Xcat = pd.DataFrame({"g": np.array(["a", "b", "c"] * 100)})
- pre = Preprocessor(numerical_method="none", categorical_method="custombin", output_dim=4)
- with pytest.raises(PretabDataError):
+ with pytest.raises(InvalidParamError):
pre.fit_transform(Xcat)
diff --git a/tests/integration/test_edge_case_contract.py b/tests/integration/test_edge_case_contract.py
new file mode 100644
index 0000000..97e8b07
--- /dev/null
+++ b/tests/integration/test_edge_case_contract.py
@@ -0,0 +1,260 @@
+"""Edge-case contract for every transformer family (roadmap Phase 8, P8.2).
+
+These tests pin down how each numerical representation family reacts to the
+degenerate inputs that show up in real tabular data: a constant column, a fully
+missing column, partially missing values, out-of-range values at transform time,
+non-finite values, duplicate support points, and too few samples. The behaviour
+asserted here *is* the public contract -- if a change makes a family behave
+differently on one of these inputs, that is an intentional contract change and
+this file must be updated alongside it.
+"""
+
+import warnings
+
+import numpy as np
+import pandas as pd
+import pytest
+
+from pretab import Preprocessor, RepresentationPolicy
+from pretab.exceptions import DataWarning, InsufficientSamplesError, PretabDataError
+from pretab.transformers import (
+ BSplineTransformer,
+ CubicRegressionSplineTransformer,
+ ISplineTransformer,
+ MSplineTransformer,
+ NaturalCubicSplineTransformer,
+ NumericBinningTransformer,
+ PLETransformer,
+ PSplineTransformer,
+ RBFExpansionTransformer,
+ ReLUExpansionTransformer,
+ SigmoidExpansionTransformer,
+ TanhExpansionTransformer,
+ TensorProductSplineTransformer,
+ ThinPlateSplineTransformer,
+)
+
+pytestmark = pytest.mark.filterwarnings("ignore::pretab.exceptions.LeakageWarning")
+
+
+def _factory(name):
+ """Build a transformer configured for an unsupervised (y-optional) fit."""
+ return {
+ "BSpline": lambda: BSplineTransformer(target_aware=False),
+ "MSpline": lambda: MSplineTransformer(target_aware=False),
+ "ISpline": lambda: ISplineTransformer(target_aware=False),
+ "RBF": lambda: RBFExpansionTransformer(target_aware=False),
+ "ReLU": lambda: ReLUExpansionTransformer(target_aware=False),
+ "Sigmoid": lambda: SigmoidExpansionTransformer(target_aware=False),
+ "Tanh": lambda: TanhExpansionTransformer(target_aware=False),
+ "NaturalCubic": lambda: NaturalCubicSplineTransformer(target_aware=False),
+ "CubicReg": lambda: CubicRegressionSplineTransformer(target_aware=False),
+ "PSpline": lambda: PSplineTransformer(),
+ "TensorProduct": lambda: TensorProductSplineTransformer(),
+ "ThinPlate": lambda: ThinPlateSplineTransformer(),
+ "Binning": lambda: NumericBinningTransformer(output_dim=5),
+ "PLE": lambda: PLETransformer(output_dim=5),
+ }[name]()
+
+
+# Families that cannot build a basis on a zero-range (constant) column and must
+# say so with a typed :class:`PretabDataError`.
+CONSTANT_RAISES = [
+ "BSpline",
+ "MSpline",
+ "ISpline",
+ "NaturalCubic",
+ "CubicReg",
+ "PSpline",
+ "TensorProduct",
+]
+
+# Families that degrade gracefully on a constant column (single bin / collapsed
+# basis) and still return a finite design matrix.
+CONSTANT_GRACEFUL = [
+ "RBF",
+ "ReLU",
+ "Sigmoid",
+ "Tanh",
+ "ThinPlate",
+ "Binning",
+ "PLE",
+]
+
+ALL_FAMILIES = CONSTANT_RAISES + CONSTANT_GRACEFUL
+
+# Families that let missing values pass through the basis (NaN in -> NaN row out).
+# The B/M/I splines instead clip a missing value to the fitted boundary, so they
+# are intentionally excluded here.
+NAN_PROPAGATING = [
+ "RBF",
+ "ReLU",
+ "Sigmoid",
+ "Tanh",
+ "NaturalCubic",
+ "CubicReg",
+ "PSpline",
+ "TensorProduct",
+]
+
+
+@pytest.fixture
+def rng():
+ return np.random.default_rng(0)
+
+
+# --------------------------------------------------------------------------- #
+# Constant column
+# --------------------------------------------------------------------------- #
+@pytest.mark.parametrize("name", CONSTANT_RAISES)
+def test_constant_column_raises_typed_error(name, rng):
+ X = np.full((40, 1), 3.14)
+ y = rng.normal(size=40)
+ with pytest.raises(PretabDataError, match="constant"):
+ _factory(name).fit(X, y)
+
+
+@pytest.mark.parametrize("name", CONSTANT_GRACEFUL)
+def test_constant_column_degrades_gracefully(name, rng):
+ X = np.full((40, 1), 3.14)
+ y = rng.normal(size=40)
+ transformer = _factory(name)
+ out = transformer.fit(X, y).transform(X)
+ assert out.shape[0] == 40
+ assert np.isfinite(out).all()
+
+
+# --------------------------------------------------------------------------- #
+# Fully-missing column
+# --------------------------------------------------------------------------- #
+@pytest.mark.parametrize("name", ALL_FAMILIES)
+def test_all_missing_column_is_rejected(name, rng):
+ X = np.full((40, 1), np.nan)
+ y = rng.normal(size=40)
+ # PretabDataError (typed) for the families that own their validation, plain
+ # ValueError for the ones that delegate to scikit-learn -- both are ValueError.
+ with pytest.raises(ValueError):
+ _factory(name).fit(X, y)
+
+
+# --------------------------------------------------------------------------- #
+# Partially-missing column: missing values propagate, they do not poison knots
+# --------------------------------------------------------------------------- #
+@pytest.mark.parametrize("name", NAN_PROPAGATING)
+def test_partial_missing_propagates_only_on_missing_rows(name, rng):
+ X = rng.normal(size=(40, 1))
+ X[7, 0] = np.nan
+ y = rng.normal(size=40)
+ transformer = _factory(name).fit(X, y)
+ out = transformer.transform(X)
+ missing_rows = np.isnan(out).any(axis=1)
+ # Exactly the one missing input row is missing in the output; the fitted
+ # support points stay finite (the NaN never reached min/max/quantiles).
+ assert missing_rows.sum() == 1
+ assert missing_rows[7]
+
+
+# --------------------------------------------------------------------------- #
+# Non-finite (infinity) input
+# --------------------------------------------------------------------------- #
+@pytest.mark.parametrize("name", ALL_FAMILIES)
+def test_infinite_values_are_rejected(name, rng):
+ X = rng.normal(size=(40, 1))
+ X[0, 0] = np.inf
+ y = rng.normal(size=40)
+ with pytest.raises(ValueError):
+ _factory(name).fit(X, y)
+
+
+# --------------------------------------------------------------------------- #
+# Out-of-range values at transform time stay finite (clip / clamp / evaluate)
+# --------------------------------------------------------------------------- #
+@pytest.mark.parametrize("name", ALL_FAMILIES)
+def test_out_of_range_transform_stays_finite(name, rng):
+ X = np.linspace(0.0, 1.0, 40).reshape(-1, 1)
+ y = rng.normal(size=40)
+ transformer = _factory(name).fit(X, y)
+ out_of_range = np.array([[-5.0], [5.0]])
+ out = transformer.transform(out_of_range)
+ assert out.shape[0] == 2
+ assert np.isfinite(out).all()
+
+
+# --------------------------------------------------------------------------- #
+# Duplicate support points (few distinct values) do not crash
+# --------------------------------------------------------------------------- #
+@pytest.mark.parametrize("name", ALL_FAMILIES)
+def test_duplicate_support_points_do_not_crash(name, rng):
+ # 40 rows but only five distinct values -> many duplicate knot / center / edge
+ # candidates that must be de-duplicated instead of raising.
+ X = np.repeat(np.linspace(0.0, 1.0, 5), 8).reshape(-1, 1)
+ y = rng.normal(size=40)
+ out = _factory(name).fit(X, y).transform(X)
+ assert out.shape[0] == 40
+ assert np.isfinite(out).all()
+
+
+# --------------------------------------------------------------------------- #
+# Too few samples
+# --------------------------------------------------------------------------- #
+def test_binning_rejects_too_few_samples(rng):
+ X = rng.normal(size=(2, 1))
+ with pytest.raises(InsufficientSamplesError):
+ NumericBinningTransformer(output_dim=5).fit(X)
+
+
+# --------------------------------------------------------------------------- #
+# Feature-count mismatch between fit and transform
+# --------------------------------------------------------------------------- #
+@pytest.mark.parametrize("name", ["BSpline", "RBF", "Binning"])
+def test_feature_count_mismatch_raises(name, rng):
+ X = rng.normal(size=(40, 2))
+ y = rng.normal(size=40)
+ transformer = _factory(name).fit(X, y)
+ with pytest.raises((ValueError, PretabDataError)):
+ transformer.transform(rng.normal(size=(40, 3)))
+
+
+# --------------------------------------------------------------------------- #
+# Central RepresentationPolicy wiring on the Preprocessor
+# --------------------------------------------------------------------------- #
+def _frame_with_constant(rng):
+ return pd.DataFrame(
+ {
+ "a": rng.normal(size=60),
+ "const": np.full(60, 2.0),
+ "c": rng.normal(size=60),
+ }
+ )
+
+
+def test_preprocessor_default_policy_allows_constant(rng):
+ df = _frame_with_constant(rng)
+ y = rng.normal(size=60)
+ # Default policy reproduces the historical behaviour: a constant column is fine.
+ Preprocessor(numerical_method="standardization").fit(df, y)
+
+
+def test_preprocessor_policy_errors_on_constant(rng):
+ df = _frame_with_constant(rng)
+ y = rng.normal(size=60)
+ with pytest.raises(PretabDataError):
+ Preprocessor(numerical_method="standardization", policy={"constant": "error"}).fit(df, y)
+
+
+def test_preprocessor_policy_warns_on_constant(rng):
+ df = _frame_with_constant(rng)
+ y = rng.normal(size=60)
+ with pytest.warns(DataWarning):
+ Preprocessor(numerical_method="standardization", policy={"constant": "warn"}).fit(df, y)
+
+
+def test_preprocessor_stores_resolved_policy(rng):
+ df = _frame_with_constant(rng)
+ y = rng.normal(size=60)
+ pre = Preprocessor(policy={"constant": "warn"})
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore")
+ pre.fit(df, y)
+ assert isinstance(pre.policy_, RepresentationPolicy)
+ assert pre.policy_.constant == "warn"
diff --git a/tests/test_exceptions.py b/tests/integration/test_exceptions.py
similarity index 84%
rename from tests/test_exceptions.py
rename to tests/integration/test_exceptions.py
index 909456a..d0d0be0 100644
--- a/tests/test_exceptions.py
+++ b/tests/integration/test_exceptions.py
@@ -2,7 +2,7 @@
These tests lock two guarantees:
-1. Every migrated raise site emits a *typed* ``core.exceptions`` class.
+1. Every migrated raise site emits a *typed* ``pretab.exceptions`` class.
2. The typed classes stay back-compatible: config/data errors remain
``ValueError`` subclasses and optional-dependency errors remain
``ImportError`` subclasses, so pre-existing ``pytest.raises(ValueError)``
@@ -16,7 +16,8 @@
from pretab import Preprocessor, PretabWarning
from pretab.core.adaptive import AdaptiveResolutionMixin
-from pretab.core.exceptions import (
+from pretab.core.knots import generate_internal_knots
+from pretab.exceptions import (
ConfigWarning,
DataWarning,
EmptyDataError,
@@ -31,15 +32,12 @@
insufficient_samples_error,
invalid_param_error,
)
-from pretab.core.knots import generate_internal_knots
+from pretab.placement.adapters import SplinePlacementAdapter
from pretab.transformers import (
BSplineTransformer,
- LagFeatureTransformer,
PLETransformer,
- RollingStatsTransformer,
ThinPlateSplineTransformer,
)
-from pretab.transformers.splines.knot_selectors import CARTKnotSelector
@pytest.fixture
@@ -185,18 +183,17 @@ def test_ple_length_mismatch_is_data_error(xy):
assert isinstance(exc.value, ValueError)
-def test_ple_all_nan_is_empty_data_error(xy):
+def test_ple_nan_input_is_value_error(xy):
X, y = xy
X_nan = np.full_like(X, np.nan)
- with pytest.raises(EmptyDataError) as exc:
+ with pytest.raises(ValueError, match="NaN"):
PLETransformer(output_dim=5).fit(X_nan, y)
- assert isinstance(exc.value, PretabDataError)
-def test_thinplate_multivariate_is_data_error():
- X = np.random.RandomState(1).rand(30, 2)
- with pytest.raises(PretabDataError, match="univariate"):
- ThinPlateSplineTransformer(output_dim=3).fit(X)
+def test_thinplate_insufficient_samples_is_data_error():
+ X = np.random.RandomState(1).rand(6, 2)
+ with pytest.raises(InsufficientSamplesError, match="needs at least"):
+ ThinPlateSplineTransformer(n_components=10).fit(X)
# --------------------------------------------------------------------------- #
@@ -207,27 +204,7 @@ def test_thinplate_multivariate_is_data_error():
def test_cart_selector_requires_y(xy):
X, _ = xy
with pytest.raises(IncompatibleParamsError, match="requires y"):
- CARTKnotSelector().get_knot_locations(X, y=None)
-
-
-# --------------------------------------------------------------------------- #
-# Temporal transformers.
-# --------------------------------------------------------------------------- #
-
-
-def test_lag_insufficient_samples():
- X = np.arange(5).reshape(-1, 1).astype(float)
- with pytest.raises(InsufficientSamplesError) as exc:
- LagFeatureTransformer(n_lags=10).fit(X)
- assert isinstance(exc.value, ValueError)
-
-
-def test_rolling_unsupported_stat():
- X = np.arange(20).reshape(-1, 1).astype(float)
- transformer = RollingStatsTransformer(window_size=3, stats=("mean", "bogus"))
- transformer.fit(X)
- with pytest.raises(InvalidParamError, match="bogus"):
- transformer.transform(X)
+ SplinePlacementAdapter(placement_strategy="cart", degree=3).get_knot_locations(X, y=None)
# --------------------------------------------------------------------------- #
diff --git a/tests/integration/test_feature_lineage.py b/tests/integration/test_feature_lineage.py
new file mode 100644
index 0000000..26432f4
--- /dev/null
+++ b/tests/integration/test_feature_lineage.py
@@ -0,0 +1,129 @@
+import warnings
+
+import numpy as np
+import pandas as pd
+import pytest
+
+from pretab import Preprocessor
+from pretab.core.representation import FeatureLineage
+
+
+@pytest.fixture
+def mixed_frame():
+ rng = np.random.default_rng(0)
+ n = 120
+ df = pd.DataFrame(
+ {
+ "age": rng.uniform(18, 80, n),
+ "income": rng.uniform(1000, 9000, n),
+ "score": rng.uniform(0, 1, n),
+ "hour": rng.integers(0, 24, n).astype(float),
+ "city": rng.choice(["ny", "sf", "la"], n),
+ "tier": rng.choice(["a", "b"], n),
+ }
+ )
+ y = (df["income"] / 1000 + rng.normal(0, 1, n)).to_numpy()
+ return df, y
+
+
+def _fit(df, y, **kwargs):
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore")
+ pre = Preprocessor(**kwargs)
+ pre.fit(df, y)
+ return pre
+
+
+def test_lineage_covers_all_output_columns_default(mixed_frame):
+ df, y = mixed_frame
+ pre = _fit(df, y)
+ names = list(pre.get_feature_names_out())
+ lineage = pre.get_feature_lineage()
+ assert len(lineage) == len(names)
+ assert [record.output_feature for record in lineage] == names
+ assert [record.output_index for record in lineage] == list(range(len(names)))
+
+
+def test_lineage_records_are_complete(mixed_frame):
+ df, y = mixed_frame
+ pre = _fit(
+ df,
+ y,
+ feature_preprocessing={
+ "age": "bspline",
+ "income": "standardization",
+ "score": "ple",
+ "hour": "rbf",
+ "city": "one-hot",
+ "tier": "int",
+ },
+ )
+ lineage = pre.get_feature_lineage()
+ for record in lineage:
+ assert isinstance(record, FeatureLineage)
+ assert record.source_features
+ assert all(isinstance(source, str) for source in record.source_features)
+ assert record.family
+ assert record.component
+ assert record.component_index >= 0
+
+
+def test_lineage_marks_supervised_representation(mixed_frame):
+ df, y = mixed_frame
+ pre = _fit(df, y, feature_preprocessing={"score": "ple"})
+ ple_records = [record for record in pre.get_feature_lineage() if record.family == "piecewise_linear"]
+ assert ple_records
+ assert all(record.uses_target for record in ple_records)
+
+
+def test_lineage_families_reflect_methods(mixed_frame):
+ df, y = mixed_frame
+ pre = _fit(
+ df,
+ y,
+ feature_preprocessing={
+ "age": "bspline",
+ "income": "standardization",
+ "score": "ple",
+ "hour": "rbf",
+ "city": "one-hot",
+ "tier": "int",
+ },
+ )
+ families_by_source = {}
+ for record in pre.get_feature_lineage():
+ families_by_source.setdefault(record.source_features, set()).update([record.family])
+ assert families_by_source[("age",)] == {"bspline"}
+ assert families_by_source[("income",)] == {"standardization"}
+ assert families_by_source[("score",)] == {"piecewise_linear"}
+ assert families_by_source[("hour",)] == {"rbf"}
+ assert families_by_source[("city",)] == {"onehot"}
+ assert families_by_source[("tier",)] == {"ordinal"}
+
+
+def test_lineage_round_trips_through_dict(mixed_frame):
+ df, y = mixed_frame
+ pre = _fit(df, y)
+ for record in pre.get_feature_lineage():
+ data = record.to_dict()
+ rebuilt = FeatureLineage(
+ output_feature=data["output_feature"],
+ output_index=data["output_index"],
+ source_features=tuple(data["source_features"]),
+ family=data["family"],
+ component=data["component"],
+ component_index=data["component_index"],
+ uses_target=data["uses_target"],
+ is_interaction=data["is_interaction"],
+ )
+ assert rebuilt == record
+
+
+def test_lineage_source_features_are_single_input_per_block(mixed_frame):
+ df, y = mixed_frame
+ pre = _fit(df, y)
+ for record in pre.get_feature_lineage():
+ # The preprocessor expands each column independently, so every output
+ # column traces back to exactly one source feature.
+ assert len(record.source_features) == 1
+ assert not record.is_interaction
diff --git a/tests/integration/test_fingerprint.py b/tests/integration/test_fingerprint.py
new file mode 100644
index 0000000..f381464
--- /dev/null
+++ b/tests/integration/test_fingerprint.py
@@ -0,0 +1,110 @@
+"""Tests for the fingerprint and reproducibility report (P9.2).
+
+Covers determinism within and across processes, sensitivity to configuration /
+data / seed changes, round-trip stability, and the ``reproducibility_report``
+contents.
+"""
+
+import subprocess
+import sys
+import textwrap
+
+import numpy as np
+import pandas as pd
+import pytest
+
+from pretab import Preprocessor
+
+
+@pytest.fixture
+def frame():
+ rng = np.random.default_rng(0)
+ return pd.DataFrame({"a": rng.random(50), "b": rng.random(50) * 5.0, "c": rng.choice(["x", "y", "z"], 50)})
+
+
+@pytest.fixture
+def target():
+ rng = np.random.default_rng(1)
+ return rng.random(50)
+
+
+def _fit(frame, target, **kwargs):
+ params = {"numerical_method": "rbf", "target_aware": False, "placement_strategy": "quantile"}
+ params.update(kwargs)
+ return Preprocessor(**params).fit(frame, target)
+
+
+def test_fingerprint_is_hex_sha256(frame, target):
+ fp = _fit(frame, target).fingerprint_
+ assert isinstance(fp, str)
+ assert len(fp) == 64
+ assert all(ch in "0123456789abcdef" for ch in fp)
+
+
+def test_fingerprint_deterministic_same_fit(frame, target):
+ assert _fit(frame, target).fingerprint_ == _fit(frame, target).fingerprint_
+
+
+def test_fingerprint_survives_round_trip(frame, target):
+ p = _fit(frame, target)
+ restored = Preprocessor.from_spec(p.to_spec())
+ assert restored.fingerprint_ == p.fingerprint_
+
+
+def test_fingerprint_changes_with_config(frame, target):
+ a = _fit(frame, target, output_dim=6)
+ b = _fit(frame, target, output_dim=9)
+ assert a.fingerprint_ != b.fingerprint_
+
+
+def test_fingerprint_changes_with_data(frame, target):
+ other = frame.copy()
+ other.loc[other.index[0], "a"] = other.loc[other.index[0], "a"] + 1.0
+ assert _fit(frame, target).fingerprint_ != _fit(other, target).fingerprint_
+
+
+def test_fingerprint_changes_with_seed(frame, target):
+ # The fingerprint incorporates the seed (roadmap D12), so distinct seeds yield
+ # distinct fingerprints even when the fitted state happens to coincide.
+ a = _fit(frame, target, numerical_method="ple", target_aware=True, placement_strategy="cart", random_state=0)
+ b = _fit(frame, target, numerical_method="ple", target_aware=True, placement_strategy="cart", random_state=1)
+ assert a.fingerprint_ != b.fingerprint_
+
+
+def test_fingerprint_stable_across_processes(frame, target):
+ script = textwrap.dedent(
+ """
+ import numpy as np, pandas as pd
+ from pretab import Preprocessor
+ rng = np.random.default_rng(0)
+ frame = pd.DataFrame({"a": rng.random(50), "b": rng.random(50) * 5.0,
+ "c": rng.choice(["x", "y", "z"], 50)})
+ y = np.random.default_rng(1).random(50)
+ p = Preprocessor(numerical_method="rbf", target_aware=False,
+ placement_strategy="quantile").fit(frame, y)
+ print(p.fingerprint_)
+ """
+ )
+
+ def _run():
+ result = subprocess.run( # noqa: S603 - fixed interpreter + inline script, no untrusted input
+ [sys.executable, "-c", script], capture_output=True, text=True, check=True
+ )
+ return result.stdout.strip()
+
+ first = _run()
+ second = _run()
+ assert first == second
+ assert first == _fit(frame, target).fingerprint_
+
+
+def test_reproducibility_report_contents(frame, target):
+ p = _fit(frame, target)
+ report = p.reproducibility_report()
+ assert report["fingerprint"] == p.fingerprint_
+ assert report["schema_version"] == 1
+ assert set(report["library_versions"]) == {"numpy", "scipy", "scikit_learn"}
+ assert report["n_features_in"] == frame.shape[1]
+ assert report["n_output_features"] == len(p.get_feature_names_out())
+ assert report["output_format"] == "dense"
+ assert "a" in report["representations"]
diff --git a/tests/integration/test_frozen_lifecycle.py b/tests/integration/test_frozen_lifecycle.py
new file mode 100644
index 0000000..61692f8
--- /dev/null
+++ b/tests/integration/test_frozen_lifecycle.py
@@ -0,0 +1,114 @@
+"""Tests for the immutable lifecycle: freeze / stale / clone / refit (P9.3).
+
+Covers the ``lifecycle_state_`` transitions, ``freeze`` / ``is_frozen``,
+``set_params`` rejection on frozen instances, ``clone_unfitted`` and ``refit``
+returning fresh objects, and ``mark_stale``.
+"""
+
+import numpy as np
+import pandas as pd
+import pytest
+
+from pretab import FrozenRepresentationError, Preprocessor
+
+
+@pytest.fixture
+def frame():
+ rng = np.random.default_rng(0)
+ return pd.DataFrame({"a": rng.random(40), "c": rng.choice(["x", "y"], 40)})
+
+
+@pytest.fixture
+def target():
+ return np.random.default_rng(1).random(40)
+
+
+def _make():
+ return Preprocessor(numerical_method="rbf", target_aware=False, placement_strategy="quantile")
+
+
+def test_unfitted_state(frame, target):
+ p = _make()
+ assert p.lifecycle_state_ == "UNFITTED"
+ assert p.is_frozen() is False
+
+
+def test_fitted_state(frame, target):
+ p = _make().fit(frame, target)
+ assert p.lifecycle_state_ == "FITTED"
+
+
+def test_freeze_transitions_and_blocks_set_params(frame, target):
+ p = _make().fit(frame, target)
+ returned = p.freeze()
+ assert returned is p
+ assert p.is_frozen() is True
+ assert p.lifecycle_state_ == "FROZEN"
+
+ with pytest.raises(FrozenRepresentationError, match="frozen"):
+ p.set_params(output_dim=9)
+
+
+def test_set_params_allowed_before_freeze(frame, target):
+ p = _make()
+ p.set_params(output_dim=9)
+ assert p.output_dim == 9
+
+
+def test_clone_unfitted_returns_fresh_unfrozen(frame, target):
+ p = _make().fit(frame, target).freeze()
+ clone = p.clone_unfitted()
+ assert clone is not p
+ assert clone.lifecycle_state_ == "UNFITTED"
+ assert clone.is_frozen() is False
+ # Params carry over; the clone is mutable.
+ clone.set_params(output_dim=5)
+ assert clone.output_dim == 5
+
+
+def test_refit_returns_new_object_and_leaves_original(frame, target):
+ p = _make().fit(frame, target).freeze()
+ refit = p.refit(frame, target)
+ assert refit is not p
+ assert refit.lifecycle_state_ == "FITTED"
+ assert refit.is_frozen() is False
+ # Original stays frozen and untouched.
+ assert p.is_frozen() is True
+ out_frozen = p.transform(frame, return_array=True)
+ out_refit = refit.transform(frame, return_array=True)
+ assert isinstance(out_frozen, np.ndarray)
+ assert isinstance(out_refit, np.ndarray)
+ assert np.array_equal(out_frozen, out_refit, equal_nan=True)
+
+
+def test_mark_stale(frame, target):
+ p = _make().fit(frame, target)
+ returned = p.mark_stale("input schema drifted")
+ assert returned is p
+ assert p.lifecycle_state_ == "STALE"
+ assert p.stale_reason_ == "input schema drifted"
+
+
+def test_frozen_takes_precedence_over_stale(frame, target):
+ p = _make().fit(frame, target)
+ p.mark_stale("drift")
+ p.freeze()
+ assert p.lifecycle_state_ == "FROZEN"
+
+
+def test_freeze_requires_fitted():
+ from sklearn.exceptions import NotFittedError
+
+ with pytest.raises(NotFittedError):
+ _make().freeze()
+
+
+def test_clone_preserves_unfrozen_via_sklearn_clone(frame, target):
+ from sklearn.base import clone
+
+ p = _make().fit(frame, target).freeze()
+ fresh = clone(p)
+ assert isinstance(fresh, Preprocessor)
+ assert fresh.is_frozen() is False
+ fresh.set_params(output_dim=7)
+ assert fresh.output_dim == 7
diff --git a/tests/integration/test_missing_policy.py b/tests/integration/test_missing_policy.py
new file mode 100644
index 0000000..974176d
--- /dev/null
+++ b/tests/integration/test_missing_policy.py
@@ -0,0 +1,169 @@
+"""Tests for the high-level ``missing_policy`` orchestration knob (P8.5)."""
+
+import numpy as np
+import pandas as pd
+import pytest
+from sklearn.base import clone
+
+from pretab import Preprocessor
+from pretab.exceptions import InvalidParamError, PretabDataError
+
+
+@pytest.fixture
+def frame_with_nan():
+ return pd.DataFrame(
+ {
+ "a": [1.0, 2.0, np.nan, 4.0, 5.0, 6.0],
+ "b": [0.1, 0.2, 0.3, np.nan, 0.5, 0.6],
+ }
+ )
+
+
+@pytest.fixture
+def clean_frame():
+ return pd.DataFrame(
+ {
+ "a": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
+ "b": [0.1, 0.2, 0.3, 0.4, 0.5, 0.6],
+ }
+ )
+
+
+@pytest.fixture
+def y():
+ return np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0])
+
+
+def _bspline(**kwargs):
+ return Preprocessor(
+ numerical_method="bspline",
+ output_dim=8,
+ target_aware=False,
+ placement_strategy="quantile",
+ **kwargs,
+ )
+
+
+# --- default / roundtrip -------------------------------------------------------
+
+
+def test_default_missing_policy_is_none():
+ assert Preprocessor().missing_policy is None
+
+
+def test_missing_policy_survives_clone():
+ p = _bspline(missing_policy="separate_state")
+ cloned = clone(p)
+ assert isinstance(cloned, Preprocessor)
+ assert cloned.missing_policy == "separate_state"
+
+
+def test_default_still_imputes(frame_with_nan, y):
+ # missing_policy=None keeps numerical_imputation="median" authoritative.
+ p = Preprocessor(numerical_method="minmax").fit(frame_with_nan, y)
+ out = p.transform(frame_with_nan, return_array=True)
+ assert isinstance(out, np.ndarray)
+ assert not np.isnan(out).any()
+
+
+# --- error ---------------------------------------------------------------------
+
+
+def test_error_policy_raises_at_fit(frame_with_nan, y):
+ with pytest.raises(PretabDataError, match="missing_policy='error'"):
+ _bspline(missing_policy="error").fit(frame_with_nan, y)
+
+
+def test_error_policy_raises_at_transform(clean_frame, frame_with_nan, y):
+ p = _bspline(missing_policy="error").fit(clean_frame, y)
+ with pytest.raises(PretabDataError, match="missing_policy='error'"):
+ p.transform(frame_with_nan)
+
+
+def test_error_policy_passes_when_clean(clean_frame, y):
+ p = _bspline(missing_policy="error").fit(clean_frame, y)
+ out = p.transform(clean_frame, return_array=True)
+ assert isinstance(out, np.ndarray)
+ assert np.isfinite(out).all()
+
+
+# --- propagate -----------------------------------------------------------------
+
+
+def test_propagate_lets_nan_through(frame_with_nan, y):
+ # MinMaxScaler maintains NaNs at transform; with no imputer they survive.
+ p = Preprocessor(numerical_method="minmax", missing_policy="propagate").fit(frame_with_nan, y)
+ out = p.transform(frame_with_nan, return_array=True)
+ assert isinstance(out, np.ndarray)
+ assert np.isnan(out).any()
+
+
+# --- impute --------------------------------------------------------------------
+
+
+def test_impute_removes_nan(frame_with_nan, y):
+ p = Preprocessor(numerical_method="minmax", missing_policy="impute").fit(frame_with_nan, y)
+ out = p.transform(frame_with_nan, return_array=True)
+ assert isinstance(out, np.ndarray)
+ assert not np.isnan(out).any()
+
+
+def test_impute_adds_no_indicator(frame_with_nan, y):
+ p = Preprocessor(numerical_method="minmax", missing_policy="impute").fit(frame_with_nan, y)
+ names = list(p.get_feature_names_out())
+ assert not any("missing" in n for n in names)
+
+
+# --- impute_with_indicator -----------------------------------------------------
+
+
+def test_impute_with_indicator_appends_columns(frame_with_nan, y):
+ plain = Preprocessor(numerical_method="minmax", missing_policy="impute").fit(frame_with_nan, y)
+ withind = Preprocessor(numerical_method="minmax", missing_policy="impute_with_indicator").fit(frame_with_nan, y)
+ assert withind.total_output_dim_ > plain.total_output_dim_
+ out = withind.transform(frame_with_nan, return_array=True)
+ assert isinstance(out, np.ndarray)
+ assert not np.isnan(out).any()
+
+
+# --- separate_state ------------------------------------------------------------
+
+
+def test_separate_state_emits_missing_column(frame_with_nan, y):
+ p = _bspline(missing_policy="separate_state").fit(frame_with_nan, y)
+ names = list(p.get_feature_names_out())
+ missing_cols = [n for n in names if n.endswith("__missing")]
+ assert len(missing_cols) == 2 # one per input feature
+
+
+def test_separate_state_output_is_finite(frame_with_nan, y):
+ p = _bspline(missing_policy="separate_state").fit(frame_with_nan, y)
+ out = p.transform(frame_with_nan, return_array=True)
+ assert isinstance(out, np.ndarray)
+ assert np.isfinite(out).all()
+
+
+def test_separate_state_indicator_marks_missing_rows(frame_with_nan, y):
+ p = _bspline(missing_policy="separate_state").fit(frame_with_nan, y)
+ names = list(p.get_feature_names_out())
+ arr = p.transform(frame_with_nan, return_array=True)
+ a_missing_name = next(n for n in names if n.startswith("num_a") and n.endswith("__missing"))
+ a_missing = arr[:, names.index(a_missing_name)]
+ # Column "a" is missing at row index 2.
+ assert a_missing[2] == 1.0
+ assert a_missing[0] == 0.0
+
+
+def test_separate_state_on_categorical(y):
+ frame = pd.DataFrame({"c": ["x", "y", None, "x", "y", "x"]})
+ p = Preprocessor(categorical_method="one-hot", missing_policy="separate_state").fit(frame, y)
+ names = list(p.get_feature_names_out())
+ assert any(n.endswith("__missing") for n in names)
+
+
+# --- validation ----------------------------------------------------------------
+
+
+def test_invalid_missing_policy_raises(frame_with_nan, y):
+ with pytest.raises(InvalidParamError):
+ _bspline(missing_policy="nonsense").fit(frame_with_nan, y)
diff --git a/tests/integration/test_output_budget.py b/tests/integration/test_output_budget.py
new file mode 100644
index 0000000..d267f63
--- /dev/null
+++ b/tests/integration/test_output_budget.py
@@ -0,0 +1,131 @@
+"""Output-budget controls on the Preprocessor (roadmap Phase 8, P8.3).
+
+The budget parameters are opt-in: with all of them ``None`` (the default) the
+Preprocessor behaves exactly as before. When a budget is set, exceeding it is
+handled by ``overflow_policy`` -- raise, warn, or ignore.
+"""
+
+import warnings
+
+import numpy as np
+import pandas as pd
+import pytest
+
+from pretab import OutputBudgetError, Preprocessor
+from pretab.exceptions import ConfigWarning, InvalidParamError
+
+
+@pytest.fixture
+def frame():
+ rng = np.random.default_rng(0)
+ return pd.DataFrame({"a": rng.normal(size=50), "b": rng.normal(size=50)})
+
+
+@pytest.fixture
+def y():
+ return np.random.default_rng(1).normal(size=50)
+
+
+def _bspline(**kwargs):
+ return Preprocessor(
+ numerical_method="bspline",
+ output_dim=8,
+ target_aware=False,
+ placement_strategy="quantile",
+ **kwargs,
+ )
+
+
+# --------------------------------------------------------------------------- #
+# Estimation helpers
+# --------------------------------------------------------------------------- #
+def test_estimate_output_shape_matches_transform(frame, y):
+ pre = _bspline().fit(frame, y)
+ n_rows, n_cols = pre.estimate_output_shape(frame)
+ assert n_rows == frame.shape[0]
+ assert n_cols == pre.total_output_dim_
+ out = pre.transform(frame, return_array=True)
+ assert isinstance(out, np.ndarray)
+ assert out.shape == (n_rows, n_cols)
+
+
+def test_estimate_memory_is_rows_times_cols_times_itemsize(frame, y):
+ pre = _bspline().fit(frame, y)
+ n_rows, n_cols = pre.estimate_output_shape(frame)
+ assert pre.estimate_memory(frame) == n_rows * n_cols * np.dtype(np.float64).itemsize
+
+
+def test_estimate_shape_scales_with_new_rows(frame, y):
+ pre = _bspline().fit(frame, y)
+ bigger = pd.concat([frame] * 3, ignore_index=True)
+ assert pre.estimate_output_shape(bigger)[0] == frame.shape[0] * 3
+
+
+# --------------------------------------------------------------------------- #
+# No budget set -> no enforcement (non-regressive default)
+# --------------------------------------------------------------------------- #
+def test_default_has_no_budget_enforcement(frame, y):
+ # Fits fine even though the output is wider than any of the (unset) budgets.
+ pre = _bspline().fit(frame, y)
+ assert pre.total_output_dim_ > 0
+
+
+# --------------------------------------------------------------------------- #
+# max_output_features
+# --------------------------------------------------------------------------- #
+def test_max_output_features_error(frame, y):
+ with pytest.raises(OutputBudgetError, match="max_output_features"):
+ _bspline(max_output_features=10).fit(frame, y)
+
+
+def test_max_output_features_within_budget_is_fine(frame, y):
+ pre = _bspline().fit(frame, y)
+ _bspline(max_output_features=pre.total_output_dim_).fit(frame, y)
+
+
+# --------------------------------------------------------------------------- #
+# max_features_per_input
+# --------------------------------------------------------------------------- #
+def test_max_features_per_input_error(frame, y):
+ with pytest.raises(OutputBudgetError, match="max_features_per_input"):
+ _bspline(max_features_per_input=5).fit(frame, y)
+
+
+# --------------------------------------------------------------------------- #
+# max_dense_memory
+# --------------------------------------------------------------------------- #
+def test_max_dense_memory_error(frame, y):
+ with pytest.raises(OutputBudgetError, match="max_dense_memory"):
+ _bspline(max_dense_memory=100).fit(frame, y)
+
+
+def test_max_dense_memory_generous_budget_is_fine(frame, y):
+ _bspline(max_dense_memory=10**9).fit(frame, y)
+
+
+# --------------------------------------------------------------------------- #
+# overflow_policy
+# --------------------------------------------------------------------------- #
+def test_overflow_policy_warn(frame, y):
+ with pytest.warns(ConfigWarning, match="Output budget exceeded"):
+ _bspline(max_output_features=10, overflow_policy="warn").fit(frame, y)
+
+
+def test_overflow_policy_ignore(frame, y):
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", ConfigWarning)
+ # No warning and no error even though the budget is exceeded.
+ _bspline(max_output_features=1, overflow_policy="ignore").fit(frame, y)
+
+
+def test_overflow_policy_invalid(frame, y):
+ with pytest.raises(InvalidParamError):
+ _bspline(max_output_features=1, overflow_policy="bogus").fit(frame, y)
+
+
+def test_multiple_budgets_reported_together(frame, y):
+ with pytest.raises(OutputBudgetError) as excinfo:
+ _bspline(max_output_features=1, max_features_per_input=1).fit(frame, y)
+ message = str(excinfo.value)
+ assert "max_output_features" in message
+ assert "max_features_per_input" in message
diff --git a/tests/integration/test_output_format.py b/tests/integration/test_output_format.py
new file mode 100644
index 0000000..8e5ef0f
--- /dev/null
+++ b/tests/integration/test_output_format.py
@@ -0,0 +1,191 @@
+"""Tests for first-class output format control (P8.4).
+
+Covers ``output_format`` (dense/sparse/auto), ``dtype`` casting, the
+``output_report_`` memory report, and ``set_output`` pandas / polars DataFrame
+wrapping.
+"""
+
+import numpy as np
+import pandas as pd
+import pytest
+from scipy import sparse as sp
+
+from pretab import Preprocessor
+from pretab.exceptions import OptionalDependencyError
+
+
+@pytest.fixture
+def frame():
+ rng = np.random.default_rng(0)
+ return pd.DataFrame({"a": rng.random(30), "b": rng.random(30)})
+
+
+@pytest.fixture
+def y():
+ rng = np.random.default_rng(1)
+ return rng.random(30)
+
+
+def _bspline(**kwargs):
+ return Preprocessor(
+ numerical_method="bspline",
+ output_dim=8,
+ target_aware=False,
+ placement_strategy="quantile",
+ **kwargs,
+ )
+
+
+# --- default (dense) behaviour -------------------------------------------------
+
+
+def test_default_output_format_is_dense(frame, y):
+ p = _bspline().fit(frame, y)
+ arr = p.transform(frame, return_array=True)
+ assert isinstance(arr, np.ndarray)
+ assert p.output_report_["format"] == "dense"
+
+
+def test_default_dict_blocks_are_dense(frame, y):
+ p = _bspline().fit(frame, y)
+ out = p.transform(frame)
+ assert isinstance(out, dict)
+ assert all(isinstance(v, np.ndarray) for v in out.values())
+
+
+# --- sparse --------------------------------------------------------------------
+
+
+def test_sparse_return_array_is_csr(frame, y):
+ p = _bspline(output_format="sparse").fit(frame, y)
+ arr = p.transform(frame, return_array=True)
+ assert sp.issparse(arr)
+ assert isinstance(arr, sp.csr_matrix)
+ assert arr.format == "csr"
+ dense = _bspline().fit(frame, y).transform(frame, return_array=True)
+ assert isinstance(dense, np.ndarray)
+ np.testing.assert_allclose(arr.toarray(), dense)
+
+
+def test_sparse_dict_blocks_are_csr(frame, y):
+ p = _bspline(output_format="sparse").fit(frame, y)
+ out = p.transform(frame)
+ assert isinstance(out, dict)
+ assert all(sp.issparse(v) for v in out.values())
+
+
+def test_sparse_report_saves_memory(frame, y):
+ p = _bspline(output_format="sparse").fit(frame, y)
+ p.transform(frame, return_array=True)
+ report = p.output_report_
+ assert report["format"] == "sparse"
+ assert report["actual_bytes"] < report["dense_bytes"]
+ assert report["memory_saved_bytes"] == report["dense_bytes"] - report["actual_bytes"]
+
+
+# --- auto ----------------------------------------------------------------------
+
+
+def test_auto_picks_sparse_for_low_density(frame, y):
+ # One-hot output on a high-cardinality categorical is very sparse.
+ cats = pd.DataFrame({"c": [f"v{i % 15}" for i in range(30)]})
+ p = Preprocessor(categorical_method="one-hot", output_format="auto").fit(cats)
+ p.transform(cats, return_array=True)
+ assert p.output_report_["format"] == "sparse"
+
+
+def test_auto_picks_dense_for_high_density(frame, y):
+ p = _bspline(output_format="auto").fit(frame, y)
+ p.transform(frame, return_array=True)
+ assert p.output_report_["format"] == "dense"
+
+
+# --- dtype ---------------------------------------------------------------------
+
+
+def test_dtype_casts_output(frame, y):
+ p = _bspline(dtype=np.float32).fit(frame, y)
+ arr = p.transform(frame, return_array=True)
+ assert isinstance(arr, np.ndarray)
+ assert arr.dtype == np.float32
+
+
+def test_dtype_none_keeps_float64(frame, y):
+ p = _bspline().fit(frame, y)
+ arr = p.transform(frame, return_array=True)
+ assert isinstance(arr, np.ndarray)
+ assert arr.dtype == np.float64
+
+
+def test_dtype_with_sparse(frame, y):
+ p = _bspline(dtype=np.float32, output_format="sparse").fit(frame, y)
+ arr = p.transform(frame, return_array=True)
+ assert sp.issparse(arr)
+ assert isinstance(arr, sp.csr_matrix)
+ assert arr.dtype == np.float32
+
+
+# --- output_report_ ------------------------------------------------------------
+
+
+def test_output_report_shape_and_keys(frame, y):
+ p = _bspline().fit(frame, y)
+ arr = p.transform(frame, return_array=True)
+ assert isinstance(arr, np.ndarray)
+ report = p.output_report_
+ assert set(report) == {
+ "format",
+ "shape",
+ "density",
+ "dense_bytes",
+ "actual_bytes",
+ "memory_saved_bytes",
+ }
+ assert report["shape"] == arr.shape
+ assert 0.0 <= report["density"] <= 1.0
+
+
+# --- set_output ----------------------------------------------------------------
+
+
+def test_set_output_pandas_returns_dataframe(frame, y):
+ p = _bspline().fit(frame, y).set_output(transform="pandas")
+ out = p.transform(frame)
+ assert isinstance(out, pd.DataFrame)
+ assert list(out.columns) == list(p.get_feature_names_out())
+ assert out.shape == (len(frame), p.total_output_dim_)
+
+
+def test_set_output_pandas_fit_transform(frame, y):
+ p = _bspline().set_output(transform="pandas")
+ out = p.fit_transform(frame, y)
+ assert isinstance(out, pd.DataFrame)
+ assert out.shape[1] == p.total_output_dim_
+
+
+def test_set_output_default_still_dict(frame, y):
+ p = _bspline().fit(frame, y).set_output(transform="default")
+ out = p.transform(frame)
+ assert isinstance(out, dict)
+
+
+def test_set_output_polars_without_polars_raises(frame, y):
+ import importlib.util
+
+ p = _bspline().fit(frame, y).set_output(transform="polars")
+ if importlib.util.find_spec("polars") is None:
+ with pytest.raises(OptionalDependencyError):
+ p.transform(frame)
+ else:
+ out = p.transform(frame)
+ assert out.shape == (len(frame), p.total_output_dim_)
+
+
+# --- validation ----------------------------------------------------------------
+
+
+def test_invalid_output_format_raises(frame, y):
+ from pretab.exceptions import InvalidParamError
+
+ with pytest.raises(InvalidParamError):
+ _bspline(output_format="nope").fit(frame, y)
diff --git a/tests/test_preprocessor.py b/tests/integration/test_preprocessor.py
similarity index 88%
rename from tests/test_preprocessor.py
rename to tests/integration/test_preprocessor.py
index 7222f82..15b4219 100644
--- a/tests/test_preprocessor.py
+++ b/tests/integration/test_preprocessor.py
@@ -1,9 +1,10 @@
-import pytest
import numpy as np
import pandas as pd
+import pytest
from sklearn.base import clone
from sklearn.exceptions import NotFittedError
from sklearn.utils.validation import check_is_fitted
+
from pretab.preprocessor import Preprocessor # Adjust the import as needed
@@ -81,6 +82,7 @@ def test_dict_output_shapes_add_up(sample_data):
X, y = sample_data
pre = Preprocessor()
out = pre.fit_transform(X, y)
+ assert isinstance(out, dict)
shapes = [v.shape for v in out.values()]
assert all(s[0] == len(X) for s in shapes)
@@ -89,6 +91,7 @@ def test_dict_keys_reflect_column_names(sample_data):
X, y = sample_data
pre = Preprocessor()
out = pre.fit_transform(X, y)
+ assert isinstance(out, dict)
expected_prefixes = ["num_", "cat_"]
for k in out:
if "embedding" not in k:
@@ -113,8 +116,19 @@ def test_dict_keys_reflect_column_names(sample_data):
"cat_cutoff",
"treat_all_integers_as_numerical",
"random_state",
- "handle_missing",
+ "numerical_imputation",
+ "categorical_imputation",
+ "add_missing_indicator",
+ "missing_policy",
+ "policy",
+ "max_output_features",
+ "max_features_per_input",
+ "max_dense_memory",
+ "overflow_policy",
+ "output_format",
+ "dtype",
"verbose",
+ "preset",
}
@@ -180,6 +194,19 @@ def test_get_feature_names_out_before_fit_raises():
Preprocessor().get_feature_names_out()
+def test_get_feature_names_out_does_not_duplicate_feature_name(sample_data):
+ """Regression guard: output names must not repeat as num____... ."""
+ X, y = sample_data
+ pre = Preprocessor()
+ pre.fit(X, y)
+ names = list(pre.get_feature_names_out())
+ assert names
+ assert all("__" not in name for name in names)
+ assert "num_num1_ple_piece0" in names
+ lineage_names = [record.output_feature for record in pre.get_feature_lineage()]
+ assert lineage_names == names
+
+
def test_lowercase_and_none_method_resolution(sample_data):
X, y = sample_data
# Mixed-case / None methods are resolved at fit time, not stored on the instance.
@@ -239,9 +266,7 @@ def test_output_dims_nonuniform_for_one_hot_categorical():
}
)
y = pd.Series(np.random.randn(30))
- pre = Preprocessor(
- numerical_method="minmax", categorical_method="one-hot"
- ).fit(X, y)
+ pre = Preprocessor(numerical_method="minmax", categorical_method="one-hot").fit(X, y)
dims = pre.output_dims_
assert dims["num"] == 1
assert dims["cat"] == 3 # one-hot of three categories
@@ -253,4 +278,3 @@ def test_output_dims_and_total_before_fit_raise():
_ = Preprocessor().output_dims_
with pytest.raises(NotFittedError):
_ = Preprocessor().total_output_dim_
-
diff --git a/tests/integration/test_presets.py b/tests/integration/test_presets.py
new file mode 100644
index 0000000..5f6d824
--- /dev/null
+++ b/tests/integration/test_presets.py
@@ -0,0 +1,89 @@
+"""Tests for the ``Preprocessor`` preset aliases and ``get_resolved_config`` (P10.5)."""
+
+import numpy as np
+import pandas as pd
+import pytest
+from sklearn.base import clone
+
+from pretab import Preprocessor
+from pretab.exceptions import InvalidParamError
+
+
+@pytest.fixture
+def data():
+ X = pd.DataFrame(
+ {
+ "a": np.linspace(0, 3, 40),
+ "b": np.linspace(-2, 2, 40),
+ "c": ["x", "y"] * 20,
+ }
+ )
+ y = np.linspace(0, 1, 40)
+ return X, y
+
+
+def test_no_preset_resolved_config_drops_preset_key():
+ cfg = Preprocessor().get_resolved_config()
+ assert "preset" not in cfg
+ assert cfg["numerical_method"] == "ple"
+ assert cfg["categorical_method"] == "int"
+
+
+def test_standard_preset_matches_baseline():
+ cfg = Preprocessor(preset="standard").get_resolved_config()
+ assert cfg["numerical_method"] == "ple"
+ assert cfg["categorical_method"] == "int"
+ assert cfg["output_dim"] == 7
+ assert cfg["adaptive"] is False
+ assert "preset" not in cfg
+
+
+def test_expanded_preset_widens_config():
+ cfg = Preprocessor(preset="expanded").get_resolved_config()
+ assert cfg["categorical_method"] == "one-hot"
+ assert cfg["output_dim"] == 16
+ assert cfg["adaptive"] is False
+
+
+def test_adaptive_preset_enables_adaptive_width():
+ cfg = Preprocessor(preset="adaptive").get_resolved_config()
+ assert cfg["adaptive"] is True
+ assert cfg["min_output_dim"] == 5
+ assert cfg["max_output_dim"] == 16
+
+
+def test_explicit_param_overrides_preset():
+ cfg = Preprocessor(preset="expanded", output_dim=5).get_resolved_config()
+ assert cfg["output_dim"] == 5 # user value wins over the preset's 16
+
+
+def test_preset_is_preserved_by_get_params_and_clone():
+ pre = Preprocessor(preset="standard")
+ assert pre.get_params()["preset"] == "standard"
+ cloned = clone(pre)
+ assert isinstance(cloned, Preprocessor)
+ assert cloned.get_params()["preset"] == "standard"
+
+
+def test_invalid_preset_raises():
+ with pytest.raises(InvalidParamError, match="preset"):
+ Preprocessor(preset="nope").get_resolved_config()
+
+
+def test_presets_fit_with_distinct_widths(data):
+ X, y = data
+ widths = {}
+ for name in ("standard", "expanded", "adaptive"):
+ pre = Preprocessor(preset=name)
+ out = np.asarray(pre.fit_transform(X, y, return_array=True))
+ assert out.shape[0] == X.shape[0]
+ widths[name] = out.shape[1]
+ # "expanded" one-hot encodes and widens the numerical basis, so it is wider
+ # than the "standard" baseline.
+ assert widths["expanded"] > widths["standard"]
+
+
+def test_invalid_preset_raises_at_fit(data):
+ X, y = data
+ with pytest.raises(InvalidParamError, match="preset"):
+ Preprocessor(preset="nope").fit(X, y)
diff --git a/tests/integration/test_public_api.py b/tests/integration/test_public_api.py
new file mode 100644
index 0000000..5b3b93e
--- /dev/null
+++ b/tests/integration/test_public_api.py
@@ -0,0 +1,43 @@
+"""Public API surface contract for the top-level ``pretab`` package.
+
+Guards the names third parties import and confirms the legacy ``pretab.pipeline``
+package (folded into ``pretab.compose`` during the 1.0.0 restructure) is gone.
+"""
+
+import importlib
+
+import pytest
+
+import pretab
+
+
+def test_public_names_are_exported():
+ for name in ("Preprocessor", "PretabWarning", "configure_logging", "set_verbosity", "__version__"):
+ assert hasattr(pretab, name)
+
+
+def test_dunder_all_is_resolvable():
+ assert pretab.__all__
+ for name in pretab.__all__:
+ assert hasattr(pretab, name)
+
+
+def test_preprocessor_is_constructible():
+ assert pretab.Preprocessor() is not None
+
+
+def test_transformers_public_surface_is_resolvable():
+ transformers = importlib.import_module("pretab.transformers")
+ assert transformers.__all__
+ for name in transformers.__all__:
+ assert hasattr(transformers, name)
+
+
+def test_legacy_pipeline_package_is_removed():
+ with pytest.raises(ModuleNotFoundError):
+ importlib.import_module("pretab.pipeline")
+
+
+def test_compose_subsystem_is_importable():
+ for module in ("config", "registry", "factory", "output", "inspection", "feature_detection"):
+ importlib.import_module(f"pretab.compose.{module}")
diff --git a/tests/test_reproducibility.py b/tests/integration/test_reproducibility.py
similarity index 58%
rename from tests/test_reproducibility.py
rename to tests/integration/test_reproducibility.py
index c7e3368..ae9fc11 100644
--- a/tests/test_reproducibility.py
+++ b/tests/integration/test_reproducibility.py
@@ -1,9 +1,11 @@
-"""Phase 18: ``random_state`` + ``handle_missing`` host control on the Preprocessor.
-
-Verifies that both knobs are exposed on the :class:`Preprocessor`, propagate to
-the underlying numerical methods, keep prior behavior when unset, and make
-stochastic fits reproducible -- so a standalone user or an embedding host
-(DeepTab) can pin a global seed and choose a missing-value policy.
+"""``random_state`` + missing-value host control on the Preprocessor.
+
+Verifies that the reproducibility seed and the imputation knobs
+(``numerical_imputation`` / ``categorical_imputation`` / ``add_missing_indicator``)
+are exposed on the :class:`Preprocessor`, drive the per-column pipelines, keep
+prior behavior when unset, and make stochastic fits reproducible -- so a
+standalone user or an embedding host (DeepTab) can pin a global seed and choose a
+missing-value policy.
"""
import numpy as np
@@ -39,25 +41,32 @@ def _numerical_transformer(pre, feature):
# --- exposure & round-trip ------------------------------------------------- #
+
def test_new_params_defaults_and_get_params():
pre = Preprocessor()
assert pre.random_state is None
- assert pre.handle_missing == "median"
+ assert pre.numerical_imputation == "median"
+ assert pre.categorical_imputation == "most_frequent"
+ assert pre.add_missing_indicator is False
params = pre.get_params()
assert params["random_state"] is None
- assert params["handle_missing"] == "median"
+ assert params["numerical_imputation"] == "median"
+ assert params["categorical_imputation"] == "most_frequent"
+ assert params["add_missing_indicator"] is False
def test_clone_preserves_new_params():
- pre = Preprocessor(random_state=99, handle_missing="error")
+ pre = Preprocessor(random_state=99, numerical_imputation="mean", add_missing_indicator=True)
cloned = clone(pre)
assert isinstance(cloned, Preprocessor)
assert cloned.random_state == 99
- assert cloned.handle_missing == "error"
+ assert cloned.numerical_imputation == "mean"
+ assert cloned.add_missing_indicator is True
# --- random_state forwarding ----------------------------------------------- #
+
@pytest.mark.parametrize("method", ["ple", "rbf"])
def test_random_state_forwarded_when_set(data, method):
X, y = data
@@ -68,14 +77,10 @@ def test_random_state_forwarded_when_set(data, method):
def test_unset_random_state_preserves_component_defaults(data):
X, y = data
# PLE keeps its own default seed (51) when the Preprocessor seed is unset.
- ple = _numerical_transformer(
- Preprocessor(numerical_method="ple").fit(X, y), "a"
- )
+ ple = _numerical_transformer(Preprocessor(numerical_method="ple").fit(X, y), "a")
assert ple.random_state == 51
# Feature maps stay unseeded (None) when the Preprocessor seed is unset.
- rbf = _numerical_transformer(
- Preprocessor(numerical_method="rbf").fit(X, y), "a"
- )
+ rbf = _numerical_transformer(Preprocessor(numerical_method="rbf").fit(X, y), "a")
assert rbf.random_state is None
@@ -89,40 +94,51 @@ def test_fixed_random_state_makes_fit_reproducible(data, method):
np.testing.assert_array_equal(o1, o2)
-# --- handle_missing policy ------------------------------------------------- #
-
-def test_handle_missing_forwarded_to_ple(data):
- X, y = data
- pre = Preprocessor(numerical_method="ple", handle_missing="error").fit(X, y)
- assert _numerical_transformer(pre, "a").handle_missing == "error"
+# --- missing-value / imputation policy ------------------------------------- #
-def test_handle_missing_median_imputes_nan(data):
+def test_numerical_imputation_median_fills_nan(data):
X, y = data
X = X.copy()
X.iloc[0, 0] = np.nan
- # Default "median" keeps the mean imputer, so NaN is filled before PLE.
- pre = Preprocessor(numerical_method="ple", handle_missing="median").fit(X, y)
+ # Default "median" imputes before PLE, so NaN is filled and the fit succeeds.
+ pre = Preprocessor(numerical_method="ple").fit(X, y)
out = pre.transform(X, return_array=True)
assert isinstance(out, np.ndarray)
assert np.isfinite(out).all()
-def test_handle_missing_error_rejects_nan(data):
+def test_numerical_imputation_none_lets_nan_reach_transformer(data):
X, y = data
X = X.copy()
X.iloc[0, 0] = np.nan
- # "error" drops the imputer, so NaN reaches PLE which raises.
- pre = Preprocessor(numerical_method="ple", handle_missing="error")
+ # Disabling imputation lets NaN reach PLE, which requires finite input.
+ pre = Preprocessor(numerical_method="ple", numerical_imputation=None)
with pytest.raises(ValueError):
pre.fit(X, y)
+def test_add_missing_indicator_appends_columns(data):
+ X, y = data
+ X = X.copy()
+ X.iloc[0, 0] = np.nan
+ base = Preprocessor(numerical_method="standardization").fit(X, y).transform(X, return_array=True)
+ with_ind = (
+ Preprocessor(numerical_method="standardization", add_missing_indicator=True)
+ .fit(X, y)
+ .transform(X, return_array=True)
+ )
+ assert isinstance(base, np.ndarray)
+ assert isinstance(with_ind, np.ndarray)
+ assert with_ind.shape[1] > base.shape[1]
+
+
# --- transformer / helper level seeding ------------------------------------ #
+
def test_rbf_transformer_seeded_centers_reproducible(data):
X, y = data
r1 = RBFExpansionTransformer(output_dim=5, target_aware=True, random_state=3).fit(X.values, y.values)
r2 = RBFExpansionTransformer(output_dim=5, target_aware=True, random_state=3).fit(X.values, y.values)
- for a, b in zip(r1.centers_, r2.centers_):
+ for a, b in zip(r1.centers_, r2.centers_, strict=True):
np.testing.assert_array_equal(a, b)
diff --git a/tests/integration/test_serialization.py b/tests/integration/test_serialization.py
new file mode 100644
index 0000000..b02caa9
--- /dev/null
+++ b/tests/integration/test_serialization.py
@@ -0,0 +1,202 @@
+"""Tests for portable serialization: ``to_spec`` / ``from_spec`` (P9.1).
+
+Covers the versioned JSON envelope, bit-for-bit transform reproduction across
+representation families, file round-trips, categorical / missing-value handling,
+policy preservation, and the security allow-list that keeps loading a spec safe
+(unlike ``pickle``).
+"""
+
+import json
+
+import numpy as np
+import pandas as pd
+import pytest
+
+from pretab import Preprocessor, PretabSerializationError, RepresentationPolicy
+from pretab.compose.serialize import SCHEMA_VERSION
+
+
+@pytest.fixture
+def frame():
+ rng = np.random.default_rng(0)
+ return pd.DataFrame(
+ {
+ "a": rng.random(60),
+ "b": rng.random(60) * 10.0,
+ "c": rng.choice(["x", "y", "z"], 60),
+ }
+ )
+
+
+@pytest.fixture
+def target():
+ rng = np.random.default_rng(1)
+ return rng.random(60)
+
+
+# Representation configs that round-trip; each is (params, id).
+_CONFIGS = [
+ {"numerical_method": "rbf", "target_aware": False, "placement_strategy": "quantile"},
+ {"numerical_method": "sigmoid", "target_aware": False, "placement_strategy": "quantile"},
+ {"numerical_method": "tanh", "target_aware": False, "placement_strategy": "quantile"},
+ {"numerical_method": "relu", "target_aware": False, "placement_strategy": "quantile"},
+ {"numerical_method": "bspline", "target_aware": False, "placement_strategy": "quantile"},
+ {"numerical_method": "cubicspline", "target_aware": False, "placement_strategy": "quantile"},
+ {"numerical_method": "naturalspline", "target_aware": False, "placement_strategy": "quantile"},
+ {"numerical_method": "pspline", "target_aware": False, "placement_strategy": "uniform"},
+ {"numerical_method": "ple", "target_aware": True, "placement_strategy": "cart"},
+ {"numerical_method": "minmax", "target_aware": True, "placement_strategy": "cart"},
+ {"numerical_method": "standardization", "target_aware": True, "placement_strategy": "cart"},
+ {"numerical_method": "quantile", "target_aware": True, "placement_strategy": "cart"},
+]
+
+
+def _ids(configs):
+ return [c["numerical_method"] for c in configs]
+
+
+@pytest.mark.parametrize("params", _CONFIGS, ids=_ids(_CONFIGS))
+@pytest.mark.parametrize("categorical_method", ["int", "one-hot"])
+def test_round_trip_reproduces_transform_bit_for_bit(frame, target, params, categorical_method):
+ p = Preprocessor(output_dim=6, categorical_method=categorical_method, **params).fit(frame, target)
+ reference = np.asarray(p.transform(frame, return_array=True), dtype=float)
+
+ restored = Preprocessor.from_spec(p.to_spec())
+ reproduced = np.asarray(restored.transform(frame, return_array=True), dtype=float)
+
+ assert np.array_equal(reference, reproduced, equal_nan=True)
+ assert list(p.get_feature_names_out()) == list(restored.get_feature_names_out())
+
+
+def test_spec_is_json_serializable_and_versioned(frame, target):
+ p = Preprocessor(numerical_method="bspline", target_aware=False, placement_strategy="quantile").fit(frame, target)
+ spec = p.to_spec()
+
+ # The whole envelope must survive a JSON dumps/loads cycle unchanged.
+ reparsed = json.loads(json.dumps(spec))
+ assert reparsed["schema_version"] == SCHEMA_VERSION
+ assert reparsed["pretab_version"] == spec["pretab_version"]
+ assert set(reparsed["library_versions"]) == {"numpy", "scipy", "scikit_learn"}
+ assert reparsed["feature_names_out"] == list(p.get_feature_names_out())
+
+
+def test_file_round_trip(tmp_path, frame, target):
+ p = Preprocessor(
+ numerical_method="rbf", categorical_method="one-hot", target_aware=False, placement_strategy="quantile"
+ ).fit(frame, target)
+ path = tmp_path / "rep.json"
+
+ returned = p.to_spec(path)
+ assert path.exists()
+ assert returned["schema_version"] == SCHEMA_VERSION # to_spec still returns the dict
+
+ restored = Preprocessor.from_spec(str(path))
+ assert np.array_equal(
+ np.asarray(p.transform(frame, return_array=True), dtype=float),
+ np.asarray(restored.transform(frame, return_array=True), dtype=float),
+ equal_nan=True,
+ )
+
+
+def test_representation_summary_present(frame, target):
+ p = Preprocessor(numerical_method="rbf", target_aware=False, placement_strategy="quantile").fit(frame, target)
+ spec = p.to_spec()
+ families = {entry["family"] for entry in spec["representations"]}
+ assert "rbf" in families
+
+
+def test_round_trip_preserves_dtype_and_output_format(frame, target):
+ p = Preprocessor(
+ numerical_method="bspline",
+ target_aware=False,
+ placement_strategy="quantile",
+ dtype="float32",
+ output_format="dense",
+ ).fit(frame, target)
+ restored = Preprocessor.from_spec(p.to_spec())
+
+ out = restored.transform(frame, return_array=True)
+ assert isinstance(out, np.ndarray)
+ assert out.dtype == np.float32
+ assert restored.dtype == "float32"
+ assert restored.output_format == "dense"
+
+
+def test_round_trip_preserves_policy(frame, target):
+ p = Preprocessor(
+ numerical_method="bspline",
+ target_aware=False,
+ placement_strategy="quantile",
+ policy={"constant": "error"},
+ ).fit(frame, target)
+ restored = Preprocessor.from_spec(p.to_spec())
+ assert isinstance(restored.policy_, RepresentationPolicy)
+ assert restored.policy_.constant == "error"
+
+
+def test_round_trip_with_missing_values(target):
+ frame = pd.DataFrame({"a": [1.0, np.nan, 3.0, 4.0, np.nan, 6.0] * 5, "c": ["x", "y", None, "x", "y", "z"] * 5})
+ y = np.arange(len(frame), dtype=float)
+ p = Preprocessor(
+ numerical_method="rbf",
+ categorical_method="one-hot",
+ target_aware=False,
+ placement_strategy="quantile",
+ numerical_imputation="median",
+ ).fit(frame, y)
+ restored = Preprocessor.from_spec(p.to_spec())
+ assert np.array_equal(
+ np.asarray(p.transform(frame, return_array=True), dtype=float),
+ np.asarray(restored.transform(frame, return_array=True), dtype=float),
+ equal_nan=True,
+ )
+
+
+def test_round_trip_reproduces_unseen_category_encoding(frame, target):
+ p = Preprocessor(
+ numerical_method="rbf", categorical_method="one-hot", target_aware=False, placement_strategy="quantile"
+ ).fit(frame, target)
+ restored = Preprocessor.from_spec(p.to_spec())
+
+ unseen = frame.copy()
+ unseen.loc[unseen.index[:5], "c"] = "brand_new"
+ assert np.array_equal(
+ np.asarray(p.transform(unseen, return_array=True), dtype=float),
+ np.asarray(restored.transform(unseen, return_array=True), dtype=float),
+ equal_nan=True,
+ )
+
+
+def test_to_spec_requires_fitted():
+ from sklearn.exceptions import NotFittedError
+
+ p = Preprocessor(numerical_method="rbf", target_aware=False, placement_strategy="quantile")
+ with pytest.raises(NotFittedError):
+ p.to_spec()
+
+
+def test_from_spec_rejects_unknown_schema_version(frame, target):
+ p = Preprocessor(numerical_method="rbf", target_aware=False, placement_strategy="quantile").fit(frame, target)
+ spec = p.to_spec()
+ spec["schema_version"] = SCHEMA_VERSION + 999
+ with pytest.raises(PretabSerializationError, match="schema_version"):
+ Preprocessor.from_spec(spec)
+
+
+def test_from_spec_rejects_missing_schema_version():
+ with pytest.raises(PretabSerializationError, match="schema_version"):
+ Preprocessor.from_spec({"state": {}})
+
+
+def test_from_spec_refuses_disallowed_module(frame, target):
+ p = Preprocessor(numerical_method="rbf", target_aware=False, placement_strategy="quantile").fit(frame, target)
+ spec = p.to_spec()
+ # Simulate a tampered spec that tries to import an arbitrary class on load.
+ spec["state"]["column_transformer_"] = {"__estimator__": {"class": "os:system", "state": {}}}
+ with pytest.raises(PretabSerializationError, match="disallowed module"):
+ Preprocessor.from_spec(spec)
+
+
+def test_from_spec_rejects_bad_source_type():
+ with pytest.raises(PretabSerializationError):
+ Preprocessor.from_spec(12345)
diff --git a/tests/test_verbosity.py b/tests/integration/test_verbosity.py
similarity index 90%
rename from tests/test_verbosity.py
rename to tests/integration/test_verbosity.py
index c7849af..cdc53be 100644
--- a/tests/test_verbosity.py
+++ b/tests/integration/test_verbosity.py
@@ -15,8 +15,7 @@
import pytest
from pretab import Preprocessor, PretabWarning, configure_logging, set_verbosity
-from pretab.core.exceptions import ConfigWarning, DataWarning
-from pretab.transformers import PLETransformer
+from pretab.exceptions import ConfigWarning
@pytest.fixture
@@ -81,9 +80,7 @@ def test_verbose_2_logs_feature_table(sample_data, caplog):
caplog.set_level(logging.DEBUG, logger="pretab")
Preprocessor(numerical_method="ple", verbose=2).fit(X, y)
assert "fit complete" in caplog.text # summary still emitted
- debug_text = "\n".join(
- r.getMessage() for r in caplog.records if r.levelno == logging.DEBUG
- )
+ debug_text = "\n".join(r.getMessage() for r in caplog.records if r.levelno == logging.DEBUG)
assert "feature" in debug_text # table header
assert "pipeline" in debug_text
@@ -92,9 +89,7 @@ def test_verbose_3_logs_internal_decisions(sample_data, caplog):
X, y = sample_data
caplog.set_level(logging.DEBUG, logger="pretab")
Preprocessor(numerical_method="ple", verbose=3).fit(X, y)
- debug_text = "\n".join(
- r.getMessage() for r in caplog.records if r.levelno == logging.DEBUG
- )
+ debug_text = "\n".join(r.getMessage() for r in caplog.records if r.levelno == logging.DEBUG)
# Level 3 surfaces fitted internals (e.g. PLE thresholds / output width).
assert "thresholds_" in debug_text or "total_output_dim_" in debug_text
@@ -169,10 +164,7 @@ def test_set_verbosity_sets_logger_level():
def test_configure_logging_attaches_stream_handler_when_none():
logger = logging.getLogger("pretab")
configure_logging(1)
- assert any(
- isinstance(h, logging.StreamHandler) and not isinstance(h, logging.NullHandler)
- for h in logger.handlers
- )
+ assert any(isinstance(h, logging.StreamHandler) and not isinstance(h, logging.NullHandler) for h in logger.handlers)
assert logger.level == logging.INFO
@@ -201,12 +193,3 @@ def test_config_warning_is_a_pretab_warning(sample_data):
X, y = sample_data
with pytest.warns(PretabWarning):
Preprocessor(numerical_method="bspline", output_dim=100).fit(X, y)
-
-
-def test_ple_nan_removal_warns_data_warning():
- rng = np.random.RandomState(3)
- X = rng.rand(30, 1)
- y = rng.rand(30)
- X[0, 0] = np.nan
- with pytest.warns(DataWarning):
- PLETransformer(output_dim=5, handle_missing="median").fit(X, y)
diff --git a/tests/placement/test_placement.py b/tests/placement/test_placement.py
new file mode 100644
index 0000000..e1c629d
--- /dev/null
+++ b/tests/placement/test_placement.py
@@ -0,0 +1,205 @@
+"""Contract tests for the :mod:`pretab.placement` subsystem (Phase 2, P2.8).
+
+These lock the placement strategy contract independently of any transformer:
+sorted, in-range, dedup-free locations; the requested-vs-effective unit counts;
+reproducibility; target-required behaviour for the supervised strategies; both
+classification and regression; the factory's combo validation; and the fixed
+resolution policy.
+"""
+
+import numpy as np
+import pytest
+
+from pretab.core.knots import quantile_knots, spanning_knots, uniform_knots
+from pretab.exceptions import IncompatibleParamsError, InvalidParamError
+from pretab.placement import (
+ BasePlacementStrategy,
+ CARTPlacement,
+ FixedResolution,
+ PlacementResult,
+ QuantilePlacement,
+ UniformPlacement,
+ create_placement_strategy,
+)
+from pretab.placement.adapters import RBFPlacementAdapter, SplinePlacementAdapter
+
+
+@pytest.fixture
+def data():
+ rng = np.random.RandomState(0)
+ x = rng.uniform(-3, 3, size=300)
+ y = np.sin(x) + 0.1 * rng.randn(300)
+ return x, y
+
+
+@pytest.fixture
+def clf_data():
+ rng = np.random.RandomState(1)
+ x = rng.uniform(-3, 3, size=300)
+ y = (x > 0).astype(int)
+ return x, y
+
+
+# --------------------------------------------------------------------------- #
+# Unsupervised strategies
+# --------------------------------------------------------------------------- #
+@pytest.mark.parametrize("cls", [UniformPlacement, QuantilePlacement])
+def test_unsupervised_sorted_and_counted(cls, data):
+ x, _ = data
+ result = cls(6).place(x)
+ assert isinstance(result, PlacementResult)
+ assert result.locations.ndim == 1
+ assert np.all(np.diff(result.locations) > 0) # sorted, no duplicates
+ assert result.requested_units == 6
+ assert result.effective_units == len(result.locations) == 6
+ assert result.target_aware is False
+
+
+@pytest.mark.parametrize("cls", [UniformPlacement, QuantilePlacement])
+def test_unsupervised_interior_in_range(cls, data):
+ x, _ = data
+ locs = cls(6).place(x).locations
+ assert locs.min() > x.min()
+ assert locs.max() < x.max()
+
+
+def test_unsupervised_matches_primitives(data):
+ x, _ = data
+ assert np.allclose(UniformPlacement(6).place(x).locations, uniform_knots(x, 6))
+ assert np.allclose(QuantilePlacement(6).place(x).locations, quantile_knots(x, 6))
+ assert np.allclose(
+ UniformPlacement(6, include_endpoints=True).place(x).locations,
+ spanning_knots(x, 6, "uniform"),
+ )
+ assert np.allclose(
+ QuantilePlacement(6, include_endpoints=True).place(x).locations,
+ spanning_knots(x, 6, "quantile"),
+ )
+
+
+def test_unsupervised_endpoints_span_range(data):
+ x, _ = data
+ locs = UniformPlacement(6, include_endpoints=True).place(x).locations
+ assert locs[0] == pytest.approx(x.min())
+ assert locs[-1] == pytest.approx(x.max())
+
+
+def test_unsupervised_ignores_nan(data):
+ x, _ = data
+ x = x.copy()
+ x[:10] = np.nan
+ locs = UniformPlacement(6).place(x).locations
+ assert np.all(np.isfinite(locs))
+
+
+# --------------------------------------------------------------------------- #
+# Supervised strategies
+# --------------------------------------------------------------------------- #
+def test_cart_sorted_in_range_and_counts(data):
+ x, y = data
+ result = CARTPlacement(min_count=2, max_count=5, task="regression").place(x, y)
+ assert np.all(np.diff(result.locations) > 0)
+ assert result.locations.min() > x.min()
+ assert result.locations.max() < x.max()
+ assert result.requested_units == 5
+ assert result.effective_units == len(result.locations) <= 5
+ assert result.target_aware is True
+
+
+def test_cart_requires_y(data):
+ x, _ = data
+ with pytest.raises(IncompatibleParamsError, match="requires y"):
+ CARTPlacement(min_count=2, max_count=5).place(x, None)
+
+
+def test_cart_reproducible(data):
+ x, y = data
+ a = CARTPlacement(min_count=3, max_count=10, random_state=51).place(x, y).locations
+ b = CARTPlacement(min_count=3, max_count=10, random_state=51).place(x, y).locations
+ np.testing.assert_array_equal(a, b)
+
+
+def test_cart_classification(clf_data):
+ x, y = clf_data
+ locs = CARTPlacement(min_count=1, max_count=5, task="classification").place(x, y).locations
+ assert np.all(np.diff(locs) > 0)
+ assert locs.min() > x.min()
+ assert locs.max() < x.max()
+
+
+# --------------------------------------------------------------------------- #
+# Factory + combo validation (D4)
+# --------------------------------------------------------------------------- #
+def test_factory_builds_each_strategy():
+ assert isinstance(
+ create_placement_strategy(target_aware=True, placement_strategy="cart", min_count=1, max_count=5),
+ CARTPlacement,
+ )
+ assert isinstance(
+ create_placement_strategy(target_aware=False, placement_strategy="uniform", min_count=6, max_count=6),
+ UniformPlacement,
+ )
+ assert isinstance(
+ create_placement_strategy(target_aware=False, placement_strategy="quantile", min_count=6, max_count=6),
+ QuantilePlacement,
+ )
+
+
+@pytest.mark.parametrize(
+ ("target_aware", "strategy"),
+ [(True, "uniform"), (True, "quantile"), (False, "cart"), (False, "lightgbm")],
+)
+def test_factory_rejects_invalid_combo(target_aware, strategy):
+ with pytest.raises(InvalidParamError):
+ create_placement_strategy(target_aware=target_aware, placement_strategy=strategy, min_count=1, max_count=5)
+
+
+def test_strategies_are_base_instances():
+ strat = create_placement_strategy(target_aware=True, placement_strategy="cart", min_count=1, max_count=5)
+ assert isinstance(strat, BasePlacementStrategy)
+
+
+# --------------------------------------------------------------------------- #
+# Resolution policy
+# --------------------------------------------------------------------------- #
+def test_fixed_resolution_non_adaptive_pins_output_dim():
+ assert FixedResolution(adaptive=False).resolve(6, None, None, floor=1) == (6, 6)
+
+
+def test_fixed_resolution_adaptive_window():
+ assert FixedResolution(adaptive=True).resolve(6, 2, 10, floor=1) == (2, 10)
+
+
+def test_fixed_resolution_rejects_below_floor():
+ with pytest.raises(InvalidParamError):
+ FixedResolution(adaptive=True).resolve(6, 0, 10, floor=1)
+
+
+def test_fixed_resolution_non_adaptive_conflict():
+ with pytest.raises(IncompatibleParamsError):
+ FixedResolution(adaptive=False).resolve(3, 5, None, floor=1)
+
+
+# --------------------------------------------------------------------------- #
+# Adapters
+# --------------------------------------------------------------------------- #
+def test_spline_adapter_returns_interior_knots(data):
+ x, y = data
+ adapter = SplinePlacementAdapter(degree=3, placement_strategy="cart")
+ knots = adapter.get_knot_locations(x.reshape(-1, 1), y, task="regression")
+ assert np.all(np.diff(knots) > 0)
+ assert knots.min() > x.min()
+ assert knots.max() < x.max()
+
+
+def test_spline_adapter_rejects_unsupervised_strategy():
+ with pytest.raises(InvalidParamError):
+ SplinePlacementAdapter(degree=3, placement_strategy="uniform")
+
+
+def test_rbf_adapter_unsupervised_matches_inline(data):
+ x, _ = data
+ centers = RBFPlacementAdapter(target_aware=False, placement_strategy="quantile").get_centers(x, None, 6, 6)
+ assert np.allclose(centers, np.percentile(x, np.linspace(0, 100, 6)))
+ centers_u = RBFPlacementAdapter(target_aware=False, placement_strategy="uniform").get_centers(x, None, 6, 6)
+ assert np.allclose(centers_u, np.linspace(x.min(), x.max(), 6))
diff --git a/tests/placement/test_spline_placement_adapter.py b/tests/placement/test_spline_placement_adapter.py
new file mode 100644
index 0000000..5d24fa1
--- /dev/null
+++ b/tests/placement/test_spline_placement_adapter.py
@@ -0,0 +1,101 @@
+import numpy as np
+import pytest
+
+from pretab.exceptions import IncompatibleParamsError
+from pretab.placement.adapters import SplinePlacementAdapter
+
+
+@pytest.fixture
+def data():
+ rng = np.random.RandomState(0)
+ X = rng.uniform(-3, 3, size=(300, 1))
+ y = np.sin(X[:, 0]) + 0.1 * rng.randn(300)
+ return X, y
+
+
+def test_basis_to_knots_conversion():
+ adapter = SplinePlacementAdapter(placement_strategy="cart", degree=3, min_basis_functions=2, max_basis_functions=10)
+ assert adapter.max_knots == 10 - 3 - 1
+ assert adapter.min_knots == 0
+
+
+def test_cart_returns_sorted_knots_in_range(data):
+ X, y = data
+ adapter = SplinePlacementAdapter(placement_strategy="cart", max_basis_functions=12, degree=3)
+ knots = adapter.get_knot_locations(X, y, task="regression")
+
+ assert knots.ndim == 1
+ assert np.all(np.diff(knots) > 0) # sorted and unique
+ assert knots.min() > X.min()
+ assert knots.max() < X.max()
+
+
+def test_cart_respects_max_knots(data):
+ X, y = data
+ adapter = SplinePlacementAdapter(placement_strategy="cart", min_basis_functions=6, max_basis_functions=8, degree=3)
+ knots = adapter.get_knot_locations(X, y)
+ assert len(knots) <= adapter.max_knots
+
+
+def test_cart_requires_y(data):
+ X, _ = data
+ with pytest.raises(IncompatibleParamsError, match="requires y"):
+ SplinePlacementAdapter(placement_strategy="cart", degree=3).get_knot_locations(X, None)
+
+
+def test_cart_reproducible(data):
+ X, y = data
+ a = SplinePlacementAdapter(placement_strategy="cart", degree=3).get_knot_locations(X, y)
+ b = SplinePlacementAdapter(placement_strategy="cart", degree=3).get_knot_locations(X, y)
+ np.testing.assert_array_equal(a, b)
+
+
+def test_cart_small_sample_quantile_fallback():
+ rng = np.random.RandomState(1)
+ X = rng.rand(5, 1)
+ y = rng.rand(5)
+ adapter = SplinePlacementAdapter(placement_strategy="cart", min_basis_functions=5, degree=1)
+ knots = adapter.get_knot_locations(X, y)
+ assert len(knots) == adapter.min_knots
+
+
+def test_cart_classification_task():
+ rng = np.random.RandomState(2)
+ X = rng.rand(200, 1)
+ y = (X[:, 0] > 0.5).astype(int)
+ knots = SplinePlacementAdapter(placement_strategy="cart", degree=3).get_knot_locations(X, y, task="classification")
+ assert knots.ndim == 1
+
+
+def test_cart_handles_nan_rows(data):
+ X, y = data
+ X_missing = X.copy()
+ X_missing[:5, 0] = np.nan
+ adapter = SplinePlacementAdapter(placement_strategy="cart", max_basis_functions=12, degree=3)
+ knots = adapter.get_knot_locations(X_missing, y)
+ assert np.isfinite(knots).all()
+
+
+def test_rejects_unsupervised_strategy():
+ with pytest.raises(Exception): # noqa: B017 - invalid_param_error -> InvalidParamError
+ SplinePlacementAdapter(placement_strategy="quantile", degree=3)
+
+
+def test_lightgbm_adapter_runs(data):
+ pytest.importorskip("lightgbm")
+ X, y = data
+ adapter = SplinePlacementAdapter(placement_strategy="lightgbm", max_basis_functions=12, degree=3)
+ knots = adapter.get_knot_locations(X, y, task="regression")
+
+ assert knots.ndim == 1
+ assert np.all(np.diff(knots) > 0)
+ assert knots.min() > X.min()
+ assert knots.max() < X.max()
+
+
+def test_lightgbm_adapter_reproducible(data):
+ pytest.importorskip("lightgbm")
+ X, y = data
+ a = SplinePlacementAdapter(placement_strategy="lightgbm", degree=3).get_knot_locations(X, y)
+ b = SplinePlacementAdapter(placement_strategy="lightgbm", degree=3).get_knot_locations(X, y)
+ np.testing.assert_array_equal(a, b)
diff --git a/tests/regression/_golden/featuremap_unsupervised.json b/tests/regression/_golden/featuremap_unsupervised.json
new file mode 100644
index 0000000..30e67d6
--- /dev/null
+++ b/tests/regression/_golden/featuremap_unsupervised.json
@@ -0,0 +1,28 @@
+{
+ "shape": [
+ 200,
+ 20
+ ],
+ "feature_names": [
+ "num_num_linear_rbf0",
+ "num_num_linear_rbf1",
+ "num_num_linear_rbf2",
+ "num_num_linear_rbf3",
+ "num_num_linear_rbf4",
+ "num_num_linear_rbf5",
+ "num_num_normal_rbf0",
+ "num_num_normal_rbf1",
+ "num_num_normal_rbf2",
+ "num_num_normal_rbf3",
+ "num_num_normal_rbf4",
+ "num_num_normal_rbf5",
+ "num_num_skewed_rbf0",
+ "num_num_skewed_rbf1",
+ "num_num_skewed_rbf2",
+ "num_num_skewed_rbf3",
+ "num_num_skewed_rbf4",
+ "num_num_skewed_rbf5",
+ "cat_cat_str",
+ "cat_cat_int"
+ ]
+}
diff --git a/tests/regression/_golden/featuremap_unsupervised.npz b/tests/regression/_golden/featuremap_unsupervised.npz
new file mode 100644
index 0000000..3e01900
Binary files /dev/null and b/tests/regression/_golden/featuremap_unsupervised.npz differ
diff --git a/tests/regression/_golden/ple_supervised.json b/tests/regression/_golden/ple_supervised.json
new file mode 100644
index 0000000..f8bb231
--- /dev/null
+++ b/tests/regression/_golden/ple_supervised.json
@@ -0,0 +1,31 @@
+{
+ "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",
+ "cat_cat_str_alpha",
+ "cat_cat_str_beta",
+ "cat_cat_str_gamma",
+ "cat_cat_int_0",
+ "cat_cat_int_1",
+ "cat_cat_int_2",
+ "cat_cat_int_3",
+ "cat_cat_int_4"
+ ]
+}
diff --git a/tests/regression/_golden/ple_supervised.npz b/tests/regression/_golden/ple_supervised.npz
new file mode 100644
index 0000000..99c8b17
Binary files /dev/null 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
new file mode 100644
index 0000000..9fbe3bb
--- /dev/null
+++ b/tests/regression/_golden/spline_unsupervised.json
@@ -0,0 +1,37 @@
+{
+ "shape": [
+ 200,
+ 29
+ ],
+ "feature_names": [
+ "num_num_linear_ncs0",
+ "num_num_linear_ncs1",
+ "num_num_linear_ncs2",
+ "num_num_linear_ncs3",
+ "num_num_linear_ncs4",
+ "num_num_linear_ncs5",
+ "num_num_linear_ncs6",
+ "num_num_normal_ncs0",
+ "num_num_normal_ncs1",
+ "num_num_normal_ncs2",
+ "num_num_normal_ncs3",
+ "num_num_normal_ncs4",
+ "num_num_normal_ncs5",
+ "num_num_normal_ncs6",
+ "num_num_skewed_ncs0",
+ "num_num_skewed_ncs1",
+ "num_num_skewed_ncs2",
+ "num_num_skewed_ncs3",
+ "num_num_skewed_ncs4",
+ "num_num_skewed_ncs5",
+ "num_num_skewed_ncs6",
+ "cat_cat_str_alpha",
+ "cat_cat_str_beta",
+ "cat_cat_str_gamma",
+ "cat_cat_int_0",
+ "cat_cat_int_1",
+ "cat_cat_int_2",
+ "cat_cat_int_3",
+ "cat_cat_int_4"
+ ]
+}
diff --git a/tests/regression/_golden/spline_unsupervised.npz b/tests/regression/_golden/spline_unsupervised.npz
new file mode 100644
index 0000000..4e1ae9a
Binary files /dev/null and b/tests/regression/_golden/spline_unsupervised.npz differ
diff --git a/tests/regression/test_edge_cases.py b/tests/regression/test_edge_cases.py
new file mode 100644
index 0000000..5733551
--- /dev/null
+++ b/tests/regression/test_edge_cases.py
@@ -0,0 +1,112 @@
+"""P8.6 edge-case regression suite.
+
+End-to-end :class:`~pretab.Preprocessor` guards for the recurring production edge
+cases: constant features, ``custombin`` discretization alongside string
+categoricals, duplicate support points, missing values, and unseen categories.
+These pin the *observable* Preprocessor behaviour (shape, finiteness,
+determinism, and typed errors) so later refactors cannot silently change
+edge-case handling.
+"""
+
+import numpy as np
+import pandas as pd
+import pytest
+
+from pretab import Preprocessor
+from pretab.exceptions import PretabDataError
+
+
+def _finite(array) -> bool:
+ return bool(np.isfinite(array).all())
+
+
+# --- constant features ---------------------------------------------------------
+
+
+def test_constant_numeric_graceful_method_is_finite():
+ X = pd.DataFrame({"const": np.full(50, 3.14), "vary": np.linspace(0.0, 1.0, 50)})
+ out = Preprocessor(numerical_method="minmax").fit_transform(X, return_array=True)
+ assert isinstance(out, np.ndarray)
+ assert out.shape == (50, 2)
+ assert _finite(out)
+
+
+def test_constant_numeric_with_error_policy_raises():
+ X = pd.DataFrame({"const": np.full(50, 3.14), "vary": np.linspace(0.0, 1.0, 50)})
+ with pytest.raises(PretabDataError):
+ Preprocessor(numerical_method="minmax", policy={"constant": "error"}).fit(X)
+
+
+# --- custombin + string categoricals ------------------------------------------
+
+
+def test_custombin_is_deterministic_and_integer_coded():
+ rng = np.random.RandomState(11)
+ X = pd.DataFrame({"num": rng.rand(120), "cat": rng.choice(["red", "green", "blue"], size=120)})
+ kwargs = {
+ "numerical_method": "custombin",
+ "categorical_method": "one-hot",
+ "output_dim": 5,
+ "target_aware": False,
+ "placement_strategy": "quantile",
+ }
+ out1 = Preprocessor(**kwargs).fit_transform(X, return_array=True)
+ out2 = Preprocessor(**kwargs).fit_transform(X, return_array=True)
+ np.testing.assert_array_equal(out1, out2)
+ assert _finite(out1)
+ # The custombin block is integer-valued bin codes in [0, output_dim).
+ bin_col = out1[:, 0]
+ assert np.all(bin_col == np.floor(bin_col))
+ assert bin_col.min() >= 0
+ assert bin_col.max() < 5
+
+
+# --- duplicate support points --------------------------------------------------
+
+
+def test_duplicate_support_points_are_handled():
+ # 90% of the mass sits on a single value, forcing duplicate knot candidates.
+ X = pd.DataFrame({"x": np.concatenate([np.full(90, 0.5), np.linspace(0.0, 1.0, 30)])})
+ p = Preprocessor(
+ numerical_method="bspline",
+ output_dim=8,
+ target_aware=False,
+ placement_strategy="quantile",
+ ).fit(X)
+ out = p.transform(X, return_array=True)
+ assert isinstance(out, np.ndarray)
+ assert _finite(out)
+ assert out.shape[0] == len(X)
+
+
+# --- missing values ------------------------------------------------------------
+
+
+def test_missing_values_imputed_by_default():
+ X = pd.DataFrame({"x": [1.0, 2.0, np.nan, 4.0, 5.0, 6.0]})
+ out = Preprocessor(numerical_method="minmax").fit_transform(X, return_array=True)
+ assert isinstance(out, np.ndarray)
+ assert not np.isnan(out).any()
+
+
+def test_missing_values_separate_state_marks_rows():
+ X = pd.DataFrame({"x": [1.0, 2.0, np.nan, 4.0, 5.0, 6.0]})
+ p = Preprocessor(numerical_method="minmax", missing_policy="separate_state").fit(X)
+ names = list(p.get_feature_names_out())
+ assert any(n.endswith("__missing") for n in names)
+ out = p.transform(X, return_array=True)
+ assert _finite(out)
+
+
+# --- unseen categories ---------------------------------------------------------
+
+
+def test_unseen_categories_do_not_crash():
+ train = pd.DataFrame({"c": ["a", "b", "a", "b", "a", "b"]})
+ unseen = pd.DataFrame({"c": ["a", "b", "c", "a", "z", "b"]})
+ p = Preprocessor(categorical_method="one-hot").fit(train)
+ out = p.transform(unseen, return_array=True)
+ assert _finite(out)
+ # handle_unknown="ignore" encodes unseen categories as an all-zero row.
+ assert out[2].sum() == 0.0
+ assert out[4].sum() == 0.0
diff --git a/tests/regression/test_golden_baseline.py b/tests/regression/test_golden_baseline.py
new file mode 100644
index 0000000..502f7a5
--- /dev/null
+++ b/tests/regression/test_golden_baseline.py
@@ -0,0 +1,110 @@
+"""P0.4 behaviour baseline: golden-output regression guard.
+
+Captures the exact output of the ``Preprocessor`` for a handful of representative
+configurations *before* the 1.0.0 restructure, so the file moves / renames of
+Phases 1-3 can be proven behaviour-preserving. Each config is compared against a
+committed golden array with a tight tolerance (robust across the OS/Python CI
+matrix) plus exact shape and feature-name checks.
+
+Regenerate the goldens intentionally (e.g. after a deliberate behaviour change in
+Phases 4-5, recorded in CHANGELOG.md) with::
+
+ PRETAB_REGEN_GOLDEN=1 pytest tests/regression/test_golden_baseline.py
+"""
+
+import json
+import os
+from pathlib import Path
+
+import numpy as np
+import pandas as pd
+import pytest
+
+from pretab.preprocessor import Preprocessor
+
+GOLDEN_DIR = Path(__file__).parent / "_golden"
+REGEN = os.environ.get("PRETAB_REGEN_GOLDEN") == "1"
+
+RTOL = 1e-6
+ATOL = 1e-8
+
+
+def _make_dataset():
+ """Deterministic mixed-type dataset shared by every golden config."""
+ rng = np.random.RandomState(20240726)
+ n = 200
+ X = pd.DataFrame(
+ {
+ "num_linear": np.linspace(-3.0, 3.0, n),
+ "num_normal": rng.randn(n),
+ "num_skewed": rng.exponential(scale=2.0, size=n),
+ "cat_str": rng.choice(["alpha", "beta", "gamma"], size=n),
+ "cat_int": rng.randint(0, 5, size=n),
+ }
+ )
+ y = pd.Series(
+ 2.0 * X["num_linear"] + X["num_normal"] - 0.5 * X["num_skewed"] + rng.randn(n) * 0.1,
+ name="target",
+ )
+ return X, y
+
+
+CONFIGS = {
+ "spline_unsupervised": {
+ "numerical_method": "naturalspline",
+ "categorical_method": "one-hot",
+ "target_aware": False,
+ "placement_strategy": "quantile",
+ "output_dim": 7,
+ "random_state": 0,
+ },
+ "ple_supervised": {
+ "numerical_method": "ple",
+ "categorical_method": "one-hot",
+ "target_aware": True,
+ "placement_strategy": "cart",
+ "task": "regression",
+ "output_dim": 5,
+ "random_state": 0,
+ },
+ "featuremap_unsupervised": {
+ "numerical_method": "rbf",
+ "categorical_method": "int",
+ "target_aware": False,
+ "placement_strategy": "uniform",
+ "output_dim": 6,
+ "random_state": 0,
+ },
+}
+
+
+def _transform(config):
+ X, y = _make_dataset()
+ pre = Preprocessor(**config)
+ array = np.asarray(pre.fit_transform(X, y, return_array=True))
+ names = [str(name) for name in pre.get_feature_names_out()]
+ return array, names
+
+
+@pytest.mark.smoke
+@pytest.mark.parametrize("config_id", sorted(CONFIGS))
+def test_golden_output_is_behaviour_preserving(config_id):
+ array, names = _transform(CONFIGS[config_id])
+ npz_path = GOLDEN_DIR / f"{config_id}.npz"
+ json_path = GOLDEN_DIR / f"{config_id}.json"
+
+ if REGEN:
+ GOLDEN_DIR.mkdir(parents=True, exist_ok=True)
+ np.savez_compressed(npz_path, output=array)
+ json_path.write_text(json.dumps({"shape": list(array.shape), "feature_names": names}, indent=2))
+ pytest.skip(f"regenerated golden for {config_id}")
+
+ assert npz_path.exists(), (
+ f"missing golden {npz_path.name}; regenerate with PRETAB_REGEN_GOLDEN=1 pytest {Path(__file__).name}"
+ )
+ golden = np.load(npz_path)["output"]
+ meta = json.loads(json_path.read_text())
+
+ assert list(array.shape) == meta["shape"]
+ assert names == meta["feature_names"]
+ np.testing.assert_allclose(array, golden, rtol=RTOL, atol=ATOL)
diff --git a/tests/test_custombin_transformer.py b/tests/test_custombin_transformer.py
deleted file mode 100644
index 1be1ec5..0000000
--- a/tests/test_custombin_transformer.py
+++ /dev/null
@@ -1,82 +0,0 @@
-import numpy as np
-import pandas as pd
-import pytest
-from sklearn.base import TransformerMixin, BaseEstimator
-from pretab.transformers import CustomBinTransformer
-
-
-@pytest.mark.parametrize("bins", [2, [0.0, 0.5, 1.0]])
-def test_custom_bin_transformer_basic_functionality(bins):
- X = np.array([[0.1], [0.4], [0.6], [0.8], [0.95]])
- transformer = CustomBinTransformer(output_dim=bins)
- transformer.fit(X)
-
- # Ensure fitted attribute exists
- assert hasattr(transformer, "n_features_in_")
- assert transformer.n_features_in_ == 1
- assert transformer.total_output_dim_ == 1
-
- # Transform
- Xt = transformer.transform(X)
- assert isinstance(Xt, np.ndarray)
- assert Xt.shape == (X.shape[0], 1)
- assert Xt.dtype.kind in {"i", "u"} # integer bins
-
- # Check values are within bin range
- assert Xt.min() >= 0
- if isinstance(bins, int):
- assert Xt.max() < bins
- else:
- assert Xt.max() < len(bins) - 1
-
-
-@pytest.mark.parametrize("bins", [2, [0.0, 0.5, 1.0]])
-@pytest.mark.parametrize("input_type", ["list", "np", "df"])
-def test_custom_bin_transformer_input_types(bins, input_type):
- raw = [[0.1], [0.4], [0.6], [0.8]]
- X = (
- np.array(raw) # Always convert to array to be safe
- if input_type == "list"
- else np.array(raw) if input_type == "np" else pd.DataFrame(raw, columns=["x"])
- )
- transformer = CustomBinTransformer(output_dim=bins)
- Xt = transformer.fit_transform(X)
-
- assert isinstance(Xt, np.ndarray)
- assert Xt.shape == (4, 1)
-
-
-def test_custom_bin_transformer_invalid_input():
- transformer = CustomBinTransformer(output_dim=3)
- with pytest.raises(Exception):
- transformer.transform("invalid_input")
-
-
-def test_custom_bin_transformer_raises_on_invalid_shape():
- transformer = CustomBinTransformer(output_dim=3)
- X = np.array([[0.1]]) # This will become scalar after squeeze()
-
- with pytest.raises(ValueError, match="Input must have more than 2 observations."):
- transformer.transform(X)
-
-
-def test_custom_bin_transformer_invalid_bins_type():
- with pytest.raises(Exception):
- CustomBinTransformer(output_dim="not_valid").fit_transform(np.array([[0.1]]))
-
-
-def test_custom_bin_transformer_feature_names_out():
- transformer = CustomBinTransformer(output_dim=3)
- transformer.fit(np.array([[0.2]]))
- names = transformer.get_feature_names_out(["feature1"])
- assert names == ["feature1"]
-
-
-def test_custom_bin_transformer_feature_names_out_raises():
- transformer = CustomBinTransformer(output_dim=3)
- with pytest.raises(ValueError):
- transformer.get_feature_names_out()
-
-
-def test_custom_bin_transformer_is_sklearn_compatible():
- assert isinstance(CustomBinTransformer(output_dim=3), (BaseEstimator, TransformerMixin))
diff --git a/tests/test_knot_selectors.py b/tests/test_knot_selectors.py
deleted file mode 100644
index b2f7709..0000000
--- a/tests/test_knot_selectors.py
+++ /dev/null
@@ -1,103 +0,0 @@
-import numpy as np
-import pytest
-
-from pretab.transformers.splines import (
- BaseKnotSelector,
- CARTKnotSelector,
- LightGBMKnotSelector,
-)
-
-
-@pytest.fixture
-def data():
- rng = np.random.RandomState(0)
- X = rng.uniform(-3, 3, size=(300, 1))
- y = np.sin(X[:, 0]) + 0.1 * rng.randn(300)
- return X, y
-
-
-def test_selectors_subclass_base():
- assert issubclass(CARTKnotSelector, BaseKnotSelector)
- assert issubclass(LightGBMKnotSelector, BaseKnotSelector)
-
-
-def test_basis_to_knots_conversion():
- sel = CARTKnotSelector(degree=3)
- assert sel._basis_to_knots(10) == 10 - 3 - 1
- assert sel._basis_to_knots(2) == 0
-
-
-def test_cart_returns_sorted_knots_in_range(data):
- X, y = data
- sel = CARTKnotSelector(max_basis_functions=12, degree=3)
- knots = sel.get_knot_locations(X, y, task="regression")
-
- assert knots.ndim == 1
- assert np.all(np.diff(knots) > 0) # sorted and unique
- assert knots.min() > X.min()
- assert knots.max() < X.max()
-
-
-def test_cart_respects_max_knots(data):
- X, y = data
- sel = CARTKnotSelector(min_basis_functions=6, max_basis_functions=8, degree=3)
- knots = sel.get_knot_locations(X, y)
- assert len(knots) <= sel.max_knots
-
-
-def test_cart_requires_y(data):
- X, _ = data
- with pytest.raises(ValueError, match="requires y"):
- CARTKnotSelector().get_knot_locations(X, None)
-
-
-def test_cart_reproducible(data):
- X, y = data
- a = CARTKnotSelector().get_knot_locations(X, y)
- b = CARTKnotSelector().get_knot_locations(X, y)
- np.testing.assert_array_equal(a, b)
-
-
-def test_cart_small_sample_quantile_fallback():
- rng = np.random.RandomState(1)
- X = rng.rand(5, 1)
- y = rng.rand(5)
- sel = CARTKnotSelector(min_samples_split=20, min_basis_functions=5, degree=1)
- knots = sel.get_knot_locations(X, y)
- assert len(knots) == sel.min_knots
-
-
-def test_cart_classification_task():
- rng = np.random.RandomState(2)
- X = rng.rand(200, 1)
- y = (X[:, 0] > 0.5).astype(int)
- knots = CARTKnotSelector().get_knot_locations(X, y, task="classification")
- assert knots.ndim == 1
-
-
-def test_cart_handles_nan_rows(data):
- X, y = data
- X_missing = X.copy()
- X_missing[:5, 0] = np.nan
- knots = CARTKnotSelector(max_basis_functions=12).get_knot_locations(X_missing, y)
- assert np.isfinite(knots).all()
-
-
-def test_lightgbm_selector_runs(data):
- pytest.importorskip("lightgbm")
- X, y = data
- sel = LightGBMKnotSelector(n_estimators=30, max_basis_functions=12)
- knots = sel.get_knot_locations(X, y, task="regression")
-
- assert knots.ndim == 1
- assert np.all(np.diff(knots) > 0)
- assert knots.min() > X.min()
- assert knots.max() < X.max()
-
-
-def test_lightgbm_selector_reproducible(data):
- pytest.importorskip("lightgbm")
- X, y = data
- a = LightGBMKnotSelector(n_estimators=30).get_knot_locations(X, y)
- b = LightGBMKnotSelector(n_estimators=30).get_knot_locations(X, y)
- np.testing.assert_array_equal(a, b)
diff --git a/tests/test_temporal.py b/tests/test_temporal.py
deleted file mode 100644
index 5a136c7..0000000
--- a/tests/test_temporal.py
+++ /dev/null
@@ -1,119 +0,0 @@
-"""Contract tests for the standalone temporal transformers.
-
-The temporal transformers are documented as standalone time-series utilities that
-are deliberately *not* wired into the ``Preprocessor`` pipeline:
-
-* ``LagFeatureTransformer`` and ``RollingStatsTransformer`` intentionally change
- the row count (they drop the initial, incomplete windows) and assume the rows
- are ordered in time, so they cannot live inside the ``ColumnTransformer`` the
- preprocessor builds.
-* ``CyclicalTimeTransformer`` preserves the row count but requires a per-feature
- ``period`` argument and constrains its inputs, so it is also applied directly.
-
-These tests pin that intended behaviour: the exact output shapes/values, the
-row-count semantics, the generated feature names, and the input-range guard.
-Error paths (insufficient samples, unsupported stat) are covered in
-``tests/test_exceptions.py``.
-"""
-
-import numpy as np
-import pytest
-
-from pretab.core.exceptions import PretabDataError
-from pretab.transformers import (
- CyclicalTimeTransformer,
- LagFeatureTransformer,
- RollingStatsTransformer,
-)
-
-# --------------------------------------------------------------------------- #
-# LagFeatureTransformer
-# --------------------------------------------------------------------------- #
-
-
-def test_lag_reduces_rows_and_pins_values():
- X = np.arange(6).reshape(-1, 1)
- out = LagFeatureTransformer(n_lags=2).fit_transform(X)
- # n_samples - n_lags rows; columns are (lag-1, lag-2).
- assert out.shape == (4, 2)
- np.testing.assert_array_equal(out, [[1, 0], [2, 1], [3, 2], [4, 3]])
-
-
-def test_lag_default_single_lag():
- X = np.arange(5).reshape(-1, 1)
- out = LagFeatureTransformer().fit_transform(X)
- assert out.shape == (4, 1)
- np.testing.assert_array_equal(out.ravel(), [0, 1, 2, 3])
-
-
-def test_lag_feature_names():
- X = np.arange(6).reshape(-1, 1)
- transformer = LagFeatureTransformer(n_lags=2).fit(X)
- np.testing.assert_array_equal(
- transformer.get_feature_names_out(["t"]), ["t_lag0", "t_lag1"]
- )
-
-
-# --------------------------------------------------------------------------- #
-# RollingStatsTransformer
-# --------------------------------------------------------------------------- #
-
-
-def test_rolling_reduces_rows_and_pins_mean():
- X = np.arange(10).reshape(-1, 1).astype(float)
- out = RollingStatsTransformer(window_size=3, stats=("mean",)).fit_transform(X)
- # n_samples - window_size + 1 rows.
- assert out.shape == (8, 1)
- np.testing.assert_allclose(out.ravel(), np.arange(1, 9, dtype=float))
-
-
-def test_rolling_min_max_columns():
- X = np.arange(10).reshape(-1, 1).astype(float)
- out = RollingStatsTransformer(window_size=3, stats=("min", "max")).fit_transform(X)
- assert out.shape == (8, 2)
- np.testing.assert_allclose(out[:, 0], np.arange(0, 8, dtype=float)) # min
- np.testing.assert_allclose(out[:, 1], np.arange(2, 10, dtype=float)) # max
-
-
-def test_rolling_feature_names():
- X = np.arange(10).reshape(-1, 1).astype(float)
- transformer = RollingStatsTransformer(window_size=3, stats=("mean", "std")).fit(X)
- np.testing.assert_array_equal(
- transformer.get_feature_names_out(["t"]), ["t_roll0", "t_roll1"]
- )
-
-
-# --------------------------------------------------------------------------- #
-# CyclicalTimeTransformer
-# --------------------------------------------------------------------------- #
-
-
-def test_cyclic_preserves_rows_and_pins_values():
- X = np.array([[0], [6], [12], [18]])
- out = CyclicalTimeTransformer(period=24).fit_transform(X)
- # Row count preserved; columns are (sin, cos).
- assert out.shape == (4, 2)
- expected_angle = 2 * np.pi * X.ravel() / 24
- np.testing.assert_allclose(out[:, 0], np.sin(expected_angle), atol=1e-12)
- np.testing.assert_allclose(out[:, 1], np.cos(expected_angle), atol=1e-12)
-
-
-def test_cyclic_rejects_out_of_range_input():
- transformer = CyclicalTimeTransformer(period=24)
- with pytest.raises(PretabDataError):
- transformer.fit(np.array([[25]]))
- with pytest.raises(PretabDataError):
- transformer.fit(np.array([[-1]]))
-
-
-def test_cyclic_requires_period():
- with pytest.raises(TypeError):
- CyclicalTimeTransformer() # type: ignore[call-arg]
-
-
-def test_cyclic_feature_names():
- X = np.array([[0], [6], [12], [18]])
- transformer = CyclicalTimeTransformer(period=24).fit(X)
- np.testing.assert_array_equal(
- transformer.get_feature_names_out(["hour"]), ["hour_cyclic0", "hour_cyclic1"]
- )
diff --git a/tests/test_thinplate_transformer.py b/tests/test_thinplate_transformer.py
deleted file mode 100644
index 6875635..0000000
--- a/tests/test_thinplate_transformer.py
+++ /dev/null
@@ -1,80 +0,0 @@
-import numpy as np
-import pytest
-from sklearn.exceptions import NotFittedError
-
-from pretab.transformers import ThinPlateSplineTransformer
-
-
-def test_tprs_output_shape_and_values():
- X = np.linspace(0, 1, 30).reshape(-1, 1)
- transformer = ThinPlateSplineTransformer(output_dim=6)
- Xt = transformer.fit_transform(X)
-
- assert Xt.shape == (30, 6)
- assert transformer.total_output_dim_ == 6
- assert np.isfinite(Xt).all()
-
-
-def test_tprs_output_consistency():
- X = np.random.rand(20, 1)
- transformer = ThinPlateSplineTransformer(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_tprs_penalty_shape_and_symmetry():
- X = np.random.rand(25, 1)
- transformer = ThinPlateSplineTransformer(output_dim=7)
- transformer.fit(X)
- P = transformer.get_penalty_matrix()
-
- assert P.shape[0] == P.shape[1]
- assert np.allclose(P, P.T, atol=1e-6)
-
-
-def test_tprs_multivariate_error():
- X = np.random.rand(10, 2)
- transformer = ThinPlateSplineTransformer(output_dim=4)
- with pytest.raises(ValueError, match="univariate"):
- transformer.fit(X)
-
- transformer = ThinPlateSplineTransformer(output_dim=4)
- transformer.fit(np.random.rand(10, 1))
- with pytest.raises(ValueError, match="is expecting 1 features"):
- transformer.transform(X)
-
-
-def test_tprs_feature_names_out():
- X = np.random.rand(20, 1)
- transformer = ThinPlateSplineTransformer(output_dim=6)
- Xt = transformer.fit_transform(X)
-
- names = transformer.get_feature_names_out(["a"])
- assert len(names) == Xt.shape[1]
- assert names[0] == "a_tps0"
- assert all(name.startswith("a_tps") for name in names)
-
-
-def test_tprs_feature_names_out_default_input():
- X = np.random.rand(15, 1)
- transformer = ThinPlateSplineTransformer(output_dim=5).fit(X)
-
- names = transformer.get_feature_names_out()
- assert len(names) == transformer.n_basis_[0]
- assert names[0].startswith("x0_tps")
-
-
-def test_tprs_allow_nan_tag():
- tags = ThinPlateSplineTransformer().__sklearn_tags__()
- assert tags.input_tags.allow_nan is True
-
-
-def test_tprs_transform_requires_fit():
- transformer = ThinPlateSplineTransformer()
- with pytest.raises(NotFittedError):
- transformer.transform(np.random.rand(5, 1))
- with pytest.raises(NotFittedError):
- transformer.get_penalty_matrix()
diff --git a/tests/test_cubic_transformer.py b/tests/transformers/test_cubic_transformer.py
similarity index 77%
rename from tests/test_cubic_transformer.py
rename to tests/transformers/test_cubic_transformer.py
index 610842e..a0f2eeb 100644
--- a/tests/test_cubic_transformer.py
+++ b/tests/transformers/test_cubic_transformer.py
@@ -2,12 +2,12 @@
import pytest
from sklearn.exceptions import NotFittedError
-from pretab.transformers import CubicSplineTransformer
+from pretab.transformers import CubicRegressionSplineTransformer
def test_cubic_spline_single_feature_shape():
X = np.linspace(0, 1, 20).reshape(-1, 1)
- transformer = CubicSplineTransformer(output_dim=8)
+ transformer = CubicRegressionSplineTransformer(output_dim=8)
Xt = transformer.fit_transform(X)
# output_dim non-bias columns (m = 3 + K interior knots) per feature
@@ -19,7 +19,7 @@ def test_cubic_spline_single_feature_shape():
def test_cubic_spline_multi_feature_shape():
X = np.random.rand(15, 3)
- transformer = CubicSplineTransformer(output_dim=9, include_bias=True)
+ transformer = CubicRegressionSplineTransformer(output_dim=9, include_bias=True)
Xt = transformer.fit_transform(X)
expected_dim = (1 + 9) * 3 # bias + output_dim columns, per feature
@@ -30,7 +30,7 @@ def test_cubic_spline_multi_feature_shape():
def test_cubic_spline_output_consistency():
X = np.random.rand(10, 2)
- transformer = CubicSplineTransformer(output_dim=7)
+ transformer = CubicRegressionSplineTransformer(output_dim=7)
transformer.fit(X)
Xt1 = transformer.transform(X)
Xt2 = transformer.fit_transform(X)
@@ -41,7 +41,7 @@ def test_cubic_spline_output_consistency():
def test_cubic_spline_penalty_matrix_shape():
X = np.linspace(0, 1, 30).reshape(-1, 1)
- transformer = CubicSplineTransformer(output_dim=10)
+ transformer = CubicRegressionSplineTransformer(output_dim=10)
transformer.fit(X)
P = transformer.get_penalty_matrix()
@@ -51,7 +51,7 @@ def test_cubic_spline_penalty_matrix_shape():
def test_cubic_feature_names_out():
X = np.random.rand(20, 2)
- transformer = CubicSplineTransformer(output_dim=8)
+ transformer = CubicRegressionSplineTransformer(output_dim=8)
Xt = transformer.fit_transform(X)
names = transformer.get_feature_names_out(["a", "b"])
@@ -62,7 +62,7 @@ def test_cubic_feature_names_out():
def test_cubic_feature_names_out_default_input():
X = np.random.rand(15, 2)
- transformer = CubicSplineTransformer(output_dim=7).fit(X)
+ transformer = CubicRegressionSplineTransformer(output_dim=7).fit(X)
names = transformer.get_feature_names_out()
assert len(names) == sum(transformer.n_basis_)
@@ -70,12 +70,12 @@ def test_cubic_feature_names_out_default_input():
def test_cubic_allow_nan_tag():
- tags = CubicSplineTransformer().__sklearn_tags__()
+ tags = CubicRegressionSplineTransformer().__sklearn_tags__()
assert tags.input_tags.allow_nan is True
def test_cubic_transform_requires_fit():
- transformer = CubicSplineTransformer()
+ transformer = CubicRegressionSplineTransformer()
with pytest.raises(NotFittedError):
transformer.transform(np.random.rand(5, 1))
with pytest.raises(NotFittedError):
diff --git a/tests/transformers/test_custombin_transformer.py b/tests/transformers/test_custombin_transformer.py
new file mode 100644
index 0000000..df365f3
--- /dev/null
+++ b/tests/transformers/test_custombin_transformer.py
@@ -0,0 +1,176 @@
+import numpy as np
+import pandas as pd
+import pytest
+from sklearn.base import BaseEstimator, TransformerMixin
+
+from pretab.exceptions import InsufficientSamplesError, InvalidParamError, PretabDataError
+from pretab.transformers import NumericBinningTransformer
+
+
+@pytest.mark.parametrize("bins", [2, [0.0, 0.5, 1.0]])
+def test_custom_bin_transformer_basic_functionality(bins):
+ X = np.array([[0.1], [0.4], [0.6], [0.8], [0.95]])
+ transformer = NumericBinningTransformer(output_dim=bins)
+ transformer.fit(X)
+
+ # Ensure fitted attributes exist
+ assert hasattr(transformer, "n_features_in_")
+ assert transformer.n_features_in_ == 1
+ assert transformer.total_output_dim_ == 1
+ assert len(transformer.bin_edges_) == 1
+
+ # Transform
+ Xt = transformer.transform(X)
+ assert isinstance(Xt, np.ndarray)
+ assert Xt.shape == (X.shape[0], 1)
+ assert Xt.dtype.kind in {"i", "u"} # integer bins
+
+ # Check values are within bin range
+ assert Xt.min() >= 0
+ if isinstance(bins, int):
+ assert Xt.max() < bins
+ else:
+ assert Xt.max() < len(bins) - 1
+
+
+@pytest.mark.parametrize("bins", [2, [0.0, 0.5, 1.0]])
+@pytest.mark.parametrize("input_type", ["list", "np", "df"])
+def test_custom_bin_transformer_input_types(bins, input_type):
+ raw = [[0.1], [0.4], [0.6], [0.8]]
+ X = (
+ np.array(raw) # Always convert to array to be safe
+ if input_type == "list"
+ else np.array(raw)
+ if input_type == "np"
+ else pd.DataFrame(raw, columns=pd.Index(["x"]))
+ )
+ transformer = NumericBinningTransformer(output_dim=bins)
+ Xt = transformer.fit_transform(X)
+
+ assert isinstance(Xt, np.ndarray)
+ assert Xt.shape == (4, 1)
+
+
+def test_custom_bin_transformer_is_stateful():
+ """Edges are learned at fit time and reused on shifted transform data."""
+ X_train = np.linspace(0.0, 1.0, 20).reshape(-1, 1)
+ transformer = NumericBinningTransformer(output_dim=4).fit(X_train)
+ learned = transformer.bin_edges_[0].copy()
+
+ # Values outside the fitted range are clamped into the outer bins, and the
+ # learned edges do not change when transforming different data.
+ X_test = np.array([[-5.0], [0.25], [0.75], [5.0]])
+ Xt = transformer.transform(X_test)
+ np.testing.assert_array_equal(transformer.bin_edges_[0], learned)
+ assert Xt[0, 0] == 0 # below the fitted minimum -> first bin
+ assert Xt[-1, 0] == transformer.n_bins_[0] - 1 # above the maximum -> last bin
+
+
+def test_custom_bin_transformer_quantile_placement():
+ """Quantile placement puts edges on the empirical distribution."""
+ rng = np.random.default_rng(0)
+ X = rng.exponential(1.0, size=200).reshape(-1, 1)
+ uniform = NumericBinningTransformer(output_dim=4, placement_strategy="uniform").fit(X)
+ quantile = NumericBinningTransformer(output_dim=4, placement_strategy="quantile").fit(X)
+
+ # The two strategies must produce different edges for skewed data.
+ assert not np.allclose(uniform.bin_edges_[0], quantile.bin_edges_[0])
+
+ # Quantile bins are all occupied for a well-spread sample.
+ counts = np.bincount(quantile.transform(X).ravel(), minlength=4)
+ assert counts.min() > 0
+
+
+def test_custom_bin_transformer_onehot_encoding():
+ X = np.linspace(0.0, 1.0, 20).reshape(-1, 1)
+ transformer = NumericBinningTransformer(output_dim=4, encode="onehot").fit(X)
+ assert transformer.total_output_dim_ == 4
+
+ Xt = transformer.transform(X)
+ assert Xt.shape == (20, 4)
+ # Exactly one active bin per row.
+ np.testing.assert_array_equal(Xt.sum(axis=1), np.ones(20))
+ assert set(np.unique(Xt)) <= {0.0, 1.0}
+
+
+def test_custom_bin_transformer_soft_encoding():
+ X = np.linspace(0.0, 1.0, 20).reshape(-1, 1)
+ transformer = NumericBinningTransformer(output_dim=5, encode="soft").fit(X)
+ assert transformer.total_output_dim_ == 5
+
+ Xt = transformer.transform(X)
+ assert Xt.shape == (20, 5)
+ # Weights are non-negative and sum to 1 for every row.
+ assert Xt.min() >= 0.0
+ np.testing.assert_allclose(Xt.sum(axis=1), np.ones(20))
+
+
+def test_custom_bin_transformer_multifeature():
+ X = np.column_stack([np.linspace(0.0, 1.0, 30), np.linspace(-5.0, 5.0, 30)])
+ transformer = NumericBinningTransformer(output_dim=3, encode="onehot").fit(X)
+ assert transformer.n_features_in_ == 2
+ assert transformer.n_bins_ == [3, 3]
+ assert transformer.total_output_dim_ == 6
+ assert transformer.transform(X).shape == (30, 6)
+
+
+def test_custom_bin_transformer_invalid_encode():
+ X = np.linspace(0.0, 1.0, 10).reshape(-1, 1)
+ with pytest.raises(InvalidParamError):
+ NumericBinningTransformer(output_dim=3, encode="bogus").fit(X)
+
+
+def test_custom_bin_transformer_invalid_placement():
+ X = np.linspace(0.0, 1.0, 10).reshape(-1, 1)
+ with pytest.raises(InvalidParamError):
+ NumericBinningTransformer(output_dim=3, placement_strategy="cart").fit(X)
+
+
+def test_custom_bin_transformer_missing_output_dim():
+ X = np.linspace(0.0, 1.0, 10).reshape(-1, 1)
+ with pytest.raises(InvalidParamError):
+ NumericBinningTransformer().fit(X)
+
+
+def test_custom_bin_transformer_invalid_input():
+ transformer = NumericBinningTransformer(output_dim=3)
+ transformer.fit(np.linspace(0.0, 1.0, 10).reshape(-1, 1))
+ with pytest.raises(PretabDataError):
+ transformer.transform("invalid_input")
+
+
+def test_custom_bin_transformer_raises_on_insufficient_samples():
+ transformer = NumericBinningTransformer(output_dim=3)
+ X = np.array([[0.1]]) # Not enough observations to bin.
+
+ with pytest.raises(ValueError, match=r"Input must have more than 2 observations."):
+ transformer.fit(X)
+
+
+def test_custom_bin_transformer_insufficient_samples_via_fit_transform():
+ with pytest.raises(InsufficientSamplesError):
+ NumericBinningTransformer(output_dim="not_valid").fit_transform(np.array([[0.1]]))
+
+
+def test_custom_bin_transformer_feature_names_out_ordinal():
+ transformer = NumericBinningTransformer(output_dim=3)
+ transformer.fit(np.linspace(0.0, 1.0, 10).reshape(-1, 1))
+ names = transformer.get_feature_names_out(["feature1"])
+ assert list(names) == ["feature1"]
+
+
+def test_custom_bin_transformer_feature_names_out_onehot():
+ transformer = NumericBinningTransformer(output_dim=3, encode="onehot")
+ transformer.fit(np.linspace(0.0, 1.0, 10).reshape(-1, 1))
+ names = transformer.get_feature_names_out(["feature1"])
+ assert list(names) == ["feature1_bin0", "feature1_bin1", "feature1_bin2"]
+
+
+def test_custom_bin_transformer_feature_names_out_raises():
+ transformer = NumericBinningTransformer(output_dim=3)
+ with pytest.raises(ValueError):
+ transformer.get_feature_names_out()
+
+
+def test_custom_bin_transformer_is_sklearn_compatible():
+ assert isinstance(NumericBinningTransformer(output_dim=3), (BaseEstimator, TransformerMixin))
diff --git a/tests/test_encoder_feature_counts.py b/tests/transformers/test_encoder_feature_counts.py
similarity index 75%
rename from tests/test_encoder_feature_counts.py
rename to tests/transformers/test_encoder_feature_counts.py
index ecef534..68d472a 100644
--- a/tests/test_encoder_feature_counts.py
+++ b/tests/transformers/test_encoder_feature_counts.py
@@ -1,14 +1,14 @@
"""A2: ``n_features_in_`` must reflect the fitted column count, not a hardcoded 1.
-Covers ``NoTransformer``, ``ToFloatTransformer`` and ``CustomBinTransformer``.
+Covers ``NoTransformer``, ``ToFloatTransformer`` and ``NumericBinningTransformer``.
"""
import numpy as np
import pytest
from pretab.transformers import (
- CustomBinTransformer,
NoTransformer,
+ NumericBinningTransformer,
ToFloatTransformer,
)
@@ -27,10 +27,10 @@ def test_to_float_transformer_records_feature_count(n_cols):
def test_custom_bin_transformer_records_single_feature():
X = np.linspace(0, 1, 10).reshape(-1, 1)
- assert CustomBinTransformer(output_dim=4).fit(X).n_features_in_ == 1
+ assert NumericBinningTransformer(output_dim=4).fit(X).n_features_in_ == 1
def test_custom_bin_transformer_reads_actual_column_count():
# Proves the value is derived from X, not hardcoded to 1.
X = np.zeros((10, 2))
- assert CustomBinTransformer(output_dim=4).fit(X).n_features_in_ == 2
+ assert NumericBinningTransformer(output_dim=4).fit(X).n_features_in_ == 2
diff --git a/tests/test_feature_names_out.py b/tests/transformers/test_feature_names_out.py
similarity index 84%
rename from tests/test_feature_names_out.py
rename to tests/transformers/test_feature_names_out.py
index a6f95ff..73f9ab0 100644
--- a/tests/test_feature_names_out.py
+++ b/tests/transformers/test_feature_names_out.py
@@ -20,9 +20,7 @@ def _num():
@pytest.mark.parametrize("transformer", _num())
def test_numeric_encoders_default_names(transformer):
transformer.fit(np.zeros((5, 2)))
- np.testing.assert_array_equal(
- transformer.get_feature_names_out(), np.asarray(["x0", "x1"], dtype=object)
- )
+ np.testing.assert_array_equal(transformer.get_feature_names_out(), np.asarray(["x0", "x1"], dtype=object))
@pytest.mark.parametrize("transformer", _num())
@@ -37,9 +35,7 @@ def test_numeric_encoders_passthrough_names(transformer):
def test_continuous_ordinal_default_names():
X = np.array([["a", "x"], ["b", "y"], ["a", "x"]], dtype=object)
transformer = ContinuousOrdinalTransformer().fit(X)
- np.testing.assert_array_equal(
- transformer.get_feature_names_out(), np.asarray(["x0", "x1"], dtype=object)
- )
+ np.testing.assert_array_equal(transformer.get_feature_names_out(), np.asarray(["x0", "x1"], dtype=object))
def test_continuous_ordinal_passthrough_names():
diff --git a/tests/transformers/test_fourier_transformer.py b/tests/transformers/test_fourier_transformer.py
new file mode 100644
index 0000000..59d9917
--- /dev/null
+++ b/tests/transformers/test_fourier_transformer.py
@@ -0,0 +1,77 @@
+import numpy as np
+import pytest
+from sklearn.exceptions import NotFittedError
+
+from pretab.exceptions import InvalidParamError
+from pretab.transformers import FourierFeatureTransformer
+
+
+def test_fourier_output_shape_and_total_dim():
+ X = np.linspace(0, 10, 50).reshape(-1, 1)
+ transformer = FourierFeatureTransformer(n_frequencies=4)
+ Xt = transformer.fit_transform(X)
+
+ assert Xt.shape == (50, 8)
+ assert transformer.total_output_dim_ == 8
+ assert np.isfinite(Xt).all()
+
+
+def test_fourier_include_original_prepends_raw_value():
+ X = np.linspace(0, 5, 20).reshape(-1, 1)
+ transformer = FourierFeatureTransformer(n_frequencies=3, include_original=True)
+ Xt = transformer.fit_transform(X)
+
+ assert Xt.shape == (20, 7)
+ np.testing.assert_allclose(Xt[:, 0], X[:, 0])
+
+
+def test_fourier_multifeature_is_per_feature_contiguous():
+ rng = np.random.RandomState(0)
+ X = rng.uniform(-2, 2, size=(40, 2))
+ transformer = FourierFeatureTransformer(n_frequencies=2)
+ Xt = transformer.fit_transform(X)
+
+ assert Xt.shape == (40, 8)
+ assert transformer.n_features_in_ == 2
+
+
+@pytest.mark.parametrize("strategy", ["harmonic", "log_spaced", "random"])
+def test_fourier_strategies_are_deterministic(strategy):
+ X = np.linspace(0, 4, 30).reshape(-1, 1)
+ transformer = FourierFeatureTransformer(n_frequencies=3, frequency_strategy=strategy, random_state=0)
+ Xt1 = transformer.fit(X).transform(X)
+ Xt2 = FourierFeatureTransformer(n_frequencies=3, frequency_strategy=strategy, random_state=0).fit_transform(X)
+
+ np.testing.assert_allclose(Xt1, Xt2)
+
+
+def test_fourier_rejects_invalid_params():
+ X = np.linspace(0, 1, 20).reshape(-1, 1)
+ with pytest.raises(InvalidParamError, match="n_frequencies"):
+ FourierFeatureTransformer(n_frequencies=0).fit(X)
+ with pytest.raises(InvalidParamError, match="frequency_strategy"):
+ FourierFeatureTransformer(frequency_strategy="bogus").fit(X)
+
+
+def test_fourier_rejects_nan():
+ X = np.full((10, 1), np.nan)
+ with pytest.raises(ValueError, match="NaN"):
+ FourierFeatureTransformer().fit(X)
+
+
+def test_fourier_feature_names_out():
+ X = np.linspace(0, 1, 20).reshape(-1, 1)
+ transformer = FourierFeatureTransformer(n_frequencies=2).fit(X)
+
+ names = transformer.get_feature_names_out(["a"])
+ assert list(names) == ["a_fourier0", "a_fourier1", "a_fourier2", "a_fourier3"]
+
+
+def test_fourier_transform_requires_fit():
+ with pytest.raises(NotFittedError):
+ FourierFeatureTransformer().transform(np.linspace(0, 1, 5).reshape(-1, 1))
+
+
+def test_fourier_allow_nan_tag_is_false():
+ tags = FourierFeatureTransformer().__sklearn_tags__()
+ assert tags.input_tags.allow_nan is False
diff --git a/tests/transformers/test_kernel_approx_transformer.py b/tests/transformers/test_kernel_approx_transformer.py
new file mode 100644
index 0000000..cd36929
--- /dev/null
+++ b/tests/transformers/test_kernel_approx_transformer.py
@@ -0,0 +1,101 @@
+import numpy as np
+import pytest
+from sklearn.exceptions import NotFittedError
+
+from pretab.exceptions import InvalidParamError
+from pretab.transformers import (
+ NystroemFeaturesTransformer,
+ RandomFourierFeaturesTransformer,
+)
+
+
+@pytest.fixture
+def X():
+ return np.random.default_rng(0).uniform(size=(60, 3))
+
+
+# --------------------------------------------------------------------------- #
+# Random Fourier features (RBFSampler wrapper).
+# --------------------------------------------------------------------------- #
+def test_rff_output_shape_and_total_dim(X):
+ transformer = RandomFourierFeaturesTransformer(n_components=20, random_state=0)
+ Xt = transformer.fit_transform(X)
+
+ assert Xt.shape == (60, 20)
+ assert transformer.total_output_dim_ == 20
+ assert transformer.n_features_in_ == 3
+ assert np.isfinite(Xt).all()
+
+
+def test_rff_is_deterministic_with_random_state(X):
+ transformer = RandomFourierFeaturesTransformer(n_components=16, random_state=0)
+ Xt1 = transformer.fit(X).transform(X)
+ Xt2 = RandomFourierFeaturesTransformer(n_components=16, random_state=0).fit_transform(X)
+
+ np.testing.assert_allclose(Xt1, Xt2)
+
+
+def test_rff_rejects_invalid_n_components(X):
+ with pytest.raises(InvalidParamError, match="n_components"):
+ RandomFourierFeaturesTransformer(n_components=0).fit(X)
+
+
+def test_rff_feature_names_out(X):
+ transformer = RandomFourierFeaturesTransformer(n_components=5, random_state=0).fit(X)
+ names = transformer.get_feature_names_out()
+
+ assert len(names) == 5
+ assert names[0].startswith("x0_rff")
+
+
+def test_rff_transform_requires_fit(X):
+ with pytest.raises(NotFittedError):
+ RandomFourierFeaturesTransformer().transform(X)
+
+
+# --------------------------------------------------------------------------- #
+# Nystroem features (Nystroem wrapper).
+# --------------------------------------------------------------------------- #
+def test_nystroem_output_shape_and_total_dim(X):
+ transformer = NystroemFeaturesTransformer(n_components=15, random_state=0)
+ Xt = transformer.fit_transform(X)
+
+ assert Xt.shape == (60, 15)
+ assert transformer.total_output_dim_ == 15
+ assert np.isfinite(Xt).all()
+
+
+def test_nystroem_is_deterministic_with_random_state(X):
+ transformer = NystroemFeaturesTransformer(n_components=12, random_state=0)
+ Xt1 = transformer.fit(X).transform(X)
+ Xt2 = NystroemFeaturesTransformer(n_components=12, random_state=0).fit_transform(X)
+
+ np.testing.assert_allclose(Xt1, Xt2)
+
+
+def test_nystroem_supports_non_default_kernel(X):
+ transformer = NystroemFeaturesTransformer(n_components=10, kernel="laplacian", random_state=0)
+ Xt = transformer.fit_transform(X)
+
+ assert Xt.shape == (60, 10)
+ assert np.isfinite(Xt).all()
+
+
+def test_nystroem_rejects_invalid_params(X):
+ with pytest.raises(InvalidParamError, match="n_components"):
+ NystroemFeaturesTransformer(n_components=0).fit(X)
+ with pytest.raises(InvalidParamError, match="kernel"):
+ NystroemFeaturesTransformer(kernel="bogus").fit(X)
+
+
+def test_nystroem_feature_names_out(X):
+ transformer = NystroemFeaturesTransformer(n_components=8, random_state=0).fit(X)
+ names = transformer.get_feature_names_out()
+
+ assert len(names) == 8
+ assert names[0].startswith("x0_nystroem")
+
+
+def test_nystroem_transform_requires_fit(X):
+ with pytest.raises(NotFittedError):
+ NystroemFeaturesTransformer().transform(X)
diff --git a/tests/test_language_embedding_transformer.py b/tests/transformers/test_language_embedding_transformer.py
similarity index 97%
rename from tests/test_language_embedding_transformer.py
rename to tests/transformers/test_language_embedding_transformer.py
index 5f6807c..a623ba1 100644
--- a/tests/test_language_embedding_transformer.py
+++ b/tests/transformers/test_language_embedding_transformer.py
@@ -13,7 +13,7 @@
import pytest
from sklearn.base import clone
-from pretab.core.exceptions import OptionalDependencyError, PretabConfigError
+from pretab.exceptions import OptionalDependencyError, PretabConfigError
from pretab.transformers import LanguageEmbeddingTransformer
diff --git a/tests/test_naturalcubic_transformer.py b/tests/transformers/test_naturalcubic_transformer.py
similarity index 100%
rename from tests/test_naturalcubic_transformer.py
rename to tests/transformers/test_naturalcubic_transformer.py
diff --git a/tests/test_onehot_from_ordinal_transformer.py b/tests/transformers/test_onehot_from_ordinal_transformer.py
similarity index 99%
rename from tests/test_onehot_from_ordinal_transformer.py
rename to tests/transformers/test_onehot_from_ordinal_transformer.py
index 2fe7648..6e85723 100644
--- a/tests/test_onehot_from_ordinal_transformer.py
+++ b/tests/transformers/test_onehot_from_ordinal_transformer.py
@@ -1,5 +1,6 @@
import numpy as np
import pytest
+
from pretab.transformers import OneHotFromOrdinalTransformer
diff --git a/tests/test_output_dimension.py b/tests/transformers/test_output_dimension.py
similarity index 91%
rename from tests/test_output_dimension.py
rename to tests/transformers/test_output_dimension.py
index 8ea692f..e705865 100644
--- a/tests/test_output_dimension.py
+++ b/tests/transformers/test_output_dimension.py
@@ -13,11 +13,11 @@
from pretab.transformers import (
BSplineTransformer,
- CubicSplineTransformer,
- CustomBinTransformer,
+ CubicRegressionSplineTransformer,
ISplineTransformer,
MSplineTransformer,
NaturalCubicSplineTransformer,
+ NumericBinningTransformer,
PLETransformer,
PSplineTransformer,
RBFExpansionTransformer,
@@ -47,7 +47,7 @@ def Xy():
# Splines whose transformed width is exactly ``n_features * output_dim``.
PER_FEATURE_SPLINES = [
- CubicSplineTransformer,
+ CubicRegressionSplineTransformer,
NaturalCubicSplineTransformer,
PSplineTransformer,
MSplineTransformer,
@@ -82,10 +82,10 @@ def test_feature_map_width_is_n_features_times_output_dim(cls, X):
def test_thinplate_width_is_output_dim():
- # Thin-plate regression splines are univariate.
+ # Thin-plate regression splines emit exactly ``n_components`` columns.
rng = np.random.RandomState(0)
X = rng.uniform(-3, 3, size=(120, 1))
- transformer = ThinPlateSplineTransformer(output_dim=OUTPUT_DIM).fit(X)
+ transformer = ThinPlateSplineTransformer(n_components=OUTPUT_DIM, random_state=0).fit(X)
Xt = transformer.transform(X)
assert Xt.shape[1] == OUTPUT_DIM
assert transformer.total_output_dim_ == Xt.shape[1]
@@ -120,7 +120,7 @@ def test_ple_output_dim_is_a_per_feature_cap(Xy):
def test_custombin_is_always_a_single_ordinal_column():
X = np.linspace(0, 1, 50).reshape(-1, 1)
- transformer = CustomBinTransformer(output_dim=OUTPUT_DIM).fit(X)
+ transformer = NumericBinningTransformer(output_dim=OUTPUT_DIM).fit(X)
Xt = transformer.transform(X)
assert Xt.shape[1] == 1
assert transformer.total_output_dim_ == 1
diff --git a/tests/test_param_aliases.py b/tests/transformers/test_param_aliases.py
similarity index 89%
rename from tests/test_param_aliases.py
rename to tests/transformers/test_param_aliases.py
index cfd5ebc..5f270f7 100644
--- a/tests/test_param_aliases.py
+++ b/tests/transformers/test_param_aliases.py
@@ -12,11 +12,11 @@
from pretab.transformers import (
BSplineTransformer,
- CubicSplineTransformer,
- CustomBinTransformer,
+ CubicRegressionSplineTransformer,
ISplineTransformer,
MSplineTransformer,
NaturalCubicSplineTransformer,
+ NumericBinningTransformer,
PLETransformer,
PSplineTransformer,
RBFExpansionTransformer,
@@ -45,8 +45,8 @@ def Xy():
# (transformer, removed constructor name) - passing any legacy count spelling
# must raise TypeError at construction (hard removal, no FutureWarning window).
REMOVED_COUNT_CASES = [
- (CubicSplineTransformer, "n_basis"),
- (CubicSplineTransformer, "n_knots"),
+ (CubicRegressionSplineTransformer, "n_basis"),
+ (CubicRegressionSplineTransformer, "n_knots"),
(NaturalCubicSplineTransformer, "n_basis"),
(NaturalCubicSplineTransformer, "n_knots"),
(PSplineTransformer, "n_basis"),
@@ -60,6 +60,7 @@ def Xy():
(ISplineTransformer, "n_basis"),
(ISplineTransformer, "n_basis_functions"),
(ThinPlateSplineTransformer, "n_basis"),
+ (ThinPlateSplineTransformer, "output_dim"),
(RBFExpansionTransformer, "n_centers"),
(RBFExpansionTransformer, "n_basis"),
(ReLUExpansionTransformer, "n_centers"),
@@ -71,8 +72,8 @@ def Xy():
(PLETransformer, "max_basis"),
(PLETransformer, "min_bins"),
(PLETransformer, "max_bins"),
- (CustomBinTransformer, "n_basis"),
- (CustomBinTransformer, "bins"),
+ (NumericBinningTransformer, "n_basis"),
+ (NumericBinningTransformer, "bins"),
]
@@ -84,14 +85,13 @@ def test_removed_count_name_raises_typeerror(cls, removed):
# Every family accepts the canonical output_dim knob.
OUTPUT_DIM_CLASSES = [
- (CubicSplineTransformer, 8),
+ (CubicRegressionSplineTransformer, 8),
(NaturalCubicSplineTransformer, 6),
(PSplineTransformer, 8),
(TensorProductSplineTransformer, 5),
(BSplineTransformer, 8),
(MSplineTransformer, 8),
(ISplineTransformer, 8),
- (ThinPlateSplineTransformer, 6),
]
diff --git a/tests/transformers/test_periodic.py b/tests/transformers/test_periodic.py
new file mode 100644
index 0000000..70a155e
--- /dev/null
+++ b/tests/transformers/test_periodic.py
@@ -0,0 +1,73 @@
+"""Contract tests for the standalone :class:`PeriodicEncodingTransformer`.
+
+``PeriodicEncodingTransformer`` preserves the row count but requires a per-feature
+``period`` argument and constrains its inputs to ``[0, period]``, so it is applied
+directly rather than wired into the ``Preprocessor`` pipeline.
+
+These tests pin the intended behaviour: the exact output shapes/values, the
+generated feature names, and the input-range guard.
+"""
+
+import numpy as np
+import pytest
+
+from pretab.exceptions import InvalidParamError, PretabDataError
+from pretab.transformers import PeriodicEncodingTransformer
+
+
+def test_cyclic_preserves_rows_and_pins_values():
+ X = np.array([[0], [6], [12], [18]])
+ out = PeriodicEncodingTransformer(period=24).fit_transform(X)
+ # Row count preserved; columns are (sin, cos).
+ assert out.shape == (4, 2)
+ expected_angle = 2 * np.pi * X.ravel() / 24
+ np.testing.assert_allclose(out[:, 0], np.sin(expected_angle), atol=1e-12)
+ np.testing.assert_allclose(out[:, 1], np.cos(expected_angle), atol=1e-12)
+
+
+def test_cyclic_rejects_out_of_range_input():
+ transformer = PeriodicEncodingTransformer(period=24)
+ with pytest.raises(PretabDataError):
+ transformer.fit(np.array([[25]]))
+ with pytest.raises(PretabDataError):
+ transformer.fit(np.array([[-1]]))
+
+
+def test_cyclic_requires_period():
+ with pytest.raises(TypeError):
+ PeriodicEncodingTransformer() # type: ignore[call-arg]
+
+
+def test_cyclic_feature_names():
+ X = np.array([[0], [6], [12], [18]])
+ transformer = PeriodicEncodingTransformer(period=24).fit(X)
+ np.testing.assert_array_equal(transformer.get_feature_names_out(["hour"]), ["hour_cyclic0", "hour_cyclic1"])
+
+
+def test_cyclic_harmonics_expand_columns():
+ X = np.array([[0], [6], [12], [18]])
+ transformer = PeriodicEncodingTransformer(period=24, harmonics=3).fit(X)
+ out = transformer.transform(X)
+ # 3 harmonic pairs -> 6 columns; layout is (sin_h, cos_h) in ascending order.
+ assert out.shape == (4, 6)
+ assert transformer.total_output_dim_ == 6
+ for h in range(1, 4):
+ angle = 2 * np.pi * h * X.ravel() / 24
+ np.testing.assert_allclose(out[:, 2 * (h - 1)], np.sin(angle), atol=1e-12)
+ np.testing.assert_allclose(out[:, 2 * (h - 1) + 1], np.cos(angle), atol=1e-12)
+
+
+def test_cyclic_include_original_prepends_raw_value():
+ X = np.array([[0], [6], [12], [18]])
+ transformer = PeriodicEncodingTransformer(period=24, harmonics=2, include_original=True).fit(X)
+ out = transformer.transform(X)
+ # 1 original + 2 harmonic pairs = 5 columns per feature.
+ assert out.shape == (4, 5)
+ assert transformer.total_output_dim_ == 5
+ np.testing.assert_allclose(out[:, 0], X.ravel(), atol=1e-12)
+
+
+def test_cyclic_rejects_non_positive_harmonics():
+ X = np.array([[0], [6], [12], [18]])
+ with pytest.raises(InvalidParamError):
+ PeriodicEncodingTransformer(period=24, harmonics=0).fit(X)
diff --git a/tests/test_ple_transformer.py b/tests/transformers/test_ple_transformer.py
similarity index 90%
rename from tests/test_ple_transformer.py
rename to tests/transformers/test_ple_transformer.py
index f4e416c..bd2baa6 100644
--- a/tests/test_ple_transformer.py
+++ b/tests/transformers/test_ple_transformer.py
@@ -49,7 +49,7 @@ def test_ple_transformer_multi_feature_shape(X_multi_feature, y_regression):
def test_ple_invalid_task_raises(X_single_feature):
with pytest.raises(ValueError, match="Unsupported task"):
- transformer = PLETransformer(task="unsupported")
+ transformer = PLETransformer(task="unsupported") # type: ignore[arg-type]
transformer.fit(X_single_feature, np.linspace(0, 1, 10))
@@ -104,27 +104,24 @@ def test_ple_is_reproducible():
np.testing.assert_array_equal(a, b)
-def test_ple_handles_nan_with_median():
+def test_ple_raises_on_nan_at_fit():
rng = np.random.RandomState(1)
X = rng.rand(30, 1)
y = rng.rand(30)
- transformer = PLETransformer(output_dim=5, handle_missing="median")
- transformer.fit(X, y)
-
X_missing = X.copy()
X_missing[0, 0] = np.nan
- Xt = transformer.transform(X_missing)
-
- assert np.isfinite(Xt).all()
+ transformer = PLETransformer(output_dim=5)
+ with pytest.raises(ValueError, match="NaN"):
+ transformer.fit(X_missing, y)
-def test_ple_raises_on_nan_when_configured():
+def test_ple_raises_on_nan_at_transform():
rng = np.random.RandomState(2)
X = rng.rand(30, 1)
y = rng.rand(30)
- transformer = PLETransformer(output_dim=5, handle_missing="error")
+ transformer = PLETransformer(output_dim=5)
transformer.fit(X, y)
X_missing = X.copy()
diff --git a/tests/test_pspline_transformer.py b/tests/transformers/test_pspline_transformer.py
similarity index 100%
rename from tests/test_pspline_transformer.py
rename to tests/transformers/test_pspline_transformer.py
diff --git a/tests/test_rbfexpansion_transformer.py b/tests/transformers/test_rbfexpansion_transformer.py
similarity index 88%
rename from tests/test_rbfexpansion_transformer.py
rename to tests/transformers/test_rbfexpansion_transformer.py
index 0b8d16d..2f9028b 100644
--- a/tests/test_rbfexpansion_transformer.py
+++ b/tests/transformers/test_rbfexpansion_transformer.py
@@ -1,6 +1,7 @@
import numpy as np
import pytest
from sklearn.utils.validation import check_is_fitted
+
from pretab.transformers import RBFExpansionTransformer
@@ -20,9 +21,7 @@ def y_regression():
def test_rbf_uniform_single_feature(X_single_feature):
- transformer = RBFExpansionTransformer(
- output_dim=5, target_aware=False, placement_strategy="uniform"
- )
+ transformer = RBFExpansionTransformer(output_dim=5, target_aware=False, placement_strategy="uniform")
transformer.fit(X_single_feature)
Xt = transformer.transform(X_single_feature)
@@ -32,9 +31,7 @@ def test_rbf_uniform_single_feature(X_single_feature):
def test_rbf_quantile_multi_feature(X_multi_feature):
- transformer = RBFExpansionTransformer(
- output_dim=4, target_aware=False, placement_strategy="quantile"
- )
+ transformer = RBFExpansionTransformer(output_dim=4, target_aware=False, placement_strategy="quantile")
transformer.fit(X_multi_feature)
Xt = transformer.transform(X_multi_feature)
@@ -64,7 +61,7 @@ def test_rbf_invalid_task():
def test_rbf_missing_target_with_tree(X_single_feature):
transformer = RBFExpansionTransformer(target_aware=True)
- with pytest.raises(ValueError, match="Target variable.*must be provided"):
+ with pytest.raises(ValueError, match=r"Target variable.*must be provided"):
transformer.fit(X_single_feature)
diff --git a/tests/test_reluexpansion_transformer.py b/tests/transformers/test_reluexpansion_transformer.py
similarity index 85%
rename from tests/test_reluexpansion_transformer.py
rename to tests/transformers/test_reluexpansion_transformer.py
index 20ba1c6..8004c5f 100644
--- a/tests/test_reluexpansion_transformer.py
+++ b/tests/transformers/test_reluexpansion_transformer.py
@@ -1,7 +1,9 @@
+import warnings
+
import numpy as np
import pytest
-import warnings
from sklearn.utils.validation import check_is_fitted
+
from pretab.transformers import ReLUExpansionTransformer
@@ -21,9 +23,7 @@ def y_regression():
def test_relu_uniform_single_feature(X_single_feature):
- transformer = ReLUExpansionTransformer(
- output_dim=4, target_aware=False, placement_strategy="uniform"
- )
+ transformer = ReLUExpansionTransformer(output_dim=4, target_aware=False, placement_strategy="uniform")
transformer.fit(X_single_feature)
Xt = transformer.transform(X_single_feature)
@@ -32,9 +32,7 @@ def test_relu_uniform_single_feature(X_single_feature):
def test_relu_quantile_multi_feature(X_multi_feature):
- transformer = ReLUExpansionTransformer(
- output_dim=5, target_aware=False, placement_strategy="quantile"
- )
+ transformer = ReLUExpansionTransformer(output_dim=5, target_aware=False, placement_strategy="quantile")
transformer.fit(X_multi_feature)
Xt = transformer.transform(X_multi_feature)
@@ -63,7 +61,7 @@ def test_relu_invalid_task():
def test_relu_missing_y_tree(X_single_feature):
transformer = ReLUExpansionTransformer(target_aware=True)
- with pytest.raises(ValueError, match="Target variable.*must be provided"):
+ with pytest.raises(ValueError, match=r"Target variable.*must be provided"):
transformer.fit(X_single_feature)
diff --git a/tests/test_sigmoidexpansion_transformer.py b/tests/transformers/test_sigmoidexpansion_transformer.py
similarity index 84%
rename from tests/test_sigmoidexpansion_transformer.py
rename to tests/transformers/test_sigmoidexpansion_transformer.py
index e162e3d..9c21275 100644
--- a/tests/test_sigmoidexpansion_transformer.py
+++ b/tests/transformers/test_sigmoidexpansion_transformer.py
@@ -1,7 +1,9 @@
+import warnings
+
import numpy as np
import pytest
-import warnings
from sklearn.utils.validation import check_is_fitted
+
from pretab.transformers import SigmoidExpansionTransformer
@@ -21,9 +23,7 @@ def y_regression():
def test_sigmoid_uniform_single_feature(X_single_feature):
- transformer = SigmoidExpansionTransformer(
- output_dim=4, target_aware=False, placement_strategy="uniform", scale=0.5
- )
+ transformer = SigmoidExpansionTransformer(output_dim=4, target_aware=False, placement_strategy="uniform", scale=0.5)
transformer.fit(X_single_feature)
Xt = transformer.transform(X_single_feature)
@@ -33,9 +33,7 @@ def test_sigmoid_uniform_single_feature(X_single_feature):
def test_sigmoid_quantile_multi_feature(X_multi_feature):
- transformer = SigmoidExpansionTransformer(
- output_dim=5, target_aware=False, placement_strategy="quantile"
- )
+ transformer = SigmoidExpansionTransformer(output_dim=5, target_aware=False, placement_strategy="quantile")
transformer.fit(X_multi_feature)
Xt = transformer.transform(X_multi_feature)
@@ -66,7 +64,7 @@ def test_sigmoid_invalid_task():
def test_sigmoid_missing_y_tree(X_single_feature):
transformer = SigmoidExpansionTransformer(target_aware=True)
- with pytest.raises(ValueError, match="Target variable.*must be provided"):
+ with pytest.raises(ValueError, match=r"Target variable.*must be provided"):
transformer.fit(X_single_feature)
@@ -79,9 +77,7 @@ def test_sigmoid_feature_mismatch(X_multi_feature, y_regression):
def test_sigmoid_no_overflow_on_large_inputs():
# Large-magnitude values used to trigger "overflow encountered in exp".
- transformer = SigmoidExpansionTransformer(
- output_dim=4, target_aware=False, placement_strategy="uniform"
- )
+ transformer = SigmoidExpansionTransformer(output_dim=4, target_aware=False, placement_strategy="uniform")
transformer.fit(np.linspace(-1, 1, 10).reshape(-1, 1))
X_extreme = np.array([[-1000.0], [1000.0]])
with warnings.catch_warnings():
@@ -90,4 +86,3 @@ def test_sigmoid_no_overflow_on_large_inputs():
assert np.isfinite(Xt).all()
assert (Xt >= 0).all()
assert (Xt <= 1).all()
-
diff --git a/tests/test_sklearn_compat.py b/tests/transformers/test_sklearn_compat.py
similarity index 78%
rename from tests/test_sklearn_compat.py
rename to tests/transformers/test_sklearn_compat.py
index f142da7..cd18ae1 100644
--- a/tests/test_sklearn_compat.py
+++ b/tests/transformers/test_sklearn_compat.py
@@ -23,20 +23,18 @@
from pretab.transformers import (
BSplineTransformer,
ContinuousOrdinalTransformer,
- CubicSplineTransformer,
- CustomBinTransformer,
- CyclicalTimeTransformer,
+ CubicRegressionSplineTransformer,
ISplineTransformer,
- LagFeatureTransformer,
MSplineTransformer,
NaturalCubicSplineTransformer,
NoTransformer,
+ NumericBinningTransformer,
OneHotFromOrdinalTransformer,
+ PeriodicEncodingTransformer,
PLETransformer,
PSplineTransformer,
RBFExpansionTransformer,
ReLUExpansionTransformer,
- RollingStatsTransformer,
SigmoidExpansionTransformer,
TanhExpansionTransformer,
TensorProductSplineTransformer,
@@ -52,14 +50,8 @@
"check_parameters_default_constructible rejects. Closing this needs an alias "
"redesign that drops sentinel defaults."
)
-REQUIRES_Y_NONE = (
- "Supervised transformer does not yet raise a clear message when y=None is "
- "passed to fit."
-)
-DTYPE = (
- "Numeric encoder casts to float output and does not accept/preserve object "
- "dtype input."
-)
+REQUIRES_Y_NONE = "Supervised transformer does not yet raise a clear message when y=None is passed to fit."
+DTYPE = "Numeric encoder casts to float output and does not accept/preserve object dtype input."
# --- near-conformant tier: (estimator, expected_failed_checks) ------------- #
@@ -69,7 +61,7 @@
(BSplineTransformer(), _SPLINE_EXPECTED),
(MSplineTransformer(), _SPLINE_EXPECTED),
(ISplineTransformer(), _SPLINE_EXPECTED),
- (CubicSplineTransformer(), _SPLINE_EXPECTED),
+ (CubicRegressionSplineTransformer(), _SPLINE_EXPECTED),
(NaturalCubicSplineTransformer(), _SPLINE_EXPECTED),
(PSplineTransformer(), _SPLINE_EXPECTED),
(TensorProductSplineTransformer(), _SPLINE_EXPECTED),
@@ -131,47 +123,31 @@ def test_check_estimator_near_conformant(estimator, expected_failed_checks):
ThinPlateSplineTransformer(),
id="ThinPlateSplineTransformer",
marks=pytest.mark.xfail(
- reason="Univariate-only (single input feature); incompatible with the "
- "multi-feature transformer checks.",
+ reason="Landmark low-rank basis needs at least n_components + d + 1 "
+ "samples; the generic small-sample estimator checks fall below that "
+ "threshold and fail at fit.",
strict=True,
),
),
pytest.param(
- CustomBinTransformer(),
- id="CustomBinTransformer",
+ NumericBinningTransformer(),
+ id="NumericBinningTransformer",
marks=pytest.mark.xfail(
- reason="Single-column ordinal binner; expects (n_samples, 1) input, "
- "incompatible with generic multi-feature checks.",
+ reason="Requires an explicit `output_dim` bin count (not default-"
+ "constructible into a fittable state), so the generic estimator "
+ "checks fail at fit.",
strict=True,
),
),
pytest.param(
- CyclicalTimeTransformer(period=12),
- id="CyclicalTimeTransformer",
+ PeriodicEncodingTransformer(period=12),
+ id="PeriodicEncodingTransformer",
marks=pytest.mark.xfail(
reason="Requires a `period` constructor argument (not default-"
"constructible) and constrains inputs to [0, period].",
strict=True,
),
),
- pytest.param(
- LagFeatureTransformer(),
- id="LagFeatureTransformer",
- marks=pytest.mark.xfail(
- reason="Windowing transformer changes the sample count, so it fails "
- "checks that assume transform preserves n_samples.",
- strict=True,
- ),
- ),
- pytest.param(
- RollingStatsTransformer(),
- id="RollingStatsTransformer",
- marks=pytest.mark.xfail(
- reason="Windowing transformer changes the sample count, so it fails "
- "checks that assume transform preserves n_samples.",
- strict=True,
- ),
- ),
pytest.param(
ContinuousOrdinalTransformer(),
id="ContinuousOrdinalTransformer",
@@ -203,8 +179,7 @@ def test_check_estimator_near_conformant(estimator, expected_failed_checks):
OneHotFromOrdinalTransformer(),
id="OneHotFromOrdinalTransformer",
marks=pytest.mark.xfail(
- reason="Categorical one-hot encoder; expects integer-coded input and "
- "does not use numeric validate_data.",
+ reason="Categorical one-hot encoder; expects integer-coded input and does not use numeric validate_data.",
strict=True,
),
),
diff --git a/tests/test_spline_api_parity.py b/tests/transformers/test_spline_api_parity.py
similarity index 82%
rename from tests/test_spline_api_parity.py
rename to tests/transformers/test_spline_api_parity.py
index 7998d72..86f8e75 100644
--- a/tests/test_spline_api_parity.py
+++ b/tests/transformers/test_spline_api_parity.py
@@ -7,9 +7,9 @@
import numpy as np
import pytest
-from pretab.core.exceptions import IncompatibleParamsError
+from pretab.exceptions import IncompatibleParamsError
from pretab.transformers import (
- CubicSplineTransformer,
+ CubicRegressionSplineTransformer,
NaturalCubicSplineTransformer,
PSplineTransformer,
TensorProductSplineTransformer,
@@ -18,16 +18,24 @@
# (class, output_dim) for the four knot-based splines that share the placement API.
KNOT_SPLINES = [
- (CubicSplineTransformer, 8),
+ (CubicRegressionSplineTransformer, 8),
(NaturalCubicSplineTransformer, 6),
(PSplineTransformer, 8),
(TensorProductSplineTransformer, 5),
]
+# Splines that also accept quantile placement. P-splines are uniform-only
+# (equally-spaced knots for the difference penalty), so they are excluded here.
+QUANTILE_SPLINES = [
+ (CubicRegressionSplineTransformer, 8),
+ (NaturalCubicSplineTransformer, 6),
+ (TensorProductSplineTransformer, 5),
+]
+
# The knot-based splines that also support the target-aware placement path.
# (The penalized ``pspline`` / ``tensorspline`` are unsupervised-only.)
TARGET_AWARE_SPLINES = [
- (CubicSplineTransformer, 8),
+ (CubicRegressionSplineTransformer, 8),
(NaturalCubicSplineTransformer, 6),
]
@@ -59,7 +67,7 @@ def test_default_strategy_matches_explicit_uniform(cls, output_dim, X_uniform):
np.testing.assert_allclose(default, explicit, rtol=1e-10)
-@pytest.mark.parametrize(("cls", "output_dim"), KNOT_SPLINES)
+@pytest.mark.parametrize(("cls", "output_dim"), QUANTILE_SPLINES)
def test_quantile_strategy_runs_and_differs(cls, output_dim, X_skewed):
"""placement_strategy='quantile' produces a finite basis of the same width as uniform."""
uniform = cls(output_dim=output_dim, placement_strategy="uniform").fit_transform(X_skewed)
@@ -102,8 +110,8 @@ def test_tensor_include_bias_widens_interaction(X_uniform):
def test_thinplate_include_bias_adds_one_column():
X = np.linspace(0, 1, 40).reshape(-1, 1)
- no_bias = ThinPlateSplineTransformer(output_dim=6).fit_transform(X)
- with_bias = ThinPlateSplineTransformer(output_dim=6, include_bias=True).fit_transform(X)
+ no_bias = ThinPlateSplineTransformer(n_components=6, random_state=0).fit_transform(X)
+ with_bias = ThinPlateSplineTransformer(n_components=6, include_bias=True, random_state=0).fit_transform(X)
assert with_bias.shape[1] == no_bias.shape[1] + 1
assert np.allclose(with_bias[:, 0], 1.0)
@@ -111,11 +119,11 @@ def test_thinplate_include_bias_adds_one_column():
@pytest.mark.parametrize(
("cls", "expected"),
[
- (CubicSplineTransformer, {"target_aware", "placement_strategy", "task", "include_bias"}),
+ (CubicRegressionSplineTransformer, {"target_aware", "placement_strategy", "task", "include_bias"}),
(NaturalCubicSplineTransformer, {"degree", "target_aware", "placement_strategy", "task"}),
(PSplineTransformer, {"placement_strategy", "include_bias"}),
(TensorProductSplineTransformer, {"placement_strategy", "include_bias"}),
- (ThinPlateSplineTransformer, {"include_bias"}),
+ (ThinPlateSplineTransformer, {"n_components", "landmark_strategy", "rank_strategy", "include_bias"}),
],
)
def test_new_params_exposed_in_get_params(cls, expected):
@@ -133,7 +141,7 @@ def test_tensor_penalty_matrix_signature_parity():
def test_thinplate_penalty_matrix_accepts_feature_index():
X = np.linspace(0, 1, 40).reshape(-1, 1)
- transformer = ThinPlateSplineTransformer(output_dim=6, include_bias=True).fit(X)
+ transformer = ThinPlateSplineTransformer(n_components=6, include_bias=True, random_state=0).fit(X)
P = transformer.get_penalty_matrix(feature_index=0)
assert P.shape == (7, 7)
assert np.allclose(P[0, :], 0.0) and np.allclose(P[:, 0], 0.0)
diff --git a/tests/test_spline_expansions.py b/tests/transformers/test_spline_expansions.py
similarity index 96%
rename from tests/test_spline_expansions.py
rename to tests/transformers/test_spline_expansions.py
index 40e50e2..7fa7431 100644
--- a/tests/test_spline_expansions.py
+++ b/tests/transformers/test_spline_expansions.py
@@ -118,9 +118,7 @@ def test_ispline_shape_multi_feature():
def test_spline_with_cart_knot_selector(data):
X, y = data
- transformer = BSplineTransformer(
- output_dim=8, include_bias=False, target_aware=True, placement_strategy="cart"
- )
+ transformer = BSplineTransformer(output_dim=8, include_bias=False, target_aware=True, placement_strategy="cart")
Xt = transformer.fit_transform(X, y)
assert Xt.shape == (200, 8)
assert np.isfinite(Xt).all()
diff --git a/tests/test_tanh_transformer.py b/tests/transformers/test_tanh_transformer.py
similarity index 99%
rename from tests/test_tanh_transformer.py
rename to tests/transformers/test_tanh_transformer.py
index 07cdb5e..a72e879 100644
--- a/tests/test_tanh_transformer.py
+++ b/tests/transformers/test_tanh_transformer.py
@@ -1,6 +1,8 @@
+import warnings
+
import numpy as np
import pytest
-import warnings
+
from pretab.transformers import TanhExpansionTransformer
diff --git a/tests/test_tensorproduct_transformer.py b/tests/transformers/test_tensorproduct_transformer.py
similarity index 100%
rename from tests/test_tensorproduct_transformer.py
rename to tests/transformers/test_tensorproduct_transformer.py
diff --git a/tests/transformers/test_thinplate_transformer.py b/tests/transformers/test_thinplate_transformer.py
new file mode 100644
index 0000000..8b2eb67
--- /dev/null
+++ b/tests/transformers/test_thinplate_transformer.py
@@ -0,0 +1,113 @@
+import numpy as np
+import pytest
+from sklearn.exceptions import NotFittedError
+
+from pretab.exceptions import InsufficientSamplesError, InvalidParamError
+from pretab.transformers import ThinPlateSplineTransformer
+
+
+def test_tprs_output_shape_and_values():
+ X = np.linspace(0, 1, 30).reshape(-1, 1)
+ transformer = ThinPlateSplineTransformer(n_components=6, random_state=0)
+ Xt = transformer.fit_transform(X)
+
+ assert Xt.shape == (30, 6)
+ assert transformer.total_output_dim_ == 6
+ assert np.isfinite(Xt).all()
+
+
+def test_tprs_output_consistency():
+ X = np.random.rand(20, 1)
+ transformer = ThinPlateSplineTransformer(n_components=5, random_state=0)
+ transformer.fit(X)
+ Xt1 = transformer.transform(X)
+ Xt2 = transformer.fit_transform(X)
+
+ np.testing.assert_allclose(Xt1, Xt2, rtol=1e-5)
+
+
+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()
+
+ assert P.shape[0] == P.shape[1]
+ assert np.allclose(P, P.T, atol=1e-6)
+
+
+def test_tprs_multivariate_is_supported():
+ rng = np.random.RandomState(0)
+ X = rng.uniform(size=(60, 3))
+ transformer = ThinPlateSplineTransformer(n_components=5, random_state=0)
+ Xt = transformer.fit_transform(X)
+
+ assert Xt.shape == (60, 5)
+ assert transformer.n_features_in_ == 3
+ assert np.isfinite(Xt).all()
+
+
+def test_tprs_feature_count_mismatch_raises():
+ rng = np.random.RandomState(0)
+ transformer = ThinPlateSplineTransformer(n_components=4, random_state=0)
+ transformer.fit(rng.uniform(size=(40, 1)))
+ with pytest.raises(ValueError, match="is expecting 1 features"):
+ transformer.transform(rng.uniform(size=(10, 2)))
+
+
+def test_tprs_insufficient_samples_raises():
+ X = np.random.rand(5, 2)
+ transformer = ThinPlateSplineTransformer(n_components=10, random_state=0)
+ with pytest.raises(InsufficientSamplesError, match="needs at least"):
+ transformer.fit(X)
+
+
+def test_tprs_rejects_invalid_strategies():
+ X = np.random.rand(40, 1)
+ with pytest.raises(InvalidParamError, match="landmark_strategy"):
+ ThinPlateSplineTransformer(n_components=4, landmark_strategy="bogus").fit(X)
+ with pytest.raises(InvalidParamError, match="rank_strategy"):
+ ThinPlateSplineTransformer(n_components=4, rank_strategy="bogus").fit(X)
+
+
+def test_tprs_nystroem_rank_strategy():
+ rng = np.random.RandomState(0)
+ X = rng.uniform(size=(50, 2))
+ transformer = ThinPlateSplineTransformer(n_components=6, rank_strategy="nystroem", random_state=0)
+ Xt = transformer.fit_transform(X)
+
+ assert Xt.shape == (50, 6)
+ assert np.isfinite(Xt).all()
+
+
+def test_tprs_feature_names_out():
+ X = np.random.rand(20, 1)
+ transformer = ThinPlateSplineTransformer(n_components=6, random_state=0)
+ Xt = transformer.fit_transform(X)
+
+ names = transformer.get_feature_names_out(["a"])
+ assert len(names) == Xt.shape[1]
+ assert names[0] == "a_tps0"
+ assert all(name.startswith("a_tps") for name in names)
+
+
+def test_tprs_feature_names_out_default_input():
+ X = np.random.rand(15, 1)
+ transformer = ThinPlateSplineTransformer(n_components=5, random_state=0).fit(X)
+
+ names = transformer.get_feature_names_out()
+ assert len(names) == transformer.n_basis_[0]
+ assert names[0].startswith("x0_tps")
+
+
+def test_tprs_allow_nan_tag():
+ tags = ThinPlateSplineTransformer().__sklearn_tags__()
+ assert tags.input_tags.allow_nan is True
+
+
+def test_tprs_transform_requires_fit():
+ transformer = ThinPlateSplineTransformer()
+ with pytest.raises(NotFittedError):
+ transformer.transform(np.random.rand(5, 1))
+ with pytest.raises(NotFittedError):
+ transformer.get_penalty_matrix()