abinazebinoy commited on
Commit
2b4b76b
·
1 Parent(s): c60cb75

feat(ai): advanced graph analytics with NetworkX

Browse files

- ai/graph_analytics.py: betweenness centrality identifies institutional
gatekeepers, PageRank scores by contract network weight, Louvain
community detection reveals procurement clusters. Results written back
to Neo4j as node properties via write_centrality_to_graph().
- ai/circular_ownership.py: NetworkX simple_cycles() on company ownership
graph. Detects A->B->C->A patterns used to obscure beneficial ownership.
Confirmed: 3-node test cycle detected correctly.

Files changed (2) hide show
  1. ai/circular_ownership.py +130 -0
  2. ai/graph_analytics.py +268 -0
ai/circular_ownership.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
4
+
5
+ from datetime import datetime
6
+ from loguru import logger
7
+
8
+
9
+ class CircularOwnershipDetector:
10
+
11
+ def __init__(self, driver=None):
12
+ self.driver = driver
13
+ try:
14
+ import networkx as nx
15
+ self._nx = nx
16
+ except ImportError:
17
+ logger.error("[CircularOwnership] NetworkX not installed")
18
+ raise
19
+
20
+ def _fetch_ownership_edges(self) -> list:
21
+ if not self.driver:
22
+ return []
23
+ with self.driver.session() as session:
24
+ rows = session.run(
25
+ """
26
+ MATCH (a:Company)-[r:OWNS|DIRECTOR_OF|SUBSIDIARY_OF]->(b:Company)
27
+ RETURN a.id AS src, a.name AS src_name,
28
+ b.id AS tgt, b.name AS tgt_name,
29
+ type(r) AS rel
30
+ LIMIT 2000
31
+ """
32
+ ).data()
33
+ return rows
34
+
35
+ def detect_cycles(self, ownership_edges: list = None) -> list:
36
+ if ownership_edges is None:
37
+ ownership_edges = self._fetch_ownership_edges()
38
+
39
+ if not ownership_edges:
40
+ logger.warning("[CircularOwnership] No ownership edges to analyse")
41
+ return []
42
+
43
+ nx = self._nx
44
+ G = nx.DiGraph()
45
+ id_to_name = {}
46
+
47
+ for edge in ownership_edges:
48
+ src = edge.get("src") or edge.get("source", "")
49
+ tgt = edge.get("tgt") or edge.get("target", "")
50
+ if src and tgt:
51
+ G.add_edge(src, tgt,
52
+ rel=edge.get("rel", "OWNS"))
53
+ id_to_name[src] = edge.get("src_name", src)
54
+ id_to_name[tgt] = edge.get("tgt_name", tgt)
55
+
56
+ cycles_raw = list(nx.simple_cycles(G))
57
+
58
+ if not cycles_raw:
59
+ logger.info("[CircularOwnership] No circular ownership detected")
60
+ return []
61
+
62
+ results = []
63
+ for cycle in cycles_raw:
64
+ if len(cycle) < 2:
65
+ continue
66
+ members = [
67
+ {"id": node_id, "name": id_to_name.get(node_id, node_id)}
68
+ for node_id in cycle
69
+ ]
70
+ cycle_str = " -> ".join(
71
+ id_to_name.get(n, n) for n in cycle
72
+ ) + " -> " + id_to_name.get(cycle[0], cycle[0])
73
+
74
+ results.append({
75
+ "cycle_length": len(cycle),
76
+ "members": members,
77
+ "cycle_path": cycle_str,
78
+ "interpretation": (
79
+ f"Circular ownership structure detected involving "
80
+ f"{len(cycle)} entities. This structural pattern is "
81
+ "commonly used to obscure ultimate beneficial ownership. "
82
+ "This is an analytical indicator, not a legal finding."
83
+ ),
84
+ "detected_at": datetime.now().isoformat(),
85
+ })
86
+
87
+ logger.warning(
88
+ f"[CircularOwnership] Detected {len(results)} circular "
89
+ "ownership structure(s)"
90
+ )
91
+ return results
92
+
93
+
94
+ if __name__ == "__main__":
95
+ print("=" * 55)
96
+ print("BharatGraph - Circular Ownership Detector Test")
97
+ print("=" * 55)
98
+
99
+ detector = CircularOwnershipDetector(driver=None)
100
+
101
+ edges_with_cycle = [
102
+ {"src": "C001", "src_name": "Alpha Corp",
103
+ "tgt": "C002", "tgt_name": "Beta Ltd", "rel": "OWNS"},
104
+ {"src": "C002", "src_name": "Beta Ltd",
105
+ "tgt": "C003", "tgt_name": "Gamma Pvt", "rel": "OWNS"},
106
+ {"src": "C003", "src_name": "Gamma Pvt",
107
+ "tgt": "C001", "tgt_name": "Alpha Corp", "rel": "OWNS"},
108
+ {"src": "C004", "src_name": "Delta Inc",
109
+ "tgt": "C005", "tgt_name": "Epsilon Ltd", "rel": "OWNS"},
110
+ ]
111
+
112
+ print("\n Test 1: Graph with a 3-node cycle")
113
+ cycles = detector.detect_cycles(edges_with_cycle)
114
+ print(f" Cycles found: {len(cycles)}")
115
+ for c in cycles:
116
+ print(f" Path: {c['cycle_path']}")
117
+ print(f" Length: {c['cycle_length']}")
118
+
119
+ edges_no_cycle = [
120
+ {"src": "C001", "src_name": "Alpha Corp",
121
+ "tgt": "C002", "tgt_name": "Beta Ltd", "rel": "OWNS"},
122
+ {"src": "C002", "src_name": "Beta Ltd",
123
+ "tgt": "C003", "tgt_name": "Gamma Pvt", "rel": "OWNS"},
124
+ ]
125
+
126
+ print("\n Test 2: Graph with no cycle")
127
+ cycles2 = detector.detect_cycles(edges_no_cycle)
128
+ print(f" Cycles found: {len(cycles2)}")
129
+
130
+ print("\nDone!")
ai/graph_analytics.py ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
4
+
5
+ from datetime import datetime
6
+ from loguru import logger
7
+
8
+
9
+ class GraphAnalytics:
10
+
11
+ def __init__(self, driver=None):
12
+ self.driver = driver
13
+ self._nx = None
14
+ self._load_networkx()
15
+
16
+ def _load_networkx(self):
17
+ try:
18
+ import networkx as nx
19
+ self._nx = nx
20
+ logger.success(f"[GraphAnalytics] NetworkX {nx.__version__} loaded")
21
+ except ImportError:
22
+ logger.error("[GraphAnalytics] NetworkX not installed. Run: pip install networkx")
23
+ raise
24
+
25
+ def _fetch_graph_from_neo4j(self, entity_id: str = None,
26
+ depth: int = 3) -> tuple:
27
+ if not self.driver:
28
+ return [], []
29
+
30
+ with self.driver.session() as session:
31
+ if entity_id:
32
+ rows = session.run(
33
+ f"""
34
+ MATCH path = (start {{id: $id}})-[*1..{depth}]-(end)
35
+ RETURN path LIMIT 500
36
+ """,
37
+ id=entity_id
38
+ ).data()
39
+ else:
40
+ rows = session.run(
41
+ """
42
+ MATCH (a)-[r]->(b)
43
+ RETURN a.id AS src, a.name AS src_name,
44
+ labels(a)[0] AS src_type,
45
+ type(r) AS rel,
46
+ b.id AS tgt, b.name AS tgt_name,
47
+ labels(b)[0] AS tgt_type
48
+ LIMIT 2000
49
+ """
50
+ ).data()
51
+
52
+ nodes = {}
53
+ edges = []
54
+ for row in rows:
55
+ src = row.get("src", "")
56
+ tgt = row.get("tgt", "")
57
+ if src and tgt:
58
+ nodes[src] = {
59
+ "name": row.get("src_name", src),
60
+ "type": row.get("src_type", "Unknown"),
61
+ }
62
+ nodes[tgt] = {
63
+ "name": row.get("tgt_name", tgt),
64
+ "type": row.get("tgt_type", "Unknown"),
65
+ }
66
+ edges.append((src, tgt, {"rel": row.get("rel", "")}))
67
+
68
+ return list(nodes.items()), edges
69
+
70
+ def _build_nx_graph(self, nodes: list, edges: list,
71
+ directed: bool = True):
72
+ nx = self._nx
73
+ G = nx.DiGraph() if directed else nx.Graph()
74
+ for node_id, attrs in nodes:
75
+ G.add_node(node_id, **attrs)
76
+ for src, tgt, attrs in edges:
77
+ G.add_edge(src, tgt, **attrs)
78
+ return G
79
+
80
+ def compute_betweenness_centrality(self, nodes: list,
81
+ edges: list) -> list:
82
+ nx = self._nx
83
+ G = self._build_nx_graph(nodes, edges, directed=False)
84
+
85
+ if G.number_of_nodes() < 3:
86
+ logger.warning("[GraphAnalytics] Too few nodes for centrality")
87
+ return []
88
+
89
+ centrality = nx.betweenness_centrality(G, normalized=True)
90
+ results = []
91
+ for node_id, score in sorted(
92
+ centrality.items(), key=lambda x: x[1], reverse=True
93
+ )[:20]:
94
+ attrs = G.nodes.get(node_id, {})
95
+ results.append({
96
+ "entity_id": node_id,
97
+ "name": attrs.get("name", node_id),
98
+ "type": attrs.get("type", "Unknown"),
99
+ "betweenness_centrality": round(score, 6),
100
+ "interpretation": (
101
+ "High betweenness: entity acts as a key bridge "
102
+ "between institutional networks"
103
+ if score > 0.1 else
104
+ "Low betweenness: entity is not a primary network bridge"
105
+ ),
106
+ })
107
+
108
+ logger.success(
109
+ f"[GraphAnalytics] Betweenness computed for {len(centrality)} nodes. "
110
+ f"Top: {results[0]['name']} ({results[0]['betweenness_centrality']:.4f})"
111
+ if results else
112
+ "[GraphAnalytics] Betweenness computed — no results"
113
+ )
114
+ return results
115
+
116
+ def compute_pagerank(self, nodes: list, edges: list) -> list:
117
+ nx = self._nx
118
+ G = self._build_nx_graph(nodes, edges, directed=True)
119
+
120
+ if G.number_of_nodes() < 2:
121
+ return []
122
+
123
+ pagerank = nx.pagerank(G, alpha=0.85)
124
+ results = []
125
+ for node_id, score in sorted(
126
+ pagerank.items(), key=lambda x: x[1], reverse=True
127
+ )[:20]:
128
+ attrs = G.nodes.get(node_id, {})
129
+ results.append({
130
+ "entity_id": node_id,
131
+ "name": attrs.get("name", node_id),
132
+ "type": attrs.get("type", "Unknown"),
133
+ "pagerank": round(score, 6),
134
+ })
135
+
136
+ logger.success(
137
+ f"[GraphAnalytics] PageRank computed for {len(pagerank)} nodes"
138
+ )
139
+ return results
140
+
141
+ def detect_communities(self, nodes: list, edges: list) -> list:
142
+ nx = self._nx
143
+ G = self._build_nx_graph(nodes, edges, directed=False)
144
+
145
+ if G.number_of_nodes() < 4:
146
+ return []
147
+
148
+ try:
149
+ from networkx.algorithms.community import greedy_modularity_communities
150
+ communities = list(greedy_modularity_communities(G))
151
+ except Exception:
152
+ communities = list(nx.connected_components(G))
153
+
154
+ results = []
155
+ for i, community in enumerate(communities):
156
+ if len(community) < 2:
157
+ continue
158
+ members = []
159
+ for node_id in community:
160
+ attrs = G.nodes.get(node_id, {})
161
+ members.append({
162
+ "id": node_id,
163
+ "name": attrs.get("name", node_id),
164
+ "type": attrs.get("type", "Unknown"),
165
+ })
166
+ results.append({
167
+ "community_id": i + 1,
168
+ "size": len(community),
169
+ "members": members,
170
+ "interpretation": (
171
+ "Large community detected — may indicate a procurement "
172
+ "cluster or shared-director network warranting review"
173
+ if len(community) >= 5 else
174
+ "Small community — limited network cluster"
175
+ ),
176
+ })
177
+
178
+ logger.success(
179
+ f"[GraphAnalytics] {len(results)} communities detected"
180
+ )
181
+ return results
182
+
183
+ def write_centrality_to_graph(self, results: list, metric: str):
184
+ if not self.driver or not results:
185
+ return
186
+ with self.driver.session() as session:
187
+ for r in results:
188
+ session.run(
189
+ f"MATCH (n {{id: $id}}) SET n.{metric} = $score",
190
+ id=r["entity_id"],
191
+ score=r.get(metric, 0.0),
192
+ )
193
+ logger.success(
194
+ f"[GraphAnalytics] Wrote {metric} scores to {len(results)} nodes"
195
+ )
196
+
197
+ def run_full_analysis(self, entity_id: str = None) -> dict:
198
+ logger.info("[GraphAnalytics] Running full graph analysis")
199
+ nodes, edges = self._fetch_graph_from_neo4j(entity_id)
200
+
201
+ if not nodes:
202
+ logger.warning("[GraphAnalytics] No graph data from Neo4j")
203
+ return {"status": "no_data", "analyzed_at": datetime.now().isoformat()}
204
+
205
+ betweenness = self.compute_betweenness_centrality(nodes, edges)
206
+ pagerank = self.compute_pagerank(nodes, edges)
207
+ communities = self.detect_communities(nodes, edges)
208
+
209
+ if self.driver:
210
+ self.write_centrality_to_graph(betweenness, "betweenness_centrality")
211
+ self.write_centrality_to_graph(pagerank, "pagerank")
212
+
213
+ return {
214
+ "node_count": len(nodes),
215
+ "edge_count": len(edges),
216
+ "top_betweenness": betweenness[:5],
217
+ "top_pagerank": pagerank[:5],
218
+ "communities": communities[:10],
219
+ "analyzed_at": datetime.now().isoformat(),
220
+ }
221
+
222
+
223
+ if __name__ == "__main__":
224
+ print("=" * 55)
225
+ print("BharatGraph - Graph Analytics Test")
226
+ print("=" * 55)
227
+
228
+ sample_nodes = [
229
+ ("P001", {"name": "Politician A", "type": "Politician"}),
230
+ ("P002", {"name": "Politician B", "type": "Politician"}),
231
+ ("C001", {"name": "Company X", "type": "Company"}),
232
+ ("C002", {"name": "Company Y", "type": "Company"}),
233
+ ("C003", {"name": "Company Z", "type": "Company"}),
234
+ ("CT01", {"name": "Contract 1", "type": "Contract"}),
235
+ ("CT02", {"name": "Contract 2", "type": "Contract"}),
236
+ ("M001", {"name": "Ministry A", "type": "Ministry"}),
237
+ ]
238
+ sample_edges = [
239
+ ("P001", "C001", {"rel": "DIRECTOR_OF"}),
240
+ ("P001", "C002", {"rel": "DIRECTOR_OF"}),
241
+ ("P002", "C003", {"rel": "DIRECTOR_OF"}),
242
+ ("C001", "CT01", {"rel": "WON_CONTRACT"}),
243
+ ("C002", "CT01", {"rel": "WON_CONTRACT"}),
244
+ ("C003", "CT02", {"rel": "WON_CONTRACT"}),
245
+ ("M001", "CT01", {"rel": "AWARDED_BY"}),
246
+ ("M001", "CT02", {"rel": "AWARDED_BY"}),
247
+ ("P001", "P002", {"rel": "MEMBER_OF"}),
248
+ ]
249
+
250
+ analytics = GraphAnalytics(driver=None)
251
+
252
+ print("\n Betweenness Centrality:")
253
+ bc = analytics.compute_betweenness_centrality(sample_nodes, sample_edges)
254
+ for r in bc[:3]:
255
+ print(f" {r['name']:20s} {r['betweenness_centrality']:.4f}")
256
+
257
+ print("\n PageRank:")
258
+ pr = analytics.compute_pagerank(sample_nodes, sample_edges)
259
+ for r in pr[:3]:
260
+ print(f" {r['name']:20s} {r['pagerank']:.4f}")
261
+
262
+ print("\n Communities:")
263
+ comms = analytics.detect_communities(sample_nodes, sample_edges)
264
+ for c in comms:
265
+ names = [m["name"] for m in c["members"]]
266
+ print(f" Community {c['community_id']}: {names}")
267
+
268
+ print("\nDone!")