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ía | Ejemplos | Cuándo usar |
|---|---|---|
| Exact match | Accuracy, exact string match | Clasificación, QA con respuesta fija |
| Reference-based | BLEU, ROUGE | Generación de texto con referencia |
| Model-based | Faithfulness, relevance, quality | Cualquier generación de texto libre |
| Structural | Format compliance, JSON validity | Structured output |
| Semantic | BERTScore, embedding similarity | Sinó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étrica | Fortaleza | Limitación | Uso recomendado |
|---|---|---|---|
| BLEU | Precision-focused | Penaliza paráfrasis | Traducción automática |
| ROUGE-1 | Simple, interpretable | No captura estructura | Resumen, QA |
| ROUGE-2 | Captura frases | Sensible a diferencias menores | Resumen extractivo |
| ROUGE-L | Captura flujo | Más lento | Texto largo |
| BERTScore | Semántico | Costo computacional | Pará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étrica | Tipo | Automática | Ventaja | Desventaja |
|---|---|---|---|---|
| Accuracy | Exact match | Sí | Rápida, sin costo extra | Solo para respuestas fijas |
| Precision/Recall/F1 | Exact match | Sí | Informativa para desbalance | Solo clasificación |
| BLEU | Reference-based | Sí | Sin API calls | Penaliza paráfrasis válidas |
| ROUGE | Reference-based | Sí | Buena para resumen | Requiere buena referencia |
| Format compliance | Structural | Sí | Determinista, cero ambigüedad | Solo verifica estructura |
| Faithfulness | LLM-based | No (usa LLM) | Captura hallucinations | Costo y latencia |
| Relevance | LLM-based | No | Entiende semántica | Inconsistente sin rubrics |
| Quality | LLM-based | No | Evaluación holística | Bias 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
- G-Eval: NLG Evaluation using GPT-4 — Paper sobre LLM-as-judge
- BERTScore — Métrica semántica basada en BERT
- ROUGE en Python (rouge-score) — Librería oficial
- NLTK BLEU — Implementación de BLEU
- Ragas Metrics — Métricas para RAG
- OpenAI Cookbook: Evaluations — Guía oficial