Módulo 7: Evaluación de Prompts
6. A/B Testing de Prompts
Descripción
Comparar versiones de prompts con métricas estadísticas robustas. Cálculo de sample size, significancia estadística, y effect size. Métricas a trackear: accuracy, latency, cost, user satisfaction. Implementación práctica y patrones de producción.
¿Qué es A/B Testing para Prompts?
A/B testing es el método para determinar con certeza estadística cuál de dos versiones de un prompt produce mejores resultados.
Sin A/B testing:
"El prompt B se ve mejor" → Deploy → 50% chance de empeorar en producción
Con A/B testing:
Prompt B tiene accuracy 94.2% vs 91.8% de A (p=0.02, mejora significativa) → Deploy con confianza
Cuándo Hacer A/B Testing
| Situación | A/B Testing | Alternativa |
|---|---|---|
| Cambio mayor de prompt | Sí | N/A |
| Nuevo modelo (gpt-4o-mini → gpt-4o) | Sí | N/A |
| Few-shot vs zero-shot | Sí | N/A |
| Fix de typo en prompt | No | Regression test |
| Cambio de orden de instrucciones | Sí (puede importar) | N/A |
| Nueva tarea sin baseline | No | Primero crear baseline |
Conceptos Estadísticos Esenciales
Hipótesis del A/B Test
H₀ (Nula): No hay diferencia entre el prompt A y el prompt B
H₁ (Alternativa): El prompt B es mejor que el prompt A
Para rechazar H₀ necesitamos:
- p-value < 0.05 (95% de confianza)
- Effect size suficiente (mejora real, no trivial)
- Sample size adecuado para detectar la mejora esperada
Tipos de Error
| Error | Descripción | Consecuencia |
|---|---|---|
| Tipo I (α) | Declaras que B es mejor cuando no lo es (falso positivo) | Deployar un prompt que no mejora |
| Tipo II (β) | No detectas que B es mejor cuando sí lo es (falso negativo) | No deployar una mejora real |
| Power | 1-β = probabilidad de detectar una mejora real | Quieres ≥ 80% |
Cálculo de Sample Size
El sample size es lo más ignorado del A/B testing — y es lo que invalida la mayoría de los tests.
from scipy import stats
import math
def calcular_sample_size(
baseline_rate: float,
mejora_minima_esperada: float,
alpha: float = 0.05,
power: float = 0.80
) -> dict:
"""
Calcula el sample size necesario por variante.
baseline_rate: Proporción actual (ej. 0.91 = 91% accuracy)
mejora_minima_esperada: Mejora que quieres detectar (ej. 0.03 = 3%)
alpha: Nivel de significancia (típico: 0.05)
power: Potencia del test (típico: 0.80)
Returns: n por variante
"""
p1 = baseline_rate
p2 = baseline_rate + mejora_minima_esperada
# Z-scores
z_alpha = stats.norm.ppf(1 - alpha / 2) # Two-tailed
z_beta = stats.norm.ppf(power)
# Pooled proportion
p_pool = (p1 + p2) / 2
# Formula de sample size para proporciones
numerator = (z_alpha * math.sqrt(2 * p_pool * (1 - p_pool)) +
z_beta * math.sqrt(p1 * (1 - p1) + p2 * (1 - p2))) ** 2
denominator = (p2 - p1) ** 2
n = math.ceil(numerator / denominator)
return {
"n_por_variante": n,
"n_total": n * 2,
"baseline": f"{p1:.1%}",
"target": f"{p2:.1%}",
"mejora_detectar": f"{mejora_minima_esperada:.1%}",
"alpha": alpha,
"power": power
}
# Ejemplos:
print("Detectar mejora de 3%:")
r = calcular_sample_size(baseline_rate=0.91, mejora_minima_esperada=0.03)
print(f" n por variante: {r['n_por_variante']}")
print(f" n total: {r['n_total']}")
print("\nDetectar mejora de 1%:")
r = calcular_sample_size(baseline_rate=0.91, mejora_minima_esperada=0.01)
print(f" n por variante: {r['n_por_variante']}")
# Output típico:
# Detectar mejora de 3%: n=~350 por variante (700 total)
# Detectar mejora de 1%: n=~3000 por variante (6000 total)
Implementación del A/B Test
import random
import time
from openai import OpenAI
from scipy import stats
from dataclasses import dataclass, field
from typing import Callable
client = OpenAI()
@dataclass
class ABTestResult:
"""Resultado completo de un A/B test."""
prompt_a_name: str
prompt_b_name: str
n_a: int
n_b: int
# Métricas de accuracy
scores_a: list[float] = field(default_factory=list)
scores_b: list[float] = field(default_factory=list)
# Métricas de performance
latencias_a: list[float] = field(default_factory=list)
latencias_b: list[float] = field(default_factory=list)
# Métricas de costo
tokens_a: list[int] = field(default_factory=list)
tokens_b: list[int] = field(default_factory=list)
@property
def mean_a(self) -> float:
return sum(self.scores_a) / len(self.scores_a) if self.scores_a else 0.0
@property
def mean_b(self) -> float:
return sum(self.scores_b) / len(self.scores_b) if self.scores_b else 0.0
@property
def mejora_relativa(self) -> float:
if self.mean_a == 0:
return 0.0
return (self.mean_b - self.mean_a) / self.mean_a
def test_estadistico(self) -> dict:
"""Aplica t-test para determinar significancia estadística."""
if len(self.scores_a) < 2 or len(self.scores_b) < 2:
return {"error": "Insuficientes datos para test estadístico"}
t_stat, p_value = stats.ttest_ind(self.scores_a, self.scores_b)
return {
"t_statistic": t_stat,
"p_value": p_value,
"significativo": p_value < 0.05,
"confianza": f"{(1 - p_value) * 100:.1f}%"
}
def ganador(self) -> str:
"""Determina el ganador basándose en significancia estadística."""
test = self.test_estadistico()
if "error" in test:
return "INCONCLUSIVO"
if not test["significativo"]:
return "EMPATE (no significativo)"
return "B" if self.mean_b > self.mean_a else "A"
def resumen(self) -> dict:
"""Resumen completo del A/B test."""
test_stats = self.test_estadistico()
return {
"winner": self.ganador(),
"mean_a": self.mean_a,
"mean_b": self.mean_b,
"mejora_absoluta": self.mean_b - self.mean_a,
"mejora_relativa": self.mejora_relativa,
"n_a": self.n_a,
"n_b": self.n_b,
"p_value": test_stats.get("p_value"),
"significativo": test_stats.get("significativo"),
"latencia_p50_a": sorted(self.latencias_a)[len(self.latencias_a)//2] if self.latencias_a else None,
"latencia_p50_b": sorted(self.latencias_b)[len(self.latencias_b)//2] if self.latencias_b else None,
"tokens_promedio_a": sum(self.tokens_a)/len(self.tokens_a) if self.tokens_a else None,
"tokens_promedio_b": sum(self.tokens_b)/len(self.tokens_b) if self.tokens_b else None,
}
def ab_test_offline(
prompt_a: str,
prompt_b: str,
golden_set: list[dict],
evaluador: Callable | None = None,
n_por_variante: int | None = None
) -> ABTestResult:
"""
Ejecuta un A/B test offline usando el golden set.
evaluador: función(expected, actual) -> float (0.0-1.0)
Si None, usa exact match.
n_por_variante: Número máximo de ejemplos por variante.
Si None, usa todo el golden set para ambos.
"""
if evaluador is None:
def evaluador(expected, actual):
return 1.0 if str(expected).strip().lower() == str(actual).strip().lower() else 0.0
# Seleccionar muestra
if n_por_variante and len(golden_set) > n_por_variante:
sample = random.sample(golden_set, min(len(golden_set), n_por_variante * 2))
sample_a = sample[:n_por_variante]
sample_b = sample[n_por_variante:]
else:
# Si el golden set es pequeño, usar todos los ejemplos para ambos
sample_a = golden_set
sample_b = golden_set
resultado = ABTestResult(
prompt_a_name="Prompt A",
prompt_b_name="Prompt B",
n_a=len(sample_a),
n_b=len(sample_b)
)
# Evaluar Prompt A
print(f"Evaluando Prompt A ({len(sample_a)} ejemplos)...")
for ejemplo in sample_a:
inicio = time.time()
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt_a.format(input=ejemplo["input"])}],
temperature=0
)
latencia = (time.time() - inicio) * 1000
output = response.choices[0].message.content.strip()
score = evaluador(ejemplo["expected_output"], output)
resultado.scores_a.append(score)
resultado.latencias_a.append(latencia)
resultado.tokens_a.append(response.usage.total_tokens)
# Evaluar Prompt B
print(f"Evaluando Prompt B ({len(sample_b)} ejemplos)...")
for ejemplo in sample_b:
inicio = time.time()
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt_b.format(input=ejemplo["input"])}],
temperature=0
)
latencia = (time.time() - inicio) * 1000
output = response.choices[0].message.content.strip()
score = evaluador(ejemplo["expected_output"], output)
resultado.scores_b.append(score)
resultado.latencias_b.append(latencia)
resultado.tokens_b.append(response.usage.total_tokens)
return resultado
Múltiples Métricas en A/B Testing
En producción, raramente decides basándote en una sola métrica. Necesitas un framework multi-métrica:
def ab_test_multimetrica(
prompt_a: str,
prompt_b: str,
golden_set: list[dict],
metricas: list[str] = None
) -> dict:
"""
A/B test evaluando múltiples dimensiones simultáneamente.
metricas: Lista de métricas a evaluar.
Opciones: "accuracy", "faithfulness", "format", "latencia", "costo"
"""
if metricas is None:
metricas = ["accuracy", "format", "latencia", "costo"]
resultados_a = {m: [] for m in metricas}
resultados_b = {m: [] for m in metricas}
for ejemplo in golden_set:
for prompt, resultados in [(prompt_a, resultados_a), (prompt_b, resultados_b)]:
inicio = time.time()
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt.format(input=ejemplo["input"])}],
temperature=0
)
latencia_ms = (time.time() - inicio) * 1000
output = response.choices[0].message.content.strip()
# Calcular cada métrica
if "accuracy" in metricas:
correcto = output.lower() == str(ejemplo["expected_output"]).lower()
resultados["accuracy"].append(1.0 if correcto else 0.0)
if "format" in metricas:
import json
try:
json.loads(output)
format_ok = 1.0
except:
format_ok = 0.0 if ejemplo.get("requires_json") else 1.0
resultados["format"].append(format_ok)
if "latencia" in metricas:
resultados["latencia"].append(latencia_ms)
if "costo" in metricas:
# GPT-4o-mini: $0.15/1M input + $0.60/1M output
costo = (response.usage.prompt_tokens * 0.15 / 1e6 +
response.usage.completion_tokens * 0.60 / 1e6)
resultados["costo"].append(costo)
# Consolidar resultados por métrica
comparacion = {}
for metrica in metricas:
vals_a = resultados_a[metrica]
vals_b = resultados_b[metrica]
mean_a = sum(vals_a) / len(vals_a)
mean_b = sum(vals_b) / len(vals_b)
# Test estadístico
if len(vals_a) >= 2:
_, p_value = stats.ttest_ind(vals_a, vals_b)
else:
p_value = 1.0
# Para latencia y costo, "menor es mejor"; para el resto, "mayor es mejor"
if metrica in ["latencia", "costo"]:
ganador = "B" if mean_b < mean_a else "A"
else:
ganador = "B" if mean_b > mean_a else "A"
comparacion[metrica] = {
"mean_a": mean_a,
"mean_b": mean_b,
"delta": mean_b - mean_a,
"p_value": p_value,
"significativo": p_value < 0.05,
"ganador": ganador if p_value < 0.05 else "EMPATE"
}
# Determinar ganador global
victorias_a = sum(1 for m in comparacion.values() if m["ganador"] == "A")
victorias_b = sum(1 for m in comparacion.values() if m["ganador"] == "B")
if victorias_b > victorias_a:
ganador_global = "B"
elif victorias_a > victorias_b:
ganador_global = "A"
else:
ganador_global = "EMPATE"
return {
"ganador": ganador_global,
"metricas": comparacion,
"victorias": {"A": victorias_a, "B": victorias_b}
}
A/B Testing en Producción (Online)
El A/B testing offline es el más práctico para prompts, pero en producción también puedes hacer online A/B testing:
import random
from datetime import datetime
class ABRouter:
"""
Router que distribuye tráfico entre dos versiones de un prompt.
Usa para A/B testing en producción (online).
"""
def __init__(
self,
prompt_a: str,
prompt_b: str,
split: float = 0.5,
experiment_id: str = "exp_001"
):
self.prompt_a = prompt_a
self.prompt_b = prompt_b
self.split = split
self.experiment_id = experiment_id
# Storage de resultados
self.results: list[dict] = []
def get_variant(self, user_id: str | None = None) -> tuple[str, str]:
"""
Determina qué variante usar.
Si user_id se provee, usa hashing para consistencia (mismo user = misma variante).
Returns: (prompt, variante_name)
"""
if user_id:
# Hashing determinista: mismo user siempre ve la misma variante
import hashlib
hash_val = int(hashlib.md5(f"{self.experiment_id}:{user_id}".encode()).hexdigest(), 16)
use_b = (hash_val % 100) < (self.split * 100)
else:
use_b = random.random() < self.split
if use_b:
return self.prompt_b, "B"
else:
return self.prompt_a, "A"
def registrar_resultado(
self,
variante: str,
input_text: str,
output: str,
expected: str | None = None,
user_feedback: float | None = None
) -> None:
"""Registra el resultado de una llamada para análisis posterior."""
self.results.append({
"timestamp": datetime.now().isoformat(),
"variante": variante,
"input": input_text[:100],
"output": output[:200],
"correct": None if expected is None else (output.strip().lower() == str(expected).strip().lower()),
"user_feedback": user_feedback
})
def analizar(self) -> dict:
"""Analiza los resultados acumulados."""
from collections import defaultdict
por_variante = defaultdict(list)
for r in self.results:
if r["correct"] is not None:
por_variante[r["variante"]].append(r["correct"])
if not por_variante:
return {"error": "Sin resultados para analizar"}
stats_por_variante = {}
for variante, correctos in por_variante.items():
stats_por_variante[variante] = {
"n": len(correctos),
"accuracy": sum(correctos) / len(correctos)
}
# Test estadístico si hay datos de ambas variantes
resumen = {"variantes": stats_por_variante}
if "A" in por_variante and "B" in por_variante:
_, p_value = stats.ttest_ind(por_variante["A"], por_variante["B"])
resumen["p_value"] = p_value
resumen["significativo"] = p_value < 0.05
acc_a = stats_por_variante["A"]["accuracy"]
acc_b = stats_por_variante["B"]["accuracy"]
resumen["ganador"] = "B" if acc_b > acc_a and p_value < 0.05 else ("A" if acc_a > acc_b and p_value < 0.05 else "EMPATE")
return resumen
def debe_detener(self, max_n: int = 1000) -> bool:
"""Verifica si el test debe detenerse (N suficiente alcanzado)."""
n_total = len(self.results)
return n_total >= max_n
# Uso en una API FastAPI:
"""
router = ABRouter(prompt_a=PROMPT_V1, prompt_b=PROMPT_V2, split=0.5)
@app.post("/classify")
async def classify(request: ClassifyRequest):
prompt, variante = router.get_variant(user_id=request.user_id)
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt.format(input=request.text)}],
temperature=0
)
output = response.choices[0].message.content
router.registrar_resultado(variante, request.text, output)
return {"result": output, "variant": variante}
"""
Interpretación de Resultados
def interpretar_ab_test(resultado: dict) -> str:
"""
Genera una interpretación en lenguaje natural del A/B test.
Útil para reportar al equipo o en pull requests.
"""
ganador = resultado.get("winner") or resultado.get("ganador", "INCONCLUSIVO")
mean_a = resultado.get("mean_a", 0)
mean_b = resultado.get("mean_b", 0)
p_value = resultado.get("p_value")
n_a = resultado.get("n_a", 0)
n_b = resultado.get("n_b", 0)
if ganador == "EMPATE (no significativo)":
interpretacion = f"""
## Resultado del A/B Test
**Conclusión: No hay diferencia estadísticamente significativa**
- Prompt A accuracy: {mean_a:.2%} (n={n_a})
- Prompt B accuracy: {mean_b:.2%} (n={n_b})
- p-value: {p_value:.3f} (> 0.05 — no significativo)
**Recomendación:** Mantener Prompt A. El Prompt B no demuestra mejora suficiente.
Si esperas una mejora mayor, considera aumentar el tamaño del golden set.
"""
elif ganador == "B":
delta = mean_b - mean_a
interpretacion = f"""
## Resultado del A/B Test
**Conclusión: Prompt B es MEJOR ✅**
- Prompt A accuracy: {mean_a:.2%} (n={n_a})
- Prompt B accuracy: {mean_b:.2%} (n={n_b})
- Mejora: {delta:+.2%} ({delta/mean_a*100:+.1f}% relativo)
- p-value: {p_value:.3f} (< 0.05 — significativo)
**Recomendación:** Desplegar Prompt B. La mejora es estadísticamente significativa.
"""
elif ganador == "A":
delta = mean_b - mean_a
interpretacion = f"""
## Resultado del A/B Test
**Conclusión: Prompt A es MEJOR (Prompt B regresó) ⚠️**
- Prompt A accuracy: {mean_a:.2%} (n={n_a})
- Prompt B accuracy: {mean_b:.2%} (n={n_b})
- Cambio: {delta:+.2%}
- p-value: {p_value:.3f}
**Recomendación:** No deployar Prompt B. Revisar los cambios realizados.
"""
else:
interpretacion = "## Resultado del A/B Test\n**Inconclusivo** — Datos insuficientes."
return interpretacion.strip()
# Ejemplo de uso completo:
def ejemplo_ab_test_completo():
from openai import OpenAI
client = OpenAI()
# Golden set mínimo para demo
golden_set = [
{"id": "1", "input": "Me encanta este producto", "expected_output": "POSITIVO"},
{"id": "2", "input": "Terrible, no sirve para nada", "expected_output": "NEGATIVO"},
{"id": "3", "input": "El paquete llegó hoy", "expected_output": "NEUTRO"},
# ... más ejemplos en producción real
]
PROMPT_A = "Clasifica como POSITIVO, NEGATIVO o NEUTRO: {input}. Solo la categoría."
PROMPT_B = """Eres un clasificador de sentimiento experto.
Clasifica el siguiente texto como POSITIVO, NEGATIVO, o NEUTRO.
Considera el sentimiento general, no solo palabras individuales.
Responde SOLO con la categoría.
Texto: {input}
Categoría:"""
resultado = ab_test_offline(PROMPT_A, PROMPT_B, golden_set)
resumen = resultado.resumen()
print(interpretar_ab_test(resumen))
return resultado
Errores Comunes en A/B Testing
Error 1: Peeking (Mirar Antes de Tiempo)
Problema: Ejecutas el test con 50 ejemplos, ves que B va ganando,
y detienes el test y declaras a B ganador.
Por qué es malo: Con N pequeño, la variación aleatoria puede ser grande.
Declaras ganador cuando aún no hay suficiente evidencia.
Solución: Definir N antes del test y NO mirar hasta que se alcance.
def test_con_n_fijo(prompt_a, prompt_b, golden_set, n_minimo=200):
"""
Test que no permite 'peeking' — solo reporta al alcanzar N_minimo.
"""
if len(golden_set) < n_minimo:
raise ValueError(
f"El golden set tiene {len(golden_set)} ejemplos, "
f"necesita al menos {n_minimo} para este test. "
f"Añade más ejemplos o reduce tu mejora_minima_esperada."
)
# Ejecutar con N fijo
resultado = ab_test_offline(prompt_a, prompt_b, golden_set, n_por_variante=n_minimo)
return resultado
Error 2: Múltiples Comparaciones
Problema: Pruebas prompts A, B, C, D, E... contra el baseline.
Con 5 comparaciones, la probabilidad de un falso positivo aumenta.
Corrección de Bonferroni:
alfa_corregido = 0.05 / n_comparaciones
def correccion_bonferroni(
resultados: list[dict],
alpha_inicial: float = 0.05
) -> list[dict]:
"""Aplica corrección de Bonferroni para múltiples comparaciones."""
n = len(resultados)
alpha_corregido = alpha_inicial / n
for r in resultados:
if "p_value" in r:
r["significativo_corregido"] = r["p_value"] < alpha_corregido
r["alpha_corregido"] = alpha_corregido
return resultados
Error 3: Contaminación de Muestras
Problema: Usas los mismos ejemplos para evaluar A y B,
y los resultados se correlacionan artificialmente.
Solución: Para golden sets pequeños (< 200 ejemplos),
usar los mismos ejemplos es aceptable pero mencionarlo.
Para golden sets grandes, dividir aleatoriamente.
Troubleshooting
Problema 1: Resultado no significativo aunque esperabas mejora
Síntoma: p-value = 0.34, no puedes concluir nada.
Causa más común: Golden set demasiado pequeño.
Solución:
# Calcular cuántos más necesitas
r = calcular_sample_size(baseline_rate=0.91, mejora_minima_esperada=0.03)
print(f"Necesitas {r['n_por_variante']} por variante")
# Si tienes 50 y necesitas 350, el test es inválido — no hagas conclusiones
Problema 2: Ganador A cuando esperabas B
Síntoma: El prompt B "mejorado" tiene accuracy más baja.
Causa: El cambio introdujo una regresión inesperada.
Acción:
# 1. Ver los casos donde B falló pero A no
def analizar_fallos_b(prompt_a, prompt_b, golden_set):
fallos_b_aciertos_a = []
for ej in golden_set:
out_a = run_prompt(prompt_a, ej["input"])[0]
out_b = run_prompt(prompt_b, ej["input"])[0]
correct_a = out_a.lower() == str(ej["expected_output"]).lower()
correct_b = out_b.lower() == str(ej["expected_output"]).lower()
if correct_a and not correct_b:
fallos_b_aciertos_a.append({
"id": ej["id"],
"input": ej["input"],
"expected": ej["expected_output"],
"output_a": out_a,
"output_b": out_b
})
return fallos_b_aciertos_a
# Ver patrones en los fallos → entender qué rompió el prompt B
Problema 3: Empate en múltiples métricas (B gana accuracy, A gana costo)
Síntoma: B tiene mejor accuracy (+3%) pero también más costo (+20%).
Acción: Decidir según prioridades del negocio:
def decidir_ganador_con_pesos(
metricas_comparacion: dict,
pesos: dict[str, float]
) -> str:
"""
Determina el ganador considerando múltiples métricas con pesos.
pesos: {"accuracy": 0.6, "costo": 0.3, "latencia": 0.1}
"""
assert abs(sum(pesos.values()) - 1.0) < 0.001
score_a = 0.0
score_b = 0.0
for metrica, peso in pesos.items():
if metrica not in metricas_comparacion:
continue
m = metricas_comparacion[metrica]
if not m.get("significativo"):
# Empate, no contribuye a ninguno
continue
ganador = m.get("ganador")
if ganador == "A":
score_a += peso
elif ganador == "B":
score_b += peso
if score_b > score_a:
return f"B (score ponderado: {score_b:.2f} vs {score_a:.2f})"
elif score_a > score_b:
return f"A (score ponderado: {score_a:.2f} vs {score_b:.2f})"
else:
return f"EMPATE ({score_a:.2f} cada uno)"
# Ejemplo:
# accuracy crucial, costo importante, latencia secundaria
pesos = {"accuracy": 0.6, "costo": 0.3, "latencia": 0.1}
ganador = decidir_ganador_con_pesos(comparacion_metricas, pesos)
Ejercicios
Ejercicio 1: Calcular sample size para tu caso
Para un clasificador con accuracy actual de 88%, calcula cuántos ejemplos necesitas para detectar una mejora de 5% con 80% de power y 95% de confianza.
Ver solución
from scipy import stats
import math
def calcular_sample_size(baseline_rate, mejora, alpha=0.05, power=0.80):
p1 = baseline_rate
p2 = baseline_rate + mejora
z_alpha = stats.norm.ppf(1 - alpha / 2)
z_beta = stats.norm.ppf(power)
p_pool = (p1 + p2) / 2
n = math.ceil(
(z_alpha * math.sqrt(2 * p_pool * (1 - p_pool)) +
z_beta * math.sqrt(p1 * (1 - p1) + p2 * (1 - p2))) ** 2
/ (p2 - p1) ** 2
)
return n
n = calcular_sample_size(baseline_rate=0.88, mejora=0.05)
print(f"Necesitas {n} ejemplos POR variante ({n*2} total)")
# Resultado típico: ~248 por variante (496 total)
# Para contexto de costo:
costo_por_request = 0.001 # $0.001 por ejemplo (aproximado con gpt-4o-mini)
costo_total = n * 2 * costo_por_request
print(f"Costo estimado del test: ${costo_total:.2f}")
Ejercicio 2: A/B test con interpretación
Escribe el código completo para comparar dos versiones de un prompt de resumen y reportar el ganador con justificación estadística.
Ver solución
from openai import OpenAI
from scipy import stats
client = OpenAI()
PROMPT_A = "Resume en 1 oración: {input}"
PROMPT_B = """Resume el siguiente texto en exactamente una oración.
Incluye el punto más importante.
No uses más de 30 palabras.
Texto: {input}
Resumen:"""
textos = [
"El banco central anunció hoy una subida de 0.25 puntos en las tasas de interés, la tercera consecutiva este año, con el objetivo de combatir la inflación que alcanzó el 8.2% en el último trimestre.",
"Apple presentó el nuevo iPhone 17 con mejoras significativas en la cámara, incluyendo sensor de 200 megapíxeles y capacidad de grabar en 8K, disponible desde el próximo mes con precios desde $999.",
]
respuestas_a = []
respuestas_b = []
for texto in textos:
resp_a = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": PROMPT_A.format(input=texto)}],
temperature=0
).choices[0].message.content.strip()
resp_b = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": PROMPT_B.format(input=texto)}],
temperature=0
).choices[0].message.content.strip()
respuestas_a.append(resp_a)
respuestas_b.append(resp_b)
print(f"A: {resp_a[:80]}...")
print(f"B: {resp_b[:80]}...")
print()
# Para evaluación: medir longitud como proxy (más corto = más conciso)
longitudes_a = [len(r.split()) for r in respuestas_a]
longitudes_b = [len(r.split()) for r in respuestas_b]
print(f"Palabras promedio A: {sum(longitudes_a)/len(longitudes_a):.1f}")
print(f"Palabras promedio B: {sum(longitudes_b)/len(longitudes_b):.1f}")
print("(Demo con n pequeño — en producción usar golden set con N>=200)")
Ejercicio 3: Detectar peeking problem
Explica con código por qué el siguiente A/B test tiene un problema de peeking y cómo corregirlo:
# Código con problema
for i, ejemplo in enumerate(golden_set):
# evaluar A y B...
if i == 20: # Revisamos con 20 ejemplos
if mean_b > mean_a + 0.05:
print("B gana! Deployar.")
break
Ver solución
# El problema: con 20 ejemplos, p-value suele ser > 0.05
# La diferencia de 5% puede ser puro ruido estadístico
# Al detenerse temprano, el "ganador" puede ser falso positivo
# SOLUCIÓN CORRECTA:
from scipy import stats
def ab_test_correcto(prompt_a, prompt_b, golden_set, n_minimo=200):
"""Test sin peeking — N fijo decidido ANTES del test."""
# 1. Calcular N necesario ANTES de empezar
n_requerido = calcular_sample_size(baseline_rate=0.90, mejora=0.03)
if len(golden_set) < n_requerido:
raise ValueError(
f"Golden set insuficiente: tiene {len(golden_set)}, necesita {n_requerido}. "
"Agrega más ejemplos o acepta menor precisión."
)
# 2. Ejecutar TODOS los ejemplos sin mirar resultados intermedios
scores_a, scores_b = [], []
import random
muestra = random.sample(golden_set, min(n_requerido * 2, len(golden_set)))
for ej in muestra[:n_requerido]: # Prompt A
out = run_prompt(prompt_a, ej["input"])[0]
scores_a.append(1.0 if out.lower() == str(ej["expected_output"]).lower() else 0.0)
for ej in muestra[n_requerido:2*n_requerido]: # Prompt B
out = run_prompt(prompt_b, ej["input"])[0]
scores_b.append(1.0 if out.lower() == str(ej["expected_output"]).lower() else 0.0)
# 3. Evaluar UNA SOLA VEZ al final
mean_a = sum(scores_a) / len(scores_a)
mean_b = sum(scores_b) / len(scores_b)
_, p_value = stats.ttest_ind(scores_a, scores_b)
ganador = "B" if mean_b > mean_a and p_value < 0.05 else ("A" if mean_a > mean_b and p_value < 0.05 else "EMPATE")
print(f"A: {mean_a:.2%}, B: {mean_b:.2%}, p={p_value:.3f}, Ganador: {ganador}")
return ganador
Resumen
- A/B testing: El método riguroso para comparar dos versiones de un prompt con certeza estadística
- Sample size: Calcular ANTES del test — con N insuficiente, los resultados son inválidos
- p-value < 0.05: El umbral estándar para significancia estadística
- Effect size: No solo "es significativo" sino "¿cuánto mejora?"
- Múltiples métricas: Accuracy + latencia + costo — decidir con pesos según prioridades
- Peeking: No detengas el test antes de alcanzar N mínimo
- Online A/B: ABRouter para testing en producción con usuarios reales
Recursos adicionales
- Evan Miller A/B Test Calculator — Calculadora de sample size
- scipy.stats — Tests estadísticos en Python
- A/B Testing Statistics Guide — Guía comprehensiva
- Stats for A/B Testing — Errores comunes
- Statsmodels — Librería estadística avanzada para Python