Módulo 7: Shipping Ai Safely

La tasa de acuerdo: `shadowCompare()` y cuándo un modelo está listo para un canary

Descripción

La lección 3 dejó recs-v2 corriendo en shadow, con cuatro requests de ejemplo que ya insinuaban una diferencia. Cuatro requests no alcanzan para confiar en ninguna conclusión — necesitas un volumen real, y una forma sistemática de resumirlo. Esta lección construye esa forma: shadowCompare(), la función central de todo este módulo, que toma las salidas de recs-v1 y recs-v2 sobre el mismo tráfico y calcula la tasa de acuerdo —en qué porcentaje de los casos los dos modelos coinciden— junto con un mapa de dónde, específicamente, difieren. Esa tasa de acuerdo, no una corazonada sobre si "el modelo nuevo se ve mejor", es lo que decide si vale la pena intentar un primer canary.

Conexión con el módulo. Esta lección reutiliza recsV1() y recsV2() exactamente como quedaron en la lección 3, sin cambiarles una línea, y las corre sobre 50 requests reales de shadow traffic en vez de los cuatro de ejemplo. shadowCompare(), la función que construyes hoy, es la que la lección 7 va a usar para decidir formalmente si migrar, y la que el proyecto de la lección 8 reutiliza, también sin cambios, sobre el caso completo.

Una analogía: del asiento de al lado a un tramo corto, con el capitán listo para retomar

El piloto en prácticas de la lección 3 lleva ya muchas horas observando desde el asiento de al lado, tomando sus propias decisiones sin tocar los controles. En algún momento, esa observación tiene que convertirse en un número: de las últimas cien maniobras que el capitán ejecutó, ¿en cuántas el piloto en prácticas habría hecho exactamente lo mismo? Si la respuesta es "en 98 de 100", y las dos que difieren son maniobras menores sin consecuencia, el capitán puede empezar a considerar cederle un tramo corto y sencillo del vuelo, con la mano lista para retomar el control al primer indicio de problema — eso es un canary. Si la respuesta es "en 60 de 100, y las 40 diferencias incluyen aterrizajes", ningún capitán responsable le cede el control a nadie todavía, sin importar cuánto haya practicado.

shadowCompare() es exactamente ese conteo, aplicado a recs-v2. No decide por sí sola si el modelo nuevo "es mejor" — decide algo más simple y más urgente: qué tan seguido decide lo mismo que el modelo de siempre, y en qué situaciones específicas no. Esa información, con números concretos en la mano, es la que hace posible una conversación real sobre si vale la pena intentar un canary — y en qué áreas conviene mirar con más cuidado durante ese primer tramo.

Ejemplo trabajado: shadowCompare() sobre 50 requests reales de shadow traffic

Vamos a generar un conjunto realista de tráfico de shadow —35 compradores con historial de compras, 15 compradores nuevos (cold-start) repartidos en cuatro regiones— y correr recsV1() y recsV2() de la lección 3, sin cambiarles nada, sobre cada uno:

// buildMercadoShadowTraffic: genera 50 requests reales de shadow, en dos
// segmentos -- compradores con historial (35) y compradores nuevos / cold-start (15).
const CATEGORIES = ['electronics', 'home', 'fashion', 'sports', 'grocery', 'beauty', 'toys'];
const REGIONS = ['north', 'south', 'east', 'west'];

function buildMercadoShadowTraffic() {
  const requests = [];
  for (let i = 0; i < 35; i++) {
    requests.push({
      requestId: 'req-hist-' + String(i).padStart(2, '0'),
      segment: 'with-history',
      hasHistory: true,
      lastPurchaseCategory: CATEGORIES[i % CATEGORIES.length],
      region: REGIONS[i % REGIONS.length],
    });
  }
  for (let i = 0; i < 15; i++) {
    requests.push({
      requestId: 'req-cold-' + String(i).padStart(2, '0'),
      segment: 'cold-start',
      hasHistory: false,
      lastPurchaseCategory: null,
      region: REGIONS[i % REGIONS.length],
    });
  }
  return requests;
}

