From 20e2b1b690b84859fdb375b2db9c416ef71a1294 Mon Sep 17 00:00:00 2001 From: bammari Date: Mon, 25 Sep 2023 12:02:43 -0400 Subject: [PATCH 01/30] Increased exection time to 500 seconds --- tests/notebooks/test_run_notebooks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/notebooks/test_run_notebooks.py b/tests/notebooks/test_run_notebooks.py index 10083768..49d15462 100644 --- a/tests/notebooks/test_run_notebooks.py +++ b/tests/notebooks/test_run_notebooks.py @@ -12,7 +12,7 @@ def _test_run_notebook(folder, notebook_fname, n_cells): # change to notebook directory to allow testing cwd = os.getcwd() os.chdir(os.path.join(this_file_dir(), "..", "..", "docs", "notebooks", folder)) - with testbook(notebook_fname, timeout=300, execute=True) as tb: + with testbook(notebook_fname, timeout=500, execute=True) as tb: assert tb.code_cells_executed == n_cells os.chdir(cwd) From 57cf3b4b32222bedc0f35d28b9dcfe0fc0be0afc Mon Sep 17 00:00:00 2001 From: bammari Date: Mon, 25 Sep 2023 12:59:58 -0400 Subject: [PATCH 02/30] Recommit to run CI --- tests/notebooks/test_run_notebooks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/notebooks/test_run_notebooks.py b/tests/notebooks/test_run_notebooks.py index 49d15462..4a7a104c 100644 --- a/tests/notebooks/test_run_notebooks.py +++ b/tests/notebooks/test_run_notebooks.py @@ -9,7 +9,7 @@ # TODO: These will be replaced with stronger tests using testbook soon def _test_run_notebook(folder, notebook_fname, n_cells): - # change to notebook directory to allow testing + # change to notebook directory to allow for testing cwd = os.getcwd() os.chdir(os.path.join(this_file_dir(), "..", "..", "docs", "notebooks", folder)) with testbook(notebook_fname, timeout=500, execute=True) as tb: From b02e6562974d9728723dd1aaa245aec1f7af1cfb Mon Sep 17 00:00:00 2001 From: bammari Date: Thu, 28 Sep 2023 10:48:15 -0400 Subject: [PATCH 03/30] Trying CI again --- tests/notebooks/test_run_notebooks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/notebooks/test_run_notebooks.py b/tests/notebooks/test_run_notebooks.py index 4a7a104c..c9b44574 100644 --- a/tests/notebooks/test_run_notebooks.py +++ b/tests/notebooks/test_run_notebooks.py @@ -9,7 +9,7 @@ # TODO: These will be replaced with stronger tests using testbook soon def _test_run_notebook(folder, notebook_fname, n_cells): - # change to notebook directory to allow for testing + # Change to notebook directory to allow for testing cwd = os.getcwd() os.chdir(os.path.join(this_file_dir(), "..", "..", "docs", "notebooks", folder)) with testbook(notebook_fname, timeout=500, execute=True) as tb: From 5857b29614017977d65e9fc5e8307573acd1d0d2 Mon Sep 17 00:00:00 2001 From: Jeremy Sadler <53983960+jezsadler@users.noreply.github.com> Date: Mon, 9 Oct 2023 11:35:05 -0700 Subject: [PATCH 04/30] Replacing assert statements with raising exceptions in omlt/src --- src/omlt/gbt/gbt_formulation.py | 8 +- src/omlt/gbt/model.py | 20 +-- src/omlt/io/onnx_parser.py | 123 ++++++++++++------- src/omlt/neuralnet/layer.py | 23 ++-- src/omlt/neuralnet/layers/full_space.py | 18 +-- src/omlt/neuralnet/layers/partition_based.py | 15 ++- src/omlt/neuralnet/layers/reduced_space.py | 5 +- src/omlt/neuralnet/network_definition.py | 6 +- src/omlt/neuralnet/nn_formulation.py | 49 ++++---- 9 files changed, 165 insertions(+), 102 deletions(-) diff --git a/src/omlt/gbt/gbt_formulation.py b/src/omlt/gbt/gbt_formulation.py index 13b03227..3c2ca27d 100644 --- a/src/omlt/gbt/gbt_formulation.py +++ b/src/omlt/gbt/gbt_formulation.py @@ -197,13 +197,17 @@ def _branching_y(tree_id, branch_node_id): node_mask = (nodes_tree_ids == tree_id) & (nodes_node_ids == branch_node_id) feature_id = nodes_feature_ids[node_mask] branch_value = nodes_values[node_mask] - assert len(feature_id) == 1 and len(branch_value) == 1 + if len(branch_value) != 1: + raise ValueError(f"The given tree_id and branch_node_id do not uniquely identify a branch value.") + if len(feature_id) != 1: + raise ValueError(f"The given tree_id and branch_node_id do not uniquely identify a feature.") feature_id = feature_id[0] branch_value = branch_value[0] (branch_y_idx,) = np.where( branch_value_by_feature_id[feature_id] == branch_value ) - assert len(branch_y_idx) == 1 + if len(branch_y_idx) != 1: + raise ValueError(f"The given tree_id and branch_node_id do not uniquely identify a branch index.") return block.y[feature_id, branch_y_idx[0]] def _sum_of_z_l(tree_id, start_node_id): diff --git a/src/omlt/gbt/model.py b/src/omlt/gbt/model.py index bbd178aa..fd0d75a4 100644 --- a/src/omlt/gbt/model.py +++ b/src/omlt/gbt/model.py @@ -56,14 +56,16 @@ def scaling_object(self, scaling_object): def _model_num_inputs(model): """Returns the number of input variables""" graph = model.graph - assert len(graph.input) == 1 + if len(graph.input) != 1: + raise ValueError(f"Model graph input field is multi-valued {graph.input}. A single value is required.") return _tensor_size(graph.input[0]) def _model_num_outputs(model): """Returns the number of output variables""" graph = model.graph - assert len(graph.output) == 1 + if len(graph.output) != 1: + raise ValueError(f"Model graph output field is multi-valued {graph.output}. A single value is required.") return _tensor_size(graph.output[0]) @@ -71,9 +73,11 @@ def _tensor_size(tensor): """Returns the size of an input tensor""" tensor_type = tensor.type.tensor_type size = None - for dim in tensor_type.shape.dim: - if dim.dim_value is not None and dim.dim_value > 0: - assert size is None - size = dim.dim_value - assert size is not None - return size + dim_values = [dim.dim_value for dim in tensor_type.shape.dim if dim.dim_value is not None and dim.dim_value > 0] + if len(dim_values) == 1: + size = dim_values[0] + elif dim_values == []: + raise ValueError(f"Tensor {tensor} has no positive dimensions.") + else: + raise ValueError(f"Tensor {tensor} has multiple positive dimensions.") + return size \ No newline at end of file diff --git a/src/omlt/io/onnx_parser.py b/src/omlt/io/onnx_parser.py index 091082a7..4ac4c362 100644 --- a/src/omlt/io/onnx_parser.py +++ b/src/omlt/io/onnx_parser.py @@ -73,13 +73,15 @@ def parse_network(self, graph, scaling_object, input_bounds): dim_value = 1 size.append(dim.dim_value) dim_value *= dim.dim_value - assert dim_value is not None + if dim_value is None: + raise ValueError(f"All dimensions in {graph} input tensor have 0 value.") assert network_input is None network_input = InputLayer(size) self._node_map[input.name] = network_input network.add_layer(network_input) - assert network_input is not None + if network_input is None: + raise ValueError(f"No valid input layer found in {graph}.") self._nodes = nodes self._nodes_by_output = nodes_by_output @@ -109,11 +111,12 @@ def parse_network(self, graph, scaling_object, input_bounds): # Now connect inputs to the current node for input in node_inputs: self._nodes[input][2].append(node.name) - else: - assert node.op_type == "Constant" + elif node.op_type == "Constant": for output in node.output: value = _parse_constant_value(node) self._constants[output] = value + else: + raise ValueError(f"Nodes must have inputs or have op_type \"Constant\". {node} has no inputs and op_type {node.op_type}") # traverse graph self._node_stack = list(inputs) @@ -169,34 +172,43 @@ def _visit_node(self, node, next_nodes): def _consume_dense_nodes(self, node, next_nodes): """Starting from a MatMul node, consume nodes to form a dense Ax + b node.""" - assert node.op_type == "MatMul" - assert len(node.input) == 2 + if node.op_type != "MatMul": + raise ValueError(f"{node} is a {node.op_type} node, only MatMul nodes can be used as starting points for consumption.") + if len(node.input) != 2: + raise ValueError(f"{node} input has {len(node.input)} dimensions, only nodes with 2 input dimensions can be used as starting points for consumption.") [in_0, in_1] = list(node.input) input_layer, transformer = self._node_input_and_transformer(in_0) node_weights = self._initializers[in_1] - assert len(next_nodes) == 1 + if len(next_nodes) != 1: + raise ValueError(f"Next nodes must have length 1, {next_nodes} has length {len(next_nodes)}") # expect 'Add' node ahead type_, node, maybe_next_nodes = self._nodes[next_nodes[0]] - assert type_ == "node" - assert node.op_type == "Add" + if type_ != "node": + raise TypeError(f"Expected a node next, got a {type_} instead.") + if node.op_type != "Add": + raise ValueError(f"The first node to be consumed, {node}, is a {node.op_type} node. Only Add nodes are supported.") # extract biases next_nodes = maybe_next_nodes - assert len(node.input) == 2 [in_0, in_1] = list(node.input) if in_0 in self._initializers: node_biases = self._initializers[in_0] - else: - assert in_1 in self._initializers + elif in_1 in self._initializers: node_biases = self._initializers[in_1] + else: + raise ValueError(f"Node inputs were not found in graph initializers.") + + if len(node_weights.shape) != 2: + raise ValueError(f"Node weights must be a 2-dimensional matrix.") + if node_weights.shape[1] != node_biases.shape[0]: + raise ValueError(f"Node weights has {node_weights.shape[1]} columns; node biases has {node_biases.shape[0]} rows. These must be equal.") + if len(node.output) != 1: + raise ValueError(f"Node output is {node.output} but should be a single value.") - assert len(node_weights.shape) == 2 - assert node_weights.shape[1] == node_biases.shape[0] - assert len(node.output) == 1 input_output_size = _get_input_output_size(input_layer, transformer) @@ -226,8 +238,10 @@ def _consume_dense_nodes(self, node, next_nodes): def _consume_gemm_dense_nodes(self, node, next_nodes): """Starting from a Gemm node, consume nodes to form a dense aAB + bC node.""" - assert node.op_type == "Gemm" - assert len(node.input) == 3 + if node.op_type != "Gemm": + raise ValueError(f"{node} is a {node.op_type} node, only Gemm nodes can be used as starting points for consumption.") + if len(node.input) != 3: + raise ValueError(f"{node} input has {len(node.input)} dimensions, only nodes with 3 input dimensions can be used as starting points for consumption.") attr = _collect_attributes(node) alpha = attr["alpha"] @@ -275,8 +289,11 @@ def _consume_conv_nodes(self, node, next_nodes): Starting from a Conv node, consume nodes to form a convolution node with (optional) activation function. """ - assert node.op_type == "Conv" - assert len(node.input) in [2, 3] + if node.op_type != "Conv": + raise ValueError(f"{node} is a {node.op_type} node, only Conv nodes can be used as starting points for consumption.") + if len(node.input) not in [2,3]: + raise ValueError(f"{node} input has {len(node.input)} dimensions, only nodes with 2 or 3 input dimensions can be used as starting points for consumption.") + if len(node.input) == 2: [in_0, in_1] = list(node.input) in_2 = None @@ -295,18 +312,27 @@ def _consume_conv_nodes(self, node, next_nodes): attr = _collect_attributes(node) strides = attr["strides"] - # check only kernel shape and stride are set - # everything else is not supported - assert biases.shape == (out_channels,) - assert in_channels == input_output_size[0] - assert attr["kernel_shape"] == kernel_shape - assert attr["dilations"] == [1, 1] - assert attr["group"] == 1 - if "pads" in attr: - assert not np.any(attr["pads"]) # pads all zero - assert len(kernel_shape) == len(strides) - assert len(input_output_size) == len(kernel_shape) + 1 + if attr["kernel_shape"] != kernel_shape: + raise ValueError(f"Kernel shape attribute {attr["kernel_shape"]} does not match initialized kernel shape {kernel_shape}.") + if len(kernel_shape) != len(strides): + raise ValueError(f"Initialized kernel shape {kernel_shape} has {len(kernel_shape)} dimensions. Strides attribute has {len(strides)} dimensions. These must be equal.") + if len(input_output_size) != len(kernel_shape) + 1: + raise ValueError(f"Input/output size ({input_output_size}) must have one more dimension than initialized kernel shape ({kernel_shape}).") + + # Check input, output have correct dimensions + if biases.shape != (out_channels,): + raise ValueError(f"Biases shape {biases.shape} must match output weights channels {(out_channels,)}.") + if in_channels != input_output_size[0]: + raise ValueError(f"Input/output size ({input_output_size}) first dimension must match input weights channels ({in_channels}).") + + # Other attributes are not supported + if attr["dilations"] != [1, 1]: + raise ValueError(f"{node} has non-identity dilations ({attr["dilations"]}). This is not supported.") + if attr["group"] != 1: + raise ValueError(f"{node} has multiple groups ({attr["group"]}). This is not supported.") + if "pads" in attr and np.any(attr["pads"]): + raise ValueError(f"{node} has non-zero pads ({attr["pads"]}). This is not supported.") # generate new nodes for the node output padding = 0 @@ -326,7 +352,8 @@ def _consume_conv_nodes(self, node, next_nodes): # convolute image one channel at the time # expect 2d image with channels - assert len(input_output_size) == 3 + if len(input_output_size) != 3: + raise ValueError(f"Expected a 2D image with channels, got {input_output_size}.") conv_layer = ConvLayer2D( input_output_size, @@ -343,8 +370,10 @@ def _consume_conv_nodes(self, node, next_nodes): def _consume_reshape_nodes(self, node, next_nodes): """Parse a Reshape node.""" - assert node.op_type == "Reshape" - assert len(node.input) == 2 + if node.op_type != "Reshape": + raise ValueError(f"{node} is a {node.op_type} node, only Reshape nodes can be used as starting points for consumption.") + if len(node.input) != 2: + raise ValueError(f"{node} input has {len(node.input)} dimensions, only nodes with 2 input dimensions can be used as starting points for consumption.") [in_0, in_1] = list(node.input) input_layer = self._node_map[in_0] new_shape = self._constants[in_1] @@ -358,13 +387,16 @@ def _consume_pool_nodes(self, node, next_nodes): Starting from a MaxPool node, consume nodes to form a pooling node with (optional) activation function. """ - assert node.op_type in _POOLING_OP_TYPES + if node.op_type not in _POOLING_OP_TYPES: + raise ValueError(f"{node} is a {node.op_type} node, only MaxPool nodes can be used as starting points for consumption.") pool_func_name = "max" # ONNX network should not contain indices output from MaxPool - not supported by OMLT - assert len(node.output) == 1 + if len(node.output) != 1: + raise ValueError(f"The ONNX contains indices output from MaxPool. This is not supported by OMLT.") + if len(node.input) != 1: + raise ValueError(f"{node} input has {len(node.input)} dimensions, only nodes with 1 input dimension can be used as starting points for consumption.") - assert len(node.input) == 1 input_layer, transformer = self._node_input_and_transformer(node.input[0]) input_output_size = _get_input_output_size(input_layer, transformer) @@ -372,9 +404,9 @@ def _consume_pool_nodes(self, node, next_nodes): if len(input_output_size) == 4: # this means there is an extra dimension for number of batches # batches not supported, so only accept if they're not there or there is only 1 batch - assert input_output_size[0] == 1 + if input_output_size[0] != 1: + raise ValueError(f"{node} has {input_output_size[0]} batches, only a single batch is supported.") input_output_size = input_output_size[1:] - assert len(input_output_size) == 3 in_channels = input_output_size[0] @@ -385,11 +417,16 @@ def _consume_pool_nodes(self, node, next_nodes): # check only kernel shape, stride, storage order are set # everything else is not supported - assert ("dilations" not in attr) or (attr["dilations"] == [1, 1]) - assert ("pads" not in attr) or (not np.any(attr["pads"])) - assert ("auto_pad" not in attr) or (attr["auto_pad"] == "NOTSET") - assert len(kernel_shape) == len(strides) - assert len(input_output_size) == len(kernel_shape) + 1 + if attr["dilations"] != [1, 1]: + raise ValueError(f"{node} has non-identity dilations ({attr["dilations"]}). This is not supported.") + if "pads" in attr and np.any(attr["pads"]): + raise ValueError(f"{node} has non-zero pads ({attr["pads"]}). This is not supported.") + if ("auto_pad" in attr) and (attr["auto_pad"] != "NOTSET"): + raise ValueError(f"{node} has autopad set ({attr["auto_pad"]}). This is not supported.") + if len(kernel_shape) != len(strides): + raise ValueError(f"Kernel shape {kernel_shape} has {len(kernel_shape)} dimensions. Strides attribute has {len(strides)} dimensions. These must be equal.") + if len(input_output_size) != len(kernel_shape) + 1: + raise ValueError(f"Input/output size ({input_output_size}) must have one more dimension than kernel shape ({kernel_shape}).") output_shape_wrapper = math.floor if "ceil_mode" in attr and attr["ceil_mode"] == 1: diff --git a/src/omlt/neuralnet/layer.py b/src/omlt/neuralnet/layer.py index 42824efc..99523351 100644 --- a/src/omlt/neuralnet/layer.py +++ b/src/omlt/neuralnet/layer.py @@ -23,10 +23,12 @@ class Layer: def __init__( self, input_size, output_size, *, activation=None, input_index_mapper=None ): - assert isinstance(input_size, list) - assert isinstance(output_size, list) - self.__input_size = input_size - self.__output_size = output_size + if not isinstance(input_size, (list,tuple)): + raise TypeError(f"input_size must be a list or tuple, {type(input_size)} was provided.") + if not isinstance(output_size, (list,tuple)): + raise TypeError(f"output_size must be a list or tuple, {type(output_size)} was provided.") + self.__input_size = tuple(input_size) + self.__output_size = tuple(output_size) self.activation = activation if input_index_mapper is None: input_index_mapper = IndexMapper(input_size, input_size) @@ -99,7 +101,8 @@ def eval_single_layer(self, x): if self.__input_index_mapper is not None else x[:] ) - assert x_reshaped.shape == tuple(self.input_size) + if x_reshaped.shape != self.input_size: + raise ValueError(f"Layer requires an input size {self.input_size}, but the input tensor had size {x_reshaped.shape}.") y = self._eval(x_reshaped) return self._apply_activation(y) @@ -201,7 +204,6 @@ def __str__(self): def _eval(self, x): y = np.dot(x, self.__weights) + self.__biases y = np.reshape(y, tuple(self.output_size)) - assert y.shape == tuple(self.output_size) return y @@ -283,6 +285,7 @@ def kernel_index_with_input_indexes(self, out_d, out_r, out_c): for k_r in range(kernel_r): for k_c in range(kernel_c): input_index = (start_in_d + k_d, start_in_r + k_r, start_in_c + k_c) + assert len(input_index) == len(self.input_size) # don't yield an out-of-bounds input index; # can happen if ceil mode is enabled for pooling layers @@ -309,8 +312,9 @@ def get_input_index(self, out_index, kernel_index): def _eval(self, x): y = np.empty(shape=self.output_size) - assert len(self.output_size) == 3 - [depth, rows, cols] = self.output_size + if len(self.output_size) != 3: + raise ValueError(f"Output should have 3 dimensions but instead has {len(self.output_size)}") + [depth, rows, cols] = list(self.output_size) for out_d in range(depth): for out_r in range(rows): for out_c in range(cols): @@ -365,6 +369,8 @@ def __init__( activation=activation, input_index_mapper=input_index_mapper, ) + if pool_func_name not in PoolingLayer2D._POOL_FUNCTIONS: + raise ValueError(f"Allowable pool functions are {PoolingLayer2D._POOL_FUNCTIONS}, {pool_func_name} was provided.") self._pool_func_name = pool_func_name self._kernel_shape = kernel_shape self._kernel_depth = kernel_depth @@ -387,7 +393,6 @@ def _eval_at_index(self, x, out_d, out_r, out_c): x[index] for (_, index) in self.kernel_index_with_input_indexes(out_d, out_r, out_c) ] - assert self._pool_func_name in PoolingLayer2D._POOL_FUNCTIONS pool_func = PoolingLayer2D._POOL_FUNCTIONS[self._pool_func_name] return pool_func(vals) diff --git a/src/omlt/neuralnet/layers/full_space.py b/src/omlt/neuralnet/layers/full_space.py index e0923656..71296bdd 100644 --- a/src/omlt/neuralnet/layers/full_space.py +++ b/src/omlt/neuralnet/layers/full_space.py @@ -5,9 +5,6 @@ from omlt.neuralnet.activations import NON_INCREASING_ACTIVATIONS from omlt.neuralnet.layer import ConvLayer2D, IndexMapper, PoolingLayer2D - -# TODO: Change asserts to exceptions with messages (or ensure they -# TODO: are trapped higher up the call stack) def full_space_dense_layer(net_block, net, layer_block, layer): r""" Add full-space formulation of the dense layer to the block @@ -58,7 +55,8 @@ def full_space_conv2d_layer(net_block, net, layer_block, layer): and layer.activation != "linear" ): # activation applied after convolution layer, so there shouldn't be an activation after max pooling too - assert succ_layer.activation == "linear" + if succ_layer.activation != "linear": + raise ValueError(f"Activation is applied after convolution layer, but the successor max pooling layer {succ_layer} has an activation function also.") succ_layer.activation = layer.activation layer.activation = "linear" @@ -116,10 +114,11 @@ def full_space_maxpool2d_layer(net_block, net, layer_block, layer): """ input_layer, input_layer_block = _input_layer_and_block(net_block, net, layer) - assert isinstance(input_layer, ConvLayer2D) - assert ( - input_layer.activation == "linear" - ) # TODO - add support for non-increasing activation functions on preceding convolutional layer + if not isinstance(input_layer,ConvLayer2D): + raise TypeError("Input layer must be a ConvLayer2D.") + if input_layer.activation != "linear": + raise ValueError("Non-increasing activation functions on the preceding convolutional layer are not supported.") + # TODO - add support for non-increasing activation functions on preceding convolutional layer # note kernel indexes are the same set of values for any output index, so wlog get kernel indexes for (0, 0, 0) layer_block._kernel_indexes = pyo.Set( @@ -192,7 +191,8 @@ def _calculate_n_plus(out_index, l, k, layer, input_layer_block): def _input_layer_and_block(net_block, net, layer): input_layers = list(net.predecessors(layer)) - assert len(input_layers) == 1 + if len(input_layers) != 1: + raise ValueError("Multiple input layers are not currently supported.") input_layer = input_layers[0] input_layer_block = net_block.layer[id(input_layer)] return input_layer, input_layer_block diff --git a/src/omlt/neuralnet/layers/partition_based.py b/src/omlt/neuralnet/layers/partition_based.py index c7116d35..c0de2053 100644 --- a/src/omlt/neuralnet/layers/partition_based.py +++ b/src/omlt/neuralnet/layers/partition_based.py @@ -44,7 +44,10 @@ def partition_based_dense_relu_layer(net_block, net, layer_block, layer, split_f """ # not an input layer, process the expressions prev_layers = list(net.predecessors(layer)) - assert len(prev_layers) == 1 + if len(prev_layers) == 0: + raise ValueError(f"Layer {layer} is not an input layer, but has no predecessors.") + elif len(prev_layers) > 1: + raise ValueError(f"Layer {layer} has multiple predecessors.") prev_layer = prev_layers[0] prev_layer_block = net_block.layer[id(prev_layer)] @@ -86,7 +89,10 @@ def output_node_block(b, *output_index): expr += prev_layer_block.z[input_index] * w lb, ub = compute_bounds_on_expr(expr) - assert lb is not None and ub is not None + if lb is None: + raise ValueError("Expression is unbounded below.") + if ub is None: + raise ValueError("Expression is unbounded above.") z2 = b.z2[split_index] z2.setlb(min(0, lb)) @@ -106,7 +112,10 @@ def output_node_block(b, *output_index): expr += bias lb, ub = compute_bounds_on_expr(expr) - assert lb is not None and ub is not None + if lb is None: + raise ValueError("Expression is unbounded below.") + if ub is None: + raise ValueError("Expression is unbounded above.") layer_block.z[output_index].setlb(0) layer_block.z[output_index].setub(max(0, ub)) diff --git a/src/omlt/neuralnet/layers/reduced_space.py b/src/omlt/neuralnet/layers/reduced_space.py index 1584b775..76a56a07 100644 --- a/src/omlt/neuralnet/layers/reduced_space.py +++ b/src/omlt/neuralnet/layers/reduced_space.py @@ -11,7 +11,10 @@ def reduced_space_dense_layer(net_block, net, layer_block, layer, activation): """ # not an input layer, process the expressions prev_layers = list(net.predecessors(layer)) - assert len(prev_layers) == 1 + if len(prev_layers) == 0: + raise ValueError(f"Layer {layer} is not an input layer, but has no predecessors.") + elif len(prev_layers) > 1: + raise ValueError(f"Layer {layer} has multiple predecessors.") prev_layer = prev_layers[0] prev_layer_block = net_block.layer[id(prev_layer)] diff --git a/src/omlt/neuralnet/network_definition.py b/src/omlt/neuralnet/network_definition.py index ac930baa..c1a142a5 100644 --- a/src/omlt/neuralnet/network_definition.py +++ b/src/omlt/neuralnet/network_definition.py @@ -77,8 +77,10 @@ def add_edge(self, from_layer, to_layer): """ id_to = id(to_layer) id_from = id(from_layer) - assert id_to in self.__layers_by_id - assert id_from in self.__layers_by_id + if id_to not in self.__layers_by_id: + raise ValueError(f"Inbound layer {id_to} not found in network.") + if id_from not in self.__layers_by_id: + raise ValueError(f"Outbound layer {id_from} not found in network.") self.__graph.add_edge(id_from, id_to) @property diff --git a/src/omlt/neuralnet/nn_formulation.py b/src/omlt/neuralnet/nn_formulation.py index 068abd6b..60b776fc 100644 --- a/src/omlt/neuralnet/nn_formulation.py +++ b/src/omlt/neuralnet/nn_formulation.py @@ -86,15 +86,12 @@ def __init__( if activation_constraints is not None: self._activation_constraints.update(activation_constraints) - # TODO: Change these to exceptions. network_inputs = list(self.__network_definition.input_nodes) - assert ( - len(network_inputs) == 1 - ), "Multiple input layers are not currently supported." + if len(network_inputs) != 1: + raise ValueError("Multiple input layers are not currently supported.") network_outputs = list(self.__network_definition.output_nodes) - assert ( - len(network_outputs) == 1 - ), "Multiple output layers are not currently supported." + if len(network_outputs) != 1: + raise ValueError("Multiple output layers are not currently supported.") def _supported_default_layer_constraints(self): return _DEFAULT_LAYER_CONSTRAINTS @@ -118,18 +115,16 @@ def _build_formulation(self): def input_indexes(self): """The indexes of the formulation inputs.""" network_inputs = list(self.__network_definition.input_nodes) - assert ( - len(network_inputs) == 1 - ), "Multiple input layers are not currently supported." + if len(network_inputs) != 1: + raise ValueError("Multiple input layers are not currently supported.") return network_inputs[0].input_indexes @property def output_indexes(self): """The indexes of the formulation output.""" network_outputs = list(self.__network_definition.output_nodes) - assert ( - len(network_outputs) == 1 - ), "Multiple output layers are not currently supported." + if len(network_outputs) != 1: + raise ValueError("Multiple output layers are not currently supported.") return network_outputs[0].output_indexes @@ -199,7 +194,8 @@ def layer(b, layer_id): # setup input variables constraints # currently only support a single input layer input_layers = list(net.input_layers) - assert len(input_layers) == 1 + if len(input_layers) != 1: + raise ValueError("Multiple input layers are not currently supported.") input_layer = input_layers[0] @block.Constraint(input_layer.output_indexes) @@ -209,7 +205,8 @@ def input_assignment(b, *output_index): # setup output variables constraints # currently only support a single output layer output_layers = list(net.output_layers) - assert len(output_layers) == 1 + if len(output_layers) != 1: + raise ValueError("Multiple output layers are not currently supported.") output_layer = output_layers[0] @block.Constraint(output_layer.output_indexes) @@ -397,16 +394,16 @@ def output_assignment(b, *output_index): def input_indexes(self): """The indexes of the formulation inputs.""" network_inputs = list(self.__network_definition.input_nodes) - assert len(network_inputs) == 1, "Unsupported multiple network input variables" + if len(network_inputs) != 1: + raise ValueError("Multiple input layers are not currently supported.") return network_inputs[0].input_indexes @property def output_indexes(self): """The indexes of the formulation output.""" network_outputs = list(self.__network_definition.output_nodes) - assert ( - len(network_outputs) == 1 - ), "Unsupported multiple network output variables" + if len(network_outputs) != 1: + raise ValueError("Multiple output layers are not currently supported.") return network_outputs[0].output_indexes @@ -509,7 +506,8 @@ def layer(b, layer_id): # setup input variables constraints # currently only support a single input layer input_layers = list(net.input_layers) - assert len(input_layers) == 1 + if len(input_layers) != 1: + raise ValueError("Multiple input layers are not currently supported.") input_layer = input_layers[0] @block.Constraint(input_layer.output_indexes) @@ -522,7 +520,8 @@ def input_assignment(b, *output_index): # setup output variables constraints # currently only support a single output layer output_layers = list(net.output_layers) - assert len(output_layers) == 1 + if len(output_layers) != 1: + raise ValueError("Multiple output layers are not currently supported.") output_layer = output_layers[0] @block.Constraint(output_layer.output_indexes) @@ -536,14 +535,14 @@ def output_assignment(b, *output_index): def input_indexes(self): """The indexes of the formulation inputs.""" network_inputs = list(self.__network_definition.input_nodes) - assert len(network_inputs) == 1, "Unsupported multiple network input variables" + if len(network_inputs) != 1: + raise ValueError("Multiple input layers are not currently supported.") return network_inputs[0].input_indexes @property def output_indexes(self): """The indexes of the formulation output.""" network_outputs = list(self.__network_definition.output_nodes) - assert ( - len(network_outputs) == 1 - ), "Unsupported multiple network output variables" + if len(network_outputs) != 1: + raise ValueError("Multiple output layers are not currently supported.") return network_outputs[0].output_indexes From 41651768fdbfaea99e223a4eaa5cfce4baa5b29a Mon Sep 17 00:00:00 2001 From: Jeremy Sadler <53983960+jezsadler@users.noreply.github.com> Date: Wed, 11 Oct 2023 13:21:21 -0700 Subject: [PATCH 05/30] Fixing quote marks in f-strings --- src/omlt/io/onnx_parser.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/omlt/io/onnx_parser.py b/src/omlt/io/onnx_parser.py index 4ac4c362..1dcd3db1 100644 --- a/src/omlt/io/onnx_parser.py +++ b/src/omlt/io/onnx_parser.py @@ -314,7 +314,7 @@ def _consume_conv_nodes(self, node, next_nodes): strides = attr["strides"] # check only kernel shape and stride are set if attr["kernel_shape"] != kernel_shape: - raise ValueError(f"Kernel shape attribute {attr["kernel_shape"]} does not match initialized kernel shape {kernel_shape}.") + raise ValueError(f"Kernel shape attribute {attr['kernel_shape']} does not match initialized kernel shape {kernel_shape}.") if len(kernel_shape) != len(strides): raise ValueError(f"Initialized kernel shape {kernel_shape} has {len(kernel_shape)} dimensions. Strides attribute has {len(strides)} dimensions. These must be equal.") if len(input_output_size) != len(kernel_shape) + 1: @@ -418,11 +418,11 @@ def _consume_pool_nodes(self, node, next_nodes): # check only kernel shape, stride, storage order are set # everything else is not supported if attr["dilations"] != [1, 1]: - raise ValueError(f"{node} has non-identity dilations ({attr["dilations"]}). This is not supported.") + raise ValueError(f"{node} has non-identity dilations ({attr['dilations']}). This is not supported.") if "pads" in attr and np.any(attr["pads"]): - raise ValueError(f"{node} has non-zero pads ({attr["pads"]}). This is not supported.") + raise ValueError(f"{node} has non-zero pads ({attr['pads']}). This is not supported.") if ("auto_pad" in attr) and (attr["auto_pad"] != "NOTSET"): - raise ValueError(f"{node} has autopad set ({attr["auto_pad"]}). This is not supported.") + raise ValueError(f"{node} has autopad set ({attr['auto_pad']}). This is not supported.") if len(kernel_shape) != len(strides): raise ValueError(f"Kernel shape {kernel_shape} has {len(kernel_shape)} dimensions. Strides attribute has {len(strides)} dimensions. These must be equal.") if len(input_output_size) != len(kernel_shape) + 1: From 72825fe97244d25c090ff6e46be86ef24d34861d Mon Sep 17 00:00:00 2001 From: Jeremy Sadler <53983960+jezsadler@users.noreply.github.com> Date: Wed, 11 Oct 2023 13:23:35 -0700 Subject: [PATCH 06/30] Fixing more quotes in f-strings --- src/omlt/io/onnx_parser.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/omlt/io/onnx_parser.py b/src/omlt/io/onnx_parser.py index 1dcd3db1..eb33bacb 100644 --- a/src/omlt/io/onnx_parser.py +++ b/src/omlt/io/onnx_parser.py @@ -328,11 +328,11 @@ def _consume_conv_nodes(self, node, next_nodes): # Other attributes are not supported if attr["dilations"] != [1, 1]: - raise ValueError(f"{node} has non-identity dilations ({attr["dilations"]}). This is not supported.") + raise ValueError(f"{node} has non-identity dilations ({attr['dilations']}). This is not supported.") if attr["group"] != 1: - raise ValueError(f"{node} has multiple groups ({attr["group"]}). This is not supported.") + raise ValueError(f"{node} has multiple groups ({attr['group']}). This is not supported.") if "pads" in attr and np.any(attr["pads"]): - raise ValueError(f"{node} has non-zero pads ({attr["pads"]}). This is not supported.") + raise ValueError(f"{node} has non-zero pads ({attr['pads']}). This is not supported.") # generate new nodes for the node output padding = 0 From d1580f057a12c0d8db4b38ae48307b2e122284e1 Mon Sep 17 00:00:00 2001 From: Jeremy Sadler <53983960+jezsadler@users.noreply.github.com> Date: Wed, 11 Oct 2023 14:03:16 -0700 Subject: [PATCH 07/30] Reverting input_size and output_size to lists --- src/omlt/neuralnet/layer.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/omlt/neuralnet/layer.py b/src/omlt/neuralnet/layer.py index 99523351..45f1acba 100644 --- a/src/omlt/neuralnet/layer.py +++ b/src/omlt/neuralnet/layer.py @@ -10,9 +10,9 @@ class Layer: Parameters ---------- - input_size : tuple + input_size : list size of the layer input - output_size : tuple + output_size : list size of the layer output activation : str or None activation function name @@ -27,8 +27,8 @@ def __init__( raise TypeError(f"input_size must be a list or tuple, {type(input_size)} was provided.") if not isinstance(output_size, (list,tuple)): raise TypeError(f"output_size must be a list or tuple, {type(output_size)} was provided.") - self.__input_size = tuple(input_size) - self.__output_size = tuple(output_size) + self.__input_size = list(input_size) + self.__output_size = list(output_size) self.activation = activation if input_index_mapper is None: input_index_mapper = IndexMapper(input_size, input_size) @@ -101,7 +101,7 @@ def eval_single_layer(self, x): if self.__input_index_mapper is not None else x[:] ) - if x_reshaped.shape != self.input_size: + if x_reshaped.shape != tuple(self.input_size): raise ValueError(f"Layer requires an input size {self.input_size}, but the input tensor had size {x_reshaped.shape}.") y = self._eval(x_reshaped) return self._apply_activation(y) From 32eae68e37c05320f69a8142f79e559c0cb87a8a Mon Sep 17 00:00:00 2001 From: Jeremy Sadler <53983960+jezsadler@users.noreply.github.com> Date: Tue, 17 Oct 2023 22:25:44 +0000 Subject: [PATCH 08/30] Fixing check for "dilations" attribute --- src/omlt/io/onnx_parser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/omlt/io/onnx_parser.py b/src/omlt/io/onnx_parser.py index eb33bacb..15107e91 100644 --- a/src/omlt/io/onnx_parser.py +++ b/src/omlt/io/onnx_parser.py @@ -417,7 +417,7 @@ def _consume_pool_nodes(self, node, next_nodes): # check only kernel shape, stride, storage order are set # everything else is not supported - if attr["dilations"] != [1, 1]: + if "dilations" in attr and attr["dilations"] != [1, 1]: raise ValueError(f"{node} has non-identity dilations ({attr['dilations']}). This is not supported.") if "pads" in attr and np.any(attr["pads"]): raise ValueError(f"{node} has non-zero pads ({attr['pads']}). This is not supported.") From 0e0df7f192892b0f825ad4188f901ed050b1c71c Mon Sep 17 00:00:00 2001 From: Jeremy Sadler <53983960+jezsadler@users.noreply.github.com> Date: Tue, 17 Oct 2023 22:30:44 +0000 Subject: [PATCH 09/30] Fixing "dilations" attribute again --- src/omlt/io/onnx_parser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/omlt/io/onnx_parser.py b/src/omlt/io/onnx_parser.py index 15107e91..61535aeb 100644 --- a/src/omlt/io/onnx_parser.py +++ b/src/omlt/io/onnx_parser.py @@ -327,7 +327,7 @@ def _consume_conv_nodes(self, node, next_nodes): raise ValueError(f"Input/output size ({input_output_size}) first dimension must match input weights channels ({in_channels}).") # Other attributes are not supported - if attr["dilations"] != [1, 1]: + if "dilations" in attr and attr["dilations"] != [1, 1]: raise ValueError(f"{node} has non-identity dilations ({attr['dilations']}). This is not supported.") if attr["group"] != 1: raise ValueError(f"{node} has multiple groups ({attr['group']}). This is not supported.") From dc84be8ae90fa388969818bcc026a76aa850e100 Mon Sep 17 00:00:00 2001 From: Bashar Ammari <96192809+bammari@users.noreply.github.com> Date: Fri, 20 Oct 2023 15:55:57 -0400 Subject: [PATCH 10/30] Update README.rst (#126) New badge link --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 4ba4566f..cb90ed8e 100644 --- a/README.rst +++ b/README.rst @@ -4,7 +4,7 @@ :align: center :width: 200px -.. image:: https://github.com/cog-imperial/OMLT/workflows/CI/badge.svg?branch=main +.. image:: https://github.com/cog-imperial/OMLT/actions/workflows/main.yml/badge.svg :target: https://github.com/cog-imperial/OMLT/actions?workflow=CI :alt: CI Status From 7d39a629488464ef99c565d0631a4a7c137dc400 Mon Sep 17 00:00:00 2001 From: Jeremy Sadler <53983960+jezsadler@users.noreply.github.com> Date: Fri, 20 Oct 2023 21:28:52 +0000 Subject: [PATCH 11/30] Unit test for adding an edge with a bad layer --- src/omlt/neuralnet/network_definition.py | 4 +-- tests/neuralnet/test_network_definition.py | 42 ++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/omlt/neuralnet/network_definition.py b/src/omlt/neuralnet/network_definition.py index c1a142a5..aeef22eb 100644 --- a/src/omlt/neuralnet/network_definition.py +++ b/src/omlt/neuralnet/network_definition.py @@ -78,9 +78,9 @@ def add_edge(self, from_layer, to_layer): id_to = id(to_layer) id_from = id(from_layer) if id_to not in self.__layers_by_id: - raise ValueError(f"Inbound layer {id_to} not found in network.") + raise ValueError(f"Inbound layer {to_layer} not found in network.") if id_from not in self.__layers_by_id: - raise ValueError(f"Outbound layer {id_from} not found in network.") + raise ValueError(f"Outbound layer {from_layer} not found in network.") self.__graph.add_edge(id_from, id_to) @property diff --git a/tests/neuralnet/test_network_definition.py b/tests/neuralnet/test_network_definition.py index 1897e88b..8117c097 100644 --- a/tests/neuralnet/test_network_definition.py +++ b/tests/neuralnet/test_network_definition.py @@ -132,3 +132,45 @@ def test_input_bound_scaling_multiD(): scaler, scaled_input_bounds=None, unscaled_input_bounds=unscaled_input_bounds ) assert net.scaled_input_bounds == scaled_input_bounds + +def _test_add_invalid_edge(direction): + """ + direction can be "in" or "out" + """ + net = NetworkDefinition(scaled_input_bounds=[(-10.0, 10.0)]) + + input_layer = InputLayer([1]) + net.add_layer(input_layer) + + dense_layer_0 = DenseLayer( + input_layer.output_size, + [1, 2], + activation="relu", + weights=np.array([[1.0, -1.0]]), + biases=np.array([0.0, 0.0]), + ) + net.add_layer(dense_layer_0) + net.add_edge(input_layer, dense_layer_0) + + dense_layer_1 = DenseLayer( + input_layer.output_size, + dense_layer_0.input_size, + activation="linear", + weights=np.array([[1.0, 0.0], [5.0, 1.0]]), + biases=np.array([0.0, 0.0]), + ) + + if direction == 'in': + with pytest.raises(ValueError) as excinfo: + net.add_edge(input_layer,dense_layer_1) + expected_msg = f"Inbound layer {dense_layer_1} not found in network." + assert str(excinfo.value) == expected_msg + elif direction == 'out': + with pytest.raises(ValueError) as excinfo: + net.add_edge(dense_layer_1,dense_layer_0) + expected_msg = f"Outbound layer {dense_layer_1} not found in network." + assert str(excinfo.value) == expected_msg + +def test_add_invalid_edge(): + _test_add_invalid_edge('in') + _test_add_invalid_edge('out') \ No newline at end of file From d65b928698e78c0b2bf0d07aa80d3727e4db47fd Mon Sep 17 00:00:00 2001 From: Jeremy Sadler <53983960+jezsadler@users.noreply.github.com> Date: Fri, 20 Oct 2023 22:07:16 +0000 Subject: [PATCH 12/30] Unit tests for NN formulations --- src/omlt/neuralnet/nn_formulation.py | 19 ++- tests/neuralnet/test_nn_formulation.py | 184 +++++++++++++++++++++++++ 2 files changed, 201 insertions(+), 2 deletions(-) diff --git a/src/omlt/neuralnet/nn_formulation.py b/src/omlt/neuralnet/nn_formulation.py index 60b776fc..1b6bc187 100644 --- a/src/omlt/neuralnet/nn_formulation.py +++ b/src/omlt/neuralnet/nn_formulation.py @@ -195,7 +195,7 @@ def layer(b, layer_id): # currently only support a single input layer input_layers = list(net.input_layers) if len(input_layers) != 1: - raise ValueError("Multiple input layers are not currently supported.") + raise ValueError("Multiple input layers are not currently supported.") input_layer = input_layers[0] @block.Constraint(input_layer.output_indexes) @@ -206,7 +206,7 @@ def input_assignment(b, *output_index): # currently only support a single output layer output_layers = list(net.output_layers) if len(output_layers) != 1: - raise ValueError("Multiple output layers are not currently supported.") + raise ValueError("Multiple output layers are not currently supported.") output_layer = output_layers[0] @block.Constraint(output_layer.output_indexes) @@ -308,6 +308,16 @@ def __init__(self, network_structure, activation_functions=None): ) if activation_functions is not None: self._activation_functions.update(activation_functions) + + # If we want to do network input/output validation at initialize time instead + # of build time, as it is for FullSpaceNNFormulation: + # + # network_inputs = list(self.__network_definition.input_nodes) + # if len(network_inputs) != 1: + # raise ValueError("Multiple input layers are not currently supported.") + # network_outputs = list(self.__network_definition.output_nodes) + # if len(network_outputs) != 1: + # raise ValueError("Multiple output layers are not currently supported.") def _supported_default_activation_functions(self): return dict(_DEFAULT_ACTIVATION_FUNCTIONS) @@ -503,6 +513,11 @@ def layer(b, layer_id): else: raise ValueError("ReluPartitionFormulation supports only Dense layers") + # This check is never hit. The formulation._build_formulation() function is + # only ever called by an OmltBlock.build_formulation(), and that runs the + # input_indexes and output_indexes first, which will catch any formulations + # with multiple input or output layers. + # setup input variables constraints # currently only support a single input layer input_layers = list(net.input_layers) diff --git a/tests/neuralnet/test_nn_formulation.py b/tests/neuralnet/test_nn_formulation.py index d22eae97..7f462486 100644 --- a/tests/neuralnet/test_nn_formulation.py +++ b/tests/neuralnet/test_nn_formulation.py @@ -9,6 +9,7 @@ NetworkDefinition, ReducedSpaceNNFormulation, ReducedSpaceSmoothNNFormulation, + ReluPartitionFormulation, ) from omlt.neuralnet.layer import ( ConvLayer2D, @@ -17,6 +18,10 @@ InputLayer, PoolingLayer2D, ) +from omlt.neuralnet.layers.full_space import full_space_dense_layer +from omlt.neuralnet.layers.partition_based import partition_based_dense_relu_layer +from omlt.neuralnet.layers.reduced_space import reduced_space_dense_layer + def two_node_network(activation, input_value): @@ -325,3 +330,182 @@ def test_maxpool_FullSpaceNNFormulation(): m.obj1 = pyo.Objective(expr=0) status = pyo.SolverFactory("cbc").solve(m, tee=False) assert abs(pyo.value(m.neural_net_block.outputs[0, 0, 0]) - y[0, 0, 0]) < 1e-6 + +def _test_formulation_initialize_extra_input(network_formulation): + """ + network_formulation can be: + 'FullSpace', + 'ReducedSpace' + """ + net, y = two_node_network("linear", -2.0) + extra_input = InputLayer([1]) + net.add_layer(extra_input) + with pytest.raises(ValueError) as excinfo: + if network_formulation == 'FullSpace': + formulation = FullSpaceNNFormulation(net) + elif network_formulation == 'ReducedSpace': + formulation = ReducedSpaceNNFormulation(net) + expected_msg = "Multiple input layers are not currently supported." + assert str(excinfo.value) == expected_msg + +def _test_formulation_added_extra_input(network_formulation): + """ + network_formulation can be: + 'FullSpace', + 'ReducedSpace' + 'relu' + """ + net, y = two_node_network("linear", -2.0) + extra_input = InputLayer([1]) + if network_formulation == 'FullSpace': + formulation = FullSpaceNNFormulation(net) + elif network_formulation == 'ReducedSpace': + formulation = ReducedSpaceNNFormulation(net) + elif network_formulation == 'relu': + formulation = ReluPartitionFormulation(net) + net.add_layer(extra_input) + with pytest.raises(ValueError) as excinfo: + formulation.input_indexes + expected_msg = "Multiple input layers are not currently supported." + assert str(excinfo.value) == expected_msg + +def _test_formulation_build_extra_input(network_formulation): + """ + network_formulation can be: + 'FullSpace', + 'ReducedSpace' + 'relu' + """ + net, y = two_node_network("linear", -2.0) + extra_input = InputLayer([1]) + if network_formulation == 'FullSpace': + formulation = FullSpaceNNFormulation(net) + elif network_formulation == 'ReducedSpace': + formulation = ReducedSpaceNNFormulation(net) + elif network_formulation == 'relu': + formulation = ReluPartitionFormulation(net) + net.add_layer(extra_input) + m = pyo.ConcreteModel() + m.neural_net_block = OmltBlock() + with pytest.raises(ValueError) as excinfo: + m.neural_net_block.build_formulation(formulation) + expected_msg = "Multiple input layers are not currently supported." + assert str(excinfo.value) == expected_msg + +def _test_formulation_added_extra_output(network_formulation): + """ + network_formulation can be: + 'FullSpace', + 'ReducedSpace' + 'relu' + """ + net, y = two_node_network("linear", -2.0) + extra_output = DenseLayer( + [1, 2], + [1, 2], + activation="linear", + weights=np.array([[1.0, 0.0], [5.0, 1.0]]), + biases=np.array([3.0, 4.0]), + ) + if network_formulation == 'FullSpace': + formulation = FullSpaceNNFormulation(net) + elif network_formulation == 'ReducedSpace': + formulation = ReducedSpaceNNFormulation(net) + elif network_formulation == 'relu': + formulation = ReluPartitionFormulation(net) + net.add_layer(extra_output) + net.add_edge(list(net.layers)[-2],extra_output) + with pytest.raises(ValueError) as excinfo: + formulation.output_indexes + expected_msg = "Multiple output layers are not currently supported." + assert str(excinfo.value) == expected_msg + +def _test_formulation_initialize_extra_output(network_formulation): + """ + network_formulation can be: + 'FullSpace', + 'ReducedSpace' + """ + net, y = two_node_network("linear", -2.0) + extra_output = DenseLayer( + [1, 2], + [1, 2], + activation="linear", + weights=np.array([[1.0, 0.0], [5.0, 1.0]]), + biases=np.array([3.0, 4.0]), + ) + net.add_layer(extra_output) + net.add_edge(list(net.layers)[-2],extra_output) + with pytest.raises(ValueError) as excinfo: + if network_formulation == 'FullSpace': + formulation = FullSpaceNNFormulation(net) + elif network_formulation == 'ReducedSpace': + formulation = ReducedSpaceNNFormulation(net) + expected_msg = "Multiple output layers are not currently supported." + assert str(excinfo.value) == expected_msg + +def test_FullSpaceNNFormulation_invalid_network(): + _test_formulation_initialize_extra_input("FullSpace") + _test_formulation_added_extra_input("FullSpace") + _test_formulation_build_extra_input("FullSpace") + _test_formulation_initialize_extra_output("FullSpace") + _test_formulation_added_extra_output("FullSpace") + +def test_ReducedSpaceNNFormulation_invalid_network(): + # _test_formulation_initialize_extra_input("ReducedSpace") + _test_formulation_added_extra_input("ReducedSpace") + _test_formulation_build_extra_input("ReducedSpace") + # _test_formulation_initialize_extra_output("ReducedSpace") + _test_formulation_added_extra_output("ReducedSpace") + +def test_ReluPartitionFormulation_invalid_network(): + _test_formulation_added_extra_input("relu") + _test_formulation_build_extra_input("relu") + _test_formulation_added_extra_output("relu") + +def _test_dense_layer_multiple_predecessors(layer_type): + m = pyo.ConcreteModel() + m.neural_net_block = OmltBlock() + net, y = two_node_network(None, -2.0) + extra_input = InputLayer([1]) + test_layer = list(net.layers)[2] + net.add_layer(extra_input) + net.add_edge(extra_input,test_layer) + with pytest.raises(ValueError) as excinfo: + if layer_type == 'PartitionBased': + partition_based_dense_relu_layer(m,net,m,test_layer,None) + elif layer_type == 'ReducedSpace': + reduced_space_dense_layer(m,net,m,test_layer,None) + expected_msg = f"Layer {test_layer} has multiple predecessors." + assert str(excinfo.value) == expected_msg + +def _test_dense_layer_no_predecessors(layer_type): + """ + Layer type can be "ReducedSpace", or "PartitionBased". + """ + m = pyo.ConcreteModel() + net = NetworkDefinition(scaled_input_bounds=[(-10.0, 10.0)]) + + test_layer = DenseLayer( + [1], + [1, 2], + activation=None, + weights=np.array([[1.0, -1.0]]), + biases=np.array([1.0, 2.0]), + ) + net.add_layer(test_layer) + with pytest.raises(ValueError) as excinfo: + if layer_type == 'PartitionBased': + partition_based_dense_relu_layer(m,net,m,test_layer,None) + elif layer_type == 'ReducedSpace': + reduced_space_dense_layer(m,net,m,test_layer,None) + expected_msg = f"Layer {test_layer} is not an input layer, but has no predecessors." + assert str(excinfo.value) == expected_msg + +def test_partition_based_dense_layer_predecessors(): + _test_dense_layer_multiple_predecessors("PartitionBased") + _test_dense_layer_no_predecessors("PartitionBased") + +def test_reduced_space_dense_layer_predecessors(): + _test_dense_layer_multiple_predecessors("ReducedSpace") + _test_dense_layer_no_predecessors("ReducedSpace") \ No newline at end of file From 9c3d356587b86bf53ddfe16da3fd5ae7bab15444 Mon Sep 17 00:00:00 2001 From: Jeremy Sadler <53983960+jezsadler@users.noreply.github.com> Date: Fri, 20 Oct 2023 23:27:23 +0000 Subject: [PATCH 13/30] Unit tests for ONNX parser input tensors --- src/omlt/io/onnx_parser.py | 6 +++--- tests/io/test_onnx_parser.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/omlt/io/onnx_parser.py b/src/omlt/io/onnx_parser.py index 61535aeb..a0496cfc 100644 --- a/src/omlt/io/onnx_parser.py +++ b/src/omlt/io/onnx_parser.py @@ -74,14 +74,14 @@ def parse_network(self, graph, scaling_object, input_bounds): size.append(dim.dim_value) dim_value *= dim.dim_value if dim_value is None: - raise ValueError(f"All dimensions in {graph} input tensor have 0 value.") + raise ValueError(f"All dimensions in graph \"{graph.name}\" input tensor have 0 value.") assert network_input is None network_input = InputLayer(size) self._node_map[input.name] = network_input network.add_layer(network_input) if network_input is None: - raise ValueError(f"No valid input layer found in {graph}.") + raise ValueError(f"No valid input layer found in graph \"{graph.name}\".") self._nodes = nodes self._nodes_by_output = nodes_by_output @@ -116,7 +116,7 @@ def parse_network(self, graph, scaling_object, input_bounds): value = _parse_constant_value(node) self._constants[output] = value else: - raise ValueError(f"Nodes must have inputs or have op_type \"Constant\". {node} has no inputs and op_type {node.op_type}") + raise ValueError(f"Nodes must have inputs or have op_type \"Constant\". Node \"{node.name}\" has no inputs and op_type \"{node.op_type}\".") # traverse graph self._node_stack = list(inputs) diff --git a/tests/io/test_onnx_parser.py b/tests/io/test_onnx_parser.py index 6454938d..2812cd96 100644 --- a/tests/io/test_onnx_parser.py +++ b/tests/io/test_onnx_parser.py @@ -4,6 +4,7 @@ if onnx_available: from omlt.io.onnx import load_onnx_neural_network + from omlt.io.onnx_parser import NetworkParser @pytest.mark.skipif(not onnx_available, reason="Need ONNX for this test") @@ -105,3 +106,34 @@ def test_maxpool(datadir): assert layers[3].output_size == [3, 2, 1] for layer in layers[1:]: assert layer.kernel_depth == 3 + +@pytest.mark.skipif(not onnx_available, reason="Need ONNX for this test") +def test_input_tensor_invalid_dims(datadir): + model = onnx.load(datadir.file("keras_linear_131.onnx")) + model.graph.input[0].type.tensor_type.shape.dim[1].dim_value = 0 + parser = NetworkParser() + with pytest.raises(ValueError) as excinfo: + parser.parse_network(model.graph,None,None) + expected_msg = "All dimensions in graph \"tf2onnx\" input tensor have 0 value." + assert str(excinfo.value) == expected_msg + +@pytest.mark.skipif(not onnx_available, reason="Need ONNX for this test") +def test_no_input_layers(datadir): + model = onnx.load(datadir.file("keras_linear_131.onnx")) + model.graph.input.remove(model.graph.input[0]) + parser = NetworkParser() + with pytest.raises(ValueError) as excinfo: + parser.parse_network(model.graph,None,None) + expected_msg = "No valid input layer found in graph \"tf2onnx\"." + assert str(excinfo.value) == expected_msg + +@pytest.mark.skipif(not onnx_available, reason="Need ONNX for this test") +def test_node_no_inputs(datadir): + model = onnx.load(datadir.file("keras_linear_131.onnx")) + while (len(model.graph.node[0].input) > 0): + model.graph.node[0].input.pop() + parser = NetworkParser() + with pytest.raises(ValueError) as excinfo: + parser.parse_network(model.graph,None,None) + expected_msg = "Nodes must have inputs or have op_type \"Constant\". Node \"StatefulPartitionedCall/keras_linear_131/dense/MatMul\" has no inputs and op_type \"MatMul\"." + assert str(excinfo.value) == expected_msg From e75a50420464d918716fe1ddb3e75d3d00f54255 Mon Sep 17 00:00:00 2001 From: jalving Date: Wed, 25 Oct 2023 21:46:16 -0700 Subject: [PATCH 14/30] update RTD yml --- .readthedocs.yml | 67 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 61 insertions(+), 6 deletions(-) diff --git a/.readthedocs.yml b/.readthedocs.yml index 29c410e7..adac860d 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,9 +1,64 @@ -# Read the Docs configuration file +# Read the Docs configuration file for Sphinx projects + # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details -conda: - file: docs/environment.yml -python: - version: 3.8 - setup_py_install: true +# Required + +version: 2 + + +# Set the OS, Python version and other tools you might need + +build: + + os: ubuntu-22.04 + + tools: + + python: "3.9" + + # You can also specify other tool versions: + + # nodejs: "20" + + # rust: "1.70" + + # golang: "1.20" + + +# Build documentation in the "docs/" directory with Sphinx + +sphinx: + + configuration: docs/conf.py + + # You can configure Sphinx to use a different builder, for instance use the dirhtml builder for simpler URLs + + # builder: "dirhtml" + + # Fail on all warnings to avoid broken references + + # fail_on_warning: true + + +# Optionally build your docs in additional formats such as PDF and ePub + +# formats: + +# - pdf + +# - epub + + +# Optional but recommended, declare the Python requirements required + +# to build your documentation + +# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html + +# python: + +# install: + +# - requirements: docs/requirements.txt \ No newline at end of file From cb560fe9c07a96222038b7a256c960535bd760e7 Mon Sep 17 00:00:00 2001 From: jalving Date: Wed, 25 Oct 2023 21:59:02 -0700 Subject: [PATCH 15/30] add requirement.txt --- docs/requirements.txt | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 docs/requirements.txt diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 00000000..51a98171 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,3 @@ +# Required dependencies for Sphinx documentation +sphinx +sphinx-rtd-theme \ No newline at end of file From 1366a3a5de6aadd2d5dead5cb81e7dba3de693fb Mon Sep 17 00:00:00 2001 From: jalving Date: Sat, 28 Oct 2023 15:29:25 -0700 Subject: [PATCH 16/30] uncomment requirements line --- .readthedocs.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.readthedocs.yml b/.readthedocs.yml index adac860d..ebcd5eba 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -57,8 +57,8 @@ sphinx: # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html -# python: +python: -# install: + install: -# - requirements: docs/requirements.txt \ No newline at end of file + - requirements: docs/requirements.txt \ No newline at end of file From b172b95e7a6970d1ff82fbca707dbfe27d4b1209 Mon Sep 17 00:00:00 2001 From: jalving Date: Sat, 28 Oct 2023 15:45:50 -0700 Subject: [PATCH 17/30] comment out environment.yml --- docs/environment.yml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/environment.yml b/docs/environment.yml index 4044f80e..78185e70 100644 --- a/docs/environment.yml +++ b/docs/environment.yml @@ -1,11 +1,11 @@ -name: omlt -channels: - - conda-forge -dependencies: - - python=3.8 - - numpy - - pyomo - - networkx - - onnx - - tensorflow - - importlib-metadata \ No newline at end of file +# name: omlt +# channels: +# - conda-forge +# dependencies: +# - python=3.8 +# - numpy +# - pyomo +# - networkx +# - onnx +# - tensorflow +# - importlib-metadata \ No newline at end of file From e1d4842521f7401a09e58773734bf78bf4f6b1c5 Mon Sep 17 00:00:00 2001 From: jalving Date: Sat, 28 Oct 2023 15:59:34 -0700 Subject: [PATCH 18/30] update doc requirements --- .readthedocs.yml | 2 +- docs/requirements.txt | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.readthedocs.yml b/.readthedocs.yml index ebcd5eba..6e41af22 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -16,7 +16,7 @@ build: tools: - python: "3.9" + python: "3.8" # You can also specify other tool versions: diff --git a/docs/requirements.txt b/docs/requirements.txt index 51a98171..ce42941c 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,3 +1,9 @@ # Required dependencies for Sphinx documentation sphinx -sphinx-rtd-theme \ No newline at end of file +sphinx-rtd-theme +numpy +pyomo +networkx +onnx +tensorflow +importlib-metadata \ No newline at end of file From 500b521e8b218799a3032110c7c90043944f4d66 Mon Sep 17 00:00:00 2001 From: jalving Date: Sat, 28 Oct 2023 16:17:24 -0700 Subject: [PATCH 19/30] add linear tree --- docs/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/requirements.txt b/docs/requirements.txt index ce42941c..6305e50b 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -6,4 +6,5 @@ pyomo networkx onnx tensorflow +linear-tree importlib-metadata \ No newline at end of file From 8728025e9a681b79160b16bdb7919fd512dc26f2 Mon Sep 17 00:00:00 2001 From: jalving Date: Sat, 28 Oct 2023 16:24:35 -0700 Subject: [PATCH 20/30] remove old docs/environment.yml --- docs/environment.yml | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 docs/environment.yml diff --git a/docs/environment.yml b/docs/environment.yml deleted file mode 100644 index 78185e70..00000000 --- a/docs/environment.yml +++ /dev/null @@ -1,11 +0,0 @@ -# name: omlt -# channels: -# - conda-forge -# dependencies: -# - python=3.8 -# - numpy -# - pyomo -# - networkx -# - onnx -# - tensorflow -# - importlib-metadata \ No newline at end of file From 0f6856bca4d45405ecdfd15b2c3ab549252b8822 Mon Sep 17 00:00:00 2001 From: Jeremy Sadler <53983960+jezsadler@users.noreply.github.com> Date: Wed, 1 Nov 2023 07:07:03 +0000 Subject: [PATCH 21/30] Unit tests for some network parsing errors --- src/omlt/io/onnx_parser.py | 30 ++++++------- tests/io/test_onnx_parser.py | 87 ++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 15 deletions(-) diff --git a/src/omlt/io/onnx_parser.py b/src/omlt/io/onnx_parser.py index a0496cfc..afb16e9f 100644 --- a/src/omlt/io/onnx_parser.py +++ b/src/omlt/io/onnx_parser.py @@ -173,9 +173,9 @@ def _visit_node(self, node, next_nodes): def _consume_dense_nodes(self, node, next_nodes): """Starting from a MatMul node, consume nodes to form a dense Ax + b node.""" if node.op_type != "MatMul": - raise ValueError(f"{node} is a {node.op_type} node, only MatMul nodes can be used as starting points for consumption.") + raise ValueError(f"{node.name} is a {node.op_type} node, only MatMul nodes can be used as starting points for consumption.") if len(node.input) != 2: - raise ValueError(f"{node} input has {len(node.input)} dimensions, only nodes with 2 input dimensions can be used as starting points for consumption.") + raise ValueError(f"{node.name} input has {len(node.input)} dimensions, only nodes with 2 input dimensions can be used as starting points for consumption.") [in_0, in_1] = list(node.input) input_layer, transformer = self._node_input_and_transformer(in_0) @@ -189,7 +189,7 @@ def _consume_dense_nodes(self, node, next_nodes): if type_ != "node": raise TypeError(f"Expected a node next, got a {type_} instead.") if node.op_type != "Add": - raise ValueError(f"The first node to be consumed, {node}, is a {node.op_type} node. Only Add nodes are supported.") + raise ValueError(f"The first node to be consumed, {node.name}, is a {node.op_type} node. Only Add nodes are supported.") # extract biases next_nodes = maybe_next_nodes @@ -239,9 +239,9 @@ def _consume_dense_nodes(self, node, next_nodes): def _consume_gemm_dense_nodes(self, node, next_nodes): """Starting from a Gemm node, consume nodes to form a dense aAB + bC node.""" if node.op_type != "Gemm": - raise ValueError(f"{node} is a {node.op_type} node, only Gemm nodes can be used as starting points for consumption.") + raise ValueError(f"{node.name} is a {node.op_type} node, only Gemm nodes can be used as starting points for consumption.") if len(node.input) != 3: - raise ValueError(f"{node} input has {len(node.input)} dimensions, only nodes with 3 input dimensions can be used as starting points for consumption.") + raise ValueError(f"{node.name} input has {len(node.input)} dimensions, only nodes with 3 input dimensions can be used as starting points for consumption.") attr = _collect_attributes(node) alpha = attr["alpha"] @@ -290,9 +290,9 @@ def _consume_conv_nodes(self, node, next_nodes): (optional) activation function. """ if node.op_type != "Conv": - raise ValueError(f"{node} is a {node.op_type} node, only Conv nodes can be used as starting points for consumption.") + raise ValueError(f"{node.name} is a {node.op_type} node, only Conv nodes can be used as starting points for consumption.") if len(node.input) not in [2,3]: - raise ValueError(f"{node} input has {len(node.input)} dimensions, only nodes with 2 or 3 input dimensions can be used as starting points for consumption.") + raise ValueError(f"{node.name} input has {len(node.input)} dimensions, only nodes with 2 or 3 input dimensions can be used as starting points for consumption.") if len(node.input) == 2: [in_0, in_1] = list(node.input) @@ -371,9 +371,9 @@ def _consume_conv_nodes(self, node, next_nodes): def _consume_reshape_nodes(self, node, next_nodes): """Parse a Reshape node.""" if node.op_type != "Reshape": - raise ValueError(f"{node} is a {node.op_type} node, only Reshape nodes can be used as starting points for consumption.") + raise ValueError(f"{node.name} is a {node.op_type} node, only Reshape nodes can be used as starting points for consumption.") if len(node.input) != 2: - raise ValueError(f"{node} input has {len(node.input)} dimensions, only nodes with 2 input dimensions can be used as starting points for consumption.") + raise ValueError(f"{node.name} input has {len(node.input)} dimensions, only nodes with 2 input dimensions can be used as starting points for consumption.") [in_0, in_1] = list(node.input) input_layer = self._node_map[in_0] new_shape = self._constants[in_1] @@ -388,14 +388,14 @@ def _consume_pool_nodes(self, node, next_nodes): (optional) activation function. """ if node.op_type not in _POOLING_OP_TYPES: - raise ValueError(f"{node} is a {node.op_type} node, only MaxPool nodes can be used as starting points for consumption.") + raise ValueError(f"{node.name} is a {node.op_type} node, only MaxPool nodes can be used as starting points for consumption.") pool_func_name = "max" # ONNX network should not contain indices output from MaxPool - not supported by OMLT if len(node.output) != 1: raise ValueError(f"The ONNX contains indices output from MaxPool. This is not supported by OMLT.") if len(node.input) != 1: - raise ValueError(f"{node} input has {len(node.input)} dimensions, only nodes with 1 input dimension can be used as starting points for consumption.") + raise ValueError(f"{node.name} input has {len(node.input)} dimensions, only nodes with 1 input dimension can be used as starting points for consumption.") input_layer, transformer = self._node_input_and_transformer(node.input[0]) input_output_size = _get_input_output_size(input_layer, transformer) @@ -405,7 +405,7 @@ def _consume_pool_nodes(self, node, next_nodes): # this means there is an extra dimension for number of batches # batches not supported, so only accept if they're not there or there is only 1 batch if input_output_size[0] != 1: - raise ValueError(f"{node} has {input_output_size[0]} batches, only a single batch is supported.") + raise ValueError(f"{node.name} has {input_output_size[0]} batches, only a single batch is supported.") input_output_size = input_output_size[1:] in_channels = input_output_size[0] @@ -418,11 +418,11 @@ def _consume_pool_nodes(self, node, next_nodes): # check only kernel shape, stride, storage order are set # everything else is not supported if "dilations" in attr and attr["dilations"] != [1, 1]: - raise ValueError(f"{node} has non-identity dilations ({attr['dilations']}). This is not supported.") + raise ValueError(f"{node.name} has non-identity dilations ({attr['dilations']}). This is not supported.") if "pads" in attr and np.any(attr["pads"]): - raise ValueError(f"{node} has non-zero pads ({attr['pads']}). This is not supported.") + raise ValueError(f"{node.name} has non-zero pads ({attr['pads']}). This is not supported.") if ("auto_pad" in attr) and (attr["auto_pad"] != "NOTSET"): - raise ValueError(f"{node} has autopad set ({attr['auto_pad']}). This is not supported.") + raise ValueError(f"{node.name} has autopad set ({attr['auto_pad']}). This is not supported.") if len(kernel_shape) != len(strides): raise ValueError(f"Kernel shape {kernel_shape} has {len(kernel_shape)} dimensions. Strides attribute has {len(strides)} dimensions. These must be equal.") if len(input_output_size) != len(kernel_shape) + 1: diff --git a/tests/io/test_onnx_parser.py b/tests/io/test_onnx_parser.py index 2812cd96..c1438264 100644 --- a/tests/io/test_onnx_parser.py +++ b/tests/io/test_onnx_parser.py @@ -137,3 +137,90 @@ def test_node_no_inputs(datadir): parser.parse_network(model.graph,None,None) expected_msg = "Nodes must have inputs or have op_type \"Constant\". Node \"StatefulPartitionedCall/keras_linear_131/dense/MatMul\" has no inputs and op_type \"MatMul\"." assert str(excinfo.value) == expected_msg + +@pytest.mark.skipif(not onnx_available, reason="Need ONNX for this test") +def test_consume_wrong_node_type(datadir): + model = onnx.load(datadir.file("keras_linear_131.onnx")) + parser = NetworkParser() + parser.parse_network(model.graph,None,None) + + with pytest.raises(ValueError) as excinfo: + parser._consume_dense_nodes(parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][1],parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][2]) + expected_msg_dense = "StatefulPartitionedCall/keras_linear_131/dense/BiasAdd is a Add node, only MatMul nodes can be used as starting points for consumption." + assert str(excinfo.value) == expected_msg_dense + + with pytest.raises(ValueError) as excinfo: + parser._consume_gemm_dense_nodes(parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][1],parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][2]) + expected_msg_gemm = "StatefulPartitionedCall/keras_linear_131/dense/BiasAdd is a Add node, only Gemm nodes can be used as starting points for consumption." + assert str(excinfo.value) == expected_msg_gemm + + with pytest.raises(ValueError) as excinfo: + parser._consume_conv_nodes(parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][1],parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][2]) + expected_msg_conv = "StatefulPartitionedCall/keras_linear_131/dense/BiasAdd is a Add node, only Conv nodes can be used as starting points for consumption." + assert str(excinfo.value) == expected_msg_conv + + with pytest.raises(ValueError) as excinfo: + parser._consume_reshape_nodes(parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][1],parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][2]) + expected_msg_reshape = "StatefulPartitionedCall/keras_linear_131/dense/BiasAdd is a Add node, only Reshape nodes can be used as starting points for consumption." + assert str(excinfo.value) == expected_msg_reshape + + with pytest.raises(ValueError) as excinfo: + parser._consume_pool_nodes(parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][1],parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][2]) + expected_msg_pool = "StatefulPartitionedCall/keras_linear_131/dense/BiasAdd is a Add node, only MaxPool nodes can be used as starting points for consumption." + assert str(excinfo.value) == expected_msg_pool + +@pytest.mark.skipif(not onnx_available, reason="Need ONNX for this test") +def test_consume_dense_wrong_dims(datadir): + model = onnx.load(datadir.file("keras_linear_131.onnx")) + parser = NetworkParser() + parser.parse_network(model.graph,None,None) + + parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/MatMul'][1].input.append('abcd') + with pytest.raises(ValueError) as excinfo: + parser._consume_dense_nodes(parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/MatMul'][1],parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/MatMul'][2]) + expected_msg_dense = "StatefulPartitionedCall/keras_linear_131/dense/MatMul input has 3 dimensions, only nodes with 2 input dimensions can be used as starting points for consumption." + assert str(excinfo.value) == expected_msg_dense + +@pytest.mark.skipif(not onnx_available, reason="Need ONNX for this test") +def test_consume_gemm_wrong_dims(datadir): + model = onnx.load(datadir.file("gemm.onnx")) + parser = NetworkParser() + parser.parse_network(model.graph,None,None) + parser._nodes['Gemm_0'][1].input.append('abcd') + with pytest.raises(ValueError) as excinfo: + parser._consume_gemm_dense_nodes(parser._nodes['Gemm_0'][1],parser._nodes['Gemm_0'][2]) + expected_msg_gemm = "Gemm_0 input has 4 dimensions, only nodes with 3 input dimensions can be used as starting points for consumption." + assert str(excinfo.value) == expected_msg_gemm + +@pytest.mark.skipif(not onnx_available, reason="Need ONNX for this test") +def test_consume_conv_wrong_dims(datadir): + model = onnx.load(datadir.file("convx1_gemmx1.onnx")) + parser = NetworkParser() + parser.parse_network(model.graph,None,None) + parser._nodes['Conv_0'][1].input.append('abcd') + with pytest.raises(ValueError) as excinfo: + parser._consume_conv_nodes(parser._nodes['Conv_0'][1],parser._nodes['Conv_0'][2]) + expected_msg_conv = "Conv_0 input has 4 dimensions, only nodes with 2 or 3 input dimensions can be used as starting points for consumption." + assert str(excinfo.value) == expected_msg_conv + +@pytest.mark.skipif(not onnx_available, reason="Need ONNX for this test") +def test_consume_reshape_wrong_dims(datadir): + model = onnx.load(datadir.file("convx1_gemmx1.onnx")) + parser = NetworkParser() + parser.parse_network(model.graph,None,None) + parser._nodes['Reshape_2'][1].input.append('abcd') + with pytest.raises(ValueError) as excinfo: + parser._consume_reshape_nodes(parser._nodes['Reshape_2'][1],parser._nodes['Reshape_2'][2]) + expected_msg_reshape = "Reshape_2 input has 3 dimensions, only nodes with 2 input dimensions can be used as starting points for consumption." + assert str(excinfo.value) == expected_msg_reshape + +@pytest.mark.skipif(not onnx_available, reason="Need ONNX for this test") +def test_consume_maxpool_wrong_dims(datadir): + model = onnx.load(datadir.file("maxpool_2d.onnx")) + parser = NetworkParser() + parser.parse_network(model.graph,None,None) + parser._nodes['node1'][1].input.append('abcd') + with pytest.raises(ValueError) as excinfo: + parser._consume_pool_nodes(parser._nodes['node1'][1],parser._nodes['node1'][2]) + expected_msg_maxpool = "node1 input has 2 dimensions, only nodes with 1 input dimension can be used as starting points for consumption." + assert str(excinfo.value) == expected_msg_maxpool \ No newline at end of file From 797b4522ed13288e20d62ff55640d9d739f7edd6 Mon Sep 17 00:00:00 2001 From: Jeremy Sadler <53983960+jezsadler@users.noreply.github.com> Date: Fri, 3 Nov 2023 03:44:03 +0000 Subject: [PATCH 22/30] Unit tests for the error messages in NN layers --- tests/neuralnet/test_nn_formulation.py | 241 ++++++++++++++++++++++++- 1 file changed, 238 insertions(+), 3 deletions(-) diff --git a/tests/neuralnet/test_nn_formulation.py b/tests/neuralnet/test_nn_formulation.py index 7f462486..4b345735 100644 --- a/tests/neuralnet/test_nn_formulation.py +++ b/tests/neuralnet/test_nn_formulation.py @@ -18,10 +18,11 @@ InputLayer, PoolingLayer2D, ) -from omlt.neuralnet.layers.full_space import full_space_dense_layer -from omlt.neuralnet.layers.partition_based import partition_based_dense_relu_layer +from omlt.neuralnet.layers.full_space import full_space_maxpool2d_layer, _input_layer_and_block +from omlt.neuralnet.layers.partition_based import partition_based_dense_relu_layer,default_partition_split_func from omlt.neuralnet.layers.reduced_space import reduced_space_dense_layer +from pyomo.contrib.fbbt import interval def two_node_network(activation, input_value): @@ -508,4 +509,238 @@ def test_partition_based_dense_layer_predecessors(): def test_reduced_space_dense_layer_predecessors(): _test_dense_layer_multiple_predecessors("ReducedSpace") - _test_dense_layer_no_predecessors("ReducedSpace") \ No newline at end of file + _test_dense_layer_no_predecessors("ReducedSpace") + +def test_partition_based_unbounded_below(): + m = pyo.ConcreteModel() + m.neural_net_block = OmltBlock() + net, y = two_node_network(None, -2.0) + test_layer = list(net.layers)[2] + prev_layer_id = id(list(net.layers)[1]) + formulation = ReluPartitionFormulation(net) + + m.neural_net_block.build_formulation(formulation) + prev_layer_block = m.neural_net_block.layer[prev_layer_id] + prev_layer_block.z.setlb(-interval.inf) + + split_func = lambda w: default_partition_split_func(w, 2) + + with pytest.raises(ValueError) as excinfo: + partition_based_dense_relu_layer(m.neural_net_block,net,m.neural_net_block,test_layer,split_func) + expected_msg = "Expression is unbounded below." + assert str(excinfo.value) == expected_msg + +def test_partition_based_unbounded_above(): + m = pyo.ConcreteModel() + m.neural_net_block = OmltBlock() + net, y = two_node_network(None, -2.0) + test_layer = list(net.layers)[2] + prev_layer_id = id(list(net.layers)[1]) + formulation = ReluPartitionFormulation(net) + + m.neural_net_block.build_formulation(formulation) + prev_layer_block = m.neural_net_block.layer[prev_layer_id] + prev_layer_block.z.setub(interval.inf) + + split_func = lambda w: default_partition_split_func(w, 2) + + with pytest.raises(ValueError) as excinfo: + partition_based_dense_relu_layer(m.neural_net_block,net,m.neural_net_block,test_layer,split_func) + expected_msg = "Expression is unbounded above." + assert str(excinfo.value) == expected_msg + +def test_partition_based_bias_unbounded_below(): + m = pyo.ConcreteModel() + m.neural_net_block = OmltBlock() + net, y = two_node_network(None, -2.0) + test_layer = list(net.layers)[2] + formulation = ReluPartitionFormulation(net) + + m.neural_net_block.build_formulation(formulation) + + test_layer.biases[0] = -interval.inf + split_func = lambda w: default_partition_split_func(w, 2) + + with pytest.raises(ValueError) as excinfo: + partition_based_dense_relu_layer(m.neural_net_block,net,m.neural_net_block,test_layer,split_func) + expected_msg = "Expression is unbounded below." + assert str(excinfo.value) == expected_msg + +def test_partition_based_bias_unbounded_above(): + m = pyo.ConcreteModel() + m.neural_net_block = OmltBlock() + net, y = two_node_network(None, -2.0) + test_layer = list(net.layers)[2] + formulation = ReluPartitionFormulation(net) + + m.neural_net_block.build_formulation(formulation) + + test_layer.biases[0] = interval.inf + split_func = lambda w: default_partition_split_func(w, 2) + + with pytest.raises(ValueError) as excinfo: + partition_based_dense_relu_layer(m.neural_net_block,net,m.neural_net_block,test_layer,split_func) + expected_msg = "Expression is unbounded above." + assert str(excinfo.value) == expected_msg + +def test_fullspace_internal_extra_input(): + m = pyo.ConcreteModel() + m.neural_net_block = OmltBlock() + net, y = two_node_network("linear", -2.0) + extra_input = InputLayer([1]) + test_layer = list(net.layers)[1] + formulation = FullSpaceNNFormulation(net) + m.neural_net_block.build_formulation(formulation) + net.add_layer(extra_input) + net.add_edge(extra_input,test_layer) + with pytest.raises(ValueError) as excinfo: + _input_layer_and_block(m.neural_net_block,net,test_layer) + expected_msg = "Multiple input layers are not currently supported." + assert str(excinfo.value) == expected_msg + +def test_conv2d_extra_activation(): + m = pyo.ConcreteModel() + m.neural_net_block = OmltBlock() + + input_size = [1, 8, 6] + input_bounds = {} + for i in range(input_size[1]): + for j in range(input_size[2]): + input_bounds[(0, i, j)] = (-10.0, 10.0) + net = NetworkDefinition(scaled_input_bounds=input_bounds) + + input_layer = InputLayer(input_size) + net.add_layer(input_layer) + + conv_layer_1_kernel = np.array([[[[-3, 0], [1, 5]]]]) + conv_layer_1 = ConvLayer2D( + input_layer.output_size, [1, 4, 5], [2, 1], conv_layer_1_kernel + ) + net.add_layer(conv_layer_1) + net.add_edge(input_layer, conv_layer_1) + + # have two consecutive conv layers, + # to check that conv layer behaves normally when a non-max pool layer succeeds it + conv_layer_2_kernel = np.array([[[[-2, -2], [-2, -2]]]]) + conv_layer_2 = ConvLayer2D( + conv_layer_1.output_size, + [1, 3, 4], + [1, 1], + conv_layer_2_kernel, + activation="relu", + ) + net.add_layer(conv_layer_2) + net.add_edge(conv_layer_1, conv_layer_2) + + # test normal ConvLayer -> MaxPoolLayer structure, with monotonic increasing activation part of ConvLayer + maxpool_layer_1 = PoolingLayer2D( + conv_layer_2.output_size, [1, 1, 2], [2, 2], "max", [3, 2], 1,activation="relu" + ) + net.add_layer(maxpool_layer_1) + net.add_edge(conv_layer_2, maxpool_layer_1) + with pytest.raises(ValueError) as excinfo: + m.neural_net_block.build_formulation(FullSpaceNNFormulation(net)) + expected_msg = "Activation is applied after convolution layer, but the successor max pooling layer PoolingLayer(input_size=[1, 3, 4], output_size=[1, 1, 2], strides=[2, 2], kernel_shape=[3, 2]), pool_func_name=max has an activation function also." + assert str(excinfo.value) == expected_msg + +def test_maxpool2d_bad_input_activation(): + m = pyo.ConcreteModel() + m.neural_net_block = OmltBlock() + + input_size = [1, 8, 6] + input_bounds = {} + for i in range(input_size[1]): + for j in range(input_size[2]): + input_bounds[(0, i, j)] = (-10.0, 10.0) + net = NetworkDefinition(scaled_input_bounds=input_bounds) + + input_layer = InputLayer(input_size) + net.add_layer(input_layer) + + conv_layer_1_kernel = np.array([[[[-3, 0], [1, 5]]]]) + conv_layer_1 = ConvLayer2D( + input_layer.output_size, [1, 4, 5], [2, 1], conv_layer_1_kernel + ) + net.add_layer(conv_layer_1) + net.add_edge(input_layer, conv_layer_1) + + # have two consecutive conv layers, + # to check that conv layer behaves normally when a non-max pool layer succeeds it + conv_layer_2_kernel = np.array([[[[-2, -2], [-2, -2]]]]) + conv_layer_2 = ConvLayer2D( + conv_layer_1.output_size, + [1, 3, 4], + [1, 1], + conv_layer_2_kernel, + activation="relu", + ) + net.add_layer(conv_layer_2) + net.add_edge(conv_layer_1, conv_layer_2) + + # test normal ConvLayer -> MaxPoolLayer structure, with monotonic increasing activation part of ConvLayer + maxpool_layer_1 = PoolingLayer2D( + conv_layer_2.output_size, [1, 1, 2], [2, 2], "max", [3, 2], 1,activation="linear" + ) + net.add_layer(maxpool_layer_1) + net.add_edge(conv_layer_2, maxpool_layer_1) + + m.neural_net_block.build_formulation(FullSpaceNNFormulation(net)) + + conv_layer_2.activation = 'relu' + + with pytest.raises(ValueError) as excinfo: + full_space_maxpool2d_layer(m.neural_net_block, net, m.neural_net_block, maxpool_layer_1) + expected_msg = "Non-increasing activation functions on the preceding convolutional layer are not supported." + assert str(excinfo.value) == expected_msg + +def test_maxpool2d_bad_input_layer(): + m = pyo.ConcreteModel() + m.neural_net_block = OmltBlock() + + input_size = [1, 8, 6] + input_bounds = {} + for i in range(input_size[1]): + for j in range(input_size[2]): + input_bounds[(0, i, j)] = (-10.0, 10.0) + net = NetworkDefinition(scaled_input_bounds=input_bounds) + + input_layer = InputLayer(input_size) + net.add_layer(input_layer) + + conv_layer_1_kernel = np.array([[[[-3, 0], [1, 5]]]]) + conv_layer_1 = ConvLayer2D( + input_layer.output_size, [1, 4, 5], [2, 1], conv_layer_1_kernel + ) + net.add_layer(conv_layer_1) + net.add_edge(input_layer, conv_layer_1) + + # have two consecutive conv layers, + # to check that conv layer behaves normally when a non-max pool layer succeeds it + conv_layer_2_kernel = np.array([[[[-2, -2], [-2, -2]]]]) + conv_layer_2 = ConvLayer2D( + conv_layer_1.output_size, + [1, 3, 4], + [1, 1], + conv_layer_2_kernel, + activation="relu", + ) + net.add_layer(conv_layer_2) + net.add_edge(conv_layer_1, conv_layer_2) + + # test normal ConvLayer -> MaxPoolLayer structure, with monotonic increasing activation part of ConvLayer + maxpool_layer_1 = PoolingLayer2D( + conv_layer_2.output_size, [1, 1, 2], [2, 2], "max", [3, 2], 1,activation="linear" + ) + net.add_layer(maxpool_layer_1) + net.add_edge(conv_layer_2, maxpool_layer_1) + + maxpool_layer_2 = PoolingLayer2D( + maxpool_layer_1.output_size, [1, 1, 2], [2, 2], "max", [3, 2], 1,activation="linear" + ) + net.add_layer(maxpool_layer_2) + net.add_edge(maxpool_layer_1, maxpool_layer_2) + + with pytest.raises(TypeError) as excinfo: + m.neural_net_block.build_formulation(FullSpaceNNFormulation(net)) + expected_msg = "Input layer must be a ConvLayer2D." + assert str(excinfo.value) == expected_msg From d890b26cd5b65b7e5e832a070bfdbba11fbc7c2c Mon Sep 17 00:00:00 2001 From: Jeremy Sadler <53983960+jezsadler@users.noreply.github.com> Date: Fri, 3 Nov 2023 04:47:58 +0000 Subject: [PATCH 23/30] Some linting --- src/omlt/neuralnet/nn_formulation.py | 10 +-- tests/io/test_onnx_parser.py | 92 +++++++++++++------- tests/neuralnet/test_nn_formulation.py | 116 +++++++++++++++++-------- 3 files changed, 145 insertions(+), 73 deletions(-) diff --git a/src/omlt/neuralnet/nn_formulation.py b/src/omlt/neuralnet/nn_formulation.py index 1b6bc187..464498bb 100644 --- a/src/omlt/neuralnet/nn_formulation.py +++ b/src/omlt/neuralnet/nn_formulation.py @@ -308,10 +308,10 @@ def __init__(self, network_structure, activation_functions=None): ) if activation_functions is not None: self._activation_functions.update(activation_functions) - + # If we want to do network input/output validation at initialize time instead # of build time, as it is for FullSpaceNNFormulation: - # + # # network_inputs = list(self.__network_definition.input_nodes) # if len(network_inputs) != 1: # raise ValueError("Multiple input layers are not currently supported.") @@ -513,10 +513,10 @@ def layer(b, layer_id): else: raise ValueError("ReluPartitionFormulation supports only Dense layers") - # This check is never hit. The formulation._build_formulation() function is - # only ever called by an OmltBlock.build_formulation(), and that runs the + # This check is never hit. The formulation._build_formulation() function is + # only ever called by an OmltBlock.build_formulation(), and that runs the # input_indexes and output_indexes first, which will catch any formulations - # with multiple input or output layers. + # with multiple input or output layers. # setup input variables constraints # currently only support a single input layer diff --git a/tests/io/test_onnx_parser.py b/tests/io/test_onnx_parser.py index c1438264..7bc13373 100644 --- a/tests/io/test_onnx_parser.py +++ b/tests/io/test_onnx_parser.py @@ -107,26 +107,29 @@ def test_maxpool(datadir): for layer in layers[1:]: assert layer.kernel_depth == 3 + @pytest.mark.skipif(not onnx_available, reason="Need ONNX for this test") def test_input_tensor_invalid_dims(datadir): model = onnx.load(datadir.file("keras_linear_131.onnx")) model.graph.input[0].type.tensor_type.shape.dim[1].dim_value = 0 parser = NetworkParser() with pytest.raises(ValueError) as excinfo: - parser.parse_network(model.graph,None,None) + parser.parse_network(model.graph, None, None) expected_msg = "All dimensions in graph \"tf2onnx\" input tensor have 0 value." assert str(excinfo.value) == expected_msg + @pytest.mark.skipif(not onnx_available, reason="Need ONNX for this test") def test_no_input_layers(datadir): model = onnx.load(datadir.file("keras_linear_131.onnx")) model.graph.input.remove(model.graph.input[0]) parser = NetworkParser() with pytest.raises(ValueError) as excinfo: - parser.parse_network(model.graph,None,None) + parser.parse_network(model.graph, None, None) expected_msg = "No valid input layer found in graph \"tf2onnx\"." assert str(excinfo.value) == expected_msg + @pytest.mark.skipif(not onnx_available, reason="Need ONNX for this test") def test_node_no_inputs(datadir): model = onnx.load(datadir.file("keras_linear_131.onnx")) @@ -134,93 +137,120 @@ def test_node_no_inputs(datadir): model.graph.node[0].input.pop() parser = NetworkParser() with pytest.raises(ValueError) as excinfo: - parser.parse_network(model.graph,None,None) - expected_msg = "Nodes must have inputs or have op_type \"Constant\". Node \"StatefulPartitionedCall/keras_linear_131/dense/MatMul\" has no inputs and op_type \"MatMul\"." + parser.parse_network(model.graph, None, None) + expected_msg = """Nodes must have inputs or have op_type \"Constant\". + Node \"StatefulPartitionedCall/keras_linear_131/dense/MatMul\" has + no inputs and op_type \"MatMul\".""" assert str(excinfo.value) == expected_msg + @pytest.mark.skipif(not onnx_available, reason="Need ONNX for this test") def test_consume_wrong_node_type(datadir): model = onnx.load(datadir.file("keras_linear_131.onnx")) parser = NetworkParser() - parser.parse_network(model.graph,None,None) - + parser.parse_network(model.graph, None, None) + with pytest.raises(ValueError) as excinfo: - parser._consume_dense_nodes(parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][1],parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][2]) + parser._consume_dense_nodes(parser._nodes[ + 'StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][1], + parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][2]) expected_msg_dense = "StatefulPartitionedCall/keras_linear_131/dense/BiasAdd is a Add node, only MatMul nodes can be used as starting points for consumption." assert str(excinfo.value) == expected_msg_dense - + with pytest.raises(ValueError) as excinfo: - parser._consume_gemm_dense_nodes(parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][1],parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][2]) + parser._consume_gemm_dense_nodes(parser._nodes[ + 'StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][1], + parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][2]) expected_msg_gemm = "StatefulPartitionedCall/keras_linear_131/dense/BiasAdd is a Add node, only Gemm nodes can be used as starting points for consumption." assert str(excinfo.value) == expected_msg_gemm - + with pytest.raises(ValueError) as excinfo: - parser._consume_conv_nodes(parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][1],parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][2]) + parser._consume_conv_nodes(parser._nodes[ + 'StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][1], + parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][2]) expected_msg_conv = "StatefulPartitionedCall/keras_linear_131/dense/BiasAdd is a Add node, only Conv nodes can be used as starting points for consumption." assert str(excinfo.value) == expected_msg_conv - + with pytest.raises(ValueError) as excinfo: - parser._consume_reshape_nodes(parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][1],parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][2]) + parser._consume_reshape_nodes(parser._nodes[ + 'StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][1], + parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][2]) expected_msg_reshape = "StatefulPartitionedCall/keras_linear_131/dense/BiasAdd is a Add node, only Reshape nodes can be used as starting points for consumption." assert str(excinfo.value) == expected_msg_reshape with pytest.raises(ValueError) as excinfo: - parser._consume_pool_nodes(parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][1],parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][2]) - expected_msg_pool = "StatefulPartitionedCall/keras_linear_131/dense/BiasAdd is a Add node, only MaxPool nodes can be used as starting points for consumption." + parser._consume_pool_nodes(parser._nodes[ + 'StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][1], + parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][2]) + expected_msg_pool = """StatefulPartitionedCall/keras_linear_131/dense/BiasAdd + is a Add node, only MaxPool nodes can be used as starting points + for consumption.""" assert str(excinfo.value) == expected_msg_pool + @pytest.mark.skipif(not onnx_available, reason="Need ONNX for this test") def test_consume_dense_wrong_dims(datadir): model = onnx.load(datadir.file("keras_linear_131.onnx")) parser = NetworkParser() - parser.parse_network(model.graph,None,None) + parser.parse_network(model.graph, None, None) parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/MatMul'][1].input.append('abcd') with pytest.raises(ValueError) as excinfo: - parser._consume_dense_nodes(parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/MatMul'][1],parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/MatMul'][2]) + parser._consume_dense_nodes(parser._nodes[ + 'StatefulPartitionedCall/keras_linear_131/dense/MatMul'][1], + parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/MatMul'][2]) expected_msg_dense = "StatefulPartitionedCall/keras_linear_131/dense/MatMul input has 3 dimensions, only nodes with 2 input dimensions can be used as starting points for consumption." assert str(excinfo.value) == expected_msg_dense + @pytest.mark.skipif(not onnx_available, reason="Need ONNX for this test") def test_consume_gemm_wrong_dims(datadir): model = onnx.load(datadir.file("gemm.onnx")) parser = NetworkParser() - parser.parse_network(model.graph,None,None) + parser.parse_network(model.graph, None, None) parser._nodes['Gemm_0'][1].input.append('abcd') with pytest.raises(ValueError) as excinfo: - parser._consume_gemm_dense_nodes(parser._nodes['Gemm_0'][1],parser._nodes['Gemm_0'][2]) + parser._consume_gemm_dense_nodes(parser._nodes['Gemm_0'][1], + parser._nodes['Gemm_0'][2]) expected_msg_gemm = "Gemm_0 input has 4 dimensions, only nodes with 3 input dimensions can be used as starting points for consumption." assert str(excinfo.value) == expected_msg_gemm + @pytest.mark.skipif(not onnx_available, reason="Need ONNX for this test") def test_consume_conv_wrong_dims(datadir): - model = onnx.load(datadir.file("convx1_gemmx1.onnx")) + model = onnx.load(datadir.file("convx1_gemmx1.onnx")) parser = NetworkParser() - parser.parse_network(model.graph,None,None) + parser.parse_network(model.graph, None, None) parser._nodes['Conv_0'][1].input.append('abcd') with pytest.raises(ValueError) as excinfo: - parser._consume_conv_nodes(parser._nodes['Conv_0'][1],parser._nodes['Conv_0'][2]) + parser._consume_conv_nodes(parser._nodes['Conv_0'][1], + parser._nodes['Conv_0'][2]) expected_msg_conv = "Conv_0 input has 4 dimensions, only nodes with 2 or 3 input dimensions can be used as starting points for consumption." assert str(excinfo.value) == expected_msg_conv + @pytest.mark.skipif(not onnx_available, reason="Need ONNX for this test") def test_consume_reshape_wrong_dims(datadir): - model = onnx.load(datadir.file("convx1_gemmx1.onnx")) + model = onnx.load(datadir.file("convx1_gemmx1.onnx")) parser = NetworkParser() - parser.parse_network(model.graph,None,None) + parser.parse_network(model.graph, None, None) parser._nodes['Reshape_2'][1].input.append('abcd') with pytest.raises(ValueError) as excinfo: - parser._consume_reshape_nodes(parser._nodes['Reshape_2'][1],parser._nodes['Reshape_2'][2]) - expected_msg_reshape = "Reshape_2 input has 3 dimensions, only nodes with 2 input dimensions can be used as starting points for consumption." + parser._consume_reshape_nodes(parser._nodes['Reshape_2'][1], + parser._nodes['Reshape_2'][2]) + expected_msg_reshape = """Reshape_2 input has 3 dimensions, only nodes with 2 input + dimensions can be used as starting points for consumption.""" assert str(excinfo.value) == expected_msg_reshape + @pytest.mark.skipif(not onnx_available, reason="Need ONNX for this test") def test_consume_maxpool_wrong_dims(datadir): - model = onnx.load(datadir.file("maxpool_2d.onnx")) + model = onnx.load(datadir.file("maxpool_2d.onnx")) parser = NetworkParser() - parser.parse_network(model.graph,None,None) + parser.parse_network(model.graph, None, None) parser._nodes['node1'][1].input.append('abcd') with pytest.raises(ValueError) as excinfo: - parser._consume_pool_nodes(parser._nodes['node1'][1],parser._nodes['node1'][2]) - expected_msg_maxpool = "node1 input has 2 dimensions, only nodes with 1 input dimension can be used as starting points for consumption." - assert str(excinfo.value) == expected_msg_maxpool \ No newline at end of file + parser._consume_pool_nodes(parser._nodes['node1'][1], parser._nodes['node1'][2]) + expected_msg_maxpool = """node1 input has 2 dimensions, only nodes with 1 input + dimension can be used as starting points for consumption.""" + assert str(excinfo.value) == expected_msg_maxpool diff --git a/tests/neuralnet/test_nn_formulation.py b/tests/neuralnet/test_nn_formulation.py index 4b345735..66cb0ed8 100644 --- a/tests/neuralnet/test_nn_formulation.py +++ b/tests/neuralnet/test_nn_formulation.py @@ -1,6 +1,7 @@ import numpy as np import pyomo.environ as pyo import pytest +from pyomo.contrib.fbbt import interval from omlt import OmltBlock from omlt.neuralnet import ( @@ -18,12 +19,16 @@ InputLayer, PoolingLayer2D, ) -from omlt.neuralnet.layers.full_space import full_space_maxpool2d_layer, _input_layer_and_block -from omlt.neuralnet.layers.partition_based import partition_based_dense_relu_layer,default_partition_split_func +from omlt.neuralnet.layers.full_space import ( + full_space_maxpool2d_layer, + _input_layer_and_block +) +from omlt.neuralnet.layers.partition_based import ( + partition_based_dense_relu_layer, + default_partition_split_func +) from omlt.neuralnet.layers.reduced_space import reduced_space_dense_layer -from pyomo.contrib.fbbt import interval - def two_node_network(activation, input_value): """ @@ -254,7 +259,8 @@ def _maxpool_conv_network(inputs): net.add_layer(conv_layer_2) net.add_edge(conv_layer_1, conv_layer_2) - # test normal ConvLayer -> MaxPoolLayer structure, with monotonic increasing activation part of ConvLayer + # test normal ConvLayer -> MaxPoolLayer structure, with monotonic increasing + # activation part of ConvLayer maxpool_layer_1 = PoolingLayer2D( conv_layer_2.output_size, [1, 1, 2], [2, 2], "max", [3, 2], 1 ) @@ -268,7 +274,8 @@ def _maxpool_conv_network(inputs): net.add_layer(conv_layer_3) net.add_edge(maxpool_layer_1, conv_layer_3) - # test ConvLayer -> MaxPoolLayer when nonlinear activation function is already part of max pooling layer + # test ConvLayer -> MaxPoolLayer when nonlinear activation function is + # already part of max pooling layer # also test index mapping logic in max pooling layers maxpool_layer_2_input_size = [1, 2, 1] maxpool_layer_2_index_mapper = IndexMapper( @@ -332,6 +339,7 @@ def test_maxpool_FullSpaceNNFormulation(): status = pyo.SolverFactory("cbc").solve(m, tee=False) assert abs(pyo.value(m.neural_net_block.outputs[0, 0, 0]) - y[0, 0, 0]) < 1e-6 + def _test_formulation_initialize_extra_input(network_formulation): """ network_formulation can be: @@ -343,12 +351,13 @@ def _test_formulation_initialize_extra_input(network_formulation): net.add_layer(extra_input) with pytest.raises(ValueError) as excinfo: if network_formulation == 'FullSpace': - formulation = FullSpaceNNFormulation(net) + FullSpaceNNFormulation(net) elif network_formulation == 'ReducedSpace': - formulation = ReducedSpaceNNFormulation(net) + ReducedSpaceNNFormulation(net) expected_msg = "Multiple input layers are not currently supported." assert str(excinfo.value) == expected_msg + def _test_formulation_added_extra_input(network_formulation): """ network_formulation can be: @@ -370,6 +379,7 @@ def _test_formulation_added_extra_input(network_formulation): expected_msg = "Multiple input layers are not currently supported." assert str(excinfo.value) == expected_msg + def _test_formulation_build_extra_input(network_formulation): """ network_formulation can be: @@ -393,6 +403,7 @@ def _test_formulation_build_extra_input(network_formulation): expected_msg = "Multiple input layers are not currently supported." assert str(excinfo.value) == expected_msg + def _test_formulation_added_extra_output(network_formulation): """ network_formulation can be: @@ -409,18 +420,19 @@ def _test_formulation_added_extra_output(network_formulation): biases=np.array([3.0, 4.0]), ) if network_formulation == 'FullSpace': - formulation = FullSpaceNNFormulation(net) + formulation = FullSpaceNNFormulation(net) elif network_formulation == 'ReducedSpace': formulation = ReducedSpaceNNFormulation(net) elif network_formulation == 'relu': formulation = ReluPartitionFormulation(net) net.add_layer(extra_output) - net.add_edge(list(net.layers)[-2],extra_output) + net.add_edge(list(net.layers)[-2], extra_output) with pytest.raises(ValueError) as excinfo: formulation.output_indexes expected_msg = "Multiple output layers are not currently supported." assert str(excinfo.value) == expected_msg + def _test_formulation_initialize_extra_output(network_formulation): """ network_formulation can be: @@ -436,15 +448,16 @@ def _test_formulation_initialize_extra_output(network_formulation): biases=np.array([3.0, 4.0]), ) net.add_layer(extra_output) - net.add_edge(list(net.layers)[-2],extra_output) + net.add_edge(list(net.layers)[-2], extra_output) with pytest.raises(ValueError) as excinfo: if network_formulation == 'FullSpace': - formulation = FullSpaceNNFormulation(net) + FullSpaceNNFormulation(net) elif network_formulation == 'ReducedSpace': - formulation = ReducedSpaceNNFormulation(net) + ReducedSpaceNNFormulation(net) expected_msg = "Multiple output layers are not currently supported." assert str(excinfo.value) == expected_msg + def test_FullSpaceNNFormulation_invalid_network(): _test_formulation_initialize_extra_input("FullSpace") _test_formulation_added_extra_input("FullSpace") @@ -452,6 +465,7 @@ def test_FullSpaceNNFormulation_invalid_network(): _test_formulation_initialize_extra_output("FullSpace") _test_formulation_added_extra_output("FullSpace") + def test_ReducedSpaceNNFormulation_invalid_network(): # _test_formulation_initialize_extra_input("ReducedSpace") _test_formulation_added_extra_input("ReducedSpace") @@ -459,11 +473,13 @@ def test_ReducedSpaceNNFormulation_invalid_network(): # _test_formulation_initialize_extra_output("ReducedSpace") _test_formulation_added_extra_output("ReducedSpace") + def test_ReluPartitionFormulation_invalid_network(): _test_formulation_added_extra_input("relu") _test_formulation_build_extra_input("relu") _test_formulation_added_extra_output("relu") + def _test_dense_layer_multiple_predecessors(layer_type): m = pyo.ConcreteModel() m.neural_net_block = OmltBlock() @@ -471,15 +487,16 @@ def _test_dense_layer_multiple_predecessors(layer_type): extra_input = InputLayer([1]) test_layer = list(net.layers)[2] net.add_layer(extra_input) - net.add_edge(extra_input,test_layer) + net.add_edge(extra_input, test_layer) with pytest.raises(ValueError) as excinfo: if layer_type == 'PartitionBased': - partition_based_dense_relu_layer(m,net,m,test_layer,None) + partition_based_dense_relu_layer(m, net, m, test_layer, None) elif layer_type == 'ReducedSpace': - reduced_space_dense_layer(m,net,m,test_layer,None) + reduced_space_dense_layer(m, net, m, test_layer, None) expected_msg = f"Layer {test_layer} has multiple predecessors." assert str(excinfo.value) == expected_msg + def _test_dense_layer_no_predecessors(layer_type): """ Layer type can be "ReducedSpace", or "PartitionBased". @@ -497,20 +514,23 @@ def _test_dense_layer_no_predecessors(layer_type): net.add_layer(test_layer) with pytest.raises(ValueError) as excinfo: if layer_type == 'PartitionBased': - partition_based_dense_relu_layer(m,net,m,test_layer,None) + partition_based_dense_relu_layer(m, net, m, test_layer, None) elif layer_type == 'ReducedSpace': - reduced_space_dense_layer(m,net,m,test_layer,None) + reduced_space_dense_layer(m, net, m, test_layer, None) expected_msg = f"Layer {test_layer} is not an input layer, but has no predecessors." assert str(excinfo.value) == expected_msg + def test_partition_based_dense_layer_predecessors(): _test_dense_layer_multiple_predecessors("PartitionBased") _test_dense_layer_no_predecessors("PartitionBased") + def test_reduced_space_dense_layer_predecessors(): _test_dense_layer_multiple_predecessors("ReducedSpace") _test_dense_layer_no_predecessors("ReducedSpace") + def test_partition_based_unbounded_below(): m = pyo.ConcreteModel() m.neural_net_block = OmltBlock() @@ -526,10 +546,12 @@ def test_partition_based_unbounded_below(): split_func = lambda w: default_partition_split_func(w, 2) with pytest.raises(ValueError) as excinfo: - partition_based_dense_relu_layer(m.neural_net_block,net,m.neural_net_block,test_layer,split_func) + partition_based_dense_relu_layer(m.neural_net_block, net, + m.neural_net_block, test_layer, split_func) expected_msg = "Expression is unbounded below." assert str(excinfo.value) == expected_msg + def test_partition_based_unbounded_above(): m = pyo.ConcreteModel() m.neural_net_block = OmltBlock() @@ -545,10 +567,12 @@ def test_partition_based_unbounded_above(): split_func = lambda w: default_partition_split_func(w, 2) with pytest.raises(ValueError) as excinfo: - partition_based_dense_relu_layer(m.neural_net_block,net,m.neural_net_block,test_layer,split_func) + partition_based_dense_relu_layer(m.neural_net_block, net, m.neural_net_block, + test_layer, split_func) expected_msg = "Expression is unbounded above." assert str(excinfo.value) == expected_msg + def test_partition_based_bias_unbounded_below(): m = pyo.ConcreteModel() m.neural_net_block = OmltBlock() @@ -562,10 +586,12 @@ def test_partition_based_bias_unbounded_below(): split_func = lambda w: default_partition_split_func(w, 2) with pytest.raises(ValueError) as excinfo: - partition_based_dense_relu_layer(m.neural_net_block,net,m.neural_net_block,test_layer,split_func) + partition_based_dense_relu_layer(m.neural_net_block, net, + m.neural_net_block, test_layer, split_func) expected_msg = "Expression is unbounded below." assert str(excinfo.value) == expected_msg + def test_partition_based_bias_unbounded_above(): m = pyo.ConcreteModel() m.neural_net_block = OmltBlock() @@ -579,10 +605,12 @@ def test_partition_based_bias_unbounded_above(): split_func = lambda w: default_partition_split_func(w, 2) with pytest.raises(ValueError) as excinfo: - partition_based_dense_relu_layer(m.neural_net_block,net,m.neural_net_block,test_layer,split_func) + partition_based_dense_relu_layer(m.neural_net_block, net, m.neural_net_block, + test_layer, split_func) expected_msg = "Expression is unbounded above." assert str(excinfo.value) == expected_msg + def test_fullspace_internal_extra_input(): m = pyo.ConcreteModel() m.neural_net_block = OmltBlock() @@ -592,12 +620,13 @@ def test_fullspace_internal_extra_input(): formulation = FullSpaceNNFormulation(net) m.neural_net_block.build_formulation(formulation) net.add_layer(extra_input) - net.add_edge(extra_input,test_layer) + net.add_edge(extra_input, test_layer) with pytest.raises(ValueError) as excinfo: - _input_layer_and_block(m.neural_net_block,net,test_layer) + _input_layer_and_block(m.neural_net_block, net, test_layer) expected_msg = "Multiple input layers are not currently supported." assert str(excinfo.value) == expected_msg + def test_conv2d_extra_activation(): m = pyo.ConcreteModel() m.neural_net_block = OmltBlock() @@ -625,24 +654,29 @@ def test_conv2d_extra_activation(): conv_layer_2 = ConvLayer2D( conv_layer_1.output_size, [1, 3, 4], - [1, 1], + [1, 1], conv_layer_2_kernel, activation="relu", ) net.add_layer(conv_layer_2) net.add_edge(conv_layer_1, conv_layer_2) - # test normal ConvLayer -> MaxPoolLayer structure, with monotonic increasing activation part of ConvLayer + # test normal ConvLayer -> MaxPoolLayer structure, with monotonic + # increasing activation part of ConvLayer maxpool_layer_1 = PoolingLayer2D( - conv_layer_2.output_size, [1, 1, 2], [2, 2], "max", [3, 2], 1,activation="relu" + conv_layer_2.output_size, [1, 1, 2], [2, 2], "max", [3, 2], 1, activation="relu" ) net.add_layer(maxpool_layer_1) net.add_edge(conv_layer_2, maxpool_layer_1) with pytest.raises(ValueError) as excinfo: m.neural_net_block.build_formulation(FullSpaceNNFormulation(net)) - expected_msg = "Activation is applied after convolution layer, but the successor max pooling layer PoolingLayer(input_size=[1, 3, 4], output_size=[1, 1, 2], strides=[2, 2], kernel_shape=[3, 2]), pool_func_name=max has an activation function also." + expected_msg = """Activation is applied after convolution layer, but the successor + max pooling layer PoolingLayer(input_size=[1, 3, 4], output_size=[1, 1, 2], + strides=[2, 2], kernel_shape=[3, 2]), pool_func_name=max has an activation function + also.""" assert str(excinfo.value) == expected_msg + def test_maxpool2d_bad_input_activation(): m = pyo.ConcreteModel() m.neural_net_block = OmltBlock() @@ -670,16 +704,18 @@ def test_maxpool2d_bad_input_activation(): conv_layer_2 = ConvLayer2D( conv_layer_1.output_size, [1, 3, 4], - [1, 1], + [1, 1], conv_layer_2_kernel, activation="relu", ) net.add_layer(conv_layer_2) net.add_edge(conv_layer_1, conv_layer_2) - # test normal ConvLayer -> MaxPoolLayer structure, with monotonic increasing activation part of ConvLayer + # test normal ConvLayer -> MaxPoolLayer structure, with monotonic increasing + # activation part of ConvLayer maxpool_layer_1 = PoolingLayer2D( - conv_layer_2.output_size, [1, 1, 2], [2, 2], "max", [3, 2], 1,activation="linear" + conv_layer_2.output_size, [1, 1, 2], [2, 2], "max", [3, 2], + 1, activation="linear" ) net.add_layer(maxpool_layer_1) net.add_edge(conv_layer_2, maxpool_layer_1) @@ -689,10 +725,13 @@ def test_maxpool2d_bad_input_activation(): conv_layer_2.activation = 'relu' with pytest.raises(ValueError) as excinfo: - full_space_maxpool2d_layer(m.neural_net_block, net, m.neural_net_block, maxpool_layer_1) - expected_msg = "Non-increasing activation functions on the preceding convolutional layer are not supported." + full_space_maxpool2d_layer(m.neural_net_block, net, m.neural_net_block, + maxpool_layer_1) + expected_msg = """Non-increasing activation functions on the preceding + convolutional layer are not supported.""" assert str(excinfo.value) == expected_msg + def test_maxpool2d_bad_input_layer(): m = pyo.ConcreteModel() m.neural_net_block = OmltBlock() @@ -720,22 +759,25 @@ def test_maxpool2d_bad_input_layer(): conv_layer_2 = ConvLayer2D( conv_layer_1.output_size, [1, 3, 4], - [1, 1], + [1, 1], conv_layer_2_kernel, activation="relu", ) net.add_layer(conv_layer_2) net.add_edge(conv_layer_1, conv_layer_2) - # test normal ConvLayer -> MaxPoolLayer structure, with monotonic increasing activation part of ConvLayer + # test normal ConvLayer -> MaxPoolLayer structure, with monotonic increasing + # activation part of ConvLayer maxpool_layer_1 = PoolingLayer2D( - conv_layer_2.output_size, [1, 1, 2], [2, 2], "max", [3, 2], 1,activation="linear" + conv_layer_2.output_size, [1, 1, 2], [2, 2], "max", + [3, 2], 1, activation="linear" ) net.add_layer(maxpool_layer_1) net.add_edge(conv_layer_2, maxpool_layer_1) maxpool_layer_2 = PoolingLayer2D( - maxpool_layer_1.output_size, [1, 1, 2], [2, 2], "max", [3, 2], 1,activation="linear" + maxpool_layer_1.output_size, [1, 1, 2], [2, 2], "max", + [3, 2], 1, activation="linear" ) net.add_layer(maxpool_layer_2) net.add_edge(maxpool_layer_1, maxpool_layer_2) From 6649f1df784b512b0807c5d9d0a39e11ab8b751b Mon Sep 17 00:00:00 2001 From: Jeremy Sadler <53983960+jezsadler@users.noreply.github.com> Date: Fri, 3 Nov 2023 04:55:38 +0000 Subject: [PATCH 24/30] Fixing some strings post-lint --- tests/neuralnet/test_nn_formulation.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/neuralnet/test_nn_formulation.py b/tests/neuralnet/test_nn_formulation.py index 66cb0ed8..c856a0c9 100644 --- a/tests/neuralnet/test_nn_formulation.py +++ b/tests/neuralnet/test_nn_formulation.py @@ -670,10 +670,7 @@ def test_conv2d_extra_activation(): net.add_edge(conv_layer_2, maxpool_layer_1) with pytest.raises(ValueError) as excinfo: m.neural_net_block.build_formulation(FullSpaceNNFormulation(net)) - expected_msg = """Activation is applied after convolution layer, but the successor - max pooling layer PoolingLayer(input_size=[1, 3, 4], output_size=[1, 1, 2], - strides=[2, 2], kernel_shape=[3, 2]), pool_func_name=max has an activation function - also.""" + expected_msg = """Activation is applied after convolution layer, but the successor max pooling layer PoolingLayer(input_size=[1, 3, 4], output_size=[1, 1, 2], strides=[2, 2], kernel_shape=[3, 2]), pool_func_name=max has an activation function also.""" assert str(excinfo.value) == expected_msg @@ -727,8 +724,7 @@ def test_maxpool2d_bad_input_activation(): with pytest.raises(ValueError) as excinfo: full_space_maxpool2d_layer(m.neural_net_block, net, m.neural_net_block, maxpool_layer_1) - expected_msg = """Non-increasing activation functions on the preceding - convolutional layer are not supported.""" + expected_msg = """Non-increasing activation functions on the preceding convolutional layer are not supported.""" assert str(excinfo.value) == expected_msg From 6a1b35d95bf750ceadf363b8d3955d4ea6ec8bd0 Mon Sep 17 00:00:00 2001 From: Jeremy Sadler <53983960+jezsadler@users.noreply.github.com> Date: Fri, 3 Nov 2023 05:21:46 +0000 Subject: [PATCH 25/30] More line fixing --- tests/io/test_onnx_parser.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/tests/io/test_onnx_parser.py b/tests/io/test_onnx_parser.py index 7bc13373..f2af846e 100644 --- a/tests/io/test_onnx_parser.py +++ b/tests/io/test_onnx_parser.py @@ -138,9 +138,7 @@ def test_node_no_inputs(datadir): parser = NetworkParser() with pytest.raises(ValueError) as excinfo: parser.parse_network(model.graph, None, None) - expected_msg = """Nodes must have inputs or have op_type \"Constant\". - Node \"StatefulPartitionedCall/keras_linear_131/dense/MatMul\" has - no inputs and op_type \"MatMul\".""" + expected_msg = """Nodes must have inputs or have op_type \"Constant\". Node \"StatefulPartitionedCall/keras_linear_131/dense/MatMul\" has no inputs and op_type \"MatMul\".""" assert str(excinfo.value) == expected_msg @@ -182,9 +180,7 @@ def test_consume_wrong_node_type(datadir): parser._consume_pool_nodes(parser._nodes[ 'StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][1], parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][2]) - expected_msg_pool = """StatefulPartitionedCall/keras_linear_131/dense/BiasAdd - is a Add node, only MaxPool nodes can be used as starting points - for consumption.""" + expected_msg_pool = """StatefulPartitionedCall/keras_linear_131/dense/BiasAdd is a Add node, only MaxPool nodes can be used as starting points for consumption.""" assert str(excinfo.value) == expected_msg_pool @@ -238,8 +234,7 @@ def test_consume_reshape_wrong_dims(datadir): with pytest.raises(ValueError) as excinfo: parser._consume_reshape_nodes(parser._nodes['Reshape_2'][1], parser._nodes['Reshape_2'][2]) - expected_msg_reshape = """Reshape_2 input has 3 dimensions, only nodes with 2 input - dimensions can be used as starting points for consumption.""" + expected_msg_reshape = """Reshape_2 input has 3 dimensions, only nodes with 2 input dimensions can be used as starting points for consumption.""" assert str(excinfo.value) == expected_msg_reshape @@ -251,6 +246,5 @@ def test_consume_maxpool_wrong_dims(datadir): parser._nodes['node1'][1].input.append('abcd') with pytest.raises(ValueError) as excinfo: parser._consume_pool_nodes(parser._nodes['node1'][1], parser._nodes['node1'][2]) - expected_msg_maxpool = """node1 input has 2 dimensions, only nodes with 1 input - dimension can be used as starting points for consumption.""" + expected_msg_maxpool = """node1 input has 2 dimensions, only nodes with 1 input dimension can be used as starting points for consumption.""" assert str(excinfo.value) == expected_msg_maxpool From 733364c0a808bd83efa5ff70173d9c2198d7f217 Mon Sep 17 00:00:00 2001 From: Jeremy Sadler <53983960+jezsadler@users.noreply.github.com> Date: Fri, 3 Nov 2023 17:38:11 +0000 Subject: [PATCH 26/30] Fixing more linting errors --- src/omlt/gbt/model.py | 2 +- src/omlt/io/onnx_parser.py | 2 +- tests/neuralnet/test_network_definition.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/omlt/gbt/model.py b/src/omlt/gbt/model.py index fd0d75a4..574f1fb9 100644 --- a/src/omlt/gbt/model.py +++ b/src/omlt/gbt/model.py @@ -80,4 +80,4 @@ def _tensor_size(tensor): raise ValueError(f"Tensor {tensor} has no positive dimensions.") else: raise ValueError(f"Tensor {tensor} has multiple positive dimensions.") - return size \ No newline at end of file + return size diff --git a/src/omlt/io/onnx_parser.py b/src/omlt/io/onnx_parser.py index afb16e9f..cf71f8a3 100644 --- a/src/omlt/io/onnx_parser.py +++ b/src/omlt/io/onnx_parser.py @@ -199,7 +199,7 @@ def _consume_dense_nodes(self, node, next_nodes): node_biases = self._initializers[in_0] elif in_1 in self._initializers: node_biases = self._initializers[in_1] - else: + else: raise ValueError(f"Node inputs were not found in graph initializers.") if len(node_weights.shape) != 2: diff --git a/tests/neuralnet/test_network_definition.py b/tests/neuralnet/test_network_definition.py index 8117c097..310f3451 100644 --- a/tests/neuralnet/test_network_definition.py +++ b/tests/neuralnet/test_network_definition.py @@ -151,7 +151,7 @@ def _test_add_invalid_edge(direction): ) net.add_layer(dense_layer_0) net.add_edge(input_layer, dense_layer_0) - + dense_layer_1 = DenseLayer( input_layer.output_size, dense_layer_0.input_size, @@ -173,4 +173,4 @@ def _test_add_invalid_edge(direction): def test_add_invalid_edge(): _test_add_invalid_edge('in') - _test_add_invalid_edge('out') \ No newline at end of file + _test_add_invalid_edge('out') From 6b82771343ba991dea0a6398cd68a13bf1324b7d Mon Sep 17 00:00:00 2001 From: Jeremy Sadler <53983960+jezsadler@users.noreply.github.com> Date: Fri, 3 Nov 2023 18:51:07 +0000 Subject: [PATCH 27/30] Fixing yet more lint issues --- src/omlt/gbt/gbt_formulation.py | 12 +- src/omlt/gbt/model.py | 14 +- src/omlt/io/onnx_parser.py | 130 ++++++++++++++----- src/omlt/neuralnet/layer.py | 20 ++- src/omlt/neuralnet/layers/full_space.py | 11 +- src/omlt/neuralnet/layers/partition_based.py | 4 +- src/omlt/neuralnet/layers/reduced_space.py | 4 +- tests/io/test_onnx_parser.py | 49 +++---- tests/neuralnet/test_network_definition.py | 13 +- tests/neuralnet/test_nn_formulation.py | 92 ++++++++----- 10 files changed, 236 insertions(+), 113 deletions(-) diff --git a/src/omlt/gbt/gbt_formulation.py b/src/omlt/gbt/gbt_formulation.py index 3c2ca27d..a3d9e878 100644 --- a/src/omlt/gbt/gbt_formulation.py +++ b/src/omlt/gbt/gbt_formulation.py @@ -198,16 +198,22 @@ def _branching_y(tree_id, branch_node_id): feature_id = nodes_feature_ids[node_mask] branch_value = nodes_values[node_mask] if len(branch_value) != 1: - raise ValueError(f"The given tree_id and branch_node_id do not uniquely identify a branch value.") + raise ValueError( + f"The given tree_id and branch_node_id do not uniquely identify a branch value." + ) if len(feature_id) != 1: - raise ValueError(f"The given tree_id and branch_node_id do not uniquely identify a feature.") + raise ValueError( + f"The given tree_id and branch_node_id do not uniquely identify a feature." + ) feature_id = feature_id[0] branch_value = branch_value[0] (branch_y_idx,) = np.where( branch_value_by_feature_id[feature_id] == branch_value ) if len(branch_y_idx) != 1: - raise ValueError(f"The given tree_id and branch_node_id do not uniquely identify a branch index.") + raise ValueError( + f"The given tree_id and branch_node_id do not uniquely identify a branch index." + ) return block.y[feature_id, branch_y_idx[0]] def _sum_of_z_l(tree_id, start_node_id): diff --git a/src/omlt/gbt/model.py b/src/omlt/gbt/model.py index 574f1fb9..c45d516b 100644 --- a/src/omlt/gbt/model.py +++ b/src/omlt/gbt/model.py @@ -57,7 +57,9 @@ def _model_num_inputs(model): """Returns the number of input variables""" graph = model.graph if len(graph.input) != 1: - raise ValueError(f"Model graph input field is multi-valued {graph.input}. A single value is required.") + raise ValueError( + f"Model graph input field is multi-valued {graph.input}. A single value is required." + ) return _tensor_size(graph.input[0]) @@ -65,7 +67,9 @@ def _model_num_outputs(model): """Returns the number of output variables""" graph = model.graph if len(graph.output) != 1: - raise ValueError(f"Model graph output field is multi-valued {graph.output}. A single value is required.") + raise ValueError( + f"Model graph output field is multi-valued {graph.output}. A single value is required." + ) return _tensor_size(graph.output[0]) @@ -73,7 +77,11 @@ def _tensor_size(tensor): """Returns the size of an input tensor""" tensor_type = tensor.type.tensor_type size = None - dim_values = [dim.dim_value for dim in tensor_type.shape.dim if dim.dim_value is not None and dim.dim_value > 0] + dim_values = [ + dim.dim_value + for dim in tensor_type.shape.dim + if dim.dim_value is not None and dim.dim_value > 0 + ] if len(dim_values) == 1: size = dim_values[0] elif dim_values == []: diff --git a/src/omlt/io/onnx_parser.py b/src/omlt/io/onnx_parser.py index cf71f8a3..9e7ab3ba 100644 --- a/src/omlt/io/onnx_parser.py +++ b/src/omlt/io/onnx_parser.py @@ -74,14 +74,16 @@ def parse_network(self, graph, scaling_object, input_bounds): size.append(dim.dim_value) dim_value *= dim.dim_value if dim_value is None: - raise ValueError(f"All dimensions in graph \"{graph.name}\" input tensor have 0 value.") + raise ValueError( + f'All dimensions in graph "{graph.name}" input tensor have 0 value.' + ) assert network_input is None network_input = InputLayer(size) self._node_map[input.name] = network_input network.add_layer(network_input) if network_input is None: - raise ValueError(f"No valid input layer found in graph \"{graph.name}\".") + raise ValueError(f'No valid input layer found in graph "{graph.name}".') self._nodes = nodes self._nodes_by_output = nodes_by_output @@ -116,7 +118,9 @@ def parse_network(self, graph, scaling_object, input_bounds): value = _parse_constant_value(node) self._constants[output] = value else: - raise ValueError(f"Nodes must have inputs or have op_type \"Constant\". Node \"{node.name}\" has no inputs and op_type \"{node.op_type}\".") + raise ValueError( + f'Nodes must have inputs or have op_type "Constant". Node "{node.name}" has no inputs and op_type "{node.op_type}".' + ) # traverse graph self._node_stack = list(inputs) @@ -173,23 +177,31 @@ def _visit_node(self, node, next_nodes): def _consume_dense_nodes(self, node, next_nodes): """Starting from a MatMul node, consume nodes to form a dense Ax + b node.""" if node.op_type != "MatMul": - raise ValueError(f"{node.name} is a {node.op_type} node, only MatMul nodes can be used as starting points for consumption.") + raise ValueError( + f"{node.name} is a {node.op_type} node, only MatMul nodes can be used as starting points for consumption." + ) if len(node.input) != 2: - raise ValueError(f"{node.name} input has {len(node.input)} dimensions, only nodes with 2 input dimensions can be used as starting points for consumption.") + raise ValueError( + f"{node.name} input has {len(node.input)} dimensions, only nodes with 2 input dimensions can be used as starting points for consumption." + ) [in_0, in_1] = list(node.input) input_layer, transformer = self._node_input_and_transformer(in_0) node_weights = self._initializers[in_1] if len(next_nodes) != 1: - raise ValueError(f"Next nodes must have length 1, {next_nodes} has length {len(next_nodes)}") + raise ValueError( + f"Next nodes must have length 1, {next_nodes} has length {len(next_nodes)}" + ) # expect 'Add' node ahead type_, node, maybe_next_nodes = self._nodes[next_nodes[0]] if type_ != "node": raise TypeError(f"Expected a node next, got a {type_} instead.") if node.op_type != "Add": - raise ValueError(f"The first node to be consumed, {node.name}, is a {node.op_type} node. Only Add nodes are supported.") + raise ValueError( + f"The first node to be consumed, {node.name}, is a {node.op_type} node. Only Add nodes are supported." + ) # extract biases next_nodes = maybe_next_nodes @@ -205,9 +217,13 @@ def _consume_dense_nodes(self, node, next_nodes): if len(node_weights.shape) != 2: raise ValueError(f"Node weights must be a 2-dimensional matrix.") if node_weights.shape[1] != node_biases.shape[0]: - raise ValueError(f"Node weights has {node_weights.shape[1]} columns; node biases has {node_biases.shape[0]} rows. These must be equal.") + raise ValueError( + f"Node weights has {node_weights.shape[1]} columns; node biases has {node_biases.shape[0]} rows. These must be equal." + ) if len(node.output) != 1: - raise ValueError(f"Node output is {node.output} but should be a single value.") + raise ValueError( + f"Node output is {node.output} but should be a single value." + ) input_output_size = _get_input_output_size(input_layer, transformer) @@ -239,9 +255,13 @@ def _consume_dense_nodes(self, node, next_nodes): def _consume_gemm_dense_nodes(self, node, next_nodes): """Starting from a Gemm node, consume nodes to form a dense aAB + bC node.""" if node.op_type != "Gemm": - raise ValueError(f"{node.name} is a {node.op_type} node, only Gemm nodes can be used as starting points for consumption.") + raise ValueError( + f"{node.name} is a {node.op_type} node, only Gemm nodes can be used as starting points for consumption." + ) if len(node.input) != 3: - raise ValueError(f"{node.name} input has {len(node.input)} dimensions, only nodes with 3 input dimensions can be used as starting points for consumption.") + raise ValueError( + f"{node.name} input has {len(node.input)} dimensions, only nodes with 3 input dimensions can be used as starting points for consumption." + ) attr = _collect_attributes(node) alpha = attr["alpha"] @@ -290,9 +310,13 @@ def _consume_conv_nodes(self, node, next_nodes): (optional) activation function. """ if node.op_type != "Conv": - raise ValueError(f"{node.name} is a {node.op_type} node, only Conv nodes can be used as starting points for consumption.") + raise ValueError( + f"{node.name} is a {node.op_type} node, only Conv nodes can be used as starting points for consumption." + ) if len(node.input) not in [2,3]: - raise ValueError(f"{node.name} input has {len(node.input)} dimensions, only nodes with 2 or 3 input dimensions can be used as starting points for consumption.") + raise ValueError( + f"{node.name} input has {len(node.input)} dimensions, only nodes with 2 or 3 input dimensions can be used as starting points for consumption." + ) if len(node.input) == 2: [in_0, in_1] = list(node.input) @@ -314,25 +338,41 @@ def _consume_conv_nodes(self, node, next_nodes): strides = attr["strides"] # check only kernel shape and stride are set if attr["kernel_shape"] != kernel_shape: - raise ValueError(f"Kernel shape attribute {attr['kernel_shape']} does not match initialized kernel shape {kernel_shape}.") + raise ValueError( + f"Kernel shape attribute {attr['kernel_shape']} does not match initialized kernel shape {kernel_shape}." + ) if len(kernel_shape) != len(strides): - raise ValueError(f"Initialized kernel shape {kernel_shape} has {len(kernel_shape)} dimensions. Strides attribute has {len(strides)} dimensions. These must be equal.") + raise ValueError( + f"Initialized kernel shape {kernel_shape} has {len(kernel_shape)} dimensions. Strides attribute has {len(strides)} dimensions. These must be equal." + ) if len(input_output_size) != len(kernel_shape) + 1: - raise ValueError(f"Input/output size ({input_output_size}) must have one more dimension than initialized kernel shape ({kernel_shape}).") + raise ValueError( + f"Input/output size ({input_output_size}) must have one more dimension than initialized kernel shape ({kernel_shape})." + ) # Check input, output have correct dimensions if biases.shape != (out_channels,): - raise ValueError(f"Biases shape {biases.shape} must match output weights channels {(out_channels,)}.") + raise ValueError( + f"Biases shape {biases.shape} must match output weights channels {(out_channels,)}." + ) if in_channels != input_output_size[0]: - raise ValueError(f"Input/output size ({input_output_size}) first dimension must match input weights channels ({in_channels}).") + raise ValueError( + f"Input/output size ({input_output_size}) first dimension must match input weights channels ({in_channels})." + ) # Other attributes are not supported if "dilations" in attr and attr["dilations"] != [1, 1]: - raise ValueError(f"{node} has non-identity dilations ({attr['dilations']}). This is not supported.") + raise ValueError( + f"{node} has non-identity dilations ({attr['dilations']}). This is not supported." + ) if attr["group"] != 1: - raise ValueError(f"{node} has multiple groups ({attr['group']}). This is not supported.") + raise ValueError( + f"{node} has multiple groups ({attr['group']}). This is not supported." + ) if "pads" in attr and np.any(attr["pads"]): - raise ValueError(f"{node} has non-zero pads ({attr['pads']}). This is not supported.") + raise ValueError( + f"{node} has non-zero pads ({attr['pads']}). This is not supported." + ) # generate new nodes for the node output padding = 0 @@ -353,7 +393,9 @@ def _consume_conv_nodes(self, node, next_nodes): # convolute image one channel at the time # expect 2d image with channels if len(input_output_size) != 3: - raise ValueError(f"Expected a 2D image with channels, got {input_output_size}.") + raise ValueError( + f"Expected a 2D image with channels, got {input_output_size}." + ) conv_layer = ConvLayer2D( input_output_size, @@ -371,9 +413,13 @@ def _consume_conv_nodes(self, node, next_nodes): def _consume_reshape_nodes(self, node, next_nodes): """Parse a Reshape node.""" if node.op_type != "Reshape": - raise ValueError(f"{node.name} is a {node.op_type} node, only Reshape nodes can be used as starting points for consumption.") + raise ValueError( + f"{node.name} is a {node.op_type} node, only Reshape nodes can be used as starting points for consumption." + ) if len(node.input) != 2: - raise ValueError(f"{node.name} input has {len(node.input)} dimensions, only nodes with 2 input dimensions can be used as starting points for consumption.") + raise ValueError( + f"{node.name} input has {len(node.input)} dimensions, only nodes with 2 input dimensions can be used as starting points for consumption." + ) [in_0, in_1] = list(node.input) input_layer = self._node_map[in_0] new_shape = self._constants[in_1] @@ -388,14 +434,20 @@ def _consume_pool_nodes(self, node, next_nodes): (optional) activation function. """ if node.op_type not in _POOLING_OP_TYPES: - raise ValueError(f"{node.name} is a {node.op_type} node, only MaxPool nodes can be used as starting points for consumption.") + raise ValueError( + f"{node.name} is a {node.op_type} node, only MaxPool nodes can be used as starting points for consumption." + ) pool_func_name = "max" # ONNX network should not contain indices output from MaxPool - not supported by OMLT if len(node.output) != 1: - raise ValueError(f"The ONNX contains indices output from MaxPool. This is not supported by OMLT.") + raise ValueError( + f"The ONNX contains indices output from MaxPool. This is not supported by OMLT." + ) if len(node.input) != 1: - raise ValueError(f"{node.name} input has {len(node.input)} dimensions, only nodes with 1 input dimension can be used as starting points for consumption.") + raise ValueError( + f"{node.name} input has {len(node.input)} dimensions, only nodes with 1 input dimension can be used as starting points for consumption." + ) input_layer, transformer = self._node_input_and_transformer(node.input[0]) input_output_size = _get_input_output_size(input_layer, transformer) @@ -405,7 +457,9 @@ def _consume_pool_nodes(self, node, next_nodes): # this means there is an extra dimension for number of batches # batches not supported, so only accept if they're not there or there is only 1 batch if input_output_size[0] != 1: - raise ValueError(f"{node.name} has {input_output_size[0]} batches, only a single batch is supported.") + raise ValueError( + f"{node.name} has {input_output_size[0]} batches, only a single batch is supported." + ) input_output_size = input_output_size[1:] in_channels = input_output_size[0] @@ -418,15 +472,25 @@ def _consume_pool_nodes(self, node, next_nodes): # check only kernel shape, stride, storage order are set # everything else is not supported if "dilations" in attr and attr["dilations"] != [1, 1]: - raise ValueError(f"{node.name} has non-identity dilations ({attr['dilations']}). This is not supported.") + raise ValueError( + f"{node.name} has non-identity dilations ({attr['dilations']}). This is not supported." + ) if "pads" in attr and np.any(attr["pads"]): - raise ValueError(f"{node.name} has non-zero pads ({attr['pads']}). This is not supported.") + raise ValueError( + f"{node.name} has non-zero pads ({attr['pads']}). This is not supported." + ) if ("auto_pad" in attr) and (attr["auto_pad"] != "NOTSET"): - raise ValueError(f"{node.name} has autopad set ({attr['auto_pad']}). This is not supported.") + raise ValueError( + f"{node.name} has autopad set ({attr['auto_pad']}). This is not supported." + ) if len(kernel_shape) != len(strides): - raise ValueError(f"Kernel shape {kernel_shape} has {len(kernel_shape)} dimensions. Strides attribute has {len(strides)} dimensions. These must be equal.") + raise ValueError( + f"Kernel shape {kernel_shape} has {len(kernel_shape)} dimensions. Strides attribute has {len(strides)} dimensions. These must be equal." + ) if len(input_output_size) != len(kernel_shape) + 1: - raise ValueError(f"Input/output size ({input_output_size}) must have one more dimension than kernel shape ({kernel_shape}).") + raise ValueError( + f"Input/output size ({input_output_size}) must have one more dimension than kernel shape ({kernel_shape})." + ) output_shape_wrapper = math.floor if "ceil_mode" in attr and attr["ceil_mode"] == 1: diff --git a/src/omlt/neuralnet/layer.py b/src/omlt/neuralnet/layer.py index 45f1acba..15ed022b 100644 --- a/src/omlt/neuralnet/layer.py +++ b/src/omlt/neuralnet/layer.py @@ -24,9 +24,13 @@ def __init__( self, input_size, output_size, *, activation=None, input_index_mapper=None ): if not isinstance(input_size, (list,tuple)): - raise TypeError(f"input_size must be a list or tuple, {type(input_size)} was provided.") + raise TypeError( + f"input_size must be a list or tuple, {type(input_size)} was provided." + ) if not isinstance(output_size, (list,tuple)): - raise TypeError(f"output_size must be a list or tuple, {type(output_size)} was provided.") + raise TypeError( + f"output_size must be a list or tuple, {type(output_size)} was provided." + ) self.__input_size = list(input_size) self.__output_size = list(output_size) self.activation = activation @@ -102,7 +106,9 @@ def eval_single_layer(self, x): else x[:] ) if x_reshaped.shape != tuple(self.input_size): - raise ValueError(f"Layer requires an input size {self.input_size}, but the input tensor had size {x_reshaped.shape}.") + raise ValueError( + f"Layer requires an input size {self.input_size}, but the input tensor had size {x_reshaped.shape}." + ) y = self._eval(x_reshaped) return self._apply_activation(y) @@ -313,7 +319,9 @@ def get_input_index(self, out_index, kernel_index): def _eval(self, x): y = np.empty(shape=self.output_size) if len(self.output_size) != 3: - raise ValueError(f"Output should have 3 dimensions but instead has {len(self.output_size)}") + raise ValueError( + f"Output should have 3 dimensions but instead has {len(self.output_size)}" + ) [depth, rows, cols] = list(self.output_size) for out_d in range(depth): for out_r in range(rows): @@ -370,7 +378,9 @@ def __init__( input_index_mapper=input_index_mapper, ) if pool_func_name not in PoolingLayer2D._POOL_FUNCTIONS: - raise ValueError(f"Allowable pool functions are {PoolingLayer2D._POOL_FUNCTIONS}, {pool_func_name} was provided.") + raise ValueError( + f"Allowable pool functions are {PoolingLayer2D._POOL_FUNCTIONS}, {pool_func_name} was provided." + ) self._pool_func_name = pool_func_name self._kernel_shape = kernel_shape self._kernel_depth = kernel_depth diff --git a/src/omlt/neuralnet/layers/full_space.py b/src/omlt/neuralnet/layers/full_space.py index 71296bdd..a699a76e 100644 --- a/src/omlt/neuralnet/layers/full_space.py +++ b/src/omlt/neuralnet/layers/full_space.py @@ -5,6 +5,7 @@ from omlt.neuralnet.activations import NON_INCREASING_ACTIVATIONS from omlt.neuralnet.layer import ConvLayer2D, IndexMapper, PoolingLayer2D + def full_space_dense_layer(net_block, net, layer_block, layer): r""" Add full-space formulation of the dense layer to the block @@ -56,7 +57,9 @@ def full_space_conv2d_layer(net_block, net, layer_block, layer): ): # activation applied after convolution layer, so there shouldn't be an activation after max pooling too if succ_layer.activation != "linear": - raise ValueError(f"Activation is applied after convolution layer, but the successor max pooling layer {succ_layer} has an activation function also.") + raise ValueError( + f"Activation is applied after convolution layer, but the successor max pooling layer {succ_layer} has an activation function also." + ) succ_layer.activation = layer.activation layer.activation = "linear" @@ -114,10 +117,12 @@ def full_space_maxpool2d_layer(net_block, net, layer_block, layer): """ input_layer, input_layer_block = _input_layer_and_block(net_block, net, layer) - if not isinstance(input_layer,ConvLayer2D): + if not isinstance(input_layer, ConvLayer2D): raise TypeError("Input layer must be a ConvLayer2D.") if input_layer.activation != "linear": - raise ValueError("Non-increasing activation functions on the preceding convolutional layer are not supported.") + raise ValueError( + "Non-increasing activation functions on the preceding convolutional layer are not supported." + ) # TODO - add support for non-increasing activation functions on preceding convolutional layer # note kernel indexes are the same set of values for any output index, so wlog get kernel indexes for (0, 0, 0) diff --git a/src/omlt/neuralnet/layers/partition_based.py b/src/omlt/neuralnet/layers/partition_based.py index c0de2053..746196e7 100644 --- a/src/omlt/neuralnet/layers/partition_based.py +++ b/src/omlt/neuralnet/layers/partition_based.py @@ -45,7 +45,9 @@ def partition_based_dense_relu_layer(net_block, net, layer_block, layer, split_f # not an input layer, process the expressions prev_layers = list(net.predecessors(layer)) if len(prev_layers) == 0: - raise ValueError(f"Layer {layer} is not an input layer, but has no predecessors.") + raise ValueError( + f"Layer {layer} is not an input layer, but has no predecessors." + ) elif len(prev_layers) > 1: raise ValueError(f"Layer {layer} has multiple predecessors.") prev_layer = prev_layers[0] diff --git a/src/omlt/neuralnet/layers/reduced_space.py b/src/omlt/neuralnet/layers/reduced_space.py index 76a56a07..3fb67477 100644 --- a/src/omlt/neuralnet/layers/reduced_space.py +++ b/src/omlt/neuralnet/layers/reduced_space.py @@ -12,7 +12,9 @@ def reduced_space_dense_layer(net_block, net, layer_block, layer, activation): # not an input layer, process the expressions prev_layers = list(net.predecessors(layer)) if len(prev_layers) == 0: - raise ValueError(f"Layer {layer} is not an input layer, but has no predecessors.") + raise ValueError( + f"Layer {layer} is not an input layer, but has no predecessors." + ) elif len(prev_layers) > 1: raise ValueError(f"Layer {layer} has multiple predecessors.") prev_layer = prev_layers[0] diff --git a/tests/io/test_onnx_parser.py b/tests/io/test_onnx_parser.py index f2af846e..59cce5c9 100644 --- a/tests/io/test_onnx_parser.py +++ b/tests/io/test_onnx_parser.py @@ -115,7 +115,7 @@ def test_input_tensor_invalid_dims(datadir): parser = NetworkParser() with pytest.raises(ValueError) as excinfo: parser.parse_network(model.graph, None, None) - expected_msg = "All dimensions in graph \"tf2onnx\" input tensor have 0 value." + expected_msg = 'All dimensions in graph "tf2onnx" input tensor have 0 value.' assert str(excinfo.value) == expected_msg @@ -126,14 +126,14 @@ def test_no_input_layers(datadir): parser = NetworkParser() with pytest.raises(ValueError) as excinfo: parser.parse_network(model.graph, None, None) - expected_msg = "No valid input layer found in graph \"tf2onnx\"." + expected_msg = 'No valid input layer found in graph "tf2onnx".' assert str(excinfo.value) == expected_msg @pytest.mark.skipif(not onnx_available, reason="Need ONNX for this test") def test_node_no_inputs(datadir): model = onnx.load(datadir.file("keras_linear_131.onnx")) - while (len(model.graph.node[0].input) > 0): + while len(model.graph.node[0].input) > 0: model.graph.node[0].input.pop() parser = NetworkParser() with pytest.raises(ValueError) as excinfo: @@ -149,36 +149,36 @@ def test_consume_wrong_node_type(datadir): parser.parse_network(model.graph, None, None) with pytest.raises(ValueError) as excinfo: - parser._consume_dense_nodes(parser._nodes[ - 'StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][1], + parser._consume_dense_nodes( + parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][1], parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][2]) expected_msg_dense = "StatefulPartitionedCall/keras_linear_131/dense/BiasAdd is a Add node, only MatMul nodes can be used as starting points for consumption." assert str(excinfo.value) == expected_msg_dense with pytest.raises(ValueError) as excinfo: - parser._consume_gemm_dense_nodes(parser._nodes[ - 'StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][1], + parser._consume_gemm_dense_nodes( + parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][1], parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][2]) expected_msg_gemm = "StatefulPartitionedCall/keras_linear_131/dense/BiasAdd is a Add node, only Gemm nodes can be used as starting points for consumption." assert str(excinfo.value) == expected_msg_gemm with pytest.raises(ValueError) as excinfo: - parser._consume_conv_nodes(parser._nodes[ - 'StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][1], + parser._consume_conv_nodes( + parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][1], parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][2]) expected_msg_conv = "StatefulPartitionedCall/keras_linear_131/dense/BiasAdd is a Add node, only Conv nodes can be used as starting points for consumption." assert str(excinfo.value) == expected_msg_conv with pytest.raises(ValueError) as excinfo: - parser._consume_reshape_nodes(parser._nodes[ - 'StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][1], + parser._consume_reshape_nodes( + parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][1], parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][2]) expected_msg_reshape = "StatefulPartitionedCall/keras_linear_131/dense/BiasAdd is a Add node, only Reshape nodes can be used as starting points for consumption." assert str(excinfo.value) == expected_msg_reshape with pytest.raises(ValueError) as excinfo: - parser._consume_pool_nodes(parser._nodes[ - 'StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][1], + parser._consume_pool_nodes( + parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][1], parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][2]) expected_msg_pool = """StatefulPartitionedCall/keras_linear_131/dense/BiasAdd is a Add node, only MaxPool nodes can be used as starting points for consumption.""" assert str(excinfo.value) == expected_msg_pool @@ -192,8 +192,8 @@ def test_consume_dense_wrong_dims(datadir): parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/MatMul'][1].input.append('abcd') with pytest.raises(ValueError) as excinfo: - parser._consume_dense_nodes(parser._nodes[ - 'StatefulPartitionedCall/keras_linear_131/dense/MatMul'][1], + parser._consume_dense_nodes( + parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/MatMul'][1], parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/MatMul'][2]) expected_msg_dense = "StatefulPartitionedCall/keras_linear_131/dense/MatMul input has 3 dimensions, only nodes with 2 input dimensions can be used as starting points for consumption." assert str(excinfo.value) == expected_msg_dense @@ -206,8 +206,9 @@ def test_consume_gemm_wrong_dims(datadir): parser.parse_network(model.graph, None, None) parser._nodes['Gemm_0'][1].input.append('abcd') with pytest.raises(ValueError) as excinfo: - parser._consume_gemm_dense_nodes(parser._nodes['Gemm_0'][1], - parser._nodes['Gemm_0'][2]) + parser._consume_gemm_dense_nodes( + parser._nodes['Gemm_0'][1], parser._nodes['Gemm_0'][2] + ) expected_msg_gemm = "Gemm_0 input has 4 dimensions, only nodes with 3 input dimensions can be used as starting points for consumption." assert str(excinfo.value) == expected_msg_gemm @@ -219,8 +220,9 @@ def test_consume_conv_wrong_dims(datadir): parser.parse_network(model.graph, None, None) parser._nodes['Conv_0'][1].input.append('abcd') with pytest.raises(ValueError) as excinfo: - parser._consume_conv_nodes(parser._nodes['Conv_0'][1], - parser._nodes['Conv_0'][2]) + parser._consume_conv_nodes( + parser._nodes['Conv_0'][1], parser._nodes['Conv_0'][2] + ) expected_msg_conv = "Conv_0 input has 4 dimensions, only nodes with 2 or 3 input dimensions can be used as starting points for consumption." assert str(excinfo.value) == expected_msg_conv @@ -232,8 +234,9 @@ def test_consume_reshape_wrong_dims(datadir): parser.parse_network(model.graph, None, None) parser._nodes['Reshape_2'][1].input.append('abcd') with pytest.raises(ValueError) as excinfo: - parser._consume_reshape_nodes(parser._nodes['Reshape_2'][1], - parser._nodes['Reshape_2'][2]) + parser._consume_reshape_nodes( + parser._nodes['Reshape_2'][1], parser._nodes['Reshape_2'][2] + ) expected_msg_reshape = """Reshape_2 input has 3 dimensions, only nodes with 2 input dimensions can be used as starting points for consumption.""" assert str(excinfo.value) == expected_msg_reshape @@ -245,6 +248,8 @@ def test_consume_maxpool_wrong_dims(datadir): parser.parse_network(model.graph, None, None) parser._nodes['node1'][1].input.append('abcd') with pytest.raises(ValueError) as excinfo: - parser._consume_pool_nodes(parser._nodes['node1'][1], parser._nodes['node1'][2]) + parser._consume_pool_nodes( + parser._nodes['node1'][1], parser._nodes['node1'][2] + ) expected_msg_maxpool = """node1 input has 2 dimensions, only nodes with 1 input dimension can be used as starting points for consumption.""" assert str(excinfo.value) == expected_msg_maxpool diff --git a/tests/neuralnet/test_network_definition.py b/tests/neuralnet/test_network_definition.py index 310f3451..dfed1ef3 100644 --- a/tests/neuralnet/test_network_definition.py +++ b/tests/neuralnet/test_network_definition.py @@ -133,6 +133,7 @@ def test_input_bound_scaling_multiD(): ) assert net.scaled_input_bounds == scaled_input_bounds + def _test_add_invalid_edge(direction): """ direction can be "in" or "out" @@ -160,17 +161,17 @@ def _test_add_invalid_edge(direction): biases=np.array([0.0, 0.0]), ) - if direction == 'in': + if direction == "in": with pytest.raises(ValueError) as excinfo: - net.add_edge(input_layer,dense_layer_1) + net.add_edge(input_layer, dense_layer_1) expected_msg = f"Inbound layer {dense_layer_1} not found in network." assert str(excinfo.value) == expected_msg - elif direction == 'out': + elif direction == "out": with pytest.raises(ValueError) as excinfo: - net.add_edge(dense_layer_1,dense_layer_0) + net.add_edge(dense_layer_1, dense_layer_0) expected_msg = f"Outbound layer {dense_layer_1} not found in network." assert str(excinfo.value) == expected_msg def test_add_invalid_edge(): - _test_add_invalid_edge('in') - _test_add_invalid_edge('out') + _test_add_invalid_edge("in") + _test_add_invalid_edge("out") diff --git a/tests/neuralnet/test_nn_formulation.py b/tests/neuralnet/test_nn_formulation.py index c856a0c9..d5ce9c37 100644 --- a/tests/neuralnet/test_nn_formulation.py +++ b/tests/neuralnet/test_nn_formulation.py @@ -21,11 +21,11 @@ ) from omlt.neuralnet.layers.full_space import ( full_space_maxpool2d_layer, - _input_layer_and_block + _input_layer_and_block, ) from omlt.neuralnet.layers.partition_based import ( partition_based_dense_relu_layer, - default_partition_split_func + default_partition_split_func, ) from omlt.neuralnet.layers.reduced_space import reduced_space_dense_layer @@ -350,9 +350,9 @@ def _test_formulation_initialize_extra_input(network_formulation): extra_input = InputLayer([1]) net.add_layer(extra_input) with pytest.raises(ValueError) as excinfo: - if network_formulation == 'FullSpace': + if network_formulation == "FullSpace": FullSpaceNNFormulation(net) - elif network_formulation == 'ReducedSpace': + elif network_formulation == "ReducedSpace": ReducedSpaceNNFormulation(net) expected_msg = "Multiple input layers are not currently supported." assert str(excinfo.value) == expected_msg @@ -367,11 +367,11 @@ def _test_formulation_added_extra_input(network_formulation): """ net, y = two_node_network("linear", -2.0) extra_input = InputLayer([1]) - if network_formulation == 'FullSpace': + if network_formulation == "FullSpace": formulation = FullSpaceNNFormulation(net) - elif network_formulation == 'ReducedSpace': + elif network_formulation == "ReducedSpace": formulation = ReducedSpaceNNFormulation(net) - elif network_formulation == 'relu': + elif network_formulation == "relu": formulation = ReluPartitionFormulation(net) net.add_layer(extra_input) with pytest.raises(ValueError) as excinfo: @@ -389,11 +389,11 @@ def _test_formulation_build_extra_input(network_formulation): """ net, y = two_node_network("linear", -2.0) extra_input = InputLayer([1]) - if network_formulation == 'FullSpace': + if network_formulation == "FullSpace": formulation = FullSpaceNNFormulation(net) - elif network_formulation == 'ReducedSpace': + elif network_formulation == "ReducedSpace": formulation = ReducedSpaceNNFormulation(net) - elif network_formulation == 'relu': + elif network_formulation == "relu": formulation = ReluPartitionFormulation(net) net.add_layer(extra_input) m = pyo.ConcreteModel() @@ -419,11 +419,11 @@ def _test_formulation_added_extra_output(network_formulation): weights=np.array([[1.0, 0.0], [5.0, 1.0]]), biases=np.array([3.0, 4.0]), ) - if network_formulation == 'FullSpace': + if network_formulation == "FullSpace": formulation = FullSpaceNNFormulation(net) - elif network_formulation == 'ReducedSpace': + elif network_formulation == "ReducedSpace": formulation = ReducedSpaceNNFormulation(net) - elif network_formulation == 'relu': + elif network_formulation == "relu": formulation = ReluPartitionFormulation(net) net.add_layer(extra_output) net.add_edge(list(net.layers)[-2], extra_output) @@ -450,9 +450,9 @@ def _test_formulation_initialize_extra_output(network_formulation): net.add_layer(extra_output) net.add_edge(list(net.layers)[-2], extra_output) with pytest.raises(ValueError) as excinfo: - if network_formulation == 'FullSpace': + if network_formulation == "FullSpace": FullSpaceNNFormulation(net) - elif network_formulation == 'ReducedSpace': + elif network_formulation == "ReducedSpace": ReducedSpaceNNFormulation(net) expected_msg = "Multiple output layers are not currently supported." assert str(excinfo.value) == expected_msg @@ -489,9 +489,9 @@ def _test_dense_layer_multiple_predecessors(layer_type): net.add_layer(extra_input) net.add_edge(extra_input, test_layer) with pytest.raises(ValueError) as excinfo: - if layer_type == 'PartitionBased': + if layer_type == "PartitionBased": partition_based_dense_relu_layer(m, net, m, test_layer, None) - elif layer_type == 'ReducedSpace': + elif layer_type == "ReducedSpace": reduced_space_dense_layer(m, net, m, test_layer, None) expected_msg = f"Layer {test_layer} has multiple predecessors." assert str(excinfo.value) == expected_msg @@ -513,9 +513,9 @@ def _test_dense_layer_no_predecessors(layer_type): ) net.add_layer(test_layer) with pytest.raises(ValueError) as excinfo: - if layer_type == 'PartitionBased': + if layer_type == "PartitionBased": partition_based_dense_relu_layer(m, net, m, test_layer, None) - elif layer_type == 'ReducedSpace': + elif layer_type == "ReducedSpace": reduced_space_dense_layer(m, net, m, test_layer, None) expected_msg = f"Layer {test_layer} is not an input layer, but has no predecessors." assert str(excinfo.value) == expected_msg @@ -546,8 +546,9 @@ def test_partition_based_unbounded_below(): split_func = lambda w: default_partition_split_func(w, 2) with pytest.raises(ValueError) as excinfo: - partition_based_dense_relu_layer(m.neural_net_block, net, - m.neural_net_block, test_layer, split_func) + partition_based_dense_relu_layer( + m.neural_net_block, net, m.neural_net_block, test_layer, split_func + ) expected_msg = "Expression is unbounded below." assert str(excinfo.value) == expected_msg @@ -567,8 +568,9 @@ def test_partition_based_unbounded_above(): split_func = lambda w: default_partition_split_func(w, 2) with pytest.raises(ValueError) as excinfo: - partition_based_dense_relu_layer(m.neural_net_block, net, m.neural_net_block, - test_layer, split_func) + partition_based_dense_relu_layer( + m.neural_net_block, net, m.neural_net_block, test_layer, split_func + ) expected_msg = "Expression is unbounded above." assert str(excinfo.value) == expected_msg @@ -586,8 +588,9 @@ def test_partition_based_bias_unbounded_below(): split_func = lambda w: default_partition_split_func(w, 2) with pytest.raises(ValueError) as excinfo: - partition_based_dense_relu_layer(m.neural_net_block, net, - m.neural_net_block, test_layer, split_func) + partition_based_dense_relu_layer( + m.neural_net_block, net, m.neural_net_block, test_layer, split_func + ) expected_msg = "Expression is unbounded below." assert str(excinfo.value) == expected_msg @@ -605,8 +608,9 @@ def test_partition_based_bias_unbounded_above(): split_func = lambda w: default_partition_split_func(w, 2) with pytest.raises(ValueError) as excinfo: - partition_based_dense_relu_layer(m.neural_net_block, net, m.neural_net_block, - test_layer, split_func) + partition_based_dense_relu_layer( + m.neural_net_block, net, m.neural_net_block, test_layer, split_func + ) expected_msg = "Expression is unbounded above." assert str(excinfo.value) == expected_msg @@ -711,19 +715,25 @@ def test_maxpool2d_bad_input_activation(): # test normal ConvLayer -> MaxPoolLayer structure, with monotonic increasing # activation part of ConvLayer maxpool_layer_1 = PoolingLayer2D( - conv_layer_2.output_size, [1, 1, 2], [2, 2], "max", [3, 2], - 1, activation="linear" + conv_layer_2.output_size, + [1, 1, 2], + [2, 2], + "max", + [3, 2], + 1, + activation="linear", ) net.add_layer(maxpool_layer_1) net.add_edge(conv_layer_2, maxpool_layer_1) m.neural_net_block.build_formulation(FullSpaceNNFormulation(net)) - conv_layer_2.activation = 'relu' + conv_layer_2.activation = "relu" with pytest.raises(ValueError) as excinfo: - full_space_maxpool2d_layer(m.neural_net_block, net, m.neural_net_block, - maxpool_layer_1) + full_space_maxpool2d_layer( + m.neural_net_block, net, m.neural_net_block, maxpool_layer_1 + ) expected_msg = """Non-increasing activation functions on the preceding convolutional layer are not supported.""" assert str(excinfo.value) == expected_msg @@ -765,15 +775,25 @@ def test_maxpool2d_bad_input_layer(): # test normal ConvLayer -> MaxPoolLayer structure, with monotonic increasing # activation part of ConvLayer maxpool_layer_1 = PoolingLayer2D( - conv_layer_2.output_size, [1, 1, 2], [2, 2], "max", - [3, 2], 1, activation="linear" + conv_layer_2.output_size, + [1, 1, 2], + [2, 2], + "max", + [3, 2], + 1, + activation="linear", ) net.add_layer(maxpool_layer_1) net.add_edge(conv_layer_2, maxpool_layer_1) maxpool_layer_2 = PoolingLayer2D( - maxpool_layer_1.output_size, [1, 1, 2], [2, 2], "max", - [3, 2], 1, activation="linear" + maxpool_layer_1.output_size, + [1, 1, 2], + [2, 2], + "max", + [3, 2], + 1, + activation="linear", ) net.add_layer(maxpool_layer_2) net.add_edge(maxpool_layer_1, maxpool_layer_2) From 071901715ddee3a9f75ec6eca14db90ebff8f6fd Mon Sep 17 00:00:00 2001 From: Jeremy Sadler <53983960+jezsadler@users.noreply.github.com> Date: Fri, 3 Nov 2023 19:41:58 +0000 Subject: [PATCH 28/30] Final trailing whitespace --- tests/neuralnet/test_nn_formulation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/neuralnet/test_nn_formulation.py b/tests/neuralnet/test_nn_formulation.py index d5ce9c37..0935e188 100644 --- a/tests/neuralnet/test_nn_formulation.py +++ b/tests/neuralnet/test_nn_formulation.py @@ -715,7 +715,7 @@ def test_maxpool2d_bad_input_activation(): # test normal ConvLayer -> MaxPoolLayer structure, with monotonic increasing # activation part of ConvLayer maxpool_layer_1 = PoolingLayer2D( - conv_layer_2.output_size, + conv_layer_2.output_size, [1, 1, 2], [2, 2], "max", From 5154edf2a73e0c7e3ab67660ebfc46f0b9b2b407 Mon Sep 17 00:00:00 2001 From: Jeremy Sadler <53983960+jezsadler@users.noreply.github.com> Date: Fri, 3 Nov 2023 21:55:01 +0000 Subject: [PATCH 29/30] More linting: indents and quote marks --- src/omlt/gbt/gbt_formulation.py | 6 +- src/omlt/gbt/model.py | 4 +- src/omlt/io/onnx_parser.py | 67 ++++++++++---------- src/omlt/neuralnet/layer.py | 14 ++-- src/omlt/neuralnet/layers/full_space.py | 4 +- src/omlt/neuralnet/layers/partition_based.py | 2 +- src/omlt/neuralnet/layers/reduced_space.py | 2 +- tests/io/test_onnx_parser.py | 56 ++++++++-------- tests/neuralnet/test_network_definition.py | 1 + tests/neuralnet/test_nn_formulation.py | 10 +-- 10 files changed, 86 insertions(+), 80 deletions(-) diff --git a/src/omlt/gbt/gbt_formulation.py b/src/omlt/gbt/gbt_formulation.py index a3d9e878..f2d01296 100644 --- a/src/omlt/gbt/gbt_formulation.py +++ b/src/omlt/gbt/gbt_formulation.py @@ -200,11 +200,11 @@ def _branching_y(tree_id, branch_node_id): if len(branch_value) != 1: raise ValueError( f"The given tree_id and branch_node_id do not uniquely identify a branch value." - ) + ) if len(feature_id) != 1: raise ValueError( f"The given tree_id and branch_node_id do not uniquely identify a feature." - ) + ) feature_id = feature_id[0] branch_value = branch_value[0] (branch_y_idx,) = np.where( @@ -213,7 +213,7 @@ def _branching_y(tree_id, branch_node_id): if len(branch_y_idx) != 1: raise ValueError( f"The given tree_id and branch_node_id do not uniquely identify a branch index." - ) + ) return block.y[feature_id, branch_y_idx[0]] def _sum_of_z_l(tree_id, start_node_id): diff --git a/src/omlt/gbt/model.py b/src/omlt/gbt/model.py index c45d516b..9bac2590 100644 --- a/src/omlt/gbt/model.py +++ b/src/omlt/gbt/model.py @@ -59,7 +59,7 @@ def _model_num_inputs(model): if len(graph.input) != 1: raise ValueError( f"Model graph input field is multi-valued {graph.input}. A single value is required." - ) + ) return _tensor_size(graph.input[0]) @@ -69,7 +69,7 @@ def _model_num_outputs(model): if len(graph.output) != 1: raise ValueError( f"Model graph output field is multi-valued {graph.output}. A single value is required." - ) + ) return _tensor_size(graph.output[0]) diff --git a/src/omlt/io/onnx_parser.py b/src/omlt/io/onnx_parser.py index 9e7ab3ba..511261c0 100644 --- a/src/omlt/io/onnx_parser.py +++ b/src/omlt/io/onnx_parser.py @@ -76,7 +76,7 @@ def parse_network(self, graph, scaling_object, input_bounds): if dim_value is None: raise ValueError( f'All dimensions in graph "{graph.name}" input tensor have 0 value.' - ) + ) assert network_input is None network_input = InputLayer(size) self._node_map[input.name] = network_input @@ -120,7 +120,7 @@ def parse_network(self, graph, scaling_object, input_bounds): else: raise ValueError( f'Nodes must have inputs or have op_type "Constant". Node "{node.name}" has no inputs and op_type "{node.op_type}".' - ) + ) # traverse graph self._node_stack = list(inputs) @@ -179,11 +179,11 @@ def _consume_dense_nodes(self, node, next_nodes): if node.op_type != "MatMul": raise ValueError( f"{node.name} is a {node.op_type} node, only MatMul nodes can be used as starting points for consumption." - ) + ) if len(node.input) != 2: raise ValueError( f"{node.name} input has {len(node.input)} dimensions, only nodes with 2 input dimensions can be used as starting points for consumption." - ) + ) [in_0, in_1] = list(node.input) input_layer, transformer = self._node_input_and_transformer(in_0) @@ -192,7 +192,7 @@ def _consume_dense_nodes(self, node, next_nodes): if len(next_nodes) != 1: raise ValueError( f"Next nodes must have length 1, {next_nodes} has length {len(next_nodes)}" - ) + ) # expect 'Add' node ahead type_, node, maybe_next_nodes = self._nodes[next_nodes[0]] @@ -201,7 +201,7 @@ def _consume_dense_nodes(self, node, next_nodes): if node.op_type != "Add": raise ValueError( f"The first node to be consumed, {node.name}, is a {node.op_type} node. Only Add nodes are supported." - ) + ) # extract biases next_nodes = maybe_next_nodes @@ -219,12 +219,11 @@ def _consume_dense_nodes(self, node, next_nodes): if node_weights.shape[1] != node_biases.shape[0]: raise ValueError( f"Node weights has {node_weights.shape[1]} columns; node biases has {node_biases.shape[0]} rows. These must be equal." - ) + ) if len(node.output) != 1: raise ValueError( f"Node output is {node.output} but should be a single value." - ) - + ) input_output_size = _get_input_output_size(input_layer, transformer) @@ -257,11 +256,11 @@ def _consume_gemm_dense_nodes(self, node, next_nodes): if node.op_type != "Gemm": raise ValueError( f"{node.name} is a {node.op_type} node, only Gemm nodes can be used as starting points for consumption." - ) + ) if len(node.input) != 3: raise ValueError( f"{node.name} input has {len(node.input)} dimensions, only nodes with 3 input dimensions can be used as starting points for consumption." - ) + ) attr = _collect_attributes(node) alpha = attr["alpha"] @@ -312,11 +311,11 @@ def _consume_conv_nodes(self, node, next_nodes): if node.op_type != "Conv": raise ValueError( f"{node.name} is a {node.op_type} node, only Conv nodes can be used as starting points for consumption." - ) - if len(node.input) not in [2,3]: + ) + if len(node.input) not in [2, 3]: raise ValueError( f"{node.name} input has {len(node.input)} dimensions, only nodes with 2 or 3 input dimensions can be used as starting points for consumption." - ) + ) if len(node.input) == 2: [in_0, in_1] = list(node.input) @@ -340,39 +339,39 @@ def _consume_conv_nodes(self, node, next_nodes): if attr["kernel_shape"] != kernel_shape: raise ValueError( f"Kernel shape attribute {attr['kernel_shape']} does not match initialized kernel shape {kernel_shape}." - ) + ) if len(kernel_shape) != len(strides): raise ValueError( f"Initialized kernel shape {kernel_shape} has {len(kernel_shape)} dimensions. Strides attribute has {len(strides)} dimensions. These must be equal." - ) + ) if len(input_output_size) != len(kernel_shape) + 1: raise ValueError( f"Input/output size ({input_output_size}) must have one more dimension than initialized kernel shape ({kernel_shape})." - ) + ) # Check input, output have correct dimensions if biases.shape != (out_channels,): raise ValueError( f"Biases shape {biases.shape} must match output weights channels {(out_channels,)}." - ) + ) if in_channels != input_output_size[0]: raise ValueError( f"Input/output size ({input_output_size}) first dimension must match input weights channels ({in_channels})." - ) + ) # Other attributes are not supported if "dilations" in attr and attr["dilations"] != [1, 1]: raise ValueError( f"{node} has non-identity dilations ({attr['dilations']}). This is not supported." - ) + ) if attr["group"] != 1: raise ValueError( f"{node} has multiple groups ({attr['group']}). This is not supported." - ) + ) if "pads" in attr and np.any(attr["pads"]): raise ValueError( f"{node} has non-zero pads ({attr['pads']}). This is not supported." - ) + ) # generate new nodes for the node output padding = 0 @@ -395,7 +394,7 @@ def _consume_conv_nodes(self, node, next_nodes): if len(input_output_size) != 3: raise ValueError( f"Expected a 2D image with channels, got {input_output_size}." - ) + ) conv_layer = ConvLayer2D( input_output_size, @@ -415,11 +414,11 @@ def _consume_reshape_nodes(self, node, next_nodes): if node.op_type != "Reshape": raise ValueError( f"{node.name} is a {node.op_type} node, only Reshape nodes can be used as starting points for consumption." - ) + ) if len(node.input) != 2: raise ValueError( f"{node.name} input has {len(node.input)} dimensions, only nodes with 2 input dimensions can be used as starting points for consumption." - ) + ) [in_0, in_1] = list(node.input) input_layer = self._node_map[in_0] new_shape = self._constants[in_1] @@ -436,18 +435,18 @@ def _consume_pool_nodes(self, node, next_nodes): if node.op_type not in _POOLING_OP_TYPES: raise ValueError( f"{node.name} is a {node.op_type} node, only MaxPool nodes can be used as starting points for consumption." - ) + ) pool_func_name = "max" # ONNX network should not contain indices output from MaxPool - not supported by OMLT if len(node.output) != 1: raise ValueError( f"The ONNX contains indices output from MaxPool. This is not supported by OMLT." - ) + ) if len(node.input) != 1: raise ValueError( f"{node.name} input has {len(node.input)} dimensions, only nodes with 1 input dimension can be used as starting points for consumption." - ) + ) input_layer, transformer = self._node_input_and_transformer(node.input[0]) input_output_size = _get_input_output_size(input_layer, transformer) @@ -459,7 +458,7 @@ def _consume_pool_nodes(self, node, next_nodes): if input_output_size[0] != 1: raise ValueError( f"{node.name} has {input_output_size[0]} batches, only a single batch is supported." - ) + ) input_output_size = input_output_size[1:] in_channels = input_output_size[0] @@ -474,23 +473,23 @@ def _consume_pool_nodes(self, node, next_nodes): if "dilations" in attr and attr["dilations"] != [1, 1]: raise ValueError( f"{node.name} has non-identity dilations ({attr['dilations']}). This is not supported." - ) + ) if "pads" in attr and np.any(attr["pads"]): raise ValueError( f"{node.name} has non-zero pads ({attr['pads']}). This is not supported." - ) + ) if ("auto_pad" in attr) and (attr["auto_pad"] != "NOTSET"): raise ValueError( f"{node.name} has autopad set ({attr['auto_pad']}). This is not supported." - ) + ) if len(kernel_shape) != len(strides): raise ValueError( f"Kernel shape {kernel_shape} has {len(kernel_shape)} dimensions. Strides attribute has {len(strides)} dimensions. These must be equal." - ) + ) if len(input_output_size) != len(kernel_shape) + 1: raise ValueError( f"Input/output size ({input_output_size}) must have one more dimension than kernel shape ({kernel_shape})." - ) + ) output_shape_wrapper = math.floor if "ceil_mode" in attr and attr["ceil_mode"] == 1: diff --git a/src/omlt/neuralnet/layer.py b/src/omlt/neuralnet/layer.py index 15ed022b..fa9951f5 100644 --- a/src/omlt/neuralnet/layer.py +++ b/src/omlt/neuralnet/layer.py @@ -23,14 +23,14 @@ class Layer: def __init__( self, input_size, output_size, *, activation=None, input_index_mapper=None ): - if not isinstance(input_size, (list,tuple)): + if not isinstance(input_size, (list, tuple)): raise TypeError( f"input_size must be a list or tuple, {type(input_size)} was provided." - ) - if not isinstance(output_size, (list,tuple)): + ) + if not isinstance(output_size, (list, tuple)): raise TypeError( f"output_size must be a list or tuple, {type(output_size)} was provided." - ) + ) self.__input_size = list(input_size) self.__output_size = list(output_size) self.activation = activation @@ -108,7 +108,7 @@ def eval_single_layer(self, x): if x_reshaped.shape != tuple(self.input_size): raise ValueError( f"Layer requires an input size {self.input_size}, but the input tensor had size {x_reshaped.shape}." - ) + ) y = self._eval(x_reshaped) return self._apply_activation(y) @@ -321,7 +321,7 @@ def _eval(self, x): if len(self.output_size) != 3: raise ValueError( f"Output should have 3 dimensions but instead has {len(self.output_size)}" - ) + ) [depth, rows, cols] = list(self.output_size) for out_d in range(depth): for out_r in range(rows): @@ -380,7 +380,7 @@ def __init__( if pool_func_name not in PoolingLayer2D._POOL_FUNCTIONS: raise ValueError( f"Allowable pool functions are {PoolingLayer2D._POOL_FUNCTIONS}, {pool_func_name} was provided." - ) + ) self._pool_func_name = pool_func_name self._kernel_shape = kernel_shape self._kernel_depth = kernel_depth diff --git a/src/omlt/neuralnet/layers/full_space.py b/src/omlt/neuralnet/layers/full_space.py index a699a76e..d1c4e824 100644 --- a/src/omlt/neuralnet/layers/full_space.py +++ b/src/omlt/neuralnet/layers/full_space.py @@ -59,7 +59,7 @@ def full_space_conv2d_layer(net_block, net, layer_block, layer): if succ_layer.activation != "linear": raise ValueError( f"Activation is applied after convolution layer, but the successor max pooling layer {succ_layer} has an activation function also." - ) + ) succ_layer.activation = layer.activation layer.activation = "linear" @@ -122,7 +122,7 @@ def full_space_maxpool2d_layer(net_block, net, layer_block, layer): if input_layer.activation != "linear": raise ValueError( "Non-increasing activation functions on the preceding convolutional layer are not supported." - ) + ) # TODO - add support for non-increasing activation functions on preceding convolutional layer # note kernel indexes are the same set of values for any output index, so wlog get kernel indexes for (0, 0, 0) diff --git a/src/omlt/neuralnet/layers/partition_based.py b/src/omlt/neuralnet/layers/partition_based.py index 746196e7..87e13f18 100644 --- a/src/omlt/neuralnet/layers/partition_based.py +++ b/src/omlt/neuralnet/layers/partition_based.py @@ -47,7 +47,7 @@ def partition_based_dense_relu_layer(net_block, net, layer_block, layer, split_f if len(prev_layers) == 0: raise ValueError( f"Layer {layer} is not an input layer, but has no predecessors." - ) + ) elif len(prev_layers) > 1: raise ValueError(f"Layer {layer} has multiple predecessors.") prev_layer = prev_layers[0] diff --git a/src/omlt/neuralnet/layers/reduced_space.py b/src/omlt/neuralnet/layers/reduced_space.py index 3fb67477..95d1f97f 100644 --- a/src/omlt/neuralnet/layers/reduced_space.py +++ b/src/omlt/neuralnet/layers/reduced_space.py @@ -14,7 +14,7 @@ def reduced_space_dense_layer(net_block, net, layer_block, layer, activation): if len(prev_layers) == 0: raise ValueError( f"Layer {layer} is not an input layer, but has no predecessors." - ) + ) elif len(prev_layers) > 1: raise ValueError(f"Layer {layer} has multiple predecessors.") prev_layer = prev_layers[0] diff --git a/tests/io/test_onnx_parser.py b/tests/io/test_onnx_parser.py index 59cce5c9..4dec3f90 100644 --- a/tests/io/test_onnx_parser.py +++ b/tests/io/test_onnx_parser.py @@ -150,36 +150,41 @@ def test_consume_wrong_node_type(datadir): with pytest.raises(ValueError) as excinfo: parser._consume_dense_nodes( - parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][1], - parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][2]) + parser._nodes["StatefulPartitionedCall/keras_linear_131/dense/BiasAdd"][1], + parser._nodes["StatefulPartitionedCall/keras_linear_131/dense/BiasAdd"][2], + ) expected_msg_dense = "StatefulPartitionedCall/keras_linear_131/dense/BiasAdd is a Add node, only MatMul nodes can be used as starting points for consumption." assert str(excinfo.value) == expected_msg_dense with pytest.raises(ValueError) as excinfo: parser._consume_gemm_dense_nodes( - parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][1], - parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][2]) + parser._nodes["StatefulPartitionedCall/keras_linear_131/dense/BiasAdd"][1], + parser._nodes["StatefulPartitionedCall/keras_linear_131/dense/BiasAdd"][2], + ) expected_msg_gemm = "StatefulPartitionedCall/keras_linear_131/dense/BiasAdd is a Add node, only Gemm nodes can be used as starting points for consumption." assert str(excinfo.value) == expected_msg_gemm with pytest.raises(ValueError) as excinfo: parser._consume_conv_nodes( - parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][1], - parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][2]) + parser._nodes["StatefulPartitionedCall/keras_linear_131/dense/BiasAdd"][1], + parser._nodes["StatefulPartitionedCall/keras_linear_131/dense/BiasAdd"][2], + ) expected_msg_conv = "StatefulPartitionedCall/keras_linear_131/dense/BiasAdd is a Add node, only Conv nodes can be used as starting points for consumption." assert str(excinfo.value) == expected_msg_conv with pytest.raises(ValueError) as excinfo: parser._consume_reshape_nodes( - parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][1], - parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][2]) + parser._nodes["StatefulPartitionedCall/keras_linear_131/dense/BiasAdd"][1], + parser._nodes["StatefulPartitionedCall/keras_linear_131/dense/BiasAdd"][2], + ) expected_msg_reshape = "StatefulPartitionedCall/keras_linear_131/dense/BiasAdd is a Add node, only Reshape nodes can be used as starting points for consumption." assert str(excinfo.value) == expected_msg_reshape with pytest.raises(ValueError) as excinfo: parser._consume_pool_nodes( - parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][1], - parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/BiasAdd'][2]) + parser._nodes["StatefulPartitionedCall/keras_linear_131/dense/BiasAdd"][1], + parser._nodes["StatefulPartitionedCall/keras_linear_131/dense/BiasAdd"][2], + ) expected_msg_pool = """StatefulPartitionedCall/keras_linear_131/dense/BiasAdd is a Add node, only MaxPool nodes can be used as starting points for consumption.""" assert str(excinfo.value) == expected_msg_pool @@ -190,11 +195,12 @@ def test_consume_dense_wrong_dims(datadir): parser = NetworkParser() parser.parse_network(model.graph, None, None) - parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/MatMul'][1].input.append('abcd') + parser._nodes["StatefulPartitionedCall/keras_linear_131/dense/MatMul"][1].input.append("abcd") with pytest.raises(ValueError) as excinfo: parser._consume_dense_nodes( - parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/MatMul'][1], - parser._nodes['StatefulPartitionedCall/keras_linear_131/dense/MatMul'][2]) + parser._nodes["StatefulPartitionedCall/keras_linear_131/dense/MatMul"][1], + parser._nodes["StatefulPartitionedCall/keras_linear_131/dense/MatMul"][2], + ) expected_msg_dense = "StatefulPartitionedCall/keras_linear_131/dense/MatMul input has 3 dimensions, only nodes with 2 input dimensions can be used as starting points for consumption." assert str(excinfo.value) == expected_msg_dense @@ -204,11 +210,11 @@ def test_consume_gemm_wrong_dims(datadir): model = onnx.load(datadir.file("gemm.onnx")) parser = NetworkParser() parser.parse_network(model.graph, None, None) - parser._nodes['Gemm_0'][1].input.append('abcd') + parser._nodes["Gemm_0"][1].input.append("abcd") with pytest.raises(ValueError) as excinfo: parser._consume_gemm_dense_nodes( - parser._nodes['Gemm_0'][1], parser._nodes['Gemm_0'][2] - ) + parser._nodes["Gemm_0"][1], parser._nodes["Gemm_0"][2] + ) expected_msg_gemm = "Gemm_0 input has 4 dimensions, only nodes with 3 input dimensions can be used as starting points for consumption." assert str(excinfo.value) == expected_msg_gemm @@ -218,11 +224,11 @@ def test_consume_conv_wrong_dims(datadir): model = onnx.load(datadir.file("convx1_gemmx1.onnx")) parser = NetworkParser() parser.parse_network(model.graph, None, None) - parser._nodes['Conv_0'][1].input.append('abcd') + parser._nodes["Conv_0"][1].input.append("abcd") with pytest.raises(ValueError) as excinfo: parser._consume_conv_nodes( - parser._nodes['Conv_0'][1], parser._nodes['Conv_0'][2] - ) + parser._nodes["Conv_0"][1], parser._nodes["Conv_0"][2] + ) expected_msg_conv = "Conv_0 input has 4 dimensions, only nodes with 2 or 3 input dimensions can be used as starting points for consumption." assert str(excinfo.value) == expected_msg_conv @@ -232,11 +238,11 @@ def test_consume_reshape_wrong_dims(datadir): model = onnx.load(datadir.file("convx1_gemmx1.onnx")) parser = NetworkParser() parser.parse_network(model.graph, None, None) - parser._nodes['Reshape_2'][1].input.append('abcd') + parser._nodes["Reshape_2"][1].input.append("abcd") with pytest.raises(ValueError) as excinfo: parser._consume_reshape_nodes( - parser._nodes['Reshape_2'][1], parser._nodes['Reshape_2'][2] - ) + parser._nodes["Reshape_2"][1], parser._nodes["Reshape_2"][2] + ) expected_msg_reshape = """Reshape_2 input has 3 dimensions, only nodes with 2 input dimensions can be used as starting points for consumption.""" assert str(excinfo.value) == expected_msg_reshape @@ -246,10 +252,10 @@ def test_consume_maxpool_wrong_dims(datadir): model = onnx.load(datadir.file("maxpool_2d.onnx")) parser = NetworkParser() parser.parse_network(model.graph, None, None) - parser._nodes['node1'][1].input.append('abcd') + parser._nodes["node1"][1].input.append("abcd") with pytest.raises(ValueError) as excinfo: parser._consume_pool_nodes( - parser._nodes['node1'][1], parser._nodes['node1'][2] - ) + parser._nodes["node1"][1], parser._nodes["node1"][2] + ) expected_msg_maxpool = """node1 input has 2 dimensions, only nodes with 1 input dimension can be used as starting points for consumption.""" assert str(excinfo.value) == expected_msg_maxpool diff --git a/tests/neuralnet/test_network_definition.py b/tests/neuralnet/test_network_definition.py index dfed1ef3..f3cadcb7 100644 --- a/tests/neuralnet/test_network_definition.py +++ b/tests/neuralnet/test_network_definition.py @@ -172,6 +172,7 @@ def _test_add_invalid_edge(direction): expected_msg = f"Outbound layer {dense_layer_1} not found in network." assert str(excinfo.value) == expected_msg + def test_add_invalid_edge(): _test_add_invalid_edge("in") _test_add_invalid_edge("out") diff --git a/tests/neuralnet/test_nn_formulation.py b/tests/neuralnet/test_nn_formulation.py index 0935e188..b2f7e811 100644 --- a/tests/neuralnet/test_nn_formulation.py +++ b/tests/neuralnet/test_nn_formulation.py @@ -548,7 +548,7 @@ def test_partition_based_unbounded_below(): with pytest.raises(ValueError) as excinfo: partition_based_dense_relu_layer( m.neural_net_block, net, m.neural_net_block, test_layer, split_func - ) + ) expected_msg = "Expression is unbounded below." assert str(excinfo.value) == expected_msg @@ -570,7 +570,7 @@ def test_partition_based_unbounded_above(): with pytest.raises(ValueError) as excinfo: partition_based_dense_relu_layer( m.neural_net_block, net, m.neural_net_block, test_layer, split_func - ) + ) expected_msg = "Expression is unbounded above." assert str(excinfo.value) == expected_msg @@ -590,7 +590,7 @@ def test_partition_based_bias_unbounded_below(): with pytest.raises(ValueError) as excinfo: partition_based_dense_relu_layer( m.neural_net_block, net, m.neural_net_block, test_layer, split_func - ) + ) expected_msg = "Expression is unbounded below." assert str(excinfo.value) == expected_msg @@ -610,7 +610,7 @@ def test_partition_based_bias_unbounded_above(): with pytest.raises(ValueError) as excinfo: partition_based_dense_relu_layer( m.neural_net_block, net, m.neural_net_block, test_layer, split_func - ) + ) expected_msg = "Expression is unbounded above." assert str(excinfo.value) == expected_msg @@ -733,7 +733,7 @@ def test_maxpool2d_bad_input_activation(): with pytest.raises(ValueError) as excinfo: full_space_maxpool2d_layer( m.neural_net_block, net, m.neural_net_block, maxpool_layer_1 - ) + ) expected_msg = """Non-increasing activation functions on the preceding convolutional layer are not supported.""" assert str(excinfo.value) == expected_msg From 7d660c4e075de4156d4b58faf4aeecab0d313df0 Mon Sep 17 00:00:00 2001 From: Jeremy Sadler <53983960+jezsadler@users.noreply.github.com> Date: Mon, 6 Nov 2023 20:28:26 +0000 Subject: [PATCH 30/30] Hopefully final linting --- src/omlt/neuralnet/layer.py | 2 +- tests/io/test_onnx_parser.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/omlt/neuralnet/layer.py b/src/omlt/neuralnet/layer.py index fa9951f5..d7f7fa89 100644 --- a/src/omlt/neuralnet/layer.py +++ b/src/omlt/neuralnet/layer.py @@ -377,7 +377,7 @@ def __init__( activation=activation, input_index_mapper=input_index_mapper, ) - if pool_func_name not in PoolingLayer2D._POOL_FUNCTIONS: + if pool_func_name not in PoolingLayer2D._POOL_FUNCTIONS: raise ValueError( f"Allowable pool functions are {PoolingLayer2D._POOL_FUNCTIONS}, {pool_func_name} was provided." ) diff --git a/tests/io/test_onnx_parser.py b/tests/io/test_onnx_parser.py index 4dec3f90..763b282c 100644 --- a/tests/io/test_onnx_parser.py +++ b/tests/io/test_onnx_parser.py @@ -195,7 +195,9 @@ def test_consume_dense_wrong_dims(datadir): parser = NetworkParser() parser.parse_network(model.graph, None, None) - parser._nodes["StatefulPartitionedCall/keras_linear_131/dense/MatMul"][1].input.append("abcd") + parser._nodes["StatefulPartitionedCall/keras_linear_131/dense/MatMul"][ + 1 + ].input.append("abcd") with pytest.raises(ValueError) as excinfo: parser._consume_dense_nodes( parser._nodes["StatefulPartitionedCall/keras_linear_131/dense/MatMul"][1], @@ -254,8 +256,6 @@ def test_consume_maxpool_wrong_dims(datadir): parser.parse_network(model.graph, None, None) parser._nodes["node1"][1].input.append("abcd") with pytest.raises(ValueError) as excinfo: - parser._consume_pool_nodes( - parser._nodes["node1"][1], parser._nodes["node1"][2] - ) + parser._consume_pool_nodes(parser._nodes["node1"][1], parser._nodes["node1"][2]) expected_msg_maxpool = """node1 input has 2 dimensions, only nodes with 1 input dimension can be used as starting points for consumption.""" assert str(excinfo.value) == expected_msg_maxpool