diactag-1.0
A diacritic model that cannot corrupt your text.
DiacTag restores accents and tone marks in 10 languages by classifying each character rather than generating new text β so the output is guaranteed to be the input with marks added, and nothing else.
Input: se eranko naa si gbo o?
Output: αΉ£Γ© αΊΉranko nÑà sΓ¬ gbα»Μ α»?
37.6M parameters. Runs on CPU. Compliance 1.0000 by construction.
π Full documentation
Install
pip install olaverse[deeplearning] # PyTorch checkpoint
pip install olaverse[onnx] # adds the int8 ONNX backend
from olaverse.nlp import Diacritizer
d = Diacritizer(model="diactag-1.0", lang="yo")
d.restore("se eranko naa si gbo o?")
# β 'αΉ£Γ© αΊΉranko nÑà sΓ¬ gbα»Μ α»?'
Loading goes through olaverse.nlp. The architectures field in config.json
documents what the checkpoint is, but there is no auto_map and no remote modeling
code, so AutoModel.from_pretrained will not resolve DiacTagger β that is
deliberate, not an omission.
lang= takes ISO-639-1 ("yo") or ISO-639-3 ("yor"). Leave it out and the
model's own LID head decides:
d = Diacritizer(model="diactag-1.0")
d.restore("Co ay rat dam dang") # β 'CΓ΄ αΊ₯y rαΊ₯t ΔαΊ£m Δang'
d.detect_language("Lodz jest piekna") # β ('pol', 0.9999)
How it works
The output has exactly as many characters as the input, in the same order, with the same base letters. Only the marks change. So don't generate β classify.
input s e r a n k o
β β β β β β β
βΌ βΌ βΌ βΌ βΌ βΌ βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββ
β character transformer encoder β
βββββββββββββββββββββββββββββββββββββββββββββββββββββ
β β β β β β β
SHAPE DOT Β· Β· Β· Β· Β· Β·
TONE Β· Β· Β· ACUTE Β· Β· Β·
β β β β β β β
βΌ βΌ βΌ βΌ βΌ βΌ βΌ
output αΉ£ e r Γ‘ n k o
ββββββββ΄βββββββ΄βββββββ΄βββββββ΄βββββββ΄βββββββ
base letters COPIED, never predicted
The base character is not an output of the network, so strip(output) == strip(input) holds for a trained model, an untrained one, or the int8 export. The
SDK asserts it on every call rather than assuming it.
Architecture
graphemes [B,T] language id [B]
β β
βΌ βΌ
βββββββββββββββ βββββββββββββββ
β char emb β β lang emb β 11th slot =
β 403Γ512 β β 11Γ512 β "unknown language"
ββββββββ¬βββββββ ββββββββ¬βββββββ
β β
ββββββββββββββββΊ + βββββββββββββ additive: no <yor> token,
β no position spent
βΌ
βββββββββββββββββββββββββββββββββββββββββ
β Γ 12 encoder block β
β βββββββββββββββββββββββββββββββββββ β bidirectional β
β β RMSNorm β MHA(8 heads, RoPE) β β a diacritic depends on
β β + residual β β what follows as much
β β RMSNorm β SwiGLU 512β1344β512 β β as what precedes
β β + residual β β
β βββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββ€ββββββββββββββββββββ
β H [B, T, 512]
ββββββββββββ¬βββββββββ΄βββββββββ¬ββββββββββββ
βΌ βΌ βΌ βΌ
ββββββββββββββββββββββ ββββββββββββββββββββββββ
β SHAPE ββ TONE β β MLM ββ LID β
β 512β15 ββ 512β8 β β 512β403 ββ 512β10 β
βper char ββper char β β tied emb ββmean-pool β
ββββββ¬βββββββββββ¬βββββ ββββββ¬ββββββββββββ¬ββββββ
β β β β
ββββββ¬ββββββ training only language
βΌ
legality mask β argmax β compose(base, shape, tone)
| component | shape | params |
|---|---|---|
| character embedding | 403 Γ 512 | 206k |
| language embedding | 11 Γ 512 | 6k |
| 12 Γ encoder block | attn 4Β·512Β² + FFN 3Β·512Β·1344 | 37.4M |
| SHAPE head | 512 Γ 15 | 7.7k |
| TONE head | 512 Γ 8 | 4.1k |
| LID head | 512 Γ 10 | 5.1k |
| MLM head | tied to embedding | 0 |
The task heads together are 12k parameters β 0.03% of the model. Nearly everything is shared encoder, which is why ten languages cost roughly the same as one.
Shape and tone are separate, and language-dependent
Every character factorizes as base + SHAPE + TONE:
αΊΉΜ β e + DOT_BELOW + acute (YorΓΉbΓ‘)
α» β o + CIRCUMFLEX + dot-below (Vietnamese)
α»― β u + HORN + tilde (Vietnamese)
Ε β n + β + acute (Polish)
SHAPE changes letter identity. TONE changes pitch or stress. Which marks belong to which slot is declared per language, and that is the point:
U+0323 dot below β SHAPE in YorΓΉbΓ‘ (αΊΉ and e are different letters)
β TONE in Vietnamese (nαΊ·ng, one of six tones)
U+0303 tilde β SHAPE in Spanish (Γ± is a letter)
β TONE in Vietnamese (ngΓ£)
A flat label set can't express that β it sees α» and assigns one class, blind to
the fact that the codepoint plays different grammatical roles in the two languages.
Factorized, Vietnamese collapses from ~60 flat classes to 5 shapes Γ 6 tones,
every component seen thousands of times. Across all ten languages the space is 15
shapes and 8 tones.
Results
diacbench, 1000 sentences per language.
| lang | DER | shape | tone | WER | exact | compliance |
|---|---|---|---|---|---|---|
| ita | 0.0002 | 0.0000 | 0.0002 | 0.0011 | 0.991 | 1.0000 |
| fra | 0.0012 | 0.0003 | 0.0009 | 0.0053 | 0.952 | 1.0000 |
| tur | 0.0016 | 0.0016 | 0.0000 | 0.0067 | 0.961 | 1.0000 |
| por | 0.0019 | 0.0006 | 0.0014 | 0.0083 | 0.925 | 1.0000 |
| pol | 0.0022 | 0.0018 | 0.0003 | 0.0109 | 0.935 | 1.0000 |
| spa | 0.0022 | 0.0001 | 0.0021 | 0.0097 | 0.917 | 1.0000 |
| hau | 0.0041 | 0.0040 | 0.0001 | 0.0163 | 0.741 | 1.0000 |
| ibo | 0.0122 | 0.0110 | 0.0013 | 0.0403 | 0.483 | 1.0000 |
| vie | 0.0164 | 0.0073 | 0.0124 | 0.0504 | 0.650 | 1.0000 |
| yor | 0.0836 | 0.0203 | 0.0695 | 0.1902 | 0.084 | 1.0000 |
| all | 0.0132 | 0.0055 | 0.0086 | 0.0424 | 0.764 | 1.0000 |
Against the model it replaces: YorΓΉbΓ‘ 0.2006 β 0.0836 (58% lower), Hausa 0.0593 β 0.0041 (93%). Much of the Hausa gain is the compliance guarantee rather than better modelling β a large share of the old error was text corruption, not wrong accents.
Versus diacnet-1.1
diacnet-1.1 (seq2seq) |
diactag-1.0 (tagger) |
|
|---|---|---|
| structural compliance | measured, ~94.7% on Hausa | 1.0000 by construction |
| confidence | sequence-level | per character, calibrated |
| language detection | <auto> prefix token |
built-in LID head |
| inference | autoregressive | one forward pass |
| parameters | 580M | 37.6M |
| CPU serving | impractical | the default |
| typo correction | possible in principle | impossible |
Versus frontier LLMs
300 sentences per language, matching how the baselines were run. These are not the same numbers as the table above, which uses all 1000; YorΓΉbΓ‘ alone moves ~1pp between the two samples.
| lang | diactag-1.0 |
Claude Sonnet 4.5 | GPT-4o-mini |
|---|---|---|---|
| yor | 0.0933 | 0.1913 | 0.2811 |
| ibo | 0.0110 | 0.0427 | 0.1277 |
| hau | 0.0041 | 0.0178 | 0.1432 |
| vie | 0.0166 | 0.0107 | 0.0399 |
| fra | 0.0014 | 0.0052 | 0.0023 |
Best on 7 of 10; Vietnamese and Portuguese genuinely lose.
Those LLM numbers are the charitable ones β from a harness that discards any output no longer stripping back to the input. Raw, against the floor of simply copying the input unchanged:
| lang | copy-input floor | Claude, raw |
|---|---|---|
| hau | 0.0236 | 0.3509 |
| ibo | 0.1171 | 0.2261 |
| fra | 0.0471 | 0.1171 |
On three languages a frontier model makes the text worse than doing nothing. Hausa is already 97.6% correct if left alone; Claude returns it 35% wrong. That fallback harness is this architecture, reimplemented externally. Here there is nothing to discard.
Training
Data. 7.0M sentences (web + Wikipedia) plus 131k from a Q&A passage set β the only non-encyclopedic register in the mix β gated on diacritic density and deduplicated against the eval splits by stripped-form hash.
The corpus was under-marked, and it was teaching the model to under-mark. YorΓΉbΓ‘ density is bimodal: a mode at 0.10 holding 89% of sentences, and one at 0.43 holding 11%. Correctly marked YorΓΉbΓ‘ lands around 0.30β0.45 β so the majority of the data was under-marked text being used as ground truth. No other language shows this (peak prominence 0.55 for YorΓΉbΓ‘, below 0.10 for every other).
The fix isn't deletion. Under-marked YorΓΉbΓ‘ is still YorΓΉbΓ‘ β good data for the language, poisonous for the marks. Sentences below the detected trough (0.2925) keep their masked-character targets and contribute no diacritic supervision. The MLM head trains on all 1.9M YorΓΉbΓ‘ sentences; the tagger heads on the clean ~205k.
Also filtered: non-Latin script (Ajami in Hausa Wikipedia, CJK and Devanagari names elsewhere) and cross-language contamination. Without both, the label space inflates from 15 shape classes to 99, most of them junk competing in the same softmax.
Objective.
loss = 1.0Β·CE(shape) + 1.0Β·CE(tone) + 0.3Β·CE(masked_char) + 0.05Β·CE(language)
The masked-character head is auxiliary, dropped at inference. Its target is always
the fully marked character β masking ΓΊ and accepting u would teach the
encoder that bare vowels are valid YorΓΉbΓ‘, which is the failure being fixed.
Hyperparameters. AdamW, lr 3e-4, 2000 warmup, cosine decay to 5%, weight_decay
0.05, batch 128 Γ 2 accumulation = 256, seq len 256, bf16, 120k steps. ~13 hours on
one A100. Language sampling temperature-balanced at Ξ±=0.5. Augmentation:
partial_mark_prob=0.30, case perturbation, 3% protected-span injection,
mlm_prob=0.15, lang_dropout=0.12.
Deployment
| backend | chars/s | p50 | size | DER |
|---|---|---|---|---|
| PyTorch CPU | 105 | 591 ms | 150 MB | 0.0105 |
| ONNX fp32 | 202 | 286 ms | 150.9 MB | 0.0105 |
| ONNX int8 | 244 | 200 ms | 38.3 MB | 0.0108 |
d = Diacritizer(model="diactag-1.0", lang="yor", onnx=True)
Three times faster and four times smaller for +0.03pp DER. Compliance stays 1.0000 under quantisation β the guarantee is architectural, not a property of numeric precision. A 38MB artifact at 244 chars/s on one CPU core takes the GPU off the serving bill entirely.
End-to-end figures: one sentence at a time, full pipeline, single core. Use
restore_batch for throughput workloads. p95 is 788β1099 ms across all backends
β the tail is length-driven, not backend-driven, so quantisation doesn't fix it.
Abstention
| threshold | coverage | DER on committed |
|---|---|---|
| 0.00 | 100.0% | 0.0132 |
| 0.90 | 97.1% | 0.0039 |
| 0.99 | 91.9% | 0.0008 |
At 0.90: 97% of characters restored at 99.6% accuracy, the rest flagged. A wrong tone mark changes meaning; a missing one is merely incomplete. Threshold is per-request, so one loaded model serves a CMS pre-fill and a legal pipeline at different points on the same curve.
text, details = d.restore(src, return_details=True)
review = [c for c in details if c.confidence < 0.9]
Confidence is temperature-calibrated on validation (T = 1.14).
Lexicon reranking (opt-in, off by default)
If the model emits a non-word whose stripped form has attested variants, rescore the candidates under the model's own distribution. It never invents a word.
d = Diacritizer(model="diactag-1.0", lang="yor", use_lexicon=True)
Measure it on your data before enabling it. On diacbench it cuts non-word outputs by 27% and raises YorΓΉbΓ‘ DER by 15% (0.0836 β 0.0961). The cause is our own density gating: restricting the lexicon to well-marked text shrank the YorΓΉbΓ‘ vocabulary from 86k forms to 18k, so "not in the lexicon" often means "rare or inflected word we didn't keep," and correct outputs get overwritten. Off by default.
Limitations
- No typo correction. Cannot insert or delete characters, so it can't fix
Ile β IlΓ©andteh β thein one pass. The price of the guarantee. The per-character confidence is a natural trigger for a separate corrector. - YorΓΉbΓ‘ is still hard. DER 0.0836, 83% of it tone direction. Sentence-level exact match is 0.084 β 92 of every 100 YorΓΉbΓ‘ sentences contain at least one wrong mark, a harsher and truer framing than DER.
- Igbo and Hausa tone numbers are not achievements.
tone_DERof 0.0013 and 0.0001 looks superb and means little: those orthographies barely write tone. There was almost nothing to learn. - Dense input degrades. The Polish pangram ZaΕΌΓ³ΕΔ gΔΕlΔ jaΕΊΕ has density 0.565 against a Polish median of 0.068 β nine times denser than real Polish β and the model misses six characters, despite a Polish DER of 0.0022.
- Some errors are irreducible.
ViaggioandViaggiΓ²are both valid Italian and the stripped form contains no information distinguishing them. - Fixed label space. Adding a language with new marks invalidates existing
checkpoints.
SPEC_VERSIONis checked on load, so a mismatch fails loudly. - Not a diacnet replacement. A parallel architecture with a different contract.
diacnet-1.1remains the option where generation is wanted, and wins on Vietnamese and Portuguese.
Which model should I use?
| Need | Model |
|---|---|
| Output must never differ from input except in marks | diactag-1.0 |
| YorΓΉbΓ‘, Igbo or Hausa accuracy | diactag-1.0 |
| CPU-only serving at scale | diactag-1.0 (onnx=True) |
| Confidence scores / human review routing | diactag-1.0 |
| Vietnamese or Portuguese peak accuracy | diacnet-1.1 |
| Fast YorΓΉbΓ‘ with no deep-learning extra | diacnet-yor-viterbi |
Files
| file | required | what |
|---|---|---|
ckpt_120000.pt |
yes | PyTorch checkpoint, 37.6M params |
labels.json |
yes | label space + legality masks; nothing decodes without it |
calibration.json |
recommended | fitted temperature (T = 1.1408) |
diactag.int8.onnx |
β | recommended for CPU serving |
diactag.onnx |
β | ONNX fp32 |
lexicon.json |
β | attested spellings; reranking off by default |
density_floors.json, stats.json |
β | corpus analysis, for reproducibility |
Citation
@misc{diactag10,
title = {DiacTag: diacritic restoration as constrained sequence labeling},
author = {Olaverse Labs},
year = {2026},
url = {https://huggingface.co/olaverse/diactag-1.0}
}
Built by Olaverse Labs Β· Docs Β· Benchmarks Β· Apache 2.0
- Downloads last month
- -
