Contents

Redes Neurais Recorrentes: RNN, LSTM e GRU

Introdução

Neste artigo, exploraremos o fascinante mundo das Redes Neurais Recorrentes (RNNs), uma tecnologia fundamental na inteligência artificial e no aprendizado de máquina. As RNNs são únicas devido à sua habilidade de processar e analisar sequências de dados, tornando-as ferramentas inestimáveis em campos que variam desde o reconhecimento de fala até a análise de séries temporais.

A essência das RNNs reside em sua capacidade de manter uma espécie de “memória” sobre as entradas anteriores. Isso as diferencia das redes neurais tradicionais, que processam cada entrada de forma independente, sem considerar a ordem ou a sequência dos dados. Neste contexto, as RNNs podem ser vistas como máquinas que têm uma compreensão contínua do contexto, algo crucial para tarefas como o processamento de linguagem natural ou a previsão de tendências baseadas em dados históricos.

Ao longo deste texto, mergulharemos nas particularidades das RNNs, começando com a explicação de como os dados sequenciais são fundamentais para seu funcionamento. Discutiremos os desafios e estratégias para treinar essas redes, abordaremos as inovações nas arquiteturas de RNN, como Long Short-Term Memory (LSTM), e ilustraremos como essas tecnologias são implementadas e utilizadas em aplicações do mundo real.

Prepare-se para uma jornada detalhada e informativa, ideal tanto para entusiastas da IA quanto para profissionais da área, que busca desvendar os mistérios e as capacidades das Redes Neurais Recorrentes.

Dados Sequenciais

Os Dados Sequenciais Massivos representam um conceito fundamental na área de ciência de dados, sendo particularmente interessantes para aqueles que estão começando a explorar este campo. Imagine uma longa fila de pessoas, onde cada pessoa representa um ponto de dado, e essa fila está se movendo constantemente. Cada pessoa (ou ponto de dado) tem uma relação com a pessoa à sua frente e atrás dela, criando uma sequência contínua. Esta é a essência dos Dados Sequenciais: uma série de informações coletadas ao longo do tempo, onde cada parte é significativamente ligada à próxima.

Para entender melhor esses dados, os cientistas utilizam algo chamado Conjuntos de Dados de Treinamento. Pense nisso como escolher alguns indivíduos específicos da fila para estudar mais de perto. Por exemplo, um cientista pode querer observar como a temperatura muda a cada hora do dia. Ao selecionar apenas esses pontos específicos, eles podem focar em padrões e tendências importantes, sem serem sobrecarregados pela quantidade massiva de dados.

Os Dados Sequenciais não estão limitados a um único campo; eles são usados em diversas áreas. Na linguística, por exemplo, cada palavra em uma frase segue a outra em uma ordem lógica, formando um texto coerente. Na medicina, a trajetória de um paciente, desde a entrada no hospital até a alta, é uma sequência de eventos interligados. Esta interconexão é vital para entender os dados como um todo, ao invés de ver cada ponto de dados isoladamente.

Uma parte crucial do trabalho com Dados Sequenciais é entender a Dependência Temporal. Isso significa que o que aconteceu no passado em uma sequência pode afetar o que acontece no futuro. Por exemplo, as decisões médicas são frequentemente baseadas no histórico de saúde do paciente, não sendo tomadas aleatoriamente. Essa relação entre passado e futuro ajuda a dar sentido a grandes quantidades de dados, permitindo previsões e insights mais precisos.

Por fim, um exemplo prático da importância dos Dados Sequenciais pode ser encontrado na previsão de preços de ações usando modelos autorregressivos. Esses modelos analisam o histórico de preços das ações para tentar prever o que acontecerá a seguir. É como tentar adivinhar o próximo número em uma sequência, baseando-se nos números anteriores. Esse conceito mostra como entender e analisar Dados Sequenciais é essencial em várias áreas, desde a ciência até as finanças, oferecendo uma base sólida para aqueles que estão começando a explorar o mundo da ciência de dados.

Redes Neurais Sem Estados Ocultos

O termo ‘sem estados ocultos’ refere-se a uma rede neural que não mantém nenhuma informação sobre as entradas anteriores além da atual. Em outras palavras, ela não possui memória dos dados que foram processados anteriormente. Vou explicar isso de uma maneira mais detalhada:

Em contextos de redes neurais, um ’estado oculto’ geralmente se refere à capacidade da rede de reter algum tipo de estado interno ou memória que carrega informações através de sequências de dados. Isso é comum em redes neurais recorrentes (RNNs), onde os estados ocultos transmitem informações de um passo de processamento para o próximo, permitindo que a rede faça uso do contexto ou da ordem sequencial dos dados.

Por outro lado, uma rede neural como o MLP (perceptron multicamadas), sem estados ocultos, trata cada entrada de forma independente, sem levar em conta a ordem ou a sequência. Cada entrada (por exemplo, uma imagem ou um conjunto de características) é processada de forma isolada. Este tipo de rede é adequado para tarefas onde a ordem dos dados de entrada não é importante.

Para exemplificar:

  • Com Estados Ocultos: Imagine uma rede neural que está tentando entender uma frase. Se possuir estados ocultos, como uma RNN, ela pode lembrar a palavra anterior enquanto processa a palavra atual, o que é muito útil para compreender o significado completo da frase.
  • Sem Estados Ocultos: Agora, imagine que você forneça a essa rede uma foto para classificar se é um gato ou um cachorro. A rede não precisa lembrar de outras fotos que viu antes; ela só precisa analisar a foto atual. É assim que um MLP sem estados ocultos opera.

