Repository: onnx/sklearn-onnx

Issue:
IsolationForest missing access to score_samples() method
Similar to #431, it would be helpful to get the anomaly scores from in addition to the prediction.

```
if_smtp_types = [('float_input', FloatTensorType([None, len(cols)]))] # our input is the number of columns and they are all floats
options = {id(classifier): {'score_samples': True}}
onx_score = convert_sklearn(classifier, initial_types=if_smtp_types, options=options)
with open("smtp_if_score.onnx", "wb") as f:
    f.write(onx_score.SerializeToString())
```

Here `classifier` is a trained `sklearn.ensemble.IsolationForest()`.

I get the following error:

```
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
<ipython-input-73-1f4325848ac6> in <module>
      3 if_smtp_types = [('float_input', FloatTensorType([None, len(cols)]))] # our input is the number of columns and they are all floats
      4 options = {id(classifier): {'score_samples': True}}
----> 5 onx_score = convert_sklearn(classifier, initial_types=if_smtp_types, options=options)
      6 with open("smtp_if_score.onnx", "wb") as f:
      7     f.write(onx_score.SerializeToString())

~/local/anaconda3/envs/pecoc_boto/lib/python3.8/site-packages/skl2onnx/convert.py in convert_sklearn(model, name, initial_types, doc_string, target_opset, custom_conversion_functions, custom_shape_calculators, custom_parsers, options, intermediate, white_op, black_op, final_types, dtype, verbose)
    160     if verbose >= 1:
    161         print("[convert_sklearn] convert_topology")
--> 162     onnx_model = convert_topology(topology, name, doc_string, target_opset,
    163                                   options=options,
    164                                   remove_identity=not intermediate,

~/local/anaconda3/envs/pecoc_boto/lib/python3.8/site-packages/skl2onnx/common/_topology.py in convert_topology(topology, model_name, doc_string, target_opset, channel_first_inputs, options, remove_identity, verbose)
   1199                     "".format(operator.type,
   1200                               type(getattr(operator, 'raw_model', None))))
-> 1201         container.validate_options(operator)
   1202         conv(scope, operator, container)
   1203 

~/local/anaconda3/envs/pecoc_boto/lib/python3.8/site-packages/skl2onnx/common/_container.py in validate_options(self, operator)
    664         """
    665         skl_op = operator.raw_operator
--> 666         self.get_options(skl_op)
    667 
    668     def get_options(self, model, default_values=None, fail=True):

~/local/anaconda3/envs/pecoc_boto/lib/python3.8/site-packages/skl2onnx/common/_container.py in get_options(self, model, default_values, fail)
    676         :return: dictionary
    677         """
--> 678         return _build_options(
    679             model, self.options, default_values,
    680             self._get_allowed_options(model), fail=fail)

~/local/anaconda3/envs/pecoc_boto/lib/python3.8/site-packages/skl2onnx/common/_container.py in _build_options(model, defined_options, default_values, allowed_options, fail)
     66             if k not in allowed_options:
     67                 if fail:
---> 68                     raise NameError(
     69                         "Option '{}' not in {} for class '{}'.".format(
     70                             k, list(sorted(allowed_options)),

NameError: Option 'score_samples' not in [] for class 'IsolationForest'.
```


Discussion:
Option 'score_samples' is not implemented yet. But it should not take long.

Resolving patch:
diff --git a/skl2onnx/_parse.py b/skl2onnx/_parse.py
--- a/skl2onnx/_parse.py
+++ b/skl2onnx/_parse.py
@@ -13,6 +13,7 @@
     class OutlierMixin:
         pass
 
+from sklearn.ensemble import IsolationForest
 from sklearn.gaussian_process import GaussianProcessRegressor
 from sklearn.linear_model import BayesianRidge
 from sklearn.model_selection import GridSearchCV
@@ -165,8 +166,21 @@ def _parse_sklearn_simple_model(scope, model, inputs, custom_parsers=None):
         this_operator.outputs.append(label_variable)
         this_operator.outputs.append(score_tensor_variable)
 
+    elif type(model) == IsolationForest:
+        label_variable = scope.declare_local_variable(
+            'label', Int64TensorType())
+        score_tensor_variable = scope.declare_local_variable(
+            'scores', guess_tensor_type(inputs[0].type))
+        this_operator.outputs.append(label_variable)
+        this_operator.outputs.append(score_tensor_variable)
+        options = scope.get_options(model, dict(score_samples=False))
+        if options['score_samples']:
+            scores_var = scope.declare_local_variable(
+                'score_samples', guess_tensor_type(inputs[0].type))
+            this_operator.outputs.append(scores_var)
+
     elif type(model) in outlier_list or isinstance(model, OutlierMixin):
