Aula 2, RNN

Esta aula apresenta as redes neurais recorrentes, as RNN, feitas para processar sequências como o texto. Vamos implementar uma RNN do zero e descobrir, com um experimento, a sua grande limitação, a dificuldade de lembrar informações distantes no tempo.

A rede da aula anterior recebe entradas de tamanho fixo e as processa de uma vez. Mas o texto não é assim, ele é uma sequência que se desenrola, palavra após palavra, e o sentido de cada parte depende do que veio antes. Para processar sequências, precisamos de uma rede com memória, que carregue informação de um passo para o seguinte. Essa é a rede neural recorrente.

A RNN, na forma estudada por Elman em 1990, processa a sequência mantendo um estado escondido que é atualizado a cada novo elemento. Esse estado funciona como uma memória do que já foi visto. Nesta aula você vai construir uma RNN do zero, treiná-la em uma tarefa que exige memória e, no caminho, esbarrar na limitação que motivou as células mais sofisticadas das próximas aulas.


Objetivos

Ao final desta aula, você deve ser capaz de:

  • Explicar como uma RNN processa sequências mantendo um estado escondido.
  • Implementar a passagem para frente de uma RNN do zero.
  • Treinar uma RNN simples em uma tarefa de memória.
  • Reconhecer o problema do gradiente que some em sequências longas.

Teoria

Uma RNN lê a sequência um elemento por vez. A cada passo $t$, ela combina o elemento atual $x_t$ com o estado escondido anterior $h_{t-1}$ para produzir um novo estado $h_t$. Esse estado é a memória da rede, ele resume tudo o que foi visto até ali e é passado adiante. No final da sequência, o estado pode ser usado para uma classificação, ou um estado pode ser produzido a cada passo, conforme a tarefa.

A mesma operação, com os mesmos pesos, é aplicada em todos os passos. É isso que torna a RNN capaz de lidar com sequências de qualquer tamanho, ela apenas repete a célula recorrente o número de vezes necessário. Treinamos a RNN com uma versão da backpropagation chamada backpropagation no tempo, que desenrola a sequência e propaga o erro de volta por todos os passos.

flowchart LR
    X1[x1] --> H1[h1]
    H0[h0] --> H1
    X2[x2] --> H2[h2]
    H1 --> H2
    X3[x3] --> H3[h3]
    H2 --> H3
    H3 --> Y[saída]

Aqui mora a fragilidade. Quando a sequência é longa, o erro precisa voltar por muitos passos, e o gradiente tende a encolher exponencialmente a cada um, até praticamente sumir. Esse é o problema do gradiente que some, descrito por Bengio, Simard e Frasconi em 1994. Na prática, a RNN aprende bem dependências curtas, mas tem muita dificuldade com dependências longas, esquecendo o começo de uma sequência grande.

Explicação Intuitiva

Imagine ler um texto cobrindo todas as palavras com a mão e revelando uma por vez, mantendo na cabeça um resumo do que já leu. Esse resumo é o estado escondido. A cada palavra nova, você atualiza o resumo. É assim que a RNN processa uma frase, sempre carregando uma memória compacta do passado.

O problema é que essa memória é frágil para coisas distantes. É como tentar lembrar a primeira palavra de um parágrafo muito longo quando você chega ao fim, a informação foi sendo sobrescrita e diluída a cada passo. A RNN sofre do mesmo mal, o que aconteceu há muitos passos vai perdendo força até desaparecer. Vamos ver esse efeito acontecer com um experimento simples.

Explicação Matemática

A atualização do estado escondido de uma RNN é dada por

\[h_t = \tanh\left(W_x x_t + W_h h_{t-1} + b\right),\]

em que $W_x$ pondera a entrada atual, $W_h$ pondera o estado anterior e $b$ é o viés. A tangente hiperbólica mantém o estado em uma faixa limitada. Para uma classificação ao final, aplicamos uma camada de saída sobre o último estado, $\hat{y} = \sigma(W_y h_T + b_y)$.

O problema do gradiente aparece na backpropagation no tempo. O gradiente que volta do passo $T$ até o passo $1$ passa por um produto de muitos fatores, ligados a $W_h$ e à derivada do tanh. Se esses fatores forem menores que 1, o produto encolhe exponencialmente com a distância, e o gradiente some. Se forem maiores que 1, ele explode. Esse comportamento é o que limita o alcance da memória de uma RNN simples, e é o ponto exato que a LSTM, na próxima aula, vai atacar.

Exemplo Prático

Vamos treinar uma RNN do zero em uma tarefa que isola a questão da memória, lembrar o primeiro bit de uma sequência binária. A rede lê a sequência inteira e, no final, deve dizer qual era o primeiro elemento. Para acertar, ela precisa carregar essa informação do início ao fim.

O experimento revela o problema de forma contundente. Com uma sequência curta, de três elementos, a RNN acerta praticamente sempre. Com uma sequência longa, de vinte e cinco elementos, a acurácia despenca para perto de 50 por cento, ou seja, o puro acaso, pois o gradiente sumiu e a rede não conseguiu aprender a carregar o primeiro bit por tanto tempo. O código está no notebook notebooks/modulo-05/02-rnn.ipynb, então abra-o ao lado para acompanhar.

