Repository: onnx/sklearn-onnx
Pull request #1156 (2025-01-22 15:53:30)

Test SelectKBest + StandardScaler pipeline
When `SelectKBest` is used before a `StandardScaler` inside a pipeline with $k$ smaller than the number of features, the conversion fails:

``` python
model = make_pipeline(SelectKBest(k=3), StandardScaler(), LogisticRegression())
```

This PR fixes this problem by using the `concat` shape calculator for selectors and adding a correct one instead.

Pierre-Bartet: @xadupre I fixed the issue (just for `SelectKBest`, not the other selectors), but before I go further I want to be sure I understand properly what was the initial intention of the code I changed.

All selectors such as `SelectKBest` use the `concat` shape calculator instead of something closer to the `array_feature_extractor` one. This look like a mistake to me, so I unregistered the selectors from the `concat` shape calculator, and use a slight variation of the `array_feature_extractor` one instead.

What bothers me is that in the end the `calculate_sklearn_array_feature_extractor` is never called during the test I added (and never was).

So my two questions are:

1. Was the use of the `concat` shape calculator for the selectors just a mistake, or was there some deeper intention here?
2. Is it intended that with or without my fix, `calculate_sklearn_array_feature_extractor` is not used when converting `SelectKBest` into an `array_feature_extractor`?
xadupre: You should remove the register and run ``tests/test_sklearn_feature_selection_converters.py`` to see which unit tests require it. shape_calculator is a kind of legacy but it is used to compute the output shape and type of a model without converting it so that the conversion of the next estimator can start before the consersion of the previous is done. In practice, this never happens.
Pierre-Bartet: No `tests/test_sklearn_feature_selection_converters.py` test fail when doing that, but other tests fail elsewhere.
So from what I understand, the answer to question 2. is "Yes".
I guess this is also the case for question 1, so I'll just proceed and do the same for all selectors.
Pierre-Bartet: @xadupre done

Patch:
diff --git a/skl2onnx/shape_calculators/__init__.py b/skl2onnx/shape_calculators/__init__.py
index ab5556b..71f9042 100644
--- a/skl2onnx/shape_calculators/__init__.py
+++ b/skl2onnx/shape_calculators/__init__.py
@@ -10,6 +10,7 @@ from . import concat
 from . import cross_decomposition
 from . import dict_vectorizer
 from . import ensemble_shapes
+from . import feature_selection
 from . import feature_hasher
 from . import flatten
 from . import function_transformer