-        # For clustering, we may have two outputs, one for label and
+        # For outliers, we may have two outputs, one for label and
         # the other one for scores.
         label_variable = scope.declare_local_variable(
             'label', Int64TensorType())
diff --git a/skl2onnx/operator_converters/isolation_forest.py b/skl2onnx/operator_converters/isolation_forest.py
--- a/skl2onnx/operator_converters/isolation_forest.py
+++ b/skl2onnx/operator_converters/isolation_forest.py
@@ -28,6 +28,7 @@ def convert_sklearn_isolation_forest(
     outputs = operator.outputs
     opv = container.target_opset
     opvml = container.target_opset_any_domain('ai.onnx.ml')
+    options = container.get_options(op, dict(score_samples=None))
     if opvml < 2:
         raise RuntimeError(
             "This converter requires at least opset 2 for "
@@ -187,13 +188,15 @@ def convert_sklearn_isolation_forest(
                      op_version=opv)
 
     # decision_function
-    decision = OnnxAdd(
-        OnnxNeg(
+    output_names = outputs[2].full_name if options['score_samples'] else None
+    score_samples = OnnxNeg(
             OnnxPow(np.array([2], dtype=dtype),
                     OnnxNeg(depths, op_version=opv),
                     op_version=opv),
-            op_version=opv),
-        np.array([-op.offset_], dtype=dtype),
+            op_version=opv, output_names=output_names)
+
+    decision = OnnxAdd(
+        score_samples, np.array([-op.offset_], dtype=dtype),
         op_version=opv, output_names=outputs[1].full_name)
     decision.set_onnx_name_prefix('dec')
 
@@ -212,6 +215,8 @@ def convert_sklearn_isolation_forest(
 
     predict.add_to(scope, container)
     less.add_to(scope, container)
+    if options['score_samples']:
+        score_samples.add_to(scope, container)
 
 
 def _build_labels(tree, output):
@@ -251,4 +256,5 @@ def _recursive_build_labels(index, current):
 
 
 register_converter('SklearnIsolationForest',
-                   convert_sklearn_isolation_forest)
+                   convert_sklearn_isolation_forest,
+                   options={'score_samples': [True, False]})


Test patch:
diff --git a/tests/test_sklearn_isolation_forest.py b/tests/test_sklearn_isolation_forest.py
--- a/tests/test_sklearn_isolation_forest.py
+++ b/tests/test_sklearn_isolation_forest.py
@@ -6,6 +6,8 @@
 import unittest
 from distutils.version import StrictVersion
 import numpy as np
+from numpy.testing import assert_almost_equal
+from onnxruntime import InferenceSession
 from sklearn import __version__ as sklv
 try:
     from sklearn.ensemble import IsolationForest
@@ -37,6 +39,28 @@ def test_isolation_forest(self):
         dump_data_and_model(data, model, model_onnx,
                             basename="IsolationForest")
 
+    @unittest.skipIf(IsolationForest is None, reason="old scikit-learn")
+    @unittest.skipIf(StrictVersion(sklv2) < StrictVersion('0.22.0'),
+                     reason="tree structure is different.")
+    def test_isolation_forest_score_samples(self):
+        isol = IsolationForest(n_estimators=3, random_state=0)
+        data = np.array([[-1.1, -1.2], [0.3, 0.2],
+                         [0.5, 0.4], [100., 99.]], dtype=np.float32)
+        model = isol.fit(data)
+        model_onnx = to_onnx(model, data, target_opset=TARGET_OPSET,
+                             options={'score_samples': True})
+        sess = InferenceSession(model_onnx.SerializeToString())
+        names = [o.name for o in sess.get_outputs()]
+        self.assertEqual(names, ['label', 'scores', 'score_samples'])
+        got = sess.run(None, {'X': data})
+        self.assertEqual(len(got), 3)
+        expected_label = isol.predict(data)
+        expected_decif = isol.decision_function(data)
+        expected_score = isol.score_samples(data)
+        assert_almost_equal(expected_label, got[0].ravel())
+        assert_almost_equal(expected_decif, got[1].ravel())
+        assert_almost_equal(expected_score, got[2].ravel())
+
     @unittest.skipIf(IsolationForest is None, reason="old scikit-learn")
     @unittest.skipIf(StrictVersion(sklv2) < StrictVersion('0.22.0'),
                      reason="tree structure is different.")