455 lines
17 KiB
Python
455 lines
17 KiB
Python
"""Computer vision example on Transfer Learning.
|
|
|
|
This computer vision example illustrates how one could fine-tune a pre-trained
|
|
network (by default, a ResNet50 is used) using pytorch-lightning. For the sake
|
|
of this example, the 'cats and dogs dataset' (~60MB, see `DATA_URL` below) and
|
|
the proposed network (denoted by `TransferLearningModel`, see below) is
|
|
trained for 15 epochs. The training consists in three stages. From epoch 0 to
|
|
4, the feature extractor (the pre-trained network) is frozen except maybe for
|
|
the BatchNorm layers (depending on whether `train_bn = True`). The BatchNorm
|
|
layers (if `train_bn = True`) and the parameters of the classifier are trained
|
|
as a single parameters group with lr = 1e-2. From epoch 5 to 9, the last two
|
|
layer groups of the pre-trained network are unfrozen and added to the
|
|
optimizer as a new parameter group with lr = 1e-4 (while lr = 1e-3 for the
|
|
first parameter group in the optimizer). Eventually, from epoch 10, all the
|
|
remaining layer groups of the pre-trained network are unfrozen and added to
|
|
the optimizer as a third parameter group. From epoch 10, the parameters of the
|
|
pre-trained network are trained with lr = 1e-5 while those of the classifier
|
|
are trained with lr = 1e-4.
|
|
|
|
Note:
|
|
See: https://pytorch.org/tutorials/beginner/transfer_learning_tutorial.html
|
|
"""
|
|
|
|
import argparse
|
|
from collections import OrderedDict
|
|
from pathlib import Path
|
|
from tempfile import TemporaryDirectory
|
|
from typing import Optional, Generator, Union
|
|
|
|
import torch
|
|
import torch.nn.functional as F
|
|
from torch import optim
|
|
from torch.nn import Module
|
|
from torch.optim.lr_scheduler import MultiStepLR
|
|
from torch.optim.optimizer import Optimizer
|
|
from torch.utils.data import DataLoader
|
|
from torchvision import models
|
|
from torchvision import transforms
|
|
from torchvision.datasets import ImageFolder
|
|
from torchvision.datasets.utils import download_and_extract_archive
|
|
|
|
import pytorch_lightning as pl
|
|
from pytorch_lightning import _logger as log
|
|
|
|
BN_TYPES = (torch.nn.BatchNorm1d, torch.nn.BatchNorm2d, torch.nn.BatchNorm3d)
|
|
DATA_URL = 'https://storage.googleapis.com/mledu-datasets/cats_and_dogs_filtered.zip'
|
|
|
|
|
|
# --- Utility functions ---
|
|
|
|
|
|
def _make_trainable(module: Module) -> None:
|
|
"""Unfreezes a given module.
|
|
|
|
Args:
|
|
module: The module to unfreeze
|
|
"""
|
|
for param in module.parameters():
|
|
param.requires_grad = True
|
|
module.train()
|
|
|
|
|
|
def _recursive_freeze(module: Module,
|
|
train_bn: bool = True) -> None:
|
|
"""Freezes the layers of a given module.
|
|
|
|
Args:
|
|
module: The module to freeze
|
|
train_bn: If True, leave the BatchNorm layers in training mode
|
|
"""
|
|
children = list(module.children())
|
|
if not children:
|
|
if not (isinstance(module, BN_TYPES) and train_bn):
|
|
for param in module.parameters():
|
|
param.requires_grad = False
|
|
module.eval()
|
|
else:
|
|
# Make the BN layers trainable
|
|
_make_trainable(module)
|
|
else:
|
|
for child in children:
|
|
_recursive_freeze(module=child, train_bn=train_bn)
|
|
|
|
|
|
def freeze(module: Module,
|
|
n: Optional[int] = None,
|
|
train_bn: bool = True) -> None:
|
|
"""Freezes the layers up to index n (if n is not None).
|
|
|
|
Args:
|
|
module: The module to freeze (at least partially)
|
|
n: Max depth at which we stop freezing the layers. If None, all
|
|
the layers of the given module will be frozen.
|
|
train_bn: If True, leave the BatchNorm layers in training mode
|
|
"""
|
|
children = list(module.children())
|
|
n_max = len(children) if n is None else int(n)
|
|
|
|
for child in children[:n_max]:
|
|
_recursive_freeze(module=child, train_bn=train_bn)
|
|
|
|
for child in children[n_max:]:
|
|
_make_trainable(module=child)
|
|
|
|
|
|
def filter_params(module: Module,
|
|
train_bn: bool = True) -> Generator:
|
|
"""Yields the trainable parameters of a given module.
|
|
|
|
Args:
|
|
module: A given module
|
|
train_bn: If True, leave the BatchNorm layers in training mode
|
|
|
|
Returns:
|
|
Generator
|
|
"""
|
|
children = list(module.children())
|
|
if not children:
|
|
if not (isinstance(module, BN_TYPES) and train_bn):
|
|
for param in module.parameters():
|
|
if param.requires_grad:
|
|
yield param
|
|
else:
|
|
for child in children:
|
|
for param in filter_params(module=child, train_bn=train_bn):
|
|
yield param
|
|
|
|
|
|
def _unfreeze_and_add_param_group(module: Module,
|
|
optimizer: Optimizer,
|
|
lr: Optional[float] = None,
|
|
train_bn: bool = True):
|
|
"""Unfreezes a module and adds its parameters to an optimizer."""
|
|
_make_trainable(module)
|
|
params_lr = optimizer.param_groups[0]['lr'] if lr is None else float(lr)
|
|
optimizer.add_param_group(
|
|
{'params': filter_params(module=module, train_bn=train_bn),
|
|
'lr': params_lr / 10.,
|
|
})
|
|
|
|
|
|
# --- Pytorch-lightning module ---
|
|
|
|
|
|
class TransferLearningModel(pl.LightningModule):
|
|
"""Transfer Learning with pre-trained ResNet50.
|
|
|
|
Args:
|
|
hparams: Model hyperparameters
|
|
dl_path: Path where the data will be downloaded
|
|
"""
|
|
def __init__(self,
|
|
dl_path: Union[str, Path],
|
|
backbone: str = 'resnet50',
|
|
train_bn: bool = True,
|
|
milestones: tuple = (5, 10),
|
|
batch_size: int = 8,
|
|
lr: float = 1e-2,
|
|
lr_scheduler_gamma: float = 1e-1,
|
|
num_workers: int = 6, **kwargs) -> None:
|
|
super().__init__()
|
|
self.dl_path = dl_path
|
|
self.backbone = backbone
|
|
self.train_bn = train_bn
|
|
self.milestones = milestones
|
|
self.batch_size = batch_size
|
|
self.lr = lr
|
|
self.lr_scheduler_gamma = lr_scheduler_gamma
|
|
self.num_workers = num_workers
|
|
|
|
self.dl_path = dl_path
|
|
self.__build_model()
|
|
|
|
def __build_model(self):
|
|
"""Define model layers & loss."""
|
|
|
|
# 1. Load pre-trained network:
|
|
model_func = getattr(models, self.backbone)
|
|
backbone = model_func(pretrained=True)
|
|
|
|
_layers = list(backbone.children())[:-1]
|
|
self.feature_extractor = torch.nn.Sequential(*_layers)
|
|
freeze(module=self.feature_extractor, train_bn=self.train_bn)
|
|
|
|
# 2. Classifier:
|
|
_fc_layers = [torch.nn.Linear(2048, 256),
|
|
torch.nn.Linear(256, 32),
|
|
torch.nn.Linear(32, 1)]
|
|
self.fc = torch.nn.Sequential(*_fc_layers)
|
|
|
|
# 3. Loss:
|
|
self.loss_func = F.binary_cross_entropy_with_logits
|
|
|
|
def forward(self, x):
|
|
"""Forward pass. Returns logits."""
|
|
|
|
# 1. Feature extraction:
|
|
x = self.feature_extractor(x)
|
|
x = x.squeeze(-1).squeeze(-1)
|
|
|
|
# 2. Classifier (returns logits):
|
|
x = self.fc(x)
|
|
|
|
return x
|
|
|
|
def loss(self, logits, labels):
|
|
return self.loss_func(input=logits, target=labels)
|
|
|
|
def train(self, mode=True):
|
|
super().train(mode=mode)
|
|
|
|
epoch = self.current_epoch
|
|
if epoch < self.milestones[0] and mode:
|
|
# feature extractor is frozen (except for BatchNorm layers)
|
|
freeze(module=self.feature_extractor,
|
|
train_bn=self.train_bn)
|
|
|
|
elif self.milestones[0] <= epoch < self.milestones[1] and mode:
|
|
# Unfreeze last two layers of the feature extractor
|
|
freeze(module=self.feature_extractor,
|
|
n=-2,
|
|
train_bn=self.train_bn)
|
|
|
|
def on_epoch_start(self):
|
|
"""Use `on_epoch_start` to unfreeze layers progressively."""
|
|
optimizer = self.trainer.optimizers[0]
|
|
if self.current_epoch == self.milestones[0]:
|
|
_unfreeze_and_add_param_group(module=self.feature_extractor[-2:],
|
|
optimizer=optimizer,
|
|
train_bn=self.train_bn)
|
|
|
|
elif self.current_epoch == self.milestones[1]:
|
|
_unfreeze_and_add_param_group(module=self.feature_extractor[:-2],
|
|
optimizer=optimizer,
|
|
train_bn=self.train_bn)
|
|
|
|
def training_step(self, batch, batch_idx):
|
|
|
|
# 1. Forward pass:
|
|
x, y = batch
|
|
y_logits = self.forward(x)
|
|
y_true = y.view((-1, 1)).type_as(x)
|
|
y_bin = torch.ge(y_logits, 0)
|
|
|
|
# 2. Compute loss & accuracy:
|
|
train_loss = self.loss(y_logits, y_true)
|
|
num_correct = torch.eq(y_bin.view(-1), y_true.view(-1)).sum()
|
|
|
|
# 3. Outputs:
|
|
tqdm_dict = {'train_loss': train_loss}
|
|
output = OrderedDict({'loss': train_loss,
|
|
'num_correct': num_correct,
|
|
'log': tqdm_dict,
|
|
'progress_bar': tqdm_dict})
|
|
|
|
return output
|
|
|
|
def training_epoch_end(self, outputs):
|
|
"""Compute and log training loss and accuracy at the epoch level."""
|
|
|
|
train_loss_mean = torch.stack([output['loss']
|
|
for output in outputs]).mean()
|
|
train_acc_mean = torch.stack([output['num_correct']
|
|
for output in outputs]).sum().float()
|
|
train_acc_mean /= (len(outputs) * self.batch_size)
|
|
return {'log': {'train_loss': train_loss_mean,
|
|
'train_acc': train_acc_mean,
|
|
'step': self.current_epoch}}
|
|
|
|
def validation_step(self, batch, batch_idx):
|
|
|
|
# 1. Forward pass:
|
|
x, y = batch
|
|
y_logits = self.forward(x)
|
|
y_true = y.view((-1, 1)).type_as(x)
|
|
y_bin = torch.ge(y_logits, 0)
|
|
|
|
# 2. Compute loss & accuracy:
|
|
val_loss = self.loss(y_logits, y_true)
|
|
num_correct = torch.eq(y_bin.view(-1), y_true.view(-1)).sum()
|
|
|
|
return {'val_loss': val_loss,
|
|
'num_correct': num_correct}
|
|
|
|
def validation_epoch_end(self, outputs):
|
|
"""Compute and log validation loss and accuracy at the epoch level."""
|
|
|
|
val_loss_mean = torch.stack([output['val_loss']
|
|
for output in outputs]).mean()
|
|
val_acc_mean = torch.stack([output['num_correct']
|
|
for output in outputs]).sum().float()
|
|
val_acc_mean /= (len(outputs) * self.batch_size)
|
|
return {'log': {'val_loss': val_loss_mean,
|
|
'val_acc': val_acc_mean,
|
|
'step': self.current_epoch}}
|
|
|
|
def configure_optimizers(self):
|
|
optimizer = optim.Adam(filter(lambda p: p.requires_grad,
|
|
self.parameters()),
|
|
lr=self.lr)
|
|
|
|
scheduler = MultiStepLR(optimizer,
|
|
milestones=self.milestones,
|
|
gamma=self.lr_scheduler_gamma)
|
|
|
|
return [optimizer], [scheduler]
|
|
|
|
def prepare_data(self):
|
|
"""Download images and prepare images datasets."""
|
|
download_and_extract_archive(url=DATA_URL,
|
|
download_root=self.dl_path,
|
|
remove_finished=True)
|
|
|
|
def setup(self, stage: str):
|
|
data_path = Path(self.dl_path).joinpath('cats_and_dogs_filtered')
|
|
|
|
# 2. Load the data + preprocessing & data augmentation
|
|
normalize = transforms.Normalize(mean=[0.485, 0.456, 0.406],
|
|
std=[0.229, 0.224, 0.225])
|
|
|
|
train_dataset = ImageFolder(root=data_path.joinpath('train'),
|
|
transform=transforms.Compose([
|
|
transforms.Resize((224, 224)),
|
|
transforms.RandomHorizontalFlip(),
|
|
transforms.ToTensor(),
|
|
normalize,
|
|
]))
|
|
|
|
valid_dataset = ImageFolder(root=data_path.joinpath('validation'),
|
|
transform=transforms.Compose([
|
|
transforms.Resize((224, 224)),
|
|
transforms.ToTensor(),
|
|
normalize,
|
|
]))
|
|
|
|
self.train_dataset = train_dataset
|
|
self.valid_dataset = valid_dataset
|
|
|
|
def __dataloader(self, train):
|
|
"""Train/validation loaders."""
|
|
|
|
_dataset = self.train_dataset if train else self.valid_dataset
|
|
loader = DataLoader(dataset=_dataset,
|
|
batch_size=self.batch_size,
|
|
num_workers=self.num_workers,
|
|
shuffle=True if train else False)
|
|
|
|
return loader
|
|
|
|
def train_dataloader(self):
|
|
log.info('Training data loaded.')
|
|
return self.__dataloader(train=True)
|
|
|
|
def val_dataloader(self):
|
|
log.info('Validation data loaded.')
|
|
return self.__dataloader(train=False)
|
|
|
|
@staticmethod
|
|
def add_model_specific_args(parent_parser):
|
|
parser = argparse.ArgumentParser(parents=[parent_parser])
|
|
parser.add_argument('--backbone',
|
|
default='resnet50',
|
|
type=str,
|
|
metavar='BK',
|
|
help='Name (as in ``torchvision.models``) of the feature extractor')
|
|
parser.add_argument('--epochs',
|
|
default=15,
|
|
type=int,
|
|
metavar='N',
|
|
help='total number of epochs',
|
|
dest='nb_epochs')
|
|
parser.add_argument('--batch-size',
|
|
default=8,
|
|
type=int,
|
|
metavar='B',
|
|
help='batch size',
|
|
dest='batch_size')
|
|
parser.add_argument('--gpus',
|
|
type=int,
|
|
default=1,
|
|
help='number of gpus to use')
|
|
parser.add_argument('--lr',
|
|
'--learning-rate',
|
|
default=1e-2,
|
|
type=float,
|
|
metavar='LR',
|
|
help='initial learning rate',
|
|
dest='lr')
|
|
parser.add_argument('--lr-scheduler-gamma',
|
|
default=1e-1,
|
|
type=float,
|
|
metavar='LRG',
|
|
help='Factor by which the learning rate is reduced at each milestone',
|
|
dest='lr_scheduler_gamma')
|
|
parser.add_argument('--num-workers',
|
|
default=6,
|
|
type=int,
|
|
metavar='W',
|
|
help='number of CPU workers',
|
|
dest='num_workers')
|
|
parser.add_argument('--train-bn',
|
|
default=True,
|
|
type=bool,
|
|
metavar='TB',
|
|
help='Whether the BatchNorm layers should be trainable',
|
|
dest='train_bn')
|
|
parser.add_argument('--milestones',
|
|
default=[5, 10],
|
|
type=list,
|
|
metavar='M',
|
|
help='List of two epochs milestones')
|
|
return parser
|
|
|
|
|
|
def main(args: argparse.Namespace) -> None:
|
|
"""Train the model.
|
|
|
|
Args:
|
|
args: Model hyper-parameters
|
|
|
|
Note:
|
|
For the sake of the example, the images dataset will be downloaded
|
|
to a temporary directory.
|
|
"""
|
|
|
|
with TemporaryDirectory(dir=args.root_data_path) as tmp_dir:
|
|
|
|
model = TransferLearningModel(dl_path=tmp_dir, **vars(args))
|
|
|
|
trainer = pl.Trainer(
|
|
weights_summary=None,
|
|
progress_bar_refresh_rate=1,
|
|
num_sanity_val_steps=0,
|
|
gpus=args.gpus,
|
|
min_epochs=args.nb_epochs,
|
|
max_epochs=args.nb_epochs)
|
|
|
|
trainer.fit(model)
|
|
|
|
|
|
def get_args() -> argparse.Namespace:
|
|
parent_parser = argparse.ArgumentParser(add_help=False)
|
|
parent_parser.add_argument('--root-data-path',
|
|
metavar='DIR',
|
|
type=str,
|
|
default=Path.cwd().as_posix(),
|
|
help='Root directory where to download the data',
|
|
dest='root_data_path')
|
|
parser = TransferLearningModel.add_model_specific_args(parent_parser)
|
|
return parser.parse_args()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main(get_args())
|