File size: 11,237 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
"""vimeml-tiny-zh-v1 tokenizers: sample the frozen export, train Unigram, verify.

Paragraphs are the lines of a document after strip(); empty lines are dropped. The
sample is taken once, written to train-sample.txt and read whole by every vocabulary.
Web and review train files are grouped back by stage-a shard; every shard gets the same
quota (see sample_shard), so the accepted review documents keep their natural share.
"""
import hashlib
import json
import random
import re
import time
from pathlib import Path

ROOT = Path(__file__).resolve().parents[3]
SPECIAL = {"pad": 0, "unk": 1, "bos": 2, "eos": 3}
META_SPACE = "▁"
HAN = re.compile(r"[㐀-䶿一-鿿豈-﫿\U00020000-\U0003134f]")


def sha256(path):
    digest = hashlib.sha256()
    with open(path, "rb") as stream:
        for block in iter(lambda: stream.read(1 << 22), b""):
            digest.update(block)
    return digest.hexdigest()


def write_json(path, value):
    path = Path(path)
    temporary = path.with_suffix(path.suffix + ".tmp")
    temporary.write_bytes((json.dumps(value, ensure_ascii=False, indent=1) + "\n").encode("utf-8"))
    temporary.replace(path)


def paragraphs(text, max_bytes=None):
    for line in text.split("\n"):
        line = line.strip()
        if line and (max_bytes is None or len(line.encode("utf-8")) <= max_bytes):
            yield line


def export_identity(export, frozen=True):
    """Frozen export: manifest.json and SHA256SUMS must exist; returns their hashes."""
    export = Path(export)
    if not frozen:
        return {"frozen": False}
    for name in ("manifest.json", "SHA256SUMS"):
        if not (export / name).exists():
            raise ValueError(f"{export} is not frozen: {name} missing (run zh_export.py finish)")
    sums = {}
    for line in (export / "SHA256SUMS").read_text("utf-8").splitlines():
        digest, name = line.split(maxsplit=1)
        sums[name.lstrip("*")] = digest
    return {"frozen": True, "manifest_sha256": sha256(export / "manifest.json"),
            "sha256sums_sha256": sha256(export / "SHA256SUMS"), "sums": sums}


def web_train_files(export):
    files = sorted((Path(export) / "train").glob("web-*.parquet")) + sorted((Path(export) / "train").glob("review-*.parquet"))
    if not files:
        raise ValueError("No web/review train files")
    return files


def check_files(export, files, identity, verify):
    """Every input must be listed in SHA256SUMS; with verify, hash it too."""
    if not identity["frozen"]:
        return
    for path in files:
        name = Path(path).relative_to(export).as_posix()
        if name not in identity["sums"]:
            raise ValueError(f"{name} is not in SHA256SUMS")
        if verify and sha256(path) != identity["sums"][name]:
            raise ValueError(f"{name} differs from SHA256SUMS")


def shard_of(path):
    """web-000_00009-001.parquet and review-000_00009-000.parquet both come from shard 000_00009."""
    return Path(path).name.split("-")[1]


def sample_shard(rng, handles, quota, max_bytes):
    """Paragraphs of one stage-a shard, its web and review files pooled back together.

    Documents of the whole shard are put in one seeded random order and taken from the
    front, quota documents per round, until their paragraphs reach the quota; then quota
    paragraphs are drawn from them. Every document has the same chance however its file
    was cut into row groups (whole row groups would favor the large web groups).
    """
    groups = [(kind, handle, g, handle.metadata.row_group(g).num_rows)
              for kind, handle in handles for g in range(handle.metadata.num_row_groups)]
    starts = [0]
    for group in groups:
        starts.append(starts[-1] + group[3])
    order = list(range(starts[-1]))
    rng.shuffle(order)
    pool, taken = [], 0
    while taken < len(order) and len(pool) < quota:
        chosen = sorted(order[taken:taken + quota])
        taken += quota
        position = 0
        for index, (kind, handle, g, rows) in enumerate(groups):
            lo, hi = starts[index], starts[index] + rows
            wanted = []
            while position < len(chosen) and chosen[position] < hi:
                wanted.append(chosen[position] - lo)
                position += 1
            if not wanted:
                continue
            texts = handle.read_row_group(g, columns=["text"])["text"].to_pylist()
            pool += [(kind, line) for row in wanted for line in paragraphs(texts[row], max_bytes)]
    return rng.sample(pool, min(quota, len(pool)))


def sample(config, export, out):
    import pyarrow.parquet as pq
    rng = random.Random(config["seed"])
    files = web_train_files(export)
    shards = {}
    for path in files:
        shards.setdefault(shard_of(path), []).append(path)
    per_shard = config["web_lines"] // len(shards) + 1
    web, by_shard = [], {}
    for shard in sorted(shards):
        handles = [(p.name.split("-")[0], pq.ParquetFile(p)) for p in shards[shard]]
        picked = sample_shard(rng, handles, per_shard, config["max_line_bytes"])
        by_shard[shard] = {kind: sum(k == kind for k, _ in picked) for kind in ("web", "review")}
        web += picked
    web = [line for _, line in web[:config["web_lines"]]]
    chat_path = Path(export) / "train/chat-lccc-train.parquet"
    chat = pq.read_table(chat_path, columns=["text"])["text"].to_pylist()
    chat = [c for c in rng.sample(chat, min(config["chat_lines"], len(chat))) if c.strip() and "\n" not in c]
    lines = web + chat
    rng.shuffle(lines)
    path = out / "train-sample.txt"
    path.write_bytes(("\n".join(lines) + "\n").encode("utf-8"))
    return path, {"web_lines": len(web), "chat_lines": len(chat), "per_shard_quota": per_shard,
                  "review_lines": sum(v["review"] for v in by_shard.values()), "by_shard": by_shard,
                  "characters": sum(map(len, lines)),
                  "han": sum(len(HAN.findall(x)) for x in lines), "seed": config["seed"],
                  "files": [p.relative_to(export).as_posix() for p in files] + [chat_path.relative_to(export).as_posix()],
                  "sha256": sha256(path)}


