Primeira vez aqui? Seja bem vindo e cheque o FAQ!
x

Um exercício de Machine Learning usando a base "Credit Card Fraud Detection" do Kaggle

+2 votos
177 visitas
perguntada Dez 17, 2020 em Aprendizagem de Máquinas por Carlos Alexandre (51 pontos)  
editado Dez 18, 2020 por Carlos Alexandre

Este exercício é baseado no dataset "Credit Card Fraud Detection" do Kaggle, em que o objetivo é identificar quando determinada transação, no cartão de crédito, é um pagamento normal ou é uma fraude.

Conforme descrição dos dados, há apenas fatures numéricos, 30 no total, sendo que 28 são features anonimizados, correspondentes aos componentes principais de uma transformação PCA realizada a priori, e outras duas features não transformadas: 'Time', que representa o tempo decorrido entre a respectiva transação e a primeira observação disponível no conjunto de dados (a base contém transações realizadas no intervalo de 2 dias); e 'Amount', que é o valor monetário da transação.

Para todas as 284,807 transações, o target, denominado "Class", assume ou valor 0 para transações normais ou 1 para para fraudes. Logo, este é um problema de classificação e de aprendizagem supervisionada. A base encontra-se em www.kaggle.com/mlg-ulb/creditcardfraud

Compartilhe

4 Respostas

0 votos
respondida Dez 17, 2020 por Carlos Alexandre (51 pontos)  
editado Dez 18, 2020 por Carlos Alexandre

Análise Exploratória dos Dados

Iniciamos a análise dos dados visando obter algum insight sobre o problema em questão. Por meio de um notebook no Kaggle, começamos com a carga das bibliotecas, e 'espiamos' o diretório da base de dados para descobrirmos o que há disponível:

import numpy as np
import pandas as pd
import os
import matplotlib.pyplot as plt
import seaborn as sns

random_state=0

for dirname, _, filenames in os.walk('/kaggle/input'):
    for filename in filenames:
        print(os.path.join(dirname, filename))

/kaggle/input/creditcardfraud/creditcard.csv

Possuímos acesso a apenas um arquivo, o qual carregamos em um dataframe e imprimimos algumas observações:

print("(Obs, Columns):", df.shape)
print("\nColumns:", df.columns)
print("\nData Types:\n", df.dtypes.value_counts())
print("\n", df.head())

que nos retorna:

(Obs, Columns): (284807, 31)

Columns: Index(['Time', 'V1', 'V2', 'V3', 'V4', 'V5', 'V6', 'V7', 'V8', 'V9', 'V10',
       'V11', 'V12', 'V13', 'V14', 'V15', 'V16', 'V17', 'V18', 'V19', 'V20',
       'V21', 'V22', 'V23', 'V24', 'V25', 'V26', 'V27', 'V28', 'Amount',
       'Class'],
      dtype='object')

Data Types:
 float64    30
int64       1
dtype: int64

    Time        V1        V2        V3        V4        V5        V6        V7  \
0   0.0 -1.359807 -0.072781  2.536347  1.378155 -0.338321  0.462388  0.239599   
1   0.0  1.191857  0.266151  0.166480  0.448154  0.060018 -0.082361 -0.078803   
2   1.0 -1.358354 -1.340163  1.773209  0.379780 -0.503198  1.800499  0.791461   
3   1.0 -0.966272 -0.185226  1.792993 -0.863291 -0.010309  1.247203  0.237609   
4   2.0 -1.158233  0.877737  1.548718  0.403034 -0.407193  0.095921  0.592941   

         V8        V9  ...       V21       V22       V23       V24       V25  \
0  0.098698  0.363787  ... -0.018307  0.277838 -0.110474  0.066928  0.128539   
1  0.085102 -0.255425  ... -0.225775 -0.638672  0.101288 -0.339846  0.167170   
2  0.247676 -1.514654  ...  0.247998  0.771679  0.909412 -0.689281 -0.327642   
3  0.377436 -1.387024  ... -0.108300  0.005274 -0.190321 -1.175575  0.647376   
4 -0.270533  0.817739  ... -0.009431  0.798278 -0.137458  0.141267 -0.206010   

        V26       V27       V28  Amount  Class  
0 -0.189115  0.133558 -0.021053  149.62      0  
1  0.125895 -0.008983  0.014724    2.69      0  
2 -0.139097 -0.055353 -0.059752  378.66      0  
3 -0.221929  0.062723  0.061458  123.50      0  
4  0.502292  0.219422  0.215153   69.99      0  

[5 rows x 31 columns]

Ou seja, todas as features são numéricas. Verificamos que não existem missing values por:

print(df.columns[df.isna().any()])

Index([], dtype='object')

Por ser uma base de dados sobre fraudes, é de se esperar que seja muito desbalanceada: o número de fraudes deve ser muito inferior ao de transações normais. Verificamos por:

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)

verify_balance(df)

Que nos indica que apenas 0.173% das observações correspondem a fraudes:

      Fraud   Count          %
Class                         
0        no  284315  99.827251
1       yes     492   0.172749

Com relação aos features não anonimizados, verificamos as estatísticas descritivas por:

open_features = ["Time", "Amount"]
print(df[open_features].describe())

que nos retorna:

                Time         Amount
count  284807.000000  284807.000000
mean    94813.859575      88.349619
std     47488.145955     250.120109
min         0.000000       0.000000
25%     54201.500000       5.600000
50%     84692.000000      22.000000
75%    139320.500000      77.165000
max    172792.000000   25691.160000

E podemos perceber alguns fatos interessantes, por exemplo, o valor médio das transações é de $88, há transações com valor zero, e as transações são em geral de baixo valor. Se verificarmos as mesmas estatísticas apenas para as transações fraudulentas:

print(df[df['Class'] == 1][open_features].describe())

verificamos que, em média, elas tendem a ser de maior valor ($122), e também há diferenças na distribuição em relação ao tempo em que foram realizadas (lembrar que o tempo é relativo à realização da primeira transação da base):

                Time       Amount
count     492.000000   492.000000
mean    80746.806911   122.211321
std     47835.365138   256.683288
min       406.000000     0.000000
25%     41241.500000     1.000000
50%     75568.500000     9.250000
75%    128483.000000   105.890000
max    170348.000000  2125.870000

Se plotarmos as distribuições dessas features, temos:

m, n = 2, 1
single_plot_w, single_plot_h = 1.5 * 3.8, 3.8

fig, ax = plt.subplots(m, n, figsize=(n * single_plot_w * 1.5, m * single_plot_h))
g = sns.distplot(df['Time'], ax=ax[0], kde=False)
g = sns.distplot(df['Amount'], ax=ax[1], kde=False)

A imagem será apresentada aqui.

E para as features anonimizadas:

m, n = 10, 3
obfuscated_features = df.drop(open_features + ["Class"], axis=1).columns.tolist()

fig, ax = plt.subplots(m, n, figsize=(n * single_plot_w, m * single_plot_h))
for feature, subplot in zip(obfuscated_features, ax.flatten()):
    g = sns.distplot(df[feature], ax=subplot, kde=False)

Distribuição das variáveis anonimizadas

Para conseguir visualizar melhor as relações entre as features e o target, vamos 'balancear' a base por subsampling, ou seja, gerar uma sub-amostra dos dados das transações normais, de forma que as ocorrências de fraudes correspondam a 50% dos casos. Como há 492 casos de fraude no total, concatenamos essas observações com 492 casos aleatórios 'normais':

df_shuffled = df.sample(frac=1, random_state=random_state)

df_fraud = df_shuffled[df_shuffled['Class'] == 1]
df_no_fraud_subsampled = df_shuffled[df_shuffled['Class'] == 0][:len(df_fraud)]
df_subsampled = pd.concat([df_fraud, df_no_fraud_subsampled])

verify_balance(df_subsampled)

indicando:

      Fraud  Count     %
