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.
| Formato | Fuerte en | Úsalo cuando… |
|---|---|---|
| JSONL | Legible, versionable, streaming línea a línea. | Autoría, revisión y control de versiones del dataset |
| Parquet | Columnar, 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": "..."}
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:
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:
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:
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.
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.
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:
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$ 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.
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.
| Elemento | Qué es | Para qué sirve |
|---|---|---|
| Positivo | Entrada + respuesta deseada. | Enseña qué hacer |
| Negativo | Entrada donde lo correcto es rechazar/aclarar. | Enseña cuándo no responder |
| Metadato | Categoría, dificultad, fuente, versión. | Filtrar y balancear — no va al modelo |
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.
- Carga un JSONL de instrucciones con
datasets(load_dataset("json", ...)) o conpolars(pl.read_ndjson(...)). - Construye la matriz de cobertura (intención × dificultad) y dibuja las celdas vacías como un heatmap.
- Corre near-dedup MinHash-LSH y cuenta cuántos ejemplos removiste con
Jaccard ≥ 0.8. - Haz un split estratificado 80/10/10 que preserve la cobertura (estratifica sobre la etiqueta de celda).
- Chequeo de contaminación 13-gram entre
trainyeval: marca y retira los ejemplos con match exacto.
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.
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.
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.
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.
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.
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.
- 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.