Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Stochastic Programming

This notebook was prepared by Jialu Wang and revised by Maddie Watson at the University of Notre Dame.

# Imports
import sys

if "google.colab" in sys.modules:
    !wget "https://raw.githubusercontent.com/ndcbe/optimization/main/notebooks/helper.py"
    import helper

    helper.easy_install()
else:
    sys.path.insert(0, "../")
    import helper
helper.set_plotting_style()
Matplotlib is building the font cache; this may take a moment.

Farmer’s example: one model, reused

  • Lecture 9: read the model, solve known yields, then share acreage across scenarios.

  • Lecture 10: value information, sample yields, and compare risk policies.

  • Sections marked Optional depth extend the lectures for independent study.

Textbook example data: Birge and Louveaux (2011), 2nd ed., §1.1a–c, pp. 4–11.

The synthetic farmer example in Birge and Louveaux (2011), 2nd ed., §1.1a–c, pp. 4–11, uses 500 acres and three crops. Wheat and corn must supply 200 and 240 tons of feed; shortages can be purchased and surplus sold. Sugar beets have a premium-price sales cap of 6000 tons.

WheatCornSugar Beets
Yield (T/acre)2.5320
Planting cost ($/acre)150230260
Selling price ($/T)17015036 under 6000 T, 10 above 6000 T
Purchase price ($/T)238210–
Minimum requirement (T)200240–

Total available land: 500 acres

To help the farmer make up his mind, we can set up the following model. Let

  • x1x_1 = acres of land devoted to wheat,

  • x2x_2 = acres of land devoted to corn,

  • x3x_3 = acres of land devoted to sugar beets,

  • w1w_1 = tons of wheat sold,

  • y1y_1 = tons of wheat purchased,

  • w2w_2 = tons of corn sold,

  • y2y_2 = tons of corn purchased,

  • w3w_3 = tons of sugar beets sold at the favorable price,

  • w4w_4 = tons of sugar beets sold at the unfavorable price.

Farmer's problem: acres allocated to each crop, with required animal feed and quantities sold to or purchased from the market.

Deterministic mean-yield model

First solve at the specified mean yields. Later, the same model is solved separately at each realized yield to compute the perfect-information benchmark:

min⁡150x1+230x2+260x3⏟planting costs+238y1−170w1⏟wheat purchases less sales+210y2−150w2⏟corn purchases less sales −36w3−10w4⏟sugar beet saless.t.x1+x2+x3≤500,(plant up to 500 acres)2.5x1+y1−w1≥200,(satisfy wheat demand)3x2+y2−w2≥240,(satisfy corn demand)w3+w4≤20x3,(beet sales cannot exceed production)w3≤6000,(cap beet sales at favorable price)x1,x2,x3,y1,y2,w1,w2,w3,w4≥0.\begin{align*} \min \quad & \underbrace{150x_1 + 230x_2 + 260x_3}_{\text{planting costs}} \underbrace{+ 238y_1 - 170w_1}_{\text{wheat purchases less sales}} \\ & \underbrace{+ 210y_2 - 150w_2}_\text{corn purchases less sales} ~ \underbrace{- 36w_3 - 10w_4}_{\text{sugar beet sales}} \\ \text{s.t.} \quad & x_1 + x_2 + x_3 \leq 500, \quad \text{(plant up to 500 acres)} \\ & 2.5x_1 + y_1 - w_1 \geq 200, \quad \text{(satisfy wheat demand)}\\ & 3x_2 + y_2 - w_2 \geq 240, \quad \text{(satisfy corn demand)}\\ & w_3 + w_4 \leq 20x_3, \quad \text{(beet sales cannot exceed production)}\\ & w_3 \leq 6000, \quad \text{(cap beet sales at favorable price)}\\ & x_1, x_2, x_3, y_1, y_2, w_1, w_2, w_3, w_4 \geq 0. \end{align*}

Declare shared decisions and scenario Blocks

A Block groups variables, constraints, and expressions. The root model owns acreage; each scenario Block owns purchases and sales and refers to the same acreage variables. One scenario gives the deterministic LP. More scenarios reuse the model without copying planting decisions.

The builder and its helpers below are defined before the first solve. Monetary objectives use profit maximization; this is equivalent to minimizing net cost in the mathematical formulation above. Units are checked on the small models.

import numpy as np
import pyomo.environ as pyo
from pyomo.environ import units as u
from pyomo.util.check_units import assert_units_consistent

u.load_definitions_from_strings(["USD = [currency]"])
USD_PER_ACRE = u.USD / u.acre
USD_PER_TON = u.USD / u.metric_ton
TON_PER_ACRE = u.metric_ton / u.acre
CROPS = ["WHEAT", "CORN", "BEETS"]
FEED_CROPS = ["WHEAT", "CORN"]
SALE_TYPES = ["WHEAT", "CORN", "BEETS_FAVORABLE", "BEETS_UNFAVORABLE"]
MEAN_YIELD = dict(zip(CROPS, [2.5, 3.0, 20.0]))  # T/acre
PLANT_COST = dict(zip(CROPS, [150.0, 230.0, 260.0]))  # $/acre
BUY_PRICE = dict(zip(FEED_CROPS, [238.0, 210.0]))  # $/T
SELL_PRICE = dict(zip(SALE_TYPES, [170.0, 150.0, 36.0, 10.0]))  # $/T
FEED_NEED = dict(zip(FEED_CROPS, [200.0, 240.0]))  # T
TOTAL_LAND, BEET_QUOTA = 500.0, 6000.0  # acres, T
def build_farmer(
    scenarios, weights=None, plant_cost=None, risk=None, check_units=False
):
    """Return a fresh farmer model; scenarios contain yields and optional prices.

    scenarios: nonempty list of dictionaries; 'yield' maps crops to T/acre.
    Optional 'buy' and 'sell' mappings give $/T. weights sum to one.
    plant_cost maps crops to $/acre. risk selects the optional Lecture 10 policy.
    """
    n = len(scenarios)
    if n == 0:
        raise ValueError("At least one scenario is required")
    w = np.full(n, 1 / n) if weights is None else np.asarray(weights, float)
    if w.shape != (n,) or not np.all(np.isfinite(w)) or np.any(w <= 0):
        raise ValueError("Retained scenarios need positive finite probabilities")
    if not np.isclose(w.sum(), 1.0):
        raise ValueError("Scenario probabilities must sum to one")
    costs = PLANT_COST if plant_cost is None else plant_cost
    m = pyo.ConcreteModel()
    m.CROPS = pyo.Set(initialize=CROPS)
    m.FEED_CROPS = pyo.Set(initialize=FEED_CROPS)
    m.SALE_TYPES = pyo.Set(initialize=SALE_TYPES)
    m.SCENARIOS = pyo.RangeSet(0, n - 1)
    m.weight = pyo.Param(m.SCENARIOS, initialize=dict(enumerate(w)))
    # Plant once, before learning the yields: no scenario index on acreage.
    m.acreage = pyo.Var(m.CROPS, domain=pyo.NonNegativeReals, units=u.acre)
    m.land_limit = pyo.Constraint(expr=sum(m.acreage.values()) <= TOTAL_LAND * u.acre)
    m.planting_cost = pyo.Expression(
        expr=sum(costs[c] * USD_PER_ACRE * m.acreage[c] for c in CROPS)
    )
    # The helper below attaches recourse to each Block, sharing root acreage.
    m.scenarios = pyo.Block(
        m.SCENARIOS, rule=lambda b, s: add_farmer_scenario(b, m, scenarios[s])
    )
    m.expected_profit = pyo.Expression(
        expr=sum(m.weight[s] * m.scenarios[s].profit for s in m.SCENARIOS)
    )
    m.objective = pyo.Objective(expr=m.expected_profit, sense=pyo.maximize)
    if risk:
        add_farmer_risk(m, risk)  # Optional policies introduced in Lecture 10.
    if check_units:
        assert_units_consistent(m)
    return m
def add_farmer_scenario(b, m, scenario):
    """Attach one scenario's recourse, using the root model's acreage."""
    crop_yield = scenario["yield"]
    buy = scenario.get("buy", BUY_PRICE)
    sell = scenario.get("sell", SELL_PRICE)
    b.purchases = pyo.Var(m.FEED_CROPS, domain=pyo.NonNegativeReals, units=u.metric_ton)
    b.sales = pyo.Var(m.SALE_TYPES, domain=pyo.NonNegativeReals, units=u.metric_ton)
    b.sales["BEETS_FAVORABLE"].setub(BEET_QUOTA)

    # Harvest + purchases - sales must cover animal feed in this scenario.
    @b.Constraint(m.FEED_CROPS)
    def feed_balance(block, c):
        return (
            crop_yield[c] * TON_PER_ACRE * m.acreage[c]
            + block.purchases[c]
            - block.sales[c]
            >= FEED_NEED[c] * u.metric_ton
        )

    b.beets_constraint = pyo.Constraint(
        expr=b.sales["BEETS_FAVORABLE"] + b.sales["BEETS_UNFAVORABLE"]
        <= crop_yield["BEETS"] * TON_PER_ACRE * m.acreage["BEETS"]
    )
    # Profit includes planting cost; weights summing to one count it once.
    b.profit = pyo.Expression(
        expr=sum(sell[k] * USD_PER_TON * b.sales[k] for k in SALE_TYPES)
        - sum(buy[c] * USD_PER_TON * b.purchases[c] for c in FEED_CROPS)
        - m.planting_cost
    )
farmer_solver = pyo.SolverFactory("appsi_highs")
assert farmer_solver.available(), "HiGHS is required for the rest of this notebook"


def solve_farmer(m):
    """Solve, refusing anything but a proven optimum."""
    results = farmer_solver.solve(m)
    assert pyo.check_optimal_termination(
        results
    ), f"Solve failed: termination={results.solver.termination_condition}"
    return m


def plan_of(m):
    """Stage 1 acreage as a plain array, in the order of CROPS."""
    return np.array([pyo.value(m.acreage[c]) for c in CROPS])
def grade_plan(plan, scenarios, weights=None, plant_cost=None):
    """Fix acreage in CROPS order; optimize trades for the supplied scenarios.

    Return expected profit and an array of scenario profits, both in dollars.
    """
    m = build_farmer(scenarios, weights, plant_cost)
    for crop, acres in zip(CROPS, plan):
        m.acreage[crop].fix(acres)
    solve_farmer(m)
    return (
        pyo.value(m.expected_profit),
        np.array([pyo.value(m.scenarios[i].profit) for i in m.SCENARIOS]),
    )
def correlated_scenario(multiplier):
    """One scenario of Section 1.1b: every crop scaled by the same factor."""
    return {"yield": {c: MEAN_YIELD[c] * multiplier for c in CROPS}}


# The three equally likely scenarios of Section 1.1b
THREE = [correlated_scenario(f) for f in (1.2, 1.0, 0.8)]

Known yields: solve, then interpret

build_farmer([correlated_scenario(1.0)]) creates the mean-yield LP. The same function receives a singleton list for each known-yield benchmark below.

Discuss: classify the problem. Why does the solution grow 300 acres of beets? Which resource limits the profit? The dual values report the local change in optimal profit per extra unit of the corresponding constraint bound.

mean_model = build_farmer([correlated_scenario(1.0)], check_units=True)
mean_model.dual = pyo.Suffix(direction=pyo.Suffix.IMPORT)
solve_farmer(mean_model)
mean_plan = plan_of(mean_model)
print("Mean-yield acreage [wheat, corn, beets]:", mean_plan)
print(f"Profit: ${pyo.value(mean_model.expected_profit):,.2f}")
print(f"Land multiplier: ${mean_model.dual[mean_model.land_limit]:.2f}/acre")
for c in FEED_CROPS:
    con = mean_model.scenarios[0].feed_balance[c]
    print(f"Feed minimum multiplier, {c}: ${mean_model.dual[con]:.2f}/T")
assert np.allclose(mean_plan, [120, 80, 300])
assert np.isclose(pyo.value(mean_model.expected_profit), 118600)
Mean-yield acreage [wheat, corn, beets]: [120.  80. 300.]
Profit: $118,600.00
Land multiplier: $275.00/acre
Feed minimum multiplier, WHEAT: $-170.00/T
Feed minimum multiplier, CORN: $-168.33/T

The mean-yield model is an LP. Premium beet acreage reaches the sales quota; corn meets feed needs, and wheat takes the remaining land. The land multiplier is a local sensitivity, not a universal crop ranking. Crop rotation and shared labor constraints can change the allocation.

What if yields are known before planting?

Solve three separate problems: all crops at 120%, 100%, or 80% of mean yield. This assumes perfect information before planting.

perfect_models = [solve_farmer(build_farmer([scenario])) for scenario in THREE]
print("Known yield     wheat     corn    beets       profit ($)")
for label, model in zip(["above", "average", "below"], perfect_models):
    a = plan_of(model)
    print(
        f"{label:12s} {a[0]:8.1f} {a[1]:8.1f} {a[2]:8.1f}"
        f" {pyo.value(model.expected_profit):16,.2f}"
    )
Known yield     wheat     corn    beets       profit ($)
above           183.3     66.7    250.0       167,666.67
average         120.0     80.0    300.0       118,600.00
below           100.0     25.0    375.0        59,950.00

Analyze the results: why does lower beet yield lead to more beet acreage? Why does the bad-year solution buy corn? Which assumption makes these three planting plans unavailable to a farmer who must plant before observing yields?

One planting plan, three possible harvests

  • Stage 1: choose acreage before yield information.

  • Stage 2: buy and sell after observing the harvest.

  • Nonanticipativity: decisions use only information available at that time. One shared m.acreage enforces this structurally.

Each scenario has probability 1/3. The same build_farmer(THREE) function now creates three Blocks and maximizes expected profit.

Birge and Louveaux, §1.1b–c, pp. 6–11; §1.2, pp. 21, 25–26.

min⁡150x1+230x2+260x3−13(170w1,1−238y1,1+150w2,1−210y2,1+36w3,1+10w4,1)−13(170w1,2−238y1,2+150w2,2−210y2,2+36w3,2+10w4,2)−13(170w1,3−238y1,3+150w2,3−210y2,3+36w3,3+10w4,3)s.t.scenario 1:x1+x2+x3≤500,3x1+y1,1−w1,1≥200,3.6x2+y2,1−w2,1≥240,w3,1+w4,1≤24x3,w3,1≤6000,scenario 2:2.5x1+y1,2−w1,2≥200,3x2+y2,2−w2,2≥240,w3,2+w4,2≤20x3,w3,2≤6000,scenario 3:2x1+y1,3−w1,3≥200,2.4x2+y2,3−w2,3≥240,w3,3+w4,3≤16x3,w3,3≤6000,x,y,w≥0.\begin{align*} \min \quad & 150x_1 + 230x_2 + 260x_3\\ & -\frac{1}{3}(170w_{1,1} - 238y_{1,1} + 150w_{2,1} - 210y_{2,1} + 36w_{3,1} + 10w_{4,1}) \\ & -\frac{1}{3}(170w_{1,2} - 238y_{1,2} + 150w_{2,2} - 210y_{2,2} + 36w_{3,2} + 10w_{4,2}) \\ & -\frac{1}{3}(170w_{1,3} - 238y_{1,3} + 150w_{2,3} - 210y_{2,3} + 36w_{3,3} + 10w_{4,3}) \\ \text{s.t.} \quad & \text{scenario 1:} \\ & x_1 + x_2 + x_3 \leq 500, \\ & 3x_1 + y_{1,1} - w_{1,1} \geq 200, \\ & 3.6x_2 + y_{2,1} - w_{2,1} \geq 240, \\ & w_{3,1} + w_{4,1} \leq 24x_3, \\ & w_{3,1} \leq 6000, \\ & \text{scenario 2:} \\ & 2.5x_1 + y_{1,2} - w_{1,2} \geq 200, \\ & 3x_2 + y_{2,2} - w_{2,2} \geq 240, \\ & w_{3,2} + w_{4,2} \leq 20x_3, \\ & w_{3,2} \leq 6000, \\ & \text{scenario 3:} \\ & 2x_1 + y_{1,3} - w_{1,3} \geq 200, \\ & 2.4x_2 + y_{2,3} - w_{2,3} \geq 240, \\ & w_{3,3} + w_{4,3} \leq 16x_3, \\ & w_{3,3} \leq 6000, \\ & \mathbf{x}, \mathbf{y}, \mathbf{w} \geq \mathbf{0}. \end{align*}
# Validation first: reproduce Birge & Louveaux Table 5, p. 8.
RP = solve_farmer(build_farmer(THREE))
rp_plan = plan_of(RP)
rp_expected = pyo.value(RP.expected_profit)
rp_profits = np.array([pyo.value(RP.scenarios[i].profit) for i in RP.SCENARIOS])

print(
    f"acres  wheat {rp_plan[0]:.1f}  corn {rp_plan[1]:.1f}  beets {rp_plan[2]:.1f}"
    "   (book: 170, 80, 250)"
)
print(f"expected profit = ${rp_expected:,.0f}   (book: $108,390)")
print(
    "scenario profits  above ${:,.0f} | average ${:,.0f} | below ${:,.0f}".format(
        *rp_profits
    )
)

assert np.allclose(rp_plan, [170.0, 80.0, 250.0], atol=1e-6)
assert abs(rp_expected - 108390.0) < 1.0

# Exercise the unit declarations once, on this small model.
assert_units_consistent(build_farmer(THREE, check_units=True))
print("units consistent")
acres  wheat 170.0  corn 80.0  beets 250.0   (book: 170, 80, 250)
expected profit = $108,390   (book: $108,390)
scenario profits  above $167,000 | average $109,350 | below $48,820
units consistent
print("Scenario    wheat sold   corn bought   corn sold   premium beets   profit ($)")
for label, s in zip(["above", "average", "below"], RP.SCENARIOS):
    b = RP.scenarios[s]
    print(
        f"{label:8s} {pyo.value(b.sales['WHEAT']):13.1f}"
        f" {pyo.value(b.purchases['CORN']):13.1f} {pyo.value(b.sales['CORN']):11.1f}"
        f" {pyo.value(b.sales['BEETS_FAVORABLE']):15.1f} {pyo.value(b.profit):12,.0f}"
    )
