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.
| Wheat | Corn | Sugar Beets | |
|---|---|---|---|
| Yield (T/acre) | 2.5 | 3 | 20 |
| Planting cost ($/acre) | 150 | 230 | 260 |
| Selling price ($/T) | 170 | 150 | 36 under 6000 T, 10 above 6000 T |
| Purchase price ($/T) | 238 | 210 | – |
| Minimum requirement (T) | 200 | 240 | – |
Total available land: 500 acres
To help the farmer make up his mind, we can set up the following model. Let
= acres of land devoted to wheat,
= acres of land devoted to corn,
= acres of land devoted to sugar beets,
= tons of wheat sold,
= tons of wheat purchased,
= tons of corn sold,
= tons of corn purchased,
= tons of sugar beets sold at the favorable price,
= tons of sugar beets sold at the unfavorable price.

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:
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, Tdef 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 mdef 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.acreageenforces 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.
# 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 include optimal trading after yields are observed.
| Policy | Low-yield profit [$] | Mean-yield profit [$] | High-yield profit [$] | Expected profit [$] |
|---|---|---|---|---|
| Perfect-information plans | 59,950 | 118,600 | 167,666.67 | 115,405.56 |
| Shared stochastic plan | 48,820 | 109,350 | 167,000 | 108,390 |
Values are rounded after averaging; Birge and Louveaux, §4.1, pp. 163–164.
Expected value of perfect information¶
For profit maximization, . 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 and then compute . Only acreage is fixed; trading is optimized per scenario.
For the mean-yield plan (120,80,300), . See Birge and Louveaux, §4.2, p. 165.
Value of the stochastic solution¶
With the profit convention, . The general ordering 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 >= EEVPolicy 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 , and , and p. 15 solves the resulting Karush--Kuhn--Tucker conditions to , . 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.0Table 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:
the average profit over the worst of the probability mass.
With equally likely scenarios and tail mass , the worst scenario alone carries the entire tail, so and the CVaR floor is the worst-case floor. For that means every .
# 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 below 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 the worst half of the probability mass is all of the below-average scenario () plus half of the average one (). A conditional expectation cannot do this: it must take whole scenarios, so it averages of the mass when 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-6profits 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 of the probability mass, so the two ends of the range are not really CVaR problems at all:
| limit | tail mass | CVaR becomes | efficient formulation |
|---|---|---|---|
| everything | drop and every shortfall variable; take the mean | ||
| the single worst outcome | minimax: one variable with for all |
Both limits are reached at finite when the scenarios are finitely many and equally likely: with scenarios, any already gives exactly the worst case, so pushing higher buys nothing.
⚠ And pushing it higher actively costs something. The epigraph carries a coefficient, which grows without bound as : at it is 1,000, and at 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 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 () 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 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 agrees, because a positive weight on the expectation prices all three scenarios. At 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:
Do not report it. Report the plan, which is determined, and the risk measure, which is the objective.
Keep a small positive weight on the expectation (). This is what the sweep above does, and it is usually what you wanted anyway.
Grade the plan in a second solve with the acreage fixed and expected profit as the objective — what
grade_plandoes 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:
That is the CVaR epigraph with the scaling removed and the threshold fixed at a target instead of optimized over — which is the clearest way to see what 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:
| Wheat | Corn | Sugar Beets | |
|---|---|---|---|
| Yield (T/acre) | 2.5 | 3 | 20 |
| Planting cost ($/acre) | 180 | 280 | 310 |
| Selling price ($/T) | 300 | 170 | 41 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 is then affine in the price. An affine function commutes with the expectation, so and the mean-price model is exact — hence .
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 and 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 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 ; and
the crop-wise separability of §1.1d is lost, so the closed-form 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.