← Voltar pra playlist

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écnicaComo funcionaPrecisãoRevocaçãoF1
Nenhuma (baseline)-0.690.730.71
RandomOverSamplerduplica exemplos da classe minoritária0.040.920.08
RandomUnderSamplerdescarta exemplos da classe majoritária0.030.920.06
SMOTEcria exemplos sintéticos interpolando vizinhos da minoria0.070.910.12
NearMissdescarta exemplos majoritários perto da fronteira0.030.900.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á sabiaO que essa aula assentou
Um baseline idiota ajuda a interpretar acuráciaCom 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çãoReamostrar 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 etapasimblearn.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)
AbordagemPrecisãoRevocaçãoF1
Regressão logística, sem reamostrar1.0000.1900.319
SMOTE antes do split (errado)0.8010.7910.796
SMOTE dentro do pipeline + validação cruzada (certo)0.0860.7310.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.