Saltar a contenido

Procesamiento del lenguaje natural

Uno de los ámbitos de aplicación más populares hoy en día de la IA es el procesamiento del lenguaje natural. Aquí podemos encontrar diversos campos: sistemas que traducen de un idioma a otro, o que interpretan un texto para encontrar un significado general, o incluso para emitir una respuesta elaborada, como ocurre hoy en día con sistemas como ChatGPT, Gemini o similares.

Veremos en este apartado algunas nociones básicas sobre las que se sustenta el procesamiento del lenguaje natural, y distintas estrategias para abordarlo.

1. Fundamentos previos

Como paso inicial, vamos a analizar algunos elementos importantes de la problemática a la que nos enfrentamos, y cómo podemos procesar las palabras del texto a analizar.

1.1. Problemas inherentes al procesamiento de textos

A la hora de analizar un texto y extraer o catalogar su significado, nos podemos encontrar con diferentes problemas:

  • La semántica o significado de las palabras. ¿Cómo trasladar esos significados a un ordenador?
  • La secuencia o el orden en que vienen dadas las palabras será determinante para asignar un significado o veracidad a la frase. Por ejemplo, las frases "Los leones comen gacelas" y "Las gacelas comen leones" tienen las mismas palabras, pero el orden en que las ubicamos hace que una sea cierta y la otra no.
  • La representación numérica de las palabras. Dado que los datos que recibe una red neuronal son datos numéricos, debemos encontrar una forma de codificar las palabras en números de forma unívoca. Trataremos este punto a continuación
  • El lenguaje no estándar, ya que en algunas ocasiones se escriben palabras que no se recogen en un diccionario tradicional: palabras mal escritas, abreviaturas, etc.
  • La segmentación de la frase, para identificar los componentes principales (sujeto, verbo...)

1.2. Representación numérica de la entrada

Como hemos visto, es necesario codificar de algún modo las palabras para pasarlas a la red neuronal. Podemos optar por dos estrategias principales:

  • Codificación (encoding): es la forma más simple de conversión. Asigna a cada palabra o a cada letra una representación numérica
  • Encaje o incrustación (embedding): las palabras se representan mediante vectores numéricos, de forma que dos palabras de significado similar tienen vectores también similares.

1.2.1. Codificación de palabras (word encoding)

Existen dos tipos de codificación de textos: por caracteres o por palabras. En la codificación por caracteres se dispone de una tabla con tantas columnas como letras del alfabeto, y tantas filas como letras tenga la frase a analizar. En cada fila, se marca como 1 la letra correspondiente a esa posición. Por ejemplo, para la frase "hola buenas" tendríamos algo así:

a b c d e f g h i j k l m ...
h 0 0 0 0 0 0 0 1 0 0 0 0 0 ...
o 0 0 0 0 0 0 0 0 0 0 0 0 0 ...
l 0 0 0 0 0 0 0 0 0 0 0 1 0 ...
a 1 0 0 0 0 0 0 0 0 0 0 0 0 ...
_ 0 0 0 0 0 0 0 0 0 0 0 0 0 ...
b 0 1 0 0 0 0 0 0 0 0 0 0 0 ...
u 0 0 0 0 0 0 0 0 0 0 0 0 0 ...
e 0 0 0 0 1 0 0 0 0 0 0 0 0 ...
n 0 0 0 0 0 0 0 0 0 0 0 0 0 ...
a 1 0 0 0 0 0 0 0 0 0 0 0 0 ...
s 0 0 0 0 0 0 0 0 0 0 0 0 0 ...

Podría ser una estrategia válida, pero se utiliza mucha memoria para almacenar la codificación de la frase y, además, se pierde la noción de "palabra" y de "significado" en este caso. La red se dedicaría a asociar secuencias de letras a resultados, pero no palabras ni combinaciones de palabras de forma sencilla.

En la codificación por palabras se construye un diccionario de palabras conocidas, y se le asigna a cada una un código numérico distinto. Por ejemplo:

Palabra Código
hola 1
buenas 2
leones 3
comen 4
gacelas 5
... ...

Es importante, en este caso, decidir el tamaño del diccionario, ya que con esto sólo permitiremos registrar un número limitado de palabras. Todas aquellas palabras no comprendidas en el diccionario quedarán marcadas con un código adicional, que significará que esa palabra no se reconoce y no se tendrá en cuenta (por ejemplo, código 0 en el caso anterior). Así, una frase como "hola buenas, los leones comen gacelas" se podría codificar como [1, 2, 0, 3, 4, 5].

1.2.2. Encaje de palabras (word embedding)

El encaje de palabras va un paso más allá de la codificación, y transforma cada código de palabra en un vector. La idea es, a partir del texto original, obtener la codificación de cada palabra, vectorizarlas y pasar esto como entrada a una red recurrente para que obtenga un resultado final:

Como vemos, añadimos una etapa más en la entrada, tras la codificación de las palabras, para vectorizarlas. Esto permite, entre otras cosas, eliminar una posible "relación de orden" entre las palabras. En el ejemplo anterior, la red podría llegar a asimilar que la palabra 120 es "mejor" o "peor" que la 35 por tener un código mayor. Por otra parte, palabras con significados similares como "triste" y "deprimido" podrían tener códigos muy dispares, lo que haría que la red no aprenda a establecer asociaciones entre ellas.

Al vectorizar los datos se pierde esa relación de orden numérica. Además, también se pretende que las palabras que sean similares tengan un vector similar de representación. Como viene siendo habitual en el mundo del deep learning, buscaremos que la red neuronal aprenda a crear vectores similares para palabras similares por sí misma.

2. Procesamiento del lenguaje en Keras/TensorFlow

Veamos algunas estrategias y mecanismos que podemos emplear usando Keras/TensorFlow y algunas librerías auxiliares para procesar secuencias de textos y pasarlos como entrada a redes neuronales recurrentes.

2.1. Pasos previos

Antes de construir un modelo de procesamiento de lenguaje natural en Keras/TensorFlow, es necesario realizar una serie de pasos previos de tratamiento del texto de entrada. Existen varias estrategias para esto, aunque aquí plantearemos la siguiente:

  • Extraer todas las palabras del conjunto de entrenamiento
  • Eliminar aquellas que no aporten un significado relevante en las frases. Estas palabras se conocen como palabras vacías o, en inglés, stop words. Existen diferentes listas de stop words para cada idioma. Aquí podéis consultar un ejemplo para el idioma español, y aquí otro para distintos idiomas. Existen, además, librerías como nltk que descargan y utilizan listados predefinidos de stop words en el idioma indicado (más información aquí).
  • De las palabras restantes, quedarnos con las N más representativas (las N que más ocurrencias tienen en el contexto en que estamos trabajando), y asignarle a cada una un código numérico distinto.

Para ayudarnos en todo este proceso, podemos emplear la clase CountVectorizer de SciKit Learn. Dispone de un constructor donde podemos indicarle la lista de stop words y el tamaño N del diccionario que queramos generar (cuántas palabras elegimos). Con el método fit_transform le pasamos un texto o array de textos y genera un diccionario en la propiedad vocabulary_.

from sklearn.feature_extraction.text import CountVectorizer

N = 5
stop_words = ['un', 'una', 'el', 'que', 'y', 'la', 'de']
textos = ['Había una vez un circo que alegraba siempre el corazón',
'Érase una vez un planeta triste y oscuro',
'Mírala, la puerta de Alcalá']

cv = CountVectorizer(stop_words=stop_words, max_features = N)
cv.fit_transform(textos)
# Diccionario con las N=5 palabras más relevantes
diccionario = cv.vocabulary_

Hay que tener en cuenta que, además de las palabras del vocabulario, habrá que dejar dos huecos especiales más, que podemos asignar a los números 0 y 1, por ejemplo:

  • 0 para palabras desconocidas (no contempladas en el vocabulario)
  • 1 para palabras de relleno o padding, usadas para igualar la longitud de las cadenas de entrada

Así, nuestro diccionario podría quedar así:

diccionario = dict([(palabra, i+2) for i, palabra in enumerate(diccionario)])
diccionario['DESC'] = 0
diccionario['PAD'] = 1 

Uniendo las piezas

Vamos a crear una función llamada generar_diccionario que recibirá como parámetros:

  • El array textos de entradas de texto a procesar
  • El array con las stop words que queremos tener en cuenta
  • El tamaño N del vocabulario que queremos construir

La función devolverá el diccionario creado con los textos de entrada.

def generar_diccionario(textos, sw, N):
    cv = CountVectorizer(stop_words = sw, max_features = N)
    cv.fit_transform(textos)
    diccionario = cv.vocabulary_
    diccionario = dict([(palabra, i+2) for i, palabra in enumerate(diccionario)])
    diccionario['DESC'] = 0
    diccionario['PAD'] = 1 
    return diccionario

Esta otra función tomará un texto como entrada y, aplicando el diccionario y la lista de stop words, devolverá la secuencia de códigos que representa el texto, incluyendo los símbolos desconocidos y de relleno, para formar una frase de T palabras:

import re
...

def procesar_cadena(texto, diccionario, stop_words, T):
    # Identificar palabras en el texto
    palabras = re.findall(r'\b\w+\b', texto.lower())
    palabras = list(filter(lambda x: x not in stop_words, palabras))
    resultado = [] 
    for i in range(0, T):
        if i < len(palabras): 
            if palabras[i] in diccionario:
                resultado.append(diccionario[palabras[i]])
            else:
                resultado.append(diccionario['DESC'])
        else:
            resultado.append(diccionario['PAD'])
    return np.array(resultado)

La función re.findall del módulo de expresiones regulares nativo de Python (re) nos permite separar las palabras de un texto, y obtener en un vector/lista todas las encontradas..

2.2. Ejemplo de red recurrente con word encoding

Para poner en práctica todos estos conceptos, utilizaremos este dataset con reseñas de hoteles en la web TripAdvisor, basado en este reto de Kaggle. En cada línea se tiene el texto completo de la reseña y la valoración numérica (entero de 1 a 5) que ha hecho el usuario en cuestión. Vamos a construir una red recurrente que, con una codificación previa de las palabras, aprenda a adivinar la puntuación que da un cliente a un hotel, dada su reseña.

Comenzaremos nuestro programa cargando las librerías necesarias, junto con las dos funciones que hemos definido previamente, y una lista de stop words en inglés:

import re
import numpy as np
import pandas as pd
from keras.models import Sequential
from keras.layers import Input, Dense, Dropout, LSTM, Bidirectional
from sklearn.feature_extraction.text import CountVectorizer

# Parámetros configurables
N = 20000  # Número de palabras del diccionario
T = 100    # Longitud prefijada de la reseña
EPOCHS = 10
NEURONAS_CAPA = 128

def generar_diccionario(textos, sw, N):
    ...

def procesar_cadena(texto, diccionario, stop_words, T):
    ...

stop_words=['a', 'about', 'an', 'and', 'as', 'at', 'be', 'because', 'been', 
    'being', 'by', 'during', 'each', 'for', 'from', 'he', 'he\'d', 'he\'ll',
    'he\'s', 'her', 'him', 'his', 'how', 'i', 'i\'d', 'i\'ll', 'i\'m', 'i\'ve',
    'if', 'in', 'into', 'it', 'it\'s', 'its', 'let\'s', 'of', 'or', 'our', 
    'out', 'over', 'same', 'she', 'she\'d', 'she\'ll', 'she\'s', 'so', 'some'
    'such', 'than', 'that', 'that\'s', 'the', 'them', 'then', 'there', 'these',
    'they', 'they\'d', 'they\'ll', 'they\'re', 'they\'ve', 'this', 'those',
    'through', 'to', 'until', 'very', 'we', 'we\'d', 'we\'ll', 'we\'re', 
    'we\'ve', 'what', 'what\'s', 'when', 'when\'s', 'where', 'where\'s', 'which',
    'while', 'who', 'who\'s', 'whom', 'why', 'why\'s', 'with', 'would', 
    'wouldn\'t', 'you', 'you\'d', 'you\'ll', 'you\'re', 'you\'ve', 'your', 
    'yours']

Ahora vamos a leer el CSV de entrada, procesar la columna con las reseñas y convertir cada reseña en una secuencia de T = 100 códigos, según el diccionario que generaremos con las propias reseñas.

datos = pd.read_csv('tripadvisor_hotel_reviews.csv')

# Variable objetivo
y = datos.iloc[:, -1]
num_clases = len(y.unique())
# Codificamos con "one hot" las posibles categorías finales
y = pd.get_dummies(datos['Rating'], columns=['Rating'])

# Textos de reseñas
textos = datos['Review']
X = []
diccionario = generar_diccionario(textos, stop_words, N)
for texto in textos:
    X.append(procesar_cadena(texto, diccionario, stop_words, T))
# X contendrá las reseñas codificadas y rellenas con la misma longitud
X = np.array(X)

Ya estamos en disposición de crear y entrenar la red. Nuestros datos de entrada serán los de la variable X, y la columna objetivo la tenemos almacenada en la variable y (columna Rating codificada en one hot).

modelo = Sequential()
modelo.add(Input((X.shape[1], 1)))
modelo.add(LSTM(units=NEURONAS_CAPA, return_sequences=True))
modelo.add(Bidirectional(LSTM(units=NEURONAS_CAPA)))
modelo.add(Dense(units=num_clases, activation='softmax'))

modelo.compile(optimizer='adam', loss='categorical_crossentropy', metrics=['accuracy'])
modelo.fit(X, y, validation_split=0.2, epochs=EPOCHS, batch_size=128)

Si lanzamos el entrenamiento, veremos que se alcanza una exactitud en torno al 50%. No está del todo mal, teniendo en cuenta que hay 5 categorías diferentes, lo que daría una probabilidad de acierto por azar del 20%. Sin embargo, la red no es todo lo buena que podría, entre otras cosas, porque la codificación word encoding tiene sus limitaciones. Por ejemplo, la red aprende que hay palabras "mejores" o "peores" que otras en función de su código numérico y, además, dos palabras con significados similares, como podrían ser "increíble" y "maravilloso", podrían tener códigos muy diferentes, lo que hace difícil que la red las asocie. Veremos cómo evitar este problema con el encaje de palabras (word embedding) a continuación.

Ejercicio 1

Utiliza este dataset sobre asociaciones entre preguntas, basado en este reto de Kaggle. En cada línea aparecen los textos de dos preguntas y un resultado (1 o 0) que indica si las dos preguntas tratan sobre el mismo contenido u objetivo final. Vamos a construir una red recurrente que, usando word encoding, entrene con el dataset proporcionado para aprender a asociar preguntas.

2.3. Ejemplo de red recurrente con word embedding

Para trabajar con la técnica del word embedding, Keras pone a nuestra disposición la clase Embedding del paquete keras.layers. Con ella añadiremos una capa más que procesará la entrada (secuencia de palabras ya codificadas) y las transformará en vectores de la dimensión que elijamos. Recibirá como entrada un código del vocabulario y devolverá su representación en forma de vector. Para ello, emplearemos dos parámetros:

  • input_dim: tamaño del vocabulario de entrada (variable N usada en ejemplos anteriores)
  • output_dim: tamaño de los vectores que se quieren generar (configurable)

Vamos a poner un ejemplo simple para comprender cómo funciona esta capa: imaginemos un tamaño de vocabulario de N = 5 palabras, y queremos vectorizarlas en vectores de tamaño D = 8. Vamos a "simular" la construcción de un modelo con una capa de embedding que haga este trabajo:

import numpy as np
from keras.models import Model
from keras.layers import Input, Embedding

N = 5
D = 8

# Capa de entrada ficticia para la red
capa_entrada = Input(shape=(None,), dtype='int32')

# Añadimos la capa de embedding
embedding = Embedding(input_dim=N, output_dim=D)(capa_entrada)

# Creamos un modelo a partir de estas dos capas
modelo = Model(capa_entrada,embedding)
modelo.summary()

# Probamos a 'predecir' a través de esta red 
codificacion_entera = [4,1,3,3,3]
codificacion_embedding = modelo.predict(np.asarray([codificacion_entera]))

print()
print('Representación de {}'.format( str(codificacion_entera) ))
print(codificacion_embedding)

La salida vectorizada que obtendremos para la entrada de las palabras [4, 1, 3, 3, 3] será algo así:

Representación de [4, 1, 3, 3, 3]
[[[-0.04356736  0.00109762 -0.03052573  0.01432915 -0.01164786
   -0.005283   -0.03599878 -0.03789987]
  [-0.02836268 -0.01595914 -0.03928456 -0.01936477 -0.01065432
   -0.01893798 -0.01274011 -0.00826163]
  [ 0.02959    -0.01099481 -0.00325797  0.03877315 -0.01169072
   -0.04086158  0.00216376 -0.02098749]
  [ 0.02959    -0.01099481 -0.00325797  0.03877315 -0.01169072
   -0.04086158  0.00216376 -0.02098749]
  [ 0.02959    -0.01099481 -0.00325797  0.03877315 -0.01169072
   -0.04086158  0.00216376 -0.02098749]]]

Así, la palabra codificada como 4 se representará con el vector [-0.04356736 0.00109762 -0.03052573 0.01432915 -0.01164786 -0.005283 -0.03599878 -0.03789987], y así sucesivamente. Estos vectores se ajustan poco a poco, de forma convencional para el entrenamiento en redes neuronales.

Transformaremos ahora el ejemplo anterior de reseñas de TripAdvisor usando word embedding, para comprobar si esta técnica ofrece mejores resultados. En cuanto a cambios importantes, debemos importar la capa Embedding junto a las demás...

from keras.layers import Dense, Dropout, LSTM, Bidirectional, Embedding

... y declaramos la variable D con el número de dimensiones que queremos que tengan los vectores (podemos hacer varias pruebas con varios valores para dar con uno apropiado).

N = 20000
T = 100
D = 128             # Dimensiones de los vectores de embedding
EPOCHS = 10
NEURONAS_CAPA = 128

El siguiente cambio viene en la definición del modelo, añadiendo una capa Embedding en la entrada:

modelo = Sequential()

# Añadimos 2 unidades más al tamaño para incluir los códigos de
# palabras desconocidas y de padding
modelo.add(Embedding(input_dim=N+2, output_dim=D))
modelo.add(Bidirectional(LSTM(units=NEURONAS_CAPA, return_sequences=True)))
modelo.add(Bidirectional(LSTM(units=NEURONAS_CAPA)))
modelo.add(Dense(units=num_clases, activation='softmax'))

modelo.compile(optimizer='adam', loss='categorical_crossentropy', metrics=['accuracy'])
modelo.fit(X, y, validation_split=0.2, epochs=EPOCHS, batch_size=128)

En este caso es fácil caer en el overfitting, con una exactitud de más del 90% en el conjunto de entrenamiento y de menos del 60% en el de test. Ajustando el número de capas, el dropout y el número de neuronas por capa se puede reducir parcialmente ese overfitting, con una exactitud cercana al 70%.

Aquí puedes descargar los ejemplos sobre el dataset de TripAdvisor, tanto con word encoding como con word embedding.

Ejercicio 2

Transforma la red recurrente del ejercicio anterior en otra que utilice word embedding para la misma tarea

2.4. Otras mejoras aplicables

