Certified-global MINLP with AMP#
discopt’s default solver path is fast and handles many nonconvex problems well in practice, but for nonconvex MINLPs (bilinear, signomial, concave objectives, trig, etc.) it cannot in general issue a certificate that the returned solution is globally optimal — it could be stuck in a local basin.
The Adaptive Multivariate Partitioning (AMP) solver path closes that gap. AMP is a piecewise-relaxation method in the lineage of Alpine.jl [Nagarajan et al., 2019], BARON, and Couenne. At every iteration it maintains a valid lower bound \(LB_k\) and upper bound \(UB_k\) on the global optimum:
and refines a partition of the nonconvex variables until \(\frac{UB_k - LB_k}{|UB_k|} \le \mathtt{rel\_gap}\). At termination the returned result.gap is a hard guarantee on suboptimality.
How AMP works#
Each iteration \(k\):
MILP relaxation — Build a piecewise McCormick [McCormick, 1976] (or convex-hull / SOS2) relaxation of every nonconvex term, augmented with \(\alpha\)-BB cuts [Adjiman et al., 1998] for nonconvex quadratics. Solve with HiGHS → valid lower bound \(LB_k\) and a relaxed candidate point.
NLP subproblem — Fix integers and solve the original NLP at the candidate (Ipopt). Any feasible solution gives a valid upper bound \(UB_k\) and an incumbent.
Refine — Pick the variable / interval with the largest relaxation gap and split it. Add outer-approximation cuts from the incumbent. Loop.
Setup#
import discopt.modeling as dm
print("discopt loaded")
discopt loaded
Example 1 — A nonconvex separable QP#
Consider the concave-quadratic problem
with offsets \(c = (-1,\, 0.5,\, 1.5)\). The objective is concave (the negative-of-square terms each open downward), so the minimum sits at a vertex of the feasible polytope — a setting where a generic NLP solver is not guaranteed to find the global optimum.
Default solver:
c = [-1.0, 0.5, 1.5]
def build_concave_qp():
m = dm.Model("concave_qp")
xs = [m.continuous(f"x{i}", lb=-2.0, ub=2.0) for i in range(3)]
m.subject_to(sum(xs) >= -1.0)
m.subject_to(sum(xs) <= 3.0)
m.minimize(sum(-((xs[i] - c[i]) ** 2) for i in range(3)))
return m, xs
m, xs = build_concave_qp()
result_default = m.solve()
print("Default solver:")
print(f" status = {result_default.status}")
print(f" objective = {result_default.objective:.6f}")
print(
f" x = ({result_default.x['x0']:.4f}, "
f"{result_default.x['x1']:.4f}, {result_default.x['x2']:.4f})"
)
Default solver:
status = optimal
objective = -23.500000
x = (2.0000, 2.0000, -2.0000)
Now solve with AMP — note the explicit gap field on the result:
m, xs = build_concave_qp()
result_amp = m.solve(solver="amp", rel_gap=1e-4)
print("AMP (certified global):")
print(f" status = {result_amp.status}")
print(f" objective = {result_amp.objective:.6f}")
print(f" gap = {result_amp.gap:.2e} <-- certified suboptimality bound")
print(f" MIPs = {result_amp.mip_count}")
print(f" wall time = {result_amp.wall_time:.2f}s")
print(
f" x = ({result_amp.x['x0']:.4f}, {result_amp.x['x1']:.4f}, {result_amp.x['x2']:.4f})"
)
AMP (certified global):
status = optimal
objective = -23.500000
gap = 6.81e-05 <-- certified suboptimality bound
MIPs = 3
wall time = 0.05s
x = (2.0000, 2.0000, -2.0000)
Both solvers find the same vertex, but AMP additionally returns a gap that proves the answer is within rel_gap of the true global optimum. That certificate is what AMP buys you.
Example 2 — A nonconvex MINLP#
Now add binary on/off variables. We pick 2 of 3 “facilities” and choose continuous loadings on each picked facility to minimize a concave operating cost subject to a minimum total throughput:
c = [1.0, 2.5, 3.5]
m = dm.Model("facility")
xs = [m.continuous(f"x{i}", lb=0.0, ub=4.0) for i in range(3)]
zs = [m.binary(f"z{i}") for i in range(3)]
m.subject_to(sum(xs) >= 2.0)
m.subject_to(sum(zs) == 2)
for i in range(3):
m.subject_to(xs[i] <= 4.0 * zs[i])
m.minimize(sum(-((xs[i] - c[i]) ** 2) for i in range(3)))
result = m.solve(solver="amp", rel_gap=1e-4, max_iter=100)
print(f"status = {result.status}")
print(f"objective = {result.objective:.6f}")
print(f"gap = {result.gap:.2e}")
print(f"MIPs = {result.mip_count}")
print(f"wall time = {result.wall_time:.2f}s")
for i in range(3):
z = int(round(float(result.x[f"z{i}"])))
x = float(result.x[f"x{i}"])
print(f" z{i}={z:d} x{i}={x:.4f}")
status = optimal
objective = -27.500000
gap = 0.00e+00
MIPs = 1
wall time = 0.30s
z0=1 x0=4.0000
z1=0 x1=-0.0000
z2=1 x2=-0.0000
Tuning AMP#
All AMP-specific options are passed as keyword arguments to Model.solve(solver="amp", ...). The most useful:
Option |
Default |
What it does |
|---|---|---|
|
|
Stop when $\tfrac{UB - LB}{ |
|
|
Absolute optimality tolerance |
|
|
Hard cap on partition-refinement iterations |
|
|
Initial partitions per discretized variable |
|
|
|
|
|
Logarithmic Gray-code embedded SOS2 binaries (fewer integer vars per partition) |
|
|
|
|
|
OBBT + FBBT bound tightening before the first MILP |
|
|
|
|
|
Strengthen variable bounds at the root using LP-based OBBT |
|
|
Use the current incumbent as a cutoff during OBBT |
|
|
Add \(\alpha\)-BB OA cuts during cutoff OBBT |
|
|
How to choose the next refinement variable / interval |
|
|
Variable-selection heuristic for partitioning |
|
|
Trust a user-supplied |
|
|
Per-MILP wall-clock cap (seconds) |
|
|
MILP-solver gap stop |
Comparing convex-hull formulations#
The default "disaggregated" McCormick is the simplest; "sos2" builds the convex hull of each piecewise segment using SOS2 lambda variables (tighter but more binaries); "sos2" with convhull_ebd=True swaps the per-interval binaries for a logarithmic Gray-code encoding.
configs = [
("disaggregated", False),
("sos2", False),
("sos2", True),
]
for formulation, ebd in configs:
m, xs = build_concave_qp()
result = m.solve(
solver="amp",
rel_gap=1e-4,
convhull_formulation=formulation,
convhull_ebd=ebd,
)
label = f"{formulation}{' + embedded' if ebd else ''}"
print(
f"{label:25s} obj={result.objective:.6f} gap={result.gap:.2e} "
f"MIPs={result.mip_count} time={result.wall_time:.2f}s"
)
disaggregated obj=-43.500000 gap=3.68e-05 MIPs=3 time=0.04s
sos2 obj=-43.500000 gap=3.68e-05 MIPs=3 time=0.04s
sos2 + embedded obj=-43.500000 gap=3.68e-05 MIPs=3 time=0.04s
Watching convergence with iteration_callback#
Pass a callback that receives {iteration, lower_bound, upper_bound} after each iteration to inspect the bound trajectory:
trace = []
def cb(info):
trace.append((info["iteration"], info["lower_bound"], info["upper_bound"]))
m, xs = build_concave_qp()
_ = m.solve(solver="amp", rel_gap=1e-4, iteration_callback=cb)
print(f"{'iter':>4} {'LB':>14} {'UB':>14} {'gap':>10}")
for it, lb, ub in trace:
gap = float("inf") if abs(ub) < 1e-12 else abs(ub - lb) / max(1.0, abs(ub))
print(f"{it:>4} {lb:>14.6f} {ub:>14.6f} {gap:>10.2e}")
iter LB UB gap
1 -44.500000 -43.500000 2.30e-02
2 -43.540000 -43.500000 9.20e-04
3 -43.501600 -43.500000 3.68e-05
When not to use AMP#
Convex MINLPs. The default solver (outer approximation / convex B&B) is faster and equally certified for convex problems — AMP’s relaxation overhead is wasted.
Pure LP / MILP / convex QP. Use the dedicated solvers; AMP adds no value.
Very large nonconvex problems where a fast feasible solution matters more than a certificate. A local NLP from a good warm start may return a usable point faster, at the cost of no guarantee.
Problems with operator combinations AMP cannot factorize cleanly. AMP requires the nonlinearities to decompose into bilinear / trilinear / monomial / supported-univariate (
exp,log,sqrt,sin,cos,tan,abs) terms. Rich polynomial objectives like \((x^2-1)^2 + xy\) that contain mixed repeated-factor products may fall back to a feasibility relaxation and lose the certificate.
Use AMP when you have a small-to-medium nonconvex (MI)NLP, the structure is factorable, and you need a certified-global answer or a verified bound on suboptimality.
References#
AMP’s algorithm follows the Alpine.jl design [Nagarajan et al., 2019]; the piecewise-McCormick relaxations originate with McCormick [1976]; the \(\alpha\)-BB cuts for nonconvex quadratics follow Adjiman et al. [1998].