Reading one hand
Episode 11 · 3 Nov 2026

Base v6: real hands

Real handwriting from 153 other people, piloted against a control and then trained into the base. The writer's test lines go from 25.2% to 17.6% error before personalising, where five kinds of fake ink had moved the score about a point.

TL;DR

Win

The question Font-made ink has stopped helping the way the app reads. Would real handwriting from other people, none of it the writer's, move the base model closer to reading the writer?

The model had only ever learned from handwriting faked from fonts, and new kinds of fake ink had stopped helping. We added real handwriting from 170 other people, a public research set, tried it in a small pilot against an unchanged control, then trained a new base with it. The writer's lines went from one letter in four wrong to fewer than one in five, before the model has seen any of them.

  • Base v6 replaces v4. On the writer's 38 dev lines with the beam search: 15.3% against v4's 23.0%, −7.6 points (−10.0 to −5.1). On the 84 test lines, read once: 17.6% against v4's 25.2%, −7.6 points (−9.9 to −5.2).
  • In the pilot, two epochs of real lines made v4 read the dev lines 6.7 to 7.7 points better with the beam search, where the best fake-ink change had moved it about 1. Unlike v4 and v5, the full run kept improving until epoch 7.
  • Lowercase errors nearly halved (470 → 250) and English lines fell from 23.4% to 12.5%. What BRUSH lacks got no better: math 33.8% → 37.7%, accents, and errors on spaces doubled.
  • Most BRUSH writers print: 0.25 loops per letter, like the fonts, against the writer's 0.58. Weighting towards loopy writers raised their share of lines from 14% to 40%, and helped the writer's lines a little more.
  • The goal is 15% before personalising: 15.3% on the dev lines, 17.6% on the test lines. Pilot $0.64, run $1.13 by its own estimate. BRUSH is research-only, so the model stays here.
The lesson

When a synthetic source stops paying, real data from many people can move what rounds of synthetic tweaks couldn't. Pilot it against a control, weight it towards your user, and list what it lacks: it fixes only what it contains.

Why read on Every file in the download could run code the moment it was opened. And the one thing that got clearly worse was the gaps between words.

1 The question

Five kinds of fake ink moved the app's reading by about a point, and base v5 didn't replace v4. Would real handwriting from other people move it more?

The base model The general reader, trained on lots of synthetic handwriting before it ever sees yours. Changing it: A better base gives a better personal model: it starts lower and learns your hand faster. Alone it can still read your writing badly. Here: From v1 on, trained on a cloud GPU from a recipe; v2's base alone gets 43.6% of your letters wrong, 13.1% once personalised. has learned only from synthetic data Training data written by code instead of collected from people: here, handwriting fonts turned into pen paths with timing. Changing it: It's unlimited and licence-clean, but if it doesn't look like real writing the model learns the wrong thing: the synthetic-to-real gap. Here: 300,000 lines for v2, from 134 fonts under open licences (OFL or Apache).: handwriting faked from fonts. The goal is a base that reads the writer at 15% or less before it sees any of their lines. Base v4 reads the writer's 38 dev lines at 23.0% with the beam search Decoding that keeps the best few partial readings at every step instead of committing to one letter at a time. Changing it: A wider beam gives the language model room to fix ambiguous letters, but costs time: the beam search is about 40% of v2's time per line. Here: Width 8, combining the network's scores with the language model and your lexicon., as the app reads.

Episode 10 tried five new kinds of fake ink. The best of them moved the beam score about a point, inside the noise, and the base trained on them, v5, read the dev lines +1.2 points (−0.8 to +3.3) against v4. It wasn't adopted.

Two thirds of v4's errors are lowercase letter shapes, and fonts only draw so many. Real hands have shapes no font draws. So this round adds real handwriting, from other people.

2 Five other hands

BRUSH: 27,649 lines from 170 people, written with a stylus, a point every 10 ms. Most print; a few loop.

BRUSH is a public set of online handwriting: each line is the pen's path as it was written, with its timing, not a picture. 170 people wrote English sentences with a stylus; the pen's position was recorded every 10 ms. The model reads timing, so only the original lines were kept, not the copies that resample them and drop it.

