Handwriting persists, even in the digital age. As we continue to transfer sorting and processing tasks from humans to machines, the ability for a scanning system to rapidly and accurately identify handwritten digits becomes paramount. This is especially true for tasks such as sorting mail and processing checks; failures in either of these fields alone could result in anything from minor inconveniences to major harm.
This problem is largely solved, but thanks to the MNIST dataset we can learn how to implement a digit recognition system of our own.
This is the capstone project for DataTalks.Club's Machine Learning Zoomcamp. This capstone represents my first independent project with a neural network, which is building upon the lessons learned over the past four months.
Note: This dataset is downloaded in a special data format and has handling procedures that were not covered in the Zoomcamp.
The dataset is the MNIST database of handwritten digits, which is a well-known dataset that is commonly used in beginner machine learning projects and is sometimes considered the "hello world" example project. The dataset consists of 60,000 images in the training dataset and 10,000 images in the validation dataset.
Actual images from the dataset are shown at the top of this README; they look like this: 
The images are 28x28 and greyscale (contain one color channel).
🌐 How to download the data
Note: downloading the data is handled in notebook.ipynb; this is here only as a reference.import torchvision
import torchvision.transforms as transforms
from torchvision.datasets import MNIST
# Define a transformation to convert images to PyTorch tensors
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,)) # Standard normalization for MNIST
])
# Download and load the training data
trainset = MNIST(
root='./data', # Directory where data will be saved
train=True, # Request the training subset
download=True, # Download the data if it's not already present
transform=transform
)
# Download and load the test data
testset = MNIST(
root='./data', # Directory where data will be saved
train=False, # Request the test subset
download=True, # Download the data if it's not already present
transform=transform
)The following will be downloaded:
- Downloading http://yann.lecun.com/exdb/mnist/train-images-idx3-ubyte.gz
- Downloading http://yann.lecun.com/exdb/mnist/train-labels-idx1-ubyte.gz
- Downloading http://yann.lecun.com/exdb/mnist/t10k-images-idx3-ubyte.gz
- Downloading http://yann.lecun.com/exdb/mnist/t10k-labels-idx1-ubyte.gz
Which contain:
- train-images-idx3-ubyte.gz: Training set images (60,000 images, 28x28 pixels, 3 dimensions).
- train-labels-idx1-ubyte.gz: Training set labels (60,000 labels, 1 dimension).
- t10k-images-idx3-ubyte.gz: Test set images (10,000 images, 28x28 pixels, 3 dimensions).
- t10k-labels-idx1-ubyte.gz: Test set labels (10,000 labels, 1 dimension).
The MNIST class, with the transform functions, take the images and create the tensors required for later processing. In the Machine Learning Zoomcamp, this replaces the Dataset class we built that ultimately loads the image datasets and applies the transformations.
In order to run this project you'll need to clone the repo and install the following (Zoomcamp participants should already have these installed):
- uv: https://docs.astral.sh/uv/getting-started/installation/
- python 3.13: https://www.python.org/downloads/
- docker: https://docs.docker.com/desktop/
- kubetl: https://kubernetes.io/docs/tasks/tools/#kubectl
- kind: https://kind.sigs.k8s.io/docs/user/quick-start/
All instructions after this point require you to be in the root of the cloned repo.
Install packages: uv sync --locked
* You can now run the notebook (notebook.ipynb).
Build the container locally: docker build -t digit-classifier:v1 .
uv run uvicorn predict:app --host 0.0.0.0 --port 8080 --reload- Navigate to the docs page and click the 'try it out' button: http://0.0.0.0:8080/docs
- Choose one of the files in the test_images directory, eg:
mnist_0_label_5.png - The correct answer is in the filename; it is the number after 'label'
docker run -it --rm -p 8080:8080 digit-classifier:v1- Navigate to the docs page and click the 'try it out' button: http://0.0.0.0:8080/docs
- Choose one of the files in the test_images directory, eg:
mnist_0_label_5.png - The correct answer is in the filename; it is the number after 'label'
curl -X 'POST' \
'http://0.0.0.0:8080/predict' \
-F 'file=@./test_images/mnist_0_label_5.png;type=image/png'- Create mlzoomcamp cluster:
kind create cluster --name mlzoomcamp - Load container into kind:
kind load docker-image digit-classifier:v1 --name mlzoomcamp - Apply kubectl deployment:
kubectl apply -f ./k8s/deployment.yaml- to check pod status:
kubectl get pod
- to check pod status:
- Apply kubectl service:
kubectl apply -f ./k8s/service.yaml- to show services:
kubectl get services
- to show services:
- Forward port to localhost:
kubectl port-forward service/digit-classifier 30080:8080 - In your browser, open localhost:30080/docs to test the predict api via the 'try it out' button.
- Choose one of the files in the test_images directory, eg:
mnist_0_label_5.png - The correct answer is in the filename; it is the number after 'label'
curl -X 'POST' \
'http://0.0.0.0:30080/predict' \
-F 'file=@./test_images/mnist_0_label_5.png;type=image/png'- Delete deployment and service:
kubectl delete all -l app=digit-classifier - Delete kind cluster:
kind delete cluster --name mlzoomcamp








