Vamos a hacer un recorrido por los pasos básicos del pre-procesamiento de texto. Estos pasos son necesarios para transformar texto del lenguaje humano a un formato legible para máquinas para su posterior procesamiento, particularmente motiva esta publicación el procesamiento en PyTorch.

Veremos cómo realizar estos pasos con código propio, para mayor entendimiento de lo que está sucediendo, y con spaCy, una herramienta de nuestro agrado.

En concreto, los pasos son:

  1. Limpieza, la remoción del contenido no deseado.
  2. Normalización, la conversión diferentes formas a una sola.
  3. Tokenización, la separación del texto en tókenes (unidades mínimas, por ejemplo palabras).
  4. Separación en conjuntos de datos: entrenamiento, validación, prueba.
  5. Generación del vocabulario, la lista de tókenes conocidos.
  6. Numericalización, el mapeo de tókenes a números enteros.

Estos pasos son comunes distintas aproximaciones al procesamiento del lenguaje. En la parte 2 mostraremos pasos útiles para abarcarlo usando deep learning.

  1. Loteo, la generación de porciones de muestras de entrenamiento.
  2. Relleno, la conversión del lote en un tensor de PyTorch.
  3. Carga de embeddings, opcionalmente el uso de embeddings precalculados.

Nota: El órden de los primeros tres pasos (limpieza, normalización, tokenización) puede variar según conveniencia. El resto de los pasos mantiene el órden.

Dataset de ejemplo

¿Qué sería de esta publicación sin algunos ejemplos? Vamos a usar el dataset de la competencia clasificación de preguntas de clientes de Meta:Data.

import pandas as pd

df = pd.read_csv('train.csv', sep='|')
with pd.option_context('display.max_colwidth', -1):
    
    display(df.sample(10))
Pregunta Intencion
9221 hice una compra y no tengo las acciones acreditadas Cat_153
1298 quiero saber si yo puedo solicitar un préstamo Cat_248
4488 perdi la credencial universitaria. como solicito una nueva? Cat_294
28 cambiar moneda tarjeta debitar exterior Cat_289
19970 quiero adherir al debito de la tarjeta el servicio epec. me pide que ingrese numero cuenta de digitos pero en la factura no aparece un numero Cat_129
3510 llegar tarjeta recargable solicití¬≠ Cat_293
14373 buenas tardes Cat_19
5485 saber de cuanto es el pago mínimo de tarjeta visa Cat_351
6752 verificar reclamo Cat_135
10645 que sucede cuando se me vence el plazo fijo? Cat_180

Expresiones regulares

Si las expresiones regulares no te resultan familiares entonces vale la pena estudiarlas brevemente, ya que las usaremos. Podés mirar este tutorial que encontramos en la web.

import re

Limpieza

Muchas técnicas modernas no realizan limpieza alguna. Dependiendo de lo que queramos hacer tal vez convenga deshacernos de algunos elementos. En el dataset de ejemplo los signos de puntuación no parecen tener gran relevancia, quizás tampoco la tengan los números (que aparentemente han sido removidos de antemano).

def limpiar(texto):
    puntuación = r'[,;.:¡!¿?@#$%&[\](){}<>~=+\-*/|\\_^`"\']'
    
    # signos de puntuación
    texto = re.sub(puntuación, ' ', texto)
    
    # dígitos [0-9]
    texto = re.sub('\d', ' ', texto)

    return texto

En esta función substituimos los signos de puntuación

, ; . : ¡ ! ¿ ? @ # $ % & [ ] ( ) { } < > ~ = + - * / | \ _ ^ ` " '

por espacios (me gusta más; usar string vacío '' para eliminarlos) medieante expresiones regulares (algunos caracteres tuvieron que ser escapados anteponiendo \ por tener un significado especial para la expresión regular). Hacemos lo mismo con los dígitos. Veamos un ejemplo de funcionamiento.

limpiar('hoy 13 trabajan?')
'hoy    trabajan '

Otros elementos que podríamos pensar en remover son caracteres invisibles, espacios redundantes. Veremos que esto en particular también puede ser resulto en la tokenización.