BRUSH: Kotani, Tellex and Tompkin, Generating Handwriting via Decoupled Style Descriptors, ECCV 2020; https://dsd.cs.brown.edu/ — non-commercial research only

Each pen-down stroke takes the next colour, so a change of colour is a pen lift. The loops per letter are the writer's own, over all their lines. Other people's ink only: the writer's own is never shown in this series, only its statistics.

3 Opening files that can run code

Every BRUSH line is a Python pickle, and opening a pickle can run any code it names. The reader allows numpy's arrays and refuses the rest.

The download is 567 MB, 89,068 files. Each line is stored as a pickle: Python's own way of saving objects. Opening one rebuilds whatever it describes, and it can describe a call to anything, such as running a shell command.

So the reader never opens a file the plain way: it allows only numpy's array and type constructors, and refuses any file that names anything else. Its tests are made of real attacks: files that do run code when opened plainly, which the reader refuses without running them. All 27,649 lines read, from 170 writers, and every character in them is one the model writes.

4 Failure, caught by a test: one damaged file would have stopped the reading

A file that opens into something other than a line raised an error nothing caught. Now any damaged file counts as one unreadable line, and the reading goes on.

A test pass over the new code wrote files the reader should refuse. One opened into something other than a line (nothing at all, say), and the reader raised an error the conversion didn't catch: it caught only the errors of a refused or broken file. One such file would have stopped the whole conversion of BRUSH.

Now the reader refuses anything that isn't a line, and any error a damaged file can raise counts that file as unreadable, and the reading goes on. No BRUSH file is like that, so all 27,649 lines read either way: the fix is for the next download.

5 Their ink against the writer's

BRUSH loops about as little as the fonts: 0.25 times per letter, the writer 0.58. 16 of 170 writers, about one in ten, loop nearly as much.

