Instructions to use Ranjit0034/finance-entity-extractor with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use Ranjit0034/finance-entity-extractor with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("text-generation", model="Ranjit0034/finance-entity-extractor", trust_remote_code=True)# Load model directly from transformers import AutoTokenizer, AutoModelForCausalLM tokenizer = AutoTokenizer.from_pretrained("Ranjit0034/finance-entity-extractor", trust_remote_code=True) model = AutoModelForCausalLM.from_pretrained("Ranjit0034/finance-entity-extractor", trust_remote_code=True, device_map="auto") - Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- vLLM
How to use Ranjit0034/finance-entity-extractor with vLLM:
Install from pip and serve model
# Install vLLM from pip: pip install vllm # Start the vLLM server: vllm serve "Ranjit0034/finance-entity-extractor" # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:8000/v1/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "Ranjit0034/finance-entity-extractor", "prompt": "Once upon a time,", "max_tokens": 512, "temperature": 0.5 }'Use Docker
docker model run hf.co/Ranjit0034/finance-entity-extractor
- SGLang
How to use Ranjit0034/finance-entity-extractor with SGLang:
Install from pip and serve model
# Install SGLang from pip: pip install sglang # Start the SGLang server: python3 -m sglang.launch_server \ --model-path "Ranjit0034/finance-entity-extractor" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "Ranjit0034/finance-entity-extractor", "prompt": "Once upon a time,", "max_tokens": 512, "temperature": 0.5 }'Use Docker images
docker run --gpus all \ --shm-size 32g \ -p 30000:30000 \ -v ~/.cache/huggingface:/root/.cache/huggingface \ --env "HF_TOKEN=<secret>" \ --ipc=host \ lmsysorg/sglang:latest \ python3 -m sglang.launch_server \ --model-path "Ranjit0034/finance-entity-extractor" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "Ranjit0034/finance-entity-extractor", "prompt": "Once upon a time,", "max_tokens": 512, "temperature": 0.5 }' - Docker Model Runner
How to use Ranjit0034/finance-entity-extractor with Docker Model Runner:
docker model run hf.co/Ranjit0034/finance-entity-extractor
feat: Add comprehensive data pipeline and fine-tuning
Browse filesData Pipeline (scripts/data_pipeline/):
- step1_unify.py: Unifies MBOX, JSON, CSV, XML sources
- step2_filter.py: Removes OTPs, spam, marketing
- step3_baseline.py: Tests regex extractor accuracy
- step4_label.py: Creates labeled training data
Synthetic Data Generator:
- generate_synthetic.py: Production-grade grammar-based generator
- 100K+ realistic Indian bank transactions
- All major banks (HDFC, ICICI, SBI, Axis, Kotak, etc.)
- Brokerages (Zerodha, Groww, Upstox, etc.)
- E-commerce, food, travel, utilities, entertainment
- generate_advanced.py: Advanced features
- Markov Chain for realistic message flow
- Real data calibration
- Multilingual (Hindi, Tamil, Telugu, Bengali, Kannada)
- Data augmentation and edge case oversampling
Fine-tuning Pipeline (scripts/finetune.py):
- Supports MLX (Apple Silicon) and PyTorch
- LoRA fine-tuning with automatic data prep
- Model fusion and evaluation
Training Results:
- 152K training records
- Val loss: 2.42 -> 0.46 (81% reduction)
- 100% JSON parsing on test cases
- Multilingual support working
- scripts/data_pipeline/README.md +128 -0
- scripts/data_pipeline/generate_advanced.py +1149 -0
- scripts/data_pipeline/generate_synthetic.py +1726 -0
- scripts/data_pipeline/step1_unify.py +334 -0
- scripts/data_pipeline/step2_filter.py +295 -0
- scripts/data_pipeline/step3_baseline.py +260 -0
- scripts/data_pipeline/step4_label.py +302 -0
- scripts/finetune.py +578 -0
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Data Pipeline
|
| 2 |
+
|
| 3 |
+
3-step pipeline to prepare training data for FinEE model fine-tuning.
|
| 4 |
+
|
| 5 |
+
## Overview
|
| 6 |
+
|
| 7 |
+
```
|
| 8 |
+
Raw Data (XML/JSON/CSV/MBOX)
|
| 9 |
+
│
|
| 10 |
+
▼
|
| 11 |
+
┌────────────────────────┐
|
| 12 |
+
│ Step 1: Unify │ → step1_unified.csv
|
| 13 |
+
│ Standardize formats │ (all records, single schema)
|
| 14 |
+
└────────────────────────┘
|
| 15 |
+
│
|
| 16 |
+
▼
|
| 17 |
+
┌────────────────────────┐
|
| 18 |
+
│ Step 2: Filter │ → step2_training_ready.csv
|
| 19 |
+
│ Remove garbage │ (clean transactions only)
|
| 20 |
+
└────────────────────────┘
|
| 21 |
+
│
|
| 22 |
+
▼
|
| 23 |
+
┌────────────────────────┐
|
| 24 |
+
│ Step 3: Baseline │ → step3_baseline_results.csv
|
| 25 |
+
│ Test current accuracy │ (extracted fields + metrics)
|
| 26 |
+
└────────────────────────┘
|
| 27 |
+
```
|
| 28 |
+
|
| 29 |
+
## Quick Start
|
| 30 |
+
|
| 31 |
+
### 1. Copy your data to the workspace
|
| 32 |
+
|
| 33 |
+
```bash
|
| 34 |
+
# Example: Google Takeout export
|
| 35 |
+
cp -r ~/Downloads/Takeout /Users/ranjit/llm-mail-trainer/data/raw/
|
| 36 |
+
|
| 37 |
+
# Or SMS backup
|
| 38 |
+
cp sms_backup.xml /Users/ranjit/llm-mail-trainer/data/raw/
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
### 2. Run the pipeline
|
| 42 |
+
|
| 43 |
+
```bash
|
| 44 |
+
cd /Users/ranjit/llm-mail-trainer
|
| 45 |
+
|
| 46 |
+
# Step 1: Unify all data formats
|
| 47 |
+
python scripts/data_pipeline/step1_unify.py --input data/raw/ --output data/pipeline/step1_unified.csv
|
| 48 |
+
|
| 49 |
+
# Step 2: Filter garbage (OTPs, spam, etc.)
|
| 50 |
+
python scripts/data_pipeline/step2_filter.py --input data/pipeline/step1_unified.csv
|
| 51 |
+
|
| 52 |
+
# Step 3: Test baseline accuracy
|
| 53 |
+
python scripts/data_pipeline/step3_baseline.py
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
## Supported Input Formats
|
| 57 |
+
|
| 58 |
+
| Format | Source | Example |
|
| 59 |
+
|--------|--------|---------|
|
| 60 |
+
| `.mbox` | Gmail export | Mail.mbox |
|
| 61 |
+
| `.json` | Google Takeout | transactions.json |
|
| 62 |
+
| `.csv` | Bank exports | statements.csv |
|
| 63 |
+
| `.xml` | SMS Backup apps | sms_backup.xml |
|
| 64 |
+
|
| 65 |
+
## Output Schema
|
| 66 |
+
|
| 67 |
+
All data is standardized to:
|
| 68 |
+
|
| 69 |
+
| Column | Type | Description |
|
| 70 |
+
|--------|------|-------------|
|
| 71 |
+
| `timestamp` | string | When message was received |
|
| 72 |
+
| `sender` | string | Bank/sender name |
|
| 73 |
+
| `body` | string | Message content |
|
| 74 |
+
| `source` | string | Original file source |
|
| 75 |
+
|
| 76 |
+
## Step 2 Filters
|
| 77 |
+
|
| 78 |
+
### Messages REMOVED (Garbage):
|
| 79 |
+
- OTPs / Verification codes
|
| 80 |
+
- Login alerts
|
| 81 |
+
- Marketing spam (% off, offers)
|
| 82 |
+
- Bill reminders (not transactions)
|
| 83 |
+
- Account statements
|
| 84 |
+
- Delivery notifications
|
| 85 |
+
|
| 86 |
+
### Messages KEPT (Transactions):
|
| 87 |
+
- Debit/Credit notifications
|
| 88 |
+
- UPI payments
|
| 89 |
+
- NEFT/IMPS transfers
|
| 90 |
+
- Amount + account references
|
| 91 |
+
|
| 92 |
+
## Step 3 Metrics
|
| 93 |
+
|
| 94 |
+
The baseline test measures:
|
| 95 |
+
|
| 96 |
+
| Metric | Description |
|
| 97 |
+
|--------|-------------|
|
| 98 |
+
| Extraction Success Rate | % of messages where amount + type extracted |
|
| 99 |
+
| Field Coverage | % for each field (amount, merchant, etc.) |
|
| 100 |
+
| Confidence Distribution | LOW / MEDIUM / HIGH breakdown |
|
| 101 |
+
| Top Merchants | Most common extracted merchants |
|
| 102 |
+
| Processing Speed | Messages per second |
|
| 103 |
+
|
| 104 |
+
## Directory Structure
|
| 105 |
+
|
| 106 |
+
```
|
| 107 |
+
data/
|
| 108 |
+
├── raw/ # Put your raw data here
|
| 109 |
+
│ └── Takeout/
|
| 110 |
+
│ └── ...
|
| 111 |
+
├── pipeline/ # Pipeline outputs
|
| 112 |
+
│ ├── step1_unified.csv
|
| 113 |
+
│ ├── step2_training_ready.csv
|
| 114 |
+
│ ├── step2_garbage.csv (optional)
|
| 115 |
+
│ ├── step2_uncertain.csv (optional)
|
| 116 |
+
│ ├── step3_baseline_results.csv
|
| 117 |
+
│ └── step3_baseline_analysis.json
|
| 118 |
+
└── training/ # Final training data (after labeling)
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
## Next Steps
|
| 122 |
+
|
| 123 |
+
After Step 3:
|
| 124 |
+
|
| 125 |
+
1. Review low-confidence extractions
|
| 126 |
+
2. Add ground truth labels for training
|
| 127 |
+
3. Identify patterns that need new regex
|
| 128 |
+
4. Fine-tune the LLM on labeled data
|
|
@@ -0,0 +1,1149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Advanced Synthetic Data Generator v4.0
|
| 4 |
+
======================================
|
| 5 |
+
|
| 6 |
+
New Features:
|
| 7 |
+
1. Markov Chain for realistic message flow
|
| 8 |
+
2. Real data calibration from actual samples
|
| 9 |
+
3. Multilingual support (Hindi, Tamil, Telugu, Bengali, Kannada)
|
| 10 |
+
4. PDF/Image generation for document training
|
| 11 |
+
5. Statistical augmentation for rare edge cases
|
| 12 |
+
|
| 13 |
+
Author: Ranjit Behera
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
import json
|
| 18 |
+
import random
|
| 19 |
+
import hashlib
|
| 20 |
+
import argparse
|
| 21 |
+
import math
|
| 22 |
+
import pickle
|
| 23 |
+
from abc import ABC, abstractmethod
|
| 24 |
+
from dataclasses import dataclass, field, asdict
|
| 25 |
+
from datetime import datetime, timedelta, date
|
| 26 |
+
from decimal import Decimal, ROUND_HALF_UP
|
| 27 |
+
from enum import Enum, auto
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
from typing import List, Dict, Optional, Tuple, Set, Any, Iterator
|
| 30 |
+
from collections import defaultdict, Counter
|
| 31 |
+
import re
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# ============================================================================
|
| 35 |
+
# MARKOV CHAIN MESSAGE GENERATOR
|
| 36 |
+
# ============================================================================
|
| 37 |
+
|
| 38 |
+
class MarkovChain:
|
| 39 |
+
"""
|
| 40 |
+
Markov Chain for realistic message structure.
|
| 41 |
+
|
| 42 |
+
Learns transition probabilities from real data to generate
|
| 43 |
+
messages that follow actual patterns.
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
def __init__(self, order: int = 2):
|
| 47 |
+
"""
|
| 48 |
+
Args:
|
| 49 |
+
order: n-gram order (1 = unigram, 2 = bigram, etc.)
|
| 50 |
+
"""
|
| 51 |
+
self.order = order
|
| 52 |
+
self.transitions: Dict[Tuple, Counter] = defaultdict(Counter)
|
| 53 |
+
self.start_states: Counter = Counter()
|
| 54 |
+
|
| 55 |
+
def train(self, messages: List[str]):
|
| 56 |
+
"""Train on real messages."""
|
| 57 |
+
for message in messages:
|
| 58 |
+
tokens = self._tokenize(message)
|
| 59 |
+
if len(tokens) <= self.order:
|
| 60 |
+
continue
|
| 61 |
+
|
| 62 |
+
# Start state
|
| 63 |
+
start = tuple(tokens[:self.order])
|
| 64 |
+
self.start_states[start] += 1
|
| 65 |
+
|
| 66 |
+
# Build transitions
|
| 67 |
+
for i in range(len(tokens) - self.order):
|
| 68 |
+
state = tuple(tokens[i:i + self.order])
|
| 69 |
+
next_token = tokens[i + self.order]
|
| 70 |
+
self.transitions[state][next_token] += 1
|
| 71 |
+
|
| 72 |
+
def _tokenize(self, text: str) -> List[str]:
|
| 73 |
+
"""Tokenize preserving structure."""
|
| 74 |
+
# Split on whitespace but keep special tokens
|
| 75 |
+
tokens = []
|
| 76 |
+
for word in text.split():
|
| 77 |
+
# Keep amounts as single tokens
|
| 78 |
+
if re.match(r'^Rs\.?\d', word) or re.match(r'^₹\d', word):
|
| 79 |
+
tokens.append(word)
|
| 80 |
+
# Keep VPAs together
|
| 81 |
+
elif '@' in word:
|
| 82 |
+
tokens.append(word)
|
| 83 |
+
else:
|
| 84 |
+
tokens.append(word)
|
| 85 |
+
return tokens
|
| 86 |
+
|
| 87 |
+
def generate(self, rng: random.Random, max_length: int = 50) -> str:
|
| 88 |
+
"""Generate a message using learned transitions."""
|
| 89 |
+
if not self.start_states:
|
| 90 |
+
return ""
|
| 91 |
+
|
| 92 |
+
# Sample start state
|
| 93 |
+
states = list(self.start_states.keys())
|
| 94 |
+
weights = [self.start_states[s] for s in states]
|
| 95 |
+
current = list(rng.choices(states, weights=weights)[0])
|
| 96 |
+
|
| 97 |
+
result = list(current)
|
| 98 |
+
|
| 99 |
+
for _ in range(max_length - len(current)):
|
| 100 |
+
state = tuple(current[-self.order:])
|
| 101 |
+
|
| 102 |
+
if state not in self.transitions:
|
| 103 |
+
break
|
| 104 |
+
|
| 105 |
+
# Sample next token
|
| 106 |
+
next_tokens = list(self.transitions[state].keys())
|
| 107 |
+
weights = [self.transitions[state][t] for t in next_tokens]
|
| 108 |
+
next_token = rng.choices(next_tokens, weights=weights)[0]
|
| 109 |
+
|
| 110 |
+
result.append(next_token)
|
| 111 |
+
current.append(next_token)
|
| 112 |
+
|
| 113 |
+
return ' '.join(result)
|
| 114 |
+
|
| 115 |
+
def save(self, path: Path):
|
| 116 |
+
"""Save trained model."""
|
| 117 |
+
with open(path, 'wb') as f:
|
| 118 |
+
pickle.dump({
|
| 119 |
+
'order': self.order,
|
| 120 |
+
'transitions': dict(self.transitions),
|
| 121 |
+
'start_states': dict(self.start_states),
|
| 122 |
+
}, f)
|
| 123 |
+
|
| 124 |
+
@classmethod
|
| 125 |
+
def load(cls, path: Path) -> 'MarkovChain':
|
| 126 |
+
"""Load trained model."""
|
| 127 |
+
with open(path, 'rb') as f:
|
| 128 |
+
data = pickle.load(f)
|
| 129 |
+
|
| 130 |
+
chain = cls(order=data['order'])
|
| 131 |
+
chain.transitions = defaultdict(Counter, {
|
| 132 |
+
k: Counter(v) for k, v in data['transitions'].items()
|
| 133 |
+
})
|
| 134 |
+
chain.start_states = Counter(data['start_states'])
|
| 135 |
+
return chain
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
class HybridGenerator:
|
| 139 |
+
"""
|
| 140 |
+
Combines Markov Chain with template-based generation.
|
| 141 |
+
|
| 142 |
+
Uses Markov for structure, templates for entity placement.
|
| 143 |
+
"""
|
| 144 |
+
|
| 145 |
+
def __init__(self, markov: MarkovChain):
|
| 146 |
+
self.markov = markov
|
| 147 |
+
self.entity_patterns = {
|
| 148 |
+
'AMOUNT': r'Rs\.?\s*[\d,]+(?:\.\d{2})?',
|
| 149 |
+
'ACCOUNT': r'XX\d{4}',
|
| 150 |
+
'DATE': r'\d{1,2}[-/]\d{1,2}[-/]\d{2,4}',
|
| 151 |
+
'REF': r'\d{12,16}',
|
| 152 |
+
'VPA': r'[a-z0-9]+@[a-z]+',
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
def generate(self, entities: Dict[str, str], rng: random.Random) -> str:
|
| 156 |
+
"""
|
| 157 |
+
Generate message with specific entities.
|
| 158 |
+
|
| 159 |
+
1. Generate base structure from Markov
|
| 160 |
+
2. Replace placeholders with actual entities
|
| 161 |
+
"""
|
| 162 |
+
base = self.markov.generate(rng)
|
| 163 |
+
|
| 164 |
+
# Replace detected patterns with actual entities
|
| 165 |
+
for entity_type, pattern in self.entity_patterns.items():
|
| 166 |
+
if entity_type in entities:
|
| 167 |
+
base = re.sub(pattern, entities[entity_type], base, count=1)
|
| 168 |
+
|
| 169 |
+
return base
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
# ============================================================================
|
| 173 |
+
# REAL DATA CALIBRATION
|
| 174 |
+
# ============================================================================
|
| 175 |
+
|
| 176 |
+
@dataclass
|
| 177 |
+
class DistributionFit:
|
| 178 |
+
"""Fitted statistical distribution."""
|
| 179 |
+
name: str
|
| 180 |
+
params: Dict[str, float]
|
| 181 |
+
|
| 182 |
+
def sample(self, rng: random.Random) -> float:
|
| 183 |
+
"""Sample from fitted distribution."""
|
| 184 |
+
if self.name == 'normal':
|
| 185 |
+
value = rng.gauss(self.params['mean'], self.params['std'])
|
| 186 |
+
return max(self.params.get('min', 0),
|
| 187 |
+
min(self.params.get('max', float('inf')), value))
|
| 188 |
+
|
| 189 |
+
elif self.name == 'lognormal':
|
| 190 |
+
# Log-normal is better for amounts
|
| 191 |
+
log_value = rng.gauss(self.params['mu'], self.params['sigma'])
|
| 192 |
+
return math.exp(log_value)
|
| 193 |
+
|
| 194 |
+
elif self.name == 'exponential':
|
| 195 |
+
return rng.expovariate(1 / self.params['lambda'])
|
| 196 |
+
|
| 197 |
+
elif self.name == 'uniform':
|
| 198 |
+
return rng.uniform(self.params['min'], self.params['max'])
|
| 199 |
+
|
| 200 |
+
elif self.name == 'categorical':
|
| 201 |
+
items = list(self.params['categories'].keys())
|
| 202 |
+
weights = list(self.params['categories'].values())
|
| 203 |
+
return rng.choices(items, weights=weights)[0]
|
| 204 |
+
|
| 205 |
+
return 0
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
class DataCalibrator:
|
| 209 |
+
"""
|
| 210 |
+
Calibrate synthetic distributions to match real data.
|
| 211 |
+
|
| 212 |
+
Fits statistical distributions to actual transaction data.
|
| 213 |
+
"""
|
| 214 |
+
|
| 215 |
+
def __init__(self):
|
| 216 |
+
self.amount_dist: Optional[DistributionFit] = None
|
| 217 |
+
self.category_dist: Optional[DistributionFit] = None
|
| 218 |
+
self.bank_dist: Optional[DistributionFit] = None
|
| 219 |
+
self.hour_dist: Optional[DistributionFit] = None
|
| 220 |
+
|
| 221 |
+
# Amount by category
|
| 222 |
+
self.amount_by_category: Dict[str, DistributionFit] = {}
|
| 223 |
+
|
| 224 |
+
def fit_from_data(self, data: List[Dict]):
|
| 225 |
+
"""Fit distributions from real data."""
|
| 226 |
+
if not data:
|
| 227 |
+
return
|
| 228 |
+
|
| 229 |
+
# Extract amounts
|
| 230 |
+
amounts = [r['amount'] for r in data if r.get('amount')]
|
| 231 |
+
if amounts:
|
| 232 |
+
mean_amt = sum(amounts) / len(amounts)
|
| 233 |
+
var_amt = sum((x - mean_amt) ** 2 for x in amounts) / len(amounts)
|
| 234 |
+
std_amt = math.sqrt(var_amt)
|
| 235 |
+
|
| 236 |
+
# Use log-normal for amounts (always positive, right-skewed)
|
| 237 |
+
log_amounts = [math.log(max(1, a)) for a in amounts]
|
| 238 |
+
mu = sum(log_amounts) / len(log_amounts)
|
| 239 |
+
sigma_sq = sum((x - mu) ** 2 for x in log_amounts) / len(log_amounts)
|
| 240 |
+
|
| 241 |
+
self.amount_dist = DistributionFit(
|
| 242 |
+
name='lognormal',
|
| 243 |
+
params={'mu': mu, 'sigma': math.sqrt(sigma_sq)}
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
# Fit categories
|
| 247 |
+
categories = Counter(r.get('category') for r in data if r.get('category'))
|
| 248 |
+
if categories:
|
| 249 |
+
total = sum(categories.values())
|
| 250 |
+
self.category_dist = DistributionFit(
|
| 251 |
+
name='categorical',
|
| 252 |
+
params={'categories': {k: v/total for k, v in categories.items()}}
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
+
# Fit banks
|
| 256 |
+
banks = Counter(r.get('bank') for r in data if r.get('bank'))
|
| 257 |
+
if banks:
|
| 258 |
+
total = sum(banks.values())
|
| 259 |
+
self.bank_dist = DistributionFit(
|
| 260 |
+
name='categorical',
|
| 261 |
+
params={'categories': {k: v/total for k, v in banks.items()}}
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
# Fit amounts by category
|
| 265 |
+
by_category = defaultdict(list)
|
| 266 |
+
for r in data:
|
| 267 |
+
if r.get('amount') and r.get('category'):
|
| 268 |
+
by_category[r['category']].append(r['amount'])
|
| 269 |
+
|
| 270 |
+
for cat, amounts in by_category.items():
|
| 271 |
+
if len(amounts) >= 10:
|
| 272 |
+
log_amounts = [math.log(max(1, a)) for a in amounts]
|
| 273 |
+
mu = sum(log_amounts) / len(log_amounts)
|
| 274 |
+
sigma_sq = sum((x - mu) ** 2 for x in log_amounts) / len(log_amounts)
|
| 275 |
+
|
| 276 |
+
self.amount_by_category[cat] = DistributionFit(
|
| 277 |
+
name='lognormal',
|
| 278 |
+
params={'mu': mu, 'sigma': max(0.1, math.sqrt(sigma_sq))}
|
| 279 |
+
)
|
| 280 |
+
|
| 281 |
+
def sample_amount(self, category: Optional[str], rng: random.Random) -> float:
|
| 282 |
+
"""Sample amount, optionally by category."""
|
| 283 |
+
if category and category in self.amount_by_category:
|
| 284 |
+
return self.amount_by_category[category].sample(rng)
|
| 285 |
+
elif self.amount_dist:
|
| 286 |
+
return self.amount_dist.sample(rng)
|
| 287 |
+
else:
|
| 288 |
+
return rng.uniform(100, 10000)
|
| 289 |
+
|
| 290 |
+
def sample_category(self, rng: random.Random) -> str:
|
| 291 |
+
"""Sample category from fitted distribution."""
|
| 292 |
+
if self.category_dist:
|
| 293 |
+
return self.category_dist.sample(rng)
|
| 294 |
+
return 'shopping'
|
| 295 |
+
|
| 296 |
+
def sample_bank(self, rng: random.Random) -> str:
|
| 297 |
+
"""Sample bank from fitted distribution."""
|
| 298 |
+
if self.bank_dist:
|
| 299 |
+
return self.bank_dist.sample(rng)
|
| 300 |
+
return 'HDFC'
|
| 301 |
+
|
| 302 |
+
def save(self, path: Path):
|
| 303 |
+
"""Save calibration."""
|
| 304 |
+
with open(path, 'wb') as f:
|
| 305 |
+
pickle.dump({
|
| 306 |
+
'amount_dist': self.amount_dist,
|
| 307 |
+
'category_dist': self.category_dist,
|
| 308 |
+
'bank_dist': self.bank_dist,
|
| 309 |
+
'amount_by_category': self.amount_by_category,
|
| 310 |
+
}, f)
|
| 311 |
+
|
| 312 |
+
@classmethod
|
| 313 |
+
def load(cls, path: Path) -> 'DataCalibrator':
|
| 314 |
+
"""Load calibration."""
|
| 315 |
+
with open(path, 'rb') as f:
|
| 316 |
+
data = pickle.load(f)
|
| 317 |
+
|
| 318 |
+
calibrator = cls()
|
| 319 |
+
calibrator.amount_dist = data.get('amount_dist')
|
| 320 |
+
calibrator.category_dist = data.get('category_dist')
|
| 321 |
+
calibrator.bank_dist = data.get('bank_dist')
|
| 322 |
+
calibrator.amount_by_category = data.get('amount_by_category', {})
|
| 323 |
+
return calibrator
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
# ============================================================================
|
| 327 |
+
# MULTILINGUAL SUPPORT
|
| 328 |
+
# ============================================================================
|
| 329 |
+
|
| 330 |
+
class Language(Enum):
|
| 331 |
+
ENGLISH = "en"
|
| 332 |
+
HINDI = "hi"
|
| 333 |
+
TAMIL = "ta"
|
| 334 |
+
TELUGU = "te"
|
| 335 |
+
BENGALI = "bn"
|
| 336 |
+
KANNADA = "kn"
|
| 337 |
+
MARATHI = "mr"
|
| 338 |
+
GUJARATI = "gu"
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
@dataclass
|
| 342 |
+
class MultilingualTemplate:
|
| 343 |
+
"""Template with translations."""
|
| 344 |
+
english: str
|
| 345 |
+
translations: Dict[Language, str]
|
| 346 |
+
|
| 347 |
+
def get(self, lang: Language) -> str:
|
| 348 |
+
"""Get template in specified language."""
|
| 349 |
+
if lang == Language.ENGLISH:
|
| 350 |
+
return self.english
|
| 351 |
+
return self.translations.get(lang, self.english)
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
class MultilingualBank:
|
| 355 |
+
"""
|
| 356 |
+
Bank SMS templates in multiple Indian languages.
|
| 357 |
+
|
| 358 |
+
Based on actual bank SMS formats in different languages.
|
| 359 |
+
"""
|
| 360 |
+
|
| 361 |
+
TEMPLATES = {
|
| 362 |
+
'debit': MultilingualTemplate(
|
| 363 |
+
english="{bank}: Rs.{amount} debited from A/c XX{account} on {date}. {vpa}. Ref: {ref}",
|
| 364 |
+
translations={
|
| 365 |
+
Language.HINDI: "{bank}: आपके खाते XX{account} से Rs.{amount} डेबिट हुआ। दिनांक {date}। {vpa}। संदर्भ: {ref}",
|
| 366 |
+
Language.TAMIL: "{bank}: உங்கள் கணக்கு XX{account} இல் இருந்து Rs.{amount} டெபிட் செய்யப்பட்டது. தேதி {date}. Ref: {ref}",
|
| 367 |
+
Language.TELUGU: "{bank}: మీ ఖాతా XX{account} నుండి Rs.{amount} డెబిట్ చేయబడింది. తేదీ {date}. {vpa}. Ref: {ref}",
|
| 368 |
+
Language.BENGALI: "{bank}: আপনার অ্যাকাউন্ট XX{account} থেকে Rs.{amount} ডেবিট হয়েছে। তারিখ {date}। Ref: {ref}",
|
| 369 |
+
Language.KANNADA: "{bank}: ನಿಮ್ಮ ಖಾತೆ XX{account} ನಿಂದ Rs.{amount} ಡೆಬಿಟ್ ಆಗಿದೆ. ದಿನಾಂಕ {date}. Ref: {ref}",
|
| 370 |
+
Language.MARATHI: "{bank}: तुमच्या खात्यातून XX{account} Rs.{amount} डेबिट झाले. तारीख {date}. Ref: {ref}",
|
| 371 |
+
Language.GUJARATI: "{bank}: તમારા ખાતા XX{account} માંથી Rs.{amount} ડેબિટ થયું. તારીખ {date}. Ref: {ref}",
|
| 372 |
+
}
|
| 373 |
+
),
|
| 374 |
+
'credit': MultilingualTemplate(
|
| 375 |
+
english="{bank}: Rs.{amount} credited to A/c XX{account} on {date}. {sender}. Ref: {ref}",
|
| 376 |
+
translations={
|
| 377 |
+
Language.HINDI: "{bank}: आपके खाते XX{account} में Rs.{amount} क्रेडिट हुआ। दिनांक {date}। {sender}। संदर्भ: {ref}",
|
| 378 |
+
Language.TAMIL: "{bank}: உங்கள் கணக்கு XX{account} க்கு Rs.{amount} கிரெடிட் செய்யப்பட்டது. தேதி {date}. Ref: {ref}",
|
| 379 |
+
Language.TELUGU: "{bank}: మీ ఖాతా XX{account} కు Rs.{amount} క్రెడిట్ చేయబడింది. తేదీ {date}. Ref: {ref}",
|
| 380 |
+
Language.BENGALI: "{bank}: আপনার অ্যাকাউন্ট XX{account} এ Rs.{amount} ক্রেডিট হয়েছে। তারিখ {date}। Ref: {ref}",
|
| 381 |
+
Language.KANNADA: "{bank}: ನಿಮ್ಮ ಖಾತೆ XX{account} ಗೆ Rs.{amount} ಕ್ರೆಡಿಟ್ ಆಗಿದೆ. ದಿನಾಂಕ {date}. Ref: {ref}",
|
| 382 |
+
Language.MARATHI: "{bank}: तुमच्या खात्यात XX{account} Rs.{amount} क्रेडिट झाले. तारीख {date}. Ref: {ref}",
|
| 383 |
+
Language.GUJARATI: "{bank}: તમારા ખાતા XX{account} માં Rs.{amount} ક્રેડિટ થયું. તારીખ {date}. Ref: {ref}",
|
| 384 |
+
}
|
| 385 |
+
),
|
| 386 |
+
'otp': MultilingualTemplate(
|
| 387 |
+
english="{bank}: Your OTP is {otp}. Valid for 10 mins. Do not share with anyone.",
|
| 388 |
+
translations={
|
| 389 |
+
Language.HINDI: "{bank}: आपका OTP {otp} है। 10 मिनट के लिए मान्य। किसी के साथ साझा न करें।",
|
| 390 |
+
Language.TAMIL: "{bank}: உங்கள் OTP {otp}. 10 நிமிடங்களுக்கு செல்லுபடியாகும். யாருடனும் பகிர வேண்டாம்.",
|
| 391 |
+
Language.TELUGU: "{bank}: మీ OTP {otp}. 10 నిమిషాలు చెల్లుబాటు. ఎవరితోనూ షేర్ చేయకండి.",
|
| 392 |
+
Language.BENGALI: "{bank}: আপনার OTP হল {otp}। 10 মিনিটের জন্য বৈধ। কারো সাথে শেয়ার করবেন না।",
|
| 393 |
+
}
|
| 394 |
+
),
|
| 395 |
+
'balance': MultilingualTemplate(
|
| 396 |
+
english="{bank}: Your A/c XX{account} balance is Rs.{balance}.",
|
| 397 |
+
translations={
|
| 398 |
+
Language.HINDI: "{bank}: आपके खाते XX{account} में शेष राशि Rs.{balance} है।",
|
| 399 |
+
Language.TAMIL: "{bank}: உங்கள் கணக்கு XX{account} இருப்பு Rs.{balance}.",
|
| 400 |
+
Language.TELUGU: "{bank}: మీ ఖాతా XX{account} బ్యాలెన్స్ Rs.{balance}.",
|
| 401 |
+
Language.BENGALI: "{bank}: আপনার অ্যাকাউন্ট XX{account} ব্যালেন্স Rs.{balance}।",
|
| 402 |
+
}
|
| 403 |
+
),
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
# Numbers in Indian languages
|
| 407 |
+
NUMBERS = {
|
| 408 |
+
Language.HINDI: {
|
| 409 |
+
'0': '०', '1': '१', '2': '२', '3': '३', '4': '४',
|
| 410 |
+
'5': '५', '6': '६', '7': '७', '8': '८', '9': '९',
|
| 411 |
+
},
|
| 412 |
+
Language.BENGALI: {
|
| 413 |
+
'0': '০', '1': '১', '2': '২', '3': '৩', '4': '৪',
|
| 414 |
+
'5': '৫', '6': '৬', '7': '৭', '8': '৮', '9': '৯',
|
| 415 |
+
},
|
| 416 |
+
Language.TAMIL: {
|
| 417 |
+
'0': '௦', '1': '௧', '2': '௨', '3': '௩', '4': '௪',
|
| 418 |
+
'5': '௫', '6': '௬', '7': '௭', '8': '௮', '9': '௯',
|
| 419 |
+
},
|
| 420 |
+
Language.KANNADA: {
|
| 421 |
+
'0': '೦', '1': '೧', '2': '೨', '3': '೩', '4': '೪',
|
| 422 |
+
'5': '೫', '6': '೬', '7': '೭', '8': '೮', '9': '೯',
|
| 423 |
+
},
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
@classmethod
|
| 427 |
+
def generate(
|
| 428 |
+
cls,
|
| 429 |
+
template_type: str,
|
| 430 |
+
language: Language,
|
| 431 |
+
params: Dict[str, str],
|
| 432 |
+
use_native_numbers: bool = False,
|
| 433 |
+
rng: random.Random = None
|
| 434 |
+
) -> str:
|
| 435 |
+
"""Generate message in specified language."""
|
| 436 |
+
template = cls.TEMPLATES.get(template_type)
|
| 437 |
+
if not template:
|
| 438 |
+
return ""
|
| 439 |
+
|
| 440 |
+
text = template.get(language)
|
| 441 |
+
message = text.format(**params)
|
| 442 |
+
|
| 443 |
+
# Optionally convert numbers to native script
|
| 444 |
+
if use_native_numbers and language in cls.NUMBERS:
|
| 445 |
+
for eng, native in cls.NUMBERS[language].items():
|
| 446 |
+
message = message.replace(eng, native)
|
| 447 |
+
|
| 448 |
+
return message
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
class MultilingualNameGenerator:
|
| 452 |
+
"""Generate names in multiple Indian languages."""
|
| 453 |
+
|
| 454 |
+
NAMES = {
|
| 455 |
+
Language.HINDI: [
|
| 456 |
+
"राहुल शर्मा", "प्रिया सिंह", "अमित कुमार", "नेहा गुप्ता",
|
| 457 |
+
"विजय पटेल", "दीपक वर्मा", "अंजलि मेहता", "राजेश नायर",
|
| 458 |
+
"सुनीता अय्यर", "अरुण जोशी", "पूजा रेड्डी", "संजय मिश्रा",
|
| 459 |
+
],
|
| 460 |
+
Language.TAMIL: [
|
| 461 |
+
"முருகன் செல்வம்", "லக்ஷ்மி நாராயணன்", "கார்த்திக் சுப்பிரமணியம்",
|
| 462 |
+
"மீனா குமார்", "அருண் பிரகாஷ்", "சரிதா வேணுகோபால்",
|
| 463 |
+
],
|
| 464 |
+
Language.TELUGU: [
|
| 465 |
+
"రవి కుమార్", "లక్ష్మీ దేవి", "సురేష్ రెడ్డి", "వెంకట రావు",
|
| 466 |
+
"ప్రసాద్ నాయుడు", "కమల శర్మ", "రాజేష్ గుప్తా",
|
| 467 |
+
],
|
| 468 |
+
Language.BENGALI: [
|
| 469 |
+
"রাহুল ব্যানার্জী", "���্রিয়া দাস", "অমিত চক্রবর্তী",
|
| 470 |
+
"সুমিতা সেন", "রাজেশ মুখার্জী", "কবিতা বসু",
|
| 471 |
+
],
|
| 472 |
+
Language.KANNADA: [
|
| 473 |
+
"ರಾಜೇಶ್ ಗೌಡ", "ಲಕ್ಷ್ಮೀ ನಾರಾಯಣ", "ಸುರೇಶ್ ಕುಮಾರ್",
|
| 474 |
+
"ಮೀನಾ ಹೆಗ್ಡೆ", "ಪ್ರಕಾಶ್ ರಾವ್", "ನೇತ್ರಾ ಶೆಟ್ಟಿ",
|
| 475 |
+
],
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
@classmethod
|
| 479 |
+
def get_name(cls, language: Language, rng: random.Random) -> str:
|
| 480 |
+
"""Get a random name in specified language."""
|
| 481 |
+
names = cls.NAMES.get(language, cls.NAMES[Language.HINDI])
|
| 482 |
+
return rng.choice(names)
|
| 483 |
+
|
| 484 |
+
|
| 485 |
+
# ============================================================================
|
| 486 |
+
# DATA AUGMENTATION
|
| 487 |
+
# ============================================================================
|
| 488 |
+
|
| 489 |
+
class DataAugmenter:
|
| 490 |
+
"""
|
| 491 |
+
Advanced data augmentation techniques.
|
| 492 |
+
|
| 493 |
+
Techniques:
|
| 494 |
+
1. Back-translation (via templates)
|
| 495 |
+
2. Synonym replacement
|
| 496 |
+
3. Random insertion/deletion
|
| 497 |
+
4. Noise injection
|
| 498 |
+
5. Entity swapping
|
| 499 |
+
"""
|
| 500 |
+
|
| 501 |
+
SYNONYMS = {
|
| 502 |
+
'debited': ['withdrawn', 'deducted', 'paid', 'transferred', 'sent'],
|
| 503 |
+
'credited': ['received', 'deposited', 'added', 'transferred'],
|
| 504 |
+
'transaction': ['payment', 'transfer', 'txn'],
|
| 505 |
+
'account': ['A/c', 'Acc', 'Acct', 'a/c'],
|
| 506 |
+
'reference': ['Ref', 'UTR', 'Txn ID'],
|
| 507 |
+
'available': ['Avl', 'remaining', 'left'],
|
| 508 |
+
'balance': ['Bal', 'amt'],
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
def __init__(self, seed: int = 42):
|
| 512 |
+
self.rng = random.Random(seed)
|
| 513 |
+
|
| 514 |
+
def augment(
|
| 515 |
+
self,
|
| 516 |
+
text: str,
|
| 517 |
+
ground_truth: Dict,
|
| 518 |
+
techniques: List[str] = None
|
| 519 |
+
) -> List[Tuple[str, Dict]]:
|
| 520 |
+
"""
|
| 521 |
+
Generate augmented versions of a sample.
|
| 522 |
+
|
| 523 |
+
Returns list of (augmented_text, ground_truth) tuples.
|
| 524 |
+
"""
|
| 525 |
+
if techniques is None:
|
| 526 |
+
techniques = ['synonym', 'noise', 'case']
|
| 527 |
+
|
| 528 |
+
augmented = []
|
| 529 |
+
|
| 530 |
+
if 'synonym' in techniques:
|
| 531 |
+
aug = self._synonym_replace(text)
|
| 532 |
+
augmented.append((aug, ground_truth))
|
| 533 |
+
|
| 534 |
+
if 'noise' in techniques:
|
| 535 |
+
aug = self._add_noise(text)
|
| 536 |
+
augmented.append((aug, ground_truth))
|
| 537 |
+
|
| 538 |
+
if 'case' in techniques:
|
| 539 |
+
aug = self._vary_case(text)
|
| 540 |
+
augmented.append((aug, ground_truth))
|
| 541 |
+
|
| 542 |
+
if 'truncate' in techniques:
|
| 543 |
+
aug = self._truncate(text)
|
| 544 |
+
augmented.append((aug, ground_truth))
|
| 545 |
+
|
| 546 |
+
if 'reorder' in techniques:
|
| 547 |
+
aug = self._reorder_phrases(text)
|
| 548 |
+
augmented.append((aug, ground_truth))
|
| 549 |
+
|
| 550 |
+
return augmented
|
| 551 |
+
|
| 552 |
+
def _synonym_replace(self, text: str) -> str:
|
| 553 |
+
"""Replace words with synonyms."""
|
| 554 |
+
words = text.split()
|
| 555 |
+
for i, word in enumerate(words):
|
| 556 |
+
word_lower = word.lower().strip('.,;:')
|
| 557 |
+
if word_lower in self.SYNONYMS and self.rng.random() < 0.3:
|
| 558 |
+
synonym = self.rng.choice(self.SYNONYMS[word_lower])
|
| 559 |
+
# Preserve case
|
| 560 |
+
if word[0].isupper():
|
| 561 |
+
synonym = synonym.capitalize()
|
| 562 |
+
words[i] = synonym
|
| 563 |
+
return ' '.join(words)
|
| 564 |
+
|
| 565 |
+
def _add_noise(self, text: str) -> str:
|
| 566 |
+
"""Add realistic noise."""
|
| 567 |
+
# Random spacing
|
| 568 |
+
if self.rng.random() < 0.3:
|
| 569 |
+
text = text.replace('. ', '.')
|
| 570 |
+
if self.rng.random() < 0.3:
|
| 571 |
+
text = text.replace(': ', ':')
|
| 572 |
+
|
| 573 |
+
# Abbreviations
|
| 574 |
+
text = text.replace('Reference', 'Ref' if self.rng.random() < 0.5 else 'Reference')
|
| 575 |
+
text = text.replace('Account', 'A/c' if self.rng.random() < 0.5 else 'Account')
|
| 576 |
+
|
| 577 |
+
return text
|
| 578 |
+
|
| 579 |
+
def _vary_case(self, text: str) -> str:
|
| 580 |
+
"""Vary text case."""
|
| 581 |
+
r = self.rng.random()
|
| 582 |
+
if r < 0.2:
|
| 583 |
+
return text.upper()
|
| 584 |
+
elif r < 0.4:
|
| 585 |
+
return text.lower()
|
| 586 |
+
return text
|
| 587 |
+
|
| 588 |
+
def _truncate(self, text: str) -> str:
|
| 589 |
+
"""Truncate to SMS limit."""
|
| 590 |
+
if len(text) > 160:
|
| 591 |
+
return text[:157] + '...'
|
| 592 |
+
return text
|
| 593 |
+
|
| 594 |
+
def _reorder_phrases(self, text: str) -> str:
|
| 595 |
+
"""Reorder independent phrases."""
|
| 596 |
+
# Split by common delimiters
|
| 597 |
+
phrases = re.split(r'[.;]', text)
|
| 598 |
+
phrases = [p.strip() for p in phrases if p.strip()]
|
| 599 |
+
|
| 600 |
+
if len(phrases) <= 2:
|
| 601 |
+
return text
|
| 602 |
+
|
| 603 |
+
# Keep first phrase, shuffle middle, keep last
|
| 604 |
+
first = phrases[0]
|
| 605 |
+
last = phrases[-1]
|
| 606 |
+
middle = phrases[1:-1]
|
| 607 |
+
self.rng.shuffle(middle)
|
| 608 |
+
|
| 609 |
+
return '. '.join([first] + middle + [last])
|
| 610 |
+
|
| 611 |
+
def augment_batch(
|
| 612 |
+
self,
|
| 613 |
+
data: List[Dict],
|
| 614 |
+
augmentation_factor: int = 3
|
| 615 |
+
) -> List[Dict]:
|
| 616 |
+
"""Augment entire dataset."""
|
| 617 |
+
augmented_data = []
|
| 618 |
+
|
| 619 |
+
for record in data:
|
| 620 |
+
text = record.get('text') or record.get('input', '')
|
| 621 |
+
gt = record.get('ground_truth', record.get('output', {}))
|
| 622 |
+
|
| 623 |
+
if isinstance(gt, str):
|
| 624 |
+
gt = json.loads(gt)
|
| 625 |
+
|
| 626 |
+
# Original
|
| 627 |
+
augmented_data.append(record)
|
| 628 |
+
|
| 629 |
+
# Augmented versions
|
| 630 |
+
for aug_text, aug_gt in self.augment(text, gt)[:augmentation_factor-1]:
|
| 631 |
+
augmented_data.append({
|
| 632 |
+
'text': aug_text,
|
| 633 |
+
'ground_truth': aug_gt,
|
| 634 |
+
'augmented': True,
|
| 635 |
+
})
|
| 636 |
+
|
| 637 |
+
return augmented_data
|
| 638 |
+
|
| 639 |
+
|
| 640 |
+
# ============================================================================
|
| 641 |
+
# RARE EDGE CASE OVERSAMPLING
|
| 642 |
+
# ============================================================================
|
| 643 |
+
|
| 644 |
+
class RareEdgeCaseSampler:
|
| 645 |
+
"""
|
| 646 |
+
Oversample rare edge cases to improve model robustness.
|
| 647 |
+
|
| 648 |
+
Uses importance sampling to increase representation of:
|
| 649 |
+
- Failed transactions
|
| 650 |
+
- Large amounts
|
| 651 |
+
- Unusual formats
|
| 652 |
+
- Rare banks
|
| 653 |
+
- Unicode text
|
| 654 |
+
"""
|
| 655 |
+
|
| 656 |
+
def __init__(self, seed: int = 42):
|
| 657 |
+
self.rng = random.Random(seed)
|
| 658 |
+
|
| 659 |
+
# Define edge case conditions
|
| 660 |
+
self.edge_cases = {
|
| 661 |
+
'failed_txn': lambda r: r.get('status') == 'failed',
|
| 662 |
+
'pending_txn': lambda r: r.get('status') == 'pending',
|
| 663 |
+
'large_amount': lambda r: (r.get('amount') or 0) > 100000,
|
| 664 |
+
'small_amount': lambda r: (r.get('amount') or float('inf')) < 10,
|
| 665 |
+
'unicode': lambda r: any(ord(c) > 127 for c in str(r.get('text', ''))),
|
| 666 |
+
'credit': lambda r: r.get('type') == 'credit',
|
| 667 |
+
}
|
| 668 |
+
|
| 669 |
+
# Oversampling weights (higher = more samples)
|
| 670 |
+
self.oversample_weights = {
|
| 671 |
+
'failed_txn': 5.0,
|
| 672 |
+
'pending_txn': 3.0,
|
| 673 |
+
'large_amount': 2.0,
|
| 674 |
+
'small_amount': 2.0,
|
| 675 |
+
'unicode': 4.0,
|
| 676 |
+
'credit': 1.5,
|
| 677 |
+
}
|
| 678 |
+
|
| 679 |
+
def identify_edge_cases(self, record: Dict) -> List[str]:
|
| 680 |
+
"""Identify which edge cases a record matches."""
|
| 681 |
+
return [
|
| 682 |
+
name for name, condition in self.edge_cases.items()
|
| 683 |
+
if condition(record)
|
| 684 |
+
]
|
| 685 |
+
|
| 686 |
+
def calculate_sample_weight(self, record: Dict) -> float:
|
| 687 |
+
"""Calculate importance weight for a record."""
|
| 688 |
+
weight = 1.0
|
| 689 |
+
for edge_case in self.identify_edge_cases(record):
|
| 690 |
+
weight *= self.oversample_weights.get(edge_case, 1.0)
|
| 691 |
+
return weight
|
| 692 |
+
|
| 693 |
+
def oversample(
|
| 694 |
+
self,
|
| 695 |
+
data: List[Dict],
|
| 696 |
+
target_size: Optional[int] = None
|
| 697 |
+
) -> List[Dict]:
|
| 698 |
+
"""
|
| 699 |
+
Oversample data with edge case weighting.
|
| 700 |
+
|
| 701 |
+
Returns dataset with increased representation of rare cases.
|
| 702 |
+
"""
|
| 703 |
+
if target_size is None:
|
| 704 |
+
target_size = len(data)
|
| 705 |
+
|
| 706 |
+
# Calculate weights
|
| 707 |
+
weights = [self.calculate_sample_weight(r) for r in data]
|
| 708 |
+
total_weight = sum(weights)
|
| 709 |
+
probs = [w / total_weight for w in weights]
|
| 710 |
+
|
| 711 |
+
# Sample with replacement
|
| 712 |
+
indices = self.rng.choices(range(len(data)), weights=probs, k=target_size)
|
| 713 |
+
|
| 714 |
+
oversampled = []
|
| 715 |
+
for i in indices:
|
| 716 |
+
record = data[i].copy()
|
| 717 |
+
record['oversampled'] = True
|
| 718 |
+
oversampled.append(record)
|
| 719 |
+
|
| 720 |
+
return oversampled
|
| 721 |
+
|
| 722 |
+
def generate_targeted_edge_cases(
|
| 723 |
+
self,
|
| 724 |
+
generator,
|
| 725 |
+
edge_case_type: str,
|
| 726 |
+
count: int
|
| 727 |
+
) -> List[Dict]:
|
| 728 |
+
"""Generate specific edge case samples."""
|
| 729 |
+
samples = []
|
| 730 |
+
|
| 731 |
+
if edge_case_type == 'failed_txn':
|
| 732 |
+
from scripts.data_pipeline.generate_synthetic import TransactionStatus
|
| 733 |
+
for _ in range(count):
|
| 734 |
+
sample = generator.generate_transaction(
|
| 735 |
+
status=TransactionStatus.FAILED
|
| 736 |
+
)
|
| 737 |
+
samples.append(sample)
|
| 738 |
+
|
| 739 |
+
elif edge_case_type == 'large_amount':
|
| 740 |
+
for _ in range(count):
|
| 741 |
+
sample = generator.generate_transaction()
|
| 742 |
+
# Force large amount
|
| 743 |
+
sample['ground_truth']['amount'] = self.rng.uniform(100000, 1000000)
|
| 744 |
+
samples.append(sample)
|
| 745 |
+
|
| 746 |
+
elif edge_case_type == 'unicode':
|
| 747 |
+
for _ in range(count):
|
| 748 |
+
sample = generator.generate_transaction()
|
| 749 |
+
# Use Hindi name
|
| 750 |
+
sample['ground_truth']['beneficiary'] = self.rng.choice([
|
| 751 |
+
"राहुल शर्मा", "प्रिया सिंह", "అమిత్ కుమార్"
|
| 752 |
+
])
|
| 753 |
+
samples.append(sample)
|
| 754 |
+
|
| 755 |
+
return samples
|
| 756 |
+
|
| 757 |
+
|
| 758 |
+
# ============================================================================
|
| 759 |
+
# DOCUMENT/PDF GENERATION (Placeholder - needs external libs)
|
| 760 |
+
# ============================================================================
|
| 761 |
+
|
| 762 |
+
class DocumentGenerator:
|
| 763 |
+
"""
|
| 764 |
+
Generate synthetic bank statements and documents.
|
| 765 |
+
|
| 766 |
+
Note: Full implementation requires:
|
| 767 |
+
- reportlab for PDF generation
|
| 768 |
+
- PIL for image processing
|
| 769 |
+
- wkhtmltopdf for HTML to PDF
|
| 770 |
+
"""
|
| 771 |
+
|
| 772 |
+
STATEMENT_TEMPLATE = """
|
| 773 |
+
============================================
|
| 774 |
+
{bank} BANK
|
| 775 |
+
ACCOUNT STATEMENT
|
| 776 |
+
============================================
|
| 777 |
+
|
| 778 |
+
Account Holder: {name}
|
| 779 |
+
Account Number: XXXXXXXX{account}
|
| 780 |
+
Statement Period: {start_date} to {end_date}
|
| 781 |
+
|
| 782 |
+
Opening Balance: Rs. {opening_balance}
|
| 783 |
+
|
| 784 |
+
--------------------------------------------
|
| 785 |
+
Date Description Debit Credit Balance
|
| 786 |
+
--------------------------------------------
|
| 787 |
+
{transactions}
|
| 788 |
+
--------------------------------------------
|
| 789 |
+
|
| 790 |
+
Closing Balance: Rs. {closing_balance}
|
| 791 |
+
|
| 792 |
+
This is a computer-generated statement.
|
| 793 |
+
"""
|
| 794 |
+
|
| 795 |
+
@classmethod
|
| 796 |
+
def generate_text_statement(
|
| 797 |
+
cls,
|
| 798 |
+
transactions: List[Dict],
|
| 799 |
+
bank: str,
|
| 800 |
+
account: str,
|
| 801 |
+
name: str,
|
| 802 |
+
rng: random.Random
|
| 803 |
+
) -> str:
|
| 804 |
+
"""Generate a text-based bank statement."""
|
| 805 |
+
if not transactions:
|
| 806 |
+
return ""
|
| 807 |
+
|
| 808 |
+
# Sort by date
|
| 809 |
+
sorted_txns = sorted(
|
| 810 |
+
transactions,
|
| 811 |
+
key=lambda x: x.get('date', '2025-01-01')
|
| 812 |
+
)
|
| 813 |
+
|
| 814 |
+
# Calculate running balance
|
| 815 |
+
opening = rng.randint(10000, 100000)
|
| 816 |
+
balance = opening
|
| 817 |
+
lines = []
|
| 818 |
+
|
| 819 |
+
for txn in sorted_txns:
|
| 820 |
+
amount = txn.get('amount', 0)
|
| 821 |
+
txn_type = txn.get('type', 'debit')
|
| 822 |
+
|
| 823 |
+
if txn_type == 'debit':
|
| 824 |
+
balance -= amount
|
| 825 |
+
debit = f"{amount:,.2f}"
|
| 826 |
+
credit = ""
|
| 827 |
+
else:
|
| 828 |
+
balance += amount
|
| 829 |
+
debit = ""
|
| 830 |
+
credit = f"{amount:,.2f}"
|
| 831 |
+
|
| 832 |
+
desc = txn.get('merchant') or txn.get('beneficiary') or 'Transaction'
|
| 833 |
+
date_str = txn.get('date', '2025-01-01')
|
| 834 |
+
|
| 835 |
+
line = f"{date_str} {desc[:20]:<20} {debit:>10} {credit:>10} {balance:>12,.2f}"
|
| 836 |
+
lines.append(line)
|
| 837 |
+
|
| 838 |
+
start_date = sorted_txns[0].get('date', '2025-01-01')
|
| 839 |
+
end_date = sorted_txns[-1].get('date', '2025-01-31')
|
| 840 |
+
|
| 841 |
+
return cls.STATEMENT_TEMPLATE.format(
|
| 842 |
+
bank=bank,
|
| 843 |
+
name=name,
|
| 844 |
+
account=account[-4:],
|
| 845 |
+
start_date=start_date,
|
| 846 |
+
end_date=end_date,
|
| 847 |
+
opening_balance=f"{opening:,.2f}",
|
| 848 |
+
closing_balance=f"{balance:,.2f}",
|
| 849 |
+
transactions='\n '.join(lines)
|
| 850 |
+
)
|
| 851 |
+
|
| 852 |
+
@classmethod
|
| 853 |
+
def generate_statement_image_data(
|
| 854 |
+
cls,
|
| 855 |
+
transactions: List[Dict],
|
| 856 |
+
bank: str,
|
| 857 |
+
rng: random.Random
|
| 858 |
+
) -> Dict:
|
| 859 |
+
"""
|
| 860 |
+
Generate data for statement image (actual rendering needs PIL).
|
| 861 |
+
|
| 862 |
+
Returns structured data that can be used with image generation.
|
| 863 |
+
"""
|
| 864 |
+
return {
|
| 865 |
+
'type': 'bank_statement',
|
| 866 |
+
'bank': bank,
|
| 867 |
+
'transactions': transactions,
|
| 868 |
+
'format': 'image_data',
|
| 869 |
+
'note': 'Use PIL/reportlab to render actual image'
|
| 870 |
+
}
|
| 871 |
+
|
| 872 |
+
|
| 873 |
+
# ============================================================================
|
| 874 |
+
# UNIFIED ADVANCED GENERATOR
|
| 875 |
+
# ============================================================================
|
| 876 |
+
|
| 877 |
+
class AdvancedSyntheticGenerator:
|
| 878 |
+
"""
|
| 879 |
+
Unified generator combining all advanced features.
|
| 880 |
+
|
| 881 |
+
Features:
|
| 882 |
+
1. Markov chain learning from real data
|
| 883 |
+
2. Statistical calibration
|
| 884 |
+
3. Multilingual support
|
| 885 |
+
4. Data augmentation
|
| 886 |
+
5. Edge case oversampling
|
| 887 |
+
"""
|
| 888 |
+
|
| 889 |
+
def __init__(self, seed: int = 42):
|
| 890 |
+
self.seed = seed
|
| 891 |
+
self.rng = random.Random(seed)
|
| 892 |
+
|
| 893 |
+
# Components
|
| 894 |
+
self.markov: Optional[MarkovChain] = None
|
| 895 |
+
self.calibrator: Optional[DataCalibrator] = None
|
| 896 |
+
self.augmenter = DataAugmenter(seed)
|
| 897 |
+
self.edge_sampler = RareEdgeCaseSampler(seed)
|
| 898 |
+
|
| 899 |
+
def train_on_real_data(self, real_data: List[Dict]):
|
| 900 |
+
"""Train/calibrate on real data."""
|
| 901 |
+
print("Training on real data...")
|
| 902 |
+
|
| 903 |
+
# Train Markov chain
|
| 904 |
+
texts = [r.get('text') or r.get('input', '') for r in real_data]
|
| 905 |
+
self.markov = MarkovChain(order=2)
|
| 906 |
+
self.markov.train(texts)
|
| 907 |
+
print(f" Markov chain trained on {len(texts)} samples")
|
| 908 |
+
|
| 909 |
+
# Calibrate distributions
|
| 910 |
+
self.calibrator = DataCalibrator()
|
| 911 |
+
parsed_data = []
|
| 912 |
+
for r in real_data:
|
| 913 |
+
gt = r.get('ground_truth') or r.get('output', {})
|
| 914 |
+
if isinstance(gt, str):
|
| 915 |
+
gt = json.loads(gt)
|
| 916 |
+
parsed_data.append(gt)
|
| 917 |
+
|
| 918 |
+
self.calibrator.fit_from_data(parsed_data)
|
| 919 |
+
print(" Distributions calibrated")
|
| 920 |
+
|
| 921 |
+
def generate(
|
| 922 |
+
self,
|
| 923 |
+
count: int,
|
| 924 |
+
languages: List[Language] = None,
|
| 925 |
+
include_documents: bool = False,
|
| 926 |
+
augmentation_factor: int = 1,
|
| 927 |
+
edge_case_ratio: float = 0.1,
|
| 928 |
+
) -> List[Dict]:
|
| 929 |
+
"""
|
| 930 |
+
Generate synthetic data with all advanced features.
|
| 931 |
+
|
| 932 |
+
Args:
|
| 933 |
+
count: Number of records
|
| 934 |
+
languages: Languages to include (None = English only)
|
| 935 |
+
include_documents: Include bank statement format
|
| 936 |
+
augmentation_factor: How many augmented versions per sample
|
| 937 |
+
edge_case_ratio: Proportion of edge cases to include
|
| 938 |
+
"""
|
| 939 |
+
if languages is None:
|
| 940 |
+
languages = [Language.ENGLISH]
|
| 941 |
+
|
| 942 |
+
records = []
|
| 943 |
+
base_count = int(count / augmentation_factor)
|
| 944 |
+
edge_count = int(base_count * edge_case_ratio)
|
| 945 |
+
normal_count = base_count - edge_count
|
| 946 |
+
|
| 947 |
+
print(f"Generating {count:,} records...")
|
| 948 |
+
print(f" Base: {base_count:,}, Edges: {edge_count:,}, Augmented: {count - base_count:,}")
|
| 949 |
+
|
| 950 |
+
# Generate normal transactions
|
| 951 |
+
for i in range(normal_count):
|
| 952 |
+
lang = self.rng.choice(languages)
|
| 953 |
+
|
| 954 |
+
# Sample from calibrated distributions if available
|
| 955 |
+
if self.calibrator:
|
| 956 |
+
category = self.calibrator.sample_category(self.rng)
|
| 957 |
+
bank = self.calibrator.sample_bank(self.rng)
|
| 958 |
+
amount = self.calibrator.sample_amount(category, self.rng)
|
| 959 |
+
else:
|
| 960 |
+
category = self.rng.choice(['shopping', 'food', 'transfer', 'bills'])
|
| 961 |
+
bank = self.rng.choice(['HDFC', 'ICICI', 'SBI', 'Axis'])
|
| 962 |
+
amount = self.rng.uniform(100, 10000)
|
| 963 |
+
|
| 964 |
+
# Generate message
|
| 965 |
+
is_debit = self.rng.random() < 0.7
|
| 966 |
+
template_type = 'debit' if is_debit else 'credit'
|
| 967 |
+
|
| 968 |
+
params = {
|
| 969 |
+
'bank': bank,
|
| 970 |
+
'amount': f"{amount:,.2f}",
|
| 971 |
+
'account': str(self.rng.randint(1000, 9999)),
|
| 972 |
+
'date': (date.today() - timedelta(days=self.rng.randint(0, 365))).strftime('%d-%m-%Y'),
|
| 973 |
+
'vpa': f"{self.rng.choice(['swiggy', 'amazon', 'paytm'])}@ybl",
|
| 974 |
+
'sender': 'PhonePe',
|
| 975 |
+
'ref': ''.join(self.rng.choices('0123456789', k=12)),
|
| 976 |
+
}
|
| 977 |
+
|
| 978 |
+
text = MultilingualBank.generate(template_type, lang, params)
|
| 979 |
+
|
| 980 |
+
records.append({
|
| 981 |
+
'text': text,
|
| 982 |
+
'ground_truth': {
|
| 983 |
+
'amount': round(amount, 2),
|
| 984 |
+
'type': 'debit' if is_debit else 'credit',
|
| 985 |
+
'bank': bank,
|
| 986 |
+
'category': category,
|
| 987 |
+
'language': lang.value,
|
| 988 |
+
},
|
| 989 |
+
'language': lang.value,
|
| 990 |
+
})
|
| 991 |
+
|
| 992 |
+
if (i + 1) % 5000 == 0:
|
| 993 |
+
print(f" Generated {i+1:,}/{base_count:,}")
|
| 994 |
+
|
| 995 |
+
# Generate edge cases
|
| 996 |
+
for i in range(edge_count):
|
| 997 |
+
lang = self.rng.choice(languages)
|
| 998 |
+
edge_type = self.rng.choice(['unicode', 'large_amount', 'small_amount'])
|
| 999 |
+
|
| 1000 |
+
if edge_type == 'unicode' and lang == Language.ENGLISH:
|
| 1001 |
+
lang = Language.HINDI
|
| 1002 |
+
|
| 1003 |
+
amount = (
|
| 1004 |
+
self.rng.uniform(100000, 1000000) if edge_type == 'large_amount'
|
| 1005 |
+
else self.rng.uniform(0.5, 10) if edge_type == 'small_amount'
|
| 1006 |
+
else self.rng.uniform(100, 10000)
|
| 1007 |
+
)
|
| 1008 |
+
|
| 1009 |
+
params = {
|
| 1010 |
+
'bank': self.rng.choice(['HDFC', 'ICICI', 'SBI']),
|
| 1011 |
+
'amount': f"{amount:,.2f}",
|
| 1012 |
+
'account': str(self.rng.randint(1000, 9999)),
|
| 1013 |
+
'date': date.today().strftime('%d-%m-%Y'),
|
| 1014 |
+
'vpa': 'merchant@ybl',
|
| 1015 |
+
'sender': MultilingualNameGenerator.get_name(lang, self.rng) if edge_type == 'unicode' else 'User',
|
| 1016 |
+
'ref': ''.join(self.rng.choices('0123456789', k=12)),
|
| 1017 |
+
}
|
| 1018 |
+
|
| 1019 |
+
text = MultilingualBank.generate('debit', lang, params, use_native_numbers=(edge_type == 'unicode'))
|
| 1020 |
+
|
| 1021 |
+
records.append({
|
| 1022 |
+
'text': text,
|
| 1023 |
+
'ground_truth': {
|
| 1024 |
+
'amount': round(amount, 2),
|
| 1025 |
+
'type': 'debit',
|
| 1026 |
+
'language': lang.value,
|
| 1027 |
+
},
|
| 1028 |
+
'edge_case': edge_type,
|
| 1029 |
+
'language': lang.value,
|
| 1030 |
+
})
|
| 1031 |
+
|
| 1032 |
+
# Augment if factor > 1
|
| 1033 |
+
if augmentation_factor > 1:
|
| 1034 |
+
print(f" Augmenting {len(records):,} records...")
|
| 1035 |
+
records = self.augmenter.augment_batch(records, augmentation_factor)
|
| 1036 |
+
|
| 1037 |
+
# Add statements if requested
|
| 1038 |
+
if include_documents:
|
| 1039 |
+
print(" Generating document samples...")
|
| 1040 |
+
for _ in range(min(100, count // 100)):
|
| 1041 |
+
bank = self.rng.choice(['HDFC', 'ICICI', 'SBI'])
|
| 1042 |
+
account = str(self.rng.randint(10000000, 99999999))
|
| 1043 |
+
name = self.rng.choice(['Rahul Sharma', 'Priya Singh', 'Amit Kumar'])
|
| 1044 |
+
|
| 1045 |
+
# Use recent records for statement
|
| 1046 |
+
txns = [r['ground_truth'] for r in self.rng.sample(records, min(10, len(records)))]
|
| 1047 |
+
statement = DocumentGenerator.generate_text_statement(
|
| 1048 |
+
txns, bank, account, name, self.rng
|
| 1049 |
+
)
|
| 1050 |
+
|
| 1051 |
+
records.append({
|
| 1052 |
+
'text': statement,
|
| 1053 |
+
'ground_truth': {'document_type': 'bank_statement', 'bank': bank},
|
| 1054 |
+
'document': True,
|
| 1055 |
+
})
|
| 1056 |
+
|
| 1057 |
+
self.rng.shuffle(records)
|
| 1058 |
+
|
| 1059 |
+
# Add IDs
|
| 1060 |
+
for i, r in enumerate(records):
|
| 1061 |
+
r['id'] = i + 1
|
| 1062 |
+
|
| 1063 |
+
print(f"✅ Generated {len(records):,} total records")
|
| 1064 |
+
return records
|
| 1065 |
+
|
| 1066 |
+
def save_training_data(self, records: List[Dict], output_path: Path):
|
| 1067 |
+
"""Save in training format."""
|
| 1068 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 1069 |
+
|
| 1070 |
+
with open(output_path, 'w', encoding='utf-8') as f:
|
| 1071 |
+
for r in records:
|
| 1072 |
+
line = {
|
| 1073 |
+
'input': r['text'],
|
| 1074 |
+
'output': json.dumps(r['ground_truth'], ensure_ascii=False),
|
| 1075 |
+
'id': r.get('id'),
|
| 1076 |
+
'language': r.get('language', 'en'),
|
| 1077 |
+
}
|
| 1078 |
+
if r.get('edge_case'):
|
| 1079 |
+
line['edge_case'] = r['edge_case']
|
| 1080 |
+
f.write(json.dumps(line, ensure_ascii=False) + '\n')
|
| 1081 |
+
|
| 1082 |
+
print(f"✅ Saved to: {output_path}")
|
| 1083 |
+
|
| 1084 |
+
|
| 1085 |
+
# ============================================================================
|
| 1086 |
+
# CLI
|
| 1087 |
+
# ============================================================================
|
| 1088 |
+
|
| 1089 |
+
def main():
|
| 1090 |
+
parser = argparse.ArgumentParser(description="Advanced Synthetic Data Generator v4.0")
|
| 1091 |
+
parser.add_argument("-n", "--count", type=int, default=10000, help="Number of records")
|
| 1092 |
+
parser.add_argument("-o", "--output", default="data/synthetic/advanced_synthetic.jsonl")
|
| 1093 |
+
parser.add_argument("--seed", type=int, default=42, help="Random seed")
|
| 1094 |
+
parser.add_argument("--languages", nargs='+', default=['en'],
|
| 1095 |
+
help="Languages: en, hi, ta, te, bn, kn, mr, gu")
|
| 1096 |
+
parser.add_argument("--augment", type=int, default=1, help="Augmentation factor")
|
| 1097 |
+
parser.add_argument("--edge-ratio", type=float, default=0.1, help="Edge case ratio")
|
| 1098 |
+
parser.add_argument("--real-data", help="Path to real data for calibration")
|
| 1099 |
+
parser.add_argument("--documents", action="store_true", help="Include document samples")
|
| 1100 |
+
|
| 1101 |
+
args = parser.parse_args()
|
| 1102 |
+
|
| 1103 |
+
# Parse languages
|
| 1104 |
+
lang_map = {
|
| 1105 |
+
'en': Language.ENGLISH, 'hi': Language.HINDI, 'ta': Language.TAMIL,
|
| 1106 |
+
'te': Language.TELUGU, 'bn': Language.BENGALI, 'kn': Language.KANNADA,
|
| 1107 |
+
'mr': Language.MARATHI, 'gu': Language.GUJARATI,
|
| 1108 |
+
}
|
| 1109 |
+
languages = [lang_map.get(l, Language.ENGLISH) for l in args.languages]
|
| 1110 |
+
|
| 1111 |
+
# Initialize generator
|
| 1112 |
+
generator = AdvancedSyntheticGenerator(seed=args.seed)
|
| 1113 |
+
|
| 1114 |
+
# Train on real data if provided
|
| 1115 |
+
if args.real_data:
|
| 1116 |
+
real_path = Path(args.real_data)
|
| 1117 |
+
if real_path.exists():
|
| 1118 |
+
with open(real_path) as f:
|
| 1119 |
+
real_data = [json.loads(line) for line in f]
|
| 1120 |
+
generator.train_on_real_data(real_data)
|
| 1121 |
+
|
| 1122 |
+
# Generate
|
| 1123 |
+
records = generator.generate(
|
| 1124 |
+
count=args.count,
|
| 1125 |
+
languages=languages,
|
| 1126 |
+
include_documents=args.documents,
|
| 1127 |
+
augmentation_factor=args.augment,
|
| 1128 |
+
edge_case_ratio=args.edge_ratio,
|
| 1129 |
+
)
|
| 1130 |
+
|
| 1131 |
+
# Save
|
| 1132 |
+
output_path = Path(args.output)
|
| 1133 |
+
generator.save_training_data(records, output_path)
|
| 1134 |
+
|
| 1135 |
+
# Summary
|
| 1136 |
+
print("\n📊 Summary:")
|
| 1137 |
+
lang_counts = Counter(r.get('language', 'en') for r in records)
|
| 1138 |
+
for lang, count in lang_counts.most_common():
|
| 1139 |
+
print(f" {lang}: {count:,}")
|
| 1140 |
+
|
| 1141 |
+
edge_counts = Counter(r.get('edge_case') for r in records if r.get('edge_case'))
|
| 1142 |
+
if edge_counts:
|
| 1143 |
+
print("\n📋 Edge Cases:")
|
| 1144 |
+
for edge, count in edge_counts.most_common():
|
| 1145 |
+
print(f" {edge}: {count:,}")
|
| 1146 |
+
|
| 1147 |
+
|
| 1148 |
+
if __name__ == "__main__":
|
| 1149 |
+
main()
|
|
@@ -0,0 +1,1726 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Production-Grade Synthetic Data Generator for Indian Banking Transactions
|
| 4 |
+
==========================================================================
|
| 5 |
+
|
| 6 |
+
Engineering Principles:
|
| 7 |
+
1. Grammar-based message generation (not hardcoded templates)
|
| 8 |
+
2. Combinatorial coverage with configurable sampling
|
| 9 |
+
3. Type-safe generation with validation
|
| 10 |
+
4. Systematic edge case enumeration
|
| 11 |
+
5. Statistical distribution control
|
| 12 |
+
6. Reproducible with proper seeding
|
| 13 |
+
7. Property-based validation
|
| 14 |
+
|
| 15 |
+
Author: Ranjit Behera
|
| 16 |
+
Version: 3.0 (Engineering Grade)
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
import json
|
| 21 |
+
import random
|
| 22 |
+
import hashlib
|
| 23 |
+
import argparse
|
| 24 |
+
import math
|
| 25 |
+
import itertools
|
| 26 |
+
from abc import ABC, abstractmethod
|
| 27 |
+
from dataclasses import dataclass, field, asdict
|
| 28 |
+
from datetime import datetime, timedelta, date
|
| 29 |
+
from decimal import Decimal, ROUND_HALF_UP
|
| 30 |
+
from enum import Enum, auto
|
| 31 |
+
from pathlib import Path
|
| 32 |
+
from typing import (
|
| 33 |
+
List, Dict, Optional, Tuple, Set, Generator,
|
| 34 |
+
Callable, TypeVar, Generic, Union, Any, Iterator
|
| 35 |
+
)
|
| 36 |
+
from collections import defaultdict
|
| 37 |
+
import re
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# ============================================================================
|
| 41 |
+
# TYPE SYSTEM - Enums and Value Objects
|
| 42 |
+
# ============================================================================
|
| 43 |
+
|
| 44 |
+
class TransactionType(Enum):
|
| 45 |
+
DEBIT = "debit"
|
| 46 |
+
CREDIT = "credit"
|
| 47 |
+
|
| 48 |
+
class TransactionStatus(Enum):
|
| 49 |
+
SUCCESS = "success"
|
| 50 |
+
FAILED = "failed"
|
| 51 |
+
PENDING = "pending"
|
| 52 |
+
REVERSED = "reversed"
|
| 53 |
+
|
| 54 |
+
class PaymentMethod(Enum):
|
| 55 |
+
UPI = "upi"
|
| 56 |
+
NEFT = "neft"
|
| 57 |
+
RTGS = "rtgs"
|
| 58 |
+
IMPS = "imps"
|
| 59 |
+
CREDIT_CARD = "credit_card"
|
| 60 |
+
DEBIT_CARD = "debit_card"
|
| 61 |
+
ATM = "atm"
|
| 62 |
+
WALLET = "wallet"
|
| 63 |
+
AUTO_DEBIT = "auto_debit"
|
| 64 |
+
CHEQUE = "cheque"
|
| 65 |
+
CASH = "cash"
|
| 66 |
+
|
| 67 |
+
class Category(Enum):
|
| 68 |
+
FOOD = "food"
|
| 69 |
+
GROCERY = "grocery"
|
| 70 |
+
SHOPPING = "shopping"
|
| 71 |
+
TRANSPORT = "transport"
|
| 72 |
+
TRAVEL = "travel"
|
| 73 |
+
FUEL = "fuel"
|
| 74 |
+
BILLS = "bills"
|
| 75 |
+
ENTERTAINMENT = "entertainment"
|
| 76 |
+
HEALTHCARE = "healthcare"
|
| 77 |
+
INVESTMENT = "investment"
|
| 78 |
+
INSURANCE = "insurance"
|
| 79 |
+
EDUCATION = "education"
|
| 80 |
+
TRANSFER = "transfer"
|
| 81 |
+
SALARY = "salary"
|
| 82 |
+
REFUND = "refund"
|
| 83 |
+
CASHBACK = "cashback"
|
| 84 |
+
EMI = "emi"
|
| 85 |
+
ATM_WITHDRAWAL = "atm_withdrawal"
|
| 86 |
+
OTHER = "other"
|
| 87 |
+
|
| 88 |
+
class MessageType(Enum):
|
| 89 |
+
TRANSACTION = "transaction"
|
| 90 |
+
OTP = "otp"
|
| 91 |
+
PROMOTIONAL = "promotional"
|
| 92 |
+
ALERT = "alert"
|
| 93 |
+
STATEMENT = "statement"
|
| 94 |
+
|
| 95 |
+
class AmountFormat(Enum):
|
| 96 |
+
"""All possible amount format variations."""
|
| 97 |
+
INTEGER = "integer" # 2500
|
| 98 |
+
DECIMAL_2 = "decimal_2" # 2500.00
|
| 99 |
+
DECIMAL_1 = "decimal_1" # 2500.5
|
| 100 |
+
COMMA_INTERNATIONAL = "comma_intl" # 2,500.00
|
| 101 |
+
COMMA_INDIAN = "comma_indian" # 2,50,000.00
|
| 102 |
+
NO_DECIMAL_COMMA = "no_decimal_comma" # 2,500
|
| 103 |
+
COMPACT = "compact" # 2.5K, 1.2L
|
| 104 |
+
PADDED = "padded" # 002500.00
|
| 105 |
+
|
| 106 |
+
class DateFormat(Enum):
|
| 107 |
+
"""All possible date format variations."""
|
| 108 |
+
DD_MM_YYYY_DASH = "dd-mm-yyyy" # 28-12-2025
|
| 109 |
+
DD_MM_YY_DASH = "dd-mm-yy" # 28-12-25
|
| 110 |
+
DD_MM_YYYY_SLASH = "dd/mm/yyyy" # 28/12/2025
|
| 111 |
+
DD_MM_YY_SLASH = "dd/mm/yy" # 28/12/25
|
| 112 |
+
DD_MON_YY = "dd-mon-yy" # 28-Dec-25
|
| 113 |
+
DD_MON_YYYY = "dd-mon-yyyy" # 28-Dec-2025
|
| 114 |
+
DD_MONTH_YYYY = "dd-month-yyyy" # 28 December 2025
|
| 115 |
+
MON_DD_YYYY = "mon-dd-yyyy" # Dec 28, 2025
|
| 116 |
+
YYYY_MM_DD = "yyyy-mm-dd" # 2025-12-28 (ISO)
|
| 117 |
+
COMPACT = "compact" # 28Dec25
|
| 118 |
+
RELATIVE = "relative" # today, yesterday
|
| 119 |
+
|
| 120 |
+
class CurrencySymbol(Enum):
|
| 121 |
+
"""Currency symbol variations."""
|
| 122 |
+
RS_DOT = "Rs."
|
| 123 |
+
RS = "Rs"
|
| 124 |
+
INR = "INR"
|
| 125 |
+
RUPEE = "₹"
|
| 126 |
+
RS_SPACE = "Rs "
|
| 127 |
+
INR_SPACE = "INR "
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# ============================================================================
|
| 131 |
+
# VALUE OBJECTS - Immutable domain objects
|
| 132 |
+
# ============================================================================
|
| 133 |
+
|
| 134 |
+
@dataclass(frozen=True)
|
| 135 |
+
class Amount:
|
| 136 |
+
"""Immutable amount with validation."""
|
| 137 |
+
value: Decimal
|
| 138 |
+
|
| 139 |
+
def __post_init__(self):
|
| 140 |
+
if self.value < 0:
|
| 141 |
+
raise ValueError(f"Amount cannot be negative: {self.value}")
|
| 142 |
+
|
| 143 |
+
@classmethod
|
| 144 |
+
def from_float(cls, value: float) -> 'Amount':
|
| 145 |
+
return cls(Decimal(str(value)).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP))
|
| 146 |
+
|
| 147 |
+
@classmethod
|
| 148 |
+
def from_int(cls, value: int) -> 'Amount':
|
| 149 |
+
return cls(Decimal(value))
|
| 150 |
+
|
| 151 |
+
def format(self, fmt: AmountFormat) -> str:
|
| 152 |
+
"""Format amount according to specified format."""
|
| 153 |
+
v = float(self.value)
|
| 154 |
+
|
| 155 |
+
if fmt == AmountFormat.INTEGER:
|
| 156 |
+
return str(int(v))
|
| 157 |
+
elif fmt == AmountFormat.DECIMAL_2:
|
| 158 |
+
return f"{v:.2f}"
|
| 159 |
+
elif fmt == AmountFormat.DECIMAL_1:
|
| 160 |
+
return f"{v:.1f}" if v != int(v) else str(int(v))
|
| 161 |
+
elif fmt == AmountFormat.COMMA_INTERNATIONAL:
|
| 162 |
+
return f"{v:,.2f}"
|
| 163 |
+
elif fmt == AmountFormat.COMMA_INDIAN:
|
| 164 |
+
return self._indian_format(v)
|
| 165 |
+
elif fmt == AmountFormat.NO_DECIMAL_COMMA:
|
| 166 |
+
return f"{int(v):,}"
|
| 167 |
+
elif fmt == AmountFormat.COMPACT:
|
| 168 |
+
return self._compact_format(v)
|
| 169 |
+
elif fmt == AmountFormat.PADDED:
|
| 170 |
+
return f"{v:012.2f}"
|
| 171 |
+
return str(v)
|
| 172 |
+
|
| 173 |
+
def _indian_format(self, v: float) -> str:
|
| 174 |
+
"""Format in Indian numbering system (lakhs, crores)."""
|
| 175 |
+
s = f"{v:.2f}"
|
| 176 |
+
integer_part, decimal_part = s.split('.')
|
| 177 |
+
|
| 178 |
+
if len(integer_part) <= 3:
|
| 179 |
+
return s
|
| 180 |
+
|
| 181 |
+
# Last 3 digits
|
| 182 |
+
result = integer_part[-3:]
|
| 183 |
+
integer_part = integer_part[:-3]
|
| 184 |
+
|
| 185 |
+
# Rest in groups of 2
|
| 186 |
+
while integer_part:
|
| 187 |
+
result = integer_part[-2:] + ',' + result
|
| 188 |
+
integer_part = integer_part[:-2]
|
| 189 |
+
|
| 190 |
+
return result + '.' + decimal_part
|
| 191 |
+
|
| 192 |
+
def _compact_format(self, v: float) -> str:
|
| 193 |
+
"""Format as compact (2.5K, 1.2L, 3.5Cr)."""
|
| 194 |
+
if v >= 10000000: # Crore
|
| 195 |
+
return f"{v/10000000:.1f}Cr"
|
| 196 |
+
elif v >= 100000: # Lakh
|
| 197 |
+
return f"{v/100000:.1f}L"
|
| 198 |
+
elif v >= 1000: # Thousand
|
| 199 |
+
return f"{v/1000:.1f}K"
|
| 200 |
+
return str(int(v))
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
@dataclass(frozen=True)
|
| 204 |
+
class Account:
|
| 205 |
+
"""Bank account representation."""
|
| 206 |
+
last_four: str
|
| 207 |
+
|
| 208 |
+
def __post_init__(self):
|
| 209 |
+
if not self.last_four.isdigit() or len(self.last_four) != 4:
|
| 210 |
+
raise ValueError(f"Invalid account last four: {self.last_four}")
|
| 211 |
+
|
| 212 |
+
def format(self, style: str = "XX") -> str:
|
| 213 |
+
"""Format account number."""
|
| 214 |
+
styles = {
|
| 215 |
+
"XX": f"XX{self.last_four}",
|
| 216 |
+
"xx": f"xx{self.last_four}",
|
| 217 |
+
"****": f"****{self.last_four}",
|
| 218 |
+
"...": f"...{self.last_four}",
|
| 219 |
+
"plain": self.last_four,
|
| 220 |
+
"X": f"X{self.last_four}",
|
| 221 |
+
}
|
| 222 |
+
return styles.get(style, f"XX{self.last_four}")
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
@dataclass(frozen=True)
|
| 226 |
+
class Reference:
|
| 227 |
+
"""Transaction reference number."""
|
| 228 |
+
value: str
|
| 229 |
+
ref_type: str # numeric, alphanumeric, utr
|
| 230 |
+
|
| 231 |
+
@classmethod
|
| 232 |
+
def generate(cls, ref_type: str, bank_code: str = "HDFC") -> 'Reference':
|
| 233 |
+
if ref_type == "numeric":
|
| 234 |
+
value = ''.join(random.choices('0123456789', k=12))
|
| 235 |
+
elif ref_type == "alphanumeric":
|
| 236 |
+
value = bank_code + ''.join(random.choices('0123456789', k=11))
|
| 237 |
+
elif ref_type == "utr":
|
| 238 |
+
date_part = datetime.now().strftime('%y%m%d')
|
| 239 |
+
value = f"{bank_code}{date_part}N{''.join(random.choices('0123456789', k=6))}"
|
| 240 |
+
else:
|
| 241 |
+
value = ''.join(random.choices('0123456789', k=12))
|
| 242 |
+
|
| 243 |
+
return cls(value=value, ref_type=ref_type)
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
@dataclass(frozen=True)
|
| 247 |
+
class TransactionDate:
|
| 248 |
+
"""Date with multiple format support."""
|
| 249 |
+
date: date
|
| 250 |
+
time: Optional[Tuple[int, int, int]] = None # (hour, minute, second)
|
| 251 |
+
|
| 252 |
+
def format_date(self, fmt: DateFormat) -> str:
|
| 253 |
+
"""Format date according to specified format."""
|
| 254 |
+
d = self.date
|
| 255 |
+
|
| 256 |
+
formats = {
|
| 257 |
+
DateFormat.DD_MM_YYYY_DASH: d.strftime("%d-%m-%Y"),
|
| 258 |
+
DateFormat.DD_MM_YY_DASH: d.strftime("%d-%m-%y"),
|
| 259 |
+
DateFormat.DD_MM_YYYY_SLASH: d.strftime("%d/%m/%Y"),
|
| 260 |
+
DateFormat.DD_MM_YY_SLASH: d.strftime("%d/%m/%y"),
|
| 261 |
+
DateFormat.DD_MON_YY: d.strftime("%d-%b-%y"),
|
| 262 |
+
DateFormat.DD_MON_YYYY: d.strftime("%d-%b-%Y"),
|
| 263 |
+
DateFormat.DD_MONTH_YYYY: d.strftime("%d %B %Y"),
|
| 264 |
+
DateFormat.MON_DD_YYYY: d.strftime("%b %d, %Y"),
|
| 265 |
+
DateFormat.YYYY_MM_DD: d.strftime("%Y-%m-%d"),
|
| 266 |
+
DateFormat.COMPACT: d.strftime("%d%b%y"),
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
if fmt == DateFormat.RELATIVE:
|
| 270 |
+
today = date.today()
|
| 271 |
+
diff = (today - d).days
|
| 272 |
+
if diff == 0:
|
| 273 |
+
return "today"
|
| 274 |
+
elif diff == 1:
|
| 275 |
+
return "yesterday"
|
| 276 |
+
else:
|
| 277 |
+
return formats[DateFormat.DD_MM_YY_DASH]
|
| 278 |
+
|
| 279 |
+
return formats.get(fmt, d.strftime("%d-%m-%Y"))
|
| 280 |
+
|
| 281 |
+
def format_time(self) -> Optional[str]:
|
| 282 |
+
"""Format time if present."""
|
| 283 |
+
if not self.time:
|
| 284 |
+
return None
|
| 285 |
+
h, m, s = self.time
|
| 286 |
+
formats = [
|
| 287 |
+
f"{h:02d}:{m:02d}:{s:02d}",
|
| 288 |
+
f"{h:02d}:{m:02d}",
|
| 289 |
+
f"{h:02d}:{m:02d} {'AM' if h < 12 else 'PM'}",
|
| 290 |
+
]
|
| 291 |
+
return random.choice(formats)
|
| 292 |
+
|
| 293 |
+
def normalized(self) -> str:
|
| 294 |
+
"""Return ISO format."""
|
| 295 |
+
return self.date.strftime("%Y-%m-%d")
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
# ============================================================================
|
| 299 |
+
# GRAMMAR-BASED MESSAGE GENERATOR
|
| 300 |
+
# ============================================================================
|
| 301 |
+
|
| 302 |
+
class GrammarRule:
|
| 303 |
+
"""Represents a grammar rule with weighted alternatives."""
|
| 304 |
+
|
| 305 |
+
def __init__(self, name: str, alternatives: List[Tuple[str, float]]):
|
| 306 |
+
"""
|
| 307 |
+
Args:
|
| 308 |
+
name: Rule name
|
| 309 |
+
alternatives: List of (pattern, weight) tuples
|
| 310 |
+
"""
|
| 311 |
+
self.name = name
|
| 312 |
+
self.alternatives = alternatives
|
| 313 |
+
self._normalize_weights()
|
| 314 |
+
|
| 315 |
+
def _normalize_weights(self):
|
| 316 |
+
"""Normalize weights to sum to 1."""
|
| 317 |
+
total = sum(w for _, w in self.alternatives)
|
| 318 |
+
self.alternatives = [(p, w/total) for p, w in self.alternatives]
|
| 319 |
+
|
| 320 |
+
def sample(self, rng: random.Random) -> str:
|
| 321 |
+
"""Sample one alternative based on weights."""
|
| 322 |
+
r = rng.random()
|
| 323 |
+
cumulative = 0
|
| 324 |
+
for pattern, weight in self.alternatives:
|
| 325 |
+
cumulative += weight
|
| 326 |
+
if r <= cumulative:
|
| 327 |
+
return pattern
|
| 328 |
+
return self.alternatives[-1][0]
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
class MessageGrammar:
|
| 332 |
+
"""
|
| 333 |
+
Grammar-based message generator.
|
| 334 |
+
|
| 335 |
+
Grammar Structure:
|
| 336 |
+
MESSAGE ::= PREFIX BODY SUFFIX
|
| 337 |
+
PREFIX ::= BANK_INTRO
|
| 338 |
+
BODY ::= AMOUNT_PHRASE ACCOUNT_PHRASE DATE_PHRASE PARTY_PHRASE REF_PHRASE
|
| 339 |
+
SUFFIX ::= BALANCE_PHRASE? WARNING_PHRASE?
|
| 340 |
+
"""
|
| 341 |
+
|
| 342 |
+
def __init__(self, seed: int = 42):
|
| 343 |
+
self.rng = random.Random(seed)
|
| 344 |
+
self._build_grammar()
|
| 345 |
+
|
| 346 |
+
def _build_grammar(self):
|
| 347 |
+
"""Build the grammar rules."""
|
| 348 |
+
|
| 349 |
+
# Bank introduction patterns
|
| 350 |
+
self.bank_intro = GrammarRule("BANK_INTRO", [
|
| 351 |
+
("{bank}:", 0.3),
|
| 352 |
+
("{bank} Bank:", 0.25),
|
| 353 |
+
("{bank} Bk:", 0.1),
|
| 354 |
+
("Dear Customer, {bank}:", 0.1),
|
| 355 |
+
("Alert: {bank}", 0.1),
|
| 356 |
+
("{bank} Bank Alert:", 0.1),
|
| 357 |
+
("{bank} Bank Acct", 0.05),
|
| 358 |
+
])
|
| 359 |
+
|
| 360 |
+
# Amount phrase patterns for DEBIT
|
| 361 |
+
self.amount_debit = GrammarRule("AMOUNT_DEBIT", [
|
| 362 |
+
("{currency}{amount} debited from", 0.25),
|
| 363 |
+
("{currency}{amount} sent to", 0.15),
|
| 364 |
+
("{currency}{amount} paid to", 0.1),
|
| 365 |
+
("{currency}{amount} transferred to", 0.1),
|
| 366 |
+
("{currency} {amount} debited from", 0.1),
|
| 367 |
+
("INR {amount} debited from", 0.1),
|
| 368 |
+
("{currency}{amount} spent at", 0.1),
|
| 369 |
+
("Amount {currency}{amount} debited", 0.1),
|
| 370 |
+
])
|
| 371 |
+
|
| 372 |
+
# Amount phrase patterns for CREDIT
|
| 373 |
+
self.amount_credit = GrammarRule("AMOUNT_CREDIT", [
|
| 374 |
+
("{currency}{amount} credited to", 0.3),
|
| 375 |
+
("{currency}{amount} received in", 0.2),
|
| 376 |
+
("{currency}{amount} deposited to", 0.1),
|
| 377 |
+
("{currency} {amount} credited to", 0.15),
|
| 378 |
+
("INR {amount} credited to", 0.1),
|
| 379 |
+
("{currency}{amount} added to", 0.1),
|
| 380 |
+
("Received {currency}{amount} in", 0.05),
|
| 381 |
+
])
|
| 382 |
+
|
| 383 |
+
# Account phrase patterns
|
| 384 |
+
self.account_phrase = GrammarRule("ACCOUNT_PHRASE", [
|
| 385 |
+
("A/c {account}", 0.3),
|
| 386 |
+
("a/c {account}", 0.15),
|
| 387 |
+
("Account {account}", 0.15),
|
| 388 |
+
("Acct {account}", 0.15),
|
| 389 |
+
("A/c No. {account}", 0.1),
|
| 390 |
+
("Acc {account}", 0.1),
|
| 391 |
+
("your A/c {account}", 0.05),
|
| 392 |
+
])
|
| 393 |
+
|
| 394 |
+
# Date phrase patterns
|
| 395 |
+
self.date_phrase = GrammarRule("DATE_PHRASE", [
|
| 396 |
+
("on {date}", 0.4),
|
| 397 |
+
("on {date} {time}", 0.2),
|
| 398 |
+
("{date}", 0.15),
|
| 399 |
+
("dated {date}", 0.1),
|
| 400 |
+
("on {date} at {time}", 0.1),
|
| 401 |
+
("{date} {time}", 0.05),
|
| 402 |
+
])
|
| 403 |
+
|
| 404 |
+
# Counterparty patterns (UPI)
|
| 405 |
+
self.party_upi = GrammarRule("PARTY_UPI", [
|
| 406 |
+
("VPA {vpa}", 0.2),
|
| 407 |
+
("to {vpa}", 0.2),
|
| 408 |
+
("VPA: {vpa}", 0.15),
|
| 409 |
+
("to VPA {vpa}", 0.15),
|
| 410 |
+
("Info: UPI/{vpa}", 0.1),
|
| 411 |
+
("{beneficiary} ({vpa})", 0.1),
|
| 412 |
+
("UPI-{vpa}", 0.1),
|
| 413 |
+
])
|
| 414 |
+
|
| 415 |
+
# Reference patterns
|
| 416 |
+
self.reference = GrammarRule("REFERENCE", [
|
| 417 |
+
("Ref: {ref}", 0.25),
|
| 418 |
+
("Ref {ref}", 0.2),
|
| 419 |
+
("UPI Ref: {ref}", 0.2),
|
| 420 |
+
("Reference: {ref}", 0.1),
|
| 421 |
+
("UTR: {ref}", 0.1),
|
| 422 |
+
("Txn Ref: {ref}", 0.1),
|
| 423 |
+
("Ref No. {ref}", 0.05),
|
| 424 |
+
])
|
| 425 |
+
|
| 426 |
+
# Balance patterns
|
| 427 |
+
self.balance = GrammarRule("BALANCE", [
|
| 428 |
+
("Avl Bal: {currency}{balance}", 0.25),
|
| 429 |
+
("Bal: {currency}{balance}", 0.2),
|
| 430 |
+
("Available Balance: {currency}{balance}", 0.15),
|
| 431 |
+
("Avl Bal {currency}{balance}", 0.15),
|
| 432 |
+
("Balance: {currency}{balance}", 0.1),
|
| 433 |
+
("A/c Bal: {currency}{balance}", 0.1),
|
| 434 |
+
("", 0.05), # No balance shown
|
| 435 |
+
])
|
| 436 |
+
|
| 437 |
+
# Warning patterns
|
| 438 |
+
self.warning = GrammarRule("WARNING", [
|
| 439 |
+
("Not you? Call {helpline}", 0.3),
|
| 440 |
+
("If not done by you, call {helpline}", 0.2),
|
| 441 |
+
("Call {helpline} for dispute", 0.2),
|
| 442 |
+
("", 0.3), # No warning
|
| 443 |
+
])
|
| 444 |
+
|
| 445 |
+
# Failed transaction patterns
|
| 446 |
+
self.failed = GrammarRule("FAILED", [
|
| 447 |
+
("Transaction of {currency}{amount} FAILED", 0.3),
|
| 448 |
+
("{currency}{amount} transaction DECLINED", 0.25),
|
| 449 |
+
("Payment of {currency}{amount} FAILED", 0.2),
|
| 450 |
+
("Transaction FAILED: {currency}{amount}", 0.15),
|
| 451 |
+
("{currency}{amount} to {vpa} DECLINED", 0.1),
|
| 452 |
+
])
|
| 453 |
+
|
| 454 |
+
# Pending patterns
|
| 455 |
+
self.pending = GrammarRule("PENDING", [
|
| 456 |
+
("{currency}{amount} debit PENDING", 0.35),
|
| 457 |
+
("Transaction of {currency}{amount} is PENDING", 0.3),
|
| 458 |
+
("{currency}{amount} payment is being processed", 0.2),
|
| 459 |
+
("Processing: {currency}{amount} to {vpa}", 0.15),
|
| 460 |
+
])
|
| 461 |
+
|
| 462 |
+
def generate_debit_message(self, params: Dict[str, str]) -> str:
|
| 463 |
+
"""Generate a debit transaction message."""
|
| 464 |
+
parts = [
|
| 465 |
+
self.bank_intro.sample(self.rng),
|
| 466 |
+
self.amount_debit.sample(self.rng),
|
| 467 |
+
self.account_phrase.sample(self.rng),
|
| 468 |
+
self.date_phrase.sample(self.rng),
|
| 469 |
+
self.party_upi.sample(self.rng) if params.get('vpa') else "",
|
| 470 |
+
self.reference.sample(self.rng),
|
| 471 |
+
self.balance.sample(self.rng),
|
| 472 |
+
self.warning.sample(self.rng),
|
| 473 |
+
]
|
| 474 |
+
|
| 475 |
+
message = " ".join(p for p in parts if p)
|
| 476 |
+
return self._apply_params(message, params)
|
| 477 |
+
|
| 478 |
+
def generate_credit_message(self, params: Dict[str, str]) -> str:
|
| 479 |
+
"""Generate a credit transaction message."""
|
| 480 |
+
parts = [
|
| 481 |
+
self.bank_intro.sample(self.rng),
|
| 482 |
+
self.amount_credit.sample(self.rng),
|
| 483 |
+
self.account_phrase.sample(self.rng),
|
| 484 |
+
self.date_phrase.sample(self.rng),
|
| 485 |
+
f"from {params.get('vpa', params.get('beneficiary', ''))}",
|
| 486 |
+
self.reference.sample(self.rng),
|
| 487 |
+
self.balance.sample(self.rng),
|
| 488 |
+
]
|
| 489 |
+
|
| 490 |
+
message = " ".join(p for p in parts if p)
|
| 491 |
+
return self._apply_params(message, params)
|
| 492 |
+
|
| 493 |
+
def generate_failed_message(self, params: Dict[str, str]) -> str:
|
| 494 |
+
"""Generate a failed transaction message."""
|
| 495 |
+
parts = [
|
| 496 |
+
self.bank_intro.sample(self.rng),
|
| 497 |
+
self.failed.sample(self.rng),
|
| 498 |
+
self.account_phrase.sample(self.rng),
|
| 499 |
+
f"Reason: {params.get('reason', 'Transaction declined')}",
|
| 500 |
+
self.reference.sample(self.rng),
|
| 501 |
+
]
|
| 502 |
+
|
| 503 |
+
message = " ".join(p for p in parts if p)
|
| 504 |
+
return self._apply_params(message, params)
|
| 505 |
+
|
| 506 |
+
def generate_pending_message(self, params: Dict[str, str]) -> str:
|
| 507 |
+
"""Generate a pending transaction message."""
|
| 508 |
+
parts = [
|
| 509 |
+
self.bank_intro.sample(self.rng),
|
| 510 |
+
self.pending.sample(self.rng),
|
| 511 |
+
f"for {params.get('vpa', '')}",
|
| 512 |
+
self.account_phrase.sample(self.rng),
|
| 513 |
+
self.reference.sample(self.rng),
|
| 514 |
+
]
|
| 515 |
+
|
| 516 |
+
message = " ".join(p for p in parts if p)
|
| 517 |
+
return self._apply_params(message, params)
|
| 518 |
+
|
| 519 |
+
def _apply_params(self, template: str, params: Dict[str, str]) -> str:
|
| 520 |
+
"""Apply parameters to template."""
|
| 521 |
+
for key, value in params.items():
|
| 522 |
+
template = template.replace(f"{{{key}}}", str(value) if value else "")
|
| 523 |
+
|
| 524 |
+
# Clean up double spaces and trailing punctuation
|
| 525 |
+
template = re.sub(r'\s+', ' ', template)
|
| 526 |
+
template = re.sub(r'\s+([.,:])', r'\1', template)
|
| 527 |
+
return template.strip()
|
| 528 |
+
|
| 529 |
+
|
| 530 |
+
# ============================================================================
|
| 531 |
+
# COMBINATORIAL GENERATOR
|
| 532 |
+
# ============================================================================
|
| 533 |
+
|
| 534 |
+
@dataclass
|
| 535 |
+
class GenerationSpace:
|
| 536 |
+
"""
|
| 537 |
+
Defines the combinatorial space for generation.
|
| 538 |
+
|
| 539 |
+
Total combinations = product of all dimension sizes
|
| 540 |
+
"""
|
| 541 |
+
banks: List[str]
|
| 542 |
+
amount_formats: List[AmountFormat]
|
| 543 |
+
date_formats: List[DateFormat]
|
| 544 |
+
currency_symbols: List[CurrencySymbol]
|
| 545 |
+
account_styles: List[str]
|
| 546 |
+
reference_types: List[str]
|
| 547 |
+
transaction_types: List[TransactionType]
|
| 548 |
+
statuses: List[TransactionStatus]
|
| 549 |
+
payment_methods: List[PaymentMethod]
|
| 550 |
+
categories: List[Category]
|
| 551 |
+
noise_levels: List[float]
|
| 552 |
+
|
| 553 |
+
@property
|
| 554 |
+
def total_combinations(self) -> int:
|
| 555 |
+
"""Calculate total theoretical combinations."""
|
| 556 |
+
return (
|
| 557 |
+
len(self.banks) *
|
| 558 |
+
len(self.amount_formats) *
|
| 559 |
+
len(self.date_formats) *
|
| 560 |
+
len(self.currency_symbols) *
|
| 561 |
+
len(self.account_styles) *
|
| 562 |
+
len(self.reference_types) *
|
| 563 |
+
len(self.transaction_types) *
|
| 564 |
+
len(self.statuses) *
|
| 565 |
+
len(self.payment_methods) *
|
| 566 |
+
len(self.categories) *
|
| 567 |
+
len(self.noise_levels)
|
| 568 |
+
)
|
| 569 |
+
|
| 570 |
+
def get_dimension_coverage(self, samples: int) -> Dict[str, float]:
|
| 571 |
+
"""Calculate coverage for each dimension given sample count."""
|
| 572 |
+
dimensions = {
|
| 573 |
+
'banks': len(self.banks),
|
| 574 |
+
'amount_formats': len(self.amount_formats),
|
| 575 |
+
'date_formats': len(self.date_formats),
|
| 576 |
+
'currency_symbols': len(self.currency_symbols),
|
| 577 |
+
'account_styles': len(self.account_styles),
|
| 578 |
+
'reference_types': len(self.reference_types),
|
| 579 |
+
'transaction_types': len(self.transaction_types),
|
| 580 |
+
'statuses': len(self.statuses),
|
| 581 |
+
'payment_methods': len(self.payment_methods),
|
| 582 |
+
'categories': len(self.categories),
|
| 583 |
+
'noise_levels': len(self.noise_levels),
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
# Expected coverage using coupon collector approximation
|
| 587 |
+
coverage = {}
|
| 588 |
+
for dim, size in dimensions.items():
|
| 589 |
+
# E[samples needed to see all] ≈ n * H_n where H_n is harmonic number
|
| 590 |
+
harmonic = sum(1/i for i in range(1, size + 1))
|
| 591 |
+
expected_needed = size * harmonic
|
| 592 |
+
coverage[dim] = min(1.0, samples / expected_needed)
|
| 593 |
+
|
| 594 |
+
return coverage
|
| 595 |
+
|
| 596 |
+
|
| 597 |
+
class EdgeCaseGenerator:
|
| 598 |
+
"""
|
| 599 |
+
Systematic edge case generation using boundary value analysis.
|
| 600 |
+
|
| 601 |
+
Categories:
|
| 602 |
+
1. Amount boundaries
|
| 603 |
+
2. Date boundaries
|
| 604 |
+
3. String boundaries
|
| 605 |
+
4. Format edge cases
|
| 606 |
+
5. Invalid inputs (for negative testing)
|
| 607 |
+
"""
|
| 608 |
+
|
| 609 |
+
# Amount edge cases
|
| 610 |
+
AMOUNT_BOUNDARIES = [
|
| 611 |
+
0.01, # Minimum meaningful
|
| 612 |
+
0.50, # Sub-rupee
|
| 613 |
+
0.99, # Just under 1
|
| 614 |
+
1.00, # Boundary
|
| 615 |
+
9.99, # Just under 10
|
| 616 |
+
10.00, # Boundary
|
| 617 |
+
99.99, # Just under 100
|
| 618 |
+
100.00, # Common boundary
|
| 619 |
+
499.99, # Just under 500
|
| 620 |
+
500.00, # Common payment
|
| 621 |
+
999.99, # Just under 1000
|
| 622 |
+
1000.00, # Thousand boundary
|
| 623 |
+
9999.99, # Just under 10K
|
| 624 |
+
10000.00, # 10K boundary
|
| 625 |
+
99999.99, # Just under 1L
|
| 626 |
+
100000.00, # Lakh boundary
|
| 627 |
+
999999.99, # Just under 10L
|
| 628 |
+
1000000.00, # 10L boundary
|
| 629 |
+
9999999.99, # Just under 1Cr
|
| 630 |
+
10000000.00, # Crore boundary
|
| 631 |
+
]
|
| 632 |
+
|
| 633 |
+
# Round numbers (common in real transactions)
|
| 634 |
+
ROUND_AMOUNTS = [
|
| 635 |
+
50, 100, 200, 250, 500, 750, 1000, 1500, 2000, 2500,
|
| 636 |
+
3000, 4000, 5000, 7500, 10000, 15000, 20000, 25000,
|
| 637 |
+
50000, 75000, 100000, 200000, 500000,
|
| 638 |
+
]
|
| 639 |
+
|
| 640 |
+
# Psychological pricing
|
| 641 |
+
PSYCHOLOGICAL_AMOUNTS = [
|
| 642 |
+
49, 99, 149, 199, 249, 299, 399, 499, 599, 699, 799, 899, 999,
|
| 643 |
+
1299, 1499, 1999, 2499, 2999, 3999, 4999, 9999,
|
| 644 |
+
]
|
| 645 |
+
|
| 646 |
+
# Date edge cases
|
| 647 |
+
DATE_EDGE_CASES = [
|
| 648 |
+
date(2024, 2, 29), # Leap year
|
| 649 |
+
date(2025, 2, 28), # Non-leap year Feb end
|
| 650 |
+
date(2025, 12, 31), # Year end
|
| 651 |
+
date(2026, 1, 1), # Year start
|
| 652 |
+
date(2025, 3, 31), # Month with 31 days
|
| 653 |
+
date(2025, 4, 30), # Month with 30 days
|
| 654 |
+
]
|
| 655 |
+
|
| 656 |
+
# Special characters in merchant names
|
| 657 |
+
SPECIAL_CHAR_MERCHANTS = [
|
| 658 |
+
"McDonald's",
|
| 659 |
+
"H&M",
|
| 660 |
+
"AT&T",
|
| 661 |
+
"L'Oreal",
|
| 662 |
+
"7-Eleven",
|
| 663 |
+
"Marks & Spencer",
|
| 664 |
+
"Johnson & Johnson",
|
| 665 |
+
"Dunkin' Donuts",
|
| 666 |
+
"Toys \"R\" Us",
|
| 667 |
+
"Yahoo!",
|
| 668 |
+
]
|
| 669 |
+
|
| 670 |
+
# Long merchant names
|
| 671 |
+
LONG_MERCHANTS = [
|
| 672 |
+
"Tata Consultancy Services Limited",
|
| 673 |
+
"Hindustan Petroleum Corporation Limited",
|
| 674 |
+
"Life Insurance Corporation of India",
|
| 675 |
+
"Indian Oil Corporation Limited",
|
| 676 |
+
"Bharat Heavy Electricals Limited",
|
| 677 |
+
"Steel Authority of India Limited",
|
| 678 |
+
]
|
| 679 |
+
|
| 680 |
+
# Unicode names (Hindi/Regional)
|
| 681 |
+
UNICODE_NAMES = [
|
| 682 |
+
"राहुल शर्मा",
|
| 683 |
+
"प्रिया सिंह",
|
| 684 |
+
"अमित कुमार",
|
| 685 |
+
"सुनीता देवी",
|
| 686 |
+
"విజయ్ కుమార్",
|
| 687 |
+
"প্রিয়া দাস",
|
| 688 |
+
"அருண் குமார்",
|
| 689 |
+
]
|
| 690 |
+
|
| 691 |
+
@classmethod
|
| 692 |
+
def get_amount_edge_cases(cls) -> List[float]:
|
| 693 |
+
"""Get all amount edge cases."""
|
| 694 |
+
return cls.AMOUNT_BOUNDARIES + cls.ROUND_AMOUNTS + cls.PSYCHOLOGICAL_AMOUNTS
|
| 695 |
+
|
| 696 |
+
@classmethod
|
| 697 |
+
def get_date_edge_cases(cls) -> List[date]:
|
| 698 |
+
"""Get all date edge cases."""
|
| 699 |
+
today = date.today()
|
| 700 |
+
return cls.DATE_EDGE_CASES + [
|
| 701 |
+
today,
|
| 702 |
+
today - timedelta(days=1),
|
| 703 |
+
today - timedelta(days=7),
|
| 704 |
+
today - timedelta(days=30),
|
| 705 |
+
today - timedelta(days=365),
|
| 706 |
+
]
|
| 707 |
+
|
| 708 |
+
@classmethod
|
| 709 |
+
def generate_edge_case_batch(cls, count: int, rng: random.Random) -> List[Dict]:
|
| 710 |
+
"""Generate a batch of edge case configurations."""
|
| 711 |
+
configs = []
|
| 712 |
+
|
| 713 |
+
# Amount edge cases
|
| 714 |
+
for amount in rng.sample(cls.AMOUNT_BOUNDARIES, min(count // 4, len(cls.AMOUNT_BOUNDARIES))):
|
| 715 |
+
configs.append({'edge_type': 'amount_boundary', 'amount': amount})
|
| 716 |
+
|
| 717 |
+
# Special character merchants
|
| 718 |
+
for merchant in cls.SPECIAL_CHAR_MERCHANTS:
|
| 719 |
+
configs.append({'edge_type': 'special_char', 'merchant': merchant})
|
| 720 |
+
|
| 721 |
+
# Long merchants
|
| 722 |
+
for merchant in cls.LONG_MERCHANTS:
|
| 723 |
+
configs.append({'edge_type': 'long_merchant', 'merchant': merchant})
|
| 724 |
+
|
| 725 |
+
# Unicode names
|
| 726 |
+
for name in cls.UNICODE_NAMES:
|
| 727 |
+
configs.append({'edge_type': 'unicode', 'beneficiary': name})
|
| 728 |
+
|
| 729 |
+
return configs[:count]
|
| 730 |
+
|
| 731 |
+
|
| 732 |
+
# ============================================================================
|
| 733 |
+
# STATISTICAL DISTRIBUTION CONTROLLER
|
| 734 |
+
# ============================================================================
|
| 735 |
+
|
| 736 |
+
class DistributionConfig:
|
| 737 |
+
"""
|
| 738 |
+
Configurable statistical distributions for realistic data.
|
| 739 |
+
|
| 740 |
+
Uses weighted random selection based on real-world frequencies.
|
| 741 |
+
"""
|
| 742 |
+
|
| 743 |
+
# Transaction type distribution
|
| 744 |
+
TRANSACTION_TYPE_WEIGHTS = {
|
| 745 |
+
TransactionType.DEBIT: 0.7,
|
| 746 |
+
TransactionType.CREDIT: 0.3,
|
| 747 |
+
}
|
| 748 |
+
|
| 749 |
+
# Status distribution
|
| 750 |
+
STATUS_WEIGHTS = {
|
| 751 |
+
TransactionStatus.SUCCESS: 0.92,
|
| 752 |
+
TransactionStatus.FAILED: 0.05,
|
| 753 |
+
TransactionStatus.PENDING: 0.02,
|
| 754 |
+
TransactionStatus.REVERSED: 0.01,
|
| 755 |
+
}
|
| 756 |
+
|
| 757 |
+
# Payment method distribution
|
| 758 |
+
PAYMENT_METHOD_WEIGHTS = {
|
| 759 |
+
PaymentMethod.UPI: 0.55,
|
| 760 |
+
PaymentMethod.CREDIT_CARD: 0.15,
|
| 761 |
+
PaymentMethod.DEBIT_CARD: 0.08,
|
| 762 |
+
PaymentMethod.NEFT: 0.08,
|
| 763 |
+
PaymentMethod.IMPS: 0.05,
|
| 764 |
+
PaymentMethod.ATM: 0.04,
|
| 765 |
+
PaymentMethod.AUTO_DEBIT: 0.03,
|
| 766 |
+
PaymentMethod.WALLET: 0.02,
|
| 767 |
+
}
|
| 768 |
+
|
| 769 |
+
# Category distribution (based on typical spending)
|
| 770 |
+
CATEGORY_WEIGHTS = {
|
| 771 |
+
Category.FOOD: 0.18,
|
| 772 |
+
Category.SHOPPING: 0.15,
|
| 773 |
+
Category.GROCERY: 0.12,
|
| 774 |
+
Category.TRANSFER: 0.12,
|
| 775 |
+
Category.BILLS: 0.10,
|
| 776 |
+
Category.TRANSPORT: 0.08,
|
| 777 |
+
Category.ENTERTAINMENT: 0.05,
|
| 778 |
+
Category.FUEL: 0.05,
|
| 779 |
+
Category.INVESTMENT: 0.04,
|
| 780 |
+
Category.HEALTHCARE: 0.03,
|
| 781 |
+
Category.TRAVEL: 0.03,
|
| 782 |
+
Category.SALARY: 0.02,
|
| 783 |
+
Category.EMI: 0.02,
|
| 784 |
+
Category.REFUND: 0.01,
|
| 785 |
+
}
|
| 786 |
+
|
| 787 |
+
# Amount distribution by category (min, max, mean, std_dev)
|
| 788 |
+
AMOUNT_DISTRIBUTIONS = {
|
| 789 |
+
Category.FOOD: (20, 3000, 350, 300),
|
| 790 |
+
Category.GROCERY: (50, 10000, 800, 600),
|
| 791 |
+
Category.SHOPPING: (100, 100000, 2500, 3000),
|
| 792 |
+
Category.TRANSPORT: (20, 5000, 250, 300),
|
| 793 |
+
Category.TRAVEL: (500, 200000, 8000, 15000),
|
| 794 |
+
Category.FUEL: (100, 10000, 1500, 1000),
|
| 795 |
+
Category.BILLS: (100, 20000, 1200, 1500),
|
| 796 |
+
Category.ENTERTAINMENT: (50, 5000, 500, 400),
|
| 797 |
+
Category.HEALTHCARE: (100, 50000, 2000, 3000),
|
| 798 |
+
Category.INVESTMENT: (500, 500000, 25000, 50000),
|
| 799 |
+
Category.TRANSFER: (100, 200000, 5000, 10000),
|
| 800 |
+
Category.SALARY: (15000, 500000, 60000, 40000),
|
| 801 |
+
Category.EMI: (1000, 100000, 15000, 12000),
|
| 802 |
+
Category.REFUND: (50, 50000, 1000, 2000),
|
| 803 |
+
}
|
| 804 |
+
|
| 805 |
+
@classmethod
|
| 806 |
+
def sample_weighted(cls, weights: Dict, rng: random.Random):
|
| 807 |
+
"""Sample from weighted distribution."""
|
| 808 |
+
items = list(weights.keys())
|
| 809 |
+
probs = list(weights.values())
|
| 810 |
+
total = sum(probs)
|
| 811 |
+
probs = [p / total for p in probs]
|
| 812 |
+
|
| 813 |
+
r = rng.random()
|
| 814 |
+
cumulative = 0
|
| 815 |
+
for item, prob in zip(items, probs):
|
| 816 |
+
cumulative += prob
|
| 817 |
+
if r <= cumulative:
|
| 818 |
+
return item
|
| 819 |
+
return items[-1]
|
| 820 |
+
|
| 821 |
+
@classmethod
|
| 822 |
+
def sample_amount(cls, category: Category, rng: random.Random) -> float:
|
| 823 |
+
"""Sample amount based on category distribution."""
|
| 824 |
+
min_amt, max_amt, mean, std = cls.AMOUNT_DISTRIBUTIONS.get(
|
| 825 |
+
category, (100, 10000, 1000, 1000)
|
| 826 |
+
)
|
| 827 |
+
|
| 828 |
+
# Use truncated normal distribution
|
| 829 |
+
amount = rng.gauss(mean, std)
|
| 830 |
+
amount = max(min_amt, min(max_amt, amount))
|
| 831 |
+
|
| 832 |
+
# Round to realistic precision
|
| 833 |
+
if amount < 100:
|
| 834 |
+
amount = round(amount, 2)
|
| 835 |
+
elif amount < 1000:
|
| 836 |
+
amount = round(amount, 1)
|
| 837 |
+
else:
|
| 838 |
+
amount = round(amount, 0)
|
| 839 |
+
|
| 840 |
+
return amount
|
| 841 |
+
|
| 842 |
+
|
| 843 |
+
# ============================================================================
|
| 844 |
+
# NOISE INJECTION ENGINE
|
| 845 |
+
# ============================================================================
|
| 846 |
+
|
| 847 |
+
class NoiseEngine:
|
| 848 |
+
"""
|
| 849 |
+
Systematic noise injection for realistic messages.
|
| 850 |
+
|
| 851 |
+
Noise types:
|
| 852 |
+
1. Spacing variations
|
| 853 |
+
2. Abbreviations
|
| 854 |
+
3. Case changes
|
| 855 |
+
4. Truncation
|
| 856 |
+
5. Typos
|
| 857 |
+
6. Punctuation variations
|
| 858 |
+
"""
|
| 859 |
+
|
| 860 |
+
ABBREVIATIONS = {
|
| 861 |
+
'Account': ['A/c', 'Acc', 'Acct', 'a/c'],
|
| 862 |
+
'Reference': ['Ref', 'Ref.', 'ref'],
|
| 863 |
+
'Transaction': ['Txn', 'txn', 'Trans'],
|
| 864 |
+
'Available': ['Avl', 'Avail', 'avl'],
|
| 865 |
+
'Balance': ['Bal', 'bal', 'Bal.'],
|
| 866 |
+
'Credited': ['Cr', 'cr', 'Cr.'],
|
| 867 |
+
'Debited': ['Dr', 'dr', 'Dr.'],
|
| 868 |
+
'Number': ['No', 'No.', 'Num'],
|
| 869 |
+
}
|
| 870 |
+
|
| 871 |
+
COMMON_TYPOS = {
|
| 872 |
+
'debited': ['debitd', 'debite', 'debitted'],
|
| 873 |
+
'credited': ['creditd', 'credite', 'creditted'],
|
| 874 |
+
'transaction': ['transction', 'transcation', 'transacton'],
|
| 875 |
+
'available': ['availble', 'avialable', 'availabel'],
|
| 876 |
+
'balance': ['balace', 'balnce', 'balanc'],
|
| 877 |
+
}
|
| 878 |
+
|
| 879 |
+
def __init__(self, seed: int = 42):
|
| 880 |
+
self.rng = random.Random(seed)
|
| 881 |
+
|
| 882 |
+
def apply(self, text: str, noise_level: float) -> str:
|
| 883 |
+
"""
|
| 884 |
+
Apply noise to text.
|
| 885 |
+
|
| 886 |
+
Args:
|
| 887 |
+
text: Original text
|
| 888 |
+
noise_level: 0.0 (clean) to 1.0 (very noisy)
|
| 889 |
+
"""
|
| 890 |
+
if noise_level <= 0:
|
| 891 |
+
return text
|
| 892 |
+
|
| 893 |
+
# Determine which noise types to apply
|
| 894 |
+
noise_budget = noise_level
|
| 895 |
+
|
| 896 |
+
# 1. Abbreviations (always some)
|
| 897 |
+
if self.rng.random() < noise_budget * 0.5:
|
| 898 |
+
text = self._apply_abbreviations(text)
|
| 899 |
+
|
| 900 |
+
# 2. Spacing
|
| 901 |
+
if self.rng.random() < noise_budget * 0.3:
|
| 902 |
+
text = self._vary_spacing(text)
|
| 903 |
+
|
| 904 |
+
# 3. Case changes
|
| 905 |
+
if self.rng.random() < noise_budget * 0.2:
|
| 906 |
+
text = self._vary_case(text)
|
| 907 |
+
|
| 908 |
+
# 4. Truncation (for SMS)
|
| 909 |
+
if self.rng.random() < noise_budget * 0.1:
|
| 910 |
+
text = self._truncate(text)
|
| 911 |
+
|
| 912 |
+
# 5. Typos (rare)
|
| 913 |
+
if self.rng.random() < noise_budget * 0.05:
|
| 914 |
+
text = self._add_typos(text)
|
| 915 |
+
|
| 916 |
+
# 6. Punctuation
|
| 917 |
+
if self.rng.random() < noise_budget * 0.2:
|
| 918 |
+
text = self._vary_punctuation(text)
|
| 919 |
+
|
| 920 |
+
return text
|
| 921 |
+
|
| 922 |
+
def _apply_abbreviations(self, text: str) -> str:
|
| 923 |
+
"""Replace words with abbreviations."""
|
| 924 |
+
for word, abbrevs in self.ABBREVIATIONS.items():
|
| 925 |
+
if word in text and self.rng.random() < 0.5:
|
| 926 |
+
text = text.replace(word, self.rng.choice(abbrevs), 1)
|
| 927 |
+
return text
|
| 928 |
+
|
| 929 |
+
def _vary_spacing(self, text: str) -> str:
|
| 930 |
+
"""Vary spacing."""
|
| 931 |
+
variations = [
|
| 932 |
+
(". ", "."),
|
| 933 |
+
(": ", ":"),
|
| 934 |
+
("Rs. ", "Rs."),
|
| 935 |
+
("Rs ", "Rs"),
|
| 936 |
+
(", ", ","),
|
| 937 |
+
]
|
| 938 |
+
for old, new in variations:
|
| 939 |
+
if self.rng.random() < 0.3:
|
| 940 |
+
text = text.replace(old, new)
|
| 941 |
+
return text
|
| 942 |
+
|
| 943 |
+
def _vary_case(self, text: str) -> str:
|
| 944 |
+
"""Vary case."""
|
| 945 |
+
if self.rng.random() < 0.3:
|
| 946 |
+
return text.upper()
|
| 947 |
+
elif self.rng.random() < 0.1:
|
| 948 |
+
return text.lower()
|
| 949 |
+
return text
|
| 950 |
+
|
| 951 |
+
def _truncate(self, text: str) -> str:
|
| 952 |
+
"""Truncate to SMS limit."""
|
| 953 |
+
if len(text) > 160:
|
| 954 |
+
return text[:157] + "..."
|
| 955 |
+
return text
|
| 956 |
+
|
| 957 |
+
def _add_typos(self, text: str) -> str:
|
| 958 |
+
"""Add occasional typos."""
|
| 959 |
+
for word, typos in self.COMMON_TYPOS.items():
|
| 960 |
+
if word in text.lower() and self.rng.random() < 0.2:
|
| 961 |
+
# Case-insensitive replace
|
| 962 |
+
pattern = re.compile(re.escape(word), re.IGNORECASE)
|
| 963 |
+
text = pattern.sub(self.rng.choice(typos), text, count=1)
|
| 964 |
+
return text
|
| 965 |
+
|
| 966 |
+
def _vary_punctuation(self, text: str) -> str:
|
| 967 |
+
"""Vary punctuation."""
|
| 968 |
+
if self.rng.random() < 0.3:
|
| 969 |
+
text = text.replace(".", "")
|
| 970 |
+
if self.rng.random() < 0.2:
|
| 971 |
+
text = text.replace(",", "")
|
| 972 |
+
return text
|
| 973 |
+
|
| 974 |
+
|
| 975 |
+
# ============================================================================
|
| 976 |
+
# MERCHANT & COUNTERPARTY DATABASE
|
| 977 |
+
# ============================================================================
|
| 978 |
+
|
| 979 |
+
@dataclass
|
| 980 |
+
class Merchant:
|
| 981 |
+
"""Merchant entity with VPA and metadata."""
|
| 982 |
+
name: str
|
| 983 |
+
vpa: str
|
| 984 |
+
category: Category
|
| 985 |
+
aliases: List[str] = field(default_factory=list)
|
| 986 |
+
|
| 987 |
+
def get_display_name(self, rng: random.Random) -> str:
|
| 988 |
+
"""Get a random display name variation."""
|
| 989 |
+
options = [self.name] + self.aliases
|
| 990 |
+
return rng.choice(options)
|
| 991 |
+
|
| 992 |
+
|
| 993 |
+
class MerchantDatabase:
|
| 994 |
+
"""
|
| 995 |
+
Database of merchants organized by category.
|
| 996 |
+
|
| 997 |
+
Provides O(1) lookup by category and O(1) random selection.
|
| 998 |
+
"""
|
| 999 |
+
|
| 1000 |
+
def __init__(self):
|
| 1001 |
+
self._by_category: Dict[Category, List[Merchant]] = defaultdict(list)
|
| 1002 |
+
self._by_name: Dict[str, Merchant] = {}
|
| 1003 |
+
self._all: List[Merchant] = []
|
| 1004 |
+
self._build_database()
|
| 1005 |
+
|
| 1006 |
+
def _build_database(self):
|
| 1007 |
+
"""Build the merchant database."""
|
| 1008 |
+
merchants_data = [
|
| 1009 |
+
# Food Delivery
|
| 1010 |
+
("Swiggy", "swiggy@ybl", Category.FOOD, ["SWIGGY", "Swiggy Instamart"]),
|
| 1011 |
+
("Zomato", "zomato@paytm", Category.FOOD, ["ZOMATO", "Zomato Gold"]),
|
| 1012 |
+
("Dominos", "dominos@hdfcbank", Category.FOOD, ["DOMINOS", "Domino's Pizza"]),
|
| 1013 |
+
("Pizza Hut", "pizzahut@icici", Category.FOOD, ["PIZZA HUT"]),
|
| 1014 |
+
("McDonalds", "mcdonalds@ybl", Category.FOOD, ["McDonald's", "MCD"]),
|
| 1015 |
+
("KFC", "kfc@paytm", Category.FOOD, ["KFC"]),
|
| 1016 |
+
("Starbucks", "starbucks@icici", Category.FOOD, ["STARBUCKS"]),
|
| 1017 |
+
("Subway", "subway@hdfcbank", Category.FOOD, ["SUBWAY"]),
|
| 1018 |
+
|
| 1019 |
+
# E-commerce
|
| 1020 |
+
("Amazon", "amazon@apl", Category.SHOPPING, ["AMAZON", "Amazon.in"]),
|
| 1021 |
+
("Flipkart", "flipkart@ybl", Category.SHOPPING, ["FLIPKART"]),
|
| 1022 |
+
("Myntra", "myntra@ybl", Category.SHOPPING, ["MYNTRA"]),
|
| 1023 |
+
("Ajio", "ajio@icici", Category.SHOPPING, ["AJIO"]),
|
| 1024 |
+
("Nykaa", "nykaa@paytm", Category.SHOPPING, ["NYKAA"]),
|
| 1025 |
+
("Meesho", "meesho@paytm", Category.SHOPPING, ["MEESHO"]),
|
| 1026 |
+
("Croma", "croma@hdfcbank", Category.SHOPPING, ["CROMA"]),
|
| 1027 |
+
|
| 1028 |
+
# Grocery
|
| 1029 |
+
("Zepto", "zepto@ybl", Category.GROCERY, ["ZEPTO"]),
|
| 1030 |
+
("BigBasket", "bigbasket@ybl", Category.GROCERY, ["BIGBASKET"]),
|
| 1031 |
+
("Blinkit", "blinkit@paytm", Category.GROCERY, ["BLINKIT", "Grofers"]),
|
| 1032 |
+
("DMart", "dmart@hdfcbank", Category.GROCERY, ["DMART", "D-Mart"]),
|
| 1033 |
+
("JioMart", "jiomart@icici", Category.GROCERY, ["JIOMART"]),
|
| 1034 |
+
|
| 1035 |
+
# Transport
|
| 1036 |
+
("Uber", "uber@paytm", Category.TRANSPORT, ["UBER"]),
|
| 1037 |
+
("Ola", "ola@icici", Category.TRANSPORT, ["OLA", "Ola Cabs"]),
|
| 1038 |
+
("Rapido", "rapido@ybl", Category.TRANSPORT, ["RAPIDO"]),
|
| 1039 |
+
|
| 1040 |
+
# Travel
|
| 1041 |
+
("IRCTC", "irctc@sbi", Category.TRAVEL, ["IRCTC"]),
|
| 1042 |
+
("MakeMyTrip", "makemytrip@icici", Category.TRAVEL, ["MMT"]),
|
| 1043 |
+
("Goibibo", "goibibo@ybl", Category.TRAVEL, ["GOIBIBO"]),
|
| 1044 |
+
("RedBus", "redbus@paytm", Category.TRAVEL, ["REDBUS"]),
|
| 1045 |
+
|
| 1046 |
+
# Fuel
|
| 1047 |
+
("IOCL", "iocl@sbi", Category.FUEL, ["Indian Oil", "IndianOil"]),
|
| 1048 |
+
("HPCL", "hpcl@hdfcbank", Category.FUEL, ["HP Petrol"]),
|
| 1049 |
+
("BPCL", "bpcl@icici", Category.FUEL, ["Bharat Petroleum"]),
|
| 1050 |
+
|
| 1051 |
+
# Bills
|
| 1052 |
+
("Airtel", "airtel@paytm", Category.BILLS, ["AIRTEL"]),
|
| 1053 |
+
("Jio", "jio@icici", Category.BILLS, ["Reliance Jio"]),
|
| 1054 |
+
("Vi", "vi@ybl", Category.BILLS, ["Vodafone Idea"]),
|
| 1055 |
+
("Tata Power", "tatapower@hdfcbank", Category.BILLS, ["TATA POWER"]),
|
| 1056 |
+
("BESCOM", "bescom@ybl", Category.BILLS, ["BESCOM"]),
|
| 1057 |
+
|
| 1058 |
+
# Entertainment
|
| 1059 |
+
("Netflix", "netflix@icici", Category.ENTERTAINMENT, ["NETFLIX"]),
|
| 1060 |
+
("Hotstar", "hotstar@ybl", Category.ENTERTAINMENT, ["Disney+ Hotstar"]),
|
| 1061 |
+
("Spotify", "spotify@paytm", Category.ENTERTAINMENT, ["SPOTIFY"]),
|
| 1062 |
+
("BookMyShow", "bookmyshow@paytm", Category.ENTERTAINMENT, ["BMS"]),
|
| 1063 |
+
("PVR", "pvr@hdfcbank", Category.ENTERTAINMENT, ["PVR Cinemas"]),
|
| 1064 |
+
|
| 1065 |
+
# Healthcare
|
| 1066 |
+
("Apollo", "apollo@hdfcbank", Category.HEALTHCARE, ["Apollo Pharmacy"]),
|
| 1067 |
+
("PharmEasy", "pharmeasy@paytm", Category.HEALTHCARE, ["PHARMEASY"]),
|
| 1068 |
+
("1mg", "1mg@ybl", Category.HEALTHCARE, ["Tata 1mg"]),
|
| 1069 |
+
|
| 1070 |
+
# Investment
|
| 1071 |
+
("Zerodha", "zerodha@hdfcbank", Category.INVESTMENT, ["ZERODHA"]),
|
| 1072 |
+
("Groww", "groww@axisbank", Category.INVESTMENT, ["GROWW"]),
|
| 1073 |
+
("Upstox", "upstox@icici", Category.INVESTMENT, ["UPSTOX"]),
|
| 1074 |
+
("Angel One", "angelone@ybl", Category.INVESTMENT, ["Angel Broking"]),
|
| 1075 |
+
("ICICI Direct", "icicidirect@icici", Category.INVESTMENT, ["ICICIdirect"]),
|
| 1076 |
+
("Paytm Money", "paytmmoney@paytm", Category.INVESTMENT, ["PAYTM MONEY"]),
|
| 1077 |
+
("5Paisa", "5paisa@icici", Category.INVESTMENT, ["5paisa"]),
|
| 1078 |
+
("Dhan", "dhan@okaxis", Category.INVESTMENT, ["DHAN"]),
|
| 1079 |
+
]
|
| 1080 |
+
|
| 1081 |
+
for name, vpa, category, aliases in merchants_data:
|
| 1082 |
+
merchant = Merchant(name=name, vpa=vpa, category=category, aliases=aliases)
|
| 1083 |
+
self._by_category[category].append(merchant)
|
| 1084 |
+
self._by_name[name.lower()] = merchant
|
| 1085 |
+
self._all.append(merchant)
|
| 1086 |
+
|
| 1087 |
+
def get_by_category(self, category: Category) -> List[Merchant]:
|
| 1088 |
+
"""Get all merchants in a category."""
|
| 1089 |
+
return self._by_category.get(category, [])
|
| 1090 |
+
|
| 1091 |
+
def get_random(self, rng: random.Random, category: Optional[Category] = None) -> Merchant:
|
| 1092 |
+
"""Get a random merchant, optionally filtered by category."""
|
| 1093 |
+
if category:
|
| 1094 |
+
merchants = self._by_category.get(category, self._all)
|
| 1095 |
+
else:
|
| 1096 |
+
merchants = self._all
|
| 1097 |
+
|
| 1098 |
+
return rng.choice(merchants) if merchants else self._all[0]
|
| 1099 |
+
|
| 1100 |
+
def get_by_name(self, name: str) -> Optional[Merchant]:
|
| 1101 |
+
"""Lookup merchant by name."""
|
| 1102 |
+
return self._by_name.get(name.lower())
|
| 1103 |
+
|
| 1104 |
+
|
| 1105 |
+
class PersonDatabase:
|
| 1106 |
+
"""Database of Indian names for P2P transactions."""
|
| 1107 |
+
|
| 1108 |
+
FIRST_NAMES = [
|
| 1109 |
+
"Rahul", "Priya", "Amit", "Neha", "Vijay", "Deepak", "Anjali", "Rajesh",
|
| 1110 |
+
"Sunita", "Arun", "Pooja", "Sanjay", "Kavita", "Manoj", "Rekha", "Suresh",
|
| 1111 |
+
"Lakshmi", "Ganesh", "Meera", "Prakash", "Asha", "Ramesh", "Geeta", "Mohan",
|
| 1112 |
+
"Savita", "Kiran", "Vinod", "Usha", "Ashok", "Padma", "Rohit", "Sneha",
|
| 1113 |
+
"Vikas", "Divya", "Nitin", "Swati", "Abhishek", "Ritu", "Manish", "Preeti",
|
| 1114 |
+
]
|
| 1115 |
+
|
| 1116 |
+
LAST_NAMES = [
|
| 1117 |
+
"Sharma", "Singh", "Kumar", "Gupta", "Patel", "Verma", "Mehta", "Nair",
|
| 1118 |
+
"Iyer", "Joshi", "Reddy", "Mishra", "Das", "Pillai", "Bose", "Menon",
|
| 1119 |
+
"Venkat", "Rao", "Kulkarni", "Shah", "Patil", "Chandra", "Devi", "Lal",
|
| 1120 |
+
"Sinha", "Chopra", "Saxena", "Rani", "Tiwari", "Hegde", "Agarwal", "Kapoor",
|
| 1121 |
+
"Yadav", "Bansal", "Jain", "Pandey", "Malhotra", "Behera", "Sahu", "Tarai",
|
| 1122 |
+
]
|
| 1123 |
+
|
| 1124 |
+
VPA_SUFFIXES = [
|
| 1125 |
+
"@ybl", "@paytm", "@okicici", "@okhdfcbank", "@oksbi",
|
| 1126 |
+
"@axl", "@apl", "@ibl", "@upi", "@okaxis",
|
| 1127 |
+
]
|
| 1128 |
+
|
| 1129 |
+
@classmethod
|
| 1130 |
+
def generate_name(cls, rng: random.Random) -> str:
|
| 1131 |
+
"""Generate a random Indian name."""
|
| 1132 |
+
return f"{rng.choice(cls.FIRST_NAMES)} {rng.choice(cls.LAST_NAMES)}"
|
| 1133 |
+
|
| 1134 |
+
@classmethod
|
| 1135 |
+
def generate_vpa(cls, name: str, rng: random.Random) -> str:
|
| 1136 |
+
"""Generate VPA from name."""
|
| 1137 |
+
parts = name.lower().split()
|
| 1138 |
+
patterns = [
|
| 1139 |
+
f"{parts[0]}{rng.randint(1, 99)}{rng.choice(cls.VPA_SUFFIXES)}",
|
| 1140 |
+
f"{parts[0]}.{parts[-1]}{rng.choice(cls.VPA_SUFFIXES)}",
|
| 1141 |
+
f"{parts[0]}{parts[-1][0]}{rng.randint(1, 9)}{rng.choice(cls.VPA_SUFFIXES)}",
|
| 1142 |
+
f"{parts[0]}_{rng.randint(100, 999)}{rng.choice(cls.VPA_SUFFIXES)}",
|
| 1143 |
+
]
|
| 1144 |
+
return rng.choice(patterns)
|
| 1145 |
+
|
| 1146 |
+
|
| 1147 |
+
# ============================================================================
|
| 1148 |
+
# BANK DATABASE
|
| 1149 |
+
# ============================================================================
|
| 1150 |
+
|
| 1151 |
+
@dataclass
|
| 1152 |
+
class Bank:
|
| 1153 |
+
"""Bank entity with metadata."""
|
| 1154 |
+
name: str
|
| 1155 |
+
code: str
|
| 1156 |
+
helpline: str
|
| 1157 |
+
|
| 1158 |
+
def get_intro(self, rng: random.Random) -> str:
|
| 1159 |
+
"""Get bank introduction variation."""
|
| 1160 |
+
intros = [
|
| 1161 |
+
f"{self.name}:",
|
| 1162 |
+
f"{self.name} Bank:",
|
| 1163 |
+
f"{self.name} Bk:",
|
| 1164 |
+
f"Alert: {self.name}",
|
| 1165 |
+
f"Dear Customer, {self.name}:",
|
| 1166 |
+
]
|
| 1167 |
+
return rng.choice(intros)
|
| 1168 |
+
|
| 1169 |
+
|
| 1170 |
+
class BankDatabase:
|
| 1171 |
+
"""Database of Indian banks."""
|
| 1172 |
+
|
| 1173 |
+
BANKS = [
|
| 1174 |
+
Bank("HDFC", "HDFC", "18002586161"),
|
| 1175 |
+
Bank("ICICI", "ICIC", "18002662"),
|
| 1176 |
+
Bank("SBI", "SBIN", "1800112211"),
|
| 1177 |
+
Bank("Axis", "UTIB", "18004195959"),
|
| 1178 |
+
Bank("Kotak", "KKBK", "18601266022"),
|
| 1179 |
+
Bank("PNB", "PUNB", "18001802222"),
|
| 1180 |
+
Bank("BOB", "BARB", "18001024455"),
|
| 1181 |
+
Bank("IDFC", "IDFB", "18001024"),
|
| 1182 |
+
Bank("Yes Bank", "YESB", "18001200"),
|
| 1183 |
+
Bank("IndusInd", "INDB", "18602677777"),
|
| 1184 |
+
Bank("Canara", "CNRB", "18004250018"),
|
| 1185 |
+
Bank("Union Bank", "UBIN", "18002082244"),
|
| 1186 |
+
]
|
| 1187 |
+
|
| 1188 |
+
@classmethod
|
| 1189 |
+
def get_all(cls) -> List[Bank]:
|
| 1190 |
+
return cls.BANKS
|
| 1191 |
+
|
| 1192 |
+
@classmethod
|
| 1193 |
+
def get_random(cls, rng: random.Random) -> Bank:
|
| 1194 |
+
return rng.choice(cls.BANKS)
|
| 1195 |
+
|
| 1196 |
+
@classmethod
|
| 1197 |
+
def get_by_name(cls, name: str) -> Optional[Bank]:
|
| 1198 |
+
for bank in cls.BANKS:
|
| 1199 |
+
if bank.name.lower() == name.lower():
|
| 1200 |
+
return bank
|
| 1201 |
+
return None
|
| 1202 |
+
|
| 1203 |
+
|
| 1204 |
+
# ============================================================================
|
| 1205 |
+
# GROUND TRUTH SCHEMA
|
| 1206 |
+
# ============================================================================
|
| 1207 |
+
|
| 1208 |
+
@dataclass
|
| 1209 |
+
class GroundTruth:
|
| 1210 |
+
"""
|
| 1211 |
+
Complete ground truth for training.
|
| 1212 |
+
|
| 1213 |
+
Includes all extractable fields with both raw and normalized versions.
|
| 1214 |
+
"""
|
| 1215 |
+
# Core
|
| 1216 |
+
amount: Optional[float] = None
|
| 1217 |
+
amount_raw: Optional[str] = None
|
| 1218 |
+
currency: str = "INR"
|
| 1219 |
+
type: Optional[str] = None
|
| 1220 |
+
status: str = "success"
|
| 1221 |
+
|
| 1222 |
+
# Account
|
| 1223 |
+
account: Optional[str] = None
|
| 1224 |
+
account_raw: Optional[str] = None
|
| 1225 |
+
bank: Optional[str] = None
|
| 1226 |
+
|
| 1227 |
+
# DateTime
|
| 1228 |
+
date: Optional[str] = None # YYYY-MM-DD
|
| 1229 |
+
date_raw: Optional[str] = None
|
| 1230 |
+
time: Optional[str] = None # HH:MM:SS
|
| 1231 |
+
time_raw: Optional[str] = None
|
| 1232 |
+
|
| 1233 |
+
# Reference
|
| 1234 |
+
reference: Optional[str] = None
|
| 1235 |
+
reference_raw: Optional[str] = None
|
| 1236 |
+
reference_type: Optional[str] = None
|
| 1237 |
+
|
| 1238 |
+
# Counterparty
|
| 1239 |
+
merchant: Optional[str] = None
|
| 1240 |
+
merchant_raw: Optional[str] = None
|
| 1241 |
+
vpa: Optional[str] = None
|
| 1242 |
+
beneficiary: Optional[str] = None
|
| 1243 |
+
|
| 1244 |
+
# Classification
|
| 1245 |
+
payment_method: Optional[str] = None
|
| 1246 |
+
category: Optional[str] = None
|
| 1247 |
+
|
| 1248 |
+
# Balance
|
| 1249 |
+
balance_after: Optional[float] = None
|
| 1250 |
+
balance_raw: Optional[str] = None
|
| 1251 |
+
|
| 1252 |
+
# Metadata
|
| 1253 |
+
message_type: str = "transaction"
|
| 1254 |
+
is_p2m: bool = False
|
| 1255 |
+
|
| 1256 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 1257 |
+
"""Convert to dict, excluding None values."""
|
| 1258 |
+
return {k: v for k, v in asdict(self).items() if v is not None}
|
| 1259 |
+
|
| 1260 |
+
def to_json(self) -> str:
|
| 1261 |
+
"""Convert to JSON string."""
|
| 1262 |
+
return json.dumps(self.to_dict(), ensure_ascii=False)
|
| 1263 |
+
|
| 1264 |
+
|
| 1265 |
+
# ============================================================================
|
| 1266 |
+
# MAIN TRANSACTION GENERATOR
|
| 1267 |
+
# ============================================================================
|
| 1268 |
+
|
| 1269 |
+
class TransactionGenerator:
|
| 1270 |
+
"""
|
| 1271 |
+
Main generator class that orchestrates all components.
|
| 1272 |
+
|
| 1273 |
+
Architecture:
|
| 1274 |
+
1. Sample from statistical distributions
|
| 1275 |
+
2. Generate using grammar rules
|
| 1276 |
+
3. Apply noise injection
|
| 1277 |
+
4. Validate output
|
| 1278 |
+
"""
|
| 1279 |
+
|
| 1280 |
+
def __init__(self, seed: int = 42):
|
| 1281 |
+
self.seed = seed
|
| 1282 |
+
self.rng = random.Random(seed)
|
| 1283 |
+
|
| 1284 |
+
# Initialize components
|
| 1285 |
+
self.grammar = MessageGrammar(seed)
|
| 1286 |
+
self.noise = NoiseEngine(seed)
|
| 1287 |
+
self.merchants = MerchantDatabase()
|
| 1288 |
+
self.distribution = DistributionConfig()
|
| 1289 |
+
|
| 1290 |
+
def generate_transaction(
|
| 1291 |
+
self,
|
| 1292 |
+
txn_type: Optional[TransactionType] = None,
|
| 1293 |
+
status: Optional[TransactionStatus] = None,
|
| 1294 |
+
category: Optional[Category] = None,
|
| 1295 |
+
payment_method: Optional[PaymentMethod] = None,
|
| 1296 |
+
noise_level: float = 0.3,
|
| 1297 |
+
) -> Dict[str, Any]:
|
| 1298 |
+
"""
|
| 1299 |
+
Generate a single transaction.
|
| 1300 |
+
|
| 1301 |
+
Args:
|
| 1302 |
+
txn_type: Force transaction type (or sample)
|
| 1303 |
+
status: Force status (or sample)
|
| 1304 |
+
category: Force category (or sample)
|
| 1305 |
+
payment_method: Force payment method (or sample)
|
| 1306 |
+
noise_level: Amount of noise to inject
|
| 1307 |
+
|
| 1308 |
+
Returns:
|
| 1309 |
+
Dict with 'text' and 'ground_truth' keys
|
| 1310 |
+
"""
|
| 1311 |
+
# Sample missing parameters from distributions
|
| 1312 |
+
if txn_type is None:
|
| 1313 |
+
txn_type = self.distribution.sample_weighted(
|
| 1314 |
+
self.distribution.TRANSACTION_TYPE_WEIGHTS, self.rng
|
| 1315 |
+
)
|
| 1316 |
+
|
| 1317 |
+
if status is None:
|
| 1318 |
+
status = self.distribution.sample_weighted(
|
| 1319 |
+
self.distribution.STATUS_WEIGHTS, self.rng
|
| 1320 |
+
)
|
| 1321 |
+
|
| 1322 |
+
if category is None:
|
| 1323 |
+
category = self.distribution.sample_weighted(
|
| 1324 |
+
self.distribution.CATEGORY_WEIGHTS, self.rng
|
| 1325 |
+
)
|
| 1326 |
+
|
| 1327 |
+
if payment_method is None:
|
| 1328 |
+
payment_method = self.distribution.sample_weighted(
|
| 1329 |
+
self.distribution.PAYMENT_METHOD_WEIGHTS, self.rng
|
| 1330 |
+
)
|
| 1331 |
+
|
| 1332 |
+
# Generate components
|
| 1333 |
+
bank = BankDatabase.get_random(self.rng)
|
| 1334 |
+
amount = self.distribution.sample_amount(category, self.rng)
|
| 1335 |
+
amount_obj = Amount.from_float(amount)
|
| 1336 |
+
amount_fmt = self.rng.choice(list(AmountFormat))
|
| 1337 |
+
amount_raw = amount_obj.format(amount_fmt)
|
| 1338 |
+
|
| 1339 |
+
currency = self.rng.choice(list(CurrencySymbol))
|
| 1340 |
+
|
| 1341 |
+
account = Account(str(self.rng.randint(1000, 9999)))
|
| 1342 |
+
account_style = self.rng.choice(["XX", "xx", "****", "X"])
|
| 1343 |
+
|
| 1344 |
+
# Date
|
| 1345 |
+
days_ago = self.rng.randint(0, 365)
|
| 1346 |
+
txn_date = date.today() - timedelta(days=days_ago)
|
| 1347 |
+
time_tuple = (
|
| 1348 |
+
self.rng.randint(0, 23),
|
| 1349 |
+
self.rng.randint(0, 59),
|
| 1350 |
+
self.rng.randint(0, 59)
|
| 1351 |
+
) if self.rng.random() > 0.5 else None
|
| 1352 |
+
date_obj = TransactionDate(txn_date, time_tuple)
|
| 1353 |
+
date_fmt = self.rng.choice(list(DateFormat))
|
| 1354 |
+
|
| 1355 |
+
# Reference
|
| 1356 |
+
ref_type = self.rng.choice(["numeric", "alphanumeric", "utr"])
|
| 1357 |
+
ref = Reference.generate(ref_type, bank.code)
|
| 1358 |
+
|
| 1359 |
+
# Balance
|
| 1360 |
+
balance = round(self.rng.uniform(1000, 500000), 2)
|
| 1361 |
+
balance_fmt = self.rng.choice([AmountFormat.COMMA_INTERNATIONAL, AmountFormat.DECIMAL_2])
|
| 1362 |
+
balance_raw = Amount.from_float(balance).format(balance_fmt)
|
| 1363 |
+
|
| 1364 |
+
# Counterparty
|
| 1365 |
+
is_p2m = category not in [Category.TRANSFER, Category.SALARY]
|
| 1366 |
+
|
| 1367 |
+
if is_p2m:
|
| 1368 |
+
merchant = self.merchants.get_random(self.rng, category)
|
| 1369 |
+
vpa = merchant.vpa
|
| 1370 |
+
merchant_raw = merchant.get_display_name(self.rng)
|
| 1371 |
+
beneficiary = None
|
| 1372 |
+
else:
|
| 1373 |
+
merchant = None
|
| 1374 |
+
name = PersonDatabase.generate_name(self.rng)
|
| 1375 |
+
vpa = PersonDatabase.generate_vpa(name, self.rng)
|
| 1376 |
+
merchant_raw = None
|
| 1377 |
+
beneficiary = name
|
| 1378 |
+
|
| 1379 |
+
# Build parameters for grammar
|
| 1380 |
+
params = {
|
| 1381 |
+
'bank': bank.name,
|
| 1382 |
+
'currency': currency.value,
|
| 1383 |
+
'amount': amount_raw,
|
| 1384 |
+
'account': account.format(account_style),
|
| 1385 |
+
'date': date_obj.format_date(date_fmt),
|
| 1386 |
+
'time': date_obj.format_time() or "",
|
| 1387 |
+
'vpa': vpa,
|
| 1388 |
+
'beneficiary': beneficiary or (merchant.name if merchant else ""),
|
| 1389 |
+
'ref': ref.value,
|
| 1390 |
+
'balance': balance_raw,
|
| 1391 |
+
'helpline': bank.helpline,
|
| 1392 |
+
'reason': self.rng.choice([
|
| 1393 |
+
"Insufficient funds",
|
| 1394 |
+
"Transaction declined",
|
| 1395 |
+
"Network error",
|
| 1396 |
+
"Invalid VPA",
|
| 1397 |
+
]) if status == TransactionStatus.FAILED else "",
|
| 1398 |
+
}
|
| 1399 |
+
|
| 1400 |
+
# Generate message using grammar
|
| 1401 |
+
if status == TransactionStatus.FAILED:
|
| 1402 |
+
text = self.grammar.generate_failed_message(params)
|
| 1403 |
+
elif status == TransactionStatus.PENDING:
|
| 1404 |
+
text = self.grammar.generate_pending_message(params)
|
| 1405 |
+
elif txn_type == TransactionType.CREDIT:
|
| 1406 |
+
text = self.grammar.generate_credit_message(params)
|
| 1407 |
+
else:
|
| 1408 |
+
text = self.grammar.generate_debit_message(params)
|
| 1409 |
+
|
| 1410 |
+
# Apply noise
|
| 1411 |
+
text = self.noise.apply(text, noise_level)
|
| 1412 |
+
|
| 1413 |
+
# Build ground truth
|
| 1414 |
+
ground_truth = GroundTruth(
|
| 1415 |
+
amount=amount,
|
| 1416 |
+
amount_raw=amount_raw,
|
| 1417 |
+
currency="INR",
|
| 1418 |
+
type=txn_type.value,
|
| 1419 |
+
status=status.value,
|
| 1420 |
+
account=account.last_four,
|
| 1421 |
+
account_raw=account.format(account_style),
|
| 1422 |
+
bank=bank.name,
|
| 1423 |
+
date=date_obj.normalized(),
|
| 1424 |
+
date_raw=date_obj.format_date(date_fmt),
|
| 1425 |
+
time=f"{time_tuple[0]:02d}:{time_tuple[1]:02d}:{time_tuple[2]:02d}" if time_tuple else None,
|
| 1426 |
+
time_raw=date_obj.format_time(),
|
| 1427 |
+
reference=ref.value,
|
| 1428 |
+
reference_type=ref_type,
|
| 1429 |
+
merchant=merchant.name.lower() if merchant else None,
|
| 1430 |
+
merchant_raw=merchant_raw,
|
| 1431 |
+
vpa=vpa,
|
| 1432 |
+
beneficiary=beneficiary,
|
| 1433 |
+
payment_method=payment_method.value,
|
| 1434 |
+
category=category.value,
|
| 1435 |
+
balance_after=balance,
|
| 1436 |
+
balance_raw=balance_raw,
|
| 1437 |
+
message_type="transaction",
|
| 1438 |
+
is_p2m=is_p2m,
|
| 1439 |
+
)
|
| 1440 |
+
|
| 1441 |
+
return {
|
| 1442 |
+
'text': text,
|
| 1443 |
+
'ground_truth': ground_truth.to_dict(),
|
| 1444 |
+
}
|
| 1445 |
+
|
| 1446 |
+
def generate_non_transaction(self, msg_type: MessageType) -> Dict[str, Any]:
|
| 1447 |
+
"""Generate non-transaction message (OTP, promo, etc.)."""
|
| 1448 |
+
bank = BankDatabase.get_random(self.rng)
|
| 1449 |
+
|
| 1450 |
+
if msg_type == MessageType.OTP:
|
| 1451 |
+
otp = ''.join(self.rng.choices('0123456789', k=6))
|
| 1452 |
+
templates = [
|
| 1453 |
+
f"{bank.name} Bank: Your OTP is {otp}. Valid for 10 mins. Do not share with anyone.",
|
| 1454 |
+
f"{bank.name}: {otp} is your OTP for transaction. Valid for 5 mins.",
|
| 1455 |
+
f"OTP for {bank.name} Bank: {otp}. Do not share this with anyone.",
|
| 1456 |
+
]
|
| 1457 |
+
text = self.rng.choice(templates)
|
| 1458 |
+
|
| 1459 |
+
elif msg_type == MessageType.PROMOTIONAL:
|
| 1460 |
+
templates = [
|
| 1461 |
+
f"{bank.name} Bank wishes you a Happy Diwali! Enjoy 10% cashback on all transactions.",
|
| 1462 |
+
f"Exclusive offer! Get 5% cashback on shopping using {bank.name} Credit Card.",
|
| 1463 |
+
f"{bank.name}: Apply for Personal Loan at lowest interest rates. Click here.",
|
| 1464 |
+
]
|
| 1465 |
+
text = self.rng.choice(templates)
|
| 1466 |
+
|
| 1467 |
+
elif msg_type == MessageType.ALERT:
|
| 1468 |
+
account = Account(str(self.rng.randint(1000, 9999)))
|
| 1469 |
+
templates = [
|
| 1470 |
+
f"{bank.name} Bank: Your Debit Card has been blocked. Call {bank.helpline}.",
|
| 1471 |
+
f"{bank.name}: Suspicious activity detected on A/c {account.format('XX')}. Call immediately.",
|
| 1472 |
+
f"{bank.name}: Your account KYC is pending. Update by end of month.",
|
| 1473 |
+
]
|
| 1474 |
+
text = self.rng.choice(templates)
|
| 1475 |
+
|
| 1476 |
+
else: # STATEMENT
|
| 1477 |
+
account = Account(str(self.rng.randint(1000, 9999)))
|
| 1478 |
+
balance = round(self.rng.uniform(1000, 500000), 2)
|
| 1479 |
+
text = f"{bank.name} Bank: Your A/c {account.format('XX')} balance is Rs.{balance:,.2f}."
|
| 1480 |
+
|
| 1481 |
+
ground_truth = GroundTruth(
|
| 1482 |
+
message_type=msg_type.value,
|
| 1483 |
+
bank=bank.name,
|
| 1484 |
+
)
|
| 1485 |
+
|
| 1486 |
+
return {
|
| 1487 |
+
'text': text,
|
| 1488 |
+
'ground_truth': ground_truth.to_dict(),
|
| 1489 |
+
}
|
| 1490 |
+
|
| 1491 |
+
def generate_batch(
|
| 1492 |
+
self,
|
| 1493 |
+
count: int,
|
| 1494 |
+
include_non_transactions: bool = True,
|
| 1495 |
+
include_edge_cases: bool = True,
|
| 1496 |
+
noise_level: float = 0.3,
|
| 1497 |
+
) -> List[Dict[str, Any]]:
|
| 1498 |
+
"""
|
| 1499 |
+
Generate a batch of transactions.
|
| 1500 |
+
|
| 1501 |
+
Args:
|
| 1502 |
+
count: Number of records to generate
|
| 1503 |
+
include_non_transactions: Include OTP, promo messages (5%)
|
| 1504 |
+
include_edge_cases: Include edge cases (3%)
|
| 1505 |
+
noise_level: Noise intensity
|
| 1506 |
+
"""
|
| 1507 |
+
records = []
|
| 1508 |
+
|
| 1509 |
+
# Calculate distribution
|
| 1510 |
+
edge_case_count = int(count * 0.03) if include_edge_cases else 0
|
| 1511 |
+
non_txn_count = int(count * 0.05) if include_non_transactions else 0
|
| 1512 |
+
txn_count = count - edge_case_count - non_txn_count
|
| 1513 |
+
|
| 1514 |
+
print(f"Generating {count:,} records...")
|
| 1515 |
+
print(f" - Transactions: {txn_count:,}")
|
| 1516 |
+
print(f" - Edge cases: {edge_case_count:,}")
|
| 1517 |
+
print(f" - Non-transactions: {non_txn_count:,}")
|
| 1518 |
+
|
| 1519 |
+
# Generate transactions
|
| 1520 |
+
for i in range(txn_count):
|
| 1521 |
+
record = self.generate_transaction(noise_level=noise_level)
|
| 1522 |
+
record['id'] = i + 1
|
| 1523 |
+
record['hash'] = hashlib.sha256(record['text'].encode()).hexdigest()[:16]
|
| 1524 |
+
records.append(record)
|
| 1525 |
+
|
| 1526 |
+
if (i + 1) % 10000 == 0:
|
| 1527 |
+
print(f" Generated {i+1:,}/{count:,}")
|
| 1528 |
+
|
| 1529 |
+
# Generate edge cases
|
| 1530 |
+
edge_configs = EdgeCaseGenerator.generate_edge_case_batch(edge_case_count, self.rng)
|
| 1531 |
+
for i, config in enumerate(edge_configs):
|
| 1532 |
+
# Generate with edge case parameters
|
| 1533 |
+
record = self.generate_transaction(noise_level=noise_level * 0.5)
|
| 1534 |
+
|
| 1535 |
+
# Apply edge case modifications
|
| 1536 |
+
if config.get('amount'):
|
| 1537 |
+
record['ground_truth']['amount'] = config['amount']
|
| 1538 |
+
if config.get('merchant'):
|
| 1539 |
+
record['ground_truth']['merchant_raw'] = config['merchant']
|
| 1540 |
+
if config.get('beneficiary'):
|
| 1541 |
+
record['ground_truth']['beneficiary'] = config['beneficiary']
|
| 1542 |
+
|
| 1543 |
+
record['id'] = txn_count + i + 1
|
| 1544 |
+
record['edge_case'] = config['edge_type']
|
| 1545 |
+
records.append(record)
|
| 1546 |
+
|
| 1547 |
+
# Generate non-transactions
|
| 1548 |
+
for i in range(non_txn_count):
|
| 1549 |
+
msg_type = self.rng.choice([MessageType.OTP, MessageType.PROMOTIONAL, MessageType.ALERT])
|
| 1550 |
+
record = self.generate_non_transaction(msg_type)
|
| 1551 |
+
record['id'] = txn_count + edge_case_count + i + 1
|
| 1552 |
+
records.append(record)
|
| 1553 |
+
|
| 1554 |
+
# Shuffle
|
| 1555 |
+
self.rng.shuffle(records)
|
| 1556 |
+
|
| 1557 |
+
return records
|
| 1558 |
+
|
| 1559 |
+
|
| 1560 |
+
# ============================================================================
|
| 1561 |
+
# VALIDATION
|
| 1562 |
+
# ============================================================================
|
| 1563 |
+
|
| 1564 |
+
class DatasetValidator:
|
| 1565 |
+
"""Validate generated dataset for quality."""
|
| 1566 |
+
|
| 1567 |
+
@staticmethod
|
| 1568 |
+
def validate(data: List[Dict]) -> Dict[str, Any]:
|
| 1569 |
+
"""
|
| 1570 |
+
Validate dataset quality.
|
| 1571 |
+
|
| 1572 |
+
Checks:
|
| 1573 |
+
1. Required fields present
|
| 1574 |
+
2. Distribution matches expectations
|
| 1575 |
+
3. No duplicate texts
|
| 1576 |
+
4. Format correctness
|
| 1577 |
+
"""
|
| 1578 |
+
issues = []
|
| 1579 |
+
warnings = []
|
| 1580 |
+
|
| 1581 |
+
# Check duplicates
|
| 1582 |
+
texts = [r['text'] for r in data]
|
| 1583 |
+
unique = set(texts)
|
| 1584 |
+
dup_rate = 1 - len(unique) / len(texts)
|
| 1585 |
+
if dup_rate > 0.01:
|
| 1586 |
+
warnings.append(f"Duplicate rate: {dup_rate:.2%}")
|
| 1587 |
+
|
| 1588 |
+
# Check field completeness
|
| 1589 |
+
txn_records = [r for r in data if r['ground_truth'].get('message_type') == 'transaction']
|
| 1590 |
+
|
| 1591 |
+
for r in txn_records:
|
| 1592 |
+
gt = r['ground_truth']
|
| 1593 |
+
if gt.get('amount') is None:
|
| 1594 |
+
issues.append(f"Missing amount in record {r.get('id')}")
|
| 1595 |
+
if gt.get('type') is None:
|
| 1596 |
+
issues.append(f"Missing type in record {r.get('id')}")
|
| 1597 |
+
|
| 1598 |
+
# Check distributions
|
| 1599 |
+
categories = defaultdict(int)
|
| 1600 |
+
statuses = defaultdict(int)
|
| 1601 |
+
types = defaultdict(int)
|
| 1602 |
+
|
| 1603 |
+
for r in txn_records:
|
| 1604 |
+
gt = r['ground_truth']
|
| 1605 |
+
categories[gt.get('category', 'unknown')] += 1
|
| 1606 |
+
statuses[gt.get('status', 'unknown')] += 1
|
| 1607 |
+
types[gt.get('type', 'unknown')] += 1
|
| 1608 |
+
|
| 1609 |
+
# Calculate statistics
|
| 1610 |
+
total_txn = len(txn_records)
|
| 1611 |
+
|
| 1612 |
+
result = {
|
| 1613 |
+
'total_records': len(data),
|
| 1614 |
+
'transaction_records': total_txn,
|
| 1615 |
+
'non_transaction_records': len(data) - total_txn,
|
| 1616 |
+
'unique_texts': len(unique),
|
| 1617 |
+
'duplicate_rate': dup_rate,
|
| 1618 |
+
'issues': issues[:20],
|
| 1619 |
+
'warnings': warnings,
|
| 1620 |
+
'distributions': {
|
| 1621 |
+
'categories': {k: v/total_txn for k, v in categories.items()},
|
| 1622 |
+
'statuses': {k: v/total_txn for k, v in statuses.items()},
|
| 1623 |
+
'types': {k: v/total_txn for k, v in types.items()},
|
| 1624 |
+
},
|
| 1625 |
+
'valid': len(issues) == 0,
|
| 1626 |
+
}
|
| 1627 |
+
|
| 1628 |
+
return result
|
| 1629 |
+
|
| 1630 |
+
|
| 1631 |
+
# ============================================================================
|
| 1632 |
+
# OUTPUT WRITERS
|
| 1633 |
+
# ============================================================================
|
| 1634 |
+
|
| 1635 |
+
def save_jsonl(data: List[Dict], path: Path):
|
| 1636 |
+
"""Save as JSONL."""
|
| 1637 |
+
with open(path, 'w', encoding='utf-8') as f:
|
| 1638 |
+
for record in data:
|
| 1639 |
+
line = {
|
| 1640 |
+
'input': record['text'],
|
| 1641 |
+
'output': json.dumps(record['ground_truth'], ensure_ascii=False),
|
| 1642 |
+
'id': record.get('id'),
|
| 1643 |
+
'hash': record.get('hash'),
|
| 1644 |
+
}
|
| 1645 |
+
f.write(json.dumps(line, ensure_ascii=False) + '\n')
|
| 1646 |
+
print(f"✅ Saved: {path}")
|
| 1647 |
+
|
| 1648 |
+
|
| 1649 |
+
def save_chat_format(data: List[Dict], path: Path):
|
| 1650 |
+
"""Save in chat format for instruction tuning."""
|
| 1651 |
+
system = """Extract financial entities from Indian banking messages. Output JSON with:
|
| 1652 |
+
- amount, amount_raw, type, status, account, date, reference
|
| 1653 |
+
- merchant, vpa, beneficiary, payment_method, category
|
| 1654 |
+
Only include fields present in the message."""
|
| 1655 |
+
|
| 1656 |
+
with open(path, 'w', encoding='utf-8') as f:
|
| 1657 |
+
for record in data:
|
| 1658 |
+
chat = {
|
| 1659 |
+
'messages': [
|
| 1660 |
+
{'role': 'system', 'content': system},
|
| 1661 |
+
{'role': 'user', 'content': record['text']},
|
| 1662 |
+
{'role': 'assistant', 'content': json.dumps(record['ground_truth'], ensure_ascii=False)},
|
| 1663 |
+
]
|
| 1664 |
+
}
|
| 1665 |
+
f.write(json.dumps(chat, ensure_ascii=False) + '\n')
|
| 1666 |
+
print(f"✅ Saved: {path}")
|
| 1667 |
+
|
| 1668 |
+
|
| 1669 |
+
# ============================================================================
|
| 1670 |
+
# CLI
|
| 1671 |
+
# ============================================================================
|
| 1672 |
+
|
| 1673 |
+
def main():
|
| 1674 |
+
parser = argparse.ArgumentParser(
|
| 1675 |
+
description="Production-grade synthetic data generator for Indian banking transactions"
|
| 1676 |
+
)
|
| 1677 |
+
parser.add_argument("-n", "--count", type=int, default=10000, help="Number of records")
|
| 1678 |
+
parser.add_argument("-o", "--output", default="data/synthetic.jsonl", help="Output path")
|
| 1679 |
+
parser.add_argument("--seed", type=int, default=42, help="Random seed")
|
| 1680 |
+
parser.add_argument("--noise", type=float, default=0.3, help="Noise level (0-1)")
|
| 1681 |
+
parser.add_argument("--validate", action="store_true", help="Validate after generation")
|
| 1682 |
+
parser.add_argument("--chat-format", action="store_true", help="Also save chat format")
|
| 1683 |
+
|
| 1684 |
+
args = parser.parse_args()
|
| 1685 |
+
|
| 1686 |
+
# Create output directory
|
| 1687 |
+
output_path = Path(args.output)
|
| 1688 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 1689 |
+
|
| 1690 |
+
# Generate
|
| 1691 |
+
generator = TransactionGenerator(seed=args.seed)
|
| 1692 |
+
data = generator.generate_batch(
|
| 1693 |
+
count=args.count,
|
| 1694 |
+
noise_level=args.noise,
|
| 1695 |
+
)
|
| 1696 |
+
|
| 1697 |
+
# Save
|
| 1698 |
+
save_jsonl(data, output_path)
|
| 1699 |
+
|
| 1700 |
+
if args.chat_format:
|
| 1701 |
+
chat_path = output_path.with_suffix('.chat.jsonl')
|
| 1702 |
+
save_chat_format(data, chat_path)
|
| 1703 |
+
|
| 1704 |
+
# Validate
|
| 1705 |
+
if args.validate:
|
| 1706 |
+
print("\n📊 Validation:")
|
| 1707 |
+
result = DatasetValidator.validate(data)
|
| 1708 |
+
print(f" Total: {result['total_records']:,}")
|
| 1709 |
+
print(f" Unique: {result['unique_texts']:,}")
|
| 1710 |
+
print(f" Duplicate rate: {result['duplicate_rate']:.2%}")
|
| 1711 |
+
print(f" Valid: {'✅' if result['valid'] else '❌'}")
|
| 1712 |
+
|
| 1713 |
+
if result['issues']:
|
| 1714 |
+
print(f"\n⚠️ Issues ({len(result['issues'])}):")
|
| 1715 |
+
for issue in result['issues'][:5]:
|
| 1716 |
+
print(f" - {issue}")
|
| 1717 |
+
|
| 1718 |
+
print("\n📈 Distributions:")
|
| 1719 |
+
for dist_name, dist in result['distributions'].items():
|
| 1720 |
+
print(f"\n {dist_name}:")
|
| 1721 |
+
for k, v in sorted(dist.items(), key=lambda x: -x[1])[:5]:
|
| 1722 |
+
print(f" {k}: {v:.1%}")
|
| 1723 |
+
|
| 1724 |
+
|
| 1725 |
+
if __name__ == "__main__":
|
| 1726 |
+
main()
|
|
@@ -0,0 +1,334 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Step 1: Data Unification Script
|
| 4 |
+
================================
|
| 5 |
+
|
| 6 |
+
Reads various data formats (XML, JSON, CSV, MBOX) and
|
| 7 |
+
combines them into a single standardized DataFrame.
|
| 8 |
+
|
| 9 |
+
Output Schema: ['timestamp', 'sender', 'body', 'source']
|
| 10 |
+
|
| 11 |
+
Usage:
|
| 12 |
+
python step1_unify.py --input /path/to/raw/data --output step1_unified.csv
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import argparse
|
| 16 |
+
import json
|
| 17 |
+
import csv
|
| 18 |
+
import os
|
| 19 |
+
import re
|
| 20 |
+
import pandas as pd
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
from datetime import datetime
|
| 23 |
+
from typing import List, Dict, Any, Optional
|
| 24 |
+
import mailbox
|
| 25 |
+
import email
|
| 26 |
+
from email.utils import parsedate_to_datetime
|
| 27 |
+
import xml.etree.ElementTree as ET
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def parse_mbox(filepath: Path) -> List[Dict[str, Any]]:
|
| 31 |
+
"""Parse Gmail MBOX export."""
|
| 32 |
+
records = []
|
| 33 |
+
try:
|
| 34 |
+
mbox = mailbox.mbox(str(filepath))
|
| 35 |
+
for message in mbox:
|
| 36 |
+
try:
|
| 37 |
+
# Get timestamp
|
| 38 |
+
date_str = message.get('Date', '')
|
| 39 |
+
try:
|
| 40 |
+
timestamp = parsedate_to_datetime(date_str).isoformat()
|
| 41 |
+
except:
|
| 42 |
+
timestamp = date_str
|
| 43 |
+
|
| 44 |
+
# Get sender
|
| 45 |
+
sender = message.get('From', '')
|
| 46 |
+
|
| 47 |
+
# Get body
|
| 48 |
+
body = ''
|
| 49 |
+
if message.is_multipart():
|
| 50 |
+
for part in message.walk():
|
| 51 |
+
if part.get_content_type() == 'text/plain':
|
| 52 |
+
payload = part.get_payload(decode=True)
|
| 53 |
+
if payload:
|
| 54 |
+
body = payload.decode('utf-8', errors='ignore')
|
| 55 |
+
break
|
| 56 |
+
else:
|
| 57 |
+
payload = message.get_payload(decode=True)
|
| 58 |
+
if payload:
|
| 59 |
+
body = payload.decode('utf-8', errors='ignore')
|
| 60 |
+
|
| 61 |
+
if body.strip():
|
| 62 |
+
records.append({
|
| 63 |
+
'timestamp': timestamp,
|
| 64 |
+
'sender': sender,
|
| 65 |
+
'body': body.strip(),
|
| 66 |
+
'source': 'mbox'
|
| 67 |
+
})
|
| 68 |
+
except Exception as e:
|
| 69 |
+
continue
|
| 70 |
+
except Exception as e:
|
| 71 |
+
print(f" ⚠️ Error parsing MBOX {filepath}: {e}")
|
| 72 |
+
|
| 73 |
+
return records
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def parse_json(filepath: Path) -> List[Dict[str, Any]]:
|
| 77 |
+
"""Parse JSON exports (Google Takeout format)."""
|
| 78 |
+
records = []
|
| 79 |
+
try:
|
| 80 |
+
with open(filepath, 'r', encoding='utf-8') as f:
|
| 81 |
+
data = json.load(f)
|
| 82 |
+
|
| 83 |
+
# Handle different JSON structures
|
| 84 |
+
if isinstance(data, list):
|
| 85 |
+
items = data
|
| 86 |
+
elif isinstance(data, dict):
|
| 87 |
+
# Common Google Takeout patterns
|
| 88 |
+
items = data.get('messages', []) or \
|
| 89 |
+
data.get('transactions', []) or \
|
| 90 |
+
data.get('items', []) or \
|
| 91 |
+
data.get('data', []) or \
|
| 92 |
+
[data]
|
| 93 |
+
else:
|
| 94 |
+
items = []
|
| 95 |
+
|
| 96 |
+
for item in items:
|
| 97 |
+
if not isinstance(item, dict):
|
| 98 |
+
continue
|
| 99 |
+
|
| 100 |
+
# Try common field names
|
| 101 |
+
timestamp = item.get('timestamp') or item.get('date') or \
|
| 102 |
+
item.get('time') or item.get('created_at') or ''
|
| 103 |
+
|
| 104 |
+
sender = item.get('sender') or item.get('from') or \
|
| 105 |
+
item.get('source') or item.get('merchant') or ''
|
| 106 |
+
|
| 107 |
+
body = item.get('body') or item.get('message') or \
|
| 108 |
+
item.get('text') or item.get('content') or \
|
| 109 |
+
item.get('description') or item.get('title') or ''
|
| 110 |
+
|
| 111 |
+
# For Google Pay transactions
|
| 112 |
+
if 'amount' in item:
|
| 113 |
+
amount = item.get('amount', '')
|
| 114 |
+
merchant = item.get('merchant', {})
|
| 115 |
+
if isinstance(merchant, dict):
|
| 116 |
+
merchant_name = merchant.get('name', '')
|
| 117 |
+
else:
|
| 118 |
+
merchant_name = str(merchant)
|
| 119 |
+
body = f"Transaction: Rs.{amount} to {merchant_name}"
|
| 120 |
+
|
| 121 |
+
if body and str(body).strip():
|
| 122 |
+
records.append({
|
| 123 |
+
'timestamp': str(timestamp),
|
| 124 |
+
'sender': str(sender),
|
| 125 |
+
'body': str(body).strip(),
|
| 126 |
+
'source': f'json:{filepath.name}'
|
| 127 |
+
})
|
| 128 |
+
|
| 129 |
+
except Exception as e:
|
| 130 |
+
print(f" ⚠️ Error parsing JSON {filepath}: {e}")
|
| 131 |
+
|
| 132 |
+
return records
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def parse_csv(filepath: Path) -> List[Dict[str, Any]]:
|
| 136 |
+
"""Parse CSV exports."""
|
| 137 |
+
records = []
|
| 138 |
+
try:
|
| 139 |
+
df = pd.read_csv(filepath, encoding='utf-8', on_bad_lines='skip')
|
| 140 |
+
|
| 141 |
+
# Find relevant columns (case-insensitive)
|
| 142 |
+
cols = {c.lower(): c for c in df.columns}
|
| 143 |
+
|
| 144 |
+
timestamp_col = None
|
| 145 |
+
for name in ['timestamp', 'date', 'time', 'datetime', 'created_at']:
|
| 146 |
+
if name in cols:
|
| 147 |
+
timestamp_col = cols[name]
|
| 148 |
+
break
|
| 149 |
+
|
| 150 |
+
sender_col = None
|
| 151 |
+
for name in ['sender', 'from', 'source', 'bank', 'merchant']:
|
| 152 |
+
if name in cols:
|
| 153 |
+
sender_col = cols[name]
|
| 154 |
+
break
|
| 155 |
+
|
| 156 |
+
body_col = None
|
| 157 |
+
for name in ['body', 'message', 'text', 'content', 'description', 'sms']:
|
| 158 |
+
if name in cols:
|
| 159 |
+
body_col = cols[name]
|
| 160 |
+
break
|
| 161 |
+
|
| 162 |
+
if body_col:
|
| 163 |
+
for _, row in df.iterrows():
|
| 164 |
+
body = str(row.get(body_col, ''))
|
| 165 |
+
if body.strip() and body != 'nan':
|
| 166 |
+
records.append({
|
| 167 |
+
'timestamp': str(row.get(timestamp_col, '')) if timestamp_col else '',
|
| 168 |
+
'sender': str(row.get(sender_col, '')) if sender_col else '',
|
| 169 |
+
'body': body.strip(),
|
| 170 |
+
'source': f'csv:{filepath.name}'
|
| 171 |
+
})
|
| 172 |
+
|
| 173 |
+
except Exception as e:
|
| 174 |
+
print(f" ⚠️ Error parsing CSV {filepath}: {e}")
|
| 175 |
+
|
| 176 |
+
return records
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def parse_xml(filepath: Path) -> List[Dict[str, Any]]:
|
| 180 |
+
"""Parse XML exports (SMS Backup format)."""
|
| 181 |
+
records = []
|
| 182 |
+
try:
|
| 183 |
+
tree = ET.parse(filepath)
|
| 184 |
+
root = tree.getroot()
|
| 185 |
+
|
| 186 |
+
# Common SMS backup format
|
| 187 |
+
for sms in root.findall('.//sms') or root.findall('.//message'):
|
| 188 |
+
body = sms.get('body') or sms.text or ''
|
| 189 |
+
timestamp = sms.get('date') or sms.get('timestamp') or ''
|
| 190 |
+
sender = sms.get('address') or sms.get('sender') or sms.get('from') or ''
|
| 191 |
+
|
| 192 |
+
if body.strip():
|
| 193 |
+
# Convert timestamp if it's milliseconds
|
| 194 |
+
if timestamp.isdigit() and len(timestamp) > 10:
|
| 195 |
+
try:
|
| 196 |
+
timestamp = datetime.fromtimestamp(int(timestamp)/1000).isoformat()
|
| 197 |
+
except:
|
| 198 |
+
pass
|
| 199 |
+
|
| 200 |
+
records.append({
|
| 201 |
+
'timestamp': timestamp,
|
| 202 |
+
'sender': sender,
|
| 203 |
+
'body': body.strip(),
|
| 204 |
+
'source': f'xml:{filepath.name}'
|
| 205 |
+
})
|
| 206 |
+
|
| 207 |
+
except Exception as e:
|
| 208 |
+
print(f" ⚠️ Error parsing XML {filepath}: {e}")
|
| 209 |
+
|
| 210 |
+
return records
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def find_all_files(input_dir: Path) -> Dict[str, List[Path]]:
|
| 214 |
+
"""Find all data files recursively."""
|
| 215 |
+
files = {
|
| 216 |
+
'mbox': [],
|
| 217 |
+
'json': [],
|
| 218 |
+
'csv': [],
|
| 219 |
+
'xml': []
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
for filepath in input_dir.rglob('*'):
|
| 223 |
+
if filepath.is_file():
|
| 224 |
+
ext = filepath.suffix.lower()
|
| 225 |
+
if ext == '.mbox':
|
| 226 |
+
files['mbox'].append(filepath)
|
| 227 |
+
elif ext == '.json':
|
| 228 |
+
files['json'].append(filepath)
|
| 229 |
+
elif ext == '.csv':
|
| 230 |
+
files['csv'].append(filepath)
|
| 231 |
+
elif ext == '.xml':
|
| 232 |
+
files['xml'].append(filepath)
|
| 233 |
+
|
| 234 |
+
return files
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def unify_data(input_dir: Path) -> pd.DataFrame:
|
| 238 |
+
"""Main function to unify all data sources."""
|
| 239 |
+
print("=" * 60)
|
| 240 |
+
print("📂 STEP 1: DATA UNIFICATION")
|
| 241 |
+
print("=" * 60)
|
| 242 |
+
|
| 243 |
+
all_records = []
|
| 244 |
+
|
| 245 |
+
# Find all files
|
| 246 |
+
print(f"\n🔍 Scanning: {input_dir}")
|
| 247 |
+
files = find_all_files(input_dir)
|
| 248 |
+
|
| 249 |
+
total_files = sum(len(v) for v in files.values())
|
| 250 |
+
print(f" Found {total_files} files to process")
|
| 251 |
+
|
| 252 |
+
# Parse MBOX files
|
| 253 |
+
if files['mbox']:
|
| 254 |
+
print(f"\n📧 Processing {len(files['mbox'])} MBOX files...")
|
| 255 |
+
for f in files['mbox']:
|
| 256 |
+
print(f" Processing: {f.name}")
|
| 257 |
+
records = parse_mbox(f)
|
| 258 |
+
all_records.extend(records)
|
| 259 |
+
print(f" ✅ Extracted {len(records)} messages")
|
| 260 |
+
|
| 261 |
+
# Parse JSON files
|
| 262 |
+
if files['json']:
|
| 263 |
+
print(f"\n📋 Processing {len(files['json'])} JSON files...")
|
| 264 |
+
for f in files['json']:
|
| 265 |
+
print(f" Processing: {f.name}")
|
| 266 |
+
records = parse_json(f)
|
| 267 |
+
all_records.extend(records)
|
| 268 |
+
print(f" ✅ Extracted {len(records)} records")
|
| 269 |
+
|
| 270 |
+
# Parse CSV files
|
| 271 |
+
if files['csv']:
|
| 272 |
+
print(f"\n📊 Processing {len(files['csv'])} CSV files...")
|
| 273 |
+
for f in files['csv']:
|
| 274 |
+
print(f" Processing: {f.name}")
|
| 275 |
+
records = parse_csv(f)
|
| 276 |
+
all_records.extend(records)
|
| 277 |
+
print(f" ✅ Extracted {len(records)} records")
|
| 278 |
+
|
| 279 |
+
# Parse XML files
|
| 280 |
+
if files['xml']:
|
| 281 |
+
print(f"\n📝 Processing {len(files['xml'])} XML files...")
|
| 282 |
+
for f in files['xml']:
|
| 283 |
+
print(f" Processing: {f.name}")
|
| 284 |
+
records = parse_xml(f)
|
| 285 |
+
all_records.extend(records)
|
| 286 |
+
print(f" ✅ Extracted {len(records)} records")
|
| 287 |
+
|
| 288 |
+
# Create DataFrame
|
| 289 |
+
df = pd.DataFrame(all_records, columns=['timestamp', 'sender', 'body', 'source'])
|
| 290 |
+
|
| 291 |
+
# Remove exact duplicates
|
| 292 |
+
original_count = len(df)
|
| 293 |
+
df = df.drop_duplicates(subset=['body'])
|
| 294 |
+
dedup_count = len(df)
|
| 295 |
+
|
| 296 |
+
print(f"\n📊 SUMMARY:")
|
| 297 |
+
print(f" Total records: {original_count}")
|
| 298 |
+
print(f" After dedup: {dedup_count}")
|
| 299 |
+
print(f" Removed: {original_count - dedup_count} duplicates")
|
| 300 |
+
|
| 301 |
+
return df
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def main():
|
| 305 |
+
parser = argparse.ArgumentParser(description="Step 1: Unify data sources")
|
| 306 |
+
parser.add_argument("--input", "-i", required=True, help="Input directory with raw data")
|
| 307 |
+
parser.add_argument("--output", "-o", default="data/pipeline/step1_unified.csv",
|
| 308 |
+
help="Output CSV path")
|
| 309 |
+
args = parser.parse_args()
|
| 310 |
+
|
| 311 |
+
input_dir = Path(args.input)
|
| 312 |
+
if not input_dir.exists():
|
| 313 |
+
print(f"❌ Input directory not found: {input_dir}")
|
| 314 |
+
return
|
| 315 |
+
|
| 316 |
+
# Unify data
|
| 317 |
+
df = unify_data(input_dir)
|
| 318 |
+
|
| 319 |
+
if len(df) == 0:
|
| 320 |
+
print("\n❌ No data extracted! Check your input directory.")
|
| 321 |
+
return
|
| 322 |
+
|
| 323 |
+
# Save output
|
| 324 |
+
output_path = Path(args.output)
|
| 325 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 326 |
+
df.to_csv(output_path, index=False)
|
| 327 |
+
|
| 328 |
+
print(f"\n✅ Saved to: {output_path}")
|
| 329 |
+
print(f" Records: {len(df)}")
|
| 330 |
+
print("\nNext: python scripts/data_pipeline/step2_filter.py")
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
if __name__ == "__main__":
|
| 334 |
+
main()
|
|
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Step 2: Garbage Filter
|
| 4 |
+
======================
|
| 5 |
+
|
| 6 |
+
Filters out noise from unified data:
|
| 7 |
+
- OTPs / Verification codes
|
| 8 |
+
- Login alerts
|
| 9 |
+
- Marketing spam
|
| 10 |
+
- Bill reminders (non-transaction)
|
| 11 |
+
- Password reset
|
| 12 |
+
|
| 13 |
+
Keeps only actual transaction messages.
|
| 14 |
+
|
| 15 |
+
Usage:
|
| 16 |
+
python step2_filter.py --input step1_unified.csv --output step2_training_ready.csv
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import argparse
|
| 20 |
+
import re
|
| 21 |
+
import pandas as pd
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
from typing import Tuple
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# ============================================================================
|
| 27 |
+
# GARBAGE PATTERNS (Messages to REMOVE)
|
| 28 |
+
# ============================================================================
|
| 29 |
+
|
| 30 |
+
GARBAGE_PATTERNS = [
|
| 31 |
+
# OTPs and verification codes
|
| 32 |
+
r'\bOTP\b',
|
| 33 |
+
r'\bone.time.password\b',
|
| 34 |
+
r'\bverification.code\b',
|
| 35 |
+
r'\bverify.your\b',
|
| 36 |
+
r'\bconfirm.your.identity\b',
|
| 37 |
+
r'\bcvv\b',
|
| 38 |
+
r'\bpin\b.*\bdo.not.share\b',
|
| 39 |
+
r'\b\d{4,6}\b.*\bexpires?\b',
|
| 40 |
+
r'\bvalid.for.\d+.min',
|
| 41 |
+
|
| 42 |
+
# Login/Security alerts
|
| 43 |
+
r'\blogin.alert\b',
|
| 44 |
+
r'\blogged.in\b',
|
| 45 |
+
r'\bnew.device\b',
|
| 46 |
+
r'\bnew.login\b',
|
| 47 |
+
r'\bsecurity.alert\b',
|
| 48 |
+
r'\bsign.in.attempt\b',
|
| 49 |
+
r'\bpassword.changed\b',
|
| 50 |
+
r'\bpassword.reset\b',
|
| 51 |
+
r'\baccount.locked\b',
|
| 52 |
+
r'\bunusual.activity\b',
|
| 53 |
+
|
| 54 |
+
# Marketing / Promotional
|
| 55 |
+
r'\b\d+%.off\b',
|
| 56 |
+
r'\bdiscount\b',
|
| 57 |
+
r'\bcashback.offer\b',
|
| 58 |
+
r'\bflat.rs\b.*\boff\b',
|
| 59 |
+
r'\bget.upto\b',
|
| 60 |
+
r'\bwin.upto\b',
|
| 61 |
+
r'\bexclusive.offer\b',
|
| 62 |
+
r'\blimited.time\b',
|
| 63 |
+
r'\bspecial.offer\b',
|
| 64 |
+
r'\bsale.live\b',
|
| 65 |
+
r'\bshop.now\b',
|
| 66 |
+
r'\bbuy.now\b',
|
| 67 |
+
r'\bsubscribe\b',
|
| 68 |
+
r'\bunsubscribe\b',
|
| 69 |
+
r'\bnewsletter\b',
|
| 70 |
+
|
| 71 |
+
# Bill reminders (not actual transactions)
|
| 72 |
+
r'\bbill.due\b',
|
| 73 |
+
r'\bbill.reminder\b',
|
| 74 |
+
r'\bdue.date\b',
|
| 75 |
+
r'\bpay.before\b',
|
| 76 |
+
r'\bauto.debit.scheduled\b',
|
| 77 |
+
r'\bemi.due\b',
|
| 78 |
+
r'\bpayment.reminder\b',
|
| 79 |
+
r'\bminimum.amount.due\b',
|
| 80 |
+
r'\boutstanding.balance\b',
|
| 81 |
+
|
| 82 |
+
# Account statements / Summaries
|
| 83 |
+
r'\baccount.statement\b',
|
| 84 |
+
r'\bmonthly.statement\b',
|
| 85 |
+
r'\be.statement\b',
|
| 86 |
+
r'\bstatement.ready\b',
|
| 87 |
+
r'\bdownload.statement\b',
|
| 88 |
+
|
| 89 |
+
# Delivery / Shipping (Not finance)
|
| 90 |
+
r'\bout.for.delivery\b',
|
| 91 |
+
r'\bdelivered.to\b',
|
| 92 |
+
r'\bshipment.update\b',
|
| 93 |
+
r'\btracking.number\b',
|
| 94 |
+
r'\border.confirmed\b',
|
| 95 |
+
r'\border.placed\b',
|
| 96 |
+
r'\border.shipped\b',
|
| 97 |
+
|
| 98 |
+
# App notifications
|
| 99 |
+
r'\brate.your.experience\b',
|
| 100 |
+
r'\bleave.a.review\b',
|
| 101 |
+
r'\bupdate.available\b',
|
| 102 |
+
r'\bapp.update\b',
|
| 103 |
+
r'\bdownload.app\b',
|
| 104 |
+
r'\binstall.app\b',
|
| 105 |
+
|
| 106 |
+
# Generic noise
|
| 107 |
+
r'\bclick.here\b',
|
| 108 |
+
r'\bvisit.us\b',
|
| 109 |
+
r'\bcall.us\b',
|
| 110 |
+
r'\bcontact.us\b',
|
| 111 |
+
r'\bfollow.us\b',
|
| 112 |
+
r'\bjoin.us\b',
|
| 113 |
+
r'\blearn.more\b',
|
| 114 |
+
r'\bread.more\b',
|
| 115 |
+
]
|
| 116 |
+
|
| 117 |
+
# ============================================================================
|
| 118 |
+
# KEEP PATTERNS (Messages to KEEP - actual transactions)
|
| 119 |
+
# ============================================================================
|
| 120 |
+
|
| 121 |
+
KEEP_PATTERNS = [
|
| 122 |
+
# Transaction keywords
|
| 123 |
+
r'\bdebited?\b',
|
| 124 |
+
r'\bcredited?\b',
|
| 125 |
+
r'\btransferred?\b',
|
| 126 |
+
r'\bwithdra(?:wn|wal)\b',
|
| 127 |
+
r'\bdeposited?\b',
|
| 128 |
+
r'\breceived?\b',
|
| 129 |
+
r'\bsent\b',
|
| 130 |
+
r'\bpaid\b',
|
| 131 |
+
r'\bpurchase\b',
|
| 132 |
+
r'\btransaction\b',
|
| 133 |
+
r'\btxn\b',
|
| 134 |
+
r'\bupi\b',
|
| 135 |
+
r'\bneft\b',
|
| 136 |
+
r'\bimps\b',
|
| 137 |
+
r'\brtgs\b',
|
| 138 |
+
|
| 139 |
+
# Amount patterns
|
| 140 |
+
r'rs\.?\s*[\d,]+',
|
| 141 |
+
r'inr\s*[\d,]+',
|
| 142 |
+
r'₹\s*[\d,]+',
|
| 143 |
+
|
| 144 |
+
# Account references
|
| 145 |
+
r'a/c\s*\w+',
|
| 146 |
+
r'acct?\s*\w+',
|
| 147 |
+
r'account\s*\w+',
|
| 148 |
+
|
| 149 |
+
# Reference numbers
|
| 150 |
+
r'ref\.?\s*:?\s*\d{10,}',
|
| 151 |
+
r'upi.?ref\s*:?\s*\d{10,}',
|
| 152 |
+
]
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def is_garbage(text: str) -> bool:
|
| 156 |
+
"""Check if message is garbage (should be removed)."""
|
| 157 |
+
text_lower = text.lower()
|
| 158 |
+
|
| 159 |
+
for pattern in GARBAGE_PATTERNS:
|
| 160 |
+
if re.search(pattern, text_lower):
|
| 161 |
+
return True
|
| 162 |
+
|
| 163 |
+
return False
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def is_transaction(text: str) -> bool:
|
| 167 |
+
"""Check if message is a transaction (should be kept)."""
|
| 168 |
+
text_lower = text.lower()
|
| 169 |
+
|
| 170 |
+
matches = 0
|
| 171 |
+
for pattern in KEEP_PATTERNS:
|
| 172 |
+
if re.search(pattern, text_lower):
|
| 173 |
+
matches += 1
|
| 174 |
+
|
| 175 |
+
# Require at least 2 transaction indicators
|
| 176 |
+
return matches >= 2
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def classify_message(text: str) -> Tuple[str, str]:
|
| 180 |
+
"""
|
| 181 |
+
Classify a message as 'keep', 'garbage', or 'uncertain'.
|
| 182 |
+
|
| 183 |
+
Returns: (classification, reason)
|
| 184 |
+
"""
|
| 185 |
+
if not text or len(text.strip()) < 20:
|
| 186 |
+
return 'garbage', 'too_short'
|
| 187 |
+
|
| 188 |
+
# First check garbage patterns (high confidence negative)
|
| 189 |
+
if is_garbage(text):
|
| 190 |
+
return 'garbage', 'matched_garbage_pattern'
|
| 191 |
+
|
| 192 |
+
# Then check transaction patterns (high confidence positive)
|
| 193 |
+
if is_transaction(text):
|
| 194 |
+
return 'keep', 'matched_transaction_pattern'
|
| 195 |
+
|
| 196 |
+
# Messages with amounts but no clear transaction type
|
| 197 |
+
if re.search(r'(?:rs\.?|inr|₹)\s*[\d,]+', text.lower()):
|
| 198 |
+
return 'uncertain', 'has_amount_no_transaction_type'
|
| 199 |
+
|
| 200 |
+
# Default: uncertain
|
| 201 |
+
return 'uncertain', 'no_strong_signals'
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def filter_data(df: pd.DataFrame) -> Tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
|
| 205 |
+
"""
|
| 206 |
+
Filter DataFrame into keep/garbage/uncertain.
|
| 207 |
+
|
| 208 |
+
Returns: (keep_df, garbage_df, uncertain_df)
|
| 209 |
+
"""
|
| 210 |
+
print("=" * 60)
|
| 211 |
+
print("🧹 STEP 2: GARBAGE FILTER")
|
| 212 |
+
print("=" * 60)
|
| 213 |
+
|
| 214 |
+
results = []
|
| 215 |
+
for _, row in df.iterrows():
|
| 216 |
+
body = row.get('body', '')
|
| 217 |
+
classification, reason = classify_message(str(body))
|
| 218 |
+
results.append({
|
| 219 |
+
**row.to_dict(),
|
| 220 |
+
'classification': classification,
|
| 221 |
+
'filter_reason': reason
|
| 222 |
+
})
|
| 223 |
+
|
| 224 |
+
result_df = pd.DataFrame(results)
|
| 225 |
+
|
| 226 |
+
keep_df = result_df[result_df['classification'] == 'keep'].copy()
|
| 227 |
+
garbage_df = result_df[result_df['classification'] == 'garbage'].copy()
|
| 228 |
+
uncertain_df = result_df[result_df['classification'] == 'uncertain'].copy()
|
| 229 |
+
|
| 230 |
+
# Print summary
|
| 231 |
+
total = len(df)
|
| 232 |
+
print(f"\n📊 FILTER RESULTS:")
|
| 233 |
+
print(f" Total input: {total:,}")
|
| 234 |
+
print(f" ✅ Keep: {len(keep_df):,} ({100*len(keep_df)/total:.1f}%)")
|
| 235 |
+
print(f" ❌ Garbage: {len(garbage_df):,} ({100*len(garbage_df)/total:.1f}%)")
|
| 236 |
+
print(f" ❓ Uncertain: {len(uncertain_df):,} ({100*len(uncertain_df)/total:.1f}%)")
|
| 237 |
+
|
| 238 |
+
# Show reason breakdown for garbage
|
| 239 |
+
if len(garbage_df) > 0:
|
| 240 |
+
print(f"\n📋 Garbage Reasons:")
|
| 241 |
+
for reason, count in garbage_df['filter_reason'].value_counts().items():
|
| 242 |
+
print(f" {reason}: {count:,}")
|
| 243 |
+
|
| 244 |
+
return keep_df, garbage_df, uncertain_df
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def main():
|
| 248 |
+
parser = argparse.ArgumentParser(description="Step 2: Filter garbage messages")
|
| 249 |
+
parser.add_argument("--input", "-i", default="data/pipeline/step1_unified.csv",
|
| 250 |
+
help="Input CSV from step 1")
|
| 251 |
+
parser.add_argument("--output", "-o", default="data/pipeline/step2_training_ready.csv",
|
| 252 |
+
help="Output CSV with clean data")
|
| 253 |
+
parser.add_argument("--save-all", action="store_true",
|
| 254 |
+
help="Also save garbage and uncertain to separate files")
|
| 255 |
+
args = parser.parse_args()
|
| 256 |
+
|
| 257 |
+
input_path = Path(args.input)
|
| 258 |
+
if not input_path.exists():
|
| 259 |
+
print(f"❌ Input file not found: {input_path}")
|
| 260 |
+
print(f" Run step1_unify.py first!")
|
| 261 |
+
return
|
| 262 |
+
|
| 263 |
+
# Load data
|
| 264 |
+
print(f"\n📂 Loading: {input_path}")
|
| 265 |
+
df = pd.read_csv(input_path)
|
| 266 |
+
print(f" Loaded {len(df):,} records")
|
| 267 |
+
|
| 268 |
+
# Filter
|
| 269 |
+
keep_df, garbage_df, uncertain_df = filter_data(df)
|
| 270 |
+
|
| 271 |
+
# Save results
|
| 272 |
+
output_path = Path(args.output)
|
| 273 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 274 |
+
|
| 275 |
+
# Drop helper columns before saving
|
| 276 |
+
keep_df = keep_df.drop(columns=['classification', 'filter_reason'])
|
| 277 |
+
keep_df.to_csv(output_path, index=False)
|
| 278 |
+
print(f"\n✅ Saved clean data to: {output_path}")
|
| 279 |
+
print(f" Records: {len(keep_df):,}")
|
| 280 |
+
|
| 281 |
+
if args.save_all:
|
| 282 |
+
garbage_path = output_path.parent / "step2_garbage.csv"
|
| 283 |
+
uncertain_path = output_path.parent / "step2_uncertain.csv"
|
| 284 |
+
|
| 285 |
+
garbage_df.to_csv(garbage_path, index=False)
|
| 286 |
+
uncertain_df.to_csv(uncertain_path, index=False)
|
| 287 |
+
print(f"\n📁 Also saved:")
|
| 288 |
+
print(f" Garbage: {garbage_path}")
|
| 289 |
+
print(f" Uncertain: {uncertain_path}")
|
| 290 |
+
|
| 291 |
+
print("\nNext: python scripts/data_pipeline/step3_baseline.py")
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
if __name__ == "__main__":
|
| 295 |
+
main()
|
|
@@ -0,0 +1,260 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Step 3: Baseline Test
|
| 4 |
+
=====================
|
| 5 |
+
|
| 6 |
+
Tests the current finee extractor on the cleaned training data
|
| 7 |
+
to establish a baseline before fine-tuning.
|
| 8 |
+
|
| 9 |
+
This answers: "How good is our Regex engine on real data?"
|
| 10 |
+
|
| 11 |
+
Usage:
|
| 12 |
+
python step3_baseline.py --input step2_training_ready.csv
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import argparse
|
| 16 |
+
import json
|
| 17 |
+
import pandas as pd
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import Dict, Any, List
|
| 20 |
+
from datetime import datetime
|
| 21 |
+
import sys
|
| 22 |
+
|
| 23 |
+
# Add parent to path for imports
|
| 24 |
+
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
from finee import extract
|
| 28 |
+
from finee.schema import ExtractionResult
|
| 29 |
+
except ImportError:
|
| 30 |
+
print("❌ finee not installed!")
|
| 31 |
+
print(" Run: pip install finee")
|
| 32 |
+
sys.exit(1)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def extract_and_analyze(row: Dict[str, Any]) -> Dict[str, Any]:
|
| 36 |
+
"""Extract entities from a message and analyze results."""
|
| 37 |
+
body = str(row.get('body', ''))
|
| 38 |
+
|
| 39 |
+
try:
|
| 40 |
+
result = extract(body)
|
| 41 |
+
|
| 42 |
+
return {
|
| 43 |
+
# Original data
|
| 44 |
+
'timestamp': row.get('timestamp', ''),
|
| 45 |
+
'sender': row.get('sender', ''),
|
| 46 |
+
'body': body[:200] + '...' if len(body) > 200 else body,
|
| 47 |
+
'source': row.get('source', ''),
|
| 48 |
+
|
| 49 |
+
# Extracted fields
|
| 50 |
+
'extracted_amount': result.amount,
|
| 51 |
+
'extracted_type': result.type.value if result.type else None,
|
| 52 |
+
'extracted_account': result.account,
|
| 53 |
+
'extracted_date': result.date,
|
| 54 |
+
'extracted_reference': result.reference,
|
| 55 |
+
'extracted_vpa': result.vpa,
|
| 56 |
+
'extracted_merchant': result.merchant,
|
| 57 |
+
'extracted_category': result.category.value if result.category else None,
|
| 58 |
+
'extracted_confidence': result.confidence.value if result.confidence else None,
|
| 59 |
+
'extracted_confidence_score': result.confidence_score,
|
| 60 |
+
|
| 61 |
+
# Quality metrics
|
| 62 |
+
'has_amount': result.amount is not None,
|
| 63 |
+
'has_type': result.type is not None,
|
| 64 |
+
'has_merchant': result.merchant is not None,
|
| 65 |
+
'has_category': result.category is not None,
|
| 66 |
+
'fields_extracted': sum([
|
| 67 |
+
result.amount is not None,
|
| 68 |
+
result.type is not None,
|
| 69 |
+
result.account is not None,
|
| 70 |
+
result.date is not None,
|
| 71 |
+
result.reference is not None,
|
| 72 |
+
result.merchant is not None,
|
| 73 |
+
result.category is not None,
|
| 74 |
+
]),
|
| 75 |
+
|
| 76 |
+
# Processing info
|
| 77 |
+
'processing_time_ms': result.processing_time_ms,
|
| 78 |
+
'extraction_success': result.amount is not None and result.type is not None,
|
| 79 |
+
}
|
| 80 |
+
except Exception as e:
|
| 81 |
+
return {
|
| 82 |
+
'timestamp': row.get('timestamp', ''),
|
| 83 |
+
'sender': row.get('sender', ''),
|
| 84 |
+
'body': body[:200],
|
| 85 |
+
'source': row.get('source', ''),
|
| 86 |
+
'extraction_error': str(e),
|
| 87 |
+
'extraction_success': False,
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def run_baseline(df: pd.DataFrame) -> pd.DataFrame:
|
| 92 |
+
"""Run baseline extraction on all rows."""
|
| 93 |
+
print("=" * 60)
|
| 94 |
+
print("📊 STEP 3: BASELINE TEST")
|
| 95 |
+
print("=" * 60)
|
| 96 |
+
print(f"\nTesting finee extractor on {len(df):,} messages...")
|
| 97 |
+
print("(This tests Regex-only mode, no LLM)\n")
|
| 98 |
+
|
| 99 |
+
results = []
|
| 100 |
+
success_count = 0
|
| 101 |
+
|
| 102 |
+
for i, (_, row) in enumerate(df.iterrows()):
|
| 103 |
+
result = extract_and_analyze(row.to_dict())
|
| 104 |
+
results.append(result)
|
| 105 |
+
|
| 106 |
+
if result.get('extraction_success'):
|
| 107 |
+
success_count += 1
|
| 108 |
+
|
| 109 |
+
# Progress every 100
|
| 110 |
+
if (i + 1) % 100 == 0:
|
| 111 |
+
pct = 100 * success_count / (i + 1)
|
| 112 |
+
print(f" Processed {i+1:,}/{len(df):,} ({pct:.1f}% success rate)")
|
| 113 |
+
|
| 114 |
+
return pd.DataFrame(results)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def analyze_results(results_df: pd.DataFrame) -> Dict[str, Any]:
|
| 118 |
+
"""Analyze extraction results."""
|
| 119 |
+
total = len(results_df)
|
| 120 |
+
|
| 121 |
+
# Core metrics
|
| 122 |
+
success_count = results_df['extraction_success'].sum()
|
| 123 |
+
has_amount = results_df['has_amount'].sum()
|
| 124 |
+
has_type = results_df['has_type'].sum()
|
| 125 |
+
has_merchant = results_df['has_merchant'].sum()
|
| 126 |
+
has_category = results_df['has_category'].sum()
|
| 127 |
+
|
| 128 |
+
# Confidence distribution
|
| 129 |
+
confidence_counts = results_df['extracted_confidence'].value_counts().to_dict()
|
| 130 |
+
|
| 131 |
+
# Type distribution
|
| 132 |
+
type_counts = results_df['extracted_type'].value_counts().to_dict()
|
| 133 |
+
|
| 134 |
+
# Category distribution
|
| 135 |
+
category_counts = results_df['extracted_category'].value_counts().to_dict()
|
| 136 |
+
|
| 137 |
+
# Top merchants
|
| 138 |
+
merchant_counts = results_df['extracted_merchant'].value_counts().head(20).to_dict()
|
| 139 |
+
|
| 140 |
+
# Performance
|
| 141 |
+
avg_time = results_df['processing_time_ms'].mean()
|
| 142 |
+
|
| 143 |
+
analysis = {
|
| 144 |
+
'total_messages': total,
|
| 145 |
+
'extraction_success_rate': 100 * success_count / total,
|
| 146 |
+
'field_coverage': {
|
| 147 |
+
'amount': 100 * has_amount / total,
|
| 148 |
+
'type': 100 * has_type / total,
|
| 149 |
+
'merchant': 100 * has_merchant / total,
|
| 150 |
+
'category': 100 * has_category / total,
|
| 151 |
+
},
|
| 152 |
+
'confidence_distribution': confidence_counts,
|
| 153 |
+
'type_distribution': type_counts,
|
| 154 |
+
'category_distribution': category_counts,
|
| 155 |
+
'top_merchants': merchant_counts,
|
| 156 |
+
'avg_processing_time_ms': avg_time,
|
| 157 |
+
'timestamp': datetime.now().isoformat(),
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
return analysis
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def print_analysis(analysis: Dict[str, Any]) -> None:
|
| 164 |
+
"""Print analysis results."""
|
| 165 |
+
print("\n" + "=" * 60)
|
| 166 |
+
print("📈 BASELINE RESULTS")
|
| 167 |
+
print("=" * 60)
|
| 168 |
+
|
| 169 |
+
print(f"\n📊 COVERAGE:")
|
| 170 |
+
print(f" Total messages: {analysis['total_messages']:,}")
|
| 171 |
+
print(f" Extraction success: {analysis['extraction_success_rate']:.1f}%")
|
| 172 |
+
|
| 173 |
+
print(f"\n📋 FIELD COVERAGE:")
|
| 174 |
+
for field, pct in analysis['field_coverage'].items():
|
| 175 |
+
status = "✅" if pct >= 80 else "⚠️" if pct >= 50 else "❌"
|
| 176 |
+
print(f" {field:12} {pct:5.1f}% {status}")
|
| 177 |
+
|
| 178 |
+
print(f"\n📊 CONFIDENCE DISTRIBUTION:")
|
| 179 |
+
for level, count in sorted(analysis['confidence_distribution'].items(), key=lambda x: -x[1]):
|
| 180 |
+
if level:
|
| 181 |
+
pct = 100 * count / analysis['total_messages']
|
| 182 |
+
print(f" {level:10} {count:,} ({pct:.1f}%)")
|
| 183 |
+
|
| 184 |
+
print(f"\n💳 TRANSACTION TYPES:")
|
| 185 |
+
for txn_type, count in sorted(analysis['type_distribution'].items(), key=lambda x: -x[1]):
|
| 186 |
+
if txn_type:
|
| 187 |
+
pct = 100 * count / analysis['total_messages']
|
| 188 |
+
print(f" {txn_type:10} {count:,} ({pct:.1f}%)")
|
| 189 |
+
|
| 190 |
+
print(f"\n🏪 TOP 10 MERCHANTS:")
|
| 191 |
+
for i, (merchant, count) in enumerate(list(analysis['top_merchants'].items())[:10]):
|
| 192 |
+
if merchant:
|
| 193 |
+
print(f" {i+1:2}. {merchant:20} {count:,}")
|
| 194 |
+
|
| 195 |
+
print(f"\n⚡ PERFORMANCE:")
|
| 196 |
+
print(f" Avg processing time: {analysis['avg_processing_time_ms']:.2f}ms")
|
| 197 |
+
print(f" Throughput: ~{1000/analysis['avg_processing_time_ms']:.0f} msg/sec")
|
| 198 |
+
|
| 199 |
+
print("\n" + "=" * 60)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def main():
|
| 203 |
+
parser = argparse.ArgumentParser(description="Step 3: Baseline extraction test")
|
| 204 |
+
parser.add_argument("--input", "-i", default="data/pipeline/step2_training_ready.csv",
|
| 205 |
+
help="Input CSV from step 2")
|
| 206 |
+
parser.add_argument("--output", "-o", default="data/pipeline/step3_baseline_results.csv",
|
| 207 |
+
help="Output CSV with extraction results")
|
| 208 |
+
parser.add_argument("--limit", "-n", type=int, default=None,
|
| 209 |
+
help="Limit number of rows to process (for testing)")
|
| 210 |
+
args = parser.parse_args()
|
| 211 |
+
|
| 212 |
+
input_path = Path(args.input)
|
| 213 |
+
if not input_path.exists():
|
| 214 |
+
print(f"❌ Input file not found: {input_path}")
|
| 215 |
+
print(f" Run step2_filter.py first!")
|
| 216 |
+
return
|
| 217 |
+
|
| 218 |
+
# Load data
|
| 219 |
+
print(f"\n📂 Loading: {input_path}")
|
| 220 |
+
df = pd.read_csv(input_path)
|
| 221 |
+
|
| 222 |
+
if args.limit:
|
| 223 |
+
df = df.head(args.limit)
|
| 224 |
+
print(f" (Limited to {args.limit} rows for testing)")
|
| 225 |
+
|
| 226 |
+
print(f" Loaded {len(df):,} records")
|
| 227 |
+
|
| 228 |
+
# Run baseline
|
| 229 |
+
results_df = run_baseline(df)
|
| 230 |
+
|
| 231 |
+
# Analyze
|
| 232 |
+
analysis = analyze_results(results_df)
|
| 233 |
+
print_analysis(analysis)
|
| 234 |
+
|
| 235 |
+
# Save results
|
| 236 |
+
output_path = Path(args.output)
|
| 237 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 238 |
+
results_df.to_csv(output_path, index=False)
|
| 239 |
+
print(f"\n✅ Saved extraction results to: {output_path}")
|
| 240 |
+
|
| 241 |
+
# Save analysis as JSON
|
| 242 |
+
analysis_path = output_path.parent / "step3_baseline_analysis.json"
|
| 243 |
+
with open(analysis_path, 'w') as f:
|
| 244 |
+
json.dump(analysis, f, indent=2, default=str)
|
| 245 |
+
print(f" Analysis saved to: {analysis_path}")
|
| 246 |
+
|
| 247 |
+
# Summary
|
| 248 |
+
success_rate = analysis['extraction_success_rate']
|
| 249 |
+
if success_rate >= 80:
|
| 250 |
+
print(f"\n🎉 Great! {success_rate:.1f}% success rate. Regex is working well!")
|
| 251 |
+
elif success_rate >= 50:
|
| 252 |
+
print(f"\n⚠️ {success_rate:.1f}% success rate. Room for improvement.")
|
| 253 |
+
print(" Consider adding more regex patterns or enabling LLM mode.")
|
| 254 |
+
else:
|
| 255 |
+
print(f"\n❌ Low {success_rate:.1f}% success rate.")
|
| 256 |
+
print(" Your data may have unusual formats. Review failed extractions.")
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
if __name__ == "__main__":
|
| 260 |
+
main()
|
|
@@ -0,0 +1,302 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Step 4: Create Labeled Training Data
|
| 4 |
+
=====================================
|
| 5 |
+
|
| 6 |
+
Processes the clean SMS data and creates training labels.
|
| 7 |
+
Extracts:
|
| 8 |
+
- amount, type, account, date, reference (regex)
|
| 9 |
+
- beneficiary_name (from SMS pattern)
|
| 10 |
+
- Detects merchant vs P2P transactions
|
| 11 |
+
|
| 12 |
+
Usage:
|
| 13 |
+
python step4_label.py --input step2_sms_clean.csv --output step4_labeled.csv
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import argparse
|
| 17 |
+
import re
|
| 18 |
+
import json
|
| 19 |
+
import pandas as pd
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
from typing import Dict, Any, Optional, Tuple
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# ============================================================================
|
| 25 |
+
# ICICI BANK SMS PATTERNS (Dominant format in data)
|
| 26 |
+
# ============================================================================
|
| 27 |
+
|
| 28 |
+
ICICI_DEBIT_PATTERN = re.compile(
|
| 29 |
+
r'ICICI Bank Acc?t?\s*XX?(\d+)\s+debited\s+(?:for\s+)?Rs\.?\s*([\d,]+(?:\.\d{2})?)\s+on\s+(\d{1,2}-[A-Za-z]{3}-\d{2,4})[\s;]+([A-Za-z0-9\s]+?)\s+credited\.\s*UPI[:\s]*(\d+)',
|
| 30 |
+
re.IGNORECASE
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
ICICI_CREDIT_PATTERN = re.compile(
|
| 34 |
+
r'(?:Dear Customer,?\s*)?Acc?t?\s*XX?(\d+)\s+(?:is\s+)?credited\s+(?:with\s+)?Rs\.?\s*([\d,]+(?:\.\d{2})?)\s+on\s+(\d{1,2}-[A-Za-z]{3}-\d{2,4})\s+from\s+([A-Za-z0-9\s]+?)[\.\s]+UPI[:\s]*(\d+)',
|
| 35 |
+
re.IGNORECASE
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
# Generic amount pattern
|
| 39 |
+
AMOUNT_PATTERN = re.compile(r'Rs\.?\s*([\d,]+(?:\.\d{1,2})?)', re.IGNORECASE)
|
| 40 |
+
DATE_PATTERN = re.compile(r'(\d{1,2}[-/][A-Za-z]{3}[-/]\d{2,4}|\d{1,2}[-/]\d{1,2}[-/]\d{2,4})')
|
| 41 |
+
UPI_REF_PATTERN = re.compile(r'UPI[:\s]*(\d{12,16})', re.IGNORECASE)
|
| 42 |
+
ACCOUNT_PATTERN = re.compile(r'XX?(\d{3,4})', re.IGNORECASE)
|
| 43 |
+
|
| 44 |
+
# Merchant detection keywords (P2M vs P2P)
|
| 45 |
+
MERCHANT_KEYWORDS = {
|
| 46 |
+
'swiggy', 'zomato', 'uber', 'ola', 'amazon', 'flipkart', 'paytm',
|
| 47 |
+
'phonepe', 'google', 'youtube', 'netflix', 'spotify', 'airtel',
|
| 48 |
+
'jio', 'vodafone', 'bsnl', 'electricity', 'gas', 'water', 'bill',
|
| 49 |
+
'store', 'mart', 'shop', 'restaurant', 'hotel', 'hospital', 'clinic',
|
| 50 |
+
'pharmacy', 'petrol', 'fuel', 'charging', 'parking', 'toll', 'metro',
|
| 51 |
+
'railway', 'flight', 'bus', 'cab', 'taxi', 'rent', 'insurance',
|
| 52 |
+
'zepto', 'bigbasket', 'blinkit', 'instamart', 'dunzo', 'myntra',
|
| 53 |
+
'ajio', 'nykaa', 'tata', 'reliance', 'dmart', 'more', 'grofers'
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def is_merchant(beneficiary: str) -> bool:
|
| 58 |
+
"""Determine if beneficiary is a merchant (P2M) or person (P2P)."""
|
| 59 |
+
if not beneficiary:
|
| 60 |
+
return False
|
| 61 |
+
|
| 62 |
+
name_lower = beneficiary.lower().strip()
|
| 63 |
+
|
| 64 |
+
# Check against known merchant keywords
|
| 65 |
+
for keyword in MERCHANT_KEYWORDS:
|
| 66 |
+
if keyword in name_lower:
|
| 67 |
+
return True
|
| 68 |
+
|
| 69 |
+
# Heuristics for P2M vs P2P:
|
| 70 |
+
# - Merchants often have Ltd, Pvt, Inc, Store, Shop
|
| 71 |
+
# - Person names are usually 2-3 words
|
| 72 |
+
|
| 73 |
+
if any(x in name_lower for x in ['ltd', 'pvt', 'inc', 'llp', 'corp',
|
| 74 |
+
'store', 'shop', 'mart', 'services',
|
| 75 |
+
'limited', 'private']):
|
| 76 |
+
return True
|
| 77 |
+
|
| 78 |
+
# All caps names with numbers are likely merchants
|
| 79 |
+
if beneficiary.isupper() and any(c.isdigit() for c in beneficiary):
|
| 80 |
+
return True
|
| 81 |
+
|
| 82 |
+
return False
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def normalize_beneficiary(name: str) -> str:
|
| 86 |
+
"""Clean up beneficiary name."""
|
| 87 |
+
if not name:
|
| 88 |
+
return ""
|
| 89 |
+
|
| 90 |
+
# Remove trailing/leading whitespace
|
| 91 |
+
name = name.strip()
|
| 92 |
+
|
| 93 |
+
# Remove common suffixes
|
| 94 |
+
name = re.sub(r'\s+credited\.?$', '', name, flags=re.IGNORECASE)
|
| 95 |
+
name = re.sub(r'\s+debited\.?$', '', name, flags=re.IGNORECASE)
|
| 96 |
+
|
| 97 |
+
# Title case if all uppercase
|
| 98 |
+
if name.isupper():
|
| 99 |
+
name = name.title()
|
| 100 |
+
|
| 101 |
+
return name.strip()
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def extract_from_sms(body: str) -> Dict[str, Any]:
|
| 105 |
+
"""Extract all fields from SMS body."""
|
| 106 |
+
result = {
|
| 107 |
+
'amount': None,
|
| 108 |
+
'type': None,
|
| 109 |
+
'account': None,
|
| 110 |
+
'date': None,
|
| 111 |
+
'reference': None,
|
| 112 |
+
'beneficiary': None,
|
| 113 |
+
'is_merchant': False,
|
| 114 |
+
'category': None,
|
| 115 |
+
'extraction_method': None
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
# Try ICICI debit pattern
|
| 119 |
+
match = ICICI_DEBIT_PATTERN.search(body)
|
| 120 |
+
if match:
|
| 121 |
+
result['account'] = match.group(1)
|
| 122 |
+
result['amount'] = float(match.group(2).replace(',', ''))
|
| 123 |
+
result['date'] = match.group(3)
|
| 124 |
+
result['beneficiary'] = normalize_beneficiary(match.group(4))
|
| 125 |
+
result['reference'] = match.group(5)
|
| 126 |
+
result['type'] = 'debit'
|
| 127 |
+
result['is_merchant'] = is_merchant(result['beneficiary'])
|
| 128 |
+
result['extraction_method'] = 'icici_debit_pattern'
|
| 129 |
+
return result
|
| 130 |
+
|
| 131 |
+
# Try ICICI credit pattern
|
| 132 |
+
match = ICICI_CREDIT_PATTERN.search(body)
|
| 133 |
+
if match:
|
| 134 |
+
result['account'] = match.group(1)
|
| 135 |
+
result['amount'] = float(match.group(2).replace(',', ''))
|
| 136 |
+
result['date'] = match.group(3)
|
| 137 |
+
result['beneficiary'] = normalize_beneficiary(match.group(4))
|
| 138 |
+
result['reference'] = match.group(5)
|
| 139 |
+
result['type'] = 'credit'
|
| 140 |
+
result['is_merchant'] = is_merchant(result['beneficiary'])
|
| 141 |
+
result['extraction_method'] = 'icici_credit_pattern'
|
| 142 |
+
return result
|
| 143 |
+
|
| 144 |
+
# Fallback: generic extraction
|
| 145 |
+
# Amount
|
| 146 |
+
amount_match = AMOUNT_PATTERN.search(body)
|
| 147 |
+
if amount_match:
|
| 148 |
+
try:
|
| 149 |
+
result['amount'] = float(amount_match.group(1).replace(',', ''))
|
| 150 |
+
except:
|
| 151 |
+
pass
|
| 152 |
+
|
| 153 |
+
# Type
|
| 154 |
+
if re.search(r'\bdebit', body, re.IGNORECASE):
|
| 155 |
+
result['type'] = 'debit'
|
| 156 |
+
elif re.search(r'\bcredit', body, re.IGNORECASE):
|
| 157 |
+
result['type'] = 'credit'
|
| 158 |
+
|
| 159 |
+
# Account
|
| 160 |
+
acc_match = ACCOUNT_PATTERN.search(body)
|
| 161 |
+
if acc_match:
|
| 162 |
+
result['account'] = acc_match.group(1)
|
| 163 |
+
|
| 164 |
+
# Date
|
| 165 |
+
date_match = DATE_PATTERN.search(body)
|
| 166 |
+
if date_match:
|
| 167 |
+
result['date'] = date_match.group(1)
|
| 168 |
+
|
| 169 |
+
# Reference
|
| 170 |
+
ref_match = UPI_REF_PATTERN.search(body)
|
| 171 |
+
if ref_match:
|
| 172 |
+
result['reference'] = ref_match.group(1)
|
| 173 |
+
|
| 174 |
+
result['extraction_method'] = 'generic_fallback'
|
| 175 |
+
return result
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def create_training_label(row: Dict[str, Any], extraction: Dict[str, Any]) -> Dict[str, Any]:
|
| 179 |
+
"""Create a training label with ground truth."""
|
| 180 |
+
body = str(row.get('body', ''))
|
| 181 |
+
|
| 182 |
+
# Build ground truth JSON (what we want the model to output)
|
| 183 |
+
ground_truth = {
|
| 184 |
+
'amount': extraction['amount'],
|
| 185 |
+
'type': extraction['type'],
|
| 186 |
+
'account': extraction['account'],
|
| 187 |
+
'date': extraction['date'],
|
| 188 |
+
'reference': extraction['reference'],
|
| 189 |
+
'beneficiary': extraction['beneficiary'],
|
| 190 |
+
'is_p2m': extraction['is_merchant'],
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
# Remove None values
|
| 194 |
+
ground_truth = {k: v for k, v in ground_truth.items() if v is not None}
|
| 195 |
+
|
| 196 |
+
return {
|
| 197 |
+
# Original data
|
| 198 |
+
'timestamp': row.get('timestamp', ''),
|
| 199 |
+
'sender': row.get('sender', ''),
|
| 200 |
+
'body': body,
|
| 201 |
+
'source': row.get('source', ''),
|
| 202 |
+
|
| 203 |
+
# Extracted fields
|
| 204 |
+
**{f'extracted_{k}': v for k, v in extraction.items()},
|
| 205 |
+
|
| 206 |
+
# Training label (JSON format for LLM fine-tuning)
|
| 207 |
+
'ground_truth_json': json.dumps(ground_truth, ensure_ascii=False),
|
| 208 |
+
|
| 209 |
+
# Quality flags
|
| 210 |
+
'has_amount': extraction['amount'] is not None,
|
| 211 |
+
'has_type': extraction['type'] is not None,
|
| 212 |
+
'has_beneficiary': extraction['beneficiary'] is not None and len(extraction['beneficiary']) > 0,
|
| 213 |
+
'complete_extraction': all([
|
| 214 |
+
extraction['amount'] is not None,
|
| 215 |
+
extraction['type'] is not None,
|
| 216 |
+
extraction['reference'] is not None
|
| 217 |
+
]),
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def label_data(df: pd.DataFrame) -> pd.DataFrame:
|
| 222 |
+
"""Label all data for training."""
|
| 223 |
+
print("=" * 60)
|
| 224 |
+
print("🏷️ STEP 4: CREATING LABELED TRAINING DATA")
|
| 225 |
+
print("=" * 60)
|
| 226 |
+
|
| 227 |
+
results = []
|
| 228 |
+
complete_count = 0
|
| 229 |
+
|
| 230 |
+
for i, (_, row) in enumerate(df.iterrows()):
|
| 231 |
+
body = str(row.get('body', ''))
|
| 232 |
+
extraction = extract_from_sms(body)
|
| 233 |
+
label = create_training_label(row.to_dict(), extraction)
|
| 234 |
+
results.append(label)
|
| 235 |
+
|
| 236 |
+
if label['complete_extraction']:
|
| 237 |
+
complete_count += 1
|
| 238 |
+
|
| 239 |
+
if (i + 1) % 500 == 0:
|
| 240 |
+
print(f" Processed {i+1:,}/{len(df):,} ({100*complete_count/(i+1):.1f}% complete)")
|
| 241 |
+
|
| 242 |
+
result_df = pd.DataFrame(results)
|
| 243 |
+
|
| 244 |
+
print(f"\n📊 LABELING RESULTS:")
|
| 245 |
+
print(f" Total records: {len(result_df):,}")
|
| 246 |
+
print(f" Complete extractions: {complete_count:,} ({100*complete_count/len(result_df):.1f}%)")
|
| 247 |
+
print(f" Has amount: {result_df['has_amount'].sum():,}")
|
| 248 |
+
print(f" Has type: {result_df['has_type'].sum():,}")
|
| 249 |
+
print(f" Has beneficiary: {result_df['has_beneficiary'].sum():,}")
|
| 250 |
+
|
| 251 |
+
# Show breakdown by extraction method
|
| 252 |
+
print(f"\n📋 EXTRACTION METHODS:")
|
| 253 |
+
method_counts = result_df['extracted_extraction_method'].value_counts()
|
| 254 |
+
for method, count in method_counts.items():
|
| 255 |
+
print(f" {method}: {count:,}")
|
| 256 |
+
|
| 257 |
+
return result_df
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def main():
|
| 261 |
+
parser = argparse.ArgumentParser(description="Step 4: Create labeled training data")
|
| 262 |
+
parser.add_argument("--input", "-i", default="data/pipeline/step2_sms_clean.csv",
|
| 263 |
+
help="Input CSV from step 2 (SMS only)")
|
| 264 |
+
parser.add_argument("--output", "-o", default="data/pipeline/step4_labeled.csv",
|
| 265 |
+
help="Output CSV with labels")
|
| 266 |
+
args = parser.parse_args()
|
| 267 |
+
|
| 268 |
+
input_path = Path(args.input)
|
| 269 |
+
if not input_path.exists():
|
| 270 |
+
print(f"❌ Input file not found: {input_path}")
|
| 271 |
+
return
|
| 272 |
+
|
| 273 |
+
# Load data
|
| 274 |
+
print(f"\n📂 Loading: {input_path}")
|
| 275 |
+
df = pd.read_csv(input_path)
|
| 276 |
+
print(f" Loaded {len(df):,} records")
|
| 277 |
+
|
| 278 |
+
# Label data
|
| 279 |
+
labeled_df = label_data(df)
|
| 280 |
+
|
| 281 |
+
# Save output
|
| 282 |
+
output_path = Path(args.output)
|
| 283 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 284 |
+
labeled_df.to_csv(output_path, index=False)
|
| 285 |
+
|
| 286 |
+
print(f"\n✅ Saved labeled data to: {output_path}")
|
| 287 |
+
|
| 288 |
+
# Also save training-ready JSONL (for LLM fine-tuning)
|
| 289 |
+
jsonl_path = output_path.parent / "step4_training.jsonl"
|
| 290 |
+
with open(jsonl_path, 'w') as f:
|
| 291 |
+
for _, row in labeled_df[labeled_df['complete_extraction']].iterrows():
|
| 292 |
+
training_example = {
|
| 293 |
+
'input': row['body'],
|
| 294 |
+
'output': row['ground_truth_json']
|
| 295 |
+
}
|
| 296 |
+
f.write(json.dumps(training_example, ensure_ascii=False) + '\n')
|
| 297 |
+
|
| 298 |
+
print(f" JSONL for LLM training: {jsonl_path}")
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
if __name__ == "__main__":
|
| 302 |
+
main()
|
|
@@ -0,0 +1,578 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
LLM Fine-tuning Pipeline for Finance Entity Extraction
|
| 4 |
+
=======================================================
|
| 5 |
+
|
| 6 |
+
Fine-tunes a language model on the combined training data
|
| 7 |
+
for Indian banking transaction entity extraction.
|
| 8 |
+
|
| 9 |
+
Supports:
|
| 10 |
+
- MLX (Apple Silicon) via mlx-lm
|
| 11 |
+
- PyTorch/Transformers (GPU/CPU)
|
| 12 |
+
|
| 13 |
+
Usage:
|
| 14 |
+
python finetune.py --model microsoft/Phi-3-mini-4k-instruct --epochs 3
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import json
|
| 18 |
+
import argparse
|
| 19 |
+
import os
|
| 20 |
+
import sys
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
from datetime import datetime
|
| 23 |
+
from typing import List, Dict, Optional, Tuple
|
| 24 |
+
import random
|
| 25 |
+
|
| 26 |
+
# Check for MLX (Apple Silicon)
|
| 27 |
+
try:
|
| 28 |
+
import mlx
|
| 29 |
+
import mlx.core as mx
|
| 30 |
+
HAS_MLX = True
|
| 31 |
+
except ImportError:
|
| 32 |
+
HAS_MLX = False
|
| 33 |
+
|
| 34 |
+
# Check for PyTorch
|
| 35 |
+
try:
|
| 36 |
+
import torch
|
| 37 |
+
HAS_TORCH = True
|
| 38 |
+
except ImportError:
|
| 39 |
+
HAS_TORCH = False
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ============================================================================
|
| 43 |
+
# DATA PREPARATION
|
| 44 |
+
# ============================================================================
|
| 45 |
+
|
| 46 |
+
class DataPreparer:
|
| 47 |
+
"""Prepare training data for fine-tuning."""
|
| 48 |
+
|
| 49 |
+
SYSTEM_PROMPT = """You are a finance entity extraction assistant for Indian banking.
|
| 50 |
+
Extract structured information from banking SMS/email messages.
|
| 51 |
+
|
| 52 |
+
Output JSON with these fields (only include if found):
|
| 53 |
+
- amount: float (transaction amount)
|
| 54 |
+
- type: "debit" or "credit"
|
| 55 |
+
- account: string (last 4 digits)
|
| 56 |
+
- bank: string (bank name)
|
| 57 |
+
- date: string (transaction date)
|
| 58 |
+
- reference: string (UPI/NEFT reference)
|
| 59 |
+
- merchant: string (business name for P2M)
|
| 60 |
+
- beneficiary: string (person name for P2P)
|
| 61 |
+
- vpa: string (UPI ID)
|
| 62 |
+
- category: string (food, shopping, travel, etc.)
|
| 63 |
+
- is_p2m: boolean (true if merchant, false if person)
|
| 64 |
+
|
| 65 |
+
Be precise. Extract exactly what's in the message."""
|
| 66 |
+
|
| 67 |
+
def __init__(self, data_path: Path, val_split: float = 0.1):
|
| 68 |
+
self.data_path = data_path
|
| 69 |
+
self.val_split = val_split
|
| 70 |
+
self.train_data = []
|
| 71 |
+
self.val_data = []
|
| 72 |
+
|
| 73 |
+
def load_and_split(self) -> Tuple[List[Dict], List[Dict]]:
|
| 74 |
+
"""Load data and split into train/val."""
|
| 75 |
+
print(f"Loading data from {self.data_path}...")
|
| 76 |
+
|
| 77 |
+
all_data = []
|
| 78 |
+
with open(self.data_path, 'r', encoding='utf-8') as f:
|
| 79 |
+
for line in f:
|
| 80 |
+
try:
|
| 81 |
+
record = json.loads(line)
|
| 82 |
+
all_data.append(record)
|
| 83 |
+
except json.JSONDecodeError:
|
| 84 |
+
continue
|
| 85 |
+
|
| 86 |
+
print(f" Loaded {len(all_data):,} records")
|
| 87 |
+
|
| 88 |
+
# Shuffle
|
| 89 |
+
random.shuffle(all_data)
|
| 90 |
+
|
| 91 |
+
# Split
|
| 92 |
+
split_idx = int(len(all_data) * (1 - self.val_split))
|
| 93 |
+
self.train_data = all_data[:split_idx]
|
| 94 |
+
self.val_data = all_data[split_idx:]
|
| 95 |
+
|
| 96 |
+
print(f" Train: {len(self.train_data):,}, Val: {len(self.val_data):,}")
|
| 97 |
+
|
| 98 |
+
return self.train_data, self.val_data
|
| 99 |
+
|
| 100 |
+
def format_for_chat(self, record: Dict) -> Dict:
|
| 101 |
+
"""Format record for chat-style fine-tuning."""
|
| 102 |
+
input_text = record.get('input', record.get('text', ''))
|
| 103 |
+
output_text = record.get('output', '{}')
|
| 104 |
+
|
| 105 |
+
if isinstance(output_text, dict):
|
| 106 |
+
output_text = json.dumps(output_text, ensure_ascii=False)
|
| 107 |
+
|
| 108 |
+
return {
|
| 109 |
+
'messages': [
|
| 110 |
+
{'role': 'system', 'content': self.SYSTEM_PROMPT},
|
| 111 |
+
{'role': 'user', 'content': input_text},
|
| 112 |
+
{'role': 'assistant', 'content': output_text},
|
| 113 |
+
]
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
def format_for_completion(self, record: Dict) -> Dict:
|
| 117 |
+
"""Format record for completion-style fine-tuning."""
|
| 118 |
+
input_text = record.get('input', record.get('text', ''))
|
| 119 |
+
output_text = record.get('output', '{}')
|
| 120 |
+
|
| 121 |
+
if isinstance(output_text, dict):
|
| 122 |
+
output_text = json.dumps(output_text, ensure_ascii=False)
|
| 123 |
+
|
| 124 |
+
prompt = f"""Extract financial entities from this message:
|
| 125 |
+
|
| 126 |
+
Message: {input_text}
|
| 127 |
+
|
| 128 |
+
JSON:"""
|
| 129 |
+
|
| 130 |
+
return {
|
| 131 |
+
'prompt': prompt,
|
| 132 |
+
'completion': output_text,
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
def save_formatted(
|
| 136 |
+
self,
|
| 137 |
+
output_dir: Path,
|
| 138 |
+
format_type: str = 'chat'
|
| 139 |
+
) -> Tuple[Path, Path]:
|
| 140 |
+
"""Save formatted train/val data."""
|
| 141 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 142 |
+
|
| 143 |
+
train_path = output_dir / 'train.jsonl'
|
| 144 |
+
val_path = output_dir / 'valid.jsonl'
|
| 145 |
+
|
| 146 |
+
formatter = (
|
| 147 |
+
self.format_for_chat if format_type == 'chat'
|
| 148 |
+
else self.format_for_completion
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
# Save train
|
| 152 |
+
with open(train_path, 'w', encoding='utf-8') as f:
|
| 153 |
+
for record in self.train_data:
|
| 154 |
+
formatted = formatter(record)
|
| 155 |
+
f.write(json.dumps(formatted, ensure_ascii=False) + '\n')
|
| 156 |
+
|
| 157 |
+
# Save val
|
| 158 |
+
with open(val_path, 'w', encoding='utf-8') as f:
|
| 159 |
+
for record in self.val_data:
|
| 160 |
+
formatted = formatter(record)
|
| 161 |
+
f.write(json.dumps(formatted, ensure_ascii=False) + '\n')
|
| 162 |
+
|
| 163 |
+
print(f" Saved train: {train_path}")
|
| 164 |
+
print(f" Saved valid: {val_path}")
|
| 165 |
+
|
| 166 |
+
return train_path, val_path
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
# ============================================================================
|
| 170 |
+
# MLX FINE-TUNING (Apple Silicon)
|
| 171 |
+
# ============================================================================
|
| 172 |
+
|
| 173 |
+
class MLXFineTuner:
|
| 174 |
+
"""Fine-tune using MLX-LM on Apple Silicon."""
|
| 175 |
+
|
| 176 |
+
def __init__(
|
| 177 |
+
self,
|
| 178 |
+
model_name: str,
|
| 179 |
+
output_dir: Path,
|
| 180 |
+
lora_rank: int = 8,
|
| 181 |
+
lora_layers: int = 16,
|
| 182 |
+
):
|
| 183 |
+
self.model_name = model_name
|
| 184 |
+
self.output_dir = output_dir
|
| 185 |
+
self.lora_rank = lora_rank
|
| 186 |
+
self.lora_layers = lora_layers
|
| 187 |
+
|
| 188 |
+
def train(
|
| 189 |
+
self,
|
| 190 |
+
train_path: Path,
|
| 191 |
+
val_path: Path,
|
| 192 |
+
epochs: int = 3,
|
| 193 |
+
batch_size: int = 4,
|
| 194 |
+
learning_rate: float = 1e-5,
|
| 195 |
+
save_every: int = 100,
|
| 196 |
+
):
|
| 197 |
+
"""Run MLX-LM LoRA fine-tuning."""
|
| 198 |
+
import subprocess
|
| 199 |
+
|
| 200 |
+
cmd = [
|
| 201 |
+
sys.executable, '-m', 'mlx_lm.lora',
|
| 202 |
+
'--model', self.model_name,
|
| 203 |
+
'--train',
|
| 204 |
+
'--data', str(train_path.parent),
|
| 205 |
+
'--lora-layers', str(self.lora_layers),
|
| 206 |
+
'--lora-rank', str(self.lora_rank),
|
| 207 |
+
'--batch-size', str(batch_size),
|
| 208 |
+
'--iters', str(epochs * 1000),
|
| 209 |
+
'--learning-rate', str(learning_rate),
|
| 210 |
+
'--save-every', str(save_every),
|
| 211 |
+
'--adapter-path', str(self.output_dir / 'adapters'),
|
| 212 |
+
]
|
| 213 |
+
|
| 214 |
+
print(f"\n🚀 Starting MLX-LM LoRA training...")
|
| 215 |
+
print(f" Command: {' '.join(cmd)}")
|
| 216 |
+
print()
|
| 217 |
+
|
| 218 |
+
result = subprocess.run(cmd, capture_output=False)
|
| 219 |
+
|
| 220 |
+
return result.returncode == 0
|
| 221 |
+
|
| 222 |
+
def fuse(self):
|
| 223 |
+
"""Fuse LoRA adapters with base model."""
|
| 224 |
+
import subprocess
|
| 225 |
+
|
| 226 |
+
adapter_path = self.output_dir / 'adapters'
|
| 227 |
+
fused_path = self.output_dir / 'fused'
|
| 228 |
+
|
| 229 |
+
cmd = [
|
| 230 |
+
sys.executable, '-m', 'mlx_lm.fuse',
|
| 231 |
+
'--model', self.model_name,
|
| 232 |
+
'--adapter-path', str(adapter_path),
|
| 233 |
+
'--save-path', str(fused_path),
|
| 234 |
+
]
|
| 235 |
+
|
| 236 |
+
print(f"\n🔗 Fusing LoRA adapters...")
|
| 237 |
+
result = subprocess.run(cmd, capture_output=False)
|
| 238 |
+
|
| 239 |
+
return result.returncode == 0
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
# ============================================================================
|
| 243 |
+
# PYTORCH/TRANSFORMERS FINE-TUNING
|
| 244 |
+
# ============================================================================
|
| 245 |
+
|
| 246 |
+
class TransformersFineTuner:
|
| 247 |
+
"""Fine-tune using PyTorch/Transformers."""
|
| 248 |
+
|
| 249 |
+
def __init__(
|
| 250 |
+
self,
|
| 251 |
+
model_name: str,
|
| 252 |
+
output_dir: Path,
|
| 253 |
+
lora_rank: int = 8,
|
| 254 |
+
):
|
| 255 |
+
self.model_name = model_name
|
| 256 |
+
self.output_dir = output_dir
|
| 257 |
+
self.lora_rank = lora_rank
|
| 258 |
+
|
| 259 |
+
def train(
|
| 260 |
+
self,
|
| 261 |
+
train_path: Path,
|
| 262 |
+
val_path: Path,
|
| 263 |
+
epochs: int = 3,
|
| 264 |
+
batch_size: int = 4,
|
| 265 |
+
learning_rate: float = 2e-5,
|
| 266 |
+
):
|
| 267 |
+
"""Run Transformers fine-tuning with PEFT."""
|
| 268 |
+
try:
|
| 269 |
+
from transformers import (
|
| 270 |
+
AutoModelForCausalLM,
|
| 271 |
+
AutoTokenizer,
|
| 272 |
+
TrainingArguments,
|
| 273 |
+
Trainer,
|
| 274 |
+
DataCollatorForSeq2Seq,
|
| 275 |
+
)
|
| 276 |
+
from peft import LoraConfig, get_peft_model
|
| 277 |
+
from datasets import load_dataset
|
| 278 |
+
except ImportError as e:
|
| 279 |
+
print(f"❌ Missing dependencies: {e}")
|
| 280 |
+
print(" Run: pip install transformers peft datasets")
|
| 281 |
+
return False
|
| 282 |
+
|
| 283 |
+
print(f"\n🚀 Loading model: {self.model_name}")
|
| 284 |
+
|
| 285 |
+
# Load model & tokenizer
|
| 286 |
+
tokenizer = AutoTokenizer.from_pretrained(self.model_name)
|
| 287 |
+
model = AutoModelForCausalLM.from_pretrained(
|
| 288 |
+
self.model_name,
|
| 289 |
+
torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32,
|
| 290 |
+
device_map='auto' if torch.cuda.is_available() else None,
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
# Add padding token if needed
|
| 294 |
+
if tokenizer.pad_token is None:
|
| 295 |
+
tokenizer.pad_token = tokenizer.eos_token
|
| 296 |
+
|
| 297 |
+
# LoRA config
|
| 298 |
+
lora_config = LoraConfig(
|
| 299 |
+
r=self.lora_rank,
|
| 300 |
+
lora_alpha=32,
|
| 301 |
+
target_modules=['q_proj', 'v_proj', 'k_proj', 'o_proj'],
|
| 302 |
+
lora_dropout=0.05,
|
| 303 |
+
bias='none',
|
| 304 |
+
task_type='CAUSAL_LM',
|
| 305 |
+
)
|
| 306 |
+
|
| 307 |
+
model = get_peft_model(model, lora_config)
|
| 308 |
+
model.print_trainable_parameters()
|
| 309 |
+
|
| 310 |
+
# Load dataset
|
| 311 |
+
dataset = load_dataset(
|
| 312 |
+
'json',
|
| 313 |
+
data_files={
|
| 314 |
+
'train': str(train_path),
|
| 315 |
+
'validation': str(val_path),
|
| 316 |
+
}
|
| 317 |
+
)
|
| 318 |
+
|
| 319 |
+
# Tokenize
|
| 320 |
+
def tokenize(examples):
|
| 321 |
+
# For chat format
|
| 322 |
+
if 'messages' in examples:
|
| 323 |
+
texts = []
|
| 324 |
+
for msgs in examples['messages']:
|
| 325 |
+
text = ''
|
| 326 |
+
for msg in msgs:
|
| 327 |
+
text += f"<|{msg['role']}|>\n{msg['content']}\n"
|
| 328 |
+
texts.append(text)
|
| 329 |
+
else:
|
| 330 |
+
texts = [f"{p}\n{c}" for p, c in zip(examples['prompt'], examples['completion'])]
|
| 331 |
+
|
| 332 |
+
return tokenizer(
|
| 333 |
+
texts,
|
| 334 |
+
truncation=True,
|
| 335 |
+
max_length=512,
|
| 336 |
+
padding='max_length',
|
| 337 |
+
)
|
| 338 |
+
|
| 339 |
+
tokenized = dataset.map(tokenize, batched=True, remove_columns=dataset['train'].column_names)
|
| 340 |
+
|
| 341 |
+
# Training args
|
| 342 |
+
training_args = TrainingArguments(
|
| 343 |
+
output_dir=str(self.output_dir),
|
| 344 |
+
num_train_epochs=epochs,
|
| 345 |
+
per_device_train_batch_size=batch_size,
|
| 346 |
+
per_device_eval_batch_size=batch_size,
|
| 347 |
+
learning_rate=learning_rate,
|
| 348 |
+
logging_steps=100,
|
| 349 |
+
save_steps=500,
|
| 350 |
+
evaluation_strategy='steps',
|
| 351 |
+
eval_steps=500,
|
| 352 |
+
fp16=torch.cuda.is_available(),
|
| 353 |
+
report_to='none',
|
| 354 |
+
)
|
| 355 |
+
|
| 356 |
+
# Trainer
|
| 357 |
+
trainer = Trainer(
|
| 358 |
+
model=model,
|
| 359 |
+
args=training_args,
|
| 360 |
+
train_dataset=tokenized['train'],
|
| 361 |
+
eval_dataset=tokenized['validation'],
|
| 362 |
+
data_collator=DataCollatorForSeq2Seq(tokenizer, padding=True),
|
| 363 |
+
)
|
| 364 |
+
|
| 365 |
+
print(f"\n🚀 Starting training...")
|
| 366 |
+
trainer.train()
|
| 367 |
+
|
| 368 |
+
# Save
|
| 369 |
+
model.save_pretrained(self.output_dir / 'adapters')
|
| 370 |
+
tokenizer.save_pretrained(self.output_dir / 'adapters')
|
| 371 |
+
|
| 372 |
+
print(f"\n✅ Saved to: {self.output_dir / 'adapters'}")
|
| 373 |
+
return True
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
# ============================================================================
|
| 377 |
+
# EVALUATION
|
| 378 |
+
# ============================================================================
|
| 379 |
+
|
| 380 |
+
class Evaluator:
|
| 381 |
+
"""Evaluate fine-tuned model."""
|
| 382 |
+
|
| 383 |
+
def __init__(self, model_path: Path, backend: str = 'mlx'):
|
| 384 |
+
self.model_path = model_path
|
| 385 |
+
self.backend = backend
|
| 386 |
+
|
| 387 |
+
def evaluate(self, test_data: List[Dict], max_samples: int = 100) -> Dict:
|
| 388 |
+
"""Evaluate on test data."""
|
| 389 |
+
if self.backend == 'mlx':
|
| 390 |
+
return self._evaluate_mlx(test_data[:max_samples])
|
| 391 |
+
else:
|
| 392 |
+
return self._evaluate_torch(test_data[:max_samples])
|
| 393 |
+
|
| 394 |
+
def _evaluate_mlx(self, test_data: List[Dict]) -> Dict:
|
| 395 |
+
"""Evaluate with MLX."""
|
| 396 |
+
from mlx_lm import load, generate
|
| 397 |
+
|
| 398 |
+
model, tokenizer = load(str(self.model_path))
|
| 399 |
+
|
| 400 |
+
correct = 0
|
| 401 |
+
total = 0
|
| 402 |
+
field_matches = {'amount': 0, 'type': 0, 'merchant': 0}
|
| 403 |
+
|
| 404 |
+
for record in test_data:
|
| 405 |
+
input_text = record.get('input', record.get('text', ''))
|
| 406 |
+
expected = record.get('output', '{}')
|
| 407 |
+
if isinstance(expected, str):
|
| 408 |
+
expected = json.loads(expected)
|
| 409 |
+
|
| 410 |
+
prompt = f"Extract financial entities:\n\n{input_text}\n\nJSON:"
|
| 411 |
+
|
| 412 |
+
output = generate(
|
| 413 |
+
model, tokenizer, prompt,
|
| 414 |
+
max_tokens=256,
|
| 415 |
+
temp=0.0,
|
| 416 |
+
)
|
| 417 |
+
|
| 418 |
+
try:
|
| 419 |
+
predicted = json.loads(output)
|
| 420 |
+
|
| 421 |
+
# Check fields
|
| 422 |
+
for field in field_matches:
|
| 423 |
+
if predicted.get(field) == expected.get(field):
|
| 424 |
+
field_matches[field] += 1
|
| 425 |
+
|
| 426 |
+
# Full match
|
| 427 |
+
if predicted == expected:
|
| 428 |
+
correct += 1
|
| 429 |
+
except json.JSONDecodeError:
|
| 430 |
+
pass
|
| 431 |
+
|
| 432 |
+
total += 1
|
| 433 |
+
|
| 434 |
+
return {
|
| 435 |
+
'accuracy': correct / total if total > 0 else 0,
|
| 436 |
+
'field_accuracy': {k: v/total for k, v in field_matches.items()},
|
| 437 |
+
'total_samples': total,
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
def _evaluate_torch(self, test_data: List[Dict]) -> Dict:
|
| 441 |
+
"""Evaluate with PyTorch."""
|
| 442 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 443 |
+
from peft import PeftModel
|
| 444 |
+
|
| 445 |
+
# Load
|
| 446 |
+
base_model = AutoModelForCausalLM.from_pretrained(
|
| 447 |
+
self.model_path.parent / 'base',
|
| 448 |
+
torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32,
|
| 449 |
+
)
|
| 450 |
+
model = PeftModel.from_pretrained(base_model, str(self.model_path))
|
| 451 |
+
tokenizer = AutoTokenizer.from_pretrained(str(self.model_path))
|
| 452 |
+
|
| 453 |
+
# Similar evaluation logic...
|
| 454 |
+
return {'accuracy': 0, 'note': 'PyTorch evaluation not fully implemented'}
|
| 455 |
+
|
| 456 |
+
|
| 457 |
+
# ============================================================================
|
| 458 |
+
# MAIN PIPELINE
|
| 459 |
+
# ============================================================================
|
| 460 |
+
|
| 461 |
+
def main():
|
| 462 |
+
parser = argparse.ArgumentParser(description="LLM Fine-tuning Pipeline")
|
| 463 |
+
parser.add_argument('--data', default='data/training/final_combined_training.jsonl',
|
| 464 |
+
help='Training data path')
|
| 465 |
+
parser.add_argument('--model', default='microsoft/Phi-3-mini-4k-instruct',
|
| 466 |
+
help='Base model')
|
| 467 |
+
parser.add_argument('--output', default='models/finetune',
|
| 468 |
+
help='Output directory')
|
| 469 |
+
parser.add_argument('--epochs', type=int, default=3, help='Training epochs')
|
| 470 |
+
parser.add_argument('--batch-size', type=int, default=4, help='Batch size')
|
| 471 |
+
parser.add_argument('--lr', type=float, default=1e-5, help='Learning rate')
|
| 472 |
+
parser.add_argument('--lora-rank', type=int, default=8, help='LoRA rank')
|
| 473 |
+
parser.add_argument('--backend', choices=['mlx', 'torch', 'auto'], default='auto',
|
| 474 |
+
help='Training backend')
|
| 475 |
+
parser.add_argument('--skip-train', action='store_true', help='Skip training, just prepare data')
|
| 476 |
+
parser.add_argument('--evaluate', action='store_true', help='Evaluate after training')
|
| 477 |
+
|
| 478 |
+
args = parser.parse_args()
|
| 479 |
+
|
| 480 |
+
# Determine backend
|
| 481 |
+
if args.backend == 'auto':
|
| 482 |
+
if HAS_MLX:
|
| 483 |
+
backend = 'mlx'
|
| 484 |
+
print("🍎 Using MLX (Apple Silicon)")
|
| 485 |
+
elif HAS_TORCH:
|
| 486 |
+
backend = 'torch'
|
| 487 |
+
print("🔥 Using PyTorch/Transformers")
|
| 488 |
+
else:
|
| 489 |
+
print("❌ No backend available. Install mlx-lm or transformers+peft")
|
| 490 |
+
return
|
| 491 |
+
else:
|
| 492 |
+
backend = args.backend
|
| 493 |
+
|
| 494 |
+
data_path = Path(args.data)
|
| 495 |
+
output_dir = Path(args.output)
|
| 496 |
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
| 497 |
+
run_dir = output_dir / f"run_{timestamp}"
|
| 498 |
+
|
| 499 |
+
print("=" * 60)
|
| 500 |
+
print("🚀 LLM FINE-TUNING PIPELINE")
|
| 501 |
+
print("=" * 60)
|
| 502 |
+
print(f" Data: {data_path}")
|
| 503 |
+
print(f" Model: {args.model}")
|
| 504 |
+
print(f" Output: {run_dir}")
|
| 505 |
+
print(f" Backend: {backend}")
|
| 506 |
+
print(f" Epochs: {args.epochs}")
|
| 507 |
+
|
| 508 |
+
# Step 1: Prepare data
|
| 509 |
+
print("\n📋 Step 1: Preparing data...")
|
| 510 |
+
preparer = DataPreparer(data_path)
|
| 511 |
+
train_data, val_data = preparer.load_and_split()
|
| 512 |
+
|
| 513 |
+
formatted_dir = run_dir / 'data'
|
| 514 |
+
format_type = 'chat' if backend == 'torch' else 'completion'
|
| 515 |
+
train_path, val_path = preparer.save_formatted(formatted_dir, format_type)
|
| 516 |
+
|
| 517 |
+
if args.skip_train:
|
| 518 |
+
print("\n⏭️ Skipping training (--skip-train)")
|
| 519 |
+
return
|
| 520 |
+
|
| 521 |
+
# Step 2: Train
|
| 522 |
+
print("\n🎯 Step 2: Training...")
|
| 523 |
+
|
| 524 |
+
if backend == 'mlx':
|
| 525 |
+
trainer = MLXFineTuner(
|
| 526 |
+
model_name=args.model,
|
| 527 |
+
output_dir=run_dir,
|
| 528 |
+
lora_rank=args.lora_rank,
|
| 529 |
+
)
|
| 530 |
+
success = trainer.train(
|
| 531 |
+
train_path, val_path,
|
| 532 |
+
epochs=args.epochs,
|
| 533 |
+
batch_size=args.batch_size,
|
| 534 |
+
learning_rate=args.lr,
|
| 535 |
+
)
|
| 536 |
+
|
| 537 |
+
if success:
|
| 538 |
+
print("\n🔗 Step 3: Fusing adapters...")
|
| 539 |
+
trainer.fuse()
|
| 540 |
+
else:
|
| 541 |
+
trainer = TransformersFineTuner(
|
| 542 |
+
model_name=args.model,
|
| 543 |
+
output_dir=run_dir,
|
| 544 |
+
lora_rank=args.lora_rank,
|
| 545 |
+
)
|
| 546 |
+
success = trainer.train(
|
| 547 |
+
train_path, val_path,
|
| 548 |
+
epochs=args.epochs,
|
| 549 |
+
batch_size=args.batch_size,
|
| 550 |
+
learning_rate=args.lr,
|
| 551 |
+
)
|
| 552 |
+
|
| 553 |
+
# Step 3: Evaluate
|
| 554 |
+
if args.evaluate and success:
|
| 555 |
+
print("\n📊 Step 4: Evaluating...")
|
| 556 |
+
evaluator = Evaluator(run_dir / 'fused' if backend == 'mlx' else run_dir / 'adapters', backend)
|
| 557 |
+
results = evaluator.evaluate(val_data)
|
| 558 |
+
|
| 559 |
+
print(f"\n📊 Results:")
|
| 560 |
+
print(f" Overall Accuracy: {results.get('accuracy', 0):.1%}")
|
| 561 |
+
for field, acc in results.get('field_accuracy', {}).items():
|
| 562 |
+
print(f" {field}: {acc:.1%}")
|
| 563 |
+
|
| 564 |
+
# Save results
|
| 565 |
+
with open(run_dir / 'eval_results.json', 'w') as f:
|
| 566 |
+
json.dump(results, f, indent=2)
|
| 567 |
+
|
| 568 |
+
print("\n" + "=" * 60)
|
| 569 |
+
print("✅ FINE-TUNING COMPLETE")
|
| 570 |
+
print("=" * 60)
|
| 571 |
+
print(f" Output: {run_dir}")
|
| 572 |
+
print(f" Adapters: {run_dir / 'adapters'}")
|
| 573 |
+
if backend == 'mlx':
|
| 574 |
+
print(f" Fused model: {run_dir / 'fused'}")
|
| 575 |
+
|
| 576 |
+
|
| 577 |
+
if __name__ == "__main__":
|
| 578 |
+
main()
|