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