Medians over 1,500 lines. Per letter: loops, pen lifts and width in line heights.
InkLoopsPen liftsWidthSlant
The writer0.580.880.40−9°
BRUSH0.251.170.450°
Fonts (v3 and v4's lines)0.22–0.270.9–1.00.43–0.48−6° to −9°

On average BRUSH looks like the fonts, not like the writer. And it is fast: 0.17 s per letter with the pen down, against the writer's 0.67 s, about four times faster.

fonts0102030writersthe writer · 0.580.000.250.500.751.001.25loops per letter, each writer's median
The 170 BRUSH writers by loops per letter, with the writer and the fonts marked. The writer: 0.58 loops per letter; fonts: 0.22 to 0.27.
Loops per letterWriters
0.00 to 0.050
0.05 to 0.106
0.10 to 0.1517
0.15 to 0.2033
0.20 to 0.2511
0.25 to 0.3035
0.30 to 0.3517
0.35 to 0.4012
0.40 to 0.4510
0.45 to 0.505
0.50 to 0.558
0.55 to 0.603
0.60 to 0.651
0.65 to 0.701
0.70 to 0.753
0.75 to 0.800
0.80 to 0.851
0.85 to 0.902
0.90 to 0.950
0.95 to 1.000
1.00 to 1.051
1.05 to 1.102
1.10 to 1.150
1.15 to 1.200
1.20 to 1.250
1.25 to 1.301
1.30 to 1.350
1.35 to 1.400
1.40 to 1.451
Each writer's median over their own lines. Blue: the 24 writers at 0.5 loops per letter or more, the ones weighting favours (section 7). The band is the fonts' range.

Most people print. 16 of the 170 writers loop 0.55 times per letter or more, about one in ten; 13 as much as the writer or more. Over all their lines, the writers run from 0.12 loops per letter (the 10th percentile) through 0.27 to 0.50 (the 90th). The average hides the loopy few, so drawing writers evenly would mostly teach print.

6 How each line was prepared

Each choice comes from something an earlier round found: no smoothing, the writer's pace, a varied copy every draw, whole writers held out.

Each choice, and the finding it came from.
ChoiceWhy
Original files, a point every 10 ms; a pause of 90 to 320 ms drawn between strokesThe model reads timing. BRUSH doesn't record pen lifts, and the writer's pauses are about this long.
No smoothingSmoothing closed loops and turned every e into a hook (v5, pilot 3).
17 whole writers (10%) held out, read after every epochTo see whether the model reads hands it has never seen, not only the writer's.
Real lines as a set share of each epoch, each draw a varied copy24,873 real lines against 290,000 synthetic: without a share they'd be lost; without varying slant, size, jitter and speed, as personalising does, memorised.
Slowed to the writer's pace, or kept at each writer's own: a pilot armv3 gained partly by matching the writer's pace, and BRUSH writes about four times faster.
Writers drawn alike, or weighted towards the writer's loops: a pilot armMost BRUSH writers print; the writer loops.
The synthetic lines are v3 and v4's own, reusedv5: new synthetic sources didn't help the beam search. One change at a time.

The varied copies are augmentation Making extra training examples by changing existing ones in ways that keep the answer the same: slant, size, speed, jitter, dropped points. Changing it: It teaches the model which differences don't matter, so it generalises from few examples. Too extreme, and the examples stop looking like real writing. Here: Each of the writer's lines becomes 40 variations; half of v2's synthetic lines are augmented too., the same kind personalising uses, so 24,873 lines can be drawn hundreds of thousands of times without being overfitting When a model learns its training examples themselves rather than the pattern behind them, so it does much better on lines it has seen than on new ones. Changing it: It makes training scores look better than reality. The cures are more or more varied data, augmentation, fewer epochs, and picking the epoch on held-out lines. Here: v2 personalised reads its own training lines with 4.5% errors and the test lines with 13.1%.. The two open questions, pace and which writers, became pilot arms.

17 whole writers, 2,776 lines, are never trained on. That leaves 24,873 lines from 153 writers to learn from, and a way to see whether the model reads hands it has never seen, not only the writer's.

7 Weighting towards the writer's loops

Drawing writers by how close they loop to the writer raises the loopy writers' share of lines from 14% to 40%. Every writer still appears.

Each writer's lines are drawn with a weight: highest for writers who loop about as much as the writer, never below 0.05, so the printers still appear, less often.

loopy: 0.5 or more00.51weight of a writer's linesthe writer · 0.580.000.250.500.751.00a writer's loops per letterLines drawn from loopy writersevery writer alike14%weighted by the curve40%
The weight a BRUSH writer's lines are drawn with, by that writer's loops per letter, and the share of drawn lines from loopy writers. The writer: 0.58 loops per letter.
A writer's loops per letterWeight
0.000.050
0.050.050
0.100.050
0.150.050
0.200.050
0.250.066
0.300.141
0.350.266
0.400.445
0.450.655
0.500.852
0.550.978
0.600.990
0.650.885
0.700.698
0.750.486
0.800.298
0.850.162
0.900.077
0.950.050
1.000.050
1.050.050
1.100.050
1.150.050
1.200.050
Share of drawn lines from writers at 0.5 loops per letter or more
DrawingShare
every writer alike14.4%
weighted by the curve39.6%
Loopy: 0.5 loops per letter or more, 24 of 170 writers. Drawn alike, they write 14% of the lines; weighted, 40%.

Whether that helps is for the pilot to say: one arm draws writers alike, one weighted, all else the same.

8 The pilot: real ink, four ways

From v4, 2 epochs, the same 80,000 fake lines; four arms add real ones. Every one reads the writer's dev lines 6.7 to 7.7 points better with the beam search.

As in episode 10: every arm starts from v4's weights The numbers inside the model that training adjusts. A trained model is just these numbers plus the code that uses them. Changing it: More weights can learn more, but need more data and training time, make the file bigger and reading slower. They grow with the square of the layer width. Here: v0: 0.18 million (1 × 128). v1: 0.97 million (3 × 128). v2: 2.15 million (3 × 192), 8.6 MB in full precision., trains 2 epoch One full pass over every training line. Changing it: More epochs keep lowering the training error until it levels off. Past that point they cost time for nothing, or the model starts memorising the training lines. The best epoch is the one that scores best on held-out lines, not the last one. Here: v1's base ran 29 epochs of about 97,000 lines (about 1.5 minutes each on a T4); v2's ran 12 epochs of 290,000 lines (about 12 minutes each). Personalising stops after 6 epochs or 10 minutes. on the same 80,000 synthetic lines (v3 and v4's own), and is read on the writer's 38 dev lines. The control adds nothing. The others add real lines:

  • 25% real: 25% of each epoch real, the writer's pace, writers alike.
  • 50% real: 50%, the writer's pace, writers alike.
  • 50%, weighted by loops: 50%, the writer's pace, writers weighted towards the writer's loops.
  • 50%, writers' own pace: 50%, each BRUSH writer's own pace, writers alike.
