CIFAR-10 Transfer Learning with Keras 3#

This complete workflow freezes an ImageNet-pretrained VGG16 feature extractor and trains a CIFAR-10 classifier. The split, head intent, ten-epoch maximum, and evaluation are aligned across frameworks.

A network connection is required the first time the pretrained weights are cached. To run the example more quickly, set EPOCHS to 1 or 2 in the data cell.

Runtime dependency check#

import importlib.util
import subprocess
import sys
from pathlib import Path

REQUIRED_RUNTIME = {'keras': 'keras', 'torch': 'torch'}
COLAB_EXTRAS = {}
missing_required = [
    package for module, package in REQUIRED_RUNTIME.items()
    if importlib.util.find_spec(module) is None
]
missing_extras = [
    package for module, package in COLAB_EXTRAS.items()
    if importlib.util.find_spec(module) is None
]
if missing_extras and "google.colab" in sys.modules:
    subprocess.check_call(
        [sys.executable, "-m", "pip", "install", "-q", *missing_extras]
    )
    missing_extras = []
if missing_required or missing_extras:
    missing = ", ".join(missing_required + missing_extras)
    raise RuntimeError(
        f"Missing notebook dependencies: {missing}. Locally run "
        "`uv sync --group notebooks`; in Colab restart the runtime if an "
        "installation cell just changed the environment."
    )
print("runtime dependency check passed")
runtime dependency check passed
%matplotlib inline

Keras-to-PyTorch crosswalk#

This optional implementation uses the same Torch runtime as the canonical native PyTorch path. Keras compile() selects the optimizer and loss, fit() owns the explicit batch and epoch loop, and callbacks provide high-level training control. Data, splits, budgets, evidence, and scientific conclusions remain aligned with the canonical PyTorch workflow.

Imports and shared CIFAR-10 split#

import hashlib
import json
import os

os.environ["KERAS_BACKEND"] = "torch"

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()}; "
    f"PyTorch {torch.__version__}"
)
Keras 3.15.1; backend=torch; PyTorch 2.13.0
SEED = 42
EPOCHS = 10  # Reduce to 1 or 2 for a quicker run.
BATCH_SIZE = 256

keras.utils.set_random_seed(SEED)
torch.use_deterministic_algorithms(True)

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

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)


assert set(train_indices).isdisjoint(validation_indices)
print(f"split signature: {split_signature}")
print(
    f"train={len(y_train):,}, validation={len(y_validation):,}, "
    f"test={len(y_test):,}, epochs={EPOCHS}"
)
split signature: bfa7941b2b58be94
train=45,000, validation=5,000, test=10,000, epochs=10

Inspect class coverage#

training_counts = np.bincount(y_train, minlength=10)
fig, ax = plt.subplots(figsize=(8, 3.2))
ax.bar(np.arange(10), training_counts)
ax.set(
    title="Training-set class distribution",
    xlabel="Class",
    ylabel="Samples",
    xticks=np.arange(10),
)
plt.show()
../../../../_images/d0b14d8ebc10670a8e62ff8874f7ea8c66b26c49b02f43ea958c0b777b3e4b64.png

Preprocess for VGG16 and define the trainable head#

