Objetivo

Dominar o Pipeline do Scikit-learn para encapsular pré-processamento e modelagem de forma segura e reproducível, aprender busca de hiperparâmetros com GridSearchCV e RandomizedSearchCV, e construir um projeto completo de ML do início ao fim integrando tudo que foi visto na Fase 3.


1. Por que Pipelines são Essenciais?

Sem Pipeline, o código de ML tem problemas sérios:

  • Data leakage: normalização feita antes do CV contamina a validação
  • Código frágil: pré-processamento manual duplicado em treino e produção
  • Bugs silenciosos: esquecer de aplicar o scaler no teste é um erro comum
  • Deploy difícil: em produção você precisa reproduzir exatamente o mesmo pré-processamento

Com Pipeline:

  • Todo o pré-processamento está encapsulado e aplicado corretamente
  • Cross-validation é automaticamente correto
  • Deploy é simples: salvar o pipeline salva tudo junto
  • Código mais limpo, legível e manutenível

2. Pipeline Básico

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.pipeline import Pipeline, make_pipeline
from sklearn.preprocessing import (StandardScaler, MinMaxScaler,
                                    OneHotEncoder, LabelEncoder,
                                    PolynomialFeatures)
from sklearn.compose import ColumnTransformer, make_column_transformer
from sklearn.impute import SimpleImputer
from sklearn.model_selection import (train_test_split, GridSearchCV,
                                      RandomizedSearchCV, cross_val_score,
                                      StratifiedKFold)
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.tree import DecisionTreeClassifier
from sklearn.metrics import (accuracy_score, f1_score, roc_auc_score,
                              classification_report, ConfusionMatrixDisplay)
from sklearn.feature_selection import SelectKBest, f_classif
import joblib
import warnings
warnings.filterwarnings("ignore")

np.random.seed(42)
sns.set_theme(style="whitegrid")

# Dataset simples para demonstrar o conceito
from sklearn.datasets import make_classification
X_demo, y_demo = make_classification(
    n_samples=500, n_features=10,
    n_informative=6, n_redundant=2,
    random_state=42
)

X_tr_d, X_te_d, y_tr_d, y_te_d = train_test_split(
    X_demo, y_demo, test_size=0.2,
    random_state=42, stratify=y_demo
)

# ── Sem Pipeline (modo errado) ──────────────────────────
print("Sem Pipeline (modo errado — tem leakage):")
scaler_errado = StandardScaler()
X_tr_wrong = scaler_errado.fit_transform(X_tr_d)
X_te_wrong = scaler_errado.transform(X_te_d)

modelo_errado = LogisticRegression(max_iter=1000, random_state=42)
modelo_errado.fit(X_tr_wrong, y_tr_d)
print(f"  Acurácia: {accuracy_score(y_te_d, modelo_errado.predict(X_te_wrong)):.4f}")

# ── Com Pipeline (modo correto) ─────────────────────────
print("\nCom Pipeline (modo correto — sem leakage):")
pipeline_correto = Pipeline([
    ("scaler",  StandardScaler()),
    ("modelo",  LogisticRegression(max_iter=1000, random_state=42))
])

pipeline_correto.fit(X_tr_d, y_tr_d)
print(f"  Acurácia: {accuracy_score(y_te_d, pipeline_correto.predict(X_te_d)):.4f}")

# Usando make_pipeline (mais conciso, nomeia automaticamente)
pipe2 = make_pipeline(
    StandardScaler(),
    LogisticRegression(max_iter=1000, random_state=42)
)
pipe2.fit(X_tr_d, y_tr_d)
print(f"  Acurácia (make_pipeline): {accuracy_score(y_te_d, pipe2.predict(X_te_d)):.4f}")

# Inspecionando o pipeline
print(f"\nEtapas do pipeline:")
for nome, etapa in pipeline_correto.named_steps.items():
    print(f"  {nome}: {type(etapa).__name__}")

3. Pipeline com Múltiplos Passos

# Pipeline com feature engineering + seleção + modelo
pipeline_completo = Pipeline([
    ("imputer",   SimpleImputer(strategy="mean")),       # 1. tratar ausentes
    ("poly",      PolynomialFeatures(degree=2,
                                      include_bias=False,
                                      interaction_only=True)),  # 2. features
    ("scaler",    StandardScaler()),                     # 3. normalizar
    ("selector",  SelectKBest(f_classif, k=15)),         # 4. selecionar features
    ("modelo",    LogisticRegression(max_iter=1000,
                                      random_state=42))  # 5. modelo
])

pipeline_completo.fit(X_tr_d, y_tr_d)
y_pred_cp = pipeline_completo.predict(X_te_d)
print("Pipeline com múltiplos passos:")
print(f"  Features originais: {X_tr_d.shape[1]}")
print(f"  Após PolynomialFeatures: "
      f"{pipeline_completo['poly'].transform(X_tr_d).shape[1]}")
print(f"  Após SelectKBest(k=15): 15")
print(f"  Acurácia final: {accuracy_score(y_te_d, y_pred_cp):.4f}")

# Cross-validation correto com pipeline
scores_cv = cross_val_score(
    pipeline_completo, X_tr_d, y_tr_d,
    cv=5, scoring="f1", n_jobs=-1
)
print(f"  CV F1: {scores_cv.mean():.4f} ± {scores_cv.std():.4f}")

4. ColumnTransformer — Pré-processamento por Tipo de Feature

Em projetos reais, você tem features numéricas e categóricas que precisam de tratamentos diferentes. ColumnTransformer resolve isso elegantemente.

# Dataset misto: numérico + categórico
np.random.seed(42)
n = 800

