Python & Data Science
MLOps En revisión

Pruebas A/B de modelos desplegados: Despliegues shadow y lanzamientos canary

La última vez, Dev analizó los números de sus dos recomendadores — valor p de 0.03, un tamaño de efecto modesto pero real. El modelo reentrenado era genuinamente mejor, no solo una cuestión de suerte. Pero había aprendido por las malas que un modelo puede ganar fuera de línea y aún así fallar en producción. Así que no iba a implementarlo para los diez millones de usuarios de una sola vez.

Por Qué No Puedes Simplemente Intercambiar Modelos en Producción

Dev ya ha escuchado esta historia antes. Un equipo pasa tres meses construyendo un nuevo modelo de recomendación. En el conjunto de prueba, es 7% más preciso que el anterior. Todos están emocionados. Lo lanzan a producción un lunes por la mañana para los 10 millones de usuarios. Para el martes por la tarde, el CEO está enviando correos electrónicos enojados: los ingresos han caído 12%, y los usuarios se quejan de que las recomendaciones no funcionan. Dev está decidido a no ser ese equipo.

¿Qué pasó? El conjunto de prueba no coincidía con los datos del mundo real. El nuevo modelo fue entrenado con datos históricos de hace seis meses, pero el comportamiento del usuario ha cambiado. Tal vez el modelo es más lento y los timeouts están causando errores. O tal vez está topando con un caso límite que nunca apareció en las pruebas, alguna combinación rara de atributos de usuario que rompe los supuestos del modelo.

Aquí está el problema central: un modelo que se ve excelente en desarrollo a menudo funciona peor en producción. Y cuando falla, falla rápido y a gran escala. Un mal modelo desplegado al 100% de los usuarios puede hundir los ingresos, erosionar la confianza del usuario o, en sistemas críticos para la seguridad como la salud o las finanzas, causar un daño genuino, todo en cuestión de horas.

Necesitas una forma de probar el nuevo modelo con tráfico real, con usuarios reales, antes de comprometerte por completo con él. Ahí es donde entran los despliegues shadow y los lanzamientos canary.

La Idea Central: Prueba Antes de Confiar

Aquí está la intuición: en lugar de accionar un interruptor y enviar todo el tráfico al modelo nuevo, lo pruebas de forma gradual. Podrías enviar el 5% del tráfico al modelo nuevo y el 95% al anterior. Observas qué sucede. Si todo va bien, lo aumentas al 10%. Luego al 25%. Luego al 100%. Si algo sale mal, reviertes los cambios de inmediato: solo una pequeña fracción de usuarios resultó afectada.

Esto detecta problemas del mundo real que los datos de prueba nunca mostraron. Surgen problemas de latencia, y también casos límite que no habías anticipado. Aprendes cómo se comporta el modelo cuando hay deriva de datos, todo con un riesgo mínimo.

Existen dos estrategias principales para el despliegue gradual: despliegue en la sombra y lanzamientos canario. Tienen diferentes compromisos, y a menudo usarás ambas.

Despliegue en la Sombra: La Forma Más Segura de Probar

Piensa en el despliegue en la sombra de esta manera: contratas a un consultor en la sombra para que asista a todas tus reuniones y dé consejos, pero nunca los sigues en realidad. Solo escuchas y ves si parece competente. Si es así, tal vez lo contrates a tiempo completo más adelante.

En el despliegue en la sombra, el nuevo modelo se ejecuta en cada solicitud, pero su predicción nunca llega al usuario. La predicción del modelo anterior se devuelve como de costumbre. La salida del nuevo modelo se registra y se compara de forma offline. Los usuarios nunca ven el nuevo modelo, por lo que hay cero riesgo para su experiencia.

Esto es lo que ocurre detrás de escena:

  1. Llega una solicitud de un usuario.
  2. El modelo anterior hace una predicción y la devuelve al usuario (como de costumbre).
  3. El nuevo modelo también hace una predicción sobre la misma solicitud, pero el resultado se registra.
  4. Offline, comparas las dos predicciones y calculas métricas como latencia, tasa de error y concordancia de predicciones.
  5. Si el nuevo modelo funciona bien, pasas a la siguiente fase.

El despliegue en la sombra es completamente seguro. Los usuarios nunca se ven afectados. Obtienes datos de rendimiento del mundo real sin ningún riesgo de producción.

Vamos a programar esto. Simularemos un despliegue en la sombra donde ambos modelos se ejecutan en las solicitudes entrantes, y registraremos sus predicciones y latencias:

import time
import random
from dataclasses import dataclass
from typing import List

@dataclass
class Request:
    user_id: int
    features: dict

@dataclass
class Prediction:
    model_name: str
    prediction: float
    latency_ms: float
    timestamp: float

class OldModel:
    """Simulates the current production model."""
    def predict(self, request: Request) -> float:
        # Simulate a simple model: average of feature values
        time.sleep(0.01)  # 10ms latency
        return sum(request.features.values()) / len(request.features)

class NewModel:
    """Simulates the new model we want to test."""
    def predict(self, request: Request) -> float:
        # Simulate a slightly different model
        time.sleep(0.015)  # 15ms latency (a bit slower)
        # Add a small random noise to simulate different behavior
        base = sum(request.features.values()) / len(request.features)
        return base + random.gauss(0, 0.05)

class ShadowDeployment:
    """Runs both models, returns old model's prediction, logs both."""
    def __init__(self, old_model, new_model):
        self.old_model = old_model
        self.new_model = new_model
        self.shadow_log = []
    
    def handle_request(self, request: Request) -> float:
        # Run old model and time it
        start = time.time()
        old_pred = self.old_model.predict(request)
        old_latency = (time.time() - start) * 1000  # Convert to ms
        
        # Run new model and time it
        start = time.time()
        new_pred = self.new_model.predict(request)
        new_latency = (time.time() - start) * 1000
        
        # Log both predictions
        self.shadow_log.append({
            'old_pred': old_pred,
            'old_latency': old_latency,
            'new_pred': new_pred,
            'new_latency': new_latency,
            'pred_diff': abs(old_pred - new_pred)
        })
        
        # Return only the old model's prediction to the user
        return old_pred
    
    def get_shadow_metrics(self):
        """Compute metrics from the shadow log."""
        if not self.shadow_log:
            return None
        
        old_latencies = [log['old_latency'] for log in self.shadow_log]
        new_latencies = [log['new_latency'] for log in self.shadow_log]
        pred_diffs = [log['pred_diff'] for log in self.shadow_log]
        
        return {
            'old_model_avg_latency_ms': sum(old_latencies) / len(old_latencies),
            'new_model_avg_latency_ms': sum(new_latencies) / len(new_latencies),
            'latency_increase_pct': ((sum(new_latencies) / len(new_latencies)) / (sum(old_latencies) / len(old_latencies)) - 1) * 100,
            'avg_prediction_diff': sum(pred_diffs) / len(pred_diffs),
            'max_prediction_diff': max(pred_diffs),
            'num_requests': len(self.shadow_log)
        }

# Simulate shadow deployment
old_model = OldModel()
new_model = NewModel()
shadow = ShadowDeployment(old_model, new_model)

# Process 100 requests
for i in range(100):
    request = Request(
        user_id=i,
        features={'feature_1': random.uniform(0, 1), 'feature_2': random.uniform(0, 1)}
    )
    prediction = shadow.handle_request(request)

# Print shadow metrics
metrics = shadow.get_shadow_metrics()
print("Shadow Deployment Metrics:")
print(f"  Old model avg latency: {metrics['old_model_avg_latency_ms']:.2f}ms")
print(f"  New model avg latency: {metrics['new_model_avg_latency_ms']:.2f}ms")
print(f"  Latency increase: {metrics['latency_increase_pct']:.1f}%")
print(f"  Avg prediction difference: {metrics['avg_prediction_diff']:.4f}")
print(f"  Max prediction difference: {metrics['max_prediction_diff']:.4f}")
print(f"  Requests processed: {metrics['num_requests']}")

