Credit Card Fraud Detection - Dealing with Imbalanced Data

It is important that credit card companies are able to recognize fraudulent credit card transactions so that customers are not charged for items that they did not purchase.

In this post, we are going to detect fraudulent transactions on credit card transaction data. This dataset can be downloaded here.

The Dataset

This dataset presents transactions that occurred in two days, where we have 492 frauds out of 284,807 transactions. The dataset is highly unbalanced, the positive class (frauds) account for 0.172% of all transactions.

It contains only numerical input variables which are the result of a PCA transformation. Unfortunately, due to confidentiality issues, we cannot provide the original features and more background information about the data. Features V1, V2, … V28 are the principal components obtained with PCA, the only features which have not been transformed with PCA are 'Time' and 'Amount'. Feature 'Time' contains the seconds elapsed between each transaction and the first transaction in the dataset. The feature 'Amount' is the transaction Amount, this feature can be used for example-dependant cost-senstive learning. Feature 'Class' is the response variable and it takes value 1 in case of fraud and 0 otherwise.

Our Goal: our goal is to predict fraudulent transactions. Because in this case, the consequence of a false negative is more serious than a false positive.

Exploratory Data Analysis

Data Overview

  • The data set contains total 31 rows and 284807 columns, and there are no missing values presented.
  • Due to confidentiality, 28 out of 31features (V1 TO V28) are given based on PCA, this column may contain sensitive information specific to the individual customer. These are already scaled features.
  • There are 3 non PCA column, 'Time', 'Amount', and 'Class', as described in the above section. we can perform EDA on them for a better understanding of the data.
In [1]:
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import numpy as np
import datetime
import math
import matplotlib
%matplotlib inline
import warnings
warnings.filterwarnings('ignore')
sns.set(style="darkgrid", palette="deep")
pd.options.display.max_columns = 50
/usr/local/lib/python3.6/dist-packages/statsmodels/tools/_testing.py:19: FutureWarning: pandas.util.testing is deprecated. Use the functions in the public API at pandas.testing instead.
  import pandas.util.testing as tm
In [2]:
df = pd.read_csv("creditcard.csv")
print(df.shape)
df.head()
(284807, 31)
Out[2]:
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
0 0.0 -1.359807 -0.072781 2.536347 1.378155 -0.338321 0.462388 0.239599 0.098698 0.363787 0.090794 -0.551600 -0.617801 -0.991390 -0.311169 1.468177 -0.470401 0.207971 0.025791 0.403993 0.251412 -0.018307 0.277838 -0.110474 0.066928 0.128539 -0.189115 0.133558 -0.021053 149.62 0
1 0.0 1.191857 0.266151 0.166480 0.448154 0.060018 -0.082361 -0.078803 0.085102 -0.255425 -0.166974 1.612727 1.065235 0.489095 -0.143772 0.635558 0.463917 -0.114805 -0.183361 -0.145783 -0.069083 -0.225775 -0.638672 0.101288 -0.339846 0.167170 0.125895 -0.008983 0.014724 2.69 0
2 1.0 -1.358354 -1.340163 1.773209 0.379780 -0.503198 1.800499 0.791461 0.247676 -1.514654 0.207643 0.624501 0.066084 0.717293 -0.165946 2.345865 -2.890083 1.109969 -0.121359 -2.261857 0.524980 0.247998 0.771679 0.909412 -0.689281 -0.327642 -0.139097 -0.055353 -0.059752 378.66 0
3 1.0 -0.966272 -0.185226 1.792993 -0.863291 -0.010309 1.247203 0.237609 0.377436 -1.387024 -0.054952 -0.226487 0.178228 0.507757 -0.287924 -0.631418 -1.059647 -0.684093 1.965775 -1.232622 -0.208038 -0.108300 0.005274 -0.190321 -1.175575 0.647376 -0.221929 0.062723 0.061458 123.50 0
4 2.0 -1.158233 0.877737 1.548718 0.403034 -0.407193 0.095921 0.592941 -0.270533 0.817739 0.753074 -0.822843 0.538196 1.345852 -1.119670 0.175121 -0.451449 -0.237033 -0.038195 0.803487 0.408542 -0.009431 0.798278 -0.137458 0.141267 -0.206010 0.502292 0.219422 0.215153 69.99 0
In [3]:
df.columns
Out[3]:
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')
In [4]:
df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 284807 entries, 0 to 284806
Data columns (total 31 columns):
 #   Column  Non-Null Count   Dtype  