df_misto = pd.DataFrame({
    "idade":          np.random.randint(18, 70, n),
    "renda":          np.random.uniform(1000, 20000, n).round(2),
    "anos_emprego":   np.random.randint(0, 40, n),
    "divida":         np.random.uniform(0, 15000, n).round(2),
    "educacao":       np.random.choice(["fundamental", "medio",
                                         "superior", "pos"], n),
    "estado":         np.random.choice(["SP", "RJ", "MG",
                                         "RS", "BA"], n),
    "tem_imovel":     np.random.randint(0, 2, n),
})

# Inserir ausentes
for col, prop in [("renda", 0.05), ("anos_emprego", 0.08),
                   ("educacao", 0.06)]:
    idx = np.random.choice(n, int(prop * n), replace=False)
    df_misto.loc[idx, col] = np.nan

# Target
score = (df_misto["renda"].fillna(5000) / 2000
         - df_misto["divida"] / 3000
         + df_misto["tem_imovel"] * 1.5)
df_misto["aprovado"] = (score > score.median()).astype(int)

print(f"Dataset misto: {df_misto.shape}")
print(f"Aprovados: {df_misto['aprovado'].mean():.1%}")
print(f"\nAusentes:\n{df_misto.isna().sum()}")

# Separar features e target
X_m = df_misto.drop(columns=["aprovado"])
y_m = df_misto["aprovado"].values

X_tr_m, X_te_m, y_tr_m, y_te_m = train_test_split(
    X_m, y_m, test_size=0.2,
    random_state=42, stratify=y_m
)

# Definir colunas por tipo
colunas_num = ["idade", "renda", "anos_emprego", "divida"]
colunas_cat = ["educacao", "estado"]
colunas_bin = ["tem_imovel"]

# Transformadores por tipo
transformador_num = Pipeline([
    ("imputer", SimpleImputer(strategy="median")),
    ("scaler",  StandardScaler())
])

transformador_cat = Pipeline([
    ("imputer",  SimpleImputer(strategy="most_frequent")),
    ("encoder",  OneHotEncoder(handle_unknown="ignore",
                                sparse_output=False))
])

# ColumnTransformer combina tudo
preprocessador = ColumnTransformer([
    ("num", transformador_num, colunas_num),
    ("cat", transformador_cat, colunas_cat),
    ("bin", "passthrough",     colunas_bin),  # sem transformação
])

# Pipeline completo com ColumnTransformer
pipeline_misto = Pipeline([
    ("preprocessador", preprocessador),
    ("modelo", RandomForestClassifier(
        n_estimators=100, random_state=42, n_jobs=-1
    ))
])

pipeline_misto.fit(X_tr_m, y_tr_m)
y_pred_m = pipeline_misto.predict(X_te_m)

print(f"\nPipeline com dados mistos:")
print(f"  Acurácia: {accuracy_score(y_te_m, y_pred_m):.4f}")
print(f"  F1-Score: {f1_score(y_te_m, y_pred_m):.4f}")
print(f"  AUC-ROC:  {roc_auc_score(y_te_m, pipeline_misto.predict_proba(X_te_m)[:, 1]):.4f}")

# CV correto com dados mistos
scores_m = cross_val_score(
    pipeline_misto, X_m, y_m,
    cv=5, scoring="roc_auc", n_jobs=-1
)
print(f"  CV AUC:   {scores_m.mean():.4f} ± {scores_m.std():.4f}")

5. GridSearchCV — Busca Exaustiva de Hiperparâmetros

GridSearchCV testa todas as combinações de hiperparâmetros especificadas.

# Sintaxe para referenciar hiperparâmetros no pipeline:
# "nome_etapa__hiperparametro"

param_grid = {
    "preprocessador__num__imputer__strategy": ["mean", "median"],
    "modelo__n_estimators":  [50, 100, 200],
    "modelo__max_depth":     [None, 5, 10],
    "modelo__min_samples_leaf": [1, 5, 10],
}

total_combinacoes = (
    len(param_grid["preprocessador__num__imputer__strategy"]) *
    len(param_grid["modelo__n_estimators"]) *
    len(param_grid["modelo__max_depth"]) *
    len(param_grid["modelo__min_samples_leaf"])
)
print(f"GridSearch: {total_combinacoes} combinações × 5 folds "
      f"= {total_combinacoes * 5} fits")

grid_search = GridSearchCV(
    pipeline_misto,
    param_grid,
    cv=StratifiedKFold(n_splits=5, shuffle=True, random_state=42),
    scoring="roc_auc",
    n_jobs=-1,
    verbose=1,
    refit=True    # re-treina com melhores parâmetros em todo X_train
)

print("\nExecutando GridSearchCV...")
grid_search.fit(X_tr_m, y_tr_m)

print(f"\nMelhores hiperparâmetros:")
for param, valor in grid_search.best_params_.items():
    print(f"  {param}: {valor}")

print(f"\nMelhor CV AUC: {grid_search.best_score_:.4f}")

# Avaliar no teste com o melhor modelo
y_pred_gs = grid_search.predict(X_te_m)
y_proba_gs = grid_search.predict_proba(X_te_m)[:, 1]
print(f"AUC no teste: {roc_auc_score(y_te_m, y_proba_gs):.4f}")
print(f"F1 no teste:  {f1_score(y_te_m, y_pred_gs):.4f}")

# Analisando resultados do grid
df_grid = pd.DataFrame(grid_search.cv_results_)
df_grid_top = (df_grid
               .sort_values("mean_test_score", ascending=False)
               .head(10)[["param_modelo__n_estimators",
                           "param_modelo__max_depth",
                           "param_modelo__min_samples_leaf",
                           "mean_test_score",
                           "std_test_score"]])
print(f"\nTop 10 combinações:")
print(df_grid_top.round(4).to_string(index=False))

6. RandomizedSearchCV — Busca Aleatória (Mais Eficiente)

Para espaços de hiperparâmetros grandes, GridSearch é proibitivo. RandomizedSearch amostra aleatoriamente e frequentemente encontra configurações quase tão boas com muito menos tempo.

from scipy.stats import randint, uniform, loguniform

# Distribuições de amostragem para cada hiperparâmetro
param_dist = {
    "modelo__n_estimators":      randint(50, 500),
    "modelo__max_depth":         [None, 3, 5, 7, 10, 15, 20],
    "modelo__min_samples_split": randint(2, 30),
    "modelo__min_samples_leaf":  randint(1, 20),
    "modelo__max_features":      ["sqrt", "log2", 0.5, 0.7],
    "modelo__bootstrap":         [True, False],
}

random_search = RandomizedSearchCV(
    pipeline_misto,
    param_distributions=param_dist,
    n_iter=50,          # número de combinações a testar
    cv=StratifiedKFold(n_splits=5, shuffle=True, random_state=42),
    scoring="roc_auc",
    n_jobs=-1,
    verbose=0,
    random_state=42,
    refit=True
)

print("Executando RandomizedSearchCV (50 iterações)...")
random_search.fit(X_tr_m, y_tr_m)

print(f"\nMelhores hiperparâmetros (RandomizedSearch):")
for param, valor in random_search.best_params_.items():
    print(f"  {param}: {valor}")

print(f"\nMelhor CV AUC: {random_search.best_score_:.4f}")
y_pred_rs = random_search.predict(X_te_m)
y_proba_rs = random_search.predict_proba(X_te_m)[:, 1]
print(f"AUC no teste: {roc_auc_score(y_te_m, y_proba_rs):.4f}")

# Comparando Grid vs Random
print("\nComparação Grid vs Random Search:")
print(f"  GridSearch   — CV AUC: {grid_search.best_score_:.4f}, "
      f"Test AUC: {roc_auc_score(y_te_m, y_proba_gs):.4f}")
print(f"  RandomSearch — CV AUC: {random_search.best_score_:.4f}, "
      f"Test AUC: {roc_auc_score(y_te_m, y_proba_rs):.4f}")

7. Salvando e Carregando Pipelines

import joblib
import os

# Salvar o melhor pipeline
melhor_pipeline = random_search.best_estimator_

joblib.dump(melhor_pipeline, "modelo_credito.pkl")
print("Pipeline salvo em 'modelo_credito.pkl'")
print(f"Tamanho do arquivo: {os.path.getsize('modelo_credito.pkl') / 1024:.1f} KB")

# Carregar e usar
pipeline_carregado = joblib.load("modelo_credito.pkl")

# Predição com novo dado (DataFrame com os mesmos campos)
novo_cliente = pd.DataFrame({
    "idade":        [35],
    "renda":        [8500.0],
    "anos_emprego": [8],
    "divida":       [2000.0],
    "educacao":     ["superior"],
    "estado":       ["SP"],
    "tem_imovel":   [1]
})

prob_aprovacao = pipeline_carregado.predict_proba(novo_cliente)[0][1]
decisao = "APROVADO" if prob_aprovacao >= 0.5 else "REPROVADO"

print(f"\nPredição para novo cliente:")
print(f"  Probabilidade de aprovação: {prob_aprovacao:.1%}")
print(f"  Decisão: {decisao}")

# Verificar que o resultado é idêntico ao original
resultado_original = melhor_pipeline.predict_proba(novo_cliente)[0][1]
resultado_carregado = pipeline_carregado.predict_proba(novo_cliente)[0][1]
print(f"\n  Original:  {resultado_original:.6f}")
print(f"  Carregado: {resultado_carregado:.6f}")
print(f"  Idênticos: {np.isclose(resultado_original, resultado_carregado)}")

8. Projeto Completo de ML — Do Início ao Fim

Agora vamos integrar tudo em um projeto realista completo: previsão de churn em uma empresa de telecomunicações.

print("=" * 65)
print("PROJETO COMPLETO DE ML")
print("Previsão de Churn — Empresa de Telecomunicações")
print("=" * 65)

# ══════════════════════════════════════════════════════════════
# FASE 1: GERAÇÃO E EXPLORAÇÃO DOS DADOS
# ══════════════════════════════════════════════════════════════
print("\n" + "─" * 65)
print("FASE 1: EXPLORAÇÃO DOS DADOS")
print("─" * 65)

np.random.seed(42)
N = 2000

df_telecom = pd.DataFrame({
    # Features demográficas
    "idade":              np.random.randint(18, 75, N),
    "genero":             np.random.choice(["M", "F"], N),

    # Features do contrato
    "tipo_contrato":      np.random.choice(
                            ["mensal", "anual", "bienal"],
                            N, p=[0.55, 0.30, 0.15]
                          ),
    "meses_contrato":     np.random.randint(1, 73, N),
    "valor_mensal":       np.random.uniform(25, 120, N).round(2),
    "total_gasto":        np.zeros(N),   # calculado abaixo

    # Features de uso
    "chamadas_mes":       np.random.randint(0, 60, N),
    "dados_gb":           np.random.uniform(0, 50, N).round(1),
    "sms_mes":            np.random.randint(0, 200, N),

    # Features de suporte
    "num_reclamacoes":    np.random.randint(0, 8, N),
    "tickets_abertos":    np.random.randint(0, 5, N),

    # Features de produto
    "tem_tv":             np.random.randint(0, 2, N),
    "tem_streaming":      np.random.randint(0, 2, N),
    "tem_seguro":         np.random.randint(0, 2, N),
    "num_linhas":         np.random.randint(1, 5, N),
})

