Aprendizaje out-of-core

Problemas de escalabilidad

Las clases sklearn.feature_extraction.text.CountVectorizer y sklearn.feature_extraction.text.TfidfVectorizer tienen una serie de problemas de escalabilidad que provienen de la forma en que se utiliza, a nivel interno, el atributo vocabulary_ (que es un diccionario Python) para convertir los nombres de las características (cadenas) a índices enteros de características.

Los principales problemas de escalabilidad son:

  • Uso de memoria del vectorizador de texto: todas las representaciones textuales de características se cargan en memoria.
  • Problemas de paralelización para extracción de características: el atributo vocabulary_ es compartido, lo que conlleva que sea difícil la sincronización y por tanto que se produzca una sobrecarga.
  • Imposibilidad de realizar aprendizaje online, out-of-core o streaming: el atributo vocabulary_ tiene que obtenerse a partir de los datos y su tamaño no se puede conocer hasta que no realizamos una pasada completa por toda la base de datos de entrenamiento.

Para entender mejor estos problemas, analicemos como trabaja el atributo vocabulary_. En la fase de fit se identifican los tokens del corpus de forma unívoca, mediante un índice entero, y esta correspondencia se guarda en el vocabulario:


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

vectorizer = CountVectorizer(min_df=1)

vectorizer.fit([
    "The cat sat on the mat.",
])
vectorizer.vocabulary_

El vocabulario se utiliza en la fase transform para construir la matriz de ocurrencias:


In [ ]:
X = vectorizer.transform([
    "The cat sat on the mat.",
    "This cat is a nice cat.",
]).toarray()

print(len(vectorizer.vocabulary_))
print(vectorizer.get_feature_names())
print(X)

Vamos a realizar un nuevo fit con un corpus algo más grande:


In [ ]:
vectorizer = CountVectorizer(min_df=1)

vectorizer.fit([
    "The cat sat on the mat.",
    "The quick brown fox jumps over the lazy dog.",
])
vectorizer.vocabulary_

El atributo vocabulary_ crece (en escala logarítmica) con respecto al tamaño del conjunto de entrenamiento. Observa que no podemos construir los vocabularios en paralelo para cada documento de texto ya que hay algunas palabras que son comunes y necesitaríamos alguna estructura compartida o barrera de sincronización (aumentando la complejidad de implementar el entrenamiento, sobre todo si queremos distribuirlo en un cluster).

Con este nuevo vocabulario, la dimensionalidad del espacio de salida es mayor:


In [ ]:
X = vectorizer.transform([
    "The cat sat on the mat.",
    "This cat is a nice cat.",
]).toarray()

print(len(vectorizer.vocabulary_))
print(vectorizer.get_feature_names())
print(X)

El dataset de películas IMDb

Para mostrar los problemas de escalabilidad con los vocabularios basados en vectorizadores, vamos a cargar un dataset realista que proviene de una tarea típica de clasificación de textos: análisis de sentimientos en texto. El objetivo es discernir entre revisiones positivas y negativas a partir de la base de datos de Internet Movie Database (IMDb).

En las siguientes secciones, vamos a usar el siguiente dataset subset de revisiones de películas de IMDb, que has sido recolectado por Maas et al.

  • A. L. Maas, R. E. Daly, P. T. Pham, D. Huang, A. Y. Ng, and C. Potts. Learning Word Vectors for Sentiment Analysis. In the proceedings of the 49th Annual Meeting of the Association for Computational Linguistics: Human Language Technologies, pages 142–150, Portland, Oregon, USA, June 2011. Association for Computational Linguistics.

Este dataset contiene 50,000 revisiones de películas, divididas en 25,000 ejemplos de entrenamiento y 25,000 ejemplos de test. Las revisiones se etiquetan como negativas (neg) o positivas (pos). De hecho, las negativas recibieron $\le 4$ estrellas en IMDb; las positivas recibieron $\ge 7$ estrellas. Las revisiones neutrales no se incluyeron en el dataset.

Asumiendo que ya habéis ejecutado el script fetch_data.py, deberías tener disponibles los siguientes ficheros:


In [ ]:
import os

train_path = os.path.join('datasets', 'IMDb', 'aclImdb', 'train')
test_path = os.path.join('datasets', 'IMDb', 'aclImdb', 'test')

Ahora, vamos a cargarlos en nuestra sesión activa usando la función load_files de scikit-learn:


In [ ]:
from sklearn.datasets import load_files

train = load_files(container_path=(train_path),
                   categories=['pos', 'neg'])

test = load_files(container_path=(test_path),
                  categories=['pos', 'neg'])
NOTA:
  • Ya que el dataset de películas contiene 50,000 ficheros individuales de texto, ejecutar el código anterior puede llevar bastante tiempo.

La función load_files ha cargado los datasets en objetos sklearn.datasets.base.Bunch, que son diccionarios de Python:


In [ ]:
train.keys()

En particular, solo estamos interesados en los arrays data y target.


In [ ]:
import numpy as np

for label, data in zip(('ENTRENAMIENTO', 'TEST'), (train, test)):
    print('\n\n%s' % label)
    print('Número de documentos:', len(data['data']))
    print('\n1er documento:\n', data['data'][0])
    print('\n1era etiqueta:', data['target'][0])
    print('\nNombre de las clases:', data['target_names'])
    print('Conteo de las clases:', 
          np.unique(data['target']), ' -> ',
          np.bincount(data['target']))

Como puedes comprobar, el array 'target' consiste en valores 0 y 1, donde el 0 es una revisión negativa y el 1 representa una positiva.

El truco del hashing

Recuerda la representación bag-of-words que se obtenía usando un vectorizador basado en vocabulario:

Para solventar las limitaciones de los vectorizadores basados en vocabularios, se puede utilizar el truco del hashing. En lugar de construir y almacenar una conversión explícita de los nombres de las características a los índices de las mismas dentro de un diccionario Python, podemos aplicar una función de hash y el operador módulo:

Podemos acceder a más información y a las referencias a los artículos originales en el siguiente sitio web, y una descripción más sencilla en este otro sitio.


In [ ]:
from sklearn.utils.murmurhash import murmurhash3_bytes_u32

# Codificado para compatibilidad con Python 3
for word in "the cat sat on the mat".encode("utf-8").split():
    print("{0} => {1}".format(
        word, murmurhash3_bytes_u32(word, 0) % 2 ** 20))

La conversión no tiene estado y la dimensionalidad del espacio de salida se fija a priori (aquí usamos módulo 2 ** 20, que significa aproximadamente que tenemos un millón de dimensiones, $2^{20}$). Esto hace posible evitar las limitaciones del vectorizador de vocabulario, tanto a nivel de paralelización como de poder aplicar aprendizaje online.

La clase HashingVectorizer es una alternativa a CountVectorizer (o a TfidfVectorizer si consideramos use_idf=False) que aplica internamente la función de hash llamada murmurhash:


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

h_vectorizer = HashingVectorizer(encoding='latin-1')
h_vectorizer

Comparte la misma estructura de preprocesamiento, generación de tokens y análisis:


In [ ]:
analyzer = h_vectorizer.build_analyzer()
analyzer('Esta es una frase de prueba.')

Podemos vectorizar nuestros datasets en matriz dispersa de scipy de la misma forma que hubiéramos hecho con CountVectorizer o TfidfVectorizer, excepto que podemos llamar directamente al método transform. No hay necesidad de llamar a fit porque el HashingVectorizer no se entrena, las transformaciones están prefijadas.


In [ ]:
docs_train, y_train = train['data'], train['target']
docs_valid, y_valid = test['data'][:12500], test['target'][:12500]
docs_test, y_test = test['data'][12500:], test['target'][12500:]

La dimensión de salida se fija de antemano a n_features=2 ** 20 (valor por defecto) para minimizar la probabilidad de colisión en la mayoría de problemas de clasificación (1M de pesos en el atributo coef_):


In [ ]:
h_vectorizer.transform(docs_train)

Ahora vamos a comparar la eficiencia computacional de HashingVectorizer con respecto a CountVectorizer:


In [ ]:
h_vec = HashingVectorizer(encoding='latin-1')
%timeit -n 1 -r 3 h_vec.fit(docs_train, y_train)

In [ ]:
count_vec =  CountVectorizer(encoding='latin-1')
%timeit -n 1 -r 3 count_vec.fit(docs_train, y_train)

Como puedes observar, HashingVectorizer es mucho más rápido que Countvectorizer.

Por último, vamos a entrenar un clasificador LogisticRegression en los datos de entrenamiento de IMDb:


In [ ]:
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline

h_pipeline = Pipeline([
    ('vec', HashingVectorizer(encoding='latin-1')),
    ('clf', LogisticRegression(random_state=1)),
])

h_pipeline.fit(docs_train, y_train)

In [ ]:
print('Accuracy de entrenamiento', h_pipeline.score(docs_train, y_train))
print('Accuracy de validación', h_pipeline.score(docs_valid, y_valid))

In [ ]:
import gc

del count_vec
del h_pipeline

gc.collect()

Aprendizaje Out-of-Core

El aprendizaje Out-of-Core consiste en entrenar un modelo de aprendizaje automático usando un dataset que no cabe en memoria RAM. Requiere las siguientes condiciones:

  • Una capa de extracción de características con una dimensionalidad de salida fija.
  • Saber la lista de clases de antemano (en este caso, sabemos que hay tweets positivos y negativos).
  • Un algoritmo de aprendizaje automático que soporte aprendizaje incremental (método partial_fit en scikit-learn).

En la siguientes secciones, vamos a configurar una función simple de entrenamiento iterativo de un SGDClassifier.

Pero primero cargamos los nombres de los ficheros en una lista de Python:


In [ ]:
train_path = os.path.join('datasets', 'IMDb', 'aclImdb', 'train')
train_pos = os.path.join(train_path, 'pos')
train_neg = os.path.join(train_path, 'neg')

fnames = [os.path.join(train_pos, f) for f in os.listdir(train_pos)] +\
         [os.path.join(train_neg, f) for f in os.listdir(train_neg)]

fnames[:3]

Ahora vamos a crear el array de etiquetas:


In [ ]:
y_train = np.zeros((len(fnames), ), dtype=int)
y_train[:12500] = 1
np.bincount(y_train)

Ahora vamos a implementar la función batch_train function:


In [ ]:
from sklearn.base import clone

def batch_train(clf, fnames, labels, iterations=25, batchsize=1000, random_seed=1):
    vec = HashingVectorizer(encoding='latin-1')
    idx = np.arange(labels.shape[0])
    c_clf = clone(clf)
    rng = np.random.RandomState(seed=random_seed)
    
    for i in range(iterations):
        rnd_idx = rng.choice(idx, size=batchsize)
        documents = []
        for i in rnd_idx:
            with open(fnames[i], 'r') as f:
                documents.append(f.read())
        X_batch = vec.transform(documents)
        batch_labels = labels[rnd_idx]
        c_clf.partial_fit(X=X_batch, 
                          y=batch_labels, 
                          classes=[0, 1])
      
    return c_clf

Ahora vamos a utilizar la clase un SGDClassifier con un coste logístico en lugar de LogisticRegression. SGD proviene de stochastic gradient descent, un algoritmo de optimización que optimiza los pesos de forma iterativa ejemplo a ejemplo, lo que nos permite pasarle los datos en grupos.

Como empleamos el SGDClassifier con la configuración por defecto, entrenará el clasificador en 25*1000=25000 documentos (lo que puede llevar algo de tiempo).


In [ ]:
from sklearn.linear_model import SGDClassifier

sgd = SGDClassifier(loss='log', random_state=1)

sgd = batch_train(clf=sgd,
                  fnames=fnames,
                  labels=y_train)

Al terminar, evaluemos el rendimiento:


In [ ]:
vec = HashingVectorizer(encoding='latin-1')
sgd.score(vec.transform(docs_test), y_test)

Limitaciones de los vectorizadores basados en hash

Utilizar este tipo de vectorizadores nos permiten una mejor escalabilidad y trabajar on-line, pero también tienen algunos inconvenientes:

  • Las colisiones podrían introducir ruido en los datos y degradar la calidad de las predicciones.
  • La clase HashingVectorizer no nos permite emplear "Inverse Document Frequency" (no existe la opción use_idf=True).
  • No hay una forma simple de realizar una conversión inversa y encontrar los nombres de las características a partir del índice.

Las colisiones pueden minimizarse incrementando el parámetro n_features.

El peso IDF podría reintroducirse si añadimos un objeto de la clase TfidfTransformer a la salida del vectorizador. Sin embargo, obtener el estadístico idf_ utilizado para sopesar las características implicaría al menos una pasada adicional por los datos de entrenamiento antes de empezar a entrenar el clasificador: ya no podríamos afrontar un escenario on-line.

La falta de una conversión inversa (método get_feature_names() de TfidfVectorizer) es mucho más difícil de evitar. Tendríamos que extender la clase HashingVectorizer para añadir un modo "traza" que nos permitiera guardar la conversión de las características más importantes.

EJERCICIO:
  • En la implementación propuesta de la función ``batch_train``, se tomaron aleatoriamente *k* ejemplos de entrenamiento como *batch* en cada iteración, lo que puede considerarse un muestreo aleatorio con reemplazamiento. Modifica la función `batch_train` de forma que itere sobre todos los documentos ***sin*** reemplazamiento, es decir, que se usa cada documento ***una sola vez*** por iteración?

In [ ]: