Use logical constraints with decision optimization

This tutorial includes everything you need to set up decision optimization engines, build a mathematical programming model, leveraging logical constraints.

When you finish this tutorial, you'll have a foundational knowledge of Prescriptive Analytics.

This notebook is part of Prescriptive Analytics for Python

It requires either an installation of CPLEX Optimizers or it can be run on IBM Watson Studio Cloud (Sign up for a free IBM Cloud account and you can start using Watson Studio Cloud right away).

Table of contents:


Logical constraints let you use the truth value of constraints inside the model. The truth value of a constraint is a binary variable equal to 1 when the constraint is satisfied, and equal to 0 when not. Adding a constraint to a model ensures that it is always satisfied. With logical constraints, one can use the truth value of a constraint inside the model, allowing to choose dynamically whether a constraint is to be satisfied (or not).

How decision optimization can help

  • Prescriptive analytics (decision optimization) technology recommends actions that are based on desired outcomes. It takes into account specific scenarios, resources, and knowledge of past and current events. With this insight, your organization can make better decisions and have greater control of business outcomes.

  • Prescriptive analytics is the next step on the path to insight-based actions. It creates value through synergy with predictive analytics, which analyzes data to predict future outcomes.

  • Prescriptive analytics takes that insight to the next level by suggesting the optimal way to handle that future situation. Organizations that can act fast in dynamic conditions and make superior decisions in uncertain environments gain a strong competitive advantage.

With prescriptive analytics, you can:

  • Automate the complex decisions and trade-offs to better manage your limited resources.
  • Take advantage of a future opportunity or mitigate a future risk.
  • Proactively update recommendations based on changing events.
  • Meet operational goals, increase customer loyalty, prevent threats and fraud, and optimize business processes.

Use decision optimization

Step 1: Import the library

Run the following code to import Decision Optimization CPLEX Modeling library. The DOcplex library contains the two modeling packages, Mathematical Programming and Constraint Programming, referred to earlier.


In [ ]:
import sys
try:
    import docplex.mp
except:
    raise Exception('Please install docplex. See https://pypi.org/project/docplex/')

A restart of the kernel might be needed.

Step 2: Learn about constraint truth values

Any discrete linear constraint can be associated to a binary variable that holds the truth value of the constraint. But first, let's explain what a discrete constraint is

Discrete linear constraint

A discrete linear constraint is built from discrete coefficients and discrete variables, that is variables with type integer or binary.

For example, assuming x and y are integer variables:

  • 2x+3y == 1 is discrete
  • x+y = 3.14 is not (because of 3.14)
  • 1.1 x + 2.2 y <= 3 is not because of the non-integer coefficients 1.1 and 2.2

The truth value of an added constraint is always 1

The truth value of a linear constraint is accessed by the status_var property. This property returns a binary which can be used anywhere a variable can. However, the value of the truth value variable and the constraint are linked, both ways:

  • a constraint is satisfied if and only if its truth value variable equals 1
  • a constraint is not satisfied if and only if its truth value variable equals 0.

In the following small model, we show that the truth value of a constraint which has been added to a model is always equal to 1.


In [ ]:
from docplex.mp.model import Model

m1 = Model()
x = m1.integer_var(name='ix')
y = m1.integer_var(name='iy')
ct = m1.add(x + y <= 3)
# acces the truth value of a linear constraint
ct_truth = ct.status_var
m1.maximize(x+y)
assert m1.solve()
print('the truth value of [{0!s}] is {1}'.format(ct, ct_truth.solution_value))

The truth value of a constraint not added to a model is free

A constraint that is not added to a model, has no effect. Its truth value is free: it can be either 1 or 0.

In the following example, both x and y are set to their upper bound, so that the constraint is not satisfied; hence the truth value is 0.


In [ ]:
m2 = Model(name='logical2')
x = m2.integer_var(name='ix', ub=4)
y = m2.integer_var(name='iy', ub=4)
ct = (x + y <= 3)
ct_truth = ct.status_var  # not m2.add() here!
m2.maximize(x+y)
assert m2.solve()
m2.print_solution()
print('the truth value of [{0!s}] is {1}'.format(ct, ct_truth.solution_value))

Using constraint truth values in modeling

We have learned about the truth value variable of linear constraints, but there's more. Linear constraints can be freely used in expressions: Docplex will then substitute the constraint's truth value variable in the expression.

Let's experiment again with a toy model: in this model, we want to express that when x ==3 is false, then y ==4 must also be false. To express this, it suffices to say that the truth value of y == 4 is less than or equal to the truth value of x ==3. When x==3 is false, is truthe value is 0, hence the truth value of y==4 is also zero, and y cannot be equal to 4.

However, as shown in the model below, it is not necessary to use the status_var propert: using the constraints in a comparison expression works fine.

As we maximize y, y has value 4 in the optimal solution (it is the upper bound), and consequently the constraint ct_y4 is satisfied. From the inequality between truth values, it follows that the truth value of ct_x2 equals 1 and x is equal to 2.

Using the constraints in the inequality has silently converted each constraint into its truth value.


In [ ]:
m3 = Model(name='logical3')
x = m3.integer_var(name='ix', ub=4)
y = m3.integer_var(name='iy', ub=4)
ct_x2 = (x == 2)
ct_y4 = (y == 4)
# use constraints in comparison
m3.add( ct_y4 <= ct_x2 )
m3.maximize(y)
assert m3.solve()
# expected solution x==2, and y==4.
m3.print_solution()

Constraint truth values can be used with arithmetic operators, just as variables can. In the next model, we express a (slightly) more complex constraint:

  • either x is equal to 3, or both y and z are equal to 5

Let's see how we can express this easily with truth values:


In [ ]:
m31 = Model(name='logical31')
x = m31.integer_var(name='ix', ub=4)
y = m31.integer_var(name='iy', ub=10)
z = m31.integer_var(name='iz', ub=10)
ct_x2 = (x == 3)
ct_y5 = (y == 5)
ct_z5 = (z == 5)
#either ct_x2 is true or -both- ct_y5 and ct_z5 must be true
m31.add( 2 * ct_x2 + (ct_y5 + ct_z5) == 2)
# force x to be less than 2: it cannot be equal to 3!
m31.add(x <= 2)
# maximize sum of x,y,z
m31.maximize(x+y+z)
assert m31.solve()
# the expected solution is: x=2, y=5, z=5
assert m31.objective_value == 12
m31.print_solution()

As we have seen, constraints can be used in expressions. This includes the Model.sum() and Model.dot() aggregation methods.

