Spaces:
Sleeping
Sleeping
File size: 13,692 Bytes
af530a3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 | """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) == []
|