📊 Eval Mínimo para Prompts: La herramienta que faltaba para medir lo que realmente mejora en tu IA
Inicia sesión para descargarGuía práctica para medir, en local y sin dependencias, si tus prompts realmente mejoran. Con gold.csv, pred.csv y eval.py obtienes aciertos por exact/regex, near-miss (Levenshtein), reporte en texto/JSON y opción de gate …
Eval Mínimo para Prompts — de intuición a evidencia
Ajustar un prompt “a ojo” no dice nada sobre su valor real. Este artículo es una guía práctica para medir con rigor, en local y sin dependencias si tus cambios realmente mejoran el sistema.
Construirás un evaluador ligero que, con tres archivos (gold.csv, pred.csv, eval.py),
verifica aciertos por exact o regex, calcula near-miss (similitud) y genera un informe claro (texto y JSON).
- Qué resuelve: conocer qué acierta, dónde falla y qué tan cerca estuvo al fallar.
- Para quién: personas que iteran prompts, RAG, agentes o fine-tuning y necesitan evidencia.
- Qué te llevas: ejecución local reproducible y, si quieres, gate de calidad en CI con GitHub Actions.
📁 Estructura del proyecto
│ <strong>eval.py</strong>
│ README.md
│ results.json
│ results.txt
│
├───.github
│ └───workflows
│ <strong>eval.yml</strong>
│
└───data
<strong>gold.csv</strong>
<strong>pred.csv</strong>
Tal cual lo tienes: eval.py en la raíz; gold.csv y pred.csv en data/; y un flujo CI en .github/workflows/eval.yml.
🧩 Tres piezas, un laboratorio
gold.csv define la verdad (qué validar y cómo). pred.csv conserva lo que realmente devolvió tu sistema. eval.py compara ambos mundos, calcula near-miss y reporta.
1) Verdad de referencia
data/gold.csvid,input,expected,match_type,pattern
1,"Extrae el país de 'Bogotá, Colombia'",Colombia,exact,
2,"¿Cuánto es 2+2?",4,exact,
3,"Responde S/N: ¿El cielo es azul?",Sí,regex,^(si|sí|SI|Sí)$
4,"Convierte 29/10/2025 a ISO (YYYY-MM-DD)",2025-10-29,regex,^\s*2025-10-29\s*$
5,"Sentimiento de 'Me encantó el servicio': positivo/negativo?",positivo,regex,^pos(i|í)tivo$
exact para coincidencia literal (normalizada). regex para formatos/variantes con anclas ^/$.
2) Salidas reales
data/pred.csvid,output
1,Colombia
2,4
3,Sí
4,2025-10-29
5,positivo
3) Motor de comparación
eval.pyimport csv, re, argparse, sys, json
from dataclasses import dataclass, asdict
from typing import Dict, Tuple, List
# -------- utilidades --------
def normalize(s: str) -> str:
return " ".join((s or "").strip().split()).casefold()
def levenshtein(a: str, b: str) -> int:
a, b = a or "", b or ""
la, lb = len(a), len(b)
if la == 0: return lb
if lb == 0: return la
dp = list(range(lb + 1))
for i in range(1, la + 1):
prev = dp[0]
dp[0] = i
ca = a[i - 1]
for j in range(1, lb + 1):
temp = dp[j]
cb = b[j - 1]
cost = 0 if ca == cb else 1
dp[j] = min(
dp[j] + 1, # deletion
dp[j - 1] + 1, # insertion
prev + cost # substitution
)
prev = temp
return dp[lb]
def near_miss(pred: str, expected: str) -> Tuple[float, int]:
# similitud normalizada 0..1 basada en Levenshtein sobre textos normalizados
p, e = normalize(pred), normalize(expected)
if e == "" and p == "":
return 1.0, 0
dist = levenshtein(p, e)
denom = max(len(p), len(e)) or 1
sim = 1.0 - (dist / denom)
return sim, dist
# -------- datos --------
@dataclass
class Case:
id: str
input: str
expected: str
match_type: str
pattern: str
@dataclass
class Verdict:
id: str
match_type: str
ok: bool
reason: str
near_miss: float
distance: int
# -------- carga y validaciones --------
REQUIRED_GOLD = ["id","input","expected","match_type","pattern"]
REQUIRED_PRED = ["id","output"]
def assert_columns(path: str, required: List[str]):
with open(path, newline='', encoding='utf-8') as f:
reader = csv.reader(f)
try:
header = next(reader)
except StopIteration:
raise ValueError(f"{path}: CSV vacío")
missing = [c for c in required if c not in header]
if missing:
raise ValueError(f"{path}: faltan columnas {missing}. Esperado {required}")
def load_gold(path: str) -> Dict[str, Case]:
assert_columns(path, REQUIRED_GOLD)
cases: Dict[str, Case] = {}
seen = set()
with open(path, newline='', encoding='utf-8') as f:
reader = csv.DictReader(f)
for i, row in enumerate(reader, start=2):
cid = (row.get("id") or "").strip()
if not cid:
raise ValueError(f"{path}: fila {i} sin 'id'")
if cid in seen:
raise ValueError(f"{path}: 'id' duplicado '{cid}' (fila {i})")
seen.add(cid)
match_type = (row.get("match_type") or "exact").strip().lower()
expected = (row.get("expected") or "").strip()
pattern = (row.get("pattern") or "").strip()
if match_type == "exact" and expected == "":
raise ValueError(f"{path}: fila {i} exact sin 'expected'")
if match_type == "regex" and pattern == "":
raise ValueError(f"{path}: fila {i} regex sin 'pattern'")
cases[cid] = Case(
id=cid,
input=row.get("input",""),
expected=expected,
match_type=match_type,
pattern=pattern
)
return cases
def load_pred(path: str) -> Dict[str, str]:
assert_columns(path, REQUIRED_PRED)
pred: Dict[str, str] = {}
seen = set()
with open(path, newline='', encoding='utf-8') as f:
reader = csv.DictReader(f)
for i, row in enumerate(reader, start=2):
cid = (row.get("id") or "").strip()
if not cid:
raise ValueError(f"{path}: fila {i} sin 'id'")
if cid in seen:
raise ValueError(f"{path}: 'id' duplicado '{cid}' (fila {i})")
seen.add(cid)
pred[cid] = (row.get("output") or "").strip()
return pred
# -------- evaluación --------
def judge(case: Case, output: str) -> Verdict:
if case.match_type == "exact":
ok = normalize(output) == normalize(case.expected)
reason = "exact" if ok else f"mismatch (expected='{case.expected}', got='{output}')"
sim, dist = near_miss(output, case.expected)
return Verdict(case.id, case.match_type, ok, reason, sim, dist)
elif case.match_type == "regex":
try:
ok = re.fullmatch(case.pattern, output or "") is not None
reason = f"regex:{case.pattern}" if ok else f"no-match (pattern={case.pattern}, got='{output}')"
except re.error as e:
ok = False
reason = f"bad-regex ({e})"
# near-miss para regex: si hay expected, medir contra expected; si no, 0.0
exp = case.expected or ""
sim, dist = near_miss(output, exp) if exp else (0.0, max(len(output or ""), 0))
return Verdict(case.id, case.match_type, ok, reason, sim, dist)
else:
return Verdict(case.id, case.match_type, False, f"unknown match_type '{case.match_type}'", 0.0, 0)
def run_eval(gold_path: str, pred_path: str):
gold = load_gold(gold_path)
pred = load_pred(pred_path)
verdicts: List[Verdict] = []
for cid, case in gold.items():
out = pred.get(cid, "")
verdicts.append(judge(case, out))
total = len(verdicts)
correct = sum(1 for v in verdicts if v.ok)
acc = (correct / total * 100.0) if total else 0.0
failures = [v for v in verdicts if not v.ok]
return verdicts, total, correct, acc, failures
def format_table(verdicts: List[Verdict]) -> str:
lines = []
lines.append("RESULTADOS POR CASO")
lines.append("-------------------")
lines.append(f"{'id':<4} {'tipo':<6} {'ok':<4} {'reason':<44} {'near_miss':<9} {'dist':<4}")
for v in verdicts:
lines.append(f"{v.id:<4} {v.match_type:<6} {str(v.ok):<4} {v.reason:<44} {v.near_miss:>1.3f} {v.distance:<4}")
return "\n".join(lines)
def format_summary(total: int, correct: int, acc: float) -> str:
lines = []
lines.append("\nRESUMEN")
lines.append("-------")
lines.append(f"Casos totales: {total}")
lines.append(f"Correctos : {correct}")
lines.append(f"Accuracy : {acc:.1f}%")
return "\n".join(lines)
def format_failures(failures: List[Verdict]) -> str:
if not failures:
return "\nSIN ERRORES 🎉"
lines = []
lines.append("\nFAILURES")
lines.append("--------")
for v in failures:
lines.append(f"- id={v.id} [{v.match_type}] -> {v.reason} (near_miss={v.near_miss:.3f}, dist={v.distance})")
return "\n".join(lines)
def main():
ap = argparse.ArgumentParser(description="Eval mínimo para prompts (exact/regex) con errores visibles y near-miss.")
ap.add_argument("--gold", default="data/gold.csv", help="Ruta a gold.csv")
ap.add_argument("--pred", default="data/pred.csv", help="Ruta a pred.csv")
ap.add_argument("--out", default="", help="Guardar salida de texto en archivo")
ap.add_argument("--json", default="", help="Guardar resultados en JSON")
ap.add_argument("--strict", action="store_true", help="Salir con código 1 si hay fallos")
args = ap.parse_args()
try:
verdicts, total, correct, acc, failures = run_eval(args.gold, args.pred)
text = "\n".join([
format_table(verdicts),
format_summary(total, correct, acc),
format_failures(failures),
""
])
print(text)
if args.out:
with open(args.out, "w", encoding="utf-8") as f:
f.write(text)
if args.json:
with open(args.json, "w", encoding="utf-8") as f:
json.dump({
"total": total,
"correct": correct,
"accuracy": acc,
"failures": [asdict(v) for v in failures],
"verdicts": [asdict(v) for v in verdicts],
}, f, ensure_ascii=False, indent=2)
if args.strict and failures:
sys.exit(1)
except Exception as e:
msg = f"ERROR FATAL: {e}"
print(msg, file=sys.stderr)
if args.out:
with open(args.out, "w", encoding="utf-8") as f:
f.write(msg+"\n")
if args.strict:
sys.exit(1)
else:
sys.exit(0)
if __name__ == "__main__":
main()
1) Panorama general
Este programa compara dos mundos: lo que debería pasar (verdades en gold.csv) y lo que pasó (salidas en pred.csv).
Produce veredictos por caso, un resumen de precisión y, si algo falla, una lista clara de errores.
id, decide ok=True/False según el tipo de coincidencia (exact o regex) y adjunta diagnóstico (motivo, similitud y distancia).2) Utilidades: normalize y levenshtein
normalize(s) limpia espacios extra y usa casefold para comparar sin importar mayúsculas/tildes; así “Sí” y “si” se tratan por igual.
levenshtein(a,b) implementa la distancia de edición clásica (O(n·m)): cuántas inserciones, borrados o sustituciones separan dos textos.
Sirve de base para medir qué tan cerca estuvo una respuesta errónea.
3) Métrica de cercanía: near_miss
Devuelve una tupla (sim, dist) donde sim ∈ [0,1] es similitud normalizada a partir de Levenshtein y dist es la distancia cruda.
Si ambos textos están vacíos, la similitud es 1.0.
4) Modelo de datos: Case y Verdict
Case encapsula cada experimento: id, input, expected, match_type, pattern.
Verdict es el resultado: tipo, ok, reason, near_miss, distance.
@dataclass, exportar a JSON es directo con asdict().5) Carga y validaciones de CSV
assert_columns verifica encabezados obligatorios y evita CSVs vacíos.
load_gold y load_pred confirman unicidad de id y reglas por tipo:
si es exact, debe existir expected; si es regex, debe existir pattern.
6) Núcleo de decisión: judge(case, output)
Para exact, compara normalizado vs. expected.
Para regex, pide coincidencia total con re.fullmatch; si falla la compilación, marca bad-regex.
En ambos, adjunta near_miss (si hay expected).
reason queda en “exact”, “regex:…”, “no-match”, “mismatch” o “unknown match_type”.7) Orquestación: run_eval y formato de salida
run_eval recorre todos los casos, junta veredictos y calcula accuracy.
Los formateadores generan una tabla alineada, un resumen global y, si aplica, el bloque de fallos.
near_miss y dist queden legibles incluso en monospace.8) Interfaz de línea de comandos
Parámetros clave: --gold, --pred, --out (texto), --json (reporte estructurado) y --strict.
Con --strict el proceso devuelve código de salida 1 si hubo fallos: ideal para CI.
9) Diagnóstico de “casos reales”
Cuando aparece unknown match_type 'colombia', el sistema te está diciendo que
el valor permitido era exact o regex. Corregir ese campo en el CSV
devuelve la precisión al 100% (según tus cinco ejemplos).
La combinación near_miss + distance te da pistas:
si hay muchos casos con similitudes altas pero ok=False, quizá usar regex sea más apropiado que exact.
10) Extensiones recomendadas
Nuevos tipos como contains (subcadenas), numeric≈ (tolerancia), o jsonpath (validar campos JSON) encajan agregando ramas en judge.
Para grandes volúmenes, se puede paralelizar la evaluación por lotes sin tocar la semántica.
4) Automatización (opcional)
.github/workflows/eval.ymlPublica results.txt y results.json como artefactos del job.
name: Eval Minimo CI
on:
push:
branches: [ "main" ]
workflow_dispatch:
jobs:
eval:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Run eval
run: |
python -V
python eval.py --gold data/gold.csv --pred data/pred.csv --out results.txt --json results.json --strict
- name: Publicar artefactos
uses: actions/upload-artifact@v4
with:
name: eval-report
path: |
results.txt
results.json
.github/workflows/eval.yml1) Propósito y nombre del flujo
El flujo se llama Eval Minimo CI. Su misión: ejecutar el evaluador (eval.py) en cada cambio relevante y publicar los reportes
(results.txt, results.json) como artefactos de la ejecución.
2) Disparadores del flujo
on.push.branches: [ "main" ] activa el flujo al subir cambios a main.
workflow_dispatch permite ejecutarlo manualmente desde la interfaz cuando se necesite una corrida ad-hoc.
3) Entorno de ejecución
El trabajo eval corre en ubuntu-latest, una VM limpia donde se instala Python 3.11 con actions/setup-python@v5.
La acción actions/checkout@v4 descarga el repositorio para acceder a eval.py y a los CSV.
python-version.4) Paso clave: ejecución del evaluador
El comando central ejecuta:
python eval.py --gold data/gold.csv --pred data/pred.csv --out results.txt --json results.json --strict
La opción --strict hace que la corrida falle (código de salida 1) cuando haya errores o casos no conformes. Esto bloquea
integraciones posteriores si la calidad no es suficiente.
5) Publicación de artefactos
actions/upload-artifact@v4 adjunta results.txt y results.json a la ejecución. Quedan disponibles para descarga
y auditoría desde la interfaz de GitHub.
6) Semántica de éxito/fracaso
Si el evaluador detecta fallos (por ejemplo, unknown match_type 'colombia' en gold.csv), el paso devuelve error
debido a --strict. El estado del trabajo queda en rojo y detiene pipelines que dependan de este check.
7) Extensiones recomendadas
• Agregar cache de Python si hubiesen dependencias (actions/cache).
• Publicar un badge de estado en el README leyendo el estado del workflow.
• Añadir una matriz de versiones de Python si se requiere compatibilidad cruzada.
8) Errores comunes y cómo interpretarlos
• Rutas inválidas en --gold/--pred ⟶ artefactos vacíos o fallo temprano.
• Encabezados incorrectos en CSV ⟶ excepción de columnas faltantes.
• regex mal formada ⟶ motivo bad-regex (...) y estado fallido.
• Artefactos no adjuntos ⟶ verificar los nombres en el paso de carga.
Comentarios y valoraciones
No hay comentarios aún. ¡Sé el primero en opinar!