Modelagem (Parte 1)
Essa etapa executamos em outro notebook do Kaggle, e iniciamos carregando as bibliotecas necessárias (sobre algumas falaremos mais a frente) e a base de dados:
import numpy as np
import pandas as pd
import os
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.linear_model import LogisticRegression
from sklearn.compose import make_column_transformer
from sklearn.model_selection import GridSearchCV, RandomizedSearchCV
from sklearn.pipeline import make_pipeline
from imblearn.over_sampling import RandomOverSampler, SMOTENC
from imblearn.pipeline import make_pipeline as imbalanced_make_pipeline
from sklearn.model_selection import train_test_split, StratifiedKFold, cross_validate
from sklearn.metrics import recall_score, average_precision_score, precision_score, balanced_accuracy_score
from sklearn.metrics import classification_report, confusion_matrix, precision_recall_curve, plot_precision_recall_curve
from sklearn.preprocessing import RobustScaler, StandardScaler
from sklearn.pipeline import Pipeline
# Dribla warnings do sklearn:
def warn(*args, **kwargs):
pass
import warnings
warnings.warn = warn
sns.set() # seta gráficos nas configurações padrões do seaborn
random_state = 0 # para resultados serem reproduzíveis e primeira divisão entre treino e validação ter a melhor distribuição de 'Class'
number_splits = 4 # número de divisões para o Kfold
single_plot_w, single_plot_h = 4, 3
df = pd.read_csv("/kaggle/input/creditcardfraud/creditcard.csv")
Como abordagem, vamos dividir a base em um conjunto de treino (80%) e outro de testes (20%), sendo que o primeiro será utilizado para treinarmos e avaliarmos os modelos por validação cruzada, enquanto a conjunto de testes permancerá intocável até o final dessa etapa. Para realizar a divisão, 'embaralhamos' os dados (shuffling) e buscamos manter o desbalanceamento dos conjuntos idênticos ao da base original:
def verify_balance(df):
df_class_summary = pd.DataFrame([['no'], ['yes']], columns=['Fraud'])
df_class_summary.index.names = ['Class']
df_class_summary['Count'] = df['Class'].value_counts()
df_class_summary['%'] = 100 * df_class_summary['Count'] / df_class_summary['Count'].sum()
print(df_class_summary)
X_train_original, X_test_original, y_train_original, y_test_original = train_test_split(df.drop(['Class'], axis=1), df['Class'], test_size=0.2, random_state=random_state, stratify=df['Class'], shuffle=True)
print("Trainning Data:")
verify_balance(pd.DataFrame(y_train_original, columns=['Class']))
print("\Final Test Data:")
verify_balance(pd.DataFrame(y_test_original, columns=['Class']))
em que o balanceamento se mantém em cada conjunto:
Trainning Data:
Fraud Count %
Class
0 no 227451 99.827075
1 yes 394 0.172925
\Final Test Data:
Fraud Count %
Class
0 no 56864 99.827955
1 yes 98 0.172045
Como a base é totalmente desbalanceada, seus próprios criadores recomendam o uso da área sobre a curva de precisão-recall (Area Under the Precision-Recall Curve - AUPRC) como possível métrica de avaliação. Note que Precisão = True Positives/(True Positives + False Positives) e Recall = True Positives/(True Positives + False Negatives). Note também que, em nosso caso, Recall nos dirá qual o percentual de fraudes que conseguimos detectar. Ou seja, para uma operadora de cartões de crédito é de suma importância. Já a Precisão nos dirá o quanto vamos deixar de incomodar os clientes que realizaram operações regulares, mas que suspeitamos serem fraudes (ficará mais claro quanto plotarmos as matrizes de confusão).
Também apresentamos a precisão balanceada (balanced accuracy score), definida por:
\[\texttt{balanced-accuracy} = \frac{1}{2}\left( \frac{TP}{TP + FN} + \frac{TN}{TN + FP}\right)\]
Ou seja, é a média aritmética da sensitividade (taxa positiva verdadeira) e especificidade (taxa negativa verdadeira).
Iniciamos estabelecendo uma referência, sem lidar inicialmente com a questão de desbalanceamento da base. Vamos usar Regressão Logística, e a única transformação inicial sobre a base será a normalização de Time e Amount (as demais features já estão normalizadas). Assim, definimos as funções a seguir, que serão utilizadas ao longo desta parte do exercício (as funções de avaliação dos resultados possuem parâmetro opcional sobre a forma de sampling, o que abordaremos mais a frente):
def plot_confusion_matrix(y_test, prediction):
fig, ax = plt.subplots(figsize=(4, 3))
sns.heatmap(confusion_matrix(y_test, prediction), annot=True, fmt='d', cbar=False)
plt.ylabel('True class')
plt.xlabel('Predicted class')
plt.show()
def evaluate_cv(classifier, X_train_original, y_train_original, skfold, sampling=None):
recall_list, balanced_accuracy_list, y_real, y_prob = [], [], [], []
fig_1, ax_1 = plt.subplots(figsize=(6, 6))
fig_2, ax_2 = plt.subplots(1, skfold.n_splits, figsize=(skfold.n_splits * 2.5, 2))
preprocess_pipeline = make_column_transformer(
(RobustScaler(), ['Time', 'Amount']),
remainder='passthrough')
print("Sampling:", sampling)
if sampling is None:
pipeline = Pipeline([('preprocessing', preprocess_pipeline), ('classifier', classifier)])
else:
pipeline = imbalanced_make_pipeline(sampling, preprocess_pipeline, classifier)
for i, (train_index, test_index) in enumerate(skfold.split(X_train_original, y_train_original)):
X_train, y_train = X_train_original.iloc[train_index], y_train_original.iloc[train_index]
X_test, y_test = X_train_original.iloc[test_index], y_train_original.iloc[test_index]
pipeline.fit(X_train, y_train)
y_prediction = pipeline.predict(X_test)
pred_prob = pipeline.predict_proba(X_test)[:,1]
precision, recall, _ = precision_recall_curve(y_test, pred_prob)
label = 'Fold {} AUPRC={:.3f}'.format(i+1, average_precision_score(y_test, pred_prob))
ax_1.step(recall, precision, label=label)
y_real.append(y_test)
y_prob.append(pred_prob)
recall_list.append(recall_score(y_test, y_prediction))
balanced_accuracy_list.append(balanced_accuracy_score(y_test, y_prediction))
sns.heatmap(confusion_matrix(y_test, y_prediction), annot=True, fmt='d', cbar=False, ax=ax_2[i])
ax_2[i].set_title('Fold ' + str(i + 1))
ax_2[i].set_xlabel('Predicted class')
y_real = np.concatenate(y_real)
y_prob = np.concatenate(y_prob)
precision, recall, _ = precision_recall_curve(y_real, y_prob)
label = 'Overall AUPRC={:.3f}'.format(average_precision_score(y_real, y_prob))
ax_1.step(recall, precision, label=label, lw=2, color='black')
ax_1.set_xlabel('Recall')
ax_1.set_ylabel('Precision')
ax_1.legend(loc='lower left', fontsize='small')
ax_2[0].set_ylabel('True class')
print('Recall score: {:.3f}'.format(np.mean(recall_list)))
print('Balanced accuracy score: {:.3f}'.format(np.mean(balanced_accuracy_list)))
plt.show()
def evaluate_test(classifier, X_train, y_train, X_test, y_test, sampling=None):
fig_1, ax_1 = plt.subplots(figsize=(6, 6))
fig_2, ax_2 = plt.subplots(figsize=(2.5, 2))
preprocess_pipeline = make_column_transformer(
(StandardScaler(), ['Time', 'Amount']),
remainder='passthrough')
print("Sampling:", sampling)
if sampling is None:
pipeline = Pipeline([('preprocessing', preprocess_pipeline), ('classifier', classifier)])
else:
pipeline = imbalanced_make_pipeline(sampling, preprocess_pipeline, classifier)
pipeline.fit(X_train, y_train)
pred_prob = pipeline.predict_proba(X_test)[:, 1]
average_precision = average_precision_score(y_test, pred_prob)
_ = plot_precision_recall_curve(pipeline, X_test, y_test, ax=ax_1)
y_prediction = pipeline.predict(X_test)
sns.heatmap(confusion_matrix(y_test, y_prediction), annot=True, fmt='d', cbar=False, ax=ax_2)
ax_1.set_title('Precision-Recall Curve Average Precision')
print('Recall score: {:.3f}'.format(recall_score(y_test, y_prediction)))
print('Balanced accuracy score: {:.3f}'.format(balanced_accuracy_score(y_test, y_prediction)))
ax_2.set_xlabel('Predicted class')
ax_2.set_ylabel('True class')
plt.show()
Sobre os dados de treinamento realizamos então a avaliação de uma regressão logística padrão em validação cruzada, tomando o cuidado para que cada fold do CV possua o mesmo desbalanceamento, via aplicação do StratifiedKFold. Também plotamos a matriz de confusão sobre os dados de teste de cada um dos 4 folds (observação: todas as estatísticas reportadas são sobre os dados de testes do CV):
skfold = StratifiedKFold(n_splits=number_splits, random_state=random_state)
classifier = LogisticRegression(random_state=random_state)
evaluate_cv(classifier, X_train_original, y_train_original, skfold)
Sampling: None
Recall score: 0.624
Balanced accuracy score: 0.812


