Skip to main content
All hands-on projects

Project 8 · Deep Learning · Full beginner handbook

Teach AI to Read Handwritten Numbers — Build a Real CNN

A courier reads a handwritten parcel number incorrectly and sends a package to the wrong place. Can we teach a neural network to recognize handwritten digits 0 through 9? We will train a real CNN rather than pretend an image model exists.

What you will build

A locally trained PyTorch CNN, a real held-out classification report, a 10-class confusion matrix and a Streamlit app that accepts an uploaded handwritten-digit image.

Exact tools you will use

Python 3.12, VS Code, scikit-learn load_digits (1,797 original 8×8 images), NumPy, PyTorch, Pillow, Streamlit, pytest, GitHub Actions

Digit-recognition flow from image through two convolution and pooling blocks to ten class probabilities
The actual order of operations in this learning project, illustrated. Not a screenshot of a trained model.

Real application evidence

Genuine full-page desktop and mobile screenshots captured from a running Streamlit app by GitHub Actions. Digit evidence uses scikit-learn's original 1,797-image dataset with a real uploaded example.

Genuine desktop Streamlit Teach AI to Read Handwritten Numbers — Build a Real CNN screenshot
Desktop browser evidence
Genuine mobile Streamlit Teach AI to Read Handwritten Numbers — Build a Real CNN screenshot
390-pixel mobile browser evidence

Calculate the actual CNN parameter count

  1. Input shape: one grayscale channel × 8 × 8 pixels.
  2. First 3×3 convolution has 16 output channels: 16 × (1×3×3 + 1 bias) = 160 parameters.
  3. After 2×2 pooling: 16 × 4 × 4. Second convolution: 32 × (16×3×3 + 1) = 4,640 parameters.
  4. After another pool: 32 × 2 × 2 = 128 inputs to a dense layer. Dense 128→64: 128×64+64 = 8,256 parameters.
  5. Final dense 64→10: 64×10+10 = 650. Total learned parameters: 160+4,640+8,256+650 = 13,706.
1

Create a real development folder

Why: Running commands from the wrong directory causes imports and file paths to fail.

Do this: Install Python 3.12 and VS Code. Choose File → Open Folder → projects/digit-recognizer, then Terminal → New Terminal.

Check: Explorer shows app.py, train.py, requirements.txt, src, tests.

2

Install the exact learning tools

Why: PyTorch learns numeric features directly from images; no pretrained classifier is used.

Do this: Create a virtual environment, activate it, then install the project dependencies. If Torch is large, ensure sufficient free disk space.

Type these terminal commandsbashRunnable
python -m venv .venv
# Windows: .venv\Scripts\activate
# macOS/Linux: source .venv/bin/activate
python -m pip install -r requirements.txt

Check: Python imports Torch, PIL and scikit-learn successfully.

3

Understand pixels, labels, training and held-out test

Why: A good model must recognize images it did not see during training.

Do this: In src/digits.py inspect load_splits. The dataset is 1,797 labelled grayscale images. Split training, validation and test with fixed seed and class stratification; normalize intensity by 16.

Check: Training, validation and test total 1,797; each image is 8×8; test remains held out.

4

Train the CNN from scratch

Why: Convolutions learn local strokes; ReLU adds nonlinearity; max-pooling condenses responses; cross-entropy trains ten outputs.

Do this: Read DigitCNN and fit in src/digits.py, then run actual training. Inspect each epoch loss and validation accuracy.

Type these terminal commandsbashRunnable
python train.py

Check: artifacts/digits-cnn.pt and artifacts/metrics.json are created.

5

Evaluate and predict

Why: Test accuracy alone hides mistakes between similar handwritten digits.

Do this: Examine the saved 10×10 confusion matrix and all ten output probabilities. Try a real upload and compare it with the 8×8 preprocessed preview.

Type these terminal commandsbashRunnable
python -m pytest -q
python -m streamlit run app.py

Check: The app shows predicted digit, original image, 8×8 preview and ten class probabilities.

Limitations you should understand

The scikit-learn dataset is only 8×8. Real phone camera photos may be out of distribution. This is not full-page OCR and cannot recognize multi-digit numbers.

Complete source code — copy every file

This is the exact code used in the repository, loaded directly as raw source in the website build. Each file below is complete, not abbreviated. Create the named file inside the project folder, paste it, and then run the commands above.

README.md

README.mdmarkdownRunnable
# Project 8 — Teach AI to Read Handwritten Numbers

Real PyTorch CNN trained from scratch on scikit-learn's offline **1,797 handwritten 8×8 digit images**, with a Streamlit upload/predict app. The dataset is built into scikit-learn — no Kaggle login, API key or external dataset download.

## Beginner setup

1. Install Python 3.12 and VS Code. Open the folder `projects/digit-recognizer` in VS Code using File → Open Folder.
2. Choose Terminal → New Terminal. Windows: `py -3.12 -m venv .venv` and `.venv\\Scripts\\activate`. macOS/Linux: `python3.12 -m venv .venv && source .venv/bin/activate`.
3. Run `python -m pip install -r requirements.txt` (PyTorch is a large library; installation can take time).
4. Run `python -m pytest -q`. Tests include real training, image validation and model reloading.
5. Train from scratch: `python train.py`. This saves a model and JSON metrics under `artifacts/` and prints held-out test accuracy.
6. Start the app: `python -m streamlit run app.py`. Open the displayed localhost address, commonly http://localhost:8501.
7. Draw a single digit with a dark pen on white paper, photograph/crop it closely or create a simple PNG in Paint, upload it and observe the original and preprocessed 8×8 image.
8. Inspect all ten model probabilities; look at errors and note that accuracy on the held-out built-in dataset does not guarantee accuracy on handwritten camera images.

## Architecture calculation

Input `1×8×8` → convolution `16×8×8` → max pooling `16×4×4` → convolution `32×4×4` → max pooling `32×2×2` → flatten `32·2·2=128` values → dense 64 → 10 class logits.

A 3×3 convolution with 1 input channel and 16 outputs has `16 × (1 × 3 × 3 + 1) = 160` learned weights including biases. The second has `32 × (16 × 3 × 3 + 1) = 4,640` learned parameters. Explain ReLU, pooling, cross-entropy, gradient descent and the 80/20 split with a further validation split.

## Limits

The dataset contains small normalized greyscale handwritten digits. External pen-on-paper images can differ substantially in stroke thickness, lighting, rotation and framing. The model does not perform OCR of sentences or multiple digits. Test and validation data must never be used as additional training labels.

## Files

`src/digits.py` contains dataset preparation, CNN, training, saving and inference; `train.py` trains; `app.py` runs Streamlit; `tests/test_digits.py` checks the critical stages. Generated artifacts are excluded from Git.

requirements.txt

requirements.txttextRunnable
torch==2.8.0
scikit-learn==1.7.2
numpy==2.3.3
Pillow==11.3.0
streamlit==1.50.0
pytest==8.4.2
matplotlib==3.10.7

src/digits.py

src/digits.pypythonRunnable
"""Reproducible digit classification using scikit-learn's built-in 8x8 images.

A small PyTorch CNN is trained from scratch; the model is not pretrained.
The test split remains untouched until final evaluation.
"""
from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
import json

import numpy as np
import torch
from PIL import Image, ImageOps
from sklearn.datasets import load_digits
from sklearn.metrics import accuracy_score, confusion_matrix, classification_report
from sklearn.model_selection import train_test_split
from torch import nn

ROOT = Path(__file__).resolve().parents[1]
MODEL_PATH = ROOT / "artifacts" / "digits-cnn.pt"
METRICS_PATH = ROOT / "artifacts" / "metrics.json"
SEED = 42

class DigitCNN(nn.Module):
    """8x8 greyscale image -> 10 handwritten-digit probabilities."""
    def __init__(self):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(1, 16, kernel_size=3, padding=1),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=2),
            nn.Conv2d(16, 32, kernel_size=3, padding=1),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=2),
        )
        self.classifier = nn.Sequential(nn.Flatten(), nn.Linear(32 * 2 * 2, 64),
                                        nn.ReLU(), nn.Linear(64, 10))

    def forward(self, images: torch.Tensor) -> torch.Tensor:
        return self.classifier(self.features(images))


@dataclass
class Splits:
    train_x: np.ndarray
    val_x: np.ndarray
    test_x: np.ndarray
    train_y: np.ndarray
    val_y: np.ndarray
    test_y: np.ndarray


def load_splits(seed: int = SEED) -> Splits:
    images, targets = load_digits(return_X_y=False).images, load_digits(return_X_y=False).target
    images = (images / 16.0).astype(np.float32)
    first_x, test_x, first_y, test_y = train_test_split(
        images, targets, test_size=0.20, random_state=seed, stratify=targets
    )
    train_x, val_x, train_y, val_y = train_test_split(
        first_x, first_y, test_size=0.20, random_state=seed, stratify=first_y
    )
    return Splits(train_x, val_x, test_x, train_y, val_y, test_y)


def to_tensor(images: np.ndarray) -> torch.Tensor:
    if images.ndim != 3 or images.shape[1:] != (8, 8):
        raise ValueError("Expected (N, 8, 8) greyscale images")
    if not np.isfinite(images).all() or images.min() < 0 or images.max() > 1:
        raise ValueError("Images must be finite pixel intensities from 0 to 1")
    return torch.from_numpy(images.astype(np.float32)).unsqueeze(1)


def fit(epochs: int = 15, learning_rate: float = 0.003, seed: int = SEED) -> dict:
    if epochs < 1 or epochs > 100:
        raise ValueError("Choose between 1 and 100 epochs")
    torch.manual_seed(seed)
    np.random.seed(seed)
    torch.set_num_threads(2)
    parts = load_splits(seed)
    model = DigitCNN()
    optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)
    criterion = nn.CrossEntropyLoss()
    x, y = to_tensor(parts.train_x), torch.from_numpy(parts.train_y).long()
    history = []
    for epoch in range(epochs):
        model.train()
        order = torch.randperm(len(x))
        for indices in order.split(64):
            optimizer.zero_grad()
            logits = model(x[indices])
            loss = criterion(logits, y[indices])
            loss.backward()
            optimizer.step()
        model.eval()
        with torch.no_grad():
            val_logits = model(to_tensor(parts.val_x))
            val_accuracy = (val_logits.argmax(dim=1).numpy() == parts.val_y).mean()
        history.append({"epoch": epoch + 1, "train_loss": float(loss.item()),
                        "val_accuracy": round(float(val_accuracy), 6)})
    model.eval()
    with torch.no_grad():
        predictions = model(to_tensor(parts.test_x)).argmax(dim=1).numpy()
    accuracy = float(accuracy_score(parts.test_y, predictions))
    confusion = confusion_matrix(parts.test_y, predictions, labels=list(range(10)))
    metrics = {"dataset": "scikit-learn digits (1,797 8x8 images)",
               "seed": seed, "epochs": epochs, "test_accuracy": accuracy,
               "history": history, "confusion_matrix": confusion.tolist(),
               "classification_report": classification_report(parts.test_y, predictions,
                                                               output_dict=True, zero_division=0)}
    MODEL_PATH.parent.mkdir(parents=True, exist_ok=True)
    torch.save(model.state_dict(), MODEL_PATH)
    METRICS_PATH.write_text(json.dumps(metrics, indent=2), encoding="utf-8")
    return metrics


def load_model(model_path: Path = MODEL_PATH) -> DigitCNN:
    model = DigitCNN()
    model.load_state_dict(torch.load(model_path, map_location="cpu", weights_only=True))
    model.eval()
    return model


def prepare_uploaded_image(raw: bytes) -> np.ndarray:
    """Convert a learner-uploaded drawing into the dataset's bright-ink 8x8 convention."""
    from io import BytesIO
    if not raw or len(raw) > 4_000_000:
        raise ValueError("Upload a nonempty image smaller than 4 MB")
    try:
        with Image.open(BytesIO(raw)) as image:
            if image.width > 4096 or image.height > 4096:
                raise ValueError("Image dimensions are too large")
            gray = image.convert("L")
            # Convention: dark background with light strokes as in sklearn digits.
            if float(np.asarray(gray).mean()) > 127:
                gray = ImageOps.invert(gray)
            gray = ImageOps.autocontrast(gray)
            gray = ImageOps.pad(gray, (8, 8), color=0)
            arr = np.asarray(gray, dtype=np.float32) / 255.0
    except (OSError, SyntaxError) as exc:
        raise ValueError("Cannot decode this image") from exc
    if arr.max() < 0.05:
        raise ValueError("No visible digit found")
    return arr


def predict(model: DigitCNN, image: np.ndarray) -> tuple[int, list[float]]:
    tensor = to_tensor(image[None])
    with torch.no_grad():
        probabilities = torch.softmax(model(tensor), dim=1)[0].numpy()
    return int(probabilities.argmax()), probabilities.tolist()

train.py

train.pypythonRunnable
from src.digits import fit

if __name__ == "__main__":
    results = fit()
    print(f"Test accuracy on held-out sklearn digits: {results['test_accuracy']:.2%}")
    print("Saved artifacts/digits-cnn.pt and artifacts/metrics.json")

app.py

app.pypythonRunnable
"""Streamlit app; train first: python train.py"""
from __future__ import annotations
import io
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import streamlit as st
from PIL import Image
from src.digits import MODEL_PATH, METRICS_PATH, load_model, prepare_uploaded_image, predict

st.set_page_config(page_title="Handwritten Digit Recognizer | LearnMLAcademy", page_icon="🔢", layout="wide")
st.title("Teach AI to Read Handwritten Numbers")
st.caption("A real 8×8 convolutional neural network trained on 1,797 built-in handwritten digit images.")
if not MODEL_PATH.exists() or not METRICS_PATH.exists():
    st.warning("Model not trained yet. In the project terminal run: python train.py")
    st.stop()

@st.cache_resource
def cached_model():
    return load_model()

import json
metrics = json.loads(METRICS_PATH.read_text(encoding="utf-8"))
st.metric("Held-out test accuracy", f'{metrics["test_accuracy"]:.1%}')
st.caption("This measures only the built-in 8×8 dataset. Phone-camera images can perform worse.")
upload = st.file_uploader("Upload a clear handwritten digit (PNG/JPG)", type=["png", "jpg", "jpeg"])
if upload is None:
    st.info("Upload a dark-ink drawing on a light background, or white ink on black. The app will convert it to 8×8 pixels.")
else:
    try:
        image = prepare_uploaded_image(upload.getvalue())
        col1, col2 = st.columns(2)
        with col1:
            st.image(upload.getvalue(), caption="Your original drawing", width=220)
        with col2:
            st.image(image, caption="Actual 8×8 input after preprocessing", width=220, clamp=True)
        prediction, probabilities = predict(cached_model(), image)
        st.subheader(f"Predicted digit: {prediction}")
        st.caption("Softmax probabilities are relative model scores, not guaranteed real-world correctness.")
        st.bar_chart({str(i): float(score) for i, score in enumerate(probabilities)})
    except (ValueError, OSError) as exc:
        st.error(f"Could not read the digit: {exc}")

with st.expander("How does the neural network learn?"):
    st.markdown("Each 3×3 convolution scans local pixel patterns; ReLU keeps positive activations; "
                "2×2 max pooling reduces width and height. The dense layers choose among 10 digits.")
    st.code("Input: 1×8×8 → Conv: 16×8×8 → Pool: 16×4×4\n"
            "→ Conv: 32×4×4 → Pool: 32×2×2\n"
            "→ Flatten: 128 → Dense: 64 → Logits: 10", language="text")
    st.markdown("A training epoch predicts labels, calculates cross-entropy loss, propagates gradients "
                "backward, then updates weights using Adam. The test set is held out until final evaluation.")
st.divider()
st.caption("Educational project. Avoid interpreting scores as calibrated confidence.")

tests/test_digits.py

tests/test_digits.pypythonRunnable
from io import BytesIO
import numpy as np
import pytest
import torch
from PIL import Image
from src.digits import DigitCNN, fit, load_model, load_splits, predict, prepare_uploaded_image, to_tensor

def test_dataset_has_stratified_disjoint_splits():
    parts = load_splits()
    assert len(parts.train_x) + len(parts.val_x) + len(parts.test_x) == 1797
    assert parts.train_x.shape[1:] == (8, 8)
    assert set(parts.train_y) == set(range(10))
    assert set(parts.test_y) == set(range(10))

def test_model_shapes_and_normalized_output():
    model = DigitCNN()
    logits = model(torch.zeros(5, 1, 8, 8))
    assert logits.shape == (5, 10)
    value, probs = predict(model, np.ones((8, 8), dtype=np.float32))
    assert value in range(10)
    assert abs(sum(probs) - 1) < 1e-5

def test_bad_inputs_are_rejected():
    with pytest.raises(ValueError): to_tensor(np.ones((5, 9, 9), dtype=np.float32))
    with pytest.raises(ValueError): to_tensor(np.ones((5, 8, 8), dtype=np.float32) * 2)
    with pytest.raises(ValueError): prepare_uploaded_image(b"not-an-image")
    with pytest.raises(ValueError): prepare_uploaded_image(b"")

def test_uploaded_black_ink_preprocessing():
    arr = np.ones((64, 64), dtype=np.uint8) * 255
    arr[10:54, 25:39] = 0
    buffer = BytesIO()
    Image.fromarray(arr).save(buffer, format="PNG")
    result = prepare_uploaded_image(buffer.getvalue())
    assert result.shape == (8, 8)
    assert result.min() >= 0 and result.max() <= 1
    assert result.max() > 0.05

def test_training_is_reproducible_and_model_reload_works(tmp_path, monkeypatch):
    import src.digits as d
    monkeypatch.setattr(d, "MODEL_PATH", tmp_path / "digit.pt")
    monkeypatch.setattr(d, "METRICS_PATH", tmp_path / "metrics.json")
    result = d.fit(epochs=3)
    assert 0.3 < result["test_accuracy"] <= 1
    assert len(result["confusion_matrix"]) == 10
    model = d.load_model(tmp_path / "digit.pt")
    parts = load_splits()
    number, scores = d.predict(model, parts.test_x[0])
    assert number in range(10)
    assert abs(sum(scores) - 1) < 1e-5

Project verification workflow

.github/workflows/three-projects-verify.ymlyamlConfiguration
name: Three Remaining Projects — Engineering Verify
on:
  push:
    branches:
      - feat/complete-three-projects-20261009
  pull_request:
    paths:
      - 'projects/digit-recognizer/**'
      - 'projects/retail-forecasting/**'
      - 'projects/disaster-tweets/**'
      - 'src/pages/*ProjectPage.tsx'
      - 'src/data/projectPortfolio.ts'
      - '.github/workflows/three-projects-verify.yml'
  workflow_dispatch:

jobs:
  retail:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    defaults:
      run:
        working-directory: projects/retail-forecasting
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
          cache: pip
          cache-dependency-path: projects/retail-forecasting/requirements.txt
      - run: python -m pip install -r requirements.txt
      - run: python -m pytest -q
      - run: python -m compileall -q src train.py download_data.py app.py
  disaster:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    defaults:
      run:
        working-directory: projects/disaster-tweets
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
          cache: pip
          cache-dependency-path: projects/disaster-tweets/requirements.txt
      - run: python -m pip install -r requirements.txt
      - run: python -m pytest -q
      - run: python -m compileall -q src train.py app.py
  digits:
    runs-on: ubuntu-latest
    timeout-minutes: 25
    defaults:
      run:
        working-directory: projects/digit-recognizer
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - run: python -m pip install -r requirements.txt
      - run: python -m pytest -q
      - run: python -m compileall -q src train.py app.py
  website:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npm run build

Your build checkpoints — Handwritten Digit Recognizer

Keep the full handbook and all source code visible above. These optional checkpoints help you track what you can actually build and explain. Progress is saved only in this browser.

0 of 5 checkpoints completed

Predict → change → observe → explain

Before: Predict which handwriting shapes the 8×8 trained CNN may confuse.

Try: Try an original single-digit image and inspect the resized 8×8 input.

Show your evidence: Compare the predicted label with all ten probabilities and the test confusion matrix.

Environment setup on your computer

Unzip the source first, open its project folder in VS Code, then read its README for dataset/download instructions. Python 3.12 is the documented starting version for this project; follow its README if it specifies a more exact patch release.

Windows PowerShell commands
py -3.12 -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
.\.venv\Scripts\python.exe -m pip check
macOS / Linux commands
python3.12 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
.venv/bin/python -m pip check

No global package installation or machine-wide policy changes are necessary. For Windows, explicit environment Python avoids PowerShell activation-policy issues. PyTorch or downloaded datasets may require substantial disk space.

Optional: publish a small demo safely
  1. Finish the local test, save a screenshot and check your actual saved model or index works after restart.
  2. Use a repository you control. Exclude API keys, .env files, personal uploads, unlicensed datasets and generated sensitive artifacts.
  3. Choose a host that supports your actual Python and system dependencies. If a model or data file is generated locally, plan a permitted and reproducible build step before expecting a cloud demo to start.
  4. Test the real hosted application on desktop and mobile, including invalid inputs, empty answers, missing model files and service restarts.
  5. Do not expose a paid AI key or an unrestricted inference endpoint to the public; add user authentication, rate limits and spending limits first. Keep a local-only demonstration if you cannot protect it.

This is an optional safety checklist, not a claim that any project already has a public deployed demo.

Important limitation: Phone photographs are different from the small built-in training images.