Módulo 02 · Datos

Ingeniería del dataset

Esta es la parte más importante de todo el curso, y la que más se subestima. El fine-tuning no falla por elegir mal el optimizador: falla porque el dataset no cubre lo que el modelo tiene que hacer en producción. El error clásico es construir miles de ejemplos aleatorios y esperar que "más datos" arregle todo. El objetivo real no es volumen, es una matriz de cobertura: un mapa explícito de qué situaciones, intenciones y dificultades debe manejar el modelo, con evidencia de que cada celda está representada. Este módulo fija los formatos, las particiones limpias y la matemática mínima para medir la calidad de un dataset antes de gastar una sola GPU-hora.

Al terminar sabrás

  • Elegir entre JSONL, Parquet y datasets.Dataset según tamaño y flujo de trabajo.
  • Distinguir el formato conversacional (messages) del prompt-completion y cuándo usar cada uno.
  • Partir el dataset en train/val/test disjuntos y explicar por qué la fuga (leakage) invalida la evaluación.
  • Correr near-dedup con MinHash-LSH y detectar contaminación train↔eval con n-gramas.
  • Diseñar una taxonomía de cobertura y leer la matriz $C$ para encontrar celdas vacías y desbalance.

1Formatos: JSONL, Parquet, Dataset

Un dataset de fine-tuning es, en el fondo, una tabla de ejemplos. Lo que cambia es cómo la guardas. JSONL (un objeto JSON por línea) es el formato de trabajo por excelencia: legible, se puede versionar en texto, se transmite en streaming y no exige cargar todo en memoria. Parquet es columnar y comprimido; gana cuando el dataset crece a millones de filas y quieres leer solo algunas columnas o filtrar rápido. La clase datasets.Dataset de Hugging Face es la representación en memoria/mapeada que consume el entrenamiento: envuelve Arrow por debajo y carga tanto JSONL como Parquet.

FormatoFuerte enÚsalo cuando…
JSONLLegible, versionable, streaming línea a línea.Autoría, revisión y control de versiones del dataset
ParquetColumnar, comprimido, filtrado y lectura selectiva.Millones de filas o pipelines analíticos
Dataset (HF)Arrow en memoria/mapeado, map/filter, integra con el Trainer.El paso final justo antes de entrenar

2Conversacional vs prompt-completion

Hay dos maneras de representar un ejemplo supervisado. La conversacional guarda una lista de turnos con roles (system, user, assistant); la plantilla de chat (Módulo 1) la convierte en tokens en el momento del entrenamiento. La prompt-completion guarda dos campos planos: la entrada y la salida esperada. La conversacional es más fiel al despliegue real (multi-turno, rol de sistema) y es la forma por defecto hoy; la prompt-completion es más simple y sirve para tareas de un solo turno bien delimitadas.

conversacional → {"messages": [{"role": "user", ...}, {"role": "assistant", ...}]}
prompt-completion → {"prompt": "...", "completion": "..."}
Misma información, dos envoltorios: elige el que refleje cómo se usará el modelo.
Nota · un solo campo manda

Sea cual sea el formato, el texto que ve el modelo se define en la etapa de tokenización, no en el archivo. Todo lo demás que guardes junto al ejemplo (categoría, dificultad, fuente) son metadatos: no se envían al modelo, sirven para balancear, filtrar y auditar. Mézclalos con el messages y contaminarás el prompt sin querer.

3Splits disjuntos: train / val / test

Nunca se entrena y se evalúa sobre los mismos datos. El dataset se parte en tres bloques disjuntos: entrenamiento (ajusta los pesos), validación (elige hiperparámetros y decide cuándo parar) y prueba (mide el resultado una sola vez, al final). Formalmente:

$$ D = D_{\text{train}} \sqcup D_{\text{val}} \sqcup D_{\text{test}} $$

El símbolo $\sqcup$ es unión disjunta: ningún ejemplo aparece en dos bloques. Un reparto típico es 80/10/10. La evaluación solo es válida bajo el supuesto i.i.d. — que train y test se muestrean de la misma distribución de forma independiente. La fuga (leakage) viola ese supuesto: si un ejemplo de test (o un casi-duplicado suyo) está también en train, el modelo lo memorizó y la métrica mide memorización, no generalización. El número sube y en producción el modelo decepciona.

Un reparto puramente aleatorio puede dejar categorías raras fuera de val o test. Por eso el split se hace con muestreo estratificado sobre la etiqueta de cobertura (§6): se reparte cada celda de la matriz $C$ por separado, de modo que los tres bloques preserven la misma distribución de cobertura que el dataset completo. Ese es exactamente el split que pide el Ejercicio 5.

4Deduplicación: Jaccard y MinHash-LSH

Los datasets crudos están llenos de casi-duplicados: mismas preguntas reformuladas, respuestas copiadas, plantillas repetidas. Los duplicados inflan el peso de unos pocos patrones y, si cruzan el split, causan fuga. Para medir cuán parecidos son dos ejemplos representados como conjuntos de tokens (o de shingles) se usa el índice de Jaccard:

$$ J(A,B)=\dfrac{|A\cap B|}{|A\cup B|} $$

Comparar todos contra todos es cuadrático — inviable a escala. MinHash lo estima barato: con una función hash aleatoria, la probabilidad de que el mínimo hash de $A$ coincida con el de $B$ es exactamente su Jaccard:

$$ \Pr[\mathrm{minhash}(A)=\mathrm{minhash}(B)] = J(A,B) $$

Con varias firmas MinHash se estima $J$ con la fracción de coincidencias. Para evitar comparar todos los pares, LSH (locality-sensitive hashing) por bandas agrupa las firmas en tramos y solo considera candidatos a los que colisionan en al menos una banda, generando los pares similares en tiempo sub-cuadrático. Se fija un umbral (p. ej. eliminar pares con $J \ge 0.8$) y se descarta un ejemplar de cada grupo. En LSH ese umbral efectivo no se pone a mano: lo determinan el número de bandas $b$ y de filas $r$ por banda (con umbral $\approx (1/b)^{1/r}$), mientras que el número de firmas MinHash ($b\,r$ en total) controla la varianza del estimador de $J$ — más firmas, estimación más estable.

5Contaminación train↔eval

La deduplicación intra-dataset no basta: hay que verificar explícitamente que el conjunto de evaluación no se filtró en train. La medida estándar es el solapamiento de n-gramas: qué fracción de los n-gramas de cada ejemplo de eval también aparece en train.

$$ \text{overlap} = \dfrac{|\mathrm{ngrams}(train)\cap\mathrm{ngrams}(eval)|}{|\mathrm{ngrams}(eval)|} $$

Un criterio robusto y muy usado es marcar (flag) todo ejemplo de eval que comparta un match exacto de 13-gram con train: 13 tokens seguidos idénticos casi nunca ocurren por azar, así que señalan copia. Los ejemplos marcados se retiran de eval (o de train) antes de medir nada.

Cuidado

La contaminación es silenciosa: no lanza ningún error, solo infla la métrica. Un dataset contaminado te hace creer que el modelo mejoró cuando solo memorizó el examen. Corre el chequeo de n-gramas antes de reportar cualquier número, y guarda el reporte junto al dataset.

6Matriz de cobertura y taxonomía

Aquí está la idea central del módulo. En vez de contar ejemplos, se define una taxonomía: un conjunto de categorías que describen qué debe cubrir el modelo — intención (preguntar, resumir, corregir…), dificultad (fácil/media/difícil), tipo de error a manejar, dominio, idioma, etc. Cada ejemplo se etiqueta contra esas $K$ categorías, produciendo una matriz de cobertura binaria sobre $N$ ejemplos:

$$ C\in\{0,1\}^{N\times K} $$

Las sumas de columna dan el conteo por categoría; una columna en cero (o casi) es una celda vacía: algo que el modelo tendrá que hacer y que nadie le enseñó. Inspeccionar esas celdas vacías vale más que añadir mil ejemplos a la categoría que ya domina. Para resumir el balance de la distribución de categorías $p_k$ se usa la entropía:

$$ H = -\sum_k p_k \log p_k $$

$H$ es máxima cuando todas las categorías están igual de representadas y baja cuando el dataset se amontona en unas pocas. No es un objetivo ciego —a veces se sobre-representa a propósito lo difícil— pero es la señal más rápida de que el reparto está sesgado.

Nota · normaliza dentro de un eje

La entropía solo tiene sentido si los $p_k$ forman una distribución normalizada dentro de un mismo eje: $p_k \ge 0$ y $\sum_k p_k = 1$. Calcúlala sobre la fracción por categoría de un eje single-label (p. ej. las clases de "intención"), o sobre el producto intención × dificultad tratado como una sola etiqueta por ejemplo. Lo que no puedes hacer es sumar columnas de la matriz $C$ cuando esta es multi-etiqueta y mezcla ejes distintos: ahí las sumas de columna no reparten $N$ (un ejemplo cuenta en varias columnas), no son una distribución y su "entropía" no mide balance.

7Positivos, negativos, metadatos y versionado

Un buen dataset no es solo ejemplos positivos (la respuesta correcta): incluye ejemplos negativos — casos donde la respuesta correcta es rechazar, pedir aclaración o señalar que la petición es inválida. Sin negativos, el modelo aprende a responder siempre, incluso cuando no debería. Los metadatos (fuente, autor, fecha, categorías) no van al modelo pero permiten filtrar, balancear y rastrear de dónde salió cada ejemplo. Y todo el dataset se versiona: cada cambio deja una versión inmutable con su reporte de dedup y contaminación, para que cualquier resultado sea reproducible.

ElementoQué esPara qué sirve
PositivoEntrada + respuesta deseada.Enseña qué hacer
NegativoEntrada donde lo correcto es rechazar/aclarar.Enseña cuándo no responder
MetadatoCategoría, dificultad, fuente, versión.Filtrar y balancear — no va al modelo
En el teléfono

El dataset es un asunto de tiempo de entrenamiento, no de on-device: nunca viaja al teléfono. Lo que llega al dispositivo son los pesos ya entrenados (Módulo 9 y 10). Aun así, la calidad y la cobertura del dataset determinan qué tan bien responde el modelo que sí corre en el teléfono: un hueco en la matriz de cobertura de hoy es un fallo del asistente offline mañana.

Cómo practicar
  1. Carga un JSONL de instrucciones con datasets (load_dataset("json", ...)) o con polars (pl.read_ndjson(...)).
  2. Construye la matriz de cobertura (intención × dificultad) y dibuja las celdas vacías como un heatmap.
  3. Corre near-dedup MinHash-LSH y cuenta cuántos ejemplos removiste con Jaccard ≥ 0.8.
  4. Haz un split estratificado 80/10/10 que preserve la cobertura (estratifica sobre la etiqueta de celda).
  5. Chequeo de contaminación 13-gram entre train y eval: marca y retira los ejemplos con match exacto.
Herramientas: Python · datasets · polars · datasketch
Rust · on-device

La limpieza y la deduplicación de datos a escala vuelan en Rust. polars (dataframes escritos en Rust) lee JSONL/Parquet más rápido que pandas y filtra sobre millones de filas sin sudar, y hay implementaciones de MinHash en Rust para el near-dedup. No es on-device —el dataset se queda en tu máquina— pero acelera el pipeline previo que produce los pesos que luego sí correrán en el teléfono.

Lecturas y recursos

Un ejemplo con metadatos

Así se ve un ejemplo conversacional con sus metadatos separados del contenido. Los campos de metadatos (id, categoria, dificultad, tipo) no se envían al modelo: solo el bloque messages se tokeniza. Los primeros sirven para construir la matriz de cobertura, balancear y filtrar.

{
  "id": "ex-000173",
  // metadatos — NO se envían al modelo
  "categoria": "resumen",
  "dificultad": "media",
  "tipo": "positivo",
  "fuente": "curado-v3",
  // esto sí se tokeniza
  "messages": [
    { "role": "system",    "content": "Eres un asistente conciso." },
    { "role": "user",      "content": "Resume el texto en una frase: ..." },
    { "role": "assistant", "content": "..." }
  ]
}

Ejercicios

De menor a mayor complejidad. El último es el que hace un practicante de verdad.

Ejercicio 1 · calentamiento

Carga y perfila el JSONL

Carga un JSONL de instrucciones crudo como datasets.Dataset. Imprime el número de ejemplos, los campos disponibles y la longitud media en tokens de cada ejemplo.

Entrega: tabla resumen (nº ejemplos, campos, longitud media).   Pista: load_dataset("json", data_files=...) + ds.map para contar tokens.

Ejercicio 2

Etiqueta y construye la matriz de cobertura

Define una taxonomía intención × dificultad. Etiqueta cada ejemplo contra ella y construye la matriz $C \in \{0,1\}^{N\times K}$. Reporta las sumas de columna.

Entrega: vector de conteos por categoría + la entropía $H = -\sum_k p_k \log p_k$.   Pista: un diccionario categoría → índice de columna; suma por eje 0.

Ejercicio 3

Jaccard a mano

Implementa jaccard(A, B) sobre conjuntos de shingles (n-gramas de caracteres o de tokens) y verifícalo en pares que sabes duplicados y no-duplicados.

Entrega: función + una pequeña tabla par → $J$.   Pista: $J(A,B)=|A\cap B| / |A\cup B|$ con set.

Ejercicio 4

Near-dedup con MinHash-LSH

Usa MinHash-LSH para encontrar pares con $J \ge 0.8$ en tiempo sub-cuadrático. Elimina un ejemplar por grupo y reporta cuántos ejemplos quedaron.

Entrega: nº de duplicados eliminados + tamaño antes/después.   Pista: datasketch (MinHash, MinHashLSH) con banda por umbral 0.8.

Ejercicio 5 · ejercicio top

Auditoría completa de un dataset crudo

Dado un JSONL de instrucciones crudo: (1) construye una matriz de cobertura sobre tu taxonomía intención × dificultad y visualiza las celdas vacías; (2) corre near-dedup MinHash-LSH y reporta cuántos ejemplos se eliminaron con Jaccard ≥ 0.8; (3) haz un split estratificado train/val/test que preserve la distribución de cobertura; (4) corre un chequeo de contaminación de 13-gram entre train y tu set de eval retenido y cuantifica la fuga.

Entrega: el heatmap de cobertura, el conteo de dedup y un reporte de contaminación con los pares filtrados listados.   Pista: estratifica sobre la etiqueta de cobertura; para el 13-gram usa un set de n-gramas de train y busca match exacto por cada ejemplo de eval.

Puntos clave
  • El dataset es una matriz de cobertura, no una pila de ejemplos: mide qué falta, no cuánto hay.
  • Los splits train/val/test deben ser disjuntos ($\sqcup$); la fuga rompe el supuesto i.i.d. y falsea la métrica.
  • MinHash-LSH encuentra casi-duplicados ($J \ge 0.8$) en tiempo sub-cuadrático; deduplica antes de partir.
  • La contaminación train↔eval se detecta con solapamiento de n-gramas / match de 13-gram; infla la métrica en silencio.
  • Los metadatos no van al modelo: sirven para balancear y filtrar. Versiona el dataset con su reporte de dedup y contaminación.