LECCIÓN 6 · BLOQUE 2

Dataset y preparación

Antes de entrenar necesitas datos propios bien preparados. Esta lección enseña a crear un dataset desde cero, dividirlo correctamente, empaquetarlo para el modelo y evitar las trampas más comunes.

Recuerda de la lección anterior: en la Lección 5 hicimos fine-tuning completo de DistilBERT sobre el dataset IMDB — ese dataset ya venía preparado y listo. Hoy aprendes a construir tu propio dataset desde un CSV, un JSON o una lista Python; a dividirlo en tres partes (entrenamiento, validación, prueba); y a usar el DataCollator, que es el mecanismo que "empaqueta" los ejemplos en lotes antes de dárselos al modelo.

1. Qué es un Dataset de HuggingFace

En el curso de PyTorch usaste Dataset y DataLoader de PyTorch. HuggingFace tiene su propia clase Dataset (del paquete datasets) que hace lo mismo pero con superpoderes: es mucho más eficiente con texto, sabe cargar datos de muchos formatos (CSV, JSON, Parquet, Arrow) sin cargar todo en RAM a la vez, y se integra perfectamente con el Trainer.

Analogía concreta: imagina que tienes 50.000 reseñas de clientes en un archivo CSV. El Dataset de HuggingFace es como una biblioteca que indexa esas reseñas: sabes exactamente dónde está cada una, puedes pedir la número 3.742 directamente, puedes filtrarlas, transformarlas — todo sin cargar las 50.000 en memoria a la vez.

Hay dos maneras de obtener un Dataset:

Crear un Dataset desde una lista Python

La forma más rápida para prototipar: tienes listas Python con los textos y etiquetas, las metes en un diccionario, y listo.

from datasets import Dataset
import pandas as pd

# Ejemplo: reseñas de un restaurante con etiqueta de sentimiento
# 0 = negativo, 1 = positivo
datos = {
    "texto": [
        "La comida estuvo deliciosa, el servicio excelente.",
        "Esperé 45 minutos y la comida llegó fría.",
        "Precios razonables y buena atención.",
        "El mesero fue grosero y el baño estaba sucio.",
        "Me encantó el ambiente, regresaré pronto.",
        "Pésima experiencia, no lo recomiendo a nadie.",
    ],
    "etiqueta": [1, 0, 1, 0, 1, 0]
}

# Crear el Dataset directamente del diccionario
ds = Dataset.from_dict(datos)
print(ds)
print("\nPrimer ejemplo:")
print(ds[0])
salida realDataset({ features: ['texto', 'etiqueta'], num_rows: 6 }) Primer ejemplo: {'texto': 'La comida estuvo deliciosa, el servicio excelente.', 'etiqueta': 1}

Crear un Dataset desde un CSV

Lo más común en proyectos reales: tienes un archivo mis_datos.csv con columnas texto y etiqueta. Dos opciones:

# OPCIÓN A: desde la librería datasets directamente
ds_csv = Dataset.from_csv("mis_datos.csv")

# OPCIÓN B: cargar con pandas primero y luego convertir
# (útil si necesitas limpiar o filtrar antes)
df = pd.read_csv("mis_datos.csv")
df = df.dropna()          # eliminar filas con valores nulos
df = df[df["texto"].str.len() > 10]  # eliminar textos muy cortos
ds_desde_df = Dataset.from_pandas(df)

print(ds_desde_df)
salida realDataset({ features: ['texto', 'etiqueta'], num_rows: 847 })

Crear un Dataset desde un JSON

# Para un archivo JSONL (un JSON por línea — formato muy común en NLP)
# Ejemplo de línea en el JSON: {"texto": "...", "etiqueta": 1}
ds_json = Dataset.from_json("mis_datos.jsonl")

# Para un JSON normal con una lista de objetos
import json
with open("datos.json") as f:
    lista = json.load(f)   # lista de dicts: [{"texto":..., "etiqueta":...}, ...]
ds_json2 = Dataset.from_list(lista)
print(ds_json2)
salida realDataset({ features: ['texto', 'etiqueta'], num_rows: 1200 })

2. Splits: train, validation y test — por qué tres partes

Cuando tienes un dataset, no puedes usar todos los datos para entrenar. Necesitas guardar una parte para saber si el modelo está aprendiendo de verdad o solo memorizando. Y otra parte para la evaluación final honesta. Eso es lo que hace dividir (en inglés, split) el dataset en tres partes.