Normalización

Normalizar es la tarea de llevar lo que puede ser expresado de múltiples maneras como fechas, números y abreviaturas a una única forma. Por ejemplo

 13/03/30 -> trece de marzo de dos mil treinta
 DC -> departamento de computación

Se trata de una práctica clásica de la época de los modelos de lenguaje probabilísticos, que intentaban reducir lo más posible la cantidad de palabras. En cierta forma 1 palabra = 1 atributo (lo que en los '90s conocimos como convertibilidad). Elegir atributos es ingeniería de atributos, la parte central del machine learning, y lo justamente lo que el deep learning busca automatizar.

Sin embargo hay una normalización muy común hoy, el convertir todo el texto a minúsculas. En el caso del español, una normalización común es la remoción de tildes.

def normalizar(texto):
    # todo a minúsculas
    texto = texto.lower()

    # tildes y diacríticas
    texto = re.sub('á', 'a', texto)
    texto = re.sub('é', 'e', texto)
    texto = re.sub('í', 'i', texto)
    texto = re.sub('ó', 'o', texto)
    texto = re.sub('ú', 'u', texto)
    texto = re.sub('ü', 'u', texto)
    texto = re.sub('ñ', 'n', texto)

    return texto
normalizar('Me podrán dar información de un préstamo personal')
'me podran dar informacion de un prestamo personal'

Hay una librería llamada unidecode que realiza transliteración: representa letras o palabras de un alfabeto en otro, útil si tenemos caracteres en ruso (cirílico) o chino (caracteres Han), aún útil para el alfabeto latino cuando queremos pasar de Unicode a ASCII (lo que substituiría las tildes).

pip install unidecode
from unidecode import unidecode

unidecode('Me podrán dar información de un préstamo personal')
'Me podran dar informacion de un prestamo personal'

Una normalización que vale la pena intentar con este dataset es la correción ortográfica con un paquete como pyspellchecker. Quizás con artículos de diarios en los que la redacción está más cuidada esto no valga la pena, pero en contextos más informales como este, conversaciones por char, Twitter, las palabras mal escritas en realidad refieren a una sola palabra y no a distintos significados.

Tokenización

Tokenizar es separar el texto en partes más pequeñas llamadas tókenes. Una unidad muy común es la palabras pero depende de lo que queramos hacer, si es que no hemos eliminado a los signos de puntuación estos también serían tókenes. Las palabras frecuentemente están compuestas por una raíz, prefijo y/o sufijo, por lo que podríamos decidir separarlos también. En inglés es común separar it's en it y 's, si bien en español esta situación no es común.

A diferencia de la limpieza y la normalización, la tokenización es un paso indispesable en la preparación de texto para su procesamiento.

Para el dataset en cuestión la tokenización es simple, vamos a separar seǵun espacios y demás caracteres invisibles como \t (tabulación) y \n (salto de línea). De haber signos de puntuación, pro ejemplo si quisiéramos procesar un documento extenso en oraciones, el proceso es más complejo ya que final. tiene un punto en vez de un espacio, y no siempre los puntos demarcan el final de un tóken como en A.M. y P.M..

Debemos definir si elementos como los signos de puntuación son tókenes o si simplemente delimitan palabras o tókenes, en cuyo caso desaparecerían en el proceso. Mismo con los caracteres invisibles, si estuviésemos haciendo un modelo que programe en Python, la indentación es fundamental y deberiera mantenerse.

def tokenizar(texto):
    # IMPORTANTE: podría devolver una lista vacía
    return [tóken for tóken in texto.split()]

split también se encarga de los caracteres invisibles repetidos.

tokenizar('hola vengo       a flotar')
['hola', 'vengo', 'a', 'flotar']

Acá estamos cambiando el tipo de datos, ya que de un string hemos pasado a una lista de strings.

Si la expresión dentro de la función no te resulta familiar, es una construcción llamada list comprehension y es una manera muy efectiva de armar una lista. Es lo mismo que hacer

lista = []

for i in range(10):
    lista.append(i)
    
lista
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

pero de una manera más expresiva y también más eficiente (está optimizado por el lenguaje)

[i for i in range(10)]
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

Varios modelos de lenguaje utilizan caracteres en vez de palabras como tókenes, esto es útil por varios motivos que listaremos más adelante. Otros utilizan partes de palabras como sílabas (las partes se determinan estadísticamente). Ver https://arxiv.org/pdf/1508.07909.pdf.

Tokenización utilizando alguna librería

pip install spacy
python -m spacy download es_core_news_sm
import spacy

nlp = spacy.load('es_core_news_sm')

doc = nlp('Esto es una frase.')

print([tóken.text for tóken in doc])
['Esto', 'es', 'una', 'frase', '.']

Otros pre-procesos

Clásicamente se aplicaban alguno de estos para reducir aún más la cantidad de palabras:

Stemming

Stem, de raíz, reduce la inflección de las palabras, mapeando un grupo de palabras a la misma raíz, sin importar si la raíz es una palabras válida en el lenguaje.

 caminando, caminar, camino -> camin

Lemmatization

A diferencia del stemming, la lematización reduce las palabras inflexadas a palabras que pertenecen al lenguaje. La raíz pasa a llamarse lema.

Primera parte del pre-procesamiento

def preprocesar(texto):
    texto = limpiar(texto)
    texto = normalizar(texto)
    texto = tokenizar(texto)

    return texto

Conjuntos de datos

En la competencias normalmente encontramos dos archivos, el de entrenamiento y el de inferencia —que le suelen llamar de prueba y es el que tenemos que predecir para entregar—. Del que suelen llamar train también tenemos que obtener el de validación.

infer_df = pd.read_csv('test.csv', sep=',')
from sklearn.model_selection import train_test_split

train_df, valid_df = train_test_split(df, test_size=.1, random_state=42)

Ahora estamos en condiciones de pre-procesar todo lo que tenemos:

train_docs = [preprocesar(doc) for doc in train_df['Pregunta'].values]
valid_docs = [preprocesar(doc) for doc in valid_df['Pregunta'].values]
infer_docs = [preprocesar(doc) for doc in infer_df['Pregunta'].values]

Hemos pasado de una Series de Pandas, array de NumPy o una lista de strings

train_df['Pregunta'].values[:4]
array(['que se requiere para un préstamo personal?',
       'me piden mi número de cuenta es mi cbu?',
       'necesitar adherir aysa tarjeta',
       'te financian igual un usado o un 0km?'], dtype=object)

a una lista de listas de strings

train_docs[:4]
[['que', 'se', 'requiere', 'para', 'un', 'prestamo', 'personal'],
 ['me', 'piden', 'mi', 'numero', 'de', 'cuenta', 'es', 'mi', 'cbu'],
 ['necesitar', 'adherir', 'aysa', 'tarjeta'],
 ['te', 'financian', 'igual', 'un', 'usado', 'o', 'un', 'km']]

Un poco de nomenclatura: estamos llamando corpus a la colección de textos. Nos referimos también a los textos como documentos. También estamos usando el término lote (batch) para referirnos a un (sub)conjunto de documentos.

Vocabulario

Este paso es importante. Aquí definimos y limitamos la tókenes que vamos a utilizar. El lenguaje es infinito, para convertirlo en un problema tratable muchas veces los que hacemos es reducirlo. Clave para varias prácticas de reducción es contar las frecuencias de los tókenes, esto es, cuántas veces aparece cada tóken en todo el corpus. Como mencionamos las palabras más frecuentes no aportan mucha información y las más infrecuentes si bien son las que más información tienen no llegarán a ser representativas para nuestro modelo. Descartar palabras poco frecuentes también afecta a errores ortográficos.

Útil para este paso es la clase Counter de la librería estándar de Python.

from collections import Counter

c = Counter(['a','b','c','a','b','a'])

# obtener los dos elementos más comunes y sus frecuencias
c.most_common(2)
[('a', 3), ('b', 2)]

Una función de la librería estándar llamada chain nos dará una mano convirtiendo la lista de listas de tókenes en una lista de tókenes, similar a numpy.flatten, ya que Counter espera una lista con elementos a contar y nuestros tókenes están separados por documentos, hay que juntarlos.

from itertools import chain

list(chain(['a','b','c'], ['c','d']))
['a', 'b', 'c', 'c', 'd']

chain encadena las listas que le pasamos como argumentos variables. Podemos usar el operador splat * para contentar a la función (convertir la lista principal en una serie de argumentos).

list(chain( *[ ['a','b','c'], ['c','d'] ] ))
['a', 'b', 'c', 'c', 'd']

En vez de una lista podemos pedir un conjunto (set), en el que los elementos no se repiten. Este bien podría ser el vocabulario.

set(chain( *[ ['a','b','c'], ['c','d'] ] ))
{'a', 'b', 'c', 'd'}

En definitiva, es la lista oficial de tókenes.

class Vocab():
    def fit(self, lote):
        self.vocabulario = set(chain(*lote))
        
        return self
    
    def __len__(self):
        return len(self.vocabulario)

Es importante generar el vocabulario con el dataset de entrenamiento, ya que como mencionamos se trata de la lista de palabras conocidas. Le agregamos un __len__ porque también es útil conocer el tamaño del vocabulario.

v = Vocab().fit(train_docs)
len(v)
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
<ipython-input-3-04b70b8e4c3d> in <module>
----> 1 v = Vocab().fit(train_docs)
      2 len(v)

NameError: name 'train_docs' is not defined

¿Qué pasa con las palabras que no están en la lista? Se las conoce como tókenen fuera del vocabulario (out-of-vocabulary, abreviado OOV). Estas requieren acciones especiales, podríamos

  • ignorarlas
  • reemplazarlas por un tóken especial
  • inferirlas (ver más adelante, embeddings)
class Vocab():
    def __init__(self, tóken_desconocido='<unk>'):
        self.tóken_desconocido = tóken_desconocido
        
    def fit(self, lote):
        self.vocabulario = list(set(chain(*lote)))
        
        if self.tóken_desconocido:
            self.vocabulario.append(self.tóken_desconocido)

        return self
    
    def transform(self, lote):
        if self.tóken_desconocido: # reemplazar
            return [[tóken if tóken in self.vocabulario else self.tóken_desconocido for tóken in doc] for doc in lote]
        else: # ignorar
            return [[tóken for tóken in doc if tóken in self.vocabulario] for doc in lote]
    
    def __len__(self):
        return len(self.vocabulario)
Vocab().fit(train_docs).transform([
    ['poder', 'gestionar', 'clave', 'paso', 'pagina'],
    ['desde', 'cuando', 'arranco', 'con', 'el', 'programa', 'de', 'millas'],
])
[['poder', 'gestionar', 'clave', 'paso', 'pagina'],
 ['desde', 'cuando', '<unk>', 'con', 'el', 'programa', 'de', 'millas']]

Numericalización

También conocido como indexación. Así como a las unidades mínimas que consideramos las llamamos tókenes, a los números que los representan los llamamos índices. Ya que el vocabulario tiene la lista de tókenes, le vamos a pedir una responsabilidad adicional: que mantenga una asignación entre tókenes y números enteros. Posiblemente ya te ha sucedido pasarle valores no númericos a un estimador y ver cómo falla.

vocabulario = ['a','b','c','d','<unk>']

{tóken: índice for índice, tóken in enumerate(vocabulario)}
{'a': 0, 'b': 1, 'c': 2, 'd': 3, '<unk>': 4}

que es lo mismo que

mapeo = {}

for índice, tóken in enumerate(vocabulario):
    mapeo[tóken] = índice

mapeo
{'a': 0, 'b': 1, 'c': 2, 'd': 3, '<unk>': 4}

¿Qué es lo que hace enumerate? Como su nombre lo indica, enumera los elementos de una colección.

list(enumerate(vocabulario))
[(0, 'a'), (1, 'b'), (2, 'c'), (3, 'd'), (4, '<unk>')]
class Vocab():
    def __init__(self, tóken_desconocido='<unk>'):
        self.tóken_desconocido = tóken_desconocido
        
    def fit(self, lote):
        vocabulario = list(set(chain(*lote)))
        
        if self.tóken_desconocido:
            vocabulario.append(self.tóken_desconocido)
        
        self.mapeo = {tóken: índice for índice, tóken in enumerate(vocabulario)}

        return self
    
    def transform(self, lote):
        if self.tóken_desconocido: # reemplazar
            return [[tóken if tóken in self.mapeo else self.tóken_desconocido for tóken in doc] for doc in lote]
        else: # ignorar
            return [[tóken for tóken in doc if tóken in self.mapeo] for doc in lote]
    
    def __len__(self):
        return len(self.mapeo)

Comprobemos que la nueva versión de Vocab funciona como la anterior. Además veamos qué sucedo cuando no queremos el tóken para palabras fuera de vocabulario.

Vocab(tóken_desconocido=None).fit(train_docs).transform([
    ['poder', 'gestionar', 'clave', 'paso', 'pagina'],
    ['desde', 'cuando', 'arranco', 'con', 'el', 'programa', 'de', 'millas'],
])
[['poder', 'gestionar', 'clave', 'paso', 'pagina'],
 ['desde', 'cuando', 'con', 'el', 'programa', 'de', 'millas']]

Ahora vamos a agregar métodos para convertir tókenes a índices y viceversa.

class Vocab():
    def __init__(self, tóken_desconocido='<unk>'):
        self.tóken_desconocido = tóken_desconocido
        
    def fit(self, lote):
        # agregamos `sorted` porque el orden al aplicar `set` no está asegurado
        vocabulario = list(sorted(set(chain(*lote))))
        
        if self.tóken_desconocido:
            vocabulario.append(self.tóken_desconocido)
        
        self.mapeo = {tóken: índice for índice, tóken in enumerate(vocabulario)}

        return self
    
    def transform(self, lote):
        if self.tóken_desconocido: # reemplazar
            return [[tóken if tóken in self.mapeo else self.tóken_desconocido for tóken in doc] for doc in lote]
        else: # ignorar
            return [[tóken for tóken in doc if tóken in self.mapeo] for doc in lote]
    
    def tókenes_a_índices(self, lote):
        lote = self.transform(lote)
        
        return [[self.mapeo[tóken] for tóken in doc] for doc in lote]
    
    def índices_a_tókenes(self, lote):
        mapeo_inverso = list(self.mapeo.keys())
        
        return [[mapeo_inverso[índice] for índice in doc] for doc in lote]
    
    def __len__(self):
        return len(self.mapeo)
v = Vocab(tóken_desconocido=None).fit(train_docs)

v.tókenes_a_índices([
    ['que', 'se', 'requiere', 'para', 'un', 'prestamo', 'personal'],
    ['me', 'piden', 'mi', 'numero', 'de', 'cuenta', 'es', 'mi', 'cbu'],
])
[[4160, 4683, 4484, 3703, 5294, 4011, 3825],
 [3275, 3854, 3319, 3554, 1532, 1462, 2151, 3319, 950]]
v.índices_a_tókenes([
    [4160, 4683, 4484, 3703, 5294, 4011, 3825],
    [3275, 3854, 3319, 3554, 1532, 1462, 2151, 3319, 950],
])
[['que', 'se', 'requiere', 'para', 'un', 'prestamo', 'personal'],
 ['me', 'piden', 'mi', 'numero', 'de', 'cuenta', 'es', 'mi', 'cbu']]

Casos especiales

¿Qué sucede con los documentos que al ser tokenizados regresan vacíos? ¿O con documentos compuestos enteramente por palabras fuera del vocabulario?

documentos_problemáticos = [
    '??? ???',
    'Banks charge high fees for foreign ATM'
]

[preprocesar(doc) for doc in documentos_problemáticos]
[[], ['banks', 'charge', 'high', 'fees', 'for', 'foreign', 'atm']]
v = Vocab().fit(train_docs)

v.transform([[], ['banks', 'charge', 'high', 'fees', 'for', 'foreign']])
[[], ['<unk>', '<unk>', '<unk>', '<unk>', '<unk>', '<unk>']]
v.tókenes_a_índices([[], ['banks', 'charge', 'high', 'fees', 'for', 'foreign']])
[[], [5583, 5583, 5583, 5583, 5583, 5583]]
v.índices_a_tókenes([[], [5583, 5583, 5583, 5583, 5583, 5583]])
[[], ['<unk>', '<unk>', '<unk>', '<unk>', '<unk>', '<unk>']]

La conclusión es que no pasa nada (al menos por ahora).

Bonus: reducción del vocabulario

La idea es limitar los tókenes que vamos a utilizar. En cierta forma cada tóken es un atributo (feature) y quisiéramos proveer atributos que sean de utilidad para el estimador.

El lenguaje es infinito, para convertirlo en un problema tratable muchas veces los que hacemos es reducirlo. Clave para varias prácticas de reducción es contar las frecuencias de los tókenes, esto es, cuántas veces aparece cada tóken en todo el corpus. Como mencionamos las palabras más frecuentes no aportan mucha información y las más infrecuentes si bien son las que más información tienen no llegarán a ser representativas para nuestro modelo. Descartar palabras poco frecuentes también afecta a errores ortográficos.

Útil para este paso es la clase Counter de la librería estándar de Python.

from collections import Counter

c = Counter(['a','b','c','a','b','a'])

# obtener los elementos ordenados de más comunes a menos
c.most_common()
[('a', 3), ('b', 2), ('c', 1)]

Acerca de contar palabras, no te pierdas la ley de Zipf-Models:-Bag-of-Words).

Más comunes

Una estrategia simple es ordenar a los tókenes según frecuencia y poner un límite duro al vocabulario, de modo de quedarnos con los límite más comunes.

límite = 2

vocabulario = list(c)[:límite]
vocabulario
['a', 'b']

Por frecuencia de tóken

Podríamos descartar los que aparecen

  • más de máximo veces,
  • menos de mínimo veces.
máximo = 3
mínimo = 2

vocabulario = [tóken for tóken, frecuencia in c.most_common() if máximo >= frecuencia >= mínimo]
vocabulario
['a', 'b']

Por frecuencia de documento

O bien, en vez de contar las apariciones absolutas, contar en cuántos documentos aparece cada tóken. Un tóken que aparezca en todos los documentos no colaboraría en una tarea de clasificación, a distinguir documentos pero uno que aparezca en la mitad de los documentos podría ser útil para separarlos en dos grupos.

c = Counter()

lote = [
    ['hola', 'buen', 'día'],
    ['hola', 'buenas', 'tardes'],
]

for doc in lote:
    c.update(set(doc))

c.most_common()
[('hola', 2), ('buen', 1), ('día', 1), ('tardes', 1), ('buenas', 1)]

Vamos a normalizar la frecuencias por la cantidad total de documentos ($D$) y de manera similar al punto anterior podríamos descartar los elementos que aparecen en:

  • más del máximo proporción de los documentos.
  • menos del mínimo proporción de los documentos.
