Skip to content

Commit 46e2b15

Browse files
kmbandyclaude
andcommitted
feat(MAD-223): calibration_report.py — pretty-print manifest.json analysis
Reads manifest.json from a calibrate_ml8.py output dir and shows: - Y_SNR / W_SNR distribution (min/median/max/mean across all linears) - Per-linear-type aggregation (gate_proj vs up_proj vs down_proj) - Total quantized size + ratio vs fp16 - PPL deltas if --eval-ppl was used - Top-N worst layers by Y_SNR (to identify quality outliers) Quick health-check for any calibration run — point at the output dir and you get the headline numbers without manually grepping json. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 27c3f5d commit 46e2b15

1 file changed

Lines changed: 120 additions & 0 deletions

File tree

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
#!/usr/bin/env python3
2+
"""calibration_report.py — pretty-print summary of a calibrate_ml8.py run.
3+
4+
Reads manifest.json from a calibration output directory and shows:
5+
- Per-layer SNR distribution (Y_SNR, W_SNR) with min/median/max
6+
- Per-linear-type aggregation (gate_proj vs up_proj vs down_proj)
7+
- Total quantization size + ratio vs fp16 baseline
8+
- PPL deltas if --eval-ppl was used
9+
- Outlier layers (worst SNR) so the user knows where quality drops
10+
11+
Usage:
12+
python3 scripts/calibration/calibration_report.py /tmp/ml8-qwen3-4b-full
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import argparse
18+
import json
19+
import statistics
20+
import sys
21+
from collections import defaultdict
22+
from pathlib import Path
23+
24+
25+
def fmt_size_gb(bits: int) -> str:
26+
return f"{bits / 8 / 1e9:.2f} GB"
27+
28+
29+
def main():
30+
p = argparse.ArgumentParser()
31+
p.add_argument("calibration_dir", type=Path,
32+
help="Output dir from calibrate_ml8.py (contains manifest.json)")
33+
p.add_argument("--top-n-worst", type=int, default=5,
34+
help="Show N layers with the worst Y_SNR (default: 5).")
35+
args = p.parse_args()
36+
37+
manifest_path = args.calibration_dir / "manifest.json"
38+
if not manifest_path.exists():
39+
print(f"error: {manifest_path} not found", file=sys.stderr)
40+
sys.exit(1)
41+
42+
m = json.loads(manifest_path.read_text())
43+
results = m["results"]
44+
if not results:
45+
print("error: manifest has no results", file=sys.stderr)
46+
sys.exit(1)
47+
48+
print(f"=== {m['model']} ({len(results)} linears) ===")
49+
print()
50+
51+
# Aggregate Y_SNR and W_SNR distributions
52+
y_snrs = [r["y_snr_db"] for r in results]
53+
w_snrs = [r["w_snr_db"] for r in results]
54+
print("SNR distribution (across all linears):")
55+
print(f" Y_SNR (output, GPTQ-optimized): "
56+
f"min={min(y_snrs):.2f} median={statistics.median(y_snrs):.2f} "
57+
f"max={max(y_snrs):.2f} mean={statistics.mean(y_snrs):.2f}")
58+
print(f" W_SNR (element-wise): "
59+
f"min={min(w_snrs):.2f} median={statistics.median(w_snrs):.2f} "
60+
f"max={max(w_snrs):.2f} mean={statistics.mean(w_snrs):.2f}")
61+
print()
62+
63+
# Per-linear-type aggregation (gate_proj vs up_proj vs down_proj vs etc)
64+
by_kind = defaultdict(list)
65+
for r in results:
66+
# Take the trailing component, e.g. "model.layers.0.mlp.gate_proj" → "gate_proj"
67+
kind = r["name"].rsplit(".", 1)[-1]
68+
by_kind[kind].append(r)
69+
70+
print("By linear type:")
71+
print(f" {'kind':20s} {'count':>6s} {'Y_SNR med':>10s} {'Y_SNR min':>10s} {'numel total':>14s}")
72+
for kind in sorted(by_kind.keys()):
73+
rows = by_kind[kind]
74+
yk = [r["y_snr_db"] for r in rows]
75+
numel = sum(r["shape"][0] * r["shape"][1] for r in rows)
76+
print(f" {kind:20s} {len(rows):>6d} {statistics.median(yk):>10.2f} "
77+
f"{min(yk):>10.2f} {numel:>14,d}")
78+
print()
79+
80+
# Total size
81+
total_numel = sum(r["shape"][0] * r["shape"][1] for r in results)
82+
# We don't store bpv per layer; recompute. Assume 4-bit indices + fp16 scales + fp32 centroids
83+
# bpv = 4 + (16/group_size) + n_centroids*32/(rows*group_size)
84+
# For typical Qwen layers, ≈ 4.125 bpv
85+
# For a precise number we'd reload the .pt files; this is just a summary, ~4.125 is enough.
86+
approx_bpv = 4.125
87+
total_quant_bits = total_numel * approx_bpv
88+
total_fp16_bits = total_numel * 16
89+
print(f"Quantized weights size:")
90+
print(f" total parameters quantized: {total_numel:,}")
91+
print(f" ~{fmt_size_gb(int(total_quant_bits))} at ~{approx_bpv:.3f} bpv "
92+
f"(vs {fmt_size_gb(total_fp16_bits)} at fp16)")
93+
print(f" ratio: {total_quant_bits / total_fp16_bits:.3f} of fp16 size")
94+
print()
95+
96+
# PPL if present
97+
if "ppl_baseline" in m and "ppl_quantized" in m:
98+
b = m["ppl_baseline"]["ppl"]
99+
q = m["ppl_quantized"]["ppl"]
100+
delta = q - b
101+
print(f"Perplexity (wikitext-2 test):")
102+
print(f" baseline (f16): {b:.4f}")
103+
print(f" quantized (ml8-4): {q:.4f}")
104+
print(f" Δ_PPL: {delta:+.4f} ({delta/b*100:+.2f}%)")
105+
if delta < 0.08:
106+
print(f" ✓ MAD-223 gate PASSED (< 0.08)")
107+
else:
108+
print(f" ⚠ MAD-223 gate triggers AWQ B.5 (≥ 0.08)")
109+
print()
110+
111+
# Worst layers
112+
print(f"Worst {args.top_n_worst} layers by Y_SNR:")
113+
worst = sorted(results, key=lambda r: r["y_snr_db"])[:args.top_n_worst]
114+
for r in worst:
115+
print(f" {r['name']:50s} Y_SNR={r['y_snr_db']:6.2f} "
116+
f"W_SNR={r['w_snr_db']:6.2f} shape={r['shape']}")
117+
118+
119+
if __name__ == "__main__":
120+
main()

0 commit comments

Comments
 (0)