diff --git a/aidge_onnx/onnx_import.py b/aidge_onnx/onnx_import.py index 0ac16ba059ced52702ab9d6e0e615c6652e6322e..a8de870d0df2d0ad82a4113f54fdafa141061cf3 100644 --- a/aidge_onnx/onnx_import.py +++ b/aidge_onnx/onnx_import.py @@ -15,7 +15,7 @@ import colorama from onnx import numpy_helper import onnx from .node_import import ONNX_NODE_CONVERTER_, generic -from .utils import * +from .utils import onnx_to_aidge_model_names def load_onnx(filename: str, verbose: bool = False): """Load an ONNX file and convert it into a :py:class:`aidge_core.GraphView`. @@ -107,7 +107,7 @@ def _load_onnx2graphview(model:onnx.ModelProto, verbose:bool = False): # Clean model if some issues in the model # might affect Aidge in the next steps - model = clean2aidge(model) + model = onnx_to_aidge_model_names(model) if verbose : print(f"\nGetting Initializers\n====================") # Get the initializers for i in model.graph.initializer: diff --git a/aidge_onnx/unit_tests/compare_layers_aidge_onnx.py b/aidge_onnx/unit_tests/compare_layers_aidge_onnx.py new file mode 100644 index 0000000000000000000000000000000000000000..0a312b82088b642156c26ff6e6d2932338c2fca9 --- /dev/null +++ b/aidge_onnx/unit_tests/compare_layers_aidge_onnx.py @@ -0,0 +1,283 @@ +import aidge_core +import aidge_backend_cpu +import aidge_onnx +from aidge_onnx.utils import onnx_to_aidge_name + +import argparse +from datetime import datetime + +import matplotlib.pyplot as plt + +import onnx +import onnxruntime + +import numpy as np +import numpy.typing as npt +import os + + +def set_all_model_nodes_as_output( + model: onnx.ModelProto, initial_output_nb: int +) -> onnx.ModelProto: + """ + Retrieves a given onnx model and tags all its nodes as outputs + args : + model : Loaded onnx model. + initial_output_nb : Number of outputs of the model. Allows to rebuild + the outputs in the right order. + """ + shape_info = onnx.shape_inference.infer_shapes(model) + # 1. retrieve output layers, they will be added at the very end of the process + output_layers = np.array( + [model.graph.output.pop() for node in range(initial_output_nb)] + ) + output_layers = np.flip(output_layers) + + # 2. tag all nodes as outputs + nodes_to_set = [] + for idx, node in enumerate(shape_info.graph.value_info): + nodes_to_set.append(node) + + model.graph.output.extend( + nodes_to_set + ) # in inference stage, these tensor will be added to output dict. + + # 3. re-add output_layers back to its original place at the end ouf output + model.graph.output.extend(output_layers) + + return model + + +def create_network_inputs( + session: onnxruntime.InferenceSession, +) -> dict[str, npt.NDArray]: + """ + From an inference session, generate random inputs for the model + args : + session : loaded onnx model. + returns : + Dictionnary containing tagged inputs. + """ + inputs = {} + for input_val in session.get_inputs(): + input_data = ( + np.random.random(size=input_val.shape).astype(np.float32) - 0.5 + ) * 2 # generating data in [-1 , 1 ] + # input_data = np.ones(input_val.shape).astype(np.float32) + inputs[input_val.name] = input_data + return inputs + + +def find_node_with_name( + aidge_nodes: set[aidge_core.Node], name: str +) -> aidge_core.Node: + """ + Find node in a set of nodes by checking its name + """ + for node in aidge_nodes: + if node.name() == name: + return node + return -1 + + +def compute_error( + estimated_output: npt.NDArray, + groundtruth_output: npt.NDArray, + relative_precision=1e-3, + absolute_presision=1e-4, +): + """ + Computes the error and an error threshold, + based on a groundtruth (onnx output) and an estimation (aidge output). + This function will automatically try to flatten layers whose shape does not + match because the remove flatten is automatically applied on the network. + If after flattening the sizes still doesn't match, the program will exit + with error code -1 + args : + estimated_output : aidge node output + groundtruth_output : onnx node output + return : + (error, maximum_threshold) + + """ + error = np.abs(estimated_output - groundtruth_output) + err_threshold = relative_precision * np.abs(groundtruth_output) + absolute_presision + if ( + estimated_output.shape != groundtruth_output.shape + ): # try to flatten the output bc if size differ it means that removeFlatten recipe has been applied + print("Warn: shape of output differ even trying to flatten them to match.") + print(f"aidge output shape : {estimated_output.shape}") + print(f"onnx output shape : {estimated_output.shape}") + + estimated_output = estimated_output.flatten() + groundtruth_output = groundtruth_output.flatten() + error = np.abs(estimated_output - groundtruth_output) / np.max( + groundtruth_output + ) + err_threshold = ( + relative_precision * np.abs(groundtruth_output) + absolute_presision + ) + if estimated_output.shape != groundtruth_output.shape: + print( + "ERROR : shape of output differ even after trying to flatten them, aborting." + ) + print(f"aidge output shape : {estimated_output.shape}") + print(f"onnx output shape : {groundtruth_output.shape}") + exit(-1) + return (error, err_threshold) + + +def compare_layers_aidge_onnx( + model_path: str, verbose=False, display=False, save_graph=False +): + """ + This function is the main of the script. + It will : + 1. Modify the onnx model to tag all its nodes as ouputs. + 2. Save it and load it as onnxruntime and aidge model (and delete temp file) + 3. Infer with both models. + 4. Check line per line the outputs of the model. + 5. If verbose is True print the nodes that failed. + 6. If enabled, Display/save the graph of mean(error)/mean(threshold) ratio. + args : + model_path : path to onnx model. + verbose : print the nodes whose size do not match + display : outputs a graph of the ratio error/threshold + save_graph : if true saves the graph to a given name + """ + + onnx_model: onnx.ModelProto = onnx.load(model_path) + # creating session to get its output nb + onnx_session = onnxruntime.InferenceSession(model_path) + nb_outputs = len(onnx_session.get_outputs()) + onnx_model = set_all_model_nodes_as_output(onnx_model, nb_outputs) + shape_info = onnx.shape_inference.infer_shapes(onnx_model) + onnx.save(shape_info, f"{model_path}_shape_info.onnx") + + modified_model_fpath = f"{model_path}_all_layers_are_outputs.onnx" + onnx.save_model(onnx_model, modified_model_fpath) + + # load modified models and delete temp file + onnx_session = onnxruntime.InferenceSession(modified_model_fpath) + aidge_model = aidge_onnx.load_onnx(modified_model_fpath) + os.remove(modified_model_fpath) + + # prepare input and outputs + inputs = create_network_inputs(onnx_session) + + # onnx inference + onnx_node_names = [output.name for output in onnx_session.get_outputs()] + onnx_outputs = onnx_session.run(onnx_node_names, inputs) + + # preparing inputs for aidge + for idx, (input_name, input_val) in enumerate(inputs.items()): + input_tensor = aidge_core.Tensor(np.array(input_val)) + input_node = aidge_core.Producer(input_tensor, f"{input_name}") + input_node.add_child( + aidge_model, 0, [sorted(aidge_model.get_input_nodes())[idx], 0] + ) + aidge_model.add(input_node) + + aidge_model.save("post_add_child") + # remove invalid layers + aidge_core.remove_flatten(aidge_model) + + aidge_model.compile("cpu", aidge_core.DataType.Float32) + + # aidge inference + scheduler = aidge_core.SequentialScheduler(aidge_model) + scheduler.forward(False, False) + + # Build the list of nodes as it is on onnx + aidge_node_names = [onnx_to_aidge_name(name) for name in onnx_node_names] + aidge_nodes = [ + node for node in aidge_model.get_nodes() if node.name() in aidge_node_names + ] + + # Iterate over all nodes and compare their outputs + all_layers_identical = True + layers_error_above_threshold = [] + layers_error = [] + for idx, name in enumerate(onnx_node_names): + if name.find("Flatten") != -1: # flatten layers have been removed + print(f"Skipping layer : {name}") + continue + aidge_node_to_search = onnx_to_aidge_name(name) + aidge_node = find_node_with_name(aidge_nodes, aidge_node_to_search) + if aidge_node == -1: + print( + f"ERROR : no aidge node found with name {aidge_node_to_search}" + "\nLeaving output comparison early.", + ) + break + onnx_node_output = onnx_outputs[idx] + aidge_node_output = np.array(aidge_node.get_operator().get_output(0)) + + (error, err_threshold) = compute_error(aidge_node_output, onnx_node_output) + if display: + layers_error.append( + np.mean(error) / np.mean(err_threshold) + if np.mean(err_threshold) != 0 + else 0 + ) + if np.any(error[error > err_threshold]): # error tolerance : + all_layers_identical = False + layers_error_above_threshold.append( + (idx, name, np.mean(error), np.mean(err_threshold)) + ) + + # create graph to save or display + fig = plt.figure() + plt.plot(layers_error) + if save_graph: + now = datetime.now() + dt_string = now.strftime("%Y-%m-%d_%H-%M-%S") + fig.savefig(f"error_threshold_ratio_{dt_string}.jpg") + if display: + plt.show() + + if all_layers_identical: + if verbose: + print("Aidge outputs the same result as ONNXRT") + return True + else: + if verbose: + print("AIDGE and ONNX outputs differ at the following nodes :") + print("IDX\tNAME\t\tMax relative error in the layer\t\t4.Error threshold") + print(*layers_error_above_threshold, sep="\n") + return False + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + prog="Compare Layers", + description="This program is meant to compare, side-by-side, for each layer, an onnx model and its imported aidge model" + "the output of each layer." + '\nIt will print the layers that differ "a lot" between ONNX and AIDGE models' + "(more informations on that in the compute_error function.\n" + "If specified, this script will show / save the graph of the mean(errors) / mean(threshold).", + epilog="WARNING : This script for now only supports nodes with a single output.", + ) + parser.add_argument( + "--onnx-model", + "-m", + help="File path of onnx model", + required=True, + action="store", + ) + parser.add_argument( + "--display", + "-d", + help="Displays the generated graph", + action="store_true", + ) + parser.add_argument( + "--save-graph", + "-s", + help="Saves the graph in jpg", + action="store_true", + ) + args = parser.parse_args() + compare_layers_aidge_onnx( + args.onnx_model, verbose=True, display=args.display, save_graph=args.save_graph + ) diff --git a/aidge_onnx/utils.py b/aidge_onnx/utils.py index 4129919982c545625d564d14ec0689118b4242b9..7a5029bae421a059dba59ce4306d7db86ca9ee6a 100644 --- a/aidge_onnx/utils.py +++ b/aidge_onnx/utils.py @@ -18,49 +18,35 @@ _MAP_NP_ONNX_TYPE = { } _MAP_ONNX_NP_TYPE = {v: k for k, v in _MAP_NP_ONNX_TYPE.items()} -def replace_with_underscore(input_string): - # Use the replace method to replace '/' with '_' - modified_string = input_string.replace('/', '_') - modified_string = modified_string.replace('.', '_') - return modified_string -def replace_digit_first_character(input_string): - modified_string = input_string - if len(input_string) > 0 and input_string[0].isdigit(): - modified_string = "data_" + input_string - return modified_string - -def replace(name): - # All replacements to do in the name - new_name = replace_with_underscore(name) - new_name = replace_digit_first_character(new_name) - return new_name - -def clean_names(model: onnx.ModelProto): +def onnx_to_aidge_model_names(model: onnx.ModelProto): + """ + Change the name of each node of the model from onnx convention to aidge's one + args : + model : to modify + return : + model : modified + """ for i in model.graph.initializer: - i.name = replace(i.name) + i.name = onnx_to_aidge_name(i.name) for n in model.graph.node: if len(n.name) > 0 and n.name[0].isdigit(): new_name = "layer_" + n.name n.name = new_name for index, i in enumerate(n.input): - n.input[index] = replace(i) + n.input[index] = onnx_to_aidge_name(i) for index, o in enumerate(n.output): - n.output[index] = replace(o) + n.output[index] = onnx_to_aidge_name(o) for i in model.graph.input: - i.name = replace(i.name) + i.name = onnx_to_aidge_name(i.name) for o in model.graph.output: - o.name = replace(o.name) + o.name = onnx_to_aidge_name(o.name) return model -def clean2aidge(model:onnx.ModelProto): - model = clean_names(model) - return model - def numpy_to_onnx_type(np_dtype): if np_dtype not in _MAP_NP_ONNX_TYPE: raise ValueError(f"Unsupported NumPy dtype: {np_dtype}") @@ -72,3 +58,11 @@ def onnx_to_numpy_type(onnx_type): raise ValueError(f"Unsupported ONNX TensorProto type: {onnx_type}") np_dtype = _MAP_ONNX_NP_TYPE[onnx_type] return np_dtype + +def onnx_to_aidge_name(name: str) -> str: + """ + Translates onnx node naming convention to aidge naming convention + """ + name = name.replace("/", "_").replace(".", "_") + name = name if (len(name) == 0 or not name[0].isdigit()) else "data_" + name + return name