| """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" |
|
|
| |
| 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)) |
| |
| |
| 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": |
| |
| 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(): |
| |
| 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() |
|
|