File size: 5,898 Bytes
bcc8d35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Shared helpers for the M1 notebooks (notes 0101-0110).

Everything is fp64 CPU torch.  The "language model" is a deliberately tiny
autoregressive policy whose *entire* sequence space can be enumerated, so every
expectation in the notes can be computed EXACTLY (no Monte Carlo) and compared
against the hand-derived formula to ~1e-15.  Monte Carlo checks are kept for the
few identities that are about sampling (and are reported against 4 standard errors).

Policy:  pi_theta(a | prev, t) = softmax(theta_tok[prev] + theta_pos[t])_a
  - vocabulary {0,..,V-1} where token V-1 is EOS
  - `prev` is the previous token (BOS = index V for t=0)
  - a sequence stops at EOS or after L tokens (truncation, no EOS)
This is a valid autoregressive factorisation  pi(o) = prod_t pi(o_t | o_<t)
(with a Markov-1 context) so every identity about the general case holds verbatim.
"""
import os, sys, math, itertools
os.environ.setdefault("CUDA_VISIBLE_DEVICES", "")      # never touch the GPUs
import torch
torch.set_default_dtype(torch.float64)
torch.set_num_threads(2)

CHECKS = []

def check(name, err, tol, kind="exact"):
    """Record + assert a numerical identity.  kind='exact' (fp64 arithmetic) or 'mc' (4-sigma Monte Carlo)."""
    err = float(err)
    ok = err <= tol
    CHECKS.append((name, err, tol, kind, ok))
    print(f"CHECK|{kind}|{name}|{err:.3e}|tol={tol:.1e}|{'PASS' if ok else 'FAIL'}")
    assert ok, f"{name}: err {err:.3e} > tol {tol:.1e}"

def maxabs(a, b):
    return float((torch.as_tensor(a) - torch.as_tensor(b)).abs().max())

class TinyLM:
    def __init__(self, V=4, L=4, seed=0, scale=1.0):
        self.V, self.L = V, L
        self.EOS = V - 1
        g = torch.Generator().manual_seed(seed)
        self.n_tok = (V + 1) * V          # rows: prev in {0..V-1, BOS}
        self.n_pos = L * V
        self.theta0 = scale * torch.randn(self.n_tok + self.n_pos, generator=g)
        self.seqs, self.tokens, self.mask = self._enumerate()
        self.N = len(self.seqs)
        self.lengths = self.mask.sum(1)

    # ---- enumeration of the whole sequence space --------------------------------
    def _enumerate(self):
        V, L, EOS = self.V, self.L, self.EOS
        seqs = []
        def rec(prefix):
            if len(prefix) == L or (prefix and prefix[-1] == EOS):
                seqs.append(tuple(prefix)); return
            for a in range(V):
                rec(prefix + [a])
        rec([])
        tokens = torch.full((len(seqs), L), EOS, dtype=torch.long)
        mask = torch.zeros(len(seqs), L)
        for i, s in enumerate(seqs):
            tokens[i, :len(s)] = torch.tensor(s)
            mask[i, :len(s)] = 1.0
        return seqs, tokens, mask

    def _logits_tables(self, theta):
        V, L = self.V, self.L
        tok = theta[:self.n_tok].reshape(V + 1, V)
        pos = theta[self.n_tok:].reshape(L, V)
        return tok, pos

    def token_logprobs(self, theta):
        """[N, L] log pi(o_t | o_<t) for every enumerated sequence (0 where masked)."""
        V, L = self.V, self.L
        tok, pos = self._logits_tables(theta)
        prev = torch.cat([torch.full((self.N, 1), V, dtype=torch.long), self.tokens[:, :-1]], 1)  # BOS = V
        logits = tok[prev] + pos[None, :, :]                       # [N, L, V]
        lp = torch.log_softmax(logits, -1)
        lp = lp.gather(-1, self.tokens[..., None]).squeeze(-1)
        return lp * self.mask

    def seq_logprob(self, theta):
        return self.token_logprobs(theta).sum(1)

    def probs(self, theta):
        return self.seq_logprob(theta).exp()

    # ---- naive, loop-based reference (used to cross-check the vectorised code) --
    def seq_logprob_naive(self, theta, seq):
        V = self.V
        tok, pos = self._logits_tables(theta)
        lp, prev = 0.0, V
        for t, a in enumerate(seq):
            logits = tok[prev] + pos[t]
            z = logits - logits.max()
            lp = lp + (z[a] - torch.log(torch.exp(z).sum()))
            prev = a
        return lp

    # ---- exact state machinery for value functions ------------------------------
    def prefixes(self):
        """All non-terminal prefixes (the MDP states), shortest first."""
        out = [()]
        for k in range(1, self.L):
            out += [p for p in itertools.product(range(self.V - 1), repeat=k)]  # non-EOS tokens only
        return out

    def cond_probs(self, theta, prefix):
        V = self.V
        tok, pos = self._logits_tables(theta)
        prev = V if len(prefix) == 0 else prefix[-1]
        return torch.softmax(tok[prev] + pos[len(prefix)], -1)

def default_reward(lm):
    """Binary verifier-style reward: success iff the sequence ends in EOS and its token sum is odd and length>=2."""
    R = torch.zeros(lm.N)
    for i, s in enumerate(lm.seqs):
        if s[-1] == lm.EOS and len(s) >= 2 and (sum(s[:-1]) % 2 == 1):
            R[i] = 1.0
    return R

def flat_grad(scalar, theta):
    g, = torch.autograd.grad(scalar, theta, retain_graph=True, allow_unused=False)
    return g

def score_matrix(lm, theta):
    """S[o] = grad_theta log pi(o)  as an [N, P] matrix (autograd, one backward per sequence)."""
    th = theta.clone().requires_grad_(True)
    lp = lm.seq_logprob(th)
    rows = [torch.autograd.grad(lp[i], th, retain_graph=True)[0] for i in range(lm.N)]
    return torch.stack(rows)

def token_score_tensor(lm, theta):
    """T[o, t] = grad_theta log pi(o_t | o_<t) as [N, L, P]."""
    th = theta.clone().requires_grad_(True)
    lp = lm.token_logprobs(th)
    P = theta.numel()
    out = torch.zeros(lm.N, lm.L, P)
    for i in range(lm.N):
        for t in range(int(lm.lengths[i])):
            out[i, t] = torch.autograd.grad(lp[i, t], th, retain_graph=True)[0]
    return out

def perturb(theta, delta, seed=1):
    g = torch.Generator().manual_seed(seed)
    d = torch.randn(theta.numel(), generator=g)
    return theta + delta * d / d.norm()