refactor: use sklearn OrdinalEncoder as source of truth for DataTransformer categorical encoding (#1564) - #1569
Conversation
There was a problem hiding this comment.
Pull request overview
Refactors DataTransformer categorical encoding internals to use sklearn.preprocessing.OrdinalEncoder as the persisted “source of truth” for per-column category lists, while preserving the external behavior (pandas category output, stable codes across fit/transform, unseen-value warnings + "__NAN__" sentinel handling) and adding backward-compatible fallbacks for older pickles.
Changes:
- Store a fitted
OrdinalEncoderduringDataTransformer.fit_transform()and use itscategories_duringtransform()to pin categorical dtypes. - Keep cross-version pickle compatibility via a three-tier transform fallback (
_ordinal_encoder→_cat_categories→ legacy). - Add a new test class validating the new encoder-backed path and both fallback behaviors.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| flaml/automl/data.py | Replaces _cat_categories write-path with fitted OrdinalEncoder, and adds encoder-first fallback logic during transform(). |
| test/automl/test_preprocess_api.py | Adds tests covering the encoder-backed behavior and backward-compatibility fallbacks. |
| # Fit an OrdinalEncoder as the source of truth for the per-column | ||
| # allowed category list — see issue #1564. This replaces the | ||
| # ad-hoc `_cat_categories` dict from #1561 with sklearn's | ||
| # standard implementation. Unknown values and missing values | ||
| # both encode to -1 internally; `transform()` uses that to |
| encoder_columns = list(encoder.feature_names_in_) | ||
| for column in cat_columns: | ||
| try: | ||
| col_idx = encoder_columns.index(column) | ||
| except ValueError: | ||
| continue |
|
Heads-up: CI is red on this PR (7 of 8 build jobs), and after digging in I think this refactor is not viable as scoped — reporting rather than patching around it. What fails
Root causeFLAML supports mixed-type categorical columns, and col = pd.Series(['a','b','a','c', 1.0, 1.0, 'a'])
pd.Categorical(col).categories # → [1.0, 'a', 'b', 'c'] works
OrdinalEncoder().fit(col.to_frame()) # → TypeError: ... Got ['float', 'str']The home-rolled Why I'm not patching around itThe two workarounds both cost more than the refactor is worth:
The stated benefits in #1564 were "standard sklearn idiom, drop the ad-hoc dict, drop the sentinel." Neither workaround delivers those, so the refactor stops paying for itself. SuggestionClose this PR and leave the #1561 defensive implementation in place — it already fixes the actual user-facing bug from #1101 (encoding drift + unseen-value warning) and it supports mixed-type columns, which The one durable thing worth keeping from this exercise is the constraint itself: any future refactor of Li Jiang (@thinkall) — your call; I'll close this unless you'd prefer one of the workarounds above. |
Why are these changes needed?
Implements the refactor agreed on #1564 (
-1sentinel greenlit by Li Jiang (@thinkall)). Replaces the ad-hoc_cat_categoriesdict introduced in #1561 with a fittedsklearn.preprocessing.OrdinalEncoder(handle_unknown="use_encoded_value", unknown_value=-1, encoded_missing_value=-1)as the source of truth for the per-column category list.The observable behavior is preserved (see the existing #1561 tests): known categories still get stable integer codes across fit/predict, unseen values still emit a
UserWarningand get remapped to the"__NAN__"sentinel category, and pandas categorical dtype is still returned so that LGBM'scategorical_featuredetection, CatBoost'scat_featuresinference, and the sklearn/KNeighbors estimator wrappers all continue to work unchanged.Scope note
My original RFC on #1564 speculated that the refactor could switch to a numeric integer output (with
-1visible in the returned DataFrame). While implementing, I audited the downstream consumers inflaml/automl/model.py:LGBMEstimatorandKNeighborsEstimator_preprocesspaths (select_dtypes(include=["category"])+.cat.codes)CatBoostEstimatorcat_features = X.select_dtypes(include="category").columnsSwitching to a numeric output would silently break CatBoost's categorical-column detection and would need a coordinated per-estimator update. That's a much bigger surgery than the RFC scoped, and the observable value — better sklearn idiom internally — is the same either way. So this PR keeps the category-dtype output and uses OrdinalEncoder as the internal source of truth. If we want the numeric-output form later, I'll open a follow-up RFC.
What the PR does
flaml/automl/data.py—DataTransformer.fit_transform:X[cat_columns].astype("category")call, fits a per-columnOrdinalEncoderonX[cat_columns].astype(object)and stores it asself._ordinal_encoder.self._cat_categories(the_ordinal_encoder'scategories_array is the equivalent, sklearn-standard representation).flaml/automl/data.py—DataTransformer.transform:Three-tier fallback for cross-version pickle compatibility:
_ordinal_encoderpresent (post-[Proposal]: Replace home-rolled categorical encoding with sklearn OrdinalEncoder in DataTransformer (follow-up to #1101 / #1561) #1564) — use it as the source-of-truth category list, pinpd.Categorical(..., categories=known_cats + ["__NAN__"])._cat_categoriespresent (post-fix: DataTransformer pins categorical codes at fit time + warns on unseen values (#1101) #1561 but pre-[Proposal]: Replace home-rolled categorical encoding with sklearn OrdinalEncoder in DataTransformer (follow-up to #1101 / #1561) #1564) — fall back to the existing dict-based path.astype("category")as before.Unseen-value handling (
UserWarning+ remap to"__NAN__") preserved in both_ordinal_encoderand_cat_categoriesbranches.test/automl/test_preprocess_api.py— newTestOrdinalEncoderBackedTransformclass:test_fit_transform_installs_ordinal_encoder— asserts_ordinal_encoderis a fitted sklearnOrdinalEncoderafterfit_transform.test_ordinal_encoder_path_matches_1561_semantics— behavioral equivalence with the fix: DataTransformer pins categorical codes at fit time + warns on unseen values (#1101) #1561 defensive patch: stable known-cat codes,UserWarningon unseen values, unseen rows remapped to the sentinel code.test_transform_falls_back_to_cat_categories_when_encoder_missing— simulates a fix: DataTransformer pins categorical codes at fit time + warns on unseen values (#1101) #1561-era pickle (removes_ordinal_encoder, installs_cat_categories) and assertstransform()still produces stable codes.test_transform_legacy_pickle_without_either_attribute— simulates a pre-fix: DataTransformer pins categorical codes at fit time + warns on unseen values (#1101) #1561 pickle (neither attribute present) and assertstransform()doesn't raise; the legacyastype("category")path is exercised.Verified locally
pytest test/automl/test_preprocess_api.py::TestOrdinalEncoderBackedTransform— 4/4 pass.TestCategoricalEncodingStability) — 2/2 pass unchanged.test_preprocess_api.pyfile — 14/14 pass.test_split.py+test_multioutput+test_ensemble_component_predict_via_public_preprocess— 10/10 pass.pre-commit run --files flaml/automl/data.py test/automl/test_preprocess_api.py— all hooks pass.Related issue / PRs
automl.preprocess(X)see identical output.Checks