File size: 17,446 Bytes
b41c6c4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e21df59
b41c6c4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
# MillID architecture

**MillID** (MultiLingual Language IDentifier) names the language of a passage of text. v1 is a
byte-level, encoder-only transformer of **115M parameters** (`medium`; 193M as `large`), trained
from scratch in PyTorch on
[baobabtech/millid-dataset](https://huggingface.co/datasets/baobabtech/millid-dataset): 1,641
language-script labels (1,551 ISO 639-3 languages, 56 scripts). v2 adds images on the same
encoder. Everything here is in one file, [`millid.py`](millid.py).

```mermaid
flowchart TD
    T["Passage<br/>a table cell to a paragraph"] --> N["Unicode NFKC<br/>whitespace collapsed"]
    N --> E["Per-byte encoding<br/>byte · script · byte-in-character · character n-grams<br/>B × L × 4 ids, L ≤ 256"]
    E --> EMB["Sum of four embeddings<br/>B × L × d"]
    EMB --> STEM["Depthwise conv stem, k = 5<br/>local byte patterns"]
    STEM --> DOWN["Strided conv, 2 bytes → 1 token<br/>B × L/2 × d"]
    DOWN --> ENC["12 × pre-norm block<br/>RMSNorm → attention with RoPE → RMSNorm → SwiGLU"]
    ENC --> POOL["RMSNorm, masked mean pool<br/>z: B × d"]
    POOL --> HEAD["Language head<br/>z · (label + language + script vectors)ᵀ + b"]
    HEAD --> OUT["Softmax over 1,641 labels<br/>ranked (label, probability)"]
```

## 1. The task

Given a passage, from a table cell of a few characters to a paragraph of a thousand, return a
ranked list of `(label, probability)`. A label is an ISO 639-3 language code and an ISO 15924
script code: `eng_Latn`, `amh_Ethi`, `srp_Cyrl`. The same language in two scripts is two labels,
because telling them apart is part of the job.

## 2. One universal encoding

### Labels

The dataset's `labels.json` fixes label *i* to `labels[i]`, so the dataset's `label_id`, the
model's output index and every report agree. It also carries `aliases`, other spellings of a
language mapped to the one used (`swa_Latn` → `swh_Latn`, `jpn_Hira` → `jpn_Jpan`, 22 in all), and
`dropped`, macrolanguage codes naming several languages GlotLID tells apart (Serbo-Croatian, Fula,
Dinka, Kalenjin), whose text is left out rather than mislabelled. A model trained with extra
sources keeps every dataset id and appends its own labels; its `labels.json` lists the labels it
was trained on (`trained`), and no other label can be an answer (§5).

### Text: four ids per byte

Text is normalised (Unicode NFKC, whitespace collapsed) and read as UTF-8 bytes, so every
language in every script is the same 256 symbols: no tokenizer to train, no language split into
worse pieces than another, no vocabulary to outgrow, no unknown token. Bytes alone hide three
things a language identifier needs, so every byte carries four ids:

| Sequence | Value | What it tells the model |
|---|---|---|
| `ids` | the byte + 3 (0 `PAD`, 1 `CLS`, 2 `EOS`) | the text itself |
| `script` | the Unicode script of the byte's character, hashed into 512 buckets | which writing system: Latin, Ethiopic, Devanagari, Han, Adlam... Digits, punctuation and spaces share one "common" id |
| `charpos` | 1 one-byte character, 2 first byte of a longer one, 3 continuation byte | where characters begin and end |
| `ngrams` | the character 2-, 3- and 4-grams ending at the byte's character, lower-cased and hashed into 32,768 buckets | the features fastText and GlotLID identify languages by, learned as embeddings |

```mermaid
flowchart LR
    C["character ሀ<br/>ETHIOPIC SYLLABLE HA"] --> B1["byte E1<br/>charpos 2"]
    C --> B2["byte 88<br/>charpos 3"]
    C --> B3["byte 80<br/>charpos 3"]
    S["script: ETHIOPIC<br/>same id on all three bytes"] -.-> B1 & B2 & B3
    G["n-grams ending at ሀ<br/>2-gram, 3-gram, 4-gram ids<br/>same on all three bytes"] -.-> B1 & B2 & B3
```

A byte's input vector is the sum of four embeddings: byte, script, byte-in-character, and the sum
of its three n-gram embeddings. Python has no Unicode script property; the first word of a
character's Unicode name (`LATIN`, `ETHIOPIC`, `CJK`, `HANGUL`) is the same grouping for letters
and marks, and hashing it keeps ids stable when Unicode adds scripts; a table of every code point
below U+40000 is built once per process. The n-gram hash is a polynomial over the lower-cased code
points followed by splitmix64, fixed rather than Python's per-process `hash`, so ids are the same
in training and at inference. Before the start of a passage every position holds a value no code
point has, so a word that opens a passage has n-grams of its own; a window cut from the middle of
a passage keeps the n-grams the passage had there.

A passage is at most 256 tokens (`CLS` and 255 bytes, which is 85 to 255 characters). A longer one
is cut to a window of whole characters, never inside a multi-byte one: at inference the start, in
training a random place.

Encoding runs a whole batch at once (`encode_batch`): the batch's passages are one string, and
every step (windows, scripts, n-grams, UTF-8 bytes, padding) is one numpy operation over it. Per
passage, those steps would be dozens of tiny array operations whose overhead dominates; batched,
one CPU core encodes about 5,000 passages a second, so a handful of DataLoader workers keep even
an H200 fed.

## 3. The network

### Shapes

| Stage | Output | Notes |
|---|---|---|
| ids | B × L, four sequences | L ≤ 256 bytes |
| embeddings | B × L × d | byte + script + charpos + Σ n-grams; padding is zero |
| conv stem | B × L × d | residual: x + Conv1×1(GELU(DepthwiseConv₅(x))) |
| downsample | B × L/2 × d | Conv1d, kernel 2, stride 2; a token is real if either byte is |
| 12 blocks | B × L/2 × d | pre-norm, RoPE attention, SwiGLU |
| pool | B × d | RMSNorm, mean over real tokens: `embed()` returns this |
| head | B × C | C = 1,641 |

### The encoder block

```mermaid
flowchart TD
    X["x: B × T × d"] --> N1["RMSNorm"]
    N1 --> QKV["Linear d → 3d<br/>q, k, v in h heads of 64"]
    QKV --> ROPE["RoPE on q and k<br/>θ = 10,000"]
    ROPE --> ATT["scaled dot-product attention<br/>padding keys masked"]
    ATT --> O["Linear d → d"]
    O --> R1(("+"))
    X --> R1
    R1 --> N2["RMSNorm"]
    N2 --> FF["SwiGLU<br/>W₂( SiLU(W₁x) ⊙ W₃x ), width 8d/3"]
    FF --> R2(("+"))
    R1 --> R2
    R2 --> Y["to the next block"]
```

### The language head

Each label's output vector is built from its parts, so a language with little data borrows from
what it shares: `srp_Cyrl` and `srp_Latn` share Serbian, and every Ethiopic-script language shares
Ethiopic.

```mermaid
flowchart LR
    L["label vector<br/>srp_Cyrl"] --> SUM(("+"))
    G["language vector<br/>srp, shared with srp_Latn"] --> SUM
    S["script vector<br/>Cyrl, shared with rus_Cyrl, ukr_Cyrl, ..."] --> SUM
    SUM --> W["row of W: C × d"]
    Z["z: passage vector"] --> DOT["z · Wᵀ + b"]
    W --> DOT
    DOT --> LOGIT["logit per label"]
```

`LanguageHead.language_vectors()` returns W: the learned language embeddings, usable on their own
to compare languages.

### Presets

| Preset | d | Blocks | Heads | SwiGLU width | Down-sample | n-gram buckets | Parameters |
|---|---|---|---|---|---|---|---|
| `tiny` | 192 | 3 | 3 | 512 | 1 | 4,096 | 2.9M (tests) |
| `small` | 384 | 6 | 6 | 1,024 | 2 | 16,384 | 18.9M (sweeps) |
| **`medium`** (default) | 768 | 12 | 12 | 2,048 | 2 | 32,768 | **115.0M** |
| **`large`** | 1,024 | 12 | 16 | 2,752 | 2 | 32,768 | **192.6M** |

Where `medium`'s 115.0M parameters are: 85.0M in the blocks (7.08M each: attention 2.36M, SwiGLU
4.72M), 25.2M in the n-gram table, 2.5M in the language head (1,641 label, 1,551 language and 56
script vectors), 1.8M in the stem and downsampling, 0.6M in the byte, script and position
embeddings.

