Preprocesamiento de texto para NLP (parte 3)
o(^・x・^)o Embeddings pre-entrenados
- Descargar los vectores pre-entrenados
- Obtener los vectores del vocabulario
- Extra: bolsa de palabras
- Inicializar los pesos de la capa de embeddings
- Inicializar los pesos en un modelo
- nn.Embedding
Este es el artículo final de la serie preprocesamiento de texto para NLP. Los artículos anteriores son parte 1 y parte 2.
En este nos vamos a focalizar en embeddings pre-entrenados. Los embeddings son un tema central en procesamiento del lenguaje y mucho se ha escrito al respecto. Acá hay algunos enlaces para introducrise en el tema
y acá dejamos algunos enlaces sobre cómo algunos frameworks abarcan el tema de este mismo artículo
Los embeddings son la primera capa en las redes neuronales que procesan texto. Mapean índices (a cada tóken le corresponde un índice, los índices corren de cero hasta len(tókenes)
). Estamos mapeando enteros a vectores, a cada índice le corresponde un vector de palabra que codifica a la palabra. El mapeo se realiza por medio de una matriz que tiene tantas filas como índices y tantas columnas como la dimensión de los vectores. Esta dimensión es un hiperparámetro del modelo y básicamente significa la cantidad de atributos con la que representaremos a las palabras. Elegir una fila de la matriz, y a cada índice/tóken le corresponde una fila) estamos rebanando la matriz de modo de quedarnos con un vector.
Como el resto de las capas de una red neuronal que no ha sido entrenada los pesos de la capa de embeddings se inicializan al azar. O sea que al seleccionar un vector de palabra obtenemos un vector con componentes aleatorios. La idea central de los embeddings es que las palabras adquieren significado a partir de las palabras que la rodean. Una vez que la red neural ha sido entrenada y que los componentes de los vectores de palabras no son azarosos sino que han capturado en mayor o menor medida el significado de las palabras, la distancia entre los vectores (similitud del coseno es una forma de calcular la distancia entre vectores) de palabras similares es más corta, es decir los embeddings están más cerca, que si cuando se consideran palabras con significados disímiles.
Una de las primeras técnicas de transfencia de aprendizaje (transfer learning) fue utilizar embeddings pre-entrenados. La red neuronal con la que son entrenados y la que los utiliza con otros fines pueden tener arquitecturas bien distintas, comparten solamente los vectores de palabras, es decir la primera capa. Vimos que el armado del vocabulario es un asunto central y sería extraño que adoptemos el mismo vocabulario que la red que se utilizó para entrenar los embeddings; no es esto un problema mientras haya una intersección substancial entre el vocabulario que queremos utilizar y el que se utilizó para los embeddings, ya que nos estamos limitando a este último, posiblemente entrenado con un corpus general (Wikipedia) mientras que el vocabulario que necesitamos posiblemente pertenezca a un corpus particular. Todos los tókenes que no están en el vocabulario se denominan fuera del vocabulario (out-of-vocabulary u OOV) y requieren un tratamiento especial como ser ignorados/eliminados o mapeados a un tóken especial que codifique tókenes desconocidos.
Los índices del vocabulario que crearemos tampoco será el mismo que los que se usaron para los vectores pre-entrenados. Por lo tanto la estrategia para obtener los pesos de la capa de vectores de palabra es la siguiente.
- Descargar los vectores pre-entrenados
- Obtener los vectores del vocabulario propio
- Ordenar los vectores según los índices propios
- Crear un tensor
- Inicializar los pesos de la capa de embeddings
Descargar los vectores pre-entrenados
Los proyectos más conocidos son
Vamos a usar fastText por tener vectores para idioma español y soporte para OOV. Primero instalamos el paquete de Python
pip install fasttext
y luego descargamos e inicializamos el modelo. Pesa unos 3,5 GB así que la descarga puede demorar. La dimensión de los vectores de este modelo es 300.
import fasttext
import fasttext.util
fasttext.util.download_model('es', if_exists='ignore')
ft = fasttext.load_model('cc.es.300.bin')
IMPORTANTE. Particularmente la carga de este modelo necesita de unos 12 GB de memoria RAM/swap, lo que me llevó a cerrar aplicaciones para liberar memoria. Para evitar pasar siempre por este paso, una vez que obtuve el tensor con los pesos necesarios lo salvé en un archivo; levantar este archivo es mucho más liviano.
import numpy as np
from itertools import chain
from collections import Counter
class Vocab():
# ningún cambio aquí
@property
def índice_relleno(self):
return self.mapeo.get(self.tóken_relleno)
# ningún cambio aquí
def __init__(self, tóken_desconocido='<unk>', tóken_relleno='<pad>', frecuencia_mínima=0.0, frecuencia_máxima=1.0,
longitud_mínima=1, longitud_máxima=np.inf, stop_words=[], límite_vocabulario=None):
self.tóken_desconocido = tóken_desconocido
self.tóken_relleno = tóken_relleno
self.frecuencia_mínima = frecuencia_mínima
self.frecuencia_máxima = frecuencia_máxima
self.longitud_mínima = longitud_mínima
self.longitud_máxima = longitud_máxima
self.stop_words = stop_words
self.límite_vocabulario = límite_vocabulario
# ningún cambio aquí
def reducir_vocabulario(self, lote):
contador_absoluto = Counter(chain(*lote))
contador_documentos = Counter()
for doc in lote:
contador_documentos.update(set(doc))
# frecuencia mínima
if isinstance(self.frecuencia_mínima, int): # frecuencia de tóken
vocabulario_mín = [tóken for tóken, frecuencia in contador_absoluto.most_common() if frecuencia >= self.frecuencia_mínima]
else: # frecuencia de documento
vocabulario_mín = [tóken for tóken, frecuencia in contador_documentos.most_common() if frecuencia/len(lote) >= self.frecuencia_mínima]
# frecuencia máxima
if isinstance(self.frecuencia_máxima, int): # frecuencia de tóken
vocabulario_máx = [tóken for tóken, frecuencia in contador_absoluto.most_common() if self.frecuencia_máxima >= frecuencia]
else: # frecuencia de documento
vocabulario_máx = [tóken for tóken, frecuencia in contador_documentos.most_common() if self.frecuencia_máxima >= frecuencia/len(lote)]
# intersección de vocabulario_mín y vocabulario_máx preservando el órden
vocabulario = [tóken for tóken in vocabulario_mín if tóken in vocabulario_máx]
# longitud
vocabulario = [tóken for tóken in vocabulario if self.longitud_máxima >= len(tóken) >= self.longitud_mínima]
# stop words
vocabulario = [tóken for tóken in vocabulario if tóken not in self.stop_words]
# límite
vocabulario = vocabulario[:self.límite_vocabulario]
return vocabulario
def fit(self, lote):
vocabulario = []
if self.tóken_relleno:
vocabulario.append(self.tóken_relleno)
if self.tóken_desconocido:
vocabulario.append(self.tóken_desconocido)
vocabulario += self.reducir_vocabulario(lote)
self.mapeo = {tóken: índice for índice, tóken in enumerate(vocabulario)}
return self
# ningún cambio aquí
def transform(self, lote):
if self.tóken_desconocido: # reemplazar
return [[tóken if tóken in self.mapeo else self.tóken_desconocido for tóken in doc] for doc in lote]
else: # ignorar
return [[tóken for tóken in doc if tóken in self.mapeo] for doc in lote]
# ningún cambio aquí
def tókenes_a_índices(self, lote):
lote = self.transform(lote)
return [[self.mapeo[tóken] for tóken in doc] for doc in lote]
# ningún cambio aquí
def índices_a_tókenes(self, lote):
mapeo_inverso = list(self.mapeo.keys())
return [[mapeo_inverso[índice] for índice in doc] for doc in lote]
# ningún cambio aquí
def __len__(self):
return len(self.mapeo)
@property
def vocabulario(self):
return list(v.mapeo.keys())
Creamos el vocabulario como lo hicimos anteriormente (parte 1).
import pandas as pd
df = pd.read_csv('train.csv', sep='|')
# hacemos una tokenización muy simple
def tokenizar(texto):
return texto.split()
train_docs = [tokenizar(doc) for doc in df['Pregunta'].values]
v = Vocab().fit(train_docs)
Desde Python 3.7 está garantizado que el orden del diccionario es el orden de inserción. Por lo tanto el órden de la lista v.vocabulario
coincide con el del diccionario v.mapeo
(ver implementación de Vocab
). Tener claro el órden / los índices de los tókenes es importante porque crearemos un tensor de embeddings al cuál accederemos mediante índices.
v.vocabulario[:10]
Por ejemplo, el embedding del tóken tarjeta
será embeddings[5]
ya que el tóken está en el quinto lugar del vocabulario (recordar que empezamos a contar por cero).
La interfaz de fastText para obtener un vector a partir de un tóken es como la de un diccionario. Así luce un embedding de dimensión 300.
ft['tarjeta']
Veamos la distancia entre embeddings de tókenes similares, por ejemplo debido a un error ortográfico, y la de tókenes disímiles, de diferente significado.
Para ello utilizaré la similitud del coseno, una fórmula trigonométrica que en la definición de scipy
es igual a cero si ambos vectores apuntan a un mismo lugar; cualquier ángulo existente entre los vectores, arrojaría un valor mayor a cero.
Los índices son cateorías que nada dicen de la relación entre las palabras pero los vectores sí.
from scipy.spatial import distance
distance.cosine(ft['tarjeta'], ft['tarjeta'])
Error ortográfico:
distance.cosine(ft['tarjeta'], ft['targeta'])
Otra palabra:
distance.cosine(ft['tarjeta'], ft['saldo'])
La siguente función servirá para
- obtener los vectores de cada uno de los tókenes del vocabulario,
- en el orden de los índices del vocabulario (es importante mantener este orden),
- convertirlos en tensores de PyTorch (
map
aplica la funcióntorch.tensor
a cada uno de los vectores), -
list
convierte el mapeo es una lista, ya quemap
es lazy, no acciona hasta que se lo piden y convertirlo en lista es una manera de pedirlo, -
torch.stack
apila los tensores de la lista (cada uno tiene dimensión 300 y la lista tiene largo $N$, el tamaño del vocabulario) en un tensor bidimensional de $N \times 300$.
import torch
# versión 1
def obtener_embeddings(tókenes, fastText):
embeddings = [fastText[tóken] for tóken in tókenes]
return torch.stack( list( map(torch.tensor, embeddings) ) )
embeddings = obtener_embeddings(v.vocabulario, ft)
embeddings
Entonces ahora podemos salvarlos para no tener que volver a generarlos, obviando así cargar el modelo de fastText.
torch.save(embeddings, 'vectores.pkl')
Si queremos cargarlos más adelante:
embeddings = torch.load('vectores.pkl')
Extra: bolsa de palabras
Hay una forma simple y efectiva de obtener la representación de un documento, si bien existen otras que son mejores. Los vectores son representaciones de tókenes, los documentos son conjuntos de tókenes, calcular la suma, el promedio o el máximo de los vectores del conjunto nos da un vector que es la representación del documento. Como esta agregación no tiene en cuenta el orden de los tókenes en el documento se llama bolsa de palabras, o en inglés bag of words.
cambio_cien = obtener_embeddings(['señor', 'tiene', 'cambio', 'de', 'cien'], ft)
cambio_cien.shape
Hacemos la agregación es en sentido de las columnas, cada columna o dimensión del embedding es un atributo o feature del tóken, queremos obtener los atributos para el documento.
cambio_cien = torch.mean(cambio_cien, dim=0)
cambio_cien.shape
Representación de una variante del documento:
cambio_mil = obtener_embeddings(['señor', 'tiene', 'cambio', 'de', 'mil'], ft)
cambio_mil = torch.mean(cambio_mil, dim=0)
Representación de un documento bien diferente:
extravío = obtener_embeddings(['extravié', 'mi', 'tarjeta', 'de', 'débito', 'anoche'], ft)
extravío = torch.mean(extravío, dim=0)
Ahora veamos las distancias entre los documentos.
distance.cosine(cambio_cien, cambio_mil)
distance.cosine(cambio_cien, extravío)
Vemos que señor tiene cambio de cien
está más cerca de señor tiene cambio de mil
que de extravié mi tarjeta de débito anoche
.
En la parte 2 hube mencionado a nn.EmbeddingBag
sin contar su finalidad; es un módulo de PyTorch que hace exactamento esto: recibe un tensor con índices de tókenes de documentos, reemplaza a los índices por vectores y los agrega en un vector por documento, usando una función que puede ser mean
, max
, sum
.
Inicializar los pesos de la capa de embeddings
El método copy_
carga el tensor de los pesos en el módulo de embeddings. Para que la carga funcione las dimensiones del tensor de pesos debe ser exactamente igual a las de la capa. Inicializamosla con cantidad de filas igual al largo del vocabulario y cantidad de columnas igual al tamaño de los vectores.
capa = nn.EmbeddingBag(len(v), ft.get_dimension(), mode='mean')
capa
Chequeamos las dimensiones del tensor de pesos.
embeddings.shape
Al inicializar la capa, sus pesos se inicializan con valores al azar. Es con el entrenamiento que adquieren valores significativos para red neuronal. Los embeddings pre-entrenados sirven justamente para comenzar con valores con sentido, lo que acorta los tiempos de aprendizaje de la red en general.
capa.weight.data.copy_(embeddings)
índices = v.tókenes_a_índices([
['señor', 'tiene', 'cambio', 'de', 'cien'],
['señor', 'tiene', 'cambio', 'de', 'mil'],
['extravié', 'mi', 'tarjeta', 'de', 'débito', 'anoche'],
])
índices
Por cómo creamos el vocabulario y por cómo está definida la clase Vocab
, el tóken <unk>
de tóken desconocido o fuera del vocabulario tiene asignado el índice 1
; esto será relevante más adelante.
Recordemos que a este módulo le gusta que los documentos sean contiguos (un único documento) y que por otro lado le informemos en qué posiciones de ese documento contiguo comienza cada uno de los documentos.
Veamos el largo de cada uno de los documentos.
list(map(len, índices))
El primer documento siempre comienza en la posición 0
, el segundo lo hace 5
tókenes/índices después, y el tercero en 5 luego del segundo, o sea en la posición 10
.
posiciones = torch.tensor([0, 5, 10])
Ahora convertimos a los documentos en un documento único y además en un tensor.
índices = torch.tensor([
1, 119, 142, 2, 1, 1, 119, 142, 2, 1311, 2268, 11, 5, 2, 149, 1443
])
Luego de estos procesamientos la capa ejecuta las mismas operaciones que realizamos manualmente.
vectores = capa(índices, posiciones)
vectores.shape
Lo que podemos verificar calculando la distancia entre señor tiene cambio de cien
está más cerca de señor tiene cambio de mil
, que manualmente dio $0.081$.
distance.cosine(vectores[0].detach().numpy(), vectores[1].detach().numpy())
Y no se cumplió. 😵
La explicación está en las palabras fuera del vocabulario. Los embeddings pre-entrenados no suelen venir con pesos para tókenes especiales como <unk>
y al hacer ft['<unk>']
, fastText que está preparado para generar vectores para tókenes con los cuales no fue entrenado, devuelve un vector con pesos sin sentido. Es decir, fastText es muy útil para obtener vectores aproximados cuando le preguntamos por un tóken que no conoce pero que es parecido a otros que sí, sin embargo <unk>
no se a parece a ningún otro. Nota: Word2Vec y GloVe no tienen soporte para tókenes fuera del vocabulario (OOV), en el caso de <unk>
no hubieran devuelto ningún valor.
¿Qué podríamos haber hecho?
- Si contamos con soporte para OOV (fastText), no usar el tóken
<unk>
ya que no es necesario. Para ello deberíamos haber creado el vocabulario inicilizando la claseVocab
con el argumentotóken_desconocido=None
. - Si no hay soporte para OOV, salvo que el modelo especifique que cuenta con un vector para el tóken especial desconocido (y que no necesariamente se simbolizará con
<unk>
), no usar el tóken<unk>
ya que no es posible. - Entrenar vectores desde cero. Al existir
<unk>
, este adquire pesos con el sentido propuesto. No era la idea. - Crear un vector a partir de los existentes, según está expresado en esta respuesta de StackOverflow.
Creando un vector desconocido
La respuesta de StackOverflow del último punto sugiere que el vector promedio de todos los vectores o, de al menos los que se van a usar, conforman un buen vector desconocido.
unk = embeddings.mean(dim=0)
unk.shape
pad = torch.zeros(ft.get_dimension())
pad.shape
pad.shape
def obtener_embeddings(tókenes, fastText, tóken_desconocido='<unk>', tóken_relleno='<pad>'):
embeddings = [fastText[tóken] for tóken in tókenes if tóken not in (tóken_desconocido, tóken_relleno)]
embeddings = torch.stack( list( map(torch.tensor, embeddings) ) )
if tóken_desconocido:
unk = embeddings.mean(dim=0, keepdim=True)
embeddings = torch.cat([unk, embeddings])
if tóken_relleno:
pad = torch.zeros(1, fastText.get_dimension())
embeddings = torch.cat([pad, embeddings])
return embeddings
embeddings = obtener_embeddings(v.vocabulario, ft)
embeddings
Inicializar los pesos en un modelo
Respecto del modelo de la parte 2, la diferencia está en el método init_weights
que carga el tensor de los pesos en la capa de embeddings y que es llamado durante la inicialización del modelo. Recordemos: para que la carga funcione (copy_
) las dimensiones del tensor de pesos debe ser exactamente igual a las de la capa de embedding.
Además congelamos los pesos (requires_grad = False
) para que no cambien durante el entrenamiento. Lo que se aconseja es entrenar el resto de las capas hasta que la función de pérdida se estabilice; dejar libres a los pesos de la capa de embeddings cuando el resto de la red tiene pesos con valores aleatorios hará que los embeddings varíen significativamente durante el aprendizaje y pierdan sentido. Suele ser útil descongelar los pesos una vez que el modelo ha alcanzado cierto nivel de aprendizaje para efectuar un aprendizaje fino, en el que los embeddings se adaptarán al problema en cuestión.
import torch.nn as nn
import torch.nn.functional as F
DIM_EMBEDDINGS = 8
class ClasificadorBolsa(nn.Module):
def __init__(self, vocab_size, embed_dim, num_class):
super().__init__()
self.embedding = nn.EmbeddingBag(vocab_size, embed_dim, sparse=False, mode='max')
self.fc = nn.Linear(embed_dim, num_class)
# inicializamos los pesos
self.init_weights()
def init_weights(self):
self.embedding.weight.data.copy_(embeddings)
self.embedding.weight.data.requires_grad = False
def forward(self, text, offsets):
embedded = self.embedding(text, offsets)
return self.fc(embedded)
nn.Embedding
Hemos visto con algo de detalle el módulo de PyTorch nn.EmbeddingBag
, una capa de doble acción: convierte índices en vectores y calcula un vector agregado, una forma simple de obtener una representación de un documento, aunque no la más efectiva de todas. Para lograr mejores representaciones encontramos en uso modelos más complejos. La primera capa de modelos que usan capas LSTM o Transformer es una nn.Embedding
, que a diferencia de la mencionada anteriormente es de simple acción: convierte índices en vectores y ya.
Quiero ilustrar brevemente cómo son la entrada y la salida de esta capa, ya que son bien diferentes a las de nn.EmbeddingBag
. La inicialización sin embargo, es similar. El tensor de los pesos tendrá las dimensiones de tamaño del vocabulario por la dimensión (valga la redundancia) de los embeddings.
capa = nn.Embedding(len(v), ft.get_dimension(), padding_idx=v.índice_relleno)
Diferentemente, como esta capa requiere el uso del tóken de relleno, podemos especificar el índice del tóken para que la capa inicialice sus pesos al azar excepto los de este vector, que será inicializado en cero. Si lo deseamos, podemos utilizar vectores pre-entrenados.
capa.weight.data.copy_(embeddings)
capa.weight.data.requires_grad = False
Ahora armaremos un lote de documentos y lo convertiremos en un tensor. Para poder hacer esto último es fundamental que los documentos tengan el mismo largo (que será igual al del documento más largo), así que nos valdremos del tóken de relleno para lograrlo.
índices = v.tókenes_a_índices([
['señor', 'tiene', 'cambio', 'de', 'cien', '<pad>'],
['señor', 'tiene', 'cambio', 'de', 'mil', '<pad>'],
['extravié', 'mi', 'tarjeta', 'de', 'débito', 'anoche'],
])
índices = torch.tensor(índices)
índices.shape
Tenemos un tensor bidimensional, la dimensión 0 (filas) es la cantidad de documentos del lote, la dimensión 1 (columnas) es el tamaño de los documentos.
Así luce el tensor de índices.
índices
Ahora lo hacemos pasar por la capa de embeddings.
vectores = capa(índices)
vectores.shape
Observamos que la capa anadió una nueva dimensión, ahora tenemos un tensor tridimensional. Reemplazó cada índice (un escalar) por su vector correspondiente de largo 300. La dimensión 2 (profundidad) siempre corresponderá al tamaño del embedding.
Aquí termina la serie de artículos de pre-procesamiento de texto. Gracias por haber llegado hasta el fin.