Esta es la infraestructura de despliegue en sombra que Dev construye para su recomendador reentrenado — tanto el modelo incumbente como el candidato se ejecutan en cada solicitud entrante, pero solo la predicción del incumbente llega al usuario. La salida del candidato se registra para comparación offline.

  • @dataclass class Request — un contenedor ligero para una solicitud de recomendación entrante. En el pipeline real de Dev, features contendría el historial de navegación del usuario, las características de sesión y los atributos del producto — no solo dos valores de coma flotante aleatorios.
  • @dataclass class Prediction — un registro estructurado para la salida de un modelo, incluyendo latencia y marca de tiempo. Definido aquí para completitud, pero no se usa hasta que Dev lo integre en un sistema de registro de producción.
  • OldModel.predict() — simula el recomendador incumbente. time.sleep(0.01) inyecta una latencia de 10ms para imitar el tiempo real de inferencia. La predicción es el promedio simple de los valores de las características — un sustituto de la puntuación del modelo real.
  • NewModel.predict() — simula el candidato reentrenado. Es 50% más lento (15ms vs 10ms) y añade ruido gaussiano (random.gauss(0, 0.05)) para simular puntuaciones de recomendación ligeramente diferentes.
  • ShadowDeployment.__init__ — almacena referencias a ambos modelos e inicializa shadow_log, la lista donde se registra cada resultado de predicción dual.
  • handle_request() — la lógica central del despliegue en sombra: ejecuta el modelo anterior, mide su tiempo; ejecuta el nuevo modelo, mide su tiempo; registra ambas predicciones, ambas latencias y la diferencia absoluta entre ellas; retorna únicamente la predicción del modelo anterior al solicitante. El usuario nunca ve la salida del nuevo modelo.
  • get_shadow_metrics() — agrega el registro de sombra en un resumen: latencia promedio para cada modelo, el aumento porcentual de latencia (la métrica clave de alerta) y la diferencia promedio y máxima de predicción. Un max_prediction_diff grande le indicaría a Dev que hay casos límite donde los dos modelos divergen significativamente — vale la pena investigar antes de pasar a despliegue canario.
  • latency_increase_pct — calculado como (new_avg / old_avg - 1) * 100. Esta es la métrica que haría que Dev se detenga: un aumento de latencia del 50% en un modelo que sirve a 10 millones de usuarios podría significar cientos de milisegundos adicionales de tiempo de espera agregado por segundo.

Cuando ejecutes esto, verás algo como:

Shadow Deployment Metrics:
  Old model avg latency: 10.23ms
  New model avg latency: 15.34ms
  Latency increase: 49.9%
  Avg prediction difference: 0.0512
  Max prediction difference: 0.2847
  Requests processed: 100

¿Qué significa esto? El nuevo modelo es aproximadamente un 50% más lento que el antiguo — eso es una señal de alerta. En producción, si tu modelo antiguo tarda 10ms y el nuevo tarda 15ms, y estás manejando 10,000 solicitudes por segundo, acabas de añadir 50,000ms (50 segundos) de latencia por segundo en todo tu sistema. Eso es un problema.

Las diferencias en las predicciones son pequeñas en promedio (0.05), lo cual es bueno — significa que el nuevo modelo coincide con el antiguo la mayor parte del tiempo. Pero la diferencia máxima de 0.28 sugiere que hay algunos casos límite donde divergen significativamente.

En un despliegue en la sombra real, ejecutarías esto durante 24-48 horas, recopilarías miles de solicitudes y revisarías las métricas. Si la latencia del nuevo modelo es aceptable y las predicciones son razonables, pasas a la siguiente fase.

Liberaciones Canary: Desvío Gradual de Tráfico

El despliegue en la sombra es seguro, pero costoso: estás ejecutando dos modelos en cada solicitud. Tampoco puede decirte cómo el nuevo modelo afecta las métricas comerciales reales como los ingresos, la retención o la satisfacción del usuario, porque los usuarios nunca lo ven.

Los lanzamientos canario solucionan esto. Un lanzamiento canario dirige un pequeño porcentaje del tráfico real al nuevo modelo y el resto al anterior. Los usuarios ven las predicciones del nuevo modelo. Comparas las métricas entre grupos y vigilas en busca de problemas.

El nombre proviene de la antigua práctica minera de llevar un canario a la mina. Si el canario moría, los mineros sabían que el aire era tóxico. Aquí, el canario es ese pequeño grupo de usuarios en el nuevo modelo. Si algo sale mal, reviertes antes de que llegue a todos.

Así es como funciona:

  1. Comienza con 5% del tráfico hacia el nuevo modelo, 95% al anterior.
  2. Monitorea las métricas de ambos grupos: latencia, tasa de errores y KPIs comerciales (conversión, ingresos, etc.).
  3. Si el nuevo modelo se ve bien, aumenta a 10%. Luego 25%. Luego 50%. Luego 100%.
  4. Si algo se ve mal en cualquier momento, revierte inmediatamente.

Vamos a programar esto:

import random
from collections import defaultdict
from dataclasses import dataclass
from typing import Dict, List

@dataclass
class UserEvent:
    user_id: int
    model: str  # 'old' or 'new'
    prediction: float
    latency_ms: float
    error: bool
    conversion: bool  # Did the user convert? (mock business metric)

class CanaryRelease:
    """Splits traffic between old and new models, tracks metrics per group."""
    def __init__(self, old_model, new_model, canary_percentage=5):
        self.old_model = old_model
        self.new_model = new_model
        self.canary_percentage = canary_percentage
        self.events: List[UserEvent] = []
    
    def handle_request(self, request: Request) -> float:
        """Route request to old or new model based on canary percentage."""
        # Decide which model to use
        if random.random() < self.canary_percentage / 100:
            model_to_use = 'new'
            model = self.new_model
        else:
            model_to_use = 'old'
            model = self.old_model
        
        # Get prediction and latency
        start = time.time()
        try:
            prediction = model.predict(request)
            latency = (time.time() - start) * 1000
            error = False
        except Exception as e:
            latency = (time.time() - start) * 1000
            prediction = 0
            error = True
        
        # Simulate a conversion (business metric)
        # In reality, this would come from user behavior tracking
        conversion = random.random() < (0.1 if prediction > 0.5 else 0.05)
        
        # Log the event
        self.events.append(UserEvent(
            user_id=request.user_id,
            model=model_to_use,
            prediction=prediction,
            latency_ms=latency,
            error=error,
            conversion=conversion
        ))
        
        return prediction
    
    def get_metrics_by_group(self) -> Dict:
        """Compute metrics for old and new model groups."""
        metrics_by_group = defaultdict(lambda: {
            'count': 0,
            'latencies': [],
            'errors': 0,
            'conversions': 0
        })
        
        for event in self.events:
            group = metrics_by_group[event.model]
            group['count'] += 1
            group['latencies'].append(event.latency_ms)
            if event.error:
                group['errors'] += 1
            if event.conversion:
                group['conversions'] += 1
        
        # Compute summary statistics
        result = {}
        for model_name, group in metrics_by_group.items():
            avg_latency = sum(group['latencies']) / len(group['latencies']) if group['latencies'] else 0
            error_rate = group['errors'] / group['count'] if group['count'] > 0 else 0
            conversion_rate = group['conversions'] / group['count'] if group['count'] > 0 else 0
            
            result[model_name] = {
                'count': group['count'],
                'avg_latency_ms': avg_latency,
                'error_rate': error_rate,
                'conversion_rate': conversion_rate
            }
        
        return result
    
    def check_for_regression(self, threshold_latency_pct=20, threshold_error_rate=0.02) -> Dict:
        """Check if new model has regressed compared to old model."""
        metrics = self.get_metrics_by_group()
        
        if 'old' not in metrics or 'new' not in metrics:
            return {'regression_detected': False, 'reason': 'Not enough data'}
        
        old_metrics = metrics['old']
        new_metrics = metrics['new']
        
        # Check latency
        latency_increase_pct = ((new_metrics['avg_latency_ms'] - old_metrics['avg_latency_ms']) / old_metrics['avg_latency_ms']) * 100
        if latency_increase_pct > threshold_latency_pct:
            return {
                'regression_detected': True,
                'reason': f'Latency increased by {latency_increase_pct:.1f}% (threshold: {threshold_latency_pct}%)'
            }
        
        # Check error rate
        error_rate_increase = new_metrics['error_rate'] - old_metrics['error_rate']
        if error_rate_increase > threshold_error_rate:
            return {
                'regression_detected': True,
                'reason': f'Error rate increased by {error_rate_increase:.2%} (threshold: {threshold_error_rate:.2%})'
            }
        
        return {'regression_detected': False, 'reason': 'All metrics look good'}

