Big-M constants come from declared bounds#
Description: How MP sizes the big-M constants of logical constraints from declared variable bounds, why naming an expression and declaring its bound tightens the LP relaxation, and how to check this with the root bound and the exported solver model
Tags: modeling-tips, mp, big-m, reinforced-formulations, formulation-comparison, facility-location, milp, amplpy, highs
Notebook author: Gleb Belov <gleb@ampl.com>
References:
MP modeling guide, Efficiency considerations and Numerical accuracy: https://mp.ampl.com/modeling-efficiency.html, https://mp.ampl.com/modeling-numerics.html
Vielma, J.P. (2015). Mixed integer linear programming formulation techniques. SIAM Review 57(1), 3-57.
Bonami, P., Lodi, A., Tramontani, A., Wiese, S. (2015). On mathematical programming with indicator constraints. Mathematical Programming 151(1), 191-223.
# Install dependencies
%pip install -q amplpy numpy pandas
# Google Colab & Kaggle integration
from amplpy import AMPL, ampl_notebook
ampl = ampl_notebook(
modules=["highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics
SOLVER_MILP = (
"highs" # any MP-based MILP solver: highs, gurobi, xpress, cplex, copt, scip, ...
)
# HiGHS has no native indicator constraints, so MP linearizes every implication
# with a big-M constant -- which is what this notebook inspects. A solver with
# native indicators may keep some implications as indicators instead.
The point#
When a solver has no native support for a logical constraint such as
MP linearizes it with a big-M constant: \(\sum_j \text{Ship}_{ij} \le M \cdot \text{Open}_i\). MP takes \(M\) from the declared bounds of the variables in the expression, propagated through the expression. It does not look for bounds implied by other constraints of the model. So the same implication can be linearized with a loose or a tight constant depending only on what the declarations say, and the constant decides how strong the LP relaxation is.
The remedy is to name the expression as a variable and declare its real bound. This notebook shows the effect on a capacitated facility location model, measures it with the root (LP relaxation) bound, and looks at the constants in the model the solver actually receives.
Data: facilities with binding capacities#
import time
import numpy as np
import pandas as pd
def make_data(n_fac, n_cust, seed=3):
rng = np.random.default_rng(seed)
cap = rng.integers(80, 160, n_fac)
dem = rng.integers(5, 25, n_cust)
fix = rng.integers(300, 700, n_fac)
xf, yf, xc, yc = (
rng.random(n_fac),
rng.random(n_fac),
rng.random(n_cust),
rng.random(n_cust),
)
cost = {
(i, j): round(100 * float(np.hypot(xf[i] - xc[j], yf[i] - yc[j])), 1)
for i in range(n_fac)
for j in range(n_cust)
}
return dict(cap=cap.tolist(), dem=dem.tolist(), fix=fix.tolist(), cost=cost)
data = make_data(20, 80)
print(
"total demand",
sum(data["dem"]),
" total capacity",
sum(data["cap"]),
" largest capacity",
max(data["cap"]),
)
total demand 1203 total capacity 2209 largest capacity 149
Three formulations of the same model#
All three have the same integer solutions. They differ only in where the capacity information is stated.
implication_on_sum: the implication is written on the sum of shipments. EachShip[i,j]is bounded by the customer’s demand, so the bound MP can derive for the sum is the total demand, far above any single capacity. The capacity is a separate constraint.named_and_bounded: the sum gets a name,Out[i], whose declared upper bound is the capacity. The implication is written onOut[i].named_capacity_row:Out[i]without a declared upper bound, and the capacity written as a constraintOut[i] <= cap[i].
BASE = r"""
set I; set J;
param cap {I}; param dem {J}; param fix {I}; param cost {I, J};
var Open {I} binary;
var Ship {i in I, j in J} >= 0, <= dem[j];
minimize Total: sum {i in I} fix[i] * Open[i] + sum {i in I, j in J} cost[i,j] * Ship[i,j];
subject to Demand {j in J}: sum {i in I} Ship[i,j] = dem[j];
"""
MODELS = {
"implication_on_sum": BASE
+ r"""
subject to Capacity {i in I}: sum {j in J} Ship[i,j] <= cap[i];
subject to Use {i in I}: Open[i] = 0 ==> sum {j in J} Ship[i,j] <= 0;
""",
"named_and_bounded": BASE
+ r"""
var Out {i in I} >= 0, <= cap[i];
subject to Out_Def {i in I}: Out[i] = sum {j in J} Ship[i,j];
subject to Use {i in I}: Open[i] = 0 ==> Out[i] <= 0;
""",
"named_capacity_row": BASE
+ r"""
var Out {i in I} >= 0;
subject to Out_Def {i in I}: Out[i] = sum {j in J} Ship[i,j];
subject to Capacity {i in I}: Out[i] <= cap[i];
subject to Use {i in I}: Open[i] = 0 ==> Out[i] <= 0;
""",
}
def load(model, data, ampl_options=None):
ampl = AMPL()
for name, value in (ampl_options or {}).items():
ampl.option[name] = value
ampl.eval(model)
n_fac, n_cust = len(data["cap"]), len(data["dem"])
ampl.set["I"] = range(n_fac)
ampl.set["J"] = range(n_cust)
for name in ("cap", "fix"):
ampl.param[name] = dict(enumerate(data[name]))
ampl.param["dem"] = dict(enumerate(data["dem"]))
ampl.param["cost"] = data["cost"]
return ampl
def solver_objective(ampl):
"""The objective value the solver reports (last 'objective <value>' in solve_message).
AMPL's own value, ampl.obj["Total"].value(), is recomputed from the returned
variables. For a relaxation of a model with logical expressions (if-then, ==>)
the two differ: AMPL evaluates the logic exactly at a fractional point."""
import re
found = re.findall(r"objective ([-+0-9.eE]+)", ampl.get_value("solve_message"))
return float(found[-1]) if found else ampl.obj["Total"].value()
def solve(model, data, relax=False, solver_options="", ampl_options=None):
"""Solve once; relax=True gives the root LP bound (MP's auxiliary binaries relaxed too)."""
ampl = load(model, data, ampl_options)
opts = "outlev=0 " + solver_options + (" relax=1 chk:mode=0" if relax else "")
start = time.time()
ampl.solve(solver=SOLVER_MILP, verbose=False, **{SOLVER_MILP + "_options": opts})
seconds = time.time() - start
message = ampl.get_value("solve_message")
nodes = next(
(line.split()[0] for line in message.splitlines() if "node" in line), ""
)
return dict(
status=ampl.solve_result,
objective=solver_objective(ampl),
nodes=nodes,
seconds=round(seconds, 2),
)
Compare the root bound#
The root bound is the optimum with integrality relaxed (relax=1, which also
relaxes the binaries MP adds). The closer it is to the integer optimum, the less
the branch-and-bound has to do.
rows = []
for name, model in MODELS.items():
root = solve(model, data, relax=True)
mip = solve(model, data, solver_options="timelim=120")
rows.append(
dict(
formulation=name,
root_bound=round(root["objective"], 1),
optimum=round(mip["objective"], 1),
status=mip["status"],
nodes=mip["nodes"],
seconds=mip["seconds"],
)
)
table = pd.DataFrame(rows).set_index("formulation")
table["root_gap_%"] = (100 * (table.optimum - table.root_bound) / table.optimum).round(
1
)
table
| root_bound | optimum | status | nodes | seconds | root_gap_% | |
|---|---|---|---|---|---|---|
| formulation | ||||||
| implication_on_sum | 14587.8 | 21626.7 | solved | 7 | 0.30 | 32.5 |
| named_and_bounded | 19743.6 | 21626.7 | solved | 1 | 0.12 | 8.7 |
| named_capacity_row | 19743.6 | 21626.7 | solved | 1 | 0.12 | 8.7 |
All three reach the same optimum. The implication on the sum has a much weaker root bound, and branch-and-bound needs more nodes to close it. How much time this costs depends on the solver and the instance: a strong solver can recover part of the gap with presolve and cuts. The root gap is the solver-independent part of the story.
named_capacity_row matches named_and_bounded although its capacity is a
constraint: a constraint on a single variable is a bound in disguise. AMPL’s
presolve turns it into a bound, and so does MP when AMPL presolve is off:
root_row_nopresolve = solve(
MODELS["named_capacity_row"], data, relax=True, ampl_options={"presolve": 0}
)
print(
"named_capacity_row, AMPL presolve off: root bound",
round(root_row_nopresolve["objective"], 1),
" vs",
table.loc["named_capacity_row", "root_bound"],
"with presolve",
)
named_capacity_row, AMPL presolve off: root bound 19743.6 vs 19743.6 with presolve
implication_on_sum has a capacity constraint too, yet stays weak: there the
constraint is on an expression, the sum of shipments, and a constraint on an
expression never reaches the big-M sizing. So: a bound on a variable, declared
or in a single-variable constraint, reaches the big-M sizing; a constraint on an
expression does not. A limit on a sum, a difference or any other expression
must be given its own variable with a bound.
Look at what the solver receives#
Export the solver’s model with writeprob on a tiny instance where the capacity
is below the total demand. In the LP file, look for the rows with the binary
Open variables (here c# columns, since the HiGHS LP writer does not keep AMPL
names) and read the coefficient of the binary: that is \(-M\).
import os
import tempfile
tiny = dict(
cap=[15, 12],
dem=[8, 9, 8],
fix=[620, 647],
cost={
(0, 0): 64.2,
(0, 1): 5.2,
(0, 2): 31.6,
(1, 0): 46.7,
(1, 1): 41.9,
(1, 2): 42.9,
},
)
tmpdir = tempfile.mkdtemp()
for name in ("implication_on_sum", "named_and_bounded"):
path = os.path.join(tmpdir, name + ".lp")
solve(MODELS[name], tiny, solver_options=f"writeprob={path}")
lines = open(path).read().splitlines()
rows = [l for l in lines[lines.index("st") + 1 : lines.index("bounds")]]
print(f"--- {name}: total demand {sum(tiny['dem'])}, capacities {tiny['cap']}")
print("\n".join(r for r in rows if "<=" in r))
--- implication_on_sum: total demand 25, capacities [15, 12]
r3: +1 c0 +1 c1 +1 c2 <= +15
r4: +1 c3 +1 c4 +1 c5 <= +12
r5: +1 c0 +1 c1 +1 c2 -25 c6 <= +0
r6: -1 c0 -1 c1 -1 c2 <= -0
r7: +1 c3 +1 c4 +1 c5 -25 c7 <= +0
r8: -1 c3 -1 c4 -1 c5 <= -0
--- named_and_bounded: total demand 25, capacities [15, 12]
r5: +1 c0 -15 c2 <= +0
r6: +1 c1 -12 c3 <= +0
With the implication on the sum, the constant multiplying each binary is the total demand, 25, the only bound MP can derive for the sum. With the named variable it is each facility’s capacity. The same check works on any model: export the solver’s model, find the rows MP generated for a logical constraint, and compare their constants with what the data says is possible.
And with no bound at all#
If a variable in a logical constraint has no finite bound, MP cannot linearize
the constraint and the solve ends with a message, not an exception. Always check
solve_result:
free = MODELS["implication_on_sum"].replace(
"var Ship {i in I, j in J} >= 0, <= dem[j];", "var Ship {i in I, j in J} >= 0;"
)
ampl = load(free, data)
ampl.solve(solver=SOLVER_MILP, verbose=False)
print("solve_result:", ampl.solve_result)
print(ampl.get_value("solve_message"))
solve_result: failure
HiGHS 1.15.1: Set bounds on variables
participating in logical expressions,
or use option cvt:bigM (with caution).
See more: mp.ampl.com/modeling-numerics.html
The message also mentions cvt:bigM, a default constant for unbounded
variables. Treat it as a last resort: a guessed constant is either too loose
(weak relaxation) or too tight (it cuts off solutions). A declared bound that
follows from the data is always better.
Takeaways#
MP sizes big-M constants from declared variable bounds, propagated through the expression in the logical constraint.
To tighten a constant, name the expression as a variable and declare the bound that follows from the data (capacity, time window, physical limit).
A constraint on a single variable is turned into a bound (by AMPL presolve, or by MP); a constraint on an expression stays a row and does not reach the big-M sizing.
Check with two cheap measurements before benchmarking: the root bound (
relax=1, oroption relax_integrality 1for the model’s own integer variables), and the exported solver model (writeprob).An indicator constraint, where a solver supports it natively, does not need the constant, but it is not automatically faster than a tight big-M row; measure both.
More on choosing between formulations: the MP modeling guide at https://mp.ampl.com/modeling-formulations.html, and the related notebooks under https://colab.ampl.com/tags/formulation-comparison.html.