Skip to content

fix(cross-fitting): pass DataFrames to the wrapped transformer unchanged - #103

Open
ChrisW09 wants to merge 1 commit into
mainfrom
fix/58-crossfit-dataframe-passthrough
Open

ChrisW09 wants to merge 1 commit into
mainfrom
fix/58-crossfit-dataframe-passthrough

Conversation

@ChrisW09

@ChrisW09 ChrisW09 commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #58.

CrossFittedTransformer converted X to a NumPy array before fitting the wrapped estimator, so a mixed-dtype DataFrame reached it as an object array without column names. A wrapped Preprocessor then treated every column as categorical and silently produced meaningless out-of-fold features, and estimators that select columns by name raised.

Root cause

_fit_full and transform called np.asarray(X), and fit_transform sliced the folds from that array (X_arr[train_idx], X_arr[test_idx]). A frame with a float and a string column becomes an object array; to_dataframe wraps it as-is and detect_column_types classifies every object column as categorical.

Fix

  • New _as_2d(X) in pretab/core/supervised.py: a 2-D pandas DataFrame is returned unchanged; any other input is still converted with np.asarray, with 1-D input reshaped to one column. _fit_full and transform use it.
  • New _take_rows(X, indices): selects rows by position (.iloc for a DataFrame). The fit_transform fold loop takes each fold's training and held-out rows with it.
  • y is still converted with np.asarray(y).ravel().

Behaviour changes

  • A pandas DataFrame reaches the wrapped estimator in fit, transform and on every fold as a DataFrame that keeps its column names, dtypes and index labels (a fold is a row subset, its index is not reset). Before, it was an ndarray, so a wrapped Preprocessor named its outputs after feature_0, feature_1, ...; it now uses the column names.
  • Any other input is converted to a 2-D NumPy array, as before.
  • Not changed: to_dataframe still wraps an object ndarray without infer_objects() (the optional second suggestion in the issue), because that would change type detection for every object-array input.

Documentation

  • CrossFittedTransformer docstring, transformer parameter: it may also be an estimator such as a Preprocessor or Pipeline built from PreTab transformers, and a pandas DataFrame X is passed to it unchanged, each fold as a row subset.
  • Docstrings of the new private helpers _as_2d and _take_rows.
  • No docs page needed a change: docs/core_concepts/target_awareness.md and the README show NumPy examples only and do not describe the input conversion.

Tests

In tests/core/test_cross_fitted.py, with a mixed_frame fixture (a float and a string column, shuffled index):

  • test_wrapped_preprocessor_keeps_dataframe_column_types: the wrapped Preprocessor detects num as numerical and city as categorical, its output width and feature names equal a direct fit, the out-of-fold PLE column holds more than one distinct value (a supervised encoding, not the "unseen category" code), and transform has the same shape. Fails on main, passes here.
  • test_wrapped_column_transformer_selects_by_column_name: a wrapped ColumnTransformer that selects ["num"] by name cross-fits. Fails on main ("Specifying the columns using strings is only supported for dataframes"), passes here.
  • test_wrapped_preprocessor_accepts_feature_preprocessing_by_column_name: with feature_preprocessing={"num": "ple"} and numerical_method="minmax", the outputs are num_num_ple0 ... num_num_ple3 and cat_city. Fails on main (InvalidParamError ... Valid values: ['feature_0', 'feature_1']), passes here.
  • test_one_dimensional_input_is_still_a_single_column: 1-D input is still reshaped to one column. Guard test: passes on both by design.

Reproduction

Output of the script from #58 on this branch, with the two further effects the issue lists (feature_preprocessing={"num": "ple"} and a ColumnTransformer selecting ["num"]) added as separate calls:

direct : ['num'] ['city'] 7
wrapped: ['num'] ['city'] 7
names  : ['num_num_ple0' 'num_num_ple1' 'num_num_ple2' 'num_num_ple3'
 'num_num_ple4' 'num_num_ple5' 'cat_city']
distinct OOF values in column 0: [0.         0.07564504 0.1530339  0.39430624 0.43366897 0.4538359
 0.469118   0.65073645 0.71018094 0.71739459 0.73780364 0.95519274
 0.99169701 0.9927367  1.        ]
feature_preprocessing={'num': 'ple'}: (300, 7)
ColumnTransformer by name: (300, 6)

Checks

  • just test: 1442 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): 1429 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 #75, which is closed in favour of one pull request per issue.

CrossFittedTransformer converted X with np.asarray and sliced folds from
that array, so a mixed-dtype DataFrame reached the wrapped estimator as an
object array without column names. A wrapped Preprocessor then treated
every column as categorical (every out-of-fold value of a numeric feature
became the 'unseen category' code), and name-based estimators such as a
ColumnTransformer selecting columns by label raised.

DataFrames are now passed through unchanged, with each fold taken as a
row subset by position; other input is still converted to a 2D array.

Fixes #58

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 converts DataFrames to ndarray; a wrapped Preprocessor treats every column as categorical

1 participant