Convolutional Network for MNIST: Keras 3#

A dense network treats an image as a list of 784 numbers and ignores which pixels are neighbours. A convolutional network slides small learned filters across the image, so the same stroke detector is applied everywhere, and pooling coarsens the image so that later filters see larger regions. This notebook trains one on the MNIST digits, with the split used in the Neural Networks chapter. Set EPOCHS to 1 or 2 in the data cell for a quicker run.

Keras and PyTorch#

This notebook repeats the PyTorch version with the Keras 3 API, running on the same PyTorch backend. compile() sets the optimizer and loss, fit() runs the loop over batches and epochs, and callbacks such as early stopping replace hand-written control flow. The data split, model, and evaluation match the PyTorch notebook.

Setup#

The libraries used below.

Hide code cell source

import hashlib
import json
import os

os.environ["KERAS_BACKEND"] = "torch"
os.environ.setdefault("CUBLAS_WORKSPACE_CONFIG", ":4096:8")  # Deterministic cuBLAS on GPUs.

import keras
import matplotlib.pyplot as plt
import numpy as np
import torch
from keras import layers
from sklearn.metrics import (
    ConfusionMatrixDisplay,
    accuracy_score,
    classification_report,
    confusion_matrix,
)
from sklearn.model_selection import train_test_split

assert keras.backend.backend() == "torch"
print(f"Keras {keras.__version__}, backend {keras.backend.backend()}, PyTorch {torch.__version__}")
Keras 3.15.1, backend torch, PyTorch 2.13.0+cu130

Load MNIST and split it#

The 60,000 official training images are split, with a fixed seed and stratified by class, into 50,000 for training and 10,000 for validation. The 10,000 official test images are used once, at the end.

SEED = 42
EPOCHS = 5  # Reduce to 1 or 2 for a quicker run.
BATCH_SIZE = 256
CLASS_NAMES = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']

keras.utils.set_random_seed(SEED)
torch.use_deterministic_algorithms(True, warn_only=True)

(x_development, y_development), (x_test, y_test) = keras.datasets.mnist.load_data()
all_indices = np.arange(len(y_development))
train_indices, validation_indices = train_test_split(
    all_indices,
    test_size=10000,
    random_state=SEED,
    stratify=y_development,
)
split_signature = hashlib.sha256(
    validation_indices.astype("<i8").tobytes()
).hexdigest()[:16]

# Scale pixel values from 0-255 to 0-1.
x_train = x_development[train_indices].astype("float32") / 255.0
y_train = y_development[train_indices].astype(np.int64)
x_validation = x_development[validation_indices].astype("float32") / 255.0
y_validation = y_development[validation_indices].astype(np.int64)
x_test = x_test.astype("float32") / 255.0
y_test = y_test.astype(np.int64)
x_train = x_train[..., np.newaxis]
x_validation = x_validation[..., np.newaxis]
x_test = x_test[..., np.newaxis]

assert set(train_indices).isdisjoint(validation_indices)
print(
    f"train={len(y_train):,}, validation={len(y_validation):,}, "
    f"test={len(y_test):,}, image shape={x_train.shape[1:]}"
)
train=50,000, validation=10,000, test=10,000, image shape=(28, 28, 1)

Look at the data#

One example per class and the number of training images in each. The classes are balanced, so accuracy is a fair summary of performance.

def as_image(sample):
    # Convert one stored sample to (height, width[, channel]) for plotting.
    if sample.ndim == 1:
        sample = sample.reshape(28, 28)
    if sample.ndim == 3 and sample.shape[0] in (1, 3):
        sample = np.transpose(sample, (1, 2, 0))
    return sample.squeeze()


fig = plt.figure(figsize=(12, 3.6))
grid = fig.add_gridspec(2, 10, height_ratios=[1, 1.2])
for label in range(10):
    axis = fig.add_subplot(grid[0, label])
    image = as_image(x_train[np.flatnonzero(y_train == label)[0]])
    axis.imshow(image, cmap="gray" if image.ndim == 2 else None)
    axis.set_title(CLASS_NAMES[label], fontsize=9)
    axis.axis("off")
