Passer au contenu

Cet exemple est une adaptation du cours ‘Entraîner un modèle linguistique causal à partir de zéro’ du Hugging Face NLP course.

library(torch)
library(tok)
library(luz)
library(minhub) # remotes::install_github("mlverse/minhub")
#library(tidyverse)
options(arrow.skip_nul = TRUE)
library(arrow)

Données

La première étape est d’implémenter un jeu de données torch qui rassemble des données et les pré-traite pour qu’elles soient au format approprié pour entraîner le modèle.

Cela signifie que nous devons :

  1. Télécharger les données textuelles
  2. Entraîner un tokeniseur pour ce jeu de données
  3. Être en mesure de produire des séquences de tokens dans le format attendu par le modèle

Nous allons utiliser 2 jeux de données disponibles sur le Hub d’Hugging Face. Le premier contient tous les codes sources des paquets R disponibles sur CRAN. Le second contient tous les codes R qui sont disponibles dans les dumps GitHub. Les deux jeux de données sont au format Parquet. Nous allons implémenter une fonction qui télécharge et cache les données, puis renvoie une seule table au format arrow contenant toutes les données.

read_dataset <- function(source) {
  d <- source |>
    hfhub::hub_snapshot(repo_type = "dataset", allow_patterns = "parquet$") |>
    fs::path("data/r") |>
    arrow::open_dataset() |>
    dplyr::filter(stringr::str_detect(path, ".*\\.[rR]$")) |>
    dplyr::select(content) |>
    dplyr::mutate(content = arrow::cast(content, arrow::string())) |>
    dplyr::filter(!is.na(content)) |>
    dplyr::collect() %>%
    # le jeu de données contient des caractères utf8 invalides...
    # nous devons les supprimer, sinon on obtient une erreur des tokenizers
    dplyr::filter(utf8::utf8_valid(content))
}

read_datasets <- function() {
  dplyr::bind_rows(
    read_dataset("dfalbel/cran-packages"),
    read_dataset("dfalbel/github-r-repos")
  )
}

Ensuite, nous implémentons une fonction qui entraîne un tokeniseur sur notre jeu de données.

create_tokenizer <- function(text, vocab_size, special_tokens) {
  tok <- tok::tokenizer$new(tok::model_bpe$new())

  tok$pre_tokenizer <- tok::pre_tokenizer_byte_level$new(add_prefix_space = FALSE)
  tok$decoder <- tok::decoder_byte_level$new()
  tok$post_processor <- tok::processor_byte_level$new(trim_offsets = FALSE)

  tok$train_from_memory(
    text,
    tok::trainer_bpe$new(vocab_size = vocab_size, special_tokens = special_tokens)
  )
  tok
}

# code de débogage pour le tokeniseur
# data <- read_datasets()
# tok <- create_tokenizer(data$content)

Nous pouvons enfin implémenter le jeu de données torch que nous allons utiliser pour entraîner le modèle. Nous allons utiliser torch::iterable_dataset au lieu de torch::dataset. La principale motivation est que nous ne pouvons pas
vraiment savoir le nombre total d’échantillons dans le jeu de données, donc nous pouvons implémenter une méthode .getitem() pour obtenir n’importe quel échantillon arbitraire. Ainsi, nous implémentons la méthode .iter qui retourne
un nouvel échantillon à chaque appel.

r_sources_dataset <- torch::iterable_dataset(
  "r_sources_dataset",
  initialize = function(root = ".", vocab_size = 20000, context_length = 128) {
    self$data <- read_datasets()
    self$context_length <- context_length
    self$index <- sample.int(nrow(self$data))

    # nous créons un tokeniseur que si celui-ci n'existe pas déjà, sinon nous le chargeons simplement
    tok_path <- file.path(root, glue::glue("tokenizer-{vocab_size}.json"))
    if (!file.exists(tok_path)) {
      self$tok <- create_tokenizer(
        as.character(self$data$content),
        vocab_size,
        c("<fbegin>", "<fend>")
      )
      fs::dir_create(root)
      self$tok$save(tok_path)
    } else {
      self$tok <- tok::tokenizer$from_file(tok_path)
    }
  },
  .iter = function() {
    i <- 1L
    sequence <- c()
    function() {
      while (length(sequence) < (self$context_length + 1) && i <= nrow(self$data)) {
        sequence <<- c(
          sequence,
          self$tok$encode(paste("<fbegin>", as.character(self$data$content[self$index[i]]), "<fend>"))$ids
        )
        i <- i + 1L
      }

      if (length(sequence) < (self$context_length + 1)) {
        return(coro::exhausted())
      }

      on.exit({
        sequence <<- sequence[-seq_len(self$context_length)]
      })
      list(
        input_ids = sequence[seq_len(self$context_length)] + 1L,
        labels = sequence[2:(self$context_length + 1)] + 1L
      )
    }
  }
)

# code de débogage pour le jeu de données
# ds <- r_sources_dataset("~/Downloads/")
# it <- ds$.iter()
# it()
# ds$tok$get_vocab_size()

Ce jeu de données est bien trop volumineux pour entraîner le modèle sur tous les documents dans cet exemple. Il est également difficile de prédire combien de temps cela prendra d’arriver à la fin de l’entraînement. Pour simplifier, nous définissons un jeu de données itérable utilisé pour exécuter le jeu de données ci-dessus pendant un nombre fixe d’étapes. Ce n’est pas nécessaire, mais rend l’utilisation de luz plus agréable, car nous pouvons facilement définir combien de tokens nous voulons entraîner notre modèle.

fixed_steps_iterable_dataset <- iterable_dataset(
  "fixed_steps_dataset",
  initialize = function(dataset, steps) {
    self$dataset <- dataset
    self$steps <- steps
  },
  .iter = function() {
    i <- 1L
    iter <- NULL
    function() {
      if (i > self$steps) {
        return(coro::exhausted())
      }

      i <<- i + 1L

      if (is.null(iter) || coro::is_exhausted(data <- iter())) {
        iter <<- self$dataset$.iter()
        data <- iter()
      }

      data
    }
  },
  .length = function() {
    self$steps
  }
)

Maintenant, nous pouvons définir le modèle que nous allons entraîner. Nous utiliserons une version légère de GPT2. Nous définissons également une méthode generate nous permettant d’échantillonner à partir du modèle donné un contexte initial.

net <- nn_module(
  initialize = function() {
    self$gpt <- minhub::gpt2(
      vocab_size = 20000,
      pdrop = 0.1
    )
  },
  forward = function(x) {
    self$gpt(x)$transpose(2,3)
  },
  generate = function(x, temperature = 1, iter = 50, top_k = 10) {
    # échantillonne à partir du modèle given un vecteur de contexte.
    for (i in seq_len(iter)) {
      logits <- self$forward(x)[,,-1]
      logits <- logits/temperature
      c(prob, ind) %<-% logits$topk(top_k)
      logits <- torch_full_like(logits, -Inf)$scatter_(-1, ind, prob)
      logits <- nnf_softmax(logits, dim = -1)
      id_next <- torch_multinomial(logits, num_samples = 1)
      x <- torch_cat(list(x, id_next), dim = 2)
    }
    x
  }
)

# code de débogage pour le modèle
# ds <- torch::dataloader(r_sources_dataset("~/Downloads/"), batch_size = 32)
# batch <- coro::collect(ds, 1)[[1]]
# str(batch)
# m <- net()
# str(m(batch$input_ids))

Pour faciliter l’inspection de la formation, nous définirons également un callback qui imprime un échantillon du modèle à chaque époque.

# échantillonne à partir du modèle en utilisant le contexte.
generate <- function(model, tok, context, ...) {
  local_no_grad() # désactive les gradients pour l'échantillonnage
  x <- tok$encode(context)$ids + 1L
  x <- torch_tensor(x)[NULL,]$to(device = model$device)
  content <- as.integer(model$generate(x, ...)$cpu())
  tok$decode(content - 1L)
}

display_cb <- luz_callback(
  initialize = function() {},
  on_epoch_end = function() {
    local_no_grad()
    # sample from the model...
    context <- "# creates a linear model"
    text <- generate(ctx$model, dataset$dataset$tok, context, iter = 100)
    cli::cli_rule()
    cat(text, "\n")
    cli::cli_rule()
  }
)

Nous pouvons maintenant entraîner le modèle. Nous définissons un entraînement du modèle pour un demi-milliard de tokens pendant un total de 100 époques.

n_tokens <- 500e6
batch_size <- 16
epochs <- 100
context_length <- 256L

steps <- n_tokens / context_length / epochs
dataset <- fixed_steps_iterable_dataset(
  r_sources_dataset(context_length = context_length),
  steps = steps
)

fitted <- net %>%
  setup(
    optimizer = optim_adam,
    loss = nn_cross_entropy_loss()
  ) %>%
  set_opt_hparams(lr = 3e-4) |>
  fit(
    dataset,
    epochs = epochs,
    dataloader_options = list(batch_size = batch_size),
    callbacks = list(
      luz_callback_lr_scheduler(
        torch::lr_one_cycle,
        max_lr = 0.1,
        epochs = epochs,
        steps_per_epoch = steps/batch_size,
        call_on = "on_batch_end"
      ),
      luz_callback_gradient_clip(max_norm = 1),
      display_cb()
    ),
    verbose = TRUE
  )

luz::luz_save(fitted, "model.pt")

Ensuite, nous pouvons utiliser le modèle pour générer du texte en fonction d’un prompt avec :

fitted <- luz::luz_load("model.pt")
tok <- tok::tokenizer$from_file("tokenizer-20000.json")
context <- "#' Crée un modèle linéaire
linear_model <- function(x, y) {
"
text <- generate(fitted$model, tok, context, iter = 100)
cat(text)