CVaR: bound the Value-at-Risk variable eta#795
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #795 +/- ##
==========================================
+ Coverage 76.16% 76.19% +0.02%
==========================================
Files 169 169
Lines 22224 22379 +155
==========================================
+ Hits 16928 17052 +124
- Misses 5296 5327 +31 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
8d3f81b to
af0f8b3
Compare
The CVaR VaR variable eta was completely free. eta lives on the objective
scale, so a free eta hurts some solvers, and it makes the Lagrangian /
subgradient outer-bound relaxations (which dualize eta's non-anticipativity)
unbounded, so those spoke solves fail. eta's optimum is VaR_alpha(Cost),
which is provably within the range of Cost over all scenarios, so bounding eta
by the global cost range is valid (non-cutting).
Two automatic methods, chosen by --cvar-eta-bound-method (default fbbt):
- fbbt: structural cost range per scenario via FBBT (no solves).
- solve: per scenario, find the cost range by solving, treating the two ends
differently. The "how cheap/good" (slack) side only needs a valid enclosing
value, so a relaxation (LP) is enough. The worst-case ("how bad") side cannot
come from a relaxation -- to bound how bad the true integer problem gets you
solve the MIP -- but only a valid bound is needed, so the MIP runs with a
loose gap (--cvar-eta-mipgap) and we read its dual bound (valid at any gap;
a loose gap only makes it looser, never invalid). The worst-case side is the
max cost for a minimize, the min reward for a maximize.
Mechanism:
- add_cvar: compute this scenario's FBBT cost range and stash it; apply any
explicit eta_lb/eta_ub override to eta immediately.
- set_cvar_eta_bounds: reduce the stashed per-scenario ranges to the global
[lb, ub] over the ROOT comm and bound eta. The bound MUST be global, not
per-scenario: eta is tied by non-anticipativity, so a per-scenario box could
intersect away the true VaR.
- SPBase.__init__ calls it once per opt object (EF and every cylinder); no-op
for non-CVaR runs.
- compute_cvar_eta_bounds_by_solve: the 'solve' method; generic_cylinders runs
it once up front and injects the result as eta bounds.
- Flags: --cvar-eta-lb/--cvar-eta-ub override a side; --cvar-eta-bound-method
none turns the automatic bound off. When a side is structurally unbounded
(e.g. farmer's purchases) no method can bound it; supply it by hand.
Tests extend test_cvar.py; Risk Management docs get a "Bounding eta" section
with the farmer worked example (solve for the profit side, hand-derive the
unbounded worst-case side).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
af0f8b3 to
fdda57b
Compare
…ution, gated The 'solve' method's worst-case side maximized cost over the feasible region, which is unbounded whenever the model can act arbitrarily wastefully (any big-M formulation, e.g. aircond, or farmer's unlimited purchases), so it returned an unbounded (useless) tail. Instead evaluate the cost at a single feasible point, the risk-neutral solution x^RN (which minimizes E[Cost]), and take max_s Cost_s(x^RN) (min for a maximize). This is finite and never cuts the optimum: eta* = VaR(x*) <= CVaR(x*) <= CVaR(x^RN) <= max_s Cost_s(x^RN), the middle step using E(x^RN) <= E(x*). Finding x^RN is a coupled solve, so this builds and solves the risk-neutral extensive form -- only tractable when the EF is -- so it is gated by the new --cvar-eta-solve-max-scenarios (default 1000) and left free above the gate; large models should use fbbt or an explicit --cvar-eta-lb/ub. The easy side is unchanged (distributed LP relaxation). --cvar-eta-mipgap is now the EF-solve gap (tight default 1e-4). Docstrings, risk_management.rst, and tests updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rio-count gate Scenario count is a crude proxy for EF solve cost (it ignores per-scenario size and integrality), so replace --cvar-eta-solve-max-scenarios with --cvar-eta-solve-time-limit (seconds, default 60): the worst-case-side EF solve is time-boxed, and if it does not reach optimality in time the tail is left free. A timed-out incumbent is discarded, never used, so the bound can never be invalid. time_limit <= 0 skips the EF solve entirely (a deterministic off-switch, also the test hook). Adds a portable 'time_limit' key to translate_solver_options (gurobi TimeLimit / cplex timelimit / xpress maxtime; HiGHS passes through). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Avoid a bare "This" with no clean antecedent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR improves CVaR robustness by ensuring the Value-at-Risk variable eta is no longer an unbounded (“free”) first-stage variable, and adds an optional solve-based mechanism to compute valid global bounds when structural (FBBT) bounds are insufficient.
Changes:
- Add per-scenario FBBT cost-range stashing during CVaR transformation and a global MPI reduction to bound
etasafely across all scenarios. - Add an optional
solve-based bound method (LP relaxation for the slack side + time-boxed risk-neutral EF solve for the tail side) and wire it intogeneric_cylinders. - Add CLI/config flags, expanded docs, and new unit tests covering bound/override behaviors and the solve-based method.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| mpisppy/utils/sputils.py | Adds canonical time_limit solver-option translation for multiple solvers. |
| mpisppy/utils/cvar.py | Implements per-scenario bound stashing, global eta-bound reduction, and solve-based bound computation. |
| mpisppy/utils/config.py | Adds new config/CLI options for eta bounding method and overrides. |
| mpisppy/tests/test_cvar.py | Adds tests for eta bounding (FBBT + overrides) and solve-based bounds. |
| mpisppy/spbase.py | Automatically applies global eta bounds during SPBase initialization for CVaR runs. |
| mpisppy/generic_cylinders.py | Wires --cvar-eta-bound-method solve to compute/inject bounds up front. |
| doc/src/risk_management.rst | Documents eta bounding rationale, methods, flags, and examples. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if not local_scenarios: | ||
| return | ||
| first = next(iter(local_scenarios.values())) | ||
| if not hasattr(first, "_mpisppy_cvar_eta"): | ||
| return # not a CVaR run; nothing to do |
| "time_limit": { | ||
| # seconds; HiGHS already uses "time_limit" so it passes through. | ||
| "gurobi": "TimeLimit", | ||
| "appsi_gurobi": "TimeLimit", | ||
| "cplex": "timelimit", |
Summary
Gives the CVaR Value-at-Risk variable
etaa valid automatic bound. Until nowetawas a completely free variable living on the objective scale.eta's optimum isVaR_alpha(Cost), which is provably within the range of thecost over all scenarios, so bounding
etaby the global[min cost, max cost]is valid (it can never cut off the optimum). Besides helping solvers and keeping
the PH subproblems well posed, this can be necessary for the outer-bound
spokes: a Lagrangian/subgradient relaxation dualizes
eta's non-anticipativity,and with
etafree that relaxed subproblem is easily unbounded and its solvesfail.
What's added
Two automatic methods, selected by
--cvar-eta-bound-method(defaultfbbt):fbbt— structural per-scenario cost range via feasibility-based boundstightening (no solves).
solve— find the cost range by solving, treating the two endsdifferently:
maximize — only needs a valid enclosing value, so a relaxation (LP) over
the feasible region is enough (that side sits far from the VaR). It is finite
for any real model and is solved per scenario, distributed across the ranks.
maximize — cannot come from maximizing cost over the feasible region:
that is unbounded whenever the model can act arbitrarily wastefully (any
big-M formulation, or farmer's unlimited purchases). Instead we evaluate the
cost at a single feasible point, the risk-neutral solution
x^RN(whichminimizes
E[Cost]), and takemax_s Cost_s(x^RN)(min for a maximize).This is finite and never cuts the optimum, because
eta* = VaR(x*) <= CVaR(x*) <= CVaR(x^RN) <= max_s Cost_s(x^RN), the middlestep using
E(x^RN) <= E(x*).Finding
x^RNis a coupled solve — one common first-stage decision — sosolvebuilds and solves the risk-neutral extensive form. That is onlytractable when the EF is, which is exactly when you would not need decomposition,
so
solveis a convenience for small/medium models wherefbbtleaves theworst-case side unbounded but one EF solve is affordable. The solve is
time-boxed by
--cvar-eta-solve-time-limit(default60s); if it does notreach optimality in time the worst-case side is left free (the timed-out solution
is discarded, never used, so the bound can never be invalid). For a genuinely
large model, use
fbbtor an explicit--cvar-eta-ub/--cvar-eta-lbinstead.New flags:
--cvar-eta-lb/--cvar-eta-ub— explicit overrides (each wins on its side).--cvar-eta-bound-method—fbbt(default) /solve/none.--cvar-eta-mipgap— the gap for thesolvesolves; for the worst-case sideit is the risk-neutral EF solve gap, so keep it tight (default
1e-4).--cvar-eta-solve-time-limit— seconds for the coupled EF solve (default60); if it does not finish in time the worst-case side is left free (<= 0skips it).
Implementation
add_cvarcomputes/stashes the FBBT cost range and applies any explicitoverride to
etaimmediately.set_cvar_eta_boundsreduces the stashed ranges to the global[lb, ub]overthe ROOT communicator and bounds
eta(only user-unset, finite sides).SPBase.__init__calls it once per opt object — EF and every cylinder — and isa no-op for non-CVaR runs.
compute_cvar_eta_bounds_by_solveimplements thesolvemethod: the easy sideis a distributed LP relaxation; the worst-case side is the cost range at the
risk-neutral solution from a time-boxed, rank-0 EF solve (broadcast to all
ranks).
generic_cylindersruns it once up front and injects the result. Aportable
time_limitkey was added totranslate_solver_options(gurobiTimeLimit/ cplextimelimit/ xpressmaxtime; HiGHS passes through).Scope / limitation
solve's worst-case side needs an EF-tractable risk-neutral problem, so it is asmall/medium convenience and is time-boxed. For a genuinely large model use
fbbtor an explicit--cvar-eta-ub/--cvar-eta-lb. Whenfbbtcannot bounda side and the EF is too big to solve in time, supply that side by hand; the docs
work the farmer both ways (small:
solvebounds both sides automatically; large:the worst-case side hand-derived).
Testing
mpisppy/tests/test_cvar.py(29 tests): the override / auto-off /unbounded-side / global-reduction paths, an EF end-to-end bound check, and for
the
solvemethod — the tiny cost range, the maximize mirror, therisk-neutral-point tail being finite where max-over-region was unbounded,
a
time_limit <= 0leaving the tail free, and both sides of the farmerbeing bounded (worst-case side matching an independent risk-neutral EF solve).
skip under
mpiexec -np 2(ranks agree), and end-to-end on a big-MaircondEF that the worst-case side is now finite (was
unbounded) and the EF stillsolves.
Docs
"Bounding the Value-at-Risk variable eta" section in
doc/src/risk_management.rstrewritten for the risk-neutral-point method and thegate, with the farmer worked both automatically (small) and by hand (large).
🤖 Generated with Claude Code