Módulo 8: Project Ship Mercados Recommendations
Iterar: migrar el modelo con shadow mode
Descripción
El postmortem de la lección anterior dejó un action item concreto: migrar el motor de recomendaciones de v1 —síncrono, sin timeout, el origen real de la regresión de latencia— a v2, una arquitectura de inferencia más rápida. Pero "más rápida" no es lo mismo que "segura de lanzar a ciegas" — y este v2 no llega a esta lección sin historia: es la segunda iteración del mismo modelo que el módulo 7 dejó en shadow. Aquella primera ronda —shadowCompare() sobre 50 requests reales— encontró un 78.0% de acuerdo global, pero apenas 26.7% en el segmento cold-start, muy por debajo del umbral crítico de 60% que modelMigrationDecision() exige: el modelo no se migró entonces. El equipo diagnosticó exactamente esa estrategia de cold-start y la corrigió antes de proponer este v2 de nuevo. Esta lección corre esa versión corregida en shadow mode una vez más —en paralelo, sin afectar a ningún comprador real— y aplica, sin cambiar una línea, el mismo shadowCompare() y el mismo modelMigrationDecision() que el módulo 7 construyó, para confirmar si el cold-start realmente quedó arreglado esta vez.
Conexión con el módulo. Esta lección reutiliza, sin cambiarles una línea, shadowCompare() y modelMigrationDecision() tal como quedaron en las lecciones 4 y 7 del módulo 7 —las dos funciones centrales de decisión que evitan que un acuerdo global alto esconda un segmento roto—, y construye redactPII(), la pieza de integración de este capstone para la privacidad de los logs que describe la lección 6 de ese mismo módulo. El resultado de esta lección —¿es seguro migrar, con el segmento crítico ya corregido?— es la condición que la lección 7 necesita cumplida antes de re-lanzar recommendations.
Una analogía: el piloto en prácticas, de vuelta después de repasar la maniobra que falló
Antes de que un piloto en entrenamiento tome los controles de un vuelo comercial real, pasa horas en el asiento del copiloto, observando y prediciendo qué haría en cada situación —sin tocar nada—, mientras el piloto con experiencia vuela de verdad. La primera vez que el capitán revisó esas predicciones, encontró algo específico: en los aterrizajes con pasajeros nuevos a bordo, el piloto en prácticas fallaba la mayoría de las veces. No lo autorizó a tomar los controles — lo mandó a repasar exactamente esa maniobra.
Esta lección es el segundo repaso, después de ese entrenamiento específico. El piloto en prácticas —v2— vuelve a observar en paralelo con el capitán —v1, todavía a cargo de verdad—, y el capitán vuelve a revisar sus predicciones con el mismo criterio exacto de la primera vez: no solo "¿coincide la mayoría de las veces?", sino "¿coincide también, específicamente, en la maniobra que falló antes?". Si la respuesta a las dos preguntas es sí, es evidencia real de que el repaso funcionó. Si la maniobra específica sigue fallando, no importa qué tan bien se vea el promedio general — todavía no es momento de ceder el control.
Ejemplo trabajado: shadowCompare() y modelMigrationDecision(), reusados de M7, sobre la segunda iteración de v2
// M8 L06: corre la SEGUNDA iteracion de v2 en shadow (con el cold-start ya corregido)
// y aplica, sin cambiarles una linea, shadowCompare() (M7 L4) y modelMigrationDecision()
// (M7 L7) -- el mismo par de funciones que detecto el problema de cold-start la primera
// vez. redactPII() protege los datos personales del log de la comparacion antes de
// guardarlo -- la practica de privacidad que el modulo 7 describe para lanzar IA con
// seguridad.
// shadowCompare: EXACTAMENTE la version segment-aware de M7 L4 -- tasa de acuerdo
// global MAS desglose por segmento, para que un promedio alto no esconda un segmento roto.
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 };
}
// modelMigrationDecision: EXACTAMENTE la version de M7 L7 -- cruza DOS umbrales,
// el global Y el del segmento mas critico, antes de autorizar un canary.
function modelMigrationDecision({ agreementRate, minAgreement, criticalSegmentAgreementRate, minCriticalSegmentAgreement }) {
if (agreementRate < minAgreement) {
return 'NO promuevas a canary: la tasa de acuerdo global esta por debajo del minimo -- revisa las diferencias primero';
}
if (criticalSegmentAgreementRate < minCriticalSegmentAgreement) {
return 'NO promuevas a canary todavia: un segmento critico difiere demasiado -- diagnostica esa estrategia antes de exponer trafico real';
}
return 'promueve recs-v2 a canary (1%), vigilando los guardrails de M4 en cada etapa';
}
function redactPII(logEntry, fields) {
const redacted = { ...logEntry };
fields.forEach((f) => { if (redacted[f] !== undefined) redacted[f] = '[REDACTED]'; });
return redacted;
}
// buildShadowTraffic: 20 requests reales de shadow -- 7 compradores con historial
// (with-history) y 13 compradores nuevos (cold-start), el segmento que la primera
// iteracion de v2 (modulo 7) dejo sin resolver.
function buildShadowTraffic() {
const requests = [];
for (let i = 0; i < 20; i++) {
requests.push({ requestId: 'buyer-' + i, segment: i < 7 ? 'with-history' : 'cold-start' });
}
return requests;
}
console.log('=== Parte 1: shadowCompare -- v2 (iteracion 2, cold-start corregido) corre en paralelo ===\n');
const requests = buildShadowTraffic();
const oldTopRecs = ['sku-104','sku-221','sku-330','sku-104','sku-558','sku-221','sku-777','sku-104','sku-330','sku-909',
'sku-221','sku-104','sku-558','sku-330','sku-777','sku-221','sku-104','sku-909','sku-330','sku-558'];
const newTopRecs = ['sku-104','sku-221','sku-330','sku-104','sku-558','sku-221','sku-777','sku-441','sku-330','sku-909',
'sku-221','sku-104','sku-558','sku-330','sku-616','sku-221','sku-104','sku-909','sku-330','sku-558'];
const oldOutputs = requests.map((r, i) => ({ requestId: r.requestId, segment: r.segment, topRec: oldTopRecs[i] }));
const newOutputs = requests.map((r, i) => ({ requestId: r.requestId, segment: r.segment, topRec: newTopRecs[i] }));
const shadow = shadowCompare(oldOutputs, newOutputs);
console.log('agreementRate global=' + shadow.agreementRate.toFixed(1) + '% (' + shadow.agreements + '/' + shadow.total + ')');
shadow.segmentBreakdown.forEach((s) => {
console.log(' ' + s.segment.padEnd(14) + 'tasa=' + s.agreementRate.toFixed(1) + '% (' + s.agreements + '/' + s.total + ')');
});
console.log('diffs: ' + shadow.differences.map((d) => d.requestId + ' [' + d.segment + ']: ' + d.oldTopRec + ' -> ' + d.newTopRec).join(', '));
console.log('\n=== Parte 2: modelMigrationDecision -- el mismo doble umbral que detecto el problema en M7 ===\n');
const coldStart = shadow.segmentBreakdown.find((s) => s.segment === 'cold-start');
const decision = modelMigrationDecision({
agreementRate: shadow.agreementRate,
minAgreement: 70,
criticalSegmentAgreementRate: coldStart.agreementRate,
minCriticalSegmentAgreement: 60,
});
console.log('agreementRate global=' + shadow.agreementRate.toFixed(1) + '% (minimo=70%)');
console.log('agreementRate cold-start=' + coldStart.agreementRate.toFixed(1) + '% (minimo=60%)');
console.log('Decision: ' + decision);
console.log('\n=== Parte 3: redactPII sobre el log del shadow run, antes de guardarlo ===\n');
const rawLogEntry = { buyerId: 'buyer-00014', email: 'ana@example.com', ip: '190.12.44.8', oldRec: 'sku-777', newRec: 'sku-616' };
const cleanLogEntry = redactPII(rawLogEntry, ['email', 'ip']);
console.log('crudo: ' + JSON.stringify(rawLogEntry));
console.log('redactado: ' + JSON.stringify(cleanLogEntry));
Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:
=== Parte 1: shadowCompare -- v2 (iteracion 2, cold-start corregido) corre en paralelo ===
agreementRate global=90.0% (18/20)
with-history tasa=100.0% (7/7)
cold-start tasa=84.6% (11/13)
diffs: buyer-7 [cold-start]: sku-104 -> sku-441, buyer-14 [cold-start]: sku-777 -> sku-616
=== Parte 2: modelMigrationDecision -- el mismo doble umbral que detecto el problema en M7 ===
agreementRate global=90.0% (minimo=70%)
agreementRate cold-start=84.6% (minimo=60%)
Decision: promueve recs-v2 a canary (1%), vigilando los guardrails de M4 en cada etapa
=== Parte 3: redactPII sobre el log del shadow run, antes de guardarlo ===
crudo: {"buyerId":"buyer-00014","email":"ana@example.com","ip":"190.12.44.8","oldRec":"sku-777","newRec":"sku-616"}
redactado: {"buyerId":"buyer-00014","email":"[REDACTED]","ip":"[REDACTED]","oldRec":"sku-777","newRec":"sku-616"}
La Parte 1 corre shadowCompare() sobre 20 compradores de muestra, ya divididos en los dos segmentos que importan: 18 de 20 coinciden en total (90.0% global), pero el desglose es lo que de verdad contesta la pregunta de esta lección — with-history llega a 100.0% (7/7), y cold-start, el segmento que la primera iteración de v2 dejó en 26.7% en el módulo 7, ahora llega a 84.6% (11/13). Las dos diferencias que quedan —buyer-7 y buyer-14— están, las dos, concentradas en cold-start: no desaparecieron por completo, pero la mejora es enorme, y ya no arrastran el segmento por debajo de ningún umbral razonable.
La Parte 2 aplica modelMigrationDecision(), sin cambiarle una letra respecto al módulo 7, a este resultado. El primer chequeo (90.0 < 70) es falso, así que pasa. El segundo chequeo (84.6 < 60) también es falso — a diferencia de la primera iteración, donde 26.7 < 60 era verdadero y frenaba la migración ahí mismo. Con los dos umbrales superados, la función devuelve la autorización: 'promueve recs-v2 a canary (1%), vigilando los guardrails de M4 en cada etapa'. Fíjate en que el mensaje sigue diciendo recs-v2 — es literalmente el mismo string que la función del módulo 7 devuelve, sin ningún cambio, porque es la misma función; Mercado usa v2 como nombre corto para el mismo modelo en la conversación diaria de este capstone.
La Parte 3 muestra la pieza de privacidad: antes de guardar el log de esta comparación en cualquier sistema de análisis o depuración, redactPII() reemplaza los campos que identifican a una persona específica (email, ip) por [REDACTED], dejando intactos los campos que sí hacen falta para el análisis técnico (buyerId como identificador opaco, y las recomendaciones mismas). El shadow mode observa el comportamiento del modelo, no necesita, ni debería guardar, los datos personales del comprador para hacerlo.
Por qué la migración necesita DOS umbrales, no uno — y por qué esta vez sí los pasa
La lección más importante de este resultado no es el número 90.0% — es que ese número, solo, nunca hubiera sido suficiente para decidir nada. modelMigrationDecision(), la misma función exacta que el módulo 7 construyó, no pregunta "¿el acuerdo global supera un umbral?" y se detiene ahí — cruza dos condiciones, en orden: primero el acuerdo global contra un mínimo (70%), y solo si eso pasa, el acuerdo del segmento más crítico contra su propio mínimo (60%). Esta segunda iteración de v2 pasa las dos: 90.0% global, muy por encima de 70%, y 84.6% en cold-start, cómodamente por encima de 60% — una mejora enorme frente al 26.7% que la primera iteración dejó en shadow en el módulo 7, muy por debajo de ese mismo umbral crítico.
Fíjate en lo que esto confirma sobre el proceso, más allá del resultado puntual: modelMigrationDecision() no cambió una sola línea entre el módulo 7 y esta lección — fue el modelo el que cambió, después de que el equipo diagnosticara específicamente el segmento que fallaba y lo corrigiera. Ese es, exactamente, el ciclo que el módulo 6 (ship → measure → learn) enseñó a aplicar a cualquier resultado: medir con rigor, encontrar dónde falla algo que un promedio esconde, arreglar esa causa específica, y volver a medir con la misma vara —no una más permisiva— antes de confiar en el resultado nuevo.
Errores comunes
Migrar directamente a producción confiando en que "la segunda iteración seguro ya lo arregló", sin correr shadow de nuevo. Qué pasa: después de corregir la estrategia de cold-start, alguien propone saltar directo al canary, razonando que ya se identificó y arregló el problema específico, así que repetir el shadow test completo se siente redundante. Por qué pasa: haber diagnosticado la causa exacta la primera vez genera una confianza —a veces excesiva— en que el arreglo funcionó exactamente como se esperaba, sin verificarlo con datos nuevos. Cómo detectarlo: si no existe un segundo resultado de shadowCompare(), posterior al cambio, con su propio desglose por segmento, la mejora es una hipótesis, no un hecho confirmado. Cómo corregirlo: como en la Parte 1 y 2 de esta lección, cualquier cambio al modelo —incluida una corrección dirigida— reinicia el ciclo completo: shadow, medir con el mismo desglose, decidir con los mismos dos umbrales.
Reportar solo el 90.0% de acuerdo global, sin el desglose por segmento que confirma que el cold-start realmente se arregló. Qué pasa: alguien resume el resultado de esta lección como "90% de acuerdo, mejoró mucho respecto a la vez pasada", sin citar el 84.6% específico del segmento cold-start. Por qué pasa: después de ya haber pasado por la sorpresa de un segmento oculto en el módulo 7, es tentador asumir que, esta vez, el número global ya cuenta toda la historia. Cómo detectarlo: si nadie en el reporte puede citar la tasa de acuerdo del segmento cold-start específicamente, la verificación se quedó en el mismo punto ciego que casi autorizó una migración sin revisar en el módulo 7. Cómo corregirlo: reporta siempre el desglose completo junto al número global —exactamente como hace la Parte 1 de esta lección—, sin importar cuánto haya mejorado el agregado.
Guardar los logs del shadow run sin pasar por redactPII(), "porque es solo para debugging interno". Qué pasa: alguien del equipo guarda los differences completos del shadow run —incluyendo email e ip de los compradores— en un sistema de logs que el equipo completo puede consultar, sin redactar nada, argumentando que "es solo para uso interno, no hay riesgo". Por qué pasa: los datos de shadow mode se sienten "menos sensibles" que los datos de producción, porque ningún usuario ve el resultado — pero los datos personales que entran a la comparación siguen siendo datos reales de personas reales. Cómo detectarlo: si cualquier log del shadow run contiene un correo electrónico o una dirección IP sin redactar, la práctica de privacidad de esta lección no se aplicó. Cómo corregirlo: como en la Parte 3 de esta lección, cualquier log que se guarde del shadow run pasa por redactPII() antes de escribirse, sin excepción, incluso cuando el propósito sea exclusivamente técnico.
Ejercicios
Ejercicio 1 — Calcula el veredicto con un umbral de segmento más estricto. El equipo de seguridad de Mercado, más conservador después del incidente del módulo 7, propone subir el umbral del segmento crítico de 60% a 90% para esta segunda iteración. Con los mismos datos de esta lección (agreementRate=90.0, cold-start=84.6), ¿qué decisión devolvería modelMigrationDecision()?
Ver solución
'NO promuevas a canary todavia...'. El primer chequeo (90.0 < 70) sigue siendo falso, así que pasa. Pero el segundo chequeo, con minCriticalSegmentAgreement: 90, se vuelve 84.6 < 90, que es verdadero — así que la función se detiene ahí, sin autorizar el canary, a pesar de que 84.6% es una mejora enorme frente al 26.7% original. Este ejercicio confirma la misma idea que el ejercicio equivalente del módulo 7: el umbral no es un dato objetivo que shadowCompare() calcule — es una decisión de negocio, y subirlo puede cambiar la decisión final incluso cuando el modelo mejoró de verdad.
Ejercicio 2 — Diseña un log para otro campo sensible. El equipo descubre que el campo sessionDeviceFingerprint (un identificador técnico del dispositivo del comprador) también debería redactarse en los logs del shadow run, porque puede usarse para identificar a una persona específica combinado con otros datos. ¿Cómo llamarías a redactPII() para redactar email, ip y sessionDeviceFingerprint a la vez, sobre el mismo rawLogEntry de esta lección (asumiendo que ese campo también existiera en el objeto)?
Ver solución
redactPII(rawLogEntry, ['email', 'ip', 'sessionDeviceFingerprint']). La función ya está diseñada para aceptar cualquier lista de campos a redactar —el segundo parámetro, fields, es un arreglo que redactPII() recorre con forEach, sin ningún límite en su tamaño—, así que agregar un tercer campo sensible no requiere ningún cambio en el código de la función, solo en la lista que se le pasa. Esto es, en pequeño, el mismo principio de diseño que sostiene toda esta guía: las funciones reciben configuración como datos, no como lógica hardcodeada.
Ejercicio 3 — Explica el shadow mode sin jerga técnica. En 2-3 frases, sin usar las palabras "shadow", "modelo" ni "agreement rate", explica a alguien de negocio por qué el equipo no cambió directamente al motor de recomendaciones nuevo, sino que primero lo hizo correr "en secreto" durante unos días, por segunda vez.
Ver solución
Un ejemplo de respuesta: "Antes de usar el sistema de recomendaciones nuevo con compradores reales, lo hicimos funcionar en paralelo con el sistema actual, sin que nadie lo viera, solo para comparar qué tan parecidas eran sus sugerencias. La primera vez que hicimos esta prueba, encontramos que fallaba justo con los compradores nuevos que todavía no tienen historial de compras, así que no lo lanzamos. El equipo corrigió específicamente esa parte, y esta segunda prueba confirma que ahora coincide en 9 de cada 10 casos en general, y también coincide de forma consistente con ese grupo de compradores nuevos que antes era el problema."
Resumen y siguiente paso
En esta lección validaste, con shadowCompare() y modelMigrationDecision() reusados sin cambios desde el módulo 7, que la segunda iteración del modelo v2 de recomendaciones es segura para migrar: 90.0% de acuerdo global y 84.6% en el segmento cold-start —el mismo segmento que dejó 26.7% en la primera ronda de shadow del módulo 7—, ambos por encima de sus umbrales (70% y 60% respectivamente). Confirmaste también, con redactPII(), que los logs de esa comparación protegen los datos personales de los compradores antes de guardarse.
Antes de avanzar deberías poder: explicar por qué modelMigrationDecision() necesita cruzar dos umbrales, no uno; y nombrar qué campos de un log de comparación necesitarían redacción antes de guardarse.
Con el fix de latencia (motor v2, más rápido) y el cold-start ya corregido y validado, la lección 7 re-lanza recommendations con la misma rampa del módulo 3 — y verifica algo que ninguna lección anterior pudo confirmar todavía: si el +18.75% de lift se sostiene semanas después del re-lanzamiento, o si era solo el efecto de la novedad.
Recursos
- Chip Huyen, Designing Machine Learning Systems, Capítulo 9, "Continual Learning and Test in Production" — huyenchip.com/machine-learning-systems-design. La referencia completa sobre shadow deployment y canary de modelos de ML, la base formal de la práctica que esta lección ejecutó en versión simplificada. En inglés.
- Google Cloud, "MLOps: Continuous delivery and automation pipelines in machine learning" — cloud.google.com/architecture/mlops-continuous-delivery-and-automation-pipelines-in-machine-learning. Documentación práctica de la industria sobre shadow y canary deployment de modelos, aplicable más allá de un proveedor específico. En inglés.
- NIST Privacy Framework, "Data Minimization" — nist.gov/privacy-framework. Los principios prácticos de minimización de datos que sostienen por qué
redactPII()importa incluso en logs de uso exclusivamente interno. En inglés.