ResNet : comparaison {torch} et pytorch

Intro

Le but est de comparer la syntaxe utilisée par les deux langages, seuls les exemples en R sont exécutés (difficultés de faire un quarto avec deux langages, voir issue ici ou de faire tourner pytorch avec {reticulate}). L’exemple d’application est un ResNet.

Pour charger installer la bibliothèque, en python, plusieurs possibilités sont données sur le site web officiel. Vous pouvez utiliser pip ou conda, avec ou sans cuda. Pour R, cela a déjà été fait à la session 2022 de finistR.

install.packages(torch)
torch::install_torch() ## si vous avez un GPU compatible avec CUDA
## torch::install_torch(type = "cpu") ## sinon

Un bon ouvrage pour démarrer : Deep Learning and Scientific Computing with R torch.

Appel des fonctions

En python, un premier exemple serait déjà d’importer la bibliothèque et de créer un tensor :

import torch
t1 = torch.tensor(1)
t1

Donnera en R :

library(torch)
t1 <- torch_tensor(1)
t1
torch_tensor
 1
[ CPUFloatType{1} ]

On comprend que la convention de nommage garde torch_ en préfix de toutes les fonctions implémentées.

Construction d’un réseau de neurone, exemple d’un ResNet

Chargement des données

On va utiliser torchvision pour importer un jeu de données connu (des images annotées). On veut appliquer des transformations sur le jeu de test, mais hélas tout n’est pas encore implémenté. On commente les transformations pas encore implementées.

transform = transforms.Compose([
    #transforms.Pad(4),
    #transforms.RandomHorizontalFlip(),
    #transforms.RandomCrop(32),
    transforms.ToTensor()])
transform <- function(img) {
  # img <- torchvision::transform_pad(img, 4) # pas implémenté
  # img <- torchvision::transform_random_horizontal_flip(img) # pas implémenté
  # img <- torchvision::transform_random_crop(img, 32) # bug sur la taille des images
  img <- torchvision::transform_to_tensor(img)
  return(img)
}

Voici le code pour charger les données en python et R :

num_samples = 1000

trainData = torchvision.datasets.CIFAR10(root="./data",
                                         train=True,
                                         transform=transform,
                                         download=True)

testData = torchvision.datasets.CIFAR10(root="./data",
                                        train=False,
                                        transform=transforms.ToTensor())

trainLoader = torch.utils.data.DataLoader(
    dataset=Subset(trainData, range(num_samples)),
    batch_size=256,
    shuffle=True)

testLoader = torch.utils.data.DataLoader(
    dataset=Subset(testData, range(num_samples)),
    batch_size=256,
    shuffle=False)
num_samples = 1000

train_data <- torchvision::cifar10_dataset(
  root = "./data",
  train = TRUE,
  transform = transform,
  download = TRUE
)

test_data <- torchvision::cifar10_dataset(
  root = "./data",
  train = FALSE,
  transform = torchvision::transform_to_tensor
)

train_loader <- dataloader(
  dataset = dataset_subset(train_data, 1:num_samples),
  batch_size = 256,
  shuffle = TRUE
)

test_loader <- dataloader(
  dataset = dataset_subset(test_data, 1:num_samples),
  batch_size = 256,
  shuffle = FALSE
)

Construction d’un block residual

Pour définir notre block residual, on crée une classe torch.nn.Module et on y définit deux méthodes : __init__ et forward : qui hérite de torch.nn.Module.

def align(num_in, num_out, stride):
    if num_in != num_out or stride > 1:
        return nn.Sequential(
            nn.Conv2d(
                num_in, num_out, kernel_size=3, stride=stride, padding=1, bias=False
                ),
            nn.BatchNorm2d(num_out)
            )
    else:
        return lambda x: x

class ResBlock(nn.Module):
    def __init__(self, num_in, num_out, stride):
        super(ResBlock, self).__init__()
        self.align = align(num_in, num_out, stride)
        self.conv1 = nn.Conv2d(num_in, num_out, kernel_size=3,
                            stride=stride, padding=1, bias=False)
        self.bn1 = nn.BatchNorm2d(num_out)
        self.relu = nn.ReLU(inplace=True)
        self.conv2 = nn.Conv2d(num_out, num_out, kernel_size=3,
                            stride=1, padding=1, bias=False)
        self.bn2 = nn.BatchNorm2d(num_out)

    def forward(self, x):
        o = self.conv1(x)
        o = self.bn1(o)
        o = self.relu(o)
        o = self.conv2(o)
        o = self.bn2(o)
        o = o + self.align(x)
        o = self.relu(o)
        return o

De la même manière, on peut créer un objet nn_module en R et y spécifier init et forward :

align <- function(num_in, num_out, stride) {
  if (num_in != num_out || stride > 1) {
    return(nn_sequential(
      nn_conv2d(
        num_in, num_out,
        kernel_size = 3, stride = stride, padding = 1,
        bias = FALSE
      ),
      nn_batch_norm2d(num_out)
    ))
  } else {
    return(function(x) x)
  }
}

res_block <- nn_module(
  initialize = function(num_in, num_out, stride) {
    self$align <- align(num_in, num_out, stride)
    self$conv1 <- nn_conv2d(num_in, num_out,
      kernel_size = 3,
      stride = stride, padding = 1, bias = FALSE
    )
    self$bn1 <- nn_batch_norm2d(num_out)
    self$relu <- nn_relu(inplace = TRUE)
    self$conv2 <- nn_conv2d(num_out, num_out,
      kernel_size = 3,
      stride = 1, padding = 1, bias = FALSE
    )
    self$bn2 <- nn_batch_norm2d(num_out)
  },
  forward = function(x) {
    o <- self$conv1(x)
    o <- self$bn1(o)
    o <- self$relu(o)
    o <- self$conv2(o)
    o <- self$bn2(o)
    o <- o + self$align(x)
    o <- self$relu(o)
    return(o)
  }
)

Constructeur ResNet

Pour construire notre ResNet, on veut créer des block residuals en chaîne. En python, on le fait de la manière suivante (toujours en utilisant torch.nn.Module) :

def buildResBlocks(num_in, num_out, stride, num_blocks):
    blocks = [ResBlock(num_in, num_out, stride)]
    for _ in range(1, num_blocks):
        blocks.append(ResBlock(num_out, num_out, 1))
    return nn.Sequential(*blocks)

class ResNet(nn.Module):
    def __init__(self, num_classes):
        super(ResNet, self).__init__()
        self.blocks0 = nn.Sequential(
            nn.Conv2d(
                3, 16, kernel_size=3,
                stride=1, padding=1, bias=False
                ),
            nn.BatchNorm2d(16),
            nn.ReLU(inplace=True)
            )
        self.blocks1 = buildResBlocks(16, 16, 1, 2)
        self.blocks2 = buildResBlocks(16, 32, 2, 2)
        self.blocks3 = buildResBlocks(32, 64, 2, 2)
        self.avgpool = nn.AvgPool2d(8)
        self.fc = nn.Linear(64, num_classes)

    def forward(self, x):
        n = x.shape[0]
        o = self.blocks0(x)
        o = self.blocks1(o)
        o = self.blocks2(o)
        o = self.blocks3(o)
        o = self.avgpool(o)
        o = self.fc(o.reshape(n, -1))
        return o

En R, cela donne :

build_res_blocks <- function(num_in, num_out, stride, num_blocks) {
  blocks <- list(res_block(num_in, num_out, stride))
  for (i in 2:num_blocks) {
    blocks[[i]] <- res_block(num_out, num_out, 1)
  }
  return(do.call(nn_sequential, blocks))
}

res_net <- nn_module(
  initialize = function(num_classes) {
    self$blocks0 <- nn_sequential(
      nn_conv2d(
        3,
        16,
        kernel_size = 3,
        stride = 1,
        padding = 1,
        bias = FALSE
      ),
      nn_batch_norm2d(16),
      nn_relu(inplace = TRUE)
    )
    self$blocks1 <- build_res_blocks(16, 16, 1, 2)
    self$blocks2 <- build_res_blocks(16, 32, 2, 2)
    self$blocks3 <- build_res_blocks(32, 64, 2, 2)
    self$avgpool <- nn_avg_pool2d(kernel_size = 8)
    self$fc <- nn_linear(64, num_classes)
  },
  forward = function(x) {
    n <- dim(x)[1]
    o <- self$blocks0(x)
    o <- self$blocks1(o)
    o <- self$blocks2(o)
    o <- self$blocks3(o)
    o <- self$avgpool(o)
    o <- torch_flatten(o, start_dim = 2)
    o <- self$fc(o)
    return(o)
  }
)

Instanciation modèle et optimiseurs

Partie un peu plus rapide et simple, quasi identique dans les deux cas :

device = "cpu"
model = ResNet(10).to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
device = "cpu"
model <- res_net$new(10)$to(device = device)
model
An `nn_module` containing 195,738 parameters.

── Modules ─────────────────────────────────────────────────────────────────────
• blocks0: <nn_sequential> #464 parameters
• blocks1: <nn_sequential> #9,344 parameters
• blocks2: <nn_sequential> #37,184 parameters
• blocks3: <nn_sequential> #148,096 parameters
• avgpool: <nn_avg_pool2d> #0 parameters
• fc: <nn_linear> #650 parameters
optimizer <- optim_adam(model$parameters, lr = 0.001)
optimizer
<optim_adam>
  Inherits from: <torch_optimizer>
  Public:
    add_param_group: function (param_group) 
    clone: function (deep = FALSE) 
    defaults: list
    initialize: function (params, lr = 0.001, betas = c(0.9, 0.999), eps = 1e-08, 
    load_state_dict: function (state_dict, ..., .refer_to_state_dict = FALSE) 
    param_groups: list
    state: State, R6
    state_dict: function () 
    step: function (closure = NULL) 
    zero_grad: function () 
  Private:
    step_helper: function (closure, loop_fun) 

Apprentissage

def train():
    for epoch in range(1, 11):
        for i, (x, y) in enumerate(trainLoader):
            (x, y) = x.to(device), y.to(device)
            o = model(x)
            loss = F.cross_entropy(o, y)

            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
            if i % 100 == 0:
                 print("Epoch: {}\tLoss: {}".format(epoch, loss.item()))
train <- function() {
  for (epoch in 1:10) {
    i <- 0
    coro::loop(for (batch in train_loader) {
      x <- batch[[1]]
      y <- batch[[2]]
      o <- model(x)
      loss <- nnf_cross_entropy(o, y)

      optimizer$zero_grad()
      loss$backward()
      optimizer$step()
      if (i %% 100 == 0) {
        cat(sprintf("Epoch: %d\tLoss: %.4f\n", epoch, loss$item()))
      }
      i <- i + 1
    })
  }
}

Comparaison temps de calcul

tic = time.time()
train()
print(time.time()-tic, "s")
tic <- system.time(
  train()
)
Epoch: 1    Loss: 2.3799
Epoch: 2    Loss: 2.0263
Epoch: 3    Loss: 1.8510
Epoch: 4    Loss: 1.7074
Epoch: 5    Loss: 1.6638
Epoch: 6    Loss: 1.6062
Epoch: 7    Loss: 1.5259
Epoch: 8    Loss: 1.3865
Epoch: 9    Loss: 1.3425
Epoch: 10   Loss: 1.2090
tic
   user  system elapsed 
 44.978   3.288  31.441 

Sur mon ordinateur : - python : 27.6s (elapsed) - R : 33.1s

Des temps de calcul très comparables !

Précision

Pour tester la précision :

n, N = 0, 0
with torch.no_grad():
    for (x, y) in testLoader:
        (x, y) = x.to(device), y.to(device)
        o = model(x)
        _, ŷ = torch.max(o, 1)
        N += y.size(0)
        n += torch.sum(ŷ == y).item()
    print("Accuracy: {}".format(n/N))
with_no_grad({
  n_tests_ok <- 0
  n_tests <- 0
  coro::loop(for (batch in test_loader) {
    x <- batch[[1]]
    y <- batch[[2]]
    o <- model(x)
    yest <- torch_max(o, dim = 2)[[2]]
    n_tests <- n_tests + y$shape
    n_tests_ok <- n_tests_ok + torch_sum(y == yest)$item()
  })
  cat("Accuracy", n_tests_ok / n_tests, "\n")
})
Accuracy 0.395 

Les deux codes donnent des résultats semblables !