In [ ]:
%matplotlib inline
import matplotlib.pyplot as plt
import numpy as np

Extracción de características de un texto mediante Bag-of-Words (bolsas de palabras)

En muchas tareas, como en la detección de spam, los datos de entrada son cadenas de texto. El texto libre con longitud variable está muy lejos de lo que necesitamos para hacer aprendizaje automático en scikit-learn (representaciones numéricas de tamaño fijo). Sin embargo, hay una forma fácil y efectiva de transformar datos textuales en una representación numérica, utilizando lo que se conoce como bag-of-words, que proporciona una estructura de datos que es compatible con los algoritmos de aprendizaje automático de scikit-learn.

Vamos a asumir que cada texto del dataset es una cadena, que puede ser una frase, un correo, un libro o un artículo completo de noticias. Para representar el patrón, primero partimos la cadena en un conjunto de tokens, que se corresponden con palabras (normalizadas de alguna forma). Un modo simple de hacer esto es partir la frase según los espacios en blanco y luego pasar a minúsculas todas las palabras.

Después hacemos un vocabulario a partir de todos los tokens (palabras en minúsculas) que encontramos en el dataset completo. Esto suele resultar en un vocabulario muy largo. Ahora tendríamos que ver si las palabras del vocabulario aparecen o no en nuestro patrón. Representamos cada patrón (cadena) con un vector, donde cada entrada nos informa acerca de cuántas veces aparece una palabra del vocabulario en el patrón (en su versión más simple un valor binario, 1 si aparece al menos una vez, 0 sino).

Ya que cada ejemplo va a tener solo unas cuantas palabras y el vocabulario suele ser muy largo, la mayoría de entradas son ceros, lo que lleva a una representación de alta dimensionalidad pero dispersa.

Este método se llama bolsa de palabras porque el orden de las palabras se pierde completamente (solo sabemos qué aparecen).


In [ ]:
X = ["Algunos dicen que el mundo terminará siendo fuego,",
     "Otros dicen que terminará siendo hielo."]

In [ ]:
len(X)

In [ ]:
from sklearn.feature_extraction.text import CountVectorizer

vectorizer = CountVectorizer()
vectorizer.fit(X)

In [ ]:
vectorizer.vocabulary_

In [ ]:
X_bag_of_words = vectorizer.transform(X)

In [ ]:
X_bag_of_words.shape

In [ ]:
X_bag_of_words

In [ ]:
X_bag_of_words.toarray()

In [ ]:
vectorizer.get_feature_names()

In [ ]:
# Volver al texto original (perdemos el orden y la capitalización)
vectorizer.inverse_transform(X_bag_of_words)

Codificación tf-idf

Una transformación bastante útil que a menudo es aplicada a la codificación bag-of-words es el escalado term-frequency inverse-document-frequency (tf-idf), frecuencia de término -- frecuencia inversa de documento (tener en cuenta la frecuencia de ocurrencia del término en la colección de documentos). Es una transformación no lineal del conteo de palabras. Consiste en una medida numérica que expresa cuán relevante es una palabra para un documento en una colección. Esta medida se utiliza a menudo como un factor de ponderación en recuperación de información y en minería de textos. El valor tf-idf aumenta proporcionalmente al número de veces que una palabra aparece en el documento, pero es compensado por la frecuencia de la palabra en la colección global de documentos, lo que permite manejar el hecho de que algunas palabras son generalmente más comunes que otras.

La codificación tf-idf rescala las palabras que son comunes para que tengan menos peso:


In [ ]:
from sklearn.feature_extraction.text import TfidfVectorizer

tfidf_vectorizer = TfidfVectorizer()
tfidf_vectorizer.fit(X)

In [ ]:
import numpy as np
np.set_printoptions(precision=2)

print(tfidf_vectorizer.transform(X).toarray())

Los tf-idfs son una forma de representar documentos como vectores de características. Se pueden entender como una modificación de la frecuencia de aparición de términos (tf): tf nos da una idea acerca de cuántas veces aparece el término en el documento (o patrón). La idea del tf-idf es bajar el peso de los términos proporcionalmente al número de documentos en que aparecen. Así, si un término aparece en muchos documentos en principio puede ser poco importante o al menos no aportar mucha información para las tareas de procesamiento de lenguaje natural (por ejemplo, la palabra que es muy común y no nos permite hacer una discriminación útil). Este libro externo de IPython proporciona mucha más información sobre las ecuaciones y el cálculo de la representación tf-idf.

Bigramas y n-gramas

En el ejemplo de la figura que había al principio de este libro, hemos usado la división en tokens basada en 1-gramas (unigramas): cada token representa un único elemento con respecto al criterio de división.

Puede ser que no siempre sea una buena idea descartar completamente el orden de las palabras, ya que las frases compuestas suelen tener significados específicos y algunos modificadores (como la palabra no) pueden invertir el significado de una palabra.

Una forma simple de incluir este orden son los n-gramas, que no miran un único token, sino todos los pares de tokens vecinos. Por ejemplo, si usamos división en tokens basada en 2-gramas (bigramas), agruparíamos juntas las palabras con un solape de una palabra. Con 3-gramas (trigramas), trabajaríamos con un solape de dos palabras...

  • Texto original: "Así es como consigues hormigas"
  • 1-gramas: "así", "es", "como", "consigues", "hormigas"
  • 2-gramas: "así es", "es como", "como consigues", "consigues hormigas"
  • 3-gramas: "así es como", "es como consigues", "como consigues hormigas"

El valor de $n$ para los n-gramas que resultará en el rendimiento óptimo para nuestro modelo predictivo depende enteramente del algoritmo de aprendizaje, del dataset y de la tarea. O, en otras palabras, tenemos que considerar $n$ como un parámetro de ajuste (en cuadernos posteriores veremos como tratar estos parámetros de ajuste).

Ahora vamos a crear un modelo basado en bag-of-words de bigramas usando la clase de scikit-learn CountVectorizer:


In [ ]:
# Utilizar secuencias de tokens de longitud mínima 2 y máxima 2
bigram_vectorizer = CountVectorizer(ngram_range=(2, 2))
bigram_vectorizer.fit(X)

In [ ]:
bigram_vectorizer.get_feature_names()

In [ ]:
bigram_vectorizer.transform(X).toarray()

Es común que queramos incluir unigramas (tokens individuales) y bigramas, lo que podemos hacer pasándole la siguiente tupla como argumento al parámetro ngram_range del constructor del CountVectorizer:


In [ ]:
gram_vectorizer = CountVectorizer(ngram_range=(1, 2))
gram_vectorizer.fit(X)

In [ ]:
gram_vectorizer.get_feature_names()

In [ ]:
transformada = gram_vectorizer.transform(X).toarray()

n-gramas de caracteres

A veces resulta interesante analizar los caracteres individuales, además de las palabras. Esto es particularmente útil si tenemos datos muy ruidosos y queremos identificar el lenguaje o si queremos predecir algo sobre una sola palabra. Para analizar caracteres en lugar de palabras utilizamos el parámetro analyzer="char". Analizar los caracteres aislados no suele proporcionar mucha información, pero considerar n-gramas más largos si que puede servir:


In [ ]:
X

In [ ]:
char_vectorizer = CountVectorizer(ngram_range=(2, 2), analyzer="char")
char_vectorizer.fit(X)

In [ ]:
print(char_vectorizer.get_feature_names())
EJERCICIO:
  • Obtener los n-gramas del "zen of python" que aparece a continuación (puedes verlo escribiendo ``import this``), y encuentra el n-grama más común. Considera valores de $n\in\{2,3,4\}$. Queremos tratar cada línea como un documento individual. Puedes conseguirlo si particionas el con el carácter de nueva línea (``\n``). Obtén la codificación tf-idf de los datos. ¿Qué palabras tienen la mayor puntuación tf-idf? ¿Por qué? ¿Qué es lo que cambia si utilizas ``TfidfVectorizer(norm="none")``?

In [ ]:
zen = """Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren't special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be one-- and preferably only one --obvious way to do it.
Although that way may not be obvious at first unless you're Dutch.
Now is better than never.
Although never is often better than *right* now.
If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea -- let's do more of those!"""

In [ ]: