From 6dc8195dd08eb7bcf12ffbe028f541c9724ea118 Mon Sep 17 00:00:00 2001 From: Laurence Dyer Date: Sun, 19 Jul 2026 11:26:54 +0200 Subject: [PATCH 01/11] Loosen estimator requirement from BaseEstimator inheritance --- skrub/_tabular_pipeline.py | 30 ++++++++++++++++++---------- skrub/tests/test_tabular_pipeline.py | 22 +++++++++++++++++--- 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/skrub/_tabular_pipeline.py b/skrub/_tabular_pipeline.py index e0f607272..02adfcf60 100644 --- a/skrub/_tabular_pipeline.py +++ b/skrub/_tabular_pipeline.py @@ -1,5 +1,4 @@ from sklearn import ensemble -from sklearn.base import BaseEstimator from sklearn.impute import SimpleImputer from sklearn.pipeline import make_pipeline from sklearn.preprocessing import OrdinalEncoder @@ -26,7 +25,7 @@ def tabular_pipeline(estimator, *, n_jobs=None): """Get a simple machine-learning pipeline for tabular data. - Given either a scikit-learn estimator or one of the special-cased strings + Given either a scikit-learn compatible estimator or one of the special-cased strings ``'regressor'``, ``'regression'``, ``'classifier'``, ``'classification'``, this function creates a scikit-learn pipeline that extracts numeric features, imputes missing values and scales the data if necessary, then applies the estimator. @@ -47,7 +46,9 @@ def tabular_pipeline(estimator, *, n_jobs=None): Parameters ---------- - estimator : {"regressor", "regression", "classifier", "classification"} or sklearn.base.BaseEstimator + estimator : {"regressor", "regression", "classifier", "classification"} or scikit-learn + compatible estimator + The estimator to use as the final step in the pipeline. Based on the type of estimator, the previous preprocessing steps and their respective parameters are chosen. The possible values are: @@ -58,7 +59,8 @@ def tabular_pipeline(estimator, *, n_jobs=None): - ``'classifier'`` or ``'classification'``: a :obj:`~sklearn.ensemble.HistGradientBoostingClassifier` is used as the final step; - - a scikit-learn estimator: the provided estimator is used as the final step. + - a scikit-learn compatible estimator: the provided estimator is used as the final + step. n_jobs : int, default=None Number of jobs to run in parallel in the :obj:`TableVectorizer` step. ``None`` @@ -240,16 +242,22 @@ def tabular_pipeline(estimator, *, n_jobs=None): "If ``estimator`` is a string it should be 'regressor', 'regression'," " 'classifier' or 'classification'." ) - if isinstance(estimator, type) and issubclass(estimator, BaseEstimator): + + if isinstance(estimator, type): raise TypeError( - "tabular_pipeline expects a scikit-learn estimator as its first" - f" argument. Pass an instance of {estimator.__name__} rather than the class" - " itself." + "tabular_pipeline expects a scikit-learn compatible estimator instance as" + " its first argument, but you have passed a type. Pass an instance of the" + " estimator rather than the class itself." ) - if not isinstance(estimator, BaseEstimator): + + is_scikit_learn_compatible = hasattr(estimator, "get_params") and hasattr( + estimator, "set_params" + ) + if not is_scikit_learn_compatible: raise TypeError( - "tabular_pipeline expects a scikit-learn estimator, 'regressor'," - " or 'classifier' as its first argument." + "tabular_pipeline expects a scikit-learn compatible estimator as its first" + " argument. The estimator object must have 'get_params' and 'set_params'" + " attributes." ) is_estimator_from_tabicl = estimator.__class__.__name__ in ( diff --git a/skrub/tests/test_tabular_pipeline.py b/skrub/tests/test_tabular_pipeline.py index 754fc9bef..2f1cecca4 100644 --- a/skrub/tests/test_tabular_pipeline.py +++ b/skrub/tests/test_tabular_pipeline.py @@ -36,14 +36,30 @@ def test_bad_learner(): match=".*should be 'regressor', 'regression', 'classifier' or 'classification'", ): tabular_pipeline("bad") + with pytest.raises(TypeError, match=".*Pass an instance"): + tabular_pipeline(ensemble.HistGradientBoostingRegressor) with pytest.raises( - TypeError, match=".*Pass an instance of HistGradientBoostingRegressor" + TypeError, match=".*expects a scikit-learn compatible estimator" ): - tabular_pipeline(ensemble.HistGradientBoostingRegressor) - with pytest.raises(TypeError, match=".*expects a scikit-learn estimator"): tabular_pipeline(object()) +def test_sklearn_incompatible_learner_fails(): + sklearn_incompatible_learner = type("", (), {"get_params": 123})() + with pytest.raises( + TypeError, match=".*expects a scikit-learn compatible estimator" + ): + tabular_pipeline(sklearn_incompatible_learner) + + +def test_no_type_error_for_sklearn_compatible_learner(): + sklearn_compatible_learner = type("", (), {"get_params": 123, "set_params": 456})() + # Still fails - because this is a dummy class for testing - but not with TypeError + with pytest.raises(Exception) as e: + tabular_pipeline(sklearn_compatible_learner) + assert not isinstance(e.value, TypeError) + + def test_linear_learner(): original_learner = Ridge() p = tabular_pipeline(original_learner) From 55193ea02a5fedc9c240bf032a8e573c4fe8f677 Mon Sep 17 00:00:00 2001 From: Laurence Dyer Date: Sun, 19 Jul 2026 12:08:08 +0200 Subject: [PATCH 02/11] Use monkeypatch for test_sklearn_compatible_learner_succeeds --- skrub/tests/test_tabular_pipeline.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/skrub/tests/test_tabular_pipeline.py b/skrub/tests/test_tabular_pipeline.py index 2f1cecca4..e68bd1389 100644 --- a/skrub/tests/test_tabular_pipeline.py +++ b/skrub/tests/test_tabular_pipeline.py @@ -5,6 +5,7 @@ from sklearn.linear_model import Ridge from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder +import skrub._tabular_pipeline from skrub import ( SquashingScaler, StringEncoder, @@ -14,6 +15,19 @@ ) +def fake_get_tags(_): + """Allows dummy estimators to get past + `if not get_tags(estimator).input_tags.allow_nan:` check for tests""" + + class fake_input_tags: + allow_nan = None + + class fake_estimator: + input_tags = fake_input_tags() + + return fake_estimator() + + @pytest.mark.parametrize( "learner_kind", ["regressor", "regression", "classifier", "classification"] ) @@ -52,12 +66,10 @@ def test_sklearn_incompatible_learner_fails(): tabular_pipeline(sklearn_incompatible_learner) -def test_no_type_error_for_sklearn_compatible_learner(): +def test_sklearn_compatible_learner_succeeds(monkeypatch): sklearn_compatible_learner = type("", (), {"get_params": 123, "set_params": 456})() - # Still fails - because this is a dummy class for testing - but not with TypeError - with pytest.raises(Exception) as e: - tabular_pipeline(sklearn_compatible_learner) - assert not isinstance(e.value, TypeError) + monkeypatch.setattr(skrub._tabular_pipeline, "get_tags", fake_get_tags) + _ = tabular_pipeline(sklearn_compatible_learner) def test_linear_learner(): From 8ee4a56b2d102d4791685354dec0d2474aacca8a Mon Sep 17 00:00:00 2001 From: Laurence Dyer Date: Sun, 19 Jul 2026 13:01:52 +0200 Subject: [PATCH 03/11] Detect HGBT and tree ensembles by estimator class names --- skrub/_tabular_pipeline.py | 30 +++++++++++++++++----------- skrub/tests/test_tabular_pipeline.py | 28 +++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/skrub/_tabular_pipeline.py b/skrub/_tabular_pipeline.py index 02adfcf60..09bb8239c 100644 --- a/skrub/_tabular_pipeline.py +++ b/skrub/_tabular_pipeline.py @@ -10,15 +10,12 @@ from ._table_vectorizer import TableVectorizer from ._to_categorical import ToCategorical -_HGBT_CLASSES = ( - ensemble.HistGradientBoostingClassifier, - ensemble.HistGradientBoostingRegressor, -) -_TREE_ENSEMBLE_CLASSES = ( - ensemble.HistGradientBoostingClassifier, - ensemble.HistGradientBoostingRegressor, - ensemble.RandomForestClassifier, - ensemble.RandomForestRegressor, +_HGBT_CLASS_NAME_SUBSTRINGS = ("HistGradientBoosting",) +_TREE_ENSEMBLE_CLASS_NAME_SUBSTRINGS = ( + "HistGradientBoosting", + "RandomForest", + "XGB", + "LGBM", ) @@ -264,15 +261,24 @@ def tabular_pipeline(estimator, *, n_jobs=None): "TabICLClassifier", "TabICLRegressor", ) + is_hgbt_estimator = any( + x.lower() in estimator.__class__.__name__.lower() + for x in _HGBT_CLASS_NAME_SUBSTRINGS + ) + is_tree_ensemble_estimator = any( + x.lower() in estimator.__class__.__name__.lower() + for x in _TREE_ENSEMBLE_CLASS_NAME_SUBSTRINGS + ) + if ( - isinstance(estimator, _HGBT_CLASSES) + is_hgbt_estimator and getattr(estimator, "categorical_features", None) == "from_dtype" ): vectorizer.set_params( low_cardinality=ToCategorical(), high_cardinality=StringEncoder(), ) - elif isinstance(estimator, _TREE_ENSEMBLE_CLASSES): + elif is_tree_ensemble_estimator: vectorizer.set_params( low_cardinality=OrdinalEncoder( handle_unknown="use_encoded_value", @@ -293,7 +299,7 @@ def tabular_pipeline(estimator, *, n_jobs=None): if not is_estimator_from_tabicl: if not get_tags(estimator).input_tags.allow_nan: steps.append(SimpleImputer(add_indicator=True)) - if not isinstance(estimator, _TREE_ENSEMBLE_CLASSES): + if not is_tree_ensemble_estimator: steps.append(SquashingScaler(max_absolute_value=5)) steps.append(estimator) diff --git a/skrub/tests/test_tabular_pipeline.py b/skrub/tests/test_tabular_pipeline.py index e68bd1389..88401422c 100644 --- a/skrub/tests/test_tabular_pipeline.py +++ b/skrub/tests/test_tabular_pipeline.py @@ -20,7 +20,7 @@ def fake_get_tags(_): `if not get_tags(estimator).input_tags.allow_nan:` check for tests""" class fake_input_tags: - allow_nan = None + allow_nan = True class fake_estimator: input_tags = fake_input_tags() @@ -94,6 +94,32 @@ def test_tree_learner(): assert tv.datetime.periodic_encoding is None +def test_tree_ensemble_treatment_for_any_random_forest(monkeypatch): + IAmARandomForestEstimator = type( + "IAmARandomForestEstimator", (), {"get_params": 123, "set_params": 456} + ) + original_learner = IAmARandomForestEstimator() + monkeypatch.setattr(skrub._tabular_pipeline, "get_tags", fake_get_tags) + p = tabular_pipeline(original_learner) + tv, learner = (e for _, e in p.steps) + assert learner is original_learner + assert isinstance(tv.high_cardinality, StringEncoder) + assert isinstance(tv.low_cardinality, OrdinalEncoder) + assert tv.datetime.periodic_encoding is None + + +def test_tree_ensemble_treatment_for_xgboost(monkeypatch): + IAmXGB = type("IAmXGB", (), {"get_params": 123, "set_params": 456}) + original_learner = IAmXGB() + monkeypatch.setattr(skrub._tabular_pipeline, "get_tags", fake_get_tags) + p = tabular_pipeline(original_learner) + tv, learner = (e for _, e in p.steps) + assert learner is original_learner + assert isinstance(tv.high_cardinality, StringEncoder) + assert isinstance(tv.low_cardinality, OrdinalEncoder) + assert tv.datetime.periodic_encoding is None + + def test_from_dtype(): p = tabular_pipeline( ensemble.HistGradientBoostingRegressor(categorical_features=()) From 1a28685368c34a674a62a2e5407f437805142c4b Mon Sep 17 00:00:00 2001 From: Laurence Dyer Date: Sun, 19 Jul 2026 13:54:03 +0200 Subject: [PATCH 04/11] Add estimator type override parameters --- skrub/_tabular_pipeline.py | 28 +++++++++++++------ skrub/tests/test_tabular_pipeline.py | 42 ++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/skrub/_tabular_pipeline.py b/skrub/_tabular_pipeline.py index 09bb8239c..a076a97c4 100644 --- a/skrub/_tabular_pipeline.py +++ b/skrub/_tabular_pipeline.py @@ -19,7 +19,9 @@ ) -def tabular_pipeline(estimator, *, n_jobs=None): +def tabular_pipeline( + estimator, *, n_jobs=None, is_tree_ensemble_estimator=None, is_hgbt_estimator=None +): """Get a simple machine-learning pipeline for tabular data. Given either a scikit-learn compatible estimator or one of the special-cased strings @@ -64,6 +66,16 @@ def tabular_pipeline(estimator, *, n_jobs=None): means 1 unless in a joblib ``parallel_backend`` context. ``-1`` means using all processors. + is_tree_ensemble_estimator : bool, default=None + Set to ``True`` to override the default class-name based heuristic and enforce + tree ensemble estimator treatment. The default heuristic should catch most common + tree ensembles; this parameter can be used otherwise. + + is_hgbt_estimator : bool, default=None + Set to ``True`` to override the default class-name based heuristic and enforce + histogram-based gradient boosting model treatment. The default heuristic should + catch most common HBGTs; this parameter can be used otherwise. + Returns ------- Pipeline @@ -261,24 +273,24 @@ def tabular_pipeline(estimator, *, n_jobs=None): "TabICLClassifier", "TabICLRegressor", ) - is_hgbt_estimator = any( + _is_hgbt_estimator = any( x.lower() in estimator.__class__.__name__.lower() for x in _HGBT_CLASS_NAME_SUBSTRINGS - ) - is_tree_ensemble_estimator = any( + ) or (is_hgbt_estimator is True) + _is_tree_ensemble_estimator = any( x.lower() in estimator.__class__.__name__.lower() for x in _TREE_ENSEMBLE_CLASS_NAME_SUBSTRINGS - ) + ) or (is_tree_ensemble_estimator is True) if ( - is_hgbt_estimator + _is_hgbt_estimator and getattr(estimator, "categorical_features", None) == "from_dtype" ): vectorizer.set_params( low_cardinality=ToCategorical(), high_cardinality=StringEncoder(), ) - elif is_tree_ensemble_estimator: + elif _is_tree_ensemble_estimator: vectorizer.set_params( low_cardinality=OrdinalEncoder( handle_unknown="use_encoded_value", @@ -299,7 +311,7 @@ def tabular_pipeline(estimator, *, n_jobs=None): if not is_estimator_from_tabicl: if not get_tags(estimator).input_tags.allow_nan: steps.append(SimpleImputer(add_indicator=True)) - if not is_tree_ensemble_estimator: + if not _is_tree_ensemble_estimator: steps.append(SquashingScaler(max_absolute_value=5)) steps.append(estimator) diff --git a/skrub/tests/test_tabular_pipeline.py b/skrub/tests/test_tabular_pipeline.py index 88401422c..ef2ecfa77 100644 --- a/skrub/tests/test_tabular_pipeline.py +++ b/skrub/tests/test_tabular_pipeline.py @@ -59,6 +59,8 @@ def test_bad_learner(): def test_sklearn_incompatible_learner_fails(): + """Test that a TypeError is raised when the estimator does not have a + `set_params` attribute""" sklearn_incompatible_learner = type("", (), {"get_params": 123})() with pytest.raises( TypeError, match=".*expects a scikit-learn compatible estimator" @@ -67,6 +69,8 @@ def test_sklearn_incompatible_learner_fails(): def test_sklearn_compatible_learner_succeeds(monkeypatch): + """Test that no error is raised when the estimate have both `get_params` + and `set_params` attributes""" sklearn_compatible_learner = type("", (), {"get_params": 123, "set_params": 456})() monkeypatch.setattr(skrub._tabular_pipeline, "get_tags", fake_get_tags) _ = tabular_pipeline(sklearn_compatible_learner) @@ -95,6 +99,8 @@ def test_tree_learner(): def test_tree_ensemble_treatment_for_any_random_forest(monkeypatch): + """Test that special treatment for tree ensemble models is applied when + substring 'RandomForest' appears in estimator class name""" IAmARandomForestEstimator = type( "IAmARandomForestEstimator", (), {"get_params": 123, "set_params": 456} ) @@ -109,6 +115,8 @@ def test_tree_ensemble_treatment_for_any_random_forest(monkeypatch): def test_tree_ensemble_treatment_for_xgboost(monkeypatch): + """Test that special treatment for tree ensemble models is applied when + substring 'XGB' appears in estimator class name""" IAmXGB = type("IAmXGB", (), {"get_params": 123, "set_params": 456}) original_learner = IAmXGB() monkeypatch.setattr(skrub._tabular_pipeline, "get_tags", fake_get_tags) @@ -120,6 +128,40 @@ def test_tree_ensemble_treatment_for_xgboost(monkeypatch): assert tv.datetime.periodic_encoding is None +def test_no_tree_ensemble_treatment_for_arbitrary_name(monkeypatch): + """Test that special treatment for tree ensemble models is not applied + when no relevant substring appears in estimator class name""" + NothingToSeeHere = type( + "NothingToSeeHere", (), {"get_params": 123, "set_params": 456} + ) + original_learner = NothingToSeeHere() + monkeypatch.setattr(skrub._tabular_pipeline, "get_tags", fake_get_tags) + p = tabular_pipeline(original_learner) + tv, scaler, learner = (e for _, e in p.steps) + assert learner is original_learner + assert isinstance(tv.high_cardinality, StringEncoder) + assert isinstance(tv.low_cardinality, OneHotEncoder) + assert isinstance(scaler, SquashingScaler) + assert tv.datetime.periodic_encoding == "spline" + + +def test_tree_ensemble_treatment_when_requested(monkeypatch): + """Test that special treatment for tree ensemble models is applied when + called with `is_tree_ensemble_estimator=True`, even when no relevant substring + appears in estimator class name""" + NothingToSeeHere = type( + "NothingToSeeHere", (), {"get_params": 123, "set_params": 456} + ) + original_learner = NothingToSeeHere() + monkeypatch.setattr(skrub._tabular_pipeline, "get_tags", fake_get_tags) + p = tabular_pipeline(original_learner, is_tree_ensemble_estimator=True) + tv, learner = (e for _, e in p.steps) + assert learner is original_learner + assert isinstance(tv.high_cardinality, StringEncoder) + assert isinstance(tv.low_cardinality, OrdinalEncoder) + assert tv.datetime.periodic_encoding is None + + def test_from_dtype(): p = tabular_pipeline( ensemble.HistGradientBoostingRegressor(categorical_features=()) From 253a989183fe3581cde9c1d36c188e0e97e0ac34 Mon Sep 17 00:00:00 2001 From: Laurence Dyer Date: Sun, 19 Jul 2026 14:06:43 +0200 Subject: [PATCH 05/11] Applied minor updates to documentation for tabular_pipeline --- doc/modules/default_wrangling/tabular_pipeline.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/modules/default_wrangling/tabular_pipeline.rst b/doc/modules/default_wrangling/tabular_pipeline.rst index d2b3067eb..3e0bee932 100644 --- a/doc/modules/default_wrangling/tabular_pipeline.rst +++ b/doc/modules/default_wrangling/tabular_pipeline.rst @@ -15,7 +15,7 @@ Building robust ML baselines with |tabular_pipeline| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -The |tabular_pipeline| is a function that, given a scikit-learn estimator, +The |tabular_pipeline| is a function that, given a scikit-learn compatible estimator, returns a full scikit-learn |Pipeline| that contains a |TableVectorizer| followed by the given estimator. If the estimator is a linear model (e.g., ``Ridge``, ``LogisticRegression``), @@ -50,8 +50,8 @@ problems, but may not beat properly tuned ad-hoc pipelines. :widths: 25 25 25 25 * - Parameter - - ``RandomForest`` models - - ``HistGradientBoosting`` models + - Tree ensemble models (e.g. ``RandomForest``) + - ``HistGradientBoosting`` models - Linear models and others * - Low-cardinality encoder - :class:`~sklearn.preprocessing.OrdinalEncoder` From 083cfe98e3350474c2b2f67584415dfed18f5d70 Mon Sep 17 00:00:00 2001 From: Laurence Dyer Date: Sun, 19 Jul 2026 14:44:37 +0200 Subject: [PATCH 06/11] Changelog --- CHANGES.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index c5284da80..334095053 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -43,6 +43,10 @@ Changes :pr:`2222` by :user:`Ashwin V. Mohanan `, with guidance from :user:`Jérôme Dockès `. +- Made the following changes to :func:`tabular_pipeline`: + - Estimators are no longer required to inherit from :class:`sklearn.BaseEstimator`. Instead, scikit-learn compatibility check is based on presence of `get_params` and `set_params` attributes. + - Requirement for special treatment for tree ensemble/HGBT models is determined based on class name substring matching, rather than exact type matching. + - Parameters are provided to override default heuristics and enforce special treatment for tree ensemble/HGBT models. Bugfixes -------- From f87d75a4370357a8de389921698f33681fabf272 Mon Sep 17 00:00:00 2001 From: Laurence Dyer Date: Sun, 19 Jul 2026 15:10:27 +0200 Subject: [PATCH 07/11] Add PR number to changelog --- CHANGES.rst | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 334095053..5868af372 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -44,9 +44,15 @@ Changes :user:`Jérôme Dockès `. - Made the following changes to :func:`tabular_pipeline`: - - Estimators are no longer required to inherit from :class:`sklearn.BaseEstimator`. Instead, scikit-learn compatibility check is based on presence of `get_params` and `set_params` attributes. - - Requirement for special treatment for tree ensemble/HGBT models is determined based on class name substring matching, rather than exact type matching. - - Parameters are provided to override default heuristics and enforce special treatment for tree ensemble/HGBT models. + - Estimators are no longer required to inherit from :class:`sklearn.BaseEstimator`. + Instead, scikit-learn compatibility check is based on presence of `get_params` + and `set_params` attributes. + - Requirement for special treatment for tree ensemble/HGBT models is determined + based on class name substring matching, rather than exact type matching. + - Parameters are provided to override default heuristics and enforce special + treatment for tree ensemble/HGBT models. + + :pr:`2225` by :user:`Laurence Dyer `. Bugfixes -------- From 1dde1b319fd30c9c4e27ddeb578906c2dee9178d Mon Sep 17 00:00:00 2001 From: Laurence Dyer Date: Wed, 29 Jul 2026 08:06:35 +0100 Subject: [PATCH 08/11] Undo estimator type override parameter addition --- skrub/_tabular_pipeline.py | 28 ++++++------------- skrub/tests/test_tabular_pipeline.py | 42 ---------------------------- 2 files changed, 8 insertions(+), 62 deletions(-) diff --git a/skrub/_tabular_pipeline.py b/skrub/_tabular_pipeline.py index a076a97c4..09bb8239c 100644 --- a/skrub/_tabular_pipeline.py +++ b/skrub/_tabular_pipeline.py @@ -19,9 +19,7 @@ ) -def tabular_pipeline( - estimator, *, n_jobs=None, is_tree_ensemble_estimator=None, is_hgbt_estimator=None -): +def tabular_pipeline(estimator, *, n_jobs=None): """Get a simple machine-learning pipeline for tabular data. Given either a scikit-learn compatible estimator or one of the special-cased strings @@ -66,16 +64,6 @@ def tabular_pipeline( means 1 unless in a joblib ``parallel_backend`` context. ``-1`` means using all processors. - is_tree_ensemble_estimator : bool, default=None - Set to ``True`` to override the default class-name based heuristic and enforce - tree ensemble estimator treatment. The default heuristic should catch most common - tree ensembles; this parameter can be used otherwise. - - is_hgbt_estimator : bool, default=None - Set to ``True`` to override the default class-name based heuristic and enforce - histogram-based gradient boosting model treatment. The default heuristic should - catch most common HBGTs; this parameter can be used otherwise. - Returns ------- Pipeline @@ -273,24 +261,24 @@ def tabular_pipeline( "TabICLClassifier", "TabICLRegressor", ) - _is_hgbt_estimator = any( + is_hgbt_estimator = any( x.lower() in estimator.__class__.__name__.lower() for x in _HGBT_CLASS_NAME_SUBSTRINGS - ) or (is_hgbt_estimator is True) - _is_tree_ensemble_estimator = any( + ) + is_tree_ensemble_estimator = any( x.lower() in estimator.__class__.__name__.lower() for x in _TREE_ENSEMBLE_CLASS_NAME_SUBSTRINGS - ) or (is_tree_ensemble_estimator is True) + ) if ( - _is_hgbt_estimator + is_hgbt_estimator and getattr(estimator, "categorical_features", None) == "from_dtype" ): vectorizer.set_params( low_cardinality=ToCategorical(), high_cardinality=StringEncoder(), ) - elif _is_tree_ensemble_estimator: + elif is_tree_ensemble_estimator: vectorizer.set_params( low_cardinality=OrdinalEncoder( handle_unknown="use_encoded_value", @@ -311,7 +299,7 @@ def tabular_pipeline( if not is_estimator_from_tabicl: if not get_tags(estimator).input_tags.allow_nan: steps.append(SimpleImputer(add_indicator=True)) - if not _is_tree_ensemble_estimator: + if not is_tree_ensemble_estimator: steps.append(SquashingScaler(max_absolute_value=5)) steps.append(estimator) diff --git a/skrub/tests/test_tabular_pipeline.py b/skrub/tests/test_tabular_pipeline.py index ef2ecfa77..88401422c 100644 --- a/skrub/tests/test_tabular_pipeline.py +++ b/skrub/tests/test_tabular_pipeline.py @@ -59,8 +59,6 @@ def test_bad_learner(): def test_sklearn_incompatible_learner_fails(): - """Test that a TypeError is raised when the estimator does not have a - `set_params` attribute""" sklearn_incompatible_learner = type("", (), {"get_params": 123})() with pytest.raises( TypeError, match=".*expects a scikit-learn compatible estimator" @@ -69,8 +67,6 @@ def test_sklearn_incompatible_learner_fails(): def test_sklearn_compatible_learner_succeeds(monkeypatch): - """Test that no error is raised when the estimate have both `get_params` - and `set_params` attributes""" sklearn_compatible_learner = type("", (), {"get_params": 123, "set_params": 456})() monkeypatch.setattr(skrub._tabular_pipeline, "get_tags", fake_get_tags) _ = tabular_pipeline(sklearn_compatible_learner) @@ -99,8 +95,6 @@ def test_tree_learner(): def test_tree_ensemble_treatment_for_any_random_forest(monkeypatch): - """Test that special treatment for tree ensemble models is applied when - substring 'RandomForest' appears in estimator class name""" IAmARandomForestEstimator = type( "IAmARandomForestEstimator", (), {"get_params": 123, "set_params": 456} ) @@ -115,8 +109,6 @@ def test_tree_ensemble_treatment_for_any_random_forest(monkeypatch): def test_tree_ensemble_treatment_for_xgboost(monkeypatch): - """Test that special treatment for tree ensemble models is applied when - substring 'XGB' appears in estimator class name""" IAmXGB = type("IAmXGB", (), {"get_params": 123, "set_params": 456}) original_learner = IAmXGB() monkeypatch.setattr(skrub._tabular_pipeline, "get_tags", fake_get_tags) @@ -128,40 +120,6 @@ def test_tree_ensemble_treatment_for_xgboost(monkeypatch): assert tv.datetime.periodic_encoding is None -def test_no_tree_ensemble_treatment_for_arbitrary_name(monkeypatch): - """Test that special treatment for tree ensemble models is not applied - when no relevant substring appears in estimator class name""" - NothingToSeeHere = type( - "NothingToSeeHere", (), {"get_params": 123, "set_params": 456} - ) - original_learner = NothingToSeeHere() - monkeypatch.setattr(skrub._tabular_pipeline, "get_tags", fake_get_tags) - p = tabular_pipeline(original_learner) - tv, scaler, learner = (e for _, e in p.steps) - assert learner is original_learner - assert isinstance(tv.high_cardinality, StringEncoder) - assert isinstance(tv.low_cardinality, OneHotEncoder) - assert isinstance(scaler, SquashingScaler) - assert tv.datetime.periodic_encoding == "spline" - - -def test_tree_ensemble_treatment_when_requested(monkeypatch): - """Test that special treatment for tree ensemble models is applied when - called with `is_tree_ensemble_estimator=True`, even when no relevant substring - appears in estimator class name""" - NothingToSeeHere = type( - "NothingToSeeHere", (), {"get_params": 123, "set_params": 456} - ) - original_learner = NothingToSeeHere() - monkeypatch.setattr(skrub._tabular_pipeline, "get_tags", fake_get_tags) - p = tabular_pipeline(original_learner, is_tree_ensemble_estimator=True) - tv, learner = (e for _, e in p.steps) - assert learner is original_learner - assert isinstance(tv.high_cardinality, StringEncoder) - assert isinstance(tv.low_cardinality, OrdinalEncoder) - assert tv.datetime.periodic_encoding is None - - def test_from_dtype(): p = tabular_pipeline( ensemble.HistGradientBoostingRegressor(categorical_features=()) From 50d4a7752c4fa9ff4c830df39e659c3aacc95a21 Mon Sep 17 00:00:00 2001 From: Laurence Dyer Date: Wed, 29 Jul 2026 08:07:49 +0100 Subject: [PATCH 09/11] Reinstate docstrings for test functions from previous commit --- skrub/tests/test_tabular_pipeline.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/skrub/tests/test_tabular_pipeline.py b/skrub/tests/test_tabular_pipeline.py index 88401422c..f39b82d9b 100644 --- a/skrub/tests/test_tabular_pipeline.py +++ b/skrub/tests/test_tabular_pipeline.py @@ -59,6 +59,8 @@ def test_bad_learner(): def test_sklearn_incompatible_learner_fails(): + """Test that a TypeError is raised when the estimator does not have a + `set_params` attribute""" sklearn_incompatible_learner = type("", (), {"get_params": 123})() with pytest.raises( TypeError, match=".*expects a scikit-learn compatible estimator" @@ -67,6 +69,8 @@ def test_sklearn_incompatible_learner_fails(): def test_sklearn_compatible_learner_succeeds(monkeypatch): + """Test that no error is raised when the estimate have both `get_params` + and `set_params` attributes""" sklearn_compatible_learner = type("", (), {"get_params": 123, "set_params": 456})() monkeypatch.setattr(skrub._tabular_pipeline, "get_tags", fake_get_tags) _ = tabular_pipeline(sklearn_compatible_learner) @@ -95,6 +99,8 @@ def test_tree_learner(): def test_tree_ensemble_treatment_for_any_random_forest(monkeypatch): + """Test that special treatment for tree ensemble models is applied when + substring 'RandomForest' appears in estimator class name""" IAmARandomForestEstimator = type( "IAmARandomForestEstimator", (), {"get_params": 123, "set_params": 456} ) @@ -109,6 +115,8 @@ def test_tree_ensemble_treatment_for_any_random_forest(monkeypatch): def test_tree_ensemble_treatment_for_xgboost(monkeypatch): + """Test that special treatment for tree ensemble models is applied when + substring 'XGB' appears in estimator class name""" IAmXGB = type("IAmXGB", (), {"get_params": 123, "set_params": 456}) original_learner = IAmXGB() monkeypatch.setattr(skrub._tabular_pipeline, "get_tags", fake_get_tags) From 46a1a76c0583be4fb217be2b14ff214b2c1adc80 Mon Sep 17 00:00:00 2001 From: Laurence Dyer Date: Wed, 29 Jul 2026 09:45:20 +0100 Subject: [PATCH 10/11] Apply feedback from PR #2225 comments --- skrub/_tabular_pipeline.py | 30 +++++-- skrub/tests/test_tabular_pipeline.py | 113 ++++++++++++++++++--------- 2 files changed, 101 insertions(+), 42 deletions(-) diff --git a/skrub/_tabular_pipeline.py b/skrub/_tabular_pipeline.py index 09bb8239c..b8aa9f495 100644 --- a/skrub/_tabular_pipeline.py +++ b/skrub/_tabular_pipeline.py @@ -19,6 +19,21 @@ ) +def is_scikit_learn_compatible_estimator(estimator) -> tuple[bool, str | None]: + """Determine whether a candidate object is a valid scikit learn-compatiable + estimator. Return True or False, plus an optional string stating the failure + reason.""" + + REQUIRED_METHOD_NAMES = ["get_params", "set_params", "fit", "predict"] + for method_name in REQUIRED_METHOD_NAMES: + if not hasattr(estimator, method_name): + return False, f"The estimator must have a {method_name} attribute." + for method_name in REQUIRED_METHOD_NAMES: + if not callable(getattr(estimator, method_name)): + return False, f"The estimator's {method_name} attribute must be callable." + return True, None + + def tabular_pipeline(estimator, *, n_jobs=None): """Get a simple machine-learning pipeline for tabular data. @@ -247,14 +262,13 @@ def tabular_pipeline(estimator, *, n_jobs=None): " estimator rather than the class itself." ) - is_scikit_learn_compatible = hasattr(estimator, "get_params") and hasattr( - estimator, "set_params" + is_scikit_learn_compatible, incompatable_reason = ( + is_scikit_learn_compatible_estimator(estimator) ) if not is_scikit_learn_compatible: raise TypeError( "tabular_pipeline expects a scikit-learn compatible estimator as its first" - " argument. The estimator object must have 'get_params' and 'set_params'" - " attributes." + " argument. " + incompatable_reason ) is_estimator_from_tabicl = estimator.__class__.__name__ in ( @@ -297,8 +311,14 @@ def tabular_pipeline(estimator, *, n_jobs=None): vectorizer.set_params(datetime=DatetimeEncoder(periodic_encoding="spline")) steps = [vectorizer] if not is_estimator_from_tabicl: - if not get_tags(estimator).input_tags.allow_nan: + # Check whether we need imputation + try: + allow_nan = get_tags(estimator).input_tags.allow_nan + except AttributeError: + allow_nan = False + if not allow_nan: steps.append(SimpleImputer(add_indicator=True)) + # Check whether we need squashing scalar if not is_tree_ensemble_estimator: steps.append(SquashingScaler(max_absolute_value=5)) diff --git a/skrub/tests/test_tabular_pipeline.py b/skrub/tests/test_tabular_pipeline.py index f39b82d9b..1956cef4b 100644 --- a/skrub/tests/test_tabular_pipeline.py +++ b/skrub/tests/test_tabular_pipeline.py @@ -1,11 +1,11 @@ +import numpy as np +import pandas as pd import pytest from sklearn import ensemble -from sklearn.base import BaseEstimator from sklearn.impute import SimpleImputer from sklearn.linear_model import Ridge from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder -import skrub._tabular_pipeline from skrub import ( SquashingScaler, StringEncoder, @@ -15,19 +15,6 @@ ) -def fake_get_tags(_): - """Allows dummy estimators to get past - `if not get_tags(estimator).input_tags.allow_nan:` check for tests""" - - class fake_input_tags: - allow_nan = True - - class fake_estimator: - input_tags = fake_input_tags() - - return fake_estimator() - - @pytest.mark.parametrize( "learner_kind", ["regressor", "regression", "classifier", "classification"] ) @@ -58,22 +45,70 @@ def test_bad_learner(): tabular_pipeline(object()) -def test_sklearn_incompatible_learner_fails(): - """Test that a TypeError is raised when the estimator does not have a - `set_params` attribute""" - sklearn_incompatible_learner = type("", (), {"get_params": 123})() +def test_missing_required_attribute(): + """Test that a TypeError is raised when the estimator does not have one of the + attributes required of a scikit learn-compatible estimator""" + + class MissingSetParams: + def fit(self, X, y=None): + return self + + def predict(self, X): + return np.zeros(X.shape[0]) + + def get_params(self): + return {} + with pytest.raises( - TypeError, match=".*expects a scikit-learn compatible estimator" + TypeError, match=".*expects a scikit-learn compatible estimator.*set_params" + ): + tabular_pipeline(MissingSetParams()) + + +def test_required_attribute_is_not_callable(): + """Test that a TypeError is raised when the estimator has all of the required + attributes, but one of them is not callable""" + + class PredictNotCallable: + def fit(self, X, y=None): + return self + + predict = 1 + + def get_params(self): + return {} + + def set_params(self, **params): + return self + + with pytest.raises( + TypeError, match=".*expects a scikit-learn compatible estimator.*predict" ): - tabular_pipeline(sklearn_incompatible_learner) + tabular_pipeline(PredictNotCallable()) + + +class Regressor: + """Dummy regressor used for tests""" + + def fit(self, X, y=None): + return self + + def predict(self, X): + return np.zeros(X.shape[0]) + + def get_params(self): + return {} + def set_params(self, **params): + return self -def test_sklearn_compatible_learner_succeeds(monkeypatch): + +def test_sklearn_compatible_learner_returns_correct_pipeline(): """Test that no error is raised when the estimate have both `get_params` and `set_params` attributes""" - sklearn_compatible_learner = type("", (), {"get_params": 123, "set_params": 456})() - monkeypatch.setattr(skrub._tabular_pipeline, "get_tags", fake_get_tags) - _ = tabular_pipeline(sklearn_compatible_learner) + pipeline = tabular_pipeline(Regressor()) + X = pd.DataFrame({"feature": [1, 2, 3]}) + pipeline.fit(X) def test_linear_learner(): @@ -98,30 +133,34 @@ def test_tree_learner(): assert tv.datetime.periodic_encoding is None -def test_tree_ensemble_treatment_for_any_random_forest(monkeypatch): +def test_tree_ensemble_treatment_for_any_random_forest(): """Test that special treatment for tree ensemble models is applied when substring 'RandomForest' appears in estimator class name""" - IAmARandomForestEstimator = type( - "IAmARandomForestEstimator", (), {"get_params": 123, "set_params": 456} - ) + + class IAmARandomForestEstimator(Regressor): + pass + original_learner = IAmARandomForestEstimator() - monkeypatch.setattr(skrub._tabular_pipeline, "get_tags", fake_get_tags) p = tabular_pipeline(original_learner) - tv, learner = (e for _, e in p.steps) + _, tv = p.steps[0] + _, learner = p.steps[-1] assert learner is original_learner assert isinstance(tv.high_cardinality, StringEncoder) assert isinstance(tv.low_cardinality, OrdinalEncoder) assert tv.datetime.periodic_encoding is None -def test_tree_ensemble_treatment_for_xgboost(monkeypatch): +def test_tree_ensemble_treatment_for_xgboost(): """Test that special treatment for tree ensemble models is applied when substring 'XGB' appears in estimator class name""" - IAmXGB = type("IAmXGB", (), {"get_params": 123, "set_params": 456}) + + class IAmXGB(Regressor): + pass + original_learner = IAmXGB() - monkeypatch.setattr(skrub._tabular_pipeline, "get_tags", fake_get_tags) p = tabular_pipeline(original_learner) - tv, learner = (e for _, e in p.steps) + _, tv = p.steps[0] + _, learner = p.steps[-1] assert learner is original_learner assert isinstance(tv.high_cardinality, StringEncoder) assert isinstance(tv.low_cardinality, OrdinalEncoder) @@ -139,13 +178,13 @@ def test_from_dtype(): assert isinstance(p.named_steps["tablevectorizer"].low_cardinality, ToCategorical) -class TabICLClassifier(BaseEstimator): +class TabICLClassifier(Regressor): """Dummy class which pretends to be `tabicl.TabICLClassifier`""" pass -class TabICLRegressor(BaseEstimator): +class TabICLRegressor(Regressor): """Dummy class which pretends to be `tabicl.TabICLRegressor`""" pass From 7ca069c24c468357e24928e855ab549fb7636596 Mon Sep 17 00:00:00 2001 From: Laurence Dyer Date: Wed, 29 Jul 2026 09:47:37 +0100 Subject: [PATCH 11/11] Update CHANGES.rst --- CHANGES.rst | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 9418acc23..7b46b37cd 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -49,12 +49,10 @@ Changes - Made the following changes to :func:`tabular_pipeline`: - Estimators are no longer required to inherit from :class:`sklearn.BaseEstimator`. - Instead, scikit-learn compatibility check is based on presence of `get_params` - and `set_params` attributes. + Instead, scikit-learn compatibility check is based on presence of the methods: + `get_params`, `set_params`, `fit`, `predict`. - Requirement for special treatment for tree ensemble/HGBT models is determined based on class name substring matching, rather than exact type matching. - - Parameters are provided to override default heuristics and enforce special - treatment for tree ensemble/HGBT models. :pr:`2225` by :user:`Laurence Dyer `.