Download code/notebooks/_m1_src/0102-policy-gradient-baselines.py from viai957/align: direct link, hf CLI and curl.
- Browser
- Download file 9.5 kB
-
https://huggingface.co/viai957/align/resolve/main/code/notebooks/_m1_src/0102-policy-gradient-baselines.py
- Command line
-
hf download hf://viai957/align/code/notebooks/_m1_src/0102-policy-gradient-baselines.py
-
curl -L -o 0102-policy-gradient-baselines.py https://huggingface.co/viai957/align/resolve/main/code/notebooks/_m1_src/0102-policy-gradient-baselines.py
9.5 kB
| # %% [markdown] | |
| # # 0102 - Policy gradient from first principles, REINFORCE, baselines | |
| # Companion to `notes/0102 - Policy Gradient and Baselines.pdf`. Exact enumeration of the 121-sequence toy LM; fp64. | |
| # %% | |
| import sys, itertools | |
| sys.path.insert(0, "..") | |
| from _m1_lib import * | |
| lm = TinyLM(V=4, L=4, seed=0, scale=1.0) | |
| theta = lm.theta0.clone() | |
| R = default_reward(lm) # binary verifier reward | |
| p = lm.probs(theta) | |
| S = score_matrix(lm, theta) # [N, P] grad log pi(o) | |
| P_ = theta.numel() | |
| print("N =", lm.N, " P =", P_, " E[R] =", float(p @ R)) | |
| # %% [markdown] | |
| # ## 1. Log-derivative trick: grad J = E_pi[R * score] (note Eq. 2) | |
| # J(theta) = sum_o pi_theta(o) R(o). Three independent computations of its gradient. | |
| # %% | |
| th = theta.clone().requires_grad_(True) | |
| J = (lm.probs(th) * R).sum() | |
| g_auto = torch.autograd.grad(J, th)[0] # differentiate the *definition* (through pi) | |
| g_ld = (p[:, None] * R[:, None] * S).sum(0) # E_pi[R * score] (the derived formula) | |
| def Jfun(t): return float((lm.probs(t) * R).sum()) | |
| h = 1e-6 | |
| g_fd = torch.zeros_like(theta) | |
| for i in range(P_): # naive central finite differences | |
| e = torch.zeros_like(theta); e[i] = h | |
| g_fd[i] = (Jfun(theta + e) - Jfun(theta - e)) / (2 * h) | |
| check("log-derivative formula == autograd of J", maxabs(g_ld, g_auto), 1e-14) | |
| check("log-derivative formula == central finite differences", maxabs(g_ld, g_fd), 1e-8) | |
| # %% [markdown] | |
| # ## 2. Baselines are free: E[b * score] = 0 for any b that does not depend on the sampled response | |
| # %% | |
| check("E_pi[score] = 0", float((p[:, None] * S).sum(0).abs().max()), 1e-14) | |
| for b in [0.0, float(p @ R), 5.0, -3.0]: | |
| g_b = (p[:, None] * (R - b)[:, None] * S).sum(0) | |
| check(f"unbiased with constant baseline b={b:+.3f}", maxabs(g_b, g_auto), 1e-13) | |
| # state-dependent baseline b_t(o_<t): arbitrary function of the prefix (note Eq. for token-level baseline) | |
| T = token_score_tensor(lm, theta) # [N, L, P] token scores | |
| gen = torch.Generator().manual_seed(5) | |
| tab = {} | |
| def bpref(prefix): | |
| if prefix not in tab: tab[prefix] = float(torch.randn(1, generator=gen)) * 3.0 | |
| return tab[prefix] | |
| B = torch.zeros(lm.N, lm.L) | |
| for i, s in enumerate(lm.seqs): | |
| for t in range(len(s)): B[i, t] = bpref(s[:t]) | |
| g_state = (p[:, None] * ((R[:, None] - B)[:, :, None] * T).sum(1)).sum(0) | |
| check("unbiased with arbitrary state-dependent baseline b(o_<t)", maxabs(g_state, g_auto), 1e-13) | |
| # a baseline that depends on the token being scored breaks the proof (counter-example) | |
| B_bad = 0.7 * (lm.tokens == 1).double() * lm.mask | |
| g_bad = (p[:, None] * ((R[:, None] - B_bad)[:, :, None] * T).sum(1)).sum(0) | |
| print("baseline depending on o_t: bias norm =", float((g_bad - g_auto).norm())) | |
| assert float((g_bad - g_auto).norm()) > 1e-3 | |
| # and the extreme case b = R(o) (the sample's own reward) kills the gradient completely | |
| g_self = (p[:, None] * (R - R)[:, None] * S).sum(0) | |
| print("self-baseline gradient norm:", float(g_self.norm()), " true grad norm:", float(g_auto.norm())) | |
| # %% [markdown] | |
| # ## 3. Variance: exact quadratic in b, optimal baseline, and the "(0, 2 b*)" interval | |
| # Var(b) = E||(R-b) s||^2 - ||grad J||^2 with E||(R-b)s||^2 = E[R^2||s||^2] - 2b E[R||s||^2] + b^2 E||s||^2. | |
| # %% | |
| s2 = (S ** 2).sum(1) # ||s||^2 per sequence | |
| A0, A1, A2 = float((p * R ** 2 * s2).sum()), float((p * R * s2).sum()), float((p * s2).sum()) | |
| bstar = A1 / A2 | |
| def var_direct(b): | |
| g = (R - b)[:, None] * S # per-sequence estimator value | |
| return float((p * (g ** 2).sum(1)).sum() - (p[:, None] * g).sum(0).norm() ** 2) | |
| def var_formula(b): return A0 - 2 * b * A1 + b * b * A2 - float(g_auto.norm() ** 2) | |
| for b in [0.0, 0.3, 1.0, 2.0]: | |
| check(f"variance formula at b={b}", abs(var_direct(b) - var_formula(b)), 1e-13) | |
| grid = torch.linspace(-2, 4, 6001) | |
| vs = torch.tensor([var_formula(float(b)) for b in grid]) | |
| check("b* = E[R||s||^2]/E||s||^2 minimises variance (grid min vs V(b*))", max(0.0, var_formula(bstar) - float(vs.min())), 1e-12) | |
| check("minimiser on grid is within grid step of b*", abs(float(grid[vs.argmin()]) - bstar), 2e-3) | |
| EvR = float(p @ R) | |
| print(f"b*={bstar:.4f} E[R]={EvR:.4f} Var(0)={var_formula(0.0):.4f} Var(E[R])={var_formula(EvR):.4f} Var(b*)={var_formula(bstar):.4f}") | |
| # which side of E[R] does b* fall on? b* - E[R] = Cov_pi(R, ||s||^2) / E||s||^2 (exact identity); sign = sign of the covariance | |
| cov_Rs2 = float((p * R * s2).sum() - EvR * (p * s2).sum()) | |
| check("b* - E[R] == Cov_pi(R, ||s||^2) / E||s||^2 (exact identity)", abs((bstar - EvR) - cov_Rs2 / A2), 1e-14) | |
| m1_ = float((p * R * s2).sum() / (p * R).sum()); m0_ = float((p * (1 - R) * s2).sum() / (p * (1 - R)).sum()) | |
| print(f"E[||s||^2 | R=1] = {m1_:.3f} E[||s||^2 | R=0] = {m0_:.3f} -> successes carry the larger score norm, so b* > E[R]") | |
| assert (m1_ > m0_) == (bstar > EvR) | |
| # interval claim: any b in (0, 2b*) has Var(b) < Var(0); b outside is worse | |
| inside = all(var_formula(float(b)) < var_formula(0.0) for b in torch.linspace(0.01, 2 * bstar - 0.01, 50)) | |
| outside = all(var_formula(float(b)) > var_formula(0.0) for b in [2 * bstar + 0.01, 2 * bstar + 1.0, -0.01, -1.0]) | |
| assert inside and outside | |
| check("Var(b) < Var(0) iff b in (0, 2 b*) (50 inside, 4 outside points)", 0.0, 0.0) | |
| # %% [markdown] | |
| # ## 4. Naive vs efficient estimator (Monte-Carlo), and agreement with the exact gradient | |
| # naive: one backward per sample, python loop. efficient: ONE backward through the surrogate -mean(sg(A) * log pi). | |
| # %% | |
| n = 2000 | |
| gen = torch.Generator().manual_seed(11) | |
| idx = torch.multinomial(p, n, replacement=True, generator=gen) | |
| b = EvR | |
| # naive | |
| th = theta.clone().requires_grad_(True) | |
| acc = torch.zeros_like(theta) | |
| for i in idx.tolist(): | |
| lp = lm.seq_logprob(th)[i] | |
| acc += (float(R[i]) - b) * torch.autograd.grad(lp, th)[0] | |
| g_naive = acc / n | |
| # efficient | |
| th = theta.clone().requires_grad_(True) | |
| surrogate = ((R[idx] - b).detach() * lm.seq_logprob(th)[idx]).mean() # gradient of this = REINFORCE estimate | |
| g_eff = torch.autograd.grad(surrogate, th)[0] | |
| check("naive per-sample loop == single backward through surrogate", maxabs(g_naive, g_eff), 1e-13) | |
| # MC accuracy against the exact gradient | |
| n2 = 400_000 | |
| idx2 = torch.multinomial(p, n2, replacement=True, generator=gen) | |
| est = ((R[idx2] - b)[:, None] * S[idx2]) | |
| g_mc, se = est.mean(0), est.std(0) / n2 ** 0.5 | |
| ok_ = se > 0 | |
| z = float(((g_mc - g_auto).abs()[ok_] / se[ok_]).max()) | |
| check("MC REINFORCE gradient within 4 SE of exact (max z)", z, 4.0, kind="mc") | |
| # %% [markdown] | |
| # ## 5. Group baselines: leave-one-out is unbiased, "mean including self" is biased by (G-1)/G | |
| # Exact enumeration of ALL G-tuples of samples (G=2: 14641 tuples, G=3: 1.77M tuples, evaluated per first sample). | |
| # %% | |
| N = lm.N | |
| RR = R[None, :] ; pp = p[None, :] | |
| def loo_and_mean(G): | |
| """exact E[(R_1 - baseline_1) s_1] for baseline = mean of others (LOO) and mean of all (incl. self).""" | |
| # sum over samples 2..G of rewards: build the joint over (o_2..o_G) as a tensor of (reward sum, prob) | |
| if G == 2: | |
| rs = R[None, :].expand(N, N); w = p[None, :].expand(N, N) | |
| sum_others_R = rs; prob_others = w | |
| elif G == 3: | |
| sum_others_R = (R[:, None] + R[None, :]).reshape(-1)[None, :].expand(N, -1) | |
| prob_others = (p[:, None] * p[None, :]).reshape(-1)[None, :].expand(N, -1) | |
| # N x N^2 : first sample index x joint of other two | |
| Ro1 = R[:, None] | |
| base_loo = sum_others_R / (G - 1) | |
| base_all = (Ro1 + sum_others_R) / G | |
| wloo = (prob_others * (Ro1 - base_loo)).sum(1) # E over others of (R1 - b) | |
| wall = (prob_others * (Ro1 - base_all)).sum(1) | |
| g_loo = (p * wloo)[:, None] * S | |
| g_all = (p * wall)[:, None] * S | |
| return g_loo.sum(0), g_all.sum(0) | |
| for G in (2, 3): | |
| gl, ga = loo_and_mean(G) | |
| check(f"G={G}: leave-one-out baseline is exactly unbiased", maxabs(gl, g_auto), 1e-13) | |
| check(f"G={G}: mean-including-self gives exactly (G-1)/G * grad J", maxabs(ga, (G - 1) / G * g_auto), 1e-13) | |
| # %% [markdown] | |
| # ## 6. Causality / reward-to-go (per-token rewards): both unbiased, reward-to-go has lower variance here | |
| # Per-token reward r_t = 1[o_t == 1]. Full return G = sum_t r_t; reward-to-go G_t = sum_{t'>=t} r_t'. | |
| # %% | |
| rt = (lm.tokens == 1).double() * lm.mask # [N, L] | |
| Gfull = rt.sum(1) | |
| Gto = rt.flip(1).cumsum(1).flip(1) * lm.mask # reward-to-go | |
| th = theta.clone().requires_grad_(True) | |
| gJ = torch.autograd.grad((lm.probs(th) * Gfull).sum(), th)[0] | |
| est_full = (T.sum(1) * Gfull[:, None]) # sum_t score_t * G | |
| est_rtg = (T * Gto[:, :, None]).sum(1) # sum_t score_t * G_t | |
| g_full = (p[:, None] * est_full).sum(0); g_rtg = (p[:, None] * est_rtg).sum(0) | |
| check("full-return estimator unbiased", maxabs(g_full, gJ), 1e-13) | |
| check("reward-to-go estimator unbiased (dropped terms E[score_t r_t'] , t'<t, vanish)", maxabs(g_rtg, gJ), 1e-13) | |
| v_full = float((p * (est_full ** 2).sum(1)).sum() - gJ.norm() ** 2) | |
| v_rtg = float((p * (est_rtg ** 2).sum(1)).sum() - gJ.norm() ** 2) | |
| print(f"trace-variance full return {v_full:.4f} reward-to-go {v_rtg:.4f}") | |
| assert v_rtg < v_full | |
| # for an OUTCOME reward (one number at the end) reward-to-go == full return: nothing to drop | |
| Gto_out = R[:, None] * lm.mask | |
| check("outcome reward: reward-to-go == outcome at every t (no causality gain)", maxabs(Gto_out.sum(1), R * lm.lengths), 1e-15) | |
| print("done; checks:", len(CHECKS)) | |