1  Arquitectura integral de IA y MLOps

mlops
arquitectura
ciclo-de-vida
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 I — Fundamentos y arquitectura de sistemas de IA

Un diálogo frecuente al cierre de un curso de machine learning:

— ¿Ya está en producción?
— Sí. El notebook corre de punta a punta y el F1 es 0.87.

Esa respuesta confunde un experimento con un sistema. El notebook demuestra que existe una función \(\hat{y}=f(x)\) que, sobre un recorte de datos, produce un número. Un sistema de IA es otra cosa: un conjunto de contratos —de dato, de modelo, de tiempo y de responsabilidad— que debe seguir produciendo \(\hat{y}\) cuando cambia la fuente, el calendario, el responsable o la máquina.

Esta sesión no enseña a limpiar nulos (sesión 3), ni a diseñar \(T(x)\) (sesión 4), ni a orquestar un DAG (sesión 7), ni a exponer HTTP (sesión 9). Enseña el mapa: qué piezas existen, en qué orden se tocan y qué falla cuando se omiten. Quien no tiene mapa trata cada síntoma como un algoritmo distinto. Quien lo tiene, sabe en qué capa buscar.

MLOps no es “poner el .pkl en un servidor”. Es impedir que el camino del dato al veredicto sea un relato oral.

Objetivos de aprendizaje

Al finalizar, el estudiante estará en capacidad de:

  1. Distinguir un experimento (notebook) de un sistema (contratos, artefactos, dueños).
  2. Recorrer el ciclo de vida del aprendizaje automático como secuencia de etapas con entradas y salidas, no como lista de herramientas.
  3. Separar el pipeline de datos del pipeline de modelo: distintos contratos, distintos fallos.
  4. Argumentar cuándo basta un monolito y cuándo hace falta descomponer por interfaces.
  5. Contrastar batch, online y near real-time por latencia, frescura y costo —sin implementar un servidor.
  6. Trazar un flujo extremo a extremo mínimo (evento → persistencia → puntuación → registro) y situar cada sesión posterior en ese mapa.

1) Qué es MLOps — y qué no es

MLOps (Machine Learning Operations) nombra el conjunto de prácticas que hacen repetible, auditable y operable el camino desde un dato crudo hasta una decisión que alguien usa. No es un producto. No es un cargo. No es “DevOps con un modelo en el medio”, aunque toma de DevOps la idea de que lo que no está escrito no existe.

Tres definiciones útiles, de más estrecha a más amplia:

Alcance Afirmación Riesgo si se toma como única
Herramientas “Usamos un registro, un orquestador y un monitor.” Se compra software y se sigue operando a mano.
Proceso “Cada cambio de dato, código o modelo deja traza.” El proceso existe en una wiki que nadie abre.
Sistema “Hay contratos: esquema, semilla, umbral, dueño, rollback.” Es la definición de trabajo de este curso.

Lo que MLOps no resuelve por sí solo: un problema mal planteado, una etiqueta podrida, un sesgo de captura. Esas fallas son de diseño del problema. MLOps las hace visibles más rápido; no las convierte en aciertos.

1.1 El objeto de estudio no es el algoritmo

En un curso clásico el objeto es \(f\): árbol, red, \(k\)-NN. En un sistema el objeto es la composición

\[ x_{\text{crudo}} \xrightarrow{E} x_{\text{extraído}} \xrightarrow{T} z \xrightarrow{f} \hat{y} \xrightarrow{A} \text{acción}, \]

donde \(E\) extrae, \(T\) representa, \(f\) puntúa y \(A\) convierte el número en una decisión (cobrar, alertar, no hacer nada). Cada flecha tiene un dueño, un artefacto y un modo de fallar. Si solo se versiona \(f\), las otras tres flechas viven en la cabeza de quien “sí se acuerda cómo se filtraba”.

La sesión 2 desarrolla \(E\) (ETL). La 3, la calidad de \(x\). La 4, el diseño de \(T\). La 5, \(T\) y \(f\) como un contrato de ejecución. Aquí basta con nombrar las flechas.

1.2 El notebook como anti-patrón de producción

Un notebook es un medio excelente para descubrir. Es un medio pésimo para operar, porque mezcla en un solo archivo:

  1. la pregunta de negocio,
  2. la extracción,
  3. la limpieza improvisada,
  4. el ajuste de \(f\),
  5. la cifra que se reporta,
  6. y, a veces, el envío de un correo.

Esa mezcla produce tres deudas. Deuda de orden: las celdas se ejecutan a mano y el resultado depende de qué se corrió anoche. Deuda de frontera: no hay interfaz entre “dato listo” y “modelo listo”. Deuda de persona: si el autor sale, el sistema sale con él.

El criterio de esta sesión: si no se puede señalar el artefacto de cada flecha, no hay sistema. Hay un relato.

from pathlib import Path
import json
from datetime import datetime, timezone

import pandas as pd
import numpy as np

RUTA = Path("artefactos_sesion1")
RUTA.mkdir(exist_ok=True)

# Inventario de un "sistema" que vive solo en un notebook.
deuda = pd.DataFrame([
    {"pieza": "pregunta de negocio", "esta_escrita": False, "dueño": "oral"},
    {"pieza": "esquema de entrada", "esta_escrita": False, "dueño": "la celda 7"},
    {"pieza": "regla de limpieza", "esta_escrita": False, "dueño": "un dropna suelto"},
    {"pieza": "función de puntuación", "esta_escrita": True, "dueño": "la última celda"},
    {"pieza": "umbral de decisión", "esta_escrita": False, "dueño": "0.5 por costumbre"},
    {"pieza": "registro de lo puntuado", "esta_escrita": False, "dueño": "nadie"},
])
deuda["hueco"] = ~deuda["esta_escrita"]
print(deuda.to_string(index=False))
print("Huecos:", int(deuda["hueco"].sum()), "de", len(deuda))
                  pieza  esta_escrita             dueño  hueco
    pregunta de negocio         False              oral   True
     esquema de entrada         False        la celda 7   True
      regla de limpieza         False  un dropna suelto   True
  función de puntuación          True   la última celda  False
     umbral de decisión         False 0.5 por costumbre   True
registro de lo puntuado         False             nadie   True
Huecos: 5 de 6

2) El ciclo de vida no es un afiche: es un grafo de artefactos

Las etapas clásicas —problema, dato, entrenamiento, evaluación, despliegue, monitoreo— son correctas y, a la vez, engañosas: sugieren una línea. En la práctica hay ciclos. Se reentrena porque el dato cambió. Se vuelve al problema porque la métrica no era la decisión. Se revierte un despliegue porque el monitor (sesión 12) marcó deriva.

Para no perderse, cada etapa se define por qué entra, qué sale y quién firma:

Etapa Entra Sale Pregunta que cierra
Problema dolor de negocio métrica de decisión, no solo F1 ¿Qué acción cambia si \(\hat{y}\) acierta?
Dato fuentes tabla con esquema y hash ¿Se puede reconstruir esta foto?
Representación tabla \(z=T(x)\) ¿Qué se le permite ver a \(f\)?
Aprendizaje \(z\) etiquetada \(f\) y protocolo ¿El test permaneció ciego?
Evaluación \(f\) + holdout cifra una vez ¿Esa cifra es estimador o trofeo?
Despliegue \(f\) + contrato servicio o lote ¿Qué rompe si cambia una columna?
Monitoreo tráfico real semáforo y acción ¿Se distingue foto de \(x\) de F1 tardío?

El ciclo de vida, entonces, no se “cumple” comprando un orquestador. Se cumple si cada fila de esa tabla tiene un artefacto en disco —un JSON, un CSV, un manifiesto— y no solo una diapositiva.

ciclo = pd.DataFrame([
    {"etapa": "problema", "artefacto": "metrica_decision.json", "depende_de": ""},
    {"etapa": "dato", "artefacto": "foto.parquet + hash", "depende_de": "problema"},
    {"etapa": "representacion", "artefacto": "T_fit.joblib", "depende_de": "dato"},
    {"etapa": "aprendizaje", "artefacto": "f.joblib + protocolo", "depende_de": "representacion"},
    {"etapa": "evaluacion", "artefacto": "holdout_una_vez.json", "depende_de": "aprendizaje"},
    {"etapa": "despliegue", "artefacto": "contrato_entrada.json", "depende_de": "evaluacion"},
    {"etapa": "monitoreo", "artefacto": "semaforo.json", "depende_de": "despliegue"},
])
orden = {e: i for i, e in enumerate(ciclo["etapa"])}

def viola_orden(fila):
    dep = fila["depende_de"]
    if dep == "":
        return False
    return orden[dep] > orden[fila["etapa"]]

ciclo["orden_roto"] = ciclo.apply(viola_orden, axis=1)
print(ciclo.to_string(index=False))
print("Etapas que dependen de algo posterior:", int(ciclo["orden_roto"].sum()))
         etapa             artefacto     depende_de  orden_roto
      problema metrica_decision.json                      False
          dato   foto.parquet + hash       problema       False
representacion          T_fit.joblib           dato       False
   aprendizaje  f.joblib + protocolo representacion       False
    evaluacion  holdout_una_vez.json    aprendizaje       False
    despliegue contrato_entrada.json     evaluacion       False
     monitoreo         semaforo.json     despliegue       False
Etapas que dependen de algo posterior: 0

2.1 Por qué el orden no es pedantería

Si se puntúa antes de fijar el esquema, \(f\) memoriza un accidente de captura. Si se evalúa después de haber mirado el test para elegir \(f\) (sesión 6), la cifra no estima nada. Si se despliega sin contrato de entrada, el primer cambio de nombre de columna es un incidente. El orden es la forma barata de no pagar esos costos.

Un comprobador mínimo: ninguna etapa consume un artefacto que todavía no existe. No es un orquestador (eso es la sesión 7). Es una invariante de diseño.

3) Pipeline de datos frente a pipeline de modelo

Confundirlos es el error de arquitectura más caro del módulo I. Un pipeline de datos mueve y estabiliza \(x\): extrae, tipa, integra, carga. Su éxito se mide en completitud, puntualidad, esquema. Un pipeline de modelo consume \(x\) ya estabilizada y produce \(\hat{y}\) (o una \(f\) nueva). Su éxito se mide en métrica de decisión y costo de error.

