File size: 15,532 Bytes
63f41a6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
# %% [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<k<G (not using g=0 on dead groups).
Gd = 16
pj = np.linspace(0.02, 0.98, 25); wj = pj ** (a - 1) * (1 - pj) ** (b - 1); wj /= wj.sum()
def binom_k(pp): return np.array([math.comb(Gd, k) * pp ** k * (1 - pp) ** (Gd - k) for k in range(Gd + 1)])
kk = np.arange(Gd + 1); gk = (kk / Gd) * (1 - kk / Gd)
Eg_all = np.array([float((binom_k(x) * gk).sum()) for x in pj])
Plive = np.array([float(binom_k(x)[1:Gd].sum()) for x in pj])
Eg_live = np.array([float((binom_k(x)[1:Gd] * gk[1:Gd]).sum()) / pl for x, pl in zip(pj, Plive)])
rho_j = float((wj * Plive).sum())
unfiltered = float((wj * Eg_all).sum())
filtered = float((wj * Plive / rho_j * Eg_live).sum())
check("dynamic sampling: E[g | accepted group] = (1/rho) * E[g | unfiltered group]  (exact, discrete prompt law)", abs(filtered - unfiltered / rho_j), 1e-15)
# the dilution identity: dividing by ALL samples instead of live samples scales the gradient by exactly the live fraction
print("   with global token normalisation, dead groups stay in the denominator: signal scales by rho =", round(rho, 3), "(identity, no estimation)")
# %% [markdown]
# ## 3. Token-level loss: global denominator is a RATIO estimator (bias O(1/#samples)); constant denominator is exactly unbiased
# Exact over all G-tuples (G=2,3) on the TinyLM.
# %%
lm = TinyLM(V=4, L=4, seed=0, scale=1.0)
theta = lm.theta0.clone(); R = default_reward(lm); p = lm.probs(theta); S = score_matrix(lm, theta); N = lm.N
lens = lm.lengths
grad_J = (p[:, None] * (R - float(p @ R))[:, None] * S).sum(0)
Lbar = float((p * lens).sum())
def exact_tokenlevel(G, normaliser):
    """E[ sum_i A_i * score(o_i) / D ] with centred advantages A_i = R_i - mean R; D = sum_i |o_i| (global) or G*Lbar (constant)."""
    if G == 2:
        R1, R2 = R[:, None].expand(N, N), R[None, :].expand(N, N)
        l1, l2 = lens[:, None].expand(N, N), lens[None, :].expand(N, N)
        P = p[:, None] * p[None, :]
        mu = (R1 + R2) / 2
        D = (l1 + l2) if normaliser == 'global' else torch.full((N, N), G * Lbar)
        w1 = P * (R1 - mu) / D; w2 = P * (R2 - mu) / D
        return w1.sum(1) @ S + w2.sum(0) @ S
    g = torch.zeros(S.shape[1])
    R2, R3 = R[:, None].expand(N, N), R[None, :].expand(N, N)
    l2, l3 = lens[:, None].expand(N, N), lens[None, :].expand(N, N)
    P23 = p[:, None] * p[None, :]
    for i in range(N):
        mu = (R[i] + R2 + R3) / 3
        D = (lens[i] + l2 + l3) if normaliser == 'global' else torch.full((N, N), G * Lbar)
        g += p[i] * ((P23 * (R[i] - mu) / D).sum()) * S[i]
        g += p[i] * ((P23 * (R2 - mu) / D).sum(1) @ S)
        g += p[i] * ((P23 * (R3 - mu) / D).sum(0) @ S)
    return g
for G in (2, 3):
    gc = exact_tokenlevel(G, 'const'); gg = exact_tokenlevel(G, 'global')
    check(f"G={G}: constant denominator G*E|o| gives (1-1/G) grad J / (G E|o|) * G exactly", maxabs(gc * Lbar, (1 - 1 / G) * grad_J), 1e-13)
    rel = float((gg - gc).norm() / gc.norm()); cs = float(gg @ grad_J / (gg.norm() * grad_J.norm()))
    print(f"   G={G}: global-token-mean estimator: relative deviation from the unbiased one = {rel:.4f}, cosine to grad J = {cs:.5f}")
