Skip to content

Commit d0b2819

Browse files
kmbandyclaude
andcommitted
feat(MAD-223): --eval-ppl flag for calibrate_ml8.py + MAD-223 PPL gate check
Adds wikitext-2 test-split PPL evaluation, computed via sliding-window (seq_len=2048, stride=1024) on the model BEFORE quantization (baseline) and AFTER all layers are in-place quantized (quantized PPL). Reports Δ_PPL = quantized - baseline. The MAD-223 acceptance gate is Δ_PPL < 0.08 vs f16 baseline; if met, AWQ phase B.5 is not needed. Smoke-tested on Qwen3.5-4B with --max-layers 1 + 20K token cap: baseline f16 PPL = 7.5765 1-layer quantized = 7.5584 (Δ = -0.018, within measurement noise) ✓ MAD-223 gate logic prints correctly Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 06b1bac commit d0b2819

1 file changed

Lines changed: 81 additions & 0 deletions

File tree

scripts/calibration/calibrate_ml8.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939

4040
import argparse
4141
import json
42+
import math
4243
import os
4344
import sys
4445
import time
@@ -228,6 +229,55 @@ def gptq_quantize_linear(layer: nn.Linear, H: torch.Tensor,
228229
return export
229230

230231

232+
# ───────────────────────────── PPL eval ─────────────────────────────────────
233+
234+
@torch.no_grad()
235+
def eval_ppl_wikitext(model, tokenizer, dev: str, seq_len: int = 2048,
236+
stride: int = 1024, max_tokens: int | None = None) -> dict:
237+
"""Compute wikitext-2 test-split PPL via sliding-window evaluation.
238+
239+
Standard pattern (matches HF eval + llama.cpp --perplexity):
240+
1. Concatenate all test text, tokenize once
241+
2. Slide a window of seq_len with overlap (stride)
242+
3. Compute next-token CE loss on the non-overlapping tail
243+
4. PPL = exp(mean(loss))
244+
"""
245+
from datasets import load_dataset
246+
ds = load_dataset("Salesforce/wikitext", "wikitext-2-raw-v1", split="test")
247+
text = "\n\n".join(r["text"] for r in ds if r["text"].strip())
248+
enc = tokenizer(text, return_tensors="pt")
249+
ids = enc.input_ids.to(dev)
250+
n_tokens = ids.shape[1]
251+
if max_tokens is not None:
252+
n_tokens = min(n_tokens, max_tokens)
253+
ids = ids[:, :n_tokens]
254+
print(f" [ppl] {n_tokens} tokens, window={seq_len} stride={stride}")
255+
256+
nll_sum = 0.0
257+
n_pred = 0
258+
prev_end = 0
259+
for begin in range(0, n_tokens, stride):
260+
end = min(begin + seq_len, n_tokens)
261+
target_len = end - prev_end
262+
input_ids = ids[:, begin:end]
263+
target_ids = input_ids.clone()
264+
# Mask out the overlap tokens (already scored)
265+
target_ids[:, :-target_len] = -100
266+
outputs = model(input_ids, labels=target_ids)
267+
# outputs.loss is mean over unmasked tokens
268+
# Re-compute: nll_sum gets total CE, normalize at end
269+
n_unmasked = (target_ids != -100).sum().item()
270+
nll_sum += outputs.loss.item() * n_unmasked
271+
n_pred += n_unmasked
272+
prev_end = end
273+
if end >= n_tokens:
274+
break
275+
276+
avg_nll = nll_sum / max(n_pred, 1)
277+
ppl = math.exp(avg_nll)
278+
return {"ppl": ppl, "avg_nll": avg_nll, "n_tokens_scored": n_pred}
279+
280+
231281
# ───────────────────────────── Driver ───────────────────────────────────────
232282

233283
def main():
@@ -253,6 +303,11 @@ def main():
253303
p.add_argument("--dtype", choices=("float16", "bfloat16"), default="float16")
254304
p.add_argument("--max-layers", type=int, default=None,
255305
help="Limit to first N transformer layers (fast iteration)")
306+
p.add_argument("--eval-ppl", action="store_true",
307+
help="Compute wikitext-2 test-split PPL before and after "
308+
"quantization. Reports Δ_PPL vs f16 baseline.")
309+
p.add_argument("--ppl-max-tokens", type=int, default=None,
310+
help="Cap PPL eval tokens (default: full test split ~280k).")
256311
args = p.parse_args()
257312

258313
os.makedirs(args.output_dir, exist_ok=True)
@@ -276,6 +331,15 @@ def main():
276331

277332
manifest = {"model": args.model, "args": vars(args), "results": []}
278333

334+
# Baseline PPL on the f16 model BEFORE any quantization
335+
if args.eval_ppl:
336+
print(f"\n[ppl-baseline] computing f16 baseline PPL...")
337+
baseline = eval_ppl_wikitext(model, tokenizer, args.device,
338+
max_tokens=args.ppl_max_tokens)
339+
print(f" baseline PPL = {baseline['ppl']:.4f} "
340+
f"(n_tokens={baseline['n_tokens_scored']})")
341+
manifest["ppl_baseline"] = baseline
342+
279343
for i, (name, layer) in enumerate(targets):
280344
t0 = time.time()
281345
rows, in_feat = layer.weight.shape
@@ -339,6 +403,23 @@ def main():
339403
if args.device.startswith("cuda"):
340404
torch.cuda.empty_cache()
341405

406+
# Post-quantization PPL on the in-place modified model
407+
if args.eval_ppl:
408+
print(f"\n[ppl-quant] computing quantized PPL...")
409+
quantized = eval_ppl_wikitext(model, tokenizer, args.device,
410+
max_tokens=args.ppl_max_tokens)
411+
print(f" quantized PPL = {quantized['ppl']:.4f}")
412+
manifest["ppl_quantized"] = quantized
413+
delta = quantized["ppl"] - manifest["ppl_baseline"]["ppl"]
414+
manifest["ppl_delta"] = delta
415+
print(f"\n Δ_PPL = {delta:+.4f} "
416+
f"({delta / manifest['ppl_baseline']['ppl'] * 100:+.2f}%)")
417+
# MAD-223 gate: <0.08 PPL → proceed without AWQ phase B.5
418+
if delta < 0.08:
419+
print(f" ✓ Δ_PPL < 0.08 — MAD-223 gate PASSED (AWQ phase B.5 not needed)")
420+
else:
421+
print(f" ⚠ Δ_PPL ≥ 0.08 — MAD-223 gate triggers AWQ phase B.5 consideration")
422+
342423
manifest_path = Path(args.output_dir) / "manifest.json"
343424
with open(manifest_path, "w") as f:
344425
json.dump(manifest, f, indent=2)

0 commit comments

Comments
 (0)