Vam prometre arremangar-nos, i aquí comença la feina. En aquesta lliçó agafarem el notebook de Laura —churn_final_v3_DEFINITIU.ipynb, exactament tal com el vam deixar al mòdul 1— i el convertirem en un projecte Python estructurat: un repositori amb un paquet instal·lable, mòduls amb responsabilitats clares, configuració externa i un primer test. És la transformació més important de tot el curs, perquè tot el que ve després (versionar dades, encadenar pipelines, servir el model, automatitzar) pressuposa que el codi ja no viu en cel·les d'un notebook sinó en mòduls que es poden importar, testejar i executar en qualsevol màquina. I de passada, en refactoritzar l'avaluació, destaparem d'una vegada per totes la mentida del 0.87.
Contingut
- Per què el notebook no escala (i per a què sí que serveix)
- El disseny del repositori
cineclick-churn - Refactorització pas a pas: dades, features, entrenament i avaluació
- Configuració externa:
config.yamlen lloc de valors hardcodejats pyproject.toml: el projecte com a paquet instal·lable- Un primer test amb pytest (avançament)
Per què el notebook no escala (i per a què sí que serveix)
Comencem amb justícia: el notebook no és l'enemic. És la millor eina que existeix per a la fase d'exploració —iterar ràpid, visualitzar, provar hipòtesis, documentar troballes al costat del codi— i aquesta fase no desapareix amb MLOps. Laura continuarà fent servir notebooks, i tu també. El problema és fer servir el notebook com a unitat de producció, perquè com a tal té defectes estructurals que cap pedaç no arregla:
- Estat ocult i execució fora d'ordre: les cel·les es poden executar en qualsevol ordre, i una variable pot venir d'una cel·la que ja no existeix. El resultat d'un notebook depèn de la seva història d'execució, no només del seu codi. En producció això és inacceptable: el mateix codi ha de produir el mateix resultat.
- No és importable: no pots fer
from notebook import netejar_dadesdes d'un servei FastAPI ni des d'un test. Tot el que viu en un notebook està condemnat a copiar-se i enganxar-se — i les còpies divergeixen (el problema núm. 9 del diagnòstic: per predir en producció caldria reimplementar el preprocessament). - No es testeja bé: pytest descobreix i executa funcions en mòduls
.py. Un notebook no exposa funcions testejables; exposa cel·les. - Els diffs són il·legibles: un
.ipynbés un JSON amb sortides, comptadors d'execució i imatges en base64 incrustades. Ungit diffde dues versions és soroll; revisar un canvi en un pull request, impossible. - Tot en un sol bloc: càrrega, neteja, features, entrenament i avaluació barrejats vol dir que no pots re-executar només una part (una cosa que serà or a la lliçó 02-04) ni raonar sobre cada peça per separat.
La divisió de feina sana és aquesta:
| Eina | Per a què SÍ | Per a què NO |
|---|---|---|
| Notebook | Explorar dades, prototipar features, visualitzar, anàlisis puntuals, comunicar troballes | Codi de producció, lògica reutilitzable, qualsevol cosa que hagi d'executar-se sense humans |
Paquet Python (mòduls .py) |
Lògica de dades/features/entrenament/avaluació, tot allò testejable i automatitzable | Exploració interactiva ràpida (es pot, però iterar és més lent) |
La regla pràctica: el notebook descobreix; el paquet industrialitza. Quan un fragment del notebook demostra el seu valor, es "gradua" a un mòdul del paquet, i el notebook passa a importar-lo en lloc de contenir-ne una còpia.
El disseny del repositori cineclick-churn
Creem un repositori Git anomenat cineclick-churn amb aquesta estructura (llegeix-la amb calma; cada directori té una raó de ser):
cineclick-churn/ ├── src/ │ └── cineclick_churn/ # El paquet Python (codi de producció) │ ├── __init__.py │ ├── data.py # Càrrega i neteja de dades │ ├── features.py # Construcció de features (one-hot, etc.) │ ├── train.py # Entrenament del model │ └── evaluate.py # Avaluació amb mètriques honestes ├── tests/ # Tests automatitzats (pytest) │ └── test_features.py ├── data/ │ ├── raw/ # Dades originals, INTOCABLES (clients_churn.csv) │ └── processed/ # Dades derivades, regenerables des de raw/ ├── models/ # Models serialitzats (artefactes de sortida) ├── notebooks/ # Exploració (aquí es muda el notebook de Laura) │ └── exploracio_churn.ipynb ├── configs/ │ └── config.yaml # Rutes i paràmetres externalitzats ├── pyproject.toml # Definició del paquet i metadades └── README.md
Decisions de disseny que convé entendre:
src/layout: el paquet viu dins desrc/en lloc de a l'arrel. És la convenció moderna recomanada perquè obliga a instal·lar el paquet per fer-lo servir (ambpip install -e ., ho veurem més avall): així els tests i el codi de producció importen exactament el mateix paquet que es desplegarà, i no un directori que casualment era al path.- Un mòdul per etapa del cicle de vida:
data.py→features.py→train.py→evaluate.pyreflecteix les etapes que vam veure a la lliçó 01-02. No és casual: a la lliçó 02-04 cada mòdul es convertirà en un pas de pipeline amb dependències explícites. data/raw/és sagrat: les dades originals no es modifiquen ni se sobreescriuen mai; tot allò derivat va adata/processed/i ha de poder regenerar-se. (Com versionar el que hi ha adata/és el tema de la lliçó 02-03 — de moment afegiremdata/imodels/al.gitignore, perquè Git no és el lloc per a ells.)notebooks/continua existint: l'exploració no mor, s'endreça. El notebook de Laura es rebateja amb un nom digne (exploracio_churn.ipynb) i es conserva com a registre del descobriment inicial.configs/separa el què del quant: el codi defineix la lògica; els fitxers de configuració, els valors. Ho desenvolupem a l'apartat 4.
Refactorització pas a pas: dades, features, entrenament i avaluació
Ara trossegem el notebook. L'estratègia: identificar cada responsabilitat, extreure-la a una funció amb entrades i sortides explícites, i col·locar-la al seu mòdul. Cap funció no farà servir rutes absolutes ni valors màgics: tot arriba per paràmetre.
Pas 1 — data.py: càrrega i neteja
Del notebook n'extraiem la càrrega (pd.read_csv amb la ruta de l'escriptori de Laura) i la neteja (dropna, filtre d'hores negatives, eliminació de l'id):
# src/cineclick_churn/data.py
"""Càrrega i neteja del dataset de churn de CineClick."""
import pandas as pd
def carregar_dades(ruta: str) -> pd.DataFrame:
"""Llegeix el CSV de clients. La ruta arriba per paràmetre: res de rutes fixes."""
return pd.read_csv(ruta)
def netejar_dades(df: pd.DataFrame) -> pd.DataFrame:
"""Aplica les regles de neteja acordades amb Laura.
- Elimina files amb valors nuls.
- Elimina files amb hores de visualització negatives (error conegut de logs).
- Elimina id_client: és un identificador sense valor predictiu.
"""
df = df.dropna()
df = df[df["hores_setmana"] >= 0]
df = df.drop(columns=["id_client"])
return df.reset_index(drop=True)Fixa't en tres canvis respecte al notebook: la ruta és un paràmetre (el problema núm. 1 comença a morir aquí), cada funció té una responsabilitat i un docstring que documenta les decisions (abans eren comentaris solts), i reset_index deixa el DataFrame net d'índexs foradats després del filtratge — un clàssic generador de bugs silenciosos.
Pas 2 — features.py: construcció de features
El one-hot encoding amb get_dummies es gradua al seu propi mòdul, amb una millora crucial per al futur: fixem explícitament quines columnes són categòriques i quina és l'etiqueta, en lloc de deixar que el codi ho "endevini":
# src/cineclick_churn/features.py
"""Transformació de features per al model de churn."""
import pandas as pd
COLUMNES_CATEGORIQUES = ["pla", "metode_pagament"]
COLUMNA_ETIQUETA = "abandonament"
def construir_features(df: pd.DataFrame) -> pd.DataFrame:
"""Converteix les categòriques en columnes 0/1 (one-hot encoding).
'pla' produeix pla_basic / pla_estandard / pla_premium;
'metode_pagament' produeix metode_pagament_targeta / _domiciliacio / _paypal.
"""
return pd.get_dummies(df, columns=COLUMNES_CATEGORIQUES, dtype=int)
def separar_features_etiqueta(df: pd.DataFrame) -> tuple[pd.DataFrame, pd.Series]:
"""Separa X (features) de y (etiqueta 'abandonament')."""
X = df.drop(columns=[COLUMNA_ETIQUETA])
y = df[COLUMNA_ETIQUETA]
return X, yPer què les constants en majúscules al principi? Perquè el servei de predicció del mòdul 4 importarà aquest mateix mòdul per transformar els clients nous exactament igual que a l'entrenament. Una sola implementació del preprocessament, compartida: el problema núm. 9 (preprocessament no reutilitzable) queda estructuralment encarrilat.
Pas 3 — train.py: entrenament amb l'atzar sota control
Aquí ataquem el problema núm. 2. Al notebook, ni train_test_split ni RandomForestClassifier no duien llavor, així que cada execució era irrepetible. Ara la llavor és un paràmetre obligatori:
# src/cineclick_churn/train.py
"""Entrenament del model de churn."""
import pickle
from pathlib import Path
import pandas as pd
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
def dividir_dades(X, y, test_size: float, random_state: int):
"""Divideix en train/test de manera reproduïble i estratificada.
- random_state fixa l'atzar: la mateixa llavor produeix la mateixa partició.
- stratify=y garanteix que train i test conservin el ~15% d'abandonament;
sense això, amb classes desequilibrades el test podria quedar esbiaixat.
"""
return train_test_split(
X, y, test_size=test_size, random_state=random_state, stratify=y
)
def entrenar_model(X_train, y_train, n_estimators: int, random_state: int):
"""Entrena el RandomForest amb llavor fixa: mateixes dades -> mateix bosc."""
model = RandomForestClassifier(n_estimators=n_estimators, random_state=random_state)
model.fit(X_train, y_train)
return model
def desar_model(model, ruta: str) -> None:
"""Serialitza el model, creant el directori destí si no existeix."""
Path(ruta).parent.mkdir(parents=True, exist_ok=True)
with open(ruta, "wb") as f:
pickle.dump(model, f)Dues llavors, un mateix valor: random_state controla tant la partició com l'aleatorietat interna del bosc (quines files i columnes mostreja cada arbre). Amb això, executar dues vegades l'entrenament sobre les mateixes dades produeix exactament el mateix model i les mateixes mètriques. Hi afegim també stratify=y, una millora que el notebook no tenia: amb només un 15% de positius, una partició no estratificada pot deixar el test amb un percentatge de churn diferent del real i distorsionar l'avaluació.
Pas 4 — evaluate.py: mètriques que no menteixen
El moment de la veritat. El notebook celebrava accuracy = 0.87, però recorda el problema núm. 5: amb un 85% de clients que no abandonen, un model trivial que sempre predigui "es queda" ja puntua 0.85. Introduïm les mètriques que miren la classe que importa (els que se'n van):
- Precision (de la classe positiva): dels clients que el model assenyala com a "abandonaran", quina fracció abandona de debò? Precision baixa = l'equip de retenció gasta descomptes en clients que no se n'anaven.
- Recall (de la classe positiva): dels clients que realment abandonen, quina fracció detecta el model? Recall baix = les baixes es produeixen sense que ningú les veiés venir. És la mètrica que fa mal a negoci.
- F1: mitjana harmònica de les dues anteriors; útil com a resum en un sol número quan cal comparar models.
# src/cineclick_churn/evaluate.py
"""Avaluació honesta del model de churn."""
from sklearn.metrics import accuracy_score, f1_score, precision_score, recall_score
def avaluar_model(model, X_test, y_test) -> dict:
"""Calcula les mètriques sobre el conjunt de test.
precision/recall/f1 es calculen sobre la classe positiva (abandonament=1),
que és la minoritària i la que interessa a negoci.
"""
prediccions = model.predict(X_test)
return {
"accuracy": round(accuracy_score(y_test, prediccions), 4),
"precision": round(precision_score(y_test, prediccions), 4),
"recall": round(recall_score(y_test, prediccions), 4),
"f1": round(f1_score(y_test, prediccions), 4),
}En executar el flux complet amb random_state=42 i n_estimators=100 sobre el dataset, obtenim (i ara sí, obtindrem sempre el mateix):
Llegim-ho sense anestèsia: l'accuracy continua rondant el que Laura veia (el 0.87 original ballava amb l'atzar), però el model només detecta el 43% dels clients que abandonen, i dels que assenyala, n'encerta el 61%. No és un desastre —detectar 4 de cada 10 abandonaments amb aquesta precisió ja permet campanyes rendibles—, però és una foto radicalment diferent del "0.87, millor que la v2!". Aquesta és la línia base honesta sobre la qual mesurarem tota millora futura. Millorar aquestes xifres (provar hiperparàmetres, llindars, altres algorismes) requerirà comparar molts experiments amb comoditat: aquesta és la feina del mòdul 3.
Configuració externa: config.yaml en lloc de valors hardcodejats
Ja no hi ha rutes absolutes al codi, però els valors (rutes relatives, test_size, n_estimators, la llavor) han de viure en algun lloc. Si els escrivim a l'script que ho orquestra tot, tornem a barrejar lògica i paràmetres. La solució estàndard: un fitxer de configuració llegible, versionat a Git al costat del codi.
# configs/config.yaml dades: ruta_raw: data/raw/clients_churn.csv ruta_processed: data/processed/clients_net.csv model: ruta: models/model_churn.pkl n_estimators: 100 random_state: 42 avaluacio: test_size: 0.2
Observa que totes les rutes són relatives a l'arrel del repositori: el projecte funciona igual al portàtil de Laura, al teu o en un servidor. Carregar-lo des de Python és trivial amb PyYAML:
# Fragment d'un script d'entrenament (p. ex. al final de train.py)
import yaml
from cineclick_churn import data, evaluate, features, train
def main():
with open("configs/config.yaml", encoding="utf-8") as f:
config = yaml.safe_load(f) # safe_load: mai yaml.load a seques
df = data.netejar_dades(data.carregar_dades(config["dades"]["ruta_raw"]))
df = features.construir_features(df)
X, y = features.separar_features_etiqueta(df)
X_train, X_test, y_train, y_test = train.dividir_dades(
X, y, config["avaluacio"]["test_size"], config["model"]["random_state"]
)
model = train.entrenar_model(
X_train, y_train, config["model"]["n_estimators"], config["model"]["random_state"]
)
train.desar_model(model, config["model"]["ruta"])
print(evaluate.avaluar_model(model, X_test, y_test))
if __name__ == "__main__":
main()El bloc if __name__ == "__main__": permet executar el mòdul com a script (python -m cineclick_churn.train) a més d'importar-lo: ens anirà de primera a la lliçó 02-04, quan cada etapa es converteixi en una ordre de pipeline. Canviar un hiperparàmetre ja no és editar codi: és editar un YAML, i aquest canvi queda registrat a l'historial de Git amb el seu commit i el seu perquè. (A 02-04 refinarem aquesta idea separant els hiperparàmetres a un params.yaml que DVC entén de manera nativa.)
pyproject.toml: el projecte com a paquet instal·lable
Falta la cola: que from cineclick_churn import features funcioni des de qualsevol lloc (tests, notebooks, el futur servei) sense trucs de sys.path. Per a això el projecte es declara com a paquet a pyproject.toml, el fitxer estàndard de configuració de projectes Python:
# pyproject.toml [build-system] requires = ["setuptools>=68"] build-backend = "setuptools.build_meta" [project] name = "cineclick-churn" version = "0.1.0" description = "Model de prediccio de churn de CineClick" requires-python = ">=3.11" [tool.setuptools.packages.find] where = ["src"]
I s'instal·la en mode editable:
El -e (editable) significa que pip no copia el codi al seu directori de paquets, sinó que crea un enllaç cap a src/: cada canvi que facis als mòduls és visible a l'instant, sense reinstal·lar. És el mode de treball estàndard durant el desenvolupament. A partir d'ara, el notebook d'exploració de Laura pot començar amb from cineclick_churn.features import construir_features — el notebook consumeix el paquet, mai a l'inrevés. (Aquest pyproject.toml és deliberadament incomplet: la declaració de dependències —pandas, scikit-learn, PyYAML i companyia— és el tema central de la propera lliçó.)
Un primer test amb pytest (avançament)
El problema núm. 6 del diagnòstic era "zero tests". No el resoldrem sencer avui —els tests seriosos de ML (de dades, de comportament del model, en CI) són el tema de la lliçó 05-01—, però ara que tenim funcions importables, escriure el primer costa dos minuts i canvia la cultura del projecte:
# tests/test_features.py
import pandas as pd
from cineclick_churn.features import construir_features
def test_one_hot_genera_columnes_de_pla():
df = pd.DataFrame({
"antiguitat_mesos": [12, 3],
"hores_setmana": [5.0, 1.5],
"tiquets_suport": [0, 2],
"pla": ["basic", "premium"],
"metode_pagament": ["targeta", "domiciliacio"],
"descompte_actiu": [0, 1],
"abandonament": [0, 1],
})
resultat = construir_features(df)
assert "pla_basic" in resultat.columns
assert "pla_premium" in resultat.columns
assert "pla" not in resultat.columns # l'original desapareix
assert resultat["pla_basic"].tolist() == [1, 0]S'executa amb pytest des de l'arrel del repo. El test construeix un mini-DataFrame fictici de dues files, aplica la transformació real (la mateixa que farà servir producció) i comprova que el one-hot fa el que promet. Si demà algú "millora" features.py i trenca l'encoding, el test fallarà abans que l'error arribi a un model. Petit, sí; però és la llavor de la xarxa de seguretat que teixirem al mòdul 5.
Errors Comuns i Consells
- Error: refactoritzar i "millorar" alhora. La temptació d'aprofitar per canviar l'algorisme o afegir features és enorme. Resisteix-la: primer reprodueix el comportament del notebook a la nova estructura (mateixa neteja, mateix model), verifica que funciona, i després millora. Si canvies estructura i lògica alhora i alguna cosa es trenca, no sabràs quina de les dues coses ha estat. (L'única "millora" que hi hem esmunyit —
stratifyi les mètriques noves— afecta la mesura, no el model.) - Error: oblidar
random_stateen un dels dos llocs. Fixar-lo a l'split però no alRandomForestClassifier(o a l'inrevés) dona una falsa sensació de reproduïbilitat: les mètriques continuaran ballant. Regla: busca tot paràmetre anomenatrandom_stateoseeda les funcions que facis servir i decideix-ne el valor conscientment. - Error: imports relatius fràgils o
sys.path.append. Si et trobes escrivintsys.path.append("../src")en un test o un notebook, la solució no és aquesta: éspip install -e .. L'src layout + instal·lació editable existeix precisament per eliminar aquesta classe de hacks. - Error: deixar el
config.yamlfora de Git. La configuració és codi a tots els efectes: sense ella, el projecte no es pot executar. El que NO va a Git són dades i models (ho gestionarem a 02-03), no la configuració. - Consell: refactoritza amb Laura, no contra Laura. Ella sap per què es filtren les hores negatives o es va eliminar una columna. Cada decisió de neteja rescatada del notebook ha d'acabar documentada en un docstring; el coneixement tàcit és deute tècnic.
Exercicis
Exercici 1
Sense escriure codi: classifica cadascun d'aquests fragments del notebook original segons el mòdul del paquet al qual pertany (data.py, features.py, train.py, evaluate.py) o si pertany a la configuració (config.yaml): (a) df = df[df["hores_setmana"] >= 0]; (b) test_size=0.2; (c) pd.get_dummies(df, columns=["pla", "metode_pagament"]); (d) accuracy_score(y_test, prediccions); (e) "C:/Users/laura/Desktop/datos/clients_churn.csv"; (f) RandomForestClassifier(n_estimators=100).
Exercici 2
Escriu un segon test per a tests/test_features.py que verifiqui que separar_features_etiqueta funciona correctament: que X no conté la columna abandonament, que y conté exactament els valors d'aquesta columna, i que X conserva el mateix nombre de files que el DataFrame d'entrada. Reutilitza el mini-DataFrame del test existent (idealment, extreu-lo a una fixture de pytest per no duplicar-lo).
Exercici 3
L'equip de retenció et pregunta: "amb les mètriques noves (precision 0.61, recall 0.43), si en un mes 1.000 clients abandonaran realment, quants en detectarà el model, i quants falsos positius generarà aproximadament?". Raona la resposta amb aquestes dues mètriques i explica quin dels dos tipus d'error és més car per a CineClick, sabent que la campanya de retenció consisteix en un descompte del 20% durant 3 mesos.
Solucions
Solució 1: (a) data.py — és una regla de neteja; (b) config.yaml — és un paràmetre, no lògica (el llegeix qui fa l'split a train.py); (c) features.py — transformació de features; (d) evaluate.py — càlcul de mètriques; (e) config.yaml — és una ruta, i a més ha de passar a ser relativa (data/raw/clients_churn.csv); (f) la crida pertany a train.py, però el valor n_estimators=100 pertany a config.yaml. La pauta general: la lògica va al mòdul de la seva etapa; els valors concrets, a configuració.
Solució 2:
import pandas as pd
import pytest
from cineclick_churn.features import separar_features_etiqueta
@pytest.fixture
def df_exemple():
return pd.DataFrame({
"antiguitat_mesos": [12, 3],
"hores_setmana": [5.0, 1.5],
"tiquets_suport": [0, 2],
"pla": ["basic", "premium"],
"metode_pagament": ["targeta", "domiciliacio"],
"descompte_actiu": [0, 1],
"abandonament": [0, 1],
})
def test_separar_features_etiqueta(df_exemple):
X, y = separar_features_etiqueta(df_exemple)
assert "abandonament" not in X.columns # l'etiqueta no es filtra a les features
assert y.tolist() == [0, 1] # y conté exactament l'etiqueta
assert len(X) == len(df_exemple) # no es perden filesLa fixture (@pytest.fixture) és la manera idiomàtica de pytest de compartir dades de prova entre tests: es declara una vegada i qualsevol test la rep com a paràmetre.
Solució 3: amb recall 0.43, dels 1.000 clients que abandonaran, el model en detecta ~430. Amb precision 0.61, aquests 430 encerts són el 61% de totes les alertes que emet el model, així que n'emet en total ~430 / 0.61 ≈ 705, de les quals ~275 són falsos positius (clients marcats com a risc que no se n'anaven). El cost de cada error: un fals positiu costa un descompte innecessari (20% × 3 mesos d'una quota, uns pocs euros); un fals negatiu costa un client sencer (tot el seu valor futur, que el mòdul 1 va xifrar en 5-7 vegades el cost d'adquisició evitat). Per a CineClick, el fals negatiu és molt més car, cosa que suggereix que en el futur convindrà empènyer el recall encara que la precision baixi una mica — exactament el tipus d'experiment que registrarem ordenadament al mòdul 3.
Conclusió
El notebook de Laura ja no és un monòlit: és un repositori cineclick-churn amb un paquet instal·lable (src/cineclick_churn/ amb data.py, features.py, train.py i evaluate.py), configuració externa a configs/config.yaml amb rutes relatives, llavors fixes (random_state=42 a l'split i al model, amb estratificació), una avaluació honesta que substitueix l'accuracy enganyosa per precision 0.61 / recall 0.43 / F1 0.50 com a línia base, i un primer test que vigila el preprocessament. Els problemes núm. 1 (rutes personals), núm. 2 (atzar sense control) i núm. 9 (preprocessament no reutilitzable) del diagnòstic estan resolts o encarrilats. Però hi ha una esquerda que aquesta feina no tapa: el nostre codi és reproduïble sobre el nostre entorn, i res no garanteix que el teu company tingui les mateixes versions de pandas o scikit-learn que tu — el problema núm. 8 continua ben viu. A la propera lliçó l'ataquem de front: entorns virtuals, declaració de dependències i fitxers de lock perquè "a la meva màquina funciona" deixi de ser una amenaça.
Curs de MLOps
Mòdul 1: Fonaments de MLOps
- Què és MLOps i per què els models moren al notebook
- El cicle de vida d'un model de ML en producció
- Nivells de maduresa MLOps i rols de l'equip
- El projecte del curs: del notebook a producció
Mòdul 2: Del notebook al codi reproduïble
- Estructura d'un projecte de ML: del notebook al paquet
- Entorns reproduïbles i gestió de dependències
- Versionat de dades amb DVC
- Pipelines d'entrenament reproduïbles
Mòdul 3: Experiments i registre de models
- Tracking d'experiments amb MLflow
- Model registry: versionar i promocionar models
- Feature stores: quan i per a què
Mòdul 4: Servir models en producció
- Patrons de desplegament: batch, online i streaming
- Un servei de predicció amb FastAPI
- Empaquetatge amb Docker
- Escalat i desplegament: Kubernetes i serverless
- Optimització de la inferència: latència i cost
Mòdul 5: Automatització: CI/CD i orquestració
- CI per a ML: tests de codi, dades i models
- CD: automatitzar el desplegament del model
- Orquestració de pipelines de ML
- Estratègies de release: shadow, canary i A/B
