Diagnostics tour¶
solve(..., diagnostics=True) attaches a battery of post-solve quality checks to the result. This notebook walks through what each one tells you and when to look at it.
import warnings
import numpy as np
import pympcc
problem = pympcc.MPCCProblem(
n=2, n_comp=1,
x0=np.array([0.5, 0.5]),
xl=np.zeros(2),
objective=lambda x: (x[0] - 2.0) ** 2 + (x[1] - 1.0) ** 2,
gradient=lambda x: np.array([2.0 * (x[0] - 2.0), 2.0 * (x[1] - 1.0)]),
comp_G=lambda x: np.array([x[0]]),
comp_G_jacobian=lambda x: np.array([[1.0, 0.0]]),
comp_H=lambda x: np.array([x[1]]),
comp_H_jacobian=lambda x: np.array([[0.0, 1.0]]),
)
with warnings.catch_warnings():
warnings.simplefilter("ignore", UserWarning)
r = pympcc.solve(problem, strategy="scholtes", diagnostics=True)
Constraint qualification¶
result.cq reports the strongest CQ that holds at the solution:
print(f"cq = {r.cq}")
print(f"active set sizes = {r.cq_active_set_sizes}")
print(f"rank deficit = {r.cq_rank_deficit} (0 ⇒ LICQ)")
cq = none
active set sizes = {'g': 0, 'h': 0, 'G': 0, 'H': 1, 'biactive': 0, 'xL': 1, 'xU': 0}
rank deficit = 1 (0 ⇒ LICQ)
MPCC-LICQ is the strongest; MPCC-MFCQ is weaker but still enough for KKT to be necessary. none means the active gradients are linearly dependent and the strategy may have converged to a non-stationary point. The hierarchy is implemented by pympcc.classify_cq:
from pympcc import classify_cq
cq_info = classify_cq(r, problem)
print(cq_info)
{'cq': 'none', 'active_set_sizes': {'g': 0, 'h': 0, 'G': 0, 'H': 1, 'biactive': 0, 'xL': 1, 'xU': 0}, 'rank_deficit': 1, 'n_active_rows': 2}
B-stationarity certification¶
result.b_stationary runs an LP enumeration over the linearised tangent cone branches at biactive pairs. The non-biactive case is automatic: if there are no biactive pairs, the result is trivially B-stationary.
print(f"b_stationary = {r.b_stationary}")
print(f"b_stationary_min_descent = {r.b_stationary_min_descent}")
print(f"b_stationary_witness = {r.b_stationary_witness}")
b_stationary = B-stationary
b_stationary_min_descent = -9.989979510294233e-09
b_stationary_witness = None
min_descent ≥ 0 (within tolerance) ⇒ no descent direction along any branch, the point is B-stationary. The witness records the branch assignment that achieved the minimum descent (only set when not B-stationary).
SOSC: second-order sufficient conditions¶
pympcc.sosc_check builds the reduced Lagrangian Hessian on the MPCC critical cone and tests positive definiteness. Positive ⇒ the point is a strict local minimum:
print(f"sosc = {r.sosc}")
print(f"sosc_min_eigenvalue = {r.sosc_min_eigenvalue}")
print(f"sosc_skipped_reason = {r.sosc_skipped_reason}")
sosc = True
sosc_min_eigenvalue = 1.9999999999999996
sosc_skipped_reason = None
sosc=None is common: SOSC is skipped at biactive points (skipped_reason="biactive_pairs") because the critical cone has a wedge that the standard reduced-Hessian test cannot resolve. For non-biactive solutions you’ll see sosc=True (or False with a negative eigenvalue).
Multi-merit cross-check¶
result.merit_cross_check evaluates three independent MPCC merit functions and reports their disagreement ratio. PATH ships this at termination; close-to-1 ratio ⇒ all three measures agree, which is what you want at a healthy convergence.
import json
print(json.dumps(r.merit_cross_check, indent=2))
{
"fb_max": 9.99502525189655e-09,
"fb_mean": 9.99502525189655e-09,
"min_map_max": 9.995025404934353e-09,
"min_map_mean": 9.995025404934353e-09,
"inner_product_max": 1.999005075994366e-08,
"inner_product_mean": 1.999005075994366e-08,
"disagreement_ratio": 2.000000025627805
}
A disagreement_ratio larger than ~10² is a warning sign: one merit thinks complementarity is satisfied, another doesn’t, usually because \(G\) and \(H\) are scaled very differently. Pair scaling (autoscale=True) usually fixes it.
Active-Jacobian degeneracy¶
result.degeneracy_report aggregates row/column norms of the active-constraint Jacobian, the smallest singular value, and the merit disagreement ratio:
print(json.dumps(r.degeneracy_report, indent=2))
{
"n_biactive": 0,
"n_zero_rows": 0,
"n_zero_cols": 1,
"min_singular_value": 0.0,
"merit_disagreement_ratio": 2.000000025627805
}
A min_singular_value near zero or n_zero_rows > 0 indicates rank deficiency — usually a sign that the active set has redundant rows or that a constraint is structurally vacuous.
Initial-point quality¶
Set before any solve work runs, result.initial_point_stats reports residuals at the user’s x0:
print(json.dumps(r.initial_point_stats, indent=2))
{
"comp_residual": 0.25,
"min_map_residual": 0.5,
"max_bound_violation": 0.0,
"ineq_residual": 0.0,
"eq_residual": 0.0
}
Useful for sanity-checking that your x0 doesn’t catastrophically violate the problem before the solver even starts.
Cost: when to enable diagnostics¶
diagnostics=True adds:
one matrix factorisation (CQ classification, ~
O(m_active · n²)),up to \(2^{|I_{00}|}\) LPs (B-stationarity — capped by
b_stat_max_biactive=10in the solver kwargs),one eigendecomposition on a reduced Hessian (SOSC).
For interactive analysis: always on. For tight inner loops (parametric sweeps, RL training): off — the convergence info on result is enough most of the time.