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:

  1. Para cada amostra minoritária x, encontre seus k vizinhos mais próximos entre outras amostras minoritárias
  2. Escolha um vizinho aleatoriamente
  3. 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:

  1. Treine um modelo
  2. Obtenha probabilidades previstas no conjunto de validação
  3. Limiares de varredura de 0,0 a 1,0
  4. Calcule F1 (ou a métrica escolhida) em cada limite
  5. 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

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

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

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

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

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

0 lifetime access. Curriculum based on AI Engineering from Scratch by Rohit Ghumare (MIT, used under attribution).