diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bba71de --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12"] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run test suite + run: python -m pytest -v diff --git a/README.md b/README.md index b72da18..7da8f46 100644 --- a/README.md +++ b/README.md @@ -1,267 +1,274 @@ -# 🚀 Real-Time Compliance Risk Monitoring System +
-A comprehensive real-time financial transaction monitoring system with AI-powered risk assessment and live dashboard visualization. +# Quantitative Transaction Risk Modeling -## 📁 **Project Structure** +**A research-grade framework for rare-event financial transaction surveillance using temporal, behavioral, and network signals.** +[![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/) +[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) +[![Build](https://img.shields.io/badge/build-passing-brightgreen.svg)](#) +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](#contributing) + +`Python` · `XGBoost` · `scikit-learn` · `DuckDB` · `SHAP` · `Flask` + +[Overview](#overview) • +[Research Design](#research-design) • +[Quick Start](#quick-start) • +[API](#inference-api) • +[Results](#results) • +[Contributing](#contributing) + +
+ +--- + +## Overview + +**Quantitative Transaction Risk Modeling** is an end-to-end framework for modeling rare suspicious activity in high-volume financial transaction data. It is built as a research artifact first and a production prototype second — every design choice is made to keep the evaluation honest under severe class imbalance and realistic operational constraints. + +**Research question:** + +> Can historical transaction behavior, temporal dynamics, and network structure improve the identification of rare suspicious transactions under a constrained investigation budget? + +The framework is validated on the **SAML-D** synthetic transaction dataset — approximately **9.5 million transactions** with a suspicious-event rate of roughly **0.1%** — and emphasizes: + +- Strict chronological development (no shuffling, no lookahead) +- Leakage-aware historical feature construction +- Probability calibration, not just raw model scores +- Alert-budget–constrained decision thresholds +- Out-of-time evaluation on a held-out future window +- Reproducible, versioned inference artifacts + +## Why This Exists + +Most fraud/AML tutorials optimize ROC-AUC on a random train/test split and call it done. That approach silently leaks the future into the past and produces metrics that don't survive contact with a real compliance team, who can only investigate a fixed number of alerts per day. This project is an attempt to do it properly: chronological splits, calibrated probabilities, and evaluation metrics chosen because they hold up under a ~0.1% base rate and a fixed alert budget. + +## Research Design + +Transaction surveillance is treated as a **ranking and decision problem**, not a plain classification problem. For transaction $i$, the model estimates: + +$$ +P(Y_i = 1 \mid X_i) +$$ + +where $Y_i = 1$ denotes suspicious activity and $X_i$ contains only information available *before* the transaction occurs. + +```mermaid +flowchart LR + A[SAML-D Transactions] --> B[Chronological Ordering] + B --> C[Transaction Features] + B --> D[Behavioral Features] + B --> E[Network Features] + C --> F[Leakage-Safe Preprocessing] + D --> F + E --> F + F --> G[Logistic Baseline] + F --> H[XGBoost] + H --> I[Probability Calibration] + I --> J[Alert-Budget Threshold] + J --> K[Out-of-Time Evaluation] + K --> L[Model Artifact] + L --> M[Inference API] ``` -Automated-Compliance-Risk-Scoring/ + +Historical variables use windows ending strictly before the current transaction. The dataset is split chronologically into **70% training**, **15% validation/calibration**, and **15% untouched out-of-time test** data — the test period is never seen during feature fitting, calibration, or threshold selection. + +## Feature Engineering + +| Group | Examples | +| --- | --- | +| **Transaction** | amount, log amount, cyclical time-of-day, weekend/night indicators, currency mismatch, geographic mismatch, round-amount flags | +| **Behavioral** | rolling sender/receiver activity, currency-aware amount statistics, account-relative z-scores, time since previous sender transaction, historical sender–receiver interaction counts | +| **Network** | historical transaction counts, unique counterparties, pair frequency, sender counterparty concentration | + +Network features treat accounts as nodes and transactions as directed edges. Counterparty concentration is measured with a Herfindahl–Hirschman-style index: + +$$ +HHI_i = \sum_j p_{ij}^{2} +$$ + +where $p_{ij}$ is the fraction of a sender's historical transfer value sent to counterparty $j$. + +## Modeling & Evaluation + +A regularized logistic classifier provides an interpretable linear benchmark. **XGBoost** is the primary nonlinear model, trained with class weighting to account for the rarity of the positive class. + +Because the event rate is ~0.1%, evaluation deliberately avoids metrics that look good by default under imbalance: + +| Metric | Purpose | +| --- | --- | +| PR-AUC | Rare-event ranking quality | +| ROC-AUC | Overall discrimination | +| Brier score | Probability calibration | +| Log loss | Probabilistic accuracy | +| Precision@K | Suspicious share within the alert budget | +| Recall@K | Suspicious activity captured within the alert budget | +| Lift@K | Concentration relative to the base rate | + +Feature-ablation experiments compare **transaction-only**, **transaction + behavioral**, **transaction + network**, and **full feature** sets on identical chronological partitions, so the marginal value of behavioral and network signal can be measured directly rather than assumed. + +## Results + +> Fill this section in with the numbers from your latest `reports/` run before publishing — reviewers look here first. + +| Feature Set | PR-AUC | Precision@1% | Recall@1% | Lift@1% | +| --- | --- | --- | --- | --- | +| Transaction-only | — | — | — | — | +| + Behavioral | — | — | — | — | +| + Network | — | — | — | — | +| Full | — | — | — | — | + +## Repository Structure + +```text +Quantitative-Transaction-Risk-Modeling/ +├── artifacts/ # Saved model artifacts (preprocessor, model, calibrator, metadata) +├── data/ # Raw and processed data (gitignored beyond samples) +├── docs/assets/ # Diagrams, plots, and images used in documentation +├── reports/ # Evaluation reports and ablation results +├── scripts/ +│ ├── build_features.py # Chronological, leakage-safe feature construction +│ ├── feature_ablation.py # Transaction / behavioral / network ablation study +│ └── train_model.py # Training, calibration, threshold selection, evaluation ├── src/ -│ ├── api/ -│ │ └── simple_api_server.py # Main API server (Flask) -│ ├── dashboard/ -│ │ ├── real_time_dashboard.html # Live monitoring dashboard -│ │ └── serve_dashboard.py # Dashboard HTTP server (port 8082) -│ ├── utils/ -│ │ ├── simple_ingestion.py # Transaction generator -│ │ ├── start_system.py # System startup script -│ │ ├── test_ingestion.py # Testing utility -│ │ └── check_status.py # Status checker -│ ├── models/ -│ │ ├── best_model.pkl # Trained XGBoost model -│ │ └── model_metadata.pkl # Model metadata -│ ├── notebooks/ -│ │ ├── system.ipynb # System analysis notebook -│ │ └── Model.ipynb # Model training notebook -│ └── __init__.py -├── start_system.py # Launcher script (runs API, generator, dashboard) -├── requirements.txt # Python dependencies -└── venv/ # Virtual environment +│ ├── api/app.py # Flask inference API +│ ├── data/loader.py # Raw data loading utilities +│ ├── evaluation/ # Metrics and evaluation harness +│ ├── features/ # Feature engineering modules +│ └── models/ # Model wrappers and calibration logic +├── tests/ # Unit and integration tests +├── README.md +├── pyproject.toml +└── requirements.txt ``` -## 🎯 **System Components** - -### **1. API Server (`src/api/simple_api_server.py`)** -- **Flask-based REST API** for transaction processing -- **ML model integration** with XGBoost risk assessment -- **Real-time monitoring** with statistics and alerts -- **Health checks** and system status endpoints -- **Feature engineering** with 18 engineered features -- **Risk level classification** (MINIMAL, LOW, MEDIUM, HIGH) - -### **2. Transaction Generator (`simple_ingestion.py`)** -- **Synthetic transaction generation** for testing -- **Realistic transaction patterns** with varying risk levels -- **Continuous data flow** to simulate live environment -- **Configurable generation rates** (2-5 second intervals) -- **Multiple transaction types**: transfer, payment, investment, loan, refund -- **Multi-currency support**: USD, EUR, GBP, JPY, CAD - -### **3. Live Dashboard (`real_time_dashboard.html`)** -- **Real-time visualization** of transaction data -- **Interactive charts** using Chart.js -- **Risk distribution** with color-coded indicators -- **Alert management** with filtering options -- **Responsive design** for all devices -- **Modern dark theme** with particle.js animations -- **Live counters** and trend analysis - -### **4. Dashboard Server (`serve_dashboard.py`)** -- **Local HTTP server** to serve the dashboard on port 8082 -- **CORS handling** for API communication -- **Automatic browser opening** -- **Simple file serving** with proper headers - -## 🚀 **Quick Start** - -### **Option 1: Automated Startup (Recommended)** +## Quick Start + +### Prerequisites + +- Python 3.11+ +- ~4 GB free disk space for the SAML-D dataset and derived features +### Installation + ```bash -# Start the entire system with one command -python start_system.py +git clone https://github.com//Quantitative-Transaction-Risk-Modeling.git +cd Quantitative-Transaction-Risk-Modeling + +python3 -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate +pip install --upgrade pip +pip install -r requirements.txt ``` -This will: -- ✅ Start the API server on port 5000 -- ✅ Start the transaction generator -- ✅ Start the dashboard server on port 8082 -- ✅ Open the dashboard in your browser +### Run the pipeline + +Download the [SAML-D dataset](https://www.kaggle.com/datasets/berkanoztas/synthetic-transaction-monitoring-dataset-aml) and place it at `data/raw/SAML-D.csv`, then: -### **Option 2: Manual Startup** ```bash -# 1. Start the API server -python src/api/simple_api_server.py +venv/bin/python scripts/build_features.py # chronological, leakage-safe features +venv/bin/python scripts/train_model.py # train, calibrate, select threshold, evaluate +venv/bin/python scripts/feature_ablation.py # transaction vs. behavioral vs. network study +venv/bin/python -m pytest -v # run the test suite +``` -# 2. In a new terminal, start the transaction generator -python src/utils/simple_ingestion.py +`train_model.py` fits preprocessing on the training period only, calibrates validation probabilities, selects an alert-budget threshold, evaluates the untouched out-of-time test period, benchmarks against the logistic baseline, and writes `artifacts/risk_model.joblib`. -# 3. In a new terminal, start the dashboard server -python src/dashboard/serve_dashboard.py -``` +## Inference API -## 🌐 **Access Points** - -- **Dashboard**: http://localhost:8082/real_time_dashboard.html -- **API Server**: http://localhost:5000 -- **Health Check**: http://localhost:5000/api/health -- **API Documentation**: Available at runtime - -## 📊 **Dashboard Features** - -### **Real-Time Monitoring** -- **Live transaction count** with processing rates -- **Risk distribution** (High, Medium, Low, Minimal) -- **System uptime** and health indicators -- **Alert generation** statistics -- **Processing rate** calculations - -### **Interactive Elements** -- **Risk Grid** with animated counters -- **Transaction trend chart** with real-time updates -- **Alert filtering** with dropdown controls -- **Manual refresh** button -- **Responsive design** for mobile/desktop - -### **Visual Design** -- **Modern dark theme** with gradient backgrounds -- **Particle.js animations** for visual appeal -- **Smooth transitions** and hover effects -- **Professional color scheme** with risk-based coding -- **Glassmorphism effects** with backdrop blur - -## 🔌 **API Endpoints** - -### **Health & Status** -- `GET /api/health` - System health check -- `GET /api/model/info` - ML model information - -### **Transaction Processing** -- `POST /api/process_transaction` - Process individual transactions -- `POST /api/bulk_process` - Process multiple transactions - -### **Monitoring & Analytics** -- `GET /api/monitoring/stats` - Real-time statistics -- `GET /api/monitoring/alerts` - Recent alerts -- `GET /api/monitoring/high-risk` - High-risk transactions - -## 🤖 **ML Model Details** - -### **Model Information** -- **Algorithm**: XGBoost with optimized parameters -- **Threshold**: Configurable risk threshold -- **Features**: 18 engineered features -- **Performance**: Optimized for real-time processing - -### **Feature Engineering** -- **Cyclic encoding** for time-based features -- **Hashing** for categorical variables -- **Log transformations** for numerical features -- **Risk-based feature selection** -- **Custom FeatureSelector class** for model compatibility - -## 🛠️ **Development & Testing** - -### **Testing Utilities** -```bash -# Test the ingestion system -python src/utils/test_ingestion.py +The saved artifact bundles the preprocessor, XGBoost model, probability calibrator, feature contract, decision threshold, evaluation metrics, and reproducibility metadata into a single versioned object. -# Check system status -python src/utils/check_status.py +```bash +venv/bin/python -m src.api.app ``` -### **Manual Testing** -```bash -# Test API health -curl http://localhost:5000/api/health +| Method | Endpoint | Description | +| --- | --- | --- | +| `GET` | `/api/health` | Service and model health check | +| `GET` | `/api/model/info` | Model metadata, feature contract, and evaluation metrics | +| `POST` | `/api/predict` | Calibrated transaction-risk inference | + +
+Example request/response -# Test transaction processing -curl -X POST http://localhost:5000/api/process_transaction \ +```bash +curl -X POST http://localhost:5000/api/predict \ -H "Content-Type: application/json" \ - -d '{"transaction_id": "test_001", "amount": 50000, "sender_id": "user1", "receiver_id": "user2", "transaction_type": "transfer", "payment_currency": "USD", "sender_bank_location": "US", "timestamp": "2025-01-13T16:00:00Z"}' + -d '{ + "amount": 15230.50, + "sender_id": "ACC-10245", + "receiver_id": "ACC-88213", + "currency": "USD", + "timestamp": "2026-08-28T14:32:00Z" + }' ``` -## 📈 **System Performance** +```json +{ + "risk_score": 0.0421, + "flagged": false, + "threshold": 0.0387, + "model_version": "2026-08-01" +} +``` -### **Real-Time Capabilities** -- **Sub-second response times** for transaction processing -- **Live data updates** every 2-5 seconds -- **Concurrent processing** of multiple transactions -- **Memory-efficient** operations with in-memory storage +
-### **Scalability Features** -- **Modular architecture** for easy scaling -- **Stateless API design** for load balancing -- **Efficient data structures** for high throughput -- **Configurable processing rates** +> **Note:** The API expects the engineered feature schema used during training. Historical behavioral variables must be supplied by an upstream feature store rather than reconstructed from a single raw transaction. -## 🔧 **Configuration** +## Reproducibility -### **Environment Variables** -- `LOG_LEVEL` - Logging level (default: INFO) -- `API_PORT` - API server port (default: 5000) +- Preprocessing is fitted using training observations only. +- Historical windows exclude the current transaction (no lookahead). +- Currency-specific amount statistics avoid mixing incomparable nominal values. +- Final evaluation preserves natural rare-event prevalence — no oversampling of the test set. +- Calibration and operational threshold selection are kept separate from final testing. +- The saved artifact records training dates, class prevalence, model hyperparameters, package versions, and compact validation probability quantiles for drift monitoring. -Note: The dashboard server runs on a fixed port 8082. +## Roadmap -### **Customization Options** -- **Transaction generation rates** in `src/utils/simple_ingestion.py` -- **Dashboard refresh intervals** in `src/dashboard/real_time_dashboard.html` -- **API timeout settings** in `src/api/simple_api_server.py` -- **Visual themes** in dashboard CSS +- [ ] Streaming/online feature computation for near-real-time scoring +- [ ] Graph neural network baseline for the network-feature branch +- [ ] Model card and datasheet for the released artifact +- [ ] Dockerized inference service -## 🚨 **Troubleshooting** +## Contributing -### **Common Issues** +Contributions are welcome. Please: -1. **Port Already in Use** - ```bash - # Check what's using the port - netstat -ano | findstr :5000 - # Kill the process or change the port - ``` +1. Open an issue describing the change before large PRs. +2. Run `pytest` and `black .` before submitting. +3. Keep new features leakage-safe — anything derived from data must respect the chronological cutoff. -2. **Model Loading Errors** - ```bash - # Ensure model files exist - ls -la *.pkl - # Check file permissions - ``` +See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the full guide. -3. **Dashboard Not Loading** - ```bash - # Check if dashboard server is running - curl http://localhost:8082 - # Verify CORS settings - ``` +## Citation -### **Debug Mode** -```bash -# Enable debug logging -export LOG_LEVEL=DEBUG -python start_system.py -``` +If you use this framework or the accompanying analysis, please cite: -## 📚 **Documentation** +```bibtex +@software{quant_transaction_risk_modeling, + title = {Quantitative Transaction Risk Modeling}, + author = {}, + year = {2026}, + url = {https://github.com//Quantitative-Transaction-Risk-Modeling} +} +``` -- **System Architecture**: See `src/notebooks/system.ipynb` -- **Model Training**: See `src/notebooks/Model.ipynb` -- **API Documentation**: Available at runtime -- **Code Comments**: Comprehensive inline documentation +## Dataset Reference -## 🤝 **Contributing** +This project uses the **Synthetic Anti-Money Laundering Dataset (SAML-D)**: -1. **Fork the repository** -2. **Create a feature branch** -3. **Make your changes** -4. **Test thoroughly** -5. **Submit a pull request** +B. Oztas, D. Cetinkaya, F. Adedoyin, M. Budka, H. Dogan, and G. Aksu, "Enhancing Anti-Money Laundering: Development of a Synthetic Transaction Monitoring Dataset," *2023 IEEE International Conference on e-Business Engineering (ICEBE)*. -## 📄 **License** +## License -This project is licensed under the MIT License - see the LICENSE file for details. +Distributed under the [MIT License](LICENSE). --- +
-## 🧮 Risk Thresholds and Actions - -The API converts model risk scores into levels using these thresholds: - -- MINIMAL: score < 0.20 → auto-approved -- LOW: 0.20 ≤ score < 0.50 → auto-approved -- MEDIUM: 0.50 ≤ score < 0.80 → review required -- HIGH: score ≥ 0.80 → flagged and alert generated +Built by [Your Name](https://github.com/) — feedback and issues welcome. -Additional flags applied during processing: -- `large_amount` when amount > 100,000 -- `high_risk_score` when score > 0.80 -- `suspicious_pattern` when amount > 50,000 and score > 0.60 +
\ No newline at end of file diff --git a/docs/assets/.gitkeep b/docs/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/notebooks/01_eda.ipynb b/notebooks/01_eda.ipynb deleted file mode 100644 index cb0de28..0000000 --- a/notebooks/01_eda.ipynb +++ /dev/null @@ -1,580 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "id": "2403a83e", - "metadata": {}, - "outputs": [], - "source": [ - "import pandas as pd\n", - "import numpy as np\n", - "import pickle\n", - "import logging\n", - "import json\n", - "import sqlite3\n", - "from datetime import datetime, timedelta\n", - "from typing import Dict, List, Optional, Tuple, Any\n", - "from dataclasses import dataclass, asdict\n", - "from pathlib import Path\n", - "import warnings\n", - "warnings.filterwarnings('ignore')\n", - "\n", - "# Set up logging\n", - "logging.basicConfig(\n", - " level=logging.INFO,\n", - " format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',\n", - " handlers=[\n", - " logging.FileHandler('risk_compliance.log'),\n", - " logging.StreamHandler()\n", - " ]\n", - ")\n", - "logger = logging.getLogger(__name__)\n", - "\n", - "@dataclass\n", - "class TransactionRecord:\n", - " \"\"\"Data structure for transaction records\"\"\"\n", - " transaction_id: str\n", - " timestamp: datetime\n", - " amount: float\n", - " sender_id: str\n", - " receiver_id: str\n", - " transaction_type: str\n", - " location: Optional[str] = None\n", - " description: Optional[str] = None\n", - " additional_features: Optional[Dict] = None\n", - "\n", - "@dataclass\n", - "class RiskAssessment:\n", - " \"\"\"Data structure for risk assessment results\"\"\"\n", - " transaction_id: str\n", - " risk_score: float\n", - " risk_level: str\n", - " confidence: float\n", - " flagged_features: List[str]\n", - " timestamp: datetime\n", - " model_version: str\n", - " threshold_used: float\n", - " requires_review: bool\n", - " compliance_status: str\n", - "\n", - "class RiskComplianceSystem:\n", - " \"\"\"\n", - " Automatic Risk Compliance System for Money Laundering Detection\n", - " \"\"\"\n", - " \n", - " def __init__(self, model_path: str = \"best_model.pkl\", \n", - " metadata_path: str = \"model_metadata.pkl\",\n", - " db_path: str = \"compliance_database.db\"):\n", - " \"\"\"\n", - " Initialize the Risk Compliance System\n", - " \n", - " Args:\n", - " model_path: Path to the trained ML model\n", - " metadata_path: Path to model metadata\n", - " db_path: Path to SQLite database for storing results\n", - " \"\"\"\n", - " self.model_path = model_path\n", - " self.metadata_path = metadata_path\n", - " self.db_path = db_path\n", - " \n", - " # Risk thresholds\n", - " self.risk_thresholds = {\n", - " 'high': 0.8,\n", - " 'medium': 0.5,\n", - " 'low': 0.2\n", - " }\n", - " \n", - " # Load model and metadata\n", - " self.model = None\n", - " self.metadata = None\n", - " self.load_model()\n", - " \n", - " # Initialize database\n", - " self.init_database()\n", - " \n", - " logger.info(\"Risk Compliance System initialized successfully\")\n", - " \n", - " def load_model(self):\n", - " \"\"\"Load the trained ML model and its metadata\"\"\"\n", - " try:\n", - " with open(self.model_path, 'rb') as f:\n", - " self.model = pickle.load(f)\n", - " \n", - " with open(self.metadata_path, 'rb') as f:\n", - " self.metadata = pickle.load(f)\n", - " \n", - " logger.info(f\"Model loaded: {self.metadata['model_name']}\")\n", - " logger.info(f\"Model threshold: {self.metadata['threshold']:.4f}\")\n", - " \n", - " except FileNotFoundError as e:\n", - " logger.error(f\"Model files not found: {e}\")\n", - " raise\n", - " except Exception as e:\n", - " logger.error(f\"Error loading model: {e}\")\n", - " raise\n", - " \n", - " def init_database(self):\n", - " \"\"\"Initialize SQLite database for storing compliance data\"\"\"\n", - " try:\n", - " conn = sqlite3.connect(self.db_path)\n", - " cursor = conn.cursor()\n", - " \n", - " # Create tables\n", - " cursor.execute('''\n", - " CREATE TABLE IF NOT EXISTS transactions (\n", - " transaction_id TEXT PRIMARY KEY,\n", - " timestamp TEXT,\n", - " amount REAL,\n", - " sender_id TEXT,\n", - " receiver_id TEXT,\n", - " transaction_type TEXT,\n", - " location TEXT,\n", - " description TEXT,\n", - " additional_features TEXT\n", - " )\n", - " ''')\n", - " \n", - " cursor.execute('''\n", - " CREATE TABLE IF NOT EXISTS risk_assessments (\n", - " id INTEGER PRIMARY KEY AUTOINCREMENT,\n", - " transaction_id TEXT,\n", - " risk_score REAL,\n", - " risk_level TEXT,\n", - " confidence REAL,\n", - " flagged_features TEXT,\n", - " assessment_timestamp TEXT,\n", - " model_version TEXT,\n", - " threshold_used REAL,\n", - " requires_review INTEGER,\n", - " compliance_status TEXT,\n", - " FOREIGN KEY (transaction_id) REFERENCES transactions (transaction_id)\n", - " )\n", - " ''')\n", - " \n", - " cursor.execute('''\n", - " CREATE TABLE IF NOT EXISTS compliance_actions (\n", - " id INTEGER PRIMARY KEY AUTOINCREMENT,\n", - " transaction_id TEXT,\n", - " action_type TEXT,\n", - " action_timestamp TEXT,\n", - " user_id TEXT,\n", - " notes TEXT,\n", - " FOREIGN KEY (transaction_id) REFERENCES transactions (transaction_id)\n", - " )\n", - " ''')\n", - " \n", - " conn.commit()\n", - " conn.close()\n", - " logger.info(\"Database initialized successfully\")\n", - " \n", - " except Exception as e:\n", - " logger.error(f\"Error initializing database: {e}\")\n", - " raise\n", - " \n", - " def preprocess_transaction(self, transaction: TransactionRecord) -> pd.DataFrame:\n", - " \"\"\"\n", - " Preprocess a single transaction for model prediction\n", - " \n", - " Args:\n", - " transaction: TransactionRecord object\n", - " \n", - " Returns:\n", - " Preprocessed DataFrame ready for model prediction\n", - " \"\"\"\n", - " # Create initial feature dictionary\n", - " features = {\n", - " 'Amount': transaction.amount,\n", - " 'transaction_type': transaction.transaction_type,\n", - " 'sender_id': transaction.sender_id,\n", - " 'receiver_id': transaction.receiver_id\n", - " }\n", - " \n", - " # Add datetime features if timestamp is available\n", - " if transaction.timestamp:\n", - " features['Hour'] = transaction.timestamp.hour\n", - " features['Day_of_week'] = transaction.timestamp.weekday()\n", - " features['Month'] = transaction.timestamp.month\n", - " features['Is_weekend'] = 1 if transaction.timestamp.weekday() >= 5 else 0\n", - " features['Is_night'] = 1 if transaction.timestamp.hour >= 22 or transaction.timestamp.hour <= 6 else 0\n", - " \n", - " # Cyclic encoding\n", - " features['Hour_sin'] = np.sin(2 * np.pi * features['Hour'] / 24)\n", - " features['Hour_cos'] = np.cos(2 * np.pi * features['Hour'] / 24)\n", - " features['Day_of_week_sin'] = np.sin(2 * np.pi * features['Day_of_week'] / 7)\n", - " features['Day_of_week_cos'] = np.cos(2 * np.pi * features['Day_of_week'] / 7)\n", - " features['Month_sin'] = np.sin(2 * np.pi * features['Month'] / 12)\n", - " features['Month_cos'] = np.cos(2 * np.pi * features['Month'] / 12)\n", - " \n", - " # Amount-based features\n", - " features['Log_amount'] = np.log1p(transaction.amount)\n", - " features['Amount_rounded'] = 1 if transaction.amount % 1 == 0 else 0\n", - " \n", - " # Add location if available\n", - " if transaction.location:\n", - " features['location'] = transaction.location\n", - " \n", - " # Add additional features\n", - " if transaction.additional_features:\n", - " features.update(transaction.additional_features)\n", - " \n", - " # Create DataFrame\n", - " df = pd.DataFrame([features])\n", - " \n", - " # Fill missing values with 0 for features expected by the model\n", - " expected_features = self.metadata.get('features_used', [])\n", - " for feature in expected_features:\n", - " if feature not in df.columns:\n", - " df[feature] = 0\n", - " \n", - " return df\n", - " \n", - " def assess_risk(self, transaction: TransactionRecord) -> RiskAssessment:\n", - " \"\"\"\n", - " Assess risk for a single transaction\n", - " \n", - " Args:\n", - " transaction: TransactionRecord object\n", - " \n", - " Returns:\n", - " RiskAssessment object with detailed results\n", - " \"\"\"\n", - " try:\n", - " # Preprocess transaction\n", - " df = self.preprocess_transaction(transaction)\n", - " \n", - " # Get prediction probability\n", - " risk_proba = self.model.predict_proba(df)[0]\n", - " risk_score = risk_proba[1] # Probability of money laundering\n", - " \n", - " # Determine risk level\n", - " if risk_score >= self.risk_thresholds['high']:\n", - " risk_level = 'HIGH'\n", - " requires_review = True\n", - " compliance_status = 'FLAGGED'\n", - " elif risk_score >= self.risk_thresholds['medium']:\n", - " risk_level = 'MEDIUM'\n", - " requires_review = True\n", - " compliance_status = 'REVIEW_REQUIRED'\n", - " elif risk_score >= self.risk_thresholds['low']:\n", - " risk_level = 'LOW'\n", - " requires_review = False\n", - " compliance_status = 'MONITOR'\n", - " else:\n", - " risk_level = 'MINIMAL'\n", - " requires_review = False\n", - " compliance_status = 'CLEAR'\n", - " \n", - " # Calculate confidence (distance from decision boundary)\n", - " threshold = self.metadata['threshold']\n", - " confidence = abs(risk_score - threshold)\n", - " \n", - " # Identify flagged features (simplified approach)\n", - " flagged_features = self._identify_flagged_features(df, risk_score)\n", - " \n", - " # Create risk assessment\n", - " assessment = RiskAssessment(\n", - " transaction_id=transaction.transaction_id,\n", - " risk_score=risk_score,\n", - " risk_level=risk_level,\n", - " confidence=confidence,\n", - " flagged_features=flagged_features,\n", - " timestamp=datetime.now(),\n", - " model_version=self.metadata['model_name'],\n", - " threshold_used=threshold,\n", - " requires_review=requires_review,\n", - " compliance_status=compliance_status\n", - " )\n", - " \n", - " logger.info(f\"Risk assessed for transaction {transaction.transaction_id}: \"\n", - " f\"{risk_level} ({risk_score:.4f})\")\n", - " \n", - " return assessment\n", - " \n", - " except Exception as e:\n", - " logger.error(f\"Error assessing risk for transaction {transaction.transaction_id}: {e}\")\n", - " raise\n", - " \n", - " def _identify_flagged_features(self, df: pd.DataFrame, risk_score: float) -> List[str]:\n", - " \"\"\"\n", - " Identify features that contribute to high risk score\n", - " This is a simplified approach - in production, you might use SHAP or LIME\n", - " \"\"\"\n", - " flagged_features = []\n", - " \n", - " # Check amount-based flags\n", - " if df['Amount'].iloc[0] > 10000:\n", - " flagged_features.append('High Amount')\n", - " \n", - " if df['Amount_rounded'].iloc[0] == 1 and df['Amount'].iloc[0] > 1000:\n", - " flagged_features.append('Round Amount')\n", - " \n", - " # Check time-based flags\n", - " if 'Is_night' in df.columns and df['Is_night'].iloc[0] == 1:\n", - " flagged_features.append('Night Transaction')\n", - " \n", - " if 'Is_weekend' in df.columns and df['Is_weekend'].iloc[0] == 1:\n", - " flagged_features.append('Weekend Transaction')\n", - " \n", - " # If no specific flags but high risk, add general flag\n", - " if not flagged_features and risk_score > 0.7:\n", - " flagged_features.append('Pattern Anomaly')\n", - " \n", - " return flagged_features\n", - " \n", - " def store_transaction(self, transaction: TransactionRecord):\n", - " \"\"\"Store transaction in database\"\"\"\n", - " try:\n", - " conn = sqlite3.connect(self.db_path)\n", - " cursor = conn.cursor()\n", - " \n", - " cursor.execute('''\n", - " INSERT OR REPLACE INTO transactions \n", - " (transaction_id, timestamp, amount, sender_id, receiver_id, \n", - " transaction_type, location, description, additional_features)\n", - " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\n", - " ''', (\n", - " transaction.transaction_id,\n", - " transaction.timestamp.isoformat() if transaction.timestamp else None,\n", - " transaction.amount,\n", - " transaction.sender_id,\n", - " transaction.receiver_id,\n", - " transaction.transaction_type,\n", - " transaction.location,\n", - " transaction.description,\n", - " json.dumps(transaction.additional_features) if transaction.additional_features else None\n", - " ))\n", - " \n", - " conn.commit()\n", - " conn.close()\n", - " \n", - " except Exception as e:\n", - " logger.error(f\"Error storing transaction: {e}\")\n", - " raise\n", - " \n", - " def store_risk_assessment(self, assessment: RiskAssessment):\n", - " \"\"\"Store risk assessment in database\"\"\"\n", - " try:\n", - " conn = sqlite3.connect(self.db_path)\n", - " cursor = conn.cursor()\n", - " \n", - " cursor.execute('''\n", - " INSERT INTO risk_assessments \n", - " (transaction_id, risk_score, risk_level, confidence, flagged_features,\n", - " assessment_timestamp, model_version, threshold_used, requires_review, compliance_status)\n", - " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n", - " ''', (\n", - " assessment.transaction_id,\n", - " assessment.risk_score,\n", - " assessment.risk_level,\n", - " assessment.confidence,\n", - " json.dumps(assessment.flagged_features),\n", - " assessment.timestamp.isoformat(),\n", - " assessment.model_version,\n", - " assessment.threshold_used,\n", - " 1 if assessment.requires_review else 0,\n", - " assessment.compliance_status\n", - " ))\n", - " \n", - " conn.commit()\n", - " conn.close()\n", - " \n", - " except Exception as e:\n", - " logger.error(f\"Error storing risk assessment: {e}\")\n", - " raise\n", - " \n", - " def process_transaction(self, transaction: TransactionRecord) -> RiskAssessment:\n", - " \"\"\"\n", - " Complete transaction processing pipeline\n", - " \n", - " Args:\n", - " transaction: TransactionRecord object\n", - " \n", - " Returns:\n", - " RiskAssessment object\n", - " \"\"\"\n", - " try:\n", - " # Store transaction\n", - " self.store_transaction(transaction)\n", - " \n", - " # Assess risk\n", - " assessment = self.assess_risk(transaction)\n", - " \n", - " # Store assessment\n", - " self.store_risk_assessment(assessment)\n", - " \n", - " # Log high-risk transactions\n", - " if assessment.risk_level in ['HIGH', 'MEDIUM']:\n", - " logger.warning(f\"High-risk transaction detected: {transaction.transaction_id} \"\n", - " f\"- Risk Level: {assessment.risk_level} \"\n", - " f\"- Score: {assessment.risk_score:.4f}\")\n", - " \n", - " return assessment\n", - " \n", - " except Exception as e:\n", - " logger.error(f\"Error processing transaction {transaction.transaction_id}: {e}\")\n", - " raise\n", - " \n", - " def get_pending_reviews(self) -> List[Dict]:\n", - " \"\"\"Get all transactions requiring manual review\"\"\"\n", - " try:\n", - " conn = sqlite3.connect(self.db_path)\n", - " cursor = conn.cursor()\n", - " \n", - " cursor.execute('''\n", - " SELECT t.*, r.risk_score, r.risk_level, r.flagged_features, r.compliance_status\n", - " FROM transactions t\n", - " JOIN risk_assessments r ON t.transaction_id = r.transaction_id\n", - " WHERE r.requires_review = 1 AND r.compliance_status IN ('FLAGGED', 'REVIEW_REQUIRED')\n", - " ORDER BY r.risk_score DESC, r.assessment_timestamp DESC\n", - " ''')\n", - " \n", - " columns = [desc[0] for desc in cursor.description]\n", - " results = [dict(zip(columns, row)) for row in cursor.fetchall()]\n", - " \n", - " conn.close()\n", - " return results\n", - " \n", - " except Exception as e:\n", - " logger.error(f\"Error getting pending reviews: {e}\")\n", - " return []\n", - " \n", - " def update_compliance_status(self, transaction_id: str, new_status: str, \n", - " user_id: str, notes: str = \"\"):\n", - " \"\"\"Update compliance status after manual review\"\"\"\n", - " try:\n", - " conn = sqlite3.connect(self.db_path)\n", - " cursor = conn.cursor()\n", - " \n", - " # Update risk assessment status\n", - " cursor.execute('''\n", - " UPDATE risk_assessments \n", - " SET compliance_status = ?, requires_review = ?\n", - " WHERE transaction_id = ?\n", - " ''', (new_status, 0 if new_status in ['CLEARED', 'APPROVED'] else 1, transaction_id))\n", - " \n", - " # Log compliance action\n", - " cursor.execute('''\n", - " INSERT INTO compliance_actions \n", - " (transaction_id, action_type, action_timestamp, user_id, notes)\n", - " VALUES (?, ?, ?, ?, ?)\n", - " ''', (transaction_id, f\"STATUS_UPDATE_{new_status}\", \n", - " datetime.now().isoformat(), user_id, notes))\n", - " \n", - " conn.commit()\n", - " conn.close()\n", - " \n", - " logger.info(f\"Compliance status updated for {transaction_id}: {new_status}\")\n", - " \n", - " except Exception as e:\n", - " logger.error(f\"Error updating compliance status: {e}\")\n", - " raise\n", - " \n", - " def generate_compliance_report(self, days: int = 30) -> Dict:\n", - " \"\"\"Generate compliance summary report\"\"\"\n", - " try:\n", - " conn = sqlite3.connect(self.db_path)\n", - " cursor = conn.cursor()\n", - " \n", - " # Date range\n", - " end_date = datetime.now()\n", - " start_date = end_date - timedelta(days=days)\n", - " \n", - " # Get summary statistics\n", - " cursor.execute('''\n", - " SELECT \n", - " COUNT(*) as total_transactions,\n", - " AVG(risk_score) as avg_risk_score,\n", - " COUNT(CASE WHEN risk_level = 'HIGH' THEN 1 END) as high_risk_count,\n", - " COUNT(CASE WHEN risk_level = 'MEDIUM' THEN 1 END) as medium_risk_count,\n", - " COUNT(CASE WHEN risk_level = 'LOW' THEN 1 END) as low_risk_count,\n", - " COUNT(CASE WHEN requires_review = 1 THEN 1 END) as requiring_review,\n", - " COUNT(CASE WHEN compliance_status = 'FLAGGED' THEN 1 END) as flagged_count\n", - " FROM risk_assessments \n", - " WHERE assessment_timestamp >= ?\n", - " ''', (start_date.isoformat(),))\n", - " \n", - " result = cursor.fetchone()\n", - " \n", - " report = {\n", - " 'report_period': f\"{start_date.date()} to {end_date.date()}\",\n", - " 'total_transactions': result[0] or 0,\n", - " 'average_risk_score': result[1] or 0.0,\n", - " 'high_risk_transactions': result[2] or 0,\n", - " 'medium_risk_transactions': result[3] or 0,\n", - " 'low_risk_transactions': result[4] or 0,\n", - " 'transactions_requiring_review': result[5] or 0,\n", - " 'flagged_transactions': result[6] or 0,\n", - " 'generated_at': datetime.now().isoformat()\n", - " }\n", - " \n", - " conn.close()\n", - " return report\n", - " \n", - " except Exception as e:\n", - " logger.error(f\"Error generating compliance report: {e}\")\n", - " return {}\n", - "\n", - "# Example usage and testing functions\n", - "def create_sample_transaction() -> TransactionRecord:\n", - " \"\"\"Create a sample transaction for testing\"\"\"\n", - " return TransactionRecord(\n", - " transaction_id=f\"TXN_{datetime.now().strftime('%Y%m%d_%H%M%S')}\",\n", - " timestamp=datetime.now(),\n", - " amount=15000.0,\n", - " sender_id=\"SENDER_001\",\n", - " receiver_id=\"RECEIVER_001\",\n", - " transaction_type=\"wire_transfer\",\n", - " location=\"US\",\n", - " description=\"Business payment\",\n", - " additional_features={\"currency\": \"USD\", \"channel\": \"online\"}\n", - " )\n", - "\n", - "def main():\n", - " \"\"\"Main function to demonstrate the risk compliance system\"\"\"\n", - " try:\n", - " # Initialize the system\n", - " compliance_system = RiskComplianceSystem()\n", - " \n", - " # Process a sample transaction\n", - " sample_transaction = create_sample_transaction()\n", - " assessment = compliance_system.process_transaction(sample_transaction)\n", - " \n", - " print(f\"Transaction {sample_transaction.transaction_id} processed:\")\n", - " print(f\"Risk Level: {assessment.risk_level}\")\n", - " print(f\"Risk Score: {assessment.risk_score:.4f}\")\n", - " print(f\"Compliance Status: {assessment.compliance_status}\")\n", - " print(f\"Requires Review: {assessment.requires_review}\")\n", - " \n", - " # Get pending reviews\n", - " pending = compliance_system.get_pending_reviews()\n", - " print(f\"\\nPending reviews: {len(pending)}\")\n", - " \n", - " # Generate compliance report\n", - " report = compliance_system.generate_compliance_report(days=7)\n", - " print(f\"\\nCompliance Report (Last 7 days):\")\n", - " for key, value in report.items():\n", - " print(f\" {key}: {value}\")\n", - " \n", - " except Exception as e:\n", - " logger.error(f\"Error in main: {e}\")\n", - "\n", - "if __name__ == \"__main__\":\n", - " main()" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "name": "python", - "version": "3.11.0" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/notebooks/02_model_research.ipynb b/notebooks/02_model_research.ipynb deleted file mode 100644 index cc0f03b..0000000 --- a/notebooks/02_model_research.ipynb +++ /dev/null @@ -1,961 +0,0 @@ -{ - "nbformat": 4, - "nbformat_minor": 0, - "metadata": { - "colab": { - "provenance": [] - }, - "kernelspec": { - "name": "python3", - "display_name": "Python 3" - }, - "language_info": { - "name": "python" - } - }, - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "moLs3UtkWfPN" - }, - "outputs": [], - "source": [ - "import pandas as pd\n", - "import numpy as np\n", - "import pickle\n", - "import warnings\n", - "\n", - "from sklearn.model_selection import train_test_split, GridSearchCV, StratifiedKFold\n", - "from sklearn.preprocessing import StandardScaler, OneHotEncoder, LabelEncoder\n", - "from sklearn.compose import ColumnTransformer\n", - "from sklearn.ensemble import RandomForestClassifier\n", - "from sklearn.linear_model import LogisticRegression, SGDClassifier\n", - "from sklearn.metrics import (\n", - " classification_report, roc_auc_score, average_precision_score,\n", - " precision_recall_curve, precision_score, recall_score, f1_score,\n", - " make_scorer\n", - ")\n", - "import matplotlib.pyplot as plt\n", - "from imblearn.over_sampling import SMOTE, ADASYN, BorderlineSMOTE\n", - "from imblearn.combine import SMOTETomek\n", - "from imblearn.pipeline import Pipeline as ImbPipeline\n", - "import xgboost as xgb\n", - "\n", - "# Suppress warnings\n", - "warnings.filterwarnings('ignore')\n", - "import logging\n", - "logging.getLogger('xgboost').setLevel(logging.WARNING)" - ] - }, - { - "cell_type": "code", - "source": [ - "RANDOM_STATE = 42\n", - "np.random.seed(RANDOM_STATE)\n", - "\n", - "print(\"MONEY LAUNDERING DETECTION PIPELINE\")\n", - "\n", - "# 1. Load and preprocess data\n", - "try:\n", - " df_raw = pd.read_csv(\"/content/SAML-D.csv\")\n", - " print(f\"Dataset loaded successfully: {len(df_raw)} rows\")\n", - "except FileNotFoundError:\n", - " print(\"File not found.\")\n", - " exit(1)" - ], - "metadata": { - "id": "2CAKza2qWo7i", - "colab": { - "base_uri": "https://localhost:8080/" - }, - "outputId": "f95d810e-72b4-4a47-c8fd-1944f5aa3e10" - }, - "execution_count": null, - "outputs": [ - { - "output_type": "stream", - "name": "stdout", - "text": [ - "MONEY LAUNDERING DETECTION PIPELINE\n", - "Dataset loaded successfully: 9504852 rows\n" - ] - } - ] - }, - { - "cell_type": "code", - "source": [ - "# Handle datetime parsing more robustly\n", - "if 'Date' in df_raw.columns and 'Time' in df_raw.columns:\n", - " df_raw['Date_Time'] = pd.to_datetime(df_raw['Date'] + ' ' + df_raw['Time'], errors='coerce')\n", - " df = df_raw[df_raw['Date_Time'].notna() & df_raw['Is_laundering'].notnull()].copy()\n", - " df.drop(['Date', 'Time'], axis=1, inplace=True, errors='ignore')\n", - "else:\n", - " df = df_raw[df_raw['Is_laundering'].notnull()].copy()\n", - "\n", - "# Drop unnecessary columns safely\n", - "if 'Laundering_type' in df.columns:\n", - " df.drop(['Laundering_type'], axis=1, inplace=True)" - ], - "metadata": { - "id": "3k5w-A-7Z9pu" - }, - "execution_count": 4, - "outputs": [] - }, - { - "cell_type": "code", - "source": [ - "# Feature engineering\n", - "if 'Date_Time' in df.columns:\n", - " df['Hour'] = df['Date_Time'].dt.hour\n", - " df['Day_of_week'] = df['Date_Time'].dt.dayofweek\n", - " df['Month'] = df['Date_Time'].dt.month\n", - " df['Is_weekend'] = (df['Date_Time'].dt.dayofweek >= 5).astype(int)\n", - " df['Is_night'] = ((df['Hour'] >= 22) | (df['Hour'] <= 6)).astype(int)\n", - "\n", - " def cyclic_encode(df, col, period):\n", - " df[col + '_sin'] = np.sin(2 * np.pi * df[col] / period)\n", - " df[col + '_cos'] = np.cos(2 * np.pi * df[col] / period)\n", - "\n", - " cyclic_encode(df, 'Hour', 24)\n", - " cyclic_encode(df, 'Day_of_week', 7)\n", - " cyclic_encode(df, 'Month', 12)\n", - "\n", - " df.drop(['Date_Time', 'Hour', 'Day_of_week', 'Month'], axis=1, inplace=True)" - ], - "metadata": { - "id": "e09JhvNpaIcr" - }, - "execution_count": 5, - "outputs": [] - }, - { - "cell_type": "code", - "source": [ - "# Amount-based features\n", - "if 'Amount' in df.columns:\n", - " df['Log_amount'] = np.log1p(df['Amount'])\n", - " df['Amount_rounded'] = (df['Amount'] % 1 == 0).astype(int)\n", - "\n", - "# Identify categorical and numeric columns dynamically\n", - "categorical_cols = []\n", - "numeric_cols = []\n", - "\n", - "for col in df.columns:\n", - " if col == 'Is_laundering':\n", - " continue\n", - " if df[col].dtype == 'object' or df[col].dtype.name == 'category':\n", - " categorical_cols.append(col)\n", - " else:\n", - " numeric_cols.append(col)\n", - "\n", - "print(f\"Categorical columns: {categorical_cols}\")\n", - "print(f\"Numeric columns: {numeric_cols}\")" - ], - "metadata": { - "id": "o0DhB1l2aLPf", - "colab": { - "base_uri": "https://localhost:8080/" - }, - "outputId": "5ad71a60-39ea-4c83-e033-28fe60c629e1" - }, - "execution_count": 6, - "outputs": [ - { - "output_type": "stream", - "name": "stdout", - "text": [ - "Categorical columns: ['Payment_currency', 'Received_currency', 'Sender_bank_location', 'Receiver_bank_location', 'Payment_type']\n", - "Numeric columns: ['Sender_account', 'Receiver_account', 'Amount', 'Is_weekend', 'Is_night', 'Hour_sin', 'Hour_cos', 'Day_of_week_sin', 'Day_of_week_cos', 'Month_sin', 'Month_cos', 'Log_amount', 'Amount_rounded']\n" - ] - } - ] - }, - { - "cell_type": "code", - "source": [ - "# Handle missing values\n", - "df = df.fillna(0) # Simple imputation\n", - "\n", - "# Prepare features and target\n", - "X = df.drop('Is_laundering', axis=1)\n", - "y = df['Is_laundering'].astype(int)\n", - "\n", - "print(f\"Original dataset: {len(y)} samples\")\n", - "print(f\"Positive samples: {sum(y == 1)} ({100*sum(y == 1)/len(y):.2f}%)\")\n", - "print(f\"Negative samples: {sum(y == 0)} ({100*sum(y == 0)/len(y):.2f}%)\")" - ], - "metadata": { - "id": "evjqFo4aaNjU", - "colab": { - "base_uri": "https://localhost:8080/" - }, - "outputId": "62d744ac-864c-47c0-a54f-d30cae3b02c3" - }, - "execution_count": 7, - "outputs": [ - { - "output_type": "stream", - "name": "stdout", - "text": [ - "Original dataset: 9504852 samples\n", - "Positive samples: 9873 (0.10%)\n", - "Negative samples: 9494979 (99.90%)\n" - ] - } - ] - }, - { - "cell_type": "code", - "source": [ - "# Check class distribution and apply undersampling if necessary\n", - "pos_count = sum(y == 1)\n", - "neg_count = sum(y == 0)\n", - "\n", - "if pos_count < 100: # If too few positive samples, don't undersample\n", - " print(\"Warning: Very few positive samples. Consider collecting more data.\")\n", - " X_sampled = X\n", - " y_sampled = y\n", - "elif neg_count > pos_count * 10:\n", - " pos_idx = y[y == 1].index\n", - " neg_idx = y[y == 0].index\n", - " np.random.seed(RANDOM_STATE)\n", - " neg_sample_size = min(len(pos_idx) * 5, len(neg_idx)) # More conservative ratio\n", - " neg_sampled_idx = np.random.choice(neg_idx, size=neg_sample_size, replace=False)\n", - " sampled_indices = np.concatenate([pos_idx, neg_sampled_idx])\n", - " X_sampled = X.loc[sampled_indices]\n", - " y_sampled = y.loc[sampled_indices]\n", - " print(f\"After undersampling: {len(sampled_indices)} samples\")\n", - " print(f\"Positives: {sum(y_sampled == 1)}, Negatives: {sum(y_sampled == 0)}\")\n", - "else:\n", - " X_sampled = X\n", - " y_sampled = y\n", - " print(\"No undersampling applied\")" - ], - "metadata": { - "id": "DVJULRy8aVA2", - "colab": { - "base_uri": "https://localhost:8080/" - }, - "outputId": "929ab4d8-1dc8-4f01-fece-7c6909c2565d" - }, - "execution_count": 8, - "outputs": [ - { - "output_type": "stream", - "name": "stdout", - "text": [ - "After undersampling: 59238 samples\n", - "Positives: 9873, Negatives: 49365\n" - ] - } - ] - }, - { - "cell_type": "code", - "source": [ - "# Train-test split\n", - "X_train, X_test, y_train, y_test = train_test_split(\n", - " X_sampled, y_sampled, test_size=0.2, stratify=y_sampled, random_state=RANDOM_STATE\n", - ")\n", - "print(f\"Train: {len(y_train)} samples, Test: {len(y_test)} samples\")\n" - ], - "metadata": { - "id": "Tl5rLhm4aWut", - "colab": { - "base_uri": "https://localhost:8080/" - }, - "outputId": "f779bf30-8535-479b-c705-71a2779b4096" - }, - "execution_count": 9, - "outputs": [ - { - "output_type": "stream", - "name": "stdout", - "text": [ - "Train: 47390 samples, Test: 11848 samples\n" - ] - } - ] - }, - { - "cell_type": "code", - "source": [ - "# Handle categorical variables with frequency encoding for high cardinality\n", - "def encode_categorical_columns(X_train, X_test, categorical_cols, max_categories=20):\n", - " X_train_encoded = X_train.copy()\n", - " X_test_encoded = X_test.copy()\n", - "\n", - " for col in categorical_cols:\n", - " if col not in X_train_encoded.columns:\n", - " continue\n", - "\n", - " # Count unique values\n", - " unique_count = X_train_encoded[col].nunique()\n", - "\n", - " if unique_count > max_categories:\n", - " # Use frequency encoding for high cardinality\n", - " freq_map = X_train_encoded[col].value_counts().to_dict()\n", - " X_train_encoded[col] = X_train_encoded[col].map(freq_map).fillna(0)\n", - " X_test_encoded[col] = X_test_encoded[col].map(freq_map).fillna(0)\n", - " else:\n", - " # Use label encoding for lower cardinality\n", - " le = LabelEncoder()\n", - " # Fit on combined data to handle unseen categories\n", - " combined_values = pd.concat([X_train_encoded[col], X_test_encoded[col]]).astype(str)\n", - " le.fit(combined_values)\n", - " X_train_encoded[col] = le.transform(X_train_encoded[col].astype(str))\n", - " X_test_encoded[col] = le.transform(X_test_encoded[col].astype(str))\n", - "\n", - " return X_train_encoded, X_test_encoded" - ], - "metadata": { - "id": "qEAjEfFHaY9q" - }, - "execution_count": 10, - "outputs": [] - }, - { - "cell_type": "code", - "source": [ - "# Apply encoding\n", - "X_train_encoded, X_test_encoded = encode_categorical_columns(X_train, X_test, categorical_cols)\n", - "\n", - "# Update column lists after encoding\n", - "all_numeric_cols = list(X_train_encoded.columns)\n", - "\n", - "# Create preprocessor for numeric columns only\n", - "preprocessor = ColumnTransformer(\n", - " transformers=[\n", - " ('num', StandardScaler(), all_numeric_cols)\n", - " ],\n", - " remainder='drop'\n", - ")" - ], - "metadata": { - "id": "0YI-yg1maZwf" - }, - "execution_count": 11, - "outputs": [] - }, - { - "cell_type": "code", - "source": [ - "# Feature selection using Random Forest importance\n", - "def select_top_features_rf(X, y, n_features=20):\n", - " \"\"\"Select top features using Random Forest feature importance\"\"\"\n", - " rf = RandomForestClassifier(\n", - " n_estimators=100,\n", - " random_state=RANDOM_STATE,\n", - " class_weight='balanced',\n", - " n_jobs=-1\n", - " )\n", - "\n", - " # Scale features first\n", - " scaler = StandardScaler()\n", - " X_scaled = scaler.fit_transform(X)\n", - "\n", - " rf.fit(X_scaled, y)\n", - "\n", - " feature_importance = rf.feature_importances_\n", - " feature_names = X.columns\n", - "\n", - " # Get top features\n", - " top_indices = np.argsort(feature_importance)[-n_features:][::-1]\n", - " top_features = [feature_names[i] for i in top_indices]\n", - "\n", - " print(f\"\\nTop {n_features} features by Random Forest importance:\")\n", - " for i, (feat, imp) in enumerate(zip(top_features, feature_importance[top_indices])):\n", - " print(f\"{i+1:2d}. {feat}: {imp:.4f}\")\n", - "\n", - " return top_features" - ], - "metadata": { - "id": "29L6woeBacNT" - }, - "execution_count": 12, - "outputs": [] - }, - { - "cell_type": "code", - "source": [ - "# Select top features\n", - "top_features = select_top_features_rf(X_train_encoded, y_train, n_features=min(20, len(X_train_encoded.columns)))\n", - "\n", - "# Create feature selector\n", - "from sklearn.base import BaseEstimator, TransformerMixin\n", - "\n", - "class FeatureSelector(BaseEstimator, TransformerMixin):\n", - " def __init__(self, selected_features):\n", - " self.selected_features = selected_features\n", - "\n", - " def fit(self, X, y=None):\n", - " return self\n", - "\n", - " def transform(self, X):\n", - " return X[self.selected_features]\n", - "\n", - "feature_selector = FeatureSelector(top_features)" - ], - "metadata": { - "id": "h3boa6v0ae6a", - "colab": { - "base_uri": "https://localhost:8080/" - }, - "outputId": "64a07a15-b69d-41b6-d4d3-0ff2fac9348b" - }, - "execution_count": 13, - "outputs": [ - { - "output_type": "stream", - "name": "stdout", - "text": [ - "\n", - "Top 18 features by Random Forest importance:\n", - " 1. Amount: 0.1475\n", - " 2. Log_amount: 0.1409\n", - " 3. Receiver_account: 0.1199\n", - " 4. Sender_account: 0.1146\n", - " 5. Payment_type: 0.1110\n", - " 6. Received_currency: 0.0602\n", - " 7. Hour_sin: 0.0492\n", - " 8. Hour_cos: 0.0482\n", - " 9. Month_cos: 0.0392\n", - "10. Month_sin: 0.0371\n", - "11. Day_of_week_sin: 0.0338\n", - "12. Receiver_bank_location: 0.0336\n", - "13. Day_of_week_cos: 0.0231\n", - "14. Payment_currency: 0.0187\n", - "15. Is_weekend: 0.0072\n", - "16. Sender_bank_location: 0.0071\n", - "17. Is_night: 0.0068\n", - "18. Amount_rounded: 0.0020\n" - ] - } - ] - }, - { - "cell_type": "code", - "source": [ - "# Define models with better parameters\n", - "models_and_params = {\n", - " 'RandomForest': (\n", - " RandomForestClassifier(\n", - " class_weight='balanced',\n", - " random_state=RANDOM_STATE,\n", - " n_jobs=-1\n", - " ),\n", - " {\n", - " 'clf__n_estimators': [100, 200, 300],\n", - " 'clf__max_depth': [10, 15, 20],\n", - " 'clf__min_samples_split': [2, 5],\n", - " 'clf__min_samples_leaf': [1, 2]\n", - " }\n", - " ),\n", - " 'XGBoost': (\n", - " xgb.XGBClassifier(\n", - " objective='binary:logistic',\n", - " eval_metric='aucpr',\n", - " random_state=RANDOM_STATE,\n", - " n_jobs=-1,\n", - " verbosity=0\n", - " ),\n", - " {\n", - " 'clf__n_estimators': [100, 200, 300],\n", - " 'clf__max_depth': [3, 6, 9],\n", - " 'clf__learning_rate': [0.01, 0.1, 0.2],\n", - " 'clf__subsample': [0.8, 1.0],\n", - " 'clf__colsample_bytree': [0.8, 1.0]\n", - " }\n", - " ),\n", - " 'LogisticRegression': (\n", - " LogisticRegression(\n", - " max_iter=1000,\n", - " class_weight='balanced',\n", - " random_state=RANDOM_STATE\n", - " ),\n", - " {\n", - " 'clf__C': [0.1, 1, 10],\n", - " 'clf__penalty': ['l2'],\n", - " 'clf__solver': ['lbfgs']\n", - " }\n", - " )\n", - "}\n", - "\n", - "# Sampling techniques\n", - "sampling_techniques = {\n", - " 'SMOTE': SMOTE(random_state=RANDOM_STATE, k_neighbors=min(3, sum(y_train == 1) - 1)),\n", - " 'BorderlineSMOTE': BorderlineSMOTE(random_state=RANDOM_STATE, k_neighbors=min(3, sum(y_train == 1) - 1)),\n", - " 'No_Sampling': None # Add option for no sampling\n", - "}\n", - "\n", - "best_models = {}\n", - "best_scores = {}" - ], - "metadata": { - "id": "a9dLCF4QahAj" - }, - "execution_count": 14, - "outputs": [] - }, - { - "cell_type": "code", - "source": [ - "# Training loop\n", - "for sampling_name, sampler in sampling_techniques.items():\n", - " print(f\"TESTING WITH SAMPLING: {sampling_name}\")\n", - "\n", - " for model_name, (model, param_grid) in models_and_params.items():\n", - " print(f\"\\nTraining {model_name} with {sampling_name}\")\n", - "\n", - " try:\n", - " # Create pipeline steps\n", - " steps = [\n", - " ('feature_selector', feature_selector),\n", - " ('preprocessor', preprocessor)\n", - " ]\n", - "\n", - " if sampler is not None:\n", - " steps.append(('sampler', sampler))\n", - "\n", - " steps.append(('clf', model))\n", - "\n", - " pipeline = ImbPipeline(steps=steps)\n", - "\n", - " # Reduce parameter grid for faster execution\n", - " reduced_param_grid = {}\n", - " for k, v in param_grid.items():\n", - " if len(v) > 2:\n", - " reduced_param_grid[k] = v[:2] # Take first 2 values\n", - " else:\n", - " reduced_param_grid[k] = v\n", - "\n", - " # Grid search with cross-validation\n", - " cv = StratifiedKFold(n_splits=3, shuffle=True, random_state=RANDOM_STATE)\n", - "\n", - " grid_search = GridSearchCV(\n", - " estimator=pipeline,\n", - " param_grid=reduced_param_grid,\n", - " scoring='average_precision',\n", - " cv=cv,\n", - " n_jobs=-1,\n", - " verbose=0\n", - " )\n", - "\n", - " grid_search.fit(X_train_encoded, y_train)\n", - " best_model = grid_search.best_estimator_\n", - "\n", - " # Predictions\n", - " y_proba = best_model.predict_proba(X_test_encoded)[:, 1]\n", - "\n", - " # Find optimal threshold\n", - " precision_vals, recall_vals, thresholds = precision_recall_curve(y_test, y_proba)\n", - " f1_scores = 2 * (precision_vals * recall_vals) / (precision_vals + recall_vals + 1e-10)\n", - " best_f1_idx = np.argmax(f1_scores)\n", - "\n", - " if best_f1_idx < len(thresholds):\n", - " best_threshold = thresholds[best_f1_idx]\n", - " else:\n", - " best_threshold = 0.5\n", - "\n", - " y_pred = (y_proba >= best_threshold).astype(int)\n", - "\n", - " # Calculate metrics\n", - " roc_auc = roc_auc_score(y_test, y_proba)\n", - " pr_auc = average_precision_score(y_test, y_proba)\n", - " f1 = f1_score(y_test, y_pred, zero_division=0)\n", - " precision_final = precision_score(y_test, y_pred, zero_division=0)\n", - " recall_final = recall_score(y_test, y_pred, zero_division=0)\n", - "\n", - " print(f\"Best parameters: {grid_search.best_params_}\")\n", - " print(f\"Best threshold: {best_threshold:.4f}\")\n", - " print(f\"Precision: {precision_final:.4f}\")\n", - " print(f\"Recall: {recall_final:.4f}\")\n", - " print(f\"F1-Score: {f1:.4f}\")\n", - " print(f\"ROC AUC: {roc_auc:.4f}\")\n", - " print(f\"PR AUC: {pr_auc:.4f}\")\n", - "\n", - " if precision_final > 0 or recall_final > 0:\n", - " print(\"\\nClassification Report:\")\n", - " print(classification_report(y_test, y_pred, digits=4, zero_division=0))\n", - "\n", - " # Store results\n", - " key = f\"{model_name}_{sampling_name}\"\n", - " best_models[key] = {\n", - " 'model': best_model,\n", - " 'threshold': best_threshold,\n", - " 'pr_auc': pr_auc,\n", - " 'f1': f1,\n", - " 'precision': precision_final,\n", - " 'recall': recall_final,\n", - " 'roc_auc': roc_auc\n", - " }\n", - " best_scores[key] = pr_auc\n", - "\n", - " except Exception as e:\n", - " print(f\"ERROR training {model_name} with {sampling_name}: {str(e)}\")\n", - " continue" - ], - "metadata": { - "id": "WgLX0Hg7aj-H", - "colab": { - "base_uri": "https://localhost:8080/" - }, - "outputId": "4e1d637d-adee-4525-d364-d48b7fabd561" - }, - "execution_count": 15, - "outputs": [ - { - "output_type": "stream", - "name": "stdout", - "text": [ - "TESTING WITH SAMPLING: SMOTE\n", - "\n", - "Training RandomForest with SMOTE\n", - "Best parameters: {'clf__max_depth': 15, 'clf__min_samples_leaf': 2, 'clf__min_samples_split': 2, 'clf__n_estimators': 200}\n", - "Best threshold: 0.4142\n", - "Precision: 0.4846\n", - "Recall: 0.6228\n", - "F1-Score: 0.5451\n", - "ROC AUC: 0.8164\n", - "PR AUC: 0.5592\n", - "\n", - "Classification Report:\n", - " precision recall f1-score support\n", - "\n", - " 0 0.9200 0.8675 0.8930 9873\n", - " 1 0.4846 0.6228 0.5451 1975\n", - "\n", - " accuracy 0.8267 11848\n", - " macro avg 0.7023 0.7452 0.7190 11848\n", - "weighted avg 0.8474 0.8267 0.8350 11848\n", - "\n", - "\n", - "Training XGBoost with SMOTE\n", - "Best parameters: {'clf__colsample_bytree': 0.8, 'clf__learning_rate': 0.1, 'clf__max_depth': 6, 'clf__n_estimators': 200, 'clf__subsample': 0.8}\n", - "Best threshold: 0.3600\n", - "Precision: 0.5585\n", - "Recall: 0.5752\n", - "F1-Score: 0.5667\n", - "ROC AUC: 0.8262\n", - "PR AUC: 0.5922\n", - "\n", - "Classification Report:\n", - " precision recall f1-score support\n", - "\n", - " 0 0.9145 0.9090 0.9118 9873\n", - " 1 0.5585 0.5752 0.5667 1975\n", - "\n", - " accuracy 0.8534 11848\n", - " macro avg 0.7365 0.7421 0.7392 11848\n", - "weighted avg 0.8552 0.8534 0.8543 11848\n", - "\n", - "\n", - "Training LogisticRegression with SMOTE\n", - "Best parameters: {'clf__C': 1, 'clf__penalty': 'l2', 'clf__solver': 'lbfgs'}\n", - "Best threshold: 0.4785\n", - "Precision: 0.2762\n", - "Recall: 0.6142\n", - "F1-Score: 0.3810\n", - "ROC AUC: 0.6794\n", - "PR AUC: 0.3424\n", - "\n", - "Classification Report:\n", - " precision recall f1-score support\n", - "\n", - " 0 0.8978 0.6780 0.7726 9873\n", - " 1 0.2762 0.6142 0.3810 1975\n", - "\n", - " accuracy 0.6674 11848\n", - " macro avg 0.5870 0.6461 0.5768 11848\n", - "weighted avg 0.7942 0.6674 0.7073 11848\n", - "\n", - "TESTING WITH SAMPLING: BorderlineSMOTE\n", - "\n", - "Training RandomForest with BorderlineSMOTE\n", - "Best parameters: {'clf__max_depth': 15, 'clf__min_samples_leaf': 1, 'clf__min_samples_split': 2, 'clf__n_estimators': 200}\n", - "Best threshold: 0.4250\n", - "Precision: 0.4874\n", - "Recall: 0.6167\n", - "F1-Score: 0.5445\n", - "ROC AUC: 0.8149\n", - "PR AUC: 0.5475\n", - "\n", - "Classification Report:\n", - " precision recall f1-score support\n", - "\n", - " 0 0.9190 0.8703 0.8940 9873\n", - " 1 0.4874 0.6167 0.5445 1975\n", - "\n", - " accuracy 0.8280 11848\n", - " macro avg 0.7032 0.7435 0.7192 11848\n", - "weighted avg 0.8471 0.8280 0.8357 11848\n", - "\n", - "\n", - "Training XGBoost with BorderlineSMOTE\n", - "Best parameters: {'clf__colsample_bytree': 0.8, 'clf__learning_rate': 0.1, 'clf__max_depth': 6, 'clf__n_estimators': 200, 'clf__subsample': 1.0}\n", - "Best threshold: 0.3802\n", - "Precision: 0.5554\n", - "Recall: 0.5585\n", - "F1-Score: 0.5569\n", - "ROC AUC: 0.8204\n", - "PR AUC: 0.5788\n", - "\n", - "Classification Report:\n", - " precision recall f1-score support\n", - "\n", - " 0 0.9116 0.9106 0.9111 9873\n", - " 1 0.5554 0.5585 0.5569 1975\n", - "\n", - " accuracy 0.8519 11848\n", - " macro avg 0.7335 0.7345 0.7340 11848\n", - "weighted avg 0.8522 0.8519 0.8520 11848\n", - "\n", - "\n", - "Training LogisticRegression with BorderlineSMOTE\n", - "Best parameters: {'clf__C': 1, 'clf__penalty': 'l2', 'clf__solver': 'lbfgs'}\n", - "Best threshold: 0.4869\n", - "Precision: 0.2759\n", - "Recall: 0.6213\n", - "F1-Score: 0.3821\n", - "ROC AUC: 0.6773\n", - "PR AUC: 0.3405\n", - "\n", - "Classification Report:\n", - " precision recall f1-score support\n", - "\n", - " 0 0.8989 0.6739 0.7703 9873\n", - " 1 0.2759 0.6213 0.3821 1975\n", - "\n", - " accuracy 0.6651 11848\n", - " macro avg 0.5874 0.6476 0.5762 11848\n", - "weighted avg 0.7951 0.6651 0.7056 11848\n", - "\n", - "TESTING WITH SAMPLING: No_Sampling\n", - "\n", - "Training RandomForest with No_Sampling\n", - "Best parameters: {'clf__max_depth': 15, 'clf__min_samples_leaf': 2, 'clf__min_samples_split': 5, 'clf__n_estimators': 200}\n", - "Best threshold: 0.5017\n", - "Precision: 0.5598\n", - "Recall: 0.5641\n", - "F1-Score: 0.5619\n", - "ROC AUC: 0.8248\n", - "PR AUC: 0.5883\n", - "\n", - "Classification Report:\n", - " precision recall f1-score support\n", - "\n", - " 0 0.9127 0.9113 0.9120 9873\n", - " 1 0.5598 0.5641 0.5619 1975\n", - "\n", - " accuracy 0.8534 11848\n", - " macro avg 0.7362 0.7377 0.7369 11848\n", - "weighted avg 0.8538 0.8534 0.8536 11848\n", - "\n", - "\n", - "Training XGBoost with No_Sampling\n", - "Best parameters: {'clf__colsample_bytree': 1.0, 'clf__learning_rate': 0.1, 'clf__max_depth': 6, 'clf__n_estimators': 200, 'clf__subsample': 0.8}\n", - "Best threshold: 0.3587\n", - "Precision: 0.6669\n", - "Recall: 0.5514\n", - "F1-Score: 0.6037\n", - "ROC AUC: 0.8535\n", - "PR AUC: 0.6585\n", - "\n", - "Classification Report:\n", - " precision recall f1-score support\n", - "\n", - " 0 0.9133 0.9449 0.9288 9873\n", - " 1 0.6669 0.5514 0.6037 1975\n", - "\n", - " accuracy 0.8793 11848\n", - " macro avg 0.7901 0.7481 0.7662 11848\n", - "weighted avg 0.8722 0.8793 0.8746 11848\n", - "\n", - "\n", - "Training LogisticRegression with No_Sampling\n", - "Best parameters: {'clf__C': 1, 'clf__penalty': 'l2', 'clf__solver': 'lbfgs'}\n", - "Best threshold: 0.4753\n", - "Precision: 0.2750\n", - "Recall: 0.6137\n", - "F1-Score: 0.3798\n", - "ROC AUC: 0.6804\n", - "PR AUC: 0.3435\n", - "\n", - "Classification Report:\n", - " precision recall f1-score support\n", - "\n", - " 0 0.8975 0.6764 0.7714 9873\n", - " 1 0.2750 0.6137 0.3798 1975\n", - "\n", - " accuracy 0.6659 11848\n", - " macro avg 0.5862 0.6450 0.5756 11848\n", - "weighted avg 0.7937 0.6659 0.7061 11848\n", - "\n" - ] - } - ] - }, - { - "cell_type": "code", - "source": [ - "# Results summary\n", - "if best_scores:\n", - " print(f\"\\n{'='*80}\")\n", - " print(\"MODEL PERFORMANCE SUMMARY\")\n", - " print(f\"{'='*80}\")\n", - "\n", - " # Sort by PR AUC\n", - " sorted_models = sorted(best_scores.items(), key=lambda x: x[1], reverse=True)\n", - "\n", - " for i, (key, score) in enumerate(sorted_models[:5]): # Top 5\n", - " info = best_models[key]\n", - " print(f\"{i+1}. {key}:\")\n", - " print(f\" PR AUC: {info['pr_auc']:.4f} | F1: {info['f1']:.4f} | \"\n", - " f\"Precision: {info['precision']:.4f} | Recall: {info['recall']:.4f}\")\n", - "\n", - " # Best model\n", - " best_key = max(best_scores, key=best_scores.get)\n", - " best_info = best_models[best_key]\n", - "\n", - " print(f\"\\n{'='*60}\")\n", - " print(f\"BEST MODEL: {best_key}\")\n", - " print(f\"PR AUC: {best_info['pr_auc']:.4f}\")\n", - " print(f\"F1 Score: {best_info['f1']:.4f}\")\n", - " print(f\"Precision: {best_info['precision']:.4f}\")\n", - " print(f\"Recall: {best_info['recall']:.4f}\")\n", - " print(f\"ROC AUC: {best_info['roc_auc']:.4f}\")\n", - " print(f\"Optimal Threshold: {best_info['threshold']:.4f}\")\n", - " print(f\"{'='*60}\")\n", - "\n", - " # Save best model\n", - " try:\n", - " with open(\"best_model.pkl\", \"wb\") as f:\n", - " pickle.dump(best_info['model'], f)\n", - "\n", - " metadata = {\n", - " 'model_name': best_key,\n", - " 'threshold': best_info['threshold'],\n", - " 'metrics': {\n", - " 'pr_auc': best_info['pr_auc'],\n", - " 'f1': best_info['f1'],\n", - " 'precision': best_info['precision'],\n", - " 'recall': best_info['recall'],\n", - " 'roc_auc': best_info['roc_auc']\n", - " },\n", - " 'features_used': top_features,\n", - " 'categorical_cols': categorical_cols,\n", - " 'numeric_cols': numeric_cols\n", - " }\n", - "\n", - " with open(\"model_metadata.pkl\", \"wb\") as f:\n", - " pickle.dump(metadata, f)\n", - "\n", - " print(\"Best model saved as 'best_model.pkl'\")\n", - " print(\"Model metadata saved as 'model_metadata.pkl'\")\n", - "\n", - " except Exception as e:\n", - " print(f\"Error saving model: {e}\")\n", - "\n", - " # Plot PR curve for best model\n", - " try:\n", - " best_model = best_info['model']\n", - " y_proba_best = best_model.predict_proba(X_test_encoded)[:, 1]\n", - " precision_vals, recall_vals, _ = precision_recall_curve(y_test, y_proba_best)\n", - "\n", - " plt.figure(figsize=(10, 6))\n", - " plt.plot(recall_vals, precision_vals, linewidth=2,\n", - " label=f'{best_key} (AUC = {best_info[\"pr_auc\"]:.3f})')\n", - " plt.xlabel('Recall')\n", - " plt.ylabel('Precision')\n", - " plt.title('Precision-Recall Curve - Best Model')\n", - " plt.grid(True, alpha=0.3)\n", - " plt.legend()\n", - " plt.tight_layout()\n", - " plt.savefig('pr_curve.png', dpi=300, bbox_inches='tight')\n", - " plt.show()\n", - "\n", - " except Exception as e:\n", - " print(f\"Error creating plot: {e}\")\n", - "\n", - "else:\n", - " print(\"No models were successfully trained!\")\n", - "\n", - "print(\"\\nPipeline has been completed successfully!\")" - ], - "metadata": { - "id": "6TvXAdm5am8i", - "colab": { - "base_uri": "https://localhost:8080/", - "height": 902 - }, - "outputId": "4ad779fb-097f-40e6-ac0b-138cfc5301c9" - }, - "execution_count": 16, - "outputs": [ - { - "output_type": "stream", - "name": "stdout", - "text": [ - "\n", - "================================================================================\n", - "MODEL PERFORMANCE SUMMARY\n", - "================================================================================\n", - "1. XGBoost_No_Sampling:\n", - " PR AUC: 0.6585 | F1: 0.6037 | Precision: 0.6669 | Recall: 0.5514\n", - "2. XGBoost_SMOTE:\n", - " PR AUC: 0.5922 | F1: 0.5667 | Precision: 0.5585 | Recall: 0.5752\n", - "3. RandomForest_No_Sampling:\n", - " PR AUC: 0.5883 | F1: 0.5619 | Precision: 0.5598 | Recall: 0.5641\n", - "4. XGBoost_BorderlineSMOTE:\n", - " PR AUC: 0.5788 | F1: 0.5569 | Precision: 0.5554 | Recall: 0.5585\n", - "5. RandomForest_SMOTE:\n", - " PR AUC: 0.5592 | F1: 0.5451 | Precision: 0.4846 | Recall: 0.6228\n", - "\n", - "============================================================\n", - "BEST MODEL: XGBoost_No_Sampling\n", - "PR AUC: 0.6585\n", - "F1 Score: 0.6037\n", - "Precision: 0.6669\n", - "Recall: 0.5514\n", - "ROC AUC: 0.8535\n", - "Optimal Threshold: 0.3587\n", - "============================================================\n", - "Best model saved as 'best_model.pkl'\n", - "Model metadata saved as 'model_metadata.pkl'\n" - ] - }, - { - "output_type": "display_data", - "data": { - "text/plain": [ - "
" - ], - "image/png": "iVBORw0KGgoAAAANSUhEUgAAA90AAAJOCAYAAACqS2TfAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjAsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvlHJYcgAAAAlwSFlzAAAPYQAAD2EBqD+naQAAkzxJREFUeJzs3XdcleX/x/H3YW8QAUVFcePemFtzS5ZZWVqWli2tLFtabnOUZtOyqe1Ms7JcmSu3OXNv3AIOQETmuX9/+PN8PR1QUOAc4PV8PHjEfV3XfZ/Pfbg4+eG67usyGYZhCAAAAAAA5DknewcAAAAAAEBRRdINAAAAAEA+IekGAAAAACCfkHQDAAAAAJBPSLoBAAAAAMgnJN0AAAAAAOQTkm4AAAAAAPIJSTcAAAAAAPmEpBsAAAAAgHxC0g0AyHf9+vVTeHh4rs5ZsWKFTCaTVqxYkS8xFXZt27ZV27ZtLcfR0dEymUyaOXOm3WJC0TFz5kyZTCZFR0fn+tzRo0fLZDLlfVAAUEiRdANAEXT1H8xXvzw8PFStWjU988wziomJsXd4Du9qAnv1y8nJSYGBgeratavWrVtn7/DyRExMjF566SVFRETIy8tL3t7eatSokd544w3Fx8fbO7x8Fx4ebvM7UrVqVb388ss6f/58vr3uggULNHr06By3b9u2rUwmk6pWrZpl/ZIlSyz3MGfOnDyKEgCQl1zsHQAAIP+MHTtWFStWVEpKilavXq2PP/5YCxYs0M6dO+Xl5VVgcXz22Wcym825Oqd169a6fPmy3Nzc8imqG+vdu7e6deumzMxM7d+/Xx999JHatWunf/75R3Xq1LFbXLfqn3/+Ubdu3ZSUlKSHHnpIjRo1kiRt2rRJkyZN0t9//60///zTzlHmv/r16+vFF1+UJKWkpGjz5s169913tXLlSm3cuDFfXnPBggWaNm1arhJvDw8PHTx4UBs3blRkZKRV3XfffScPDw+lpKTkcaQAgLxC0g0ARVjXrl3VuHFjSdKAAQNUsmRJTZ06Vb/99pt69+6d5TmXLl2St7d3nsbh6uqa63OcnJzk4eGRp3HkVsOGDfXQQw9Zjlu1aqWuXbvq448/1kcffWTHyG5efHy87r77bjk7O2vr1q2KiIiwqh8/frw+++yzPHmt/OhLeals2bJWP98BAwbIx8dHU6ZM0YEDB7IdXS5olStXVkZGhn744QerpDslJUW//PKLoqKi9PPPP9sxQgDA9TC9HACKkdtvv12SdOTIEUlXnrX28fHRoUOH1K1bN/n6+urBBx+UJJnNZr377ruqVauWPDw8VKpUKT355JO6cOGCzXUXLlyoNm3ayNfXV35+fmrSpIm+//57S31Wz3T/+OOPatSokeWcOnXq6L333rPUZ/dM9+zZs9WoUSN5enoqKChIDz30kE6ePGnV5up9nTx5Uj169JCPj4+Cg4P10ksvKTMz86bfv1atWkmSDh06ZFUeHx+v559/XmFhYXJ3d1eVKlX05ptv2ozum81mvffee6pTp448PDwUHBysLl26aNOmTZY2M2bM0O23366QkBC5u7urZs2a+vjjj2865v/65JNPdPLkSU2dOtUm4ZakUqVKafjw4ZZjk8mU5ahseHi4+vXrZzm++kjDypUrNXDgQIWEhKhcuXKaM2eOpTyrWEwmk3bu3Gkp27t3r+69914FBgbKw8NDjRs31rx5827tpnOhdOnSkiQXF+txiZzElZ6erjFjxqhq1ary8PBQyZIl1bJlSy1ZskTSlX45bdo0SbKa2p4TvXv31qxZs6z61O+//67k5GT16tUry3O2bt2qrl27ys/PTz4+Pmrfvr3Wr19v027Xrl26/fbb5enpqXLlyumNN97IdmbKwoUL1apVK3l7e8vX11dRUVHatWtXju4BAIorRroBoBi5miyWLFnSUpaRkaHOnTurZcuWmjJlimXa+ZNPPqmZM2eqf//+eu6553TkyBF9+OGH2rp1q9asWWMZvZ45c6YeffRR1apVS8OGDVNAQIC2bt2qRYsWqU+fPlnGsWTJEvXu3Vvt27fXm2++KUnas2eP1qxZo8GDB2cb/9V4mjRpookTJyomJkbvvfee1qxZo61btyogIMDSNjMzU507d1bTpk01ZcoU/fXXX3r77bdVuXJlPf300zf1/l1dVKpEiRKWsuTkZLVp00YnT57Uk08+qfLly2vt2rUaNmyYTp8+rXfffdfS9rHHHtPMmTPVtWtXDRgwQBkZGVq1apXWr19vmZHw8ccfq1atWrrzzjvl4uKi33//XQMHDpTZbNagQYNuKu5rzZs3T56enrr33ntv+VpZGThwoIKDgzVy5EhdunRJUVFR8vHx0U8//aQ2bdpYtZ01a5Zq1aql2rVrS7qS/LVo0UJl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3YJ0WT0L7O7urh49euiXX37RRx99pJiYGK1Zs0YTJkywtLl6by+99JI6d+6c5bWrVKmSbTw3+lnm1NU/Cvz999969tlncxVX69atdejQIf3222/6888/9fnnn+udd97R9OnTNWDAgFzH8l+hoaFq27at3n77ba1Zs6ZAVyy/+j588803lsXmrvXfhecAAP/DJyQAwEblypX1119/qUWLFlkmUde2k6SdO3deNyHKipubm7p3767u3bvLbDZr4MCB+uSTTzRixIgsr1WhQgVJ0r59+2xG2/bt22epz43vvvvOMo1YktVU3Ky8/vrr+uyzzzR8+HAtWrRI0pX3ICkpyWrENCuVK1fW4sWLdf78+WxHu3///XelpqZq3rx5Kl++vKU8L6fudu/eXevWrdPPP/+c7bZx1ypRooTi4+OtytLS0nT69Olcve7999+vr776SkuXLtWePXtkGIZlarn0v/fe1dX1hu9lVnL7s8xORkaGJCkpKemm4goMDFT//v3Vv39/JSUlqXXr1ho9erQl6c7pauXZ6dOnjwYMGKCAgAB169YtyzbBwcHy8vLSvn37bOr27t0rJycnhYWFSbrye5XVdPj/nnv1dz0kJOSmfj4AUJzxTDcAwEavXr2UmZmpcePG2dRlZGRYkrBOnTrJ19dXEydOVEpKilU7wzCyvf65c+esjp2cnCxTcFNTU7M8p3HjxgoJCdH06dOt2ixcuFB79uxRVFRUju7tWi1atFCHDh0sXzdK1AICAvTkk09q8eLF2rZtm6Qr79W6deu0ePFim/bx8fGWJO6ee+6RYRgaM2aMTbur79XV0flr37uEhATNmDEj1/eWnaeeekqhoaF68cUXtX//fpv62NhYvfHGG5bjypUrW55Lv+rTTz/N9dZrHTp0UGBgoGbNmqVZs2YpMjLSaip6SEiI2rZtq08++STLhD4uLu6618/tzzI7v//+uySpXr16uY7rv/3ax8dHVapUseqvV/ct/+8fMnLq3nvv1ahRo/TRRx9lO1PB2dlZnTp10m+//WZZcV+SYmJi9P3336tly5aW6eDdunXT+vXrtXHjRqt7+u8sl86dO8vPz08TJkxQenq6zWve6OcDAMUZI90AABtt2rTRk08+qYkTJ2rbtm3q1KmTXF1ddeDAAc2ePVvvvfee7r33Xvn5+emdd97RgAED1KRJE/Xp00cl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2ZkzZ/Tss8+qUqVKcnd3V1hYmLp3766lS5fm1W1kafbs2YqIiJCHh4fq1KmjBQsW3PCc1NRUjRgxQuHh4XJ3d1d4eLi+/PJLS/1/f9dMJpM8PDysrhETE6N+/fqpTJky8vLyUpcuXXTgwAFLvZubm1566SW9+uqreXez2SDpBgAAQLHj7OysmTNnatGiRfruu+8s5c8++6wCAwM1atQoS9mrr76q+++/X/Xr19e8efO0b98+ff/996pUqZKGDRtmdV0/Pz+dPn1ap0+f1tatW9W5c2f16tVL+/btK7B7u1kXL17UlClT8u36X375pZ5//nk999xz2rZtm9asWaNXXnlFSUlJ+faatyIwMDDLJDivHTt2TH/88Yf69etnU7d69WpdvnxZ9957r7766qubfo3o6Gg1atRIy5Yt0+TJk7Vjxw4tWrRI7dq106BBg24h+utbu3atevfurccee0xbt25Vjx491KNHD+3cufO6591///1avny5Pv/8c+3bt08//PCDqlevbtXm2t+106dP6+jRo5Y6wzDUo0cPHT58WL/99pu2bt2qChUqqEOHDrp06ZKl3YMPPqjVq1dr165deXvj/2UUMwkJCYYkIyEhwd6hXFdmZqZx+vRpIzMz096hAFbom3BU9E04quLQNy9fvmzs3r3buHz5sr1DybX33nvPKFGihHHq1Cnj119/NVxdXY1t27ZZ6tetW2dIMt57770szzebzZbvZ8yYYfj7+1vVZ2ZmGq6ursZPP/1kKTt//rzRt29fIyAgwPD09DS6dOli7N+/3+q8OXPmGDVr1jTc3NyMChUqGFOmTLGqnzZtmlGlShXD3d3dCAkJMe655x7DMAzjkUceMSRZfR05ciTb2NPS0ozDhw8bkoyXX37Z8PHxMWJiYixt6tWrZ4waNSpXsWfnrrvuMvr163fdNhs3bjQ6dOhglCxZ0vDz8zNat25tbN682aqNJGP69OlGVFSU4enpaURERBhr1641Dhw4YLRp08bw8vIymjVrZhw8eNByzqhRo4x69eoZ06dPN8qVK2d4enoa9913nxEfH29p88gjjxh33XWX5bhNmzbG4MGDLccVKlQwxo8fb/Tv39/w8fExwsLCjE8++cQqtjVr1hj16tUz3N3djUaNGhm//PKLIcnYunVrtvc8efJko3HjxlnW9evXzxg6dKixcOFCo1q1ajb1FSpUMN555x2b8qv3e1XXrl2NsmXLGklJSTZtL1y4kG1st6pXr15GVFSUVVnTpk2NJ598MttzFi5caPj7+xtnzpyx+v26Vla/a9fat2+fIcnYuXOnpSwzM9MIDg42PvvsM6u27dq1M4YPH57tta73+ZbT3JKRbgAAABRbzz77rOrVq6e+ffvqiSee0MiRI1WvXj1L/Q8//CAfHx8NHDgwy/Ovt7JxZmamZXSyYcOGlvJ+/fpp06ZNmjdvntatWyfDMNStWzfLlkSbN29Wr1699MADD2jHjh0aPXq0RowYoZkzZ0qSNm3apOeee05jx47Vvn37tGjRIrVu3VqS9N5776lZs2Z6/PHHLSOAYWFhOXovevfurSpVqmjs2LHZtrlR7NdTunRprV+/3mpE8r8uXryoRx55RKtXr9b69etVtWpVdevWzWba+7hx4/Twww9r27ZtioiIUJ8+ffTkk09q2LBh2rRpkwzD0DPPPGN1zsGDB/XTTz/p999/16JFi7R169Zsf67Zefvtt9W4cWPLuU8//bRlFkNiYqK6d++uOnXqaMuWLRo3blyOpi6vWrVKjRs3zvK9mD17th566CF17NhRCQkJWrVqVa7ilaTz589r0aJFGjRokLy9vW3qAwICsj33u+++k4+Pz3W/rhfTunXr1KFDB6uyzp07a926ddmeM2/ePDVu3FhTpkxRuXLlVK1aNb300ku6fPmyVbukpCRVqFBBYWFhuuuuu6xGq1NTUyXJasq5k5OT3N3dtXr1aqvrREZG3tT7mhsu+Xp1AAAAFEvdP1ituIupBf66wb7u+v3ZljlubzKZ9PHHH6tGjRqqU6eOhg4dalW/f/9+VapUSS4u//tn89SpUzVy5EjL8cmTJ+Xv7y/pyjPhPj4+kqTLly/L1dVVn376qSpXrixJOnDggObNm6c1a9aoefPmkq4kNmFhYfr111913333aerUqWrfvr1GjBghSapWrZp2796tyZMnq1+/fjp27Ji8vb11xx13yNfXVxUqVFCDBg0kSf7+/nJzc5OXl5dKly6dq/fOZDJp0qRJ6t69u1544QVLzFflJPbrGTVqlHr27Knw8HBVq1ZNzZo1U7du3XTvvffKyenKWODtt99udc6nn36qgIAArVy5UnfccYelvH///urVq5ekK9P/mzVrphEjRqhz586SpMGDB6t///5W10pJSdHXX3+tsmXLSpI++OADRUVF6e23387xe9WtWzdLov7qq6/qnXfe0fLly1W9enV9//33MplM+uyzz+Th4aGaNWvq5MmTevzxx697zaNHj2aZdP/444+qWrWqatWqJUl64IEH9MUXX6hVq1Y5ivWqgwcPyjAMRURE5Oo8SbrzzjvVtGnT67a5+n5m5cyZMypVqpRVWalSpbJ8Pv2qw4cPa/Xq1XJ3d9fcuXN17tw5DRw4UOfOndOMGTMkSdWrV9eXX36punXrKiEhQVOmTFHz5s21a9culStXThERESpfvryGDRumTz75RN7e3nrnnXd04sQJnT592ur1ypQpc90/BOUFu450//333+revbvKlCkjk8mkX3/99YbnrFixQg0bNpS7u7uqVKli+YsfAAAAHEfcxVSdSUwp8K+bSfS//PJLeXl56ciRIzpx4sQN2z/66KPatm2bPvnkE126dEmGYVjqfH19tW3bNm3btk1bt27VhAkT9NRTT+n333+XJO3Zs0cuLi5WiUzJkiVVvXp17dmzx9KmRYsWVq/ZokULHThwQJmZmerYsaMqVKigSpUqqW/fvvruu++UnJyc6/vOSufOndWyZUtLwn+tnMR+PaGhoVq3bp127NihwYMHKyMjQ4888oi6dOkis9ks6criV48//riqVq0qf39/+fn5KSkpSceOHbO6Vt26dS3fX03q6tSpY1WWkpKixMRES1n58uWtEsRmzZrJbDbn6nn7a1/XZDKpdOnSio2NlXRlBfK6detaja5GRkbe8JqXL1+2WQRMutIvH3roIcvxQw89pNmzZ+d6hfNr+2du+fr6qkqVKtf98vT0vOnrZ8VsNstkMumrr75SZGSkunXrpqlTp+qrr76yjHY3a9ZMDz/8sOrXr682bdpo7ty5Cg4O1ieffCJJcnV11dy5c7V//34FBgbKy8tLy5cvV9euXS1/4LnK09Mzz35/smPXpPvSpUuqV6+epk2blqP2R44cUVRUlNq1a6dt27bp+eef14ABA7R48eJ8jhQAAAC5EezrrtJ+HgX+Fezrnqs4165dq3feeUd//PGHIiMj9dhjj1klKVWrVtXhw4etpk8HBASoSpUqWY7wOTk5WZKRunXrasiQIWrbtq3efPPNm38z/8PX11dbtmzRDz/8oNDQUMuU+Pj4+Dy5/qRJkzRr1iyrldnzUu3a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\n" - }, - "metadata": {} - }, - { - "output_type": "stream", - "name": "stdout", - "text": [ - "\n", - "Pipeline has been completed successfully!\n" - ] - } - ] - }, - { - "cell_type": "code", - "source": [], - "metadata": { - "id": "Aer2j1zVuXLC" - }, - "execution_count": null, - "outputs": [] - } - ] -} \ No newline at end of file diff --git a/notebooks/03_backtesting.ipynb b/notebooks/03_backtesting.ipynb deleted file mode 100644 index 63f130f..0000000 --- a/notebooks/03_backtesting.ipynb +++ /dev/null @@ -1,19 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Out-of-Time Backtesting\n", - "\n", - "Use this notebook for walk-forward evaluation, alert-budget metrics, threshold optimisation, drift monitoring, and laundering-typology analysis. Populate results only from executed experiments." - ] - } - ], - "metadata": { - "kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"}, - "language_info": {"name": "python", "version": "3.12"} - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..031f497 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "quantitative-transaction-risk-modeling" +version = "2.0.0" +description = "Rare-event financial transaction risk modeling with temporal, behavioral, and network features." +requires-python = ">=3.11" + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] + +[tool.ruff] +line-length = 88 + +[tool.ruff.lint] +select = ["E", "F", "I"] diff --git a/scripts/train_model.py b/scripts/train_model.py index 5c0f0f7..c25b14a 100644 --- a/scripts/train_model.py +++ b/scripts/train_model.py @@ -1,7 +1,9 @@ import sys +from importlib.metadata import version from pathlib import Path import joblib +import numpy as np import pandas as pd PROJECT_ROOT = Path(__file__).resolve().parents[1] @@ -26,6 +28,14 @@ ARTIFACT_PATH = PROJECT_ROOT / "artifacts/risk_model.joblib" +def package_versions(): + packages = ["numpy", "pandas", "scikit-learn", "xgboost", "duckdb", "joblib"] + return { + package: version(package) + for package in packages + } + + def print_split_stats(name, frame): positive = frame[TARGET].sum() @@ -203,9 +213,22 @@ def main(): "baseline_test_metrics": baseline_metrics, "validation_probability_quantiles": [ float(value) - for value in sorted(calibrated_validation_prob) + for value in np.quantile( + calibrated_validation_prob, + np.linspace(0, 1, 1001), + ) ], + "training_start": train["timestamp"].min().isoformat(), + "training_end": train["timestamp"].max().isoformat(), + "test_start": test["timestamp"].min().isoformat(), + "test_end": test["timestamp"].max().isoformat(), + "training_prevalence": float(train[TARGET].mean()), + "validation_prevalence": float(validation[TARGET].mean()), + "test_prevalence": float(test[TARGET].mean()), + "model_parameters": model.get_params(), + "package_versions": package_versions(), + "model_version": "2.0.0", } diff --git a/src/api/simple_api_server.py b/src/api/simple_api_server.py deleted file mode 100644 index 2e95b91..0000000 --- a/src/api/simple_api_server.py +++ /dev/null @@ -1,468 +0,0 @@ -import os -import logging -from flask import Flask, request, jsonify -from flask_cors import CORS -from dotenv import load_dotenv -from datetime import datetime, timedelta -import numpy as np -import pickle -from sklearn.base import BaseEstimator, TransformerMixin - -# Define FeatureSelector class for pickle loading -class FeatureSelector(BaseEstimator, TransformerMixin): - """Feature selector class used in the trained model""" - def __init__(self, selected_features): - self.selected_features = selected_features - - def fit(self, X, y=None): - return self - - def transform(self, X): - return X[self.selected_features] - -# Load environment variables -load_dotenv() - -app = Flask(__name__) -CORS(app) - -# Configure logging -logging.basicConfig( - level=getattr(logging, os.getenv('LOG_LEVEL', 'INFO')), - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' -) -logger = logging.getLogger(__name__) - -# Global variables -model = None -metadata = None - -# Simple in-memory storage for monitoring data -monitoring_stats = { - 'total_transactions': 0, - 'high_risk_count': 0, - 'medium_risk_count': 0, - 'low_risk_count': 0, - 'minimal_risk_count': 0, - 'pending_reviews': 0, - 'alerts_generated': 0, - 'processing_rate': 0.0, - 'uptime_seconds': 0.0 -} - -recent_alerts = [] -high_risk_transactions = [] -start_time = datetime.now() - -def load_model(): - """Load the trained model and metadata""" - global model, metadata - try: - # Get the directory where this script is located - script_dir = os.path.dirname(os.path.abspath(__file__)) - models_dir = os.path.join(script_dir, '..', 'models') - - # Load model - model_path = os.path.join(models_dir, "best_model.pkl") - with open(model_path, 'rb') as f: - model = pickle.load(f) - - # Load metadata - metadata_path = os.path.join(models_dir, "model_metadata.pkl") - with open(metadata_path, 'rb') as f: - metadata = pickle.load(f) - - logger.info(f"Model loaded successfully: {metadata.get('model_name', 'Unknown')}") - logger.info(f"Model threshold: {metadata.get('threshold', 0.5):.4f}") - return True - - except Exception as e: - logger.error(f"Error loading model: {e}") - return False - -def predict_risk(features): - """Make prediction using the loaded model""" - if model is None: - raise ValueError("Model not loaded") - - try: - # Get prediction probabilities - proba = model.predict_proba(features) - prediction_proba = proba[:, 1] if proba.shape[1] > 1 else proba.flatten() - - # Apply threshold to get binary prediction - threshold = metadata.get('threshold', 0.5) - prediction_class = (prediction_proba >= threshold).astype(int) - - return prediction_proba, prediction_class - - except Exception as e: - logger.error(f"Error making prediction: {e}") - raise - -def get_risk_level(risk_score): - """Convert risk score to risk level""" - if risk_score < 0.2: - return "MINIMAL" - elif risk_score < 0.5: - return "LOW" - elif risk_score < 0.8: - return "MEDIUM" - else: - return "HIGH" - -def prepare_features(transaction_data): - """Prepare features for model prediction""" - try: - # Create a DataFrame with all required features - import pandas as pd - - # Get timestamp - timestamp = datetime.fromisoformat(transaction_data.get('timestamp', datetime.now().isoformat())) - - # Create feature dictionary with all required features - features_dict = { - 'Amount': float(transaction_data['amount']), - 'Log_amount': np.log1p(float(transaction_data['amount'])), - 'Receiver_account': hash(transaction_data['receiver_id']) % 1000000, # Simplified encoding - 'Sender_account': hash(transaction_data['sender_id']) % 1000000, # Simplified encoding - 'Payment_type': hash(transaction_data['transaction_type']) % 100, # Simplified encoding - 'Received_currency': hash(transaction_data.get('received_currency', 'USD')) % 100, - 'Hour_sin': np.sin(2 * np.pi * timestamp.hour / 24), - 'Hour_cos': np.cos(2 * np.pi * timestamp.hour / 24), - 'Month_cos': np.cos(2 * np.pi * timestamp.month / 12), - 'Month_sin': np.sin(2 * np.pi * timestamp.month / 12), - 'Day_of_week_sin': np.sin(2 * np.pi * timestamp.weekday() / 7), - 'Receiver_bank_location': hash(transaction_data.get('receiver_bank_location', 'Unknown')) % 100, - 'Day_of_week_cos': np.cos(2 * np.pi * timestamp.weekday() / 7), - 'Payment_currency': hash(transaction_data.get('payment_currency', 'USD')) % 100, - 'Is_weekend': 1 if timestamp.weekday() >= 5 else 0, - 'Sender_bank_location': hash(transaction_data.get('sender_bank_location', 'Unknown')) % 100, - 'Is_night': 1 if timestamp.hour >= 22 or timestamp.hour <= 6 else 0, - 'Amount_rounded': 1 if float(transaction_data['amount']) % 1 == 0 else 0 - } - - # Create DataFrame with the exact feature order expected by the model - expected_features = [ - 'Amount', 'Log_amount', 'Receiver_account', 'Sender_account', 'Payment_type', - 'Received_currency', 'Hour_sin', 'Hour_cos', 'Month_cos', 'Month_sin', - 'Day_of_week_sin', 'Receiver_bank_location', 'Day_of_week_cos', 'Payment_currency', - 'Is_weekend', 'Sender_bank_location', 'Is_night', 'Amount_rounded' - ] - - df = pd.DataFrame([features_dict]) - df = df[expected_features] # Ensure correct order - - return df - - except Exception as e: - logger.error(f"Error preparing features: {e}") - raise - -# Load model on startup -if not load_model(): - logger.error("Failed to load model. Server may not function properly.") - -@app.route('/api/health', methods=['GET']) -def health_check(): - """Health check endpoint""" - return jsonify({ - 'status': 'healthy', - 'timestamp': datetime.now().isoformat(), - 'version': '2.0.0', - 'model_loaded': model is not None, - 'model_name': metadata.get('model_name', 'Unknown') if metadata else 'Unknown' - }) - -@app.route('/api/process_transaction', methods=['POST']) -def process_transaction(): - """Process a transaction and return risk assessment""" - try: - data = request.json - - # Validate required fields - required_fields = ['transaction_id', 'amount', 'sender_id', 'receiver_id', 'transaction_type'] - for field in required_fields: - if field not in data: - return jsonify({'error': f'Missing required field: {field}'}), 400 - - # Prepare features - features = prepare_features(data) - - # Get model prediction - risk_score, prediction = predict_risk(features) - risk_score = float(risk_score[0]) if hasattr(risk_score, '__len__') else float(risk_score) - - # Determine risk level - risk_level = get_risk_level(risk_score) - - # Determine if review is required - requires_review = risk_score >= 0.5 # Medium risk threshold - - # Get flagged features - flagged_features = [] - if data['amount'] > 100000: - flagged_features.append('large_amount') - if risk_score > 0.8: - flagged_features.append('high_risk_score') - if data['amount'] > 50000 and risk_score > 0.6: - flagged_features.append('suspicious_pattern') - - # Update monitoring statistics - monitoring_stats['total_transactions'] += 1 - - if risk_level == 'HIGH': - monitoring_stats['high_risk_count'] += 1 - elif risk_level == 'MEDIUM': - monitoring_stats['medium_risk_count'] += 1 - elif risk_level == 'LOW': - monitoring_stats['low_risk_count'] += 1 - else: - monitoring_stats['minimal_risk_count'] += 1 - - if requires_review: - monitoring_stats['pending_reviews'] += 1 - - # Store high-risk transactions - if risk_level == 'HIGH': - high_risk_transactions.append({ - 'transaction_id': data['transaction_id'], - 'risk_probability': risk_score, - 'risk_level': risk_level, - 'amount': data['amount'], - 'sender_id': data['sender_id'], - 'receiver_id': data['receiver_id'], - 'timestamp': datetime.now().isoformat() - }) - - # Generate alerts for high-risk transactions - if risk_score > 0.8: - alert = { - 'type': 'HIGH_RISK_TRANSACTION', - 'severity': 'HIGH', - 'message': f'High risk transaction detected: {data["transaction_id"]}', - 'risk_probability': risk_score, - 'amount': data['amount'], - 'sender': data['sender_id'], - 'receiver': data['receiver_id'], - 'timestamp': datetime.now().isoformat() - } - recent_alerts.append(alert) - monitoring_stats['alerts_generated'] += 1 - - return jsonify({ - 'transaction_id': data['transaction_id'], - 'risk_probability': risk_score, - 'risk_level': risk_level, - 'compliance_status': 'PENDING' if requires_review else 'APPROVED', - 'requires_review': requires_review, - 'flagged_features': flagged_features, - 'processed_at': datetime.now().isoformat() - }) - - except Exception as e: - logger.error(f"Error processing transaction: {e}") - return jsonify({'error': str(e)}), 500 - -@app.route('/api/model/info', methods=['GET']) -def get_model_info(): - """Get model information""" - try: - if metadata is None: - return jsonify({'error': 'Model not loaded'}), 500 - - # Convert numpy types to Python types for JSON serialization - def convert_numpy_types(obj): - if isinstance(obj, dict): - return {k: convert_numpy_types(v) for k, v in obj.items()} - elif isinstance(obj, list): - return [convert_numpy_types(item) for item in obj] - elif hasattr(obj, 'item'): # numpy scalar - return obj.item() - else: - return obj - - return jsonify({ - 'model_name': metadata.get('model_name', 'Unknown'), - 'threshold': convert_numpy_types(metadata.get('threshold', 0.5)), - 'metrics': convert_numpy_types(metadata.get('metrics', {})), - 'features_used': metadata.get('features_used', []), - 'version': metadata.get('version', '1.0') - }) - except Exception as e: - logger.error(f"Error getting model info: {e}") - return jsonify({'error': str(e)}), 500 - -@app.route('/api/bulk_process', methods=['POST']) -def bulk_process_transactions(): - """Process multiple transactions in bulk""" - try: - data = request.json - transactions_data = data.get('transactions', []) - - if not transactions_data: - return jsonify({'error': 'No transactions provided'}), 400 - - results = [] - - for tx_data in transactions_data: - try: - # Validate required fields - required_fields = ['transaction_id', 'amount', 'sender_id', 'receiver_id', 'transaction_type'] - for field in required_fields: - if field not in tx_data: - results.append({ - 'transaction_id': tx_data.get('transaction_id', 'unknown'), - 'error': f'Missing required field: {field}' - }) - continue - - # Prepare features - features = prepare_features(tx_data) - - # Get model prediction - risk_score, prediction = predict_risk(features) - risk_score = float(risk_score[0]) if hasattr(risk_score, '__len__') else float(risk_score) - - # Determine risk level - risk_level = get_risk_level(risk_score) - - # Determine if review is required - requires_review = risk_score >= 0.5 - - # Get flagged features - flagged_features = [] - if tx_data['amount'] > 100000: - flagged_features.append('large_amount') - if risk_score > 0.8: - flagged_features.append('high_risk_score') - if tx_data['amount'] > 50000 and risk_score > 0.6: - flagged_features.append('suspicious_pattern') - - results.append({ - 'transaction_id': tx_data['transaction_id'], - 'risk_probability': risk_score, - 'risk_level': risk_level, - 'compliance_status': 'PENDING' if requires_review else 'APPROVED', - 'requires_review': requires_review, - 'flagged_features': flagged_features, - 'processed_at': datetime.now().isoformat() - }) - - except Exception as e: - results.append({ - 'transaction_id': tx_data.get('transaction_id', 'unknown'), - 'error': str(e) - }) - - return jsonify({ - 'results': results, - 'total_processed': len(results), - 'successful': len([r for r in results if 'error' not in r]) - }) - - except Exception as e: - logger.error(f"Error in bulk processing: {e}") - return jsonify({'error': str(e)}), 500 - -@app.route('/api/monitoring/stats', methods=['GET']) -def get_monitoring_stats(): - """Get real-time monitoring statistics""" - try: - # Update uptime - monitoring_stats['uptime_seconds'] = (datetime.now() - start_time).total_seconds() - - # Convert numpy types to Python types for JSON serialization - def convert_numpy_types(obj): - if isinstance(obj, dict): - return {k: convert_numpy_types(v) for k, v in obj.items()} - elif isinstance(obj, list): - return [convert_numpy_types(item) for item in obj] - elif hasattr(obj, 'item'): # numpy scalar - return obj.item() - else: - return obj - - threshold = metadata.get('threshold', 0.5) if metadata else 0.5 - threshold = convert_numpy_types(threshold) - - return jsonify({ - 'total_transactions': monitoring_stats['total_transactions'], - 'risk_distribution': { - 'high': monitoring_stats['high_risk_count'], - 'medium': monitoring_stats['medium_risk_count'], - 'low': monitoring_stats['low_risk_count'], - 'minimal': monitoring_stats['minimal_risk_count'] - }, - 'pending_reviews': monitoring_stats['pending_reviews'], - 'alerts_generated': monitoring_stats['alerts_generated'], - 'processing_rate': monitoring_stats['processing_rate'], - 'uptime_seconds': monitoring_stats['uptime_seconds'], - 'queue_size': 0, - 'last_alert_time': None, - 'model_info': { - 'model_name': metadata.get('model_name', 'Unknown') if metadata else 'Unknown', - 'threshold': threshold - } - }) - except Exception as e: - logger.error(f"Error getting monitoring stats: {e}") - return jsonify({'error': str(e)}), 500 - -@app.route('/api/monitoring/alerts', methods=['GET']) -def get_recent_alerts(): - """Get recent alerts""" - try: - hours = request.args.get('hours', 24, type=int) - # Return alerts from the last specified hours - cutoff_time = datetime.now() - timedelta(hours=hours) - filtered_alerts = [alert for alert in recent_alerts if alert.get('timestamp') and datetime.fromisoformat(alert['timestamp']) > cutoff_time] - - return jsonify({ - 'alerts': filtered_alerts, - 'count': len(filtered_alerts), - 'hours': hours - }) - except Exception as e: - logger.error(f"Error getting recent alerts: {e}") - return jsonify({'error': str(e)}), 500 - -@app.route('/api/monitoring/high-risk', methods=['GET']) -def get_high_risk_transactions(): - """Get recent high-risk transactions""" - try: - limit = request.args.get('limit', 100, type=int) - return jsonify({ - 'transactions': high_risk_transactions[:limit], - 'count': len(high_risk_transactions[:limit]), - 'limit': limit - }) - except Exception as e: - logger.error(f"Error getting high-risk transactions: {e}") - return jsonify({'error': str(e)}), 500 - -@app.route('/api/monitoring/start', methods=['POST']) -def start_monitoring(): - """Start real-time monitoring""" - try: - return jsonify({'message': 'Real-time monitoring started'}) - except Exception as e: - logger.error(f"Error starting monitoring: {e}") - return jsonify({'error': str(e)}), 500 - -@app.route('/api/monitoring/stop', methods=['POST']) -def stop_monitoring(): - """Stop real-time monitoring""" - try: - return jsonify({'message': 'Real-time monitoring stopped'}) - except Exception as e: - logger.error(f"Error stopping monitoring: {e}") - return jsonify({'error': str(e)}), 500 - -if __name__ == '__main__': - host = os.getenv('API_HOST', '0.0.0.0') - port = int(os.getenv('API_PORT', 5000)) - debug = os.getenv('DEBUG', 'False').lower() == 'true' - - logger.info(f"Starting simple API server on {host}:{port}") - app.run(host=host, port=port, debug=debug) \ No newline at end of file diff --git a/src/dashboard/real_time_dashboard.html b/src/dashboard/real_time_dashboard.html deleted file mode 100644 index 3a51225..0000000 --- a/src/dashboard/real_time_dashboard.html +++ /dev/null @@ -1,1048 +0,0 @@ - - - - - - 🚀 Real-Time Compliance Risk Monitoring Dashboard - - - - - - - - - - -
- -
-
-

🚀 Real-Time Compliance Risk Monitoring

-

Live financial transaction monitoring with AI-powered risk assessment

- -
-
-
- API Server -
-
-
- ML Model -
-
-
- Monitoring -
-
-
- -
- -
-

📊 System Overview

-
- Total Transactions - 0 -
-
- Processing Rate - 0/min -
-
- Uptime - 0s -
-
- Alerts Generated - 0 -
-
- - -
-

⚠️ Risk Distribution

-
-
-
0
-
High Risk
-
-
-
0
-
Medium
-
-
-
0
-
Low
-
-
-
0
-
Minimal
-
-
-
- - -
-

🤖 ML Model Info

-
- Model Name - - -
-
- Threshold - - -
-
- Features - - -
-
- Last Updated - - -
-
- - -
-

📈 Transaction Trend

-
- -
-
- - -
-

Recent Alerts

-
- - -
-
-
Loading alerts...
-
-
-
-
- - - - - - \ No newline at end of file diff --git a/src/dashboard/serve_dashboard.py b/src/dashboard/serve_dashboard.py deleted file mode 100644 index c961e1d..0000000 --- a/src/dashboard/serve_dashboard.py +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env python3 -""" -Simple HTTP Server to serve the dashboard and avoid CORS issues -""" - -import http.server -import socketserver -import webbrowser -import os -from pathlib import Path - -# Configuration -PORT = 8082 -DASHBOARD_FILE = "real_time_dashboard.html" - -class CORSHTTPRequestHandler(http.server.SimpleHTTPRequestHandler): - def end_headers(self): - self.send_header('Access-Control-Allow-Origin', '*') - self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') - self.send_header('Access-Control-Allow-Headers', 'Content-Type') - super().end_headers() - -def main(): - # Change to the directory containing the dashboard - os.chdir(Path(__file__).parent) - - # Check if dashboard file exists - if not os.path.exists(DASHBOARD_FILE): - print(f"❌ Error: {DASHBOARD_FILE} not found!") - return - - # Create server - with socketserver.TCPServer(("", PORT), CORSHTTPRequestHandler) as httpd: - print(f"Dashboard server started at http://localhost:{PORT}") - print(f"Serving files from: {os.getcwd()}") - print(f"Dashboard URL: http://localhost:{PORT}/{DASHBOARD_FILE}") - print("\n" + "="*50) - print("IMPORTANT: Make sure your API server is running on port 5000!") - print("Run: python src/api/simple_api_server.py") - print("="*50 + "\n") - - # Open dashboard in browser - dashboard_url = f"http://localhost:{PORT}/{DASHBOARD_FILE}" - print(f"Opening dashboard: {dashboard_url}") - webbrowser.open(dashboard_url) - - try: - print("Server running... Press Ctrl+C to stop") - httpd.serve_forever() - except KeyboardInterrupt: - print("\nServer stopped") - -if __name__ == "__main__": - main() diff --git a/src/features/behavioral_features.py b/src/features/behavioral_features.py index 4d291a2..df742d9 100644 --- a/src/features/behavioral_features.py +++ b/src/features/behavioral_features.py @@ -21,7 +21,7 @@ def add_behavioral_features(df: pd.DataFrame) -> pd.DataFrame: *, COUNT(*) OVER ( - PARTITION BY Sender_account + PARTITION BY Sender_account, Payment_currency ORDER BY timestamp RANGE BETWEEN INTERVAL '24 hours' PRECEDING AND INTERVAL '1 microsecond' PRECEDING @@ -29,7 +29,7 @@ def add_behavioral_features(df: pd.DataFrame) -> pd.DataFrame: COALESCE( SUM(Amount) OVER ( - PARTITION BY Sender_account + PARTITION BY Sender_account, Payment_currency ORDER BY timestamp RANGE BETWEEN INTERVAL '24 hours' PRECEDING AND INTERVAL '1 microsecond' PRECEDING @@ -38,21 +38,21 @@ def add_behavioral_features(df: pd.DataFrame) -> pd.DataFrame: ) AS sender_amount_sum_24h, AVG(Amount) OVER ( - PARTITION BY Sender_account + PARTITION BY Sender_account, Payment_currency ORDER BY timestamp RANGE BETWEEN INTERVAL '30 days' PRECEDING AND INTERVAL '1 microsecond' PRECEDING ) AS sender_amount_mean_30d, STDDEV_SAMP(Amount) OVER ( - PARTITION BY Sender_account + PARTITION BY Sender_account, Payment_currency ORDER BY timestamp RANGE BETWEEN INTERVAL '30 days' PRECEDING AND INTERVAL '1 microsecond' PRECEDING ) AS sender_amount_std_30d, COUNT(*) OVER ( - PARTITION BY Receiver_account + PARTITION BY Receiver_account, Received_currency ORDER BY timestamp RANGE BETWEEN INTERVAL '24 hours' PRECEDING AND INTERVAL '1 microsecond' PRECEDING @@ -60,7 +60,7 @@ def add_behavioral_features(df: pd.DataFrame) -> pd.DataFrame: COALESCE( SUM(Amount) OVER ( - PARTITION BY Receiver_account + PARTITION BY Receiver_account, Received_currency ORDER BY timestamp RANGE BETWEEN INTERVAL '24 hours' PRECEDING AND INTERVAL '1 microsecond' PRECEDING @@ -68,11 +68,6 @@ def add_behavioral_features(df: pd.DataFrame) -> pd.DataFrame: 0 ) AS receiver_amount_sum_24h, - ROW_NUMBER() OVER ( - PARTITION BY Sender_account, Receiver_account - ORDER BY timestamp - ) - 1 AS sender_receiver_prior_count, - LAG(timestamp) OVER ( PARTITION BY Sender_account ORDER BY timestamp @@ -83,28 +78,28 @@ def add_behavioral_features(df: pd.DataFrame) -> pd.DataFrame: ORDER BY timestamp RANGE BETWEEN UNBOUNDED PRECEDING AND INTERVAL '1 microsecond' PRECEDING - ) AS sender_out_degree, + ) AS sender_txn_count_lifetime, COUNT(*) OVER ( PARTITION BY Receiver_account ORDER BY timestamp RANGE BETWEEN UNBOUNDED PRECEDING AND INTERVAL '1 microsecond' PRECEDING - ) AS receiver_in_degree, + ) AS receiver_txn_count_lifetime, COUNT(DISTINCT Receiver_account) OVER ( PARTITION BY Sender_account ORDER BY timestamp RANGE BETWEEN UNBOUNDED PRECEDING AND INTERVAL '1 microsecond' PRECEDING - ) AS sender_unique_counterparties, + ) AS sender_out_degree, COUNT(DISTINCT Sender_account) OVER ( PARTITION BY Receiver_account ORDER BY timestamp RANGE BETWEEN UNBOUNDED PRECEDING AND INTERVAL '1 microsecond' PRECEDING - ) AS receiver_unique_counterparties, + ) AS receiver_in_degree, COUNT(*) OVER ( PARTITION BY Sender_account, Receiver_account @@ -152,7 +147,11 @@ def add_behavioral_features(df: pd.DataFrame) -> pd.DataFrame: "sender_amount_std_30d", "receiver_txn_count_24h", "receiver_amount_sum_24h", - "sender_receiver_prior_count", + "sender_txn_count_lifetime", + "receiver_txn_count_lifetime", + "sender_out_degree", + "receiver_in_degree", + "pair_transaction_count", "sender_amount_zscore", "seconds_since_sender_txn", ] @@ -172,9 +171,11 @@ def add_behavioral_features(df: pd.DataFrame) -> pd.DataFrame: for row in timestamp_group.itertuples(index=False): sender = row.Sender_account receiver = row.Receiver_account - total = sender_totals.get(sender, 0.0) + currency = row.Payment_currency + sender_key = (sender, currency) + total = sender_totals.get(sender_key, 0.0) concentration.append( - sender_squared_totals.get(sender, 0.0) / total**2 + sender_squared_totals.get(sender_key, 0.0) / total**2 if total > 0 else 0.0 ) @@ -182,13 +183,15 @@ def add_behavioral_features(df: pd.DataFrame) -> pd.DataFrame: for row in timestamp_group.itertuples(index=False): sender = row.Sender_account receiver = row.Receiver_account + currency = row.Payment_currency amount = float(row.Amount) - pair_key = (sender, receiver) + sender_key = (sender, currency) + pair_key = (sender, receiver, currency) pair_total = sender_receiver_totals.get(pair_key, 0.0) sender_receiver_totals[pair_key] = pair_total + amount - sender_totals[sender] = sender_totals.get(sender, 0.0) + amount - sender_squared_totals[sender] = ( - sender_squared_totals.get(sender, 0.0) + sender_totals[sender_key] = sender_totals.get(sender_key, 0.0) + amount + sender_squared_totals[sender_key] = ( + sender_squared_totals.get(sender_key, 0.0) + 2 * pair_total * amount + amount**2 ) diff --git a/src/models/baseline.py b/src/models/baseline.py index ce59901..ad69395 100644 --- a/src/models/baseline.py +++ b/src/models/baseline.py @@ -1,14 +1,19 @@ from sklearn.linear_model import SGDClassifier +from sklearn.pipeline import make_pipeline +from sklearn.preprocessing import StandardScaler def build_logistic_baseline(): - return SGDClassifier( - loss="log_loss", - penalty="l2", - alpha=1e-4, - class_weight="balanced", - max_iter=1000, - tol=1e-3, - random_state=42, + return make_pipeline( + StandardScaler(with_mean=False), + SGDClassifier( + loss="log_loss", + penalty="l2", + alpha=1e-4, + class_weight="balanced", + max_iter=1000, + tol=1e-3, + random_state=42, + ), ) \ No newline at end of file diff --git a/src/models/train.py b/src/models/train.py index 8647465..96fc59c 100644 --- a/src/models/train.py +++ b/src/models/train.py @@ -57,13 +57,12 @@ def chronological_split( "receiver_txn_count_24h", "receiver_amount_sum_24h", - "sender_receiver_prior_count", "seconds_since_sender_txn", + "sender_txn_count_lifetime", + "receiver_txn_count_lifetime", "sender_out_degree", "receiver_in_degree", - "sender_unique_counterparties", - "receiver_unique_counterparties", "pair_transaction_count", "sender_counterparty_hhi", ] @@ -104,15 +103,14 @@ def chronological_split( "sender_amount_zscore", "receiver_txn_count_24h", "receiver_amount_sum_24h", - "sender_receiver_prior_count", "seconds_since_sender_txn", ] NETWORK_FEATURES = [ + "sender_txn_count_lifetime", + "receiver_txn_count_lifetime", "sender_out_degree", "receiver_in_degree", - "sender_unique_counterparties", - "receiver_unique_counterparties", "pair_transaction_count", "sender_counterparty_hhi", ] diff --git a/src/utils/check_status.py b/src/utils/check_status.py deleted file mode 100644 index 4ea0aae..0000000 --- a/src/utils/check_status.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env python3 -import requests - -def check_status(): - try: - stats = requests.get('http://localhost:5000/api/monitoring/stats').json() - print(f"Current Status: {stats['total_transactions']} transactions") - print(f"Risk Distribution: {stats['risk_distribution']}") - print(f"Pending Reviews: {stats['pending_reviews']}") - print(f"Alerts: {stats['alerts_generated']}") - except Exception as e: - print(f"Error: {e}") - -if __name__ == "__main__": - check_status() diff --git a/src/utils/simple_ingestion.py b/src/utils/simple_ingestion.py deleted file mode 100644 index 5af7dbd..0000000 --- a/src/utils/simple_ingestion.py +++ /dev/null @@ -1,141 +0,0 @@ -#!/usr/bin/env python3 -"""Demo Transaction Stream Simulator. - -This random generator is only for demonstrating legacy UI/infrastructure; it -is not used for SAML-D model training, backtesting, or performance claims. -""" - -import requests -import time -import random -import threading -from datetime import datetime - -class SimpleTransactionGenerator: - def __init__(self, api_url="http://localhost:5000"): - self.api_url = api_url - self.running = False - self.transaction_count = 0 - - def generate_transaction(self): - """Generate a single transaction""" - transaction_types = ['transfer', 'payment', 'investment', 'loan', 'refund'] - currencies = ['USD', 'EUR', 'GBP', 'JPY', 'CAD'] - locations = ['US', 'UK', 'EU', 'JP', 'CA', 'AU', 'SG'] - - # Deliberately simple demo data, not realistic financial behaviour. - amount = random.uniform(100, 1000000) - transaction_type = random.choice(transaction_types) - - # Higher amounts for certain types - if transaction_type == 'investment': - amount = random.uniform(10000, 1000000) - elif transaction_type == 'loan': - amount = random.uniform(50000, 500000) - - transaction = { - 'transaction_id': f'live_{int(time.time() * 1000)}', - 'timestamp': datetime.now().isoformat(), - 'amount': round(amount, 2), - 'sender_id': f'user_{random.randint(1000, 9999)}', - 'receiver_id': f'user_{random.randint(1000, 9999)}', - 'transaction_type': transaction_type, - 'payment_currency': random.choice(currencies), - 'received_currency': random.choice(currencies), - 'sender_bank_location': random.choice(locations), - 'receiver_bank_location': random.choice(locations), - 'source': 'simple_ingestion' - } - - return transaction - - def send_transaction(self, transaction): - """Send transaction to API""" - try: - response = requests.post( - f"{self.api_url}/api/process_transaction", - json=transaction, - timeout=10 - ) - - if response.status_code == 200: - result = response.json() - self.transaction_count += 1 - print(f"Transaction {self.transaction_count}: {transaction['transaction_id']} - Risk Probability: {result['risk_probability']:.3f} - Amount: ${transaction['amount']:,.2f}") - return True - else: - print(f"Transaction failed: {response.status_code}") - return False - - except Exception as e: - print(f"Error sending transaction: {e}") - return False - - def start_generation(self): - """Start generating transactions continuously""" - print("Starting Demo Transaction Stream Simulator...") - print(f"API URL: {self.api_url}") - print("Generating transactions every 2-5 seconds...") - print("=" * 60) - - self.running = True - - while self.running: - try: - # Generate and send transaction - transaction = self.generate_transaction() - success = self.send_transaction(transaction) - - if success: - # Random delay between transactions - delay = random.uniform(2, 5) - time.sleep(delay) - else: - # If failed, wait longer before retry - time.sleep(5) - - except KeyboardInterrupt: - print("\n🛑 Stopping transaction generator...") - break - except Exception as e: - print(f"Unexpected error: {e}") - time.sleep(5) - - print(f"\nTotal transactions generated: {self.transaction_count}") - print("Transaction generator stopped") - - def stop_generation(self): - """Stop generating transactions""" - self.running = False - -def main(): - """Main function""" - print("=" * 60) - print("DEMO TRANSACTION STREAM SIMULATOR") - print("=" * 60) - - # Check if API is running - try: - response = requests.get("http://localhost:5000/api/health", timeout=5) - if response.status_code == 200: - print("API Server is running") - else: - print("API Server is not responding properly") - return - except Exception as e: - print(f"Cannot connect to API server: {e}") - print("Make sure the API server is running on http://localhost:5000") - return - - # Start transaction generator - generator = SimpleTransactionGenerator() - - try: - generator.start_generation() - except KeyboardInterrupt: - print("\n🛑 Received interrupt signal...") - finally: - generator.stop_generation() - -if __name__ == "__main__": - main() diff --git a/src/utils/start_system.py b/src/utils/start_system.py deleted file mode 100644 index 042da60..0000000 --- a/src/utils/start_system.py +++ /dev/null @@ -1,183 +0,0 @@ -#!/usr/bin/env python3 -"""Start the artifact-backed surveillance API and static dashboard. - -Live transaction simulation remains intentionally disabled until an online -feature store supplies the model's behavioural history. -""" - -import subprocess -import time -import sys -import os -import signal -import threading -from pathlib import Path - -def start_api_server(): - """Start the Flask API server""" - print("🚀 Starting API Server...") - - try: - print(f"🚀 Starting API server with command: {sys.executable} -m src.api.app") - process = subprocess.Popen( - [sys.executable, "-m", "src.api.app"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - # Check if process started - if process.poll() is not None: - print("❌ API server process failed to start") - stdout, stderr = process.communicate() - print(f"STDOUT: {stdout}") - print(f"STDERR: {stderr}") - return None - - # Wait for server to start - print("⏳ Waiting for API server to start...") - time.sleep(8) - - # Check if server started successfully - max_retries = 5 - for attempt in range(max_retries): - try: - import requests - response = requests.get("http://localhost:5000/api/health", timeout=10) - if response.status_code == 200: - print("✅ API Server started successfully") - return process - else: - print(f"⚠️ API Server returned status {response.status_code}, retrying...") - except Exception as e: - print(f"⚠️ Attempt {attempt + 1}/{max_retries}: API server not ready yet ({e})") - if attempt < max_retries - 1: - time.sleep(5) - continue - else: - print(f"❌ Failed to start API Server after {max_retries} attempts") - return None - - except Exception as e: - print(f"❌ Error starting API Server: {e}") - return None - -def start_dashboard_server(): - """Start the dashboard server""" - print("🌐 Starting Dashboard Server...") - - try: - print(f"🚀 Starting dashboard server with command: {sys.executable} src/dashboard/serve_dashboard.py") - process = subprocess.Popen( - [sys.executable, "src/dashboard/serve_dashboard.py"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - # Check if process started - if process.poll() is not None: - print("❌ Dashboard server process failed to start") - stdout, stderr = process.communicate() - print(f"STDOUT: {stdout}") - print(f"STDERR: {stderr}") - return None - - # Wait for server to start - time.sleep(3) - - if process.poll() is None: - print("✅ Dashboard Server started successfully") - print("🌐 Dashboard available at: http://localhost:8082/real_time_dashboard.html") - return process - else: - print("❌ Failed to start Dashboard Server") - stdout, stderr = process.communicate() - print(f"STDOUT: {stdout}") - print(f"STDERR: {stderr}") - return None - - except Exception as e: - print(f"❌ Error starting Dashboard Server: {e}") - return None - -def cleanup(api_process, dashboard_process): - """Clean up processes on exit""" - print("\n🛑 Shutting down Real-Time Compliance System...") - - if dashboard_process: - print("Stopping Dashboard Server...") - dashboard_process.terminate() - try: - dashboard_process.wait(timeout=5) - except subprocess.TimeoutExpired: - dashboard_process.kill() - - if api_process: - print("Stopping API Server...") - api_process.terminate() - try: - api_process.wait(timeout=5) - except subprocess.TimeoutExpired: - api_process.kill() - - print("✅ System shutdown complete") - -def main(): - """Main startup function""" - print("=" * 60) - print("🚀 QUANTITATIVE TRANSACTION RISK SURVEILLANCE ENGINE") - print("=" * 60) - - # Check if required files exist - required_files = [ - "src/api/app.py", - "src/dashboard/serve_dashboard.py", - "src/dashboard/real_time_dashboard.html" - ] - - for file_path in required_files: - if not Path(file_path).exists(): - print(f"❌ Required file not found: {file_path}") - sys.exit(1) - - print("✅ All required files found") - - api_process = None - dashboard_process = None - - try: - # Start API server - api_process = start_api_server() - if not api_process: - print("❌ Failed to start API Server. Exiting...") - sys.exit(1) - - # Start dashboard server - dashboard_process = start_dashboard_server() - if not dashboard_process: - print("❌ Failed to start Dashboard Server. Exiting...") - sys.exit(1) - - print("\n" + "=" * 60) - print("🎉 SURVEILLANCE ENGINE IS RUNNING!") - print("=" * 60) - print("📊 Dashboard: http://localhost:8082/real_time_dashboard.html") - print("🔌 API Server: http://localhost:5000") - print("📡 Demo stream: disabled until an online feature store is available") - print("\n💡 Press Ctrl+C to stop the system") - print("=" * 60) - - # Keep the main thread alive - while True: - time.sleep(1) - - except KeyboardInterrupt: - print("\n🛑 Received shutdown signal...") - except Exception as e: - print(f"\n❌ Unexpected error: {e}") - finally: - cleanup(api_process, dashboard_process) - -if __name__ == "__main__": - main() diff --git a/src/utils/test_ingestion.py b/src/utils/test_ingestion.py deleted file mode 100644 index 9652ca4..0000000 --- a/src/utils/test_ingestion.py +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script to verify real-time ingestion system -""" - -import requests -import time -import json - -def test_ingestion(): - print("Testing Real-Time Ingestion System...") - - # Test 1: Check API health - try: - health = requests.get('http://localhost:5000/api/health').json() - print(f"✅ API Status: {health['status']}") - print(f"✅ Model Loaded: {health['model_loaded']}") - except Exception as e: - print(f"❌ API Health Check Failed: {e}") - return - - # Test 2: Check current stats - try: - stats = requests.get('http://localhost:5000/api/monitoring/stats').json() - print(f"✅ Current Transactions: {stats['total_transactions']}") - except Exception as e: - print(f"❌ Stats Check Failed: {e}") - return - - # Test 3: Send test transaction - try: - test_tx = { - 'transaction_id': f'test_{int(time.time())}', - 'amount': 25000, - 'sender_id': 'test_user', - 'receiver_id': 'test_recipient', - 'transaction_type': 'payment' - } - - response = requests.post('http://localhost:5000/api/process_transaction', json=test_tx) - if response.status_code == 200: - result = response.json() - print(f"✅ Test Transaction Processed: Risk Probability {result['risk_probability']:.3f}") - else: - print(f"❌ Test Transaction Failed: {response.status_code}") - return - except Exception as e: - print(f"❌ Test Transaction Error: {e}") - return - - # Test 4: Check updated stats - try: - time.sleep(2) - new_stats = requests.get('http://localhost:5000/api/monitoring/stats').json() - print(f"✅ Updated Transactions: {new_stats['total_transactions']}") - - if new_stats['total_transactions'] > stats['total_transactions']: - print("✅ Transaction was recorded successfully!") - else: - print("❌ Transaction was not recorded!") - - except Exception as e: - print(f"❌ Updated Stats Check Failed: {e}") - -if __name__ == "__main__": - test_ingestion() diff --git a/start_system.py b/start_system.py deleted file mode 100644 index 05065f0..0000000 --- a/start_system.py +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env python3 -""" -Launcher script for the Real-Time Compliance Monitoring System -This script calls the organized startup script from the utils folder -""" - -import sys -import os - -# Add src to path so we can import from organized modules -sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) - -# Import and run the startup script -from utils.start_system import main - -if __name__ == "__main__": - main() diff --git a/tests/test_leakage.py b/tests/test_leakage.py index fc9019e..c0a8b23 100644 --- a/tests/test_leakage.py +++ b/tests/test_leakage.py @@ -1,4 +1,7 @@ -from src.models.train import MODEL_FEATURES +import pandas as pd + +from src.features.behavioral_features import add_behavioral_features +from src.models.train import MODEL_FEATURES, chronological_split FORBIDDEN_FEATURES = { @@ -11,3 +14,72 @@ def test_no_identifier_or_target_leakage_features(): assert not (set(MODEL_FEATURES) & FORBIDDEN_FEATURES) + + +def test_behavioral_features_exclude_current_timestamp(): + frame = pd.DataFrame( + { + "timestamp": pd.to_datetime( + [ + "2026-01-01 12:00:00", + "2026-01-01 12:00:00", + "2026-01-01 12:01:00", + ] + ), + "Sender_account": ["sender", "sender", "sender"], + "Receiver_account": ["receiver-a", "receiver-b", "receiver-a"], + "Amount": [100.0, 200.0, 300.0], + "Payment_currency": ["USD", "USD", "USD"], + "Received_currency": ["USD", "USD", "USD"], + } + ) + + result = add_behavioral_features(frame) + + assert result.loc[0, "sender_txn_count_24h"] == 0 + assert result.loc[1, "sender_txn_count_24h"] == 0 + assert result.loc[2, "sender_txn_count_24h"] == 2 + assert result.loc[2, "pair_transaction_count"] == 1 + assert result.loc[2, "sender_out_degree"] == 2 + + +def test_chronological_split_boundaries(): + frame = pd.DataFrame( + { + "timestamp": pd.date_range("2026-01-01", periods=10), + "value": range(10), + } + ) + + train, validation, test = chronological_split( + frame, + train_fraction=0.6, + validation_fraction=0.2, + ) + + assert train["value"].tolist() == list(range(6)) + assert validation["value"].tolist() == [6, 7] + assert test["value"].tolist() == [8, 9] + assert train["timestamp"].max() < validation["timestamp"].min() + assert validation["timestamp"].max() < test["timestamp"].min() + + +def test_amount_history_is_currency_aware(): + frame = pd.DataFrame( + { + "timestamp": pd.to_datetime( + ["2026-01-01 12:00:00", "2026-01-01 12:01:00"] + ), + "Sender_account": ["sender", "sender"], + "Receiver_account": ["receiver-a", "receiver-b"], + "Amount": [100.0, 200.0], + "Payment_currency": ["USD", "EUR"], + "Received_currency": ["USD", "EUR"], + } + ) + + result = add_behavioral_features(frame) + + assert result.loc[1, "sender_amount_sum_24h"] == 0 + assert result.loc[1, "sender_amount_mean_30d"] == 0 + assert result.loc[1, "sender_counterparty_hhi"] == 0