One recipe, 136 training runs
How personal v3.1-stable got its six settings: two searches on a rented GPU, a lucky winner caught, five seeds per finalist, and $1.99 for a model that reads the writer at 6.8% error.
TL;DR
MethodThe question Personalising has six settings that pull against each other. Which values did v3.1-stable end up with, what evidence put each one there, and what did finding them cost?
To fit the general model to one writer, six settings have to be chosen, and they pull against each other. A search tried 120 combinations on a rented GPU; its winner turned out to be lucky, so the finalists were each trained five times and the plan that was best on average won. The result reads the writer's test lines with 41 wrong letters of 600, for less than two dollars.
- Personal v3.1-stable reads the 24 test lines at 6.8% error (41 letters of 600), from 10.3% for v2.1-personal, and reads 95% of lines within 45.1 ms.
- Its plan: 8 gentle epochs, 70 varied copies of each of the writer's 68 lines, 361 synthetic lines mixed in, nothing frozen. Over five seeds it averages 11.4% on the dev lines.
- Two searches, 120 trials, found two valleys: short and hot (2 epochs), long and gentle (8). Freezing any layer never helped.
- The short plan's 10.9% was luck: made again from its recipe, it scored 15.6%, and over six runs it averaged 13.9%.
- 136 training runs, about an hour of rented GPU, $1.99 on the bill. The same search on the laptop: 12 hours or more.
Treat a hyperparameter search as a shortlist, not an answer: widen it when the best sits at the edge of a range, and rerun the finalists with several seeds before believing any of them.
Why read on Even if all 60 trials of a search were the same plan, the best of them would look 2.3 spreads better than the plan really is. How that caught the winner, and the fifteen-run chart that chose instead.
1 The question
Episode 7 told all of iteration 3. This one follows one step of it, fitting the model to the writer, through every training run it took.
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. reads handwriting in general. fine-tuning Continuing to train an already-trained model for a short while on new data: here, your own lines. Changing it: It adapts a general model to your hand from very little data. Too long, or with too high a learning rate, and it memorises your lines or forgets general writing. Here: 40 varied copies of each of your 25 lines, plus 2,000 synthetic lines replayed. It took v2 from 43.6% to 13.1% errors. trains it a little more on one writer's own lines, so it learns their hand. How base v3 was built is in episode 7; here it is the starting point.
Personalising has six settings, and the right value of each depends on the others. This episode is the lab notebook of how they were chosen: every search trial, every repeat, what each cost, and why the recipe says what it says.
- Personal v3.1-stable, test lines
- 6.8%
- 41 of 600 letters; v2.1-personal: 62
- Training runs to choose it
- 136
- 120 search trials, then 16 models
- Rented GPU time
- 64 min
- an A10G, rented by the second
- On the bill
- $1.99
- for all of personalising
2 The day, step by step
Thirteen steps between the base model and the release. Most cost nothing; the two searches cost most; two steps went wrong and are kept here.
- Pick the starting pointfreeBase v3: 27.2% on the test lines before personalising, the same size and speed as v2.1-personal, and it has never seen a line of the writer's.
- Join both recording roundsfree85 training lines (one round had been uploaded twice: 40 duplicates kept once). A fifth of each language, 17 lines, is set aside as dev lines; 68 are learned from.
- Time the laptopfreeAt most 12.9 lines a second: a thorough search here would take 12 hours or more. So the search goes to a rented GPU (section 5).
- First attempt stops before the GPUfreeMaking the synthetic lines to mix in failed: they needed the bank of hand-like words the base's own lines were made with. A bug, fixed, with a test that fails without the fix.
- Search 1: 60 trials19 min · $0.52Best: 10.9% on the dev lines, against 13.4% for v3's own plan. But at the edge of two ranges (section 6).
- Search 2: 60 trials, wider ranges31 min · $0.84Best: 10.4%, in the middle of the new ranges (section 7).
- Make both bests again, test what they forget$0.04Two letters apart, so a rule written beforehand decides: the one that forgets less. The short plan becomes the recipe for v3.1 (section 8).
- v3.1 from its recipe: 15.6%$0.01The same plan that had scored 10.9% twice. Nothing had changed (section 9).
- Twelve repeats: three plans, four more seeds each$0.29The long plan has the best average (section 10). On the way, a connection slip (section 14).
- Make v3.1-stable$0.03Its own recipe: the long plan, seed 0. 10.43% on the dev lines, epoch for epoch the same as before.
- Tune the decoder on the dev linesfree, on the laptop10.2% → 9.3% on the dev lines.
- Read the test lines, oncefree6.8% with the default decoder, 7.8% with the tuned one: the defaults stay (section 13).
- Time it, export itfreeWithin the 50 ms budget on every timing run; an 8.6 MB file for the browser that reads exactly as the training code on 10 of 10 check lines.
3 Six dials, in plain words
Each setting trades one risk against another: learn the hand too little, or learn these 68 lines too well and forget the rest.
Personalising starts from a model that already reads well and nudges it toward one hand. Every setting decides how hard, how long, or with what, and each fails in both directions:
learning rate How big a step each weight takes after every batch. Changing it: Too small and training creeps, wasting GPU time. Too big and it overshoots: the loss bounces or blows up. It's usually the first setting to tune. Here: 0.002 for the base models; 0.001 for personalising, which starts near a good solution and shouldn't be knocked away from it.
How big a step each weight takes after every batch of 32 lines.
Too small the model hardly leaves the base.
Too big it overshoots, and can knock loose what the base knew.
Ended at 0.0017 (v3's plan: 0.001)
Varied copies
Each of the writer's lines is shown as written plus this many copies, each a little 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. in slant, size and speed.
Too few it learns the exact strokes, not the hand.
Too many longer epochs, and the writer's lines crowd out the rest.
Ended at 70 (v3's plan: 40)
Synthetic lines replayed Mixing some of the original training data back in while fine-tuning on new data. Changing it: It keeps the general skill while the model learns the new data; too much and the new data has less effect. Here: 2,000 synthetic lines alongside your lines.
Lines of the base's own kind, mixed in to remind it of everything else.
Too few it catastrophic forgetting When a model trained on new data loses what it knew before. Changing it: A personalised model could read your hand and nothing else. Mixing old data back in (replay) and a small learning rate prevent it. Here: Checked every personalisation: error on synthetic lines stays flat. the general skill.
Too many the writer's lines have less effect.
Ended at 361 (v3's plan: 2,000)
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.
Passes over all of it.
Too few the hand isn't learned yet.
Too many it 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 68 lines.
Ended at 8 (v3's plan: 6)
Timing hidden (dropout Hiding part of the input or the network at random, during training only. Changing it: It stops the model leaning on any one point or feature, so it generalises better. Too much and it learns slowly. Here: The time feature (dt) is hidden on 20% of points.)
The share of pen points whose timing is hidden from the model during training.
Too little it leans on this writer's exact pace.
Too much it learns slowly.
Ended at 14% (v3's plan: 20%)
Layers frozen
How many of the base's lowest LSTM A recurrent layer: it reads a sequence one step at a time and keeps a memory (its state) of what it has seen. Changing it: Well suited to pen paths, but each step waits for the one before, which limits how much a GPU can speed it up. Here: Each layer has four gates (input, forget, cell, output) deciding what to keep and what to forget. layers are kept as they are.
None every weight can change: the most to gain and to lose.
All three only the top of the model learns the hand.
Ended at 0 (v3's plan: 0)
A hand-picked plan existed: v3's own, from the base's recipe. The question was whether a search could beat it, by how much, and whether the gain would hold.
4 A ruler for small differences
Settings are judged on 17 lines the model never learns from. On them one letter is 0.23 points, and a single reading wobbles by several letters.
The writer's lines are split three ways, and each pile has one job:
- Learned from
- 68
- both rounds, the fit
- Dev lines
- 17
- 441 characters: settings are chosen on these
- Test lines
- 24
- 600 characters: read once, at the end
The dev set A second set of held-out lines, for choosing settings and the best epoch. Changing it: It keeps the test set untouched, so the final score isn't flattered by choices made while looking at it. Here: A fifth of the writer's training lines, held out: the decoder and the personalising settings are tuned on them, never on the test lines. are a fifth of each language, picked by a fingerprint of each line's name, so every run of the day held out the same 17. One wrong letter of 441 is 0.23%: two plans half a point apart differ by two letters. Keep that ruler in mind for every number below.
The dev score is also noisy from epoch to epoch. The release's own run jumped by up to 10 letters between one epoch and the next (13.2% → 11.8% → 14.1% → 12.0% → 13.8% → 12.2% → 11.3% → 10.4%). So a single dev reading is a measurement with a wobble of several letters, not a fixed truth.
Which epoch is kept, and why it isn't chosen on the dev lines
Each run keeps the epoch that reads its 68 training lines best, a tie going to the later epoch: that keeps the model close to the hand without drifting far. The dev lines are left to do one job, scoring the plan, and every plan is scored the same way.
For the long plan both ways agree: its training lines read perfectly from epoch 7, so epoch 8 is kept. For one run of v3's plan they don't: it kept epoch 5, where its training lines read best.
5 Measured first: why the search left the laptop
On this laptop personalising tops out near 13 lines a second, and more cores don't help. A rented GPU ran the same code 38 to 71 times faster.
Before choosing where to search, the laptop was timed. Personalising this model can't use more than about two processor cores, and running several at once barely adds up:
| Row | Value | Interval |
|---|---|---|
| 1 run, 2 threads | 12.5 | |
| 1 run, 4 threads | 12.9 | |
| 1 run, 8 threads | 12.8 | |
| 2 runs at once | 16.6 | |
| 4 runs at once | 18.8 |
One model of the final plan reads 5,177 lines an epoch for 8 epochs. At the laptop's best that is at least 54 min of training; on the rented A10G it took 52 seconds. A 60-trial search here: 12 hours or more.
The GPU benchmark had promised about 1,500 lines a second, roughly 100 times the laptop. The personalising runs themselves read between 490 and 918 lines a second per epoch: 38 to 71 times faster. Less than promised, and still the difference between a search in minutes and one overnight.
6 Search 1: sixty trials draw a map
A search tried 60 plans, stopping weak ones early. Its best, 10.9%, sat at the edge of two ranges: the map was cut off where the treasure might be.
Each trial is a real personalising run, about a minute on the GPU, scored on the dev lines. Trial 0 is v3's own plan, so the search can't end worse than it. Optuna, an open-source search library, picks the rest:
How the search decides what to try next
The first ten or so trials are spread at random across the six ranges, to get a rough picture.
Then it learns from them. It splits the trials so far into the better ones and the rest, and proposes settings that look like the better group more than the worse. It looks at all six together, because a learning rate that suits 12 epochs doesn't suit 2. (The method is called TPE, a tree-structured Parzen estimator.)
Weak trials stop early. After each epoch, from the fifth trial on, a trial doing worse than the median of earlier trials at the same epoch is stopped. Of the 58 trials stopped across both searches, 51 were stopped after their first epoch.
| Group | Trial | learning rate (each step ×10 is the same distance) | CER on the 17 dev lines, lower is better | Ran |
|---|---|---|---|---|
| Search 1 | v3's plan | 0.001 | 13% | every epoch |
| Search 1 | t1 | 0.00086 | 16% | every epoch |
| Search 1 | t2 | 0.00055 | 16% | every epoch |
| Search 1 | t3 | 0.00092 | 25% | every epoch |
| Search 1 | t4 | 0.0021 | 25% | stopped early |
| Search 1 | t5 | 0.00016 | 21% | stopped early |
| Search 1 | t6 | 0.00028 | 22% | stopped early |
| Search 1 | t7 | 0.0011 | 15% | every epoch |
| Search 1 | t8 | 0.0015 | 15% | stopped early |
| Search 1 | t9 | 0.00034 | 17% | stopped early |
| Search 1 | t10 | 0.0027 | 12% | every epoch |
| Search 1 | t11 | 0.0019 | 14% | every epoch |
| Search 1 | t12 | 0.0034 | 13% | every epoch |
| Search 1 | t13 | 0.0014 | 16% | stopped early |
| Search 1 | t14 | 0.0037 | 12% | every epoch |
| Search 1 | t15 | 0.0042 | 12% | every epoch |
| Search 1 | t16 | 0.0043 | 12% | every epoch |
| Search 1 | t17 | 0.0023 | 13% | every epoch |
| Search 1 | t18 | 0.0045 | 12% | every epoch |
| Search 1 | t19 | 0.0036 | 15% | stopped early |
| Search 1 | t20 | 0.0039 | 14% | stopped early |
| Search 1 | t21 | 0.0016 | 16% | stopped early |
| Search 1 | t22 | 0.0046 | 13% | every epoch |
| Search 1 | t23 | 0.002 | 11% | every epoch |
| Search 1 | t24 | 0.0044 | 14% | every epoch |
| Search 1 | t25 | 0.0045 | 20% | stopped early |
| Search 1 | t26 | 0.002 | 14% | stopped early |
| Search 1 | t27 | 0.0018 | 16% | stopped early |
| Search 1 | t28 | 0.0029 | 14% | stopped early |
| Search 1 | t29 | 0.0022 | 15% | stopped early |
| Search 1 | t30 | 0.0019 | 16% | stopped early |
| Search 1 | t31 | 0.0042 | 15% | stopped early |
| Search 1 | t32 | 0.0037 | 14% | stopped early |
| Search 1 | t33 | 0.0021 | 15% | stopped early |
| Search 1 | t34 | 0.0011 | 15% | stopped early |
| Search 1 | t35 | 0.0029 | 14% | stopped early |
| Search 1 | t36 | 0.0015 | 15% | stopped early |
| Search 1 | t37 | 0.0031 | 14% | every epoch |
| Search 1 | t38 | 0.0024 | 14% | stopped early |
| Search 1 | t39 | 0.0026 | 13% | every epoch |
| Search 1 | t40 | 0.0049 | 15% | stopped early |
| Search 1 | t41 | 0.0041 | 14% | stopped early |
| Search 1 | t42 | 0.002 | 14% | stopped early |
| Search 1 | t43 | 0.0032 | 12% | every epoch |
| Search 1 | t44 | 0.0029 | 16% | stopped early |
| Search 1 | t45 | 0.0031 | 14% | stopped early |
| Search 1 | t46 | 0.002 | 12% | every epoch |
| Search 1 | t47 | 0.002 | 16% | stopped early |
| Search 1 | t48 | 0.0022 | 14% | every epoch |
| Search 1 | t49 | 0.0039 | 12% | every epoch |
| Search 1 | t50 | 0.0029 | 13% | every epoch |
| Search 1 | t51 | 0.0036 | 14% | every epoch |
| Search 1 | t52 | 0.0047 | 14% | every epoch |
| Search 1 | trial 53: 10.9% | 0.0042 | 11% | every epoch |
| Search 1 | t54 | 0.0032 | 13% | every epoch |
| Search 1 | t55 | 0.0041 | 12% | every epoch |
| Search 1 | t56 | 0.0046 | 15% | stopped early |
| Search 1 | t57 | 0.0032 | 14% | stopped early |
| Search 1 | t58 | 0.004 | 15% | stopped early |
| Search 1 | t59 | 0.0035 | 13% | every epoch |
| Search 2 | t0 | 0.001 | 13% | every epoch |
| Search 2 | t1 | 0.0013 | 17% | every epoch |
| Search 2 | t2 | 0.00075 | 18% | every epoch |
| Search 2 | t3 | 0.0014 | 24% | every epoch |
| Search 2 | t4 | 0.0036 | 23% | stopped early |
| Search 2 | t5 | 0.00017 | 21% | stopped early |
| Search 2 | t6 | 0.00034 | 21% | stopped early |
| Search 2 | t7 | 0.0017 | 15% | every epoch |
| Search 2 | t8 | 0.0025 | 16% | every epoch |
| Search 2 | t9 | 0.00043 | 18% | stopped early |
| Search 2 | t10 | 0.0043 | 14% | every epoch |
| Search 2 | t11 | 0.0065 | 13% | every epoch |
| Search 2 | t12 | 0.0029 | 13% | every epoch |
| Search 2 | t13 | 0.0047 | 14% | every epoch |
| Search 2 | t14 | 0.00098 | 17% | stopped early |
| Search 2 | t15 | 0.0074 | 14% | every epoch |
| Search 2 | t16 | 0.0089 | 16% | stopped early |
| Search 2 | t17 | 0.0058 | 13% | every epoch |
| Search 2 | t18 | 0.0035 | 14% | every epoch |
| Search 2 | t19 | 0.00071 | 13% | every epoch |
| Search 2 | t20 | 0.0094 | 16% | stopped early |
| Search 2 | t21 | 0.00087 | 15% | stopped early |
| Search 2 | t22 | 0.00047 | 14% | every epoch |
| Search 2 | t23 | 0.0011 | 13% | every epoch |
| Search 2 | t24 | 0.00055 | 16% | stopped early |
| Search 2 | t25 | 0.00023 | 17% | stopped early |
| Search 2 | t26 | 0.0011 | 13% | every epoch |
| Search 2 | t27 | 0.00051 | 15% | stopped early |
| Search 2 | t28 | 0.00065 | 16% | stopped early |
| Search 2 | t29 | 0.00035 | 15% | stopped early |
| Search 2 | t30 | 0.0061 | 18% | stopped early |
| Search 2 | t31 | 0.0021 | 12% | every epoch |
| Search 2 | t32 | 0.0016 | 16% | stopped early |
| Search 2 | t33 | 0.001 | 13% | every epoch |
| Search 2 | t34 | 0.0031 | 13% | every epoch |
| Search 2 | t35 | 0.002 | 12% | every epoch |
| Search 2 | t36 | 0.0021 | 12% | every epoch |
| Search 2 | t37 | 0.002 | 12% | every epoch |
| Search 2 | t38 | 0.0028 | 14% | every epoch |
| Search 2 | t39 | 0.0029 | 12% | every epoch |
| Search 2 | t40 | 0.002 | 13% | every epoch |
| Search 2 | t41 | 0.0015 | 15% | stopped early |
| Search 2 | t42 | 0.0032 | 13% | every epoch |
| Search 2 | t43 | 0.00083 | 12% | every epoch |
| Search 2 | t44 | 0.0012 | 13% | stopped early |
| Search 2 | t45 | 0.00081 | 15% | stopped early |
| Search 2 | t46 | 0.0015 | 15% | stopped early |
| Search 2 | t47 | 0.0013 | 15% | stopped early |
| Search 2 | t48 | 0.0016 | 14% | stopped early |
| Search 2 | t49 | 0.0022 | 11% | every epoch |
| Search 2 | t50 | 0.002 | 15% | stopped early |
| Search 2 | t51 | 0.0028 | 11% | every epoch |
| Search 2 | trial 52: 10.4% | 0.0017 | 10% | every epoch |
| Search 2 | t53 | 0.002 | 13% | every epoch |
| Search 2 | t54 | 0.0015 | 12% | every epoch |
| Search 2 | t55 | 0.0015 | 13% | stopped early |
| Search 2 | t56 | 0.0028 | 14% | stopped early |
| Search 2 | t57 | 0.0032 | 14% | stopped early |
| Search 2 | t58 | 0.0019 | 14% | stopped early |
| Search 2 | t59 | 0.0021 | 14% | stopped early |
Search 1's best used 2 epochs, the fewest it allowed, and a learning rate 95% of the way up its range, measured the way the search drew it. A best at the edge of a range says the real best may lie past it.
| Trial | Dev CER | Learning rate | Copies | Mixed in | Epochs | Timing hidden |
|---|---|---|---|---|---|---|
| 53 | 10.9% | 0.0042 | 19 | 1,370 | 2 | 1% |
| 23 | 11.1% | 0.002 | 26 | 2,317 | 3 | 2% |
| 49 | 11.8% | 0.0039 | 24 | 1,630 | 2 | 10% |
| 16 | 12.0% | 0.0043 | 28 | 1,171 | 2 | 13% |
| 14 | 12.2% | 0.0037 | 22 | 1,873 | 4 | 5% |
And one setting was settled for good: freezing never helped. Of the 14 trials across both searches that kept any of the base's layers frozen and ran every epoch, the best read 12.5%. With nothing frozen, each search did 7 and 9 letters better. The writer's hand needs the low layers too (episode 7 has the chart).
7 Search 2: a wider map, and a second valley
With room past both edges, the best landed in the middle instead: 8 gentle epochs, 10.4%. Two different recipes, two letters apart.
The second search allowed learning rates up to 0.01 and as few as 1 epoch. It was a study of its own: the search method only learns from trials that shared the same ranges.
Past the old edges there was nothing to find. The 6 trials above the old ceiling scored 13.2% at best, and the 3 one-epoch trials 13.2%. Instead, the search found a second valley:
| Group | Trial | epochs planned | CER on the 17 dev lines, lower is better | Ran |
|---|---|---|---|---|
| Search 1 | t0 | 6 | 13% | every epoch |
| Search 1 | t1 | 7 | 16% | every epoch |
| Search 1 | t2 | 6 | 16% | every epoch |
| Search 1 | t3 | 2 | 25% | every epoch |
| Search 1 | t7 | 9 | 15% | every epoch |
| Search 1 | t10 | 3 | 12% | every epoch |
| Search 1 | t11 | 5 | 14% | every epoch |
| Search 1 | t12 | 2 | 13% | every epoch |
| Search 1 | t14 | 4 | 12% | every epoch |
| Search 1 | t15 | 2 | 12% | every epoch |
| Search 1 | t16 | 2 | 12% | every epoch |
| Search 1 | t17 | 3 | 13% | every epoch |
| Search 1 | t18 | 3 | 12% | every epoch |
| Search 1 | t22 | 4 | 13% | every epoch |
| Search 1 | t23 | 3 | 11% | every epoch |
| Search 1 | t24 | 2 | 14% | every epoch |
| Search 1 | t37 | 2 | 14% | every epoch |
| Search 1 | t39 | 3 | 13% | every epoch |
| Search 1 | t43 | 4 | 12% | every epoch |
| Search 1 | t46 | 2 | 12% | every epoch |
| Search 1 | t48 | 4 | 14% | every epoch |
| Search 1 | t49 | 2 | 12% | every epoch |
| Search 1 | t50 | 2 | 13% | every epoch |
| Search 1 | t51 | 4 | 14% | every epoch |
| Search 1 | t52 | 3 | 14% | every epoch |
| Search 1 | short | 2 | 11% | every epoch |
| Search 1 | t54 | 2 | 13% | every epoch |
| Search 1 | t55 | 3 | 12% | every epoch |
| Search 1 | t59 | 3 | 13% | every epoch |
| Search 2 | t0 | 6 | 13% | every epoch |
| Search 2 | t1 | 7 | 17% | every epoch |
| Search 2 | t2 | 5 | 18% | every epoch |
| Search 2 | t3 | 2 | 24% | every epoch |
| Search 2 | t7 | 9 | 15% | every epoch |
| Search 2 | t8 | 9 | 16% | every epoch |
| Search 2 | t10 | 2 | 14% | every epoch |
| Search 2 | t11 | 1 | 13% | every epoch |
| Search 2 | t12 | 5 | 13% | every epoch |
| Search 2 | t13 | 3 | 14% | every epoch |
| Search 2 | t15 | 1 | 14% | every epoch |
| Search 2 | t17 | 2 | 13% | every epoch |
| Search 2 | t18 | 1 | 14% | every epoch |
| Search 2 | t19 | 5 | 13% | every epoch |
| Search 2 | t22 | 2 | 14% | every epoch |
| Search 2 | t23 | 7 | 13% | every epoch |
| Search 2 | t26 | 7 | 13% | every epoch |
| Search 2 | t31 | 5 | 12% | every epoch |
| Search 2 | t33 | 8 | 13% | every epoch |
| Search 2 | t34 | 5 | 13% | every epoch |
| Search 2 | t35 | 7 | 12% | every epoch |
| Search 2 | t36 | 6 | 12% | every epoch |
| Search 2 | t37 | 6 | 12% | every epoch |
| Search 2 | t38 | 7 | 14% | every epoch |
| Search 2 | t39 | 6 | 12% | every epoch |
| Search 2 | t40 | 7 | 13% | every epoch |
| Search 2 | t42 | 7 | 13% | every epoch |
| Search 2 | t43 | 7 | 12% | every epoch |
| Search 2 | t49 | 8 | 11% | every epoch |
| Search 2 | t51 | 6 | 11% | every epoch |
| Search 2 | long | 8 | 10% | every epoch |
| Search 2 | t53 | 7 | 13% | every epoch |
| Search 2 | t54 | 7 | 12% | every epoch |
| Trial | Dev CER | Learning rate | Copies | Mixed in | Epochs | Timing hidden |
|---|---|---|---|---|---|---|
| 52 | 10.4% | 0.0017 | 70 | 361 | 8 | 14% |
| 49 | 11.3% | 0.0022 | 75 | 1,006 | 8 | 18% |
| 51 | 11.3% | 0.0028 | 48 | 579 | 6 | 3% |
| 37 | 11.6% | 0.002 | 49 | 926 | 6 | 7% |
| 54 | 11.8% | 0.0015 | 53 | 526 | 7 | 17% |
Which setting mattered most? Optuna estimates the share of the spread in scores that each setting explains. The answer changed with the map:
| Row | Value | Interval |
|---|---|---|
| Learning rate, search 1 | 20% | |
| Learning rate, search 2 | 34% | |
| Copies per line, search 1 | 46% | |
| Copies per line, search 2 | 16% | |
| Synthetic lines mixed in, search 1 | 3% | |
| Synthetic lines mixed in, search 2 | 25% | |
| Epochs, search 1 | 21% | |
| Epochs, search 2 | 15% | |
| Timing hidden, search 1 | 9% | |
| Timing hidden, search 2 | 4% | |
| Layers frozen, search 1 | 0% | |
| Layers frozen, search 2 | 6% |
8 What mixing in synthetic lines buys: a forgetting test
The long plan's batches are 93% the writer's ink, so it forgets more of the base's skill. A rule written beforehand used that to break the tie, for the short plan.
The two bests learn from very different diets. Counting what one epoch holds (the writer's 68 lines with their copies, beside the synthetic lines):
| Plan | Writer's lines | Synthetic lines | Writer's share | Updates in the run |
|---|---|---|---|---|
| Short plan | 1,360 | 1,325 | 50.7% | 168 |
| v3's plan | 2,788 | 1,934 | 59.0% | 888 |
| Long plan | 4,828 | 349 | 93.3% | 1,296 |
A plan that is nearly all the writer risks catastrophic forgetting When a model trained on new data loses what it knew before. Changing it: A personalised model could read your hand and nothing else. Mixing old data back in (replay) and a small learning rate prevent it. Here: Checked every personalisation: error on synthetic lines stays flat. what the base knew. A few synthetic lines are held out of each run to check, but for the long plan that is 12 lines: far too few to see a change of a point or two. So both bests were read on 2,000 synthetic lines that neither had ever seen:
| Row | Value | Interval |
|---|---|---|
| Base v3, all | 1.37% | |
| Base v3, math | 1.09% | |
| Short plan, all | 3.58% | |
| Short plan, math | 2.20% | |
| Long plan, all | 4.17% | |
| Long plan, math | 2.62% |
The two bests were 2 letters apart on the dev lines, inside the noise. The rule, written before measuring: within 3 letters, keep the plan that forgets less. The short plan won, and its values were written into the recipe for personal v3.1.
It was a sensible rule. It was about to be overturned by something no rule had foreseen.
9 Failure: the same recipe, 21 letters worse
Made from its recipe, the short plan scored 15.6% instead of 10.9%. GPU training isn't exactly repeatable, and a search's best is partly luck.
Personal v3.1 was trained from its recipe: same plan, same seed, same lines, same GPU type. It read the dev lines at 15.6%. The same plan had scored 10.9% twice, in the search and as a candidate.
| Plan | Run | After each epoch | Kept |
|---|---|---|---|
| Short plan, seed 0, twice | as a candidate | 11.3%, 10.9% | epoch 2: 10.9% |
| Short plan, seed 0, twice | as v3.1 | 13.2%, 15.6% | epoch 2: 15.6% |
| Long plan, seed 0, twice | as a candidate | 13.2%, 11.8%, 14.1%, 12.0%, 13.8%, 12.2%, 11.3%, 10.4% | epoch 8: 10.4% |
| Long plan, seed 0, twice | as v3.1-stable | 13.2%, 11.8%, 14.1%, 12.0%, 13.8%, 12.2%, 11.3%, 10.4% | epoch 8: 10.4% |
Why: the loss this model learns from is added up in parallel on the GPU, in an order that can change from run to run, and nothing in training asked the GPU to be exactly repeatable. Rounding then differs in the last digit, and training can amplify that from the first epoch. Sometimes it doesn't (the long plan repeated exactly); sometimes it does, and a short plan at a high learning rate amplifies it most.
The winner's curse: why the best of many noisy trials is the one to trust least
Every score is part plan, part luck. A search keeps the lowest score, so it favours plans that are good, and also runs that were lucky. How lucky? If every trial were the same plan, with run-to-run spread s, the best of n would on average look this much better than the plan really is:
| Runs | 1 | 5 | 20 | 60 | 120 |
|---|---|---|---|---|---|
| Luck of the best | -0.00 s | 1.16 s | 1.87 s | 2.32 s | 2.57 s |
The trials of a search aren't one plan repeated, so this overstates it. But with the short plan's spread of 1.8 points, the best of 60 copies would look 4.2 points better than its average. The real short plan's 10.9% sat 3.1 points under its own average: well within what luck alone could do.
10 Five seeds each: judge a plan by its mean and its spread
Over five runs, the long plan averages 11.4%, v3's plan 12.8%, the short plan 13.9%. The lucky winner was the worst of the three on average.
A 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. now picks the varied copies and seeds training, so a plan can be run several times on purpose. Each finalist and v3's own plan, as the reference, ran with four more seeds:
| Plan | Run | After each epoch | Kept |
|---|---|---|---|
| Long plan, 8 epochs | seed 0 | 13.2%, 11.8%, 14.1%, 12.0%, 13.8%, 12.2%, 11.3%, 10.4% | epoch 8: 10.4% |
| Long plan, 8 epochs | seed 1 | 13.8%, 12.9%, 11.8%, 10.7%, 11.1%, 11.1%, 11.1%, 10.9% | epoch 8: 10.9% |
| Long plan, 8 epochs | seed 2 | 12.7%, 12.2%, 12.7%, 11.3%, 12.2%, 12.9%, 11.1%, 10.7% | epoch 8: 10.7% |
| Long plan, 8 epochs | seed 3 | 12.9%, 12.9%, 12.7%, 13.2%, 11.6%, 14.1%, 11.1%, 12.2% | epoch 8: 12.2% |
| Long plan, 8 epochs | seed 4 | 13.4%, 12.5%, 11.8%, 11.3%, 12.2%, 12.2%, 12.2%, 12.7% | epoch 8: 12.7% |
| v3's plan, 6 epochs | seed 1 | 14.3%, 14.7%, 14.1%, 12.9%, 12.0%, 13.4% | epoch 5: 12.0% |
| v3's plan, 6 epochs | seed 2 | 13.6%, 12.5%, 13.4%, 14.7%, 12.2%, 13.4% | epoch 6: 13.4% |
| v3's plan, 6 epochs | seed 3 | 14.3%, 13.4%, 12.9%, 12.9%, 13.2%, 12.9% | epoch 6: 12.9% |
| v3's plan, 6 epochs | seed 4 | 14.1%, 13.8%, 13.8%, 14.1%, 13.8%, 12.9% | epoch 6: 12.9% |
| Short plan, 2 epochs | seed 0 | 11.3%, 10.9% | epoch 2: 10.9% |
| Short plan, 2 epochs | seed 0, again | 13.2%, 15.6% | epoch 2: 15.6% |
| Short plan, 2 epochs | seed 1 | 12.9%, 15.6% | epoch 2: 15.6% |
| Short plan, 2 epochs | seed 2 | 16.6%, 14.3% | epoch 2: 14.3% |
| Short plan, 2 epochs | seed 3 | 13.8%, 12.9% | epoch 2: 12.9% |
| Short plan, 2 epochs | seed 4 | 14.5%, 14.3% | epoch 2: 14.3% |
| Plan | Runs, by seed | Mean | Spread (sd) |
|---|---|---|---|
| Long plan | 0: 10.4% · 1: 10.9% · 2: 10.7% · 3: 12.2% · 4: 12.7% | 11.4% | 1.0 points |
| v3's plan | 1: 12.0% · 2: 13.4% · 3: 12.9% · 4: 12.9% | 12.8% | 0.6 points |
| Short plan | 0: 10.9% · 0, again: 15.6% · 1: 15.6% · 2: 14.3% · 3: 12.9% · 4: 14.3% | 13.9% | 1.8 points |
The long plan beats v3's plan by 1.4 points on average, and the short plan by 2.6. Divided by their own uncertainty (Welch's t), those gaps are 2.7 and 2.9: past 2, a gap is rarely luck, though five runs is still a small sample.
Note who came last on average: the short plan, the search's original winner, 1.1 points behind the hand-picked plan it was meant to replace. v3's own plan was the steadiest (spread 0.6 points), just not the best.
11 The recipe, value by value
Every value of v3.1-stable, what it means in lines and steps, and the evidence that put it there.
The pick got a recipe of its own, written before the model was made and never edited after. v3.1's recipe stays as the record of the lucky plan; v3.1-stable's keeps both searches, the superseded tie-break and all fifteen repeat runs with their scores.
| Value | v3.1-stable | In practice | Why |
|---|---|---|---|
| Start from | base v3 | 3 × 192 network, 2.15 million weights | 27.2% before personalising; never saw the writer's lines |
| Lines | both rounds | 68 learned from, 17 held out | more of the writer's lines is the biggest lever; dev lines to choose with |
| Learning rate | 0.001671 | constant, every batch | search 2's best; the lowest mean over five seeds |
| Copies per line | 70 | 4,828 of the writer's lines an epoch | search 2's five best used 48 to 75 |
| Synthetic lines mixed in | 361 | 349 trained on, 12 held out to check | search 2's five best used 361 to 1,006; it forgets a little more (section 8) |
| Epochs | 8 | 162 batches each, 1,296 updates | the second valley; every epoch ran |
| Timing hidden | 14.3% | of pen points, in training only | the searched value; it mattered least in search 2 (4%) |
| Layers frozen | 0 | every weight learns | each frozen layer made every search's best worse |
| Seed | 0 | picks the copies, seeds training | the plan's best run of five |
| Time limit | 60 min | needs about a minute | the epochs end training, never the clock |
| Where | a rented A10G | 69 s, $0.03 | section 5 |
| Decoder | the defaults | the tuned settings are kept, unused | tuned won on dev lines, lost on test lines (section 13) |
The learning rate reads 0.0016710470002597796 in the recipe. Nobody chose those digits: the search draws from a continuous range, and the recipe keeps exactly what it drew, so the model can be made again from it.
12 Win: 6.8% on the test lines, read once
v3.1-stable gets 41 of 600 test letters wrong, against 62 for v2.1-personal: better on 13 lines, worse on 3.
- Character error rate
- 6.8%
- 95% interval 4.6% to 9.2%
- Word error rate
- 23.6%
- from 32.9%
- 95% of lines read within
- 45.1 ms
- budget 50 ms
The test lines are the same 24 that v2.1-personal was judged on, so the two can be compared line by line. Each square below is a letter of a test line; the shaded ones are how many that model got wrong on that line.
| Line | Language | Letters | Wrong |
|---|---|---|---|
| 1 | en | 32 | 1 |
| 2 | fr | 26 | 0 |
| 3 | math | 11 | 1 |
| 4 | mixed | 27 | 1 |
| 5 | en | 30 | 5 |
| 6 | fr | 33 | 1 |
| 7 | math | 23 | 1 |
| 8 | en | 33 | 7 |
| 9 | fr | 26 | 2 |
| 10 | mixed | 31 | 5 |
| 11 | math | 8 | 1 |
| 12 | en | 27 | 1 |
| 13 | fr | 24 | 8 |
| 14 | math | 23 | 4 |
| 15 | mixed | 31 | 3 |
| 16 | en | 26 | 2 |
| 17 | fr | 27 | 2 |
| 18 | math | 14 | 4 |
| 19 | en | 35 | 0 |
| 20 | fr | 22 | 2 |
| 21 | mixed | 32 | 3 |
| 22 | math | 6 | 2 |
| 23 | en | 26 | 3 |
| 24 | fr | 27 | 3 |
| Line | Language | Letters | Wrong |
|---|---|---|---|
| 1 | en | 32 | 0 |
| 2 | fr | 26 | 0 |
| 3 | math | 11 | 0 |
| 4 | mixed | 27 | 1 |
| 5 | en | 30 | 4 |
| 6 | fr | 33 | 0 |
| 7 | math | 23 | 2 |
| 8 | en | 33 | 3 |
| 9 | fr | 26 | 2 |
| 10 | mixed | 31 | 5 |
| 11 | math | 8 | 0 |
| 12 | en | 27 | 1 |
| 13 | fr | 24 | 3 |
| 14 | math | 23 | 1 |
| 15 | mixed | 31 | 2 |
| 16 | en | 26 | 3 |
| 17 | fr | 27 | 3 |
| 18 | math | 14 | 3 |
| 19 | en | 35 | 0 |
| 20 | fr | 22 | 2 |
| 21 | mixed | 32 | 2 |
| 22 | math | 6 | 2 |
| 23 | en | 26 | 1 |
| 24 | fr | 27 | 1 |
Paired line by line, the step is −3.5 points (−6.0 to −1.3), and the interval stays clear of zero. It was made from fewer of the writer's lines: 68, where v2.1-personal learned from all 85.
| Language | v2.1-personal | v3.1-stable | No language model |
|---|---|---|---|
| English | 9.1% | 5.7% | 7.7% |
| French | 9.7% | 5.9% | 8.6% |
| Mixed | 9.9% | 8.3% | 9.1% |
| Math | 15.3% | 9.4% | 8.2% |
Math is the one kind of line read better without the language model A model of which letters tend to follow which, learned from plain text. Changing it: It fixes readings the network finds ambiguous. Weighted too heavily, it overrules what you actually wrote, such as rare words and names. Here: A character model (each letter given the 4 before it) from 120,000 Tatoeba sentences per language, weighted 0.5. (8.2% against 9.4%): it learned which letters follow which from ordinary text, and pulls formulas toward words. What the remaining mistakes look like:
| Written | Read | Network alone |
|---|---|---|
| Δx ≠ 0 | √x + 0 | √x + 0 |
| A = πr² ≈ 28.3 | A = ∑ r² = 28.3 | A = π r² = 28.3 |
| Draft du mail pour Hélène, ASAP | Draft du mail pour Hélène, 49/12 | Draft du mail pour Hélème, $S/12 |
| Buy a gift for Yasmin, max $40 | Buy a gift for yas men, max d40 | Buy a gift for yasmin, max 940 |
| Facture n° 58 : 230,00 € | Facture n° 58: 230 000 € | Facture n° 58: 230 000 € |
| Unpack the boxes in room 9 | Unpach the boce in room 9 | Unpach the boce in roioi 9 |
Symbols, units and names: Δ read as √, "max $40" as "max d40", "ASAP" as "49/12". The network alone gets π right, and "Yasmin" all but its capital, where the language model talks it out of them.
On all 39 test lines of both rounds, v3.1-stable reads 8.6%. The 8% goal is met on the 24, but the interval reaches 9.2%: 24 lines can't prove "under 8%" yet. The comparison with every earlier model is in episode 7.
13 Not kept: a decoder tuned on the dev lines
Tuning the decoder gained 4 letters on the dev lines and lost 6 on the test lines. The defaults stay, and they keep within the speed budget too.
The decoder turns the network's letter scores into text, guided by the language model. Its six settings were searched on the same dev lines (100 trials, on the laptop), keeping the fastest settings within half a point of the most accurate.
| Decoder | Dev lines | Test lines (24) | Both rounds (39) |
|---|---|---|---|
| Default (kept) | 10.2% | 6.8% | 8.6% |
| Tuned | 9.3% | 7.8% | 9.4% |
4 letters gained on 441, 6 lost on 600. Four letters is inside the dev lines' wobble (section 4), and settings fitted to 17 lines learn their quirks. Most of the search's attention went to two settings: how early to drop unlikely readings (38%) and how much to trust the language model (20%).
The tuned decoder was faster, though, and speed has a budget: 95% of lines read within 50 ms. The first timing of the defaults, 49.2 ms, was taken while the site was being built on the same machine. So the two decoders were timed in turn, three times each, so that any load fell on both:
| Row | Value | Interval |
|---|---|---|
| Default, run 1 | 47.3 ms | |
| Default, run 2 | 45.6 ms | |
| Default, run 3 | 46.1 ms | |
| Tuned, run 1 | 52.2 ms | |
| Tuned, run 2 | 41.7 ms | |
| Tuned, run 3 | 41.7 ms |
- Two bests within 3 letters: keep the one that forgets less. It picked the short plan, then the five-seed repeats replaced it with a better rule: the lowest mean.
- Keep tuned decoder settings only if they also win on the test lines. They didn't: defaults.
- Fewest errors first, if 95% of lines stay within 50 ms on a quiet machine. The defaults did, three times out of three.
14 What it cost: $1.99
Two searches took most of the money. A finished model costs about 3 cents and a minute; the whole of personalising cost 13% of the base model.
| Row | Value | Interval |
|---|---|---|
| Search 2, 60 trials | $0.84 | |
| Search 1, 60 trials | $0.52 | |
| 12 repeat runs | $0.29 | |
| 2 candidates | $0.04 | |
| v3.1-stable | $0.03 | |
| v3.1 | $0.01 |
- One finished model
- $0.03
- about a minute on the GPU
- All of personalising
- $1.99
- 13% of base v3's $15.06
- The same search on the laptop
- 12 h +
- and the laptop busy all along
15 Making it again, and what's next
The recipe makes the model again, within the noise of GPU training. Three things to try next, each with a measurement waiting.
Everything that made v3.1-stable is pinned: the base, both rounds of lines, the dev split, the plan and its seed, the GPU type. Made twice, the long plan gave the same score after every epoch, down to the same 46 wrong letters at the end. But GPU training isn't guaranteed to repeat exactly, so a fresh run of the plan should be expected anywhere between its best and worst of five: 10.4% to 12.7% on the dev lines.
- Math without the letter language model, or with one that knows formulas: the network alone already reads math lines at 8.2% against 9.4%.
- A third recording round, so a model refitted on all 85 lines, with no dev lines held out, can be judged against this one on lines neither has seen.
- More test lines, to tell whether the error is really under 8%: on 24 lines the interval still reaches 9.2%.
- Which part of the step did most: the new base, both rounds of lines and the searched plan all changed at once.
- Whether the plan suits a different writer: it was chosen on this writer's 17 dev lines.
- How the plan would rank on a bigger dev set, where luck has less room.
16 What carries over
Six habits for choosing training settings when the data is small and every score is noisy.
- Measure before renting, and measure the right thing: here, more processor cores did nothing, and the GPU personalised well under its benchmark speed.
- Put a ruler on every score. With 441 dev characters, one letter is 0.23 points, and a single reading wobbles by several letters between epochs.
- When a search's best sits at the edge of a range, widen it as a new search; don't believe the edge.
- Read importances as a property of the ranges searched, not of the setting: widen a range and its importance changes.
- Treat a search's winner as a shortlist. Make it again, then run every finalist with several seeds and pick by the mean: here the winner came last.
- Write the rule before the measurement, and the recipe before the model, and never edit either afterwards: when the luck showed, a new recipe replaced the old one instead of hiding it.