Download python/qwen_refine.py from pdnhan/Qwen2.5-3B-Instruct-mobile: direct link, hf CLI and curl.
- Browser
- Download file 21.2 kB
-
https://huggingface.co/pdnhan/Qwen2.5-3B-Instruct-mobile/resolve/main/python/qwen_refine.py
- Command line
-
hf download hf://pdnhan/Qwen2.5-3B-Instruct-mobile/python/qwen_refine.py
-
curl -L -o qwen_refine.py https://huggingface.co/pdnhan/Qwen2.5-3B-Instruct-mobile/resolve/main/python/qwen_refine.py
21.2 kB
| #!/usr/bin/env python3 | |
| """Reference pipeline that turns a Vietnamese speech-recognition transcript into clean written text with | |
| Qwen2.5-Instruct (ONNX): punctuation and capitals, misheard words fixed from context, fillers and stutters | |
| removed, and lists written as "- " bullet points. | |
| The Kotlin (kotlin/src/main/kotlin/QwenRefiner.kt) and Swift (swift/Sources/QwenRefiner/) ports follow this file | |
| step by step, so keep the three in sync: | |
| 1. Tokenizer: Qwen2's byte-level BPE from vocab.txt and merges.txt. Text is NFC-normalized and split with Qwen2's | |
| pre-tokenizer regex, each piece is mapped to byte-level characters and merged by rank. Special tokens are never | |
| parsed out of the transcript; the prompt inserts their ids directly, so a transcript cannot inject a turn. | |
| 2. Prompt: ChatML with a fixed system message and few-shot turns. Everything up to the transcript is the same for | |
| every call, so its KV cache is computed once when the model loads and reused. | |
| 3. Decoding: greedy, stopping at <|im_end|> or <|endoftext|>, at most max_new_tokens(n) tokens. | |
| 4. Merge: small models reword and drop words, so the output is aligned word by word with the transcript (LCS) | |
| and only trusted edits are kept: punctuation, capitals, line breaks and bullets; dropped fillers, stutters and | |
| list markers ("thứ hai là") before a bullet; a word replaced by a similar-sounding word (a misheard word | |
| fixed); spoken numbers written as digits. Any other change is undone with the transcript's own words. | |
| """ | |
| import argparse | |
| import json | |
| import sys | |
| import unicodedata | |
| from pathlib import Path | |
| import numpy as np | |
| import onnxruntime as ort | |
| import regex | |
| IM_START = "<|im_start|>" | |
| IM_END = "<|im_end|>" | |
| END_OF_TEXT = "<|endoftext|>" | |
| # Qwen2's pre-tokenizer split (tokenizer.json, pre_tokenizer.pretokenizers[0].pattern.Regex). | |
| PRETOKENIZE = regex.compile( | |
| r"""(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}| ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+""" | |
| ) | |
| SYSTEM = """You correct Vietnamese speech-recognition transcripts. Copy the transcript word for word, and only: | |
| - add punctuation and capital letters (sentence starts, names, places); | |
| - replace a word the recognizer clearly misheard with the similar-sounding word the context needs; | |
| - delete filler sounds (ờ, à, ừm, ừ) and words said twice by mistake; | |
| - when the speaker lists several items, steps or points, put each one on its own line starting with "- ". | |
| Never reword, shorten, translate, answer or add words. Keep names and foreign words you do not know as they are. Reply with the corrected transcript only.""" | |
| # (transcript, corrected) turns shown before every transcript. | |
| EXAMPLES = [ | |
| ( | |
| "ờ hôm nay em cần làm ba việc thứ nhất là đi chợ mua rau thứ hai là đón con lúc năm giờ chiều " | |
| "và thứ ba là gọi điện cho cho mẹ", | |
| "Hôm nay em cần làm ba việc:\n- Đi chợ mua rau.\n- Đón con lúc 5 giờ chiều.\n- Gọi điện cho mẹ.", | |
| ), | |
| ( | |
| "bạn có biết bệnh viện bạch mai ở đâu không mình cần đến đó trước tám giờ sáng mai", | |
| "Bạn có biết Bệnh viện Bạch Mai ở đâu không? Mình cần đến đó trước 8 giờ sáng mai.", | |
| ), | |
| ( | |
| "chúng tôi đã ký hợp đồng với công ty vào thán trước và ừm sẽ giao hàng trong tuần lày", | |
| "Chúng tôi đã ký hợp đồng với công ty vào tháng trước và sẽ giao hàng trong tuần này.", | |
| ), | |
| ( | |
| "nhà nghiên cứu jan rossby ở viện max planck cho biết hệ số kờ rô càng lớn thì sao càng sáng", | |
| "Nhà nghiên cứu Jan Rossby ở Viện Max Planck cho biết hệ số kờ rô càng lớn thì sao càng sáng.", | |
| ), | |
| ] | |
| # Filler sounds the merge lets the model delete. | |
| FILLERS = frozenset("ờ ơ à ừ ừm ừ hừm ưm ừa um uh ah ừhm".split()) | |
| # Words of a spoken list marker ("thứ hai là", "và ba là") the merge lets the model replace with a bullet. | |
| LIST_MARKERS = frozenset("thứ nhất hai ba bốn năm sáu bảy tám chín mười một là và".split()) | |
| # Spoken digits, and the words that scale them, for checking a number the model wrote in digits. | |
| DIGITS = { | |
| "không": 0, "một": 1, "mốt": 1, "hai": 2, "ba": 3, "bốn": 4, "tư": 4, "năm": 5, "lăm": 5, "nhăm": 5, | |
| "sáu": 6, "bảy": 7, "tám": 8, "chín": 9, | |
| } | |
| SCALES = {"nghìn": 1_000, "ngàn": 1_000, "triệu": 1_000_000, "tỷ": 1_000_000_000, "tỉ": 1_000_000_000} | |
| def bytes_to_unicode() -> dict[int, str]: | |
| """GPT-2's byte-level alphabet: printable bytes map to themselves, the rest to chars from U+0100 up.""" | |
| printable = list(range(ord("!"), ord("~") + 1)) + list(range(ord("¡"), ord("¬") + 1)) + list(range(ord("®"), ord("ÿ") + 1)) | |
| chars, extra = {}, 0 | |
| for b in range(256): | |
| if b in printable: | |
| chars[b] = chr(b) | |
| else: | |
| chars[b] = chr(256 + extra) | |
| extra += 1 | |
| return chars | |
| class Tokenizer: | |
| def __init__(self, repo: Path): | |
| tokens = (repo / "vocab.txt").read_text(encoding="utf-8").split("\n")[:-1] | |
| self.id_of = {t: i for i, t in enumerate(tokens)} | |
| self.tokens = tokens | |
| merges = (repo / "merges.txt").read_text(encoding="utf-8").split("\n")[:-1] | |
| self.rank = {tuple(m.split(" ")): r for r, m in enumerate(merges)} | |
| self.byte_char = bytes_to_unicode() | |
| self.char_byte = {c: b for b, c in self.byte_char.items()} | |
| self.cache: dict[str, list[int]] = {} | |
| def special(self, token: str) -> int: | |
| return self.id_of[token] | |
| def encode(self, text: str) -> list[int]: | |
| ids = [] | |
| for piece in PRETOKENIZE.findall(unicodedata.normalize("NFC", text)): | |
| ids += self.bpe("".join(self.byte_char[b] for b in piece.encode("utf-8"))) | |
| return ids | |
| def bpe(self, word: str) -> list[int]: | |
| cached = self.cache.get(word) | |
| if cached is not None: | |
| return cached | |
| parts = list(word) | |
| while len(parts) > 1: | |
| best, best_rank = -1, None | |
| for i in range(len(parts) - 1): | |
| r = self.rank.get((parts[i], parts[i + 1])) | |
| if r is not None and (best_rank is None or r < best_rank): | |
| best, best_rank = i, r | |
| if best < 0: | |
| break | |
| merged = parts[best] + parts[best + 1] | |
| # Merge every occurrence of the best pair, left to right, as the reference BPE does. | |
| out, i = [], 0 | |
| while i < len(parts): | |
| if i < len(parts) - 1 and parts[i] == parts[best] and parts[i + 1] == parts[best + 1]: | |
| out.append(merged) | |
| i += 2 | |
| else: | |
| out.append(parts[i]) | |
| i += 1 | |
| parts = out | |
| ids = [self.id_of[p] for p in parts] | |
| self.cache[word] = ids | |
| return ids | |
| def decode(self, ids: list[int]) -> str: | |
| data = bytes(self.char_byte[c] for i in ids for c in self.tokens[i]) | |
| return data.decode("utf-8", errors="replace") | |
| def prompt_prefix(tok: Tokenizer) -> list[int]: | |
| """System message and few-shot turns, ending right before the transcript.""" | |
| start, end, nl = tok.special(IM_START), tok.special(IM_END), tok.encode("\n") | |
| ids = [start] + tok.encode("system\n" + SYSTEM) + [end] + nl | |
| for transcript, edited in EXAMPLES: | |
| ids += [start] + tok.encode("user\n" + transcript) + [end] + nl | |
| ids += [start] + tok.encode("assistant\n" + edited) + [end] + nl | |
| return ids + [start] + tok.encode("user\n") | |
| def prompt_suffix(tok: Tokenizer, transcript: str) -> list[int]: | |
| """The transcript and the opening of the assistant turn.""" | |
| return tok.encode(transcript) + [tok.special(IM_END)] + tok.encode("\n") + [tok.special(IM_START)] + tok.encode("assistant\n") | |
| def max_new_tokens(input_tokens: int) -> int: | |
| """Room for punctuation, bullet markers and line breaks on top of the input's length.""" | |
| return min(input_tokens * 3 // 2 + 32, 1024) | |
| def prepare(transcript: str) -> str: | |
| """NFC, whitespace collapsed. Whisper's punctuation is kept: it helps the model.""" | |
| return " ".join(unicodedata.normalize("NFC", transcript).split()) | |
| class Unit: | |
| """A word of the text with what is written around it: punctuation before and after it, and whether it starts | |
| a line and a bullet. key is the word in lower case, for matching.""" | |
| __slots__ = ("lead", "word", "trail", "newline", "bullet") | |
| def __init__(self, word: str, lead: str = "", trail: str = "", newline: bool = False, bullet: bool = False): | |
| self.word, self.lead, self.trail, self.newline, self.bullet = word, lead, trail, newline, bullet | |
| def key(self) -> str: | |
| return self.word.lower() | |
| # A word: from its first to its last letter or digit; what is around it is its punctuation. | |
| WORD_EDGES = regex.compile(r"^([^\p{L}\p{N}]*)(.*?)([^\p{L}\p{N}]*)$", regex.DOTALL) | |
| BULLETS = ("-", "*", "•", "–") | |
| def units(text: str) -> list[Unit]: | |
| """Splits text into Units. Line breaks and bullet markers go to the next word, punctuation-only pieces to the | |
| next word's lead (or the last word's trail at the end).""" | |
| out: list[Unit] = [] | |
| newline = bullet = False | |
| lead = "" | |
| for piece in regex.findall(r"\n|[^\s]+", text): | |
| if piece == "\n": | |
| if out: | |
| newline = True | |
| continue | |
| before, word, after = WORD_EDGES.match(piece).groups() | |
| if not word: | |
| if piece in BULLETS and not lead: | |
| bullet = True | |
| elif out and not newline and not bullet: | |
| out[-1].trail += piece | |
| else: | |
| lead += piece | |
| continue | |
| if before[:1] in BULLETS and len(before) == 1 and not lead: | |
| bullet, before = True, "" | |
| out.append(Unit(word, lead + before, after, newline, bullet)) | |
| newline = bullet = False | |
| lead = "" | |
| return out | |
| def render(items: list[Unit]) -> str: | |
| parts = [] | |
| for i, u in enumerate(items): | |
| if i > 0: | |
| parts.append("\n" if u.newline else " ") | |
| parts.append(("- " if u.bullet else "") + u.lead + u.word + u.trail) | |
| return "".join(parts) | |
| def plain(word: str) -> str: | |
| """Lower case without tone and vowel marks (đ is d), so similar-sounding words compare close.""" | |
| decomposed = unicodedata.normalize("NFD", word.lower().replace("đ", "d")) | |
| return "".join(c for c in decomposed if not unicodedata.combining(c)) | |
| def edit_distance(a: str, b: str) -> int: | |
| row = list(range(len(b) + 1)) | |
| for i in range(1, len(a) + 1): | |
| prev, row[0] = row[0], i | |
| for j in range(1, len(b) + 1): | |
| cur = min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] != b[j - 1])) | |
| prev, row[j] = row[j], cur | |
| return row[len(b)] | |
| def sounds_alike(a: str, b: str) -> bool: | |
| """One edit apart without marks for short words, two for longer ones: lội/nội, thán/tháng, tuần/tuần.""" | |
| pa, pb = plain(a), plain(b) | |
| return edit_distance(pa, pb) <= (1 if max(len(pa), len(pb)) <= 4 else 2) | |
| def spoken_number(words: list[str]) -> int | None: | |
| """The value of a spoken Vietnamese number ("hai nghìn không trăm hai mươi sáu" is 2026), or None when the | |
| words are not one number.""" | |
| total = group = 0 # group: the part below the current thousand | |
| digit: int | None = None | |
| for w in words: | |
| if w in DIGITS: | |
| if digit is not None: | |
| return None # two digits in a row: a list of digits, not one number | |
| digit = DIGITS[w] | |
| elif w == "mười": | |
| if digit is not None: | |
| return None | |
| group += 10 | |
| elif w == "mươi" and digit is not None: | |
| group, digit = group + digit * 10, None | |
| elif w == "trăm" and digit is not None: | |
| group, digit = group + digit * 100, None | |
| elif w in ("linh", "lẻ"): | |
| continue | |
| elif w in SCALES: | |
| total, group, digit = total + (group + (digit or 0)) * SCALES[w], 0, None | |
| else: | |
| return None | |
| if not words: | |
| return None | |
| return total + group + (digit or 0) | |
| def written_number(word: str) -> int | None: | |
| """The value of digits the model wrote, with "." or "," as thousands separators: 15, 2026, 1.000.""" | |
| if not regex.fullmatch(r"\d{1,3}([.,]\d{3})*|\d+", word): | |
| return None | |
| return int(word.replace(".", "").replace(",", "")) | |
| def accept_replacement(old: list[Unit], new: list[Unit]) -> bool: | |
| """A misheard word fixed one for one, or a spoken number written as digits with the same value.""" | |
| if len(old) == len(new) and all(sounds_alike(o.key, n.key) for o, n in zip(old, new)): | |
| return True | |
| if len(new) != 1: | |
| return False | |
| value = written_number(new[0].word) | |
| return value is not None and value == spoken_number([o.key for o in old]) | |
| def align(a: list[str], b: list[str]) -> list[tuple[str, int, int, int, int]]: | |
| """Longest common subsequence of a and b as difflib-style opcodes (tag, i1, i2, j1, j2); a match is taken as | |
| early as possible, and an unmatched run of a before one of b.""" | |
| n, m = len(a), len(b) | |
| lcs = [[0] * (m + 1) for _ in range(n + 1)] | |
| for i in range(n - 1, -1, -1): | |
| for j in range(m - 1, -1, -1): | |
| lcs[i][j] = lcs[i + 1][j + 1] + 1 if a[i] == b[j] else max(lcs[i + 1][j], lcs[i][j + 1]) | |
| ops, i, j = [], 0, 0 | |
| while i < n or j < m: | |
| if i < n and j < m and a[i] == b[j]: | |
| i2, j2 = i, j | |
| while i2 < n and j2 < m and a[i2] == b[j2]: | |
| i2, j2 = i2 + 1, j2 + 1 | |
| ops.append(("equal", i, i2, j, j2)) | |
| i, j = i2, j2 | |
| continue | |
| i2, j2 = i, j | |
| while i2 < n or j2 < m: | |
| if i2 < n and j2 < m and a[i2] == b[j2]: | |
| break | |
| if j2 >= m or (i2 < n and lcs[i2 + 1][j2] >= lcs[i2][j2 + 1]): | |
| i2 += 1 | |
| else: | |
| j2 += 1 | |
| tag = "replace" if i2 > i and j2 > j else ("delete" if i2 > i else "insert") | |
| ops.append((tag, i, i2, j, j2)) | |
| i, j = i2, j2 | |
| return ops | |
| def may_delete(old: list[Unit], start: int, end: int, next_unit: Unit | None) -> list[bool]: | |
| """For each transcript word in old[start:end] the model left out: whether leaving it out is trusted.""" | |
| run = old[start:end] | |
| if next_unit is not None and next_unit.bullet and all(u.key in LIST_MARKERS for u in run): | |
| return [True] * len(run) | |
| keep = [] | |
| for k, u in enumerate(run): | |
| i = start + k | |
| repeated = (i > 0 and old[i - 1].key == u.key) or (i + 1 < len(old) and old[i + 1].key == u.key) | |
| keep.append(u.key in FILLERS or repeated) | |
| return keep | |
| def merge(transcript: str, edited: str) -> str: | |
| """The model's text, with every edit that is not trusted undone (see the module docstring).""" | |
| old, new = units(transcript), units(edited) | |
| out: list[Unit] = [] | |
| carry: Unit | None = None # layout of dropped words, for the next word written | |
| def emit(u: Unit) -> None: | |
| nonlocal carry | |
| if carry is not None: | |
| u.newline, u.bullet = u.newline or carry.newline, u.bullet or carry.bullet | |
| u.lead = carry.lead + u.lead | |
| carry = None | |
| out.append(u) | |
| def drop(dropped: list[Unit]) -> None: | |
| """Keeps the layout and the closing punctuation of words that are left out.""" | |
| nonlocal carry | |
| for u in dropped: | |
| if u.newline or u.bullet or u.lead: | |
| carry = carry or Unit("") | |
| carry.newline, carry.bullet = carry.newline or u.newline, carry.bullet or u.bullet | |
| carry.lead += u.lead | |
| if u.trail and out and not out[-1].trail: | |
| out[-1].trail = u.trail | |
| def restore(words: list[Unit], like: list[Unit]) -> list[Unit]: | |
| """The transcript's words in place of the model's [like]: its layout, its first capital, its last mark.""" | |
| back = [Unit(u.word) for u in words] | |
| if like and back: | |
| back[0].newline, back[0].bullet, back[0].lead = like[0].newline, like[0].bullet, like[0].lead | |
| back[-1].trail = like[-1].trail | |
| if like[0].word[:1].isupper(): | |
| back[0].word = back[0].word[:1].upper() + back[0].word[1:] | |
| return back | |
| for tag, i1, i2, j1, j2 in align([u.key for u in old], [u.key for u in new]): | |
| if tag == "equal": | |
| for u in new[j1:j2]: | |
| emit(u) | |
| elif tag == "insert": | |
| drop(new[j1:j2]) | |
| elif tag == "replace" and accept_replacement(old[i1:i2], new[j1:j2]): | |
| for u in new[j1:j2]: | |
| emit(u) | |
| elif tag == "replace": | |
| for u in restore(old[i1:i2], new[j1:j2]): | |
| emit(u) | |
| else: # delete | |
| next_unit = new[j1] if j1 < len(new) else None | |
| for u, ok in zip(old[i1:i2], may_delete(old, i1, i2, next_unit)): | |
| if not ok: | |
| restored = Unit(u.word) | |
| # The word belongs before the mark that ends the previous word's sentence or clause. | |
| if out and out[-1].trail: | |
| restored.trail, out[-1].trail = out[-1].trail, "" | |
| emit(restored) | |
| if carry is not None and out and carry.lead: | |
| out[-1].trail += carry.lead | |
| capitalize_sentence_starts(out) | |
| return render(out) | |
| def capitalize_sentence_starts(items: list[Unit]) -> None: | |
| """Restored words keep the transcript's lower case; one that now starts a sentence, line or bullet gets a capital.""" | |
| for i, u in enumerate(items): | |
| starts = i == 0 or u.newline or u.bullet or items[i - 1].trail.rstrip("\"')»”").endswith((".", "?", "!")) | |
| if starts and u.word[:1].islower() and not u.lead: | |
| u.word = u.word[:1].upper() + u.word[1:] | |
| class Refiner: | |
| def __init__(self, repo: Path, threads: int = 4): | |
| self.tok = Tokenizer(repo) | |
| options = ort.SessionOptions() | |
| options.intra_op_num_threads = threads | |
| self.session = ort.InferenceSession(str(repo / "onnx" / "model.onnx"), options, providers=["CPUExecutionProvider"]) | |
| names = [i.name for i in self.session.get_inputs()] | |
| self.past_names = [n for n in names if n.startswith("past_key_values.")] | |
| self.uses_position_ids = "position_ids" in names | |
| config = json.loads((repo / "onnx" / "genai_config.json").read_text(encoding="utf-8"))["model"]["decoder"] | |
| self.kv_heads, self.head_size = config["num_key_value_heads"], config["head_size"] | |
| self.stop = {self.tok.special(IM_END), self.tok.special(END_OF_TEXT)} | |
| self.prefix = prompt_prefix(self.tok) | |
| empty = np.zeros((1, self.kv_heads, 0, self.head_size), np.float32) | |
| _, self.prefix_cache = self.forward(self.prefix, {n: empty for n in self.past_names}, 0) | |
| def forward(self, ids: list[int], cache: dict[str, np.ndarray], past_len: int) -> tuple[np.ndarray, dict[str, np.ndarray]]: | |
| """Runs ids after past_len cached tokens; returns the last position's logits and the grown cache.""" | |
| total = past_len + len(ids) | |
| feed = { | |
| "input_ids": np.array([ids], np.int64), | |
| "attention_mask": np.ones((1, total), np.int64), | |
| **cache, | |
| } | |
| if self.uses_position_ids: | |
| feed["position_ids"] = np.arange(past_len, total, dtype=np.int64)[None, :] | |
| outputs = self.session.run(None, feed) | |
| out_names = [o.name for o in self.session.get_outputs()] | |
| result = dict(zip(out_names, outputs)) | |
| new_cache = {n: result[n.replace("past_key_values.", "present.")] for n in self.past_names} | |
| return result["logits"][0, -1], new_cache | |
| def generate(self, transcript: str) -> list[int]: | |
| suffix = prompt_suffix(self.tok, transcript) | |
| logits, cache = self.forward(suffix, self.prefix_cache, len(self.prefix)) | |
| length = len(self.prefix) + len(suffix) | |
| out: list[int] = [] | |
| for _ in range(max_new_tokens(len(suffix))): | |
| nxt = int(np.argmax(logits)) | |
| if nxt in self.stop: | |
| break | |
| out.append(nxt) | |
| logits, cache = self.forward([nxt], cache, length) | |
| length += 1 | |
| return out | |
| def refine(self, transcript: str) -> str: | |
| text = prepare(transcript) | |
| if not text: | |
| return transcript | |
| edited = self.tok.decode(self.generate(text)).strip() | |
| merged = merge(text, edited) if edited else "" | |
| return merged if merged.strip() else text | |
| def main() -> None: | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument("repo", type=Path) | |
| parser.add_argument("text", nargs="+") | |
| args = parser.parse_args() | |
| refiner = Refiner(args.repo) | |
| for text in args.text: | |
| print(refiner.refine(text)) | |
| sys.stdout.flush() | |
| if __name__ == "__main__": | |
| main() | |