Módulo 7: Evaluación de Prompts

2. Métricas de Evaluación

Descripción

Accuracy, faithfulness, relevance, coherence, completeness, format compliance. Métricas automáticas vs métricas basadas en LLM. BLEU y ROUGE para generación de texto. Cuándo usar cada tipo y cómo combinarlas en un score final.


Taxonomía de Métricas

Antes de implementar métricas, necesitas entender qué tipo de métrica necesitas:

CategoríaEjemplosCuándo usar
Exact matchAccuracy, exact string matchClasificación, QA con respuesta fija
Reference-basedBLEU, ROUGEGeneración de texto con referencia
Model-basedFaithfulness, relevance, qualityCualquier generación de texto libre
StructuralFormat compliance, JSON validityStructured output
SemanticBERTScore, embedding similaritySinónimos, paráfrasis

Accuracy

La métrica más simple y directa. Solo funciona cuando hay una respuesta "correcta" única.

Implementación Básica

def accuracy(predicciones: list[str], ground_truth: list[str]) -> float:
    """Accuracy simple: predicción == ground truth (exacto)."""
    if not predicciones:
        return 0.0
    return sum(p == g for p, g in zip(predicciones, ground_truth)) / len(predicciones)

Con Normalización

El matching exacto a menudo falla por capitalización, espacios extras, o puntuación. La normalización evita falsos negativos:

import re

def normalizar(texto: str) -> str:
    """Normalizar para comparación: lowercase, strip whitespace, remove punctuation."""
    texto = texto.lower().strip()
    texto = re.sub(r'[^\w\s]', '', texto)
    texto = re.sub(r'\s+', ' ', texto)
    return texto

def accuracy_normalizada(predicciones: list[str], ground_truth: list[str]) -> float:
    """Accuracy con normalización — reduce falsos negativos."""
    return sum(
        normalizar(p) == normalizar(g)
        for p, g in zip(predicciones, ground_truth)
    ) / len(predicciones)

# Ejemplo:
preds = ["POSITIVO", " positivo ", "Positivo."]
truths = ["POSITIVO", "POSITIVO", "POSITIVO"]

print(accuracy(preds, truths))            # 0.33 — solo el primero coincide
print(accuracy_normalizada(preds, truths)) # 1.0 — todos normalizan a "positivo"

Accuracy por Categoría (Breakdown)

Para clasificadores, el accuracy global puede ocultar problemas en categorías específicas:

from collections import defaultdict

def accuracy_por_categoria(
    predicciones: list[str],
    ground_truth: list[str]
) -> dict[str, dict]:
    """
    Calcula accuracy desglosado por categoría.
    Útil para detectar que el modelo falla en una clase específica.
    """
    stats = defaultdict(lambda: {"total": 0, "correct": 0})
    
    for pred, truth in zip(predicciones, ground_truth):
        stats[truth]["total"] += 1
        if normalizar(pred) == normalizar(truth):
            stats[truth]["correct"] += 1
    
    return {
        cat: {
            "accuracy": data["correct"] / data["total"],
            "total": data["total"],
            "correct": data["correct"]
        }
        for cat, data in stats.items()
    }

# Uso:
preds = ["POS", "NEG", "POS", "NEG", "POS"]
truths = ["POS", "NEG", "NEG", "NEG", "POS"]

breakdown = accuracy_por_categoria(preds, truths)
# Output: {"POS": {"accuracy": 1.0, "total": 2}, "NEG": {"accuracy": 0.67, "total": 3}}

Precision, Recall y F1

Para clasificación binaria o multi-clase, accuracy sola puede ser engañosa (especialmente con clases desbalanceadas).

def precision_recall_f1(
    predicciones: list[str],
    ground_truth: list[str],
    clase_positiva: str
) -> dict[str, float]:
    """
    Calcula precision, recall y F1 para una clase específica.
    
    Precision: De lo que predije como positivo, ¿cuánto era realmente positivo?
    Recall: De lo que era realmente positivo, ¿cuánto predije como positivo?
    F1: Media armónica de precision y recall
    """
    tp = sum(p == clase_positiva and g == clase_positiva
             for p, g in zip(predicciones, ground_truth))
    fp = sum(p == clase_positiva and g != clase_positiva
             for p, g in zip(predicciones, ground_truth))
    fn = sum(p != clase_positiva and g == clase_positiva
             for p, g in zip(predicciones, ground_truth))
    
    precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
    recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
    f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0.0
    
    return {"precision": precision, "recall": recall, "f1": f1}

