Skip to content

CVaR: bound the Value-at-Risk variable eta#795

Open
DLWoodruff wants to merge 3 commits into
Pyomo:mainfrom
DLWoodruff:cvar-eta-bound
Open

CVaR: bound the Value-at-Risk variable eta#795
DLWoodruff wants to merge 3 commits into
Pyomo:mainfrom
DLWoodruff:cvar-eta-bound

Conversation

@DLWoodruff

@DLWoodruff DLWoodruff commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

Gives the CVaR Value-at-Risk variable eta a valid automatic bound. Until now
eta was a completely free variable living on the objective scale.

eta's optimum is VaR_alpha(Cost), which is provably within the range of the
cost over all scenarios, so bounding eta by 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 eta free that relaxed subproblem is easily unbounded and its solves
fail.

What's added

Two automatic methods, selected by --cvar-eta-bound-method (default fbbt):

  • fbbt — structural per-scenario cost range via feasibility-based bounds
    tightening (no solves).
  • solve — find the cost range by solving, treating the two ends
    differently:
    • the easy (slack) side — the min cost for a minimize, the max reward for a
      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.
    • the worst-case side — the max cost for a minimize, the min reward for a
      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 (which
      minimizes E[Cost]), and take max_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 middle
      step using E(x^RN) <= E(x*).

Finding x^RN is a coupled solve — one common first-stage decision — so
solve builds and solves the risk-neutral extensive form. That is only
tractable when the EF is, which is exactly when you would not need decomposition,
so solve is a convenience for small/medium models where fbbt leaves the
worst-case side unbounded but one EF solve is affordable. The solve is
time-boxed by --cvar-eta-solve-time-limit (default 60s); if it does not
reach 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 fbbt or an explicit --cvar-eta-ub / --cvar-eta-lb instead.

New flags:

  • --cvar-eta-lb / --cvar-eta-ub — explicit overrides (each wins on its side).
  • --cvar-eta-bound-methodfbbt (default) / solve / none.
  • --cvar-eta-mipgap — the gap for the solve solves; for the worst-case side
    it 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 (default
    60); if it does not finish in time the worst-case side is left free (<= 0
    skips it).

Implementation

  • add_cvar computes/stashes the FBBT cost range and applies any explicit
    override to eta immediately.
  • set_cvar_eta_bounds reduces the stashed ranges to the global [lb, ub] over
    the ROOT communicator and bounds eta (only user-unset, finite sides).
  • SPBase.__init__ calls it once per opt object — EF and every cylinder — and is
    a no-op for non-CVaR runs.
  • compute_cvar_eta_bounds_by_solve implements the solve method: the easy side
    is 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_cylinders runs it once up front and injects the result. A
    portable time_limit key was added to translate_solver_options (gurobi
    TimeLimit / cplex timelimit / xpress maxtime; HiGHS passes through).

Scope / limitation

solve's worst-case side needs an EF-tractable risk-neutral problem, so it is a
small/medium convenience and is time-boxed. For a genuinely large model use
fbbt or an explicit --cvar-eta-ub / --cvar-eta-lb. When fbbt cannot bound
a side and the EF is too big to solve in time, supply that side by hand; the docs
work the farmer both ways (small: solve bounds 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 solve method — the tiny cost range, the maximize mirror, the
    risk-neutral-point tail being finite where max-over-region was unbounded,
    a time_limit <= 0 leaving the tail free, and both sides of the farmer
    being bounded (worst-case side matching an independent risk-neutral EF solve).
  • Verified the distributed easy-side reduction + tail broadcast + time-limit
    skip under mpiexec -np 2 (ranks agree), and end-to-end on a big-M aircond
    EF that the worst-case side is now finite (was unbounded) and the EF still
    solves.

Docs

"Bounding the Value-at-Risk variable eta" section in
doc/src/risk_management.rst rewritten for the risk-neutral-point method and the
gate, with the farmer worked both automatically (small) and by hand (large).

🤖 Generated with Claude Code

@codecov

codecov Bot commented Jul 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.50314% with 31 lines in your changes missing coverage. Please review.
✅ Project coverage is 76.19%. Comparing base (64a8fff) to head (5bb71cd).

Files with missing lines Patch % Lines
mpisppy/generic_cylinders.py 30.43% 16 Missing ⚠️
mpisppy/utils/cvar.py 88.52% 14 Missing ⚠️
mpisppy/spbase.py 88.88% 1 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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>
DLWoodruff and others added 2 commits July 11, 2026 19:14
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant