Repository: onnx/sklearn-onnx

Issue:
Isssue in converting a tfidf-SVM pipeline
Hello all -- Recently ran into onnx project in a conference. Trying to export some of my legacy systems to onnx so I can host them on model-server platforms directly.
 
I am trying to convert a sklearn pipeline that takes text as input and classifies it using tfidf feature extraction followed by SvmClassifier.
My code -> 
```
    print(X_train.shape)
    print(X_train.head())
    model_pipeline = Pipeline([
        ('vectorizer', TfidfVectorizer(stop_words=stopwords, lowercase=True, use_idf=True, ngram_range=(1,3), max_features=30000)),
        ('feature_selector', SelectKBest(chi2, k=1000)),
        ('classifier', SVC(class_weight='balanced', C=args.C, decision_function_shape='ovo', kernel='rbf', gamma='scale'))  
    ])
    
    model_pipeline.fit(X_train, y_train)
    initial_type = [('left_wing', StringTensorType([None, 1]))]
    onx = convert_sklearn(model_pipeline, "cv", initial_types=initial_type)
```

Stdout and stack trace -> 
```
(8320,)
0    in david i l c i a l one two zero seven at hot...
1    three payments i can make it three monthly pay...
2    if she's available at if he would like yeah ok...
3    be twenty eight dollars and fifty four cents t...
4    let me can i you on a brief hold really quick ...
Name: left_wing, dtype: object
Traceback (most recent call last):
  File "/home/ec2-user/anaconda3/envs/python3/lib/python3.6/site-packages/skl2onnx/common/_container.py", line 380, in add_node
    node = make_node(op_type, inputs, outputs, name=name, **attrs)
  File "/home/ec2-user/anaconda3/envs/python3/lib/python3.6/site-packages/skl2onnx/proto/onnx_helper_modified.py", line 58, in make_node
    for key, value in sorted(kwargs.items()))
  File "/home/ec2-user/anaconda3/envs/python3/lib/python3.6/site-packages/skl2onnx/proto/onnx_helper_modified.py", line 58, in <genexpr>
    for key, value in sorted(kwargs.items()))
  File "/home/ec2-user/anaconda3/envs/python3/lib/python3.6/site-packages/skl2onnx/proto/onnx_helper_modified.py", line 173, in make_attribute
    [type(_) for _, __ in zip(value, range(0, 5))]))
ValueError: You passed in an iterable attribute but I cannot figure out its applicable type, key='coefficients', type=<class 'scipy.sparse.csr.csr_matrix'>, types=[<class 'scipy.sparse.csr.csr_matrix'>, <class 'scipy.sparse.csr.csr_matrix'>].

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "script.py", line 112, in <module>
    onx = convert_sklearn(model_pipeline, "cv", initial_types=initial_type)
  File "/home/ec2-user/anaconda3/envs/python3/lib/python3.6/site-packages/skl2onnx/convert.py", line 138, in convert_sklearn
    dtype=dtype, options=options)
  File "/home/ec2-user/anaconda3/envs/python3/lib/python3.6/site-packages/skl2onnx/common/_topology.py", line 977, in convert_topology
    conv(scope, operator, container)
  File "/home/ec2-user/anaconda3/envs/python3/lib/python3.6/site-packages/skl2onnx/operator_converters/support_vector_machines.py", line 95, in convert_sklearn_svm
    op_domain='ai.onnx.ml', **svm_attrs)
  File "/home/ec2-user/anaconda3/envs/python3/lib/python3.6/site-packages/skl2onnx/common/_container.py", line 383, in add_node
    "".format(op_type, name)) from e
ValueError: Unable to create node 'SVMClassifier' with name='SVM'.
```

I am using skl2onnx==1.6.1 and sklearn==0.20.3

Any pointers/help will be greatly appreciated. Thanks!


Discussion:

Resolving patch:
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
@@ -7,6 +7,7 @@
 import numbers
 import numpy as np
 import six