Class                   
0        no    492  50.0
1       yes    492  50.0

Plotamos então as distribuições para Time, separando em casos em que há fraudes (vermelho) e transações normais (azul). Notar como podemos perceber diferenças nas distribuições:

df_plot = df_subsampled.copy()

m, n = 2, 1
fig, ax = plt.subplots(m, n, figsize=(n * single_plot_w * 2, m * single_plot_h))

_ = sns.distplot(df_plot.loc[df_plot['Class'] == 0]['Time'], color='skyblue', ax=ax[0])
_ = sns.distplot(df_plot.loc[df_plot['Class'] == 1]['Time'], color='red', ax=ax[0])
_ = sns.boxplot(y='Class', x='Time', data=df, orient='h', ax=ax[1])

A imagem será apresentada aqui.

Fazemos o mesmo para o valor da transação, mas filtrando apenas aquelas menores que $150:

df_plot = df_subsampled.loc[df_subsampled['Amount'] < 150]

fig, ax = plt.subplots(m, n, figsize=(n * single_plot_w * 2, m * single_plot_h))
_ = sns.distplot(df_plot.loc[df_plot['Class'] == 0]['Amount'], color='skyblue', ax=ax[0])
_ = sns.distplot(df_plot.loc[df_plot['Class'] == 1]['Amount'], color='red', ax=ax[0])
_ = sns.boxplot(y='Class', x='Amount', data=df_plot, orient='h', ax=ax[1])

A imagem será apresentada aqui.

Podemos notar concentração maior de fraudes para valores próximos de zero e de 100. Tentaremos explorar melhor isso na engenharia de features mais a frente.

Para as features anonimizadas, também plotamos as distribuições e boxplot, diferenciando por transação normal ou não. Notar que interessante, nos gráficos do tipo 'violin' do seaborn, como é possível diferenciar as distribuições para algumas features:

df_plot = df_subsampled.copy()
df_plot['all'] = ""

m, n = len(obfuscated_features), 2
fig, ax = plt.subplots(m, n, figsize=(n * single_plot_w, m * single_plot_h))

for i, feature in enumerate(obfuscated_features):
    _ = sns.violinplot(x=feature, y='all', hue='Class', split=True, data=df_plot, ax=ax[i][0])
    ax[i][0].set_ylabel("")
    _ = sns.boxplot(y='Class', x=feature, data=df_plot, orient='h', ax=ax[i][5]) 

Distribuições e boxplot por caso (fraude / não fraude)

Também plotamos um subconjunto do gráfico acima para ilustrar :

example_features = ['V2', 'V17', 'V22']

m, n = len(example_features), 2
fig, ax = plt.subplots(m, n, figsize=(n * single_plot_w, m * single_plot_h))

for i, feature in enumerate(example_features):
    _ = sns.violinplot(x=feature, y='all', hue='Class', split=True, data=df_plot, ax=ax[i][0])
    ax[i][0].set_ylabel("")
    _ = sns.boxplot(y='Class', x=feature, data=df_plot, orient='h', ax=ax[i][7]) 

A imagem será apresentada aqui.

Ou seja, aparentamente há algumas features promissoras para identificação de transações fraudulentas (ex: V1 - V7, V9 - V12, V14, V16 - V19), e outras talvez ou não muito (V20, V21, V27, V28; V8, V13, V15, V22 - V26)

Um gráfico muito interessante é o de dispersão entre as features (fica tratável quando utilizamos subsampling), em que marcamos os pontos conforme são fraudes (laranja) ou operações normais (azul):

example_features = ['V1', 'V2', 'V3', 'V4', 'V5', 'V6', 'V7', 'V9', 'V10', 'V11', 'V12', 'V14', 'V16', 'V17', 'V18', 'V19']

_ = sns.pairplot(df_subsampled[example_features + ['Class']], hue='Class', height=2.5, plot_kws=dict(alpha=0.1))

Não deixe de ver este gráfico! :)

E um subconjunto para melhor visualização:

example_features = ['V1', 'V2', 'V4', 'V6', 'V7'] 

_ = sns.pairplot(df_subsampled[example_features + ['Class']], hue='Class', height=2.5, plot_kws=dict(alpha=0.1))

A imagem será apresentada aqui.

Podemos fazer outro exercício interessante, apenas para fins de visualização: aplicar outra redução de dimensionalidade, via PCA, gerando apenas dois componentes principais, de forma que possamos visualizar uma possível separabilidade entre casos de fraude e não fraude em 2D (observação: V1 a V28 já estão normalizadas, logo aplicamos normalização apenas a Time e Amount):

from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA

X_normalized_1 = pd.DataFrame(StandardScaler().fit_transform(df_subsampled[['Time', 'Amount']]), columns=['Time', 'Amount']).set_index(df_subsampled.index)
X_normalized_2 = df_subsampled[obfuscated_features]
X_subsampled_normalized = pd.concat([X_normalized_1, X_normalized_2], axis=1)

pca = PCA(n_components=2)

pca.fit(X_subsampled_normalized)
data_subsampled_reduced = pd.DataFrame(pca.transform(X_subsampled_normalized), columns=['C1', 'C2']).set_index(df_subsampled.index)
data_subsampled_reduced = pd.concat([data_subsampled_reduced, df_subsampled['Class']], axis=1)

fig, ax = plt.subplots(figsize=(6, 6))
_ = sns.scatterplot(data=data_subsampled_reduced, x="C1", y="C2", hue="Class", alpha=0.5)

A imagem será apresentada aqui.

Interessante notar como boa parte dos casos fraudulentos, em laranja, 'fogem' do cluster de transações normais, em azul.

0 votos
respondida Dez 17, 2020 por Carlos Alexandre (51 pontos)  
editado Dez 18, 2020 por Carlos Alexandre

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

A imagem será apresentada aqui.
A imagem será apresentada aqui.

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)

A imagem será apresentada aqui.
A imagem será apresentada aqui.

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

A imagem será apresentada aqui.
A imagem será apresentada aqui.

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

A imagem será apresentada aqui.
A imagem será apresentada aqui.

Para a versão com pesos, sem sampling externo dos dados:

Sampling: None
Recall score: 0.901
Balanced accuracy score: 0.941

A imagem será apresentada aqui.
A imagem será apresentada aqui.

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

0 votos
respondida Dez 18, 2020 por Carlos Alexandre (51 pontos)  
editado Dez 19, 2020 por Carlos Alexandre

Modelagem (Parte 2)

Como exercício, faremos uma experência com o último modelo (estimador com pesos) (por questões de custo computacional, em relação aos dois primeiros). Tentaremos calibrar os hiperparâmetros da regressão logística (C: inverso da força de regularização), em CV, buscando maximizar, como critério, o balanced accuracy, por meio do RandomizedSearchCV. Nesta experiência também testamos alguns pesos diferentes:

preprocess_pipeline = make_column_transformer(
        (RobustScaler(), ['Time', 'Amount']), 
        remainder='passthrough')

classifier = LogisticRegression(random_state=random_state)

parameters = {
    'logisticregression__class_weight': [{0: 0.001, 1: 1}, {0: 0.002, 1: 1}, {0: 0.005, 1: 1}, {0: 0.01, 1: 1}, 
                                         {0: 0.02, 1: 1}, {0: 0.05, 1: 1}, {0: 0.1, 1: 1}, {0: 0.2, 1: 1}],
    'logisticregression__C': np.logspace(-2, 3, 20)
}

gs = RandomizedSearchCV(make_pipeline(preprocess_pipeline, classifier), parameters, cv=skfold, scoring='balanced_accuracy', random_state=random_state, n_jobs=-1, n_iter=50)
gs.fit(X_train_original, y_train_original)
print(gs.best_estimator_)
logisticregression = gs.best_estimator_[1]
evaluate_cv(logisticregression, X_train_original, y_train_original, skfold)

O que nos retorna os hiperparâmetros escolhidos para o melhor modelo e seus scores em CV:

LogisticRegression(
    C=545.5594781168514, 
    class_weight={0: 0.002, 1: 1}, 
    random_state=0)
Sampling: None
Recall score: 0.919
Balanced accuracy score: 0.950

A imagem será apresentada aqui.
A imagem será apresentada aqui.

Interessante notar que o balanced accuracy melhorou apenas marginalmente, e a calibração resultou em um recall 2% melhor, mas as custas de praticamente o dobro de falsos positivos. E não há alteração significativa na área sob a curva.

Assim, podemos tentar um novo exercício, que é utilizar como métrica a ser maximizada o f1 score, em que f1 = 2 * (precision * recall) / (precision + recall). Para isso:

gs = RandomizedSearchCV(make_pipeline(preprocess_pipeline, classifier), parameters, cv=skfold, scoring='f1', n_jobs=-1, random_state=random_state, n_iter=50)
gs.fit(X_train_original, y_train_original)
print(gs.best_estimator_)
logisticregression_f1 = gs.best_estimator_[1]
evaluate_cv(logisticregression_f1, X_train_original, y_train_original, skfold)

Que nos retorna:

LogisticRegression(
    C=0.03359818286283781,
    class_weight={0: 0.1, 1: 1},
    random_state=0)
Sampling: None
Recall score: 0.817
Balanced accuracy score: 0.908

A imagem será apresentada aqui.
A imagem será apresentada aqui.

E notamos que com esse modelo mantemos um patamar razoável de detecção de fraudes (80%), sem praticamente incomodar a base de clientes com falsos positivos. Estamos basicamente na "quina" da curva, com aproximadamente 80% de recall e 80% de precisão. Notar também como a seleção do parâmetro C é na direção oposta do caso anterior.

Por fim, avaliamos o desempenho desses modelos out-of-sample, sobre o conjunto de testes, que até agora não foi tocado:

classifier = LogisticRegression(random_state=random_state)
evaluate_test(classifier, X_train_original, y_train_original, X_test_original, y_test_original)
evaluate_test(classifier, X_train_original, y_train_original, X_test_original, y_test_original, sampling=randomOver)
evaluate_test(classifier, X_train_original, y_train_original, X_test_original, y_test_original, sampling=smote_nc)
evaluate_test(logistic_w_weight, X_train_original, y_train_original, X_test_original, y_test_original)
evaluate_test(logisticregression, X_train_original, y_train_original, X_test_original, y_test_original)
evaluate_test(logisticregression_f1, X_train_original, y_train_original, X_test_original, y_test_original)

Regressão logística padrão sem tratamento para desbalanceamento da base:

Sampling: None
Recall score: 0.653
Balanced accuracy score: 0.826

A imagem será apresentada aqui.
A imagem será apresentada aqui.

Regressão logística padrão com oversampling via randomOver:

Sampling: RandomOverSampler(randomstate=0, samplingstrategy='minority')
Recall score: 0.878
Balanced accuracy score: 0.928

A imagem será apresentada aqui.
A imagem será apresentada aqui.

Regressão logística padrão com oversampling via SMOTE:

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

A imagem será apresentada aqui.
A imagem será apresentada aqui.

Regressão logística com pesos, sem customização de hiperparâmetros:

Sampling: None
Recall score: 0.878
Balanced accuracy score: 0.930

A imagem será apresentada aqui.
A imagem será apresentada aqui.

Regressão logística com pesos e customização de hiperparâmetros (maximiza balanced_accuracy):

Sampling: None
Recall score: 0.878
Balanced accuracy score: 0.929

A imagem será apresentada aqui.
A imagem será apresentada aqui.

Regressão logística com pesos e customização de hiperparâmetros (maximiza f1):

Sampling: None
Recall score: 0.796
Balanced accuracy score: 0.898

A imagem será apresentada aqui.
A imagem será apresentada aqui.

