Adaptive Householder Gradient Subspaces / adaptive_bench.py
Mechanism confirmed, baseline not beaten
1import json, math, sys, time
2from pathlib import Path
3import numpy as np
4import torch
5from torch import nn
6
7sys.path.insert(0, "/home/maxwelhelp/all/math2nn")
8from bench import get_dataset, make_model, evaluate, sweep_baseline, make_report
9
10SEEDS = tuple(range(8))
11LR_GRID = [0.001, 0.003, 0.006]
12# These are fixed before running: adaptive tolerance, Gaussian block, and max rank.
13IDEA_GRID = [{"lr": lr, "rel_tol": 0.30, "block": 4, "kmax": 16} for lr in LR_GRID]
14EPOCHS = 12
15BATCH = 128
16
17
18def adaptive_subspace(g, rel_tol=0.30, block=4, kmax=16, generator=None):
19 """Adaptive randomized range finder for a 2-D gradient matrix.
20
21 QR is the numerically stable Householder implementation in torch.linalg.qr.
22 The returned Q is explicit for this small MVP; production code would retain
23 reflector factors and apply them implicitly.
24 """
25 m, n = g.shape
26 kmax = min(int(kmax), m, n)
27 initial = torch.sum(g * g).detach()
28 residual = g.clone()
29 blocks = []
30 energy = initial
31 while float(energy) > float(initial) * rel_tol * rel_tol and sum(q.shape[1] for q in blocks) < kmax:
32 b = min(int(block), kmax - sum(q.shape[1] for q in blocks))
33 omega = torch.randn((n, b), device=g.device, dtype=g.dtype, generator=generator)
34 y = residual @ omega
35 # Residual construction makes the new block orthogonal to prior blocks;
36 # QR itself is Householder-stable within the block.
37 q, _ = torch.linalg.qr(y, mode="reduced")
38 if q.shape[1] == 0:
39 break
40 blocks.append(q)
41 residual = residual - q @ (q.T @ residual)
42 energy = torch.sum(residual * residual).detach()
43 if not blocks:
44 return torch.zeros((m, 0), device=g.device, dtype=g.dtype), float(initial), 0.0
45 q = torch.cat(blocks, dim=1)
46 return q, float(torch.sum(residual * residual)), float(torch.linalg.norm(q.T @ q - torch.eye(q.shape[1], device=g.device, dtype=g.dtype)))
47
48
49def train_one(seed, cfg, idea):
50 np.random.seed(seed); torch.manual_seed(seed)
51 # Explicit CPU default avoids shared-GPU contention; CUDA fallback is safe.
52 device = "cuda" if torch.cuda.is_available() else "cpu"
53 try:
54 d = get_dataset("tabular", seed=seed, n_train=400, n_test=200)
55 net = make_model("mlp_tiny", d["input_shape"], d["out_dim"]).to(device)
56 xtr, ytr = d["xtr"].to(device), d["ytr"].to(device)
57 xte, yte = d["xte"].to(device), d["yte"].to(device)
58 params = list(net.parameters())
59 # Ordinary Adam moments are baseline; idea stores moments for projected
60 # gradients only (equivalent reduced-coordinate Adam for each refresh).
61 opt = torch.optim.Adam(params, lr=float(cfg["lr"]))
62 rng = torch.Generator(device=device); rng.manual_seed(seed + 991)
63 last_orth, ranks, proj_ratios = [], [], []
64 net.train()
65 for ep in range(EPOCHS):
66 order = torch.randperm(xtr.shape[0], device=device, generator=rng)
67 for start in range(0, xtr.shape[0], BATCH):
68 ix = order[start:start+BATCH]
69 opt.zero_grad(set_to_none=True)
70 loss = torch.mean((net(xtr[ix]) - ytr[ix]) ** 2)
71 loss.backward()
72 if idea:
73 for p in params:
74 if p.grad is None or p.ndim != 2:
75 continue
76 g = p.grad
77 q, rem, orth = adaptive_subspace(g, cfg["rel_tol"], cfg["block"], cfg["kmax"], rng)
78 if q.shape[1]:
79 projected = q @ (q.T @ g)
80 p.grad.copy_(projected)
81 ranks.append(q.shape[1]); last_orth.append(orth)
82 proj_ratios.append(float(torch.linalg.norm(projected) / (torch.linalg.norm(g)+1e-12)))
83 opt.step()
84 net.eval()
85 with torch.no_grad(): metric = float(torch.mean((net(xte) - yte) ** 2).cpu())
86 stats = {"rank_mean": float(np.mean(ranks)) if ranks else 0.0,
87 "orth_mean": float(np.mean(last_orth)) if last_orth else 0.0,
88 "projection_ratio": float(np.mean(proj_ratios)) if proj_ratios else 1.0}
89 return metric, stats
90 except Exception:
91 if device == "cuda":
92 torch.cuda.empty_cache()
93 # Retry deterministically on CPU after any CUDA failure.
94 torch.cuda.is_available = lambda: False
95 return train_one(seed, cfg, idea)
96 raise
97
98
99def run_eval(cfg, idea, collect=False):
100 stats = []
101 def fn(seed):
102 val, st = train_one(int(seed), cfg, idea)
103 stats.append(st)
104 return val
105 out = evaluate(fn, SEEDS)
106 if collect: out["stats_per_seed"] = stats
107 return out
108
109
110def main():
111 t0 = time.time()
112 # Baseline sweep includes every lr evaluated by the idea (search-space parity).
113 base = sweep_baseline(lambda cfg: lambda seed: train_one(seed, cfg, False)[0],
114 [{"lr": lr} for lr in LR_GRID], seeds=SEEDS)
115 idea_trials = []
116 for cfg in IDEA_GRID:
117 idea_trials.append({"cfg": cfg, "result": run_eval(cfg, True, collect=True)})
118 best = min(idea_trials, key=lambda z: z["result"]["mean"])
119 base_cfg = base["best_cfg"]
120 # Signature is measured on trained benchmark models: retained rank,
121 # orthogonality, and captured gradient energy, not an analytical toy.
122 sig_stats = best["result"].get("stats_per_seed", [])
123 signature = {
124 "prediction": "adaptive Householder subspaces retain fewer than kmax dimensions while maintaining orthogonality",
125 "observed_rank_mean": float(np.mean([x["rank_mean"] for x in sig_stats])),
126 "observed_orthogonality_mean": float(np.mean([x["orth_mean"] for x in sig_stats])),
127 "observed_projected_gradient_norm_ratio": float(np.mean([x["projection_ratio"] for x in sig_stats])),
128 "confirmed": bool(sig_stats and np.mean([x["orth_mean"] for x in sig_stats]) < 1e-5 and np.mean([x["rank_mean"] for x in sig_stats]) < 16)
129 }
130 # Re-evaluate baseline best and selected idea are already full eight paired seeds.
131 report = make_report("tabular", "mlp_tiny", base, best["result"], {
132 "track_rationale": "optimizer modification matches the tabular optimizer track",
133 "idea_trials": [{"cfg": x["cfg"], "mean": x["result"]["mean"], "std": x["result"]["std"]} for x in idea_trials],
134 "mechanism_signature": signature,
135 "runtime_sec": time.time() - t0
136 })
137 # Required explicit metadata for downstream runner.
138 report["custom_track"] = None
139 Path("bench_report.json").write_text(json.dumps(report, indent=2))
140 print(json.dumps(report, indent=2))
141
142if __name__ == "__main__": main()