Calibrating the ambiguity radius¶
Every other page in this section quietly cheats. They sweep the radius, compare radii, or pick one that makes the plot legible — and none of them answers the question a reader actually has the first time they reach for DRO: what radius should I use?
Left to intuition, the answer is bad in both directions. Too small and the robust value collapses onto the empirical mean, so you have paid for a nested dual solve and bought nothing. Too large and it saturates at \(\max_i \ell_i\), which is a statement about your worst observation rather than about your problem. Neither failure announces itself.
Finite-sample theory answers it properly. If the nominal distribution is an empirical measure over \(n\) observations, then the radius is not a taste parameter at all — it is a quantile, and the ambiguity set it defines is a confidence region.
The calibration¶
Fix a functional of interest, here the mean \(\mu = \mathbb{E}[Z]\), and define the profile divergence of a candidate value \(\mu\) against the empirical distribution \(\hat{P}_n\):
This is the empirical-likelihood statistic with the \(\chi^2\)-divergence in place of the log-likelihood, and it obeys the same limit theorem: evaluated at the true mean \(\mu_0\),
Duchi and Namkoong1 build their statistical theory of \(\phi\)-divergence DRO on exactly this expansion — the robust objective is an asymptotically exact upper confidence bound on the true risk, and its width is the variance term a \(\chi^2\) ball generates. Read backwards, the theorem is a recipe. Choosing
makes \(\{q : D_{\chi^2}(q \| \hat{P}_n) \le \rho_n(\alpha)\}\) an asymptotically valid \((1-\alpha)\) confidence region, so the interval it induces on the mean,
is an asymptotically valid \((1-\alpha)\) confidence interval. The radius has stopped being a knob and become a confidence level.
The \(\chi^2\) geometry is the one where this is cleanest, because the divergence itself is the test statistic. The Wasserstein geometry admits the same programme, but the radius there has to be tied to a concentration rate rather than to a quantile, and getting a rate that does not collapse under the curse of dimensionality is its own research problem2.
Mind the factor of two
Optora's ChiSquareDivergence is
\(D_{\chi^2}(q \| p) = \sum_i (q_i - p_i)^2 / p_i\), generated by
\(\phi(t) = (t-1)^2\). Duchi and Namkoong use
\(\phi(t) = \tfrac{1}{2}(t-1)^2\), so their radius is half of the one
written above. Converting a radius between papers is the single easiest
way to be silently wrong by \(\sqrt{2}\) in every interval you report.
The sanity check it has to pass¶
The calibration is only believable if it reproduces something everybody already trusts. It does, and immediately. In the interior regime — no candidate \(q_i\) pushed to zero — the \(\chi^2\) worst-case expectation has the closed form \(\bar{Z} + \sqrt{\rho \cdot \mathrm{Var}(Z)}\), so substituting \(\rho_n(\alpha)\) and using \(\chi^2_{1,1-\alpha} = z_{1-\alpha/2}^2\) gives
the textbook normal interval, exactly. The script checks this against the
solver rather than against the algebra, and so does
tests/dro/test_phi_dro.py::test_chi_square_calibrated_radius_gives_the_normal_interval.
No scipy required
A \(\chi^2\) variable with one degree of freedom is the square of a
standard normal, so
\(\chi^2_{1,\,1-\alpha} = \big(\Phi^{-1}(1 - \alpha/2)\big)^2\) and the
whole calibration is one call to torch.special.ndtri. Optora's only
runtime dependency stays torch.
Measuring the coverage¶
An asymptotic guarantee is a promise about \(n \to \infty\), and the interesting question is how badly it is broken at the \(n\) you actually have. So the script runs the experiment the theorem describes: draw 200 independent samples from a standard exponential, build the calibrated interval for each, and count how often it contains the true mean of \(1\).
The exponential is chosen to be awkward. It has skewness \(2\), and skewness is precisely what the \(\chi^2\) limit throws away, so any under-coverage should be visible rather than buried under Monte Carlo noise.
| \(1-\alpha\) | \(n = 10\) | \(n = 40\) | \(n = 160\) |
|---|---|---|---|
| 0.50 | 0.415 | 0.510 | 0.485 |
| 0.80 | 0.725 | 0.725 | 0.785 |
| 0.90 | 0.790 | 0.830 | 0.865 |
| 0.95 | 0.855 | 0.880 | 0.935 |
| 0.99 | 0.920 | 0.945 | 0.975 |
The pattern is the honest one: coverage is systematically below nominal, and the worst deviation shrinks from \(0.110\) at \(n = 10\) to \(0.035\) at \(n = 160\). A calibrated radius is not an exact one — it is a radius whose error you can name and watch decay.
Common random numbers
Each sample size draws its replications once and reuses them for every confidence level. The calibrated sets are nested in \(\alpha\), so the measured coverage curve is exactly monotone in the nominal level rather than monotone up to noise — which is why the script can assert monotonicity instead of merely plotting it.
Where the closed form gives up¶
There is a reason this page solves the dual instead of evaluating \(\bar{Z} + \sqrt{\rho\, s_n^2}\) directly. That formula is the interior optimum: it corresponds to the worst-case distribution
which stops being a distribution as soon as \(\rho\) is large enough to drive some \(q_i\) negative. Past that point the true worst case is smaller than the closed form, and only the dual knows it.
The script prints the most negative discrepancy over all replications, and it is a clean indicator function:
| \(1-\alpha\) | \(n = 10\) | \(n = 40\) | \(n = 160\) |
|---|---|---|---|
| 0.90 | \(\approx 0\) | \(\approx 0\) | \(\approx 0\) |
| 0.95 | \(-1.8 \times 10^{-3}\) | \(\approx 0\) | \(\approx 0\) |
| 0.99 | \(-2.1 \times 10^{-2}\) | \(\approx 0\) | \(\approx 0\) |
Entries marked \(\approx 0\) are floating-point rounding, a few times
\(10^{-16}\): the dual solve is exact to working precision there and simply
reproduces the closed form. The two negative ones are real: at
\(n = 10\) and high confidence the calibrated radius is large enough
(\(\rho = 0.38\) and \(\rho = 0.66\)) to hit the boundary of the simplex. That
is not a coincidence — it is the same small-\(n\), high-confidence corner
where the coverage table is worst. The regime in which the asymptotics
strain is the regime in which the closed form is also wrong, and
ChiSquareAmbiguitySet handles both without being
told which one it is in.
Reading the figure¶
- Left: empirical coverage against nominal confidence, one curve per sample size, with the diagonal marking exact calibration. Every curve sits below the diagonal and climbs toward it as \(n\) grows.
- Right: the signed error, coverage minus nominal, against sample size on a log axis. It trends to zero. It does not do so monotonically at every level — with 200 replications the standard error of a coverage estimate near \(0.9\) is about \(0.02\), which is exactly the size of the wobble you can see at \(1-\alpha = 0.50\).
One batched solve per side
Coverage is a Monte Carlo quantity, so the experiment needs
\(3 \times 5 \times 200 \times 2 = 6000\) dual solves. They are not run
one by one: worst_case_expectation accepts a loss of shape
\((\dots, n)\) and a tensor radius that broadcasts against the batch, so
the five confidence levels form a \((5, 1)\) column of radii and the 200
replications a \((200, n)\) loss, and one call returns all
\(5 \times 200\) values. Each sample size therefore costs two calls, one
per side of the interval, and the whole script runs in about ten
seconds.
A batched dual solve starts every element from one shared point, here
the population asymptotic optimum at the geometric mean of the radii,
which is a legitimate guess because it uses no information from the
sample being solved. It also runs until the slowest element is
stationary, so the script gives GradientDescent a generous iteration
budget; a smaller one leaves the extreme radii a few \(10^{-4}\) short of
the optimum, enough to move a coverage estimate.
Source¶
"""Calibrating the ambiguity radius from finite-sample theory.
Every DRO example has to pick a radius, and picking one by hand is the
weakest part of the whole pipeline: too small and the robust value is just
the empirical mean, too large and it saturates at the worst scenario.
Finite-sample theory removes the choice. For the chi-square ambiguity set
built on an empirical distribution of `n` points, the profile divergence
T_n(mu) = min { D_chi2(q || nominal) : E_q[Z] = mu }
evaluated at the *true* mean satisfies `n * T_n(mu_true) -> chi2_1` in
distribution, which is the empirical-likelihood / DRO calibration result of
Duchi and Namkoong. So setting
radius(n, alpha) = chi2_{1, 1 - alpha} / n
makes the ambiguity set an asymptotically valid `(1 - alpha)` confidence
region for the true distribution, and the interval it induces on the mean,
[ -sup_q E_q[-Z] , sup_q E_q[Z] ]
an asymptotically valid `(1 - alpha)` confidence interval. Nothing here is
a new solver: `ChiSquareAmbiguitySet` is used exactly as it ships, and the
only new ingredient is the radius formula.
This script measures whether that promise holds at finite `n`. It draws
many independent samples from a standard exponential (mean 1, variance 1,
skewness 2 — deliberately far from Gaussian), builds the calibrated
interval for each one, and reports the fraction of intervals that actually
cover the true mean, against the nominal confidence and against `n`.
Two things are worth watching:
* Coverage is *below* nominal at small `n`. The calibration is asymptotic,
and skewness is exactly what the chi-square limit ignores.
* The dual solve is not redundant. In the interior regime the worst-case
expectation equals `mean + sqrt(radius * variance)`, but a large radius
drives some `q_i` to zero, where that formula overstates the worst case
and only the dual is correct. The printed `worst closed-form gap` column
is the most negative such discrepancy over the replications: it is solver
noise everywhere except at small `n` and high confidence, which is
precisely the regime where the calibration is under the most strain.
References:
John C. Duchi and Hongseok Namkoong, "Learning Models with Uniform
Performance via Distributionally Robust Optimization", Annals of
Statistics 49(3), 2021.
Rui Gao, "Finite-Sample Guarantees for Wasserstein Distributionally
Robust Optimization", Operations Research 73(4), 2025.
Run:
python examples/06_calibrated_ambiguity_radius.py
"""
import math
import matplotlib.pyplot as plt
import torch
from _plotting import save_figure
from optora.dro import ChiSquareAmbiguitySet
from optora.solvers import GradientDescent
CONFIDENCE_LEVELS = (0.50, 0.80, 0.90, 0.95, 0.99)
SAMPLE_SIZES = (10, 40, 160)
NUM_REPLICATIONS = 200
SEED = 20260915
POPULATION_MEAN = 1.0
POPULATION_VARIANCE = 1.0
# The whole radius-by-replication grid is one joint solve that starts from a
# single shared point, so it runs until the *slowest* element is stationary;
# a larger budget than a lone solve needs is cheap per iteration and keeps
# every element at solver precision.
DUAL_SOLVER = GradientDescent(step_size=0.2, max_iter=2000, tol=1e-7)
def chi_square_quantile(confidence: float) -> float:
"""Return the `confidence` quantile of the chi-square law with one dof.
A chi-square variable with one degree of freedom is the square of a
standard normal, so its quantile function is the squared two-sided
normal quantile and needs no special-function code beyond
`torch.special.ndtri`.
Args:
confidence: Probability level in `(0, 1)`.
Returns:
The `confidence` quantile of `chi2_1`.
"""
normal_quantile = torch.special.ndtri(
torch.tensor((1.0 + confidence) / 2.0, dtype=torch.float64)
)
return float(normal_quantile**2)
def calibrated_radius(num_samples: int, confidence: float) -> float:
"""Return the chi-square ambiguity radius that targets `confidence` coverage.
Args:
num_samples: Number of observations in the empirical distribution.
confidence: Target coverage level in `(0, 1)`.
Returns:
`chi2_{1, confidence} / num_samples`, the radius under which the
ambiguity set is an asymptotically valid confidence region for the
data-generating distribution.
"""
return chi_square_quantile(confidence) / num_samples
def exponential_samples(num_samples: int, generator: torch.Generator) -> torch.Tensor:
"""Draw `NUM_REPLICATIONS` independent standard-exponential samples.
Args:
num_samples: Observations per replication.
generator: Seeded generator, so the reported coverage is
reproducible.
Returns:
A `(NUM_REPLICATIONS, num_samples)` tensor of nonnegative draws with
unit mean and unit variance.
"""
uniform = torch.rand(
NUM_REPLICATIONS, num_samples, generator=generator, dtype=torch.float64
)
return -torch.log(uniform)
def build_ambiguity_sets(
nominal: torch.Tensor, radius: torch.Tensor
) -> tuple[ChiSquareAmbiguitySet, ChiSquareAmbiguitySet]:
"""Build the two ambiguity sets that produce a two-sided mean interval.
Both sets are identical as sets; they differ only in where their dual
solve starts, because the lower bound is computed on the negated loss
and its multiplier `lam` therefore sits near `-mean` rather than
`mean`. The starting point is the asymptotic interior optimum implied
by the *population* moments, which is a good guess without reading
anything off the sample. A dual solve starts from one point shared by
the whole batch, so a sweep of radii starts from the interior optimum
at their geometric mean.
Args:
nominal: Uniform empirical distribution over the support.
radius: Calibrated chi-square radius, a scalar or a tensor of radii
that broadcasts against the batch of losses.
Returns:
The `(lower, upper)` ambiguity sets.
"""
typical_radius = float(torch.exp(torch.mean(torch.log(radius))))
initial_log_eta = 0.5 * math.log(POPULATION_VARIANCE / (4.0 * typical_radius))
lower = ChiSquareAmbiguitySet(
nominal=nominal,
radius=radius,
dual_solver=DUAL_SOLVER,
initial_log_eta=initial_log_eta,
initial_lam=-POPULATION_MEAN,
)
upper = ChiSquareAmbiguitySet(
nominal=nominal,
radius=radius,
dual_solver=DUAL_SOLVER,
initial_log_eta=initial_log_eta,
initial_lam=POPULATION_MEAN,
)
return lower, upper
def main() -> None:
generator = torch.Generator().manual_seed(SEED)
coverage_by_size: dict[int, list[float]] = {}
closed_form_gap_by_setting: dict[tuple[int, float], float] = {}
for num_samples in SAMPLE_SIZES:
# Common random numbers across confidence levels: the calibrated sets
# are then nested by construction, so measured coverage is exactly
# monotone in the nominal level rather than monotone up to noise.
samples = exponential_samples(num_samples, generator)
nominal = torch.full((num_samples,), 1.0 / num_samples, dtype=torch.float64)
sample_mean = torch.mean(samples, dim=-1)
sample_variance = torch.var(samples, dim=-1, unbiased=False)
# One batched solve per side covers every confidence level and every
# replication: the radii form a `(levels, 1)` column that broadcasts
# against the `(replications,)` batch of losses, so each side of the
# interval comes back as a `(levels, replications)` tensor.
radii = torch.tensor(
[calibrated_radius(num_samples, level) for level in CONFIDENCE_LEVELS],
dtype=torch.float64,
).unsqueeze(-1)
lower_set, upper_set = build_ambiguity_sets(nominal, radii)
lower = -lower_set.worst_case_expectation(-samples)
upper = upper_set.worst_case_expectation(samples)
covered = (lower <= POPULATION_MEAN) & (upper >= POPULATION_MEAN)
coverages = torch.mean(covered.to(samples.dtype), dim=-1).tolist()
asymptotic = sample_mean + torch.sqrt(radii * sample_variance)
closed_form_gaps = torch.amin(upper - asymptotic, dim=-1).tolist()
print(f"\nn = {num_samples}, {NUM_REPLICATIONS} replications")
print(" confidence radius coverage worst closed-form gap")
for confidence, radius, coverage, closed_form_gap in zip(
CONFIDENCE_LEVELS,
radii.squeeze(-1).tolist(),
coverages,
closed_form_gaps,
strict=True,
):
closed_form_gap_by_setting[num_samples, confidence] = closed_form_gap
print(
f" {confidence:>9.2f} {radius:>6.4f} {coverage:>8.3f}"
f" {closed_form_gap:>+21.2e}"
)
coverage_by_size[num_samples] = coverages
nominal_levels = torch.tensor(CONFIDENCE_LEVELS, dtype=torch.float64)
for num_samples, coverages in coverage_by_size.items():
measured = torch.tensor(coverages, dtype=torch.float64)
assert bool(torch.all(torch.diff(measured) >= 0.0)), num_samples
errors = {
num_samples: torch.tensor(coverages, dtype=torch.float64) - nominal_levels
for num_samples, coverages in coverage_by_size.items()
}
worst_error = {
num_samples: float(torch.max(torch.abs(error)))
for num_samples, error in errors.items()
}
print("\nlargest |coverage - nominal| by sample size")
for num_samples, error in worst_error.items():
print(f" n={num_samples:<5} {error:.3f}")
assert worst_error[SAMPLE_SIZES[-1]] < worst_error[SAMPLE_SIZES[0]]
# The boundary regime has to bind somewhere, or the dual solve would be
# an expensive way of evaluating `mean + sqrt(radius * variance)`.
assert closed_form_gap_by_setting[SAMPLE_SIZES[0], CONFIDENCE_LEVELS[-1]] < -1e-4
_assert_calibration_matches_normal_interval()
fig, (ax_coverage, ax_error) = plt.subplots(1, 2, figsize=(11, 4))
ax_coverage.plot(
CONFIDENCE_LEVELS,
CONFIDENCE_LEVELS,
color="black",
linestyle="--",
label="exact calibration",
)
for num_samples, coverages in coverage_by_size.items():
ax_coverage.plot(
CONFIDENCE_LEVELS, coverages, marker="o", label=f"n = {num_samples}"
)
ax_coverage.set_xlabel("nominal confidence 1 - alpha")
ax_coverage.set_ylabel("empirical coverage")
ax_coverage.set_title("calibrated radius: coverage vs. nominal level")
ax_coverage.legend()
ax_error.axhline(0.0, color="black", linestyle="--")
for index, confidence in enumerate(CONFIDENCE_LEVELS):
ax_error.plot(
SAMPLE_SIZES,
[float(errors[num_samples][index]) for num_samples in SAMPLE_SIZES],
marker="o",
label=f"1 - alpha = {confidence:.2f}",
)
ax_error.set_xscale("log")
ax_error.set_xlabel("sample size n")
ax_error.set_ylabel("coverage - nominal")
ax_error.set_title("under-coverage shrinks as the sample grows")
ax_error.legend(fontsize="small")
fig.tight_layout()
save_figure(fig, "06_calibrated_ambiguity_radius")
def _assert_calibration_matches_normal_interval() -> None:
"""Check the calibrated radius reproduces the textbook normal interval.
In the interior regime the chi-square worst-case expectation is
`mean + sqrt(radius * variance)`, so the calibrated radius
`z_{1-alpha/2}^2 / n` must reproduce the half-width
`z_{1-alpha/2} * std / sqrt(n)` exactly. This is the algebraic identity
the whole experiment rests on, checked against the solver rather than
against itself.
"""
num_samples = 160
confidence = 0.95
generator = torch.Generator().manual_seed(SEED)
sample = exponential_samples(num_samples, generator)[0]
nominal = torch.full((num_samples,), 1.0 / num_samples, dtype=torch.float64)
radius = torch.tensor(
calibrated_radius(num_samples, confidence), dtype=torch.float64
)
_, upper_set = build_ambiguity_sets(nominal, radius)
solved = float(upper_set.worst_case_expectation(sample))
normal_quantile = math.sqrt(chi_square_quantile(confidence))
standard_error = float(torch.std(sample, unbiased=False)) / math.sqrt(num_samples)
textbook = float(torch.mean(sample)) + normal_quantile * standard_error
print(
f"\ncalibrated upper bound at n={num_samples}, "
f"1 - alpha={confidence}: {solved:.9f}"
)
print(f"normal-quantile upper bound: {textbook:.9f}")
assert abs(solved - textbook) < 1e-5
if __name__ == "__main__":
main()
-
John C. Duchi and Hongseok Namkoong, "Learning Models with Uniform Performance via Distributionally Robust Optimization", Annals of Statistics 49(3), 1378–1406 (2021). arXiv:1810.08750. The companion asymptotic theory is Duchi, Glynn and Namkoong, "Statistics of Robust Optimization: A Generalized Empirical Likelihood Approach", Mathematics of Operations Research 46(3), 946–969 (2021). ↩
-
The same calibration question in the Wasserstein geometry, where the radius must instead be tied to a concentration rate: Rui Gao, "Finite-Sample Guarantees for Wasserstein Distributionally Robust Optimization: Breaking the Curse of Dimensionality", Operations Research (2025). arXiv:2009.04382. ↩