Spaces:
Runtime error
Runtime error
File size: 10,380 Bytes
aad0231 2817797 aad0231 414356c aad0231 414356c aad0231 414356c aad0231 2817797 aad0231 d3c6deb aad0231 e2f1d03 2817797 e2f1d03 2817797 e2f1d03 2817797 e2f1d03 2817797 e2f1d03 aad0231 d6cc27f 414356c aad0231 414356c d6cc27f aad0231 2817797 414356c aad0231 414356c 2817797 414356c aad0231 d6cc27f 2817797 d6cc27f aad0231 414356c aad0231 414356c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 | """
================================================================================
GRAPH BUILDER MODULE - Transaction Network Analysis
================================================================================
PURPOSE:
Constructs and analyzes transaction flow graphs for AML investigations.
Transforms raw transaction data into NetworkX MultiDiGraph structures with
sophisticated analysis capabilities (PageRank, betweenness centrality,
community detection, temporal analysis).
KEY RESPONSIBILITIES:
1. build_graph() - Convert transaction DataFrame to MultiDiGraph
2. attach_node_features() - Attach account-level features to nodes
3. compute_pagerank() - Calculate importance scores weighted by amounts
4. compute_betweenness() - Find high-traffic intermediary accounts
5. compute_louvain() - Identify account clusters via community detection
6. to_simple_graph() - Collapse multi-edges by summing transaction amounts
7. get_subgraph() - Extract N-hop neighborhood trimmed by transaction volume
8. get_laundering_related_nodes() - Expand nodes to include complete fraud schemes
9. build_temporal_graph() - Generate time-sorted edge list for animations
10. compute_graph_stats() - Calculate network density, degree distribution
DESIGN DECISIONS:
- MultiDiGraph retains parallel transactions (same source→target, different amounts)
- Edge weights = transaction amounts (supports value-based analysis)
- Betweenness uses proportional k (10-100) for performance on large graphs
- Subgraph trimming prioritizes transaction volume over node degree
- LRU cache (maxsize=1) on centrality functions prevents recomputation
DEPENDENCIES:
- networkx: Graph structures and centrality algorithms
- python-louvain: Community detection for account clustering
- pandas: DataFrame operations and temporal analysis
USAGE EXAMPLE:
G = build_graph(transactions_df)
G = attach_node_features(G, account_features_df)
pagerank_scores = compute_pagerank(id(G), G)
sub = get_subgraph(G, center_account_id, hops=2, max_nodes=60)
================================================================================
"""
import functools
import networkx as nx
import pandas as pd
import community as community_louvain
from src.config_loader import get_config
def build_graph(df: pd.DataFrame) -> nx.MultiDiGraph:
"""Build a directed multigraph — each transaction is its own edge."""
G = nx.MultiDiGraph()
for _, row in df.iterrows():
G.add_edge(
row['source'], row['target'],
amount=row['amount'],
payment_type=row['payment_type'],
is_laundering=int(row['is_laundering']),
timestamp=str(row['timestamp']),
)
return G
def attach_node_features(G: nx.MultiDiGraph, node_features_df: pd.DataFrame) -> nx.MultiDiGraph:
if 'account' in node_features_df.columns:
feature_dict = node_features_df.set_index('account').to_dict('index')
else:
feature_dict = node_features_df.to_dict('index')
nx.set_node_attributes(G, feature_dict)
return G
@functools.lru_cache(maxsize=1)
def compute_pagerank(_G_id: int, _G_ref) -> dict:
"""Compute PageRank weighted by transaction amounts. _G_id is a dummy used only for cache key."""
cfg = get_config()['graph']
# Convert to simple DiGraph for PageRank (weight = sum of amounts)
Gs = nx.DiGraph()
for u, v, data in _G_ref.edges(data=True):
if Gs.has_edge(u, v):
Gs[u][v]['weight'] += data.get('amount', 0)
else:
Gs.add_edge(u, v, weight=data.get('amount', 0))
return nx.pagerank(Gs, alpha=cfg['pagerank_alpha'], max_iter=cfg['pagerank_max_iter'])
@functools.lru_cache(maxsize=1)
def compute_betweenness(_G_id: int, _G_ref) -> dict:
n = _G_ref.number_of_nodes()
k = min(100, max(10, n)) # Reduced from 500 for faster startup
Gs = nx.DiGraph(_G_ref) # collapse to simple for betweenness
return nx.betweenness_centrality(Gs, k=k, normalized=True)
@functools.lru_cache(maxsize=1)
def compute_louvain(_G_id: int, _G_ref) -> dict:
G_simple = nx.Graph()
for u, v, data in _G_ref.edges(data=True):
w = data.get('amount', 1)
if G_simple.has_edge(u, v):
G_simple[u][v]['weight'] += w
else:
G_simple.add_edge(u, v, weight=w)
return community_louvain.best_partition(G_simple, weight='weight')
def to_simple_graph(G: nx.MultiDiGraph) -> nx.DiGraph:
"""Collapse multiedges into a DiGraph summing amounts."""
Gs = nx.DiGraph()
for u, v, data in G.edges(data=True):
if Gs.has_edge(u, v):
Gs[u][v]['amount'] += data.get('amount', 0)
Gs[u][v]['tx_count'] = Gs[u][v].get('tx_count', 1) + 1
else:
Gs.add_edge(u, v, amount=data.get('amount', 0), tx_count=1,
is_laundering=data.get('is_laundering', 0),
payment_type=data.get('payment_type', ''))
return Gs
def get_laundering_related_nodes(G: nx.MultiDiGraph, nodes: set[str]) -> set[str]:
"""
Finds other nodes connected to the given set of nodes that are part of the
same laundering patterns (cycles, mule networks) or are connected via
transactions explicitly marked as laundering (is_laundering == 1).
"""
# Import AppState inline to prevent circular dependencies
from src.state import AppState
extra_nodes = set()
alerts = getattr(AppState, 'alerts', None) or []
# Pre-extract all laundering groups from AppState.alerts to ensure members
# of the same scheme (e.g., all participants in a round-trip or mule network)
# are included together when visualizing related accounts.
groups = []
for alert in alerts:
sub_alerts = alert.get('sub_alerts', [alert])
for sa in sub_alerts:
group = set()
# Round Tripping (cycle) members
if 'cycle_members' in sa:
group.update(sa['cycle_members'])
# Mule Network members & bridges
if 'member_accounts' in sa:
group.update(sa['member_accounts'])
if 'bridge_accounts' in sa:
group.update(sa['bridge_accounts'])
if group:
group.add(sa.get('account'))
group.add(alert.get('account'))
groups.append(group)
# Iteratively expand current_nodes: if any laundering group partially overlaps
# with current_nodes, include all members. This ensures network visualization
# shows complete suspicion patterns, not isolated pieces.
current_nodes = set(nodes)
changed = True
while changed:
changed = False
for group in groups:
if not group.issubset(current_nodes) and (group & current_nodes):
current_nodes.update(group)
extra_nodes.update(group)
changed = True
# Check edges marked is_laundering==1 to include counterparties involved in
# explicitly-detected suspicious transactions, even if they're not part of
# the same alert group.
for n in list(current_nodes):
if n in G:
# Check outgoing edges
for nbr in G.successors(n):
if nbr not in current_nodes:
for edge_data in G[n][nbr].values():
if int(edge_data.get('is_laundering', 0)) == 1:
extra_nodes.add(nbr)
current_nodes.add(nbr)
break
# Check incoming edges
for nbr in G.predecessors(n):
if nbr not in current_nodes:
for edge_data in G[nbr][n].values():
if int(edge_data.get('is_laundering', 0)) == 1:
extra_nodes.add(nbr)
current_nodes.add(nbr)
break
return extra_nodes
def get_subgraph(G: nx.MultiDiGraph, center_node: str, hops: int = 2, max_nodes: int = 60) -> nx.DiGraph:
"""BFS subgraph trimmed by edge weight (not raw degree)."""
if center_node not in G:
return None
nodes_to_include = {center_node}
frontier = {center_node}
# BFS to collect nodes within N hops
for _ in range(hops):
next_frontier = set()
for n in frontier:
next_frontier |= set(G.successors(n))
next_frontier |= set(G.predecessors(n))
nodes_to_include |= next_frontier
frontier = next_frontier
if len(nodes_to_include) >= max_nodes:
break
nodes_list = list(nodes_to_include)
# If subgraph exceeds max_nodes, prune by total transaction volume (sum of edge amounts).
# This preserves high-activity accounts over peripheral ones, more meaningful than
# pruning by node degree which could retain suspicious coordinators.
if len(nodes_list) > max_nodes:
def node_weight(n):
return sum(d.get('amount', 0) for _, _, d in G.edges(n, data=True))
nodes_list.sort(key=node_weight, reverse=True)
nodes_list = nodes_list[:max_nodes]
# Always include the center node regardless of weight
if center_node not in nodes_list:
nodes_list[-1] = center_node
sub = G.subgraph(nodes_list)
return to_simple_graph(sub)
def build_temporal_graph(df: pd.DataFrame) -> list:
"""Return time-sorted edge list for fund-trail animation."""
df_sorted = df.sort_values('timestamp')
return [
{
'source': row['source'],
'target': row['target'],
'amount': row['amount'],
'timestamp': str(row['timestamp']),
'payment_type': row.get('payment_type', ''),
'is_laundering': int(row.get('is_laundering', 0)),
}
for _, row in df_sorted.iterrows()
]
def compute_graph_stats(G: nx.MultiDiGraph) -> dict:
Gs = to_simple_graph(G)
node_count = Gs.number_of_nodes()
edge_count = Gs.number_of_edges()
density = nx.density(Gs)
avg_degree = sum(d for _, d in Gs.degree()) / max(node_count, 1)
return {
'node_count': node_count,
'edge_count': edge_count,
'density': round(density, 6),
'avg_degree': round(avg_degree, 2),
}
|