Pular para o conteúdo principal

word2vec 3: skip-gram

Como já dito antes, o skip-gram faz um treinamento meio que ao contrário do cbow, no treinamento a rede neural recebe as palavras centrais para tentar prever as palavras de contexto e assim ajusta os pesos das camadas da rede neural aproximando valores para palavras semelhantes no hiperplano.

window = 2
pair_ids = []

text_size = len(corpus_text)

corpus_text = np.array(corpus_text)
mask = np.array(
           [i for i in range(-window, window+1) if i is not 0]
       )

for center_word in range(window, text_size-window):
    center_word_id = word2id[corpus_text[center_word]]
    for i in corpus_text[mask + center_word]:
        context_word_id = word2id[i]
        pair_ids.append([center_word_id, context_word_id])
pair_ids = np.array(pair_ids)

A única diferença do código acima para criar os pares de ids está na ordem: primeiro a palavra central e depois a palavra de contexto:

central contexto central contexto
604 97 máquina desempenhando
75 302 turing computação
75 604 turing máquina
75 97 turing desempenhando
75 277 turing papel
97 604 desempenhando máquina
97 75 desempenhando turing
97 277 desempenhando papel
97 409 desempenhando importante
277 75 papel turing
277 97 papel desempenhando

O modelo da rede neural não se difere muito da usada no cbow, a única diferença fica por conta do tamanho da entrada da primeira função linear, já que passaremos 1 id por vez e não 4 como no cbow.

class CBOW(torch.nn.Module):
    def __init__(self, vocab_size, emb_size):
        super(CBOW, self).__init__()

        self.embeddings = torch.nn.Embedding(vocab_size, emb_size)

        self.linear0 =  torch.nn.Linear(emb_size, 512) # única diferença aqui
        self.linear1 = torch.nn.Linear(512, vocab_size)

        self.log_softmax = torch.nn.LogSoftmax(dim=1)

    def forward(self, x):
        out = self.embeddings(x)

        out = self.linear0(out)
        out = self.linear1(out)

        out = self.log_softmax(out)
        return out

    def get_word_emb(self, word_id):
        word = torch.LongTensor([word_id])
        return self.embeddings(word).view(1, -1)

De modo geral o nível de erro (ou perda, nunca sei ao certo como traduzir "loss" neste contexto) no skip-gram é maior que no cbow, mas repito que o importante é que esteja havendo um aprendizado e não que a rede neural se adapte ao ponto de prever todas as palavras relacionadas ainda que ocasionalmente isso ocorra, para nós interessa o seguinte movimento: numa época a rede neural elevar os valores das palavras próximas na saída e afastar as mais distantes, assim naturalmente ela vai aprendendo a agrupar palavras em regiões de um hiperplano aproximando ou afastando de acordo com o modo como as palavras são usadas, tendendo a manter um distanciamento relacionado ao seu valor semântico.

/images/word2vec-skipgram-loss.png

Reduzindo as dimensões para visualizar a distribuição...

/images/word2vec-skipgram-1.png

Logicamente dessa forma como implementei, o custo/perda/loss é mais alto que na implementação feita do cbow, afinal vamos aos poucos ajustando 4 resultados possíveis para cada termo. Neste exemplo aumentei a quantidade de épocas para 2500 e ainda assim ficou imensamente distante do resultado da implementação do cbow neste aspecto, porém a relação entre as palavras se mostrou um pouco melhor ainda que longe do ideal.

rank sim cos
rank dist eucl
muitos 0.14544 muitos 0.07375
poderia 0.26087 code 0.08692
ceruzzi 0.28141 ceruzzi 0.08939
code 0.28206 condados 0.09595
britânica 0.28430 mortem 0.09709
mortem 0.33544 atos 0.10284
condenado 0.33660 teórica 0.10357
comerciantes 0.33929 condenado 0.10376
cabeceira 0.34548 rápido 0.10433
condados 0.36041 prazer 0.10648
/images/word2vec-skipgram-rank.png

Só lembrando que segui o mesmo padrão de cores:

amarelo: Palavra escolhida
vermelho: Termos mais próximos pela similaridade de cossenos
azul: Termos mais próximos pela distância euclidiana
roxo: Termos que ambas as métricas concordam

Nota

notebook usado: link para o nbviewer

SVD vs PCA

Não vou tratar aqui de como se implementa o PCA e o SVD, prefiro indicar esses tutoriais abaixo, eles foram muito bem escritos e são muito claros sobre como são os cálculos usados:

Embora esses métodos possam ser usados para compressão de dados, análises populacionais e uma infinidade de análises envolvendo dados organizados em matrizes, aqui prefiro comparar cada método e discutir o uso voltado à redução de dimensões a fim que possamos visualizar os dados dessas anotações,

mas antes de chegar nas discussões, vamos ver alguns gráficos mostrando o que o SVD e o PCA retornaram quando os usamos para reduzir dimensões de matrizes:

/images/svd_pca_0_3d.png/images/svd_pca_1_3dreduction.png

curiosamente vemos que ocorreu uma rotação no gráfico do PCA e que o gráfico do SVD mantém uma certa similaridade visual com o gráfico original em 3D. Só compreendi melhor vendo esta resposta no Quora:

"Geometrically PCA corresponds to “centering the dataset”, and then rotating it to align the axis of highest variance with the principle axis."

Geometricamente, PCA corresponde a "centralização do dataset", e depois rotaciona para alinhar o eixo de maior variância com o eixo principal

Lógico que nem sempre acontece de ambos as representações ficarem tão diferentes, para observar melhor isso resolvi seguir um exemplo da documentação do sklearn

/images/svd_pca_2_64reduction.png

A imagem acima mostra que deve ter coincidido a forma como o SVD reduziu as dimensões e a rotação feita pelo PCA, só lembrando o que está de forma muito explícita no link para o Quora: o PCA usa o SVD para criar um ranking, afinal PCA significa "análise do componente principal" e o SVD fornece um dos passos para chegar ao componente pricipal.

Mas o KMeans realiza um aprendizado não supervisionado, e ainda especialmente neste caso onde a redução de 64 dimensões para 2 com certeza não deu margem para que os dados fossem linearmente separáveis, resolvi usar o SVM para desenhar o espaço para cada classe.

/images/svd_pca_3_svm.png

Algo que se deve ressaltar no gráfico acima é que os pontos semi-transparentes que adicionei ao gráfico são os que os classificadores treinados erraram, sobre isso repare no resultado abaixo:

erros SVD: 704 de 1797
erros PCA: 704 de 1797
erros normal: 0 de 1797
-----------------------
percentuais de acertos:
> SVD: 60.824%
> pca: 60.824%
> normal: 100.000%

Considerei "normal" como a aplicação do SVM sem reduzir as dimensões. Estes resultados mostram que a sobreposição de dados na redução de dimensões assim como a distorção que ocorre nas transformações feitas com as matrizes, tende a dificultar o trabalho dos algoritmos, mesmo mantendo um certo nível de fidelidade com a distribuição original dos dadosm o melhor é usar essa redução mais para visualizar do que para aplicar métricas ou classificadores, e por isso também que nas notas onde uso distância euclidiana e similaridade de cossenos, ao reduzir as dimensões os resultados parecem errados ainda que nas dimensões originais esteja correto.

Word2Vec 2: CBOW

Na anotação anterior vimos de forma mais ou menos prática o sentido da coisa, implementamos o Word2Vec com o objetivo de identificar a proximidade semântica entre palavras com base no uso em textos, este post é fundamentalmente teórico e a implementação do cbow aqui demonstrada está muito longe de ser algo pronto para produção, é apenas um exemplo que tenta ser didático.

preparação dos dados

Como nosso objetivo é fazer com que uma rede neural receba as palavras de contexto e indique a palavra central, e na anotação anterior fiz uma pequena observação dizendo que sempre teremos $2w$ palavras de contexto para cada palavra central e assim faremos, vamos modificar um pouco o código que cria os pares do word2vec:

window = 2
pair_ids = []

text_size = len(corpus_text)

corpus_text = np.array(corpus_text)
mask = np.array([i for i in range(-window, window+1) if i is not 0])

