Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 31 additions & 20 deletions doc/designs/cvar_design.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,15 @@ def add_cvar(scenario, *, cvar_weight, cvar_alpha, cvar_mean_weight=1.0):
obj = sputils.find_active_objective(scenario) # pristine user cost objective
cost = obj.expr
sense = obj.sense
if sense != pyo.minimize: # maximize mirrors PySP (§6.3) — not yet shipped
raise NotImplementedError("CVaR for maximization is not yet implemented")
scenario._mpisppy_cvar_eta = pyo.Var()
scenario._mpisppy_cvar_excess = pyo.Var(domain=pyo.NonNegativeReals)
scenario._mpisppy_cvar_excess_con = pyo.Constraint(
expr=scenario._mpisppy_cvar_excess >= cost - scenario._mpisppy_cvar_eta)
if sense == pyo.minimize: # upper (cost) tail
scenario._mpisppy_cvar_excess = pyo.Var(domain=pyo.NonNegativeReals)
scenario._mpisppy_cvar_excess_con = pyo.Constraint(
expr=scenario._mpisppy_cvar_excess >= cost - scenario._mpisppy_cvar_eta)
else: # lower (reward) tail mirror (§6.3)
scenario._mpisppy_cvar_excess = pyo.Var(domain=pyo.NonPositiveReals)
scenario._mpisppy_cvar_excess_con = pyo.Constraint(
expr=scenario._mpisppy_cvar_excess <= cost - scenario._mpisppy_cvar_eta)
# keep the original risk-neutral objective (deactivated) for E[Cost] reporting;
# the new objective is named WITH_CVAR (NOT PySP's "MASTER" — see §3).
obj.deactivate()
Expand Down Expand Up @@ -178,11 +181,16 @@ A single insertion point → `do_EF`, `do_decomp`, and every spoke inherit the r
For multistage problems it therefore measures risk of the total cost only.
**If a time-consistent (nested) risk measure is needed, users should contact the developers.**
This caveat MUST appear verbatim in the user-facing docs — see the doc note below and §8/§9.
3. **Minimize first.** Phase 1 raises `NotImplementedError` for a maximization objective rather
than silently building the wrong (upper-tail) model. Maximize is then handled by mirroring PySP
(δ domain `NonPositiveReals`, negate the excess expression); ship in a later phase.
3. **Both senses supported.** Minimization penalizes the upper (cost) tail
(δ ≥ Cost−η, δ ≥ 0). Maximization mirrors PySP onto the lower (reward) tail
(δ ≤ Cost−η, δ ≤ 0); the objective expression and its sense are unchanged.
(Phase 1 shipped minimize-only and raised `NotImplementedError` for maximize;
the combined Phase 2/3 PR added the maximize mirror.)
4. **η fixed during xhat evaluation** → valid but possibly loose inner bound. Optional later
refinement: re-optimize the shared η given the fixed "real" first-stage vars.
5. **Setting ρ.** η has a much larger cost scale than typical first-stage variables, so a uniform
proximal ρ stalls PH (e.g. farmer with `--default-rho 1` sits at a 40% gap after 100 iters while
`--grad-rho` / `--sep-rho` converge to 0%). The docs recommend a cost-aware ρ (grad-rho/sep-rho).

### 6.5 Documentation note (verbatim — to ship in the Risk Management docs section, Phase 3)

Expand All @@ -201,21 +209,24 @@ A single insertion point → `do_EF`, `do_decomp`, and every spoke inherit the r
- EF-CVaR (mpi-sppy) vs an independent PySP-style monolithic build.
- β=0 reduces to risk-neutral EF (regression guard).
- α sweep: α→0 ⇒ CVaR ≈ E[Cost]; α→1 ⇒ CVaR ≈ worst case.
- PH-on-CVaR converges to EF-CVaR objective; η consensus → VaR.
- Bound sandwich: Lagrangian outer ≤ EF-CVaR ≤ xhat inner.
- PH-on-CVaR converges to EF-CVaR objective; η consensus → VaR (needs a cost-aware ρ such as
grad-rho/sep-rho — a uniform ρ stalls because η's cost scale dwarfs the other variables').
- Bound sandwich: Lagrangian outer ≤ EF-CVaR ≤ xhat inner (rho-independent; this is what the
cylinder test asserts).

