Pular para o conteúdo principal

Classificação 4: RNN parte 2 (+ convolução)

Admito que eu já disse tudo o que precisava dizer nos posts anteriores, para os testes dessa última etapa, usei 2 redes neurais com apenas 1 diferença entre elas, uma usa convolução 1d e outra 2d, e por não ter muito o que falar no momento vamos direto ao que interessa.

Procedimentos

  1. init_hidden

  2. recurrence (lstm)

  3. convolução

    1. convolução (nn.Conv)
    2. ReLu
    3. MaxPool
    4. BatchNorm
  4. funções lineares (3 no total)

Resultados:

/images/classification_conv_rnn.png

É interessante notar que mesmo não alcançando um bom resultado, o aprendizado com a convolução 1d indica um aprendizado mais "real" dentre todos até agora, talvez se seguisse o aprendizado por mais 50 ou até mesmo 100 épocas, a acurácia talvez se equiparasse à convolução 2d.

Sobre a convolução 2d fica bem claro que mesmo mesmo alcançando basicamente o mesmo resultado que sem convolução, a distância entre a linha que representa os percentuais de acertos para os dados de treinamento em relação aos de teste é o maior dentre os 4 testes com redes convolucionais, isso indica um ajuste mais rápido ao treinamento ainda que para os testes tenha chegado no mesmo limite que na rnn sem convolução.

Resumindo os parágrafos anteriores, a convolução 1d + rnn leva a um aprendizado mais lento, o que não é ruim, aprender muito rápido nesse contexto significa não explorar adequadamente o espaço de busca a ser percorrido pela otimização (gosto de definir algoritmos de otimização como uma busca heurística num hiperplano altamente irregular na maioria dos casos), aprender devagar tem seu lado bom mas pela ausência de garantias que chegará em aproximadamente 75% de acerto nos dados de teste, só posso concluir que tende ao underfitting, especialmente por nenhum experimento que fiz, mesmo com taxa de aprendizado mais alta conseguiu chegar a tal resultado, porém só posso ter certeza treinando por mais épocas. Por outro lado a convolução 2d + rnn tendeu ao overfitting, isso fica bem claro pela distância crescente entre a acurácia para dados de testes e treinamento. Resumindo o resumo: neste caso não necessariamente adicionar etapas como a convolução foi um bom negócio.

---

post scriptum: Pensei em fazer um post final com um resumo geral, mas agora considero isso desnecessário, no repositório ainda bagunçado com os códigos desse experimento há um notebook chamado "relatorio.ipynb" onde estão os gráficos e uma tabela que indica coisas como quantidade de épocas taxas de aprendizado usadas.

Classificação 3: RNN parte 1

Nste primeiro experimento com redes neurais recorrentes, vamos apenas analisar o efeito da própria recorrência com o LSTM e GRU. Preliminarmente é preciso compreender alguns aspectos:

  1. O que faremos com o skip-gram treinado pelo Gensim?
  2. Em quê o LSTM ou o GRU poderão influenciar?
  3. Se a entrada tem tamanho variável, como internamente fica a rede neural?

Antes de chegar a essas explicações, essa é a classe que contém nossa rede neural:

class RNN(nn.Module):
    def __init__(self, vocab_size, model, m, n, n_layers, hidden, out, mode):
        super(RNN, self).__init__()

        self.n_layers = n_layers
        self.m = m
        self.n = n
        self.hidden_size = hidden
        self.mode = mode

        self.embed = nn.Embedding(vocab_size, n)
        self.embed.load_state_dict({
            "weight": torch.FloatTensor(model.wv.vectors)
        })

        if mode is "gru":
            self.recurrence = nn.GRU(n, hidden, n_layers, dropout=0.2)
        elif mode is "lstm":
            self.recurrence = nn.LSTM(n, hidden, n_layers, dropout=0.2)
        else:
            raise "escolha entre gru e lstm apenas"

        self.linear0 = nn.Linear(m*hidden, int(hidden*2))
        self.linear1 = nn.Linear(int(hidden*2), hidden)
        self.linear2 = nn.Linear(hidden, out)

    def forward(self, inpt):

        hidden = self._init_hidden(inpt.size(1))

        x, hidden = self.recurrence(
            self.embed(inpt),
            hidden
        )

        space = torch.zeros(self.m*self.hidden_size)
        x = x.flatten()
        space[:x.shape[0]] = x
        x = space

        x = self.linear0(x)
        x = self.linear1(x)
        x = self.linear2(x)

        return torch.sigmoid(x).unsqueeze(0)

    def _init_hidden(self, batch_size):
        if self.mode == "lstm":
            return (torch.zeros(self.n_layers, batch_size, self.hidden_size),
                    torch.zeros(self.n_layers, batch_size, self.hidden_size))
        else:
            return torch.zeros(self.n_layers, batch_size, self.hidden_size)

1. O que faremos com o skip-gram treinado pelo Gensim?

A camada incorporada tem uma dupla função: converter a entrada (array de inteiros e de tamanho variável) em uma matriz de largura fixa, útil à célula que cuidará da recorrência, e aplicar o skip-gram, por isso ela precisa ter as seguintes dimensões [tamanho do vocabulario x dimensões do skip-gram], O que é devolvido por esta camada é uma matriz no formato [quantidade de palavras x dimensões do skip-gram].

