|
| 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