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

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

Advanced Topics in Stochastic Programming

This notebook contains advanced topics for optimization under uncertainty.

Sample average approximation

Sample average approximation (SAA) replaces an expectation with a finite sample:

[ z_N = \min_{\mathbf{x}\in X}\frac{1}{N}\sum_{i=1}^{N}g(\mathbf{x},\boldsymbol{\xi}^i), \qquad \boldsymbol{\xi}^i \overset{\mathrm{iid}}{\sim} P. ]

A responsible SAA study separates:

  • a training sample used to choose (\mathbf{x}_N);

  • a larger, independent evaluation sample used to estimate out-of-sample performance; and

  • repeated samples used to measure solution variability.

One SAA solve is not a statistical guarantee. Sample size, random seed, and evaluation protocol are part of the model. A fixed candidate must be independent of assessment data. Within an assessment batch, candidate evaluation and re-optimization may deliberately share common random numbers; the paired gap is nonnegative. Independent replications estimate its uncertainty. See Mak, Morton and Wood (1999), and Bayraksan and Morton (2006).

Quadrature and sparse grids

Quadrature replaces an integral with a weighted sum,

[ \int f(x),dx \approx \sum_{i=1}^{n}\omega_i f(x_i). ]

Tensor-product quadrature grows exponentially with dimension. Sparse grids retain selected tensor-product points and can be effective for smooth, moderately dimensional integrands. They do not universally “defeat” dimensionality: performance depends on dimension, regularity, anisotropy, and the grid rule.

For (y(x)=7x^3-8x^2-3x+3) on ([-1,1]):

  • the exact integral is (2/3);

  • the endpoint trapezoid gives (-10); and

  • two-point Gauss--Legendre quadrature is exact because the integrand is cubic.

Exact cubic together with the two-point trapezoidal and Gauss--Legendre interpolants.
# Install course dependencies before importing Pyomo on Colab.
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()

import numpy as np

lower, upper = -1.0, 1.0


def integrand(x):
    return 7 * x**3 - 8 * x**2 - 3 * x + 3


# Odd powers integrate to zero; integrate -8*x**2 + 3 analytically.
exact = 2 / 3
trapezoid = (upper - lower) * (integrand(lower) + integrand(upper)) / 2
# Two nodes integrate polynomials through degree 3 exactly on [-1, 1].
nodes, weights = np.polynomial.legendre.leggauss(2)
gauss_legendre = sum(weights * integrand(nodes))

print(f"exact: {exact:.12f}")
print(f"trapezoid: {trapezoid:.12f}")
print(f"two-point Gauss--Legendre: {gauss_legendre:.12f}")
assert abs(gauss_legendre - exact) < 1e-12
exact: 0.666666666667
trapezoid: -10.000000000000
two-point Gauss--Legendre: 0.666666666667

Python implementations

NumPy supplies one-dimensional Gauss--Legendre nodes through numpy.polynomial.legendre.leggauss. The optional Tasmanian package provides several sparse-grid constructions.

The course does not require Tasmanian. This notebook keeps the executable calculation dependency-light and uses the repository figure below for the sparse-grid concept.

The comparison uses the same number of points in two dimensions:

  • the sparse grid is a union of tensor products of nested Clenshaw--Curtis nodes; and

  • the Monte Carlo sample uses a fixed random seed.

Visual coverage alone does not establish accuracy. Compare integration error on an independent test problem.

Nested two-dimensional sparse-grid points beside the same number of seeded Monte Carlo samples.

A sample average approximation study

This computational study builds sampled farmer models, compares their plans as sample size changes, measures the optimism of sampled optima, and estimates a multiple-replication optimality-gap bound.

The data are the farmer example of Birge and Louveaux, Introduction to Stochastic Programming, 2nd ed., §1.1a–b, pp. 4–8. Acreage is chosen before yields are known; purchases and sales adapt afterward. The core farmer guide supplies every balance and the complete three-scenario solutions.

A continuous law to sample. The book’s three equally likely yield vectors at 80%, 100%, and 120% form an exact finite law; using all three is not an approximation to that law. Here instead one continuous multiplier scales every crop:

Ξ∼Uniform⁡(0.8,1.2).\Xi\sim\operatorname{Uniform}(0.8,1.2).

Section 1.1d, pp. 11–15, gives each crop an independent uniform yield on the same marginal interval. This study uses perfectly correlated uniform yields. The joint laws differ, but the marginal laws match. At fixed acreage the farmer’s recourse separates by crop, so linearity of expectation gives the same population expected-cost function and risk-neutral optimum under either dependence model. Independence is unnecessary for that equality. The full derivation and a controlled tail-risk comparison are in Further Modeling Examples.

A common multiplier also permits one-dimensional quadrature. Independent yields would produce a three-dimensional joint integral, although separability reduces its expected-cost calculation to three one-dimensional integrals. Dependence still changes the distribution of total profit and can change a CVaR-optimal policy.

Cost convention. This optional study minimizes net cost. The core farmer handouts maximize its negative, profit; the shared three-scenario profit of $108,390\$108,390 is therefore a cost of −108,390-108,390 here. Continuous uniform and three-point marginals are different, so their risk-neutral optima need not agree. The continuous law is a teaching assumption, not a fitted agricultural model.

# The scenario structure is built with Pyomo Blocks: the stage 1 acreage lives on
# the model, and each sampled realization gets its own Block holding that
# sample's stage 2 purchases and sales. This is the same pattern used in
# "Stochastic Programming" (notebook 4/SP); the only difference is that the
# blocks are now indexed by a sample number instead of a named scenario, and
# there can be hundreds of them.
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
from scipy import stats

# Pyomo's unit library has acres and metric tons but no money, so declare one.
if not hasattr(u, "USD"):
    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

# Birge & Louveaux, 2nd ed., Table 1, p. 5: mean yields [T/acre], planting costs
# [USD/acre], purchase and selling prices [USD/T], and the cattle feed
# requirements [T]. The 6000 T quota caps sales at the favorable beet price.
CROPS = ["WHEAT", "CORN", "BEETS"]
FEED_CROPS = ["WHEAT", "CORN"]
SALE_TYPES = ["WHEAT", "CORN", "BEETS_FAVORABLE", "BEETS_UNFAVORABLE"]

MEAN_YIELD = {"WHEAT": 2.5, "CORN": 3.0, "BEETS": 20.0}
PLANT_COST = {"WHEAT": 150.0, "CORN": 230.0, "BEETS": 260.0}
BUY_PRICE = {"WHEAT": 238.0, "CORN": 210.0}
SELL_PRICE = {
    "WHEAT": 170.0,
    "CORN": 150.0,
    "BEETS_FAVORABLE": 36.0,
    "BEETS_UNFAVORABLE": 10.0,
}
FEED_NEED = {"WHEAT": 200.0, "CORN": 240.0}
TOTAL_LAND = 500.0
BEET_QUOTA = 6000.0


def build_saa_model(multipliers, weights=None, check_units=False):
    """Extensive form over a list of yield multipliers, one Block per sample.

    Arguments:
        multipliers: yield multipliers, one per sample. Each scales all three
            mean yields.
        weights: probability weight of each sample. Defaults to 1/N, which is
            the sample average approximation. Passing quadrature weights
            instead turns the same builder into a deterministic integration
            rule, which is how the reference answer below is computed.
        check_units: run assert_units_consistent. Left off by default because
            it walks every constraint on every block, and these models are
            built hundreds of times; it is exercised on a small model below.

    Return:
        A Pyomo model whose objective is the weighted average cost in USD.
    """
    multipliers = np.asarray(multipliers, dtype=float)
    n = len(multipliers)
    if weights is None:
        weights = np.full(n, 1.0 / n)
    weights = np.asarray(weights, dtype=float)
    assert len(weights) == n, "one weight per sample"

    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.SAMPLES = pyo.Set(initialize=range(n))

    # Stage 1: acres of each crop, chosen before any yield is observed [acre]
    m.acreage = pyo.Var(m.CROPS, domain=pyo.NonNegativeReals, units=u.acre)

    # Stage 1 cost [USD]
    m.planting_cost = sum(PLANT_COST[c] * USD_PER_ACRE * m.acreage[c] for c in CROPS)

    # Stage 1 constraint: the farm is 500 acres [acre]
    m.total_land_constraint = pyo.Constraint(
        expr=pyo.summation(m.acreage) <= TOTAL_LAND * u.acre
    )

    def scenario_block_rule(b, i):
        """Stage 2 for sample i. `b` is the block; `m` comes from the closure."""
        # Stage 2 decisions, made after the yield is observed [T]
        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.purchase_cost = sum(
            BUY_PRICE[c] * USD_PER_TON * b.purchases[c] for c in FEED_CROPS
        )
        b.sales_revenue = sum(
            SELL_PRICE[s] * USD_PER_TON * b.sales[s] for s in SALE_TYPES
        )

        # Yields realized in this sample [T/acre]
        y = {c: MEAN_YIELD[c] * multipliers[i] for c in CROPS}

        # Feed requirements: grow it, buy it, or do not sell it [T]
        b.wheat_constraint = pyo.Constraint(
            expr=y["WHEAT"] * TON_PER_ACRE * m.acreage["WHEAT"]
            + b.purchases["WHEAT"]
            - b.sales["WHEAT"]
            >= FEED_NEED["WHEAT"] * u.metric_ton
        )
        b.corn_constraint = pyo.Constraint(
            expr=y["CORN"] * TON_PER_ACRE * m.acreage["CORN"]
            + b.purchases["CORN"]
            - b.sales["CORN"]
            >= FEED_NEED["CORN"] * u.metric_ton
        )
        # Beet sales cannot exceed beet production [T]
        b.beets_constraint = pyo.Constraint(
            expr=y["BEETS"] * TON_PER_ACRE * m.acreage["BEETS"]
            - b.sales["BEETS_FAVORABLE"]
            - b.sales["BEETS_UNFAVORABLE"]
            >= 0 * u.metric_ton
        )
        b.sales["BEETS_FAVORABLE"].setub(BEET_QUOTA)

    # One Block per sample: identical structure, different yield data
    m.scenarios = pyo.Block(m.SAMPLES, rule=scenario_block_rule)

    # Weighted average cost [USD]
    m.objective = pyo.Objective(
        expr=m.planting_cost
        + sum(
            weights[i] * (m.scenarios[i].purchase_cost - m.scenarios[i].sales_revenue)
            for i in range(n)
        ),
        sense=pyo.minimize,
    )

    if check_units:
        assert_units_consistent(m)
    return m


solver = pyo.SolverFactory("appsi_highs")
assert solver.available(), "HiGHS is required for this section"
print("HiGHS version:", solver.version())


def solve_model(m):
    """Solve and return the objective value, refusing anything but optimal."""
    results = solver.solve(m)
    assert pyo.check_optimal_termination(
        results
    ), f"Solve failed: termination={results.termination_condition}"
    return pyo.value(m.objective)