@@ -63,6 +64,7 @@ __all__ = [
     dict_vectorizer,
     ensemble_shapes,
     feature_hasher,
+    feature_selection,
     flatten,
     function_transformer,
     gaussian_process,
diff --git a/skl2onnx/shape_calculators/concat.py b/skl2onnx/shape_calculators/concat.py
index afdb6ab..9561c03 100644
--- a/skl2onnx/shape_calculators/concat.py
+++ b/skl2onnx/shape_calculators/concat.py
@@ -93,13 +93,3 @@ def calculate_sklearn_concat(operator):
 
 
 register_shape_calculator("SklearnConcat", calculate_sklearn_concat)
-register_shape_calculator("SklearnGenericUnivariateSelect", calculate_sklearn_concat)
-register_shape_calculator("SklearnRFE", calculate_sklearn_concat)
-register_shape_calculator("SklearnRFECV", calculate_sklearn_concat)
-register_shape_calculator("SklearnSelectFdr", calculate_sklearn_concat)
-register_shape_calculator("SklearnSelectFpr", calculate_sklearn_concat)
-register_shape_calculator("SklearnSelectFromModel", calculate_sklearn_concat)
-register_shape_calculator("SklearnSelectFwe", calculate_sklearn_concat)
-register_shape_calculator("SklearnSelectKBest", calculate_sklearn_concat)
-register_shape_calculator("SklearnSelectPercentile", calculate_sklearn_concat)
-register_shape_calculator("SklearnVarianceThreshold", calculate_sklearn_concat)
diff --git a/skl2onnx/shape_calculators/feature_selection.py b/skl2onnx/shape_calculators/feature_selection.py
new file mode 100644
index 0000000..8c11648
--- /dev/null
+++ b/skl2onnx/shape_calculators/feature_selection.py
@@ -0,0 +1,25 @@
+# SPDX-License-Identifier: Apache-2.0
+
+
+from ..common._registration import register_shape_calculator
+from ..common.utils import check_input_and_output_numbers
+
+
+def calculate_sklearn_select(operator):
+    check_input_and_output_numbers(operator, output_count_range=1)
+    i = operator.inputs[0]
+    N = i.get_first_dimension()
+    C = operator.raw_operator.get_support().sum()
+    operator.outputs[0].type = i.type.__class__([N, C])
+
+
+register_shape_calculator("SklearnGenericUnivariateSelect", calculate_sklearn_select)
+register_shape_calculator("SklearnRFE", calculate_sklearn_select)
+register_shape_calculator("SklearnRFECV", calculate_sklearn_select)
+register_shape_calculator("SklearnSelectFdr", calculate_sklearn_select)
+register_shape_calculator("SklearnSelectFpr", calculate_sklearn_select)
+register_shape_calculator("SklearnSelectFromModel", calculate_sklearn_select)
+register_shape_calculator("SklearnSelectFwe", calculate_sklearn_select)
+register_shape_calculator("SklearnSelectKBest", calculate_sklearn_select)
+register_shape_calculator("SklearnSelectPercentile", calculate_sklearn_select)
+register_shape_calculator("SklearnVarianceThreshold", calculate_sklearn_select)


Test patch:
diff --git a/tests/test_sklearn_feature_selection_converters.py b/tests/test_sklearn_feature_selection_converters.py
index 1feaa11..7ede714 100644
--- a/tests/test_sklearn_feature_selection_converters.py
+++ b/tests/test_sklearn_feature_selection_converters.py
@@ -21,6 +21,9 @@ from sklearn.feature_selection import (
     SelectPercentile,
     VarianceThreshold,
 )
+from sklearn.pipeline import make_pipeline
+from sklearn.linear_model import LogisticRegression
+from sklearn.preprocessing import StandardScaler
 from sklearn.svm import SVR
 from skl2onnx import convert_sklearn
 from skl2onnx.common.data_types import Int64TensorType, FloatTensorType
@@ -30,11 +33,33 @@ from test_utils import dump_data_and_model, TARGET_OPSET
 class TestSklearnFeatureSelectionConverters(unittest.TestCase):
     def test_generic_univariate_select_int(self):
         model = GenericUnivariateSelect()
+
         X = np.array(
             [[1, 2, 3, 1], [0, 3, 1, 4], [3, 5, 6, 1], [1, 2, 1, 5]], dtype=np.int64
         )
         y = np.array([0, 1, 0, 1])
         model.fit(X, y)
+
+        model_onnx = convert_sklearn(
+            model,
+            "generic univariate select",
+            [("input", Int64TensorType([None, X.shape[1]]))],
+            target_opset=TARGET_OPSET,
+        )
+        self.assertTrue(model_onnx is not None)
+        dump_data_and_model(
+            X, model, model_onnx, basename="SklearnGenericUnivariateSelect"
+        )
+
+    def test_generic_univariate_select_kbest_int(self):
+        model = GenericUnivariateSelect(mode="k_best", param=2)
+
+        X = np.array(
+            [[1, 2, 3, 1], [0, 3, 1, 4], [3, 5, 6, 1], [1, 2, 1, 5]], dtype=np.int64
+        )
+        y = np.array([0, 1, 0, 1])
+        model.fit(X, y)
+
         model_onnx = convert_sklearn(
             model,
             "generic univariate select",
@@ -340,6 +365,23 @@ class TestSklearnFeatureSelectionConverters(unittest.TestCase):
         self.assertTrue(model_onnx is not None)
         dump_data_and_model(X, model, model_onnx, basename="SklearnSelectKBest")
 
+    def test_select_k_best_scaler_logistic_regression_pipeline_float(self):
+        model = make_pipeline(SelectKBest(k=3), StandardScaler(), LogisticRegression())
+
+        X = np.array(
+            [[1, 2, 3, 1], [0, 3, 1, 4], [3, 5, 6, 1], [1, 2, 1, 5]], dtype=np.float32
+        )
+        y = np.array([0, 1, 0, 1])
+        model.fit(X, y)
+        model_onnx = convert_sklearn(
+            model,
+            "select k best",
+            [("input", FloatTensorType([None, X.shape[1]]))],
+            target_opset=TARGET_OPSET,
+        )
+        self.assertTrue(model_onnx is not None)
+        dump_data_and_model(X, model, model_onnx, basename="SklearnSelectKBest")
+
     def test_select_percentile_float(self):
         model = SelectPercentile()
         X = np.array(