# %% [markdown]
# ## 4. Overlong handling: soft punishment is a REWARD; loss masking is a reward equal to the baseline
# %%
def soft_overlong(Ly, Lmax, Lcache):
    if Ly <= Lmax - Lcache: return 0.0
    if Ly <= Lmax: return ((Lmax - Lcache) - Ly) / Lcache
    return -1.0
Lmax, Lcache = 16384, 4096
check("soft overlong: 0 at Lmax-Lcache", abs(soft_overlong(Lmax - Lcache, Lmax, Lcache)), 0.0)
check("soft overlong: -1 at Lmax (continuous with the truncation penalty)", abs(soft_overlong(Lmax, Lmax, Lcache) + 1.0), 0.0)
ls = np.arange(1, 24000)
vals = np.array([soft_overlong(int(x), Lmax, Lcache) for x in ls])
slope = float(np.abs(np.diff(vals[:Lmax])).max())          # largest per-token change of the ramp (up to Lmax)
check("soft overlong: ramp slope is exactly 1/Lcache (|max slope * Lcache - 1|)", abs(slope * Lcache - 1.0), 1e-9)
print("   hard truncation reward has a jump of size 1 at Lmax; the soft ramp spreads it over", Lcache, "tokens")
# masking == baseline reward  (TinyLM: 'truncated' = hit the length limit L=4 without EOS)
trunc = (lm.mask.sum(1) == lm.L) & (lm.tokens[:, -1] != lm.EOS)
b = float(p @ R)
g_masked = (p[:, None] * ((~trunc).double() * (R - b))[:, None] * S).sum(0)               # loss masked on truncated samples
Rprime = torch.where(trunc, torch.full_like(R, b), R)                                      # truncated sample gets reward = baseline
g_rb = (p[:, None] * (Rprime - b)[:, None] * S).sum(0)
check("loss-masking == assigning the baseline value as reward to truncated samples (exact)", maxabs(g_masked, g_rb), 1e-14)
th = theta.clone().requires_grad_(True)
Rlen = torch.where(trunc, torch.full_like(R, -1.0), torch.zeros_like(R)) + 0.0
g_total_reward = (p[:, None] * (R + Rlen - b)[:, None] * S).sum(0)
th = theta.clone().requires_grad_(True)
g_sep = torch.autograd.grad((lm.probs(th) * (R + Rlen)).sum(), th)[0]
check("shaping as reward: policy gradient of E[R + R_len] (separable objective)", maxabs(g_total_reward, g_sep), 1e-14)
g_hard = (p[:, None] * (R - b)[:, None] * S).sum(0)                                        # truncated treated as plain failures (R=0)
print(f"   ||g_masked - g_hard|| = {float((g_masked-g_hard).norm()):.4f}  vs ||grad J|| = {float(grad_J.norm()):.4f}  (filtering changes the objective)")
# %% [markdown]
# ## 5. Naive vs vectorised DAPO objective: asymmetric clip + global token-level normaliser + dynamic-sampling filter (gradient agreement)
# %%
th_old = lm.theta0.clone(); lq = lm.token_logprobs(th_old); q_old = lm.probs(th_old)
th_new = perturb(th_old, 1.0, seed=3)
gen = torch.Generator().manual_seed(17)
G, groups = 8, []
while len(groups) < 3:                                               # dynamic sampling: resample until the group is live (0 < k < G)
    idx = torch.multinomial(q_old, G, replacement=True, generator=gen)
    k = int(R[idx].sum())
    if 0 < k < G: groups.append(idx)
def dapo_naive(th):
    lp_all = lm.token_logprobs(th)
    tot, ntok = 0.0, 0
    for idx in groups:
        Rg = R[idx]; A = (Rg - Rg.mean()) / (Rg.std(unbiased=False) + 1e-6)
        for j, i in enumerate(idx.tolist()):
            for t in range(int(lm.lengths[i])):
                r = torch.exp(lp_all[i, t] - lq[i, t])
                a = A[j]
                term = a * torch.clamp(r, max=1 + eh) if a > 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))