Repository: onnx/sklearn-onnx

Issue:
Converter fail when the target_opset is a dictionary



Discussion:

Resolving patch:
diff --git a/skl2onnx/common/_container.py b/skl2onnx/common/_container.py
--- a/skl2onnx/common/_container.py
+++ b/skl2onnx/common/_container.py
@@ -255,7 +255,12 @@ def __init__(self, target_opset, options=None, dtype=None,
         self.node_domain_version_pair_sets = set()
         # The targeted ONNX operator set (referred to as opset) that
         # matches the ONNX version.
-        self.target_opset = target_opset
+        if isinstance(target_opset, dict):
+            self.target_opset_all = target_opset
+            self.target_opset = target_opset.get('', None)
+        else:
+            self.target_opset = target_opset
+            self.target_opset_all = {'': target_opset}
         # Additional options given to converters.
         self.options = options
         # All registered models.
@@ -568,14 +573,16 @@ def add_node(self, op_type, inputs, outputs, op_domain='', op_version=None,
                 op_version is not None and
                 op_version > self.target_opset_any_domain(op_domain)):
             raise RuntimeError(
-                "Opset number {} is higher than targeted opset {} for "
+                "Opset number {} is higher than targeted opsets {} for "
                 "node '{}' (domain: '{}').".format(
-                    op_version, self.target_opset, node.op_type, op_domain))
+                    op_version, self.target_opset_all,
+                    node.op_type, op_domain))
 
     def target_opset_any_domain(self, domain):
-        if isinstance(self.target_opset, dict):
-            if domain in self.target_opset:
-                to = self.target_opset[domain]
+        target_opset = self.target_opset_all
+        if isinstance(target_opset, dict):
+            if domain in target_opset:
+                to = target_opset[domain]
             else:
                 to = None
             if to is None and domain == '':
diff --git a/skl2onnx/operator_converters/label_encoder.py b/skl2onnx/operator_converters/label_encoder.py
--- a/skl2onnx/operator_converters/label_encoder.py
+++ b/skl2onnx/operator_converters/label_encoder.py
@@ -21,6 +21,12 @@ def convert_sklearn_label_encoder(scope, operator, container):
         attrs['keys_strings'] = np.array([s.encode('utf-8') for s in classes])
     attrs['values_int64s'] = np.arange(len(classes))
 
+    cop = container.target_opset_any_domain('ai.onnx.ml')
+    if cop is not None and cop < 2:
+        raise RuntimeError(
+            "LabelEncoder requires at least opset 2 for domain 'ai.onnx.ml' "
+            "not {}".format(cop))
+
     container.add_node(op_type, operator.input_full_names,
                        operator.output_full_names, op_domain='ai.onnx.ml',
                        op_version=2, **attrs)