+from scipy.sparse import isspmatrix
 from sklearn.svm import SVC, NuSVC, SVR, NuSVR, OneClassSVM
 from ..common._apply_operation import (
     apply_cast, apply_concat, apply_abs,
@@ -56,8 +57,14 @@ def convert_sklearn_svm_regressor(
     svm_attrs['kernel_type'] = op.kernel.upper()
     svm_attrs['kernel_params'] = [float(_) for _ in
                                   [op._gamma, op.coef0, op.degree]]
-    svm_attrs['support_vectors'] = support_vectors
-    svm_attrs['coefficients'] = coef
+    if isspmatrix(support_vectors):
+        svm_attrs['support_vectors'] = support_vectors.toarray().ravel()
+    else:
+        svm_attrs['support_vectors'] = support_vectors
+    if isspmatrix(coef):
+        svm_attrs['coefficients'] = coef.toarray().ravel()
+    else:
+        svm_attrs['coefficients'] = coef
     svm_attrs['rho'] = intercept
 
     if operator.type in ['SklearnSVR', 'SklearnNuSVR'] or isinstance(
@@ -123,7 +130,7 @@ def convert_sklearn_svm_classifier(
     sklearn/utils/multiclass.py#L402>`_. *onnxruntime* returns
     the raw score from *svm* algorithm as a *matrix[N, (C(C-1)/2]*.
     """
-    svm_attrs = {'name': scope.get_unique_operator_name('SVM')}
+    svm_attrs = {'name': scope.get_unique_operator_name('SVMc')}
     op = operator.raw_operator
     if isinstance(op.dual_coef_, np.ndarray):
         coef = op.dual_coef_.ravel().tolist()
@@ -132,6 +139,8 @@ def convert_sklearn_svm_classifier(
     intercept = op.intercept_
     if isinstance(op.support_vectors_, np.ndarray):
         support_vectors = op.support_vectors_.ravel().tolist()
+    elif isspmatrix(op.support_vectors_):
+        support_vectors = op.support_vectors_.toarray().ravel().tolist()
     else:
         support_vectors = op.support_vectors_
 
@@ -142,10 +151,17 @@ def convert_sklearn_svm_classifier(
 
     if (operator.type in ['SklearnSVC', 'SklearnNuSVC'] or isinstance(
             op, (SVC, NuSVC))) and len(op.classes_) == 2:
-        svm_attrs['coefficients'] = [-v for v in coef]
+        if isspmatrix(coef):
+            coef_dense = coef.toarray().ravel()
+            svm_attrs['coefficients'] = [-v for v in coef_dense]
+        else:
+            svm_attrs['coefficients'] = [-v for v in coef]
         svm_attrs['rho'] = [-v for v in intercept]
     else:
-        svm_attrs['coefficients'] = coef
+        if isspmatrix(coef):
+            svm_attrs['coefficients'] = coef.todense()
+        else:
+            svm_attrs['coefficients'] = coef
         svm_attrs['rho'] = intercept
 
     handles_ovr = False
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
@@ -216,8 +216,10 @@ def convert_sklearn_text_vectorizer(scope, operator, container):
             "You may raise an issue at "
             "https://github.com/onnx/sklearn-onnx/issues.")
 
-    if op.lowercase or op.stop_words_:
+    stop_words = op.stop_words_ | (
+        set(op.stop_words) if op.stop_words else set())
 
+    if op.lowercase or stop_words:
         if len(operator.input_full_names) != 1:
             raise RuntimeError("Only one input is allowed, found {}.".format(
                 operator.input_full_names))
@@ -245,8 +247,8 @@ def convert_sklearn_text_vectorizer(scope, operator, container):
             op_version = 9
             domain = 'com.microsoft'
 
-        if op.stop_words_:
-            attrs['stopwords'] = list(sorted(op.stop_words_))
+        if stop_words:
+            attrs['stopwords'] = list(sorted(stop_words))
         container.add_node(op_type, flatten,
                            normalized, op_version=op_version,
                            op_domain=domain, **attrs)


Test patch:
diff --git a/tests/test_sklearn_tfidf_vectorizer_converter_pipeline.py b/tests/test_sklearn_tfidf_vectorizer_converter_pipeline.py
new file mode 100644
--- /dev/null
+++ b/tests/test_sklearn_tfidf_vectorizer_converter_pipeline.py
@@ -0,0 +1,119 @@
+"""
+Tests scikit-learn's tfidf converter.
+"""
+import unittest
+from distutils.version import StrictVersion
+import numpy
+from numpy.testing import assert_almost_equal
+from onnxruntime import InferenceSession, __version__ as ort_version
+from sklearn.pipeline import Pipeline
+from sklearn.svm import SVC, SVR
+from sklearn.feature_extraction.text import TfidfVectorizer, CountVectorizer
+from sklearn.feature_selection import SelectKBest
+from skl2onnx import convert_sklearn
+from skl2onnx.common.data_types import StringTensorType
+import onnx
+
+
+class TestSklearnTfidfVectorizerPipeline(unittest.TestCase):
+
+    def common_test_model_tfidf_vectorizer_pipeline_cls(
+            self, kind=None, verbose=False):
+        if kind == 'stop':
+            stopwords = ['the', 'and', 'is']
+        else:
+            stopwords = None
+        X_train = 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))
+        y_train = numpy.array([0, 1, 0, 1])
+
+        if kind is None:
+            model_pipeline = Pipeline([
+                ('vectorizer', TfidfVectorizer(
+                    stop_words=stopwords, lowercase=True, use_idf=True,
+                    ngram_range=(1, 3), max_features=30000)),
+            ])
+        elif kind == 'cls':
+            model_pipeline = Pipeline([
+                ('vectorizer', TfidfVectorizer(
+                    stop_words=stopwords, lowercase=True, use_idf=True,
+                    ngram_range=(1, 3), max_features=30000)),
+                ('feature_selector', SelectKBest(k=10)),
+                ('classifier', SVC(
+                    class_weight='balanced', kernel='rbf', gamma='scale',
+                    probability=True))
+            ])
+        elif kind == 'stop':
+            model_pipeline = Pipeline([
+                ('vectorizer', CountVectorizer(
+                    stop_words=stopwords, lowercase=True,
+                    ngram_range=(1, 2), max_features=30000)),
+            ])
+        elif kind == 'reg':
+            model_pipeline = Pipeline([
+                ('vectorizer', TfidfVectorizer(
+                    stop_words=stopwords, lowercase=True, use_idf=True,
+                    ngram_range=(1, 3), max_features=30000)),
+                ('feature_selector', SelectKBest(k=10)),
+                ('classifier', SVR(kernel='rbf', gamma='scale'))
+            ])
+        else:
+            raise AssertionError(kind)
+
+        model_pipeline.fit(X_train.ravel(), y_train)
+        initial_type = [('input', StringTensorType([None, 1]))]
+        model_onnx = convert_sklearn(
+            model_pipeline, "cv", initial_types=initial_type,
+            options={SVC: {'zipmap': False}})
+
+        if kind in (None, 'stop'):
+            exp = [model_pipeline.transform(X_train.ravel()).toarray()]
+        elif kind == 'cls':
+            exp = [model_pipeline.predict(X_train.ravel()),
+                   model_pipeline.predict_proba(X_train.ravel())]
+        elif kind == 'reg':
+            exp = [model_pipeline.predict(X_train.ravel()).reshape((-1, 1))]
+
+        sess = InferenceSession(model_onnx.SerializeToString())
+        got = sess.run(None, {'input': X_train})
+        if verbose:
+            voc = model_pipeline.steps[0][-1].vocabulary_
+            voc = list(sorted([(v, k) for k, v in voc.items()]))
+            for kv in voc:
+                print(kv)
+        for a, b in zip(exp, got):
+            if verbose:
+                print(a)
+                print(b)
+            assert_almost_equal(a, b)
+
+    @unittest.skipIf(
+        StrictVersion(onnx.__version__) < StrictVersion("1.5.0"),
+        reason="Requires opset 10.")
+    @unittest.skipIf(
+        StrictVersion(ort_version) < StrictVersion("1.0.0"),
+        reason="Too old")
+    def test_model_tfidf_vectorizer_pipeline(self):
+        for kind in [None, 'cls', 'reg']:
+            with self.subTest(kind=kind):
+                self.common_test_model_tfidf_vectorizer_pipeline_cls(kind)
+
+    @unittest.skipIf(
+        StrictVersion(onnx.__version__) < StrictVersion("1.5.0"),
+        reason="Requires opset 10.")
+    @unittest.skipIf(
+        StrictVersion(ort_version) < StrictVersion("1.4.0"),
+        reason="Wrong handling of stopwods and n-grams")
+    def test_model_tfidf_vectorizer_pipeline_stop_words(self):
+        for kind in ['stop']:
+            with self.subTest(kind=kind):
+                self.common_test_model_tfidf_vectorizer_pipeline_cls(
+                    kind, verbose=False)
+
+
+if __name__ == "__main__":
+    unittest.main()