Download python/capu.py from pdnhan/xlm-roberta-capu-mobile: direct link, hf CLI and curl.
- Browser
- Download file 13.1 kB
-
https://huggingface.co/pdnhan/xlm-roberta-capu-mobile/resolve/main/python/capu.py
- Command line
-
hf download hf://pdnhan/xlm-roberta-capu-mobile/python/capu.py
-
curl -L -o capu.py https://huggingface.co/pdnhan/xlm-roberta-capu-mobile/resolve/main/python/capu.py
13.1 kB
| #!/usr/bin/env python3 | |
| """Vietnamese capitalization and punctuation restoration with the ONNX export of dragonSwing/xlm-roberta-capu. | |
| The reference for the Kotlin and Swift ports: it needs only numpy, onnxruntime and the files of the published repo | |
| (onnx/*.onnx, unigram.tsv, labels.txt, number_words.tsv), and does every step itself, the way an app must. | |
| It is a port of the model repo's GecBERTModel (gec_model.py, utils.py) with the model card's settings | |
| (split_chunk=True): same chunking, the same up-to-3 correction rounds, the same chunk merging, so it gives the same | |
| text (validate.py checks this). | |
| capu = Capu("path/to/repo", quantized=True) | |
| capu("theo đó thủ tướng dự kiến tiếp bộ trưởng nông nghiệp mỹ") # "Theo đó, Thủ tướng dự kiến tiếp Bộ trưởng Nông nghiệp Mỹ." | |
| Usage: | |
| python -I capu.py path/to/repo "text to correct" [--fp32] | |
| """ | |
| import re | |
| import sys | |
| import unicodedata | |
| from pathlib import Path | |
| import numpy as np | |
| import onnxruntime as ort | |
| START = "$START" | |
| UNK, PAD = "@@UNKNOWN@@", "@@PADDING@@" | |
| PUNCTUATION = (":", ".", ",", "?") | |
| MAX_WORDS, MIN_WORDS, ITERATIONS = 64, 3, 3 | |
| CHUNK_SIZE, OVERLAP_SIZE, MIN_WORDS_CUT = 48, 12, 6 | |
| MAX_TOKENS = 512 # XLM-RoBERTa's position embeddings | |
| class Tokenizer: | |
| """XLM-RoBERTa's SentencePiece unigram model (as in tokenizers' Unigram), one word at a time. | |
| Each word is NFKC-normalized, prefixed with "▁" and split by Viterbi search for the pieces with the highest | |
| total log-probability. A character that no piece covers becomes <unk>, with consecutive <unk>s fused. | |
| """ | |
| def __init__(self, path: Path): | |
| self.ids: dict[str, int] = {} | |
| self.scores: list[float] = [] | |
| with open(path, encoding="utf-8") as f: | |
| for i, line in enumerate(f): | |
| piece, score = line.rstrip("\n").split("\t") | |
| self.ids.setdefault(piece, i) | |
| self.scores.append(float(score)) | |
| self.start_id = self.ids[START] | |
| self.unk_id = self.ids["<unk>"] | |
| self.unk_score = min(self.scores[4:self.start_id]) - 10.0 # tokenizers: min score - kUnkPenalty | |
| self.max_len = max(len(p) for p in self.ids) | |
| def encode_word(self, word: str) -> list[int]: | |
| text = "▁" + unicodedata.normalize("NFKC", word) | |
| n = len(text) | |
| best = [-np.inf] * (n + 1) # best score of text[:end] | |
| back: list[tuple[int, int]] = [(0, 0)] * (n + 1) # (start, token id) of the last piece of that path | |
| best[0] = 0.0 | |
| for start in range(n): | |
| if best[start] == -np.inf: | |
| continue | |
| single = False | |
| for end in range(start + 1, min(n, start + self.max_len) + 1): | |
| token = self.ids.get(text[start:end]) | |
| if token is None or token == self.start_id: | |
| continue | |
| single |= end == start + 1 | |
| score = best[start] + self.scores[token] | |
| if score > best[end]: | |
| best[end], back[end] = score, (start, token) | |
| if not single and best[start] + self.unk_score > best[start + 1]: | |
| best[start + 1], back[start + 1] = best[start] + self.unk_score, (start, self.unk_id) | |
| tokens, end = [], n | |
| while end > 0: | |
| start, token = back[end] | |
| if not (token == self.unk_id and tokens and tokens[-1] == self.unk_id): | |
| tokens.append(token) | |
| end = start | |
| return tokens[::-1] | |
| def capitalize(s: str) -> str: | |
| return s[:1].upper() + s[1:].lower() | |
| def transform_case(token: str, label: str) -> str: | |
| # utils.convert_using_case; the endswith order matters ("$..._CAPITAL_1" does not end with "CAPITAL"). | |
| if label.endswith("LOWER"): | |
| return token.lower() | |
| if label.endswith("UPPER"): | |
| return token.upper() | |
| if label.endswith("CAPITAL"): | |
| return capitalize(token) | |
| if label.endswith("CAPITAL_1"): | |
| return token[0] + capitalize(token[1:]) | |
| if label.endswith("UPPER_-1"): | |
| return token[:-1].upper() + token[-1] | |
| return token | |
| class Capu: | |
| def __init__(self, repo: str | Path, quantized: bool = True, threads: int = 4): | |
| repo = Path(repo) | |
| opts = ort.SessionOptions() | |
| opts.intra_op_num_threads = threads | |
| name = "model_quantized.onnx" if quantized else "model.onnx" | |
| self.session = ort.InferenceSession(str(repo / "onnx" / name), opts, providers=["CPUExecutionProvider"]) | |
| self.tokenizer = Tokenizer(repo / "unigram.tsv") | |
| self.labels = (repo / "labels.txt").read_text(encoding="utf-8").splitlines() | |
| self.number_words: dict[tuple[str, str], str] = {} | |
| for line in (repo / "number_words.tsv").read_text(encoding="utf-8").splitlines(): | |
| word, tag, replacement = line.split("\t") | |
| self.number_words[(word, tag)] = replacement | |
| def __call__(self, text: str) -> str: | |
| chunks = split_chunks(text.split()) | |
| merged: list[str] = [] | |
| for chunk in chunks: | |
| chunk = self.correct(chunk) | |
| merged = chunk if len(chunks) == 1 else merge_chunks(merged, chunk) | |
| return re.sub(r"\s+([:.,?])", r"\1", " ".join(merged)) | |
| def correct(self, tokens: list[str]) -> list[str]: | |
| """GecBERTModel.handle_batch for one chunk: up to 3 rounds, stopping when the text stops changing.""" | |
| if len(tokens) < MIN_WORDS: | |
| return tokens | |
| seen = [tokens] | |
| for _ in range(ITERATIONS): | |
| new = self.apply(tokens, self.predict(tokens)) | |
| if new == tokens: | |
| break | |
| tokens = new | |
| if new in seen: | |
| break | |
| seen.append(new) | |
| return tokens | |
| def predict(self, tokens: list[str]) -> list[int]: | |
| """Label id for $START and each of the first MAX_WORDS tokens.""" | |
| ids, offsets = [self.tokenizer.start_id], [0] | |
| for word in tokens[:MAX_WORDS]: | |
| offsets.append(len(ids)) | |
| ids += self.tokenizer.encode_word(word) | |
| if len(ids) > MAX_TOKENS: | |
| # Only happens with very long "words" (URLs); words starting past the limit keep $KEEP. | |
| words = sum(o < MAX_TOKENS for o in offsets) | |
| ids, offsets = ids[:MAX_TOKENS], offsets[:words] | |
| logits = self.session.run(["logits"], { | |
| "input_ids": np.array([ids], np.int64), | |
| "attention_mask": np.ones((1, len(ids)), np.int64), | |
| "input_offsets": np.array([offsets], np.int64), | |
| })[0][0] | |
| labels = logits.argmax(-1).tolist() | |
| return labels + [0] * (min(len(tokens), MAX_WORDS) + 1 - len(labels)) | |
| def apply(self, tokens: list[str], labels: list[int]) -> list[str]: | |
| """GecBERTModel.postprocess_batch and utils.get_target_sent_by_edits.""" | |
| if max(labels) == 0: | |
| return tokens | |
| edits = [] # (position, label) in the order of the words | |
| for i in range(min(len(tokens), MAX_WORDS) + 1): | |
| label = self.labels[labels[i]] | |
| if label in (UNK, PAD, "$KEEP"): | |
| continue | |
| if label.startswith("$APPEND_"): | |
| edits.append((i, i, label[len("$APPEND_"):])) | |
| elif label.startswith("$MERGE_"): | |
| edits.append((i, i, label)) | |
| else: # $TRANSFORM_*; i - 1 because position 0 is $START | |
| edits.append((i - 1, i, label)) | |
| target, shift = tokens[:], 0 | |
| for start, end, label in edits: | |
| pos = start + shift | |
| if start < 0: | |
| continue | |
| source = target[pos] if pos < len(target) else "" | |
| if start == end: # append a punctuation mark or a $MERGE_ marker after the word | |
| if (pos < len(target) and target[pos] == label) or (pos > 0 and target[pos - 1] == label): | |
| continue | |
| target.insert(pos, label) | |
| shift += 1 | |
| elif label.startswith("$TRANSFORM_CASE"): | |
| target[pos] = transform_case(source, label) | |
| elif label.startswith("$TRANSFORM_VERB_"): | |
| target[pos] = self.number_words.get((source, label[len("$TRANSFORM_VERB_"):]), source) | |
| return replace_merges(target) | |
| def replace_merges(tokens: list[str]) -> list[str]: | |
| """utils.replace_merge_transforms: "5 $MERGE_SPACE G" -> "5G".""" | |
| if not any(t.startswith("$MERGE_") for t in tokens): | |
| return tokens | |
| if tokens[0].startswith("$MERGE_"): | |
| tokens = tokens[1:] | |
| if tokens and tokens[-1].startswith("$MERGE_"): | |
| tokens = tokens[:-1] | |
| line = " ".join(tokens).replace(" $MERGE_HYPHEN ", "-").replace(" $MERGE_SPACE ", "") | |
| return re.sub(r"([.,?:]\s+)+", r"\1", line).split() | |
| def split_chunks(words: list[str]) -> list[list[str]]: | |
| """GecBERTModel.split_chunks: overlapping windows of CHUNK_SIZE words.""" | |
| n = len(words) | |
| if n <= CHUNK_SIZE: | |
| return [words] | |
| if n < CHUNK_SIZE * 2 - OVERLAP_SIZE: | |
| split = (n + OVERLAP_SIZE + 1) // 2 | |
| return [words[:split], words[split - OVERLAP_SIZE:]] | |
| return [words[i:i + CHUNK_SIZE] for i in range(0, n - OVERLAP_SIZE, CHUNK_SIZE - OVERLAP_SIZE)] | |
| def merge_chunks(tokens: list[str], following: list[str]) -> list[str]: | |
| """GecBERTModel.apply_chunk_merging: joins two corrected chunks inside their overlap. | |
| Aligns the last OVERLAP_SIZE words of one chunk with the first OVERLAP_SIZE of the next (punctuation ignored, | |
| lower-cased) and cuts at the first common run at least MIN_WORDS_CUT words into the overlap. Where the original | |
| finds no cut it raises, and its caller drops the following chunk; this keeps that behavior. | |
| """ | |
| if not tokens: | |
| return following | |
| source_idx, source = [], [] | |
| i = 0 | |
| while len(source_idx) < OVERLAP_SIZE and -i < len(tokens): | |
| i -= 1 | |
| if tokens[i] not in PUNCTUATION: | |
| source_idx.insert(0, i) | |
| source.insert(0, tokens[i].lower()) | |
| target_idx, target = [], [] | |
| i = 0 | |
| while len(target_idx) < OVERLAP_SIZE and i < len(following): | |
| if following[i] not in PUNCTUATION: | |
| target_idx.append(i) | |
| target.append(following[i].lower()) | |
| i += 1 | |
| keep = OVERLAP_SIZE - MIN_WORDS_CUT | |
| for tag, i1, i2, j1, j2 in opcodes(source, target): | |
| if tag == "equal": | |
| if i1 >= keep: | |
| return tokens[:source_idx[i1]] + following[target_idx[j1]:] | |
| if i2 > keep: | |
| j = j2 - i2 + keep | |
| if j >= len(target_idx): | |
| return tokens # IndexError in the original | |
| return tokens[:source_idx[keep]] + following[target_idx[j]:] | |
| elif tag == "delete" and i1 == 0: | |
| keep += i2 // 2 | |
| return tokens | |
| def matching_blocks(a: list[str], b: list[str]) -> list[tuple[int, int, int]]: | |
| """difflib.SequenceMatcher(None, a, b).get_matching_blocks() for short lists (no junk, no autojunk).""" | |
| def longest(alo, ahi, blo, bhi): | |
| # Longest common run; ties go to the smallest i, then the smallest j, as in difflib. | |
| besti, bestj, bestsize = alo, blo, 0 | |
| lengths: dict[int, int] = {} | |
| for i in range(alo, ahi): | |
| current = {} | |
| for j in range(blo, bhi): | |
| if a[i] == b[j]: | |
| k = current[j] = lengths.get(j - 1, 0) + 1 | |
| if k > bestsize: | |
| besti, bestj, bestsize = i - k + 1, j - k + 1, k | |
| lengths = current | |
| return besti, bestj, bestsize | |
| queue, blocks = [(0, len(a), 0, len(b))], [] | |
| while queue: | |
| alo, ahi, blo, bhi = queue.pop() | |
| i, j, k = longest(alo, ahi, blo, bhi) | |
| if k: | |
| blocks.append((i, j, k)) | |
| if alo < i and blo < j: | |
| queue.append((alo, i, blo, j)) | |
| if i + k < ahi and j + k < bhi: | |
| queue.append((i + k, ahi, j + k, bhi)) | |
| blocks.sort() | |
| merged = [] | |
| for i, j, k in blocks: # join adjacent blocks | |
| if merged and merged[-1][0] + merged[-1][2] == i and merged[-1][1] + merged[-1][2] == j: | |
| merged[-1] = (merged[-1][0], merged[-1][1], merged[-1][2] + k) | |
| else: | |
| merged.append((i, j, k)) | |
| merged.append((len(a), len(b), 0)) | |
| return merged | |
| def opcodes(a: list[str], b: list[str]) -> list[tuple[str, int, int, int, int]]: | |
| """difflib.SequenceMatcher(None, a, b).get_opcodes().""" | |
| i = j = 0 | |
| result = [] | |
| for ai, bj, size in matching_blocks(a, b): | |
| tag = "replace" if i < ai and j < bj else "delete" if i < ai else "insert" if j < bj else "" | |
| if tag: | |
| result.append((tag, i, ai, j, bj)) | |
| i, j = ai + size, bj + size | |
| if size: | |
| result.append(("equal", ai, i, bj, j)) | |
| return result | |
| if __name__ == "__main__": | |
| if len(sys.argv) < 3: | |
| sys.exit(f"usage: {sys.argv[0]} path/to/repo 'text' [--fp32]") | |
| print(Capu(sys.argv[1], quantized="--fp32" not in sys.argv)(sys.argv[2])) | |