The digits on the receipt
In short: Recognising digits with a neural network turns the picture into pixels as features, and a network with one neuron per digit learns to say which digit it is.
Problem
The total from a photo
To return an item, the customer sends a photo of the till receipt, and the system has to read the total. The till prints the digits in one font, so it looks easy: compare the pixels with the font and you are done.
But paper creases, ink fades and the photo has smudges. Here is the 7 from the font and three 7s from real receipts:
printSide([show(pixels(DIGITS[7])), show(trainX[7]), show(trainX[17]), show(trainX[27])]);##### ##### ##### #####....# ....# ....# ....#...#. ...## ..... ...#...#.. ..#.. #.#.. ..##...#.. ..#.. ..#.. #.##.The third 7 is missing a pixel in the middle row, the fourth has extra ink at the bottom. A rule “it is a 7 if these 9 pixels are ink” breaks at the first smudge. And you cannot write a rule for every possible smudge.
The idea
The pixels are features
A 5 × 5 picture is 25 pixels. Line them up row by row and you have 25 numbers: 1 for ink, 0 for blank. These are features, just like the distance and the weight of an order. Recognising a picture is classification with 25 features and 10 classes, the digits 0 to 9. This is image classification.
The network is one layer of 10 neurons, one per digit. Every neuron has one weight for every pixel: its sum is large when the ink is where its digit has ink.
The ten sums become probabilities with softmax: every sum becomes eˢ, then every number is divided by the total of all of them. The numbers are positive and add up to 1. The largest one is the answer, and its size is how sure the network is.
Training is the loop from the previous lesson: epochs, shuffling, batches, a step. The error for one picture is −ln(p) of the right digit: 0 if the network gives the right answer 100%, and large if it gives it little.
The data: 60 digits for training (6 of each) and 30 for the test (3 of each), all from the font with smudges. Every pixel flips with probability 8%, two pixels per digit on average.
See it
The data and the network
The font is written in the code as text, # is ink:
// the digits of the till font: 5 × 5 pixels, # is inkconst DIGITS = [ [".###.", "#...#", "#...#", "#...#", ".###."], ["..#..", ".##..", "..#..", "..#..", ".###."], [".###.", "#...#", "..##.", ".#...", "#####"], ["####.", "....#", ".###.", "....#", "####."], ["#..#.", "#..#.", "#####", "...#.", "...#."], ["#####", "#....", "####.", "....#", "####."], [".###.", "#....", "####.", "#...#", ".###."], ["#####", "....#", "...#.", "..#..", "..#.."], [".###.", "#...#", ".###.", "#...#", ".###."], [".###.", "#...#", ".####", "....#", ".###."],];A picture becomes features, the smudges come from the generator, and the features turn back into a picture so we can print them:
// the picture as 25 features: row by row, 1 for ink, 0 for blankfunction pixels(rows) { const x = []; for (let r = 0; r < 5; r++) for (let c = 0; c < 5; c++) x.push(rows[r][c] === "#" ? 1 : 0); return x;}
// a smudge or faded ink: every pixel flips with probability 8%const smudge = (x, rng) => x.map((v) => (rng.next() < 0.08 ? 1 - v : v));
// the features back as a pictureconst show = (x) => [0, 1, 2, 3, 4].map((r) => x.slice(r * 5, r * 5 + 5).map((v) => (v > 0.5 ? "#" : ".")).join(""));
// several pictures side by sidefunction printSide(pics) { for (let r = 0; r < 5; r++) console.log(pics.map((pic) => pic[r]).join(" "));}The generator is the one from the previous lesson, with seed 7. First come the 60 training digits, then the 30 test digits:
class Rng { constructor(seed) { this.state = seed; }
// the next number between 0 and 1; one seed gives the same numbers next() { this.state = (this.state * 48271) % 2147483647; return this.state / 2147483647; }}const rng = new Rng(7);const trainY = Array.from({ length: 60 }, (_, n) => n % 10);const trainX = trainY.map((d) => smudge(pixels(DIGITS[d]), rng));const testY = Array.from({ length: 30 }, (_, n) => n % 10);const testX = testY.map((d) => smudge(pixels(DIGITS[d]), rng));The network: 10 × 25 weights and 10 biases. predict computes the ten sums and turns them into probabilities. Before exp, the largest sum is subtracted from every sum: the result is the same, but exp does not overflow on large sums. In step the slope of neuron k by its sum is p[k] − 1 for the right digit and p[k] for the others: the right neuron is pushed up and the others down, by as much as they were wrong.
class Net { // small random weights; biases stay 0 constructor(rng) { this.w = Array.from({ length: 10 }, () => Array.from({ length: 25 }, () => (rng.next() * 2 - 1) * 0.1)); // a weight for every pixel for each of the 10 neurons this.b = new Array(10).fill(0); }
// the ten probabilities (softmax): they add up to 1 predict(x) { const p = []; for (let k = 0; k < 10; k++) { let s = this.b[k]; for (let i = 0; i < 25; i++) s += this.w[k][i] * x[i]; p.push(s); } const top = Math.max(...p); let total = 0; for (let k = 0; k < 10; k++) { p[k] = Math.exp(p[k] - top); total += p[k]; } for (let k = 0; k < 10; k++) p[k] /= total; return p; }
// the error: the average −ln(p) of the right answer loss(xs, ys) { let total = 0; for (let n = 0; n < xs.length; n++) total -= Math.log(this.predict(xs[n])[ys[n]]); return total / xs.length; }
// a step per batch: the slope of neuron k is p[k] − 1 for the right digit, p[k] for the others step(xs, ys, batch, rate) { const gw = Array.from({ length: 10 }, () => new Array(25).fill(0)); const gb = new Array(10).fill(0); for (const n of batch) { const p = this.predict(xs[n]); for (let k = 0; k < 10; k++) { const d = p[k] - (k === ys[n] ? 1 : 0); gb[k] += d; for (let i = 0; i < 25; i++) gw[k][i] += d * xs[n][i]; } } for (let k = 0; k < 10; k++) { this.b[k] -= (rate * gb[k]) / batch.length; for (let i = 0; i < 25; i++) this.w[k][i] -= (rate * gw[k][i]) / batch.length; } }}Try it
Predict the output
What does the program print? x[r * 5 + 2] is the pixel in row r and column 2 (counting from 0).
const x = pixels(DIGITS[7]);console.log([0, 1, 2, 3, 4].map((r) => x[r * 5 + 2]).join(" "));Row r starts at index r · 5, so r · 5 + 2 is the third column of every row. In the 7 it is ink at the top (#####), blank in the next two rows and ink in the two bottom ones (..#..). “0 0 0 1 0” is the third row, not the third column.
See it
Training, and one digit up close
60 epochs, batches of 10, rate 0.5. Every 10 epochs the program prints the error and how many digits are right on the training and the test set:
const net = new Net(rng);const order = [...trainX.keys()];console.log("epoch error training test");for (let epoch = 1; epoch <= 60; epoch++) { shuffle(order, rng); for (let start = 0; start < order.length; start += 10) net.step(trainX, trainY, order.slice(start, start + 10), 0.5); if (epoch % 10 === 0) { const right = `${correct(net, trainX, trainY)}/${trainX.length}`; const test = `${correct(net, testX, testY)}/${testX.length}`; const loss = net.loss(trainX, trainY).toFixed(3); console.log(String(epoch).padStart(5) + loss.padStart(7) + right.padStart(10) + test.padStart(6)); }}epoch error training test 10 0.268 59/60 28/30 20 0.128 60/60 29/30 30 0.084 60/60 29/30 40 0.063 60/60 29/30 50 0.050 60/60 29/30 60 0.042 60/60 29/30After 20 epochs the network gets all 60 training digits and 29 of 30 on the test set. The error keeps falling because the network gets more sure, but the test digit it misses stays the same.
Here is what happens to one digit. The pixels go in, every neuron adds up its weights over them, and softmax turns the sums into probabilities:
// the ten probabilities (softmax): they add up to 1predict(x) { const p = []; for (let k = 0; k < 10; k++) { let s = this.b[k]; for (let i = 0; i < 25; i++) s += this.w[k][i] * x[i]; p.push(s); } const top = Math.max(...p); let total = 0; for (let k = 0; k < 10; k++) { p[k] = Math.exp(p[k] - top); total += p[k]; } for (let k = 0; k < 10; k++) p[k] /= total; return p;}
const net = train(trainX, trainY, rng);const shifted = pixels(shift(DIGITS[1]));for (const x of [testX[17], shifted]) { printSide([show(x)]); const p = net.predict(x); for (let k = 0; k < 10; k++) { const bar = "█".repeat(Math.round(p[k] * 20)); const pct = (p[k] * 100).toFixed(1).padStart(5); console.log(`${k} ${bar.padEnd(20)} ${pct}%`); } console.log(`→ ${best(p)}`);}Step by step, in words
- A 7 from the test set, with a smudge at the bottom left. For the network it is 25 numbers: 1 for ink, 0 for blank.
- Each of the 10 neurons adds up its weights over the ink pixels. The neuron for 7 gets the largest sum: 6.68.
- Softmax: e to the power of every sum, divided by the total. The ten bars add up to 100%.
- The largest bar is the answer: 7 with 98.4%. The smudge did not get in the way.
- The 1 from the font, without a smudge, printed one pixel to the right. Its ink is in the fourth column.
- The network says 4 with 57.1%, and 1 gets only 4.4%. The weights of 4 wait for ink in that column.
- Every weight is tied to one pixel. Smudges it has learned to ignore; a shift of one pixel it has never seen.
const net = train(trainX, trainY, rng);const shifted = pixels(shift(DIGITS[1]));for (const x of [testX[17], shifted]) { printSide([show(x)]); const p = net.predict(x); for (let k = 0; k < 10; k++) { const bar = "█".repeat(Math.round(p[k] * 20)); const pct = (p[k] * 100).toFixed(1).padStart(5); console.log(`${k} ${bar.padEnd(20)} ${pct}%`); } console.log(`→ ${best(p)}`);}#####....#...#...#..#.#..0 0.1%1 0.0%2 0.2%3 0.4%4 0.0%5 0.7%6 0.0%7 ████████████████████ 98.4%8 0.1%9 0.1%→ 7...#...##....#....#...###0 0.2%1 █ 4.4%2 ███ 15.4%3 2.3%4 ███████████ 57.1%5 0.6%6 █ 5.0%7 █ 7.1%8 █ 2.9%9 █ 5.0%→ 4The 7 from the test set has a smudge at the bottom left, and the network still says 7 with 98.4%. The second picture is the 1 from the font, printed one pixel to the right. Not a single smudge, and the network says 4 with 57.1%. We will come back to it.
The idea
What every neuron looks at
Every neuron has 25 weights, one per pixel, so its weights are a 5 × 5 picture too. A large positive weight means “ink here is for me”, a large negative one means “ink here is against me”:
// # strong “yes”, + “yes”, . no matter, - “no”, = strong “no”function symbol(v) { if (v > 0.8) return "#"; if (v > 0.25) return "+"; if (v < -0.8) return "="; if (v < -0.25) return "-"; return ".";}
const weightMap = (weights) => [0, 1, 2, 3, 4].map((r) => weights.slice(r * 5, r * 5 + 5).map(symbol).join(""));const net = train(trainX, trainY, rng);console.log(" 0 1 7 ");printSide([0, 1, 7].map((d) => weightMap(net.w[d]))); 0 1 7.+.-- .-+=. #+++#+.-.# -##.- -.--+#===# --+=- -==+.#--.+ -+#.- ..#.-.+.-- -+.+- -=+-.The neuron for 0 wants ink in the two outer columns and is strongly against ink in the middle of the third row: there the 0 is blank, while 8, 3 and 2 have ink. The neuron for 7 wants the whole top row and the third column at the bottom. The neuron for 1 wants the two pixels at the top middle (.##.) and is against the bottom left corner, where 2 and 5 have ink.
Nobody told the network where the stroke of the 7 is. The weights were found from the examples alone. They are not a copy of the digit: a neuron also looks at where its digit differs from the others.
Try it
Fill in
The last lines of softmax. What is every number divided by, so that the ten add up to 1?
for (let k = 0; k < 10; k++) p[k] /= ;By total, the sum of the ten exp values. Every number becomes its share of the whole, so the shares add up to 1. If you divided by the largest, the largest would become 1 and the sum of all of them would be more than 1, and the numbers would no longer be probabilities.
The idea
One pixel to the side
The till sometimes prints a digit a little to the side, and the photo is taken at an angle. Here are three digits from the font, without smudges, moved one pixel to the right:
// the same digit, printed one pixel to the rightconst shift = (rows) => rows.map((row) => "." + row.slice(0, 4));const net = train(trainX, trainY, rng);const digits = [1, 3, 7];printSide(digits.map((d) => shift(DIGITS[d])));for (const d of digits) { const p = net.predict(pixels(shift(DIGITS[d]))); console.log(`${d} → ${best(p)} (${(p[best(p)] * 100).toFixed(1)}%)`);}...#. .#### .####..##. ..... ........#. ..### ....#...#. ..... ...#...### .#### ...#.1 → 4 (57.1%)3 → 9 (77.7%)7 → 9 (39.0%)The network that gets 29 of 30 smudged digits gets all three wrong. Of all ten moved digits only the 9 comes out right, and with 53.8%.
The reason is in the weights. Every weight is tied to one pixel in one place. The neuron for 1 expects ink in the middle column. The moved 1 is in the fourth column, and ink there is what the neuron for 4 expects. For the network the same shape in another place is another picture: it does not know it has seen “a stroke”, it only knows which pixels are ink.
That is why the training data has to look like the real data. If digits move around on the photos, the training examples have to move too. Or the network has to be of another kind, one that looks for the shape everywhere (see “More”).
Try it
Find the bug
A colleague writes a report on the accuracy of the network and says “100%”. On customers' receipts the network makes mistakes. Which line has the bug?
Pick the line with the bug.
The accuracy is measured on the digits the network learned from. It has already seen them, so it gets all 60. The honest measure is the test set, which took no part in training: correct(net, testX, testY), 29 of 30, or 96%. And the test set is the same font with smudges: it has no moved digits, so even 96% is more optimistic than real receipts.
The idea
When it hurts
- Confidently wrong. The only mistake on the test set is a 3 with a smudge in the middle that the network reads as a 9 with 91.2%. The probability is how sure the network is, not how right it is. When a mistake costs money, the threshold for “sure” is checked on a test set, and a person looks at the doubtful digits.
- Anything it has not seen. A shift, another font, a tilted photo, a handwritten digit: the network has only seen the font with smudges and knows nothing else. To it every picture is one of the ten digits, even an empty square or a letter.
- Little data. 60 pictures are enough because there is one font and the digits are 5 × 5. A photo of a real receipt needs thousands of examples and a bigger network.
- No network needed. If the till also sends the total as text, or prints a QR code on the receipt, read that. Recognition is for the cases where there is no other way.
More precisely, if you are curious
Softmax as a formula. The sums of the ten neurons are s₀ … s₉. The probability of digit k is:
p[k] = e^(s[k]) / (e^(s₀) + e^(s₁) + … + e^(s₉))
In words: make every sum positive with e to the power of the sum, then divide by the total. With two digits this is the sigmoid from logistic regression: softmax is its extension to more classes. Subtracting the largest sum does not change the result, because e^(s − m) = e^s / e^m, and e^m cancels out.
Why the slope is p[k] − 1. The error for one picture is L = −ln(p[y]), where y is the right digit (cross-entropy). Its slope by the sum of neuron k comes out very simple: p[k] − 1 for k = y and p[k] for the others. If the network gives the right answer 98%, the push is only −0.02. If it gives it 10%, it is −0.9. From there backprop goes on to the weights as in the earlier lessons: the slope by weight i is the slope by the sum times pixel i.
Why there is no hidden layer. With 10 neurons straight from the pixels, every weight is for one pixel and one digit, so the weight map can be read. With a hidden layer the network gets more right, but the maps of the hidden neurons are a mix of parts of several digits and hard to read. Try it in the exercises.
Networks for pictures. Real networks for pictures are convolutional (CNN): a small window of weights, say 3 × 3, slides over the whole picture and looks for the same shape everywhere. A stroke is a stroke on the left and on the right, so a shift hardly bothers them. The classic example is MNIST: 70,000 handwritten digits, 28 × 28 pixels. In PyTorch the window is nn.Conv2d.
The same numbers in four languages. The programs print the probabilities with one decimal, and the bars are rounded to 20 parts. The lesson checks that no bar and no weight sits exactly on a rounding boundary, so the output is the same in Java, C#, Python and JavaScript.
Exercise
The total on the receipt
Solve it in your editor.
The total on the receipt is €12.90: the digits 1, 2, 9 and 0. The till printed the last digit one pixel to the right, and the photo has smudges. Make the four pictures (the last one moved, all of them smudged with the same generator, after training), recognise each one and print the digit and its probability. If the probability is below 90%, mark the digit to be checked by hand and put a ? in its place in the total.
digit 1: 1 (97.0%)digit 2: 2 (94.4%)digit 3: 9 (90.5%)digit 4: 7 (37.4%) → check by handtotal: €12.9? → check by handThe moved 0 becomes a 7, but only with 37.4%, and the threshold catches it. The 9 passes with 90.5%, a hair above the threshold. The threshold does not make the network smarter, it tells you when not to trust it. A receipt with a ? goes to a person, not to accounting.
More exercises
- Moved examples. Add to the training data every digit of the font moved one pixel to the left and to the right, and train again. How many of the ten moved digits does it get now? And how many on the test set?
- A hidden layer. Put a hidden layer of 16 neurons with a sigmoid between the pixels and softmax. Print the weights of two hidden neurons as a picture. Does any of them look like a digit?
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: The neuron · Layers · Backpropagation · Training from start to finish
- Continues in: Predicting the next token
- Tasks on this topic: A neural network from the inside