Esta aula fecha a fase juntando as peças. O Pipeline do Scikit-learn embala pré-processamento e modelo num objeto só, de forma reproduzível e à prova de vazamento; em cima dele entram GridSearchCV e RandomizedSearchCV para a busca de hiperparâmetros. No fim, um projeto completo, da exploração ao arquivo pronto para produção.
O que quebra quando não há pipeline
Sem ele, o código de ML acumula problemas que não aparecem no notebook e aparecem em produção:
- 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
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__}")
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}")
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}")
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))
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}")
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)}")
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}")
O Pipeline embala pré-processamento e modelo num objeto só, e é essa embalagem que elimina o data leakage e encurta o código: dentro de uma validação cruzada, cada fold refaz o ajuste do pré-processamento sozinho, do jeito certo. O make_pipeline serve quando os nomes das etapas não importam, e o ColumnTransformer resolve o caso comum de features numéricas e categóricas que pedem tratamentos diferentes. Dentro do pipeline, um hiperparâmetro é endereçado como "etapa__hiperparametro" — é assim que a busca alcança tanto o modelo quanto o pré-processamento. Entre as duas buscas, o GridSearchCV testa todas as combinações e paga caro por isso conforme o espaço cresce, enquanto o RandomizedSearchCV sorteia e costuma chegar perto do mesmo resultado por uma fração do custo. Salvar vira uma linha com joblib.dump, e o que se carrega do outro lado já traz o pré-processamento junto — que é exatamente a garantia que se quer em produção: os dados novos passam pelo mesmo caminho que os dados de treino passaram. O projeto completo percorre sete fases, da exploração ao deploy, e a regra que atravessa todas é manter a feature engineering dentro do pipeline sempre que ela couber lá.
Exercícios
-
Explique por que o uso de
Pipelinedo 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 ofitdo 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_transformnos dados de treino do fold e apenastransformnos dados de validação do fold. Ofitdo 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.
-
Qual a diferença prática entre
GridSearchCVeRandomizedSearchCV? 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.
-
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.
-
Por que
total_gastofoi 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_gastofoi removida porque é calculada comovalor_mensal × meses_contrato— essencialmente uma função das outras features. Incluí-la causaria leakage indireto: ao aprender quetotal_gastoalto prediz permanência, o modelo estaria usando a mesma informação demeses_contrato(que já indica clientes mais antigos, que têm menos churn) de forma redundante e potencialmente enviesada.Mais importante:
total_gastoem produção depende do histórico completo do cliente. Para um cliente novo, você não saberia ototal_gastofinal — você só saberia ovalor_mensal. Se o modelo depende muito detotal_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_gastoestava disponível) mas falha em produção. -
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.
-
Explique o que
joblib.dumpejoblib.loadfazem 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.dumpserializa um objeto Python (neste caso, um Pipeline completo) para um arquivo em disco.joblib.loaddeserializa 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
- Scikit-learn — Pipeline — explica a regra que sustenta tudo: todo passo intermediário precisa ter
fitetransform, e só o último pode ser um estimador. - Scikit-learn — ColumnTransformer — além do
ColumnTransformer, mostra oremainder— o parâmetro que decide o destino das colunas que você não listou. - Scikit-learn — GridSearchCV — consulte pelo formato do
param_griddentro de pipeline, com o dois-pontos duplo que aponta para o passo (clf__max_depth). - Scikit-learn — RandomizedSearchCV — a diferença está em
n_itere na possibilidade de passar distribuições do SciPy em vez de listas fechadas. - Scikit-learn — Model Persistence — a página é franca sobre o risco: carregar um pickle é executar código, e o formato não promete sobreviver a uma troca de versão.
- Real Python — K-Means Clustering in Python — o guia termina montando um pipeline completo de clustering sobre dados de expressão gênica — outro domínio, mesma estrutura da aula.
- freeCodeCamp — Pipeline e ColumnTransformer na prática — Yannawut Kimnaruk monta pipelines separados para colunas numéricas e categóricas, junta no
ColumnTransformere ajusta com busca em grade. - Random Search vs Grid Search (Bergstra & Bengio, 2012) — Bergstra e Bengio provam por que a busca aleatória costuma achar melhores hiperparâmetros: só alguns deles importam, e a grade desperdiça tentativas nos outros.
- joblib documentation — a documentação da biblioteca que faz a serialização e também o paralelismo por trás do
n_jobs.