Diacritificatorul

Despre Diacritificator

Am început proiectul în 2018 și m-am apucat din nou de el în 2026. E încă un PoC, dar acum face ceva util: pune diacritice în text direct în browser, fără să trimită textul la un server.

Termenii tehnici subliniați așa au o explicație pe scurt. Treci cu mouse-ul peste ei sau atinge-i.

De ce m-am apucat din nou de el

În 2018 am făcut keras-diacritics, un model BiLSTM în Keras, cu un server Python pentru inferență. A funcționat cât să văd că ideea are potențial, dar nu l-am dus prea departe.

Aveam atunci un RTX 2060 și m-am lovit destul de repede de limitele hardware-ului. În 2026 am un RTX 5090 și am vrut să văd ce pot obține cu un model antrenat de la zero, suficient de mic încât să ruleze direct în browser.

De data asta, am vrut să văd dacă pot muta totul în browser. Fără server, fără cont, iar textul rămâne la tine.

2018 vs. 2026

20182026
ModelBiLSTMTransformer pe caractere
Parametri—10,8 milioane
TrainingKeras, RTX 2060PyTorch, RTX 5090, ~4 ore
InferențăServer PythonBrowser, Rust + WebAssembly
Date de antrenaretrain.txtWikipedia, OpenSubtitles, text de pe web (FineWeb-2) și corecturi manuale
Dimensiune—6,2 MB, weights quantized la 4 biți

Cum funcționează

Modelul analizează textul și prezice ce diacritice trebuie adăugate. Pentru fiecare literă care poate primi diacritice (a, i, s, t), alege între forma fără diacritică, diacritica specifică literei sau, unde e cazul, circumflexul. Decizia depinde și de context, nu doar de literă.

sus: inputmijloc: predicția modeluluijos: output

Fereastra de context are 256 de caractere. Asta ajută la diferențierea unor cuvinte precum „fata”, „fată” și „față”, unde forma corectă depinde de propoziție.

Attention-ul real al modelului pentru „s” din „pisat”, în propoziția „Si acum strivim usturoiul pisat in farfurie.” (media celor 6 heads, arcul mai gros înseamnă că litera aceea contează mai mult). Primul strat se uită în jurul literei, în cuvântul ei. Ultimul strat se uită și la „strivim”, „usturoiul” și „farfurie”.
Textele mai lungi de 256 de caractere sunt împărțite în ferestre care se suprapun cu 32 de caractere. Fiecare caracter e prezis de o singură fereastră (linia plină): cea în care se află suficient de departe de margini încât să aibă context de ambele părți.

Sub capotă

Modelul e antrenat în PyTorch, iar inferența e implementată în Rust cu Candle și compilată în WebAssembly. Am quantizat weights la 4 biți, așa că modelul are 6,2 MB.

Arhitectura

  1. Inputpână la 256 de caractere, dintr-un alfabet de 256 de simboluri
  2. Embeddingcaracter → vector de 384, plus o poziție învățată
  3. Bloc encoder × 6
    • LayerNorm → self-attention, 6 heads
    • LayerNorm → feed-forward 384 → 1536 → 384, GELU
    • conexiuni reziduale în jurul fiecăruia
  4. HeadLayerNorm → Linear 384 → 3
  5. Output, per caracternone · breve_comma (ă ș ț) · circumflex (â î)
Transformer encoder pre-LayerNorm, 10,8M parametri. Attention-ul nu e cauzal: fiecare literă vede contextul din stânga și din dreapta.

De la date la browser

  1. DateWikipedia, OpenSubtitles (1 din 4 fragmente), FineWeb-2 (1 din 4 pagini), corecturi manuale (×90)
  2. Filtruscoate textele fără ă/ș/ț și pe cele cu encoding stricat
  3. Ferestre de 256 de caractere13,6M pentru training, 513k pentru validare
  4. TrainingPyTorch, bf16, RTX 5090, 80.000 de pași de 512 ferestre, ~7 ore
  5. Exportsafetensors, weights la 4 biți, 6,2 MB
  6. InferențăRust + Candle → WebAssembly, într-un Web Worker

Rezultate

99,6%
accuracy pentru predicțiile literelor a, i, s și t
10,8M
parametri
6,2 MB
de descărcat, o singură dată
~0,5 s
pentru un text de 500 de caractere

Training

Pe setul de validare, accuracy a crescut de la 98,5% la 99,6% în 80.000 de pași.

Fiecare pas folosește 512 ferestre, nu 256 ca înainte. Cu aceleași date și același număr de pași, accuracy a urcat de la 99,53% la 99,60%, iar antrenarea a durat de două ori mai mult. Pe 500 de documente din fiecare sursă, procentul cuvintelor greșite a scăzut de la 0,69% la 0,59% pe Wikipedia, de la 0,73% la 0,68% pe subtitrări și de la 0,68% la 0,60% pe paginile web.

