Accueil Intelligence artificielle série chronologique de la torche, épisode final : Attention

série chronologique de la torche, épisode final : Attention

0
série chronologique de la torche, épisode final : Attention


Ceci est le dernier article d’une introduction en quatre parties à la prévision de séries chronologiques avec torch. Ces articles sont l’histoire d’une quête de prédiction en plusieurs étapes, et nous avons désormais vu trois approches différentes : la prévision en boucle, incorporant un perceptron multicouche (MLP) et les modèles séquence à séquence. Voici un bref récapitulatif.

  • Comme il se doit lorsque l’on part pour un voyage aventureux, nous avons commencé avec un étude approfondie des outils à notre disposition : les réseaux de neurones récurrents (RNN). Nous avons entraîné un modèle pour prédire la toute prochaine observation en ligne, puis avons pensé à un hack astucieux : que diriez-vous de l’utiliser pour une prédiction en plusieurs étapes, en renvoyant des prédictions individuelles dans une boucle ? Il s’est avéré que le résultat était tout à fait acceptable.

  • C’est alors que l’aventure a réellement commencé. Nous avons construit notre premier modèle « nativement » pour la prédiction en plusieurs étapes, soulageant un peu le RNN de sa charge de travail et impliquant un deuxième acteur, un tout petit MLP. Il incombait désormais au MLP de projeter la sortie du RNN à plusieurs moments dans le futur. Même si les résultats ont été plutôt satisfaisants, nous ne nous sommes pas arrêtés là.

  • Au lieu de cela, nous avons appliqué aux séries temporelles numériques une technique couramment utilisée en traitement du langage naturel (NLP) : séquence à séquence (séquence2séq) prédiction. Bien que les performances des prévisions ne soient pas très différentes du cas précédent, nous avons trouvé la technique plus intuitivement attrayante, car elle reflète les causal relation entre les prévisions successives.

Aujourd’hui, nous allons enrichir l’approche seq2seq en ajoutant un nouveau composant : le attention module. Initialement introduits vers 2014, les mécanismes d’attention ont gagné énormément de popularité, à tel point qu’un récent titre d’article commence par « L’attention n’est pas tout ce dont vous avez besoin ».

L’idée est la suivante.

Dans la configuration codeur-décodeur classique, le décodeur est « amorcé » avec un résumé de l’encodeur une seule fois : l’heure à laquelle il démarre sa boucle de prévision. A partir de là, c’est tout seul. Cependant, avec attention, il peut revoir la séquence complète des sorties du codeur à chaque fois qu’il prévoit une nouvelle valeur. De plus, à chaque fois, il peut zoomer sur ceux des sorties qui semblent pertinent pour l’étape de prédiction actuelle.

Il s’agit d’une stratégie particulièrement utile en traduction : pour générer le mot suivant, un modèle devra savoir sur quelle partie de la phrase source se concentrer. En revanche, l’utilité de la technique avec les séquences numériques dépendra probablement des caractéristiques de la série en question.

Comme auparavant, nous travaillons avec vic_elec, mais cette fois, nous nous écartons en partie de la façon dont nous l’employions auparavant. Avec l’ensemble de données d’origine bi-horaire, la formation du modèle actuel prend beaucoup de temps, plus longtemps que ce que les lecteurs voudront attendre lors de l’expérimentation. Au lieu de cela, nous regroupons les observations par jour. Afin de disposer de suffisamment de données, nous nous entraînons sur les années 2012 et 2013, réservant 2014 à la validation ainsi qu’à l’inspection post-formation.

Nous tenterons de prévoir la demande jusqu’à quatorze jours à l’avance. Quelle doit alors être la durée des séquences d’entrée ? C’est une question d’expérimentation ; d’autant plus maintenant que nous ajoutons le mécanisme d’attention. (Je soupçonne qu’il ne gère peut-être pas aussi bien les très longues séquences).

Ci-dessous, nous optons également pour la durée de saisie de quatorze jours, mais ce n’est pas nécessairement le meilleur choix possible pour cette série.

n_timesteps <- 7 * 2
n_forecast <- 7 * 2

elec_dataset <- dataset(
  name = "elec_dataset",
  
  initialize = function(x, n_timesteps, sample_frac = 1) {
    
    self$n_timesteps <- n_timesteps
    self$x <- torch_tensor((x - train_mean) / train_sd)
    
    n <- length(self$x) - self$n_timesteps - 1
    
    self$starts <- sort(sample.int(
      n = n,
      size = n * sample_frac
    ))
    
  },
  
  .getitem = function(i) {
    
    start <- self$starts[i]
    end <- start + self$n_timesteps - 1
    lag <- 1
    
    list(
      x = self$x[start:end],
      y = self$x[(start+lag):(end+lag)]$squeeze(2)
    )
    
  },
  
  .length = function() {
    length(self$starts) 
  }
)

batch_size <- 32

train_ds <- elec_dataset(elec_train, n_timesteps)
train_dl <- train_ds %>% dataloader(batch_size = batch_size, shuffle = TRUE)

valid_ds <- elec_dataset(elec_valid, n_timesteps)
valid_dl <- valid_ds %>% dataloader(batch_size = batch_size)

test_ds <- elec_dataset(elec_test, n_timesteps)
test_dl <- test_ds %>% dataloader(batch_size = 1)

Du point de vue du modèle, nous rencontrons à nouveau les trois modules familier du post précédent : encodeur, décodeur et module seq2seq de niveau supérieur. Il existe cependant un élément supplémentaire : le attention module, utilisé par le décodeur pour obtenir poids d’attention.

Encodeur

L’encodeur fonctionne toujours de la même manière. Il enveloppe un RNN et renvoie l’état final.

encoder_module <- nn_module(
  
  initialize = function(type, input_size, hidden_size, num_layers = 1, dropout = 0) {
    
    self$type <- type
    
    self$rnn <- if (self$type == "gru") {
      nn_gru(
        input_size = input_size,
        hidden_size = hidden_size,
        num_layers = num_layers,
        dropout = dropout,
        batch_first = TRUE
      )
    } else {
      nn_lstm(
        input_size = input_size,
        hidden_size = hidden_size,
        num_layers = num_layers,
        dropout = dropout,
        batch_first = TRUE
      )
    }
    
  },
  
  forward = function(x) {
    
    # return outputs for all timesteps, as well as last-timestep states for all layers
    x %>% self$rnn()
    
  }
)

Module Attention

Dans seq2seq de base, chaque fois qu’il devait générer une nouvelle valeur, le décodeur prenait en compte deux éléments : son état antérieur et la sortie précédente générée. Dans une configuration enrichie en attention, le décodeur reçoit en outre la sortie complète de l’encodeur. Pour décider quel sous-ensemble de cette sortie devrait être important, il obtient l’aide d’un nouvel agent, le module d’attention.

C’est donc la raison d’être du module d’attention : étant donné l’état actuel du décodeur et les sorties complètes du codeur, obtenir une pondération de ces sorties indiquant leur pertinence par rapport à ce que le décodeur fait actuellement. Cette procédure aboutit à ce que l’on appelle poids d’attention: un score normalisé, pour chaque pas de temps de l’encodage, qui quantifie leur importance respective.

L’attention peut être mise en œuvre de différentes manières. Ici, nous montrons deux options d’implémentation, une additive et une multiplicative.

Attention additive

Avec une attention additive, les sorties du codeur et l’état du décodeur sont généralement soit ajoutés, soit concaténés (nous choisissons de faire cette dernière ci-dessous). Le tenseur résultant traverse une couche linéaire et un softmax est appliqué pour la normalisation.

attention_module_additive <- nn_module(
  
  initialize = function(hidden_dim, attention_size) {
    
    self$attention <- nn_linear(2 * hidden_dim, attention_size)
    
  },
  
  forward = function(state, encoder_outputs) {
    
    # function argument shapes
    # encoder_outputs: (bs, timesteps, hidden_dim)
    # state: (1, bs, hidden_dim)
    
    # multiplex state to allow for concatenation (dimensions 1 and 2 must agree)
    seq_len <- dim(encoder_outputs)[2]
    # resulting shape: (bs, timesteps, hidden_dim)
    state_rep <- state$permute(c(2, 1, 3))$repeat_interleave(seq_len, 2)
    
    # concatenate along feature dimension
    concat <- torch_cat(list(state_rep, encoder_outputs), dim = 3)
    
    # run through linear layer with tanh
    # resulting shape: (bs, timesteps, attention_size)
    scores <- self$attention(concat) %>% 
      torch_tanh()
    
    # sum over attention dimension and normalize
    # resulting shape: (bs, timesteps) 
    attention_weights <- scores %>%
      torch_sum(dim = 3) %>%
      nnf_softmax(dim = 2)
    
    # a normalized score for every source token
    attention_weights
  }
)

Attention multiplicative

En attention multiplicative, les scores sont obtenus en calculant les produits scalaires entre l’état du décodeur et toutes les sorties du codeur. Ici aussi, un softmax est ensuite utilisé pour la normalisation.

attention_module_multiplicative <- nn_module(
  
  initialize = function() {
    
    NULL
    
  },
  
  forward = function(state, encoder_outputs) {
    
    # function argument shapes
    # encoder_outputs: (bs, timesteps, hidden_dim)
    # state: (1, bs, hidden_dim)

    # allow for matrix multiplication with encoder_outputs
    state <- state$permute(c(2, 3, 1))
 
    # prepare for scaling by number of features
    d <- torch_tensor(dim(encoder_outputs)[3], dtype = torch_float())
       
    # scaled dot products between state and outputs
    # resulting shape: (bs, timesteps, 1)
    scores <- torch_bmm(encoder_outputs, state) %>%
      torch_div(torch_sqrt(d))
    
    # normalize
    # resulting shape: (bs, timesteps) 
    attention_weights <- scores$squeeze(3) %>%
      nnf_softmax(dim = 2)
    
    # a normalized score for every source token
    attention_weights
  }
)

Décodeur

Une fois les poids d’attention calculés, leur application réelle est gérée par le décodeur. Concrètement, la méthode en question, weighted_encoder_outputs()calcule un produit des poids et des sorties de l’encodeur, en s’assurant que chaque sortie aura un impact approprié.

Le reste de l’action se déroule ensuite dans forward(). Une concaténation de sorties d’encodeur pondérées (souvent appelées « contexte ») et d’entrée de courant est exécutée via un RNN. Ensuite, un ensemble de sortie, de contexte et d’entrée RNN est transmis à un MLP. Enfin, l’état RNN et la prédiction actuelle sont renvoyés.

decoder_module <- nn_module(
  
  initialize = function(type, input_size, hidden_size, attention_type, attention_size = 8, num_layers = 1) {
    
    self$type <- type
    
    self$rnn <- if (self$type == "gru") {
      nn_gru(
        input_size = input_size,
        hidden_size = hidden_size,
        num_layers = num_layers,
        batch_first = TRUE
      )
    } else {
      nn_lstm(
        input_size = input_size,
        hidden_size = hidden_size,
        num_layers = num_layers,
        batch_first = TRUE
      )
    }
    
    self$linear <- nn_linear(2 * hidden_size + 1, 1)
    
    self$attention <- if (attention_type == "multiplicative") attention_module_multiplicative()
      else attention_module_additive(hidden_size, attention_size)
    
  },
  
  weighted_encoder_outputs = function(state, encoder_outputs) {

    # encoder_outputs is (bs, timesteps, hidden_dim)
    # state is (1, bs, hidden_dim)
    # resulting shape: (bs * timesteps)
    attention_weights <- self$attention(state, encoder_outputs)
    
    # resulting shape: (bs, 1, seq_len)
    attention_weights <- attention_weights$unsqueeze(2)
    
    # resulting shape: (bs, 1, hidden_size)
    weighted_encoder_outputs <- torch_bmm(attention_weights, encoder_outputs)
    
    weighted_encoder_outputs
    
  },
  
  forward = function(x, state, encoder_outputs) {
 
    # encoder_outputs is (bs, timesteps, hidden_dim)
    # state is (1, bs, hidden_dim)
    
    # resulting shape: (bs, 1, hidden_size)
    context <- self$weighted_encoder_outputs(state, encoder_outputs)
    
    # concatenate input and context
    # NOTE: this repeating is done to compensate for the absence of an embedding module
    # that, in NLP, would give x a higher proportion in the concatenation
    x_rep <- x$repeat_interleave(dim(context)[3], 3) 
    rnn_input <- torch_cat(list(x_rep, context), dim = 3)
    
    # resulting shapes: (bs, 1, hidden_size) and (1, bs, hidden_size)
    rnn_out <- self$rnn(rnn_input, state)
    rnn_output <- rnn_out[[1]]
    next_hidden <- rnn_out[[2]]
    
    mlp_input <- torch_cat(list(rnn_output$squeeze(2), context$squeeze(2), x$squeeze(2)), dim = 2)
    
    output <- self$linear(mlp_input)
    
    # shapes: (bs, 1) and (1, bs, hidden_size)
    list(output, next_hidden)
  }
  
)

seq2seq module

Le seq2seq Le module est fondamentalement inchangé (mis à part le fait que désormais, il permet la configuration du module d’attention). Pour une explication détaillée de ce qui se passe ici, veuillez consulter le post précédent.

seq2seq_module <- nn_module(
  
  initialize = function(type, input_size, hidden_size, attention_type, attention_size, n_forecast, 
                        num_layers = 1, encoder_dropout = 0) {
    
    self$encoder <- encoder_module(type = type, input_size = input_size, hidden_size = hidden_size,
                                   num_layers, encoder_dropout)
    self$decoder <- decoder_module(type = type, input_size = 2 * hidden_size, hidden_size = hidden_size,
                                   attention_type = attention_type, attention_size = attention_size, num_layers)
    self$n_forecast <- n_forecast
    
  },
  
  forward = function(x, y, teacher_forcing_ratio) {
    
    outputs <- torch_zeros(dim(x)[1], self$n_forecast)
    encoded <- self$encoder(x)
    encoder_outputs <- encoded[[1]]
    hidden <- encoded[[2]]
    # list of (batch_size, 1), (1, batch_size, hidden_size)
    out <- self$decoder(x[ , n_timesteps, , drop = FALSE], hidden, encoder_outputs)
    # (batch_size, 1)
    pred <- out[[1]]
    # (1, batch_size, hidden_size)
    state <- out[[2]]
    outputs[ , 1] <- pred$squeeze(2)
    
    for (t in 2:self$n_forecast) {
      
      teacher_forcing <- runif(1) < teacher_forcing_ratio
      input <- if (teacher_forcing == TRUE) y[ , t - 1, drop = FALSE] else pred
      input <- input$unsqueeze(3)
      out <- self$decoder(input, state, encoder_outputs)
      pred <- out[[1]]
      state <- out[[2]]
      outputs[ , t] <- pred$squeeze(2)
      
    }
    
    outputs
  }
  
)

Lors de l’instanciation du modèle de niveau supérieur, nous avons désormais un choix supplémentaire : celui entre l’attention additive et multiplicative. Au sens de performance « précision », mes tests n’ont montré aucune différence. Cependant, la variante multiplicative est beaucoup plus rapide.

net <- seq2seq_module("gru", input_size = 1, hidden_size = 32, attention_type = "multiplicative",
                      attention_size = 8, n_forecast = n_forecast)

Tout comme la dernière fois, lors de la formation de modèles, nous pouvons choisir le degré de contrainte imposée par l’enseignant. Ci-dessous, nous optons pour une fraction de 0,0, c’est-à-dire sans aucun forçage.

optimizer <- optim_adam(net$parameters, lr = 0.001)

num_epochs <- 1000

train_batch <- function(b, teacher_forcing_ratio) {
  
  optimizer$zero_grad()
  output <- net(b$x, b$y, teacher_forcing_ratio)
  target <- b$y
  
  loss <- nnf_mse_loss(output, target[ , 1:(dim(output)[2])])
  loss$backward()
  optimizer$step()
  
  loss$item()
  
}

valid_batch <- function(b, teacher_forcing_ratio = 0) {
  
  output <- net(b$x, b$y, teacher_forcing_ratio)
  target <- b$y
  
  loss <- nnf_mse_loss(output, target[ , 1:(dim(output)[2])])
  
  loss$item()
  
}

for (epoch in 1:num_epochs) {
  
  net$train()
  train_loss <- c()
  
  coro::loop(for (b in train_dl) {
    loss <-train_batch(b, teacher_forcing_ratio = 0.0)
    train_loss <- c(train_loss, loss)
  })
  
  cat(sprintf("\nEpoch %d, training: loss: %3.5f \n", epoch, mean(train_loss)))
  
  net$eval()
  valid_loss <- c()
  
  coro::loop(for (b in valid_dl) {
    loss <- valid_batch(b)
    valid_loss <- c(valid_loss, loss)
  })
  
  cat(sprintf("\nEpoch %d, validation: loss: %3.5f \n", epoch, mean(valid_loss)))
}
# Epoch 1, training: loss: 0.83752 
# Epoch 1, validation: loss: 0.83167

# Epoch 2, training: loss: 0.72803 
# Epoch 2, validation: loss: 0.80804 

# ...
# ...

# Epoch 99, training: loss: 0.10385 
# Epoch 99, validation: loss: 0.21259 

# Epoch 100, training: loss: 0.10396 
# Epoch 100, validation: loss: 0.20975 

Pour l’inspection visuelle, nous sélectionnons quelques prévisions dans l’ensemble de tests.

net$eval()

test_preds <- vector(mode = "list", length = length(test_dl))

i <- 1

vic_elec_test <- vic_elec_daily %>%
  filter(year(Date) == 2014, month(Date) %in% 1:4)


coro::loop(for (b in test_dl) {

  output <- net(b$x, b$y, teacher_forcing_ratio = 0)
  preds <- as.numeric(output)
  
  test_preds[[i]] <- preds
  i <<- i + 1
  
})

test_pred1 <- test_preds[[1]]
test_pred1 <- c(rep(NA, n_timesteps), test_pred1, rep(NA, nrow(vic_elec_test) - n_timesteps - n_forecast))

test_pred2 <- test_preds[[21]]
test_pred2 <- c(rep(NA, n_timesteps + 20), test_pred2, rep(NA, nrow(vic_elec_test) - 20 - n_timesteps - n_forecast))

test_pred3 <- test_preds[[41]]
test_pred3 <- c(rep(NA, n_timesteps + 40), test_pred3, rep(NA, nrow(vic_elec_test) - 40 - n_timesteps - n_forecast))

test_pred4 <- test_preds[[61]]
test_pred4 <- c(rep(NA, n_timesteps + 60), test_pred4, rep(NA, nrow(vic_elec_test) - 60 - n_timesteps - n_forecast))

test_pred5 <- test_preds[[81]]
test_pred5 <- c(rep(NA, n_timesteps + 80), test_pred5, rep(NA, nrow(vic_elec_test) - 80 - n_timesteps - n_forecast))


preds_ts <- vic_elec_test %>%
  select(Demand, Date) %>%
  add_column(
    ex_1 = test_pred1 * train_sd + train_mean,
    ex_2 = test_pred2 * train_sd + train_mean,
    ex_3 = test_pred3 * train_sd + train_mean,
    ex_4 = test_pred4 * train_sd + train_mean,
    ex_5 = test_pred5 * train_sd + train_mean) %>%
  pivot_longer(-Date) %>%
  update_tsibble(key = name)


preds_ts %>%
  autoplot() +
  scale_color_hue(h = c(80, 300), l = 70) +
  theme_minimal()

Un échantillon de prédictions à deux semaines pour l'ensemble de test, 2014.

Figure 1 : Un échantillon de prévisions à deux semaines pour l’ensemble de tests, 2014.

Nous ne pouvons pas comparer directement les performances ici à celles des modèles précédents de notre série, car nous avons redéfini la tâche de manière pragmatique. Cependant, l’objectif principal a été d’introduire le concept d’attention. Concrètement, comment manuellement mettre en œuvre la technique – quelque chose que, une fois que vous avez compris le concept, vous n’aurez peut-être jamais à le faire en pratique. Au lieu de cela, vous utiliserez probablement les outils existants fournis avec torch (modules d’attention et de transformateur multi-têtes), outils que nous pourrions introduire dans une future « saison » de cette série.

Merci d’avoir lu!

photo par David Clodé sur Unsplash

Bahdanau, Dzmitry, Kyunghyun Cho et Yoshua Bengio. 2014. « Traduction automatique neuronale en apprenant conjointement à aligner et à traduire. » CoRR abs/1409.0473. http://arxiv.org/abs/1409.0473.

Dong, Yihe, Jean-Baptiste Cordonnier et Andreas Loukas. 2021. « L’attention n’est pas tout ce dont vous avez besoin : l’attention pure perd son rang de manière doublement exponentielle avec la profondeur Impressions électroniques arXivmars, arXiv : 2103.03404. https://arxiv.org/abs/2103.03404.

Vaswani, Ashish, Noam Shazeer, Niki Parmar, Jakob Uszkoreit, Llion Jones, Aidan N. Gomez, Lukasz Kaiser et Illia Polosukhin. 2017. « L’attention est tout ce dont vous avez besoin Impressions électroniques arXivjuin, arXiv : 1706.03762. https://arxiv.org/abs/1706.03762.

Vinyals, Oriol, Lukasz Kaiser, Terry Koo, Slav Petrov, Ilya Sutskever et Geoffrey E. Hinton. 2014. «La grammaire comme langue étrangère». CoRR abs/1412.7449. http://arxiv.org/abs/1412.7449.

Xu, Kelvin, Jimmy Ba, Ryan Kiros, Kyunghyun Cho, Aaron C. Courville, Ruslan Salakhutdinov, Richard S. Zemel et Yoshua Bengio. 2015. « Montrer, assister et raconter : génération de légendes d’images neuronales avec attention visuelle. » CoRR abs/1502.03044. http://arxiv.org/abs/1502.03044.

LAISSER UN COMMENTAIRE

S'il vous plaît entrez votre commentaire!
S'il vous plaît entrez votre nom ici