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.
- quantgap/__init__.py +3 -0
- quantgap/backends/__init__.py +60 -0
- quantgap/backends/base.py +118 -0
- quantgap/backends/hf.py +77 -0
- quantgap/backends/llamacpp.py +99 -0
- quantgap/backends/mlx.py +86 -0
- quantgap/backends/openai_compat.py +164 -0
- quantgap/cli.py +521 -0
- quantgap/config.py +60 -0
- quantgap/hub/__init__.py +1 -0
- quantgap/hub/packs.py +142 -0
- quantgap/hub/resolve.py +247 -0
- quantgap/hub/submit.py +87 -0
- quantgap/models.py +202 -0
- quantgap/pack/__init__.py +4 -0
- quantgap/pack/reader.py +142 -0
- quantgap/pack/runner.py +301 -0
- quantgap/pack/writer.py +162 -0
- quantgap/probes/__init__.py +11 -0
- quantgap/probes/build.py +484 -0
- quantgap/probes/data/SOURCES.md +15 -0
- quantgap/probes/data/probes_v1.jsonl +160 -0
- quantgap/probes/registry.py +34 -0
- quantgap/probes/render.py +158 -0
- quantgap/report/__init__.py +0 -0
- quantgap/report/chart.py +92 -0
- quantgap/report/jsonout.py +25 -0
- quantgap/report/table.py +82 -0
- quantgap/score/__init__.py +4 -0
- quantgap/score/compare.py +33 -0
- quantgap/score/damage.py +103 -0
- quantgap/score/kld.py +73 -0
- quantgap/score/recommend.py +78 -0
- quantgap/score/run.py +260 -0
- quantgap/score/stats.py +33 -0
- quantgap/tokcheck.py +71 -0
- quantgap/util.py +95 -0
- quantgap-0.1.0.dist-info/METADATA +311 -0
- quantgap-0.1.0.dist-info/RECORD +42 -0
- quantgap-0.1.0.dist-info/WHEEL +4 -0
- quantgap-0.1.0.dist-info/entry_points.txt +2 -0
- quantgap-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -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
|
quantgap/report/chart.py
ADDED
|
@@ -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
|
quantgap/report/table.py
ADDED
|
@@ -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,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
|
+
)
|
quantgap/score/damage.py
ADDED
|
@@ -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)))
|