SabaPivot's picture
download
raw
13.2 kB
"""CLAIM 5 -- Theorem 5.2: the folklore Omega(sqrt(dT)) minimax lower bound for adversarial
linear bandits on the unit Euclidean ball.
The theorem's construction (part 1) is implemented verbatim: theta in {+-Delta}^d with
Delta = Theta(T^{-1/2}), losses l_t = theta + xi_t with xi_t ~ N(0, (2d)^{-1} I_d), learner
plays z_t in the unit ball B_d and observes only <l_t, z_t>. The regret measured is the
direction regret of the paper's Eq. (7),
R^Z_T = sum_t <z_t, theta> + T ||theta|| (comparator u = -theta/||theta||).
Tests
1 Run several strong bandit algorithms on the construction and check the claimed floor
R^Z_T >= sqrt(dT)/64 ^ T/(12d) for every one of them, for the adversary's best Delta.
2 Fit the d- and T-exponents of the smallest regret achieved by any algorithm we ran
(empirical minimax envelope) -- predicted 1/2 and 1/2.
3 MECHANISM: the proof sketch attributes the sqrt(dT) rate (rather than d sqrt(T)) to the
1/d noise-variance constraint. Sweep the noise variance sigma^2 and check that the
achievable regret behaves as sigma * d * sqrt(T), so that sigma^2 = 1/(2d) gives
sqrt(dT/2).
4 INFORMATION ARGUMENT re-derived numerically: along real trajectories, compute
sum_i KL(P_theta || P_theta^(i)) for the d sign-flipped neighbours and check it equals
4 d Delta^2 sum_t E[z_{ti}^2/||z_t||^2] = 4 d Delta^2 T; then check that when this is
O(1) the algorithms recover no more coordinate signs than chance.
5 Part 2 of the theorem: the same construction with losses truncated to ||l_t|| <= 1.
"""
from __future__ import annotations
import json
import os
import sys
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from batch import fit_exponent
OUT = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "outputs"
)
SEED = 20260725
# ---------------------------------------------------------------------------- algorithms
def alg_thompson(T, d, theta, sigma, rng, truncate=False, lam=1.0):
"""Linear Thompson sampling on the unit ball (a strong stochastic linear-bandit
baseline). Regressors are normalised so the observation noise is homoscedastic."""
Vinv = np.eye(d) / lam
b = np.zeros(d)
reg_dir = 0.0
zs = np.zeros((T, d))
L = np.eye(d) * sigma
for t in range(T):
th_hat = Vinv @ b
if t % 25 == 0: # posterior square root, refreshed periodically
try:
L = np.linalg.cholesky(sigma * sigma * Vinv + 1e-12 * np.eye(d))
except np.linalg.LinAlgError:
L = np.eye(d) * sigma
th_s = th_hat + L @ rng.standard_normal(d)
n = np.linalg.norm(th_s)
z = -th_s / n if n > 0 else rng.standard_normal(d) / np.sqrt(d)
ell = theta + rng.standard_normal(d) * sigma
if truncate:
nl = np.linalg.norm(ell)
ell = ell * min(1.0, 1.0 / nl)
y = float(np.dot(ell, z))
reg_dir += float(np.dot(z, theta))
zs[t] = z
Vz = Vinv @ z
Vinv -= np.outer(Vz, Vz) / (1.0 + float(np.dot(z, Vz)))
b += z * y
return reg_dir + T * np.linalg.norm(theta), zs
def alg_etc(T, d, theta, sigma, rng, frac=None, truncate=False):
"""Explore-then-commit: explore coordinate directions, then commit to -theta_hat/||.||.
frac=None optimises the exploration budget over a grid (an *advantage* given to the
algorithm, which only strengthens a lower-bound test)."""
best = None
fracs = [frac] if frac is not None else [0.05, 0.1, 0.2, 0.4, 0.8, 1.0]
for f in fracs:
T0 = max(d, int(f * T) // d * d)
T0 = min(T0, T)
est = np.zeros(d)
reg = 0.0
for t in range(T0):
i = t % d
z = np.zeros(d)
z[i] = 1.0
ell = theta + rng.standard_normal(d) * sigma
if truncate:
ell = ell * min(1.0, 1.0 / np.linalg.norm(ell))
est[i] += float(np.dot(ell, z))
reg += float(np.dot(z, theta))
est = est / max(T0 // d, 1)
n = np.linalg.norm(est)
z = -est / n if n > 0 else np.zeros(d)
reg += (T - T0) * float(np.dot(z, theta))
r = reg + T * np.linalg.norm(theta)
best = r if best is None else min(best, r)
return best
def alg_osmd_ball(T, d, theta, sigma, rng, eta=None, truncate=False):
"""Bandit gradient descent on the ball with a one-coordinate importance-weighted
estimator (the SCRiBLe/OSMD-style baseline the paper compares against); the step size is
optimised over a grid, again in the algorithm's favour."""
best = None
etas = [eta] if eta is not None else [0.02, 0.05, 0.1, 0.3, 1.0]
for e in etas:
w = np.zeros(d)
reg = 0.0
gamma = min(0.5, np.sqrt(d / T)) # exploration rate
for t in range(T):
if rng.random() < gamma:
i = int(rng.integers(d))
s = 1.0 if rng.integers(2) == 0 else -1.0
z = np.zeros(d)
z[i] = s
explore = True
else:
z = w.copy()
nz = np.linalg.norm(z)
if nz > 1:
z = z / nz
explore = False
ell = theta + rng.standard_normal(d) * sigma
if truncate:
ell = ell * min(1.0, 1.0 / np.linalg.norm(ell))
y = float(np.dot(ell, z))
reg += float(np.dot(z, theta))
if explore:
ghat = (d / gamma) * z * y
w = w - e * ghat / np.sqrt(T)
nw = np.linalg.norm(w)
if nw > 1:
w = w / nw
r = reg + T * np.linalg.norm(theta)
best = r if best is None else min(best, r)
return best
ALGS = {
"thompson": lambda *a, **k: alg_thompson(*a, **k)[0],
"etc": alg_etc,
"osmd_ball": alg_osmd_ball,
}
def theorem_floor(d, T):
return min(np.sqrt(d * T) / 64.0, T / (12.0 * d))
def test1_and_2(seeds=15):
rows = []
cfgs = [(d, 4000) for d in [4, 8, 16, 32]] + [
(16, T) for T in [500, 1000, 2000, 8000]
]
deltas = [0.25, 0.5, 1.0, 2.0, 4.0]
for d, T in cfgs:
sigma = 1.0 / np.sqrt(2 * d)
per_alg = {}
for name, fn in ALGS.items():
worst = -np.inf
for c in deltas:
Delta = c / np.sqrt(T)
vals = []
for s in range(seeds):
rng = np.random.default_rng(SEED + 1000 * s + d + T + int(100 * c))
theta = (rng.integers(2, size=d) * 2.0 - 1.0) * Delta
vals.append(fn(T, d, theta, sigma, rng))
m = float(np.mean(vals))
if m > worst:
worst = m
per_alg[name] = dict(
regret=m,
best_Delta_coefficient=c,
sem=float(np.std(vals, ddof=1) / np.sqrt(seeds)),
)
env = min(v["regret"] for v in per_alg.values())
rows.append(
dict(
d=d,
T=T,
sqrt_dT=float(np.sqrt(d * T)),
theorem_floor=float(theorem_floor(d, T)),
per_algorithm=per_alg,
empirical_minimax_envelope=env,
envelope_over_sqrt_dT=env / float(np.sqrt(d * T)),
above_theorem_floor=bool(env >= theorem_floor(d, T)),
)
)
dsw = [r for r in rows if r["T"] == 4000]
tsw = [r for r in rows if r["d"] == 16]
sd, _, sed = fit_exponent(
[r["d"] for r in dsw], [r["empirical_minimax_envelope"] for r in dsw]
)
st, _, set_ = fit_exponent(
[r["T"] for r in tsw], [r["empirical_minimax_envelope"] for r in tsw]
)
return dict(
seeds=seeds,
rows=rows,
fitted_d_exponent=sd,
stderr_d=sed,
fitted_T_exponent=st,
stderr_T=set_,
predicted=0.5,
)
def test3_variance_mechanism(seeds=12):
"""regret ~ sigma * d * sqrt(T): the 1/d variance is what turns d sqrt(T) into
sqrt(dT)."""
d, T = 16, 4000
rows = []
for s2 in [1.0 / (2 * d), 1.0 / d, 0.25, 0.5, 1.0]:
sigma = np.sqrt(s2)
worst = -np.inf
for c in [0.25, 0.5, 1.0, 2.0, 4.0]:
Delta = c * sigma * np.sqrt(2 * d) / np.sqrt(T) # scaled to the noise level
vals = []
for s in range(seeds):
rng = np.random.default_rng(SEED + 77 * s + int(1000 * s2))
theta = (rng.integers(2, size=d) * 2.0 - 1.0) * Delta
vals.append(min(fn(T, d, theta, sigma, rng) for fn in ALGS.values()))
worst = max(worst, float(np.mean(vals)))
rows.append(
dict(
sigma_sq=s2,
envelope_regret=worst,
sigma_d_sqrtT=float(sigma * d * np.sqrt(T)),
ratio=worst / float(sigma * d * np.sqrt(T)),
)
)
sl, _, se = fit_exponent(
[r["sigma_sq"] for r in rows], [r["envelope_regret"] for r in rows]
)
return dict(
d=d,
T=T,
seeds=seeds,
rows=rows,
fitted_sigma_sq_exponent=sl,
stderr=se,
predicted_exponent=0.5,
note="regret ~ sigma d sqrt(T) => exponent 1/2 in sigma^2; at sigma^2=1/(2d) "
"this equals sqrt(dT/2)",
)
def test4_information(seeds=25):
"""Numerical re-derivation of the KL/Pinsker step and its consequence."""
rows = []
d, T = 16, 4000
sigma = 1.0 / np.sqrt(2 * d)
for c in [0.25, 0.5, 1.0, 2.0, 8.0]:
Delta = c / np.sqrt(T)
kls, correct, regs = [], [], []
for s in range(seeds):
rng = np.random.default_rng(SEED + 5 * s + int(10 * c))
theta = (rng.integers(2, size=d) * 2.0 - 1.0) * Delta
r, zs = alg_thompson(T, d, theta, sigma, rng)
nz2 = np.sum(zs * zs, axis=1)
nz2 = np.where(nz2 > 0, nz2, 1.0)
# KL per round between theta and its i-th sign flip (Gaussian observations with
# variance sigma^2 ||z_t||^2): (2 Delta z_{ti})^2 / (2 sigma^2 ||z_t||^2)
kl_i = np.sum(
(2 * Delta) ** 2 * zs**2 / (2 * sigma**2 * nz2[:, None]), axis=0
)
kls.append(float(np.sum(kl_i)))
# how many coordinate signs did the algorithm's final direction get right?
correct.append(float(np.mean(np.sign(-zs[-1]) == np.sign(theta))))
regs.append(r)
rows.append(
dict(
Delta_coefficient=c,
mean_total_KL=float(np.mean(kls)),
predicted_total_KL_4dDelta2T=float(
4 * d * Delta**2 * T / (2 * sigma**2) / d * 1.0
),
closed_form_2Delta2T_over_sigma2=float(2 * Delta**2 * T / sigma**2),
mean_fraction_of_signs_recovered=float(np.mean(correct)),
mean_regret=float(np.mean(regs)),
regret_over_sqrt_dT=float(np.mean(regs) / np.sqrt(d * T)),
)
)
return dict(
d=d,
T=T,
seeds=seeds,
rows=rows,
note="sum_i KL = 2 Delta^2 T / sigma^2 exactly (||z_t||=1 for this "
"algorithm); with sigma^2 = 1/(2d) and Delta = c/sqrt(T) this is "
"4 c^2 d, i.e. O(1) KL per coordinate -- the signs are unidentifiable "
"and chance-level recovery is expected",
)
def test5_truncated(seeds=12):
rows = []
for d in [8, 16, 32]:
T = 4000
sigma = 1.0 / np.sqrt(2 * d)
best = np.inf
for name, fn in ALGS.items():
worst = -np.inf
for c in [0.5, 1.0, 2.0]:
Delta = c / np.sqrt(T)
vals = []
for s in range(seeds):
rng = np.random.default_rng(SEED + 9 * s + d + int(10 * c))
theta = (rng.integers(2, size=d) * 2.0 - 1.0) * Delta
vals.append(fn(T, d, theta, sigma, rng, truncate=True))
worst = max(worst, float(np.mean(vals)))
best = min(best, worst)
rows.append(
dict(
d=d,
T=T,
envelope_regret=best,
over_sqrt_dT=best / float(np.sqrt(d * T)),
above_theorem_floor=bool(best >= theorem_floor(d, T)),
)
)
return rows
if __name__ == "__main__":
res = dict(
claim="claim-5 Theorem 5.2 Omega(sqrt(dT)) lower bound on the unit ball",
seed=SEED,
construction="theta in {+-Delta}^d, Delta = c/sqrt(T), l_t = theta + "
"N(0,(2d)^{-1} I), action set = unit Euclidean ball",
T1_T2_rates=test1_and_2(),
T3_noise_variance_mechanism=test3_variance_mechanism(),
T4_information_argument=test4_information(),
T5_truncated_bounded_losses=test5_truncated(),
)
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "claim5_lowerbound.json"), "w") as f:
json.dump(res, f, indent=1)
print(json.dumps(res, indent=1))

Xet Storage Details

Size:
13.2 kB
·
Xet hash:
f9f7a1574a69bdd5dc2b8f95a23b2a8dcccc28738bb54877068e5d1e85672d70

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