diff --git a/_unittests/ut_onnxrt/test_nb_onnx.py b/_unittests/ut_onnxrt/test_nb_onnx.py index 28c1f48a8..478497e44 100644 --- a/_unittests/ut_onnxrt/test_nb_onnx.py +++ b/_unittests/ut_onnxrt/test_nb_onnx.py @@ -4,7 +4,7 @@ import unittest from logging import getLogger import numpy -from pyquickhelper.pycode import ExtTestCase +from pyquickhelper.pycode import ExtTestCase, ignore_warnings from skl2onnx.algebra.onnx_ops import OnnxAdd # pylint: disable=E0611 from mlprodict.onnxrt.doc.nb_helper import OnnxNotebook from mlprodict.tools import get_opset_number_from_onnx @@ -16,8 +16,8 @@ def setUp(self): logger = getLogger('skl2onnx') logger.disabled = True + @ignore_warnings(DeprecationWarning) def test_onnxview(self): - idi = numpy.identity(2) onx = OnnxAdd('X', idi, output_names=['Y'], op_version=get_opset_number_from_onnx()) @@ -47,6 +47,21 @@ def test_onnxview(self): self.assertNotEmpty(res) self.assertIn('RenderJsDot', str(res)) + @ignore_warnings(DeprecationWarning) + def test_onnxview_empty(self): + idi = numpy.identity(2) + onx = OnnxAdd('X', idi, output_names=['Y'], + op_version=get_opset_number_from_onnx()) + model_def = onx.to_onnx({'X': idi.astype(numpy.float32)}) + + mg = OnnxNotebook() + mg.add_context( + {"model": model_def}) + cmd = "model --runtime=empty" + res = mg.onnxview(cmd) + self.assertNotEmpty(res) + self.assertIn('RenderJsDot', str(res)) + if __name__ == "__main__": unittest.main() diff --git a/_unittests/ut_onnxrt/test_onnxrt_runtime_empty.py b/_unittests/ut_onnxrt/test_onnxrt_runtime_empty.py new file mode 100644 index 000000000..fba772852 --- /dev/null +++ b/_unittests/ut_onnxrt/test_onnxrt_runtime_empty.py @@ -0,0 +1,65 @@ +""" +@brief test log(time=2s) +""" +import unittest +from logging import getLogger +import numpy +from onnx import helper, TensorProto +from pyquickhelper.pycode import ExtTestCase, ignore_warnings +from skl2onnx.algebra.onnx_ops import ( # pylint: disable=E0611 + OnnxAdd) +from mlprodict.onnxrt import OnnxInference +from mlprodict.tools.asv_options_helper import ( + get_ir_version_from_onnx, get_opset_number_from_onnx) + + +class TestOnnxrtRuntimeEmpty(ExtTestCase): + + def setUp(self): + logger = getLogger('skl2onnx') + logger.disabled = True + + @ignore_warnings(DeprecationWarning) + def test_onnxt_runtime_empty(self): + idi = numpy.identity(2, dtype=numpy.float32) + onx = OnnxAdd('X', idi, output_names=['Y'], + op_version=get_opset_number_from_onnx()) + model_def = onx.to_onnx({'X': idi.astype(numpy.float32)}) + model_def.ir_version = get_ir_version_from_onnx() + oinf = OnnxInference(model_def, runtime='empty') + self.assertNotEmpty(oinf) + + @ignore_warnings(DeprecationWarning) + def test_onnxt_runtime_empty_dot(self): + idi = numpy.identity(2, dtype=numpy.float32) + onx = OnnxAdd('X', idi, output_names=['Y'], + op_version=get_opset_number_from_onnx()) + model_def = onx.to_onnx({'X': idi.astype(numpy.float32)}) + model_def.ir_version = get_ir_version_from_onnx() + oinf = OnnxInference(model_def, runtime='empty') + self.assertNotEmpty(oinf) + dot = oinf.to_dot() + self.assertIn("-> Y;", dot) + + @ignore_warnings(DeprecationWarning) + def test_onnxt_runtime_empty_unknown(self): + X = helper.make_tensor_value_info( + 'X', TensorProto.FLOAT, [None, 2]) # pylint: disable=E1101 + Y = helper.make_tensor_value_info( + 'Y', TensorProto.FLOAT, [None, 2]) # pylint: disable=E1101 + Z = helper.make_tensor_value_info( + 'Z', TensorProto.FLOAT, [None, 2]) # pylint: disable=E1101 + node_def = helper.make_node('Add', ['X', 'Y'], ['Zt'], name='Zt') + node_def2 = helper.make_node('AddUnknown', ['X', 'Zt'], ['Z'], name='Z') + graph_def = helper.make_graph( + [node_def, node_def2], 'test-model', [X, Y], [Z]) + model_def = helper.make_model(graph_def, producer_name='onnx-example') + oinf = OnnxInference(model_def, runtime='empty') + self.assertNotEmpty(oinf) + dot = oinf.to_dot() + self.assertIn('AddUnknown', dot) + self.assertNotIn('x{', dot) + + +if __name__ == "__main__": + unittest.main() diff --git a/mlprodict/onnxrt/doc/nb_helper.py b/mlprodict/onnxrt/doc/nb_helper.py index 6813268f8..6629c5104 100644 --- a/mlprodict/onnxrt/doc/nb_helper.py +++ b/mlprodict/onnxrt/doc/nb_helper.py @@ -9,7 +9,8 @@ from ..onnx_inference import OnnxInference -def onnxview(graph, recursive=False, local=False, add_rt_shapes=False): +def onnxview(graph, recursive=False, local=False, add_rt_shapes=False, + runtime='python'): """ Displays an :epkg:`ONNX` graph into a notebook. @@ -20,8 +21,13 @@ def onnxview(graph, recursive=False, local=False, add_rt_shapes=False): :param add_rt_shapes: add information about the shapes the runtime was able to find out, the runtime has to be `'python'` + :param runtime: the view fails if a runtime does not implement a specific + node unless *runtime* is `'empty'` + + .. versionchanged:: 0.6 + Parameter *runtime* was added. """ - sess = OnnxInference(graph, skip_run=not add_rt_shapes) + sess = OnnxInference(graph, skip_run=not add_rt_shapes, runtime=runtime) dot = sess.to_dot(recursive=recursive, add_rt_shapes=add_rt_shapes) return RenderJsDot(dot, local=local) diff --git a/mlprodict/onnxrt/onnx_inference.py b/mlprodict/onnxrt/onnx_inference.py index b85bcfa20..dbc5b96e1 100644 --- a/mlprodict/onnxrt/onnx_inference.py +++ b/mlprodict/onnxrt/onnx_inference.py @@ -145,7 +145,7 @@ def _init(self): for node in self.sequence_: domain = node.onnx_node.domain target_opset = self.target_opset_.get(domain, None) - if self.runtime == 'onnxruntime2': + if self.runtime in ('onnxruntime2', 'empty'): node.setup_runtime(self.runtime, variables, self.__class__, target_opset=target_opset, dtype=dtype, domain=domain, ir_version=self.ir_version_, @@ -414,7 +414,7 @@ def to_sequence(self): k, names[k, 0][0])) names[k, 0] = ('I', v) for k, v in outputs.items(): - if (k, 0) in names: + if (k, 0) in names and self.runtime != 'empty': raise RuntimeError( # pragma: no cover "Output '{}' already exists (tag='{}').".format( k, names[k, 0][0])) diff --git a/mlprodict/onnxrt/onnx_inference_exports.py b/mlprodict/onnxrt/onnx_inference_exports.py index 20f4b9178..4d857ddef 100644 --- a/mlprodict/onnxrt/onnx_inference_exports.py +++ b/mlprodict/onnxrt/onnx_inference_exports.py @@ -4,6 +4,7 @@ """ import os import json +import re from io import BytesIO import pickle import textwrap @@ -23,8 +24,8 @@ def __init__(self, oinf): """ self.oinf = oinf - def to_dot(self, recursive=False, prefix='', add_rt_shapes=False, - use_onnx=False, **params): + def to_dot(self, recursive=False, prefix='', # pylint: disable=R0914 + add_rt_shapes=False, use_onnx=False, **params): """ Produces a :epkg:`DOT` language string for the graph. @@ -78,6 +79,19 @@ def to_dot(self, recursive=False, prefix='', add_rt_shapes=False, See an example of representation in notebook :ref:`onnxvisualizationrst`. """ + clean_label_reg1 = re.compile("\\\\x\\{[0-9A-F]{1,6}\\}") + clean_label_reg2 = re.compile("\\\\p\\{[0-9P]{1,6}\\}") + + def dot_name(text): + return text.replace("/", "_").replace(":", "__") + + def dot_label(text): + for reg in [clean_label_reg1, clean_label_reg2]: + fall = reg.findall(text) + for f in fall: + text = text.replace(f, "_") + return text + options = { 'orientation': 'portrait', 'ranksep': '0.25', @@ -123,8 +137,10 @@ def to_dot(self, recursive=False, prefix='', add_rt_shapes=False, sh = shapes.get(dobj['name'], '') if sh: sh = "\\nshape={}".format(sh) - exp.append(' {3}{0} [shape=box color=red label="{0}\\n{1}{4}" fontsize={2}];'.format( - dobj['name'], _type_to_string(dobj['type']), fontsize, prefix, sh)) + exp.append( + ' {3}{0} [shape=box color=red label="{0}\\n{1}{4}" fontsize={2}];'.format( + dot_name(dobj['name']), _type_to_string(dobj['type']), + fontsize, prefix, dot_label(sh))) inter_vars[obj.name] = obj # outputs @@ -134,8 +150,10 @@ def to_dot(self, recursive=False, prefix='', add_rt_shapes=False, sh = shapes.get(dobj['name'], '') if sh: sh = "\\nshape={}".format(sh) - exp.append(' {3}{0} [shape=box color=green label="{0}\\n{1}{4}" fontsize={2}];'.format( - dobj['name'], _type_to_string(dobj['type']), fontsize, prefix, sh)) + exp.append( + ' {3}{0} [shape=box color=green label="{0}\\n{1}{4}" fontsize={2}];'.format( + dot_name(dobj['name']), _type_to_string(dobj['type']), + fontsize, prefix, dot_label(sh))) inter_vars[obj.name] = obj # initializer @@ -152,9 +170,10 @@ def to_dot(self, recursive=False, prefix='', add_rt_shapes=False, st = st[:50] + '...' st = st.replace('\n', '\\n') kind = "" - exp.append(' {6}{0} [shape=box label="{0}\\n{4}{1}({2})\\n{3}" fontsize={5}];'.format( - dobj['name'], dobj['value'].dtype, - dobj['value'].shape, st, kind, fontsize, prefix)) + exp.append( + ' {6}{0} [shape=box label="{0}\\n{4}{1}({2})\\n{3}" fontsize={5}];'.format( + dot_name(dobj['name']), dobj['value'].dtype, + dobj['value'].shape, dot_label(st), kind, fontsize, prefix)) inter_vars[obj.name] = obj # nodes @@ -169,7 +188,7 @@ def to_dot(self, recursive=False, prefix='', add_rt_shapes=False, sh = "\\nshape={}".format(sh) exp.append( ' {2}{0} [shape=box label="{0}{3}" fontsize={1}];'.format( - out, fontsize, prefix, sh)) + dot_name(out), fontsize, dot_name(prefix), dot_label(sh))) dobj = _var_as_dict(node) if dobj['name'].strip() == '': # pragma: no cover @@ -221,7 +240,7 @@ def to_dot(self, recursive=False, prefix='', add_rt_shapes=False, exp.append(" subgraph cluster_{}{} {{".format( node.op_type, id(node))) exp.append(' label="{0}\\n({1}){2}";'.format( - dobj['op_type'], dobj['name'], satts)) + dobj['op_type'], dot_name(dobj['name']), satts)) exp.append(' fontsize={0};'.format(fontsize)) exp.append(' color=black;') exp.append( @@ -229,21 +248,28 @@ def to_dot(self, recursive=False, prefix='', add_rt_shapes=False, for inp1, inp2 in zip(node.input, body.input): exp.append( - " {0}{1} -> {2}{3};".format(prefix, inp1, subprefix, inp2.name)) + " {0}{1} -> {2}{3};".format( + dot_name(prefix), dot_name(inp1), + dot_name(subprefix), dot_name(inp2.name))) for out1, out2 in zip(body.output, node.output): exp.append( - " {0}{1} -> {2}{3};".format(subprefix, out1.name, prefix, out2)) + " {0}{1} -> {2}{3};".format( + dot_name(subprefix), dot_name(out1.name), + dot_name(prefix), dot_name(out2))) else: exp.append(' {4}{1} [shape=box style="filled,rounded" color=orange label="{0}\\n({1}){2}" fontsize={3}];'.format( - dobj['op_type'], dobj['name'], satts, fontsize, prefix)) + dobj['op_type'], dot_name(dobj['name']), satts, fontsize, + dot_name(prefix))) for inp in node.input: exp.append( - " {0}{1} -> {0}{2};".format(prefix, inp, node.name)) + " {0}{1} -> {0}{2};".format( + dot_name(prefix), dot_name(inp), dot_name(node.name))) for out in node.output: exp.append( - " {0}{1} -> {0}{2};".format(prefix, node.name, out)) + " {0}{1} -> {0}{2};".format( + dot_name(prefix), dot_name(node.name), dot_name(out))) exp.append('}') return "\n".join(exp) @@ -428,7 +454,8 @@ def clean_args(args): if self.oinf.runtime != 'python': raise ValueError( - "The runtime must be python not '{}'.".format(self.oinf.runtime)) + "The runtime must be 'python' not '{}'.".format( + self.oinf.runtime)) # metadata obj = {} diff --git a/mlprodict/onnxrt/ops.py b/mlprodict/onnxrt/ops.py index 22a928b06..176891f12 100644 --- a/mlprodict/onnxrt/ops.py +++ b/mlprodict/onnxrt/ops.py @@ -28,6 +28,9 @@ def load_op(onnx_node, desc=None, options=None, variables=None, dtype=None): if provider == 'python': from .ops_cpu import load_op as lo return lo(onnx_node, desc=desc, options=options) + if provider == 'empty': + from .ops_empty import load_op as lo + return lo(onnx_node, desc=desc, options=options) if provider == 'onnxruntime2': from .ops_onnxruntime import load_op as lo return lo(onnx_node, desc=desc, options=options, # pylint: disable=E1123 diff --git a/mlprodict/onnxrt/ops_empty/__init__.py b/mlprodict/onnxrt/ops_empty/__init__.py new file mode 100644 index 000000000..b169dc10f --- /dev/null +++ b/mlprodict/onnxrt/ops_empty/__init__.py @@ -0,0 +1,25 @@ +# -*- encoding: utf-8 -*- +""" +@file +@brief Shortcut to *ops_cpu*. +""" +from ._op import OpRunOnnxEmpty + + +def load_op(onnx_node, desc=None, options=None, variables=None, dtype=None): + """ + Gets the operator related to the *onnx* node. + This runtime does nothing and never complains. + + :param onnx_node: :epkg:`onnx` node + :param desc: internal representation + :param options: runtime options + :param variables: registered variables created by previous operators + :param dtype: float computation type + :return: runtime class + """ + if desc is None: + raise ValueError( # pragma: no cover + "desc should not be None.") + return OpRunOnnxEmpty(onnx_node, desc, variables=variables, + dtype=dtype, **options) diff --git a/mlprodict/onnxrt/ops_empty/_op.py b/mlprodict/onnxrt/ops_empty/_op.py new file mode 100644 index 000000000..aedfbd0b6 --- /dev/null +++ b/mlprodict/onnxrt/ops_empty/_op.py @@ -0,0 +1,202 @@ +# -*- encoding: utf-8 -*- +""" +@file +@brief Shortcut to *ops_onnxruntime*. +""" +import numpy +import onnx.defs +from onnx.helper import make_tensor +import skl2onnx.algebra.onnx_ops as alg +try: + import skl2onnx.algebra.custom_ops as alg2 +except ImportError: # pragma: no cover + # older version of skl2onnx + alg2 = alg +from ...tools.onnx2py_helper import guess_proto_dtype +from ..optim.graph_schema_helper import ( + get_defined_inputs, get_defined_outputs, proto2vars) + + +_schemas = { + schema.name: schema for schema in onnx.defs.get_all_schemas_with_history()} + + +class OpRunOnnxEmpty: + """ + Unique operator for an empty runtime. + """ + + def __init__(self, onnx_node, desc=None, variables=None, + dtype=None, **options): + """ + :param onnx_node: :epkg:`onnx` node + :param desc: internal representation + :param variables: registered variables created by previous operators + :param dtype: float computation type + :param options: runtime options + """ + self._provider = 'empty' + self.onnx_node = onnx_node + self.desc = desc + self._schema = _schemas.get(onnx_node.op_type, None) + if desc is not None: + if 'atts' in desc: + for a, b in desc['atts'].items(): + if not isinstance(b, dict) or 'value' not in b: + raise ValueError( # pragma: no cover + "Unexpected value {}.".format(b)) + options[a] = b['value'] + + self.options = options + self.dtype = dtype + self._init(variables) + + def _name_mapping(self, inputs): + mapping = {} + new_inputs = [] + for name in inputs: + if name in mapping: + i = 0 + new_name = "{}_{}".format(name, i) + while new_name in mapping: + i += 1 # pragma: no cover + new_name = "{}_{}".format(name, i) # pragma: no cover + mapping[new_name] = name + new_inputs.append(new_name) + else: + new_inputs.append(name) + mapping[name] = name + return mapping, new_inputs + + def _guess_proto_type(self, dtype): + return guess_proto_dtype(dtype) + + def _init(self, variables=None): + """ + Initializes the node. + + @param variables registered variables created by previous operators + + The current implementation for operator *Scan* + only works for matrices. + """ + try: + self.alg_class = getattr(alg2, 'Onnx' + self.onnx_node.op_type) + except AttributeError: + try: + self.alg_class = getattr(alg, 'Onnx' + self.onnx_node.op_type) + except AttributeError: + self.alg_class = None + inputs = list(self.onnx_node.input) + self.mapping, self.inputs = self._name_mapping(inputs) + self.outputs = list(self.onnx_node.output) + + options = self.options.copy() + target_opset = options.pop('target_opset', None) + domain = options.pop('domain', None) + # disable_optimisation = options.pop('disable_optimisation', False) + # ir_version = options.pop('ir_version', None) + + if self.alg_class is None: + self.onnx_ = self.onnx_node + elif self.onnx_node.op_type == 'ConstantOfShape': + for k in options: + v = options[k] + if isinstance(v, numpy.ndarray): + options[k] = make_tensor( + k, self._guess_proto_type(v.dtype), + v.shape, v.tolist()) + + self.inst_ = self.alg_class(*self.inputs, output_names=self.outputs, + op_version=target_opset, **options) + inputs = get_defined_inputs( + self.inputs, variables, dtype=self.dtype) + try: + self.onnx_ = self.inst_.to_onnx(inputs, target_opset=target_opset, + domain=domain) + if "dim_value: 0" in str(self.onnx_): + raise RuntimeError( # pragma: no cover + "Probable issue as one dimension is null.\n--\n{}".format( + self.onnx_)) + except AttributeError as e: # pragma: no cover + # older version of skl2onnx + self.onnx_ = self.inst_.to_onnx(inputs) + if "dim_value: 0" in str(self.onnx_): + raise RuntimeError( + "Probable issue as one dimension is null.\n--\n{}".format( + self.onnx_)) from e + elif self.onnx_node.op_type == 'Scan': + self.inst_ = self.alg_class( + *self.inputs, output_names=self.outputs, + op_version=target_opset, **options) + inputs = get_defined_inputs( + self.inputs, variables, dtype=self.dtype) + outputs = get_defined_outputs( + self.outputs, self.onnx_node, inputs, variables, + dtype=self.dtype) + inputs = [(name, cl.__class__([None, None])) + for (name, cl) in inputs] + outputs = [(name, cl.__class__([None, None])) + for (name, cl) in outputs] + self.onnx_ = self.inst_.to_onnx(inputs, outputs=outputs, + target_opset=target_opset, + domain=domain) + if "dim_value: 0" in str(self.onnx_): + raise RuntimeError( # pragma: no cover + "Probable issue as one dimension is null.\n--\n{}".format( + self.onnx_)) + else: + self.inst_ = self.alg_class(*self.inputs, output_names=self.outputs, + op_version=target_opset, domain=domain, + **options) + inputs = get_defined_inputs( + self.inputs, variables, dtype=self.dtype) + + try: + self.onnx_ = self.inst_.to_onnx( + inputs, target_opset=target_opset, domain=domain) + if "dim_value: 0" in str(self.onnx_): + raise RuntimeError( # pragma: no cover + "Probable issue as one dimension is null.\n--\n{}\n---\n{}".format( + self.onnx_, inputs)) + except (RuntimeError, ValueError): + # Let's try again by forcing output types. + outputs = get_defined_outputs( + self.outputs, self.onnx_node, inputs, variables, + dtype=self.dtype) + self.onnx_ = self.inst_.to_onnx(inputs, outputs=outputs, + target_opset=target_opset, + domain=domain) + if "dim_value: 0" in str(self.onnx_): + raise RuntimeError( # pragma: no cover + "Probable issue as one dimension is null.\n--\n{}".format( + self.onnx_)) from e + + if hasattr(self.onnx_, 'graph'): + if len(self.onnx_.graph.output) != len(self.outputs): # pragma: no cover + # Something is wrong, falls back to default plan. + outputs = get_defined_outputs( + self.outputs, self.onnx_node, inputs, variables, + dtype=self.dtype) + self.onnx_ = self.inst_.to_onnx(inputs, outputs=outputs, + target_opset=target_opset, + domain=domain) + if "dim_value: 0" in str(self.onnx_): + raise RuntimeError( # pragma: no cover + "Probable issue as one dimension is null.\n--\n{}".format( + self.onnx_)) + else: + lo = list(self.onnx_.graph.output) + outputs = proto2vars(lo) + else: + outputs = [(o, None) for o in self.onnx_.output] + + self.typed_outputs_ = outputs + + def run(self, *args, **kwargs): + """ + Should be overwritten. + """ + # inputs = {name: val for name, val in zip(self.inputs, args)} + raise RuntimeError( + "This runtime does nothing. Running it is useless.") diff --git a/mlprodict/tools/onnx2py_helper.py b/mlprodict/tools/onnx2py_helper.py index 8ee1b37bf..6571aba9e 100644 --- a/mlprodict/tools/onnx2py_helper.py +++ b/mlprodict/tools/onnx2py_helper.py @@ -135,6 +135,8 @@ def _elem_type_as_str(elem_type): return 'int8' if elem_type == onnx_proto.TensorProto.FLOAT16: # pylint: disable=E1101 return 'float16' + if elem_type == 0: # pylint: disable=E1101 + return 'unk' # The following code should be refactored. selem = str(elem_type) @@ -235,6 +237,16 @@ def _var_as_dict(var): key_type = _elem_type_as_str(t.key_type) value_type = _elem_type_as_str(t.value_type) dtype = dict(kind='map', key=key_type, value=value_type) + elif hasattr(var.type, 'tensor_type') and var.type.tensor_type.elem_type == 0: + t = var.type.tensor_type + elem_type = _elem_type_as_str(t.elem_type) + shape = t.shape + dim = shape.dim + dims = [d.dim_value for d in dim] + if len(dims) == 0: + dims = '?' + dtype = dict(kind='tensor', elem=elem_type, + shape=tuple(dims)) else: raise NotImplementedError( # pragma: no cover "Unable to convert a type into a dictionary for '{}'. "