SabaPivot's picture
Publish canonical reproduction with fresh CPU audit
bd2c7f9 verified
Raw
History Blame Contribute Delete
23.9 kB
#!/usr/bin/env python3
"""
apub_paper_models.py -- the paper's OWN Section 5 / Appendix C application
models for "Minimizing Upper Confidence Bounds: A Data-Driven Framework for
Stochastic Programming" (arXiv 2403.08966), orid eXLcL70GXO.
Everything in this module is transcribed from the arXiv e-print LaTeX source
(Section5-NumericalAnalysis-ICML.tex, SectionC-Experiment_Parameters.tex,
SectionD-Sensitivity.tex, Section4-SolutionMethod-ICML.tex). No parameter is
invented.
Three application arms:
A. Two-stage product mix with RANDOM recourse (Section 5, baseline instance
|I| = 20 products, |J| = 8 departments), two-regime uniform marginals
coupled by a Gumbel copula (lambda^r = 2.0 regular, lambda^w = 5.0
worst-case, p = 0.9). Appendix C gives c (20-vector) and T (8x20).
B. Two-stage product mix with FIXED recourse (Section 5.3 / Appendix C.3),
|I| = 4, |J| = 2, gamma ~ 0.7 N(mu_a, S_a) + 0.3 N(mu_b, S_b). This is
the instance the paper uses for its Wasserstein-DRO comparison.
C. Multi-product newsvendor, 10 products (Appendix D + Appendix C.4),
F(x, xi) = p'x + h'(x - xi)_+ + b'(xi - x)_+, p = -2, h = 9, b = 5,
Case I = Gaussian mixture N(mu1, Sigma1) / N(mu2, Sigma2),
Case II = Case I + independent biased uniform noise.
Solution method: the deterministic equivalent of the paper's own bootstrap
approximation (Section 4, eq. mod:first-bs)
min_{x in X, t} c'x + t + 1/(alpha M) sum_m [ (1/N) sum_n V_mn Q(x, xi_n) - t ]_+
is written as ONE linear program and solved with HiGHS. The paper solves the
same program with an adapted L-shaped (Benders) decomposition; L-shaped is an
algorithm for this LP, so solving the LP directly gives the same optimum. We
solve the monolithic form because HiGHS handles these sizes directly.
"""
import numpy as np
from scipy import sparse
from scipy.optimize import linprog
# =============================================================================
# Appendix C -- deterministic parameters, random-recourse product mix
# =============================================================================
# C.1 item 1: unit cost (negative profit) for all 20 products
PM_C = np.array([-14, -9, -20, -15, -4, -40, -18, -11, -13, -16,
-17, -8, -9, -24, -10, -7, -12, -3, -4, -5], dtype=float)
# C.1 item 2: labor of department j required per unit of product i (8 x 20)
PM_T = np.array([
[10, 6, 8, 4, 10, 6, 8, 4, 6, 8, 4, 10, 7, 9, 12, 8, 11, 13, 16, 17],
[6, 2, 3, 2, 6, 2, 3, 2, 3, 2, 6, 2, 5, 3, 7, 4, 6, 5, 8, 9],
[10, 6, 8, 4, 10, 6, 8, 4, 8, 4, 10, 6, 7, 9, 12, 8, 11, 13, 16, 17],
[6, 2, 3, 2, 6, 2, 3, 2, 2, 6, 2, 3, 5, 3, 7, 4, 6, 5, 8, 9],
[0, 2, 3, 2, 2, 6, 2, 3, 0, 0, 0, 0, 1, 4, 0, 2, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 10, 6, 8, 4, 0, 0, 0, 0, 0, 0, 9, 0, 0],
[0, 0, 0, 1, 4, 0, 2, 0, 0, 0, 0, 0, 3, 0, 0, 0, 5, 0, 0, 0],
[6, 8, 4, 6, 0, 0, 0, 0, 0, 4, 6, 8, 4, 10, 7, 0, 0, 0, 0, 0],
], dtype=float)
# C.2 regular period (probability p = 0.9, Gumbel lambda^r = 2.0)
PM_P_REGULAR = 0.9
PM_LAMBDA_R = 2.0
PM_H_R = np.array([[8000.0, 8500.0], [10000.0, 120000.0]]) # h1, h2
PM_Q_R = np.array([[3, 5], [13, 16], [4, 7], [14, 17],
[15, 17], [4, 8], [15, 19], [18, 20]], dtype=float)
PM_W_R = np.array([[0.8, 1.0], [0.8, 1.0], [0.9, 1.0], [0.8, 1.0],
[0.85, 1.0], [0.85, 1.0], [0.9, 1.0], [0.9, 1.0]])
# C.2 worst-case period (Gumbel lambda^w = 5.0)
PM_LAMBDA_W = 5.0
PM_H_W = np.array([[2000.0, 3000.0], [10000.0, 120000.0]])
PM_Q_W = np.array([[9, 12], [21, 25], [10, 12], [22, 24],
[18, 20], [18, 21], [18, 20], [22, 25]], dtype=float)
PM_W_W = np.array([[0.5, 0.6], [0.5, 0.6], [0.6, 0.7], [0.4, 0.6],
[0.55, 0.65], [0.55, 0.65], [0.6, 0.7], [0.6, 0.7]])
# =============================================================================
# Appendix C.3 -- fixed-recourse product mix (the WassDRO comparison instance)
# =============================================================================
FR_C = np.array([-12.0, -20.0, -18.0, -40.0])
FR_QCOST = np.array([6.0, 12.0, 0.0, 0.0])
FR_W = np.array([[-0.9, 0.0, 1.0, 0.0],
[0.0, -0.9, 0.0, 1.0]])
FR_T_BASE = np.array([[4.0, 9.0, 7.0, 10.0],
[3.0, 1.0, 3.0, 6.0]])
FR_MIX_W = 0.7
FR_MU_A = np.array([12.0, 8.0])
FR_SIG_A = np.array([[5.76, 1.92], [1.92, 2.56]])
FR_MU_B = np.array([2.0, 1.0])
FR_SIG_B = np.array([[0.16, 0.04], [0.04, 0.04]])
# =============================================================================
# Appendix C.4 -- 10-product newsvendor
# =============================================================================
NV_P, NV_H, NV_B = -2.0, 9.0, 5.0
NV_MU1 = np.array([60.89, 48.58, 46.81, 56.54, 61.58,
52.69, 69.42, 60.54, 54.43, 51.76])
NV_MU2 = np.array([50.30, 61.87, 53.16, 41.79, 51.94,
62.14, 45.47, 45.26, 55.95, 55.95])
NV_SIGMA1 = np.array([
[9.27, 2.84, -0.07, 1.19, -0.48, 1.40, 2.87, 4.06, -1.40, -1.96],
[2.84, 5.90, -2.83, 0.21, 2.27, -2.40, -0.89, 4.22, 3.43, 2.78],
[-0.07, -2.83, 5.48, -0.30, 0.90, 3.54, -4.51, -2.45, -2.91, -4.95],
[1.19, 0.21, -0.30, 7.99, -1.02, -1.27, -0.15, -1.55, -1.69, -0.36],
[-0.48, 2.27, 0.90, -1.02, 9.48, -0.08, -3.69, 2.71, -0.69, -0.34],
[1.40, -2.40, 3.54, -1.27, -0.08, 6.94, -1.26, -2.73, 0.01, -5.19],
[2.87, -0.89, -4.51, -0.15, -3.69, -1.26, 12.05, -0.16, -0.16, 2.44],
[4.06, 4.22, -2.45, -1.55, 2.71, -2.73, -0.16, 9.16, -0.77, 1.94],
[-1.40, 3.43, -2.91, -1.69, -0.69, 0.01, -0.16, -0.77, 7.41, 2.24],
[-1.96, 2.78, -4.95, -0.36, -0.34, -5.19, 2.44, 1.94, 2.24, 6.70],
])
NV_SIGMA2 = np.array([
[6.32, 2.99, -0.06, 0.73, -0.33, 1.36, 1.55, 2.51, -1.19, -1.75],
[2.99, 9.57, -4.09, 0.19, 2.44, -3.60, -0.74, 4.02, 4.49, 3.83],
[-0.06, -4.09, 7.06, -0.25, 0.86, 4.74, -3.35, -2.08, -3.40, -6.08],
[0.73, 0.19, -0.25, 4.37, -0.64, -1.11, -0.07, -0.86, -1.29, -0.29],
[-0.33, 2.44, 0.86, -0.64, 6.74, -0.08, -2.04, 1.71, -0.60, -0.31],
[1.36, -3.60, 4.74, -1.11, -0.08, 9.65, -0.98, -2.41, 0.01, -6.62],
[1.55, -0.74, -3.35, -0.07, -2.04, -0.98, 5.17, -0.08, -0.10, 1.72],
[2.51, 4.02, -2.08, -0.86, 1.71, -2.41, -0.08, 5.12, -0.59, 1.57],
[-1.19, 4.49, -3.40, -1.29, -0.60, 0.01, -0.10, -0.59, 7.83, 2.49],
[-1.75, 3.83, -6.08, -0.29, -0.31, -6.62, 1.72, 1.57, 2.49, 7.83],
])
NV_EPS_LO = np.array([-5.37, 6.74, 3.22, -7.48, -4.89,
-0.21, -12.14, -7.74, 0.77, 2.13])
NV_EPS_HI = np.array([26.27, 14.16, 17.68, 28.38, 25.79,
16.11, 32.99, 28.64, 20.13, 18.77])
# =============================================================================
# Gumbel copula sampling (Marshall-Olkin with Kanter's positive-stable draw)
# =============================================================================
def positive_stable(a, size, rng):
"""Positive stable S with Laplace transform E[exp(-tS)] = exp(-t^a),
a in (0,1). Kanter (1975) / Chambers-Mallows-Stuck."""
u = rng.uniform(0.0, np.pi, size=size)
w = rng.exponential(1.0, size=size)
return (np.sin(a * u) / np.sin(u) ** (1.0 / a)) * \
(np.sin((1.0 - a) * u) / w) ** ((1.0 - a) / a)
def gumbel_copula_uniforms(n, d, lam, rng):
"""n x d matrix of U(0,1) marginals with Gumbel copula dependence,
C(u; lam) = exp(-(sum_k (-log u_k)^lam)^(1/lam))."""
a = 1.0 / lam
s = positive_stable(a, size=(n, 1), rng=rng)
e = rng.exponential(1.0, size=(n, d))
return np.exp(-((e / s) ** (1.0 / lam)))
def sample_product_mix_xi(n, rng):
"""Section 5 DGP: two regimes (regular w.p. p = 0.9, worst-case otherwise);
within a regime the 18 marginals (h1, h2, q1..q8, w1..w8) are uniform on
the Appendix-C intervals and coupled by a Gumbel copula.
Returns dict with h1 (n,), h2 (n,), q (n,8), w (n,8)."""
regular = rng.random(n) < PM_P_REGULAR
n_r, n_w = int(regular.sum()), int((~regular).sum())
out_h = np.empty((n, 2))
out_q = np.empty((n, 8))
out_w = np.empty((n, 8))
for mask, cnt, lam, hb, qb, wb in (
(regular, n_r, PM_LAMBDA_R, PM_H_R, PM_Q_R, PM_W_R),
(~regular, n_w, PM_LAMBDA_W, PM_H_W, PM_Q_W, PM_W_W)):
if cnt == 0:
continue
u = gumbel_copula_uniforms(cnt, 18, lam, rng)
out_h[mask] = hb[:, 0] + (hb[:, 1] - hb[:, 0]) * u[:, 0:2]
out_q[mask] = qb[:, 0] + (qb[:, 1] - qb[:, 0]) * u[:, 2:10]
out_w[mask] = wb[:, 0] + (wb[:, 1] - wb[:, 0]) * u[:, 10:18]
return {"h1": out_h[:, 0], "h2": out_h[:, 1], "q": out_q, "w": out_w,
"regular": regular}
# =============================================================================
# Arm A recourse: Q(x, xi) = min_{y,z >= 0} q'y
# s.t. w_j y_j + z_j >= (Tx)_j, sum_j z_j = h1, sum_j y_j <= h2
# =============================================================================
def recourse_pm_closed_form(x, xi, h2_cap=True):
"""Vectorised exact Q(x, xi_n) for every scenario n.
Free permanent labor (total h1) should be spent on the departments with the
most expensive temp labor per unit of work, q_j / w_j; whatever demand is
left is covered by temps. This greedy allocation is exactly optimal (it is
a continuous knapsack), and is unit-tested against linprog.
The greedy ignores the sum_j y_j <= h2 cap; it is returned together with a
`feasible` flag. Where the flag is True the greedy solution is optimal for
the capped problem as well (it is optimal for a relaxation and feasible for
the original). Appendix C sets h2 ~ U[10000, 120000] precisely to suppress
that cap, and the flag is asserted in every reported evaluation."""
d = PM_T @ np.asarray(x, dtype=float) # (8,) department demand
q, w, h1, h2 = xi["q"], xi["w"], xi["h1"], xi["h2"]
rate = q / w # (n,8) cost per unit work
order = np.argsort(-rate, axis=1) # expensive first
d_sorted = np.take_along_axis(np.broadcast_to(d, rate.shape), order, axis=1)
csum = np.cumsum(d_sorted, axis=1)
remaining = np.maximum(h1[:, None] - (csum - d_sorted), 0.0)
z_sorted = np.minimum(d_sorted, remaining)
uncovered_sorted = d_sorted - z_sorted
rate_sorted = np.take_along_axis(rate, order, axis=1)
q_sorted = np.take_along_axis(q, order, axis=1)
w_sorted = np.take_along_axis(w, order, axis=1)
y_sorted = uncovered_sorted / w_sorted
cost = (q_sorted * y_sorted).sum(axis=1)
feasible = np.ones_like(cost, dtype=bool) if not h2_cap \
else (y_sorted.sum(axis=1) <= h2 + 1e-9)
return cost, feasible
def recourse_pm_lp(x, q, w, h1, h2):
"""Reference single-scenario recourse LP (used only in the unit tests)."""
d = PM_T @ np.asarray(x, dtype=float)
nJ = 8
# variables [y (8), z (8)]
c = np.concatenate([q, np.zeros(nJ)])
rows, cols, vals = [], [], []
for j in range(nJ): # -w_j y_j - z_j <= -d_j
rows += [j, j]
cols += [j, nJ + j]
vals += [-w[j], -1.0]
rows += [nJ] * nJ # sum_j y_j <= h2
cols += list(range(nJ))
vals += [1.0] * nJ
A_ub = sparse.coo_matrix((vals, (rows, cols)), shape=(nJ + 1, 2 * nJ))
b_ub = np.concatenate([-d, [h2]])
A_eq = sparse.coo_matrix(([1.0] * nJ, ([0] * nJ, list(range(nJ, 2 * nJ)))),
shape=(1, 2 * nJ))
res = linprog(c, A_ub=A_ub, b_ub=b_ub, A_eq=A_eq, b_eq=np.array([h1]),
bounds=[(0, None)] * (2 * nJ), method="highs")
return res.fun if res.status == 0 else np.inf
# =============================================================================
# Bootstrap multiplicities V (Section 4: V ~ Multinomial(N, 1/N))
# =============================================================================
def bootstrap_multiplicities(N, M, rng):
return rng.multinomial(N, np.full(N, 1.0 / N), size=M).astype(float)
def apub_from_costs(theta, V, alpha):
"""Direct evaluation of the bootstrap APUB
min_t t + 1/(alpha M) sum_m [ (1/N) sum_n V_mn theta_n - t ]_+
which is the empirical CVaR at level alpha of the bootstrap means."""
N = theta.shape[0]
zm = (V @ theta) / N
if alpha >= 1.0:
return float(zm.mean())
zs = np.sort(zm)
M = zs.shape[0]
k = alpha * M
kf = int(np.floor(k))
tail = zs[M - kf:].sum() if kf > 0 else 0.0
frac = k - kf
if frac > 1e-12 and M - kf - 1 >= 0:
tail += frac * zs[M - kf - 1]
return float(tail / k)
# =============================================================================
# Arm A -- APUB-SP / SAA deterministic-equivalent LP (20 products, 8 depts)
# =============================================================================
def solve_pm_random_recourse(xi, alpha, V=None, x_ub=None, h2_cap=True):
"""alpha = 1.0 (and V = None) gives the classical SAA model.
h2_cap=False drops the sum_j y_j <= h2 row. Appendix C says verbatim:
"Without loss, we suppress the constraint on the capacity of total
outsourced labor by setting a large value for h2", and with the cap active
the second stage is genuinely infeasible for the low tail of
h2 ~ U[10000, 120000] at the models' own optimal x, i.e. the program has no
relatively complete recourse. Reproducing the paper therefore means
honouring that sentence; the rate at which the cap would have bound is
reported alongside every result."""
N = xi["h1"].shape[0]
nI, nJ = PM_T.shape[1], PM_T.shape[0]
M = 0 if V is None else V.shape[0]
q, w, h1, h2 = xi["q"], xi["w"], xi["h1"], xi["h2"]
off_x, off_y = 0, nI
off_z = off_y + N * nJ
off_th = off_z + N * nJ
off_t = off_th + N
off_u = off_t + 1
nvar = off_u + M
obj = np.zeros(nvar)
obj[off_x:off_x + nI] = PM_C
if M == 0:
obj[off_th:off_th + N] = 1.0 / N
else:
obj[off_t] = 1.0
obj[off_u:off_u + M] = 1.0 / (alpha * M)
rows, cols, vals, b_ub = [], [], [], []
r = 0
# (1) T x - w_nj y_nj - z_nj <= 0
Tc = sparse.coo_matrix(PM_T)
for n in range(N):
for j in range(nJ):
for i in range(nI):
if PM_T[j, i] != 0.0:
rows.append(r); cols.append(off_x + i); vals.append(PM_T[j, i])
rows.append(r); cols.append(off_y + n * nJ + j); vals.append(-w[n, j])
rows.append(r); cols.append(off_z + n * nJ + j); vals.append(-1.0)
b_ub.append(0.0)
r += 1
# (2) sum_j y_nj <= h2_n (suppressed per Appendix C when h2_cap=False)
if h2_cap:
for n in range(N):
for j in range(nJ):
rows.append(r); cols.append(off_y + n * nJ + j); vals.append(1.0)
b_ub.append(h2[n]); r += 1
# (3) (1/N) sum_n V_mn theta_n - u_m - t <= 0
if M > 0:
for m in range(M):
nz = np.nonzero(V[m])[0]
for n in nz:
rows.append(r); cols.append(off_th + n); vals.append(V[m, n] / N)
rows.append(r); cols.append(off_u + m); vals.append(-1.0)
rows.append(r); cols.append(off_t); vals.append(-1.0)
b_ub.append(0.0); r += 1
A_ub = sparse.coo_matrix((vals, (rows, cols)), shape=(r, nvar)).tocsr()
rows, cols, vals, b_eq = [], [], [], []
re = 0
for n in range(N): # sum_j z_nj = h1_n
for j in range(nJ):
rows.append(re); cols.append(off_z + n * nJ + j); vals.append(1.0)
b_eq.append(h1[n]); re += 1
for n in range(N): # theta_n - q_n' y_n = 0
rows.append(re); cols.append(off_th + n); vals.append(1.0)
for j in range(nJ):
rows.append(re); cols.append(off_y + n * nJ + j); vals.append(-q[n, j])
b_eq.append(0.0); re += 1
A_eq = sparse.coo_matrix((vals, (rows, cols)), shape=(re, nvar)).tocsr()
bounds = [(0.0, x_ub)] * nI + [(0.0, None)] * (2 * N * nJ) + \
[(None, None)] * N + [(None, None)] + [(0.0, None)] * M
res = linprog(obj, A_ub=A_ub, b_ub=np.array(b_ub), A_eq=A_eq,
b_eq=np.array(b_eq), bounds=bounds, method="highs")
if res.status != 0:
raise RuntimeError(f"pm LP failed: {res.message}")
x = res.x[off_x:off_x + nI]
return {"x": x, "obj": float(res.fun),
"theta": res.x[off_th:off_th + N]}
# =============================================================================
# Arm B -- fixed-recourse product mix (4 products, 2 departments)
# =============================================================================
def sample_fixed_recourse_gamma(n, rng):
comp = rng.random(n) < FR_MIX_W
a = rng.multivariate_normal(FR_MU_A, FR_SIG_A, size=n)
b = rng.multivariate_normal(FR_MU_B, FR_SIG_B, size=n)
return np.where(comp[:, None], a, b)
def fr_recourse(x, gam):
"""Q(x, gamma) = min_y q'y s.t. W y = h - T(gamma) x, y >= 0, closed form.
W = [[-0.9,0,1,0],[0,-0.9,0,1]], q = (6,12,0,0), h = 500*gamma,
T(gamma)_ji = T_base_ji - gamma_j/4 => (Tx)_j - h_j = a_j(x) - gamma_j k(x)
with a_j(x) = T_base[j] . x and k(x) = sum(x)/4 + 500."""
x = np.asarray(x, dtype=float)
a = FR_T_BASE @ x # (2,)
k = x.sum() / 4.0 + 500.0
short = np.maximum(a[None, :] - gam * k, 0.0) # (n,2)
return (FR_QCOST[0] / 0.9) * short[:, 0] + (FR_QCOST[1] / 0.9) * short[:, 1]
def fr_lipschitz(x):
"""Lipschitz modulus of F(x, .) w.r.t. gamma in the dual (l-inf) norm of the
l1 ground metric on the Wasserstein ball. The subgradient wrt gamma of an
active piece is (-(6/0.9)k, -(12/0.9)k), so Lip = (12/0.9) k(x) -- genuinely
DECISION DEPENDENT, unlike the newsvendor case."""
k = np.asarray(x, dtype=float).sum() / 4.0 + 500.0
return (FR_QCOST[1] / 0.9) * k
def solve_fr(gam, alpha=1.0, V=None, dro_eps=None, x_ub=1e5):
"""SAA (alpha=1, V=None, dro_eps=None), APUB-M (V given), or exact
Wasserstein-1 DRO (dro_eps given): worst-case expectation over a
Wasserstein-1 ball equals SAA(x) + eps * Lip(x) for a cost that is a max of
finitely many affine functions of xi on an unbounded support
(Mohajerin Esfahani & Kuhn 2018, Cor. 5.1 / Remark 6.6)."""
N = gam.shape[0]
nI = 4
M = 0 if V is None else V.shape[0]
off_x = 0
off_v = nI # v1_n, v2_n interleaved: off_v + 2n + j
off_th = off_v + 2 * N
off_t = off_th + N
off_u = off_t + 1
nvar = off_u + M
obj = np.zeros(nvar)
obj[off_x:off_x + nI] = FR_C
if M == 0:
obj[off_th:off_th + N] = 1.0 / N
if dro_eps:
# + eps * (12/0.9) * (sum(x)/4 + 500) [affine in x]
obj[off_x:off_x + nI] += dro_eps * (FR_QCOST[1] / 0.9) * 0.25
else:
obj[off_t] = 1.0
obj[off_u:off_u + M] = 1.0 / (alpha * M)
rows, cols, vals, b_ub = [], [], [], []
r = 0
for n in range(N):
for j in range(2):
# T_base[j] . x - gamma_nj * (sum(x)/4 + 500) - v_nj <= 0
for i in range(nI):
rows.append(r); cols.append(off_x + i)
vals.append(FR_T_BASE[j, i] - gam[n, j] * 0.25)
rows.append(r); cols.append(off_v + 2 * n + j); vals.append(-1.0)
b_ub.append(500.0 * gam[n, j]); r += 1
if M > 0:
for m in range(M):
nz = np.nonzero(V[m])[0]
for n in nz:
rows.append(r); cols.append(off_th + n); vals.append(V[m, n] / N)
rows.append(r); cols.append(off_u + m); vals.append(-1.0)
rows.append(r); cols.append(off_t); vals.append(-1.0)
b_ub.append(0.0); r += 1
A_ub = sparse.coo_matrix((vals, (rows, cols)), shape=(r, nvar)).tocsr()
rows, cols, vals, b_eq = [], [], [], []
for n in range(N):
rows.append(n); cols.append(off_th + n); vals.append(1.0)
rows.append(n); cols.append(off_v + 2 * n); vals.append(-FR_QCOST[0] / 0.9)
rows.append(n); cols.append(off_v + 2 * n + 1); vals.append(-FR_QCOST[1] / 0.9)
b_eq.append(0.0)
A_eq = sparse.coo_matrix((vals, (rows, cols)), shape=(N, nvar)).tocsr()
bounds = [(0.0, x_ub)] * nI + [(0.0, None)] * (2 * N) + \
[(None, None)] * N + [(None, None)] + [(0.0, None)] * M
res = linprog(obj, A_ub=A_ub, b_ub=np.array(b_ub), A_eq=A_eq,
b_eq=np.array(b_eq), bounds=bounds, method="highs")
if res.status != 0:
raise RuntimeError(f"fr LP failed: {res.message}")
return {"x": res.x[off_x:off_x + nI], "obj": float(res.fun),
"theta": res.x[off_th:off_th + N]}
# =============================================================================
# Arm C -- 10-product newsvendor
# =============================================================================
def sample_newsvendor(n, rng, case):
comp = rng.random(n) < 0.5
a = rng.multivariate_normal(NV_MU1, NV_SIGMA1, size=n)
b = rng.multivariate_normal(NV_MU2, NV_SIGMA2, size=n)
d = np.where(comp[:, None], a, b)
if case == 2:
d = d + rng.uniform(NV_EPS_LO, NV_EPS_HI, size=(n, 10))
return d
def nv_cost(x, xi):
x = np.asarray(x, dtype=float)
return (NV_P * x).sum() + (NV_H * np.maximum(x - xi, 0.0)
+ NV_B * np.maximum(xi - x, 0.0)).sum(axis=1)
def solve_nv(xi, alpha=1.0, V=None, dro_eps=None, x_ub=500.0):
N, nI = xi.shape
M = 0 if V is None else V.shape[0]
off_x = 0
off_s = nI # s_ni at off_s + n*nI + i
off_th = off_s + N * nI
off_t = off_th + N
off_u = off_t + 1
nvar = off_u + M
obj = np.zeros(nvar)
const = 0.0
if M == 0:
obj[off_th:off_th + N] = 1.0 / N
if dro_eps:
# Lipschitz modulus of F(x, .) wrt xi in the dual l-inf norm is
# max(h, b) = 9 for EVERY x -> constant shift, x_DRO = x_SAA.
const = dro_eps * max(NV_H, NV_B)
else:
obj[off_t] = 1.0
obj[off_u:off_u + M] = 1.0 / (alpha * M)
rows, cols, vals, b_ub = [], [], [], []
r = 0
for n in range(N):
for i in range(nI):
rows += [r, r]; cols += [off_x + i, off_s + n * nI + i]
vals += [NV_H, -1.0]; b_ub.append(NV_H * xi[n, i]); r += 1
rows += [r, r]; cols += [off_x + i, off_s + n * nI + i]
vals += [-NV_B, -1.0]; b_ub.append(-NV_B * xi[n, i]); r += 1
if M > 0:
for m in range(M):
nz = np.nonzero(V[m])[0]
for n in nz:
rows.append(r); cols.append(off_th + n); vals.append(V[m, n] / N)
rows.append(r); cols.append(off_u + m); vals.append(-1.0)
rows.append(r); cols.append(off_t); vals.append(-1.0)
b_ub.append(0.0); r += 1
A_ub = sparse.coo_matrix((vals, (rows, cols)), shape=(r, nvar)).tocsr()
rows, cols, vals, b_eq = [], [], [], []
for n in range(N):
rows.append(n); cols.append(off_th + n); vals.append(1.0)
for i in range(nI):
rows.append(n); cols.append(off_x + i); vals.append(-NV_P)
rows.append(n); cols.append(off_s + n * nI + i); vals.append(-1.0)
b_eq.append(0.0)
A_eq = sparse.coo_matrix((vals, (rows, cols)), shape=(N, nvar)).tocsr()
bounds = [(0.0, x_ub)] * nI + [(0.0, None)] * (N * nI) + \
[(None, None)] * N + [(None, None)] + [(0.0, None)] * M
res = linprog(obj, A_ub=A_ub, b_ub=np.array(b_ub), A_eq=A_eq,
b_eq=np.array(b_eq), bounds=bounds, method="highs")
if res.status != 0:
raise RuntimeError(f"nv LP failed: {res.message}")
return {"x": res.x[off_x:off_x + nI], "obj": float(res.fun) + const,
"theta": res.x[off_th:off_th + N]}