Repository navigation
Conversation
CrossFittedTransformer.fit_transform wrote the out-of-fold rows into a preallocated float64 buffer, so it disagreed with transform: a wrapped Preprocessor(categorical_method=None) raised on its unchanged string columns, sparse output (output_format="sparse") raised because the fold width could not be read, dtype=np.float32 gave float64, and a DataFrame-returning estimator gave an ndarray. The fold outputs are now collected and stacked in the original row order with their own container: sparse blocks are stacked with scipy.sparse.vstack in the transform format, pandas / polars DataFrames are concatenated (keeping the input index when the blocks carry it, renumbered otherwise), and dense blocks take their common dtype. The width check still runs on every fold, a DataFrame fold whose columns differ from the all-data fit raises instead of being misaligned by label, and a dict of blocks (output_structure="blocks") raises a clear error instead of an IndexError. Fixes #90
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 #90.
CrossFittedTransformer.fit_transformwrote the out-of-fold rows into a preallocated dense float64 array whatever the wrapped transformer returned, so it disagreed withtransform: string columns left unchanged (Preprocessor(categorical_method=None)) and sparse output crashed, float32 output came back as float64, and a DataFrame-returning transformer gave an ndarray.Root cause
fit_transformallocatedout = np.empty((n_rows, width), dtype=float), converted each fold withnp.asarray(fold.transform(...))and assigned it intoout.np.asarrayturns a sparse matrix into a 0-d object array, so readingfold_out.shape[1]raisedIndexError, and the assignment cast every fold to float64, which fails for strings.Fix
fit_transformcollects each fold's output with its held-out row indices, and the new_stack_foldsinpretab/core/supervised.pystacks them in the row order ofX, in the container the wrapped transformer returns:scipy.sparse.vstack, rows reordered with the inverse permutation, returned in the first block's format;pd.concatand reordering; the index ofXis kept when the blocks carry it (scikit-learn'sset_outputcopies the input index), otherwise the rows are numbered from 0, astransformnumbers the rows of aPreprocessor;pl.concatand reordering (polars is imported only when the output already is a polars frame);np.result_type,objectfor strings).Behaviour changes
fit_transformreturns the container and dtypetransformreturns: a sparse matrix in the same format, a pandas or polars DataFrame, or a dense array of the folds' common dtype. For example, cross-fitting a standalonePLETransformernow gives float32, its native dtype, wherefit_transformused to give float64.transform's output, since onmainthe wrapped transformer always receives an ndarray. With the PR for CrossFittedTransformer converts DataFrames to ndarray; a wrapped Preprocessor treats every column as categorical #58 (fix(cross-fitting): pass DataFrames to the wrapped transformer unchanged #103), the index of a DataFrameXis kept when the fold frames carry it.IncompatibleParamsError("Cross-fitting requires the same output columns across folds; a fold's columns differ from the all-data fit in [...]").Preprocessor(output_structure="blocks")) raisesIncompatibleParamsError("... the wrapped transformer returned a dict of blocks. Use output_structure='matrix' on a wrapped Preprocessor.") instead of anIndexError.Documentation
CrossFittedTransformerclass docstring:fit_transformandtransformboth return the wrapped transformer's kind of output (a dense array of the same dtype, a sparse matrix in the same format, or a DataFrame).fit_transformdocstring: newReturnsandRaisessections.docs/core_concepts/target_awareness.md: one sentence in the cross-fitting section saying both methods return the same kind of output, the one the wrapped transformer produces._stack_folds.Tests
All in
tests/core/test_cross_fitted.py:test_fit_transform_keeps_strings_left_unchanged:Preprocessor(categorical_method=None)on an object array returns an object array equal toX, liketransform. Fails onmain("could not convert string to float"), passes here.test_fit_transform_stacks_out_of_fold_values_with_strings: aColumnTransformerwith a PLE column and a passthrough string column gives an object array whose PLE part equals the out-of-fold PLE of the numeric column alone and whose strings are unchanged. Fails onmain, passes here.test_fit_transform_keeps_sparse_output:output_format="sparse"gives CSR liketransform, with the values of the dense run. Fails onmain(IndexError), passes here.test_fit_transform_keeps_the_configured_dtype:dtype=np.float32gives float32 liketransform, with the values of the default run (rtol 1e-6). Fails onmain, passes here.test_fit_transform_returns_the_dataframe_transform_returns: withset_output(transform="pandas"), a DataFrame whose columns and index equaltransform's and whose values equal the array run. Fails onmain, passes here.test_fit_transform_returns_polars_like_transform: the same withset_output(transform="polars"). Fails onmain, passes here; skipped without polars (the minimum-dependencies job).test_stacked_dataframe_folds_keep_the_input_index_they_carry: unit test of_stack_folds. Frames that carryX's index keep it, inX's row order; frames that do not are renumbered. Fails onmain(no_stack_folds), passes here.test_fit_transform_rejects_folds_with_other_dataframe_columns: with unshuffled folds and a one-hot encoder withmax_categories=2, fold 0 keeps category "b" where the all-data fit keeps "a"; the error namesx1_a. Fails onmain, passes here.test_cross_fitting_rejects_blocks:output_structure="blocks"raises the "dict of blocks" error. Fails onmain(IndexError), passes here.Reproduction
Output of the script from #90 on this branch:
The literal sparse case also needs the PRs for #58 (#103) and #79 (#117). It no longer fails on the container but on the width: on
mainthe mixed frame becomes an object array (#58), so the float columnais detected as categorical and one-hot encoded too (200 + 3 = 203 columns), and the per-fold refit relearns those categories on 133 training rows (#79), giving 136. With #58 and #79 added, the literal script returnscsr_matrix float64for this case. The sparse container itself works on this branch, as does the pandas case from the issue description; both on the same data withcitycoded as integers, so the frame stays numeric:Checks
just test: 1447 passed, 59 skipped, 7 xfailed; coverage 93.67% (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: every symptom in the issue reproduces onmainwith NumPy input.fold = self._fit_fold(...)from CrossFittedTransformer(Preprocessor) relearns column types and category codes per fold, unlike transform #79, thenfold_out = fold.transform(...), this PR's dict / shape / width / column checks andblocks.append(...). Take CrossFittedTransformer(Preprocessor) relearns column types and category codes per fold, unlike transform #79'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 ("... configure it to produce the same columns on every fold."). The output-container handling (sparse output, the dict-of-blocks check) is only in this PR._take_rows(X_arr, ...)._stack_folds(blocks, test_indices, X_arr)then receives the DataFrame, so keeping its index becomes reachable through the public API; whichever of the two PRs lands second can add a test that the index of scikit-learnset_outputframes is kept._stack_foldsfollows whatever container the wrapped transformer returns, so the other output-container fixes (Preprocessor pandas output (set_output) drops the input index, breaking ColumnTransformer/FeatureUnion #60, Preprocessor with one-hot encoding or preset="expanded" fails under set_config(transform_output="pandas") #76) need no change here. Polars input (Preprocessor rejects polars DataFrames, so pipelines with set_output(transform="polars") crash #94, which needs a follow-up inCrossFittedTransformeronce CrossFittedTransformer converts DataFrames to ndarray; a wrapped Preprocessor treats every column as categorical #58 lands) is independent of the polars output stacking here.This change was previously part of #97, which is closed in favour of one pull request per issue.