6.2. Extracción de características

El módulo sklearn.feature_extraction puede utilizarse para extraer características en un formato compatible con los algoritmos de aprendizaje automático a partir de conjuntos de datos formados por formatos como texto e imagen.

Nota

La extracción de características es muy diferente de la Selección de características: la primera consiste en transformar datos arbitrarios, como texto o imágenes, en características numéricas que se pueden utilizar para el aprendizaje automático. La segunda es una técnica de aprendizaje automático aplicada a estas características.

6.2.1. Cargando características desde diccionarios

La clase DictVectorizer puede utilizarse para convertir arreglos de características representadas como listas de objetos estándar de Python dict a la representación NumPy/SciPy utilizada por los estimadores de scikit-learn.

Si bien no es particularmente rápido de procesar, el dict de Python tiene las ventajas de ser cómodo de usar, ser disperso (las características ausentes no necesitan ser almacenadas) y almacenar los nombres de las características además de los valores.

DictVectorizer implementa lo que se denomina codificación «one-of-K» o «one-hot» para características categóricas (también conocidas como nominales, discretas). Las características categóricas son pares «atributo-valor» donde el valor está restringido a una lista de posibilidades discretas sin ordenar (por ejemplo, identificadores de temas, tipos de objetos, etiquetas, nombres…).

En lo siguiente, «city» es un atributo categórico mientras que «temperature» es una característica numérica tradicional:

>>> measurements = [
...     {'city': 'Dubai', 'temperature': 33.},
...     {'city': 'London', 'temperature': 12.},
...     {'city': 'San Francisco', 'temperature': 18.},
... ]

>>> from sklearn.feature_extraction import DictVectorizer
>>> vec = DictVectorizer()

>>> vec.fit_transform(measurements).toarray()
array([[ 1.,  0.,  0., 33.],
       [ 0.,  1.,  0., 12.],
       [ 0.,  0.,  1., 18.]])

>>> vec.get_feature_names()
['city=Dubai', 'city=London', 'city=San Francisco', 'temperature']

DictVectorizer acepta múltiples valores de cadena para una característica, como, por ejemplo, múltiples categorías para una película.

Suponte que una base de datos clasifica cada película usando algunas categorías (no obligatorias) y su año de estreno.

>>> movie_entry = [{'category': ['thriller', 'drama'], 'year': 2003},
...                {'category': ['animation', 'family'], 'year': 2011},
...                {'year': 1974}]
>>> vec.fit_transform(movie_entry).toarray()
array([[0.000e+00, 1.000e+00, 0.000e+00, 1.000e+00, 2.003e+03],
       [1.000e+00, 0.000e+00, 1.000e+00, 0.000e+00, 2.011e+03],
       [0.000e+00, 0.000e+00, 0.000e+00, 0.000e+00, 1.974e+03]])
>>> vec.get_feature_names() == ['category=animation', 'category=drama',
...                             'category=family', 'category=thriller',
...                             'year']
True
>>> vec.transform({'category': ['thriller'],
...                'unseen_feature': '3'}).toarray()
array([[0., 0., 0., 1., 0.]])

DictVectorizer también es una transformación de representación útil para el entrenamiento de clasificadores de secuencias en modelos de Procesamiento de Lenguaje Natural que normalmente trabajan extrayendo ventanas de características alrededor de una palabra de interés particular.

Por ejemplo, supongamos que tenemos un primer algoritmo que extrae las etiquetas de la categoría gramatical (Part of Speech, PoS, en inglés) que queremos usar como etiquetas complementarias para entrenar un clasificador de secuencias (por ejemplo, un chunker). El siguiente diccionario podría ser una ventana de características extraídas alrededor de la palabra «sat» en la frase “The cat sat on the mat.”:

>>> pos_window = [
...     {
...         'word-2': 'the',
...         'pos-2': 'DT',
...         'word-1': 'cat',
...         'pos-1': 'NN',
...         'word+1': 'on',
...         'pos+1': 'PP',
...     },
...     # in a real application one would extract many such dictionaries
... ]

Esta descripción puede ser vectorizada en una matriz bidimensional dispersa adecuada para alimentar a un clasificador (tal vez después de ser canalizada en un TfidfTransformer para su normalización):

>>> vec = DictVectorizer()
>>> pos_vectorized = vec.fit_transform(pos_window)
>>> pos_vectorized
<1x6 sparse matrix of type '<... 'numpy.float64'>'
    with 6 stored elements in Compressed Sparse ... format>
>>> pos_vectorized.toarray()
array([[1., 1., 1., 1., 1., 1.]])
>>> vec.get_feature_names()
['pos+1=PP', 'pos-1=NN', 'pos-2=DT', 'word+1=on', 'word-1=cat', 'word-2=the']

Como puedes imaginar, si se extrae un contexto de este tipo alrededor de cada palabra individual de un corpus de documentos, la matriz resultante será muy amplia (muchas características one-hot) y la mayoría de ellas con valor cero la mayor parte del tiempo. Para que la estructura de datos resultante pueda encajar en la memoria, la clase DictVectorizer utiliza por defecto una matriz scipy. parse en lugar de una numpy.ndarray.

6.2.2. Hashing de características

La clase FeatureHasher es un vectorizador de alta velocidad y baja memoria que utiliza una técnica conocida como hashing de características, o el «truco de hashing. En lugar de construir una tabla hash de las características encontradas en el entrenamiento, como hacen los vectorizadores, las instancias de FeatureHasher aplican una función hash a las características para determinar su índice de columna en las matrices de muestra directamente. El resultado es una mayor velocidad y un menor uso de la memoria, a expensas de la inspeccionabilidad; el hasher no recuerda el aspecto de las características de entrada y no tiene un método inverse_transform.

Dado que la función hash puede causar colisiones entre características (no relacionadas), se utiliza una función hash con signo y el signo del valor hash determina el signo del valor almacenado en la matriz de salida para una característica. De esta manera, es probable que las colisiones se cancelen en lugar de acumular errores, y que la media esperada del valor de cualquier característica de salida es cero. Este mecanismo está activado por defecto con alternate_sign=True y es particularmente útil para tamaños de tabla hash pequeños (n_features < 10000). Para tamaños de tabla hash grandes, se puede desactivar, para permitir que la salida se pase a estimadores como MultinomialNB o chi2 selectores de características que esperan entradas no negativas.

FeatureHasher acepta cualquier mapeo (como el dict de Python y sus variantes en el módulo collections), pares (feature, value), o cadenas, dependiendo del parámetro constructor input_type. El mapeo se trata como listas de pares (feature, value), mientras que las cadenas simples tienen un valor implícito de 1, por lo que ['feat1', 'feat2', 'feat3'] se interpreta como [('feat1', 1), ('feat2', 1), ('feat3', 1)]. Si una misma característica aparece varias veces en una muestra, los valores asociados serán sumados (así ('feat', 2) y ('feat', 3.5 ) se convierten en ('feat', 5.5)). La salida de FeatureHasher es siempre una matriz scipy.sparse en el formato CSR.

El hashing de características puede emplearse en la clasificación de documentos, pero a diferencia de CountVectorizer, FeatureHasher no hace la división de palabras ni ningún otro preprocesamiento excepto la codificación Unicode-to-UTF-8; ver Vectorizando un corpus de texto grande con el truco de hashing, más abajo, para un tokenizador/hasher combinado.

Como ejemplo, considera una tarea de procesamiento de lenguaje natural a nivel de palabras que necesite características extraídas de pares (token, part_of_speech ). Se podría utilizar una función generadora de Python para extraer características:

def token_features(token, part_of_speech):
    if token.isdigit():
        yield "numeric"
    else:
        yield "token={}".format(token.lower())
        yield "token,pos={},{}".format(token, part_of_speech)
    if token[0].isupper():
        yield "uppercase_initial"
    if token.isupper():
        yield "all_uppercase"
    yield "pos={}".format(part_of_speech)

Entonces, el raw_X para ser suministrado a FeatureHasher.transform puede ser construido usando:

raw_X = (token_features(tok, pos_tagger(tok)) for tok in corpus)

y suministrado a un hasher con:

hasher = FeatureHasher(input_type='string')
X = hasher.transform(raw_X)

para obtener una matriz scipy.sparse X.

Nota que el uso de una comprensión generadora, introduce la holgura en la extracción de características: los tokens sólo se procesan bajo demanda del hasher.

6.2.2.1. Detalles de implementación

FeatureHasher utiliza la variante de 32 bits con signo de MurmurHash3. Como resultado (y debido a las limitaciones en scipy. parse), el número máximo de características soportadas es actualmente \(2^{31} - 1\).

La formulación original del truco de hashing de Weinberger et al. usó dos funciones hash separadas \(h\) y \(\xi\) para determinar el índice de columna y el signo de una característica, respectivamente. La implementación actual funciona bajo el supuesto de que el bit de signo de MurmurHash3 es independiente de sus otros bits.

Dado que se utiliza un módulo simple para transformar la función hash en un índice de columnas, es recomendable utilizar una potencia de dos como el parámetro n_features; de lo contrario, las características no serán asignadas uniformemente a las columnas.

Referencias:

6.2.3. Extracción de característica de texto

6.2.3.1. La representación Bolsa de Palabras

El análisis de textos es uno de los principales campos de aplicación de los algoritmos de aprendizaje automático. Sin embargo, los datos en bruto, una secuencia de símbolos no puede ser suministrada directamente a los propios algoritmos, ya que la mayoría de ellos esperan vectores de características numéricas con un tamaño fijo en lugar de los documentos de texto sin formato con longitud variable.

Para abordar esto, scikit-learn proporciona utilidades para las formas más comunes de extraer características numéricas del contenido del texto, a saber:

  • tokenizar las cadenas y dar un identificador entero para cada token posible, por ejemplo, usando espacios en blanco y signos de puntuación como separadores de token.

  • conteo de las ocurrencias de tokens en cada documento.

  • normalizar y ponderar con importancia decreciente los tokens que aparecen en la mayoría de las muestras / documentos.

En este esquema, las características y muestras se definen de la siguiente manera:

  • cada ** frecuencia de ocurrencia de token individual ** (normalizada o no) se trata como una ** característica **.

  • el vector de todas las frecuencias de tokens para un documento dado se considera una muestra multivariante.

Por lo tanto, un corpus de documentos puede representarse mediante una matriz con una fila por documento y una columna por token (por ejemplo, palabra) que aparezca en el corpus.

Llamamos vectorización al proceso general de convertir una colección de documentos de texto en vectores de características numéricas. Esta estrategia específica (tokenización, conteo y normalización) se denomina representación Bolsa de palabras o «Bolsa de n-gramas». Los documentos se describen por las ocurrencias de las palabras, ignorando por completo la información sobre la posición relativa de las palabras en el documento.

6.2.3.2. Dispersión

Como la mayoría de los documentos suelen utilizar un subconjunto muy pequeño de las palabras utilizadas en el corpus, la matriz resultante tendrá muchos valores de características que son ceros (normalmente más del 99% de ellos).

Por ejemplo, una colección de 10.000 documentos de texto cortos (como correos electrónicos) utilizará un vocabulario con un tamaño del orden de 100.000 palabras únicas en total, mientras que cada documento utilizará entre 100 y 1000 palabras únicas individualmente.

