Skip to content

Commit cee164c

Browse files
committed
transformerless_lm: LM-head compression sweep across 3 substrate schemes
Tests whether language is low-Zeckendorf-rank by SVD-compressing a trained transformer's LM head and measuring perplexity preservation. Per the user's observation that the substrate's canonical operation in phi_pi_fib.rs is NOT bare Fibonacci but the cross-product F(k) / phi^(pi*k), the bench compares three substrate-aligned index-selection schemes against the Eckart-Young optimal top_k and a random-K control: fib_pure : pure Fibonacci positions [0,1,2,3,5,8,13,21,...] = the simplest substrate basis fib_phi_pi : floor(F(k) * pi / phi) [0,1,3,5,9,15,25,40,...] = Fibonacci pushed outward by pi/phi cross-mult phi_pi_canonical : floor(n * F(k) / phi^(pi*k)) [0,2,6,14,...] = the canonical substrate split-points from PHI_PI_FIB_ALGORITHM.md, scaled to rank range All schemes compared at MATCHED n_keep (= matched memory). The hypothesis: if language structure is preferentially low-rank along the substrate basis, fib_pure and/or fib_phi_pi and/or phi_pi_canonical beat rand_k and approach top_k. If they all indistinguishable from rand_k, the substrate basis is not special for the LM head and the inference-first compression story has no foothold at this layer. Setup: crt_only baseline (validated arch), 1500 steps on TinyShakespeare distractor mix. LM head W of shape [65, 128] = 8320 floats. Sweep n_keep in {2,3,4,5,6,8,10,13,16,21,28,34,45,55}. Results JSON includes the actual indices selected per scheme so the substrate basis choice is auditable.
1 parent 79c5508 commit cee164c

1 file changed

Lines changed: 326 additions & 0 deletions

