Bilevel Optimization (Stackelberg games)#
A bilevel program models a leader who optimizes subject to a follower solving its own optimization problem in response:
The leader picks \(x\); the follower responds with the \(y\) that is optimal for its problem. This models toll setting, network interdiction, pricing, and Stackelberg competition [Bard, 1998, Colson et al., 2007, Dempe, 2002].
The KKT reduction#
When the lower level is convex in \(y\) (for each fixed \(x\)), the follower’s
KKT conditions are necessary and sufficient for its optimality, so we can
replace y ∈ argmin{…} by
stationarity \(\nabla_y L = 0\) with \(L = f + \sum_i \mu_i g_i\),
primal feasibility \(g_i \le 0\),
dual feasibility \(\mu_i \ge 0\),
complementarity \(0 \le \mu_i \perp -g_i \ge 0\).
The result is a single-level MPEC, which discopt already solves
(discopt.mpec: GDP / SOS1 / Scholtes). discopt’s BilevelProblem builds this
reduction automatically. It handles the optimistic case; the lower level must
be convex in \(y\) — an LP, a convex-QP, or a convex-NLP proved convex by an
interval-Hessian test over the box (so the natural linearly-coupled form such as
\(e^{y} - x\,y\) is accepted). Nonconvex / integer-follower / pessimistic problems
are refused loudly rather than silently mis-solved
[Kleinert et al., 2021].
Stationarity needs a symbolic derivative#
So that \(\nabla_y L = 0\) stays inside discopt’s certified global path, the
gradient must be an ordinary expression (not an opaque numeric callback). The
discopt.bilevel.symbolic_diff engine provides diff/grad over the
expression DAG:
import discopt.modeling as dm
from discopt.bilevel import diff
m = dm.Model('demo')
x = m.continuous('x', lb=-5, ub=5)
expr = x**2 + 3 * x
print('d/dx (x^2 + 3x) =', diff(expr, x)) # simplifies to (x + x) + 3
d/dx (x^2 + 3x) = ((2 * x) + 3)
Worked example: toll setting#
A road authority (leader) sets a toll \(t\) on an arc to maximize revenue
\(t\,f\). Drivers (follower) choose the flow \(f\) to minimize a convex
congestion-plus-toll cost \(\tfrac12 f^2 - 5f + t f\). The follower is a convex QP
in \(f\), so BilevelProblem replaces it by its KKT conditions. We build the
follower’s KKT system with build_kkt_system() and inspect the emitted
conditions (pure Python — no solver needed for this step):
from discopt.bilevel import BilevelProblem
m = dm.Model('toll')
t = m.continuous('toll', lb=0, ub=5) # leader: toll
f = m.continuous('flow', lb=0, ub=8) # follower: flow
m.minimize(-(t * f)) # maximize revenue t*f
bl = BilevelProblem(
m, upper_vars=[t], lower_vars=[f],
lower_objective=0.5 * f * f - 5 * f + t * f, # convex QP in f
lower_constraints=[], # only the flow bounds (folded in)
)
# build_kkt_system() emits the follower's KKT conditions (sound math) and returns
# them for inspection, independent of how the complementarity is later encoded.
bl.build_kkt_system()
print('stationarity:', bl.kkt.stationarity[0].body)
for p in bl.kkt.comp_pairs:
print('complementarity: 0 <=', p.f, 'perp', p.g, '>= 0')
stationarity: ((((((0.5 * flow) + (0.5 * flow)) - 5) + toll) + (bl_mu0 * -1)) + bl_mu1)
complementarity: 0 <= bl_mu0 perp neg((0 - flow)) >= 0
complementarity: 0 <= bl_mu1 perp neg((flow - 8)) >= 0
The follower’s finite bounds (\(0 \le f \le 8\)) are folded into the KKT system automatically — they are genuine follower constraints, so omitting them would be wrong whenever the follower optimum sits on a bound.
Solving and the optimum#
Strong duality. Call bl.formulate(method='strong_duality') then m.solve().
Strong duality keeps stationarity, primal and dual feasibility, and replaces the
per-row complementarity with the single aggregate equality
\(\sum_i \mu_i g_i = 0\) (one bilinear equality, no disjunction).
We can check the answer by hand: the follower’s optimum is \(f = 5 - t\), so the leader’s revenue is \(t(5-t)\), maximized at \(t^\* = 2.5\), \(f^\* = 2.5\), revenue \(6.25\).
That equality is nonconvex, so certification is not guaranteed — but nor is it precluded, since the spatial branch-and-bound can still close the gap. On a model this small it does, and the cell below reports what actually came back rather than predicting it.
Big-M KKT encodings. mpec_method='gdp'/'sos1' need a finite bound on
each follower multiplier: a KKT multiplier has no a-priori upper bound, and a
sentinel-sized big-M is numerically vacuous — it would let the solver certify a
follower-infeasible point (a false optimum), so BilevelProblem refuses the big-M
methods loudly for unbounded multipliers. Deriving a valid bound automatically is
NP-hard in general [Kleinert et al., 2021], so if you can assert one
for your problem, pass it explicitly as multiplier_ub.
The bound is a modeling assertion, exactly like a user-supplied big-M: it must be
\(\ge\) the largest multiplier at the true follower optimum for every leader decision,
or it silently cuts the true response. solve() warns if a multiplier ends up at
its supplied bound.
def build_toll(multiplier_ub=None):
"""Rebuild the toll model; pass multiplier_ub to enable the big-M encodings."""
mm = dm.Model("toll")
tt = mm.continuous("toll", lb=0, ub=5)
ff = mm.continuous("flow", lb=0, ub=8)
mm.minimize(-(tt * ff))
kwargs = {} if multiplier_ub is None else {"multiplier_ub": multiplier_ub}
prob = BilevelProblem(
mm, upper_vars=[tt], lower_vars=[ff],
lower_objective=0.5 * ff * ff - 5 * ff + tt * ff,
lower_constraints=[], **kwargs,
)
return mm, prob
ANALYTIC_REVENUE = 6.25 # t*(5-t) at t = 2.5
# 1. Strong duality (no multiplier bound needed).
mm, prob = build_toll()
prob.formulate(method="strong_duality")
r = mm.solve()
print(f"strong_duality : toll={r.x['toll']:.4f} flow={r.x['flow']:.4f} "
f"revenue={-r.objective:.4f} gap_certified={r.gap_certified}")
assert abs(-r.objective - ANALYTIC_REVENUE) < 1e-6, -r.objective
# 2. Big-M encodings WITHOUT a multiplier bound: these must refuse, not guess.
mm, prob = build_toll()
try:
prob.formulate(method="kkt", mpec_method="gdp")
print("gdp (no bound) : UNEXPECTED -- should have refused")
except NotImplementedError as exc:
print(f"gdp (no bound) : refused, as designed -- {str(exc).split(chr(10))[0][:70]}...")
# 3. Big-M encodings WITH an asserted bound.
for mpec_method in ("gdp", "sos1"):
mm, prob = build_toll(multiplier_ub=50.0)
prob.formulate(method="kkt", mpec_method=mpec_method)
r = mm.solve()
print(f"kkt/{mpec_method:<4s} (ub=50) : toll={r.x['toll']:.4f} flow={r.x['flow']:.4f} "
f"revenue={-r.objective:.4f} gap_certified={r.gap_certified}")
assert abs(-r.objective - ANALYTIC_REVENUE) < 1e-6, -r.objective
print(f"\nAll solved arms match the analytic optimum ({ANALYTIC_REVENUE}).")
/Users/jkitchin/projects/discopt/python/discopt/debug/__init__.py:98: UserWarning: Variables with very large or infinite declared bounds: bl_mu0 (lb=0, ub=9.999e+19 [default]), bl_mu1 (lb=0, ub=9.999e+19 [default]). A bound marked [default] is the box applied to a column you declared with no bounds: 9.999e+19, which is FINITE (it sits just below the 1e+20 infinity sentinel), so the solve can return a certified 'optimal' sitting on that corner rather than 'unbounded'. NLP solvers may fail (NaN, iteration_limit) when bounds exceed ~1e15. Add tighter explicit bounds, e.g. m.continuous('x', lb=0, ub=1000).
return fn(*args, **kwargs)
INFO pounce_algorithm::conv_check::opt_error: certifying with a dual infeasibility above dual_inf_tol: it is within the scale-relative floor set by the terms the Lagrangian gradient is built from (dual_inf_scale_kappa=0 disables) dual_inf=1.4667778015136719 dual_scale=15684713060.164063 dual_inf_tol=1.0 bound=156.84713060164063
INFO pounce_algorithm::conv_check::opt_error: certifying with a dual infeasibility above dual_inf_tol: it is within the scale-relative floor set by the terms the Lagrangian gradient is built from (dual_inf_scale_kappa=0 disables) dual_inf=2.850830078125 dual_scale=1771142500419.375 dual_inf_tol=1.0 bound=17711.425004193752
INFO pounce_algorithm::conv_check::opt_error: certifying with a dual infeasibility above dual_inf_tol: it is within the scale-relative floor set by the terms the Lagrangian gradient is built from (dual_inf_scale_kappa=0 disables) dual_inf=40.04273986816406 dual_scale=110702145987.36311 dual_inf_tol=1.0 bound=1107.021459873631
INFO pounce_algorithm::conv_check::opt_error: certifying with a dual infeasibility above dual_inf_tol: it is within the scale-relative floor set by the terms the Lagrangian gradient is built from (dual_inf_scale_kappa=0 disables) dual_inf=8.390632629394531 dual_scale=18621633243.747612 dual_inf_tol=1.0 bound=186.21633243747613
strong_duality : toll=2.5000 flow=2.5000 revenue=6.2500 gap_certified=True
gdp (no bound) : refused, as designed -- the KKT complementarity multiplier(s) ['bl_mu0', 'bl_mu1'] are unbound...
kkt/gdp (ub=50) : toll=2.5000 flow=2.5000 revenue=6.2500 gap_certified=True
kkt/sos1 (ub=50) : toll=2.5000 flow=2.5000 revenue=6.2500 gap_certified=True
All solved arms match the analytic optimum (6.25).
References#
See the References page for full citations.