Si se mezclan en una sola función run(), ocurre lo siguiente: un fallo de red en la extracción se reporta como “el modelo falló”; un cambio de umbral se “arregla” tocando el SQL; nadie sabe qué reejecutar.

Contrato mínimo, en palabras:

  • Datos. “Dada una fuente y una marca de agua, entrego una tabla con columnas \(C\) y un hash \(h\).”
  • Modelo. “Dada una tabla con columnas \(C\) (las mismas), entrego \(\hat{y}\) y no leo la fuente original.”

La sesión 2 escribe el primer contrato. La 5 escribe el segundo como fit/transform/predict. Aquí solo se demuestra que son dos funciones.

def pipeline_datos(eventos: pd.DataFrame, columnas: list[str]) -> dict:
    # Mueve y recorta. No puntúa.
    tabla = eventos.loc[:, columnas].copy()
    payload = tabla.to_csv(index=False).encode("utf-8")
    import hashlib
    return {"tabla": tabla, "n": len(tabla), "hash": hashlib.sha256(payload).hexdigest()[:12]}


def pipeline_modelo(tabla: pd.DataFrame, umbral: float) -> pd.Series:
    # Puntúa. No extrae. Regla deliberadamente tonta: no es la sesión 6.
    puntaje = (tabla["monto"] / tabla["monto"].median()).clip(0, 5)
    return (puntaje >= umbral).astype(int)


rng = np.random.default_rng(1)
eventos = pd.DataFrame({
    "id": np.arange(8),
    "monto": rng.integers(10, 90, size=8),
    "canal": rng.choice(["app", "web"], size=8),
    "ruido_operativo": rng.normal(0, 1, size=8),
})
foto = pipeline_datos(eventos, ["id", "monto", "canal"])
yhat = pipeline_modelo(foto["tabla"], umbral=1.1)
print("hash de la foto:", foto["hash"], "| n =", foto["n"])
print("predicciones   :", yhat.tolist())
print("El modelo no vio 'ruido_operativo'. Ese es el contrato.")
hash de la foto: 08fe058f4417 | n = 8
predicciones   : [0, 0, 1, 1, 0, 0, 1, 1]
El modelo no vio 'ruido_operativo'. Ese es el contrato.

3.1 Una fuga temprana, solo para nombrarla

Si el pipeline de modelo calcula una estadística —la mediana del monto— sobre todo el histórico, incluido lo que más tarde se llamará test, la cifra de evaluación ya no es independiente. No se resuelve aquí (sesiones 5 y 6). Se deja el síntoma a la vista: la frontera dato/modelo también es una frontera de información.

monto = pd.Series([10, 12, 11, 80, 9, 13, 10, 12], name="monto")
mediana_todo = monto.median()
mediana_train = monto.iloc[:6].median()
print(f"mediana(todo)={mediana_todo:.1f}  mediana(train)={mediana_train:.1f}")
print("Si T usa la primera, el test ya participó en la representación.")
mediana(todo)=11.5  mediana(train)=11.5
Si T usa la primera, el test ya participó en la representación.

4) Monolito frente a servicios

Un monolito es un único proceso (a menudo un único repositorio y un único despliegue) que extrae, transforma, puntúa y escribe. Un sistema por servicios parte esas responsabilidades detrás de interfaces: el extractor no importa el clasificador; el clasificador no abre la base transaccional.

Ninguno de los dos es moralmente superior. El monolito gana cuando el equipo es pequeño, el flujo es batch y el esquema es estable. Los servicios ganan cuando distintas cadencias conviven: el dato llega cada hora, el modelo se reentrena cada mes, la consulta online pide 50 ms.

El criterio de corte no es “microservicios porque es moderno”. Es ¿quién debe poder cambiar sin avisar a quién?. Si cambiar el SQL de extracción obliga a reentrenar, la frontera está mal dibujada —o el esquema no es un contrato. Si cambiar el umbral obliga a redeployar el extractor, también.

# Acoplamiento: el monolito lee una columna por posición.
# El servicio lee un contrato (nombres). Un rename rompe uno y no el otro.

def puntuar_monolito(fila: list) -> int:
    return int(fila[1] >= 50)  # "la segunda columna es el monto"... hasta que no lo es


def puntuar_contrato(fila: dict, contrato: set[str]) -> int:
    faltan = contrato - set(fila)
    if faltan:
        raise KeyError(f"contrato roto: {sorted(faltan)}")
    return int(fila["monto"] >= 50)


fila_ok = {"id": 1, "monto": 72, "canal": "app"}
fila_rename = {"id": 1, "importe": 72, "canal": "app"}  # alguien "mejoró" el nombre
contrato = {"id", "monto", "canal"}
print("monolito, orden original :", puntuar_monolito([1, 72, "app"]))
print("monolito, columnas al revés:", puntuar_monolito([72, 1, "app"]), "← silencio incorrecto")
print("contrato, fila ok        :", puntuar_contrato(fila_ok, contrato))
try:
    puntuar_contrato(fila_rename, contrato)
except KeyError as e:
    print("contrato, rename         :", e)
monolito, orden original : 1
monolito, columnas al revés: 0 ← silencio incorrecto
contrato, fila ok        : 1
contrato, rename         : "contrato roto: ['monto']"

4.1 Cuándo no partir

