Optional extensions to the core farmer example: uniform-yield sampling, dependence, profit floors, other risk measures, and uncertain prices. For the main information-and-risk reading, use SAA, CVaR, and zero VSS.
import sys
from pathlib import Path
if "google.colab" in sys.modules:
from urllib.request import urlretrieve
base = "https://raw.githubusercontent.com/ndcbe/optimization/main/notebooks/"
for name in ["helper.py", "farmer.py"]:
urlretrieve(base + name, name)
import helper
helper.easy_install()
else:
sys.path.insert(0, str(Path.cwd().parent))
import helper
import farmer as f
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from IPython.display import display
helper.set_plotting_style()
from farmer import *
mean_model=solve_farmer(build_farmer([correlated_scenario(1)]))
mean_plan=plan_of(mean_model)
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])
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
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.
For fixed acreage, let be crop ’s feasible trading set and let be its net trading cost. The feed and beet constraints do not link trading variables from different crops. Therefore
The feasible recourse set is a Cartesian product, so the separate minima can be attained together. Linearity of expectation then requires no independence among crops, assuming the expectations exist. The first-stage land limit still couples acreage choices. A shared trading budget, storage limit, or another constraint linking crop recourse would remove this argument.
Dependence can still change a tail criterion. In a two-crop illustration, each crop earns either 0 or 100 with probability one half. Perfect positive dependence gives total profit 0 or 200, each with probability one half. Independence gives total profit 0, 100, or 200 with probabilities one quarter, one half, and one quarter. Both means are 100. The average of the lowest half of total profit is 0 in the first model and 50 in the second. These are constructed values, not additional farmer data.
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 (). Conditioning on all scenarios at or below the average-profit cutoff instead includes of the mass. The CVaR tail uses only the required half:
This differs from the cutoff-conditioned mean . Splitting the cutoff atom supplies exactly the requested tail probability.
# 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 uses more variables and larger coefficients than the equivalent minimax model. Those coefficients can make numerical scaling more difficult. Once the tail lies within a worst atom, the minimax formulation expresses the same objective without the diverging coefficient.
# 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 need not determine efficient trades in every scenario¶
The lower-tail objective rewards improvements to tail profit. It may leave good-scenario trades nonunique: those profits still enter shortfall constraints, but their shortfalls can remain zero while trades change. An optimal solve therefore need not return the maximum feasible profit in every scenario.
This also occurs with minimax objectives. It concerns the reported recourse for an optimized acreage plan, rather than an incorrect optimum. Grade the chosen acreage in a second expected-profit solve to obtain efficient trades in every positive-probability scenario.
# 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 displayed row with agrees because its positive mean-profit weight rewards profit in all positive-probability scenarios. At , the reported and separately optimized mean profits differ even though the risk solve terminates optimally and its reported trades are feasible.
To compare policies, report the optimized acreage and risk objective, then fix acreage and reoptimize trades scenario by scenario. A positive mean-profit weight also rewards efficient trades, but changes the preference model and should be chosen deliberately. Neither an optimal objective nor an optimal acreage vector is necessarily unique.
An objective that ignores improvements in some scenario outcomes can leave their recourse nonunique; it does not guarantee that every feasible recourse choice in those scenarios remains optimal. Constraints may still couple or bound those outcomes.
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¶
The information-value comparison 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.
Each scenario Block owns its price data and its sales variables. The revenue multiplies fixed scenario coefficients by decision variables, so the extensive form stays linear.
Combining the two wheat-price states with three yield states creates six scenarios in this example. Crop-wise recourse separability remains: there are still no trading constraints linking different crops. What changes is each affected crop’s cost function and the joint law of its own yield and price. The earlier yield-only closed forms cannot be reused unchanged, but random prices alone do not eliminate separability across crops. With exogenous crop-specific data, expected recourse still sums crop-wise expectations; a tail criterion requires the joint distribution of total profit.