← Ruta Perico
Etapa 07

Clasificar con un LLM, y medir si acierta

1ª categoría Publicada ~1 h · $0,44
07

Quedan 63 candidatas y ninguna regla las separa. Toca pedirle criterio a un modelo.

Lo que vamos a montar es un clasificador: un programa que mira cada caso y lo mete en una de dos cajas. Aquí, «esto lo dijo él» y «esto no».

Pero un clasificador sin forma de medirlo es fe, no ingeniería. Así que hay dos cosas que montar: el clasificador, y la manera de saber si acierta.

Primero el conjunto de referencia, y a mano

Antes de escribir nada automático, etiqueta el conjunto entero tú mismo. Sí/no en cada caso, más el porqué.

Eso es un conjunto de referenciagold set, en la literatura: el juego de respuestas correctas contra el que se puntúa todo lo demás. Hace el papel de los casos esperados de un test, con una diferencia incómoda: estos no los decides de antemano, los descubres leyendo los datos. Y, como se ve al final de la etapa, pueden estar mal.

data/eval/forum-gold.json
{
  "text_start": "los locos de las cumbres",
  "is_perico": false,
  "confidence": "high",
  "devices": [],
  "reason": "Rango del propio foro"
}

Ese confidence es tuyo, no del modelo: marca dónde dudaste. Es la columna más interesante del informe — un modelo que acierta con seguridad justo donde un humano titubea no es necesariamente mejor, puede ser un exceso de confianza.

Por qué 63 y no 3.000

El clasificador tiene que correr después sobre 374.000 palabras, donde verificar a mano es imposible. Se construye aquí precisamente porque son pocas y puedes comprobar cada respuesta. Si falla con 63, lo ves; con 3.000, no.

El clasificador

Dos decisiones de diseño antes del código:

Lotes, no una llamada por frase. 63 llamadas son 63 latencias y 63 veces el prompt del sistema — las instrucciones fijas que van delante de cada petición, y que se pagan enteras en cada llamada. En lotes de 25, el prompt se manda tres veces y el modelo además calibra mejor viendo el conjunto.

Salida estructurada. En vez de pedirle JSON por favor y confiar, se le pasa a la API un JSON Schema — el mismo formato que se usa para validar el body de una petición en cualquier API — y la API garantiza que la respuesta encaja. La diferencia con validar por tu cuenta es que aquí no se comprueba después de generar: se restringe lo que el modelo puede llegar a escribir, así que no hay salida malformada que reintentar.

classify.py
SCHEMA = {
    "type": "json_schema",
    "schema": {
        "type": "object",
        "properties": {
            "verdicts": {
                "type": "array",
                "items": {
                    "type": "object",
                    "properties": {
                        "id": {"type": "integer"},
                        "is_perico": {"type": "boolean"},
                        "confidence": {"type": "string",
                                       "enum": ["high", "medium", "low"]},
                        "devices": {"type": "array", "items": {
                            "type": "string",
                            "enum": ["tic", "proverb", "metaphor",
                                     "digression", "blunder"],
                        }},
                        "reason": {"type": "string"},
                    },
                    "required": ["id", "is_perico", "confidence",
                                 "devices", "reason"],
                    "additionalProperties": False,
                },
            }
        },
        "required": ["verdicts"],
        "additionalProperties": False,
    },
}

response = client.messages.create(
    model=model,
    max_tokens=16000,
    system=SYSTEM,
    output_config={"format": SCHEMA},
    messages=[{"role": "user", "content": listing}],
)

Ese devices con enum cerrado no es adorno: alimenta el test de cobertura por tipo de recurso estilístico, y sin enum el modelo inventaría etiquetas nuevas en cada lote y no se podrían agrupar.

Primera medición, y primera sorpresa

La pregunta era si bastaba el modelo barato. Probar los dos costó 31 céntimos:

ModeloAciertoRuido coladoFrases perdidasCoste
Sonnet 578%113$0,10
Opus 571%315$0,21

El modelo caro salió peor. Y los dos fallaban en la misma dirección: 13 y 15 falsos negativos frente a 1 y 3 falsos positivos.

Los dos nombres se confunden a todas horas, así que conviene fijarlos aquí. Un falso positivo es ruido que se cuela como si fuera bueno: el clasificador dijo «sí» y se equivocó — la columna «ruido colado». Un falso negativo es una frase buena que se descarta creyéndola ruido: dijo «no» y se equivocó — la columna «frases perdidas». Con trece y quince, estaban tirando material bueno a puñados.

Mirando cuáles, el patrón cantaba:

frases perdidas
"déficit de oxígeno"  →  "sintagma suelto sin marca de estilo"
"fuera de punto"      →  "expresión suelta sin contexto"
"hombre del mazo"     →  "apodo del foro"

Son sus muletillas más conocidas, descartadas por cortas.

Los dos fallos eran del prompt, no del modelo

Fallo 1: le mandaba fragmentos aislados. El extractor sacaba lo de dentro de las comillas y tiraba el mensaje que lo rodeaba. Ese mensaje es exactamente donde está la prueba:

«Me parece que podíamos hacer un recopilatorio con las frases más típicas del segoviano, a botepronto me vienen a la memoria: "déficit de oxígeno" "fuerza inusitada" "campos magnéticos"»

El contexto que se estaba tirando a la basura

Arreglarlo fue cambiar findall por finditer y guardar 220 caracteres a cada lado:

clean_forum.py
CONTEXT_CHARS = 220

for match in QUOTED.finditer(body):
    phrase = re.sub(r"\s+", " ", match.group(1)).strip()
    if not (MIN_WORDS <= len(phrase.split()) <= MAX_WORDS):
        continue
    start = max(0, match.start() - CONTEXT_CHARS)
    end = min(len(body), match.end() + CONTEXT_CHARS)
    context = re.sub(r"\s+", " ", body[start:end]).strip()
    yield phrase, context

finditer devuelve objetos con posición (.start(), .end()), no solo el texto. Es lo que permite recortar alrededor.

Fallo 2: el prompt decía «sé estricto». Literalmente: «es preferible descartar una frase buena que meter ruido». Y obedecía. El sesgo hacia el falso negativo estaba escrito por mí.

La versión corregida invierte la instrucción y explica por qué:

classify.py — fragmento del prompt
El hilo existe para recoger cosas que dijo él, así que la expectativa
por defecto es que el fragmento sea suyo.

USA EL CONTEXTO. Es donde está la prueba. Frases como "hoy ha dicho",
"otra de las suyas" o "Perico:" confirman que la cita es suya.

Marca is_perico = false solo cuando tengas motivo positivo para creer
que es otra cosa. Ante la duda, márcalo true con confidence "low" —
más adelante hay una revisión humana que puede filtrar, pero una frase
descartada aquí se pierde para siempre.
Asimetría de errores

Los dos errores no cuestan lo mismo. Un falso positivo lo caza una revisión posterior; un falso negativo desaparece sin dejar rastro. Dile al modelo cuál de los dos duele más — si no se lo dices, elige por su cuenta.

Segunda medición: 81%, y las discrepancias raras

El acierto subió a 81% y el reparto de errores se equilibró (6 y 6 en vez de 13 y 1). Pero los motivos de las frases que ahora descartaba eran llamativos:

salida del scorer
PHRASE LOST
  [high] hay que pasar por Francia para meter puertos duros
         El contexto atribuye explícitamente esta muletilla a Carlos De Andrés.
  [high] Tú eres un poco malo ¿no?
         La frase la dice Carlos de Andrés, no Perico.

No decía «no estoy seguro». Decía «el contexto dice que es de otro». Así que fuimos a mirar el contexto:

«…Y De Andrés, a pesar de sus molestas (y falsas) letanías de "hay que pasar por Francia para meter puertos duros"…»

«…y de repente empieza a partirse el culo Carlos de Andrés y le dice: "Tú eres un poco malo ¿no?"»

El modelo tenía razón. Las dos son de De Andrés.

El conjunto de referencia estaba peor que el modelo

Se comprobaron las doce discrepancias, una por una. El modelo acertó en las doce.

FragmentoYo puseEl contexto decía
«hay que pasar por Francia…»suya«De Andrés, a pesar de sus letanías de»
«quieres este compresor…»suyauna locutora en un anuncio
«en todo momento…»ruido«Su coletilla»
«Se te olvida uno. Carlos De Andrés…»ruido«Y va Perico y le suelta:»

Yo había etiquetado leyendo los fragmentos sueltos — exactamente el mismo error por el que estaba culpando al clasificador.

Corregidas las doce etiquetas, la medición real:

terminal
forum-classified-claude-sonnet-5.jsonl
  agreement: 63/63  (100%)
  false positives (noise kept):   0
  false negatives (phrase lost):  0
  on the 2 humanly ambiguous cases: 2 agree
El conjunto de referencia no es sagrado

Cuando el modelo discrepa, hay dos hipótesis: se equivoca el modelo, o se equivoca la referencia. La segunda se descarta demasiado deprisa. Aquí ganó doce de doce veces.

Detalle práctico: si el modelo te da un motivo con cada veredicto, comprobar la discrepancia cuesta un minuto. Sin motivo, no sabrías ni dónde mirar.

El scorer

Un script aparte, y esto importa: la medición no vive dentro del clasificador. Así puedes puntuar cualquier ejecución pasada sin volver a llamar a la API.

terminal
python -m perico.evals.score_classifier \
       data/clean/forum-classified-claude-sonnet-5.jsonl

Y no informa solo del porcentaje. Informa de la dirección del error (falsos positivos frente a negativos, que cuestan distinto), del texto y el motivo de cada fallo, y de qué tal fue en los casos que un humano marcó como ambiguos. Un número suelto no te dice qué arreglar.

Las decisiones que deciden el resultado

Toda esta etapa se apoya en un número: 100% de acuerdo sobre 63 frases. Ese número no salió del modelo. Salió de una lista de elecciones que se tomaron en un rato, escribiendo el script, y que en su momento no parecían elecciones:

Se eligióY con eso quedó fijado
Las 63 frases del hilo del foroEl universo entero sobre el que se mide. Si ese hilo no se parece al resto del corpus, el acierto tampoco
Lotes de 25Cuánto ve el modelo de una vez, y por tanto cómo calibra
Los cinco valores de devicesQué recursos estilísticos existen. Lo que no está en el enum no se puede etiquetar, así que no aparece en ninguna métrica
220 caracteres de contextoCuánta prueba acompaña a cada frase
Sonnet 5 y Opus 5Los dos únicos candidatos que llegaron a compararse
«Ante la duda, márcalo true»La dirección del error, como se ha visto más arriba

Cambia cualquiera de esas filas y el 100% es otro número. Ninguna se tomó como «decisión»: se tomaron escribiendo el script, en el orden en que hacían falta para que arrancara. Y ahí está el problema — las decisiones que fijan el resultado vienen disfrazadas de detalles de implementación.

El experimento de recuperación que viene en la etapa 12 se montó en diez minutos, y en esos diez minutos se decidieron tres cosas:

  • Qué modelo de embeddings. Se cogió uno multilingüe pequeño, de los que corren en el portátil, porque es gratis. Ese modelo es el mecanismo de recuperación: él solo decide qué frases vuelven para cada consulta. Nadie había comprobado qué tal se le da el español coloquial.
  • Qué significa «español llano». El prompt de paráfrasis lleva cuatro ejemplos resueltos. Esos cuatro ejemplos definen, para todo el corpus, qué es una paráfrasis: plana o rica, corta o fiel. Cámbialos y cambian con ellos todos los embeddings que vienen detrás.
  • Las diez consultas de prueba. Escritas sobre la oficina y los recados del día; ninguna sobre ciclismo. Eso es una apuesta sobre lo que va a escribir un usuario. Si la apuesta falla, el experimento mide otra cosa — y devuelve igualmente un número con muy buena pinta.

Las tres parecen fontanería. Las tres deciden de antemano el resultado del experimento que iban a servir para decidir.

Antes de ejecutar, enseña las decisiones