## 8. Phased rollout (each phase a review-sized, green-on-its-own PR)

- **Phase 1 — core + EF.** `cvar.py` (`add_cvar` incl. `cvar_mean_weight` + wrapper);
`tests/test_cvar.py` comparing EF-CVaR to closed form, the β=0 regression, and pure CVaR (λ=0).
Programmatic API only. Maximization raises `NotImplementedError` (full support is Phase 3).
- **Phase 2 — CLI + decomposition.** `cvar_args()` in config (`--cvar`, `--cvar-weight`,
`--cvar-alpha`, `--cvar-mean-weight`); `generic_cylinders` wiring; a farmer risk-averse example; a
cylinders test (PH hub + Lagrangian + xhat bound sandwich). Wire the new test into
`run_coverage.bash` AND `test_pr_and_main.yml` in the same commit.
- **Phase 3 — polish.** maximize support; docs section ("Risk Management") that MUST include the
single-root-stage / not-time-consistent caveat verbatim from §6.5; confidence-interval note
(`zhat4xhat` evaluates the same risk-averse objective).
- **Phase 1 — core + EF (DONE, PR #746).** `cvar.py` (`add_cvar` incl. `cvar_mean_weight` +
wrapper); `tests/test_cvar.py` comparing EF-CVaR to closed form, the β=0 regression, and pure
CVaR (λ=0). Programmatic API only; minimize-only (maximize raised `NotImplementedError`).
- **Phases 2 + 3 — CLI, decomposition, maximize, docs (combined PR).** `cvar_args()` in config
(`--cvar`, `--cvar-weight`, `--cvar-alpha`, `--cvar-mean-weight`); `generic_cylinders` wiring;
maximize support (lower-tail mirror, replacing the Phase 1 guard); a farmer risk-averse example
(`farmer_generic.bash` + `run_all.py`, using `--grad-rho`); the maximize closed-form, serial-PH,
CLI, and cylinder bound-sandwich tests added to existing test files (already wired into CI and
`run_coverage.bash`); and a "Risk Management" docs section that includes the single-root-stage /
not-time-consistent caveat verbatim from §6.5, the ρ-with-η guidance, and the `zhat4xhat`
confidence-interval note. (The two phases were combined into one PR at the maintainer's request.)

## 9. Files touched

Expand Down
16 changes: 16 additions & 0 deletions doc/src/generic_cylinders.rst
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,22 @@ See :ref:`rho_setting` for a full description of all rho-related options,
including ``--default-rho``, ``--sep-rho``, ``--coeff-rho``,
``--sensi-rho``, ``--grad-rho``, and adaptive rho updaters.

Risk Management (CVaR)
----------------------

A risk-averse CVaR objective can be requested with ``--cvar``:

- ``--cvar`` -- apply the CVaR transform to every scenario
- ``--cvar-weight`` -- weight on CVaR, :math:`\beta` (default ``1.0``)
- ``--cvar-alpha`` -- confidence level :math:`\alpha`, ``0 < alpha < 1``
(default ``0.95``)
- ``--cvar-mean-weight`` -- weight on the expectation, :math:`\lambda`
(default ``1.0``; use ``0`` for pure CVaR)

Because the Value-at-Risk variable has a much larger cost scale than typical
model variables, a cost-aware rho such as ``--grad-rho`` or ``--sep-rho`` is
strongly recommended; see :ref:`risk management` for details and an example.

Extensions via Command Line
----------------------------

Expand Down
1 change: 1 addition & 0 deletions doc/src/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ MPI is used.
access_solutions.rst
confidence_intervals.rst
zhat.rst
risk_management.rst
seqsamp.rst

.. toctree::
Expand Down
156 changes: 156 additions & 0 deletions doc/src/risk_management.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
.. _risk management:

Risk Management (CVaR)
======================

mpi-sppy supports minimizing (or maximizing) a risk-averse objective that
blends the expected cost with the Conditional Value-at-Risk (CVaR, also called
expected shortfall) of the cost:

.. math::

\lambda \cdot \mathbb{E}[\text{Cost}] \;+\; \beta \cdot \text{CVaR}_\alpha(\text{Cost})

This is the same "weighted CVaR" formulation used by PySP, where
:math:`\alpha \in (0,1)` is the confidence level, :math:`\beta \ge 0` is the
weight on CVaR, and :math:`\lambda \ge 0` is the weight on the expectation.

How it works
------------

CVaR is added as a per-scenario model transform using the Rockafellar--Uryasev
linearization. For each scenario :math:`s` a non-negative excess variable
:math:`\delta_s` is added together with a single shared Value-at-Risk variable
:math:`\eta`:

.. math::

\text{minimize} \quad
\lambda \, \text{Cost}_s + \beta\, \eta
+ \frac{\beta}{1-\alpha}\, \delta_s
\qquad \text{s.t.} \quad \delta_s \ge \text{Cost}_s - \eta,\;\; \delta_s \ge 0 .

Because :math:`\sum_s p_s = 1` and :math:`\eta` is a single first-stage
(non-anticipative) variable, the risk measure distributes over scenarios and
:math:`\eta` becomes *"just another first-stage variable."* The original
risk-neutral objective is deactivated (but left on the model, so it remains
available for separate :math:`\mathbb{E}[\text{Cost}]` reporting) and replaced
by a new active objective. As a result the extensive form (EF) and **every
cylinder** (PH/APH hub, Lagrangian and subgradient outer-bound spokes, xhat
inner-bound spokes, FWPH, ...) inherit risk aversion with no algorithm changes.

Using it from the command line
------------------------------

With ``generic_cylinders`` (or any driver that calls ``cvar_args``), add
``--cvar`` and, optionally, the weights:

.. code-block:: bash

# EF solve of the risk-averse farmer
python ../../mpisppy/generic_cylinders.py --module-name farmer --num-scens 3 \
--EF --EF-solver-name gurobi --cvar --cvar-weight 2.0 --cvar-alpha 0.8

The flags are:

* ``--cvar`` -- apply the CVaR transform to every scenario (default off).
* ``--cvar-weight`` -- :math:`\beta`, the weight on CVaR (default ``1.0``).
* ``--cvar-alpha`` -- the confidence level :math:`\alpha`, ``0 < alpha < 1``
(default ``0.95``).
* ``--cvar-mean-weight`` -- :math:`\lambda`, the weight on
:math:`\mathbb{E}[\text{Cost}]` (default ``1.0``); use ``0`` for pure CVaR.

Using it programmatically
-------------------------

Wrap any ``scenario_creator`` with ``cvar_scenario_creator`` (or call
``add_cvar`` on an already-built scenario):

.. code-block:: python

import mpisppy.utils.cvar as cvar

risk_creator = cvar.cvar_scenario_creator(
my_scenario_creator, cvar_weight=2.0, cvar_alpha=0.8)
# risk_creator now has the same signature as my_scenario_creator and can be
# passed to create_EF, the PH constructor, the cfg_vanilla factories, etc.

.. _cvar rho:

Setting rho with CVaR (important)
---------------------------------

.. warning::

The Value-at-Risk variable :math:`\eta` typically has a **very different cost
profile** from the model's other first-stage variables: it lives on the scale
of the *objective* (often orders of magnitude larger than, say, an acreage or
a production quantity). A single uniform proximal :math:`\rho` that is
reasonable for the original variables is usually far too small for
:math:`\eta`, so Progressive Hedging barely moves :math:`\eta` and converges
extremely slowly (or not at all within an iteration budget).

The robust fix is to let mpi-sppy choose a per-variable :math:`\rho` from the
cost coefficients. **We recommend gradient-based rho** (``--grad-rho``), and
``--sep-rho`` is another good cost-aware option. For example, on the
three-scenario farmer with ``--cvar --cvar-weight 2.0 --cvar-alpha 0.8`` (whose
EF-CVaR optimum is ``-220700``):

.. list-table::
:header-rows: 1

* - rho strategy
- result after up to 100 PH iterations
* - ``--default-rho 1``
- 40% gap; the bounds never close because :math:`\eta` is stuck
* - ``--grad-rho --grad-order-stat 0.5``
- converges to the optimum (0% gap)
* - ``--sep-rho``
- converges to the optimum (0% gap)

So a risk-averse PH run on the farmer looks like:

.. code-block:: bash

mpiexec -np 3 python -m mpi4py ../../mpisppy/generic_cylinders.py \
--module-name farmer --num-scens 3 --solver-name gurobi \
--max-iterations 100 --default-rho 1 --grad-rho --grad-order-stat 0.5 \
--lagrangian --xhatshuffle --rel-gap 1e-6 \
--cvar --cvar-weight 2.0 --cvar-alpha 0.8

See :ref:`rho_setting` for the full menu of rho strategies.

Maximization
------------

Maximization objectives are supported as well. Risk aversion then applies to the
*lower* (reward) tail: the excess variable becomes non-positive and the excess
constraint mirrors (:math:`\delta_s \le \text{Cost}_s - \eta`,
:math:`\delta_s \le 0`), so the same ``--cvar`` flags maximize
:math:`\lambda\,\mathbb{E}[\text{Reward}] + \beta\,\text{CVaR}_\alpha`
of the worst-case (lowest) rewards.

Confidence intervals
--------------------

The ``zhat4xhat`` program (see :ref:`zhat introduction`) evaluates whichever
objective is active in each scenario. Because the CVaR transform leaves the
risk-averse objective active, ``zhat4xhat`` automatically evaluates the
risk-averse objective for a candidate ``xhat`` -- no extra flags are needed --
so the resulting interval reflects the risk-averse value rather than the
risk-neutral cost.

Scope and limitations
---------------------

.. admonition:: Scope: single root-stage CVaR, not time-consistent

mpi-sppy's CVaR support uses a single Value-at-Risk variable :math:`\eta` at
the root node and applies CVaR to the *total* (end-of-horizon) scenario cost.
This is the same formulation as PySP. It is a single-period risk measure on
the total cost and is **not** a nested / time-consistent multistage risk
measure (there is no per-stage :math:`\eta` and no recursive composition
across stages). For two-stage problems this is the usual CVaR; for multistage
problems it measures the risk of the total cost only. **If you need a
time-consistent (nested) risk measure, please contact the mpi-sppy
developers.**
13 changes: 12 additions & 1 deletion examples/farmer/farmer_generic.bash
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,16 @@ python ../../mpisppy/generic_cylinders.py --module-name farmer --num-scens 3 --E

# Here is a very simple PH command that also computes bounds
echo "Starting PH"
mpiexec -np 3 python -m mpi4py ../../mpisppy/generic_cylinders.py --module-name farmer --num-scens 3 --solver-name ${SOLVER} --max-iterations 10 --max-solver-threads 4 --default-rho 1 --lagrangian --xhatshuffle --rel-gap 0.01
mpiexec -np 3 python -m mpi4py ../../mpisppy/generic_cylinders.py --module-name farmer --num-scens 3 --solver-name ${SOLVER} --max-iterations 10 --max-solver-threads 4 --default-rho 1 --lagrangian --xhatshuffle --rel-gap 0.01

# Risk-averse (CVaR) variants: minimize cvar-mean-weight*E[Cost] + cvar-weight*CVaR_alpha(Cost).
# Adding --cvar is all it takes; the VaR variable eta is just another first-stage
# variable, so the EF solve and every cylinder inherit risk aversion unchanged.
echo "Starting risk-averse (CVaR) EF"
python ../../mpisppy/generic_cylinders.py --module-name farmer --num-scens 3 --EF --EF-solver-name gurobi --cvar --cvar-weight 2.0 --cvar-alpha 0.8

# For PH, eta has a much larger cost scale than the acreage variables, so a
# uniform rho stalls; use a cost-aware rho (--grad-rho here). See the docs.
echo "Starting risk-averse (CVaR) PH"
mpiexec -np 3 python -m mpi4py ../../mpisppy/generic_cylinders.py --module-name farmer --num-scens 3 --solver-name ${SOLVER} --max-iterations 100 --max-solver-threads 4 --default-rho 1 --grad-rho --grad-order-stat 0.5 --lagrangian --xhatshuffle --rel-gap 1e-6 --cvar --cvar-weight 2.0 --cvar-alpha 0.8

10 changes: 10 additions & 0 deletions examples/run_all.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,16 @@ def do_one_mmw(dirname, modname, runefstring, npyfile, mmwargstring):
"--rel-gap 0.001 --max-iterations=50 "
"--grad-rho --indep-denom "
"--default-rho=2 --solver-name={} --lagrangian --xhatshuffle".format(solver_name))
# risk-averse (CVaR): eta is just another first-stage var, so the cylinders
# run unchanged with --cvar added. eta has a much larger cost scale than the
# acreage variables, so use a cost-aware rho (grad-rho) instead of a uniform
# default-rho (see the Risk Management docs).
do_one("farmer", "../../mpisppy/generic_cylinders.py", 3,
"--module-name farmer --num-scens 6 "
"--rel-gap 0.001 --max-iterations=50 "
"--default-rho=2 --grad-rho --grad-order-stat 0.5 "
"--solver-name={} --lagrangian --xhatshuffle "
"--cvar --cvar-weight 2.0 --cvar-alpha 0.8".format(solver_name))
do_one("farmer", "../../mpisppy/generic_cylinders.py", 4,
"--module-name farmer --num-scens 6 "
"--rel-gap 0.001 --max-iterations=50 "
Expand Down
1 change: 1 addition & 0 deletions mpisppy/generic/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ def parse_args(m):
cfg.dualcg_args()
cfg.subgradient_args()
cfg.fixer_args()
cfg.cvar_args()
cfg.relaxed_ph_fixer_args()
cfg.integer_relax_then_enforce_args()
cfg.gapper_args()
Expand Down
16 changes: 16 additions & 0 deletions mpisppy/generic_cylinders.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,22 @@
scenario_creator, scenario_creator_kwargs, _, _ = \
setup_stoch_admm(module, cfg, n_cylinders)

# CVaR risk-management transform: wrap whatever scenario_creator we ended up
# with so every scenario carries the risk-averse WITH_CVAR objective. eta
# becomes "just another first-stage variable", so EF and every cylinder
# inherit risk aversion with no further changes.
if cfg.get("cvar", ifmissing=False):
if proper_bundles(cfg) or cfg.get("admm", ifmissing=False) \
or cfg.get("stoch_admm", ifmissing=False):
raise RuntimeError(
"--cvar cannot (yet) be combined with proper bundles or ADMM")
from mpisppy.utils.cvar import cvar_scenario_creator
scenario_creator = cvar_scenario_creator(
scenario_creator,
cvar_weight=cfg.cvar_weight,
cvar_alpha=cfg.cvar_alpha,
cvar_mean_weight=cfg.cvar_mean_weight)

assert hasattr(module, "scenario_denouement"), "The model file must have a scenario_denouement function"
scenario_denouement = module.scenario_denouement
# Bundle models are EFs, not individual scenarios — the module's denouement
Expand Down
Loading
Loading