Evaluación de LLMs
¿Por Qué es Difícil Evaluar LLMs?
A diferencia del ML tradicional donde hay métricas claras (accuracy, F1), evaluar LLMs es complejo porque las respuestas pueden ser correctas de múltiples formas, el lenguaje es subjetivo, y la calidad depende del contexto. Aún así, la evaluación sistemática es imprescindible para LLMOps.
Tipos de Evaluación
┌────────────────────────────────────────────┐
│ PIRÁMIDE DE EVALUACIÓN │
│ │
│ ┌───────┐ │
│ │ Human │ Costoso, lento │
│ │ Eval │ pero gold standard │
│ ┌┴───────┴┐ │
│ │LLM-as-a │ Rápido, escalable │
│ │ Judge │ buena correlación │
│ ┌┴─────────┴┐ │
│ │ Automated │ Rápido, barato │
│ │ Metrics │ pero limitado │
│ ┌┴───────────┴┐ │
│ │ Unit Tests │ Base mínima │
│ │ (assertions)│ determinista │
│ └──────────────┘ │
└────────────────────────────────────────────┘
Métricas Automatizadas
Para Texto Generado
from rouge_score import rouge_scorer
def calculate_rouge(reference: str, generated: str) -> dict:
scorer = rouge_scorer.RougeScorer(['rouge1', 'rouge2', 'rougeL'])
scores = scorer.score(reference, generated)
return {
"rouge1": scores["rouge1"].fmeasure, # Unigrams
"rouge2": scores["rouge2"].fmeasure, # Bigrams
"rougeL": scores["rougeL"].fmeasure, # Longest common subsequence
}
Para Clasificación
def evaluate_classification(predictions, ground_truth):
correct = sum(p == gt for p, gt in zip(predictions, ground_truth))
accuracy = correct / len(predictions)
# Per-class metrics
classes = set(ground_truth)
metrics = {}
for cls in classes:
tp = sum(p == cls and gt == cls for p, gt in zip(predictions, ground_truth))
fp = sum(p == cls and gt != cls for p, gt in zip(predictions, ground_truth))
fn = sum(p != cls and gt == cls for p, gt in zip(predictions, ground_truth))
precision = tp / (tp + fp) if (tp + fp) > 0 else 0
recall = tp / (tp + fn) if (tp + fn) > 0 else 0
f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0
metrics[cls] = {"precision": precision, "recall": recall, "f1": f1}
return {"accuracy": accuracy, "per_class": metrics}
LLM-as-a-Judge
Usar un LLM para evaluar las respuestas de otro LLM. Tiene buena correlación con evaluación humana.
def llm_judge(question: str, response: str, criteria: list[str]) -> dict:
criteria_text = "\n".join(f"- {c}" for c in criteria)
judge_response = client.chat.completions.create(
model="gpt-4o",
messages=[{
"role": "system",
"content": """Eres un evaluador experto. Evalúa la respuesta en cada criterio
de 1 a 5 (1=muy malo, 5=excelente). Responde en JSON."""
}, {
"role": "user",
"content": f"""Pregunta: {question}
Respuesta a evaluar: {response}
Criterios:
{criteria_text}
Evalúa cada criterio con un score de 1 a 5 y una justificación breve.
Formato JSON: {{"criterio": {{"score": N, "reason": "..."}}}}"""
}],
response_format={"type": "json_object"},
temperature=0,
)
return json.loads(judge_response.choices[0].message.content)
# Uso
result = llm_judge(
question="¿Qué es Docker?",
response="Docker es una plataforma de contenedores...",
criteria=["Precisión técnica", "Completitud", "Claridad", "Relevancia"]
)
Pairwise Comparison
def pairwise_judge(question: str, response_a: str, response_b: str) -> str:
"""Comparar dos respuestas lado a lado."""
result = client.chat.completions.create(
model="gpt-4o",
messages=[{
"role": "user",
"content": f"""Pregunta: {question}
Respuesta A: {response_a}
Respuesta B: {response_b}
¿Cuál respuesta es mejor? Responde "A", "B" o "empate".
Justifica brevemente."""
}],
temperature=0,
)
return result.choices[0].message.content
Evaluación de RAG (RAGAS)
def evaluate_rag_pipeline(test_cases: list[dict]) -> dict:
"""
Cada test_case tiene:
- question: pregunta del usuario
- ground_truth: respuesta esperada
- retrieved_docs: documentos recuperados
- generated_answer: respuesta generada
"""
metrics = {
"faithfulness": [], # ¿Se basa en los docs?
"answer_relevancy": [], # ¿Responde la pregunta?
"context_precision": [], # ¿Los docs son relevantes?
}
for tc in test_cases:
# Faithfulness: ¿La respuesta se apoya en los documentos?
faith = llm_judge(
question=f"¿La respuesta se basa fielmente en estos documentos?\n{tc['retrieved_docs']}",
response=tc["generated_answer"],
criteria=["Fidelidad al contexto"]
)
metrics["faithfulness"].append(faith)
# Answer relevancy
relevancy = llm_judge(
question=tc["question"],
response=tc["generated_answer"],
criteria=["Relevancia de la respuesta"]
)
metrics["answer_relevancy"].append(relevancy)
return {k: sum(v) / len(v) for k, v in metrics.items() if v}
Framework de Evaluación Completo
class LLMEvalFramework:
def __init__(self, model, test_suite_path):
self.model = model
self.test_suite = self._load_tests(test_suite_path)
def _load_tests(self, path):
with open(path) as f:
return json.load(f)
def run_evaluation(self) -> dict:
results = {
"total_tests": len(self.test_suite),
"passed": 0,
"failed": 0,
"scores": [],
"failures": [],
}
for test in self.test_suite:
response = self._call_model(test["input"])
score = self._score_response(test, response)
results["scores"].append(score)
if score >= test.get("threshold", 0.7):
results["passed"] += 1
else:
results["failed"] += 1
results["failures"].append({
"input": test["input"],
"expected": test.get("expected"),
"actual": response,
"score": score,
})
results["avg_score"] = sum(results["scores"]) / len(results["scores"])
results["pass_rate"] = results["passed"] / results["total_tests"]
return results
def _score_response(self, test, response) -> float:
if "exact_match" in test:
return 1.0 if response.strip() == test["exact_match"] else 0.0
if "contains" in test:
return 1.0 if test["contains"].lower() in response.lower() else 0.0
if "llm_judge" in test:
judge = llm_judge(test["input"], response, test["llm_judge"]["criteria"])
return sum(v["score"] for v in judge.values()) / (5 * len(judge))
return 0.5 # Default
Regression Testing
def regression_test(old_model, new_model, test_suite):
"""Verificar que el nuevo modelo no empeora."""
old_results = evaluate(old_model, test_suite)
new_results = evaluate(new_model, test_suite)
regressions = []
for old, new in zip(old_results, new_results):
if new["score"] < old["score"] - 0.1: # 10% de margen
regressions.append({
"test": old["test_name"],
"old_score": old["score"],
"new_score": new["score"],
})
if regressions:
print(f"⚠️ {len(regressions)} regresiones detectadas!")
else:
print("✓ Sin regresiones")
return regressions
Resumen
Evaluar LLMs requiere múltiples capas: unit tests para assertions básicas, métricas automatizadas, LLM-as-a-judge para evaluación escalable, y evaluación humana para validación final. Un pipeline de evaluación robusto es la columna vertebral de LLMOps.