Scenario    wheat sold   corn bought   corn sold   premium beets   profit ($)
above            310.0           0.0        48.0          6000.0      167,000
average          225.0           0.0         0.0          5000.0      109,350
below            140.0          48.0         0.0          4000.0       48,820

Analyze the results: compare acreage with the three perfect-information plans. Explain the switch between buying and selling corn. Does maximizing expected profit protect the worst-year outcome?

The extensive form has 3 shared acreage variables and 6 trading variables per scenario: 21 variables total. With the quota as a bound, there are 10 explicit inequalities (one land limit and three balances per scenario), plus variable bounds. None is an equality; subtracting inequality counts is not a degrees-of- freedom calculation.

Compare information values consistently

Use profit maximization throughout this comparison. Let F(x,ξ)F(\mathbf{x},\boldsymbol\xi) include optimal trading after yields are observed.

WS=E[max⁡xF(x,Ξ)],RP=max⁡xE[F(x,Ξ)].WS=\mathbb E[\max_{\mathbf{x}}F(\mathbf{x},\boldsymbol\Xi)],\qquad RP=\max_{\mathbf{x}}\mathbb E[F(\mathbf{x},\boldsymbol\Xi)].
PolicyLow-yield profit [$]Mean-yield profit [$]High-yield profit [$]Expected profit [$]
Perfect-information plans59,950118,600167,666.67115,405.56
Shared stochastic plan48,820109,350167,000108,390

Values are rounded after averaging; Birge and Louveaux, §4.1, pp. 163–164.

Expected value of perfect information

For profit maximization, EVPI=WS−RP≈$7,015.56EVPI=WS-RP\approx\$7,015.56. It is the model’s increase in optimal expected profit if yields are known before planting. It bounds a risk-neutral decision maker’s willingness to pay for perfect yield information within this model, not the price of an arbitrary forecast. See Birge and Louveaux, §4.1, pp. 163–164.

Expected-value plan evaluated under uncertainty

Choose xˉ∈arg⁡max⁡xF(x,E[Ξ])\bar{\mathbf{x}}\in\arg\max_{\mathbf{x}}F(\mathbf{x},\mathbb E[\boldsymbol\Xi]) and then compute EEV=E[F(xˉ,Ξ)]EEV=\mathbb E[F(\bar{\mathbf{x}},\boldsymbol\Xi)]. Only acreage is fixed; trading is optimized per scenario.

For the mean-yield plan (120,80,300), EEV=$107,240EEV=\$107,240. See Birge and Louveaux, §4.2, p. 165.

Value of the stochastic solution

With the profit convention, VSS=RP−EEV=$108,390−$107,240=$1,150VSS=RP-EEV=\$108,390-\$107,240=\$1,150. The general ordering WS≥RP≥EEVWS\ge RP\ge EEV assumes a feasible EV plan and consistently optimized recourse. See Birge and Louveaux, §§4.2–4.3, pp. 165–166.

WS = np.mean([pyo.value(m.expected_profit) for m in perfect_models])
EEV, ev_profits = grade_plan(mean_plan, THREE)
print("Policy                         expected profit ($)")
print(f"Perfect information (WS)        {WS:17,.2f}")
print(f"Shared stochastic plan (RP)     {rp_expected:17,.2f}")
print(f"Mean-yield plan evaluated (EEV) {EEV:17,.2f}")
print(f"EVPI = WS - RP: ${WS - rp_expected:,.2f}")
print(f"VSS  = RP - EEV: ${rp_expected - EEV:,.2f}")
print("EEV scenario profits [above, average, below]:", ev_profits)
assert np.isclose(EEV, 107240)
assert WS >= rp_expected >= EEV
Policy                         expected profit ($)
Perfect information (WS)               115,405.56
Shared stochastic plan (RP)            108,390.00
Mean-yield plan evaluated (EEV)        107,240.00
EVPI = WS - RP: $7,015.56
VSS  = RP - EEV: $1,150.00
EEV scenario profits [above, average, below]: [148000. 118600.  55120.]

EVPI measures the value of perfect pre-decision information. VSS compares the stochastic optimum with the evaluated mean-data plan; it does not measure the value of learning an unknown distribution.

Sample average approximation: train and evaluate

For the continuous-uniform model (§1.1d, pp. 11–15), replace the expectation by an average over independent sampled yields. Reuse build_farmer with one Block per sample. Choose acreage using a training sample, then fix acreage and optimize trading on a new evaluation sample.

The table uses a common evaluation sample, independent of all training samples, to compare plans. Each reported standard error estimates uncertainty in that fixed plan’s mean profit. It is not a confidence bound on the optimality gap. Analyze whether bigger training samples improve these particular decisions; monotonic improvement in any finite sequence is not guaranteed.

def sample_farmer_yields(n, seed):
    """Independent uniform yields, T/acre; Birge & Louveaux, Section 1.1d."""
    rng = np.random.default_rng(seed)
    return [
        {"yield": {c: rng.uniform(0.8, 1.2) * MEAN_YIELD[c] for c in CROPS}}
        for _ in range(n)
    ]


def build_sampled_farmer(n, seed):
    """Build an n-scenario SAA model with reproducible, equally weighted draws."""
    # Equal weights 1/n are the builder's default.
    return build_farmer(sample_farmer_yields(n, seed))
evaluation_sample = sample_farmer_yields(2000, seed=20260922)
trained = solve_farmer(build_sampled_farmer(200, seed=12))
evaluated, profits = grade_plan(plan_of(trained), evaluation_sample)
se = profits.std(ddof=1) / np.sqrt(len(profits))
print("N train   wheat    corn   beets   train mean   eval mean   eval SE ($)")
for n_train, seed in [(50, 11), (200, 12), (1000, 13)]:
    trained = solve_farmer(build_sampled_farmer(n_train, seed))
    plan = plan_of(trained)
    evaluated, profits = grade_plan(plan, evaluation_sample)
    se = profits.std(ddof=1) / np.sqrt(len(profits))
    print(
        f"{n_train:7d} {plan[0]:7.1f} {plan[1]:7.1f} {plan[2]:7.1f}"
        f" {pyo.value(trained.expected_profit):12,.0f} {evaluated:11,.0f} {se:13,.0f}"
    )
N train   wheat    corn   beets   train mean   eval mean   eval SE ($)
     50   129.9    86.4   283.7      105,724     111,611           460
    200   136.1    85.7   278.2      110,180     111,713           476
   1000   135.0    84.6   280.4      110,314     111,694           471

Reuse the farmer model for risk policies

The optional risk argument adds a profit floor, CVaR, downside risk, or a worst-case objective. The default builder above is unchanged. Lower-tail profit CVaR averages the worst 1-alpha probability mass; it is the negative of upper-tail loss CVaR when loss is minus profit. Re-evaluate a risk-selected acreage with grade_plan to obtain optimal trading in every scenario.

def add_farmer_cvar(m, risk):
    """Lower-tail profit CVaR: alpha is the confidence level."""
    w = m.weight
    n = len(m.SCENARIOS)
    # Rockafellar-Uryasev epigraph, B&L (9.8)-(9.10), p. 85, written for the
    # LOWER tail of profit: CVaR_a = max_nu { nu - E[(nu - profit)_+]/(1-a) }
    alpha = (risk.get("cvar") or risk.get("cvar_floor"))[0]
    m.nu = pyo.Var(domain=pyo.Reals, units=u.USD)
    m.shortfall = pyo.Var(m.SCENARIOS, domain=pyo.NonNegativeReals, units=u.USD)
    m.shortfall_con = pyo.Constraint(
        m.SCENARIOS,
        rule=lambda m, i: m.shortfall[i] >= m.nu - m.scenarios[i].profit,
    )
    m.cvar = m.nu - (1.0 / (1.0 - alpha)) * sum(w[i] * m.shortfall[i] for i in range(n))
    if "cvar_floor" in risk:
        m.cvar_con = pyo.Constraint(expr=m.cvar >= risk["cvar_floor"][1] * u.USD)
def add_farmer_risk(m, risk):
    """Attach the requested risk policy to a fresh farmer model."""
    w = m.weight
    n = len(m.SCENARIOS)
    m.del_component(m.objective)
    if "floor" in risk:
        # Exercise 7(b): a profit floor imposed in every scenario
        m.profit_floor = pyo.Constraint(
            m.SCENARIOS,
            rule=lambda m, i: m.scenarios[i].profit >= risk["floor"] * u.USD,
        )
    if "minimax" in risk:
        # B&L (9.15), p. 86, in profit form: maximize the worst scenario
        m.worst = pyo.Var(domain=pyo.Reals, units=u.USD)
        m.worst_con = pyo.Constraint(
            m.SCENARIOS, rule=lambda m, i: m.worst <= m.scenarios[i].profit
        )
    if "cvar" in risk or "cvar_floor" in risk:
        add_farmer_cvar(m, risk)
    if "downside" in risk:
        # Expected downside risk, B&L (5.1)-(5.3), p. 68
        target, level = risk["downside"]
        m.shortfall_below_target = pyo.Var(
            m.SCENARIOS, domain=pyo.NonNegativeReals, units=u.USD
        )
        m.downside_def = pyo.Constraint(
            m.SCENARIOS,
            rule=lambda m, i: m.shortfall_below_target[i]
            >= target * u.USD - m.scenarios[i].profit,
        )
        m.downside_limit = pyo.Constraint(
            expr=sum(w[i] * m.shortfall_below_target[i] for i in range(n))
            <= level * u.USD
        )

    if "minimax" in risk:
        m.objective = pyo.Objective(expr=m.worst, sense=pyo.maximize)
    elif "cvar" in risk:
        lam = risk["cvar"][1]
        m.objective = pyo.Objective(
            expr=(1 - lam) * m.expected_profit + lam * m.cvar, sense=pyo.maximize
        )
    else:
        m.objective = pyo.Objective(expr=m.expected_profit, sense=pyo.maximize)

Optional depth: discrete yields and continuous yields

Birge and Louveaux §1.1b, pp. 6–8, uses three equally likely, correlated yield vectors. Section 1.1d, pp. 11–15, uses independent uniforms on 80–120% of each mean yield. Their means match, but their marginal distributions do not. The optimal plans differ: (170, 80, 250) for the three-point model versus about (135.83, 85.07, 279.10) for the continuous model.

For a separate, controlled dependence comparison, the samplers below both use uniform marginals. One draws crops independently; the other uses a shared continuous weather factor. The correlated-uniform model is an instructor extension, not the three-point model in §1.1b.

Hold marginals fixed to isolate dependence

The farmer’s recourse separates across crops. Therefore the expected recourse is a sum of crop-wise expectations, each determined by that crop’s marginal (Birge and Louveaux, §1.1d, Eq. (1.6), p. 11).

Independent and correlated uniform yields consequently give the same expected-profit function and risk-neutral optimal plan. The three-point model has different marginals, so this argument does not equate its solution with the continuous model. Equal means alone are insufficient.

Sampling the continuous model, and checking it without a solver

Section 1.1d has no scenario list: the yields are continuous. Approximate the expectation by sampling, one pyo.Block per draw, and seed every draw so the cell reproduces on its own.

The check is the interesting part. Birge and Louveaux solve the continuous model analytically: pp. 12--14 give closed forms for Q1Q_1, Q2Q_2 and Q3Q_3, and p. 15 solves the resulting Karush--Kuhn--Tucker conditions to λ=275.00\lambda = 275.00, x=(135.83, 85.07, 279.10)\mathbf x = (135.83,\ 85.07,\ 279.10). So this model can be checked by a route that uses no solver at all — a luxury worth taking whenever it is available.

# Two samplers, seeded. The only difference is whether one draw is shared by all
# three crops (an instructor extension) or each crop is drawn on its own
# (Section 1.1d's independence). Both give each crop the SAME marginal:
# Uniform(0.8 * mean, 1.2 * mean).
YIELD_BOUNDS = {c: (0.8 * MEAN_YIELD[c], 1.2 * MEAN_YIELD[c]) for c in CROPS}


def sample_independent(n, seed):
    """Section 1.1d, p. 11: each crop's yield drawn independently."""
    rng = np.random.default_rng(seed)
    return [
        {"yield": {c: rng.uniform(*YIELD_BOUNDS[c]) for c in CROPS}} for _ in range(n)
    ]


def sample_correlated(n, seed):
    """Instructor extension: one common continuous weather factor."""
    rng = np.random.default_rng(seed)
    return [correlated_scenario(f) for f in rng.uniform(0.8, 1.2, size=n)]


# --- the solver-free reference: B&L's closed-form expected recourse, pp. 13-14
def Q_wheat(x):
    if x <= 200 / 3:
        return 47600 - 595 * x
    if x <= 100:
        return 119 * (200 - 2 * x) ** 2 / x - 85 * (200 - 3 * x) ** 2 / x
    return 34000 - 425 * x


def Q_corn(x):
    if x <= 200 / 3:
        return 50400 - 630 * x
    if x <= 100:
        return 87.5 * (240 - 2.4 * x) ** 2 / x - 62.5 * (240 - 3.6 * x) ** 2 / x
    return 36000 - 450 * x


def Q_beets(x):
    """Equations (1.10)-(1.12), pp. 12-13, with l3 = 16 and u3 = 24 T/acre."""
    lo, hi = 16.0, 24.0
    if x <= 0:
        return 0.0
    if lo * x > 6000:  # quota exceeded for every yield
        return -156000 - 10 * MEAN_YIELD["BEETS"] * x
    if hi * x < 6000:  # quota never reached
        return -36 * MEAN_YIELD["BEETS"] * x
    return -36 * MEAN_YIELD["BEETS"] * x + 13 * (hi * x - 6000) ** 2 / (x * (hi - lo))


def analytic_expected_profit(plan):
    """Exact E[profit] of a plan under continuous uniform yields -- no solver."""
    planting = sum(PLANT_COST[c] * a for c, a in zip(CROPS, plan))
    return -(planting + Q_wheat(plan[0]) + Q_corn(plan[1]) + Q_beets(plan[2]))


book_plan = np.array([135.83, 85.07, 279.10])
print(
    f"B&L KKT solution, p. 15:  ({book_plan[0]}, {book_plan[1]}, {book_plan[2]})"
    f"   analytic E[profit] = ${analytic_expected_profit(book_plan):,.2f}"
)
print()
print(
    f"{'sampler':>12s} {'N':>6s} {'wheat':>8s} {'corn':>8s} {'beets':>8s}"
    f" {'sampled E':>12s} {'analytic E':>12s}"
)
for sampler, label in [
    (sample_independent, "independent"),
    (sample_correlated, "correlated"),
]:
    for n, seed in [(200, 1), (1000, 2), (4000, 3)]:
        m = solve_farmer(build_farmer(sampler(n, seed)))
        p = plan_of(m)
        print(
            f"{label:>12s} {n:6d} {p[0]:8.2f} {p[1]:8.2f} {p[2]:8.2f}"
            f" {pyo.value(m.expected_profit):12,.0f} {analytic_expected_profit(p):12,.0f}"
        )
B&L KKT solution, p. 15:  (135.83, 85.07, 279.1)   analytic E[profit] = $111,237.44

     sampler      N    wheat     corn    beets    sampled E   analytic E
 independent    200   134.31    84.21   281.49      110,390      111,220
 independent   1000   137.32    84.96   277.71      111,730      111,232
 independent   4000   135.64    84.76   279.60      110,604      111,237
  correlated    200   137.09    84.47   278.44      112,259      111,235
  correlated   1000   135.27    85.15   279.58      111,452      111,237
  correlated   4000   136.23    85.29   278.48      110,886      111,236

Both samplers target the continuous-uniform optimum; finite samples yield different plans. The analytic column evaluates a sampled plan under the true uniform marginals. Neither a particular sample objective nor a sequence of sampled decisions must improve monotonically with sample size. Training objectives are optimistic in expectation, not on every run.

# Hold the plan FIXED at the book's solution and look at the distribution of
# profit it produces under each assumption. Same decision, same mean -- the only
# difference is whether the three crops fail together.
N_EVAL, EVAL_SEED = 20_000, 20260921


def tail_summary(profits, alpha):
    """Empirical mean, spread and lower-tail risk of a profit sample."""
    s = np.sort(profits)
    mass = (1 - alpha) * len(s)
    k = min(len(s), int(np.floor(mass + 1e-10)))
    fractional = max(0.0, mass - k)
    tail_mean = (s[:k].sum() + (fractional * s[k] if k < len(s) else 0)) / mass
    cutoff = max(0, int(np.ceil(mass - 1e-10)) - 1)
    return {
        "mean": s.mean(),
        "sd": s.std(ddof=1),
        "min": s[0],
        f"VaR_{alpha}": s[cutoff],
        f"CVaR_{alpha}": tail_mean,
    }


print(
    f"Plan held fixed at ({book_plan[0]}, {book_plan[1]}, {book_plan[2]}) acres;"
    f" {N_EVAL:,} seeded draws each."
)
print(
    f"True E[profit] for BOTH, analytically: ${analytic_expected_profit(book_plan):,.2f}"
)
print()
print(
    f"{'assumption':>22s} {'E (sampled)':>13s} {'std dev':>10s} {'min':>10s}"
    f" {'VaR_0.95':>10s} {'CVaR_0.95':>10s}"
)
summaries = {}
for label, sampler in [
    ("independent (1.1d)", sample_independent),
    ("correlated uniform", sample_correlated),
]:
    _, profits = grade_plan(book_plan, sampler(N_EVAL, EVAL_SEED))
    s = tail_summary(profits, 0.95)
    summaries[label] = s
    print(
        f"{label:>22s} {s['mean']:13,.0f} {s['sd']:10,.0f} {s['min']:10,.0f}"
        f" {s['VaR_0.95']:10,.0f} {s['CVaR_0.95']:10,.0f}"
    )

gap = (
    summaries["independent (1.1d)"]["CVaR_0.95"]
    - summaries["correlated uniform"]["CVaR_0.95"]
)
print(f"\nSame plan, same true mean, but CVaR_0.95 differs by ${gap:,.0f}.")
print("The two sampled means differ only by Monte Carlo noise; the spread does not.")
Plan held fixed at (135.83, 85.07, 279.1) acres; 20,000 seeded draws each.
True E[profit] for BOTH, analytically: $111,237.44

            assumption   E (sampled)    std dev        min   VaR_0.95  CVaR_0.95
    independent (1.1d)       111,106     21,294     56,525     74,911     69,317
    correlated uniform       110,667     31,035     52,928     59,198     56,059