In the next model, we define ten variables, one of which must be equal to 3 (we dpn't care which one, for now). As we maximize the sum of all xs variables, all will end up equal to their upper bound, except for one.


In [ ]:
m4 = Model(name='logical4')
xs = m4.integer_var_list(10, ub=100)
cts = [xi==3 for xi in xs]
m4.add( m4.sum(cts) == 1)
m4.maximize(m4.sum(xs))
assert m4.solve()
m4.print_solution()

As we can see, all variables but one are set to their upper bound of 100. We cannot predict which variable will be set to 3. However, let's imagine that we prefer variable with a lower index to be set to 3, how can we express this preference?

The answer is to use an additional expression to the objective, using a scalar product of constraint truth value


In [ ]:
preference = m4.dot(cts, (k+1 for k in range(len(xs))))
# we prefer lower indices for satisfying the x==3 constraint
# so the final objective is a maximize of sum of xs -minus- the preference
m4.maximize(m4.sum(xs) - preference)
assert m4.solve()
m4.print_solution()

As expected, the x variable set to 3 now is the first one.

Using truth values to negate a constraint

Truth values can be used to negate a complex constraint, by forcing its truth value to be equal to 0.

In the next model, we illustrate how an equality constraint can be negated by forcing its truth value to zero. This negation forbids y to be equal to 4, as it would be without this negation. Finally, the objective is 7 instead of 8.


In [ ]:
m5 = Model(name='logical5')
x = m5.integer_var(name='ix', ub=4)
y = m5.integer_var(name='iy', ub=4)
# this is the equality constraint we want to negate
ct_xy7 = (y + x >= 7)
# forcing truth value to zero means the constraint is not satisfied.
# note how we use a constraint in an expression
negation = m5.add( ct_xy7 == 0)
# maximize x+y should yield both variables to 4, but x+y cannot be greater than 7
m5.maximize(x + y)
assert m5.solve()
m5.print_solution()
# expecting 6 as objective, not 8
assert m5.objective_value == 6

# now remove the negation
m5.remove_constraint(negation)
# and solve again
assert m5.solve()
# the objective is 8 as expected: both x and y are equal to 4
assert m5.objective_value == 8
m5.print_solution()

Summary

We have seen that linear constraints have an associated binary variable, its truth value, whose value is linked to whether or not the constraint is satisfied.

second, linear constraints can be freely mixed with variables in expression to express meta-constraints that is, constraints about constraints. As an example, we have shown how to use truth values to negate constraints.

Note: the != (not_equals) operator

Since version 2.9, Docplex provides a 'not_equal' operator, between discrete expressions. Of course, this is implemented using truth values, but the operator provides a convenient way to express this constraint.


In [ ]:
m6 = Model(name='logical6')
x = m6.integer_var(name='ix', ub=4)
y = m6.integer_var(name='iy', ub=4)
# this is the equality constraint we want to negate
m6.add(x +1 <= y)
m6.add(x != 3)
m6.add(y != 4)
# forcing truth value to zero means the constraint is not satisfied.
# note how we use a constraint in an expression
m6.add(x+y <= 7)
# maximize x+y should yield both variables to 4, 
# but here: x < y, y cannot be 4 thus x cannot be 3 either so we get x=2, y=3
m6.maximize(x + y)
assert m6.solve()
m6.print_solution()
# expecting 5 as objective, not 8
assert m6.objective_value == 5

Step 3: Learn about equivalence constraints

As we have seen, using a constraint in expressions automtically generates a truth value variable, whose value is linked to the status of the constraint.

However, in some cases, it can be useful to relate the status of a constraint to an existing binary variable. This is the purpose of equivalence constraints.

An equivalence constraint relates an existing binary variable to the status of a discrete linear constraints, in both directions. The syntax is:

`Model.add_equivalence(bvar, linear_ct, active_value, name)`

  • bvar is the existing binary variable
  • linear-ct is a discrete linear constraint
  • active_value can take values 1 or 0 (the default is 1)
  • name is an optional string to name the equivalence.

If the binary variable bvar equals 1, then the constraint is satisfied. Conversely, if the constraint is satisfied, the binary variable is set to 1.


In [ ]:
m7 = Model(name='logical7')
size = 7
il = m7.integer_var_list(size, name='i', ub=10)
jl = m7.integer_var_list(size, name='j', ub=10)
bl = m7.binary_var_list(size, name='b')
for k in range(size):
    # for each i, relate bl_k to il_k==5 *and* jl_k == 7
    m7.add_equivalence(bl[k], il[k] == 5)
    m7.add_equivalence(bl[k], jl[k] == 7)
# now maximize sum of bs
m7.maximize(m7.sum(bl))
assert m7.solve()
m7.print_solution()

Step 4: Learn about indicator constraints

The equivalence constraint decsribed in the previous section links the value of an existing binary variable to the satisfaction of a linear constraint. In certain cases, it is sufficient to link from an existing binary variable to the constraint, but not the other way. This is what indicator constraints do.

The syntax is very similar to equivalence:

`Model.add_indicator(bvar, linear_ct, active_value=1, name=None)`

  • bvar is the existing binary variable
  • linear-ct is a discrete linear constraint
  • active_value can take values 1 or 0 (the default is 1)
  • name is an optional string to name the indicator.

    The indicator constraint works as follows: if the binary variable is set to 1, the constraint is satified; if the binary variable is set to 0, anything can happen.

    One noteworty difference between indicators and equivalences is that, for indicators, the linear constraint need not be discrete.

In the following small model, we first solve without the indicator: both b and x are set to their upper bound, and the final objective is 200.

Then we add an indicator sttaing that when b equals1, then x must be less than 3.14; the resulting objective is 103.14, as b is set to 1, which trigger the x <= 31.4 constraint.

Note that the right-hand side constraint is not discrete (because of 3.14).


In [ ]:
m8 = Model(name='logical8')
x = m8.continuous_var(name='x', ub=100)
b = m8.binary_var(name='b')

m8.maximize(100*b +x)
assert m8.solve()
assert m8.objective_value == 200
m8.print_solution()
ind_pi = m8.add_indicator(b, x <= 3.14)
assert m8.solve()
assert m8.objective_value <= 104
m8.print_solution()

Step 5: Learn about if-then

In this section we explore the Model.add_if_then construct which links the truth value of two constraints: Model.add_if_then(if_ct, then_ct) ensures that, when constraint if_ct is satisfied, then then_ct is also satisfied. When if_ct is not satisfied, then_ct is free to be satsfied or not.

The syntax is:

`Model.add_if_then(if_ct, then_ct, negate=False)`

  • if_ct is a discrete linear constraint
  • then_ct is any linear constraint (not necessarily discrete),
  • negate is an optional flag to reverse the logic, that is satisfy then_ct if if_ct is not (more on this later)

    As for indicators, the then_ct need not be discrete.

    Model.add_if_then(if_ct, then_ct) is roughly equivalent to Model.add_indicator(if_ct.status_var, then_ct).


In [ ]:
m9 = Model(name='logical9')
x = m9.continuous_var(name='x', ub=100)
y = m9.integer_var(name='iy', ub = 11)
z = m9.integer_var(name='iz', ub = 13)

m9.add_if_then(y+z >= 10, x <= 3.14)

# y and z are puashed to their ub, so x is down to 3.14
m9.maximize(x + 100*(y + z))
m9.solve()
m9.print_solution()

In this second variant, the objective coefficient for (y+z) is 2 instead of 100, so x domines the objective, and reache sits upper bound, while (y+z) must be less than 9, which is what we observe.


In [ ]:
# y and z are pushed to their ub, so x is down to 3.14
m9.maximize(x + 2 *(y + z))
m9.solve()
m9.print_solution()

assert abs(m9.objective_value - 118) <= 1e-2

Summary

We have seen that linear constraints have an associated binary variable, its truth value, whose value is linked to whether or not the constraint is satisfied.

second, linear constraints can be freely mixed with variables in expression to express meta-constraints that is, constraints about constraints. As an example, we have shown how to use truth values to negate constraints.

In addition, we have learned to use equivalence, indicator and if_then constraints.

You learned how to set up and use the IBM Decision Optimization CPLEX Modeling for Python to formulate a Mathematical Programming model with logical constraints.

References

Copyright © 2017-2019 IBM. Sample Materials.


In [ ]: