#!/usr/bin/env python3 """Токен-калькулятор TextMachine: замер фактической токенизации художественных текстов. Считает для каждого файла eval/data/samples//*.txt метрики по всем токенизаторам: - tokens/CJK-знак (zh, ja), tokens/слово (en, ru), символов/токен; - при наличии manifest.json с parallel_of — соотношение объёмов перевод/оригинал. Запуск: eval/.venv/bin/python eval/token_calc.py [--samples-dir eval/data/samples] [--json-out ...] Выход: markdown-таблицы в stdout + JSON с сырыми числами. """ from __future__ import annotations import argparse import json import re import sys import unicodedata from dataclasses import dataclass, field from pathlib import Path ROOT = Path(__file__).resolve().parent # --- Токенизаторы ----------------------------------------------------------- def load_tokenizers() -> dict[str, "callable"]: """Возвращает {имя: fn(text)->int}. Каждый fn считает токены.""" toks: dict[str, callable] = {} import tiktoken for enc_name, label in [("o200k_base", "gpt-o200k (GPT-4o/5)"), ("cl100k_base", "gpt-cl100k (GPT-4)")]: enc = tiktoken.get_encoding(enc_name) toks[label] = (lambda e: lambda t: len(e.encode(t, disallowed_special=())))(enc) from tokenizers import Tokenizer hf_dirs = { "deepseek-v3.2": "DeepSeek V3.2", "qwen3": "Qwen3", "qwen2.5": "Qwen2.5", "glm-4.6": "GLM-4.6", } for d, label in hf_dirs.items(): path = ROOT / "tokenizers" / d / "tokenizer.json" if path.exists(): tk = Tokenizer.from_file(str(path)) toks[label] = (lambda t_: lambda t: len(t_.encode(t, add_special_tokens=False).ids))(tk) else: print(f"[warn] нет {path}, пропускаю {label}", file=sys.stderr) return toks # --- Подсчёт символов/слов --------------------------------------------------- CJK_RE = re.compile( "[㐀-䶿一-鿿豈-﫿\U00020000-\U0002ebef]" ) # хань-иероглифы (кандзи/ханьцзы), вкл. расширения KANA_RE = re.compile("[぀-ヿ]") # хирагана + катакана WORD_RE = re.compile(r"[A-Za-zА-Яа-яЁёÀ-ɏ]+(?:[-'’][A-Za-zА-Яа-яЁё]+)*") @dataclass class TextStats: path: str lang: str chars_total: int = 0 chars_no_ws: int = 0 cjk_chars: int = 0 # хань-иероглифы kana_chars: int = 0 # ja: кана words: int = 0 # en/ru: слова tokens: dict = field(default_factory=dict) # {tokenizer: n} def analyze(path: Path, lang: str, toks: dict) -> TextStats: text = path.read_text(encoding="utf-8") # нормализуем переводы строк, убираем BOM text = text.replace("", "").strip() st = TextStats(path=str(path), lang=lang) st.chars_total = len(text) st.chars_no_ws = sum(1 for c in text if not c.isspace()) st.cjk_chars = len(CJK_RE.findall(text)) st.kana_chars = len(KANA_RE.findall(text)) st.words = len(WORD_RE.findall(text)) for name, fn in toks.items(): st.tokens[name] = fn(text) return st def unit_count(st: TextStats) -> tuple[str, int]: """Опорная единица языка: zh/ja — CJK-знаки (для ja — кандзи+кана), en/ru — слова.""" if st.lang == "zh": return "иероглиф", st.cjk_chars if st.lang == "ja": return "знак (кандзи+кана)", st.cjk_chars + st.kana_chars return "слово", st.words # --- Отчёт ------------------------------------------------------------------- def fmt_table(rows: list[list[str]], header: list[str]) -> str: out = ["| " + " | ".join(header) + " |", "|" + "|".join("---" for _ in header) + "|"] out += ["| " + " | ".join(r) + " |" for r in rows] return "\n".join(out) def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--samples-dir", default=str(ROOT / "data" / "samples")) ap.add_argument("--json-out", default=str(ROOT / "data" / "token_calc_results.json")) args = ap.parse_args() samples = Path(args.samples_dir) toks = load_tokenizers() tok_names = list(toks) stats: list[TextStats] = [] for lang_dir in sorted(samples.iterdir()): if not lang_dir.is_dir(): continue lang = lang_dir.name for f in sorted(lang_dir.glob("*.txt")): stats.append(analyze(f, lang, toks)) if not stats: sys.exit(f"Нет файлов в {samples}//*.txt") # 1. Таблица per-file: tokens/единица по каждому токенизатору print("## Токены на опорную единицу языка (zh/ja — знак, en/ru — слово)\n") header = ["Файл", "Язык", "Единиц"] + tok_names rows = [] for st in stats: unit, n = unit_count(st) rows.append([Path(st.path).name, st.lang, f"{n}"] + [f"{st.tokens[t] / n:.3f}" if n else "—" for t in tok_names]) print(fmt_table(rows, header)) # 2. Агрегат по языку (взвешенно по объёму) print("\n## Агрегат по языку (сумма токенов / сумма единиц)\n") rows = [] langs = sorted({st.lang for st in stats}) agg: dict[str, dict] = {} for lang in langs: group = [st for st in stats if st.lang == lang] n_units = sum(unit_count(st)[1] for st in group) n_chars = sum(st.chars_no_ws for st in group) row = [lang, unit_count(group[0])[0], f"{n_units}"] agg[lang] = {"unit": unit_count(group[0])[0], "units": n_units, "chars_no_ws": n_chars, "per_unit": {}, "chars_per_token": {}} for t in tok_names: total = sum(st.tokens[t] for st in group) row.append(f"{total / n_units:.3f}") agg[lang]["per_unit"][t] = total / n_units agg[lang]["chars_per_token"][t] = n_chars / total rows.append(row) print(fmt_table(rows, ["Язык", "Единица", "Единиц"] + tok_names)) print("\n## Символов (без пробелов) на токен\n") rows = [[lang, *(f"{agg[lang]['chars_per_token'][t]:.2f}" for t in tok_names)] for lang in langs] print(fmt_table(rows, ["Язык"] + tok_names)) # 3. Параллельные пары из manifest.json manifest_path = samples / "manifest.json" pairs_out = [] if manifest_path.exists(): manifest = json.loads(manifest_path.read_text()) by_path = {st.path: st for st in stats} entries = manifest if isinstance(manifest, list) else manifest.get("files", []) pairs = [(e["path"], e["parallel_of"]) for e in entries if e.get("parallel_of")] if pairs: print("\n## Параллельные пары: объём перевода к оригиналу (в токенах одного токенизатора)\n") rows = [] def resolve(p): return by_path.get(str(ROOT.parent / p)) or by_path.get(p) for tr_path, src_spec in pairs: # parallel_of может быть списком файлов (перевод покрывает несколько) src_paths = src_spec if isinstance(src_spec, list) else [src_spec] tr = resolve(tr_path) srcs = [resolve(s) for s in src_paths] if not tr or not all(srcs): print(f"[warn] пара не найдена: {tr_path} / {src_spec}", file=sys.stderr) continue src_tokens = {t: sum(s.tokens[t] for s in srcs) for t in tok_names} ratios = {t: tr.tokens[t] / src_tokens[t] for t in tok_names} pairs_out.append({"translation": tr.path, "source": src_paths, "src_lang": srcs[0].lang, "ratios": ratios}) rows.append([Path(tr.path).name, "+".join(Path(s).name for s in src_paths)] + [f"{ratios[t]:.2f}" for t in tok_names]) if rows: print(fmt_table(rows, ["Перевод", "Оригинал"] + tok_names)) out = { "files": [vars(st) for st in stats], "aggregate_by_lang": agg, "parallel_pairs": pairs_out, "tokenizers": tok_names, } Path(args.json_out).write_text(json.dumps(out, ensure_ascii=False, indent=1)) print(f"\nJSON: {args.json_out}", file=sys.stderr) if __name__ == "__main__": main()