Linia punctată e baseline-ul: un dicționar care alege forma cea mai frecventă a fiecărui cuvânt (98,4%). Pe setul de validare, modelul a trecut de baseline.

Contează și dimensiunea modelului

Am testat mai multe dimensiuni pe un set de 10.223 de propoziții pe care modelul inițial le greșea.

  • 1,5M parametri: 5.963 de propoziții încă greșite
  • 10,8M parametri: 0 propoziții greșite

Pe setul ăsta, varianta de 10,8M nu a mai greșit nicio propoziție.

De ce am folosit și subtitrări

Cu Wikipedia mergea bine, dar pe replici din subtitrări mai făcea greșeli. Am adăugat OpenSubtitles în training și am comparat rezultatele.

Pe setul meu de evaluare, procentul replicilor cu cel puțin o greșeală a scăzut de la 6,4% la 3,7%.

De ce am adăugat și text de pe web

Wikipedia și subtitrările nu acoperă știri, bloguri, forumuri sau descrieri de produse. Am adăugat o parte din FineWeb-2 și am păstrat doar paginile scrise consecvent cu diacritice: cam 36% din ele.

Pe 500 de pagini web din setul de validare, procentul cuvintelor greșite a scăzut de la 0,85% la 0,68%. Pe Wikipedia și pe subtitrări rezultatele au rămas aproape la fel.

Viteză și dimensiune

Am comparat timpul de inferență pentru patru dimensiuni de model, pe același text și pe PC-ul meu.

ParametriLatență
1,8M76 ms
4,7M209 ms
10,8M~420 ms
25M901 ms

Am rămas la 10,8M de parametri. Varianta de 25M era prea lentă pentru ce voiam.

Dimensiunea la descărcare

FormatDimensiune
FP3243 MB
FP1621,7 MB
4-bit6,2 MB

Cu weights la 4 biți, am obținut aceeași accuracy pe același set de validare: 99,55% în FP32 și 99,55% la 4 biți. Comparația e făcută pe versiunea dinainte de subtitrări, de aceea diferă de cele 99,6% de mai sus.

Un grup de 32 de weights, FP32

Aceleași 32 de weights, rotunjite la 15 niveluri (4 biți)

scale = max|w| / 7un FP16 pe grup
q = round(w / scale)întreg între −7 și 7, stocat ca q + 8 (codul 0 nu e folosit)
128 B → 18 Bdouă valori q într-un byte, plus scale
Cum ajunge modelul de la 43 MB la 6,2 MB. Fiecare matrice e împărțită în grupuri de 32 de valori; fiecare grup își păstrează propriul scale. În browser, weights sunt reconstruite ca q × scale înainte de inferență.

Mai are rost proiectul în 2026, când există LLM-uri?

Un LLM poate pune diacritice și se descurcă bine, mai ales la cuvintele ambigue, unde contează sensul propoziției. Diferența e în ce trebuie să rulezi pentru asta.

  • Textul rămâne la tine: un LLM mare rulează pe un server, deci textul pleacă acolo. Modelul de aici rulează în browser.
  • Dimensiune: 6,2 MB, descărcați o singură dată. Un LLM care rulează local are sute de MB sau câțiva GB și are nevoie de un GPU sau de mult RAM.
  • Nu rescrie textul: modelul poate schimba doar literele a, i, s și t, iar textul iese cu aceeași lungime. Un LLM poate reformula sau sări peste bucăți, așa că rezultatul trebuie verificat.
  • Viteză și cost: ~0,5 s pentru 500 de caractere, fără cont, fără API key și fără cost per cerere.

La textele unde sensul decide forma corectă, un LLM poate alege mai bine. Pentru restul, un model mic, făcut doar pentru diacritice, e suficient.

Ce greșește încă

Modelul nu înțelege perfect sensul unui text. În special, are probleme când mai multe forme ale unui cuvânt sunt valide.

  • Cuvinte ambigue: „peste” / „pește”, „fata” / „fată” / „față”, „sa” / „să”. Alege în funcție de context și nu nimerește întotdeauna.
  • Texte foarte scurte: într-o propoziție precum „Ai pisat piperul?”, are puțin context și poate alege forma greșită.
  • Nume proprii, abrevieri și texte amestecate cu engleză: poate adăuga diacritice unde nu trebuie.
  • Nu face spellcheck: adaugă diacritice, nu corectează toate greșelile de scriere.

La primele teste am folosit propoziția „Și acum strivim usturoiul pisat”. Modelul a transformat „pisat” în „pișat”, probabil pentru că forma respectivă apărea mai des în datele de antrenare, inclusiv în subtitrări.

usturoiul pișat s

Am adăugat 17 propoziții din contexte culinare și am făcut un fine-tuning de aproximativ 30 de minute. După asta, modelul a început să scrie „pisat” cum trebuie.

Ai găsit o greșeală?

Deschide un issue cu propoziția originală, forma generată de model și forma corectă. Pot adăuga exemplul în setul de corecturi și îl pot folosi la următorul fine-tuning.

Deschide un issue pe GitHub