Por Qué A/B Testing de Modelos ML Difiere del A/B Testing Tradicional

El A/B testing tradicional compara cambios estáticos de UI. El A/B testing ML (como se discute en AI-Assisted Bug Triaging: Intelligent Defect Prioritization at Scale) es fundamentalmente diferente:

  1. No-determinístico: Misma entrada puede producir salidas diferentes
  2. Aprendizaje Continuo: Modelos se reentrenan, comportamiento evoluciona
  3. Métricas Complejas: Precisión, latencia, equidad, KPIs de negocio
  4. Efectos a Largo Plazo: Cambios de modelo impactan distribución futura de datos

Framework A/B Testing para ML

(como se discute en AI Code Smell Detection: Finding Problems in Test Automation with ML) 1. Formación de Hipótesis

class ExperimentoML:
    def __init__(self, nombre, hipotesis, criterios_exito):
        self.nombre = nombre
        self.hipotesis = hipotesis
        self.criterios_exito = criterios_exito
        self.variantes = {}

experimento = ExperimentoML(
 (como se discute en [AI-powered Test Generation: The Future Is Already Here](/blog/ai-powered-test-generation))    nombre="modelo_ranking_v2",
    hipotesis="Nuevo modelo transformer aumentará CTR en 5%",
    criterios_exito={
        'aumento_ctr': 0.05,
        'latencia_p95': 200,  # ms
        'significancia_minima': 0.95
    }
)

experimento.agregar_variante('control', modelo_v1, asignacion_trafico=0.5)
experimento.agregar_variante('tratamiento', modelo_v2, asignacion_trafico=0.5)

2. División de Tráfico

class DivisorTrafico:
    def asignar_variante(self, user_id):
        """Asignación basada en hash consistente"""
        valor_hash = hashlib.md5(
            f"{self.experimento.nombre}:{user_id}".encode()
        ).hexdigest()

        hash_int = int(valor_hash, 16)
        umbral = hash_int % 100

        acumulativo = 0
        for nombre_variante, variante in self.experimento.variantes.items():
            acumulativo += variante['asignacion_trafico'] * 100
            if umbral < acumulativo:
                return nombre_variante, variante['modelo']

3. Testing de Significancia Estadística

from scipy import stats

class ProbadorSignificancia:
    def __init__(self, alpha=0.05):
        self.alpha = alpha

    def t_test(self, datos_control, datos_tratamiento):
        """Prueba t de dos muestras para métricas continuas"""
        t_stat, p_value = stats.ttest_ind(datos_control, datos_tratamiento)

        return {
            't_statistic': t_stat,
            'p_value': p_value,
            'es_significativo': p_value < self.alpha,
            'media_control': np.mean(datos_control),
            'media_tratamiento': np.mean(datos_tratamiento),
            'lift_relativo': (np.mean(datos_tratamiento) - np.mean(datos_control)) / np.mean(datos_control)
        }

    def prueba_chi_cuadrado(self, conversiones_control, total_control, conversiones_tratamiento, total_tratamiento):
        """Prueba chi-cuadrado para métricas binarias"""
        tabla_contingencia = np.array([
            [conversiones_control, total_control - conversiones_control],
            [conversiones_tratamiento, total_tratamiento - conversiones_tratamiento]
        ])

        chi2, p_value, dof, expected = stats.chi2_contingency(tabla_contingencia)

        tasa_control = conversiones_control / total_control
        tasa_tratamiento = conversiones_tratamiento / total_tratamiento

        return {
            'chi2_statistic': chi2,
            'p_value': p_value,
            'es_significativo': p_value < self.alpha,
            'tasa_control': tasa_control,
            'tasa_tratamiento': tasa_tratamiento,
            'lift_relativo': (tasa_tratamiento - tasa_control) / tasa_control
        }

Evaluación Online vs. Offline

Evaluación Offline

