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

  1. Per què el notebook no escala (i per a què sí que serveix)
  2. El disseny del repositori cineclick-churn
  3. Refactorització pas a pas: dades, features, entrenament i avaluació
  4. Configuració externa: config.yaml en lloc de valors hardcodejats
  5. pyproject.toml: el projecte com a paquet instal·lable
  6. 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_dades des 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. Un git diff de 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 de src/ en lloc de a l'arrel. És la convenció moderna recomanada perquè obliga a instal·lar el paquet per fer-lo servir (amb pip 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.pyfeatures.pytrain.pyevaluate.py reflecteix 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 a data/processed/ i ha de poder regenerar-se. (Com versionar el que hi ha a data/ és el tema de la lliçó 02-03 — de moment afegirem data/ i models/ 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, y

Per 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):

{"accuracy": 0.86, "precision": 0.61, "recall": 0.43, "f1": 0.5}

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:

pip install -e .

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 —stratify i les mètriques noves— afecta la mesura, no el model.)
  • Error: oblidar random_state en un dels dos llocs. Fixar-lo a l'split però no al RandomForestClassifier (o a l'inrevés) dona una falsa sensació de reproduïbilitat: les mètriques continuaran ballant. Regla: busca tot paràmetre anomenat random_state o seed a les funcions que facis servir i decideix-ne el valor conscientment.
  • Error: imports relatius fràgils o sys.path.append. Si et trobes escrivint sys.path.append("../src") en un test o un notebook, la solució no és aquesta: és pip install -e .. L'src layout + instal·lació editable existeix precisament per eliminar aquesta classe de hacks.
  • Error: deixar el config.yaml fora 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 files

La 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.

© Copyright 2026. Tots els drets reservats