def first_stage(m):
    """The 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 saa_optimum(multipliers, weights=None):
    """Solve the sampled problem: returns (objective, acreage)."""
    m = build_saa_model(multipliers, weights)
    return solve_model(m), first_stage(m)


def evaluate_candidate(acreage, multipliers, weights=None):
    """Weighted average cost of a FIXED stage 1 plan, recourse still optimized.

    Fixing the stage 1 variables on the same model is all that separates
    "choose a plan" from "grade a plan". Every block still solves its own
    recourse problem.
    """
    m = build_saa_model(multipliers, weights)
    for c, acres in zip(CROPS, acreage):
        m.acreage[c].fix(acres)
    return solve_model(m)
HiGHS version: (1, 15, 1)

Check the builder against the book before trusting it

Passing the three multipliers 0.8, 1.0, 1.2 with equal weights rebuilds exactly the book’s three-scenario extensive form, so its answer is known: Birge and Louveaux, Table 5, p. 8, report 170 acres of wheat, 80 of corn and 250 of sugar beets, for an expected profit of $108,390\$108{,}390. The information-value comparison uses the same number as RPRP.

# Birge & Louveaux Table 5, p. 8 -- the RP solution quoted in Lecture 10.
book_cost, book_acres = saa_optimum([0.8, 1.0, 1.2])
print(f"expected profit = ${-book_cost:,.0f}   (book: $108,390)")
print(
    "acres  wheat {:.1f}  corn {:.1f}  beets {:.1f}   (book: 170, 80, 250)".format(
        *book_acres
    )
)
assert abs(-book_cost - 108390.0) < 1.0
assert np.allclose(book_acres, [170.0, 80.0, 250.0], atol=1e-6)

# Exercise the unit declarations once, on a small model.
assert_units_consistent(build_saa_model([0.8, 1.0, 1.2], check_units=True))
print("units consistent")
expected profit = $108,390   (book: $108,390)
acres  wheat 170.0  corn 80.0  beets 250.0   (book: 170, 80, 250)
units consistent

A reference answer, from the quadrature above

The common yield multiplier makes q(x)=E[f(x,Ξ)]q(\mathbf{x})=\mathbb E[f(\mathbf{x},\Xi)] a one-dimensional integral. A dense grid with trapezoid weights approximates it; the same build_saa_model accepts those weights instead of sample weights. Refining the grid checks numerical stability.

For fixed acreage, recourse cost is piecewise linear and convex in the multiplier. The slope changes when a feed shortage becomes a surplus or beets reach the premium quota. A high-order smooth-integrand rule does not remove those kinks. The table below compares grid refinements before using the finest result as a numerical reference.

This is an approximate reference, not an exact population solution or a certified quadrature error bound. The crop-wise formulas provide a separate analytic check for the uniform-marginal model.

def trapezoid_grid(n, lo=0.8, hi=1.2):
    """n-point trapezoid rule on [lo, hi] for a Uniform(lo, hi) expectation.

    The uniform density 1/(hi-lo) cancels the (hi-lo) in the quadrature
    weights, so the weights below sum to one and are directly comparable with
    the 1/N sampling weights.
    """
    nodes = np.linspace(lo, hi, n)
    weights = np.full(n, 1.0 / (n - 1))
    weights[0] = weights[-1] = 0.5 / (n - 1)
    return nodes, weights


print("grid      reference cost        wheat     corn    beets")
for n_grid in (201, 401, 801, 1601, 3201):
    nodes, weights = trapezoid_grid(n_grid)
    z_ref, x_ref = saa_optimum(nodes, weights)
    print(
        f"{n_grid:5d}   {z_ref:16,.4f}   {x_ref[0]:7.2f}  {x_ref[1]:6.2f}  {x_ref[2]:7.2f}"
    )

# Keep the finest grid as the reference for the rest of the section.
REF_NODES, REF_WEIGHTS = trapezoid_grid(3201)
Z_STAR, X_STAR = saa_optimum(REF_NODES, REF_WEIGHTS)
print(f"\nreference z* = {Z_STAR:,.2f} USD  (expected profit ${-Z_STAR:,.2f})")
grid      reference cost        wheat     corn    beets
  201      -111,237.2935    135.56   85.11   279.33
  401      -111,237.4344    135.82   85.11   279.07
  801      -111,237.4383    135.87   85.06   279.07
 1601      -111,237.4383    135.87   85.06   279.07
 3201      -111,237.4405    135.84   85.06   279.10

reference z* = -111,237.44 USD  (expected profit $111,237.44)

The last grid refinements change the displayed objective by a fraction of a dollar. The code calls the finest-grid values Z_STAR and X_STAR; those names denote a numerical grid reference here. Agreement between successive grids does not by itself bound the remaining integration or optimization error.

The reference differs from the three-point result −108,390-108,390 because a continuous uniform marginal puts probability inside the interval rather than only at the three discrete yields.

How much does the solution move with the sample size?

Each row uses one seeded training draw. It optimizes acreage, then evaluates that fixed plan on the reference grid. q_ref is this quadrature estimate of population cost. The output’s true gap column means gap relative to the numerical grid reference; it is not a certified population optimality gap.

# One independent, seeded draw per sample size.
rng = np.random.default_rng(20260920)

print("    N       z_N          q_ref(x_N)     true gap     wheat    corn   beets")
sweep = []
for N in (5, 10, 25, 50, 100, 200, 500):
    xi = rng.uniform(0.8, 1.2, N)
    z_N, x_N = saa_optimum(xi)
    q_ref = evaluate_candidate(x_N, REF_NODES, REF_WEIGHTS)
    sweep.append((N, z_N, q_ref))
    print(
        f"{N:5d}  {z_N:12,.2f}  {q_ref:14,.2f}  {q_ref - Z_STAR:10,.2f}   "
        f"{x_N[0]:7.1f} {x_N[1]:6.1f} {x_N[2]:7.1f}"
    )
    N       z_N          q_ref(x_N)     true gap     wheat    corn   beets
    5    -98,025.42     -109,521.04    1,716.40     100.8   95.6   303.6
   10   -110,484.82     -110,321.65      915.79     116.9   84.9   298.2
   25   -101,488.46     -111,150.97       86.47     133.1   90.5   276.3
   50   -116,479.50     -110,764.34      473.10     151.2   82.5   266.4
  100   -108,751.64     -111,183.35       54.09     130.1   86.5   283.4
  200   -114,580.54     -111,223.53       13.91     139.1   83.2   277.7
  500   -111,873.95     -111,235.99        1.45     136.6   85.1   278.4

The table separates policy quality from the sampled objective:

  • q_ref(x_N) - Z_STAR estimates how much the sampled plan gives up relative to the numerical reference. These seeded rows need not improve monotonically.

  • z_N is the optimum on the training sample. One realization may lie above or below the population optimum; it does not assess the plan on fresh weather.

Independent policy assessment is needed even when training and evaluation use the same declared probability law.

The sampled optimum is optimistically biased

Let q(x)=E[f(x,Ξ)]q(\mathbf{x})=\mathbb E[f(\mathbf{x},\Xi)], let XX be the fixed feasible first-stage set, and assume iid sampling, finite expectations, and an attained population optimum x∗\mathbf{x}^*. For each training sample,

zN=min⁡x∈Xq^N(x)≤q^N(x∗)⟹E[zN]≤q(x∗)=z∗.z_N=\min_{\mathbf{x}\in X}\hat q_N(\mathbf{x})\le\hat q_N(\mathbf{x}^*) \quad\Longrightarrow\quad \mathbb E[z_N]\le q(\mathbf{x}^*)=z^*.

The inequality is in expectation; an individual sampled optimum need not be below z∗z^*. It follows from evaluating one feasible candidate, without a convexity assumption.

A paired difference reduces weather noise. For a fixed reference plan xref\mathbf{x}^{ref}, each replication uses the same sample in both terms:

Dj=zNj−q^Nj(xref)≤0,E[Dj]=E[zN]−q(xref).D_j=z_N^j-\hat q_N^j(\mathbf{x}^{ref})\le0,\qquad \mathbb E[D_j]=\mathbb E[z_N]-q(\mathbf{x}^{ref}).

If xref=x∗\mathbf{x}^{ref}=\mathbf{x}^*, this expectation is exactly the optimization bias. The implementation instead uses the numerical X_STAR. Its difference measures bias relative to that plan’s population value; the discrepancy from the ideal bias is the reference plan’s unknown population suboptimality. A grid-refinement difference alone is not a bound on that discrepancy.

# Each replication solves the sampled problem and evaluates the reference plan
# on the SAME sample, so the shared sampling noise cancels in the difference.
rng_bias = np.random.default_rng(424242)
T_bias = 100
sizes = (5, 10, 20, 40, 80, 160)
bias_mean, bias_se = [], []

print(f"replications T = {T_bias}")
print("    N     mean D (bias)      std error    worst-case D      N x |bias|")
for N in sizes:
    D = np.empty(T_bias)
    for j in range(T_bias):
        xi = rng_bias.uniform(0.8, 1.2, N)
        D[j] = saa_optimum(xi)[0] - evaluate_candidate(X_STAR, xi)
    assert (D <= 0).all(), "the inequality above is pathwise, not on average"
    bias_mean.append(D.mean())
    bias_se.append(D.std(ddof=1) / np.sqrt(T_bias))
    print(
        f"{N:5d}  {D.mean():14,.2f}  {bias_se[-1]:12,.2f}  {D.max():14,.2f}  "
        f"{N * abs(D.mean()):14,.0f}"
    )
replications T = 100
    N     mean D (bias)      std error    worst-case D      N x |bias|
    5       -1,249.73        121.62          -36.07           6,249
   10         -549.30         60.27          -11.22           5,493
   20         -301.56         48.29           -8.59           6,031
   40         -171.92         20.04           -1.28           6,877
   80          -62.18          7.49           -0.12           4,974
  160          -35.43          4.16           -0.96           5,669

Every displayed replication has Dj≤0D_j\le0 because the sampled optimum can choose the reference plan. The worst-case D column checks this pathwise inequality. The table and plot show roughly 1/N1/N decay in this experiment; this is an observed pattern, rather than a general rate proved here.

The standard errors describe the replicated paired differences, not the variability of the sampled optimum itself. The plot’s bias label uses the ideal z∗z^* notation, while the computed difference uses X_STAR; interpret it with the numerical-reference qualification above.

import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(5.5, 3.6))
ax.errorbar(
    sizes,
    [-b for b in bias_mean],
    yerr=bias_se,
    marker="o",
    linestyle="-",
    color="tab:blue",
    label=r"measured $|\mathbb{E}[z_N] - z^*|$",
)
reference = abs(bias_mean[0]) * sizes[0] / np.array(sizes, dtype=float)
ax.plot(
    sizes,
    reference,
    marker="s",
    linestyle="--",
    color="0.35",
    label=r"$1/N$ reference, anchored at $N=5$",
)
ax.set_xscale("log")
ax.set_yscale("log")
ax.set_xlabel("sample size $N$")
ax.set_ylabel("optimistic bias [USD]")
ax.set_title("Bias of the sampled optimum")
ax.legend(frameon=False, fontsize=8)
fig.tight_layout()
plt.show()
<Figure size 550x360 with 1 Axes>

An optimality-gap certificate for one candidate plan

Choose a candidate x^\hat{\mathbf{x}} using training data. Keep it fixed and assess it on TT fresh iid batches, independent of training and of each other. Within each batch, evaluate the candidate and reoptimize on the same realizations:

Gj=q^Nj(x^)−min⁡x∈Xq^Nj(x)≥0.G_j=\hat q_N^j(\hat{\mathbf{x}})-\min_{\mathbf{x}\in X}\hat q_N^j(\mathbf{x})\ge0.

With μN=E[zN]≤z∗\mu_N=\mathbb E[z_N]\le z^*,

E[Gj]=q(x^)−μN≥q(x^)−z∗.\mathbb E[G_j]=q(\hat{\mathbf{x}})-\mu_N\ge q(\hat{\mathbf{x}})-z^*.

Thus an upper confidence bound for the mean paired difference can bound the candidate’s population gap. The code uses

Gˉ+tT−1,1−δsGT.\bar G+t_{T-1,1-\delta}\frac{s_G}{\sqrt T}.

This Student-tt construction is approximate for general iid replicate gaps with finite variance; it is not a deterministic certificate or an exact finite-sample coverage theorem. Reusing the training sample for assessment would invalidate the fixed-candidate independence condition. See Mak, Morton and Wood (1999), §2, and Bayraksan and Morton (2006), §§1–2.

# Training: a deliberately small sample, so the candidate is visibly imperfect.
rng_gap = np.random.default_rng(31415)
xi_train = rng_gap.uniform(0.8, 1.2, 20)
z_train, x_hat = saa_optimum(xi_train)
q_hat = evaluate_candidate(x_hat, REF_NODES, REF_WEIGHTS)
true_gap = q_hat - Z_STAR

print(
    f"candidate from N = 20: wheat {x_hat[0]:.1f}, corn {x_hat[1]:.1f}, "
    f"beets {x_hat[2]:.1f} acres"
)
print(f"training objective z_20      = {z_train:12,.2f}  (optimistic, not a bound)")
print(f"true expected cost q(x_hat)  = {q_hat:12,.2f}")
print(f"TRUE optimality gap          = {true_gap:12,.2f}  <- known only because")
print("                                               the reference exists\n")


def gap_bound(x_candidate, T, N, delta, seed):
    """Multiple-replication optimality-gap bound for a fixed candidate.

    Returns (G, bound). Each batch is fresh and independent of the others;
    within a batch the candidate evaluation and the re-optimization share the
    realizations, which is the paired (common random numbers) estimator.
    """
    rng_local = np.random.default_rng(seed)
    G = np.empty(T)
    for j in range(T):
        xi = rng_local.uniform(0.8, 1.2, N)
        G[j] = evaluate_candidate(x_candidate, xi) - saa_optimum(xi)[0]
    t_crit = stats.t.ppf(1.0 - delta, T - 1)  # ONE-sided critical value
    bound = G.mean() + t_crit * G.std(ddof=1) / np.sqrt(T)
    return G, bound


print("  T     N      G_bar       s_G     one-sided 95% bound    covers true gap?")
for T, N, seed in ((30, 50, 9001), (30, 200, 9002), (100, 200, 9003)):
    G, bound = gap_bound(x_hat, T=T, N=N, delta=0.05, seed=seed)
    assert (G >= -1e-6).all(), "paired gaps are nonnegative by construction"
    print(
        f"{T:4d} {N:5d}  {G.mean():9,.2f}  {G.std(ddof=1):8,.2f}  "
        f"{bound:18,.2f}    {'yes' if bound >= true_gap else 'NO'}"
    )
candidate from N = 20: wheat 133.5, corn 89.1, beets 277.4 acres
training objective z_20      =  -107,210.81  (optimistic, not a bound)
true expected cost q(x_hat)  =  -111,193.97
TRUE optimality gap          =        43.47  <- known only because
                                               the reference exists

  T     N      G_bar       s_G     one-sided 95% bound    covers true gap?
  30    50     138.27     96.77              168.29    yes
  30   200      76.50     51.40               92.45    yes
 100   200      71.88     48.45               79.92    yes

Read the output with these distinctions:

  • The mean paired difference exceeds the true gap in expectation. Its conservatism includes the sampled optimizer’s bias. Larger assessment samples can reduce that bias; more independent replications reduce uncertainty in the replicate mean.

  • The interval is approximate. Finite variance and sufficient replications support a central-limit approximation. T=30T=30 is a choice for this demonstration, not a coverage guarantee for every distribution.

  • The rows are separate comparisons. Three nominal 95% bounds do not jointly confer 95% coverage. Combining separately estimated value intervals also requires accounting for their simultaneous coverage.

  • The printed TRUE optimality gap and covers true gap? use the numerical reference. They compare with a grid estimate, not an exactly known population optimum. A displayed “yes” is a diagnostic in this run, not evidence of repeated-sampling coverage.

All draws are seeded. Changing the seeds illustrates how the estimates and bounds vary; it does not change their stated assumptions.

References

  • Birge and Louveaux (2011), Introduction to Stochastic Programming, 2nd ed., Springer. The farmer’s problem and its data are §1.1a–b, pp. 4–8 (data in Table 1, p. 5); the three-scenario solution used as a check is Table 5, p. 8; the continuous uniform-yield version is §1.1d, p. 11.

  • Mak, Morton and Wood (1999), Monte Carlo bounding techniques for determining solution quality in stochastic programs, DOI: Mak et al. (1999).

  • Bayraksan and Morton (2006), Assessing solution quality in stochastic programs, DOI: Bayraksan & Morton (2006).

  • NumPy Gauss–Legendre documentation: exactness through degree 2n−12n-1.

  • Tasmanian project documentation: optional sparse-grid software.

The two comparison figures are original course illustrations. The sparse-grid picture displays nodes, not quadrature weights or an error bound.

References
  1. Mak, W.-K., Morton, D. P., & Wood, R. K. (1999). Monte Carlo bounding techniques for determining solution quality in stochastic programs. Operations Research Letters, 24(1–2), 47–56. 10.1016/s0167-6377(98)00054-6
  2. Bayraksan, G., & Morton, D. P. (2006). Assessing solution quality in stochastic programs. Mathematical Programming, 108(2–3), 495–514. 10.1007/s10107-006-0720-x