Posit AI Weblog: série temporal da tocha, tomada três: previsão sequência a sequência


Hoje, continuamos nossa exploração da previsão de séries temporais em várias etapas com torch. Este publish é o terceiro de uma série.

  • Inicialmentecobrimos os fundamentos das redes neurais recorrentes (RNNs) e treinamos um modelo para prever o próximo valor em uma sequência. Descobrimos também que poderíamos prever alguns passos à frente, retroalimentando as previsões individuais em um ciclo.

  • Próximoconstruímos um modelo “nativamente” para previsão em várias etapas. Um pequeno perceptron multicamadas (MLP) foi usado para projetar a saída RNN para vários pontos no tempo no futuro.

De ambas as abordagens, a última foi a mais bem sucedida. Mas, conceitualmente, tem um toque insatisfatório: quando o MLP extrapola e gera resultados para, digamos, dez pontos consecutivos no tempo, não há relação causal entre eles. (Think about uma previsão do tempo para dez dias que nunca foi atualizada.)

Agora, gostaríamos de tentar algo mais intuitivamente atraente. A entrada é uma sequência; a saída é uma sequência. No processamento de linguagem pure (PNL), esse tipo de tarefa é muito comum: é exatamente o tipo de situação que vemos na tradução automática ou no resumo.

Muito apropriadamente, os tipos de modelos empregados para esses fins são chamados de modelos sequência a sequência (frequentemente abreviados seq2seq). Em poucas palavras, eles dividiram a tarefa em dois componentes: uma parte de codificação e uma parte de decodificação. O primeiro é feito apenas uma vez por par entrada-alvo. Este último é feito em loop, como em nossa primeira tentativa. Mas o decodificador tem mais informações à sua disposição: a cada iteração, seu processamento é baseado na previsão anterior, bem como no estado anterior. Esse estado anterior será o do codificador quando um loop for iniciado e o seu próprio estado a partir de então.

Antes de discutir o modelo em detalhes, precisamos adaptar o nosso mecanismo de entrada de dados.

Continuamos trabalhando com vic_elec fornecido por tsibbledata.

Novamente, a definição do conjunto de dados na postagem atual parece um pouco diferente de como period antes; é a forma do alvo que difere. Desta vez, y é igual xdeslocado para a esquerda em um.

A razão pela qual fazemos isso se deve à maneira como vamos treinar a rede. Com seq2seqas pessoas costumam usar uma técnica chamada “forçamento do professor”, onde, em vez de realimentar sua própria previsão no módulo decodificador, você passa a ele o valor que ele deve previu. Para ser claro, isso é feito apenas durante o treinamento e em um grau configurável.

library(torch)
library(tidyverse)
library(tsibble)
library(tsibbledata)
library(lubridate)
library(fable)
library(zeallot)

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

vic_elec_get_year <- perform(yr, month = NULL) {
  vic_elec %>%
    filter(yr(Date) == yr, month(Date) == if (is.null(month)) month(Date) else month) %>%
    as_tibble() %>%
    choose(Demand)
}

elec_train <- vic_elec_get_year(2012) %>% as.matrix()
elec_valid <- vic_elec_get_year(2013) %>% as.matrix()
elec_test <- vic_elec_get_year(2014, 1) %>% as.matrix()

train_mean <- imply(elec_train)
train_sd <- sd(elec_train)

elec_dataset <- dataset(
  title = "elec_dataset",
  
  initialize = perform(x, n_timesteps, sample_frac = 1) {
    
    self$n_timesteps <- n_timesteps
    self$x <- torch_tensor((x - train_mean) / train_sd)
    
    n <- size(self$x) - self$n_timesteps - 1
    
    self$begins <- type(pattern.int(
      n = n,
      dimension = n * sample_frac
    ))
    
  },
  
  .getitem = perform(i) {
    
    begin <- self$begins(i)
    finish <- begin + self$n_timesteps - 1
    lag <- 1
    
    checklist(
      x = self$x(begin:finish),
      y = self$x((begin+lag):(finish+lag))$squeeze(2)
    )
    
  },
  
  .size = perform() {
    size(self$begins) 
  }
)

As instanciações do conjunto de dados e do carregador de dados podem prosseguir como antes.

batch_size <- 32

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

valid_ds <- elec_dataset(elec_valid, n_timesteps, sample_frac = 0.5)
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)

Tecnicamente, o modelo consiste em três módulos: o codificador e decodificador mencionados acima, e o seq2seq módulo que os orquestra.

Codificador

O codificador recebe sua entrada e a executa por meio de um RNN. Das duas coisas retornadas por uma rede neural recorrente, saídas e estado, até agora usamos apenas saída. Desta vez, fazemos o oposto: descartamos as saídas e retornamos apenas o estado.

Se o RNN em questão for uma GRU (e assumindo que das saídas, tomamos apenas o passo de tempo remaining, que é o que temos feito o tempo todo), realmente não há diferença: o estado remaining é igual à saída remaining. Se for um LSTM, entretanto, existe um segundo tipo de estado, o “estado da célula”. Nesse caso, retornar o estado em vez da saída remaining trará mais informações.

encoder_module <- nn_module(
  
  initialize = perform(sort, input_size, hidden_size, num_layers = 1, dropout = 0) {
    
    self$sort <- sort
    
    self$rnn <- if (self$sort == "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
      )
    }
    
  },
  
  ahead = perform(x) {
    
    x <- self$rnn(x)
    
    # return final states for all layers
    # per layer, a single tensor for GRU, a listing of two tensors for LSTM
    x <- x((2))
    x
    
  }
  
)

Decodificador

No decodificador, assim como no codificador, o componente principal é um RNN. Em contraste com as arquiteturas mostradas anteriormente, porém, ela não retorna apenas uma previsão. Também reporta o estado remaining da RNN.

decoder_module <- nn_module(
  
  initialize = perform(sort, input_size, hidden_size, num_layers = 1) {
    
    self$sort <- sort
    
    self$rnn <- if (self$sort == "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(hidden_size, 1)
    
  },
  
  ahead = perform(x, state) {
    
    # enter to ahead:
    # x is (batch_size, 1, 1)
    # state is (1, batch_size, hidden_size)
    x <- self$rnn(x, state)
    
    # break up RNN return values
    # output is (batch_size, 1, hidden_size)
    # next_hidden is
    c(output, next_hidden) %<-% x
    
    output <- output$squeeze(2)
    output <- self$linear(output)
    
    checklist(output, next_hidden)
    
  }
  
)

seq2seq módulo

seq2seq é onde a ação acontece. O plano é codificar uma vez e depois chamar o decodificador em loop.

Se você olhar para trás, para o decodificador ahead()você vê que são necessários dois argumentos: x e state.

Dependendo do contexto, x corresponde a uma de três coisas: entrada remaining, previsão anterior ou verdade básica anterior.

  • A primeira vez que o decodificador é chamado em uma sequência de entrada, x mapeia para o valor de entrada remaining. Isso é diferente de uma tarefa como tradução automática, onde você passaria um token inicial. Com as séries temporais, porém, gostaríamos de continuar onde as medições reais param.

  • Nas próximas chamadas, queremos que o descodificador proceed a partir da sua previsão mais recente. É lógico, portanto, repassar a previsão anterior.

  • Dito isto, na PNL uma técnica chamada “forçamento do professor” é comumente usada para acelerar o treinamento. Com a força do professor, em vez da previsão, passamos a verdade básica, aquilo que o decodificador deveria ter previsto. Fazemos isso apenas em uma fração configurável de casos e – naturalmente – apenas durante o treinamento. A lógica por trás desta técnica é que sem esta forma de recalibração, erros consecutivos de previsão podem apagar rapidamente qualquer sinal restante.

statetambém é polivalente. Mas aqui existem apenas duas possibilidades: estado do codificador e estado do decodificador.

  • Na primeira vez que o decodificador é chamado, ele é “semeado” com o estado remaining do codificador. Observe como isso é a única vez fazemos uso da codificação.

  • A partir daí, será passado o estado anterior do próprio decodificador. Lembra como ele retorna dois valores, previsão e estado?

seq2seq_module <- nn_module(
  
  initialize = perform(sort, input_size, hidden_size, n_forecast, num_layers = 1, encoder_dropout = 0) {
    
    self$encoder <- encoder_module(sort = sort, input_size = input_size,
                                   hidden_size = hidden_size, num_layers, encoder_dropout)
    self$decoder <- decoder_module(sort = sort, input_size = input_size,
                                   hidden_size = hidden_size, num_layers)
    self$n_forecast <- n_forecast
    
  },
  
  ahead = perform(x, y, teacher_forcing_ratio) {
    
    # put together empty output
    outputs <- torch_zeros(dim(x)(1), self$n_forecast)$to(system = system)
    
    # encode present enter sequence
    hidden <- self$encoder(x)
    
    # prime decoder with remaining enter worth and hidden state from the encoder
    out <- self$decoder(x( , n_timesteps, , drop = FALSE), hidden)
    
    # decompose into predictions and decoder state
    # pred is (batch_size, 1)
    # state is (1, batch_size, hidden_size)
    c(pred, state) %<-% out
    
    # retailer first prediction
    outputs( , 1) <- pred$squeeze(2)
    
    # iterate to generate remaining forecasts
    for (t in 2:self$n_forecast) {
      
      # name decoder on both floor reality or earlier prediction, plus earlier decoder state
      teacher_forcing <- runif(1) < teacher_forcing_ratio
      enter <- if (teacher_forcing == TRUE) y( , t - 1, drop = FALSE) else pred
      enter <- enter$unsqueeze(3)
      out <- self$decoder(enter, state)
      
      # once more, decompose decoder return values
      c(pred, state) %<-% out
      # and retailer present prediction
      outputs( , t) <- pred$squeeze(2)
    }
    outputs
  }
  
)

internet <- seq2seq_module("gru", input_size = 1, hidden_size = 32, n_forecast = n_forecast)

# coaching RNNs on the GPU presently prints a warning that will litter 
# the console
# see https://github.com/mlverse/torch/points/461
# alternatively, use 
# system <- "cpu"
system <- torch_device(if (cuda_is_available()) "cuda" else "cpu")

internet <- internet$to(system = system)

O procedimento de treinamento é principalmente inalterado. Precisamos, no entanto, de decidir sobre teacher_forcing_ratioa proporção de sequências de entrada nas quais queremos realizar a recalibração. Em valid_batch()isso deve ser sempre 0enquanto em train_batch()depende de nós (ou melhor, da experimentação). Aqui, nós configuramos para 0.3.

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

num_epochs <- 50

train_batch <- perform(b, teacher_forcing_ratio) {
  
  optimizer$zero_grad()
  output <- internet(b$x$to(system = system), b$y$to(system = system), teacher_forcing_ratio)
  goal <- b$y$to(system = system)
  
  loss <- nnf_mse_loss(output, goal)
  loss$backward()
  optimizer$step()
  
  loss$merchandise()
  
}

valid_batch <- perform(b, teacher_forcing_ratio = 0) {
  
  output <- internet(b$x$to(system = system), b$y$to(system = system), teacher_forcing_ratio)
  goal <- b$y$to(system = system)
  
  loss <- nnf_mse_loss(output, goal)
  
  loss$merchandise()
  
}

for (epoch in 1:num_epochs) {
  
  internet$practice()
  train_loss <- c()
  
  coro::loop(for (b in train_dl) {
    loss <-train_batch(b, teacher_forcing_ratio = 0.3)
    train_loss <- c(train_loss, loss)
  })
  
  cat(sprintf("nEpoch %d, coaching: loss: %3.5f n", epoch, imply(train_loss)))
  
  internet$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, imply(valid_loss)))
}
Epoch 1, coaching: loss: 0.37961 

Epoch 1, validation: loss: 1.10699 

Epoch 2, coaching: loss: 0.19355 

Epoch 2, validation: loss: 1.26462 

# ...
# ...

Epoch 49, coaching: loss: 0.03233 

Epoch 49, validation: loss: 0.62286 

Epoch 50, coaching: loss: 0.03091 

Epoch 50, validation: loss: 0.54457

É interessante comparar desempenhos para diferentes configurações de teacher_forcing_ratio. Com uma configuração de 0.5a perda de treino diminui muito mais lentamente; o oposto é visto com uma configuração de 0. A perda de validação, no entanto, não é afetada significativamente.

O código para inspecionar as previsões do conjunto de testes permanece inalterado.

internet$eval()

test_preds <- vector(mode = "checklist", size = size(test_dl))

i <- 1

coro::loop(for (b in test_dl) {
  
  output <- internet(b$x$to(system = system), b$y$to(system = system), teacher_forcing_ratio = 0)
  preds <- as.numeric(output)
  
  test_preds((i)) <- preds
  i <<- i + 1
  
})

vic_elec_jan_2014 <- vic_elec %>%
  filter(yr(Date) == 2014, month(Date) == 1)

test_pred1 <- test_preds((1))
test_pred1 <- c(rep(NA, n_timesteps), test_pred1, rep(NA, nrow(vic_elec_jan_2014) - n_timesteps - n_forecast))

test_pred2 <- test_preds((408))
test_pred2 <- c(rep(NA, n_timesteps + 407), test_pred2, rep(NA, nrow(vic_elec_jan_2014) - 407 - n_timesteps - n_forecast))

test_pred3 <- test_preds((817))
test_pred3 <- c(rep(NA, nrow(vic_elec_jan_2014) - n_forecast), test_pred3)


preds_ts <- vic_elec_jan_2014 %>%
  choose(Demand) %>%
  add_column(
    mlp_ex_1 = test_pred1 * train_sd + train_mean,
    mlp_ex_2 = test_pred2 * train_sd + train_mean,
    mlp_ex_3 = test_pred3 * train_sd + train_mean) %>%
  pivot_longer(-Time) %>%
  update_tsibble(key = title)


preds_ts %>%
  autoplot() +
  scale_colour_manual(values = c("#08c5d1", "#00353f", "#ffbf66", "#d46f4d")) +
  theme_minimal()

Posit AI Weblog: série temporal da tocha, tomada três: previsão sequência a sequência

Figura 1: Previsões com uma semana de antecedência para janeiro de 2014.

Comparando isso com a previsão obtida na combinação RNN-MLP da última vez, não vemos muita diferença. Isso é surpreendente? Para mim é. Se me pedissem para especular sobre o motivo, eu provavelmente diria o seguinte: em todas as arquiteturas que usamos até agora, o principal transportador de informações tem sido o estado remaining oculto da RNN (primeira e única RNN nas duas configurações anteriores, codificador RNN neste). Será interessante ver o que acontece na última parte desta série, quando aumentamos a arquitetura do codificador-decodificador por atenção.

Obrigado por ler!

Foto de Suzuha Kozuki sobre Remover respingo

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *