Skip to content

CrossFittedTransformer(Preprocessor) refits column types and category codes per fold, so out-of-fold features use a different encoding than transform #79

Description

@ChrisW09

Description

CrossFittedTransformer builds each fold's model by cloning the wrapped estimator and refitting it from scratch on the fold's training rows. For a wrapped Preprocessor, that refit redoes everything that does not depend on the target:

  • column-type detection, whose unique-ratio cat_cutoff depends on the row count;
  • the category vocabularies (sorted integer codes, one-hot columns).

The out-of-fold training features therefore use a different encoding from transform, which uses the all-data estimator_. Several things follow:

  • A category missing from a fold shifts every later integer code in that fold.
  • An integer column can be categorical on the full data but numerical on a fold.
  • One-hot widths change between folds, which crashes the fit with a message about adaptive sizing.

Affected code

This applies with #75 applied, which makes wrapping a Preprocessor possible (fixes #58). On main the wrapped Preprocessor already receives an object array (#58), and the same per-fold refit then affects every column.

Reproduction

import numpy as np, pandas as pd
from pretab import CrossFittedTransformer, Preprocessor

rng = np.random.default_rng(0)
X = pd.DataFrame({"x": rng.normal(size=100), "city": rng.choice(["Berlin", "Paris"], 100)})
X.loc[7, "city"] = "Amsterdam"                      # one rare category
y = rng.normal(size=100)

cf = CrossFittedTransformer(Preprocessor(random_state=0), n_folds=5, random_state=0)
out_of_fold = cf.fit_transform(X, y)
j = list(cf.get_feature_names_out()).index("cat_city")
print(pd.crosstab(X["city"], out_of_fold[:, j]))
print("transform() codes for Amsterdam, Berlin, Paris:",
      cf.transform(pd.DataFrame({"x": [0.0] * 3, "city": ["Amsterdam", "Berlin", "Paris"]}))[:, j])
try:
    CrossFittedTransformer(Preprocessor(categorical_method="one-hot", random_state=0), n_folds=5, random_state=0).fit_transform(X, y)
except Exception as e:
    print(type(e).__name__, e)

Output (#75 head 92b1ddc, Python 3.12, numpy 2.5.3, pandas 2.3.3, scikit-learn 1.9.1, scipy 1.18.1):

col_0      0.0  1.0  2.0  3.0
city
Amsterdam    1    0    0    0
Berlin       0   13   35    0
Paris        0    0    6   45
transform() codes for Amsterdam, Berlin, Paris: [1. 2. 3.]
IncompatibleParamsError Cross-fitting requires a fixed output width across folds; expected 10, got 9. Disable adaptive sizing on the wrapped transformer.

In the training features, code 2 means Berlin for 35 rows and Paris for 6 rows, while transform maps Berlin to 2 and Paris to 3.

Expected behavior

Cross-fitting exists so that "every training row is encoded by a model that never saw its own target", while transform uses one all-data model. For that to hold, the out-of-fold features must use the same encoding as transform. Only the target-aware parts should differ per fold. Category codes, column types and blocks that do not use y involve no target leakage, and they should match transform.

Actual behavior

  • Integer codes (and the numerical/categorical split itself) differ between the out-of-fold training features and transform.
  • One-hot encoding crashes with a misleading message.

Impact

A downstream model trained on the out-of-fold features learns category effects under one coding and is then applied under another, with no error. This happens with the default categorical encoding as soon as a category is rare.

Root cause

The fold models are refit from scratch instead of being derived from the all-data fit.

Suggested fix direction

For a wrapped Preprocessor, derive each fold model from estimator_ and refit only the blocks that use the target on the fold's rows. Keep the clone-and-refit path for other estimators. Also make the width-mismatch message generic.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions