Spaces:
Runtime error
Runtime error
Commit ·
372c2d4
1
Parent(s): 7cee5a0
Implement ML fraud detection pipeline and interactive graph visualiser
Browse files- app.py +47 -0
- src/ml/explainer.py +69 -0
- src/ml/features.py +114 -0
- src/ml/predictor.py +41 -0
- src/ml/trainer.py +123 -0
- src/visualiser/pyvis_graph.py +183 -0
- test_ml.py +69 -0
- tests/graph_test_output.html +127 -0
- tests/test_graph_visual.py +66 -0
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")
|