Phase 02 - Lesson 17
Tratamento de dados desequilibrados
This lesson includes a graded coding exercise that runs in your browser, unlocked with lifetime access.
Quando 99% dos seus dados são “normais”, a precisão é uma mentira.
Tipo: Construir Idioma: Python Pré-requisitos: Fase 2, Lições 01-09 (especialmente métricas de avaliação) Tempo: ~90 minutos
Objetivos de aprendizagem
- Implemente SMOTE do zero e explique como a sobreamostragem sintética difere da duplicação aleatória
- Avalie classificadores desequilibrados usando F1, AUPRC e coeficiente de correlação de Matthews em vez de precisão
- Compare estratégias de ponderação de classe, ajuste de limite e reamostragem e selecione a abordagem correta para uma determinada proporção de desequilíbrio
- Construa um pipeline de dados desequilibrado completo que combine SMOTE, pesos de classe e otimização de limite
O problema
Você constrói um modelo de detecção de fraude. Obtém 99,9% de precisão. Você comemora. Então você percebe que ele prevê “não fraude” para cada transação.
Isso não é um bug. É a coisa racional a fazer quando apenas 0,1% das transações são fraudulentas. O modelo aprende que sempre adivinhar a classe majoritária minimiza o erro geral. É tecnicamente correto e completamente inútil.
Isso acontece em todos os lugares em que a classificação real é importante. Diagnóstico da doença: taxa positiva de 1%. Intrusão de rede: ataques de 0,01%. Defeitos de fabricação: 0,5% defeituosos. Filtragem de spam: 20% de spam. Previsão de rotatividade: 5% de desistências. Quanto mais importante for a classe minoritária, mais rara ela tende a ser.
A precisão falha porque trata todas as previsões corretas igualmente. Rotular corretamente uma transação legítima e detectar fraudes corretamente contam como um ponto de precisão. Mas detectar fraudes é a razão pela qual o modelo existe. Precisamos de métricas, técnicas e estratégias de treinamento que forcem o modelo a prestar atenção à classe rara, mas importante.
O Conceito
Por que a precisão falha
Considere um conjunto de dados com 1.000 amostras: 990 negativas, 10 positivas. Um modelo que sempre prevê negativo:
| Previsto Positivo | Negativo previsto | |
|---|---|---|
| Na verdade positivo | 0 (TP) | 10 (FN) |
| Na verdade negativo | 0 (PF) | 990 (TN) |
Precisão = (0 + 990) / 1000 = 99,0%
O modelo detecta zero fraudes. Doença zero. Zero defeitos. Mas a precisão diz 99%. É por isso que a precisão é perigosa para problemas desequilibrados.
Melhores métricas
Precisão = TP / (TP + FP). De tudo sinalizado como positivo, quantos realmente são? Alta precisão significa poucos alarmes falsos.
Recuperação = TP / (TP + FN). De tudo o que é realmente positivo, quantos pegamos? Alta recuperação significa poucos positivos perdidos.
F1 Pontuação = 2 * precisão * recuperação / (precisão + recuperação). A média harmônica. Penaliza o desequilíbrio extremo entre precisão e recuperação mais do que a média aritmética faria.
Pontuação F-beta = (1 + beta^2) * precisão * recuperação / (beta^2 * precisão + recuperação). Quando beta > 1, o recall é mais importante. Quando beta <1, a precisão é mais importante. F2 é comum na detecção de fraudes (uma fraude perdida é pior que um alarme falso).
AUPRC (Área sob curva de recuperação de precisão). Como AUC-ROC, mas mais informativo para dados desequilibrados. Um classificador aleatório tem AUPRC igual à taxa de classe positiva (não 0,5 como ROC). Isso torna as melhorias mais fáceis de ver.
Coeficiente de Correlação de Matthews = (TP * TN - FP * FN) / sqrt((TP+FP)(TP+FN)(TN+FP)(TN+FN)). Varia de -1 a +1. Só dá nota alta quando o modelo se sai bem em ambas as classes. Equilibrado mesmo quando as turmas têm tamanhos muito diferentes.
Para o modelo "sempre prever negativo" acima: precisão = 0/0 (indefinido, geralmente definido como 0), recall = 0/10 = 0, F1 = 0, MCC = 0. Essas métricas identificam corretamente o modelo como inútil.
Os dados desequilibrados Pipeline
flowchart TD
A[Imbalanced Dataset] --> B{Imbalance Ratio?}
B -->|Mild: 80/20| C[Class Weights]
B -->|Moderate: 95/5| D[SMOTE + Threshold Tuning]
B -->|Severe: 99/1| E[SMOTE + Class Weights + Threshold]
C --> F[Train Model]
D --> F
E --> F
F --> G[Evaluate with F1 / AUPRC / MCC]
G --> H{Good Enough?}
H -->|No| I[Try Different Strategy]
H -->|Yes| J[Deploy with Monitoring]
I --> B
SMOTE: Técnica de sobreamostragem minoritária sintética
A sobreamostragem aleatória duplica amostras minoritárias existentes. Isso funciona, mas corre o risco de overfitting porque o modelo vê pontos idênticos repetidamente.
SMOTE cria novas amostras minoritárias sintéticas que são plausíveis, mas não cópias. O algoritmo:
- Para cada amostra minoritária x, encontre seus k vizinhos mais próximos entre outras amostras minoritárias
- Escolha um vizinho aleatoriamente
- Crie uma nova amostra no segmento de linha entre x e aquele vizinho
A fórmula: new_sample = x + random(0, 1) * (neighbor - x)
Isso interpola entre pontos minoritários reais, criando amostras na mesma região do espaço de recursos sem apenas copiar os dados existentes.
flowchart LR
subgraph Original["Original Minority Points"]
P1["x1 (1.0, 2.0)"]
P2["x2 (1.5, 2.5)"]
P3["x3 (2.0, 1.5)"]
end
subgraph SMOTE["SMOTE Generation"]
direction TB
S1["Pick x1, neighbor x2"]
S2["random t = 0.4"]
S3["new = x1 + 0.4*(x2-x1)"]
S4["new = (1.2, 2.2)"]
S1 --> S2 --> S3 --> S4
end
Original --> SMOTE
subgraph Result["Augmented Set"]
R1["x1 (1.0, 2.0)"]
R2["x2 (1.5, 2.5)"]
R3["x3 (2.0, 1.5)"]
R4["synthetic (1.2, 2.2)"]
end
SMOTE --> Result
Estratégias de amostragem comparadas
Sobreamostragem aleatória: amostras minoritárias duplicadas para corresponder à contagem da maioria.
- Prós: simples, sem perda de informações
- Contras: duplicatas exatas causam overfitting, aumentam o tempo de treinamento
Subamostragem aleatória: remova as amostras majoritárias para corresponder à contagem minoritária.
- Prós: treinamento rápido, simples
- Contras: descarta dados majoritários potencialmente úteis, maior variância
SMOTE: cria amostras minoritárias sintéticas por meio de interpolação.
- Prós: gera novos pontos de dados, reduz o overfitting em comparação com a sobreamostragem aleatória
- Contras: pode criar amostras ruidosas perto do limite de decisão, não leva em conta a distribuição da classe majoritária
| Estratégia | Dados alterados | Risco | Quando usar |
|---|---|---|---|
| Sobreamostra | Minoria duplicada | Sobreajuste | Pequenos conjuntos de dados, desequilíbrio moderado |
| Subamostra | Maioria removida | Perda de informação | Grandes conjuntos de dados, querem treinamento rápido |
| SMOTE | Minoria sintética adicionada | Ruído limite | Desequilíbrio moderado, amostras minoritárias suficientes para k-NN |
Pesos de classe
Em vez de alterar os dados, mude a forma como o modelo trata os erros. Atribua peso maior à classificação incorreta da classe minoritária.
Para um problema binário com 950 amostras negativas e 50 positivas:
- Peso para classe negativa = n_samples / (2 * n_negative) = 1000 / (2 * 950) = 0,526
- Peso para classe positiva = n_amostras / (2 * n_positivo) = 1000 / (2 * 50) = 10,0
A classe positiva recebe 19x o peso. Classificar incorretamente uma amostra positiva custa tanto quanto classificar incorretamente 19 amostras negativas. O modelo é forçado a prestar atenção à classe minoritária.
Na regressão logística, isso modifica a função de perda:
weighted_loss = -sum(w_i * [y_i * log(p_i) + (1-y_i) * log(1-p_i)])
onde w_i depende da classe da amostra i.
Os pesos das classes são matematicamente equivalentes à sobreamostragem esperada, mas sem criar novos pontos de dados. Isso os torna mais rápidos e evita o risco de overfitting de amostras duplicadas.
Ajuste de limite
A maioria dos classificadores gera uma probabilidade. O limite padrão é 0,5: se P(positivo) >= 0,5, preveja positivo. Mas 0,5 é arbitrário. Quando as classes estão desequilibradas, o limite ideal é geralmente muito mais baixo.
O processo:
- Treine um modelo
- Obtenha probabilidades previstas no conjunto de validação
- Limiares de varredura de 0,0 a 1,0
- Calcule F1 (ou a métrica escolhida) em cada limite
- Escolha o limite que maximiza sua métrica
flowchart LR
A[Model] --> B[Predict Probabilities]
B --> C[Sweep Thresholds 0.0 to 1.0]
C --> D[Compute F1 at Each]
D --> E[Pick Best Threshold]
E --> F[Use in Production]
Um modelo pode gerar P(fraude) = 0,15 para uma transação fraudulenta. No limite 0,5, isso é classificado como não fraudulento. No limite 0,10, ele é capturado corretamente. A calibração da probabilidade importa menos do que a classificação – desde que a fraude obtenha probabilidades mais elevadas do que a não fraude, existe um limite que as separa.
Aprendizagem sensível ao custo
Generalização dos pesos das classes. Em vez de custos uniformes, atribua custos específicos de classificação incorreta:
| Prever Positivo | Prever Negativo | |
|---|---|---|
| Na verdade positivo | 0 (correto) | C_FN = 100 |
| Na verdade negativo | C_FP = 1 | 0 (correto) |
Perder uma transação fraudulenta (FN) custa 100 vezes mais do que um alarme falso (FP). O modelo otimiza o custo total, não a contagem total de erros.
Esta é a abordagem mais baseada em princípios quando você pode estimar os custos do mundo real. Um diagnóstico de câncer perdido tem um custo muito diferente de um alarme falso que leva a uma biópsia extra. Tornar estes custos explícitos força as compensações certas.
Fluxograma de decisão
flowchart TD
A[Start: Imbalanced Dataset] --> B{How imbalanced?}
B -->|"< 70/30"| C["Mild: try class weights first"]
B -->|"70/30 to 95/5"| D["Moderate: SMOTE + class weights"]
B -->|"> 95/5"| E["Severe: combine multiple strategies"]
C --> F{Enough data?}
D --> F
E --> F
F -->|"< 1000 samples"| G["Oversample or SMOTE, avoid undersampling"]
F -->|"1000-10000"| H["SMOTE + threshold tuning"]
F -->|"> 10000"| I["Undersampling OK, or class weights"]
G --> J[Train + Evaluate with F1/AUPRC]
H --> J
I --> J
J --> K{Recall high enough?}
K -->|No| L[Lower threshold]
K -->|Yes| M{Precision acceptable?}
M -->|No| N[Raise threshold or add features]
M -->|Yes| O[Ship it]
Construa
Etapa 1: gerar um conjunto de dados desequilibrado
import numpy as np
def make_imbalanced_data(n_majority=950, n_minority=50, seed=42):
rng = np.random.RandomState(seed)
X_maj = rng.randn(n_majority, 2) * 1.0 + np.array([0.0, 0.0])
X_min = rng.randn(n_minority, 2) * 0.8 + np.array([2.5, 2.5])
X = np.vstack([X_maj, X_min])
y = np.concatenate([np.zeros(n_majority), np.ones(n_minority)])
shuffle_idx = rng.permutation(len(y))
return X[shuffle_idx], y[shuffle_idx]
Etapa 2: SMOTE do zero
def euclidean_distance(a, b):
return np.sqrt(np.sum((a - b) ** 2))
def find_k_neighbors(X, idx, k):
distances = []
for i in range(len(X)):
if i == idx:
continue
d = euclidean_distance(X[idx], X[i])
distances.append((i, d))
distances.sort(key=lambda x: x[1])
return [d[0] for d in distances[:k]]
def smote(X_minority, k=5, n_synthetic=100, seed=42):
rng = np.random.RandomState(seed)
n_samples = len(X_minority)
k = min(k, n_samples - 1)
synthetic = []
for _ in range(n_synthetic):
idx = rng.randint(0, n_samples)
neighbors = find_k_neighbors(X_minority, idx, k)
neighbor_idx = neighbors[rng.randint(0, len(neighbors))]
t = rng.random()
new_point = X_minority[idx] + t * (X_minority[neighbor_idx] - X_minority[idx])
synthetic.append(new_point)
return np.array(synthetic)
Etapa 3: sobreamostragem e subamostragem aleatória
def random_oversample(X, y, seed=42):
rng = np.random.RandomState(seed)
classes, counts = np.unique(y, return_counts=True)
max_count = counts.max()
X_resampled = list(X)
y_resampled = list(y)
for cls, count in zip(classes, counts):
if count < max_count:
cls_indices = np.where(y == cls)[0]
n_needed = max_count - count
chosen = rng.choice(cls_indices, size=n_needed, replace=True)
X_resampled.extend(X[chosen])
y_resampled.extend(y[chosen])
X_out = np.array(X_resampled)
y_out = np.array(y_resampled)
shuffle = rng.permutation(len(y_out))
return X_out[shuffle], y_out[shuffle]
def random_undersample(X, y, seed=42):
rng = np.random.RandomState(seed)
classes, counts = np.unique(y, return_counts=True)
min_count = counts.min()
X_resampled = []
y_resampled = []
for cls in classes:
cls_indices = np.where(y == cls)[0]
chosen = rng.choice(cls_indices, size=min_count, replace=False)
X_resampled.extend(X[chosen])
y_resampled.extend(y[chosen])
X_out = np.array(X_resampled)
y_out = np.array(y_resampled)
shuffle = rng.permutation(len(y_out))
return X_out[shuffle], y_out[shuffle]
Etapa 4: Regressão logística com pesos de classe
def sigmoid(z):
return 1.0 / (1.0 + np.exp(-np.clip(z, -500, 500)))
def logistic_regression_weighted(X, y, weights, lr=0.01, epochs=200):
n_samples, n_features = X.shape
w = np.zeros(n_features)
b = 0.0
for _ in range(epochs):
z = X @ w + b
pred = sigmoid(z)
error = pred - y
weighted_error = error * weights
gradient_w = (X.T @ weighted_error) / n_samples
gradient_b = np.mean(weighted_error)
w -= lr * gradient_w
b -= lr * gradient_b
return w, b
def compute_class_weights(y):
classes, counts = np.unique(y, return_counts=True)
n_samples = len(y)
n_classes = len(classes)
weight_map = {}
for cls, count in zip(classes, counts):
weight_map[cls] = n_samples / (n_classes * count)
return np.array([weight_map[yi] for yi in y])
Etapa 5: ajuste de limite
def find_optimal_threshold(y_true, y_probs, metric="f1"):
best_threshold = 0.5
best_score = -1.0
for threshold in np.arange(0.05, 0.96, 0.01):
y_pred = (y_probs >= threshold).astype(int)
tp = np.sum((y_pred == 1) & (y_true == 1))
fp = np.sum((y_pred == 1) & (y_true == 0))
fn = np.sum((y_pred == 0) & (y_true == 1))
if metric == "f1":
precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
score = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0.0
elif metric == "recall":
score = tp / (tp + fn) if (tp + fn) > 0 else 0.0
elif metric == "precision":
score = tp / (tp + fp) if (tp + fp) > 0 else 0.0
if score > best_score:
best_score = score
best_threshold = threshold
return best_threshold, best_score
Etapa 6: Funções de avaliação
def confusion_matrix_values(y_true, y_pred):
tp = np.sum((y_pred == 1) & (y_true == 1))
tn = np.sum((y_pred == 0) & (y_true == 0))
fp = np.sum((y_pred == 1) & (y_true == 0))
fn = np.sum((y_pred == 0) & (y_true == 1))
return tp, tn, fp, fn
def compute_metrics(y_true, y_pred):
tp, tn, fp, fn = confusion_matrix_values(y_true, y_pred)
accuracy = (tp + tn) / (tp + tn + fp + fn)
precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0.0
denom = np.sqrt(float((tp + fp) * (tp + fn) * (tn + fp) * (tn + fn)))
mcc = (tp * tn - fp * fn) / denom if denom > 0 else 0.0
return {
"accuracy": accuracy,
"precision": precision,
"recall": recall,
"f1": f1,
"mcc": mcc,
}
Etapa 7: Compare todas as abordagens
X, y = make_imbalanced_data(950, 50, seed=42)
split = int(0.8 * len(y))
X_train, X_test = X[:split], X[split:]
y_train, y_test = y[:split], y[split:]
# Baseline: no treatment
w_base, b_base = logistic_regression_weighted(
X_train, y_train, np.ones(len(y_train)), lr=0.1, epochs=300
)
probs_base = sigmoid(X_test @ w_base + b_base)
preds_base = (probs_base >= 0.5).astype(int)
# Oversampled
X_over, y_over = random_oversample(X_train, y_train)
w_over, b_over = logistic_regression_weighted(
X_over, y_over, np.ones(len(y_over)), lr=0.1, epochs=300
)
preds_over = (sigmoid(X_test @ w_over + b_over) >= 0.5).astype(int)
# SMOTE
minority_mask = y_train == 1
X_minority = X_train[minority_mask]
synthetic = smote(X_minority, k=5, n_synthetic=len(y_train) - 2 * int(minority_mask.sum()))
X_smote = np.vstack([X_train, synthetic])
y_smote = np.concatenate([y_train, np.ones(len(synthetic))])
w_sm, b_sm = logistic_regression_weighted(
X_smote, y_smote, np.ones(len(y_smote)), lr=0.1, epochs=300
)
preds_smote = (sigmoid(X_test @ w_sm + b_sm) >= 0.5).astype(int)
# Class weights
sample_weights = compute_class_weights(y_train)
w_cw, b_cw = logistic_regression_weighted(
X_train, y_train, sample_weights, lr=0.1, epochs=300
)
probs_cw = sigmoid(X_test @ w_cw + b_cw)
preds_cw = (probs_cw >= 0.5).astype(int)
# Threshold tuning (tune on held-out validation set, not test set)
probs_val = sigmoid(X_val @ w_cw + b_cw)
best_thresh, best_f1 = find_optimal_threshold(y_val, probs_val, metric="f1")
preds_thresh = (probs_cw >= best_thresh).astype(int)
O arquivo de código executa tudo isso em um único script e imprime os resultados.
Use-o
Com scikit-learn e aprendizagem desequilibrada, essas técnicas são de uma linha:
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, f1_score
from sklearn.model_selection import train_test_split
from imblearn.over_sampling import SMOTE
from imblearn.under_sampling import RandomUnderSampler
from imblearn.pipeline import Pipeline
X_train, X_test, y_train, y_test = train_test_split(X, y, stratify=y)
model_weighted = LogisticRegression(class_weight="balanced")
model_weighted.fit(X_train, y_train)
print(classification_report(y_test, model_weighted.predict(X_test)))
smote = SMOTE(random_state=42)
X_resampled, y_resampled = smote.fit_resample(X_train, y_train)
model_smote = LogisticRegression()
model_smote.fit(X_resampled, y_resampled)
print(classification_report(y_test, model_smote.predict(X_test)))
pipeline = Pipeline([
("smote", SMOTE()),
("model", LogisticRegression(class_weight="balanced")),
])
pipeline.fit(X_train, y_train)
print(classification_report(y_test, pipeline.predict(X_test)))
As implementações do zero mostram exatamente o que cada técnica faz. SMOTE é apenas interpolação k-NN na classe minoritária. Os pesos das classes multiplicam a perda. A afinação de limite é um loop for sobre cortes. Sem mágica.
Envie
Esta lição produz:
outputs/skill-imbalanced-data.md-- uma lista de verificação de decisão para lidar com problemas de classificação desequilibrada
Exercícios
Borderline-SMOTE: modifique a implementação SMOTE para gerar apenas amostras sintéticas para pontos minoritários que estão próximos do limite de decisão (aqueles cujos k-vizinhos mais próximos incluem amostras de classe majoritária). Compare os resultados com o padrão SMOTE em um conjunto de dados onde as classes se sobrepõem.
Otimização da matriz de custos: implemente aprendizagem sensível ao custo onde a matriz de custos seja um parâmetro. Crie uma função que receba uma matriz de custos e retorne previsões ideais que minimizem o custo esperado. Teste com diferentes taxas de custo (1:10, 1:100, 1:1000) e represente graficamente como a compensação entre recall de precisão muda.
Calibração de limite: implementar a escala Platt (ajustar uma regressão logística nos resultados brutos do modelo para produzir probabilidades calibradas). Compare a curva de recuperação de precisão antes e depois da calibração. Mostre que a calibração não altera a classificação (AUC permanece a mesma), mas torna as probabilidades mais significativas.
Conjunto com bagging balanceado: treine vários modelos, cada um em uma amostra de bootstrap balanceada (todos minoritários + subconjunto aleatório de maioria). Média de suas previsões. Compare esta abordagem com um único modelo com SMOTE. Meça o desempenho e a variação entre execuções.
Experiência de taxa de desequilíbrio: pegue um conjunto de dados balanceado e aumente progressivamente a taxa de desequilíbrio (50/50, 70/30, 90/10, 95/5, 99/1). Para cada proporção, treine com e sem SMOTE. Gráfico F1 vs proporção de desequilíbrio para ambas as abordagens. Em que proporção SMOTE começa a fazer uma diferença significativa?
Termos-chave
| Prazo | O que as pessoas dizem | O que isso realmente significa |
|---|---|---|
| Desequilíbrio de classe | “Uma classe tem muito mais amostras” | A distribuição de classes no conjunto de dados é significativamente distorcida, fazendo com que os modelos favoreçam a classe majoritária |
| SMOTE | "Sobreamostragem sintética" | Cria novas amostras minoritárias interpolando entre amostras minoritárias existentes e seus k-vizinhos minoritários mais próximos |
| Pesos de classe | “Tornando os erros em classes raras mais caros” | Multiplicação da função de perda por pesos específicos da classe para que o modelo penalize mais fortemente a classificação incorreta das minorias |
| Ajuste de limite | “Movendo a fronteira da decisão” | Alteração do limite de probabilidade para classificação do padrão 0,5 para um valor que otimize a métrica desejada |
| Compensação de recall de precisão | “Você não pode ter os dois” | A redução do limite detecta mais positivos (maior recall), mas também sinaliza mais falsos positivos (menor precisão) e vice-versa |
| AUPRC | “Área sob a curva PR” | Resume a curva de recuperação de precisão em um único número; mais informativo que AUC-ROC quando as classes estão fortemente desequilibradas |
| Coeficiente de Correlação de Matthews | “A métrica equilibrada” | Uma correlação entre rótulos previstos e reais que produz uma pontuação alta somente quando o modelo tem um bom desempenho em ambas as classes |
| Aprendizagem sensível aos custos | “Erros diferentes custam valores diferentes” | Incorporação de custos reais de classificação incorreta no objetivo de treinamento para que o modelo otimize o custo total e não a contagem de erros |
| Sobreamostragem aleatória | “Duplicar a minoria” | Repetição de amostras de classes minoritárias para equilibrar a contagem de classes; simples, mas corre o risco de overfitting para pontos duplicados |
Leitura Adicional
- SMOTE: Técnica de sobreamostragem de minoria sintética (Chawla et al., 2002) - o artigo original SMOTE, ainda o trabalho mais citado sobre aprendizagem desequilibrada
- Aprendendo com dados desequilibrados (He & Garcia, 2009) - pesquisa abrangente cobrindo amostragem, abordagens sensíveis ao custo e algorítmicas
- documentação de aprendizagem desequilibrada -- Biblioteca Python com SMOTE variantes, estratégias de subamostragem e integração de pipeline
- O gráfico de recuperação de precisão é mais informativo que o gráfico ROC (Saito & Rehmsmeier, 2015) - quando e por que preferir curvas PR em vez de curvas ROC para problemas desequilibrados