LAS TRES PARTES DE UN DATASET
División de dataset en train, validation y test Una barra horizontal dividida en tres segmentos: 80% train en verde, 10% validation en azul y 10% test en naranja. Debajo, la explicación del uso de cada parte. TRAIN — 80% para ajustar los pesos VAL 10% TEST 10% El modelo ve estos datos durante entrenamiento El modelo aprende de ellos — puede verlos muchas veces Validación Mides durante entrenamiento Test Evaluación final honesta

La proporción más común: 80% entrenar, 10% validar, 10% probar. En datasets pequeños puede ser 70/15/15.

Expliquemos para qué sirve cada parte con una analogía de examen:

⚠️ El error más común: usar el conjunto de test para decidir cuándo parar de entrenar o para elegir hiperparámetros. Si lo haces, el test ya no es honesto — has "mirado el examen" y lo has optimizado. Para eso está la validación. El test se toca solo al final.

Cómo hacer los splits en HuggingFace

from datasets import Dataset

# Suponemos que tenemos un dataset grande ya cargado
# Primer split: separar test del resto (10% para test)
ds_train_val, ds_test = ds.train_test_split(
    test_size=0.1,   # 10% para test
    seed=42          # semilla para reproducibilidad (siempre el mismo split)
).values()

# Segundo split: separar validation del conjunto train_val
# Ahora el "test_size" es 10% de lo que queda (≈11.1% del total, ajustado a 10/90)
splits = ds_train_val.train_test_split(test_size=0.111, seed=42)
ds_train = splits["train"]
ds_val   = splits["test"]    # la función lo llama "test" pero es nuestro validation

print(f"Train:      {len(ds_train):>6} ejemplos ({len(ds_train)/len(ds)*100:.1f}%)")
print(f"Validation: {len(ds_val):>6} ejemplos ({len(ds_val)/len(ds)*100:.1f}%)")
print(f"Test:       {len(ds_test):>6} ejemplos ({len(ds_test)/len(ds)*100:.1f}%)")
salida realTrain: 720 ejemplos (80.0%) Validation: 90 ejemplos (10.0%) Test: 90 ejemplos (10.0%)

También puedes empaquetar los tres en un DatasetDict — un diccionario especial que guarda los tres splits juntos, igual que el IMDB que descargaste en la Lección 5:

from datasets import DatasetDict

dataset_completo = DatasetDict({
    "train":      ds_train,
    "validation": ds_val,
    "test":       ds_test,
})
print(dataset_completo)
salida realDatasetDict({ train: Dataset({features: ['texto', 'etiqueta'], num_rows: 720}) validation: Dataset({features: ['texto', 'etiqueta'], num_rows: 90}) test: Dataset({features: ['texto', 'etiqueta'], num_rows: 90}) })

3. El DataCollator: el "empaquetador" de lotes

Aquí viene un concepto que muchos tutoriales saltan sin explicar. Cuando el Trainer entrena el modelo, no le da ejemplos de uno en uno — los agrupa en lotes (batches) de, digamos, 16 ejemplos a la vez. Recuerda del curso de PyTorch que esto es más eficiente porque la GPU puede procesar muchos en paralelo.

El problema es que los textos tienen longitudes distintas. "La comida estuvo rica" tiene 4 tokens. "Me sentí muy decepcionado con la calidad del servicio, especialmente considerando el precio" tiene 18 tokens. Si quieres meterlos en el mismo lote (que debe ser un tensor rectangular — todos del mismo tamaño), necesitas rellenar los cortos con un token especial de relleno llamado [PAD] (o su ID numérico, típicamente 0).

El DataCollator es exactamente eso: una función que toma una lista de ejemplos individuales y los convierte en un lote rectangular rellenado, listo para entrar al modelo.

LO QUE HACE EL DATACOLLATOR
El DataCollator convierte ejemplos de longitud variable en un tensor rectangular rellenado A la izquierda, tres secuencias de tokens de distinta longitud. En el centro, el DataCollator. A la derecha, un tensor rectangular con padding añadido a los ejemplos más cortos. EJEMPLOS (longitud variable) 101 243 892 17 102 1→ 101 5517 102 2→ 101 731 22 449 8 513 102 3→ Data Collator añade [PAD] TENSOR RECTANGULAR (3×7) 101 243 892 17 102 101 5517 102 PAD PAD 101 731 22 449 8… = token [PAD] — el modelo lo ignora gracias al attention_mask

El DataCollator añade [PAD] hasta que todos los ejemplos del lote tengan la misma longitud — la del más largo del lote.

DataCollatorWithPadding — el más común

Para tareas de clasificación con modelos tipo BERT, el DataCollator correcto es DataCollatorWithPadding. Solo necesita el tokenizer para saber qué ID usar como [PAD] y cuál es la longitud máxima permitida.

from transformers import AutoTokenizer, DataCollatorWithPadding

tokenizer = AutoTokenizer.from_pretrained("distilbert-base-uncased")

# Tokenizar el dataset (sin padding fijo — lo hará el collator)
def tokenize(batch):
    return tokenizer(
        batch["texto"],
        truncation=True,
        max_length=128,
        # NO ponemos padding aquí — lo dejamos al collator
    )

ds_tokenizado = dataset_completo.map(tokenize, batched=True)

# Crear el DataCollator — solo necesita el tokenizer
collator = DataCollatorWithPadding(tokenizer=tokenizer)

print("DataCollator listo")

# Demostración: simular lo que hace con 3 ejemplos distintos
ejemplos = [ds_tokenizado["train"][i] for i in [0, 1, 2]]
longitudes_antes = [len(e["input_ids"]) for e in ejemplos]
print(f"Longitudes antes del collator: {longitudes_antes}")

lote = collator(ejemplos)
print(f"Forma del lote (input_ids): {lote['input_ids'].shape}")
print(f"Forma del lote (attention_mask): {lote['attention_mask'].shape}")
salida realDataCollator listo Longitudes antes del collator: [12, 7, 18] Forma del lote (input_ids): torch.Size([3, 18]) Forma del lote (attention_mask): torch.Size([3, 18])

Los tres ejemplos tenían 12, 7 y 18 tokens. El collator rellenó los dos primeros con tokens [PAD] hasta 18 (la longitud del más largo del lote). El tensor resultante es 3×18 — rectangular, listo para la GPU. El attention_mask es 1 donde hay texto real y 0 donde hay [PAD], para que el modelo ignore el relleno.

4. Formato de instrucción para modelos generativos

Lo que viste hasta ahora sirve para fine-tuning de clasificación (BERT-style). Pero si estás haciendo fine-tuning de un modelo generativo (como Llama, Mistral, GPT), el formato del dataset es diferente. En vez de pares (texto, etiqueta), necesitas triples de (instrucción, entrada, respuesta).

Por qué este formato: los modelos generativos aprendieron a predecir la siguiente palabra. Para que aprendan a seguir instrucciones, les mostramos muchos ejemplos del tipo: "cuando veas esta instrucción + esta entrada, debes generar esta respuesta". El modelo aprende el patrón completo.

El formato Alpaca (el más usado)

Stanford Alpaca popularizó este formato en 2023. Cada ejemplo se convierte en un texto único con marcadores especiales:

# Ejemplo de un dato de entrenamiento en formato Alpaca
ejemplo_alpaca = {
    "instruction": "Clasifica el sentimiento del siguiente texto en Positivo, Negativo o Neutro.",
    "input": "Me encantó la película, aunque el final fue un poco predecible.",
    "output": "Positivo"
}

# Función para convertir al formato de texto que ve el modelo
def formato_alpaca(ejemplo):
    if ejemplo["input"]:  # si hay entrada
        texto = (
            f"### Instrucción:\n{ejemplo['instruction']}\n\n"
            f"### Entrada:\n{ejemplo['input']}\n\n"
            f"### Respuesta:\n{ejemplo['output']}"
        )
    else:  # si no hay entrada (la instrucción es suficiente)
        texto = (
            f"### Instrucción:\n{ejemplo['instruction']}\n\n"
            f"### Respuesta:\n{ejemplo['output']}"
        )
    return {"text": texto}

print(formato_alpaca(ejemplo_alpaca)["text"])
salida real### Instrucción: Clasifica el sentimiento del siguiente texto en Positivo, Negativo o Neutro. ### Entrada: Me encantó la película, aunque el final fue un poco predecible. ### Respuesta: Positivo

Aplicar el formato a todo el dataset

