File size: 13,062 Bytes
99297d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
"""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