Partir de más produce un sistema que nadie puede ejecutar en un portátil y, por tanto, nadie puede razonar. Una regla prudente para este curso: un proceso hasta que una cadencia o un dueño lo obliguen a ser dos. La sesión 9 introduce el servicio HTTP; la 10, la imagen. Aquí solo se fija el criterio.

5) Batch, online y la mentira del “tiempo real”

Tres modos, tres presupuestos:

Modo Unidad Latencia típica Frescura de \(x\) Costo relativo
Batch lote (hora, día) minutos–horas la del cierre de lote bajo
Online una petición decenas de ms la de esa petición medio–alto
Streaming / near-RT evento o micro-lote segundos continua alto de operación

“Tiempo real” en un slide suele significar online o near-RT. Casi nunca significa “el modelo se reentrena en 10 ms”. Puntuar rápido y aprender rápido son problemas distintos. Mezclarlos produce la arquitectura más cara: un reentrenamiento continuo que nadie puede auditar.

La pregunta de diseño no es “¿cuál es más avanzado?”. Es: ¿la decisión tolera esperar al próximo lote? Cobrar un fraude en la transacción no. Recalcular un score de morosidad mensual, sí. Quien elige online para un problema batch paga latencia, disponibilidad y versionado que no usa.

# Misma regla, dos cadencias. No hay servidor: hay un reloj.

eventos = pd.DataFrame({
    "id": [1, 2, 3, 4],
    "t_evento": pd.to_datetime(["2026-09-12 08:01", "2026-09-12 08:04",
                                "2026-09-12 09:10", "2026-09-12 09:40"]),
    "monto": [20, 80, 15, 95],
})
cierre_lote = pd.Timestamp("2026-09-12 09:00")
eventos["modo_batch"] = np.where(eventos["t_evento"] <= cierre_lote, "entra_al_lote_09", "espera_lote_10")
eventos["modo_online"] = "puntua_al_llegar"
print(eventos.to_string(index=False))
print("Batch pospone el 09:10 y el 09:40. Online no. Esa es la única diferencia de arquitectura aquí.")
 id            t_evento  monto       modo_batch      modo_online
  1 2026-09-12 08:01:00     20 entra_al_lote_09 puntua_al_llegar
  2 2026-09-12 08:04:00     80 entra_al_lote_09 puntua_al_llegar
  3 2026-09-12 09:10:00     15   espera_lote_10 puntua_al_llegar
  4 2026-09-12 09:40:00     95   espera_lote_10 puntua_al_llegar
Batch pospone el 09:10 y el 09:40. Online no. Esa es la única diferencia de arquitectura aquí.

5.1 Frescura: el dato también tiene fecha de caducidad

Sea \(t_{\text{evento}}\) el instante del hecho y \(t_{\text{score}}\) el instante de la predicción. La antigüedad \(\delta = t_{\text{score}}-t_{\text{evento}}\) es una propiedad del sistema, no del algoritmo. Un modelo excelente alimentado con \(x\) de ayer es un sistema tardío. La sesión 12 vigilará deriva; aquí solo se mide \(\delta\).

t_score = pd.Timestamp("2026-09-12 10:00")
eventos["delta_min"] = (t_score - eventos["t_evento"]).dt.total_seconds() / 60
print(eventos[["id", "t_evento", "delta_min"]].to_string(index=False))
print(f"antigüedad mediana del lote: {eventos['delta_min'].median():.0f} min")
 id            t_evento  delta_min
  1 2026-09-12 08:01:00      119.0
  2 2026-09-12 08:04:00      116.0
  3 2026-09-12 09:10:00       50.0
  4 2026-09-12 09:40:00       20.0
antigüedad mediana del lote: 83 min

6) Un sistema mínimo extremo a extremo

Para que el mapa no quede en vocabulario, se construye un flujo deliberadamente pobre en algoritmo y rico en contratos:

  1. llegan eventos (simulación de captura),
  2. se persisten en crudo (bronca: no se limpia aquí; eso es la sesión 3),
  3. se recortan al esquema acordado,
  4. se puntúan con una regla (no se entrena; eso es la sesión 6),
  5. se escribe un registro de predicciones con marca de tiempo.

El valor pedagógico no es el umbral. Es que cada paso deja un archivo.

rng = np.random.default_rng(7)
n = 12
crudo = pd.DataFrame({
    "evento_id": [f"e{i:03d}" for i in range(n)],
    "t_evento": pd.date_range("2026-09-12 08:00", periods=n, freq="7min"),
    "monto": rng.integers(8, 120, size=n),
    "canal": rng.choice(["app", "web", "tienda"], size=n),
    "notas_libres": rng.choice(["ok", "", "revisar"], size=n),
})
(crudo).to_csv(RUTA / "crudo.csv", index=False)
print(crudo.head(4).to_string(index=False))
print("crudo.csv:", (RUTA / "crudo.csv").stat().st_size, "B")
evento_id            t_evento  monto  canal notas_libres
     e000 2026-09-12 08:00:00    113 tienda      revisar
     e001 2026-09-12 08:07:00     78    app           ok
     e002 2026-09-12 08:14:00     84    web      revisar
     e003 2026-09-12 08:21:00    108 tienda             
crudo.csv: 506 B

