Representar palavras com inserções
Em nosso exemplo anterior, operamos em vetores de saco de palavras de alta dimensão com comprimento vocab_size e convertemos explicitamente vetores de representação posicional de baixa dimensão em representação esparsa one-hot. Essa representação one-hot não é eficiente em termos de memória. Além disso, cada palavra é tratada independentemente uma da outra, de modo que vetores codificados one-hot não expressam semelhanças semânticas entre palavras.
Nesta unidade, continuamos explorando o conjunto de dados do AG News . Para começar, vamos carregar os dados e obter algumas definições da unidade anterior.
import tensorflow as tf
import keras
import tensorflow_datasets as tfds
import numpy as np
# In this tutorial, we will be training a lot of models. In order to use GPU memory cautiously,
# we will set tensorflow option to grow GPU memory allocation when required.
physical_devices = tf.config.list_physical_devices('GPU')
if len(physical_devices)>0:
tf.config.set_memory_growth(physical_devices[0], True)
dataset = tfds.load('ag_news_subset')
ds_train = dataset['train']
ds_test = dataset['test']
O que é uma inserção?
A ideia da inserção é representar palavras usando vetores densos tridimensionais inferiores que refletem o significado semântico da palavra. Mais tarde, discutiremos como criar inserções de palavras significativas, mas por enquanto vamos pensar nas inserções como uma maneira de reduzir a dimensionalidade de um vetor de palavras.
Portanto, uma camada de inserção usa uma palavra como entrada e produz um vetor de saída do especificado embedding_size. De certa forma, é semelhante a uma camada Dense, mas em vez de receber um vetor codificado one-hot como entrada, é capaz de receber um número de palavra.
Ao usar uma camada de incorporação como a primeira camada em nossa rede, podemos mudar de bag-or-words para um modelo embedding bag, em que primeiro convertemos cada palavra em nosso texto na incorporação correspondente e, em seguida, calculamos alguma função agregada sobre todos esses embeddings, como sum, average ou max.
Nossa rede neural do classificador consiste nas seguintes camadas:
- Camada TextVectorization, que recebe uma string como entrada e produz um tensor de números de token. Especificaremos algum tamanho
vocab_sizerazoável de vocabulário e ignoraremos palavras usadas com menos frequência. A forma de entrada é 1 e a forma de saída será $n$, já que obtemos tokens $n$ como resultado, cada um deles contendo números de 0 avocab_size. - A camada de inserção, que usa $n$ números, e reduz cada número a um vetor denso de um determinado comprimento (100 em nosso exemplo). Assim, o tensor de entrada de forma $n$ será transformado em um tensor $n\times 100$.
- A camada de agregação, que leva a média desse tensor ao longo do primeiro eixo, ou seja, calcula a média de todos os tensores de entrada $n$ correspondentes a palavras diferentes. Para implementar essa camada, usaremos uma
Lambdacamada e passaremos para ela a função para calcular a média. A saída terá uma forma de 100 e é a representação numérica de toda a sequência de entrada. - Classificador linear denso final.
Podemos implementar essas camadas com o seguinte código:
vocab_size = 30000
batch_size = 128
vectorizer = keras.layers.TextVectorization(max_tokens=vocab_size)
model = keras.Sequential([
keras.Input(shape=(1,), dtype=tf.string),
vectorizer,
keras.layers.Embedding(vocab_size,100),
keras.layers.Lambda(lambda x: tf.reduce_mean(x,axis=1)),
keras.layers.Dense(4, activation='softmax')
])
model.summary()
A execução desse código produz a seguinte saída:
Model: "sequential"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Layer (type) ┃ Output Shape ┃ Param # ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ text_vectorization │ (None, None) │ 0 │
│ (TextVectorization) │ │ │
├──────────────────────────────┼───────────────────────────┼───────────────┤
│ embedding (Embedding) │ (None, None, 100) │ 3,000,000 │
├──────────────────────────────┼───────────────────────────┼───────────────┤
│ lambda (Lambda) │ (None, 100) │ 0 │
├──────────────────────────────┼───────────────────────────┼───────────────┤
│ dense (Dense) │ (None, 4) │ 404 │
└──────────────────────────────┴───────────────────────────┴───────────────┘
Total params: 3,000,404 (11.45 MB)
Trainable params: 3,000,404 (11.45 MB)
Non-trainable params: 0 (0.00 B)
Na impressão summary na coluna formato de saída, a primeira dimensão do tensor None corresponde ao tamanho do minilote e a segunda corresponde ao comprimento da sequência de token. Todas as sequências de token no minibatch têm comprimentos diferentes. Discutiremos como lidar com isso na próxima seção.
Podemos treinar a rede com o seguinte código:
def extract_text(x):
return x['title']+' '+x['description']
def tupelize(x):
return (extract_text(x),x['label'])
print("Training vectorizer")
vectorizer.adapt(ds_train.take(500).map(extract_text))
model.compile(loss='sparse_categorical_crossentropy',optimizer='adam',metrics=['acc'])
model.fit(ds_train.map(tupelize).batch(batch_size),validation_data=ds_test.map(tupelize).batch(batch_size))
Observação
Estamos construindo um vetorizador com base em um subconjunto dos dados. Isso é feito para acelerar o processo e pode resultar em uma situação em que nem todos os tokens do nosso texto estão presentes no vocabulário. Nesse caso, esses tokens seriam ignorados, o que pode resultar em uma precisão ligeiramente menor. No entanto, na vida real, um subconjunto de texto geralmente fornece uma boa estimativa de vocabulário.
Lidando com tamanhos de sequência variável
Vamos entender como o treinamento acontece em minibates. No exemplo acima, o tensor de entrada tem a dimensão 1 e usamos minibatches de 128 comprimentos, de modo que o tamanho real do tensor seja $128 \times 1$. No entanto, o número de tokens em cada frase é diferente. Se aplicarmos a TextVectorization camada a uma única entrada, o número de tokens retornados será diferente, dependendo de como o texto é tokenizado:
print(vectorizer('Hello, world!'))
print(vectorizer('I am glad to meet you!'))
A execução desse código produz a seguinte saída:
tf.Tensor([ 1 45], shape=(2,), dtype=int64)
tf.Tensor([ 112 1271 1 3 1747 158], shape=(6,), dtype=int64)
No entanto, quando aplicamos o vetorizador a várias sequências, ele precisa produzir um tensor de forma retangular, de modo que ele preencha elementos não utilizados com o token PAD (que em nosso caso é zero):
vectorizer(['Hello, world!','I am glad to meet you!'])
A execução desse código produz a seguinte saída:
<tf.Tensor: shape=(2, 6), dtype=int64, numpy=
array([[ 1, 45, 0, 0, 0, 0],
[ 112, 1271, 1, 3, 1747, 158]])>
Aqui, podemos ver as inserções:
model.layers[1](vectorizer(['Hello, world!','I am glad to meet you!'])).numpy()
A execução desse código produz a seguinte saída:
array([[[-0.02485236, -0.00416857, -0.06599288, ..., -0.02404598,
0.03529833, -0.02100844],
[ 0.22493948, 0.01383338, 0.12420551, ..., 0.19531338,
0.13524376, 0.04216914],
[ 0.04510409, 0.00708018, -0.0310419 , ..., -0.0188726 ,
-0.0179676 , -0.04813331],
[ 0.04510409, 0.00708018, -0.0310419 , ..., -0.0188726 ,
-0.0179676 , -0.04813331],
[ 0.04510409, 0.00708018, -0.0310419 , ..., -0.0188726 ,
-0.0179676 , -0.04813331],
[ 0.04510409, 0.00708018, -0.0310419 , ..., -0.0188726 ,
-0.0179676 , -0.04813331]],
[[-0.00226152, -0.0972852 , -0.00063103, ..., 0.00504377,
0.22460397, 0.1497297 ],
[-0.15621698, -0.13758421, -0.02889572, ..., -0.02577994,
0.03472563, 0.08767739],
[-0.02485236, -0.00416857, -0.06599288, ..., -0.02404598,
0.03529833, -0.02100844],
[-0.06490357, -0.08200071, -0.06175491, ..., -0.02477042,
-0.06802022, -0.01040947],
[ 0.03279151, 0.12563369, 0.06062867, ..., -0.04349922,
-0.12154414, -0.12533969],
[-0.14435016, -0.304014 , -0.00378676, ..., 0.05609043,
0.20370889, 0.28518862]]], dtype=float32)
Observação
Para minimizar a quantidade de preenchimento, em alguns casos faz sentido classificar todas as sequências no conjunto de dados na ordem de aumento do comprimento (ou, mais precisamente, do número de tokens). Isso garante que cada minibatch contenha sequências de comprimento semelhante.
Inserções semânticas: Word2Vec
Em nosso exemplo anterior, a camada de inserção aprendeu a mapear palavras para representações de vetor, no entanto, essas representações não tinham significado semântico. Seria bom aprender uma representação de vetor de modo que palavras ou sinônimos semelhantes correspondam a vetores próximos uns dos outros em termos de alguma distância de vetor (por exemplo, distância euclidiana).
Para fazer isso, precisamos pré-treinar nosso modelo de inserção em uma grande coleção de texto usando uma técnica como o Word2Vec. Ele se baseia em duas arquiteturas principais que são usadas para produzir uma representação distribuída de palavras:
- Contínuo saco de palavras (CBoW), em que treinamos o modelo para prever uma palavra do contexto circundante. Considerando o ngram $(W_{-2},W_{-1},W_0,W_1,W_2)$, a meta do modelo é prever $W_0$ de $(W_{-2},W_{-1},W_1,W_2)$.
- Skip-gram contínuo é o oposto de CBoW. O modelo usa a palavra de entrada ($W_0$) para prever a janela ao redor das palavras de contexto.
CBoW é mais rápido e, embora skip-gram seja mais lento, ele faz um trabalho melhor de representar palavras pouco frequentes.
Para experimentar a incorporação do Word2Vec pré-treinada no conjunto de dados do Google Notícias, podemos usar a biblioteca gensim. Abaixo, encontramos as palavras mais semelhantes a "neural".
Observação
Quando você cria pela primeira vez vetores de palavra, baixá-los pode levar algum tempo!
import gensim.downloader as api
w2v = api.load('word2vec-google-news-300')
for w,p in w2v.most_similar('neural'):
print(f"{w} -> {p}")
A execução desse código produz a seguinte saída:
neuronal -> 0.7804799675941467
neurons -> 0.7326500415802002
neural_circuits -> 0.7252851724624634
neuron -> 0.7174385190010071
cortical -> 0.6941086649894714
brain_circuitry -> 0.6923246383666992
synaptic -> 0.6699118614196777
neural_circuitry -> 0.6638563275337219
neurochemical -> 0.6555314064025879
neuronal_activity -> 0.6531826257705688
Também podemos extrair a incorporação vetorial da palavra, para ser usada no treinamento do modelo de classificação. A inserção tem 300 componentes, mas aqui mostramos apenas os primeiros 20 componentes do vetor para maior clareza:
w2v['play'][:20]
A execução desse código produz a seguinte saída:
array([ 0.01226807, 0.06225586, 0.10693359, 0.05810547, 0.23828125,
0.03686523, 0.05151367, -0.20703125, 0.01989746, 0.10058594,
-0.03759766, -0.1015625 , -0.15820312, -0.08105469, -0.0390625 ,
-0.05053711, 0.16015625, 0.2578125 , 0.10058594, -0.25976562],
dtype=float32)
A grande coisa sobre inserções semânticas é que você pode manipular a codificação de vetor com base na semântica. Por exemplo, podemos pedir para encontrar uma palavra cuja representação de vetor é o mais próxima possível das palavras rei e mulher, e o mais longe possível da palavra homem:
w2v.most_similar(positive=['king','woman'],negative=['man'])[0]
A execução desse código produz a seguinte saída:
('queen', 0.7118192911148071)
O exemplo acima usa alguma magia interna do Gensim, mas a lógica subjacente é simples. Uma coisa interessante sobre embeddings é que você pode realizar operações vetoriais normais em vetores de incorporação, e isso refletiria operações em palavras significados. O exemplo acima pode ser expresso em termos de operações vetoriais: calculamos o vetor correspondente a KING-MAN+WOMAN (as operações + e - são realizadas nas representações vetoriais das palavras correspondentes) e, em seguida, encontramos a palavra mais próxima no dicionário para esse vetor.
# get the vector corresponding to king-man+woman
qvec = w2v['king'] - w2v['man'] + w2v['woman']
# find the index of the closest embedding vector, excluding input words
d = np.sum((w2v.vectors-qvec)**2,axis=1)
exclude = {w2v.key_to_index[w] for w in ['king', 'man', 'woman']}
d[list(exclude)] = np.inf
min_idx = np.argmin(d)
# find the corresponding word
w2v.index_to_key[min_idx]
A execução desse código produz a seguinte saída:
'queen'
Para encontrar o vetor mais próximo, usamos NumPy para calcular um vetor de distâncias entre nosso vetor e todos os vetores no vocabulário e, em seguida, encontramos o índice da palavra mais próxima usando argmin. Excluimos as palavras de entrada (king, , ) manda pesquisa, já que o woman método faz most_similarisso automaticamente.
Embora o Word2Vec pareça uma ótima maneira de expressar semântica de palavras, ele tem muitas desvantagens, incluindo o seguinte:
- Os modelos CBoW e skip-gram são inserções preditivas e levam em conta apenas o contexto local. O Word2Vec não aproveita o contexto global.
- Word2Vec não leva em conta a morfologia de palavras, ou seja, o fato de que o significado da palavra pode depender de diferentes partes da palavra, como a raiz.
O FastText tenta superar a segunda limitação e baseia-se no Word2Vec, aprendendo representações vetoriais para cada palavra e os n-gramas de caracteres encontrados em cada palavra. Os valores das representações são então calculados e a média é feita em um vetor a cada etapa de treinamento. Embora isso adicione vários cálculos adicionais ao pré-treinamento, permite a incorporação de palavras para codificar informações de subpalavras.
Outro método, GloVe, usa uma abordagem diferente para inserções de palavras, com base na factorização da matriz de contexto de palavras. Primeiro, ele cria uma matriz grande que conta o número de ocorrências de palavras em contextos diferentes e, em seguida, tenta representar essa matriz em dimensões inferiores de forma a minimizar a perda de reconstrução.
A biblioteca gensim dá suporte a essas inserções de palavras e você pode experimentá-las alterando o código de carregamento do modelo acima.
Usando embeddings pré-treinados em Keras
Podemos modificar o exemplo acima para pré-preencher a matriz em nossa camada de inserção com inserções semânticas, como o Word2Vec. Os vocabulários da incorporação pré-treinada e do corpus de texto provavelmente não corresponderão, então precisamos escolher um. Aqui, exploramos as duas opções possíveis: usar o vocabulário do tokenizador e usar o vocabulário das inserções do Word2Vec.
Usando o vocabulário do tokenizador
Ao usar o vocabulário do tokenizer, algumas das palavras do vocabulário terão embeddings Word2Vec correspondentes e algumas estarão faltando. Considerando que nosso tamanho de vocabulário é vocab_size, e o comprimento do vetor de inserção word2Vec é embed_size, a camada de inserção será representada por uma matriz de peso da forma vocab_size$\times$embed_size. Vamos preencher essa matriz percorrendo o vocabulário:
embed_size = len(w2v.get_vector('hello'))
print(f'Embedding size: {embed_size}')
vocab = vectorizer.get_vocabulary()
W = np.zeros((vocab_size,embed_size))
print('Populating matrix, this will take some time...',end='')
found, not_found = 0,0
for i,w in enumerate(vocab):
try:
W[i] = w2v.get_vector(w)
found+=1
except KeyError:
# W[i] = np.random.normal(0.0,0.3,size=(embed_size,))
not_found+=1
print(f"Done, found {found} words, {not_found} words missing")
Para palavras que não estão presentes no vocabulário do Word2Vec, podemos deixá-las como zeros ou gerar um vetor aleatório.
Para definir uma camada de inserção com pesos pré-treinados, executamos o seguinte código:
emb = keras.layers.Embedding(vocab_size,embed_size,weights=[W],trainable=False)
model = keras.Sequential([
keras.Input(shape=(1,), dtype=tf.string),
vectorizer, emb,
keras.layers.Lambda(lambda x: tf.reduce_mean(x,axis=1)),
keras.layers.Dense(4, activation='softmax')
])
Depois que isso terminar, podemos treinar nosso modelo.
model.compile(loss='sparse_categorical_crossentropy',optimizer='adam',metrics=['acc'])
model.fit(ds_train.map(tupelize).batch(batch_size),
validation_data=ds_test.map(tupelize).batch(batch_size))
Observação
Observe que definimos trainable=False ao criar Embedding, que significa que não estamos re-treinando a camada Embedding. Isso pode fazer com que a precisão seja um pouco menor, mas acelera o treinamento.
Usando vocabulário de incorporação
Um problema com a abordagem anterior é que os vocabulários usados em TextVectorization e Embedding são diferentes. Para superar esse problema, podemos usar uma das seguintes soluções:
- Treine novamente o modelo word2Vec em nosso vocabulário.
- Carregue nosso conjunto de dados com o vocabulário do modelo Word2Vec pré-treinado. Vocabulários usados para carregar o conjunto de dados podem ser especificados durante o carregamento.
A última abordagem parece mais fácil, então vamos implementá-la. Em primeiro lugar, criamos uma TextVectorization camada com o vocabulário especificado, extraído das inserções do Word2Vec:
vocab = list(w2v.key_to_index.keys())
vectorizer = keras.layers.TextVectorization()
vectorizer.set_vocabulary(vocab)
Agora, precisamos criar a matriz de peso de inserção a partir dos vetores do Word2Vec. Criamos uma matriz em que cada linha corresponde a uma palavra no vocabulário e construímos manualmente a camada de embedding Keras.
embed_size = w2v.vector_size
vocab_size_w2v = len(vocab) + 2 # +2 for padding and unknown tokens
W = np.zeros((vocab_size_w2v, embed_size))
for i, word in enumerate(vocab):
W[i + 2] = w2v[word] # offset by 2 for padding (0) and unknown (1) tokens
emb_w2v = keras.layers.Embedding(vocab_size_w2v, embed_size, weights=[W], trainable=False)
model = keras.Sequential([
keras.Input(shape=(1,), dtype=tf.string),
vectorizer,
emb_w2v,
keras.layers.Lambda(lambda x: tf.reduce_mean(x,axis=1)),
keras.layers.Dense(4, activation='softmax')
])
model.compile(loss='sparse_categorical_crossentropy',optimizer='adam',metrics=['acc'])
model.fit(ds_train.map(tupelize).batch(128),validation_data=ds_test.map(tupelize).batch(128),epochs=5)
A execução desse código produz a seguinte saída:
Epoch 1/5
938/938 ━━━━━━━━━━━━━━━━━━━━ 7s 7ms/step - loss: 1.3381 - acc: 0.4961 - val_loss: 1.2996 - val_acc: 0.5682
Epoch 2/5
938/938 ━━━━━━━━━━━━━━━━━━━━ 7s 7ms/step - loss: 1.2591 - acc: 0.5714 - val_loss: 1.2340 - val_acc: 0.5839
Epoch 3/5
938/938 ━━━━━━━━━━━━━━━━━━━━ 7s 7ms/step - loss: 1.1983 - acc: 0.5883 - val_loss: 1.1827 - val_acc: 0.5951
Epoch 4/5
938/938 ━━━━━━━━━━━━━━━━━━━━ 7s 7ms/step - loss: 1.1505 - acc: 0.6001 - val_loss: 1.1417 - val_acc: 0.6021
Epoch 5/5
938/938 ━━━━━━━━━━━━━━━━━━━━ 7s 7ms/step - loss: 1.1122 - acc: 0.6093 - val_loss: 1.1084 - val_acc: 0.6103
Uma das razões pelas quais não vemos maior precisão é porque algumas palavras de nosso conjunto de dados estão ausentes no vocabulário pré-treinado do Word2Vec e, portanto, são ignoradas. O modelo pré-treinado do Word2Vec foi treinado no corpus do Google News, que tem uma distribuição de domínio e vocabulário diferente do conjunto de dados de classificação do AG News. As palavras presentes em nossos dados de treinamento, mas ausentes do vocabulário do Word2Vec, são mapeadas para zero vetores, reduzindo o sinal efetivo disponível para o classificador. Além disso, usar trainable=False na camada de inserção significa que o modelo não pode adaptar os vetores de palavra à nossa tarefa de classificação específica, o que limita sua capacidade de aprender recursos específicos da tarefa. Para superar isso, podemos treinar nossas próprias inserções com base em nosso conjunto de dados.
Observação
A menor precisão vista aqui em comparação com os embeddings de treinamento do zero é esperada. O modelo pré-treinado do Word2Vec foi treinado no corpus do Google News, que tem um vocabulário e domínio diferentes do conjunto de dados de classificação do AG News. As palavras presentes nos dados de treinamento do módulo, mas ausentes do vocabulário do Word2Vec, são silenciosamente ignoradas (mapeadas para zero vetores), reduzindo o sinal efetivo disponível para o classificador. Para obter melhores resultados em tarefas específicas do domínio, considere o ajuste fino de incorporações em dados no domínio.
Treinando seus próprios embeddings
Em nossos exemplos, temos usado inserções semânticas pré-treinadas, mas é interessante ver como essas inserções podem ser treinadas usando arquiteturas CBoW ou skip-gram. Este exercício vai além deste módulo, mas os interessados podem querer conferir este tutorial oficial do TensorFlow sobre o treinamento do modelo word2Vec. Além disso, a estrutura gensim pode ser usada para treinar as inserções mais usadas em algumas linhas de código, conforme descrito na documentação oficial.
Inserções contextuais
Uma limitação fundamental das representações tradicionais de inserção pré-treinada, como o Word2Vec, é o fato de que, mesmo que possam capturar algum significado de uma palavra, elas não podem diferenciar entre significados diferentes. Isso pode causar problemas em modelos downstream.
Por exemplo, a palavra 'play' tem um significado diferente nestas duas frases diferentes:
- Fui a uma peça no teatro.
- John quer brincar com seus amigos.
Os embeddings pré-treinados de que falamos representam ambos os significados da palavra 'brincar' no mesmo embedding. Para superar essa limitação, precisamos criar inserções com base no modelo de linguagem, que é treinado em um grande corpus de texto e sabe como as palavras podem ser montadas em contextos diferentes. Discutir inserções contextuais está fora do escopo deste módulo, mas voltaremos a eles ao falar sobre modelos de linguagem na próxima unidade.