Worst-case distribution shift¶
The AmbiguitySet contract is deliberately
narrow: worst_case_expectation hands back the scalar
\(\mathbb{E}_{q^\star}[\mathrm{loss}]\) and nothing else. That is the right
API — the worst-case distribution \(q^\star\) is an implementation detail of
each formulation, and for the dual-solved sets it never exists explicitly in
memory at all. But it does mean you never get to look at the adversary.
For total variation you can, because the solution is combinatorial rather than variational. This example reconstructs \(q^\star\) by hand and then plots where the probability mass goes.
The closed form being reconstructed¶
Inside a TV ball the adversary is solving a linear program with a mass budget, and the optimal strategy is embarrassingly simple: sort the scenarios by loss, then move mass from the cheapest scenarios into the single most expensive one until the budget
runs out. Cheapest scenario first, drained completely before touching the
next. Optora implements exactly this, vectorized, in
TotalVariationAmbiguitySet — no iterative solver,
no step size to tune, exact.
The worst_case_distribution helper in the script mirrors that logic in
about ten lines. The subtle bit is cumsum(rest) - rest, which gives the
mass already consumed before each scenario; the more obvious
cat([zeros(1), cumsum(rest)[:-1]]) is off by one when there is only a
single scenario to drain.
Trust, but cross-check¶
A reconstruction that merely looks right is worthless, so the script
recomputes \(\mathbb{E}_{q^\star}[\mathrm{loss}]\) from its own \(q^\star\) and
compares it against the library's worst_case_expectation across forty
radii. It asserts agreement below \(10^{-9}\). If the two ever drift apart,
either the reconstruction is wrong or the library is — and the assertion
tells you to go find out which.
The radius sweep stops at the total movable mass \(1 - \mathrm{nominal}_{\arg\max \mathrm{loss}}\), because past that point everything is already piled onto the worst scenario and the plot has nothing left to say.
Reading the figure¶
The stackplot is the interesting artefact here. Each band is one scenario's probability mass as a function of radius, and you watch the low-loss bands collapse one at a time — never gradually and never simultaneously — while the worst-loss band swells to absorb them. That staircase is the LP's vertex-hopping made visible, and it is a much better intuition for "what does robustness actually assume about the world" than any scalar curve.
Source¶
"""Tracking convergence across probability space: the TV-DRO worst-case
candidate distribution as the ambiguity radius grows.
`TotalVariationAmbiguitySet.worst_case_expectation` only returns the scalar
`E_q*[loss]` at the worst-case candidate distribution `q*`, not `q*`
itself -- the `AmbiguitySet` API is deliberately narrow (see
`optora/core/dro_base.py`). This example reconstructs `q*` directly from
the closed-form combinatorial solution the library documents internally
(sort scenarios by loss, then reallocate probability mass from the
cheapest scenarios to the single most expensive one, up to the radius
budget -- see `optora/dro/phi_dro.py` and `progress/decisions.md`), then
cross-checks `E_q*[loss]` against the library's own `worst_case_expectation`
to confirm the reconstruction is exact, not just plausible.
Run:
python examples/02_worst_case_distribution_shift.py
"""
import matplotlib.pyplot as plt
import torch
from _plotting import save_figure
from optora.dro import TotalVariationAmbiguitySet
NOMINAL = torch.tensor([0.4, 0.3, 0.2, 0.1], dtype=torch.float64)
LOSS = torch.tensor([0.5, 1.5, 3.0, 8.0], dtype=torch.float64)
def worst_case_distribution(
nominal: torch.Tensor, loss: torch.Tensor, radius: float
) -> torch.Tensor:
"""Reconstruct the TV-DRO worst-case candidate distribution `q*`."""
order = torch.argsort(loss)
rest_idx, worst_idx = order[:-1], order[-1]
rest_nominal = nominal[rest_idx]
mass_available_before = torch.cumsum(rest_nominal, dim=0) - rest_nominal
remaining_budget = torch.clamp(radius - mass_available_before, min=0.0)
moved = torch.minimum(remaining_budget, rest_nominal)
q = nominal.clone()
q[rest_idx] = nominal[rest_idx] - moved
q[worst_idx] = nominal[worst_idx] + moved.sum()
return q
def main() -> None:
max_movable_mass = (torch.sum(NOMINAL) - NOMINAL[torch.argmax(LOSS)]).item()
radii = torch.linspace(0.0, max_movable_mass, steps=40, dtype=torch.float64)
reconstructed = torch.stack(
[worst_case_distribution(NOMINAL, LOSS, radius.item()) for radius in radii]
)
library_values = torch.stack(
[
TotalVariationAmbiguitySet(nominal=NOMINAL, radius=radius.item())
.worst_case_expectation(LOSS)
.detach()
for radius in radii
]
)
reconstructed_values = reconstructed @ LOSS
max_abs_diff = torch.max(torch.abs(reconstructed_values - library_values)).item()
print(
"max |reconstructed E_q*[loss] - library worst_case_expectation| = "
f"{max_abs_diff:.2e}"
)
assert max_abs_diff < 1e-9
fig, ax = plt.subplots(figsize=(7, 4))
ax.stackplot(
radii,
*reconstructed.T,
labels=[f"scenario {i} (loss={value:.1f})" for i, value in enumerate(LOSS)],
)
ax.set_xlabel("total-variation ambiguity radius")
ax.set_ylabel("candidate probability mass q*_i(radius)")
ax.set_title("worst-case distribution shift as the TV ball grows")
ax.legend(loc="center left", bbox_to_anchor=(1.0, 0.5))
fig.tight_layout()
save_figure(fig, "02_worst_case_distribution_shift")
if __name__ == "__main__":
main()