eldamar-tokenizer / src /generate.py
erenyanic's picture
Add eldamar-tokenizer project
c60ec61 verified
Raw
History Blame Contribute Delete
4.34 kB
"""Generate Middle-earth names from any trained checkpoint.
Works for all four architectures: it reads the checkpoint's architecture folder,
rebuilds the model, and samples with the matching BPE tokeniser. Like the worker
in train_one.py it runs one architecture per process, so the flat module names in
the architecture folders never collide.
Run: python src/generate.py qwen3 512 # 20 names, temperature 0.8
python src/generate.py gemma4 char --count 40 # the CharTokenizer baseline
python src/generate.py deepseek3 512 --temperature 0.7 --novel-only
Lower temperature -> safer, more familiar names. Higher -> more varied/inventive.
"""
from __future__ import annotations
import argparse
import importlib
import sys
from pathlib import Path
import torch
ROOT = Path(__file__).resolve().parents[1]
REPO = ROOT / "single_letter_transformers"
CKPT_DIR = ROOT / "checkpoints"
NAMES_FILE = ROOT / "data" / "middle_earth_names.txt"
# architecture -> (folder, model class name).
ARCHITECTURES = {
"qwen3": ("qwen3", "TinyQwen"),
"qwen3_5": ("qwen3_5", "TinyQwen35"),
"gemma4": ("gemma4", "TinyGemma"),
"deepseek3": ("deepseek3", "TinyDeepSeek"),
}
def load_model_and_tokenizer(arch: str, tok_spec: str):
"""Rebuild the trained model and its tokeniser (char or BPE) from the checkpoint."""
folder, class_name = ARCHITECTURES[arch]
sys.path.insert(0, str(REPO / folder))
# The architecture folder must be importable *before* torch.load, because the
# pickled cfg is that folder's config.ModelConfig.
model_class = getattr(importlib.import_module("model"), class_name)
name = "char" if tok_spec == "char" else f"bpe{tok_spec}"
checkpoint_path = CKPT_DIR / f"{arch}_{name}.pt"
if not checkpoint_path.exists():
sys.exit(f"No checkpoint at {checkpoint_path.relative_to(ROOT)} -- train it first.")
ckpt = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
model = model_class(ckpt["cfg"])
model.load_state_dict(ckpt["model"])
model.eval()
if ckpt.get("tokenizer_kind") == "char":
# Rebuild the CharTokenizer from the exact character list it was trained on.
char_tokenizer = importlib.import_module("tokenizer").CharTokenizer
tokenizer = char_tokenizer(ckpt["chars"])
else:
from bpe_tokenizer import BpeTokenizer
tokenizer = BpeTokenizer.from_file(ROOT / ckpt["tokenizer"])
return model, tokenizer
@torch.no_grad()
def generate_names(model, tokenizer, count: int, temperature: float) -> list[str]:
"""Sample `count` names, each starting from the newline (start-of-name) token."""
start = torch.full((count, 1), tokenizer.newline_id, dtype=torch.long)
out = model.generate(start, max_new_tokens=model.cfg.max_seq_len,
temperature=temperature, top_k=None, eos_id=tokenizer.eos_id)
names = []
for row in out.tolist():
# Drop the leading newline, then keep everything up to the next newline.
name = tokenizer.decode(row[1:]).split("\n")[0]
if name:
names.append(name)
return names
def main() -> None:
parser = argparse.ArgumentParser(description="Sample names from a trained checkpoint.")
parser.add_argument("arch", choices=sorted(ARCHITECTURES))
parser.add_argument("tokenizer", choices=("char", "256", "512"),
help="'char' for the CharTokenizer baseline, or a BPE vocab size")
parser.add_argument("--count", type=int, default=20)
parser.add_argument("--temperature", type=float, default=0.8)
parser.add_argument("--seed", type=int, default=None, help="fix the RNG for reproducible samples")
parser.add_argument("--novel-only", action="store_true",
help="only show names that are NOT already in the training corpus")
args = parser.parse_args()
if args.seed is not None:
torch.manual_seed(args.seed)
model, tokenizer = load_model_and_tokenizer(args.arch, args.tokenizer)
names = generate_names(model, tokenizer, args.count, args.temperature)
if args.novel_only:
known = set(NAMES_FILE.read_text(encoding="utf-8").split("\n"))
names = [n for n in names if n not in known]
for name in names:
print(name)
if __name__ == "__main__":
main()