Para poder almacenar una matriz de este tipo en la memoria, pero también para acelerar las operaciones algebraicas matriz / vector, las implementaciones suelen utilizar una representación dispersa, como las implementaciones disponibles en el paquete scipy.sparse.

6.2.3.3. Uso común del Vectorizador

CountVectorizer implementa tanto la tokenización como el conteo de ocurrencias en una sola clase:

>>> from sklearn.feature_extraction.text import CountVectorizer

Este modelo tiene muchos parámetros, sin embargo los valores por defecto son bastante razonables (por favor consulta la documentación de referencia para los detalles):

>>> vectorizer = CountVectorizer()
>>> vectorizer
CountVectorizer()

Vamos a utilizarlo para tokenizar y contar las ocurrencias de palabras de un corpus minimalista de documentos de texto:

>>> corpus = [
...     'This is the first document.',
...     'This is the second second document.',
...     'And the third one.',
...     'Is this the first document?',
... ]
>>> X = vectorizer.fit_transform(corpus)
>>> X
<4x9 sparse matrix of type '<... 'numpy.int64'>'
    with 19 stored elements in Compressed Sparse ... format>

La configuración por defecto tokeniza la cadena extrayendo palabras de al menos 2 letras. La función específica que realiza este paso puede solicitarse explícitamente:

>>> analyze = vectorizer.build_analyzer()
>>> analyze("This is a text document to analyze.") == (
...     ['this', 'is', 'text', 'document', 'to', 'analyze'])
True

A cada término encontrado por el analizador durante el ajuste se le asigna un índice entero único correspondiente a una columna en la matriz resultante. Esta interpretación de las columnas se puede recuperar de la siguiente manera:

>>> vectorizer.get_feature_names() == (
...     ['and', 'document', 'first', 'is', 'one',
...      'second', 'the', 'third', 'this'])
True

>>> X.toarray()
array([[0, 1, 1, 1, 0, 0, 1, 0, 1],
       [0, 1, 0, 1, 0, 2, 1, 0, 1],
       [1, 0, 0, 0, 1, 0, 1, 1, 0],
       [0, 1, 1, 1, 0, 0, 1, 0, 1]]...)

El mapeo de conversión del nombre de característica al índice de columna se almacena en el atributo vocabulary_ del vectorizador:

>>> vectorizer.vocabulary_.get('document')
1

Por lo tanto, las palabras que no se hayan visto en el corpus de entrenamiento se ignorarán por completo en futuras llamadas al método de transformación:

>>> vectorizer.transform(['Something completely new.']).toarray()
array([[0, 0, 0, 0, 0, 0, 0, 0, 0]]...)

Tenga en cuenta que en el corpus anterior, el primer y el último documento tienen exactamente las mismas palabras, por lo tanto, están codificados en vectores iguales. En particular, perdemos la información de que el último documento es una forma interrogativa. Para preservar parte de la información de pedido local, podemos extraer bigramas de palabras además de unigramas (palabras individuales):

>>> bigram_vectorizer = CountVectorizer(ngram_range=(1, 2),
...                                     token_pattern=r'\b\w+\b', min_df=1)
>>> analyze = bigram_vectorizer.build_analyzer()
>>> analyze('Bi-grams are cool!') == (
...     ['bi', 'grams', 'are', 'cool', 'bi grams', 'grams are', 'are cool'])
True

El vocabulario extraído por este vectorizador es, por lo tanto, mucho más grande y ahora puede resolver las ambigüedades codificadas en patrones de posicionamiento local:

>>> X_2 = bigram_vectorizer.fit_transform(corpus).toarray()
>>> X_2
array([[0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0],
       [0, 0, 1, 0, 0, 1, 1, 0, 0, 2, 1, 1, 1, 0, 1, 0, 0, 0, 1, 1, 0],
       [1, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 1, 1, 1, 0, 0, 0],
       [0, 0, 1, 1, 1, 1, 0, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 0, 1]]...)

En particular, la forma interrogativa «Is this» sólo está presente en el último documento:

>>> feature_index = bigram_vectorizer.vocabulary_.get('is this')
>>> X_2[:, feature_index]
array([0, 0, 0, 1]...)

6.2.3.3.1. Usando palabras funcionales (stop words)

Las palabras funcionales son palabras como «y», «el», «él», que se suponen poco informativas para representar el contenido de un texto, y que pueden eliminarse para evitar que sean interpretadas como señal para la predicción. Sin embargo, a veces palabras similares son útiles para la predicción, como en la clasificación del estilo de escritura o la personalidad.

Hay varios problemas conocidos en nuestra lista de palabras funcionales “inglés” proporcionada. No tiene como objetivo ser una solución general, de «talla única», ya que algunas tareas pueden requerir una solución más personalizada. Consulta [NQY18] para más detalles.

Por favor, ten cuidado al elegir una lista de palabras funcionales. Las listas populares de palabras funcionales pueden incluir palabras que son altamente informativas para algunas tareas, como computadora.

También asegúrate de que a la lista de palabras funcionales se le ha aplicado el mismo preprocesamiento y tokenización que el utilizado en el vectorizador. La palabra we’ve está dividida en we y ve por el tokenizador por defecto CountVectorizer, así que si we’ve está en stop_words, pero ve no lo está, ve será retenido de we’ve en el texto transformado. Nuestros vectorizadores tratarán de identificar y advertir sobre algunos tipos de inconsistencias.

Referencias

NQY18

J. Nothman, H. Qin and R. Yurchak (2018). «Stop Word Lists in Free Open-source Software Packages». In Proc. Workshop for NLP Open Source Software.

6.2.3.4. Ponderación de términos Tf-idf

