Módulo 3: El eval como fitness function

2. Por qué no puedes hacer `assert` a un componente probabilístico

Descripción

Al terminar esta lección vas a entender, ejecutándolo, por qué la prueba con la que verificas cualquier código clásico no puede verificar un componente de IA, y qué la reemplaza. La prueba clásica es el assert de igualdad: le das una entrada a una función, comparas su salida contra el valor que esperabas, y si son idénticos, pasa. Con código determinista funciona siempre, porque una función normal da la misma salida para la misma entrada, sin excepción. Con un LLM se rompe en la primera corrida, porque el mismo input produce redacciones distintas cada vez —lo viste en el módulo 1—. Aquí lo vas a ver caer: un assert output == expected que aprueba una función determinista y lanza AssertionError contra un componente que respondió lo mismo con otras palabras. Y vas a ver el cambio de forma que lo salva: dejar de comparar por igualdad exacta y pasar a verificar una propiedad —que la salida contenga la información correcta—, agregando el resultado en un score en vez de un veredicto binario por caso.

Esto importa porque el error de fondo no es un detalle de sintaxis: es un modelo mental equivocado que lleva a dos desenlaces igual de malos. El primero es tests frágiles: si insistes en ==, tu suite se rompe cada vez que el modelo reformula una respuesta correcta, y terminas o borrando los tests por ruidosos, o forzando al modelo a repetir frases fijas —matando la flexibilidad que justifica usar un LLM—. El segundo, más común, es rendirse: el equipo concluye "la IA no se puede probar" y deja la calidad a la vista, sin ninguna compuerta. Los dos nacen de la misma raíz: tratar un componente probabilístico como si fuera determinista. Cambiar la forma de la prueba —de igualdad exacta a propiedad, y de binario por caso a score agregado— es lo que destraba todo el módulo, porque el score es la materia prima de la compuerta.

Conexión con el módulo: esta lección instala el problema que las cinco siguientes resuelven. Aquí ves por qué el assert clásico no sirve y aparece, por primera vez, la palabra score. La lección 3 formaliza ese score con la anatomía del eval-set —muchos casos, cada uno con su criterio, corridos juntos—. La lección 4 lo convierte en compuerta con un umbral. Las lecciones 5 y 6 la ponen a trabajar contra regresiones y en CI. La lección 7 abre los tipos de criterio de éxito —el contains que usamos aquí es solo el más simple—. En una frase: esta lección te dice por qué no puedes usar ==; el resto del módulo te da con qué sí.

La analogía: el examen de opción múltiple contra el ensayo

Piensa en dos formas de calificar un examen. La primera es un examen de opción múltiple corregido por una máquina lectora: cada respuesta es una burbuja rellenada, y el escáner compara la burbuja del alumno contra la clave —A, B, C o D—. Es una comparación de igualdad exacta: o marcaste la misma letra que la clave, o no. Rápido, objetivo, infalible... mientras las respuestas sean burbujas idénticas y comparables. La segunda es un ensayo: le pides al alumno que explique con sus palabras por qué el envío tarda de 3 a 5 días. Cada alumno escribe algo distinto —distinta redacción, distinto orden, distinta longitud— y ninguno coincide letra por letra con una "respuesta modelo". Si intentaras calificar el ensayo con el escáner de opción múltiple, todos reprobarían, porque ninguno marcó exactamente la clave. El escáner no está roto: está usando la herramienta equivocada para la pregunta equivocada.

Un LLM es una máquina de ensayos, no de burbujas. Cada respuesta es texto libre que dice lo correcto de una forma distinta cada vez. Calificarlo con assert output == expected es calificar un ensayo con el escáner de opción múltiple: reprueba respuestas correctas por no ser idénticas. Lo que hace un maestro sensato al calificar ensayos es usar una rúbrica: no exige que el alumno escriba la respuesta modelo palabra por palabra, sino que verifica que contenga las ideas clave —"¿mencionó el rango de 3 a 5 días?"—. Esa rúbrica es el criterio de propiedad de esta lección, y la nota que sale de aplicarla a muchos ensayos es el score. El módulo entero es, en el fondo, aprender a calificar ensayos con rúbrica en vez de exigir burbujas.

Ejemplo trabajado: el assert que se rompe y el criterio que lo salva

Vamos a poner las dos formas de probar lado a lado. Primero le pedimos tres veces la misma pregunta al agente de soporte simulado —"¿cuánto tarda el envío?"— y vemos que responde lo correcto de tres formas distintas. Luego intentamos verificarlo con un assert de igualdad exacta (el escáner de opción múltiple) y lo vemos romperse. Y por último aplicamos un criterio de propiedad (la rúbrica) y obtenemos un score.

Recuerda: el LLM está simulado con un stub determinista —cero red, cero API, cero claves—. La no-determinación se simula haciendo que la misma pregunta produzca una redacción distinta en cada llamada, en un ciclo fijo y reproducible.

# Leccion 02 — por que el assert exacto no puede probar un componente probabilistico
# Todo SIMULADO. Cero red, cero API, cero claves. Salida determinista.
import itertools

# --- El componente de IA, SIMULADO: misma pregunta, redaccion distinta cada vez ---
VARIANTS = [
    "El envio estandar tarda de 3 a 5 dias habiles.",
    "Normalmente tu pedido llega en 3 a 5 dias habiles.",
    "Tu envio deberia llegar en un plazo de 3 a 5 dias habiles.",
]
_calls = itertools.count()

def support_agent_stub(question):
    # STUB del LLM: la MISMA pregunta produce una redaccion distinta en cada
    # llamada (ciclo fijo, reproducible). Asi es un LLM real: no determinista.
    return VARIANTS[next(_calls) % len(VARIANTS)]

# Tres llamadas con el MISMO input.
question = "cuanto tarda el envio"
outs = [support_agent_stub(question) for _ in range(3)]
print("Mismo input, tres llamadas al componente:")
for i, o in enumerate(outs, 1):
    print(f"  llamada {i}: {o!r}")

# --- Intento 1: el assert de igualdad exacta (lo de una funcion normal) ---
EXPECTED = "El envio estandar tarda de 3 a 5 dias habiles."
print("\n-- Intento 1: assert de igualdad exacta (lo que harias con una funcion normal) --")
try:
    for o in outs:
        assert o == EXPECTED
    print("todos iguales -> PASS")
except AssertionError:
    print("AssertionError: una salida != la esperada -> el test se ROMPE")

# --- Intento 2: el criterio de PROPIEDAD (la rubrica: contiene la info clave) ---
print("\n-- Intento 2: criterio de propiedad (contiene la info clave) --")
crit = "3 a 5 dias"
passed = sum(crit in o for o in outs)
print(f"casos que cumplen el criterio '{crit}': {passed}/{len(outs)}")
print(f"score = {passed/len(outs):.2f}")

Qué esperar. Al correrlo:

Mismo input, tres llamadas al componente:
  llamada 1: 'El envio estandar tarda de 3 a 5 dias habiles.'
  llamada 2: 'Normalmente tu pedido llega en 3 a 5 dias habiles.'
  llamada 3: 'Tu envio deberia llegar en un plazo de 3 a 5 dias habiles.'

-- Intento 1: assert de igualdad exacta (lo que harias con una funcion normal) --
AssertionError: una salida != la esperada -> el test se ROMPE

-- Intento 2: criterio de propiedad (contiene la info clave) --
casos que cumplen el criterio '3 a 5 dias': 3/3
score = 1.00

Lee las tres llamadas primero. El componente respondió lo mismo las tres veces —el envío tarda de 3 a 5 días— pero lo escribió distinto cada vez: "El envío estándar tarda...", "Normalmente tu pedido llega...", "Tu envío debería llegar...". Las tres son correctas. Un cliente quedaría igual de bien atendido con cualquiera de las tres. Esa es la naturaleza de un componente probabilístico: la información es estable, la forma no.

Ahora el Intento 1, el assert output == EXPECTED. Fija la salida esperada en la primera redacción y compara las tres contra ella. La primera coincide, pero la segunda —"Normalmente tu pedido llega..."— no es idéntica, y el assert lanza AssertionError. El test se rompe. Y fíjate en lo perverso: no se rompió porque el componente falló; se rompió porque el componente respondió bien con otras palabras. Un test que reprueba respuestas correctas no está protegiéndote: te está mintiendo. Si dejaras este test en tu suite, se pondría rojo en cada deploy sin que nada esté mal, y en dos semanas alguien lo borraría por ruidoso —y con él, la única "prueba" del componente—.

