Repository: onnx/sklearn-onnx

Issue:
Remove ZipMap from every classifier converter
ZipMap is highly inefficient in its current implementation (see Issue https://github.com/onnx/onnx/issues/2153). It should be removed and replaced by a constant storing the labels.


Discussion:

Resolving patch:
diff --git a/docs/examples/plot_convert_zipmap.py b/docs/examples/plot_convert_zipmap.py
new file mode 100644
--- /dev/null
+++ b/docs/examples/plot_convert_zipmap.py
@@ -0,0 +1,103 @@
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License.
+
+"""
+.. _l-rf-example-zipmap:
+
+Probabilities as a vector or as a ZipMap
+========================================
+
+A classifier usually returns a matrix of probabilities.
+By default, *sklearn-onnx* converts that matrix
+into a list of dictionaries where each probabilies is mapped
+to its class id or name. That mechanism retains the class names.
+This conversion increases the prediction time and is not
+always needed. Let's see how to deactivate this behaviour
+on the Iris example.
+
+.. contents::
+    :local:
+
+Train a model and convert it.
++++++++++++++++++++++++++++++
+
+"""
+from timeit import repeat
+import numpy
+import sklearn
+from sklearn.datasets import load_iris
+from sklearn.model_selection import train_test_split
+import onnxruntime as rt
+import onnx
+import skl2onnx
+from skl2onnx.common.data_types import FloatTensorType
+from skl2onnx import convert_sklearn
+from sklearn.linear_model import LogisticRegression
+
+iris = load_iris()
+X, y = iris.data, iris.target
+X_train, X_test, y_train, y_test = train_test_split(X, y)
+clr = LogisticRegression()
+clr.fit(X_train, y_train)
+print(clr)
+
+initial_type = [('float_input', FloatTensorType([None, 4]))]
+onx = convert_sklearn(clr, initial_types=initial_type)
+
+############################
+# Output type
+# +++++++++++
+#
+# Let's confirm the output type of the probabilities
+# is a list of dictionaries with onnxruntime.
+
+sess = rt.InferenceSession(onx.SerializeToString())
+res = sess.run(None, {'float_input': X_test.astype(numpy.float32)})
+print(res[1][:2])
+print("probabilities type:", type(res[1]))
+print("type for the first observations:", type(res[1][0]))
+
+###################################
+# Without ZipMap
+# ++++++++++++++
+#
+# Let's remove the ZipMap operator.
+
+initial_type = [('float_input', FloatTensorType([None, 4]))]
+options = {id(clr): {'zipmap': False}}
+onx2 = convert_sklearn(clr, initial_types=initial_type, options=options)
+
+sess2 = rt.InferenceSession(onx2.SerializeToString())
+res2 = sess2.run(None, {'float_input': X_test.astype(numpy.float32)})
+print(res2[1][:2])
+print("probabilities type:", type(res2[1]))
+print("type for the first observations:", type(res2[1][0]))
+
+###################################
+# Let's compare prediction time
+# +++++++++++++++++++++++++++++
+
+X32 = X_test.astype(numpy.float32)
+
+print("Time with ZipMap:")
+print(repeat(lambda: sess.run(None, {'float_input': X32}),
+             number=100, repeat=3))
+
+print("Time without ZipMap:")
+print(repeat(lambda: sess2.run(None, {'float_input': X32}),
+             number=100, repeat=3))
+
+# The prediction is much faster on this example.
+# The optimisation is even faster when the classes
+# are described with strings and not integers
+# as the final result (list of dictionaries) may copy
+# many times the same information with onnxruntime.
+
+#################################
+# **Versions used for this example**
+
+print("numpy:", numpy.__version__)
+print("scikit-learn:", sklearn.__version__)
+print("onnx: ", onnx.__version__)
+print("onnxruntime: ", rt.__version__)
+print("skl2onnx: ", skl2onnx.__version__)
diff --git a/skl2onnx/_parse.py b/skl2onnx/_parse.py
--- a/skl2onnx/_parse.py
+++ b/skl2onnx/_parse.py
@@ -319,6 +319,9 @@ def _parse_sklearn_classifier(scope, model, inputs, custom_parsers=None):
             scope, model, inputs, custom_parsers=custom_parsers)
     if model.__class__ in [NuSVC, SVC] and not model.probability:
         return probability_tensor
+    options = scope.get_options(model, dict(zipmap=True))
+    if not options['zipmap']:
+        return probability_tensor
     this_operator = scope.declare_local_operator('SklearnZipMap')
     this_operator.inputs = probability_tensor
     classes = model.classes_
diff --git a/skl2onnx/operator_converters/support_vector_machines.py b/skl2onnx/operator_converters/support_vector_machines.py
--- a/skl2onnx/operator_converters/support_vector_machines.py
+++ b/skl2onnx/operator_converters/support_vector_machines.py
@@ -76,17 +76,14 @@ def convert_sklearn_svm(scope, operator, container):
         label_name = operator.outputs[0].full_name
         probability_tensor_name = operator.outputs[1].full_name
 
-        zipmap_attrs = {'name': scope.get_unique_operator_name('ZipMap')}
         if all(isinstance(i, (numbers.Real, bool, np.bool_))
                 for i in op.classes_):
             labels = [int(i) for i in op.classes_]
             svm_attrs['classlabels_ints'] = labels
-            zipmap_attrs['classlabels_int64s'] = labels
         elif all(isinstance(i, (six.text_type, six.string_types))
                  for i in op.classes_):
             labels = [str(i) for i in op.classes_]
             svm_attrs['classlabels_strings'] = labels
-            zipmap_attrs['classlabels_strings'] = labels
         else:
             raise RuntimeError("Invalid class label type '%s'." % op.classes_)
 


Test patch:
diff --git a/tests/test_sklearn_glm_classifier_converter.py b/tests/test_sklearn_glm_classifier_converter.py
--- a/tests/test_sklearn_glm_classifier_converter.py
+++ b/tests/test_sklearn_glm_classifier_converter.py
@@ -325,6 +325,41 @@ def test_model_ridge_classifier_binary(self):
                           " <= StrictVersion('0.2.1')",
         )
 
+    @unittest.skipIf(not onnx_built_with_ml(),
+                     reason="Requires ONNX-ML extension.")
+    def test_model_ridge_classifier_binary_nozipmap(self):
+        model, X = fit_classification_model(
+            linear_model.LogisticRegression(), 2)
+
+        model_onnx = convert_sklearn(
+            model, "binary ridge classifier",
+            [("input", FloatTensorType([None, X.shape[1]]))])
+        assert 'zipmap' in str(model_onnx).lower()
+
+        options = {id(model): {'zipmap': True}}
+        model_onnx = convert_sklearn(
+            model, "binary ridge classifier",
+            [("input", FloatTensorType([None, X.shape[1]]))],
+            options=options)
+        assert 'zipmap' in str(model_onnx).lower()
+
+        options = {id(model): {'zipmap': False}}
+        model_onnx = convert_sklearn(
+            model, "binary ridge classifier",
+            [("input", FloatTensorType([None, X.shape[1]]))],
+            options=options)
+        assert 'zipmap' not in str(model_onnx).lower()
+
+        self.assertIsNotNone(model_onnx)
+        dump_data_and_model(
+            X,
+            model,
+            model_onnx,
+            basename="SklearnRidgeClassifierNZMBin",
+            allow_failure="StrictVersion(onnxruntime.__version__)"
+                          " <= StrictVersion('0.2.1')",
+        )
+
     @unittest.skipIf(not onnx_built_with_ml(),
                      reason="Requires ONNX-ML extension.")
     def test_model_ridge_classifier_multi_class(self):