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),
    }