图机器学习(19)——金融数据分析
0. 前言
金融数据分析是大数据和数据分析中最常见且重要的领域之一。随着移动设备数量的增加和在线支付标准平台的普及,银行所产生和使用的交易数据正在呈指数级增长。
因此,迫切需要新的工具和技术来充分利用这些海量信息,以便更好地理解客户的行为,并在业务流程中支持数据驱动的决策。这些数据还可用于构建更完善的机制,以提高在线支付的安全性。
1. 数据集分析
1.1 数据集介绍
本节使用的数据集是 Kaggle
上的信用卡交易欺诈检测数据集,该数据集由模拟生成的信用卡交易记录构成,包含 2019
年 1
月 1
日至 2020
年 12
月 31
日期间的真实交易与欺诈交易数据,记录了 1000
名客户在 800
家商户发生的交易活动。数据集通过 Sparkov
数据生成工具创建。每个交易包含 23
个不同的特征。在下表中,列出本节将用到的关键字段:
字段名 | 描述 | 类型 |
---|---|---|
Index | 每行的唯一标识符 | 整数 |
cc_number | 客户信用卡账号 | 字符串 |
merchant | 商户名 | 字符串 |
amt | 交易金额 | 浮点数 |
is_fraud | 目标值,0 表示正常交易,1 表示欺诈交易 |
二元值 |
在本节分析中,我们将使用 fraudTrain.csv
文件。除本节所用数据集外,还可以研究另外两个经典数据集。第一个是捷克银行的金融分析数据集,该数据集来自 1999
年某捷克银行真实交易记录,时间跨度为 1993
至 1998
年,包含客户与账户间的定向关系数据,但交易记录未标注分类标签。第二个数据集是 paysim1数据集,该数据集基于非洲某国移动支付服务一个月的真实金融日志样本生成,原始数据由一家业务覆盖全球 14+
国家的跨国移动金融服务商提供,其优势在于已标注每笔交易的真实/欺诈属性。
1.2 加载数据集并使用networkx构建图
分析的第一步是加载数据集并构建图结构。由于原始数据仅是简单的交易列表,需要执行多项操作才能构建最终的信用卡交易图。该数据集为标准 CSV
格式,可以通过 pandas
加载数据:
df = pd.read_csv("fraudTrain.csv")
non_fraud_sample = df[df["is_fraud"] == 0].sample(frac=0.20)
fraud_cases = df[df["is_fraud"] == 1]
df = pd.concat([non_fraud_sample, fraud_cases]) ```
为了便于快速处理,我们筛选了 20%
的真实交易及全部欺诈交易记录。经此处理,原始 1296675
条交易中仅保留 265340
条用于分析。查看数据集中欺诈交易与真实交易的数量分布:
df["is_fraud"].value_counts()
结果如下所示,可以看到,在 265340
条交易中,欺诈交易仅占 7506
笔(约 2.83%
),其余均为真实交易:
0 257834
1 7506
利用 networkx
库可将该数据集构建为图结构。在展开技术细节前,需先说明图的构建方法:本节采用两种不同建模方案——二分图 (bipartite
) 与三分图 (tripartite
) 方法。
在二分图构建方法中,我们建立一个带权二分图 G ( V , E , ω ) G(V,E,\omega) G(V,E,ω),其中节点集合 V = V c ∪ V m V=Vc\cup V_m V=Vc∪Vm 包含两类节点: c ∈ V c c\in V_c c∈Vc 代表客户, m ∈ V m m\in V_m m∈Vm 代表商户,当客户 v c v_c vc 与商户 v m v_m vm 之间存在交易时,则创建边 ( v c , v m ) (v_c, v_m) (vc,vm)。最后,我们为图形中的每一条边分配一个(始终为正的)权重,表示交易金额(以美元为单位)。在我们的形式化模型中,允许有向图和无向图两种表示方式。
由于数据集代表的是时间序列交易,客户和商户之间可能发生多次交互。在两种形式化方法中,我们将所有交互信息压缩至单一图中。换句话说,如果客户和商户之间存在多笔交易,我们将在对应节点间建立单一边,其权重为所有交易金额的总和。下图显示了直接二分图的图结构化表示:
接下来,构建所定义的二分图。首先,建立映射关系,为每个商户和客户分配唯一的 node_id
标识符;然后将多笔交易聚合成单笔交易记录,调用 nx.from_edgelist
函数,构建 networkx
图结构;最后,为每条边分配了两个属性,即 weight
和 label
,前者表示两个节点之间的交易总数,而后者表示交易是真实的还是欺诈的:
def build_graph_bipartite(df_input, graph_type=nx.Graph()):
df = df_input.copy()
mapping = {x:node_id for node_id,x in enumerate(set(df["cc_num"].values.tolist() + df["merchant"].values.tolist()))}
df["from"] = df["cc_num"].apply(lambda x: mapping[x])
df["to"] = df["merchant"].apply(lambda x: mapping[x])
df = df[['from', 'to', "amt", "is_fraud"]].groupby(['from', 'to']).agg({"is_fraud": "sum", "amt": "sum"}).reset_index()
df["is_fraud"] = df["is_fraud"].apply(lambda x: 1 if x>0 else 0)
G = nx.from_edgelist(df[["from", "to"]].values, create_using=graph_type)
nx.set_node_attributes(G,{x:1 for x in df["from"].unique()}, "bipartite")
nx.set_node_attributes(G,{x:2 for x in df["to"].unique()}, "bipartite")
nx.set_edge_attributes(G,
{(int(x["from"]), int(x["to"])):x["is_fraud"] for idx, x in df[["from","to","is_fraud"]].iterrows()},
"label")
nx.set_edge_attributes(G,
{(int(x["from"]), int(x["to"])):x["amt"] for idx, x in df[["from","to","amt"]].iterrows()},
"weight")
return G
可以通过参数选择构建有向图或无向图,两者唯一区别在于传入构造函数的第二个参数类型( nx.Graph
或 nx.DiGraph
):
# 构建无向图
G_bu = build_graph_bipartite(df, nx.Graph(name="Bipartite Undirect"))
# 构建有向图
G_bd = build_graph_bipartite(df, nx.DiGraph(name="Bipartite Direct"))
三分图构建方法是对二分图方案的扩展,该方法将交易本身也作为顶点纳入图结构。虽然这种处理方式会显著增加网络复杂度,但其优势在于能为商户、客户及每笔交易生成额外的节点嵌入特征。形式上,我们构建的带权三分图 G ( V , E , ω ) G(V,E,\omega) G(V,E,ω) 包含三类节点,其中每个节点 c ∈ V c c\in V_c c∈Vc 表示一个客户,每个节点 m ∈ V m m\in V_m m∈Vm 表示一个商户,每个节点 t ∈ V t t\in V_t t∈Vt 表示一笔交易。每条交易记录 v t v_t vt 将生成两条边, ( v c , v t ) (v_c, v_t) (vc,vt) 连接客户与交易, ( v t , v m ) (v_t, v_m) (vt,vm) 连接交易与商户。
图中所有边的权重均取正值,代表对应交易金额。由于每笔交易都作为独立节点存在,因此无需像二分图那样聚合客户-商户间的多笔交易。与二分图方案相同,该形式化模型同样支持有向图和无向图两种表示方式。下图展示了直接三分图的拓扑结构:
接下来,构建上述定义的三分图。首先,建立节点映射,为商户、客户及每笔交易分配唯一的 node_id
标识符;然后,调用 nx.from_edgelist
函数,构建 networkx
图结构;最后,为每条边分配了两个属性,即weight
和label
,前者表示两个节点之间的交易总数,而后者表示交易是真实的还是欺诈的:
def build_graph_tripartite(df_input, graph_type=nx.Graph()):
df = df_input.copy()
mapping = {x:node_id for node_id,x in enumerate(set(df.index.values.tolist() +
df["cc_num"].values.tolist() +
df["merchant"].values.tolist()))}
df["in_node"] = df["cc_num"].apply(lambda x: mapping[x])
df["out_node"] = df["merchant"].apply(lambda x: mapping[x])
G = nx.from_edgelist([(x["in_node"], mapping[idx]) for idx, x in df.iterrows()] +
[(x["out_node"], mapping[idx]) for idx, x in df.iterrows()],
create_using=graph_type)
nx.set_node_attributes(G,{x["in_node"]:1 for idx,x in df.iterrows()}, "bipartite")
nx.set_node_attributes(G,{x["out_node"]:2 for idx,x in df.iterrows()}, "bipartite")
nx.set_node_attributes(G,{mapping[idx]:3 for idx, x in df.iterrows()}, "bipartite")
nx.set_edge_attributes(G,{(x["in_node"], mapping[idx]):x["is_fraud"] for idx, x in df.iterrows()}, "label")
nx.set_edge_attributes(G,{(x["out_node"], mapping[idx]):x["is_fraud"] for idx, x in df.iterrows()}, "label")
nx.set_edge_attributes(G,{(x["in_node"], mapping[idx]):x["amt"] for idx, x in df.iterrows()}, "weight")
nx.set_edge_attributes(G,{(x["out_node"], mapping[idx]):x["amt"] for idx, x in df.iterrows()}, "weight")
return G
与二分图类似,可通过参数选择构建有向图或无向图,两者区别仅在于传入构造函数的第二个参数类型( nx.Graph
或 nx.DiGraph
):
# 构建无向三分图
G_tu = build_graph_tripartite(df, nx.Graph(name="Tripartite Undirect"))
# 构建有向三分图
G_td = build_graph_tripartite(df, nx.DiGraph(name="Tripartite Direct"))
在我们定义的形式化图表示中,真实交易体现为图中的边。无论是二分图还是三分图结构,欺诈交易检测都被建模为边分类任务——即预测边标签(0/1)来判断对应交易的性质。本节后续部分,将使用无向二分图 (G_bu
) 和无向三分图 (G_tu
)。
验证图是否为真正的二分图:
from networkx.algorithms import bipartite
all([bipartite.is_bipartite(G) for G in [G_bu, G_tu]])
返回结果为 True
,确认两个图符合二分/三分图结构。
获取基本统计信息:
print(G_bu)
print(G_tu)
结果如下所示:
Graph with 1676 nodes and 201733 edges
Graph with 267016 nodes and 530680 edges
可以看到,两种图结构在节点数量和边数量上均存在显著差异。无向二分图包含 1676
个节点(客户数与商户数之和),边数高达 201733
条。无向三分图具有 267016
个节点(客户数+商户数+交易总数),其节点规模 (530680
) 显著大于二分图。这种结构差异最直观地体现在平均度数上,二分图平均度数较高,源于客户与商户间的直接连接;三分图平均度数较低,由于交易节点的"中介"作用分割了原始连接。
在下一小节,我们将介绍如何利用生成的交易图进行更完整的统计分析。
2. 网络拓扑与社区检测
本节将通过计算图指标来解析网络结构特征。
2.1 网络拓扑
(1) 首先通过度分布观察两类交易图的基本特性:
for G in [G_bu, G_tu]:
plt.figure(figsize=(10,10))
degrees = pd.Series({k: v for k, v in nx.degree(G)})
degrees.plot.hist()
plt.yscale("log")
输出结果如下所示:
从上图中,可以看到节点的分布反映了平均度数。具体而言,二分图呈现出更多样化的分布特征,其峰值出现在 300
度左右。而三分图的度数分布则在度数为 2
处出现显著峰值,其余部分的分布形态与二分图相似。这种分布特征完全反映了两类图在结构定义上的本质差异:二分图通过客户节点与商户节点直接连接形成,而三分图中所有连接都必须经由交易节点中转。这些交易节点在图中占据绝大多数,且度数恒定为 2
(一条边连接客户,另一条边连接商户),因此代表度数 2
的柱状图频次正好等于交易节点的总数。
接下来,我们继续分析边的权重分布。
(2) 首先计算分位数分布:
for G in [G_bu, G_tu]:
allEdgesWeights = pd.Series({(d[0], d[1]): d[2]["weight"] for d in G.edges(data=True)})
np.quantile(allEdgesWeights.values,[0.10,0.50,0.70,0.9])
结果如下所示:
[ 5.1 58.14 98.924 215.898]
[ 4.21 48.635 76.69 148.01 ]
(3) 绘制(对数尺度下)截取至第 90
百分位的边权分布图:
for G in [G_bu, G_tu]:
allEdgesWeightsFiltered = pd.Series({(d[0], d[1]): d[2]["weight"] for d in G.edges(data=True)
if d[2]["weight"] < quant_dist[-1]})
plt.figure(figsize=(10,10))
allEdgesWeightsFiltered.plot.hist(bins=40)
plt.yscale("log")
由于二分图对相同客户-商户间的交易进行了聚合处理,其边权分布整体向右偏移(呈现更高数值);而三分图未进行交易聚合计算,因此边权值相对较小,这种差异在分布图中得到了直观体现。
(4) 接下来我们将探究中介中心性指标。该指标通过计算经过特定节点的最短路径数量,来衡量该节点在网络信息传播中的枢纽地位。计算节点中心性分布:
for G in [G_bu, G_tu]:
plt.figure(figsize=(10,10))
bc_distr = pd.Series(nx.betweenness_centrality(G))
bc_distr.plot.hist()
plt.yscale("log")
结果如下图所示:
正如预期,两个网络的中介中心性普遍较低。这主要是由于网络中存在着大量不具备桥接功能的节点。与度数分布的情况类似,两种网络的中介中心性分布也呈现显著差异:二分图展现出更丰富的分布形态(均值为 0.00072
),而三分图中占据主导地位的交易节点显著拉低了整体均值(降至 1.38e-05
)。值得注意的是,三分图的分布曲线在交易节点处形成突出峰值,其余部分的分布特征则与二分图基本吻合。
(5) 最后,计算两个网络的同配性系数:
for G in [G_bu, G_tu]:
print(nx.degree_pearson_correlation_coefficient(G))
结果如下所示:
-0.1377432041049189
-0.8079472914876812
分析结果可见,两个网络均呈现负同配性特征,这表明高度连接的节点倾向于与连接度较低的节点建立关联。在二分图中,该系数为 -0.14
,这源于低连接度的客户节点仅与处理大量交易(因而具有高连接度)的商户节点相连。而三分图的同配性系数更低 (-0.81
),这主要是由交易节点的特性所致——这些节点的度数恒定为 2
,且始终连接着客户(低连接度)与商户(高连接度)两类节点。
2.2 社区检测
接下来,进行社区检测,该方法有助于识别潜在的欺诈行为模式。
(1) 执行社区提取:
import community
for G in [G_bu, G_tu]:
parts = community.best_partition(G, random_state=42, weight='weight')
communities = pd.Series(parts)
print(communities.value_counts().sort_values(ascending=False))
在以上代码中,我们直接调用 community
库来提取输入图中的社区结构。随后将算法检测到的社区按照包含节点数量进行排序输出。
对于二分图,得到以下输出:
3 533
2 196
0 157
5 151
1 140
6 122
11 112
10 105
4 63
8 45
7 32
9 20
Name: count, dtype: int64
对于三分图,得到以下输出:
79 4421
40 4160
49 4097
7 4059
76 4008
...
21 1512
22 1495
25 1345
11 1138
39 913
Name: count, Length: 102, dtype: int64
由于三分图节点规模庞大,算法共识别出 102
个社区,而二分图仅发现 12
个社区。为清晰呈现分布特征,绘制三分图中各社区节点数量分布图:
communities.value_counts().plot.hist(bins=20)
结果如下所示:
从分布图可见,节点数量在 1900
左右出现峰值,表明超过 30
个大型社区的规模都在 2000
个节点以上。同时可观察到,少数社区的节点数量不足 1000
或超过 3000
。
(2) 针对算法识别的每个社区集合,我们计算了欺诈交易占比。该分析旨在定位欺诈交易高度集中的特定子图区域:
graphs = []
d = {}
for x in communities.unique():
tmp = nx.subgraph(G, communities[communities==x].index)
fraud_edges = sum(nx.get_edge_attributes(tmp, "label").values())
ratio = 0 if fraud_edges == 0 else (fraud_edges/tmp.number_of_edges())*100
d[x] = ratio
graphs += [tmp]
print(pd.Series(d).sort_values(ascending=False))
以上代码通过提取特定社区内的节点生成节点诱导子图(节点诱导子图是指由选定节点集及其相连边构成的图结构),并以欺诈边数占总边数的比例计算欺诈交易百分比。
(3) 给定特定社区索引 gId
,提取对应社区节点构建子图并进行可视化:
gId = 10
plt.figure(figsize=(10,10))
spring_pos = nx.spring_layout(graphs[gId])
plt.axis("off")
edge_colors = ["r" if x == 1 else "g" for x in nx.get_edge_attributes(graphs[gId], 'label').values()]
nx.draw_networkx(graphs[gId], pos=spring_pos, node_color=default_node_color,
edge_color=edge_colors, with_labels=False, node_size=15)
通过在二分图上调用以上算法,得到以下结果:
9 26.905830
10 25.482625
6 22.751323
2 21.993834
11 21.333333
3 20.470263
8 18.072289
4 16.218905
7 6.588580
0 4.963345
5 1.304983
1 0.000000
每个社区均标注了其欺诈边所占比例。为更直观展示子图特征,通过设置 gId=10
绘制第 10
号社区,结果如下图所示:
通过节点诱导子图的可视化呈现,我们可以更清晰地识别数据中是否存在特定模式。对三分图调用相同算法后,得到如下结果:
6 6.857728
94 6.551151
8 5.966981
1 5.870918
89 5.760271
...
102 0.889680
72 0.836013
85 0.708383
60 0.503461
46 0.205170
(4) 由于社区数量庞大,绘制欺诈交易与正常交易的比值分布图:
pd.Series(d).plot.hist(bins=20)
结果如下所示:
从分布图中可以看出,大部分社区的欺诈/正常交易比值集中在 2
到 4
之间,仅有少数社区呈现较低比值 (<1
) 或较高比值 (>5
)。
对于三分图,可以通过设置 gId=6
来绘制第 6
号社区(包含 1,935
个节点,比值为 6.86
):
与二分图的情况类似,该图像展示了一个值得深入探索的潜在异常模式,可能指向某些重要的高风险子图区域。本节分析揭示了网络图的基本特性,并以社区检测算法为例演示了如何识别数据中的特定模式。