El Intento 2 es la rúbrica. En vez de exigir igualdad, verifica una propiedad: ¿la respuesta contiene la frase clave "3 a 5 días"? Las tres la contienen, así que las tres pasan: 3 de 3, score = 1.00. Aquí están las dos ideas nuevas del módulo, juntas por primera vez. La primera: el criterio dejó de ser == (igualdad) y pasó a ser una propiedad (in, contiene) —la rúbrica que califica el ensayo sin exigir que sea idéntico a la respuesta modelo—. La segunda, más sutil pero más importante: el resultado dejó de ser un booleano por caso ("¿pasó este?") y pasó a ser un score agregado ("¿qué fracción del total pasó?"). Con tres casos y un criterio flexible, el score fue 1.00. Cuando tengas diez casos y algunos fallen —la lección 3—, el score será un número intermedio, y ese número es lo que la compuerta de la lección 4 comparará contra un umbral. El assert binario no te sirve para eso; el score, sí.

Profundización: de la igualdad a la propiedad, y del caso al agregado

Por qué el == es la herramienta equivocada, en términos de contrato. En el módulo 1 aprendiste que el contrato de un LLM es probabilístico: no afirmas su salida exacta, afirmas propiedades e invariantes de su salida. El assert output == expected es la encarnación en código del contrato determinista: exige la salida exacta. Usarlo contra un LLM es aplicar el contrato equivocado. No es que el assert esté mal en general —es la herramienta correcta para normalize_sku o para sumar dos números—; es que está mal aquí, porque el componente no promete igualdad, promete corrección. La prueba tiene que verificar lo que el componente promete, no lo que a ti te gustaría que prometiera.

Por qué el resultado tiene que ser un score, no un booleano. Un assert colapsa toda la información en un bit: pasó o no pasó. Para un componente probabilístico eso pierde demasiado. Imagina que corres el agente contra 100 preguntas: un componente que responde bien 95 y otro que responde bien 60 son muy distintos, pero un assert all(...) los aplasta a ambos en "falló" en cuanto uno solo de los casos no pasa. El score conserva la diferencia —0.95 contra 0.60— y esa diferencia es exactamente lo que necesitas para decidir. La calidad de un componente probabilístico no es binaria (perfecto o roto): es un grado, y el score mide el grado. Por eso el módulo entero opera sobre scores, no sobre asserts.

El score no reemplaza el criterio por caso: lo agrega. No pierdas de vista que el score se construye desde verificaciones por caso. Cada caso sí tiene un veredicto binario —cumplió el criterio o no—; el score es la fracción de casos que lo cumplieron. Es la diferencia entre "¿este alumno acertó esta pregunta?" (binario, por caso) y "¿qué calificación sacó el alumno en todo el examen?" (agregado, el score). Los dos niveles conviven: el detalle por caso te dice qué falló (útil para depurar), y el score te da el número contra el cual decides (útil para la compuerta). La lección 3 hace explícitos los dos niveles.

Dónde está la frontera con AI Engineering. Habrás notado que elegí el criterio "contiene '3 a 5 días'" sin justificar por qué esa frase y no otra, ni por qué contains y no un criterio más fino. Eso es deliberado: elegir el criterio correcto —qué frase clave importa, si contains basta o necesitas algo más semántico, cómo evitar que un criterio flojo apruebe respuestas malas— es diseño de evals, y es de AI Engineering. En este módulo el criterio viene dado, y nuestro trabajo es usarlo para producir un score y gobernar el deploy con él. Esta lección solo establece el cambio de forma —de igualdad a propiedad, de booleano a score—; qué propiedad exacta verificar en cada tarea es la otra guía.

Errores comunes

Insistir en el assert exacto y terminar con tests frágiles (de modelo mental). Qué pasa: el equipo prueba el componente con assert output == "respuesta modelo" y la suite se pone roja en cada deploy porque el modelo reformula respuestas correctas. Para "arreglarlo", o borran los tests (y se quedan sin compuerta) o fuerzan al modelo con un prompt rígido a repetir la frase exacta (y pierden la naturalidad). Por qué pasa: == es el reflejo de todo programador y funciona con el otro 99% del código. Cómo detectarlo: si tu test de un componente de IA se rompe cuando el modelo dice lo mismo con otras palabras, estás usando igualdad exacta donde va una propiedad. Cómo corregirlo: cambia == por una verificación de propiedad (contains, un schema, un juez —lección 7—) y agrega en un score.

Rendirse: "la IA no se puede probar" (de omisión). Qué pasa: tras ver el assert romperse, el equipo concluye que un componente probabilístico es inverificable por naturaleza y deja la calidad al criterio de quien mire las respuestas antes de un release. Por qué pasa: es cierto que la prueba clásica no aplica, y sin conocer el reemplazo (el score) parece que no hay alternativa. Cómo detectarlo: si la frase "la IA no se prueba, se revisa a ojo" circula en tu equipo, cayeron en este error. Cómo corregirlo: la IA se prueba, solo que con una prueba de otra forma —un eval-set que produce un score (lección 3)—; el assert exacto no sirve, pero el score sí, y es tan automatizable como un test.

Colapsar el resultado en un booleano y perder el grado (de sobre-simplificación). Qué pasa: el equipo hace el cambio a criterio de propiedad pero envuelve todo en un assert all(casos_pasan), que vuelve a colapsar el resultado en pasó/no-pasó. Con eso, un componente que responde bien el 95% y otro que responde bien el 60% se ven iguales —"falló"— porque a ambos les falló al menos un caso. Por qué pasa: all() se siente natural después de un for, y devuelve el booleano al que uno está acostumbrado. Cómo detectarlo: si tu prueba de un componente de IA responde solo "sí o no" y no un número entre 0 y 1, perdiste el grado. Cómo corregirlo: mide la fracción de casos que pasan (el score), no si todos pasan; el grado es la información que la compuerta necesita.

Ejercicios

Ejercicio 1 — Predice el veredicto. Un componente de IA responde estas tres veces a "¿puedo pagar en cuotas?": (1) "Sí, puedes pagar en cuotas sin interés con tarjeta.", (2) "Claro, aceptamos pago en cuotas con tarjeta de crédito.", (3) "Sí, ofrecemos cuotas.". Para cada una, di el veredicto bajo dos pruebas: (a) assert output == "Sí, puedes pagar en cuotas sin interés con tarjeta." y (b) el criterio de propiedad "cuotas" in output.lower(). Luego da el score bajo cada prueba.

Ver solución

Bajo (a), igualdad exacta contra la primera redacción:

  • (1) coincide → PASS
  • (2) "Claro, aceptamos..." ≠ la esperada → FAIL
  • (3) "Sí, ofrecemos cuotas." ≠ la esperada → FAIL
  • Score exacto = 1/3 = 0.33. Reprobó dos respuestas correctas solo por redactarse distinto.

Bajo (b), criterio de propiedad "cuotas" in output.lower():

  • (1) contiene "cuotas" → PASS
  • (2) contiene "cuotas" → PASS
  • (3) contiene "cuotas" → PASS
  • Score propiedad = 3/3 = 1.00. Las tres comunican lo correcto, las tres pasan.

La moraleja: la misma calidad real (tres respuestas correctas) da un score de 0.33 con igualdad exacta y de 1.00 con el criterio de propiedad. El 0.33 no mide la calidad del componente, mide qué tanto se parece su redacción a una frase arbitraria que elegiste. El criterio de propiedad mide lo que importa: si la respuesta contiene la información correcta.

Ejercicio 2 — El criterio demasiado flojo. Un compañero, tras aprender que == es muy estricto, se va al otro extremo y propone el criterio len(output) > 0 (la respuesta no está vacía) para el agente de soporte. Explica por qué este criterio es tan inútil como el ==, aunque por la razón opuesta, y qué garantiza (nada) sobre la calidad.

Ver solución

El == peca de estricto: reprueba respuestas correctas. El len(output) > 0 peca de flojo: aprueba respuestas incorrectas. Cualquier texto no vacío pasa este criterio —incluida "Lo siento, no sé", "banana", o una alucinación completa—. Un componente que respondiera pura basura, siempre que no devolviera cadena vacía, sacaría un score de 1.00 con este criterio. Eso lo hace inútil como compuerta: un gate que nunca puede fallar no protege de nada (es la "fitness function inútil" que verás en la lección 4).

El punto: un criterio de éxito tiene que ser ni tan estricto que reprueba lo correcto, ni tan flojo que aprueba lo incorrecto. Debe capturar lo que hace que una respuesta sea buena —para el agente de soporte, que contenga la información correcta que responde la pregunta—. Encontrar ese criterio justo es un oficio (y es AI Engineering); pero reconocer los dos extremos que no sirven —igualdad exacta y "no vacío"— ya es parte de saber usar el eval como compuerta.

Ejercicio 3 — ¿Función normal o componente probabilístico? Para cada una, di si la probarías con assert output == expected (igualdad exacta) o con un criterio de propiedad sobre un eval-set, y por qué. (a) Una función apply_discount(price, pct) que calcula un precio con descuento. (b) El agente de soporte que responde preguntas de clientes. (c) Una función normalize_email(s) que pasa un correo a minúsculas y quita espacios. (d) La búsqueda semántica que reordena productos por relevancia a una query.

Ver solución
  • (a) apply_discount → igualdad exacta. Es una función determinista y numérica: 100 con 10% siempre da 90.0. assert apply_discount(100, 10) == 90.0 es la herramienta correcta. La salida es única y exacta.
  • (b) El agente de soporte → criterio de propiedad sobre un eval-set. Es un componente probabilístico: responde lo correcto con redacciones distintas. == se rompería; el criterio es que la respuesta contenga la información correcta, agregado en un score.
  • (c) normalize_email → igualdad exacta. También determinista: " Ana@X.COM " siempre da "ana@x.com". assert output == "ana@x.com" es correcto.
  • (d) La búsqueda semántica → criterio de propiedad sobre un eval-set. Es probabilística: para una query, el orden exacto de los productos puede variar, pero la propiedad que importa es que los productos relevantes salgan arriba. Se evalúa con un criterio sobre un eval-set (queries con los productos que deberían aparecer), no con igualdad del orden exacto.

La regla que separa: si el componente es determinista —misma entrada, misma salida exacta— (a, c), usa igualdad exacta; si es probabilístico —misma entrada, salida que varía en forma pero debe cumplir una propiedad— (b, d), usa un criterio de propiedad sobre un eval-set y mide un score.

Resumen y siguiente paso

En esta lección viste, ejecutándolo, por qué la prueba clásica no puede verificar un componente de IA. El assert output == expected —el escáner de opción múltiple— se rompió contra un componente que respondió lo mismo con otras palabras, no porque el componente fallara sino porque respondió bien de una forma distinta. Y viste el cambio de forma que lo salva, con sus dos partes: dejar de comparar por igualdad exacta y verificar una propiedad (la rúbrica que califica el ensayo sin exigir que sea idéntico), y dejar de dar un booleano por caso para dar un score agregado (la fracción de casos que cumplen el criterio). El criterio de propiedad dio 3 de 3 —score 1.00— donde el == reprobaba respuestas correctas. Esas dos ideas —propiedad en vez de igualdad, score en vez de booleano— son la materia prima de todo lo que sigue.

Antes de avanzar deberías poder: explicar por qué el assert de igualdad es la herramienta equivocada para un componente probabilístico (verifica el contrato determinista, que el LLM no promete); distinguir un criterio demasiado estricto (==) de uno demasiado flojo (len > 0); y separar "¿este caso pasó?" (binario, por caso) de "¿qué fracción pasó?" (score, agregado).

Lo que sigue es formalizar el score. Aquí lo viste con tres corridas de una sola pregunta; en la lección 3 vas a construir la herramienta completa —el eval-set—: muchos casos distintos, cada uno con su criterio de éxito, corridos juntos contra el componente para producir un solo score. Vas a ver el eval-set del agente de soporte de Mercado ejecutado caso por caso, con su detalle y su score final (9 de 10, 0.90). Es el paso de "sé que necesito un score en vez de un assert" a "sé exactamente cómo se construye ese score, caso por caso".

Recursos