x_train = keras.applications.vgg16.preprocess_input(x_train * 255.0)
x_validation = keras.applications.vgg16.preprocess_input(x_validation * 255.0)
x_test_images = x_test.copy()
x_test = keras.applications.vgg16.preprocess_input(x_test * 255.0)
base_model = keras.applications.VGG16(
    include_top=False, weights="imagenet", input_shape=(32, 32, 3)
)
base_model.trainable = False
print("extracting frozen VGG16 features once per split")
x_train = base_model.predict(
    x_train, batch_size=BATCH_SIZE, verbose=1
).reshape(len(x_train), -1)
x_validation = base_model.predict(
    x_validation, batch_size=BATCH_SIZE, verbose=1
).reshape(len(x_validation), -1)
x_test = base_model.predict(
    x_test, batch_size=BATCH_SIZE, verbose=1
).reshape(len(x_test), -1)
model = keras.Sequential(
    [
        keras.Input(shape=(x_train.shape[1],)),
        layers.Dense(512, activation='relu'), layers.Dropout(0.25), layers.Dense(256, activation='relu'), layers.Dropout(0.25),
        layers.Dense(10),
    ],
    name="vgg16_frozen_features",
)
model.compile(
    optimizer=keras.optimizers.Adam(1e-3),
    loss=keras.losses.SparseCategoricalCrossentropy(from_logits=True),
    metrics=["accuracy"],
)
print(f"trainable parameters: {sum(np.prod(v.shape) for v in model.trainable_weights):,}")
model.summary()
extracting frozen VGG16 features once per split
  1/176 ━━━━━━━━━━━━━━━━━━━━ 52s 298ms/step

  3/176 ━━━━━━━━━━━━━━━━━━━━ 7s 45ms/step  

  5/176 ━━━━━━━━━━━━━━━━━━━━ 7s 45ms/step

  7/176 ━━━━━━━━━━━━━━━━━━━━ 7s 45ms/step

  9/176 ━━━━━━━━━━━━━━━━━━━ 7s 46ms/step

 10/176 ━━━━━━━━━━━━━━━━━━━ 7s 47ms/step

 11/176 ━━━━━━━━━━━━━━━━━━━ 7s 47ms/step

 13/176 ━━━━━━━━━━━━━━━━━━━ 7s 48ms/step

 15/176 ━━━━━━━━━━━━━━━━━━━ 7s 48ms/step

 17/176 ━━━━━━━━━━━━━━━━━━━ 7s 47ms/step

 19/176 ━━━━━━━━━━━━━━━━━━━━ 7s 48ms/step

 21/176 ━━━━━━━━━━━━━━━━━━━━ 7s 47ms/step

 22/176 ━━━━━━━━━━━━━━━━━━━━ 7s 48ms/step

 24/176 ━━━━━━━━━━━━━━━━━━━━ 7s 48ms/step

 26/176 ━━━━━━━━━━━━━━━━━━━━ 7s 47ms/step

 28/176 ━━━━━━━━━━━━━━━━━━━━ 6s 47ms/step

 30/176 ━━━━━━━━━━━━━━━━━━━━ 6s 47ms/step

 32/176 ━━━━━━━━━━━━━━━━━━━━ 6s 47ms/step

 34/176 ━━━━━━━━━━━━━━━━━━━━ 6s 48ms/step

 36/176 ━━━━━━━━━━━━━━━━━━━━ 6s 48ms/step

 38/176 ━━━━━━━━━━━━━━━━━━━━ 6s 47ms/step

 40/176 ━━━━━━━━━━━━━━━━━━━━ 6s 47ms/step

 42/176 ━━━━━━━━━━━━━━━━━━━━ 6s 47ms/step

 44/176 ━━━━━━━━━━━━━━━━━━━━ 6s 47ms/step

 45/176 ━━━━━━━━━━━━━━━━━━━━ 6s 47ms/step

 47/176 ━━━━━━━━━━━━━━━━━━━━ 6s 47ms/step

 48/176 ━━━━━━━━━━━━━━━━━━━━ 6s 48ms/step

 50/176 ━━━━━━━━━━━━━━━━━━━━ 5s 47ms/step

 52/176 ━━━━━━━━━━━━━━━━━━━━ 5s 47ms/step

 54/176 ━━━━━━━━━━━━━━━━━━━━ 5s 47ms/step

 56/176 ━━━━━━━━━━━━━━━━━━━━ 5s 47ms/step

 58/176 ━━━━━━━━━━━━━━━━━━━━ 5s 47ms/step

 60/176 ━━━━━━━━━━━━━━━━━━━━ 5s 47ms/step

 62/176 ━━━━━━━━━━━━━━━━━━━━ 5s 47ms/step

 64/176 ━━━━━━━━━━━━━━━━━━━━ 5s 47ms/step

 66/176 ━━━━━━━━━━━━━━━━━━━━ 5s 47ms/step

 68/176 ━━━━━━━━━━━━━━━━━━━━ 5s 47ms/step

 70/176 ━━━━━━━━━━━━━━━━━━━━ 4s 47ms/step

 72/176 ━━━━━━━━━━━━━━━━━━━━ 4s 47ms/step

 74/176 ━━━━━━━━━━━━━━━━━━━━ 4s 47ms/step

 76/176 ━━━━━━━━━━━━━━━━━━━━ 4s 47ms/step

 78/176 ━━━━━━━━━━━━━━━━━━━━ 4s 47ms/step

 80/176 ━━━━━━━━━━━━━━━━━━━━ 4s 47ms/step

 82/176 ━━━━━━━━━━━━━━━━━━━━ 4s 47ms/step

 84/176 ━━━━━━━━━━━━━━━━━━━━ 4s 47ms/step

 86/176 ━━━━━━━━━━━━━━━━━━━━ 4s 47ms/step

 88/176 ━━━━━━━━━━━━━━━━━━━━ 4s 47ms/step

 90/176 ━━━━━━━━━━━━━━━━━━━━ 4s 47ms/step

 91/176 ━━━━━━━━━━━━━━━━━━━━ 3s 47ms/step

 93/176 ━━━━━━━━━━━━━━━━━━━━ 3s 47ms/step

 95/176 ━━━━━━━━━━━━━━━━━━━━ 3s 47ms/step

 97/176 ━━━━━━━━━━━━━━━━━━━━ 3s 47ms/step

 99/176 ━━━━━━━━━━━━━━━━━━━━ 3s 47ms/step

101/176 ━━━━━━━━━━━━━━━━━━━━ 3s 47ms/step

103/176 ━━━━━━━━━━━━━━━━━━━━ 3s 47ms/step

105/176 ━━━━━━━━━━━━━━━━━━━━ 3s 47ms/step

107/176 ━━━━━━━━━━━━━━━━━━━━ 3s 47ms/step

109/176 ━━━━━━━━━━━━━━━━━━━━ 3s 47ms/step

111/176 ━━━━━━━━━━━━━━━━━━━━ 3s 47ms/step

113/176 ━━━━━━━━━━━━━━━━━━━━ 2s 47ms/step

115/176 ━━━━━━━━━━━━━━━━━━━━ 2s 47ms/step

117/176 ━━━━━━━━━━━━━━━━━━━━ 2s 47ms/step

119/176 ━━━━━━━━━━━━━━━━━━━━ 2s 47ms/step

121/176 ━━━━━━━━━━━━━━━━━━━━ 2s 47ms/step

123/176 ━━━━━━━━━━━━━━━━━━━━ 2s 47ms/step

125/176 ━━━━━━━━━━━━━━━━━━━━ 2s 47ms/step

127/176 ━━━━━━━━━━━━━━━━━━━━ 2s 47ms/step

129/176 ━━━━━━━━━━━━━━━━━━━━ 2s 47ms/step

130/176 ━━━━━━━━━━━━━━━━━━━━ 2s 47ms/step

132/176 ━━━━━━━━━━━━━━━━━━━━ 2s 47ms/step

134/176 ━━━━━━━━━━━━━━━━━━━━ 1s 47ms/step

136/176 ━━━━━━━━━━━━━━━━━━━━ 1s 47ms/step

138/176 ━━━━━━━━━━━━━━━━━━━━ 1s 47ms/step

140/176 ━━━━━━━━━━━━━━━━━━━━ 1s 47ms/step

142/176 ━━━━━━━━━━━━━━━━━━━━ 1s 47ms/step

144/176 ━━━━━━━━━━━━━━━━━━━━ 1s 47ms/step

146/176 ━━━━━━━━━━━━━━━━━━━━ 1s 47ms/step

148/176 ━━━━━━━━━━━━━━━━━━━━ 1s 47ms/step

150/176 ━━━━━━━━━━━━━━━━━━━━ 1s 47ms/step

152/176 ━━━━━━━━━━━━━━━━━━━━ 1s 47ms/step

154/176 ━━━━━━━━━━━━━━━━━━━━ 1s 47ms/step

156/176 ━━━━━━━━━━━━━━━━━━━━ 0s 47ms/step

158/176 ━━━━━━━━━━━━━━━━━━━━ 0s 47ms/step

160/176 ━━━━━━━━━━━━━━━━━━━━ 0s 47ms/step

162/176 ━━━━━━━━━━━━━━━━━━━━ 0s 47ms/step

164/176 ━━━━━━━━━━━━━━━━━━━━ 0s 47ms/step

166/176 ━━━━━━━━━━━━━━━━━━━━ 0s 47ms/step

168/176 ━━━━━━━━━━━━━━━━━━━ 0s 47ms/step

170/176 ━━━━━━━━━━━━━━━━━━━ 0s 47ms/step

172/176 ━━━━━━━━━━━━━━━━━━━ 0s 47ms/step

174/176 ━━━━━━━━━━━━━━━━━━━ 0s 47ms/step

176/176 ━━━━━━━━━━━━━━━━━━━━ 0s 47ms/step

176/176 ━━━━━━━━━━━━━━━━━━━━ 8s 47ms/step
 1/20 ━━━━━━━━━━━━━━━━━━━ 0s 45ms/step

 3/20 ━━━━━━━━━━━━━━━━━━━━ 0s 44ms/step

 5/20 ━━━━━━━━━━━━━━━━━━━━ 0s 44ms/step

 7/20 ━━━━━━━━━━━━━━━━━━━━ 0s 45ms/step

 9/20 ━━━━━━━━━━━━━━━━━━━━ 0s 45ms/step

