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.
Description
CrossFittedTransformerbuilds each fold's model by cloning the wrapped estimator and refitting it from scratch on the fold's training rows. For a wrappedPreprocessor, that refit redoes everything that does not depend on the target:cat_cutoffdepends on the row count;The out-of-fold training features therefore use a different encoding from
transform, which uses the all-dataestimator_. Several things follow:Affected code
This applies with #75 applied, which makes wrapping a
Preprocessorpossible (fixes #58). Onmainthe wrappedPreprocessoralready receives an object array (#58), and the same per-fold refit then affects every column.pretab/core/supervised.py:199-202:fold = clone(self.transformer); fold.fit(...)for every fold.pretab/preprocessor.py:563: every fit re-detects column types and relearns the categorical encoders.Reproduction
Output (#75 head
92b1ddc, Python 3.12, numpy 2.5.3, pandas 2.3.3, scikit-learn 1.9.1, scipy 1.18.1):In the training features, code 2 means Berlin for 35 rows and Paris for 6 rows, while
transformmaps 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
transformuses one all-data model. For that to hold, the out-of-fold features must use the same encoding astransform. Only the target-aware parts should differ per fold. Category codes, column types and blocks that do not useyinvolve no target leakage, and they should matchtransform.Actual behavior
transform.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 fromestimator_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.