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

  1. 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.

  2. 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.Pipeline no Python ou step_smote na recipe do tidymodels), de modo que ele seja reajustado apenas no treino de cada fold, sem tocar na validação.

  3. 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.

  4. 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).

  5. 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