IA Intermedio (IA aplicada / MLOps ligero)

📊 Eval Mínimo para Prompts: La herramienta que faltaba para medir lo que realmente mejora en tu IA

Guí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 …

Publicado: 30/10/2025 Por: Juan Felipe Orozco Cortés Duración: Lectura 7–9 min · Puesta en marcha 15–20 min Nivel: Intermedio (IA aplicada / MLOps ligero)
Contenido del tutorial

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.
Enfoque: minimalista, 100 % local, licencia MIT. Sin APIs externas, sin magia negra: sólo datos, reglas y un reporte interpretable.

📁 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

Archivo: data/gold.csv
id,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$
Claves: exact para coincidencia literal (normalizada). regex para formatos/variantes con anclas ^/$.

2) Salidas reales

Archivo: data/pred.csv
id,output
1,Colombia
2,4
3,Sí
4,2025-10-29
5,positivo
Datos honestos ⇒ métricas honestas.

3) Motor de comparación

Archivo: eval.py
import 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.

Idea central: para cada 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í “” 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.

Buen detalle: la DP está optimizada en memoria usando un solo vector que se actualiza por fila.
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.

Se calcula sobre las cadenas normalizadas para evitar sesgos por mayúsculas/espacios.
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.

Ventaja: al usar @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.

Errores típicos capturados: id duplicado, match_type desconocido, columnas faltantes, regex mal formada.
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).

Mensajes claros: 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.

La tabla fija anchos para que columnas como 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.

Integración CI: úsalo en pipelines para cortar despliegues cuando caiga la precisión o aparezcan regex inválidas.
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.

Mantén el contrato de columnas; el resto evoluciona sin romper a tus usuarios.

4) Automatización (opcional)

Archivo: .github/workflows/eval.yml

Publica 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
🧩 Lectura guiada del flujo CI para evaluación mínima
Archivo: .github/workflows/eval.yml
1) 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.

Resultado esperado: una verificación automática de calidad con éxito/fracaso visible en la pestaña Actions.
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.

Observación: si se trabaja con ramas de características, se pueden añadir más ramas o usar filtros por rutas.
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.

Compatibilidad: Python 3.11 ofrece mejoras de rendimiento; si se necesita otra versión, se cambia 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.

Artefactos generados: un reporte de texto legible por humanos y un JSON estructurado para dashboards o parsers.
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.

Buena práctica: conservar artefactos permite comparar corridas, reproducir incidencias y alimentar métricas históricas.
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.

Diagnóstico rápido: revisar las columnas obligatorias, normalización de textos y expresiones regulares válidas.
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.

Futuro: se puede subir el JSON a un tablero (por ejemplo, un job adicional que procese métricas).
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.

Estás viendo solo el 60% del contenido. Hazte Premium para acceder al tutorial completo.

Comunidad

Comentarios y valoraciones

No hay comentarios aún. ¡Sé el primero en opinar!