counts_axis = fig.add_subplot(grid[1, :])
counts_axis.bar(CLASS_NAMES, np.bincount(y_train, minlength=10), color="0.45")
counts_axis.set(ylabel="Training images")
plt.tight_layout()
plt.show()
../../../../_images/de35b6dcd4e5d493c6ffafd5497793e911cef5aca67809f42b2ea2b01aad2010.png

Define the model#

Two 3×3 convolution layers with 32 and 64 filters, each followed by a ReLU and 2×2 max pooling, turn a 28×28 image into 64 feature maps of 5×5 pixels. Dropout then zeroes a random quarter of those activations during training, a guard against relying on any single feature. Two dense layers produce ten logits, one unnormalized score per digit.

model = keras.Sequential(
    [
        keras.Input(shape=(28, 28, 1)),
        layers.Conv2D(32, 3, activation="relu"),
        layers.MaxPooling2D(),
        layers.Conv2D(64, 3, activation="relu"),
        layers.MaxPooling2D(),
        layers.Dropout(0.25),
        layers.Flatten(),
        layers.Dense(200, activation="relu"),
        layers.Dense(150, activation="relu"),
        layers.Dropout(0.5),
        layers.Dense(10),
    ],
    name="mnist_cnn",
)
model.summary()
Model: "mnist_cnn"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ 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 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dropout (Dropout)               │ (None, 5, 5, 64)       │             0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ flatten (Flatten)               │ (None, 1600)           │             0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dense (Dense)                   │ (None, 200)            │       320,200 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dense_1 (Dense)                 │ (None, 150)            │        30,150 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dropout_1 (Dropout)             │ (None, 150)            │             0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dense_2 (Dense)                 │ (None, 10)             │         1,510 │
└─────────────────────────────────┴────────────────────────┴───────────────┘
 Total params: 370,676 (1.41 MB)
 Trainable params: 370,676 (1.41 MB)
 Non-trainable params: 0 (0.00 B)

Training loop#

fit() below compiles the model with the Adam optimizer and the cross-entropy loss, trains it, and uses a callback to restore the weights from the epoch with the best validation accuracy.

def fit(model, epochs, learning_rate=1e-3, train=None, validation=None, verbose=True):
    # Train with Adam and restore the epoch with the best validation accuracy.
    model.compile(
        optimizer=keras.optimizers.Adam(learning_rate),
        loss=keras.losses.SparseCategoricalCrossentropy(from_logits=True),
        metrics=["accuracy"],
    )
    keep_best = keras.callbacks.EarlyStopping(
        monitor="val_accuracy", mode="max", patience=epochs, restore_best_weights=True
    )
    history = model.fit(
        *(train or (x_train, y_train)),
        validation_data=validation or (x_validation, y_validation),
        epochs=epochs,
        batch_size=BATCH_SIZE,
        callbacks=[keep_best],
        verbose=2 if verbose else 0,
    ).history
    if verbose:
        best_epoch = int(np.argmax(history["val_accuracy"]))
        print(
            f"restored epoch {best_epoch + 1} "
            f"(validation accuracy {history['val_accuracy'][best_epoch]:.4f})"
        )
    return history


def predict_classes(model, images):
    return model.predict(images, batch_size=BATCH_SIZE, verbose=0).argmax(axis=1)



def plot_history(history, title=None):
    epochs = np.arange(1, len(history["loss"]) + 1)
    best = epochs[int(np.argmax(history["val_accuracy"]))]
    fig, axes = plt.subplots(1, 2, figsize=(10, 3.4))
    for axis, key, label in zip(axes, ("loss", "accuracy"), ("Cross-entropy loss", "Accuracy")):
        axis.plot(epochs, history[key], marker="o", label="training")
        axis.plot(epochs, history[f"val_{key}"], marker="o", label="validation")
        axis.axvline(best, color="0.6", linestyle=":", label="restored epoch")
        axis.set(title=label, xlabel="Epoch")
        axis.grid(alpha=0.25)
    axes[0].legend()
    if title:
        fig.suptitle(title)
    plt.tight_layout()
    plt.show()
history = fit(model, EPOCHS)
plot_history(history)
Epoch 1/5
196/196 - 13s - 66ms/step - accuracy: 0.8675 - loss: 0.4210 - val_accuracy: 0.9745 - val_loss: 0.0874
Epoch 2/5
196/196 - 13s - 67ms/step - accuracy: 0.9666 - loss: 0.1122 - val_accuracy: 0.9806 - val_loss: 0.0680
Epoch 3/5
196/196 - 13s - 66ms/step - accuracy: 0.9769 - loss: 0.0786 - val_accuracy: 0.9855 - val_loss: 0.0533
Epoch 4/5
196/196 - 13s - 66ms/step - accuracy: 0.9829 - loss: 0.0595 - val_accuracy: 0.9857 - val_loss: 0.0443
Epoch 5/5
196/196 - 14s - 69ms/step - accuracy: 0.9852 - loss: 0.0514 - val_accuracy: 0.9868 - val_loss: 0.0443
restored epoch 5 (validation accuracy 0.9868)
../../../../_images/f5da55c25afe292197167b457f27fc15c35a1b449cc3c2813cb0ccd34a57bab2.png

Evaluate on the test set#

The confusion matrix counts, for each true digit (rows), how often each digit was predicted (columns). The images below are the first twelve test mistakes.

test_predictions = predict_classes(model, x_test)
test_accuracy = accuracy_score(y_test, test_predictions)
cm = confusion_matrix(y_test, test_predictions, labels=np.arange(10))
print(f"test accuracy: {test_accuracy:.4f}")
print(
    classification_report(
        y_test, test_predictions, labels=np.arange(10),
        target_names=CLASS_NAMES, digits=3, zero_division=0,
    )
)

fig, ax = plt.subplots(figsize=(7, 6))
ConfusionMatrixDisplay(cm, display_labels=CLASS_NAMES).plot(
    ax=ax, colorbar=False, values_format="d", xticks_rotation=45
)
ax.set_title("Test confusion matrix (rows: true class)")
plt.show()

mistakes = np.flatnonzero(test_predictions != y_test)[:12]
fig, axes = plt.subplots(2, 6, figsize=(12, 4.6))
for axis in axes.flat:
    axis.axis("off")
for axis, index in zip(axes.flat, mistakes):
    image = as_image(x_test[index])
    axis.imshow(image, cmap="gray" if image.ndim == 2 else None)
    axis.set_title(f"{CLASS_NAMES[y_test[index]]} as {CLASS_NAMES[test_predictions[index]]}", fontsize=9)
fig.suptitle("First twelve test mistakes (true as predicted)")
plt.tight_layout()
plt.show()
test accuracy: 0.9900
              precision    recall  f1-score   support

           0      0.982     0.997     0.989       980
           1      0.994     0.999     0.996      1135
           2      0.988     0.993     0.991      1032
           3      0.992     0.990     0.991      1010
           4      0.997     0.990     0.993       982
           5      0.991     0.984     0.988       892
           6      0.998     0.982     0.990       958
           7      0.977     0.993     0.985      1028
           8      0.993     0.987     0.990       974
           9      0.989     0.982     0.986      1009

    accuracy                          0.990     10000
   macro avg      0.990     0.990     0.990     10000
weighted avg      0.990     0.990     0.990     10000
../../../../_images/97076235b3490d5db6df15329d940ffebc11b35ed640203b1a4db91325c7638e.png ../../../../_images/93fe312f6ac3376bfc49f1eecc6a0ce7a59b96943eb25f55ec9b2dbff9f1b172.png

What the results show#

The convolutional network misclassifies roughly one test digit in a hundred, fewer than half the errors of the dense network in the Neural Networks chapter after the same number of epochs. Weight sharing is the reason: a filter that detects a stroke in one corner detects it everywhere. Many of the remaining mistakes are digits a person would also hesitate over.

Try it yourself in Keras#

Change one choice at a time and keep the data split and evaluation unchanged:

  • change the first convolution from 32 to 64 filters and compare accuracy with parameter count.

  • compare 3×3 and 5×5 kernels; the dense layer’s input size changes, so print the feature-map shape first.

  • remove the dropout layers and watch the gap between training and validation loss.