Repository navigation
Conversation
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 was referenced Oct 16, 2026
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #79.
CrossFittedTransformerrefit a wrappedPreprocessorfrom 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 thantransform. 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_transformcloned the wrapped estimator and refit it on each fold's training rows. For aPreprocessorthat reruns, on fewer rows, everything that does not depend on the target: column-type detection (thecat_cutoffratio 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 fittedPreprocessorand itscolumn_transformer_and refits, on the fold's rows, only the blocks whose representation consumedy. Each such block gets its columns as the internalColumnTransformerroutes them infit(to_dataframeplussklearn.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_foldwhen it has one. It is found by duck typing, sopretab.corestill 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 usedyfrom its representation'sRepresentationSpec(for a separate-state union, from itsrepresentationbranch). The spec is read without input names: withadd_missing_indicator=True,mainfeeds the imputer's indicator to the representation as a second input, and naming the spec after the block's single column raisedInvalidParamErrorfor every representation except PLE.RepresentationSpec:_resolve_block_representationnow falls back toTRANSFORMER_REGISTRY. A class registered withsupervision="supervised", or"optional"withtarget_awareset 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 asTargetEncoderwas 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 withyat about 0.5). It is now refit on every fold.Behaviour changes
Preprocessor,fit_transformkeeps the all-data column types, category codes and one-hot columns, and every block that does not usey(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_transformequalstransform.add_missing_indicator=Trueno longer fails for blocks that do not usey. 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.IncompatibleParamsErrorhas 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."RepresentationSpecthat is declared"supervised"or"optional":familyis the registered name,componentis"category"for categorical methods and"basis"otherwise,is_interactionfollows the registered scope, anduses_targetis true for"supervised"and for"optional"withtarget_aware=True. Before, such a block was reported asfamily="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 wrappedPreprocessorreuses its all-data fit on every fold and refits only the target-aware representations; column types, category codes and the blocks that do not useyare shared, so the out-of-fold features have exactly the columns and encodingtransformproduces.CrossFittedTransformerdocstring,transformerparameter: for aPreprocessor, only its target-aware blocks are refit per fold; any other estimator is cloned and refit.register_representationdocstring,supervision: for a class withoutget_representation_spec, the declaration decides whether a fitted block usedy, which sets the lineage'suses_targetand makesCrossFittedTransformerrefit the block on every fold._fit_fold,_cross_fit_fold,block_uses_targetand_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 nearcat_cutoffand a category that some folds never see, the out-of-fold categorical columns equaltransform's and all four codes 1-4 appear. Fails onmain("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 fromtransform. Fails onmain, passes here.test_wrapped_preprocessor_with_one_hot_keeps_a_fixed_width: the one-hot columns havetransform's width and values. Fails onmain("expected 16, got 11"), passes here.test_wrapped_unsupervised_preprocessor_matches_transform: without target-aware blocks,fit_transformequalstransformexactly. Fails onmain, 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 equaltransform's. Fails onmain(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]: withadd_missing_indicator=Trueand a single missing value, blocks that do not useykeep their all-data fit, indicator column included, andfit_transformequalstransform. Fails onmain(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 (mainrefits everything); it fails ifblock_uses_targetreads the spec with the block's column names.test_width_mismatch_message_does_not_blame_adaptive_sizing_only: pins the new message. Fails onmain, 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-learnTargetEncoderand a plain optionally supervised encoder, both without aRepresentationSpec, are refit per fold: on a pure-noise target the out-of-fold encoding has |corr| < 0.2 withy, and the lineage reportsuses_target. Fails onmainon the lineage assertion (mainrefits the wholePreprocessorper 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 totransform's):Checks
just test: 1452 passed, 59 skipped, 7 xfailed; coverage 93.73% (gate 90%).scripts/quickstart.pypasses.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).Merge notes
main. Not stacked on CrossFittedTransformer converts DataFrames to ndarray; a wrapped Preprocessor treats every column as categorical #58: the symptoms reproduce with NumPy input, and the tests use NumPy arrays.mainafter this PR merges.fit_transformfold loop and at the end oftests/core/test_cross_fitted.py. Usefold = self._fit_fold(_take_rows(X_arr, train_idx), y_arr[train_idx])andfold.transform(_take_rows(X_arr, test_idx)), and keep both test sections._cross_fit_foldthen receives DataFrame folds whose index is not reset;to_dataframeand_safe_indexinghandle them._stack_folds, get each fold fromself._fit_fold(...)instead ofclone(...), and take this PR's width-mismatch message (or CrossFittedTransformer(Preprocessor(add_missing_indicator=True)) crashes when a fold's training rows contain no missing value #89's, once that lands). Until CrossFittedTransformer.fit_transform forces a dense float64 array: string and sparse outputs crash #90 lands, sparse fold output and a dict of blocks fail as onmain. With CrossFittedTransformer converts DataFrames to ndarray; a wrapped Preprocessor treats every column as categorical #58, CrossFittedTransformer(Preprocessor) relearns column types and category codes per fold, unlike transform #79, CrossFittedTransformer(Preprocessor(add_missing_indicator=True)) crashes when a fold's training rows contain no missing value #89 and CrossFittedTransformer.fit_transform forces a dense float64 array: string and sparse outputs crash #90 combined, the full test suite passes (1473 passed, 59 skipped, 8 xfailed)._column_transformer_input) and Preprocessor fitted on a DataFrame rejects a same-width NumPy array at transform (and vice versa) #78 (_align_input) land,_cross_fit_foldmust switch its input path to prepare the fold exactly asfitandtransformthen do:X_ct = self._column_transformer_input(self._align_input(X)), with each block's columns taken asX_ct[list(columns)], instead ofto_dataframe(X)and_safe_indexing(X, columns, axis=1). Otherwise an integer-labelled or boolean frame is routed differently from the all-data fit (with Preprocessor routes integer column labels positionally, silently swapping columns #55 and CrossFittedTransformer converts DataFrames to ndarray; a wrapped Preprocessor treats every column as categorical #58, an integer-labelled frame raises aKeyError).supervisionentry of theregister_representationdocstring inpretab/extension.py. Keep the shared first sentence ("Inferred fromclswhen omitted.") once, followed by Preprocessor(target_aware=False) still fits registered supervision="optional" representations on y #96'starget_awarerequirement and this PR's text on how the declaration decides lineage and per-fold refitting. The two fixes interact: with Preprocessor(target_aware=False) still fits registered supervision="optional" representations on y #96, an optional registered class that defaults totarget_aware=Falsebecomes target-aware under the defaultPreprocessor, and this PR's registry fallback must then refit it per fold. The testtest_optional_class_follows_the_preprocessor_target_aware_across_foldsintests/extension/test_registered_cross_fitting.pypins the two fixes together; it is added by whichever of the PRs for CrossFittedTransformer(Preprocessor) relearns column types and category codes per fold, unlike transform #79 (fix(cross-fitting): refit only target-aware Preprocessor blocks per fold #117) and Preprocessor(target_aware=False) still fits registered supervision="optional" representations on y #96 (fix(compose): pass target_aware to optionally supervised representations #131) merges second.__all__inpretab/compose/inspection.py(Preprocessor.fit crashes when a column name starts with '_' or contains '__' #71 also to the import list inpretab/preprocessor.py): keep all names.block_uses_targetalready looks into therepresentationbranch of add_missing_indicator / impute_with_indicator feeds the indicator through the scaler and basis; lineage crashes #62's indicator union, so add_missing_indicator / impute_with_indicator feeds the indicator through the scaler and basis; lineage crashes #62 needs no change here.This change was previously part of #86, which is closed in favour of one pull request per issue.