Código Comentado

import numpy as np


def sigmoide(z):
    return 1 / (1 + np.exp(-z))


def treinar_rnn(T, n_amostras=600, H=12, epocas=120, taxa=0.1, seed=1):
    """Treina uma RNN para lembrar o primeiro bit de uma sequência de tamanho T."""
    rng = np.random.default_rng(seed)
    dados = rng.integers(0, 2, size=(n_amostras, T)).astype(float)
    alvo = dados[:, 0].copy()           # o alvo é o primeiro bit

    Wx = rng.normal(0, 0.3, H); Wh = rng.normal(0, 0.3, (H, H))
    bh = np.zeros(H); Wy = rng.normal(0, 0.3, H); by = 0.0

    for _ in range(epocas):
        for i in rng.permutation(n_amostras):
            seq = dados[i]
            hs = [np.zeros(H)]
            for t in range(T):          # passo para frente, guardando os estados
                hs.append(np.tanh(seq[t] * Wx + hs[-1] @ Wh + bh))
            yhat = sigmoide(hs[-1] @ Wy + by)

            dy = yhat - alvo[i]         # backpropagation no tempo
            Wy -= taxa * dy * hs[-1]; by -= taxa * dy
            dh = dy * Wy
            gWx = np.zeros(H); gWh = np.zeros((H, H)); gbh = np.zeros(H)
            for t in reversed(range(T)):
                draw = dh * (1 - hs[t + 1] ** 2)   # derivada do tanh
                gWx += seq[t] * draw
                gWh += np.outer(hs[t], draw)
                gbh += draw
                dh = draw @ Wh.T        # propaga para o passo anterior
            Wx -= taxa * gWx; Wh -= taxa * gWh; bh -= taxa * gbh

    # Avalia em sequências novas.
    teste = rng.integers(0, 2, size=(300, T)).astype(float)
    acertos = 0
    for i in range(300):
        h = np.zeros(H)
        for t in range(T):
            h = np.tanh(teste[i, t] * Wx + h @ Wh + bh)
        pred = 1 if sigmoide(h @ Wy + by) > 0.5 else 0
        acertos += int(pred == int(teste[i, 0]))
    return acertos / 300


print("Sequência curta (T=3) :", round(treinar_rnn(3), 3))
print("Sequência longa (T=25):", round(treinar_rnn(25), 3))

Ao rodar, a sequência curta atinge acurácia próxima de 1,0, enquanto a longa fica em torno de 0,5, que é o mesmo que chutar. Não é falta de capacidade da rede, é o gradiente que some ao voltar por vinte e cinco passos, impedindo a RNN de aprender a dependência longa. Esse resultado, simples e claro, é a motivação direta para as células com portões que veremos a seguir.

Exercícios

1) Conceitual: Explique o papel do estado escondido em uma RNN e por que os mesmos pesos são usados em todos os passos. 2) Conceitual: O que é o problema do gradiente que some, e por que ele aparece em sequências longas? 3) Prático: Teste tamanhos intermediários de sequência, como 8 e 15, e observe a partir de quando a acurácia começa a cair. 4) Prático: Aumente o número de neurônios escondidos e de épocas e veja se a RNN melhora um pouco na sequência longa. 5) Extensão: Pesquise o recorte de gradiente, o gradient clipping, e explique como ele ajuda com o problema do gradiente que explode.

Projeto da Aula

Investigue, de forma sistemática, o alcance da memória de uma RNN. A entrega é um experimento que treina a RNN da aula para vários tamanhos de sequência e registra a acurácia em cada um, montando uma tabela ou um gráfico de acurácia por tamanho.

Considere o projeto pronto quando você conseguir mostrar a curva de desempenho caindo conforme a sequência cresce e escrever um parágrafo relacionando essa queda com o problema do gradiente que some. Esse diagnóstico é a ponte perfeita para a próxima aula, em que a LSTM resolve exatamente esse problema.

Leituras Recomendadas

  • O artigo de Elman, Finding Structure in Time, que apresentou a RNN simples.
  • O artigo de Bengio, Simard e Frasconi sobre a dificuldade de aprender dependências longas.
  • Capítulos sobre redes recorrentes em Goodfellow e colegas, Deep Learning.

Referências Científicas

As referências abaixo são reais e estão registradas em references/referencias.bib. As chaves entre parênteses são as do BibTeX.

  • Elman, J. L. (1990). Finding Structure in Time. Cognitive Science, 14(2), 179-211. (elman1990finding)
  • Bengio, Y., Simard, P., e Frasconi, P. (1994). Learning Long-Term Dependencies with Gradient Descent is Difficult. IEEE Transactions on Neural Networks, 5(2), 157-166. (bengio1994longterm)
  • Goodfellow, I., Bengio, Y., e Courville, A. (2016). Deep Learning. MIT Press. (goodfellow2016deep)