# Simulate canary release
old_model = OldModel()
new_model = NewModel()
canary = CanaryRelease(old_model, new_model, canary_percentage=5)

# Process 1000 requests
for i in range(1000):
    request = Request(
        user_id=i,
        features={'feature_1': random.uniform(0, 1), 'feature_2': random.uniform(0, 1)}
    )
    canary.handle_request(request)

# Print metrics
metrics = canary.get_metrics_by_group()
print("Canary Release Metrics:")
for model_name, model_metrics in metrics.items():
    print(f"\n{model_name.upper()} Model:")
    print(f"  Requests: {model_metrics['count']}")
    print(f"  Avg latency: {model_metrics['avg_latency_ms']:.2f}ms")
    print(f"  Error rate: {model_metrics['error_rate']:.2%}")
    print(f"  Conversion rate: {model_metrics['conversion_rate']:.2%}")

# Check for regression
regression_check = canary.check_for_regression()
print(f"\nRegression Check: {regression_check['reason']}")

Este es el entorno de lanzamiento canary que Dev construye para su recomendador — a diferencia del despliegue shadow anterior, aquí un pequeño porcentaje de usuarios reales realmente ve las recomendaciones del modelo reentrenado, y el sistema rastrea si su comportamiento (conversiones) difiere del grupo de control.

  • @dataclass class UserEvent — un registro estructurado para cada solicitud, que captura qué modelo la sirvió, la predicción, la latencia, si hubo errores y si el usuario “convirtió” (hizo clic / compró). En el sistema real de Dev, conversion provendría del flujo de eventos, no de una selección aleatoria.
  • CanaryRelease.__init__ — almacena ambos modelos, el porcentaje canary (por defecto 5%), y una lista events que acumula cada solicitud servida para su posterior análisis.
  • handle_request() — la lógica de división de tráfico. random.random() < self.canary_percentage / 100 da a cada solicitud un 5% de probabilidad de ser dirigida al nuevo modelo. El resto va al modelo anterior. El bloque try/except captura los errores de predicción para que un fallo en el nuevo modelo no tumbe todo el sistema.
  • conversion = random.random() < (0.1 if prediction > 0.5 else 0.05) — simula una métrica de negocio. Las predicciones más altas conducen a una mayor probabilidad de conversión. En producción, Dev rastrearía las tasas reales de clics o los eventos de compra — esto es un sustituto de la señal de negocio real que el canary está diseñado para validar.
  • get_metrics_by_group() — divide todos los eventos por nombre de modelo ('old' vs 'new') y calcula los promedios por grupo: conteo, latencia promedio, tasa de error y tasa de conversión. Este es el panel que Dev revisa cada 15 minutos durante un lanzamiento canary.
  • check_for_regression() — la protección automática. Compara la latencia y la tasa de error del nuevo modelo con las del modelo anterior, usando umbrales configurables (threshold_latency_pct=20 significa “revertir si el nuevo modelo es más de 20% más lento”). Esto es lo que activaría automáticamente una reversión a las 3 a.m. si el nuevo modelo empieza a comportarse mal.
  • canary_percentage=5 — el peso inicial canary. Al 5% de 1,000 solicitudes simuladas, aproximadamente 50 llegan al nuevo modelo — suficiente para detectar una regresión de latencia del 50% pero no suficiente para detectar una diferencia del 1% en la tasa de conversión (ese es el problema de potencia estadística que se cubre más adelante en el artículo).

La salida podría verse así:

Canary Release Metrics:

OLD Model:
  Requests: 950
  Avg latency: 10.25ms
  Error rate: 0.00%
  Conversion rate: 7.16%

NEW Model:
  Requests: 50
  Avg latency: 15.38ms
  Error rate: 0.00%
  Conversion rate: 6.00%

Regression Check: Latency increased by 50.0% (threshold: 20%)

¿Qué sucede aquí? Enviamos el 5% del tráfico al nuevo modelo — 50 de 1000 solicitudes. La latencia del nuevo modelo es 50% mayor que la del anterior. Eso es una regresión. Revertiríamos inmediatamente e investigaríamos por qué el nuevo modelo es tan lento.

La tasa de conversión también es ligeramente menor para el nuevo modelo (6% vs 7.16%), pero con solo 50 solicitudes en el nuevo modelo, eso podría ser ruido aleatorio. Aquí es donde entra la significancia estadística; lo abordaremos más adelante.

Sombra vs. Canary: Cuándo Usar Cada Uno

Tanto el despliegue shadow como el canary son formas seguras de probar nuevos modelos, pero tienen diferentes compromisos.

El despliegue shadow es la opción más segura. Los usuarios nunca ven el nuevo modelo, por lo que hay riesgo cero para su experiencia. Obtienes datos de rendimiento del mundo real sin ningún impacto en producción. La desventaja es que es costoso — estás ejecutando dos modelos en cada solicitud. Y no te dice cómo el nuevo modelo afecta las métricas del negocio porque los usuarios nunca lo ven realmente.

Los lanzamientos canary son más rápidos y económicos. Solo ejecutas el nuevo modelo en un pequeño porcentaje del tráfico. Ves métricas reales del negocio porque los usuarios realmente ven las predicciones del nuevo modelo. La desventaja es que hay un pequeño riesgo — si algo sale mal, una pequeña fracción de usuarios se ve afectada.

En la práctica, muchos equipos usan ambos. Comienza con el despliegue shadow para detectar problemas obvios (latencia, errores, caídas). Una vez que el shadow se ve bien, pasa a canary para ver el impacto real en el negocio. Esto te da la seguridad del shadow más la información de negocio del canary.

Aquí tienes una comparación rápida:

# Comparison of Shadow vs Canary

comparison = {
    'Shadow Deployment': {
        'User Risk': 'None (users never see new model)',
        'Cost': 'High (run both models on every request)',
        'Time to Deploy': 'Slow (need 24-48 hours of data)',
        'Business Metrics': 'Not captured (users never see new model)',
        'Best For': 'High-risk domains (healthcare, finance, safety-critical)'
    },
    'Canary Release': {
        'User Risk': 'Low (only small % of users affected)',
        'Cost': 'Low (run new model on small % of traffic)',
        'Time to Deploy': 'Fast (can ramp up in hours)',
        'Business Metrics': 'Captured in real time',
        'Best For': 'Most production scenarios'
    }
}

for strategy, traits in comparison.items():
    print(f"\n{strategy}:")
    for trait, value in traits.items():
        print(f"  {trait}: {value}")

Esta es la tabla de resumen que Dev mantiene en su runbook de despliegue — una referencia rápida para elegir entre shadow y canary al planificar un lanzamiento para su recomendador.

  • comparison = { ... } — un diccionario anidado que mapea cada nombre de estrategia a un diccionario de pares de característica → valor. Cada característica (User Risk, Cost, Time to Deploy, Business Metrics, Best For) captura una dimensión del compromiso.
  • for strategy, traits in comparison.items() — itera sobre las claves de nivel superior ('Shadow Deployment' y 'Canary Release') y sus diccionarios de características anidados.
  • for trait, value in traits.items() — itera sobre cada característica dentro de una estrategia, imprimiendo el nombre de la característica y su valor. La salida es una tabla de texto plano que Dev puede revisar rápidamente durante una reunión de planificación de despliegue.

Definiendo Métricas que Importan

La parte más difícil del despliegue no es servir el modelo, sino saber qué métricas monitorear y qué umbrales establecer. Si monitoreas las incorrectas, pasarás por alto problemas reales o revertirás buenos modelos.

Las métricas se dividen en dos categorías.

Las métricas técnicas revelan los problemas rápidamente, pero no cuentan toda la historia:

  • Latencia (¿cuánto tiempo tarda el modelo en hacer una predicción?)
  • Tasa de error (¿con qué frecuencia falla el modelo o devuelve un error?)
  • Rendimiento (¿cuántas solicitudes por segundo puede manejar el modelo?)

Las métricas de negocio importan más, pero tardan más en mostrar una señal:

  • Tasa de conversión (¿los usuarios compran?)
  • Ingresos (¿cuánto dinero gastan los usuarios?)
  • Retención (¿los usuarios regresan?)
  • Satisfacción del usuario (¿a los usuarios les gustan las recomendaciones?)

Necesitas ambas. Las métricas técnicas te brindan una advertencia temprana; las métricas de negocio te brindan la validación final.

Aquí tienes un sencillo panel de métricas:

from datetime import datetime, timedelta
from collections import defaultdict

class MetricsDashboard:
    """Tracks technical and business metrics for canary deployment."""
    def __init__(self):
        self.metrics_by_time = defaultdict(lambda: {'old': [], 'new': []})
        self.thresholds = {
            'latency_increase_pct': 20,  # Roll back if latency increases >20%
            'error_rate_increase': 0.02,  # Roll back if error rate increases >2%
            'conversion_rate_decrease_pct': 5  # Roll back if conversion decreases >5%
        }
    
    def record_event(self, model: str, latency_ms: float, error: bool, conversion: bool):
        """Record a single event."""
        now = datetime.now().replace(second=0, microsecond=0)  # Round to minute
        self.metrics_by_time[now][model].append({
            'latency': latency_ms,
            'error': error,
            'conversion': conversion
        })
    
    def get_current_metrics(self):
        """Get metrics for the most recent minute."""
        if not self.metrics_by_time:
            return None
        
        latest_time = max(self.metrics_by_time.keys())
        latest_data = self.metrics_by_time[latest_time]
        
        result = {}
        for model_name in ['old', 'new']:
            if not latest_data[model_name]:
                continue
            
            events = latest_data[model_name]
            latencies = [e['latency'] for e in events]
            errors = sum(1 for e in events if e['error'])
            conversions = sum(1 for e in events if e['conversion'])
            
            result[model_name] = {
                'count': len(events),
                'avg_latency_ms': sum(latencies) / len(latencies),
                'error_rate': errors / len(events),
                'conversion_rate': conversions / len(events)
            }
        
        return result
    
    def check_health(self):
        """Check if new model has regressed."""
        metrics = self.get_current_metrics()
        
        if not metrics or 'old' not in metrics or 'new' not in metrics:
            return {'status': 'OK', 'alerts': []}
        
        old_m = metrics['old']
        new_m = metrics['new']
        alerts = []
        
        # Check latency
        latency_increase_pct = ((new_m['avg_latency_ms'] - old_m['avg_latency_ms']) / old_m['avg_latency_ms']) * 100
        if latency_increase_pct > self.thresholds['latency_increase_pct']:
            alerts.append(f"ALERT: Latency increased {latency_increase_pct:.1f}%")
        
        # Check error rate
        error_rate_increase = new_m['error_rate'] - old_m['error_rate']
        if error_rate_increase > self.thresholds['error_rate_increase']:
            alerts.append(f"ALERT: Error rate increased {error_rate_increase:.2%}")
        
        # Check conversion rate
        if old_m['conversion_rate'] > 0:
            conversion_decrease_pct = ((old_m['conversion_rate'] - new_m['conversion_rate']) / old_m['conversion_rate']) * 100
            if conversion_decrease_pct > self.thresholds['conversion_rate_decrease_pct']:
                alerts.append(f"ALERT: Conversion rate decreased {conversion_decrease_pct:.1f}%")
        
        status = 'ALERT' if alerts else 'OK'
        return {'status': status, 'alerts': alerts, 'metrics': metrics}

# Simulate metric tracking
dashboard = MetricsDashboard()

# Simulate 100 events for old model
for i in range(100):
    latency = random.gauss(10, 1)  # Mean 10ms, std dev 1ms
    error = random.random() < 0.001  # 0.1% error rate
    conversion = random.random() < 0.07  # 7% conversion rate
    dashboard.record_event('old', latency, error, conversion)

# Simulate 20 events for new model (5% of traffic)
for i in range(20):
    latency = random.gauss(15, 2)  # Mean 15ms, std dev 2ms (slower)
    error = random.random() < 0.001
    conversion = random.random() < 0.06  # Slightly lower conversion
    dashboard.record_event('new', latency, error, conversion)

# Check health
health = dashboard.check_health()
print(f"Status: {health['status']}")
if health['alerts']:
    for alert in health['alerts']:
        print(f"  {alert}")

if health['metrics']:
    print("\nMetrics:")
    for model, metrics in health['metrics'].items():
        print(f"  {model.upper()}: latency={metrics['avg_latency_ms']:.1f}ms, error_rate={metrics['error_rate']:.2%}, conversion={metrics['conversion_rate']:.2%}")

Este es el panel de monitoreo que Dev construye para ejecutar junto con su canary — agrupa eventos por minuto, compara las métricas del modelo antiguo vs. el nuevo, y dispara alertas cuando el modelo nuevo cruza un umbral de regresión.

  • self.metrics_by_time = defaultdict(...) — un almacén organizado por intervalos de tiempo. Cada minuto tiene su propia entrada con listas separadas para los eventos del modelo 'old' y 'new'. Esto permite a Dev ver cómo evolucionan las métricas a lo largo del tiempo en lugar de como un único agregado.
  • self.thresholds — los umbrales de alerta configurables. latency_increase_pct: 20 significa “alertar si el modelo nuevo es más de 20% más lento”. conversion_rate_decrease_pct: 5 significa “alertar si la conversión cae más de 5% en relación con el modelo antiguo”. Dev ajusta estos valores según el SLA de su recomendador y la sensibilidad del negocio.
  • record_event() — redondea la marca de tiempo al minuto más cercano (replace(second=0, microsecond=0)) para agrupar los eventos en bloques por minuto, luego agrega el evento a la lista del modelo correspondiente.
  • get_current_metrics() — encuentra el bloque de minuto más reciente (max(self.metrics_by_time.keys())) y calcula los promedios por modelo para latencia, tasa de error y tasa de conversión. Esta es la instantánea que Dev ve en su panel.
  • check_health() — el guardián automatizado. Compara el modelo nuevo contra el modelo antiguo en tres dimensiones: porcentaje de aumento de latencia, aumento de la tasa de error y disminución de la tasa de conversión. Cualquier infracción agrega una cadena de alerta. El campo status es 'ALERT' si se disparó alguna alerta, 'OK' en caso contrario.
  • conversion_decrease_pct = ((old - new) / old) * 100 — la disminución relativa de conversión, no absoluta. Una caída de 7% a 6% es una disminución relativa de 14.3% — más alarmante de lo que suena en términos absolutos, por eso Dev usa la fórmula relativa para el umbral.
  • La simulación inyecta 100 eventos del modelo antiguo (10ms de latencia, 7% de conversión) y 20 eventos del modelo nuevo (15ms de latencia, 6% de conversión) — una proporción 5:1 que refleja un canary del 5%. El aumento de 50% en la latencia dispara la alerta.

Output:

Status: ALERT
  ALERT: Latency increased 50.0%

Metrics:
  OLD: latency=10.1ms, error_rate=0.00%, conversion=7.00%
  NEW: latency=15.1ms, error_rate=0.00%, conversion=5.00%

Este panel rastrea tanto métricas técnicas como de negocio y marca las regresiones automáticamente. En producción, ejecutarías esta verificación cada 5–15 minutos y alertarías al equipo si algo sale mal.

Pruebas A/B en la Mezcla: Comparando Modelos de Manera Justa

Los despliegues canary son una forma de prueba A/B: asignas a los usuarios de forma aleatoria al modelo antiguo o al nuevo y comparas los resultados. El inconveniente: con solo el 5% del tráfico en el modelo nuevo, necesitas ejecutar la prueba el tiempo suficiente para recolectar datos suficientes.

Digamos que el modelo nuevo tiene una tasa de conversión del 7% y el modelo antiguo del 7.1%. Esa es una diferencia mínima. ¿Es real o simplemente ruido? Con solo 50 usuarios en el modelo nuevo, no puedes saberlo. Necesitas cientos o miles para poder estar seguro.

La significancia estadística es la forma en que resolvemos esto. Ejecutamos una prueba que plantea: si los dos modelos fueran realmente idénticos, ¿qué tan probable sería que viéramos una diferencia de este tamaño solo por azar?

“Muy improbable” (valor p < 0.05) significa que la diferencia es estadísticamente significativa. “Bastante probable” (valor p > 0.05) significa que es solo ruido.

Vamos a programar esto:

from scipy import stats

class ABTestAnalyzer:
    """Runs statistical tests to compare old and new models."""
    def __init__(self, old_events: List[UserEvent], new_events: List[UserEvent]):
        self.old_events = old_events
        self.new_events = new_events
    
    def compare_conversion_rates(self):
        """Compare conversion rates using chi-square test."""
        old_conversions = sum(1 for e in self.old_events if e.conversion)
        old_total = len(self.old_events)
        
        new_conversions = sum(1 for e in self.new_events if e.conversion)
        new_total = len(self.new_events)
        
        # Chi-square test
        # Contingency table: [[conversions, non-conversions], ...]
        contingency_table = [
            [old_conversions, old_total - old_conversions],
            [new_conversions, new_total - new_conversions]
        ]
        
        chi2, p_value, dof, expected = stats.chi2_contingency(contingency_table)
        
        old_rate = old_conversions / old_total
        new_rate = new_conversions / new_total
        difference_pct = ((new_rate - old_rate) / old_rate) * 100
        
        return {
            'old_conversion_rate': old_rate,
            'new_conversion_rate': new_rate,
            'difference_pct': difference_pct,
            'p_value': p_value,
            'significant': p_value < 0.05,
            'old_sample_size': old_total,
            'new_sample_size': new_total
        }
    
    def compare_latencies(self):
        """Compare latencies using t-test."""
        old_latencies = [e.latency_ms for e in self.old_events]
        new_latencies = [e.latency_ms for e in self.new_events]
        
        t_stat, p_value = stats.ttest_ind(old_latencies, new_latencies)
        
        old_mean = sum(old_latencies) / len(old_latencies)
        new_mean = sum(new_latencies) / len(new_latencies)
        difference_ms = new_mean - old_mean
        
        return {
            'old_avg_latency_ms': old_mean,
            'new_avg_latency_ms': new_mean,
            'difference_ms': difference_ms,
            'p_value': p_value,
            'significant': p_value < 0.05
        }

# Simulate A/B test with 1000 old model users and 50 new model users
old_events = []
for i in range(1000):
    old_events.append(UserEvent(
        user_id=i,
        model='old',
        prediction=random.uniform(0, 1),
        latency_ms=random.gauss(10, 1),
        error=random.random() < 0.001,
        conversion=random.random() < 0.07
    ))

new_events = []
for i in range(50):
    new_events.append(UserEvent(
        user_id=1000 + i,
        model='new',
        prediction=random.uniform(0, 1),
        latency_ms=random.gauss(15, 2),
        error=random.random() < 0.001,
        conversion=random.random() < 0.06
    ))

# Run A/B test
analyzer = ABTestAnalyzer(old_events, new_events)

conversion_results = analyzer.compare_conversion_rates()
print("Conversion Rate Comparison:")
print(f"  Old model: {conversion_results['old_conversion_rate']:.2%}")
print(f"  New model: {conversion_results['new_conversion_rate']:.2%}")
print(f"  Difference: {conversion_results['difference_pct']:.1f}%")
print(f"  P-value: {conversion_results['p_value']:.4f}")
print(f"  Statistically significant: {conversion_results['significant']}")
print(f"  Sample sizes: Old={conversion_results['old_sample_size']}, New={conversion_results['new_sample_size']}")

latency_results = analyzer.compare_latencies()
print("\nLatency Comparison:")
print(f"  Old model: {latency_results['old_avg_latency_ms']:.2f}ms")
print(f"  New model: {latency_results['new_avg_latency_ms']:.2f}ms")
print(f"  Difference: {latency_results['difference_ms']:.2f}ms")
print(f"  P-value: {latency_results['p_value']:.4f}")
print(f"  Statistically significant: {latency_results['significant']}")

Este es el análisis estadístico que Dev ejecuta sobre sus datos del canary — la misma pregunta de “¿es la diferencia real o ruido?” del artículo anterior, ahora aplicada al tráfico de producción en vivo en lugar de un conjunto de holdout.

  • ABTestAnalyzer.__init__ — toma dos listas de objetos UserEvent: una del tráfico del modelo anterior y otra del modelo nuevo. Estos son los eventos acumulados durante el canary.
  • compare_conversion_rates() — la prueba de métricas de negocio. Cuenta las conversiones y no conversiones para cada modelo, construye una tabla de contingencia de 2×2 ([[old_conversions, old_non_conversions], [new_conversions, new_non_conversions]]) y ejecuta stats.chi2_contingency() — una prueba chi-cuadrado de independencia. El p-valor responde: “si ambos modelos tuvieran la misma tasa de conversión real, ¿qué tan probable es obtener una diferencia de este tamaño por azar?”
  • chi2, p_value, dof, expected = stats.chi2_contingency(contingency_table)chi2 es el estadístico de prueba, p_value es la probabilidad de observar esta diferencia bajo la hipótesis nula, dof son los grados de libertad (1 para una tabla de 2×2) y expected es la tabla de conteos esperados bajo la hipótesis nula.
  • difference_pct = ((new_rate - old_rate) / old_rate) * 100 — la diferencia porcentual relativa en la tasa de conversión. Un valor negativo significa que el modelo nuevo convierte peor.
  • compare_latencies() — la prueba de métricas técnicas. Extrae listas de latencias de ambos grupos de eventos y ejecuta stats.ttest_ind() — una prueba t de dos muestras independientes. Esto evalúa si las latencias promedio son significativamente diferentes. A diferencia de la conversión (binaria), la latencia es continua, por lo que una prueba t es la herramienta adecuada.
  • significant': p_value < 0.05 — el umbral de significancia convencional. Dev usa 0.05 para comprobaciones exploratorias, pero podría hacerlo más estricto, a 0.01, para decisiones de alto riesgo.
  • La simulación crea 1,000 eventos del modelo anterior (7% de conversión, 10ms de latencia) y 50 eventos del modelo nuevo (6% de conversión, 15ms de latencia) — la misma división canary del 5% de la sección anterior, ahora analizada estadísticamente.

La salida podría verse así:

Conversion Rate Comparison:
  Old model: 7.10%
  New model: 6.00%
  Difference: -15.5%
  P-value: 0.2847
  Statistically significant: False
  Sample sizes: Old=1000, New=50

Latency Comparison:
  Old model: 10.05ms
  New model: 15.12ms
  Difference: 5.07ms
  P-value: 0.0000
  Statistically significant: True

¿Qué nos indica esto? La tasa de conversión del nuevo modelo es 1.1 puntos porcentuales menor que la del modelo anterior (6% vs 7.1%), pero la diferencia no es estadísticamente significativa (valor p = 0.28). Con solo 50 usuarios en el nuevo modelo, no podemos estar seguros de que la diferencia sea real; podría ser ruido aleatorio.

La diferencia de latencia, sin embargo, es altamente significativa (valor p ≈ 0). El nuevo modelo es consistentemente más lento. Eso es un problema real.

