Source code for omlt.neuralnet.nn_formulation
from functools import partial
import pyomo.environ as pyo
from omlt.formulation import _PyomoFormulation, _setup_scaled_inputs_outputs
from omlt.neuralnet.activations import (
ACTIVATION_FUNCTION_MAP as _DEFAULT_ACTIVATION_FUNCTIONS,
)
from omlt.neuralnet.activations import (
ComplementarityReLUActivation,
bigm_relu_activation_constraint,
linear_activation_constraint,
linear_activation_function,
sigmoid_activation_constraint,
sigmoid_activation_function,
softplus_activation_constraint,
softplus_activation_function,
tanh_activation_constraint,
tanh_activation_function,
)
from omlt.neuralnet.layer import (
ConvLayer2D,
DenseLayer,
GNNLayer,
InputLayer,
PoolingLayer2D,
)
from omlt.neuralnet.layers.full_space import (
full_space_conv2d_layer,
full_space_dense_layer,
full_space_gnn_layer,
full_space_maxpool2d_layer,
)
from omlt.neuralnet.layers.partition_based import (
default_partition_split_func,
partition_based_dense_relu_layer,
)
from omlt.neuralnet.layers.reduced_space import reduced_space_dense_layer
def _ignore_input_layer():
pass
_DEFAULT_LAYER_CONSTRAINTS = {
InputLayer: _ignore_input_layer,
DenseLayer: full_space_dense_layer,
ConvLayer2D: full_space_conv2d_layer,
PoolingLayer2D: full_space_maxpool2d_layer,
GNNLayer: full_space_gnn_layer,
}
_DEFAULT_ACTIVATION_CONSTRAINTS = {
"linear": linear_activation_constraint,
"relu": bigm_relu_activation_constraint,
"sigmoid": sigmoid_activation_constraint,
"softplus": softplus_activation_constraint,
"tanh": tanh_activation_constraint,
}
MULTI_INPUTS_UNSUPPORTED = "Multiple input layers are not currently supported."
MULTI_OUTPUTS_UNSUPPORTED = "Multiple output layers are not currently supported."
[docs]
class FullSpaceNNFormulation(_PyomoFormulation):
"""This class is the entry-point to build neural network formulations.
This class iterates over all nodes in the neural network and for
each one them, generates the constraints to represent the layer
and its activation function.
Parameters
----------
network_structure : NetworkDefinition
the neural network definition
layer_constraints : dict-like or None
overrides the constraints generated for the specified layer types
activation_constraints : dict-like or None
overrides the constraints generated for the specified activation functions
"""
def __init__(
self, network_structure, layer_constraints=None, activation_constraints=None
):
super().__init__()
self.__network_definition = network_structure
self.__scaling_object = network_structure.scaling_object
self.__scaled_input_bounds = network_structure.scaled_input_bounds
self._layer_constraints = dict(self._supported_default_layer_constraints())
self._activation_constraints = dict(
self._supported_default_activation_constraints()
)
if layer_constraints is not None:
self._layer_constraints.update(layer_constraints)
if activation_constraints is not None:
self._activation_constraints.update(activation_constraints)
network_inputs = list(self.__network_definition.input_nodes)
if len(network_inputs) != 1:
raise ValueError(MULTI_INPUTS_UNSUPPORTED)
network_outputs = list(self.__network_definition.output_nodes)
if len(network_outputs) != 1:
raise ValueError(MULTI_OUTPUTS_UNSUPPORTED)
def _supported_default_layer_constraints(self):
return _DEFAULT_LAYER_CONSTRAINTS
def _supported_default_activation_constraints(self):
return _DEFAULT_ACTIVATION_CONSTRAINTS
def _build_formulation(self):
_setup_scaled_inputs_outputs(
self.block, self.__scaling_object, self.__scaled_input_bounds
)
_build_neural_network_formulation(
block=self.block,
network_structure=self.__network_definition,
layer_constraints=self._layer_constraints,
activation_constraints=self._activation_constraints,
)
@property
def input_indexes(self):
"""The indexes of the formulation inputs."""
network_inputs = list(self.__network_definition.input_nodes)
if len(network_inputs) != 1:
raise ValueError(MULTI_INPUTS_UNSUPPORTED)
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)
if len(network_outputs) != 1:
raise ValueError(MULTI_OUTPUTS_UNSUPPORTED)
return network_outputs[0].output_indexes
def _build_neural_network_formulation( # noqa: C901
block, network_structure, layer_constraints, activation_constraints
):
"""Adds the neural network formulation to the given Pyomo block.
Parameters
----------
block : Block
the Pyomo block
network_structure : NetworkDefinition
the neural network definition
layer_constraints : dict-like or None
the constraints generated for the specified layer types
activation_constraints : dict-like or None
the constraints generated for the specified activation functions
"""
net = network_structure
layers = list(net.layers)
block.layers = pyo.Set(initialize=[id(layer) for layer in layers], ordered=True)
# create the z and z_hat variables for each of the layers
@block.Block(block.layers)
def layer(b, layer_id):
net_layer = net.layer(layer_id)
b.z = pyo.Var(net_layer.output_indexes, initialize=0)
if isinstance(net_layer, InputLayer):
for index in net_layer.output_indexes:
input_var = block.scaled_inputs[index]
z_var = b.z[index]
z_var.setlb(input_var.lb)
z_var.setub(input_var.ub)
else:
# add zhat only to non input layers
b.zhat = pyo.Var(net_layer.output_indexes, initialize=0)
return b
for layer in layers:
if isinstance(layer, InputLayer):
continue
layer_id = id(layer)
layer_block = block.layer[layer_id]
layer_constraints_func = layer_constraints.get(type(layer), None)
if layer_constraints_func is None:
msg = f"Layer type {type(layer)} is not supported by this formulation."
raise ValueError(msg)
layer_constraints_func(block, net, layer_block, layer)
activation_constraints_func = activation_constraints.get(layer.activation, None)
if activation_constraints_func is None:
msg = f"Activation {layer.activation} is not supported by this formulation."
raise ValueError(msg)
activation_constraints_func(block, net, layer_block, layer)
# setup input variables constraints
# currently only support a single input layer
input_layers = list(net.input_layers)
if len(input_layers) != 1:
raise ValueError(MULTI_INPUTS_UNSUPPORTED)
input_layer = input_layers[0]
@block.Constraint(input_layer.output_indexes)
def input_assignment(b, *output_index):
return b.scaled_inputs[output_index] == b.layer[id(input_layer)].z[output_index]
# setup output variables constraints
# currently only support a single output layer
output_layers = list(net.output_layers)
if len(output_layers) != 1:
raise ValueError(MULTI_OUTPUTS_UNSUPPORTED)
output_layer = output_layers[0]
@block.Constraint(output_layer.output_indexes)
def output_assignment(b, *output_index):
return (
b.scaled_outputs[output_index] == b.layer[id(output_layer)].z[output_index]
)
[docs]
class FullSpaceSmoothNNFormulation(FullSpaceNNFormulation):
def __init__(self, network_structure):
"""Full Space Smooth Neural Network Formulation.
This class is used for building "full-space" formulations of
neural network models composed of smooth activations (e.g., tanh,
sigmoid, etc.)
Parameters
----------
network_structure : NetworkDefinition
the neural network definition
"""
super().__init__(network_structure)
def _supported_default_activation_constraints(self):
return {
"linear": linear_activation_constraint,
"sigmoid": sigmoid_activation_constraint,
"softplus": softplus_activation_constraint,
"tanh": tanh_activation_constraint,
}
[docs]
class ReluBigMFormulation(FullSpaceNNFormulation):
def __init__(self, network_structure):
"""Relu Big-M Formulation.
This class is used for building "full-space" formulations of
neural network models composed of relu activations using a
big-M formulation
Parameters
----------
network_structure : NetworkDefinition
the neural network definition
"""
super().__init__(network_structure)
def _supported_default_activation_constraints(self):
return {
"linear": linear_activation_constraint,
"relu": bigm_relu_activation_constraint,
}
[docs]
class ReluComplementarityFormulation(FullSpaceNNFormulation):
def __init__(self, network_structure):
"""Relu Complementarity Formulation.
This class is used for building "full-space" formulations of
neural network models composed of relu activations using
a complementarity formulation (smooth represenation)
Parameters
----------
network_structure : NetworkDefinition
the neural network definition
"""
super().__init__(network_structure)
def _supported_default_activation_constraints(self):
return {
"linear": linear_activation_constraint,
"relu": ComplementarityReLUActivation(),
}
[docs]
class ReducedSpaceNNFormulation(_PyomoFormulation):
"""Reduced Space Neural Network Formulation.
This class is used to build reduced-space formulations
of neural networks.
Parameters
----------
network_structure : NetworkDefinition
the neural network definition
activation_functions : dict-like or None
overrides the actual functions used for particular activations
"""
def __init__(self, network_structure, activation_functions=None):
super().__init__()
self.__network_definition = network_structure
self.__scaling_object = network_structure.scaling_object
self.__scaled_input_bounds = network_structure.scaled_input_bounds
self._activation_functions = dict(
self._supported_default_activation_functions()
)
if activation_functions is not None:
self._activation_functions.update(activation_functions)
network_inputs = list(self.__network_definition.input_nodes)
if len(network_inputs) != 1:
raise ValueError(MULTI_INPUTS_UNSUPPORTED)
network_outputs = list(self.__network_definition.output_nodes)
if len(network_outputs) != 1:
raise ValueError(MULTI_OUTPUTS_UNSUPPORTED)
def _supported_default_activation_functions(self):
return dict(_DEFAULT_ACTIVATION_FUNCTIONS)
def _build_formulation(self):
_setup_scaled_inputs_outputs(
self.block, self.__scaling_object, self.__scaled_input_bounds
)
net = self.__network_definition
layers = list(net.layers)
block = self.block
# create the blocks for each layer
block.layers = pyo.Set(initialize=[id(layer) for layer in layers], ordered=True)
block.layer = pyo.Block(block.layers)
# currently only support a single input layer
input_layers = list(net.input_layers)
if len(input_layers) != 1:
msg = (
"build_formulation called with a network that has more than"
" one input layer. Only single input layers are supported."
)
raise ValueError(msg)
input_layer = input_layers[0]
input_layer_id = id(input_layer)
input_layer_block = block.layer[input_layer_id]
# connect the outputs of the input layer to
# the main inputs on the block
@input_layer_block.Expression(input_layer.output_indexes)
def z(b, *output_index):
pb = b.parent_block()
return pb.scaled_inputs[output_index]
# loop over the layers and build the expressions
for layer in layers:
if isinstance(layer, InputLayer):
# skip the InputLayer
continue
if not isinstance(layer, DenseLayer):
msg = (
f"ReducedSpaceNNFormulation only supports Dense layers. {net}"
f" contains {layer} which is a {type(layer)}."
)
raise TypeError(msg)
# build the linear expressions and the activation function
layer_id = id(layer)
layer_block = block.layer[layer_id]
layer_func = reduced_space_dense_layer # layer_constraints[type(layer)]
activation_func = self._activation_functions.get(layer.activation, None)
if activation_func is None:
msg = (
f"Activation {layer.activation} is not supported by this"
" formulation."
)
raise ValueError(msg)
layer_func(block, net, layer_block, layer, activation_func)
# setup output variable constraints
# currently only support a single output layer
output_layers = list(net.output_layers)
if len(output_layers) != 1:
msg = (
"build_formulation called with a network that has more than"
" one output layer. Only single output layers are supported."
)
raise ValueError(msg)
output_layer = output_layers[0]
@block.Constraint(output_layer.output_indexes)
def output_assignment(b, *output_index):
b.parent_block()
return (
b.scaled_outputs[output_index]
== b.layer[id(output_layer)].z[output_index]
)
@property
def input_indexes(self):
"""The indexes of the formulation inputs."""
network_inputs = list(self.__network_definition.input_nodes)
if len(network_inputs) != 1:
raise ValueError(MULTI_INPUTS_UNSUPPORTED)
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)
if len(network_outputs) != 1:
raise ValueError(MULTI_OUTPUTS_UNSUPPORTED)
return network_outputs[0].output_indexes
[docs]
class ReducedSpaceSmoothNNFormulation(ReducedSpaceNNFormulation):
"""Reduced Space Smooth Neural Network Formulation.
This class is used to build reduced-space formulations
of neural networks with smooth activation functions.
Parameters
----------
network_structure : NetworkDefinition
the neural network definition
"""
def __init__(self, network_structure):
super().__init__(network_structure)
def _supported_default_activation_functions(self):
return {
"linear": linear_activation_function,
"sigmoid": sigmoid_activation_function,
"softplus": softplus_activation_function,
"tanh": tanh_activation_function,
}
[docs]
class ReluPartitionFormulation(_PyomoFormulation):
"""ReLU Partition Formulation.
This class is used to build partition-based formulations
of neural networks.
Parameters
----------
network_structure : NetworkDefinition
the neural network definition
split_func : callable
the function used to compute the splits
"""
def __init__(self, network_structure, split_func=None):
super().__init__()
self.__network_definition = network_structure
self.__scaling_object = network_structure.scaling_object
self.__scaled_input_bounds = network_structure.scaled_input_bounds
if split_func is None:
split_func = partial(default_partition_split_func, n=2)
self.__split_func = split_func
def _build_formulation(self): # noqa: C901
_setup_scaled_inputs_outputs(
self.block, self.__scaling_object, self.__scaled_input_bounds
)
block = self.block
net = self.__network_definition
layers = list(net.layers)
split_func = self.__split_func
block.layers = pyo.Set(initialize=[id(layer) for layer in layers], ordered=True)
# create the z and z_hat variables for each of the layers
@block.Block(block.layers)
def layer(b, layer_id):
net_layer = net.layer(layer_id)
b.z = pyo.Var(net_layer.output_indexes, initialize=0)
if isinstance(net_layer, InputLayer):
for index in net_layer.output_indexes:
input_var = block.scaled_inputs[index]
z_var = b.z[index]
z_var.setlb(input_var.lb)
z_var.setub(input_var.ub)
else:
# add zhat only to non input layers
b.zhat = pyo.Var(net_layer.output_indexes, initialize=0)
return b
for layer in layers:
if isinstance(layer, InputLayer):
continue
layer_id = id(layer)
layer_block = block.layer[layer_id]
if isinstance(layer, DenseLayer):
if layer.activation == "relu":
partition_based_dense_relu_layer(
block, net, layer_block, layer, split_func
)
elif layer.activation == "linear":
full_space_dense_layer(block, net, layer_block, layer)
linear_activation_constraint(block, net, layer_block, layer)
else:
msg = (
"ReluPartitionFormulation supports Dense layers with relu or"
" linear activation"
)
raise ValueError(msg)
else:
msg = "ReluPartitionFormulation supports only Dense layers"
raise TypeError(msg)
# 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)
if len(input_layers) != 1:
raise ValueError(MULTI_INPUTS_UNSUPPORTED)
input_layer = input_layers[0]
@block.Constraint(input_layer.output_indexes)
def input_assignment(b, *output_index):
return (
b.scaled_inputs[output_index]
== b.layer[id(input_layer)].z[output_index]
)
# setup output variables constraints
# currently only support a single output layer
output_layers = list(net.output_layers)
if len(output_layers) != 1:
raise ValueError(MULTI_OUTPUTS_UNSUPPORTED)
output_layer = output_layers[0]
@block.Constraint(output_layer.output_indexes)
def output_assignment(b, *output_index):
return (
b.scaled_outputs[output_index]
== b.layer[id(output_layer)].z[output_index]
)
@property
def input_indexes(self):
"""The indexes of the formulation inputs."""
network_inputs = list(self.__network_definition.input_nodes)
if len(network_inputs) != 1:
raise ValueError(MULTI_INPUTS_UNSUPPORTED)
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)
if len(network_outputs) != 1:
raise ValueError(MULTI_OUTPUTS_UNSUPPORTED)
return network_outputs[0].output_indexes