Robust decisions across geometries¶
Once you accept that you should optimize against a worst case, the next question is which worst case. This example takes one small decision problem and solves it four times, changing only the shape of the ambiguity set:
with \(\mathcal{Q}\) ranging over the KL, \(\chi^2\), total-variation, and
Wasserstein balls. The outcomes are [1, 2, 3, 10] under a uniform nominal
— three ordinary values and one outlier — because the whole point is to see
how hard each geometry leans on that 10.
What the comparison shows¶
At \(\rho = 0\) every ball collapses to \(\{\mathrm{nominal}\}\), so all four formulations must return the same answer: the plain empirical-risk decision, which for squared error is just the nominal mean, \(4.0\). That shared starting point is what makes the rest of the plot comparable at all.
As \(\rho\) grows, each robust decision \(x^\star\) is dragged toward the outlier, but at different rates, and the ordering is a property of the divergence rather than of the solver:
- Total variation moves in discrete-feeling jumps — it can relocate a block of mass wholesale, so it reacts fast and early.
- KL is the most reluctant to fully abandon a scenario, since driving \(q_i \to 0\) costs it unboundedly.
- \(\chi^2\) sits in between, and in the interior regime it matches the familiar \(\mathbb{E}[\cdot] + \sqrt{\rho \operatorname{Var}[\cdot]}\) variance-penalty form.
- Wasserstein is the only one that cares about the geometry of the outcome space rather than just the labels: it is given the squared-distance cost matrix, so moving mass from \(1\) to \(10\) costs far more than moving it from \(3\) to \(10\).
That last distinction is the one I find easiest to forget. The \(\phi\)-divergence family is blind to how far apart the scenarios are; it only sees probabilities. Wasserstein is not.
A note on cost¶
Every point on every curve is a nested solve — an outer
MinimaxSolver whose objective internally
runs an ambiguity set's own dual solve on each iteration. Iteration counts
multiply, so the budgets at the top of the script are cut down hard from the
defaults, and each set gets its own step size.
The \(\chi^2\) step size is not arbitrary
Its conjugate \(\phi^\ast(s) = s + s^2/4\) grows faster than KL's
logsumexp, and an aggressive step size makes the joint dual solve
diverge to NaN silently rather than loudly. KL tolerates \(0.1\) here;
\(\chi^2\) gets \(0.05\) for a reason.
Also worth knowing, since it looks like it should not work: the outer
gradient flows correctly through worst_case_expectation even though the
inner dual variable is solved for under detach. That is the envelope
theorem — at the inner optimum the gradient contribution through the dual
variable vanishes, so only the final re-evaluation needs to stay attached to
the graph.
Source¶
"""Comparing the robust decision across all four ambiguity-set geometries.
Problem: choose a single scalar decision `x` to minimize the worst-case
expected squared error against four observed outcomes,
min_x sup_{q in ambiguity_set(nominal, radius)} E_q[(outcomes - x) ** 2]
At `radius = 0` every ambiguity set collapses to `{nominal}`, so every
formulation recovers the same ordinary empirical-risk decision (the
nominal-weighted mean of `outcomes`). As `radius` grows, each formulation's
robust decision `x*` is pulled toward the outcome that would hurt most if
an adversary reallocated probability mass there; how quickly, and how far,
depends on the divergence geometry underneath the ambiguity set.
Run:
python examples/03_robust_decision_across_ambiguity_sets.py
"""
from collections.abc import Callable
import matplotlib.pyplot as plt
import torch
from _plotting import save_figure
from optora.dro import (
ChiSquareAmbiguitySet,
KLAmbiguitySet,
MinimaxProblem,
MinimaxSolver,
TotalVariationAmbiguitySet,
WassersteinAmbiguitySet,
)
from optora.solvers import GradientDescent
OUTCOMES = torch.tensor([1.0, 2.0, 3.0, 10.0], dtype=torch.float64)
NOMINAL = torch.full_like(OUTCOMES, 1.0 / OUTCOMES.numel())
COST = (OUTCOMES.unsqueeze(0) - OUTCOMES.unsqueeze(1)) ** 2
# Nested outer/inner solver composition is expensive in eager PyTorch (see
# `progress/decisions.md`), so both budgets below are tuned down
# aggressively from each solver's own defaults; this is a demo of
# qualitative behavior, not a tight-tolerance convergence test.
# The outer solver uses `check_interval=1` because one of its steps costs a
# full inner dual solve, which dwarfs the synchronization a check costs.
OUTER_SOLVER = GradientDescent(step_size=0.01, max_iter=120, tol=1e-7, check_interval=1)
KL_DUAL_SOLVER = GradientDescent(step_size=0.1, max_iter=200, tol=1e-8)
CHI_SQUARE_DUAL_SOLVER = GradientDescent(step_size=0.05, max_iter=300, tol=1e-8)
def loss_fn(x: torch.Tensor) -> torch.Tensor:
return (OUTCOMES - x) ** 2
def robust_decision(ambiguity_set: object, radius: float) -> float:
problem = MinimaxProblem(
ambiguity_set=ambiguity_set, # type: ignore[arg-type]
loss_fn=loss_fn,
initial_point=torch.tensor(OUTCOMES.mean().item(), dtype=torch.float64),
)
result = MinimaxSolver(solver=OUTER_SOLVER).solve(problem)
return result.point.item()
def main() -> None:
erm_decision = OUTCOMES.mean().item()
print(
f"empirical-risk decision (every formulation at radius=0): {erm_decision:.4f}"
)
families: dict[str, Callable[[float], object]] = {
"KL": lambda radius: KLAmbiguitySet(
nominal=NOMINAL, radius=radius, dual_solver=KL_DUAL_SOLVER
),
"chi-square": lambda radius: ChiSquareAmbiguitySet(
nominal=NOMINAL, radius=radius, dual_solver=CHI_SQUARE_DUAL_SOLVER
),
"total variation": lambda radius: TotalVariationAmbiguitySet(
nominal=NOMINAL, radius=radius
),
"Wasserstein": lambda radius: WassersteinAmbiguitySet(
nominal=NOMINAL,
cost=COST,
radius=radius,
),
}
radii = [0.0, 0.02, 0.05, 0.1, 0.2, 0.4]
fig, ax = plt.subplots(figsize=(7, 4))
for name, build_ambiguity_set in families.items():
decisions = [
robust_decision(build_ambiguity_set(radius), radius) for radius in radii
]
print(f"{name:>16}: " + " -> ".join(f"{value:.3f}" for value in decisions))
ax.plot(radii, decisions, marker="o", label=name)
ax.axhline(
erm_decision, color="gray", linestyle="--", label="empirical risk (radius=0)"
)
ax.set_xlabel("ambiguity radius")
ax.set_ylabel("robust decision x*")
ax.set_title("robust decision vs. radius, across ambiguity-set geometries")
ax.legend()
fig.tight_layout()
save_figure(fig, "03_robust_decision_across_ambiguity_sets")
if __name__ == "__main__":
main()