"""Byte-level BPE compatible with babble / booper-pretrain tokenizer.json.""" from __future__ import annotations import json import re from pathlib import Path _CHUNK_RE = re.compile(r"\s+|\S+") def _merge_ids(ids: list[int], a: int, b: int, new_id: int) -> list[int]: if len(ids) < 2: return ids out: list[int] = [] i = 0 n = len(ids) while i < n: if i + 1 < n and ids[i] == a and ids[i + 1] == b: out.append(new_id) i += 2 else: out.append(ids[i]) i += 1 return out class BPETokenizer: def __init__(self, merges: list[tuple[int, int, int]]) -> None: self.merges = merges vocab: dict[int, bytes] = {i: bytes([i]) for i in range(256)} for a, b, new_id in merges: vocab[new_id] = vocab[a] + vocab[b] self.vocab = vocab self._ranks = {(a, b): i for i, (a, b, _) in enumerate(merges)} self._pair_to_id = {(a, b): new_id for a, b, new_id in merges} base = 256 + len(merges) self.pad, self.bos, self.sep, self.eos = base, base + 1, base + 2, base + 3 @property def vocab_size(self) -> int: return 256 + len(self.merges) + 4 def _encode_chunk(self, chunk: str) -> list[int]: ids = list(chunk.encode("utf-8")) ranks = self._ranks pair_to_id = self._pair_to_id while len(ids) >= 2: best_rank = None best_pair = None for a, b in zip(ids, ids[1:]): r = ranks.get((a, b)) if r is not None and (best_rank is None or r < best_rank): best_rank = r best_pair = (a, b) if best_pair is None: break ids = _merge_ids(ids, best_pair[0], best_pair[1], pair_to_id[best_pair]) return ids def _build_fast(self): try: from tokenizers import Tokenizer from tokenizers import models as tokmodels except Exception: self._fast = None return id_to_tok = {i: bytes([i]).decode("latin-1") for i in range(256)} vocab = {s: i for i, s in id_to_tok.items()} hf_merges: list[tuple[str, str]] = [] for a, b, nid in self.merges: sa, sb = id_to_tok[a], id_to_tok[b] merged = sa + sb id_to_tok[nid] = merged vocab[merged] = nid hf_merges.append((sa, sb)) fast = Tokenizer(tokmodels.BPE(vocab, hf_merges, fuse_unk=False)) self._fast = fast def _ensure_fast(self) -> None: if getattr(self, "_fast", None) is None and not hasattr(self, "_fast_tried"): self._fast_tried = True self._build_fast() def encode(self, text: str) -> list[int]: self._ensure_fast() if getattr(self, "_fast", None) is not None: ids: list[int] = [] for chunk in _CHUNK_RE.findall(text): raw = chunk.encode("utf-8").decode("latin-1") ids.extend(self._fast.encode(raw).ids) return ids ids = [] for chunk in _CHUNK_RE.findall(text): ids.extend(self._encode_chunk(chunk)) return ids def encode_docs(self, texts: list[str]) -> list[int]: """Encode many docs and join with eos. Uses tokenizers encode_batch.""" self._ensure_fast() chunks: list[str] = [] lens: list[int] = [] for text in texts: cs = _CHUNK_RE.findall(text) lens.append(len(cs)) chunks.extend(c.encode("utf-8").decode("latin-1") for c in cs) out: list[int] = [] if self._fast is not None and chunks: encs = self._fast.encode_batch(chunks) i = 0 for n in lens: for _ in range(n): out.extend(encs[i].ids) i += 1 out.append(self.eos) return out for text in texts: out.extend(self.encode(text)) out.append(self.eos) return out def decode(self, ids: list[int]) -> str: raw = bytearray() for i in ids: piece = self.vocab.get(i) if piece is not None: raw.extend(piece) return bytes(raw).decode("utf-8", errors="replace") def to_json(self, path: Path) -> None: path.write_text(json.dumps({"merges": [list(m) for m in self.merges]})) @classmethod def from_json(cls, path: Path | str) -> "BPETokenizer": raw = json.loads(Path(path).read_text()) return cls([tuple(m) for m in raw["merges"]])