#!/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]}