Aniket2006 commited on
Commit
372c2d4
·
1 Parent(s): 7cee5a0

Implement ML fraud detection pipeline and interactive graph visualiser

Browse files
app.py CHANGED
@@ -1,7 +1,11 @@
1
  import streamlit as st
 
2
  from src.data_loader import get_processed_data
3
  import src.graph_builder as gb
4
  from src.detectors.alert_engine import get_all_alerts
 
 
 
5
  from collections import Counter
6
 
7
  # Constants
@@ -90,6 +94,49 @@ def main():
90
  else:
91
  st.warning(f"⚠️ Only {len(unique_typologies)} typologies detected — expected >= 3.")
92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
 
94
  except FileNotFoundError as e:
95
  st.error(f"Data file not found. Please ensure the raw data is placed at `data/raw/HI_Small_Trans.csv`. Details: {e}")
 
1
  import streamlit as st
2
+ import os
3
  from src.data_loader import get_processed_data
4
  import src.graph_builder as gb
5
  from src.detectors.alert_engine import get_all_alerts
6
+ from src.ml.features import engineer_features
7
+ from src.ml.trainer import train_model, MODEL_PATH
8
+ from src.ml.predictor import load_model, score_account
9
  from collections import Counter
10
 
11
  # Constants
 
94
  else:
95
  st.warning(f"⚠️ Only {len(unique_typologies)} typologies detected — expected >= 3.")
96
 
97
+ # --- ML Pipeline ---
98
+ st.subheader("ML Fraud Classifier")
99
+
100
+ # Gather cycle accounts from alerts
101
+ cycle_accounts = {a['account'] for a in alerts if 'RoundTripping' in a.get('typology', '')}
102
+
103
+ if not os.path.exists(MODEL_PATH):
104
+ with st.spinner("Engineering features..."):
105
+ feature_df = engineer_features(
106
+ transactions_df, G, pagerank_scores,
107
+ louvain_partition, cycle_accounts,
108
+ )
109
+ with st.spinner("Training XGBoost model..."):
110
+ model, scaler, metrics = train_model(feature_df)
111
+ st.write("**Model trained!** Metrics:")
112
+ st.write(f"AUC-ROC: {metrics['auc_roc']:.4f}")
113
+ st.write(f"F1: {metrics['f1']:.4f}")
114
+ st.write(f"Precision: {metrics['precision']:.4f}")
115
+ st.write(f"Recall: {metrics['recall']:.4f}")
116
+ if metrics['auc_roc'] >= 0.85:
117
+ st.success(f"✅ AUC-ROC = {metrics['auc_roc']:.4f} — above 0.85 threshold")
118
+ else:
119
+ st.warning(f"⚠️ AUC-ROC = {metrics['auc_roc']:.4f} — below 0.85 target")
120
+ else:
121
+ with st.spinner("Engineering features..."):
122
+ feature_df = engineer_features(
123
+ transactions_df, G, pagerank_scores,
124
+ louvain_partition, cycle_accounts,
125
+ )
126
+ st.write("Model already trained. Loading from disk.")
127
+
128
+ bundle = load_model()
129
+
130
+ # Score 3 sample accounts
131
+ st.subheader("Sample Account Scoring")
132
+ sample_accounts = feature_df['account'].head(3).tolist()
133
+ for acct in sample_accounts:
134
+ result = score_account(acct, feature_df, bundle)
135
+ st.write(
136
+ f"Account **{acct}**: Risk Score = {result['risk_score']}, "
137
+ f"Fraud Probability = {result['fraud_probability']:.4f}"
138
+ )
139
+
140
 
141
  except FileNotFoundError as e:
142
  st.error(f"Data file not found. Please ensure the raw data is placed at `data/raw/HI_Small_Trans.csv`. Details: {e}")
src/ml/explainer.py CHANGED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SHAP Explainer Module
3
+ Provides interpretable explanations for individual fraud predictions.
4
+ """
5
+ import shap
6
+ import pandas as pd
7
+
8
+ # Plain-English descriptions for every feature
9
+ FEATURE_DESCRIPTIONS = {
10
+ 'tx_count_total': 'Total number of outgoing transactions',
11
+ 'tx_count_7d': 'Transaction count in the last 7 days',
12
+ 'amount_sent_total': 'Total amount of funds sent',
13
+ 'amount_sent_7d': 'Amount of funds sent in the last 7 days',
14
+ 'amount_received_total': 'Total amount of funds received',
15
+ 'amount_received_7d': 'Amount of funds received in the last 7 days',
16
+ 'forward_ratio': 'Percentage of received funds immediately forwarded',
17
+ 'avg_tx_amount': 'Average transaction amount sent',
18
+ 'amount_std': 'Consistency of transaction amounts',
19
+ 'in_out_ratio': 'Ratio of received to sent funds',
20
+ 'pagerank_score': 'Network influence of this account',
21
+ 'in_degree': 'Number of accounts sending funds to this account',
22
+ 'out_degree': 'Number of accounts receiving funds from this account',
23
+ 'fan_in_ratio': 'Concentration of incoming vs outgoing connections',
24
+ 'community_id': 'Louvain community cluster assignment',
25
+ 'is_in_cycle': 'Account detected in a circular transaction loop',
26
+ 'account_age_days': 'Age of the account based on transaction history',
27
+ 'currency_diversity': 'Number of distinct currencies used',
28
+ 'channel_diversity': 'Number of distinct payment channels used',
29
+ 'bank_diversity': 'Number of distinct destination banks used',
30
+ }
31
+
32
+ TOP_N_FEATURES = 5
33
+
34
+
35
+ def explain_prediction(
36
+ account_id: str,
37
+ feature_df: pd.DataFrame,
38
+ bundle: dict,
39
+ ) -> list[dict]:
40
+ """
41
+ Generate SHAP-based explanations for a single account's fraud prediction.
42
+
43
+ Returns:
44
+ List of top-N feature explanation dicts sorted by |SHAP value|.
45
+ """
46
+ row = feature_df[feature_df['account'] == account_id]
47
+
48
+ if row.empty:
49
+ return []
50
+
51
+ feature_cols = bundle['feature_cols']
52
+ scaled_row = bundle['scaler'].transform(row[feature_cols])
53
+
54
+ explainer = shap.TreeExplainer(bundle['model'])
55
+ shap_values = explainer.shap_values(scaled_row)
56
+
57
+ results = []
58
+ for i, col in enumerate(feature_cols):
59
+ sv = float(shap_values[0][i])
60
+ results.append({
61
+ 'feature_name': col,
62
+ 'shap_value': sv,
63
+ 'feature_value': float(row[col].values[0]),
64
+ 'direction': 'increases risk' if sv > 0 else 'decreases risk',
65
+ 'description': FEATURE_DESCRIPTIONS.get(col, col),
66
+ })
67
+
68
+ results.sort(key=lambda x: abs(x['shap_value']), reverse=True)
69
+ return results[:TOP_N_FEATURES]
src/ml/features.py CHANGED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Feature Engineering Module
3
+ Builds a per-account feature DataFrame for the ML fraud classifier.
4
+ """
5
+ import pandas as pd
6
+ import numpy as np
7
+ import networkx as nx
8
+
9
+ # Constants
10
+ RECENT_WINDOW_DAYS = 7
11
+
12
+
13
+ def engineer_features(
14
+ df: pd.DataFrame,
15
+ G: nx.DiGraph,
16
+ pagerank_scores: dict,
17
+ louvain_partition: dict,
18
+ cycle_accounts: set,
19
+ ) -> pd.DataFrame:
20
+ """
21
+ Build a feature DataFrame with one row per unique account.
22
+
23
+ Combines velocity, ratio, graph, and profile features with a fraud_flag target.
24
+
25
+ Returns:
26
+ pd.DataFrame with all features and fraud_flag column.
27
+ """
28
+ df = df.copy()
29
+ df['timestamp'] = pd.to_datetime(df['timestamp'])
30
+ max_date = df['timestamp'].max()
31
+ cutoff_7d = max_date - pd.Timedelta(days=RECENT_WINDOW_DAYS)
32
+
33
+ fraud_sources = set(df[df['is_laundering'] == 1]['source'].values)
34
+
35
+ # --- All unique accounts ---
36
+ all_accounts = set(df['source'].unique()) | set(df['target'].unique())
37
+
38
+ # --- Pre-aggregate sent stats ---
39
+ sent_all = df.groupby('source').agg(
40
+ tx_count_total=('amount', 'count'),
41
+ amount_sent_total=('amount', 'sum'),
42
+ )
43
+ sent_7d = df[df['timestamp'] >= cutoff_7d].groupby('source').agg(
44
+ tx_count_7d=('amount', 'count'),
45
+ amount_sent_7d=('amount', 'sum'),
46
+ )
47
+
48
+ # --- Pre-aggregate received stats ---
49
+ recv_all = df.groupby('target').agg(
50
+ amount_received_total=('amount', 'sum'),
51
+ )
52
+ recv_7d = df[df['timestamp'] >= cutoff_7d].groupby('target').agg(
53
+ amount_received_7d=('amount', 'sum'),
54
+ )
55
+
56
+ # --- Amount std: combine sent + received per account ---
57
+ sent_amounts = df[['source', 'amount']].rename(columns={'source': 'account'})
58
+ recv_amounts = df[['target', 'amount']].rename(columns={'target': 'account'})
59
+ combined_amounts = pd.concat([sent_amounts, recv_amounts], ignore_index=True)
60
+ amount_std = combined_amounts.groupby('account')['amount'].std().rename('amount_std')
61
+
62
+ # --- Profile features ---
63
+ first_tx = df.groupby('source')['timestamp'].min().rename('first_tx')
64
+ last_tx = df.groupby('source')['timestamp'].max().rename('last_tx')
65
+
66
+ currency_div = df.groupby('source')['Payment Currency'].nunique().rename('currency_diversity') \
67
+ if 'Payment Currency' in df.columns else pd.Series(dtype=float, name='currency_diversity')
68
+ channel_div = df.groupby('source')['payment_type'].nunique().rename('channel_diversity')
69
+ bank_div = df.groupby('source')['target_bank'].nunique().rename('bank_diversity')
70
+
71
+ # --- Build DataFrame ---
72
+ features = pd.DataFrame(index=list(all_accounts))
73
+ features.index.name = 'account'
74
+
75
+ # Velocity
76
+ features = features.join(sent_all)
77
+ features = features.join(sent_7d)
78
+ features = features.join(recv_all)
79
+ features = features.join(recv_7d)
80
+
81
+ # Ratio
82
+ features['forward_ratio'] = features['amount_sent_total'] / (features['amount_received_total'] + 1)
83
+ features['avg_tx_amount'] = features['amount_sent_total'] / (features['tx_count_total'] + 1)
84
+ features = features.join(amount_std)
85
+ features['in_out_ratio'] = features['amount_received_total'] / (features['amount_sent_total'] + 1)
86
+
87
+ # Graph
88
+ features['pagerank_score'] = features.index.map(lambda a: pagerank_scores.get(a, 0))
89
+ features['in_degree'] = features.index.map(lambda a: G.in_degree(a) if a in G else 0)
90
+ features['out_degree'] = features.index.map(lambda a: G.out_degree(a) if a in G else 0)
91
+ features['fan_in_ratio'] = features['in_degree'] / (features['in_degree'] + features['out_degree'] + 1)
92
+ features['community_id'] = features.index.map(lambda a: louvain_partition.get(a, -1))
93
+ features['is_in_cycle'] = features.index.map(lambda a: 1 if a in cycle_accounts else 0)
94
+
95
+ # Profile
96
+ features = features.join(first_tx)
97
+ features = features.join(last_tx)
98
+ features['account_age_days'] = (features['last_tx'] - features['first_tx']).dt.days
99
+ features = features.drop(columns=['first_tx', 'last_tx'], errors='ignore')
100
+ features = features.join(currency_div)
101
+ features = features.join(channel_div)
102
+ features = features.join(bank_div)
103
+
104
+ # Target
105
+ features['fraud_flag'] = features.index.map(lambda a: 1 if a in fraud_sources else 0)
106
+
107
+ # Clean infinities and NaNs
108
+ features = features.replace([np.inf, -np.inf], 0)
109
+ features = features.fillna(0)
110
+
111
+ # Reset index so 'account' becomes a column
112
+ features = features.reset_index()
113
+
114
+ return features
src/ml/predictor.py CHANGED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Predictor Module
3
+ Loads the trained model and scores individual accounts.
4
+ """
5
+ import pickle
6
+ import streamlit as st
7
+ import pandas as pd
8
+
9
+ MODEL_PATH = 'models/xgb_fraud_model.pkl'
10
+
11
+
12
+ @st.cache_resource
13
+ def load_model() -> dict:
14
+ """
15
+ Load the trained model bundle from disk.
16
+
17
+ Returns:
18
+ Dict with 'model', 'scaler', and 'feature_cols'.
19
+ """
20
+ with open(MODEL_PATH, 'rb') as f:
21
+ bundle = pickle.load(f)
22
+ return bundle
23
+
24
+
25
+ def score_account(account_id: str, feature_df: pd.DataFrame, bundle: dict) -> dict:
26
+ """
27
+ Score a single account's fraud risk using the trained model.
28
+
29
+ Returns:
30
+ Dict with 'risk_score' (0-99) and 'fraud_probability' (0.0-1.0).
31
+ """
32
+ row = feature_df[feature_df['account'] == account_id]
33
+
34
+ if row.empty:
35
+ return {'risk_score': 0, 'fraud_probability': 0.0}
36
+
37
+ scaled = bundle['scaler'].transform(row[bundle['feature_cols']])
38
+ proba = bundle['model'].predict_proba(scaled)[0][1]
39
+ risk_score = int(proba * 99)
40
+
41
+ return {'risk_score': risk_score, 'fraud_probability': float(proba)}
src/ml/trainer.py CHANGED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Model Trainer Module
3
+ Trains an XGBoost classifier for fraud detection.
4
+ """
5
+ import os
6
+ import pickle
7
+ import xgboost as xgb
8
+ import pandas as pd
9
+ import numpy as np
10
+ from sklearn.model_selection import train_test_split
11
+ from sklearn.preprocessing import StandardScaler
12
+ from sklearn.metrics import (
13
+ roc_auc_score, f1_score, precision_score,
14
+ recall_score, confusion_matrix, roc_curve,
15
+ )
16
+
17
+ # Constants
18
+ TEST_SIZE = 0.2
19
+ RANDOM_STATE = 42
20
+ N_ESTIMATORS = 300
21
+ MAX_DEPTH = 6
22
+ LEARNING_RATE = 0.05
23
+ SUBSAMPLE = 0.8
24
+ COLSAMPLE = 0.8
25
+ EARLY_STOPPING = 20
26
+ MODEL_PATH = 'models/xgb_fraud_model.pkl'
27
+
28
+ FEATURE_COLS = [
29
+ 'tx_count_total',
30
+ 'tx_count_7d',
31
+ 'amount_sent_total',
32
+ 'amount_sent_7d',
33
+ 'amount_received_total',
34
+ 'amount_received_7d',
35
+ 'forward_ratio',
36
+ 'avg_tx_amount',
37
+ 'amount_std',
38
+ 'in_out_ratio',
39
+ 'pagerank_score',
40
+ 'in_degree',
41
+ 'out_degree',
42
+ 'fan_in_ratio',
43
+ 'community_id',
44
+ 'is_in_cycle',
45
+ 'account_age_days',
46
+ 'currency_diversity',
47
+ 'channel_diversity',
48
+ 'bank_diversity',
49
+ ]
50
+
51
+
52
+ def train_model(feature_df: pd.DataFrame) -> tuple:
53
+ """
54
+ Train an XGBoost fraud classifier on the feature DataFrame.
55
+
56
+ Returns:
57
+ Tuple of (model, scaler, metrics_dict).
58
+ """
59
+ X = feature_df[FEATURE_COLS].copy()
60
+ y = feature_df['fraud_flag'].copy()
61
+
62
+ positive_count = (y == 1).sum()
63
+ negative_count = (y == 0).sum()
64
+ scale_pos_weight = negative_count / max(positive_count, 1)
65
+
66
+ X_train, X_test, y_train, y_test = train_test_split(
67
+ X, y,
68
+ test_size=TEST_SIZE,
69
+ random_state=RANDOM_STATE,
70
+ stratify=y,
71
+ )
72
+
73
+ X_val = X_test
74
+
75
+ scaler = StandardScaler()
76
+ X_train_scaled = scaler.fit_transform(X_train)
77
+ X_test_scaled = scaler.transform(X_test)
78
+
79
+ model = xgb.XGBClassifier(
80
+ n_estimators=N_ESTIMATORS,
81
+ max_depth=MAX_DEPTH,
82
+ learning_rate=LEARNING_RATE,
83
+ subsample=SUBSAMPLE,
84
+ colsample_bytree=COLSAMPLE,
85
+ scale_pos_weight=scale_pos_weight,
86
+ eval_metric='auc',
87
+ random_state=RANDOM_STATE,
88
+ tree_method='hist',
89
+ )
90
+
91
+ model.fit(
92
+ X_train_scaled, y_train,
93
+ eval_set=[(scaler.transform(X_val), y_test)],
94
+ verbose=False,
95
+ )
96
+
97
+ # Predictions
98
+ y_pred = model.predict(X_test_scaled)
99
+ y_proba = model.predict_proba(X_test_scaled)[:, 1]
100
+
101
+ fpr, tpr, thresholds = roc_curve(y_test, y_proba)
102
+
103
+ metrics = {
104
+ 'auc_roc': roc_auc_score(y_test, y_proba),
105
+ 'f1': f1_score(y_test, y_pred),
106
+ 'precision': precision_score(y_test, y_pred),
107
+ 'recall': recall_score(y_test, y_pred),
108
+ 'confusion_matrix': confusion_matrix(y_test, y_pred),
109
+ 'fpr': fpr,
110
+ 'tpr': tpr,
111
+ }
112
+
113
+ # Save bundle
114
+ bundle = {
115
+ 'model': model,
116
+ 'scaler': scaler,
117
+ 'feature_cols': FEATURE_COLS,
118
+ }
119
+ os.makedirs(os.path.dirname(MODEL_PATH), exist_ok=True)
120
+ with open(MODEL_PATH, 'wb') as f:
121
+ pickle.dump(bundle, f)
122
+
123
+ return model, scaler, metrics
src/visualiser/pyvis_graph.py CHANGED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PyVis Graph Visualiser
3
+ Builds interactive network visualisations for account investigation.
4
+ """
5
+ import json
6
+ import tempfile
7
+ import os
8
+ import streamlit as st
9
+ import streamlit.components.v1 as components
10
+ import pandas as pd
11
+ import networkx as nx
12
+ from pyvis.network import Network
13
+
14
+ # Constants
15
+ NODE_SIZE_CENTER = 35
16
+ NODE_SIZE_HIGH_RISK = 22
17
+ NODE_SIZE_NORMAL = 14
18
+ NODE_SIZE_HIGH_PAGERANK = 26
19
+ EDGE_WIDTH_MAX = 8
20
+ EDGE_WIDTH_SCALE = 1_000_000
21
+ PAGERANK_TOP_PCT = 0.01
22
+ PHYSICS_GRAVITY = -50
23
+ PHYSICS_SPRING = 100
24
+ PHYSICS_ITERATIONS = 150
25
+
26
+ # Color palette
27
+ COLOR_CENTER = '#FFD700' # Gold for center node
28
+ COLOR_FRAUD = '#FF4136' # Red for confirmed fraud
29
+ COLOR_HIGH_PR = '#FF851B' # Orange for high PageRank
30
+ COLOR_NORMAL = '#0074D9' # Blue for normal
31
+ COLOR_FRAUD_EDGE = '#FF4136' # Red for fraudulent edges
32
+ COLOR_NORMAL_EDGE = '#AAAAAA' # Grey for normal edges
33
+
34
+ PHYSICS_OPTIONS = json.dumps({
35
+ "nodes": {"borderWidth": 2, "shadow": True},
36
+ "edges": {
37
+ "smooth": {"type": "curvedCW", "roundness": 0.2},
38
+ "shadow": True,
39
+ "arrows": {"to": {"enabled": True, "scaleFactor": 0.8}},
40
+ },
41
+ "physics": {
42
+ "forceAtlas2Based": {
43
+ "gravitationalConstant": PHYSICS_GRAVITY,
44
+ "springLength": PHYSICS_SPRING,
45
+ },
46
+ "solver": "forceAtlas2Based",
47
+ "stabilization": {"iterations": PHYSICS_ITERATIONS},
48
+ },
49
+ "interaction": {"hover": True, "tooltipDelay": 100},
50
+ })
51
+
52
+
53
+ def build_pyvis_graph(
54
+ subgraph: nx.DiGraph,
55
+ df: pd.DataFrame,
56
+ center_node: str,
57
+ fraud_accounts: set,
58
+ pagerank_scores: dict,
59
+ louvain_partition: dict,
60
+ ) -> Network:
61
+ """
62
+ Build an interactive PyVis network from a NetworkX subgraph.
63
+
64
+ Nodes are sized and colored based on their role (center, fraud, high-PR, normal).
65
+ Edges are scaled by transaction amount and colored by fraud status.
66
+
67
+ Returns:
68
+ A pyvis.network.Network instance ready for rendering.
69
+ """
70
+ net = Network(
71
+ height='570px',
72
+ width='100%',
73
+ directed=True,
74
+ notebook=False,
75
+ )
76
+
77
+ net.set_options(PHYSICS_OPTIONS)
78
+
79
+ # Compute pagerank threshold for highlighting top nodes
80
+ all_pr = sorted(pagerank_scores.values(), reverse=True)
81
+ top_n = max(1, int(len(all_pr) * PAGERANK_TOP_PCT))
82
+ pagerank_threshold = all_pr[min(top_n, len(all_pr) - 1)]
83
+
84
+ # --- Add nodes ---
85
+ for node in subgraph.nodes():
86
+ node_data = subgraph.nodes[node]
87
+ is_center = (node == center_node)
88
+ is_fraud = node in fraud_accounts
89
+ is_high_pr = pagerank_scores.get(node, 0) >= pagerank_threshold
90
+
91
+ # Size and shape
92
+ if is_center:
93
+ size = NODE_SIZE_CENTER
94
+ shape = 'star'
95
+ color = COLOR_CENTER
96
+ elif is_high_pr:
97
+ size = NODE_SIZE_HIGH_PAGERANK
98
+ shape = 'diamond'
99
+ color = COLOR_HIGH_PR
100
+ elif is_fraud:
101
+ size = NODE_SIZE_HIGH_RISK
102
+ shape = 'dot'
103
+ color = COLOR_FRAUD
104
+ else:
105
+ size = NODE_SIZE_NORMAL
106
+ shape = 'dot'
107
+ color = COLOR_NORMAL
108
+
109
+ # Tooltip
110
+ total_sent = node_data.get('total_sent', 0)
111
+ total_received = node_data.get('total_received', 0)
112
+ count_sent = node_data.get('count_sent', 0)
113
+ community = louvain_partition.get(node, 'N/A')
114
+ status = 'FLAGGED' if is_fraud else 'Normal'
115
+
116
+ tooltip = (
117
+ f"Account: {node}\n"
118
+ f"Total Sent: {total_sent:,.0f}\n"
119
+ f"Total Received: {total_received:,.0f}\n"
120
+ f"Transactions: {count_sent}\n"
121
+ f"Community: {community}\n"
122
+ f"Status: {status}"
123
+ )
124
+
125
+ label = str(node)[:12]
126
+
127
+ net.add_node(
128
+ str(node),
129
+ label=label,
130
+ size=size,
131
+ shape=shape,
132
+ color=color,
133
+ title=tooltip,
134
+ borderWidth=2,
135
+ borderWidthSelected=4,
136
+ )
137
+
138
+ # --- Add edges ---
139
+ for src, tgt, data in subgraph.edges(data=True):
140
+ amount = data.get('amount', 0)
141
+ is_fraud_edge = data.get('is_laundering', 0) == 1
142
+
143
+ edge_width = min(amount / EDGE_WIDTH_SCALE, EDGE_WIDTH_MAX)
144
+ edge_width = max(edge_width, 0.5)
145
+
146
+ edge_color = COLOR_FRAUD_EDGE if is_fraud_edge else COLOR_NORMAL_EDGE
147
+
148
+ tooltip = (
149
+ f"Amount: {amount:,.0f}\n"
150
+ f"Channel: {data.get('payment_type', '')}\n"
151
+ f"Suspicious: {'Yes' if is_fraud_edge else 'No'}"
152
+ )
153
+
154
+ net.add_edge(
155
+ str(src),
156
+ str(tgt),
157
+ value=edge_width,
158
+ title=tooltip,
159
+ color=edge_color,
160
+ arrows='to',
161
+ )
162
+
163
+ return net
164
+
165
+
166
+ def render_pyvis(net: Network) -> None:
167
+ """
168
+ Render a PyVis network inside a Streamlit app using an HTML component.
169
+ """
170
+ with tempfile.NamedTemporaryFile(delete=False, suffix='.html', mode='w') as f:
171
+ tmp_path = f.name
172
+ net.save_graph(tmp_path)
173
+ with open(tmp_path, 'r') as f:
174
+ html_content = f.read()
175
+ os.unlink(tmp_path)
176
+ components.html(html_content, height=580, scrolling=False)
177
+
178
+
179
+ def save_pyvis_html(net: Network, path: str) -> None:
180
+ """
181
+ Save a PyVis network to an HTML file on disk.
182
+ """
183
+ net.save_graph(path)
test_ml.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Test the full ML pipeline: feature engineering, training, scoring, explainability.
3
+ """
4
+ import warnings
5
+ warnings.filterwarnings('ignore')
6
+ import time
7
+
8
+ from src.data_loader import get_processed_data
9
+ import src.graph_builder as gb
10
+ from src.ml.features import engineer_features
11
+ from src.ml.trainer import train_model
12
+ from src.ml.predictor import score_account
13
+ from src.ml.explainer import explain_prediction
14
+ import pickle
15
+
16
+ print("Loading data...")
17
+ df, nf = get_processed_data()
18
+ print(f"Transactions: {len(df):,}, Node features: {len(nf):,}")
19
+
20
+ print("\nBuilding graph...")
21
+ G = gb.build_graph(df)
22
+ G = gb.attach_node_features(G, nf)
23
+ print(f"Graph: {G.number_of_nodes():,} nodes, {G.number_of_edges():,} edges")
24
+
25
+ print("Computing PageRank...")
26
+ pagerank_scores = gb.compute_pagerank(G)
27
+
28
+ print("Computing Louvain...")
29
+ t = time.time()
30
+ louvain_partition = gb.compute_louvain(G)
31
+ print(f"Louvain: {time.time()-t:.1f}s")
32
+
33
+ # Use empty set for cycle_accounts (skip expensive cycle detection for test)
34
+ cycle_accounts = set()
35
+
36
+ print("\nEngineering features...")
37
+ t = time.time()
38
+ feature_df = engineer_features(df, G, pagerank_scores, louvain_partition, cycle_accounts)
39
+ print(f"Features: {feature_df.shape} in {time.time()-t:.1f}s")
40
+ print(f"Fraud flag distribution:\n{feature_df['fraud_flag'].value_counts()}")
41
+ print(f"Feature columns: {list(feature_df.columns)}")
42
+
43
+ print("\nTraining XGBoost model...")
44
+ t = time.time()
45
+ model, scaler, metrics = train_model(feature_df)
46
+ print(f"Trained in {time.time()-t:.1f}s")
47
+ print(f"\n--- METRICS ---")
48
+ print(f"AUC-ROC : {metrics['auc_roc']:.4f} {'✅ >= 0.85' if metrics['auc_roc'] >= 0.85 else '❌ < 0.85'}")
49
+ print(f"F1 : {metrics['f1']:.4f}")
50
+ print(f"Precision : {metrics['precision']:.4f}")
51
+ print(f"Recall : {metrics['recall']:.4f}")
52
+ print(f"Confusion Matrix:\n{metrics['confusion_matrix']}")
53
+
54
+ print("\nLoading model from disk...")
55
+ with open('models/xgb_fraud_model.pkl', 'rb') as f:
56
+ bundle = pickle.load(f)
57
+
58
+ print("\nScoring 3 sample accounts...")
59
+ sample_accounts = feature_df['account'].head(3).tolist()
60
+ for acct in sample_accounts:
61
+ result = score_account(acct, feature_df, bundle)
62
+ print(f" {acct}: risk_score={result['risk_score']}, fraud_prob={result['fraud_probability']:.4f}")
63
+
64
+ print("\nSHAP explanation for first account...")
65
+ explanation = explain_prediction(sample_accounts[0], feature_df, bundle)
66
+ for e in explanation:
67
+ print(f" {e['feature_name']:25s} SHAP={e['shap_value']:+.4f} val={e['feature_value']:.2f} ({e['direction']})")
68
+
69
+ print("\n✅ ML pipeline fully verified.")
tests/graph_test_output.html ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <html>
2
+ <head>
3
+ <meta charset="utf-8">
4
+
5
+ <script src="lib/bindings/utils.js"></script>
6
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/vis-network/9.1.2/dist/dist/vis-network.min.css" integrity="sha512-WgxfT5LWjfszlPHXRmBWHkV2eceiWTOBvrKCNbdgDYTHrT2AeLCGbF4sZlZw3UMN3WtL0tGUoIAKsu8mllg/XA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
7
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/vis-network/9.1.2/dist/vis-network.min.js" integrity="sha512-LnvoEWDFrqGHlHmDD2101OrLcbsfkrzoSpvtSQtxK3RMnRV0eOkhhBN2dXHKRrUU8p2DGRTk35n4O8nWSVe1mQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
8
+
9
+
10
+ <center>
11
+ <h1></h1>
12
+ </center>
13
+
14
+ <!-- <link rel="stylesheet" href="../node_modules/vis/dist/vis.min.css" type="text/css" />
15
+ <script type="text/javascript" src="../node_modules/vis/dist/vis.js"> </script>-->
16
+ <link
17
+ href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta3/dist/css/bootstrap.min.css"
18
+ rel="stylesheet"
19
+ integrity="sha384-eOJMYsd53ii+scO/bJGFsiCZc+5NDVN2yr8+0RDqr0Ql0h+rP48ckxlpbzKgwra6"
20
+ crossorigin="anonymous"
21
+ />
22
+ <script
23
+ src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta3/dist/js/bootstrap.bundle.min.js"
24
+ integrity="sha384-JEW9xMcG8R+pH31jmWH6WWP0WintQrMb4s7ZOdauHnUtxwoG2vI5DkLtS3qm9Ekf"
25
+ crossorigin="anonymous"
26
+ ></script>
27
+
28
+
29
+ <center>
30
+ <h1></h1>
31
+ </center>
32
+ <style type="text/css">
33
+
34
+ #mynetwork {
35
+ width: 100%;
36
+ height: 570px;
37
+ background-color: #ffffff;
38
+ border: 1px solid lightgray;
39
+ position: relative;
40
+ float: left;
41
+ }
42
+
43
+
44
+
45
+
46
+
47
+
48
+ </style>
49
+ </head>
50
+
51
+
52
+ <body>
53
+ <div class="card" style="width: 100%">
54
+
55
+
56
+ <div id="mynetwork" class="card-body"></div>
57
+ </div>
58
+
59
+
60
+
61
+
62
+ <script type="text/javascript">
63
+
64
+ // initialize global variables.
65
+ var edges;
66
+ var nodes;
67
+ var allNodes;
68
+ var allEdges;
69
+ var nodeColors;
70
+ var originalNodes;
71
+ var network;
72
+ var container;
73
+ var options, data;
74
+ var filter = {
75
+ item : '',
76
+ property : '',
77
+ value : []
78
+ };
79
+
80
+
81
+
82
+
83
+
84
+ // This method is responsible for drawing the graph, returns the drawn network
85
+ function drawGraph() {
86
+ var container = document.getElementById('mynetwork');
87
+
88
+
89
+
90
+ // parsing and collecting nodes and edges from the python
91
+ nodes = new vis.DataSet([{"borderWidth": 2, "borderWidthSelected": 4, "color": "#FFD700", "id": "8101AEF90", "label": "8101AEF90", "shape": "star", "size": 35, "title": "Account: 8101AEF90\nTotal Sent: 30,734\nTotal Received: 1,965,570\nTransactions: 11.0\nCommunity: 2769\nStatus: FLAGGED"}, {"borderWidth": 2, "borderWidthSelected": 4, "color": "#0074D9", "id": "8105DEB80", "label": "8105DEB80", "shape": "dot", "size": 14, "title": "Account: 8105DEB80\nTotal Sent: 1,399\nTotal Received: 142,255\nTransactions: 2.0\nCommunity: 61635\nStatus: Normal"}, {"borderWidth": 2, "borderWidthSelected": 4, "color": "#0074D9", "id": "810EAAC30", "label": "810EAAC30", "shape": "dot", "size": 14, "title": "Account: 810EAAC30\nTotal Sent: 65\nTotal Received: 2,572,429\nTransactions: 1.0\nCommunity: 41974\nStatus: Normal"}, {"borderWidth": 2, "borderWidthSelected": 4, "color": "#0074D9", "id": "810518810", "label": "810518810", "shape": "dot", "size": 14, "title": "Account: 810518810\nTotal Sent: 6,630,785\nTotal Received: 316,695\nTransactions: 31.0\nCommunity: 302\nStatus: Normal"}, {"borderWidth": 2, "borderWidthSelected": 4, "color": "#0074D9", "id": "8000D4D50", "label": "8000D4D50", "shape": "dot", "size": 14, "title": "Account: 8000D4D50\nTotal Sent: 9,756,656\nTotal Received: 755,453\nTransactions: 26.0\nCommunity: 15615\nStatus: Normal"}, {"borderWidth": 2, "borderWidthSelected": 4, "color": "#0074D9", "id": "80D876800", "label": "80D876800", "shape": "dot", "size": 14, "title": "Account: 80D876800\nTotal Sent: 278,055\nTotal Received: 2,238,568\nTransactions: 34.0\nCommunity: 357\nStatus: Normal"}, {"borderWidth": 2, "borderWidthSelected": 4, "color": "#FF4136", "id": "80DF9EF10", "label": "80DF9EF10", "shape": "dot", "size": 22, "title": "Account: 80DF9EF10\nTotal Sent: 702,187\nTotal Received: 94,302\nTransactions: 17.0\nCommunity: 2769\nStatus: FLAGGED"}, {"borderWidth": 2, "borderWidthSelected": 4, "color": "#0074D9", "id": "810217F20", "label": "810217F20", "shape": "dot", "size": 14, "title": "Account: 810217F20\nTotal Sent: 0\nTotal Received: 4,076,387\nTransactions: 0.0\nCommunity: 302\nStatus: Normal"}, {"borderWidth": 2, "borderWidthSelected": 4, "color": "#0074D9", "id": "80FFFF350", "label": "80FFFF350", "shape": "dot", "size": 14, "title": "Account: 80FFFF350\nTotal Sent: 2,397,697\nTotal Received: 16,532,279\nTransactions: 59.0\nCommunity: 2769\nStatus: Normal"}, {"borderWidth": 2, "borderWidthSelected": 4, "color": "#0074D9", "id": "8000D2450", "label": "8000D2450", "shape": "dot", "size": 14, "title": "Account: 8000D2450\nTotal Sent: 19,950,045\nTotal Received: 49\nTransactions: 84.0\nCommunity: 61613\nStatus: Normal"}, {"borderWidth": 2, "borderWidthSelected": 4, "color": "#0074D9", "id": "80FDF7EF0", "label": "80FDF7EF0", "shape": "dot", "size": 14, "title": "Account: 80FDF7EF0\nTotal Sent: 5,073,726\nTotal Received: 654,042\nTransactions: 44.0\nCommunity: 302\nStatus: Normal"}, {"borderWidth": 2, "borderWidthSelected": 4, "color": "#0074D9", "id": "8005A7740", "label": "8005A7740", "shape": "dot", "size": 14, "title": "Account: 8005A7740\nTotal Sent: 21,627,411\nTotal Received: 1,623,296\nTransactions: 94.0\nCommunity: 364\nStatus: Normal"}, {"borderWidth": 2, "borderWidthSelected": 4, "color": "#0074D9", "id": "811247290", "label": "811247290", "shape": "dot", "size": 14, "title": "Account: 811247290\nTotal Sent: 642,819\nTotal Received: 721,317\nTransactions: 4.0\nCommunity: 302\nStatus: Normal"}, {"borderWidth": 2, "borderWidthSelected": 4, "color": "#0074D9", "id": "810F29690", "label": "810F29690", "shape": "dot", "size": 14, "title": "Account: 810F29690\nTotal Sent: 208,594\nTotal Received: 1,617,539\nTransactions: 19.0\nCommunity: 1895\nStatus: Normal"}, {"borderWidth": 2, "borderWidthSelected": 4, "color": "#0074D9", "id": "8115CB540", "label": "8115CB540", "shape": "dot", "size": 14, "title": "Account: 8115CB540\nTotal Sent: 3,341,743\nTotal Received: 3,483,443\nTransactions: 2.0\nCommunity: 302\nStatus: Normal"}, {"borderWidth": 2, "borderWidthSelected": 4, "color": "#0074D9", "id": "811279D10", "label": "811279D10", "shape": "dot", "size": 14, "title": "Account: 811279D10\nTotal Sent: 83,238\nTotal Received: 943,537\nTransactions: 8.0\nCommunity: 14817\nStatus: Normal"}, {"borderWidth": 2, "borderWidthSelected": 4, "color": "#0074D9", "id": "811842A50", "label": "811842A50", "shape": "dot", "size": 14, "title": "Account: 811842A50\nTotal Sent: 5,830\nTotal Received: 18,576\nTransactions: 2.0\nCommunity: 61604\nStatus: Normal"}, {"borderWidth": 2, "borderWidthSelected": 4, "color": "#0074D9", "id": "8106F24B0", "label": "8106F24B0", "shape": "dot", "size": 14, "title": "Account: 8106F24B0\nTotal Sent: 41,687,726\nTotal Received: 917,480\nTransactions: 15.0\nCommunity: 54823\nStatus: Normal"}, {"borderWidth": 2, "borderWidthSelected": 4, "color": "#0074D9", "id": "810A1A5D0", "label": "810A1A5D0", "shape": "dot", "size": 14, "title": "Account: 810A1A5D0\nTotal Sent: 25,456\nTotal Received: 51,514\nTransactions: 1.0\nCommunity: 302\nStatus: Normal"}, {"borderWidth": 2, "borderWidthSelected": 4, "color": "#0074D9", "id": "8100C70F0", "label": "8100C70F0", "shape": "dot", "size": 14, "title": "Account: 8100C70F0\nTotal Sent: 492,671\nTotal Received: 193,322\nTransactions: 38.0\nCommunity: 302\nStatus: Normal"}, {"borderWidth": 2, "borderWidthSelected": 4, "color": "#0074D9", "id": "810BEA5B0", "label": "810BEA5B0", "shape": "dot", "size": 14, "title": "Account: 810BEA5B0\nTotal Sent: 435,216\nTotal Received: 7,052,896\nTransactions: 1.0\nCommunity: 2769\nStatus: Normal"}, {"borderWidth": 2, "borderWidthSelected": 4, "color": "#0074D9", "id": "8114D3220", "label": "8114D3220", "shape": "dot", "size": 14, "title": "Account: 8114D3220\nTotal Sent: 69\nTotal Received: 217,291\nTransactions: 1.0\nCommunity: 1895\nStatus: Normal"}, {"borderWidth": 2, "borderWidthSelected": 4, "color": "#0074D9", "id": "8105DE7A0", "label": "8105DE7A0", "shape": "dot", "size": 14, "title": "Account: 8105DE7A0\nTotal Sent: 271,174\nTotal Received: 113,753\nTransactions: 29.0\nCommunity: 61635\nStatus: Normal"}, {"borderWidth": 2, "borderWidthSelected": 4, "color": "#0074D9", "id": "810F29870", "label": "810F29870", "shape": "dot", "size": 14, "title": "Account: 810F29870\nTotal Sent: 1,618,474\nTotal Received: 935\nTransactions: 3.0\nCommunity: 1895\nStatus: Normal"}, {"borderWidth": 2, "borderWidthSelected": 4, "color": "#0074D9", "id": "81067F4C0", "label": "81067F4C0", "shape": "dot", "size": 14, "title": "Account: 81067F4C0\nTotal Sent: 863,027\nTotal Received: 796,781\nTransactions: 38.0\nCommunity: 302\nStatus: Normal"}, {"borderWidth": 2, "borderWidthSelected": 4, "color": "#0074D9", "id": "804E53900", "label": "804E53900", "shape": "dot", "size": 14, "title": "Account: 804E53900\nTotal Sent: 386,927\nTotal Received: 13,704\nTransactions: 34.0\nCommunity: 673\nStatus: Normal"}, {"borderWidth": 2, "borderWidthSelected": 4, "color": "#0074D9", "id": "810B4B330", "label": "810B4B330", "shape": "dot", "size": 14, "title": "Account: 810B4B330\nTotal Sent: 94,008\nTotal Received: 694,430\nTransactions: 2.0\nCommunity: 2769\nStatus: Normal"}, {"borderWidth": 2, "borderWidthSelected": 4, "color": "#0074D9", "id": "8108149D0", "label": "8108149D0", "shape": "dot", "size": 14, "title": "Account: 8108149D0\nTotal Sent: 5,273\nTotal Received: 18,751\nTransactions: 1.0\nCommunity: 2769\nStatus: Normal"}, {"borderWidth": 2, "borderWidthSelected": 4, "color": "#FF851B", "id": "1004289C0", "label": "1004289C0", "shape": "diamond", "size": 26, "title": "Account: 1004289C0\nTotal Sent: 10,517,190,141\nTotal Received: 313,581\nTransactions: 16794.0\nCommunity: 302\nStatus: FLAGGED"}]);
92
+ edges = new vis.DataSet([{"arrows": "to", "color": "#AAAAAA", "from": "8101AEF90", "title": "Amount: 1,245\nChannel: Cheque\nSuspicious: No", "to": "8105DEB80", "value": 0.5}, {"arrows": "to", "color": "#FF4136", "from": "8101AEF90", "title": "Amount: 18,288\nChannel: ACH\nSuspicious: Yes", "to": "80DF9EF10", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "8105DEB80", "title": "Amount: 32\nChannel: Reinvestment\nSuspicious: No", "to": "8105DEB80", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "810EAAC30", "title": "Amount: 65\nChannel: Reinvestment\nSuspicious: No", "to": "810EAAC30", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "810518810", "title": "Amount: 103,085\nChannel: Reinvestment\nSuspicious: No", "to": "810518810", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "8000D4D50", "title": "Amount: 754,950\nChannel: Reinvestment\nSuspicious: No", "to": "8000D4D50", "value": 0.75494959}, {"arrows": "to", "color": "#AAAAAA", "from": "8000D4D50", "title": "Amount: 27\nChannel: Wire\nSuspicious: No", "to": "80FDF7EF0", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "80D876800", "title": "Amount: 29\nChannel: Credit Card\nSuspicious: No", "to": "80DF9EF10", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "80DF9EF10", "title": "Amount: 42,597\nChannel: Reinvestment\nSuspicious: No", "to": "80DF9EF10", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "80DF9EF10", "title": "Amount: 18,754\nChannel: Credit Card\nSuspicious: No", "to": "810B4B330", "value": 0.5}, {"arrows": "to", "color": "#FF4136", "from": "80DF9EF10", "title": "Amount: 65,262\nChannel: ACH\nSuspicious: Yes", "to": "810BEA5B0", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "80FFFF350", "title": "Amount: 102,125\nChannel: ACH\nSuspicious: No", "to": "8101AEF90", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "80FFFF350", "title": "Amount: 423,324\nChannel: Reinvestment\nSuspicious: No", "to": "80FFFF350", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "80FFFF350", "title": "Amount: 388\nChannel: Credit Card\nSuspicious: No", "to": "810518810", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "80FFFF350", "title": "Amount: 1,291\nChannel: Credit Card\nSuspicious: No", "to": "8108149D0", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "8000D2450", "title": "Amount: 49\nChannel: Reinvestment\nSuspicious: No", "to": "8000D2450", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "8000D2450", "title": "Amount: 257\nChannel: Wire\nSuspicious: No", "to": "80FDF7EF0", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "80FDF7EF0", "title": "Amount: 170,137\nChannel: Cash\nSuspicious: No", "to": "81067F4C0", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "80FDF7EF0", "title": "Amount: 559,189\nChannel: Reinvestment\nSuspicious: No", "to": "80FDF7EF0", "value": 0.5591893}, {"arrows": "to", "color": "#AAAAAA", "from": "80FDF7EF0", "title": "Amount: 182,883\nChannel: Wire\nSuspicious: No", "to": "810217F20", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "80FDF7EF0", "title": "Amount: 4,364\nChannel: Credit Card\nSuspicious: No", "to": "8101AEF90", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "80FDF7EF0", "title": "Amount: 548\nChannel: Wire\nSuspicious: No", "to": "810A1A5D0", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "8005A7740", "title": "Amount: 1,609,724\nChannel: Reinvestment\nSuspicious: No", "to": "8005A7740", "value": 1.60972417}, {"arrows": "to", "color": "#AAAAAA", "from": "8005A7740", "title": "Amount: 2,556\nChannel: Credit Card\nSuspicious: No", "to": "80DF9EF10", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "811247290", "title": "Amount: 370,069\nChannel: Reinvestment\nSuspicious: No", "to": "811247290", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "810F29690", "title": "Amount: 608\nChannel: Credit Card\nSuspicious: No", "to": "8100C70F0", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "810F29690", "title": "Amount: 29,084\nChannel: Cheque\nSuspicious: No", "to": "8114D3220", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "810F29690", "title": "Amount: 116\nChannel: Credit Card\nSuspicious: No", "to": "8101AEF90", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "810F29690", "title": "Amount: 548\nChannel: Credit Card\nSuspicious: No", "to": "811842A50", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "810F29690", "title": "Amount: 732\nChannel: Credit Card\nSuspicious: No", "to": "811247290", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "810F29690", "title": "Amount: 5\nChannel: Credit Card\nSuspicious: No", "to": "811279D10", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "810F29690", "title": "Amount: 79\nChannel: Credit Card\nSuspicious: No", "to": "8106F24B0", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "810F29690", "title": "Amount: 440\nChannel: Credit Card\nSuspicious: No", "to": "810EAAC30", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "810F29690", "title": "Amount: 27\nChannel: Credit Card\nSuspicious: No", "to": "8115CB540", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "8115CB540", "title": "Amount: 65\nChannel: Reinvestment\nSuspicious: No", "to": "8115CB540", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "811279D10", "title": "Amount: 31\nChannel: Reinvestment\nSuspicious: No", "to": "811279D10", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "811842A50", "title": "Amount: 65\nChannel: Reinvestment\nSuspicious: No", "to": "811842A50", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "8106F24B0", "title": "Amount: 692,939\nChannel: Reinvestment\nSuspicious: No", "to": "8106F24B0", "value": 0.69293866}, {"arrows": "to", "color": "#AAAAAA", "from": "810A1A5D0", "title": "Amount: 25,456\nChannel: Reinvestment\nSuspicious: No", "to": "810A1A5D0", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "8100C70F0", "title": "Amount: 21,509\nChannel: Reinvestment\nSuspicious: No", "to": "8100C70F0", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "810BEA5B0", "title": "Amount: 435,216\nChannel: Reinvestment\nSuspicious: No", "to": "810BEA5B0", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "8114D3220", "title": "Amount: 69\nChannel: Reinvestment\nSuspicious: No", "to": "8114D3220", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "8105DE7A0", "title": "Amount: 128,411\nChannel: ACH\nSuspicious: No", "to": "8105DEB80", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "810F29870", "title": "Amount: 37\nChannel: Reinvestment\nSuspicious: No", "to": "810F29870", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "810F29870", "title": "Amount: 1,617,539\nChannel: Cheque\nSuspicious: No", "to": "810F29690", "value": 1.617539}, {"arrows": "to", "color": "#AAAAAA", "from": "81067F4C0", "title": "Amount: 358,264\nChannel: Reinvestment\nSuspicious: No", "to": "81067F4C0", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "804E53900", "title": "Amount: 1,254\nChannel: Cheque\nSuspicious: No", "to": "80DF9EF10", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "804E53900", "title": "Amount: 276\nChannel: Reinvestment\nSuspicious: No", "to": "804E53900", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "810B4B330", "title": "Amount: 13\nChannel: Reinvestment\nSuspicious: No", "to": "810B4B330", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "8108149D0", "title": "Amount: 5,273\nChannel: Reinvestment\nSuspicious: No", "to": "8108149D0", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "1004289C0", "title": "Amount: 24,315\nChannel: Cheque\nSuspicious: No", "to": "811247290", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "1004289C0", "title": "Amount: 872\nChannel: Cash\nSuspicious: No", "to": "80FFFF350", "value": 0.5}, {"arrows": "to", "color": "#AAAAAA", "from": "1004289C0", "title": "Amount: 13,381\nChannel: Credit Card\nSuspicious: No", "to": "8100C70F0", "value": 0.5}]);
93
+
94
+ nodeColors = {};
95
+ allNodes = nodes.get({ returnType: "Object" });
96
+ for (nodeId in allNodes) {
97
+ nodeColors[nodeId] = allNodes[nodeId].color;
98
+ }
99
+ allEdges = edges.get({ returnType: "Object" });
100
+ // adding nodes and edges to the graph
101
+ data = {nodes: nodes, edges: edges};
102
+
103
+ var options = {"nodes": {"borderWidth": 2, "shadow": true}, "edges": {"smooth": {"type": "curvedCW", "roundness": 0.2}, "shadow": true, "arrows": {"to": {"enabled": true, "scaleFactor": 0.8}}}, "physics": {"forceAtlas2Based": {"gravitationalConstant": -50, "springLength": 100}, "solver": "forceAtlas2Based", "stabilization": {"iterations": 150}}, "interaction": {"hover": true, "tooltipDelay": 100}};
104
+
105
+
106
+
107
+
108
+
109
+
110
+ network = new vis.Network(container, data, options);
111
+
112
+
113
+
114
+
115
+
116
+
117
+
118
+
119
+
120
+
121
+ return network;
122
+
123
+ }
124
+ drawGraph();
125
+ </script>
126
+ </body>
127
+ </html>
tests/test_graph_visual.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Test the PyVis graph visualiser.
3
+ Loads a sample, builds graph, picks a fraud account, and renders to HTML.
4
+ """
5
+ import warnings
6
+ warnings.filterwarnings('ignore')
7
+
8
+ from src.data_loader import get_processed_data
9
+ import src.graph_builder as gb
10
+ from src.visualiser.pyvis_graph import build_pyvis_graph, save_pyvis_html
11
+
12
+ OUTPUT_PATH = 'tests/graph_test_output.html'
13
+
14
+ print("Loading data...")
15
+ df, nf = get_processed_data()
16
+
17
+ # Use a 1000-row sample for faster testing
18
+ sample_df = df.sample(n=min(1000, len(df)), random_state=42).copy()
19
+ print(f"Sample: {len(sample_df)} rows")
20
+
21
+ print("Building graph from sample...")
22
+ G = gb.build_graph(sample_df)
23
+ G = gb.attach_node_features(G, nf)
24
+ print(f"Sample graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges")
25
+
26
+ print("Computing PageRank on sample graph...")
27
+ pagerank_scores = gb.compute_pagerank(G)
28
+
29
+ print("Computing Louvain on sample graph...")
30
+ import community as community_louvain
31
+ G_undirected = G.to_undirected()
32
+ louvain_partition = community_louvain.best_partition(G_undirected, weight='amount')
33
+
34
+ # Pick a confirmed fraud account
35
+ fraud_accounts = set(df[df['is_laundering'] == 1]['source'].values)
36
+ fraud_in_sample = fraud_accounts & set(G.nodes())
37
+ if fraud_in_sample:
38
+ center_node = list(fraud_in_sample)[0]
39
+ else:
40
+ center_node = list(G.nodes())[0]
41
+ print(f"Center node: {center_node} (fraud={center_node in fraud_accounts})")
42
+
43
+ # Extract subgraph
44
+ sub_G = gb.get_subgraph(G, center_node, hops=2, max_nodes=60)
45
+ print(f"Subgraph: {sub_G.number_of_nodes()} nodes, {sub_G.number_of_edges()} edges")
46
+
47
+ # Build PyVis graph
48
+ print("Building PyVis graph...")
49
+ net = build_pyvis_graph(
50
+ subgraph=sub_G,
51
+ df=sample_df,
52
+ center_node=center_node,
53
+ fraud_accounts=fraud_accounts,
54
+ pagerank_scores=pagerank_scores,
55
+ louvain_partition=louvain_partition,
56
+ )
57
+
58
+ # Save HTML
59
+ save_pyvis_html(net, OUTPUT_PATH)
60
+ print(f"\n✅ Graph built successfully with {sub_G.number_of_nodes()} nodes and {sub_G.number_of_edges()} edges")
61
+ print(f"HTML saved to: {OUTPUT_PATH}")
62
+
63
+ # Verify file exists and has content
64
+ import os
65
+ size = os.path.getsize(OUTPUT_PATH)
66
+ print(f"HTML file size: {size:,} bytes")