Build a Simple MNIST Convnet in Keras (Step by Step)

A reader asked me for the smallest Keras model that reads handwritten digits well enough to trust.

The answer is a simple MNIST convnet: two Conv2D + MaxPooling2D blocks and one Dense softmax layer. Trained for 3 epochs on my laptop’s CPU, it scored 98.54% on the 10,000 test images.

This is the finished model at work on real test digits:

Matplotlib window with the first 12 MNIST test digits and the Keras convnet's predicted label and confidence above each one
The finished model on the first 12 test digits: 12 of 12 correct, with its confidence over each image.

In this tutorial, I’ll build that MNIST convnet in Keras one script at a time, then use it on a digit you draw yourself.

Before You Start: install TensorFlow and Keras

You need Python 3.10 or newer plus pip install tensorflow matplotlib pillow; TensorFlow brings Keras 3 with it. I used Python 3.12.5, TensorFlow 2.21.0 and Keras 3.15.1.

No GPU is needed; on native Windows, TensorFlow 2.11+ runs on the CPU anyway. If Keras isn’t installed yet, my guide on installing and setting up Keras in Python walks through it. MNIST itself ships with Keras:

SplitImagesShape per imageLabels
Training60,00028 x 28 grayscale0-9
Test10,00028 x 28 grayscale0-9

Step 1: Load and prepare the MNIST dataset in Keras

Every script below imports one small module, so save this as convnet.py first. It loads the data and builds the model:

import os, logging
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3"                  # hide TensorFlow C++ log lines
os.environ["TF_ENABLE_ONEDNN_OPTS"] = "0"                 # and the oneDNN start-up notice
logging.getLogger("tensorflow").setLevel(logging.ERROR)   # hide the Windows GPU notice
import numpy as np
import keras
from keras import layers


def load_mnist():
    """MNIST scaled to 0-1 with a channel axis: (60000, 28, 28, 1) and (10000, 28, 28, 1)."""
    (x_train, y_train), (x_test, y_test) = keras.datasets.mnist.load_data()
    x_train = np.expand_dims(x_train.astype("float32") / 255, -1)
    x_test = np.expand_dims(x_test.astype("float32") / 255, -1)
    return (x_train, y_train), (x_test, y_test)


def build_model():
    return keras.Sequential([
        keras.Input(shape=(28, 28, 1)),
        layers.Conv2D(32, kernel_size=3, activation="relu"),
        layers.MaxPooling2D(pool_size=2),
        layers.Conv2D(64, kernel_size=3, activation="relu"),
        layers.MaxPooling2D(pool_size=2),
        layers.Flatten(),
        layers.Dropout(0.5),
        layers.Dense(10, activation="softmax"),
    ], name="mnist_convnet")

load_mnist() scales pixels from 0-255 to 0-1 and adds a channel axis, because Conv2D expects (height, width, channels). Compare the raw and prepared data:

import numpy as np
import convnet
import keras

(x_train, y_train), (x_test, y_test) = keras.datasets.mnist.load_data()
print("raw train:", x_train.shape, x_train.dtype, "pixels", x_train.min(), "to", x_train.max())
print("images per digit:", np.bincount(y_train).tolist())

(x_train, y_train), (x_test, y_test) = convnet.load_mnist()
print("ready train:", x_train.shape, x_train.dtype, "pixels", x_train.min(), "to", x_train.max())

Output:

raw train: (60000, 28, 28) uint8 pixels 0 to 255
images per digit: [5923, 6742, 5958, 6131, 5842, 5421, 5918, 6265, 5851, 5949]
ready train: (60000, 28, 28, 1) float32 pixels 0.0 to 1.0
Command Prompt output of the MNIST dataset in Keras showing raw and prepared array shapes, pixel ranges and images per digit
The raw MNIST arrays and the prepared ones in Command Prompt: same images, new dtype, range and channel axis.

Checkpoint: The raw images are uint8 from 0 to 255; the prepared ones are float32 from 0.0 to 1.0 with shape (60000, 28, 28, 1). Each digit has 5,421 to 6,742 training images.

If you’re loading your own images instead, loading and preprocessing datasets in TensorFlow covers folders and tf.data.

Step 2: Build the convnet layers in Keras

The model is already in convnet.py. Here’s what each layer does with one 28 x 28 digit:

  • Conv2D(32, 3) learns 32 small 3 x 3 filters for edges and strokes.
  • MaxPooling2D(2) keeps the strongest value in each 2 x 2 patch.
  • The second Conv2D + pooling pair combines strokes into loops and corners.
  • Dense(10, softmax) turns those features into 10 digit probabilities.

keras.Sequential suits one straight stack. For branches you’d use the Functional API, which rapid prototyping with Keras and TensorFlow compares.

Print the summary to check the shapes:

import convnet

model = convnet.build_model()
model.summary()

Output:

Model: "mnist_convnet"
┌─────────────────────────────────┬────────────────────────┬───────────────┐
│ Layer (type)                    │ Output Shape           │       Param # │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ conv2d (Conv2D)                 │ (None, 26, 26, 32)     │           320 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ max_pooling2d (MaxPooling2D)    │ (None, 13, 13, 32)     │             0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ conv2d_1 (Conv2D)               │ (None, 11, 11, 64)     │        18,496 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ max_pooling2d_1 (MaxPooling2D)  │ (None, 5, 5, 64)       │             0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ flatten (Flatten)               │ (None, 1600)           │             0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dropout (Dropout)               │ (None, 1600)           │             0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dense (Dense)                   │ (None, 10)             │        16,010 │
└─────────────────────────────────┴────────────────────────┴───────────────┘
 Total params: 34,826 (136.04 KB)
 Trainable params: 34,826 (136.04 KB)
 Non-trainable params: 0 (0.00 B)
Command Prompt showing model.summary() for the Keras MNIST convnet with output shapes and parameter counts per layer
model.summary() in Command Prompt: the image shrinks from 28 x 28 to 5 x 5 x 64 before the Dense layer, with 34,826 parameters in total.

Checkpoint: Total params reads 34,826. A different number means a layer argument doesn’t match.

To see what those filters react to, visualizing convnet layers in Keras plots the feature maps.

Step 3: Train the MNIST convnet with model.fit()

This script compiles the model, trains it for 3 epochs and saves it. Save it as train_mnist.py:

import convnet
import keras
import tensorflow as tf

keras.utils.set_random_seed(42)                 # same weights and shuffling every run
tf.config.experimental.enable_op_determinism()  # same numbers on CPU, too

(x_train, y_train), _ = convnet.load_mnist()
model = convnet.build_model()
model.compile(loss="sparse_categorical_crossentropy", optimizer="adam",
              metrics=["accuracy"])

stop = keras.callbacks.EarlyStopping(monitor="val_loss", patience=2,
                                     restore_best_weights=True)
history = model.fit(x_train, y_train, batch_size=128, epochs=3,
                    validation_split=0.1, callbacks=[stop], verbose=2)

model.save("mnist_cnn.keras")
print("Saved mnist_cnn.keras after", len(history.history["loss"]), "epochs")

Output:

Epoch 1/3
422/422 - 24s - 57ms/step - accuracy: 0.8926 - loss: 0.3586 - val_accuracy: 0.9780 - val_loss: 0.0807
Epoch 2/3
422/422 - 22s - 51ms/step - accuracy: 0.9661 - loss: 0.1097 - val_accuracy: 0.9840 - val_loss: 0.0579
Epoch 3/3
422/422 - 21s - 50ms/step - accuracy: 0.9739 - loss: 0.0848 - val_accuracy: 0.9882 - val_loss: 0.0458
Saved mnist_cnn.keras after 3 epochs
Command Prompt training log of the Keras MNIST convnet showing accuracy, loss, val_accuracy and val_loss for 3 epochs
The training log: one line per epoch, with validation accuracy above 97% from the first epoch.

Training accuracy climbed from 89.26% to 97.39%, and validation accuracy finished at 98.82%. The 422 steps are 54,000 images in batches of 128; validation_split=0.1 holds back 6,000.

Validation accuracy beats training accuracy in every epoch. That’s normal here: Dropout only switches off neurons while training, so you score the full network on validation.

sparse_categorical_crossentropy fits integer labels. EarlyStopping didn’t fire in 3 epochs, but it pays off once you raise epochs to 15 or 20.

Checkpoint: mnist_cnn.keras now sits next to the script: layers, weights and optimizer state in one file.

For callbacks, custom loops and more about fit(), see training neural networks in TensorFlow.

Test the MNIST Convnet on 10,000 Test Images

The test images played no part in training, so they give the honest score:

import numpy as np
import convnet
import keras

_, (x_test, y_test) = convnet.load_mnist()
model = keras.models.load_model("mnist_cnn.keras")

loss, acc = model.evaluate(x_test, y_test, verbose=0)
print(f"test loss {loss:.4f} | test accuracy {acc:.2%}")

preds = model.predict(x_test, verbose=0).argmax(axis=1)
wrong = np.flatnonzero(preds != y_test)
print(f"wrong: {len(wrong)} of {len(y_test)} test images")
for i in wrong[:3]:
    print(f"  image {i}: predicted {preds[i]}, actually {y_test[i]}")

Output:

test loss 0.0468 | test accuracy 98.54%
wrong: 146 of 10000 test images
  image 18: predicted 5, actually 3
  image 259: predicted 0, actually 6
  image 320: predicted 8, actually 9