Spaces:
Sleeping
Sleeping
adding model
Browse files- telecom_anomaly/api.py +166 -78
- telecom_anomaly/core/detector.py +32 -11
- telecom_anomaly/detection/isolation_forest.py +171 -0
- telecom_anomaly/io/pdf_exporter.py +20 -20
- telecom_anomaly/io/reader.py +38 -23
- telecom_anomaly/main.py +21 -9
- telecom_anomaly/validation/validator.py +26 -0
telecom_anomaly/api.py
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
|
|
| 1 |
import os
|
| 2 |
import shutil
|
| 3 |
import tempfile
|
| 4 |
import logging
|
| 5 |
from pathlib import Path
|
|
|
|
|
|
|
| 6 |
from fastapi import FastAPI, UploadFile, File, HTTPException
|
| 7 |
from fastapi.responses import RedirectResponse
|
| 8 |
|
|
@@ -19,7 +22,6 @@ from telecom_anomaly.utils.pdf_processor import (
|
|
| 19 |
STRICT_CLEAN_DIR,
|
| 20 |
)
|
| 21 |
|
| 22 |
-
# Configure logging
|
| 23 |
logging.basicConfig(level=logging.INFO)
|
| 24 |
logger = logging.getLogger(__name__)
|
| 25 |
|
|
@@ -32,6 +34,17 @@ app = FastAPI(
|
|
| 32 |
version="1.0.0"
|
| 33 |
)
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
@app.get("/")
|
| 37 |
async def root():
|
|
@@ -50,13 +63,38 @@ async def analyze_cdr(
|
|
| 50 |
file: UploadFile = File(...),
|
| 51 |
filter_movements: bool = False,
|
| 52 |
run_anomaly_detection: bool = True,
|
|
|
|
| 53 |
cluster_km: float = 1.0,
|
|
|
|
| 54 |
):
|
| 55 |
"""
|
| 56 |
Analyze a PDF or CSV file for telecom anomalies. PDFs go through the
|
| 57 |
3-stage disk-based pipeline (extract -> repair -> strict-clean) so the
|
| 58 |
-
detectors see exactly the same normalized inputs as
|
| 59 |
-
All intermediate CSVs are removed before
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
"""
|
| 61 |
filename = file.filename.lower()
|
| 62 |
is_pdf = filename.endswith('.pdf')
|
|
@@ -70,76 +108,25 @@ async def analyze_cdr(
|
|
| 70 |
tmp_path = tmp.name
|
| 71 |
|
| 72 |
base_name = os.path.splitext(os.path.basename(file.filename))[0]
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
|
| 77 |
try:
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
interim_csv = process_telecom_data_complete(raw_csv, base_name)
|
| 94 |
-
if not interim_csv:
|
| 95 |
-
raise HTTPException(status_code=500, detail="Failed to repair fragmented rows in CSV")
|
| 96 |
-
|
| 97 |
-
final_csv = strict_clean_data(interim_csv, base_name)
|
| 98 |
-
if not final_csv or not os.path.exists(final_csv):
|
| 99 |
-
raise HTTPException(status_code=500, detail="Strict-clean stage produced no output")
|
| 100 |
-
|
| 101 |
-
detector.load_data([final_csv], filter_movements=filter_movements)
|
| 102 |
-
else:
|
| 103 |
-
logger.info(f"Processing CSV: {file.filename}")
|
| 104 |
-
detector.load_data([tmp_path], filter_movements=filter_movements)
|
| 105 |
-
|
| 106 |
-
if run_anomaly_detection:
|
| 107 |
-
detector.detect_anomalies()
|
| 108 |
-
|
| 109 |
-
stats = detector.get_statistics()
|
| 110 |
-
|
| 111 |
-
requested_fields = [
|
| 112 |
-
"event_date", "event_time", "call_direction", "usage_sub_type",
|
| 113 |
-
"calling_no", "called_no", "usage_type", "duration", "imei",
|
| 114 |
-
"location_id", "region", "district", "city", "msisdn",
|
| 115 |
-
"longitude", "latitude", "azimuth",
|
| 116 |
-
]
|
| 117 |
-
|
| 118 |
-
final_records_to_return = []
|
| 119 |
-
kept_original_indices = set(detector.data_indices.values())
|
| 120 |
-
|
| 121 |
-
for idx, row in enumerate(detector.original_data):
|
| 122 |
-
if idx not in kept_original_indices:
|
| 123 |
-
continue
|
| 124 |
-
|
| 125 |
-
methods = detector.row_anomaly_map.get(idx, [])
|
| 126 |
-
|
| 127 |
-
if run_anomaly_detection and not methods:
|
| 128 |
-
continue
|
| 129 |
-
|
| 130 |
-
record = {field: row.get(field, "") for field in requested_fields}
|
| 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 |
-
return {
|
| 139 |
-
"filename": file.filename,
|
| 140 |
-
"statistics": stats.to_dict() if run_anomaly_detection else {"total_rows": len(detector.data)},
|
| 141 |
-
"results": final_records_to_return,
|
| 142 |
-
}
|
| 143 |
|
| 144 |
except HTTPException:
|
| 145 |
raise
|
|
@@ -155,13 +142,12 @@ async def analyze_cdr(
|
|
| 155 |
except OSError:
|
| 156 |
pass
|
| 157 |
|
| 158 |
-
# Remove intermediate CSVs from each pipeline stage so the deploy
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
):
|
| 165 |
if path and os.path.exists(path):
|
| 166 |
try:
|
| 167 |
os.remove(path)
|
|
@@ -169,6 +155,108 @@ async def analyze_cdr(
|
|
| 169 |
pass
|
| 170 |
|
| 171 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
if __name__ == "__main__":
|
| 173 |
import uvicorn
|
| 174 |
uvicorn.run(app, host="0.0.0.0", port=8000)
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
import os
|
| 3 |
import shutil
|
| 4 |
import tempfile
|
| 5 |
import logging
|
| 6 |
from pathlib import Path
|
| 7 |
+
from typing import Any, Dict, List
|
| 8 |
+
|
| 9 |
from fastapi import FastAPI, UploadFile, File, HTTPException
|
| 10 |
from fastapi.responses import RedirectResponse
|
| 11 |
|
|
|
|
| 22 |
STRICT_CLEAN_DIR,
|
| 23 |
)
|
| 24 |
|
|
|
|
| 25 |
logging.basicConfig(level=logging.INFO)
|
| 26 |
logger = logging.getLogger(__name__)
|
| 27 |
|
|
|
|
| 34 |
version="1.0.0"
|
| 35 |
)
|
| 36 |
|
| 37 |
+
# Canonical 14-column output schema shared across CSV and PDF inputs.
|
| 38 |
+
# msisdn is derived internally when missing; it intentionally is not
|
| 39 |
+
# emitted in the response because call_direction + calling_no + called_no
|
| 40 |
+
# already identify the subscriber unambiguously.
|
| 41 |
+
REQUESTED_FIELDS: List[str] = [
|
| 42 |
+
"event_date", "event_time", "call_direction",
|
| 43 |
+
"calling_no", "called_no", "usage_type", "duration", "imei",
|
| 44 |
+
"location_id", "region", "district", "city",
|
| 45 |
+
"longitude", "latitude", "azimuth",
|
| 46 |
+
]
|
| 47 |
+
|
| 48 |
|
| 49 |
@app.get("/")
|
| 50 |
async def root():
|
|
|
|
| 63 |
file: UploadFile = File(...),
|
| 64 |
filter_movements: bool = False,
|
| 65 |
run_anomaly_detection: bool = True,
|
| 66 |
+
use_model: bool = False,
|
| 67 |
cluster_km: float = 1.0,
|
| 68 |
+
max_results: int = 100,
|
| 69 |
):
|
| 70 |
"""
|
| 71 |
Analyze a PDF or CSV file for telecom anomalies. PDFs go through the
|
| 72 |
3-stage disk-based pipeline (extract -> repair -> strict-clean) so the
|
| 73 |
+
detectors see exactly the same normalized inputs as
|
| 74 |
+
telecom_anomaly_detection. All intermediate CSVs are removed before
|
| 75 |
+
the response returns.
|
| 76 |
+
|
| 77 |
+
Args:
|
| 78 |
+
file: PDF or CSV file to analyze.
|
| 79 |
+
filter_movements: If true, clusters coordinates within cluster_km
|
| 80 |
+
and keeps only distinct locations.
|
| 81 |
+
run_anomaly_detection: If true, performs anomaly detection. When
|
| 82 |
+
false the endpoint just returns the cleaned/clustered rows.
|
| 83 |
+
use_model: If true (and run_anomaly_detection is true), also runs
|
| 84 |
+
the Isolation Forest model-based detector alongside the rule
|
| 85 |
+
detectors. Default false preserves the original rule-only
|
| 86 |
+
pipeline.
|
| 87 |
+
cluster_km: Distance margin in kilometres for geographic
|
| 88 |
+
clustering (default 1.0).
|
| 89 |
+
max_results: Maximum number of records to include inline in the
|
| 90 |
+
JSON response (default 100). Records are ranked by
|
| 91 |
+
anomaly_count descending so the most interesting rows
|
| 92 |
+
survive truncation. Set to 0 for no cap. This cap exists
|
| 93 |
+
so Swagger UI / the browser does not freeze rendering huge
|
| 94 |
+
payloads when use_model=true on large inputs.
|
| 95 |
+
|
| 96 |
+
Returns:
|
| 97 |
+
Detection statistics and a list of results (capped per max_results).
|
| 98 |
"""
|
| 99 |
filename = file.filename.lower()
|
| 100 |
is_pdf = filename.endswith('.pdf')
|
|
|
|
| 108 |
tmp_path = tmp.name
|
| 109 |
|
| 110 |
base_name = os.path.splitext(os.path.basename(file.filename))[0]
|
| 111 |
+
# The threaded helper appends intermediate-CSV paths to this list so
|
| 112 |
+
# the finally block can clean them up even when an exception fires.
|
| 113 |
+
paths_to_cleanup: List[str] = []
|
| 114 |
|
| 115 |
try:
|
| 116 |
+
payload = await asyncio.to_thread(
|
| 117 |
+
_run_analysis,
|
| 118 |
+
tmp_path=tmp_path,
|
| 119 |
+
original_filename=file.filename,
|
| 120 |
+
base_name=base_name,
|
| 121 |
+
is_pdf=is_pdf,
|
| 122 |
+
cluster_km=cluster_km,
|
| 123 |
+
filter_movements=filter_movements,
|
| 124 |
+
run_anomaly_detection=run_anomaly_detection,
|
| 125 |
+
use_model=use_model,
|
| 126 |
+
max_results=max_results,
|
| 127 |
+
paths_to_cleanup=paths_to_cleanup,
|
| 128 |
+
)
|
| 129 |
+
return payload
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
except HTTPException:
|
| 132 |
raise
|
|
|
|
| 142 |
except OSError:
|
| 143 |
pass
|
| 144 |
|
| 145 |
+
# Remove intermediate CSVs from each pipeline stage so the deploy
|
| 146 |
+
# stays stateless (matches the original behaviour).
|
| 147 |
+
paths_to_cleanup.append(
|
| 148 |
+
os.path.join(PROCESS_OUTPUT_DIR, f"{base_name}_customer_info.csv")
|
| 149 |
+
)
|
| 150 |
+
for path in paths_to_cleanup:
|
|
|
|
| 151 |
if path and os.path.exists(path):
|
| 152 |
try:
|
| 153 |
os.remove(path)
|
|
|
|
| 155 |
pass
|
| 156 |
|
| 157 |
|
| 158 |
+
def _run_analysis(
|
| 159 |
+
tmp_path: str,
|
| 160 |
+
original_filename: str,
|
| 161 |
+
base_name: str,
|
| 162 |
+
is_pdf: bool,
|
| 163 |
+
cluster_km: float,
|
| 164 |
+
filter_movements: bool,
|
| 165 |
+
run_anomaly_detection: bool,
|
| 166 |
+
use_model: bool,
|
| 167 |
+
max_results: int,
|
| 168 |
+
paths_to_cleanup: List[str],
|
| 169 |
+
) -> Dict[str, Any]:
|
| 170 |
+
"""
|
| 171 |
+
Synchronous heavy work for /analyze.
|
| 172 |
+
|
| 173 |
+
Runs in a worker thread (via asyncio.to_thread) so the FastAPI event
|
| 174 |
+
loop stays responsive — without this, the browser sees the page as
|
| 175 |
+
frozen during long Isolation Forest fits and the user is prompted to
|
| 176 |
+
abandon the tab.
|
| 177 |
+
|
| 178 |
+
Mutates `paths_to_cleanup` with each intermediate CSV created so the
|
| 179 |
+
caller's finally block can delete them stateless-style.
|
| 180 |
+
"""
|
| 181 |
+
config = TelecomConfig()
|
| 182 |
+
config.service_numbers = SERVICE_NUMBERS
|
| 183 |
+
config.output_dir = None
|
| 184 |
+
config.save_pdf_dir = None
|
| 185 |
+
config.location_cluster_km = cluster_km
|
| 186 |
+
|
| 187 |
+
detector = TelecomAnomalyDetector(config)
|
| 188 |
+
|
| 189 |
+
if is_pdf:
|
| 190 |
+
logger.info(f"Processing PDF: {original_filename}")
|
| 191 |
+
|
| 192 |
+
raw_csv = extract_pdf_to_csv(tmp_path, base_name)
|
| 193 |
+
if not raw_csv:
|
| 194 |
+
raise HTTPException(status_code=500, detail="Failed to extract tables from PDF")
|
| 195 |
+
paths_to_cleanup.append(raw_csv)
|
| 196 |
+
|
| 197 |
+
interim_csv = process_telecom_data_complete(raw_csv, base_name)
|
| 198 |
+
if not interim_csv:
|
| 199 |
+
raise HTTPException(status_code=500, detail="Failed to repair fragmented rows in CSV")
|
| 200 |
+
paths_to_cleanup.append(interim_csv)
|
| 201 |
+
|
| 202 |
+
final_csv = strict_clean_data(interim_csv, base_name)
|
| 203 |
+
if not final_csv or not os.path.exists(final_csv):
|
| 204 |
+
raise HTTPException(status_code=500, detail="Strict-clean stage produced no output")
|
| 205 |
+
paths_to_cleanup.append(final_csv)
|
| 206 |
+
|
| 207 |
+
detector.load_data([final_csv], filter_movements=filter_movements)
|
| 208 |
+
else:
|
| 209 |
+
logger.info(f"Processing CSV: {original_filename}")
|
| 210 |
+
detector.load_data([tmp_path], filter_movements=filter_movements)
|
| 211 |
+
|
| 212 |
+
if run_anomaly_detection:
|
| 213 |
+
detector.detect_anomalies(use_model=use_model)
|
| 214 |
+
|
| 215 |
+
stats = detector.get_statistics()
|
| 216 |
+
kept_original_indices = set(detector.data_indices.values())
|
| 217 |
+
|
| 218 |
+
full_records: List[Dict[str, Any]] = []
|
| 219 |
+
for idx, row in enumerate(detector.original_data):
|
| 220 |
+
if idx not in kept_original_indices:
|
| 221 |
+
continue
|
| 222 |
+
methods = detector.row_anomaly_map.get(idx, [])
|
| 223 |
+
if run_anomaly_detection and not methods:
|
| 224 |
+
continue
|
| 225 |
+
record = {field: row.get(field, "") for field in REQUESTED_FIELDS}
|
| 226 |
+
record.update({
|
| 227 |
+
"has_anomaly": "YES" if methods else "NO",
|
| 228 |
+
"anomaly_count": len(methods),
|
| 229 |
+
"anomaly_methods": "; ".join(methods),
|
| 230 |
+
})
|
| 231 |
+
full_records.append(record)
|
| 232 |
+
|
| 233 |
+
# Inline response cap. Rank by anomaly_count desc so the most
|
| 234 |
+
# interesting rows survive truncation. max_results == 0 disables.
|
| 235 |
+
if max_results and max_results > 0 and len(full_records) > max_results:
|
| 236 |
+
response_records = sorted(
|
| 237 |
+
full_records,
|
| 238 |
+
key=lambda r: r.get("anomaly_count", 0),
|
| 239 |
+
reverse=True,
|
| 240 |
+
)[:max_results]
|
| 241 |
+
truncated = True
|
| 242 |
+
else:
|
| 243 |
+
response_records = full_records
|
| 244 |
+
truncated = False
|
| 245 |
+
|
| 246 |
+
return {
|
| 247 |
+
"filename": original_filename,
|
| 248 |
+
"statistics": (
|
| 249 |
+
stats.to_dict()
|
| 250 |
+
if run_anomaly_detection
|
| 251 |
+
else {"total_rows": len(detector.data)}
|
| 252 |
+
),
|
| 253 |
+
"results": response_records,
|
| 254 |
+
"total_results": len(full_records),
|
| 255 |
+
"results_truncated": truncated,
|
| 256 |
+
"results_cap": max_results,
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
|
| 260 |
if __name__ == "__main__":
|
| 261 |
import uvicorn
|
| 262 |
uvicorn.run(app, host="0.0.0.0", port=8000)
|
telecom_anomaly/core/detector.py
CHANGED
|
@@ -23,6 +23,7 @@ from telecom_anomaly.detection.sms import SMSDetector
|
|
| 23 |
from telecom_anomaly.detection.location import LocationDetector
|
| 24 |
# from telecom_anomaly.detection.imei import IMEIDetector
|
| 25 |
from telecom_anomaly.detection.time_patterns import TimePatternDetector
|
|
|
|
| 26 |
|
| 27 |
logger = logging.getLogger(__name__)
|
| 28 |
|
|
@@ -54,15 +55,20 @@ class TelecomAnomalyDetector:
|
|
| 54 |
|
| 55 |
self.validator = DataValidator()
|
| 56 |
|
| 57 |
-
#
|
| 58 |
-
|
|
|
|
| 59 |
DurationDetector(self.config),
|
| 60 |
FrequencyDetector(self.config),
|
| 61 |
SMSDetector(self.config),
|
| 62 |
LocationDetector(self.config),
|
| 63 |
# IMEIDetector(self.config),
|
| 64 |
-
TimePatternDetector(self.config)
|
| 65 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
# Data storage
|
| 68 |
self.data: List[Dict[str, Any]] = []
|
|
@@ -78,7 +84,11 @@ class TelecomAnomalyDetector:
|
|
| 78 |
lambda: {'total': 0, 'methods': set()}
|
| 79 |
)
|
| 80 |
|
| 81 |
-
logger.info(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
def load_data(self, file_paths: List[str], filter_movements: bool = False) -> 'TelecomAnomalyDetector':
|
| 84 |
"""Load data from files"""
|
|
@@ -176,15 +186,26 @@ class TelecomAnomalyDetector:
|
|
| 176 |
|
| 177 |
logger.info(f"Geographic filtering complete: {original_count} -> {len(self.data)} records")
|
| 178 |
|
| 179 |
-
def detect_anomalies(self) -> List[AnomalyResult]:
|
| 180 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
start_time = datetime.now()
|
| 182 |
-
logger.info(
|
| 183 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
all_anomalies = []
|
| 185 |
-
|
| 186 |
-
# Run each detector
|
| 187 |
-
for detector in self.detectors:
|
| 188 |
try:
|
| 189 |
detector_anomalies = detector.detect(self.data)
|
| 190 |
all_anomalies.extend(detector_anomalies)
|
|
|
|
| 23 |
from telecom_anomaly.detection.location import LocationDetector
|
| 24 |
# from telecom_anomaly.detection.imei import IMEIDetector
|
| 25 |
from telecom_anomaly.detection.time_patterns import TimePatternDetector
|
| 26 |
+
from telecom_anomaly.detection.isolation_forest import IsolationForestDetector
|
| 27 |
|
| 28 |
logger = logging.getLogger(__name__)
|
| 29 |
|
|
|
|
| 55 |
|
| 56 |
self.validator = DataValidator()
|
| 57 |
|
| 58 |
+
# Rule-based detectors always run. Model-based detectors are
|
| 59 |
+
# opt-in via detect_anomalies(use_model=True).
|
| 60 |
+
self.rule_detectors = [
|
| 61 |
DurationDetector(self.config),
|
| 62 |
FrequencyDetector(self.config),
|
| 63 |
SMSDetector(self.config),
|
| 64 |
LocationDetector(self.config),
|
| 65 |
# IMEIDetector(self.config),
|
| 66 |
+
TimePatternDetector(self.config),
|
| 67 |
]
|
| 68 |
+
self.model_detectors = [
|
| 69 |
+
IsolationForestDetector(self.config),
|
| 70 |
+
]
|
| 71 |
+
self.detectors = self.rule_detectors # back-compat alias
|
| 72 |
|
| 73 |
# Data storage
|
| 74 |
self.data: List[Dict[str, Any]] = []
|
|
|
|
| 84 |
lambda: {'total': 0, 'methods': set()}
|
| 85 |
)
|
| 86 |
|
| 87 |
+
logger.info(
|
| 88 |
+
f"Initialized TelecomAnomalyDetector with "
|
| 89 |
+
f"{len(self.rule_detectors)} rule detectors and "
|
| 90 |
+
f"{len(self.model_detectors)} model detectors (opt-in)"
|
| 91 |
+
)
|
| 92 |
|
| 93 |
def load_data(self, file_paths: List[str], filter_movements: bool = False) -> 'TelecomAnomalyDetector':
|
| 94 |
"""Load data from files"""
|
|
|
|
| 186 |
|
| 187 |
logger.info(f"Geographic filtering complete: {original_count} -> {len(self.data)} records")
|
| 188 |
|
| 189 |
+
def detect_anomalies(self, use_model: bool = False) -> List[AnomalyResult]:
|
| 190 |
+
"""
|
| 191 |
+
Run anomaly detectors over the loaded data.
|
| 192 |
+
|
| 193 |
+
Args:
|
| 194 |
+
use_model: When True, also runs model-based detectors
|
| 195 |
+
(Isolation Forest). Default False keeps behaviour
|
| 196 |
+
identical to the original rule-only pipeline.
|
| 197 |
+
"""
|
| 198 |
start_time = datetime.now()
|
| 199 |
+
logger.info(
|
| 200 |
+
f"Starting anomaly detection (use_model={use_model})"
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
detectors_to_run = list(self.rule_detectors)
|
| 204 |
+
if use_model:
|
| 205 |
+
detectors_to_run.extend(self.model_detectors)
|
| 206 |
+
|
| 207 |
all_anomalies = []
|
| 208 |
+
for detector in detectors_to_run:
|
|
|
|
|
|
|
| 209 |
try:
|
| 210 |
detector_anomalies = detector.detect(self.data)
|
| 211 |
all_anomalies.extend(detector_anomalies)
|
telecom_anomaly/detection/isolation_forest.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Isolation Forest anomaly detector.
|
| 3 |
+
|
| 4 |
+
Acts as a 6th detector alongside the rule-based ones. The rule detectors
|
| 5 |
+
each check a single column against a hard threshold; this one looks for
|
| 6 |
+
records that are unusual in their *combination* of features (e.g. a short
|
| 7 |
+
call at an out-of-region tower at 03:00 from a rarely-seen IMEI, where no
|
| 8 |
+
single rule fires).
|
| 9 |
+
|
| 10 |
+
Trained per-msisdn so each subscriber's "normal" is defined relative to
|
| 11 |
+
their own history. Subscribers below `min_samples_for_model` rows are
|
| 12 |
+
skipped to avoid overfitting noise.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from collections import Counter, defaultdict
|
| 16 |
+
from typing import List, Dict, Any
|
| 17 |
+
|
| 18 |
+
import numpy as np
|
| 19 |
+
from sklearn.ensemble import IsolationForest
|
| 20 |
+
|
| 21 |
+
from telecom_anomaly.detection.base import BaseDetector
|
| 22 |
+
from telecom_anomaly.core.models import AnomalyResult, TelecomConfig
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class IsolationForestDetector(BaseDetector):
|
| 26 |
+
"""Multivariate unsupervised anomaly detector."""
|
| 27 |
+
|
| 28 |
+
FEATURE_COLUMNS = [
|
| 29 |
+
'duration',
|
| 30 |
+
'hour_of_day',
|
| 31 |
+
'is_night',
|
| 32 |
+
'is_sms',
|
| 33 |
+
'direction_outgoing',
|
| 34 |
+
'latitude',
|
| 35 |
+
'longitude',
|
| 36 |
+
'region_freq',
|
| 37 |
+
'district_freq',
|
| 38 |
+
]
|
| 39 |
+
|
| 40 |
+
def __init__(self, config: TelecomConfig):
|
| 41 |
+
super().__init__(config)
|
| 42 |
+
self.contamination = getattr(config, 'iforest_contamination', 'auto')
|
| 43 |
+
self.min_samples = getattr(config, 'iforest_min_samples', 50)
|
| 44 |
+
self.random_state = getattr(config, 'iforest_random_state', 42)
|
| 45 |
+
self.n_estimators = getattr(config, 'iforest_n_estimators', 100)
|
| 46 |
+
|
| 47 |
+
def detect(self, data: List[Dict], **kwargs) -> List[AnomalyResult]:
|
| 48 |
+
if not data:
|
| 49 |
+
return []
|
| 50 |
+
|
| 51 |
+
per_subscriber: Dict[str, List[int]] = defaultdict(list)
|
| 52 |
+
for idx, row in enumerate(data):
|
| 53 |
+
msisdn = row.get('msisdn', '')
|
| 54 |
+
if msisdn:
|
| 55 |
+
per_subscriber[msisdn].append(idx)
|
| 56 |
+
|
| 57 |
+
anomalies: List[AnomalyResult] = []
|
| 58 |
+
for msisdn, indices in per_subscriber.items():
|
| 59 |
+
if len(indices) < self.min_samples:
|
| 60 |
+
continue
|
| 61 |
+
anomalies.extend(self._detect_for_subscriber(msisdn, indices, data))
|
| 62 |
+
|
| 63 |
+
return anomalies
|
| 64 |
+
|
| 65 |
+
def _detect_for_subscriber(self, msisdn: str, indices: List[int],
|
| 66 |
+
data: List[Dict]) -> List[AnomalyResult]:
|
| 67 |
+
rows = [data[i] for i in indices]
|
| 68 |
+
features = self._build_features(rows)
|
| 69 |
+
|
| 70 |
+
if features.shape[0] < self.min_samples:
|
| 71 |
+
return []
|
| 72 |
+
|
| 73 |
+
model = IsolationForest(
|
| 74 |
+
n_estimators=self.n_estimators,
|
| 75 |
+
contamination=self.contamination,
|
| 76 |
+
random_state=self.random_state,
|
| 77 |
+
n_jobs=1,
|
| 78 |
+
)
|
| 79 |
+
model.fit(features)
|
| 80 |
+
|
| 81 |
+
predictions = model.predict(features) # -1 = anomaly, 1 = normal
|
| 82 |
+
scores = model.score_samples(features) # lower = more anomalous
|
| 83 |
+
|
| 84 |
+
# Map raw scores to a 0..1 confidence using min-max within this
|
| 85 |
+
# subscriber. Scores are negative; the minimum score gets the
|
| 86 |
+
# highest confidence. This keeps the confidence field comparable
|
| 87 |
+
# to the rule detectors' 0.75-0.95 range.
|
| 88 |
+
s_min, s_max = float(scores.min()), float(scores.max())
|
| 89 |
+
denom = (s_max - s_min) or 1.0
|
| 90 |
+
|
| 91 |
+
results: List[AnomalyResult] = []
|
| 92 |
+
for local_idx, global_idx in enumerate(indices):
|
| 93 |
+
if predictions[local_idx] != -1:
|
| 94 |
+
continue
|
| 95 |
+
raw_score = float(scores[local_idx])
|
| 96 |
+
normalized = (s_max - raw_score) / denom # 0..1
|
| 97 |
+
confidence = round(0.6 + 0.35 * normalized, 4) # 0.60..0.95
|
| 98 |
+
results.append(self._create_anomaly(
|
| 99 |
+
row_index=global_idx,
|
| 100 |
+
column_name='multivariate',
|
| 101 |
+
value=raw_score,
|
| 102 |
+
method='iforest',
|
| 103 |
+
score=raw_score,
|
| 104 |
+
threshold=float(model.offset_),
|
| 105 |
+
confidence=confidence,
|
| 106 |
+
msisdn=msisdn,
|
| 107 |
+
metadata={
|
| 108 |
+
'features': self.FEATURE_COLUMNS,
|
| 109 |
+
'subscriber_samples': len(indices),
|
| 110 |
+
},
|
| 111 |
+
))
|
| 112 |
+
return results
|
| 113 |
+
|
| 114 |
+
def _build_features(self, rows: List[Dict[str, Any]]) -> np.ndarray:
|
| 115 |
+
region_freq = self._frequency_map(rows, 'region')
|
| 116 |
+
district_freq = self._frequency_map(rows, 'district')
|
| 117 |
+
|
| 118 |
+
lat_values = [r.get('latitude') for r in rows if r.get('latitude') is not None]
|
| 119 |
+
lon_values = [r.get('longitude') for r in rows if r.get('longitude') is not None]
|
| 120 |
+
lat_default = float(np.median(lat_values)) if lat_values else 0.0
|
| 121 |
+
lon_default = float(np.median(lon_values)) if lon_values else 0.0
|
| 122 |
+
|
| 123 |
+
features = []
|
| 124 |
+
for row in rows:
|
| 125 |
+
duration = float(row.get('duration') or 0.0)
|
| 126 |
+
hour = self._hour_of_day(row.get('event_time', ''))
|
| 127 |
+
is_night = 1 if row.get('is_night') else 0
|
| 128 |
+
is_sms = 1 if (str(row.get('usage_type') or '')).upper() == 'SMS' else 0
|
| 129 |
+
direction = (str(row.get('call_direction') or '')).upper()
|
| 130 |
+
direction_outgoing = 1 if ('OUT' in direction or 'MO' in direction) else 0
|
| 131 |
+
lat = row.get('latitude')
|
| 132 |
+
lon = row.get('longitude')
|
| 133 |
+
lat = float(lat) if lat is not None else lat_default
|
| 134 |
+
lon = float(lon) if lon is not None else lon_default
|
| 135 |
+
region = str(row.get('region') or '')
|
| 136 |
+
district = str(row.get('district') or '')
|
| 137 |
+
|
| 138 |
+
features.append([
|
| 139 |
+
duration,
|
| 140 |
+
hour,
|
| 141 |
+
is_night,
|
| 142 |
+
is_sms,
|
| 143 |
+
direction_outgoing,
|
| 144 |
+
lat,
|
| 145 |
+
lon,
|
| 146 |
+
region_freq.get(region, 0.0),
|
| 147 |
+
district_freq.get(district, 0.0),
|
| 148 |
+
])
|
| 149 |
+
|
| 150 |
+
return np.asarray(features, dtype=np.float64)
|
| 151 |
+
|
| 152 |
+
@staticmethod
|
| 153 |
+
def _frequency_map(rows: List[Dict], column: str) -> Dict[str, float]:
|
| 154 |
+
"""
|
| 155 |
+
Frequency-encode a categorical column. Rare values get small
|
| 156 |
+
numbers; common values get large numbers. Rarity itself is the
|
| 157 |
+
signal Isolation Forest can exploit.
|
| 158 |
+
"""
|
| 159 |
+
counts = Counter(str(row.get(column) or '') for row in rows)
|
| 160 |
+
total = sum(counts.values()) or 1
|
| 161 |
+
return {value: count / total for value, count in counts.items()}
|
| 162 |
+
|
| 163 |
+
@staticmethod
|
| 164 |
+
def _hour_of_day(time_str) -> int:
|
| 165 |
+
try:
|
| 166 |
+
from datetime import time as _time
|
| 167 |
+
if isinstance(time_str, _time):
|
| 168 |
+
return int(time_str.hour)
|
| 169 |
+
return int(str(time_str).split(':')[0])
|
| 170 |
+
except (ValueError, IndexError, AttributeError):
|
| 171 |
+
return 0
|
telecom_anomaly/io/pdf_exporter.py
CHANGED
|
@@ -43,30 +43,30 @@ class PDFExporter:
|
|
| 43 |
|
| 44 |
# Detailed Table
|
| 45 |
pdf.set_font("helvetica", "B", 12)
|
| 46 |
-
pdf.cell(0, 8, " 2. Detailed Anomalous Records
|
| 47 |
pdf.ln(3)
|
| 48 |
-
|
| 49 |
-
#
|
|
|
|
|
|
|
| 50 |
fields = [
|
| 51 |
-
("Date", "event_date",
|
| 52 |
-
("Time", "event_time",
|
| 53 |
-
("Dir", "call_direction",
|
| 54 |
-
("
|
| 55 |
-
("
|
| 56 |
-
("
|
| 57 |
-
("
|
| 58 |
-
("
|
| 59 |
-
("
|
| 60 |
-
("
|
| 61 |
-
("
|
| 62 |
-
("
|
| 63 |
-
("City", "city", 15),
|
| 64 |
-
("MSISDN", "msisdn", 22),
|
| 65 |
("Long", "longitude", 14),
|
| 66 |
("Lat", "latitude", 14),
|
| 67 |
-
("Azi", "azimuth",
|
| 68 |
-
("Anom", "has_anomaly",
|
| 69 |
-
("Cnt", "anomaly_count",
|
| 70 |
("Methods", "anomaly_methods", 0) # 0 means fill remaining width
|
| 71 |
]
|
| 72 |
|
|
|
|
| 43 |
|
| 44 |
# Detailed Table
|
| 45 |
pdf.set_font("helvetica", "B", 12)
|
| 46 |
+
pdf.cell(0, 8, " 2. Detailed Anomalous Records", ln=True, fill=True)
|
| 47 |
pdf.ln(3)
|
| 48 |
+
|
| 49 |
+
# Columns mirror the 14 common-input fields plus the 3 anomaly
|
| 50 |
+
# columns. Widths sum to <277mm (Landscape A4 minus margins);
|
| 51 |
+
# the last column is auto-sized to fill remaining width.
|
| 52 |
fields = [
|
| 53 |
+
("Date", "event_date", 18),
|
| 54 |
+
("Time", "event_time", 16),
|
| 55 |
+
("Dir", "call_direction", 14),
|
| 56 |
+
("Calling No", "calling_no", 24),
|
| 57 |
+
("Called No", "called_no", 24),
|
| 58 |
+
("Type", "usage_type", 12),
|
| 59 |
+
("Dur", "duration", 10),
|
| 60 |
+
("IMEI", "imei", 26),
|
| 61 |
+
("Loc-ID", "location_id", 18),
|
| 62 |
+
("Region", "region", 16),
|
| 63 |
+
("District", "district", 16),
|
| 64 |
+
("City", "city", 16),
|
|
|
|
|
|
|
| 65 |
("Long", "longitude", 14),
|
| 66 |
("Lat", "latitude", 14),
|
| 67 |
+
("Azi", "azimuth", 10),
|
| 68 |
+
("Anom", "has_anomaly", 10),
|
| 69 |
+
("Cnt", "anomaly_count", 8),
|
| 70 |
("Methods", "anomaly_methods", 0) # 0 means fill remaining width
|
| 71 |
]
|
| 72 |
|
telecom_anomaly/io/reader.py
CHANGED
|
@@ -65,9 +65,12 @@ class TelecomDataReader:
|
|
| 65 |
records = []
|
| 66 |
original_data = []
|
| 67 |
|
| 68 |
-
#
|
| 69 |
-
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
# Convert DataFrame to records (list of dicts)
|
| 73 |
from datetime import date, time
|
|
@@ -96,34 +99,36 @@ class TelecomDataReader:
|
|
| 96 |
logger.info(f"Processed {len(records)} valid records from {len(original_data)} DataFrame rows")
|
| 97 |
return records, original_data
|
| 98 |
|
| 99 |
-
def _read_single_file(self, file_path: str, encoding: str,
|
| 100 |
base_idx: int) -> tuple:
|
| 101 |
"""Read and validate a single CSV file"""
|
| 102 |
records = []
|
| 103 |
original_data = []
|
| 104 |
-
|
| 105 |
with open(file_path, 'r', encoding=encoding) as f:
|
| 106 |
reader = csv.reader(f)
|
| 107 |
-
|
| 108 |
-
#
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
| 115 |
for row_num, row in enumerate(reader, start=1):
|
| 116 |
if not row or all(not cell for cell in row):
|
| 117 |
continue
|
| 118 |
-
|
| 119 |
# Ensure row length matches headers
|
| 120 |
-
if len(row) < len(
|
| 121 |
-
row.extend([''] * (len(
|
| 122 |
-
elif len(row) > len(
|
| 123 |
-
row = row[:len(
|
| 124 |
-
|
| 125 |
# Create row dictionary
|
| 126 |
-
row_dict = dict(zip(
|
| 127 |
|
| 128 |
# Store original
|
| 129 |
original_copy = row_dict.copy()
|
|
@@ -148,11 +153,21 @@ class TelecomDataReader:
|
|
| 148 |
|
| 149 |
# Add metadata
|
| 150 |
cleaned['_original_idx'] = original_idx
|
| 151 |
-
|
| 152 |
-
#
|
|
|
|
|
|
|
|
|
|
| 153 |
msisdn = cleaned.get('msisdn', '')
|
| 154 |
if not self.validator.validate_msisdn(msisdn):
|
| 155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
|
| 157 |
# Validate duration
|
| 158 |
cleaned['duration'] = self.validator.validate_duration(cleaned.get('duration'))
|
|
|
|
| 65 |
records = []
|
| 66 |
original_data = []
|
| 67 |
|
| 68 |
+
# Always extend the known-headers set with this DataFrame's
|
| 69 |
+
# columns so multi-call usage doesn't silently misalign columns.
|
| 70 |
+
for col in df.columns:
|
| 71 |
+
name = str(col).strip()
|
| 72 |
+
if name not in self.headers:
|
| 73 |
+
self.headers.append(name)
|
| 74 |
|
| 75 |
# Convert DataFrame to records (list of dicts)
|
| 76 |
from datetime import date, time
|
|
|
|
| 99 |
logger.info(f"Processed {len(records)} valid records from {len(original_data)} DataFrame rows")
|
| 100 |
return records, original_data
|
| 101 |
|
| 102 |
+
def _read_single_file(self, file_path: str, encoding: str,
|
| 103 |
base_idx: int) -> tuple:
|
| 104 |
"""Read and validate a single CSV file"""
|
| 105 |
records = []
|
| 106 |
original_data = []
|
| 107 |
+
|
| 108 |
with open(file_path, 'r', encoding=encoding) as f:
|
| 109 |
reader = csv.reader(f)
|
| 110 |
+
|
| 111 |
+
# Headers must be read fresh for every file: different sources
|
| 112 |
+
# ship slightly different column sets (e.g. some have msisdn,
|
| 113 |
+
# others have first_site_name/tbl_dt instead). The reader keeps
|
| 114 |
+
# the union of headers seen so downstream code can still iterate.
|
| 115 |
+
file_headers = [h.strip() for h in next(reader)]
|
| 116 |
+
for h in file_headers:
|
| 117 |
+
if h not in self.headers:
|
| 118 |
+
self.headers.append(h)
|
| 119 |
+
|
| 120 |
for row_num, row in enumerate(reader, start=1):
|
| 121 |
if not row or all(not cell for cell in row):
|
| 122 |
continue
|
| 123 |
+
|
| 124 |
# Ensure row length matches headers
|
| 125 |
+
if len(row) < len(file_headers):
|
| 126 |
+
row.extend([''] * (len(file_headers) - len(row)))
|
| 127 |
+
elif len(row) > len(file_headers):
|
| 128 |
+
row = row[:len(file_headers)]
|
| 129 |
+
|
| 130 |
# Create row dictionary
|
| 131 |
+
row_dict = dict(zip(file_headers, row))
|
| 132 |
|
| 133 |
# Store original
|
| 134 |
original_copy = row_dict.copy()
|
|
|
|
| 153 |
|
| 154 |
# Add metadata
|
| 155 |
cleaned['_original_idx'] = original_idx
|
| 156 |
+
|
| 157 |
+
# MSISDN: prefer an explicit column, otherwise derive from the leg
|
| 158 |
+
# numbers using call_direction. Files (or DataFrames) where msisdn
|
| 159 |
+
# is absent must still produce a subscriber id so the per-subscriber
|
| 160 |
+
# detectors and exporters keep working.
|
| 161 |
msisdn = cleaned.get('msisdn', '')
|
| 162 |
if not self.validator.validate_msisdn(msisdn):
|
| 163 |
+
msisdn = self.validator.derive_msisdn(
|
| 164 |
+
cleaned.get('call_direction', ''),
|
| 165 |
+
cleaned.get('calling_no', ''),
|
| 166 |
+
cleaned.get('called_no', ''),
|
| 167 |
+
)
|
| 168 |
+
if not msisdn:
|
| 169 |
+
return None
|
| 170 |
+
cleaned['msisdn'] = msisdn
|
| 171 |
|
| 172 |
# Validate duration
|
| 173 |
cleaned['duration'] = self.validator.validate_duration(cleaned.get('duration'))
|
telecom_anomaly/main.py
CHANGED
|
@@ -34,9 +34,20 @@ def main():
|
|
| 34 |
config.output_dir = str(OUTPUT_DIR)
|
| 35 |
config.save_pdf_dir = str(SAVE_PDF_DIR)
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
# Get files
|
| 38 |
-
if
|
| 39 |
-
file_paths =
|
| 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:")
|
|
@@ -74,22 +85,23 @@ def main():
|
|
| 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",
|
| 91 |
-
"calling_no", "called_no", "usage_type", "duration", "imei",
|
| 92 |
-
"location_id", "region", "district", "city",
|
| 93 |
"longitude", "latitude", "azimuth"
|
| 94 |
]
|
| 95 |
for idx, row in enumerate(detector.original_data):
|
|
|
|
| 34 |
config.output_dir = str(OUTPUT_DIR)
|
| 35 |
config.save_pdf_dir = str(SAVE_PDF_DIR)
|
| 36 |
|
| 37 |
+
# Parse the optional --model / --no-model flag out of argv. Anything
|
| 38 |
+
# else on the command line is treated as a CSV path (existing behaviour).
|
| 39 |
+
cli_args = sys.argv[1:]
|
| 40 |
+
use_model = False
|
| 41 |
+
if '--model' in cli_args:
|
| 42 |
+
cli_args.remove('--model')
|
| 43 |
+
use_model = True
|
| 44 |
+
if '--no-model' in cli_args:
|
| 45 |
+
cli_args.remove('--no-model')
|
| 46 |
+
use_model = False
|
| 47 |
+
|
| 48 |
# Get files
|
| 49 |
+
if cli_args:
|
| 50 |
+
file_paths = cli_args
|
| 51 |
else:
|
| 52 |
print(f"\nEnter CSV file paths (or place files in {INPUT_DIR}):")
|
| 53 |
print("Press Enter to scan input directory, or type paths:")
|
|
|
|
| 85 |
return
|
| 86 |
|
| 87 |
print(f"\n📊 Processing {len(valid_files)} file(s)...")
|
| 88 |
+
print(f" Model-based detection: {'ON (Isolation Forest)' if use_model else 'OFF'}")
|
| 89 |
+
|
| 90 |
# Run detection
|
| 91 |
try:
|
| 92 |
detector = TelecomAnomalyDetector(config)
|
| 93 |
detector.load_data(valid_files)
|
| 94 |
+
anomalies = detector.detect_anomalies(use_model=use_model)
|
| 95 |
+
|
| 96 |
# Export results
|
| 97 |
outputs = detector.export_results()
|
| 98 |
+
|
| 99 |
# Prepare results for PDF (enriched records)
|
| 100 |
pdf_results = []
|
| 101 |
requested_fields = [
|
| 102 |
+
"event_date", "event_time", "call_direction",
|
| 103 |
+
"calling_no", "called_no", "usage_type", "duration", "imei",
|
| 104 |
+
"location_id", "region", "district", "city",
|
| 105 |
"longitude", "latitude", "azimuth"
|
| 106 |
]
|
| 107 |
for idx, row in enumerate(detector.original_data):
|
telecom_anomaly/validation/validator.py
CHANGED
|
@@ -23,6 +23,32 @@ class DataValidator:
|
|
| 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:
|
|
|
|
| 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 derive_msisdn(call_direction: str, calling_no: str, called_no: str) -> str:
|
| 29 |
+
"""
|
| 30 |
+
Derive the subscriber's MSISDN from the leg numbers when the CSV
|
| 31 |
+
does not provide an explicit msisdn column.
|
| 32 |
+
|
| 33 |
+
Outgoing legs -> subscriber is the caller; Incoming legs -> the callee.
|
| 34 |
+
Falls back to whichever side is a valid Ghana MSISDN.
|
| 35 |
+
"""
|
| 36 |
+
direction = (call_direction or '').upper()
|
| 37 |
+
calling = (str(calling_no).strip() if calling_no is not None else '')
|
| 38 |
+
called = (str(called_no).strip() if called_no is not None else '')
|
| 39 |
+
|
| 40 |
+
if 'OUT' in direction or 'MO' in direction:
|
| 41 |
+
primary, secondary = calling, called
|
| 42 |
+
elif 'IN' in direction or 'MT' in direction:
|
| 43 |
+
primary, secondary = called, calling
|
| 44 |
+
else:
|
| 45 |
+
primary, secondary = calling, called
|
| 46 |
+
|
| 47 |
+
if DataValidator.validate_msisdn(primary):
|
| 48 |
+
return primary
|
| 49 |
+
if DataValidator.validate_msisdn(secondary):
|
| 50 |
+
return secondary
|
| 51 |
+
return ''
|
| 52 |
|
| 53 |
@staticmethod
|
| 54 |
def validate_duration(duration: Any) -> float:
|