erenyanic's picture
Add eldamar-tokenizer project
c60ec61 verified
|
Raw
History Blame Contribute Delete
8.47 kB
---
language:
- tr
license: apache-2.0
library_name: transformers
tags:
- tokenizer
- bpe
- byte-level-bpe
- turkish
- turkce
datasets:
- winvoker/turkish-sentiment-analysis-dataset
- kmkarakaya/turkishReviews-ds
---
# turkish-bpe-128k
A **128,000-token byte-level BPE tokeniser for Turkish**, trained from scratch on ~200M characters of Turkish user-generated text with Hugging Face [`tokenizers`](https://github.com/huggingface/tokenizers) and packaged for `transformers`.
Training and evaluation code: **[ErenYanic/eldamar-tokenizer](https://github.com/ErenYanic/eldamar-tokenizer)** (`turkish_bpe_128k/`).
```python
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)
# tokenize() returns byte-level forms -- see the note on `Ġ` and `ç` below.
tok.tokenize("İstanbul'da yağmur yağıyor.")
# ['İstanbul', "'da", 'ĠyaÄŁmur', 'Ġyaģıyor', '.']
# Decode each id on its own to see the text the pieces stand for.
[tok.decode([i]) for i in ids]
# ['İstanbul', "'da", ' yağmur', ' yağıyor', '.']
```
This is a **separate sub-project** from the Middle-earth name generator in [`src/`](https://github.com/ErenYanic/eldamar-tokenizer/tree/main/src). It shares nothing with it but the repository — different data, different tokeniser design, different scale.
## Design
The tokeniser follows what current frontier models (GPT-4, Llama 3, Qwen 3, DeepSeek-V3) actually do, rather than the textbook BPE recipe.
| Choice | Value | Why |
| ------------- | -------------- | ---------------------------------------------------------------------------- |
| Algorithm | Byte-level BPE | The 256 raw bytes are the base alphabet, so **any** input is representable |
| Vocab | 128,000 | In the modern band (Llama 3 = 128k, Qwen 3 ≈ 151k) |
| Normaliser | none | Even NFC is technically lossy; byte-level preserves input exactly |
| Casing | preserved | Keeps `Ankara``ankara`, and sidesteps the Turkish dotted/dotless *i* trap |
| Digits | groups of ≤ 3 | Stops the vocab memorising specific prices and years |
| Unknown token | **none** | See below |
### Why there is no `<unk>`
Because with byte-level BPE it could never fire. Every input decomposes into one of 256 byte tokens, all of which are in the vocabulary, so nothing is ever out-of-vocabulary. An `<unk>` here would be unreachable code.
This is the reason the token has disappeared from frontier tokenisers. A live `<unk>` is silent data destruction — the model cannot learn what it never sees, and the loss surfaces only in production. Byte-level made that failure mode cost nothing, so there was no reason to keep it.
The trade is real, though: a classic BPE with a capped alphabet gives you a *meaningful* `<unk>` but **cannot round-trip** text containing characters outside that alphabet. That data is simply gone. Byte-level trades the diagnostic signal for guaranteed losslessness, which is the better deal for anything that will front an actual model.
### The Turkish apostrophe fix
The published cl100k / Llama-3 split regex opens with an English contraction clause — `'s|'t|'re|'ve|'m|'ll|'d`. Applied to Turkish it is a bug: `İstanbul'da` would split into `İstanbul` + `'d` + `a`, severing the apostrophe suffix Turkish uses on proper nouns. [`train_tokenizer.py`](https://github.com/ErenYanic/eldamar-tokenizer/blob/main/turkish_bpe_128k/train_tokenizer.py) drops that clause, so `[^\r\n\p{L}\p{N}]?\p{L}+` keeps `'da` intact as one chunk. The rest of the pattern is unchanged.
### The `Ġ` and `ç` you will see in the vocabulary
Byte-level BPE stores the 256 bytes remapped to printable Unicode, because raw bytes include control characters that cannot be written safely into JSON. So byte `0x20` (space) is stored as `Ġ`, and `ç` — two bytes in UTF-8 — appears as `ç`. `tokenizer.json` therefore starts with a few special tokens, then those 256 byte tokens, then ~127,700 learned merges. Decoding turns them back into text; nothing is wrong.
## Data
| Dataset | Column used | Lines kept |
| ---------------------------------------------------------------------------------------------------------------------------- | ------------------------- | -------------------------------- |
| [`winvoker/turkish-sentiment-analysis-dataset`](https://huggingface.co/datasets/winvoker/turkish-sentiment-analysis-dataset) | `text` (labels discarded) | 487,070 |
| [`kmkarakaya/turkishReviews-ds`](https://huggingface.co/datasets/kmkarakaya/turkishReviews-ds) | `review` | 398,832 |
| **Total** | | **885,902 lines / 199.6M chars** |
All splits of both datasets are used — a tokeniser has no train/test leakage concern, and holding data back would only make the vocabulary worse.
Cleaning is deliberately light ([`build_corpus.py`](https://github.com/ErenYanic/eldamar-tokenizer/blob/main/turkish_bpe_128k/build_corpus.py)): whitespace collapsed to single spaces, records under 10 characters dropped, exact duplicates removed. No lower-casing, no punctuation stripping, no accent folding — a tokeniser should see text the way a model later will.
## Results
Measured on a 20,000-line random sample ([`evaluate.py`](https://github.com/ErenYanic/eldamar-tokenizer/blob/main/turkish_bpe_128k/evaluate.py)):
| Metric | Value |
| ------------------------ | ---------------------------------------- |
| Vocabulary reached | 128,000 / 128,000 |
| Corpus round-trip | **20,000 / 20,000 exact** |
| Unseen-script round-trip | **PASS** (emoji, CJK, Cyrillic, Arabic) |
| Fertility | **1.239 tokens/word** (5.76 chars/token) |
Turkish is agglutinative, so segmentation quality is best judged on long suffix chains:
```
evlerimizden -> ['ev', 'lerimizden']
İstanbul'da yağmur -> ['İstanbul', "'da", ' yağmur']
Çekoslovakyalılaştıramadıklarımızdan
-> ['Çek', 'os', 'lovak', 'yalı', 'laştır', 'amadık', 'larımızdan']
```
Roots separate from suffix chains rather than collapsing into opaque blobs, which is the behaviour that matters for a Turkish model.
### Honest caveats
- **Fertility is flattered by the domain.** Both datasets are product and service reviews. 1.239 tokens/word partly reflects the tokeniser memorising in-domain vocabulary; on Turkish news, legal text or literature it will be worse. Treat it as an upper bound, not a general figure.
- **200M characters is modest for a 128K vocab.** For scale, GPT-2 drew a 50K vocab from ~40GB. The vocabulary did reach 128,000 and the rarest merges are still sane sub-words (`pişirme`, `pandemi`, `seti`) rather than memorised phrases — so the tail is not junk — but a broader corpus would produce a better-balanced vocabulary.
- **Single-domain register.** No news, wiki, literary or conversational text, so formal and archaic Turkish are under-represented.
## Reproduce
```bash
uv pip install tokenizers transformers datasets
python turkish_bpe_128k/build_corpus.py # -> corpus/turkish_corpus.txt (~200MB)
python turkish_bpe_128k/train_tokenizer.py # -> tokenizer/ (~25s)
python turkish_bpe_128k/evaluate.py # self-test + report card
```
The corpus file is git-ignored; rebuild it with `build_corpus.py`.
## Files
```
turkish_bpe_128k/
├── build_corpus.py # 1. merge both HF datasets into one text file
├── train_tokenizer.py # 2. train the 128K byte-level BPE
├── evaluate.py # 3. round-trip / coverage / fertility self-test
├── corpus/ # generated, git-ignored
└── tokenizer/ # tokenizer.json + transformers config
```