← All posts
5 min read

How I Built a Digit Classifier That Runs in the Browser

I wanted to build a portfolio piece that showed what a neural network is doing between the input and the final prediction. The result is the Neural Network Playground: draw a digit, watch the activations move through a simplified network, and inspect the probability for every digit from 0 to 9.

The important constraint was that the finished experience had to be real and local. The browser should run the model and calculate the probabilities. There is no API route, server-side inference, API key, analytics call, or LangChain pipeline involved.

The architecture

The project has two separate parts:

  1. A Python training/export script that creates the model artifact.
  2. A small React/TypeScript inference layer that loads the artifact and runs it in the browser.

The model itself is a dense network with this shape:

784 inputs → 64 ReLU units → 32 ReLU units → 10 outputs

The 784 inputs are the pixels in a 28×28 grayscale image. The ten outputs are the digits 0 through 9. The hidden layers use ReLU activation, while the final layer produces scores that are converted to probabilities with softmax.

Training the model in Python

The training script lives in tools/train_digit_model.py. It uses scikit-learn's built-in handwritten-digits dataset. That dataset contains 8×8 grayscale images, so it is not the original MNIST dataset. I expand each image by 3× and add 2 pixels of padding on every side, producing a 28×28 input with the same shape expected by an MNIST-style classifier.

The training process is reproducible:

  • The data is split into training and test sets with a fixed random seed.
  • The network uses scikit-learn's MLPClassifier.
  • The hidden layers have 64 and 32 units.
  • The trained model reaches about 98.6% accuracy on its held-out split.
  • The script exports only the weights, biases, and metadata required for inference.

The output is a JSON file at public/models/digit-model.json. The file is roughly 480 KB, which is small enough to ship with the page without adding a browser ML runtime.

Preprocessing the drawing

A visitor does not draw directly on a 28×28 canvas. The visible canvas is much larger so pointer, touch, and stylus input feels natural.

When the drawing ends, the app:

  1. Reads the canvas pixels as grayscale values.
  2. Finds the bounding box of non-empty pixels.
  3. Crops the empty area around the digit.
  4. Scales the crop to a maximum dimension of 20 pixels.
  5. Centers it inside a 28×28 image, leaving padding around the stroke.
  6. Uses bilinear sampling while resizing so the result is not jagged.
  7. Returns exactly 784 values normalized to the [0, 1] range.

That preprocessing step matters more than it first appears. A digit drawn in the corner or stretched to fill the entire input would look very different from the examples used during training. Cropping, preserving the aspect ratio, and centering the result makes the browser input more compatible with the training data.

The normalized 28×28 image is shown beside the prediction so the visitor can see exactly what the model receives.

Running inference without TensorFlow.js

The page loads the JSON artifact after the initial render through useDigitClassifier. Once loaded, runInference performs the matrix multiplications directly:

next = activation(input × weights + bias)

The first two layers apply ReLU. The final layer returns ten raw scores. The softmax calculation subtracts the largest score before exponentiating, which keeps the calculation numerically stable:

probability[i] = exp(score[i] - maxScore) / sum(exp(scores - maxScore))

This approach avoids adding TensorFlow.js or ONNX Runtime for a model small enough to express in a few loops. It also makes the inference path easy to inspect: the same function returns both the ten probabilities and the intermediate hidden-layer activations.

The app measures only the inference calculation with performance.now(). The displayed duration does not include the separate visualization animation.

Making the visualization honest

A real first layer contains 64 neurons, and the input contains 784 pixels. Rendering every neuron and every model weight would make the page noisy and would not teach much.

The visualization therefore uses three different levels of detail:

  • The actual 28×28 input is shown as a compact pixel grid.
  • A representative subset of the real hidden activations is shown as nodes.
  • All ten real output probabilities are shown as the final layer.

The links between nodes are representative visual connections. They do not claim to render every weight in the model. The UI says this directly because a clear simplification is better than an impressive but misleading diagram.

The animation has five stages: reading pixels, detecting patterns, combining evidence, calculating probabilities, and completing the prediction. The activation intensity comes from the model output, while the timing is only a presentation layer. Visitors can skip the animation, and reduced-motion preferences complete it immediately.

Testing the path

There was no test runner in the existing portfolio, so I added a lightweight Node check instead of introducing a large testing dependency. It verifies that:

  • Empty input produces 784 zeros.
  • Preprocessing preserves the 784-value shape and numeric range.
  • Example digits run through the real model.
  • The output contains ten finite values.
  • The probabilities sum to approximately 1.
  • The hidden layers expose 64, 32, and 10 activations.

The project also passes TypeScript checking and the production Next.js build. The route and model are both static assets from the portfolio deployment.

What the model can and cannot do

This is an educational, self-contained model rather than a production handwriting service. The training data is small and based on 8×8 examples that are expanded to 28×28. It can struggle with unusual handwriting, very thin strokes, decorative fonts, or digits drawn unlike the training samples.

That limitation is part of the demo. The confidence value is a probability distribution from the model, not a guarantee that the prediction is correct. When the top probability is low, the interface explicitly says that the model is unsure and suggests drawing the digit more clearly.