Skip to content

fix(cross-fitting): refit only target-aware Preprocessor blocks per fold - #117

Open
ChrisW09 wants to merge 1 commit into
mainfrom
fix/79-crossfit-preprocessor-encoding
Open

ChrisW09 wants to merge 1 commit into
mainfrom
fix/79-crossfit-preprocessor-encoding

Conversation

@ChrisW09

@ChrisW09 ChrisW09 commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #79.

CrossFittedTransformer refit a wrapped Preprocessor from scratch on every fold, so the out-of-fold training features could use other category codes, another numerical/categorical split or other one-hot columns than transform. A downstream model was then trained under one encoding and applied under another without an error, and a changing width crashed the fit with a message about adaptive sizing.

Root cause

fit_transform cloned the wrapped estimator and refit it on each fold's training rows. For a Preprocessor that reruns, on fewer rows, everything that does not depend on the target: column-type detection (the cat_cutoff ratio depends on the row count) and the category vocabularies (sorted integer codes, one-hot columns).

Fix

  • Preprocessor._cross_fit_fold(X, y) (new, private) derives a fold model from the all-data fit. It shallow-copies the fitted Preprocessor and its column_transformer_ and refits, on the fold's rows, only the blocks whose representation consumed y. Each such block gets its columns as the internal ColumnTransformer routes them in fit (to_dataframe plus sklearn.utils._safe_indexing(X, columns, axis=1)). Column types, category vocabularies and every other block are shared with the all-data fit.
  • CrossFittedTransformer._fit_fold (new) uses the wrapped estimator's _cross_fit_fold when it has one. It is found by duck typing, so pretab.core still imports nothing from the layers above it. Any other estimator is cloned and refit on each fold as before.
  • block_uses_target(transformer) (new, pretab/compose/inspection.py) decides whether a fitted block used y from its representation's RepresentationSpec (for a separate-state union, from its representation branch). The spec is read without input names: with add_missing_indicator=True, main feeds the imputer's indicator to the representation as a second input, and naming the spec after the block's single column raised InvalidParamError for every representation except PLE.
  • Registered classes without a RepresentationSpec: _resolve_block_representation now falls back to TRANSFORMER_REGISTRY. A class registered with supervision="supervised", or "optional" with target_aware set on the instance, counts as target-aware. This follow-up closes a leak that the per-block refit would otherwise introduce: a plain scikit-learn class such as TargetEncoder was not recognised as target-aware, so its block would have been shared with the all-data fit, which had seen each out-of-fold row's own target (on a pure-noise target, the out-of-fold encoding would correlate with y at about 0.5). It is now refit on every fold.
  • The width-mismatch error no longer blames adaptive sizing only.

Behaviour changes

  • For a wrapped Preprocessor, fit_transform keeps the all-data column types, category codes and one-hot columns, and every block that does not use y (scalers, unsupervised splines, categorical encoders) encodes the out-of-fold rows with its all-data fit instead of a per-fold refit. A target-aware block is refit as a whole (imputer, scaler and representation) on the fold's training rows. Without target-aware blocks, fit_transform equals transform.
  • As a consequence, cross-fitting with add_missing_indicator=True no longer fails for blocks that do not use y. Target-aware blocks still lose the indicator column on a fold without missing values; that is CrossFittedTransformer(Preprocessor(add_missing_indicator=True)) crashes when a fold's training rows contain no missing value #89, whose PR is stacked on this one.
  • Other wrapped estimators are unchanged: cloned and refit on each fold.
  • The width-mismatch IncompatibleParamsError has a new message: "... The wrapped transformer's width depends on the training rows (e.g. adaptive sizing); fix it, for example by disabling adaptive sizing."
  • Feature lineage of a registered class without a RepresentationSpec that is declared "supervised" or "optional": family is the registered name, component is "category" for categorical methods and "basis" otherwise, is_interaction follows the registered scope, and uses_target is true for "supervised" and for "optional" with target_aware=True. Before, such a block was reported as family="passthrough", component="raw", uses_target=False. Unsupervised registered classes and all built-in methods (every built-in target-using class has a spec) are unchanged.

Documentation

  • docs/core_concepts/target_awareness.md: new paragraph in the cross-fitting section. A wrapped Preprocessor reuses its all-data fit on every fold and refits only the target-aware representations; column types, category codes and the blocks that do not use y are shared, so the out-of-fold features have exactly the columns and encoding transform produces.
  • CrossFittedTransformer docstring, transformer parameter: for a Preprocessor, only its target-aware blocks are refit per fold; any other estimator is cloned and refit.
  • register_representation docstring, supervision: for a class without get_representation_spec, the declaration decides whether a fitted block used y, which sets the lineage's uses_target and makes CrossFittedTransformer refit the block on every fold.
  • Docstrings of _fit_fold, _cross_fit_fold, block_uses_target and _resolve_block_representation.

Tests

