SabaPivot's picture
Publish canonical reproduction with fresh CPU audit
bd2c7f9 verified
Raw
History Blame Contribute Delete
13 kB
#!/usr/bin/env python3
"""Correctness tests for the paper's Section 5 / Appendix C application models.
Every transcription and every closed form used by run_apub_applications.py is
pinned here against an independent computation, so a silent transcription bug
cannot pass as a result. Run: python3 code/test_apub_paper_models.py
"""
import json
import sys
import numpy as np
from scipy import stats
sys.path.insert(0, __file__.rsplit("/", 1)[0])
import apub_paper_models as P
rng = np.random.default_rng(20260802)
RESULTS = []
def check(name, ok, detail):
RESULTS.append({"test": name, "pass": bool(ok), "detail": detail})
print(f"[{'PASS' if ok else 'FAIL'}] {name}: {detail}")
return ok
# ---------------------------------------------------------------------------
# T1 positive-stable draw has Laplace transform exp(-t^a)
# ---------------------------------------------------------------------------
def t1():
worst = 0.0
for lam in (2.0, 5.0):
a = 1.0 / lam
s = P.positive_stable(a, size=400000, rng=rng)
for t in (0.3, 1.0, 3.0):
emp = np.mean(np.exp(-t * s))
worst = max(worst, abs(emp - np.exp(-t ** a)))
return check("positive_stable_laplace_transform", worst < 5e-3,
f"max |E[exp(-tS)] - exp(-t^a)| = {worst:.2e} over lam in "
f"{{2,5}}, t in {{0.3,1,3}} (400k draws)")
# ---------------------------------------------------------------------------
# T2 Gumbel copula: uniform marginals and Kendall tau = 1 - 1/lambda
# ---------------------------------------------------------------------------
def t2():
ok, det = True, []
for lam in (2.0, 5.0):
u = P.gumbel_copula_uniforms(60000, 4, lam, rng)
ks = max(stats.kstest(u[:, k], "uniform").statistic for k in range(4))
tau = stats.kendalltau(u[:2000, 0], u[:2000, 1]).statistic
target = 1.0 - 1.0 / lam
ok &= (ks < 0.01) and (abs(tau - target) < 0.03)
det.append(f"lam={lam}: max KS={ks:.4f}, tau={tau:.3f} (theory {target:.3f})")
return check("gumbel_copula_marginals_and_kendall_tau", ok, "; ".join(det))
# ---------------------------------------------------------------------------
# T3 closed-form product-mix recourse == recourse LP
# ---------------------------------------------------------------------------
def t3():
xi = P.sample_product_mix_xi(40, rng)
worst = 0.0
for _ in range(6):
x = rng.uniform(0, 12, size=20)
cf, feas = P.recourse_pm_closed_form(x, xi)
assert feas.all(), "closed form only claims optimality where sum_j y_j <= h2"
for n in range(0, 40, 7):
lp = P.recourse_pm_lp(x, xi["q"][n], xi["w"][n], xi["h1"][n], xi["h2"][n])
worst = max(worst, abs(cf[n] - lp) / max(1.0, abs(lp)))
return check("recourse_closed_form_equals_LP", worst < 1e-8,
f"max relative gap over 6 x-vectors x 6 scenarios = {worst:.2e}")
# ---------------------------------------------------------------------------
# T4 APUB(alpha) is the CVaR of the bootstrap means and its t-formulation
# agrees with the sorted-tail formula
# ---------------------------------------------------------------------------
def t4():
theta = rng.gamma(2.0, 3.0, size=40)
V = P.bootstrap_multiplicities(40, 4000, rng)
zm = (V @ theta) / 40
worst = 0.0
for alpha in (0.05, 0.2, 0.5, 1.0):
direct = P.apub_from_costs(theta, V, alpha)
grid = np.linspace(zm.min() - 1, zm.max() + 1, 200001)
var = np.quantile(zm, 1 - alpha) if alpha < 1 else zm.min() - 1
cand = np.unique(np.concatenate([grid[::200], [var]]))
vals = cand + (np.maximum(zm[None, :] - cand[:, None], 0).mean(axis=1)) / alpha
worst = max(worst, abs(direct - vals.min()) / max(1.0, abs(direct)))
return check("apub_equals_inf_t_formulation", worst < 1e-3,
f"max relative gap between sorted-tail CVaR and "
f"inf_t {{t + E[(Z-t)_+]/alpha}} = {worst:.2e}")
# ---------------------------------------------------------------------------
# T5 APUB monotone decreasing in alpha, and alpha = 1 reduces to SAA
# (Remark rem:APUB-DEF-OPT), on the real 20x8 model
# ---------------------------------------------------------------------------
def t5():
xi = P.sample_product_mix_xi(25, rng)
V = P.bootstrap_multiplicities(25, 400, rng)
objs = {}
for alpha in (0.05, 0.2, 0.5, 1.0):
objs[alpha] = P.solve_pm_random_recourse(xi, alpha, V, x_ub=5000.0)["obj"]
saa = P.solve_pm_random_recourse(xi, 1.0, None, x_ub=5000.0)["obj"]
mono = all(objs[a] >= objs[b] - 1e-6
for a, b in zip([0.05, 0.2, 0.5], [0.2, 0.5, 1.0]))
# Remark rem:APUB-DEF-OPT holds for the EXACT bootstrap law; with a finite
# M the alpha = 1 model is the Monte-Carlo bootstrap approximation of SAA,
# so the right check is that the gap contracts as M grows.
gaps = []
for M in (200, 1600, 12800):
Vm = P.bootstrap_multiplicities(25, M, rng)
o = P.solve_pm_random_recourse(xi, 1.0, Vm, x_ub=5000.0)["obj"]
gaps.append(abs(o - saa) / abs(saa))
# At alpha = 1 the model reduces to a bootstrap-REWEIGHTED SAA with weights
# w_n = (1/M) sum_m V_mn (mean 1, sd ~ M^{-1/2}), so the gap to plain SAA is
# O(M^{-1/2}), not zero at finite M.
contracts = all(gaps[i] > gaps[i + 1] for i in range(len(gaps) - 1)) \
and gaps[-1] < 1e-2
return check("apub_sp_monotone_in_alpha_and_alpha1_converges_to_SAA",
mono and contracts,
f"objs by alpha = { {k: round(v, 4) for k, v in objs.items()} } "
f"(monotone non-increasing in alpha: {mono}); SAA = {saa:.4f}; "
f"alpha=1 relative gap to SAA at M=(200,1600,12800) = "
f"{[f'{g:.2e}' for g in gaps]}")
# ---------------------------------------------------------------------------
# T6 the APUB-SP LP optimum equals the APUB of the recourse costs evaluated
# independently at the returned x (i.e. the LP really optimises the paper's
# objective, not a relaxation of it)
# ---------------------------------------------------------------------------
def t6():
xi = P.sample_product_mix_xi(25, rng)
V = P.bootstrap_multiplicities(25, 400, rng)
det = []
ok = True
for alpha in (0.1, 0.5):
sol = P.solve_pm_random_recourse(xi, alpha, V, x_ub=5000.0)
cf, _ = P.recourse_pm_closed_form(sol["x"], xi)
rebuilt = float(P.PM_C @ sol["x"]) + P.apub_from_costs(cf, V, alpha)
gap = abs(rebuilt - sol["obj"]) / max(1.0, abs(sol["obj"]))
ok &= gap < 1e-6
det.append(f"alpha={alpha}: LP obj={sol['obj']:.4f}, "
f"independent rebuild={rebuilt:.4f}, rel gap={gap:.2e}")
return check("apub_sp_LP_objective_matches_independent_rebuild", ok, "; ".join(det))
# ---------------------------------------------------------------------------
# T7 fixed-recourse closed form == the W y = h - T x recourse LP
# ---------------------------------------------------------------------------
def t7():
from scipy.optimize import linprog
gam = P.sample_fixed_recourse_gamma(30, rng)
worst = 0.0
for _ in range(8):
x = rng.uniform(0, 200, size=4)
cf = P.fr_recourse(x, gam)
Tg = P.FR_T_BASE - gam[:, :, None] * 0.25 # (n,2,4)
for n in range(0, 30, 5):
rhs = 500.0 * gam[n] - Tg[n] @ x
res = linprog(P.FR_QCOST, A_eq=P.FR_W, b_eq=rhs,
bounds=[(0, None)] * 4, method="highs")
worst = max(worst, abs(cf[n] - res.fun) / max(1.0, abs(res.fun)))
return check("fixed_recourse_closed_form_equals_LP", worst < 1e-8,
f"max relative gap over 8 x-vectors x 6 scenarios = {worst:.2e}")
# ---------------------------------------------------------------------------
# T8 the DRO Lipschitz moduli are the true ones (finite-difference check)
# ---------------------------------------------------------------------------
def t8():
ok, det = True, []
for _ in range(5):
x = rng.uniform(0, 200, size=4)
g = rng.uniform(0.5, 20.0, size=(4000, 2))
f = P.fr_recourse(x, g)
pert = g + rng.uniform(-1e-3, 1e-3, size=g.shape)
num = np.abs(P.fr_recourse(x, pert) - f) / np.abs(pert - g).sum(axis=1)
ok &= num.max() <= P.fr_lipschitz(x) * (1 + 1e-6)
det.append(f"emp={num.max():.2f} <= analytic={P.fr_lipschitz(x):.2f}")
# newsvendor: modulus is max(h, b) and is x-independent
for _ in range(3):
x = rng.uniform(20, 90, size=10)
d = rng.uniform(20, 90, size=(4000, 10))
pert = d + rng.uniform(-1e-3, 1e-3, size=d.shape)
num = np.abs(P.nv_cost(x, pert) - P.nv_cost(x, d)) / \
np.abs(pert - d).sum(axis=1)
ok &= num.max() <= max(P.NV_H, P.NV_B) * (1 + 1e-6)
det.append(f"newsvendor modulus max(h,b)={max(P.NV_H, P.NV_B)} respected")
return check("wasserstein_lipschitz_moduli_are_exact", ok, "; ".join(det))
# ---------------------------------------------------------------------------
# T9 newsvendor SAA LP optimum equals the direct empirical-average minimiser
# (checked against the closed-form critical quantile, product by product)
# ---------------------------------------------------------------------------
def t9():
xi = P.sample_newsvendor(400, rng, case=1)
sol = P.solve_nv(xi, alpha=1.0, V=None)
# The multi-product newsvendor separates by product; the marginal of
# p x + h(x-d)_+ + b(d-x)_+ is p + hF(x) - b(1-F(x)), so the empirical
# minimiser is the (b - p)/(h + b) empirical quantile of that column.
qlev = (P.NV_B - P.NV_P) / (P.NV_H + P.NV_B)
closed = np.quantile(xi, qlev, axis=0, method="inverted_cdf")
obj_closed = float(P.nv_cost(closed, xi).mean())
gap = abs(obj_closed - sol["obj"]) / max(1.0, abs(sol["obj"]))
return check("newsvendor_SAA_LP_equals_empirical_quantile_solution",
gap < 1e-9,
f"LP obj={sol['obj']:.6f}, closed-form quantile obj="
f"{obj_closed:.6f}, rel gap={gap:.2e}, "
f"max |x_LP - x_quantile| = {np.abs(sol['x'] - closed).max():.3f}")
# ---------------------------------------------------------------------------
# T10 newsvendor APUB-SP LP objective matches an independent rebuild, and the
# DRO shift is exactly eps*max(h,b) with an unchanged solution
# ---------------------------------------------------------------------------
def t10():
xi = P.sample_newsvendor(30, rng, case=1)
V = P.bootstrap_multiplicities(30, 800, rng)
sol = P.solve_nv(xi, alpha=0.1, V=V)
theta = P.nv_cost(sol["x"], xi)
rebuilt = P.apub_from_costs(theta, V, 0.1)
gap = abs(rebuilt - sol["obj"]) / max(1.0, abs(sol["obj"]))
saa = P.solve_nv(xi, alpha=1.0, V=None)
dro = P.solve_nv(xi, alpha=1.0, V=None, dro_eps=0.7)
shift_ok = abs((dro["obj"] - saa["obj"]) - 0.7 * max(P.NV_H, P.NV_B)) < 1e-6
same_x = np.abs(dro["x"] - saa["x"]).max() < 1e-6
return check("newsvendor_apub_rebuild_and_dro_constant_shift",
gap < 1e-6 and shift_ok and same_x,
f"APUB LP obj={sol['obj']:.6f} vs rebuild={rebuilt:.6f} "
f"(rel gap {gap:.2e}); DRO-SAA shift="
f"{dro['obj'] - saa['obj']:.6f} vs eps*max(h,b)="
f"{0.7 * max(P.NV_H, P.NV_B):.6f}; x unchanged={same_x}")
# ---------------------------------------------------------------------------
# T11 fixed-recourse DRO solution DOES move with eps (decision-dependent
# Lipschitz modulus), unlike the newsvendor
# ---------------------------------------------------------------------------
def t11():
gam = P.sample_fixed_recourse_gamma(120, rng)
saa = P.solve_fr(gam, 1.0, None)
moves = []
eps_grid = (0.05, 0.2, 1.0)
for eps in eps_grid:
dro = P.solve_fr(gam, 1.0, None, dro_eps=eps)
moves.append(float(np.linalg.norm(dro["x"] - saa["x"])))
nondec = all(moves[i] <= moves[i + 1] + 1e-6 for i in range(len(moves) - 1))
ok = moves[-1] > 1e-6 and nondec
return check("fixed_recourse_DRO_solution_is_decision_dependent", ok,
f"||x_DRO - x_SAA||_2 at eps={eps_grid} = "
f"{[round(m, 3) for m in moves]} (non-decreasing and eventually "
f"positive => the Wasserstein modulus really depends on x; "
f"contrast the newsvendor, where it cannot move at all)")
def main():
fns = [t1, t2, t3, t4, t5, t6, t7, t8, t9, t10, t11]
allok = True
for f in fns:
allok &= bool(f())
out = {"all_passed": bool(allok), "tests": RESULTS}
with open(__file__.rsplit("/", 2)[0] + "/results/paper_model_unit_tests.json", "w") as fh:
json.dump(out, fh, indent=1)
print("\nALL PASSED" if allok else "\nSOME FAILED")
return 0 if allok else 1
if __name__ == "__main__":
sys.exit(main())