quant-regress 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.
- quant_regress/__init__.py +13 -0
- quant_regress/adapter.py +70 -0
- quant_regress/cli.py +110 -0
- quant_regress/evalset.py +69 -0
- quant_regress/harness.py +209 -0
- quant_regress-0.1.0.dist-info/METADATA +89 -0
- quant_regress-0.1.0.dist-info/RECORD +10 -0
- quant_regress-0.1.0.dist-info/WHEEL +5 -0
- quant_regress-0.1.0.dist-info/entry_points.txt +2 -0
- quant_regress-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""quant-regress — task-level regression testing for quantized models.
|
|
2
|
+
|
|
3
|
+
Perplexity and token-level metrics do not reliably detect the damage
|
|
4
|
+
quantization does at the margins. This package measures task accuracy
|
|
5
|
+
before and after quantization so a regression fails a build instead of
|
|
6
|
+
shipping.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
|
|
11
|
+
from .harness import QuantHarness, ComparisonResult, Verdict
|
|
12
|
+
|
|
13
|
+
__all__ = ["QuantHarness", "ComparisonResult", "Verdict", "__version__"]
|
quant_regress/adapter.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Build real models at a requested precision.
|
|
2
|
+
|
|
3
|
+
Only dynamic ``quantize_dynamic`` INT8 is supported. That is deliberate:
|
|
4
|
+
|
|
5
|
+
* it needs no extra dependency (no autoawq / optimum / onnxruntime),
|
|
6
|
+
* it emits CPU-only kernels, which is what CI runners have, and
|
|
7
|
+
* it is the exact method that produced the motivating INT8 accuracy collapse.
|
|
8
|
+
|
|
9
|
+
Attempting to move a quantized model to CUDA raises at forward time, so the
|
|
10
|
+
device is pinned to CPU and we say so rather than letting it fail obscurely.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Callable
|
|
16
|
+
|
|
17
|
+
import torch
|
|
18
|
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
|
19
|
+
|
|
20
|
+
SUPPORTED = ("fp32", "int8")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AdapterError(RuntimeError):
|
|
24
|
+
"""Raised with an actionable message when a model cannot be built."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _quantize_int8(model):
|
|
28
|
+
"""Dynamic INT8 on Linear layers; embeddings/LayerNorm stay FP32."""
|
|
29
|
+
return torch.ao.quantization.quantize_dynamic(
|
|
30
|
+
model, {torch.nn.Linear}, dtype=torch.qint8
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_model_factory(
|
|
35
|
+
model_name: str,
|
|
36
|
+
cache_dir: str | None = None,
|
|
37
|
+
max_new_tokens: int = 32,
|
|
38
|
+
) -> Callable[[str], object]:
|
|
39
|
+
if not model_name or not model_name.strip():
|
|
40
|
+
raise AdapterError(
|
|
41
|
+
"no model specified. Pass model: to the action, e.g. "
|
|
42
|
+
"model: meta-llama/Llama-3.2-1B-Instruct"
|
|
43
|
+
)
|
|
44
|
+
name = model_name.strip()
|
|
45
|
+
|
|
46
|
+
def factory(precision: str):
|
|
47
|
+
p = precision.lower().strip()
|
|
48
|
+
if p not in SUPPORTED:
|
|
49
|
+
raise ValueError(
|
|
50
|
+
f"unsupported precision {precision!r}; supported: {SUPPORTED}"
|
|
51
|
+
)
|
|
52
|
+
kwargs = {"cache_dir": cache_dir} if cache_dir else {}
|
|
53
|
+
try:
|
|
54
|
+
tok = AutoTokenizer.from_pretrained(name, **kwargs)
|
|
55
|
+
model = AutoModelForCausalLM.from_pretrained(name, **kwargs)
|
|
56
|
+
except Exception as exc:
|
|
57
|
+
raise AdapterError(
|
|
58
|
+
f"could not load {name!r}: {type(exc).__name__}: {exc}. "
|
|
59
|
+
"Check the model id, and that the runner has disk space "
|
|
60
|
+
"(a 1B model is ~2 GB, a 7B model is ~14 GB)."
|
|
61
|
+
) from exc
|
|
62
|
+
if p == "int8":
|
|
63
|
+
model = _quantize_int8(model)
|
|
64
|
+
# quantize_dynamic emits CPU-only kernels.
|
|
65
|
+
model = model.to("cpu").eval()
|
|
66
|
+
model.tokenizer = tok # attached for the harness
|
|
67
|
+
model.max_new_tokens = max_new_tokens
|
|
68
|
+
return model
|
|
69
|
+
|
|
70
|
+
return factory
|
quant_regress/cli.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Command-line entry point.
|
|
2
|
+
|
|
3
|
+
Exit codes are the contract with CI:
|
|
4
|
+
|
|
5
|
+
* ``0`` — within tolerance
|
|
6
|
+
* ``1`` — accuracy regressed past ``--max-drop-points``
|
|
7
|
+
* ``2`` — bad usage / configuration (missing model, unreadable eval set)
|
|
8
|
+
|
|
9
|
+
A JSON report is written when ``--report`` is given so a workflow can publish
|
|
10
|
+
it as an artifact.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .adapter import AdapterError, build_model_factory
|
|
21
|
+
from .evalset import EvalSet, EvalSetError
|
|
22
|
+
from .harness import QuantHarness, Verdict
|
|
23
|
+
|
|
24
|
+
EXIT_OK, EXIT_REGRESSION, EXIT_USAGE = 0, 1, 2
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
28
|
+
p = argparse.ArgumentParser(
|
|
29
|
+
prog="quant-regress",
|
|
30
|
+
description="Fail CI when quantization costs you task accuracy.",
|
|
31
|
+
)
|
|
32
|
+
p.add_argument("--eval", required=True, help="path to a JSONL task eval set")
|
|
33
|
+
p.add_argument("--model", default=None, help="HF model id (required for a real run)")
|
|
34
|
+
p.add_argument("--precisions", default="int8",
|
|
35
|
+
help="comma-separated candidate precisions (default: int8)")
|
|
36
|
+
p.add_argument("--baseline-precision", default="fp32")
|
|
37
|
+
p.add_argument("--max-drop-points", type=float, default=2.0,
|
|
38
|
+
help="fail if any candidate drops more than this many points")
|
|
39
|
+
p.add_argument("--max-new-tokens", type=int, default=32)
|
|
40
|
+
p.add_argument("--report", default=None, help="write a JSON report here")
|
|
41
|
+
p.add_argument("--cache-dir", default=None)
|
|
42
|
+
return p
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _markdown(res) -> str:
|
|
46
|
+
b = res.baseline
|
|
47
|
+
lines = [
|
|
48
|
+
"| precision | accuracy | correct | drop |",
|
|
49
|
+
"|---|---|---|---|",
|
|
50
|
+
f"| {b.precision} (baseline) | {b.accuracy*100:.1f}% | {b.correct}/{b.total} | — |",
|
|
51
|
+
]
|
|
52
|
+
for c in res.candidates:
|
|
53
|
+
drop = (b.accuracy - c.accuracy) * 100.0
|
|
54
|
+
lines.append(
|
|
55
|
+
f"| {c.precision} | {c.accuracy*100:.1f}% | {c.correct}/{c.total} | "
|
|
56
|
+
f"{drop:+.1f} pts |"
|
|
57
|
+
)
|
|
58
|
+
return "\n".join(lines)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main(argv: list[str] | None = None) -> int:
|
|
62
|
+
args = build_parser().parse_args(argv)
|
|
63
|
+
try:
|
|
64
|
+
eval_set = EvalSet.load(args.eval)
|
|
65
|
+
except EvalSetError as exc:
|
|
66
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
67
|
+
return EXIT_USAGE
|
|
68
|
+
if not args.model:
|
|
69
|
+
print(
|
|
70
|
+
"error: --model is required (e.g. --model "
|
|
71
|
+
"meta-llama/Llama-3.2-1B-Instruct)", file=sys.stderr
|
|
72
|
+
)
|
|
73
|
+
return EXIT_USAGE
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
factory = build_model_factory(
|
|
77
|
+
args.model, cache_dir=args.cache_dir,
|
|
78
|
+
max_new_tokens=args.max_new_tokens,
|
|
79
|
+
)
|
|
80
|
+
except AdapterError as exc:
|
|
81
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
82
|
+
return EXIT_USAGE
|
|
83
|
+
|
|
84
|
+
harness = QuantHarness(model_factory=factory, max_new_tokens=args.max_new_tokens)
|
|
85
|
+
try:
|
|
86
|
+
res = harness.compare(
|
|
87
|
+
eval_set,
|
|
88
|
+
baseline_precision=args.baseline_precision,
|
|
89
|
+
candidate_precisions=[p.strip() for p in args.precisions.split(",") if p.strip()],
|
|
90
|
+
max_drop_points=args.max_drop_points,
|
|
91
|
+
)
|
|
92
|
+
except RuntimeError as exc:
|
|
93
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
94
|
+
return EXIT_USAGE
|
|
95
|
+
|
|
96
|
+
print(_markdown(res))
|
|
97
|
+
print(f"\nworst drop: {res.worst_drop_points:+.2f} pts "
|
|
98
|
+
f"(threshold {args.max_drop_points:+.2f}) -> {res.verdict.value.upper()}")
|
|
99
|
+
|
|
100
|
+
if args.report:
|
|
101
|
+
Path(args.report).parent.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
Path(args.report).write_text(
|
|
103
|
+
json.dumps(res.to_dict(), indent=2) + "\n", encoding="utf-8"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
return EXIT_OK if res.verdict is Verdict.PASS else EXIT_REGRESSION
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
raise SystemExit(main())
|
quant_regress/evalset.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Eval-set loading for quant-regress.
|
|
2
|
+
|
|
3
|
+
Format: JSONL, one case per line, each ``{"id", "prompt", "expected"}``.
|
|
4
|
+
Comparison is exact-after-normalisation by design: a fuzzy scorer would hide
|
|
5
|
+
the marginal-decision damage this tool exists to detect.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Callable, Iterable
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EvalSetError(ValueError):
|
|
17
|
+
"""Raised when an eval set is malformed. Always names the offending row."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def normalise(text: str) -> str:
|
|
21
|
+
"""Lowercase, strip, and collapse internal whitespace."""
|
|
22
|
+
return " ".join(str(text).split()).strip().lower()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Case:
|
|
27
|
+
id: str
|
|
28
|
+
prompt: str
|
|
29
|
+
expected: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class EvalSet:
|
|
33
|
+
def __init__(self, cases: Iterable[Case]):
|
|
34
|
+
self.cases = list(cases)
|
|
35
|
+
|
|
36
|
+
def __len__(self) -> int:
|
|
37
|
+
return len(self.cases)
|
|
38
|
+
|
|
39
|
+
def __iter__(self):
|
|
40
|
+
return iter(self.cases)
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def load(cls, path: str | Path) -> "EvalSet":
|
|
44
|
+
p = Path(path)
|
|
45
|
+
if not p.exists():
|
|
46
|
+
raise EvalSetError(f"eval set not found: {p}")
|
|
47
|
+
cases: list[Case] = []
|
|
48
|
+
seen: set[str] = set()
|
|
49
|
+
for lineno, raw in enumerate(p.read_text(encoding="utf-8").splitlines(), 1):
|
|
50
|
+
line = raw.strip()
|
|
51
|
+
if not line:
|
|
52
|
+
continue
|
|
53
|
+
try:
|
|
54
|
+
row = json.loads(line)
|
|
55
|
+
except json.JSONDecodeError as exc:
|
|
56
|
+
raise EvalSetError(f"line {lineno}: invalid JSON ({exc})") from exc
|
|
57
|
+
missing = [k for k in ("id", "prompt", "expected") if k not in row]
|
|
58
|
+
if missing:
|
|
59
|
+
raise EvalSetError(f"line {lineno}: missing {', '.join(missing)}")
|
|
60
|
+
cid = str(row["id"])
|
|
61
|
+
if cid in seen:
|
|
62
|
+
raise EvalSetError(f"line {lineno}: duplicate id {cid!r}")
|
|
63
|
+
seen.add(cid)
|
|
64
|
+
cases.append(
|
|
65
|
+
Case(id=cid, prompt=str(row["prompt"]), expected=normalise(row["expected"]))
|
|
66
|
+
)
|
|
67
|
+
if not cases:
|
|
68
|
+
raise EvalSetError(f"eval set is empty: {p}")
|
|
69
|
+
return cls(cases)
|
quant_regress/harness.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Core harness: run a task eval at several precisions and compare accuracy.
|
|
2
|
+
|
|
3
|
+
The point of this module is to make the marginal-decision damage visible.
|
|
4
|
+
Token-level proxies (perplexity, KL) routinely miss it; task accuracy does
|
|
5
|
+
not, provided the whole set is actually run — which the harness enforces by
|
|
6
|
+
scoring every case at every precision and refusing to report a partial run.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from typing import Callable, Sequence
|
|
15
|
+
|
|
16
|
+
from .evalset import EvalSet, normalise
|
|
17
|
+
|
|
18
|
+
SUPPORTED_PRECISIONS = ("fp32", "int8")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Verdict(str, Enum):
|
|
22
|
+
PASS = "pass"
|
|
23
|
+
FAIL = "fail"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class PrecisionResult:
|
|
28
|
+
precision: str
|
|
29
|
+
correct: int
|
|
30
|
+
total: int
|
|
31
|
+
seconds: float
|
|
32
|
+
misclassified: list[str] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def accuracy(self) -> float:
|
|
36
|
+
return (self.correct / self.total) if self.total else 0.0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ComparisonResult:
|
|
41
|
+
baseline: PrecisionResult
|
|
42
|
+
candidates: list[PrecisionResult]
|
|
43
|
+
max_drop_points: float
|
|
44
|
+
verdict: Verdict
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def worst_drop_points(self) -> float:
|
|
48
|
+
drops = [
|
|
49
|
+
(self.baseline.accuracy - c.accuracy) * 100.0 for c in self.candidates
|
|
50
|
+
]
|
|
51
|
+
return max(drops) if drops else 0.0
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> dict:
|
|
54
|
+
return {
|
|
55
|
+
"baseline": {
|
|
56
|
+
"precision": self.baseline.precision,
|
|
57
|
+
"accuracy": round(self.baseline.accuracy, 4),
|
|
58
|
+
"correct": self.baseline.correct,
|
|
59
|
+
"total": self.baseline.total,
|
|
60
|
+
"seconds": round(self.baseline.seconds, 3),
|
|
61
|
+
},
|
|
62
|
+
"candidates": [
|
|
63
|
+
{
|
|
64
|
+
"precision": c.precision,
|
|
65
|
+
"accuracy": round(c.accuracy, 4),
|
|
66
|
+
"correct": c.correct,
|
|
67
|
+
"total": c.total,
|
|
68
|
+
"seconds": round(c.seconds, 3),
|
|
69
|
+
"drop_points": round(
|
|
70
|
+
(self.baseline.accuracy - c.accuracy) * 100.0, 3
|
|
71
|
+
),
|
|
72
|
+
}
|
|
73
|
+
for c in self.candidates
|
|
74
|
+
],
|
|
75
|
+
"worst_drop_points": round(self.worst_drop_points, 3),
|
|
76
|
+
"max_drop_points": self.max_drop_points,
|
|
77
|
+
"verdict": self.verdict.value,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class QuantHarness:
|
|
82
|
+
"""Measures task accuracy for a model at each requested precision.
|
|
83
|
+
|
|
84
|
+
``model_factory(precision) -> model`` is injected so tests can run with a
|
|
85
|
+
deterministic fake and CI can run with a real model.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
model_factory: Callable[[str], object],
|
|
91
|
+
max_new_tokens: int = 32,
|
|
92
|
+
scorer: Callable[[str, str], bool] | None = None,
|
|
93
|
+
system_prompt: str | None = None,
|
|
94
|
+
labels: Sequence[str] | None = None,
|
|
95
|
+
):
|
|
96
|
+
if scorer is None:
|
|
97
|
+
def scorer(pred: str, expected: str) -> bool:
|
|
98
|
+
return normalise(pred) == expected
|
|
99
|
+
self._factory = model_factory
|
|
100
|
+
self.max_new_tokens = max_new_tokens
|
|
101
|
+
self._scorer = scorer
|
|
102
|
+
self.system_prompt = system_prompt
|
|
103
|
+
# Label set for models that expose only logits and carry no id2label.
|
|
104
|
+
self.labels = list(labels) if labels else []
|
|
105
|
+
|
|
106
|
+
# -- public -----------------------------------------------------------
|
|
107
|
+
def compare(
|
|
108
|
+
self,
|
|
109
|
+
eval_set: EvalSet,
|
|
110
|
+
baseline_precision: str = "fp32",
|
|
111
|
+
candidate_precisions: Sequence[str] = ("int8",),
|
|
112
|
+
max_drop_points: float = 2.0,
|
|
113
|
+
) -> ComparisonResult:
|
|
114
|
+
for p in (baseline_precision, *candidate_precisions):
|
|
115
|
+
if p not in SUPPORTED_PRECISIONS:
|
|
116
|
+
raise ValueError(
|
|
117
|
+
f"unsupported precision {p!r}; supported: {SUPPORTED_PRECISIONS}"
|
|
118
|
+
)
|
|
119
|
+
if not len(eval_set):
|
|
120
|
+
raise ValueError("eval set is empty")
|
|
121
|
+
|
|
122
|
+
baseline = self._measure(eval_set, baseline_precision)
|
|
123
|
+
candidates = [self._measure(eval_set, p) for p in candidate_precisions]
|
|
124
|
+
|
|
125
|
+
# Every precision must have scored the whole set, or the comparison
|
|
126
|
+
# is meaningless. Refuse rather than report a partial number.
|
|
127
|
+
for r in (baseline, *candidates):
|
|
128
|
+
if r.total != len(eval_set):
|
|
129
|
+
raise RuntimeError(
|
|
130
|
+
f"incomplete run at {r.precision}: scored {r.total} of "
|
|
131
|
+
f"{len(eval_set)} cases"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
worst = max(
|
|
135
|
+
((baseline.accuracy - c.accuracy) * 100.0 for c in candidates), default=0.0
|
|
136
|
+
)
|
|
137
|
+
return ComparisonResult(
|
|
138
|
+
baseline=baseline,
|
|
139
|
+
candidates=candidates,
|
|
140
|
+
max_drop_points=max_drop_points,
|
|
141
|
+
verdict=Verdict.FAIL if worst > max_drop_points else Verdict.PASS,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# -- internals --------------------------------------------------------
|
|
145
|
+
def _measure(self, eval_set: EvalSet, precision: str) -> PrecisionResult:
|
|
146
|
+
model = self._factory(precision)
|
|
147
|
+
tok = getattr(model, "tokenizer", None)
|
|
148
|
+
correct = 0
|
|
149
|
+
mis: list[str] = []
|
|
150
|
+
t0 = time.perf_counter()
|
|
151
|
+
for case in eval_set:
|
|
152
|
+
pred = self._predict(model, tok, case.prompt)
|
|
153
|
+
ok = self._scorer(pred, case.expected)
|
|
154
|
+
correct += int(ok)
|
|
155
|
+
if not ok:
|
|
156
|
+
mis.append(case.id)
|
|
157
|
+
return PrecisionResult(
|
|
158
|
+
precision=precision,
|
|
159
|
+
correct=correct,
|
|
160
|
+
total=len(eval_set),
|
|
161
|
+
seconds=time.perf_counter() - t0,
|
|
162
|
+
misclassified=mis,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def _predict(self, model, tok, prompt: str) -> str:
|
|
166
|
+
"""Return the model's answer as text.
|
|
167
|
+
|
|
168
|
+
Three supported model shapes, in priority order:
|
|
169
|
+
|
|
170
|
+
1. A real HF model with ``generate`` + tokenizer — decode the output.
|
|
171
|
+
2. A model exposing ``.answer(prompt)`` — used by fakes and by any model
|
|
172
|
+
whose output is not free text (a classifier, a scorer).
|
|
173
|
+
3. A bare callable returning ``.logits`` — decoded via the model's own
|
|
174
|
+
``id2label`` when it has one, else by argmax over a caller-supplied
|
|
175
|
+
label set.
|
|
176
|
+
|
|
177
|
+
Shape 3 deliberately does NOT stringify the raw logit. A logit is not an
|
|
178
|
+
answer: ``str(0.95)`` can never equal ``"yes"``, so stringifying it made
|
|
179
|
+
every such model score 0% and silently defeated the regression gate.
|
|
180
|
+
"""
|
|
181
|
+
# 2. explicit text answer
|
|
182
|
+
answer = getattr(model, "answer", None)
|
|
183
|
+
if callable(answer):
|
|
184
|
+
return str(answer(prompt))
|
|
185
|
+
|
|
186
|
+
# 1. real HF generation path
|
|
187
|
+
if tok is not None and hasattr(model, "generate"):
|
|
188
|
+
text = prompt if self.system_prompt is None else f"{self.system_prompt}\n{prompt}"
|
|
189
|
+
inputs = tok(text, return_tensors="pt")
|
|
190
|
+
inputs = {k: v.to(getattr(model, "device", "cpu")) for k, v in inputs.items()}
|
|
191
|
+
out = model.generate(**inputs, max_new_tokens=self.max_new_tokens,
|
|
192
|
+
do_sample=False)
|
|
193
|
+
return tok.decode(out[0], skip_special_tokens=True)
|
|
194
|
+
|
|
195
|
+
# 3. logits -> label
|
|
196
|
+
logits = model(**{"input_ids": [[1]], "attention_mask": [[1]]}).logits
|
|
197
|
+
row = logits[0]
|
|
198
|
+
idx = int(max(range(len(row)), key=lambda i: float(row[i])))
|
|
199
|
+
id2label = getattr(model, "config", None)
|
|
200
|
+
id2label = getattr(id2label, "id2label", None) if id2label else None
|
|
201
|
+
if id2label:
|
|
202
|
+
return str(id2label.get(idx, idx))
|
|
203
|
+
if self.labels:
|
|
204
|
+
return str(self.labels[idx]) if idx < len(self.labels) else str(idx)
|
|
205
|
+
raise RuntimeError(
|
|
206
|
+
"cannot map model output to text: the model exposes no .answer(), "
|
|
207
|
+
"no generate()+tokenizer, and no id2label. Give the fake model an "
|
|
208
|
+
"answer(prompt) method, or pass labels=[...] to QuantHarness."
|
|
209
|
+
)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: quant-regress
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Fail CI when quantization costs you task accuracy. Task-level regression testing for quantized models.
|
|
5
|
+
Author: DawnofGenX
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/DawnofGenX/quant-regress
|
|
8
|
+
Project-URL: Issues, https://github.com/DawnofGenX/quant-regress/issues
|
|
9
|
+
Keywords: quantization,regression-testing,evaluation,ci,llm
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: torch>=2.0
|
|
17
|
+
Requires-Dist: transformers>=4.40
|
|
18
|
+
Requires-Dist: numpy>=1.24
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
21
|
+
|
|
22
|
+
# quant-regress
|
|
23
|
+
|
|
24
|
+
Fail CI when quantization costs you task accuracy.
|
|
25
|
+
|
|
26
|
+
Token-level proxies — perplexity, KL divergence — routinely report that a
|
|
27
|
+
quantized model is fine. Task accuracy does not, provided you actually measure
|
|
28
|
+
it. This action measures a task eval at each precision and fails the build when
|
|
29
|
+
accuracy drops past a threshold you set.
|
|
30
|
+
|
|
31
|
+
## Why
|
|
32
|
+
|
|
33
|
+
<!-- claim:int8-collapse -->
|
|
34
|
+
On a cross-encoder claim verifier, dynamic INT8 quantization left aggregate
|
|
35
|
+
accuracy looking survivable while destroying the capability that mattered:
|
|
36
|
+
|
|
37
|
+
<!-- claim:int8-collapse -->
|
|
38
|
+
| precision | accuracy | correct |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| fp32 (baseline) | 100.0% <!-- claim:int8-collapse --> | 20/20 |
|
|
41
|
+
| int8 | 0.0% <!-- claim:int8-collapse --> | 0/20 |
|
|
42
|
+
|
|
43
|
+
Regenerate with `python scripts/regen_int8_collapse.py` — every figure above
|
|
44
|
+
comes from that script, and `tests/test_claims_are_reproducible.py` fails the
|
|
45
|
+
build if a number in this file stops matching it.
|
|
46
|
+
|
|
47
|
+
The pattern that matters: the quantized model still runs, still returns a valid
|
|
48
|
+
answer, and still looks like a working model. It is simply wrong every time.
|
|
49
|
+
That is why a token-level proxy can clear it and a task eval cannot.
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
```yaml
|
|
54
|
+
- uses: DawnofGenX/quant-regress@v1
|
|
55
|
+
with:
|
|
56
|
+
eval: evals/tasks.jsonl
|
|
57
|
+
model: meta-llama/Llama-3.2-1B-Instruct
|
|
58
|
+
max-drop-points: 2
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Exit codes: `0` within tolerance, `1` accuracy regressed, `2` bad configuration.
|
|
62
|
+
|
|
63
|
+
## Constraints worth knowing
|
|
64
|
+
|
|
65
|
+
- **CPU only.** `quantize_dynamic` emits CPU-only quantized kernels; moving the
|
|
66
|
+
model to CUDA raises at forward time. GitHub-hosted runners have no GPU, so
|
|
67
|
+
this is not a limitation in CI — but it does mean GPU-only quantizers
|
|
68
|
+
(AWQ/GPTQ/GGUF) are out of scope for v1.
|
|
69
|
+
- **The threshold unit is accuracy points**, not a ratio. `--max-drop-points: 2`
|
|
70
|
+
means "fail if any candidate is more than 2 points below the baseline".
|
|
71
|
+
- Comparison is exact-after-normalisation by default. Pass a scorer if you need
|
|
72
|
+
semantic equivalence.
|
|
73
|
+
- Models are downloaded at run time. A 1B model is ~2 GB; check runner disk.
|
|
74
|
+
|
|
75
|
+
## Honest limits
|
|
76
|
+
|
|
77
|
+
- Only `fp32` and dynamic `int8` are supported in v1.
|
|
78
|
+
- The library runs generation, so it needs a task whose success is checkable in
|
|
79
|
+
code. It is not an LLM-judge harness.
|
|
80
|
+
- **The bundled `evals/fixtures/collapse.jsonl` is model-specific.** Its cases
|
|
81
|
+
expect the answer `yes`, so it only demonstrates a collapse for a model that
|
|
82
|
+
answers `yes` when unquantized. Pointed at an untuned tiny LM it scores 0/N at
|
|
83
|
+
both precisions, produces no drop, and the gate correctly reports PASS. Use it
|
|
84
|
+
to see the output format; bring your own eval for a real check.
|
|
85
|
+
- What the repo's own CI pins is the **exit-code contract** (0 holds, 1 regressed,
|
|
86
|
+
2 misconfigured), not any particular model's accuracy — see
|
|
87
|
+
`tests/test_selftest_gate.py`.
|
|
88
|
+
- No published adoption yet. If you use it, an issue saying what you quantized
|
|
89
|
+
and what broke is genuinely useful.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
quant_regress/__init__.py,sha256=FdX062901mNltHsvTWGpnAKR4MWqClsXuKihCBXlHYk,457
|
|
2
|
+
quant_regress/adapter.py,sha256=iIBYKxRBUaWzRjrZcix10nZLJoNLWAngZggDjkpxlIE,2401
|
|
3
|
+
quant_regress/cli.py,sha256=aTSbXbD_igfQwwULFm2lpuH_IYdjUYA824qKi6cLym8,3712
|
|
4
|
+
quant_regress/evalset.py,sha256=C7RtyoijUOm4crpfe9V5Pg0NwDy_JOOSmDrBBZDrG40,2144
|
|
5
|
+
quant_regress/harness.py,sha256=P3nPYRe99-Sh1Wb8SydGWFX0KFYm1CGiLUrcuAXFx4k,7852
|
|
6
|
+
quant_regress-0.1.0.dist-info/METADATA,sha256=Hev2UbdF0d0nVwD4hQgHRb5eJoiPzMA0lM_ArSVgeAY,3752
|
|
7
|
+
quant_regress-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
8
|
+
quant_regress-0.1.0.dist-info/entry_points.txt,sha256=oRiCjEh4r0wiHGdzNeGw8YktnknxPSujgpMjcDF4hCA,57
|
|
9
|
+
quant_regress-0.1.0.dist-info/top_level.txt,sha256=uQCAYM3v-PLGcdsYS4qoFdvTl-nGvO9RXZvT9W-Wqig,14
|
|
10
|
+
quant_regress-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
quant_regress
|