Problemas del Mundo Real: Lo que Realmente Falla

Esto es lo que he aprendido al ver despliegues fallar:

Las definiciones de métricas importan más de lo que crees. Una ganancia de 1% en una métrica puede significar una regresión de 10% en otra. Define tus métricas antes de desplegar — qué cuenta como una conversión, cómo mides la latencia (¿Mediana? ¿P95? ¿Media?). Si te equivocas en esto, tomarás malas decisiones.

La latencia puede dispararse de forma inesperada. El nuevo modelo podría ser más lento por todo tipo de razones — un algoritmo diferente, mal ajuste de hardware, infraestructura mal configurada. En un caso que vi, un equipo desplegó un modelo que se ejecutaba 3x más lento que el anterior. El modelo en sí estaba bien; solo habían olvidado habilitar la aceleración de GPU. Lo detectaron en el despliegue canary al 5% del tráfico y revirtieron antes de que afectara a todos.

Los casos límite y los comportamientos de usuario poco frecuentes a menudo no aparecen en los datos de prueba, pero sí en producción. El nuevo modelo podría fallar con un tipo de entrada que es poco frecuente en tu conjunto de prueba, pero común en producción. Podría manejar los datos faltantes de manera diferente, o romperse con cuentas de usuario muy antiguas. Estos problemas solo se manifiestan bajo tráfico real.

La reversión puede ser lenta si no la planificas. Cuando necesitas revertir, tienes que hacerlo rápido. Ten preparado el procedimiento antes de desplegar. ¿Puedes regresar al modelo anterior en segundos, o tomará 30 minutos? En producción, 30 minutos es una eternidad.

Simulemos un despliegue que falla:

class FailingNewModel:
    """A new model that has a latency spike."""
    def predict(self, request: Request) -> float:
        # Simulate a latency spike: 50% of the time, the model is very slow
        if random.random() < 0.5:
            time.sleep(0.1)  # 100ms latency
        else:
            time.sleep(0.015)  # 15ms latency
        return sum(request.features.values()) / len(request.features)

class DeploymentMonitor:
    """Monitors a deployment and decides whether to roll back."""
    def __init__(self, latency_threshold_ms=20):
        self.latency_threshold_ms = latency_threshold_ms
        self.old_latencies = []
        self.new_latencies = []
    
    def record_latency(self, model: str, latency_ms: float):
        if model == 'old':
            self.old_latencies.append(latency_ms)
        else:
            self.new_latencies.append(latency_ms)
    
    def should_rollback(self) -> bool:
        """Check if we should roll back based on latency."""
        if len(self.old_latencies) < 100 or len(self.new_latencies) < 100:
            return False  # Not enough data yet
        
        old_avg = sum(self.old_latencies[-100:]) / 100  # Last 100 requests
        new_avg = sum(self.new_latencies[-100:]) / 100
        
        if new_avg > old_avg * (1 + self.latency_threshold_ms / 100):
            return True
        return False

# Simulate deployment with failing model
old_model = OldModel()
failing_model = FailingNewModel()
monitor = DeploymentMonitor(latency_threshold_ms=20)

print("Simulating deployment with latency spike...")
for i in range(500):
    request = Request(
        user_id=i,
        features={'feature_1': random.uniform(0, 1), 'feature_2': random.uniform(0, 1)}
    )
    
    # Route 5% to new model
    if random.random() < 0.05:
        start = time.time()
        failing_model.predict(request)
        latency = (time.time() - start) * 1000
        monitor.record_latency('new', latency)
    else:
        start = time.time()
        old_model.predict(request)
        latency = (time.time() - start) * 1000
        monitor.record_latency('old', latency)
    
    # Check for rollback every 100 requests
    if i > 0 and i % 100 == 0:
        if monitor.should_rollback():
            print(f"Request {i}: ROLLBACK TRIGGERED")
            print(f"  Old model avg latency: {sum(monitor.old_latencies[-100:]) / 100:.2f}ms")
            print(f"  New model avg latency: {sum(monitor.new_latencies[-100:]) / 100:.2f}ms")
            break
        else:
            print(f"Request {i}: All metrics look good")

Esta es la simulación de modo de falla que Dev ejecuta antes de su canary real: quiere verificar que su disparador de reversión se active correctamente cuando el nuevo modelo comience a presentar picos. El FailingNewModel imita un recomendador que es intermitentemente lento (quizás debido a un caché frío o a una ruta de código no optimizada con ciertas entradas).

  • FailingNewModel.predict() — el 50% de las solicitudes toma 100ms (un pico de latencia), el otro 50% toma 15ms (normal). Esta distribución de latencia bimodal es común en sistemas reales: una penalización por arranque en frío, un fallo de caché o una rama lenta en el código de inferencia que solo se activa con ciertas combinaciones de características.
  • DeploymentMonitor.__init__ — almacena el umbral de latencia (por defecto 20%) y dos listas de latencias, una por modelo. El umbral significa «revertir si la latencia promedio del nuevo modelo supera la del modelo anterior en más del 20%».
  • record_latency() — agrega la latencia medida de cada solicitud a la lista del modelo correspondiente.
  • should_rollback() — la verificación de ventana deslizante. Requiere al menos 100 muestras de cada modelo antes de tomar una decisión (evitando falsas alarmas por muestras demasiado pequeñas). Compara las últimas 100 latencias de cada modelo: si new_avg > old_avg * 1.2, devuelve True (revertir). Utilizar las últimas 100 solicitudes en lugar de todo el historial hace que el monitor sea reactivo a nuevas regresiones: un pico repentino no se diluirá con horas de buenos datos.
  • if random.random() < 0.05: — dirige el 5% del tráfico al modelo fallido, coincidiendo con el porcentaje de canary de las secciones anteriores.
  • if i > 0 and i % 100 == 0: — verifica la reversión cada 100 solicitudes. En producción, Dev verificaría de forma continua (cada 15 minutos) en lugar de cada N solicitudes, pero el principio es el mismo: verificar con la frecuencia suficiente para detectar una regresión antes de que afecte a demasiados usuarios.

Salida:

Simulating deployment with latency spike...
Request 100: All metrics look good
Request 200: All metrics look good
Request 300: ROLLBACK TRIGGERED
  Old model avg latency: 10.23ms
  New model avg latency: 57.45ms

El monitor detectó el pico de latencia y activó una reversión en la solicitud 300. En producción, esto significaría revertir al modelo anterior e investigar qué salió mal.

Poniéndolo Todo Junto: Una Lista de Verificación de Despliegue

Un despliegue seguro sigue un patrón predecible. Aquí te mostramos cómo lanzar un nuevo modelo sin sorpresas:

Paso 1: Despliegue en modo shadow (24-48 horas)

  • Despliega el nuevo modelo en modo shadow.
  • Ejecútalo en todo el tráfico, pero no muestres las predicciones a los usuarios.
  • Registra las predicciones y las métricas.
  • Verifica: ¿La latencia es aceptable? ¿Hay errores? ¿Las predicciones tienen sentido?
  • Si algo se ve mal, vuelve a desarrollo y corrígelo.
  • Si el modo shadow se ve limpio, pasa al paso 2.

Paso 2: Canary al 5% (1-2 horas)

  • Dirige el 5% del tráfico al nuevo modelo.
  • Monitorea la latencia, la tasa de errores y las métricas de negocio.
  • Revisa cada 15 minutos.
  • Si algo se ve mal, revierte inmediatamente.
  • Si el canary al 5% se mantiene por 1-2 horas, pasa al paso 3.

Paso 3: Canary al 10% (1-2 horas)

  • Sube al 10% del tráfico.
  • Monitorea por 1-2 horas.
  • Si algo se ve mal, revierte.
  • Si todo va bien, pasa al paso 4.

Paso 4: Canary al 25% (2-4 horas)

  • Sube al 25% del tráfico.
  • Monitorea por 2-4 horas.
  • Si algo se ve mal, revierte.
  • Si todo va bien, pasa al paso 5.