# Ejemplo con clases desbalanceadas:
# Si tu dataset tiene 90% negativos y 10% positivos,
# un modelo que siempre dice "NEGATIVO" tiene accuracy=0.90
# pero recall=0.0 para la clase positiva — useless

Faithfulness

¿El output es fiel al input? ¿El modelo inventa información (hallucination)?

Esta métrica es crítica para sistemas de resumen, extracción, o RAG.

from openai import OpenAI

client = OpenAI()

def evaluar_faithfulness(input_text: str, output: str) -> float:
    """
    Evalúa si el output contiene solo información del input.
    Retorna 1.0 si es fiel, 0.0 si hay hallucinations.
    
    Usa LLM-as-judge para esta evaluación.
    """
    prompt = f"""Eres un evaluador de faithfulness (fidelidad).

Tu tarea: determinar si el OUTPUT contiene información que NO está en el INPUT.

INPUT:
{input_text}

OUTPUT:
{output}

Instrucciones:
- Si el OUTPUT solo usa información que está explícitamente en el INPUT: responde "1"
- Si el OUTPUT inventa, asume, o añade información que NO está en el INPUT: responde "0"
- Solo responde con el número "0" o "1". Nada más.

Evaluación:"""
    
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=0,
        max_tokens=5
    )
    
    resultado = response.choices[0].message.content.strip()
    return float("1" in resultado)


def evaluar_faithfulness_detallado(input_text: str, output: str) -> dict:
    """
    Versión detallada: también explica qué se inventó.
    """
    prompt = f"""Eres un evaluador de faithfulness.

INPUT:
{input_text}

OUTPUT:
{output}

Evalúa en formato JSON:
{{
  "score": 0 o 1,
  "fiel": true o false,
  "elementos_inventados": ["lista de claims que no están en el input"],
  "justificacion": "explicación breve"
}}"""
    
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=0,
        response_format={"type": "json_object"}
    )
    
    import json
    return json.loads(response.choices[0].message.content)


# Ejemplo de uso:
documento = "La empresa fundada en 2010 tiene 500 empleados y opera en México."
resumen_malo = "La empresa fundada en 2010 tiene 500 empleados, opera en México y en toda Latinoamérica."
resumen_bueno = "La empresa, establecida en 2010, cuenta con 500 empleados y trabaja en México."

print(evaluar_faithfulness(documento, resumen_malo))   # 0.0 — inventó "toda Latinoamérica"
print(evaluar_faithfulness(documento, resumen_bueno))  # 1.0 — solo usa info del documento

Relevance

¿El output responde la pregunta o tarea que se le pidió?

def evaluar_relevance(pregunta: str, output: str, contexto: str = "") -> float:
    """
    Evalúa si el output es relevante y responde la pregunta.
    Escala: 0.0 (irrelevante) a 1.0 (perfectamente relevante)
    """
    contexto_str = f"\nContexto adicional: {contexto}" if contexto else ""
    
    prompt = f"""Eres un evaluador de relevancia.

PREGUNTA/TAREA:
{pregunta}{contexto_str}

RESPUESTA:
{output}

Evalúa qué tan bien la RESPUESTA atiende la PREGUNTA/TAREA.
Responde con un número entre 0 y 10 (sin texto adicional):
- 0-3: Irrelevante o fuera de tema
- 4-6: Parcialmente relevante
- 7-9: Relevante pero incompleto
- 10: Perfectamente relevante y completo

Score (0-10):"""
    
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=0,
        max_tokens=5
    )
    
    try:
        score_raw = response.choices[0].message.content.strip()
        score = float(score_raw) / 10.0  # Normalizar a 0-1
        return max(0.0, min(1.0, score))
    except ValueError:
        return 0.5  # Default si no puede parsear


# Ejemplo:
pregunta = "¿Cuál es la capital de Francia?"
respuesta_correcta = "La capital de Francia es París."
respuesta_incorrecta = "Francia es un país en Europa occidental conocido por su gastronomía."

