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ónA/B TestingAlternativa
Cambio mayor de promptN/A
Nuevo modelo (gpt-4o-mini → gpt-4o)N/A
Few-shot vs zero-shotN/A
Fix de typo en promptNoRegression test
Cambio de orden de instruccionesSí (puede importar)N/A
Nueva tarea sin baselineNoPrimero 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

ErrorDescripciónConsecuencia
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
Power1-β = probabilidad de detectar una mejora realQuieres ≥ 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

  1. Evan Miller A/B Test Calculator — Calculadora de sample size
  2. scipy.stats — Tests estadísticos en Python
  3. A/B Testing Statistics Guide — Guía comprehensiva
  4. Stats for A/B Testing — Errores comunes
  5. Statsmodels — Librería estadística avanzada para Python