winsweb commited on
Commit
892b85b
Β·
1 Parent(s): a124d29

fix AI model

Browse files
check_ai_only.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Verify AI-only mode (run_anomaly_detection=False, use_model=True):
2
+ - detect_anomalies should run only the iforest, no rule detectors.
3
+ - Every row in the response should carry has_anomaly=YES with a method
4
+ starting "AI:".
5
+ """
6
+ import sys
7
+ from pathlib import Path
8
+ sys.path.insert(0, str(Path(__file__).parent))
9
+
10
+ from telecom_anomaly.core.detector import TelecomAnomalyDetector
11
+ from telecom_anomaly.core.models import TelecomConfig
12
+
13
+ CSV = (
14
+ r"C:\my_project\work\GH_Police\main project\backend"
15
+ r"\telecom_anomaly_detection\data\strictly_cleaned_data"
16
+ r"\591296805 calls (1)_strictly_cleaned.csv"
17
+ )
18
+
19
+ cfg = TelecomConfig()
20
+ det = TelecomAnomalyDetector(cfg)
21
+ det.load_data([CSV])
22
+
23
+ # AI-only mode
24
+ det.detect_anomalies(use_model=True, run_rules=False)
25
+ stats = det.get_statistics()
26
+
27
+ print("=== AI-only mode (run_rules=False, use_model=True) ===")
28
+ print(f"Total anomalies: {stats.total_anomalies_detected}")
29
+ print(f"Rows with anomalies: {stats.rows_with_anomalies}")
30
+ print(f"anomaly_types (should be AI: only): {stats.anomaly_types}")
31
+
32
+ # Confirm no rule detector fired
33
+ rule_method_seen = any(
34
+ not m.startswith('AI:')
35
+ for methods in det.row_anomaly_map.values()
36
+ for m in methods
37
+ )
38
+ print(f"Any non-AI method fired? {rule_method_seen}")
39
+
40
+ # Show that 5 sample rows would land with has_anomaly=YES in the response
41
+ print("\nSample of 5 flagged rows (these would have has_anomaly=YES):")
42
+ shown = 0
43
+ for orig_idx, methods in det.row_anomaly_map.items():
44
+ if shown >= 5:
45
+ break
46
+ row = det.original_data[orig_idx]
47
+ has_anomaly = "YES" if methods else "NO"
48
+ print(f" row {orig_idx}: has_anomaly={has_anomaly} methods={methods}")
49
+ shown += 1
check_model_summary.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Print the full model_summary block the API will emit at the end of
2
+ every detection that runs the iforest. Verifies every key the planning
3
+ doc promises is populated and well-formed.
4
+ """
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+ sys.path.insert(0, str(Path(__file__).parent))
9
+
10
+ from telecom_anomaly.core.detector import TelecomAnomalyDetector
11
+ from telecom_anomaly.core.models import TelecomConfig
12
+
13
+ CSV = (
14
+ r"C:\my_project\work\GH_Police\main project\backend"
15
+ r"\telecom_anomaly_detection\data\strictly_cleaned_data"
16
+ r"\591296805 calls (1)_strictly_cleaned.csv"
17
+ )
18
+
19
+ cfg = TelecomConfig()
20
+ det = TelecomAnomalyDetector(cfg)
21
+ det.load_data([CSV])
22
+
23
+ # Cover all three modes.
24
+ for label, kwargs in [
25
+ ("Rules-only", {"use_model": False, "run_rules": True}),
26
+ ("AI-only", {"use_model": True, "run_rules": False}),
27
+ ("Both", {"use_model": True, "run_rules": True}),
28
+ ]:
29
+ # Reset tracking between modes.
30
+ det.anomalies = []
31
+ det.rows_with_anomalies = set()
32
+ det.row_anomaly_map.clear()
33
+ det.row_anomaly_reasons.clear()
34
+ det.row_anomaly_attribution.clear()
35
+ det.subscriber_anomalies.clear()
36
+
37
+ det.detect_anomalies(**kwargs)
38
+ stats = det.get_statistics()
39
+ print(f"\n=== {label} ({kwargs}) ===")
40
+ print(f"total_anomalies_detected: {stats.total_anomalies_detected}")
41
+ print(f"anomaly_types : {stats.anomaly_types}")
42
+ print(f"model_feature_drivers : {stats.model_feature_drivers}")
43
+ print("model_summary:")
44
+ print(json.dumps(stats.model_summary, indent=2, default=str))
telecom_anomaly/api.py CHANGED
@@ -209,8 +209,18 @@ def _run_analysis(
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())
@@ -220,7 +230,11 @@ def _run_analysis(
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
  reasons = detector.row_anomaly_reasons.get(idx, [])
226
  attributions = detector.row_anomaly_attribution.get(idx, [])
 
209
  logger.info(f"Processing CSV: {original_filename}")
210
  detector.load_data([tmp_path], filter_movements=filter_movements)
211
 
212
+ # Detection runs when EITHER the rule-based pipeline ("Anomaly
213
+ # Detection") OR the model pipeline ("Use AI Model") was requested.
214
+ # `run_rules=run_anomaly_detection` makes the rule detectors honour
215
+ # the rule toggle independently, so the four combinations work as
216
+ # expected: rules-only, model-only, both, or neither (clustering /
217
+ # preview only).
218
+ any_detection = run_anomaly_detection or use_model
219
+ if any_detection:
220
+ detector.detect_anomalies(
221
+ use_model=use_model,
222
+ run_rules=run_anomaly_detection,
223
+ )
224
 
225
  stats = detector.get_statistics()
226
  kept_original_indices = set(detector.data_indices.values())
 
230
  if idx not in kept_original_indices:
231
  continue
232
  methods = detector.row_anomaly_map.get(idx, [])
233
+ # When any detection ran, only flagged rows make it into the
234
+ # response. When no detection ran (clustering-only), every kept
235
+ # row goes through with has_anomaly=NO so the caller still sees
236
+ # the clustered dataset.
237
+ if any_detection and not methods:
238
  continue
239
  reasons = detector.row_anomaly_reasons.get(idx, [])
240
  attributions = detector.row_anomaly_attribution.get(idx, [])
telecom_anomaly/core/detector.py CHANGED
@@ -198,21 +198,32 @@ class TelecomAnomalyDetector:
198
 
199
  logger.info(f"Geographic filtering complete: {original_count} -> {len(self.data)} records")
200
 
201
- def detect_anomalies(self, use_model: bool = False) -> List[AnomalyResult]:
 
 
 
 
202
  """
203
  Run anomaly detectors over the loaded data.
204
 
205
  Args:
206
- use_model: When True, also runs model-based detectors
207
- (Isolation Forest). Default False keeps behaviour
208
- identical to the original rule-only pipeline.
 
 
 
 
209
  """
210
  start_time = datetime.now()
211
  logger.info(
212
- f"Starting anomaly detection (use_model={use_model})"
 
213
  )
214
 
215
- detectors_to_run = list(self.rule_detectors)
 
 
216
  if use_model:
217
  detectors_to_run.extend(self.model_detectors)
218
 
@@ -330,6 +341,10 @@ class TelecomAnomalyDetector:
330
  top = '_multivariate'
331
  model_feature_drivers[top] += 1
332
 
 
 
 
 
333
  return DetectionStatistics(
334
  total_rows=len(self.data),
335
  rows_with_anomalies=rows_with_anomalies,
@@ -338,8 +353,139 @@ class TelecomAnomalyDetector:
338
  unique_subscribers=len(set(a.msisdn for a in self.anomalies if a.msisdn)),
339
  anomaly_types=dict(anomaly_types),
340
  model_feature_drivers=dict(model_feature_drivers),
 
341
  )
342
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
  def export_results(self, format: str = 'json', only_anomalies: bool = True) -> Dict[str, str]:
344
  """Export all results. CLI-only β€” the API never calls this."""
345
  if self.exporter is None:
 
198
 
199
  logger.info(f"Geographic filtering complete: {original_count} -> {len(self.data)} records")
200
 
201
+ def detect_anomalies(
202
+ self,
203
+ use_model: bool = False,
204
+ run_rules: bool = True,
205
+ ) -> List[AnomalyResult]:
206
  """
207
  Run anomaly detectors over the loaded data.
208
 
209
  Args:
210
+ use_model: When True, runs the model-based detectors
211
+ (Isolation Forest).
212
+ run_rules: When True, runs the rule-based detectors. Set to
213
+ False to run the model alone β€” this is what powers the
214
+ Flutter "Use AI Model" toggle without "Anomaly Detection".
215
+ Defaults to True so existing callers (CLI, prior tests)
216
+ keep their rule-only behaviour.
217
  """
218
  start_time = datetime.now()
219
  logger.info(
220
+ f"Starting anomaly detection "
221
+ f"(run_rules={run_rules}, use_model={use_model})"
222
  )
223
 
224
+ detectors_to_run = []
225
+ if run_rules:
226
+ detectors_to_run.extend(self.rule_detectors)
227
  if use_model:
228
  detectors_to_run.extend(self.model_detectors)
229
 
 
341
  top = '_multivariate'
342
  model_feature_drivers[top] += 1
343
 
344
+ model_summary = self._build_model_summary(
345
+ dict(model_feature_drivers)
346
+ )
347
+
348
  return DetectionStatistics(
349
  total_rows=len(self.data),
350
  rows_with_anomalies=rows_with_anomalies,
 
353
  unique_subscribers=len(set(a.msisdn for a in self.anomalies if a.msisdn)),
354
  anomaly_types=dict(anomaly_types),
355
  model_feature_drivers=dict(model_feature_drivers),
356
+ model_summary=model_summary,
357
  )
358
 
359
+ def _build_model_summary(
360
+ self,
361
+ feature_driver_counts: Dict[str, int],
362
+ ) -> Dict[str, Any]:
363
+ """
364
+ Consolidated IsolationForest report. Empty dict if no model
365
+ anomalies fired (so the frontend can skip rendering the
366
+ AI-specific charts cleanly).
367
+ """
368
+ model_anomalies = [
369
+ a for a in self.anomalies
370
+ if (a.metadata or {}).get('family') == 'iforest'
371
+ ]
372
+ if not model_anomalies:
373
+ return {}
374
+
375
+ # Category counts (AI: … only). Lets the frontend draw a bar
376
+ # chart that's specifically about model output, without the
377
+ # rule-detector counts mixed in like `anomaly_types` has.
378
+ category_counts: Dict[str, int] = defaultdict(int)
379
+ for a in model_anomalies:
380
+ category_counts[a.detection_method] += 1
381
+
382
+ # Mean |z| per feature across every model flag. Highest value =
383
+ # the feature with the strongest average pull on the model β€” a
384
+ # natural "feature importance" chart.
385
+ z_sums: Dict[str, List[float]] = defaultdict(list)
386
+ for a in model_anomalies:
387
+ for entry in (a.metadata.get('attribution') or []):
388
+ z_sums[entry['feature']].append(abs(float(entry['z'])))
389
+ feature_z_means: Dict[str, float] = {
390
+ feat: round(sum(zs) / len(zs), 2)
391
+ for feat, zs in z_sums.items()
392
+ }
393
+
394
+ # 7 fixed buckets covering the detector's 0.60..0.95 confidence
395
+ # band. Fixed-width buckets so a histogram chart can render
396
+ # without the frontend deciding bin edges.
397
+ confidence_buckets: Dict[str, int] = {
398
+ '0.60-0.65': 0, '0.65-0.70': 0, '0.70-0.75': 0,
399
+ '0.75-0.80': 0, '0.80-0.85': 0, '0.85-0.90': 0,
400
+ '0.90-0.95': 0,
401
+ }
402
+ for a in model_anomalies:
403
+ c = a.confidence
404
+ if c < 0.65:
405
+ k = '0.60-0.65'
406
+ elif c < 0.70:
407
+ k = '0.65-0.70'
408
+ elif c < 0.75:
409
+ k = '0.70-0.75'
410
+ elif c < 0.80:
411
+ k = '0.75-0.80'
412
+ elif c < 0.85:
413
+ k = '0.80-0.85'
414
+ elif c < 0.90:
415
+ k = '0.85-0.90'
416
+ else:
417
+ k = '0.90-0.95'
418
+ confidence_buckets[k] += 1
419
+
420
+ # Hour-of-day distribution restricted to AI flags only β€” a
421
+ # "when does the model fire" view distinct from the frontend's
422
+ # existing all-data hourly chart.
423
+ flags_by_hour: List[int] = [0] * 24
424
+ for a in model_anomalies:
425
+ original_idx = self.data_indices.get(a.row_index)
426
+ if original_idx is None:
427
+ continue
428
+ row = self.original_data[original_idx]
429
+ time_str = row.get('event_time') or ''
430
+ try:
431
+ hour = int(str(time_str).split(':')[0])
432
+ except (ValueError, IndexError):
433
+ continue
434
+ if 0 <= hour < 24:
435
+ flags_by_hour[hour] += 1
436
+
437
+ # Top 5 subscribers by AI flag count β€” useful leaderboard chart
438
+ # on multi-subscriber files. Meaningless on single-subscriber
439
+ # files (just shows the one subscriber); the frontend can
440
+ # gracefully hide the card when length <= 1.
441
+ sub_counts: Dict[str, int] = defaultdict(int)
442
+ for a in model_anomalies:
443
+ if a.msisdn:
444
+ sub_counts[a.msisdn] += 1
445
+ top_subscribers = [
446
+ {'msisdn': m, 'flag_count': c}
447
+ for m, c in sorted(
448
+ sub_counts.items(), key=lambda x: -x[1]
449
+ )[:5]
450
+ ]
451
+
452
+ # Pull subscribers_scored / subscribers_skipped from whichever
453
+ # model detector ran. Defensive β€” model_detectors list may be
454
+ # empty (shouldn't happen at this point but cheap to guard).
455
+ scored = 0
456
+ skipped = 0
457
+ for det in self.model_detectors:
458
+ last = getattr(det, 'last_run_stats', None) or {}
459
+ scored += int(last.get('subscribers_scored', 0))
460
+ skipped += int(last.get('subscribers_skipped', 0))
461
+
462
+ summary = {
463
+ 'total_flags': len(model_anomalies),
464
+ 'subscribers_scored': scored,
465
+ 'subscribers_skipped': skipped,
466
+ 'category_counts': dict(category_counts),
467
+ 'feature_driver_counts': feature_driver_counts,
468
+ 'feature_z_means': feature_z_means,
469
+ 'confidence_buckets': confidence_buckets,
470
+ 'flags_by_hour': flags_by_hour,
471
+ 'top_subscribers': top_subscribers,
472
+ }
473
+
474
+ # Single-line server log so anyone tailing the API sees the
475
+ # model output at the end of every run without grepping the
476
+ # JSON response.
477
+ logger.info(
478
+ "IsolationForest summary: %d flags across %d categor%s; "
479
+ "%d subscribers scored, %d skipped",
480
+ summary['total_flags'],
481
+ len(category_counts),
482
+ 'y' if len(category_counts) == 1 else 'ies',
483
+ scored,
484
+ skipped,
485
+ )
486
+
487
+ return summary
488
+
489
  def export_results(self, format: str = 'json', only_anomalies: bool = True) -> Dict[str, str]:
490
  """Export all results. CLI-only β€” the API never calls this."""
491
  if self.exporter is None:
telecom_anomaly/core/models.py CHANGED
@@ -107,6 +107,26 @@ class DetectionStatistics:
107
  # model is finding anomalies in" alongside the rule-detector
108
  # breakdown.
109
  model_feature_drivers: Dict[str, int] = field(default_factory=dict)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
 
111
  def to_dict(self) -> Dict:
112
  """Convert to dictionary"""
 
107
  # model is finding anomalies in" alongside the rule-detector
108
  # breakdown.
109
  model_feature_drivers: Dict[str, int] = field(default_factory=dict)
110
+ # Consolidated IsolationForest summary β€” everything the frontend
111
+ # needs to render iforest-specific charts in one place. Empty when
112
+ # use_model=False or the model produced zero flags. Keys:
113
+ # total_flags β€” int, count of iforest anomalies
114
+ # subscribers_scored β€” int, msisdns that met min_samples
115
+ # subscribers_skipped β€” int, msisdns below min_samples
116
+ # category_counts β€” {category: count}, AI: … only
117
+ # feature_driver_counts β€” {feature: count}, same shape as the
118
+ # top-level model_feature_drivers map
119
+ # feature_z_means β€” {feature: mean_abs_z}, signal strength
120
+ # per feature across all flags
121
+ # confidence_buckets β€” {"0.60-0.65": n, …}, 7 buckets
122
+ # flags_by_hour β€” list[int] of length 24, hourly counts
123
+ # of AI flags only (so the frontend can
124
+ # draw a "when does the AI fire" chart
125
+ # distinct from the all-data hourly
126
+ # distribution it already has)
127
+ # top_subscribers β€” list[{msisdn, flag_count}], top 5 by
128
+ # AI flag count
129
+ model_summary: Dict[str, Any] = field(default_factory=dict)
130
 
131
  def to_dict(self) -> Dict:
132
  """Convert to dictionary"""
telecom_anomaly/detection/isolation_forest.py CHANGED
@@ -83,8 +83,21 @@ class IsolationForestDetector(BaseDetector):
83
  self.min_samples = getattr(config, 'iforest_min_samples', 50)
84
  self.random_state = getattr(config, 'iforest_random_state', 42)
85
  self.n_estimators = getattr(config, 'iforest_n_estimators', 100)
 
 
 
 
 
 
 
 
86
 
87
  def detect(self, data: List[Dict], **kwargs) -> List[AnomalyResult]:
 
 
 
 
 
88
  if not data:
89
  return []
90
 
@@ -94,12 +107,20 @@ class IsolationForestDetector(BaseDetector):
94
  if msisdn:
95
  per_subscriber[msisdn].append(idx)
96
 
 
 
97
  anomalies: List[AnomalyResult] = []
98
  for msisdn, indices in per_subscriber.items():
99
  if len(indices) < self.min_samples:
 
100
  continue
 
101
  anomalies.extend(self._detect_for_subscriber(msisdn, indices, data))
102
 
 
 
 
 
103
  return anomalies
104
 
105
  def _detect_for_subscriber(self, msisdn: str, indices: List[int],
 
83
  self.min_samples = getattr(config, 'iforest_min_samples', 50)
84
  self.random_state = getattr(config, 'iforest_random_state', 42)
85
  self.n_estimators = getattr(config, 'iforest_n_estimators', 100)
86
+ # Populated at the end of every `detect()` call. The orchestrator
87
+ # pulls these into `DetectionStatistics.model_summary` so the
88
+ # frontend can chart "how much of the data was actually scored"
89
+ # alongside the per-flag breakdowns.
90
+ self.last_run_stats: Dict[str, Any] = {
91
+ 'subscribers_scored': 0,
92
+ 'subscribers_skipped': 0,
93
+ }
94
 
95
  def detect(self, data: List[Dict], **kwargs) -> List[AnomalyResult]:
96
+ # Reset stats so a re-run starts clean.
97
+ self.last_run_stats = {
98
+ 'subscribers_scored': 0,
99
+ 'subscribers_skipped': 0,
100
+ }
101
  if not data:
102
  return []
103
 
 
107
  if msisdn:
108
  per_subscriber[msisdn].append(idx)
109
 
110
+ scored = 0
111
+ skipped = 0
112
  anomalies: List[AnomalyResult] = []
113
  for msisdn, indices in per_subscriber.items():
114
  if len(indices) < self.min_samples:
115
+ skipped += 1
116
  continue
117
+ scored += 1
118
  anomalies.extend(self._detect_for_subscriber(msisdn, indices, data))
119
 
120
+ self.last_run_stats = {
121
+ 'subscribers_scored': scored,
122
+ 'subscribers_skipped': skipped,
123
+ }
124
  return anomalies
125
 
126
  def _detect_for_subscriber(self, msisdn: str, indices: List[int],