winsweb commited on
Commit
39be5b4
·
1 Parent(s): d229285

adding model

Browse files
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 telecom_anomaly_detection.
59
- All intermediate CSVs are removed before the response returns.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- raw_csv = None
74
- interim_csv = None
75
- final_csv = None
76
 
77
  try:
78
- config = TelecomConfig()
79
- config.service_numbers = SERVICE_NUMBERS
80
- config.output_dir = None
81
- config.save_pdf_dir = None
82
- config.location_cluster_km = cluster_km
83
-
84
- detector = TelecomAnomalyDetector(config)
85
-
86
- if is_pdf:
87
- logger.info(f"Processing PDF: {file.filename}")
88
-
89
- raw_csv = extract_pdf_to_csv(tmp_path, base_name)
90
- if not raw_csv:
91
- raise HTTPException(status_code=500, detail="Failed to extract tables from PDF")
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 stays stateless
159
- for path in (
160
- raw_csv,
161
- interim_csv,
162
- final_csv,
163
- os.path.join(PROCESS_OUTPUT_DIR, f"{base_name}_customer_info.csv"),
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
- # Initialize detectors
58
- self.detectors = [
 
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(f"Initialized TelecomAnomalyDetector with {len(self.detectors)} detectors")
 
 
 
 
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
- """Run all anomaly detectors"""
 
 
 
 
 
 
 
181
  start_time = datetime.now()
182
- logger.info("Starting anomaly detection")
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 (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
 
 
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
- # 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
  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
- # Read headers
109
- if not self.headers:
110
- header_row = next(reader)
111
- self.headers = [h.strip() for h in header_row]
112
- else:
113
- next(reader) # Skip header if already read
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(self.headers):
121
- row.extend([''] * (len(self.headers) - len(row)))
122
- elif len(row) > len(self.headers):
123
- row = row[:len(self.headers)]
124
-
125
  # Create row dictionary
126
- row_dict = dict(zip(self.headers, row))
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
- # Validate MSISDN
 
 
 
153
  msisdn = cleaned.get('msisdn', '')
154
  if not self.validator.validate_msisdn(msisdn):
155
- return None
 
 
 
 
 
 
 
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 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:")
@@ -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", "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):
 
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: