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:
vocabulary_
es compartido, lo que conlleva que sea difícil la sincronización y por tanto que se produzca una sobrecarga.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)
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.
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'])
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.
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:
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()
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:
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)
Utilizar este tipo de vectorizadores nos permiten una mejor escalabilidad y trabajar on-line, pero también tienen algunos inconvenientes:
HashingVectorizer
no nos permite emplear "Inverse Document Frequency" (no existe la opción use_idf=True
).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.
In [ ]: