Source code for omlt.linear_tree.lt_formulation
import numpy as np
import pyomo.environ as pe
from pyomo.gdp import Disjunct
from omlt.formulation import _PyomoFormulation, _setup_scaled_inputs_outputs
[docs]class LinearTreeGDPFormulation(_PyomoFormulation):
r"""
Class to add a Linear Tree GDP formulation to OmltBlock. We use Pyomo.GDP
to create the disjuncts and disjunctions and then apply a transformation
to convert to a mixed-integer programming representation.
.. math::
\begin{align*}
& \underset{\ell \in L}{\bigvee} \left[ \begin{gathered}
Z_{\ell} \\
\underline{x}_{\ell} \leq x \leq \overline{x}_{\ell} \\
d = a_{\ell}^T x + b_{\ell} \end{gathered} \right] \\
& \texttt{exactly_one} \{ Z_{\ell} : \ell \in L \} \\
& x^L \leq x \leq x^U \\
& x \in \mathbb{R}^n \\
& Z_{\ell} \in \{ \texttt{True, False} \} \quad \forall \ \ell \in L
\end{align*}
Additional nomenclature for this formulation is as follows:
.. math::
\begin{align*}
Z_{\ell} &:= \text{Boolean variable indicating which leaf is selected} \\
\overline{x}_{\ell} &:= \text{Vector of upper bounds for leaf } \ell \in L \\
\underline{x}_{\ell} &:= \text{Vector of lower bounds for leaf } \ell \in L \\
x^U &:= \text{Vector of global upper bounds} \\
x^L &:= \text{Vector of global lower bounds} \\
\end{align*}
Attributes:
Inherited from _PyomoFormulation Class
model_definition : LinearTreeDefinition object
transformation : choose which transformation to apply. The supported
transformations are bigm, mbigm, hull, and custom.
References:
* Ammari et al. (2023) Linear Model Decision Trees as Surrogates in
Optimization of Engineering Applications. Computers & Chemical Engineering
* Chen et al. (2022) Pyomo.GDP: An ecosystem for logic based modeling and
optimization development. Optimization and Engineering, 23:607–642
"""
def __init__(self, lt_definition, transformation="bigm"):
"""
Create a LinearTreeGDPFormulation object
Arguments:
lt_definition -- LinearTreeDefintion Object
Keyword Arguments:
transformation -- choose which Pyomo.GDP formulation to apply.
Supported transformations are bigm, hull, mbigm, and custom
(default: {'bigm'})
Raises:
Exception: If transformation not in supported transformations
"""
super().__init__()
self.model_definition = lt_definition
self.transformation = transformation
# Ensure that the GDP transformation given is supported
supported_transformations = ["bigm", "hull", "mbigm", "custom"]
if transformation not in supported_transformations:
raise NotImplementedError(
"Supported transformations are: bigm, mbigm, hull, and custom"
)
@property
def input_indexes(self):
"""The indexes of the formulation inputs."""
return list(range(self.model_definition.n_inputs))
@property
def output_indexes(self):
"""The indexes of the formulation output."""
return list(range(self.model_definition.n_outputs))
def _build_formulation(self):
"""This method is called by the OmltBlock to build the corresponding
mathematical formulation on the Pyomo block.
"""
_setup_scaled_inputs_outputs(
self.block,
self.model_definition.scaling_object,
self.model_definition.scaled_input_bounds,
)
_add_gdp_formulation_to_block(
block=self.block,
model_definition=self.model_definition,
input_vars=self.block.scaled_inputs,
output_vars=self.block.scaled_outputs,
transformation=self.transformation,
)
[docs]class LinearTreeHybridBigMFormulation(_PyomoFormulation):
r"""
Class to add a Linear Tree Hybrid Big-M formulation to OmltBlock.
.. math::
\begin{align*}
& d = \sum_{\ell \in L} (a_{\ell}^T x + b_{\ell})z_{\ell} \\
& x_i \leq \sum_{\ell \in L} \overline{x}_{i,\ell} z_{\ell} &&
\forall i \in [n] \\
& x_i \geq \sum_{\ell \in L} \underline{x}_{i,\ell} z_{\ell} &&
\forall i \in [n] \\
& \sum_{\ell \in L} z_{\ell} = 1
\end{align*}
Where the following additional nomenclature is defined:
.. math::
\begin{align*}
[n] &:= \text{the integer set of variables that the tree splits on
(e.g. [n] = {1, 2, ... , n})} \\
\overline{x}_{\ell} &:= \text{Vector of upper bounds for leaf } \ell \in L \\
\underline{x}_{\ell} &:= \text{Vector of lower bounds for leaf } \ell \in L \\
\end{align*}
Attributes:
Inherited from _PyomoFormulation Class
model_definition : LinearTreeDefinition object
"""
def __init__(self, lt_definition):
"""
Create a LinearTreeHybridBigMFormulation object
Arguments:
lt_definition -- LinearTreeDefinition Object
"""
super().__init__()
self.model_definition = lt_definition
@property
def input_indexes(self):
"""The indexes of the formulation inputs."""
return list(range(self.model_definition.n_inputs))
@property
def output_indexes(self):
"""The indexes of the formulation output."""
return list(range(self.model_definition.n_outputs))
def _build_formulation(self):
"""This method is called by the OmltBlock to build the corresponding
mathematical formulation on the Pyomo block.
"""
_setup_scaled_inputs_outputs(
self.block,
self.model_definition.scaling_object,
self.model_definition.scaled_input_bounds,
)
_add_hybrid_formulation_to_block(
block=self.block,
model_definition=self.model_definition,
input_vars=self.block.scaled_inputs,
output_vars=self.block.scaled_outputs,
)
def _build_output_bounds(model_def, input_bounds):
"""
This helper function develops bounds of the output variable based on the
values of the input_bounds and the signs of the slope
Arguments:
model_def -- Model definition
input_bounds -- Dict of input bounds
Returns:
List that contains the conservative lower and upper bounds of the
output variable
"""
leaves = model_def.leaves
n_inputs = model_def.n_inputs
tree_ids = np.array(list(leaves.keys()))
features = np.arange(0, n_inputs)
# Initialize bounds and variables
bounds = [0, 0]
upper_bound = 0
lower_bound = 0
for tree in tree_ids:
for leaf in leaves[tree]:
slopes = leaves[tree][leaf]["slope"]
intercept = leaves[tree][leaf]["intercept"]
for k in features:
if slopes[k] <= 0:
upper_bound += slopes[k] * input_bounds[k][0] + intercept
lower_bound += slopes[k] * input_bounds[k][1] + intercept
else:
upper_bound += slopes[k] * input_bounds[k][1] + intercept
lower_bound += slopes[k] * input_bounds[k][0] + intercept
if upper_bound >= bounds[1]:
bounds[1] = upper_bound
if lower_bound <= bounds[0]:
bounds[0] = lower_bound
upper_bound = 0
lower_bound = 0
return bounds
def _add_gdp_formulation_to_block(
block, model_definition, input_vars, output_vars, transformation
):
"""
This function adds the GDP representation to the OmltBlock using Pyomo.GDP
Arguments:
block -- OmltBlock
model_definition -- LinearTreeDefinition Object
input_vars -- input variables to the linear tree model
output_vars -- output variable of the linear tree model
transformation -- Transformation to apply
"""
leaves = model_definition.leaves
input_bounds = model_definition.scaled_input_bounds
n_inputs = model_definition.n_inputs
# The set of leaves and the set of features
tree_ids = list(leaves.keys())
t_l = []
for tree in tree_ids:
for leaf in leaves[tree].keys():
t_l.append((tree, leaf))
features = np.arange(0, n_inputs)
# Use the input_bounds and the linear models in the leaves to calculate
# the lower and upper bounds on the output variable. Required for Pyomo.GDP
output_bounds = _build_output_bounds(model_definition, input_bounds)
# Ouptuts are automatically scaled based on whether inputs are scaled
block.outputs.setub(output_bounds[1])
block.outputs.setlb(output_bounds[0])
block.scaled_outputs.setub(output_bounds[1])
block.scaled_outputs.setlb(output_bounds[0])
block.intermediate_output = pe.Var(
tree_ids, bounds=(output_bounds[0], output_bounds[1])
)
# Create a disjunct for each leaf containing the bound constraints
# and the linear model expression.
def disjuncts_rule(dsj, tree, leaf):
def lb_rule(dsj, feat):
return input_vars[feat] >= leaves[tree][leaf]["bounds"][feat][0]
dsj.lb_constraint = pe.Constraint(features, rule=lb_rule)
def ub_rule(dsj, feat):
return input_vars[feat] <= leaves[tree][leaf]["bounds"][feat][1]
dsj.ub_constraint = pe.Constraint(features, rule=ub_rule)
slope = leaves[tree][leaf]["slope"]
intercept = leaves[tree][leaf]["intercept"]
dsj.linear_exp = pe.Constraint(
expr=sum(slope[k] * input_vars[k] for k in features) + intercept
== block.intermediate_output[tree]
)
block.disjunct = Disjunct(t_l, rule=disjuncts_rule)
@block.Disjunction(tree_ids)
def disjunction_rule(b, tree):
leaf_ids = list(leaves[tree].keys())
return [block.disjunct[tree, leaf] for leaf in leaf_ids]
block.total_output = pe.Constraint(
expr=output_vars[0] == sum(block.intermediate_output[tree] for tree in tree_ids)
)
transformation_string = "gdp." + transformation
if transformation != "custom":
pe.TransformationFactory(transformation_string).apply_to(block)
def _add_hybrid_formulation_to_block(block, model_definition, input_vars, output_vars):
"""
This function adds the Hybrid BigM representation to the OmltBlock
Arguments:
block -- OmltBlock
model_definition -- LinearTreeDefinition Object
input_vars -- input variables to the linear tree model
output_vars -- output variable of the linear tree model
"""
leaves = model_definition.leaves
input_bounds = model_definition.scaled_input_bounds
n_inputs = model_definition.n_inputs
# The set of trees
tree_ids = list(leaves.keys())
# Create a list of tuples that contains the tree and leaf indices. Note that
# the leaf indices depend on the tree in the ensemble.
t_l = []
for tree in tree_ids:
for leaf in leaves[tree].keys():
t_l.append((tree, leaf))
features = np.arange(0, n_inputs)
# Use the input_bounds and the linear models in the leaves to calculate
# the lower and upper bounds on the output variable. Required for Pyomo.GDP
output_bounds = _build_output_bounds(model_definition, input_bounds)
# Ouptuts are automatically scaled based on whether inputs are scaled
block.outputs.setub(output_bounds[1])
block.outputs.setlb(output_bounds[0])
block.scaled_outputs.setub(output_bounds[1])
block.scaled_outputs.setlb(output_bounds[0])
# Create the intermeditate variables. z is binary that indicates which leaf
# in tree t is returned. intermediate_output is the output of tree t and
# the total output of the model is the sum of the intermediate_output vars
block.z = pe.Var(t_l, within=pe.Binary)
block.intermediate_output = pe.Var(tree_ids)
@block.Constraint(features, tree_ids)
def lower_bound_constraints(mdl, feat, tree):
leaf_ids = list(leaves[tree].keys())
return (
sum(
leaves[tree][leaf]["bounds"][feat][0] * mdl.z[tree, leaf]
for leaf in leaf_ids
)
<= input_vars[feat]
)
@block.Constraint(features, tree_ids)
def upper_bound_constraints(mdl, feat, tree):
leaf_ids = list(leaves[tree].keys())
return (
sum(
leaves[tree][leaf]["bounds"][feat][1] * mdl.z[tree, leaf]
for leaf in leaf_ids
)
>= input_vars[feat]
)
@block.Constraint(tree_ids)
def linear_constraint(mdl, tree):
leaf_ids = list(leaves[tree].keys())
return block.intermediate_output[tree] == sum(
(
sum(
leaves[tree][leaf]["slope"][feat] * input_vars[feat]
for feat in features
)
+ leaves[tree][leaf]["intercept"]
)
* block.z[tree, leaf]
for leaf in leaf_ids
)
@block.Constraint(tree_ids)
def only_one_leaf_per_tree(b, tree):
leaf_ids = list(leaves[tree].keys())
return sum(block.z[tree, leaf] for leaf in leaf_ids) == 1
@block.Constraint()
def output_sum_of_trees(b):
return output_vars[0] == sum(
block.intermediate_output[tree] for tree in tree_ids
)