El contenido sobre fine-tuning suele empezar igual: cargas un conjunto de datos de un repositorio, le das formato y entrenas. El conjunto de datos ya existe. Llega limpio, etiquetado y dividido.
El mío llegó como client_feedback_examples.txt. 43 KB. El nombre de una categoría en una línea y, debajo, bloques como este:
Selfie:
Ejemplo 1:
"Ok, esta sí la subiría 👀. Tiene una vibra súper natural y se siente
mucho más auténtica que esas fotos donde uno intenta demasiado."
Ejemplo 2:
"No sé qué tiene esta exactamente, pero me gusta más que muchas selfies
perfectamente planeadas. Se siente más tú."
Cuatrocientos ejemplos así, escritos a mano por el cliente en español, con párrafos de indicaciones entre las secciones. Ese archivo era toda la especificación de cómo debía sonar el producto. No había otra fuente de verdad.
El script que lo convierte en JSONL para entrenar tiene 852 líneas. El entrenamiento que consumió ese resultado tomó una tarde.
Esa proporción explica esta nota.
Por qué no basta con separar líneas
Lo obvio sería dividir por Ejemplo N: y tomar lo que sigue. Eso destroza el archivo: los ejemplos ocupan varias líneas y el cliente escribió de forma natural, sin pensar en registros.
Por eso el parser sigue una comilla abierta en lugar de una línea:
def is_quote_complete(lines: list[str]) -> bool:
text = "\n".join(lines).strip()
if len(text) < 2:
return False
expected_closer = OPENING_QUOTES.get(text[0])
return bool(expected_closer and text.endswith(expected_closer))
OPENING_QUOTES relaciona cinco marcas de apertura con sus cierres: comillas rectas, tipográficas y «». Una persona que escribe durante una semana no mantiene siempre el mismo estilo de comillas, y una comilla tipográfica tiene un código distinto de una ASCII aunque parezcan iguales en la terminal.
Un ejemplo acumula líneas hasta encontrar el cierre que corresponde a su apertura. Es una pequeña máquina de estados, y marca la diferencia entre 400 registros completos y 400 truncados.
La línea de la que menos orgulloso estoy
Las categorías llegan como encabezados sueltos: Selfie:, Gym:, Viajes:. El parser conserva una current_category y se la asigna a todo lo que aparece debajo.
Pero el cliente también escribió párrafos entre las secciones: pautas, contexto, peticiones. Esos párrafos tienen la misma sangría que el resto. Un parser ingenuo mantiene activa la categoría anterior y clasifica mal los ejemplos siguientes.
La solución:
# Long prose between sections is client guidance, not feedback. It ends
# the current category so unlabeled future examples are not misfiled.
if len(line) > 40:
current_category = None
Cuarenta caracteres. Un número mágico sin ninguna teoría detrás, elegido porque funcionaba con este archivo y que habría que ajustar para cualquier otro.
Lo dejé y escribí el comentario. Defendería ambas decisiones. La alternativa, un clasificador o una gramática más general, exige más ingeniería para una entrada que se procesará una sola vez y que tiene un único autor. Lo que hace aceptable el atajo es que los registros sin resolver no se etiquetan por intuición. Fallan:
if base_record["category"] is None:
parse_errors.append({
"line_number": base_record["line_number"],
"reason": "Missing category header",
"raw": base_record["feedback_text"],
})
continue
Los errores de análisis se escriben en parse_errors.txt. El archivo tiene actualmente cero bytes; esa es la única razón por la que confío en el cuarenta.
Una heurística con una vía de fallo visible es ingeniería. Esa misma heurística fallando en silencio corrompe los datos, y meses después aparece un modelo que falla en una categoría sin que nadie sepa por qué.
También hay una pasada de reparación: si una línea posterior dice Categoría: fitness, se completa la categoría de todos los ejemplos que siguen en None. Recuperar la etiqueta desde el contexto es mejor que descartar texto real del cliente.
Adaptar en vez de traducir literalmente
El producto se publica en inglés. Los datos están en español. El paso obvio sería pasarlos por un traductor.
Me negué. Es la decisión que más defendería.
La traducción literal conserva el significado y destruye el registro. "Esta transmite una confianza muy tranquila" se convierte en "This transmits a very calm confidence": tiene gramática, pero no suena como algo que un amigo diría en inglés. Entrena con 400 frases así y tendrás un modelo fluido que suena ajeno. Una frase sencilla puede parecer escueta; una traducida de esa manera parece equivocada.
Por eso el pipeline produce dos conjuntos y etiqueta la diferencia dentro de los propios datos:
{
"id": "selfie_001",
"language": "en",
"source": "client_adapted",
"translation_status": "adapted"
}
El conjunto en español conserva source: "client_original". El de inglés se escribe para sonar como una conversación social estadounidense que exprese la misma emoción, según la categoría:
'fitness': [
'This feels earned. Like the kind of progress that only shows up after a lot of quiet work.',
"Lowkey, this has 'I kept showing up even when nobody noticed' energy.",
'This is not just about how it looks. It feels like discipline finally becoming visible.',
...
]
Los emojis se trasladan por separado porque sobreviven al cambio de idioma sin perder su función en el registro. El 27 % de los ejemplos del cliente tiene alguno. Un conjunto en inglés sin emojis habría enseñado al modelo una voz más plana que la que usa el cliente.
El README del proyecto deja la regla escrita para que mi yo del futuro no la rompa en silencio:
No uses solo los ejemplos en español para evaluar o entrenar VIOFY en inglés. El conjunto en inglés busca sonar como comentarios sociales naturales de Estados Unidos, no como una traducción literal.
Tres cifras que muestran lo que los datos no pueden enseñar
Una pieza de portafolio podría dejar esto fuera. Por eso merece estar aquí.
400 ejemplos en español se reducen a 162 textos únicos en inglés. La adaptación recorre ocho plantillas por categoría, seleccionadas por el número del ejemplo. Cincuenta ejemplos del cliente por categoría se convierten en ocho frases en inglés que se repiten. Una aparece siete veces. El conjunto en español tiene 398 textos distintos de 400; el de inglés, 162 de 400.
Eso demuestra un estilo, pero no aporta mucha diversidad. Un modelo entrenado así aprende ocho formas muy bien y casi nada del espacio entre ellas. Los resultados genéricos que he obtenido apuntan a esa cifra.
sentiment_type es positive en las 400 filas. Se deriva de una tabla de palabras clave y, cuando lo construí, lo llamé una etiqueta débil. En la práctica es una constante con nombre de columna. Un campo que nunca cambia no enseña nada, ocupa una columna y servía sobre todo para que el CSV pareciera más un conjunto de datos de lo que era.
Cuatro de doce categorías no tienen ejemplos del cliente. El mapa de pautas cubre doce; el cliente escribió para ocho. celebration, nightlife, food y general_lifestyle no tienen ninguno.
La última importa más que las otras tres juntas porque general_lifestyle es la categoría de reserva. Cuando el modelo de visión tiene poca confianza o no entiende la escena, el pipeline elige general_lifestyle en lugar de transmitir una categoría dudosa. La ruta que toma el sistema cuando menos certeza tiene lleva precisamente a una categoría que el redactor nunca vio en el entrenamiento.
No lo diseñé así. Lo encontré al contar filas para esta nota. La lógica de reserva y los datos de entrenamiento se construyeron con semanas de diferencia. Cada parte tenía sentido por separado y nadie estaba mirando ambas a la vez.
Qué haría distinto
«Recopilar más datos» es la respuesta habitual, y cuesta dinero que no tengo.
Escribir primero los ejemplos adaptados y después el parser. El parser es determinista, se puede probar y disfruté construyéndolo. Las 162 frases en inglés son el producto real, y las generé desde una tabla de plantillas porque era la parte rápida. Dediqué el cuidado a la parte equivocada.
Auditar las columnas derivadas antes de entrenar. Un value_counts() sobre sentiment_type habría mostrado 400 valores idénticos en un segundo. Nunca lo ejecuté porque había construido el campo y asumí que sabía lo que contenía.
Contrastar los datos con la ejecución real. El conjunto de entrenamiento y el pipeline de inferencia forman un solo sistema. Nada en mi repositorio obligaba a que las categorías del mapa de pautas coincidieran con las que tienen ejemplos. Por eso no coinciden.
El fine-tuning es una pequeña parte de trabajo bien documentado apoyada sobre muchas decisiones sin documentar acerca de lo que significan los datos. Los tutoriales cubren la primera porque es la parte que se puede generalizar.
La segunda es el trabajo.
Un pequeño voto de confianza
¿Valió la pena leer esta nota?