252 lines
10 KiB
Python
252 lines
10 KiB
Python
#!/usr/bin/env python3
|
|||
|
|
"""Compare normalizer models on the same sentences.
|
||
|
|
|
||
|
|
The shipped model is a 0.6B task-specific fine-tune that needs no prompt but
|
||
|
|
miscounts large numbers and occasionally drops a word. This runs any causal LM
|
||
|
|
over the same battery so the trade is measurable rather than argued about:
|
||
|
|
|
||
|
|
python bench.py # the fine-tune
|
||
|
|
python bench.py --model models/PLLuM-4B-instruct-2512 --prompted
|
||
|
|
python bench.py --model … --prompted --save pllum.json
|
||
|
|
python bench.py --compare fine-tune.json pllum.json
|
||
|
|
|
||
|
|
`--prompted` switches from the fine-tune's bare "text in, text out" contract to
|
||
|
|
an instruction + few-shot prompt, which a general instruct model needs. Digit
|
||
|
|
tokens are banned from the output either way, and decoding is greedy, so a
|
||
|
|
difference between two runs is the model and not the sampling.
|
||
|
|
|
||
|
|
Automatic flags are cheap heuristics, not a grade: `digits` means an
|
||
|
|
un-normalized digit survived, `lost` means a word from the input has no
|
||
|
|
plausible inflected match in the output. Read the text before believing either.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import json
|
||
|
|
import re
|
||
|
|
import sys
|
||
|
|
import time
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
HERE = Path(__file__).resolve().parent
|
||
|
|
sys.path.insert(0, str(HERE))
|
||
|
|
|
||
|
|
import normalizer as nz # noqa: E402 (reuses its protect/restore and banned list)
|
||
|
|
|
||
|
|
SYSTEM_PROMPTED = (
|
||
|
|
"Jesteś narzędziem do normalizacji tekstu dla syntezatora mowy. "
|
||
|
|
"Zamieniasz wszystkie liczby zapisane cyframi na słowa, w poprawnej formie "
|
||
|
|
"gramatycznej wynikającej z kontekstu zdania. Nie zmieniasz, nie dodajesz "
|
||
|
|
"ani nie usuwasz żadnych innych słów. Zwracasz wyłącznie przekształcone "
|
||
|
|
"zdanie, bez komentarza."
|
||
|
|
)
|
||
|
|
|
||
|
|
# Few-shot examples chosen to pin down the three things a general model gets
|
||
|
|
# wrong without them: the case must follow the governing word, the rest of the
|
||
|
|
# sentence must survive verbatim, and the answer must be the sentence alone.
|
||
|
|
FEWSHOT = [
|
||
|
|
("Koncert dla 8250 osób.",
|
||
|
|
"Koncert dla ośmiu tysięcy dwustu pięćdziesięciu osób."),
|
||
|
|
("Populacja Polski to 38 000 000 mieszkańców.",
|
||
|
|
"Populacja Polski to trzydzieści osiem milionów mieszkańców."),
|
||
|
|
("Mówimy o 25 psach.",
|
||
|
|
"Mówimy o dwudziestu pięciu psach."),
|
||
|
|
("Wygrał 1247 złotych.",
|
||
|
|
"Wygrał tysiąc dwieście czterdzieści siedem złotych."),
|
||
|
|
]
|
||
|
|
|
||
|
|
BATTERY = [
|
||
|
|
"Sprawa dotyczy 1 350 000 ludzi.",
|
||
|
|
"Koncert dla 8250 osób.",
|
||
|
|
"Budżet wynosi 12 500 000 złotych.",
|
||
|
|
"Populacja Polski to 38 000 000 mieszkańców.",
|
||
|
|
"Zapłacili 1 500 000 000 dolarów.",
|
||
|
|
"Zostało 999 999 sztuk.",
|
||
|
|
"Numer seryjny 1234567.",
|
||
|
|
"Ponad 100 000 widzów na stadionie.",
|
||
|
|
"Mówimy o 1 350 000 ludziach.",
|
||
|
|
"Z 8250 osobami nie da się dyskutować.",
|
||
|
|
"Firma zatrudnia 2 500 pracowników.",
|
||
|
|
"Dystans 42 195 metrów.",
|
||
|
|
"Cena spadła z 15 700 000 do 8 250 000.",
|
||
|
|
"Było tam 25 psów.",
|
||
|
|
"Mówimy o 25 psach.",
|
||
|
|
"Przyjechał z 25 psami.",
|
||
|
|
"Przyglądał się 25 psom.",
|
||
|
|
"Nie ma tu 25 psów.",
|
||
|
|
"Urodził się 3 maja 1791 roku.",
|
||
|
|
"Wzrost o 16 procent w 2026 roku.",
|
||
|
|
"Spotkanie o 14:30.",
|
||
|
|
"Godzina 23:56.",
|
||
|
|
"Radio 95,7 FM.",
|
||
|
|
"Około 3,5 metra.",
|
||
|
|
"Mieszka pod 12/45.",
|
||
|
|
"Wygrał 1247 złotych.",
|
||
|
|
"To kosztuje 1999 dolarów i 99 centów.",
|
||
|
|
"Mam 3 koty i 25 psów.",
|
||
|
|
"Zostało 5 minut.",
|
||
|
|
"Temperatura opon w porządku. Przód 85, tył 75.",
|
||
|
|
"Louis Chiron miał 57 lat, kiedy wygrał Grand Prix Monako.",
|
||
|
|
"Zwykłe zdanie bez żadnych liczb.",
|
||
|
|
]
|
||
|
|
|
||
|
|
_WORD_RE = re.compile(r'[^\W\d_]+', re.UNICODE)
|
||
|
|
|
||
|
|
|
||
|
|
def lost_words(src: str, out: str) -> list:
|
||
|
|
"""Input words with no plausible match in the output.
|
||
|
|
|
||
|
|
Polish inflects, so an exact comparison is useless — `ludziach` legitimately
|
||
|
|
becomes `ludziach` and nothing else, but `psach`/`psami` share a stem. A
|
||
|
|
five-character prefix is a crude stand-in for a lemma and is good enough to
|
||
|
|
notice a noun that vanished entirely, which is the failure worth catching.
|
||
|
|
"""
|
||
|
|
def stems(text):
|
||
|
|
return {w.lower()[:5] for w in _WORD_RE.findall(text)}
|
||
|
|
missing = stems(src) - stems(out)
|
||
|
|
return sorted(w for w in missing if len(w) >= 4)
|
||
|
|
|
||
|
|
|
||
|
|
def load_prompted(model_dir: str, gpu_gib: float = None,
|
||
|
|
system: str = None, fewshot: list = None):
|
||
|
|
"""A general instruct model: system prompt, few-shot, greedy, digits banned.
|
||
|
|
|
||
|
|
`gpu_gib` caps GPU use and spills the rest to RAM. Needed for the int4
|
||
|
|
"awq" builds: transformers decompresses compressed-tensors weights back to
|
||
|
|
bfloat16 at load, so an 11B model wants ~22 GB whatever the file size says.
|
||
|
|
Offloading is slow but it is the difference between measuring a model and
|
||
|
|
guessing about it.
|
||
|
|
"""
|
||
|
|
import torch
|
||
|
|
from transformers import AutoModelForCausalLM, AutoTokenizer
|
||
|
|
|
||
|
|
tok = AutoTokenizer.from_pretrained(model_dir)
|
||
|
|
kwargs = {"dtype": torch.bfloat16, "device_map": "auto"}
|
||
|
|
if gpu_gib == 0:
|
||
|
|
# Everything in RAM. Slow, but the only way to run a compressed-tensors
|
||
|
|
# int4 build in transformers: its decompression hook expands weights to
|
||
|
|
# bfloat16 in place, and it cannot read the meta tensors that accelerate
|
||
|
|
# leaves behind when max_memory forces a partial offload.
|
||
|
|
kwargs["device_map"] = {"": "cpu"}
|
||
|
|
elif gpu_gib:
|
||
|
|
kwargs["max_memory"] = {0: f"{gpu_gib}GiB", "cpu": "80GiB"}
|
||
|
|
model = AutoModelForCausalLM.from_pretrained(model_dir, **kwargs).eval()
|
||
|
|
|
||
|
|
banned = set()
|
||
|
|
for tid in range(len(tok)):
|
||
|
|
piece = tok.decode([tid]).strip()
|
||
|
|
if piece and piece.isdigit():
|
||
|
|
banned.add(tid)
|
||
|
|
banned = sorted(banned)
|
||
|
|
print(f" {len(banned)} digit tokens banned; device "
|
||
|
|
f"{next(model.parameters()).device}", file=sys.stderr)
|
||
|
|
|
||
|
|
def run(text: str) -> str:
|
||
|
|
messages = [{"role": "system", "content": system or SYSTEM_PROMPTED}]
|
||
|
|
for user, assistant in (fewshot or FEWSHOT):
|
||
|
|
messages.append({"role": "user", "content": user})
|
||
|
|
messages.append({"role": "assistant", "content": assistant})
|
||
|
|
messages.append({"role": "user", "content": text})
|
||
|
|
prompt = tok.apply_chat_template(messages, tokenize=False,
|
||
|
|
add_generation_prompt=True)
|
||
|
|
inputs = tok(prompt, return_tensors="pt").to(model.device)
|
||
|
|
n = inputs["input_ids"].shape[1]
|
||
|
|
with torch.no_grad():
|
||
|
|
out = model.generate(**inputs, max_new_tokens=nz.MAX_NEW_TOKENS,
|
||
|
|
do_sample=False,
|
||
|
|
pad_token_id=tok.pad_token_id or tok.eos_token_id,
|
||
|
|
suppress_tokens=banned)
|
||
|
|
answer = tok.decode(out[0][n:], skip_special_tokens=True).strip()
|
||
|
|
# Instruct models like to introduce themselves; keep the last non-empty
|
||
|
|
# line, which is the sentence in every observed case.
|
||
|
|
lines = [l.strip() for l in answer.splitlines() if l.strip()]
|
||
|
|
return lines[-1] if lines else answer
|
||
|
|
|
||
|
|
return run
|
||
|
|
|
||
|
|
|
||
|
|
def main() -> int:
|
||
|
|
ap = argparse.ArgumentParser(description=__doc__,
|
||
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||
|
|
ap.add_argument("--model", default=None,
|
||
|
|
help="Model directory (default: the shipped fine-tune)")
|
||
|
|
ap.add_argument("--prompted", action="store_true",
|
||
|
|
help="Use an instruction + few-shot prompt (general models)")
|
||
|
|
ap.add_argument("--gpu-gib", type=float, default=None, metavar="N",
|
||
|
|
help="Cap GPU memory and spill the rest to RAM (slow, but "
|
||
|
|
"the only way to run a model that does not fit)")
|
||
|
|
ap.add_argument("--no-protect", action="store_true",
|
||
|
|
help="Do not shield standalone single digits — normalize the "
|
||
|
|
"text exactly as the model card does")
|
||
|
|
ap.add_argument("--save", metavar="FILE", help="Write results as JSON")
|
||
|
|
ap.add_argument("--compare", nargs=2, metavar=("A", "B"),
|
||
|
|
help="Print two saved runs side by side and exit")
|
||
|
|
args = ap.parse_args()
|
||
|
|
|
||
|
|
if args.compare:
|
||
|
|
a, b = (json.loads(Path(f).read_text(encoding="utf-8")) for f in args.compare)
|
||
|
|
agree = 0
|
||
|
|
for ra, rb in zip(a["results"], b["results"]):
|
||
|
|
if ra["out"] == rb["out"]:
|
||
|
|
agree += 1
|
||
|
|
continue
|
||
|
|
print(f" in : {ra['in']}")
|
||
|
|
print(f" A : {ra['out']}")
|
||
|
|
print(f" B : {rb['out']}\n")
|
||
|
|
print(f"identical: {agree}/{len(a['results'])} "
|
||
|
|
f"A={a['model']} B={b['model']}")
|
||
|
|
return 0
|
||
|
|
|
||
|
|
if args.prompted:
|
||
|
|
if not args.model:
|
||
|
|
ap.error("--prompted needs --model")
|
||
|
|
run = load_prompted(args.model, args.gpu_gib)
|
||
|
|
label = args.model
|
||
|
|
else:
|
||
|
|
if args.model:
|
||
|
|
nz.MODEL_DIR = Path(args.model)
|
||
|
|
if not nz.load_model():
|
||
|
|
print("model unavailable; refusing to benchmark the fallback",
|
||
|
|
file=sys.stderr)
|
||
|
|
return 1
|
||
|
|
run = nz.normalize
|
||
|
|
label = str(nz.MODEL_DIR)
|
||
|
|
if args.no_protect:
|
||
|
|
nz.KEEP_RE = re.compile(r'(?!x)x') # matches nothing
|
||
|
|
label += " [no single-digit protection]"
|
||
|
|
|
||
|
|
results, t0 = [], time.time()
|
||
|
|
for text in BATTERY:
|
||
|
|
t = time.time()
|
||
|
|
out = run(text)
|
||
|
|
results.append({"in": text, "out": out, "sec": round(time.time() - t, 2),
|
||
|
|
"digits": bool(nz.DIGIT_RE.search(nz.KEEP_RE.sub("", out))),
|
||
|
|
"lost": lost_words(text, out)})
|
||
|
|
total = time.time() - t0
|
||
|
|
|
||
|
|
for r in results:
|
||
|
|
flags = []
|
||
|
|
if r["digits"]:
|
||
|
|
flags.append("DIGITS LEFT")
|
||
|
|
if r["lost"]:
|
||
|
|
flags.append("LOST: " + ", ".join(r["lost"]))
|
||
|
|
print(f" in : {r['in']}")
|
||
|
|
print(f" out: {r['out']}" + (f" [{'; '.join(flags)}]" if flags else ""))
|
||
|
|
print()
|
||
|
|
|
||
|
|
n_dig = sum(r["digits"] for r in results)
|
||
|
|
n_lost = sum(bool(r["lost"]) for r in results)
|
||
|
|
print(f"{label}\n {len(results)} sentences in {total:.1f}s "
|
||
|
|
f"({total/len(results):.2f}s each)\n"
|
||
|
|
f" {n_dig} with digits left, {n_lost} with a word apparently lost")
|
||
|
|
|
||
|
|
if args.save:
|
||
|
|
Path(args.save).write_text(json.dumps(
|
||
|
|
{"model": label, "prompted": args.prompted, "seconds": round(total, 1),
|
||
|
|
"results": results}, ensure_ascii=False, indent=1), encoding="utf-8")
|
||
|
|
print(f" saved to {args.save}")
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
sys.exit(main())
|