Skip to content
jcbnesh.com — home, Yagub Ismayilli
All posts
13 min read

Teaching a Transformer to Read Azerbaijani Handwriting

Two-stage fine-tuning took character error rate from 17.23% to 3.47% on a language with almost no training data. The interesting part is not the architecture — it is that 5,000 real lines were worth as much as twenty thousand synthetic ones, and that most of the error left over sits on seven letters.

For my master's thesis I built a handwritten text recognition system for Azerbaijani. The final model reads a line of handwriting with a 3.47% character error rate, against 17.23% for the strongest baseline I trained to compare it with.

The architecture is off the shelf. Almost everything that moved the number was about data — where it came from, and in what order the model saw it. This post is about that part, because it is the part I would have wanted to read before starting.

The situation

The internship was at Evler.az, a real-estate company in Baku. They hold handwritten documents: rental applications, ownership forms, contracts, client notes. Those have to become searchable text. Someone types them out by hand today, which is slow and introduces its own errors.

Standard OCR does not solve this. Off-the-shelf systems are trained overwhelmingly on English and a handful of other well-resourced languages, and handwriting is the hard case even in those. Azerbaijani adds a specific difficulty: the alphabet is Latin-based, but it carries ç, ə, ğ, ı, ö, ş, ü. A dropped diacritic does not just misspell a word — it can turn it into a different word.

Underneath all of that sat the actual obstacle. There was no large annotated corpus of handwritten Azerbaijani text lines to train on. Not at Evler.az, and not publicly. The data problem had to be solved before the model problem could be.

I also chose to recognise whole text lines rather than individual characters, which is the standard modern approach and, for handwriting, close to forced. Cutting a handwritten line into characters is difficult and sometimes impossible — letters join, spacing varies, and where one character ends is a judgement call that changes from writer to writer.

What "nothing" scores

I wanted to know what the alternatives were worth before claiming to beat them, so I trained two comparison systems on the same task:

SystemTraining linesCERWERExact-line match
TrOCR-small10,00021.44%52.46%11.4%
CNN–BiLSTM–CTC10,00017.23%53.08%8.1%

Translate 21% CER into practice: roughly one character in five is wrong. A human has to re-read every single line to find the errors, which means the system saves nobody any time. It is not a tool. It is a demo that produces text-shaped output.

That is the bar. Anyone can claim their model beats nothing; the question is whether it beats the thing a competent person would actually reach for.

Stage one: 25,000 synthetic lines

I trained on the LocalDoc Azerbaijani synthetic handwritten OCR dataset, a public corpus of synthetic Azerbaijani handwriting in the Latin script, paired line images with ground-truth transcriptions.

I want to be clear that I did not generate it, because a lot of low-resource write-ups blur this. At the start of the project there was no real Azerbaijani handwriting dataset of usable size in existence, and a public synthetic set was the only way to get a model off the ground at all. If you are starting a low-resource problem, finding out whether someone has already built a synthetic corpus for your language is worth doing before you build your own.

The model is microsoft/trocr-base-handwritten — a Transformer encoder–decoder that reads the image with a vision encoder and generates the transcription token by token. No character segmentation, no separate classification stage. I chose it for one reason above the others: it is already pretrained on OCR, so fine-tuning it on a new language is a much smaller ask than teaching it to read from scratch.

I scaled the training set up in stages rather than starting at full size, so that a pipeline bug would show up on a cheap run instead of an expensive one:

ExperimentTrainValTestEpochsCERWERExact-line match
E12,000300300218.5%48.4%11.7%
E25,000500500312.1%33.0%21.6%
E325,0005001,00036.40%16.61%55.4%

That habit paid for itself immediately. My first implementation preprocessed the entire dataset up front, with a map over the Hugging Face dataset. It cached so many transformed samples that RAM became the binding constraint well before GPU memory did — on a laptop GPU, which is what I had. Switching to lazy preprocessing, transforming each batch during training instead of all of them beforehand, is the change that made the 25,000-line run finish at all.

The rest of the hardware story is unglamorous and worth writing down anyway, because it is what the project actually consisted of for a month: small batch sizes with gradient accumulation to fake a larger effective batch, dataloader workers set to zero to keep memory pressure down, checkpoints written periodically so an out-of-memory kill did not cost a day, and evaluation switched off during the long runs because generating text from a thousand images is slow enough to double the wall clock. The big models were evaluated separately afterwards.

E2 is also the honest architecture comparison, and it is the one I would point to. At 5,000 lines TrOCR-base reached 12.1% CER, while CNN–BiLSTM–CTC on twice that data reached 17.23%. The transformer is genuinely better here, and that is a narrower claim than the headline number implies.

6.40% CER is a large improvement over both baselines and still not deployable. Synthetic handwriting is too regular. Real handwriting has habits — pressure, slant, spacing, the specific way a person joins two letters — and a generator does not reproduce them because it does not know about them.

Stage two: 5,000 real lines

So I collected and annotated a dataset of 5,000 real handwritten Azerbaijani text lines, matching the schema of the synthetic set: an image, and its ground-truth transcription. This is the part that did not exist before the project.

It is worth being specific about where those lines came from, because it bears on how far the result travels. They are my own handwriting and my colleagues' at Evler.az — a small pool of writers, all adults, all writing to order on prepared text. That is a real dataset in the sense that matters here, a pen on paper rather than a font renderer. It is not a representative sample of Azerbaijani handwriting, and I would not present it as one.

Then I fine-tuned the stage-one model on it, rather than training fresh on the real data. That ordering matters. Five thousand lines is not enough to teach a model the orthography of a language from scratch. It is more than enough to teach a model that already knows the orthography what real handwriting looks like.

MetricStage 1 (25,000 synthetic)Stage 2 (+ 5,000 real)
Character error rate6.40%3.47%
Word error rate16.61%10.4%
Exact-line match55.4%75.7%

Here is the number I keep coming back to. Going from 5,000 to 25,000 synthetic lines — twenty thousand additional images — improved CER by about 1.89×. Adding five thousand real ones improved it by a further 1.84×.

Four times fewer images, for the same proportional gain.

I would not want to over-generalise from one experiment on one language. But the direction is hard to argue with: for this problem, where the data came from mattered about as much as how much of it there was. If you have a fixed budget and you are choosing between generating another twenty thousand synthetic samples and annotating five thousand real ones, that ratio is worth knowing before you decide.

Where it still fails

Aggregate error rates hide the shape of the failure, so I aligned every prediction against its ground truth with Levenshtein and counted the operations:

OperationCount
Match56,420
Substitution3,009
Deletion421
Insertion398

Substitutions outnumber insertions and deletions by roughly seven to one. That tells you something useful: the model is not losing its place in the line or inventing extra text. It gets the length and the order right, and confuses individual characters.

Which characters, in hindsight, was obvious. Breaking the error down per letter:

CharacterOccurrencesErrorsError rate
ə4,7671853.88%
i4,9832875.76%
ş707425.94%
ı1,7741206.76%
ç370267.03%
ğ248187.26%
ü907667.28%
ö369369.76%

The most frequent single error was dropping an i entirely. After that came the pairs that differ by a diacritic and nothing else — ə ↔ a and ı ↔ i, in both directions — along with visual near-twins like r/n, m/n and l/d.

There is a loose pattern in that table, with exceptions large enough that I would not call it a law: the letters the model saw most often are the ones it gets right. ə is the most common Azerbaijani-specific letter in the corpus by a wide margin, and it has the lowest error rate of the group at 3.88% — better than plain i. ö appears 369 times and is wrong nearly one time in ten. But ü appears more than twice as often as ç and does slightly worse, so frequency is clearly not the only thing going on.

What the table does say plainly is that the remaining error is concentrated almost entirely in the characters that make Azerbaijani Azerbaijani. Which is exactly what you would expect from a model whose pretraining saw very little of the language, and it means the next improvement has to target those characters specifically rather than hoping general scale washes them out.

The thing that didn't work

Knowing the errors were concentrated in a handful of characters, the obvious next move is to fix them after the fact. I wrote post-processing rules: normalise whitespace, strip characters that cannot occur in Azerbaijani, and correct the common language-specific mistakes.

It made the model worse.

OutputCERWERExact-line match
Raw prediction6.40%16.61%55.4%
Post-processed6.56%17.55%51.3%

The reason is straightforward once you see it, and I did not see it in advance. A rule that rewrites ə → a fixes the cases where the model was wrong and breaks the cases where it was right. Unless the rule knows which is which, it is trading errors at roughly even odds — and the model was already right more often than not, so an even trade is a net loss.

Exact-line match fell furthest, from 55.4% to 51.3%, which is the tell. A rule only needs to corrupt one character to take a line that was perfect and make it wrong.

The final system uses the raw model output. Fixing this properly needs a language model over the output, something with enough context to know whether this particular word wants ə or a. It does not need a bigger substitution table. The experiment still earned its place — it is what told me the residual errors are not the kind a lookup table can reach.

What I can't tell you

The recipe generalises further than the numbers do. Synthetic data to teach the orthography, a small real dataset to teach the handwriting, in that order, with the second stage fine-tuning on top of the first. Nothing about that is specific to Azerbaijani.

What I do not know is worth stating too:

  • Whether it holds for writers outside my pool. Five thousand lines from me and a handful of colleagues is a narrow sample of handwriting. I would want a test set built from writers the model has never seen before quoting 3.47% at anyone as a general figure.
  • How small the real dataset can get. I tested 5,000. I did not test 500 or 1,500, so I cannot tell you where the floor is.
  • Whether it holds for non-Latin scripts. Azerbaijani's Latin alphabet is close enough to what TrOCR pretrained on that a lot came for free. A script the pretrained model has never seen is a different problem.
  • Whether more writers beats more lines. This is the one I would most like to know, and my dataset cannot answer it, because it does not contain enough different hands to split on.

Being clear about the edges of a result is not modesty. It is the difference between something another person can build on and something they have to re-derive.

What I'd do next

More writers, not more lines. The real data bought a disproportionate improvement, which suggests the model is short of handwriting variety rather than volume. Same annotation budget, spread across more people — and a writer-disjoint test split, so the next version of this post can answer the question this one can't.

Target the diacritics directly. Weight ə, ı, ö and their look-alikes in the loss, or augment specifically for them, instead of waiting for scale to fix it.

Give the baselines an equal data budget. The comparison models saw 10,000 samples and the final model saw 30,000. Part of that gap is architecture and part of it is data, and my comparison does not separate them. It costs a few more GPU hours to do properly.

Full pages, not pre-segmented lines. Everything here assumes the text lines have already been cut out. Real documents arrive as whole pages, and layout analysis is the missing half of a working system.

How it would actually ship

Not as an oracle. The integration I proposed to Evler.az is human-in-the-loop, and deliberately so: scan the document, detect or crop the text regions, transcribe each line with the model, show the result, let a person correct it, and only then write it to a record. The corrections go back into the training set.

At 3.47% CER that is a large saving on a task that is currently manual end to end. At any error rate above zero, on documents carrying names, addresses and legal terms, it is the only responsible way to deploy it. My own conclusion in the thesis was that the pipeline is not ready for unattended industrial use, and I would rather write that down than let a good number imply otherwise.

The thing I would still like to do is release the real dataset. There is very little to train on in this language, and a corpus of real Azerbaijani handwriting would be more useful to more people than my model is.