Effective-Exploration Bias Correction / experiment.py
Mechanism failed
1import json, math, random
2from pathlib import Path
3import numpy as np
4
5SEED = 1653
6
7def simulate_bandit(mu, sigma, T, c, reps=220, seed=SEED):
8 rng = np.random.default_rng(seed)
9 K = len(mu)
10 zs = np.zeros((reps, K)); means = np.zeros((reps, K)); counts = np.zeros((reps, K), dtype=int)
11 for rep in range(reps):
12 sums = np.zeros(K); sums2 = np.zeros(K); n = np.zeros(K, dtype=int)
13 # forced initialization makes every empirical statistic defined
14 for a in range(K):
15 r = rng.normal(mu[a], sigma[a]); sums[a] += r; sums2[a] += r*r; n[a] += 1
16 for t in range(K, T):
17 f = c * math.sqrt(math.log(t + 1.0))
18 bonus = f / np.sqrt(n)
19 arm = int(np.argmax(sums / n + bonus))
20 r = rng.normal(mu[arm], sigma[arm]); sums[arm] += r; sums2[arm] += r*r; n[arm] += 1
21 m = sums / n
22 # population variance estimate is used here to avoid tiny-n instability
23 sd = np.sqrt(np.maximum(sums2 / n - m*m, 1e-12))
24 means[rep] = m; counts[rep] = n
25 zs[rep] = np.sqrt(n) * (m - mu) / sigma
26 fT = c * math.sqrt(math.log(T + 1.0))
27 return {"z_mean": zs.mean(0), "z_se": zs.std(0, ddof=1)/math.sqrt(reps),
28 "mean_bias": (means-mu).mean(0), "counts": counts.mean(0), "fT": fT}
29
30def mechanism_sweep():
31 # Arm 1 is suboptimal and therefore covered by coefficient 1 in the theorem.
32 mu = np.array([0.50, 0.00, 0.50])
33 sigma = np.ones(3)
34 rows=[]
35 for c in [0.5, 1.0, 2.0, 4.0]:
36 out = simulate_bandit(mu, sigma, T=1400, c=c)
37 z = float(out["z_mean"][1])
38 rows.append({"c":c, "fT":out["fT"], "z_suboptimal":z,
39 "predicted_sign":"negative", "scaled_fT_times_z":out["fT"]*z,
40 "predicted_scaled_limit":-1.0,
41 "mean_count_suboptimal":float(out["counts"][1])})
42 # T sweep checks the predicted 1/sqrt(log T) UCB1 decay.
43 trows=[]
44 for T in [400, 800, 1400, 2400]:
45 out=simulate_bandit(mu, sigma, T=T, c=1.0, reps=260, seed=SEED+T)
46 z=float(out["z_mean"][1]); f=out["fT"]
47 trows.append({"T":T,"fT":f,"z_suboptimal":z,"fT_times_z":f*z,
48 "predicted_z":-1.0/f})
49 # Direct target correction, using g=1 for the known non-unique-optimal test arm.
50 out=simulate_bandit(mu,sigma,T=1400,c=1.0,reps=350,seed=SEED+99)
51 raw=float(out["z_mean"][1]); corrected=raw+1.0/out["fT"]
52 return {"exploration_sweep":rows,"horizon_sweep":trows,
53 "correction":{"raw_z":raw,"corrected_z":corrected,
54 "predicted_raw_z":-1/out["fT"],"fT":out["fT"]}}
55
56def neural_comparison():
57 # Small contextual bandit: nonlinear arm means, Gaussian reward. Torch is optional.
58 try:
59 import torch
60 import torch.nn as nn
61 torch.manual_seed(SEED); np.random.seed(SEED); random.seed(SEED)
62 device = "cuda" if torch.cuda.is_available() else "cpu"
63 try:
64 torch.tensor([1.], device=device)
65 except Exception:
66 device="cpu"
67 rng=np.random.default_rng(SEED); K=6; d=4; T=1100
68 x=rng.normal(size=(T,d));
69 def true_mean(xx):
70 vals=[]
71 for a in range(K):
72 vals.append(.55*xx[:,0]*np.sin(.7*(a+1))+ .35*xx[:,1]**2/(a+1)
73 +.25*xx[:,2]*(-1)**a + .08*a)
74 return np.stack(vals,1)
75 mt=true_mean(x); noise=.6
76 counts=np.zeros(K,int); sums=np.zeros(K); sumsq=np.zeros(K); data=[]
77 for a in range(K):
78 r=mt[a,a] + rng.normal(0,noise); counts[a]+=1; sums[a]+=r; sumsq[a]+=r*r; data.append((x[a],a,r))
79 c=1.0
80 for t in range(K,T):
81 # policy uses the known functional oracle plus empirical arm residual; this isolates sampling bias
82 est=sums/counts
83 arm=int(np.argmax(mt[t]+c*math.sqrt(math.log(t+1))/np.sqrt(counts)))
84 r=mt[t,arm]+rng.normal(0,noise)
85 counts[arm]+=1; sums[arm]+=r; sumsq[arm]+=r*r; data.append((x[t],arm,r))
86 X=np.stack([z[0] for z in data]); A=np.array([z[1] for z in data]); Y=np.array([z[2] for z in data])
87 f=c*math.sqrt(math.log(T+1)); m=sums/counts
88 sd=np.sqrt(np.maximum(sumsq/counts-m*m, .05)); u=m+sd/np.sqrt(counts)
89 gates=np.zeros(K)
90 for a in range(K):
91 competitor=np.max(np.delete(u,a)); gates[a]=1/(1+np.exp(-(competitor-u[a])/.15))
92 corr=gates[A]*sd[A]/(np.sqrt(counts[A])*f)
93 class Net(nn.Module):
94 def __init__(self):
95 super().__init__(); self.net=nn.Sequential(nn.Linear(d+K,32),nn.Tanh(),nn.Linear(32,1))
96 def forward(self,z): return self.net(z).squeeze(-1)
97 inp=np.concatenate([X,np.eye(K)[A]],1).astype("float32")
98 tx=torch.tensor(inp,device=device); ty=torch.tensor(Y.astype("float32"),device=device)
99 def fit(target):
100 torch.manual_seed(SEED); net=Net().to(device); opt=torch.optim.Adam(net.parameters(),lr=.01)
101 for _ in range(18):
102 perm=torch.randperm(len(ty),device=device)
103 for j in range(0,len(ty),128):
104 q=perm[j:j+128]; loss=((net(tx[q])-target[q])**2).mean(); opt.zero_grad(); loss.backward(); opt.step()
105 # uniformly randomized held-out contexts/arms
106 xe=rng.normal(size=(1200,d)); ae=rng.integers(K,size=1200); truth=true_mean(xe)[np.arange(1200),ae]
107 ii=np.concatenate([xe,np.eye(K)[ae]],1).astype("float32")
108 with torch.no_grad(): pred=net(torch.tensor(ii,device=device)).cpu().numpy()
109 mse=float(np.mean((pred-truth)**2)); rare=np.argsort(counts)[:3]
110 rare_mask=np.isin(ae,rare); rare_bias=float(np.mean(pred[rare_mask]-truth[rare_mask]))
111 return mse,rare_bias
112 b=fit(ty); idea=fit(ty+torch.tensor(corr.astype("float32"),device=device))
113 return {"device":device,"counts":counts.tolist(),"gates":gates.tolist(),
114 "baseline":{"heldout_mse":b[0],"rare_signed_bias":b[1]},
115 "idea":{"heldout_mse":idea[0],"rare_signed_bias":idea[1]}}
116 except Exception as e:
117 return {"error":repr(e)}
118
119if __name__ == "__main__":
120 result={"seed":SEED,"mechanism":mechanism_sweep(),"neural":neural_comparison()}
121 Path("results.json").write_text(json.dumps(result,indent=2))
122 print(json.dumps(result,indent=2))