File size: 20,141 Bytes
c83c617 | 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 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 | """V4 tokenizer experiment: shared train-only sample, strict kana checks.
Does not open test or blind text. Existing V1/V2 training stays frozen.
"""
import argparse
import hashlib
import json
import math
import random
import sys
import time
import tomllib
from collections import Counter
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
from vimeml.tokenizer.monitor import RunMonitor
from vimeml.tokenizer.train import (
ROOT, SPECIAL_IDS, PROBES, aggregate_measurements, dump_json, example,
file_sha, measure_range, percentile, train_native, validate,
)
MARKERS = ["<CTX>", "<READ>", "<OUT>"]
def read_json(path):
return json.loads(path.read_text(encoding="utf-8-sig"))
def permitted_path(path):
"""Reject held-out roles before any file is opened, including symlinks."""
path = Path(path).resolve()
if any("blind" in part.lower() or part.lower() in {"test", "test.txt"}
for part in path.parts):
raise ValueError(f"Held-out input is forbidden: {path}")
return path
def sample_train(path, output, size, seed, expected, max_bytes, progress):
"""Explicit reservoir sampling; persist identical bytes for all vocab sizes."""
rng = random.Random(seed)
digest = hashlib.sha256()
rows = []
count = characters = longest = 0
with permitted_path(path).open("rb") as stream:
for count, raw in enumerate(stream, 1):
digest.update(raw)
text = raw.decode("utf-8").removesuffix("\n").removesuffix("\r")
if not text or "▁" in text or any(marker in text for marker in MARKERS):
raise ValueError(f"Empty/reserved text in train line {count}")
characters += len(text)
longest = max(longest, len(text.encode("utf-8")))
if longest > max_bytes:
raise ValueError("Train sentence exceeds max_sentence_length; no silent exclusion allowed")
if len(rows) < size:
rows.append(raw)
else:
index = rng.randrange(count)
if index < size:
rows[index] = raw
if count % 1_000_000 == 0:
progress(count, expected["sentences"])
if count != expected["sentences"] or characters != expected["characters"]:
raise ValueError("Train counts differ from frozen corpus metadata")
with output.open("wb") as stream:
for raw in rows:
stream.write(raw if raw.endswith(b"\n") else raw + b"\n")
return {"sentences": count, "characters": characters, "max_utf8_bytes": longest,
"train_sha256": digest.hexdigest(), "sample_sentences": len(rows),
"sample_characters": sum(len(raw.decode("utf-8").rstrip("\r\n")) for raw in rows),
"sample_sha256": file_sha(output), "seed": seed,
"policy": "Python random.Random(seed) reservoir over every train.txt line; persisted order"}
def verify_model(model, vocab_size, kana):
import sentencepiece as spm
from sentencepiece import sentencepiece_model_pb2 as pb
processor = spm.SentencePieceProcessor(model_file=str(model))
proto = pb.ModelProto.FromString(model.read_bytes())
if processor.get_piece_size() != vocab_size:
raise ValueError("Exact vocabulary size check failed")
if any(getattr(processor, f"{name}_id")() != value for name, value in SPECIAL_IDS.items()):
raise ValueError("Special IDs must be PAD/UNK/BOS/EOS = 0/1/2/3")
normalizer = proto.normalizer_spec
if (normalizer.name != "identity" or normalizer.add_dummy_prefix
or normalizer.remove_extra_whitespaces or not normalizer.escape_whitespaces):
raise ValueError("V4 normalization/whitespace contract failed")
if (proto.trainer_spec.model_type != pb.TrainerSpec.UNIGRAM
or not proto.trainer_spec.byte_fallback
or proto.trainer_spec.required_chars != kana
or list(proto.trainer_spec.user_defined_symbols) != MARKERS):
raise ValueError("V4 trainer contract failed")
for index, marker in enumerate(MARKERS, 4):
if (processor.piece_to_id(marker) != index
or proto.pieces[index].type != pb.ModelProto.SentencePiece.USER_DEFINED
or processor.encode(marker) != [index]
or processor.decode([index]) != marker):
raise ValueError(f"Reserved marker contract failed: {marker}")
checks = []
for char in kana:
token = processor.piece_to_id(char)
ok = (token != processor.unk_id() and processor.id_to_piece(token) == char
and proto.pieces[token].type == pb.ModelProto.SentencePiece.NORMAL
and processor.encode(char) == [token] and processor.decode([token]) == char)
checks.append({"character": char, "codepoint": f"U+{ord(char):04X}", "id": token, "passed": ok})
if not all(item["passed"] for item in checks):
raise ValueError(f"Kana single-character check failed: {[c for c in checks if not c['passed']]}")
byte_count = sum(processor.is_byte(i) for i in range(vocab_size))
if byte_count != 256:
raise ValueError("Expected exactly 256 byte tokens")
probes = [example(processor, text) for text in [*PROBES, *MARKERS, "か\u3099", "ガ", "ヴヷヸヹヺ"]]
if any(not p["roundtrip_ok"] or processor.unk_id() in p["ids"] for p in probes):
raise ValueError(f"Probe UNK/roundtrip check failed: {[p for p in probes if not p['roundtrip_ok'] or processor.unk_id() in p['ids']]}")
return {"status": "passed", "vocab_size": vocab_size, "special_ids": SPECIAL_IDS,
"marker_ids": dict(zip(MARKERS, range(4, 7))), "byte_piece_count": byte_count,
"normal_piece_count": vocab_size - 263, "required_kana_count": len(kana),
"kana_checks": checks, "probes": probes}
def measure_corpora(model, paths, expected, workers, batch_size, progress):
"""Exact statistics on the training sample and full validation, without test."""
import sentencepiece as spm
jobs = []
collected = {split: [] for split in paths}
for split, path in paths.items():
path = permitted_path(path)
size = path.stat().st_size
partitions = min(workers * 4, max(1, math.ceil(size / (32 * 1024 * 1024))))
for index in range(partitions):
jobs.append((model, path, size * index // partitions,
size * (index + 1) // partitions, split, batch_size))
completed = 0
total = sum(item["sentences"] for item in expected.values())
started = time.monotonic()
with ProcessPoolExecutor(max_workers=workers) as pool:
pending = {pool.submit(measure_range, job): job[4] for job in jobs}
for future in as_completed(pending):
split = pending[future]
result = future.result()
collected[split].append(result)
completed += result["sentences"]
progress(completed, total, time.monotonic() - started)
processor = spm.SentencePieceProcessor(model_file=str(model))
stats = {}
for split, results in collected.items():
stats[split], _ = aggregate_measurements(processor, results, split)
if any(stats[split][key] != expected[split][key] for key in ("sentences", "characters")):
raise ValueError(f"Measurement count mismatch: {split}")
if stats[split]["unknown_tokens"] or stats[split]["roundtrip_mismatches"]:
raise ValueError(f"Measurement UNK/roundtrip failure: {split}")
stats[split]["estimated_characters_in_128_content_tokens"] = 128 * stats[split]["characters_per_token"]
stats[split]["estimated_characters_in_126_content_tokens_plus_bos_eos"] = 126 * stats[split]["characters_per_token"]
return stats
def text_statistics(processor, texts):
lengths = Counter()
characters = tokens = byte_tokens = unknown = mismatches = 0
coverage = []
for text in texts:
ids = processor.encode(text)
lengths[len(ids)] += 1
characters += len(text)
tokens += len(ids)
byte_tokens += sum(processor.is_byte(i) for i in ids)
unknown += ids.count(processor.unk_id())
mismatches += processor.decode(ids) != text
# Immutable proto offsets use Unicode character positions in Python.
proto = processor.encode(text, out_type="immutable_proto")
if proto.pieces:
coverage.append(proto.pieces[min(126, len(proto.pieces)) - 1].end)
else:
coverage.append(0)
count = len(texts)
if unknown or mismatches:
raise ValueError("Development text failed UNK/roundtrip verification")
return {"texts": count, "characters": characters, "tokens": tokens,
"mean_tokens": tokens / count if count else None,
"characters_per_token": characters / tokens if tokens else None,
"byte_fallback_tokens": byte_tokens,
"byte_fallback_token_fraction": byte_tokens / tokens if tokens else None,
"unknown_tokens": unknown, "roundtrip_mismatches": mismatches,
"token_length_percentiles": {f"p{n}": percentile(lengths, n / 100) for n in (50, 95, 99)},
"fits_128_with_bos_eos_fraction": sum(n for length, n in lengths.items() if length + 2 <= 128) / count if count else None,
"mean_characters_retained_in_128_with_bos_eos": sum(coverage) / count if count else None}
def measure_development(model, directory):
"""Read the explicit development directory only; no LM or ranking scores."""
import sentencepiece as spm
directory = permitted_path(directory)
manifest_path = directory / "manifest.json"
manifest = read_json(manifest_path)
if (directory.name != "development" or manifest.get("split") != "development"
or manifest.get("status") != "complete"
or manifest.get("format") != "vimeml_standard_ime_candidates_v1"):
raise ValueError("Only completed standard development candidates are permitted")
from vimeml.benchmarks.standard_ime import load_standard_export
_, _, rows = load_standard_export(directory)
if not rows or any(row.get("provenance", {}).get("role") != "development" for row in rows):
raise ValueError("Benchmark contains a non-development row")
processor = spm.SentencePieceProcessor(model_file=str(model))
from vimeml.benchmarks.standard_ime import read
originals_path = directory / "evaluation_items.json"
originals = read(originals_path)
candidates = [candidate["text"] for row in rows for candidate in row["candidates"]]
readings = [row["input"] for row in originals]
# Katakana -> hiragana for later reading tasks; originals remain immutable.
hiragana = ["".join(chr(ord(c) - 0x60) if "ァ" <= c <= "ヶ" else c for c in text) for text in readings]
result = {"role": "development", "release_id": manifest["release_id"], "cases": len(rows),
"purpose": "Tokenizer statistics only; no probability/ranking/reference accuracy",
"candidates": text_statistics(processor, candidates),
"reading_original_katakana": text_statistics(processor, readings),
"reading_hiragana_diagnostic": text_statistics(processor, hiragana),
"context_plus_candidate": text_statistics(processor, [row["left_context"] + c["text"] for row in rows for c in row["candidates"]])}
result["input_sha256"] = {str(path): file_sha(path) for path in (
manifest_path, originals_path, directory / "ajimee-results/azookey-candidates.json")}
return result
def run(config_path, dry_run=False):
import sentencepiece as spm
if spm.__version__ != "0.2.1":
raise ValueError("V4 requires sentencepiece==0.2.1")
config_path = permitted_path(config_path)
config = tomllib.loads(config_path.read_text(encoding="utf-8-sig"))
settings = config["training"]
validate(settings)
if (settings["model_type"] != "unigram" or settings["vocab_size"] != 8192
or settings.get("add_dummy_prefix") is not False
or settings.get("user_defined_symbols") != MARKERS
or settings["input_sentence_size"] <= 100):
raise ValueError("Fixed V4-a tokenizer contract differs from configuration")
sizes = config["comparison"]["vocab_sizes"]
if sizes != [8192, 12288, 16384]:
raise ValueError("Expected 8K/12K/16K comparison")
measurement = config["measurement"]
if any(type(measurement[k]) is not int or measurement[k] < 1 for k in ("workers", "batch_size")):
raise ValueError("Measurement resources must be positive integers")
paths = config["paths"]
corpus = permitted_path(ROOT / paths["corpus_dir"])
output = permitted_path(ROOT / paths["output_dir"])
kana_path = permitted_path(ROOT / paths["required_chars_file"])
kana = "".join(kana_path.read_text(encoding="utf-8").splitlines())
if not kana or len(set(kana)) != len(kana):
raise ValueError("Kana list must be nonempty and unique")
benchmark = permitted_path(ROOT / paths["benchmark_development"])
if benchmark.name != "development":
raise ValueError("Benchmark path must point specifically to development")
if output.exists() and any(output.iterdir()):
raise FileExistsError(f"Refusing to overwrite existing experiment: {output}")
corpus_manifest = read_json(corpus / "manifest.json")
stats = read_json(corpus / "stats.json")["splits"]
integrity = read_json(corpus / "integrity-check.json")
if (corpus_manifest.get("status") != "complete" or integrity.get("status") != "passed"
or file_sha(corpus / "manifest.json") != integrity["manifest_sha256"]):
raise ValueError("Frozen corpus integrity metadata failed")
inputs = [config_path, kana_path, corpus / "train.txt", corpus / "validation.txt",
corpus / "manifest.json", corpus / "stats.json", corpus / "integrity-check.json"]
before = {str(p): {"bytes": p.stat().st_size, "mtime_ns": p.stat().st_mtime_ns} for p in inputs}
plan = {"output": str(output), "vocab_sizes": sizes, "required_kana_count": len(kana),
"trained_on": "train.txt only; one persisted reservoir sample",
"sample_size": settings["input_sentence_size"], "seed": settings["seed"],
"evaluated_on": ["training sample", "full validation", "standard development"],
"test_and_blind_access": False, "lm_training_started": False,
"dry_run": dry_run}
print(json.dumps(plan, ensure_ascii=False, indent=2), flush=True)
if dry_run:
return plan
output.mkdir(parents=True)
dump_json(output / "config.json", config)
dump_json(output / "plan.json", plan)
sample_path = output / "train-sample.txt"
started = time.monotonic()
with RunMonitor(output, ROOT / "runs/tokenizer/v4-a-sampling", config["monitoring"]["tensorboard"]) as monitor:
monitor.update("checking_inputs")
sample = sample_train(corpus / "train.txt", sample_path, settings["input_sentence_size"],
settings["seed"], stats["train"], settings["max_sentence_length"],
lambda done, total: monitor.update("checking_inputs", preflight_completed_sentences=done, preflight_total_sentences=total))
if sample["train_sha256"] != integrity["exports"]["train.txt"]["sha256"]:
raise ValueError("Actual train SHA256 differs from frozen corpus")
validation_hash = file_sha(corpus / "validation.txt")
if validation_hash != integrity["exports"]["validation.txt"]["sha256"]:
raise ValueError("Actual validation SHA256 differs from frozen corpus")
dump_json(output / "sampling.json", sample)
monitor.update("complete")
results = {}
for size in sizes:
target = output / f"ja-unigram-{size}"
target.mkdir()
with RunMonitor(target, ROOT / "runs/tokenizer" / f"v4-a-{size}", config["monitoring"]["tensorboard"]) as monitor:
monitor.update("training")
options = {k: v for k, v in settings.items() if k not in {"seed", "input_sentence_size"}}
options.update(input=str(sample_path), model_prefix=str(target / "tokenizer"), vocab_size=size,
input_sentence_size=0, shuffle_input_sentence=False, hard_vocab_limit=True,
escape_whitespaces=True, required_chars=kana,
**{f"{name}_id": value for name, value in SPECIAL_IDS.items()})
train_native(options, settings["seed"], target, monitor)
model = target / "tokenizer.model"
verification = verify_model(model, size, kana)
dump_json(target / "validation.json", verification)
monitor.update("measuring")
split_stats = measure_corpora(model, {"train_sample": sample_path, "validation": corpus / "validation.txt"},
{"train_sample": {"sentences": sample["sample_sentences"], "characters": sample["sample_characters"]}, "validation": stats["validation"]},
measurement["workers"], measurement["batch_size"],
lambda done, total, elapsed: monitor.update("measuring", completed_sentences=done, total_sentences=total, measurement_elapsed_seconds=elapsed))
development = measure_development(model, benchmark)
result = {"vocab_size": size, "normal_piece_count": size - 263,
"splits": split_stats, "development": development}
dump_json(target / "stats.json", result)
dump_json(target / "manifest.json", {"status": "complete", "trained_on": "shared train-only sample",
"model_sha256": file_sha(model), "vocab_sha256": file_sha(target / "tokenizer.vocab"),
"sample_sha256": sample["sample_sha256"], "trainer_options": options,
"sentencepiece_version": spm.__version__, "special_ids": SPECIAL_IDS,
"marker_ids": verification["marker_ids"], "test_and_blind_access": False})
monitor.final_statistics(split_stats)
monitor.update("complete")
results[str(size)] = result
print(f"[complete] vocab={size}; validation chars/token={split_stats['validation']['characters_per_token']:.4f}", flush=True)
after = {str(p): {"bytes": p.stat().st_size, "mtime_ns": p.stat().st_mtime_ns} for p in inputs}
if before != after or file_sha(sample_path) != sample["sample_sha256"]:
raise ValueError("Inputs changed during training")
dump_json(output / "comparison.json", results)
dump_json(output / "manifest.json", {"status": "complete", "primary_vocab_size": 8192,
"primary_model": str(output / "ja-unigram-8192/tokenizer.model"), "plan": plan,
"sample": sample, "validation_sha256": validation_hash, "input_metadata": before,
"input_sha256": {str(p): file_sha(p) for p in inputs if p.name not in {"train.txt", "validation.txt"}},
"code_sha256": {str(p): file_sha(p) for p in [Path(__file__), Path(__file__).with_name("train.py"), Path(__file__).with_name("native_train.py"), Path(__file__).with_name("monitor.py")]},
"python_version": sys.version, "elapsed_seconds": time.monotonic() - started,
"corpus_ready_for_lm_training": corpus_manifest.get("ready_for_lm_training", False),
"reproducibility": "Exact shared sample and artifacts are preserved; native multithreading/platform can affect learned IDs",
"context_coverage_note": "Corpus 128-token character estimates are ratio-based, not measured packed-window coverage. Development prefix coverage is measured and includes short texts."})
print(f"[complete] V4 tokenizer experiment: {output}", flush=True)
return results
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config", type=Path, default=ROOT / "configs/tokenizer-v4.toml")
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
run(args.config, args.dry_run)
if __name__ == "__main__":
main()
|