self.embed = nn.Embedding(vocab_size, n)
self.embed.load_state_dict({
    "weight": torch.FloatTensor(model.wv.vectors)
})

Outro fator importante que precisa ser mencionado é que esta camada também está sujeita ao treinamento da rede neural, ou seja, mantendo desse modo como está, estamos aplicando uma transferência neural, ou seja utilizando uma rede já treinada (no caso skip-gram) e continuando seu treinamento aplicado a uma outra situação, cujo treinamento neste novo contexto tende a ter um ponto de partida com maior precisão, esta é a idéia por trás da escolha de manter o treinamento ativo nesta camada, fazer com que o conteúdo aprendido no treinamento feito pelo Gensim vá se adaptando ao contexto da análise de sentimento segundo o dataset utilizado.

2. Em quê o LSTM ou o GRU poderão influenciar?

Em sua estrutura, a recorrêncida da RNN se resume a uma camada oculta, uma célula de recorrência que trata do gerenciamento de memória e a entrada, olhando com atenção, a célula fará basicamente é alterar os pesos da entrada de acordo com a camada oculta, que serve como uma memória, indicando o que valorizar ou não na etapa seguinte da execução da rede neural.

Traduzindo para o contexto da entrada conter a posição de cada palavra num hiperplano de acordo com o valor semântico expresso pelo uso demonstrado no dataset, isso quer dizer que será feito mais um ajuste sobre o skip-gram, na prática esse pós processamento vai indicar a importãncia de cada dimensão para cada palavra, potencializando o viés indicado pelo treinamento do skip-gram.

No PyTorch temos GRU e GRUCell, e LSTM e LSTMCell, a diferença é que usando simpelsmente GRU ou LSTM estamos inserindo uma pequena rede neural com a célula indicada no nome, podemos variar a quantidade de camadas lineares e definir uma taca pra o dropout, etc, mas se usarmos GRUCell ou LSTMCell teremos apenas a célula, no experimento feito, escolhi a 1ª opção pois não custava nada para mim definir logo o dropout em vez de definir ele em algum outro ponto.

Como este é um simples teste para ver o impacto da recorrência, resolvi manter apenas uma memória de curto prazo (reicinializando a cada novo uso da rede neural) para mostrar que mesmo com um uso mínimo as diferenças já são notáveis.

3. Se a entrada tem tamanho variável, como internamente fica a rede neural?

Em algum momento precisaremos de um tamanho fixo para passar pelas funções lineares, para o experimento atual apenas concatenei a matriz devolvida pelo GRU/LSTM, queria testar nas condições mais adversas, no próximo post aplicarei nesta etapa da execução tanto a convolução 1d quanto a convolução 2d, mas não há como fugir do tamanho fixo em algum momento neste caso, então a regra que usamos no post anterior de considerar o caso com maior quantidade de palavras vale aqui também.

obs: um dos problemas do tamanho variável da entrada está no DataLoader do pytorch, ele lança um erro quando definimos um batch_size que nos deixe confortáveis, para trabalhar com dados em lotes ele exige tamanho fixo, de modo que só nos resta ler 1 item do dataset por vez, deixando o processo muito lento já que a cada item lido será aplicada uma etapa do treinamento, vamos ver a evolução mais rápida considerando as épocas mas a quantidade de vezes que os pesos foram atualizados correspondem a $n_epocas * n_items$

Conclusões

/images/classification_gru_ltsm.png

É notável e incrível a diferença do uso de redes recorrentes para lidar com linguagem, a grande diferença está no foco da recorrência: enquanto habitualmente ajustamos pesos para classificação, aqui transformamos os valores de entrada e diferentemente da convolução, não espalhamos informações nem produzimos matrizes ou arrays equivalente mas ajustamos a entrada de modo a se adequar melhor ao contexto do treinamenhto, e o melhor de tudo: sabendo lidar com sequências em nível mais abstrato do que apenas arrays e matrizes, o contexto envolta da ordem que as palavras são usadas nas frases tem relevância para a recorrência, e mesmo aplicando tão minimamente e de forma tão pouco eficiente neste exemplo, o resultado já superou largamente o uso de redes convolucionais demonstradas na anotação anterior.

No próximo post vou misturar as coisas, aplicando a recorreência, depois convlução (1d e 2d), e por último camadas lineares, assim espero concluir os modelos temporariamente e fazer uma análise geral sobre o que os métodos apresentados ao longo dessas anotações sobre classificação influenciam os resultados.

Resumos 0: PageRank

Esta é uma anotação introdutória ao problema de resumir textos, o ponto principal abordado aqui será a dificuldade de identificar o que é relevante, para isso usei o textrank, não entrarei em muitos detalhes sobre esse algoritmo, tratando de forma intuitiva a idéia geral que será mais aprofundada em anotações posteriores.

PageRank foi o primeiro algoritmo usado pelo google para rankear os links de sua busca, logicamente o google evoluiu neste tempo todo e usa uma combinação de vários algoritmos e não o pagerank puro, aqui o usaremos para rankear os parágrafos de um texto da wikipedia.

Funcionamento

Não entrarei em muitos detalhes sobre o algoritmo, então explicando de forma superficial temos o fato do pagerank se valer de um grafo, e ao considerar o grau de cada nó, ou seja a quantidade de conexões de cada nó, e um peso atribuído a cada conexão, teremos um ranking de importância. Até mesmo explicando desse modo já imaginamos como o algoritmo se aplica bem a links entre páginas na internet, mas para textos ele realmente não é tão adequado porém é didático como algo introdutório.

Os passos do "resumo" que na verdade é um rankeamento:

  1. extrair estatísticas do texto (tf-idf ou word2vec, enfim, qualquer coisa que nos diga algo sobre o texto)
  2. gerar uma matriz de similaridade, que na verdade servirá como matriz adjacente
  3. converter a matriz adjacente num grafo
  4. aplicar o PageRank

Implementação

Resolvi o skip-gram já treinado[1]_ e a página da wikipédia sobre Alan Turing como já feito antes, o processamento realmente começa criando listas com os valores correspondentes a cada palavra indicado pelo skip-gram.

sentences = []

for paragraph in text:
sentences.append([word2id[word] for word in paragraph if word in id2word])

O passo seguinte é criar uma matriz quadrada onde cada lado tem o nº de parágrafos, preenchi a matriz da seguinte forma:

for i, x in enumerate(sentences):
    for j, y in enumerate(sentences):
        if i != j :
            similarity_matrix[i, j] = np.sum(
                                          cosine_similarity(data[x],data[y])
                                      ).item(0)

O que é feito acima é apenas comparar a similaridade de cossenos entre cada parágrafo, indicando de alguma forma algum nível de similaridade, de modo que o parágrafo com maior índice de similaridade em relação aos demais será aquele que melhor representa o conjunto.

/images/similarity_matrix-classificacao-1.png

Essa é uma matriz simétrica que seŕá lida como uma matriz adjacente de um grafo, cada linha e coluna serão nós e cada corrdenada indica o peso do vértice que liga cada nó, um dos problemas dessa estratégia é que todos os nós terão o mesmo grau, já que todos se ligam a todos, isso acaba inutilizando o uso do grau de cada nó para o pagerank, tendo como único parâmetro a considerar o peso dos vértices.

G = nx.from_numpy_array(similarity_matrix)
scores = nx.pagerank(G)

original = pickle.load(
    open("original_text-Alan_Turing.pickle", "rb")
    )

    word_rank = sorted(
            [(scores[i],i, s) for i, s in enumerate(original)],
            key=lambda x:x[0],
            reverse=True
    )

    qnt_lines = 3

    top = sorted(word_rank[:qnt_lines], key=lambda x:x[0])

    for i in range(qnt_lines):
        print(f"-- parágrafo do resumo: {i} | parágrafo original: {top[i][1]}")
        print(top[i][2], end="\n\n")

Na saída do código acima podemos reparar que a ordem de importância dada a cada parágrafo não necessariamente está relacionado ao seu tamanho ou à sua posição no texto: .. epigraph:

-- parágrafo do resumo: 0 | parágrafo original: 19
Por muitos anos, foram feitas campanhas que envolveram ativistas da tecnologia da informação, do meio político e do público LGBT. Em 11 de setembro de 2009, 55 anos após sua morte, o primeiro-ministro do Reino Unido, Gordon Brown, seguindo um pedido feito através de uma petição direcionada ao governo britânico, pediu desculpas formais em nome do governo pelo tratamento preconceituoso e desumano dado a Turing, que o levou ao suicídio. Em 24 de dezembro de 2013, passou a ter efeito a Real Prerrogativa do Perdão, concedida a Turing pela Rainha Elizabeth II, a pedido do ministro da justiça do Reino Unido, Chirs Grayling, depois que uma petição criada em 2012 obteve mais de 37.000 assinaturas solicitando o devido perdão.

-- parágrafo do resumo: 1 | parágrafo original: 3
A homossexualidade de Turing resultou em um processo criminal em 1952, pois atos homossexuais eram ilegais no Reino Unido na época, e ele aceitou o tratamento com hormônios femininos e castração química, como alternativa à prisão. Morreu em 1954, algumas semanas antes de seu aniversário de 42 anos, devido a um aparente autoadministrado envenenamento por cianeto, apesar de sua mãe (e alguns outros) terem considerado sua morte acidental. Em 10 de setembro de 2009, após uma campanha de internet, o primeiro-ministro britânico Gordon Brown fez um pedido oficial de desculpas público, em nome do governo britânico, devido à maneira pela qual Turing foi tratado após a guerra. Em 24 de dezembro de 2013, Alan Turing recebeu o perdão real da rainha Elizabeth II, da condenação por homossexualidade.

-- parágrafo do resumo: 2 | parágrafo original: 12
Em 1938, Turing se uniu ao GC&CS, o braço de decodificação de mensagens da inteligência britânica, para efetuar a Criptoanálise da Máquina Enigma. O Enigma era uma máquina de codificação que mudava seus códigos diariamente, obrigando a que o projeto de decifração se tornasse bastante rápido. Após o Reino Unido iniciar a Segunda Guerra Mundial ao declarar guerra à Alemanha em 1939, Turing foi direcionado para o quartel da GC&CS em Bletchley Park. A partir de uma máquina decodificadora polonesa, Turing projetou a Bomba eletromecânica ("Bombe"),  um equipamento eletromecânico que ajudaria a decriptar as mensagens do Enigma e foi montada em 1940. Novas Bombas foram construídas após Turing e sua equipe pedirem apoio a Winston Churchill, e mais de duzentas operavam ao fim da Guerra em 1945. Turing também introduziu sua equipe em Bletchley Park ao matemático Tommy Flowers, que em 1943 projetou o Colossus, um computador primitivo que ajudou a decodificar outra máquina criptográfica alemã, o Lorenz.

Logicamente eu poderia ter usado frases em vez de parágrafos para fazer o resumo, talvez até fizesse mais sentido chamar a saída do código de resumo, mas resolvi usar parágrafos inteiros por considerar que a idéia fica mais clara assim e ao comparar com o texto original, fica mais visualmente evidente como se deu o trabalho do pagerank, nos próximos posts sobre este tópico serão mostradas redes neurais que fazem um trabalho bem mais coerente, logicamente usarei redes neurais recorrentes e o seq2seq, portanto recomendo que veja as anotações que escrevi sobre esses temas:

GRU e LSTM seq2seq: introdução

---

_[1] http://www.nilc.icmc.usp.br/nilc/index.php/repositorio-de-word-embeddings-do-nilc

Classificação 2: CNN

Como falado anteriormente <link://filename/posts/classificacao-1.rst>_, classificar um texto é algo que vai além do vocabulário ainda que a gente utilize o word2vec ou o glove, e como a ordem das palavras importa muito, vamos dar aqui o 1º passo neste sentido, vamos ver como funciona uma rede neural convolucional (CNN) aplicada à classificação de textos.

Habitualmente elas são usadas essencialmente em processamento de imagens, no caso o que teremos é uma linha representando cada palavra e as colunas representando um ponto no hiperplano de acordo com a veotrização treinada. A idéia por trás da convolução aqui aplicada é bastante simples: ter uma matriz maior e calcular uma matriz menor equivalente, neste processo há perda de dados e portanto é irreversível, porém tem se demonstrado muito útil em muitos casos.

Implementação:

O primeiro passo é transformar um texto numa matriz, para isso vamos recordar o que temos:

  • texto ~> sequência de ids de palavras
  • skip-gram, cbow, glove, etc. ~> representação cartesiana de palavras segundo o sentido compreendido pelo seu uso

Diante disso se torna meio lógico fazer uma matriz no formato words x embedding dims.

Um dos problemas dessa abordagem é que frases tem tamanhos variáveis enquanto a matriz de entrada na camada convolucional da CNN precisa ter tamanho fixo, então temos de lidar sempre com o pior caso, frases grandes, e preencher o espaço restante das frases menores com zeros, isso acaba nos obrigando a ter um custo computacional extra já que teremos muitos espaços em branco só para que sempre tenhamos matrizes do mesmo tamanho.

Antes de continuar preciso fazer algumas observações:

  1. Não encontrei um dataset em PTBR bom o suficiente, portanto, contrariando minha proposta inicial, o ciclo de anotações sobre classificação usará um dataset em inglês.
  2. Tentei baixar um word embedding já treinado com o vocabulário da língua inglesa, mas ele tinha mais de 6Gb, como esse fato ia complicar algumas coisas a nível operacional, diminuindo minha proposta de fazer as anotações da forma mais simples possível, preferi usar o Gensim para calcular o skip-gram estrito ao dataset.

Primeiros passos:

Sobre o tamanho das dimensões no word2vec: como neste caso precisamos de uma matriz quadrada, precisamos que a quantidade de dimensões seja a mesma do maior texto depois do processo de limpeza.

Após isso ser feito podemos montar as matrizes:

def make_matrix(phrase, model_vec, n, stop_words):
    if type(phrase) is str:
        phrase = gensim.utils.simple_preprocess(phrase)
        phrase = [i for i in phrase if i not in stop_words]

    out = np.zeros((n, n))
    for i, label in enumerate(phrase):
        if label in model_vec.wv.vocab.keys():
            out[i, :] = model_vec[label]

    return out

class Data(Dataset):
    def __init__(self, data, target, model, n, stop_words):
        self.n = n
        self.data = torch.FloatTensor(
            [make_matrix(i, model, n, stop_words) for i in data]
        )
        self.target =  torch.LongTensor([int(i) for i in target])
        self._len = len(target)

    def __getitem__(self, x):
        return self.data[x].view(1, self.n, self.n), self.target[x]

    def __len__(self):
        return self._len

Para facilitar a divisão do dataset para uma valização cruzada, usei o sklearn bem pontualmente:

train_data, test_data, train_target, test_target = train_test_split(corpus, target, test_size=0.25)

data_train = Data(train_data, train_target, skip_gram, max_sentence, stopw)
data_test = Data(test_data, test_target, skip_gram, max_sentence, stopw)

Modelo de rede neural:

A rede neural será uma rede convolucional bem padrão:

class CNN_2D(nn.Module):
    def __init__(self, lin_in, lin_out):
        super(CNN, self).__init__()

        self.conv0 = nn.Sequential(
            nn.Conv2d(1, 16, 5, stride=2),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=2)
        )

        self.conv1 = nn.Sequential(
            nn.Conv2d(16, 32, 5, stride=3),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=2)
        )

        self.linear = nn.Linear(lin_in, lin_out)

    def forward(self, x):
        x = self.conv0(x)
        x = self.conv1(x)
        x = x.view(x.shape[0], -1)
        x = torch.sigmoid(self.linear(x))
        return x

Para entender melhor como a convolução funciona neste contexto, recomendo os vídeos: * video1 * video2

Uma vantagem da convolução 2d (com uma matriz e não com um array) é uma maior propagação de informação sobre áreas tomadas pelos zeros, se formos imaginar o processo num array logo percebemos um espaço muito limitado de propagação das informações finais apenas. Para expor melhor as diferenças óbvias em se trabalhar com 1 e com 2 dimensões, também repeti o experimento com uma rede convolucional 1d, ela apresenta pouquíssimas diferenças: menores dimensões no skip-gram (apenas 10), 3 camadas lineares na rede neural, maior quantidade de épocas no treinamento. Em todos os casos resolvi usar uma função sigmoide na saída, isso para ter uma estimativa de "certeza" quanto às escolhas da rede neural após o treinamento, mas deixarei essa análise comparativa para o último post desse ciclo.

class CNN_1D(nn.Module):
    def __init__(self, lin_in, lin_out):
        super(CNN, self).__init__()

        self.conv = nn.Sequential(
            nn.Conv1d(1, 16, 5, stride=2),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=5)
        )

        self.dropout = nn.Dropout(p=0.2)

        self.linear0 = nn.Linear(lin_in, int(lin_in*2))
        self.linear1 = nn.Linear(int(lin_in*2), int(lin_in/2))
        self.linear2 = nn.Linear(int(lin_in/2), lin_out)

    def forward(self, x):
        x = self.conv(x)

        x = x.view(x.shape[0], -1)
        x = self.linear0(x)
        x = self.dropout(x)
        x = self.linear1(x)
        x = torch.sigmoid(self.linear2(x))
        return x

Como acho a idéia aqui não ficou tão clara, antes de continuar, vou apenas ressaltar dimensões de entrada para cada rede neural:

  • Conv2d:

    • altura: tamanho máximo de palavras
    • largura: tamanho máximo de palavras
    • word embedding dims: tamanho máximo de palavras
    • a matriz será: palavras x word embedding
  • Conv1d:

    • tamanho: tamanho máximo de palavras * word embedding dims
    • word embedding dims: qualquer tamanho que queira, no caso eu escolhi 10
    • o array será: dimensões de cada palavra lida na ordem colocada uma após a outra num array

Treinamento:

Como pretendo fazer um post final de análise dos resultados, usei o ignite para organizar a criação de um csv e salvar a rede neural ao final do treinamento, não entrarei em muitos detalhes mas basta ver o notebook usado que (ao menos espero) fique bem clara a utilidade.

Em todos os experimentos, para as comparações serem mais justas, usarei essencialmente os mesmos parâmetros:

  • loss function: Cross Entropy
  • Optimizer: Adam
  • learning rate: 0.001

Apesar de ser um dataset muito pequeno, com apenas 3000 textos no total e invariavelmente fadado ao overfitting justamente por causa disso, a rede convolucional 2d funcionou muito melhor que a 1d como os gráficos abaixo claramente demonstram:

/images/classification_conv.png

resultado final:

Na próxima anotação será a vez de tratar de redes recorrentes, serão no total 3 redes neurais (1 na próxima anotação e 2 na seguinte), inicialmente lidando apenas com a recorrência comparando o desempenho do GRU e LSTM e posteriormente combinado a recorrência com a convolução, mas para adiantar as coisas recomendo começar a ler a respeito do LSTM e GRU.

---

Leituras recomendadas:

https://arxiv.org/abs/1408.5882

GRU e LSTM

obs: Não vou me demorar tratando de questões teóricas sobre as RNN em si, já que o foco dessas anotações é NLP, futuramente, ao concluir essas anotações talvez eu inicie uma série mais abrangente sobre machine learning, mas por enquanto apenas traterei de assuntos teóricos relativos a NLP e ao resto apenas uma abordagem prática.

Parece meio óbvio dizer mas o que define uma rede neural recorrente é exatamente a recorrência, isto é, informações que são armazenadas e depois reutilizadas, a forma mais simples de implementação consiste em criar uma camada (um array) que armazena os resultados e é utilizado normalmente no processamento dos dados que passam por uma rede neural, só que com 1 caracérística distinta: haver uma condição ou não para atualizar esses dados aprendidos ao longo do treinamento e uma condição que a informação armazenada seja utilizada.

Devido a esse mecanismo ser claramente um gerenciamento de memória e estarmos tratando de redes neurais, não demora muito para começarmos a associar ao modo como nosso cérebro gerencia a memória, desta forma vem ao mundo em 1997 o LSTM (Long Short-Term Memory) [1] [2], e como o nome bem indica, se trata de gerenciamento de memória de curto e longo prazo, posteriormente, em 2014 nasce o GRU (Gated Recurrent Unit) [3], mantendo diversas semelhanças com o LSTM porém com um custo computacional um pouco menor mas que no geral tem um desempenho semelhante ainda que é comum encontrar artigos falando que um ou outro método funcionou bem melhor em algum caso específico. Mas aqui estamos tratando de entender por alto como funciona esse gerenciamento de memória, o que precisamos ter noção é de como eles fazem isso?

Portões

Portões == funções

https://upload.wikimedia.org/wikipedia/commons/3/3b/The_LSTM_cell.png https://upload.wikimedia.org/wikipedia/commons/3/37/Gated_Recurrent_Unit%2C_base_type.svg

Explicando as imagens acima: a primeira mostra como funciona o LSTM e a segunda mostra como funciona o GRU, ainda que os diagramas sejam diferentes, vemos que as funções usadas, os "portões" são os mesmos embora aplicados de diferentes formas, como são as funções que importam para entender aidéia geral, vou direto à explicação sobre as funções.

\begin{equation*} \sigma = \frac{1}{1 + exp(-x)} \end{equation*}
\begin{equation*} tanh = \frac{e^x - e^-x}{e^x + e^{-x}} \end{equation*}
/images/lstm_gru-tanh-sigmoid.png

Vemos que a diferença entre os gráficos dessas funções é essencialmente o limite quando tende a menos infinito, e isso faz toda a diferença, pois um limite que tende a 0 significa que posteriormente qualquer número multiplicado por 0 será 0, neste caso a função sigmoide indica a relevância de cada dimensão de entrada, se a dimensão for próxima a zero, ela vai perdendo relevância até desaparecer ou ser substituída por outra informação mais relevante (decidida pela função tanh).

Todo o mecanismo de preservação e esquecimento desses métodos se baseia nesses "portões" que é como são chamadas as camadas com a função sigmoide, enquanto a função tanh tem o dever de fazer as escolhas finais, repare nas somas e multiplicações que unem o fluxo da saída com a camada oculta que armazena a memória, como a última estapa das operações com o estado da célula é sempre uma soma e os anteriores são multiplicações, isso revela a alteração dos pesos para definir a importância de cada dimensão e posteriormente a atualização mantendo assim para a época atual do treinamento da rede neural, a memória de curto prazo (os espaços próximos a zero que após a soma se mantém próximos aos resultados mais recentes) e a memória de longo prazo (o conteúdo mais relevante deixado mais próximo de 1)

---

Seq2Seq - Introdução

Tenho certeza que todos ao menos uma vez se perguntaram, pelo menos nas primeiras vezes que usaram o google translate, "como é que isso funciona? é magica?", até mesmo pelo que escrevi aqui até agora, todos os conteúdos estão bastante distantes de algo que trate tão intensamente com linguagem do que o desta anotação. O seq2seq nos permite criar redes que aprendam a sequência em que as palavras estão dispostas num texto de modo que fique fácil gerar textos, por hora, para simplificar esse assunto bastante extendo, traterei aqui apenas de explicar cada passo praticamente sem o código e na próxima anotação terá uma implementação completa.

encoder - decoder

O grande "truque" está no mecanismo de codificação-decodificação, na prática são 2 redes neurais recorrentes bem simples que compartilham uma mesma camada oculta e não tem camada de ativação, só uma célula GRU ou LSTM que realiza o processamento.

A informação que dá sentido à ambas as redes é a camada oculta, é sobre ela que incide o treinamento, portanto essa é a camada responsável por fazer a relação entre as saídas de cada rede neural.

Então o que temos até o momento é:

  • encoder:

    • hidden layer
    • gru
  • decoder:

    • hidden layer
    • gru

Treinamento

Como já disse que tudo está em torno da camada oculta compartilhada, é criando este array que se inicia o treinamento, que incide mais sobre a camada de decodificação que sobre a de encodificação, é feito desse modo pela camada de decodificação ser a usada para calcular a perda já que é ela que nos fornecerá a saída final do algoritmo.

Procedimento:

  • hidden layer
  • encoder_output, hidden_layer = encoder(input, hidden_layer)
  • decoder_output, hidden_layer = decoder(encoder_output, hidden_layer)
  • loss(decoder_output, target)
  • backward
  • step

Na próxima anotação sobre o seq2seq, diante do código, tudo ficará mais claro.

Resolvi não colocar imagens ilustrativas aqui pois no 1º link das leituras recomendadas há um monte de animações explicando bem detalhadamente todo o processo, das 2 leituras recomendadas, essa é a que mais recomendo.

---

Classificação 1

O problema fundamental da classificação de textos reside no fato da dificuldade de representar o texto a nível numérico, ainda que o word2vec, o glove, o seq2seq sejam realmente úteis assim como vários outros algoritmos que não incluí aqui mas que são facilmente encontrados em buscas no google, eles por si só não conseguem ir além da representação semântica ou de alguma outra lógica sobre as palavras, tanto para gerar textos quanto para classificá-los precisamos do auxílio de outros algoritmos. O objetivo desta anotação é identificar os desafions inerentes a esta tarefa.

Apenas vocabulário

Esse seria o caminho mais óbvio, tendo em vista que temos uma representação espacial da disposição das palavras num hiperplano, então faz sentido imaginar que textos sobre diferentes assuntos necessariamente tem diferentes vocabulários. Façamos um teste:

  1. peguei 2 textos da wikipedia:

    1. Lutefisk - um prato da culinária norueguesa
    2. Erhu - um instrumento tradicional chinês
  2. fiz o pré-processamento das palavras como já descrito em outro post, ficando apenas com o vocabulário

  3. peguei as posições correspondentes a cada palavra no skip-gram já treinado

  4. reduzi as dimensões e plotei o gráfico

/images/classificacao_1_scatter_vec.png

explicando as cores:

Como é possível perceber acima, não identificamos um nível de separação consistente entre os vocabulários, olhando a densidade de concentração do vocabulário nos dois textos, descartando as palavras em comum, temos a imagem abaixo:

/images/classificacao_1_kde_vec.png

Os centros estão muito próximos, ou seja, mesmo identificando as regiões mais densas nos vocabulários, ao tentar usar esta região como métrica, a similaridade de sentido entre os termos para o texto 1 e para o texto 2 continuam muito próximas tornando essa estratégia bem ineficaz.

A solução está na compreensão que um texto não são apenas palavras soltas, mas o sentifo extrapola a simples junção de palavras, então a ordem do que está escrito importa, a disposição das palavras no texto importa muito.

Nas próximas anotações abordarei sobre o uso de redes convolucionais e redes neurais recorrentes, diferentes formas de tentar burlar as dificuldades aqui apresentadas.

Distância Euclidiama vs Similaridade de Cossenos

Indo direto ao ponto a principal diferença entre os cálculos é que enquanto na distância euclidiana é como se fizéssemos uma medição com uma régua entre 2 pontos, na similaridade de cossenos analisamos a distância angular entre 2 pontos a partir da origem, isso ficará mais claro no gráfico perto do final desta anotação.

\begin{equation*} dist\_eucl = \sqrt{\sum{(a-b)^2}} \end{equation*}
\begin{equation*} cosine\_sim = \frac{\sqrt{\sum{a * b}}}{\sqrt{\sum{a^2}} * \sqrt{\sum{b^2}}} \end{equation*}

Comparando resultados

Primeiro vamos implementar cada cálculo e depois uma função que receba uma matriz, normalize os dados, e indike os "k" pontos mais próximos a alguma coordenada que a gente escolher. Como usaremos em outras anotações, escrevi mais linhas do que um código simples e didático deveria ter:

import numpy as np
from scipy.spatial.distance import euclidean, cosine

def norm(x):
    return x/np.sqrt(np.sum(np.square(x)))

def knn(matrix, n=5, func="cos", **kw):
    data_norm, coord_norm = None, None

    if "coord" in kw.keys():
        data = np.concatenate((matrix, np.array([kw["coord"]])))
        ata_norm = norm(matrix)
        coord_norm = data_norm[-1, :]
    else:
        data_norm = norm(matrix)
        coord_norm = data_norm[kw["pos"]]

    res = []
    for i in data_norm:
        if func=="cos":
            res.append(cosine(coord_norm, i))
        else:
            res.append(euclidean(coord_norm, i))

    if func=="cos":
        return np.array(res).argsort()[1:n+1], sorted(res)[1:n+1]
    else:
        return np.array(res).argsort()[1:n+1], sorted(res)[1:n+1]

Visualizando a diferença de resultados entre as medições, gerei esse gráfico abaixo:

/images/eucl_vs_cos.thumbnail.png

explicando: os pontos vermelhos representam os pontos mais próximos desse ponto amarelo cortado por uma seta são os pontos mais próximos considerando a distância euclidiana, os pontos azuis é pela similaridade de cossenos e os roxos são os que as duas métricas coincidem ao listar os mais próximos, a seta indica a inclinação do ponto amarelo em relação a origem, e é isso que a similaridade de cossenos leva em consideração, perceba que um dos pontos azuis ficou bem distante mas projetando a seta vemos que se mantém mais próximo ao ângulo do ponto amarelo que o ponto vemelho.

O motivo de preferirmos usar a similaridade de cossenos a usar distância euclidiana ou outras métricas para medir distâncias é que quando trabalhamos com NLP e ainda mais quando fazemos uma redução de dimensionalidade (onde ficou claro que há rotação e distorção) os ângulos ficam mais bem preservados que as distâncias.

obs: É muito comum a similaridade é calculada com 1 passo a mais do que o demonstrado aqui, a distância angular é dada por:

\begin{equation*} dist\_angular = \frac{cos^-1(cos\_similarity)}{\pi} \end{equation*}
\begin{equation*} angular\_similarity = 1-dist\_angular \end{equation*}

Outras vezes apenas fazem 1-similaridade.

Estatística: TF-IDF e LSA

Antes da popularidade de métodos baseados em IA, muito também devido à capacidade dos computadores da época, o que restava para análises de texto era quantificar as palavras e buscar extrair estatísticas, o mais básico e fundamental talvez seja o TF-IDF e por isso este post.

tf-idf: frequency-inverse document frequency

Este método se resume a contar a frequência de uso de palavras e realizar um cálculo que gere uma estimativa de uso/importância da palavra no texto, de certa forma ele se conecta à Lei de Zipf que trata justamente de uma análise da frequência de palavras.