Paso 5: Canary al 50% (4-8 horas)

  • Sube al 50% del tráfico.
  • Monitorea por 4-8 horas.
  • Este es el punto sin retorno: ahora estás afectando a la mitad de tus usuarios.
  • Si algo se ve mal, revierte.
  • Si todo va bien, pasa al paso 6.

Paso 6: 100% del tráfico (24 horas)

  • Dirige todo el tráfico al nuevo modelo.
  • Sigue monitoreando por 24 horas.
  • Vigila problemas que solo surgen a escala.
  • Si todo se ve bien, has terminado.

Ahora vamos a programar esto como un pipeline de despliegue simulado:

class DeploymentPipeline:
    """Manages a staged deployment with shadow and canary phases."""
    def __init__(self, old_model, new_model):
        self.old_model = old_model
        self.new_model = new_model
        self.phase = 'shadow'
        self.canary_percentage = 0
        self.events = []
    
    def handle_request(self, request: Request) -> float:
        """Route request based on current phase."""
        if self.phase == 'shadow':
            # Run both models, return old model's prediction
            old_pred = self.old_model.predict(request)
            new_pred = self.new_model.predict(request)
            self.events.append({'phase': 'shadow', 'model': 'old', 'pred': old_pred})
            self.events.append({'phase': 'shadow', 'model': 'new', 'pred': new_pred})
            return old_pred
        else:  # canary
            # Route based on canary percentage
            if random.random() < self.canary_percentage / 100:
                pred = self.new_model.predict(request)
                self.events.append({'phase': 'canary', 'model': 'new', 'pred': pred})
            else:
                pred = self.old_model.predict(request)
                self.events.append({'phase': 'canary', 'model': 'old', 'pred': pred})
            return pred
    
    def advance_phase(self, new_phase: str, canary_percentage: int = 0):
        """Advance to the next phase."""
        self.phase = new_phase
        self.canary_percentage = canary_percentage
        print(f"Advanced to {new_phase} phase (canary={canary_percentage}%)")
    
    def rollback(self):
        """Roll back to the previous phase."""
        if self.phase == 'canary':
            self.canary_percentage = max(0, self.canary_percentage - 5)
            if self.canary_percentage == 0:
                self.phase = 'shadow'
            print(f"Rolled back to {self.phase} phase (canary={self.canary_percentage}%)")

# Simulate deployment
pipeline = DeploymentPipeline(OldModel(), NewModel())

print("Phase 1: Shadow Deployment")
for i in range(100):
    request = Request(user_id=i, features={'f1': random.uniform(0, 1), 'f2': random.uniform(0, 1)})
    pipeline.handle_request(request)
print(f"  Processed 100 requests. Shadow looks good.")

print("\nPhase 2: Canary at 5%")
pipeline.advance_phase('canary', 5)
for i in range(100):
    request = Request(user_id=100 + i, features={'f1': random.uniform(0, 1), 'f2': random.uniform(0, 1)})
    pipeline.handle_request(request)
print(f"  Processed 100 requests. Canary at 5% looks good.")

print("\nPhase 3: Canary at 10%")
pipeline.advance_phase('canary', 10)
for i in range(100):
    request = Request(user_id=200 + i, features={'f1': random.uniform(0, 1), 'f2': random.uniform(0, 1)})
    pipeline.handle_request(request)
print(f"  Processed 100 requests. Canary at 10% looks good.")

print("\nPhase 4: Canary at 25%")
pipeline.advance_phase('canary', 25)
for i in range(100):
    request = Request(user_id=300 + i, features={'f1': random.uniform(0, 1), 'f2': random.uniform(0, 1)})
    pipeline.handle_request(request)
print(f"  Processed 100 requests. Canary at 25% looks good.")

print("\nPhase 5: Canary at 50%")
pipeline.advance_phase('canary', 50)
for i in range(100):
    request = Request(user_id=400 + i, features={'f1': random.uniform(0, 1), 'f2': random.uniform(0, 1)})
    pipeline.handle_request(request)
print(f"  Processed 100 requests. Canary at 50% looks good.")

print("\nPhase 6: 100% Traffic")
pipeline.advance_phase('canary', 100)
for i in range(100):
    request = Request(user_id=500 + i, features={'f1': random.uniform(0, 1), 'f2': random.uniform(0, 1)})
    pipeline.handle_request(request)
print(f"  Processed 100 requests. 100% traffic looks good.")
print(f"\nDeployment complete! Total events: {len(pipeline.events)}")

Este es el pipeline de despliegue de extremo a extremo que Dev utiliza para su recomendador reentrenado — codifica el despliegue gradual completo de la lista de verificación anterior: primero shadow, luego canary al 5%, 10%, 25%, 50%, y finalmente 100%.

  • DeploymentPipeline.__init__ — almacena ambos modelos, comienza en la fase 'shadow' con canary_percentage=0, e inicializa una lista events para auditoría.
  • handle_request() — la lógica de enrutamiento, que se ramifica según self.phase. En modo shadow, ejecuta ambos modelos pero devuelve solo la predicción del modelo anterior (reflejando la clase ShadowDeployment de antes). En modo canary, enruta basándose en self.canary_percentage, igual que CanaryRelease.
  • advance_phase() — promueve el pipeline a la siguiente etapa. Dev invoca esto después de que la ventana de monitoreo de cada etapa pase sin alertas. new_phase y canary_percentage se establecen juntos — advance_phase('canary', 25) avanza a canary al 25%.
  • rollback() — revierte disminuyendo canary_percentage en 5 puntos. Si llega a 0, la fase vuelve a 'shadow'. En un sistema real, Dev también alertaría al equipo y congelaría posteriores promociones hasta que se investigue la regresión.
  • La simulación procesa 100 solicitudes por fase (un sustituto de las ventanas de monitoreo de 1-8 horas en la lista de verificación). En producción, Dev procesaría decenas de miles de solicitudes por fase y revisaría las métricas continuamente.
  • print(f"\nDeployment complete! Total events: {len(pipeline.events)}") — la línea de meta. El pipeline registra 600 eventos en total (200 de shadow — 100 por modelo — más 400 de canary), y el recomendador reentrenado de Dev ahora está sirviendo a los 10 millones de usuarios.

Salida:

Phase 1: Shadow Deployment
  Processed 100 requests. Shadow looks good.

Phase 2: Canary at 5%
Advanced to canary phase (canary=5%)
  Processed 100 requests. Canary at 5% looks good.

Phase 3: Canary at 10%
Advanced to canary phase (canary=10%)
  Processed 100 requests. Canary at 10% looks good.

Phase 4: Canary at 25%
Advanced to canary phase (canary=25%)
  Processed 100 requests. Canary at 25% looks good.

Phase 5: Canary at 50%
Advanced to canary phase (canary=50%)
  Processed 100 requests. Canary at 50% looks good.

Phase 6: 100% Traffic
Advanced to canary phase (canary=100%)
  Processed 100 requests. 100% traffic looks good.

Deployment complete! Total events: 600

Herramientas y Plataformas Útiles

No tienes que construir despliegues shadow y canary desde cero. Varias herramientas existentes manejan esto.

Kubernetes con Istio es el estándar de la industria. Istio es una malla de servicios que se sitúa entre tus servicios y gestiona el enrutamiento del tráfico. Defines los despliegues canary en Istio, y este desplaza gradualmente el tráfico del modelo antiguo al nuevo. También gestiona la recopilación de métricas y la reversión automática.

Flagger se ejecuta sobre Kubernetes y automatiza los despliegues canary. Estableces umbrales para métricas como la latencia y la tasa de errores, y Flagger revierte automáticamente si se superan esos umbrales.

Los Feature flags (LaunchDarkly, Unleash) te permiten controlar qué usuarios ven qué modelo sin tener que volver a desplegar. Puedes hacer lanzamientos canary de esta manera: mostrar el nuevo modelo al 5% de los usuarios sin tocar tu infraestructura de despliegue.

