CIFAR-10 Transfer Learning with PyTorch#
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 = {'torch': 'torch', 'torchvision': 'torchvision'}
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
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()
Normalize for VGG16 and define the trainable head#
from torchvision.models import VGG16_Weights, vgg16
mean = np.asarray([0.485, 0.456, 0.406], dtype=np.float32)[:, None, None]
std = np.asarray([0.229, 0.224, 0.225], dtype=np.float32)[:, None, None]
x_train = ((x_train - mean) / std).astype(np.float32)
x_validation = ((x_validation - mean) / std).astype(np.float32)
x_test_images = x_test.copy()
x_test = ((x_test - mean) / std).astype(np.float32)
class TransferClassifier(nn.Module):
def __init__(self):
super().__init__()
source = vgg16(weights=VGG16_Weights.DEFAULT)
self.features = source.features
for parameter in self.features.parameters():
parameter.requires_grad = False
self.pool = nn.AdaptiveAvgPool2d((1, 1))
self.eval()
def forward(self, values):
return torch.flatten(self.pool(self.features(values)), 1)
feature_extractor = TransferClassifier().to(DEVICE)
def extract_features(images):
loader = DataLoader(
TensorDataset(torch.from_numpy(images)),
batch_size=BATCH_SIZE,
shuffle=False,
)
batches = []
with torch.no_grad():
for (batch,) in loader:
batches.append(feature_extractor(batch.to(DEVICE)).cpu().numpy())
return np.concatenate(batches).astype(np.float32)
print("extracting frozen VGG16 features once per split")
x_train = extract_features(x_train)
x_validation = extract_features(x_validation)
x_test = extract_features(x_test)
model = nn.Sequential(
nn.Linear(512, 512), nn.ReLU(), nn.Dropout(0.25), nn.Linear(512, 256), nn.ReLU(), nn.Dropout(0.25),
nn.Linear(256, 10),
)
print(
"trainable parameters:",
f"{sum(value.numel() for value in model.parameters() if value.requires_grad):,}",
)
extracting frozen VGG16 features once per split
trainable parameters: 396,554
Final test evidence#
model.eval()
prediction_parts = []
with torch.no_grad():
for features, _ in test_loader:
prediction_parts.append(model(features.to(DEVICE)).argmax(1).cpu().numpy())
test_predictions = np.concatenate(prediction_parts)
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.6974
precision recall f1-score support
0 0.722 0.742 0.732 1000
1 0.805 0.756 0.780 1000
2 0.672 0.570 0.617 1000
3 0.548 0.587 0.567 1000
4 0.653 0.647 0.650 1000
5 0.647 0.623 0.635 1000
6 0.670 0.774 0.718 1000
7 0.755 0.701 0.727 1000
8 0.799 0.773 0.786 1000
9 0.724 0.801 0.760 1000
accuracy 0.697 10000
macro avg 0.699 0.697 0.697 10000
weighted avg 0.699 0.697 0.697 10000
HELIO_RESULT {"confusion_shape": [10, 10], "split_signature": "bfa7941b2b58be94", "test_accuracy": 0.6974}
Try it yourself#
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.