Skip to content
Open
200 changes: 188 additions & 12 deletions pyomo/core/plugins/transform/add_slack_vars.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
# software. This software is distributed under the 3-clause BSD License.
# ____________________________________________________________________________________

from collections import defaultdict
from operator import attrgetter

from pyomo.core import (
TransformationFactory,
Var,
Expand All @@ -15,14 +18,30 @@
Objective,
Block,
value,
Expression,
Param,
Suffix,
)

from pyomo.common.autoslots import AutoSlots
from pyomo.common.collections import ComponentMap
from pyomo.common.modeling import unique_component_name
from pyomo.core.plugins.transform.hierarchy import NonIsomorphicTransformation
from pyomo.common.config import ConfigBlock, ConfigValue
from pyomo.core.base import ComponentUID
from pyomo.core.base import ComponentUID, SortComponents
from pyomo.common.deprecation import deprecation_warning

from pyomo.repn.util import categorize_valid_components

### FIXME: Remove the following as soon as non-active components no
### longer report active==True
from pyomo.network import Port
from pyomo.core.base import RangeSet, Set

import logging

logger = logging.getLogger('pyomo.core')


def target_list(x):
deprecation_msg = (
Expand Down Expand Up @@ -64,9 +83,16 @@ def target_list(x):
)


import logging
class _AddSlackVariablesData(AutoSlots.Mixin):
__slots__ = ('slack_variables', 'relaxed_constraint', 'summed_slacks_expr')

def __init__(self):
self.slack_variables = defaultdict(list)
self.relaxed_constraint = ComponentMap()
self.summed_slacks_expr = None

logger = logging.getLogger('pyomo.core')

Block.register_private_data_initializer(_AddSlackVariablesData)


@TransformationFactory.register(
Expand All @@ -90,6 +116,22 @@ class AddSlackVariables(NonIsomorphicTransformation):
doc="This specifies the list of Constraints to add slack variables to.",
),
)
CONFIG.declare(
'add_slack_objective',
ConfigValue(
default=True,
domain=bool,
description="Whether or not to change the model objective to minimizing "
"the added slack variables.",
doc="""
Whether or not to change the problem objective to minimize the added slack
variables. If True (the default), the original objective is deactivated
and the transformation adds an objective to minimize the sum of the added
(non-negative) slack variables. If False, the transformation does not
change the model objective.
""",
),
)

def __init__(self, **kwds):
kwds['name'] = "add_slack_vars"
Expand All @@ -103,10 +145,11 @@ def _apply_to_impl(self, instance, **kwds):
config.set_value(kwds)
targets = config.targets

trans_info = instance.private_data()

if targets is None:
constraintDatas = instance.component_data_objects(
Constraint, descend_into=True
)
constraintDatas = self._get_all_constraint_datas(instance)

else:
constraintDatas = []
for t in targets:
Expand All @@ -126,10 +169,6 @@ def _apply_to_impl(self, instance, **kwds):
else:
constraintDatas.append(t)

# deactivate the objective
for o in instance.component_data_objects(Objective):
o.deactivate()

# create block where we can add slack variables safely
xblockname = unique_component_name(instance, "_core_add_slack_variables")
instance.add_component(xblockname, Block())
Expand Down Expand Up @@ -161,6 +200,8 @@ def _apply_to_impl(self, instance, **kwds):
body += posSlack
# penalize slack in objective
obj_expr += posSlack
trans_info.slack_variables[cons].append(posSlack)
trans_info.relaxed_constraint[posSlack] = cons
Comment on lines +203 to +204
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Philosophical question: now that we are providing a useful API for mapping slacks to constraints and back, does it make sense to avoid all the name munging and just make a single indexed xblock.slacks = Var(NonNegativeIntegers, bounds=(0, None))?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maaaaybe. Though the way it is now, pprint is enough for debugging... I am certainly guilty of this method.

if upper is not None:
# we subtract a positive slack variable from the body:
# declare slack
Expand All @@ -171,6 +212,141 @@ def _apply_to_impl(self, instance, **kwds):
body -= negSlack
# add slack to objective
obj_expr += negSlack
trans_info.slack_variables[cons].append(negSlack)
trans_info.relaxed_constraint[negSlack] = cons

cons.set_value((lower, body, upper))
# make a new objective that minimizes sum of slack variables
xblock._slack_objective = Objective(expr=obj_expr)

trans_info.summed_slacks_expr = obj_expr
if config.add_slack_objective:
# deactivate the objective
for o in instance.component_data_objects(Objective):
o.deactivate()

# make a new objective that minimizes sum of slack variables
xblock._slack_objective = Objective(expr=obj_expr)
Comment thread
jsiirola marked this conversation as resolved.

