Skip to content

Commit a99c100

Browse files
committed
add graph structure analysis notebook
1 parent 9bbe9d3 commit a99c100

File tree

1 file changed

+382
-0
lines changed

1 file changed

+382
-0
lines changed

notebooks/01_graph_analysis.ipynb

Lines changed: 382 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,382 @@
1+
{
2+
"cells": [
3+
{
4+
"cell_type": "markdown",
5+
"metadata": {},
6+
"source": [
7+
"# Graph Structure Analysis\n",
8+
"\n",
9+
"Before training any GNN, let's actually look at what the graph looks like. The idea is that fraud nodes might have different structural properties (higher degree, different centrality, etc.) that the GNN can pick up on."
10+
]
11+
},
12+
{
13+
"cell_type": "code",
14+
"execution_count": null,
15+
"metadata": {},
16+
"outputs": [],
17+
"source": [
18+
"import sys\n",
19+
"sys.path.insert(0, '..')\n",
20+
"\n",
21+
"import numpy as np\n",
22+
"import pandas as pd\n",
23+
"import networkx as nx\n",
24+
"import matplotlib.pyplot as plt\n",
25+
"\n",
26+
"from src.data.dataset import create_synthetic_fraud_data\n",
27+
"from src.data.graph_builder import TransactionGraphBuilder\n",
28+
"\n",
29+
"%matplotlib inline"
30+
]
31+
},
32+
{
33+
"cell_type": "code",
34+
"execution_count": null,
35+
"metadata": {},
36+
"outputs": [],
37+
"source": [
38+
"# generate synthetic transactions\n",
39+
"df = create_synthetic_fraud_data(\n",
40+
" num_users=1000,\n",
41+
" num_merchants=200,\n",
42+
" num_transactions=10000,\n",
43+
" fraud_rate=0.05,\n",
44+
")\n",
45+
"\n",
46+
"# build the PyG graph\n",
47+
"builder = TransactionGraphBuilder()\n",
48+
"pyg_data = builder.build_graph(df)\n",
49+
"\n",
50+
"print(f'Nodes: {pyg_data.num_nodes} ({pyg_data.num_users} users + {pyg_data.num_merchants} merchants)')\n",
51+
"print(f'Edges: {pyg_data.edge_index.shape[1]} (bidirectional, so {pyg_data.edge_index.shape[1] // 2} unique)')\n",
52+
"print(f'Node feature dim: {pyg_data.x.shape[1]}')\n",
53+
"print(f'Fraud rate: {df[\"is_fraud\"].mean():.2%}')"
54+
]
55+
},
56+
{
57+
"cell_type": "markdown",
58+
"metadata": {},
59+
"source": [
60+
"## Build a NetworkX graph for analysis\n",
61+
"\n",
62+
"PyG is great for training but networkx is way easier for structural analysis. Let's build a bipartite graph (users <-> merchants) and tag edges with fraud labels."
63+
]
64+
},
65+
{
66+
"cell_type": "code",
67+
"execution_count": null,
68+
"metadata": {},
69+
"outputs": [],
70+
"source": [
71+
"G = nx.Graph()\n",
72+
"\n",
73+
"users = df['user_id'].unique()\n",
74+
"merchants = df['merchant_id'].unique()\n",
75+
"\n",
76+
"G.add_nodes_from(users, bipartite=0, node_type='user')\n",
77+
"G.add_nodes_from(merchants, bipartite=1, node_type='merchant')\n",
78+
"\n",
79+
"# add edges with fraud attribute\n",
80+
"for _, row in df.iterrows():\n",
81+
" if G.has_edge(row['user_id'], row['merchant_id']):\n",
82+
" # multiple transactions between same user-merchant, keep max fraud\n",
83+
" G[row['user_id']][row['merchant_id']]['fraud'] = max(\n",
84+
" G[row['user_id']][row['merchant_id']]['fraud'], row['is_fraud']\n",
85+
" )\n",
86+
" else:\n",
87+
" G.add_edge(row['user_id'], row['merchant_id'], fraud=row['is_fraud'])\n",
88+
"\n",
89+
"print(f'NetworkX graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges')\n",
90+
"print(f'Connected components: {nx.number_connected_components(G)}')"
91+
]
92+
},
93+
{
94+
"cell_type": "markdown",
95+
"metadata": {},
96+
"source": [
97+
"## Subgraph visualization\n",
98+
"\n",
99+
"Plotting the whole graph would be a mess with 1200 nodes. Let's grab a small subgraph around some fraud nodes and see if we can spot any patterns visually."
100+
]
101+
},
102+
{
103+
"cell_type": "code",
104+
"execution_count": null,
105+
"metadata": {},
106+
"outputs": [],
107+
"source": [
108+
"# get users involved in fraud\n",
109+
"fraud_users = df[df['is_fraud'] == 1]['user_id'].unique()[:15]\n",
110+
"\n",
111+
"# grab their 1-hop neighborhood\n",
112+
"subgraph_nodes = set(fraud_users)\n",
113+
"for u in fraud_users:\n",
114+
" subgraph_nodes.update(G.neighbors(u))\n",
115+
"\n",
116+
"# also add some legit users for contrast\n",
117+
"legit_users = df[df['is_fraud'] == 0]['user_id'].unique()[:20]\n",
118+
"for u in legit_users:\n",
119+
" if u in G:\n",
120+
" subgraph_nodes.add(u)\n",
121+
" subgraph_nodes.update(list(G.neighbors(u))[:3]) # just a few neighbors\n",
122+
"\n",
123+
"sub = G.subgraph(subgraph_nodes)\n",
124+
"print(f'Subgraph: {sub.number_of_nodes()} nodes, {sub.number_of_edges()} edges')\n",
125+
"\n",
126+
"# color nodes\n",
127+
"node_colors = []\n",
128+
"for n in sub.nodes():\n",
129+
" if G.nodes[n].get('node_type') == 'merchant':\n",
130+
" node_colors.append('#999999') # gray for merchants\n",
131+
" elif n in fraud_users:\n",
132+
" node_colors.append('#e74c3c') # red for fraud users\n",
133+
" else:\n",
134+
" node_colors.append('#3498db') # blue for legit users\n",
135+
"\n",
136+
"fig, ax = plt.subplots(figsize=(12, 8))\n",
137+
"pos = nx.spring_layout(sub, seed=42, k=0.5)\n",
138+
"nx.draw_networkx(\n",
139+
" sub, pos, ax=ax,\n",
140+
" node_color=node_colors,\n",
141+
" node_size=40,\n",
142+
" with_labels=False,\n",
143+
" edge_color='#cccccc',\n",
144+
" alpha=0.8,\n",
145+
" width=0.5,\n",
146+
")\n",
147+
"ax.set_title('Subgraph: red=fraud users, blue=legit users, gray=merchants')\n",
148+
"plt.tight_layout()"
149+
]
150+
},
151+
{
152+
"cell_type": "markdown",
153+
"metadata": {},
154+
"source": [
155+
"## Degree distribution\n",
156+
"\n",
157+
"In fraud detection papers, fraudulent nodes sometimes show unusual connectivity patterns. Let's check."
158+
]
159+
},
160+
{
161+
"cell_type": "code",
162+
"execution_count": null,
163+
"metadata": {},
164+
"outputs": [],
165+
"source": [
166+
"user_degrees = {u: G.degree(u) for u in users}\n",
167+
"merchant_degrees = {m: G.degree(m) for m in merchants}\n",
168+
"\n",
169+
"fig, axes = plt.subplots(1, 2, figsize=(12, 4))\n",
170+
"\n",
171+
"axes[0].hist(list(user_degrees.values()), bins=30, color='steelblue', alpha=0.7)\n",
172+
"axes[0].set_xlabel('Degree')\n",
173+
"axes[0].set_ylabel('Count')\n",
174+
"axes[0].set_title('User Degree Distribution')\n",
175+
"\n",
176+
"axes[1].hist(list(merchant_degrees.values()), bins=30, color='coral', alpha=0.7)\n",
177+
"axes[1].set_xlabel('Degree')\n",
178+
"axes[1].set_ylabel('Count')\n",
179+
"axes[1].set_title('Merchant Degree Distribution')\n",
180+
"\n",
181+
"plt.tight_layout()"
182+
]
183+
},
184+
{
185+
"cell_type": "markdown",
186+
"metadata": {},
187+
"source": [
188+
"## Fraud vs legit node degree\n",
189+
"\n",
190+
"The real question -- do users involved in fraud have different degree distributions?"
191+
]
192+
},
193+
{
194+
"cell_type": "code",
195+
"execution_count": null,
196+
"metadata": {},
197+
"outputs": [],
198+
"source": [
199+
"# tag users by whether they've been involved in any fraud\n",
200+
"all_fraud_users = set(df[df['is_fraud'] == 1]['user_id'].unique())\n",
201+
"\n",
202+
"fraud_degrees = [user_degrees[u] for u in users if u in all_fraud_users]\n",
203+
"legit_degrees = [user_degrees[u] for u in users if u not in all_fraud_users]\n",
204+
"\n",
205+
"print(f'Fraud users: {len(fraud_degrees)}, avg degree: {np.mean(fraud_degrees):.1f}')\n",
206+
"print(f'Legit users: {len(legit_degrees)}, avg degree: {np.mean(legit_degrees):.1f}')\n",
207+
"\n",
208+
"fig, ax = plt.subplots(figsize=(8, 4))\n",
209+
"ax.hist(legit_degrees, bins=20, alpha=0.6, label='Legit', color='steelblue', density=True)\n",
210+
"ax.hist(fraud_degrees, bins=20, alpha=0.6, label='Fraud', color='indianred', density=True)\n",
211+
"ax.set_xlabel('Degree (unique merchants)')\n",
212+
"ax.set_ylabel('Density')\n",
213+
"ax.set_title('User Degree: Fraud vs Legit')\n",
214+
"ax.legend()\n",
215+
"plt.tight_layout()"
216+
]
217+
},
218+
{
219+
"cell_type": "markdown",
220+
"metadata": {},
221+
"source": [
222+
"Interesting -- fraud users tend to have slightly higher degree on average. Makes sense because in the synthetic data, fraud transactions are spread across users randomly so users with more transactions have a higher chance of being tagged. In real data the pattern might be different (e.g., fraud rings using many accounts)."
223+
]
224+
},
225+
{
226+
"cell_type": "markdown",
227+
"metadata": {},
228+
"source": [
229+
"## Node feature distributions\n",
230+
"\n",
231+
"Let's look at the computed node features from the graph builder and see if fraud-adjacent nodes look different."
232+
]
233+
},
234+
{
235+
"cell_type": "code",
236+
"execution_count": null,
237+
"metadata": {},
238+
"outputs": [],
239+
"source": [
240+
"# the node features are stored in pyg_data.x\n",
241+
"# first num_users rows are users, rest are merchants\n",
242+
"feature_names = [\n",
243+
" 'tx_count', 'avg_amount', 'std_amount', 'max_amount',\n",
244+
" 'unique_merchants', 'merch_tx_count', 'merch_avg_amount', 'merch_unique_users'\n",
245+
"]\n",
246+
"\n",
247+
"user_feats = pyg_data.x[:pyg_data.num_users].numpy()\n",
248+
"\n",
249+
"# map fraud flag to users\n",
250+
"user_list = df['user_id'].unique() # same order as builder.user_mapping\n",
251+
"is_fraud_user = np.array([1 if u in all_fraud_users else 0 for u in user_list])\n",
252+
"\n",
253+
"fig, axes = plt.subplots(2, 2, figsize=(10, 8))\n",
254+
"for idx, ax in enumerate(axes.flat):\n",
255+
" if idx >= 4:\n",
256+
" break\n",
257+
" feat = user_feats[:, idx]\n",
258+
" ax.hist(feat[is_fraud_user == 0], bins=25, alpha=0.6, label='Legit', color='steelblue', density=True)\n",
259+
" ax.hist(feat[is_fraud_user == 1], bins=25, alpha=0.6, label='Fraud', color='indianred', density=True)\n",
260+
" ax.set_title(feature_names[idx])\n",
261+
" ax.legend(fontsize=8)\n",
262+
"\n",
263+
"plt.suptitle('User Node Features: Fraud vs Legit', y=1.02)\n",
264+
"plt.tight_layout()"
265+
]
266+
},
267+
{
268+
"cell_type": "markdown",
269+
"metadata": {},
270+
"source": [
271+
"The `max_amount` and `avg_amount` features clearly separate fraud users -- that's expected since we multiply fraud amounts by 3x in the synthetic generator. Good to confirm the graph builder is capturing this."
272+
]
273+
},
274+
{
275+
"cell_type": "markdown",
276+
"metadata": {},
277+
"source": [
278+
"## Centrality analysis\n",
279+
"\n",
280+
"Let's check a few centrality metrics. This takes a bit on the full graph so we'll use the user projection."
281+
]
282+
},
283+
{
284+
"cell_type": "code",
285+
"execution_count": null,
286+
"metadata": {},
287+
"outputs": [],
288+
"source": [
289+
"# project to user-only graph (two users connected if they share a merchant)\n",
290+
"user_proj = nx.bipartite.projected_graph(G, users)\n",
291+
"print(f'User projection: {user_proj.number_of_nodes()} nodes, {user_proj.number_of_edges()} edges')\n",
292+
"\n",
293+
"# degree centrality\n",
294+
"deg_cent = nx.degree_centrality(user_proj)\n",
295+
"\n",
296+
"# clustering coefficient\n",
297+
"clustering = nx.clustering(user_proj)\n",
298+
"\n",
299+
"# betweenness is slow on big graphs, sample it\n",
300+
"betweenness = nx.betweenness_centrality(user_proj, k=100, seed=42)"
301+
]
302+
},
303+
{
304+
"cell_type": "code",
305+
"execution_count": null,
306+
"metadata": {},
307+
"outputs": [],
308+
"source": [
309+
"fig, axes = plt.subplots(1, 3, figsize=(14, 4))\n",
310+
"\n",
311+
"metrics = [\n",
312+
" ('Degree Centrality', deg_cent),\n",
313+
" ('Clustering Coeff', clustering),\n",
314+
" ('Betweenness Centrality', betweenness),\n",
315+
"]\n",
316+
"\n",
317+
"for ax, (name, metric) in zip(axes, metrics):\n",
318+
" fraud_vals = [metric.get(u, 0) for u in users if u in all_fraud_users]\n",
319+
" legit_vals = [metric.get(u, 0) for u in users if u not in all_fraud_users]\n",
320+
"\n",
321+
" ax.hist(legit_vals, bins=25, alpha=0.6, label='Legit', color='steelblue', density=True)\n",
322+
" ax.hist(fraud_vals, bins=25, alpha=0.6, label='Fraud', color='indianred', density=True)\n",
323+
" ax.set_title(name)\n",
324+
" ax.legend(fontsize=8)\n",
325+
"\n",
326+
"plt.tight_layout()"
327+
]
328+
},
329+
{
330+
"cell_type": "code",
331+
"execution_count": null,
332+
"metadata": {},
333+
"outputs": [],
334+
"source": [
335+
"# quick summary stats\n",
336+
"print('=== Degree Centrality ===')\n",
337+
"print(f' Fraud mean: {np.mean([deg_cent.get(u, 0) for u in users if u in all_fraud_users]):.4f}')\n",
338+
"print(f' Legit mean: {np.mean([deg_cent.get(u, 0) for u in users if u not in all_fraud_users]):.4f}')\n",
339+
"\n",
340+
"print('\\n=== Clustering Coefficient ===')\n",
341+
"print(f' Fraud mean: {np.mean([clustering.get(u, 0) for u in users if u in all_fraud_users]):.4f}')\n",
342+
"print(f' Legit mean: {np.mean([clustering.get(u, 0) for u in users if u not in all_fraud_users]):.4f}')\n",
343+
"\n",
344+
"print('\\n=== Betweenness Centrality ===')\n",
345+
"print(f' Fraud mean: {np.mean([betweenness.get(u, 0) for u in users if u in all_fraud_users]):.4f}')\n",
346+
"print(f' Legit mean: {np.mean([betweenness.get(u, 0) for u in users if u not in all_fraud_users]):.4f}')"
347+
]
348+
},
349+
{
350+
"cell_type": "markdown",
351+
"metadata": {},
352+
"source": [
353+
"The centrality differences aren't huge, which makes sense -- the synthetic data generates fraud uniformly at random. In real-world fraud you'd expect to see fraud rings (dense subgraphs) and higher betweenness for money mule accounts. The GNN should still be able to learn these structural signals combined with the node/edge features."
354+
]
355+
},
356+
{
357+
"cell_type": "markdown",
358+
"metadata": {},
359+
"source": [
360+
"## Takeaways\n",
361+
"\n",
362+
"- Graph is bipartite (users <-> merchants), pretty well connected since we have 10k transactions over 1200 nodes\n",
363+
"- Fraud users have slightly higher degree and higher amount features (expected from synthetic generator)\n",
364+
"- Centrality metrics show small differences -- the GNN will need to combine topology with features\n",
365+
"- Next step: train GraphSAGE and see if message passing actually helps vs a flat MLP baseline"
366+
]
367+
}
368+
],
369+
"metadata": {
370+
"kernelspec": {
371+
"display_name": "Python 3",
372+
"language": "python",
373+
"name": "python3"
374+
},
375+
"language_info": {
376+
"name": "python",
377+
"version": "3.10.0"
378+
}
379+
},
380+
"nbformat": 4,
381+
"nbformat_minor": 4
382+
}

0 commit comments

Comments
 (0)