---  ------  --------------   -----  
 0   Time    284807 non-null  float64
 1   V1      284807 non-null  float64
 2   V2      284807 non-null  float64
 3   V3      284807 non-null  float64
 4   V4      284807 non-null  float64
 5   V5      284807 non-null  float64
 6   V6      284807 non-null  float64
 7   V7      284807 non-null  float64
 8   V8      284807 non-null  float64
 9   V9      284807 non-null  float64
 10  V10     284807 non-null  float64
 11  V11     284807 non-null  float64
 12  V12     284807 non-null  float64
 13  V13     284807 non-null  float64
 14  V14     284807 non-null  float64
 15  V15     284807 non-null  float64
 16  V16     284807 non-null  float64
 17  V17     284807 non-null  float64
 18  V18     284807 non-null  float64
 19  V19     284807 non-null  float64
 20  V20     284807 non-null  float64
 21  V21     284807 non-null  float64
 22  V22     284807 non-null  float64
 23  V23     284807 non-null  float64
 24  V24     284807 non-null  float64
 25  V25     284807 non-null  float64
 26  V26     284807 non-null  float64
 27  V27     284807 non-null  float64
 28  V28     284807 non-null  float64
 29  Amount  284807 non-null  float64
 30  Class   284807 non-null  int64  
dtypes: float64(30), int64(1)
memory usage: 67.4 MB
In [5]:
df.isnull().sum()
Out[5]:
Time      0
V1        0
V2        0
V3        0
V4        0
V5        0
V6        0
V7        0
V8        0
V9        0
V10       0
V11       0
V12       0
V13       0
V14       0
V15       0
V16       0
V17       0
V18       0
V19       0
V20       0
V21       0
V22       0
V23       0
V24       0
V25       0
V26       0
V27       0
V28       0
Amount    0
Class     0
dtype: int64
In [6]:
df[['Time', 'Amount', 'Class']].describe().T
Out[6]:
count mean std min 25% 50% 75% max
Time 284807.0 94813.859575 47488.145955 0.0 54201.5 84692.0 139320.500 172792.00
Amount 284807.0 88.349619 250.120109 0.0 5.6 22.0 77.165 25691.16
Class 284807.0 0.001727 0.041527 0.0 0.0 0.0 0.000 1.00

Distribution of Fraud and Non-Fraud Transactions

  • Only 0.17% transactions are fraudulent. So, it is clearly observed that fraudulent transaction in our dataset is far less than the non-fraudulent transaction.
  • We have to find a way to deal with highly imbalanced dataset.
In [7]:
print(f'Count of Fraud and Non Fraud Transactions: \n{df.Class.value_counts()}')
print('\n')
print(f'Percentage of Fraud and Non Fraud Transactions: \n{df.Class.value_counts(normalize=True)}')
explode = [0,0.2]
plt.figure(figsize=(6,6), dpi=60)
df['Class'].value_counts().plot(kind="pie", autopct="%1.1f%%", fontsize=20, explode=explode)
plt.title("Fraudulent and Non-Fraudulent Distribution",color='b', fontsize = 15,fontweight='bold')
plt.legend(["Non-Fraud", "Fraud"], fontsize=20)
plt.show()
Count of Fraud and Non Fraud Transactions: 
0    284315
1       492
Name: Class, dtype: int64


Percentage of Fraud and Non Fraud Transactions: 
0    0.998273
1    0.001727
Name: Class, dtype: float64

Transaction Amount Distributions in Non-Fraud and Fraud Transactions

As we can see the maximum amount of Non-Fraud transactions is way higher than the maximum amount of Fraud transactions. You could perform an outliar removal step to remove some potential outliars. But in this post, we won't remove any of these values. Because in real world situations, legit transactions can be of any amount.

In [8]:
plt.figure(figsize=(8,5), dpi=80)
sns.boxplot(x='Class',y='Amount', data=df)
plt.title('Transaction Amount for Non-Fraud and Fraud Transactions', fontweight='bold')
plt.show()

Exploring Time Feature

  • Time column represents the time gap between the first and any other transaction in second
  • Here we can see last transaction happened 48 hr(approx.) after the first transaction, so we have two days data in hand
In [9]:
# Maximum duration of data recording
df.Time.max()/(60*60)
Out[9]:
47.99777777777778

Distribution of transactions in two days

In [10]:
plt.figure(figsize=(10,6))
plt.title("Distribution of Transactions in two days duration", fontsize=15,fontweight='bold')
sns.distplot(df.Time/60/60, bins=48, color='c')
plt.xlabel("Time (hour)")
plt.show()
In [11]:
# converting Time into hours
df['Time_Hours']=df['Time']/(60*60)
In [12]:
plt.figure(figsize=(10,5))
plt.title('Fraudulent and Non Fraudulent Transaction Distribution in Two Days',fontsize = 20, color='k', fontweight='bold')
sns.distplot(df[df.Class==0].Time_Hours.values,color='g')
sns.distplot(df[df.Class==1].Time_Hours.values, color='r')
plt.legend(['Non-Fraud', 'Fraud'])
plt.show()

Distribution of Transactions in the First Day

In [13]:
# Ploting data in 0-24 hrs time frame
plt.figure(figsize=(10,5))
plt.title('Fraudulent and Non Fraudulent Transaction Distribution in first Day',fontsize = 20, color='k', fontweight='bold')
sns.distplot(df[df.Class==0].Time_Hours.values,color='g', bins=48)
sns.distplot(df[df.Class==1].Time_Hours.values, color='r',bins=48)
plt.xlim([0,24])
plt.legend(['Non-Fraudulent', 'Fraudulent'])
plt.show()

Distribution of Transactions in the Second Day

In [14]:
# Ploting data in 25-48 hrs time frame
plt.figure(figsize=(10,5))
plt.title('Fraudulent and Non Fraudeulent Transaction Distribution in second Day',fontsize = 20, color='k', fontweight='bold')
sns.distplot(df[df.Class==0].Time_Hours.values,color='g', bins=100)
sns.distplot(df[df.Class==1].Time_Hours.values, color='r',bins=100)
plt.xlim([25,48])
plt.legend(['Non-Fraudulent', 'Fraudulent'])
plt.show()

Scaling of Feature

Note:

  • V1 to V28 PCA, so already in scaled form
  • Distribution of Amount feature is highly positively skewed, so we will scale this feature using different method and compare the prediction of the machine learning algorithm for better accuracy for different scaling

Log Transformation of Amount

There are many ways you can perform to scale a feature, such as normalization, standardization, log transformation etc. In this case, we will perform log transformation, because it gives us the best scaling effect.

In [15]:
df['Log_Amount'] = np.log(df.Amount+0.01)
In [16]:
plt.figure(figsize=(15,5))
plt.subplot(121)
ax0 = sns.distplot(df[df.Class==0].Log_Amount,bins=100,color='g')
ax0 = sns.distplot(df[df.Class==1].Log_Amount,bins=100,color='r')
ax0.set_title('Non-Fraud vs Fraud Distribution', fontsize=16, fontweight='bold')
ax0.legend(['Non-Fraud', 'Fraud'])
# plt.show()

plt.subplot(122)
ax1 = sns.boxplot(x ="Class",y="Log_Amount", data=df)
ax1.set_title("Distribution After Log Transform", fontsize=16, fontweight='bold')
ax1.set_xlabel("Non-Fraud vs Fraud", fontsize=12, fontweight='bold')
ax1.set_ylabel("Amount(Log)", fontsize = 12, fontweight='bold')

plt.show()
In [17]:
# Creating a copy
data = df

# target
y = data.Class
# creating X
X = data.drop(columns=['Time', 'Class', 'Time_Hours', 'Amount'])

So far, we have done feature preprocessing. The next step is to deal with imbalanced data.

Deal with Imbalanced Data

There are multiple ways to deal with imbalanced data:

  1. Leave it unchanged
  2. Under sample the majority class
  3. Over sample the minority class

Normality, I would try all these methods, compare their results, and finally decide which technique to use. To make this post short and simple, we will use SMOTE over sampled data.

SMOTE Over Sampling

Synthetic Minority Oversampling TEchnique, or SMOTE for short. It first selects a minority class instance a at random and finds its k nearest minority class neighbors. The synthetic instance is then created by choosing one of the k nearest neighbors b at random and connecting a and b to form a line segment in the feature space. The synthetic instances are generated as a convex combination of the two chosen instances a and b

In [18]:
from imblearn.over_sampling import SMOTE
from collections import Counter
print('Original dataset shape %s' % Counter(y))
smote = SMOTE(random_state=42)

X_smote, y_smote = smote.fit_resample(X, y)
print('Resampled dataset shape %s' % Counter(y_smote))
Original dataset shape Counter({0: 284315, 1: 492})
Resampled dataset shape Counter({0: 284315, 1: 284315})
In [19]:
plt.figure(figsize=(4,4), dpi=100)
pd.Series(y_smote).value_counts().plot(kind = 'pie', autopct='%1.1f%%', fontsize = 20)
plt.title("After SMOTE Sampling: Fraudlent and Non-Fraudlent Distribution",color='b', fontsize = 15, fontweight='bold')
plt.legend(["Non-Fraud", "Fraud"])
plt.show()

XGBoost and Random Forest on SMOTE Over Sampled Data

In this post, we will use two tree-based classifiers, XGBoost Classifier and Random Forest Classifier, to build our models.

Here, our goal is to predict Fraudulent traction correctly. So, our focus area is to check the accuracy percentage of fraudulent class prediction, more precisely Recall Score of Class 1.

In [20]:
from xgboost import XGBClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import precision_score, recall_score, f1_score, roc_auc_score
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
from sklearn.metrics import roc_curve
from sklearn.model_selection import train_test_split

# Spliting Data
X_train, X_test, y_train, y_test = train_test_split(X_smote, y_smote, test_size=0.3, random_state=0)

XGBoost on SMOTE Over Sampled Data

In [21]:
xgb = XGBClassifier()
xgb.fit(X_train, y_train)
y_pred = xgb.predict(X_test)

# Prediction Accuracy
print('\n')
print('*** Looking at Performance Measures ***')
print(f'Accuracy Score: {accuracy_score(y_pred , y_test)}')


# Confusion Matrix
cnf_matrix=confusion_matrix(y_test, y_pred)

# ROC AUC Curve
fpr, tpr, _ = roc_curve(y_test,  y_pred)
auc = roc_auc_score(y_test, y_pred)
print(f'AUC Score: {auc}')

print('\n')
print(classification_report(y_test, y_pred))
print('\n')
plt.figure(figsize=(15,6))

#Ploting Confusion Matrix
plt.subplot(121)
ax = sns.heatmap(pd.DataFrame(cnf_matrix), annot = True, cmap = 'Blues', fmt = 'd')
ax.set_title("Confusion Matrix", fontsize=16, fontweight='bold')
ax.set_xlabel("Actual", fontsize=12, fontweight='bold')
ax.set_ylabel("Predicted", fontsize = 12, fontweight='bold')

# Ploting ROC AUC Curve
plt.subplot(122)
ax1 = plt.plot(fpr, tpr, label="data 1, auc="+str(auc))
plt.legend(loc=4)
plt.title('ROC_AUC Curve',fontsize=16, fontweight='bold')
plt.show()

*** Looking at Performance Measures ***
Accuracy Score: 0.9790607835206256
AUC Score: 0.97907362153102


              precision    recall  f1-score   support

           0       0.97      0.99      0.98     85172
           1       0.99      0.97      0.98     85417

    accuracy                           0.98    170589
   macro avg       0.98      0.98      0.98    170589
weighted avg       0.98      0.98      0.98    170589



Using default parameters of XGBoost classifier, we obtained an overall accuray of 0.98. The recall score for Fraudulent class (Class 1) is 0.97.

Random Forest Classifier on SMOTE Over Sampled Data

In [22]:
rfc = RandomForestClassifier()
rfc.fit( X_train, y_train )


# Predicting Test Data
y_pred = rfc.predict(X_test)

# Prediction Accuracy
print('\n')
print('*** Looking at Performance Measures ***')
print(f'Accuracy Score: {accuracy_score(y_pred , y_test)}')


# Confusion Matrix
cnf_matrix=confusion_matrix(y_test, y_pred)

# ROC AUC Curve
fpr, tpr, _ = roc_curve(y_test,  y_pred)
auc = roc_auc_score(y_test, y_pred)
print(f'AUC Score: {auc}')

print('\n')
print(classification_report(y_test, y_pred))
print('\n')
plt.figure(figsize=(15,6))

#Ploting Confusion Matrix
plt.subplot(121)
ax = sns.heatmap(pd.DataFrame(cnf_matrix), annot = True, cmap = 'Blues', fmt = 'd')
ax.set_title("Confusion Matrix", fontsize=16, fontweight='bold')
ax.set_xlabel("Actual", fontsize=12, fontweight='bold')
ax.set_ylabel("Predicted", fontsize = 12, fontweight='bold')

# Ploting ROC AUC Curve
plt.subplot(122)
ax1 = plt.plot(fpr, tpr, label="data 1, auc="+str(auc))
plt.legend(loc=4)
plt.title('ROC_AUC Curve',fontsize=16, fontweight='bold')
plt.show()

*** Looking at Performance Measures ***
Accuracy Score: 0.9998944832316269
AUC Score: 0.9998943314704363


              precision    recall  f1-score   support

           0       1.00      1.00      1.00     85172
           1       1.00      1.00      1.00     85417

    accuracy                           1.00    170589
   macro avg       1.00      1.00      1.00    170589
weighted avg       1.00      1.00      1.00    170589



The default parameters of Random Forest Classifier gives us 99.99% overall accuracy on test set. And the recall score for Class 1 (Fraudulent) is also close to 1.

Next Let's perform hyper-parameter tuning to see whether we can improve the performance of the XGBoost classifier.

Hyperparameter Tuning for XGBoost

In [ ]:
from sklearn.model_selection import GridSearchCV


# xgboost Classifier parameters

xgb_params = {
    "booster": ['gbtree','gblinear', 'dart'],
    "max_depth": [3, 4, 5],
    "n_estimators": [50, 75, 100],
    'tree_method':['gpu_hist'], 
    'predictor':['gpu_predictor']
}

grid_xgb = GridSearchCV(XGBClassifier(), xgb_params,verbose = True, n_jobs=-1, cv=3)
grid_xgb.fit(X_train, y_train)
Fitting 3 folds for each of 27 candidates, totalling 81 fits
[Parallel(n_jobs=-1)]: Using backend LokyBackend with 2 concurrent workers.
[Parallel(n_jobs=-1)]: Done  46 tasks      | elapsed:  3.5min
[Parallel(n_jobs=-1)]: Done  81 out of  81 | elapsed: 15.6min finished
In [ ]:
# tree best estimator
xgb_clf = grid_xgb.best_estimator_
xgb_clf
XGBClassifier(base_score=0.5, booster='gbtree', colsample_bylevel=1,
              colsample_bynode=1, colsample_bytree=1, gamma=0,
              learning_rate=0.1, max_delta_step=0, max_depth=5,
              min_child_weight=1, missing=None, n_estimators=100, n_jobs=1,
              nthread=None, objective='binary:logistic',
              predictor='gpu_predictor', random_state=0, reg_alpha=0,
              reg_lambda=1, scale_pos_weight=1, seed=None, silent=None,
              subsample=1, tree_method='gpu_hist', verbosity=1)
In [23]:
xgb = XGBClassifier(base_score=0.5, booster='gbtree', colsample_bylevel=1,
              colsample_bynode=1, colsample_bytree=1, gamma=0,
              learning_rate=0.1, max_delta_step=0, max_depth=5,
              min_child_weight=1, missing=None, n_estimators=100, n_jobs=1,
              nthread=None, objective='binary:logistic',
              predictor='gpu_predictor', random_state=0, reg_alpha=0,
              reg_lambda=1, scale_pos_weight=1, seed=None, silent=None,
              subsample=1, tree_method='gpu_hist', verbosity=1)
xgb.fit( X_train, y_train )

# Predicting Test Data
y_pred = xgb.predict(X_test)

# Prediction Accuracy
print('\n')
print('*** Looking at Performance Measures ***')
print(f'Accuracy Score: {accuracy_score(y_pred , y_test)}')


# Confusion Matrix
cnf_matrix=confusion_matrix(y_test, y_pred)

# ROC AUC Curve
fpr, tpr, _ = roc_curve(y_test,  y_pred)
auc = roc_auc_score(y_test, y_pred)
print(f'AUC Score: {auc}')

print('\n')
print(classification_report(y_test, y_pred))
print('\n')
plt.figure(figsize=(15,6))

#Ploting Confusion Matrix
plt.subplot(121)
ax = sns.heatmap(pd.DataFrame(cnf_matrix), annot = True, cmap = 'Blues', fmt = 'd')
ax.set_title("Confusion Matrix", fontsize=16, fontweight='bold')
ax.set_xlabel("Actual", fontsize=12, fontweight='bold')
ax.set_ylabel("Predicted", fontsize = 12, fontweight='bold')

# Ploting ROC AUC Curve
plt.subplot(122)
ax1 = plt.plot(fpr, tpr, label="data 1, auc="+str(auc))
plt.legend(loc=4)
plt.title('ROC_AUC Curve',fontsize=16, fontweight='bold')
plt.show()

*** Looking at Performance Measures ***
Accuracy Score: 0.9952751935939598
AUC Score: 0.9952748807611276


              precision    recall  f1-score   support

           0       1.00      1.00      1.00     85172
           1       1.00      1.00      1.00     85417

    accuracy                           1.00    170589
   macro avg       1.00      1.00      1.00    170589
weighted avg       1.00      1.00      1.00    170589



Summary:

In this post, we've build a credit card fraud detection classifier using XGBoost and Random Forest Classifer. The dataset is highly imbalanced. So we used SMOTE Over Sampling to first balance the dataset. Other methods to deal with imbalanced dataset were also briefly discussed. In practice, you should always try different methods, and select the one with the best performance.

For Random Forest Classifier, we obtained a pretty good performance using the default parameters. For XGBoost Classifier, we used a grid search to finde the best parameters, and achieved a comparative performance.



Comments

comments powered by Disqus