# %% [markdown] # # 0103 - Importance sampling for off-policy updates; ratio variance; the product-of-token-ratios explosion # Companion to `notes/0103 - Importance Sampling and Off-Policy Updates.pdf`. Exact enumeration, fp64. # %% import sys, math sys.path.insert(0, "..") from _m1_lib import * # %% [markdown] # ## 1. IS identity, unit mean, and Var_q[w] = chi^2 = E_p[w] - 1 (note Sec. 2.1) # q = pi_old (sampler), p = pi_theta (target). Sequence-level weight w(o) = p(o)/q(o). # %% lm = TinyLM(V=4, L=4, seed=0, scale=1.0) th_old = lm.theta0.clone() th_new = perturb(th_old, 0.5, seed=3) q, p = lm.probs(th_old), lm.probs(th_new) w = p / q f = default_reward(lm) + 0.3 * lm.lengths # an arbitrary test function f(o) check("E_q[w f] = E_p[f] (IS identity)", abs(float((q * w * f).sum() - (p * f).sum())), 1e-14) check("E_q[w] = 1", abs(float((q * w).sum()) - 1.0), 1e-14) chi2 = float((p * w).sum()) - 1.0 check("Var_q[w] = E_p[w] - 1 = chi^2(p||q)", abs(float((q * w ** 2).sum() - 1.0) - chi2), 1e-14) # variance of the IS estimator of E_p[f]: Var_q[w f] = E_p[w f^2] - (E_p f)^2 v_direct = float((q * (w * f) ** 2).sum() - (q * w * f).sum() ** 2) v_formula = float((p * w * f ** 2).sum() - (p * f).sum() ** 2) check("Var_q[w f] = E_p[w f^2] - (E_p f)^2", abs(v_direct - v_formula), 1e-13) # %% [markdown] # ## 2. Self-normalised IS: bias O(1/n) and the effective sample size ESS = (sum w)^2 / sum w^2 ~ n / (1 + chi^2) # %% gen = torch.Generator().manual_seed(21) def snis(n, reps): est, ess = [], [] for _ in range(reps): idx = torch.multinomial(q, n, replacement=True, generator=gen) wi = w[idx] est.append(float((wi * f[idx]).sum() / wi.sum())) ess.append(float(wi.sum() ** 2 / (wi ** 2).sum())) return torch.tensor(est), torch.tensor(ess) target = float((p * f).sum()) for n in (4, 16, 64, 256): est, ess = snis(n, 40000 // (1 if n <= 16 else 4)) bias = float(est.mean() - target); se = float(est.std() / len(est) ** 0.5) print(f"n={n:4d} SNIS bias={bias:+.5f} (se {se:.5f}) mean ESS={float(ess.mean()):.2f} n/(1+chi2)={n/(1+chi2):.2f}") est, ess = snis(256, 4000) check("MC: ESS at n=256 within 15% of n/(1+chi^2)", abs(float(ess.mean()) / (256 / (1 + chi2)) - 1), 0.15, kind="mc") # %% [markdown] # ## 3. Product of token ratios: exact chi^2 growth with horizon T (long responses) # Sequence weight over the first T tokens: w_T = prod_{t<=T} r_t. E_q[w_T] = 1 for every T but E_q[w_T^2] grows geometrically. # %% lmL = TinyLM(V=3, L=12, seed=4, scale=1.0) tok = lmL.theta0[:lmL.n_tok].reshape(lmL.V + 1, lmL.V); tok[:, lmL.EOS] -= 2.5 # make EOS rare -> long sequences thq = lmL.theta0.clone(); thp = perturb(thq, 0.6, seed=9) lq, lp = lmL.token_logprobs(thq), lmL.token_logprobs(thp) qL = lq.sum(1).exp() check("long-horizon model: sum_o q(o) = 1", abs(float(qL.sum()) - 1), 1e-12) print("N sequences:", lmL.N, " mean length under q:", float((qL * lmL.lengths).sum())) logw_cum = (lp - lq).cumsum(1) # log w_T for T = 1..L (masked tokens contribute 0 -> ratio 1 after EOS) rows = [] for T in range(1, lmL.L + 1): wT = logw_cum[:, T - 1].exp() m1 = float((qL * wT).sum()); m2 = float((qL * wT ** 2).sum()) med = float((qL * (wT < 1).double()).sum()) # P_q(w_T < 1): the typical sample has weight < 1 rows.append((T, m1, m2 - 1, med, float((qL * logw_cum[:, T - 1]).sum()))) check(f"E_q[w_T] = 1 at T={T}", abs(m1 - 1), 1e-12) if T in (1, 6, 12) else None print(" T E[w] chi2_T P(w<1) E[log w]") for r in rows: print(f"{r[0]:2d} {r[1]:.4f} {r[2]:9.4f} {r[3]:.4f} {r[4]:+.4f}") chi_T = torch.tensor([r[2] for r in rows]) assert bool((chi_T[1:] >= chi_T[:-1] - 1e-12).all()) check("chi^2_T non-decreasing in T (E_q[w_T^2] only grows)", 0.0, 0.0) ratio_growth = float(chi_T[-1] / chi_T[0]) print(f"chi2_12 / chi2_1 = {ratio_growth:.1f}") # chain rule: E_q[log w_T] = - sum_{t<=T} E_q[KL_t(q || p)] computed independently on the prefix tree def tree_kl(thq, thp, T): tot = 0.0 def rec(prefix, prob): nonlocal tot if len(prefix) >= T: return cq, cp = lmL.cond_probs(thq, prefix), lmL.cond_probs(thp, prefix) tot += prob * float((cq * (cq.log() - cp.log())).sum()) for a in range(lmL.V - 1): rec(prefix + (a,), prob * float(cq[a])) rec((), 1.0) return tot for T in (3, 8): check(f"E_q[log w_T] = -sum_t E[KL_t] (tree recursion), T={T}", abs(rows[T - 1][4] + tree_kl(thq, thp, T)), 1e-12) # %% [markdown] # ## 4. iid-token closed form E_q[w_T^2] = (1 + chi2_1)^T and ESS collapse # %% Vv = 3 qa = torch.tensor([0.5, 0.3, 0.2]); pa = torch.tensor([0.45, 0.33, 0.22]) chi1 = float((pa ** 2 / qa).sum()) - 1 def brute(T): seqs = torch.cartesian_prod(*[torch.arange(Vv)] * T) if T > 1 else torch.arange(Vv)[:, None] lqs = qa[seqs].log().sum(1); lps = pa[seqs].log().sum(1) return float((lqs.exp() * (lps - lqs).exp() ** 2).sum()) for T in (1, 3, 6, 9): check(f"iid tokens: E_q[w_T^2] = (1+chi2_1)^T, T={T}", abs(brute(T) - (1 + chi1) ** T) / (1 + chi1) ** T, 1e-12) print(f"per-token chi2_1 = {chi1:.5f}") for T in (1, 10, 100, 1000, 4000): print(f"T={T:5d} chi2_T = {(1+chi1)**T-1:12.3f} ESS/n ~ 1/(1+chi2) = {(1+chi1)**(-T):.3e}") # %% [markdown] # ## 5. Naive vs stable weight computation (fp32 running product underflows; log-space is exact) # %% gen = torch.Generator().manual_seed(77) def weights(T, sigma, dtype): logr = (-0.5 * sigma ** 2 + sigma * torch.randn(T, generator=gen)).to(torch.float64) naive = torch.tensor(1.0, dtype=dtype) for x in logr.exp().to(dtype): naive = naive * x # naive running product of token ratios stable = logr.sum().exp() # log-space return float(naive), float(stable), float(logr.sum()) n64, s64, _ = weights(50, 0.1, torch.float64) check("T=50: naive fp64 product == exp(sum log r)", abs(n64 - s64) / s64, 1e-12) n32, s64b, lw = weights(4000, 0.5, torch.float32) print(f"T=4000 sigma=0.5: log w = {lw:.1f}; naive fp32 product = {n32}; log-space fp64 = {s64b:.3e}") assert n32 == 0.0 and math.isfinite(lw) # %% [markdown] # ## 6. Token-level surrogate (PPO-style ratio) vs the exact off-policy gradient # Exact: grad J(theta) = E_old[ w(o) (R-b) sum_t grad log pi_theta(o_t|s_t) ] (sequence-level weight, all tokens). # Token-level surrogate: L(theta) = E_old[ sum_t r_t(theta) (R-b) ] with r_t = pi_theta(o_t|s_t)/pi_old(o_t|s_t). # They agree at theta_old and drift apart as ||theta - theta_old|| grows: value error O(delta^2), gradient error O(delta). # %% lm = TinyLM(V=4, L=4, seed=0, scale=1.0) R = default_reward(lm) th_old = lm.theta0.clone() q = lm.probs(th_old) b = float(q @ R) def exact_grad(th): t = th.clone().requires_grad_(True) return torch.autograd.grad((lm.probs(t) * R).sum(), t)[0] def seq_is_grad(th): t = th.clone().requires_grad_(True) lpn = lm.seq_logprob(t) wts = (lpn - lm.seq_logprob(th_old)).exp().detach() return torch.autograd.grad((q * wts * (R - b) * lpn).sum(), t)[0] def token_surrogate(th): t = th.clone().requires_grad_(True) r = (lm.token_logprobs(t) - lm.token_logprobs(th_old)).exp() * lm.mask # r_t(theta), 0 where masked L = (q[:, None] * r * (R - b)[:, None]).sum() return L, torch.autograd.grad(L, t)[0] deltas = [0.0, 0.02, 0.04, 0.08, 0.16, 0.32] L0 = float((q * lm.lengths * (R - b)).sum()) gerr, verr = [], [] for d in deltas: th = perturb(th_old, d, seed=5) if d > 0 else th_old.clone() ge = exact_grad(th) check(f"sequence-level IS gradient == exact grad J, delta={d}", maxabs(seq_is_grad(th), ge), 1e-14) if d in (0.0, 0.32) else None Lv, gt = token_surrogate(th) gerr.append(float((gt - ge).norm())); verr.append(abs(float(Lv.detach()) - L0 - float((lm.probs(th) * R).sum() - (q * R).sum()))) print(f"delta={d:5.2f} |grad_tok - grad_J| = {gerr[-1]:.3e} |dL - dJ| = {verr[-1]:.3e}") check("token surrogate gradient == exact gradient at theta_old", gerr[0], 1e-14) check("token surrogate value == J to first order at theta_old (error at delta=0)", verr[0], 1e-14) import numpy as np sl_g = np.polyfit(np.log(deltas[1:]), np.log(gerr[1:]), 1)[0] sl_v = np.polyfit(np.log(deltas[1:]), np.log(verr[1:]), 1)[0] print(f"log-log slopes: gradient error {sl_g:.2f} (expect ~1), value error {sl_v:.2f} (expect ~2)") check("gradient-error slope ~ 1", abs(sl_g - 1.0), 0.15) check("value-error slope ~ 2", abs(sl_v - 2.0), 0.15) print("done; checks:", len(CHECKS))