En un corpus de texto extenso, algunas palabras estarán muy presentes (por ejemplo, «the», «a», «is» en inglés), por lo que contienen muy poca información significativa sobre el contenido real del documento. Si tuviéramos que suministrar los datos de recuento directo inmediatamente a un clasificador, esos términos muy frecuentes ensombrecerían las frecuencias de términos más raros pero más interesantes.

Para reponderar las características de conteo en valores de punto flotante adecuados para su uso por un clasificador, es muy común utilizar la transformación tf-idf.

Tf significa frecuencia de términos mientras que tf-idf significa frecuencia de términos por frecuencia inversa de documentos: \(\text{tf-idf(t,d)}=\text{tf(t,d)} \times \text{idf(t)}\).

Utilizando la configuración por defecto del TfidfTransformer, TfidfTransformer(norm='l2', use_idf=True, smooth_idf=True, sublinear_tf=False) la frecuencia de términos, el número de veces que un término aparece en un documento determinado, se multiplica por el componente idf, que se calcula como

\(\text{idf}(t) = \log{\frac{1 + n}{1+\text{df}(t)}} + 1\),

donde \(n\) es el número total de documentos en el conjunto de documentos, y \(\text{df}(t)\) es el número de documentos en el conjunto de documentos que contienen el término \(t\). Los vectores tf-idf resultantes son normalizados por la norma Euclideana:

\(v_{norm} = \frac{v}{||v||_2} = \frac{v}{\sqrt{v{_1}^2 + v{_2}^2 + \dots + v{_n}^2}}\).

Originalmente se trataba de un esquema de ponderación de términos desarrollado para la recuperación de la información (como una función de clasificación para los resultados de los motores de búsqueda) que también ha encontrado un buen uso en la clasificación y conglomeración de documentos.

Las siguientes secciones contienen más explicaciones y ejemplos que ilustran cómo se calculan exactamente los tf-idfs y cómo los tf-idfs calculados en scikit-learn TfidfTransformer y TfidfVectorizer difieren ligeramente de la notación estándar de los libros de texto que definen el idf como

\(\text{idf}(t) = \log{\frac{n}{1+\text{df}(t)}}.\)

En TfidfTransformer y el TfidfVectorizer con smooth_idf=False, el conteo «1» se añade al idf en lugar del denominador del idf:

\(\text{idf}(t) = \log{\frac{n}{\text{df}(t)}} + 1\)

Esta normalización es implementada por la clase TfidfTransformer:

>>> from sklearn.feature_extraction.text import TfidfTransformer
>>> transformer = TfidfTransformer(smooth_idf=False)
>>> transformer
TfidfTransformer(smooth_idf=False)

De nuevo, por favor consulta la documentación de referencia para los detalles de todos los parámetros.

Tomemos un ejemplo con los siguientes conteos. El primer término está presente el 100% de las veces, por lo tanto, no es muy interesante. Las otras dos características solo en menos del 50% de las ocasiones, por lo que probablemente sean más representativas del contenido de los documentos:

>>> counts = [[3, 0, 1],
...           [2, 0, 0],
...           [3, 0, 0],
...           [4, 0, 0],
...           [3, 2, 0],
...           [3, 0, 2]]
...
>>> tfidf = transformer.fit_transform(counts)
>>> tfidf
<6x3 sparse matrix of type '<... 'numpy.float64'>'
    with 9 stored elements in Compressed Sparse ... format>

>>> tfidf.toarray()
array([[0.81940995, 0.        , 0.57320793],
       [1.        , 0.        , 0.        ],
       [1.        , 0.        , 0.        ],
       [1.        , 0.        , 0.        ],
       [0.47330339, 0.88089948, 0.        ],
       [0.58149261, 0.        , 0.81355169]])

Cada fila está normalizada para tener una norma Euclideana unitaria:

\(v_{norm} = \frac{v}{||v||_2} = \frac{v}{\sqrt{v{_1}^2 + v{_2}^2 + \dots + v{_n}^2}}\)

Por ejemplo, podemos calcular el tf-idf del primer término del primer documento en el arreglo counts de la siguiente manera:

\(n = 6\)

\(\text{df}(t)_{\text{term1}} = 6\)

\(\text{idf}(t)_{\text{term1}} = \log \frac{n}{\text{df}(t)} + 1 = \log(1)+1 = 1\)

\(\text{tf-idf}_{\text{term1}} = \text{tf} \times \text{idf} = 3 \times 1 = 3\)

Ahora, si repetimos este cálculo para los 2 términos restantes en el documento, obtenemos

\(\text{tf-idf}_{\text{term2}} = 0 \times (\log(6/1)+1) = 0\)

\(\text{tf-idf}_{\text{term3}} = 1 \times (\log(6/2)+1) \approx 2.0986\)

y el vector de tf-idfs en bruto:

\(\text{tf-idf}_{\text{raw}} = [3, 0, 2.0986].\)

Luego, aplicando la norma Euclideana (L2) obtenemos los siguientes tf-idfs para el documento 1:

\(\frac{[3, 0, 2.0986]}{\sqrt{\big(3^2 + 0^2 + 2.0986^2\big)}} = [ 0.819, 0, 0.573].\)

Además, el parámetro por defecto smooth_idf=True añade «1» al numerador y al denominador como si se viera un documento extra que contiene cada término de la colección exactamente una vez, lo que evita las divisiones cero:

\(\text{idf}(t) = \log{\frac{1 + n}{1+\text{df}(t)}} + 1\)

Usando esta modificación, el tf-idf del tercer término del documento 1 cambia a 1.8473:

\(\text{tf-idf}_{\text{term3}} = 1 \times \log(7/3)+1 \approx 1.8473\)

Y el tf-idf L2-normalizado cambia a

\(\frac{[3, 0, 1.8473]}{\sqrt{\big(3^2 + 0^2 + 1.8473^2\big)}} = [0.8515, 0, 0.5243]\):

>>> transformer = TfidfTransformer()
>>> transformer.fit_transform(counts).toarray()
array([[0.85151335, 0.        , 0.52433293],
       [1.        , 0.        , 0.        ],
       [1.        , 0.        , 0.        ],
       [1.        , 0.        , 0.        ],
       [0.55422893, 0.83236428, 0.        ],
       [0.63035731, 0.        , 0.77630514]])

Los pesos de cada característica calculados por la llamada al método fit se almacenan en un atributo del modelo:

>>> transformer.idf_
array([1. ..., 2.25..., 1.84...])

Como tf-idf se utiliza muy a menudo para las características de texto, también hay otra clase llamada TfidfVectorizer que combina todas las opciones de CountVectorizer y TfidfTransformer en un solo modelo:

>>> from sklearn.feature_extraction.text import TfidfVectorizer
>>> vectorizer = TfidfVectorizer()
>>> vectorizer.fit_transform(corpus)
<4x9 sparse matrix of type '<... 'numpy.float64'>'
    with 19 stored elements in Compressed Sparse ... format>

Aunque la normalización tf-idf suele ser muy útil, puede haber casos en los que los marcadores de ocurrencia binarios podrían ofrecer mejores características. Esto se puede lograr usando el parámetro binary de CountVectorizer. En particular, algunos estimadores como Bayesiano ingenuo de Bernoulli modelan explícitamente variables aleatorias booleanas discretas. Además, es probable que los textos muy cortos tengan valores tf-idf ruidosos, mientras que la información de la ocurrencia binaria es más estable.

Como es habitual, la mejor manera de ajustar los parámetros de extracción de características es utilizar una búsqueda en cuadrícula con validación cruzada, por ejemplo, canalizando el extractor de características con un clasificador:

6.2.3.5. Decodificación de archivos de texto

El texto está hecho de caracteres, pero los archivos están hechos de bytes. Estos bytes representan caracteres de acuerdo a alguna codificación. Para trabajar con archivos de texto en Python, sus bytes deben ser decodificados a un conjunto de caracteres llamado Unicode. Las codificaciones comunes son ASCII, Latin-1 (Europa Occidental), KOI8-R (Ruso) y las codificaciones universales UTF-8 y UTF-16. Existen muchas otras.

Nota

Una codificación también puede llamarse “conjunto de caracteres”, pero este término es menos preciso: pueden existir varias codificaciones para un mismo conjunto de caracteres.

Los extractores de características de texto en scikit-learn saben cómo decodificar archivos de texto, pero sólo si se les dice en qué codificación están los archivos. El CountVectorizer toma un parámetro encoding para este propósito. Para archivos de texto modernos, la codificación correcta es probablemente UTF-8, que es por lo tanto el valor por defecto (encoding="utf-8").

Sin embargo, si el texto que estás cargando no está codificado con UTF-8, obtendrás un UnicodeDecodeError. Se puede indicar a los vectorizadores que guarden silencio sobre los errores de decodificación estableciendo el parámetro decode_error como "ignore" o "replace". Consulta la documentación de la función de Python bytes.decode para más detalles (escribe help(bytes.decode) en la consola de Python).

Si estás teniendo problemas para decodificar texto, aquí tienes algunas cosas para probar:

  • Averigua cuál es la codificación real del texto. El archivo puede venir con un encabezado o README que te diga la codificación, o puede haber alguna codificación estándar que puedas asumir basándote en la procedencia del texto.

  • Puedes averiguar qué tipo de codificación es en general usando el comando UNIX file. El módulo chardet de Python viene con un script llamado chardetect.py que adivinará la codificación específica, aunque no puedes confiar en que su conjetura sea correcta.

  • Puedes intentar con UTF-8 e ignorar los errores. Puedes decodificar cadenas de bytes con bytes.decode(errors='replace') para reemplazar todos los errores de decodificación con un carácter sin sentido, o establecer decode_error='replace' en el vectorizador. Esto puede dañar la utilidad de tus características.

  • El texto real puede provenir de una variedad de fuentes que pueden haber utilizado diferentes codificaciones, o incluso ser decodificado de forma descuidada en una codificación diferente a la que fue codificado. Esto es común en textos recuperados de la web. El paquete de Python ftfy puede ordenar automáticamente algunas clases de errores de decodificación, así que puedes intentar decodificar el texto desconocido como latin-1 y luego usar ftfy para corregir errores.

  • Si el texto se encuentra en una mezcla de codificaciones que es simplemente demasiado difícil de ordenar (como es el caso del conjunto de datos 20 Newsgroups), puede recurrir a una codificación simple de un solo byte como latin-1. Algún texto puede mostrarse incorrectamente, pero al menos la misma secuencia de bytes siempre representará la misma característica.

Por ejemplo, el siguiente fragmento de código utiliza chardet (no se incluye con scikit-learn, debe ser instalado por separado) para determinar la codificación de tres textos. Luego vectoriza los textos e imprime el vocabulario aprendido. La salida no se muestra aquí.

>>> import chardet    
>>> text1 = b"Sei mir gegr\xc3\xbc\xc3\x9ft mein Sauerkraut"
>>> text2 = b"holdselig sind deine Ger\xfcche"
>>> text3 = b"\xff\xfeA\x00u\x00f\x00 \x00F\x00l\x00\xfc\x00g\x00e\x00l\x00n\x00 \x00d\x00e\x00s\x00 \x00G\x00e\x00s\x00a\x00n\x00g\x00e\x00s\x00,\x00 \x00H\x00e\x00r\x00z\x00l\x00i\x00e\x00b\x00c\x00h\x00e\x00n\x00,\x00 \x00t\x00r\x00a\x00g\x00 \x00i\x00c\x00h\x00 \x00d\x00i\x00c\x00h\x00 \x00f\x00o\x00r\x00t\x00"
>>> decoded = [x.decode(chardet.detect(x)['encoding'])
...            for x in (text1, text2, text3)]        
>>> v = CountVectorizer().fit(decoded).vocabulary_    
>>> for term in v: print(v)                           

(Dependiendo de la versión de chardet, puede que se equivoque en la primera.)

Para una introducción a Unicode y las codificaciones de caracteres en general, consulta Joel Spolsky Absolute Minimum Every Software Developer Must Know About Unicode.

6.2.3.6. Aplicaciones y ejemplos

La representación bolsa de palabras es bastante simplista pero sorprendentemente útil en la práctica.

En particular en una situación de clasificación supervisada puede combinarse con éxito con modelos lineales rápidos y escalables para entrenar clasificadores de documentos, por ejemplo:

En una **situación de clasificación no supervisada* se puede utilizar para agrupar documentos similares aplicando algoritmos de análisis de conglomerados como K-medias:

Finalmente, es posible descubrir los temas principales de un corpus relajando la restricción de asignación estricta del análisis de conglomerados, por ejemplo, usando: Factorización matricial no negativa (NMF o NNMF):

6.2.3.7. Limitaciones de la representación Bolsa de palabras

Una colección de unigramas (lo que es la bolsa de palabras) no puede capturar frases y expresiones de varias palabras, ignorando efectivamente cualquier dependencia del orden de las palabras. Además, el modelo de bolsa de palabras no tiene en cuenta posibles errores ortográficos ni las derivaciones de las palabras.

¡N-gramas al rescate! En lugar de construir una simple colección de unigramas (n=1), se puede preferir una colección de bigramas (n=2), donde se cuentan las ocurrencias de pares de palabras consecutivas.

Se puede considerar alternativamente una colección de n-gramas de caracteres, una representación resistente a errores ortográficos y derivaciones.

Por ejemplo, digamos que estamos tratando con un corpus de dos documentos: ['words', 'wprds']. El segundo documento contiene una falta de ortografía de la palabra “words”. Una simple representación Bolsa de palabras consideraría estos dos como documentos muy distintos, que difieren en las dos características posibles. Una representación de bigramas de caracteres, sin embargo, encontraría los documentos que coincidan en 4 de las 8 características, lo que puede ayudar al clasificador preferido a decidir mejor:

>>> ngram_vectorizer = CountVectorizer(analyzer='char_wb', ngram_range=(2, 2))
>>> counts = ngram_vectorizer.fit_transform(['words', 'wprds'])
>>> ngram_vectorizer.get_feature_names() == (
...     [' w', 'ds', 'or', 'pr', 'rd', 's ', 'wo', 'wp'])
True
>>> counts.toarray().astype(int)
array([[1, 1, 1, 0, 1, 1, 1, 0],
       [1, 1, 0, 1, 1, 1, 0, 1]])

En el ejemplo anterior, se utiliza el analizador char_wb, que crea n-gramas sólo a partir de caracteres dentro de los límites de las palabras (rellenados con espacio a cada lado). El analizador char`, alternativamente, crea n-gramas que se extienden a través de palabras:

>>> ngram_vectorizer = CountVectorizer(analyzer='char_wb', ngram_range=(5, 5))
>>> ngram_vectorizer.fit_transform(['jumpy fox'])
<1x4 sparse matrix of type '<... 'numpy.int64'>'
   with 4 stored elements in Compressed Sparse ... format>
>>> ngram_vectorizer.get_feature_names() == (
...     [' fox ', ' jump', 'jumpy', 'umpy '])
True

>>> ngram_vectorizer = CountVectorizer(analyzer='char', ngram_range=(5, 5))
>>> ngram_vectorizer.fit_transform(['jumpy fox'])
<1x5 sparse matrix of type '<... 'numpy.int64'>'
    with 5 stored elements in Compressed Sparse ... format>
>>> ngram_vectorizer.get_feature_names() == (
...     ['jumpy', 'mpy f', 'py fo', 'umpy ', 'y fox'])
True

La variante char_wb que conoce los límites de las palabras es especialmente interesante para los idiomas que usan espacios en blanco para la separación de palabras, ya que en ese caso genera características mucho menos ruidosas que la variante char en bruto. Para estos idiomas, puede aumentar tanto la precisión predictiva como la velocidad de convergencia de los clasificadores entrenados con estas características, al tiempo que mantiene la robustez con respecto a errores ortográficos y derivaciones de palabras.

Mientras que la información de posicionamiento local se puede preservar extrayendo n-gramas en lugar de palabras individuales, la bolsa de palabras y la bolsa de n-gramas destruyen la mayor parte de la estructura interna del documento y, por tanto, la mayor parte del significado que conlleva esa estructura interna.

Con el fin de abordar la tarea más amplia de la comprensión del lenguaje natural, debe tenerse en cuenta la estructura local de frases y párrafos. Muchos de estos modelos serán, por lo tanto, considerados como problemas de «salida estructurada» que actualmente están fuera del alcance de scikit-learn.

6.2.3.8. Vectorizando un corpus de texto grande con el truco de hashing

El esquema de vectorización anterior es simple, pero el hecho de que contenga un mapeo de memoria de los tokens de cadena a los índices de características enteras (el atributo ``vocabulary_`) causa varios problemas al tratar con conjuntos de datos grandes:

  • cuanto más grande sea el corpus, mayor será el vocabulario y, por tanto, también el uso de memoria,

  • el ajuste requiere la asignación de estructuras de datos intermedias de tamaño proporcional al del conjunto de datos original.

  • la construcción del mapeo de palabras requiere un pase completo sobre el conjunto de datos, por lo que no es posible ajustar los clasificadores de texto de una manera estrictamente en línea.

  • pickling y un-pickling vectorizadores con un vocabulary_ extenso puede llegar a ser muy lento (típicamente mucho más lento que el pickling / un-pickling de estructuras de datos planas como un arreglo de NumPy del mismo tamaño),

  • no es posible dividir fácilmente el trabajo de vectorización en subtareas simultáneas, ya que el atributo vocabulary_ tendría que ser un estado compartido con una barrera de sincronización de granularidad fina: el mapeo de la cadena del token al índice de características depende del orden de la primera ocurrencia de cada token, por lo que tendría que ser compartido, perjudicando potencialmente el rendimiento simultáneo de los trabajadores hasta el punto de hacerlos más lentos que la variante secuencial.

Es posible superar esas limitaciones combinando el «truco de hashing» (Hashing de características) implementado por la clase FeatureHasher y el preprocesamiento de texto y tokenización de características de CountVectorizer.

Esta combinación está implementada en HashingVectorizer, una clase transformadora que es mayormente compatible con la API de CountVectorizer. HashingVectorizer no tiene estado, lo que significa que no hay que llamar a fit en ella:

>>> from sklearn.feature_extraction.text import HashingVectorizer
>>> hv = HashingVectorizer(n_features=10)
>>> hv.transform(corpus)
<4x10 sparse matrix of type '<... 'numpy.float64'>'
    with 16 stored elements in Compressed Sparse ... format>

Puedes ver que se extrajeron 16 tokens de características distintas de cero en el vector de salida: esto es menor que los 19 no ceros extraídos previamente por CountVectorizer en el mismo corpus de juego. La discrepancia proviene de las colisiones de la función hash debido al bajo valor del parámetro n_features.

En un entorno del mundo real, el parámetro n_features se puede dejar en su valor predeterminado de 2 ** 20 (aproximadamente un millón de características posibles). Si la memoria o el tamaño de los modelos posteriores es un problema, seleccionar un valor inferior como 2 ** 18 podría ayudar sin introducir demasiadas colisiones adicionales en las tareas típicas de clasificación de texto.

Ten en cuenta que la dimensionalidad no afecta al tiempo de entrenamiento de la CPU de algoritmos que operan con matrices CSR (LinearSVC(dual=True), Perceptron, SGDClassifier, PassiveAggressive), pero sí lo hace para algoritmos que trabajan con matrices CSC (LinearSVC(dual=False), Lasso(), etc).

Intentemos de nuevo con la configuración por defecto:

>>> hv = HashingVectorizer()
>>> hv.transform(corpus)
<4x1048576 sparse matrix of type '<... 'numpy.float64'>'
    with 19 stored elements in Compressed Sparse ... format>

Ya no tenemos las colisiones, pero esto se produce a costa de una dimensionalidad mucho mayor del espacio de salida. Por supuesto, otros términos distintos de los 19 utilizados aquí podrían seguir colisionando entre sí.

El HashingVectorizer también viene con las siguientes limitaciones:

  • no es posible invertir el modelo (no hay método inverse_transform), ni acceder a la representación original de cadenas de las características, debido a la naturaleza unidireccional de la función hash que realiza el mapeo.

  • no proporciona ponderación de IDF, ya que esto introduciría la condición de admitir diferentes estados en el modelo. Un TfidfTransformer puede ser anexado a él en un pipeline si es necesario.

6.2.3.9. Realizando escalado fuera del núcleo con HashingVectorizer

Un desarrollo interesante de usar un HashingVectorizer es la capacidad de realizar un escalado out-of-core. Esto significa que podemos aprender de datos que no encajan en la memoria principal de la computadora.

Una estrategia para implementar el escalado fuera del núcleo es enviar los datos al estimador en mini lotes. Cada mini lote es vectorizado usando HashingVectorizer para garantizar que el espacio de entrada del estimador tenga siempre la misma dimensionalidad. La cantidad de memoria utilizada en cualquier momento está así limitada por el tamaño de un mini lote. Aunque no hay límite para la cantidad de datos que se pueden ingerir utilizando este enfoque, desde un punto de vista práctico el tiempo de aprendizaje a menudo está limitado por el tiempo de CPU que se quiera dedicar a la tarea.

Para un ejemplo completo de escalado fuera del núcleo en una tarea de clasificación de texto, consulta Clasificación de documentos de texto fuera del núcleo.

6.2.3.10. Personalización de las clases del vectorizador

Es posible personalizar el comportamiento pasando una llamada al constructor del vectorizador:

>>> def my_tokenizer(s):
...     return s.split()
...
>>> vectorizer = CountVectorizer(tokenizer=my_tokenizer)
>>> vectorizer.build_analyzer()(u"Some... punctuation!") == (
...     ['some...', 'punctuation!'])
True

En particular, nombramos:

  • preprocessor: una llamada que toma un documento entero como entrada (como una sola cadena), y devuelve una versión posiblemente transformada del documento, todavía como una cadena completa. Esto se puede utilizar para eliminar etiquetas HTML, poner en minúsculas todo el documento, etc.

  • tokenizer: una llamada que toma la salida del preprocesador y la divide en tokens, luego devuelve una lista de estos.

  • analyzer: un invocable que reemplaza al preprocesador y al tokenizador. Todos los analizadores por defecto llaman al preprocesador y al tokenizador, pero los analizadores personalizados se saltarán esto. La extracción de N-gramas y el filtrado de palabras funcionales tienen lugar en el nivel del analizador, por lo que un analizador personalizado puede tener que reproducir estos pasos.