Portanto, ‘sem estados ocultos’ significa que a rede neural não tem a capacidade de lembrar entradas anteriores e cada decisão é feita com base apenas na entrada atual.

Redes Neurais Recorrentes

As RNNs tem a capacidade de processar sequências de dados, como frases ou séries temporais. Diferente de redes neurais tradicionais, como o MLP, as RNNs possuem uma espécie de memória de curto prazo, armazenando informações dos passos anteriores da sequência. Esta memória é concretizada através dos “estados ocultos”, que permitem à rede fazer conexões ao longo do tempo.

A equação que define o funcionamento de uma RNN é uma excelente maneira de entender sua operação:

$$ H_t = \phi(W_{xh} \cdot X_t + W_{hh} \cdot H_{t-1} + b_h) $$

Nesta equação:

  • $ H_t $. é o estado oculto no momento atual, representando a memória atual da rede.
  • $ \phi $ é a função de ativação, como a tangente hiperbólica ou ReLU, introduzindo não-linearidade ao modelo.
  • $ X_t $ são os dados de entrada no momento atual.
  • $ W_{xh} $ e $ W_{hh} $ são os pesos que conectam, respectivamente, a entrada atual e o estado oculto anterior ao estado oculto atual.
  • $ b_h $ é o viés para os estados ocultos.

O processo de recorrência em RNNs envolve o uso do estado oculto anterior $ H_{t-1} $ para gerar o novo estado $ H_t $, juntamente com a entrada atual $ X_t $. Este mecanismo permite que a RNN mantenha um registro contextual ao longo do tempo, atualizando sua “memória” com base na nova entrada e na informação do passo anterior.

Além disso, apesar das RNNs processarem sequências de diferentes comprimentos, elas mantêm a quantidade de parâmetros constante, o que significa que a rede pode lidar com sequências longas sem aumentar a complexidade do modelo.

A principal vantagem das RNNs sobre redes neurais sem estados ocultos é sua habilidade de trabalhar com sequências onde a ordem e o contexto são importantes. Elas são particularmente úteis para tarefas como reconhecimento de fala, tradução de idiomas e previsão de séries temporais. A capacidade de armazenar e processar informações sequenciais torna as RNNs ferramentas poderosas para muitas aplicações em inteligência artificial.

Implementando Redes Neurais Recorrentes

Redes Neurais Recorrentes (RNNs) são fundamentais em muitas aplicações de aprendizado de máquina, especialmente aquelas que envolvem dados sequenciais, como processamento de linguagem natural ou séries temporais. Uma RNN é única em sua habilidade de manter um estado interno, capturando informações sobre os dados já processados, essencial para tarefas que envolvem dependências temporais.

Estrutura da Classe CustomRNN

No PyTorch, uma biblioteca de aprendizado de máquina para Python, uma RNN personalizada pode ser implementada estendendo a classe nn.Module. A classe CustomRNN, como definida no código fornecido, exemplifica uma implementação básica de uma RNN. Ela é inicializada com input_size, que define a dimensão dos dados de entrada, e hidden_size, que é a dimensão do estado oculto.

Inicialização dos Parâmetros

A RNN personalizada utiliza três conjuntos principais de parâmetros:

  1. Pesos da Entrada para o Estado Oculto (self.Wxh): Conectam cada entrada à camada do estado oculto.
  2. Pesos do Estado Oculto para o Estado Oculto (self.Whh): Permitem que a RNN mantenha informação ao longo do tempo.
  3. Viés para o Estado Oculto (self.bh): Contribui para a capacidade do modelo de se ajustar aos dados.

Processamento da Sequência

Durante a passagem para frente (forward), a RNN processa uma sequência de entrada (X_sequence) usando uma série de cálculos:

  • Cada elemento da sequência é processado individualmente.
  • O estado oculto é atualizado em cada passo de tempo com base na entrada atual e no estado oculto anterior.
  • A função tanh introduz não-linearidade, permitindo que a rede aprenda padrões complexos.
  • Os estados ocultos são armazenados e depois concatenados para formar a saída final da sequência.
import torch
import torch.nn as nn


class CustomRNN(nn.Module):
    def __init__(self, input_size, hidden_size):
        super(CustomRNN, self).__init__()
        self.input_size = input_size
        self.hidden_size = hidden_size

        # Inicializando os pesos e o viés
        self.Wxh = nn.Parameter(torch.randn(input_size, hidden_size))
        self.Whh = nn.Parameter(torch.randn(hidden_size, hidden_size))
        self.bh = nn.Parameter(torch.randn(1, hidden_size))

    def forward(self, X_sequence, Ht):
        outputs = []

        # Processando a sequência
        for t in range(X_sequence.size(0)):
            Xt = X_sequence[t]
            Ht = torch.tanh(torch.mm(Xt, self.Wxh) +
                            torch.mm(Ht, self.Whh) + self.bh)
            # Adiciona uma dimensão extra para seq_len
            outputs.append(Ht.unsqueeze(0))

        # Concatenando a saída para cada etapa de tempo em uma única tensor
        outputs = torch.cat(outputs, dim=0)
        return outputs, Ht


# Parâmetros
seq_length = 3  # Comprimento da sequência
batch_size = 1
input_size = 5  # Tamanho dos dados de entrada
hidden_size = 10  # Tamanho do estado oculto

# Instanciando a RNN personalizada
custom_rnn = CustomRNN(input_size, hidden_size)

# Inicializando os dados de entrada para a sequência completa
X_sequence = torch.randn(seq_length, batch_size, input_size)
hidden_state = torch.zeros(batch_size, hidden_size)  # Estado oculto inicial

# Passando a sequência através da RNN
output, hidden_state = custom_rnn(X_sequence, hidden_state)

print("Output:", output)
print("Hidden state:", hidden_state)

Treinamento de Redes Neurais Recorrentes (RNNs): Estratégias e Desafios

Redes Neurais Recorrentes (RNNs) são um tipo de rede neural artificial especializada no processamento de sequências de dados, como séries temporais ou linguagem natural. No entanto, lidar com sequências longas de dados pode ser desafiador para essas redes. Quando uma RNN tenta lembrar muitos dados anteriores, ela pode enfrentar problemas devido à grande quantidade de operações matemáticas envolvidas. Isso não só exige muito da memória do computador, mas também pode levar a erros de cálculo, um fenômeno conhecido como instabilidade numérica. Imagine tentar lembrar de uma longa lista de compras sem anotar nada – eventualmente, você pode começar a esquecer ou confundir os itens.

Um problema crítico em RNNs é o desvanecimento do gradiente, onde os valores do gradiente diminuem exponencialmente durante o treinamento, dificultando a atualização dos pesos para os primeiros elementos da sequência. A explosão do gradiente, por outro lado, ocorre quando os valores do gradiente crescem exponencialmente, podendo levar a instabilidades numéricas.

Para contornar esses desafios, pesquisadores desenvolveram arquiteturas avançadas como as redes LSTM e GRU, que implementam mecanismos de portas para controlar o fluxo de informações, permitindo que a rede retenha informações relevantes por mais tempo e descarte informações desnecessárias. Além disso, técnicas como o truncamento do gradiente são amplamente utilizadas. Essa abordagem é semelhante a uma RNN decidindo focar apenas nas partes mais recentes da sequência, ignorando dados antigos. Isso ajuda a manter os cálculos gerenciáveis e minimiza erros, sem perder muita precisão nas previsões.

Existem diferentes maneiras de realizar esses ajustes. Uma delas é o cálculo completo, que considera a contribuição de cada passo de tempo na atualização dos pesos, mas é um processo lento e complicado. Outras técnicas incluem o truncamento de passos de tempo e o truncamento aleatório. O truncamento de passos de tempo limita a análise aos eventos mais recentes, enquanto o truncamento aleatório utiliza uma variável aleatória para decidir quando parar de considerar passos de tempo anteriores.

Embora o truncamento aleatório possa parecer mais preciso teoricamente, na prática, não é necessariamente mais eficaz que o truncamento regular. Frequentemente, olhar apenas alguns passos para trás é suficiente para capturar as dependências mais importantes. Além disso, a variabilidade introduzida pela aleatoriedade pode não compensar os benefícios de ser um pouco mais preciso. Curiosamente, modelos que se concentram em um intervalo de tempo mais curto podem ser mais eficazes, pois isso pode atuar como uma forma de regularização, ajudando a rede a generalizar melhor para novos dados.

LSTM (Long Short-Term Memory)

A LSTM, que significa “Memória Longa de Curto Prazo”, é uma técnica avançada em Inteligência Artificial, especialmente útil para lidar com redes neurais, que são como o cérebro dos computadores. Ela foi criada para superar um desafio específico encontrado nas Redes Neurais Recorrentes (RNNs).

As RNNs são ótimas para processar sequências de dados, como uma série de números ou palavras em uma frase. Porém, elas enfrentam um grande problema chamado “desaparecimento e explosão de gradientes”. Isso significa que, ao tentar aprender com dados muito longos, as RNNs perdem informações importantes ou as informações se tornam excessivamente amplificadas. Imagine tentar lembrar uma longa história: quanto mais longa a história, mais difícil é lembrar os detalhes do início ou você pode acabar focando demais em uma parte específica.

A LSTM foi desenvolvida para resolver esse problema. Ela é uma versão melhorada das RNNs, equipada com uma estrutura especial chamada “célula de memória”. Essas células de memória são como mini-armazenadores de informações, capazes de reter informações importantes por um longo tempo e descartar informações desnecessárias. Isso permite que a LSTM mantenha um equilíbrio, evitando a perda ou amplificação excessiva de informações ao processar sequências de dados.

A “memória de longo e curto prazo” no nome da LSTM vem dessa habilidade de gerenciar diferentes tipos de memória. As RNNs padrão têm memória de longo prazo, armazenada em seus pesos (ajustes no modelo), e memória de curto prazo, nas ativações (informações temporárias passadas entre os nós). A LSTM, com suas células de memória, adiciona um nível intermediário de armazenamento, ajudando o modelo a manter informações relevantes e descartar as irrelevantes durante o processamento de dados.

Porta de entrada, porta de esquecimento e porta de saída

As Redes Neurais LSTM (Long Short-Term Memory) processam sequências de dados, como textos ou séries temporais, usando um sistema de portas que decide o que lembrar e o que esquecer. Essas portas operam de maneira coordenada para gerenciar a memória da célula em três aspectos principais:

  1. Porta de Entrada (Input Gate): Esta porta determina quais novas informações são relevantes e devem ser armazenadas na memória. A equação associada, $ I_t = \sigma(X_t W_{xi} + H_{t-1} W_{hi} + b_i) $, usa a função sigmoide $ \sigma $ para avaliar a importância das novas informações, transformando os valores para o intervalo entre 0 e 1. Valores mais próximos de 1 indicam alta relevância.

  2. Porta de Esquecimento (Forget Gate): Responsável por avaliar quais informações antigas não são mais úteis e devem ser descartadas. A equação $ F_t = \sigma(X_t W_{xf} + H_{t-1} W_{hf} + b_f) $ também utiliza a função sigmoide para determinar quais dados devem ser mantidos (valores próximos de 1) ou descartados (valores próximos de 0).

  3. Porta de Saída (Output Gate): Esta porta decide quais informações da memória serão usadas no cálculo da saída da rede naquele momento. A equação $ O_t = \sigma(X_t W_{xo} + H_{t-1} W_{ho} + b_o) $ determina quais informações armazenadas são relevantes para representar o estado atual.

Cada uma dessas portas considera a entrada atual $ X_t $ (por exemplo, uma palavra nova em uma frase), o estado anterior $ H_{t-1} $ (o que foi processado anteriormente), e realiza operações matemáticas (como multiplicações e adições) com pesos e polarizações ajustados durante o treinamento da rede.

Os pesos e vieses $ W $ e $ b $ em cada equação são ajustados durante o treinamento do modelo para otimizar o desempenho. Eles determinam a importância das entradas e do estado anterior em cada decisão. As funções sigmóides nas equações ajudam a modelar decisões binárias, facilitando a escolha entre manter ou descartar informações, pois funções sigmoids retornam valores entre 0 (zero) e 1 (um), onde quanto mais perto de zero o valor ele deve ser esquecido e quanto mais perto de um ele deve ser lebrando.

Nó de entrada

O nó de entrada, ou $ \tilde{C}_t $, em uma arquitetura LSTM é onde os novos valores candidatos para o estado da célula são gerados. A equação:

$$ \tilde{C}_t = \tanh ( X_t W_xc + H_t-1 W_hc + b_c ) $$

é central para essa função. Aqui, $ \tanh $ é a função de ativação tangente hiperbólica que ajuda a normalizar as entradas entre -1 e 1, permitindo que o modelo capture relações não-lineares. $ W_{xc} $ e $ W_{hc} $ são matrizes de pesos que transformam, respectivamente, a entrada atual $ X_t $ e o estado oculto anterior $ H_{t-1} $ . O vetor de bias $ b_c $ permite ajustes finos na transformação.

Este nó é essencial por várias razões:

  • Geração de Novos Candidatos: Ele combina informações atuais e aprendizados anteriores para propor atualizações ao estado da célula, permitindo que a LSTM considere novas informações enquanto mantém conhecimentos anteriores.

  • Captura de Relações Complexas: Através da função $ \tanh $, o nó de entrada pode processar e preparar informações complexas, que são essenciais para as decisões subsequentes sobre quais dados manter ou descartar.

  • Atualização Seletiva: Os valores gerados aqui são ponderados pela porta de entrada, o que significa que apenas informações consideradas úteis são mantidas, preservando a capacidade da LSTM de manter a memória sobre sequências de dados por longos períodos.

Portanto, o nó de entrada é um componente crucial que assegura a capacidade das LSTMs de atualizar sua memória de forma seletiva e controlada, o que é vital para tarefas que exigem a compreensão e o processamento de sequências de dados, como a tradução de idiomas ou a previsão de séries temporais.

Estado interno da célula de memória

O estado interno de uma célula de memória em uma rede LSTM é uma forma de a rede neural armazenar informações ao longo do tempo. Cada célula de memória em uma LSTM tem a capacidade de manter um registro do que aconteceu em passos anteriores do tempo. Pense nisso como uma memória de curto prazo para a rede.

Aqui estão os pontos principais para entender o estado interno de uma célula de memória:

  • Armazenamento de Informações: O estado interno mantém informações importantes que a rede neural precisa lembrar. Essas informações são utilizadas para fazer previsões ou tomar decisões nos passos seguintes.

  • Persistência: Ao contrário das redes neurais tradicionais, onde a informação flui apenas em uma direção e é perdida após cada etapa, o estado interno da LSTM pode reter informações por um longo período de tempo. Isso é crucial para tarefas como processamento de linguagem natural, onde a compreensão do contexto passado (palavras ou frases anteriores) é necessária para interpretar o presente.

  • Atualização Seletiva: O estado interno pode ser atualizado de forma seletiva. Isso significa que em cada etapa do tempo, a rede decide com base na nova entrada e no estado oculto anterior, o que deve ser lembrado (atualização) e o que deve ser esquecido.

  • Influência na Saída: O estado interno, juntamente com o estado oculto, influencia a saída final da LSTM. Por exemplo, em uma tarefa de previsão de texto, o estado interno pode ajudar a decidir qual será a próxima palavra com base nas palavras anteriores.

A equação do estado interno de uma célula de memória numa LSTM é dada por:

$$ C_t = F_t \odot C_{t-1} + I_t \odot \tilde{C}_t $$

Aqui está o que cada termo na equação representa e como funciona de maneira simples:

  • $ C_t $: Este é o novo estado interno da célula de memória no tempo atual ’t’. É o resultado da combinação do que a célula decide manter da memória anterior e o que decide adicionar da nova informação proposta.

  • $ F_t $: A porta de esquecimento decide quanto da memória anterior $ ( C_{t-1} ) $ será mantida. Se $ F_t $ for 0, isso significa esquecer tudo da memória anterior; se for 1, significa manter tudo.

  • $ \odot $: Este é o símbolo para o produto de Hadamard, ou seja, multiplicação elemento a elemento. Então, $ F_t \odot C_{t-1} $ significa que cada elemento do estado da célula anterior é multiplicado pelo elemento correspondente da porta de esquecimento.

  • $ C_{t-1} $: O estado interno da célula de memória no tempo anterior ’t-1’. Ele contém a informação que foi armazenada até esse ponto.

  • $ I_t $: A porta de entrada decide quanto da nova informação candidata $ ( \tilde{C}_t ) $ será adicionada à célula de memória.

  • $ \tilde{C}_t $: Este é o novo candidato a estado da célula proposto, baseado na informação atual e no estado oculto anterior.

  • $ I_t \odot \tilde{C}_t $: Similar ao produto de Hadamard com a porta de esquecimento, aqui a nova informação candidata é multiplicada pela porta de entrada. Isso determina quanto da nova informação será adicionada ao estado da célula.

Por que funciona?

  • Flexibilidade: Esta equação permite que a LSTM ajuste dinamicamente o quanto quer lembrar de informação antiga e o quanto quer adicionar de nova informação. Isso dá à rede a capacidade de manter informações relevantes ao longo do tempo e descartar informações desnecessárias.

  • Memória de Longo Prazo: Esta estrutura de célula de memória é o que permite que LSTMs sejam usadas em tarefas que requerem memória de longo prazo, como processamento de linguagem natural ou séries temporais, porque elas podem lembrar informações por muitos passos de tempo.

Estado oculto

O estado oculto carrega a informação que é passada adiante na rede neural. O estado da célula $ ( C_t ) $ contém a memória de longo prazo, enquanto o estado oculto $ ( H_t ) $ é a memória de curto prazo que também serve como a saída da LSTM em um determinado passo de tempo.

A função da porta de saída $ ( O_t ) $ é como um regulador que controla quando e quanto da memória de longo prazo no estado da célula é transferida para o estado oculto. Se a porta de saída estiver aberta (valores próximos a 1), a memória fluirá para o estado oculto e afetará a saída da rede. Se estiver fechada (valores próximos a 0), a memória é retida dentro da célula e não afeta imediatamente a saída.

Esse mecanismo permite que a LSTM decida o que é importante passar para as próximas camadas ou para os próximos passos de tempo, possibilitando a retenção de informações relevantes e a supressão de informações desnecessárias, o que é crucial para o processamento de sequências de dados e tarefas que dependem do contexto temporal.

A equação para o estado oculto é:

$$ H_t = O_t \odot \tanh(C_t) $$

Onde:

  • $ H_t $: É o estado oculto no tempo ’t’, que é o que a LSTM passa para a próxima camada ou o próximo passo de tempo.
  • $ O_t $: É a porta de saída no tempo ’t’. Ela decide quanto do estado interno será revelado ao estado oculto. Quando $ O_t $ está próximo de 1, isso permite que as informações fluam para fora livremente. Quando está próximo de 0, bloqueia o fluxo de informações, mantendo-as dentro da célula.
  • $ \odot $: Representa a multiplicação elemento a elemento, conhecida como produto de Hadamard.
  • $ \tanh(C_t) $: É a função tangente hiperbólica aplicada ao estado da célula no tempo ’t’ $ ( C_t ) $. Ela ajuda a normalizar os valores do estado da célula para estarem entre -1 e 1.

Implementando uma LSTM (Long Short-Term Memory)

O código fornecido define uma célula de LSTM (Long Short-Term Memory) personalizada usando a biblioteca PyTorch, que é um dos frameworks de aprendizado profundo mais populares. A célula LSTM é uma unidade fundamental de uma rede LSTM, que é amplamente utilizada em problemas de séries temporais e processamento de linguagem natural devido à sua capacidade de capturar dependências de longo prazo.

import torch
import torch.nn as nn


class LSTMCell(nn.Module):
    def __init__(self, input_size, hidden_size):
        super(LSTMCell, self).__init__()
        self.input_size = input_size
        self.hidden_size = hidden_size

        # Parâmetros do portão de entrada (input gate)
        self.W_ii = nn.Parameter(torch.Tensor(hidden_size, input_size))
        self.W_hi = nn.Parameter(torch.Tensor(hidden_size, hidden_size))
        self.b_ii = nn.Parameter(torch.Tensor(hidden_size))
        self.b_hi = nn.Parameter(torch.Tensor(hidden_size))

        # Parâmetros do portão de esquecimento (forget gate)
        self.W_if = nn.Parameter(torch.Tensor(hidden_size, input_size))
        self.W_hf = nn.Parameter(torch.Tensor(hidden_size, hidden_size))
        self.b_if = nn.Parameter(torch.Tensor(hidden_size))
        self.b_hf = nn.Parameter(torch.Tensor(hidden_size))

        # Parâmetros do nó de entrada (input node)
        self.W_ig = nn.Parameter(torch.Tensor(hidden_size, input_size))
        self.W_hg = nn.Parameter(torch.Tensor(hidden_size, hidden_size))
        self.b_ig = nn.Parameter(torch.Tensor(hidden_size))
        self.b_hg = nn.Parameter(torch.Tensor(hidden_size))

        # Parâmetros do portão de saída (output gate)
        self.W_io = nn.Parameter(torch.Tensor(hidden_size, input_size))
        self.W_ho = nn.Parameter(torch.Tensor(hidden_size, hidden_size))
        self.b_io = nn.Parameter(torch.Tensor(hidden_size))
        self.b_ho = nn.Parameter(torch.Tensor(hidden_size))

        # Inicialização dos parâmetros
        self.init_parameters()

    def init_parameters(self):
        # Inicializa os parâmetros com distribuições adequadas
        for p in self.parameters():
            if p.data.ndimension() >= 2:
                nn.init.xavier_uniform_(p.data)
            else:
                nn.init.zeros_(p.data)

    def forward(self, inputs, init_states=None):
        """
        Método forward para processar uma sequência de entradas ao longo do tempo.
        :param inputs: Sequência de tensores de entrada.
        :param init_states: Estados iniciais (hidden state e cell state).
        :return: Saídas ao longo do tempo e o último estado (hidden state e cell state).
        """
        if init_states is None:
            h_t = torch.zeros(
                (inputs.shape[1], self.hidden_size), device=inputs.device)
            c_t = torch.zeros(
                (inputs.shape[1], self.hidden_size), device=inputs.device)
        else:
            h_t, c_t = init_states

        outputs = []
        # Percorre a sequência de entradas
        for x in inputs:
            # Portão de entrada: decide quais informações serão atualizadas
            i_t = torch.sigmoid(x @ self.W_ii.t() +
                                self.b_ii + h_t @ self.W_hi.t() + self.b_hi)

            # Portão de esquecimento: decide quais informações serão descartadas do estado da célula
            f_t = torch.sigmoid(x @ self.W_if.t() +
                                self.b_if + h_t @ self.W_hf.t() + self.b_hf)

            # Nó de entrada: cria um vetor de novos candidatos a serem adicionados ao estado da célula
            g_t = torch.tanh(x @ self.W_ig.t() + self.b_ig +
                             h_t @ self.W_hg.t() + self.b_hg)

            # Atualização do estado interno da célula de memória
            c_t = f_t * c_t + i_t * g_t

            # Portão de saída: decide quais partes do estado da célula serão saídas
            o_t = torch.sigmoid(x @ self.W_io.t() +
                                self.b_io + h_t @ self.W_ho.t() + self.b_ho)

            # Estado oculto: é a saída do LSTM, utilizando o estado da célula passado por uma função tanh
            h_t = o_t * torch.tanh(c_t)

            outputs.append(h_t)

        return outputs, (h_t, c_t)


# Tamanho da entrada e tamanho oculto para demonstração
input_size = 10
hidden_size = 20

# Instância da célula LSTM
lstm_cell = LSTMCell(input_size, hidden_size)

# Exemplo de sequência de entrada e estados iniciais
inputs = torch.randn(5, 1, input_size)  # Sequência de entradas
init_states = (torch.zeros(1, hidden_size), torch.zeros(
    1, hidden_size))  # Estados iniciais nulos

# Saídas ao longo do tempo e os últimos estados
outputs, (h_n, c_n) = lstm_cell(inputs, init_states)
outputs

A classe LSTMCell herda de nn.Module, que é a base de todos os módulos de rede neural no PyTorch. O construtor __init__ inicializa os parâmetros da célula LSTM, que são:

  • Parâmetros do portão de entrada: responsáveis por decidir quais informações serão atualizadas no estado da célula.
  • Parâmetros do portão de esquecimento: determinam quais informações do estado da célula serão descartadas, ajudando a evitar o problema do gradiente desaparecido.
  • Parâmetros do nó de entrada: criam um vetor de novos candidatos que podem ser adicionados ao estado da célula.
  • Parâmetros do portão de saída: selecionam as partes do estado da célula que serão utilizadas para calcular o estado oculto final.

Os parâmetros são matrizes de pesos e vetores de viés que serão aprendidos durante o treinamento da rede. A função init_parameters é usada para inicializar esses parâmetros com valores adequados, que é crucial para o bom desempenho da rede neural.

O método forward é o coração da célula LSTM, onde a lógica para processar uma sequência de entradas ao longo do tempo é implementada. Se nenhum estado inicial é fornecido, o estado oculto e o estado da célula são inicializados com zeros. Em seguida, para cada elemento da sequência de entrada, os portões de entrada, esquecimento e saída são calculados usando funções de ativação sigmóide, e o nó de entrada é calculado com a função de ativação tangente hiperbólica. Esses valores são usados para atualizar o estado da célula e calcular o novo estado oculto. O estado oculto resultante é coletado em uma lista de saídas.

No final do código, uma instância da célula LSTM é criada com tamanhos de entrada e oculto especificados. Uma sequência de entradas aleatórias é passada para a célula LSTM junto com estados iniciais nulos, e a célula LSTM processa a sequência, retornando as saídas ao longo do tempo e os estados finais.

GRU (Gated Recurrent Units)

As LSTMs podem ser computacionalmente intensivas, o que significa que elas precisam de bastante poder de processamento e tempo para aprender. Por causa disso, pesquisadores começaram a procurar alternativas mais simples e eficientes. Uma dessas alternativas é a GRU.

A GRU é uma variação da LSTM que também é capaz de capturar dependências de longo prazo nos dados, mas de forma mais eficiente. Ela faz isso usando uma estrutura mais simplificada que combina dois dos portões das LSTMs em um só, o que reduz o número de operações matemáticas necessárias e, por consequência, o tempo de computação.

Portão de Atualização e Portão de Reset

As redes neurais GRU (Gated Recurrent Unit) e LSTM (Long Short-Term Memory) destacam-se no processamento de sequências, como no processamento de linguagem natural e análise de séries temporais. O Portão de Atualização (Update Gate) e o Portão de Reset (Reset Gate) são mecanismos essenciais que controlam o fluxo de informações nessas redes.

Compreendendo o Portão de Reset (Reset Gate):

Portão de Reset ajuda a determinar a quantidade de informação passada que será utilizada para calcular o estado atual.

Quando a GRU processa uma sequência de dados, ela mantém um estado oculto que carrega informações ao longo de diferentes pontos no tempo. Este estado oculto é fundamental para que a rede possa fazer previsões ou tomar decisões baseadas não apenas na entrada atual, mas também no que aprendeu previamente.

O funcionamento do Portão de Reset pode ser entendido da seguinte forma:

  • Se o Portão de Reset estiver próximo de 1 (ou seja, quando a função de ativação sigmoide retorna um valor alto), isso significa que o estado oculto anterior $ H_{t-1} $ é considerado importante, e portanto, muita da informação que ele contém deve ser “lembrada” ou mantida, influenciando fortemente o próximo estado oculto.

  • Se o Portão de Reset estiver próximo de 0 (ou seja, a função de ativação sigmoide retorna um valor baixo), isso indica que o estado anterior não é tão relevante para o momento atual na sequência. Nesse caso, a rede “esquece” ou descarta parte do que sabia anteriormente, permitindo que o novo estado oculto seja formado com menos influência do passado.

Portanto, o Portão de Reset age como um regulador que pode dinamicamente esquecer informações desnecessárias do passado ou preservar aspectos importantes para contribuir com decisões ou previsões futuras, baseando-se na relevância das informações passadas para a tarefa atual. A equação atualizada para o Portão de Reset é:

$$ R_t = \sigma(X_t W_{xr} + H_{t-1} W_{hr} + b_r) $$

Onde $ R_t $ é o vetor do Portão de Reset no tempo $ t $, $ \sigma $ é a função sigmoide, $ X_t $ é o vetor de entrada, $ W_{xr} $ é a matriz de pesos da entrada para o Portão de Reset, $ H_{t-1} $ é o estado oculto anterior, $ W_{hr} $ é a matriz de pesos do estado oculto para o Portão de Reset, e $ b_r $ é o viés. Este portão permite que a rede neural decida eficientemente o que esquecer, especialmente quando mudanças significativas ocorrem nos dados.

Explorando o Portão de Atualização (Update Gate):

Portão de Atualização (Update Gate) controla quanto do estado anterior ainda podemos querer lembrar.

O Portão de Atualização opera da seguinte maneira:

  • Quando o valor do Update Gate é próximo de 1, significa que a rede decide manter a maior parte do estado oculto anterior $ H_{t-1} $. Isso é útil em situações em que a informação passada é relevante para a previsão ou entendimento do próximo passo na sequência. Por exemplo, se a sequência é uma frase onde o próximo termo depende fortemente do contexto fornecido pelos termos anteriores, o Update Gate permitirá que este contexto seja mantido.

  • Por outro lado, se o valor do Update Gate é próximo de 0, a rede está decidindo atualizar o seu estado oculto com novas informações, dando menos ênfase ao que foi aprendido anteriormente. Isto é benéfico quando o novo dado de entrada contém informações suficientes para a previsão ou decisão atual, e o histórico anterior é menos importante.

O Update Gate, portanto, desempenha um papel crítico na determinação de como as informações passadas são fundidas com as novas entradas para formar o estado atual. Isso permite que a GRU seja adaptável ao contexto da sequência de dados, mantendo a flexibilidade para manter informações críticas ou adaptar-se a novos padrões à medida que emergem. A equação ajustada é:

$$ Z_t = \sigma(X_t W_{xz} + H_{t-1} W_{hz} + b_z) $$

Aqui, $ Z_t $ é o vetor do Portão de Atualização, $ X_t $ é o vetor de entrada, $ W_{xz} $ e $ W_{hz} $ são as matrizes de pesos para a entrada e para o estado oculto anterior, respectivamente, e $ b_z $ é o viés. Este portão desempenha um papel crucial na preservação de informações importantes ao longo do tempo, permitindo a continuidade e a coerência na sequência de dados.

Estado Oculto do Candidato

O estado oculto do candidato $ \tilde{H_t} $ é uma versão provisória do novo estado oculto que a GRU está tentando construir em um dado momento. Este estado é calculado usando a entrada atual $ X_t $ e o estado oculto anterior $ H_{t-1} $, ambos influenciados por suas respectivas matrizes de pesos $ W $ e $ U $, e um vetor de viés $ b $. A fórmula é:

$$ \tilde{H_t} = \tanh(X_t W_{xh} + (R_t \odot H_{t-1}) W_{hh} + b_h) $$

Aqui:

  • $ \tanh $ é a função de ativação tangente hiperbólica, que transforma os valores de entrada em um novo conjunto de valores entre -1 e 1.
  • $ \odot $ é o operador de produto Hadamard, que significa que a multiplicação é feita elemento a elemento.
  • $ R_t $ é o vetor resultante do Portão de Reset, que decide quanto do estado anterior $ H_{t-1} $ será considerado ao calcular o novo candidato a estado oculto.

Com base na influência do Portão de Reset, o estado oculto do candidato é criado. Então, o Portão de Atualização decidirá quanto do estado oculto anterior será mantido e quanto do novo candidato a estado oculto será usado para formar o estado oculto final $ H_t $.

Estado Oculto

A atualização do estado oculto $ H_t $ em uma GRU é feita por meio da seguinte equação:

$$ H_t = Z_t \odot H_{t-1} + (1 - Z_t) \odot \tilde{H}_t $$

  • $ H_t $: Estado oculto no tempo $ t $, que será passado para o próximo passo de tempo e para a camada de saída se necessário.
  • $ H_{t-1} $: Estado oculto no tempo $ t-1 $, ou seja, o estado oculto do passo anterior.
  • $ \tilde{H}_t $: Estado oculto candidato no tempo $ t $, calculado com base na entrada atual e no estado oculto anterior, após ser modificado pelo portão de reset.
  • $ Z_t $: Portão de atualização no tempo $ t $, que determina quanto do estado anterior será mantido.
  • $ \odot $: Operador de produto Hadamard, que significa multiplicação elemento a elemento.

Esta equação determina como o novo estado oculto $ H_t $ é calculado:

  1. Portão de Atualização $ Z_t $: Determina a proporção do estado anterior $ H_{t-1} $ que deve ser retido. Quanto mais próximo de 1, mais do estado anterior é mantido.

  2. Estado Oculto Anterior $ H_{t-1} $: É ponderado pelo portão de atualização $ Z_t $ para decidir quanto desse estado anterior deve ser mantido.

  3. Estado Oculto Candidato $ \tilde{H}_t $: É uma nova proposta de estado oculto gerada com base na entrada atual e no estado anterior $ H_{t-1} $, ajustado pelo portão de reset. Ele é ponderado por $ 1 - Z_t $, indicando que se o portão de atualização favorece o estado anterior, o impacto do estado candidato será menor, e vice-versa.

  4. Combinação Final: O estado oculto atualizado $ H_t $ é a combinação do estado anterior ajustado pelo portão de atualização com o estado candidato ajustado pela inversão do portão de atualização. Isso permite que a GRU decida se deve manter a informação antiga (com base na relevância determinada pelo portão de atualização) ou substituí-la por uma nova proposta de estado.

Essa mecânica permite que a GRU se adapte a diferentes requisitos de dependência de tempo, retendo informações importantes de passos anteriores da sequência ou atualizando-as com novas informações, conforme necessário.

Implementando uma GRU (Gated Recurrent Units)

O fragmento de código apresentado é uma implementação customizada de uma célula GRU utilizando PyTorch.

import torch
from torch import nn

class GRUCell(nn.Module):
    def __init__(self, input_size, num_hiddens):
        super(GRUCell, self).__init__()
        self.num_hiddens = num_hiddens
        # Parâmetros do portão de atualização
        self.W_xz = nn.Parameter(torch.randn(input_size, num_hiddens))
        self.W_hz = nn.Parameter(torch.randn(num_hiddens, num_hiddens))
        self.b_z = nn.Parameter(torch.zeros(num_hiddens))
        # Parâmetros do portão de reset
        self.W_xr = nn.Parameter(torch.randn(input_size, num_hiddens))
        self.W_hr = nn.Parameter(torch.randn(num_hiddens, num_hiddens))
        self.b_r = nn.Parameter(torch.zeros(num_hiddens))
        # Parâmetros do estado oculto candidato
        self.W_xh = nn.Parameter(torch.randn(input_size, num_hiddens))
        self.W_hh = nn.Parameter(torch.randn(num_hiddens, num_hiddens))
        self.b_h = nn.Parameter(torch.zeros(num_hiddens))
        # Inicialização dos parâmetros
        self.init_parameters()

    def init_parameters(self):
        # Um procedimento simples de inicialização
        for p in self.parameters():
            if p.data.ndimension() >= 2:
                nn.init.xavier_uniform_(p.data)
            else:
                nn.init.zeros_(p.data)

    def forward(self, inputs, H=None):
        if H is None:
            # Estado inicial com forma: (tamanho_do_lote, num_hiddens)
            H = torch.zeros(
                (inputs.shape[1], self.num_hiddens), device=inputs.device)
        outputs = []
        for X in inputs:
            # Cálculo do portão de atualização
            Z = torch.sigmoid(torch.matmul(X, self.W_xz) +
                              torch.matmul(H, self.W_hz) + self.b_z)
            # Cálculo do portão de reset
            R = torch.sigmoid(torch.matmul(X, self.W_xr) +
                              torch.matmul(H, self.W_hr) + self.b_r)
            # Cálculo do estado oculto candidato
            H_tilde = torch.tanh(torch.matmul(X, self.W_xh) +
                                 torch.matmul(R * H, self.W_hh) + self.b_h)
            # Atualização do estado oculto
            H = Z * H + (1 - Z) * H_tilde
            outputs.append(H)
        return torch.stack(outputs), H


# Exemplo de uso:
# Definir o tamanho da entrada e o número de unidades ocultas
input_size = 5  # Dimensão de características de entrada de exemplo
num_hiddens = 10  # Número de unidades ocultas

# Criar uma célula GRU
gru_cell = GRUCell(input_size, num_hiddens)

# Criar alguns dados de entrada fictícios
# (comprimento_da_sequência, tamanho_do_lote, input_size)
inputs = torch.randn(3, 1, input_size)

# Passagem para a frente através da célula GRU
outputs, H = gru_cell(inputs)
outputs

Conclusão

Ao longo deste artigo, foi abordado os conceitos e aplicações de Redes Neurais Recorrentes, incluindo suas variações avançadas como LSTM e GRU. Observamos como essas tecnologias são cruciais para entender e processar dados sequenciais, oferecendo soluções inovadoras para desafios complexos em diversos campos, desde o processamento de linguagem natural até a análise de séries temporais.

As RNNs, com sua capacidade única de manter uma “memória” dos dados anteriores, revolucionaram a maneira como lidamos com sequências de dados. As LSTMs e GRUs, com seus mecanismos de portões, levaram essa capacidade ainda mais longe, permitindo um controle mais refinado sobre o que é lembrado e o que é esquecido, tornando o processamento de sequências longas e complexas mais eficiente e eficaz.

A implementação dessas redes, como demonstrado através dos exemplos de código em PyTorch, ilustra a flexibilidade e adaptabilidade destas tecnologias em aplicações práticas. Com os exemplos de LSTM e GRU, vimos como é possível construir e treinar redes neurais que podem captar e utilizar dependências de longo prazo nos dados, uma habilidade essencial para muitas tarefas de IA atuais.

Em suma, as Redes Neurais Recorrentes, LSTMs e GRUs são ferramentas poderosas no arsenal da Inteligência Artificial, oferecendo a capacidade de processar e interpretar sequências de dados de maneira eficaz. Seu uso contínuo e evolução provavelmente abrirão novos horizontes e possibilidades no campo do aprendizado de máquina e além, contribuindo significativamente para o avanço da tecnologia e sua aplicação em uma variedade de domínios da vida real.