Antes de lanzar nada cuyo resultado vaya a zanjar una cuestión de diseño: haz la lista de las elecciones que podrían dar la vuelta a la respuesta y ponla delante de alguien. Cinco minutos, y es la única oportunidad de que alguien diga «ese modelo no vale para español coloquial» antes y no después.

Y luego guarda esa lista pegada al número. Una medición cuyas entradas no están escritas no se puede comprobar más tarde y no se puede repetir en absoluto: dentro de tres meses el número seguirá ahí y nadie sabrá contra qué se midió.

Lo difícil de esto no es la disciplina, es que estas decisiones no se anuncian. Nadie se sienta a decidir la pregunta más importante del proyecto. Se sienta a escribir un script, y la pregunta queda contestada de rebote, mientras consigue que el script arranque.

Epílogo: y después nadie las usó

El clasificador quedó validado, 63 de 63 contra la referencia, y su salida se guardó en forum-classified.jsonl. Y ahí se quedó durante semanas.

Ningún paso del pipeline leía ese fichero. La segmentación, las paráfrasis y la carga en Supabase trabajaban solo con las transcripciones, así que las 3.448 frases que acabó recuperando la app venían todas de ahí. Las del foro, que eran justamente las que los aficionados recopilaron por memorables («déficit de oxígeno», «Tira tú que a mí me da la risa»), no las podía encontrar nadie.

No saltó ninguna alarma, porque no había nada roto: cada paso hacía bien lo suyo. Lo que faltaba era el paso entre dos pasos. Se descubrió al revisar el tracker de tareas contra el código, una por una, y no contra lo que cada tarea decía de sí misma. En esa misma revisión apareció otra cosa: 12 de las 28 tareas abiertas ya estaban hechas y nadie las había cerrado.

El arreglo costó siete céntimos. De las 63 frases, 54 se quedan, porque suenan a él (en el foro no se exige que estén atribuidas, por decisión del dueño). A 49 se les escribió paráfrasis y entraron en la tabla, identificadas con source = 'forum'. Ahora, si escribes «me falta el aire», la cuarta frase que devuelve la búsqueda es «déficit de oxígeno».

Las otras cinco se quedaron fuera por algo que merece la pena mirar. Son frases de puro estilo, como la muletilla «en todo momento…» o el lapsus «Cancellara o cancellase». No dicen nada que se pueda parafrasear, así que la recuperación nunca podría encontrarlas: la búsqueda funciona por significado, y en ellas no hay significado. Su sitio no es la recuperación, es el núcleo.

Una salida que nadie consume es trabajo perdido que parece hecho

Cuando un paso termina, la pregunta no es solo si su salida es buena. También hay que preguntarse quién la lee. Un fichero correcto que ningún paso siguiente consume no aparece en ninguna prueba, porque las pruebas miran lo que existe, no lo que falta. Y tampoco en el tracker, si la tarea se cerró cuando se validó la salida y no cuando la salida llegó al producto.

Lo que se lleva uno de esta etapa

  1. El conjunto de referencia va primero. Sin él, «parece que funciona» es todo lo que puedes decir, y no basta para soltar el clasificador sobre 374.000 palabras.
  2. Cuando la salida es mala, sospecha del prompt y de los datos que le mandas antes que del modelo. Aquí el modelo caro daba peor resultado; lo que arregló la calidad fue el contexto y quitar una instrucción mía.
  3. Dile al modelo qué error duele más. Si no, elige por su cuenta y no siempre como te conviene.
  4. Pide un motivo con cada veredicto. Convierte cada discrepancia en algo comprobable en un minuto.
  5. Tu referencia también puede estar mal. Compruébala cuando el modelo discrepe, en vez de anotar un fallo.
  6. Apunta las decisiones al lado del número. Un porcentaje sin el modelo, el prompt y el conjunto que lo produjeron no es un resultado: es una anécdota.
  7. Una tarea no está hecha cuando su salida es correcta, sino cuando alguien la usa. Estas frases pasaron semanas validadas y sin llegar a ninguna parte.

Coste total de la etapa, incluidas las ejecuciones fallidas y la del modelo caro que salió peor: 44 céntimos.