11/20 ━━━━━━━━━━━━━━━━━━━━ 0s 45ms/step

13/20 ━━━━━━━━━━━━━━━━━━━━ 0s 45ms/step

15/20 ━━━━━━━━━━━━━━━━━━━━ 0s 46ms/step

17/20 ━━━━━━━━━━━━━━━━━━━━ 0s 46ms/step

19/20 ━━━━━━━━━━━━━━━━━━━ 0s 46ms/step

20/20 ━━━━━━━━━━━━━━━━━━━━ 0s 46ms/step

20/20 ━━━━━━━━━━━━━━━━━━━━ 1s 46ms/step
 1/40 ━━━━━━━━━━━━━━━━━━━━ 1s 45ms/step

 3/40 ━━━━━━━━━━━━━━━━━━━ 1s 44ms/step

 4/40 ━━━━━━━━━━━━━━━━━━━━ 1s 55ms/step

 6/40 ━━━━━━━━━━━━━━━━━━━━ 1s 51ms/step

 8/40 ━━━━━━━━━━━━━━━━━━━━ 1s 49ms/step

10/40 ━━━━━━━━━━━━━━━━━━━━ 1s 48ms/step

12/40 ━━━━━━━━━━━━━━━━━━━━ 1s 47ms/step

14/40 ━━━━━━━━━━━━━━━━━━━━ 1s 47ms/step

16/40 ━━━━━━━━━━━━━━━━━━━━ 1s 46ms/step

18/40 ━━━━━━━━━━━━━━━━━━━━ 1s 46ms/step

20/40 ━━━━━━━━━━━━━━━━━━━━ 0s 46ms/step

22/40 ━━━━━━━━━━━━━━━━━━━━ 0s 46ms/step

24/40 ━━━━━━━━━━━━━━━━━━━━ 0s 46ms/step

26/40 ━━━━━━━━━━━━━━━━━━━━ 0s 46ms/step

28/40 ━━━━━━━━━━━━━━━━━━━━ 0s 46ms/step

29/40 ━━━━━━━━━━━━━━━━━━━━ 0s 47ms/step

31/40 ━━━━━━━━━━━━━━━━━━━━ 0s 47ms/step

33/40 ━━━━━━━━━━━━━━━━━━━━ 0s 47ms/step

35/40 ━━━━━━━━━━━━━━━━━━━━ 0s 46ms/step

37/40 ━━━━━━━━━━━━━━━━━━━━ 0s 46ms/step

39/40 ━━━━━━━━━━━━━━━━━━━ 0s 47ms/step

40/40 ━━━━━━━━━━━━━━━━━━━━ 2s 46ms/step
trainable parameters: 396,554
Model: "vgg16_frozen_features"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Layer (type)                     Output Shape                  Param # ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ dense (Dense)                   │ (None, 512)            │       262,656 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dropout (Dropout)               │ (None, 512)            │             0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dense_1 (Dense)                 │ (None, 256)            │       131,328 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dropout_1 (Dropout)             │ (None, 256)            │             0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dense_2 (Dense)                 │ (None, 10)             │         2,570 │
└─────────────────────────────────┴────────────────────────┴───────────────┘
 Total params: 396,554 (1.51 MB)
 Trainable params: 396,554 (1.51 MB)
 Non-trainable params: 0 (0.00 B)

Train with validation-based early stopping#

history = model.fit(
    x_train,
    y_train,
    validation_data=(x_validation, y_validation),
    epochs=EPOCHS,
    batch_size=BATCH_SIZE,
    callbacks=[
        keras.callbacks.EarlyStopping(
            monitor="val_loss", patience=2, restore_best_weights=True
        )
    ],
    verbose=2,
)
fig, axes = plt.subplots(1, 2, figsize=(10, 3.5))
axes[0].plot(history.history["loss"], label="training")
axes[0].plot(history.history["val_loss"], label="validation")
axes[0].set(title="Cross-entropy", xlabel="Epoch")
axes[1].plot(history.history["accuracy"], label="training")
axes[1].plot(history.history["val_accuracy"], label="validation")
axes[1].set(title="Accuracy", xlabel="Epoch")
for axis in axes:
    axis.legend()
plt.tight_layout()
plt.show()
Epoch 1/10
176/176 - 1s - 7ms/step - accuracy: 0.4539 - loss: 2.1779 - val_accuracy: 0.5932 - val_loss: 1.1905
Epoch 2/10
176/176 - 1s - 7ms/step - accuracy: 0.5726 - loss: 1.2374 - val_accuracy: 0.6316 - val_loss: 1.0662
Epoch 3/10
176/176 - 1s - 6ms/step - accuracy: 0.6172 - loss: 1.0987 - val_accuracy: 0.6418 - val_loss: 1.0283
Epoch 4/10
176/176 - 1s - 6ms/step - accuracy: 0.6478 - loss: 1.0073 - val_accuracy: 0.6584 - val_loss: 0.9859
Epoch 5/10
176/176 - 1s - 6ms/step - accuracy: 0.6704 - loss: 0.9387 - val_accuracy: 0.6688 - val_loss: 0.9578
Epoch 6/10
176/176 - 1s - 6ms/step - accuracy: 0.6871 - loss: 0.8803 - val_accuracy: 0.6752 - val_loss: 0.9432
Epoch 7/10
176/176 - 1s - 6ms/step - accuracy: 0.7048 - loss: 0.8276 - val_accuracy: 0.6798 - val_loss: 0.9405
Epoch 8/10
176/176 - 1s - 6ms/step - accuracy: 0.7222 - loss: 0.7740 - val_accuracy: 0.6856 - val_loss: 0.9285
Epoch 9/10
176/176 - 1s - 6ms/step - accuracy: 0.7365 - loss: 0.7370 - val_accuracy: 0.6800 - val_loss: 0.9406
Epoch 10/10
176/176 - 1s - 6ms/step - accuracy: 0.7518 - loss: 0.6928 - val_accuracy: 0.6796 - val_loss: 0.9555
../../../../_images/a2a2c500bcd9701c25f17800bf8783de402f41203bd7940fad03a58ab1172af3.png

Final test evidence#

test_logits = model.predict(x_test, batch_size=BATCH_SIZE, verbose=0)
test_predictions = test_logits.argmax(axis=1)

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),
        digits=3,
        zero_division=0,
    )
)
print(
    "HELIO_RESULT "
    + json.dumps(
        {
            "split_signature": split_signature,
            "test_accuracy": float(test_accuracy),
            "confusion_shape": list(cm.shape),
        },
        sort_keys=True,
    )
)
assert cm.shape == (10, 10)

fig, ax = plt.subplots(figsize=(7, 6))
ConfusionMatrixDisplay(cm, display_labels=np.arange(10)).plot(
    ax=ax, colorbar=False, values_format="d"
)
ax.set_title("Test confusion matrix")
plt.show()

mistakes = np.flatnonzero(test_predictions != y_test)[:12]
if len(mistakes):
    fig, axes = plt.subplots(3, 4, figsize=(9, 7))
    for axis, index in zip(axes.flat, mistakes):
        image = x_test_images[index]
        if image.shape[0] in (1, 3):
            image = np.transpose(image, (1, 2, 0))
        axis.imshow(image.squeeze(), cmap="gray" if image.squeeze().ndim == 2 else None)
        axis.set_title(f"true={y_test[index]}, pred={test_predictions[index]}")
        axis.axis("off")
    plt.tight_layout()
    plt.show()
test accuracy: 0.6738
              precision    recall  f1-score   support

           0      0.743     0.733     0.738      1000
           1      0.758     0.751     0.754      1000
           2      0.635     0.504     0.562      1000
           3      0.505     0.512     0.509      1000
           4      0.595     0.630     0.612      1000
           5      0.639     0.601     0.619      1000
           6      0.655     0.798     0.720      1000
           7      0.739     0.689     0.713      1000
           8      0.778     0.762     0.770      1000
           9      0.697     0.758     0.726      1000

    accuracy                          0.674     10000
   macro avg      0.674     0.674     0.672     10000
weighted avg      0.674     0.674     0.672     10000

HELIO_RESULT {"confusion_shape": [10, 10], "split_signature": "bfa7941b2b58be94", "test_accuracy": 0.6738}
../../../../_images/1307953d5ba6b6ee03063414af0e47a6579bbb5ec5c0a25d838283c5de234b30.png ../../../../_images/9ee82721dc5bca57bdc99d27ad97c6c9012946f205c98bb659ae3aca43caf38b.png

Try it yourself in Keras#

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

  • replace the 512→256 classifier with one 256-unit layer.

  • change the classifier dropout while keeping VGG16 frozen.

  • unfreeze only the final VGG16 block and use a smaller learning rate.