#!/usr/bin/env python3 """Checks the exported models and the reference pipeline, and measures what the refiner does to real PhoWhisper output. 1. Tokenizer: qwen_refine.Tokenizer gives the same ids as Hugging Face's tokenizer on every FLEURS sentence, and the prompt equals the model's own chat template. 2. Refiner, on data/asr_hyps.tsv (asr_hyps.py): word error rate against the reference before and after (case and punctuation ignored), punctuation F1 on the words both share, how often the guard falls back, and the time per transcript. 3. Fixtures for the Kotlin and Swift checks in out//fixtures: cases.tsv (input, expected output, both as JSON strings), tokens.tsv (text as a JSON string, ids), generations.tsv (prompt ids, generated ids), and, with no model involved, numbers.tsv (text, write_numbers(text)) and merges.tsv (transcript, model text, merge(transcript, model text)). Usage: .venv/bin/python -I validate.py [--size 1.5B VyLinh-3B] [--count 300] """ import argparse import csv import json import sys import time import unicodedata from difflib import SequenceMatcher from pathlib import Path import jiwer import regex from tokenizers import Tokenizer as HfTokenizer sys.path.insert(0, str(Path(__file__).resolve().parent)) # -I drops the script's own directory from common import HERE, SIZES, fleurs_sentences, out_dir, src_dir, strip # noqa: E402 from qwen_refine import ( # noqa: E402 EXAMPLES, SYSTEM, Refiner, Tokenizer, merge, prepare, prompt_prefix, prompt_suffix, write_numbers, ) MARKS = ".,?!:" WER_TOLERANCE = 0.005 # Spoken-style transcripts for the bullet and fix checks; FLEURS has none of these. CASES = [ "ờ hôm nay chúng ta sẽ họp về ba vấn đề một là ngân sách quý tới hai là tuyển thêm nhân viên và ba là kế hoạch du lịch công ty", "thủ tướng phạm minh chính đã tiếp bộ trưởng nông nghiệp mỹ tại hà lội vào sáng nay", "bạn có biết mấy giờ rồi không", "nhớ mua giúp mình mấy thứ nhé trứng sữa tươi bánh mì và một ít táo", "các bước cài đặt như sau đầu tiên tải ứng dụng sau đó đăng nhập bằng số điện thoại cuối cùng là bật thông báo", "ừm thì là cuộc họp ngày mai được dời sang hai giờ chiều nha mọi người", "tôi tôi nghĩ là chúng ta nên nên kiểm tra lại số liệu trước khi gửi báo cáo cho khách hàng", "em ơi cho anh hỏi đường đến chợ bến thành đi như thế nào", "hôm qua trời mưa to quá nên tôi ở nhà đọc sách cả ngày", "dự án cần hoàn thành trước ngày mười lăm tháng mười hai năm hai nghìn không trăm hai mươi sáu", "doanh thu quý này tăng hai mươi phần trăm so với cùng kỳ năm ngoái", "em bé nặng ba phẩy hai ki lô gam lúc mới sinh", "từ nhà tôi đến công ty khoảng mười hai ki lô mét đi xe máy mất ba mươi phút", "số điện thoại của tôi là không chín một hai ba bốn năm sáu bảy tám", ] # Spoken numbers for write_numbers: ones it writes in digits, and ones it must leave to the model. NUMBER_CASES = [ "ngày hai mươi tháng mười một năm hai nghìn không trăm hai mươi sáu", "năm một nghìn chín trăm chín mươi hai", "hai mươi năm trước", "một trăm năm cô đơn", "mười năm", "hai triệu ba trăm nghìn", "hai triệu ba", "một nghìn hai", "năm trăm nghìn đồng", "một chút", "ba việc", "hai mươi ba mươi người", "hai mươi, ba mươi người", "một trăm linh năm", "ba trăm sáu mươi lăm ngày", "tháng mười hai", "hai mươi mốt tuổi", "ba mươi tư độ", "không chín một hai", "không hai mươi", "một tỷ hai trăm triệu", "hai nghìn hai trăm", "tôi không biết", "năm năm mươi", "Hai mươi người.", "có mười lăm, bạn", "ba phẩy năm", "tháng năm năm hai nghìn không trăm hai mươi tư", "", "vào ngày hai mươi tháng mười một năm hai nghìn không trăm hai mươi sáu tại hà nội", "ngày mùng năm tháng ba", "từ tháng tư năm hai nghìn không trăm hai mươi lăm", "ngày hai mươi tháng mười một năm nay", "uống thuốc ngày hai lần", "tăng năm phần trăm", "em bé nặng ba phẩy hai ki lô gam", "nặng không phẩy không năm ki lô gam", "lãi suất sáu phẩy năm phần trăm", "khoảng mười hai ki lô mét đi xe", "rộng tám mươi lăm mét vuông", "ngoài trời đang ba mươi tám độ c", "cao một mét bảy", "chạy năm mươi ki lô mét trên giờ", "mua hai kí", "ngày hai, tháng ba", "tôi đi ba mét.", "ba lít nước", "không chín một hai ba bốn năm sáu bảy tám", "năm sáu người", "một hai ba bốn năm sáu", "có hai phòng ngủ", "đặt bàn cho bốn người", "được điểm mười môn toán", "ba bạn tôi", "năm học mới", "năm tháng trôi qua", "hai năm trước", "ba mẹ", "có hai học sinh", "một viên", "hai bên", "năm nay", "ba, người", "tháng ba", "mười giờ", "pha ba muỗng bột", ] # (transcript as the model sees it, model text) for the merge's number, date, unit and percent rules. MERGE_CASES = [ ("vào ngày 20 tháng 11 năm 2026 tại hà nội", "Vào ngày 20/11/2026 tại Hà Nội."), ("ngày hai mươi lăm tháng năm năm 2024", "Ngày 25/5/2024."), ("ngày hai mươi lăm tháng năm năm 2024", "Ngày 25/5/2022."), ("sinh nhật em là ngày mùng năm tháng ba", "Sinh nhật em là ngày 5/3."), ("từ tháng tư năm 2025", "Từ tháng 4/2025."), ("tăng 20 phần trăm so với", "Tăng 20% so với"), ("tăng 20 phần trăm.", "Tăng 20%."), ("tăng 20 phần trăm so với", "Tăng 2% so với"), ("khoảng 35 độ trời nắng", "Khoảng 35% trời nắng"), ("được mười điểm", "Được 10% điểm."), ("số của tôi là không chín một hai ba", "Số của tôi là 09123."), ("số của tôi là không chín một hai ba", "Số của tôi là 0912."), ("nặng ba phẩy năm ki lô gam", "Nặng 3,5 kg."), ("nặng không phẩy không năm ki lô gam", "Nặng 0,05 kg."), ("nặng năm ki lô gam và chạy mười ki lô mét", "Nặng 5 kg và chạy 10 km."), ("uống 200 mi li gam", "Uống 200 mg."), ("uống 200 mi li gam", "Uống 200 kg."), ("ba mươi độ c", "30°C."), ("ngoài trời 38 độ c", "Ngoài trời 38 °C."), ("cao 1 mét bảy", "Cao 1 m 7."), ("giá hai triệu ba trăm nghìn", "Giá 2.300.000."), ("rộng 85 mét vuông", "Rộng 85 m²."), ("mua 2 ki lô gạo", "Mua 2kg gạo."), ("học kì này", "Học kg này."), ("mua hai ki lô gam gạo", "Mua hai kg gạo."), ("hôm mùng một tháng sáu", "Hôm 1/6."), ("chờ một chút", "Chờ 1 chút."), ("bạn có biết mấy giờ rồi không", "Bạn có biết mấy giờ rồi không."), ("anh gửi trước thứ hai được không em cần xem", "Anh gửi trước thứ hai được không. Em cần xem."), ("tôi đã nói là không", "Tôi đã nói là không."), ("em đã ăn cơm chưa", "Em đã ăn cơm chưa."), ("tôi không biết", "Tôi không biết."), ("cái này đúng phải không", "Cái này đúng phải không!"), ("có ai biết không", "Có ai biết không..."), ("ừm tôi nghĩ là", "ừm, tôi nghĩ là."), ("tôi nghĩ ờ nên đi", "Tôi nghĩ, ờ, nên đi."), ("đi chợ ờ", "Đi chợ, ờ."), ("ờ", "Ờ."), ("ừ được", "Ừ, được."), ("- ờ mua rau", "- Ờ, mua rau."), ("quán ven xông", "Quán ven đường."), ("quán ven xông", "Quán ven sông."), ("ở hà lội", "Ở Hà Nội."), ("tập thể giục mỗi sáng", "Tập thể dục mỗi sáng."), ("trời xông to", "Trời sông to."), ("ông ấy gì tôi", "Ông ấy dì tôi."), ("cảm ơn bạn nhiều lém", "Rất vui khi giúp bạn. Chúc bạn một ngày tốt lành!"), ("ba mươi độ c", "30°C."), ] def check_tokenizer(size: str) -> dict: ours = Tokenizer(out_dir(size)) hf = HfTokenizer.from_file(str(src_dir(size) / "tokenizer.json")) texts = [t for _, t in fleurs_sentences()] + [s for s, _ in fleurs_sentences()] + CASES texts += [SYSTEM] + [t for pair in EXAMPLES for t in pair] texts += [" hai khoảng\ttrắng\n\nxuống dòng ", "It's 5:30 AM — ok?", "emoji 🙂 và ký tự lạ ½ fi"] mismatches = [t for t in texts if ours.encode(t) != hf.encode(t, add_special_tokens=False).ids] for t in mismatches[:5]: print(" tokenizer mismatch:", repr(t)) # The prompt must be exactly what the chat template gives (the model was tuned on that format). from transformers import AutoTokenizer auto = AutoTokenizer.from_pretrained(src_dir(size)) messages = [{"role": "system", "content": SYSTEM}] for transcript, edited in EXAMPLES: messages += [{"role": "user", "content": transcript}, {"role": "assistant", "content": edited}] messages.append({"role": "user", "content": CASES[0]}) text = auto.apply_chat_template(messages, add_generation_prompt=True, tokenize=False) template = auto(text, add_special_tokens=False).input_ids ours_prompt = prompt_prefix(ours) + prompt_suffix(ours, CASES[0]) roundtrip = sum(ours.decode(ours.encode(t)) != unicodedata.normalize("NFC", t) for t in texts) return { "texts": len(texts), "mismatches": len(mismatches), "chat_template_equal": template == ours_prompt, "decode_roundtrip_failures": roundtrip, } def marks_by_word(text: str) -> list[tuple[str, str]]: """(word, mark after it) for each word; a bullet line break counts as a full stop.""" out = [] for token in regex.findall(r"[\p{L}\p{N}]+|[.,?!:;\n]", unicodedata.normalize("NFC", text.lower())): if token in MARKS + ";\n": mark = "." if token in ".!;\n" else token if out and out[-1][1] == "": out[-1] = (out[-1][0], mark) else: out.append((token, "")) return out def punctuation_counts(hyp: str, ref: str) -> tuple[int, int, int]: """True positives, predicted and reference marks on the words both texts share (aligned by difflib).""" h, r = marks_by_word(hyp), marks_by_word(ref) tp = pred = gold = 0 matcher = SequenceMatcher(a=[w for w, _ in h], b=[w for w, _ in r], autojunk=False) for block in matcher.get_matching_blocks(): for k in range(block.size): hm, rm = h[block.a + k][1], r[block.b + k][1] pred += hm != "" gold += rm != "" tp += hm != "" and hm == rm return tp, pred, gold def read_hyps(count: int) -> list[tuple[str, str, str]]: with open(HERE / "data" / "asr_hyps.tsv", encoding="utf-8") as f: rows = list(csv.reader(f, delimiter="\t", quoting=csv.QUOTE_NONE, escapechar="\\")) return [(size, ref, hyp) for size, _, ref, hyp in rows[:count]] def evaluate(size: str, count: int) -> dict: refiner = Refiner(out_dir(size), threads=8) rows = read_hyps(count) refs, before, after, outputs = [], [], [], [] tp = pred = gold = fallbacks = 0 start = time.time() for _, ref, hyp in rows: edited = refiner.refine(hyp) outputs.append(edited) fallbacks += edited == write_numbers(prepare(hyp)) refs.append(strip(ref)) before.append(strip(hyp)) after.append(strip(edited)) t, p, g = punctuation_counts(edited, ref) tp, pred, gold = tp + t, pred + p, gold + g seconds = (time.time() - start) / len(rows) precision, recall = tp / max(pred, 1), tp / max(gold, 1) by_size = {} for asr in sorted({s for s, _, _ in rows}): idx = [i for i, (s, _, _) in enumerate(rows) if s == asr] by_size[asr] = { "wer_before": round(jiwer.wer([refs[i] for i in idx], [before[i] for i in idx]), 4), "wer_after": round(jiwer.wer([refs[i] for i in idx], [after[i] for i in idx]), 4), } cases = [(c, refiner.refine(c)) for c in CASES] fixtures = out_dir(size).parent / "fixtures" fixtures.mkdir(exist_ok=True) with open(fixtures / "cases.tsv", "w", encoding="utf-8") as f: for text, edited in cases + list(zip([h for _, _, h in rows[:20]], outputs[:20])): f.write(json.dumps(text, ensure_ascii=False) + "\t" + json.dumps(edited, ensure_ascii=False) + "\n") # For the Swift check's ONNX Runtime stand-in: the whole prompt and the ids the model generated after it. with open(fixtures / "generations.tsv", "w", encoding="utf-8") as f: for text in CASES + [h for _, _, h in rows[:20]]: prepared = write_numbers(prepare(text)) prompt = refiner.prefix + prompt_suffix(refiner.tok, prepared) f.write(",".join(map(str, prompt)) + "\t" + ",".join(map(str, refiner.generate(prepared))) + "\n") with open(fixtures / "numbers.tsv", "w", encoding="utf-8") as f: for text in NUMBER_CASES: f.write(json.dumps(text, ensure_ascii=False) + "\t" + json.dumps(write_numbers(text), ensure_ascii=False) + "\n") with open(fixtures / "merges.tsv", "w", encoding="utf-8") as f: for transcript, edited in MERGE_CASES: f.write("\t".join(json.dumps(t, ensure_ascii=False) for t in (transcript, edited, merge(transcript, edited))) + "\n") with open(fixtures / "tokens.tsv", "w", encoding="utf-8") as f: for text in CASES + [t for _, t in fleurs_sentences()[:200]] + [SYSTEM]: ids = refiner.tok.encode(text) f.write(json.dumps(text, ensure_ascii=False) + "\t" + ",".join(map(str, ids)) + "\n") for text, edited in cases: print(f" {text}\n -> {edited!r}") return { "transcripts": len(rows), "wer": by_size, "punctuation": {"precision": round(precision, 4), "recall": round(recall, 4), "f1": round(2 * precision * recall / max(precision + recall, 1e-9), 4)}, "guard_fallbacks": fallbacks, "seconds_per_transcript": round(seconds, 2), "examples": [{"input": c, "output": e} for c, e in cases], } def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--size", choices=SIZES, nargs="+", default=list(SIZES)) parser.add_argument("--count", type=int, default=300) args = parser.parse_args() failed = False for size in args.size: result = {"tokenizer": check_tokenizer(size)} print(size, "tokenizer:", result["tokenizer"]) result.update(evaluate(size, args.count)) print(size, {k: v for k, v in result.items() if k not in ("examples", "tokenizer")}) (out_dir(size).parent / "validation.json").write_text(json.dumps(result, ensure_ascii=False, indent=1) + "\n") tok = result["tokenizer"] failed |= tok["mismatches"] > 0 or not tok["chat_template_equal"] or tok["decode_roundtrip_failures"] > 0 # The merge undoes rewording, so the word error rate should not go up beyond noise (one word in 200). failed |= any(w["wer_after"] > w["wer_before"] + WER_TOLERANCE for w in result["wer"].values()) if failed: sys.exit("error: tokenizer mismatch, prompt differs from the chat template, or the refiner adds word errors") if __name__ == "__main__": main()