D = len(lote)

máximo = .9
mínimo = .1

vocabulario = [tóken for tóken, frecuencia in c.most_common() if máximo >= frecuencia/D >= mínimo]
vocabulario
['buen', 'día', 'tardes', 'buenas']

Stop words

Hay listas armadas de palabras muy comunes (stop words). Podemos elaborarla de alguna manera o usar alguna existente.

pip install nltk
import nltk
nltk.download('stopwords')
    
from nltk.corpus import stopwords

stopwords.words('spanish')[:10]
[nltk_data] Downloading package stopwords to /home/matias/nltk_data...
[nltk_data]   Package stopwords is already up-to-date!
['de', 'la', 'que', 'el', 'en', 'y', 'a', 'los', 'del', 'se']

Un detalle a cuidar es que la tokenización usada para la lista de stop words tiene que haber sido la misma o similar que la usada para los documentos.

def filtrar_stop_words(lote):
    return [[tóken for tóken in doc if tóken not in stopwords.words('spanish')] for doc in lote]

filtrar_stop_words([
    ['que', 'se', 'requiere', 'para', 'un', 'prestamo', 'personal'],
    ['me', 'piden', 'mi', 'numero', 'de', 'cuenta', 'es', 'mi', 'cbu'],
])
[['requiere', 'prestamo', 'personal'], ['piden', 'numero', 'cuenta', 'cbu']]

