Loading…
Loading…
A 6-layer, 384-wide BERT-shaped encoder pretrained from scratch with masked language modelling on 2.00 GB of FineWeb-Edu text, using a 16,000-token vocabulary (Byte-level BPE).
This is the small-vocab candidate arm of leanvocab, a controlled study asking whether a tokenizer with an 8k-16k vocabulary can reach sentence-embedding quality within 0.5 MTEB-avg points of a same-recipe MiniLM-shaped encoder using BERT's 30,522-token vocabulary, while freeing 25-38% of the parameters and without slower end-to-end inference.
This is a research checkpoint, not a finished embedding model. It has only been pretrained with MLM on a small budget; the contrastive fine-tuning that would make it produce useful sentence embeddings has not run yet. See Intended use.
| Vocabulary | 16,000 tokens, Byte-level BPE |
| Encoder parameters | 16.89M (25% fewer than the control) |
Parameters in model.safetensors (encoder + MLM head) |
17.06M |
| Held-out bits per byte | 1.1613 (-0.0318 vs the control) |
| Held-out MLM accuracy | 37.6% |
| Training text | 2.00 GB, one pass, 6,527 steps |
| Wall clock | 1.79 h on one M5 Pro |
| Compression on held-out text | 4.568 bytes/token, fertility 1.354 tokens/word |
Bits per byte is the held-out MLM loss per byte of raw text rather than per token. That is the point: two tokenizers cut the same sentence into different numbers of tokens, so per-token loss is not comparable across them, while per-byte loss is. Lower is better.
An encoder of this shape spends a large share of its parameters on the token embedding matrix - 22.47M total for the 30,522-token control. If a much smaller vocabulary costs little quality, that block shrinks and the savings are free. To make the tokenizer the only moving part, every arm shares the corpus, the bytes of text per optimizer step, the model shape, the optimizer, the schedule, the masking and the seed.
| Arm | Vocab | Encoder params | Held-out bits/byte | vs control | MLM acc | Wall clock |
|---|---|---|---|---|---|---|
bpe-16k |
16,000 | 16.89M (0.75x) | 1.1613 | -0.0318 | 37.6% | 1.79 h |
bert-orig (control) |
30,522 | 22.47M (1.00x) | 1.1931 | - | 35.5% | 2.51 h |
bpe-8k |
8,000 | 13.82M (0.62x) | 1.1992 | +0.0060 | 38.1% | 1.70 h |
factorized-bert-orig |
30,522 | 14.64M (0.65x) | 1.8467 | +0.6536 | 14.7% | 1.67 h |
Reading of these numbers, as of the runs above:
bpe-16k (16,000-token small-vocab candidate) beat the control by 0.032 bits/byte while using 25% fewer encoder parameters and 29% less wall clock.bpe-8k (8,000-token small-vocab candidate) landed within 0.006 bits/byte of the control for 38% fewer encoder parameters - close enough that a single seed cannot separate them.factorized-bert-orig (30,522-token factorized-embedding baseline) came out 0.654 bits/byte worse than the control despite 35% fewer encoder parameters, so its parameter saving did not come for free under this recipe.Use it for: reproducing or extending the study, comparing tokenizers under a fixed compute budget, or as a starting point for your own fine-tuning experiments on Apple silicon.
Do not use it for: anything in production, and do not read these numbers as a verdict on the study's claim. Specifically:
sample-10BT at a pinned revision, NFC-normalized, exactly deduplicated (BLAKE2b-128), with a frozen held-out split removed before training.| File | What it is |
|---|---|
model.safetensors |
Final weights, float32, BertForMaskedLM parameter names |
leanvocab_config.json |
The study's own EncoderConfig for this arm |
metrics.json |
The full recorded run: curve, plan, final evaluation, provenance |
tokenizer.json |
The tokenizer this arm was trained with |
config.json |
transformers configuration for loading the weights |
Optimizer state is not published (it is twice the size of the weights); resuming training needs the local checkpoint directory from the study.
The weights were trained in MLX but are stored with BertForMaskedLM parameter names, so transformers loads them directly:
from transformers import AutoModelForMaskedLM, AutoTokenizer
model = AutoModelForMaskedLM.from_pretrained("DhruvParmar051/leanvocab-bpe-16k")
tok = AutoTokenizer.from_pretrained("DhruvParmar051/leanvocab-bpe-16k")
inputs = tok("photosynthesis happens in the[MASK] of plant cells.", return_tensors="pt")
logits = model(**inputs).logits
There is no pooler in the checkpoint (the MLM recipe never trains one), so a BertModel load will report a randomly initialized pooler - that is expected.
One tokenizer detail worth knowing: this vocabulary uses a byte-level pre-tokenizer, so a space written before [MASK] becomes its own Ġ token - a shape the packed pretraining sequences never contained, and predictions at that position get noticeably worse. Attach the mask to the preceding word (the[MASK], not the [MASK]); the token it predicts carries the leading space itself.
DhruvParmar051/leanvocab-tokenizersbert-orig arm (control): DhruvParmar051/leanvocab-bert-origbpe-8k arm (small-vocab candidate): DhruvParmar051/leanvocab-bpe-8kfactorized-bert-orig arm (factorized-embedding baseline): DhruvParmar051/leanvocab-factorized-bert-orig20260918T062350Z-1e4505cf-bpe-16k, seed 0, config SHA-256 1e4505cf5866, commit e46a9184ca06 (working tree dirty).MLX_ENABLE_TF32=1: MLX uses TF32 matmuls on Apple GPUs by default (about 1e-3 relative error, about 1.9x faster). The study records this rather than silently depending on it.tokenizer.json SHA-256 25099cdd24c4; round-trip check on 2,000 held-out documents: exact, passed.@misc{leanvocab,
title = {leanvocab: a controlled study of small-vocabulary English tokenizers
for sentence-embedding encoders},
author = {Parmar, Dhruv},
year = {2026},
url = {https://github.com/DhruvParmar051/leanvocab}
}
Card generated 2026-11-12 from the recorded run metrics.