Spaces:
Sleeping
Sleeping
Deploy FastAPI Telecom Anomaly Detection to Hugging Face
Browse files- .dockerignore +19 -0
- .gitignore +10 -0
- .python-version +1 -0
- Dockerfile +30 -0
- README.md +165 -7
- READ_ME.md +10 -0
- RunAPI.md +78 -0
- config/__init__.py +1 -0
- config/settings.py +59 -0
- config/telecom_anomaly/core/models.py +102 -0
- main.py +5 -0
- pyproject.toml +29 -0
- requirements.txt +23 -0
- setup.py +41 -0
- telecom_anomaly/__init__.py +9 -0
- telecom_anomaly/api.py +167 -0
- telecom_anomaly/core/__init__.py +0 -0
- telecom_anomaly/core/detector.py +312 -0
- telecom_anomaly/core/models.py +107 -0
- telecom_anomaly/detection/__init__.py +0 -0
- telecom_anomaly/detection/base.py +65 -0
- telecom_anomaly/detection/duration.py +70 -0
- telecom_anomaly/detection/frequency.py +59 -0
- telecom_anomaly/detection/imei.py +0 -0
- telecom_anomaly/detection/location.py +78 -0
- telecom_anomaly/detection/sms.py +55 -0
- telecom_anomaly/detection/time_patterns.py +37 -0
- telecom_anomaly/io/__init__.py +0 -0
- telecom_anomaly/io/exporter.py +183 -0
- telecom_anomaly/io/pdf_exporter.py +107 -0
- telecom_anomaly/io/reader.py +181 -0
- telecom_anomaly/main.py +136 -0
- telecom_anomaly/telecom_anomaly/detection/base.py +65 -0
- telecom_anomaly/utils/__init__.py +0 -0
- telecom_anomaly/utils/geo.py +56 -0
- telecom_anomaly/utils/helpers.py +63 -0
- telecom_anomaly/utils/pdf_processor.py +159 -0
- telecom_anomaly/validation/__init__.py +0 -0
- telecom_anomaly/validation/validator.py +78 -0
- tests/test_validator.py +61 -0
- uv.lock +0 -0
.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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|