Safe lab preview
Transfer Learning Py Torch
This is a sanitized, read-only preview. Nothing executes in this page.
Read-only
Notebook preview
Transfer Learning Py Torch
> **Integrated runtime note:** This preview uses a small deterministic, rights-safe offline fixture for reproducible learning. Full-scale results require the lesson's documented dataset or model in an approved external environment.
# Pre-trained models and transfer learning
Training CNNs can take a lot of time, and a lot of data is required for that task. However, much of the time is spent to learn the best low-level filters that a network is using to extract patterns from images. A natural question arises - can we use a neural network trained on one dataset and adapt it to classifying different images without full training process?
This approach is called **transfer learning**, because we transfer some knowledge from one neural network model to another. In transfer learning, we typically start with a pre-trained model, which has been trained on some large image dataset, such as **ImageNet**. Those models can already do a good job extracting different features from generic images, and in many cases just building a classifier on top of those extracted features can yield a good result.
import torch
import torch.nn as nn
import torchvision
import torchvision.transforms as transforms
import matplotlib.pyplot as plt
from torchinfo import summary
import numpy as np
import os
# Self-contained course PyTorch computer-vision helpers.
from torchvision.transforms import ToTensor
from sklearn.datasets import load_digits
from PIL import Image
import glob
default_device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
def load_mnist(batch_size=64):
global data_train, data_test, train_loader, test_loader
course_digits = load_digits()
images = torch.as_tensor(course_digits.images, dtype=torch.float32).unsqueeze(1) / 16.0
images = torch.nn.functional.interpolate(images, size=(28, 28), mode="bilinear", align_corners=False)
labels = torch.as_tensor(course_digits.target, dtype=torch.long)
split = int(0.8 * len(images))
data_train = torch.utils.data.TensorDataset(images[:split], labels[:split])
data_test = torch.utils.data.TensorDataset(images[split:], labels[split:])
train_loader = torch.utils.data.DataLoader(data_train, batch_size=batch_size, shuffle=True)
test_loader = torch.utils.data.DataLoader(data_test, batch_size=batch_size)
return data_train, data_test
def _validate(net, dataloader, loss_fn):
net.eval()
total_loss, correct, count = 0.0, 0, 0
with torch.no_grad():
for features, labels in dataloader:
features, labels = features.to(default_device), labels.to(default_device)
output = net(features)
total_loss += loss_fn(output, labels).item() * len(labels)
correct += (output.argmax(1) == labels).sum().item()
count += len(labels)
return total_loss / max(count, 1), correct / max(count, 1)
def train(net, train_loader, test_loader, optimizer=None, lr=0.01, epochs=10, loss_fn=None):
net.to(default_device)
loss_fn = loss_fn or nn.CrossEntropyLoss()
optimizer = optimizer or torch.optim.Adam(net.parameters(), lr=lr)
history = {"train_loss": [], "train_acc": [], "val_loss": [], "val_acc": []}
for epoch in range(epochs):
net.train()
total_loss, correct, count = 0.0, 0, 0
for features, labels in train_loader:
features, labels = features.to(default_device), labels.to(default_device)
optimizer.zero_grad()
output = net(features)
loss = loss_fn(output, labels)
loss.backward()
optimizer.step()
total_loss += loss.item() * len(labels)
correct += (output.argmax(1) == labels).sum().item()
count += len(labels)
validation_loss, validation_accuracy = _validate(net, test_loader, loss_fn)
history["train_loss"].append(total_loss / max(count, 1))
history["train_acc"].append(correct / max(count, 1))
history["val_loss"].append(validation_loss)
history["val_acc"].append(validation_accuracy)
print(f"Epoch {epoch + 1}: train_acc={history['train_acc'][-1]:.3f}, val_acc={validation_accuracy:.3f}")
return history
def train_long(net, train_loader, test_loader, **kwargs):
return train(net, train_loader, test_loader, **kwargs)
def plot_results(history):
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
axes[0].plot(history["train_acc"], label="train")
axes[0].plot(history["val_acc"], label="validation")
axes[1].plot(history["train_loss"], label="train")
axes[1].plot(history["val_loss"], label="validation")
axes[0].set_title("Accuracy")
axes[1].set_title("Loss")
axes[0].legend()
axes[1].legend()
plt.show()
def plot_convolution(kernel, title=""):
convolution = nn.Conv2d(1, 1, kernel_size=3, bias=False)
with torch.no_grad():
convolution.weight.copy_(kernel.reshape(1, 1, 3, 3))
fig, axes = plt.subplots(2, 6, figsize=(10, 4))
fig.suptitle(title)
for index in range(5):
image = data_train[index][0]
axes[0, index].imshow(image[0], cmap="gray")
axes[1, index].imshow(convolution(image.unsqueeze(0))[0, 0], cmap="gray")
axes[0, index].axis("off")
axes[1, index].axis("off")
axes[0, 5].imshow(kernel, cmap="gray")
axes[0, 5].axis("off")
axes[1, 5].axis("off")
plt.show()
def display_dataset(dataset, n=10, classes=None):
count = min(n, len(dataset))
fig, axes = plt.subplots(1, count, figsize=(1.8 * count, 3))
axes = np.atleast_1d(axes)
for index in range(count):
image, label = dataset[index]
image = image.detach().cpu()
low, high = image.min(), image.max()
image = (image - low) / (high - low + 1e-7)
axes[index].imshow(np.transpose(image.numpy(), (1, 2, 0)))
axes[index].axis("off")
if classes is not None:
axes[index].set_title(classes[int(label)])
plt.show()
def check_image_dir(pattern):
invalid = []
for filename in glob.glob(pattern):
try:
with Image.open(filename) as image:
image.verify()
except (OSError, SyntaxError):
invalid.append(filename)
if invalid:
raise ValueError(f"Invalid images detected: {invalid[:5]}")
return 0## Course-authored cats-and-dogs fixture
This notebook creates a deterministic geometric image fixture authored for this edition. It exercises ImageFolder and the transfer-learning pipeline without downloading a third-party archive; use a documented, licensed dataset for a real experiment.
from pathlib import Path
course_data_dir = Path("data/course-pets")
def course_pet_image(kind, index, size=224):
rng = np.random.default_rng(10_000 + index + (0 if kind == "Cat" else 1_000))
y, x = np.mgrid[0:size, 0:size]
image = np.full((size, size, 3), 0.12, dtype="float32")
image += rng.normal(0, 0.025, image.shape).astype("float32")
cx, cy = size // 2 + rng.integers(-12, 13, size=2)
face = (x - cx) ** 2 + (y - cy) ** 2 < (size * 0.27) ** 2
if kind == "Cat":
ears = ((y < cy - size * 0.18) & (np.abs(x - cx) > size * 0.12) & (np.abs(x - cx) < size * 0.3))
shape = face | ears
color = np.array([0.85, 0.58, 0.25], dtype="float32")
else:
ears = (((x - (cx - size * 0.3)) / (size * 0.14)) ** 2 + ((y - cy) / (size * 0.25)) ** 2 < 1) | (((x - (cx + size * 0.3)) / (size * 0.14)) ** 2 + ((y - cy) / (size * 0.25)) ** 2 < 1)
shape = face | ears
color = np.array([0.35, 0.62, 0.88], dtype="float32")
image[shape] = color
return np.clip(image, 0, 1)
for class_name in ("Cat", "Dog"):
class_dir = course_data_dir / class_name
class_dir.mkdir(parents=True, exist_ok=True)
for index in range(48):
pixels = (course_pet_image(class_name, index) * 255).astype("uint8")
Image.fromarray(pixels).save(class_dir / f"{index:03}.png")
data_dir = str(course_data_dir)print(f"Prepared the deterministic course fixture at {data_dir}.")The files are generated locally, and we still validate them before building the dataset.
check_image_dir(f"{data_dir}/Cat/*.png")
check_image_dir(f"{data_dir}/Dog/*.png")Next, let's load the images into PyTorch dataset, converting them to tensors and doing some normalization. We will apply `std_normalize` transform to bring images to the range expected by pre-trained VGG network:
std_normalize = transforms.Normalize(
mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225],
)
transform = transforms.Compose([
transforms.Resize(256),
transforms.CenterCrop(224),
transforms.ToTensor(),
std_normalize,
])
dataset = torchvision.datasets.ImageFolder(data_dir, transform=transform)
train_size = int(0.8 * len(dataset))
test_size = len(dataset) - train_size
split_generator = torch.Generator().manual_seed(2026)
trainset, testset = torch.utils.data.random_split(
dataset, [train_size, test_size], generator=split_generator
)
display_dataset(dataset, classes=dataset.classes)## Pre-trained models
There are many different pre-trained models available inside `torchvision` module, and even more models can be found on the Internet. Let's see how simplest VGG-16 model can be loaded and used:
# Keep the release notebook deterministic and offline: pretrained weights
# are intentionally not fetched by the embedded execution profile.
vgg = torchvision.models.vgg16(weights=None)
sample_image = dataset[0][0].unsqueeze(0)
res = vgg(sample_image)
print(res[0].argmax())The result that we have received is a number of an `ImageNet` class, which can be looked up [here](https://gist.github.com/yrevar/942d3a0ac09ec9e5eb3a). We can use the following code to automatically load this class table and return the result:
class_map = {index: (f"course-{index:04d}", f"fixture class {index}") for index in range(1000)}
class_map[res[0].argmax().item()]Let's also see the architecture of the VGG-16 network:
summary(vgg,input_size=(1,3,224,224))In addition to the layer we already know, there is also another layer type called **Dropout**. These layers act as **regularization** technique. Regularization makes slight modifications to the learning algorithm so the model generalizes better. During training, dropout layers discard some proportion (around 30%) of the neurons in the previous layer, and training happens without them. This helps to get the optimization process out of local minima, and to distribute decisive power between different neural paths, which improves overall stability of the network.
## GPU computations
Deep neural networks, such as VGG-16 and other more modern architectures require quite a lot of computational power to run. It makes sense to use GPU acceleration, if it is available. In order to do so, we need to explicitly move all tensors involved in the computation to GPU.
The way it is normally done is to check the availability of GPU in the code, and define `device` variable that points to the computational device - either GPU or CPU.
device = 'cuda' if torch.cuda.is_available() else 'cpu'
print('Doing computations on device = {}'.format(device))
vgg.to(device)
sample_image = sample_image.to(device)
vgg(sample_image).argmax()## Extracting VGG features
If we want to use VGG-16 to extract features from our images, we need the model without final classification layers. In fact, this "feature extractor" can be obtained using `vgg.features` method:
res = vgg.features(sample_image).cpu()
plt.figure(figsize=(15,3))
plt.imshow(res.detach().view(512,-1).T)
print(res.size())The dimension of feature tensor is 512x7x7, but in order to visualize it we had to reshape it to 2D form.
Now let's try to see if those features can be used to classify images. Let's manually take some portion of images (800 in our case), and pre-compute their feature vectors. We will store the result in one big tensor called `feature_tensor`, and also labels into `label_tensor`:
bs = 8
dl = torch.utils.data.DataLoader(dataset,batch_size=bs,shuffle=True)
num = len(dataset)
feature_tensor = torch.zeros(num,512*7*7).to(device)
label_tensor = torch.zeros(num).to(device)
i = 0
for x,l in dl:
with torch.no_grad():
f = vgg.features(x.to(device))
batch_count = len(l)
feature_tensor[i:i+batch_count] = f.view(batch_count, -1)
label_tensor[i:i+batch_count] = l
i += batch_count
print('.',end='')
if i>=num:
breakNow we can define `vgg_dataset` that takes data from this tensor, split it into training and test sets using `random_split` function, and train a small one-layer dense classifier network on top of extracted features:
vgg_dataset = torch.utils.data.TensorDataset(feature_tensor,label_tensor.to(torch.long))
train_count = max(1, int(0.8 * len(vgg_dataset)))
test_count = len(vgg_dataset) - train_count
train_ds, test_ds = torch.utils.data.random_split(vgg_dataset, [train_count, test_count], generator=torch.Generator().manual_seed(2026))
train_loader = torch.utils.data.DataLoader(train_ds,batch_size=32)
test_loader = torch.utils.data.DataLoader(test_ds,batch_size=32)
net = torch.nn.Sequential(torch.nn.Linear(512*7*7,2),torch.nn.LogSoftmax(dim=1)).to(device)
history = train(net,train_loader,test_loader)The result is great, we can distinguish between a cat and a dog with almost 98% probability! However, we have only tested this approach on a small subset of all images, because manual feature extraction seems to take a lot of time.
## Transfer learning using one VGG network
We can also avoid manually pre-computing the features by using the original VGG-16 network as a whole during training. Let's look at the VGG-16 object structure:
print(vgg)You can see that the network contains:
* feature extractor (`features`), comprised of a number of convolutional and pooling layers
* average pooling layer (`avgpool`)
* final `classifier`, consisting of several dense layers, which turns 25088 input features into 1000 classes (which is the number of classes in ImageNet)
To train the end-to-end model that will classify our dataset, we need to:
* **replace the final classifier** with the one that will produce required number of classes. In our case, we can use one `Linear` layer with 25088 inputs and 2 output neurons.
* **freeze weights of convolutional feature extractor**, so that they are not trained. It is recommended to initially do this freezing, because otherwise untrained classifier layer can destroy the original pre-trained weights of convolutional extractor. Freezing weights can be accomplished by setting `requires_grad` property of all parameters to `False`
vgg.classifier = torch.nn.Linear(25088,2).to(device)
for x in vgg.features.parameters():
x.requires_grad = False
summary(vgg,(1, 3,244,244))As you can see from the summary, this model contain around 15 million total parameters, but only 50k of them are trainable - those are the weights of classification layer. That is good, because we are able to fine-tune smaller number of parameters with smaller number of examples.
Now let's train the model using our original dataset. This process will take a long time, so we will use `train_long` function that will print some intermediate results without waiting for the end of epoch. It is highly recommended to run this training on GPU-enabled compute!
trainset, testset = torch.utils.data.random_split(dataset, [max(1, int(0.8 * len(dataset))), len(dataset) - max(1, int(0.8 * len(dataset)))], generator=torch.Generator().manual_seed(42))
train_loader = torch.utils.data.DataLoader(trainset,batch_size=16)
test_loader = torch.utils.data.DataLoader(testset,batch_size=16)
train_long(vgg,train_loader,test_loader,loss_fn=torch.nn.CrossEntropyLoss(),epochs=1)It looks like we have obtained reasonably accurate cats vs. dogs classifier! Let's save it for future use!
torch.save(vgg.state_dict(), 'data/cats_dogs.pth')We can then load the model from file at any time. You may find it useful in case the next experiment destroys the model - you would not have to re-start from scratch.
vgg.load_state_dict(torch.load('data/cats_dogs.pth', map_location=device, weights_only=True))
vgg = vgg.to(device)## Fine-tuning transfer learning
In the previous section, we have trained the final classifier layer to classify images in our own dataset. However, we did not re-train the feature extractor, and our model relied on the features that the model has learned on ImageNet data. If your objects visually differ from ordinary ImageNet images, this combination of features might not work best. Thus it makes sense to start training convolutional layers as well.
To do that, we can unfreeze the convolutional filter parameters that we have previously frozen.
> **Note:** It is important that you freeze parameters first and perform several epochs of training in order to stabilize weights in the classification layer. If you immediately start training end-to-end network with unfrozen parameters, large errors are likely to destroy the pre-trained weights in the convolutional layers.
for x in vgg.features.parameters():
x.requires_grad = TrueAfter unfreezing, we can do a few more epochs of training. You can also select lower learning rate, in order to minimize the impact on the pre-trained weights. However, even with low learning rate, you can expect the accuracy to drop in the beginning of the training, until finally reaching slightly higher level than in the case of fixed weights.
> **Note:** This training happens much slower, because we need to propagate gradients back through many layers of the network! You may want to watch the first few minibatches to see the tendency, and then stop the computation.
train_long(vgg,train_loader,test_loader,loss_fn=torch.nn.CrossEntropyLoss(),epochs=1,lr=0.0001)## Other computer vision models
VGG-16 is one of the simplest computer vision architectures. `torchvision` package provides many more pre-trained networks. The most frequently used ones among those are **ResNet** architectures, developed by Microsoft, and **Inception** by Google. For example, let's explore the architecture of the simplest ResNet-18 model (ResNet is a family of models with different depth, you can try experimenting with ResNet-151 if you want to see what a really deep model looks like):
resnet = torchvision.models.resnet18()
print(resnet)As you can see, the model contains the same building blocks: feature extractor and final classifier (`fc`). This allows us to use this model in exactly the same manner as we have been using VGG-16 for transfer learning. You can try experimenting with the code above, using different ResNet models as the base model, and see how accuracy changes.
## Batch Normalization
This network contains yet another type of layer: **Batch Normalization**. The idea of batch normalization is to bring values that flow through the neural network to right interval. Usually neural networks work best when all values are in the range of [-1,1] or [0,1], and that is the reason that we scale/normalize our input data accordingly. However, during training of a deep network, it can happen that values get significantly out of this range, which makes training problematic. Batch normalization layer computes average and standard deviation for all values of the current minibatch, and uses them to normalize the signal before passing it through a neural network layer. This significantly improves the stability of deep networks.
## Takeaway
Using transfer learning, we were able to quickly put together a classifier for our custom object classification task, and achieve high accuracy. However, this example was not completely fair, because original VGG-16 network was pre-trained to recognize cats and dogs, and thus we were just reusing most of the patterns that were already present in the network. You can expect lower accuracy on more exotic domain-specific objects, such as details on production line in a plant, or different tree leaves.
You can see that more complex tasks that we are solving now require higher computational power, and cannot be easily solved on the CPU. In the next unit, we will try to use more lightweight implementation to train the same model using lower compute resources, which results in just slightly lower accuracy.
Outputs, execution counts, widgets, and active content were removed during import. Run notebooks only in an external environment you trust.
Record your practice
Optional self-reporting helps you remember what you practiced and never gates course completion.