for center_word in range(window, text_size-window):
    center_word_id = word2id[corpus_text[center_word]]
    context_words = [word2id[i] for i in corpus_text[mask + center_word]]

    pair_ids.append([context_words, center_word_id])

Assim feito, teremos algo como:

contexto central contexto central
[155, 77, 577, 495] 544 ['armazenado', 'ace', 'turing', 'interessou'] posteriormente
[77, 544, 495, 233] 577 ['ace', 'posteriormente', 'interessou', 'química'] turing
[544, 577, 233, 308] 495 ['posteriormente', 'turing', 'química', 'escreveu'] interessou
[577, 495, 308, 446] 233 ['turing', 'interessou', 'escreveu', 'artigo'] química
[495, 233, 446, 537] 308 ['interessou', 'química', 'artigo', 'sobre'] escreveu
[233, 308, 537, 323] 446 ['química', 'escreveu', 'sobre', 'base'] artigo
[308, 446, 323, 233] 537 ['escreveu', 'artigo', 'base', 'química'] sobre
[446, 537, 233, 504] 323 ['artigo', 'sobre', 'química', 'morfogênese'] base
[537, 323, 504, 506] 233 ['sobre', 'base', 'morfogênese', 'previu'] química
[323, 233, 506, 492] 504 ['base', 'química', 'previu', 'reações'] morfogênese
[233, 504, 492, 8] 506 ['química', 'morfogênese', 'reações', 'químicas'] previu

A rede neural

O que importa na rede neural neste método e no skip-gram é a camada Embedding

class CBOW(torch.nn.Module):
    def __init__(self, vocab_size, emb_size, context_size):
        super(CBOW, self).__init__()

        self.embeddings = torch.nn.Embedding(vocab_size, emb_size)

        self.linear0 =  torch.nn.Linear(2*emb_size*context_size, 512)
        self.linear1 = torch.nn.Linear(512, vocab_size)

        self.log_softmax = torch.nn.LogSoftmax(dim=1)

    def forward(self, x):
        out = self.embeddings(x).view(1, -1)

        out = self.linear0(out)
        out = self.linear1(out)

        out = self.log_softmax(out)
        return out

    def get_word_emb(self, word_id):
        word = torch.LongTensor([word_id])
        return self.embeddings(word).view(1, -1)

O treinamento será demorado, afinal como já dito, este não é um código para produção, é apenas um código didático, então enquanto ocorre o treinamento, não é má idéia ir tomar um chá e caminhar um pouco.

Algo que preciso ressaltar aqui é que predizer a palavra central corretamente não importa tanto, o importante é que esteja ocorrendo o aprendizado já queo que nos interessa é que os valores da camada incorporada se aproximem em palavras próximas e se distanciem para palavras distantes, então é de se esperar um gráfico horrível mostrando a evolução da perda.

/images/word2vec-cbow-loss.png

Para visualizar a distribuição das palavras num plano cartesiano, faremos o mesmo que com o Gensim, usaremos a implementação do PCA disponível no slearn.

/images/word2vec-cbow-1.png

Observando a similaridade, que não é lá tão boa neste caso devido a total falta de otimização em tudo no código:

rank sim cos   rank dist eucl  
novas 0.28059 novas 0.09326
equivalia 0.31309 polonesa 0.09989
pioneiro 0.31798 neve 0.10029
afirma 0.32445 andrew 0.10191
neve 0.33447 pioneiro 0.10310
polonesa 0.33585 afirma 0.10484
massachusetts 0.34675 conduzida 0.10508
conduzida 0.34768 bombas 0.10641
andrew 0.35143 manipular 0.10718
hastings 0.35665 homossexuais 0.11074

Observando onde cada termo está com as dimensões da camada incorporada da rede neural reduzida a 2d temos:

/images/word2vec-cbow-rank.png

É compreensível ver estas distâncias tão em desarcodo pelo fato das distorções da redução de dimensões, de 10 para 2.

Nota

notebook usado: link para o nbviewer

Word2Vec 1: Introdução

O Word2Vec parte de uma idéia muito simples e até certo ponto bastante lógica: relacionar uma palavra com as que estão em sua volta num texto. A partir desse conceito tão básico o Word2Vec acaba sendo uma base para outros algoritmos e não necessariamente um fim em si, a partir dele vamos implementar o cbow e o skip-gram nas anotações seguintes, por hora, vamos entender como funciona a criação dos pares que são a base do Word2Vec.