tests/core/test_cross_fitted.py, on NumPy input (which behaves the same with or without the PR for #58 (#103)):

  • test_wrapped_preprocessor_out_of_fold_codes_match_transform: with an integer column near cat_cutoff and a category that some folds never see, the out-of-fold categorical columns equal transform's and all four codes 1-4 appear. Fails on main ("expected 6, got 9": a column is re-detected as numerical on a fold), passes here.
  • test_wrapped_preprocessor_target_aware_blocks_stay_out_of_fold: each fold's PLE columns equal the block refit on that fold's training rows, and differ from transform. Fails on main, passes here.
  • test_wrapped_preprocessor_with_one_hot_keeps_a_fixed_width: the one-hot columns have transform's width and values. Fails on main ("expected 16, got 11"), passes here.
  • test_wrapped_unsupervised_preprocessor_matches_transform: without target-aware blocks, fit_transform equals transform exactly. Fails on main, passes here.
  • test_wrapped_preprocessor_keeps_the_all_data_column_types: 25 integer levels on 1000 rows (categorical on all rows, numerical on an 800-row fold) stay categorical; the out-of-fold values are the codes 1-25 and equal transform's. Fails on main (min-max values), passes here.
  • test_wrapped_preprocessor_shares_indicator_blocks_that_do_not_use_y[minmax|standardization|bspline-unsupervised|categorical-int|categorical-one-hot]: with add_missing_indicator=True and a single missing value, blocks that do not use y keep their all-data fit, indicator column included, and fit_transform equals transform. Fails on main (width error), passes here.
  • test_wrapped_preprocessor_reads_the_target_use_of_an_indicator_block: an rbf block with an indicator, with missing values in every fold, is recognised as target-aware and refit per fold. Guard test: passes on both by design (main refits everything); it fails if block_uses_target reads the spec with the block's column names.
  • test_width_mismatch_message_does_not_blame_adaptive_sizing_only: pins the new message. Fails on main, passes here.

tests/extension/test_registered_cross_fitting.py (new):

  • test_registered_target_encoder_is_refit_per_fold[TargetEncoder-supervised|_MeanEncoder-optional]: a registered scikit-learn TargetEncoder and a plain optionally supervised encoder, both without a RepresentationSpec, are refit per fold: on a pure-noise target the out-of-fold encoding has |corr| < 0.2 with y, and the lineage reports uses_target. Fails on main on the lineage assertion (main refits the whole Preprocessor per fold, so it has no leak), passes here. Without the registry fallback the correlation is about 0.5.

Reproduction

Output of the script from #79 on this branch (each call wrapped in try/except; the one-hot call, which raised on main, prints its shape next to transform's):

col_0      1.0  2.0  3.0
city                    
Amsterdam    1    0    0
Berlin       0   43    0
Paris        0    0   56
transform() codes for Amsterdam, Berlin, Paris: [1 2 3]
one-hot fit_transform: (100, 3) | transform(): (100, 3)
out-of-fold values: [1. 2. 3. 4.] | transform() values: [1 2 3 4]

Checks

  • just test: 1452 passed, 59 skipped, 7 xfailed; coverage 93.73% (gate 90%).
  • Minimum dependencies (CI job: Python 3.10, numpy 1.24.4, pandas 2.0.3, scipy 1.10.1, scikit-learn 1.6.0): 1439 passed, 72 skipped, 7 xfailed; scripts/quickstart.py passes.
  • just docs: strict build succeeds.
  • just check: all pre-commit and pre-push hooks pass (ruff format, ruff lint, prettier, file hygiene, pyright with 0 errors).
  • Import direction between subpackages is unchanged.

Merge notes

This change was previously part of #86, which is closed in favour of one pull request per issue.

CrossFittedTransformer refit a fresh clone of the wrapped estimator on
each fold. For a Preprocessor that re-detected column types (the
cat_cutoff ratio depends on the row count) and relearned category
vocabularies on fewer rows, so the out-of-fold training features used
different integer codes, a different numerical/categorical split, or a
different one-hot width than transform, which crashed the fit with a
message about adaptive sizing.

A Preprocessor now derives each fold model from the all-data fit and
refits only the blocks that consume the target; everything else is
shared, so out-of-fold rows are encoded exactly like transform encodes
them while target-aware placement still never sees a row's own target.
CrossFittedTransformer finds this through a duck-typed _cross_fit_fold
hook, so core keeps no dependency on the Preprocessor. Other estimators
keep the clone-and-refit path, and the width-mismatch message no longer
blames adaptive sizing only.

A block is target-aware when its representation reports uses_target in
its RepresentationSpec (read without input names, since with
add_missing_indicator the representation is fitted on its column and the
imputer's indicator). A class registered with
supervision="supervised", or "optional" with target_aware set, that has
no RepresentationSpec (such as scikit-learn's TargetEncoder) is resolved
from its registry entry, so it is refit on every fold instead of
encoding out-of-fold rows with an all-data fit that saw their targets,
and the feature lineage reports uses_target for it.

Fixes #79

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CrossFittedTransformer(Preprocessor) relearns column types and category codes per fold, unlike transform

1 participant