class EvaluadorOffline:
    def validacion_holdout(self, modelo_viejo, modelo_nuevo):
        """Evaluar en datos retenidos"""
        X_test, y_test = self.datos['X_test'], self.datos['y_test']

        predicciones_viejas = modelo_viejo.predict(X_test)
        predicciones_nuevas = modelo_nuevo.predict(X_test)

        return {
            'auc_modelo_viejo': roc_auc_score(y_test, predicciones_viejas),
            'auc_modelo_nuevo': roc_auc_score(y_test, predicciones_nuevas),
            'mejora_auc': roc_auc_score(y_test, predicciones_nuevas) - roc_auc_score(y_test, predicciones_viejas)
        }

Evaluación Online

class EvaluadorOnline:
    def monitorear_tiempo_real(self):
        """Monitoreo en tiempo real con alertas"""
        resumen = self.calcular_rendimiento_actual()

        alertas = []

        if resumen['tratamiento']['tasa_error'] > resumen['control']['tasa_error'] * 1.5:
            alertas.append({
                'severidad': 'CRITICA',
                'mensaje': f"Tasa de error de tratamiento 50% mayor"
            })

        return {'resumen': resumen, 'alertas': alertas}

    def verificar_barandillas(self):
        """Asegurar que métricas críticas no se degraden"""
        barandillas = {
            'aumento_tasa_error': 0.10,
            'aumento_latencia_p95': 0.20,
            'disminucion_ingresos': 0.05
        }

        violaciones = []
        return {'aprobado': len(violaciones) == 0, 'violaciones': violaciones}

Técnicas Avanzadas

Multi-Armed Bandits

class ThompsonSampling:
    """Asignación adaptativa de tráfico basada en rendimiento"""
    def __init__(self, variantes):
        self.variantes = {
            nombre: {'alpha': 1, 'beta': 1}
            for nombre in variantes
        }

    def seleccionar_variante(self):
        """Thompson sampling: muestra de distribuciones posteriores"""
        muestras = {
            nombre: np.random.beta(params['alpha'], params['beta'])
            for nombre, params in self.variantes.items()
        }

        return max(muestras, key=muestras.get)

    def actualizar(self, nombre_variante, recompensa):
        """Actualizar posterior basado en recompensa observada"""
        if recompensa > 0:
            self.variantes[nombre_variante]['alpha'] += 1
        else:
            self.variantes[nombre_variante]['beta'] += 1

Estrategias de Despliegue

class DespliegueGradual:
    def __init__(self, experimento):
        self.experimento = experimento
        self.asignacion_actual = 0.05  # Empezar con 5%

    def deberia_aumentar_trafico(self, horas_corriendo, metricas):
        """Decidir si aumentar tráfico a tratamiento"""
        if horas_corriendo < 24:
            return False

        if not metricas['barandillas_aprobadas']:
            return False

        if metricas['significancia_estadistica'] and metricas['lift_positivo']:
            return True

        return False

# Etapas de despliegue
# Etapa 1: 5% durante 24-48 horas
# Etapa 2: 20% durante 48 horas
# Etapa 3: 50% durante 48 horas
# Etapa 4: 100% (despliegue completo)

Mejores Prácticas

PrácticaDescripción
Definir Métricas de Éxito AnticipadamentePrimaria, secundaria, barandilla
Calcular Tamaño de Muestra RequeridoUsar análisis de poder
Ejecutar por Ciclos de Negocio CompletosContabilizar estacionalidad
Monitorear BarandillasDetener automáticamente si degradan métricas críticas
Testear Un Cambio a la VezAislar causas de diferencias de rendimiento
Registrar TodoHabilitar análisis post-hoc
Despliegue GradualEmpezar pequeño, expandir si exitoso

Conclusión

El A/B testing de modelos ML requiere métodos estadísticos rigurosos, monitoreo en tiempo real y alineación con métricas de negocio. A diferencia de funcionalidades estáticas, los experimentos ML involucran interacciones complejas, efectos a largo plazo y comportamiento evolutivo.

El éxito viene de combinar validación offline, experimentos online cuidadosamente diseñados, rigor estadístico, monitoreo de barandillas y despliegues graduales.