Slack-lifting strategy¶
The slack strategy is a lifting reformulation: it introduces auxiliary variables \(s_G = G(x)\) and \(s_H = H(x)\) so the complementarity rows become \(s_G \cdot s_H = 0\) — completely independent of \(x\). The problem grows from \(n\) to \(n + 2 n_\text{comp}\) variables, but the Jacobian of every comp row has only two nonzeros (one in each slack block), regardless of how dense \(\nabla G\) and \(\nabla H\) are.
When to reach for it¶
Problem property |
|
|---|---|
|
yes — sparse comp Jacobians fixed at |
|
yes — moves the density out of the comp block |
|
maybe — the lifting overhead may not pay off |
You need analytical Hessians |
supports |
For sparse \(G\), \(H\) that already have lean Jacobians, scholtes typically wins on raw iterations. See the strategy selection guide.
A small lifted solve¶
import warnings
import numpy as np
import pympcc
# 30-variable problem with 15 dense comp pairs:
# G_i(x) = sum_j x_j - target_G[i] (every G_i depends on every x)
# H_i(x) = x[2i] + 0.5 * x[2i + 1]
n = 30
n_comp = 15
target_G = np.linspace(1.0, 2.0, n_comp)
def make_problem():
return pympcc.MPCCProblem(
n=n, n_comp=n_comp,
x0=np.full(n, 0.5),
xl=np.zeros(n),
objective=lambda x: float(np.sum(x ** 2)),
gradient=lambda x: 2.0 * x,
comp_G=lambda x: np.full(n_comp, np.sum(x)) - target_G,
comp_G_jacobian=lambda x: np.ones((n_comp, n)), # dense!
comp_H=lambda x: x[0::2][:n_comp] + 0.5 * x[1::2][:n_comp],
comp_H_jacobian=lambda x: np.array(
[[1.0 if j == 2 * i else (0.5 if j == 2 * i + 1 else 0.0)
for j in range(n)] for i in range(n_comp)]
),
)
with warnings.catch_warnings():
warnings.simplefilter("ignore", UserWarning)
r_scholtes = pympcc.solve(make_problem(), strategy="scholtes")
r_slack = pympcc.solve(make_problem(), strategy="slack")
print(f" {'strategy':<10} {'f*':>10} {'comp_res':>10} {'iters':>6}")
print(" " + "-" * 10 + " " + "-" * 10 + " " + "-" * 10 + " " + "-" * 6)
print(f" {'scholtes':<10} {r_scholtes.obj:>10.4f} "
f"{r_scholtes.comp_residual:>10.2e} {len(r_scholtes.history):>6}")
print(f" {'slack':<10} {r_slack.obj:>10.4f} "
f"{r_slack.comp_residual:>10.2e} {len(r_slack.history):>6}")
strategy f* comp_res iters
---------- ---------- ---------- ------
scholtes 2.0000 1.95e-07 9
slack 2.0000 1.95e-07 9
Both reach the same optimum. The wall-clock timing is barely distinguishable at \(n = 30\), but on production-sized problems the slack reformulation reduces per-callback Jacobian assembly from \(\mathcal O(n_\text{comp} \cdot n)\) entries to \(\mathcal O(n_\text{comp})\).
Reading the lifted variables¶
The result is reported in the original variable space — slack strips the slack variables from result.x automatically, so user code never sees the lifting. If you need the slack values, they’re recoverable via \(s_G = G(x^\*)\):
print(f"x* has length {r_slack.x.size} (original n = {n})")
print(f"G(x*) = {r_slack.G[:3]} ...")
print(f"H(x*) = {r_slack.H[:3]} ...")
print(f"comp_residual = max |G_i · H_i| = {r_slack.comp_residual:.2e}")
x* has length 30 (original n = 30)
G(x*) = [1.00000013 0.92857156 0.85714299] ...
H(x*) = [2.99905137e-08 3.15290724e-08 3.33240398e-08] ...
comp_residual = max |G_i · H_i| = 1.95e-07
When slack underperforms¶
Small dense problems (\(n \lesssim 100\), \(n_\text{comp} \lesssim 50\)) — the lifting overhead exceeds the per-iteration savings.
Already-sparse
G,H— you’ve paid the cost of writing sparse Jacobians, soscholtesalready gets the cheap callbacks.Strict CQ requirements — the lifting destroys MPCC-MFCQ; if you need the certified-multiplier story, prefer
lin_fukushimaor TNLP refinement.