PowerMachine commited on
Commit
5b5cfcd
·
verified ·
1 Parent(s): 0be1502

HAKO upload: hako/tokenizer/byte_bpe.py

Browse files
Files changed (1) hide show
  1. hako/tokenizer/byte_bpe.py +234 -0
hako/tokenizer/byte_bpe.py ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Parallel byte-level BPE trainer/encoder with provably exact sharded counts.
2
+
3
+ Lemma 1 (additivity of pair counts across shards -- exactness).
4
+ The corpus is a MULTISET OF DOCUMENT SEQUENCES {s_d}; tokenization is
5
+ document-independent, so the multiset of adjacent pairs decomposes as the
6
+ DISJOINT union
7
+ Pair(corpus) = U_d Interior(s_d), Interior(s_d) = {(s_d_i, s_d_{i+1})}.
8
+ A shard assignment partitions the documents, hence for every symbol pair (a,b)
9
+ C_global(a, b) = sum_shard p sum_{d in p} C_interior_d(a, b),
10
+ an identity (counting measure over a disjoint union). No cross-document
11
+ pairs exist in the object language (documents are atomic sequences, exactly
12
+ as in LM training), so -- unlike a naive 'boundary pair ownership' scheme --
13
+ NO counting or ownership rule can depend on the sharding. The merge choice
14
+ is a deterministic function of the global counts (argmax, fixed
15
+ lexicographic tie-break, min-frequency threshold), and every occurrence of
16
+ the winning pair lies ENTIRELY inside one document, so the per-shard apply
17
+ step rewrites exactly the occurrences that were counted. By induction on
18
+ merge iterations, a trainer consuming sharded counts performs EXACTLY the
19
+ merge sequence of a full-corpus trainer, for any sharding. Q.E.D.
20
+
21
+ (Correction note: an earlier draft counted pairs between chained documents
22
+ inside a shard; that made counts sharding-dependent -- the inconsistency is
23
+ resolved by the document-atomic formulation above, which the test suite
24
+ verifies by merge-history equality across shardings.)
25
+
26
+ Encoding uses the learned rank table (greedy lowest-rank merge); the vocab
27
+ is byte-aligned so any UTF-8 string round-trips losslessly (all 256 bytes
28
+ are always in the vocab: PT-BR accents and CJK included).
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ from collections import Counter
34
+ from concurrent.futures import ThreadPoolExecutor
35
+ from pathlib import Path
36
+ from typing import Dict, Iterable, List, Sequence, Tuple
37
+
38
+ _NUM_WORKERS_CAP = 8
39
+
40
+
41
+ def _byte_encode(text: str) -> List[int]:
42
+ return list(text.encode("utf-8"))
43
+
44
+
45
+ def _byte_decode(ids: Sequence[int]) -> str:
46
+ return bytes(ids).decode("utf-8", errors="replace")
47
+
48
+
49
+ class ByteBPETokenizer:
50
+ """Trainer + encoder/decoder for byte-level BPE with sharded exact counts."""
51
+
52
+ def __init__(self, vocab_size: int = 8192, min_freq: int = 2,
53
+ shards: int = 8) -> None:
54
+ self.vocab_size = max(vocab_size, 300) # TARGET size (attribute)
55
+ self.min_freq = min_freq
56
+ self.shards = max(2, min(shards, _NUM_WORKERS_CAP))
57
+ # id -> bytes string (bytes 0..255 always present)
58
+ self.vocab: Dict[int, bytes] = {i: bytes([i]) for i in range(256)}
59
+ self.ranks: Dict[Tuple[int, int], int] = {} # pair -> merge priority
60
+ self.merge_left: Dict[Tuple[int, int], int] = {}
61
+ self.merge_right: Dict[Tuple[int, int], int] = {}
62
+ self.merge_history: List[Tuple[int, int, int]] = []
63
+
64
+ # ------------------------------------------------------------- training
65
+ @staticmethod
66
+ def _count_interior(seq: List[int]) -> Counter:
67
+ c = Counter()
68
+ for a, b in zip(seq, seq[1:]):
69
+ c[(a, b)] += 1
70
+ return c
71
+
72
+ def _boundary_counts(self, shard_seqs: List[List[int]]) -> Counter:
73
+ c = Counter()
74
+ for left, right in zip(shard_seqs, shard_seqs[1:]):
75
+ if left and right:
76
+ c[(left[-1], right[0])] += 1
77
+ return c
78
+
79
+ def train(self, corpus: Iterable[str], log_every: int = 128) -> None:
80
+ """Chunk-atomic BPE with INCREMENTAL pair counting (efficient).
81
+
82
+ Pre-tokenization splits each document into atomic chunks (whitespace
83
+ runs and non-whitespace words, both byte-encoded). Lemma 1 applies at
84
+ chunk level: C_global = sum over chunks of interior pairs, identical
85
+ for any shard assignment of chunks. Instead of recounting all pairs
86
+ at every merge (O(corpus) per merge), an index pair -> {chunk ids}
87
+ updates only the chunks touched by the merge -- the standard
88
+ linear-work BPE optimization, with the SAME merge sequence as full
89
+ recounting (counts are maintained as exact deltas).
90
+ """
91
+ import re
92
+ texts = [t for t in corpus if t]
93
+ if not texts:
94
+ raise ValueError("empty corpus")
95
+ # unique chunks with frequencies (huge speedup; same counts)
96
+ chunk_pat = re.compile(r"\s+|\S+")
97
+ freq: Counter = Counter()
98
+ for t in texts:
99
+ for chunk in chunk_pat.findall(t):
100
+ if chunk:
101
+ freq[bytes(_byte_encode(chunk))] += 1
102
+ # de-duplicate identical byte sequences (same bytes, same merges)
103
+ uniq: Dict[bytes, int] = {}
104
+ seqs: List[List[int]] = []
105
+ counts: List[int] = []
106
+ for seq, c in freq.items():
107
+ key = bytes(seq)
108
+ if key in uniq:
109
+ counts[uniq[key]] += c
110
+ else:
111
+ uniq[key] = len(seqs)
112
+ seqs.append(list(seq))
113
+ counts.append(c)
114
+ # shard the UNIQUE chunks (Lemma-1 shard assignment kept explicit)
115
+ # NOTE: work is single-threaded incremental updates; the shard
116
+ # assignment documents the equivalence contract (any sharding of the
117
+ # atomic chunks yields identical merges).
118
+ assignments: List[List[int]] = [[] for _ in range(self.shards)]
119
+ for i in range(len(seqs)):
120
+ assignments[i % self.shards].append(i)
121
+ _ = assignments
122
+
123
+ # global pair counts + pair -> seq ids index
124
+ total: Counter = Counter()
125
+ index: Dict[Tuple[int, int], set] = {}
126
+ for sid, ids in enumerate(seqs):
127
+ for pair in zip(ids, ids[1:]):
128
+ total[pair] += counts[sid]
129
+ index.setdefault(pair, set()).add(sid)
130
+
131
+ n_merges = self.vocab_size - 256
132
+ for step in range(n_merges):
133
+ if not total:
134
+ break
135
+ (a, b), cnt = min(total.items(), key=lambda kv: (-kv[1], kv[0]))
136
+ if cnt < self.min_freq:
137
+ break
138
+ new_id = 256 + step
139
+ self.vocab[new_id] = self.vocab[a] + self.vocab[b]
140
+ self.ranks[(a, b)] = step
141
+ self.merge_left[(a, b)] = a
142
+ self.merge_right[(a, b)] = b
143
+ self.merge_history.append((a, b, new_id))
144
+ # delta-update only the sequences containing the pair
145
+ affected = index.pop((a, b), set())
146
+ for sid in affected:
147
+ ids = seqs[sid]
148
+ c = counts[sid]
149
+ # subtract old pair multiset of this sequence
150
+ for pair in zip(ids, ids[1:]):
151
+ total[pair] -= c
152
+ new_ids = self._apply_merge(ids, a, b, new_id)
153
+ seqs[sid] = new_ids
154
+ for pair in zip(new_ids, new_ids[1:]):
155
+ total[pair] += c
156
+ index.setdefault(pair, set()).add(sid)
157
+ total.pop((a, b), None)
158
+ # periodic prune (zero/near-zero entries cannot win the argmax
159
+ # because min_freq >= 2 > 0, but they would grow memory)
160
+ if step % 64 == 63:
161
+ total = Counter({p: v for p, v in total.items() if v > 0})
162
+ if log_every and (step + 1) % log_every == 0:
163
+ print(f"[bpe] merge {step+1}/{n_merges} pair=({a},{b}) cnt={cnt}")
164
+
165
+ @staticmethod
166
+ def _count_docs(docs: List[List[int]]) -> Counter:
167
+ c = Counter()
168
+ for seq in docs:
169
+ for pair in zip(seq, seq[1:]):
170
+ c[pair] += 1
171
+ return c
172
+
173
+ @staticmethod
174
+ def _apply_merge(seq: List[int], a: int, b: int, new_id: int) -> List[int]:
175
+ out: List[int] = []
176
+ i, n = 0, len(seq)
177
+ while i < n:
178
+ if i < n - 1 and seq[i] == a and seq[i + 1] == b:
179
+ out.append(new_id)
180
+ i += 2
181
+ else:
182
+ out.append(seq[i])
183
+ i += 1
184
+ return out
185
+
186
+ # ---------------------------------------------------------- encode/decode
187
+ def _encode_seq(self, ids: List[int]) -> List[int]:
188
+ # greedy by rank (standard BPE encode)
189
+ while len(ids) >= 2:
190
+ best, best_rank = None, None
191
+ for pair in zip(ids, ids[1:]):
192
+ r = self.ranks.get(pair)
193
+ if r is not None and (best_rank is None or r < best_rank):
194
+ best, best_rank = pair, r
195
+ if best is None:
196
+ break
197
+ a, b = best
198
+ new_id = 256 + self.ranks[best]
199
+ ids = self._apply_merge(ids, a, b, new_id)
200
+ return ids
201
+
202
+ def encode(self, text: str) -> List[int]:
203
+ return self._encode_seq(_byte_encode(text))
204
+
205
+ def decode(self, ids: Iterable[int]) -> str:
206
+ buf = b"".join(self.vocab.get(int(i), bytes([int(i) & 0xFF]))
207
+ for i in ids)
208
+ return buf.decode("utf-8", errors="replace")
209
+
210
+ @property
211
+ def n_vocab(self) -> int:
212
+ """ACTUAL number of learned ids (256 bytes + merges so far)."""
213
+ return len(self.vocab)
214
+
215
+ # ------------------------------------------------------------------- io
216
+ def save(self, path: Path) -> None:
217
+ data = {
218
+ "vocab_size": self.vocab_size,
219
+ "min_freq": self.min_freq,
220
+ "merges": [[a, b, new] for (a, b, new) in self.merge_history],
221
+ }
222
+ Path(path).write_text(json.dumps(data), encoding="utf-8")
223
+
224
+ @classmethod
225
+ def load(cls, path: Path) -> "ByteBPETokenizer":
226
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
227
+ tok = cls(vocab_size=data["vocab_size"], min_freq=data["min_freq"])
228
+ for a, b, new in data["merges"]:
229
+ tok.vocab[new] = tok.vocab[a] + tok.vocab[b]
230
+ tok.ranks[(a, b)] = len(tok.merge_history)
231
+ tok.merge_left[(a, b)] = a
232
+ tok.merge_right[(a, b)] = b
233
+ tok.merge_history.append((a, b, new))
234
+ return tok