Spaces:
Runtime error
Runtime error
Commit ·
c802754
1
Parent(s): 4283c81
feat: Implement caching for API responses and SHAP explanations to improve performance and reduce redundant computations.
Browse files- .gitignore +1 -1
- api/routes/graph_api.py +21 -19
- api/routes/investigation.py +22 -17
- api/routes/overview.py +7 -23
- frontend/pages/investigation.html +229 -16
- server.py +106 -15
- src/ml/explainer.py +16 -8
- src/ml/predictor.py +9 -9
- src/state.py +8 -0
.gitignore
CHANGED
|
@@ -122,5 +122,5 @@ Thumbs.db
|
|
| 122 |
|
| 123 |
# Antigravity / Agent files
|
| 124 |
.agent/
|
| 125 |
-
|
| 126 |
|
|
|
|
| 122 |
|
| 123 |
# Antigravity / Agent files
|
| 124 |
.agent/
|
| 125 |
+
|
| 126 |
|
api/routes/graph_api.py
CHANGED
|
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Query, HTTPException
|
|
| 2 |
from typing import Optional
|
| 3 |
import networkx as nx
|
| 4 |
from src.state import AppState
|
| 5 |
-
from src.graph_builder import get_subgraph
|
| 6 |
|
| 7 |
router = APIRouter()
|
| 8 |
|
|
@@ -13,9 +13,9 @@ async def get_account_graph(
|
|
| 13 |
max_nodes: int = Query(100, ge=10, le=500)
|
| 14 |
):
|
| 15 |
G = AppState.graph
|
| 16 |
-
|
| 17 |
|
| 18 |
-
if G is None
|
| 19 |
raise HTTPException(status_code=503, detail="Graph data is still loading. Please wait.")
|
| 20 |
|
| 21 |
if account_id not in G:
|
|
@@ -25,23 +25,22 @@ async def get_account_graph(
|
|
| 25 |
|
| 26 |
if subG is None or subG.number_of_nodes() == 0:
|
| 27 |
return {"nodes": [], "edges": []}
|
| 28 |
-
|
| 29 |
-
#
|
| 30 |
-
|
| 31 |
-
|
| 32 |
nodes_data = []
|
| 33 |
-
edges_data = []
|
| 34 |
-
|
| 35 |
for n in subG.nodes():
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
risk = 0
|
| 39 |
pr = 0
|
| 40 |
is_fraud = False
|
| 41 |
-
else:
|
| 42 |
-
risk = int(row.iloc[0].get('risk_score', 0))
|
| 43 |
-
pr = float(row.iloc[0].get('pagerank_score', 0))
|
| 44 |
-
is_fraud = bool(row.iloc[0].get('fraud_flag', 0))
|
| 45 |
|
| 46 |
nodes_data.append({
|
| 47 |
"data": {
|
|
@@ -50,13 +49,13 @@ async def get_account_graph(
|
|
| 50 |
"risk_score": risk,
|
| 51 |
"pagerank": pr,
|
| 52 |
"is_fraud": is_fraud,
|
| 53 |
-
"community":
|
| 54 |
"is_target": (n == account_id)
|
| 55 |
}
|
| 56 |
})
|
| 57 |
|
| 58 |
-
|
| 59 |
-
|
| 60 |
"data": {
|
| 61 |
"source": u,
|
| 62 |
"target": v,
|
|
@@ -64,9 +63,12 @@ async def get_account_graph(
|
|
| 64 |
"tx_count": int(d.get('tx_count', 1)),
|
| 65 |
"payment_type": d.get('payment_type', 'Unknown'),
|
| 66 |
}
|
| 67 |
-
}
|
|
|
|
|
|
|
| 68 |
|
| 69 |
return {
|
| 70 |
"nodes": nodes_data,
|
| 71 |
"edges": edges_data
|
| 72 |
}
|
|
|
|
|
|
| 2 |
from typing import Optional
|
| 3 |
import networkx as nx
|
| 4 |
from src.state import AppState
|
| 5 |
+
from src.graph_builder import get_subgraph
|
| 6 |
|
| 7 |
router = APIRouter()
|
| 8 |
|
|
|
|
| 13 |
max_nodes: int = Query(100, ge=10, le=500)
|
| 14 |
):
|
| 15 |
G = AppState.graph
|
| 16 |
+
fba = AppState.features_by_account
|
| 17 |
|
| 18 |
+
if G is None:
|
| 19 |
raise HTTPException(status_code=503, detail="Graph data is still loading. Please wait.")
|
| 20 |
|
| 21 |
if account_id not in G:
|
|
|
|
| 25 |
|
| 26 |
if subG is None or subG.number_of_nodes() == 0:
|
| 27 |
return {"nodes": [], "edges": []}
|
| 28 |
+
|
| 29 |
+
# Use pre-computed global partition — no per-request Louvain
|
| 30 |
+
global_partition = AppState.louvain_partition or {}
|
| 31 |
+
|
| 32 |
nodes_data = []
|
|
|
|
|
|
|
| 33 |
for n in subG.nodes():
|
| 34 |
+
# O(1) dict lookup instead of per-node DataFrame filter
|
| 35 |
+
feat = fba.get(n)
|
| 36 |
+
if feat:
|
| 37 |
+
risk = int(feat.get('risk_score', 0))
|
| 38 |
+
pr = float(feat.get('pagerank_score', 0))
|
| 39 |
+
is_fraud = bool(feat.get('fraud_flag', 0))
|
| 40 |
+
else:
|
| 41 |
risk = 0
|
| 42 |
pr = 0
|
| 43 |
is_fraud = False
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
nodes_data.append({
|
| 46 |
"data": {
|
|
|
|
| 49 |
"risk_score": risk,
|
| 50 |
"pagerank": pr,
|
| 51 |
"is_fraud": is_fraud,
|
| 52 |
+
"community": global_partition.get(n, 0),
|
| 53 |
"is_target": (n == account_id)
|
| 54 |
}
|
| 55 |
})
|
| 56 |
|
| 57 |
+
edges_data = [
|
| 58 |
+
{
|
| 59 |
"data": {
|
| 60 |
"source": u,
|
| 61 |
"target": v,
|
|
|
|
| 63 |
"tx_count": int(d.get('tx_count', 1)),
|
| 64 |
"payment_type": d.get('payment_type', 'Unknown'),
|
| 65 |
}
|
| 66 |
+
}
|
| 67 |
+
for u, v, d in subG.edges(data=True)
|
| 68 |
+
]
|
| 69 |
|
| 70 |
return {
|
| 71 |
"nodes": nodes_data,
|
| 72 |
"edges": edges_data
|
| 73 |
}
|
| 74 |
+
|
api/routes/investigation.py
CHANGED
|
@@ -7,22 +7,26 @@ router = APIRouter()
|
|
| 7 |
|
| 8 |
@router.get("/account/{account_id}")
|
| 9 |
async def get_account_details(account_id: str, tx_limit: int = Query(default=200, ge=1, le=500)):
|
| 10 |
-
|
| 11 |
df = AppState.df
|
| 12 |
alerts = AppState.alerts
|
| 13 |
|
| 14 |
-
if df is None or
|
| 15 |
raise HTTPException(status_code=503, detail="Data is still loading. Please wait a moment and try again.")
|
| 16 |
|
| 17 |
-
|
| 18 |
-
if
|
| 19 |
raise HTTPException(status_code=404, detail="Account not found in feature set")
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
txns = df[(df['source'] == account_id) | (df['target'] == account_id)].copy()
|
| 25 |
total_tx_count = len(txns)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
txns = txns.sort_values('timestamp', ascending=False).head(tx_limit)
|
| 27 |
txns['timestamp'] = txns['timestamp'].astype(str)
|
| 28 |
|
|
@@ -30,19 +34,20 @@ async def get_account_details(account_id: str, tx_limit: int = Query(default=200
|
|
| 30 |
acct_alerts = [a for a in (alerts or []) if a['account'] == account_id]
|
| 31 |
|
| 32 |
# Generate SHAP explanation on the fly
|
| 33 |
-
|
|
|
|
| 34 |
|
| 35 |
return {
|
| 36 |
"account_id": account_id,
|
| 37 |
"metrics": {
|
| 38 |
-
"total_sent": metrics.get('amount_sent_total', 0),
|
| 39 |
-
"total_received": metrics.get('amount_received_total', 0),
|
| 40 |
-
"tx_count": metrics.get('tx_count_total', 0),
|
| 41 |
-
"risk_score": metrics.get('risk_score', 0),
|
| 42 |
-
"gnn_risk_score": metrics.get('gnn_fraud_score', 0),
|
| 43 |
-
"fraud_probability": metrics.get('fraud_probability', 0),
|
| 44 |
-
"pagerank": metrics.get('pagerank_score', 0),
|
| 45 |
-
"betweenness": metrics.get('betweenness_score', 0),
|
| 46 |
},
|
| 47 |
"transactions": txns.to_dict(orient="records"),
|
| 48 |
"total_tx_count": total_tx_count,
|
|
|
|
| 7 |
|
| 8 |
@router.get("/account/{account_id}")
|
| 9 |
async def get_account_details(account_id: str, tx_limit: int = Query(default=200, ge=1, le=500)):
|
| 10 |
+
fba = AppState.features_by_account
|
| 11 |
df = AppState.df
|
| 12 |
alerts = AppState.alerts
|
| 13 |
|
| 14 |
+
if df is None or not fba or not AppState.startup_ready:
|
| 15 |
raise HTTPException(status_code=503, detail="Data is still loading. Please wait a moment and try again.")
|
| 16 |
|
| 17 |
+
metrics = fba.get(account_id)
|
| 18 |
+
if not metrics:
|
| 19 |
raise HTTPException(status_code=404, detail="Account not found in feature set")
|
| 20 |
|
| 21 |
+
# Fast filtering for transactions
|
| 22 |
+
mask = (df['source'] == account_id) | (df['target'] == account_id)
|
| 23 |
+
txns = df[mask].copy()
|
|
|
|
| 24 |
total_tx_count = len(txns)
|
| 25 |
+
|
| 26 |
+
# Sort only the tail if there are too many (faster than sorting all 50k txns for one account)
|
| 27 |
+
if total_tx_count > tx_limit * 5:
|
| 28 |
+
txns = txns.tail(tx_limit * 5)
|
| 29 |
+
|
| 30 |
txns = txns.sort_values('timestamp', ascending=False).head(tx_limit)
|
| 31 |
txns['timestamp'] = txns['timestamp'].astype(str)
|
| 32 |
|
|
|
|
| 34 |
acct_alerts = [a for a in (alerts or []) if a['account'] == account_id]
|
| 35 |
|
| 36 |
# Generate SHAP explanation on the fly
|
| 37 |
+
feature_hash = id(AppState.full_features) if AppState.full_features is not None else 0
|
| 38 |
+
shap_expl = explain_prediction(account_id, feature_hash, top_n=5)
|
| 39 |
|
| 40 |
return {
|
| 41 |
"account_id": account_id,
|
| 42 |
"metrics": {
|
| 43 |
+
"total_sent": float(metrics.get('amount_sent_total', 0)),
|
| 44 |
+
"total_received": float(metrics.get('amount_received_total', 0)),
|
| 45 |
+
"tx_count": int(metrics.get('tx_count_total', 0)),
|
| 46 |
+
"risk_score": int(metrics.get('risk_score', 0)),
|
| 47 |
+
"gnn_risk_score": float(metrics.get('gnn_fraud_score', 0)),
|
| 48 |
+
"fraud_probability": float(metrics.get('fraud_probability', 0)),
|
| 49 |
+
"pagerank": float(metrics.get('pagerank_score', 0)),
|
| 50 |
+
"betweenness": float(metrics.get('betweenness_score', 0)),
|
| 51 |
},
|
| 52 |
"transactions": txns.to_dict(orient="records"),
|
| 53 |
"total_tx_count": total_tx_count,
|
api/routes/overview.py
CHANGED
|
@@ -10,7 +10,7 @@ async def get_overview():
|
|
| 10 |
alerts = AppState.alerts
|
| 11 |
|
| 12 |
# Guard: if data hasn't loaded yet, return a safe loading response
|
| 13 |
-
if df is None or fdf is None:
|
| 14 |
return {
|
| 15 |
"loading": True,
|
| 16 |
"system_health": {},
|
|
@@ -21,11 +21,7 @@ async def get_overview():
|
|
| 21 |
"channel_stats": [], "graph_stats": {"nodes": 0, "edges": 0}
|
| 22 |
}
|
| 23 |
|
| 24 |
-
tot_tx = len(df)
|
| 25 |
-
flagged_tx = len(df[df['is_laundering'] == 1])
|
| 26 |
-
tot_vol = float(df['amount'].sum())
|
| 27 |
active_accts = len(fdf)
|
| 28 |
-
|
| 29 |
crit_alerts = len([a for a in alerts if a['risk_score'] >= 75])
|
| 30 |
|
| 31 |
# Generate Typology Counts
|
|
@@ -36,21 +32,9 @@ async def get_overview():
|
|
| 36 |
for t in types:
|
| 37 |
typo_counts[t] = typo_counts.get(t, 0) + 1
|
| 38 |
|
| 39 |
-
# Channel Stats
|
| 40 |
-
channel_counts = df['payment_type'].value_counts()
|
| 41 |
-
fraud_channel_counts = df[df['is_laundering'] == 1]['payment_type'].value_counts()
|
| 42 |
-
|
| 43 |
-
channel_stats = []
|
| 44 |
-
for p_type, count in channel_counts.items():
|
| 45 |
-
channel_stats.append({
|
| 46 |
-
'channel': p_type,
|
| 47 |
-
'count': int(count),
|
| 48 |
-
'fraud_count': int(fraud_channel_counts.get(p_type, 0))
|
| 49 |
-
})
|
| 50 |
-
|
| 51 |
# Graph Stats
|
| 52 |
-
nodes = AppState.graph.number_of_nodes()
|
| 53 |
-
edges = AppState.graph.number_of_edges()
|
| 54 |
|
| 55 |
# System Health Checks
|
| 56 |
system_health = {
|
|
@@ -64,14 +48,14 @@ async def get_overview():
|
|
| 64 |
|
| 65 |
return {
|
| 66 |
"system_health": system_health,
|
| 67 |
-
"total_transactions":
|
| 68 |
-
"flagged_transactions":
|
| 69 |
-
"total_volume":
|
| 70 |
"active_accounts": active_accts,
|
| 71 |
"alerts_generated": len(alerts),
|
| 72 |
"critical_alerts": crit_alerts,
|
| 73 |
"model_auc": AppState.model_metrics.get('auc_roc', 0.0) if AppState.model_metrics else 0.0,
|
| 74 |
"typology_counts": typo_counts,
|
| 75 |
-
"channel_stats":
|
| 76 |
"graph_stats": {"nodes": nodes, "edges": edges}
|
| 77 |
}
|
|
|
|
| 10 |
alerts = AppState.alerts
|
| 11 |
|
| 12 |
# Guard: if data hasn't loaded yet, return a safe loading response
|
| 13 |
+
if df is None or fdf is None or not AppState.startup_ready:
|
| 14 |
return {
|
| 15 |
"loading": True,
|
| 16 |
"system_health": {},
|
|
|
|
| 21 |
"channel_stats": [], "graph_stats": {"nodes": 0, "edges": 0}
|
| 22 |
}
|
| 23 |
|
|
|
|
|
|
|
|
|
|
| 24 |
active_accts = len(fdf)
|
|
|
|
| 25 |
crit_alerts = len([a for a in alerts if a['risk_score'] >= 75])
|
| 26 |
|
| 27 |
# Generate Typology Counts
|
|
|
|
| 32 |
for t in types:
|
| 33 |
typo_counts[t] = typo_counts.get(t, 0) + 1
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
# Graph Stats
|
| 36 |
+
nodes = AppState.graph.number_of_nodes() if AppState.graph else 0
|
| 37 |
+
edges = AppState.graph.number_of_edges() if AppState.graph else 0
|
| 38 |
|
| 39 |
# System Health Checks
|
| 40 |
system_health = {
|
|
|
|
| 48 |
|
| 49 |
return {
|
| 50 |
"system_health": system_health,
|
| 51 |
+
"total_transactions": AppState.cached_overview.get('total_transactions', 0),
|
| 52 |
+
"flagged_transactions": AppState.cached_overview.get('flagged_transactions', 0),
|
| 53 |
+
"total_volume": AppState.cached_overview.get('total_volume', 0),
|
| 54 |
"active_accounts": active_accts,
|
| 55 |
"alerts_generated": len(alerts),
|
| 56 |
"critical_alerts": crit_alerts,
|
| 57 |
"model_auc": AppState.model_metrics.get('auc_roc', 0.0) if AppState.model_metrics else 0.0,
|
| 58 |
"typology_counts": typo_counts,
|
| 59 |
+
"channel_stats": AppState.cached_channel_stats,
|
| 60 |
"graph_stats": {"nodes": nodes, "edges": edges}
|
| 61 |
}
|
frontend/pages/investigation.html
CHANGED
|
@@ -30,9 +30,10 @@
|
|
| 30 |
<label class="text-[10px] font-bold uppercase tracking-widest text-on-surface-variant">Max Nodes</label>
|
| 31 |
<select id="inv-nodes"
|
| 32 |
class="bg-surface-container border-none text-xs rounded py-2 pl-3 pr-8 focus:ring-1 focus:ring-primary/20">
|
|
|
|
|
|
|
| 33 |
<option value="50">50 Nodes</option>
|
| 34 |
-
<option value="100"
|
| 35 |
-
<option value="250">250 Nodes</option>
|
| 36 |
</select>
|
| 37 |
</div>
|
| 38 |
<button onclick="window.loadInvestigation()"
|
|
@@ -90,13 +91,40 @@
|
|
| 90 |
|
| 91 |
<!-- Graph View -->
|
| 92 |
<section
|
| 93 |
-
class="bg-surface-container-lowest rounded shadow-sm border border-outline-variant/10 flex flex-col h-[
|
| 94 |
<div
|
| 95 |
class="px-6 py-3 flex justify-between border-b border-outline-variant/10 z-20 bg-surface-container-lowest">
|
| 96 |
-
<h3 class="font-bold text-sm uppercase flex items-center gap-2">
|
| 97 |
-
<span class="material-symbols-outlined
|
| 98 |
</h3>
|
| 99 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
<div id="cy-container" class="flex-1 w-full h-full bg-[#f2f4f6]/40 relative z-10"></div>
|
| 101 |
</section>
|
| 102 |
|
|
@@ -323,29 +351,61 @@
|
|
| 323 |
if (n.data('is_target')) return '#005596';
|
| 324 |
if (n.data('risk_score') > 75) return '#ba1a1a';
|
| 325 |
if (n.data('risk_score') > 40) return '#c6e4f4';
|
| 326 |
-
return '#
|
| 327 |
},
|
| 328 |
'width': (n) => 20 + (n.data('pagerank') / maxPr) * 40,
|
| 329 |
'height': (n) => 20 + (n.data('pagerank') / maxPr) * 40,
|
| 330 |
-
|
| 331 |
-
'
|
|
|
|
|
|
|
| 332 |
'color': '#191c1e',
|
| 333 |
'text-valign': 'top',
|
| 334 |
'text-halign': 'center',
|
| 335 |
-
'text-margin-y': -
|
| 336 |
-
'
|
| 337 |
-
'
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
}
|
| 339 |
},
|
| 340 |
{
|
| 341 |
selector: 'edge',
|
| 342 |
style: {
|
| 343 |
-
'width': (e) => Math.max(1, Math.log10(e.data('amount')) -
|
| 344 |
-
'line-color':
|
| 345 |
-
'target-arrow-color':
|
| 346 |
'target-arrow-shape': 'triangle',
|
|
|
|
| 347 |
'curve-style': 'bezier',
|
| 348 |
-
'opacity': 0.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
}
|
| 350 |
}
|
| 351 |
],
|
|
@@ -353,7 +413,160 @@
|
|
| 353 |
name: 'concentric',
|
| 354 |
concentric: function (n) { return n.data('is_target') ? 10 : n.data('pagerank'); },
|
| 355 |
levelWidth: function (nodes) { return maxPr / 4; },
|
| 356 |
-
padding:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
}
|
| 358 |
});
|
| 359 |
};
|
|
|
|
| 30 |
<label class="text-[10px] font-bold uppercase tracking-widest text-on-surface-variant">Max Nodes</label>
|
| 31 |
<select id="inv-nodes"
|
| 32 |
class="bg-surface-container border-none text-xs rounded py-2 pl-3 pr-8 focus:ring-1 focus:ring-primary/20">
|
| 33 |
+
<option value="10">10 Nodes</option>
|
| 34 |
+
<option value="25" selected>25 Nodes</option>
|
| 35 |
<option value="50">50 Nodes</option>
|
| 36 |
+
<option value="100">100 Nodes</option>
|
|
|
|
| 37 |
</select>
|
| 38 |
</div>
|
| 39 |
<button onclick="window.loadInvestigation()"
|
|
|
|
| 91 |
|
| 92 |
<!-- Graph View -->
|
| 93 |
<section
|
| 94 |
+
class="bg-surface-container-lowest rounded shadow-sm border border-outline-variant/10 flex flex-col h-[600px] relative">
|
| 95 |
<div
|
| 96 |
class="px-6 py-3 flex justify-between border-b border-outline-variant/10 z-20 bg-surface-container-lowest">
|
| 97 |
+
<h3 class="font-bold text-sm uppercase flex items-center gap-2 text-error">
|
| 98 |
+
<span class="material-symbols-outlined">warning</span> Suspicious Fund Flow — Pattern Detected
|
| 99 |
</h3>
|
| 100 |
</div>
|
| 101 |
+
|
| 102 |
+
<!-- Graph Legend -->
|
| 103 |
+
<div class="absolute top-16 left-6 z-30 bg-surface-container-lowest/90 backdrop-blur border border-outline-variant/50 rounded shadow flex flex-col p-3 text-[10px] pointer-events-none">
|
| 104 |
+
<div class="font-bold uppercase text-on-surface-variant mb-2">Legend</div>
|
| 105 |
+
<div class="flex items-center gap-2 mb-1"><div class="w-3 h-3 rounded-full bg-[#ba1a1a]"></div> <span class="font-bold">High-risk Accounts</span></div>
|
| 106 |
+
<div class="flex items-center gap-2 mb-1"><div class="w-3 h-3 rounded-full bg-[#c6e4f4]"></div> Medium risk</div>
|
| 107 |
+
<div class="flex items-center gap-2 mb-3"><div class="w-3 h-3 rounded-full bg-[#ffffff] border border-outline-variant"></div> Normal</div>
|
| 108 |
+
<div class="border-t border-outline-variant/50 pt-2 mb-1 flex items-center gap-2">
|
| 109 |
+
<div class="w-4 h-1 bg-outline rounded"></div> Thickness = Amount
|
| 110 |
+
</div>
|
| 111 |
+
<div class="flex items-center gap-2">
|
| 112 |
+
<span class="material-symbols-outlined text-[14px] text-outline">arrow_forward</span> Arrow = Direction
|
| 113 |
+
</div>
|
| 114 |
+
</div>
|
| 115 |
+
|
| 116 |
+
<!-- Graph Tooltip Hover -->
|
| 117 |
+
<div id="cy-tooltip" class="hidden absolute z-50 bg-surface-container-highest border border-outline-variant rounded p-2 text-xs shadow-md pointer-events-none font-mono text-on-surface"></div>
|
| 118 |
+
|
| 119 |
+
<!-- Graph Click Details Modal/Card -->
|
| 120 |
+
<div id="cy-click-card" class="hidden absolute top-16 right-4 z-40 bg-surface-container-lowest border border-outline-variant shadow-lg rounded-md w-72 flex flex-col">
|
| 121 |
+
<div class="p-3 border-b border-outline-variant/30 flex justify-between items-center bg-surface-container-low">
|
| 122 |
+
<h4 class="font-bold text-sm text-primary flex items-center gap-1"><span class="material-symbols-outlined text-sm text-primary">info</span> Details</h4>
|
| 123 |
+
<button onclick="document.getElementById('cy-click-card').classList.add('hidden')" class="text-outline hover:text-error transition-colors"><span class="material-symbols-outlined text-sm">close</span></button>
|
| 124 |
+
</div>
|
| 125 |
+
<div id="cy-click-content" class="p-4 flex flex-col gap-2 text-xs"></div>
|
| 126 |
+
</div>
|
| 127 |
+
|
| 128 |
<div id="cy-container" class="flex-1 w-full h-full bg-[#f2f4f6]/40 relative z-10"></div>
|
| 129 |
</section>
|
| 130 |
|
|
|
|
| 351 |
if (n.data('is_target')) return '#005596';
|
| 352 |
if (n.data('risk_score') > 75) return '#ba1a1a';
|
| 353 |
if (n.data('risk_score') > 40) return '#c6e4f4';
|
| 354 |
+
return '#ffffff';
|
| 355 |
},
|
| 356 |
'width': (n) => 20 + (n.data('pagerank') / maxPr) * 40,
|
| 357 |
'height': (n) => 20 + (n.data('pagerank') / maxPr) * 40,
|
| 358 |
+
// Label ONLY target or high-risk nodes to reduce clutter
|
| 359 |
+
'label': (n) => (n.data('is_target') || n.data('risk_score') > 75) ? n.data('id') : '',
|
| 360 |
+
'font-size': '10px',
|
| 361 |
+
'font-weight': 'bold',
|
| 362 |
'color': '#191c1e',
|
| 363 |
'text-valign': 'top',
|
| 364 |
'text-halign': 'center',
|
| 365 |
+
'text-margin-y': -4,
|
| 366 |
+
'text-background-color': '#ffffff',
|
| 367 |
+
'text-background-opacity': 0.7,
|
| 368 |
+
'text-background-padding': '2px',
|
| 369 |
+
'border-width': (n) => n.data('is_target') ? 4 : (n.data('risk_score') > 75 ? 2 : 1),
|
| 370 |
+
'border-color': (n) => n.data('is_target') ? '#c6e4f4' : '#727781',
|
| 371 |
+
'z-index': (n) => n.data('is_target') || n.data('risk_score') > 75 ? 100 : 10
|
| 372 |
}
|
| 373 |
},
|
| 374 |
{
|
| 375 |
selector: 'edge',
|
| 376 |
style: {
|
| 377 |
+
'width': (e) => Math.max(1, Math.log10(e.data('amount')) - 2),
|
| 378 |
+
'line-color': '#e2e5ea',
|
| 379 |
+
'target-arrow-color': '#e2e5ea',
|
| 380 |
'target-arrow-shape': 'triangle',
|
| 381 |
+
'arrow-scale': 1.2,
|
| 382 |
'curve-style': 'bezier',
|
| 383 |
+
'opacity': 0.8,
|
| 384 |
+
'z-index': 1
|
| 385 |
+
}
|
| 386 |
+
},
|
| 387 |
+
{
|
| 388 |
+
selector: '.highlighted-edge',
|
| 389 |
+
style: {
|
| 390 |
+
'line-color': '#ba1a1a',
|
| 391 |
+
'target-arrow-color': '#ba1a1a',
|
| 392 |
+
'width': (e) => Math.max(3, Math.log10(e.data('amount')) - 1),
|
| 393 |
+
'opacity': 1.0,
|
| 394 |
+
'arrow-scale': 1.5,
|
| 395 |
+
'z-index': 50,
|
| 396 |
+
'line-style': 'dashed',
|
| 397 |
+
'line-dash-pattern': [8, 4],
|
| 398 |
+
'line-dash-offset': 0
|
| 399 |
+
}
|
| 400 |
+
},
|
| 401 |
+
{
|
| 402 |
+
selector: '.highlighted-node',
|
| 403 |
+
style: {
|
| 404 |
+
'background-color': '#ba1a1a',
|
| 405 |
+
'label': 'data(id)',
|
| 406 |
+
'color': '#ba1a1a',
|
| 407 |
+
'font-size': '12px',
|
| 408 |
+
'z-index': 100
|
| 409 |
}
|
| 410 |
}
|
| 411 |
],
|
|
|
|
| 413 |
name: 'concentric',
|
| 414 |
concentric: function (n) { return n.data('is_target') ? 10 : n.data('pagerank'); },
|
| 415 |
levelWidth: function (nodes) { return maxPr / 4; },
|
| 416 |
+
padding: 40
|
| 417 |
+
}
|
| 418 |
+
});
|
| 419 |
+
|
| 420 |
+
// --- Find & Highlight Main Suspicious Path ---
|
| 421 |
+
const targetNode = cy.getElementById(targetId);
|
| 422 |
+
if (targetNode.length > 0) {
|
| 423 |
+
// Find edges with highest amount flowing into/out of target, trace back
|
| 424 |
+
// Simplify: Highlight any edge connected to a high risk node, or the top 3 highest amount edges
|
| 425 |
+
let highEdges = cy.edges().sort((a, b) => b.data('amount') - a.data('amount')).slice(0, 5);
|
| 426 |
+
// Also highlight paths between high risk nodes
|
| 427 |
+
let highNodes = cy.nodes().filter(n => n.data('risk_score') > 75 || n.data('is_target'));
|
| 428 |
+
|
| 429 |
+
let highlightCollection = cy.collection();
|
| 430 |
+
|
| 431 |
+
highNodes.forEach(node => {
|
| 432 |
+
let neighbors = node.neighborhood('edge');
|
| 433 |
+
// get max amount edge
|
| 434 |
+
if(neighbors.length > 0) {
|
| 435 |
+
let sorted = neighbors.sort((a,b) => b.data('amount') - a.data('amount'));
|
| 436 |
+
highlightCollection = highlightCollection.union(sorted[0]);
|
| 437 |
+
if(sorted.length > 1) highlightCollection = highlightCollection.union(sorted[1]);
|
| 438 |
+
}
|
| 439 |
+
highlightCollection = highlightCollection.union(node);
|
| 440 |
+
});
|
| 441 |
+
|
| 442 |
+
highlightCollection = highlightCollection.union(highEdges);
|
| 443 |
+
|
| 444 |
+
// Apply highlight classes
|
| 445 |
+
highlightCollection.edges().addClass('highlighted-edge');
|
| 446 |
+
|
| 447 |
+
// Show sequence tooltip on central node
|
| 448 |
+
targetNode.addClass('highlighted-node');
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
// --- Edge Animation Loop ---
|
| 452 |
+
let offset = 0;
|
| 453 |
+
function animateEdges() {
|
| 454 |
+
if (!cy || cy.destroyed()) return;
|
| 455 |
+
offset -= 1; // Moves dash pattern forward
|
| 456 |
+
cy.edges('.highlighted-edge').style('line-dash-offset', offset);
|
| 457 |
+
requestAnimationFrame(animateEdges);
|
| 458 |
+
}
|
| 459 |
+
animateEdges();
|
| 460 |
+
|
| 461 |
+
// --- Hover Interactivity ---
|
| 462 |
+
const tooltip = document.getElementById('cy-tooltip');
|
| 463 |
+
|
| 464 |
+
cy.on('mouseover', 'node', function(e) {
|
| 465 |
+
const data = e.target.data();
|
| 466 |
+
let extra = "";
|
| 467 |
+
if (data.is_target) extra = "<div class='text-error mt-1 font-bold'>Target Account</div>";
|
| 468 |
+
else if (data.risk_score > 75) extra = "<div class='text-error mt-1 font-bold'>High Risk (Detected)</div>";
|
| 469 |
+
else if (data.pagerank > maxPr / 2) extra = "<div class='text-tertiary mt-1 font-bold'>High Centrality (Hub)</div>";
|
| 470 |
+
|
| 471 |
+
tooltip.innerHTML = `
|
| 472 |
+
<div class="border-b border-outline-variant/30 pb-1 mb-1 font-bold">Node ${data.id}</div>
|
| 473 |
+
<div class="grid grid-cols-2 gap-x-4 gap-y-1">
|
| 474 |
+
<span class="text-on-surface-variant">Risk Score</span><span class="${data.risk_score > 75 ? 'text-error font-bold' : ''}">${data.risk_score}</span>
|
| 475 |
+
<span class="text-on-surface-variant">PageRank</span><span>${data.pagerank.toFixed(4)}</span>
|
| 476 |
+
</div>
|
| 477 |
+
${extra}
|
| 478 |
+
`;
|
| 479 |
+
tooltip.classList.remove('hidden');
|
| 480 |
+
});
|
| 481 |
+
|
| 482 |
+
cy.on('mousemove', 'node', function(e) {
|
| 483 |
+
tooltip.style.left = (e.renderedPosition.x + 15) + 'px';
|
| 484 |
+
tooltip.style.top = (e.renderedPosition.y + 15 + 48) + 'px'; // +48 offset for header
|
| 485 |
+
});
|
| 486 |
+
|
| 487 |
+
cy.on('mouseout', 'node', function(e) {
|
| 488 |
+
tooltip.classList.add('hidden');
|
| 489 |
+
});
|
| 490 |
+
|
| 491 |
+
cy.on('mouseover', 'edge', function(e) {
|
| 492 |
+
const data = e.target.data();
|
| 493 |
+
tooltip.innerHTML = `<strong>Type:</strong> ${data.payment_type}<br><strong>Amt:</strong> ₹${formatCurrency(data.amount)}<br><strong>Tx(s):</strong> ${data.tx_count}`;
|
| 494 |
+
tooltip.classList.remove('hidden');
|
| 495 |
+
});
|
| 496 |
+
|
| 497 |
+
cy.on('mousemove', 'edge', function(e) {
|
| 498 |
+
tooltip.style.left = (e.renderedPosition.x + 15) + 'px';
|
| 499 |
+
tooltip.style.top = (e.renderedPosition.y + 15 + 48) + 'px';
|
| 500 |
+
});
|
| 501 |
+
|
| 502 |
+
cy.on('mouseout', 'edge', function(e) {
|
| 503 |
+
tooltip.classList.add('hidden');
|
| 504 |
+
});
|
| 505 |
+
|
| 506 |
+
// --- Click Interactivity ---
|
| 507 |
+
const clickCard = document.getElementById('cy-click-card');
|
| 508 |
+
const clickContent = document.getElementById('cy-click-content');
|
| 509 |
+
|
| 510 |
+
cy.on('tap', 'node', function(e) {
|
| 511 |
+
const data = e.target.data();
|
| 512 |
+
clickContent.innerHTML = `
|
| 513 |
+
<div class="flex justify-between border-b border-outline-variant/10 pb-1 mb-1">
|
| 514 |
+
<span class="font-bold text-on-surface-variant">Account</span>
|
| 515 |
+
<span class="font-mono text-primary font-bold">${data.id}</span>
|
| 516 |
+
</div>
|
| 517 |
+
<div class="flex justify-between border-b border-outline-variant/10 pb-1 mb-1">
|
| 518 |
+
<span class="font-bold text-on-surface-variant">Risk Score</span>
|
| 519 |
+
<span class="${data.risk_score > 75 ? 'text-error font-bold' : ''}">${data.risk_score}</span>
|
| 520 |
+
</div>
|
| 521 |
+
<div class="flex justify-between border-b border-outline-variant/10 pb-1 mb-1">
|
| 522 |
+
<span class="font-bold text-on-surface-variant">Is Fraud</span>
|
| 523 |
+
<span>${data.is_fraud ? '<span class="text-error font-bold">Yes</span>' : 'No'}</span>
|
| 524 |
+
</div>
|
| 525 |
+
<div class="flex justify-between border-b border-outline-variant/10 pb-1 mb-1">
|
| 526 |
+
<span class="font-bold text-on-surface-variant">Community</span>
|
| 527 |
+
<span>${data.community}</span>
|
| 528 |
+
</div>
|
| 529 |
+
<div class="flex justify-between border-b border-outline-variant/10 pb-1 mb-2">
|
| 530 |
+
<span class="font-bold text-on-surface-variant">PageRank</span>
|
| 531 |
+
<span class="font-mono">${data.pagerank.toFixed(5)}</span>
|
| 532 |
+
</div>
|
| 533 |
+
<button onclick="document.getElementById('inv-acct-input').value='${data.id}'; window.loadInvestigation(); document.getElementById('cy-click-card').classList.add('hidden');" class="w-full py-1.5 bg-primary/10 hover:bg-primary/20 text-primary font-bold rounded flex justify-center items-center gap-1 transition-colors">
|
| 534 |
+
<span class="material-symbols-outlined text-[14px]">search</span> Inspect Account
|
| 535 |
+
</button>
|
| 536 |
+
`;
|
| 537 |
+
clickCard.classList.remove('hidden');
|
| 538 |
+
});
|
| 539 |
+
|
| 540 |
+
cy.on('tap', 'edge', function(e) {
|
| 541 |
+
const data = e.target.data();
|
| 542 |
+
clickContent.innerHTML = `
|
| 543 |
+
<div class="flex justify-between border-b border-outline-variant/10 pb-1 mb-1">
|
| 544 |
+
<span class="font-bold text-on-surface-variant">Source</span>
|
| 545 |
+
<span class="font-mono">${data.source}</span>
|
| 546 |
+
</div>
|
| 547 |
+
<div class="flex justify-between border-b border-outline-variant/10 pb-1 mb-1">
|
| 548 |
+
<span class="font-bold text-on-surface-variant">Target</span>
|
| 549 |
+
<span class="font-mono">${data.target}</span>
|
| 550 |
+
</div>
|
| 551 |
+
<div class="flex justify-between border-b border-outline-variant/10 pb-1 mb-1">
|
| 552 |
+
<span class="font-bold text-on-surface-variant">Amount</span>
|
| 553 |
+
<span class="font-mono text-error font-bold">₹${formatCurrency(data.amount)}</span>
|
| 554 |
+
</div>
|
| 555 |
+
<div class="flex justify-between border-b border-outline-variant/10 pb-1 mb-1">
|
| 556 |
+
<span class="font-bold text-on-surface-variant">Channel</span>
|
| 557 |
+
<span class="uppercase">${data.payment_type}</span>
|
| 558 |
+
</div>
|
| 559 |
+
<div class="flex justify-between border-b border-outline-variant/10 pb-1">
|
| 560 |
+
<span class="font-bold text-on-surface-variant">Num Tx</span>
|
| 561 |
+
<span>${data.tx_count}</span>
|
| 562 |
+
</div>
|
| 563 |
+
`;
|
| 564 |
+
clickCard.classList.remove('hidden');
|
| 565 |
+
});
|
| 566 |
+
|
| 567 |
+
cy.on('tap', function(e) {
|
| 568 |
+
if (e.target === cy) {
|
| 569 |
+
clickCard.classList.add('hidden');
|
| 570 |
}
|
| 571 |
});
|
| 572 |
};
|
server.py
CHANGED
|
@@ -5,6 +5,7 @@ FastAPI Server Entrypoint
|
|
| 5 |
- Frontend polls /api/status to show loading progress
|
| 6 |
"""
|
| 7 |
import os
|
|
|
|
| 8 |
import threading
|
| 9 |
import uvicorn
|
| 10 |
from fastapi import FastAPI
|
|
@@ -46,6 +47,44 @@ class Checkpoint:
|
|
| 46 |
except Exception as e:
|
| 47 |
print(f" ✗ Failed to save {name} checkpoint: {e}")
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
# ── FastAPI App ──────────────────────────────────────────────
|
| 51 |
app = FastAPI(title="Fund Flow Tracker API")
|
|
@@ -101,20 +140,27 @@ def background_init():
|
|
| 101 |
AppState.df = pd.DataFrame({'source':[], 'target':[], 'amount':[], 'timestamp':[], 'is_laundering':[], 'payment_type':[]})
|
| 102 |
AppState.node_features = None
|
| 103 |
|
| 104 |
-
# Step 2: Build Graph (
|
| 105 |
_update_status("Building transaction graph...", 2)
|
| 106 |
-
|
| 107 |
-
if
|
| 108 |
-
AppState.graph =
|
| 109 |
-
print(f" ✓ Loaded graph from checkpoint ({cached_graph.number_of_nodes()} nodes, {cached_graph.number_of_edges()} edges).")
|
| 110 |
else:
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
print(f"
|
| 116 |
-
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
|
| 119 |
gid = 1
|
| 120 |
|
|
@@ -251,8 +297,15 @@ def background_init():
|
|
| 251 |
acct_ids = AppState.full_features['account'].tolist() if 'account' in AppState.full_features.columns else []
|
| 252 |
if AppState.xgb_bundle and acct_ids:
|
| 253 |
scores = score_accounts_batch(acct_ids, AppState.full_features, AppState.xgb_bundle)
|
| 254 |
-
|
| 255 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
else:
|
| 257 |
AppState.full_features['risk_score'] = 0
|
| 258 |
AppState.full_features['fraud_probability'] = 0.0
|
|
@@ -265,7 +318,7 @@ def background_init():
|
|
| 265 |
try:
|
| 266 |
if hasattr(AppState.full_features, 'columns') and 'account' in AppState.full_features.columns:
|
| 267 |
gnn_scores = predict_gnn_score(AppState.graph, AppState.full_features)
|
| 268 |
-
AppState.full_features['gnn_fraud_score'] = AppState.full_features['account'].map(
|
| 269 |
else:
|
| 270 |
AppState.full_features['gnn_fraud_score'] = 0.0
|
| 271 |
except Exception as e:
|
|
@@ -273,6 +326,44 @@ def background_init():
|
|
| 273 |
if hasattr(AppState.full_features, 'columns') and 'gnn_fraud_score' not in AppState.full_features.columns:
|
| 274 |
AppState.full_features['gnn_fraud_score'] = 0.0
|
| 275 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
# DONE
|
| 277 |
AppState.startup_ready = True
|
| 278 |
AppState.startup_status['current_step'] = 'Ready'
|
|
|
|
| 5 |
- Frontend polls /api/status to show loading progress
|
| 6 |
"""
|
| 7 |
import os
|
| 8 |
+
import time
|
| 9 |
import threading
|
| 10 |
import uvicorn
|
| 11 |
from fastapi import FastAPI
|
|
|
|
| 47 |
except Exception as e:
|
| 48 |
print(f" ✗ Failed to save {name} checkpoint: {e}")
|
| 49 |
|
| 50 |
+
@classmethod
|
| 51 |
+
def save_graph_parquet(cls, graph: nx.MultiDiGraph):
|
| 52 |
+
"""Save graph as a Parquet edge list — much smaller and faster to reload."""
|
| 53 |
+
os.makedirs(cls.DIR, exist_ok=True)
|
| 54 |
+
path = os.path.join(cls.DIR, "graph_edges.parquet")
|
| 55 |
+
rows = []
|
| 56 |
+
for u, v, d in graph.edges(data=True):
|
| 57 |
+
rows.append({
|
| 58 |
+
'source': u, 'target': v,
|
| 59 |
+
'amount': d.get('amount', 0),
|
| 60 |
+
'payment_type': d.get('payment_type', ''),
|
| 61 |
+
'is_laundering': d.get('is_laundering', 0),
|
| 62 |
+
'timestamp': d.get('timestamp', ''),
|
| 63 |
+
})
|
| 64 |
+
pd.DataFrame(rows).to_parquet(path, index=False)
|
| 65 |
+
print(f" ✓ Saved graph edge list ({len(rows):,} edges) to Parquet.")
|
| 66 |
+
|
| 67 |
+
@classmethod
|
| 68 |
+
def load_graph_parquet(cls) -> nx.MultiDiGraph:
|
| 69 |
+
"""Rebuild graph from Parquet edge list — 10-20x faster than pickle."""
|
| 70 |
+
path = os.path.join(cls.DIR, "graph_edges.parquet")
|
| 71 |
+
if not os.path.exists(path):
|
| 72 |
+
return None
|
| 73 |
+
t0 = time.time()
|
| 74 |
+
df = pd.read_parquet(path)
|
| 75 |
+
G = nx.MultiDiGraph()
|
| 76 |
+
for row in df.itertuples(index=False):
|
| 77 |
+
G.add_edge(
|
| 78 |
+
row.source, row.target,
|
| 79 |
+
amount=row.amount,
|
| 80 |
+
payment_type=row.payment_type,
|
| 81 |
+
is_laundering=int(row.is_laundering),
|
| 82 |
+
timestamp=str(row.timestamp),
|
| 83 |
+
)
|
| 84 |
+
elapsed = time.time() - t0
|
| 85 |
+
print(f" ✓ Rebuilt graph from Parquet in {elapsed:.1f}s ({G.number_of_nodes():,} nodes, {G.number_of_edges():,} edges).")
|
| 86 |
+
return G
|
| 87 |
+
|
| 88 |
|
| 89 |
# ── FastAPI App ──────────────────────────────────────────────
|
| 90 |
app = FastAPI(title="Fund Flow Tracker API")
|
|
|
|
| 140 |
AppState.df = pd.DataFrame({'source':[], 'target':[], 'amount':[], 'timestamp':[], 'is_laundering':[], 'payment_type':[]})
|
| 141 |
AppState.node_features = None
|
| 142 |
|
| 143 |
+
# Step 2: Build Graph (fast Parquet edge-list, fallback to old pickle)
|
| 144 |
_update_status("Building transaction graph...", 2)
|
| 145 |
+
parquet_graph = Checkpoint.load_graph_parquet()
|
| 146 |
+
if parquet_graph is not None:
|
| 147 |
+
AppState.graph = parquet_graph
|
|
|
|
| 148 |
else:
|
| 149 |
+
# Try legacy pickle checkpoint
|
| 150 |
+
cached_graph = Checkpoint.load('graph')
|
| 151 |
+
if cached_graph is not None:
|
| 152 |
+
AppState.graph = cached_graph
|
| 153 |
+
print(f" ✓ Loaded graph from pickle ({cached_graph.number_of_nodes()} nodes, {cached_graph.number_of_edges()} edges).")
|
| 154 |
+
# Migrate: save as Parquet for next time
|
| 155 |
+
Checkpoint.save_graph_parquet(cached_graph)
|
| 156 |
+
else:
|
| 157 |
+
try:
|
| 158 |
+
AppState.graph = build_graph(AppState.df)
|
| 159 |
+
Checkpoint.save_graph_parquet(AppState.graph)
|
| 160 |
+
except Exception as e:
|
| 161 |
+
print(f" ✗ Graph build failed: {e}")
|
| 162 |
+
AppState.startup_status['errors'].append(f"Graph: {e}")
|
| 163 |
+
AppState.graph = nx.MultiDiGraph()
|
| 164 |
|
| 165 |
gid = 1
|
| 166 |
|
|
|
|
| 297 |
acct_ids = AppState.full_features['account'].tolist() if 'account' in AppState.full_features.columns else []
|
| 298 |
if AppState.xgb_bundle and acct_ids:
|
| 299 |
scores = score_accounts_batch(acct_ids, AppState.full_features, AppState.xgb_bundle)
|
| 300 |
+
# Vectorised: build scores DataFrame and merge — no lambdas
|
| 301 |
+
scores_df = pd.DataFrame([
|
| 302 |
+
{'account': a, 'risk_score': s['risk_score'] or 0, 'fraud_probability': s['fraud_probability'] or 0.0}
|
| 303 |
+
for a, s in scores.items()
|
| 304 |
+
])
|
| 305 |
+
AppState.full_features = AppState.full_features.drop(columns=['risk_score', 'fraud_probability'], errors='ignore')
|
| 306 |
+
AppState.full_features = AppState.full_features.merge(scores_df, on='account', how='left')
|
| 307 |
+
AppState.full_features['risk_score'] = AppState.full_features['risk_score'].fillna(0).astype(int)
|
| 308 |
+
AppState.full_features['fraud_probability'] = AppState.full_features['fraud_probability'].fillna(0.0)
|
| 309 |
else:
|
| 310 |
AppState.full_features['risk_score'] = 0
|
| 311 |
AppState.full_features['fraud_probability'] = 0.0
|
|
|
|
| 318 |
try:
|
| 319 |
if hasattr(AppState.full_features, 'columns') and 'account' in AppState.full_features.columns:
|
| 320 |
gnn_scores = predict_gnn_score(AppState.graph, AppState.full_features)
|
| 321 |
+
AppState.full_features['gnn_fraud_score'] = AppState.full_features['account'].map(gnn_scores).fillna(0.0)
|
| 322 |
else:
|
| 323 |
AppState.full_features['gnn_fraud_score'] = 0.0
|
| 324 |
except Exception as e:
|
|
|
|
| 326 |
if hasattr(AppState.full_features, 'columns') and 'gnn_fraud_score' not in AppState.full_features.columns:
|
| 327 |
AppState.full_features['gnn_fraud_score'] = 0.0
|
| 328 |
|
| 329 |
+
# Build pre-indexed lookup cache for O(1) API access
|
| 330 |
+
if hasattr(AppState.full_features, 'columns') and 'account' in AppState.full_features.columns:
|
| 331 |
+
AppState.features_by_account = AppState.full_features.set_index('account').to_dict('index')
|
| 332 |
+
else:
|
| 333 |
+
AppState.features_by_account = {}
|
| 334 |
+
|
| 335 |
+
# Pre-compute overview stats so /api/overview doesn't recompute each call
|
| 336 |
+
if AppState.df is not None and len(AppState.df) > 0:
|
| 337 |
+
channel_counts = AppState.df['payment_type'].value_counts()
|
| 338 |
+
fraud_channel_counts = AppState.df[AppState.df['is_laundering'] == 1]['payment_type'].value_counts()
|
| 339 |
+
AppState.cached_channel_stats = [
|
| 340 |
+
{'channel': p, 'count': int(c), 'fraud_count': int(fraud_channel_counts.get(p, 0))}
|
| 341 |
+
for p, c in channel_counts.items()
|
| 342 |
+
]
|
| 343 |
+
AppState.cached_overview = {
|
| 344 |
+
'total_transactions': len(AppState.df),
|
| 345 |
+
'flagged_transactions': int((AppState.df['is_laundering'] == 1).sum()),
|
| 346 |
+
'total_volume': float(AppState.df['amount'].sum()),
|
| 347 |
+
}
|
| 348 |
+
else:
|
| 349 |
+
AppState.cached_channel_stats = []
|
| 350 |
+
AppState.cached_overview = {}
|
| 351 |
+
|
| 352 |
+
if AppState.alerts:
|
| 353 |
+
crit_count = 0
|
| 354 |
+
typo_counts = {}
|
| 355 |
+
for a in AppState.alerts:
|
| 356 |
+
if a['risk_score'] >= 75:
|
| 357 |
+
crit_count += 1
|
| 358 |
+
types = [t.strip() for t in a['typology'].split(',')]
|
| 359 |
+
for t in types:
|
| 360 |
+
typo_counts[t] = typo_counts.get(t, 0) + 1
|
| 361 |
+
AppState.cached_crit_alerts = crit_count
|
| 362 |
+
AppState.cached_typo_counts = typo_counts
|
| 363 |
+
else:
|
| 364 |
+
AppState.cached_crit_alerts = 0
|
| 365 |
+
AppState.cached_typo_counts = {}
|
| 366 |
+
|
| 367 |
# DONE
|
| 368 |
AppState.startup_ready = True
|
| 369 |
AppState.startup_status['current_step'] = 'Ready'
|
src/ml/explainer.py
CHANGED
|
@@ -46,21 +46,29 @@ def _get_explainer(model):
|
|
| 46 |
return _explainer_cache
|
| 47 |
|
| 48 |
|
|
|
|
|
|
|
|
|
|
| 49 |
def explain_prediction(
|
| 50 |
account_id: str,
|
| 51 |
-
|
| 52 |
-
bundle: dict,
|
| 53 |
top_n: int = 5,
|
| 54 |
) -> list[dict]:
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
return []
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
if
|
| 60 |
return []
|
| 61 |
|
| 62 |
-
feature_cols = [c for c in bundle['feature_cols'] if c in
|
| 63 |
-
|
|
|
|
| 64 |
|
| 65 |
explainer = _get_explainer(bundle['model'])
|
| 66 |
shap_values = explainer.shap_values(X)
|
|
|
|
| 46 |
return _explainer_cache
|
| 47 |
|
| 48 |
|
| 49 |
+
from functools import lru_cache
|
| 50 |
+
|
| 51 |
+
@lru_cache(maxsize=100)
|
| 52 |
def explain_prediction(
|
| 53 |
account_id: str,
|
| 54 |
+
feature_df_hash: int, # Added hash so cache clears if features change
|
|
|
|
| 55 |
top_n: int = 5,
|
| 56 |
) -> list[dict]:
|
| 57 |
+
# Use the global state directly to avoid hashing the whole DataFrame
|
| 58 |
+
from src.state import AppState
|
| 59 |
+
fba = AppState.features_by_account
|
| 60 |
+
bundle = AppState.xgb_bundle
|
| 61 |
+
|
| 62 |
+
if not bundle or not fba:
|
| 63 |
return []
|
| 64 |
+
|
| 65 |
+
feat = fba.get(account_id)
|
| 66 |
+
if not feat:
|
| 67 |
return []
|
| 68 |
|
| 69 |
+
feature_cols = [c for c in bundle['feature_cols'] if c in feat]
|
| 70 |
+
# Build single-row DataFrame for SHAP
|
| 71 |
+
X = pd.DataFrame([feat])[feature_cols]
|
| 72 |
|
| 73 |
explainer = _get_explainer(bundle['model'])
|
| 74 |
shap_values = explainer.shap_values(X)
|
src/ml/predictor.py
CHANGED
|
@@ -64,7 +64,7 @@ def score_account(account_id: str, feature_df: pd.DataFrame, bundle: dict) -> di
|
|
| 64 |
|
| 65 |
|
| 66 |
def score_accounts_batch(account_ids: list, feature_df: pd.DataFrame, bundle: dict) -> dict:
|
| 67 |
-
"""Vectorised batch scoring —
|
| 68 |
rows = feature_df[feature_df['account'].isin(account_ids)]
|
| 69 |
if rows.empty:
|
| 70 |
return {aid: {'risk_score': None, 'fraud_probability': None, 'unscored': True}
|
|
@@ -72,14 +72,14 @@ def score_accounts_batch(account_ids: list, feature_df: pd.DataFrame, bundle: di
|
|
| 72 |
|
| 73 |
cols = [c for c in bundle['feature_cols'] if c in rows.columns]
|
| 74 |
probas = bundle['model'].predict_proba(rows[cols])[:, 1]
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
# Fill missing accounts
|
| 84 |
for aid in account_ids:
|
| 85 |
if aid not in result:
|
|
|
|
| 64 |
|
| 65 |
|
| 66 |
def score_accounts_batch(account_ids: list, feature_df: pd.DataFrame, bundle: dict) -> dict:
|
| 67 |
+
"""Vectorised batch scoring — fully numpy, no Python loops."""
|
| 68 |
rows = feature_df[feature_df['account'].isin(account_ids)]
|
| 69 |
if rows.empty:
|
| 70 |
return {aid: {'risk_score': None, 'fraud_probability': None, 'unscored': True}
|
|
|
|
| 72 |
|
| 73 |
cols = [c for c in bundle['feature_cols'] if c in rows.columns]
|
| 74 |
probas = bundle['model'].predict_proba(rows[cols])[:, 1]
|
| 75 |
+
|
| 76 |
+
# Vectorised: build result directly from numpy arrays — no itertuples
|
| 77 |
+
accounts = rows['account'].values
|
| 78 |
+
risk_scores = (probas * 99).astype(int)
|
| 79 |
+
result = {
|
| 80 |
+
acct: {'risk_score': int(rs), 'fraud_probability': float(p), 'unscored': False}
|
| 81 |
+
for acct, rs, p in zip(accounts, risk_scores, probas)
|
| 82 |
+
}
|
| 83 |
# Fill missing accounts
|
| 84 |
for aid in account_ids:
|
| 85 |
if aid not in result:
|
src/state.py
CHANGED
|
@@ -10,6 +10,13 @@ class AppState:
|
|
| 10 |
xgb_bundle = None
|
| 11 |
model_metrics = None
|
| 12 |
gnn_metrics = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
# Startup tracking — lets the frontend show a loading screen
|
| 15 |
startup_ready = False
|
|
@@ -19,3 +26,4 @@ class AppState:
|
|
| 19 |
'total_steps': 10,
|
| 20 |
'errors': []
|
| 21 |
}
|
|
|
|
|
|
| 10 |
xgb_bundle = None
|
| 11 |
model_metrics = None
|
| 12 |
gnn_metrics = None
|
| 13 |
+
|
| 14 |
+
# Pre-built caches for fast API responses
|
| 15 |
+
features_by_account = {} # account_id -> feature dict (O(1) lookup)
|
| 16 |
+
cached_channel_stats = [] # pre-computed channel breakdown
|
| 17 |
+
cached_overview = {} # pre-computed overview numbers
|
| 18 |
+
cached_typo_counts = {} # pre-computed alerts typologies
|
| 19 |
+
cached_crit_alerts = 0 # pre-computed critical count
|
| 20 |
|
| 21 |
# Startup tracking — lets the frontend show a loading screen
|
| 22 |
startup_ready = False
|
|
|
|
| 26 |
'total_steps': 10,
|
| 27 |
'errors': []
|
| 28 |
}
|
| 29 |
+
|