|
1 |
+#!/usr/bin/env python3 |
| 1 |
2 |
# ============================================================================= |
| 2 |
3 |
# Project : localvm-research |
| 3 |
4 |
# File : experiments/micro/expG_decision_stability/benchmark.py |
| 4 |
|
−# Purpose : Benchmark runner: Decision stability: how many token decisions are stable before full precision is available |
|
5 |
+# Purpose : Joint (cheap-pass margin × agreement) matrix across bit-widths — |
|
6 |
+# the Gate-Zero measurement for margin-gated escalation (G02/G23) |
| 5 |
7 |
# Author : Simon-Pierre Boucher |
| 6 |
8 |
# Contact : contact@spboucher.ai |
| 7 |
|
−# Created : 2026-08-11 |
| 8 |
|
−# Modified : 2026-08-11 |
| 9 |
|
−# Platform : macOS / Apple Silicon (arm64) |
|
9 |
+# Created : 2026-08-12 |
|
10 |
+# Modified : 2026-08-12 |
|
11 |
+# Platform : macOS / Apple Silicon (arm64) — MLX / Metal |
| 10 |
12 |
# License : All rights reserved (research code) |
| 11 |
13 |
# ============================================================================= |
|
14 |
+"""Experiment G — decision stability (charter §9.G). |
| 12 |
15 |
|
| 13 |
|
−"""Benchmark entry point for expG_decision_stability. |
|
16 |
+Generates greedy continuations with a bf16 reference model, teacher-forces |
|
17 |
+low-bit quantized variants over the same sequences, and records per-position |
|
18 |
+margin/agreement/KL. Outputs the joint matrix, AUROC of margin as a |
|
19 |
+disagreement detector, and escalation curves. |
| 14 |
20 |
|
| 15 |
|
−Must embed the hardware manifest in all result output |
| 16 |
|
−(see benchmarks/hardware_manifest.py) and write results to |
| 17 |
|
−results/expG_decision_stability/<timestamp>/. |
|
21 |
+Usage: |
|
22 |
+ .venv/bin/python benchmark.py [--model mlx-community/Qwen3-1.7B-bf16] |
|
23 |
+ [--gen-tokens 128] [--bits 2,3,4,8] [--per-domain 8] |
| 18 |
24 |
""" |
| 19 |
25 |
|
|
26 |
+from __future__ import annotations |
|
27 |
+ |
|
28 |
+import argparse |
|
29 |
+import gc |
|
30 |
+import json |
| 20 |
31 |
import sys |
|
32 |
+import time |
|
33 |
+from datetime import datetime, timezone |
| 21 |
34 |
from pathlib import Path |
| 22 |
35 |
|
| 23 |
|
−sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "benchmarks")) |
|
36 |
+import mlx.core as mx |
|
37 |
+import mlx.nn as nn |
|
38 |
+import numpy as np |
|
39 |
+from mlx_lm import load |
|
40 |
+ |
|
41 |
+REPO_ROOT = Path(__file__).resolve().parents[3] |
|
42 |
+sys.path.insert(0, str(REPO_ROOT / "benchmarks")) |
| 24 |
43 |
from hardware_manifest import collect_manifest # noqa: E402 |
| 25 |
44 |
|
| 26 |
45 |
|
|
46 |
+def greedy_generate(model, tokenizer, prompt_ids: list[int], n_tokens: int) -> list[int]: |
|
47 |
+ """Greedy generation without sampling helpers — deterministic, no cache reuse |
|
48 |
+ across prompts. Returns generated token ids.""" |
|
49 |
+ tokens = list(prompt_ids) |
|
50 |
+ generated = [] |
|
51 |
+ from mlx_lm.models.cache import make_prompt_cache |
|
52 |
+ |
|
53 |
+ cache = make_prompt_cache(model) |
|
54 |
+ inp = mx.array(tokens)[None] |
|
55 |
+ for _ in range(n_tokens): |
|
56 |
+ logits = model(inp, cache=cache) |
|
57 |
+ nxt = int(mx.argmax(logits[0, -1]).item()) |
|
58 |
+ if nxt == tokenizer.eos_token_id: |
|
59 |
+ break |
|
60 |
+ generated.append(nxt) |
|
61 |
+ inp = mx.array([[nxt]]) |
|
62 |
+ return generated |
|
63 |
+ |
|
64 |
+ |
|
65 |
+def teacher_forced_stats(model, full_ids: list[int], start: int) -> dict: |
|
66 |
+ """Forward the full sequence once; return per-position stats for positions |
|
67 |
+ predicting tokens at indices [start, len(full_ids)) — i.e., logits at |
|
68 |
+ positions start-1 .. len-2.""" |
|
69 |
+ logits = model(mx.array(full_ids)[None])[0] # (T, V) |
|
70 |
+ sel = logits[start - 1 : len(full_ids) - 1].astype(mx.float32) |
|
71 |
+ top2 = mx.topk(sel, 2, axis=-1) # values sorted ascending in MLX topk |
|
72 |
+ argmax = mx.argmax(sel, axis=-1) |
|
73 |
+ logprobs = sel - mx.logsumexp(sel, axis=-1, keepdims=True) |
|
74 |
+ mx.eval(top2, argmax, logprobs) |
|
75 |
+ v = np.array(top2) |
|
76 |
+ margin = v[:, 1] - v[:, 0] if v[0, 1] >= v[0, 0] else v[:, 0] - v[:, 1] |
|
77 |
+ return { |
|
78 |
+ "margin": np.abs(margin), |
|
79 |
+ "argmax": np.array(argmax), |
|
80 |
+ # float16 storage: 48 trajectories × (128, ~152k vocab) would be ~4 GB |
|
81 |
+ # in float32; KL is computed in float32 at use time. |
|
82 |
+ "logprobs": np.array(logprobs).astype(np.float16), |
|
83 |
+ } |
|
84 |
+ |
|
85 |
+ |
|
86 |
+def auroc(scores: np.ndarray, labels: np.ndarray) -> float: |
|
87 |
+ """AUROC of `scores` (higher = predicted positive) for binary labels. |
|
88 |
+ Here: score = -margin (low margin should predict disagreement=1).""" |
|
89 |
+ pos, neg = scores[labels == 1], scores[labels == 0] |
|
90 |
+ if len(pos) == 0 or len(neg) == 0: |
|
91 |
+ return float("nan") |
|
92 |
+ order = np.argsort(np.concatenate([pos, neg]), kind="mergesort") |
|
93 |
+ ranks = np.empty(len(order)); ranks[order] = np.arange(1, len(order) + 1) |
|
94 |
+ # average ranks for ties |
|
95 |
+ allv = np.concatenate([pos, neg]) |
|
96 |
+ sorted_v = allv[order] |
|
97 |
+ i = 0 |
|
98 |
+ while i < len(sorted_v): |
|
99 |
+ j = i |
|
100 |
+ while j + 1 < len(sorted_v) and sorted_v[j + 1] == sorted_v[i]: |
|
101 |
+ j += 1 |
|
102 |
+ if j > i: |
|
103 |
+ ranks[order[i : j + 1]] = ranks[order[i : j + 1]].mean() |
|
104 |
+ i = j + 1 |
|
105 |
+ r_pos = ranks[: len(pos)].sum() |
|
106 |
+ return float((r_pos - len(pos) * (len(pos) + 1) / 2) / (len(pos) * len(neg))) |
|
107 |
+ |
|
108 |
+ |
|
109 |
+def escalation_curve(margins: np.ndarray, agree: np.ndarray, points: int = 200) -> list[dict]: |
|
110 |
+ """For threshold τ over margins: escalate tokens with margin < τ (assume the |
|
111 |
+ escalated decision becomes correct). Report escalated fraction vs residual |
|
112 |
+ disagreement (disagreements with margin ≥ τ).""" |
|
113 |
+ qs = np.quantile(margins, np.linspace(0, 1, points)) |
|
114 |
+ out, n = [], len(margins) |
|
115 |
+ for tau in qs: |
|
116 |
+ esc = margins < tau |
|
117 |
+ residual = np.sum((~esc) & (agree == 0)) / n |
|
118 |
+ out.append({"tau": float(tau), "escalated_frac": float(esc.mean()), |
|
119 |
+ "residual_disagree": float(residual)}) |
|
120 |
+ return out |
|
121 |
+ |
|
122 |
+ |
| 27 |
123 |
def main() -> None: |
| 28 |
|
− manifest = collect_manifest() |
| 29 |
|
− raise NotImplementedError("experiment not yet implemented") |
|
124 |
+ ap = argparse.ArgumentParser() |
|
125 |
+ ap.add_argument("--model", default="mlx-community/Qwen3-1.7B-bf16") |
|
126 |
+ ap.add_argument("--gen-tokens", type=int, default=128) |
|
127 |
+ ap.add_argument("--bits", default="2,3,4,8") |
|
128 |
+ ap.add_argument("--per-domain", type=int, default=8) |
|
129 |
+ ap.add_argument("--group-size", type=int, default=64) |
|
130 |
+ args = ap.parse_args() |
|
131 |
+ bits_list = [int(b) for b in args.bits.split(",")] |
|
132 |
+ |
|
133 |
+ prompts_file = REPO_ROOT / "benchmarks" / "datasets" / "eval_prompts.json" |
|
134 |
+ domains = json.loads(prompts_file.read_text())["domains"] |
|
135 |
+ |
|
136 |
+ print(f"loading reference {args.model} …", flush=True) |
|
137 |
+ model, tokenizer = load(args.model) |
|
138 |
+ |
|
139 |
+ # -------- pass 1: reference greedy trajectories + reference stats |
|
140 |
+ trajectories = [] # {domain, prompt_ids, full_ids, start} |
|
141 |
+ t0 = time.time() |
|
142 |
+ for domain, plist in domains.items(): |
|
143 |
+ for prompt in plist[: args.per_domain]: |
|
144 |
+ msgs = [{"role": "user", "content": prompt}] |
|
145 |
+ ids = tokenizer.apply_chat_template(msgs, add_generation_prompt=True) |
|
146 |
+ gen = greedy_generate(model, tokenizer, ids, args.gen_tokens) |
|
147 |
+ if len(gen) < 8: |
|
148 |
+ continue |
|
149 |
+ trajectories.append({"domain": domain, "full_ids": list(ids) + gen, "start": len(ids)}) |
|
150 |
+ print(f" generated {domain}", flush=True) |
|
151 |
+ print(f"reference generation done in {time.time() - t0:.0f}s " |
|
152 |
+ f"({len(trajectories)} trajectories)", flush=True) |
|
153 |
+ |
|
154 |
+ ref_stats = [teacher_forced_stats(model, t["full_ids"], t["start"]) for t in trajectories] |
|
155 |
+ |
|
156 |
+ # -------- pass 2: quantized variants, teacher-forced on the same ids |
|
157 |
+ per_bits: dict[int, dict] = {} |
|
158 |
+ for bits in bits_list: |
|
159 |
+ print(f"quantizing to {bits}-bit (group {args.group_size}) …", flush=True) |
|
160 |
+ del model |
|
161 |
+ gc.collect(); mx.clear_cache() |
|
162 |
+ model, _ = load(args.model) |
|
163 |
+ nn.quantize(model, group_size=args.group_size, bits=bits, |
|
164 |
+ class_predicate=lambda p, m: isinstance(m, nn.Linear) |
|
165 |
+ and m.weight.shape[-1] % args.group_size == 0) |
|
166 |
+ rows = [] |
|
167 |
+ for t, ref in zip(trajectories, ref_stats): |
|
168 |
+ qs = teacher_forced_stats(model, t["full_ids"], t["start"]) |
|
169 |
+ ref_next = np.array(t["full_ids"][t["start"]:]) # actual (=ref argmax) tokens |
|
170 |
+ agree = (qs["argmax"] == ref_next).astype(np.int8) |
|
171 |
+ # KL(ref||q) per position |
|
172 |
+ ref_lp = ref["logprobs"].astype(np.float32) |
|
173 |
+ kl = np.sum(np.exp(ref_lp) * (ref_lp - qs["logprobs"].astype(np.float32)), axis=-1) |
|
174 |
+ rows.append({"domain": t["domain"], "margin": qs["margin"], |
|
175 |
+ "agree": agree, "kl": kl}) |
|
176 |
+ margins = np.concatenate([r["margin"] for r in rows]) |
|
177 |
+ agrees = np.concatenate([r["agree"] for r in rows]) |
|
178 |
+ kls = np.concatenate([r["kl"] for r in rows]) |
|
179 |
+ disagree = 1 - agrees |
|
180 |
+ stats = { |
|
181 |
+ "bits": bits, |
|
182 |
+ "n_positions": int(len(margins)), |
|
183 |
+ "agreement_rate": float(agrees.mean()), |
|
184 |
+ "mean_kl_ref_q": float(np.mean(kls)), |
|
185 |
+ "auroc_margin_predicts_disagreement": auroc(-margins, disagree), |
|
186 |
+ "median_margin_agree": float(np.median(margins[agrees == 1])), |
|
187 |
+ "median_margin_disagree": float(np.median(margins[agrees == 0])) if (agrees == 0).any() else None, |
|
188 |
+ "escalation_curve": escalation_curve(margins, agrees), |
|
189 |
+ "per_domain": { |
|
190 |
+ d: { |
|
191 |
+ "agreement_rate": float(np.concatenate([r["agree"] for r in rows if r["domain"] == d]).mean()), |
|
192 |
+ "auroc": auroc( |
|
193 |
+ -np.concatenate([r["margin"] for r in rows if r["domain"] == d]), |
|
194 |
+ 1 - np.concatenate([r["agree"] for r in rows if r["domain"] == d]), |
|
195 |
+ ), |
|
196 |
+ } |
|
197 |
+ for d in domains |
|
198 |
+ }, |
|
199 |
+ } |
|
200 |
+ # operating point: escalation fraction to reach 99% agreement |
|
201 |
+ for pt in stats["escalation_curve"]: |
|
202 |
+ if pt["residual_disagree"] <= 0.01: |
|
203 |
+ stats["escalation_frac_for_99pct"] = pt["escalated_frac"] |
|
204 |
+ break |
|
205 |
+ per_bits[bits] = stats |
|
206 |
+ print(f" {bits}-bit: agree={stats['agreement_rate']:.4f} " |
|
207 |
+ f"AUROC={stats['auroc_margin_predicts_disagreement']:.3f} " |
|
208 |
+ f"esc@99%={stats.get('escalation_frac_for_99pct', 'n/a')}", flush=True) |
|
209 |
+ |
|
210 |
+ ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") |
|
211 |
+ out_dir = REPO_ROOT / "results" / "expG_decision_stability" / ts |
|
212 |
+ out_dir.mkdir(parents=True) |
|
213 |
+ payload = { |
|
214 |
+ "experiment": "expG_decision_stability", |
|
215 |
+ "author": "Simon-Pierre Boucher", |
|
216 |
+ "contact": "contact@spboucher.ai", |
|
217 |
+ "manifest": collect_manifest(), |
|
218 |
+ "config": vars(args), |
|
219 |
+ "n_trajectories": len(trajectories), |
|
220 |
+ "results_by_bits": {str(k): v for k, v in per_bits.items()}, |
|
221 |
+ } |
|
222 |
+ (out_dir / "results.json").write_text(json.dumps(payload, indent=2)) |
|
223 |
+ print(f"\nwrote {out_dir / 'results.json'}") |
| 30 |
224 |
|
| 31 |
225 |
|
| 32 |
226 |
if __name__ == "__main__": |