# %% [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_ 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'