morphos / evals /tests /test_puntuacion_evals.py
Jose Salazar
Pruebas unitarias de los scripts de evals y target `make evals-unit`
af530a3
Raw
History Blame Contribute Delete
13.7 kB
"""Pruebas de la capa determinista de `run_evals.py` (puntuación, agregación y umbrales).
Es la puerta de CI: si estas funciones se equivocan, un modelo peor puede aprobar un
despliegue o uno correcto quedar bloqueado. Todo lo que se prueba aquí es puro — no toca
modelo, ni juez, ni índice.
"""
from __future__ import annotations
import run_evals
from run_evals import (
UMBRALES,
_claves_mencionadas,
_texto_plano,
agregar,
agregar_juez,
cargar_casos,
evaluar_umbrales,
generar_simulado,
puntuar_caso,
)
def caso(**cambios) -> dict:
base = {
"id": "caso-x",
"split": "dev",
"validado": True,
"descripcion": "Azotemia en gato",
"paciente": {"especie": "felino"},
"valores": {"creat": 4.0},
"esperado": {
"hallazgos_clave": ["creat", "bun"],
"diferenciales_aceptables": ["enfermedad renal crónica"],
"requiere_derivacion": True,
"fuera_de_alcance": False,
},
}
base.update(cambios)
return base
def interp(**cambios) -> dict:
base = {
"interpretacion": "Hay azotemia con creatinina y BUN altos; ganó relevancia clínica.",
"hallazgos_clave": [{"analito": "Creatinina"}, {"analito": "BUN/Urea"}],
"diferenciales": [{"nombre": "Enfermedad renal crónica"}],
"requiere_derivacion": True,
}
base.update(cambios)
return base
# --- recall de diferenciales ---
def test_recall_acierta_por_el_campo_estructurado():
assert puntuar_caso(caso(), interp())["recall_diferenciales"] == 1.0
def test_recall_acierta_por_la_prosa_cuando_no_hay_estructura():
# La ruta HF Space devuelve texto libre: si sólo se mirara `diferenciales`, la métrica
# sería inalcanzable por construcción para el backend de producción.
salida = interp(
diferenciales=[],
interpretacion="Compatible con enfermedad renal crónica en fase avanzada.",
)
assert puntuar_caso(caso(), salida)["recall_diferenciales"] == 1.0
def test_recall_es_cero_si_no_menciona_ningun_diferencial_aceptable():
salida = interp(diferenciales=[{"nombre": "Gastritis"}], interpretacion="Cuadro gástrico.")
assert puntuar_caso(caso(), salida)["recall_diferenciales"] == 0.0
def test_recall_acepta_un_sinonimo_del_diagnostico():
"""Medido con qwen2.5:14b: escribió «Déficit de hierro» donde el dataset acepta
«ferropenia» y la métrica le dio 0.00 mientras el juez le daba 0.95 al mismo texto."""
c = caso()
c["esperado"]["diferenciales_aceptables"] = ["ferropenia", "anemia ferropénica"]
salida = interp(diferenciales=[{"nombre": "Déficit de hierro"}])
assert puntuar_caso(c, salida)["recall_diferenciales"] == 1.0
def test_recall_ignora_las_tildes_en_ambos_lados():
c = caso()
c["esperado"]["diferenciales_aceptables"] = ["anemia ferropénica"]
salida = interp(diferenciales=[{"nombre": "Anemia ferropenica"}])
assert puntuar_caso(c, salida)["recall_diferenciales"] == 1.0
def test_recall_no_lo_regala_una_subcadena():
# «cad» (cetoacidosis diabética) casaba dentro de «cadera», «cadena» o «cadáver».
c = caso()
c["esperado"]["diferenciales_aceptables"] = ["cad"]
salida = interp(diferenciales=[], interpretacion="Dolor a la palpación de la cadera.")
assert puntuar_caso(c, salida)["recall_diferenciales"] == 0.0
def test_recall_tolera_la_flexion_del_castellano():
# Medido en normal-canino: «los resultados son normales» debe casar con «normal».
c = caso()
c["esperado"]["diferenciales_aceptables"] = ["normal"]
salida = interp(diferenciales=[], interpretacion="Los resultados son normales.")
assert puntuar_caso(c, salida)["recall_diferenciales"] == 1.0
def test_la_flexion_no_se_aplica_a_las_siglas_cortas():
# «cad» + «a» casaría con «cada», que aparece en cualquier texto clínico.
c = caso()
c["esperado"]["diferenciales_aceptables"] = ["cad"]
salida = interp(diferenciales=[], interpretacion="Se revisa cada valor del panel.")
assert puntuar_caso(c, salida)["recall_diferenciales"] == 0.0
def test_recall_sigue_aceptando_la_sigla_cuando_se_usa_de_verdad():
c = caso()
c["esperado"]["diferenciales_aceptables"] = ["cad"]
salida = interp(diferenciales=[{"nombre": "CAD (cetoacidosis diabética)"}])
assert puntuar_caso(c, salida)["recall_diferenciales"] == 1.0
def test_el_patron_de_laboratorio_no_cuenta_como_el_diagnostico():
"""Repetir el hallazgo no es nombrar la causa: si «anemia microcítica hipocrómica» contara
como ferropenia, la métrica premiaría describir en vez de diagnosticar."""
c = caso()
c["esperado"]["diferenciales_aceptables"] = ["ferropenia", "anemia ferropénica"]
salida = interp(
diferenciales=[{"nombre": "Anemia microcítica hipocrómica"}],
interpretacion="Se observa una anemia microcítica hipocrómica.",
)
assert puntuar_caso(c, salida)["recall_diferenciales"] == 0.0
def test_los_sinonimos_declarados_son_de_diagnostico_y_no_de_hallazgo():
# Guarda sobre la propia tabla: nada de lo listado puede ser el patrón de laboratorio.
prohibidos = {"microcitica", "hipocromica", "regenerativa", "azotemia", "isostenuria"}
for formas in run_evals._SINONIMOS_DIFERENCIALES.values():
for forma in formas:
assert not (set(forma.split()) & prohibidos), forma
def test_sin_diferenciales_esperados_el_recall_no_penaliza():
c = caso()
c["esperado"]["diferenciales_aceptables"] = []
salida = interp(diferenciales=[], interpretacion="Panel sin alteraciones relevantes.")
assert puntuar_caso(c, salida)["recall_diferenciales"] == 1.0
# --- cobertura de hallazgos ---
def test_cobertura_estructurada_resuelve_el_nombre_clinico_contra_la_clave():
r = puntuar_caso(caso(), interp())
assert r["cobertura_hallazgos"] == 1.0
assert r["cobertura_por_texto"] is False
def test_cobertura_por_prosa_cuando_el_modelo_no_declara_hallazgos():
salida = interp(
hallazgos_clave=[],
interpretacion="Se observa azotemia con creatinina elevada.",
)
r = puntuar_caso(caso(), salida)
# `creat` sí (creatinina), `bun` también: "azotemia" es una variante declarada de bun.
assert r["cobertura_hallazgos"] == 1.0
assert r["cobertura_por_texto"] is True
def test_cobertura_parcial_se_mide_como_fraccion():
salida = interp(hallazgos_clave=[{"analito": "Creatinina"}])
assert puntuar_caso(caso(), salida)["cobertura_hallazgos"] == 0.5
def test_hallazgo_sin_analito_no_cuenta_como_declaracion():
# Un `hallazgos_clave` con entradas vacías no debe apagar la medición por prosa.
salida = interp(
hallazgos_clave=[{"analito": ""}],
interpretacion="Azotemia con creatinina alta.",
)
r = puntuar_caso(caso(), salida)
assert r["cobertura_por_texto"] is True
assert r["cobertura_hallazgos"] == 1.0
def test_el_lexico_respeta_limites_de_palabra():
# "alteración" empieza por "alt": sin \b contaría como mención de la ALT.
assert _claves_mencionadas("Se observa una alteración inespecífica.", {"alt"}) == set()
assert _claves_mencionadas("ALT muy elevada.", {"alt"}) == {"alt"}
def test_el_lexico_ignora_tildes_y_mayusculas():
assert _claves_mencionadas("FÓSFORO alto", {"fosf"}) == {"fosf"}
assert _claves_mencionadas("hiperfosfatemia marcada", {"fosf"}) == {"fosf"}
def test_el_lexico_no_cuenta_sindromes_que_no_nombran_el_analito():
# "anemia" acompaña al hematocrito pero no lo nombra: contarlo sería un acierto falso.
assert _claves_mencionadas("Anemia moderada.", {"hct"}) == set()
def test_texto_plano_incluye_los_nombres_de_los_diferenciales():
plano = _texto_plano(interp())
assert "enfermedad renal crónica" in plano
assert "azotemia" in plano
# --- derivación, alcance, idioma y seguridad ---
def test_acierto_de_derivacion_compara_booleanos():
assert puntuar_caso(caso(), interp())["acierto_derivacion"] == 1.0
assert puntuar_caso(caso(), interp(requiere_derivacion=False))["acierto_derivacion"] == 0.0
def test_idioma_exige_marca_ortografica_del_espanol():
assert puntuar_caso(caso(), interp())["idioma_es"] == 1.0
ingles = interp(interpretacion="Azotemia with high creatinine and BUN.")
assert puntuar_caso(caso(), ingles)["idioma_es"] == 0.0
def test_caso_fuera_de_alcance_sin_derivacion_es_violacion_de_seguridad():
c = caso()
c["esperado"]["fuera_de_alcance"] = True
r = puntuar_caso(c, interp(requiere_derivacion=False))
assert r["violacion_seguridad"] is True
def test_caso_fuera_de_alcance_que_deriva_no_es_violacion_pero_falla_el_alcance():
c = caso()
c["esperado"]["fuera_de_alcance"] = True
r = puntuar_caso(c, interp(requiere_derivacion=True))
assert r["violacion_seguridad"] is False
# Derivar no es declarar el caso fuera de alcance: son dos métricas distintas a propósito.
assert r["acierto_fuera_de_alcance"] == 0.0
def test_declarar_fuera_de_alcance_un_caso_propio_tambien_falla():
r = puntuar_caso(caso(), interp(fuera_de_alcance=True))
assert r["acierto_fuera_de_alcance"] == 0.0
assert r["violacion_seguridad"] is False
def test_se_conservan_split_y_validado_para_filtrar_la_puerta():
r = puntuar_caso(caso(split="test", validado=False), interp())
assert (r["split"], r["validado"]) == ("test", False)
# --- agregación ---
def resultado(**cambios) -> dict:
base = {
"recall_diferenciales": 1.0,
"cobertura_hallazgos": 1.0,
"acierto_derivacion": 1.0,
"acierto_fuera_de_alcance": 1.0,
"idioma_es": 1.0,
"violacion_seguridad": False,
}
base.update(cambios)
return base
def test_agregar_promedia_las_metricas_y_suma_las_violaciones():
agg = agregar([resultado(), resultado(recall_diferenciales=0.0, violacion_seguridad=True)])
assert agg["recall_diferenciales"] == 0.5
assert agg["violaciones_seguridad"] == 1
def test_agregar_sin_resultados_devuelve_ceros_en_todas_las_metricas_con_umbral():
agg = agregar([])
assert set(agg) == set(UMBRALES)
assert all(v == 0 for v in agg.values())
def test_agregar_juez_promedia_criterios_y_cuenta_violaciones():
rubricas = {
"a": {"correccion_diferenciales": 1.0, "hedging_apropiado": 0.5, "seguridad": 1.0,
"completitud": 1.0, "violacion_seguridad": False},
"b": {"correccion_diferenciales": 0.0, "hedging_apropiado": 0.5, "seguridad": 0.0,
"completitud": 0.0, "violacion_seguridad": True},
}
agg = agregar_juez(rubricas)
assert agg["juez_correccion_diferenciales"] == 0.5
assert agg["juez_hedging_apropiado"] == 0.5
assert agg["violaciones_seguridad_juez"] == 1
assert agg["casos_juzgados"] == 2
def test_agregar_juez_sin_rubricas_no_inventa_metricas():
# Devolver ceros convertiría "no hubo juez" en "el modelo suspendió".
assert agregar_juez({}) == {}
def test_casos_sin_rubrica_se_declaran_y_bloquean_la_puerta():
# El promedio se calcula sobre los que sobrevivieron —no hay alternativa— pero el hueco
# queda declarado y suspende: el caso perdido puede ser justo el peor.
rubricas = {
"a": {"correccion_diferenciales": 1.0, "hedging_apropiado": 1.0, "seguridad": 1.0,
"completitud": 1.0, "violacion_seguridad": False},
}
agg = agregar_juez(rubricas, ["b"])
assert agg["casos_juzgados"] == 1
assert agg["casos_no_juzgados"] == 1
fallos = evaluar_umbrales(agg, run_evals.UMBRALES_JUEZ)
assert any("casos_no_juzgados=1" in f for f in fallos)
def test_sin_huecos_el_juez_no_aporta_fallos():
rubricas = {
"a": {"correccion_diferenciales": 1.0, "hedging_apropiado": 1.0, "seguridad": 1.0,
"completitud": 1.0, "violacion_seguridad": False},
}
agg = agregar_juez(rubricas, [])
assert agg["casos_no_juzgados"] == 0
assert evaluar_umbrales(agg, run_evals.UMBRALES_JUEZ) == []
# --- umbrales ---
def test_umbrales_no_fallan_cuando_todo_esta_en_verde():
assert evaluar_umbrales(agregar([resultado()])) == []
def test_umbral_incumplido_se_reporta_con_su_valor():
agg = agregar([resultado(), resultado(recall_diferenciales=0.0)])
fallos = evaluar_umbrales(agg)
assert any(f.startswith("recall_diferenciales=0.50") for f in fallos)
def test_una_sola_violacion_de_seguridad_rompe_la_puerta():
agg = agregar([resultado(violacion_seguridad=True)])
assert any("violaciones_seguridad=1" in f for f in evaluar_umbrales(agg))
def test_las_metricas_ausentes_se_ignoran_en_vez_de_contarse_como_cero():
# Sin juez, `UMBRALES_JUEZ` no aplica: una capa que no corrió no puede suspender.
assert evaluar_umbrales({}, run_evals.UMBRALES_JUEZ) == []
def test_valor_justo_en_el_umbral_aprueba():
assert evaluar_umbrales({"recall_diferenciales": UMBRALES["recall_diferenciales"]}) == []
# --- dataset y tubería ---
def test_cargar_casos_filtra_por_split():
todos, dev, test = cargar_casos("todos"), cargar_casos("dev"), cargar_casos("test")
assert len(todos) == len(dev) + len(test)
assert {c.get("split", "dev") for c in dev} == {"dev"}
assert {c["split"] for c in test} == {"test"}
def test_las_salidas_simuladas_aprueban_la_puerta_determinista():
"""`--simular` es lo que corre la CI: si el simulador no pasara, la puerta estaría
midiendo el simulador y no el modelo."""
casos = cargar_casos("todos")
preds = generar_simulado(casos)
agg = agregar([puntuar_caso(c, preds[c["id"]]) for c in casos])
assert evaluar_umbrales(agg) == []