Skip to content

CVaR Phase 2/3: CLI + decomposition, maximize support, and docs#751

Merged
DLWoodruff merged 7 commits into
Pyomo:mainfrom
DLWoodruff:cvar-phase2-3
Jun 27, 2026
Merged

CVaR Phase 2/3: CLI + decomposition, maximize support, and docs#751
DLWoodruff merged 7 commits into
Pyomo:mainfrom
DLWoodruff:cvar-phase2-3

Conversation

@DLWoodruff

Copy link
Copy Markdown
Collaborator

What

Combined Phase 2 + Phase 3 of CVaR risk management (follow-on to #746, which landed the core transform and EF tests). Per the design doc doc/designs/cvar_design.md, this adds the command-line / generic_cylinders surface, completes maximize support, and ships the user docs — so the risk-averse objective λ·E[Cost] + β·CVaR_α(Cost) is usable end-to-end across the EF solve and every cylinder.

Phases 2 and 3 are combined into one PR at the maintainer's request. Opened as a draft.

CLI + decomposition (Phase 2)

  • config: cvar_args() adds --cvar, --cvar-weight (β), --cvar-alpha (α), --cvar-mean-weight (λ); wired into mpisppy/generic/parsing.py.
  • generic_cylinders: when --cvar is set, scenario_creator is wrapped with cvar_scenario_creator (after the ADMM block; guarded against bundles/ADMM, which it can't yet compose with). Because η is appended to the root node it is "just another first-stage variable," so EF, PH/APH, Lagrangian, subgradient, FWPH, and xhat all inherit risk aversion with no algorithm changes.
  • example: examples/farmer/farmer_generic.bash and examples/run_all.py gain risk-averse --cvar invocations.

Maximize support (Phase 3)

add_cvar now handles maximization via the PySP lower-tail mirror: the excess variable becomes NonPositiveReals and the excess constraint flips to δ_s ≤ Cost_s − η. The objective expression and sense are unchanged, so the same --cvar flags maximize λ·E[Reward] + β·CVaR_α of the worst-case (lowest) rewards. This replaces the Phase 1 NotImplementedError guard (which addressed @bknueven's review on #746).

Setting rho with CVaR (important)

The VaR variable η has a much larger cost scale than typical model variables (it lives on the objective scale), so a uniform rho stalls PH. Measured on the 3-scenario farmer (--cvar-weight 2 --cvar-alpha 0.8, EF-CVaR optimum -220700):

rho strategy result after up to 100 PH iters
--default-rho 1 40% gap — bounds never close (η stuck)
--grad-rho --grad-order-stat 0.5 converges to the optimum (0% gap)
--sep-rho converges to the optimum (0% gap)

The new docs and example scripts recommend and use a cost-aware rho.

Docs (Phase 3)

New doc/src/risk_management.rst (registered in the toctree): CLI + programmatic usage, the rho/η guidance above, the maximize note, a zhat4xhat confidence-interval note (it evaluates whichever objective is active, so it automatically uses the risk-averse one), and the verbatim single-root-stage / not-time-consistent caveat from design §6.5. CVaR flags also added to generic_cylinders.rst.

Tests

Extended existing (already CI- and run_coverage.bash-wired) files:

  • test_cvar.py: maximize closed-form EF (lower-tail) + pure CVaR, a structural maximize check, and a serial PH-on-CVaR run asserting a valid (rho-independent) outer bound. The maximize closed form: rewards {10,20,30,40} uniform, α=0.6 ⇒ E=25, lower-VaR η*=20, lower-CVaR=13.75.
  • test_generic_cylinders.py: --EF --cvar through the CLI matches a directly-built EF-CVaR objective.
  • test_with_cylinders.py: PH hub + Lagrangian (outer) and + xhatshuffle (inner) bracket the EF-CVaR optimum — the rho-independent bound sandwich.

All green locally (serial via pytest; test_with_cylinders.py via mpiexec -np 2); ruff clean; docs build succeeds.

🤖 Generated with Claude Code

DLWoodruff and others added 2 commits June 13, 2026 15:14
Adds the command-line / generic_cylinders surface for the CVaR transform
and completes maximize support, so risk aversion is available end-to-end.

- config: cvar_args() adds --cvar / --cvar-weight / --cvar-alpha /
  --cvar-mean-weight; wired into generic/parsing.py.
- generic_cylinders: when --cvar is set, wrap scenario_creator with
  cvar_scenario_creator (guarded against bundles/ADMM). eta becomes just
  another first-stage variable, so EF and every cylinder inherit risk
  aversion with no algorithm changes.
- cvar.py: maximize support via the PySP lower-tail mirror -- the excess
  var becomes NonPositiveReals and the excess constraint flips to
  delta <= Cost - eta; the objective expression and sense are unchanged.
  Replaces the Phase 1 NotImplementedError guard.

Tests (extend existing, already wired into CI + run_coverage.bash):
- test_cvar.py: maximize closed-form EF (lower-tail) + pure CVaR, a
  structural maximize check, and a serial PH-on-CVaR run asserting a valid
  (rho-independent) outer bound.
- test_generic_cylinders.py: --EF --cvar matches a direct EF-CVaR build.
- test_with_cylinders.py: PH hub + Lagrangian (outer) and + xhatshuffle
  (inner) bracket the EF-CVaR optimum.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- examples: farmer_generic.bash and run_all.py gain risk-averse (--cvar)
  invocations.
- docs: new doc/src/risk_management.rst (registered in the toctree), CVaR
  flags in generic_cylinders.rst.

The docs emphasize that the VaR variable eta has a much larger cost scale
than typical model variables, so a uniform rho stalls PH; a cost-aware rho
(--grad-rho or --sep-rho) is recommended. Verified on the farmer: with
--default-rho 1 the gap sits at 40% after 100 iterations, while --grad-rho
and --sep-rho converge to the EF-CVaR optimum. The example scripts use
--grad-rho accordingly. The Risk Management section also carries the
verbatim single-root-stage / not-time-consistent caveat and the zhat4xhat
note. Design doc reconciled (maximize implemented, phases 2/3 combined,
rho guidance recorded).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Jun 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.75000% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 74.63%. Comparing base (32190ff) to head (0f94c09).

Files with missing lines Patch % Lines
mpisppy/generic_cylinders.py 80.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #751      +/-   ##
==========================================
+ Coverage   74.59%   74.63%   +0.03%     
==========================================
  Files         166      166              
  Lines       21532    21544      +12     
==========================================
+ Hits        16062    16079      +17     
+ Misses       5470     5465       -5     

☔ 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.

@DLWoodruff DLWoodruff marked this pull request as ready for review June 14, 2026 14:34
Comment thread mpisppy/tests/test_with_cylinders.py Outdated
ef_opt = self._ef_cvar_opt()
self.assertIsNotNone(wheel.BestOuterBound)
# outer (lower) bound for this minimization
self.assertLessEqual(wheel.BestOuterBound, ef_opt + 1e-4 * abs(ef_opt))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason for the additional fudge factor? I wouldn't mind 1e-10 or so, but 1e-4 seems like a bug.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — no real reason, the relative fudge factor was overkill and could mask a genuinely wrong-side bound. Tightened to 1e-8 * abs(ef_opt) (just round-off absorption) in 120cbe8; both CVaR tests still pass.

Comment thread mpisppy/tests/test_with_cylinders.py Outdated
ef_opt = self._ef_cvar_opt()
self.assertIsNotNone(wheel.BestInnerBound)
# inner (upper) bound for this minimization
self.assertGreaterEqual(wheel.BestInnerBound, ef_opt - 1e-4 * abs(ef_opt))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same question here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same fix here — now 1e-8 * abs(ef_opt) in 120cbe8.

DLWoodruff and others added 3 commits June 26, 2026 18:37
… review)

The CVaR sandwich tests assert that the Lagrangian outer bound and the
xhatshuffle inner bound sit on the correct side of the EF-CVaR optimum.
These inequalities hold exactly up to solver round-off, so the slack
should be a tiny numerical epsilon, not a 1e-4 relative tolerance (~22
units on this problem) that could mask a genuinely invalid bound.

Per @bknueven's review, drop the relative fudge factor to 1e-8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@bknueven bknueven left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would like to spend more time to understand this better, but that shouldn't hold it up, and I might not get said time for at least another week.

@DLWoodruff DLWoodruff merged commit 4ff5a8b into Pyomo:main Jun 27, 2026
32 checks passed
@DLWoodruff DLWoodruff deleted the cvar-phase2-3 branch June 27, 2026 02:26
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.

2 participants