Predicted before the run

Written before running: base v6 takes the arm with the lowest dev score with the beam search among those whose 95% interval against the control is below zero. If none is, the arm with the best point estimate that isn't worse than the control, and the result says so.

Observed

Every real arm's interval is below zero. The lowest: 50%, weighted by loops, 15.7% with the beam search, −7.7 points (−10.8 to −4.4). v6 takes it.

noise: two controls (v5)−12−8−40difference in CER, points; below zero, fewer errors than the controlbase v4 itself−0.425% real−6.750% real−7.150%, weighted by loops−7.750%, writers' own pace−7.5
The pilot: each arm minus the control on the writer's 38 dev lines, beam search, with 95% intervals. Shaded, −1 to +1: noise: two controls (v5)
RowValueInterval
base v4 itself−0.4−2 to +1
25% real−6.7−9 to −4
50% real−7.1−10 to −4
50%, weighted by loops−7.7−11 to −4
50%, writers' own pace−7.5−10 to −5
noise: two controls (v5)−12−8−40the same, read greedily: the network alonebase v4 itself−2.025% real−7.750% real−8.250%, weighted by loops−9.450%, writers' own pace−7.9
The pilot: each arm minus the control on the writer's 38 dev lines, greedy, with 95% intervals. Shaded, −1 to +1: noise: two controls (v5)
RowValueInterval
base v4 itself−2.0−4 to 0
25% real−7.7−10 to −5
50% real−8.2−11 to −5
50%, weighted by loops−9.4−12 to −6
50%, writers' own pace−7.9−10 to −5
Top, with the beam search Decoding that keeps the best few partial readings at every step instead of committing to one letter at a time. Changing it: A wider beam gives the language model room to fix ambiguous letters, but costs time: the beam search is about 40% of v2's time per line. Here: Width 8, combining the network's scores with the language model and your lexicon. (the network, a language model and a word list), as the app reads; bottom, greedy decoding Reading the model's output by taking the top guess at every point, then applying CTC's rules. Changing it: Fast and simple, but it can't use spelling knowledge, so it produces things like "1%e" for "the". Here: Greedy CER is the network on its own: 18.4% for v2 personalised, against 13.1% with the beam search., the network alone. Green: the whole interval below zero. The shaded band is how far apart two controls came in episode 10 with nothing changed but the seed The starting number for every random choice: which font, which word, how much slant. Changing it: Same recipe and same seed give the same data, so a version can be rebuilt. Different seeds show how much a result is luck. Here: Pinned in each recipe.. Base v4 itself, read the same way, is a control of a kind.

Every real arm is 6.7 to 7.7 points better with the beam search, every interval far below zero. With the language model, v4's 23.0% falls to 15.7% in the best arm, near the 15% goal, after two epochs at a small dose.

  • More real ink helps a little: 16.6% with 25%, 16.3% with 50%.
  • Weighting towards the writer's loops helps the writer's lines a little more (15.7%), and other hands a little less: 6.3% on the held-out writers against 5.6%. Expected: the writer's hand is the target.
  • Pace barely matters with the beam search: each writer's own pace reads 15.9%, the writer's pace 16.3%; greedy, the other way round.
Every arm on the writer's dev lines: greedy after each epoch, and the last epoch with the beam search. Base v4 itself: 28.9% greedy, 23.0% beam.
ArmEpoch 1, greedyEpoch 2, greedyEpoch 2, beam
control31.7%30.9%23.4%
25% real23.2%23.2%16.6%
50% real23.4%22.7%16.3%
50%, weighted by loops22.3%21.5%15.7%
50%, writers' own pace23.0%23.0%15.9%
The 17 held-out writers (1,000 lines, greedy, at their own pace) after each epoch; the real lines each arm drew; its held-out synthetic lines; its GPU.
ArmReal linesOther writers, 1Other writers, 2SyntheticGPU
control0not readnot read0.38%$0.04
25% real25,7788.4%7.9%0.65%$0.05
50% real77,3346.2%5.6%0.91%$0.09
50%, weighted by loops77,3346.9%6.3%0.91%$0.07
50%, writers' own pace77,3346.0%5.5%0.85%$0.09