Pares

vamos imaginar que já tenhamos feito todo o processo descrito no post de introdução a esta série. O que buscamos nesta etapa é apenas definir uma "janela" que será a quantidade de palavras vizinhas à uma palavra que chamaremos de central e criar pares ligando essa palavra central às vizinhas, lógico que no código real trabalharemos com ids que representam palavras e não com as palavras em si.

ex.:

O cachorro comeu o trabalho da faculdade de novo

considerando a janela w = 2 teríamos:

[
    ("comeu", "o"),
    ("comeu", "cachorro"),
    ("comeu", "o"),
    ("comeu", "trabalho"),
    ...
]

Coisas óbvias a se deduzir: a partir da palavra central, as vezes que ela aparece é sempre 2*w e em relação às vizinhas, que chamamos de palavras de contexto, a proporção sempre será de 2*w para cada palavra central, isso será importante para o cbow e para o skip-gram.

Traduzindo esse procedimento bem básico em código, teremos:

w = 2 # janela (window)
pair_ids = []

text_size = len(sentences)

corpus_text = np.array(sentences)
mask = np.array([i for i in range(-w, w+1) if i is not 0])

for center_word in range(w, text_size-w):
    center_word_id = word2id[corpus_text[center_word]]
    for i in sentences[mask + center_word]:
        context_word_id = word2id[i]
        pair_ids.append([center_word_id, context_word_id])

pair_ids = np.array(pair_ids)

Esse será exatamente o código que teremos no método skip-gram. Mas por enquanto vamos aproveitar os métodos que usam o word2vec já implementados e vamos ver o que podemos extrair deles.

Gensim

No Gensim as operações são muito simples, basta passar para ele o texto processado de acordo com a introdução a este material:

model_sg = gensim.models.Word2Vec(sentences, min_count=1, window=2, compute_loss=True, sg=1)
model_cb = gensim.models.Word2Vec(sentences, min_count=1, window=2, compute_loss=True, sg=0)

No momento de criar o objeto, a única diferença nos parâmetros usados é no sg que a essa altura já está claro que signfica skip-gram e em vez de usar True ou False, usamos 1 ou 0 para definir qual método será usado.

A diferença real deles está no input e output pois ambos, cbow e skip-gram, são apenas redes neurais com pouquíssima diferença entre si como será visto posteiormente.

No cbow buscamos predizer a palavra central a partir das palavras de contexto e no skip-gram fazemos o contrário, a partir da palavra central buscamos prever as palavras de contexto.

/images/skip-gram_cbow.png
model_sg.train(sentences, total_examples=len(sentences), epochs=100)
model_cb.train(sentences, total_examples=len(sentences), epochs=100)

Na prática, a função do treinamento é, a partir da proximidade entre as palavras, as camadas da rede neural vão se ajustando o que acaba indicando a proximidade de sentido entre elas, indo para um exemplo clássico queremos que seja possível, através de uma distribuição no plano cartesiano que o meio do caminho entre as palavras "rei" e "mulher" seja "rainha".

## visualizando

Primeiro vamos ver as dimensões na saída para cada palavra:

>>> model_sg["turing"].shape
(100,)

Como podemos perceber, nos é impossível fazer uma visualização de algo em 100 dimensões, para reduzi para 2 dimensões vamos usar o sklearn com a classe PCA, como o sklearn mantém o mesmo procedimento para praticamente tudo, vou me abster de colocar o código aqui que pode ser visto no jupyter notebook com o código completo. O importante é que ao final teremos esses gráficos para cada método:

obs: queria fazer algo mais interativo mas não consegui no momento

/images/word2vec-1.png

O Gensim já tem métodos nos objetos formados para encontrar as palavras mais próximas usando a similaridade de cossenos:

