Módulo 7: Evaluación de Prompts
3. LLM-as-Judge
Descripción
Usar un LLM para evaluar outputs de otro LLM. Rubrics detallados, escalas de scoring, comparison-based evaluation. Biases del LLM-judge y cómo mitigarlos. Cuándo confiar en el juicio del LLM.
El Problema que Resuelve
Para métricas como faithfulness, relevance o quality, no existe una función determinista que las calcule. No puedes comparar strings y detectar si hay hallucinations. No puedes parsear un JSON y saber si es "relevante".
LLM-as-judge es la solución: usas un LLM (el "juez") para evaluar el output de otro LLM (el "modelo evaluado").
Flujo LLM-as-judge:
Input → [Modelo evaluado] → Output
↓
Input + Output → [LLM Judge] → Score + Justificación
Cuándo usar LLM-as-judge vs métricas automáticas
| Situación | Usar LLM-judge | Usar métrica automática |
|---|---|---|
| Clasificación con respuesta fija | No | Sí (accuracy) |
| Resumen de texto libre | Sí | ROUGE (complemento) |
| Detección de hallucinations | Sí | No disponible |
| Respuesta a pregunta | Sí | BLEU (limitado) |
| Formato JSON | No | Sí (JSON parsing) |
| Tono y estilo | Sí | No disponible |
| Código funcional | Parcial | Ejecución + tests |
Principios de un Buen LLM-Judge
Para que el juicio del LLM sea confiable, el prompt del juez debe:
- Ser específico: Criterios claros, no "evalúa la calidad"
- Ser calibrado: Incluir ejemplos de cada nivel (anchoring)
- Ser reproducible: Temperature=0, mismo modelo siempre
- Ser consistente: Misma escala, mismos criterios en todas las evaluaciones
- Ser explicable: El juez debe justificar su score
Rubric-Based Scoring
El rubric es el documento de criterios que le das al juez. La calidad del rubric determina la calidad del juicio.
Rubric Simple (Score Global)
from openai import OpenAI
client = OpenAI()
RUBRIC_SIMPLE = """
Evalúa la respuesta en una escala del 1 al 5:
1 = Completamente incorrecta o irrelevante
2 = Mayormente incorrecta con algunos elementos útiles
3 = Parcialmente correcta pero incompleta o con errores
4 = Mayormente correcta con detalles menores incorrectos
5 = Completamente correcta, completa y relevante
Responde solo con el número (1-5). Nada más.
"""
def llm_judge_simple(pregunta: str, respuesta: str) -> int:
"""Judge simple: score del 1 al 5."""
prompt = f"""{RUBRIC_SIMPLE}
Pregunta: {pregunta}
Respuesta: {respuesta}
Score:"""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
temperature=0,
max_tokens=5
)
try:
score_text = response.choices[0].message.content.strip()
return int(score_text[0]) # Solo el primer dígito
except (ValueError, IndexError):
return 3 # Default si no puede parsear
Rubric Multi-Criterio (Detallado)
Para evaluaciones más profundas, descompone en dimensiones:
RUBRIC_DETALLADO = """
Evalúa la respuesta en 3 dimensiones (0-2 puntos cada una):
1. CORRECCIÓN (0-2):
- 0: Información factualmente incorrecta
- 1: Mayormente correcto con un error menor
- 2: Completamente correcto y verificable
2. COMPLETITUD (0-2):
- 0: No responde lo que se preguntó
- 1: Responde parcialmente, falta información importante
- 2: Responde todo lo que se preguntó
3. CLARIDAD (0-2):
- 0: Confuso, difícil de entender
- 1: Comprensible pero podría ser más claro
- 2: Muy claro y bien organizado
TOTAL POSIBLE: 6 puntos
Responde EXACTAMENTE en este formato:
CORRECCIÓN: X/2
COMPLETITUD: X/2
CLARIDAD: X/2
TOTAL: X/6
JUSTIFICACIÓN: [Una oración explicando la evaluación]
"""
def llm_judge_detallado(
pregunta: str,
respuesta: str,
ground_truth: str = ""
) -> dict:
"""Judge multi-criterio con justificación."""
gt_str = f"\nRespuesta de referencia (ground truth): {ground_truth}" if ground_truth else ""
prompt = f"""{RUBRIC_DETALLADO}
Pregunta: {pregunta}{gt_str}
Respuesta a evaluar: {respuesta}
Evaluación:"""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
temperature=0,
max_tokens=200
)
raw = response.choices[0].message.content
return parsear_rubric_detallado(raw)
def parsear_rubric_detallado(raw: str) -> dict:
"""Parsea el output del rubric detallado."""
import re
resultado = {
"correccion": 0,
"completitud": 0,
"claridad": 0,
"total": 0,
"max": 6,
"score_normalizado": 0.0,
"justificacion": "",
"raw": raw
}
# Extraer scores
correccion_match = re.search(r'CORRECCIÓN:\s*(\d)/2', raw)
completitud_match = re.search(r'COMPLETITUD:\s*(\d)/2', raw)
claridad_match = re.search(r'CLARIDAD:\s*(\d)/2', raw)
total_match = re.search(r'TOTAL:\s*(\d)/6', raw)
just_match = re.search(r'JUSTIFICACIÓN:\s*(.+)', raw)
if correccion_match:
resultado["correccion"] = int(correccion_match.group(1))
if completitud_match:
resultado["completitud"] = int(completitud_match.group(1))
if claridad_match:
resultado["claridad"] = int(claridad_match.group(1))
if total_match:
resultado["total"] = int(total_match.group(1))
else:
resultado["total"] = resultado["correccion"] + resultado["completitud"] + resultado["claridad"]
resultado["score_normalizado"] = resultado["total"] / 6.0
if just_match:
resultado["justificacion"] = just_match.group(1).strip()
return resultado
Rubric con Anchoring (Ejemplos de Calibración)
El anchoring es la técnica más efectiva para reducir la varianza del juez:
RUBRIC_CON_ANCHORING = """
Evalúa la FIDELIDAD de un resumen respecto al texto original (0-5).
EJEMPLOS DE CALIBRACIÓN:
Score 5 — El resumen solo usa información del texto original:
Original: "Apple fundó en 1976 en California. Steve Jobs fue cofundador."
Resumen: "Apple fue fundada en 1976 en California, con Steve Jobs como cofundador."
→ Score: 5 ✓
Score 3 — El resumen hace inferencias razonables pero no verificables:
Original: "Apple fundó en 1976. En los 80s lanzó el Mac."
Resumen: "Apple, fundada en 1976, fue pionera en computadoras personales con el Mac."
→ Score: 3 (inferencia de "pionera" no está en el texto)
Score 1 — El resumen inventa información:
Original: "Apple fundó en 1976 en California."
Resumen: "Apple fundada en 1976 en California, actualmente la empresa más valiosa del mundo."
→ Score: 1 (no hay info de "más valiosa del mundo")
Ahora evalúa:
Original: {original}
Resumen: {resumen}
Responde SOLO: "Score: X/5" donde X es tu evaluación.
"""
def judge_con_anchoring(original: str, resumen: str) -> float:
"""LLM judge con anchoring para máxima consistencia."""
prompt = RUBRIC_CON_ANCHORING.format(original=original, resumen=resumen)
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
temperature=0,
max_tokens=20
)
raw = response.choices[0].message.content
import re
match = re.search(r'Score:\s*(\d)', raw)
if match:
return int(match.group(1)) / 5.0
return 0.5
Comparison-Based Evaluation
En lugar de puntuar en absoluto, compara dos outputs directamente. Es más confiable para detectar cuál es mejor.
def compare_responses(
pregunta: str,
resp_a: str,
resp_b: str,
criterios: str = "corrección, completitud y claridad"
) -> dict:
"""
Compara dos respuestas y determina cuál es mejor.
Retorna: "A", "B", o "EMPATE"
"""
prompt = f"""Compara estas dos respuestas a la misma pregunta.
Evalúa basándote en: {criterios}
Pregunta: {pregunta}
Respuesta A:
{resp_a}
Respuesta B:
{resp_b}
¿Cuál respuesta es mejor?
- Responde "A" si la Respuesta A es claramente mejor
- Responde "B" si la Respuesta B es claramente mejor
- Responde "EMPATE" si son equivalentes en calidad
Responde SOLO con: A, B, o EMPATE"""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
temperature=0,
max_tokens=10
)
resultado = response.choices[0].message.content.strip().upper()
# Normalizar respuesta
if "EMPATE" in resultado:
winner = "EMPATE"
elif resultado.startswith("A"):
winner = "A"
elif resultado.startswith("B"):
winner = "B"
else:
winner = "EMPATE" # Default conservador
return {
"winner": winner,
"raw": resultado,
"resp_a": resp_a[:100] + "...",
"resp_b": resp_b[:100] + "..."
}
def tournament_evaluation(
pregunta: str,
respuestas: dict[str, str]
) -> dict[str, int]:
"""
Torneo round-robin entre N respuestas.
Cada par se compara, ganador gana un punto.
respuestas: {"nombre": "texto_respuesta"}
"""
from itertools import combinations
puntos = {nombre: 0 for nombre in respuestas}
for (nombre_a, resp_a), (nombre_b, resp_b) in combinations(respuestas.items(), 2):
resultado = compare_responses(pregunta, resp_a, resp_b)
if resultado["winner"] == "A":
puntos[nombre_a] += 1
elif resultado["winner"] == "B":
puntos[nombre_b] += 1
else: # EMPATE
puntos[nombre_a] += 0.5
puntos[nombre_b] += 0.5
return dict(sorted(puntos.items(), key=lambda x: x[1], reverse=True))
# Ejemplo: Comparar 3 versiones de un prompt
pregunta = "¿Cómo funciona el machine learning?"
respuestas = {
"zero_shot": "El machine learning es...",
"few_shot": "El machine learning aprende...",
"cot": "Para entender machine learning, primero...",
}
ranking = tournament_evaluation(pregunta, respuestas)
print("Ranking:", ranking)
# Ejemplo: {"cot": 2, "few_shot": 1, "zero_shot": 0}
Biases del LLM-Judge y Cómo Mitigarlos
El LLM-judge no es imparcial. Tiene biases conocidos que pueden distorsionar los resultados.
Bias 1: Length Bias (Verbosity Bias)
El juez tiende a preferir respuestas más largas, asumiendo que más texto = más información.
Ejemplo:
Respuesta A: "Python es mejor para ML debido a su ecosistema de librerías."
Respuesta B: "Python es una opción razonable para ML, aunque también hay otras opciones como R y Julia que tienen sus propias fortalezas dependiendo del caso de uso específico que se esté considerando."
Sin bias correction: Judge prefiere B (más larga)
Con bias correction: A puede ser mejor (más concisa y directa)
Mitigación:
RUBRIC_ANTI_LENGTH_BIAS = """
IMPORTANTE: Evalúa el CONTENIDO, no la longitud.
Una respuesta corta y precisa es MEJOR que una respuesta larga con relleno.
Penaliza activamente las respuestas que añaden palabras innecesarias.
Criterio de calidad: ¿Cada oración añade información útil? Si no, reduce el score.
"""
def judge_sin_length_bias(pregunta: str, respuesta: str) -> float:
"""Judge con instrucciones explícitas contra length bias."""
prompt = f"""{RUBRIC_ANTI_LENGTH_BIAS}
Evalúa (0-10): ¿Cuán bien responde esta respuesta la pregunta?
Pregunta: {pregunta}
Respuesta: {respuesta}
Score (0-10):"""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
temperature=0,
max_tokens=5
)
try:
return float(response.choices[0].message.content.strip()) / 10.0
except ValueError:
return 0.5
Bias 2: Position Bias
En comparison-based, el juez tiende a preferir la primera respuesta (si ambas son similares).
Mitigación: Evaluar en ambas direcciones y promediar:
def compare_sin_position_bias(
pregunta: str,
resp_a: str,
resp_b: str
) -> dict:
"""
Evalúa A vs B y B vs A, luego combina los resultados.
Elimina el position bias.
"""
# Comparación directa: A primero
resultado_ab = compare_responses(pregunta, resp_a, resp_b)
# Comparación inversa: B primero (posiciones intercambiadas)
resultado_ba = compare_responses(pregunta, resp_b, resp_a)
# Interpretar: en resultado_ba, "A" (primer lugar) es resp_b, "B" es resp_a
winner_ab = resultado_ab["winner"]
winner_ba_en_original = {
"A": "B", # En BA, "A" es resp_b → resp_b ganó → "B" en original
"B": "A", # En BA, "B" es resp_a → resp_a ganó → "A" en original
"EMPATE": "EMPATE"
}[resultado_ba["winner"]]
# Consolidar
if winner_ab == winner_ba_en_original:
# Ambas evaluaciones coinciden → resultado confiable
winner_final = winner_ab
confianza = "ALTA"
elif winner_ab == "EMPATE" or winner_ba_en_original == "EMPATE":
# Una es empate → usar la que no es empate
winner_final = winner_ab if winner_ba_en_original == "EMPATE" else winner_ba_en_original
confianza = "MEDIA"
else:
# Contradicción → empate conservador
winner_final = "EMPATE"
confianza = "BAJA (posible position bias detectado)"
return {
"winner": winner_final,
"confianza": confianza,
"resultado_ab": winner_ab,
"resultado_ba_normalizado": winner_ba_en_original
}
Bias 3: Self-Enhancement Bias
Si el juez es el mismo modelo que generó las respuestas, tiende a preferir su propio estilo.
Problema: Evalúas con gpt-4o-mini los outputs de gpt-4o-mini
→ El juez favorece respuestas que suenan como gpt-4o-mini
Mitigación:
- Usar un modelo diferente como juez (ej. Claude como juez de GPT)
- Si no es posible, usar un modelo más potente como juez (gpt-4o como juez de gpt-4o-mini)
- Evaluar con múltiples jueces y promediar
Bias 4: Sycophancy
El juez puede ser complaciente: si incluyes la "respuesta correcta" en el prompt, tenderá a dar un score alto independientemente de la calidad real.
Mitigación:
# MAL: Contamina el juicio
prompt_contaminado = f"""
La respuesta correcta es: {ground_truth}
Evalúa si esta respuesta es correcta: {output}
"""
# BIEN: Ground truth como referencia, no como "respuesta correcta"
prompt_limpio = f"""
Contexto de referencia (para verificar hechos): {ground_truth}
Evalúa la calidad de esta respuesta sin considerar si coincide exactamente con la referencia:
{output}
"""
Evaluación con Múltiples Jueces
Para mayor confiabilidad, usa múltiples llamadas o múltiples modelos:
def judge_con_consenso(
pregunta: str,
respuesta: str,
n_jueces: int = 3
) -> dict:
"""
Evalúa N veces y calcula consenso.
Reduce varianza por aleatoriedad residual.
Nota: Aunque usamos temperature=0, puede haber variación mínima.
En modelos más recientes, temperatura 0 no es 100% determinista.
"""
scores = []
for i in range(n_jueces):
prompt = f"""Evalúa (1-10) cuán bien responde la respuesta a la pregunta.
Solo el número.
Pregunta: {pregunta}
Respuesta: {respuesta}
Score:"""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
temperature=0
)
try:
score = float(response.choices[0].message.content.strip())
scores.append(score / 10.0)
except ValueError:
pass
if not scores:
return {"score": 0.5, "variance": 0, "n": 0}
mean = sum(scores) / len(scores)
variance = sum((s - mean) ** 2 for s in scores) / len(scores)
return {
"score": mean,
"variance": variance,
"scores": scores,
"n": len(scores),
"confianza": "ALTA" if variance < 0.01 else "MEDIA" if variance < 0.04 else "BAJA"
}
Validar el LLM-Judge
El juez también necesita ser evaluado. ¿Qué tan bien correlaciona con evaluación humana?
def validar_correlacion_human_judge(
evaluaciones_humanas: list[float],
evaluaciones_llm: list[float]
) -> dict:
"""
Calcula correlación de Pearson y Spearman entre evaluaciones humanas y del LLM.
Interpretación:
> 0.8: Excelente — el LLM-judge es confiable
0.6-0.8: Bueno — usar con precaución
< 0.6: Pobre — revisar el rubric
"""
from scipy import stats
pearson_r, pearson_p = stats.pearsonr(evaluaciones_humanas, evaluaciones_llm)
spearman_r, spearman_p = stats.spearmanr(evaluaciones_humanas, evaluaciones_llm)
return {
"pearson_r": pearson_r,
"pearson_p": pearson_p,
"spearman_r": spearman_r,
"spearman_p": spearman_p,
"interpretacion": (
"EXCELENTE" if pearson_r > 0.8 else
"BUENO" if pearson_r > 0.6 else
"POBRE — revisar rubric"
)
}
# Proceso de validación:
# 1. Tomar 50-100 ejemplos
# 2. Evaluar con humanos (gold standard)
# 3. Evaluar con LLM-judge
# 4. Calcular correlación
# 5. Si correlación > 0.7: el LLM-judge es confiable para este tipo de tarea
Patrones de Producción
Patrón 1: Judge como servicio
class LLMJudge:
"""Judge reutilizable para múltiples métricas."""
def __init__(self, model: str = "gpt-4o-mini"):
self.client = OpenAI()
self.model = model
def score(
self,
dimension: str,
pregunta: str,
respuesta: str,
contexto: str = ""
) -> dict:
"""
dimension: "faithfulness" | "relevance" | "quality" | "safety"
"""
rubrics = {
"faithfulness": "¿El output contiene solo información del input? 0=inventa, 1=fiel",
"relevance": "¿El output responde la pregunta? 0=irrelevante, 10=perfectamente relevante",
"quality": "¿La respuesta es útil, correcta y bien escrita? 1-5",
"safety": "¿El output es seguro y apropiado? 0=unsafe, 1=safe"
}
if dimension not in rubrics:
raise ValueError(f"Dimensión no soportada: {dimension}")
ctx_str = f"\nContexto: {contexto}" if contexto else ""
prompt = f"""{rubrics[dimension]}
Pregunta: {pregunta}{ctx_str}
Respuesta: {respuesta}
Responde solo con el número:"""
response = self.client.chat.completions.create(
model=self.model,
messages=[{"role": "user", "content": prompt}],
temperature=0,
max_tokens=10
)
raw = response.choices[0].message.content.strip()
try:
score = float(raw.split()[0])
except (ValueError, IndexError):
score = 0.5
return {
"dimension": dimension,
"score": score,
"raw": raw
}
def evaluar_completo(self, pregunta: str, respuesta: str, input_texto: str = "") -> dict:
"""Evalúa en todas las dimensiones y retorna resumen."""
resultados = {}
for dim in ["faithfulness", "relevance", "quality", "safety"]:
r = self.score(dim, pregunta, respuesta, contexto=input_texto)
resultados[dim] = r["score"]
# Normalizar a 0-1
def normalizar(score, dim):
if dim == "faithfulness": return score # ya 0-1
if dim == "relevance": return score / 10.0
if dim == "quality": return (score - 1) / 4.0
if dim == "safety": return score # ya 0-1
return score
return {dim: normalizar(score, dim) for dim, score in resultados.items()}
# Uso:
judge = LLMJudge()
scores = judge.evaluar_completo(
pregunta="¿Qué es Python?",
respuesta="Python es un lenguaje de programación interpretado, de alto nivel.",
input_texto="Python es un lenguaje creado por Guido van Rossum en 1991."
)
print(scores)
Troubleshooting
Problema 1: Juez demasiado generoso (score siempre alto)
Síntoma: El juez da 4-5/5 a casi todo.
Causa: Rubric sin calibración, o el modelo tiende a ser positivo.
Solución:
# Añadir instrucción explícita de calibración:
CALIBRACION = """
IMPORTANTE: Usa la escala COMPLETA.
- Espero que el 20% de las respuestas obtengan 1-2 (pobres)
- El 30% obtengan 3 (aceptable)
- El 40% obtengan 4 (bueno)
- Solo el 10% más excepcionales obtengan 5
Sé crítico. Una respuesta "completa" pero con errors menores es 3, no 5.
"""
Problema 2: Score inconsistente entre runs
Síntoma: El mismo input recibe scores diferentes en runs consecutivos.
Causa: Temperature > 0, o el prompt es ambiguo.
Solución:
# 1. Forzar temperature=0
# 2. Usar formato de output más estructurado
# 3. Si la varianza persiste, promediar N runs
def score_estable(pregunta, respuesta, n=3):
scores = []
for _ in range(n):
r = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": f"Evalúa (1-5): {respuesta}"}],
temperature=0
)
try:
scores.append(int(r.choices[0].message.content.strip()[0]))
except:
pass
return sum(scores) / len(scores) if scores else 0
Problema 3: Costo del judge en producción
Síntoma: El judge duplica o triplica el costo de la pipeline de evaluación.
Solución:
# Estrategia 1: Sampling — evalúa solo el 10% del tráfico
import random
def should_judge(rate: float = 0.1) -> bool:
return random.random() < rate
# Estrategia 2: Solo evaluar casos de bajo confidence
def judge_selectivo(output: str, confidence: float) -> bool:
return confidence < 0.8 # Solo judge si el modelo no está seguro
# Estrategia 3: Modelo más pequeño para judge cuando es posible
# gpt-4o-mini como judge es 10x más barato que gpt-4o
Problema 4: Judge que no sigue el formato pedido
Síntoma: El judge devuelve texto largo en lugar del número pedido.
Solución:
import re
def parsear_score_robusto(raw: str, max_score: float = 10.0) -> float:
"""Parser robusto para scores que pueden venir en formatos variables."""
# Intentar extraer número con regex
patterns = [
r'\b(\d+(?:\.\d+)?)/\d+', # "7/10" o "3.5/5"
r'\b(\d+(?:\.\d+)?)\b', # Número standalone
r'score[:\s]+(\d+)', # "score: 7"
r'(\d+)[/\s]*(puntos|points)', # "7 puntos"
]
for pattern in patterns:
match = re.search(pattern, raw.lower())
if match:
score = float(match.group(1))
return min(score, max_score) # Clamp al máximo
# Si falla todo, retornar score medio
return max_score / 2.0
Ejercicios
Ejercicio 1: Rubric personalizado para tu caso de uso
Crea un rubric para evaluar respuestas de un chatbot de soporte técnico. Debe evaluar: precisión técnica, claridad para no-técnicos, y accionabilidad.
Ver solución
RUBRIC_SOPORTE_TECNICO = """
Evalúa esta respuesta de soporte técnico en 3 dimensiones:
1. PRECISIÓN TÉCNICA (0-3):
- 0: Información incorrecta que podría empeorar el problema
- 1: Información vaga o incompleta
- 2: Mayormente correcto con algún detalle faltante
- 3: Técnicamente preciso y completo
2. CLARIDAD (0-3):
- 0: Un no-técnico no podría seguir las instrucciones
- 1: Algunas partes son claras pero hay jerga no explicada
- 2: Mayormente claro, un no-técnico lo entendería con esfuerzo
- 3: Un no-técnico puede seguir los pasos sin ayuda adicional
3. ACCIONABILIDAD (0-3):
- 0: No hay pasos concretos que el usuario pueda seguir
- 1: Hay un próximo paso pero no es suficientemente específico
- 2: Pasos razonablemente específicos
- 3: Pasos exactos, en orden, con lo que hacer si falla cada uno
TOTAL: 0-9 puntos
Responde:
PRECISIÓN: X/3
CLARIDAD: X/3
ACCIONABILIDAD: X/3
TOTAL: X/9
SUGERENCIA: [Una mejora concreta para la respuesta]
"""
def evaluar_soporte_tecnico(pregunta_usuario: str, respuesta_soporte: str) -> dict:
from openai import OpenAI
import re
client = OpenAI()
prompt = f"""{RUBRIC_SOPORTE_TECNICO}
Pregunta del usuario: {pregunta_usuario}
Respuesta de soporte: {respuesta_soporte}
Evaluación:"""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
temperature=0
)
raw = response.choices[0].message.content
total_match = re.search(r'TOTAL:\s*(\d+)/9', raw)
sugerencia_match = re.search(r'SUGERENCIA:\s*(.+)', raw)
return {
"total": int(total_match.group(1)) if total_match else 0,
"max": 9,
"score_normalizado": int(total_match.group(1)) / 9 if total_match else 0,
"sugerencia": sugerencia_match.group(1) if sugerencia_match else "",
"raw": raw
}
Ejercicio 2: Position bias test
Implementa un test que demuestre el position bias: toma dos respuestas donde A es claramente peor, evalúa como A vs B y como B vs A, y verifica si hay inconsistencia.
Ver solución
from openai import OpenAI
client = OpenAI()
def test_position_bias():
pregunta = "¿Cuál es la capital de Francia?"
# Respuesta claramente mejor
resp_buena = "La capital de Francia es París. Es también la ciudad más grande del país."
# Respuesta claramente peor
resp_mala = "Francia tiene ciudades. Una de ellas es importante."
def comparar(p1, p2, etiquetas):
prompt = f"""Pregunta: {pregunta}
Respuesta A: {p1}
Respuesta B: {p2}
¿Cuál es mejor? Responde: A, B, o EMPATE"""
r = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
temperature=0,
max_tokens=10
)
resultado = r.choices[0].message.content.strip().upper()
return resultado, etiquetas
# Test 1: Buena primero (esperamos "A")
r1, e1 = comparar(resp_buena, resp_mala, {"A": "buena", "B": "mala"})
# Test 2: Mala primero (esperamos "B", porque B es la buena)
r2, e2 = comparar(resp_mala, resp_buena, {"A": "mala", "B": "buena"})
print(f"Test 1 (buena=A, mala=B): Ganador = {r1}") # Debería ser A
print(f"Test 2 (mala=A, buena=B): Ganador = {r2}") # Debería ser B
# Si en Test 2 gana A (la mala), hay position bias
if r2 == "A":
print("⚠️ POSITION BIAS DETECTADO: El juez prefirió la respuesta mala cuando estaba primero")
else:
print("✓ Sin position bias evidente en este caso")
test_position_bias()
Ejercicio 3: Judge con consenso de 3 llamadas
Implementa un judge que haga 3 llamadas con temperature=0 y solo confíe en el resultado si al menos 2 de 3 coinciden.
Ver solución
from openai import OpenAI
from collections import Counter
client = OpenAI()
def judge_mayoria(pregunta: str, respuesta: str) -> dict:
"""Judge con voto por mayoría (3 llamadas)."""
scores = []
prompt = f"""Evalúa si esta respuesta es correcta y relevante.
Escala: 1=mala, 2=regular, 3=buena, 4=muy buena, 5=excelente
Solo el número.
Pregunta: {pregunta}
Respuesta: {respuesta}
Score:"""
for _ in range(3):
r = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
temperature=0,
max_tokens=5
)
try:
score = int(r.choices[0].message.content.strip()[0])
if 1 <= score <= 5:
scores.append(score)
except (ValueError, IndexError):
pass
if not scores:
return {"score": 3, "confianza": "BAJA", "scores": []}
conteo = Counter(scores)
score_mayoria = conteo.most_common(1)[0][0]
votos_mayoria = conteo.most_common(1)[0][1]
return {
"score": score_mayoria / 5.0,
"score_raw": score_mayoria,
"scores": scores,
"confianza": "ALTA" if votos_mayoria >= 2 else "BAJA",
"acuerdo": votos_mayoria >= 2
}
# Test
resultado = judge_mayoria(
"¿Qué es Python?",
"Python es un lenguaje de programación de alto nivel, interpretado y multiparadigma."
)
print(resultado)
Resumen
- Rubrics: Criterios explícitos con escala numérica y ejemplos de calibración (anchoring)
- Comparison-based: Más confiable que scoring absoluto para comparar variantes de prompt
- Biases: Length (verbosity), position, self-enhancement, sycophancy — todos mitigables
- Position bias: Evaluar A vs B y B vs A; solo confiar si coinciden
- Consenso: Múltiples llamadas o múltiples modelos reducen varianza
- Validación: Correlacionar con evaluación humana antes de usar en producción
- Costo: Sampling estratégico para reducir costo en producción
Recursos adicionales
- JudgeLM: Fine-Tuned Large Language Models are Scalable Judges — Paper sobre LLM judges
- G-Eval — Framework NLG con LLM-as-judge
- Judging the Judges: A Systematic Study — Análisis de biases en LLM judges
- MT-Bench — Benchmark usando GPT-4 como juez
- LangSmith Evaluators — Evaluadores en LangSmith