Draw a digit in your browser and a neural network trained on MNIST reads it back — scikit-learn MLPClassifier + Streamlit.
Reading handwritten digits is the classic first real computer-vision problem: the same digit looks
different in every hand, so there is no simple rule that separates a sloppy 4 from a sloppy 9.
This project trains a small neural network to classify 28x28 images of digits, then wraps it in a
web app where anyone can draw a digit with the mouse and get a live prediction.
The interesting part is not the model — it is the gap between training data and reality. MNIST digits are carefully normalised; a mouse drawing is not. Most of the engineering here is the preprocessing that closes that gap (see Key Insights).
- Name: MNIST database of handwritten digits (60,000 training + 10,000 test images, 28x28 grayscale)
- Source URL: https://storage.googleapis.com/tensorflow/tf-keras-datasets/mnist.npz (~11 MB
.npzarchive) - License / citation: MNIST is released for free use. Original creators: Yann LeCun, Corinna Cortes and Christopher J.C. Burges — http://yann.lecun.com/exdb/mnist/. Please cite LeCun et al., "Gradient-based learning applied to document recognition", Proceedings of the IEEE, 1998.
The archive is downloaded automatically on first run into data/, which is gitignored — nobody
needs an 11 MB blob in their clone.
| Module | What it does |
|---|---|
src/data_loader.py |
Downloads mnist.npz once into data/ and returns (x_train, y_train, x_test, y_test) via numpy.load. |
src/train.py |
Flattens images to 784 features, scales pixels to [0, 1], trains an MLPClassifier with one hidden layer of 128 units and early stopping, evaluates on the full test set, and writes the model, reports/metrics.json and the confusion-matrix figure. |
src/predict.py |
Turns a raw canvas drawing into an MNIST-style 28x28 input (grayscale → threshold → crop → resize to 20 px → pad to 28x28 → recentre by centre of mass → scale) and runs the prediction. |
app.py |
Streamlit UI: a 280x280 drawing canvas, a stroke-width slider, the predicted digit, a bar chart of all 10 class probabilities, and a preview of the 28x28 image the model actually sees. |
tests/test_predict.py |
Headless tests for the preprocessing and end-to-end prediction path. |
Training setup. The model is trained on a stratified 30,000-image subsample of the training set to keep training to about 20 seconds on a laptop, but it is always evaluated on the full, untouched 10,000-image test set — so the accuracy below is honest, not measured on data the model has seen.
All numbers below are copied from reports/metrics.json, produced by running
python -m src.train.
| Metric | Value |
|---|---|
| Test accuracy (10,000 unseen images) | 97.11% |
| Macro-average F1 | 0.9709 |
| Training images used | 30,000 (stratified subsample) |
| Test images | 10,000 (full test set) |
| Training time | 20.3 s |
| Epochs run (early stopping, max 40) | 29 |
| Model file size | 2.3 MB (models/mnist_mlp.joblib) |
| Digit | Precision | Recall | F1 | Support |
|---|---|---|---|---|
| 0 | 0.9767 | 0.9857 | 0.9812 | 980 |
| 1 | 0.9842 | 0.9868 | 0.9855 | 1135 |
| 2 | 0.9729 | 0.9729 | 0.9729 | 1032 |
| 3 | 0.9711 | 0.9653 | 0.9682 | 1010 |
| 4 | 0.9743 | 0.9654 | 0.9698 | 982 |
| 5 | 0.9666 | 0.9742 | 0.9704 | 892 |
| 6 | 0.9737 | 0.9656 | 0.9696 | 958 |
| 7 | 0.9678 | 0.9640 | 0.9659 | 1028 |
| 8 | 0.9632 | 0.9671 | 0.9652 | 974 |
| 9 | 0.9585 | 0.9623 | 0.9604 | 1009 |
Highlights: 1 is the easiest digit (F1 0.9855) — it has the least shape variation.
9 is the hardest (F1 0.9604), and 8 is close behind (F1 0.9652).
# 1. Clone and enter the project
git clone <your-repo-url>
cd digit-recognizer-streamlit
# 2. Create a virtual environment
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
# 3. Install dependencies
pip install -r requirements.txt
# 4. Launch the app (a trained model is already committed)
streamlit run app.pyThen open http://localhost:8501 and draw a digit.
Optional — retrain the model yourself (downloads MNIST on first run, ~20 s to train):
python -m src.train # writes models/ and reports/
python -m pytest # runs the test suite- Push this repository to GitHub.
- Go to https://share.streamlit.io and sign in with your GitHub account.
- Click New app, pick this repository and branch (
main). - Set the main file path to
app.py. - Click Deploy.
The trained model (models/mnist_mlp.joblib, 2.3 MB) is committed to the repo, so no training
happens in the cloud — the app just loads the file and starts serving. requirements.txt pins
scikit-learn==1.9.0 so the model unpickles against exactly the version that created it.
- Recentring the drawing matters more than the model does. MNIST digits are scaled to a 20x20
box and shifted so their centre of mass sits in the middle of a 28x28 field. A raw mouse drawing
is off-centre and the wrong size, so
preprocess_canvasreproduces that exact normalisation. Without this step the same 97%-accurate model gives near-random answers on hand-drawn input — the model isn't wrong, it is just being shown something that doesn't look like its training data. 4and9are the model's blind spot. The single largest error in the confusion matrix is 18 fours predicted as nines — more than double any other mistake. That matches intuition: a4with a closed top is a9.9ends up with the lowest recall (0.9623) and the lowest precision (0.9585) of any digit.- Half the data got 97% of the way there. Training on 30,000 of the 60,000 images still reaches 97.11%, and early stopping halted at 29 of the allowed 40 epochs — the network stopped improving on its validation split before it ran out of budget. Extra data and epochs would give diminishing returns here; a convolutional network, not more of the same training, is the real next step.
digit-recognizer-streamlit/
├── app.py # Streamlit app (drawing canvas + prediction UI)
├── requirements.txt
├── README.md
├── .gitignore
├── data/ # gitignored — mnist.npz downloaded on first run
├── models/
│ └── mnist_mlp.joblib # trained model, committed for cloud deployment
├── reports/
│ ├── metrics.json # machine-written metrics (source of every number above)
│ └── confusion_matrix.png
├── src/
│ ├── __init__.py
│ ├── data_loader.py # download + load MNIST
│ ├── train.py # train, evaluate, save artifacts
│ └── predict.py # canvas preprocessing + prediction
└── tests/
└── test_predict.py # preprocessing + end-to-end prediction tests