# repare que quanto mais próximo de 1, mais similar
>>> w = "cianeto"
>>> model_sg.wv.most_similar(w)
[('corpo', 0.9956434965133667),
 ('envenenamento', 0.9950364828109741),
 ('apesar', 0.9946295022964478),
 ('aparente', 0.9940468668937683),
 ('presença', 0.9939732551574707),
 ('descoberto', 0.9937050342559814),
 ('níveis', 0.9936593770980835),
 ('quanto', 0.993450403213501),
 ('testada', 0.9933900833129883),
 ('determinar', 0.9930295944213867)]

Agora comparando o CBOW e o Skip-Gram:

w = "morte"

sg_similar = model_sg.wv.similar_by_word(w)
cb_similar = model_cb.wv.similar_by_word(w)

md = "| skip-gram | cbow |\n|--|--|\n"
for i in zip(sg_similar, cb_similar):
    md += f"| {i[0][0]} |  {i[1][0]} |\n"

Markdown(md)
skip-gram cbow
causa turing
defende maçã
setembro suicídio
acidental após
estabeleceu cianeto
campanha computador
necessariamente onde
copeland ser
suicídio anos
resultado ter

Nota

notebook usado: link para o nbviewer

Pré-processamento de textos

Este é o processo padrão usado em praticamente todas as anotações relacionadas à NLP:

  1. limpar o texto:
    • remover pontuação, acentos, e stop-words [1]
    • colocar tudo em minúsculas
  2. converter numa lista de termos usados.

A única excessão é com o TF-IDF e LSA e comparo quando o processamento é feito dividindo em parágrafos e com o texto inteiro de uma vez.

Sempre usarei textos da wikipédia, pelo simples motivo de ser muito prático e inteiramente legal.

bibliotecas usadas

%pylab inline
import nltk
import gensim
import wikipedia

No Jupyter notebook o comando %pylab importa o matplotlib e numpy e configura o modo como os gráficos serão apresentados, ocasionalmente também usarei o Altair junto com o Pandas para visualizar os dados.

Pré-processamento

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

sentences = []

stop_words = nltk.corpus.stopwords.words("portuguese") +\
             nltk.corpus.stopwords.words("english")

for i in text.splitlines():
    clean_text = gensim.utils.simple_preprocess(i)
    clean_text = [i for i in clean_text if i not in stop_words]
    sentences.append(clean_text)

Explicando as etapas do código acima:

linhas 6 e 7: lista de stop-words, como no texto há termos em inglês, juntei as duas listas (termos em português e em inglês) numa só.
for: splitlines vai dividir o texto em parágrafod e a função simple_preprocess() do Gensim remove pontuação e converte tudo para minúsculas, em seguida removo as stop words e por último adiciono o parágrafo à lista de sentenças usadas no texto.

Facilitando as coisas

Para dar maior foco ao que importa, salvei a lista de termos usando o pickle:

import pickle

# salvando em arquivo
with open("sentences.pickle", "wb") as f:
   pickle.dump(sentences, f)
   f.close()

# lendo do arquivo
sentences = pickle.load(
   open("sentences.pickle", "rb")
)

footnotes

[1] stop words são as palavras sem valor semântico ao que pretendemos fazer, são palavras como "eu", "está", "era", "têm", etc. São palavras de uso tão comum e frequente que acabaria por ofuscar a presença de palavras mais relevantes no processo de classificação de textos por exemplo, afinal para saber o sentido de frases como "Alan Turing é o pai da ciência da computação" basta apenas as palavras ["Alan", "Turing", "pai", "ciência", "computação"], isso é o que basta para uma máquina.

Nota

link para o código

README

Pretendo fazer uma longa série de posts sobre NLP, não sou especialista nisso e podemos considerar os posts mais como anotações de estudo do que tutoriais ou manuais. O Índice abaixo será atualizado à medida que eu for publicando novos conteúdos, a idéia é seguir o andamento histórico de cada parte, na 1ª parte começaremos com o tf-idf para depois seguirmos para o word2vec e glove:

Obs1.: O pré-processamento é a etapa inicial de praticamente todos os conteúdos aqui escritos, é realmente muito importante, por isso antes de partir para qualquer outro conteúdo, leia ele primeiro.

Obs2.: O que estiver em itálico é que ainda não escrevi mas devo fazer ao longo dessas semanas.

obs3.: Com excessão da parte 1, usarei o cbow, skip-gram e glove já computados, fontes recomendadas: