Feasibility-Ranked Group Policy Gradient / experiment.py
Unverified
1import json, math, random
2from pathlib import Path
3import numpy as np
4import torch
5
6SEED = 2345
7np.set_printoptions(precision=6, suppress=True)
8
9def seed_all(seed):
10 random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)
11
12
13def normalize(A, eps=1e-8):
14 mu = A.mean(axis=0, keepdims=True)
15 sig = A.std(axis=0, keepdims=True)
16 return (A-mu)/(sig+eps), mu, sig
17
18
19def toy_verification():
20 # Two equally-sized return groups with controlled separation d. Every timestep
21 # has the same return ordering, so the conditional-margin prediction is exact.
22 B, T = 4000, 5
23 rng = np.random.default_rng(SEED)
24 feasible = np.zeros(B, dtype=bool); feasible[:B//2] = True
25 rng.shuffle(feasible)
26 noise = rng.normal(size=(B,T))
27 results = {}
28
29 # Prediction 1: normalized variance is (sigma/(sigma+eps))^2, approximately one.
30 var_rows=[]
31 for scale in [0.05, 0.2, 1.0, 5.0, 20.0]:
32 A = scale * noise
33 At, _, sig = normalize(A)
34 observed = float(At.var(axis=0).mean())
35 predicted = float(np.mean((sig/(sig+1e-8))**2))
36 var_rows.append({'scale':scale, 'observed_variance':observed, 'predicted':predicted})
37
38 # Prediction 2/3: with equal groups and feasible-return mean higher by d,
39 # E[At|F]-E[At|V] = d/sigma; weighted margin is (wF+wV)d/(2 sigma).
40 margin_rows=[]
41 base_noise = rng.normal(scale=0.25, size=(B,T))
42 for d in [0.0, 0.2, 0.5, 1.0, 2.0]:
43 G = base_noise.copy()
44 G[feasible] += d/2; G[~feasible] -= d/2
45 At, _, sig = normalize(G)
46 mf = float(At[feasible].mean()); mv = float(At[~feasible].mean())
47 sigma = float(sig.mean())
48 for ratio in [1.0, 2.0, 4.0]:
49 wf, wv = ratio, 1.0
50 observed = wf*mf - wv*mv
51 predicted = (wf+wv)*d/(2*sigma) if sigma > 0 else 0.0
52 margin_rows.append({'separation':d, 'wF':wf, 'wV':wv,
53 'observed_margin':observed, 'predicted_margin':predicted})
54 # Fit observed margin slope at wF=2,wV=1, compare formula slope.
55 selected=[r for r in margin_rows if r['wF']==2.0]
56 x=np.array([r['separation'] for r in selected]); y=np.array([r['observed_margin'] for r in selected])
57 slope=float(np.polyfit(x,y,1)[0])
58 sigma_ref=float(np.mean(np.abs(base_noise))) # only informational
59 return {'normalization_sweep':var_rows, 'margin_sweep':margin_rows,
60 'margin_observed_slope_wF2_wV1':slope,
61 'margin_prediction_slope_note':'(wF+wV)/(2*sigma), with sigma measured per sweep'}
62
63
64def rollout(theta, B=64, T=5, device='cpu'):
65 # action 0 is feasible/safe and pays .8; action 1 is risky and pays 1.2.
66 p=torch.sigmoid(theta)
67 acts=torch.bernoulli(p.expand(B,T))
68 rewards=torch.where(acts < .5, torch.tensor(.8,device=device), torch.tensor(1.2,device=device))
69 feasible=(acts.sum(dim=1)==0)
70 G=rewards.sum(dim=1)
71 return acts, rewards, feasible, G
72
73
74def update(theta, old_logp, acts, G, feasible, ranked, clip_eps=.2, wf=3., wv=.25):
75 # Group-relative trajectory advantage, repeated across time, then timestep norm.
76 raw=(G-G.mean()).unsqueeze(1).expand_as(acts)
77 mu=raw.mean(dim=0,keepdim=True); sd=raw.std(dim=0,keepdim=True,unbiased=False)
78 A=(raw-mu)/(sd+1e-8)
79 p=torch.sigmoid(theta)
80 logp=acts*torch.log(p+1e-8)+(1-acts)*torch.log(1-p+1e-8)
81 ratio=torch.exp(logp-old_logp)
82 clipped=torch.clamp(ratio,1-clip_eps,1+clip_eps)
83 weights=torch.where(feasible, torch.tensor(wf), torch.tensor(wv)) if ranked else torch.ones_like(feasible,dtype=torch.float)
84 obj=torch.minimum(ratio*A,clipped*A)*weights.unsqueeze(1)
85 loss=-obj.mean()
86 loss.backward()
87 return float(loss.detach()), float(A.var(dim=0,unbiased=False).mean()), float(feasible.float().mean())
88
89
90def mini_experiment():
91 # Same sampled-rollout setup and seeds for standard normalized GRPO vs ranking.
92 device='cuda' if torch.cuda.is_available() else 'cpu'
93 rows=[]
94 for ranked in [False, True]:
95 finals=[]; rewards=[]; curves=[]
96 for s in range(8):
97 seed_all(SEED+s)
98 theta=torch.tensor(0.0,device=device,requires_grad=True)
99 opt=torch.optim.SGD([theta],lr=.18)
100 hist=[]
101 for it in range(180):
102 opt.zero_grad()
103 acts,rews,feas,G=rollout(theta,device=device)
104 p=torch.sigmoid(theta)
105 oldlp=acts*torch.log(p+1e-8)+(1-acts)*torch.log(1-p+1e-8)
106 loss,var,fb=update(theta,oldlp.detach(),acts,G,feas,ranked)
107 opt.step()
108 hist.append((fb,float(rews.mean())))
109 with torch.no_grad():
110 p=float(torch.sigmoid(theta)); final_feas=(1-p)**5
111 finals.append(final_feas); rewards.append(.8*5*(1-p)+1.2*5*p)
112 curves.append(hist)
113 rows.append({'method':'ranked' if ranked else 'standard',
114 'final_feasibility_mean':float(np.mean(finals)),
115 'final_feasibility_std':float(np.std(finals)),
116 'policy_risky_probability_mean':float(1-np.mean(np.array(finals)**(1/5))),
117 'expected_reward_mean':float(np.mean(rewards)),
118 'training_rollout_feasibility_last20':float(np.mean([np.mean(c[-20:],axis=0)[0] for c in curves]))})
119 return {'device':device,'rows':rows}
120
121
122def main():
123 seed_all(SEED)
124 try:
125 mini = mini_experiment()
126 except Exception as exc:
127 print('CUDA/accelerator run failed; retrying on CPU:', repr(exc))
128 if torch.cuda.is_available():
129 torch.cuda.empty_cache()
130 old = torch.cuda.is_available
131 torch.cuda.is_available = lambda: False
132 try:
133 mini = mini_experiment()
134 finally:
135 torch.cuda.is_available = old
136 out={'toy_verification':toy_verification(),'mini_experiment':mini}
137 Path('results.json').write_text(json.dumps(out,indent=2))
138 print(json.dumps(out,indent=2))
139
140if __name__=='__main__': main()