mindXtrain / mindxtrain /eval /mei /tokenizer.py
Gregory-L's picture
fork mindXtrain from GitHub (Professor-Codephreak/mindXtrain@661bd41) as the mindX-specific line
dfb775d verified
Raw History Blame Contribute Delete
5.99 kB
"""Canonical tokenizer wrapper (spec Rule 3.1).
The model's own `tokenizer.json` loaded via the HuggingFace `tokenizers`
(Rust) library is the authoritative token counter. Every reported token
figure carries the tokenizer revision hash, so cross-run comparisons
remain honest when tokenizer vocabularies differ (Qwen3.5's 248k vs
Qwen2.5's 152k vs Llama 3's 128k vocabulary).
The two cross-checks that matter operationally:
1. **Scaffold accounting (Rule 3.2)** — the ChatML wrapping adds 10-25
tokens per turn. `count_with_scaffold` reports both inclusive and
content-only counts so a "tok/s" figure can be disambiguated.
2. **Bytes-per-second (Rule 3.3)** — Qwen3.5 compresses English to ~3.5
chars/tok where Llama 3 sits at ~4.0; tokenizer-relative throughput
alone is misleading. `bytes_per_decoded_token` is the cross-vocabulary
honest answer.
Lazy import: this module loads without `tokenizers` installed; callers
get a clear RuntimeError pointing at `uv sync --extra ml`.
"""
from __future__ import annotations
import hashlib
from functools import lru_cache
from pathlib import Path
from typing import Any
def _require_tokenizers() -> Any:
try:
import tokenizers # type: ignore
except ImportError as exc:
msg = (
"MEI canonical tokenizer requires the `tokenizers` library — "
"run `uv sync --extra ml`."
)
raise RuntimeError(msg) from exc
return tokenizers
@lru_cache(maxsize=8)
def _load_tokenizer(model_id_or_path: str) -> Any:
"""Load Tokenizer.from_file(<dir>/tokenizer.json) or from_pretrained.
Cached so repeated count calls don't re-parse the JSON. The cache key
is the input string — callers can either pass a local path or an HF
Hub repo ID; both resolve through the same code path.
"""
tok_mod = _require_tokenizers()
path = Path(model_id_or_path).expanduser()
if path.is_dir() and (path / "tokenizer.json").exists():
return tok_mod.Tokenizer.from_file(str(path / "tokenizer.json"))
if path.is_file() and path.name == "tokenizer.json":
return tok_mod.Tokenizer.from_file(str(path))
# Fall back to HF Hub lookup via the transformers convenience layer if
# available; otherwise raise so the caller knows the lookup failed.
try:
return tok_mod.Tokenizer.from_pretrained(model_id_or_path)
except Exception as exc:
msg = (
f"could not load tokenizer for {model_id_or_path!r}: "
f"neither a local tokenizer.json nor an HF Hub repo could be "
f"resolved ({type(exc).__name__}: {exc})"
)
raise RuntimeError(msg) from exc
def tokenizer_revision(model_id_or_path: str) -> str:
"""Stable hash over the tokenizer.json bytes (spec Rule 3.1).
Every reported token figure carries this hash so a measurement run
can be replayed and cross-checked against the exact tokenizer
revision it consumed.
"""
path = Path(model_id_or_path).expanduser()
tj = path / "tokenizer.json" if path.is_dir() else path
if tj.exists() and tj.is_file():
digest = hashlib.blake2b(tj.read_bytes(), digest_size=16).hexdigest()
return f"local:{digest}"
# For HF Hub references we can't BLAKE the local file (it isn't
# materialized yet at this layer). Surface the repo identity instead.
return f"hub:{model_id_or_path}"
def encode_with_revision(
text: str, model_id_or_path: str, *, add_special_tokens: bool = False,
) -> tuple[list[int], str]:
"""Return (token_ids, tokenizer_revision_hash). Rule 3.1."""
tok = _load_tokenizer(model_id_or_path)
encoding = tok.encode(text, add_special_tokens=add_special_tokens)
return list(encoding.ids), tokenizer_revision(model_id_or_path)
def _chatml_render(messages: list[dict[str, str]]) -> str:
"""Render a ChatML conversation the way Qwen tokenizers expect.
The MEI counts every token the model processes, *including* scaffold
(Rule 3.2). We render explicitly here rather than delegating to a
tokenizer's `apply_chat_template` because we want byte-stable output
regardless of template revisions — the count is what matters.
"""
parts: list[str] = []
for m in messages:
role = m.get("role", "user")
content = m.get("content", "")
parts.append(f"<|im_start|>{role}\n{content}<|im_end|>\n")
# Trailing assistant prompt — the model's generation prefix.
parts.append("<|im_start|>assistant\n")
return "".join(parts)
def count_with_scaffold(
messages: list[dict[str, str]],
model_id_or_path: str,
*,
include_scaffold: bool = True,
) -> int:
"""Token count for a chat-style payload (Rule 3.2).
When `include_scaffold=True` (the operationally-honest default), the
ChatML `<|im_start|>`, `<|im_end|>` markers and role headers are
counted — matches what the model actually processes. When False, the
count strips structural overhead — suitable for user-facing
reporting only, never for tok/s denominators.
"""
if include_scaffold:
rendered = _chatml_render(messages)
else:
rendered = "\n".join(m.get("content", "") for m in messages)
ids, _ = encode_with_revision(rendered, model_id_or_path)
return len(ids)
def bytes_per_decoded_token(
token_ids: list[int], model_id_or_path: str,
) -> float:
"""UTF-8 bytes per decoded token (Rule 3.3).
Cross-tokenizer-invariant cross-check. A Qwen 100 tok/s and a Llama
100 tok/s produce different amounts of useful output; this number is
how MEI keeps cross-vocab comparisons honest. Returns 0.0 for empty
token lists.
"""
if not token_ids:
return 0.0
tok = _load_tokenizer(model_id_or_path)
decoded = tok.decode(token_ids)
return len(decoded.encode("utf-8")) / len(token_ids)
__all__ = [
"bytes_per_decoded_token",
"count_with_scaffold",
"encode_with_revision",
"tokenizer_revision",
]