# Dataset con ejemplos en formato instrucción
datos_instruccion = [
    {"instruction": "Resume en una oración.",
     "input": "El cambio climático es causado principalmente por la quema de combustibles fósiles...",
     "output": "El cambio climático se debe al uso de combustibles fósiles."},
    {"instruction": "Traduce al inglés.",
     "input": "La inteligencia artificial está cambiando el mundo.",
     "output": "Artificial intelligence is changing the world."},
    # ... más ejemplos
]

ds_instruccion = Dataset.from_list(datos_instruccion)

# Aplicar el formato a todos los ejemplos
ds_formateado = ds_instruccion.map(formato_alpaca)

print(ds_formateado[0]["text"])
salida real### Instrucción: Resume en una oración. ### Entrada: El cambio climático es causado principalmente por la quema de combustibles fósiles... ### Respuesta: La inteligencia artificial está cambiando el mundo.
Lo importante: para modelos generativos, el dataset es una colección de textos completos (instrucción + entrada + respuesta concatenados). El modelo aprende a generar la parte "respuesta" cuando ve la instrucción y la entrada. El DataCollator para este caso se llama DataCollatorForLanguageModeling.

5. Equilibrio de clases: qué pasa cuando tienes 90% de positivos

Imagina que tienes un dataset para detectar spam: 900 correos normales y 100 correos spam. Si entrenas el modelo así, aprende rápidamente a decir siempre "no es spam" — obtendrá 90% de accuracy sin aprender nada útil. Esto es el desbalance de clases (en inglés, class imbalance).

✍️ Por qué 90% de accuracy no es suficiente — ejemplo numérico
Dataset: 900 correos normales + 100 correos spam = 1000 total

Un modelo que SIEMPRE dice "no es spam":
  · Acierta en los 900 correos normales → 900 correctos
  · Falla en los 100 spam → 100 incorrectos
  · Accuracy = 900 / 1000 = 90% ← parece bueno, pero...
  · Recall de spam = 0% ← no detecta NINGÚN spam

Un modelo que SÍ aprende:
  · Accuracy = 85% (parece peor)
  · Pero detecta 70 de los 100 spam → Recall de spam = 70%
  · El segundo modelo es MUY superior para la tarea real

Hay cuatro estrategias principales para manejar el desbalance:

Estrategia Qué hace Cuándo usarla
Oversampling Duplicar ejemplos de la clase minoritaria Cuando tienes pocos datos en la clase rara
Undersampling Reducir ejemplos de la clase mayoritaria Cuando tienes muchos datos en total
Class weights Penalizar más los errores en la clase minoritaria La más práctica — no cambia el dataset
Métricas correctas Usar F1, Precision, Recall en vez de Accuracy Siempre, junto a cualquier estrategia

Oversampling — duplicar la clase minoritaria

from datasets import concatenate_datasets

# Separar por clase
ds_normal = ds_train.filter(lambda x: x["etiqueta"] == 0)  # 900 ejemplos
ds_spam   = ds_train.filter(lambda x: x["etiqueta"] == 1)  # 100 ejemplos

print(f"Normal: {len(ds_normal)}, Spam: {len(ds_spam)}")

# Duplicar el spam 9 veces para igualar las clases
ds_spam_x9 = concatenate_datasets([ds_spam] * 9)  # 9 × 100 = 900 ejemplos

# Combinar y mezclar
ds_balanceado = concatenate_datasets([ds_normal, ds_spam_x9])
ds_balanceado = ds_balanceado.shuffle(seed=42)

print(f"Dataset balanceado: {len(ds_balanceado)} ejemplos")

# Verificar distribución
from collections import Counter
etiquetas = ds_balanceado["etiqueta"]
print("Distribución:", Counter(etiquetas))
salida realNormal: 900, Spam: 100 Dataset balanceado: 1800 ejemplos Distribución: Counter({0: 900, 1: 900})

Class weights — la opción más limpia

import torch
from sklearn.utils.class_weight import compute_class_weight
import numpy as np

etiquetas_numpy = np.array(ds_train["etiqueta"])

# Calcular pesos: la clase rara recibe un peso mayor
pesos = compute_class_weight(
    class_weight="balanced",
    classes=np.unique(etiquetas_numpy),
    y=etiquetas_numpy
)
pesos_tensor = torch.tensor(pesos, dtype=torch.float)

