modmul router v0: t12 + composed-t3b members (pre-t3g)
Browse files- composed_encoding.py +193 -0
- coppola_pretrain_tiny.py +0 -0
- coppola_pretraining.py +613 -0
- divcot2_encoding.py +143 -0
- encoding.py +97 -0
- kvgen.py +172 -0
- limbs.py +66 -0
- manifest.json +7 -0
- model.py +208 -0
- train_arith_bp_supervised.py +633 -0
- weights_r0.pt +3 -0
- weights_r1.pt +3 -0
composed_encoding.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Composed multiply+reduce CoT — the DEPLOYABLE surface.
|
| 2 |
+
|
| 3 |
+
divcot validates the reduction in isolation by feeding N=x*y as input; a real
|
| 4 |
+
submission only gets (x, y, p) with x,y < p. This encoding chains the two
|
| 5 |
+
validated scaffolds in one generated trace:
|
| 6 |
+
|
| 7 |
+
1. schoolbook multiply as a running accumulator: after consuming each y-limb
|
| 8 |
+
y_j the model emits acc_j = acc_{j-1} + x*y_j*B^j (2W limbs, LSB-first so
|
| 9 |
+
the product+add carry chain is local). acc_{W-1} = N.
|
| 10 |
+
2. copy-reverse: re-emit N MSB-first (attention reversal — staging the
|
| 11 |
+
dividend so the division block looks exactly like validated divcot).
|
| 12 |
+
3. long division by p: quotient limbs MSB-first with running-remainder
|
| 13 |
+
install targets, then the answer (= final remainder) LSB-first.
|
| 14 |
+
|
| 15 |
+
Surface:
|
| 16 |
+
"A" x(W,LSB) "B" y(W,LSB) "M" p(W,MSB) "="
|
| 17 |
+
[ "a" acc_j(2W,LSB) ] * W
|
| 18 |
+
"N" N(2W,MSB) Q(2W) "R" ans(W,LSB) "\n"
|
| 19 |
+
|
| 20 |
+
Tokens/example = 2W^2 + 9W + 7 (W=5: 102; base-100 tier 5 W=10: 297).
|
| 21 |
+
|
| 22 |
+
Install targets (NTP-aligned):
|
| 23 |
+
acarry carry into each emitted acc limb (the multiply-accumulate state)
|
| 24 |
+
rem{j} MSB limb j of the running remainder at each quotient position
|
| 25 |
+
qhat leading-limbs quotient estimate at each quotient position
|
| 26 |
+
ans limb k of (x*y) mod p at each answer position
|
| 27 |
+
|
| 28 |
+
Compliance: identical status to divcot/karatsuba — fixed trace SHAPE, every
|
| 29 |
+
value generated by trained weights; randomising weights collapses accuracy.
|
| 30 |
+
"""
|
| 31 |
+
from __future__ import annotations
|
| 32 |
+
|
| 33 |
+
from limbs import limb_char, limb_str, parse_limbs, to_limbs
|
| 34 |
+
from divcot2_encoding import long_division, qhat_estimate
|
| 35 |
+
|
| 36 |
+
AMARK, BMARK, DIVMARK, EQ, ACC, NMARK, REVMARK, NL = "A", "B", "M", "=", "a", "N", "R", "\n"
|
| 37 |
+
PLSB = "m"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def prompt_str(x: int, y: int, p: int, W: int, base: int, subpad: bool = False) -> str:
|
| 41 |
+
"""subpad adds p LSB-first to the prompt: the MSB field serves quotient
|
| 42 |
+
estimation, the LSB field aligns with the LSB-emitted qd*p / remainder
|
| 43 |
+
chains (per-step diagnostic: subtraction digits need REVERSED access into
|
| 44 |
+
the MSB-only field — the aligned acc stage learned, the division didn't)."""
|
| 45 |
+
s = (AMARK + limb_str(x, W, base) + BMARK + limb_str(y, W, base)
|
| 46 |
+
+ DIVMARK + limb_str(p, W, base, msb_first=True))
|
| 47 |
+
if subpad:
|
| 48 |
+
s += PLSB + limb_str(p, W, base)
|
| 49 |
+
return s + EQ
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def gen_len(W: int, scratch: bool = False, cursor: bool = False,
|
| 53 |
+
subpad: bool = False) -> int:
|
| 54 |
+
"""Tokens generated after '=': W acc blocks, N restage, quotient, answer.
|
| 55 |
+
scratch=True: each quotient limb is followed by the running remainder
|
| 56 |
+
(W limbs, LSB-first) — externalizes the division state, which the model
|
| 57 |
+
cannot hold internally past W~3 (composed tier-3 diagnostic: first
|
| 58 |
+
quotient digit right, then collapse).
|
| 59 |
+
cursor=True (requires scratch): each step block starts with the consumed
|
| 60 |
+
dividend limb, copied from the restaged N — an explicit progress anchor.
|
| 61 |
+
Diagnostic at scratch step-4k: mid-sequence remainder drift + the model
|
| 62 |
+
losing count of steps (9 blocks emitted instead of 2W); copying gives the
|
| 63 |
+
same position mechanism that put restage at 1.00.
|
| 64 |
+
subpad=True (requires cursor): each step also emits qd*p (W+1 limbs, LSB)
|
| 65 |
+
before the remainder — externalizes the multiply-subtract that the
|
| 66 |
+
per-step diagnostic showed failing (steps 0-4 = copies, perfect; real
|
| 67 |
+
division steps 5+ at 0.04-0.15 even teacher-forced). Tier-2's compact
|
| 68 |
+
success could memorize qd*p over 48 primes; tier 3's 400 primes need the
|
| 69 |
+
generic circuit, so generate it like the (perfectly learned) acc rows."""
|
| 70 |
+
per_step = (1 if cursor else 0) + 1 + ((W + 1) if subpad else 0) + (W if scratch else 0)
|
| 71 |
+
return W * (1 + 2 * W) + (1 + 2 * W) + 2 * W * per_step + 1 + W
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def build_example(x: int, y: int, p: int, W: int, base: int, scratch: bool = False,
|
| 75 |
+
cursor: bool = False, subpad: bool = False):
|
| 76 |
+
"""Return (text, ann). x,y < p < base**W."""
|
| 77 |
+
assert not cursor or scratch, "cursor requires scratch"
|
| 78 |
+
assert not subpad or cursor, "subpad requires cursor"
|
| 79 |
+
N = x * y
|
| 80 |
+
y_limbs = to_limbs(y, W, base)
|
| 81 |
+
x_limbs = to_limbs(x, W, base)
|
| 82 |
+
parts, ann_g = [], [] # ann_g: (gen_index, var, val)
|
| 83 |
+
pos = 0
|
| 84 |
+
|
| 85 |
+
acc = 0
|
| 86 |
+
for j, yj in enumerate(y_limbs): # multiply-accumulate rows
|
| 87 |
+
prev = acc
|
| 88 |
+
acc = prev + x * yj * base ** j
|
| 89 |
+
parts.append(ACC)
|
| 90 |
+
pos += 1
|
| 91 |
+
prev_l = to_limbs(prev, 2 * W, base)
|
| 92 |
+
c = 0
|
| 93 |
+
for i in range(2 * W): # carry into limb i of acc_j
|
| 94 |
+
ann_g.append((pos + i, "acarry", c))
|
| 95 |
+
xi = x_limbs[i - j] if 0 <= i - j < W else 0
|
| 96 |
+
c = (prev_l[i] + xi * yj + c) // base
|
| 97 |
+
parts.append(limb_str(acc, 2 * W, base))
|
| 98 |
+
pos += 2 * W
|
| 99 |
+
assert acc == N
|
| 100 |
+
|
| 101 |
+
parts.append(NMARK + limb_str(N, 2 * W, base, msb_first=True)) # restage MSB
|
| 102 |
+
pos += 1 + 2 * W
|
| 103 |
+
|
| 104 |
+
q_limbs, rems, answer = long_division(N, p, W, base)
|
| 105 |
+
n_msb = to_limbs(N, 2 * W, base, msb_first=True)
|
| 106 |
+
r_prev = 0
|
| 107 |
+
for i, (qd, r) in enumerate(zip(q_limbs, rems)):
|
| 108 |
+
if cursor: # copy the consumed dividend limb
|
| 109 |
+
parts.append(limb_char(n_msb[i]))
|
| 110 |
+
pos += 1
|
| 111 |
+
ann_g.append((pos, "qhat", qhat_estimate(r_prev, n_msb[i], p, base)))
|
| 112 |
+
if not scratch: # compact: remainder is internal state
|
| 113 |
+
rstr = to_limbs(r, W, base, msb_first=True)
|
| 114 |
+
for j in range(W):
|
| 115 |
+
ann_g.append((pos, f"rem{j}", rstr[j]))
|
| 116 |
+
parts.append(limb_char(qd))
|
| 117 |
+
pos += 1
|
| 118 |
+
if subpad: # emit qd*p LSB-first (generic mult)
|
| 119 |
+
qdp = qd * p
|
| 120 |
+
p_l = to_limbs(p, W, base) + [0]
|
| 121 |
+
c = 0
|
| 122 |
+
for k in range(W + 1): # carry into limb k of qd*p
|
| 123 |
+
ann_g.append((pos + k, "spcarry", c))
|
| 124 |
+
c = (qd * p_l[k] + c) // base
|
| 125 |
+
parts.append(limb_str(qdp, W + 1, base))
|
| 126 |
+
pos += W + 1
|
| 127 |
+
if scratch: # emit remainder LSB-first (local borrow)
|
| 128 |
+
if subpad: # borrow chain of num - qd*p
|
| 129 |
+
num_l = [n_msb[i]] + to_limbs(r_prev, W, base)
|
| 130 |
+
qdp_l = to_limbs(qd * p, W + 1, base)
|
| 131 |
+
b = 0
|
| 132 |
+
for k in range(W):
|
| 133 |
+
ann_g.append((pos + k, "sborrow", b))
|
| 134 |
+
b = 1 if num_l[k] - qdp_l[k] - b < 0 else 0
|
| 135 |
+
parts.append(limb_str(r, W, base))
|
| 136 |
+
pos += W
|
| 137 |
+
r_prev = r
|
| 138 |
+
parts.append(REVMARK)
|
| 139 |
+
pos += 1
|
| 140 |
+
ans_limbs = to_limbs(answer, W, base)
|
| 141 |
+
for k in range(W):
|
| 142 |
+
ann_g.append((pos + k, "ans", ans_limbs[k]))
|
| 143 |
+
parts.append(limb_str(answer, W, base) + NL)
|
| 144 |
+
pos += W + 1
|
| 145 |
+
|
| 146 |
+
prompt = prompt_str(x, y, p, W, base, subpad)
|
| 147 |
+
text = prompt + "".join(parts)
|
| 148 |
+
assert len(text) == len(prompt) + gen_len(W, scratch, cursor, subpad) + 1, \
|
| 149 |
+
(len(text), len(prompt), gen_len(W, scratch, cursor, subpad))
|
| 150 |
+
ann = [dict() for _ in range(len(text))]
|
| 151 |
+
base_i = len(prompt)
|
| 152 |
+
for gi, var, val in ann_g:
|
| 153 |
+
ann[base_i + gi - 1][var] = int(val) # NTP alignment
|
| 154 |
+
return text, ann
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def var_specs(W: int, base: int, scratch: bool = False, subpad: bool = False):
|
| 158 |
+
specs = [("ans", base), ("qhat", base), ("acarry", base)]
|
| 159 |
+
if subpad: # multiply-carry + subtract-borrow chains
|
| 160 |
+
specs += [("spcarry", base), ("sborrow", 2)]
|
| 161 |
+
if not scratch: # scratch emits remainders: no probe
|
| 162 |
+
specs += [(f"rem{j}", base) for j in range(W)]
|
| 163 |
+
return specs
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def decode_answer(gen_chars: str, W: int, base: int, scratch: bool = False,
|
| 167 |
+
cursor: bool = False, subpad: bool = False) -> int:
|
| 168 |
+
off = gen_len(W, scratch, cursor, subpad) - W # answer = last W generated limbs
|
| 169 |
+
return parse_limbs(gen_chars[off:off + W], base)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
if __name__ == "__main__":
|
| 173 |
+
import random
|
| 174 |
+
rng = random.Random(3)
|
| 175 |
+
for base in (10, 100):
|
| 176 |
+
for scratch, cursor, subpad in ((False, False, False), (True, False, False),
|
| 177 |
+
(True, True, False), (True, True, True)):
|
| 178 |
+
for _ in range(4000):
|
| 179 |
+
W = rng.randint(1, 6)
|
| 180 |
+
p = rng.randrange(max(2, base ** (W - 1)), base ** W)
|
| 181 |
+
x, y = rng.randrange(p), rng.randrange(p)
|
| 182 |
+
text, ann = build_example(x, y, p, W, base, scratch, cursor, subpad)
|
| 183 |
+
plen = len(prompt_str(x, y, p, W, base, subpad))
|
| 184 |
+
assert decode_answer(text[plen:], W, base, scratch, cursor, subpad) \
|
| 185 |
+
== (x * y) % p, (x, y, p, W, base, scratch, cursor, subpad)
|
| 186 |
+
assert len(ann) == len(text)
|
| 187 |
+
print(f"composed base={base} scratch={scratch} cursor={cursor} "
|
| 188 |
+
f"subpad={subpad}: 4000/4000 decode OK")
|
| 189 |
+
print("\ntokens/example (prompt+gen+NL):")
|
| 190 |
+
for tier, Wd in [(3, 5), (4, 10), (5, 20), (6, 39), (7, 78)]:
|
| 191 |
+
for base, W in ((10, Wd), (100, (Wd + 1) // 2)):
|
| 192 |
+
print(f" tier {tier} base {base:>3} W={W:>3} compact {3 * W + 4 + gen_len(W) + 1:>6}"
|
| 193 |
+
f" scratch {3 * W + 4 + gen_len(W, True) + 1:>6}")
|
coppola_pretrain_tiny.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
coppola_pretraining.py
ADDED
|
@@ -0,0 +1,613 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Utilities for full Coppola-typed pretraining in nanochat-style trainers.
|
| 3 |
+
|
| 4 |
+
This module is intentionally trainer-agnostic. It provides:
|
| 5 |
+
|
| 6 |
+
- basis extraction from current attention-output and MLP down-project weights
|
| 7 |
+
- typed gradient decomposition and projection
|
| 8 |
+
- a depth-aware scale policy
|
| 9 |
+
- a small controller that can refresh bases from a nanochat-style model
|
| 10 |
+
|
| 11 |
+
The intended integration is:
|
| 12 |
+
|
| 13 |
+
1. instantiate a controller from the current model weights
|
| 14 |
+
2. before each matrix optimizer step, project the raw gradient by family
|
| 15 |
+
3. apply Muon / Newton-Schulz or another matrix optimizer
|
| 16 |
+
4. re-project the transformed gradient before the final step
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
from dataclasses import dataclass, field
|
| 22 |
+
from typing import Callable, Dict, List, Optional, Sequence, Tuple
|
| 23 |
+
|
| 24 |
+
import torch
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
Tensor = torch.Tensor
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass(frozen=True)
|
| 31 |
+
class RankPolicy:
|
| 32 |
+
"""Energy-based rank truncation policy."""
|
| 33 |
+
|
| 34 |
+
energy: float = 0.99
|
| 35 |
+
max_rank: Optional[int] = None
|
| 36 |
+
min_rank: int = 1
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@dataclass(frozen=True)
|
| 40 |
+
class FamilyScales:
|
| 41 |
+
"""Typed gradient multipliers for a layer."""
|
| 42 |
+
|
| 43 |
+
routing: float = 1.0
|
| 44 |
+
and_transport: float = 1.0
|
| 45 |
+
or_update: float = 1.0
|
| 46 |
+
remainder: float = 1.0
|
| 47 |
+
coupling: Optional[float] = None
|
| 48 |
+
|
| 49 |
+
def coupling_scale(self) -> float:
|
| 50 |
+
if self.coupling is not None:
|
| 51 |
+
return self.coupling
|
| 52 |
+
return 0.5 * (self.and_transport + self.or_update)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@dataclass
|
| 56 |
+
class LayerBases:
|
| 57 |
+
"""Typed bases for one transformer layer."""
|
| 58 |
+
|
| 59 |
+
routing_head_bases: List[Tensor] = field(default_factory=list)
|
| 60 |
+
q_head_bases: List[Tensor] = field(default_factory=list)
|
| 61 |
+
k_head_bases: List[Tensor] = field(default_factory=list)
|
| 62 |
+
and_output_basis: Optional[Tensor] = None
|
| 63 |
+
or_input_basis: Optional[Tensor] = None
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@dataclass
|
| 67 |
+
class ComponentNorms:
|
| 68 |
+
"""Norms of the typed gradient components before scaling."""
|
| 69 |
+
|
| 70 |
+
routing: float = 0.0
|
| 71 |
+
and_transport: float = 0.0
|
| 72 |
+
or_update: float = 0.0
|
| 73 |
+
coupling: float = 0.0
|
| 74 |
+
remainder: float = 0.0
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _select_rank(singular_values: Tensor, policy: RankPolicy) -> int:
|
| 78 |
+
if singular_values.numel() == 0:
|
| 79 |
+
return policy.min_rank
|
| 80 |
+
total = float(singular_values.square().sum().item())
|
| 81 |
+
if total <= 0.0:
|
| 82 |
+
return policy.min_rank
|
| 83 |
+
cumulative = singular_values.square().cumsum(0) / total
|
| 84 |
+
rank = int(torch.searchsorted(cumulative, torch.tensor(policy.energy, device=cumulative.device)).item()) + 1
|
| 85 |
+
rank = max(policy.min_rank, rank)
|
| 86 |
+
if policy.max_rank is not None:
|
| 87 |
+
rank = min(rank, policy.max_rank)
|
| 88 |
+
return min(rank, singular_values.numel())
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _orthonormal_rows(basis: Tensor) -> Tensor:
|
| 92 |
+
"""Return an orthonormal row basis spanning the same row space."""
|
| 93 |
+
if basis.ndim != 2:
|
| 94 |
+
raise ValueError(f"basis must be rank-2, got {tuple(basis.shape)}")
|
| 95 |
+
if basis.shape[0] == 0:
|
| 96 |
+
return basis
|
| 97 |
+
q, _ = torch.linalg.qr(basis.T, mode="reduced")
|
| 98 |
+
return q.T.contiguous()
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _project_left(grad: Tensor, basis: Tensor) -> Tensor:
|
| 102 |
+
basis = basis.to(device=grad.device, dtype=grad.dtype)
|
| 103 |
+
proj = basis.T @ basis
|
| 104 |
+
return proj @ grad
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _project_right(grad: Tensor, basis: Tensor) -> Tensor:
|
| 108 |
+
basis = basis.to(device=grad.device, dtype=grad.dtype)
|
| 109 |
+
proj = basis.T @ basis
|
| 110 |
+
return grad @ proj
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _zero_like(grad: Tensor) -> Tensor:
|
| 114 |
+
return torch.zeros_like(grad)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def compute_attention_output_bases(
|
| 118 |
+
weight: Tensor,
|
| 119 |
+
n_head: int,
|
| 120 |
+
policy: RankPolicy = RankPolicy(),
|
| 121 |
+
) -> List[Tensor]:
|
| 122 |
+
"""Build one output-space basis per attention head block.
|
| 123 |
+
|
| 124 |
+
Args:
|
| 125 |
+
weight: output projection weight of shape [hidden, hidden]
|
| 126 |
+
n_head: number of attention heads
|
| 127 |
+
policy: rank selection policy inside each head block
|
| 128 |
+
"""
|
| 129 |
+
if weight.ndim != 2:
|
| 130 |
+
raise ValueError(f"attention output weight must be rank-2, got {tuple(weight.shape)}")
|
| 131 |
+
hidden, width = weight.shape
|
| 132 |
+
if width % n_head != 0:
|
| 133 |
+
raise ValueError(f"weight width {width} not divisible by n_head={n_head}")
|
| 134 |
+
head_dim = width // n_head
|
| 135 |
+
bases: List[Tensor] = []
|
| 136 |
+
with torch.no_grad():
|
| 137 |
+
w = weight.detach().float().cpu()
|
| 138 |
+
for h in range(n_head):
|
| 139 |
+
cols = w[:, h * head_dim:(h + 1) * head_dim]
|
| 140 |
+
u, s, _ = torch.linalg.svd(cols, full_matrices=False)
|
| 141 |
+
rank = _select_rank(s, policy)
|
| 142 |
+
basis = u[:, :rank].T.contiguous()
|
| 143 |
+
bases.append(_orthonormal_rows(basis))
|
| 144 |
+
return bases
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def compute_attention_score_bases(
|
| 148 |
+
weight: Tensor,
|
| 149 |
+
n_head: int,
|
| 150 |
+
policy: RankPolicy = RankPolicy(),
|
| 151 |
+
) -> List[Tensor]:
|
| 152 |
+
"""Build one row-space basis per attention head for Q/K score-side blocks."""
|
| 153 |
+
if weight.ndim != 2:
|
| 154 |
+
raise ValueError(f"attention score weight must be rank-2, got {tuple(weight.shape)}")
|
| 155 |
+
width, hidden = weight.shape
|
| 156 |
+
if width % n_head != 0:
|
| 157 |
+
raise ValueError(f"weight height {width} not divisible by n_head={n_head}")
|
| 158 |
+
head_dim = width // n_head
|
| 159 |
+
bases: List[Tensor] = []
|
| 160 |
+
with torch.no_grad():
|
| 161 |
+
w = weight.detach().float().cpu()
|
| 162 |
+
for h in range(n_head):
|
| 163 |
+
rows = w[h * head_dim:(h + 1) * head_dim, :]
|
| 164 |
+
u, s, _ = torch.linalg.svd(rows, full_matrices=False)
|
| 165 |
+
rank = _select_rank(s, policy)
|
| 166 |
+
basis = u[:, :rank].T.contiguous()
|
| 167 |
+
bases.append(_orthonormal_rows(basis))
|
| 168 |
+
return bases
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def compute_down_proj_bases(
|
| 172 |
+
weight: Tensor,
|
| 173 |
+
output_policy: RankPolicy = RankPolicy(),
|
| 174 |
+
input_policy: RankPolicy = RankPolicy(),
|
| 175 |
+
) -> Tuple[Tensor, Tensor]:
|
| 176 |
+
"""Build output-side AND basis and input-side OR basis for down_proj."""
|
| 177 |
+
if weight.ndim != 2:
|
| 178 |
+
raise ValueError(f"down projection weight must be rank-2, got {tuple(weight.shape)}")
|
| 179 |
+
with torch.no_grad():
|
| 180 |
+
w = weight.detach().float().cpu()
|
| 181 |
+
u, s, vh = torch.linalg.svd(w, full_matrices=False)
|
| 182 |
+
out_rank = _select_rank(s, output_policy)
|
| 183 |
+
in_rank = _select_rank(s, input_policy)
|
| 184 |
+
and_basis = _orthonormal_rows(u[:, :out_rank].T.contiguous())
|
| 185 |
+
or_basis = _orthonormal_rows(vh[:in_rank, :].contiguous())
|
| 186 |
+
return and_basis, or_basis
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def decompose_down_proj_gradient(
|
| 190 |
+
grad: Tensor,
|
| 191 |
+
and_output_basis: Optional[Tensor],
|
| 192 |
+
or_input_basis: Optional[Tensor],
|
| 193 |
+
) -> Dict[str, Tensor]:
|
| 194 |
+
"""Decompose an MLP down_proj gradient into typed components.
|
| 195 |
+
|
| 196 |
+
The components are:
|
| 197 |
+
- and_transport_only
|
| 198 |
+
- or_update_only
|
| 199 |
+
- coupling: in both the output and input typed subspaces
|
| 200 |
+
- remainder
|
| 201 |
+
"""
|
| 202 |
+
if grad.ndim != 2:
|
| 203 |
+
raise ValueError(f"gradient must be rank-2, got {tuple(grad.shape)}")
|
| 204 |
+
|
| 205 |
+
g_and = _zero_like(grad)
|
| 206 |
+
g_or = _zero_like(grad)
|
| 207 |
+
g_both = _zero_like(grad)
|
| 208 |
+
|
| 209 |
+
if and_output_basis is not None:
|
| 210 |
+
g_and = _project_left(grad, and_output_basis)
|
| 211 |
+
if or_input_basis is not None:
|
| 212 |
+
g_or = _project_right(grad, or_input_basis)
|
| 213 |
+
if and_output_basis is not None and or_input_basis is not None:
|
| 214 |
+
g_both = _project_right(g_and, or_input_basis)
|
| 215 |
+
|
| 216 |
+
g_and_only = g_and - g_both
|
| 217 |
+
g_or_only = g_or - g_both
|
| 218 |
+
g_remainder = grad - g_and_only - g_or_only - g_both
|
| 219 |
+
|
| 220 |
+
return {
|
| 221 |
+
"and_transport_only": g_and_only,
|
| 222 |
+
"or_update_only": g_or_only,
|
| 223 |
+
"coupling": g_both,
|
| 224 |
+
"remainder": g_remainder,
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def project_down_proj_gradient(
|
| 229 |
+
grad: Tensor,
|
| 230 |
+
and_output_basis: Optional[Tensor],
|
| 231 |
+
or_input_basis: Optional[Tensor],
|
| 232 |
+
scales: FamilyScales,
|
| 233 |
+
) -> Tensor:
|
| 234 |
+
parts = decompose_down_proj_gradient(grad, and_output_basis, or_input_basis)
|
| 235 |
+
return (
|
| 236 |
+
scales.and_transport * parts["and_transport_only"]
|
| 237 |
+
+ scales.or_update * parts["or_update_only"]
|
| 238 |
+
+ scales.coupling_scale() * parts["coupling"]
|
| 239 |
+
+ scales.remainder * parts["remainder"]
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def support_project_down_proj_gradient(
|
| 244 |
+
grad: Tensor,
|
| 245 |
+
and_output_basis: Optional[Tensor],
|
| 246 |
+
or_input_basis: Optional[Tensor],
|
| 247 |
+
) -> Tensor:
|
| 248 |
+
"""Project a down-proj update onto typed Coppola support, dropping remainder."""
|
| 249 |
+
parts = decompose_down_proj_gradient(grad, and_output_basis, or_input_basis)
|
| 250 |
+
return parts["and_transport_only"] + parts["or_update_only"] + parts["coupling"]
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def down_proj_component_norms(
|
| 254 |
+
grad: Tensor,
|
| 255 |
+
and_output_basis: Optional[Tensor],
|
| 256 |
+
or_input_basis: Optional[Tensor],
|
| 257 |
+
) -> ComponentNorms:
|
| 258 |
+
parts = decompose_down_proj_gradient(grad, and_output_basis, or_input_basis)
|
| 259 |
+
return ComponentNorms(
|
| 260 |
+
and_transport=float(parts["and_transport_only"].norm().item()),
|
| 261 |
+
or_update=float(parts["or_update_only"].norm().item()),
|
| 262 |
+
coupling=float(parts["coupling"].norm().item()),
|
| 263 |
+
remainder=float(parts["remainder"].norm().item()),
|
| 264 |
+
)
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def project_attention_output_gradient(
|
| 268 |
+
grad: Tensor,
|
| 269 |
+
routing_head_bases: Sequence[Tensor],
|
| 270 |
+
scales: FamilyScales,
|
| 271 |
+
) -> Tensor:
|
| 272 |
+
"""Project an attention output gradient head-by-head on the left."""
|
| 273 |
+
if grad.ndim != 2:
|
| 274 |
+
raise ValueError(f"gradient must be rank-2, got {tuple(grad.shape)}")
|
| 275 |
+
if not routing_head_bases:
|
| 276 |
+
return grad
|
| 277 |
+
hidden, width = grad.shape
|
| 278 |
+
if width % len(routing_head_bases) != 0:
|
| 279 |
+
raise ValueError(f"gradient width {width} incompatible with {len(routing_head_bases)} head bases")
|
| 280 |
+
head_dim = width // len(routing_head_bases)
|
| 281 |
+
out = torch.empty_like(grad)
|
| 282 |
+
for h, basis in enumerate(routing_head_bases):
|
| 283 |
+
cols = grad[:, h * head_dim:(h + 1) * head_dim]
|
| 284 |
+
routed = _project_left(cols, basis)
|
| 285 |
+
remainder = cols - routed
|
| 286 |
+
out[:, h * head_dim:(h + 1) * head_dim] = scales.routing * routed + scales.remainder * remainder
|
| 287 |
+
return out
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def project_attention_qk_gradient(
|
| 291 |
+
grad: Tensor,
|
| 292 |
+
q_head_bases: Sequence[Tensor],
|
| 293 |
+
k_head_bases: Sequence[Tensor],
|
| 294 |
+
scales: FamilyScales,
|
| 295 |
+
) -> Tensor:
|
| 296 |
+
"""Project fused Q/K/V gradient on Q and K row blocks only, leaving V unchanged."""
|
| 297 |
+
if grad.ndim != 2:
|
| 298 |
+
raise ValueError(f"gradient must be rank-2, got {tuple(grad.shape)}")
|
| 299 |
+
if not q_head_bases and not k_head_bases:
|
| 300 |
+
return grad
|
| 301 |
+
hidden3, width = grad.shape
|
| 302 |
+
if hidden3 % 3 != 0:
|
| 303 |
+
raise ValueError(f"expected fused qkv height divisible by 3, got {hidden3}")
|
| 304 |
+
hidden = hidden3 // 3
|
| 305 |
+
if q_head_bases and hidden % len(q_head_bases) != 0:
|
| 306 |
+
raise ValueError(f"q block height {hidden} incompatible with {len(q_head_bases)} q bases")
|
| 307 |
+
if k_head_bases and hidden % len(k_head_bases) != 0:
|
| 308 |
+
raise ValueError(f"k block height {hidden} incompatible with {len(k_head_bases)} k bases")
|
| 309 |
+
out = grad.clone()
|
| 310 |
+
if q_head_bases:
|
| 311 |
+
head_dim = hidden // len(q_head_bases)
|
| 312 |
+
for h, basis in enumerate(q_head_bases):
|
| 313 |
+
rows = grad[h * head_dim:(h + 1) * head_dim, :]
|
| 314 |
+
routed = _project_left(rows, basis)
|
| 315 |
+
remainder = rows - routed
|
| 316 |
+
out[h * head_dim:(h + 1) * head_dim, :] = scales.routing * routed + scales.remainder * remainder
|
| 317 |
+
if k_head_bases:
|
| 318 |
+
head_dim = hidden // len(k_head_bases)
|
| 319 |
+
offset = hidden
|
| 320 |
+
for h, basis in enumerate(k_head_bases):
|
| 321 |
+
start = offset + h * head_dim
|
| 322 |
+
stop = offset + (h + 1) * head_dim
|
| 323 |
+
rows = grad[start:stop, :]
|
| 324 |
+
routed = _project_left(rows, basis)
|
| 325 |
+
remainder = rows - routed
|
| 326 |
+
out[start:stop, :] = scales.routing * routed + scales.remainder * remainder
|
| 327 |
+
return out
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
def support_project_attention_output_gradient(
|
| 331 |
+
grad: Tensor,
|
| 332 |
+
routing_head_bases: Sequence[Tensor],
|
| 333 |
+
) -> Tensor:
|
| 334 |
+
"""Project an attention-output update onto routing support, dropping remainder."""
|
| 335 |
+
if grad.ndim != 2:
|
| 336 |
+
raise ValueError(f"gradient must be rank-2, got {tuple(grad.shape)}")
|
| 337 |
+
if not routing_head_bases:
|
| 338 |
+
return grad
|
| 339 |
+
hidden, width = grad.shape
|
| 340 |
+
if width % len(routing_head_bases) != 0:
|
| 341 |
+
raise ValueError(f"gradient width {width} incompatible with {len(routing_head_bases)} head bases")
|
| 342 |
+
head_dim = width // len(routing_head_bases)
|
| 343 |
+
out = torch.empty_like(grad)
|
| 344 |
+
for h, basis in enumerate(routing_head_bases):
|
| 345 |
+
cols = grad[:, h * head_dim:(h + 1) * head_dim]
|
| 346 |
+
out[:, h * head_dim:(h + 1) * head_dim] = _project_left(cols, basis)
|
| 347 |
+
return out
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
def support_project_attention_qk_gradient(
|
| 351 |
+
grad: Tensor,
|
| 352 |
+
q_head_bases: Sequence[Tensor],
|
| 353 |
+
k_head_bases: Sequence[Tensor],
|
| 354 |
+
) -> Tensor:
|
| 355 |
+
"""Project fused Q/K/V update on Q and K support only, leaving V unchanged."""
|
| 356 |
+
if grad.ndim != 2:
|
| 357 |
+
raise ValueError(f"gradient must be rank-2, got {tuple(grad.shape)}")
|
| 358 |
+
if not q_head_bases and not k_head_bases:
|
| 359 |
+
return grad
|
| 360 |
+
hidden3, width = grad.shape
|
| 361 |
+
if hidden3 % 3 != 0:
|
| 362 |
+
raise ValueError(f"expected fused qkv height divisible by 3, got {hidden3}")
|
| 363 |
+
hidden = hidden3 // 3
|
| 364 |
+
out = grad.clone()
|
| 365 |
+
if q_head_bases:
|
| 366 |
+
head_dim = hidden // len(q_head_bases)
|
| 367 |
+
for h, basis in enumerate(q_head_bases):
|
| 368 |
+
rows = grad[h * head_dim:(h + 1) * head_dim, :]
|
| 369 |
+
out[h * head_dim:(h + 1) * head_dim, :] = _project_left(rows, basis)
|
| 370 |
+
if k_head_bases:
|
| 371 |
+
head_dim = hidden // len(k_head_bases)
|
| 372 |
+
offset = hidden
|
| 373 |
+
for h, basis in enumerate(k_head_bases):
|
| 374 |
+
start = offset + h * head_dim
|
| 375 |
+
stop = offset + (h + 1) * head_dim
|
| 376 |
+
rows = grad[start:stop, :]
|
| 377 |
+
out[start:stop, :] = _project_left(rows, basis)
|
| 378 |
+
return out
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
def attention_component_norms(grad: Tensor, routing_head_bases: Sequence[Tensor]) -> ComponentNorms:
|
| 382 |
+
if not routing_head_bases:
|
| 383 |
+
return ComponentNorms(routing=float(grad.norm().item()))
|
| 384 |
+
hidden, width = grad.shape
|
| 385 |
+
head_dim = width // len(routing_head_bases)
|
| 386 |
+
routed_norm_sq = 0.0
|
| 387 |
+
remainder_norm_sq = 0.0
|
| 388 |
+
for h, basis in enumerate(routing_head_bases):
|
| 389 |
+
cols = grad[:, h * head_dim:(h + 1) * head_dim]
|
| 390 |
+
routed = _project_left(cols, basis)
|
| 391 |
+
remainder = cols - routed
|
| 392 |
+
routed_norm_sq += float(routed.square().sum().item())
|
| 393 |
+
remainder_norm_sq += float(remainder.square().sum().item())
|
| 394 |
+
return ComponentNorms(
|
| 395 |
+
routing=routed_norm_sq**0.5,
|
| 396 |
+
remainder=remainder_norm_sq**0.5,
|
| 397 |
+
)
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
def attention_qk_component_norms(
|
| 401 |
+
grad: Tensor,
|
| 402 |
+
q_head_bases: Sequence[Tensor],
|
| 403 |
+
k_head_bases: Sequence[Tensor],
|
| 404 |
+
) -> ComponentNorms:
|
| 405 |
+
if not q_head_bases and not k_head_bases:
|
| 406 |
+
return ComponentNorms(routing=float(grad.norm().item()))
|
| 407 |
+
hidden3, width = grad.shape
|
| 408 |
+
if hidden3 % 3 != 0:
|
| 409 |
+
raise ValueError(f"expected fused qkv height divisible by 3, got {hidden3}")
|
| 410 |
+
hidden = hidden3 // 3
|
| 411 |
+
routed_norm_sq = 0.0
|
| 412 |
+
remainder_norm_sq = 0.0
|
| 413 |
+
if q_head_bases:
|
| 414 |
+
head_dim = hidden // len(q_head_bases)
|
| 415 |
+
for h, basis in enumerate(q_head_bases):
|
| 416 |
+
rows = grad[h * head_dim:(h + 1) * head_dim, :]
|
| 417 |
+
routed = _project_left(rows, basis)
|
| 418 |
+
remainder = rows - routed
|
| 419 |
+
routed_norm_sq += float(routed.square().sum().item())
|
| 420 |
+
remainder_norm_sq += float(remainder.square().sum().item())
|
| 421 |
+
if k_head_bases:
|
| 422 |
+
head_dim = hidden // len(k_head_bases)
|
| 423 |
+
offset = hidden
|
| 424 |
+
for h, basis in enumerate(k_head_bases):
|
| 425 |
+
start = offset + h * head_dim
|
| 426 |
+
stop = offset + (h + 1) * head_dim
|
| 427 |
+
rows = grad[start:stop, :]
|
| 428 |
+
routed = _project_left(rows, basis)
|
| 429 |
+
remainder = rows - routed
|
| 430 |
+
routed_norm_sq += float(routed.square().sum().item())
|
| 431 |
+
remainder_norm_sq += float(remainder.square().sum().item())
|
| 432 |
+
return ComponentNorms(
|
| 433 |
+
routing=routed_norm_sq**0.5,
|
| 434 |
+
remainder=remainder_norm_sq**0.5,
|
| 435 |
+
)
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
def default_zone_scales(layer_idx: int, n_layer: int) -> FamilyScales:
|
| 439 |
+
"""Default full-Coppola depth policy for from-scratch pretraining."""
|
| 440 |
+
frac = layer_idx / max(n_layer - 1, 1)
|
| 441 |
+
if frac < 0.20:
|
| 442 |
+
return FamilyScales(routing=1.40, and_transport=1.10, or_update=0.70, remainder=0.0, coupling=0.90)
|
| 443 |
+
if frac < 0.65:
|
| 444 |
+
return FamilyScales(routing=0.90, and_transport=1.40, or_update=0.90, remainder=0.0, coupling=1.15)
|
| 445 |
+
if frac < 0.90:
|
| 446 |
+
return FamilyScales(routing=0.75, and_transport=0.95, or_update=1.40, remainder=0.0, coupling=1.15)
|
| 447 |
+
return FamilyScales(routing=0.60, and_transport=0.85, or_update=1.15, remainder=0.0, coupling=1.00)
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
@dataclass
|
| 451 |
+
class CoppolaPretrainingConfig:
|
| 452 |
+
n_head: int
|
| 453 |
+
attn_output_policy: RankPolicy = field(default_factory=RankPolicy)
|
| 454 |
+
attn_qk_policy: RankPolicy = field(default_factory=RankPolicy)
|
| 455 |
+
mlp_output_policy: RankPolicy = field(default_factory=RankPolicy)
|
| 456 |
+
mlp_input_policy: RankPolicy = field(default_factory=RankPolicy)
|
| 457 |
+
basis_update_interval: int = 250
|
| 458 |
+
uniform_scales: FamilyScales = field(default_factory=FamilyScales)
|
| 459 |
+
scale_fn: Callable[[int, int], FamilyScales] = default_zone_scales
|
| 460 |
+
|
| 461 |
+
|
| 462 |
+
class CoppolaPretrainingController:
|
| 463 |
+
"""Refreshes typed bases and projects gradients for nanochat-style models."""
|
| 464 |
+
|
| 465 |
+
def __init__(self, config: CoppolaPretrainingConfig):
|
| 466 |
+
self.config = config
|
| 467 |
+
self.layer_bases: Dict[int, LayerBases] = {}
|
| 468 |
+
|
| 469 |
+
def refresh_from_model(self, model) -> None:
|
| 470 |
+
layers = self._resolve_layers(model)
|
| 471 |
+
n_layer = len(layers)
|
| 472 |
+
bases: Dict[int, LayerBases] = {}
|
| 473 |
+
for layer_idx, block in enumerate(layers):
|
| 474 |
+
attn_weight = self._resolve_attn_out_weight(block)
|
| 475 |
+
q_weight, k_weight = self._resolve_attn_qk_weights(block)
|
| 476 |
+
mlp_weight = self._resolve_mlp_down_weight(block)
|
| 477 |
+
routing = compute_attention_output_bases(
|
| 478 |
+
attn_weight, self.config.n_head, self.config.attn_output_policy
|
| 479 |
+
)
|
| 480 |
+
q_bases: List[Tensor] = []
|
| 481 |
+
k_bases: List[Tensor] = []
|
| 482 |
+
if q_weight is not None and k_weight is not None:
|
| 483 |
+
q_bases = compute_attention_score_bases(
|
| 484 |
+
q_weight, self.config.n_head, self.config.attn_qk_policy
|
| 485 |
+
)
|
| 486 |
+
k_bases = compute_attention_score_bases(
|
| 487 |
+
k_weight, self.config.n_head, self.config.attn_qk_policy
|
| 488 |
+
)
|
| 489 |
+
and_basis, or_basis = compute_down_proj_bases(
|
| 490 |
+
mlp_weight,
|
| 491 |
+
output_policy=self.config.mlp_output_policy,
|
| 492 |
+
input_policy=self.config.mlp_input_policy,
|
| 493 |
+
)
|
| 494 |
+
bases[layer_idx] = LayerBases(
|
| 495 |
+
routing_head_bases=routing,
|
| 496 |
+
q_head_bases=q_bases,
|
| 497 |
+
k_head_bases=k_bases,
|
| 498 |
+
and_output_basis=and_basis,
|
| 499 |
+
or_input_basis=or_basis,
|
| 500 |
+
)
|
| 501 |
+
self.layer_bases = bases
|
| 502 |
+
self._n_layer = n_layer
|
| 503 |
+
|
| 504 |
+
def scales_for_layer(self, layer_idx: int, mode: str = "zoned") -> FamilyScales:
|
| 505 |
+
if mode == "uniform":
|
| 506 |
+
return self.config.uniform_scales
|
| 507 |
+
if not hasattr(self, "_n_layer"):
|
| 508 |
+
raise RuntimeError("refresh_from_model() must be called before requesting zoned scales")
|
| 509 |
+
return self.config.scale_fn(layer_idx, self._n_layer)
|
| 510 |
+
|
| 511 |
+
def project_attn_out_grad(self, layer_idx: int, grad: Tensor, mode: str = "zoned") -> Tensor:
|
| 512 |
+
bases = self.layer_bases[layer_idx]
|
| 513 |
+
scales = self.scales_for_layer(layer_idx, mode=mode)
|
| 514 |
+
return project_attention_output_gradient(grad, bases.routing_head_bases, scales)
|
| 515 |
+
|
| 516 |
+
def project_attn_qk_grad(self, layer_idx: int, grad: Tensor, mode: str = "zoned") -> Tensor:
|
| 517 |
+
bases = self.layer_bases[layer_idx]
|
| 518 |
+
scales = self.scales_for_layer(layer_idx, mode=mode)
|
| 519 |
+
return project_attention_qk_gradient(grad, bases.q_head_bases, bases.k_head_bases, scales)
|
| 520 |
+
|
| 521 |
+
def project_mlp_down_grad(self, layer_idx: int, grad: Tensor, mode: str = "zoned") -> Tensor:
|
| 522 |
+
bases = self.layer_bases[layer_idx]
|
| 523 |
+
scales = self.scales_for_layer(layer_idx, mode=mode)
|
| 524 |
+
return project_down_proj_gradient(grad, bases.and_output_basis, bases.or_input_basis, scales)
|
| 525 |
+
|
| 526 |
+
def project_attn_out_support(self, layer_idx: int, grad: Tensor) -> Tensor:
|
| 527 |
+
bases = self.layer_bases[layer_idx]
|
| 528 |
+
return support_project_attention_output_gradient(grad, bases.routing_head_bases)
|
| 529 |
+
|
| 530 |
+
def project_attn_qk_support(self, layer_idx: int, grad: Tensor) -> Tensor:
|
| 531 |
+
bases = self.layer_bases[layer_idx]
|
| 532 |
+
return support_project_attention_qk_gradient(grad, bases.q_head_bases, bases.k_head_bases)
|
| 533 |
+
|
| 534 |
+
def project_mlp_down_support(self, layer_idx: int, grad: Tensor) -> Tensor:
|
| 535 |
+
bases = self.layer_bases[layer_idx]
|
| 536 |
+
return support_project_down_proj_gradient(grad, bases.and_output_basis, bases.or_input_basis)
|
| 537 |
+
|
| 538 |
+
def project_after_matrix_transform(
|
| 539 |
+
self,
|
| 540 |
+
layer_idx: int,
|
| 541 |
+
grad: Tensor,
|
| 542 |
+
param_kind: str,
|
| 543 |
+
transform: Callable[[Tensor], Tensor],
|
| 544 |
+
mode: str = "zoned",
|
| 545 |
+
) -> Tensor:
|
| 546 |
+
"""Project -> transform -> re-project for Muon-style optimizers."""
|
| 547 |
+
g = self.project_gradient(layer_idx, grad, param_kind=param_kind, mode=mode)
|
| 548 |
+
g = transform(g)
|
| 549 |
+
return self.project_gradient(layer_idx, g, param_kind=param_kind, mode=mode)
|
| 550 |
+
|
| 551 |
+
def project_gradient(self, layer_idx: int, grad: Tensor, param_kind: str, mode: str = "zoned") -> Tensor:
|
| 552 |
+
if param_kind == "attn_out":
|
| 553 |
+
return self.project_attn_out_grad(layer_idx, grad, mode=mode)
|
| 554 |
+
if param_kind == "attn_qk":
|
| 555 |
+
return self.project_attn_qk_grad(layer_idx, grad, mode=mode)
|
| 556 |
+
if param_kind == "mlp_down":
|
| 557 |
+
return self.project_mlp_down_grad(layer_idx, grad, mode=mode)
|
| 558 |
+
raise ValueError(f"unknown param_kind={param_kind!r}")
|
| 559 |
+
|
| 560 |
+
def project_update_support(self, layer_idx: int, grad: Tensor, param_kind: str) -> Tensor:
|
| 561 |
+
if param_kind == "attn_out":
|
| 562 |
+
return self.project_attn_out_support(layer_idx, grad)
|
| 563 |
+
if param_kind == "attn_qk":
|
| 564 |
+
return self.project_attn_qk_support(layer_idx, grad)
|
| 565 |
+
if param_kind == "mlp_down":
|
| 566 |
+
return self.project_mlp_down_support(layer_idx, grad)
|
| 567 |
+
raise ValueError(f"unknown param_kind={param_kind!r}")
|
| 568 |
+
|
| 569 |
+
def gradient_component_norms(self, layer_idx: int, grad: Tensor, param_kind: str) -> ComponentNorms:
|
| 570 |
+
bases = self.layer_bases[layer_idx]
|
| 571 |
+
if param_kind == "attn_out":
|
| 572 |
+
return attention_component_norms(grad, bases.routing_head_bases)
|
| 573 |
+
if param_kind == "attn_qk":
|
| 574 |
+
return attention_qk_component_norms(grad, bases.q_head_bases, bases.k_head_bases)
|
| 575 |
+
if param_kind == "mlp_down":
|
| 576 |
+
return down_proj_component_norms(grad, bases.and_output_basis, bases.or_input_basis)
|
| 577 |
+
raise ValueError(f"unknown param_kind={param_kind!r}")
|
| 578 |
+
|
| 579 |
+
@staticmethod
|
| 580 |
+
def _resolve_layers(model):
|
| 581 |
+
if hasattr(model, "transformer") and hasattr(model.transformer, "h"):
|
| 582 |
+
return list(model.transformer.h)
|
| 583 |
+
if hasattr(model, "model") and hasattr(model.model, "layers"):
|
| 584 |
+
return list(model.model.layers)
|
| 585 |
+
raise ValueError("could not resolve transformer layers from model")
|
| 586 |
+
|
| 587 |
+
@staticmethod
|
| 588 |
+
def _resolve_attn_out_weight(block) -> Tensor:
|
| 589 |
+
if hasattr(block, "attn") and hasattr(block.attn, "c_proj"):
|
| 590 |
+
return block.attn.c_proj.weight
|
| 591 |
+
if hasattr(block, "self_attn") and hasattr(block.self_attn, "o_proj"):
|
| 592 |
+
return block.self_attn.o_proj.weight
|
| 593 |
+
raise ValueError("could not resolve attention output weight")
|
| 594 |
+
|
| 595 |
+
@staticmethod
|
| 596 |
+
def _resolve_attn_qk_weights(block) -> Tuple[Optional[Tensor], Optional[Tensor]]:
|
| 597 |
+
if hasattr(block, "attn") and hasattr(block.attn, "c_attn"):
|
| 598 |
+
weight = block.attn.c_attn.weight
|
| 599 |
+
if weight.shape[0] % 3 != 0:
|
| 600 |
+
raise ValueError(f"expected fused qkv height divisible by 3, got {weight.shape[0]}")
|
| 601 |
+
hidden = weight.shape[0] // 3
|
| 602 |
+
return weight[:hidden, :], weight[hidden:2 * hidden, :]
|
| 603 |
+
if hasattr(block, "self_attn") and hasattr(block.self_attn, "q_proj") and hasattr(block.self_attn, "k_proj"):
|
| 604 |
+
return block.self_attn.q_proj.weight, block.self_attn.k_proj.weight
|
| 605 |
+
return None, None
|
| 606 |
+
|
| 607 |
+
@staticmethod
|
| 608 |
+
def _resolve_mlp_down_weight(block) -> Tensor:
|
| 609 |
+
if hasattr(block, "mlp") and hasattr(block.mlp, "c_proj"):
|
| 610 |
+
return block.mlp.c_proj.weight
|
| 611 |
+
if hasattr(block, "mlp") and hasattr(block.mlp, "down_proj"):
|
| 612 |
+
return block.mlp.down_proj.weight
|
| 613 |
+
raise ValueError("could not resolve MLP down projection weight")
|
divcot2_encoding.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generalized long-division reduction CoT — base-B limbs, estimated quotient,
|
| 2 |
+
optional scratchpad. Scales divcot_encoding.py (validated at tier 2, base 10)
|
| 3 |
+
to higher tiers.
|
| 4 |
+
|
| 5 |
+
Three levers over the validated v1:
|
| 6 |
+
|
| 7 |
+
1. **Base-B limbs** (B=100 halves W): each limb is one byte (limbs.py), so
|
| 8 |
+
tier 5's W=20 decimal becomes 10 limbs, tier 7's W=78 becomes 39.
|
| 9 |
+
2. **Estimated-quotient install** (`qhat`): the classic scaling trick. The
|
| 10 |
+
one-shot quotient digit qd = floor((r*B+d)/p) needs a W-limb comparison —
|
| 11 |
+
fine at W=3, strained at W>=5. Humans estimate qd from the LEADING limbs
|
| 12 |
+
of numerator and divisor (a constant-size table independent of W), then
|
| 13 |
+
correct by ±1. We install that estimate at an early block so the circuit
|
| 14 |
+
the model learns is estimate-then-correct, not a W-limb divide.
|
| 15 |
+
3. **Scratchpad mode** (`scratch=True`): for large W the model can't hold the
|
| 16 |
+
running remainder internally — EMIT it (LSB-first, so the subtraction
|
| 17 |
+
borrow chain is local). Cost: O(W^2) tokens vs O(W) compact; use compact
|
| 18 |
+
while it trains, scratch when it stops.
|
| 19 |
+
|
| 20 |
+
Surface (compact): "N" N(2W,MSB) "M" p(W,MSB) "=" Q(2W) "R" ans(W,LSB) "\n"
|
| 21 |
+
Surface (scratch): ... "=" [qd r(W,LSB)]*2W "R" ans(W,LSB) "\n"
|
| 22 |
+
|
| 23 |
+
Install targets (NTP-aligned: label at position t supervises the residual
|
| 24 |
+
predicting byte t+1):
|
| 25 |
+
rem{j} compact only — MSB limb j of the running remainder at each
|
| 26 |
+
quotient-emission position (the division carry/state)
|
| 27 |
+
qhat leading-limbs quotient estimate at each quotient position
|
| 28 |
+
ans limb k of N mod p at each answer position
|
| 29 |
+
"""
|
| 30 |
+
from __future__ import annotations
|
| 31 |
+
|
| 32 |
+
from limbs import limb_char, limb_str, parse_limbs, to_limbs
|
| 33 |
+
|
| 34 |
+
NMARK, DIVMARK, EQ, REVMARK, NL = "N", "M", "=", "R", "\n"
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def long_division(N: int, p: int, W: int, base: int):
|
| 38 |
+
"""(quotient_limbs[2W] MSB-first, remainders[2W], answer)."""
|
| 39 |
+
q_limbs, rems, r = [], [], 0
|
| 40 |
+
for d in to_limbs(N, 2 * W, base, msb_first=True):
|
| 41 |
+
r = r * base + d
|
| 42 |
+
qd = r // p # single limb 0..base-1 (r < base*p)
|
| 43 |
+
r -= qd * p
|
| 44 |
+
q_limbs.append(qd)
|
| 45 |
+
rems.append(r)
|
| 46 |
+
return q_limbs, rems, r # r == N % p
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def qhat_estimate(r_prev: int, d: int, p: int, base: int) -> int:
|
| 50 |
+
"""Leading-limbs estimate of floor((r*B+d)/p): numerator's top two limbs
|
| 51 |
+
over divisor's top limb — constant-size lookup regardless of W."""
|
| 52 |
+
num = r_prev * base + d
|
| 53 |
+
if num < p:
|
| 54 |
+
return 0
|
| 55 |
+
nw = 1
|
| 56 |
+
while base ** nw <= num:
|
| 57 |
+
nw += 1
|
| 58 |
+
pw = 1
|
| 59 |
+
while base ** pw <= p:
|
| 60 |
+
pw += 1
|
| 61 |
+
n_top = num // base ** max(0, nw - 2) # top 2 limbs of numerator
|
| 62 |
+
p_top = p // base ** (pw - 1) # top 1 limb of divisor
|
| 63 |
+
shift = (nw - 2) - (pw - 1)
|
| 64 |
+
est = (n_top // p_top) * base ** shift if shift >= 0 else n_top // (p_top * base ** -shift)
|
| 65 |
+
return min(base - 1, max(0, est))
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def prompt_str(N: int, p: int, W: int, base: int) -> str:
|
| 69 |
+
return NMARK + limb_str(N, 2 * W, base, msb_first=True) \
|
| 70 |
+
+ DIVMARK + limb_str(p, W, base, msb_first=True) + EQ
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def gen_len(W: int, scratch: bool = False) -> int:
|
| 74 |
+
steps = 2 * W * (1 + W) if scratch else 2 * W
|
| 75 |
+
return steps + 1 + W
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def build_example(N: int, p: int, W: int, base: int, scratch: bool = False):
|
| 79 |
+
q_limbs, rems, answer = long_division(N, p, W, base)
|
| 80 |
+
n_msb = to_limbs(N, 2 * W, base, msb_first=True)
|
| 81 |
+
prompt = prompt_str(N, p, W, base)
|
| 82 |
+
parts, ann_q = [], [] # ann_q: (char_index, var, val)
|
| 83 |
+
pos = len(prompt)
|
| 84 |
+
r_prev = 0
|
| 85 |
+
for i, (qd, r) in enumerate(zip(q_limbs, rems)):
|
| 86 |
+
ann_q.append((pos, "qhat", qhat_estimate(r_prev, n_msb[i], p, base)))
|
| 87 |
+
if not scratch: # compact: remainder is internal state
|
| 88 |
+
rstr = to_limbs(r, W, base, msb_first=True)
|
| 89 |
+
for j in range(W):
|
| 90 |
+
ann_q.append((pos, f"rem{j}", rstr[j]))
|
| 91 |
+
parts.append(limb_char(qd))
|
| 92 |
+
pos += 1
|
| 93 |
+
if scratch: # emit remainder LSB-first (local borrow)
|
| 94 |
+
parts.append(limb_str(r, W, base))
|
| 95 |
+
pos += W
|
| 96 |
+
r_prev = r
|
| 97 |
+
ans_limbs = to_limbs(answer, W, base) # LSB-first
|
| 98 |
+
parts.append(REVMARK + limb_str(answer, W, base) + NL)
|
| 99 |
+
text = prompt + "".join(parts)
|
| 100 |
+
ann = [dict() for _ in range(len(text))]
|
| 101 |
+
for idx, var, val in ann_q:
|
| 102 |
+
ann[idx - 1][var] = int(val) # NTP alignment
|
| 103 |
+
ans_base = pos + 1 # after 'R'
|
| 104 |
+
for k in range(W):
|
| 105 |
+
ann[ans_base + k - 1]["ans"] = ans_limbs[k]
|
| 106 |
+
return text, ann
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def var_specs(W: int, base: int, scratch: bool = False):
|
| 110 |
+
"""(name, n_classes) install vars; assign blocks in the trainer."""
|
| 111 |
+
specs = [("ans", base), ("qhat", base)]
|
| 112 |
+
if not scratch:
|
| 113 |
+
specs += [(f"rem{j}", base) for j in range(W)]
|
| 114 |
+
return specs
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def decode_answer(gen_chars: str, W: int, base: int, scratch: bool = False) -> int:
|
| 118 |
+
off = (2 * W * (1 + W) if scratch else 2 * W) + 1 # skip quotient(+rems) and 'R'
|
| 119 |
+
return parse_limbs(gen_chars[off:off + W], base)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
if __name__ == "__main__":
|
| 123 |
+
import random
|
| 124 |
+
rng = random.Random(1)
|
| 125 |
+
for base in (10, 100):
|
| 126 |
+
for scratch in (False, True):
|
| 127 |
+
for _ in range(4000):
|
| 128 |
+
W = rng.randint(1, 8)
|
| 129 |
+
p = rng.randrange(max(2, base ** (W - 1)), base ** W)
|
| 130 |
+
N = rng.randrange(p * p) if p > 1 else 0
|
| 131 |
+
text, ann = build_example(N, p, W, base, scratch)
|
| 132 |
+
plen = len(prompt_str(N, p, W, base))
|
| 133 |
+
assert len(text) == plen + gen_len(W, scratch) + 1, (len(text), plen, gen_len(W, scratch)) # +1: NL
|
| 134 |
+
assert decode_answer(text[plen:], W, base, scratch) == N % p
|
| 135 |
+
assert len(ann) == len(text)
|
| 136 |
+
print(f"divcot2 base={base} scratch={scratch}: 4000/4000 decode OK")
|
| 137 |
+
# tier-relevant lengths
|
| 138 |
+
print("\ntokens/example (prompt+gen):")
|
| 139 |
+
for tier, Wd in [(3, 5), (4, 10), (5, 20), (6, 39), (7, 78), (8, 155)]:
|
| 140 |
+
for base, W in ((10, Wd), (100, (Wd + 1) // 2)):
|
| 141 |
+
plen = 3 * W + 3
|
| 142 |
+
print(f" tier {tier} base {base:>3} W={W:>3} compact {plen + gen_len(W)}"
|
| 143 |
+
f" scratch {plen + gen_len(W, True)}")
|
encoding.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared sequence encoding for the modmul BP-install model.
|
| 2 |
+
|
| 3 |
+
The model learns the field-multiplication map (p, x, y) -> (x*y) mod p with
|
| 4 |
+
x, y in [0, p). At inference the legal two-operand reduction a%p, b%p produces
|
| 5 |
+
x, y (the same step the baselines use); training samples x, y in [0, p) directly,
|
| 6 |
+
which matches the (a mod p) distribution for p << operand range.
|
| 7 |
+
|
| 8 |
+
Fixed-width, base-10, reverse-LSB answer (the proven arithmetic recipe). The
|
| 9 |
+
prime p is an explicit in-sequence conditioning field so one model generalises
|
| 10 |
+
across primes:
|
| 11 |
+
|
| 12 |
+
p007*012,003=R120\n # W=3 example: p=7, x=12%7=5? no -- x,y already <p
|
| 13 |
+
# e.g. p=7,x=5,y=4 -> 20%7=6 -> "p7*5,4=R6\n" (W=1)
|
| 14 |
+
|
| 15 |
+
Layout (width W = max decimal digits of any prime in scope):
|
| 16 |
+
"p" P(W) "*" X(W) "," Y(W) "=R" REV_ANS(W) "\n"
|
| 17 |
+
zero-padded fields; REV_ANS is the W-digit answer reversed (least-significant
|
| 18 |
+
digit first), so digit k is emitted at a fixed position and the carry flows
|
| 19 |
+
left-to-right the way reverse-LSB addition does.
|
| 20 |
+
"""
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
MUL, SEP, EQ, REVMARK, NL = "*", ",", "=", "R", "\n"
|
| 24 |
+
NEWLINE_ID = ord("\n")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def width_for_primes(primes) -> int:
|
| 28 |
+
"""Decimal digits needed to hold the largest residue (p-1)."""
|
| 29 |
+
return max(len(str(p - 1)) for p in primes)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def prompt_str(p: int, x: int, y: int, W: int) -> str:
|
| 33 |
+
return f"p{p:0{W}d}*{x:0{W}d},{y:0{W}d}={REVMARK}"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def build_example(p: int, x: int, y: int, W: int):
|
| 37 |
+
"""Return (text, ann) where ann[t] holds the install label for the residual
|
| 38 |
+
at byte position t (which predicts byte t+1), NTP-aligned."""
|
| 39 |
+
ans = (x * y) % p
|
| 40 |
+
rev_ans = f"{ans:0{W}d}"[::-1] # LSB-first, width W
|
| 41 |
+
prompt = prompt_str(p, x, y, W)
|
| 42 |
+
text = prompt + rev_ans + NL
|
| 43 |
+
ann = [dict() for _ in range(len(text))]
|
| 44 |
+
base = len(prompt) # first answer char index
|
| 45 |
+
for k in range(W): # answer digit k (LSB-first)
|
| 46 |
+
pos = base + k
|
| 47 |
+
ann[pos - 1]["ans"] = int(rev_ans[k]) # residual at pos-1 predicts it
|
| 48 |
+
return text, ann
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def answer_len(W: int) -> int:
|
| 52 |
+
return W
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# --- Scratchpad ("chain-of-thought") inference variant -------------------
|
| 56 |
+
# The model GENERATES the product as reverse-LSB digits before the answer:
|
| 57 |
+
# "p" P(W) "*" X(W) "," Y(W) "=" REV_PROD(2W) "R" REV_ANS(W) "\n"
|
| 58 |
+
# At inference we feed up to '=' and greedily decode the full scratchpad; the
|
| 59 |
+
# answer is the last W generated digits. Python never computes x*y -- the model
|
| 60 |
+
# emits the product digits (a learned circuit). Only a%p, b%p is done in code.
|
| 61 |
+
|
| 62 |
+
def prompt_str_sp(p: int, x: int, y: int, W: int) -> str:
|
| 63 |
+
return f"p{p:0{W}d}*{x:0{W}d},{y:0{W}d}{EQ}"
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def scratchpad_len(W: int) -> int:
|
| 67 |
+
return 2 * W + 1 + W
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def decode_answer_sp(gen_chars: str, W: int) -> int:
|
| 71 |
+
"""Generated suffix is 2W product digits, 'R', then W answer digits."""
|
| 72 |
+
return decode_answer(gen_chars[2 * W + 1 : 2 * W + 1 + W], W)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# --- Schoolbook ("partial products") inference variant -------------------
|
| 76 |
+
# Generated suffix: W partial products (W+1 wide), 'P', 2W product digits,
|
| 77 |
+
# 'R', W answer digits. The model emits the partial products and sums them
|
| 78 |
+
# (learned circuit); the answer is the final W digits.
|
| 79 |
+
|
| 80 |
+
def school_gen_len(W: int) -> int:
|
| 81 |
+
return W * (W + 1) + 1 + 2 * W + 1 + W
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def decode_answer_school(gen_chars: str, W: int) -> int:
|
| 85 |
+
start = W * (W + 1) + 1 + 2 * W + 1
|
| 86 |
+
return decode_answer(gen_chars[start:start + W], W)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def decode_answer(gen_chars: str, W: int) -> int:
|
| 90 |
+
"""Greedy-generated W chars are the reverse-LSB answer digits. Non-digits
|
| 91 |
+
(untrained junk) count as 0. Returns the integer answer."""
|
| 92 |
+
val = 0
|
| 93 |
+
for k in range(min(W, len(gen_chars))):
|
| 94 |
+
c = gen_chars[k]
|
| 95 |
+
d = int(c) if "0" <= c <= "9" else 0 # ASCII only ('³'.isdigit() is True but int() fails)
|
| 96 |
+
val += d * (10 ** k)
|
| 97 |
+
return val
|
kvgen.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""KV-cached batched greedy generation for ByteGPT — the wall-clock fix.
|
| 2 |
+
|
| 3 |
+
The naive loop re-forwards the full prefix for every generated token
|
| 4 |
+
(O(T·L²) attention work); harmless at tier-2's ~20-token CoT, fatal for
|
| 5 |
+
tier-3+ chains-of-thought (hundreds-thousands of tokens) under the
|
| 6 |
+
challenge's 5-min/1100-problem inference budget.
|
| 7 |
+
|
| 8 |
+
This module reuses the model's OWN weights and norm functions — it is a
|
| 9 |
+
faster schedule for the same computation, not a different model. Prefill
|
| 10 |
+
captures each block's K/V via a forward hook on `attn.c_attn` (one ordinary
|
| 11 |
+
forward over the prompt, which also yields the first generated token); each
|
| 12 |
+
subsequent token does one single-position pass per block against the cache.
|
| 13 |
+
|
| 14 |
+
Supported config (asserted): attn_kind=mha, attn_norm in {entmax15, softmax},
|
| 15 |
+
position_encoding in {alibi, learned, none}, no block gates / loop blocks /
|
| 16 |
+
attention residuals. Validated token-identical against the naive loop in
|
| 17 |
+
__main__ (random-init smoke + optional real checkpoint).
|
| 18 |
+
"""
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import sys
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
import torch
|
| 25 |
+
|
| 26 |
+
_HERE = Path(__file__).resolve().parent
|
| 27 |
+
sys.path.insert(0, str(_HERE)) # submission layout (vendored)
|
| 28 |
+
for _anc in Path(__file__).resolve().parents: # dev layout (repo experiments/)
|
| 29 |
+
if (_anc / "experiments" / "coppola_pretrain_tiny.py").exists():
|
| 30 |
+
sys.path.insert(0, str(_anc / "experiments"))
|
| 31 |
+
break
|
| 32 |
+
|
| 33 |
+
import coppola_pretrain_tiny as cpt # noqa: E402
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _norm_row(scores: torch.Tensor, mode: str) -> torch.Tensor:
|
| 37 |
+
"""Attention norm over one query row (all cached keys are valid: no mask)."""
|
| 38 |
+
if mode == "softmax":
|
| 39 |
+
return torch.softmax(scores, dim=-1)
|
| 40 |
+
if mode == "entmax15":
|
| 41 |
+
return (cpt._pkg_entmax15(scores, dim=-1) if cpt._HAVE_ENTMAX_PKG
|
| 42 |
+
else cpt._entmax15(scores, dim=-1))
|
| 43 |
+
raise ValueError(f"kvgen: unsupported attn_norm {mode!r}")
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _assert_supported(model) -> None:
|
| 47 |
+
cfg = model.config
|
| 48 |
+
assert cfg.attn_kind == "mha", f"kvgen: attn_kind={cfg.attn_kind!r}"
|
| 49 |
+
assert cfg.attn_norm in ("entmax15", "softmax"), f"kvgen: attn_norm={cfg.attn_norm!r}"
|
| 50 |
+
assert cfg.position_encoding in ("alibi", "learned", "none"), \
|
| 51 |
+
f"kvgen: position_encoding={cfg.position_encoding!r}"
|
| 52 |
+
assert cfg.res_attn == "none", "kvgen: res_attn unsupported"
|
| 53 |
+
assert not cfg.loop_block_indices and not cfg.loop_unit_indices, "kvgen: loop blocks unsupported"
|
| 54 |
+
assert model._gate_values() is None, "kvgen: block gates unsupported"
|
| 55 |
+
assert cfg.dropout == 0.0 or not model.training, "kvgen: eval mode required"
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@torch.no_grad()
|
| 59 |
+
def generate_kv(model, ids: torch.Tensor, n_gen: int) -> torch.Tensor:
|
| 60 |
+
"""Append n_gen greedy tokens to each row of ids ([B, T0] long). Returns
|
| 61 |
+
the [B, n_gen] generated tokens. Total length must fit cfg.seq_len."""
|
| 62 |
+
_assert_supported(model)
|
| 63 |
+
cfg = model.config
|
| 64 |
+
B, T0 = ids.shape
|
| 65 |
+
assert T0 + n_gen <= cfg.seq_len, f"kvgen: {T0}+{n_gen} exceeds seq_len {cfg.seq_len}"
|
| 66 |
+
device = ids.device
|
| 67 |
+
blocks = model.transformer.h
|
| 68 |
+
L = len(blocks)
|
| 69 |
+
nh = [blk.attn.n_head for blk in blocks]
|
| 70 |
+
hd = [blk.attn.head_dim for blk in blocks]
|
| 71 |
+
|
| 72 |
+
# ---- prefill: one ordinary forward over the prompt, K/V captured by hook
|
| 73 |
+
kv: list[tuple[torch.Tensor, torch.Tensor] | None] = [None] * L
|
| 74 |
+
|
| 75 |
+
def _mk_hook(i):
|
| 76 |
+
def hook(_mod, _inp, qkv):
|
| 77 |
+
_q, k, v = qkv.chunk(3, dim=-1)
|
| 78 |
+
kv[i] = (k.view(B, -1, nh[i], hd[i]).transpose(1, 2).contiguous(),
|
| 79 |
+
v.view(B, -1, nh[i], hd[i]).transpose(1, 2).contiguous())
|
| 80 |
+
return hook
|
| 81 |
+
|
| 82 |
+
handles = [blk.attn.c_attn.register_forward_hook(_mk_hook(i))
|
| 83 |
+
for i, blk in enumerate(blocks)]
|
| 84 |
+
try:
|
| 85 |
+
logits, _ = model(ids)
|
| 86 |
+
finally:
|
| 87 |
+
for h in handles:
|
| 88 |
+
h.remove()
|
| 89 |
+
out = [logits[:, -1].argmax(dim=-1)] # first generated token
|
| 90 |
+
|
| 91 |
+
slopes = (model._alibi_slopes.to(device) if cfg.position_encoding == "alibi" else None)
|
| 92 |
+
|
| 93 |
+
# ---- incremental steps: one single-position pass per block per token
|
| 94 |
+
for step in range(1, n_gen):
|
| 95 |
+
t = T0 + step - 1 # position of the token we feed in
|
| 96 |
+
x = model.transformer.wte(out[-1]).unsqueeze(1) # [B, 1, E]
|
| 97 |
+
if cfg.position_encoding == "learned":
|
| 98 |
+
x = x + model.transformer.wpe(torch.tensor([t], device=device))[None, :, :]
|
| 99 |
+
for i, blk in enumerate(blocks):
|
| 100 |
+
h = blk.ln_1(x)
|
| 101 |
+
q, k, v = blk.attn.c_attn(h).chunk(3, dim=-1)
|
| 102 |
+
q = q.view(B, 1, nh[i], hd[i]).transpose(1, 2) # [B, nh, 1, hd]
|
| 103 |
+
k = k.view(B, 1, nh[i], hd[i]).transpose(1, 2)
|
| 104 |
+
v = v.view(B, 1, nh[i], hd[i]).transpose(1, 2)
|
| 105 |
+
K = torch.cat([kv[i][0], k], dim=2) # [B, nh, t+1, hd]
|
| 106 |
+
V = torch.cat([kv[i][1], v], dim=2)
|
| 107 |
+
kv[i] = (K, V)
|
| 108 |
+
# [B, nh, 1, t+1]: dot of the single query with every cached key
|
| 109 |
+
scores = torch.einsum("bhqd,bhjd->bhqj", q, K) / (hd[i] ** 0.5)
|
| 110 |
+
if slopes is not None: # ALiBi row: slope * -(t - j)
|
| 111 |
+
j = torch.arange(K.size(2), device=device, dtype=scores.dtype)
|
| 112 |
+
scores = scores + slopes.view(1, -1, 1, 1) * (-(t - j).abs()).view(1, 1, 1, -1)
|
| 113 |
+
attn = _norm_row(scores, cfg.attn_norm)
|
| 114 |
+
y = torch.einsum("bhqj,bhjd->bhqd", attn, V)
|
| 115 |
+
y = y.transpose(1, 2).contiguous().view(B, 1, -1)
|
| 116 |
+
x = x + blk.attn.c_proj(y)
|
| 117 |
+
x = x + blk.mlp(blk.ln_2(x))
|
| 118 |
+
logits1 = model.lm_head(model.transformer.ln_f(x)) # [B, 1, vocab]
|
| 119 |
+
out.append(logits1[:, -1].argmax(dim=-1))
|
| 120 |
+
return torch.stack(out, dim=1) # [B, n_gen]
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
@torch.no_grad()
|
| 124 |
+
def generate_naive(model, ids: torch.Tensor, n_gen: int) -> torch.Tensor:
|
| 125 |
+
"""Reference loop (full re-forward per token), for validation/timing."""
|
| 126 |
+
cap = model.config.seq_len
|
| 127 |
+
for _ in range(n_gen):
|
| 128 |
+
logits, _ = model(ids[:, -cap:])
|
| 129 |
+
ids = torch.cat([ids, logits[:, -1].argmax(dim=-1, keepdim=True)], dim=1)
|
| 130 |
+
return ids[:, -n_gen:]
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
if __name__ == "__main__":
|
| 134 |
+
import argparse
|
| 135 |
+
import time
|
| 136 |
+
|
| 137 |
+
ap = argparse.ArgumentParser(description="validate kvgen vs naive loop")
|
| 138 |
+
ap.add_argument("--ckpt", default=None, help="optional trained checkpoint (.pt)")
|
| 139 |
+
ap.add_argument("--n-gen", type=int, default=40)
|
| 140 |
+
ap.add_argument("--batch", type=int, default=8)
|
| 141 |
+
args = ap.parse_args()
|
| 142 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 143 |
+
|
| 144 |
+
if args.ckpt:
|
| 145 |
+
sys.path.insert(0, str(_HERE))
|
| 146 |
+
from train_arith_bp_supervised import TrainConfig, build_model
|
| 147 |
+
ck = torch.load(args.ckpt, map_location=device, weights_only=False)
|
| 148 |
+
model = build_model(TrainConfig(**ck["config"]), device)
|
| 149 |
+
model.load_state_dict(ck["state_dict"])
|
| 150 |
+
else:
|
| 151 |
+
cfg = cpt.GPTConfig(vocab_size=256, n_layer=4, n_head=4, n_embd=128,
|
| 152 |
+
seq_len=256, dropout=0.0, attn_kind="mha",
|
| 153 |
+
attn_norm="entmax15", position_encoding="alibi",
|
| 154 |
+
loss_kind="bce")
|
| 155 |
+
torch.manual_seed(0)
|
| 156 |
+
model = cpt.ByteGPT(cfg).to(device)
|
| 157 |
+
model.eval()
|
| 158 |
+
|
| 159 |
+
torch.manual_seed(1)
|
| 160 |
+
ids = torch.randint(40, 70, (args.batch, 24), device=device)
|
| 161 |
+
t0 = time.time()
|
| 162 |
+
a = generate_naive(model, ids, args.n_gen)
|
| 163 |
+
t_naive = time.time() - t0
|
| 164 |
+
t0 = time.time()
|
| 165 |
+
b = generate_kv(model, ids, args.n_gen)
|
| 166 |
+
t_kv = time.time() - t0
|
| 167 |
+
same = (a == b).all().item()
|
| 168 |
+
n_diff = (a != b).sum().item()
|
| 169 |
+
print(f"identical={same} diff_tokens={n_diff}/{a.numel()} "
|
| 170 |
+
f"naive={t_naive:.2f}s kv={t_kv:.2f}s speedup={t_naive / max(t_kv, 1e-9):.1f}x")
|
| 171 |
+
if not same:
|
| 172 |
+
sys.exit(1)
|
limbs.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Limb codec shared by the scaled CoT encodings (divcot2, karatsuba).
|
| 2 |
+
|
| 3 |
+
A limb is one digit in base B (10 or 100), encoded as a SINGLE byte
|
| 4 |
+
0x80+value (128..227) so sequence length scales with limb count, not decimal
|
| 5 |
+
digits. Base-100 halves W and roughly quarters quadratic CoT lengths.
|
| 6 |
+
Strings are latin1-safe: encode("latin1") round-trips; never use ascii.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
LIMB_OFFSET = 0x80 # limb v -> chr(0x80+v); supports base <= 128
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def limb_char(v: int) -> str:
|
| 14 |
+
return chr(LIMB_OFFSET + v)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def char_limb(c: str) -> int:
|
| 18 |
+
"""Inverse of limb_char; out-of-range chars decode to 0 (eval leniency)."""
|
| 19 |
+
v = ord(c) - LIMB_OFFSET
|
| 20 |
+
return v if v >= 0 else 0
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def to_limbs(n: int, width: int, base: int, msb_first: bool = False) -> list[int]:
|
| 24 |
+
out = []
|
| 25 |
+
for _ in range(width):
|
| 26 |
+
out.append(n % base)
|
| 27 |
+
n //= base
|
| 28 |
+
assert n == 0, "width too small for value"
|
| 29 |
+
return out[::-1] if msb_first else out
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def from_limbs(limbs, base: int, msb_first: bool = False) -> int:
|
| 33 |
+
seq = limbs[::-1] if msb_first else limbs
|
| 34 |
+
val = 0
|
| 35 |
+
for k, v in enumerate(seq):
|
| 36 |
+
val += v * base ** k
|
| 37 |
+
return val
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def limb_str(n: int, width: int, base: int, msb_first: bool = False) -> str:
|
| 41 |
+
return "".join(limb_char(v) for v in to_limbs(n, width, base, msb_first))
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def parse_limbs(s: str, base: int, msb_first: bool = False) -> int:
|
| 45 |
+
vals = [min(char_limb(c), base - 1) for c in s]
|
| 46 |
+
return from_limbs(vals, base, msb_first)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def width_for(p_max: int, base: int) -> int:
|
| 50 |
+
w = 1
|
| 51 |
+
while base ** w <= p_max - 1:
|
| 52 |
+
w += 1
|
| 53 |
+
return w
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
if __name__ == "__main__":
|
| 57 |
+
import random
|
| 58 |
+
rng = random.Random(0)
|
| 59 |
+
for base in (10, 100):
|
| 60 |
+
for _ in range(2000):
|
| 61 |
+
w = rng.randint(1, 12)
|
| 62 |
+
n = rng.randrange(base ** w)
|
| 63 |
+
for msb in (False, True):
|
| 64 |
+
s = limb_str(n, w, base, msb)
|
| 65 |
+
assert len(s) == w and parse_limbs(s, base, msb) == n
|
| 66 |
+
print("limbs.py: all round-trip tests pass")
|
manifest.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"entry_class": "model.ModMulRouter",
|
| 3 |
+
"output_base": 10,
|
| 4 |
+
"framework": "pytorch",
|
| 5 |
+
"model_description": "Prime-magnitude router over from-random-init ByteGPT members (entmax15+ALiBi, BCE+reg): tiers 1-2 scratchpad-CoT member; tier-3 composed multiply+long-division CoT member (model generates accumulator rows, restaged dividend, per-step qd*p and remainders, answer). Routing keys on the size of p only; all answers are generated digit sequences from trained weights.",
|
| 6 |
+
"training_description": "All members trained from random init with BCE(+reg) LM loss plus per-block BP-install probe losses on intermediate variables (carries, borrows, quotient estimates, remainder digits). Synthetic data from the official tier geometry with held-out seeds; tier-3 member trained on ~all ~6445 primes of the 9-16-bit range (full domain coverage), bit-uniform prime sampling, example-aligned batch rows. Randomising weights collapses accuracy. Training code: github.com/area9innovation/modmul-challenge (available to reviewers on request)."
|
| 7 |
+
}
|
model.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Challenge-interface wrapper for the modmul BP-install model.
|
| 2 |
+
|
| 3 |
+
Implements ModularMultiplicationModel. Inference:
|
| 4 |
+
1. per-argument: parse a, b, p to ints (each hook sees only its own arg)
|
| 5 |
+
2. predict_digits: reduce x=a%p, y=b%p (legal two-operand reduction), and if
|
| 6 |
+
p is within the model's trained width, greedy-decode the reverse-LSB answer
|
| 7 |
+
digits from the network; otherwise emit [0] (honest out-of-regime fallback).
|
| 8 |
+
|
| 9 |
+
The answer comes entirely from the trained network on in-regime primes:
|
| 10 |
+
randomising the weights collapses accuracy. output_base = 10.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import sys
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
import torch
|
| 18 |
+
|
| 19 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
| 20 |
+
# Self-contained: ByteGPT + build_model + the entmax15 recipe are VENDORED into
|
| 21 |
+
# this directory (coppola_pretrain_tiny.py, coppola_pretraining.py,
|
| 22 |
+
# train_arith_bp_supervised.py), so the submission loads with only `torch`
|
| 23 |
+
# available and read access limited to its own dir (the eval sandbox contract).
|
| 24 |
+
# entmax15 falls back to a local forward-exact impl when the `entmax` pip
|
| 25 |
+
# package is absent.
|
| 26 |
+
from train_arith_bp_supervised import TrainConfig, build_model # noqa: E402
|
| 27 |
+
import encoding as enc # noqa: E402
|
| 28 |
+
import composed_encoding as cenc # noqa: E402 (composed multiply+reduce CoT)
|
| 29 |
+
import kvgen # noqa: E402 (KV-cached generation; falls back to naive loop)
|
| 30 |
+
|
| 31 |
+
from modchallenge.interface.base_model import ModularMultiplicationModel # noqa: E402
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class ModMulBP(ModularMultiplicationModel):
|
| 35 |
+
def __init__(self):
|
| 36 |
+
self.model = None
|
| 37 |
+
self.W = 1
|
| 38 |
+
self.device = None
|
| 39 |
+
self.regime = 10 # max p exclusive = 10**W
|
| 40 |
+
self.scratchpad = False
|
| 41 |
+
self.school = False
|
| 42 |
+
|
| 43 |
+
def load(self, model_dir: str, weights: str = "weights.pt") -> None:
|
| 44 |
+
torch.manual_seed(0) # determinism is the model's responsibility (rules)
|
| 45 |
+
self.device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 46 |
+
ckpt = torch.load(Path(model_dir) / weights,
|
| 47 |
+
map_location=self.device, weights_only=False)
|
| 48 |
+
tc = TrainConfig(**ckpt["config"])
|
| 49 |
+
self.model = build_model(tc, self.device)
|
| 50 |
+
self.model.load_state_dict(ckpt["state_dict"])
|
| 51 |
+
self.model.eval()
|
| 52 |
+
self.W = ckpt["W"]
|
| 53 |
+
self.scratchpad = bool(ckpt.get("scratchpad", False))
|
| 54 |
+
self.school = bool(ckpt.get("school", False))
|
| 55 |
+
self.composed = bool(ckpt.get("composed", False))
|
| 56 |
+
if self.composed:
|
| 57 |
+
self.base = ckpt["base"]
|
| 58 |
+
self.scratch = bool(ckpt.get("scratch", False))
|
| 59 |
+
self.cursor = bool(ckpt.get("cursor", False))
|
| 60 |
+
self.subpad = bool(ckpt.get("subpad", False))
|
| 61 |
+
self.regime = self.base ** self.W
|
| 62 |
+
else:
|
| 63 |
+
self.regime = 10 ** self.W
|
| 64 |
+
# Trained prime span (for router dispatch): derived from the ckpt's
|
| 65 |
+
# tier list via the official tier geometry; fallback = full regime.
|
| 66 |
+
self.p_lo, self.p_hi = 2, self.regime - 1
|
| 67 |
+
try:
|
| 68 |
+
from modchallenge.config import TIERS
|
| 69 |
+
spans = [(2 ** TIERS[t].min_bits, 2 ** TIERS[t].max_bits - 1)
|
| 70 |
+
for t in ckpt.get("tiers", [])]
|
| 71 |
+
if spans:
|
| 72 |
+
self.p_lo = min(lo for lo, _ in spans)
|
| 73 |
+
self.p_hi = min(max(hi for _, hi in spans), self.regime - 1)
|
| 74 |
+
except Exception:
|
| 75 |
+
pass
|
| 76 |
+
|
| 77 |
+
# per-argument preprocessing (each sees only its own argument)
|
| 78 |
+
def preprocess_a(self, a: str) -> int:
|
| 79 |
+
return int(a)
|
| 80 |
+
|
| 81 |
+
def preprocess_b(self, b: str) -> int:
|
| 82 |
+
return int(b)
|
| 83 |
+
|
| 84 |
+
def preprocess_p(self, p: str) -> int:
|
| 85 |
+
return int(p)
|
| 86 |
+
|
| 87 |
+
@torch.no_grad()
|
| 88 |
+
def predict_digits(self, a_enc, b_enc, p_enc):
|
| 89 |
+
return self.predict_digits_batch([(a_enc, b_enc, p_enc)])[0]
|
| 90 |
+
|
| 91 |
+
@torch.no_grad()
|
| 92 |
+
def predict_digits_batch(self, inputs):
|
| 93 |
+
out = [[0]] * len(inputs)
|
| 94 |
+
if self.composed:
|
| 95 |
+
prompt_fn = lambda p, x, y, W: cenc.prompt_str( # noqa: E731
|
| 96 |
+
x, y, p, W, self.base, self.subpad)
|
| 97 |
+
n_gen = cenc.gen_len(self.W, self.scratch, self.cursor, self.subpad)
|
| 98 |
+
decode_fn = lambda g, W: cenc.decode_answer( # noqa: E731
|
| 99 |
+
g, W, self.base, self.scratch, self.cursor, self.subpad)
|
| 100 |
+
else:
|
| 101 |
+
cot = self.scratchpad or self.school
|
| 102 |
+
prompt_fn = enc.prompt_str_sp if cot else enc.prompt_str
|
| 103 |
+
n_gen = (enc.school_gen_len(self.W) if self.school
|
| 104 |
+
else enc.scratchpad_len(self.W) if self.scratchpad
|
| 105 |
+
else enc.answer_len(self.W))
|
| 106 |
+
decode_fn = (enc.decode_answer_school if self.school
|
| 107 |
+
else enc.decode_answer_sp if self.scratchpad
|
| 108 |
+
else enc.decode_answer)
|
| 109 |
+
prompts, idx = [], []
|
| 110 |
+
for i, (a, b, p) in enumerate(inputs):
|
| 111 |
+
if p >= self.regime: # outside trained width -> honest 0
|
| 112 |
+
continue
|
| 113 |
+
x, y = a % p, b % p
|
| 114 |
+
prompts.append(prompt_fn(p, x, y, self.W))
|
| 115 |
+
idx.append((i, x, y, p))
|
| 116 |
+
if not prompts:
|
| 117 |
+
return out
|
| 118 |
+
# All in-regime prompts share the same length (fixed width) -> batchable.
|
| 119 |
+
# latin1: composed prompts carry limb bytes >127 (limbs.py codec).
|
| 120 |
+
ids = torch.tensor([list(s.encode("latin1")) for s in prompts],
|
| 121 |
+
dtype=torch.long, device=self.device)
|
| 122 |
+
plen = len(prompts[0])
|
| 123 |
+
# KV-cached generation: the naive loop re-forwards the whole prefix per
|
| 124 |
+
# token, which blows the 5-min/1100-problem budget on long CoTs. kvgen
|
| 125 |
+
# is the same computation on the same weights, token-identical
|
| 126 |
+
# (validated); fall back to the naive loop on unsupported configs.
|
| 127 |
+
try:
|
| 128 |
+
gens = kvgen.generate_kv(self.model, ids, n_gen).tolist()
|
| 129 |
+
except AssertionError:
|
| 130 |
+
seq_cap = self.model.config.seq_len
|
| 131 |
+
for _k in range(n_gen):
|
| 132 |
+
logits, _ = self.model(ids[:, -seq_cap:])
|
| 133 |
+
nxt = logits[:, -1].argmax(dim=-1, keepdim=True)
|
| 134 |
+
ids = torch.cat([ids, nxt], dim=1)
|
| 135 |
+
gens = ids[:, plen:].tolist()
|
| 136 |
+
for row, (i, x, y, p) in zip(gens, idx):
|
| 137 |
+
gen = bytes(b & 0xFF for b in row).decode("latin1")
|
| 138 |
+
# No arithmetic touch-up of the model's answer: a decoded value >= p
|
| 139 |
+
# would be malformed (scored incorrect anyway), so emit the honest
|
| 140 |
+
# [0] fallback instead of clamping with % p.
|
| 141 |
+
ans = decode_fn(gen, self.W)
|
| 142 |
+
if 0 <= ans < p:
|
| 143 |
+
out[i] = [int(c) for c in str(ans)]
|
| 144 |
+
return out
|
| 145 |
+
|
| 146 |
+
def max_batch_size(self) -> int:
|
| 147 |
+
return 512
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
class ModMulRouter(ModularMultiplicationModel):
|
| 151 |
+
"""Routes each problem to the most specialized member model by prime
|
| 152 |
+
magnitude. Members are weights_r*.pt files (sorted name order); each is a
|
| 153 |
+
full ModMulBP checkpoint with its own trained regime. A problem goes to
|
| 154 |
+
the FIRST member whose regime covers its p; out-of-regime problems emit
|
| 155 |
+
the honest [0].
|
| 156 |
+
|
| 157 |
+
Compliance: routing keys on the SIZE of p only (per-argument
|
| 158 |
+
representation work, like base conversion); every answer comes from a
|
| 159 |
+
trained member's generated digits.
|
| 160 |
+
"""
|
| 161 |
+
|
| 162 |
+
def __init__(self):
|
| 163 |
+
self.members: list[ModMulBP] = []
|
| 164 |
+
|
| 165 |
+
def load(self, model_dir: str) -> None:
|
| 166 |
+
torch.manual_seed(0)
|
| 167 |
+
for f in sorted(Path(model_dir).glob("weights_r*.pt")):
|
| 168 |
+
m = ModMulBP()
|
| 169 |
+
m.load(model_dir, weights=f.name)
|
| 170 |
+
self.members.append(m)
|
| 171 |
+
assert self.members, "router needs weights_r*.pt member checkpoints"
|
| 172 |
+
self.members.sort(key=lambda m: m.regime) # most specialized first
|
| 173 |
+
|
| 174 |
+
def preprocess_a(self, a: str) -> int:
|
| 175 |
+
return int(a)
|
| 176 |
+
|
| 177 |
+
def preprocess_b(self, b: str) -> int:
|
| 178 |
+
return int(b)
|
| 179 |
+
|
| 180 |
+
def preprocess_p(self, p: str) -> int:
|
| 181 |
+
return int(p)
|
| 182 |
+
|
| 183 |
+
@torch.no_grad()
|
| 184 |
+
def predict_digits(self, a_enc, b_enc, p_enc):
|
| 185 |
+
return self.predict_digits_batch([(a_enc, b_enc, p_enc)])[0]
|
| 186 |
+
|
| 187 |
+
@torch.no_grad()
|
| 188 |
+
def predict_digits_batch(self, inputs):
|
| 189 |
+
out = [[0]] * len(inputs)
|
| 190 |
+
groups: dict[int, list[int]] = {}
|
| 191 |
+
for i, (_a, _b, p) in enumerate(inputs):
|
| 192 |
+
# Prefer the member whose TRAINED prime span contains p; fall back
|
| 193 |
+
# to the most specialized member whose regime merely covers it.
|
| 194 |
+
mi = next((k for k, m in enumerate(self.members)
|
| 195 |
+
if m.p_lo <= p <= m.p_hi), None)
|
| 196 |
+
if mi is None:
|
| 197 |
+
mi = next((k for k, m in enumerate(self.members) if p < m.regime), None)
|
| 198 |
+
if mi is not None:
|
| 199 |
+
groups.setdefault(mi, []).append(i)
|
| 200 |
+
for mi, idxs in groups.items():
|
| 201 |
+
sub = [inputs[i] for i in idxs]
|
| 202 |
+
res = self.members[mi].predict_digits_batch(sub)
|
| 203 |
+
for i, r in zip(idxs, res):
|
| 204 |
+
out[i] = r
|
| 205 |
+
return out
|
| 206 |
+
|
| 207 |
+
def max_batch_size(self) -> int:
|
| 208 |
+
return 512
|
train_arith_bp_supervised.py
ADDED
|
@@ -0,0 +1,633 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""BP-install supervised training on arithmetic.
|
| 3 |
+
|
| 4 |
+
Adds per-block probe-loss to the standard NTP training:
|
| 5 |
+
total = NTP_loss + λ_install · sum_V CE(probe_V(pre_blk_target_V), V_labels)
|
| 6 |
+
|
| 7 |
+
For arithmetic, BP variables are (carry_after_k, sum_digit_k) at known
|
| 8 |
+
positions. Target blocks default to Phase-A install pattern:
|
| 9 |
+
- carry_after_k -> block 2
|
| 10 |
+
- sum_digit_k -> block 3
|
| 11 |
+
|
| 12 |
+
The supervised loss pushes the model to install each BP variable at its
|
| 13 |
+
target block's INPUT (i.e., the residual just before block target_block).
|
| 14 |
+
|
| 15 |
+
Hypothesis (from user): forcing single-block install via supervision
|
| 16 |
+
will concentrate the per-head distributed signal from Phase A into a
|
| 17 |
+
cleaner BP message.
|
| 18 |
+
"""
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import argparse
|
| 22 |
+
import json
|
| 23 |
+
import math
|
| 24 |
+
import random
|
| 25 |
+
import sys
|
| 26 |
+
from dataclasses import dataclass
|
| 27 |
+
from pathlib import Path
|
| 28 |
+
from typing import Dict, List, Tuple
|
| 29 |
+
|
| 30 |
+
import torch
|
| 31 |
+
import torch.nn as nn
|
| 32 |
+
import torch.nn.functional as F
|
| 33 |
+
|
| 34 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
| 35 |
+
from coppola_pretrain_tiny import (
|
| 36 |
+
ByteGPT, GPTConfig,
|
| 37 |
+
evaluate_arithmetic_accuracy,
|
| 38 |
+
bits_per_byte,
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ----------------------------- BP annotations ------------------------------
|
| 43 |
+
|
| 44 |
+
def _make_addition_with_annotations(max_digits: int,
|
| 45 |
+
surface: str = "reverse"
|
| 46 |
+
) -> Tuple[str, List[Dict[str, int]]]:
|
| 47 |
+
"""Generate an addition problem in one of two surfaces, with per-position
|
| 48 |
+
BP-variable annotations placed at the NTP predicting residual.
|
| 49 |
+
|
| 50 |
+
Surfaces:
|
| 51 |
+
- "reverse": `a+b=R<ans_lsb_first>\n`, e.g. 12+34 -> "12+34=R64\n"
|
| 52 |
+
- "forward": `a+b=F<ans_msb_first>\n`, e.g. 12+34 -> "12+34=F46\n"
|
| 53 |
+
|
| 54 |
+
Annotation IDENTITY (carry_after_k, sum_digit_k) is invariant across
|
| 55 |
+
surfaces; only the POSITION at which the predicting residual sits
|
| 56 |
+
differs. This is the test substrate for surface-form invariance of
|
| 57 |
+
BP-install location.
|
| 58 |
+
"""
|
| 59 |
+
if surface not in ("reverse", "forward"):
|
| 60 |
+
raise ValueError(f"surface must be 'reverse' or 'forward', got {surface!r}")
|
| 61 |
+
n_a = random.randint(1, max_digits)
|
| 62 |
+
n_b = random.randint(1, max_digits)
|
| 63 |
+
a = random.randint(0, 10 ** n_a - 1)
|
| 64 |
+
b = random.randint(0, 10 ** n_b - 1)
|
| 65 |
+
ans_int = a + b
|
| 66 |
+
ans_forward = str(ans_int)
|
| 67 |
+
ans_emit = ans_forward[::-1] if surface == "reverse" else ans_forward
|
| 68 |
+
marker = "R" if surface == "reverse" else "F"
|
| 69 |
+
text = f"{a}+{b}={marker}{ans_emit}\n"
|
| 70 |
+
a_str = str(a)
|
| 71 |
+
b_str = str(b)
|
| 72 |
+
plus_pos = len(a_str)
|
| 73 |
+
eq_pos = plus_pos + 1 + len(b_str)
|
| 74 |
+
marker_pos = eq_pos + 1
|
| 75 |
+
ans_start = marker_pos + 1
|
| 76 |
+
ans_len = len(ans_emit)
|
| 77 |
+
ann = [{} for _ in range(len(text))]
|
| 78 |
+
|
| 79 |
+
# Compute carries: carry_after_k = carry out of column k (binary).
|
| 80 |
+
max_n = max(n_a, n_b)
|
| 81 |
+
carry = 0
|
| 82 |
+
carries = []
|
| 83 |
+
for k in range(max_n + 1):
|
| 84 |
+
da = (a // (10 ** k)) % 10 if k < n_a else 0
|
| 85 |
+
db = (b // (10 ** k)) % 10 if k < n_b else 0
|
| 86 |
+
tot = da + db + carry
|
| 87 |
+
carry = tot // 10
|
| 88 |
+
carries.append(carry)
|
| 89 |
+
|
| 90 |
+
# Sum digits and carries placed at predicting residuals (NTP-aligned).
|
| 91 |
+
# For position-of-sum_digit_k's EMISSION:
|
| 92 |
+
# reverse: sum_digit_k emitted at ans_start + k
|
| 93 |
+
# forward: sum_digit_k emitted at ans_start + (ans_len - 1 - k)
|
| 94 |
+
# The annotation goes at emission_pos - 1.
|
| 95 |
+
# carry_after_k is needed to PREDICT sum_digit_(k+1), so it sits at the
|
| 96 |
+
# residual that PRECEDES sum_digit_(k+1)'s emission position.
|
| 97 |
+
def emission_pos_for_sum_k(k: int) -> int:
|
| 98 |
+
# k=0 is LSB; emission position depends on surface
|
| 99 |
+
if surface == "reverse":
|
| 100 |
+
return ans_start + k
|
| 101 |
+
# forward: most-significant emitted first
|
| 102 |
+
# max emitted digit index = ans_len - 1 corresponds to LSB if forward
|
| 103 |
+
# The kth LSB digit is the (ans_len - 1 - k)-th emitted in forward
|
| 104 |
+
return ans_start + (ans_len - 1 - k)
|
| 105 |
+
|
| 106 |
+
for k in range(ans_len):
|
| 107 |
+
emit = emission_pos_for_sum_k(k)
|
| 108 |
+
tgt = emit - 1
|
| 109 |
+
if 0 <= tgt < len(text):
|
| 110 |
+
# Extract the actual digit value at that emission position
|
| 111 |
+
digit_char = text[emit]
|
| 112 |
+
if digit_char.isdigit():
|
| 113 |
+
ann[tgt][f"sum_digit_{k}"] = int(digit_char)
|
| 114 |
+
|
| 115 |
+
# carry_after_k is needed to predict sum_digit_(k+1).
|
| 116 |
+
for k, c in enumerate(carries):
|
| 117 |
+
if k + 1 >= ans_len:
|
| 118 |
+
continue # no next sum digit to predict
|
| 119 |
+
emit_next = emission_pos_for_sum_k(k + 1)
|
| 120 |
+
tgt = emit_next - 1
|
| 121 |
+
if 0 <= tgt < len(text):
|
| 122 |
+
ann[tgt][f"carry_after_{k}"] = c
|
| 123 |
+
|
| 124 |
+
return text, ann
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
# Variables we supervise and their target blocks.
|
| 128 |
+
# Per Phase A: carries install around block 2, sums around block 3.
|
| 129 |
+
# For 4L model: blocks 0..3.
|
| 130 |
+
def default_var_specs(max_digits: int, target_carry_block: int = 2,
|
| 131 |
+
target_sum_block: int = 3
|
| 132 |
+
) -> List[Tuple[str, int, int]]:
|
| 133 |
+
"""Return list of (var_name, n_classes, target_block_idx)."""
|
| 134 |
+
specs = []
|
| 135 |
+
# carry_after_k: 2-class (0 or 1) — for digits beyond highest column,
|
| 136 |
+
# carry could exceed 1 only in pathological cases, so binary is safe
|
| 137 |
+
# for 1-3 digit ops with values 0..999+0..999=0..1998.
|
| 138 |
+
for k in range(max_digits + 1):
|
| 139 |
+
specs.append((f"carry_after_{k}", 2, target_carry_block))
|
| 140 |
+
for k in range(max_digits + 1):
|
| 141 |
+
specs.append((f"sum_digit_{k}", 10, target_sum_block))
|
| 142 |
+
return specs
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
# ----------------------------- Corpus --------------------------------------
|
| 146 |
+
|
| 147 |
+
class ArithmeticBPCorpus:
|
| 148 |
+
"""Arithmetic corpus emitting (x, y, bp_labels)."""
|
| 149 |
+
|
| 150 |
+
def __init__(self, max_digits: int, var_specs, surfaces=("reverse",),
|
| 151 |
+
surface_weights=None):
|
| 152 |
+
"""surfaces: tuple of surface names to sample from.
|
| 153 |
+
surface_weights: matching list of relative weights (default uniform)."""
|
| 154 |
+
self.max_digits = max_digits
|
| 155 |
+
self.var_specs = var_specs
|
| 156 |
+
self.var_names = [name for name, _, _ in var_specs]
|
| 157 |
+
self.surfaces = tuple(surfaces)
|
| 158 |
+
if surface_weights is None:
|
| 159 |
+
surface_weights = [1.0] * len(self.surfaces)
|
| 160 |
+
if len(surface_weights) != len(self.surfaces):
|
| 161 |
+
raise ValueError("surface_weights length mismatch")
|
| 162 |
+
total = sum(surface_weights)
|
| 163 |
+
self._cum_weights = []
|
| 164 |
+
run = 0.0
|
| 165 |
+
for w in surface_weights:
|
| 166 |
+
run += w / total
|
| 167 |
+
self._cum_weights.append(run)
|
| 168 |
+
|
| 169 |
+
def _pick_surface(self) -> str:
|
| 170 |
+
r = random.random()
|
| 171 |
+
for s, c in zip(self.surfaces, self._cum_weights):
|
| 172 |
+
if r <= c:
|
| 173 |
+
return s
|
| 174 |
+
return self.surfaces[-1]
|
| 175 |
+
|
| 176 |
+
def sample_batch(self, batch_size: int, seq_len: int, device: str):
|
| 177 |
+
need = seq_len + 1
|
| 178 |
+
x_batch = torch.zeros((batch_size, seq_len), dtype=torch.long)
|
| 179 |
+
y_batch = torch.zeros((batch_size, seq_len), dtype=torch.long)
|
| 180 |
+
labels = {v: torch.full((batch_size, seq_len), -1, dtype=torch.long)
|
| 181 |
+
for v in self.var_names}
|
| 182 |
+
for b in range(batch_size):
|
| 183 |
+
buf_bytes: List[int] = []
|
| 184 |
+
buf_ann: List[Dict[str, int]] = []
|
| 185 |
+
while len(buf_bytes) < need:
|
| 186 |
+
surface = self._pick_surface()
|
| 187 |
+
text, ann = _make_addition_with_annotations(self.max_digits,
|
| 188 |
+
surface=surface)
|
| 189 |
+
buf_bytes.extend(text.encode("ascii"))
|
| 190 |
+
buf_ann.extend(ann)
|
| 191 |
+
ids = buf_bytes[:need]
|
| 192 |
+
x_batch[b] = torch.tensor(ids[:-1], dtype=torch.long)
|
| 193 |
+
y_batch[b] = torch.tensor(ids[1:], dtype=torch.long)
|
| 194 |
+
for t in range(seq_len):
|
| 195 |
+
if t < len(buf_ann):
|
| 196 |
+
for v, val in buf_ann[t].items():
|
| 197 |
+
if v in labels:
|
| 198 |
+
labels[v][b, t] = int(val)
|
| 199 |
+
return (
|
| 200 |
+
x_batch.to(device),
|
| 201 |
+
y_batch.to(device),
|
| 202 |
+
{v: t.to(device) for v, t in labels.items()},
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
# ----------------------------- Probes --------------------------------------
|
| 207 |
+
|
| 208 |
+
class BPProbeHeads(nn.Module):
|
| 209 |
+
"""One linear probe per BP variable. Probes are applied to the residual
|
| 210 |
+
at the variable's target block. Optionally also at other blocks
|
| 211 |
+
(anti-spread)."""
|
| 212 |
+
|
| 213 |
+
def __init__(self, d_model: int, var_specs):
|
| 214 |
+
super().__init__()
|
| 215 |
+
self.var_specs = var_specs
|
| 216 |
+
self.probes = nn.ModuleDict({
|
| 217 |
+
name: nn.Linear(d_model, n_classes)
|
| 218 |
+
for name, n_classes, _ in var_specs
|
| 219 |
+
})
|
| 220 |
+
|
| 221 |
+
def loss(self, residuals_per_block: List[torch.Tensor],
|
| 222 |
+
labels: Dict[str, torch.Tensor],
|
| 223 |
+
anti_lambda: float = 0.0) -> Tuple[torch.Tensor, Dict[str, float]]:
|
| 224 |
+
"""Install loss: CE at target block. Anti-spread (optional): for
|
| 225 |
+
other blocks, push the probe to NOT extract V (negative log-likelihood
|
| 226 |
+
of UNIFORM distribution = encourage low confidence)."""
|
| 227 |
+
total = torch.zeros((), device=residuals_per_block[0].device)
|
| 228 |
+
details = {}
|
| 229 |
+
for name, n_classes, target_block in self.var_specs:
|
| 230 |
+
res = residuals_per_block[target_block]
|
| 231 |
+
logits = self.probes[name](res) # [B, T, n_classes]
|
| 232 |
+
y = labels[name] # [B, T]
|
| 233 |
+
mask = y >= 0
|
| 234 |
+
if int(mask.sum()) == 0:
|
| 235 |
+
continue
|
| 236 |
+
ce = F.cross_entropy(
|
| 237 |
+
logits[mask],
|
| 238 |
+
y[mask],
|
| 239 |
+
reduction="mean",
|
| 240 |
+
)
|
| 241 |
+
total = total + ce
|
| 242 |
+
details[f"L_{name}_at_blk{target_block}"] = float(ce.item())
|
| 243 |
+
|
| 244 |
+
if anti_lambda > 0.0:
|
| 245 |
+
anti_terms = []
|
| 246 |
+
for b_idx, res_b in enumerate(residuals_per_block):
|
| 247 |
+
if b_idx == target_block:
|
| 248 |
+
continue
|
| 249 |
+
other_logits = self.probes[name](res_b)
|
| 250 |
+
# Want predicted distribution near uniform -> minimize KL
|
| 251 |
+
# to uniform = maximize entropy of softmax.
|
| 252 |
+
logp = F.log_softmax(other_logits, dim=-1)
|
| 253 |
+
p = logp.exp()
|
| 254 |
+
# Negative entropy of p (we want to MAXIMIZE entropy ->
|
| 255 |
+
# MINIMIZE negative entropy).
|
| 256 |
+
neg_ent = (p * logp).sum(dim=-1)
|
| 257 |
+
anti_terms.append(neg_ent[mask].mean())
|
| 258 |
+
if anti_terms:
|
| 259 |
+
anti = torch.stack(anti_terms).mean()
|
| 260 |
+
total = total + anti_lambda * anti
|
| 261 |
+
details[f"anti_{name}"] = float(anti.item())
|
| 262 |
+
return total, details
|
| 263 |
+
|
| 264 |
+
@torch.no_grad()
|
| 265 |
+
def probe_accs(self, residuals_per_block: List[torch.Tensor],
|
| 266 |
+
labels: Dict[str, torch.Tensor]) -> Dict[str, Dict[int, float]]:
|
| 267 |
+
out: Dict[str, Dict[int, float]] = {}
|
| 268 |
+
for name, n_classes, _ in self.var_specs:
|
| 269 |
+
y = labels[name]
|
| 270 |
+
mask = y >= 0
|
| 271 |
+
if int(mask.sum()) == 0:
|
| 272 |
+
continue
|
| 273 |
+
out[name] = {}
|
| 274 |
+
for b_idx, res in enumerate(residuals_per_block):
|
| 275 |
+
logits = self.probes[name](res)
|
| 276 |
+
pred = logits.argmax(dim=-1)
|
| 277 |
+
acc = float((pred[mask] == y[mask]).float().mean())
|
| 278 |
+
out[name][b_idx] = acc
|
| 279 |
+
return out
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
# ----------------------------- Forward helper ------------------------------
|
| 283 |
+
|
| 284 |
+
def head_entropy_loss(model: ByteGPT) -> torch.Tensor:
|
| 285 |
+
"""Penalize entropy of head-norm distribution per row of c_proj.
|
| 286 |
+
|
| 287 |
+
For each block, c_proj has shape [d_model, n_head * head_dim]. Reshape to
|
| 288 |
+
[d_model, n_head, head_dim] and compute per-row squared norms across heads.
|
| 289 |
+
Normalize per-row to get a head-attribution distribution. Penalize its
|
| 290 |
+
entropy — minimum (0) means each row written by ONE head.
|
| 291 |
+
"""
|
| 292 |
+
total = 0.0
|
| 293 |
+
n_blocks = 0
|
| 294 |
+
eps = 1e-9
|
| 295 |
+
n_head = model.config.n_head
|
| 296 |
+
for blk in model.transformer.h:
|
| 297 |
+
if not hasattr(blk.attn, "c_proj"):
|
| 298 |
+
continue
|
| 299 |
+
W = blk.attn.c_proj.weight
|
| 300 |
+
d_model = W.shape[0]
|
| 301 |
+
head_dim = W.shape[1] // n_head
|
| 302 |
+
if head_dim * n_head != W.shape[1]:
|
| 303 |
+
continue
|
| 304 |
+
blocks = W.view(d_model, n_head, head_dim)
|
| 305 |
+
head_norms_sq = (blocks ** 2).sum(dim=-1) # [d_model, n_head]
|
| 306 |
+
p = head_norms_sq / (head_norms_sq.sum(dim=-1, keepdim=True) + eps)
|
| 307 |
+
ent = -(p * (p + eps).log()).sum(dim=-1) # [d_model]
|
| 308 |
+
total = total + ent.mean()
|
| 309 |
+
n_blocks += 1
|
| 310 |
+
if n_blocks == 0:
|
| 311 |
+
return torch.zeros((), device=W.device)
|
| 312 |
+
return total / n_blocks
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
@torch.no_grad()
|
| 316 |
+
def evaluate_mixed_arithmetic(model: ByteGPT, device: str, n_problems: int,
|
| 317 |
+
max_digits: int, surfaces=("reverse", "forward"),
|
| 318 |
+
seed: int = 42) -> Dict[str, float]:
|
| 319 |
+
"""Greedy-decode `a+b=<marker>` and compare against ground truth for each
|
| 320 |
+
surface. Returns per-surface accuracies and a combined number."""
|
| 321 |
+
random.seed(seed)
|
| 322 |
+
torch.manual_seed(seed)
|
| 323 |
+
nl_id = ord("\n")
|
| 324 |
+
results: Dict[str, Dict[str, list]] = {s: {"correct": [], "by_len": {}}
|
| 325 |
+
for s in surfaces}
|
| 326 |
+
per_surface_n = max(1, n_problems // len(surfaces))
|
| 327 |
+
for surface in surfaces:
|
| 328 |
+
marker = "R" if surface == "reverse" else "F"
|
| 329 |
+
for _ in range(per_surface_n):
|
| 330 |
+
n_a = random.randint(1, max_digits)
|
| 331 |
+
n_b = random.randint(1, max_digits)
|
| 332 |
+
a = random.randint(0, 10 ** n_a - 1)
|
| 333 |
+
b = random.randint(0, 10 ** n_b - 1)
|
| 334 |
+
ans_int = a + b
|
| 335 |
+
ans_forward = str(ans_int)
|
| 336 |
+
true_emit = ans_forward[::-1] if surface == "reverse" else ans_forward
|
| 337 |
+
prompt = f"{a}+{b}={marker}"
|
| 338 |
+
ids = torch.tensor([list(prompt.encode("ascii"))],
|
| 339 |
+
dtype=torch.long, device=device)
|
| 340 |
+
max_new = len(true_emit) + 2
|
| 341 |
+
gen = bytearray()
|
| 342 |
+
for _ in range(max_new):
|
| 343 |
+
logits, _ = model(ids)
|
| 344 |
+
nxt = int(logits[0, -1].argmax())
|
| 345 |
+
if nxt == nl_id:
|
| 346 |
+
break
|
| 347 |
+
gen.append(nxt)
|
| 348 |
+
ids = torch.cat([ids, torch.tensor([[nxt]], device=device)],
|
| 349 |
+
dim=1)
|
| 350 |
+
try:
|
| 351 |
+
got = gen.decode("ascii", errors="ignore")
|
| 352 |
+
except Exception:
|
| 353 |
+
got = ""
|
| 354 |
+
is_correct = (got == true_emit)
|
| 355 |
+
results[surface]["correct"].append(1 if is_correct else 0)
|
| 356 |
+
n_digits = max(n_a, n_b)
|
| 357 |
+
bucket = results[surface]["by_len"].setdefault(n_digits, [0, 0])
|
| 358 |
+
bucket[1] += 1
|
| 359 |
+
if is_correct:
|
| 360 |
+
bucket[0] += 1
|
| 361 |
+
out = {}
|
| 362 |
+
total_correct, total_n = 0, 0
|
| 363 |
+
for s in surfaces:
|
| 364 |
+
c = sum(results[s]["correct"])
|
| 365 |
+
n = max(1, len(results[s]["correct"]))
|
| 366 |
+
out[f"acc_{s}"] = c / n
|
| 367 |
+
for k, (cc, tt) in sorted(results[s]["by_len"].items()):
|
| 368 |
+
out[f"acc_{s}_{k}d"] = cc / max(1, tt)
|
| 369 |
+
total_correct += c
|
| 370 |
+
total_n += n
|
| 371 |
+
out["accuracy"] = total_correct / max(1, total_n)
|
| 372 |
+
return out
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
def forward_with_block_captures(model: ByteGPT, x: torch.Tensor, y: torch.Tensor):
|
| 376 |
+
"""Run model and capture pre-block residuals + post-ln_f.
|
| 377 |
+
Returns (logits, lm_loss, residuals_per_block).
|
| 378 |
+
residuals_per_block[i] is the input to block i (= output of block i-1).
|
| 379 |
+
"""
|
| 380 |
+
captures: List[torch.Tensor] = [None] * model.config.n_layer
|
| 381 |
+
handles = []
|
| 382 |
+
for i, blk in enumerate(model.transformer.h):
|
| 383 |
+
def make_hook(idx):
|
| 384 |
+
def hook(module, inputs):
|
| 385 |
+
captures[idx] = inputs[0]
|
| 386 |
+
return hook
|
| 387 |
+
handles.append(blk.register_forward_pre_hook(make_hook(i)))
|
| 388 |
+
try:
|
| 389 |
+
logits, lm_loss = model(x, y)
|
| 390 |
+
finally:
|
| 391 |
+
for h in handles:
|
| 392 |
+
h.remove()
|
| 393 |
+
return logits, lm_loss, captures
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
# ----------------------------- Training ------------------------------------
|
| 397 |
+
|
| 398 |
+
@dataclass
|
| 399 |
+
class TrainConfig:
|
| 400 |
+
n_layer: int = 4
|
| 401 |
+
n_head: int = 4
|
| 402 |
+
n_embd: int = 128
|
| 403 |
+
mlp_mult: int = 4
|
| 404 |
+
mlp_activation: str = "silu"
|
| 405 |
+
seq_len: int = 128
|
| 406 |
+
attn_kind: str = "mha"
|
| 407 |
+
attn_norm: str = "entmax15"
|
| 408 |
+
position_encoding: str = "alibi"
|
| 409 |
+
loss: str = "bce"
|
| 410 |
+
logit_rmsnorm_scale: float = 8.0
|
| 411 |
+
sparsity_lambda: float = 1e-3
|
| 412 |
+
# A4: Attention Residuals depth-router + entropy pressure, composed
|
| 413 |
+
# with the BP-install probe-loss. The entropy term is added to
|
| 414 |
+
# lm_loss inside ByteGPT.forward; install-loss supplies the correct
|
| 415 |
+
# support. See docs/attnres-bp-research-program.md A2′-result / A4.
|
| 416 |
+
res_attn: str = "none"
|
| 417 |
+
res_attn_norm: str = "softmax"
|
| 418 |
+
res_attn_temp: float = 1.0
|
| 419 |
+
res_attn_entropy_lambda: float = 0.0
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
def build_model(tc: TrainConfig, device: str) -> ByteGPT:
|
| 423 |
+
cfg = GPTConfig(
|
| 424 |
+
vocab_size=256,
|
| 425 |
+
n_layer=tc.n_layer,
|
| 426 |
+
n_head=tc.n_head,
|
| 427 |
+
n_embd=tc.n_embd,
|
| 428 |
+
seq_len=tc.seq_len,
|
| 429 |
+
mlp_mult=tc.mlp_mult,
|
| 430 |
+
mlp_activation=tc.mlp_activation,
|
| 431 |
+
attn_kind=tc.attn_kind,
|
| 432 |
+
attn_norm=tc.attn_norm,
|
| 433 |
+
position_encoding=tc.position_encoding,
|
| 434 |
+
loss_kind=tc.loss,
|
| 435 |
+
logit_rmsnorm_scale=tc.logit_rmsnorm_scale,
|
| 436 |
+
sparsity_lambda=tc.sparsity_lambda,
|
| 437 |
+
res_attn=tc.res_attn,
|
| 438 |
+
res_attn_norm=tc.res_attn_norm,
|
| 439 |
+
res_attn_temp=tc.res_attn_temp,
|
| 440 |
+
res_attn_entropy_lambda=tc.res_attn_entropy_lambda,
|
| 441 |
+
)
|
| 442 |
+
return ByteGPT(cfg).to(device)
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
def main() -> None:
|
| 446 |
+
ap = argparse.ArgumentParser(description=__doc__)
|
| 447 |
+
ap.add_argument("--steps", type=int, default=8000)
|
| 448 |
+
ap.add_argument("--batch-size", type=int, default=64)
|
| 449 |
+
ap.add_argument("--seq-len", type=int, default=128)
|
| 450 |
+
ap.add_argument("--lr", type=float, default=3e-3)
|
| 451 |
+
ap.add_argument("--lr-min", type=float, default=3e-4)
|
| 452 |
+
ap.add_argument("--warmup-steps", type=int, default=500)
|
| 453 |
+
ap.add_argument("--weight-decay", type=float, default=0.01)
|
| 454 |
+
ap.add_argument("--max-digits", type=int, default=3)
|
| 455 |
+
ap.add_argument("--seed", type=int, default=0)
|
| 456 |
+
ap.add_argument("--surfaces", nargs="+", default=["reverse"],
|
| 457 |
+
choices=["reverse", "forward"],
|
| 458 |
+
help="Surface forms to sample (uniform).")
|
| 459 |
+
ap.add_argument("--eval-every", type=int, default=1000)
|
| 460 |
+
ap.add_argument("--n-eval-arith", type=int, default=200)
|
| 461 |
+
ap.add_argument("--target-carry-block", type=int, default=2)
|
| 462 |
+
ap.add_argument("--target-sum-block", type=int, default=3)
|
| 463 |
+
ap.add_argument("--install-lambda", type=float, default=1.0,
|
| 464 |
+
help="Coefficient for BP-install probe loss.")
|
| 465 |
+
ap.add_argument("--anti-lambda", type=float, default=0.0,
|
| 466 |
+
help="Coefficient for anti-spread loss (push non-target "
|
| 467 |
+
"block probes to uniform).")
|
| 468 |
+
ap.add_argument("--head-entropy-lambda", type=float, default=0.0,
|
| 469 |
+
help="Coefficient for head specialization loss: entropy of "
|
| 470 |
+
"head-norm distribution across rows of c_proj. "
|
| 471 |
+
"Minimize -> each row written by one head.")
|
| 472 |
+
ap.add_argument("--res-attn", choices=["none", "full"], default="none",
|
| 473 |
+
help="A4: 'full' = block-granular Attention Residuals "
|
| 474 |
+
"(depth-router) composed with BP-install supervision.")
|
| 475 |
+
ap.add_argument("--res-attn-norm",
|
| 476 |
+
choices=["softmax", "entmax15", "sparsemax"],
|
| 477 |
+
default="softmax",
|
| 478 |
+
help="Depth-attention normalization (res-attn only).")
|
| 479 |
+
ap.add_argument("--res-attn-temp", type=float, default=1.0,
|
| 480 |
+
help="A2′ fixed depth-score temperature (res-attn only).")
|
| 481 |
+
ap.add_argument("--res-attn-entropy-lambda", type=float, default=0.0,
|
| 482 |
+
help="A2′ entropy penalty on α_depth (res-attn only); "
|
| 483 |
+
"supplies sparsity, install-loss supplies support.")
|
| 484 |
+
ap.add_argument("--n-layer", type=int, default=4)
|
| 485 |
+
ap.add_argument("--n-head", type=int, default=4)
|
| 486 |
+
ap.add_argument("--n-embd", type=int, default=128)
|
| 487 |
+
ap.add_argument("--checkpoint-dir", type=Path, required=True)
|
| 488 |
+
ap.add_argument("--output", type=Path, required=True)
|
| 489 |
+
ap.add_argument("--save-every", type=int, default=2000)
|
| 490 |
+
args = ap.parse_args()
|
| 491 |
+
|
| 492 |
+
random.seed(args.seed)
|
| 493 |
+
torch.manual_seed(args.seed)
|
| 494 |
+
|
| 495 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 496 |
+
tc = TrainConfig(seq_len=args.seq_len, n_layer=args.n_layer,
|
| 497 |
+
n_head=args.n_head, n_embd=args.n_embd,
|
| 498 |
+
res_attn=args.res_attn, res_attn_norm=args.res_attn_norm,
|
| 499 |
+
res_attn_temp=args.res_attn_temp,
|
| 500 |
+
res_attn_entropy_lambda=args.res_attn_entropy_lambda)
|
| 501 |
+
model = build_model(tc, device)
|
| 502 |
+
if tc.res_attn != "none":
|
| 503 |
+
print(f"# AttnRes: {tc.res_attn}/{tc.res_attn_norm} "
|
| 504 |
+
f"temp={tc.res_attn_temp} entropy_lambda={tc.res_attn_entropy_lambda}")
|
| 505 |
+
|
| 506 |
+
var_specs = default_var_specs(args.max_digits, args.target_carry_block,
|
| 507 |
+
args.target_sum_block)
|
| 508 |
+
probes = BPProbeHeads(tc.n_embd, var_specs).to(device)
|
| 509 |
+
corpus = ArithmeticBPCorpus(args.max_digits, var_specs=var_specs,
|
| 510 |
+
surfaces=tuple(args.surfaces))
|
| 511 |
+
|
| 512 |
+
# Optimizer: include both model and probe params
|
| 513 |
+
all_params = list(model.parameters()) + list(probes.parameters())
|
| 514 |
+
optimizer = torch.optim.AdamW(all_params, lr=args.lr,
|
| 515 |
+
weight_decay=args.weight_decay,
|
| 516 |
+
betas=(0.9, 0.95))
|
| 517 |
+
|
| 518 |
+
def cur_lr(step):
|
| 519 |
+
if step < args.warmup_steps:
|
| 520 |
+
return args.lr * step / max(1, args.warmup_steps)
|
| 521 |
+
progress = (step - args.warmup_steps) / max(1, args.steps - args.warmup_steps)
|
| 522 |
+
progress = min(1.0, max(0.0, progress))
|
| 523 |
+
return args.lr_min + 0.5 * (args.lr - args.lr_min) * (1 + math.cos(math.pi * progress))
|
| 524 |
+
|
| 525 |
+
args.checkpoint_dir.mkdir(parents=True, exist_ok=True)
|
| 526 |
+
history: List[Dict] = []
|
| 527 |
+
|
| 528 |
+
print(f"# device={device} n_layer={tc.n_layer} n_head={tc.n_head} n_embd={tc.n_embd}")
|
| 529 |
+
print(f"# BP supervision: λ_install={args.install_lambda} λ_anti={args.anti_lambda}")
|
| 530 |
+
print(f"# Targets: carry→blk{args.target_carry_block}, sum→blk{args.target_sum_block}")
|
| 531 |
+
print(f"# Variables supervised: {[s[0] for s in var_specs]}")
|
| 532 |
+
|
| 533 |
+
last_train_loss = None
|
| 534 |
+
last_lm_loss = None
|
| 535 |
+
last_install_loss = None
|
| 536 |
+
for step in range(1, args.steps + 1):
|
| 537 |
+
model.train()
|
| 538 |
+
probes.train()
|
| 539 |
+
lr = cur_lr(step)
|
| 540 |
+
for pg in optimizer.param_groups:
|
| 541 |
+
pg["lr"] = lr
|
| 542 |
+
|
| 543 |
+
x, y, bp_labels = corpus.sample_batch(args.batch_size, args.seq_len, device)
|
| 544 |
+
optimizer.zero_grad(set_to_none=True)
|
| 545 |
+
_, lm_loss, captures = forward_with_block_captures(model, x, y)
|
| 546 |
+
install_loss, details = probes.loss(captures, bp_labels,
|
| 547 |
+
anti_lambda=args.anti_lambda)
|
| 548 |
+
total = lm_loss + args.install_lambda * install_loss
|
| 549 |
+
if args.head_entropy_lambda > 0:
|
| 550 |
+
head_ent = head_entropy_loss(model)
|
| 551 |
+
total = total + args.head_entropy_lambda * head_ent
|
| 552 |
+
last_head_ent = float(head_ent.item())
|
| 553 |
+
else:
|
| 554 |
+
last_head_ent = 0.0
|
| 555 |
+
total.backward()
|
| 556 |
+
torch.nn.utils.clip_grad_norm_(all_params, 1.0)
|
| 557 |
+
optimizer.step()
|
| 558 |
+
last_train_loss = float(total.item())
|
| 559 |
+
last_lm_loss = float(lm_loss.item())
|
| 560 |
+
last_install_loss = float(install_loss.item())
|
| 561 |
+
|
| 562 |
+
if step == 1 or step % args.eval_every == 0 or step == args.steps:
|
| 563 |
+
model.eval()
|
| 564 |
+
probes.eval()
|
| 565 |
+
# Compute val loss + bpb on fresh batch
|
| 566 |
+
with torch.no_grad():
|
| 567 |
+
vx, vy, vbp = corpus.sample_batch(args.batch_size, args.seq_len, device)
|
| 568 |
+
vlogits, vlm_loss, vcaps = forward_with_block_captures(model, vx, vy)
|
| 569 |
+
vce = F.cross_entropy(vlogits.view(-1, vlogits.size(-1)),
|
| 570 |
+
vy.reshape(-1))
|
| 571 |
+
vbpb = float(bits_per_byte(vce))
|
| 572 |
+
# Probe accuracies across all blocks (diagnostic)
|
| 573 |
+
accs = probes.probe_accs(vcaps, vbp)
|
| 574 |
+
arith = evaluate_mixed_arithmetic(
|
| 575 |
+
model, device, n_problems=args.n_eval_arith,
|
| 576 |
+
max_digits=args.max_digits,
|
| 577 |
+
surfaces=tuple(args.surfaces),
|
| 578 |
+
)
|
| 579 |
+
entry = {
|
| 580 |
+
"step": step,
|
| 581 |
+
"lr": lr,
|
| 582 |
+
"train_loss": last_train_loss,
|
| 583 |
+
"lm_loss": last_lm_loss,
|
| 584 |
+
"install_loss": last_install_loss,
|
| 585 |
+
"head_ent": last_head_ent,
|
| 586 |
+
"val_lm": float(vlm_loss.item()),
|
| 587 |
+
"val_ce": float(vce.item()),
|
| 588 |
+
"val_bpb": vbpb,
|
| 589 |
+
"arith_acc": arith["accuracy"],
|
| 590 |
+
"probe_accs": accs,
|
| 591 |
+
}
|
| 592 |
+
history.append(entry)
|
| 593 |
+
line = (f"step={step:>5d} lr={lr:.2e} "
|
| 594 |
+
f"lm={last_lm_loss:.3f} install={last_install_loss:.3f} "
|
| 595 |
+
f"head_ent={last_head_ent:.3f} "
|
| 596 |
+
f"val_bpb={vbpb:.3f} arith_acc={arith['accuracy']:.3f}")
|
| 597 |
+
print(line)
|
| 598 |
+
# Compact probe summary: install variable at its target block + spread
|
| 599 |
+
for name, n_cls, tgt in var_specs:
|
| 600 |
+
if name in accs:
|
| 601 |
+
row = accs[name]
|
| 602 |
+
target_acc = row.get(tgt, float("nan"))
|
| 603 |
+
others = [v for k, v in row.items() if k != tgt]
|
| 604 |
+
spread = (sum(others) / len(others)) if others else 0.0
|
| 605 |
+
if step == args.steps or step % (args.eval_every * 4) == 1 or step == 1:
|
| 606 |
+
print(f" {name}: blk{tgt}={target_acc:.3f} "
|
| 607 |
+
f"avg-other={spread:.3f} delta={target_acc-spread:+.3f}")
|
| 608 |
+
|
| 609 |
+
if (args.save_every and step % args.save_every == 0) or step == args.steps:
|
| 610 |
+
ck = args.checkpoint_dir / f"bp_sup_step_{step}.pt"
|
| 611 |
+
torch.save({
|
| 612 |
+
"model_state": model.state_dict(),
|
| 613 |
+
"model_config": vars(model.config),
|
| 614 |
+
"probe_state": probes.state_dict(),
|
| 615 |
+
"var_specs": var_specs,
|
| 616 |
+
"step": step,
|
| 617 |
+
}, ck)
|
| 618 |
+
torch.save({"model_state": model.state_dict(),
|
| 619 |
+
"model_config": vars(model.config),
|
| 620 |
+
"probe_state": probes.state_dict(),
|
| 621 |
+
"var_specs": var_specs,
|
| 622 |
+
"step": step}, args.checkpoint_dir / "bp_sup_latest.pt")
|
| 623 |
+
|
| 624 |
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
| 625 |
+
args.output.write_text(json.dumps({
|
| 626 |
+
"config": vars(args) | {"checkpoint_dir": str(args.checkpoint_dir), "output": str(args.output)},
|
| 627 |
+
"var_specs": var_specs,
|
| 628 |
+
"history": history,
|
| 629 |
+
}, indent=2, default=str))
|
| 630 |
+
|
| 631 |
+
|
| 632 |
+
if __name__ == "__main__":
|
| 633 |
+
main()
|
weights_r0.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:9451db167738bd8b84e25ffc1944af16f14c91a721f39f8257e23b0646ac7734
|
| 3 |
+
size 43512279
|
weights_r1.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:6a405c8aeb8bc1d5beb451cc619e7e0c55bd0807b636fae9b7a438c6fe2c5dca
|
| 3 |
+
size 19709291
|