# Feature calculada
df_telecom["total_gasto"] = (df_telecom["valor_mensal"] *
                              df_telecom["meses_contrato"] *
                              np.random.uniform(0.85, 1.15, N)).round(2)

# Regra de churn (mais realista)
score_churn = (
    -0.04  * df_telecom["meses_contrato"]           # mais tempo = menos churn
    + 0.6  * df_telecom["num_reclamacoes"]           # reclamações aumentam churn
    - 1.5  * (df_telecom["tipo_contrato"] == "bienal")
    - 0.8  * (df_telecom["tipo_contrato"] == "anual")
    + 0.01 * df_telecom["valor_mensal"]              # valor alto = mais churn
    - 0.3  * df_telecom["tem_seguro"]               # seguro retém cliente
    - 0.2  * df_telecom["tem_tv"]
    + 0.5  * df_telecom["tickets_abertos"]
    + np.random.normal(0, 0.5, N)
)

prob_churn = 1 / (1 + np.exp(-score_churn + 1))
df_telecom["churn"] = (np.random.random(N) < prob_churn).astype(int)

# Inserir ausentes realistas
for col, prop in [("dados_gb", 0.04), ("sms_mes", 0.03),
                   ("num_reclamacoes", 0.05)]:
    idx = np.random.choice(N, int(prop * N), replace=False)
    df_telecom.loc[idx, col] = np.nan

print(f"Shape: {df_telecom.shape}")
print(f"Taxa de churn: {df_telecom['churn'].mean():.1%}")
print(f"\nDistribuição de contrato:")
print(df_telecom["tipo_contrato"].value_counts(normalize=True).round(3))
print(f"\nAusentes:")
print(df_telecom.isna().sum()[df_telecom.isna().sum() > 0])

# ── EDA rápida ──────────────────────────────────────────────
fig, axes = plt.subplots(2, 3, figsize=(16, 10))

# Distribuição do target
axes[0, 0].pie(df_telecom["churn"].value_counts(),
               labels=["Ficou", "Cancelou"],
               colors=["steelblue", "coral"],
               autopct="%1.1f%%",
               wedgeprops=dict(edgecolor="white"))
axes[0, 0].set_title("Distribuição de Churn")

# Churn por tipo de contrato
churn_contrato = (df_telecom.groupby("tipo_contrato")["churn"]
                              .mean() * 100)
axes[0, 1].bar(churn_contrato.index, churn_contrato.values,
                color=["steelblue", "coral", "mediumseagreen"],
                alpha=0.8)
axes[0, 1].set_title("Taxa de Churn por Tipo de Contrato")
axes[0, 1].set_ylabel("Taxa de Churn (%)")

# Distribuição de meses de contrato
axes[0, 2].hist(
    df_telecom[df_telecom["churn"] == 0]["meses_contrato"],
    bins=25, alpha=0.6, color="steelblue", label="Ficou"
)
axes[0, 2].hist(
    df_telecom[df_telecom["churn"] == 1]["meses_contrato"],
    bins=25, alpha=0.6, color="coral", label="Cancelou"
)
axes[0, 2].set_title("Meses de Contrato por Churn")
axes[0, 2].legend()

# Reclamações vs Churn
churn_rec = df_telecom.groupby("num_reclamacoes")["churn"].mean() * 100
axes[1, 0].plot(churn_rec.index, churn_rec.values,
                 marker="o", color="coral", linewidth=2)
axes[1, 0].set_title("Taxa de Churn por Reclamações")
axes[1, 0].set_xlabel("Número de Reclamações")
axes[1, 0].set_ylabel("Taxa de Churn (%)")

# Valor mensal por churn
axes[1, 1].boxplot(
    [df_telecom[df_telecom["churn"] == 0]["valor_mensal"],
     df_telecom[df_telecom["churn"] == 1]["valor_mensal"]],
    labels=["Ficou", "Cancelou"],
    patch_artist=True
)
axes[1, 1].set_title("Valor Mensal por Churn")
axes[1, 1].set_ylabel("Valor Mensal (R$)")

# Correlação com target
colunas_num_eda = ["meses_contrato", "valor_mensal", "num_reclamacoes",
                    "tickets_abertos", "dados_gb", "chamadas_mes"]
corr_target = df_telecom[colunas_num_eda + ["churn"]].corr()["churn"]
corr_target = corr_target.drop("churn").sort_values()
cores_corr  = ["coral" if v > 0 else "steelblue" for v in corr_target]
axes[1, 2].barh(corr_target.index, corr_target.values,
                 color=cores_corr, alpha=0.8)
axes[1, 2].axvline(x=0, color="black", linewidth=0.8)
axes[1, 2].set_title("Correlação com Churn")
axes[1, 2].set_xlabel("Correlação de Pearson")

plt.suptitle("EDA — Dataset de Churn Telecom", fontsize=15)
plt.tight_layout()
plt.show()

# ══════════════════════════════════════════════════════════════
# FASE 2: PREPARAÇÃO DOS DADOS
# ══════════════════════════════════════════════════════════════
print("\n" + "─" * 65)
print("FASE 2: PREPARAÇÃO DOS DADOS")
print("─" * 65)

# Feature engineering
df_telecom["gasto_por_mes"] = (df_telecom["total_gasto"] /
                                df_telecom["meses_contrato"].clip(1))