// shadowCompare: la tasa de acuerdo global, MAS un desglose por segmento y la
// lista de diferencias concretas (donde, no solo cuanto).
function shadowCompare(oldOutputs, newOutputs) {
  const bySegment = {};
  const differences = [];
  let agreements = 0;
  oldOutputs.forEach((oldReq, i) => {
    const newReq = newOutputs[i];
    const agree = oldReq.topRec === newReq.topRec;
    const seg = oldReq.segment;
    if (!bySegment[seg]) bySegment[seg] = { total: 0, agreements: 0 };
    bySegment[seg].total++;
    if (agree) {
      bySegment[seg].agreements++;
      agreements++;
    } else {
      differences.push({ requestId: oldReq.requestId, segment: seg, oldTopRec: oldReq.topRec, newTopRec: newReq.topRec });
    }
  });
  const total = oldOutputs.length;
  const agreementRate = (agreements / total) * 100;
  const segmentBreakdown = Object.entries(bySegment).map(([segment, s]) => ({
    segment, total: s.total, agreements: s.agreements, agreementRate: (s.agreements / s.total) * 100,
  }));
  return { total, agreements, agreementRate, differences, segmentBreakdown };
}

// recsV1 y recsV2, EXACTAMENTE como quedaron en la leccion 3.
function recsV1(request) {
  if (request.hasHistory) return request.lastPurchaseCategory;
  return 'electronics';
}
const REGION_TRENDING = { north: 'electronics', south: 'home', east: 'fashion', west: 'sports' };
function recsV2(request) {
  if (request.hasHistory) return request.lastPurchaseCategory;
  return REGION_TRENDING[request.region];
}

const mercadoRequests = buildMercadoShadowTraffic();
const oldOutputs = mercadoRequests.map((r) => ({ requestId: r.requestId, segment: r.segment, topRec: recsV1(r) }));
const newOutputs = mercadoRequests.map((r) => ({ requestId: r.requestId, segment: r.segment, topRec: recsV2(r) }));

console.log('=== shadowCompare sobre 50 requests reales de shadow traffic ===\n');
const comparison = shadowCompare(oldOutputs, newOutputs);
console.log('Total requests: ' + comparison.total);
console.log('Acuerdos: ' + comparison.agreements);
console.log('Tasa de acuerdo global: ' + comparison.agreementRate.toFixed(1) + '%');
console.log('\nDesglose por segmento:');
comparison.segmentBreakdown.forEach((s) => {
  console.log('  ' + s.segment.padEnd(14) + 'total=' + s.total + '  acuerdos=' + s.agreements + '  tasa=' + s.agreementRate.toFixed(1) + '%');
});
console.log('\nPrimeras 5 diferencias (de ' + comparison.differences.length + ' totales):');
comparison.differences.slice(0, 5).forEach((d) => {
  console.log('  ' + d.requestId + ' [' + d.segment + ']  recs-v1=' + d.oldTopRec + '  recs-v2=' + d.newTopRec);
});

Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:

=== shadowCompare sobre 50 requests reales de shadow traffic ===

Total requests: 50
Acuerdos: 39
Tasa de acuerdo global: 78.0%

Desglose por segmento:
  with-history  total=35  acuerdos=35  tasa=100.0%
  cold-start    total=15  acuerdos=4  tasa=26.7%

Primeras 5 diferencias (de 11 totales):
  req-cold-01 [cold-start]  recs-v1=electronics  recs-v2=home
  req-cold-02 [cold-start]  recs-v1=electronics  recs-v2=fashion
  req-cold-03 [cold-start]  recs-v1=electronics  recs-v2=sports
  req-cold-05 [cold-start]  recs-v1=electronics  recs-v2=home
  req-cold-06 [cold-start]  recs-v1=electronics  recs-v2=fashion

El número que probablemente llamaría primero la atención es el 78.0% global: casi ocho de cada diez requests, los dos modelos coinciden. Pero fíjate en lo que el desglose por segmento revela, y que el número global esconde por completo: entre los compradores con historial, la tasa de acuerdo es 100.0% —perfecta, sin ninguna diferencia—, mientras que entre los compradores cold-start, la tasa cae a 26.7% —los dos modelos coinciden en menos de tres de cada diez casos—. El 22% de diferencia global no está repartido de forma pareja por todo el tráfico: está concentrado casi por completo en un solo segmento, el de los compradores nuevos. Un equipo que solo mirara el 78.0% y decidiera "está bastante bien, migremos" estaría ignorando que, para un tipo entero de comprador, el modelo nuevo se comporta de forma casi opuesta al modelo de siempre.

Por qué el desglose importa más que el número global

Este es, probablemente, el punto más importante de la lección, y vale la pena decirlo sin rodeos: una tasa de acuerdo global, sola, puede ocultar exactamente el problema que más te interesa encontrar. Dos escenarios completamente distintos pueden producir el mismo 78%: uno donde las diferencias están dispersas de forma pareja y aleatoria por todo el tráfico —sin ningún patrón identificable—, y otro, como el de recommendations, donde las diferencias están concentradas en un segmento específico y reconocible. El primer escenario podría, razonablemente, ser "ruido aceptable". El segundo es una señal clara de que la nueva estrategia de cold-start de recs-v2 —tendencia regional en vez de tendencia general— cambia el comportamiento del modelo de forma sistemática, no aleatoria, exactamente donde Mercado tiene menos información sobre el comprador para empezar.

Esto no significa, todavía, que la estrategia de recs-v2 para cold-start sea peor —podría, de hecho, ser mejor: recomendar según tendencias regionales es, en principio, una idea razonable—. Lo que significa es que es distinta, de forma concentrada y predecible, y eso es exactamente lo que hay que mirar con cuidado antes de decidir cualquier cosa. shadowCompare() no contesta "¿es mejor recs-v2?" —esa pregunta, una vez que decidas exponer tráfico real, se contesta con el mismo rigor estadístico que la guía de métricas ya usó para recommendations—. Lo que contesta es la pregunta anterior, la que tiene que resolverse primero: ¿sé, con precisión, dónde el modelo nuevo decide distinto, antes de que esa diferencia le llegue a un comprador real?

Con este resultado en la mano, la decisión razonable no es "sí, canary" ni "no, cancelar" — es "sí, pero investiga primero por qué cold-start difiere tanto, y con qué consecuencia real para el comprador", antes de exponer ese segmento específico a un canary. Formalizar exactamente esa decisión —con un umbral explícito, no con una lectura informal del número— es el trabajo de la lección 7.

Errores comunes

Reportar solo la tasa de acuerdo global, sin el desglose por segmento. Qué pasa: alguien resume el resultado de shadowCompare() como "78% de acuerdo, bastante bien" en una actualización al equipo, sin mencionar que ese número esconde un 100% en un segmento y un 26.7% en otro. Por qué pasa: un solo número es más fácil de comunicar y recordar que una tabla desglosada, y 78% suena, de entrada, como un resultado razonablemente positivo. Cómo detectarlo: si el reporte de un shadow test no puede contestar "¿el desacuerdo está concentrado en algún segmento identificable, o repartido de forma pareja?", falta la mitad de la información que la comparación produjo. Cómo corregirlo: como en el ejemplo de esta lección, reporta siempre el desglose por segmento junto al número global — la decisión correcta depende casi por completo de si el 22% de diferencia está concentrado o disperso.

Tratar cualquier tasa de acuerdo por debajo de 100% como una señal de alarma. Qué pasa: el equipo ve que recs-v2 no coincide perfectamente con recs-v1 y concluye que algo está mal con el modelo nuevo, sin considerar que cierto grado de diferencia es exactamente lo que se espera de un modelo distinto —y en algunos casos, deseable, si la diferencia representa una mejora real—. Por qué pasa: "100% de acuerdo" se siente como el resultado ideal, y cualquier desviación de ahí se lee, por default, como un problema. Cómo detectarlo: si la reacción a cualquier tasa de acuerdo menor a 100% es "hay que investigar por qué el modelo está mal", sin distinguir entre una diferencia concentrada y sospechosa y una diferencia dispersa y esperable, falta este matiz. Cómo corregirlo: un modelo nuevo que coincidiera al 100% con el viejo probablemente no cambió nada útil — el objetivo de shadowCompare() no es maximizar el acuerdo, es entender el desacuerdo, con suficiente detalle para decidir si es un riesgo o una mejora.

Correr shadowCompare() con muy pocos requests y confiar en el resultado igual que con una muestra grande. Qué pasa: alguien ejecuta la comparación con solo los cuatro requests de ejemplo de la lección 3 —o un número igual de chico— y trata la tasa de acuerdo resultante con la misma confianza que le daría a un resultado sobre miles de requests reales. Por qué pasa: shadowCompare() corre igual de rápido con cuatro requests que con cincuenta mil, y no hay ninguna señal visual en el código que distinga una muestra confiable de una demasiado chica. Cómo detectarlo: si el total en el resultado de shadowCompare() es de un solo dígito, o si algún segmento tiene menos de una decena de casos, cualquier tasa de acuerdo calculada sobre ese segmento tiene un margen de error demasiado grande para decidir nada con ella. Cómo corregirlo: antes de tomar cualquier decisión con el resultado, confirma que cada segmento relevante —no solo el total— tiene suficiente volumen. Un 26.7% de acuerdo sobre 15 requests, como en el ejemplo de esta lección, ya es una muestra razonable para el cold-start de Mercado, pero conviene seguir acumulando shadow traffic antes de tratar ese número como definitivo.

Ejercicios

Ejercicio 1 — Calcula un segmento nuevo a mano. Si Mercado agregara un tercer segmento, "compradores VIP" (10 requests, con historial de compras pero criterio especial), y recsV1() y recsV2() coincidieran en 7 de esos 10 casos, ¿cuál sería la agreementRate de ese segmento? ¿Cómo cambiaría la tasa de acuerdo global si se agregan esos 10 requests al conjunto de 50 de esta lección (39 acuerdos previos, sumando estos 7)?

Ver solución

La tasa del segmento VIP sería 7 / 10 * 100 = 70.0%. La tasa global, con el segmento nuevo agregado, sería (39 + 7) / (50 + 10) * 100 = 46 / 60 * 100 = 76.6...%, redondeado a 76.7% — apenas por debajo del 78.0% original, porque el segmento VIP, con un acuerdo del 70%, es un poco peor que el promedio anterior, pero no drásticamente distinto. Este ejercicio ilustra que agregar un segmento nuevo con una tasa de acuerdo intermedia mueve el número global mucho menos que un segmento con una tasa muy baja, como el cold-start de esta lección.

Ejercicio 2 — Diagnostica sin ejecutar código. Mirando solo las 5 diferencias impresas en el ejemplo de esta lección, ¿qué tienen en común los valores de recs-v1 en todas ellas? ¿Qué te dice eso sobre la estrategia de cold-start del modelo viejo, comparada con la del modelo nuevo?

Ver solución

En las 5 diferencias mostradas, recs-v1 recomienda siempre electronics — porque, para cualquier comprador cold-start, recsV1() devuelve la misma categoría fija (la tendencia general del sitio), sin importar la región. recs-v2, en cambio, varía (home, fashion, sports) según la región del comprador. Esto confirma, con solo mirar los datos, lo que el código ya revela: recs-v1 trata a todos los compradores cold-start igual, mientras recs-v2 los distingue por región — la fuente exacta de la caída en la tasa de acuerdo de ese segmento.

Ejercicio 3 — Predicción sobre la lección 7. Con una tasa de acuerdo global de 78.0% pero solo 26.7% en cold-start, ¿crees que una función de decisión razonable debería mirar únicamente el número global antes de autorizar un canary, o debería considerar también el peor segmento? Justifica en 2-3 frases.

Ver solución

Debería considerar también el peor segmento, no solo el global. Un umbral que solo mire el 78.0% podría autorizar un canary sin que nadie haya investigado por qué el segmento cold-start difiere tanto — y si esa diferencia resulta ser una mejora, perfecto, pero si resulta ser un problema (por ejemplo, recomendaciones de menor calidad para compradores nuevos, justo el momento donde Mercado más necesita causarles una buena primera impresión), el canary lo expondría a ese riesgo sin que ninguna señal previa lo hubiera anticipado. La lección 7 construye exactamente esta idea: una decisión que cruza el acuerdo global contra un umbral, y el acuerdo del segmento más crítico contra un umbral propio, antes de autorizar cualquier canary.

Resumen y siguiente paso

En esta lección construiste shadowCompare(), la función central del módulo, y la corriste sobre 50 requests reales de shadow traffic de Mercado: 78.0% de acuerdo global, pero con una brecha marcada entre el 100.0% del segmento con historial y el 26.7% del segmento cold-start —11 diferencias, todas concentradas ahí—. Confirmaste la idea central de la lección: una tasa de acuerdo global, sin desglosar, puede esconder exactamente el patrón que más importa investigar antes de exponer un modelo nuevo a compradores reales.

Antes de avanzar deberías poder: explicar qué mide la tasa de acuerdo y qué no mide (no dice cuál modelo es "mejor"); leer un desglose por segmento y reconocer cuándo un desacuerdo está concentrado versus disperso; y justificar por qué ninguna decisión de migración debería basarse solo en el número global.

La lección 5 cambia de tema: qué pasa cuando el modelo nuevo, ya en producción —en shadow, en canary, o en pleno rollout—, produce una salida que alguien reporta como un problema real. La investigación de ese incidente empieza con una pregunta que un bug de código normal no necesita hacerse: ¿esto se reproduce igual, dos veces seguidas?

Recursos

  • Google SRE Workbook, Capítulo 16, "Canarying Releases" — sre.google/workbook/canarying-releases. El capítulo que formaliza la práctica de exponer primero a una fracción chica y evaluar antes de continuar — el paso que un resultado alto y consistente de shadowCompare() autoriza a considerar. En inglés.
  • Amazon SageMaker AI, "Shadow tests" — docs.aws.amazon.com/sagemaker/latest/dg/shadow-tests.html. Describe cómo estos tests están pensados exactamente para decidir, con datos reales de operación, si conviene promover una variante nueva a producción — el mismo tipo de decisión que shadowCompare() informa en esta lección. En inglés.