Preparación de un conjunto de datos para el entrenamiento
Continuando con el ejemplo del capítulo anterior, así es como entrenaríamos un clasificador de secuencias en un batch:
from torch.optim import AdamW
from transformers import AutoTokenizer, AutoModelForSequenceClassification
# Same as before
checkpoint = "bert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
model = AutoModelForSequenceClassification.from_pretrained(checkpoint)
sequences = [
"I've been waiting for a HuggingFace course my whole life.",
"This course is amazing!",
]
batch = tokenizer(sequences, padding=True, truncation=True, return_tensors="pt")
# This is new
batch["labels"] = torch.tensor([1, 1])
optimizer = AdamW(model.parameters())
loss = model(**batch).loss
loss.backward()
optimizer.step()
Por supuesto, entrenar el modelo con solo dos oraciones no dará muy buenos resultados. Para obtener mejores resultados, necesitarás preparar un conjunto de datos más grande.
En esta sección usaremos como ejemplo el conjunto de datos MRPC (Microsoft Research Paraphrase Corpus), introducido en un artículo de William B. Dolan y Chris Brockett. El conjunto de datos consta de 5,801 pares de oraciones, con una etiqueta que indica si son paráfrasis o no (es decir, si ambas oraciones significan lo mismo). Lo hemos seleccionado para este capítulo porque es un conjunto de datos pequeño, por lo que es fácil experimentar con el entrenamiento.
Cargar un conjunto de datos desde el Hub[[loading-a-dataset-from-the-hub]]
El Hub no solo contiene modelos; también tiene múltiples conjuntos de datos en muchos idiomas diferentes. Puedes explorar los conjuntos de datos aquí, y te recomendamos que intentes cargar y procesar un nuevo conjunto de datos una vez que hayas revisado esta sección (consulta la documentación general aquí). ¡Pero por ahora, centrémonos en el conjunto de datos MRPC! Este es uno de los 10 conjuntos de datos que componen el benchmark GLUE, que es un benchmark académico utilizado para medir el rendimiento de los modelos de ML en 10 tareas diferentes de clasificación de texto.
La biblioteca 🤗 Datasets proporciona un comando muy simple para descargar y almacenar en caché un conjunto de datos en el Hub. Podemos descargar el conjunto de datos MRPC así:
[!TIP] 💡 Recursos adicionales: Para más técnicas y ejemplos de carga de conjuntos de datos, consulta la documentación de 🤗 Datasets.
from datasets import load_dataset
raw_datasets = load_dataset("glue", "mrpc")
raw_datasets
DatasetDict({
train: Dataset({
features: ['sentence1', 'sentence2', 'label', 'idx'],
num_rows: 3668
})
validation: Dataset({
features: ['sentence1', 'sentence2', 'label', 'idx'],
num_rows: 408
})
test: Dataset({
features: ['sentence1', 'sentence2', 'label', 'idx'],
num_rows: 1725
})
})
Como puedes ver, obtenemos un objeto DatasetDict que contiene el conjunto de entrenamiento, el conjunto de validación y el conjunto de prueba. Cada uno de ellos contiene varias columnas (sentence1, sentence2, label y idx) y un número variable de filas, que son el número de elementos en cada conjunto (así, hay 3,668 pares de oraciones en el conjunto de entrenamiento, 408 en el conjunto de validación y 1,725 en el conjunto de prueba).
[!TIP] Este comando descarga y almacena en caché el conjunto de datos, por defecto en ~/.cache/huggingface/datasets. Recuerda del Capítulo 2 que puedes personalizar tu carpeta de caché configurando la variable de entorno
HF_HOME.
Podemos acceder a cada par de oraciones en nuestro objeto raw_datasets indexando, como con un diccionario:
raw_train_dataset = raw_datasets["train"]
raw_train_dataset[0]
{'idx': 0,
'label': 1,
'sentence1': 'Amrozi accused his brother , whom he called " the witness " , of deliberately distorting his evidence .',
'sentence2': 'Referring to him as only " the witness " , Amrozi accused his brother of deliberately distorting his evidence .'}
Podemos ver que las etiquetas ya son números enteros, por lo que no tendremos que hacer ningún preprocesamiento allí. Para saber qué número entero corresponde a qué etiqueta, podemos inspeccionar el features de nuestro raw_train_dataset. Esto nos dirá el tipo de cada columna:
raw_train_dataset.features
{'sentence1': Value(dtype='string', id=None),
'sentence2': Value(dtype='string', id=None),
'label': ClassLabel(num_classes=2, names=['not_equivalent', 'equivalent'], names_file=None, id=None),
'idx': Value(dtype='int32', id=None)}
Detrás de escena, label es de tipo ClassLabel, y el mapeo de enteros a nombres de etiquetas se almacena en la carpeta names. 0 corresponde a not_equivalent, y 1 corresponde a equivalent.
[!TIP] ✏️ ¡Pruébalo! Mira el elemento 15 del conjunto de entrenamiento y el elemento 87 del conjunto de validación. ¿Cuáles son sus etiquetas?
Preprocesamiento de un conjunto de datos[[preprocessing-a-dataset]]
Para preprocesar el conjunto de datos, necesitamos convertir el texto a números que el modelo pueda entender. Como viste en el capítulo anterior, esto se hace con un tokenizer. Podemos alimentar al tokenizer una oración o una lista de oraciones, por lo que podemos tokenizar directamente todas las primeras oraciones y todas las segundas oraciones de cada par así:
from transformers import AutoTokenizer
checkpoint = "bert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
tokenized_sentences_1 = tokenizer(raw_datasets["train"]["sentence1"])
tokenized_sentences_2 = tokenizer(raw_datasets["train"]["sentence2"])
[!TIP] 💡 Profundización: Para técnicas de tokenización más avanzadas y para entender cómo funcionan los diferentes tokenizers, explora la documentación de 🤗 Tokenizers y la guía de tokenización en el cookbook.
Sin embargo, no podemos simplemente pasar dos secuencias al modelo y obtener una predicción de si las dos oraciones son paráfrasis o no. Necesitamos manejar las dos secuencias como un par y aplicar el preprocesamiento adecuado. Afortunadamente, el tokenizer también puede tomar un par de secuencias y prepararlas de la manera que nuestro modelo BERT espera:
inputs = tokenizer("This is the first sentence.", "This is the second one.")
inputs
{
'input_ids': [101, 2023, 2003, 1996, 2034, 6251, 1012, 102, 2023, 2003, 1996, 2117, 2028, 1012, 102],
'token_type_ids': [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1],
'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
}
Discutimos las claves input_ids y attention_mask en el Capítulo 2, pero pospusimos hablar sobre token_type_ids. En este ejemplo, esto es lo que le dice al modelo qué parte de la entrada es la primera oración y cuál es la segunda oración.
[!TIP] ✏️ ¡Pruébalo! Toma el elemento 15 del conjunto de entrenamiento y tokeniza las dos oraciones por separado y como un par. ¿Cuál es la diferencia entre los dos resultados?
Si decodificamos los IDs dentro de input_ids de nuevo a palabras:
tokenizer.convert_ids_to_tokens(inputs["input_ids"])
obtendremos:
['[CLS]', 'this', 'is', 'the', 'first', 'sentence', '.', '[SEP]', 'this', 'is', 'the', 'second', 'one', '.', '[SEP]']
Así que vemos que el modelo espera que las entradas sean de la forma [CLS] sentence1 [SEP] sentence2 [SEP] cuando hay dos oraciones. Alineando esto con el token_type_ids obtenemos:
['[CLS]', 'this', 'is', 'the', 'first', 'sentence', '.', '[SEP]', 'this', 'is', 'the', 'second', 'one', '.', '[SEP]']
[ 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1]
Como puedes ver, las partes de la entrada correspondientes a [CLS] sentence1 [SEP] tienen un ID de tipo de token de 0, mientras que las otras partes, correspondientes a sentence2 [SEP], tienen un ID de tipo de token de 1.
Ten en cuenta que si seleccionas un checkpoint diferente, no necesariamente tendrás el token_type_ids en tus entradas tokenizadas (por ejemplo, no se devuelven si usas un modelo DistilBERT). Solo se devuelven cuando el modelo sabrá qué hacer con ellos, porque los ha visto durante su preentrenamiento.
Aquí, BERT está preentrenado con IDs de tipo de token, y además del objetivo de masked language modeling del que hablamos en el Capítulo 1, tiene un objetivo adicional llamado next sentence prediction. El objetivo de esta tarea es modelar la relación entre pares de oraciones.
Con la predicción de la siguiente oración, al modelo se le proporcionan pares de oraciones (con tokens enmascarados aleatoriamente) y se le pide que prediga si la segunda oración sigue a la primera. Para que la tarea no sea trivial, la mitad de las veces las oraciones se siguen entre sí en el documento original del que fueron extraídas, y la otra mitad de las veces las dos oraciones provienen de dos documentos diferentes.
En general, no necesitas preocuparte por si hay token_type_ids en tus entradas tokenizadas: siempre que uses el mismo checkpoint para el tokenizer y el modelo, todo estará bien, ya que el tokenizer sabe qué proporcionar a su modelo.
Ahora que hemos visto cómo nuestro tokenizer puede manejar un par de oraciones, podemos usarlo para tokenizar todo nuestro conjunto de datos: como en el capítulo anterior, podemos alimentar al tokenizer una lista de pares de oraciones dándole la lista de las primeras oraciones, luego la lista de las segundas oraciones. Esto también es compatible con las opciones de padding y truncamiento que vimos en el Capítulo 2. Así, una forma de preprocesar el conjunto de datos de entrenamiento es:
tokenized_dataset = tokenizer(
raw_datasets["train"]["sentence1"],
raw_datasets["train"]["sentence2"],
padding=True,
truncation=True,
)
Esto funciona bien, pero tiene la desventaja de devolver un diccionario (con nuestras claves, input_ids, attention_mask y token_type_ids, y valores que son listas de listas). También solo funcionará si tienes suficiente RAM para almacenar todo tu conjunto de datos durante la tokenización (mientras que los conjuntos de datos de la biblioteca 🤗 Datasets son archivos Apache Arrow almacenados en el disco, por lo que solo mantienes cargadas en memoria las muestras que solicitas).
Para mantener los datos como un conjunto de datos, usaremos el método Dataset.map(). Esto también nos permite una flexibilidad adicional, si necesitamos más preprocesamiento que solo la tokenización. El método map() funciona aplicando una función a cada elemento del conjunto de datos, así que definamos una función que tokenice nuestras entradas:
def tokenize_function(example):
return tokenizer(example["sentence1"], example["sentence2"], truncation=True)
Esta función toma un diccionario (como los elementos de nuestro conjunto de datos) y devuelve un nuevo diccionario con las claves input_ids, attention_mask y token_type_ids. Ten en cuenta que también funciona si el diccionario example contiene varias muestras (cada clave como una lista de oraciones) ya que el tokenizer funciona con listas de pares de oraciones, como se vio antes. Esto nos permitirá usar la opción batched=True en nuestra llamada a map(), lo que acelerará enormemente la tokenización. El tokenizer está respaldado por un tokenizer escrito en Rust de la biblioteca 🤗 Tokenizers. Este tokenizer puede ser muy rápido, pero solo si le damos muchas entradas a la vez.
Ten en cuenta que hemos omitido el argumento padding en nuestra función de tokenización por ahora. Esto se debe a que rellenar todas las muestras a la longitud máxima no es eficiente: es mejor rellenar las muestras cuando estamos construyendo un batch, ya que entonces solo necesitamos rellenar a la longitud máxima en ese batch, y no a la longitud máxima en todo el conjunto de datos. ¡Esto puede ahorrar mucho tiempo y poder de procesamiento cuando las entradas tienen longitudes muy variables!
[!TIP] 📚 Consejos de rendimiento: Aprende más sobre técnicas eficientes de procesamiento de datos en la guía de rendimiento de 🤗 Datasets.
Así es como aplicamos la función de tokenización a todos nuestros conjuntos de datos a la vez. Estamos usando batched=True en nuestra llamada a map para que la función se aplique a múltiples elementos de nuestro conjunto de datos a la vez, y no a cada elemento por separado. Esto permite un preprocesamiento más rápido.
tokenized_datasets = raw_datasets.map(tokenize_function, batched=True)
tokenized_datasets
La forma en que la biblioteca 🤗 Datasets aplica este procesamiento es agregando nuevos campos a los conjuntos de datos, uno por cada clave en el diccionario devuelto por la función de preprocesamiento:
DatasetDict({
train: Dataset({
features: ['attention_mask', 'idx', 'input_ids', 'label', 'sentence1', 'sentence2', 'token_type_ids'],
num_rows: 3668
})
validation: Dataset({
features: ['attention_mask', 'idx', 'input_ids', 'label', 'sentence1', 'sentence2', 'token_type_ids'],
num_rows: 408
})
test: Dataset({
features: ['attention_mask', 'idx', 'input_ids', 'label', 'sentence1', 'sentence2', 'token_type_ids'],
num_rows: 1725
})
})
Incluso puedes usar el multiprocesamiento al aplicar tu función de preprocesamiento con map() pasando un argumento num_proc. No lo hicimos aquí porque la biblioteca 🤗 Tokenizers ya usa múltiples hilos para tokenizar nuestras muestras más rápido, pero si no estás usando un tokenizer rápido respaldado por esta biblioteca, esto podría acelerar tu preprocesamiento.
Nuestro tokenize_function devuelve un diccionario con las claves input_ids, attention_mask y token_type_ids, por lo que esos tres campos se agregan a todas las divisiones de nuestro conjunto de datos. Ten en cuenta que también podríamos haber cambiado los campos existentes si nuestra función de preprocesamiento devolviera un nuevo valor para una clave existente en el conjunto de datos al que aplicamos map().
Lo último que tendremos que hacer es rellenar todos los ejemplos a la longitud del elemento más largo cuando agrupemos los elementos, una técnica a la que nos referimos como dynamic padding.
Dynamic padding[[dynamic-padding]]
La función encargada de agrupar las muestras dentro de un batch se llama collate function. Es un argumento que puedes pasar cuando construyes un DataLoader, siendo el valor predeterminado una función que simplemente convertirá tus muestras en tensores de PyTorch y los concatenará (recursivamente si tus elementos son listas, tuplas o diccionarios). Esto no será posible en nuestro caso ya que las entradas que tenemos no serán todas del mismo tamaño. Hemos pospuesto deliberadamente el padding, para aplicarlo solo cuando sea necesario en cada batch y evitar tener entradas demasiado largas con mucho padding. Esto acelerará bastante el entrenamiento, pero ten en cuenta que si estás entrenando en una TPU puede causar problemas, ya que las TPU prefieren formas fijas, incluso cuando eso requiere padding adicional.
[!TIP] 🚀 Guía de optimización: Para más detalles sobre la optimización del rendimiento del entrenamiento, incluyendo estrategias de padding y consideraciones de TPU, consulta la documentación de rendimiento de 🤗 Transformers.
Para hacer esto en la práctica, tenemos que definir una función de collate que aplicará la cantidad correcta de padding a los elementos del conjunto de datos que queremos agrupar. Afortunadamente, la biblioteca 🤗 Transformers nos proporciona dicha función a través de DataCollatorWithPadding. Toma un tokenizer cuando lo instancias (para saber qué token de padding usar y si el modelo espera que el padding esté a la izquierda o a la derecha de las entradas) y hará todo lo que necesites:
from transformers import DataCollatorWithPadding
data_collator = DataCollatorWithPadding(tokenizer=tokenizer)
Para probar este nuevo juguete, tomemos algunas muestras de nuestro conjunto de entrenamiento que nos gustaría agrupar. Aquí, eliminamos las columnas idx, sentence1 y sentence2 ya que no serán necesarias y contienen cadenas (y no podemos crear tensores con cadenas) y echamos un vistazo a las longitudes de cada entrada en el batch:
samples = tokenized_datasets["train"][:8]
samples = {k: v for k, v in samples.items() if k not in ["idx", "sentence1", "sentence2"]}
[len(x) for x in samples["input_ids"]]
[50, 59, 47, 67, 59, 50, 62, 32]
No es de extrañar, obtenemos muestras de longitud variable, de 32 a 67. El padding dinámico significa que las muestras en este batch deben rellenarse a una longitud de 67, la longitud máxima dentro del batch. Sin padding dinámico, todas las muestras tendrían que rellenarse a la longitud máxima en todo el conjunto de datos, o a la longitud máxima que el modelo puede aceptar. Verifiquemos que nuestro data_collator está rellenando dinámicamente el batch correctamente:
batch = data_collator(samples)
{k: v.shape for k, v in batch.items()}
{'attention_mask': torch.Size([8, 67]),
'input_ids': torch.Size([8, 67]),
'token_type_ids': torch.Size([8, 67]),
'labels': torch.Size([8])}
¡Se ve bien! Ahora que hemos pasado del texto sin procesar a batches con los que nuestro modelo puede trabajar, ¡estamos listos para hacer fine-tuning!
[!TIP] ✏️ ¡Pruébalo! Replica el preprocesamiento en el conjunto de datos GLUE SST-2. Es un poco diferente ya que se compone de oraciones individuales en lugar de pares, pero el resto de lo que hicimos debería ser igual. Para un desafío más difícil, intenta escribir una función de preprocesamiento que funcione en cualquiera de las tareas GLUE.
📖 Práctica adicional: Consulta estos ejemplos prácticos de los ejemplos de 🤗 Transformers.
¡Perfecto! Ahora que hemos preprocesado nuestros datos con las últimas mejores prácticas de la biblioteca 🤗 Datasets, estamos listos para pasar a entrenar nuestro modelo usando la moderna API de Trainer. La siguiente sección te mostrará cómo hacer fine-tuning de tu modelo de manera efectiva usando las últimas características y optimizaciones disponibles en el ecosistema de Hugging Face.
Cuestionario de la sección[[section-quiz]]
Pon a prueba tu comprensión de los conceptos de procesamiento de datos:
1. ¿Cuál es la principal ventaja de usar Dataset.map() con batched=True?
2. ¿Por qué usamos el padding dinámico en lugar de rellenar todas las secuencias a la longitud máxima del conjunto de datos?
3. ¿Qué representa el campo token_type_ids en la tokenización de BERT?
4. Al cargar un conjunto de datos con load_dataset('glue', 'mrpc'), ¿qué especifica el segundo argumento?
5. ¿Cuál es el propósito de eliminar columnas como 'sentence1' y 'sentence2' antes del entrenamiento?
[!TIP] 💡 Puntos clave:
- Usa
batched=TrueconDataset.map()para un preprocesamiento significativamente más rápido- El padding dinámico con
DataCollatorWithPaddinges más eficiente que el padding de longitud fija- Siempre preprocesa tus datos para que coincidan con lo que tu modelo espera (tensores numéricos, nombres de columna correctos)
- La biblioteca 🤗 Datasets proporciona herramientas potentes para un procesamiento eficiente de datos a escala