""" Data Loader Module - Fixed - Config-driven paths - Validates schema - Labels both source AND target in fraud_flag - Temporal features - No Streamlit dependency """ import os import functools import pandas as pd from datetime import datetime from src.config_loader import get_config REQUIRED_COLUMNS = ['Account', 'Account.1', 'Amount Paid', 'Timestamp', 'Payment Format', 'Is Laundering'] CHECKPOINT_EDGE_COLUMNS = ['source', 'target', 'amount', 'timestamp', 'payment_type', 'is_laundering'] def validate_schema(df: pd.DataFrame) -> None: missing = [c for c in REQUIRED_COLUMNS if c not in df.columns] if missing: raise ValueError(f"Raw dataset is missing required columns: {missing}") def load_raw() -> pd.DataFrame: cfg = get_config() raw_path = cfg['data']['raw_path'] df = pd.read_csv( raw_path, dtype={'Account': str, 'Account.1': str}, parse_dates=['Timestamp'], low_memory=False ) validate_schema(df) return df def load_checkpoint_transactions() -> pd.DataFrame: """ Recover transactions from the cached graph edge list when the raw CSV is not available on the deployment host. """ project_root = os.path.dirname(os.path.dirname(__file__)) checkpoint_path = os.path.join(project_root, 'data', 'checkpoints', 'graph_edges.parquet') if not os.path.exists(checkpoint_path): raise FileNotFoundError(checkpoint_path) df = pd.read_parquet(checkpoint_path) missing = [c for c in CHECKPOINT_EDGE_COLUMNS if c not in df.columns] if missing: raise ValueError(f"Checkpoint edge list is missing required columns: {missing}") df = df[CHECKPOINT_EDGE_COLUMNS].copy() df['source'] = df['source'].astype(str) df['target'] = df['target'].astype(str) df['amount'] = pd.to_numeric(df['amount'], errors='coerce') df['timestamp'] = pd.to_datetime(df['timestamp'], errors='coerce') df['payment_type'] = df['payment_type'].fillna('Unknown').astype(str) df['is_laundering'] = pd.to_numeric(df['is_laundering'], errors='coerce').fillna(0).astype(int) df = df.dropna(subset=['source', 'target', 'amount', 'timestamp']) return df.reset_index(drop=True) def clean(df: pd.DataFrame) -> pd.DataFrame: cfg = get_config() df = df.rename(columns={ 'Account': 'source', 'Account.1': 'target', 'Amount Paid': 'amount', 'Timestamp': 'timestamp', 'Payment Format': 'payment_type', 'Is Laundering': 'is_laundering', 'From Bank': 'source_bank', 'To Bank': 'target_bank', }) df = df.dropna(subset=['source', 'target', 'amount', 'timestamp']) df = df[(df['source'] != df['target']) | (df['payment_type'] == 'Reinvestment')] df['is_laundering'] = df['is_laundering'].astype(int) df = df[df['amount'] > cfg['data']['min_amount_threshold']] df['timestamp'] = pd.to_datetime(df['timestamp']) return df.reset_index(drop=True) def precompute_node_features(df: pd.DataFrame) -> pd.DataFrame: cfg = get_config() sent = df.groupby('source').agg( total_sent=('amount', 'sum'), count_sent=('source', 'count'), unique_recipients=('target', 'nunique'), ) recv = df.groupby('target').agg( total_received=('amount', 'sum'), count_received=('target', 'count'), unique_senders=('source', 'nunique'), ) node_features = sent.join(recv, how='outer').fillna(0) node_features.index.name = 'account' node_features['forward_ratio'] = node_features['total_sent'] / (node_features['total_received'] + 1) # FIX: label both source AND target of laundering transactions fraudulent_sources = set(df[df['is_laundering'] == 1]['source'].unique()) fraudulent_targets = set(df[df['is_laundering'] == 1]['target'].unique()) fraud_accounts = fraudulent_sources | fraudulent_targets node_features['fraud_flag'] = node_features.index.isin(fraud_accounts).astype(int) # Temporal features first_tx = df.groupby('source')['timestamp'].min().rename('first_tx') last_tx = df.groupby('source')['timestamp'].max().rename('last_tx') node_features = node_features.join(first_tx, how='left') node_features = node_features.join(last_tx, how='left') now = pd.Timestamp(datetime.now()) node_features['account_age_days'] = ( (node_features['last_tx'] - node_features['first_tx']).dt.days.fillna(0) ) node_features['days_since_last_tx'] = ( (now - node_features['last_tx']).dt.days.fillna(9999) ) # Burst score: tx in last 3 days / total tx cutoff_3d = df['timestamp'].max() - pd.Timedelta(days=3) burst = df[df['timestamp'] >= cutoff_3d].groupby('source').size().rename('burst_count') node_features = node_features.join(burst, how='left') node_features['burst_score'] = node_features['burst_count'].fillna(0) / (node_features['count_sent'] + 1) node_features = node_features.drop(columns=['first_tx', 'last_tx', 'burst_count'], errors='ignore') os.makedirs(os.path.dirname(cfg['data']['processed_path']), exist_ok=True) node_features.to_parquet(cfg['data']['processed_path']) return node_features @functools.lru_cache(maxsize=1) def get_processed_data() -> tuple: cfg = get_config() tx_path = cfg['data']['transactions_path'] nf_path = cfg['data']['processed_path'] raw_path = cfg['data']['raw_path'] if os.path.exists(tx_path): transactions_df = pd.read_parquet(tx_path) else: if os.path.exists(raw_path): raw_df = load_raw() transactions_df = clean(raw_df) else: transactions_df = load_checkpoint_transactions() os.makedirs(os.path.dirname(tx_path), exist_ok=True) transactions_df.to_parquet(tx_path) if os.path.exists(nf_path): node_features_df = pd.read_parquet(nf_path) else: node_features_df = precompute_node_features(transactions_df) return transactions_df, node_features_df