El crudo se guarda entero, incluidas columnas que el modelo no verá. Borrar “lo que no sirve” en la captura es perder la posibilidad de auditar. La sesión 2 llamará a esto capa bronce.

ESQUEMA = ["evento_id", "t_evento", "monto", "canal"]


def extraer_foto(crudo: pd.DataFrame, esquema: list[str]) -> pd.DataFrame:
    faltan = [c for c in esquema if c not in crudo.columns]
    if faltan:
        raise ValueError(f"esquema incompleto: {faltan}")
    return crudo.loc[:, esquema].copy()


foto = extraer_foto(crudo, ESQUEMA)
foto.to_csv(RUTA / "foto.csv", index=False)
print("columnas de la foto:", list(foto.columns))
print("columnas que el modelo no ve:", [c for c in crudo.columns if c not in ESQUEMA])
columnas de la foto: ['evento_id', 't_evento', 'monto', 'canal']
columnas que el modelo no ve: ['notas_libres']
def puntuar(foto: pd.DataFrame, umbral: float, t_score: pd.Timestamp) -> pd.DataFrame:
    salida = foto.copy()
    salida["yhat"] = (salida["monto"] >= umbral).astype(int)
    salida["t_score"] = t_score
    salida["umbral"] = umbral
    return salida


t_score = pd.Timestamp("2026-09-12 10:00")
scored = puntuar(foto, umbral=60, t_score=t_score)
scored.to_csv(RUTA / "predicciones.csv", index=False)
print(scored[["evento_id", "monto", "yhat"]].to_string(index=False))
evento_id  monto  yhat
     e000    113     1
     e001     78     1
     e002     84     1
     e003    108     1
     e004     72     1
     e005     94     1
     e006    101     1
     e007     33     0
     e008     14     0
     e009     41     0
     e010     39     0
     e011    105     1

El registro de predicciones es el primer objeto que un auditor pediría: qué se decidió, con qué umbral, sobre qué foto, a qué hora. Sin ese archivo, “el modelo dijo que no” es una frase, no un hecho.

manifiesto = {
    "sesion": 1,
    "t_score": str(t_score),
    "n_crudo": int(len(crudo)),
    "n_foto": int(len(foto)),
    "umbral": 60,
    "esquema": ESQUEMA,
    "archivos": sorted(p.name for p in RUTA.iterdir()),
}
(RUTA / "manifiesto.json").write_text(json.dumps(manifiesto, indent=2, ensure_ascii=False), encoding="utf-8")
print(json.dumps(manifiesto, indent=2, ensure_ascii=False))
{
  "sesion": 1,
  "t_score": "2026-09-12 10:00:00",
  "n_crudo": 12,
  "n_foto": 12,
  "umbral": 60,
  "esquema": [
    "evento_id",
    "t_evento",
    "monto",
    "canal"
  ],
  "archivos": [
    "crudo.csv",
    "foto.csv",
    "predicciones.csv"
  ]
}

6.1 Trazabilidad: reconstruir una predicción

Dado un evento_id, debe ser posible ir del registro a la fila cruda. Si no se puede, el sistema no es auditable. No hace falta un feature store (fuera de alcance) para exigir esa línea de vista.

def trazar(evento_id: str) -> dict:
    p = scored.loc[scored["evento_id"] == evento_id].iloc[0]
    c = crudo.loc[crudo["evento_id"] == evento_id].iloc[0]
    return {
        "evento_id": evento_id,
        "monto_crudo": int(c["monto"]),
        "notas_que_el_modelo_no_vio": c["notas_libres"],
        "yhat": int(p["yhat"]),
        "umbral": float(p["umbral"]),
        "t_evento": str(c["t_evento"]),
        "t_score": str(p["t_score"]),
    }

print(json.dumps(trazar("e003"), indent=2, ensure_ascii=False))
{
  "evento_id": "e003",
  "monto_crudo": 108,
  "notas_que_el_modelo_no_vio": "",
  "yhat": 1,
  "umbral": 60.0,
  "t_evento": "2026-09-12 08:21:00",
  "t_score": "2026-09-12 10:00:00"
}

7) Roles: quién firma cada flecha

Un mapa sin firmas es un organigrama decorativo. En un equipo pequeño una persona ocupa varios roles; eso no autoriza a no nombrarlos.

Rol Firma Lo que no firma
Dueño del problema métrica de decisión, costo de FN/FP el algoritmo
Ingeniero de datos esquema, puntualidad, hash de la foto el umbral de \(f\)
Científico de datos \(T\), \(f\), protocolo de evaluación el SQL de extracción “porque ya lo tenía”
Operación / MLOps despliegue, rollback, semáforo cambiar la etiqueta a mano
Auditor / riesgo trazas y veto reentrenar

La sesión 14 volverá sobre esto como comunicación. Aquí el punto es arquitectónico: una frontera de sistema que no tiene dueño se vuelve una frontera oral.

7.1 Ambientes: no son carpetas, son permisos

Dev, staging y producción no se distinguen por el color del logo. Se distinguen por quién puede escribir y por qué dato se ve. En desarrollo se experimenta. En staging se ensaya el contrato con un recorte realista. En producción se escribe el registro que un cliente o un regulador puede pedir.

Promover \(f\) de staging a producción sin promover el esquema y el umbral es el clásico “funciona en QA”. La sesión 8 formaliza el registro; aquí solo se nombra la regla: se promueve un paquete, no un archivo suelto.

ambientes = pd.DataFrame([
    {"ambiente": "dev", "escribe_predicciones_reales": False, "dato": "muestra o sintético", "puede_romper": True},
    {"ambiente": "staging", "escribe_predicciones_reales": False, "dato": "foto reciente anonimizada", "puede_romper": False},
    {"ambiente": "prod", "escribe_predicciones_reales": True, "dato": "tráfico vivo", "puede_romper": False},
])
print(ambientes.to_string(index=False))
ambiente  escribe_predicciones_reales                      dato  puede_romper
     dev                        False       muestra o sintético          True
 staging                        False foto reciente anonimizada         False
    prod                         True              tráfico vivo         False

8) Tres fallos de sistema que parecen fallos de modelo

  1. Sesgo de entrenamiento–servicio. Se puntúa en producción una \(x\) que \(T\) nunca vio en entrenamiento: otra unidad, otro huso, otra columna derivada “que alguien agregó en el SQL del batch”. El F1 cae y se culpa a \(f\).
  2. Cadencia cruzada. El lote de datos cierra a las 09:00; el modelo se reentrena a las 08:50 con la foto de ayer; a las 09:05 alguien compara cifras y declara deriva.
  3. Decisión sin registro. El umbral se cambió “un poco” en una celda. No hay umbral en el manifiesto. No hay incidente: hay amnesia.

Ninguno se arregla eligiendo XGBoost.

# El mismo umbral, dos fotos: cambia n y cambia la tasa de positivos.
# Quien solo mira la tasa dirá "el modelo se degradó".

def tasa(df, umbral):
    return float((df["monto"] >= umbral).mean())

foto_lunes = foto.copy()
foto_martes = foto.copy()
foto_martes.loc[0:3, "monto"] = foto_martes.loc[0:3, "monto"] * 2  # otra captura, no otro f
print(f"tasa lunes  (umbral 60): {tasa(foto_lunes, 60):.2f}")
print(f"tasa martes (umbral 60): {tasa(foto_martes, 60):.2f}")
print("f no cambió. Cambió la foto. El diagnóstico es de dato o de captura, no de algoritmo.")
tasa lunes  (umbral 60): 0.67
tasa martes (umbral 60): 0.67
f no cambió. Cambió la foto. El diagnóstico es de dato o de captura, no de algoritmo.

8.1 Reproducir el sistema, no el número

Reproducir un F1 de un artículo exige semilla, datos y código. Reproducir un sistema exige, además, el manifiesto: esquema, umbral, \(t_{ ext{score}}\), hash de la foto. Un número que no apunta a esos cuatro objetos es una anécdota.

9) Dónde se sienta cada sesión posterior

Este curso recorre el mapa en orden de dependencia conceptual, no de moda industrial.

Sesión Capa del mapa Lo que no se pide todavía
2 ETL \(E\): extraer, integrar, cargar calidad profunda ni \(T(x)\)
3 Calidad diagnóstico de \(x\) monitoreo en producción (3a, 12)
4 Representación diseñar \(T(x)\) el Pipeline como contrato
5 Contrato de ejecución fit/transform/predict la búsqueda de hiperparámetros
6 Entrenamiento protocolo honesto de \(f\) el DAG
7 Orquestación orden y reintentos el registro de experimentos
8 Experimentos run_id, staging/prod el HTTP
9 Servicio contrato 200/422/500 la imagen
10 Contenedor lock y receta el CI
11 CI/CD puertas de métrica el semáforo de deriva
12 Monitoreo PSI/KS y frescura SHAP
13 Interpretabilidad explicar un caso el informe de presupuesto
14 Comunicación decidir con el titular —

Quien salte de un accuracy de notebook a “montemos Kubernetes” está saltando las filas 2–8. El síntoma habitual es un servicio rápido que puntúa basura con gran disponibilidad.

9.1 Lo que esta sesión se niega a hacer

  • No limpia nulos ni discute MCAR (sesión 3).
  • No diseña encodings ni escaladores (sesión 4).
  • No serializa un Pipeline de scikit-learn (sesión 5).
  • No busca \(C\) ni declara un test ciego (sesión 6).
  • No implementa un orquestador ni HTTP ni Docker.

Si un ejercicio pide eso, está mal enunciado para este cuaderno. El ejercicio correcto pide nombrar la capa y el artefacto.

10) Inventario: el sistema cabe en una carpeta

Una arquitectura didáctica no se demuestra con un diagrama de cajas. Se demuestra listando archivos que un tercero puede leer sin el autor.

inventario = pd.DataFrame([
    {"archivo": p.name, "bytes": p.stat().st_size, "capa": {
        "crudo.csv": "dato / bronce",
        "foto.csv": "dato / esquema",
        "predicciones.csv": "modelo / registro",
        "manifiesto.json": "sistema / contrato",
    }.get(p.name, "?")}
    for p in sorted(RUTA.iterdir())
])
print(inventario.to_string(index=False))
         archivo  bytes               capa
       crudo.csv    506      dato / bronce
        foto.csv    444     dato / esquema
 manifiesto.json    274 sistema / contrato
predicciones.csv    764  modelo / registro

10.1 Idempotencia de la puntuación batch

Volver a puntuar la misma foto con el mismo umbral debe producir el mismo yhat. Si no, hay estado escondido (una semilla no fijada, un now() dentro de \(f\), una lectura a una API). La sesión 7 formalizará reintentos; aquí se comprueba la invariante.

otra = puntuar(foto, umbral=60, t_score=t_score)
igual = scored["yhat"].equals(otra["yhat"])
print("misma foto + mismo umbral => mismo yhat:", igual)
misma foto + mismo umbral => mismo yhat: True

11) Reloj y calendario como entradas de primera clase

\(t_{\text{evento}}\) y \(t_{\text{score}}\) no son metadatos decorativos. Deciden si un lote es comparable con otro, si una etiqueta ya “cerró” (sesión 12) y si un reentrenamiento usó información del futuro. Un sistema que no persiste relojes no puede explicar una discrepancia el viernes.

relojes = scored[["evento_id", "t_evento", "t_score"]].copy()
relojes["delta_min"] = (relojes["t_score"] - relojes["t_evento"]).dt.total_seconds() / 60
print(relojes.to_string(index=False))
print("si t_score < t_evento en alguna fila, el sistema viaja al pasado.")
evento_id            t_evento             t_score  delta_min
     e000 2026-09-12 08:00:00 2026-09-12 10:00:00      120.0
     e001 2026-09-12 08:07:00 2026-09-12 10:00:00      113.0
     e002 2026-09-12 08:14:00 2026-09-12 10:00:00      106.0
     e003 2026-09-12 08:21:00 2026-09-12 10:00:00       99.0
     e004 2026-09-12 08:28:00 2026-09-12 10:00:00       92.0
     e005 2026-09-12 08:35:00 2026-09-12 10:00:00       85.0
     e006 2026-09-12 08:42:00 2026-09-12 10:00:00       78.0
     e007 2026-09-12 08:49:00 2026-09-12 10:00:00       71.0
     e008 2026-09-12 08:56:00 2026-09-12 10:00:00       64.0
     e009 2026-09-12 09:03:00 2026-09-12 10:00:00       57.0
     e010 2026-09-12 09:10:00 2026-09-12 10:00:00       50.0
     e011 2026-09-12 09:17:00 2026-09-12 10:00:00       43.0
si t_score < t_evento en alguna fila, el sistema viaja al pasado.

12) El humano en el lazo no es un adorno

Muchos sistemas no cierran en \(\hat{y}\): cierran en una cola para un analista. Eso también es arquitectura. Cambia el SLA (el humano no responde en 40 ms), cambia el registro (hay que guardar la decisión humana) y cambia la métrica (ya no basta el F1 de \(f\); importa el F1 de \(f\) más el override).

Ignorarlo produce tableros que celebran un modelo que en la práctica nadie usa, o un modelo que se usa en contra de su umbral.

scored["override"] = np.where(scored["monto"] >= 100, "revisar", "auto")
print(scored.groupby("override")["yhat"].agg(["count", "mean"]).rename(columns={"mean": "tasa_yhat"}))
print("La cola 'revisar' es una etapa. Tiene dueño. No es un print.")
          count  tasa_yhat
override                  
auto          8        0.5
revisar       4        1.0
La cola 'revisar' es una etapa. Tiene dueño. No es un print.

12.1 Sesgo entrenamiento–servicio, visto como arquitectura

No es un bug de scikit-learn. Es dos construcciones de \(x\) que no comparten receta: el batch de entrenamiento aplica un CAST y un filtro; el camino online no. El síntoma es un F1 que “se cae al desplegar”. El arreglo no es reentrenar: es una sola función \(T\) (sesión 5) invocada en ambos caminos.

def T_train(df):
    return df.loc[df["canal"] != "tienda", ["monto"]]


def T_servicio(df):
    return df[["monto"]]  # olvidó el filtro


print("n train :", len(T_train(foto)))
print("n serv. :", len(T_servicio(foto)))
print("mismas columnas, distinta población: el sistema tiene dos T.")
n train : 8
n serv. : 12
mismas columnas, distinta población: el sistema tiene dos T.

12.2 Contrato de tipos, no solo de nombres

Un rename se caza con un KeyError. Un monto que llega como texto ("1.200") pasa el contrato de nombres y rompe la regla numérica —o, peor, se interpreta mal. El manifiesto debe listar tipos, no solo etiquetas. La sesión 2 lo implementa al extraer; aquí se deja el hueco visible.

contrato_tipos = {"evento_id": "string", "monto": "int64", "canal": "string"}
foto_mala = foto.copy()
foto_mala["monto"] = foto_mala["monto"].astype(str) + ".0"
rotos = {c: str(foto_mala[c].dtype) for c, t in contrato_tipos.items()
         if c in foto_mala and t not in str(foto_mala[c].dtype)}
print("tipos esperados:", contrato_tipos)
print("desajustes     :", rotos or "ninguno")
tipos esperados: {'evento_id': 'string', 'monto': 'int64', 'canal': 'string'}
desajustes     : {'evento_id': 'object', 'monto': 'object', 'canal': 'object'}

12.3 Interruptor y rollback son parte del mapa