def _get_all_constraint_datas(self, model):
components, unknown = categorize_valid_components(
model,
active=True,
sort=SortComponents.deterministic,
valid={
Block,
Expression,
Var,
Param,
Suffix,
Objective,
# FIXME: Non-active components should not report as Active
Set,
RangeSet,
Port,
},
targets={Constraint},
)
if unknown:
raise ValueError(
"The model ('%s') contains the following active components "
"that the 'core.add_slack_variables' transformation does not "
"know how to process:\n\t%s\nIf these components are Block-like "
"(e.g., Disjuncts) and the intent is to add slacks on them, call "
"the transformation on them directly."
% (
model.name,
"\n\t".join(
sorted(
"%s:\n\t\t%s"
% (k, "\n\t\t".join(sorted(map(attrgetter('name'), v))))
for k, v in unknown.items()
)
),
)
)
if components[Constraint]:
for block in components[Constraint]:
for cons in block.component_data_objects(
Constraint,
active=True,
descend_into=False,
sort=SortComponents.deterministic,
):
yield cons

def get_slack_variables(self, model, constraint):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you need to pass the model as an argument? Why not get the model from the constraint object directly, constraint.model()?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

model is probably a misnomer. It is really the "root block" that was passed to the transformation, which may or may not be the the model() (which is where the transformation hides the mapping between constraints and slack variables.

@emma58, question: would it make sense to put a private_data on every block holding a transformation target that has a pointer to the "root block" from the transformation that holds the mappings? If we do that, then we wouldn't need the model attribute (as @blnicho was suggesting).

"""Return the list of slack variables used to relax 'constraint.' Note
that if 'constraint' is one-sided, there will be a single variable in
the list, but if it is a ranged constraint (l <= expr <= u) or an
equality, there will be two variables.

Returns
-------
List of slack variables

Parameters
----------
model: ConcreteModel
A model, having had the 'core.add_slack_variables' transformation
applied to it
constraint: Constraint
A constraint that was relaxed by the transformation (either
because no targets were specified or because it was a target)
"""
slack_variables = model.private_data().slack_variables
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you get a meaningful error if get_slack_variables is called on a model that was not transformed by this Transformation?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow, significantly more meaningful than I was expecting, actually:

>>> from pyomo.environ import *
>>> m = ConcreteModel()
>>> m.x = Var()
>>> m.y = Var()
>>> m.c = Constraint(expr=(3, m.x + m.y, 4))
>>> m.obj = Objective(expr= m.x - m.y)
>>> TransformationFactory('core.add_slack_variables').get_slack_variables(m, m.c)
Traceback (most recent call last):
  File "<python-input-6>", line 1, in <module>
    TransformationFactory('core.add_slack_variables').get_slack_variables(m, m.c)
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^
  File "/Users/esjohn/src/pyomo/pyomo/core/plugins/transform/add_slack_vars.py", line 240, in get_slack_variables
    raise ValueError(
    ...<3 lines>...
    )
ValueError: It does not appear that c is a constraint on model unknown that was relaxed by the 'core.add_slack_variables' transformation.

We were having a very smart day when we implemented the private data Blocks... This is working because, in the scope of that file, slack_variables gets created when we get the private_data, so then it's just empty and we get a good error.

if constraint in slack_variables:
return slack_variables[constraint]
else:
raise ValueError(
f"It does not appear that {constraint.name} is a constraint "
f"on model {model.name} that was relaxed by the "
f"'core.add_slack_variables' transformation."
)

def get_relaxed_constraint(self, model, slack_var):
"""Return the constraint that 'slack_var' is used to relax.

Returns
-------
Constraint

Parameters
-----------
model: ConcreteModel
A model, having had the 'core.add_slack_variables' transformation
applied to it
slack_var: Var
A variable created by the 'core.add_slack_variables' transformation to
relax a constraint.
"""
relaxed_constraints = model.private_data().relaxed_constraint
if slack_var in relaxed_constraints:
return relaxed_constraints[slack_var]
else:
raise ValueError(
f"It does not appear that {slack_var.name} is a slack variable "
f"created by applying the 'core.add_slack_variables' transformation "
f"to model {model.name}."
)

def get_summed_slacks_expr(self, model):
"""Return an expression summing all the slacks added to the model during the
transformation. This would most commonly be used to add a penalty on non-zero
slacks to an existing objective.

Returns
-------
Expression

Parameters
----------
model: ConcreteModel
A model, having had the 'core.add_slack_variables' transformation
applied to it
"""
expr = model.private_data().summed_slacks_expr
if expr is None:
raise ValueError(
f"It does not appear that {model.name} is a model that was transformed "
f"by the 'core.add_slack_variables' transformation."
)
return expr
Loading
Loading