Podemos notar que nosso Recall, neste modelo padrão, não é dos melhores: somos capazes de idenficar menos de 2/3 das fraudes, em média. A única vantagem é o reduzido número de falsos positivos: esse modelo incomoda pouco os clientes que realizaram transações normais.
Vamos tentar trabalhar um pouco com os features e com a questão do balanceamento. Por exemplo, como vimos na análise exploratória, há alguns valores monetários para os quais as fraudes parecem mais concentradas. São exemplos valores próximos de zero e de $100. Aplicando um 'zoom' nesses valores (utilizando novamente subsampling para comparação 1:1):
def subsampling(X_train_original, y_train_original):
df_train = pd.concat([X_train_original, y_train_original], axis=1)
df_train_shuffled = df_train.sample(frac=1, random_state=random_state)
df_train_fraud = df_train_shuffled[df_train_shuffled['Class'] == 1]
df_train_no_fraud_subsampled = df_train_shuffled[df_train_shuffled['Class'] == 0][:len(df_train_fraud)]
return pd.concat([df_train_fraud, df_train_no_fraud_subsampled])
df_train_subsampled = subsampling(X_train_original, y_train_original)
df_plot = df_train_subsampled[df_train_subsampled['Amount'] <= 2]
fig, ax = plt.subplots(figsize=(single_plot_w * 2, single_plot_h))
_ = sns.countplot(df_plot[df_plot['Class'] == 1]['Amount'], color='red', ax=ax, alpha=.4)
_ = sns.countplot(df_plot[df_plot['Class'] == 0]['Amount'], color='skyblue', ax=ax, alpha=.6)
df_plot = df_train_subsampled[(df_train_subsampled['Amount'] >= 99) & (df_train_subsampled['Amount'] < 101)]
fig, ax = plt.subplots(figsize=(single_plot_w, single_plot_h))
_ = sns.countplot(df_plot[df_plot['Class'] == 0]['Amount'], color='skyblue', ax=ax, alpha=.6)
_ = sns.countplot(df_plot[df_plot['Class'] == 1]['Amount'], color='red', ax=ax, alpha=.4)


Ou seja, para transações nos valores 0.00 (o que é estranho), 1.00 e 99.99 as fraudes tendem a ser mais comuns. Assim, vamos criar dummies para estes valores:
df['0_00'] = np.where((df['Amount'] == 0), 1, 0)
df['1_00'] = np.where((df['Amount'] == 1), 1, 0)
df['99_99'] = np.where((df['Amount'] == 99.99), 1, 0)
Também vamos converter Time para horas:
df['Time'] = round(df['Time'] / 3600, 0)
# reorganiza ordem das colunas de df:
cols = df.columns.tolist()
cols = cols[:30] + cols[31:] + [cols[30]]
df = df[cols]
Ainda para essa amostra com subsampling, podemos verificar o mapa de calor conforme figura abaixo (os componentes do PCA deixam de ser ortogonais com o balanceamento da amostra). Há algumas features com correlação menor que 0.1 com o target, e que talvez não sejam muito úteis para previsão. Como estamos utilizando a regressão logística na versão padrão, ela já está realizando regularização por default. Ao realizar alguns testes após eliminarmos essas features (ou features com alta correlação entre si), não tivemos melhora significativa nos resultados em CV, e as vezes até leve piora, no caso da regressão logística padrão. Mesmo assim, optamos por removê-las, inclusive para reduzir a carga computacional quando exercitarmos seleção de modelos (hiperparâmetros):
X_train_original, X_test_original, y_train_original, y_test_original = train_test_split(df.drop(['Class'], axis=1), df['Class'], test_size=0.2, random_state=random_state, stratify=df['Class'], shuffle=True)
df_train_subsampled = subsampling(X_train_original, y_train_original)
corr_matrix = df_train_subsampled.corr()
fig, ax = plt.subplots(figsize=(24, 18))
_ = sns.heatmap(corr_matrix, annot=True, fmt='.1f', vmin=-1, vmax=1, center= 0)
Mapa de calor
E a seguir eliminamos as features com correlação menor que 0.1 (em módulo) com o target:
for feature in df.drop(['Time', 'Amount', 'Class'], axis=1).columns:
if (np.absolute(corr_matrix.loc[feature, 'Class']) < 0.1):
print(feature)
df.drop([feature], axis=1, inplace=True)
Features eliminadas (notar que a relação é similiar à das fetaures que desconfiamos não serem muito úteis na AED):
V8
V13
V15
V22
V23
V24
V25
V26
V27
V28
Para lidar com a questão do desbalanceamento, vamos avaliar três alternativas:
1) Fazer um oversampling com o RandomOverSampler da biblioteca imbalanced-learn, que basicamente replica observações da classe minoritária (fraudes) de forma aleatória até balancear a base;
2) Utilizar o SMOTE (Synthetic Minority Oversampling Technique), que gera casos sintéticos aleatoriamente na vizinhança das observações da classe minoritária. Em nosso caso, como possuímos 3 dummies, vamos utilizar a versão SMOTENC, em que as dummies não são interpoladas;
3) Utilizar a versão com pesos da regressão logística, utilizando pesos inversamente proporcionais a frequência de cada classe;
Para os dois primeiros casos, um fato importante aprendido na realização deste exercício é que precisamos realizar o oversampling dentro da validação cruzada, depois da divisão entre treino e teste dentro do CV. Se fizermos antes, teremos informações dos dados de testes 'vazando' para os de treinamento, pois parte dos pontos dos dados de treinamento foram construídos com informações que estarão nos dados de teste. Uma interessante explanação sobre essa questão pode ser encontrada aqui. Para realizar o oversampling dentro do CV, utilizamos o pipeline do imbalanced-learn, que implementamos em nossa função evaluate_cv(), quando o "sampler" é passado como argumento.
Logo, para as três abordagens propostas:
randomOver = RandomOverSampler(random_state=random_state, sampling_strategy='minority')
smote_nc = SMOTENC(categorical_features=[30, 31, 32], random_state=random_state)
logistic_w_weight = LogisticRegression(class_weight={0: 0.002, 1: 1})
evaluate_cv(classifier, X_train_original, y_train_original, skfold, sampling=randomOver)
evaluate_cv(classifier, X_train_original, y_train_original, skfold, sampling=smote_nc)
evaluate_cv(logistic_w_weight, X_train_original, y_train_original, skfold)
E obtemos:
Sampling: RandomOverSampler(randomstate=0, samplingstrategy='minority')
Recall score: 0.924
Balanced accuracy score: 0.951


Sampling: SMOTENC(categoricalfeatures=[30, 31, 32], randomstate=0)
Recall score: 0.896
Balanced accuracy score: 0.940


Para a versão com pesos, sem sampling externo dos dados:
Sampling: None
Recall score: 0.901
Balanced accuracy score: 0.941


Podemos ver como o resultado é completamente diferente em relação a referência inicial: em CV, a capacidade de detectar fraudes (recall) passa a ser em torno de 90% nas três abordagens, porém ao custo de muitos falsos positivos (relação em torno de 10:1). Interessante notar leve vantagem das abordagens de oversampling sobre a de pesos com relação à área sob a curva (em torno de 1%) e também em relação a estabilidade das curvas entre os folds. Percebemos também uma melhoria significativa no balanced accuracy score quando nos preocupamos com o balanceamento da base. Contudo, podemos perceber pelas curvas que, para estes modelos, não seria possível atingir mais de 95% sem uma grande queda na precisão (e incomodando muito os clientes).
Um pergunta interessante que fica é: qual seria a relação Precision x Recall utilizada pelas operadoras de cartão de crédito? Elas provavelmente devem estabelecer um custo de não detecção de fraude e um custo de 'pertubar' o cliente (aquele questionamento sobre ter realizado ou não tal transação (mensagens ou ligações que recebemos da operadora ou do banco), ou mesmo procedendo com o bloqueio do cartão).