Spaces:
Running
Running
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.
- ai/circular_ownership.py +130 -0
- 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!")
|