Fine-tuning — Módulo 2: el dataset es el modelo
Segundo módulo del Curso de Fine-tuning práctico: cuántos ejemplos hacen falta de verdad, el formato JSONL de conversaciones, calidad sobre cantidad, cosechar ejemplos de producción y curarlos, diversidad y balance, anonimización de datos personales, y los splits train/val/test. Con código real para construir y validar el dataset.
☰ Índice del contenido
- La tesis del módulo
- Cuántos ejemplos hacen falta de verdad
- El formato: JSONL de conversaciones
- Calidad sobre cantidad: qué significa en concreto
- Cosechar de producción: la mejor fuente
- Diversidad y balance de la distribución
- Anonimización y datos personales
- Splits: train, validation y test
- Construir y validar el dataset, en código
- El error de fondo: entrenar el sesgo que querías quitar
- Errores comunes
- La frase para llevarse
- Qué viene
- Material relacionado
En el módulo 1 decidimos que corresponde entrenar: el prompt ya está afinado, el few-shot no da más, la información necesaria llega por contexto y aun así el comportamiento no es el que necesitas. Ahora empieza el trabajo real, y no es el que la gente espera. No vas a pasar las próximas dos semanas eligiendo learning rates: vas a pasarlas construyendo un archivo de texto. Ese archivo es la mayor parte del resultado. Puedes equivocarte en casi todos los hiperparámetros y aun así obtener un modelo decente si el dataset está bien; con un dataset malo no hay configuración que te salve, y el modelo va a aprender exactamente los defectos que le mostraste, con una fidelidad que da rabia.
La tesis del módulo
El modelo es un espejo del dataset, incluidas las manchas. No aprende lo que quisiste enseñarle: aprende lo que efectivamente está en los ejemplos. Si tus respuestas de entrenamiento empiezan la mitad de las veces con “Claro, con gusto”, tu modelo va a empezar así siempre. Si el 70% de los ejemplos son de una sola categoría, tu modelo va a responder esa categoría por defecto. Y si los datos traen el sesgo que querías corregir, el fine-tune lo va a amplificar.
Cuántos ejemplos hacen falta de verdad
La respuesta que sorprende: cientos a pocos miles, no decenas de miles. Y bajar de ahí es más viable de lo que parece, mientras que subir mucho rinde menos de lo que uno esperaría.
| Objetivo | Ejemplos | Comentario |
|---|---|---|
| Formato de salida estricto (JSON) | 200 – 500 | El formato se aprende rápido |
| Estilo y tono de marca | 300 – 1.000 | Requiere ver variedad de situaciones |
| Clasificación en N categorías | 50 – 200 por clase | El balance manda más que el total |
| Tarea de extracción estructurada | 500 – 2.000 | Depende de cuántos campos y su rareza |
| Asistente de dominio multi-tarea | 2.000 – 10.000 | Ya es un proyecto grande |
| Idioma o registro completamente nuevo | 50.000+ | Ahí ya no es SFT, es continued pretraining |
La razón de fondo de que alcance con tan poco es la misma que vimos en el módulo 7 del Curso de Transformers: el modelo ya sabe hacer la tarea. Ya sabe español, ya sabe qué es un JSON, ya sabe clasificar. Lo que le falta no es capacidad, es especificación: entender exactamente cuál de todos los comportamientos posibles quieres. Y una especificación se transmite con ejemplos claros, no con volumen.
El trabajo de LIMA (Less Is More for Alignment, Meta AI, 2023) es la referencia habitual para este punto: mostró que 1.000 ejemplos cuidadosamente curados bastaban para llevar un base model a un comportamiento de asistente competitivo. La lección práctica no es “1.000 es el número mágico” — es que la curva de retorno se aplana muy temprano y que la calidad domina sobre el volumen mucho antes de lo que la intuición de machine learning clásico sugiere.
RETORNO POR EJEMPLO AGREGADO (cualitativo)
calidad
│ ╭────────────────── curados
│ ╭────╯
│ ╭────╯
│ ╭────╯ ╭─────────────────── sin curar
│ ╭────╯ ╭────╯
│ ╭───╯ ╭────╯
│ ─╯ ╭────╯
└────────────────────────────────────────────►
100 500 1k 3k 10k ejemplos
Curar 500 rinde más que agregar 5.000 sin revisar.
Ojo: hay un contra-argumento válido para el volumen, y es la diversidad. Mil ejemplos que cubren mil situaciones distintas valen mucho más que diez mil que son variaciones de veinte plantillas. Cuando alguien dice “necesito más datos”, la pregunta correcta es “¿más datos o más variedad?”. Casi siempre es lo segundo.
El formato: JSONL de conversaciones
El estándar de facto es JSONL (glosar: JSON Lines; un archivo de texto donde cada línea es un objeto JSON completo e independiente — se lee de a una línea sin cargar todo en memoria y se anexa sin reescribir el archivo). Cada línea es un ejemplo de entrenamiento, y cada ejemplo es una conversación con roles.
{"messages": [{"role": "system", "content": "Eres el asistente de soporte de Acme. Respondes breve y en español de Chile."}, {"role": "user", "content": "no me llega la boleta del mes pasado"}, {"role": "assistant", "content": "Las boletas se emiten el día 5 de cada mes y llegan al correo registrado. Si no la ves, revisa la carpeta de spam. Si sigue sin aparecer, la puedes descargar desde Mi cuenta > Documentos."}]}
{"messages": [{"role": "user", "content": "Extrae los datos de esta factura: FACT-8812, Comercial Ríos Ltda, 2026-03-14, $1.249.900"}, {"role": "assistant", "content": "{\"folio\": \"FACT-8812\", \"razon_social\": \"Comercial Ríos Ltda\", \"fecha\": \"2026-03-14\", \"monto\": 1249900}"}]}
Tres decisiones de formato que se toman aquí y que después son caras de cambiar:
El system prompt. Puedes entrenar con un system fijo, con systems variados o sin system. La regla: entrena con el mismo system que vas a usar en producción. Si en producción vas a mandar un system, ponlo en el dataset. Si vas a variarlo, incluye esa variación en los ejemplos, o el modelo va a ignorarlo en inferencia porque nunca aprendió que ese campo importa.
Turnos múltiples. Si tu producto es una conversación de varios turnos, el dataset debe tener conversaciones de varios turnos. Un modelo entrenado solo con pares pregunta-respuesta pierde el hilo cuando el usuario dice “¿y eso incluye IVA?”.
Los casos negativos. Ejemplos donde la respuesta correcta es negarse, pedir aclaración o decir “no tengo esa información”. Si no están en el dataset, el modelo aprende que a toda pregunta le sigue una respuesta segura — y va a inventar. Un 5% a 10% del dataset debería ser este tipo de caso.
Cuidado: las claves exactas (
messages,role,content) y el formato de la línea dependen de la herramienta que uses. El formato de arriba es el de la API de OpenAI y el que TRL espera en su modo “conversacional”. Si entrenas con otra librería, verifica el formato en su documentación antes de generar 3.000 líneas.
Calidad sobre cantidad: qué significa en concreto
“Calidad” suena a palabra de póster. Estas son las seis propiedades concretas que la componen, y todas son verificables:
- La respuesta es correcta. Obvio y sin embargo la fuente número uno de datasets malos, sobre todo cuando se cosechan logs de producción sin revisar.
- La respuesta está en el formato objetivo. Si quieres JSON sin preámbulo, ningún ejemplo puede tener preámbulo. Uno solo que lo tenga le enseña al modelo que a veces se hace.
- La respuesta es completa pero no rellena. Si la mitad de tus ejemplos terminan con “Espero que esto te ayude, no dudes en consultar”, el modelo va a agregar esa coletilla en producción. Todo lo que se repite se aprende.
- El estilo es consistente entre ejemplos. Datasets escritos por cinco personas distintas sin guía de estilo producen modelos con personalidad inestable. Una guía de estilo escrita antes de anotar vale más que 500 ejemplos extra.
- No hay contradicciones. Dos ejemplos con entradas casi iguales y salidas incompatibles enseñan al modelo que la tarea es ambigua. El resultado es un modelo que promedia y no cumple ninguno de los dos criterios.
- La longitud es representativa. Si en producción las entradas tienen 800 tokens y en el dataset tienen 80, el modelo no vio nunca su caso real.
Cosechar de producción: la mejor fuente
El dataset más valioso no se escribe: se cosecha. Si ya tienes un sistema en producción con un modelo grande y un prompt largo, cada llamada exitosa es un ejemplo de entrenamiento potencial, y viene con algo que ningún dataset sintético tiene: la distribución real de lo que preguntan los usuarios.
START ──► ┌──────────────────┐
│ logs de prod │
│ (entrada+salida)│
└────────┬─────────┘
│
▼
┌──────────────────┐ descarta
│ filtro auto │ ─────────► basura
│ formato, largo │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ señal implícita │
│ pulgar, reintento│
└────────┬─────────┘
│
▼
┌──────────────────┐
│ revisión humana │ ◄── el paso
│ editar o botar │ que nadie
└────────┬─────────┘ se puede
│ saltar
▼
┌──────────────────┐
│ dataset v1 │ ──► END
└──────────────────┘
Las señales implícitas de calidad son oro y suelen estar ya en tu base de datos: el usuario copió la respuesta, el usuario no reformuló la pregunta, el ticket se cerró, el pulgar arriba, el agente humano no tuvo que intervenir. Al revés: el usuario reformuló tres veces, el ticket escaló, la sesión se abandonó. Filtrar por esas señales antes de la revisión humana multiplica el rendimiento del revisor.
Y el paso que no se puede saltar sigue siendo el mismo: un humano lee y edita. Cosechar logs sin curar es entrenar al modelo para que imite al modelo anterior, con errores incluidos, y con la ventaja perdida de que el anterior al menos tenía el prompt largo que tú vas a quitar.
Diversidad y balance de la distribución
Un dataset puede tener 2.000 ejemplos perfectos y aun así producir un modelo malo, si esos 2.000 ejemplos son todos parecidos. Dos dimensiones que hay que auditar explícitamente:
Balance de clases. Si clasificas tickets en 14 categorías y el 60% de tus ejemplos son “consulta de facturación”, el modelo va a responder “consulta de facturación” ante la duda. Regla práctica: ninguna clase debería tener menos de un tercio de los ejemplos de la más frecuente. Si en la realidad la distribución es desbalanceada, tienes dos opciones honestas — sobremuestrear las clases raras en el entrenamiento, o aceptar el desbalance y medirlo por clase en la evaluación. Lo que no puedes es ignorarlo.
Diversidad de superficie. Formas de preguntar, largos, registros, faltas de ortografía, mayúsculas, emojis, mensajes de una palabra. Si todos tus ejemplos son oraciones bien escritas y tus usuarios escriben “hola necesito ayuda con lo del pago q no me sale”, el modelo va a rendir peor en producción que en tu test.
Anonimización y datos personales
Si cosechas de producción, tus ejemplos casi con certeza contienen PII (glosar: Personally Identifiable Information; datos personales que permiten identificar a una persona — nombre, RUT, correo, teléfono, dirección, número de tarjeta). Y aquí hay un riesgo específico del fine-tuning que no aplica a RAG: el modelo memoriza. Un dato personal que entra al dataset queda dentro de los pesos y puede salir en producción ante un prompt cualquiera, sin forma de borrarlo salvo reentrenar.
La regla operativa: anonimiza antes de mirar el dataset, no antes de entrenar. Si el pipeline de anonimización corre como último paso, alguien va a hacer una copia del archivo crudo para revisarla y esa copia va a vivir para siempre en un notebook.
"""Anonimización de PII para datasets de fine-tuning (contexto chileno).
Estrategia: reemplazo con placeholders CONSISTENTES por conversación.
Si "Juan Pérez" aparece 3 veces en el mismo ejemplo, las 3 veces se
convierte en [NOMBRE_1] — así el modelo sigue aprendiendo a mantener
la coherencia referencial, que es parte del comportamiento deseado.
Ojo: regex NO es suficiente por sí sola. Es la primera pasada barata;
para nombres y direcciones hace falta NER (reconocimiento de entidades)
o revisión humana. Trátala como un filtro, no como una garantía.
"""
import re
from collections import defaultdict
PATRONES = {
# RUT chileno: 12.345.678-9 o 12345678-K
"RUT": re.compile(r"\b\d{1,2}\.?\d{3}\.?\d{3}-[\dkK]\b"),
"EMAIL": re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.]+\b"),
# Teléfono chileno: +56 9 XXXX XXXX y variantes
"TELEFONO": re.compile(r"(?:\+?56)?\s?9\s?\d{4}\s?\d{4}\b"),
"TARJETA": re.compile(r"\b(?:\d{4}[\s-]?){3}\d{4}\b"),
"IP": re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b"),
}
# El orden importa: TARJETA antes que IP evita que un número de
# tarjeta con puntos se confunda con una dirección IP.
ORDEN = ["TARJETA", "RUT", "EMAIL", "TELEFONO", "IP"]
def anonimizar_conversacion(mensajes: list[dict]) -> tuple[list[dict], dict]:
"""Devuelve la conversación anonimizada y el mapa de reemplazos.
El mapa NO se guarda junto al dataset: sirve para auditar y se bota.
"""
mapa: dict[str, str] = {}
contadores: dict[str, int] = defaultdict(int)
def reemplazar(match: re.Match, etiqueta: str) -> str:
valor = match.group(0)
if valor not in mapa:
contadores[etiqueta] += 1
mapa[valor] = f"[{etiqueta}_{contadores[etiqueta]}]"
return mapa[valor]
limpios = []
for m in mensajes:
texto = m["content"]
for etiqueta in ORDEN:
texto = PATRONES[etiqueta].sub(
lambda mt, e=etiqueta: reemplazar(mt, e), texto
)
limpios.append({**m, "content": texto})
return limpios, mapa
if __name__ == "__main__":
conv = [
{"role": "user",
"content": "Soy Ana, RUT 12.345.678-9, mi correo [email protected]"},
{"role": "assistant",
"content": "Listo, verifiqué el RUT 12.345.678-9 en el sistema."},
]
limpia, mapa = anonimizar_conversacion(conv)
for m in limpia:
print(m["role"], "->", m["content"])
# user -> Soy Ana, RUT [RUT_1], mi correo [EMAIL_1]
# assistant -> Listo, verifiqué el RUT [RUT_1] en el sistema.
print(len(mapa), "entidades reemplazadas")
Fíjate en el detalle del placeholder consistente: el mismo RUT es [RUT_1] en los dos mensajes. Si cada aparición tuviera un número distinto, el modelo aprendería que las referencias no se mantienen, que es justo lo contrario de lo que quieres.
Cuidado: las expresiones regulares atrapan RUTs, correos y teléfonos. No atrapan nombres propios, direcciones, nombres de empresa ni el dato de “el paciente de la cama 4 del Hospital X”. Para eso hace falta reconocimiento de entidades o revisión humana. Un pipeline que solo usa regex y declara el dataset “anonimizado” está dando una garantía que no puede cumplir.
Splits: train, validation y test
Tres conjuntos, tres propósitos distintos, y una regla estricta.
| Split | Proporción típica | Para qué sirve | Cuántas veces se mira |
|---|---|---|---|
| train | 80 – 90% | Ajustar los pesos | Todas |
| validation | 5 – 10% | Elegir hiperparámetros, detectar overfitting | Cada época |
| test | 5 – 10% | Estimar el rendimiento real | Una sola vez |
El test se toca una sola vez, al final del proyecto. La razón es concreta y no es puritanismo académico: cada vez que miras el test y decides algo en base a él, estás usando esa información para ajustar el sistema. Después de veinte decisiones tomadas mirando el test, ese conjunto ya no mide el rendimiento en datos nuevos — mide el rendimiento en datos que indirectamente te ayudaron a construir el modelo. El número te va a mentir hacia arriba, y lo va a hacer justo cuando lo presentes.
Para eso está el validation: ese sí lo miras todas las veces que quieras. Ahí eliges el learning rate, el número de épocas, el rango del LoRA. El test queda guardado hasta que el modelo esté congelado.
Y hay un detalle específico de datasets cosechados: el split tiene que ser por usuario o por sesión, no por línea. Si un mismo cliente generó 30 ejemplos y los repartes al azar, van a quedar ejemplos casi idénticos de ese cliente en train y en test. Tu métrica de test va a salir excelente y va a estar midiendo memorización.
Construir y validar el dataset, en código
Este script es el que corres antes de cada entrenamiento. No es opcional: es lo que atrapa los problemas que después se ven como “el modelo salió raro”.
"""Validación de un dataset JSONL de fine-tuning conversacional.
Chequea: estructura, duplicados exactos y casi-exactos, longitudes,
balance de clases, casos negativos y fugas entre splits.
Correr SIEMPRE antes de entrenar. Falla ruidosamente.
"""
import hashlib
import json
import re
from collections import Counter
from statistics import mean, quantiles
def cargar(path: str) -> list[dict]:
filas = []
with open(path, encoding="utf-8") as f:
for n, linea in enumerate(f, 1):
if not linea.strip():
continue
try:
filas.append(json.loads(linea))
except json.JSONDecodeError as e:
raise ValueError(f"{path}:{n} JSON inválido: {e}") from e
return filas
def validar_estructura(filas: list[dict]) -> list[str]:
"""El error más común y más silencioso: un ejemplo mal armado
que la librería ignora o, peor, entrena en el formato equivocado."""
errores = []
for i, fila in enumerate(filas):
msgs = fila.get("messages")
if not isinstance(msgs, list) or not msgs:
errores.append(f"[{i}] sin 'messages'")
continue
roles = [m.get("role") for m in msgs]
if roles[-1] != "assistant":
# Si el último turno no es del assistant, no hay nada
# que aprender en ese ejemplo.
errores.append(f"[{i}] no termina en assistant: {roles}")
if any(r not in {"system", "user", "assistant"} for r in roles):
errores.append(f"[{i}] rol desconocido: {roles}")
if any(not (m.get("content") or "").strip() for m in msgs):
errores.append(f"[{i}] mensaje con content vacío")
return errores
def _normalizar(texto: str) -> str:
"""Para detectar casi-duplicados: minúsculas, sin puntuación,
espacios colapsados. Dos ejemplos que solo difieren en '¿' son
el mismo ejemplo para efectos de entrenamiento."""
t = texto.lower()
t = re.sub(r"[^\wáéíóúñü ]+", " ", t)
return re.sub(r"\s+", " ", t).strip()
def detectar_duplicados(filas: list[dict]) -> dict:
exactos, aproximados = Counter(), Counter()
for fila in filas:
crudo = json.dumps(fila["messages"], ensure_ascii=False,
sort_keys=True)
exactos[hashlib.sha1(crudo.encode()).hexdigest()] += 1
# Casi-duplicado: misma entrada del usuario normalizada
user = " ".join(m["content"] for m in fila["messages"]
if m["role"] == "user")
aproximados[_normalizar(user)] += 1
return {
"duplicados_exactos": sum(c - 1 for c in exactos.values() if c > 1),
"entradas_repetidas": sum(c - 1 for c in aproximados.values()
if c > 1),
"peores": aproximados.most_common(5),
}
def perfil_longitudes(filas: list[dict], tokenizer=None) -> dict:
"""Con tokenizer usa tokens reales; sin él, aproxima por caracteres.
La aproximación 1 token ~ 3.5 caracteres sirve para español, pero
para decidir max_seq_length usa el tokenizer de verdad."""
def largo(texto: str) -> int:
if tokenizer is not None:
return len(tokenizer.encode(texto, add_special_tokens=False))
return max(1, len(texto) // 4)
totales, respuestas = [], []
for fila in filas:
totales.append(sum(largo(m["content"]) for m in fila["messages"]))
respuestas.append(sum(largo(m["content"]) for m in fila["messages"]
if m["role"] == "assistant"))
q = quantiles(totales, n=100)
return {
"media_total": round(mean(totales)),
"p50": round(q[49]), "p95": round(q[94]), "p99": round(q[98]),
"max": max(totales),
"media_respuesta": round(mean(respuestas)),
# Si respuesta/total es muy chico, la mayor parte de la
# secuencia es prompt y el gradiente útil es escaso.
"ratio_respuesta": round(mean(respuestas) / mean(totales), 3),
}
def balance_de_clases(filas: list[dict], extraer_clase) -> dict:
"""`extraer_clase(fila) -> str | None`. Para clasificación, suele ser
parsear el JSON de la respuesta y sacar el campo de categoría."""
conteo = Counter(filter(None, (extraer_clase(f) for f in filas)))
if not conteo:
return {}
mayor = conteo.most_common(1)[0][1]
return {
"conteo": dict(conteo.most_common()),
"clases": len(conteo),
# Clases con menos de un tercio de la mayor: el modelo las
# va a subrepresentar sistemáticamente.
"subrepresentadas": [c for c, n in conteo.items() if n < mayor / 3],
}
def fuga_entre_splits(train: list[dict], test: list[dict]) -> int:
"""Casi-duplicados compartidos entre train y test. Cualquier valor
mayor a 0 invalida la métrica de test."""
def claves(filas):
return {
_normalizar(" ".join(m["content"] for m in f["messages"]
if m["role"] == "user"))
for f in filas
}
return len(claves(train) & claves(test))
if __name__ == "__main__":
train = cargar("data/train.jsonl")
test = cargar("data/test.jsonl")
errores = validar_estructura(train)
assert not errores, f"{len(errores)} ejemplos rotos:\n" + "\n".join(
errores[:10])
print("duplicados :", detectar_duplicados(train))
print("longitudes :", perfil_longitudes(train))
print("balance :", balance_de_clases(
train,
lambda f: json.loads(f["messages"][-1]["content"]).get("categoria"),
))
fugas = fuga_entre_splits(train, test)
assert fugas == 0, f"{fugas} ejemplos de test también están en train"
print("sin fuga entre splits ✓")
Los tres números que más rinden de esa salida: entradas_repetidas (si es alto, tu dataset es más chico de lo que crees y el modelo va a sobreajustar a esas repeticiones), p99 de longitudes (define el max_seq_length que necesitas, tema del módulo 3) y subrepresentadas (las clases que el modelo va a fallar y que en el promedio no se ven).
El error de fondo: entrenar el sesgo que querías quitar
Este es el más difícil de detectar porque el dataset se ve perfecto. El caso típico:
Un equipo quiere que su asistente deje de escalar tickets innecesariamente al equipo humano. Cosechan seis meses de conversaciones reales del asistente anterior, las curan bien, filtran las que terminaron con el cliente satisfecho, y entrenan. El modelo nuevo escala más que el anterior.
¿Qué pasó? Los ejemplos donde el cliente quedó satisfecho eran, en buena parte, aquellos donde el asistente escaló a un humano y el humano resolvió. El filtro de “cliente satisfecho” seleccionó justamente el comportamiento que querían eliminar. El dataset no contenía ejemplos de “el asistente resolvió solo un caso difícil”, porque el asistente anterior nunca lo hacía.
La forma general del error: un dataset cosechado del comportamiento actual contiene el comportamiento actual. Si lo que quieres es cambiar ese comportamiento, cosechar no basta — hay que escribir o editar los ejemplos del comportamiento nuevo, aunque nunca haya ocurrido.
Otras versiones del mismo error que vale la pena reconocer:
- El dataset de clasificación se etiquetó con el modelo anterior, así que hereda exactamente sus confusiones entre las dos categorías difíciles.
- Los ejemplos de código los escribió el equipo, que usa una convención antigua, y ahora el modelo escribe con esa convención antigua.
- Las respuestas del dataset son largas porque el modelo profesor era verboso, y ahora el modelo chico es verboso aunque el objetivo era la brevedad.
- Los casos difíciles no están en el dataset porque en producción se escalaban antes de generar una respuesta, y el modelo nunca ve un ejemplo de cómo se resuelven.
El antídoto es un ejercicio de media hora antes de entrenar: escribe el comportamiento objetivo en cinco viñetas y busca en el dataset ejemplos que lo demuestren. Si no los encuentras, el modelo tampoco los va a encontrar.
Errores comunes
- Perseguir volumen en vez de calidad y diversidad. Diez mil ejemplos generados a partir de veinte plantillas enseñan veinte cosas. La curva se aplana temprano: curar 500 rinde más que agregar 5.000 sin revisar.
- Cosechar logs de producción sin curación humana. Entrena al modelo para imitar al modelo anterior, con sus errores, y además sin el prompt largo que lo sostenía. La revisión humana es el paso caro y es el que da el resultado.
- Un system prompt en entrenamiento distinto al de producción. El modelo aprende a responder bajo ciertas condiciones; si en inferencia esas condiciones cambian, el comportamiento aprendido se degrada. Entrena con lo que vas a usar.
- Cero ejemplos negativos. Sin casos de “no sé”, “no puedo ayudarte con eso” o “necesito más información”, el modelo aprende que a toda pregunta le corresponde una respuesta segura. Ese es el mecanismo de la alucinación con formato perfecto.
- Splits al azar por línea cuando hay múltiples ejemplos por usuario. Ejemplos casi idénticos quedan en train y en test, la métrica sale espectacular y está midiendo memorización. Divide por usuario o por sesión.
- Mirar el test durante el desarrollo. Después de veinte decisiones tomadas mirando el test, ese número dejó de ser una estimación honesta. Para eso está validation; el test se abre una vez.
- Confiar en regex como anonimización completa. Atrapa RUTs, correos y teléfonos. No atrapa nombres, direcciones ni contextos identificatorios. Y en fine-tuning el dato memorizado no se puede borrar sin reentrenar.
- No versionar el dataset. Sin versión no puedes comparar dos entrenamientos, ni reproducir el que salió bueno, ni reentrenar cuando salga el modelo base nuevo. El dataset va en control de versiones (o en DVC, ver el módulo de reproducibilidad de MLOps) con changelog.
La frase para llevarse
“El modelo no aprende lo que le quisiste enseñar: aprende lo que efectivamente pusiste en el archivo. Cada coletilla que se repite, cada clase sobrerrepresentada y cada sesgo que dejaste pasar, los vas a leer de vuelta en producción.”
Qué viene
Módulo 3: chat templates, tokens especiales y loss masking. El dataset ya está limpio, curado y validado — y ahora viene el paso donde más fine-tunes se rompen sin que nadie se entere. El modelo no lee tu JSON: lee una secuencia de tokens armada con la plantilla de chat de su familia, y si esa plantilla está mal aplicada, o si hay un BOS duplicado, o si la pérdida se está calculando sobre el prompt en vez de sobre la respuesta, el entrenamiento corre sin errores, la loss baja, y el modelo no aprende nada de lo que querías. Es el módulo más técnico del bloque básico y el que más problemas evita.
Material relacionado
Preguntas sobre este curso
Regístrate gratis para hacer preguntas y guardar tu progreso.
✉ Entérate de lo nuevo
Recibe un aviso cuando publique nuevos cursos, workshops o investigaciones. Sin spam.