# %% [markdown] # # 0106 - DAPO: each of its four techniques derived as the fix for a named pathology # Companion to `notes/0106 - DAPO.pdf`. fp64; exact enumeration where possible, Monte Carlo (flagged) elsewhere. # %% import sys, math sys.path.insert(0, "..") from _m1_lib import * import numpy as np # %% [markdown] # ## 1. Clip-Higher (pathology: entropy collapse / exploration bottleneck) # 1a. Asymmetric clip gradient table (eps_lo = 0.2, eps_hi = 0.28 as in DAPO Sec. 4.1) vs autograd; 1b. absolute probability headroom. # %% el, eh = 0.2, 0.28 def f_closed(r, A, el, eh): return A * min(r, 1 + eh) if A > 0 else A * max(r, 1 - el) def grad_auto(r, A): lp = torch.tensor(math.log(r), requires_grad=True); rr = lp.exp() f = torch.minimum(rr * A, torch.clamp(rr, 1 - el, 1 + eh) * A) return float(torch.autograd.grad(f, lp)[0]) worst = 0.0; n = 0 for A in (-1.5, -0.2, 0.2, 1.5): for r in np.linspace(0.05, 3.0, 400): if min(abs(r - (1 - el)), abs(r - (1 + eh))) < 1e-6: continue live = (r < 1 + eh) if A > 0 else (r > 1 - el) worst = max(worst, abs(grad_auto(r, A) - (A * r if live else 0.0))); n += 1 check(f"asymmetric clip: autograd == table (A r on live set) on {n} points", worst, 1e-14) print(" log-ratio limits: log(1+eps_hi)=%.4f log(1-eps_lo)=%.4f (symmetric eps=.2: +%.4f / %.4f)" % (math.log(1 + eh), math.log(1 - el), math.log(1.2), math.log(0.8))) print(" absolute probability reachable by ONE clipped update of a token with pi_old = p (A > 0):") print(" p cap eps=0.20 cap eps=0.28 headroom(0.20) headroom(0.28)") for pp in (0.001, 0.01, 0.1, 0.5, 0.9): c1, c2 = min(1.0, 1.2 * pp), min(1.0, 1.28 * pp) print(f" {pp:5.3f} {c1:8.4f} {c2:8.4f} {c1-pp:8.4f} {c2-pp:8.4f}") check("DAPO example: 0.01 -> 0.012 at eps=0.2", abs(min(1, 1.2 * 0.01) - 0.012), 1e-15) check("DAPO example: 0.9 -> min(1, 1.08) = 1.0 (no effective cap)", abs(min(1, 1.2 * 0.9) - 1.0), 1e-15) # %% [markdown] # 1c. Entropy dynamics of a softmax policy: dH = -Cov_{a~pi}( log pi_a , dz_a ) (exact first order). PG: dz = eta*pi*A; NPG: dz = eta*A. # %% def entropy(z): lp = torch.log_softmax(z, 0); return float(-(lp.exp() * lp).sum()) def cov_formula(z, dz): pi = torch.softmax(z, 0); lg = pi.log() return float((pi * lg * dz).sum() - (pi * lg).sum() * (pi * dz).sum()) gen = torch.Generator().manual_seed(3) worst_rel = 0.0 for trial in range(6): K = 5 z = torch.randn(K, generator=gen) * 1.5 A = torch.randn(K, generator=gen) pi = torch.softmax(z, 0); A = A - (pi * A).sum() # advantages must have zero pi-mean for name, dz in (("PG", pi * A), ("NPG", A)): eta = 1e-6 dH_fd = (entropy(z + eta * dz) - entropy(z - eta * dz)) / (2 * eta) dH_cov = -cov_formula(z, dz) worst_rel = max(worst_rel, abs(dH_fd - dH_cov) / max(1e-12, abs(dH_cov))) check("entropy change = -Cov(log pi, dz): finite difference vs formula (PG & NPG, 6 random policies)", worst_rel, 1e-6) pi = torch.tensor([0.90, 0.05, 0.03, 0.02]); z = pi.log() def dH_for(A): A = torch.tensor(A); A = A - (pi * A).sum(); return -cov_formula(z, pi * A) print(" dH/d eta for tabular PG: good action is the DOMINANT one (pi=.90): %+.5f good action is a RARE one (pi=.02): %+.5f" % (dH_for([1., 0, 0, 0]), dH_for([0., 0, 0, 1.]))) assert dH_for([1., 0, 0, 0]) < 0 < dH_for([0., 0, 0, 1.]) check("positive-advantage dominant token lowers entropy; positive-advantage rare token raises it (signs)", 0.0, 0.0) # %% [markdown] # 1d. Exact multi-step dynamics on a 2-action bandit (rare correct action a, pi=0.01): how fast can p(a) grow per rollout iteration? # Objective per inner step = sum_a pi_old(a) * f(r_a, A_a) (exact expectation), tabular logits, plain gradient ascent. # %% def run_bandit(p0, eps_hi, outer=60, inner=400, lr=0.4, el=0.2): z = torch.log(torch.tensor([p0, 1 - p0])).clone() traj = [p0] for it in range(outer): pi_old = torch.softmax(z, 0).detach() A = torch.tensor([1.0, 0.0]) - pi_old[0] # A_a = 1 - p, A_b = -p (binary reward on action a) zz = z.clone().requires_grad_(True) for _ in range(inner): pi = torch.softmax(zz, 0); r = pi / pi_old f = torch.where(A > 0, A * torch.clamp(r, max=1 + eps_hi), A * torch.clamp(r, min=1 - el)) obj = (pi_old * f).sum() g = torch.autograd.grad(obj, zz)[0] with torch.no_grad(): zz += lr * g z = zz.detach() traj.append(float(torch.softmax(z, 0)[0])) return torch.tensor(traj) t20, t28, tinf = run_bandit(0.01, 0.2), run_bandit(0.01, 0.28), run_bandit(0.01, 10.0) def first_cross(t, v=0.5): idx = (t >= v).nonzero() return int(idx[0]) if len(idx) else None print(" iterations for p(a) to reach 0.5 starting from 0.01: eps_hi=0.20 ->", first_cross(t20), " eps_hi=0.28 ->", first_cross(t28), " no upper clip ->", first_cross(tinf)) g20 = (t20[1:6] / t20[:5]); g28 = (t28[1:6] / t28[:5]) print(" per-iteration growth factor p'/p over first 5 iterations: eps=.20:", [round(float(x), 3) for x in g20], " eps=.28:", [round(float(x), 3) for x in g28]) print(" growth in iteration 1 slightly EXCEEDS 1+eps (1.251 vs 1.2; 1.342 vs 1.28): the clip stops only the DIRECT push on the rare action; the unclipped negative-advantage action keeps moving mass to it") assert first_cross(t28) <= first_cross(t20) check("larger eps_hi never slows the rare action (iterations to 0.5: eps_hi=.28 <= eps_hi=.20)", 0.0, 0.0) # %% [markdown] # ## 2. Dynamic sampling (pathology: zero-advantage groups). P(dead group) = p^G + (1-p)^G # %% torch.manual_seed(8) # torch.distributions samplers use the global RNG gen = torch.Generator().manual_seed(8) for G in (4, 16): for pp in (0.02, 0.3): ks = torch.distributions.Binomial(G, torch.tensor(pp)).sample((400000,)) emp = float(((ks == 0) | (ks == G)).double().mean()); ex = pp ** G + (1 - pp) ** G se = math.sqrt(ex * (1 - ex) / 400000) check(f"P(dead) MC vs p^G+(1-p)^G (G={G}, p={pp}) |diff|/SE", abs(emp - ex) / se, 4.0, kind="mc") # expected live fraction for p ~ Beta(a,b): 1 - B(a+G,b)/B(a,b) - B(a,b+G)/B(a,b) def lbeta(a, b): return math.lgamma(a) + math.lgamma(b) - math.lgamma(a + b) def live_fraction(a, b, G): return 1 - math.exp(lbeta(a + G, b) - lbeta(a, b)) - math.exp(lbeta(a, b + G) - lbeta(a, b)) xs, ws = np.polynomial.legendre.leggauss(400); xs = 0.5 * (xs + 1); ws = 0.5 * ws def live_quad(a, b, G): dens = np.exp(-lbeta(a, b)) * xs ** (a - 1) * (1 - xs) ** (b - 1) return float((ws * dens * (1 - xs ** G - (1 - xs) ** G)).sum()) for (a, b) in ((2, 5), (3, 3), (2, 8)): check(f"expected live fraction closed form vs quadrature, Beta({a},{b}), G=16", abs(live_fraction(a, b, 16) - live_quad(a, b, 16)), 1e-10) a, b, G = 2.0, 8.0, 16 rho = live_fraction(a, b, G) print(f" Beta({a:.0f},{b:.0f}) prompts (mean p={a/(a+b):.2f}), G={G}: live fraction rho = {rho:.3f} -> oversampling factor 1/rho = {1/rho:.2f}") # MC: prompts drawn until m live ones are found; expectation m / rho torch.manual_seed(2) gen = torch.Generator().manual_seed(2) m_need, reps = 64, 600 draws = [] beta = torch.distributions.Beta(torch.tensor(a), torch.tensor(b)) for _ in range(reps): live = 0; drawn = 0 while live < m_need: pp = beta.sample() k = torch.distributions.Binomial(G, pp).sample() drawn += 1; live += int(0 < k < G) draws.append(drawn) d = torch.tensor(draws, dtype=torch.float64) check("MC: prompts drawn to collect 64 live groups vs m/rho (z-score)", abs(float(d.mean()) - m_need / rho) / (float(d.std()) / reps ** 0.5), 4.0, kind="mc") # Unbiasedness of dynamic sampling (exact, no MC): prompts q_j with success prob p_j and weights w_j; per-prompt Bernoulli-bandit gradient g = p_hat(1-p_hat) # (centred advantage x score, see 0110). Filtered batch: a prompt is accepted with probability P(live|q_j) (so accepted prompts have law w_j P(live_j)/rho) and its # group contributes E[g | live, q_j] computed DIRECTLY from the binomial restricted to 0 0 else a * torch.clamp(r, min=1 - el) tot = tot + term; ntok += 1 return -tot / ntok def dapo_vec(th): lp_all = lm.token_logprobs(th) idx = torch.cat(groups); Rg = R[idx].reshape(len(groups), G) A = ((Rg - Rg.mean(1, keepdim=True)) / (Rg.std(1, unbiased=False, keepdim=True) + 1e-6)).reshape(-1) r = (lp_all[idx] - lq[idx]).exp(); mk = lm.mask[idx] f = torch.where(A[:, None] > 0, A[:, None] * torch.clamp(r, max=1 + eh), A[:, None] * torch.clamp(r, min=1 - el)) return -(f * mk).sum() / mk.sum() t1 = th_new.clone().requires_grad_(True); t2 = th_new.clone().requires_grad_(True) check("naive triple loop == vectorised DAPO loss (value)", abs(float(dapo_naive(t1).detach()) - float(dapo_vec(t2).detach())), 1e-13) g1 = torch.autograd.grad(dapo_naive(t1), t1)[0]; g2 = torch.autograd.grad(dapo_vec(t2), t2)[0] check("naive triple loop == vectorised DAPO loss (gradient)", maxabs(g1, g2), 1e-13) print("done; checks:", len(CHECKS))