Pràctica PR5072/01: Pipeline de ML amb scikit-learn i Docker
Objectius
- Configurar entorn Jupyter + scikit-learn amb Docker
- Realitzar EDA (Exploratory Data Analysis) complet
- Construir un pipeline de preprocés reutilitzable
- Entrenar i comparar 3 models de classificació
- Avaluar amb mètriques professionals (AUC-ROC, F1, Precision, Recall)
- Optimitzar hiperparàmetres amb Optuna
- Exportar el model final amb joblib
Prerequisits
- Temps estimat: 8 hores
- RAM mínima: 8 GB
- Docker Desktop instal·lat i en funcionament
- Coneixements bàsics de Python i Pandas
- Conceptes de ML supervisat (classificació binària)
Introducció
El churn (o taxa de baixa de clients) és un dels indicadors de negoci més crítics per a qualsevol empresa de serveis. Quan un client deixa de contractar un servei, no sols es perd el seu ingrés futur sinó que captar un client nou costa entre cinc i set vegades més que retenir-ne un d'existent. Detectar proactivament quins clients tenen més probabilitat d'abandonar permet desplegar accions preventives: ofertes personalitzades, millores de servei o contacte directe del servei d'atenció al client.
En aquesta pràctica construirem un pipeline complet de Machine Learning supervisat per predir la baixa de clients a partir del dataset Telco Customer Churn de Kaggle, que conté informació real (anonimitzada) d'una companyia de telecomunicacions nord-americana. El dataset inclou variables demogràfiques, tipus de contracte, serveis contractats, temps com a client i si finalment va cancel·lar el servei.
flowchart LR
A[Dataset Telco CSV] --> B[EDA i neteja]
B --> C[ColumnTransformer]
C --> D[Pipeline sklearn]
D --> E[Entrenament 3 models]
E --> F[Cross-validation]
F --> G[Millor model]
G --> H[Optuna HPO]
H --> I[Model final]
I --> J[joblib export]
Dataset Telco Customer Churn
El dataset original es pot descarregar des de Kaggle - Telco Customer Churn. Conté 7.043 registres de clients amb 21 variables. La variable objectiu és Churn (Sí/No).
Part 1: Configuració de l'entorn Docker
1.1 Estructura del projecte
Crea la carpeta del projecte amb el teu nom seguint la convenció del curs:
# Substitueix joan-garcia pel teu nom real
mkdir sklearn-joan-garcia
cd sklearn-joan-garcia
mkdir notebooks data
1.2 Fitxer requirements.txt
cat > requirements.txt << 'EOF'
scikit-learn==1.4.2
pandas==2.2.1
numpy==1.26.4
matplotlib==3.8.4
seaborn==0.13.2
xgboost==2.0.3
optuna==3.6.1
joblib==1.4.0
jupyter==1.0.0
ipywidgets==8.1.2
plotly==5.22.0
EOF
1.3 Dockerfile
FROM jupyter/scipy-notebook:python-3.11
# Metadades
LABEL maintainer="Joan Garcia"
LABEL course="5072-Sistemes-ML"
# Copia i instal·la dependències
COPY requirements.txt /tmp/
RUN pip install --no-cache-dir -r /tmp/requirements.txt
# Directori de treball
WORKDIR /home/jovyan/work
# Healthcheck
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s \
CMD curl -f http://localhost:8888/api || exit 1
1.4 Construcció i arrencada del contenidor
# Construeix la imatge amb el teu nom
docker build -t sklearn-joan-garcia .
# Arrenca el contenidor (el token es veu als logs)
docker run --name sklearn-joan-garcia \
-p 8888:8888 \
-v $(pwd)/notebooks:/home/jovyan/work \
-v $(pwd)/data:/home/jovyan/data \
-e JUPYTER_ENABLE_LAB=yes \
sklearn-joan-garcia
# Per veure el token d'accés (en altra terminal)
docker logs sklearn-joan-garcia 2>&1 | grep token
Accés a Jupyter Lab
Un cop arrencat el contenidor, obre el navegador a http://localhost:8888. Copia el token que apareix als logs i entra-hi. Crea un nou notebook Python 3 amb el nom churn_joan_garcia.ipynb.
Windows i els paths
A Windows, substitueix $(pwd) per la ruta absoluta del directori, per exemple: -v C:/Users/joan/sklearn-joan-garcia/notebooks:/home/jovyan/work
Part 2: Preparació del dataset i EDA
2.1 Configuració inicial del notebook
# =============================================================
# Pràctica PR5072/01 - Pipeline ML amb scikit-learn
# Alumne: Joan Garcia
# Institut Sa Palomera - Curs IABD 2026-2027
# Mòdul: 5072 Sistemes d'Aprenentatge Automàtic
# =============================================================
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import warnings
warnings.filterwarnings('ignore')
# Metadades de l'alumne (modifica aquests valors)
ALUMNE = "Joan Garcia"
ALUMNE_ID = "joan_garcia"
DATASET = "Telco Customer Churn"
MODUL = "5072"
print(f"=" * 60)
print(f"Pràctica PR5072/01 - Pipeline ML amb scikit-learn")
print(f"Alumne: {ALUMNE}")
print(f"Dataset: {DATASET}")
print(f"=" * 60)
# Configuració estètica
plt.style.use('seaborn-v0_8-darkgrid')
sns.set_palette("husl")
plt.rcParams['figure.figsize'] = (10, 6)
plt.rcParams['font.size'] = 12
2.2 Càrrega i inspecció inicial
# Càrrega del dataset
# Si treballes dins Docker, el CSV ha d'estar a /home/jovyan/data/
df = pd.read_csv('/home/jovyan/data/telco_churn.csv')
print(f"\n--- INSPECCIÓ INICIAL ---")
print(f"Shape: {df.shape}")
print(f"Columnes: {list(df.columns)}")
print(f"\nPrimeres files:")
display(df.head())
print(f"\nTipus de dades:")
display(df.dtypes)
print(f"\nEstadístiques descriptives (numèriques):")
display(df.describe())
2.3 Neteja de dades
# TotalCharges ve com a string perque te espais buits
# Convertim a numeric i gestionem els NaN
df['TotalCharges'] = pd.to_numeric(df['TotalCharges'], errors='coerce')
print(f"Valors nuls per columna:")
nuls = df.isnull().sum()
print(nuls[nuls > 0])
# Eliminem customerID (no te valor predictiu)
df = df.drop('customerID', axis=1)
# Convertim la target a binari
df['Churn'] = (df['Churn'] == 'Yes').astype(int)
print(f"\nShape despres de neteja: {df.shape}")
print(f"Distribucio target:")
print(df['Churn'].value_counts(normalize=True).round(3) * 100)
2.4 Visualitzacions EDA
fig, axes = plt.subplots(2, 3, figsize=(18, 10))
fig.suptitle(f'EDA - {DATASET} | Alumne: {ALUMNE}', fontsize=14, fontweight='bold')
# 1. Distribucio de la target
ax = axes[0, 0]
churn_counts = df['Churn'].value_counts()
ax.bar(['No Churn', 'Churn'], churn_counts.values, color=['#2ecc71', '#e74c3c'])
ax.set_title('Distribucio de Churn')
ax.set_ylabel('Nombre de clients')
for i, v in enumerate(churn_counts.values):
ax.text(i, v + 50, f'{v}\n({v/len(df)*100:.1f}%)', ha='center')
# 2. Churn per tipus de contracte
ax = axes[0, 1]
contract_churn = df.groupby('Contract')['Churn'].mean() * 100
contract_churn.plot(kind='bar', ax=ax, color='#3498db', rot=0)
ax.set_title('Taxa de Churn per Tipus de Contracte')
ax.set_ylabel('Taxa de Churn (%)')
# 3. Distribucio de tenure
ax = axes[0, 2]
ax.hist(df[df['Churn']==0]['tenure'], alpha=0.5, label='No Churn', bins=30, color='#2ecc71')
ax.hist(df[df['Churn']==1]['tenure'], alpha=0.5, label='Churn', bins=30, color='#e74c3c')
ax.set_title('Distribucio de Temps com a Client')
ax.set_xlabel('Mesos com a client')
ax.legend()
# 4. MonthlyCharges vs Churn
ax = axes[1, 0]
df.boxplot(column='MonthlyCharges', by='Churn', ax=ax,
patch_artist=True,
boxprops=dict(facecolor='#3498db', alpha=0.7))
ax.set_title('Quota Mensual vs Churn')
ax.set_xlabel('Churn (0=No, 1=Si)')
# 5. Churn per metode de pagament
ax = axes[1, 1]
payment_churn = df.groupby('PaymentMethod')['Churn'].mean() * 100
payment_churn.sort_values(ascending=True).plot(kind='barh', ax=ax, color='#9b59b6')
ax.set_title('Taxa de Churn per Metode de Pagament')
ax.set_xlabel('Taxa de Churn (%)')
# 6. Correlacio (numeriques)
ax = axes[1, 2]
num_cols = df.select_dtypes(include=[np.number]).columns
corr_matrix = df[num_cols].corr()
sns.heatmap(corr_matrix, annot=True, fmt='.2f', ax=ax, cmap='coolwarm',
center=0, square=True)
ax.set_title('Matriu de Correlacio')
plt.tight_layout()
plt.savefig(f'eda_{ALUMNE_ID}.png', dpi=150, bbox_inches='tight')
plt.show()
print(f"Grafic guardat com: eda_{ALUMNE_ID}.png")
Miniactivitat
Abans de continuar, respon per escrit al notebook:
- Quin percentatge de clients han fet churn? El dataset esta balancejat?
- Quin tipus de contracte te la taxa de churn mes alta? Per que creus que es aixi?
- Hi ha alguna variable numerica molt correlacionada amb Churn?
Part 3: Pipeline de preprocés amb scikit-learn
3.1 Separacio de features i target
from sklearn.model_selection import train_test_split
# Separar X i y
X = df.drop('Churn', axis=1)
y = df['Churn']
# Identificar columnes per tipus
columnes_num = X.select_dtypes(include=[np.number]).columns.tolist()
columnes_cat = X.select_dtypes(include=['object']).columns.tolist()
print(f"Features numeriques ({len(columnes_num)}): {columnes_num}")
print(f"Features categoriques ({len(columnes_cat)}): {columnes_cat}")
print(f"\nDistribucio de la target:")
print(f" No Churn: {(y==0).sum()} ({(y==0).mean()*100:.1f}%)")
print(f" Churn: {(y==1).sum()} ({(y==1).mean()*100:.1f}%)")
# Divisio train/test (estratificada per preservar proporcions)
X_train, X_test, y_train, y_test = train_test_split(
X, y,
test_size=0.2,
random_state=42,
stratify=y
)
print(f"\nMides dels conjunts:")
print(f" Train: {X_train.shape[0]} registres")
print(f" Test: {X_test.shape[0]} registres")
3.2 Construccio del preprocessor
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.impute import SimpleImputer
# Pipeline per a features numeriques
pipeline_numeric = Pipeline(steps=[
('imputer', SimpleImputer(strategy='median')), # Imputa amb la mediana
('scaler', StandardScaler()) # Normalitza (mu=0, sigma=1)
])
# Pipeline per a features categoriques
pipeline_categoric = Pipeline(steps=[
('imputer', SimpleImputer(strategy='most_frequent')), # Imputa amb la moda
('encoder', OneHotEncoder(
handle_unknown='ignore', # Ignora categories desconegudes al test
sparse_output=False # Retorna array dens
))
])
# ColumnTransformer: aplica cada pipeline a les seves columnes
preprocessor = ColumnTransformer(
transformers=[
('num', pipeline_numeric, columnes_num),
('cat', pipeline_categoric, columnes_cat)
],
remainder='drop' # Elimina columnes no especificades
)
print("Preprocessor configurat correctament:")
print(preprocessor)
Per que StandardScaler?
La regressio logistica i els SVM son sensibles a l'escala de les features. Sense normalitzar, una variable com TotalCharges (rang 0-8.000) dominaria sobre SeniorCitizen (rang 0-1). El StandardScaler centra cada variable en zero i la escala a varianca unitat, garantint que totes les features contribueixin equitativament.
Part 4: Entrenament i comparacio de 3 models
4.1 Definicio dels models
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from xgboost import XGBClassifier
from sklearn.model_selection import cross_val_score, StratifiedKFold
# Definicio dels 3 pipelines (preprocessor + model)
models = {
'Regressio Logistica': Pipeline([
('preprocessor', preprocessor),
('classifier', LogisticRegression(
max_iter=1000,
class_weight='balanced', # Compensar desbalanceig
random_state=42,
C=1.0
))
]),
'Random Forest': Pipeline([
('preprocessor', preprocessor),
('classifier', RandomForestClassifier(
n_estimators=200,
class_weight='balanced',
random_state=42,
n_jobs=-1
))
]),
'XGBoost': Pipeline([
('preprocessor', preprocessor),
('classifier', XGBClassifier(
n_estimators=200,
learning_rate=0.05,
max_depth=6,
scale_pos_weight=(y_train==0).sum() / (y_train==1).sum(),
random_state=42,
eval_metric='logloss',
verbosity=0
))
])
}
print(f"Models definits: {list(models.keys())}")
4.2 Cross-validation amb StratifiedKFold
from sklearn.metrics import roc_auc_score, f1_score, precision_score, recall_score
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
resultats_cv = {}
print(f"\n{'='*65}")
print(f"CROSS-VALIDATION (5-Fold Estratificat)")
print(f"{'='*65}")
for nom_model, pipeline in models.items():
print(f"\nEntrenant: {nom_model}...")
# AUC-ROC (metrica principal per a churn)
scores_auc = cross_val_score(
pipeline, X_train, y_train,
cv=cv, scoring='roc_auc', n_jobs=-1
)
# F1 (equilibri entre precision i recall)
scores_f1 = cross_val_score(
pipeline, X_train, y_train,
cv=cv, scoring='f1', n_jobs=-1
)
resultats_cv[nom_model] = {
'AUC-ROC': scores_auc,
'F1': scores_f1
}
print(f" AUC-ROC: {scores_auc.mean():.4f} (+/- {scores_auc.std():.4f})")
print(f" F1: {scores_f1.mean():.4f} (+/- {scores_f1.std():.4f})")
# Taula resum
print(f"\n{'='*65}")
print(f"RESUM COMPARATIU")
print(f"{'='*65}")
print(f"{'Model':<25} {'AUC-ROC':>10} {'F1':>10}")
print(f"{'-'*45}")
for nom, res in resultats_cv.items():
print(f"{nom:<25} {res['AUC-ROC'].mean():>10.4f} {res['F1'].mean():>10.4f}")
4.3 Visualitzacio comparativa
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
fig.suptitle(f'Comparativa de Models | Alumne: {ALUMNE}', fontsize=13)
metriques = ['AUC-ROC', 'F1']
colors = ['#3498db', '#e74c3c', '#2ecc71']
for idx, metrica in enumerate(metriques):
ax = axes[idx]
noms = list(resultats_cv.keys())
means = [resultats_cv[n][metrica].mean() for n in noms]
stds = [resultats_cv[n][metrica].std() for n in noms]
bars = ax.bar(noms, means, yerr=stds, capsize=8,
color=colors, alpha=0.8, edgecolor='white')
ax.set_title(f'{metrica} (5-fold CV)')
ax.set_ylabel(metrica)
ax.set_ylim(0.5, 1.0)
ax.tick_params(axis='x', rotation=15)
for bar, mean in zip(bars, means):
ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.01,
f'{mean:.4f}', ha='center', va='bottom', fontsize=10)
plt.tight_layout()
plt.savefig(f'comparativa_models_{ALUMNE_ID}.png', dpi=150, bbox_inches='tight')
plt.show()
Part 5: Avaluacio detallada del millor model
5.1 Entrenament final i prediccions
from sklearn.metrics import (
classification_report, roc_auc_score, roc_curve,
ConfusionMatrixDisplay, precision_recall_curve, average_precision_score
)
# Determina el millor model segons AUC-ROC
millor_nom = max(resultats_cv, key=lambda n: resultats_cv[n]['AUC-ROC'].mean())
print(f"Millor model: {millor_nom}")
# Entrenament sobre tot el conjunt de train
millor_pipeline = models[millor_nom]
millor_pipeline.fit(X_train, y_train)
# Prediccions sobre el conjunt de test (no vist durant el training)
y_pred = millor_pipeline.predict(X_test)
y_prob = millor_pipeline.predict_proba(X_test)[:, 1]
print(f"\n--- INFORME DE CLASSIFICACIO ---")
print(classification_report(y_test, y_pred, target_names=['No Churn', 'Churn']))
print(f"AUC-ROC final (test): {roc_auc_score(y_test, y_prob):.4f}")
5.2 Matriu de confusio i corba ROC
fig, axes = plt.subplots(1, 3, figsize=(18, 5))
fig.suptitle(f'Avaluacio Final - {millor_nom} | Alumne: {ALUMNE}', fontsize=13)
# Matriu de confusio
ConfusionMatrixDisplay.from_predictions(
y_test, y_pred,
display_labels=['No Churn', 'Churn'],
cmap='Blues', ax=axes[0]
)
axes[0].set_title('Matriu de Confusio')
# Corba ROC
fpr, tpr, _ = roc_curve(y_test, y_prob)
auc = roc_auc_score(y_test, y_prob)
axes[1].plot(fpr, tpr, color='#e74c3c', lw=2, label=f'AUC = {auc:.4f}')
axes[1].plot([0, 1], [0, 1], 'k--', lw=1)
axes[1].fill_between(fpr, tpr, alpha=0.1, color='#e74c3c')
axes[1].set_xlabel('False Positive Rate')
axes[1].set_ylabel('True Positive Rate')
axes[1].set_title('Corba ROC')
axes[1].legend()
# Corba Precision-Recall
precision, recall, _ = precision_recall_curve(y_test, y_prob)
ap = average_precision_score(y_test, y_prob)
axes[2].plot(recall, precision, color='#3498db', lw=2, label=f'AP = {ap:.4f}')
axes[2].fill_between(recall, precision, alpha=0.1, color='#3498db')
axes[2].set_xlabel('Recall')
axes[2].set_ylabel('Precision')
axes[2].set_title('Corba Precision-Recall')
axes[2].legend()
plt.tight_layout()
plt.savefig(f'avaluacio_final_{ALUMNE_ID}.png', dpi=150, bbox_inches='tight')
plt.show()
5.3 Importancia de les features
# Extreu el classificador i el nom de les features transformades
clf = millor_pipeline.named_steps['classifier']
prep = millor_pipeline.named_steps['preprocessor']
# Noms de les features despres de la transformacio
feature_names_num = columnes_num
feature_names_cat = prep.named_transformers_['cat']['encoder'].get_feature_names_out(columnes_cat)
feature_names = np.concatenate([feature_names_num, feature_names_cat])
# Importances (Random Forest i XGBoost)
if hasattr(clf, 'feature_importances_'):
importances = clf.feature_importances_
indices = np.argsort(importances)[::-1][:20] # Top 20
plt.figure(figsize=(12, 6))
plt.title(f'Top 20 Features Importants - {millor_nom} | {ALUMNE}')
plt.bar(range(20), importances[indices], color='#3498db', alpha=0.8)
plt.xticks(range(20), feature_names[indices], rotation=45, ha='right')
plt.ylabel('Importancia relativa')
plt.tight_layout()
plt.savefig(f'feature_importance_{ALUMNE_ID}.png', dpi=150, bbox_inches='tight')
plt.show()
else:
# Regressio Logistica: coeficients
coefs = pd.Series(clf.coef_[0], index=feature_names)
top_pos = coefs.nlargest(10)
top_neg = coefs.nsmallest(10)
top_coefs = pd.concat([top_pos, top_neg]).sort_values()
plt.figure(figsize=(12, 6))
top_coefs.plot(kind='barh', color=['#e74c3c' if c > 0 else '#2ecc71'
for c in top_coefs])
plt.title(f'Coeficients - Regressio Logistica | {ALUMNE}')
plt.xlabel('Coeficient (log-odds)')
plt.tight_layout()
plt.savefig(f'feature_importance_{ALUMNE_ID}.png', dpi=150, bbox_inches='tight')
plt.show()
Part 6: Optimitzacio d'hiperparàmetres amb Optuna
6.1 Funcio objectiu per a XGBoost
import optuna
from sklearn.model_selection import cross_val_score
optuna.logging.set_verbosity(optuna.logging.WARNING)
def objective(trial):
"""
Funcio objectiu d'Optuna per optimitzar XGBoost.
Retorna la mitjana AUC-ROC en 5-fold CV.
"""
# Espai de cerca d'hiperparametres
params = {
'n_estimators': trial.suggest_int('n_estimators', 50, 500),
'max_depth': trial.suggest_int('max_depth', 3, 10),
'learning_rate': trial.suggest_float('learning_rate', 0.01, 0.3, log=True),
'subsample': trial.suggest_float('subsample', 0.6, 1.0),
'colsample_bytree': trial.suggest_float('colsample_bytree', 0.6, 1.0),
'min_child_weight': trial.suggest_int('min_child_weight', 1, 10),
'gamma': trial.suggest_float('gamma', 0, 5),
'reg_alpha': trial.suggest_float('reg_alpha', 0, 1),
'reg_lambda': trial.suggest_float('reg_lambda', 0.5, 2.0),
}
pipeline_optuna = Pipeline([
('preprocessor', preprocessor),
('classifier', XGBClassifier(
**params,
scale_pos_weight=(y_train==0).sum() / (y_train==1).sum(),
random_state=42,
eval_metric='logloss',
verbosity=0
))
])
scores = cross_val_score(
pipeline_optuna, X_train, y_train,
cv=StratifiedKFold(n_splits=5, shuffle=True, random_state=42),
scoring='roc_auc',
n_jobs=-1
)
return scores.mean()
# Execucio de l'optimitzacio
print("Iniciant optimitzacio d'hiperparametres amb Optuna...")
print(f"Alumne: {ALUMNE} | Model: XGBoost | Trials: 50")
study = optuna.create_study(
direction='maximize',
study_name=f'churn_xgb_{ALUMNE_ID}',
sampler=optuna.samplers.TPESampler(seed=42)
)
study.optimize(objective, n_trials=50, show_progress_bar=True)
print(f"\nMillors hiperparametres trobats:")
for k, v in study.best_params.items():
print(f" {k}: {v}")
print(f"\nMillor AUC-ROC CV: {study.best_value:.4f}")
6.2 Entrenament del model optimitzat
# Entrena el model final amb els millors hiperparametres
pipeline_optim = Pipeline([
('preprocessor', preprocessor),
('classifier', XGBClassifier(
**study.best_params,
scale_pos_weight=(y_train==0).sum() / (y_train==1).sum(),
random_state=42,
eval_metric='logloss',
verbosity=0
))
])
pipeline_optim.fit(X_train, y_train)
y_pred_optim = pipeline_optim.predict(X_test)
y_prob_optim = pipeline_optim.predict_proba(X_test)[:, 1]
auc_optim = roc_auc_score(y_test, y_prob_optim)
print(f"\n--- MODEL OPTIMITZAT ---")
print(f"AUC-ROC (test): {auc_optim:.4f}")
print(f"Millora respecte model base: {(auc_optim - roc_auc_score(y_test, y_prob)):.4f}")
print(f"\n{classification_report(y_test, y_pred_optim, target_names=['No Churn', 'Churn'])}")
# Visualitzacio de la convergencia d'Optuna
import optuna.visualization as vis
try:
fig_hist = vis.plot_optimization_history(study)
fig_hist.show()
fig_import = vis.plot_param_importances(study)
fig_import.show()
except:
# Si plotly no esta disponible, fem un grafic matplotlib simple
plt.figure(figsize=(10, 4))
trials_vals = [t.value for t in study.trials]
best_so_far = np.maximum.accumulate(trials_vals)
plt.plot(trials_vals, alpha=0.4, label='Trial individual')
plt.plot(best_so_far, color='#e74c3c', lw=2, label='Millor fins ara')
plt.xlabel('Trial')
plt.ylabel('AUC-ROC')
plt.title(f'Convergencia Optuna - XGBoost | {ALUMNE}')
plt.legend()
plt.tight_layout()
plt.savefig(f'optuna_convergencia_{ALUMNE_ID}.png', dpi=150)
plt.show()
Part 7: Exportació del model final
7.1 Serialitzacio amb joblib
import joblib
import json
from datetime import datetime
# Determina el millor pipeline final (base vs optimitzat)
if auc_optim > roc_auc_score(y_test, y_prob):
pipeline_final = pipeline_optim
nom_model_final = 'XGBoost-Optimitzat'
else:
pipeline_final = millor_pipeline
nom_model_final = millor_nom
# Nom del fitxer amb el nom de l'alumne
nom_fitxer = f'model_churn_{ALUMNE_ID}.pkl'
# Exporta el pipeline complet (preprocessor + model)
joblib.dump(pipeline_final, nom_fitxer)
print(f"Model exportat: {nom_fitxer}")
print(f"Mida del fitxer: {__import__('os').path.getsize(nom_fitxer) / 1024:.1f} KB")
# Metadata del model
metadata = {
'alumne': ALUMNE,
'alumne_id': ALUMNE_ID,
'data_entrenament': datetime.now().isoformat(),
'model': nom_model_final,
'dataset': DATASET,
'registres_train': len(X_train),
'registres_test': len(X_test),
'auc_roc_test': round(float(roc_auc_score(y_test, pipeline_final.predict_proba(X_test)[:, 1])), 4),
'features': list(X.columns),
'modul': MODUL,
'practica': 'PR5072/01'
}
with open(f'metadata_model_{ALUMNE_ID}.json', 'w', encoding='utf-8') as f:
json.dump(metadata, f, indent=2, ensure_ascii=False)
print(f"\nMetadata guardada: metadata_model_{ALUMNE_ID}.json")
print(json.dumps(metadata, indent=2, ensure_ascii=False))
7.2 Verificacio de la carrega
# Carrega i verifica que el model funciona correctament
model_carregat = joblib.load(nom_fitxer)
# Prova amb 5 exemples del test
exemples = X_test.iloc[:5].copy()
prediccions = model_carregat.predict(exemples)
probabilitats = model_carregat.predict_proba(exemples)[:, 1]
print("Verificacio del model carregat (5 exemples):")
print(f"{'Exemple':<10} {'Prediccio':<12} {'Prob. Churn':<15} {'Real':<10}")
print("-" * 47)
for i, (pred, prob, real) in enumerate(zip(prediccions, probabilitats, y_test.iloc[:5])):
etiqueta_pred = 'CHURN' if pred == 1 else 'No Churn'
etiqueta_real = 'CHURN' if real == 1 else 'No Churn'
ok = "OK" if pred == real else "ERROR"
print(f"{i+1:<10} {etiqueta_pred:<12} {prob:>10.2%} {etiqueta_real:<10} {ok}")
Part 8: Informe de conclusions
8.1 Resum executiu
Redacta al notebook una cel·la de tipus Markdown amb el següent contingut:
## Informe de Conclusions - Practica PR5072/01
**Alumne:** Joan Garcia | **Data:** [data actual]
### Resum executiu
S'ha construït un pipeline complet de Machine Learning supervisat per a la predicció de churn de clients de telecomunicacions. El model final (XGBoost optimitzat) aconsegueix un AUC-ROC de X.XXXX en el conjunt de test, superant la línia base de la regressió logística en X punts percentuals.
### Metodologia aplicada
1. **EDA:** Anàlisi de 7.043 registres amb 20 features. Distribució desequilibrada (73% no churn / 27% churn). Variables clau identificades: tenure, Contract, MonthlyCharges.
2. **Preprocés:** Pipeline ColumnTransformer amb imputació + StandardScaler per numeriques i imputació + OneHotEncoder per categòriques.
3. **Modelatge:** Comparació de 3 algoritmes amb 5-fold stratified CV. XGBoost millor AUC-ROC.
4. **Optimització:** 50 trials d'Optuna milloren el model base en X punts percentuals.
5. **Interpretabilitat:** Les features mes importants son: tenure, Contract, MonthlyCharges, TechSupport.
### Resultats comparatius
| Model | AUC-ROC CV | F1 CV |
|-------|-----------|-------|
| Regressio Logistica | X.XXXX | X.XXXX |
| Random Forest | X.XXXX | X.XXXX |
| XGBoost | X.XXXX | X.XXXX |
| XGBoost Optuna | X.XXXX | X.XXXX |
### Recomanacions de negoci
- Clients amb contractes mes de 24 mesos son molt estables: prioritzar conversio a contracte anual/bianual.
- Clients amb quota mensual > 70 EUR i fibra optima son el segment de risc principal.
- Recomanem desplegar el model en producció amb un umbral de decisio de 0.35 (maximitza recall) per prioritzar la captació preventiva.
Preguntes de reflexió
Reflexio i aprofundiment
Respon per escrit (com a cel·les Markdown al notebook) les seguents preguntes:
Tecniques:
-
Per quin motiu XGBoost supera la regressio logistica en aquest dataset? Explica les diferencies en termes de capacitat de modelatge.
-
El dataset te un desbalanceig (73% vs 27%). Com has gestionat aquest problema? Quines alternatives hi hauria?
-
Que significa un AUC-ROC de 0.85? Com l'interpretaries davant el director de marketing de l'empresa?
-
En el pipeline de sklearn, per que apliquem el StandardScaler despres de la imputacio i no abans?
Negoci i etica:
-
Com detectaries si el model te data drift en produccio? Quins senyals d'alerta monitoritzaries?
-
Quins biaixos podria tenir aquest model? Podria discriminar clients per raons no etiques (edat, zona geografica, idioma)?
-
Si el model prediu que un client fara churn, quina accio preventiva proposaries? Quins riscos te actuar sobre falsos positius?
Lliurament
Puja els seguents fitxers al Campus Virtual abans de la data llindar:
| Fitxer | Descripció |
|---|---|
churn_joan_garcia.ipynb |
Notebook Jupyter complet amb totes les parts |
model_churn_joan_garcia.pkl |
Model final exportat |
metadata_model_joan_garcia.json |
Metadata del model |
informe_churn_joan_garcia.pdf |
Informe de conclusions en PDF (exporta el notebook) |
Requisits minims per ser avaluat
- El notebook ha d'executar-se de dalt a baix sense errors.
- El nom de l'alumne ha d'apareixer a la variable
ALUMNEi al nom de tots els fitxers. - L'AUC-ROC del model final ha de ser superior a 0.78.
- Les 7 preguntes de reflexio han d'estar resposes.
Consell per al lliurament
Abans de lliurar, reinicia el kernel i executa el notebook de dalt a baix (Kernel > Restart & Run All). Aixo garanteix que el notebook es reproduible i no te dependencies ocultes d'ordre d'execucio.
Pràctica PR5072/01 | Mòdul 5072 Sistemes d'Aprenentatge Automàtic | Institut Sa Palomera (Blanes) | Curs IABD 2026-2027