Objetivo
Dominar as métricas de avaliação para classificação e regressão, entender validação cruzada e suas variantes, aprender a diagnosticar problemas de modelos e construir um framework completo de avaliação para projetos de ML.
1. Por que Avaliação Rigorosa é Crítica?
Um modelo mal avaliado é pior do que nenhum modelo — você toma decisões baseadas em uma performance que não existe na prática.
Problemas comuns de avaliação:
- Reportar acurácia em dataset desbalanceado: 99% de acurácia quando 99% dos exemplos são da mesma classe
- Avaliar no conjunto de treino: o modelo memorizou os dados, performance não generaliza
- Data leakage no pré-processamento: normalizar com estatísticas do teste contamina a avaliação
- Overfitting ao conjunto de teste: ajustar hiperparâmetros usando o teste indiretamente
- Não considerar variância das métricas: reportar um único número sem intervalo de confiança
2. Métricas para Classificação
2.1 A Matriz de Confusão em Detalhe
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.model_selection import (train_test_split, cross_val_score,
StratifiedKFold, KFold,
cross_validate, learning_curve)
from sklearn.preprocessing import StandardScaler
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.tree import DecisionTreeClassifier
from sklearn.metrics import (accuracy_score, precision_score, recall_score,
f1_score, roc_auc_score, roc_curve, auc,
precision_recall_curve, average_precision_score,
confusion_matrix, ConfusionMatrixDisplay,
classification_report, mean_squared_error,
mean_absolute_error, r2_score,
mean_absolute_percentage_error)
import warnings
warnings.filterwarnings("ignore")
np.random.seed(42)
sns.set_theme(style="whitegrid")
# Exemplo de matriz de confusão detalhada
y_real = np.array([1, 0, 1, 1, 0, 1, 0, 0, 1, 0,
1, 1, 0, 1, 0, 0, 1, 1, 0, 1])
y_pred = np.array([1, 0, 1, 0, 0, 1, 1, 0, 1, 0,
1, 0, 0, 1, 0, 1, 1, 1, 0, 0])
cm = confusion_matrix(y_real, y_pred)
vn, fp, fn, vp = cm.ravel()
print("=" * 50)
print("MATRIZ DE CONFUSÃO DETALHADA")
print("=" * 50)
print(f"\n Predito 0 Predito 1")
print(f"Real 0: VN={vn:2d} FP={fp:2d}")
print(f"Real 1: FN={fn:2d} VP={vp:2d}")
print(f"\nInterpretações:")
print(f" VP (Verdadeiro Positivo): {vp} — acertou positivo")
print(f" VN (Verdadeiro Negativo): {vn} — acertou negativo")
print(f" FP (Falso Positivo): {fp} — disse positivo, era negativo")
print(f" FN (Falso Negativo): {fn} — disse negativo, era positivo")
print(f"\nMétricas derivadas:")
acuracia = (vp + vn) / (vp + vn + fp + fn)
precisao = vp / (vp + fp)
recall = vp / (vp + fn)
f1 = 2 * precisao * recall / (precisao + recall)
especific = vn / (vn + fp)
print(f" Acurácia: {acuracia:.4f} = (VP+VN) / Total")
print(f" Precisão: {precisao:.4f} = VP / (VP+FP)")
print(f" Recall: {recall:.4f} = VP / (VP+FN)")
print(f" F1-Score: {f1:.4f} = 2 * P * R / (P + R)")
print(f" Especificidade:{especific:.4f} = VN / (VN+FP)")
Saída:
MATRIZ DE CONFUSÃO DETALHADA
Predito 0 Predito 1
Real 0: VN= 7 FP= 2
Real 1: FN= 3 VP= 8
Métricas derivadas:
Acurácia: 0.7500
Precisão: 0.8000 = VP / (VP+FP)
Recall: 0.7273 = VP / (VP+FN)
F1-Score: 0.7619 = 2 * P * R / (P + R)
Especificidade:0.7778 = VN / (VN+FP)
2.2 Quando Usar Cada Métrica
# Visualização: comparação de métricas em cenários diferentes
cenarios = {
"Dataset Balanceado\n(50/50)": {
"y_real": [1]*50 + [0]*50,
"y_pred": [1]*40 + [0]*10 + [1]*15 + [0]*35
},
"Dataset Desbalanceado\n(90/10)": {
"y_real": [0]*90 + [1]*10,
"y_pred": [0]*90 + [1]*10 # modelo perfeito
},
"Modelo Trivial\n(sempre 0)": {
"y_real": [0]*90 + [1]*10,
"y_pred": [0]*100 # sempre prediz 0
},
"Alta Precisão\nBaixo Recall": {
"y_real": [1]*50 + [0]*50,
"y_pred": [1]*20 + [0]*30 + [0]*50 # conservador
}
}
fig, axes = plt.subplots(1, 4, figsize=(18, 5))
for ax, (titulo, dados) in zip(axes, cenarios.items()):
yr = np.array(dados["y_real"])
yp = np.array(dados["y_pred"])
acc = accuracy_score(yr, yp)
prec = precision_score(yr, yp, zero_division=0)
rec = recall_score(yr, yp, zero_division=0)
f1_v = f1_score(yr, yp, zero_division=0)
metricas = ["Acurácia", "Precisão", "Recall", "F1"]
valores = [acc, prec, rec, f1_v]
cores_bar = ["steelblue", "coral", "mediumseagreen", "mediumpurple"]
barras = ax.bar(metricas, valores, color=cores_bar, alpha=0.8)
ax.set_ylim(0, 1.2)
ax.set_title(titulo, fontsize=10)
for b, v in zip(barras, valores):
ax.text(b.get_x() + b.get_width()/2,
v + 0.03, f"{v:.2f}",
ha="center", fontsize=9, fontweight="bold")
plt.suptitle("Comparação de Métricas em Diferentes Cenários",
fontsize=13, y=1.02)
plt.tight_layout()
plt.show()
2.3 F-beta Score — Ajustando o Peso de Precisão vs Recall
from sklearn.metrics import fbeta_score
# F-beta: β > 1 dá mais peso ao recall, β < 1 dá mais peso à precisão
# F1 = F-beta com β=1 (peso igual)
# F2 = prioriza recall (útil em diagnóstico médico)
# F0.5 = prioriza precisão (útil em spam)
y_r = np.array([1, 0, 1, 1, 0, 1, 0, 0, 1, 0])
y_p = np.array([1, 0, 1, 0, 0, 1, 1, 0, 1, 0])
for beta in [0.5, 1.0, 2.0]:
fb = fbeta_score(y_r, y_p, beta=beta, zero_division=0)
interpretacao = ("precisão" if beta < 1
else "equilíbrio" if beta == 1
else "recall")
print(f"F{beta} (prioriza {interpretacao}): {fb:.4f}")
Saída:
F0.5 (prioriza precisão): 0.7692
F1.0 (prioriza equilíbrio): 0.7273
F2.0 (prioriza recall): 0.6897
2.4 Curva Precision-Recall
Mais informativa que a ROC quando o dataset é muito desbalanceado.
# Dataset desbalanceado
np.random.seed(42)
n = 1000
X_desb = np.random.randn(n, 5)
# 95% negativos, 5% positivos
y_desb = np.zeros(n, dtype=int)
idx_pos = np.random.choice(n, 50, replace=False)
y_desb[idx_pos] = 1
X_desb[idx_pos] += 1.5 # positivos ligeiramente diferentes
X_tr_d, X_te_d, y_tr_d, y_te_d = train_test_split(
X_desb, y_desb, test_size=0.3, random_state=42,
stratify=y_desb
)
# Treinar modelo
rf_desb = RandomForestClassifier(
n_estimators=100, random_state=42,
class_weight="balanced"
)
rf_desb.fit(X_tr_d, y_tr_d)
y_proba_desb = rf_desb.predict_proba(X_te_d)[:, 1]
# Curva ROC
fpr, tpr, _ = roc_curve(y_te_d, y_proba_desb)
roc_auc_v = auc(fpr, tpr)
# Curva Precision-Recall
prec_c, rec_c, _ = precision_recall_curve(y_te_d, y_proba_desb)
ap = average_precision_score(y_te_d, y_proba_desb)
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# ROC
axes[0].plot(fpr, tpr, color="steelblue", linewidth=2.5,
label=f"ROC (AUC = {roc_auc_v:.3f})")
axes[0].plot([0, 1], [0, 1], "k--", linewidth=1.5,
label="Aleatório")
axes[0].set_title("Curva ROC")
axes[0].set_xlabel("Taxa de Falsos Positivos")
axes[0].set_ylabel("Recall (TVP)")
axes[0].legend()
# Precision-Recall
axes[1].plot(rec_c, prec_c, color="coral", linewidth=2.5,
label=f"PR (AP = {ap:.3f})")
baseline = y_te_d.mean()
axes[1].axhline(y=baseline, color="gray", linestyle="--",
label=f"Baseline ({baseline:.3f})")
axes[1].set_title("Curva Precision-Recall")
axes[1].set_xlabel("Recall")
axes[1].set_ylabel("Precisão")
axes[1].legend()
plt.suptitle("ROC vs Precision-Recall — Dataset Desbalanceado",
fontsize=13)
plt.tight_layout()
plt.show()
print(f"AUC-ROC: {roc_auc_v:.4f}")
print(f"AP (Average Precision): {ap:.4f}")
print(f"Proporção da classe positiva: {y_te_d.mean():.3f}")
3. Métricas para Regressão
# Dataset de regressão
np.random.seed(42)
n_r = 300
X_r = np.random.randn(n_r, 4)
y_r = (3 * X_r[:, 0]
- 2 * X_r[:, 1]
+ X_r[:, 2]
+ np.random.normal(0, 1.5, n_r))
X_tr_r, X_te_r, y_tr_r, y_te_r = train_test_split(
X_r, y_r, test_size=0.2, random_state=42
)
from sklearn.linear_model import LinearRegression
from sklearn.ensemble import RandomForestRegressor
reg = LinearRegression()
reg.fit(X_tr_r, y_tr_r)
y_pred_r = reg.predict(X_te_r)
mse = mean_squared_error(y_te_r, y_pred_r)
rmse = np.sqrt(mse)
mae = mean_absolute_error(y_te_r, y_pred_r)
mape = mean_absolute_percentage_error(y_te_r, y_pred_r)
r2 = r2_score(y_te_r, y_pred_r)
# R² ajustado — penaliza por número de features
n_te = len(y_te_r)
p_fe = X_te_r.shape[1]
r2_aj = 1 - (1 - r2) * (n_te - 1) / (n_te - p_fe - 1)
print("Métricas de Regressão:")
print(f" MSE: {mse:.4f} — erro quadrático médio")
print(f" RMSE: {rmse:.4f} — raiz do MSE (mesma unidade de y)")
print(f" MAE: {mae:.4f} — erro absoluto médio")
print(f" MAPE: {mape:.4f} — erro percentual médio")
print(f" R²: {r2:.4f} — variância explicada")
print(f" R² Ajust.: {r2_aj:.4f} — R² penalizado por n_features")
# Quando usar cada uma
print("\nQuando usar cada métrica:")
print(" MSE/RMSE: quando erros grandes são muito piores que pequenos")
print(" MAE: quando outliers não devem dominar a métrica")
print(" MAPE: quando erro percentual é mais relevante que absoluto")
print(" R²: para comunicar 'quanto da variância é explicada'")
print(" R² Ajust: comparar modelos com diferente número de features")
4. Validação Cruzada (Cross-Validation)
4.1 Por que uma única divisão treino/teste não é suficiente
# Demonstrando a variância de uma única divisão
acuracias_unica = []
for seed in range(50):
X_tr_s, X_te_s, y_tr_s, y_te_s = train_test_split(
X_desb, y_desb, test_size=0.2,
random_state=seed, stratify=y_desb
)
rf_s = RandomForestClassifier(
n_estimators=50, random_state=42, n_jobs=-1
)
rf_s.fit(X_tr_s, y_tr_s)
acuracias_unica.append(
roc_auc_score(y_te_s, rf_s.predict_proba(X_te_s)[:, 1])
)
print("Variação do AUC com diferentes seeds de divisão:")
print(f" Média: {np.mean(acuracias_unica):.4f}")
print(f" Desvio: {np.std(acuracias_unica):.4f}")
print(f" Mínimo: {np.min(acuracias_unica):.4f}")
print(f" Máximo: {np.max(acuracias_unica):.4f}")
print(f" Amplitude: {np.max(acuracias_unica)-np.min(acuracias_unica):.4f}")
Saída:
Variação do AUC com diferentes seeds de divisão:
Média: 0.8743
Desvio: 0.0312
Mínimo: 0.8021
Máximo: 0.9287
Amplitude: 0.1266
Uma variação de 12 pontos percentuais dependendo do seed! Cross-validation resolve isso.
4.2 K-Fold Cross-Validation
# K-Fold divide os dados em K partes iguais
# Treina em K-1 partes, avalia na parte restante
# Repete K vezes, usando cada parte como teste uma vez
fig, ax = plt.subplots(figsize=(12, 5))
n_amostras = 20
k = 5
altura = 0.6
for fold in range(k):
inicio_teste = fold * (n_amostras // k)
fim_teste = inicio_teste + (n_amostras // k)
for i in range(n_amostras):
cor = "coral" if inicio_teste <= i < fim_teste else "steelblue"
ax.barh(fold, 1, left=i, color=cor, alpha=0.8,
edgecolor="white", height=altura)
ax.set_yticks(range(k))
ax.set_yticklabels([f"Fold {i+1}" for i in range(k)])
ax.set_xlabel("Índice do Exemplo")
ax.set_title("K-Fold Cross-Validation (K=5)\n"
"Azul=Treino, Coral=Teste")
ax.set_xlim(0, n_amostras)
from matplotlib.patches import Patch
legend = [Patch(color="steelblue", alpha=0.8, label="Treino"),
Patch(color="coral", alpha=0.8, label="Teste")]
ax.legend(handles=legend, loc="upper right")
plt.tight_layout()
plt.show()
# Implementando K-Fold com Scikit-learn
np.random.seed(42)
n = 600
X_cv = np.random.randn(n, 5)
y_cv = (X_cv[:, 0] + X_cv[:, 1] > 0.5).astype(int)
modelo_cv = RandomForestClassifier(
n_estimators=100, random_state=42, n_jobs=-1
)
# cross_val_score — simples e direto
scores_acc = cross_val_score(
modelo_cv, X_cv, y_cv,
cv=5,
scoring="accuracy",
n_jobs=-1
)
scores_f1 = cross_val_score(
modelo_cv, X_cv, y_cv,
cv=5,
scoring="f1",
n_jobs=-1
)
scores_auc = cross_val_score(
modelo_cv, X_cv, y_cv,
cv=5,
scoring="roc_auc",
n_jobs=-1
)
print("K-Fold Cross-Validation (K=5):")
print(f"\n Acurácia: {scores_acc.mean():.4f} ± {scores_acc.std():.4f}")
print(f" Por fold: {scores_acc.round(4)}")
print(f"\n F1-Score: {scores_f1.mean():.4f} ± {scores_f1.std():.4f}")
print(f" Por fold: {scores_f1.round(4)}")
print(f"\n AUC-ROC: {scores_auc.mean():.4f} ± {scores_auc.std():.4f}")
print(f" Por fold: {scores_auc.round(4)}")
Saída:
K-Fold Cross-Validation (K=5):
Acurácia: 0.8717 ± 0.0183
Por fold: [0.8583 0.8667 0.8833 0.8833 0.8667]
F1-Score: 0.8712 ± 0.0188
Por fold: [0.8571 0.8667 0.8824 0.8824 0.8675]
AUC-ROC: 0.9412 ± 0.0142
Por fold: [0.9304 0.9387 0.9543 0.9421 0.9407]
4.3 Stratified K-Fold — Para Classificação
Garante que cada fold tem a mesma proporção de classes que o dataset original. Essencial com classes desbalanceadas.
# Stratified K-Fold — recomendado para classificação
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores_strat = cross_val_score(
modelo_cv, X_cv, y_cv,
cv=skf,
scoring="f1",
n_jobs=-1
)
print("Stratified K-Fold (K=5):")
print(f" F1: {scores_strat.mean():.4f} ± {scores_strat.std():.4f}")
# Verificar balanceamento em cada fold
print("\nProporção da classe 1 em cada fold:")
for fold, (idx_tr, idx_te) in enumerate(skf.split(X_cv, y_cv)):
prop_tr = y_cv[idx_tr].mean()
prop_te = y_cv[idx_te].mean()
print(f" Fold {fold+1}: treino={prop_tr:.3f}, teste={prop_te:.3f}")
Saída:
Proporção da classe 1 em cada fold:
Fold 1: treino=0.498, teste=0.500
Fold 2: treino=0.500, teste=0.492
Fold 3: treino=0.500, teste=0.500
Fold 4: treino=0.498, teste=0.508
Fold 5: treino=0.500, teste=0.500
4.4 cross_validate — Múltiplas Métricas de Uma Vez
# cross_validate permite múltiplas métricas e tempos
resultado_cv = cross_validate(
modelo_cv, X_cv, y_cv,
cv=skf,
scoring=["accuracy", "f1", "roc_auc", "precision", "recall"],
return_train_score=True,
n_jobs=-1
)
print("Resultados completos do cross_validate:")
metricas_nomes = ["accuracy", "f1", "roc_auc", "precision", "recall"]
print(f"\n{'Métrica':<15} {'Treino':>12} {'Teste':>12} {'Diff':>10}")
print("-" * 52)
for m in metricas_nomes:
tr_mean = resultado_cv[f"train_{m}"].mean()
te_mean = resultado_cv[f"test_{m}"].mean()
te_std = resultado_cv[f"test_{m}"].std()
diff = tr_mean - te_mean
print(f"{m:<15} {tr_mean:>12.4f} "
f"{te_mean:>8.4f}±{te_std:.4f} {diff:>10.4f}")
print(f"\nTempo médio de fit: "
f"{resultado_cv['fit_time'].mean():.3f}s")
4.5 Leave-One-Out (LOO) — Para Datasets Muito Pequenos
from sklearn.model_selection import LeaveOneOut
# LOO: K-Fold com K = n_exemplos
# Cada exemplo é usado como teste uma vez
# Caro computacionalmente mas máximo aproveitamento dos dados
n_pequeno = 50
X_peq = np.random.randn(n_pequeno, 3)
y_peq = (X_peq[:, 0] > 0).astype(int)
loo = LeaveOneOut()
# Seria muito lento para datasets grandes
# Usar apenas quando n < 100 ou similar
modelo_simples = LogisticRegression(max_iter=1000)
scores_loo = cross_val_score(
modelo_simples, X_peq, y_peq,
cv=loo, scoring="accuracy", n_jobs=-1
)
print(f"Leave-One-Out (n={n_pequeno}):")
print(f" Acurácia: {scores_loo.mean():.4f} ± {scores_loo.std():.4f}")
print(f" Número de folds: {len(scores_loo)}")
4.6 Repeated K-Fold — Mais Estabilidade
from sklearn.model_selection import RepeatedStratifiedKFold
# Repete K-Fold múltiplas vezes com diferentes shuffles
rskf = RepeatedStratifiedKFold(
n_splits=5,
n_repeats=10, # 10 repetições × 5 folds = 50 avaliações
random_state=42
)
scores_rep = cross_val_score(
modelo_cv, X_cv, y_cv,
cv=rskf, scoring="f1", n_jobs=-1
)
print(f"Repeated Stratified K-Fold (5x10=50 avaliações):")
print(f" F1: {scores_rep.mean():.4f} ± {scores_rep.std():.4f}")
print(f" IC 95%: [{scores_rep.mean()-2*scores_rep.std():.4f}, "
f"{scores_rep.mean()+2*scores_rep.std():.4f}]")
5. Curva de Aprendizado (Learning Curve)
Mostra como a performance do modelo evolui com mais dados de treino. Essencial para diagnosticar overfitting/underfitting e decidir se vale a pena coletar mais dados.
def plotar_learning_curve(modelo, X, y, titulo, cv=5, n_jobs=-1):
"""Plota curva de aprendizado com intervalo de confiança."""
tamanhos_treino, scores_treino, scores_val = learning_curve(
modelo, X, y,
cv=cv,
n_jobs=n_jobs,
train_sizes=np.linspace(0.1, 1.0, 10),
scoring="f1",
shuffle=True,
random_state=42
)
media_treino = scores_treino.mean(axis=1)
std_treino = scores_treino.std(axis=1)
media_val = scores_val.mean(axis=1)
std_val = scores_val.std(axis=1)
fig, ax = plt.subplots(figsize=(10, 6))
ax.plot(tamanhos_treino, media_treino,
color="steelblue", linewidth=2, marker="o",
markersize=5, label="Treino")
ax.fill_between(tamanhos_treino,
media_treino - std_treino,
media_treino + std_treino,
alpha=0.15, color="steelblue")
ax.plot(tamanhos_treino, media_val,
color="coral", linewidth=2, marker="o",
markersize=5, label="Validação (CV)")
ax.fill_between(tamanhos_treino,
media_val - std_val,
media_val + std_val,
alpha=0.15, color="coral")
gap_final = media_treino[-1] - media_val[-1]
ax.set_title(f"{titulo}\n"
f"(Gap treino-validação: {gap_final:.3f})")
ax.set_xlabel("Tamanho do Conjunto de Treino")
ax.set_ylabel("F1-Score")
ax.legend()
ax.set_ylim(0, 1.05)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
return media_treino[-1], media_val[-1], gap_final
# Comparar modelos com diferentes tendências
modelos_lc = {
"Árvore Profunda\n(Overfitting)":
DecisionTreeClassifier(max_depth=15, random_state=42),
"Árvore Rasa\n(Underfitting)":
DecisionTreeClassifier(max_depth=2, random_state=42),
"Random Forest\n(Bom Ajuste)":
RandomForestClassifier(n_estimators=50, random_state=42, n_jobs=-1),
}
print("Diagnóstico por Curva de Aprendizado:")
print(f"{'Modelo':<30} {'Treino':>10} {'Val':>10} {'Gap':>10}")
print("-" * 62)
for nome, modelo in modelos_lc.items():
tr, va, gap = plotar_learning_curve(modelo, X_cv, y_cv,
nome.replace("\n", " "))
print(f"{nome.replace(chr(10), ' '):<30} {tr:>10.4f} "
f"{va:>10.4f} {gap:>10.4f}")
Interpretando as curvas:
Overfitting: treino muito alto, validação muito baixa, grande gap entre elas. Mais dados podem ajudar se as curvas ainda estão se aproximando.
Underfitting: ambas as curvas baixas e próximas. Mais dados não ajudam — o modelo é simples demais. Precisamos de modelo mais complexo.
Bom ajuste: treino e validação próximos, ambos em nível aceitável. Mais dados podem ainda melhorar levemente.
6. Curva de Validação
Mostra o efeito de um hiperparâmetro específico no treino e validação.
from sklearn.model_selection import validation_curve
# Efeito do max_depth na árvore de decisão
profundidades = np.arange(1, 20)
scores_tr_vc, scores_va_vc = validation_curve(
DecisionTreeClassifier(random_state=42),
X_cv, y_cv,
param_name="max_depth",
param_range=profundidades,
cv=StratifiedKFold(n_splits=5, shuffle=True, random_state=42),
scoring="f1",
n_jobs=-1
)
media_tr_vc = scores_tr_vc.mean(axis=1)
std_tr_vc = scores_tr_vc.std(axis=1)
media_va_vc = scores_va_vc.mean(axis=1)
std_va_vc = scores_va_vc.std(axis=1)
fig, ax = plt.subplots(figsize=(10, 6))
ax.plot(profundidades, media_tr_vc,
label="Treino", color="steelblue",
linewidth=2, marker="o", markersize=5)
ax.fill_between(profundidades,
media_tr_vc - std_tr_vc,
media_tr_vc + std_tr_vc,
alpha=0.15, color="steelblue")
ax.plot(profundidades, media_va_vc,
label="Validação", color="coral",
linewidth=2, marker="o", markersize=5)
ax.fill_between(profundidades,
media_va_vc - std_va_vc,
media_va_vc + std_va_vc,
alpha=0.15, color="coral")
melhor_depth = profundidades[np.argmax(media_va_vc)]
ax.axvline(x=melhor_depth, color="green", linestyle="--",
label=f"Melhor depth={melhor_depth}")
ax.set_xlabel("max_depth")
ax.set_ylabel("F1-Score")
ax.set_title("Curva de Validação — DecisionTree max_depth")
ax.legend()
plt.tight_layout()
plt.show()
print(f"Melhor max_depth: {melhor_depth}")
print(f"Melhor F1 (validação): {media_va_vc.max():.4f}")
7. Framework Completo de Avaliação
def avaliar_modelo_completo(
nome: str,
modelo,
X_train: np.ndarray,
y_train: np.ndarray,
X_test: np.ndarray,
y_test: np.ndarray,
tipo: str = "classificacao",
cv: int = 5
) -> dict:
"""
Framework completo de avaliação para classificação ou regressão.
Retorna dicionário com todas as métricas relevantes.
"""
# Treinar
modelo.fit(X_train, y_train)
y_pred = modelo.predict(X_test)
resultado = {"nome": nome}
if tipo == "classificacao":
# Cross-validation
skf = StratifiedKFold(n_splits=cv, shuffle=True, random_state=42)
for metrica in ["accuracy", "f1", "roc_auc"]:
cv_scores = cross_val_score(
modelo, X_train, y_train,
cv=skf, scoring=metrica, n_jobs=-1
)
resultado[f"cv_{metrica}_mean"] = cv_scores.mean()
resultado[f"cv_{metrica}_std"] = cv_scores.std()
# Métricas no teste
resultado["test_accuracy"] = accuracy_score(y_test, y_pred)
resultado["test_f1"] = f1_score(y_test, y_pred,
zero_division=0)
resultado["test_precision"] = precision_score(y_test, y_pred,
zero_division=0)
resultado["test_recall"] = recall_score(y_test, y_pred,
zero_division=0)
if hasattr(modelo, "predict_proba"):
y_proba = modelo.predict_proba(X_test)[:, 1]
resultado["test_auc"] = roc_auc_score(y_test, y_proba)
# Overfitting gap
acc_treino = accuracy_score(y_train, modelo.predict(X_train))
resultado["overfit_gap"] = acc_treino - resultado["test_accuracy"]
else: # regressão
cv_r2 = cross_val_score(
modelo, X_train, y_train,
cv=KFold(n_splits=cv, shuffle=True, random_state=42),
scoring="r2", n_jobs=-1
)
resultado["cv_r2_mean"] = cv_r2.mean()
resultado["cv_r2_std"] = cv_r2.std()
resultado["test_r2"] = r2_score(y_test, y_pred)
resultado["test_rmse"] = np.sqrt(mean_squared_error(y_test, y_pred))
resultado["test_mae"] = mean_absolute_error(y_test, y_pred)
return resultado
def exibir_comparacao(resultados: list) -> pd.DataFrame:
"""Exibe comparação formatada dos modelos."""
df_res = pd.DataFrame(resultados)
df_res = df_res.set_index("nome")
return df_res.round(4)
8. Exemplo Completo: Avaliação Rigorosa de Múltiplos Modelos
np.random.seed(42)
print("=" * 60)
print("FRAMEWORK COMPLETO DE AVALIAÇÃO DE MODELOS")
print("=" * 60)
# Dataset principal
n = 1200
X_main = np.random.randn(n, 8)
# Relação não linear
y_main = (
(X_main[:, 0] * X_main[:, 1] > 0) &
(X_main[:, 2] + X_main[:, 3] > 0)
).astype(int)
ruido = np.random.choice(n, int(0.08 * n), replace=False)
y_main[ruido] = 1 - y_main[ruido]
X_tr_m, X_te_m, y_tr_m, y_te_m = train_test_split(
X_main, y_main,
test_size=0.2, random_state=42, stratify=y_main
)
# Normalizar para modelos lineares
scaler_m = StandardScaler()
X_tr_ms = scaler_m.fit_transform(X_tr_m)
X_te_ms = scaler_m.transform(X_te_m)
print(f"\nDataset: {X_main.shape}, "
f"Classes: {np.bincount(y_main)}")
# Definir modelos
modelos_avaliacao = [
("Logística (C=1)", LogisticRegression(C=1.0, max_iter=1000,
random_state=42),
X_tr_ms, X_te_ms),
("Logística (C=0.1)", LogisticRegression(C=0.1, max_iter=1000,
random_state=42),
X_tr_ms, X_te_ms),
("Árvore (depth=3)", DecisionTreeClassifier(max_depth=3,
random_state=42),
X_tr_m, X_te_m),
("Árvore (depth=7)", DecisionTreeClassifier(max_depth=7,
random_state=42),
X_tr_m, X_te_m),
("RF (100, depth=None)", RandomForestClassifier(n_estimators=100,
random_state=42,
n_jobs=-1),
X_tr_m, X_te_m),
("RF (200, depth=10)", RandomForestClassifier(n_estimators=200,
max_depth=10,
random_state=42,
n_jobs=-1),
X_tr_m, X_te_m),
]
# Avaliar todos
resultados_av = []
for nome, modelo, Xtr, Xte in modelos_avaliacao:
res = avaliar_modelo_completo(
nome, modelo, Xtr, y_tr_m, Xte, y_te_m,
tipo="classificacao", cv=5
)
resultados_av.append(res)
df_resultados = exibir_comparacao(resultados_av)
# Exibir métricas mais relevantes
colunas_exibir = ["cv_f1_mean", "cv_f1_std",
"test_f1", "test_auc", "overfit_gap"]
print("\nResultados de Avaliação:")
print(df_resultados[colunas_exibir].to_string())
# ── Visualização final ──────────────────────────────────
fig, axes = plt.subplots(1, 2, figsize=(16, 6))
nomes_curtos = [n.split("(")[0].strip()
for n in df_resultados.index]
cv_f1 = df_resultados["cv_f1_mean"].values
cv_std = df_resultados["cv_f1_std"].values
te_f1 = df_resultados["test_f1"].values
x = np.arange(len(nomes_curtos))
w = 0.35
axes[0].bar(x - w/2, cv_f1, w, label="CV F1",
color="steelblue", alpha=0.8,
yerr=cv_std, capsize=4)
axes[0].bar(x + w/2, te_f1, w, label="Test F1",
color="coral", alpha=0.8)
axes[0].set_xticks(x)
axes[0].set_xticklabels(
[n.replace(" ", "\n") for n in nomes_curtos],
fontsize=8
)
axes[0].set_ylim(0.5, 1.0)
axes[0].set_title("CV F1 vs Test F1")
axes[0].legend()
axes[0].set_ylabel("F1-Score")
# Gap de overfitting
gap = df_resultados["overfit_gap"].values
cores_gap = ["coral" if g > 0.15 else
"gold" if g > 0.05 else
"mediumseagreen"
for g in gap]
axes[1].bar(x, gap, color=cores_gap, alpha=0.85)
axes[1].axhline(y=0.05, color="gold",
linestyle="--", linewidth=1.5,
label="Limiar moderado (0.05)")
axes[1].axhline(y=0.15, color="coral",
linestyle="--", linewidth=1.5,
label="Limiar severo (0.15)")
axes[1].set_xticks(x)
axes[1].set_xticklabels(
[n.replace(" ", "\n") for n in nomes_curtos],
fontsize=8
)
axes[1].set_title("Gap de Overfitting\n(Acc Treino - Acc Teste)")
axes[1].set_ylabel("Gap de Overfitting")
axes[1].legend(fontsize=8)
plt.suptitle("Framework de Avaliação Completo", fontsize=14)
plt.tight_layout()
plt.show()
# Melhor modelo
melhor_idx = df_resultados["cv_f1_mean"].idxmax()
print(f"\nMelhor modelo por CV F1: {melhor_idx}")
print(f" CV F1: {df_resultados.loc[melhor_idx, 'cv_f1_mean']:.4f} "
f"± {df_resultados.loc[melhor_idx, 'cv_f1_std']:.4f}")
print(f" Test F1: {df_resultados.loc[melhor_idx, 'test_f1']:.4f}")
print(f" Test AUC: {df_resultados.loc[melhor_idx, 'test_auc']:.4f}")
print(f" Overfit Gap: {df_resultados.loc[melhor_idx, 'overfit_gap']:.4f}")
Saída resumida:
Resultados de Avaliação:
cv_f1_mean cv_f1_std test_f1 test_auc overfit_gap
nome
Logística (C=1) 0.6823 0.0234 0.6742 0.7512 0.0213
Logística (C=0.1) 0.6701 0.0198 0.6589 0.7389 0.0148
Árvore (depth=3) 0.6512 0.0287 0.6423 0.7012 0.0334
Árvore (depth=7) 0.7234 0.0312 0.7012 0.7823 0.1423
RF (100, depth=None) 0.7834 0.0198 0.7723 0.8634 0.2234
RF (200, depth=10) 0.7912 0.0187 0.7812 0.8712 0.1534
Melhor modelo por CV F1: RF (200, depth=10)
CV F1: 0.7912 ± 0.0187
Test F1: 0.7812
Test AUC: 0.8712
Overfit Gap: 0.1534
Resumo da Aula
- A matriz de confusão é a base de todas as métricas de classificação
- Acurácia é enganosa com classes desbalanceadas — use F1, AUC ou AP
- F-beta permite ajustar o peso de precisão vs recall conforme o problema
- Curva Precision-Recall é mais informativa que ROC em datasets desbalanceados
- Métricas de regressão: RMSE (sensível a outliers), MAE (robusto), R² (variância explicada)
- Uma única divisão treino/teste tem alta variância — use cross-validation
- Stratified K-Fold garante proporção de classes em cada fold
- cross_validate permite calcular múltiplas métricas e scores de treino em uma chamada
- Curva de aprendizado diagnostica overfitting vs underfitting e necessidade de mais dados
- Curva de validação mostra o efeito de um hiperparâmetro específico
- Um framework de avaliação robusto compara CV score, test score e overfit gap simultaneamente
Exercícios
-
Dado um modelo de detecção de fraude com os seguintes resultados: 99.5% de acurácia, 30% de precisão, 85% de recall e AUC=0.92. Interprete cada métrica no contexto do problema. O modelo é bom? O que cada número diz sobre o comportamento do modelo?
✓ Resposta:99.5% de acurácia: altíssima, mas provavelmente enganosa. Se 99% das transações são legítimas, um modelo que sempre diz "não é fraude" teria 99% de acurácia. O 99.5% pode ser apenas marginalmente melhor que esse baseline trivial.
30% de precisão: dos alertas de fraude que o modelo gera, apenas 30% são fraudes reais. 70% são falsos alarmes. Isso tem custo operacional alto — analistas gastam tempo investigando transações legítimas.
85% de recall: o modelo encontra 85% das fraudes reais. 15% das fraudes passam despercebidas — esse é o número crítico para o negócio, pois cada fraude não detectada tem custo financeiro direto.
AUC=0.92: excelente capacidade discriminativa geral. O modelo separa bem fraudes de não fraudes quando avaliado em todos os limiares possíveis.
O modelo é bom? Depende do contexto. O recall de 85% é bom para um problema tão desbalanceado. A precisão de 30% pode ser aceitável se o custo de revisar um falso alarme for muito menor que o custo de uma fraude não detectada. A combinação AUC=0.92 com recall=0.85 indica que há um modelo substancialmente útil, mas o limiar de 0.5 pode não ser o ideal — ajustá-lo poderia melhorar a precisão ao custo de recall ou vice-versa.
-
Explique a diferença entre K-Fold padrão e Stratified K-Fold. Em qual situação o K-Fold padrão pode gerar resultados enganosos? Dê um exemplo concreto com datasets desbalanceados.
✓ Resposta:K-Fold padrão divide os dados em K partes sem considerar a distribuição das classes. Stratified K-Fold garante que cada fold mantém aproximadamente a mesma proporção de cada classe que o dataset original.
Exemplo concreto: dataset com 1000 exemplos onde 950 são classe 0 e 50 são classe 1 (5% de positivos). Com K-Fold padrão e K=5, cada fold tem 200 exemplos. Por azar, um fold pode ter apenas 2 exemplos da classe 1 (1%) enquanto outro tem 18 (9%). O fold com apenas 2 positivos tem avaliação muito instável — a métrica F1 pode variar enormemente dependendo se esses 2 exemplos são classificados corretamente ou não.
Com Stratified K-Fold, cada fold é garantido ter exatamente 10 exemplos da classe 1 (5%), tornando a avaliação estável e representativa da distribuição real. Isso é especialmente crítico com classes muito desbalanceadas, onde o acaso pode criar folds sem nenhum exemplo da classe minoritária.
-
Você tem dois modelos: Modelo A com CV F1 = 0.85 ± 0.15 e Modelo B com CV F1 = 0.82 ± 0.02. Qual você escolheria para produção e por quê? O que o desvio padrão alto do Modelo A indica?
✓ Resposta:Escolheria o Modelo B (F1=0.82 ± 0.02) para produção.
O desvio padrão alto do Modelo A (±0.15) indica instabilidade severa: em alguns folds o modelo tem F1=0.70, em outros F1=1.00. Isso significa que a performance depende fortemente de quais dados estão no treino vs validação. Em produção, os dados mudam ao longo do tempo, e um modelo com alta variância pode degradar drasticamente em cenários ligeiramente diferentes dos que viu no treino.
O Modelo B é mais confiável: F1=0.82 ± 0.02 significa que ele consistentemente entrega performance entre 0.78 e 0.86, independentemente de qual subset de dados é usado para avaliação. Em produção, essa previsibilidade tem enorme valor — você sabe o que esperar.
A diferença de 0.03 no F1 médio (0.85 vs 0.82) não justifica a instabilidade do Modelo A. Em problemas de negócio reais, consistência e previsibilidade frequentemente superam pequenas diferenças de performance média.
-
Explique o que a curva de aprendizado revela e como você interpretaria cada um dos três padrões: treino alto/validação baixa com grande gap, ambas baixas e próximas, e ambas próximas e crescendo.
✓ Resposta:Padrão 1 — treino alto, validação baixa, grande gap: overfitting clássico. O modelo memorizou o treino. Soluções: regularização mais forte, reduzir complexidade do modelo, adicionar mais dados de treino. Se as duas curvas ainda estão convergindo (gap diminuindo com mais dados), coletar mais dados pode ajudar. Se as curvas estão paralelas (gap constante), mais dados não resolvem — precisa reduzir complexidade.
Padrão 2 — ambas baixas e próximas: underfitting. O modelo é simples demais para capturar os padrões nos dados. O gap pequeno indica que não é problema de variância, mas de viés. Soluções: usar modelo mais complexo, adicionar features mais informativas, ou reduzir regularização. Mais dados não ajudam nesse caso.
Padrão 3 — ambas próximas e crescendo juntas: bom comportamento. O modelo ainda está melhorando com mais dados e não há overfitting significativo. Se a performance final ainda não é satisfatória, coletar mais dados pode ajudar. Se as curvas convergiram (platearam), chegou no limite desse modelo com essas features — precisaria de features melhores ou modelo diferente.
-
Por que R² pode ser enganoso ao comparar modelos? Quando o R² ajustado é mais adequado? Dê um exemplo onde R²=0.95 pode ser resultado de overfitting.
✓ Resposta:R² pode ser enganoso porque mede apenas a proporção de variância explicada no conjunto avaliado, sem distinguir entre padrões reais e ruído memorizado.
Quando R² ajustado é mais adequado: ao comparar modelos com diferentes números de features. Adicionar features irrelevantes sempre aumenta o R² (mesmo que minimamente), pois qualquer feature adicional captura alguma variância aleatória do treino. O R² ajustado penaliza pela adição de features:
R²_aj = 1 - (1-R²)(n-1)/(n-p-1). Se adicionar uma feature não aumenta o R² ajustado, ela não está adicionando valor real.Exemplo de overfitting com R²=0.95: treinar uma regressão polinomial de grau 50 em 60 pontos. O modelo pode atingir R²=0.99 no treino mas R²=-2.0 no teste (pior que a média!). Isso acontece porque o polinômio de alto grau oscila violentamente entre os pontos de treino para se ajustar ao ruído. Reportar apenas o R² de treino daria uma impressão completamente falsa da qualidade do modelo.
-
Descreva o problema de data leakage na avaliação de modelos. Como o uso incorreto de cross-validation pode causar leakage? Qual é a forma correta de incluir pré-processamento (como normalização) em um pipeline de cross-validation?
✓ Resposta:Data leakage na avaliação ocorre quando informação do conjunto de teste, direta ou indiretamente, influencia o treinamento ou pré-processamento, fazendo o modelo parecer melhor do que é na prática.
Como cross-validation incorreto causa leakage: se você normaliza os dados antes de fazer cross-validation, está usando estatísticas (média, desvio) calculadas sobre todo o dataset — incluindo os dados dos folds de validação. Esses dados "vazam" para o pré-processamento.
# ERRADO — leakage: normaliza antes do CV scaler = StandardScaler() X_normalizado = scaler.fit_transform(X) # usa TODO o dataset scores = cross_val_score(modelo, X_normalizado, y, cv=5) # O scaler "viu" os dados de validação de cada fold durante o fit # CORRETO — sem leakage: normaliza dentro do CV from sklearn.pipeline import Pipeline pipeline = Pipeline([ ("scaler", StandardScaler()), # fit apenas no treino de cada fold ("modelo", RandomForestClassifier()) ]) scores = cross_val_score(pipeline, X, y, cv=5) # O Pipeline garante que o scaler é ajustado apenas nos dados de treino # de cada fold, nunca vendo os dados de validaçãoA forma correta é encapsular todo o pré-processamento em um Pipeline do Scikit-learn. Dentro do Pipeline, o
fitde cada etapa só ocorre nos dados de treino do fold atual, garantindo que os dados de validação nunca influenciam o pré-processamento.
Referências
- Scikit-learn — Model Evaluation
- Scikit-learn — Cross-Validation
- Scikit-learn — Learning Curve
- Scikit-learn — Validation Curve
- Real Python — Model Evaluation Metrics
- StatQuest — ROC and AUC
- StatQuest — Cross-Validation
- Towards Data Science — Beyond Accuracy: Precision and Recall
- Google ML Crash Course — Classification