Same plan, same true mean, but CVaR_0.95 differs by $13,258.
The two sampled means differ only by Monte Carlo noise; the spread does not.

At this fixed plan, the independent-uniform and correlated-uniform models have the same true expected profit but different downside risk. Their sampled means need not coincide. This comparison isolates dependence because the marginals are held fixed; it does not compare §1.1b directly with §1.1d.

Risk aversion: the farmer’s efficient frontier

Birge and Louveaux pose this as Exercise 7 of §1.1, pp. 18--19:

“Economic theory tells us that, like many other people, the farmer would normally act as a risk-averse person. … One simple way is to plan for the worst case.”

They note that the worst situation is Scenario 3 (below-average yields), and that planning for it makes the Table 4 solution optimal — 100 acres of wheat, 25 of corn, 375 of sugar beets.

  • (a) compute the loss in expected profit if that plan is taken;

  • (b) instead require the worst-case profit not to fall below $58,000, and maximize expected profit subject to that;

  • (c) repeat at $56,000, $54,000, $52,000, $50,000 and $48,000, and graph the curve of expected profit loss.

Everything needed is already in hand. No new data, no new setting.

# Exercise 7(a): grade the worst-case plan of Table 4, p. 7. Recourse is still
# optimized in every scenario -- only the acreage is fixed.
table_4_plan = np.array([100.0, 25.0, 375.0])
e_worst_case, profits_worst_case = grade_plan(table_4_plan, THREE)

print("Table 4 plan (plan for the worst case): 100 / 25 / 375 acres")
print(
    "  scenario profits  above ${:,.0f} | average ${:,.0f} | below ${:,.0f}".format(
        *profits_worst_case
    )
)
print(f"  expected profit = ${e_worst_case:,.0f}")
print(
    f"\nExercise 7(a): loss in expected profit = "
    f"${rp_expected:,.0f} - ${e_worst_case:,.0f} = ${rp_expected - e_worst_case:,.0f}"
)

# The below-average profit is printed in the book: Table 4, p. 7, gives $59,950.
assert abs(profits_worst_case.min() - 59950.0) < 1.0
Table 4 plan (plan for the worst case): 100 / 25 / 375 acres
  scenario profits  above $113,250 | average $86,600 | below $59,950
  expected profit = $86,600

Exercise 7(a): loss in expected profit = $108,390 - $86,600 = $21,790
protected = solve_farmer(build_farmer(THREE, risk={"floor": 56_000}))
protected_mean, protected_profits = grade_plan(plan_of(protected), THREE)
# Exercises 7(b) and 7(c): raise the floor and watch what the insurance costs.
print(
    f"{'floor':>10s} {'wheat':>8s} {'corn':>8s} {'beets':>8s}"
    f" {'E[profit]':>12s} {'loss vs RP':>12s}"
)
frontier = []
for floor in [48_000, 50_000, 52_000, 54_000, 56_000, 58_000]:
    m = solve_farmer(build_farmer(THREE, risk={"floor": floor}))
    p, e = plan_of(m), pyo.value(m.expected_profit)
    frontier.append((floor, p, e))
    print(
        f"{floor:10,d} {p[0]:8.1f} {p[1]:8.1f} {p[2]:8.1f} {e:12,.0f}"
        f" {rp_expected - e:12,.0f}"
    )

print("\nMarginal cost of each extra $2,000 of floor:")
for (f0, _, e0), (f1, _, e1) in zip(frontier, frontier[1:]):
    print(f"  ${f0:,} -> ${f1:,}:  ${e0 - e1:>8,.0f}")
     floor    wheat     corn    beets    E[profit]   loss vs RP
    48,000    170.0     80.0    250.0      108,390            0
    50,000    156.0     94.0    250.0      108,292           98
    52,000    138.1    100.0    261.9      107,976          414
    54,000    122.2    100.0    277.8      107,611          779
    56,000    106.3    100.0    293.7      107,246        1,144
    58,000    100.0     71.4    328.6      101,176        7,214

Marginal cost of each extra $2,000 of floor:
  $48,000 -> $50,000:  $      98
  $50,000 -> $52,000:  $     315
  $52,000 -> $54,000:  $     365
  $54,000 -> $56,000:  $     365
  $56,000 -> $58,000:  $   6,070

The curve has a kink, and the kink is the discussion.

From $50,000 to $56,000 each extra $2,000 of guaranteed profit costs a near-constant $365 or so. The last step to $58,000 costs $6,070 — sixteen times the marginal rate.

The acreage column explains it. Through the linear stretch, corn sits pinned at its upper limit of 100 acres and the adjustment is made by trading wheat for beets. At $58,000 corn falls off that bound, dropping to 71.4 acres while beets jump to 328.6, and the plan collapses toward the worst-case allocation of 100 / 25 / 375.

Insurance is cheap until it isn’t.

From a worst-case floor to CVaR

Exercise 7’s floor turns out to be a conditional value at risk constraint in disguise, and seeing why makes the general CVaR machinery arrive as a generalization of something already understood rather than as new construction.

Write CVaR on the lower tail of profit — the mirror of the loss convention in Birge and Louveaux (9.7), p. 85:

CVaRα(π)=max⁡ν{ν−11−α E[(ν−π)+]},\mathrm{CVaR}_\alpha(\pi)=\max_{\nu}\left\{\nu-\frac{1}{1-\alpha}\, \mathbb E\big[(\nu-\pi)_+\big]\right\},

the average profit over the worst 1−α1-\alpha of the probability mass.

With SS equally likely scenarios and tail mass 1−α≤1/S1-\alpha \le 1/S, the worst scenario alone carries the entire tail, so CVaRα(π)=min⁡sπs\mathrm{CVaR}_\alpha(\pi) = \min_s \pi_s and the CVaR floor is the worst-case floor. For S=3S = 3 that means every α≥2/3\alpha \ge 2/3.

# Verify the equivalence rather than asserting it: solve BOTH models and diff.
print(
    f"{'alpha':>8s} {'floor':>9s} {'E (floor model)':>17s} {'E (CVaR model)':>16s}"
    f" {'dE':>8s} {'max|d acres|':>13s}"
)
for alpha in [2 / 3, 0.75, 0.90, 0.95]:
    for floor, plan_floor, e_floor in frontier:
        if floor < 52_000:
            continue
        m = solve_farmer(build_farmer(THREE, risk={"cvar_floor": (alpha, floor)}))
        e_cvar, plan_cvar = pyo.value(m.expected_profit), plan_of(m)
        d_acres = np.abs(plan_cvar - plan_floor).max()
        print(
            f"{alpha:8.3f} {floor:9,d} {e_floor:17,.2f} {e_cvar:16,.2f}"
            f" {e_cvar - e_floor:8.2f} {d_acres:13.2e}"
        )
        assert abs(e_cvar - e_floor) < 1e-4 and d_acres < 1e-6
   alpha     floor   E (floor model)   E (CVaR model)       dE  max|d acres|
   0.667    52,000        107,976.19       107,976.19     0.00      0.00e+00
   0.667    54,000        107,611.11       107,611.11     0.00      0.00e+00
   0.667    56,000        107,246.03       107,246.03     0.00      0.00e+00
   0.667    58,000        101,176.19       101,176.19     0.00      0.00e+00
   0.750    52,000        107,976.19       107,976.19     0.00      0.00e+00
   0.750    54,000        107,611.11       107,611.11     0.00      0.00e+00
   0.750    56,000        107,246.03       107,246.03     0.00      0.00e+00
   0.750    58,000        101,176.19       101,176.19     0.00      0.00e+00
   0.900    52,000        107,976.19       107,976.19     0.00      0.00e+00
   0.900    54,000        107,611.11       107,611.11     0.00      0.00e+00
   0.900    56,000        107,246.03       107,246.03     0.00      0.00e+00
   0.900    58,000        101,176.19       101,176.19     0.00      0.00e+00
   0.950    52,000        107,976.19       107,976.19     0.00      0.00e+00
   0.950    54,000        107,611.11       107,611.11     0.00      0.00e+00
   0.950    56,000        107,246.03       107,246.03     0.00      0.00e+00
   0.950    58,000        101,176.19       101,176.19     0.00      0.00e+00

Partial mass at the cutoff, on a model you already own

The equivalence above is a degeneracy of three equally likely scenarios, not a general fact — and the quickest way to see CVaR do something the minimum cannot is to drop α\alpha below 2/32/3 so that the tail splits an atom.

At the risk-neutral plan, the three equally likely profits are $48,820, $109,350 and $167,000. At α=0.5\alpha = 0.5 the worst half of the probability mass is all of the below-average scenario (1/31/3) plus half of the average one (1/61/6). A conditional expectation cannot do this: it must take whole scenarios, so it averages 2/32/3 of the mass when 1/21/2 was asked for.