El proceso anterior puede funcionar correctamente para muchos casos de procesamiento del lenguaje. Sin embargo, tiene algunos problemas que podemos intentar subsanar. Por ejemplo, hacer un padding o rellenado general en todo el conjunto de datos puede resultar ineficiente si las entradas tienen tamaños muy dispares. Demasiados códigos de padding pueden alterar significativamente el aprendizaje de la red.

Para evitar este problema podemos aplicar distintas alternativas:

  • Podemos aplicar un padding por lotes, a través de lo que se llaman funciones generadoras. En este caso, definimos un tamaño de lote B, y la función se encarga de, a partir del conjunto de entradas X, irlas agrupando en bloques de B entradas, y aplicar un padding a ese bloque de acuerdo al tamaño de las entradas de ese bloque nada más. Esto permitirá, además, que la red pueda entrenar con entradas de longitud variable, ya que cada lote podrá tener un tamaño diferente.
  • Complementar la técnica anterior con otra llamada bucketting. Consiste en ordenar el conjunto X de entrada por tamaño, para que luego los lotes de tamaño B agrupen entradas de tamaño similar, y se reduzcan aún más los caracteres de padding necesarios.

3. Transfer learning para redes recurrentes

En documentos anteriores ya explicamos el concepto de transfer learning, y vimos que consiste en re-entrenar parcialmente un modelo ya definido para utilizarlo en otro contexto diferente, a grandes rasgos.

En el ámbito de las redes recurrentes también podemos aplicarlo. Por ejemplo, el análisis de textos o procesamiento del lenguaje es una tarea algo tediosa en ocasiones, en lo que se refiere al procesamiento previo de los textos para convertirlos en secuencias de códigos (word encoding) o incluso en vectores de códigos (word embedding), como ya hemos visto antes. Sin embargo, podemos valernos del transfer learning para incorporar ya un modelo de embedding específico, y construir con él otro modelo propio.

En la red existen multitud de modelos pre-entrenados para hacer esta tarea de word embedding. Podemos utilizar cualquiera de ellos como punto de entrada a nuestro modelo. Vamos a utilizar en este ejemplo este modelo entrenado sobre noticias de Google News. Podemos ver cómo vectoriza contenido muy sencillamente, con un ejemplo como éste (necesitamos tener instalado el paquete tensorflow_hub):

import tensorflow_hub as hub

embed = hub.load("https://www.kaggle.com/models/google/nnlm/TensorFlow2/tf2-preview-en-dim50/1")
embeddings = embed(["cat is on the mat", "dog is in the fog"])

Como podemos ver si ejecutamos el ejemplo anterior y echamos un vistazo a la variable embeddings, se vectoriza cada frase en 50 elementos. Cada elemento de estos vectores de embedding representa una característica, y su valor representa la importancia de esa característica para esa palabra o frase. Podemos utilizar también un modelo que genera vectores de 128 elementos, cambiando 50 por 128 en la URL anterior. A partir de aquí, podemos tomar este modelo como capa de entrada de nuestra red.

Si queremos conectar la capa de embedding importada con capas LSTM, necesitamos redimensionar el conjunto X de entrada previo. Suponiendo que X tenga un array de textos, podríamos hacer algo así:

hub_layer = hub.KerasLayer("https://www.kaggle.com/models/google/nnlm/TensorFlow2/tf2-preview-en-dim50/1",
                           input_shape=[], dtype=tf.string, trainable=True)

# Convertimos los textos en vectores de códigos numéricos
# Con esto conseguimos que X tenga 2 dimensiones:
# - Número de textos
# - Número de códigos por texto (50 en este caso)
X = hub_layer(X)

modelo = keras.Sequential()
modelo.add(Input((X.shape[1], 1)))
modelo.add(keras.layers.LSTM(units=128, return_sequences=True))
modelo.add(keras.layers.Bidirectional(keras.layers.LSTM(units=128)))
...

Ejercicio 3

Utiliza el modelo de embedding visto en el ejemplo anterior para entrenar una red que prediga si un tuit trata de un desastre real, usando este dataset.