Por longitud

Esta técnica no requiere contar la frecuencia de los tókenes, simplemente filtramos tókenes muy cortos o muy largos ya que en general son ruidos.

def filtrar_por_longitud(lote, máxima, mínima):
    return [[tóken for tóken in doc if máxima >= len(tóken) >= mínima] for doc in lote]

filtrar_por_longitud([
    ['que', 'se', 'requiere', 'para', 'un', 'prestamo', 'personal'],
    ['me', 'piden', 'mi', 'numero', 'de', 'cuenta', 'es', 'mi', 'cbu'],
], máxima=9, mínima=3)
[['que', 'requiere', 'para', 'prestamo', 'personal'],
 ['piden', 'numero', 'cuenta', 'cbu']]

Implementación

Veamos cómo acomodamos lo que hemos visto ahora en la clase Vocab.

import numpy as np
from itertools import chain
from collections import Counter

class Vocab():
    def __init__(self, tóken_desconocido='<unk>', frecuencia_mínima=0.0, frecuencia_máxima=1.0,
                 longitud_mínima=1, longitud_máxima=np.inf, stop_words=[], límite_vocabulario=None):
        
        self.tóken_desconocido = tóken_desconocido
        self.frecuencia_mínima = frecuencia_mínima
        self.frecuencia_máxima = frecuencia_máxima
        self.longitud_mínima = longitud_mínima
        self.longitud_máxima = longitud_máxima
        self.stop_words = stop_words
        self.límite_vocabulario = límite_vocabulario
    
    def reducir_vocabulario(self, lote):
        contador_absoluto = Counter(chain(*lote))
        
        contador_documentos = Counter()
        
        for doc in lote:
            contador_documentos.update(set(doc))
        
        # frecuencia mínima
        if isinstance(self.frecuencia_mínima, int): # frecuencia de tóken
            vocabulario_mín = [tóken for tóken, frecuencia in contador_absoluto.most_common() if frecuencia >= self.frecuencia_mínima]
        else: # frecuencia de documento
            vocabulario_mín = [tóken for tóken, frecuencia in contador_documentos.most_common() if frecuencia/len(lote) >= self.frecuencia_mínima]
        
        # frecuencia máxima
        if isinstance(self.frecuencia_máxima, int): # frecuencia de tóken
            vocabulario_máx = [tóken for tóken, frecuencia in contador_absoluto.most_common() if self.frecuencia_máxima >= frecuencia]
        else: # frecuencia de documento
            vocabulario_máx = [tóken for tóken, frecuencia in contador_documentos.most_common() if self.frecuencia_máxima >= frecuencia/len(lote)]

        # intersección de vocabulario_mín y vocabulario_máx preservando el órden
        vocabulario = [tóken for tóken in vocabulario_mín if tóken in vocabulario_máx]

        # longitud
        vocabulario = [tóken for tóken in vocabulario if self.longitud_máxima >= len(tóken) >= self.longitud_mínima]
        
        # stop words
        vocabulario = [tóken for tóken in vocabulario if tóken not in self.stop_words]
        
        # límite
        vocabulario = vocabulario[:self.límite_vocabulario]
        
        return vocabulario
        
    def fit(self, lote):
        vocabulario = self.reducir_vocabulario(lote)
        
        if self.tóken_desconocido:
            vocabulario.append(self.tóken_desconocido)
        
        self.mapeo = {tóken: índice for índice, tóken in enumerate(vocabulario)}

        return self
    
    def transform(self, lote):
        if self.tóken_desconocido: # reemplazar
            return [[tóken if tóken in self.mapeo else self.tóken_desconocido for tóken in doc] for doc in lote]
        else: # ignorar
            return [[tóken for tóken in doc if tóken in self.mapeo] for doc in lote]
    
    def tókenes_a_índices(self, lote):
        lote = self.transform(lote)
        
        return [[self.mapeo[tóken] for tóken in doc] for doc in lote]
    
    def índices_a_tókenes(self, lote):
        mapeo_inverso = list(self.mapeo.keys())
        
        return [[mapeo_inverso[índice] for índice in doc] for doc in lote]

    def __len__(self):
        return len(self.mapeo)
Vocab(longitud_mínima=3).fit(train_docs).transform([
    ['poder', 'gestionar', 'clave', 'paso', 'pagina'],
    ['desde', 'cuando', 'arranco', 'con', 'el', 'programa', 'de', 'millas'],
])
[['poder', 'gestionar', 'clave', 'paso', 'pagina'],
 ['desde', 'cuando', '<unk>', 'con', '<unk>', 'programa', '<unk>', 'millas']]

El pre-procesamiento hasta ahora

v = Vocab().fit(train_docs)

train_índices = v.tókenes_a_índices(train_docs)
valid_índices = v.tókenes_a_índices(valid_docs)
infer_índices = v.tókenes_a_índices(infer_docs)

Con esto concluye la primera parte. Hay varias librerías que tienen clases que se encargan de efectuar los pasos que hemos visto. Tienen un comportamiento por defecto, que es configurable (los parámetros que hemos visto) y a su vez, personalizable, para reemplazar algunos o todos los pasos por código propio. En general son librerías desarrolladas por angloparlantes, funcionan out-of-the-box bien para el inglés; cuando queremos procesar texto en español vale la pena tener más control sobre estos procesos.

Pre-procesando las etiquetas

Las etiquetas del dataset también necesitan ser convertidas a números enteros consecutivos. No lo pensamos para este fin pero Vocab sería útil en este aspecto. El único tema es que Vocab.fit y demás métodos esperan listas de listas de tókenes y a las etiquetas las encontramos en forma de listas de tókenes simplemente.

