Preprocesamiento de texto para NLP (parte 1)
(=^・ω・^=) Limpieza, tokenización y más
- Dataset de ejemplo
- Expresiones regulares
- Limpieza
- Normalización
- Tokenización
- Otros pre-procesos
- Primera parte del pre-procesamiento
- Conjuntos de datos
- Vocabulario
- Numericalización
- Casos especiales
- Bonus: reducción del vocabulario
- El pre-procesamiento hasta ahora
- Pre-procesando las etiquetas
- Fuentes consultadas
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:
- Limpieza, la remoción del contenido no deseado.
- Normalización, la conversión diferentes formas a una sola.
- Tokenización, la separación del texto en tókenes (unidades mínimas, por ejemplo palabras).
- Separación en conjuntos de datos: entrenamiento, validación, prueba.
- Generación del vocabulario, la lista de tókenes conocidos.
- 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.
- Loteo, la generación de porciones de muestras de entrenamiento.
- Relleno, la conversión del lote en un tensor de PyTorch.
- 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))
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?')
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')
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')
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')
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
pero de una manera más expresiva y también más eficiente (está optimizado por el lenguaje)
[i for i in range(10)]
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.
import spacy
nlp = spacy.load('es_core_news_sm')
doc = nlp('Esto es una frase.')
print([tóken.text for tóken in doc])
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.
def preprocesar(texto):
texto = limpiar(texto)
texto = normalizar(texto)
texto = tokenizar(texto)
return texto
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]
a una lista de listas de strings
train_docs[:4]
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.
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)
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']))
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'] ] ))
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'] ] ))
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)
¿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'],
])
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)}
que es lo mismo que
mapeo = {}
for índice, tóken in enumerate(vocabulario):
mapeo[tóken] = índice
mapeo
¿Qué es lo que hace enumerate
? Como su nombre lo indica, enumera los elementos de una colección.
list(enumerate(vocabulario))
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'],
])
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'],
])
v.índices_a_tókenes([
[4160, 4683, 4484, 3703, 5294, 4011, 3825],
[3275, 3854, 3319, 3554, 1532, 1462, 2151, 3319, 950],
])
¿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]
v = Vocab().fit(train_docs)
v.transform([[], ['banks', 'charge', 'high', 'fees', 'for', 'foreign']])
v.tókenes_a_índices([[], ['banks', 'charge', 'high', 'fees', 'for', 'foreign']])
v.índices_a_tókenes([[], [5583, 5583, 5583, 5583, 5583, 5583]])
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()
Acerca de contar palabras, no te pierdas la ley de Zipf-Models:-Bag-of-Words).
límite = 2
vocabulario = list(c)[:límite]
vocabulario
máximo = 3
mínimo = 2
vocabulario = [tóken for tóken, frecuencia in c.most_common() if máximo >= frecuencia >= mínimo]
vocabulario
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()
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
import nltk
nltk.download('stopwords')
from nltk.corpus import stopwords
stopwords.words('spanish')[:10]
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'],
])
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)
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'],
])
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.
- CountVectorizer de scikit-learn.
- TextDataBunch de fast.ai.
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
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
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]
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]
Ahora estás en condiciones de seguir con la segunda parte.