def train_one(config, sample_path, prefix, vocab):
    import sentencepiece as spm
    spm.SentencePieceTrainer.train(
        input=str(sample_path), model_prefix=str(prefix), model_type="unigram", vocab_size=vocab,
        character_coverage=config["character_coverage"], byte_fallback=True,
        normalization_rule_name="identity", remove_extra_whitespaces=False, add_dummy_prefix=False,
        split_digits=False, hard_vocab_limit=True, pad_id=0, unk_id=1, bos_id=2, eos_id=3,
        control_symbols=config["control_symbols"], user_defined_symbols=config["user_defined_symbols"],
        input_sentence_size=0, shuffle_input_sentence=False, max_sentence_length=config["max_line_bytes"],
        seed_sentencepiece_size=1_000_000, num_threads=config["num_threads"], minloglevel=2)


def verify(model_path, config, validation_texts):
    """Special/reserved IDs, newline and marker behavior, exact roundtrip of validation."""
    import sentencepiece as spm
    sp = spm.SentencePieceProcessor(model_file=str(model_path))
    found = {name: getattr(sp, f"{name}_id")() for name in SPECIAL}
    if found != SPECIAL:
        raise ValueError(f"Special IDs {found}")
    reserved = {}
    next_id = 4
    for symbol in config["control_symbols"]:
        if sp.piece_to_id(symbol) != next_id or not sp.is_control(next_id):
            raise ValueError(f"Control symbol {symbol!r} is not ID {next_id}")
        reserved[symbol] = next_id
        next_id += 1
    for symbol in config["user_defined_symbols"]:
        if sp.piece_to_id(symbol) != next_id or sp.encode(symbol) != [next_id]:
            raise ValueError(f"User symbol {symbol!r} is not the single ID {next_id}")
        reserved[symbol] = next_id
        next_id += 1
    for symbol in config["control_symbols"]:
        ids = sp.encode(symbol)
        if any(i < next_id for i in ids) or sp.decode(ids) != symbol:
            raise ValueError(f"Literal {symbol!r} must stay ordinary text")
    bytes_ = sum(sp.is_byte(i) for i in range(sp.get_piece_size()))
    if sp.get_piece_size() != config["vocab"] or bytes_ != 256:
        raise ValueError("Vocabulary size or byte pieces differ")
    checked = mismatched = meta_space = 0
    for text in validation_texts:
        for piece in (text, *paragraphs(text)):
            if META_SPACE in piece:
                meta_space += 1  # SentencePiece decodes U+2581 as a space; the encoder drops these
                continue
            checked += 1
            ids = sp.encode(piece)
            # IDs below the newline symbol are special or control: never produced from text.
            if sp.decode(ids) != piece or any(i < 4 + len(config["control_symbols"]) for i in ids):
                mismatched += 1
    if mismatched:
        raise ValueError(f"{mismatched}/{checked} validation texts fail roundtrip or contain reserved IDs")
    return {"special_ids": SPECIAL, "reserved_ids": reserved, "byte_pieces": bytes_,
            "vocab_size": sp.get_piece_size(), "roundtrip_checked": checked,
            "roundtrip_skipped_meta_space": meta_space}


def run(config, export, out, frozen=True, verify_inputs=True):
    import pyarrow.parquet as pq
    export, out = Path(export), Path(out)
    if out.exists() and any(out.iterdir()):
        raise ValueError(f"{out} is not empty; a new tokenizer needs a new output directory")
    identity = export_identity(export, frozen)
    files = web_train_files(export) + [export / "train/chat-lccc-train.parquet"]
    validation = sorted((export / "validation").glob("*.parquet"))
    check_files(export, files + validation, identity, verify_inputs)
    out.mkdir(parents=True, exist_ok=True)
    began = time.time()
    sample_path, sample_info = sample(config, export, out)
    print(json.dumps({"sample": {k: v for k, v in sample_info.items() if k != "files"}}), flush=True)
    texts = []
    for path in validation:
        texts += pq.read_table(path, columns=["text"])["text"].to_pylist()
    models = {}
    for vocab in config["vocab_sizes"]:
        folder = out / f"zh-unigram-{vocab}"
        folder.mkdir()
        started = time.time()
        train_one(config, sample_path, folder / "tokenizer", vocab)
        checks = verify(folder / "tokenizer.model", {**config, "vocab": vocab}, texts)
        models[str(vocab)] = {"directory": folder.relative_to(out).as_posix(),
                              "tokenizer_model_sha256": sha256(folder / "tokenizer.model"),
                              "train_seconds": round(time.time() - started, 1), **checks}
        print(json.dumps({"vocab": vocab, **models[str(vocab)]}, ensure_ascii=False), flush=True)
    manifest = {"format": "vimeml_zh13_tokenizers_v1", "status": "complete", "config": config,
                "export": {k: v for k, v in identity.items() if k != "sums"},
                "sample": sample_info, "models": models,
                "validation_files": [p.relative_to(export).as_posix() for p in validation],
                "test_or_blind_read": False, "seconds": round(time.time() - began, 1)}
    write_json(out / "manifest.json", manifest)
    return manifest