(Los usuarios de Lucene pueden reconocer estos nombres, pero ten en cuenta que los conceptos de scikit-learn pueden no coincidir uno a uno con los conceptos de Lucene.)

Para que el preprocesador, el tokenizador y los analizadores sean conscientes de los parámetros del modelo es posible derivar de la clase y anular los métodos de fábrica build_preprocessor, build_tokenizer y build_analyzer en lugar de pasar funciones personalizadas.

Algunos consejos y trucos:

  • Si los documentos están pre-tokenizados por un paquete externo, entonces almacénalos en archivos (o cadenas) con los tokens separados por espacios en blanco y pasa analyzer=str.split

  • El análisis sofisticado a nivel de tokens, como el stemming, la lematización, la división compuesta, el filtrado basado en etiquetas part-of-speech, etc., no se incluyen en la base de código de scikit-learn, pero puede añadirse personalizando el tokenizador o el analizador. Aquí hay un CountVectorizer con un tokenizador y lematizador usando NLTK:

    >>> from nltk import word_tokenize          
    >>> from nltk.stem import WordNetLemmatizer 
    >>> class LemmaTokenizer:
    ...     def __init__(self):
    ...         self.wnl = WordNetLemmatizer()
    ...     def __call__(self, doc):
    ...         return [self.wnl.lemmatize(t) for t in word_tokenize(doc)]
    ...
    >>> vect = CountVectorizer(tokenizer=LemmaTokenizer())  
    

    (Nota que esto no filtrará los signos de puntuación.)

    El siguiente ejemplo transformará, por ejemplo, algunas ortografías Británicas en ortografías Americanas:

    >>> import re
    >>> def to_british(tokens):
    ...     for t in tokens:
    ...         t = re.sub(r"(...)our$", r"\1or", t)
    ...         t = re.sub(r"([bt])re$", r"\1er", t)
    ...         t = re.sub(r"([iy])s(e$|ing|ation)", r"\1z\2", t)
    ...         t = re.sub(r"ogue$", "og", t)
    ...         yield t
    ...
    >>> class CustomVectorizer(CountVectorizer):
    ...     def build_tokenizer(self):
    ...         tokenize = super().build_tokenizer()
    ...         return lambda doc: list(to_british(tokenize(doc)))
    ...
    >>> print(CustomVectorizer().build_analyzer()(u"color colour"))
    [...'color', ...'color']
    

    para otros estilos de preprocesamiento; los ejemplos incluyen stemming, lematización o normalización de tokens numéricos, con estos últimos ilustrados en:

La personalización del vectorizador también puede ser útil cuando se manejan idiomas asiáticos que no utilizan un separador de palabras explícito, como los espacios en blanco.

6.2.4. Extracción de características de imagen

6.2.4.1. Extracción de parches

La función extract_patches_2d extrae fragmentos de una imagen almacenada como un arreglo bidimensional o tridimensional con información de color a lo largo del tercer eje. Para reconstruir una imagen a partir de todos sus fragmentos, utiliza reconstruct_from_patches_2d. Por ejemplo, generemos una imagen de 4x4 píxeles con 3 canales de color (por ejemplo, en formato RGB):

>>> import numpy as np
>>> from sklearn.feature_extraction import image

>>> one_image = np.arange(4 * 4 * 3).reshape((4, 4, 3))
>>> one_image[:, :, 0]  # R channel of a fake RGB picture
array([[ 0,  3,  6,  9],
       [12, 15, 18, 21],
       [24, 27, 30, 33],
       [36, 39, 42, 45]])

>>> patches = image.extract_patches_2d(one_image, (2, 2), max_patches=2,
...     random_state=0)
>>> patches.shape
(2, 2, 2, 3)
>>> patches[:, :, :, 0]
array([[[ 0,  3],
        [12, 15]],

       [[15, 18],
        [27, 30]]])
>>> patches = image.extract_patches_2d(one_image, (2, 2))
>>> patches.shape
(9, 2, 2, 3)
>>> patches[4, :, :, 0]
array([[15, 18],
       [27, 30]])

Intentemos ahora reconstruir la imagen original a partir de los fragmentos promediando en áreas superpuestas:

>>> reconstructed = image.reconstruct_from_patches_2d(patches, (4, 4, 3))
>>> np.testing.assert_array_equal(one_image, reconstructed)

La clase PatchExtractor funciona de la misma manera que extract_patches_2d, sólo que soporta múltiples imágenes como entrada. Se implementa como un estimador, así que puede ser utilizado en pipelines. Ver

>>> five_images = np.arange(5 * 4 * 4 * 3).reshape(5, 4, 4, 3)
>>> patches = image.PatchExtractor(patch_size=(2, 2)).transform(five_images)
>>> patches.shape
(45, 2, 2, 3)

6.2.4.2. Grafo de conectividad de una imagen

Varios estimadores de scikit-learn pueden utilizar la información de conectividad entre características o muestras. Por ejemplo, el agrupamiento de Ward (Análisis de conglomerados jerárquicos) puede agrupar sólo los píxeles vecinos de una imagen, formando así fragmentos contiguos:

../_images/sphx_glr_plot_coin_ward_segmentation_001.png

Para ello, los estimadores utilizan una matriz «conectividad», que indica las muestras que están conectadas.

La función img_to_graph devuelve una matriz de este tipo a partir de una imagen 2D o 3D. Del mismo modo, grid_to_graph construye una matriz de conectividad para imágenes dada la forma de estas.

Estas matrices pueden utilizarse para imponer conectividad en estimadores que utilizan información de conectividad, tales como el agrupamiento de Ward (Análisis de conglomerados jerárquicos), pero también para construir kernels precalculados, o matrices de similitud.