The real arms read their own synthetic lines a little worse than the control (0.38%): fonts are no longer all they see.

The pilot
$0.64
Modal's bill, 5 arms
Its GPU
$0.34
the runs' own estimates
All of v5's work
$4.33
three pilots and a run

Before it: nothing was running on Modal, and 525 leftovers of v5's pilots went from the storage volume. The pilot models are kept on the laptop; v3 and v4's synthetic lines stay, for reuse.

9 Fake ink moved it a point; real ink, seven

On the same 38 dev lines with the beam search: v5's fake-ink recipe −1.3 in its pilot, +1.2 at full scale. Two epochs of real lines: −6.7 to −7.7.

−12−8−40+4difference in CER, points; below zero, fewer errorsno changefake ink: v5's pilot arm−1.3fake ink: base v5 vs v4+1.2real ink: 25%, pilot−6.7real ink: 50%, weighted by loops−7.7real ink: base v6 vs v4−7.6
Fake ink against real ink: each change on the writer's 38 dev lines with the beam search, with 95% intervals
RowValueInterval
fake ink: v5's pilot arm−1.3−3 to 0
fake ink: base v5 vs v4+1.2−1 to +3
real ink: 25%, pilot−6.7−9 to −4
real ink: 50%, weighted by loops−7.7−11 to −4
real ink: base v6 vs v4−7.6−10 to −5
v5's pilot arm (real sentences and lognormal movement) against four controls pooled; bases v5 and v6 against v4; the real arms against their pilot's control. Different baselines, the same lines and the same reading.

Three pilots of new fake ink, 16 arms, moved the beam score by about a point. Two epochs with real handwriting moved it seven, and the full run kept it. What the fonts taught, the language model already gave the beam search. Real hands bring letter shapes no font draws.

10 Other hands

Bases v3 to v5 read the 17 writers they never saw at 27 to 29%. A real arm, after 2 epochs, reads them at about 6%; base v6 at 2.9%, on an easy set.

0%10%20%30%character error on writers never trained onbase v3, beam28.9%base v4, beam26.9%base v5, beam28.5%25% real, greedy7.9%50% real, greedy5.6%50%, weighted by loops, greedy6.3%50%, writers' own pace, greedy5.5%base v6, beam2.9%
Character error on the 17 held-out BRUSH writers: bases v3 to v6 with the beam search, the pilot's real arms greedy
RowValueInterval
base v3, beam28.9%
base v4, beam26.9%
base v5, beam28.5%
25% real, greedy7.9%
50% real, greedy5.6%
50%, weighted by loops, greedy6.3%
50%, writers' own pace, greedy5.5%
base v6, beam2.9%
Grey: the bases before v6, 400 lines with the beam search. Blue: the pilot's real arms after 2 epochs, 1,000 lines read greedily. Green: base v6, read as the bases were.

A base that has seen only fonts reads a stranger's hand about as badly as the writer's. After two epochs with other people's, it reads people it never saw at about 6%.

11 Base v6 replaces v4

By the rule set before training, v6 replaces v4: 15.3% on the dev lines with the beam search against v4's 23.0%, −7.6 points (−10.0 to −5.1).

By the rule set before the pilot, v6 takes 50%, weighted by loops: real lines as half of each epoch, at the writer's pace, writers weighted towards the writer's loops.

Base v6's recipe.
Starts fromv4's weights
Synthetic linesv3 and v4's 300,000, reused
Real linesBRUSH, 50% of each epoch: about 290,000 varied draws of 24,873 lines, at the writer's pace, writers weighted towards the writer's loops
Training8 epochs, the learning rate easing from v4's 0.001 along a cosine
Epoch keptthe one that reads the dev lines best, greedy

Eight epochs, not five: the pilot was still improving at epoch 2, and an epoch now holds twice the examples.

8 epochs on a rented A10G, 37 min. Unlike v4 and v5, whose dev scores went flat after an epoch or two, v6 kept improving until epoch 7: 22.9% to 19.7% on the writer's dev lines, read greedily. Real ink gives the model more to learn. The synthetic lines would have kept epoch 8.

0%10%20%30%12345678epochcharacter error, greedythe writer's dev lines · 20%other writers · 3%synthetic · 1%
Read greedily from every epoch's weights: the writer's 38 dev lines (never trained on), 1,000 lines of the 17 writers never trained on, and held-out synthetic lines. Kept: epoch 7, the best on the dev lines.
Every epoch of base v6, greedy: the writer's dev lines (v4: 28.9%), the held-out writers, and synthetic lines.
EpochThe writer's dev linesOther writersSynthetic
122.9%4.4%1.33%
222.4%3.7%1.28%
321.8%3.4%1.07%
420.5%3.2%0.98%
521.2%3.1%0.84%
620.9%2.7%0.75%
7 (kept)19.7%2.8%0.69%
820.0%2.8%0.67%
Predicted before the run

Written before training: v6 replaces v4 only if it reads the dev lines better with the beam search and the 95% interval of the paired difference is below zero. Then the 84 test lines are read once, v6 against v4 and v3, and the held-out writers with the beam search.

Observed

Dev lines, beam: 23.0% → 15.3%, −7.6 points (−10.0 to −5.1). v6 replaces v4. Prediction held

v3, v4 and v6 with the beam search, as the app reads. Only the dev lines decide; the test lines are read once, after.
Linesv3v4v6
The writer's dev lines (38): decides26.3%23.0%15.3%
The writer's test lines (84): read once27.5%25.2%17.6%
Other writers, never trained on (400 lines)28.9%26.9%2.9%
The 24 official test lines27.2%24.2%18.2%
−24−16−80difference in CER, points; below zero, v6 makes fewer errorsno changeDev lines, beam−7.6Dev lines, greedy−9.3Test lines, beam−7.6Test lines, beam, vs v3−10.0Other writers, beam−24.0
Base v6 minus base v4 (and v3), paired line by line, with 95% bootstrap intervals
RowValueInterval
Dev lines, beam−7.6−10 to −5
Dev lines, greedy−9.3−12 to −6
Test lines, beam−7.6−10 to −5
Test lines, beam, vs v3−10.0−13 to −7
Other writers, beam−24.0−26 to −22
Against v4 unless marked. Dev: the writer's 38 lines; test: 84 lines, read once; other writers: 400 lines of the 17 held out. Green: the whole interval below zero.

The test lines agree: 25.2% → 17.6%, −7.6 points (−9.9 to −5.2). The same size of gain as on the dev lines, so it isn't the luck of choosing an epoch on them. Against v3: −10.0 points (−12.6 to −7.3). Read greedily, the model alone, the test lines go from 30.3% to 21.4%.

The goal was 15% before personalising. v6 reads the writer's dev lines at 15.3% and the test lines at 17.6%. English lines are already below it. Personalising from v6, with the writer's own lines, is the next measurement.

Dev lines, beam
15.3%
v4 23.0%
Test lines, beam
17.6%
v4 25.2%, v3 27.5%
The 24 official lines
18.2%
v4 24.2%, v3 27.2%
Other writers, beam
2.9%
v4 26.9%
The pilot
$0.64
Modal's bill
Base v6's run
$1.13
the run's own GPU estimate
Pilot and run
$1.77
the run by its own estimate

Modal's per-app bill for the run lagged when this was written, so its cost is the GPU machine's own estimate: $1.13 for 37 min of training.

12 Where real ink helped

Lowercase errors nearly halved, 470 to 250, and English lines fell from 23.4% to 12.5%. The looped-l confusions are gone.

All 124 of the writer's lines, read by v4 and v6 with the beam search. The dev lines are among them, so this is for finding where the errors are, not a test figure.

Error rate by language, all 124 lines.
Linesv4v6
All lines25.5%17.9%
English23.4%12.5%
French25.4%18.2%
Mixed24.8%16.7%
Math33.8%37.7%
Errors by kind, all 124 lines.
Kindv4v6
Lowercase letters470250
Punctuation6433
Capitals4431
Digits5867
Accents4550
Math symbols4041
Spaces4487