df_telecom["razao_recl_meses"] = (df_telecom["num_reclamacoes"].fillna(0) /
                                   df_telecom["meses_contrato"].clip(1))
df_telecom["num_servicos"] = (df_telecom["tem_tv"] +
                               df_telecom["tem_streaming"] +
                               df_telecom["tem_seguro"])
df_telecom["cliente_novo"] = (df_telecom["meses_contrato"] < 6).astype(int)

print("Features criadas: gasto_por_mes, razao_recl_meses, "
      "num_servicos, cliente_novo")

# Separar X e y
colunas_remover = ["churn", "total_gasto"]   # total_gasto é leakage
X_full = df_telecom.drop(columns=colunas_remover)
y_full = df_telecom["churn"].values

# Identificar tipos de colunas
cols_num = X_full.select_dtypes(include=[np.number]).columns.tolist()
cols_cat = X_full.select_dtypes(include=["object"]).columns.tolist()

print(f"\nFeatures numéricas ({len(cols_num)}): {cols_num}")
print(f"Features categóricas ({len(cols_cat)}): {cols_cat}")

# Divisão treino/teste
X_tr, X_te, y_tr, y_te = train_test_split(
    X_full, y_full,
    test_size=0.2, random_state=42, stratify=y_full
)
print(f"\nTreino: {len(X_tr)}, Teste: {len(X_te)}")

# ══════════════════════════════════════════════════════════════
# FASE 3: CONSTRUÇÃO DO PIPELINE
# ══════════════════════════════════════════════════════════════
print("\n" + "─" * 65)
print("FASE 3: CONSTRUÇÃO DO PIPELINE")
print("─" * 65)

# Pré-processadores
prep_num = Pipeline([
    ("imputer", SimpleImputer(strategy="median")),
    ("scaler",  StandardScaler())
])

prep_cat = Pipeline([
    ("imputer", SimpleImputer(strategy="most_frequent")),
    ("encoder", OneHotEncoder(handle_unknown="ignore",
                               sparse_output=False))
])

preprocessador_full = ColumnTransformer([
    ("num", prep_num, cols_num),
    ("cat", prep_cat, cols_cat),
])

# Três candidatos a modelo
candidatos = {
    "Logística": Pipeline([
        ("prep",   preprocessador_full),
        ("modelo", LogisticRegression(
            C=1.0, max_iter=2000, random_state=42
        ))
    ]),
    "Random Forest": Pipeline([
        ("prep",   preprocessador_full),
        ("modelo", RandomForestClassifier(
            n_estimators=100, random_state=42, n_jobs=-1
        ))
    ]),
    "Gradient Boosting": Pipeline([
        ("prep",   preprocessador_full),
        ("modelo", GradientBoostingClassifier(
            n_estimators=100, random_state=42
        ))
    ]),
}

# ══════════════════════════════════════════════════════════════
# FASE 4: AVALIAÇÃO E SELEÇÃO DE MODELO
# ══════════════════════════════════════════════════════════════
print("\n" + "─" * 65)
print("FASE 4: AVALIAÇÃO E SELEÇÃO DE MODELO")
print("─" * 65)

skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
resultados_proj = {}

print(f"\n{'Modelo':<20} {'CV AUC':>12} {'CV F1':>12} {'Test AUC':>12} {'Test F1':>10}")
print("-" * 70)

for nome, pipe in candidatos.items():
    cv_auc = cross_val_score(pipe, X_tr, y_tr,
                              cv=skf, scoring="roc_auc", n_jobs=-1)
    cv_f1  = cross_val_score(pipe, X_tr, y_tr,
                              cv=skf, scoring="f1",      n_jobs=-1)

    pipe.fit(X_tr, y_tr)
    y_pred_p   = pipe.predict(X_te)
    y_proba_p  = pipe.predict_proba(X_te)[:, 1]
    test_auc   = roc_auc_score(y_te, y_proba_p)
    test_f1    = f1_score(y_te, y_pred_p)

    resultados_proj[nome] = {
        "cv_auc":  cv_auc.mean(),
        "cv_f1":   cv_f1.mean(),
        "test_auc": test_auc,
        "test_f1":  test_f1,
        "pipeline": pipe
    }

    print(f"{nome:<20} {cv_auc.mean():>8.4f}±{cv_auc.std():.3f} "
          f"{cv_f1.mean():>8.4f}±{cv_f1.std():.3f} "
          f"{test_auc:>12.4f} {test_f1:>10.4f}")

# Selecionar melhor por CV AUC
melhor_nome = max(resultados_proj,
                   key=lambda k: resultados_proj[k]["cv_auc"])
melhor_pipe  = resultados_proj[melhor_nome]["pipeline"]
print(f"\nMelhor modelo: {melhor_nome}")

# ══════════════════════════════════════════════════════════════
# FASE 5: AJUSTE DE HIPERPARÂMETROS DO MELHOR MODELO
# ══════════════════════════════════════════════════════════════
print("\n" + "─" * 65)
print("FASE 5: AJUSTE DE HIPERPARÂMETROS")
print("─" * 65)

if melhor_nome == "Random Forest":
    param_dist_rf = {
        "modelo__n_estimators":     randint(100, 400),
        "modelo__max_depth":        [None, 5, 10, 15, 20],
        "modelo__min_samples_leaf": randint(1, 15),
        "modelo__max_features":     ["sqrt", "log2", 0.5],
    }
    buscador = RandomizedSearchCV(
        melhor_pipe,
        param_distributions=param_dist_rf,
        n_iter=30,
        cv=skf,
        scoring="roc_auc",
        n_jobs=-1,
        random_state=42,
        refit=True,
        verbose=0
    )
elif melhor_nome == "Gradient Boosting":
    param_dist_gb = {
        "modelo__n_estimators":    randint(100, 400),
        "modelo__learning_rate":   loguniform(0.01, 0.3),
        "modelo__max_depth":       randint(2, 8),
        "modelo__subsample":       uniform(0.6, 0.4),
    }
    buscador = RandomizedSearchCV(
        melhor_pipe,
        param_distributions=param_dist_gb,
        n_iter=30,
        cv=skf,
        scoring="roc_auc",
        n_jobs=-1,
        random_state=42,
        refit=True,
        verbose=0
    )
else:
    param_dist_lr = {
        "modelo__C":           loguniform(0.001, 100),
        "modelo__penalty":     ["l1", "l2"],
        "modelo__solver":      ["liblinear", "saga"],
    }
    buscador = RandomizedSearchCV(
        melhor_pipe,
        param_distributions=param_dist_lr,
        n_iter=20,
        cv=skf,
        scoring="roc_auc",
        n_jobs=-1,
        random_state=42,
        refit=True,
        verbose=0
    )

print(f"Ajustando hiperparâmetros do {melhor_nome}...")
buscador.fit(X_tr, y_tr)

print(f"Melhor CV AUC antes: {resultados_proj[melhor_nome]['cv_auc']:.4f}")
print(f"Melhor CV AUC depois: {buscador.best_score_:.4f}")

# ══════════════════════════════════════════════════════════════
# FASE 6: AVALIAÇÃO FINAL
# ══════════════════════════════════════════════════════════════
print("\n" + "─" * 65)
print("FASE 6: AVALIAÇÃO FINAL NO CONJUNTO DE TESTE")
print("─" * 65)

modelo_final = buscador.best_estimator_
y_pred_final   = modelo_final.predict(X_te)
y_proba_final  = modelo_final.predict_proba(X_te)[:, 1]

auc_final  = roc_auc_score(y_te, y_proba_final)
f1_final   = f1_score(y_te, y_pred_final)
acc_final  = accuracy_score(y_te, y_pred_final)

print(f"\nResultados finais no teste:")
print(f"  Acurácia: {acc_final:.4f}")
print(f"  F1-Score: {f1_final:.4f}")
print(f"  AUC-ROC:  {auc_final:.4f}")
print(f"\n{classification_report(y_te, y_pred_final, target_names=['Ficou', 'Cancelou'])}")

# Visualizações finais
fig, axes = plt.subplots(1, 3, figsize=(18, 5))

# Curva ROC
fpr_f, tpr_f, _ = roc_curve(y_te, y_proba_final)
axes[0].plot(fpr_f, tpr_f, color="steelblue",
              linewidth=2.5, label=f"AUC = {auc_final:.4f}")
axes[0].plot([0, 1], [0, 1], "k--", linewidth=1.5)
axes[0].set_title("Curva ROC — Modelo Final")
axes[0].set_xlabel("Taxa de Falsos Positivos")
axes[0].set_ylabel("Recall")
axes[0].legend()

# Matriz de confusão
ConfusionMatrixDisplay.from_predictions(
    y_te, y_pred_final,
    display_labels=["Ficou", "Cancelou"],
    cmap="Blues", ax=axes[1]
)
axes[1].set_title("Matriz de Confusão")

# Distribuição de probabilidades
axes[2].hist(y_proba_final[y_te == 0], bins=25,
              alpha=0.6, color="steelblue",
              label="Ficou", edgecolor="white")
axes[2].hist(y_proba_final[y_te == 1], bins=25,
              alpha=0.6, color="coral",
              label="Cancelou", edgecolor="white")
axes[2].axvline(x=0.5, color="black",
                 linestyle="--", linewidth=1.5)
axes[2].set_title("Distribuição de Probabilidades")
axes[2].set_xlabel("P(Churn)")
axes[2].legend()

plt.suptitle(f"Avaliação Final — {melhor_nome} Otimizado",
             fontsize=14)
plt.tight_layout()
plt.show()

# ══════════════════════════════════════════════════════════════
# FASE 7: SALVANDO E USANDO O MODELO
# ══════════════════════════════════════════════════════════════
print("\n" + "─" * 65)
print("FASE 7: DEPLOY DO MODELO")
print("─" * 65)

# Salvar
joblib.dump(modelo_final, "churn_predictor.pkl")
print("Modelo salvo: churn_predictor.pkl")

# Simular uso em produção
def prever_churn(dados_cliente: dict, limiar: float = 0.5) -> dict:
    """
    Prevê probabilidade de churn para um cliente.

    Parâmetros:
        dados_cliente: dicionário com features do cliente
        limiar: limiar de decisão (padrão 0.5)

    Retorna:
        dicionário com probabilidade e decisão
    """
    modelo_prod = joblib.load("churn_predictor.pkl")
    df_cliente  = pd.DataFrame([dados_cliente])

    # Feature engineering (as mesmas do treino)
    df_cliente["gasto_por_mes"] = (
        df_cliente.get("total_gasto", [0])[0] /
        max(df_cliente["meses_contrato"].values[0], 1)
    )
    df_cliente["razao_recl_meses"] = (
        df_cliente.get("num_reclamacoes", [0])[0] /
        max(df_cliente["meses_contrato"].values[0], 1)
    )
    df_cliente["num_servicos"] = (
        df_cliente["tem_tv"] +
        df_cliente["tem_streaming"] +
        df_cliente["tem_seguro"]
    )
    df_cliente["cliente_novo"] = (
        df_cliente["meses_contrato"] < 6
    ).astype(int)

    prob   = modelo_prod.predict_proba(df_cliente)[0][1]
    classe = "RISCO ALTO" if prob >= limiar else "RISCO BAIXO"

    return {
        "probabilidade_churn": round(float(prob), 4),
        "classificacao":       classe,
        "recomendacao":        ("Acionar equipe de retenção"
                                if prob >= limiar
                                else "Monitorar normalmente")
    }

# Testando com dois clientes
clientes_teste = [
    {
        "nome": "Cliente em risco",
        "dados": {
            "idade": 28, "genero": "M",
            "tipo_contrato": "mensal",
            "meses_contrato": 3,
            "valor_mensal": 95.0,
            "chamadas_mes": 5, "dados_gb": 2.0, "sms_mes": 10,
            "num_reclamacoes": 4, "tickets_abertos": 2,
            "tem_tv": 0, "tem_streaming": 0, "tem_seguro": 0,
            "num_linhas": 1,
            "gasto_por_mes": 95.0,
            "razao_recl_meses": 4/3,
            "num_servicos": 0,
            "cliente_novo": 1
        }
    },
    {
        "nome": "Cliente fiel",
        "dados": {
            "idade": 45, "genero": "F",
            "tipo_contrato": "bienal",
            "meses_contrato": 48,
            "valor_mensal": 75.0,
            "chamadas_mes": 25, "dados_gb": 15.0, "sms_mes": 80,
            "num_reclamacoes": 0, "tickets_abertos": 0,
            "tem_tv": 1, "tem_streaming": 1, "tem_seguro": 1,
            "num_linhas": 3,
            "gasto_por_mes": 75.0,
            "razao_recl_meses": 0.0,
            "num_servicos": 3,
            "cliente_novo": 0
        }
    }
]

print("\nPredições em produção:")
for cliente in clientes_teste:
    df_c  = pd.DataFrame([cliente["dados"]])
    prob  = modelo_final.predict_proba(df_c)[0][1]
    classe = "RISCO ALTO" if prob >= 0.5 else "RISCO BAIXO"
    print(f"\n  {cliente['nome']}:")
    print(f"    P(churn) = {prob:.1%}")
    print(f"    Status:    {classe}")

Resumo da Aula

  • Pipeline encapsula pré-processamento + modelo, eliminando data leakage e simplificando o código
  • make_pipeline cria pipelines com nomes automáticos para uso rápido
  • ColumnTransformer aplica transformações diferentes para features numéricas e categóricas
  • Hiperparâmetros no pipeline são referenciados como "etapa__hiperparametro"
  • GridSearchCV testa todas as combinações mas escala pobremente com muitos parâmetros
  • RandomizedSearchCV amostra aleatoriamente e frequentemente encontra resultados quase tão bons
  • joblib.dump/load salva e carrega pipelines completos para produção
  • O fluxo completo de um projeto de ML tem 7 fases: exploração, preparação, pipeline, avaliação, ajuste, avaliação final e deploy
  • Em produção, o pipeline garante que o mesmo pré-processamento do treino é aplicado nos novos dados
  • Feature engineering deve ser consistente entre treino e predição — encapsule no pipeline sempre que possível

Exercícios

  1. Explique por que o uso de Pipeline do Scikit-learn elimina data leakage na validação cruzada. O que exatamente acontece diferente dentro de cada fold quando se usa um Pipeline comparado a normalizar os dados manualmente antes?

    ✓ Resposta:

    Sem Pipeline, quando você usa cross-validation, o processo é: normalizar todo X_train com fit_transform, depois dividir em folds. O problema é que o fit do scaler usou estatísticas (média, desvio) calculadas sobre todos os dados de treino, incluindo os dados que serão usados como validação em cada fold. As informações dos folds de validação "vazaram" para o pré-processamento.

    Com Pipeline, o processo dentro de cada fold é diferente: o Pipeline automaticamente chama fit_transform nos dados de treino do fold e apenas transform nos dados de validação do fold. O fit do scaler nunca vê os dados de validação — ele calcula média e desvio apenas dos dados de treino daquele fold específico.

    Concretamente: imagine fold 1 com exemplos 200-399 como validação. Sem Pipeline, o scaler foi ajustado com os exemplos 0-999 (incluindo 200-399). Com Pipeline, o scaler do fold 1 é ajustado apenas com os exemplos 0-199 e 400-999, e então aplicado aos exemplos 200-399 sem tê-los visto. Isso replica fielmente o cenário de produção, onde o scaler nunca viu os dados novos.

  2. Qual a diferença prática entre GridSearchCV e RandomizedSearchCV? Se você tiver 5 hiperparâmetros com 4 valores cada e quiser fazer 5-fold CV, quantos fits cada um faria? Quando você claramente escolheria um sobre o outro?

    ✓ Resposta:

    GridSearchCV testa todas as combinações possíveis do grid. Com 5 hiperparâmetros × 4 valores cada = 4⁵ = 1024 combinações × 5 folds = 5120 fits.

    RandomizedSearchCV amostra aleatoriamente N combinações. Se n_iter=50 com 5-fold CV = 50 × 5 = 250 fits, independente do tamanho do espaço.

    Escolha GridSearchCV quando: o espaço de busca é pequeno (menos de ~100 combinações), você precisa garantir que o ótimo global foi encontrado, ou está em fase final de ajuste fino com poucos parâmetros.

    Escolha RandomizedSearchCV quando: o espaço é grande (muitos hiperparâmetros ou ranges contínuos), o tempo é limitado, ou você está em fase exploratória inicial. Pesquisas empíricas mostram que RandomizedSearch encontra configurações tão boas quanto GridSearch em frações do tempo quando o espaço é grande. Uma razão é que tipicamente poucos hiperparâmetros são realmente importantes, e RandomizedSearch explora mais o espaço total ao invés de testar exaustivamente valores de parâmetros irrelevantes.

  3. Explique o conceito de feature leakage usando um exemplo concreto diferente do que foi visto na aula. Como você identificaria e corrigiria esse tipo de leakage em um projeto real?

    ✓ Resposta:

    Exemplo concreto de feature leakage: previsão de readmissão hospitalar em 30 dias. Uma das features é "número de medicamentos prescritos na alta". Parece razoável — mais medicamentos poderia indicar paciente mais grave. Mas na prática, o médico prescreve mais medicamentos quando sabe que o paciente tem alto risco de readmissão — então essa feature incorpora o julgamento clínico do médico, que foi feito com conhecimento do desfecho futuro. O modelo aprende a usar o "cheat code" do médico em vez de aprender os padrões reais.

    Para identificar: analise temporalmente. Cada feature foi registrada antes ou depois do evento que você está prevendo? Features registradas após o evento são leakage.

    Para corrigir: remover a feature de prescrição na alta e substituir por features disponíveis na admissão (diagnóstico de entrada, exames iniciais, histórico prévio). Regra geral: toda feature deve representar informação disponível no momento da predição real, não depois.

  4. Por que total_gasto foi removida das features no projeto de churn? O que aconteceria se ela fosse mantida? Isso é overfitting ou leakage? Explique a diferença entre os dois conceitos nesse contexto.

    ✓ Resposta:

    total_gasto foi removida porque é calculada como valor_mensal × meses_contrato — essencialmente uma função das outras features. Incluí-la causaria leakage indireto: ao aprender que total_gasto alto prediz permanência, o modelo estaria usando a mesma informação de meses_contrato (que já indica clientes mais antigos, que têm menos churn) de forma redundante e potencialmente enviesada.

    Mais importante: total_gasto em produção depende do histórico completo do cliente. Para um cliente novo, você não saberia o total_gasto final — você só saberia o valor_mensal. Se o modelo depende muito de total_gasto, teria performance degradada em produção para clientes com histórico curto.

    A diferença entre overfitting e leakage nesse contexto: overfitting seria o modelo memorizar padrões específicos do treino que não generalizam, mas usando apenas features válidas. Leakage é usar informação que não estaria disponível na hora da predição real, criando uma estimativa artificialmente otimista de performance — o modelo parece bom no treino e teste (onde total_gasto estava disponível) mas falha em produção.

  5. Descreva as 7 fases de um projeto de ML completo listadas na aula. Para cada fase, cite um erro comum que pode invalidar todo o projeto se cometido.

    ✓ Resposta:

    Fase 1 — Exploração dos dados: erro comum é pular essa fase por pressa e não detectar problemas como valores ausentes, distribuições anormais ou features que não fazem sentido para o problema. Isso contamina todo o pipeline subsequente.

    Fase 2 — Preparação dos dados: erro comum é aplicar transformações usando estatísticas de todo o dataset antes da divisão treino/teste, causando data leakage e estimativas otimistas de performance.

    Fase 3 — Construção do pipeline: erro comum é não encapsular o pré-processamento no Pipeline, causando leakage na cross-validation e inconsistências entre treino e produção.

    Fase 4 — Avaliação e seleção de modelo: erro comum é usar o conjunto de teste para comparar e selecionar modelos. Isso "queima" o teste — ele deixa de ser uma estimativa não enviesada da performance real.

    Fase 5 — Ajuste de hiperparâmetros: erro comum é fazer grid search sem cross-validation adequada, ou usar o conjunto de teste como critério de parada, tornando o teste parte do processo de treinamento indiretamente.

    Fase 6 — Avaliação final: erro comum é reportar apenas a métrica principal (ex: acurácia) e ignorar outras métricas relevantes, matriz de confusão e análise de erros, levando a decisões de negócio erradas.

    Fase 7 — Deploy: erro comum é salvar apenas os pesos do modelo sem o pré-processador, causando falhas em produção quando os dados chegam no formato bruto original.

  6. Explique o que joblib.dump e joblib.load fazem e por que salvar o pipeline completo (não apenas os pesos do modelo) é essencial para deploy em produção. O que pode dar errado se você salvar apenas os pesos do modelo sem o pré-processador?

    ✓ Resposta:

    joblib.dump serializa um objeto Python (neste caso, um Pipeline completo) para um arquivo em disco. joblib.load deserializa esse arquivo de volta para o objeto Python original, com todo o estado preservado.

    Salvar o pipeline completo é essencial porque o pipeline contém o pré-processador ajustado (fitted) além do modelo. O pré-processador carrega consigo os parâmetros aprendidos durante o treino: médias e desvios do StandardScaler, valores mais frequentes do SimpleImputer, categorias aprendidas pelo OneHotEncoder, etc.

    Se você salvar apenas os pesos do modelo e, em produção, receber dados brutos (com ausentes, categorias em texto, features em diferentes escalas), o modelo não conseguirá processar esses dados — ele espera receber os dados já transformados exatamente como eram no treino.

    O que pode dar errado com apenas os pesos: em produção, você precisaria recriar manualmente todo o pré-processamento, incluindo ajustar o scaler com os mesmos parâmetros do treino. Se você usar os dados de produção para ajustar um novo scaler, as médias e desvios serão diferentes, e as features normalizadas serão diferentes das que o modelo aprendeu — as predições serão incorretas ou absurdas. Com o pipeline completo, tudo isso é tratado automaticamente com uma única chamada pipeline.predict(dados_brutos).

Referências