11  CI/CD aplicado a Machine Learning

mlops
cicd
devops
Autor/a

Wilder Ramírez Delgado

Fecha de publicación

12 de septiembre de 2026

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

  1. Distinguir DevOps, CI, CD y lo que cambia cuando el artefacto es un modelo.
  2. Diseñar pruebas que no reentrenan: esquema, carga, caso canónico.
  3. Instalar un gate de métrica antes de promover.
  4. Fallar una etapa a propósito y leer la bitácora.
  5. 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

  1. DevOps / CI / CD.
  2. Dos fuentes de cambio: código y datos.
  3. Etapas y un motor mínimo.
  4. Gate de calidad.
  5. YAML documental.
  6. Rollback conceptual.
  7. Taller.
# Motor de etapas y un artefacto mínimo.

import json
from pathlib import Path

import joblib
import numpy as np
import pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import f1_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler

np.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 = 320
x = 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:

  1. lint_esquema — el caso canónico tiene las claves.
  2. carga_artefacto — joblib.load no explota.
  3. predict_humo — el caso canónico produce la etiqueta acordada.
  4. gate_metrica — el F1 de un holdout congelado no baja del umbral.
  5. 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 caso
    return "esquema ok"


def carga_artefacto(ctx):
    ctx["modelo"] = joblib.load(RUTA / "modelo.joblib")
    assert hasattr(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"]:
        raise AssertionError(f"esperado {caso['etiqueta_esperada']}, obtuvo {pred}")
    return f"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"]:
        raise AssertionError(f"F1 {umb['f1_holdout']:.3f} < {umb['f1_minimo']}")
    return f"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])
Etapas: ['lint_esquema', 'carga_artefacto', 'predict_humo', 'gate_metrica', 'promover']

4) El motor: la misma idea que el DAG, con semántica de calidad

def correr_ci(etapas, inyectar_fallo=None):
    ctx, filas, fallo = {}, [], False
    for nombre, fn in etapas:
        if fallo:
            filas.append({"etapa": nombre, "estado": "omitida", "detalle": "upstream failed"})
            continue
        if nombre == inyectar_fallo:
            filas.append({"etapa": nombre, "estado": "fallo", "detalle": "inyectado"})
            fallo = True
            continue
        try:
            filas.append({"etapa": nombre, "estado": "ok", "detalle": fn(ctx)})
        except Exception as exc:
            filas.append({"etapa": nombre, "estado": "fallo", "detalle": str(exc)})
            fallo = True
    return pd.DataFrame(filas)

ci_ok = correr_ci(ETAPAS)
print(ci_ok.to_string(index=False))
print("¿Existe sello de promoción?:", (RUTA / "PROMOVIDO").exists())
          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-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
'''
(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.

14b) Bitácora del CI como artefacto

(RUTA / "ci_ok.json").write_text(ci_ok.to_json(orient="records", force_ascii=False, indent=2), encoding="utf-8")
print((RUTA / "ci_ok.json").read_text(encoding="utf-8")[:400])
[
  {
    "etapa":"lint_esquema",
    "estado":"ok",
    "detalle":"esquema ok"
  },
  {
    "etapa":"carga_artefacto",
    "estado":"ok",
    "detalle":"joblib carga"
  },
  {
    "etapa":"predict_humo",
    "estado":"ok",
    "detalle":"humo ok (y=1)"
  },
  {
    "etapa":"gate_metrica",
    "estado":"ok",
    "detalle":"gate ok F1=0.714"
  },
  {
    "etapa":"promover",
    "estado":"ok",
    "

14c) El caso canónico debe ser aburrido

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

  1. Reentrenar en cada push.
  2. CI que nunca carga el joblib.
  3. Bajar el umbral para poner verde.
  4. Promover aunque el humo falle.
  5. YAML sin lock (sesión 10).
  6. Un solo secreto para lint y production.

16) Checklist

  1. ¿Las etapas son fail-fast?
  2. ¿El humo usa un caso aburrido versionado?
  3. ¿El gate está escrito antes del número?
  4. ¿Hay puntero de rollback?
  5. ¿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.