Voltline's picture
Release vimeml-tiny-zh-v1 (step 844730): FP32 bundle, INT8 Core ML packages, source snapshot (part 2)
99297d7 verified
Raw History Blame Contribute Delete
13.1 kB
"""Decoder, simulation and metrics of the Chinese prediction benchmark ime-predict-zh-v1.
Protocol: docs/zh-CN/benchmarks/predict-zh-plan.md; implementation details fixed before any
score: docs/zh-CN/benchmarks/predict-ime-zh.md (解码与模拟的实现细节).
Every language model goes through the same decoder; only the adapter differs (how turns are
joined into token ids, and next-token log-probabilities):
- context: the adapter joins earlier turns and the typed prefix in its own training format
and drops the oldest whole turns that do not fit, keeping RESERVE tokens for decoding;
- token healing: when the prefix ends in Chinese and its last token is pure Chinese, that
token is taken back and every hypothesis must start by re-spelling it;
- beam search, width 16, one token per step, only tokens whose text is entirely Chinese
characters, at most 6 new characters; every hypothesis ending on a token boundary with
2-6 new characters is a candidate, scored by the sum of its token log-probabilities
(the best path when several spell the same string);
- the top 5 candidates by score that are not prefixes of one another;
- track C keeps only hypotheses compatible with the typed keys (rime-ice single-character
readings).
"""
import json
import math
import re
from dataclasses import dataclass
from pathlib import Path
import torch
HAN = re.compile("[〇㐀-䶿一-鿿豈-﫿]")
HAN_RUN = re.compile("[〇㐀-䶿一-鿿豈-﫿]+")
BEAM = 16
MAX_CHARS = 6
MIN_CHARS = 2
TOP = 5
SHOW = 3
RESERVE = 12
NEG = float("-inf")
def all_han(text):
return bool(text) and all(HAN.fullmatch(c) for c in text)
def read_jsonl(path):
with open(path, encoding="utf-8") as stream:
return [json.loads(line) for line in stream if line.strip()]
def load_readings(dict_dir):
"""Character -> set of syllables (ü as v) from the rime-ice single-character tables."""
readings = {}
for name in ("8105.dict.yaml", "41448.dict.yaml"):
body = False
with open(Path(dict_dir) / name, encoding="utf-8") as stream:
for line in stream:
line = line.rstrip("\n")
if line == "...":
body = True
continue
if not body or not line or line.startswith("#"):
continue
parts = line.split("\t")
if len(parts) >= 2 and len(parts[0]) == 1 and parts[1].isalpha():
readings.setdefault(parts[0], set()).add(parts[1])
return readings
@dataclass(frozen=True)
class Constraint:
"""kind 'syllable': keys is the full first syllable; kind 'initials': one letter per character."""
kind: str
keys: str
@property
def max_chars(self):
return len(self.keys) if self.kind == "initials" else MAX_CHARS
def candidate_length_ok(self, n):
return n == len(self.keys) if self.kind == "initials" else MIN_CHARS <= n <= MAX_CHARS
def chars_ok(self, readings, chars, done):
"""Can `chars` follow `done` already generated characters?"""
if self.kind == "syllable":
return done > 0 or not chars or self.keys in readings.get(chars[0], ())
if done + len(chars) > len(self.keys):
return False
return all(any(r.startswith(self.keys[done + i]) for r in readings.get(c, ())) for i, c in enumerate(chars))
class Vocabulary:
"""Per-token facts the decoder needs, built once per adapter."""
def __init__(self, texts, device):
self.texts = texts
self.device = device
han = [t is not None and all_han(t) for t in texts]
self.han = torch.tensor(han, device=device)
self.chars = torch.tensor([len(t) if h else 0 for t, h in zip(texts, han)], device=device)
self.han_ids = [i for i, h in enumerate(han) if h]
self.by_first = {}
for i in self.han_ids:
self.by_first.setdefault(texts[i][0], []).append(i)
self._length = {}
self._constraint = {}
def length_mask(self, room):
if room not in self._length:
self._length[room] = self.han & (self.chars <= room)
return self._length[room]
def constraint_mask(self, constraint, readings, done):
key = (constraint.kind, constraint.keys[done:] if constraint.kind == "initials" else constraint.keys,
done if constraint.kind == "syllable" else None)
if key not in self._constraint:
if constraint.kind == "syllable" and done > 0:
mask = self.han
else:
ok = [i for i in self.han_ids if constraint.chars_ok(readings, self.texts[i], done)]
mask = torch.zeros_like(self.han)
mask[ok] = True
self._constraint[key] = mask
return self._constraint[key]
def healing(lm, ids, prefix):
"""Take back the last context token when it is a pure-Chinese spelling of the prefix's end."""
if prefix and HAN.fullmatch(prefix[-1]) and len(ids) > 1:
text = lm.vocabulary.texts[ids[-1]]
if text is not None and all_han(text) and prefix.endswith(text):
return ids[:-1], text
return ids, ""
def select(scored, top=TOP):
"""Highest scores first; skip a candidate that is a prefix of a chosen one or has one as prefix."""
chosen = []
for text, score in sorted(scored.items(), key=lambda x: (-x[1], x[0])):
if any(text.startswith(c) or c.startswith(text) for c, _ in chosen):
continue
chosen.append((text, score))
if len(chosen) == top:
break
return chosen
def predict(lm, turns, prefix, constraint=None, readings=None, beam=BEAM, top=TOP):
"""Top candidates [(text, score)] after `prefix`, with earlier `turns` as context."""
vocabulary = lm.vocabulary
ids, required = healing(lm, lm.context(turns, prefix), prefix)
max_chars = constraint.max_chars if constraint else MAX_CHARS
if len(ids) + len(required) + max_chars > lm.context_length:
raise ValueError("Context leaves no room for decoding")
candidates = {}
states = [((), "", 0.0)]
while states:
logprobs = lm.logprobs([ids + list(suffix) for suffix, _, _ in states])
expansions = []
for row, (suffix, text, score) in enumerate(states):
pending = required[len(text):]
if pending:
for token in vocabulary.by_first.get(pending[0], ()):
piece = vocabulary.texts[token]
if piece.startswith(pending):
extra = piece[len(pending):]
if len(extra) > max_chars or (constraint and not constraint.chars_ok(readings, extra, 0)):
continue
elif not pending.startswith(piece):
continue
expansions.append((score + float(logprobs[row, token]), suffix + (token,), text + piece))
continue
done = len(text) - len(required)
mask = vocabulary.length_mask(max_chars - done)
if constraint:
mask = mask & vocabulary.constraint_mask(constraint, readings, done)
allowed = int(mask.sum())
if not allowed:
continue
values, tokens = logprobs[row].masked_fill(~mask, NEG).topk(min(beam, allowed))
for value, token in zip(values.tolist(), tokens.tolist()):
expansions.append((score + value, suffix + (token,), text + vocabulary.texts[token]))
expansions.sort(key=lambda x: (-x[0], x[2]))
states = []
for score, suffix, text in expansions[:beam]:
if len(text) < len(required):
states.append((suffix, text, score))
continue
new = text[len(required):]
ok = constraint.candidate_length_ok(len(new)) if constraint else MIN_CHARS <= len(new) <= MAX_CHARS
if ok and score > candidates.get(new, NEG):
candidates[new] = score
if len(new) < max_chars:
states.append((suffix, text, score))
return select(candidates, top)
class VimeLM:
"""vimeml-tiny-zh models: each turn is its own BOS ... EOS sequence, as in training."""
def __init__(self, model, processor, device, special):
self.model, self.processor, self.device, self.special = model, processor, device, special
self.context_length = model.config.context_length
texts = []
for i in range(processor.get_piece_size()):
plain = not (processor.is_control(i) or processor.is_unknown(i) or processor.is_byte(i))
texts.append(processor.id_to_piece(i) if plain else None)
self.vocabulary = Vocabulary(texts, "cpu")
def context(self, turns, prefix):
bos, eos = self.special["bos"], self.special["eos"]
limit = self.context_length - RESERVE
tail = self.processor.encode(prefix) if prefix else []
ids = [bos, *tail[-(limit - 1):]] if tail else [bos]
for turn in reversed(turns):
if not turn:
continue
piece = [bos, *self.processor.encode(turn), eos]
if len(ids) + len(piece) > limit:
break
ids = piece + ids
return ids
@torch.inference_mode()
def logprobs(self, sequences):
inputs = torch.tensor(sequences, dtype=torch.long, device=self.device)
return self.model(inputs)[:, -1].float().log_softmax(-1).cpu()
# Track B ------------------------------------------------------------------------------------
def keystrokes(message, pinyin):
"""Per-character full-pinyin key count: letters of the reading for Chinese, 1 otherwise."""
costs = [1] * len(message)
for run in HAN_RUN.finditer(message):
readings = pinyin(run.group())
if len(readings) != len(run.group()):
raise ValueError("Reading count differs from character count")
for k, reading in enumerate(readings):
costs[run.start() + k] = len(reading)
return costs
def boundaries(message, cut):
out, position = [], 0
for word in cut(message):
position += len(word)
out.append(position)
if position != len(message):
raise ValueError("Segmentation does not cover the message")
return out
def simulate(message, costs, ends, predictions, threshold, show=SHOW):
"""Type `message`; after every commit (not before the first one) up to `show` predictions
with score >= threshold are offered, and the longest one that is a prefix of the rest is
taken (one key). Otherwise the next segment (to the next word end) is typed in full pinyin.
predictions(p) -> [(text, score)]."""
position = keys = shows = clicks = 0
while position < len(message):
offered = []
if position:
offered = [text for text, score in predictions(position)[:show] if score >= threshold]
shows += bool(offered)
hits = [text for text in offered if message.startswith(text, position)]
if hits:
position += len(max(hits, key=len))
keys += 1
clicks += 1
continue
end = next(e for e in ends if e > position)
keys += sum(costs[position:end])
position = end
return {"keys": keys, "baseline_keys": sum(costs), "shows": shows, "clicks": clicks}
# Metrics ------------------------------------------------------------------------------------
def rank_of_hit(candidates, continuation):
for rank, (text, _) in enumerate(candidates, 1):
if continuation.startswith(text):
return rank, len(text)
return None, 0
def rank_exact(candidates, target):
for rank, (text, _) in enumerate(candidates, 1):
if text == target:
return rank
return None
def rates(rows, key, ks=(1, 3, 5)):
"""rows: dicts with 'source' and key -> rank or None. Micro and macro over sources."""
sources = sorted({r["source"] for r in rows})
out = {}
for k in ks:
per = {s: sum(1 for r in rows if r["source"] == s and r[key] and r[key] <= k) /
max(1, sum(1 for r in rows if r["source"] == s)) for s in sources}
hits = sum(1 for r in rows if r[key] and r[key] <= k)
out[f"top{k}"] = {"micro": hits / max(1, len(rows)), "hits": hits, "items": len(rows),
"macro": sum(per.values()) / max(1, len(per)), "by_source": per}
return out
def coverage_curve(rows, points=(0.2, 0.5)):
"""Track D: Top-1 hit rate among the most confident fraction of track A items."""
ordered = sorted(rows, key=lambda r: (-(r["confidence"] if r["confidence"] is not None else NEG), r["id"]))
out = {}
for c in points:
n = max(1, round(c * len(ordered)))
out[f"{int(c * 100)}%"] = {"items": n, "top1": sum(1 for r in ordered[:n] if r["rank"] == 1) / n}
return out
def finite(x):
return x if x is not None and math.isfinite(x) else None