Stochastic Programming (two-stage recourse)#
A stochastic program optimizes an expected (or risk-adjusted) cost when part of the data is uncertain. In the two-stage setting a here-and-now first-stage decision \(x\) is made before the uncertainty \(\xi\) is realized, and a wait-and-see recourse \(y\) adapts per outcome [Birge and Louveaux, 2011, Kall and Wallace, 1994]:
This is the distributional sibling of discopt.ro: robust optimization
protects the worst case over an uncertainty set, stochastic programming optimizes
the average over a distribution [Shapiro et al., 2021]. With a
finite scenario set \(\{\xi_s, p_s\}\) the expectation is \(\sum_s p_s Q(x,\xi_s)\).
The extensive form (deterministic equivalent)#
The simplest exact method builds one monolithic model — the deterministic equivalent — via a scenario-creator callback that constructs each scenario’s recourse. Here is the classic newsvendor: order \(q\) at cost \(c\), then in each demand scenario sell \(\min(q, d_s)\) at price \(r\) (revenue = negative cost).
import discopt.modeling as dm
from discopt.modeling.core import Constant
from discopt.stochastic import ScenarioSet, build_extensive_form, Expectation
scen = ScenarioSet.from_list([(0.3, {'demand': 40}),
(0.5, {'demand': 60}),
(0.2, {'demand': 80})])
def recourse(model, data, s):
d = float(data['demand'])
sales = model.continuous(f'sales_{s}', lb=0, ub=100)
model.subject_to(sales <= q) # cannot sell more than ordered
model.subject_to(sales <= Constant(d)) # cannot sell more than demanded
return Constant(-5.0) * sales # recourse cost = -revenue (price 5)
m = dm.Model('newsvendor')
q = m.continuous('q', lb=0, ub=100) # first-stage order
ef = build_extensive_form(m, scenarios=scen, recourse_builder=recourse,
first_stage_cost=Constant(2.0) * q, # cost 2/unit
risk=Expectation())
print('scenarios:', len(scen), '| expected-value objective built')
scenarios: 3 | expected-value objective built
The objective is c·q + Σ_s p_s Q_s — the scenario probabilities are baked in.
Solving it returns the classic critical-fractile order quantity. For a
newsvendor with order cost \(c\) and price \(r\), the optimal order is the smallest
\(q\) whose demand CDF reaches the critical ratio \((r-c)/r\). Here
\((5-2)/5 = 0.6\), and the demand CDF is \(F(40)=0.3\), \(F(60)=0.8\), \(F(80)=1.0\),
so the answer should be \(q^\star = 60\) — which the next cell checks rather than
takes on trust.
The Birge–Louveaux farmer problem [Birge and Louveaux, 2011] is built the same way, with per-scenario planting/selling recourse.
res_nv = m.solve()
print("status :", res_nv.status)
print("q* :", float(res_nv.x["q"]))
print("objective :", float(res_nv.objective), "(negative = expected profit)")
# Critical fractile: smallest q with F(q) >= (r - c)/r = 0.6 -> q = 60.
CRITICAL_FRACTILE_Q = 60.0
assert abs(float(res_nv.x["q"]) - CRITICAL_FRACTILE_Q) < 1e-6, res_nv.x["q"]
# And check the objective by hand: cost 2*q minus revenue 5*E[min(q, d)].
expected_sales = 0.3 * min(60, 40) + 0.5 * min(60, 60) + 0.2 * min(60, 80)
expected_cost = 2.0 * CRITICAL_FRACTILE_Q - 5.0 * expected_sales
print("hand-computed objective:", expected_cost)
assert abs(float(res_nv.objective) - expected_cost) < 1e-6
print(f"\nMatches the critical-fractile solution q* = {CRITICAL_FRACTILE_Q:.0f}.")
status : optimal
q* : 60.0
objective : -150.0 (negative = expected profit)
hand-computed objective: -150.0
Matches the critical-fractile solution q* = 60.
Risk measures: CVaR and chance constraints#
Beyond the risk-neutral expectation, discopt.stochastic provides CVaR
(Conditional Value-at-Risk) via the Rockafellar–Uryasev formulation
[Rockafellar and Uryasev, 2000], which penalizes the worst-tail scenarios:
from discopt.stochastic import CVaR
mc = dm.Model('newsvendor_cvar')
q = mc.continuous('q', lb=0, ub=100)
ef_cvar = build_extensive_form(mc, scenarios=scen, recourse_builder=recourse,
first_stage_cost=Constant(2.0) * q,
risk=CVaR(alpha=0.9)) # penalize worst 10%
aux = [v.name for v in mc._variables if v.name.startswith('cvar')]
print('CVaR auxiliaries (VaR + per-scenario shortfalls):', aux)
CVaR auxiliaries (VaR + per-scenario shortfalls): ['cvar_eta', 'cvar_u0', 'cvar_u1', 'cvar_u2']
A chance constraint \(P(g(x,\xi)\le 0)\ge 1-\varepsilon\) is imposed (under SAA) with a per-scenario indicator and a coverage budget:
from discopt.stochastic import chance_constraint
mch = dm.Model('chance'); x = mch.continuous('x', lb=0, ub=100)
demands = [30.0, 50.0, 70.0, 90.0]
z = chance_constraint(mch, [x - Constant(d) for d in demands],
probs=[0.25]*4, epsilon=0.25, big_m=1000.0)
print('indicator binaries:', len(z), '(Σ p_s z_s ≤ ε caps the violation prob.)')
indicator binaries: 4 (Σ p_s z_s ≤ ε caps the violation prob.)
Decomposition: the L-shaped method#
For many scenarios the extensive form is huge. The L-shaped method
[Van Slyke and Wets, 1969] is Benders decomposition for SP: the first stage is the
master, each scenario a subproblem, and cuts approximate the expected recourse. The
probability weighting rides in the objective, so the decomposition engine is reused
unchanged. solve_lshaped(..., solve=False) builds the model and its structure:
from discopt.stochastic import solve_lshaped
ml = dm.Model('newsvendor_lshaped')
q = ml.continuous('q', lb=0, ub=100)
res = solve_lshaped(ml, first_stage_vars=[q], scenarios=scen,
recourse_builder=recourse,
first_stage_cost=Constant(2.0) * q, solve=False)
recourse_blocks = [b for b in res.structure.blocks if 'q' not in b]
print('first-stage (complicating):', res.structure.complicating_vars)
print('separable recourse blocks:', len(recourse_blocks))
first-stage (complicating): ['q']
separable recourse blocks: 3
Progressive hedging#
Progressive hedging [Rockafellar and Wets, 1991] is a scenario-decomposition alternative: each scenario keeps its own first-stage copy, and a proximal term drives the copies to a common nonanticipative value \(\bar x = \sum_s p_s x_s\). The driver takes an injected subproblem solver; here a closed-form quadratic illustrates that PH converges to the expected-value decision:
import numpy as np
from discopt.stochastic import progressive_hedging, quadratic_subproblem_solver
targets = {0: np.array([40.0]), 1: np.array([60.0]), 2: np.array([80.0])}
ph = progressive_hedging(scen, first_stage_dim=1,
subproblem_solver=quadratic_subproblem_solver(targets),
rho=1.0)
print('x_bar =', float(ph.x_bar[0]), '(= Σ p_s a_s = 58) | converged:', ph.converged)
x_bar = 58.00000000000002 (= Σ p_s a_s = 58) | converged: True
How good is the sample? SAA statistical bounds#
When scenarios are sampled (SAA), the sample optimum is a biased-low estimate of the true optimum. Averaging independent batches gives a statistical lower bound, and evaluating a candidate on a fresh sample gives an upper bound — together an optimality-gap estimate [Mak et al., 1999]:
from discopt.stochastic import (saa_lower_bound_estimate,
saa_upper_bound_estimate, optimality_gap_estimate)
rng = np.random.default_rng(0)
lower = saa_lower_bound_estimate(rng.normal(100, 5, 20), confidence=0.95)
upper = saa_upper_bound_estimate(rng.normal(103, 4, 50), confidence=0.95)
gap = optimality_gap_estimate(lower, upper)
print('point gap:', round(gap['point_gap'], 2),
'| conservative gap:', round(gap['conservative_gap'], 2))
point gap: 4.66 | conservative gap: 7.27
References#
See the References page for full citations.