quantgap 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,34 @@
1
+ """Probe set versions and their hashes. A probe set is frozen once released."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from pathlib import Path
7
+
8
+ from quantgap.models import ProbeSeq
9
+
10
+ _DATA = Path(__file__).parent / "data"
11
+ AVAILABLE = {"v1": "probes_v1.jsonl"}
12
+
13
+
14
+ def probes_path(version: str = "v1") -> Path:
15
+ if version not in AVAILABLE:
16
+ raise ValueError(
17
+ f"Unknown probe set {version!r}. Available: {', '.join(sorted(AVAILABLE))}."
18
+ )
19
+ return _DATA / AVAILABLE[version]
20
+
21
+
22
+ def probes_sha256(version: str = "v1") -> str:
23
+ return hashlib.sha256(probes_path(version).read_bytes()).hexdigest()
24
+
25
+
26
+ def load_probes(version: str = "v1", path: Path | None = None) -> list[ProbeSeq]:
27
+ p = path or probes_path(version)
28
+ rows = []
29
+ with open(p, encoding="utf-8") as f:
30
+ for line in f:
31
+ line = line.strip()
32
+ if line:
33
+ rows.append(ProbeSeq.model_validate_json(line))
34
+ return rows
@@ -0,0 +1,158 @@
1
+ """Turn probe rows into token ids and a score mask, using the reference model's tokenizer.
2
+
3
+ Position `pos` predicts token `pos + 1`. A position is scored when token `pos + 1` is real
4
+ content: not a special token, and for chat rows, inside the assistant reply.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+ import numpy as np
13
+
14
+ from quantgap.models import ProbeSeq
15
+
16
+ MAX_LEN = 512
17
+ GREEDY_PROMPT_TOKENS = 64
18
+ _BOUNDARIES = ("\n", ". ", "。", "।", "؟", "? ", "! ", "! ", "}", ";")
19
+ FALLBACK_TEMPLATE_NOTE = "no chat template on tokenizer: used plain 'User:/Assistant:' text"
20
+
21
+
22
+ @dataclass
23
+ class RenderedSeq:
24
+ seq_id: int
25
+ group: str
26
+ token_ids: list[int]
27
+ score_mask: list[bool] # per token: is this token a scored target
28
+ n_scored: int = 0
29
+ notes: list[str] = field(default_factory=list)
30
+
31
+
32
+ @dataclass
33
+ class RenderedGreedy:
34
+ seq_id: int
35
+ group: str
36
+ prompt_ids: list[int]
37
+ lang: str = "en"
38
+
39
+
40
+ def _encode(tok: Any, text: str) -> list[int]:
41
+ return list(tok(text, add_special_tokens=True)["input_ids"])
42
+
43
+
44
+ def _truncate_text(tok: Any, text: str, max_len: int) -> str:
45
+ """Longest prefix of `text` that fits in max_len tokens, cut at a line or sentence end."""
46
+ if len(_encode(tok, text)) <= max_len:
47
+ return text
48
+ lo, hi = 1, len(text)
49
+ while lo < hi: # binary search on characters
50
+ mid = (lo + hi + 1) // 2
51
+ if len(_encode(tok, text[:mid])) <= max_len:
52
+ lo = mid
53
+ else:
54
+ hi = mid - 1
55
+ cut = text[:lo]
56
+ best = max((cut.rfind(b) + len(b) for b in _BOUNDARIES if cut.rfind(b) > 0), default=0)
57
+ if best >= int(len(cut) * 0.6):
58
+ cut = cut[:best]
59
+ return cut.rstrip()
60
+
61
+
62
+ def _special_ids(tok: Any) -> set[int]:
63
+ return {int(i) for i in getattr(tok, "all_special_ids", [])}
64
+
65
+
66
+ def _ids_and_start(tok: Any, full: str, prefix: str, add_special: bool) -> tuple[list[int], int]:
67
+ """Token ids of `full` and the index of the first token that lies entirely after `prefix`.
68
+
69
+ A BPE token can straddle the boundary (for example " The" glues the space of the prompt to
70
+ the first word of the reply). Such a token counts as context and is not scored.
71
+ """
72
+ try:
73
+ enc = tok(full, add_special_tokens=add_special, return_offsets_mapping=True)
74
+ ids = list(enc["input_ids"])
75
+ offs = enc["offset_mapping"]
76
+ start = next((i for i, (a, _) in enumerate(offs) if a >= len(prefix)), len(ids))
77
+ return ids, start
78
+ except (NotImplementedError, TypeError, ValueError): # slow tokenizer: no offsets
79
+ ids = list(tok(full, add_special_tokens=add_special)["input_ids"])
80
+ plen = len(tok(prefix, add_special_tokens=add_special)["input_ids"])
81
+ return ids, plen
82
+
83
+
84
+ def _chat_ids(tok: Any, messages: list[dict[str, str]]) -> tuple[list[int], int, list[str]]:
85
+ """Return (ids, assistant_start_index, notes)."""
86
+ notes: list[str] = []
87
+ if getattr(tok, "chat_template", None):
88
+ prefix = tok.apply_chat_template(messages[:-1], add_generation_prompt=True, tokenize=False)
89
+ full = tok.apply_chat_template(messages, tokenize=False)
90
+ # The template already wrote any BOS token as text, so do not add special tokens again.
91
+ ids, start = _ids_and_start(tok, full, prefix, add_special=False)
92
+ if not full.startswith(prefix):
93
+ notes.append("chat prefix not a prefix of full render: scored all non-special tokens")
94
+ start = 0
95
+ return ids, start, notes
96
+ notes.append(FALLBACK_TEMPLATE_NOTE)
97
+ prefix = _plain_chat_prefix(messages[:-1]) + "Assistant:"
98
+ full = prefix + " " + messages[-1]["content"]
99
+ ids, start = _ids_and_start(tok, full, prefix, add_special=True)
100
+ return ids, start, notes
101
+
102
+
103
+ def _plain_chat_prefix(messages: list[dict[str, str]]) -> str:
104
+ return "".join(
105
+ f"{'User' if m['role'] == 'user' else 'Assistant'}: {m['content']}\n" for m in messages
106
+ )
107
+
108
+
109
+ def render_probes(
110
+ probes: list[ProbeSeq], tok: Any, max_len: int = MAX_LEN
111
+ ) -> tuple[list[RenderedSeq], list[RenderedGreedy]]:
112
+ """Render every probe with `tok`. Deterministic: same inputs give identical ids."""
113
+ special = _special_ids(tok)
114
+ seqs: list[RenderedSeq] = []
115
+ greedy: list[RenderedGreedy] = []
116
+ for p in probes:
117
+ notes: list[str] = []
118
+ if p.kind == "chat":
119
+ assert p.messages, f"probe {p.seq_id}: chat row without messages"
120
+ msgs = p.messages
121
+ ids, start, notes = _chat_ids(tok, msgs)
122
+ if len(ids) > max_len:
123
+ # Shorten the assistant reply, never the prompt.
124
+ reply = msgs[-1]["content"]
125
+ budget = max_len - start
126
+ short = _truncate_text(tok, reply, max(8, budget - 8))
127
+ msgs = msgs[:-1] + [{"role": msgs[-1]["role"], "content": short}]
128
+ ids, start, notes = _chat_ids(tok, msgs)
129
+ ids = ids[:max_len]
130
+ else:
131
+ assert p.text, f"probe {p.seq_id}: text row without text"
132
+ ids = _encode(tok, _truncate_text(tok, p.text, max_len))
133
+ start = 0
134
+ mask = [False] * len(ids)
135
+ for t in range(1, len(ids)):
136
+ mask[t] = t >= start and ids[t] not in special
137
+ seqs.append(
138
+ RenderedSeq(p.seq_id, p.group, ids, mask, n_scored=int(np.sum(mask)), notes=notes)
139
+ )
140
+ if p.greedy:
141
+ if p.kind == "chat":
142
+ gm = [m for m in (p.messages or [])[:-1]]
143
+ if getattr(tok, "chat_template", None):
144
+ s = tok.apply_chat_template(gm, add_generation_prompt=True, tokenize=False)
145
+ gids = list(tok(s, add_special_tokens=False)["input_ids"])
146
+ else:
147
+ s = "".join(
148
+ f"{'User' if m['role'] == 'user' else 'Assistant'}: {m['content']}\n"
149
+ for m in gm
150
+ )
151
+ gids = _encode(tok, s + "Assistant:")
152
+ else:
153
+ gtext = p.greedy_prompt or p.text or ""
154
+ gids = _encode(tok, gtext)
155
+ if p.greedy_prompt is None:
156
+ gids = gids[:GREEDY_PROMPT_TOKENS]
157
+ greedy.append(RenderedGreedy(p.seq_id, p.group, gids, p.lang))
158
+ return seqs, greedy
File without changes
@@ -0,0 +1,92 @@
1
+ """Quality-vs-size chart. One series (the quants), one hue, direct labels, no legend box.
2
+
3
+ Lower and further left is better: a smaller file with less drift. The line joins the quants no
4
+ other quant beats on both size and KLD (the "best for its size" frontier).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ import matplotlib
12
+
13
+ matplotlib.use("Agg")
14
+ import matplotlib.pyplot as plt # noqa: E402
15
+
16
+ from quantgap.models import ScoreResult # noqa: E402
17
+
18
+ INK = "#1f2937"
19
+ MUTED = "#6b7280"
20
+ GRID = "#e5e7eb"
21
+ MARK = "#2563eb"
22
+ SURFACE = "#ffffff"
23
+ FLAG = "#b45309"
24
+
25
+
26
+ def pareto(results: list[ScoreResult]) -> list[ScoreResult]:
27
+ """Quants not beaten by a smaller-or-equal file with lower-or-equal KLD."""
28
+ out: list[ScoreResult] = []
29
+ best = float("inf")
30
+ for r in sorted(results, key=lambda r: (r.quant.size_bytes, r.metrics.kld_mean)):
31
+ if r.metrics.kld_mean < best:
32
+ out.append(r)
33
+ best = r.metrics.kld_mean
34
+ return out
35
+
36
+
37
+ def draw_chart(
38
+ results: list[ScoreResult],
39
+ out: str | Path,
40
+ title: str | None = None,
41
+ budget_bytes: int | None = None,
42
+ ) -> Path:
43
+ out = Path(out)
44
+ fig, ax = plt.subplots(figsize=(8, 5), dpi=160)
45
+ fig.patch.set_facecolor(SURFACE)
46
+ ax.set_facecolor(SURFACE)
47
+ rs = sorted(results, key=lambda r: r.quant.size_bytes)
48
+ xs = [r.quant.size_bytes / 1e9 for r in rs]
49
+ ys = [r.metrics.kld_mean for r in rs]
50
+ lo = [r.metrics.kld_mean - r.metrics.kld_mean_ci95[0] for r in rs]
51
+ hi = [r.metrics.kld_mean_ci95[1] - r.metrics.kld_mean for r in rs]
52
+ ax.errorbar(
53
+ xs, ys, yerr=[[max(0, v) for v in lo], [max(0, v) for v in hi]],
54
+ fmt="none", ecolor=MARK, elinewidth=1, alpha=0.45, capsize=0,
55
+ ) # fmt: skip
56
+ front = pareto(rs)
57
+ ax.plot(
58
+ [r.quant.size_bytes / 1e9 for r in front], [r.metrics.kld_mean for r in front],
59
+ color=MARK, linewidth=1.5, alpha=0.5, zorder=2,
60
+ ) # fmt: skip
61
+ ax.scatter(xs, ys, s=64, color=MARK, edgecolor=SURFACE, linewidth=2, zorder=3)
62
+ labels = [r.quant.label for r in rs]
63
+ for r, x, y in zip(rs, xs, ys, strict=True):
64
+ label = r.quant.label
65
+ if labels.count(r.quant.label) > 1 and r.quant.publisher: # same label from two publishers
66
+ label += f" ({r.quant.publisher})"
67
+ label += " *" if r.approximate else ""
68
+ ax.annotate(label, (x, y), textcoords="offset points", xytext=(7, 6), fontsize=8, color=INK)
69
+ if budget_bytes:
70
+ ax.axvline(budget_bytes / 1e9, color=MUTED, linewidth=1, linestyle=(0, (4, 3)))
71
+ ax.annotate(
72
+ "memory budget", (budget_bytes / 1e9, 1), xycoords=("data", "axes fraction"),
73
+ xytext=(-4, -12), textcoords="offset points", ha="right", fontsize=8, color=MUTED,
74
+ ) # fmt: skip
75
+ ax.set_xlabel("File size (GB)", color=MUTED, fontsize=9)
76
+ ax.set_ylabel("KLD mean vs original (lower is better)", color=MUTED, fontsize=9)
77
+ if min(ys) > 0 and max(ys) / max(min(ys), 1e-12) > 30:
78
+ ax.set_yscale("log")
79
+ ax.grid(axis="y", color=GRID, linewidth=0.8)
80
+ ax.set_axisbelow(True)
81
+ for side in ("top", "right", "left"):
82
+ ax.spines[side].set_visible(False)
83
+ ax.spines["bottom"].set_color(GRID)
84
+ ax.tick_params(colors=MUTED, labelsize=8, length=0)
85
+ pack = results[0].pack_name or results[0].pack_id
86
+ ax.set_title(title or f"Quality vs size | pack {pack}", loc="left", color=INK, fontsize=11)
87
+ if any(r.approximate for r in rs):
88
+ fig.text(0.01, 0.01, "* approximate (server top-N)", fontsize=7, color=FLAG)
89
+ fig.tight_layout()
90
+ fig.savefig(out, facecolor=SURFACE)
91
+ plt.close(fig)
92
+ return out
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from quantgap.models import CompareReport, ScoreResult
7
+
8
+
9
+ def safe_name(r: ScoreResult) -> str:
10
+ base = f"{(r.pack_name or r.pack_id).split('@')[0]}-{r.quant.label}"
11
+ return re.sub(r"[^A-Za-z0-9._-]+", "_", base)
12
+
13
+
14
+ def write_result(r: ScoreResult, path: str | Path) -> Path:
15
+ p = Path(path)
16
+ p.parent.mkdir(parents=True, exist_ok=True)
17
+ p.write_text(r.to_json() + "\n", encoding="utf-8")
18
+ return p
19
+
20
+
21
+ def write_report(rep: CompareReport, path: str | Path) -> Path:
22
+ p = Path(path)
23
+ p.parent.mkdir(parents=True, exist_ok=True)
24
+ p.write_text(rep.model_dump_json(indent=2, by_alias=True) + "\n", encoding="utf-8")
25
+ return p
@@ -0,0 +1,82 @@
1
+ """Rich tables for the terminal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.console import Console
6
+ from rich.table import Table
7
+
8
+ from quantgap.models import ScoreResult
9
+ from quantgap.util import human_size
10
+
11
+
12
+ def print_result(c: Console, r: ScoreResult) -> None:
13
+ m = r.metrics
14
+ c.print(f"[bold]quantgap {r.quantgap}[/bold] pack: {r.pack_name}")
15
+ flags = " ".join(
16
+ f"[yellow]{t}[/yellow]"
17
+ for t, on in (
18
+ ("quick", r.quick),
19
+ ("approximate", r.approximate),
20
+ ("non-deterministic", r.nondeterministic),
21
+ )
22
+ if on
23
+ )
24
+ c.print(
25
+ f"quant: {r.quant.source} [bold]{r.quant.label}[/bold] {human_size(r.quant.size_bytes)} "
26
+ f"{r.backend.name} {flags}"
27
+ )
28
+ t = Table(show_header=True, header_style="bold", box=None, pad_edge=False)
29
+ t.add_column("metric")
30
+ t.add_column("value", justify="right")
31
+ t.add_column("note")
32
+ t.add_row(
33
+ "KLD mean",
34
+ f"{m.kld_mean:.4f}",
35
+ f"95% CI [{m.kld_mean_ci95[0]:.4f}, {m.kld_mean_ci95[1]:.4f}] (lower is better)",
36
+ )
37
+ t.add_row("KLD worst 1%", f"{m.kld_p99:.3f}", "99th percentile of positions")
38
+ t.add_row("KLD worst 0.1%", f"{m.kld_p999:.3f}", "99.9th percentile")
39
+ t.add_row("top-1 agree", f"{m.top1_agree * 100:.1f} %", "same best guess as the original")
40
+ t.add_row("ppl ratio", f"{m.ppl_ratio:.4f}", "1.0 means no change")
41
+ d = m.damage
42
+ if d:
43
+ t.add_row("loops", f"{d.loops} / {d.n_prompts}", f"original: {d.loops_ref}")
44
+ t.add_row(
45
+ "wrong script", f"{d.wrong_script} / {d.n_prompts}", "script not in the original's text"
46
+ )
47
+ t.add_row("valid JSON", f"{d.json_valid} / {d.json_n}", f"original: {d.json_valid_ref}")
48
+ t.add_row(
49
+ "first divergence",
50
+ f"{d.first_divergence_median:.0f} tokens",
51
+ "median, vs the original's greedy text",
52
+ )
53
+ c.print(t)
54
+ c.print("by group: " + " ".join(f"{g} {gm.kld_mean:.4f}" for g, gm in m.by_group.items()))
55
+ for w in r.warnings:
56
+ c.print(f"[yellow]note:[/yellow] {w}")
57
+
58
+
59
+ def print_compare(c: Console, results: list[ScoreResult]) -> None:
60
+ t = Table(show_header=True, header_style="bold")
61
+ for col, j in (("quant", "left"), ("size", "right"), ("KLD mean", "right"), ("95% CI", "right"),
62
+ ("worst 1%", "right"), ("top-1", "right"), ("ppl", "right"), ("loops", "right"),
63
+ ("wrong script", "right"), ("JSON ok", "right")): # fmt: skip
64
+ t.add_column(col, justify=j)
65
+ for r in sorted(results, key=lambda r: r.metrics.kld_mean):
66
+ m, d = r.metrics, r.metrics.damage
67
+ t.add_row(
68
+ r.quant.label + (" *" if r.approximate else "") + (" (quick)" if r.quick else ""),
69
+ human_size(r.quant.size_bytes),
70
+ f"{m.kld_mean:.4f}",
71
+ f"{m.kld_mean_ci95[0]:.4f}-{m.kld_mean_ci95[1]:.4f}",
72
+ f"{m.kld_p99:.3f}",
73
+ f"{m.top1_agree * 100:.1f}%",
74
+ f"{m.ppl_ratio:.3f}",
75
+ f"{d.loops}/{d.loops_ref}" if d else "-",
76
+ f"{d.wrong_script}" if d else "-",
77
+ f"{d.json_valid}/{d.json_n}" if d else "-",
78
+ )
79
+ c.print(t)
80
+ if any(r.approximate for r in results):
81
+ c.print("* approximate (server top-N)")
82
+ c.print("loops shows quant/original. Lower KLD is better.")
@@ -0,0 +1,4 @@
1
+ from quantgap.score.kld import kld_full, kld_topk, ppl_ratio
2
+ from quantgap.score.stats import bootstrap_mean_ci, percentile
3
+
4
+ __all__ = ["bootstrap_mean_ci", "kld_full", "kld_topk", "percentile", "ppl_ratio"]
@@ -0,0 +1,33 @@
1
+ """Build a comparison report from several results (one pack only, spec E7)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from quantgap.models import CompareReport, ScoreResult
6
+ from quantgap.score.recommend import ASSUMPTION, recommend
7
+
8
+
9
+ class MixedPacksError(ValueError):
10
+ pass
11
+
12
+
13
+ def build_report(results: list[ScoreResult], budget_bytes: int | None = None) -> CompareReport:
14
+ if not results:
15
+ raise ValueError("Nothing to compare.")
16
+ ids = {r.pack_id for r in results}
17
+ if len(ids) > 1:
18
+ raise MixedPacksError(
19
+ "These results come from different packs, so their numbers cannot share one "
20
+ f"table: {', '.join(sorted(ids))}. Score all quants against the same pack."
21
+ )
22
+ rec_text = None
23
+ if budget_bytes:
24
+ rec = recommend(results, budget_bytes)
25
+ rec_text = " ".join([rec.sentence, *rec.warnings])
26
+ return CompareReport(
27
+ pack_id=results[0].pack_id,
28
+ pack_name=results[0].pack_name,
29
+ results=results,
30
+ budget_bytes=budget_bytes,
31
+ recommendation=rec_text,
32
+ assumptions=ASSUMPTION if budget_bytes else None,
33
+ )
@@ -0,0 +1,103 @@
1
+ """Visible-damage checks on greedy generations (section 7.4 of the spec).
2
+
3
+ Plain words: KLD can look small while a model still does something obviously wrong, like
4
+ repeating itself forever, dropping a Chinese character into English text, or breaking JSON.
5
+ We run short greedy generations and count those failures, always next to the reference's own.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from collections import Counter
12
+ from statistics import median
13
+
14
+ LOOP_NGRAM = 8
15
+ LOOP_REPEATS = 4
16
+ TAIL_WINDOW = 64
17
+ TAIL_MIN_DISTINCT = 12
18
+
19
+ # Unicode ranges for the scripts that most often leak into the wrong place.
20
+ _SCRIPTS: dict[str, list[tuple[int, int]]] = {
21
+ "cjk": [(0x4E00, 0x9FFF), (0x3400, 0x4DBF), (0x3040, 0x30FF), (0xAC00, 0xD7AF)],
22
+ "cyrillic": [(0x0400, 0x052F)],
23
+ "arabic": [(0x0600, 0x06FF), (0x0750, 0x077F), (0xFB50, 0xFDFF), (0xFE70, 0xFEFF)],
24
+ "devanagari": [(0x0900, 0x097F)],
25
+ }
26
+ # Groups where only the reference's own scripts are expected.
27
+ SCRIPT_CHECK_GROUPS = ("general", "code", "math", "json")
28
+
29
+
30
+ def is_loop(ids: list[int]) -> bool:
31
+ """True if any 8-token run repeats 4+ times, or the last 64 tokens use fewer than 12 tokens."""
32
+ n = len(ids)
33
+ if n >= LOOP_NGRAM * LOOP_REPEATS:
34
+ counts = Counter(tuple(ids[i : i + LOOP_NGRAM]) for i in range(n - LOOP_NGRAM + 1))
35
+ if counts.most_common(1)[0][1] >= LOOP_REPEATS:
36
+ return True
37
+ return n >= TAIL_WINDOW and len(set(ids[-TAIL_WINDOW:])) < TAIL_MIN_DISTINCT
38
+
39
+
40
+ def scripts_in(text: str) -> set[str]:
41
+ found: set[str] = set()
42
+ for ch in text:
43
+ c = ord(ch)
44
+ if c < 0x0400:
45
+ continue
46
+ for name, ranges in _SCRIPTS.items():
47
+ if name not in found and any(lo <= c <= hi for lo, hi in ranges):
48
+ found.add(name)
49
+ return found
50
+
51
+
52
+ def has_wrong_script(quant_text: str, ref_text: str) -> bool:
53
+ """True if the quant text uses a script the reference text does not."""
54
+ return bool(scripts_in(quant_text) - scripts_in(ref_text))
55
+
56
+
57
+ def first_json_block(text: str) -> str | None:
58
+ """The first balanced {...} block in the text, or None."""
59
+ start = text.find("{")
60
+ if start < 0:
61
+ return None
62
+ depth, in_str, esc = 0, False, False
63
+ for i in range(start, len(text)):
64
+ ch = text[i]
65
+ if in_str:
66
+ if esc:
67
+ esc = False
68
+ elif ch == "\\":
69
+ esc = True
70
+ elif ch == '"':
71
+ in_str = False
72
+ continue
73
+ if ch == '"':
74
+ in_str = True
75
+ elif ch == "{":
76
+ depth += 1
77
+ elif ch == "}":
78
+ depth -= 1
79
+ if depth == 0:
80
+ return text[start : i + 1]
81
+ return None
82
+
83
+
84
+ def json_valid(text: str) -> bool:
85
+ block = first_json_block(text)
86
+ if block is None:
87
+ return False
88
+ try:
89
+ return isinstance(json.loads(block), dict)
90
+ except ValueError:
91
+ return False
92
+
93
+
94
+ def first_divergence(quant_ids: list[int], ref_ids: list[int]) -> int:
95
+ """Index of the first token where the two outputs differ (the length if one is a prefix)."""
96
+ for i, (a, b) in enumerate(zip(quant_ids, ref_ids, strict=False)):
97
+ if a != b:
98
+ return i
99
+ return min(len(quant_ids), len(ref_ids))
100
+
101
+
102
+ def median_or_zero(values: list[int]) -> float:
103
+ return float(median(values)) if values else 0.0
quantgap/score/kld.py ADDED
@@ -0,0 +1,73 @@
1
+ """Top-k KLD with a tail bucket, top-1 agreement, perplexity ratio.
2
+
3
+ Plain words: at each position the reference model gives probabilities to its k best next tokens
4
+ and the rest of the probability ("the tail") is lumped into one bucket. We ask the quant for
5
+ its probabilities on the same k tokens, lump its leftover into a tail bucket too, and measure
6
+ how far apart the two sets of numbers are. Lumping can only make two distributions look
7
+ closer, so this number is a lower bound of the true KLD (see docs/method.md).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+
14
+ import numpy as np
15
+
16
+ EPS = 1e-12 # floor for the quant's probabilities before taking a log
17
+ TAIL_SKIP = 1e-9 # ignore the reference tail term when its mass is this small
18
+
19
+
20
+ @dataclass
21
+ class KLResult:
22
+ kl: np.ndarray # (n,) per-position KL_k
23
+ clamps: int # how many probabilities had to be raised to EPS
24
+
25
+
26
+ def kld_topk(p_logp: np.ndarray, q_logp: np.ndarray) -> KLResult:
27
+ """Per-position top-k + tail KLD.
28
+
29
+ p_logp: (n, k) reference log probabilities of its own top-k ids.
30
+ q_logp: (n, k) quant log probabilities of the *same* ids. NaN means "the backend could not
31
+ tell" (a server that returns only its own top-N). Such ids are moved into the tail bucket
32
+ for both distributions, which keeps the result a valid lower bound.
33
+ """
34
+ p_logp = np.asarray(p_logp, dtype=np.float64)
35
+ q_logp = np.asarray(q_logp, dtype=np.float64)
36
+ valid = ~np.isnan(q_logp)
37
+ p_lp = np.where(valid, p_logp, 0.0)
38
+ p = np.where(valid, np.exp(p_logp), 0.0)
39
+ q = np.where(valid, np.exp(np.where(valid, q_logp, 0.0)), 0.0)
40
+
41
+ clamped = valid & (q < EPS)
42
+ q_safe = np.maximum(q, EPS)
43
+ head = np.where(valid, p * (p_lp - np.log(q_safe)), 0.0).sum(axis=1)
44
+
45
+ p_tail = np.clip(1.0 - p.sum(axis=1), 0.0, 1.0)
46
+ q_tail_raw = 1.0 - q.sum(axis=1)
47
+ q_tail = np.maximum(q_tail_raw, EPS)
48
+ use_tail = p_tail >= TAIL_SKIP
49
+ tail = np.where(use_tail, p_tail * (np.log(np.maximum(p_tail, EPS)) - np.log(q_tail)), 0.0)
50
+ # A clearly negative tail means the quant's probabilities add up to more than 1: broken.
51
+ broken_tail = use_tail & (q_tail_raw < -1e-6)
52
+ clamps = int(clamped.sum() + broken_tail.sum())
53
+ return KLResult(head + tail, clamps)
54
+
55
+
56
+ def log_softmax(x: np.ndarray) -> np.ndarray:
57
+ """Stable log-softmax in float64 (subtract the max first)."""
58
+ x = np.asarray(x, dtype=np.float64)
59
+ m = x.max(axis=-1, keepdims=True)
60
+ z = x - m
61
+ return z - np.log(np.exp(z).sum(axis=-1, keepdims=True))
62
+
63
+
64
+ def kld_full(p_logits: np.ndarray, q_logits: np.ndarray) -> np.ndarray:
65
+ """Exact KLD over the whole vocabulary (used by tests and `doctor --selftest`)."""
66
+ p_lp = log_softmax(p_logits)
67
+ q_lp = log_softmax(q_logits)
68
+ return (np.exp(p_lp) * (p_lp - q_lp)).sum(axis=-1)
69
+
70
+
71
+ def ppl_ratio(logp_target_quant: np.ndarray, logp_target_ref: np.ndarray) -> float:
72
+ """exp(mean(-log q)) / exp(mean(-log p)) over scored positions. 1.0 means no change."""
73
+ return float(np.exp(np.mean(logp_target_ref - logp_target_quant)))