\begin{equation*} TF(t) = \frac{nº\ de\ vezes\ que\ t\ aparece\ no\ texto}{total\ de\ termos\ no\ texto} \end{equation*}
\begin{equation*} IDF(t) = log_e(\frac{quantidade\ total\ de\ textos}{numero\ de\ textos\ em\ que\ t\ aparece}) \end{equation*}

Recomendo bastante a wikipédia em inglês, há bastante exemplos de cálculos variantes: https://en.wikipedia.org/wiki/Tf%E2%80%93idf

Logicamente há inconsistências, afinal apenas a frequência não alcança o uso das palavras, não indica necessariamente as mais significativas se uma pessoa em vez de fazer referências a uma palavra ficar repetindo a mesma coisa o tempo todo. ex.:

"há filmes bons, ruins e medianos, mas o filme em questão é o pior de todos, o filme é tão chato e cansativo que todos dormem assistindo os primeiros minutos do filme"

"há filmes bons, ruins e medianos, mas este em questão é o pior de todos, tão chato e cansativo que todos dormem aos primeiros minutos"

É bem claro que apesar do sentido do texto ser o mesmo, a importância dada à palavra "filme" seria diferente. E de fato, o TF-IDF funciona melhor para textos que seguem as regras de coesão e coerência, então vamos usar publicações da wikipédia.

Apesar do cálculo ser bastante simples, vou preferir usar o sklearn pois neste caso o mais importante é ter uma ideia geral sobre um recurso básico e servir como uma introdução básica sobre NLP, especialmente sobre vertorização de textos

TF-IDF

Como quase tudo no sklearn...

import wikipedia
from nltk.corpus import stopwords
from sklearn.feature_extraction.text import TfidfVectorizer

stopw = stopwords.words("portuguese") +\
        stopwords.words("english")

wikipedia.set_lang("pt")
text = wikipedia.page("Alan_Turing").content

tfidf = TfidfVectorizer(stop_words=stopw)

X = tfidf.fit_transform(text.splitlines())
X.shape

Na penúltima linha usei o splitlines para dividir o texto em parágrafos, assim podemos posteriormente coletar informações sobre os termos relevantes para cada parágrafo, mas admito esta forma ser demasiadamente simplista pois neste caso acabo considerando subtítulos como parágrafos.

Internamente, o objeto que criamos, durante o treinamento, armazena um dicionário com as palavras e um "id", vamos usar isso para converter os termos:

>>> X
<61x664 sparse matrix of type '<class 'numpy.float64'>'
    with 862 stored elements in Compressed Sparse Row format>

A matriz esparsa tem diversas vantagens quando tratamos com longos arrays rechados de zeros, talvez o produto principal nessa implementação seja exatamente essa matriz que indica em cada parágrafo quais os termos presentes e a sua frequência, que é o ponto principal do TF-IDF.

visualização da matriz resultante

E é exatamente sobre essa matriz que chegamos no LSA (Latent Semantic Analysis), mas antes vamos ver quais as palavras mais relevantes do primeiro parágrafo:

>>> ft_name = tfidf.get_feature_names()
>>> top_tfidf = X[0].transpose().toarray().argsort(axis=0)[::-1]
>>> for i in top_tfidf[:10]:
        print(ft_name[i[0]])

computação
cheshire
junho
ciência
influente
algoritmo
east
lógico
desenvolvimento
desempenhando

O ft_name é a lista de termos que irá converter para string a posição do termo indicada quando ordenamos o array comtendo o valor calculado para cada termo devolvendo as respectivas posições.

LSA

O LSA é nada mais que usar o SVD mas em vez de diminuir as dimensões vamos manter o tamanho da matriz:

>>> X.shape
(61, 664)

>>> lsa = TruncatedSVD(n_components=61, n_iter=1000)
>>> lsa.fit(X)
TruncatedSVD(algorithm='randomized', n_components=61, n_iter=1000,
   random_state=None, tol=0.0)

O real poder do LSA vem desse tratamento dado à matriz formada a partir do TF-IDF, o código abaixo indica as palavras mais relevantes para cada parágrafo:

for i, comp in enumerate(lsa.components_):
    terms_in_comp = zip(ft_name, comp)
    sorted_terms = sorted(terms_in_comp,
                          key=lambda x: x[1], reverse=True)[:10]

    print(f"paragrafo: {i}")
    for t in sorted_terms:
        print(t[0])
    print("-"*20)

Pegando apenas o parágrafo 0, o resultado que temos é:

paragrafo 0:
  • turing
  • máquina
  • alan
  • prêmio
  • memorial
  • guerra
  • enigma
  • bletchley
  • park
  • computação

off-topic

  1. E para gerar estatísticas de relevância de um texto inteiro, basta não dividir em parágrafos
  2. E para gerarmos aquele bag of words que está na moda temos algumas opções, dependendo do caso aplicamos só o TF para gerar um ranking, para outros casos o TF-IDF funciona melhor, especialmente quando juntamos vários textos como uma análise geral de várias páginas de blogs, o LSA tende a ser melhor em usos mais específicos porém nada impede de usa-lo para gerar o ranking de termos para um livro, por exemplo.

Nota

notebook usado: link para o nbviewer