### Why each part

- **Bytes, not subwords.** A subword vocabulary for 1,600 languages either fragments the small
  ones into near-characters or spends most of its size on the large ones; bytes treat them alike.
  CANINE and ByT5 showed character- and byte-level encoders can match subword ones on
  multilingual tasks, and language identification is about the surface of text.
- **Script, character and n-gram embeddings.** What bytes hide, given directly: the writing
  system, where characters begin, and the character n-grams that fastText (GlotLID's architecture)
  is built on. The transformer starts from the features a fastText model ends with.
- **The conv stem and downsampling.** A width-5 depthwise convolution mixes neighbouring bytes;
  merging every two bytes then halves the sequence the transformer reads, which halves its compute,
  as CANINE does. A token covers two bytes: two-thirds of an Ethiopic or Devanagari character, two
  Latin ones, after the embeddings have already summarised each character.
- **Pre-norm RMSNorm, RoPE, SwiGLU.** The block of current encoders: stable at depth, relative
  positions that need no table, and a gated feed-forward that trains better at the same size.
  Attention runs on PyTorch's fused scaled-dot-product kernels.
- **Mean pooling.** Every token votes, which is robust for short passages. `CLS` stays as the
  first token, which gives v2 a slot to attend from.
- **Structured language vectors.** See the head above.

### Initialisation

Linear and embedding weights from N(0, 0.02), padding rows zero, biases zero. The last projection
of every residual branch (attention output, SwiGLU W₂) from N(0, 0.02 / √(2 × blocks)), so a deep
stack starts close to the identity.

## 4. Training

```mermaid
flowchart LR
    D["train split<br/>1.9M+ passages"] --> SAMP["draw a language ∝ n^0.3<br/>then one of its passages"]
    SAMP --> SHORT["25%: cut to a 20–80 character snippet"]
    SHORT --> WIN["random window of whole characters<br/>≤ 255 bytes"]
    WIN --> ENC["four ids per byte"]
    ENC --> W["8 DataLoader workers<br/>batches of 512"]
    W --> GPU["forward, cross-entropy<br/>label smoothing 0.1, bf16"]
    GPU --> OPT["AdamW, clip 1.0<br/>warm-up then cosine"]
    OPT --> VAL{"every 2,000 steps:<br/>validation macro F1"}
    VAL -->|better| SAVE["keep checkpoint"]
    VAL -->|12,000 steps without gain| STOP["stop"]
```

| | |
|---|---|
| Objective | cross-entropy over the labels, label smoothing 0.1, on float32 logits |
| Optimiser | AdamW, β = (0.9, 0.98), ε = 1e-8; weight decay 0.01 on weight matrices (not embeddings, norms, biases) |
| Schedule | linear warm-up for 2,000 steps to 3e-4, cosine to 5% of the peak |
| Batch | 512 passages, padded to the longest in the batch |
| Steps | 60,000: about 31M passages, 16 passes over the training split |
| Precision | bfloat16 autocast; gradients clipped to norm 1.0; optional `torch.compile` |
| Checkpoint | validation every 2,000 steps (20 passages per language), best macro F1 kept |

- **Which language.** In proportion to n^α, α = 0.3, n its passage count: between the data as it
  is (α = 1), where the largest languages fill every batch, and uniform (α = 0), where a
  100-passage language is seen until memorised. XLM-R and mT5 sample their languages the same way.
- **How long.** A quarter of passages are cut to a 20 to 80 character snippet, on word boundaries
  where the script has them: short text is the hard case, and what a document holds in its
  headers, captions and table cells.
- **Why macro F1 chooses.** It weighs every language equally, the score a thousand-language model
  should be judged on. Top-1 accuracy, which the largest languages dominate, is reported beside it.

### Cost

The transformer reads 128 tokens per passage after downsampling. Training costs about 6 ×
parameters × tokens operations for the dense layers, plus attention; the embedding tables are
lookups and cost almost nothing.

| Preset | Per passage | 60,000 steps × 512 | Measured | Training time |
|---|---|---|---|---|
| `tiny` | small; 256 tokens, no downsampling | | 18,200 passages/s on an H200 (2,100 on an L4) | ≈ 0.5 h (4.1 h on an L4) |
| `medium` | ≈ 67 GFLOPs | ≈ 2.1 × 10¹⁸ | 2,500 passages/s on an H200 | ≈ 3.4 h |
| `large` | ≈ 119 GFLOPs | ≈ 3.7 × 10¹⁸ | 1,790 passages/s on an H200 | ≈ 4.8 h |

On the H200 that is about 17% of peak bf16 throughput: the model runs eagerly, and at these
widths the norms, rotary embeddings and SwiGLU gates, each a separate pass over memory, weigh
as much as the matrix multiplications. `--compile` fuses them and is the first thing to try for
a faster run. Every run is GPU-bound; data loading keeps pace with room to spare (§2,
`encode_batch`). A run logs passages per second every hundred steps, so a new configuration's
cost is known within minutes. The final evaluation (GlotLID included) adds a few minutes.

## 5. Inference

```python
from millid import MillID

model = MillID.from_pretrained("baobabtech/millid")   # config.json, labels.json, model.safetensors
model.predict(["Lave men ou ak savon anvan ou manje."], k=3)
# e.g. [[("hat_Latn", 0.97), ("fra_Latn", 0.01), ...]]
model.embed(["..."])                                   # passage vectors, B × d
model.model.head.language_vectors()                    # language embeddings, C × d
```

The text is encoded as in §2 (the first window), the logits of labels the model was never trained
on are set to −∞, and a softmax gives the probabilities; batches of 512, bfloat16 on GPU.
`medium` is 460 MB in float32 and runs on a CPU, slowly.

## 6. Evaluation

| Set | Used for |
|---|---|
| `validation` | choosing the checkpoint; never reported as the result |
| `test` | the result: split by document, so no test passage shares a document with a training one |
| `udhr` | the honest result: the UDHR, labelled by its translators, not by GlotLID; passages quoting it were removed from training. `udhr` covers the languages MillID knows, `udhr-all` all 429 |
| `test-common`, `udhr-common` | the head-to-head: every model on the passages whose language MillID, GlotLID and fastText all know (about 210) |

Per language: support, TP, FP, FN, TN, precision, recall (= sensitivity), F1, specificity, false
positives per million passages of other languages. Averaged: macro, weighted, micro (= top-1
accuracy), median F1, languages above 0.9 and below 0.5. Top-1, top-3 and top-5 accuracy; top-1 by
passage size.

Two existing identifiers are scored on the same sets, each as it is and restricted to MillID's
labels (its best answer among them), the like-for-like comparison:

| | Labels | Model | Flag |
|---|---|---|---|
| [GlotLID](https://huggingface.co/cis-lmu/glotlid) | 2,000+ | fastText, 1.7 GB | `--compare-glotlid` |
| [fastText, Meta](https://huggingface.co/facebook/fasttext-language-identification) (NLLB) | 218 | fastText, 1.2 GB | `--compare-fasttext` |

Their answers are mapped onto MillID's spelling of a language before scoring (Simplified and
Traditional Han as `Hani`, MillID's aliases such as `pes`/`prs` as `fas`, and `est` as `ekk`), so a
right answer spelled differently is not counted wrong. Each reads a set once however many MillIDs
are evaluated (`millid.py evaluate --model A B C`).

## 7. Configuration and tracking

CleanRL-style: each command (`train`, `evaluate`, `sweep-agent`, `predict`) is a typed dataclass,
its fields the flags and their docstrings the help, parsed by tyro; `python millid.py train
--help` lists every setting. The resolved settings are saved with every run, in `config.json` and
in Weights & Biases, which with `WANDB_API_KEY` set receives the configuration, training loss,
learning rate, gradient norm, throughput, the validation curve, and the final per-set metrics with
per-language tables. `sweep.yaml` is a Bayesian sweep with hyperband stopping; its agents run as
Hugging Face Jobs.

## 8. MillID v2: text and images

```mermaid
flowchart LR
    TXT["text"] --> TE["MillIDTextEncoder<br/>v1, text.*"] --> ZT["z_text: B × d"]
    IMG["page or crop"] --> IE["image encoder<br/>ViT or ConvNeXt, image.*"] --> ZI["z_image: B × d"]
    ZT --> F["gated fusion, fuse.*<br/>z = g·z_text + (1 − g)·z_image"]
    ZI --> F
    F --> H["LanguageHead<br/>v1, head.*, same labels"] --> O["language"]
```

- **Image encoder.** A small vision backbone over a page or a crop, projected to d, reading the
  script and the language from the shapes of the writing: a scan with no text layer, a photo of a
  sign, a figure with text in it. Training images come from the same PDFs as the text, labelled by
  the same documents; CAWST's picture-only materials and GlotLID's `zxx` give a "no language" class.
- **Fusion.** A gated sum first, which falls back to either modality alone when the other is
  missing; cross-attention from text tokens to image patches if that is not enough.
- **Shared head, shared labels.** One label space for both modalities.
- **Compatibility.** v1's keys are `text.*` and `head.*`; v2 adds `image.*` and `fuse.*`. v1
  weights load into v2 as they are; the text path can be frozen while the image path trains, then
  everything fine-tuned together.

## 9. Known limits of v1

- **Close languages** (Bemba and Lamba, Zulu and Ndebele, Arabic varieties, Malay and Indonesian)
  are hard by nature, and short passages of them are often genuinely ambiguous.
- **Labels from FinePDFs and FineWeb-2 are GlotLID's,** so on those sources MillID partly learns
  GlotLID's mistakes. UDHR is the check.
- **Genre.** For many small languages the web is mostly religious text, and the storybooks are
  children's books: a language can be learned together with its genre.
- **One language per passage.** A mixed passage gets the language of most of its text.