print(evaluar_relevance(pregunta, respuesta_correcta))    # ~1.0
print(evaluar_relevance(pregunta, respuesta_incorrecta))  # ~0.3

Coherence y Completeness

Para textos más largos, necesitas evaluar coherencia (fluye bien) y completitud (cubre todo lo necesario).

def evaluar_coherence_completeness(
    tarea: str,
    output: str,
    criterios_completitud: list[str]
) -> dict[str, float]:
    """
    Evalúa coherencia y completitud del output.
    
    criterios_completitud: Lista de aspectos que el output debe cubrir.
    """
    criterios_str = "\n".join(f"- {c}" for c in criterios_completitud)
    
    prompt = f"""Evalúa el siguiente texto en dos dimensiones.

TAREA ORIGINAL:
{tarea}

TEXTO A EVALUAR:
{output}

CRITERIOS DE COMPLETITUD (debe cubrir todos):
{criterios_str}

Responde en JSON:
{{
  "coherence_score": 0-10,
  "completeness_score": 0-10,
  "criterios_cubiertos": ["lista de criterios que SÍ cubre"],
  "criterios_faltantes": ["lista de criterios que NO cubre"],
  "problemas_coherencia": ["lista de problemas de fluidez o estructura"]
}}"""
    
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=0,
        response_format={"type": "json_object"}
    )
    
    import json
    resultado = json.loads(response.choices[0].message.content)
    
    return {
        "coherence": resultado["coherence_score"] / 10.0,
        "completeness": resultado["completeness_score"] / 10.0,
        "criterios_cubiertos": resultado.get("criterios_cubiertos", []),
        "criterios_faltantes": resultado.get("criterios_faltantes", []),
        "problemas_coherencia": resultado.get("problemas_coherencia", [])
    }

Format Compliance

¿El output cumple el formato solicitado? Esta métrica puede verificarse programáticamente.

import json
import re

def evaluar_format_compliance(output: str, formato_esperado: str) -> dict[str, any]:
    """
    Verifica que el output cumple el formato pedido.
    
    Formatos soportados: "json", "json_array", "bullet_list", "numbered_list",
                         "markdown_table", "yaml", "email"
    """
    
    resultado = {"compliant": False, "formato": formato_esperado, "error": None}
    
    if formato_esperado == "json":
        try:
            parsed = json.loads(output)
            resultado["compliant"] = True
            resultado["parsed"] = parsed
        except json.JSONDecodeError as e:
            resultado["error"] = str(e)
    
    elif formato_esperado == "json_array":
        try:
            parsed = json.loads(output)
            resultado["compliant"] = isinstance(parsed, list)
            if not resultado["compliant"]:
                resultado["error"] = "El JSON no es un array"
        except json.JSONDecodeError as e:
            resultado["error"] = str(e)
    
    elif formato_esperado == "bullet_list":
        lines = [l.strip() for l in output.strip().split("\n") if l.strip()]
        bullet_lines = [l for l in lines if l.startswith(("-", "*", "•"))]
        resultado["compliant"] = len(bullet_lines) >= 2
        resultado["bullet_count"] = len(bullet_lines)
        if not resultado["compliant"]:
            resultado["error"] = f"Solo {len(bullet_lines)} bullets, esperaba 2+"
    
    elif formato_esperado == "numbered_list":
        lines = [l.strip() for l in output.strip().split("\n") if l.strip()]
        numbered = [l for l in lines if re.match(r'^\d+[\.\)]', l)]
        resultado["compliant"] = len(numbered) >= 2
        resultado["item_count"] = len(numbered)
    
    elif formato_esperado == "markdown_table":
        resultado["compliant"] = "|" in output and "---" in output
        if not resultado["compliant"]:
            resultado["error"] = "No se detectó tabla markdown (|...|...---...)"
    
    return resultado


# Con Pydantic para JSON con schema específico:
from pydantic import BaseModel, ValidationError
from typing import Optional

class SentimentOutput(BaseModel):
    sentiment: str  # "POSITIVO", "NEGATIVO", "NEUTRO"
    confidence: float  # 0.0 - 1.0
    explanation: Optional[str] = None

def evaluar_schema_compliance(output: str, schema_class: type[BaseModel]) -> dict:
    """Valida el output contra un Pydantic schema."""
    try:
        data = json.loads(output)
        validated = schema_class(**data)
        return {"compliant": True, "parsed": validated.model_dump()}
    except json.JSONDecodeError as e:
        return {"compliant": False, "error": f"JSON inválido: {e}"}
    except ValidationError as e:
        return {"compliant": False, "error": f"Schema inválido: {e}"}

BLEU y ROUGE

Métricas de referencia para generación de texto. Comparan el output contra una respuesta de referencia.

BLEU (Bilingual Evaluation Understudy)

Mide cuántos n-grams del output aparecen en la referencia. Originalmente para traducción.

from nltk.translate.bleu_score import sentence_bleu, SmoothingFunction
import nltk

def calcular_bleu(referencia: str, candidato: str, n: int = 4) -> float:
    """
    Calcula BLEU score entre referencia y candidato.
    
    n: n-gram máximo (1-4 típicamente)
    Retorna float 0.0-1.0
    """
    ref_tokens = referencia.lower().split()
    cand_tokens = candidato.lower().split()
    
    # Weights para n-grams (1-gram, 2-gram, etc.)
    weights = [1/n] * n
    
    # SmoothingFunction para evitar 0 cuando hay n-grams sin matches
    smoothing = SmoothingFunction().method1
    
    score = sentence_bleu(
        [ref_tokens],
        cand_tokens,
        weights=weights,
        smoothing_function=smoothing
    )
    return score


# BLEU en dataset completo:
def bleu_dataset(referencias: list[str], candidatos: list[str]) -> dict[str, float]:
    scores = [calcular_bleu(r, c) for r, c in zip(referencias, candidatos)]
    return {
        "bleu_mean": sum(scores) / len(scores),
        "bleu_min": min(scores),
        "bleu_max": max(scores)
    }

ROUGE (Recall-Oriented Understudy for Gisting Evaluation)

Más orientado a recall. Mide cuántos tokens de la referencia aparecen en el output.

from rouge_score import rouge_scorer

def calcular_rouge(referencia: str, candidato: str) -> dict[str, float]:
    """
    Calcula ROUGE-1, ROUGE-2 y ROUGE-L.
    
    ROUGE-1: overlap de unigrams
    ROUGE-2: overlap de bigrams  
    ROUGE-L: longest common subsequence
    """
    scorer = rouge_scorer.RougeScorer(
        ['rouge1', 'rouge2', 'rougeL'],
        use_stemmer=True
    )
    
    scores = scorer.score(referencia, candidato)
    
    return {
        "rouge1_f1": scores['rouge1'].fmeasure,
        "rouge2_f1": scores['rouge2'].fmeasure,
        "rougeL_f1": scores['rougeL'].fmeasure,
        "rouge1_precision": scores['rouge1'].precision,
        "rouge1_recall": scores['rouge1'].recall,
    }


# Ejemplo:
ref = "El banco central subió las tasas de interés al 5% para controlar la inflación."
cand_bueno = "El banco central incrementó las tasas al 5% con el objetivo de reducir la inflación."
cand_malo = "Las tasas fueron modificadas por las autoridades monetarias."

print(calcular_rouge(ref, cand_bueno))
# {'rouge1_f1': ~0.6, 'rouge2_f1': ~0.4, 'rougeL_f1': ~0.5}

print(calcular_rouge(ref, cand_malo))
# {'rouge1_f1': ~0.3, 'rouge2_f1': ~0.1, 'rougeL_f1': ~0.2}

Cuándo usar BLEU vs ROUGE

MétricaFortalezaLimitaciónUso recomendado
BLEUPrecision-focusedPenaliza paráfrasisTraducción automática
ROUGE-1Simple, interpretableNo captura estructuraResumen, QA
ROUGE-2Captura frasesSensible a diferencias menoresResumen extractivo
ROUGE-LCaptura flujoMás lentoTexto largo
BERTScoreSemánticoCosto computacionalParáfrasis, creatividad

Composite Score: Combinando Métricas

En producción, raramente usas una sola métrica. Lo ideal es un score compuesto:

def composite_score(
    accuracy: float,
    faithfulness: float,
    relevance: float,
    format_compliance: float,
    pesos: dict[str, float] | None = None
) -> dict[str, float]:
    """
    Score compuesto ponderado.
    
    Los pesos default son iguales, pero en tu caso de uso
    quizás faithfulness es más importante (RAG, resumen)
    o format_compliance es crítico (structured output).
    """
    if pesos is None:
        pesos = {
            "accuracy": 0.30,
            "faithfulness": 0.25,
            "relevance": 0.25,
            "format": 0.20
        }
    
    assert abs(sum(pesos.values()) - 1.0) < 0.001, "Los pesos deben sumar 1.0"
    
    composite = (
        accuracy * pesos["accuracy"] +
        faithfulness * pesos["faithfulness"] +
        relevance * pesos["relevance"] +
        format_compliance * pesos["format"]
    )
    
    return {
        "composite": composite,
        "breakdown": {
            "accuracy": accuracy,
            "faithfulness": faithfulness,
            "relevance": relevance,
            "format": format_compliance
        },
        "interpretation": (
            "EXCELENTE" if composite >= 0.90 else
            "BUENO" if composite >= 0.80 else
            "ACEPTABLE" if composite >= 0.70 else
            "MEJORAR"
        )
    }


# Ejemplo:
resultado = composite_score(
    accuracy=0.94,
    faithfulness=0.88,
    relevance=0.91,
    format_compliance=1.0,
    # Pesos específicos para un sistema RAG donde faithfulness es crítica
    pesos={"accuracy": 0.25, "faithfulness": 0.40, "relevance": 0.25, "format": 0.10}
)
print(resultado)
# {'composite': 0.919, 'interpretation': 'EXCELENTE', ...}

Dashboard de Métricas en Producción

import json
from datetime import datetime
from pathlib import Path

class MetricsDashboard:
    """Clase para trackear métricas a lo largo del tiempo."""
    
    def __init__(self, storage_path: str = "metrics_history.jsonl"):
        self.storage_path = Path(storage_path)
    
    def registrar(self, prompt_name: str, version: str, metricas: dict) -> None:
        """Registra un snapshot de métricas con timestamp."""
        registro = {
            "timestamp": datetime.now().isoformat(),
            "prompt_name": prompt_name,
            "version": version,
            **metricas
        }
        with open(self.storage_path, "a") as f:
            f.write(json.dumps(registro) + "\n")
    
    def historial(self, prompt_name: str, last_n: int = 10) -> list[dict]:
        """Retorna los últimos N registros de un prompt."""
        if not self.storage_path.exists():
            return []
        
        registros = []
        with open(self.storage_path) as f:
            for line in f:
                r = json.loads(line)
                if r["prompt_name"] == prompt_name:
                    registros.append(r)
        
        return registros[-last_n:]
    
    def detectar_tendencia(self, prompt_name: str, metrica: str) -> str:
        """Detecta si una métrica está mejorando, empeorando o estable."""
        historial = self.historial(prompt_name, last_n=5)
        
        if len(historial) < 2:
            return "INSUFICIENTE_DATOS"
        
        valores = [h.get(metrica, 0) for h in historial]
        delta = valores[-1] - valores[0]
        
        if delta > 0.05:
            return "MEJORANDO ↑"
        elif delta < -0.05:
            return "EMPEORANDO ↓"
        else:
            return "ESTABLE →"


# Uso:
dashboard = MetricsDashboard()
dashboard.registrar(
    prompt_name="clasificador_tickets",
    version="v1.2",
    metricas={"accuracy": 0.94, "faithfulness": 0.88, "format_compliance": 1.0}
)

Comparación: Métricas Automáticas vs LLM-based

MétricaTipoAutomáticaVentajaDesventaja
AccuracyExact matchRápida, sin costo extraSolo para respuestas fijas
Precision/Recall/F1Exact matchInformativa para desbalanceSolo clasificación
BLEUReference-basedSin API callsPenaliza paráfrasis válidas
ROUGEReference-basedBuena para resumenRequiere buena referencia
Format complianceStructuralDeterminista, cero ambigüedadSolo verifica estructura
FaithfulnessLLM-basedNo (usa LLM)Captura hallucinationsCosto y latencia
RelevanceLLM-basedNoEntiende semánticaInconsistente sin rubrics
QualityLLM-basedNoEvaluación holísticaBias del modelo-juez

Troubleshooting

Problema 1: Accuracy inflada artificialmente

Síntoma: Accuracy de 0.98 pero el sistema falla en producción.

Causa: Ground truth ambiguo o golden set sesgado (solo casos fáciles).

Solución:

# Auditar el golden set
def auditar_golden_set(golden_set: list[dict]) -> dict:
    """Detecta posibles problemas en un golden set."""
    categorias = {}
    for ej in golden_set:
        cat = ej.get("expected_output", "UNKNOWN")
        categorias[cat] = categorias.get(cat, 0) + 1
    
    total = len(golden_set)
    distribucion = {k: v/total for k, v in categorias.items()}
    
    # Detectar desbalance
    max_cat = max(distribucion.values())
    alerta = max_cat > 0.7  # Una categoría domina
    
    return {
        "total_ejemplos": total,
        "distribucion": distribucion,
        "alerta_desbalance": alerta,
        "recomendacion": "Balancear categorías" if alerta else "OK"
    }

Problema 2: LLM-as-judge inconsistente

Síntoma: El mismo output recibe 7/10 un día y 5/10 al día siguiente.

Causa: Temperature > 0, o rubrics ambiguos.

Solución:

# Siempre temperature=0 para judge
# Usar rubrics con ejemplos concretos (anchoring)
RUBRIC_CON_EJEMPLOS = """
Evalúa la fidelidad (0-5):
- 5: El output solo contiene información del input. Ejemplo: [ejemplo de output fiel]
- 3: El output contiene una inferencia razonable. Ejemplo: [ejemplo límite]
- 0: El output inventa datos. Ejemplo: [ejemplo de hallucination]
"""

Problema 3: Format compliance con falsos positivos

Síntoma: El modelo retorna JSON válido pero con estructura incorrecta.

Causa: Validar solo la sintaxis del JSON, no el schema.

Solución: Usar Pydantic o JSON Schema para validación estructural:

import jsonschema

SCHEMA = {
    "type": "object",
    "properties": {
        "sentiment": {"type": "string", "enum": ["POSITIVO", "NEGATIVO", "NEUTRO"]},
        "confidence": {"type": "number", "minimum": 0, "maximum": 1}
    },
    "required": ["sentiment", "confidence"]
}

def validar_json_schema(output: str, schema: dict) -> dict:
    try:
        data = json.loads(output)
        jsonschema.validate(data, schema)
        return {"valid": True, "data": data}
    except json.JSONDecodeError as e:
        return {"valid": False, "error": f"JSON inválido: {e}"}
    except jsonschema.ValidationError as e:
        return {"valid": False, "error": f"Schema inválido: {e.message}"}

Problema 4: BLEU/ROUGE muy bajos aunque el output es bueno

Síntoma: Output semánticamente correcto pero BLEU = 0.2.

Causa: BLEU/ROUGE son sensibles a diferencias lexicales aunque el significado sea igual.

Solución: Complementar con BERTScore o LLM-as-judge de relevance:

# Si BLEU < threshold, evaluar con LLM antes de rechazar
def evaluar_con_fallback(referencia: str, candidato: str) -> dict:
    bleu = calcular_bleu(referencia, candidato)
    
    if bleu >= 0.4:
        return {"score": bleu, "metodo": "bleu"}
    
    # Fallback a LLM si BLEU es bajo (posible paráfrasis válida)
    relevance = evaluar_relevance(referencia, candidato)
    return {"score": relevance, "metodo": "llm_relevance", "bleu": bleu}

Ejercicios

Ejercicio 1: Implementar accuracy con normalización

Implementa una función de accuracy que normalice las predicciones y ground truths antes de comparar. Debe manejar: mayúsculas, espacios extras y puntuación final.

Ver solución
import re

