Detecção de Fraude: Quando 99.8% de Acurácia Não Significa Nada
Aula 12, e o dataset (o Credit Card Fraud Detection do Kaggle, transações reais e anonimizadas de cartão de crédito europeu) é grande demais e protegido demais pra eu conseguir baixar e reproduzir sozinho nesse ambiente, sem conta no Kaggle. Esse post fica em cima dos números que o próprio notebook já rodou (executado de verdade, saídas reais), e a seção de Aplicação Prática reconstrói o achado mais importante da aula num dataset sintético que eu consigo gerar e conferir na hora.
O dataset: 492 fraudes em quase 285 mil transações
df = pd.read_csv('creditcard.csv')
print(df['Class'].value_counts())
Saída: classe 0 (transação normal): 284315. Classe 1 (fraude): 492.
Isso é 0.17% de fraude. As variáveis de entrada já vêm transformadas por PCA (V1 a V28, sem nome original, por privacidade do banco), mais Time e Amount sem transformar. Esse desbalanceamento extremo é o assunto do post inteiro.
O baseline que expõe a mentira da acurácia
class ZeroR(BaseEstimator, TransformerMixin):
def fit(self, X, y):
self.most_frequent_class_ = y.value_counts().idxmax()
return self
def transform(self, X):
return [self.most_frequent_class_] * len(X)
model = ZeroR()
model.fit(X_train, y_train)
print(accuracy_score(y_test, model.transform(X_test)))
Saída:
0.9983. Chutando "não é fraude" sempre, sem olhar pra nenhuma variável, o modelo mais idiota possível acerta 99.83%.
Esse é o baseline mais extremo que já apareceu nessa playlist (o Car Evaluation, no post de árvores, tinha 70%, aqui são quase 100%). Qualquer notícia de "modelo de fraude com 99% de acurácia" precisa dessa régua do lado, porque sem ela o número não diz nada.
Um modelo de verdade, e duas curvas melhores que acurácia
model = LogisticRegression(tol=0.005)
model.fit(X_train, y_train)
Saída: matriz de confusão
[[56832, 32], [26, 72]]. Precisão 0.69, revocação 0.73, F1 0.71 (pra classe fraude). ROC AUC 0.867. AUC da curva precisão-revocação: 0.613.
Repara que eu nem cito acurácia aqui, ela ia esconder tudo (98 fraudes entre quase 57 mil transações, então qualquer modelo razoável já bate 99.8%+ de acurácia). Precisão e revocação, sim, contam a história certa: das transações que o modelo marcou como fraude, 69% eram fraude de verdade. Das fraudes reais, o modelo pegou 73%.
E entre as duas curvas, a curva precisão-revocação (AUC 0.613) é mais honesta que a curva ROC (AUC 0.867) pra esse tipo de problema. A curva ROC usa a taxa de falso positivo no eixo, que é falsos positivos / total de negativos, e com quase 57 mil negativos, até um punhado de falsos positivos vira uma fração minúscula, a curva parece ótima quase à toa. A curva precisão-revocação, em compensação, usa precisão no eixo, que é verdadeiros positivos / (verdadeiros positivos + falsos positivos), direto sensível a quantos falsos positivos existem comparado às poucas fraudes reais, sem se diluir no mar de negativos.
Resampling: o remédio que piora as coisas
A ideia mais comum pra lidar com desbalanceamento é reamostrar o treino, de um jeito ou de outro, pra equilibrar as classes:
| Técnica | Como funciona | Precisão | Revocação | F1 |
|---|---|---|---|---|
| Nenhuma (baseline) | - | 0.69 | 0.73 | 0.71 |
RandomOverSampler | duplica exemplos da classe minoritária | 0.04 | 0.92 | 0.08 |
RandomUnderSampler | descarta exemplos da classe majoritária | 0.03 | 0.92 | 0.06 |
SMOTE | cria exemplos sintéticos interpolando vizinhos da minoria | 0.07 | 0.91 | 0.12 |
NearMiss | descarta exemplos majoritários perto da fronteira | 0.03 | 0.90 | 0.05 |
Nas quatro técnicas, a revocação sobe (de 0.73 pra ~0.90), o modelo passa a pegar mais fraudes de verdade. Mas a precisão despenca (de 0.69 pra 0.03-0.07), o modelo passa a gritar "fraude!" pra um monte de transação normal também. O F1 (que equilibra as duas) piora bastante em todos os quatro casos. Reamostrar não é bala de prata, é uma troca, e nesse dataset específico a troca sai perdendo.
O motivo da precisão desabar tão feio é uma mistura simples de duas coisas. Primeiro, o reamostramento só mexe no treino: o modelo aprende num mundo artificialmente equilibrado (perto de 50/50), mas continua sendo testado no mundo real, onde fraude é 0.17% das transações. Segundo, com tanta transação normal no teste (quase 57 mil), até uma taxinha pequena de erro nelas vira um monte de caso em número absoluto: se o modelo, calibrado pra um mundo onde fraude é comum, passa a "desconfiar" de qualquer coisa que se pareça um pouco com fraude, mesmo uma taxa de falso positivo de 1-2% em cima de 57 mil transações normais já gera centenas de alarmes falsos, muito mais do que as poucas dezenas de fraudes reais que existem pra acertar. É essa desproporção entre "quantos normais existem" e "quantos falsos positivos o modelo agora comete" que faz a precisão desabar.
O jeito errado de reamostrar (e por que ele engana)
O notebook tem uma seção com o título mais direto da matéria inteira: "Abordagem ERRADA - Não fazer assim!".
smote = SMOTE(random_state=42)
X_resampled, y_resampled = smote.fit_resample(X, y) # reamostra ANTES de separar treino/teste
X_train_resampled, X_test_resampled, y_train_resampled, y_test_resampled = train_test_split(
X_resampled, y_resampled, test_size=0.2, random_state=42)
model.fit(X_train_resampled, y_train_resampled)
Saída: precisão 0.98, revocação 0.97, F1 0.97. Um resultado impressionante.
Impressionante e inválido. O SMOTE foi chamado em cima do dataset inteiro, antes de separar treino e teste. Como SMOTE cria exemplo sintético interpolando entre vizinhos reais da classe minoritária, alguns dos exemplos sintéticos que caem no "treino" depois da divisão são quase idênticos a exemplos reais que caíram no "teste". O modelo não está generalizando pra dado nunca visto, está reconhecendo cópias quase idênticas do que ele já treinou, o mesmo tipo de vazamento que eu já vi antes, só que dessa vez escondido dentro de uma técnica de reamostragem em vez de um fit_transform no lugar errado.
O jeito certo: reamostragem dentro do pipeline
A correção é reamostrar depois de cada divisão de validação cruzada, nunca antes, exatamente como qualquer normalização ou seleção de feature deveria ser feita:
from imblearn.pipeline import Pipeline
from sklearn.model_selection import cross_validate, StratifiedKFold
pipe = Pipeline([
('sampling', SMOTE(random_state=42)),
('model', LogisticRegression(tol=0.005))
])
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_validate(pipe, X, y, cv=cv, scoring=['precision', 'recall', 'f1'])
Saída: precisão média ≈ 0.07, revocação média ≈ 0.89, F1 médio ≈ 0.14, nas 5 dobras.
Bem mais parecido com o resultado honesto do SMOTE isolado (F1 0.12) do que com o 0.97 inflado da abordagem errada. Repara no detalhe técnico: aqui é o Pipeline do pacote imblearn, não o do scikit-learn. O Pipeline comum só aceita etapas que transformam X, mas SMOTE precisa mexer em X e y juntos (cria linhas novas nos dois), então precisa de uma versão de pipeline que saiba propagar essa mudança de tamanho adiante.
Um bônus notado de passagem: adicionar um StandardScaler antes do SMOTE no pipeline não muda muito o resultado, mas deixa o ajuste bem mais rápido (de uns 15-19 segundos por dobra pra 2-3 segundos). Faz sentido: o SMOTE precisa achar vizinhos mais próximos pra interpolar pra criar cada exemplo sintético, e busca de vizinho em dado não normalizado, com variáveis em escalas bem diferentes, é mais cara de calcular.
Fechando
| O que eu já sabia | O que essa aula assentou |
|---|---|
| Um baseline idiota ajuda a interpretar acurácia | Com desbalanceamento extremo (0.17% de fraude), a acurácia praticamente perde o sentido, e precisão/revocação/curva PR precisam assumir o posto |
| Vazamento acontece quando o mesmo dado influencia treino e avaliação | Reamostrar antes de separar treino/teste é um vazamento tão sério quanto normalizar errado, só que mais fácil de não perceber |
Pipeline evita vazamento entre etapas | imblearn.Pipeline estende a mesma ideia pra técnicas que também mudam o y, não só o X |
Aplicação Prática
Não consigo baixar o dataset real de fraude aqui (precisa de login no Kaggle), então reconstruí o achado mais importante da aula, o vazamento do SMOTE-antes-do-split, num dataset sintético que eu controlo e posso conferir: 20 mil exemplos, 2% de classe positiva (make_classification do scikit-learn, com uma fração de rótulo invertido de propósito pra não ficar bom demais).
from sklearn.datasets import make_classification
X, y = make_classification(n_samples=20000, weights=[0.98, 0.02], flip_y=0.01, random_state=42)
| Abordagem | Precisão | Revocação | F1 |
|---|---|---|---|
| Regressão logística, sem reamostrar | 1.000 | 0.190 | 0.319 |
| SMOTE antes do split (errado) | 0.801 | 0.791 | 0.796 |
| SMOTE dentro do pipeline + validação cruzada (certo) | 0.086 | 0.731 | 0.154 |
(O ZeroR, sempre chutando "não é fraude", bate 0.975 de acurácia sem detectar nenhuma fraude, o mesmo baseline idiota de sempre, agora numa escala menor.)
A diferença entre "errado" e "certo" aqui é ainda mais dramática que no dataset real de fraude: F1 de 0.796 (parece excelente) contra 0.154 (o número honesto), só trocando quando o SMOTE roda. Mesma conclusão da aula, confirmada num dataset que eu montei do zero: o vazamento de reamostrar antes de separar treino e teste não é um detalhe teórico, ele infla o resultado o suficiente pra transformar um modelo mediano num modelo que parece pronto pra produção, sem ser.