Presolve¶
Before the strategy runs its outer loop, solve(..., presolve=True) (or the standalone pympcc.presolve(problem)) applies a sequence of structural reductions:
Pass |
What it does |
|---|---|
A1 Pinned-variable elimination |
Substitutes out every \(j\) with |
A2 FBBT (linear bound tightening) |
Iterates |
A4 Empty row/col pruning |
Drops constraints with no nonzero Jacobian and variables with no nonzero column. |
B1 Dead-pair pruning |
Drops comp pairs where one side is structurally zero and trivially satisfied at |
B2 Forced-pair pruning |
Comp pairs where both sides are linear with a forced sign reduce to a single equality. |
B3 Linear sign-prefix |
Comp pairs whose sign is determined by domain bounds rewrite as equalities. |
When nothing can be eliminated, presolve is a no-op and returns an identity PresolveMap.
Demo: pinned-variable elimination¶
import warnings
import numpy as np
import pympcc
# x[2] is pinned at 0.5 by xl == xu; presolve will eliminate it.
problem = pympcc.MPCCProblem(
n=3, n_comp=1,
x0=np.array([0.5, 0.5, 0.5]),
xl=np.array([0.0, 0.0, 0.5]),
xu=np.array([np.inf, np.inf, 0.5]), # x[2] pinned
objective=lambda x: (x[0] - 2.0) ** 2 + (x[1] - 1.0) ** 2 + x[2],
gradient=lambda x: np.array([2.0 * (x[0] - 2.0), 2.0 * (x[1] - 1.0), 1.0]),
comp_G=lambda x: np.array([x[0]]),
comp_G_jacobian=lambda x: np.array([[1.0, 0.0, 0.0]]),
comp_H=lambda x: np.array([x[1]]),
comp_H_jacobian=lambda x: np.array([[0.0, 1.0, 0.0]]),
)
reduced, pmap = pympcc.presolve(problem)
print(f"Original n = {problem.n}, n_comp = {problem.n_comp}")
print(f"Reduced n = {reduced.n}, n_comp = {reduced.n_comp}")
Original n = 3, n_comp = 1
Reduced n = 2, n_comp = 1
The reduced problem has n = 2. The strategy never sees x[2]; the PresolveMap re-injects it into the result.
End-to-end with presolve=True¶
solve(presolve=True) runs the reduction once at solver construction, lets the strategy operate on the reduced problem, then expands the result back to the original variable space:
with warnings.catch_warnings():
warnings.simplefilter("ignore", UserWarning)
r_with = pympcc.solve(problem, strategy="scholtes", presolve=True)
r_without = pympcc.solve(problem, strategy="scholtes", presolve=False)
print(f"with presolve: x* = {r_with.x}, f* = {r_with.obj:.4f}")
print(f"without presolve: x* = {r_without.x}, f* = {r_without.obj:.4f}")
print(f"agree: {np.allclose(r_with.x, r_without.x, atol=1e-5)}")
with presolve: x* = [2.0000000e+00 9.9950254e-09 5.0000000e-01], f* = 1.5000
without presolve: x* = [2.0000000e+00 9.9950254e-09 5.0000000e-01], f* = 1.5000
agree: True
Both yield identical result.x in the original variable space — pinned x[2] = 0.5 shows up automatically.
Demo: dead-pair pruning¶
A comp pair where one side is structurally zero (no nonzeros in its Jacobian sparsity pattern) and trivially satisfied at x0 is dropped:
# Pair 0: x[0] >= 0 ⊥ 0 >= 0 — H row 0 has no Jacobian nnz, dead.
# Pair 1: x[1] >= 0 ⊥ x[2] >= 0 — genuine.
problem_dead = pympcc.MPCCProblem(
n=3, n_comp=2,
x0=np.array([0.5, 0.5, 0.5]),
xl=np.zeros(3),
objective=lambda x: x[0] + x[1] + x[2],
gradient=lambda x: np.ones(3),
comp_G=lambda x: np.array([x[0], x[1]]),
comp_G_jacobian=lambda x: np.array([1.0, 1.0]),
comp_G_jacobian_sparsity=(np.array([0, 1]), np.array([0, 1])),
comp_H=lambda x: np.array([0.0, x[2]]),
# Sparsity declares only one nonzero: row 1, col 2. Row 0 has no nnz → dead.
comp_H_jacobian=lambda x: np.array([1.0]),
comp_H_jacobian_sparsity=(np.array([1]), np.array([2])),
)
reduced, pmap = pympcc.presolve(problem_dead)
print(f"Original n_comp = {problem_dead.n_comp}")
print(f"Reduced n_comp = {reduced.n_comp}")
Original n_comp = 2
Reduced n_comp = 1
Dead-pair pruning collapses two pairs to one when the structural test detects a row with no nonzero Jacobian entries.
When presolve helps (and when it doesn’t)¶
Helps a lot: hand-coded MPCCs with redundant pinned variables, AMPL/Pyomo imports that include vacuous bounds, parametric problems where some
thetacollapses pairs.No-op: clean small MPCCs (the quickstart-style problem).
presolve(...)returns the identity map and adds zero overhead.Caveat:
presolveruns once at solver construction, not at each warm-startresolve(). If your parametric sweep mutates structure, reconstruct the solver.
For the full pass-by-pass list, see the presolve & multistart user guide.