def accuracy_normalizada(predicciones: list[str], ground_truths: list[str]) -> float:
    """Accuracy con normalización completa."""
    def normalizar(texto: str) -> str:
        texto = texto.lower().strip()
        texto = re.sub(r'[^\w\s]', '', texto)  # Eliminar puntuación
        texto = re.sub(r'\s+', ' ', texto)     # Espacios extras
        return texto
    
    correctos = sum(
        normalizar(p) == normalizar(g)
        for p, g in zip(predicciones, ground_truths)
    )
    return correctos / len(predicciones)

# Test
preds = ["POSITIVO", " positivo ", "Positivo!", "positivo."]
truths = ["POSITIVO"] * 4
assert accuracy_normalizada(preds, truths) == 1.0
print("✓ Accuracy normalizada funciona")

Ejercicio 2: Evaluar faithfulness de un resumen

Toma el siguiente documento y dos resúmenes (uno fiel, uno con hallucinations). Implementa la evaluación de faithfulness y verifica que detecta correctamente cuál es cuál.

documento = """
El proyecto Apollo 11 aterrizó en la Luna el 20 de julio de 1969.
Los astronautas Neil Armstrong y Buzz Aldrin caminaron en la superficie lunar.
Michael Collins permaneció en órbita en el módulo de comando.
"""

resumen_fiel = "Apollo 11 llegó a la Luna en julio de 1969, con Armstrong y Aldrin en la superficie y Collins en órbita."
resumen_con_hallucination = "Apollo 11 llegó a la Luna en julio de 1969. Los tres astronautas caminaron por la superficie lunar juntos."
Ver solución
from openai import OpenAI
client = OpenAI()

def evaluar_faithfulness(input_text: str, output: str) -> float:
    prompt = f"""Evalúa si el OUTPUT contiene solo información del INPUT.
INPUT: {input_text}
OUTPUT: {output}
Responde "1" (fiel) o "0" (inventa información). Solo el número."""
    
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=0,
        max_tokens=5
    )
    return float("1" in response.choices[0].message.content.strip())

score_fiel = evaluar_faithfulness(documento, resumen_fiel)
score_hallucination = evaluar_faithfulness(documento, resumen_con_hallucination)

print(f"Resumen fiel: {score_fiel}")           # Debería ser 1.0
print(f"Resumen con hallucination: {score_hallucination}")  # Debería ser 0.0
# La hallucination: "los tres astronautas caminaron juntos" — Collins no caminó

Ejercicio 3: Composite score para clasificador

Implementa un composite score para un clasificador de sentimiento que evalúe accuracy, format compliance y relevance:

Ver solución
import json
from openai import OpenAI
client = OpenAI()

def evaluar_clasificador_completo(
    prompt_template: str,
    golden_set: list[dict]
) -> dict:
    """Evalúa un clasificador con múltiples métricas."""
    
    resultados = {
        "accuracy_scores": [],
        "format_scores": [],
        "total_ejemplos": len(golden_set)
    }
    
    for ejemplo in golden_set:
        prompt = prompt_template.format(texto=ejemplo["input"])
        
        response = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": prompt}],
            temperature=0,
            response_format={"type": "json_object"}
        )
        
        output = response.choices[0].message.content
        
        # Format compliance
        try:
            data = json.loads(output)
            format_ok = "sentiment" in data
            resultados["format_scores"].append(1.0 if format_ok else 0.0)
            
            # Accuracy
            if format_ok:
                pred = data["sentiment"].upper()
                truth = ejemplo["expected_output"].upper()
                resultados["accuracy_scores"].append(1.0 if pred == truth else 0.0)
        except json.JSONDecodeError:
            resultados["format_scores"].append(0.0)
            resultados["accuracy_scores"].append(0.0)
    
    accuracy = sum(resultados["accuracy_scores"]) / len(resultados["accuracy_scores"])
    format_compliance = sum(resultados["format_scores"]) / len(resultados["format_scores"])
    composite = accuracy * 0.6 + format_compliance * 0.4
    
    return {
        "accuracy": accuracy,
        "format_compliance": format_compliance,
        "composite": composite
    }

Ejercicio 4: Detectar clase problemática

Dado el siguiente resultado de clasificación, identifica qué clase tiene peor performance:

predicciones = ["POS", "NEG", "POS", "NEU", "NEG", "POS", "NEG", "NEU", "POS", "NEG"]
ground_truth  = ["POS", "NEG", "NEG", "NEU", "NEG", "POS", "POS", "NEU", "POS", "POS"]
Ver solución
from collections import defaultdict

def accuracy_por_categoria(predicciones, ground_truth):
    stats = defaultdict(lambda: {"total": 0, "correct": 0})
    for pred, truth in zip(predicciones, ground_truth):
        stats[truth]["total"] += 1
        if pred == truth:
            stats[truth]["correct"] += 1
    
    return {
        cat: {
            "accuracy": data["correct"] / data["total"],
            "total": data["total"]
        }
        for cat, data in stats.items()
    }

predicciones = ["POS", "NEG", "POS", "NEU", "NEG", "POS", "NEG", "NEU", "POS", "NEG"]
ground_truth  = ["POS", "NEG", "NEG", "NEU", "NEG", "POS", "POS", "NEU", "POS", "POS"]

resultado = accuracy_por_categoria(predicciones, ground_truth)
for cat, stats in sorted(resultado.items(), key=lambda x: x[1]["accuracy"]):
    print(f"{cat}: {stats['accuracy']:.1%} ({stats['total']} ejemplos)")

# NEG: 50.0% — clase problemática
# NEU: 100.0% — funciona bien
# POS: 80.0% — aceptable
# → La clase NEG tiene problemas, necesita más ejemplos o ajuste del prompt

Ejercicio 5: Comparar BLEU de dos prompts

Dado este golden set de resúmenes, ¿qué prompt produce mejores resúmenes según BLEU?

Ver solución
from nltk.translate.bleu_score import sentence_bleu, SmoothingFunction
from openai import OpenAI

client = OpenAI()

referencias = [
    "El mercado de valores subió un 2% impulsado por el sector tecnológico.",
    "La empresa reportó ganancias récord de 500 millones en el trimestre.",
]

prompt_a = "Resume en una oración: {texto}"
prompt_b = "Resume el siguiente texto en una oración concisa, usando las palabras clave del original: {texto}"

textos = [
    "Las acciones en el mercado de valores tuvieron un desempeño positivo hoy con un incremento del dos por ciento, liderado principalmente por las compañías del sector de tecnología que reportaron buenos resultados.",
    "La compañía anunció sus resultados financieros trimestrales mostrando ganancias sin precedentes que alcanzaron los quinientos millones de dólares, superando todas las expectativas de los analistas.",
]

def bleu_promedio(prompt_template: str, textos: list, referencias: list) -> float:
    smoothing = SmoothingFunction().method1
    scores = []
    for texto, ref in zip(textos, referencias):
        prompt = prompt_template.format(texto=texto)
        response = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": prompt}],
            temperature=0
        )
        output = response.choices[0].message.content
        score = sentence_bleu([ref.split()], output.split(), smoothing_function=smoothing)
        scores.append(score)
    return sum(scores) / len(scores)

bleu_a = bleu_promedio(prompt_a, textos, referencias)
bleu_b = bleu_promedio(prompt_b, textos, referencias)
print(f"Prompt A BLEU: {bleu_a:.3f}")
print(f"Prompt B BLEU: {bleu_b:.3f}")
print(f"Ganador: {'A' if bleu_a > bleu_b else 'B'}")

Resumen

  • Accuracy: Para clasificación y QA con respuesta fija. Normalizar siempre.
  • Precision/Recall/F1: Para clasificación desbalanceada o cuando el recall importa
  • Faithfulness: Detectar hallucinations. Crítica para RAG y resúmenes
  • Relevance: ¿El output responde la pregunta? Siempre evalúa con LLM
  • Format compliance: Para structured output. Usar Pydantic o JSON Schema
  • BLEU/ROUGE: Para generación con referencia. Complementar con métricas semánticas
  • Composite score: Combinar métricas con pesos según el caso de uso

Recursos adicionales

  1. G-Eval: NLG Evaluation using GPT-4 — Paper sobre LLM-as-judge
  2. BERTScore — Métrica semántica basada en BERT
  3. ROUGE en Python (rouge-score) — Librería oficial
  4. NLTK BLEU — Implementación de BLEU
  5. Ragas Metrics — Métricas para RAG
  6. OpenAI Cookbook: Evaluations — Guía oficial