Serie MLOps y Producción · de la ingeniería de datos al monitoreo.
Módulo IV — Despliegue y producción
La sesión 9 volvió el modelo un servicio. La 10, una receta portable. Falta: ¿quién pulsa el botón? Si la respuesta es “yo, cuando me acuerdo”, el contrato se rompe el primer viernes de apuro.
CI/CD no es “poner GitHub Actions porque se ve profesional”. Es un pipeline de máquina que, ante un cambio, prueba el contrato, evalúa un umbral y solo entonces permite publicar. Aquí se simula en Python. El YAML es la misma idea con otra sintaxis.
Objetivos de aprendizaje
Distinguir DevOps, CI, CD y lo que cambia cuando el artefacto es un modelo.
Diseñar pruebas que no reentrenan: esquema, carga, caso canónico.
Instalar un gate de métrica antes de promover.
Fallar una etapa a propósito y leer la bitácora.
Situar la nube como dónde corre el pipeline, no como teoría aparte.
Qué no cubre esta sesión
El Dockerfile (sesión 10): aquí se invoca la receta.
Detectar drift (sesión 12).
Ruta
DevOps / CI / CD.
Dos fuentes de cambio: código y datos.
Etapas y un motor mínimo.
Gate de calidad.
YAML documental.
Rollback conceptual.
Taller.
# Motor de etapas y un artefacto mínimo.import jsonfrom pathlib import Pathimport joblibimport numpy as npimport pandas as pdfrom sklearn.linear_model import LogisticRegressionfrom sklearn.metrics import f1_scorefrom sklearn.model_selection import train_test_splitfrom sklearn.pipeline import Pipelinefrom sklearn.preprocessing import StandardScalernp.random.seed(42)RUTA = Path("artefactos_sesion11")RUTA.mkdir(exist_ok=True)print("Entorno listo. El botón lo pulsa este motor, no un humano.")
Entorno listo. El botón lo pulsa este motor, no un humano.
1) Tres capas, un hábito
En una frase: DevOps es no separar quien construye de quien opera; CI y CD son máquinas que materializan esa cultura.
Sigla
Pregunta
En software
En ML, además
CI
¿Lo que cambié sigue válido?
tests, lint
el joblib carga; el caso canónico predice lo acordado
CD
¿Puede publicarse sin ritual?
build + deploy
gate de métrica + promoción (sesión 8)
DevOps
¿Producción es el camino por defecto?
—
el modelo es un artefacto, no un USB
La diferencia de ML no es que “los tests no existan”. Es que hay dos fuentes de cambio: código y datos.
2) Lo que un CI de ML debe probar (y lo que no)
Un CI que reentrena en cada push convierte la sesión 6 en una lotería cara. El default: no entrenar. Entrenar es un job (sesión 7) disparado por datos nuevos o por un botón consciente. El CI del servicio carga el artefacto ya elegido.
# Artefacto 'ya promovido' que el CI va a interrogar, no a reentrenar.rng = np.random.default_rng(42)n =320x = rng.normal(0, 1, n)y = (rng.random(n) <1/ (1+ np.exp(-(1.3* x)))).astype(int)df = pd.DataFrame({"x": x, "y": y})Xtr, Xte, ytr, yte = train_test_split(df[["x"]], df["y"], test_size=0.3, random_state=42, stratify=df["y"])pipe = Pipeline([("sc", StandardScaler()), ("clf", LogisticRegression(max_iter=300))])pipe.fit(Xtr, ytr)joblib.dump(pipe, RUTA /"modelo.joblib")(RUTA /"caso_canonico.json").write_text(json.dumps({"x": 1.5, "etiqueta_esperada": 1}), encoding="utf-8")f1_ref =float(f1_score(yte, pipe.predict(Xte)))(RUTA /"umbral.json").write_text(json.dumps({"f1_minimo": 0.35, "f1_holdout": f1_ref}), encoding="utf-8")print("F1 holdout de referencia:", round(f1_ref, 3))print("El CI no vuelve a hacer fit. Pregunta si ESTE archivo sigue siendo el acordado.")
F1 holdout de referencia: 0.714
El CI no vuelve a hacer fit. Pregunta si ESTE archivo sigue siendo el acordado.
3) Etapas declaradas
Un pipeline es una lista ordenada de predicados. Si uno falla, los siguientes no se ejecutan (fail-fast, sesión 7). Las etapas de esta sesión:
lint_esquema — el caso canónico tiene las claves.
carga_artefacto — joblib.load no explota.
predict_humo — el caso canónico produce la etiqueta acordada.
gate_metrica — el F1 de un holdout congelado no baja del umbral.
promover — solo si 1–4 pasaron.
# Implementación de las cinco etapas.def lint_esquema(_ctx): caso = json.loads((RUTA /"caso_canonico.json").read_text(encoding="utf-8"))assert"x"in caso and"etiqueta_esperada"in casoreturn"esquema ok"def carga_artefacto(ctx): ctx["modelo"] = joblib.load(RUTA /"modelo.joblib")asserthasattr(ctx["modelo"], "predict")return"joblib carga"def predict_humo(ctx): caso = json.loads((RUTA /"caso_canonico.json").read_text(encoding="utf-8")) pred =int(ctx["modelo"].predict(pd.DataFrame([{"x": caso["x"]}]))[0])if pred != caso["etiqueta_esperada"]:raiseAssertionError(f"esperado {caso['etiqueta_esperada']}, obtuvo {pred}")returnf"humo ok (y={pred})"def gate_metrica(_ctx): umb = json.loads((RUTA /"umbral.json").read_text(encoding="utf-8"))if umb["f1_holdout"] < umb["f1_minimo"]:raiseAssertionError(f"F1 {umb['f1_holdout']:.3f} < {umb['f1_minimo']}")returnf"gate ok F1={umb['f1_holdout']:.3f}"def promover(ctx): (RUTA /"PROMOVIDO").write_text("ok", encoding="utf-8")return"promovido a staging (didáctico)"ETAPAS = [ ("lint_esquema", lint_esquema), ("carga_artefacto", carga_artefacto), ("predict_humo", predict_humo), ("gate_metrica", gate_metrica), ("promover", promover),]print("Etapas:", [n for n, _ in ETAPAS])
etapa estado detalle
lint_esquema ok esquema ok
carga_artefacto ok joblib carga
predict_humo ok humo ok (y=1)
gate_metrica ok gate ok F1=0.714
promover ok promovido a staging (didáctico)
¿Existe sello de promoción?: True
Lectura. Cinco ok no significan “el modelo es bueno para siempre”. Significan: hoy, este commit, este artefacto, este umbral, la máquina dejó pasar. El jueves es la sesión 12.
5) Fallar es el caso de uso del CI
# Si el humo falla, promover no existe.if (RUTA /"PROMOVIDO").exists(): (RUTA /"PROMOVIDO").unlink()ci_ko = correr_ci(ETAPAS, inyectar_fallo="predict_humo")print(ci_ko.to_string(index=False))print("¿Promovió?:", (RUTA /"PROMOVIDO").exists())
etapa estado detalle
lint_esquema ok esquema ok
carga_artefacto ok joblib carga
predict_humo fallo inyectado
gate_metrica omitida upstream failed
promover omitida upstream failed
¿Promovió?: False
Un CI que nunca falla no está probando: está saludando. El valor pedagógico de esta celda es el omitida de promover.
6) El gate no se negocia después de ver el número
Bajar el umbral porque este commit falló es el diálogo del 0.91 (sesión 6) aplicado al deploy. El umbral se acuerda con negocio y se versiona. Cambiarlo es otro commit, no un parámetro oculto del YAML.
# Gate deliberadamente imposible: el pipeline debe parar.(RUTA /"umbral.json").write_text(json.dumps({"f1_minimo": 0.99, "f1_holdout": f1_ref}), encoding="utf-8")if (RUTA /"PROMOVIDO").exists(): (RUTA /"PROMOVIDO").unlink()ci_gate = correr_ci(ETAPAS)print(ci_gate.to_string(index=False))(RUTA /"umbral.json").write_text(json.dumps({"f1_minimo": 0.35, "f1_holdout": f1_ref}), encoding="utf-8")print("Umbral restaurado a 0.35 para no dejar el laboratorio roto.")
etapa estado detalle
lint_esquema ok esquema ok
carga_artefacto ok joblib carga
predict_humo ok humo ok (y=1)
gate_metrica fallo F1 0.714 < 0.99
promover omitida upstream failed
Umbral restaurado a 0.35 para no dejar el laboratorio roto.
7) Tests de datos frente a tests de código
Tipo
Ejemplo
Cuándo corre
Código
validar_caso rechaza un JSON cojo
cada push
Artefacto
el joblib carga con este sklearn
cada push
Datos
el hash del train no es el de production
job de datos / sesión 8
Modelo
F1 de holdout ≥ umbral
cada candidato a promover
Mezclar la fila 3 en el CI de cada typo del README es caro e inútil. Separarlos es diseño.
8) YAML: la misma lista, otra piel
yaml ='''name: ci-mlon: [push]jobs: contrato: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: { python-version: "3.12" } - run: pip install -r requirements.lock - run: python -m pytest tests/test_esquema.py - run: python -m pytest tests/test_humo.py - run: python scripts/gate_metrica.py'''(RUTA /"ci.yml").write_text(yaml, encoding="utf-8")print(yaml)print("Si el YAML no cita el lock de la sesión 10, el CI corre 'en mi máquina'.")
name: ci-ml
on: [push]
jobs:
contrato:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install -r requirements.lock
- run: python -m pytest tests/test_esquema.py
- run: python -m pytest tests/test_humo.py
- run: python scripts/gate_metrica.py
Si el YAML no cita el lock de la sesión 10, el CI corre 'en mi máquina'.
9) CD: publicar no es scp del joblib
Entrega continua: el artefacto puede publicarse (está en staging). Despliegue continuo: se publica a production si el gate pasa. Muchos equipos de ML se quedan en la primera —y está bien— si production exige un humano. Lo que no está bien es el humano sin gate.
10) Rollback: el CD también sabe volver
Si el /salud de producción falla tras el deploy, el pipeline de CD debe poder apuntar al run_id anterior (sesión 8), no “arreglar a mano el servidor”. Rollback es un puntero, no un pánico.
# Dos punteros: actual y anterior.punteros = {"production": "run_actual", "rollback": "run_previo"}(RUTA /"punteros.json").write_text(json.dumps(punteros, indent=2), encoding="utf-8")print("Rollback = cambiar production →", punteros["rollback"])print("Sin registry (sesión 8) este JSON no tiene a quién apuntar.")
Rollback = cambiar production → run_previo
Sin registry (sesión 8) este JSON no tiene a quién apuntar.
11) ¿Entrenar en el CI?
Solo si el entrenamiento es barato y el disparador es datos versionados, no un push de README. Aun entonces, el job de train es otro workflow (on: workflow_dispatch o un sensor de hash). Mezclarlo con lint es cómo una cola de 40 minutos enseña al equipo a saltarse el CI.
12) Nube: el dónde, no el qué
GitHub Actions, GitLab CI, CodeBuild, Jenkins: ejecutan la misma lista. La nube no añade teoría; añade máquinas y secretos. Un estudiante que solo sabe el YAML de un proveedor no sabe CI: sabe un dialecto.
13) Canary y blue-green (idea)
CD avanzado no cambia a todos los clientes de golpe. Canary: 5 % del tráfico al artefacto nuevo. Blue-green: dos flotas, un switch. Esta sesión no los implementa. Los nombra para que “despliegue continuo” no se lea como “apaga el servidor y espera”.
14) Relación con 10 y 12
10 da la receta que el step docker build invoca. 12 vigila después de que este pipeline dijo que sí. Un gate verde el lunes no exonera un drift el jueves.
Un caso canónico “interesante” (un outlier) hace fallar el humo por azar. Se elige un punto estable del espacio, con etiqueta acordada, versionado junto al umbral. El azar se reserva para el test de la sesión 6, no para el semáforo del viernes.
14d) Permisos del pipeline
El job de promover no debe poder empujar a main con la misma llave que el job de lint. Principio de menor privilegio: un CI comprometido no reescribe el registry entero.
14e) Tiempos: el CI también tiene presupuesto
Si el pipeline tarda 25 minutos, el equipo lo desactiva. Etapas baratas primero (lint), caras después (cargar un joblib de 2 GB). Es el camino crítico de la sesión 7, aplicado a calidad.
print(ci_ok.assign(orden=range(1, len(ci_ok)+1)).to_string(index=False))print("Lint antes que gate: si el JSON está roto, no cargue el modelo.")
etapa estado detalle orden
lint_esquema ok esquema ok 1
carga_artefacto ok joblib carga 2
predict_humo ok humo ok (y=1) 3
gate_metrica ok gate ok F1=0.714 4
promover ok promovido a staging (didáctico) 5
Lint antes que gate: si el JSON está roto, no cargue el modelo.
14f) Una frase de examen
“CI pregunta si este commit rompe el contrato; CD pregunta si este artefacto puede publicarse; el umbral no se rebaja para que la respuesta sea sí.”
14g) Tests flaky: el CI que ‘a veces’ falla
Un humo que depende de un random_state no fijado enseña al equipo a pulsar re-run. Un test flaky es peor que no tener test: educa a ignorar el rojo.
print("Regla: el caso canónico es determinista. Si falla una de cada diez, no es humo: es ruido.")
Regla: el caso canónico es determinista. Si falla una de cada diez, no es humo: es ruido.
14h) Matriz de versiones
CI puede correr el humo en Python 3.11 y 3.12. No sustituye la imagen (sesión 10); detecta un f-string o un pickle incompatible antes del build caro.
14i) Caché de dependencias
Cachear pip está bien. Cachear el modelo.joblib entre branches es una forma de promover el artefacto equivocado. La caché no cruza el registry.
14j) Ramas protegidas
main no recibe push directo. El CI es required check. Sin eso, el YAML es teatro: un viernes se salta el gate con un push a la fuerza.
14k) Escaneo de secretos
Un commit con una API key no debe llegar a promover. El CI de ML hereda el de software: secret scanning antes del humo.
14l) Nightly frente a on-push
On-push: contrato barato. Nightly: holdout más largo o chequeo de hash de datos. Mezclar el nightly en cada typo recrea la cola de 40 minutos.
print("on-push : lint + carga + humo + gate corto")print("nightly : holdout extendido + diff de hash de datos (sesión 8)")
on-push : lint + carga + humo + gate corto
nightly : holdout extendido + diff de hash de datos (sesión 8)
14m) Aprobación humana como etapa explícita
Si production exige un humano, esa aprobación es una etapa (manual) después del gate verde, no un en lugar de. El humano no sustituye al umbral; lo confirma.
15) Errores frecuentes
Reentrenar en cada push.
CI que nunca carga el joblib.
Bajar el umbral para poner verde.
Promover aunque el humo falle.
YAML sin lock (sesión 10).
Un solo secreto para lint y production.
16) Checklist
¿Las etapas son fail-fast?
¿El humo usa un caso aburrido versionado?
¿El gate está escrito antes del número?
¿Hay puntero de rollback?
¿El YAML instala el lock, no pip install sklearn?
17) Conclusiones
CI/CD es el hábito de no desplegar de memoria. En ML ese hábito incluye el artefacto y un umbral, no solo el linter. La máquina pulsa el botón; el humano acuerda las reglas. La sesión 12 pregunta si esas reglas siguen describiendo el mundo.
18) Taller
A. Añada una etapa licencias que falle si un archivo LICENSE no existe. Inyecte el fallo.
B. Cambie la etiqueta esperada del caso canónico y lea la bitácora.
C. Proponga un umbral de recall además de F1. ¿Quién lo firma?
D. Escriba en cinco líneas cuándo SÍ entrenaría en un workflow separado.
E. Dibuje (texto) blue-green con dos run_id.
print("Sello PROMOVIDO:", (RUTA /"PROMOVIDO").exists())print("Etapas ok:")print(ci_ok.to_string(index=False))print("Taller A–E. Si promover corre tras un humo en fallo, no hay CI.")
Sello PROMOVIDO: False
Etapas ok:
etapa estado detalle
lint_esquema ok esquema ok
carga_artefacto ok joblib carga
predict_humo ok humo ok (y=1)
gate_metrica ok gate ok F1=0.714
promover ok promovido a staging (didáctico)
Taller A–E. Si promover corre tras un humo en fallo, no hay CI.
💬 ¿Te sirvió?
Deja en los comentarios una duda o un caso donde aplicarías esto — respondo todos. Sígueme para no perderte el próximo artículo de la serie y comparte con alguien que esté aprendiendo arquitectura de IA.
👉 El código completo está disponible para ejecutar directamente.