Real hands fixed what fonts couldn't. Lowercase letters, two thirds of v4's errors, went from 470 errors to 250. English, the language BRUSH is written in, fell from 23.4% to 12.5%; French and mixed lines improved less.

v4's top confusions, greedy

  • n→u 21
  • t→l 20
  • h→L 19
  • i→e 18
  • m→n 17

v6's

  • n→u 15
  • i→e 15
  • é→e 15
  • 0→o 12
  • k→h 9

Written → read as, with how often. The looped letters fonts drew badly, t read as l and h as L, are gone from the top. n read as u is still first, less often.

13 Failure: what real ink didn't fix

What BRUSH doesn't have got no better: math went from 33.8% to 37.7%, digits and accents rose, and the errors on spaces doubled.

BRUSH is English sentences, mostly two words a line. What it lacks, v6 didn't learn:

  • Math: 33.8% → 37.7%, the only kind of line that got worse. Digits 58 → 67 errors, math symbols 40 → 41; 0 read as o is now a top confusion.
  • Accents: 45 → 50. é read as e is now one of the most common confusions.
  • Spaces: 44 → 87 errors, doubled. Lines of two words show few gaps between words of the writer's kind.

A public set fixes what it contains. French, math and word spacing are the next step. Real ink with accents, from French writers; longer lines; or synthetic lines kept for math while real ink carries the letters.

14 What the app now shows

v6 is the base: personalising starts from it, and the browser reads with it. The Models page says what each model was built on and learned from, and draws the family.

Base v6 is now the base. Personalising starts from it, and the browser's model is v6: 8.60 MB, reading 10 of 10 check lines exactly as the trained model does, at 45.6 ms per line (95th percentile), inside the 50 ms budget. A smaller 8-bit copy read 6 of the 10 differently, so the full model is used.

  • Built on: trained further from a base's weights, a new model trained from scratch, or fitted to the writer's lines from a base. For v6: trained further from Base v4's weights.
  • What it learned from, with what is new since its base marked. For v6: "Real handwriting from other writers: BRUSH, 24,873 lines from 153 writers (17 more kept out to read other hands), as 290,000 varied draws, 50% of each epoch, at your pace, writers weighted towards your loops", and BRUSH's research-only licence under it.
  • Per-epoch curves for the writer's dev lines and for "Other writers (never trained on)".
  • Base v3 new model
    • Base v4 trained further v6 was trained from it
      • Base v5 trained further not adopted
      • Base v6 trained further, with BRUSH current base

15 What carries over

When a synthetic source stops paying, real data from many people can move what rounds of synthetic changes couldn't. Pilot it, weight it towards the user, and check what it lacks.

Beyond this project
  • When tuning synthetic data stops paying, look for real data from many people, even if none of it is your user's. Here it moved the score several times as far as the best synthetic change, on lines the model never saw.
  • Pilot it like any other source: a short run from the current model, against a control, read the way the product reads. This one cost $0.64 and predicted the full run.
  • Weight a public set towards the person you're building for. Most people here print; the user loops. Weighting nearly tripled the loopy writers' share and helped the user's lines a little more.
  • A public set fixes what it contains. Before relying on one, list what your users write that it doesn't: here math, accents and the gaps between words got no better.
  • Hold out whole people, not lines, to see whether a model reads hands it has never met, and say how easy that held-out set is.
  • Treat downloaded files as code until proven otherwise. A pickle can run anything; read it with a loader that allows only the types you expect, and test it with real attacks.

Previous · Episode 10 · 2 Nov 2026

Base v5: five new kinds of fake ink, and why v4 stays

New kinds of synthetic handwriting, piloted in 16 arms against controls on the writer's real lines. Three changes made the model alone read better, by up to 4 points. None helped the way the app reads, and base v5 didn't replace v4.

Failure

Next · Episode 12 · 3 Nov 2026

Base v7: stitched hands

Real writers' letters, cut out and joined into the writer's kind of text: notes, French accents, math. Piloted against a control and against more real ink, then trained into the base. The writer's test lines go from 17.6% to 11.5% error, below the 15% goal, before personalising.

Win