File size: 10,401 Bytes
81cffe7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Run and score the Chinese prediction benchmark ime-predict-zh-v1-r1 on development.

  run    decode tracks A, C (both key modes) and B for one model; development only
  score  Top-k rates (A, C), keystroke savings with the tuned threshold (B), coverage (D)

Protocol: docs/zh-CN/benchmarks/predict-zh-plan.md. Decoder: vimeml.benchmarks.predict_zh.
The blind split is never read here.
"""
import argparse
import json
import math
import subprocess
import sys
import time
import warnings
from pathlib import Path

ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "src"))

import torch  # noqa: E402

from vimeml.benchmarks import predict_zh as P  # noqa: E402
from vimeml.training.data import file_sha, write_json  # noqa: E402

VERSION = "ime-predict-zh-v1-r1"
FROZEN = ROOT / "outputs/benchmarks/ime-predict-zh-v1/frozen-r1"
FROZEN_SHA = {
    "items-a.jsonl": "493fc4702b9dc37b1f645fcfdcc3efa515f840adcef7e1423c3d233b21eea87f",
    "messages-b.jsonl": "2640d6b573fb2e45996a7b6556a2aeafe9af3e42e96603588947ba75fb7a1c71",
    "items-c.jsonl": "dc88bc5f0a7e1ed2e3c0ee2e9ae7841ca2712e13a34151e293e2b205df21fb2e",
    "manifest.json": "002dc9a39113e1831b5d2352187a079e15705770310dc81de6e06a87e68f6415",
}
RIME = ROOT / "datasets/ime-standard-zh-v1/engine/rime-ice"
QUANTILES = [q / 100 for q in range(5, 100, 5)]
MODES = (("first_syllable", "syllable", "keys_first_syllable"), ("initials", "initials", "keys_initials"))


def log(*parts):
    print(time.strftime("%H:%M:%S"), *parts, file=sys.stderr, flush=True)


def development(name, limit):
    rows = [r for r in P.read_jsonl(FROZEN / name) if r["split"] == "development"]
    if limit:  # smoke runs: the first `limit` items of every source
        seen = {}
        rows = [r for r in rows if seen.setdefault(r["source"], []).append(1) or len(seen[r["source"]]) <= limit]
    return rows


def write_jsonl(path, rows):
    with open(path, "w", encoding="utf-8") as stream:
        for row in rows:
            stream.write(json.dumps(row, ensure_ascii=False) + "\n")


def quantile(values, q):
    values = sorted(values)
    position = q * (len(values) - 1)
    low = math.floor(position)
    high = min(low + 1, len(values) - 1)
    return values[low] + (values[high] - values[low]) * (position - low)


def load_model(args):
    if args.model != "vime":
        raise SystemExit(f"Unknown model adapter {args.model}")
    from vimeml.benchmarks.zh13_lm_scores import load
    from vimeml.training.joint_zh13 import SPECIAL
    model, processor, meta = load(args.checkpoint, args.tokenizer, args.device)
    return P.VimeLM(model, processor, args.device, SPECIAL), meta


def run(args):
    for name, sha in FROZEN_SHA.items():
        if file_sha(FROZEN / name) != sha:
            raise SystemExit(f"{name} differs from the frozen {VERSION}")
    out = Path(args.out)
    out.mkdir(parents=True, exist_ok=True)
    lm, model_meta = load_model(args)
    dicts = RIME / "cn_dicts"
    readings = P.load_readings(dicts)
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        import jieba
        import pypinyin
    jieba.setLogLevel(60)
    rime_commit = subprocess.run(["git", "-C", str(RIME), "rev-parse", "HEAD"], capture_output=True,
                                 text=True).stdout.strip()
    meta = {
        "version": VERSION, "split": "development", "limit_per_source": args.limit,
        "model": {"adapter": args.model, **model_meta, "device": args.device, "dtype": "float32"},
        "decoder": {"beam": P.BEAM, "max_chars": P.MAX_CHARS, "min_chars": P.MIN_CHARS, "top": P.TOP,
                    "show": P.SHOW, "reserve": P.RESERVE, "context_length": lm.context_length},
        "frozen_sha256": FROZEN_SHA,
        "readings": {"rime_ice_commit": rime_commit,
                     **{n: file_sha(dicts / n) for n in ("8105.dict.yaml", "41448.dict.yaml")}},
        "jieba": jieba.__version__, "pypinyin": pypinyin.__version__, "torch": torch.__version__,
    }
    started = time.time()

    rows_a, items = [], development("items-a.jsonl", args.limit)
    for k, item in enumerate(items, 1):
        candidates = P.predict(lm, item["previous_turns"], item["prefix"])
        rank, chars = P.rank_of_hit(candidates, item["continuation"])
        rows_a.append({"id": item["id"], "source": item["source"], "dialogue": item["dialogue"],
                       "candidates": candidates, "rank": rank, "hit_chars": chars,
                       "confidence": candidates[0][1] if candidates else None})
        if k % 500 == 0:
            log("A", k, len(items))
    write_jsonl(out / "track-a.jsonl", rows_a)
    meta["seconds_a"] = round(time.time() - started, 1)

    rows_c, items = [], development("items-c.jsonl", args.limit)
    for k, item in enumerate(items, 1):
        for mode, kind, field in MODES:
            constraint = P.Constraint(kind, item[field])
            candidates = P.predict(lm, item["previous_turns"], item["prefix"], constraint, readings)
            rows_c.append({"id": item["id"], "source": item["source"], "dialogue": item["dialogue"],
                           "mode": mode, "keys": item[field], "candidates": candidates,
                           "rank": P.rank_exact(candidates, item["target"])})
        if k % 500 == 0:
            log("C", k, len(items))
    write_jsonl(out / "track-c.jsonl", rows_c)
    meta["seconds_c"] = round(time.time() - started - meta["seconds_a"], 1)

    scores = [r["confidence"] for r in rows_a if r["confidence"] is not None]
    grid = [P.NEG] + [quantile(scores, q) for q in QUANTILES]
    meta["threshold_grid"] = [P.finite(t) for t in grid]

    def pinyin(run):
        return pypinyin.lazy_pinyin(run, style=pypinyin.Style.NORMAL)

    def cut(message):
        return jieba.lcut(message, HMM=False)

    rows_b, items, decodes = [], development("messages-b.jsonl", args.limit), 0
    for k, item in enumerate(items, 1):
        message, memo = item["message"], {}

        def predictions(position):
            nonlocal decodes
            if position not in memo:
                memo[position] = P.predict(lm, item["previous_turns"], message[:position])
                decodes += 1
            return memo[position]

        costs, ends = P.keystrokes(message, pinyin), P.boundaries(message, cut)
        results = [P.simulate(message, costs, ends, predictions, t) for t in grid]
        rows_b.append({"id": item["id"], "source": item["source"], "dialogue": item["dialogue"],
                       "results": results})
        if k % 500 == 0:
            log("B", k, len(items), "decodes", decodes)
    write_jsonl(out / "track-b.jsonl", rows_b)
    meta["decodes_b"] = decodes
    meta["seconds_total"] = round(time.time() - started, 1)
    write_json(out / "meta.json", meta)
    log("done", meta["seconds_total"], "s")


def savings(rows, index):
    keys = sum(r["results"][index]["keys"] for r in rows)
    base = sum(r["results"][index]["baseline_keys"] for r in rows)
    shows = sum(r["results"][index]["shows"] for r in rows)
    clicks = sum(r["results"][index]["clicks"] for r in rows)
    return {"savings": 1 - keys / base, "keys": keys, "baseline_keys": base, "shows": shows,
            "clicks": clicks, "click_rate": clicks / shows if shows else None}


def score(args):
    run_dir = Path(args.run)
    meta = json.loads((run_dir / "meta.json").read_text(encoding="utf-8"))
    rows_a = P.read_jsonl(run_dir / "track-a.jsonl")
    hits3 = [r["hit_chars"] for r in rows_a if r["rank"] and r["rank"] <= 3]
    result = {"version": meta["version"], "split": meta["split"], "model": meta["model"],
              "track_a": {**P.rates(rows_a, "rank"),
                          "mean_hit_chars_top3": sum(hits3) / len(hits3) if hits3 else None}}
    rows_c = P.read_jsonl(run_dir / "track-c.jsonl")
    result["track_c"] = {mode: P.rates([r for r in rows_c if r["mode"] == mode], "rank") for mode, _, _ in MODES}

    rows_b = P.read_jsonl(run_dir / "track-b.jsonl")
    grid = meta["threshold_grid"]
    table = [{"threshold": t, **savings(rows_b, i)} for i, t in enumerate(grid)]
    # best savings; ties go to the higher threshold (fewer shows)
    best = max(range(len(grid)), key=lambda i: (table[i]["savings"], i))
    sources = sorted({r["source"] for r in rows_b})
    result["track_b"] = {"chosen": table[best],
                         "by_source": {s: savings([r for r in rows_b if r["source"] == s], best)["savings"]
                                       for s in sources},
                         "grid": table}
    result["track_d"] = P.coverage_curve([{"id": r["id"], "confidence": r["confidence"], "rank": r["rank"]}
                                          for r in rows_a])
    write_json(run_dir / "score.json", result)
    a, b = result["track_a"], result["track_b"]["chosen"]
    print(f"A  top1 {a['top1']['micro']:.4f}  top3 {a['top3']['micro']:.4f} (macro {a['top3']['macro']:.4f})"
          f"  top5 {a['top5']['micro']:.4f}  hit chars {a['mean_hit_chars_top3']:.2f}")
    for mode in result["track_c"]:
        c = result["track_c"][mode]
        print(f"C  {mode:14s} top1 {c['top1']['micro']:.4f}  top3 {c['top3']['micro']:.4f}"
              f"  top5 {c['top5']['micro']:.4f}")
    print(f"B  savings {b['savings']:.4f}  threshold {b['threshold']}  shows {b['shows']}"
          f"  clicks {b['clicks']}  click rate {b['click_rate']}")
    print("D ", {k: round(v["top1"], 4) for k, v in result["track_d"].items()})


def main():
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = parser.add_subparsers(dest="command", required=True)
    r = sub.add_parser("run")
    r.add_argument("--model", default="vime")
    r.add_argument("--checkpoint", default=str(ROOT / "artifacts/models/tiny-zh13-12k-fresh-s1-d384-l9/final.pt"))
    r.add_argument("--tokenizer", default=str(ROOT / "artifacts/tokenizers/zh-v13/zh-unigram-12288/tokenizer.model"))
    r.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
    r.add_argument("--limit", type=int, default=0, help="items per source, for smoke runs")
    r.add_argument("--out", required=True)
    s = sub.add_parser("score")
    s.add_argument("--run", required=True)
    args = parser.parse_args()
    run(args) if args.command == "run" else score(args)


if __name__ == "__main__":
    main()