winsweb commited on
Commit
c9875bd
·
1 Parent(s): 53c5d36

Deploy FastAPI Telecom Anomaly Detection to Hugging Face

Browse files
.dockerignore ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .gitignore
3
+ .venv
4
+ .python-version
5
+ __pycache__
6
+ *.pyc
7
+ *.pyo
8
+ *.pyd
9
+ .pytest_cache
10
+ data/
11
+ logs/
12
+ uv.lock
13
+ pyproject.toml
14
+ README.md
15
+ Task.txt
16
+ Task2.txt
17
+ Task3.txt
18
+ error.txt
19
+ tests/
.gitignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
.python-version ADDED
@@ -0,0 +1 @@
 
 
1
+ 3.14
Dockerfile ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use official Python slim image
2
+ FROM python:3.9-slim
3
+
4
+ # Install Java Runtime Environment (REQUIRED for tabula-py to process PDFs)
5
+ RUN apt-get update && \
6
+ apt-get install -y --no-install-recommends default-jre && \
7
+ apt-get clean && \
8
+ rm -rf /var/lib/apt/lists/*
9
+
10
+ # Set working directory
11
+ WORKDIR /app
12
+
13
+ # Set PYTHONPATH to ensure imports work correctly
14
+ ENV PYTHONPATH=/app
15
+
16
+ # Copy requirements and install
17
+ COPY requirements.txt .
18
+ RUN pip install --no-cache-dir -r requirements.txt
19
+
20
+ # Copy the rest of the application
21
+ COPY . .
22
+
23
+ # Create necessary directories (though the app is configured not to save, logic uses them)
24
+ RUN mkdir -p data/raw_csv_temp data/output_process_data data/strictly_cleaned_data data/output data/save_pdf logs
25
+
26
+ # Expose port 7860 (Hugging Face default)
27
+ EXPOSE 7860
28
+
29
+ # Command to run the API
30
+ CMD ["uvicorn", "telecom_anomaly.api:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,10 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
- title: Teleprocessinggh
3
- emoji: 🏢
4
- colorFrom: pink
5
- colorTo: yellow
6
- sdk: docker
7
- pinned: false
 
 
 
 
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 📱 Telecom Anomaly Detection System
2
+
3
+ A production-ready anomaly detection system for telecommunications Call Data Records (CDR). This system identifies unusual patterns in subscriber behavior, ranging from potential fraud to technical malfunctions.
4
+
5
+ ## 🚀 How it Works
6
+
7
+ The system operates as an automated pipeline that transforms raw CDR data into actionable security insights:
8
+
9
+ 1. **Data Ingestion**: Reads CSV files from the `data/input` directory or direct CLI arguments.
10
+ 2. **Validation & Cleaning**: Filters out invalid records, parses dates/times, and categorizes calls (e.g., voice vs. SMS, service vs. subscriber).
11
+ 3. **Multi-Layered Detection**: Parallel analysis of each record across multiple specialized detector modules.
12
+ 4. **Anomaly Aggregation**: Combines findings per subscriber and per record, calculating confidence scores and eliminating duplicates.
13
+ 5. **Reporting**: Generates multiple output formats for different stakeholders (analysts, systems, executives).
14
+
15
  ---
16
+
17
+ ## 🔍 Core Detection Modules
18
+
19
+ The system uses 6 primary detection strategies:
20
+
21
+ | Detector | Logic | Use Case |
22
+ | :--- | :--- | :--- |
23
+ | **Duration** | Monitors for "infinite calls" or "flash calls" | Fraud, billing errors |
24
+ | **Frequency** | Tracks calls per minute/hour/day | Automated dialing, SMS flooding |
25
+ | **SMS** | Specifically analyzes SMS volume and rate | Spam, bulk-SMS marketing |
26
+ | **Location** | Calculates velocity between consecutive activities | Account sharing, travel anomalies |
27
+ | **IMEI** | Tracks hardware-to-subscriber changes | SIM swapping, device theft |
28
+ | **Time Pattern** | Identifies activity during unusual (night) hours | Targeted attacks, outlier behavior |
29
+
30
  ---
31
 
32
+ ## 🛠 Architecture
33
+
34
+ The project follows a modular orchestrator-based architecture:
35
+
36
+ - **`TelecomAnomalyDetector` (Core)**: The central brain that coordinates the reader, validator, and all detector modules.
37
+ - **`TelecomDataReader`**: Handles high-performance batch reading of large CSV files.
38
+ - **`DataValidator`**: Ensures MSISDN formats, geographical coordinates, and timestamps are valid.
39
+ - **`ResultExporter`**: Formats findings into JSON (for APIs), CSV (for Excel), and Markdown (for reports).
40
+
41
+ ---
42
+
43
+ ## 📊 Data Requirements
44
+
45
+ The system expects CSV files with the following core columns:
46
+
47
+ - `msisdn`: Subscriber identification (e.g., `233XXXXXXXXX`)
48
+ - `usage_type`: `VOICE` or `SMS`
49
+ - `duration`: Call length in seconds
50
+ - `latitude` / `longitude`: Geographical coordinates of the activity
51
+ - `date` / `time`: Activity timestamps (`YYYY-MM-DD` and `HH:MM:SS`)
52
+ - `imei`: Device hardware identifier
53
+
54
+ ---
55
+
56
+ ## ⚙️ Configuration
57
+
58
+ Detection sensitivity can be tuned in `config/settings.py` or via `TelecomConfig` overrides:
59
+
60
+ ```python
61
+ DEFAULT_THRESHOLDS = {
62
+ 'extreme_call_threshold': 10800.0, # 3 hours
63
+ 'max_calls_per_hour': 60, # >1 call/minute
64
+ 'max_location_change_speed': 1000.0,# km/h (impossible travel)
65
+ 'night_hours_start': 23, # Start of "unusual" hours
66
+ }
67
+ ```
68
+
69
+ ---
70
+
71
+ ## 💻 Installation & Usage
72
+
73
+ ### Setup with uv
74
+ The project uses [uv](https://github.com/astral-sh/uv) for high-performance dependency management.
75
+
76
+ ```bash
77
+ # Clone and enter the repository
78
+ git clone https://github.com/yourusername/telecom-anomaly-detection.git
79
+ cd telecom-anomaly-detection
80
+
81
+ # Sync dependencies and create virtual environment
82
+ uv sync
83
+ ```
84
+
85
+ ### Running the Analysis
86
+ Place your CSV files in `data/input/` and run using `uv run`:
87
+ ```bash
88
+ uv run python -m telecom_anomaly.main
89
+ ```
90
+
91
+ Or specify files directly:
92
+ ```bash
93
+ uv run python -m telecom_anomaly.main data/input/records_2023_10.csv
94
+ ```
95
+
96
+ ### Development
97
+ ```bash
98
+ # Run tests
99
+ uv run pytest
100
+
101
+ # Format code
102
+ uv run black .
103
+
104
+ # Lint code
105
+ uv run flake8 .
106
+ ```
107
+
108
+ ## 🌐 API (FastAPI)
109
+
110
+ The project includes a FastAPI-based REST API for programmatic access to the anomaly detection engine.
111
+
112
+ ### Running the API
113
+ ```bash
114
+ uv run python -m uvicorn telecom_anomaly.api:app --reload
115
+ ```
116
+ The API will be available at `http://127.0.0.1:8000`.
117
+
118
+ ### API Documentation
119
+ Interactive documentation is available at:
120
+ - Swagger UI: `http://127.0.0.1:8000/docs`
121
+ - ReDoc: `http://127.0.0.1:8000/redoc`
122
+
123
+ ### Key Endpoints
124
+
125
+ #### `POST /analyze`
126
+ Upload a CDR CSV file for real-time analysis.
127
+
128
+ **Example using curl:**
129
+ ```bash
130
+ curl -X 'POST' \
131
+ 'http://127.0.0.1:8000/analyze' \
132
+ -H 'accept: application/json' \
133
+ -H 'Content-Type: multipart/form-data' \
134
+ -F 'file=@data/input/sample_cdr.csv'
135
+ ```
136
+
137
+ **Response Example:**
138
+ ```json
139
+ {
140
+ "filename": "sample_cdr.csv",
141
+ "statistics": {
142
+ "total_rows": 1000,
143
+ "rows_with_anomalies": 15,
144
+ "total_anomalies_detected": 22,
145
+ "anomaly_rate": 1.5,
146
+ "unique_subscribers": 12,
147
+ "anomaly_types": {
148
+ "short_duration": 10,
149
+ "impossible_speed": 5
150
+ }
151
+ },
152
+ "anomalies": [
153
+ {
154
+ "row_index": 42,
155
+ "column_name": "duration",
156
+ "value": 1.2,
157
+ "detection_method": "short_duration",
158
+ "score": 2.5,
159
+ "threshold": 3.0,
160
+ "confidence": 0.75,
161
+ "msisdn": "233123456789"
162
+ }
163
+ ]
164
+ }
165
+ ```
166
+
167
+ #### `GET /health`
168
+ Returns the health status of the API.
READ_ME.md ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Teleprocessinggh
3
+ emoji: 🏢
4
+ colorFrom: pink
5
+ colorTo: yellow
6
+ sdk: docker
7
+ pinned: false
8
+ ---
9
+
10
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
RunAPI.md ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Deployment & API Guide
2
+
3
+ This guide explains how to push your code to GitHub and deploy the API so you can access the Swagger UI.
4
+
5
+ ## 1. Push to GitHub
6
+
7
+ 1. **Initialize Git** (if not already done):
8
+ ```bash
9
+ git init
10
+ git add .
11
+ git commit -m "Initial commit: Joined PDF/CSV API with Docker support"
12
+ ```
13
+
14
+ 2. **Create a Repository** on GitHub (e.g., `telecom-anomaly-detection`).
15
+
16
+ 3. **Link and Push**:
17
+ ```bash
18
+ git remote add origin https://github.com/YOUR_USERNAME/YOUR_REPO_NAME.git
19
+ git branch -M main
20
+ git push -u origin main
21
+ ```
22
+
23
+ ---
24
+
25
+ ## 2. Deploying to Vercel (Serverless)
26
+
27
+ Vercel is primarily for frontend, but you can run the FastAPI backend using their Python runtime.
28
+
29
+ 1. **Install Vercel CLI**: `npm i -g vercel`
30
+ 2. **Login**: `vercel login`
31
+ 3. **Deploy**: Run `vercel` in the project root.
32
+ 4. **Access Swagger**: Once deployed, go to `https://your-project-name.vercel.app/docs`.
33
+
34
+ **⚠️ Note:** If the PDF processing fails on Vercel due to "Java not found", you MUST use the Docker method below.
35
+
36
+ ---
37
+
38
+ ## 3. Recommended: Deploying with Docker (Railway / Render)
39
+
40
+ Since your project requires **Java** (for `tabula-py`), using a platform that supports your `Dockerfile` is much more reliable.
41
+
42
+ ### Option A: Railway.app (Easiest)
43
+ 1. Go to [Railway.app](https://railway.app/) and login with GitHub.
44
+ 2. Click **New Project** -> **Deploy from GitHub repo**.
45
+ 3. Select your repository.
46
+ 4. Railway will see your `Dockerfile` and deploy it automatically.
47
+ 5. Your Swagger UI will be at: `https://your-railway-url.up.railway.app/docs`.
48
+
49
+ ### Option B: Render.com
50
+ 1. Go to [Render.com](https://render.com/).
51
+ 2. Click **New** -> **Web Service**.
52
+ 3. Connect your GitHub and select the repo.
53
+ 4. Select **Environment: Docker**.
54
+ 5. Click **Deploy**.
55
+ 6. Your Swagger UI will be at: `https://your-subdomain.onrender.com/docs`.
56
+
57
+ ---
58
+
59
+ ## 4. Accessing Swagger UI Locally
60
+
61
+ To test everything locally before pushing:
62
+
63
+ 1. **Run with Uvicorn**:
64
+ ```bash
65
+ uvicorn telecom_anomaly.api:app --reload
66
+ ```
67
+ 2. **Open Browser**:
68
+ - **Interactive Documentation (Swagger)**: [http://127.0.0.1:8000/docs](http://127.0.0.1:8000/docs)
69
+ - **Alternative Docs (Redoc)**: [http://127.0.0.1:8000/redoc](http://127.0.0.1:8000/redoc)
70
+
71
+ ## 5. Using the Unified Endpoint
72
+
73
+ - **Endpoint**: `POST /analyze`
74
+ - **Function**: Upload a PDF (or CSV).
75
+ - **Parameters**:
76
+ - `save_results`: Set to `true` if you want to save files on the server (Docker only).
77
+ - `filter_movements`: Set to `true` to clean up geographic data.
78
+ - All other settings match your original logic.
config/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # config package
config/settings.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Configuration settings for the telecom anomaly detection system
3
+ """
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ # Base paths
9
+ BASE_DIR = Path(__file__).parent.parent
10
+ DATA_DIR = BASE_DIR / 'data'
11
+ INPUT_DIR = DATA_DIR / 'input'
12
+ OUTPUT_DIR = DATA_DIR / 'output'
13
+ SAVE_PDF_DIR = DATA_DIR / 'save_pdf'
14
+ LOG_DIR = BASE_DIR / 'logs'
15
+
16
+ # Create directories if they don't exist
17
+ # On environments like Vercel, this may fail due to read-only filesystem
18
+ for dir_path in [DATA_DIR, INPUT_DIR, OUTPUT_DIR, SAVE_PDF_DIR, LOG_DIR]:
19
+ try:
20
+ dir_path.mkdir(parents=True, exist_ok=True)
21
+ except Exception as e:
22
+ print(f"Warning: Could not create directory {dir_path}: {e}")
23
+
24
+ # Logging configuration
25
+ LOGGING_CONFIG = {
26
+ 'level': 'INFO',
27
+ 'format': '%(asctime)s - %(name)s - %(levelname)s - %(message)s',
28
+ 'log_file': LOG_DIR / 'telecom_anomaly_detection.log'
29
+ }
30
+
31
+ # Default detection thresholds (can be overridden by config file)
32
+ DEFAULT_THRESHOLDS = {
33
+ 'short_call_threshold': 3.0,
34
+ 'long_call_threshold': 5400.0,
35
+ 'extreme_call_threshold': 10800.0,
36
+ 'max_calls_per_hour': 60,
37
+ 'max_calls_per_day': 300,
38
+ 'max_calls_per_minute': 10,
39
+ 'max_sms_per_hour': 100,
40
+ 'max_sms_per_minute': 20,
41
+ 'max_location_change_speed': 1000.0,
42
+ 'location_cluster_radius': 10.0,
43
+ 'call_ratio_outgoing_incoming': 20.0,
44
+ 'unique_contacts_per_day': 100,
45
+ 'z_score_threshold': 4.0,
46
+ 'iqr_multiplier': 2.0,
47
+ 'night_hours_start': 23,
48
+ 'night_hours_end': 5,
49
+ 'min_samples_for_pattern': 20,
50
+ 'batch_size': 10000
51
+ }
52
+
53
+ # Service numbers to ignore
54
+ SERVICE_NUMBERS = [
55
+ 'MTN', 'MOBILEMONEY', 'MTN-DATA', 'MTNXTRATIME', 'MTN-JUST4U',
56
+ 'EVD', '5040', '1506', '431', '1326', '1349', '438840',
57
+ '54211', '435942', '5381060', '783273271', '141', '402',
58
+ 'TWO SURE LP', 'DRMBAWUMIA', 'MYMTN 2.0', 'EOCO', 'DREAMTRAVEL'
59
+ ]
config/telecom_anomaly/core/models.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data models for the anomaly detection system
3
+ """
4
+
5
+ from dataclasses import dataclass, field, asdict
6
+ from datetime import datetime
7
+ from typing import Dict, Any, List, Optional
8
+
9
+
10
+ @dataclass
11
+ class TelecomConfig:
12
+ """Configuration for telecom anomaly detection"""
13
+
14
+ # Call duration thresholds (seconds)
15
+ short_call_threshold: float = 3.0
16
+ long_call_threshold: float = 5400.0 # 90 minutes
17
+ extreme_call_threshold: float = 10800.0 # 3 hours
18
+
19
+ # Call frequency thresholds
20
+ max_calls_per_hour: int = 60
21
+ max_calls_per_day: int = 300
22
+ max_calls_per_minute: int = 10
23
+
24
+ # SMS thresholds
25
+ max_sms_per_hour: int = 100
26
+ max_sms_per_minute: int = 20
27
+ ignore_service_sms: bool = True
28
+
29
+ # Geographic thresholds
30
+ max_location_change_speed: float = 1000.0 # km/h
31
+ location_cluster_radius: float = 10.0 # km
32
+
33
+ # Behavioral thresholds
34
+ call_ratio_outgoing_incoming: float = 20.0
35
+ unique_contacts_per_day: int = 100
36
+
37
+ # Service numbers to ignore
38
+ service_numbers: List[str] = field(default_factory=list)
39
+
40
+ # Statistical thresholds
41
+ z_score_threshold: float = 4.0
42
+ iqr_multiplier: float = 2.0
43
+
44
+ # Time-based patterns
45
+ night_hours_start: int = 23
46
+ night_hours_end: int = 5
47
+
48
+ # Minimum samples needed
49
+ min_samples_for_pattern: int = 20
50
+
51
+ # Performance
52
+ batch_size: int = 10000
53
+
54
+ def validate(self):
55
+ """Validate configuration"""
56
+ assert self.short_call_threshold >= 0, "Short call threshold must be non-negative"
57
+ assert self.long_call_threshold > self.short_call_threshold, \
58
+ "Long call threshold must be greater than short call threshold"
59
+ assert self.extreme_call_threshold > self.long_call_threshold, \
60
+ "Extreme call threshold must be greater than long call threshold"
61
+ return self
62
+
63
+ @classmethod
64
+ def from_dict(cls, config_dict: Dict[str, Any]) -> 'TelecomConfig':
65
+ """Create config from dictionary"""
66
+ return cls(**{k: v for k, v in config_dict.items() if k in cls.__dataclass_fields__})
67
+
68
+
69
+ @dataclass
70
+ class AnomalyResult:
71
+ """Anomaly detection result"""
72
+ row_index: int
73
+ column_name: str
74
+ value: Any
75
+ detection_method: str
76
+ score: float
77
+ threshold: float
78
+ confidence: float
79
+ timestamp: datetime = field(default_factory=datetime.now)
80
+ metadata: Dict[str, Any] = field(default_factory=dict)
81
+ msisdn: str = ""
82
+
83
+ def to_dict(self) -> Dict:
84
+ """Convert to dictionary"""
85
+ result = asdict(self)
86
+ result['timestamp'] = self.timestamp.isoformat()
87
+ return result
88
+
89
+
90
+ @dataclass
91
+ class DetectionStatistics:
92
+ """Statistics from anomaly detection"""
93
+ total_rows: int
94
+ rows_with_anomalies: int
95
+ total_anomalies_detected: int
96
+ anomaly_rate: float
97
+ unique_subscribers: int
98
+ anomaly_types: Dict[str, int]
99
+
100
+ def to_dict(self) -> Dict:
101
+ """Convert to dictionary"""
102
+ return asdict(self)
main.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from telecom_anomaly.api import app
2
+
3
+ if __name__ == "__main__":
4
+ import uvicorn
5
+ uvicorn.run(app, host="0.0.0.0", port=8000)
pyproject.toml ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "telecom-anomaly-detection"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ requires-python = ">=3.9"
7
+ dependencies = [
8
+ "fastapi>=0.135.2",
9
+ "fpdf2>=2.8.7",
10
+ "jpype1>=1.6.0",
11
+ "matplotlib>=3.4.0",
12
+ "numpy>=1.21.0",
13
+ "pandas>=1.3.0",
14
+ "python-multipart>=0.0.22",
15
+ "scikit-learn>=1.0.0",
16
+ "seaborn>=0.11.0",
17
+ "tabula-py>=2.10.0",
18
+ "uvicorn>=0.42.0",
19
+ ]
20
+
21
+ [project.scripts]
22
+ app = "telecom_anomaly.api:app"
23
+
24
+ [dependency-groups]
25
+ dev = [
26
+ "black>=21.0.0",
27
+ "flake8>=3.9.0",
28
+ "pytest>=6.2.0",
29
+ ]
requirements.txt ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Core Analysis
2
+ pandas>=1.3.0
3
+ numpy>=1.21.0
4
+ scikit-learn>=1.0.0
5
+ tabula-py>=2.4.0
6
+
7
+ # API
8
+ fastapi>=0.95.0
9
+ uvicorn>=0.21.0
10
+ python-multipart>=0.0.6
11
+ pydantic>=1.10.0
12
+
13
+ # Export & PDF
14
+ fpdf2>=2.7.0
15
+
16
+ # Visualization (if needed for exports)
17
+ matplotlib>=3.4.0
18
+ seaborn>=0.11.0
19
+
20
+ # Development
21
+ pytest>=6.2.0
22
+ black>=21.0.0
23
+ flake8>=3.9.0
setup.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Setup configuration for telecom anomaly detection package
3
+ """
4
+
5
+ from setuptools import setup, find_packages
6
+
7
+ with open("README.md", "r", encoding="utf-8") as fh:
8
+ long_description = fh.read()
9
+
10
+ setup(
11
+ name="telecom-anomaly-detection",
12
+ version="1.0.0",
13
+ author="Your Name",
14
+ author_email="your.email@example.com",
15
+ description="Production-ready anomaly detection system for telecommunications call data records",
16
+ long_description=long_description,
17
+ long_description_content_type="text/markdown",
18
+ url="https://github.com/yourusername/telecom-anomaly-detection",
19
+ packages=find_packages(),
20
+ classifiers=[
21
+ "Development Status :: 5 - Production/Stable",
22
+ "Intended Audience :: Telecommunications Industry",
23
+ "License :: OSI Approved :: MIT License",
24
+ "Operating System :: OS Independent",
25
+ "Programming Language :: Python :: 3",
26
+ "Programming Language :: Python :: 3.8",
27
+ "Programming Language :: Python :: 3.9",
28
+ "Programming Language :: Python :: 3.10",
29
+ ],
30
+ python_requires=">=3.8",
31
+ install_requires=[
32
+ "pandas>=1.3.0",
33
+ "numpy>=1.21.0",
34
+ "scikit-learn>=1.0.0",
35
+ ],
36
+ entry_points={
37
+ "console_scripts": [
38
+ "telecom-anomaly=telecom_anomaly.main:main",
39
+ ],
40
+ },
41
+ )
telecom_anomaly/__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Telecom Anomaly Detection System
3
+ """
4
+
5
+ from telecom_anomaly.core.detector import TelecomAnomalyDetector
6
+ from telecom_anomaly.core.models import TelecomConfig, AnomalyResult
7
+
8
+ __version__ = '1.0.0'
9
+ __all__ = ['TelecomAnomalyDetector', 'TelecomConfig', 'AnomalyResult']
telecom_anomaly/api.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import shutil
3
+ import tempfile
4
+ import logging
5
+ import pandas as pd
6
+ import io
7
+ from pathlib import Path
8
+ from typing import List, Dict, Any, Optional
9
+ from fastapi import FastAPI, UploadFile, File, HTTPException
10
+
11
+ from telecom_anomaly.core.detector import TelecomAnomalyDetector
12
+ from telecom_anomaly.core.models import TelecomConfig
13
+ from config.settings import SERVICE_NUMBERS, OUTPUT_DIR, SAVE_PDF_DIR
14
+ from telecom_anomaly.utils.pdf_processor import (
15
+ extract_pdf_to_lines,
16
+ process_telecom_data_df,
17
+ strict_clean_df
18
+ )
19
+
20
+ # Configure logging
21
+ logging.basicConfig(level=logging.INFO)
22
+ logger = logging.getLogger(__name__)
23
+
24
+ app = FastAPI(
25
+ title="Telecom Anomaly Detection API",
26
+ description="API for detecting anomalies in telecom Call Data Records (CDR)",
27
+ version="1.0.0"
28
+ )
29
+
30
+ @app.get("/health")
31
+ def health_check():
32
+ """Health check endpoint"""
33
+ return {"status": "healthy", "version": "1.0.0"}
34
+
35
+ @app.post("/analyze")
36
+ async def analyze_cdr(
37
+ file: UploadFile = File(...),
38
+ save_results: bool = False,
39
+ save_pdf: bool = False,
40
+ filter_movements: bool = False,
41
+ run_anomaly_detection: bool = True,
42
+ cluster_km: float = 1.0
43
+ ):
44
+ """
45
+ Analyze a PDF or CSV file for telecom anomalies.
46
+ Settings are matched to the original CSV analysis endpoint.
47
+ """
48
+ filename = file.filename.lower()
49
+ is_pdf = filename.endswith('.pdf')
50
+ is_csv = filename.endswith('.csv')
51
+
52
+ if not (is_pdf or is_csv):
53
+ raise HTTPException(status_code=400, detail="Only PDF and CSV files are supported")
54
+
55
+ # Create a temporary file
56
+ with tempfile.NamedTemporaryFile(delete=False, suffix=Path(file.filename).suffix) as tmp:
57
+ shutil.copyfileobj(file.file, tmp)
58
+ tmp_path = tmp.name
59
+
60
+ try:
61
+ # Step 1: Initialize detector with original settings
62
+ config = TelecomConfig()
63
+ config.service_numbers = SERVICE_NUMBERS
64
+ config.output_dir = str(OUTPUT_DIR) if save_results else None
65
+ config.save_pdf_dir = str(SAVE_PDF_DIR) if save_pdf else None
66
+ config.location_cluster_km = cluster_km
67
+
68
+ detector = TelecomAnomalyDetector(config)
69
+
70
+ # Step 2: Load Data
71
+ if is_pdf:
72
+ logger.info(f"Processing PDF: {file.filename}")
73
+ base_name = os.path.splitext(file.filename)[0]
74
+
75
+ # Use original logic via lines -> df -> strict_clean
76
+ raw_lines = extract_pdf_to_lines(tmp_path)
77
+ if not raw_lines:
78
+ raise HTTPException(status_code=500, detail="Failed to extract tables from PDF")
79
+
80
+ df_processed = process_telecom_data_df(raw_lines, base_name)
81
+ if df_processed is None:
82
+ raise HTTPException(status_code=500, detail="Failed to process extracted telecom data")
83
+
84
+ df_final = strict_clean_df(df_processed)
85
+ if df_final is None or df_final.empty:
86
+ raise HTTPException(status_code=500, detail="No valid records found after cleaning")
87
+
88
+ # Load into detector
89
+ detector.load_dataframe(df_final, filter_movements=filter_movements)
90
+ else:
91
+ logger.info(f"Processing CSV: {file.filename}")
92
+ # Use original CSV loading logic
93
+ detector.load_data([tmp_path], filter_movements=filter_movements)
94
+
95
+ # Step 3: Run Analysis
96
+ anomalies = []
97
+ if run_anomaly_detection:
98
+ anomalies = detector.detect_anomalies()
99
+
100
+ # Get statistics
101
+ stats = detector.get_statistics()
102
+
103
+ # Step 4: Prepare Results (Exactly like original)
104
+ requested_fields = [
105
+ "event_date", "event_time", "call_direction", "usage_sub_type",
106
+ "calling_no", "called_no", "usage_type", "duration", "imei",
107
+ "location_id", "region", "district", "city", "msisdn",
108
+ "longitude", "latitude", "azimuth"
109
+ ]
110
+
111
+ final_records_to_return = []
112
+ kept_original_indices = set(detector.data_indices.values())
113
+
114
+ for idx, row in enumerate(detector.original_data):
115
+ if idx not in kept_original_indices:
116
+ continue
117
+
118
+ methods = detector.row_anomaly_map.get(idx, [])
119
+
120
+ if run_anomaly_detection and not methods:
121
+ continue
122
+
123
+ record = {field: row.get(field, "") for field in requested_fields}
124
+
125
+ # Format dates/times for JSON parity
126
+ if hasattr(record['event_date'], 'isoformat'):
127
+ record['event_date'] = record['event_date'].isoformat()
128
+ if hasattr(record['event_time'], 'isoformat'):
129
+ record['event_time'] = record['event_time'].isoformat()
130
+
131
+ record.update({
132
+ "has_anomaly": "YES" if methods else "NO",
133
+ "anomaly_count": len(methods),
134
+ "anomaly_methods": "; ".join(methods)
135
+ })
136
+ final_records_to_return.append(record)
137
+
138
+ saved_files = []
139
+ if save_results and detector.exporter:
140
+ outputs = detector.export_results(only_anomalies=run_anomaly_detection)
141
+ saved_files.extend([Path(p).name for p in outputs.values()])
142
+
143
+ if save_pdf and detector.pdf_exporter:
144
+ pdf_path = detector.export_pdf(final_records_to_return, file.filename)
145
+ saved_files.append(Path(pdf_path).name)
146
+
147
+ return {
148
+ "filename": file.filename,
149
+ "statistics": stats.to_dict() if run_anomaly_detection else {"total_rows": len(detector.data)},
150
+ "results": final_records_to_return,
151
+ "saved_files": saved_files
152
+ }
153
+
154
+ except Exception as e:
155
+ logger.error(f"Analysis failed: {e}", exc_info=True)
156
+ raise HTTPException(status_code=500, detail=str(e))
157
+
158
+ finally:
159
+ if os.path.exists(tmp_path):
160
+ try:
161
+ os.remove(tmp_path)
162
+ except:
163
+ pass
164
+
165
+ if __name__ == "__main__":
166
+ import uvicorn
167
+ uvicorn.run(app, host="0.0.0.0", port=8000)
telecom_anomaly/core/__init__.py ADDED
File without changes
telecom_anomaly/core/detector.py ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Main anomaly detector orchestrator
3
+ """
4
+
5
+ import logging
6
+ import numpy as np
7
+ from typing import List, Dict, Any, Optional, Set
8
+ from collections import defaultdict
9
+ from datetime import datetime
10
+ from sklearn.cluster import DBSCAN
11
+
12
+ from telecom_anomaly.core.models import TelecomConfig, AnomalyResult, DetectionStatistics
13
+ from telecom_anomaly.io.reader import TelecomDataReader
14
+ from telecom_anomaly.io.exporter import ResultExporter
15
+ from telecom_anomaly.io.pdf_exporter import PDFExporter
16
+ from telecom_anomaly.validation.validator import DataValidator
17
+
18
+ # Import detectors
19
+ from telecom_anomaly.detection.duration import DurationDetector
20
+ from telecom_anomaly.detection.frequency import FrequencyDetector
21
+ from telecom_anomaly.detection.sms import SMSDetector
22
+ from telecom_anomaly.detection.location import LocationDetector
23
+ # from telecom_anomaly.detection.imei import IMEIDetector
24
+ from telecom_anomaly.detection.time_patterns import TimePatternDetector
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class TelecomAnomalyDetector:
30
+ """
31
+ Main orchestrator for telecom anomaly detection
32
+ """
33
+
34
+ def __init__(self, config: Optional[TelecomConfig] = None):
35
+ self.config = config or TelecomConfig()
36
+ self.config.validate()
37
+
38
+ # Initialize components
39
+ self.reader = TelecomDataReader(self.config, self.config.batch_size)
40
+ self.exporter = None
41
+ self.pdf_exporter = None
42
+
43
+ if self.config.output_dir:
44
+ self.exporter = ResultExporter(self.config.output_dir)
45
+
46
+ if self.config.save_pdf_dir:
47
+ self.pdf_exporter = PDFExporter(self.config.save_pdf_dir)
48
+ elif self.config.output_dir:
49
+ pdf_dir = str(Path(self.config.output_dir).parent / "save_pdf")
50
+ self.pdf_exporter = PDFExporter(pdf_dir)
51
+
52
+ self.validator = DataValidator()
53
+
54
+ # Initialize detectors
55
+ self.detectors = [
56
+ DurationDetector(self.config),
57
+ FrequencyDetector(self.config),
58
+ SMSDetector(self.config),
59
+ LocationDetector(self.config),
60
+ # IMEIDetector(self.config),
61
+ TimePatternDetector(self.config)
62
+ ]
63
+
64
+ # Data storage
65
+ self.data: List[Dict[str, Any]] = []
66
+ self.headers: List[str] = []
67
+ self.original_data: List[Dict[str, Any]] = []
68
+ self.data_indices: Dict[int, int] = {} # processed_idx -> original_idx
69
+ self.anomalies: List[AnomalyResult] = []
70
+
71
+ # Anomaly tracking
72
+ self.rows_with_anomalies: Set[int] = set()
73
+ self.row_anomaly_map: Dict[int, List[str]] = defaultdict(list)
74
+ self.subscriber_anomalies: Dict[str, Dict] = defaultdict(
75
+ lambda: {'total': 0, 'methods': set()}
76
+ )
77
+
78
+ logger.info(f"Initialized TelecomAnomalyDetector with {len(self.detectors)} detectors")
79
+
80
+ def load_data(self, file_paths: List[str], filter_movements: bool = False) -> 'TelecomAnomalyDetector':
81
+ """Load data from files"""
82
+ self.data, self.original_data = self.reader.read_files(file_paths)
83
+ self.headers = self.reader.headers
84
+
85
+ if filter_movements and self.data:
86
+ self.filter_distinct_movements()
87
+
88
+ # Build index mapping
89
+ self.data_indices = {}
90
+ for processed_idx, row in enumerate(self.data):
91
+ if '_original_idx' in row:
92
+ self.data_indices[processed_idx] = row['_original_idx']
93
+
94
+ logger.info(f"Loaded {len(self.data)} valid records")
95
+ return self
96
+
97
+ def load_dataframe(self, df: 'pd.DataFrame', filter_movements: bool = False) -> 'TelecomAnomalyDetector':
98
+ """Load data from a pandas DataFrame"""
99
+ self.data, self.original_data = self.reader.read_dataframe(df)
100
+ self.headers = self.reader.headers
101
+
102
+ if filter_movements and self.data:
103
+ self.filter_distinct_movements()
104
+
105
+ # Build index mapping
106
+ self.data_indices = {}
107
+ for processed_idx, row in enumerate(self.data):
108
+ if '_original_idx' in row:
109
+ self.data_indices[processed_idx] = row['_original_idx']
110
+
111
+ logger.info(f"Loaded {len(self.data)} valid records from DataFrame")
112
+ return self
113
+
114
+ def filter_distinct_movements(self):
115
+ """
116
+ Filter data to keep only representative records for distinct geographic locations.
117
+ Uses DBSCAN with Haversine distance.
118
+ """
119
+ if not self.data:
120
+ return
121
+
122
+ logger.info("Filtering for distinct geographic movements...")
123
+
124
+ # Extract coordinates for valid location rows
125
+ valid_indices = []
126
+ coords = []
127
+ for i, row in enumerate(self.data):
128
+ lat = row.get('latitude')
129
+ lon = row.get('longitude')
130
+ if lat is not None and lon is not None:
131
+ valid_indices.append(i)
132
+ coords.append([lat, lon])
133
+
134
+ if not coords:
135
+ return
136
+
137
+ coords_np = np.array(coords)
138
+
139
+ # Parameters for DBSCAN
140
+ distance_km = self.config.location_cluster_km
141
+ kms_per_radian = 6371.0088
142
+ epsilon = distance_km / kms_per_radian
143
+
144
+ # Run DBSCAN
145
+ db = DBSCAN(
146
+ eps=epsilon,
147
+ min_samples=1,
148
+ algorithm='ball_tree',
149
+ metric='haversine'
150
+ ).fit(np.radians(coords_np))
151
+
152
+ # Identify representative row for each cluster
153
+ cluster_labels = db.labels_
154
+ unique_clusters = set(cluster_labels)
155
+
156
+ kept_indices = set()
157
+ cluster_to_idx = {}
158
+
159
+ for i, label in enumerate(cluster_labels):
160
+ if label not in cluster_to_idx:
161
+ cluster_to_idx[label] = valid_indices[i]
162
+ kept_indices.add(valid_indices[i])
163
+
164
+ # Also keep rows without coordinates
165
+ all_indices = set(range(len(self.data)))
166
+ coord_indices = set(valid_indices)
167
+ no_coord_indices = all_indices - coord_indices
168
+
169
+ final_indices = sorted(list(kept_indices | no_coord_indices))
170
+
171
+ original_count = len(self.data)
172
+ self.data = [self.data[i] for i in final_indices]
173
+
174
+ logger.info(f"Geographic filtering complete: {original_count} -> {len(self.data)} records")
175
+
176
+ def detect_anomalies(self) -> List[AnomalyResult]:
177
+ """Run all anomaly detectors"""
178
+ start_time = datetime.now()
179
+ logger.info("Starting anomaly detection")
180
+
181
+ all_anomalies = []
182
+
183
+ # Run each detector
184
+ for detector in self.detectors:
185
+ try:
186
+ detector_anomalies = detector.detect(self.data)
187
+ all_anomalies.extend(detector_anomalies)
188
+ logger.info(f"{detector.name}: {len(detector_anomalies)} anomalies")
189
+ except Exception as e:
190
+ logger.error(f"Error in {detector.name}: {e}")
191
+
192
+ # Combine anomalies
193
+ self.anomalies = self._combine_anomalies(all_anomalies)
194
+
195
+ # Update tracking
196
+ self._update_anomaly_tracking()
197
+
198
+ elapsed = (datetime.now() - start_time).total_seconds()
199
+ logger.info(f"Detection completed in {elapsed:.2f}s")
200
+ logger.info(f"Rows with anomalies: {len(self.rows_with_anomalies)}/{len(self.data)}")
201
+
202
+ return self.anomalies
203
+
204
+ def _combine_anomalies(self, anomalies: List[AnomalyResult]) -> List[AnomalyResult]:
205
+ """Combine anomalies, removing duplicates"""
206
+ if not anomalies:
207
+ return []
208
+
209
+ # Separate row and subscriber anomalies
210
+ row_anomalies = defaultdict(list)
211
+ subscriber_anomalies = defaultdict(list)
212
+
213
+ for a in anomalies:
214
+ if a.row_index >= 0:
215
+ row_anomalies[a.row_index].append(a)
216
+ else:
217
+ subscriber_anomalies[a.msisdn].append(a)
218
+
219
+ combined = []
220
+
221
+ # Keep unique methods per row, highest confidence
222
+ for row_idx, row_anoms in row_anomalies.items():
223
+ best_per_method = {}
224
+ for anom in row_anoms:
225
+ if anom.detection_method not in best_per_method or \
226
+ anom.confidence > best_per_method[anom.detection_method].confidence:
227
+ best_per_method[anom.detection_method] = anom
228
+ combined.extend(best_per_method.values())
229
+
230
+ # Keep unique methods per subscriber
231
+ for msisdn, sub_anoms in subscriber_anomalies.items():
232
+ best_per_method = {}
233
+ for anom in sub_anoms:
234
+ if anom.detection_method not in best_per_method:
235
+ best_per_method[anom.detection_method] = anom
236
+ combined.extend(best_per_method.values())
237
+
238
+ return combined
239
+
240
+ def _update_anomaly_tracking(self):
241
+ """Update anomaly tracking maps"""
242
+ for anomaly in self.anomalies:
243
+ # Track rows with anomalies
244
+ if anomaly.row_index >= 0:
245
+ self.rows_with_anomalies.add(anomaly.row_index)
246
+ original_idx = self.data_indices.get(anomaly.row_index)
247
+ if original_idx is not None:
248
+ self.row_anomaly_map[original_idx].append(anomaly.detection_method)
249
+
250
+ # Track subscriber anomalies
251
+ if anomaly.msisdn:
252
+ self.subscriber_anomalies[anomaly.msisdn]['total'] += 1
253
+ self.subscriber_anomalies[anomaly.msisdn]['methods'].add(anomaly.detection_method)
254
+
255
+ def get_statistics(self) -> DetectionStatistics:
256
+ """Get detection statistics"""
257
+ rows_with_anomalies = len(self.rows_with_anomalies)
258
+
259
+ # Calculate anomaly rate correctly
260
+ anomaly_rate = (rows_with_anomalies / len(self.data)) * 100 if self.data else 0
261
+
262
+ # Count anomaly types
263
+ anomaly_types = defaultdict(int)
264
+ for anomaly in self.anomalies:
265
+ anomaly_types[anomaly.detection_method] += 1
266
+
267
+ return DetectionStatistics(
268
+ total_rows=len(self.data),
269
+ rows_with_anomalies=rows_with_anomalies,
270
+ total_anomalies_detected=len(self.anomalies),
271
+ anomaly_rate=anomaly_rate,
272
+ unique_subscribers=len(set(a.msisdn for a in self.anomalies if a.msisdn)),
273
+ anomaly_types=dict(anomaly_types)
274
+ )
275
+
276
+ def export_results(self, format: str = 'json', only_anomalies: bool = True) -> Dict[str, str]:
277
+ """Export all results"""
278
+ stats = self.get_statistics()
279
+ anomaly_dicts = [a.to_dict() for a in self.anomalies]
280
+
281
+ outputs = {}
282
+
283
+ # Export trackable data
284
+ outputs['trackable'] = self.exporter.export_trackable_data(
285
+ self.original_data, self.headers, self.row_anomaly_map,
286
+ only_anomalies=only_anomalies
287
+ )
288
+
289
+ # Export JSON results
290
+ outputs['json'] = self.exporter.export_results(
291
+ anomaly_dicts, stats.to_dict(), 'json'
292
+ )
293
+
294
+ # Export CSV results
295
+ outputs['csv'] = self.exporter.export_results(
296
+ anomaly_dicts, stats.to_dict(), 'csv'
297
+ )
298
+
299
+ # Export report
300
+ outputs['report'] = self.exporter.export_report(stats.to_dict())
301
+
302
+ # Export suspicious subscribers
303
+ outputs['subscribers'] = self.exporter.export_suspicious_subscribers(
304
+ dict(self.subscriber_anomalies)
305
+ )
306
+
307
+ return outputs
308
+
309
+ def export_pdf(self, results: List[Dict[str, Any]], filename: str) -> str:
310
+ """Export results as PDF report"""
311
+ stats = self.get_statistics().to_dict()
312
+ return self.pdf_exporter.generate_report(stats, results, filename)
telecom_anomaly/core/models.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data models for the anomaly detection system
3
+ """
4
+
5
+ from dataclasses import dataclass, field, asdict
6
+ from datetime import datetime
7
+ from typing import Dict, Any, List, Optional
8
+
9
+
10
+ @dataclass
11
+ class TelecomConfig:
12
+ """Configuration for telecom anomaly detection"""
13
+
14
+ # Call duration thresholds (seconds)
15
+ short_call_threshold: float = 3.0
16
+ long_call_threshold: float = 5400.0 # 90 minutes
17
+ extreme_call_threshold: float = 10800.0 # 3 hours
18
+
19
+ # Call frequency thresholds
20
+ max_calls_per_hour: int = 60
21
+ max_calls_per_day: int = 300
22
+ max_calls_per_minute: int = 10
23
+
24
+ # SMS thresholds
25
+ max_sms_per_hour: int = 100
26
+ max_sms_per_minute: int = 20
27
+ ignore_service_sms: bool = True
28
+
29
+ # Geographic thresholds
30
+ max_location_change_speed: float = 1000.0 # km/h
31
+ location_cluster_radius: float = 10.0 # km
32
+ location_cluster_km: float = 1.0 # Margin for 'same spot' clustering
33
+
34
+ # Behavioral thresholds
35
+ call_ratio_outgoing_incoming: float = 20.0
36
+ unique_contacts_per_day: int = 100
37
+
38
+ # Service numbers to ignore
39
+ service_numbers: List[str] = field(default_factory=list)
40
+
41
+ # Statistical thresholds
42
+ z_score_threshold: float = 4.0
43
+ iqr_multiplier: float = 2.0
44
+
45
+ # Time-based patterns
46
+ night_hours_start: int = 23
47
+ night_hours_end: int = 5
48
+
49
+ # Minimum samples needed
50
+ min_samples_for_pattern: int = 20
51
+
52
+ # Performance
53
+ batch_size: int = 10000
54
+
55
+ # Paths
56
+ output_dir: Optional[str] = None
57
+ save_pdf_dir: Optional[str] = None
58
+
59
+ def validate(self):
60
+ """Validate configuration"""
61
+ assert self.short_call_threshold >= 0, "Short call threshold must be non-negative"
62
+ assert self.long_call_threshold > self.short_call_threshold, \
63
+ "Long call threshold must be greater than short call threshold"
64
+ assert self.extreme_call_threshold > self.long_call_threshold, \
65
+ "Extreme call threshold must be greater than long call threshold"
66
+ return self
67
+
68
+ @classmethod
69
+ def from_dict(cls, config_dict: Dict[str, Any]) -> 'TelecomConfig':
70
+ """Create config from dictionary"""
71
+ return cls(**{k: v for k, v in config_dict.items() if k in cls.__dataclass_fields__})
72
+
73
+
74
+ @dataclass
75
+ class AnomalyResult:
76
+ """Anomaly detection result"""
77
+ row_index: int
78
+ column_name: str
79
+ value: Any
80
+ detection_method: str
81
+ score: float
82
+ threshold: float
83
+ confidence: float
84
+ timestamp: datetime = field(default_factory=datetime.now)
85
+ metadata: Dict[str, Any] = field(default_factory=dict)
86
+ msisdn: str = ""
87
+
88
+ def to_dict(self) -> Dict:
89
+ """Convert to dictionary"""
90
+ result = asdict(self)
91
+ result['timestamp'] = self.timestamp.isoformat()
92
+ return result
93
+
94
+
95
+ @dataclass
96
+ class DetectionStatistics:
97
+ """Statistics from anomaly detection"""
98
+ total_rows: int
99
+ rows_with_anomalies: int
100
+ total_anomalies_detected: int
101
+ anomaly_rate: float
102
+ unique_subscribers: int
103
+ anomaly_types: Dict[str, int]
104
+
105
+ def to_dict(self) -> Dict:
106
+ """Convert to dictionary"""
107
+ return asdict(self)
telecom_anomaly/detection/__init__.py ADDED
File without changes
telecom_anomaly/detection/base.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Base classes for anomaly detectors
3
+ """
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import List, Dict, Any, Optional
7
+ from collections import defaultdict
8
+
9
+ from telecom_anomaly.core.models import AnomalyResult, TelecomConfig
10
+
11
+
12
+ class BaseDetector(ABC):
13
+ """Base class for all anomaly detectors"""
14
+
15
+ def __init__(self, config: TelecomConfig):
16
+ self.config = config
17
+ self.name = self.__class__.__name__
18
+
19
+ @abstractmethod
20
+ def detect(self, data: List[Dict], **kwargs) -> List[AnomalyResult]:
21
+ """Detect anomalies in the data"""
22
+ pass
23
+
24
+ def _create_anomaly(self,
25
+ row_index: int,
26
+ column_name: str,
27
+ value: Any,
28
+ method: str,
29
+ score: float,
30
+ threshold: float,
31
+ confidence: float,
32
+ msisdn: str = "",
33
+ metadata: Optional[Dict] = None) -> AnomalyResult:
34
+ """Helper method to create an anomaly result"""
35
+ return AnomalyResult(
36
+ row_index=row_index,
37
+ column_name=column_name,
38
+ value=value,
39
+ detection_method=method,
40
+ score=score,
41
+ threshold=threshold,
42
+ confidence=confidence,
43
+ msisdn=msisdn,
44
+ metadata=metadata or {}
45
+ )
46
+
47
+
48
+ class SubscriberBasedDetector(BaseDetector):
49
+ """Base class for detectors that operate on subscriber data"""
50
+
51
+ def __init__(self, config: TelecomConfig):
52
+ super().__init__(config)
53
+ self.subscriber_data: Dict[str, List[Dict]] = defaultdict(list)
54
+
55
+ def prepare_data(self, data: List[Dict]):
56
+ """Prepare data by grouping by subscriber"""
57
+ self.subscriber_data.clear()
58
+ for row in data:
59
+ msisdn = row.get('msisdn')
60
+ if msisdn:
61
+ self.subscriber_data[msisdn].append(row)
62
+
63
+ @abstractmethod
64
+ def detect(self, data: List[Dict], **kwargs) -> List[AnomalyResult]:
65
+ pass
telecom_anomaly/detection/duration.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Call duration anomaly detector
3
+ """
4
+
5
+ from typing import List, Dict, Any
6
+
7
+ from telecom_anomaly.detection.base import BaseDetector
8
+ from telecom_anomaly.core.models import AnomalyResult, TelecomConfig
9
+
10
+
11
+ class DurationDetector(BaseDetector):
12
+ """Detect anomalous call durations"""
13
+
14
+ def __init__(self, config: TelecomConfig):
15
+ super().__init__(config)
16
+
17
+ def detect(self, data: List[Dict], **kwargs) -> List[AnomalyResult]:
18
+ """Detect duration anomalies"""
19
+ anomalies = []
20
+
21
+ for idx, row in enumerate(data):
22
+ # Skip non-voice, service calls, or zero duration
23
+ if row.get('usage_type') != 'VOICE' or \
24
+ row.get('is_service') or \
25
+ row.get('duration', 0) == 0:
26
+ continue
27
+
28
+ duration = row['duration']
29
+ msisdn = row.get('msisdn', '')
30
+
31
+ # Extreme duration
32
+ if duration > self.config.extreme_call_threshold:
33
+ anomalies.append(self._create_anomaly(
34
+ row_index=idx,
35
+ column_name='duration',
36
+ value=duration,
37
+ method='extreme_duration',
38
+ score=min(duration / self.config.extreme_call_threshold, 10.0),
39
+ threshold=self.config.extreme_call_threshold,
40
+ confidence=0.95,
41
+ msisdn=msisdn
42
+ ))
43
+
44
+ # Long duration
45
+ elif duration > self.config.long_call_threshold:
46
+ anomalies.append(self._create_anomaly(
47
+ row_index=idx,
48
+ column_name='duration',
49
+ value=duration,
50
+ method='long_duration',
51
+ score=min(duration / self.config.long_call_threshold, 5.0),
52
+ threshold=self.config.long_call_threshold,
53
+ confidence=0.80,
54
+ msisdn=msisdn
55
+ ))
56
+
57
+ # Short duration
58
+ elif 0 < duration < self.config.short_call_threshold:
59
+ anomalies.append(self._create_anomaly(
60
+ row_index=idx,
61
+ column_name='duration',
62
+ value=duration,
63
+ method='short_duration',
64
+ score=self.config.short_call_threshold / max(duration, 0.1),
65
+ threshold=self.config.short_call_threshold,
66
+ confidence=0.75,
67
+ msisdn=msisdn
68
+ ))
69
+
70
+ return anomalies
telecom_anomaly/detection/frequency.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Call frequency anomaly detector
3
+ """
4
+
5
+ from typing import List, Dict, Any
6
+ from collections import defaultdict
7
+
8
+ from telecom_anomaly.detection.base import SubscriberBasedDetector
9
+ from telecom_anomaly.core.models import AnomalyResult, TelecomConfig
10
+
11
+
12
+ class FrequencyDetector(SubscriberBasedDetector):
13
+ """Detect abnormal call frequency"""
14
+
15
+ def __init__(self, config: TelecomConfig):
16
+ super().__init__(config)
17
+
18
+ def detect(self, data: List[Dict], **kwargs) -> List[AnomalyResult]:
19
+ """Detect frequency anomalies"""
20
+ self.prepare_data(data)
21
+ anomalies = []
22
+
23
+ for msisdn, calls in self.subscriber_data.items():
24
+ # Filter for voice calls with valid datetime
25
+ voice_calls = [
26
+ (idx, call) for idx, call in enumerate(data)
27
+ if call.get('msisdn') == msisdn and
28
+ call.get('usage_type') == 'VOICE' and
29
+ not call.get('is_service') and
30
+ call.get('datetime')
31
+ ]
32
+
33
+ if len(voice_calls) < 10:
34
+ continue
35
+
36
+ # Sort by datetime
37
+ voice_calls.sort(key=lambda x: x[1]['datetime'])
38
+
39
+ # Check for call bursts (calls per minute)
40
+ for i in range(len(voice_calls) - self.config.max_calls_per_minute):
41
+ time_window = (
42
+ voice_calls[i + self.config.max_calls_per_minute - 1][1]['datetime'] -
43
+ voice_calls[i][1]['datetime']
44
+ )
45
+
46
+ if time_window and time_window.total_seconds() < 60:
47
+ anomalies.append(self._create_anomaly(
48
+ row_index=voice_calls[i][0],
49
+ column_name='frequency',
50
+ value=self.config.max_calls_per_minute,
51
+ method='call_burst',
52
+ score=1.0,
53
+ threshold=self.config.max_calls_per_minute,
54
+ confidence=0.85,
55
+ msisdn=msisdn
56
+ ))
57
+ break # One burst per subscriber is enough
58
+
59
+ return anomalies
telecom_anomaly/detection/imei.py ADDED
File without changes
telecom_anomaly/detection/location.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Location jump anomaly detector
3
+ """
4
+
5
+ from typing import List, Dict, Any, Optional
6
+ from datetime import datetime
7
+
8
+ from telecom_anomaly.detection.base import SubscriberBasedDetector
9
+ from telecom_anomaly.core.models import AnomalyResult, TelecomConfig
10
+ from telecom_anomaly.utils.geo import haversine_distance, calculate_speed
11
+
12
+
13
+ class LocationDetector(SubscriberBasedDetector):
14
+ """Detect impossible location jumps"""
15
+
16
+ def __init__(self, config: TelecomConfig):
17
+ super().__init__(config)
18
+
19
+ def detect(self, data: List[Dict], **kwargs) -> List[AnomalyResult]:
20
+ """Detect location anomalies"""
21
+ self.prepare_data(data)
22
+ anomalies = []
23
+
24
+ for msisdn, calls in self.subscriber_data.items():
25
+ # Get calls with valid location and datetime
26
+ valid_calls = []
27
+ for call in calls:
28
+ idx = data.index(call) if call in data else -1
29
+ if (call.get('latitude') and call.get('longitude') and
30
+ not call.get('is_service') and
31
+ call.get('datetime') and idx >= 0):
32
+ valid_calls.append((idx, call))
33
+
34
+ if len(valid_calls) < 3:
35
+ continue
36
+
37
+ # Sort by datetime
38
+ valid_calls.sort(key=lambda x: x[1]['datetime'])
39
+
40
+ # Check consecutive calls
41
+ for i in range(len(valid_calls) - 1):
42
+ idx1, call1 = valid_calls[i]
43
+ idx2, call2 = valid_calls[i + 1]
44
+
45
+ dt1: Optional[datetime] = call1.get('datetime')
46
+ dt2: Optional[datetime] = call2.get('datetime')
47
+
48
+ if dt1 and dt2:
49
+ time_diff = (dt2 - dt1).total_seconds() / 3600 # hours
50
+
51
+ # Check calls within 24 hours
52
+ if 0 < time_diff < 24:
53
+ distance = haversine_distance(
54
+ call1['latitude'], call1['longitude'],
55
+ call2['latitude'], call2['longitude']
56
+ )
57
+
58
+ if distance > 100: # More than 100km
59
+ speed = calculate_speed(distance, time_diff)
60
+
61
+ if speed > self.config.max_location_change_speed:
62
+ anomalies.append(self._create_anomaly(
63
+ row_index=idx2,
64
+ column_name='location',
65
+ value=distance,
66
+ method='impossible_speed',
67
+ score=min(speed / self.config.max_location_change_speed, 10.0),
68
+ threshold=self.config.max_location_change_speed,
69
+ confidence=0.90,
70
+ msisdn=msisdn,
71
+ metadata={
72
+ 'speed': speed,
73
+ 'distance': distance,
74
+ 'time_diff_hours': time_diff
75
+ }
76
+ ))
77
+
78
+ return anomalies
telecom_anomaly/detection/sms.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SMS flooding detector
3
+ """
4
+
5
+ from typing import List, Dict, Any
6
+
7
+ from telecom_anomaly.detection.base import SubscriberBasedDetector
8
+ from telecom_anomaly.core.models import AnomalyResult, TelecomConfig
9
+
10
+
11
+ class SMSDetector(SubscriberBasedDetector):
12
+ """Detect SMS flooding"""
13
+
14
+ def __init__(self, config: TelecomConfig):
15
+ super().__init__(config)
16
+
17
+ def detect(self, data: List[Dict], **kwargs) -> List[AnomalyResult]:
18
+ """Detect SMS anomalies"""
19
+ self.prepare_data(data)
20
+ anomalies = []
21
+
22
+ for msisdn, sms_list in self.subscriber_data.items():
23
+ # Filter for SMS with valid datetime
24
+ sms_events = [
25
+ (idx, sms) for idx, sms in enumerate(data)
26
+ if sms.get('msisdn') == msisdn and
27
+ sms.get('usage_type') == 'SMS' and
28
+ not sms.get('is_service') and
29
+ sms.get('datetime')
30
+ ]
31
+
32
+ if len(sms_events) < 10:
33
+ continue
34
+
35
+ # Sort by datetime
36
+ sms_events.sort(key=lambda x: x[1]['datetime'])
37
+
38
+ # Check for SMS bursts (5 in 30 seconds)
39
+ for i in range(len(sms_events) - 5):
40
+ time_window = sms_events[i + 4][1]['datetime'] - sms_events[i][1]['datetime']
41
+
42
+ if time_window and time_window.total_seconds() < 30:
43
+ anomalies.append(self._create_anomaly(
44
+ row_index=sms_events[i][0],
45
+ column_name='sms',
46
+ value=5,
47
+ method='sms_burst',
48
+ score=1.0,
49
+ threshold=5,
50
+ confidence=0.90,
51
+ msisdn=msisdn
52
+ ))
53
+ break # One burst per subscriber is enough
54
+
55
+ return anomalies
telecom_anomaly/detection/time_patterns.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Time-based pattern anomaly detector
3
+ """
4
+
5
+ from typing import List, Dict, Any
6
+
7
+ from telecom_anomaly.detection.base import BaseDetector
8
+ from telecom_anomaly.core.models import AnomalyResult, TelecomConfig
9
+
10
+
11
+ class TimePatternDetector(BaseDetector):
12
+ """Detect unusual time patterns"""
13
+
14
+ def __init__(self, config: TelecomConfig):
15
+ super().__init__(config)
16
+
17
+ def detect(self, data: List[Dict], **kwargs) -> List[AnomalyResult]:
18
+ """Detect time-based anomalies"""
19
+ anomalies = []
20
+
21
+ for idx, row in enumerate(data):
22
+ # Check for night calls (excluding service)
23
+ if row.get('is_night') and not row.get('is_service'):
24
+ # Simple heuristic: flag all night calls
25
+ # This can be enhanced with statistical models
26
+ anomalies.append(self._create_anomaly(
27
+ row_index=idx,
28
+ column_name='time',
29
+ value=row.get('event_time', ''),
30
+ method='night_call',
31
+ score=1.0,
32
+ threshold=0.5,
33
+ confidence=0.60,
34
+ msisdn=row.get('msisdn', '')
35
+ ))
36
+
37
+ return anomalies
telecom_anomaly/io/__init__.py ADDED
File without changes
telecom_anomaly/io/exporter.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Export utilities for anomaly detection results
3
+ """
4
+
5
+ import csv
6
+ import json
7
+ import logging
8
+ from datetime import datetime
9
+ from typing import List, Dict, Any, Optional
10
+ from pathlib import Path
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class ResultExporter:
16
+ """Export anomaly detection results in various formats"""
17
+
18
+ def __init__(self, output_dir: Optional[str] = None):
19
+ self.output_dir = Path(output_dir) if output_dir else Path.cwd()
20
+ self.output_dir.mkdir(parents=True, exist_ok=True)
21
+
22
+ def export_trackable_data(self,
23
+ original_data: List[Dict[str, Any]],
24
+ headers: List[str],
25
+ row_anomaly_map: Dict[int, List[str]],
26
+ output_path: Optional[str] = None,
27
+ only_anomalies: bool = True) -> str:
28
+ """Export original data with anomaly flags"""
29
+
30
+ if not output_path:
31
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
32
+ output_path = self.output_dir / f"telecom_data_results_{timestamp}.csv"
33
+
34
+ logger.info(f"Generating trackable data: {output_path}")
35
+
36
+ # Prepare headers
37
+ trackable_headers = headers.copy()
38
+ trackable_headers.extend(['has_anomaly', 'anomaly_count', 'anomaly_methods'])
39
+
40
+ with open(output_path, 'w', newline='', encoding='utf-8') as f:
41
+ writer = csv.writer(f)
42
+ writer.writerow(trackable_headers)
43
+
44
+ exported_rows = 0
45
+
46
+ for original_idx, original_row in enumerate(original_data):
47
+ anomaly_methods = row_anomaly_map.get(original_idx, [])
48
+
49
+ # If only_anomalies is True, only export rows that HAVE anomalies
50
+ if only_anomalies and not anomaly_methods:
51
+ continue
52
+
53
+ exported_rows += 1
54
+ has_anomaly = 'YES' if anomaly_methods else 'NO'
55
+
56
+ # Get original data in header order
57
+ row_data = [original_row.get(h, '') for h in headers]
58
+
59
+ # Add anomaly columns
60
+ row_data.extend([
61
+ has_anomaly,
62
+ len(anomaly_methods),
63
+ '; '.join(anomaly_methods[:3])
64
+ ])
65
+
66
+ writer.writerow(row_data)
67
+
68
+ logger.info(f"Exported {exported_rows} records to CSV")
69
+
70
+ return str(output_path)
71
+
72
+ return str(output_path)
73
+
74
+ def export_results(self,
75
+ anomalies: List[Dict],
76
+ statistics: Dict[str, Any],
77
+ format: str = 'json',
78
+ output_path: Optional[str] = None) -> str:
79
+ """Export results in specified format"""
80
+
81
+ if not output_path:
82
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
83
+ output_path = self.output_dir / f"telecom_anomaly_results_{timestamp}.{format}"
84
+
85
+ if format == 'json':
86
+ self._export_json(anomalies, statistics, output_path)
87
+ elif format == 'csv':
88
+ self._export_csv(anomalies, output_path)
89
+ else:
90
+ raise ValueError(f"Unsupported format: {format}")
91
+
92
+ logger.info(f"Results exported to {output_path}")
93
+ return str(output_path)
94
+
95
+ def _export_json(self, anomalies: List[Dict], statistics: Dict[str, Any], output_path: Path):
96
+ """Export as JSON"""
97
+ data = {
98
+ 'metadata': {
99
+ 'timestamp': datetime.now().isoformat(),
100
+ 'statistics': statistics
101
+ },
102
+ 'anomalies': anomalies
103
+ }
104
+
105
+ with open(output_path, 'w') as f:
106
+ json.dump(data, f, indent=2)
107
+
108
+ def _export_csv(self, anomalies: List[Dict], output_path: Path):
109
+ """Export as CSV"""
110
+ with open(output_path, 'w', newline='') as f:
111
+ writer = csv.writer(f)
112
+ writer.writerow(['row_index', 'msisdn', 'method', 'value', 'confidence', 'column'])
113
+
114
+ for a in anomalies:
115
+ if a.get('row_index', -1) >= 0:
116
+ writer.writerow([
117
+ a.get('row_index', ''),
118
+ a.get('msisdn', ''),
119
+ a.get('detection_method', ''),
120
+ f"{a.get('value', 0):.2f}" if isinstance(a.get('value'), (int, float)) else a.get('value', ''),
121
+ f"{a.get('confidence', 0):.2f}",
122
+ a.get('column_name', '')
123
+ ])
124
+
125
+ def export_report(self,
126
+ statistics: Dict[str, Any],
127
+ output_path: Optional[str] = None) -> str:
128
+ """Generate human-readable report"""
129
+
130
+ if not output_path:
131
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
132
+ output_path = self.output_dir / f"telecom_anomaly_report_{timestamp}.txt"
133
+
134
+ with open(output_path, 'w') as f:
135
+ f.write("=" * 60 + "\n")
136
+ f.write("TELECOM ANOMALY DETECTION REPORT\n")
137
+ f.write("=" * 60 + "\n\n")
138
+
139
+ f.write(f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n")
140
+
141
+ f.write("SUMMARY\n")
142
+ f.write("-" * 40 + "\n")
143
+ f.write(f"Total Records: {statistics['total_rows']:,}\n")
144
+ f.write(f"Rows with Anomalies: {statistics['rows_with_anomalies']:,}\n")
145
+ f.write(f"Total Anomalies: {statistics['total_anomalies_detected']:,}\n")
146
+ f.write(f"Anomaly Rate: {statistics['anomaly_rate']:.2f}%\n")
147
+ f.write(f"Unique Subscribers: {statistics['unique_subscribers']:,}\n\n")
148
+
149
+ f.write("ANOMALIES BY TYPE\n")
150
+ f.write("-" * 40 + "\n")
151
+ for method, count in sorted(statistics['anomaly_types'].items(),
152
+ key=lambda x: x[1], reverse=True):
153
+ f.write(f" {method}: {count}\n")
154
+
155
+ f.write("\n" + "=" * 60 + "\n")
156
+
157
+ logger.info(f"Report generated: {output_path}")
158
+ return str(output_path)
159
+
160
+ def export_suspicious_subscribers(self,
161
+ subscriber_anomalies: Dict[str, Dict],
162
+ output_path: Optional[str] = None) -> str:
163
+ """Export suspicious subscribers"""
164
+
165
+ if not output_path:
166
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
167
+ output_path = self.output_dir / f"suspicious_subscribers_{timestamp}.csv"
168
+
169
+ with open(output_path, 'w', newline='') as f:
170
+ writer = csv.writer(f)
171
+ writer.writerow(['msisdn', 'anomaly_count', 'unique_methods', 'methods'])
172
+
173
+ for msisdn, data in sorted(subscriber_anomalies.items(),
174
+ key=lambda x: x[1]['total'], reverse=True):
175
+ writer.writerow([
176
+ msisdn,
177
+ data['total'],
178
+ len(data['methods']),
179
+ ', '.join(data['methods'])
180
+ ])
181
+
182
+ logger.info(f"Suspicious subscribers exported to {output_path}")
183
+ return str(output_path)
telecom_anomaly/io/pdf_exporter.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fpdf import FPDF
2
+ from datetime import datetime
3
+ from pathlib import Path
4
+ from typing import Dict, Any, List
5
+
6
+ class PDFExporter:
7
+ """Generates professional Landscape PDF reports for telecom anomaly detection"""
8
+
9
+ def __init__(self, output_dir: str):
10
+ self.output_dir = Path(output_dir)
11
+ self.output_dir.mkdir(parents=True, exist_ok=True)
12
+
13
+ def generate_report(self, stats: Dict[str, Any], results: List[Dict[str, Any]], filename: str) -> str:
14
+ # Use Landscape orientation to fit 20 columns
15
+ pdf = FPDF(orientation="L", unit="mm", format="A4")
16
+ pdf.add_page()
17
+
18
+ # Title
19
+ pdf.set_font("helvetica", "B", 18)
20
+ pdf.set_text_color(0, 51, 102)
21
+ pdf.cell(0, 12, "Telecom Anomaly Detection - Detailed Report", ln=True, align="C")
22
+
23
+ # Metadata
24
+ pdf.set_font("helvetica", "I", 9)
25
+ pdf.set_text_color(100, 100, 100)
26
+ pdf.cell(0, 8, f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} | Source: {filename}", ln=True, align="C")
27
+ pdf.ln(5)
28
+
29
+ # Summary Section
30
+ pdf.set_font("helvetica", "B", 12)
31
+ pdf.set_text_color(0, 0, 0)
32
+ pdf.set_fill_color(240, 240, 240)
33
+ pdf.cell(0, 8, " 1. Detection Summary", ln=True, fill=True)
34
+ pdf.ln(3)
35
+
36
+ pdf.set_font("helvetica", "", 10)
37
+ summary_text = (f"Total Records: {stats['total_rows']:,} | "
38
+ f"Anomalies: {stats['rows_with_anomalies']:,} | "
39
+ f"Subscribers: {stats['unique_subscribers']:,} | "
40
+ f"Rate: {stats['anomaly_rate']:.2f}%")
41
+ pdf.cell(0, 8, summary_text, ln=True)
42
+ pdf.ln(5)
43
+
44
+ # Detailed Table
45
+ pdf.set_font("helvetica", "B", 12)
46
+ pdf.cell(0, 8, " 2. Detailed Anomalous Records (All 20 Fields)", ln=True, fill=True)
47
+ pdf.ln(3)
48
+
49
+ # Define 20 columns and their widths (Landscape A4 width is ~277mm inside margins)
50
+ fields = [
51
+ ("Date", "event_date", 16),
52
+ ("Time", "event_time", 14),
53
+ ("Dir", "call_direction", 8),
54
+ ("Sub-Type", "usage_sub_type", 16),
55
+ ("Calling No", "calling_no", 22),
56
+ ("Called No", "called_no", 22),
57
+ ("Type", "usage_type", 11),
58
+ ("Dur", "duration", 8),
59
+ ("IMEI", "imei", 24),
60
+ ("Loc-ID", "location_id", 14),
61
+ ("Region", "region", 15),
62
+ ("District", "district", 15),
63
+ ("City", "city", 15),
64
+ ("MSISDN", "msisdn", 22),
65
+ ("Long", "longitude", 14),
66
+ ("Lat", "latitude", 14),
67
+ ("Azi", "azimuth", 8),
68
+ ("Anom", "has_anomaly", 8),
69
+ ("Cnt", "anomaly_count", 6),
70
+ ("Methods", "anomaly_methods", 0) # 0 means fill remaining width
71
+ ]
72
+
73
+ # Calculate width of last column
74
+ used_width = sum(f[2] for f in fields[:-1])
75
+ fields[-1] = (fields[-1][0], fields[-1][1], 277 - used_width)
76
+
77
+ # Header
78
+ pdf.set_font("helvetica", "B", 5.5) # Small font for many columns
79
+ pdf.set_fill_color(200, 220, 255)
80
+ for label, _, width in fields:
81
+ pdf.cell(width, 7, label, border=1, fill=True, align="C")
82
+ pdf.ln()
83
+
84
+ # Data Rows
85
+ pdf.set_font("helvetica", "", 5.5)
86
+ for record in results[:100]: # Limit to top 100 in PDF for performance
87
+ # Draw row
88
+ for _, key, width in fields:
89
+ val = str(record.get(key, ""))
90
+ # Truncate if too long for cell
91
+ if pdf.get_string_width(val) > width - 1:
92
+ while pdf.get_string_width(val + "..") > width - 1 and len(val) > 0:
93
+ val = val[:-1]
94
+ val += ".."
95
+
96
+ pdf.cell(width, 6, val, border=1)
97
+ pdf.ln()
98
+
99
+ if len(results) > 100:
100
+ pdf.set_font("helvetica", "I", 7)
101
+ pdf.cell(0, 10, f"Showing top 100 of {len(results)} anomalies. Please refer to CSV/JSON for full dataset.", ln=True, align="R")
102
+
103
+ # Save PDF
104
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
105
+ output_path = self.output_dir / f"Anomaly_Report_Detailed_{timestamp}.pdf"
106
+ pdf.output(str(output_path))
107
+ return str(output_path)
telecom_anomaly/io/reader.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data reader for telecom CSV files
3
+ """
4
+
5
+ import csv
6
+ import os
7
+ import logging
8
+ import pandas as pd
9
+ from typing import List, Dict, Any, Optional, Union
10
+ from datetime import datetime
11
+
12
+ from telecom_anomaly.validation.validator import DataValidator
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class TelecomDataReader:
18
+ """Read and validate telecom data from CSV files"""
19
+
20
+ def __init__(self, config, batch_size: int = 10000):
21
+ self.config = config
22
+ self.batch_size = batch_size
23
+ self.headers: List[str] = []
24
+ self.validator = DataValidator()
25
+
26
+ def read_files(self, file_paths: Union[str, List[str]],
27
+ encoding: str = 'utf-8-sig') -> List[Dict[str, Any]]:
28
+ """
29
+ Read and validate data from CSV files
30
+
31
+ Returns list of cleaned records with original indices
32
+ """
33
+ if isinstance(file_paths, str):
34
+ file_paths = [file_paths]
35
+
36
+ all_records = []
37
+ original_data = []
38
+
39
+ for file_path in file_paths:
40
+ if not os.path.exists(file_path):
41
+ logger.warning(f"File not found: {file_path}")
42
+ continue
43
+
44
+ logger.info(f"Reading data from {file_path}")
45
+
46
+ try:
47
+ file_records, file_original = self._read_single_file(
48
+ file_path, encoding, len(original_data)
49
+ )
50
+ all_records.extend(file_records)
51
+ original_data.extend(file_original)
52
+
53
+ except Exception as e:
54
+ logger.error(f"Failed to read {file_path}: {e}")
55
+
56
+ logger.info(f"Read {len(all_records)} valid records from {len(original_data)} total rows")
57
+ return all_records, original_data
58
+
59
+ def read_dataframe(self, df: 'pd.DataFrame') -> List[Dict[str, Any]]:
60
+ """
61
+ Read and validate data from a pandas DataFrame
62
+
63
+ Returns list of cleaned records with original indices
64
+ """
65
+ records = []
66
+ original_data = []
67
+
68
+ # Set headers from dataframe columns if not already set
69
+ if not self.headers:
70
+ self.headers = [str(c).strip() for c in df.columns]
71
+
72
+ # Convert DataFrame to records (list of dicts)
73
+ # We need to handle it row by row to match the original index-based cleaning
74
+ for idx, row in df.iterrows():
75
+ # Create row dictionary
76
+ row_dict = row.to_dict()
77
+ # Ensure all values are strings or expected types for cleaning
78
+ row_dict = {k: str(v) if pd.notna(v) else "" for k, v in row_dict.items()}
79
+
80
+ # Store original
81
+ original_copy = row_dict.copy()
82
+ original_copy['_file_source'] = "dataframe_input"
83
+ original_copy['_row_number'] = idx + 1
84
+ original_data.append(original_copy)
85
+
86
+ # Clean record
87
+ cleaned = self._clean_record(row_dict, len(original_data) - 1)
88
+ if cleaned:
89
+ records.append(cleaned)
90
+
91
+ logger.info(f"Processed {len(records)} valid records from {len(original_data)} DataFrame rows")
92
+ return records, original_data
93
+
94
+ def _read_single_file(self, file_path: str, encoding: str,
95
+ base_idx: int) -> tuple:
96
+ """Read and validate a single CSV file"""
97
+ records = []
98
+ original_data = []
99
+
100
+ with open(file_path, 'r', encoding=encoding) as f:
101
+ reader = csv.reader(f)
102
+
103
+ # Read headers
104
+ if not self.headers:
105
+ self.headers = next(reader)
106
+ self.headers = [h.strip() for h in self.headers]
107
+ else:
108
+ next(reader) # Skip header if already read
109
+
110
+ for row_num, row in enumerate(reader, start=1):
111
+ if not row or all(not cell for cell in row):
112
+ continue
113
+
114
+ # Ensure row length matches headers
115
+ if len(row) < len(self.headers):
116
+ row.extend([''] * (len(self.headers) - len(row)))
117
+ elif len(row) > len(self.headers):
118
+ row = row[:len(self.headers)]
119
+
120
+ # Create row dictionary
121
+ row_dict = dict(zip(self.headers, row))
122
+
123
+ # Store original
124
+ original_copy = row_dict.copy()
125
+ original_copy['_file_source'] = os.path.basename(file_path)
126
+ original_copy['_row_number'] = row_num
127
+ original_data.append(original_copy)
128
+
129
+ # Clean record
130
+ cleaned = self._clean_record(row_dict, base_idx + len(original_data) - 1)
131
+ if cleaned:
132
+ records.append(cleaned)
133
+
134
+ return records, original_data
135
+
136
+ def _clean_record(self, row: Dict[str, Any], original_idx: int) -> Optional[Dict[str, Any]]:
137
+ """Clean and validate a single record"""
138
+ cleaned = {}
139
+
140
+ # Strip string values
141
+ for key, value in row.items():
142
+ cleaned[key] = value.strip() if isinstance(value, str) else value
143
+
144
+ # Add metadata
145
+ cleaned['_original_idx'] = original_idx
146
+
147
+ # Validate MSISDN
148
+ msisdn = cleaned.get('msisdn', '')
149
+ if not self.validator.validate_msisdn(msisdn):
150
+ return None
151
+
152
+ # Validate duration
153
+ cleaned['duration'] = self.validator.validate_duration(cleaned.get('duration'))
154
+
155
+ # Validate coordinates
156
+ lat, lon = self.validator.validate_coordinates(
157
+ cleaned.get('latitude'), cleaned.get('longitude')
158
+ )
159
+ cleaned['latitude'] = lat
160
+ cleaned['longitude'] = lon
161
+
162
+ # Parse datetime
163
+ cleaned['datetime'] = self.validator.parse_datetime(
164
+ cleaned.get('event_date', ''),
165
+ cleaned.get('event_time', '')
166
+ )
167
+
168
+ # Check night call
169
+ cleaned['is_night'] = self.validator.is_night_call(
170
+ cleaned.get('event_time', ''),
171
+ self.config.night_hours_start,
172
+ self.config.night_hours_end
173
+ )
174
+
175
+ # Check service number
176
+ calling = cleaned.get('calling_no', '')
177
+ cleaned['is_service'] = self.validator.is_service_number(
178
+ calling, self.config.service_numbers
179
+ )
180
+
181
+ return cleaned
telecom_anomaly/main.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Main entry point for telecom anomaly detection system
3
+ """
4
+
5
+ import sys
6
+ import logging
7
+ from pathlib import Path
8
+
9
+ from telecom_anomaly import TelecomAnomalyDetector, TelecomConfig
10
+ from config.settings import DEFAULT_THRESHOLDS, SERVICE_NUMBERS, INPUT_DIR, OUTPUT_DIR, SAVE_PDF_DIR
11
+
12
+ # Configure logging
13
+ from config.settings import LOGGING_CONFIG
14
+ logging.basicConfig(
15
+ level=LOGGING_CONFIG['level'],
16
+ format=LOGGING_CONFIG['format'],
17
+ handlers=[
18
+ logging.FileHandler(LOGGING_CONFIG['log_file']),
19
+ logging.StreamHandler(sys.stdout)
20
+ ]
21
+ )
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def main():
26
+ """Main function"""
27
+ print("\n" + "=" * 60)
28
+ print("📱 TELECOM ANOMALY DETECTION SYSTEM")
29
+ print("=" * 60)
30
+
31
+ # Create config with defaults
32
+ config = TelecomConfig()
33
+ config.service_numbers = SERVICE_NUMBERS
34
+ config.output_dir = str(OUTPUT_DIR)
35
+ config.save_pdf_dir = str(SAVE_PDF_DIR)
36
+
37
+ # Get files
38
+ if len(sys.argv) > 1:
39
+ file_paths = sys.argv[1:]
40
+ else:
41
+ print(f"\nEnter CSV file paths (or place files in {INPUT_DIR}):")
42
+ print("Press Enter to scan input directory, or type paths:")
43
+
44
+ user_input = input().strip()
45
+
46
+ if not user_input:
47
+ # Scan input directory
48
+ file_paths = [str(p) for p in INPUT_DIR.glob('*.csv')]
49
+ if file_paths:
50
+ print(f"Found {len(file_paths)} CSV files in input directory")
51
+ else:
52
+ print("No CSV files found in input directory")
53
+ file_paths = []
54
+ else:
55
+ file_paths = [user_input]
56
+
57
+ # Get additional files
58
+ while True:
59
+ path = input().strip()
60
+ if not path:
61
+ break
62
+ file_paths.append(path)
63
+
64
+ # Validate files
65
+ valid_files = []
66
+ for path in file_paths:
67
+ if Path(path).exists():
68
+ valid_files.append(path)
69
+ else:
70
+ print(f"❌ File not found: {path}")
71
+
72
+ if not valid_files:
73
+ print("\n❌ No valid input files. Exiting.")
74
+ return
75
+
76
+ print(f"\n📊 Processing {len(valid_files)} file(s)...")
77
+
78
+ # Run detection
79
+ try:
80
+ detector = TelecomAnomalyDetector(config)
81
+ detector.load_data(valid_files)
82
+ anomalies = detector.detect_anomalies()
83
+
84
+ # Export results
85
+ outputs = detector.export_results()
86
+
87
+ # Prepare results for PDF (enriched records)
88
+ pdf_results = []
89
+ requested_fields = [
90
+ "event_date", "event_time", "call_direction", "usage_sub_type",
91
+ "calling_no", "called_no", "usage_type", "duration", "imei",
92
+ "location_id", "region", "district", "city", "msisdn",
93
+ "longitude", "latitude", "azimuth"
94
+ ]
95
+ for idx, row in enumerate(detector.original_data):
96
+ methods = detector.row_anomaly_map.get(idx, [])
97
+ if methods:
98
+ record = {field: row.get(field, "") for field in requested_fields}
99
+ record.update({
100
+ "has_anomaly": "YES",
101
+ "anomaly_count": len(methods),
102
+ "anomaly_methods": "; ".join(methods)
103
+ })
104
+ pdf_results.append(record)
105
+
106
+ # Generate PDF report
107
+ pdf_source_name = Path(valid_files[0]).name if valid_files else "Batch_Analysis"
108
+ pdf_path = detector.export_pdf(pdf_results, pdf_source_name)
109
+ outputs['pdf_report'] = pdf_path
110
+
111
+ # Show results
112
+ stats = detector.get_statistics()
113
+
114
+ print("\n" + "=" * 60)
115
+ print("✅ ANALYSIS COMPLETE")
116
+ print("=" * 60)
117
+ print(f"📁 Total records: {stats.total_rows:,}")
118
+ print(f"👤 Subscribers with anomalies: {stats.unique_subscribers:,}")
119
+ print(f"🔍 Rows with anomalies: {stats.rows_with_anomalies:,}")
120
+ print(f"⚠️ Total anomalies: {stats.total_anomalies_detected:,}")
121
+ print(f"📊 Anomaly rate: {stats.anomaly_rate:.2f}%")
122
+ print("\n📄 Files created:")
123
+ for name, path in outputs.items():
124
+ print(f" - {name}: {Path(path).name}")
125
+ print("\n" + "=" * 60)
126
+
127
+ except Exception as e:
128
+ logger.error(f"Detection failed: {e}", exc_info=True)
129
+ print(f"\n❌ Error: {e}")
130
+ return 1
131
+
132
+ return 0
133
+
134
+
135
+ if __name__ == "__main__":
136
+ sys.exit(main())
telecom_anomaly/telecom_anomaly/detection/base.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Base classes for anomaly detectors
3
+ """
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import List, Dict, Any, Optional
7
+ from collections import defaultdict
8
+
9
+ from telecom_anomaly.core.models import AnomalyResult, TelecomConfig
10
+
11
+
12
+ class BaseDetector(ABC):
13
+ """Base class for all anomaly detectors"""
14
+
15
+ def __init__(self, config: TelecomConfig):
16
+ self.config = config
17
+ self.name = self.__class__.__name__
18
+
19
+ @abstractmethod
20
+ def detect(self, data: List[Dict], **kwargs) -> List[AnomalyResult]:
21
+ """Detect anomalies in the data"""
22
+ pass
23
+
24
+ def _create_anomaly(self,
25
+ row_index: int,
26
+ column_name: str,
27
+ value: Any,
28
+ method: str,
29
+ score: float,
30
+ threshold: float,
31
+ confidence: float,
32
+ msisdn: str = "",
33
+ metadata: Optional[Dict] = None) -> AnomalyResult:
34
+ """Helper method to create an anomaly result"""
35
+ return AnomalyResult(
36
+ row_index=row_index,
37
+ column_name=column_name,
38
+ value=value,
39
+ detection_method=method,
40
+ score=score,
41
+ threshold=threshold,
42
+ confidence=confidence,
43
+ msisdn=msisdn,
44
+ metadata=metadata or {}
45
+ )
46
+
47
+
48
+ class SubscriberBasedDetector(BaseDetector):
49
+ """Base class for detectors that operate on subscriber data"""
50
+
51
+ def __init__(self, config: TelecomConfig):
52
+ super().__init__(config)
53
+ self.subscriber_data: Dict[str, List[Dict]] = defaultdict(list)
54
+
55
+ def prepare_data(self, data: List[Dict]):
56
+ """Prepare data by grouping by subscriber"""
57
+ self.subscriber_data.clear()
58
+ for row in data:
59
+ msisdn = row.get('msisdn')
60
+ if msisdn:
61
+ self.subscriber_data[msisdn].append(row)
62
+
63
+ @abstractmethod
64
+ def detect(self, data: List[Dict], **kwargs) -> List[AnomalyResult]:
65
+ pass
telecom_anomaly/utils/__init__.py ADDED
File without changes
telecom_anomaly/utils/geo.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Geographic utilities for location-based anomaly detection
3
+ """
4
+
5
+ import math
6
+ from typing import Optional, Tuple
7
+
8
+
9
+ def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
10
+ """
11
+ Calculate the great circle distance between two points
12
+ on the Earth (specified in decimal degrees)
13
+
14
+ Returns distance in kilometers
15
+ """
16
+ if None in (lat1, lon1, lat2, lon2):
17
+ return 0.0
18
+
19
+ # Convert decimal degrees to radians
20
+ lat1, lon1, lat2, lon2 = map(math.radians, [lat1, lon1, lat2, lon2])
21
+
22
+ # Haversine formula
23
+ dlat = lat2 - lat1
24
+ dlon = lon2 - lon1
25
+ a = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2
26
+ c = 2 * math.asin(min(math.sqrt(a), 1.0))
27
+
28
+ # Radius of Earth in kilometers
29
+ r = 6371
30
+ return c * r
31
+
32
+
33
+ def calculate_speed(distance_km: float, time_hours: float) -> float:
34
+ """Calculate speed in km/h"""
35
+ if time_hours <= 0:
36
+ return 0.0
37
+ return distance_km / time_hours
38
+
39
+
40
+ def is_plausible_location_change(
41
+ lat1: float, lon1: float,
42
+ lat2: float, lon2: float,
43
+ time_diff_hours: float,
44
+ max_speed_kmh: float = 1000.0
45
+ ) -> Tuple[bool, float, float]:
46
+ """
47
+ Check if a location change is plausible
48
+
49
+ Returns: (is_plausible, distance_km, speed_kmh)
50
+ """
51
+ distance = haversine_distance(lat1, lon1, lat2, lon2)
52
+ speed = calculate_speed(distance, time_diff_hours)
53
+
54
+ is_plausible = speed <= max_speed_kmh
55
+
56
+ return is_plausible, distance, speed
telecom_anomaly/utils/helpers.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Helper utilities
3
+ """
4
+
5
+ import hashlib
6
+ from typing import Any, Dict, List, Set
7
+ from collections import defaultdict
8
+
9
+
10
+ def generate_row_hash(row: Dict[str, Any]) -> str:
11
+ """Generate a unique hash for a row"""
12
+ row_str = ''.join(str(v) for v in row.values() if v is not None)
13
+ return hashlib.md5(row_str.encode()).hexdigest()
14
+
15
+
16
+ def safe_divide(numerator: float, denominator: float, default: float = 0.0) -> float:
17
+ """Safe division with default value"""
18
+ try:
19
+ if denominator == 0:
20
+ return default
21
+ return numerator / denominator
22
+ except (ZeroDivisionError, TypeError, ValueError):
23
+ return default
24
+
25
+
26
+ def chunk_list(lst: List, chunk_size: int):
27
+ """Split list into chunks"""
28
+ for i in range(0, len(lst), chunk_size):
29
+ yield lst[i:i + chunk_size]
30
+
31
+
32
+ class SubscriberTracker:
33
+ """Track subscriber data efficiently"""
34
+
35
+ def __init__(self):
36
+ self.subscriber_data: Dict[str, List[Dict]] = defaultdict(list)
37
+ self.subscriber_indices: Dict[str, Set[int]] = defaultdict(set)
38
+
39
+ def add_record(self, msisdn: str, record: Dict, index: int):
40
+ """Add a record for a subscriber"""
41
+ self.subscriber_data[msisdn].append(record)
42
+ self.subscriber_indices[msisdn].add(index)
43
+
44
+ def get_records(self, msisdn: str) -> List[Dict]:
45
+ """Get all records for a subscriber"""
46
+ return self.subscriber_data.get(msisdn, [])
47
+
48
+ def get_indices(self, msisdn: str) -> Set[int]:
49
+ """Get all indices for a subscriber"""
50
+ return self.subscriber_indices.get(msisdn, set())
51
+
52
+ def get_all_subscribers(self) -> List[str]:
53
+ """Get all subscriber MSISDNs"""
54
+ return list(self.subscriber_data.keys())
55
+
56
+ def get_subscriber_count(self) -> int:
57
+ """Get number of unique subscribers"""
58
+ return len(self.subscriber_data)
59
+
60
+ def clear(self):
61
+ """Clear all data"""
62
+ self.subscriber_data.clear()
63
+ self.subscriber_indices.clear()
telecom_anomaly/utils/pdf_processor.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import tabula
2
+ import pandas as pd
3
+ import io
4
+ import re
5
+ import os
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ # --- CONFIGURATION (Restored) ---
10
+ DATA_BASE = Path("data")
11
+ RAW_CSV_DIR = DATA_BASE / "raw_csv_temp"
12
+ PROCESS_OUTPUT_DIR = DATA_BASE / "output_process_data"
13
+ STRICT_CLEAN_DIR = DATA_BASE / "strictly_cleaned_data"
14
+
15
+ def setup_folders():
16
+ """Ensure all necessary output directories exist."""
17
+ for folder in [RAW_CSV_DIR, PROCESS_OUTPUT_DIR, STRICT_CLEAN_DIR]:
18
+ try:
19
+ if not os.path.exists(folder):
20
+ os.makedirs(folder)
21
+ except Exception as e:
22
+ print(f"Warning: Could not create folder {folder}: {e}")
23
+
24
+ # --- ORIGINAL FUNCTIONS (Restored) ---
25
+
26
+ def extract_pdf_to_csv(pdf_path, base_name):
27
+ """Stage 1: Convert PDF tables to a raw CSV file."""
28
+ setup_folders()
29
+ csv_path = os.path.join(RAW_CSV_DIR, f"{base_name}_raw.csv")
30
+ try:
31
+ tabula.convert_into(pdf_path, csv_path, output_format="csv", pages="all", lattice=True)
32
+ return csv_path
33
+ except Exception as e:
34
+ print(f"Error extracting {pdf_path}: {e}")
35
+ return None
36
+
37
+ def process_telecom_data_complete(input_path, base_name):
38
+ """Stage 2: Parse raw CSV and repair fragmented rows."""
39
+ try:
40
+ with open(input_path, 'r', encoding='utf-8') as f:
41
+ lines = f.readlines()
42
+ return _process_lines_logic(lines, base_name, save_files=True)
43
+ except Exception as e:
44
+ print(f"Error reading {input_path}: {e}")
45
+ return None
46
+
47
+ def strict_clean_data(file_path, base_name):
48
+ """Stage 3: Final strict cleaning."""
49
+ df = pd.read_csv(file_path)
50
+ df_cleaned = _strict_clean_logic(df)
51
+
52
+ setup_folders()
53
+ final_path = os.path.join(STRICT_CLEAN_DIR, f'{base_name}_strictly_cleaned.csv')
54
+ df_cleaned.to_csv(final_path, index=False)
55
+ return final_path
56
+
57
+ # --- IN-MEMORY VERSIONS (Exact same logic) ---
58
+
59
+ def extract_pdf_to_lines(pdf_file_path):
60
+ """Stage 1 (In-Memory): Returns raw CSV lines."""
61
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".csv") as tmp:
62
+ tmp_path = tmp.name
63
+ try:
64
+ tabula.convert_into(pdf_file_path, tmp_path, output_format="csv", pages="all", lattice=True)
65
+ with open(tmp_path, 'r', encoding='utf-8') as f:
66
+ lines = f.readlines()
67
+ return lines
68
+ finally:
69
+ if os.path.exists(tmp_path):
70
+ os.remove(tmp_path)
71
+
72
+ def process_telecom_data_df(lines, base_name):
73
+ """Stage 2 (In-Memory): Returns DataFrame."""
74
+ return _process_lines_logic(lines, base_name, save_files=False)
75
+
76
+ def strict_clean_df(df):
77
+ """Stage 3 (In-Memory): Returns Cleaned DataFrame."""
78
+ return _strict_clean_logic(df)
79
+
80
+ # --- SHARED LOGIC (To ensure 100% parity) ---
81
+
82
+ def _process_lines_logic(lines, base_name, save_files=False):
83
+ header_idx = -1
84
+ for i, line in enumerate(lines):
85
+ if "event_date_time" in line:
86
+ header_idx = i
87
+ break
88
+
89
+ if header_idx == -1:
90
+ return None
91
+
92
+ if save_files:
93
+ setup_folders()
94
+ customer_text = "".join(lines[:header_idx])
95
+ df_customer = pd.read_csv(io.StringIO(customer_text))
96
+ df_customer.columns = [re.sub(r'[\r\n\s]+', ' ', c).strip() for c in df_customer.columns]
97
+ cust_path = os.path.join(PROCESS_OUTPUT_DIR, f'{base_name}_customer_info.csv')
98
+ df_customer.to_csv(cust_path, index=False)
99
+
100
+ call_text = "".join(lines[header_idx:])
101
+ df_raw = pd.read_csv(io.StringIO(call_text))
102
+ df_raw.columns = [c.strip() for c in df_raw.columns]
103
+ main_col = df_raw.columns[0]
104
+ df_raw = df_raw[df_raw[main_col].astype(str).str.contains("event_date_time") == False]
105
+
106
+ fixed_rows = []
107
+ current_row = None
108
+ for _, row in df_raw.iterrows():
109
+ val = str(row[main_col]).strip()
110
+ if re.match(r'^\d{4}-\d{2}-\d{2}', val):
111
+ if current_row is not None:
112
+ fixed_rows.append(current_row)
113
+ current_row = row.copy()
114
+ elif current_row is not None:
115
+ current_row[main_col] = (str(current_row[main_col]) + " " + val).replace('\r', '').replace('\n', '')
116
+ for col in df_raw.columns:
117
+ if col != main_col and pd.api.types.is_string_dtype(df_raw[col]):
118
+ fragment = str(row[col]).strip()
119
+ if fragment.lower() != 'nan':
120
+ current_row[col] = (str(current_row[col]) + " " + fragment).replace('\r', '').replace('\n', ' ')
121
+
122
+ if current_row is not None:
123
+ fixed_rows.append(current_row)
124
+
125
+ df_calls = pd.DataFrame(fixed_rows)
126
+ df_calls[main_col] = df_calls[main_col].str.replace(r'[\r\n]+', '', regex=True).str.replace(r'\s+', ' ', regex=True).str.strip()
127
+ dt_series = pd.to_datetime(df_calls[main_col], errors='coerce')
128
+ df_calls['event_date'] = dt_series.dt.date
129
+ df_calls['event_time'] = dt_series.dt.time
130
+ df_calls.drop(columns=[main_col], inplace=True)
131
+
132
+ uppercase_cols = ['district', 'city', 'usage_type', 'usage_sub_type', 'calling_no', 'called_no', 'imei', 'location_id', 'msisdn']
133
+ for col in df_calls.columns:
134
+ c_low = col.lower()
135
+ if df_calls[col].dtype == 'object':
136
+ df_calls[col] = df_calls[col].astype(str).str.replace(r'[\r\n\s]+', ' ', regex=True).str.strip()
137
+ if c_low in uppercase_cols:
138
+ df_calls[col] = df_calls[col].str.upper()
139
+ elif c_low == 'region':
140
+ df_calls[col] = df_calls[col].str.title()
141
+ elif c_low == 'call_direction':
142
+ df_calls[col] = df_calls[col].str.title().replace({'Incomingmt': 'Incoming (MT)', '\(\(': '(', '\)\)': ')'}, regex=True)
143
+
144
+ ordered_cols = ['event_date', 'event_time'] + [c for c in df_calls.columns if c not in ['event_date', 'event_time']]
145
+ df_calls = df_calls[ordered_cols]
146
+
147
+ if save_files:
148
+ interim_path = os.path.join(PROCESS_OUTPUT_DIR, f'{base_name}_call_records_final.csv')
149
+ df_calls.to_csv(interim_path, index=False)
150
+ return interim_path
151
+ return df_calls
152
+
153
+ def _strict_clean_logic(df):
154
+ if 'duration' in df.columns and df['duration'].dtype == 'object':
155
+ df['duration'] = df['duration'].str.replace(',', '.').astype(float)
156
+ df['event_date_copy'] = pd.to_datetime(df['event_date'], errors='coerce')
157
+ df['event_time_check'] = pd.to_datetime(df['event_time'].astype(str), format='%H:%M:%S', errors='coerce')
158
+ df_cleaned = df.dropna().copy()
159
+ return df_cleaned.drop(columns=['event_date_copy', 'event_time_check'])
telecom_anomaly/validation/__init__.py ADDED
File without changes
telecom_anomaly/validation/validator.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data validation utilities for telecom data
3
+ """
4
+
5
+ from datetime import datetime
6
+ from typing import Optional, Tuple, Any
7
+ import logging
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class DataValidator:
13
+ """Validate and clean telecom data"""
14
+
15
+ @staticmethod
16
+ def validate_msisdn(msisdn: str) -> bool:
17
+ """Validate MSISDN format"""
18
+ if not msisdn:
19
+ return False
20
+ msisdn_str = str(msisdn).strip()
21
+ # Allow service names
22
+ if msisdn_str in ['MTN', 'MOBILEMONEY', 'MTN-DATA', 'MTNXTRATIME', 'EVD']:
23
+ return True
24
+ # Ghana format: starts with 233 and 12 digits
25
+ return msisdn_str.startswith('233') and len(msisdn_str) == 12
26
+
27
+ @staticmethod
28
+ def validate_duration(duration: Any) -> float:
29
+ """Validate and clean duration"""
30
+ if duration is None or duration == '':
31
+ return 0.0
32
+ try:
33
+ duration_float = float(duration)
34
+ return max(0.0, duration_float)
35
+ except (ValueError, TypeError):
36
+ return 0.0
37
+
38
+ @staticmethod
39
+ def validate_coordinates(lat: Any, lon: Any) -> Tuple[Optional[float], Optional[float]]:
40
+ """Validate latitude and longitude"""
41
+ try:
42
+ lat_float = float(lat) if lat not in (None, '', '0.0') else None
43
+ lon_float = float(lon) if lon not in (None, '', '0.0') else None
44
+
45
+ if lat_float is not None and not (-90 <= lat_float <= 90):
46
+ lat_float = None
47
+ if lon_float is not None and not (-180 <= lon_float <= 180):
48
+ lon_float = None
49
+
50
+ return lat_float, lon_float
51
+ except (ValueError, TypeError):
52
+ return None, None
53
+
54
+ @staticmethod
55
+ def parse_datetime(date_str: str, time_str: str) -> Optional[datetime]:
56
+ """Parse datetime from date and time strings"""
57
+ try:
58
+ if date_str and time_str and date_str != 'NULL' and time_str != 'NULL':
59
+ return datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M:%S")
60
+ except (ValueError, TypeError) as e:
61
+ logger.debug(f"Failed to parse datetime: {date_str} {time_str} - {e}")
62
+ return None
63
+
64
+ @staticmethod
65
+ def is_night_call(time_str: str, night_start: int = 23, night_end: int = 5) -> bool:
66
+ """Check if call occurred at night"""
67
+ try:
68
+ if not time_str or time_str == 'NULL':
69
+ return False
70
+ hour = int(time_str.split(':')[0])
71
+ return hour >= night_start or hour < night_end
72
+ except (ValueError, IndexError):
73
+ return False
74
+
75
+ @staticmethod
76
+ def is_service_number(calling_no: str, service_numbers: list) -> bool:
77
+ """Check if number is a service number"""
78
+ return calling_no in service_numbers
tests/test_validator.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for data validator
3
+ """
4
+
5
+ import unittest
6
+ from datetime import datetime
7
+ from telecom_anomaly.validation.validator import DataValidator
8
+
9
+
10
+ class TestDataValidator(unittest.TestCase):
11
+
12
+ def setUp(self):
13
+ self.validator = DataValidator()
14
+
15
+ def test_validate_msisdn_valid(self):
16
+ self.assertTrue(self.validator.validate_msisdn('233123456789'))
17
+ self.assertTrue(self.validator.validate_msisdn('MTN'))
18
+
19
+ def test_validate_msisdn_invalid(self):
20
+ self.assertFalse(self.validator.validate_msisdn(''))
21
+ self.assertFalse(self.validator.validate_msisdn('12345'))
22
+ self.assertFalse(self.validator.validate_msisdn('23312345')) # Too short
23
+
24
+ def test_validate_duration(self):
25
+ self.assertEqual(self.validator.validate_duration('120'), 120.0)
26
+ self.assertEqual(self.validator.validate_duration('-10'), 0.0)
27
+ self.assertEqual(self.validator.validate_duration(''), 0.0)
28
+ self.assertEqual(self.validator.validate_duration(None), 0.0)
29
+
30
+ def test_validate_coordinates(self):
31
+ lat, lon = self.validator.validate_coordinates('5.5', '-0.2')
32
+ self.assertEqual(lat, 5.5)
33
+ self.assertEqual(lon, -0.2)
34
+
35
+ lat, lon = self.validator.validate_coordinates('', '')
36
+ self.assertIsNone(lat)
37
+ self.assertIsNone(lon)
38
+
39
+ lat, lon = self.validator.validate_coordinates('100', '200') # Invalid
40
+ self.assertIsNone(lat)
41
+ self.assertIsNone(lon)
42
+
43
+ def test_parse_datetime(self):
44
+ dt = self.validator.parse_datetime('2024-01-01', '12:30:00')
45
+ self.assertIsNotNone(dt)
46
+ self.assertEqual(dt.year, 2024)
47
+ self.assertEqual(dt.month, 1)
48
+ self.assertEqual(dt.day, 1)
49
+
50
+ dt = self.validator.parse_datetime('NULL', 'NULL')
51
+ self.assertIsNone(dt)
52
+
53
+ def test_is_night_call(self):
54
+ self.assertTrue(self.validator.is_night_call('23:30:00', 23, 5))
55
+ self.assertTrue(self.validator.is_night_call('02:00:00', 23, 5))
56
+ self.assertFalse(self.validator.is_night_call('14:00:00', 23, 5))
57
+ self.assertFalse(self.validator.is_night_call('', 23, 5))
58
+
59
+
60
+ if __name__ == '__main__':
61
+ unittest.main()
uv.lock ADDED
The diff for this file is too large to render. See raw diff