Practitioner-first post-optimization analysis for linear, quadratic, and mixed-integer optimization.
Project description
pyoptexplain
Practitioner-first post-optimality analysis and explanation for linear, quadratic, and mixed-integer optimization.
pyoptexplain sits above the modeling languages used to build a model and the
solvers used to solve it. It is not a modeling language and not a solver. A model
authored in any supported front end is adapted into one normalized internal
representation, solved through a choice of backends, and interrogated through a
single uniform interface: which constraints bind, what the solution is worth at
the margin, how it moves under perturbation, and how it changes under what-if
scenarios.
Its guiding principle is honesty. A post-optimality quantity is reported only when both the representation and the chosen backend can justify it; unavailable information is reported as unavailable rather than approximated.
Install
pip install pyoptexplain
The base install ships NumPy, SciPy, pandas, xarray, and the HiGHS and OSQP solvers, so direct-matrix LP, MILP, and QP workflows run immediately. Front ends and extra backends are optional extras:
| Extra | Pulls in | Use for |
|---|---|---|
pyoptexplain[cvxpy] |
cvxpy | cvxpy model inspection |
pyoptexplain[pyomo] |
Pyomo | Pyomo model inspection |
pyoptexplain[gurobi] |
gurobipy | Gurobi front end + backend |
pyoptexplain[cplex] |
cplex, docplex | CPLEX front end + backend |
pyoptexplain[ortools] |
ortools | OR-Tools front end |
pyoptexplain[scip] |
pyscipopt | SCIP backend (default MIQP) |
pyoptexplain[plot] |
matplotlib | grid plotting helpers |
Optional commercial solvers (Gurobi, CPLEX) still require their own licenses. All optional imports are lazy, so the base install stays light.
Quickstart
A two-product factory that maximizes profit subject to two resource capacities:
from pyoptexplain import Analyzer, LinearMatrixProblemHandle
handle = LinearMatrixProblemHandle(
sense="max",
c=[40.0, 30.0],
A_ub=[[2.0, 1.0], [1.0, 2.0]],
b_ub=[100.0, 80.0],
bounds=[(0.0, None), (0.0, None)],
variable_names=["chairs", "tables"],
inequality_names=["labor", "material"],
)
analyzer = Analyzer(handle.linear_representation())
analyzer.summary() # status, objective, primal values
analyzer.constraints() # binding, slack, and shadow price per named block
analyzer.duals() # block-level duals
analyzer.rhs_ranges() # classical sensitivity (HiGHS / Gurobi / CPLEX)
The optimum makes 40 chairs and 20 tables for a profit of 2200, with both capacities binding and priced at their shadow values (labor 16.67, material 6.67).
The pipeline
pyoptexplain is a single, explicit pipeline. Each stage is chosen by hand and
never collapsed into the next:
user-authored model -> ProblemHandle -> AnalysisRepresentation -> Analyzer
|
RepresentationCertificate
ProblemHandlewraps an already-built model, preserves its native identity, and exposes the representations it can honestly support (basic_representation(),linear_representation(),quadratic_representation(),scenario_representation()).AnalysisRepresentationis the normalized, provenance-agnostic surface the analyses run on. The same LP is analyzed identically whether it came from a raw matrix, a cvxpy inspection, or a Pyomo model.RepresentationCertificateis the trust-and-provenance record: a status (verified/asserted/unknown), the capabilities the representation supports, and the mappings from each user-level block to the canonical rows it lowered into.Analyzeris the single user-facing gateway. It is a factory:Analyzer(representation)returns a representation-specific subclass exposing only the methods that representation can actually run.
The user's unit of meaning is the block. A ConstraintBlock is one
constraint as written, a VariableBlock its variable-side peer. A block may lower
into several canonical rows (abs(x) <= b becomes two rows), but every report and
experiment targets the named block, not the rows.
Certificate-gated honesty
Analysis is gated at three levels, so the library never guesses:
- Method presence — an unsupported operation is simply absent on the returned
analyzer.
reduced_costs()exists only on a linear analyzer,run_scenarios()only on a scenario analyzer. - Certificate — structural analyses (LP/QP duals, classical sensitivity,
integrality relaxation, ...) require a
verifiedorassertedstatus. - Backend — the configured backend must actually implement the diagnostic.
Analyzer.capabilities() reports the effective set as the intersection of the
certificate and the backend. Unavailable information stays explicitly unavailable,
reported as None, NaN, or an UnsupportedFeature.
A progression of questions
The analyses form a progression, ordered by how far they depart from the problem as originally stated.
Solve report — the static view of the current optimum:
analyzer.summary()
analyzer.constraints()
analyzer.duals()
analyzer.dual_details() # per-row component duals for multi-row blocks
analyzer.representation_mappings() # how each block lowered into canonical rows
For linear representations on a basis-exposing backend (HiGHS, Gurobi, CPLEX),
classical sensitivity is available: reduced_costs(), basis_status(),
rhs_ranges(), objective_ranges(). A mixed-integer optimum carries no duals, so
valuations are read on an explicitly derived relaxation via lp_relaxation() or
qp_relaxation().
Perturbation analysis — does the solution stay stable under small data changes? Complete parameter families (objective, RHS, LHS, and for QPs the quadratic objective) are perturbed one at a time and re-solved:
robustness = analyzer.perturbation_robustness() # +-1%, +-2%, +-5%, +-10% by default
robustness.summary()
Scenarios and grids — change the model itself and compare against the baseline. A scenario is a typed, ordered set of changes; a grid is the Cartesian product of scenario axes:
from pyoptexplain import (
Analyzer, ChangeRHS, GridAxis,
LinearMatrixScenarioRepresentation, ScenarioCase, SetObjectiveCoefficient,
)
scenario_analyzer = Analyzer(
LinearMatrixScenarioRepresentation.from_matrix(handle.linear_representation())
)
scenario_analyzer.run_scenarios({
"base": ScenarioCase(),
"pricier_chairs": ScenarioCase((SetObjectiveCoefficient("chairs", 55.0),)),
"less_material": ScenarioCase((ChangeRHS("material", delta=-20.0),)),
})
scenario_analyzer.explore_grid([
GridAxis.rhs_change("material", [10.0, 0.0, -10.0]),
])
The typed changes include RemoveConstraint, RemoveConstraintGroup,
RelaxConstraint, ChangeRHS, RemoveVariable, SetVariableBounds,
ChangeVariableBounds, RelaxIntegrality, SetObjectiveCoefficient,
ChangeObjectiveCoefficient, ScaleQuadraticObjective, ScaleQuadraticDiagonal,
and SetParameter.
One model, many front ends
The same analysis surface is recovered no matter how the model was written. Wrap a
native model in the matching handle and call linear_representation() or
quadratic_representation(); everything after the handle is identical.
import cvxpy as cp
from pyoptexplain import Analyzer, CvxpyProblemHandle
chairs = cp.Variable(nonneg=True, name="chairs")
tables = cp.Variable(nonneg=True, name="tables")
labor = 2 * chairs + tables <= 100
material = chairs + 2 * tables <= 80
problem = cp.Problem(cp.Maximize(40 * chairs + 30 * tables), [labor, material])
handle = CvxpyProblemHandle(
problem,
variables={"chairs": chairs, "tables": tables},
constraints={"labor": labor, "material": material},
)
Analyzer(handle.linear_representation()).duals() # same answer as the matrix path
| Front end | Handle | Notes |
|---|---|---|
| Direct matrix | LinearMatrixProblemHandle, QuadraticMatrixProblemHandle |
structure known by construction |
| cvxpy | CvxpyProblemHandle |
inspects DCP canonicalization; scenarios via cp.Parameter |
| Pyomo | PyomoProblemHandle |
algebraic inspection; in-place RHS/bound/ablation scenarios |
| Gurobi | GurobiProblemHandle |
direct-model scenarios (obj/bound/RHS, in-place ablation) |
| CPLEX | CplexProblemHandle |
direct-model scenarios |
| OR-Tools | OrToolsProblemHandle |
pywraplp (LP/MILP) or MathOpt (also QP/MIQP); scenarios via the matrix path |
Where a native object cannot be extracted to a matrix (nonlinear, conic), the basic and native scenario paths still apply; structural matrix analyses do not.
Solver backends
Structured representations are provenance-agnostic and accept any compatible backend, defaulting sensibly per problem class:
| Problem class | Default backend | Also supported |
|---|---|---|
| LP / MILP | HiGHSBackend |
GurobiBackend, CPLEXBackend, SCIPBackend |
| QP | OSQPBackend |
GurobiBackend, CPLEXBackend, SCIPBackend |
| MIQP | SCIPBackend |
GurobiBackend, CPLEXBackend |
from pyoptexplain.solvers import HiGHSBackend, GurobiBackend
Analyzer(handle.linear_representation(), backend=GurobiBackend())
Switching backend is what unlocks different diagnostics: a structured
representation can be sent to whichever backend exposes the information an analysis
needs. Custom backends implement the exported SolverBackend contract.
Scenarios at scale
Repeated what-if studies are the case the architecture optimizes. A scenario representation amortizes the analysis surface across a batch instead of rebuilding and re-solving from scratch each time.
- The structured (matrix) scenario representation extracts the problem into arrays once and builds its certificate once. Structure- and dimension-preserving changes (RHS shifts, inequality relaxation, inequality removal by deactivation, variable-bound changes) edit only the touched arrays on a warm solver session; structure-changing cases fall back to a fresh derivation. Available whenever the problem is extractable.
- The native scenario representation mutates the original modeling object in place and restores it, available even for models that cannot be extracted, and able to apply changes the live object exposes directly (e.g. a Gurobi objective-coefficient edit).
Across LP and QP batches this cuts per-scenario cost several-fold, reaching 10–25×
once a batch exceeds a handful of cases. Solve-dominated integer problems see no
benefit, as expected. See notebooks/scenario_scaling_study.ipynb for the full
study.
Notebooks
notebooks/ holds runnable, end-to-end demonstrations: one per modeling language,
the matrix LP/MILP/QP walkthroughs, the perturbation and scenario workflows, and
the performance and scaling studies. They are the canonical worked examples.
Status and license
First release (0.1.0). Apache-2.0 licensed. Requires Python 3.9+.
Contributions are welcome; see CONTRIBUTING.md.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pyoptexplain-0.1.0.tar.gz.
File metadata
- Download URL: pyoptexplain-0.1.0.tar.gz
- Upload date:
- Size: 165.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.9.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
161f3807434be796ae55d141c2c0ebfb35c57093354ebd52986735cbad51e20c
|
|
| MD5 |
15e9e6f2478d32e53a40884244098ea4
|
|
| BLAKE2b-256 |
98f3e73a08e13ec406dc7ce65c2fe9363c0d799194fc01615d6963d8681cb7e5
|
File details
Details for the file pyoptexplain-0.1.0-py3-none-any.whl.
File metadata
- Download URL: pyoptexplain-0.1.0-py3-none-any.whl
- Upload date:
- Size: 197.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.9.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3742c2541c4cb115f3484eb5aa339a9c078a1e524fe7f18f9ec39c9900819e49
|
|
| MD5 |
5de224ae2d69835554471d4c7fa29a12
|
|
| BLAKE2b-256 |
8050d5b8bed44b60222d609034a6b4944998e63d3e2edce36f5667f400f1a765
|