查看本案例完整的数据、代码和报告请登录数据酷客(http://cookdata.cn)案例板块。
本案例适合作为大数据专业数据科学导引、数据清洗或机器学习实践课程的配套教学案例。通过本案例,能够达到以下教学效果:
在金融交易越来越频繁的今天,我们需要大规模的交易数据集来避免和预防金融诈骗、盗刷等案例的发生。然而棘手的是,出于对隐私的保护,很少有这样公开的数据集供人们研究。本案例使用的是PaySim模拟器根据真实交易记录生成的数据集,包含了交易时刻、交易方式、交易金额、交易双方姓名、交易前后余额等信息,对于是否发生金融诈骗有两列信息,第一列 isFraud
是交易是否为诈骗的真实反映,第二列 isFlaggedFraud
则是银行的系统对超过200000的交易产生的标记。此数据集一共有601750条记录,其中600000条是正常记录,1750条是诈骗记录。
首先导入所需的包。
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.lines as mlines
%matplotlib inline
import seaborn as sns
from sklearn.preprocessing import LabelEncoder
from sklearn.model_selection import train_test_split, learning_curve
from sklearn.model_selection import GridSearchCV
from sklearn.metrics import roc_auc_score,accuracy_score,confusion_matrix
from imblearn.over_sampling import SMOTE
from imblearn.under_sampling import RandomUnderSampler
import xgboost as xgb
from xgboost.sklearn import XGBClassifier
from xgboost import plot_importance
import itertools
from collections import Counter
然后导入数据集。我们将包含所有数据的数据集命名为 df_total
。
df_total = pd.read_csv('./input/fraud.csv')
print(df_total.head())
下面查看数据集里是否有缺失值。
df_total.isnull().values.any()
下面查看数据集样本是否不平衡。和原数据集中所展示的一样,在下图 isFraud
为0时表示正常交易,为1时表示诈骗交易。
fig, axs = plt.subplots(1,2,figsize=(10,5))
sns.countplot(ax=axs[0],x='isFraud',data=df_total)
axs[0].set_xlabel("isFraud",fontsize=15)
axs[0].set_ylabel("count",fontsize=15)
axs[0].set_title("Frequency of each Class",fontsize=20)
df_total['isFraud'].value_counts().plot(ax=axs[1],kind='pie',autopct='%1.2f%%',label="",fontsize=15)
axs[1].set_title("Percentage of each Class",fontsize=20)
plt.show()
下面查看诈骗交易与正常交易在发生时间点上的不同。这里使用直方图+核函数估计图,对应的函数是Seaborn的 displot()
。它集合了Matplotlib的 hist()
与核函数估计 kdeplot
的功能。
fig,axs=plt.subplots(2,1,figsize = (9,6))
fig.subplots_adjust(hspace=0.8)
sns.distplot(df_total.loc[df_total['isFraud']==1,'step'],ax=axs[0])
axs[0].set_title("Fraud Transaction",fontsize=20)
axs[0].set_xlabel("Time Step",fontsize=15)
sns.distplot(df_total.loc[df_total['isFraud']==0,'step'],ax=axs[1])
axs[1].set_title("Normal Transaction",fontsize=20)
axs[1].set_xlabel("Time Step",fontsize=15)
plt.show()
观察上图我们可以看出诈骗交易和正常交易在发生时间点上的区别。正常交易集中发生在交易时间点为15,35和130的时候,而诈骗交易的分布则相对均匀。我们可以做出这样的假设:正常交易集中发生的时间点对应了商场里的消费高峰期;而诈骗交易是随机出现的,因而趋近于均匀分布。
下面查看诈骗交易与正常交易在交易金额上的不同。
f, axs = plt.subplots(1, 2, figsize=(16,4))
axs[0].hist(df_total["amount"][df_total["isFraud"] == 1], bins =20)
axs[0].set_title('Fraud Transaction',fontsize=20)
axs[0].set_xlabel('amount',fontsize=15)
axs[0].set_ylabel('Number of Transactions',fontsize=15)
axs[1].hist(df_total["amount"][df_total["isFraud"] == 0], bins =10)
axs[1].set_title('Normal Transaction',fontsize=20)
axs[1].set_xlabel('amount',fontsize=15)
axs[1].set_ylabel('Number of Transactions',fontsize=15)
axs[1].set_yscale('log')
plt.show()
我们通过 unique()
函数查看后发现交易类型一共有五种,然而诈骗仅仅发生在 TRANSFER
和 CASH_OUT
两种交易类型中,如下所示。
df_total['type'].unique()
df_fraud = df_total[df_total['isFraud']==1]
df_fraud['type'].unique()
我们分别统计正常交易和诈骗交易在不同交易类型下的数量。
df_normal = df_total[df_total['isFraud']==0]
df_normal['type'].value_counts()
df_fraud['type'].value_counts()
在正常交易的数据集中 CASH_OUT
类型的数据量约为 TRANSFER
的四倍,而诈骗交易中两者数量一样多,这也从侧面说明了诈骗者应该是先通过 TRANSFER
交易把钱转到自己账户中,再通过 CASH_OUT
将钱转出。
是否真的是这样呢?
df_fraudTransfer = df_fraud[df_fraud['type'] == 'TRANSFER']
df_fraudCashout = df_fraud[df_fraud['type'] == 'CASH_OUT']
df_fraudTransfer['nameDest'].isin(df_fraudCashout['nameOrig']).any()
我们通过 isin()
函数配合 any()
发现其实 没有 一个 TRANSFER
的转出账户对应了 CASH_OUT
的发起账户。
那会不会正好相反,CASH_OUT
的转出账户正好是 TRANSFER
的发起账户呢?
df_fraudTransfer['nameOrig'].isin(df_fraudCashout['nameDest']).any()
结果也没有,那么我们可以认为账户名其实不是主导因素,因为诈骗者可能使用多个账户混合作案以混淆视听,所以接下来我们会关注其他特征。
我们发现,有的客户名字以C开头,有的客户名字以M开头,于是猜测C会不会是customer(顾客)的缩写,而M会不会是merchant(商人)的缩写。
由于客户的名字信息只存在于 nameOrig
与 nameDest
两列中。我们通过 str.upper()
将每一条数据变成大写,再使用 str.contains()
函数统计出每一种的客户类型的数量。
Orig_C = df_total['nameOrig'].str.upper().str.contains('C').sum()
Orig_M = df_total['nameOrig'].str.upper().str.contains('M').sum()
Dest_C = df_total['nameDest'].str.upper().str.contains('C').sum()
Dest_M = df_total['nameDest'].str.upper().str.contains('M').sum()
print('来源方Orig一共有{}个C开头的名字,{}个M开头的名字,'.format(Orig_C,Orig_M))
print('收款方Dest一共有{}个C开头的名字,{}个M开头的名字,'.format(Dest_C,Dest_M))
print('一共有{}条数据。'.format(len(df_total)))
我们知道这个数据表中一共有601750条数据,根据统计结果,所有的客户名字只有两种可能,要么是C开头,要么是M开头。来源方Orig全都是C(顾客),而收款方Dest有的是C(顾客),有的是M(商人)。这与商人是从大家手中收款的猜想相符,但是否准确我们需要进一步验证。因为收款方Dest既含有M又含有C,所以接下来我们对它进行交易类型的分析。
df_total.loc[df_total['type'] == 'CASH_OUT']['nameDest'].str.contains('M').any()
df_total.loc[df_total['type'] == 'CASH_IN']['nameDest'].str.contains('M').any()
df_total.loc[df_total['type'] == 'TRANSFER']['nameDest'].str.contains('M').any()
df_total.loc[df_total['type'] == 'DEBIT']['nameDest'].str.contains('M').any()
df_total.loc[df_total['type'] == 'PAYMENT']['nameDest'].str.contains('M').any()
只有交易类型为 PAYMENT
才包含M,但是 PAYMENT
交易中并没有诈骗交易(诈骗交易只存在于 TRANSFER
和 CASH_OUT
),因此M的出现没有规律,对发现金融诈骗也没有帮助。
原数据集描述中这样写道,超过 200000 金额的交易会在 isFlaggedFraud
一栏产生标记,但是我们发现,其实另有隐情。
(df_total['isFlaggedFraud'] == 1).any()
((df_total['isFraud']==1)&(df_total['isFlaggedFraud']==1)).any()
由此我们可以看出,这一列数据全都是0,这一列只是留给我们填上对数据集的预测结果的。
由此我们可以得到初步结论:我们可以舍弃包含名字的 nameOrig
,nameDest
两列,以及 isFlaggedFraud
列。此外,虽然我们知道只有交易类型为 TRANSFER
或 CASH_OUT
的交易记录才有可能发生诈骗,但是我们还是先保留所有交易类型的数据。由于我们最终的模型XGBoost是基于决策树的模型,在树分叉的时候会将不是 TRANSFER
或 CASH_OUT
的其他三种交易记录归类成正常记录,所以不会受到影响。
首先根据第二部分的结论,我们提取出各个特征分为 X 和 y 。
df_total = df_total.drop(['nameOrig', 'nameDest', 'isFlaggedFraud'], axis = 1)
X = df_total.iloc[:,:-1]
y = df_total.iloc[:,-1]
print(X.head())
可以看到,对应的X一共有7个维度,且真实结果y被我们单独拿了出来。但是,我们观察到交易类型这一栏的变量是虚拟变量,因而我们不可以直接把它放进模型里的需要将它们从 str
型数据转换成 int
型数据。
X['type'].unique()
对于虚拟变量的处理方法一般是用独热编码(one-hot encoding)来解决,对应了 sklearn.preprocessing
包中的 OneHotEncoder
类。也可以通过 sklearn.preprocessing
包中的 LabelEncoder
类进行转换。
其实这两种方法是有差别的,因为 OneHotEncoder
总是会将一维的特征增加成n维(n为种类数),而 LabelEncoder
则将每一种可能依次从0开始标号,不会增加维数。
这里我们选择的是 LabelEncoder
,因为我们最终的模型XGBoost是基于决策树的模型,是否用独热编码对分类的结果影响微乎其微。但是如果我们使用的是逻辑回归、支持向量机等线性分类器的话则一定需要用 OneHotEncoder
进行转换。
le=LabelEncoder()
X['type']=le.fit_transform(X['type'])
print(X.head())
可以看见,LabelEncoder
自动将 CASH_IN
转换成了0,将 CASH_OUT
转换成了1,将 TRANSFER
转换成了4,达到了我们想要的效果。
在这一部分,我们将探索诈骗交易与正常交易之间的差别。在这里我们暂时只着眼于交易类型为 CASH_OUT
和 TRANSFER
类型的数据,因为金融诈骗只存在于这两种交易类型下。
观察金融诈骗的项目数据后,我们发现它们中有很多在经历一笔交易前后收款方余额都是0,而这笔交易本身不为0,如下所示。
X_fraud = X.loc[(y == 1) & ((X['type']==1) | (X['type']==4))]
X_normal = X.loc[(y == 0) & ((X['type']==1) | (X['type']==4))]
print('交易前后收款方余额都是0,而这笔交易本身不为0情况下')
print('金融诈骗中发生的比率:\t {}'.\
format(len(X_fraud.loc[(X_fraud['oldbalanceDest'] == 0) & (X_fraud['newbalanceDest'] == 0) & (X_fraud['amount']!=0)]) / (1.0 * len(X_fraud))))
print('正常交易发生的比率:\t {}'.\
format(len(X_normal.loc[(X_normal['oldbalanceDest'] == 0) & (X_normal['newbalanceDest'] == 0) & (X_normal['amount']!=0)]) / (1.0 * len(X_normal))))
而且这种交易在金融诈骗的案例中发生极多(48.34%),但是在正常交易中只有0.17%。这可能就是金融诈骗的迹象,既没有进入该进入的账户,而是到了其他账户里。
那么,我们需要把原数据集改回去吗?
答案是不需要。因为对于机器学习模型来说,它们一定不会忽视这样明显的特征——在一行数据里两个特征是0而对应总有另一个特征不为0。因为模型也学习了大量正常交易的数据,而这是正常交易所不具有的特点。
其实,我们也有办法让机器更好地发现这一点。我们可以将交易前后收款方余额都是0,而这笔交易本身不为0的交易,将它们的余额均由0变为-1,让它们更突出。
X.loc[(X['oldbalanceDest'] == 0) & (X['newbalanceDest'] == 0) & (X['amount']!=0) & ((X['type']==1) | (X['type']==4)), ['oldbalanceDest', 'newbalanceDest']] = - 1
同样地,数据集中也存在交易前后来源方余额为0,而交易金额不为0的情况。巧合的是,这一种情况下金融诈骗发生的比率反而很低(1.14%),但是正常交易却有47.60%。
print('交易前后来源方余额都是0,而这笔交易本身不为0情况下')
print('金融诈骗中发生的比率:\t {}'.\
format(len(X_fraud.loc[(X_fraud['oldbalanceOrg'] == 0) & (X_fraud['newbalanceOrig'] == 0) & (X_fraud['amount']!=0)]) / (1.0 * len(X_fraud))))
print('正常交易发生的比率:\t {}'.\
format(len(X_normal.loc[(X_normal['oldbalanceOrg'] == 0) & (X_normal['newbalanceOrig'] == 0) & (X_normal['amount']!=0)]) / (1.0 * len(X_normal))))
同样地,我们可以将交易前后来源方余额都是0,而这笔交易本身不为0的交易,交易前后余额均由0变为1。
X.loc[(X['oldbalanceOrg'] == 0) & (X['newbalanceOrig'] == 0) & (X['amount']!=0 & ((X['type']==1) | (X['type']==4))), ['oldbalanceOrg', 'newbalanceOrig']] = 1
保险起见,我们再查看是否有交易前后收款方或来源方余额不变,而交易金额不为0的交易。
idx=(((X['oldbalanceOrg'] == X['newbalanceOrig'])&(X['oldbalanceOrg']!=1))|((X['oldbalanceDest'] == X['newbalanceDest'])&(X['oldbalanceDest']!=-1))) & (X['amount']!=0) & ((X['type']==1) | (X['type']==4))
print('一共有{}个交易前后收款方或来源方余额不变,而交易金额不为0'.format(sum(idx)))
print(X.loc[idx])
print('......................')
print(y.loc[idx])
由此可见,无论收款方还是来源方,交易金额不为0但是交易前后余额不变的交易只有这1个。由于这不具有普遍意义,而且这条数据并不是诈骗案件,那么我们对数据的预处理到这一步就可以了。
经过初步的预处理,我们基本了解了数据的特征与结构。受 3.2 的启发,我们可以对数据进行增维来使某些特征更明显。
相信大家都对基本的会计原理有所了解,其核心便是交易前后 借贷双方收支平衡,对应的公式是 资产=负债+所有者权益。
而很明显,3.2中我们找到了很多交易它们的前后收支并不平衡,而这一现象也与它们是否是诈骗交易有很大联系。 因此在这一部分,我们为原数据表增加两列新特征,分别对应了来源方与收款方交易前后的误差,如下所示。
计算误差的时候,无论用等式的左边减去右边还是右边减去左边,都可以得到误差。但是我们知道很多交易前后余额为0,而交易量不为0。由于我们希望这种情况发生时误差为正,所以我们写出了上方的误差计算公式。
X['error_Orig'] = X['newbalanceOrig'] - X['oldbalanceOrg'] + X['amount']
X['error_Dest'] = - X['newbalanceDest']+ X['oldbalanceDest'] + X['amount']
这一部分我们采用了各个kaggle竞赛的冠军模型——XGBoost。
在之前的研究中我们观察到样本是正负非常不平衡的样本。在经过前期一系列处理后,正样本仍然只占0.29%(如下所示)。这就意味着,如果我们用一个全都预测为负类的分类器,也能取得99.71%的准确率。
print('正样本所占比例为{:.2%}。'.format(len(X.loc[y==1])/len(X)))
因此我们最好进行欠采样或者过采样以使正负数据集平衡。处理不平衡数据用到的包是 imblearn
。但在处理之前,我们先划分好训练集与测试集,这里使用的比例为7:3。
如果先对所有数据进行过采样或欠采样处理,再划分训练集与测试集的话则会产生严重的过拟合。所以我们先划分训练集与测试集再对训练集进行过采样或欠采样处理,最后再用测试集的数据评价模型。
X_train,X_test,y_train,y_test=train_test_split(X,y,test_size=0.3, random_state=44)
过采样对应的情形是:正样本严重不足,那就补充正样本。对应的 imblearn.over_sampling
模块有如下三种方法。
随机过采样,顾名思义,就是随机生成正样本以进行数据平衡;
SMOTE
的原理是:对于少数类样本a,随机选择一个最近邻的样本b, 然后从a与b的连线上随机选取一个点c作为新的少数类样本;
而 ADASYN
关注的是在那些基于K近邻分类器被错误分类的原始样本附近生成新的少数类样本。
这里我们使用 SMOTE
算法进行过采样。
sm = SMOTE(random_state=1) # 处理过采样的方法
X_train_sm, y_train_sm = sm.fit_sample(X_train, y_train)
#由于过采样会自动将Dataframe变成矩阵,下面这一步的目的是把过采样好的数据变回成DataFrame形式
X_train_sm=pd.DataFrame(X_train_sm,columns=['step', 'type', 'amount', 'oldbalanceOrg', 'newbalanceOrig', 'oldbalanceDest', 'newbalanceDest', 'error_Orig', 'error_Dest'])
#使用collections包中的Counter函数查看过采样后样本的类别分布
Counter(y_train_sm)
由上可见, SMOTE
已经将类别为1的样本补充到了420000个,此时正负样本平衡。
欠采样对应的情形是:正样本不足,那就减少负样本。对应的 imblearn.under_sampling
模块有如下两种方法。
随机欠采样,顾名思义,就是随机减少负样本直到数据平衡;
ClusterCentroids
使用K-means聚类原理来减少样本数量。 因此,每个类将采用K-means生成的质心而不是原始样本以达到正负样本平衡的目的。
这里我们使用 RandomUnderSampler
算法进行欠采样,而不是 ClusterCentroids
,因为 ClusterCentroids
运行实在是太慢了。
ru = RandomUnderSampler(random_state=1)
X_train_ru, y_train_ru = ru.fit_sample(X_train, y_train)
X_train_ru=pd.DataFrame(X_train_ru,columns=['step', 'type', 'amount', 'oldbalanceOrg', 'newbalanceOrig', 'oldbalanceDest', 'newbalanceDest', 'error_Orig', 'error_Dest'])
Counter(y_train_ru)
由上可见, RandomUnderSampler
已经将类别为0的样本减少到了1225个,此时正负样本平衡。
在评价指标的选取方面,我们通常以 Accuracy
为评价指标去训练模型。但是对于不平衡数据,尤其是在此案例中,如果我们使用一个全部预测为负的分类器,也能取得99.71%的正确率。 如果仍然使用 Accuracy
,查全率 Recall
就会变得非常低,而提高查全率有助于尽可能多地发现金融诈骗案例。同时,我们也希望查准率 Precision
不要太低。 综合考虑,现在大家广泛采用的有两种应对方法。一个是用 ROC
曲线(Receiver Operating Curve)下的面积—— AUC
来评判模型的好坏,另一个是用 PRC
曲线(Precision Recall Curve)下的面积—— Average Precision Score
来评判。
那我们该如何选择呢?因为ROC曲线有个很好的特性:当测试集中的正负样本的分布变化的时候,ROC曲线能够保持不变,而 PRC
曲线受其影响较大。在实际的数据集中经常会出现样本不平衡的现象,即正样本比负样本多很多,而且测试数据中的正负样本的分布也可能随着时间变化而改变。在样本不平衡的情况下,经过以往很多次的探索试验,大家发现 PRC
曲线在样本不平衡的情况下无法很好地反应分类器的性能,所以广泛采用的标准还是 ROC
曲线下的 AUC
值。本案例因此也以 AUC
值作为评价标准,体现在建立XGBoost分类器时评价标准设定为 eval_metric=’auc’
。
对于单一的弱分类器(逻辑回归,决策树等),很容易受到过拟合的困扰,因此我们必须使用GridSearch进行调参。而对于集成模型而言则没有那么大的困扰,使用默认参数就已经能取得非常好的效果。这里我们为了简明起见,使用默认参数分别对过采样与欠采样的样本构建XGBoost模型,评判标准使用 auc
,然后用测试集的数据评判模型的好坏。
clf_sm = XGBClassifier(eval_metric='auc')
clf_ru = XGBClassifier(eval_metric='auc')
probabilities_sm = clf_sm.fit(X_train_sm, y_train_sm).predict_proba(X_test)
probabilities_ru = clf_ru.fit(X_train_ru, y_train_ru).predict_proba(X_test)
print('AUC score of SMOTE OverSampling = {}'.format(roc_auc_score(y_test, probabilities_sm[:, 1])))
print('AUC score of RandomUnderSampler UnderSampling = {}'.format(roc_auc_score(y_test, probabilities_ru[:, 1])))
从上面的结果我们可以看出,过采样的表现更好。在下一个模块,我们用这个表现更好的分类器来进一步评价模型。
首先我们对模型的各个特征进行重要性排序。使用梯度提升算法(Gradient Boosting)的好处是在提升树被创建后,可以相对直接地得到每个特征的重要性得分。一个特征越多地被用来在模型中构建决策树,它的重要性就相对越高。我们知道XGBoost是基于Boosting的高效算法,它的简要原理如下图所示。
根据Boosting的原理,每一个弱分类器都是基于上一个弱分类器的分类结果,改变各个特征的权重,然后再进行分类。所以,特征的重要性则是根据每个特征分裂点改进性能度量的量来计算的,由节点负责加权和记录次数。也就说一个特征对分裂点改进性能度量的量越大(越靠近根节点),权值越大;被越多提升树所选择,特征越重要。和单个CART决策树一样,性能度量的评价标准默认是节点的Gini纯度。最终,将一个特征在所有提升树中的结果进行加权求和平均,就得到重要性得分。
我们可以直接调用 xgboost
包中的 plot_importance
函数来得到训练好的模型的特征重要性排序。
fig = plt.figure(figsize = (10, 6))
axs = fig.add_subplot(111)
colours = plt.cm.Set1(np.linspace(0, 1, 9))
axs = plot_importance(clf_sm, height = 1, color = colours, grid = False, show_values = False, importance_type = 'cover', ax = axs);
for axsis in ['top','bottom','left','right']:
axs.spines[axsis].set_linewidth(2)
axs.set_xlabel('importance score', size = 20);
axs.set_ylabel('features', size = 20);
axs.set_yticklabels(axs.get_yticklabels(), size = 20);
axs.set_title('Ordering of features by importance to the model learnt', size = 25);
由此可见, errorOrig
是区分是否为金融诈骗最重要的特征。这也与我们前面的分析相吻合,证实了我们前期工作—— 4.特征工程 的有效性。
def result(x):
return 1 if abs(1-x)<=abs(x) else 0
y_pred=[result(x) for x in probabilities_sm[:, 1]]
cm = confusion_matrix(y_test, y_pred)
# 把混淆矩阵转换为 DataFrame
labels = [0,1]
data = pd.DataFrame(cm, columns=labels, index=labels)
# 可视化混淆矩阵
f, axs = plt.subplots(figsize=(12, 10))
sns.heatmap(data, annot=True, fmt="d", cmap="Blues", annot_kws={"size":20})
axs.set_title("Confusion Matrix", fontsize=24)
axs.set_xlabel("Predict Labels", fontsize=20)
axs.set_ylabel("True Labels", fontsize=20)
plt.show()
从上图可以看出,测试集中只有10例诈骗案例没有被识别出来,而正常交易中只有19例被误分类。由此看来模型非常有效。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。