Si \(\hat{y}\) se publica sin un kill switch (volver al lote anterior, o a una regla), el primer incidente se gestiona por WhatsApp. No hace falta Kubernetes: hace falta un puntero a la versión vigente y una forma de moverlo. La sesión 8 construye el registro; aquí se nombra el requisito.

punteros = {"vigente": "regla_umbral_60", "anterior": "regla_umbral_50"}
print("producción apunta a:", punteros["vigente"])
punteros["vigente"], punteros["anterior"] = punteros["anterior"], punteros["vigente"]
print("tras rollback      :", punteros["vigente"])
producción apunta a: regla_umbral_60
tras rollback      : regla_umbral_50

12.4 Registro de predicciones frente a monitoreo

Escribir predicciones.csv es registro. Comparar esa foto con una referencia y encender un semáforo es monitoreo (sesión 12). Quien tiene lo primero y llama a eso “ya monitoreamos” no tiene alerta: tiene un archivo que nadie mira.

print("registro :", (RUTA / "predicciones.csv").exists())
print("monitor  :", (RUTA / "semaforo.json").exists(), "← no existe, y no debe existir aún")
registro : True
monitor  : False ← no existe, y no debe existir aún

12.5 Enrutamiento: más de una \(f\) no es “ya somos microservicios”

Un umbral para app y otro para tienda es política, no topología. Puede vivir en una tabla de reglas. Convertirlo en dos servicios solo se justifica si tienen cadencias o dueños distintos. Mientras tanto, es un if versionado.

reglas = {"app": 60, "web": 60, "tienda": 90}
ruta = foto.assign(umbral=foto["canal"].map(reglas))
ruta["yhat"] = (ruta["monto"] >= ruta["umbral"]).astype(int)
print(ruta.groupby("canal")[["umbral", "yhat"]].agg({"umbral": "first", "yhat": "mean"}))
        umbral      yhat
canal                   
app         60  0.800000
tienda      90  0.750000
web         60  0.333333

12.6 Arranque en frío del sistema

El primer día no hay histórico de predicciones, no hay semáforo y a veces no hay etiqueta. Un sistema que exige tres meses de monitor para arrancar no arranca. El mapa debe admitir un modo degradado: regla + registro, y el resto de capas se encienden cuando hay con qué compararlos.

modo = "degradado" if not (RUTA / "semaforo.json").exists() else "pleno"
print("modo de arranque:", modo)
print("obligatorio en frío: crudo, foto, predicciones, manifiesto")
print("aplazable          : semáforo, registro de experimentos, imagen")
modo de arranque: degradado
obligatorio en frío: crudo, foto, predicciones, manifiesto
aplazable          : semáforo, registro de experimentos, imagen

13) Errores frecuentes

  1. Llamar “producción” a un notebook que corre en el portátil del autor.
  2. Versionar \(f\) y no el esquema ni el umbral.
  3. Medir el éxito del sistema solo con F1 de un holdout de hace tres meses.
  4. Elegir online porque “suena a tiempo real” cuando el lote diario basta.
  5. Partir en servicios antes de tener un contrato de columnas.
  6. Borrar el crudo para “dejar limpio el proyecto”.
  7. Diagnosticar como overfitting un cambio de captura.

14) Checklist

  1. ¿Hay manifiesto con esquema, umbral, \(n\) y hora?
  2. ¿El pipeline de datos y el de modelo son dos funciones?
  3. ¿Se puede trazar un evento_id hasta el crudo?
  4. ¿Está escrito el modo (batch / online) y su justificación?
  5. ¿Cada etapa del ciclo tiene artefacto o una razón explícita para no tenerlo todavía?
  6. ¿Se nombró dueño de la métrica de decisión?

15) Conclusiones

Un sistema de IA es una composición de contratos, no un algoritmo con un logo. Esta sesión dejó un mapa —ciclo de vida, dos pipelines, dos topologías, tres cadencias— y un contraejemplo operable: crudo, foto, predicciones, manifiesto. Las sesiones 2 y 3 bajan a la primera flecha (\(E\) y la calidad de \(x\)). Quien avance al modelo sin esos contratos estará, otra vez, en el diálogo del inicio: el notebook corre.

16) Taller

A. Añada al manifiesto el hash de foto.csv. ¿Cambia el hash si reordena las filas? ¿Debería?

B. Cambie el nombre monto → importe solo en el crudo. ¿En qué función debe fallar? ¿En el monolito fallaría en silencio?

C. Justifique en cinco líneas si el problema de su dominio pide batch u online. La latencia de \(f\) no es un argumento suficiente.

D. Señale, en la tabla de la sección 9, qué sesión firmaría el artefacto que usted no tiene hoy en un proyecto real.

E. Introduzca un override humano en tres filas y calcule la tasa de \(\hat{y}\) solo en la parte automática. Documente ambos números.

print("Artefactos de la sesión 1:")
for p in sorted(RUTA.iterdir()):
    print(f"  {p.name:20s} {p.stat().st_size:6d} B")
print("Taller A–E. Si la respuesta no nombra una capa, vuelva a la sección 9.")
Artefactos de la sesión 1:
  crudo.csv               506 B
  foto.csv                444 B
  manifiesto.json         274 B
  predicciones.csv        764 B
Taller A–E. Si la respuesta no nombra una capa, vuelva a la sección 9.

💬 ¿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.