train_df['Intencion'].values
array(['Cat_248', 'Cat_42', 'Cat_132', ..., 'Cat_293', 'Cat_138',
       'Cat_219'], dtype=object)

Podemos llevar la columna de las etiquetas a una lista de listas con train_df['Intencion'].values.reshape(-1,1), de manera de poder interfacearlo con Vocab. Algo como train_df[['Intencion']].values para que Pandas devuelva un DataFrame en vez de una Series también funcionaría.

train_etiquetas = train_df[['Intencion']].values
valid_etiquetas = valid_df[['Intencion']].values
train_etiquetas
array([['Cat_248'],
       ['Cat_42'],
       ['Cat_132'],
       ...,
       ['Cat_293'],
       ['Cat_138'],
       ['Cat_219']], dtype=object)

Todo lo que tenga que ver con limitación del vocabulario o agregado de tókenes especiales no nos interesa para este caso de uso.

vocabulario_etiquetas = Vocab(tóken_desconocido=None).fit(train_etiquetas)

train_etiquetas = vocabulario_etiquetas.tókenes_a_índices(train_etiquetas)
valid_etiquetas = vocabulario_etiquetas.tókenes_a_índices(valid_etiquetas)

train_etiquetas[:10]
[[6], [128], [0], [104], [6], [17], [8], [202], [306], [166]]

Ya casi estamos. Solo debemos reconvertir a las etiquetas en una lista de índices (su dimensión original) con un recurso que ya conocemos.

train_etiquetas = list(chain(*train_etiquetas))
valid_etiquetas = list(chain(*valid_etiquetas))

train_etiquetas[:10]
[6, 128, 0, 104, 6, 17, 8, 202, 306, 166]

Ahora estás en condiciones de seguir con la segunda parte.