Las plataformas de monitoreo (Datadog, New Relic, Prometheus) recopilan métricas de tus modelos y te alertan sobre regresiones. Se integran con herramientas de despliegue para que puedas revertir automáticamente cuando las métricas se deterioran.

Las plataformas específicas de ML (Seldon, Cortex, Qwak) tienen soporte integrado para shadow y canary. Tú defines tus modelos y la estrategia de despliegue, y la plataforma se encarga del resto.

Para un equipo pequeño que recién empieza, me inclinaría por:

  1. Empezar con feature flags para los lanzamientos canary: simple, no se necesita infraestructura.
  2. Añadir una herramienta de monitoreo como Prometheus o Datadog para rastrear las métricas.
  3. A medida que escales, migrar a Kubernetes con Istio para un enrutamiento de tráfico más sofisticado.

Qué Sigue: De Canary a Despliegue Continuo

Una vez que te sientas cómodo con los despliegues shadow y canary, la automatización es el siguiente paso natural. En lugar de promover manualmente de 5% a 10% a 25%, dejas que el sistema gestione esos saltos por sí solo.

El despliegue continuo significa que los nuevos modelos entran en producción automáticamente una vez que pasan las pruebas canary. Podrías lanzar un nuevo modelo cada día, o cada hora. Eso requiere un monitoreo robusto y una reversión rápida: los cimientos que has construido en este artículo.

A continuación: pipelines de reentrenamiento. Ahora mismo, entrenas un modelo una vez y lo despliegas. En producción, los datos sufren deriva. El comportamiento de los usuarios cambia. El rendimiento se degrada. Un pipeline de reentrenamiento gestiona esto reentrenando con datos nuevos y desplegando la nueva versión si supera al modelo actual.

Luego está el aprendizaje en línea: modelos que se actualizan a sí mismos a medida que llegan nuevos datos, sin reentrenar desde cero.

Dejaremos esos temas para más adelante. Por ahora, tienes un conjunto de herramientas para desplegar modelos de forma segura: despliegues shadow para pruebas de bajo riesgo, lanzamientos canary para un despliegue gradual, y métricas para detectar problemas de manera temprana.

Resumen

Lo que cubrimos:

  1. Por qué es importante: Desplegar un modelo sin probar es riesgoso. Un modelo defectuoso puede hundir los ingresos y erosionar la confianza del usuario en cuestión de horas.

  2. Despliegue en sombra (Shadow deployment): Ejecuta el nuevo modelo en cada solicitud, pero no expongas las predicciones a los usuarios. Registra las métricas y compáralas sin conexión. Cero riesgo para el usuario, pero es costoso y no mostrará el impacto en el negocio.

  3. Lanzamientos canary (Canary releases): Dirige un pequeño porcentaje del tráfico al nuevo modelo. Compara las métricas entre los grupos. Revierte si algo se ve mal. Más rápido y económico que el despliegue en sombra, con algo de riesgo.

  4. Métricas que importan: Monitorea las métricas técnicas (latencia, tasa de error) para obtener alertas tempranas, y las métricas de negocio (conversión, ingresos) para la validación final.

  5. Significancia estadística: Con porcentajes bajos en el lanzamiento canary, necesitas suficientes datos para estar seguro de que las diferencias son reales y no ruido. Usa pruebas estadísticas para validarlo.

  6. La lista de verificación de despliegue: Despliegue en sombra (24-48h) → Canary 5% (1-2h) → 10% → 25% → 50% → 100% (24h). Monitorea en cada etapa. Revierte si es necesario.

  7. Herramientas: Kubernetes, Istio, Flagger, feature flags y plataformas de monitoreo pueden automatizar gran parte de esto.

Estás listo para desplegar modelos de forma segura. Comienza con un despliegue en sombra para generar confianza y luego pasa a los lanzamientos canary. Monitorea de cerca en cada etapa. En producción, lento y seguro vence a rápido y roto.

El nuevo sistema de recomendación se implementa de forma segura — primero en sombra, luego 5%, 10%, 25%, 50%, 100% — y reemplaza por completo al anterior sin un solo incidente que reduzca los ingresos. Pero mirando hacia atrás en todo el trayecto, desde el primer pipeline de entrenamiento pasando por la detección de drift, el reentrenamiento, la validación estadística y el despliegue gradual, Dev nota un patrón: casi todos los incidentes se remontan a la misma causa raíz — la inconsistencia en el cálculo de características entre el entrenamiento y la inferencia, el mismo train-serving skew que encontró por primera vez en su pipeline inicial. Lo ha ido solucionando con parches mediante monitoreo, detección de drift y prácticas de despliegue cuidadosas. Está cansado de parchear. Decide arreglarlo correctamente, de una vez por todas — con un feature store.

Comprueba tu comprensión

Las preguntas a continuación avanzan desde la memoria simple hasta el diseño abierto, siguiendo aproximadamente la Taxonomía de Bloom.

Recordar ¿Cuál es la diferencia clave entre un despliegue en la sombra y un lanzamiento canario —específicamente, si los usuarios reales alguna vez ven la predicción del nuevo modelo?

Comprender En tus propias palabras, explica por qué el despliegue en la sombra puede informarte sobre la latencia y los errores, pero no puede informarte sobre las métricas de negocio como la tasa de conversión.

Aplicar Usando la fórmula de verificación de regresión del artículo ((new_latency - old_latency) / old_latency * 100), si el modelo antiguo promedia 12ms y el modelo nuevo promedia 16ms, ¿qué porcentaje de aumento de latencia se reportaría y activaría el umbral predeterminado del 20% del artículo?

Analizar La prueba A/B del artículo muestra una diferencia en la tasa de conversión de -15.5% que no es estadísticamente significativa (p=0.28) con solo 50 usuarios en el modelo nuevo, mientras que una diferencia de latencia es altamente significativa (p≈0.0000) con ese mismo tamaño de muestra. Explica paso a paso por qué el mismo tamaño de muestra puede ser “suficiente” para detectar con confianza un tipo de diferencia pero no otro.

Evaluar La lista de verificación para el lanzamiento por etapas del artículo avanza del 5% al 10%, al 25%, al 50% y al 100%, monitoreando una ventana fija en cada etapa (1-2 horas, luego 2-4, luego 4-8). Critica el uso de ventanas de tiempo fijas en lugar de tamaños de muestra fijos como criterio de promoción: ¿qué podría salir mal en horas de bajo tráfico (por ejemplo, 3am) que no sucedería durante el tráfico pico?

Crear Diseña una política de activación de reversión para un escenario que el artículo no cubre: un modelo de detección de fraude donde la “tasa de errores” no es binaria (caída frente a no caída), sino que es “marcar una transacción legítima como fraude”. ¿Qué métrica rastrearías en lugar de la latencia/tasa de errores y qué umbral justificaría una reversión automática?


Artículos relacionados

Referencias y lecturas adicionales

  • Kohavi, R., Crook, T., Longbotham, R., Frasca, B., Kansal, S., & Pfeiffer, R. (2009). “Experimentos controlados en la web: encuesta y guía práctica.” Data Mining and Knowledge Discovery, 18(1), 140–181. — la referencia canónica sobre la ejecución de experimentos controlados en línea, que abarca la aleatorización, el tamaño de la muestra y el rigor estadístico necesario para confiar en los resultados de las pruebas A/B en producción.
  • Beyer, B., Jones, C., Petoff, J., & Murphy, N. (2016). Ingeniería de confiabilidad del sitio: cómo ejecuta Google los sistemas de producción. O’Reilly Media. — cubre estrategias de despliegue progresivo, despliegues canary y la infraestructura de monitoreo necesaria para realizar despliegues seguros a gran escala.

Esta traducción fue generada automáticamente y puede contener errores. Si el idioma inglés es tu preferencia, puedes leer el artículo original en inglés .

«Aplica lo que aprendiste» es para suscriptores Supporter e Insider.

Suscríbete para desbloquear los ejercicios de este artículo.

Ver planes

¿Buscas otra cosa?

Busca en todos los artículos por título, resumen o tema.