YAML Metadata Warning:empty or missing yaml metadata in repo card

Check out the documentation for more information.

eldamar-tokenizer

A tiny, end-to-end pipeline that learns to invent Middle-earth names. It cleans a dataset of Tolkien character and place names and trains four tiny from-scratch LLM architectures on it under three tokenisers — a plain character baseline plus character-level BPE at vocab 256 and 512 — for 12 models in total (4 architectures × 3 tokenisers).

$ python src/generate.py gemma4 512 --count 8 --temperature 0.8 --seed 1
shield    boromir    markhîr    pengolodh
brandyuis echoriath  arches     running

boromir, pengolodh and echoriath are real Tolkien names the model has memorised; markhîr, brandyuis and shield are inventions (none appear in the training data); arches and running are everyday English words the dataset keeps, split out of multi-word place names. All were produced by models small enough to train on a CPU in a couple of minutes.

Credit / attribution

The four model architectures are adapted from malibayram/single_letter_transformers, a collection of from-scratch reference implementations of four tiny LLM architectures (Qwen3, Qwen3.5, Gemma, DeepSeek V3). That repository lives unchanged in single_letter_transformers/ as plain content; all credit for the model code belongs to its author. eldamar-tokenizer only swaps the data (Turkish names → Middle-earth names), adds a character-level BPE tokeniser alongside the original character tokeniser, and adds the glue to run the whole sweep.

What is different from the original

Original (single_letter_transformers) This project
Task Generate Turkish first names Generate Middle-earth names
Data 921 cleaned Turkish names 2,189 cleaned Tolkien names (characters + places)
Tokeniser Character level (~30 tokens) Character level and character-level BPE (256 & 512)
Models 1 per architecture 3 per architecture (char + BPE-256 + BPE-512) = 12

Pipeline

3 CSVs + scraped locations
        │
        ▼
   clean_data.py ─► data/middle_earth_names.txt   (2,189 names)
        │
        ├─► bpe_tokenizer.py ─► bpe/bpe_{256,512}.json    ┐
        └─► CharTokenizer (built inline at train time)    ├─► train_all.py ─► checkpoints/*.pt (12) ─► generate.py
                                                          ┘

Directory layout

eldamar-tokenizer/
├── data/
│   ├── Characters.csv, lotr_characters.csv, characters_data.csv   # raw name sources
│   ├── locations.txt              # place names scraped from Tolkien Gateway
│   └── middle_earth_names.txt     # the clean corpus, one name per line
├── bpe/
│   ├── bpe_256.json               # trained BPE tokenisers (Hugging Face format)
│   └── bpe_512.json
├── checkpoints/                   # 12 trained models: {arch}_{char,bpe256,bpe512}.pt
├── src/
│   ├── scrape_locations.py        # 1. fetch place names via the MediaWiki API
│   ├── clean_data.py              # 2. merge + clean all sources into one corpus
│   ├── bpe_tokenizer.py           # 3. train / load the character-level BPE tokeniser
│   ├── train_one.py               # 4. train a single (arch, tokeniser) model
│   ├── train_all.py               #    ... orchestrate all 12
│   └── generate.py                # 5. sample names from any checkpoint
└── single_letter_transformers/    # the original repo (model code), unchanged

Data cleaning

Three character CSVs plus a scraped list of place names all pass through the same pipeline in src/clean_data.py:

  • Lower-case with plain str.lower() (not Turkish lowering, which would turn Isildur into ısildur).
  • Keep diacritics (á â ä é ê ë í î ó ô ö ú û) — they are half the Elvish flavour.
  • Mine parentheticals: strip them from the main string but feed their contents back through the pipeline, so Belladonna (Took) Baggins yields took while filters discard disambiguation prose like (son of Axantur).
  • Split multi-word names into one word per line; keep internal hyphens (aelin-uial, ar-pharazôn).
  • Filter connective/geographic stop-words (of, the, mount, river, …), regnal Roman numerals (II, VI, …) and abbreviations (Jr).
  • De-duplicate and sort.

Result: 2,189 unique names, a 42-character alphabet (plus newline).

Place names come from Tolkien Gateway's Index:Locations. The site blocks plain fetches, so src/scrape_locations.py uses its MediaWiki API with a descriptive User-Agent and extracts the display text of every [[link]] bullet.

Tokeniser

src/bpe_tokenizer.py trains a character-level BPE with Hugging Face tokenizers:

  • No byte-level pre-tokeniser, so the base alphabet is the 42 real characters (each diacritic is one base token). This keeps both 256 and 512 meaningful merge targets rather than making "vocab 256" a degenerate zero-merge tokeniser.
  • A Split("\n", isolated) pre-tokeniser means merges never cross a name boundary and \n stays a lone token — the start/end-of-name marker (EOS) the models rely on.
  • Encoding is lossless (verified by a full-corpus round-trip).
Vocab Tokens reached Tokens / name
256 256 3.68
512 512 3.12

Example: galadrielgal·ad·ri·el (256) → gal·ad·riel (512).

The character baseline (char) uses the original repo's CharTokenizer, whose vocabulary is just the 43 symbols in the corpus (42 letters + newline). It needs no training and no artifact — it is rebuilt from the names file on each run — and gives us a reference point to judge what BPE actually buys.

Results

Each of the four architectures was trained for 5,000 steps on CPU under all three tokenisers — 12 models. Every one lands far below its uniform-guessing baseline (ln 43 ≈ 3.76, ln 256 ≈ 5.55, ln 512 ≈ 6.24).

Architecture char — params / loss BPE-256 — params / loss BPE-512 — params / loss
Qwen3 (dense) 20.0k / 1.38 26.8k / 1.47 35.0k / 1.25
Qwen3.5 (hybrid) 42.4k / 0.90 49.2k / 0.76 57.4k / 0.64
Gemma (sliding-window) 65.8k / 0.78 113.5k / 0.70 170.9k / 0.56
DeepSeek-V3 (MoE) 48.4k / 1.23 55.2k / 0.94 63.4k / 0.82

Sample names (temperature 1.0, straight from training):

Tokeniser Examples
char balar, héoden, laketown, taur-en-faroth, samdalf, malach
BPE-256 siriondir, alcarnor, elfstan, menelvy, clayhanger, beleth
BPE-512 thorondor, entwash, gléowine, drúadan, harondor, sarum

Reading the numbers — the trap to avoid:

  • Loss is only comparable down a column, never across one. Within a tokeniser all four architectures share the same vocabulary, token stream and baseline, so the losses rank the architectures fairly. Across tokenisers the vocabulary (43 vs 256 vs 512) and the tokens-per-name differ, so the raw cross-entropy measures different things — a lower BPE-512 number does not mean it models names "better" than the char baseline. (A fair cross-tokeniser metric would be bits-per-character.)

  • Bigger vocab ⇒ bigger model. The tied embedding/output matrix is vocab × hidden, so char → 256 → 512 inflates every model (Gemma 66k → 114k → 171k). Seeing that cost is part of the point of the sweep.

  • Qualitatively, the character baseline blends whole words freely (samdalf = Sam + Gandalf, laketown), while BPE leans on learned sub-word chunks and tends to assemble names from morpheme-like pieces. Both are fun; neither is strictly best.

Reproduce from scratch

Requires Python 3.13 and uv. Dependencies: torch (CPU), tokenizers, numpy.

uv venv                                                   # create .venv
uv pip install torch --index-url https://download.pytorch.org/whl/cpu
uv pip install tokenizers numpy

python src/scrape_locations.py       # -> data/locations.txt
python src/clean_data.py             # -> data/middle_earth_names.txt
python src/bpe_tokenizer.py          # -> bpe/bpe_256.json, bpe/bpe_512.json
python src/train_all.py              # -> checkpoints/*.pt (12 models, a few minutes)
#   python src/train_all.py --tokenizer char   # just the 4 character baselines

python src/generate.py qwen3_5 512 --count 20 --temperature 0.8
python src/generate.py qwen3_5 char --count 20 --temperature 0.8

Generation options

python src/generate.py <arch> <tokenizer> [--count N] [--temperature T] [--seed S] [--novel-only]
  • arch ∈ {qwen3, qwen3_5, gemma4, deepseek3}, tokenizer ∈ {char, 256, 512}
  • Lower --temperature → safer, more familiar names; higher → more inventive.
  • --novel-only hides names that already exist in the training corpus.

Turkish 128K BPE tokeniser (separate sub-project)

turkish_bpe_128k/ is a separate sub-project: a production-style 128,000-token byte-level BPE tokeniser for Turkish, trained on ~200M characters from two Hugging Face review datasets and published as erenyanic/turkish_bpe_128k.

from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("erenyanic/turkish_bpe_128k")
ids = tok.encode("İstanbul'da yağmur yağıyor.", add_special_tokens=False)
[tok.decode([i]) for i in ids]
# ['İstanbul', "'da", ' yağmur', ' yağıyor', '.']

(Decoding ids one at a time is what renders the pieces as readable text — tokenize() returns them in byte-level form, e.g. 'ĠyaÄŁmur'.)

It is the opposite end of the design space from the src/ tokeniser above, which makes the pair a useful contrast:

src/ char-level BPE turkish_bpe_128k/
Alphabet 42 real characters 256 raw bytes
Vocab 256 / 512 128,000
Unknown token none needed (closed corpus) none possible (bytes cover everything)
Round-trip lossless on its corpus lossless on any input, any script
Purpose teach a tiny model 2,189 names front a real Turkish LM

Two Turkish-specific details worth noting: the tokeniser preserves casing (sidestepping the dotted/dotless i trap), and it drops the English contraction clause from the standard GPT-4 split regex — left in, that clause would cut İstanbul'da into İstanbul + 'd + a, severing the apostrophe suffix Turkish uses on proper nouns.

Full design notes, evaluation numbers and honest limitations are in turkish_bpe_128k/README.md.

Bonus: Turkish district generator (separate task)

districts/ is a small separate side task — the same four architectures generating Turkish district (ilçe) names with the original repo's plain character tokeniser (no BPE), trained on names scraped from Turkish Wikipedia and cleaned by the repo's own temizle_isimler.py. It does not touch the src/ pipeline. See districts/README.md.

Downloads last month

-

Downloads are not tracked for this model. How to track
Inference Providers NEW
This model isn't deployed by any Inference Provider. 🙋 Ask for provider support