SabaPivot's picture
download
raw
9.53 kB
"""Independent re-implementation of PABLO (Jacobsen, Baudry, Ito, Cesa-Bianchi, ICML 2026),
"A Perturbation Approach to Unconstrained Linear Bandits" (arXiv:2603.28201).
Everything here is written from the paper's pseudocode only (Algorithm 1, Algorithm 5,
Algorithm 6, Eq. (4)); no author code exists (no repository is linked in the paper, on
OpenReview, or on arXiv).
Conventions
-----------
* Domain W = R^d (unconstrained), losses f_t(w) = <l_t, w>, ||l_t|| <= G.
* uBLO feedback: the learner plays wtilde_t and observes only the scalar <l_t, wtilde_t>.
* Regret is measured on the *played* point: R_T(u_{1:T}) = sum_t <l_t, wtilde_t - u_t>.
"""
from __future__ import annotations
import numpy as np
# --------------------------------------------------------------------------------------
# Algorithm 1: PABLO (Section 2 of the paper)
# --------------------------------------------------------------------------------------
def pablo_perturbation(w, d, eps):
"""Isotropic choice of Eq. (4): H_t = I / (d * (||w_t||^2 v eps^2)), so that
H_t <= I / (d(||w_t||^2 v eps^2)) holds with equality (Corollary 2.2 applies).
Eigenvectors of H_t are the standard basis; S = {+-e_i}.
Returns (H_scale, m) where H_t = (1/(d m^2)) I and m = max(||w||, eps).
"""
m = max(float(np.linalg.norm(w)), eps)
return 1.0 / (d * m * m), m
def pablo_step(w, ell, d, eps, rng):
"""One round of Algorithm 1 with the isotropic H_t of Eq. (4).
H_t^{-1/2} = sqrt(d) * m * I , H_t^{1/2} = 1/(sqrt(d) m) * I.
wtilde_t = w_t + H_t^{-1/2} s_t = w_t + sqrt(d) * m * s_t
ltilde_t = d H_t^{1/2} s_t <wtilde_t, l_t> = (sqrt(d)/m) * s_t * <wtilde_t, l_t>
Returns (wtilde, ltilde, i, sigma).
"""
_, m = pablo_perturbation(w, d, eps)
i = int(rng.integers(d))
sigma = 1.0 if rng.integers(2) == 0 else -1.0
s = np.zeros(d)
s[i] = sigma
wt = w + np.sqrt(d) * m * s
obs = float(np.dot(wt, ell)) # <-- the ONLY feedback the learner gets
lt = (np.sqrt(d) / m) * s * obs
return wt, lt, i, sigma
def pablo_all_outcomes(w, ell, d, eps, c=1.0):
"""Exhaustive enumeration of all 2d equally-likely perturbation outcomes of a PABLO
round. Used for exact (non-Monte-Carlo) verification of Proposition 2.1."""
m = max(float(np.linalg.norm(w)), eps) / np.sqrt(c) # H = c I/(d(||w||^2 v eps^2))
W = np.repeat(w[None, :], 2 * d, axis=0)
S = np.zeros((2 * d, d))
for i in range(d):
S[2 * i, i] = 1.0
S[2 * i + 1, i] = -1.0
WT = W + np.sqrt(d) * m * S
obs = WT @ ell
LT = (np.sqrt(d) / m) * S * obs[:, None]
return WT, LT
# --------------------------------------------------------------------------------------
# Algorithm 5: refined dynamic base algorithm for OLO (Appendix E.2)
# --------------------------------------------------------------------------------------
# psi(w) = (k/eta) int_0^{||w-w1||} log(x/alpha + 1) dx
# theta_t = (k/eta) log(||w_t-w1||/alpha + 1) (w_t-w1)/||w_t-w1|| - g_t
# w_{t+1} = w1 + theta_t/||theta_t|| * alpha [ exp( (eta/k)(||theta_t|| - eta/2 ||g_t||^2
# - gamma) ) - 1 ]_+
# (the exponent's eta/k factor is the inverse of the k/eta scale of psi; it is the unique
# parsing that makes the mirror-descent optimality condition and Theorem E.6 consistent,
# and Theorem E.6's inequality is checked numerically in scripts/claim3_dynamic.py)
class Alg6:
"""Algorithm 6 -- dynamic algorithm for unconstrained OLO.
Runs one Algorithm-5 instance per step size eta_i = min(2^i/(T L), 1/L) and plays the
SUM of their iterates. alpha = epsilon/T, gamma = L/T, k = 4, w1 = 0.
Requires no knowledge of P_T, of the comparator sequence, or of ||u||.
Vectorised across the eta-grid: state W has shape (|S|, d).
"""
EXP_CLIP = 300.0
def __init__(self, d, T, L, epsilon, k=4.0):
self.d, self.T, self.L, self.eps, self.k = d, T, L, epsilon, k
n = int(np.ceil(np.log2(max(T, 2)))) + 1
etas = np.array([min(2.0**i / (T * L), 1.0 / L) for i in range(n)])
self.etas = etas
self.alpha = epsilon / T
self.gamma = L / T
self.W = np.zeros((len(etas), d))
self.clipped = 0
def play(self):
return self.W.sum(axis=0)
def update(self, g):
k, alpha, gamma = self.k, self.alpha, self.gamma
etas = self.etas[:, None]
nw = np.linalg.norm(self.W, axis=1, keepdims=True)
safe = np.where(nw > 0, nw, 1.0)
theta = (k / etas) * np.log(nw / alpha + 1.0) * (self.W / safe) - g[None, :]
nt = np.linalg.norm(theta, axis=1, keepdims=True)
gg = float(np.dot(g, g))
x = (etas / k) * (nt - 0.5 * etas * gg - gamma)
if np.any(x > self.EXP_CLIP):
self.clipped += int(np.sum(x > self.EXP_CLIP))
x = np.clip(x, -np.inf, self.EXP_CLIP)
mag = alpha * np.maximum(np.expm1(x), 0.0)
safe_t = np.where(nt > 0, nt, 1.0)
self.W = mag * theta / safe_t
class OGD:
"""Baseline: online gradient descent with a fixed step size (needs oracle tuning)."""
def __init__(self, d, lr):
self.w = np.zeros(d)
self.lr = lr
def play(self):
return self.w
def update(self, g):
self.w = self.w - self.lr * g
# --------------------------------------------------------------------------------------
# The full uBLO learner: PABLO wrapped around an OLO subroutine
# --------------------------------------------------------------------------------------
def run_pablo(losses, olo, d, eps, rng, comparators=None, record=False):
"""Run PABLO on a loss sequence.
losses : (T, d) array of l_t (the environment; never revealed to the learner)
olo : OLO subroutine object with .play() / .update(g)
comparators : (T, d) comparator sequence u_{1:T} (only used for bookkeeping)
Returns a dict with the realised regret and the diagnostics needed by the claims.
"""
T = len(losses)
reg = 0.0
sum_lt_sq = 0.0 # sum ||ltilde_t||^2 (drives the kappa discussion)
sum_l_sq = 0.0 # V_T = sum ||l_t||^2
sum_w_sq = 0.0 # sum ||w_t||^2 (Proposition 4.1)
olo_reg = 0.0 # regret of the OLO subroutine on the estimated losses
max_lt = 0.0
ws, wts = [], []
for t in range(T):
w = olo.play()
ell = losses[t]
wt, lt, _, _ = pablo_step(w, ell, d, eps, rng)
u = comparators[t] if comparators is not None else np.zeros(d)
reg += float(np.dot(ell, wt - u))
olo_reg += float(np.dot(lt, w - u))
sum_lt_sq += float(np.dot(lt, lt))
sum_l_sq += float(np.dot(ell, ell))
sum_w_sq += float(np.dot(w, w))
max_lt = max(max_lt, float(np.linalg.norm(lt)))
if record:
ws.append(w.copy())
wts.append(wt.copy())
olo.update(lt)
out = dict(
regret=reg,
olo_regret=olo_reg,
sum_ltilde_sq=sum_lt_sq,
V_T=sum_l_sq,
sum_w_sq=sum_w_sq,
max_ltilde=max_lt,
)
if record:
out["w"] = np.array(ws)
out["wtilde"] = np.array(wts)
return out
# --------------------------------------------------------------------------------------
# Environments
# --------------------------------------------------------------------------------------
def env_stochastic(T, d, G, rng, bias=None, sigma=None):
"""l_t = theta + noise, projected to ||l_t|| <= G (sub-Gaussian stochastic instance)."""
if bias is None:
bias = np.zeros(d)
bias[0] = G / np.sqrt(T)
if sigma is None:
sigma = G / np.sqrt(2 * d)
L = bias[None, :] + sigma * rng.standard_normal((T, d))
n = np.linalg.norm(L, axis=1, keepdims=True)
return L * np.minimum(1.0, G / np.maximum(n, 1e-12))
def env_rademacher(T, d, G, rng):
"""Oblivious adversarial instance: l_t = G * (random +-1 coordinate direction)."""
L = np.zeros((T, d))
idx = rng.integers(d, size=T)
sgn = rng.integers(2, size=T) * 2.0 - 1.0
L[np.arange(T), idx] = G * sgn
return L
def env_switching(T, d, G, rng, n_blocks=8):
"""Piecewise-constant adversarial instance (used with moving comparators)."""
L = np.zeros((T, d))
bl = np.array_split(np.arange(T), n_blocks)
for b in bl:
v = rng.standard_normal(d)
v = G * v / np.linalg.norm(v)
L[b] = v
return L
def env_hard(T, d, G, rng, c=1.0, ret_theta=False):
"""The hypercube instance underlying Theorem 5.2 / the minimax rate: theta in {+-Delta}^d
with Delta = c*G/sqrt(T), plus Gaussian noise with per-coordinate variance G^2/(2d) so
that E||l_t||^2 <= G^2. This is the loss family on which sqrt(dT) is the right rate."""
theta = (rng.integers(2, size=d) * 2.0 - 1.0) * (c * G / np.sqrt(T))
L = theta[None, :] + rng.standard_normal((T, d)) * (G / np.sqrt(2 * d))
n = np.linalg.norm(L, axis=1, keepdims=True)
L = L * np.minimum(1.0, G / np.maximum(n, 1e-12))
return (L, theta) if ret_theta else L
def env_flip(T, d, G, rng, amp=0.25, n_flips=3):
"""Adversarial instance that first rewards scaling up and then reverses sign: the
stress test for the risk-control term R_T(0) <= O(G eps) in Theorem 3.1."""
v = rng.standard_normal(d)
v = amp * G * v / np.linalg.norm(v)
L = np.zeros((T, d))
sign = -1.0
for b, idx in enumerate(np.array_split(np.arange(T), n_flips + 1)):
L[idx] = sign * v
sign = -sign
return L

Xet Storage Details

Size:
9.53 kB
·
Xet hash:
86529ff86a8138b9964ce6313440ffde4fffbb16e1f5266b4b2838753377b5f5

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.