diff --git a/skl2onnx/operator_converters/text_vectoriser.py b/skl2onnx/operator_converters/text_vectoriser.py
--- a/skl2onnx/operator_converters/text_vectoriser.py
+++ b/skl2onnx/operator_converters/text_vectoriser.py
@@ -140,9 +140,14 @@ def convert_sklearn_text_vectorizer(scope, operator, container):
     ````
     
     """ # noqa
-
     op = operator.raw_operator
 
+    if (container.target_opset is not None and
+            container.target_opset < 9):
+        raise RuntimeError(
+            "Converter for '{}' only works for opset >= 9."
+            "".format(op.__class__.__name__))
+
     if op.analyzer == "char_wb":
         raise NotImplementedError(
             "CountVectorizer cannot be converted, "
@@ -248,8 +253,9 @@ def convert_sklearn_text_vectorizer(scope, operator, container):
 
         if stop_words:
             attrs['stopwords'] = list(sorted(stop_words))
+        opvs = 1 if domain == 'com.microsoft' else op_version
         container.add_node(op_type, flatten,
-                           normalized, op_version=op_version,
+                           normalized, op_version=opvs,
                            op_domain=domain, **attrs)
     else:
         normalized = operator.input_full_names


Test patch:
diff --git a/tests/test_convert.py b/tests/test_convert.py
new file mode 100644
--- /dev/null
+++ b/tests/test_convert.py
@@ -0,0 +1,132 @@
+# -------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for
+# license information.
+# --------------------------------------------------------------------------
+
+import unittest
+import numpy
+from sklearn.preprocessing import OneHotEncoder, LabelEncoder
+from sklearn.cluster import KMeans
+try:
+    from sklearn.preprocessing import KBinsDiscretizer
+except ImportError:
+    KBinsDiscretizer = None
+from sklearn.gaussian_process import GaussianProcessRegressor
+from sklearn.datasets import load_iris
+from skl2onnx import to_onnx
+from test_utils import TARGET_OPSET
+
+
+def get_domain_opset(onx):
+    domains = onx.opset_import
+    res = [{'domain': dom.domain, 'version': dom.version}
+           for dom in domains]
+    return {d['domain']: d['version'] for d in res}
+
+
+class TestConvert(unittest.TestCase):
+
+    def test_target_opset(self):
+        data = load_iris()
+        X = data.data
+        model = KMeans(n_clusters=3)
+        model.fit(X)
+        for i in range(1, TARGET_OPSET+1):
+            model_onnx = to_onnx(model, X[:1].astype(numpy.float32),
+                                 target_opset=i)
+            dom = get_domain_opset(model_onnx)
+            self.assertEqual(len(dom), 1)
+            assert dom[''] <= i
+
+    def test_target_opset_dict(self):
+        data = load_iris()
+        X = data.data
+        model = KMeans(n_clusters=3)
+        model.fit(X)
+        for i in range(1, TARGET_OPSET+1):
+            for j in (1, 2):
+                tops = {'': i, 'ai.onnx.ml': j}
+                model_onnx = to_onnx(model, X[:1].astype(numpy.float32),
+                                     target_opset=tops)
+                dom = get_domain_opset(model_onnx)
+                self.assertEqual(len(dom), 1)
+                assert dom[''] <= i
+
+    @unittest.skipIf(KBinsDiscretizer is None, "skl too old")
+    def test_target_opset_dict_kbins(self):
+        data = load_iris()
+        X = data.data
+        model = KBinsDiscretizer(encode="ordinal")
+        model.fit(X)
+        for i in range(9, TARGET_OPSET+1):
+            for j in (1, 2):
+                tops = {'': i, 'ai.onnx.ml': j}
+                model_onnx = to_onnx(model, X[:1].astype(numpy.float32),
+                                     target_opset=tops)
+                dom = get_domain_opset(model_onnx)
+                if dom != {'ai.onnx.ml': 1, '': i}:
+                    assert dom[''] <= i
+                    assert dom['ai.onnx.ml'] == 1
+                    continue
+                self.assertEqual(dom, {'ai.onnx.ml': 1, '': i})
+
+    def test_regressor(self):
+        data = load_iris()
+        X = data.data
+        y = data.target
+        model = GaussianProcessRegressor()
+        model.fit(X, y)
+        for i in range(9, TARGET_OPSET+1):
+            for j in (1, 2):
+                tops = {'': i, 'ai.onnx.ml': j}
+                model_onnx = to_onnx(model, X[:1].astype(numpy.float32),
+                                     target_opset=tops)
+                dom = get_domain_opset(model_onnx)
+                self.assertEqual(len(dom), 1)
+                self.assertIn(dom[''], (i, i-1))
+
+    def test_onehot(self):
+        try:
+            model = OneHotEncoder(categories='auto')
+        except TypeError:
+            # parameter categories added in 0.20
+            return
+        data = numpy.array([[1, 2, 3], [4, 3, 0], [0, 1, 4], [0, 5, 6]],
+                           dtype=numpy.int64)
+        model.fit(data)
+        for i in range(9, TARGET_OPSET+1):
+            for j in (1, 2):
+                tops = {'': i, 'ai.onnx.ml': j}
+                model_onnx = to_onnx(model, data[:1],
+                                     target_opset=tops)
+                dom = get_domain_opset(model_onnx)
+                self.assertEqual(len(dom), 2)
+                self.assertIn(dom[''], (i, i-1, i-2))
+                self.assertEqual(dom['ai.onnx.ml'], 1)
+
+    def test_label_encoder(self):
+        model = LabelEncoder()
+        data = numpy.array([1.2, 3.4, 5.4, 1.2],
+                           dtype=numpy.float32)
+        model.fit(data)
+        for i in range(9, TARGET_OPSET+1):
+            for j in (1, 2):
+                tops = {'': i, 'ai.onnx.ml': j}
+                try:
+                    model_onnx = to_onnx(model, data[:1],
+                                         target_opset=tops)
+                except RuntimeError as e:
+                    if j == 1:
+                        # expected
+                        continue
+                    raise e
+                if j == 1:
+                    raise AssertionError("It should fail for opset.ml == 1")
+                dom = get_domain_opset(model_onnx)
+                self.assertEqual(len(dom), 1)
+                self.assertEqual(dom['ai.onnx.ml'], 2)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tests/test_sklearn_label_encoder_converter.py b/tests/test_sklearn_label_encoder_converter.py
--- a/tests/test_sklearn_label_encoder_converter.py
+++ b/tests/test_sklearn_label_encoder_converter.py
@@ -1,7 +1,9 @@
 """Tests scikit-LabelEncoder converter"""
 
 import unittest
+from distutils.version import StrictVersion
 import numpy as np
+import onnxruntime
 from sklearn.preprocessing import LabelEncoder
 from skl2onnx import convert_sklearn
 from skl2onnx.common.data_types import (
@@ -13,6 +15,10 @@
 
 
 class TestSklearnLabelEncoderConverter(unittest.TestCase):
+
+    @unittest.skipIf(
+        StrictVersion(onnxruntime.__version__) < StrictVersion("0.3.0"),
+        reason="onnxruntime too old")
     def test_model_label_encoder(self):
         model = LabelEncoder()
         data = ["str3", "str2", "str0", "str1", "str3"]
@@ -37,6 +43,9 @@ def test_model_label_encoder(self):
             "<= StrictVersion('0.5.0')",
         )
 
+    @unittest.skipIf(
+        StrictVersion(onnxruntime.__version__) < StrictVersion("0.3.0"),
+        reason="onnxruntime too old")
     def test_model_label_encoder_float(self):
         model = LabelEncoder()
         data = np.array([1.2, 3.4, 5.4, 1.2], dtype=np.float32)
@@ -61,6 +70,9 @@ def test_model_label_encoder_float(self):
             "<= StrictVersion('0.5.0')",
         )
 
+    @unittest.skipIf(
+        StrictVersion(onnxruntime.__version__) < StrictVersion("0.3.0"),
+        reason="onnxruntime too old")
     def test_model_label_encoder_int(self):
         model = LabelEncoder()
         data = np.array([10, 3, 5, -34, 0], dtype=np.int64)
diff --git a/tests/test_sklearn_ordinal_encoder.py b/tests/test_sklearn_ordinal_encoder.py
--- a/tests/test_sklearn_ordinal_encoder.py
+++ b/tests/test_sklearn_ordinal_encoder.py
@@ -3,6 +3,7 @@
 from distutils.version import StrictVersion
 import numpy as np
 import onnx
+import onnxruntime
 from sklearn import __version__ as sklearn_version
 try:
     from sklearn.preprocessing import OrdinalEncoder
@@ -19,6 +20,10 @@
 def ordinal_encoder_support():
     # StrictVersion does not work with development versions
     vers = '.'.join(sklearn_version.split('.')[:2])
+    if StrictVersion(vers) < StrictVersion("0.20.0"):
+        return False
+    if StrictVersion(onnxruntime.__version__) < StrictVersion("0.3.0"):
+        return False
     return StrictVersion(vers) >= StrictVersion("0.20.0")
 
 
diff --git a/tests/test_sklearn_tfidf_vectorizer_converter_dataset.py b/tests/test_sklearn_tfidf_vectorizer_converter_dataset.py
--- a/tests/test_sklearn_tfidf_vectorizer_converter_dataset.py
+++ b/tests/test_sklearn_tfidf_vectorizer_converter_dataset.py
@@ -33,6 +33,9 @@ def test_tfidf_20newsgroups(self):
             allow_failure="StrictVersion(onnxruntime.__version__)"
                           " <= StrictVersion('0.4.0')")
 
+    @unittest.skipIf(
+        StrictVersion(onnx.__version__) <= StrictVersion("1.4.1"),
+        reason="Requires opset 9.")
     def test_tfidf_20newsgroups_nolowercase(self):
         data = fetch_20newsgroups()
         X, y = np.array(data.data)[:100], np.array(data.target)[:100]
diff --git a/tests/test_sklearn_tfidf_vectorizer_converter_regex.py b/tests/test_sklearn_tfidf_vectorizer_converter_regex.py
--- a/tests/test_sklearn_tfidf_vectorizer_converter_regex.py
+++ b/tests/test_sklearn_tfidf_vectorizer_converter_regex.py
@@ -8,7 +8,7 @@
 from skl2onnx import convert_sklearn
 from skl2onnx.common.data_types import StringTensorType
 import onnx
-from test_utils import dump_data_and_model
+from test_utils import dump_data_and_model, TARGET_OPSET
 
 
 class TestSklearnTfidfVectorizerRegex(unittest.TestCase):
@@ -17,8 +17,8 @@ def get_options(self):
         return {TfidfVectorizer: {"tokenexp": ""}}
 
     @unittest.skipIf(
-        StrictVersion(onnx.__version__) < StrictVersion("1.4.1"),
-        reason="Requires opset 9.")
+        StrictVersion(onnx.__version__) <= StrictVersion("1.4.1"),
+        reason="Requires opset 10.")
     def test_model_tfidf_vectorizer11(self):
         corpus = numpy.array([
             'This is the first document.',
@@ -39,8 +39,38 @@ def test_model_tfidf_vectorizer11(self):
                           "StrictVersion('0.4.0')")
 
     @unittest.skipIf(
-        StrictVersion(onnx.__version__) < StrictVersion("1.4.1"),
-        reason="Requires opset 9.")
+        StrictVersion(onnx.__version__) <= StrictVersion("1.4.1"),
+        reason="Requires opset 10.")
+    def test_model_tfidf_vectorizer11_opset(self):
+        corpus = numpy.array([
+            'This is the first document.',
+            'This document is the second document.',
+            'And this is the third one.',
+            'Is this the first document?',
+        ]).reshape((4, 1))
+        vect = TfidfVectorizer(ngram_range=(1, 1), norm=None)
+        vect.fit(corpus.ravel())
+        for opset in range(8, TARGET_OPSET + 1):
+            try:
+                model_onnx = convert_sklearn(
+                    vect, 'TfidfVectorizer',
+                    [('input', StringTensorType([1]))],
+                    options=self.get_options(), target_opset=opset)
+            except RuntimeError as e:
+                if "only works for opset" in str(e):
+                    continue
+                raise e
+            self.assertTrue(model_onnx is not None)
+            if opset >= 10:
+                name = "SklearnTfidfVectorizer11Rx%d-OneOff-SklCol" % opset
+                dump_data_and_model(
+                    corpus, vect, model_onnx, basename=name,
+                    allow_failure="StrictVersion(onnxruntime.__version__) <= "
+                                  "StrictVersion('0.4.0')")
+
+    @unittest.skipIf(
+        StrictVersion(onnx.__version__) <= StrictVersion("1.4.1"),
+        reason="Requires opset 10.")
     def test_model_tfidf_vectorizer11_word4(self):
         corpus = numpy.array([
             'This is the first document.',
@@ -62,8 +92,8 @@ def test_model_tfidf_vectorizer11_word4(self):
                           "StrictVersion('0.4.0')")
 
     @unittest.skipIf(
-        StrictVersion(onnx.__version__) < StrictVersion("1.4.1"),
-        reason="Requires opset 9.")
+        StrictVersion(onnx.__version__) <= StrictVersion("1.4.1"),
+        reason="Requires opset 10.")
     def test_model_tfidf_vectorizer11_empty_string(self):
         corpus = numpy.array([
             'This is the first document.',
@@ -85,8 +115,8 @@ def test_model_tfidf_vectorizer11_empty_string(self):
                           "<= StrictVersion('0.4.0')")
 
     @unittest.skipIf(
-        StrictVersion(onnx.__version__) < StrictVersion("1.4.1"),
-        reason="Requires opset 9.")
+        StrictVersion(onnx.__version__) <= StrictVersion("1.4.1"),
+        reason="Requires opset 10.")
     def test_model_tfidf_vectorizer11_out_vocabulary(self):
         corpus = numpy.array([
             'This is the first document.',
@@ -113,8 +143,8 @@ def test_model_tfidf_vectorizer11_out_vocabulary(self):
                           "StrictVersion('0.4.0')")
 
     @unittest.skipIf(
-        StrictVersion(onnx.__version__) < StrictVersion("1.4.1"),
-        reason="Requires opset 9.")
+        StrictVersion(onnx.__version__) <= StrictVersion("1.4.1"),
+        reason="Requires opset 10.")
     def test_model_tfidf_vectorizer22(self):
         corpus = numpy.array([
             'This is the first document.',
@@ -135,8 +165,8 @@ def test_model_tfidf_vectorizer22(self):
                           "StrictVersion('0.4.0')")
 
     @unittest.skipIf(
-        StrictVersion(onnx.__version__) < StrictVersion("1.4.1"),
-        reason="Requires opset 9.")
+        StrictVersion(onnx.__version__) <= StrictVersion("1.4.1"),
+        reason="Requires opset 10.")
     def test_model_tfidf_vectorizer12(self):
         corpus = numpy.array([
             'AA AA',
@@ -155,8 +185,8 @@ def test_model_tfidf_vectorizer12(self):
                           "StrictVersion('0.4.0')")
 
     @unittest.skipIf(
-        StrictVersion(onnx.__version__) < StrictVersion("1.4.1"),
-        reason="Requires opset 9.")
+        StrictVersion(onnx.__version__) <= StrictVersion("1.4.1"),
+        reason="Requires opset 10.")
     def test_model_tfidf_vectorizer122(self):
         corpus = numpy.array([
             'This is the first document.',
@@ -177,8 +207,8 @@ def test_model_tfidf_vectorizer122(self):
                           "StrictVersion('0.4.0')")
 
     @unittest.skipIf(
-        StrictVersion(onnx.__version__) < StrictVersion("1.4.1"),
-        reason="Requires opset 9.")
+        StrictVersion(onnx.__version__) <= StrictVersion("1.4.1"),
+        reason="Requires opset 10.")
     def test_model_tfidf_vectorizer12_normL1(self):
         corpus = numpy.array([
             'This is the first document.',
@@ -198,8 +228,8 @@ def test_model_tfidf_vectorizer12_normL1(self):
                           "StrictVersion('0.4.0')")
 
     @unittest.skipIf(
-        StrictVersion(onnx.__version__) < StrictVersion("1.4.1"),
-        reason="Requires opset 9.")
+        StrictVersion(onnx.__version__) <= StrictVersion("1.4.1"),
+        reason="Requires opset 10.")
     def test_model_tfidf_vectorizer12_normL2(self):
         corpus = numpy.array([
             'This is the first document.',
@@ -220,8 +250,8 @@ def test_model_tfidf_vectorizer12_normL2(self):
                           "StrictVersion('0.4.0')")
 
     @unittest.skipIf(
-        StrictVersion(onnx.__version__) < StrictVersion("1.4.1"),
-        reason="Requires opset 9.")
+        StrictVersion(onnx.__version__) <= StrictVersion("1.4.1"),
+        reason="Requires opset 10.")
     def test_model_tfidf_vectorizer13(self):
         corpus = numpy.array([
             'This is the first document.',
@@ -242,8 +272,8 @@ def test_model_tfidf_vectorizer13(self):
                           "StrictVersion('0.4.0')")
 
     @unittest.skipIf(
-        StrictVersion(onnx.__version__) < StrictVersion("1.4.1"),
-        reason="Requires opset 9.")
+        StrictVersion(onnx.__version__) <= StrictVersion("1.4.1"),
+        reason="Requires opset 10.")
     def test_model_tfidf_vectorizer11parenthesis_class(self):
         corpus = numpy.array([
             'This is the first document.',
@@ -272,8 +302,8 @@ def test_model_tfidf_vectorizer11parenthesis_class(self):
                           "StrictVersion('0.4.0')")
 
     @unittest.skipIf(
-        StrictVersion(onnx.__version__) < StrictVersion("1.4.1"),
-        reason="Requires opset 9.")
+        StrictVersion(onnx.__version__) <= StrictVersion("1.4.1"),
+        reason="Requires opset 10.")
     def test_model_tfidf_vectorizer11_idparenthesis_id(self):
         corpus = numpy.array([
             'This is the first document.',
diff --git a/tests/test_utils/tests_helper.py b/tests/test_utils/tests_helper.py
--- a/tests/test_utils/tests_helper.py
+++ b/tests/test_utils/tests_helper.py
@@ -392,8 +392,7 @@ def _raw_score_binary_classification(model, X):
                             basename,
                             str(e).replace("\n", " -- ")))
                         continue
-                    else:
-                        raise e
+                    raise e
 
             if output is not None:
                 dest = os.path.join(folder,
diff --git a/tests/test_utils/utils_backend_onnxruntime.py b/tests/test_utils/utils_backend_onnxruntime.py
--- a/tests/test_utils/utils_backend_onnxruntime.py
+++ b/tests/test_utils/utils_backend_onnxruntime.py
@@ -4,11 +4,10 @@
 import numpy
 import pandas
 import warnings
+import onnx as onnx_package
 from skl2onnx.helpers.onnx_helper import (
-    select_model_inputs_outputs,
-    enumerate_model_node_outputs,
-    enumerate_model_initializers
-)
+    select_model_inputs_outputs, enumerate_model_node_outputs,
+    enumerate_model_initializers)
 from skl2onnx.algebra.type_helper import _guess_type
 
 from .utils_backend import (
@@ -17,8 +16,7 @@
     ExpectedAssertionError,
     OnnxRuntimeAssertionError,
     OnnxRuntimeMissingNewOnnxOperatorException,
-    compare_outputs,
-)
+    compare_outputs)
 
 
 def _display_intermediate_steps(model_onnx, inputs):
@@ -132,6 +130,11 @@ def compare_runtime(test,
                     "onnxruntime does not implement a new operator "
                     "'{0}'\n{1}\nONNX\n{2}".format(
                         onx, e, smodel))
+            if "is not a registered function/op" in str(e):
+                content = onnx_package.load(onx)
+                raise OnnxRuntimeAssertionError(
+                    "Missing op? '{0}'\nONNX\n{1}\n{2}\n---\n{3}".format(
+                        onx, smodel, e, content))
             raise OnnxRuntimeAssertionError(
                 "Unable to load onnx '{0}'\nONNX\n{1}\n{2}".format(
                     onx, smodel, e))