# Hand arithmetic first, then the LP epigraph, then check they agree.
sorted_profits = np.sort(rp_profits)
var_50 = sorted_profits[1]  # smallest t with P(profit <= t) >= 0.5
cvar_50 = (1 / 3 * sorted_profits[0] + 1 / 6 * sorted_profits[1]) / 0.5
conditional_mean = sorted_profits[sorted_profits <= var_50].mean()

print(
    "profits at the risk-neutral plan: "
    + " | ".join(f"${p:,.0f}" for p in sorted_profits)
)
print(f"  VaR_0.5  (lower tail)          = ${var_50:,.2f}")
print(
    f"  CVaR_0.5 = (1/3 x {sorted_profits[0]:,.0f} + 1/6 x {sorted_profits[1]:,.0f}) / 0.5"
    f" = ${cvar_50:,.2f}"
)
print(
    f"  E[profit | profit <= VaR_0.5]  = ${conditional_mean:,.2f}"
    "   <- averages 2/3 of the mass, not 1/2"
)

# Second route: the Rockafellar-Uryasev epigraph solved at the same fixed plan.
m = build_farmer(THREE, risk={"cvar": (0.5, 1.0)})
for crop, acres in zip(CROPS, rp_plan):
    m.acreage[crop].fix(acres)
solve_farmer(m)
print(f"\n  LP epigraph at the same plan   = ${pyo.value(m.cvar):,.2f}")
assert abs(pyo.value(m.cvar) - cvar_50) < 1e-6
profits at the risk-neutral plan: $48,820 | $109,350 | $167,000
  VaR_0.5  (lower tail)          = $109,350.00
  CVaR_0.5 = (1/3 x 48,820 + 1/6 x 109,350) / 0.5 = $68,996.67
  E[profit | profit <= VaR_0.5]  = $79,085.00   <- averages 2/3 of the mass, not 1/2

  LP epigraph at the same plan   = $68,996.67

When CVaR is the expected value, and when it is the worst case

CVaR prices the worst 1−α1-\alpha of the probability mass, so the two ends of the α\alpha range are not really CVaR problems at all:

limittail massCVaR becomesefficient formulation
α→0\alpha \to 0everythingE[π]\mathbb E[\pi]drop ν\nu and every shortfall variable; take the mean
α→1\alpha \to 1the single worst outcomemin⁡sπs\min_s \pi_sminimax: one variable tt with t≤πst \le \pi_s for all ss

Both limits are reached at finite α\alpha when the scenarios are finitely many and equally likely: with SS scenarios, any α≥1−1/S\alpha \ge 1 - 1/S already gives exactly the worst case, so pushing α\alpha higher buys nothing.

⚠ And pushing it higher actively costs something. The epigraph carries a 1/(1−α)1/(1-\alpha) coefficient, which grows without bound as α→1\alpha \to 1: at α=0.999\alpha = 0.999 it is 1,000, and at α=0.9999\alpha = 0.9999 it is 10,000. Near the worst-case end the general formulation is therefore both larger and worse conditioned than the minimax model it is approximating. Switching formulation is the right move; raising α\alpha is not.

# The limits, verified rather than asserted. Read the PLAN off the risk solve,
# then grade it with a separate expectation solve (the next section explains why).
print(
    f"{'alpha':>8s} {'CVaR*':>12s} {'wheat':>7s} {'corn':>7s} {'beets':>7s}"
    f" {'E[profit]':>12s} {'worst':>10s}"
)
for alpha in [0.0, 0.1, 1 / 3, 0.5, 2 / 3, 0.9, 0.99]:
    m = solve_farmer(build_farmer(THREE, risk={"cvar": (alpha, 1.0)}))
    p = plan_of(m)
    e, scenario_profits = grade_plan(p, THREE)
    print(
        f"{alpha:8.3f} {pyo.value(m.cvar):12,.2f} {p[0]:7.1f} {p[1]:7.1f} {p[2]:7.1f}"
        f" {e:12,.0f} {scenario_profits.min():10,.0f}"
    )

print(
    f"\nalpha = 0    : CVaR* = ${rp_expected:,.2f} = the risk-neutral expected profit"
)
print(
    f"alpha >= 2/3 : CVaR* = ${profits_worst_case.min():,.2f} = the worst scenario profit"
)
   alpha        CVaR*   wheat    corn   beets    E[profit]      worst
   0.000   108,390.00   170.0    80.0   250.0      108,390     48,820
   0.100   102,711.11   120.0    80.0   300.0      107,240     55,120
   0.333    87,150.00   100.0   100.0   300.0      107,100     56,800
   0.500    77,033.33   100.0   100.0   300.0      107,100     56,800
   0.667    59,950.00   100.0    25.0   375.0       86,600     59,950
   0.900    59,950.00   100.0    25.0   375.0       86,600     59,950
   0.990    59,950.00   100.0    25.0   375.0       86,600     59,950

alpha = 0    : CVaR* = $108,390.00 = the risk-neutral expected profit
alpha >= 2/3 : CVaR* = $59,950.00 = the worst scenario profit
# What the purpose-built models cost, against the general epigraph.
def size_of(m):
    """Active variables and constraints, scenario blocks included."""
    return (
        sum(1 for _ in m.component_data_objects(pyo.Var, active=True)),
        sum(1 for _ in m.component_data_objects(pyo.Constraint, active=True)),
    )


expectation_model = solve_farmer(build_farmer(THREE))
epigraph_at_0 = solve_farmer(build_farmer(THREE, risk={"cvar": (0.0, 1.0)}))
minimax_model = solve_farmer(build_farmer(THREE, risk={"minimax": True}))
epigraph_at_high = solve_farmer(build_farmer(THREE, risk={"cvar": (0.99, 1.0)}))

rows = [
    (
        "expectation (alpha -> 0 limit)",
        expectation_model,
        pyo.value(expectation_model.expected_profit),
    ),
    ("epigraph at alpha = 0", epigraph_at_0, pyo.value(epigraph_at_0.cvar)),
    ("minimax (alpha -> 1 limit)", minimax_model, pyo.value(minimax_model.worst)),
    ("epigraph at alpha = 0.99", epigraph_at_high, pyo.value(epigraph_at_high.cvar)),
]
print(f"{'model':>32s} {'objective':>12s} {'vars':>6s} {'cons':>6s}")
for label, m, value in rows:
    v, c = size_of(m)
    print(f"{label:>32s} {value:12,.2f} {v:6d} {c:6d}")

print("\nSame two answers, reached twice. The epigraph adds one threshold variable")
print("and one shortfall variable per scenario, plus one constraint per scenario,")
print("to compute numbers that a mean and a minimax deliver directly.")
print("\n1/(1-alpha), the epigraph coefficient:")
for alpha in [0.5, 0.9, 0.99, 0.999, 0.9999]:
    print(f"  alpha = {alpha:<8.4f}  1/(1-alpha) = {1 / (1 - alpha):>10,.1f}")
                           model    objective   vars   cons
  expectation (alpha -> 0 limit)   108,390.00     21     10
           epigraph at alpha = 0   108,390.00     25     13
      minimax (alpha -> 1 limit)    59,950.00     22     13
        epigraph at alpha = 0.99    59,950.00     25     13

Same two answers, reached twice. The epigraph adds one threshold variable
and one shortfall variable per scenario, plus one constraint per scenario,
to compute numbers that a mean and a minimax deliver directly.

1/(1-alpha), the epigraph coefficient:
  alpha = 0.5000    1/(1-alpha) =        2.0
  alpha = 0.9000    1/(1-alpha) =       10.0
  alpha = 0.9900    1/(1-alpha) =      100.0
  alpha = 0.9990    1/(1-alpha) =    1,000.0
  alpha = 0.9999    1/(1-alpha) =   10,000.0

⚠ A pure-CVaR objective leaves the non-tail scenarios undetermined

This is a different problem from the limits above, and it is easy to conflate them. The limits are about what CVaR equals. This is about what the solver reports.

A pure-CVaR objective (λ=1\lambda = 1) references only the tail scenarios. Every other scenario’s recourse variables appear nowhere in the objective, so the solver is free to return any feasible values for them. The model is solved correctly; the per-scenario numbers you read off it are simply not pinned down.

The two ideas share one root — CVaR only prices the tail — and the α→1\alpha \to 1 end is where they meet: exactly one scenario is priced, so the recourse in all the others is unpriced and free.

# Demonstrate it. Read the scenario profits straight off a pure-CVaR solve, then
# read them again after fixing the plan and re-optimizing recourse.
print(
    f"{'lambda':>7s} {'wheat':>7s} {'corn':>7s} {'beets':>7s}"
    f" {'E (read off)':>14s} {'E (re-optimized)':>18s} {'gap':>10s}"
)
for lam in [0.0, 0.25, 0.5, 0.75, 0.9, 1.0]:
    m = solve_farmer(build_farmer(THREE, risk={"cvar": (2 / 3, lam)}))
    p = plan_of(m)
    read_off = np.array([pyo.value(m.scenarios[i].profit) for i in m.SCENARIOS])
    e_true, _ = grade_plan(p, THREE)
    print(
        f"{lam:7.2f} {p[0]:7.1f} {p[1]:7.1f} {p[2]:7.1f}"
        f" {read_off.mean():14,.0f} {e_true:18,.0f} {e_true - read_off.mean():10,.0f}"
    )

# The same trap in a model that contains no CVaR at all.
mm = solve_farmer(build_farmer(THREE, risk={"minimax": True}))
mm_read_off = np.array([pyo.value(mm.scenarios[i].profit) for i in mm.SCENARIOS])
mm_true, mm_profits = grade_plan(plan_of(mm), THREE)
print(f"\nminimax (B&L 9.15): worst = ${pyo.value(mm.worst):,.0f}")
print(
    f"  scenario profits read off the solve : "
    + " | ".join(f"${p:,.0f}" for p in mm_read_off)
)
print(
    f"  same plan, recourse re-optimized    : "
    + " | ".join(f"${p:,.0f}" for p in mm_profits)
)
print(f"  error if the first line were reported: ${mm_true - mm_read_off.mean():,.0f}")
 lambda   wheat    corn   beets   E (read off)   E (re-optimized)        gap
   0.00   170.0    80.0   250.0        108,390            108,390          0
   0.25   100.0   100.0   300.0        107,100            107,100          0
   0.50   100.0   100.0   300.0        107,100            107,100          0
   0.75   100.0   100.0   300.0        107,100            107,100          0
   0.90   100.0    25.0   375.0         86,600             86,600          0
   1.00   100.0    25.0   375.0         59,950             86,600     26,650

minimax (B&L 9.15): worst = $59,950
  scenario profits read off the solve : $59,950 | $59,950 | $59,950
  same plan, recourse re-optimized    : $113,250 | $86,600 | $59,950
  error if the first line were reported: $26,650

Every row with λ<1\lambda < 1 agrees, because a positive weight on the expectation prices all three scenarios. At λ=1\lambda = 1 the two columns differ by tens of thousands of dollars, and nothing errors — the solve terminates optimally and the reported numbers are feasible.

The minimax model shows the same failure without any CVaR in it, which is the proof that this is about which scenarios the objective touches, not about CVaR.

The rule: if you are going to report per-scenario recourse from a risk solve, pin it down first. Three ways, in increasing order of effort:

  1. Do not report it. Report the plan, which is determined, and the risk measure, which is the objective.

  2. Keep a small positive weight on the expectation (λ<1\lambda < 1). This is what the sweep above does, and it is usually what you wanted anyway.

  3. Grade the plan in a second solve with the acreage fixed and expected profit as the objective — what grade_plan does throughout this notebook.

The general statement covers more than CVaR: any objective that references only part of the scenario set leaves the rest of the recourse undetermined.

Compare expected profit and tail protection

The table selects acreage with weighted expected profit and lower-tail profit CVaR, then uses grade_plan to optimize recourse in every scenario. This matters especially at weight 1: pure CVaR need not determine non-tail trades. With three equally likely scenarios and alpha = 2/3, the tail is the worst scenario. More general alpha values can use only part of a scenario’s mass.

selected = solve_farmer(build_farmer(THREE, risk={"cvar": (2/3, 0.25)}))
plan = plan_of(selected)
mean, profits = grade_plan(plan, THREE)
print("weight   wheat   corn   beets   mean profit   worst profit ($)")
for weight in [0.0, 0.25, 0.5, 1.0]:
    selected = solve_farmer(build_farmer(THREE, risk={"cvar": (2 / 3, weight)}))
    plan = plan_of(selected)
    mean, profits = grade_plan(plan, THREE)
    print(
        f"{weight:6.2f} {plan[0]:7.1f} {plan[1]:6.1f} {plan[2]:7.1f}"
        f" {mean:13,.0f} {profits.min():16,.0f}"
    )
weight   wheat   corn   beets   mean profit   worst profit ($)
  0.00   170.0   80.0   250.0       108,390           48,820
  0.25   100.0  100.0   300.0       107,100           56,800
  0.50   100.0  100.0   300.0       107,100           56,800
  1.00   100.0   25.0   375.0        86,600           59,950

The other risk measures, and why they disagree

Birge and Louveaux §2.9, pp. 84--87, defines the measures this course uses: value at risk (9.3), the four coherence axioms of Artzner et al. (Definition 2.1, p. 85), CVaR (9.5)--(9.7) with its linear-programming form (9.8)--(9.10), the CVaR constraint (9.11)--(9.13), and robust optimization (9.15). Section 2.5, p. 68, adds expected downside risk from Eppen, Martin and Schrage:

u(ξ(ω))≥g−qT(ω)y(ω),u≥0,Eξ u(ξ)≤l.(5.1–5.3)u(\xi(\omega)) \ge g - q^{\mathsf T}(\omega)y(\omega), \qquad u \ge 0, \qquad \mathbb E_\xi\,u(\xi) \le l. \tag{5.1--5.3}

That is the CVaR epigraph with the 1/(1−α)1/(1-\alpha) scaling removed and the threshold fixed at a target gg instead of optimized over ν\nu — which is the clearest way to see what ν\nu is doing in the CVaR form.

⚠ Neither section contains a worked numerical example. The book supplies the definitions; the numbers below are this course’s, computed on those definitions.

# Four plans, seven measures. Every plan is graded with recourse re-optimized.
def risk_profile(plan, target=100_000.0):
    _, p = grade_plan(plan, THREE)
    p = np.sort(p)
    out = {
        "E": p.mean(),
        "worst": p[0],
        "std dev": p.std(ddof=1),
        "MAD": np.abs(p - p.mean()).mean(),
    }
    for alpha in (2 / 3, 0.9):
        k = max(1, int(np.floor((1 - alpha) * len(p))))
        out[f"CVaR_{alpha:.2f}"] = p[:k].mean()
    out[f"EDR_{int(target / 1000)}k"] = np.maximum(target - p, 0).mean()
    return out


plans = [
    ("risk neutral (RP)", rp_plan),
    ("floor $56,000", [p for f, p, _ in frontier if f == 56_000][0]),
    ("Table 4 / worst case", table_4_plan),
    ("robust minimax (9.15)", plan_of(mm)),
]
header = None
for label, plan in plans:
    row = risk_profile(plan)
    if header is None:
        header = list(row)
        print(f"{'plan':>22s}" + "".join(f"{h:>12s}" for h in header))
    print(f"{label:>22s}" + "".join(f"{row[h]:12,.0f}" for h in header))

print("\nThe robust model returns exactly the Table 4 plan, which is what Birge and")
print("Louveaux assert on p. 19 without solving it.")
                  plan           E       worst     std dev         MAD   CVaR_0.67   CVaR_0.90    EDR_100k
     risk neutral (RP)     108,390      48,820      59,096      39,713      48,820      48,820      17,060
         floor $56,000     107,246      56,000      47,364      34,164      56,000      56,000      14,667
  Table 4 / worst case      86,600      59,950      26,650      17,767      59,950      59,950      17,817
 robust minimax (9.15)      86,600      59,950      26,650      17,767      59,950      59,950      17,817

The robust model returns exactly the Table 4 plan, which is what Birge and
Louveaux assert on p. 19 without solving it.

Read the last two columns against each other.

The worst-case plan has the best worst case, the best standard deviation, the best mean absolute deviation and the best CVaR at every confidence level — and the worst expected downside risk against a $100,000 target of all four plans, worse even than the plan that ignores risk entirely. It gives up so much upside that it falls short of $100,000 on average more often than the risk-neutral plan does.

“Less risky” is not a total order. Which plan is safest depends on which question you asked, and four numbers on the running example make that concrete in a way an axiom list does not.

# The expected-downside-risk frontier, B&L (5.1)-(5.3), p. 68, at g = $100,000.
TARGET = 100_000.0

# What is the smallest achievable expected downside risk? Ask, rather than guess:
# a risk CONSTRAINT can be infeasible, where a risk OBJECTIVE never is.
m = build_farmer(THREE)
m.u = pyo.Var(m.SCENARIOS, domain=pyo.NonNegativeReals, units=u.USD)
m.u_def = pyo.Constraint(
    m.SCENARIOS, rule=lambda m, i: m.u[i] >= TARGET * u.USD - m.scenarios[i].profit
)
m.objective.deactivate()
m.min_edr = pyo.Objective(expr=sum(m.u[i] for i in m.SCENARIOS) / 3, sense=pyo.minimize)
solve_farmer(m)
edr_floor = pyo.value(m.min_edr)
print(f"smallest attainable expected downside risk = ${edr_floor:,.0f}")
print(f"any limit below that is infeasible\n")

print(
    f"{'limit l':>10s} {'wheat':>8s} {'corn':>8s} {'beets':>8s}"
    f" {'E[profit]':>12s} {'achieved EDR':>14s} {'worst':>10s}"
)
for level in [20_000, 17_000, 15_000, 14_000]:
    m = solve_farmer(build_farmer(THREE, risk={"downside": (TARGET, level)}))
    p = plan_of(m)
    e, scenario_profits = grade_plan(p, THREE)
    achieved = np.maximum(TARGET - scenario_profits, 0).mean()
    print(
        f"{level:10,d} {p[0]:8.1f} {p[1]:8.1f} {p[2]:8.1f} {e:12,.0f}"
        f" {achieved:14,.0f} {scenario_profits.min():10,.0f}"
    )
smallest attainable expected downside risk = $13,758
any limit below that is infeasible

   limit l    wheat     corn    beets    E[profit]   achieved EDR      worst
    20,000    170.0     80.0    250.0      108,390         17,060     48,820
    17,000    167.9     82.1    250.0      108,375         17,000     49,000
    15,000    114.3    100.0    285.7      107,429         15,000     55,000
    14,000    100.0     71.4    328.6      101,176         14,000     58,000

Uncertain prices, not just uncertain yields

Everything so far has held prices fixed and let yields vary. Birge and Louveaux flag the other direction in the body text on p. 6 — “The influence of price fluctuations, illustrated by the dramatic price increases in 2007, is discussed in Exercise 8” — and then pose it as Exercise 8, “Data fluctuations”, p. 19, with its own data table:

WheatCornSugar Beets
Yield (T/acre)2.5320
Planting cost ($/acre)180280310
Selling price ($/T)30017041 under 6000 T, 11 above

“Consider a model with a random selling price of wheat being 300 or 220 with equal probability. Purchase prices are as before 40% higher than selling prices. … How much would a farmer be willing to pay for a perfect forecast on the selling price of wheat?”

Nothing in the builder needs to change: a scenario already carries its own prices alongside its own yields.

# Exercise 8 data, p. 19. Purchase prices are 40% above selling prices.
EX8_PLANT_COST = {"WHEAT": 180.0, "CORN": 280.0, "BEETS": 310.0}
EX8_SELL_PRICE = {
    "WHEAT": 300.0,
    "CORN": 170.0,
    "BEETS_FAVORABLE": 41.0,
    "BEETS_UNFAVORABLE": 11.0,
}


def price_scenario(wheat_price, yield_multiplier=1.0):
    """One scenario of Exercise 8: a wheat selling price, optionally with a yield."""
    sell = dict(EX8_SELL_PRICE, WHEAT=wheat_price)
    return {
        "yield": {c: MEAN_YIELD[c] * yield_multiplier for c in CROPS},
        "sell": sell,
        "buy": {c: 1.4 * sell[c] for c in FEED_CROPS},
    }


# Wait-and-see: what the farmer would do knowing next year's wheat price.
wait_and_see = {}
for price in (300.0, 220.0):
    m = solve_farmer(build_farmer([price_scenario(price)], plant_cost=EX8_PLANT_COST))
    wait_and_see[price] = pyo.value(m.expected_profit)
    p = plan_of(m) + 0.0  # normalize any -0.0 the LP returns
    print(
        f"perfect forecast, wheat at ${price:.0f}/T:"
        f"  acres {p[0]:5.1f} / {p[1]:5.1f} / {p[2]:5.1f}"
        f"   profit ${wait_and_see[price]:,.0f}"
    )

# Here-and-now: one plan, both prices, recourse after the price is revealed.
price_sp = solve_farmer(
    build_farmer(
        [price_scenario(300.0), price_scenario(220.0)], plant_cost=EX8_PLANT_COST
    )
)
price_plan = plan_of(price_sp)
rp_price = pyo.value(price_sp.expected_profit)
ws_price = np.mean(list(wait_and_see.values()))
print(
    f"\nstochastic plan: acres {price_plan[0]:5.1f} / {price_plan[1]:5.1f}"
    f" / {price_plan[2]:5.1f}   E[profit] ${rp_price:,.0f}"
)
print(f"WS   = ${ws_price:,.0f}")
print(f"RP   = ${rp_price:,.0f}")
print(f"EVPI = ${ws_price - rp_price:,.0f}   <- Exercise 8's question")

# EEV: plan at the mean price, then live with both.
mean_price_model = solve_farmer(
    build_farmer([price_scenario(260.0)], plant_cost=EX8_PLANT_COST)
)
eev, _ = grade_plan(
    plan_of(mean_price_model),
    [price_scenario(300.0), price_scenario(220.0)],
    plant_cost=EX8_PLANT_COST,
)
print(f"EEV  = ${eev:,.0f}")
print(f"VSS  = ${rp_price - eev:,.0f}")
perfect forecast, wheat at $300/T:  acres 500.0 /   0.0 /   0.0   profit $167,880
perfect forecast, wheat at $220/T:  acres 120.0 /  80.0 / 300.0   profit $131,000

stochastic plan: acres 200.0 /   0.0 / 300.0   E[profit] $137,880
WS   = $149,440
RP   = $137,880
EVPI = $11,560   <- Exercise 8's question
EEV  = $137,880
VSS  = $0

EVPI is $11,560 and VSS is $0 — at the same time

Lecture 10 makes the point that EVPI and VSS measure different things and do not order each other. Exercise 8 is a worked instance on the running example: a perfect price forecast is worth $11,560 a year, and the stochastic model itself is worth nothing over simply plugging in the mean price.

The reason is checkable, which is what makes it teachable.

# Why VSS = 0 here: does the recourse actually move with the price?
for i in price_sp.SCENARIOS:
    b = price_sp.scenarios[i]
    sales = {s: pyo.value(b.sales[s]) for s in SALE_TYPES}
    buys = {c: pyo.value(b.purchases[c]) for c in FEED_CROPS}
    label = "wheat $300" if i == 0 else "wheat $220"
    print(
        f"{label}:  sales "
        + ", ".join(f"{k} {v:,.0f}" for k, v in sales.items())
        + "   purchases "
        + ", ".join(f"{k} {v:,.0f}" for k, v in buys.items())
    )

identical = all(
    abs(
        pyo.value(price_sp.scenarios[0].sales[s])
        - pyo.value(price_sp.scenarios[1].sales[s])
    )
    < 1e-6
    for s in SALE_TYPES
) and all(
    abs(
        pyo.value(price_sp.scenarios[0].purchases[c])
        - pyo.value(price_sp.scenarios[1].purchases[c])
    )
    < 1e-6
    for c in FEED_CROPS
)
print(f"\nrecourse identical in both price scenarios: {identical}")
wheat $300:  sales WHEAT 300, CORN 0, BEETS_FAVORABLE 6,000, BEETS_UNFAVORABLE 0   purchases WHEAT 0, CORN 240
wheat $220:  sales WHEAT 300, CORN 0, BEETS_FAVORABLE 6,000, BEETS_UNFAVORABLE 0   purchases WHEAT 0, CORN 240

recourse identical in both price scenarios: True

The second-stage decision does not move. Both prices leave the farmer a wheat seller, so no decision flips, and q(ξ)Tyq(\xi)^{\mathsf T}y is then affine in the price. An affine function commutes with the expectation, so Eξ Q(x,ξ)=Q(x,E ξ)\mathbb E_\xi\,Q(\mathbf x,\xi) = Q(\mathbf x, \mathbb E\,\xi) and the mean-price model is exact — hence VSS=0VSS = 0.

Birge and Louveaux ask for exactly this argument in Exercise 9, p. 20: show that for the news vendor with independent random prices and demands the solution is the one obtained with qq and rr replaced by their expected values, and “indicate under which conditions the same proposition is true for the farmer’s problem.” The condition is the one just demonstrated — the recourse must not change with the price. It is a property of this instance, not a theorem: a wheat price low enough to make the farmer a buyer would break it.

⚠ Note what did not happen: making prices random did not make the problem nonlinear.

# Prices AND yields: two prices x three yields = six scenarios.
joint = [
    price_scenario(price, factor)
    for price in (300.0, 220.0)
    for factor in (1.2, 1.0, 0.8)
]
joint_model = solve_farmer(build_farmer(joint, plant_cost=EX8_PLANT_COST))
p = plan_of(joint_model)
print(
    f"acres {p[0]:.1f} / {p[1]:.1f} / {p[2]:.1f}"
    f"   E[profit] ${pyo.value(joint_model.expected_profit):,.0f}"
)
print(
    "scenario profits  "
    + " | ".join(
        f"${pyo.value(joint_model.scenarios[i].profit):,.0f}"
        for i in joint_model.SCENARIOS
    )
)
print(
    f"\nobjective polynomial degree = "
    f"{joint_model.objective.expr.polynomial_degree()}   (1 means linear)"
)
acres 250.0 / 0.0 / 250.0   E[profit] $135,880
scenario profits  $231,380 | $152,880 | $74,380 | $187,380 | $118,880 | $50,380

objective polynomial degree = 1   (1 means linear)

Each scenario block owns both its price data and its sales variables, so the revenue term qsTysq_s^{\mathsf T}\mathbf y_s is a constant times a variable — data times decision, never decision times decision. The extensive form stays a linear program.

The real costs of adding price uncertainty are different ones, and both are worth naming:

  • the scenario count multiplies, here 2×3=62\times3 = 6; and

  • the crop-wise separability of §1.1d is lost, so the closed-form QiQ_i used earlier in this notebook no longer supply a solver-free reference answer.

Modeling qualifications

  • A continuous random vector has uncountably many outcomes but can have finite dimension. A probability density can exceed one; its integral is one.

  • These historical farmer data are Birge and Louveaux (2011), 2nd ed., §1.1a–c, pp. 4–11. They are not current crop prices or policy.

  • The acreage heuristic applies to this data set. Higher purchase prices alone do not imply that buying feed is always suboptimal; the low-yield optimum buys corn.

  • Information values use §§4.1–4.3, pp. 163–166. Stage decisions are distinguished by information timing, not expense or irreversibility.