Metadata-Version: 2.4
Name: pyoptexplain
Version: 0.1.0
Summary: Practitioner-first post-optimization analysis for linear, quadratic, and mixed-integer optimization.
Author: pyoptexplain contributors
License-Expression: Apache-2.0
Project-URL: Homepage, https://github.com/h-fellahi/pyoptexplain
Project-URL: Repository, https://github.com/h-fellahi/pyoptexplain
Project-URL: Issues, https://github.com/h-fellahi/pyoptexplain/issues
Classifier: Development Status :: 5 - Production/Stable
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy
Requires-Dist: scipy
Requires-Dist: pandas
Requires-Dist: highspy
Requires-Dist: osqp
Requires-Dist: xarray
Provides-Extra: cvxpy
Requires-Dist: cvxpy; extra == "cvxpy"
Provides-Extra: gurobi
Requires-Dist: gurobipy; extra == "gurobi"
Provides-Extra: highs
Requires-Dist: highspy; extra == "highs"
Provides-Extra: osqp
Requires-Dist: osqp; extra == "osqp"
Provides-Extra: cplex
Requires-Dist: cplex; extra == "cplex"
Requires-Dist: docplex; extra == "cplex"
Provides-Extra: scip
Requires-Dist: pyscipopt>=4.2.0; extra == "scip"
Provides-Extra: pyomo
Requires-Dist: pyomo; extra == "pyomo"
Provides-Extra: ortools
Requires-Dist: ortools>=9.8; extra == "ortools"
Provides-Extra: plot
Requires-Dist: matplotlib; extra == "plot"
Provides-Extra: dev
Requires-Dist: build; extra == "dev"
Requires-Dist: pytest; extra == "dev"
Requires-Dist: pyflakes; extra == "dev"
Requires-Dist: twine; extra == "dev"
Dynamic: license-file

# 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

```bash
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:

```python
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:

```text
user-authored model  ->  ProblemHandle  ->  AnalysisRepresentation  ->  Analyzer
                                                  |
                                         RepresentationCertificate
```

- **`ProblemHandle`** wraps an already-built model, preserves its native identity,
  and exposes the representations it can honestly support
  (`basic_representation()`, `linear_representation()`,
  `quadratic_representation()`, `scenario_representation()`).
- **`AnalysisRepresentation`** is 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.
- **`RepresentationCertificate`** is 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.
- **`Analyzer`** is 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:

1. **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.
2. **Certificate** — structural analyses (LP/QP duals, classical sensitivity,
   integrality relaxation, ...) require a `verified` or `asserted` status.
3. **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:

```python
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:

```python
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:

```python
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.

```python
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` |

```python
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](CONTRIBUTING.md).
