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 x
deslocado 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.
state
també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_ratio
a proporção de sequências de entrada nas quais queremos realizar a recalibração. Em valid_batch()
isso deve ser sempre 0
enquanto 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.5
a 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()

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