Buckets:
| """Numerical audit test-bed for arXiv:2502.07397v2 (ICML 2026): | |
| "Linear Bandits beyond Inner Product Spaces, the case of Bandit Optimal Transport". | |
| Discrete Bandit Optimal Transport instances where every object of the paper | |
| (L^2(rho) geometry, Fourier-type orthonormal basis, RLS estimator, confidence | |
| sets Eqs.(4)-(5), EntUCB optimism Eq.(9), entropic OT, Kantorovich LP) is | |
| computable exactly in finite dimension. | |
| Setting | |
| ------- | |
| Marginals mu (K atoms at x_i in [0,1]) and nu (K' atoms at y_j in [0,1]). | |
| Reference measure rho = mu (x) nu on the N = K*K' support pairs (paper, | |
| App. B.2: "by taking rho = mu (x) nu ... we can reduce <c|pi> to a | |
| L^2(rho) inner product"). | |
| L^2(rho) inner product of functions u, v on the support: | |
| <u|v>_rho = sum_z u(z) v(z) rho(z). | |
| Any rho-orthonormal basis B (rows b_k) gives the *exact* reduction of the | |
| duality pairing to a Hilbert inner product (Claim 1's mechanism): | |
| <c|pi> = sum_z c(z) pi(z) = <c | dpi/drho>_rho = theta* . a(pi) | |
| with theta*_k = <c|b_k>_rho (coefficients of c*), | |
| a(pi)_k = <dpi/drho | b_k>_rho = sum_z b_k(z) pi(z) (embedding). | |
| We build B by rho-weighted Gram-Schmidt over the real trigonometric system | |
| on [0,1]^2 ordered by frequency ("the Fourier basis if supp(mu)x supp(nu) | |
| is bounded", paper Sec. 5.1), so basis truncation matches the paper's | |
| functional-regression story. | |
| """ | |
| from __future__ import annotations | |
| import numpy as np | |
| from dataclasses import dataclass, field | |
| from typing import Callable | |
| # ---------------------------------------------------------------------------- | |
| # Instance construction | |
| # ---------------------------------------------------------------------------- | |
| def trig_features_1d(u: np.ndarray, n_feats: int) -> tuple[np.ndarray, np.ndarray]: | |
| """First `n_feats` real trigonometric functions evaluated at points u. | |
| Order: 1, cos(2 pi u), sin(2 pi u), cos(4 pi u), sin(4 pi u), ... | |
| Returns (features (n_feats, len(u)), frequency of each feature). | |
| """ | |
| feats = np.empty((n_feats, len(u))) | |
| freqs = np.empty(n_feats) | |
| for m in range(n_feats): | |
| if m == 0: | |
| feats[m] = 1.0 | |
| freqs[m] = 0 | |
| else: | |
| k = (m + 1) // 2 | |
| phase = 2 * np.pi * k * u | |
| feats[m] = np.cos(phase) if (m % 2 == 1) else np.sin(phase) | |
| freqs[m] = k | |
| return feats, freqs | |
| def pair_feature_order(n1: int, n2: int, f1: np.ndarray, f2: np.ndarray) -> list[tuple[int, int]]: | |
| """Order index pairs (m1,m2) by (max freq, total freq, m1+m2) so spans are | |
| nested by frequency — low-order features first.""" | |
| pairs = [(m1, m2) for m1 in range(n1) for m2 in range(n2)] | |
| pairs.sort(key=lambda p: (max(f1[p[0]], f2[p[1]]), f1[p[0]] + f2[p[1]], p[0] + p[1], p)) | |
| return pairs | |
| def rho_gram_schmidt(cands: np.ndarray, rho: np.ndarray, n_keep: int, tol: float = 1e-8) -> np.ndarray: | |
| """Modified Gram-Schmidt (with re-orthogonalisation) in L^2(rho). | |
| cands: (M, N) candidate function values on the support, M >= n_keep. | |
| Returns B (n_keep, N) with B diag(rho) B^T = I. | |
| """ | |
| N = cands.shape[1] | |
| B: list[np.ndarray] = [] | |
| for f in cands: | |
| g = f.astype(float).copy() | |
| for _ in range(2): # re-orthogonalise for numerical hygiene | |
| if B: | |
| Bm = np.asarray(B) | |
| g = g - Bm.T @ (Bm @ (rho * g)) | |
| nrm = np.sqrt(g @ (rho * g)) | |
| if nrm > tol: | |
| B.append(g / nrm) | |
| if len(B) == n_keep: | |
| break | |
| if len(B) < n_keep: | |
| raise RuntimeError(f"Gram-Schmidt only found {len(B)} of {n_keep} independent functions") | |
| return np.asarray(B) | |
| class Instance: | |
| """A discrete BOT instance with an exact L^2(rho) Fourier-type basis.""" | |
| x: np.ndarray # (K,) support of mu | |
| y: np.ndarray # (K',) support of nu | |
| mu: np.ndarray # (K,) | |
| nu: np.ndarray # (K',) | |
| cost: np.ndarray # (K, K') c* on the support | |
| B: np.ndarray # (N, N) rho-orthonormal basis rows (functions on pairs) | |
| rho: np.ndarray = field(init=False) # (N,) | |
| theta_star: np.ndarray = field(init=False) # (N,) coefficients of c* | |
| c_vec: np.ndarray = field(init=False) # (N,) | |
| def __post_init__(self): | |
| self.rho = np.outer(self.mu, self.nu).ravel() | |
| self.c_vec = self.cost.ravel() | |
| self.theta_star = self.B @ (self.rho * self.c_vec) | |
| def K(self): | |
| return len(self.mu) | |
| def Kp(self): | |
| return len(self.nu) | |
| def N(self): | |
| return len(self.rho) | |
| # --- L2(rho) geometry ------------------------------------------------ | |
| def inner(self, u, v): | |
| return float(u @ (self.rho * v)) | |
| def embed(self, pi: np.ndarray) -> np.ndarray: | |
| """a(pi) = coefficients of dpi/drho in the basis (= B @ vec(pi)).""" | |
| return self.B @ pi.ravel() | |
| def func_from_coef(self, theta: np.ndarray) -> np.ndarray: | |
| """Function values (N,) of sum_k theta_k b_k. Accepts truncated theta.""" | |
| n = len(theta) | |
| return self.B[:n].T @ theta | |
| def pairing(self, cvec: np.ndarray, pi: np.ndarray) -> float: | |
| """Duality pairing <c|pi> = int c dpi.""" | |
| return float(cvec.ravel() @ pi.ravel()) | |
| def entropy(self, pi: np.ndarray) -> float: | |
| """H(pi|rho) = KL(pi || mu (x) nu), with 0 log 0 = 0.""" | |
| p = pi.ravel() | |
| mask = p > 0 | |
| return float(np.sum(p[mask] * np.log(p[mask] / self.rho[mask]))) | |
| def make_instance(K: int, Kp: int, seed: int, cost_kind: str = "smooth", | |
| planted_theta: np.ndarray | None = None, | |
| weights: str = "dirichlet") -> Instance: | |
| """Random discrete BOT instance. | |
| cost_kind: | |
| 'smooth' c*(x,y) = cos(2 pi (x-y)) + 0.5 sin(2 pi x) cos(4 pi y) + 0.3 xy | |
| 'planted' c* = sum_k planted_theta_k b_k (exact finite basis support) | |
| """ | |
| rng = np.random.default_rng(seed) | |
| # keep support points well-separated and off any lattice | |
| x = np.sort(rng.uniform(0.02, 0.98, K)) + rng.normal(0, 1e-3, K) | |
| y = np.sort(rng.uniform(0.02, 0.98, Kp)) + rng.normal(0, 1e-3, Kp) | |
| if weights == "dirichlet": | |
| mu = rng.dirichlet(np.full(K, 5.0)) | |
| nu = rng.dirichlet(np.full(Kp, 5.0)) | |
| mu = np.maximum(mu, 1e-3); mu /= mu.sum() | |
| nu = np.maximum(nu, 1e-3); nu /= nu.sum() | |
| else: | |
| mu = np.full(K, 1.0 / K) | |
| nu = np.full(Kp, 1.0 / Kp) | |
| rho = np.outer(mu, nu).ravel() | |
| N = K * Kp | |
| # candidate features: products of 1-d trig features, frequency-ordered | |
| n1 = 2 * K + 3 | |
| n2 = 2 * Kp + 3 | |
| fx, freq_x = trig_features_1d(x, n1) | |
| fy, freq_y = trig_features_1d(y, n2) | |
| order = pair_feature_order(n1, n2, freq_x, freq_y) | |
| cands = np.asarray([np.outer(fx[m1], fy[m2]).ravel() for (m1, m2) in order]) | |
| B = rho_gram_schmidt(cands, rho, N) | |
| if cost_kind == "planted": | |
| assert planted_theta is not None | |
| cvec = B[: len(planted_theta)].T @ planted_theta | |
| cost = cvec.reshape(K, Kp) | |
| elif cost_kind == "smooth": | |
| X, Y = np.meshgrid(x, y, indexing="ij") | |
| cost = np.cos(2 * np.pi * (X - Y)) + 0.5 * np.sin(2 * np.pi * X) * np.cos(4 * np.pi * Y) + 0.3 * X * Y | |
| else: | |
| raise ValueError(cost_kind) | |
| return Instance(x=x, y=y, mu=mu, nu=nu, cost=cost, B=B) | |
| # ---------------------------------------------------------------------------- | |
| # Optimal transport solvers | |
| # ---------------------------------------------------------------------------- | |
| def kantorovich(inst: Instance, cvec: np.ndarray | None = None) -> tuple[float, np.ndarray]: | |
| """Exact Kantorovich LP value via POT's network simplex.""" | |
| import ot as pot | |
| M = (cvec if cvec is not None else inst.c_vec).reshape(inst.K, inst.Kp) | |
| # ot.emd requires non-negative? it accepts arbitrary finite costs. | |
| plan = pot.emd(inst.mu, inst.nu, np.ascontiguousarray(M)) | |
| return float(np.sum(plan * M)), plan | |
| def sinkhorn_log(mu: np.ndarray, nu: np.ndarray, M: np.ndarray, eps: float, | |
| n_iter: int = 1200, tol: float = 3e-8, | |
| f0: np.ndarray | None = None, g0: np.ndarray | None = None | |
| ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: | |
| """Log-domain Sinkhorn for min_pi <M,pi> + eps*KL(pi | mu (x) nu). | |
| Returns (plan, f, g) with duals for warm starting. | |
| Optimal plan: pi_ij = mu_i nu_j exp((f_i + g_j - M_ij)/eps). | |
| """ | |
| logmu = np.log(mu) | |
| lognu = np.log(nu) | |
| f = np.zeros_like(mu) if f0 is None else f0.copy() | |
| g = np.zeros_like(nu) if g0 is None else g0.copy() | |
| Me = M / eps | |
| for it in range(n_iter): | |
| # f-update: f_i = -eps * logsumexp_j( lognu_j + (g_j - M_ij)/eps ) | |
| A = (g[None, :] - M) / eps + lognu[None, :] | |
| f_new = -eps * _logsumexp(A, axis=1) | |
| A2 = (f_new[:, None] - M) / eps + logmu[:, None] | |
| g_new = -eps * _logsumexp(A2, axis=0) | |
| delta = max(np.max(np.abs(f_new - f)), np.max(np.abs(g_new - g))) | |
| f, g = f_new, g_new | |
| if delta < tol * max(1.0, eps): | |
| break | |
| P = np.exp((f[:, None] + g[None, :] - M) / eps + logmu[:, None] + lognu[None, :]) | |
| # clean tiny negative / renormalise marginals drift | |
| P = np.maximum(P, 0.0) | |
| s = P.sum() | |
| if s > 0: | |
| P /= s | |
| return P, f, g | |
| def _logsumexp(A, axis): | |
| m = np.max(A, axis=axis, keepdims=True) | |
| return np.squeeze(m, axis=axis) + np.log(np.sum(np.exp(A - m), axis=axis)) | |
| def entropic_value(inst: Instance, eps: float, cvec: np.ndarray | None = None, | |
| duals: tuple | None = None) -> tuple[float, np.ndarray, tuple]: | |
| """Ent(mu,nu,c,eps) = min_pi <c|pi> + eps H(pi|rho), exact via Sinkhorn.""" | |
| c = (cvec if cvec is not None else inst.c_vec).reshape(inst.K, inst.Kp) | |
| f0, g0 = duals if duals is not None else (None, None) | |
| P, f, g = sinkhorn_log(inst.mu, inst.nu, c, eps, f0=f0, g0=g0) | |
| val = inst.pairing(c, P) + eps * inst.entropy(P) | |
| return float(val), P, (f, g) | |
| # ---------------------------------------------------------------------------- | |
| # Confidence widths and bound formulas — exactly as printed in the paper | |
| # ---------------------------------------------------------------------------- | |
| def beta_width(sigma: float, delta: float, lam: float, Cbar: float, | |
| logdet_reg: float) -> float: | |
| """Eq. (5): beta_t(delta) = sigma sqrt(log(4 det(DLambda + lam^-1 M_t M_t^*) / delta^2)) | |
| + sqrt(lam) Cbar / ||DLambda||_op^{1/2} | |
| with DLambda = Id (Lambda = 1/2 ||.||^2), so logdet_reg = logdet(I + lam^-1 A_t). | |
| """ | |
| return sigma * np.sqrt(max(logdet_reg + np.log(4.0 / delta ** 2), 0.0)) + np.sqrt(lam) * Cbar | |
| def thm41_width_term_literal(Cbar: float, beta_T: float, T: int, A_T: np.ndarray, lam: float) -> float: | |
| """Paper-literal main term of Thm 4.1: | |
| 2 Cbar beta_T(delta) sqrt(T logdet(Id + M_T (DLambda)^{-1} M_T^* / (2 lam Cbar))) | |
| """ | |
| sign, logdet = np.linalg.slogdet(np.eye(len(A_T)) + A_T / (2 * lam * Cbar)) | |
| return 2 * Cbar * beta_T * np.sqrt(T * max(logdet, 0.0)) | |
| def thm41_width_term_canonical(Cbar: float, beta_T: float, T: int, A_T: np.ndarray, lam: float) -> float: | |
| """Canonical OFUL-style width term (Abbasi-Yadkori): | |
| beta_T sqrt(T * 2 logdet(I + lam^{-1} A_T)) | |
| used as a cross-check of the paper-literal constant. | |
| """ | |
| sign, logdet = np.linalg.slogdet(np.eye(len(A_T)) + A_T / lam) | |
| return beta_T * np.sqrt(T * 2 * max(logdet, 0.0)) | |
| def kappa_term(kappa: float, eta: float, T: int) -> float: | |
| """Middle term of Thm 4.1: kappa eta/(1-eta) (T^{1-eta} log T + eta 2^{-eta} log 6).""" | |
| return kappa * eta / (1 - eta) * (T ** (1 - eta) * np.log(max(T, 2)) + eta * 2.0 ** (-eta) * np.log(6.0)) | |
| def martingale_term(sigma: float, delta: float, T: int) -> float: | |
| """sigma sqrt(2 T log(2/delta)).""" | |
| return sigma * np.sqrt(2 * T * np.log(2 / delta)) | |
| # ---------------------------------------------------------------------------- | |
| # EntUCB (Algorithm 1) and Basis-truncation EntUCB (Algorithm 2) | |
| # ---------------------------------------------------------------------------- | |
| class RunResult: | |
| ts: np.ndarray # recorded time steps (1-indexed) | |
| kant_regret: np.ndarray # cumulative sum <c*|pi_t> - Kant (pseudo-regret) | |
| kant_regret_noisy: np.ndarray # cumulative sum C_t - Kant (paper's regret) | |
| ent_regret: np.ndarray # cumulative sum Psi^{eps_t}(c*,pi_t) - Ent(c*,eps_t) | |
| ent_regret_noisy: np.ndarray | |
| bound_literal: np.ndarray # paper-literal Thm 4.1 width+martingale (+kappa if given) | |
| bound_canonical: np.ndarray | |
| beta: np.ndarray # beta_t(delta) at recorded steps | |
| logdet: np.ndarray # logdet(I + lam^-1 A_t) | |
| cert_viol_frac: float # fraction of steps where the optimism certificate failed | |
| cert_worst: float # worst positive certificate violation | |
| covered: np.ndarray # confidence-set coverage indicator at recorded steps | |
| covered_all: bool # theta* in C_t for ALL t=1..T (uniform coverage) | |
| def run_entucb(inst: Instance, T: int, sigma: float, delta: float, lam: float, | |
| Cbar: float, eps_schedule: Callable[[int], float], | |
| n_schedule: Callable[[int], int] | None = None, | |
| seed: int = 0, opt_iters: int = 6, n_record: int = 60, | |
| kappa: float = 0.0, eta: float | None = None, | |
| beta_scale: float = 1.0, sigma_run: float | None = None) -> RunResult: | |
| """Run EntUCB exactly as in Algorithm 1/2 with alternating-minimisation optimism. | |
| beta_scale : multiply the width beta_t by this factor (control experiments). | |
| sigma_run : actual noise std used to generate feedback (defaults to sigma; | |
| setting it larger than sigma is the assumption-violation control). | |
| n_schedule : if given, Basis-truncation EntUCB with order n_t (Algorithm 2). | |
| """ | |
| rng = np.random.default_rng(seed) | |
| N = inst.N | |
| sig_run = sigma if sigma_run is None else sigma_run | |
| kant_val, _ = kantorovich(inst) | |
| theta_star = inst.theta_star | |
| A = np.zeros((N, N)) # sum a_s a_s^T (full order) | |
| bvec = np.zeros(N) # sum a_s C_s | |
| logdet_reg = 0.0 # logdet(I + lam^-1 A_t) tracked exactly at record points | |
| Vinv = np.eye(N) / lam # (lam I + A)^{-1} via Sherman-Morrison (full order) | |
| pi = np.outer(inst.mu, inst.nu) # pi_1 = mu (x) nu | |
| theta_tilde_prev = None | |
| record_ts = np.unique(np.clip(np.round(np.geomspace(1, T, n_record)).astype(int), 1, T)) | |
| recs = {k: [] for k in ["ts", "kr", "krn", "er", "ern", "bl", "bc", "beta", "ld", "cov"]} | |
| cum_kr = cum_krn = cum_er = cum_ern = 0.0 | |
| cert_viol = 0 | |
| cert_worst = -np.inf | |
| covered_all = True | |
| ent_duals = None | |
| wit_duals = None | |
| alt_duals = None | |
| ent_cache = {"eps": None, "val": None} # Ent(c*, eps) cache (plan-independent) | |
| wit_cache = {"key": None, "P": None, "H": None, "ent_w": None} # witness cache | |
| for t in range(1, T + 1): | |
| eps_t = float(eps_schedule(t)) | |
| n_t = N if n_schedule is None else min(int(n_schedule(t)), N) | |
| # --- play pi_t, receive feedback ------------------------------- | |
| a_full = inst.embed(pi) | |
| true_mean = inst.pairing(inst.c_vec, pi) | |
| C_t = true_mean + sig_run * rng.standard_normal() | |
| # --- regret accounting ---------------------------------------- | |
| if ent_cache["eps"] == eps_t: | |
| ent_val = ent_cache["val"] | |
| else: | |
| ent_val, _, ent_duals = entropic_value(inst, eps_t, duals=ent_duals) | |
| ent_cache["eps"], ent_cache["val"] = eps_t, ent_val | |
| H_pi = inst.entropy(pi) | |
| cum_kr += true_mean - kant_val | |
| cum_krn += C_t - kant_val | |
| cum_er += true_mean + eps_t * H_pi - ent_val | |
| cum_ern += C_t + eps_t * H_pi - ent_val | |
| # --- RLS update (full-order structures) ------------------------ | |
| w = Vinv @ a_full | |
| denom = 1.0 + a_full @ w | |
| Vinv -= np.outer(w, w) / denom | |
| A += np.outer(a_full, a_full) | |
| bvec += a_full * C_t | |
| # --- estimator + confidence set at order n_t -------------------- | |
| An = A[:n_t, :n_t] | |
| Vn = lam * np.eye(n_t) + An | |
| cho = np.linalg.cholesky(Vn) | |
| theta_hat = _cho_solve(cho, bvec[:n_t]) | |
| sgn, ld_full = np.linalg.slogdet(np.eye(n_t) + An / lam) | |
| beta_t = beta_scale * beta_width(sigma, delta, lam, Cbar, ld_full) | |
| # coverage of the truncated true parameter (c*|_{n_t}) | |
| dtheta = theta_star[:n_t] - theta_hat | |
| dist = np.sqrt(dtheta @ (Vn @ dtheta)) | |
| cov_t = bool(dist <= beta_t) | |
| covered_all = covered_all and cov_t | |
| # --- optimism: alternating min over (theta, pi) ----------------- | |
| # The plan played at round t+1 must be optimistic for eps_{t+1} | |
| # (proof of Thm 4.1 uses <c~_t|pi_t> + eps_t H(pi_t) <= Ent(c*, eps_t) | |
| # for the round in which pi_t is *played*). | |
| # | |
| # EntUCB as defined by the paper plays the *exact* joint argmin, which | |
| # alternating minimisation (a DC-type scheme) cannot guarantee. To make | |
| # the audit of the regret THEOREM faithful, we add the proof's own | |
| # witness pair as an oracle candidate: pi+ = Sinkhorn plan of the | |
| # (truncated) true cost, theta+ = ellipsoid minimiser for a(pi+). On | |
| # the coverage event Psi(theta+, pi+) <= Psi(theta*|n, pi+) = | |
| # Ent(c*|n, eps), so the chosen pair provably satisfies the optimism | |
| # inequality the proof needs — verified per-step by the certificate. | |
| eps_next = float(eps_schedule(t + 1)) | |
| best = None | |
| th = (theta_tilde_prev if theta_tilde_prev is not None | |
| and len(theta_tilde_prev) == n_t else theta_hat).copy() | |
| prev_val = np.inf | |
| for _ in range(opt_iters): | |
| cmat = inst.func_from_coef(th).reshape(inst.K, inst.Kp) | |
| P, fdu, gdu = sinkhorn_log(inst.mu, inst.nu, cmat, eps_next, | |
| f0=None if alt_duals is None else alt_duals[0], | |
| g0=None if alt_duals is None else alt_duals[1]) | |
| alt_duals = (fdu, gdu) | |
| a_n = (inst.B[:n_t] @ P.ravel()) | |
| wv = _cho_solve(cho, a_n) | |
| nrm = np.sqrt(max(a_n @ wv, 1e-300)) | |
| th = theta_hat - beta_t * wv / nrm | |
| val = float(th @ a_n) + eps_next * inst.entropy(P) | |
| if best is None or val < best[0]: | |
| best = (val, P.copy(), th.copy()) | |
| if abs(prev_val - val) < 1e-10 * max(1.0, abs(val)): | |
| break | |
| prev_val = val | |
| # oracle witness candidate (the proof's own point); its plan depends | |
| # only on (n_t, eps_next) -> cache across steps | |
| wkey = (n_t, eps_next) | |
| if wit_cache.get("key") != wkey: | |
| c_trunc = inst.func_from_coef(theta_star[:n_t]) # c*|_{n_t} | |
| Pw, fw, gw = sinkhorn_log(inst.mu, inst.nu, c_trunc.reshape(inst.K, inst.Kp), | |
| eps_next, | |
| f0=None if wit_duals is None else wit_duals[0], | |
| g0=None if wit_duals is None else wit_duals[1]) | |
| wit_duals = (fw, gw) | |
| Hw = inst.entropy(Pw) | |
| wit_cache = {"key": wkey, "P": Pw, "H": Hw, | |
| "ent_w": float(inst.pairing(c_trunc, Pw)) + eps_next * Hw, | |
| "a_w": inst.B[:n_t] @ Pw.ravel()} | |
| Pw, a_w = wit_cache["P"], wit_cache["a_w"] | |
| wv = _cho_solve(cho, a_w) | |
| nrm = np.sqrt(max(a_w @ wv, 1e-300)) | |
| th_w = theta_hat - beta_t * wv / nrm | |
| val_w = float(th_w @ a_w) + eps_next * wit_cache["H"] | |
| if best is None or val_w < best[0]: | |
| best = (val_w, Pw.copy(), th_w.copy()) | |
| opt_val, pi_next, theta_tilde = best | |
| theta_tilde_prev = theta_tilde | |
| # --- optimism certificate (audit only) -------------------------- | |
| # On the good event the paper's proof needs Psi(theta~, pi~) <= Ent(c*|n, eps). | |
| ent_w = wit_cache["ent_w"] | |
| gap = opt_val - ent_w | |
| if gap > 1e-6 * max(1.0, abs(ent_w)): | |
| cert_viol += 1 | |
| cert_worst = max(cert_worst, gap) | |
| pi = pi_next | |
| # --- record ----------------------------------------------------- | |
| if t in record_ts: | |
| bl = thm41_width_term_literal(Cbar, beta_t, t, A, lam) + martingale_term(sigma, delta, t) | |
| bc = thm41_width_term_canonical(Cbar, beta_t, t, A, lam) + martingale_term(sigma, delta, t) | |
| if eta is not None: | |
| bl += kappa_term(kappa, eta, t) | |
| bc += kappa_term(kappa, eta, t) | |
| recs["ts"].append(t); recs["kr"].append(cum_kr); recs["krn"].append(cum_krn) | |
| recs["er"].append(cum_er); recs["ern"].append(cum_ern) | |
| recs["bl"].append(bl); recs["bc"].append(bc) | |
| recs["beta"].append(beta_t); recs["ld"].append(ld_full); recs["cov"].append(cov_t) | |
| return RunResult( | |
| ts=np.asarray(recs["ts"]), kant_regret=np.asarray(recs["kr"]), | |
| kant_regret_noisy=np.asarray(recs["krn"]), ent_regret=np.asarray(recs["er"]), | |
| ent_regret_noisy=np.asarray(recs["ern"]), bound_literal=np.asarray(recs["bl"]), | |
| bound_canonical=np.asarray(recs["bc"]), beta=np.asarray(recs["beta"]), | |
| logdet=np.asarray(recs["ld"]), cert_viol_frac=cert_viol / T, | |
| cert_worst=float(cert_worst), covered=np.asarray(recs["cov"]), | |
| covered_all=covered_all, | |
| ) | |
| def _cho_solve(cho: np.ndarray, rhs: np.ndarray) -> np.ndarray: | |
| from scipy.linalg import solve_triangular | |
| z = solve_triangular(cho, rhs, lower=True) | |
| return solve_triangular(cho.T, z, lower=False) | |
| # ---------------------------------------------------------------------------- | |
| # Picklable config-based runner (for multiprocessing in experiment sweeps) | |
| # ---------------------------------------------------------------------------- | |
| def run_from_config(cfg: dict) -> RunResult: | |
| """Reconstruct instance and schedules from a plain-dict config and run. | |
| cfg keys: K, Kp, inst_seed, cost_kind, planted (dict: kind='unit'|'decay', | |
| q, seed), T, sigma, delta, lam, Cbar_mult, eps (dict: kind='const'|'power', | |
| v / eta), n_sched (None | dict: kind='const'|'power', v / q), run_seed, | |
| opt_iters, kappa, eta_for_bound, beta_scale, sigma_run. | |
| """ | |
| planted_theta = None | |
| if cfg.get("planted"): | |
| p = cfg["planted"] | |
| N = cfg["K"] * cfg["Kp"] | |
| rng = np.random.default_rng(p["seed"]) | |
| if p["kind"] == "unit": | |
| th = rng.standard_normal(N) | |
| planted_theta = th / np.linalg.norm(th) | |
| elif p["kind"] == "decay": | |
| q = p["q"] | |
| idx = np.arange(1, N + 1, dtype=float) | |
| mag2 = idx ** (-q) - (idx + 1) ** (-q) | |
| mag2 = mag2 / mag2.sum() * (1 - (N + 1.0) ** (-q)) | |
| planted_theta = rng.choice([-1.0, 1.0], N) * np.sqrt(mag2) | |
| inst = make_instance(cfg["K"], cfg["Kp"], seed=cfg["inst_seed"], | |
| cost_kind=cfg.get("cost_kind", "smooth"), | |
| planted_theta=planted_theta) | |
| Cbar = cfg.get("Cbar_mult", 1.1) * np.linalg.norm(inst.theta_star) | |
| e = cfg["eps"] | |
| if e["kind"] == "const": | |
| eps_schedule = lambda t, v=e["v"]: v | |
| else: | |
| eps_schedule = lambda t, eta=e["eta"]: eta * t ** (-eta) | |
| n_schedule = None | |
| ns = cfg.get("n_sched") | |
| if ns: | |
| if ns["kind"] == "const": | |
| n_schedule = lambda t, v=ns["v"]: v | |
| else: | |
| n_schedule = lambda t, q=ns["q"]: int(np.ceil(t ** (1.0 / (q + 1.0)))) | |
| return run_entucb(inst, T=cfg["T"], sigma=cfg["sigma"], delta=cfg["delta"], | |
| lam=cfg["lam"], Cbar=Cbar, eps_schedule=eps_schedule, | |
| n_schedule=n_schedule, seed=cfg["run_seed"], | |
| opt_iters=cfg.get("opt_iters", 3), | |
| kappa=cfg.get("kappa", 0.0), eta=cfg.get("eta_for_bound"), | |
| beta_scale=cfg.get("beta_scale", 1.0), | |
| sigma_run=cfg.get("sigma_run")) | |
| def parallel_runs(cfgs: list[dict], procs: int = 0) -> list[RunResult]: | |
| import os | |
| from concurrent.futures import ProcessPoolExecutor | |
| procs = procs or min(len(cfgs), os.cpu_count() or 1) | |
| if procs <= 1 or len(cfgs) == 1: | |
| return [run_from_config(c) for c in cfgs] | |
| with ProcessPoolExecutor(max_workers=procs) as ex: | |
| return list(ex.map(run_from_config, cfgs)) | |
Xet Storage Details
- Size:
- 24.6 kB
- Xet hash:
- 4f101fef7ab39e36ed88ea5cc433b7a35e040742f23aeaba5e2b503b497dfbff
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.