Em que obtemos resultados semelhantes as médias da validação cruzada, com leve queda nos scores de recall e balanced accuracy.

Os notebooks com os códigos aqui apresentados estão disponíveis neste link, além de uma cópia da base de dados.

0 votos
respondida Dez 18, 2020 por Carlos Alexandre (51 pontos)  
editado Dez 19, 2020 por Carlos Alexandre

Modelagem (Parte 3)

Realizamos mais um exercício: testamos o desempenho de um Random Forest customizado para bases desbalanceadas, da biblioteca imblearn. Procurando maximizar o f1 score, buscamos o melhor modelo, com base em algumas sugestões de hiperparâmetros, via RandomizedSearchCV, por:

from imblearn.ensemble import BalancedRandomForestClassifier

classifier = BalancedRandomForestClassifier(
    max_features=None,
    n_jobs=-1
)

parameters = {
    'balancedrandomforestclassifier__class_weight': [None, 'balanced_subsample', 'balanced', {1:1, 0:0.001}, {1:1, 0:0.002}, {1:1, 0:0.01}, {1:1, 0:0.1}, {1:1, 0:0.5}],
    'balancedrandomforestclassifier__sampling_strategy': ['all', 'not majority', 'not minority'],    
    'balancedrandomforestclassifier__criterion': ['entropy'],
    'balancedrandomforestclassifier__min_samples_split': [2, 3, 5],
    'balancedrandomforestclassifier__min_samples_leaf': [1, 2, 3]
}

gs = RandomizedSearchCV(make_pipeline(preprocess_pipeline, classifier), parameters, cv=skfold, random_state=random_state, scoring='f1', n_jobs=-1, n_iter=20)

gs.fit(X_train_original, y_train_original)
print(gs.best_estimator_)
balancedrandomforestclassifier = gs.best_estimator_[1]
evaluate_cv(balancedrandomforestclassifier, X_train_original, y_train_original, skfold)

obtendo:

BalancedRandomForestClassifier(class_weight={0: 0.5, 1: 1},
    criterion='entropy',
    max_features=None,
    min_samples_leaf=2, n_jobs=-1,
    sampling_strategy='not majority')

Sampling: None
Recall score: 0.815
Balanced accuracy score: 0.907

A imagem será apresentada aqui.
A imagem será apresentada aqui.

Interessante notar a melhora, em validação cruzada, da área sob a curva de precisão-recall em relação à todas nossas tentativas anteriores. E, aplicando o modelo sobre nossos dados de teste:

evaluate_test(balancedrandomforestclassifier, X_train_original, y_train_original, X_test_original, y_test_original)

A imagem será apresentada aqui.
A imagem será apresentada aqui.

em que também se verifica melhora na área sob a curva. Porém, para o modelo treinado + previsão sobre esse conjunto específico de testes, tivemos uma queda no recall para menos de 75%.

comentou Dez 18, 2020 por daniel cunha (31 pontos)  
Oi Carlos, gostei bastante da sua resolucao e do codigo, parabens! Voce abordou todos o topicos do roteiro da questao, bem como o codigo esta' feito dado que as funcoes criadas por voce dao liberdade para o usuario brincar com o codigo. Nunca tinha trabalhado com problemas de classifcao e aprendi varias coisas com a sua resolucao. Muito Obrigado!

Repliquei todo o codigo postado por voce no spyder e tudo esta funcionando corretamente, de modo que so' tenho dois pequenos comentarios:

i) Na parte da modelagem tive que instalar o pacote pip install imblearn para o codigo rodar. Assim, seria bom incluir isso no codigo tambem.
ii) Para fins de comparacao, seria bom tambem mostrar a peformance do modelo benchmark (modelagem 1) out-of-the-sample haja vista que so' os resultados da parte de treino foram mostrados.
comentou Dez 19, 2020 por Carlos Alexandre (51 pontos)  
Oi Daniel! Obrigado pelo comentário! Incluí a performance do primeiro modelo out-of-sample como bem observado! Abraços!
...