print(f"Peso clase 0 (normal): {pesos_tensor[0]:.4f}")
print(f"Peso clase 1 (spam):   {pesos_tensor[1]:.4f}")
print("→ Un error en spam penaliza 9 veces más que un error en normal")
salida realPeso clase 0 (normal): 0.5556 Peso clase 1 (spam): 5.0000 → Un error en spam penaliza 9 veces más que un error en normal

6. Ejemplo completo: desde CSV hasta DataCollator

Juntamos todo lo que aprendiste en este flujo completo. Supón que tienes un archivo resenas.csv con columnas texto y etiqueta (0 = negativo, 1 = positivo):

import pandas as pd
from datasets import Dataset, DatasetDict
from transformers import AutoTokenizer, DataCollatorWithPadding

## ── PASO 1: Cargar y limpiar ──────────────────────────
df = pd.read_csv("resenas.csv")
df = df.dropna()                          # eliminar filas vacías
df = df[df["texto"].str.len() >= 10]       # descartar textos muy cortos
df = df.reset_index(drop=True)
print(f"Total después de limpiar: {len(df)} filas")
print(df["etiqueta"].value_counts())

## ── PASO 2: Crear Dataset y hacer splits ──────────────
ds_completo = Dataset.from_pandas(df)
split1 = ds_completo.train_test_split(test_size=0.1, seed=42)
split2 = split1["train"].train_test_split(test_size=0.111, seed=42)

dataset = DatasetDict({
    "train":      split2["train"],
    "validation": split2["test"],
    "test":       split1["test"],
})
print(dataset)

## ── PASO 3: Tokenizar ─────────────────────────────────
tokenizer = AutoTokenizer.from_pretrained("distilbert-base-uncased")

def tokenize_fn(batch):
    return tokenizer(
        batch["texto"],
        truncation=True,
        max_length=128,
    )

ds_tok = dataset.map(tokenize_fn, batched=True, remove_columns=["texto"])
print("Columnas después de tokenizar:", ds_tok["train"].column_names)

## ── PASO 4: Renombrar etiqueta a 'labels' (lo espera el Trainer) ─
ds_tok = ds_tok.rename_column("etiqueta", "labels")
ds_tok = ds_tok.with_format("torch")   # convertir a tensores PyTorch

## ── PASO 5: Crear el DataCollator ─────────────────────
collator = DataCollatorWithPadding(tokenizer=tokenizer)

print("\n¡Dataset listo para el Trainer!")
print(f"Train: {len(ds_tok['train'])} | Val: {len(ds_tok['validation'])} | Test: {len(ds_tok['test'])}")
salida realTotal después de limpiar: 1000 filas 1 605 0 395 Name: etiqueta, dtype: int64 DatasetDict({ train: Dataset({features: ['texto', 'etiqueta'], num_rows: 800}) validation: Dataset({features: ['texto', 'etiqueta'], num_rows: 100}) test: Dataset({features: ['texto', 'etiqueta'], num_rows: 100}) }) Columnas después de tokenizar: ['etiqueta', 'input_ids', 'attention_mask'] ¡Dataset listo para el Trainer! Train: 800 | Val: 100 | Test: 100

🎮 Pruébalo: simulador de preparación de lotes

Escribe hasta 4 textos cortos y observa cómo el DataCollator los convierte en un tensor rectangular. Verás los IDs de tokens (simulados), el padding añadido y el attention mask.

8. Lo que aprendiste

Lo que aprendiste hoy: puedes crear un Dataset de HuggingFace desde cualquier fuente (lista Python, CSV, JSON) con pocas líneas de código. Un dataset bien preparado tiene tres partes: train para aprender (80%), validation para monitorear durante el entrenamiento (10%) y test para la evaluación final honesta (10%). El DataCollator es el mecanismo que toma ejemplos de longitud variable y los convierte en tensores rectangulares añadiendo [PAD] — el Trainer lo usa automáticamente. Para modelos generativos, el formato instrucción-entrada-respuesta permite que el modelo aprenda a seguir órdenes. Si tus clases están desbalanceadas, usa oversampling o class weights — nunca confíes solo en accuracy.

En la próxima lección: Trainer API — el objeto que orquesta el entrenamiento completo: cómo configurar los hiperparámetros, qué hace en cada paso y cómo detenerlo automáticamente cuando deja de mejorar.