Classificação introdutória termina em "treine, preveja a classe, meça a acurácia". A realidade de produção é muito mais áspera: as classes vêm desbalanceadas (fraude, doença rara, churn), cada erro tem um custo diferente (deixar passar um câncer ≠ um falso alarme), às vezes um exemplo pertence a várias classes ao mesmo tempo (um artigo é sobre "política" e "economia"), e as probabilidades que o modelo cospe muitas vezes são mentiras — dizem 0.9 quando a frequência real é 0.6. Esta aula ataca cada um desses problemas.
Dominar classificação em cenários realistas: métricas além da acurácia, técnicas para classes desbalanceadas (reamostragem, pesos, threshold tuning), classificação multirrótulo e multiclasse, calibração de probabilidades e decisão sensível a custo.
Onde a acurácia falha e o que usar no lugar
Considere fraude com 0,3% de positivos. Prever sempre "não-fraude" dá 99,7% de acurácia e zero utilidade. Precisamos de métricas que enxerguem a classe minoritária.
A matriz de confusão é a base de tudo:
Predito Negativo Predito Positivo
Real Negativo TN FP
Real Positivo FN TP
Dela derivam:
- Precisão = TP / (TP + FP) — "dos que apontei como positivos, quantos eram?" (custo de falso alarme)
- Recall / Sensibilidade = TP / (TP + FN) — "dos positivos reais, quantos peguei?" (custo de deixar passar)
- F1 = média harmônica de precisão e recall — equilíbrio entre os dois
- Especificidade = TN / (TN + FP) — recall da classe negativa
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (classification_report, confusion_matrix,
roc_auc_score, average_precision_score)
X, y = make_classification(n_samples=5000, n_features=20, weights=[0.97, 0.03],
n_informative=8, random_state=42)
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.3,
stratify=y, random_state=42)
clf = LogisticRegression(max_iter=1000).fit(X_tr, y_tr)
proba = clf.predict_proba(X_te)[:, 1]
pred = (proba >= 0.5).astype(int)
print(confusion_matrix(y_te, pred))
print(classification_report(y_te, pred, digits=3))
print(f"ROC-AUC: {roc_auc_score(y_te, proba):.3f}")
print(f"PR-AUC: {average_precision_score(y_te, proba):.3f}")
ROC-AUC vs. PR-AUC: qual usar em dados desbalanceados
- ROC-AUC mede a capacidade de ranquear positivos acima de negativos, variando o threshold. É insensível ao desbalanceamento — o que soa bom, mas é enganoso: em bases muito desbalanceadas, a ROC parece otimista porque a taxa de falso-positivo tem um denominador (negativos) enorme.
- PR-AUC (área sob precisão×recall) foca na classe positiva e é muito mais informativa quando os positivos são raros.
Regra prática: em problemas com forte desbalanceamento (fraude, doença rara), reporte PR-AUC como métrica principal, com ROC-AUC como secundária.
Estratégias para classes desbalanceadas
Há três famílias de soluções. Não são excludentes.
Pesos de classe (a primeira coisa a tentar)
Penaliza mais o erro na classe minoritária, sem tocar nos dados. Barato e sem risco de vazamento.
from sklearn.ensemble import RandomForestClassifier
clf = RandomForestClassifier(n_estimators=300, class_weight="balanced",
random_state=42).fit(X_tr, y_tr)
class_weight="balanced" ajusta os pesos inversamente à frequência das classes. Funciona em regressão logística, SVM, árvores e florestas.
Reamostragem (oversampling / undersampling)
Feita com a biblioteca imbalanced-learn. Ponto crítico: a reamostragem só pode ser aplicada ao treino de cada fold, nunca ao dataset inteiro antes da CV — caso contrário há vazamento. Use o Pipeline do imblearn, que garante isso.
from imblearn.over_sampling import SMOTE
from imblearn.pipeline import Pipeline as ImbPipeline
from sklearn.model_selection import cross_val_score, StratifiedKFold
# SMOTE cria exemplos sintéticos da classe minoritária por interpolação
pipe = ImbPipeline([
("smote", SMOTE(random_state=42)),
("clf", RandomForestClassifier(n_estimators=300, random_state=42)),
])
cv = StratifiedKFold(5, shuffle=True, random_state=42)
scores = cross_val_score(pipe, X, y, cv=cv, scoring="average_precision")
print(f"PR-AUC: {scores.mean():.3f} ± {scores.std():.3f}")
- SMOTE interpola entre vizinhos da classe minoritária (não só duplica). Variantes: BorderlineSMOTE, ADASYN.
- Undersampling (ex.:
RandomUnderSampler,TomekLinks) remove negativos — útil quando há dados demais. - Combinado (
SMOTETomek,SMOTEENN) faz oversampling e limpa fronteiras ruidosas.
Aviso: SMOTE pode piorar em espaços de alta dimensão ou com muito ruído. Nem sempre supera class_weight; teste ambos.
Ajuste de limiar (threshold tuning)
O threshold padrão de 0.5 raramente é ótimo em dados desbalanceados. Escolha-o pela métrica que importa — usando um conjunto de validação, nunca o teste.
import numpy as np
from sklearn.metrics import f1_score, precision_recall_curve
# Escolher threshold que maximiza F1 na validação
prec, rec, thr = precision_recall_curve(y_te, proba)
f1s = 2 * prec * rec / (prec + rec + 1e-9)
melhor = thr[np.argmax(f1s[:-1])]
print(f"Melhor threshold (F1): {melhor:.3f}")
pred_ajustada = (proba >= melhor).astype(int)
print(f"F1 @0.5: {f1_score(y_te, (proba>=0.5).astype(int)):.3f}")
print(f"F1 @opt: {f1_score(y_te, pred_ajustada):.3f}")
Decisão sensível a custo
Falsos positivos e falsos negativos raramente custam o mesmo. Se um FN (deixar passar uma fraude) custa R$1000 e um FP (bloquear cliente legítimo) custa R$10, você deve escolher o threshold que minimiza o custo esperado, não que maximiza F1.
import numpy as np
custo_fp, custo_fn = 10, 1000
thresholds = np.linspace(0.01, 0.99, 99)
custos = []
for t in thresholds:
pred = (proba >= t).astype(int)
fp = np.sum((pred == 1) & (y_te == 0))
fn = np.sum((pred == 0) & (y_te == 1))
custos.append(fp * custo_fp + fn * custo_fn)
t_otimo = thresholds[np.argmin(custos)]
print(f"Threshold de menor custo: {t_otimo:.2f}")
print(f"Custo mínimo: R$ {min(custos):,.0f}")
Este é o elo entre a métrica técnica e o objetivo de negócio. Sempre que possível, traduza os erros em custo monetário e otimize isso diretamente.
Calibração de probabilidades
Um modelo é calibrado se, entre os exemplos aos quais atribui probabilidade 0.8, cerca de 80% são de fato positivos. Random forests e SVMs costumam ser mal calibrados; boosting tende a ser superconfiante. Se você usa a probabilidade para tomar decisões (preço de seguro, priorização), a calibração importa tanto quanto o ranqueamento.
from sklearn.calibration import CalibratedClassifierCV, calibration_curve
import matplotlib.pyplot as plt
base = RandomForestClassifier(n_estimators=300, random_state=42)
base.fit(X_tr, y_tr)
# Calibração: Platt (sigmoid) ou isotônica
calibrado = CalibratedClassifierCV(base, method="isotonic", cv=5)
calibrado.fit(X_tr, y_tr)
for nome, modelo in [("Original", base), ("Calibrado", calibrado)]:
p = modelo.predict_proba(X_te)[:, 1]
frac_pos, media_pred = calibration_curve(y_te, p, n_bins=10)
plt.plot(media_pred, frac_pos, marker="o", label=nome)
plt.plot([0, 1], [0, 1], "k--", label="Perfeito")
plt.xlabel("Probabilidade prevista"); plt.ylabel("Fração real de positivos")
plt.legend(); plt.title("Curva de calibração")
- Platt scaling / sigmoid: ajusta uma sigmoide; bom com poucos dados.
- Isotônica: não-paramétrica e mais flexível; precisa de mais dados, risco de overfit em bases pequenas.
- Meça calibração com Brier score (quanto menor, melhor) e ECE (Expected Calibration Error).
Classificação multiclasse
Com mais de duas classes, há duas estratégias de decomposição (muitos modelos fazem isso internamente):
- One-vs-Rest (OvR): um classificador binário por classe. K classes → K modelos.
- One-vs-One (OvO): um classificador por par de classes. K classes → K(K−1)/2 modelos.
from sklearn.datasets import load_iris
from sklearn.multiclass import OneVsRestClassifier
from sklearn.svm import SVC
from sklearn.metrics import classification_report
Xi, yi = load_iris(return_X_y=True)
Xi_tr, Xi_te, yi_tr, yi_te = train_test_split(Xi, yi, test_size=0.3,
stratify=yi, random_state=42)
ovr = OneVsRestClassifier(SVC(probability=True)).fit(Xi_tr, yi_tr)
print(classification_report(yi_te, ovr.predict(Xi_te)))
Para métricas multiclasse, atenção ao tipo de média:
- macro: média simples entre classes (cada classe pesa igual — bom quando classes raras importam).
- weighted: média ponderada pela frequência (dominada pelas classes grandes).
- micro: agrega TP/FP/FN globais (equivale à acurácia em multiclasse de rótulo único).
Classificação multirrótulo
Diferente de multiclasse: aqui cada exemplo pode ter vários rótulos simultâneos (um filme é "ação" e "comédia"). O alvo é uma matriz binária.
from sklearn.datasets import make_multilabel_classification
from sklearn.multioutput import MultiOutputClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import hamming_loss, f1_score
X, Y = make_multilabel_classification(n_samples=2000, n_features=20,
n_classes=5, n_labels=2, random_state=42)
X_tr, X_te, Y_tr, Y_te = train_test_split(X, Y, test_size=0.3, random_state=42)
# Binary Relevance: um classificador independente por rótulo
modelo = MultiOutputClassifier(RandomForestClassifier(n_estimators=200,
random_state=42))
modelo.fit(X_tr, Y_tr)
Y_pred = modelo.predict(X_te)
print(f"Hamming loss: {hamming_loss(Y_te, Y_pred):.3f}") # menor é melhor
print(f"F1 micro: {f1_score(Y_te, Y_pred, average='micro'):.3f}")
print(f"F1 macro: {f1_score(Y_te, Y_pred, average='macro'):.3f}")
Abordagens de multirrótulo:
- Binary Relevance: um modelo por rótulo, independentes. Simples, mas ignora correlações entre rótulos.
- Classifier Chains: os modelos são encadeados; cada um vê as predições dos anteriores, capturando dependências entre rótulos.
- Label Powerset: trata cada combinação de rótulos como uma classe única. Explode combinatorialmente.
Métricas específicas: Hamming loss (fração de rótulos errados), subset accuracy (fração de exemplos com todos os rótulos certos — bem exigente), F1 micro/macro por rótulo.
O mesmo em R (tidymodels)
library(tidymodels)
library(themis) # passos de balanceamento para recipes
# Recipe com SMOTE aplicado só no treino de cada fold (sem vazamento)
receita <- recipe(classe ~ ., data = treino) |>
step_normalize(all_numeric_predictors()) |>
step_smote(classe) # balanceia a classe minoritária
modelo <- rand_forest(trees = 300) |>
set_engine("ranger") |>
set_mode("classification")
wf <- workflow() |> add_recipe(receita) |> add_model(modelo)
set.seed(42)
folds <- vfold_cv(treino, v = 5, strata = classe)
res <- fit_resamples(wf, folds,
metrics = metric_set(pr_auc, roc_auc, f_meas))
collect_metrics(res)
O pacote themis fornece step_smote, step_downsample, step_upsample — todos dentro da recipe, portanto seguros contra vazamento.
A acurácia engana em dados desbalanceados; quem fala a verdade ali é precisão, recall, F1 e sobretudo a PR-AUC, melhor que a ROC-AUC quando os positivos são raros. Para o desbalanceamento em si, comece pelo class_weight, e só depois teste reamostragem (SMOTE e variantes) e ajuste de limiar — com a reamostragem aplicada apenas no treino de cada fold, nunca antes da divisão. Quando a decisão tem custo, o limiar certo é o que minimiza o custo esperado, o que exige traduzir cada tipo de erro em valor de negócio. Vale lembrar que modelos costumam sair mal calibrados: corrija com Platt ou isotônica e meça com Brier ou ECE. No multiclasse entram OvR e OvO, com atenção ao tipo de média — macro, weighted e micro contam histórias diferentes. E no multirrótulo, onde um exemplo carrega vários rótulos, as opções são Binary Relevance, Classifier Chains e Label Powerset, medidas por Hamming loss e subset accuracy.
Exercícios
-
Em um problema com 1% de positivos, o modelo A tem ROC-AUC 0.92 e o modelo B tem ROC-AUC 0.90, mas B tem PR-AUC 0.55 contra 0.40 de A. Qual você prefere e por quê?
✓ Resposta:Provavelmente B. Com apenas 1% de positivos, a PR-AUC é a métrica mais informativa, porque foca diretamente na qualidade das predições da classe positiva (rara). A ROC-AUC é inflada nesse regime porque a taxa de falso-positivo tem um denominador enorme (muitos negativos), fazendo diferenças parecerem pequenas. B ranqueia melhor os positivos raros na região que interessa (alta precisão e recall da classe minoritária), então tende a ser mais útil na prática, apesar da ROC-AUC ligeiramente menor.
-
Por que aplicar SMOTE no dataset inteiro antes de rodar validação cruzada causa vazamento, e qual é a forma correta?
✓ Resposta:SMOTE cria exemplos sintéticos interpolando vizinhos da classe minoritária. Se aplicado antes da CV, exemplos sintéticos gerados a partir de amostras que cairão no fold de validação "contaminam" o treino (e vice-versa): há amostras de validação que influenciaram os dados de treino, superestimando o desempenho. A forma correta é colocar o SMOTE dentro de um pipeline (
imblearn.Pipelineno Python oustep_smotena recipe do tidymodels), de modo que ele seja reajustado apenas no treino de cada fold, sem tocar na validação. -
Um FN custa 50× mais que um FP. O threshold padrão de 0.5 é apropriado? O que fazer?
✓ Resposta:Não. Com FN muito mais caro, queremos capturar mais positivos (aumentar o recall), o que significa abaixar o threshold para classificar mais exemplos como positivos. O procedimento correto é definir a função de custo esperado —
custo = FP·custo_fp + FN·custo_fn— e varrer os thresholds em um conjunto de validação para encontrar o que minimiza o custo total (não o que maximiza F1 ou acurácia). Esse threshold ótimo estará bem abaixo de 0.5 dado o custo assimétrico. -
Qual a diferença entre classificação multiclasse e multirrótulo? Dê um exemplo de cada.
✓ Resposta:Em multiclasse, cada exemplo pertence a exatamente uma de K classes mutuamente exclusivas (ex.: classificar uma flor como setosa, versicolor ou virginica — é uma só). Em multirrótulo, cada exemplo pode ter vários rótulos simultâneos de um conjunto (ex.: marcar um artigo de notícia como "política" e "economia" ao mesmo tempo). Tecnicamente, multiclasse tem um alvo categórico único; multirrótulo tem um alvo vetorial binário (uma indicadora por rótulo). Isso muda os modelos (Binary Relevance, Classifier Chains) e as métricas (Hamming loss, subset accuracy).
-
O que significa dizer que um modelo é "mal calibrado", e por que isso pode ser um problema mesmo quando o ROC-AUC é alto?
✓ Resposta:Um modelo mal calibrado produz probabilidades que não correspondem às frequências reais: dentre os casos a que ele atribui 0.8, talvez só 60% sejam realmente positivos. O ROC-AUC mede apenas a capacidade de ranquear (ordenar positivos acima de negativos) e é invariante a transformações monotônicas das probabilidades — logo, um modelo pode ter AUC alto e ainda assim probabilidades erradas em valor absoluto. Isso é um problema quando a probabilidade em si é usada para decisões (precificação de risco, valor esperado, priorização por limiar de probabilidade). Corrige-se com calibração (Platt/isotônica) e mede-se com Brier score e ECE.
Fontes e leituras recomendadas
- imbalanced-learn — documentação
- scikit-learn — Probability calibration
- scikit-learn — Multiclass and multioutput
- Chawla et al. (2002) — SMOTE: Synthetic Minority Over-sampling Technique
- Saito & Rehmsmeier (2015) — The Precision-Recall Plot Is More Informative than the ROC Plot in Imbalanced Datasets
- themis — balanceamento em recipes (R)