File tree

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
"""LM-head Zeckendorf-rank compression test.
2+
3+
The architectural question: is language low-Zeckendorf-rank?
4+
5+
If YES, the substrate's compression primitive is the right axis for
6+
building inference-cheap LLMs (the inference-first re-derivation in
7+
INFERENCE_FIRST_DERIVATION.md). If NO, we need a different basis.
8+
9+
Test design:
10+
11+
1. Train a `crt_only` baseline on TinyShakespeare (validated arch
12+
from the prior bench, ~800K params, mean val 2.46).
13+
2. Extract its LM head W ∈ R^[vocab, d_model]. Compute the full SVD
14+
W = U Σ V^T.
15+
3. Build three rank-K approximations Ŵ at varying K, all using the
16+
SAME total memory K·(vocab + d_model):
17+
- top_k: first K singular components (Eckart-Young optimal).
18+
- fib_k: singular components at Fibonacci indices ≤ K.
19+
- rand_k: uniformly-random K indices from [0, min_dim).
20+
4. For each Ŵ, swap into the model and measure val perplexity.
21+
22+
Hypothesis: if Fibonacci-indexed singular components carry
23+
disproportionately more language structure than random ones, then
24+
fib_k > rand_k (closer to top_k) at matched K. If fib_k ≈ rand_k,
25+
language is NOT preferentially low-Zeckendorf-rank and the substrate
26+
compression story has no foothold at the LM head layer.
27+
28+
The result is a yes/no signal for the broader inference-first thesis.
29+
"""
30+
31+
import argparse
32+
import json
33+
import math
34+
import sys
35+
import time
36+
from pathlib import Path
37+
38+
import torch
39+
import torch.nn.functional as F
40+
41+
sys.path.insert(0, str(Path(__file__).parent))
42+
from corpus import make_dataset
43+
from models import make_model
44+
from train_distractor_mix import (
45+
build_distractor_stream,
46+
get_batch_split,
47+
evaluate,
48+
)
49+
50+
# Canonical Fibonacci table from omnimcode-core/src/phi_pi_fib.rs.
51+
FIBONACCI = [1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987,
52+
1597, 2584, 4181, 6765, 10946, 17711, 28657, 46368]
53+
54+
PHI = (1 + 5 ** 0.5) / 2 # golden ratio
55+
PI = math.pi
56+
PHI_PI = PHI ** PI # ≈ 36.46, the substrate exponent base
57+
58+
# ---- Scheme 1: pure Fibonacci ----
59+
# Indices = {0} ∪ {unique positive Fibonacci numbers}.
60+
FIB_PURE_INDICES = sorted(set([0] + FIBONACCI))
61+
62+
63+
# ---- Scheme 2: π/φ-modulated Fibonacci ----
64+
# F(k) · π / φ. Pushes Fibonacci values outward by ~1.94×. The user
65+
# observation: if φ is the derivation and Fibonacci is the basis, then
66+
# the natural cross-multiplication with π is the next substrate term.
67+
FIB_PHI_PI_INDICES = sorted(set([0] + [
68+
int(f * PI / PHI) for f in FIBONACCI
69+
] + [int(f * PI / PHI) for f in FIBONACCI if int(f * PI / PHI) > 0]))
70+
71+
72+
def phi_pi_canonical_indices(n_components: int, n_terms: int = 24) -> list[int]:
73+
"""Substrate-canonical split-point offsets, scaled to the SVD rank range.
74+
75+
Mirrors the formula in PHI_PI_FIB_ALGORITHM.md:
76+
offset(k) = n · F(k) / φ^(π·k)
77+
These cluster near 0 with rapidly diminishing reach — the same
78+
probe pattern phi_pi_fib_search_v2 uses on a sorted array.
79+
80+
Returns sorted unique indices in [0, n_components).
81+
"""
82+
offs = set([0])
83+
for k in range(1, n_terms + 1):
84+
Fk = FIBONACCI[k - 1] if k - 1 < len(FIBONACCI) else FIBONACCI[-1]
85+
idx = int(n_components * Fk / (PHI ** (PI * k)))
86+
if 0 <= idx < n_components:
87+
offs.add(idx)
88+
return sorted(offs)
89+
90+
91+
def compress_lm_head(W: torch.Tensor, n_keep: int, scheme: str,
92+
rng: torch.Generator) -> tuple[torch.Tensor, list[int]]:
93+
"""Build an approximation of W keeping `n_keep` SVD components selected
94+
by the chosen scheme. Returns (Ŵ, indices_kept).
95+
96+
All three schemes use the SAME n_keep, so memory footprint is
97+
identical: n_keep · (W.shape[0] + W.shape[1]) floats.
98+
"""
99+
U, S, Vh = torch.linalg.svd(W, full_matrices=False)
100+
n_components = S.numel()
101+
def _fill(candidates: list[int]) -> list[int]:
102+
"""Take first n_keep candidates; pad with dense indices if short."""
103+
idx = [c for c in candidates if 0 <= c < n_components][:n_keep]
104+
if len(idx) < n_keep:
105+
for i in range(n_components):
106+
if i not in idx:
107+
idx.append(i)
108+
if len(idx) >= n_keep:
109+
break
110+
return sorted(idx)
111+
112+
if scheme == "top_k":
113+
idx = list(range(min(n_keep, n_components)))
114+
elif scheme == "fib_pure":
115+
idx = _fill(FIB_PURE_INDICES)
116+
elif scheme == "fib_phi_pi":
117+
idx = _fill(FIB_PHI_PI_INDICES)
118+
elif scheme == "phi_pi_canonical":
119+
idx = _fill(phi_pi_canonical_indices(n_components))
120+
elif scheme == "rand_k":
121+
perm = torch.randperm(n_components, generator=rng).tolist()
122+
idx = sorted(perm[:n_keep])
123+
else:
124+
raise ValueError(scheme)
125+
126+
idx_t = torch.tensor(idx, dtype=torch.long)
127+
U_k = U[:, idx_t]
128+
S_k = S[idx_t]
129+
Vh_k = Vh[idx_t, :]
130+
W_approx = (U_k * S_k) @ Vh_k
131+
return W_approx, idx
132+
133+
134+
def measure_val_perplexity(model, val_split, batch_size, seq_len,
135+
n_batches=32, generator=None):
136+
losses = []
137+
model.eval()
138+
with torch.no_grad():
139+
for _ in range(n_batches):
140+
x, y = get_batch_split(val_split, batch_size, seq_len, generator)
141+
logits = model(x)
142+
loss = F.cross_entropy(
143+
logits.reshape(-1, logits.size(-1)),
144+
y.reshape(-1),
145+
)
146+
losses.append(loss.item())
147+
model.train()
148+
return sum(losses) / len(losses)
149+
150+
151+
def train_baseline(args, vocab_size, train_split, val_split):
152+
"""Train a fresh crt_only baseline and return the model."""
153+
torch.manual_seed(args.seed)
154+
gen = torch.Generator()
155+
gen.manual_seed(args.seed + 1)
156+
model = make_model(
157+
"crt_only", vocab_size=vocab_size, seq_len=args.seq_len,
158+
d_model=args.d_model, n_blocks=args.n_blocks,
159+
)
160+
n_params = sum(p.numel() for p in model.parameters())
161+
optimizer = torch.optim.AdamW(model.parameters(), lr=args.lr)
162+
print(f"\n[baseline crt_only] params={n_params:,}", flush=True)
163+
t0 = time.time()
164+
for step in range(args.steps):
165+
x, y = get_batch_split(train_split, args.batch_size, args.seq_len, gen)
166+
logits = model(x)
167+
loss = F.cross_entropy(
168+
logits.reshape(-1, logits.size(-1)),
169+
y.reshape(-1),
170+
)
171+
optimizer.zero_grad()
172+
loss.backward()
173+
optimizer.step()
174+
if step % args.eval_every == 0 or step == args.steps - 1:
175+
vl = measure_val_perplexity(model, val_split, args.batch_size,
176+
args.seq_len, n_batches=16, generator=gen)
177+
elapsed = time.time() - t0
178+
print(f" step {step:5d} train={loss.item():.4f} val={vl:.4f} ({elapsed:.1f}s)",
179+
flush=True)
180+
return model
181+
182+
183+
def main():
184+
parser = argparse.ArgumentParser()
185+
parser.add_argument("--steps", type=int, default=1500)
186+
parser.add_argument("--batch-size", type=int, default=32)
187+
parser.add_argument("--seq-len", type=int, default=128)
188+
parser.add_argument("--d-model", type=int, default=128)
189+
parser.add_argument("--n-blocks", type=int, default=4)
190+
parser.add_argument("--lr", type=float, default=3e-4)
191+
parser.add_argument("--eval-every", type=int, default=300)
192+
parser.add_argument("--seed", type=int, default=42)
193+
parser.add_argument("--n-rand-trials", type=int, default=5,
194+
help="Random rank-K runs to average for the rand_k baseline.")
195+
parser.add_argument("--distractor-frac", type=float, default=0.20)
196+
parser.add_argument("--out", type=str, default="results_lm_head_compression.json")
197+
args = parser.parse_args()
198+
199+
chars, stoi, itos, encoded = make_dataset(
200+
seq_len=args.seq_len, source="tinyshakespeare",
201+
)
202+
vocab_size = len(chars)
203+
204+
print(f"LM-head Zeckendorf-rank compression test")
205+
print(f"Corpus: TinyShakespeare ({encoded.numel():,} chars, vocab {vocab_size})")
206+
print(f"Model: d_model={args.d_model}, n_blocks={args.n_blocks}, "
207+
f"seq_len={args.seq_len}", flush=True)
208+
209+
train_split, val_split = build_distractor_stream(
210+
encoded, args.distractor_frac, args.seq_len, args.seed,
211+
)
212+
213+
# ---- 1. Train baseline ----
214+
model = train_baseline(args, vocab_size, train_split, val_split)
215+
gen = torch.Generator()
216+
gen.manual_seed(args.seed + 1)
217+
baseline_val = measure_val_perplexity(
218+
model, val_split, args.batch_size, args.seq_len, n_batches=32, generator=gen,
219+
)
220+
print(f"\nBaseline val loss (full LM head): {baseline_val:.4f}")
221+
222+
# ---- 2. Extract LM head ----
223+
# The model ties head.weight to embed.weight, so we work on a copy.
224+
W_orig = model.head.weight.detach().clone() # [vocab, d_model]
225+
print(f"\nLM head shape: {tuple(W_orig.shape)}, total params: {W_orig.numel():,}")
226+
print(f"Full-rank memory: {W_orig.numel() * 4:,} bytes (fp32)")
227+
228+
# ---- 3. Sweep K, compare schemes ----
229+
# K values to test. We use {1, 2, 3, 5, 8, 13, 21, 34, 55} (Fibonacci) +
230+
# interpolating dense values so every K is comparable.
231+
min_dim = min(W_orig.shape)
232+
# n_keep values where Fibonacci has a "natural" footprint. Including
233+
# in-between values lets us see whether the substrate ordering is
234+
# better than top-rank or just lucky at specific points.
235+
K_values = sorted(set([2, 3, 4, 5, 6, 8, 10, 13, 16, 21, 28, 34, 45, 55]))
236+
K_values = [k for k in K_values if k < min_dim]
237+
238+
rng = torch.Generator()
239+
rng.manual_seed(args.seed + 100)
240+
241+
results = []
242+
for K in K_values:
243+
compression_ratio = W_orig.numel() / (K * (W_orig.shape[0] + W_orig.shape[1]))
244+
print(f"\n--- K={K} (compression ratio: {compression_ratio:.2f}x) ---")
245+
row = {"K": K, "compression": compression_ratio, "baseline_val": baseline_val}
246+
247+
for scheme in ["top_k", "fib_pure", "fib_phi_pi", "phi_pi_canonical"]:
248+
W_approx, idx = compress_lm_head(W_orig, K, scheme, rng)
249+
with torch.no_grad():
250+
model.head.weight.copy_(W_approx)
251+
# Embedding is tied — copy through.
252+
model.embed.weight.copy_(W_approx)
253+
val = measure_val_perplexity(
254+
model, val_split, args.batch_size, args.seq_len,
255+
n_batches=32, generator=gen,
256+
)
257+
row[scheme] = {"val": val, "indices": idx}
258+
print(f" {scheme:<8} val={val:.4f} Δ={val - baseline_val:+.4f} "
259+
f"indices={idx[:6]}{'...' if len(idx) > 6 else ''}")
260+
261+
# rand_k: average over multiple trials
262+
rand_vals = []
263+
rand_idx_samples = []
264+
for trial in range(args.n_rand_trials):
265+
W_approx, idx = compress_lm_head(W_orig, K, "rand_k", rng)
266+
with torch.no_grad():
267+
model.head.weight.copy_(W_approx)
268+
model.embed.weight.copy_(W_approx)
269+
val = measure_val_perplexity(
270+
model, val_split, args.batch_size, args.seq_len,
271+
n_batches=16, generator=gen,
272+
)
273+
rand_vals.append(val)
274+
rand_idx_samples.append(idx[:6])
275+
row["rand_k"] = {
276+
"val_mean": sum(rand_vals)/len(rand_vals),
277+
"val_std": (sum((v - sum(rand_vals)/len(rand_vals))**2 for v in rand_vals) / len(rand_vals))**0.5,
278+
"vals": rand_vals,
279+
}
280+
print(f" {'rand_k':<8} val={row['rand_k']['val_mean']:.4f} "
281+
f"(std {row['rand_k']['val_std']:.4f}, n={args.n_rand_trials}) "
282+
f"Δ={row['rand_k']['val_mean'] - baseline_val:+.4f}")
283+
284+
results.append(row)
285+
286+
# Restore full-rank head before returning (so subsequent code can use the model).
287+
with torch.no_grad():
288+
model.head.weight.copy_(W_orig)
289+
model.embed.weight.copy_(W_orig)
290+
291+
# ---- 4. Summary ----
292+
print()
293+
print("=" * 110)
294+
schemes = ["top_k", "fib_pure", "fib_phi_pi", "phi_pi_canonical"]
295+
print(f"{'K':>4} {'compress':>10} " + " ".join(f"{s:>15}" for s in schemes)
296+
+ f" {'rand_k':>16}")
297+
print("-" * 110)
298+
for row in results:
299+
rand = row["rand_k"]
300+
rs = f"{rand['val_mean']:.4f}±{rand['val_std']:.3f}"
301+
cells = " ".join(f"{row[s]['val']:>15.4f}" for s in schemes)
302+
print(f"{row['K']:>4} {row['compression']:>9.2f}x {cells} {rs:>16}")
303+
304+
print()
305+
print("Interpretation:")
306+
for s in ("fib_pure", "fib_phi_pi", "phi_pi_canonical"):
307+
better = sum(1 for r in results if r[s]["val"] < r["rand_k"]["val_mean"])
308+
gap_top = sum(r[s]["val"] - r["top_k"]["val"] for r in results) / len(results)
309+
gap_rand = sum(r[s]["val"] - r["rand_k"]["val_mean"] for r in results) / len(results)
310+
print(f" {s:<18} beats rand at {better}/{len(results)} Ks "
311+
f"mean Δ vs top_k:{gap_top:+.4f} mean Δ vs rand:{gap_rand:+.4f}")
312+
313+
# Save
314+
out_path = Path(__file__).parent / args.out
315+
with open(out_path, "w") as f:
316+
json.dump({
317+
"baseline_val": baseline_val,
318+
"W_shape": list(W_orig.shape),
319+
"K_values": K_values,
320+
"results": results,
321+
}, f, indent=2, default=str)
322+
print(f"\nWrote {out_path}")
323+
324+
325+
if __name__ == "__main__":
326+
main()

0 commit comments

Comments
 (0)