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.**
+[](https://www.python.org/)
+[](LICENSE)
+[](#)
+[](https://github.com/psf/black)
+[](#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
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
📊 System Overview
-
- Total Transactions
- 0
-
-
- Processing Rate
- 0/min
-
-
- Uptime
- 0s
-
-
- Alerts Generated
- 0
-
-
-
-
-
-
⚠️ Risk Distribution
-
-
-
-
-
-
🤖 ML Model Info
-
- Model Name
- -
-
-
- Threshold
- -
-
-
- Features
- -
-
-
- Last Updated
- -
-
-
-
-
-
-
📈 Transaction Trend
-
-
-
-
-
-
-
-
Recent Alerts
-
- Show:
-
- 5 alerts
- 10 alerts
- 20 alerts
- 50 alerts
-
-
-
-
-
-
-
-
- 🔄 Refresh Data
-
-
-
-
-
\ 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