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

fix AI model

Browse files
check_attribution.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """One-off: load a CSV via the real reader, run the detector with
2
+ use_model=True, and print:
3
+ - the new categorised method names + per-row attribution,
4
+ - the aggregate `model_feature_drivers` map from get_statistics()
5
+ (the shape the frontend will chart).
6
+ """
7
+ import sys
8
+ from pathlib import Path
9
+ sys.path.insert(0, str(Path(__file__).parent))
10
+
11
+ from telecom_anomaly.core.detector import TelecomAnomalyDetector
12
+ from telecom_anomaly.core.models import TelecomConfig
13
+
14
+ CSV = (
15
+ r"C:\my_project\work\GH_Police\main project\backend"
16
+ r"\telecom_anomaly_detection\data\strictly_cleaned_data"
17
+ r"\591296805 calls (1)_strictly_cleaned.csv"
18
+ )
19
+
20
+ cfg = TelecomConfig()
21
+ det = TelecomAnomalyDetector(cfg)
22
+ det.load_data([CSV])
23
+ det.detect_anomalies(use_model=True)
24
+
25
+ stats = det.get_statistics()
26
+ print(f"\nTotal anomalies: {stats.total_anomalies_detected}")
27
+ print(f"Rows with anomalies: {stats.rows_with_anomalies}")
28
+ print(f"\nanomaly_types (rule + model categories combined):")
29
+ for k, v in sorted(stats.anomaly_types.items(), key=lambda x: -x[1]):
30
+ print(f" {v:4d} {k}")
31
+
32
+ print(f"\nmodel_feature_drivers (top-driver counts across all AI flags):")
33
+ for k, v in sorted(stats.model_feature_drivers.items(), key=lambda x: -x[1]):
34
+ print(f" {v:4d} {k}")
35
+
36
+ print(f"\nSample AI-flagged rows with category + reason + structured attribution:")
37
+ shown = 0
38
+ for orig_idx, methods in det.row_anomaly_map.items():
39
+ ai = [(i, m) for i, m in enumerate(methods) if m.startswith('AI:')]
40
+ if not ai or shown >= 5:
41
+ continue
42
+ i, method = ai[0]
43
+ reason = det.row_anomaly_reasons[orig_idx][i]
44
+ attribution = det.row_anomaly_attribution[orig_idx][i]
45
+ row = det.original_data[orig_idx]
46
+ print(f"\n row {orig_idx}: {row.get('event_date')} {row.get('event_time')} "
47
+ f"dur={row.get('duration')}")
48
+ print(f" method : {method}")
49
+ print(f" reason : {reason}")
50
+ print(f" attribution: {attribution}")
51
+ shown += 1
profile_iforest.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ One-off profiler for the two strictly-cleaned CSVs to inform IsolationForest
3
+ tuning. Reports schema, per-subscriber row counts, feature distributions,
4
+ hour-of-day histograms, and the actual flag rate the current model produces
5
+ on each file at the current defaults.
6
+
7
+ Run from `teleprocessinggh/`:
8
+ .venv\\Scripts\\python.exe profile_iforest.py
9
+ """
10
+
11
+ import os
12
+ import sys
13
+ from collections import Counter, defaultdict
14
+ from pathlib import Path
15
+
16
+ import numpy as np
17
+ import pandas as pd
18
+
19
+ # Make `telecom_anomaly.*` imports resolve when run from this folder.
20
+ sys.path.insert(0, str(Path(__file__).parent))
21
+
22
+ from telecom_anomaly.core.models import TelecomConfig # noqa: E402
23
+ from telecom_anomaly.detection.isolation_forest import ( # noqa: E402
24
+ IsolationForestDetector,
25
+ )
26
+ from telecom_anomaly.validation.validator import DataValidator # noqa: E402
27
+
28
+
29
+ CSV_PATHS = [
30
+ r"C:\my_project\work\GH_Police\main project\backend\telecom_anomaly_detection"
31
+ r"\data\strictly_cleaned_data\246060920_calls_strictly_cleaned.csv",
32
+ r"C:\my_project\work\GH_Police\main project\backend\telecom_anomaly_detection"
33
+ r"\data\strictly_cleaned_data\591296805 calls (1)_strictly_cleaned.csv",
34
+ ]
35
+
36
+
37
+ def hr(c="-", n=72):
38
+ print(c * n)
39
+
40
+
41
+ def profile(path):
42
+ name = os.path.basename(path)
43
+ print()
44
+ hr("=")
45
+ print(f"FILE: {name}")
46
+ hr("=")
47
+
48
+ df = pd.read_csv(path, dtype=str, keep_default_na=False, low_memory=False)
49
+ print(f"Rows: {len(df):,} Columns: {len(df.columns)}")
50
+ print(f"Headers: {list(df.columns)}")
51
+
52
+ v = DataValidator()
53
+ # Derive msisdn (matches the reader's logic).
54
+ def _msisdn(r):
55
+ explicit = r.get("msisdn", "")
56
+ if v.validate_msisdn(explicit):
57
+ return explicit
58
+ return v.derive_msisdn(
59
+ r.get("call_direction", ""),
60
+ r.get("calling_no", ""),
61
+ r.get("called_no", ""),
62
+ )
63
+
64
+ df["_msisdn"] = df.apply(_msisdn, axis=1)
65
+ valid = df[df["_msisdn"] != ""].copy()
66
+ print(f"Rows with derivable msisdn: {len(valid):,} / {len(df):,}")
67
+
68
+ # Per-subscriber row counts
69
+ sub_counts = valid["_msisdn"].value_counts()
70
+ print(f"Distinct subscribers: {len(sub_counts):,}")
71
+ print(f" rows-per-subscriber min={sub_counts.min()} "
72
+ f"p50={int(sub_counts.median())} p90={int(sub_counts.quantile(0.9))} "
73
+ f"max={sub_counts.max()}")
74
+ eligible = sub_counts[sub_counts >= 50]
75
+ print(f"Subscribers with >=50 rows (eligible for IForest): "
76
+ f"{len(eligible)} ({eligible.sum():,} rows)")
77
+ print(f"Top 5 subscribers by row count:")
78
+ for m, c in sub_counts.head(5).items():
79
+ print(f" {m} -> {c:,} rows")
80
+
81
+ # Categorical distributions
82
+ print()
83
+ print("usage_type:")
84
+ for k, c in valid["usage_type"].value_counts().head(10).items():
85
+ print(f" {k or '<empty>':20s} {c:6,}")
86
+ print("call_direction:")
87
+ for k, c in valid["call_direction"].value_counts().head(10).items():
88
+ # call_direction often contains a newline; collapse for readability
89
+ kk = " ".join(str(k).split())
90
+ print(f" {kk or '<empty>':20s} {c:6,}")
91
+
92
+ # Duration distribution
93
+ dur = pd.to_numeric(valid["duration"], errors="coerce").fillna(0)
94
+ print()
95
+ print(f"duration (sec) min={dur.min():.0f} "
96
+ f"p50={dur.quantile(0.5):.0f} p90={dur.quantile(0.9):.0f} "
97
+ f"p99={dur.quantile(0.99):.0f} max={dur.max():.0f}")
98
+ print(f" duration < 3s : {(dur < 3).sum():,} "
99
+ f"(flash-call territory)")
100
+ print(f" duration > 1hr : {(dur > 3600).sum():,} "
101
+ f"(extreme-call territory)")
102
+
103
+ # Hour distribution
104
+ def _hour(t):
105
+ try:
106
+ return int(str(t).split(":")[0])
107
+ except Exception:
108
+ return -1
109
+ valid["_hour"] = valid["event_time"].apply(_hour)
110
+ hist = valid[valid["_hour"] >= 0]["_hour"].value_counts().sort_index()
111
+ print()
112
+ print("Hour-of-day histogram (counts):")
113
+ for h in range(24):
114
+ count = int(hist.get(h, 0))
115
+ bar = "#" * min(60, count // max(1, sub_counts.sum() // 800))
116
+ marker = " *night*" if (h >= 23 or h < 5) else ""
117
+ print(f" {h:02d}:00 {count:6,} {bar}{marker}")
118
+ night = valid[(valid["_hour"] >= 23) | ((valid["_hour"] >= 0) & (valid["_hour"] < 5))]
119
+ print(f"Night-hour rows (23:00-04:59): {len(night):,} "
120
+ f"({100*len(night)/max(1,len(valid)):.1f}%)")
121
+
122
+ # Coordinate coverage
123
+ lat = pd.to_numeric(valid["latitude"], errors="coerce")
124
+ lon = pd.to_numeric(valid["longitude"], errors="coerce")
125
+ print()
126
+ print(f"latitude present: {lat.notna().sum():,} "
127
+ f"({100*lat.notna().sum()/len(valid):.1f}%)")
128
+ print(f"longitude present: {lon.notna().sum():,} "
129
+ f"({100*lon.notna().sum()/len(valid):.1f}%)")
130
+
131
+ # Region / district cardinality
132
+ print(f"distinct regions : {valid['region'].nunique()}")
133
+ print(f"distinct districts: {valid['district'].nunique()}")
134
+ if 'imei' in valid.columns:
135
+ print(f"distinct IMEIs : {valid['imei'].nunique()}")
136
+
137
+ # ----- Run IForest at multiple contamination levels -----
138
+ print()
139
+ print("IsolationForest flag rates per contamination (current min_samples=50):")
140
+ # Build records list in the shape the detector expects.
141
+ records = []
142
+ for _, r in valid.iterrows():
143
+ records.append({
144
+ "msisdn": r["_msisdn"],
145
+ "duration": pd.to_numeric(r.get("duration"), errors="coerce") or 0.0,
146
+ "event_time": r.get("event_time", ""),
147
+ "is_night": (_hour(r.get("event_time")) >= 23
148
+ or 0 <= _hour(r.get("event_time")) < 5),
149
+ "usage_type": r.get("usage_type", ""),
150
+ "call_direction": r.get("call_direction", ""),
151
+ "latitude": float(r["latitude"])
152
+ if r.get("latitude") not in ("", None) and pd.notna(
153
+ pd.to_numeric(r["latitude"], errors="coerce")) else None,
154
+ "longitude": float(r["longitude"])
155
+ if r.get("longitude") not in ("", None) and pd.notna(
156
+ pd.to_numeric(r["longitude"], errors="coerce")) else None,
157
+ "region": r.get("region", ""),
158
+ "district": r.get("district", ""),
159
+ })
160
+
161
+ for contamination in ["auto", 0.10, 0.05, 0.02, 0.01]:
162
+ cfg = TelecomConfig()
163
+ cfg.iforest_contamination = contamination
164
+ cfg.iforest_min_samples = 50
165
+ det = IsolationForestDetector(cfg)
166
+ flagged = det.detect(records)
167
+ scored_subs = sum(
168
+ 1 for s, idxs in
169
+ _group_subscribers(records).items() if len(idxs) >= 50
170
+ )
171
+ scored_rows = sum(
172
+ len(idxs) for s, idxs in
173
+ _group_subscribers(records).items() if len(idxs) >= 50
174
+ )
175
+ rate = 100 * len(flagged) / max(1, scored_rows)
176
+ print(f" contamination={str(contamination):>5} "
177
+ f"flagged={len(flagged):4} "
178
+ f"of scored_rows={scored_rows:5} "
179
+ f"({rate:5.2f}%) "
180
+ f"across {scored_subs} subscribers")
181
+
182
+
183
+ def _group_subscribers(records):
184
+ g = defaultdict(list)
185
+ for i, r in enumerate(records):
186
+ m = r.get("msisdn", "")
187
+ if m:
188
+ g[m].append(i)
189
+ return g
190
+
191
+
192
+ if __name__ == "__main__":
193
+ for path in CSV_PATHS:
194
+ if not os.path.exists(path):
195
+ print(f"Missing: {path}")
196
+ continue
197
+ profile(path)
telecom_anomaly/api.py CHANGED
@@ -222,11 +222,25 @@ def _run_analysis(
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
 
 
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, [])
227
  record = {field: row.get(field, "") for field in REQUESTED_FIELDS}
228
  record.update({
229
  "has_anomaly": "YES" if methods else "NO",
230
  "anomaly_count": len(methods),
231
  "anomaly_methods": "; ".join(methods),
232
+ # Index-aligned with anomaly_methods. Currently only the iforest
233
+ # detector populates a non-empty reason; rule detectors leave
234
+ # their slot blank. Empty entries are dropped so the joined
235
+ # string stays readable.
236
+ "anomaly_reasons": "; ".join(r for r in reasons if r),
237
+ # Structured per-method attribution. Each entry is the top-k
238
+ # {feature, z, value, direction} dicts the detector produced.
239
+ # Rule detectors contribute empty lists; iforest contributes
240
+ # 1-3 entries per flag. The frontend can render these as
241
+ # per-row bar charts ("why was this flagged"), or aggregate
242
+ # them client-side for a global feature-importance view.
243
+ "anomaly_attribution": attributions,
244
  })
245
  full_records.append(record)
246
 
telecom_anomaly/core/detector.py CHANGED
@@ -80,6 +80,18 @@ class TelecomAnomalyDetector:
80
  # Anomaly tracking
81
  self.rows_with_anomalies: Set[int] = set()
82
  self.row_anomaly_map: Dict[int, List[str]] = defaultdict(list)
 
 
 
 
 
 
 
 
 
 
 
 
83
  self.subscriber_anomalies: Dict[str, Dict] = defaultdict(
84
  lambda: {'total': 0, 'methods': set()}
85
  )
@@ -270,7 +282,21 @@ class TelecomAnomalyDetector:
270
  original_idx = self.data_indices.get(anomaly.row_index)
271
  if original_idx is not None:
272
  self.row_anomaly_map[original_idx].append(anomaly.detection_method)
273
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
  # Track subscriber anomalies
275
  if anomaly.msisdn:
276
  self.subscriber_anomalies[anomaly.msisdn]['total'] += 1
@@ -279,22 +305,39 @@ class TelecomAnomalyDetector:
279
  def get_statistics(self) -> DetectionStatistics:
280
  """Get detection statistics"""
281
  rows_with_anomalies = len(self.rows_with_anomalies)
282
-
283
  # Calculate anomaly rate correctly
284
  anomaly_rate = (rows_with_anomalies / len(self.data)) * 100 if self.data else 0
285
-
286
  # Count anomaly types
287
  anomaly_types = defaultdict(int)
288
  for anomaly in self.anomalies:
289
  anomaly_types[anomaly.detection_method] += 1
290
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
  return DetectionStatistics(
292
  total_rows=len(self.data),
293
  rows_with_anomalies=rows_with_anomalies,
294
  total_anomalies_detected=len(self.anomalies),
295
  anomaly_rate=anomaly_rate,
296
  unique_subscribers=len(set(a.msisdn for a in self.anomalies if a.msisdn)),
297
- anomaly_types=dict(anomaly_types)
 
298
  )
299
 
300
  def export_results(self, format: str = 'json', only_anomalies: bool = True) -> Dict[str, str]:
 
80
  # Anomaly tracking
81
  self.rows_with_anomalies: Set[int] = set()
82
  self.row_anomaly_map: Dict[int, List[str]] = defaultdict(list)
83
+ # Per-row "reason" strings, parallel to row_anomaly_map (index i in
84
+ # the value list corresponds to method i). Empty string when the
85
+ # method didn't supply a reason (rule detectors today). The iforest
86
+ # detector populates this from anomaly.metadata['reason'].
87
+ self.row_anomaly_reasons: Dict[int, List[str]] = defaultdict(list)
88
+ # Per-row structured attribution, parallel to row_anomaly_map.
89
+ # Each entry is a list of {feature, z, value, direction} dicts
90
+ # (or an empty list for methods that didn't compute attribution —
91
+ # i.e. the rule detectors today). Lets the frontend draw per-row
92
+ # bar charts of which features drove a flag.
93
+ self.row_anomaly_attribution: Dict[int, List[List[Dict[str, Any]]]] = \
94
+ defaultdict(list)
95
  self.subscriber_anomalies: Dict[str, Dict] = defaultdict(
96
  lambda: {'total': 0, 'methods': set()}
97
  )
 
282
  original_idx = self.data_indices.get(anomaly.row_index)
283
  if original_idx is not None:
284
  self.row_anomaly_map[original_idx].append(anomaly.detection_method)
285
+ # Mirror reason + structured attribution in the parallel
286
+ # lists. Rule detectors don't currently set
287
+ # metadata['reason'] / metadata['attribution'] so they
288
+ # fall back to '' / [] to keep all three lists
289
+ # index-aligned.
290
+ reason = ''
291
+ attribution: List[Dict[str, Any]] = []
292
+ if anomaly.metadata:
293
+ reason = str(anomaly.metadata.get('reason', '') or '')
294
+ attribution = list(
295
+ anomaly.metadata.get('attribution', []) or []
296
+ )
297
+ self.row_anomaly_reasons[original_idx].append(reason)
298
+ self.row_anomaly_attribution[original_idx].append(attribution)
299
+
300
  # Track subscriber anomalies
301
  if anomaly.msisdn:
302
  self.subscriber_anomalies[anomaly.msisdn]['total'] += 1
 
305
  def get_statistics(self) -> DetectionStatistics:
306
  """Get detection statistics"""
307
  rows_with_anomalies = len(self.rows_with_anomalies)
308
+
309
  # Calculate anomaly rate correctly
310
  anomaly_rate = (rows_with_anomalies / len(self.data)) * 100 if self.data else 0
311
+
312
  # Count anomaly types
313
  anomaly_types = defaultdict(int)
314
  for anomaly in self.anomalies:
315
  anomaly_types[anomaly.detection_method] += 1
316
+
317
+ # Aggregate model-flag top-driver counts. Only iforest anomalies
318
+ # carry a `family='iforest'` metadata marker today, so this naturally
319
+ # ignores rule detectors. The output is shaped for direct rendering
320
+ # by a bar chart on the visualization screen.
321
+ model_feature_drivers: Dict[str, int] = defaultdict(int)
322
+ for anomaly in self.anomalies:
323
+ meta = anomaly.metadata or {}
324
+ if meta.get('family') != 'iforest':
325
+ continue
326
+ top = meta.get('top_feature') or ''
327
+ if not top:
328
+ # Multivariate-but-no-top-driver flags get bucketed under
329
+ # an explicit label so the chart still accounts for them.
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,
336
  total_anomalies_detected=len(self.anomalies),
337
  anomaly_rate=anomaly_rate,
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]:
telecom_anomaly/core/models.py CHANGED
@@ -101,7 +101,13 @@ class DetectionStatistics:
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)
 
101
  anomaly_rate: float
102
  unique_subscribers: int
103
  anomaly_types: Dict[str, int]
104
+ # Per-feature counts of "this feature was the #1 driver" across all
105
+ # IsolationForest flags. Empty when use_model=False (or no model
106
+ # anomalies fired). Lets the frontend chart "what features the AI
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"""
113
  return asdict(self)
telecom_anomaly/detection/isolation_forest.py CHANGED
@@ -10,10 +10,16 @@ single rule fires).
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
@@ -35,11 +41,45 @@ class IsolationForestDetector(BaseDetector):
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)
@@ -88,6 +128,16 @@ class IsolationForestDetector(BaseDetector):
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:
@@ -95,11 +145,26 @@ class IsolationForestDetector(BaseDetector):
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,
@@ -107,13 +172,73 @@ class IsolationForestDetector(BaseDetector):
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]
@@ -121,7 +246,7 @@ class IsolationForestDetector(BaseDetector):
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
@@ -134,6 +259,7 @@ class IsolationForestDetector(BaseDetector):
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,
@@ -145,6 +271,8 @@ class IsolationForestDetector(BaseDetector):
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)
@@ -160,10 +288,44 @@ class IsolationForestDetector(BaseDetector):
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])
 
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
+ Each flagged row carries a per-feature attribution (top-3 features that
15
+ pulled it furthest from the subscriber's mean, in z-score terms) so the
16
+ caller can show *why* the row was flagged — the rule detectors give
17
+ single-column reasons, this gives multivariate ones.
18
  """
19
 
20
  from collections import Counter, defaultdict
21
+ from datetime import datetime, time as _time
22
+ from typing import Any, Dict, List, Tuple
23
 
24
  import numpy as np
25
  from sklearn.ensemble import IsolationForest
 
41
  'longitude',
42
  'region_freq',
43
  'district_freq',
44
+ 'imei_freq',
45
+ 'time_since_last_call_sec',
46
  ]
47
 
48
+ # Maps each feature to the category label used as the row's
49
+ # detection_method. Picking the *category* of the top-z feature lets the
50
+ # frontend donut chart show the model's findings broken down the same
51
+ # way the rule detectors are ("DurationDetector", "SMSDetector", …)
52
+ # rather than collapsing every model flag into a single "iforest" slice.
53
+ _FEATURE_CATEGORY = {
54
+ 'duration': 'AI: Duration Outlier',
55
+ 'hour_of_day': 'AI: Time Pattern',
56
+ 'is_night': 'AI: Time Pattern',
57
+ 'is_sms': 'AI: Usage Pattern',
58
+ 'direction_outgoing': 'AI: Usage Pattern',
59
+ 'latitude': 'AI: Location Outlier',
60
+ 'longitude': 'AI: Location Outlier',
61
+ 'region_freq': 'AI: Location Outlier',
62
+ 'district_freq': 'AI: Location Outlier',
63
+ 'imei_freq': 'AI: Device Switch',
64
+ 'time_since_last_call_sec': 'AI: Bursty Pattern',
65
+ }
66
+ _DEFAULT_CATEGORY = 'AI: Multivariate'
67
+
68
+ # Z-score below which a feature is too "normal" to be worth surfacing in
69
+ # the per-row reason string. Set conservatively so reasons stay focused
70
+ # on what actually drove the flag.
71
+ _ATTRIBUTION_MIN_ABS_Z = 1.0
72
+
73
+ # Max number of feature attributions to surface per flagged row.
74
+ _ATTRIBUTION_TOP_K = 3
75
+
76
  def __init__(self, config: TelecomConfig):
77
  super().__init__(config)
78
+ # Default contamination tightened from sklearn's 'auto' (which flags
79
+ # ~10–17% of rows on the canonical CSVs) down to 2%, matching the
80
+ # tuning recommendation in planing.md. Override via TelecomConfig
81
+ # if you want the legacy 'auto' behaviour back.
82
+ self.contamination = getattr(config, 'iforest_contamination', 0.02)
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)
 
128
  s_min, s_max = float(scores.min()), float(scores.max())
129
  denom = (s_max - s_min) or 1.0
130
 
131
+ # Per-feature z-scores across the subscriber's data. Drives the
132
+ # attribution / "reason" strings on each flagged row.
133
+ means = features.mean(axis=0)
134
+ stds = features.std(axis=0)
135
+ # Avoid div-by-zero for features with no variance in this subscriber
136
+ # (e.g. a subscriber whose every call is is_sms=0); their z-scores
137
+ # become 0, so they won't appear in the top-k.
138
+ stds_safe = np.where(stds > 1e-9, stds, 1.0)
139
+ z_all = (features - means) / stds_safe
140
+
141
  results: List[AnomalyResult] = []
142
  for local_idx, global_idx in enumerate(indices):
143
  if predictions[local_idx] != -1:
 
145
  raw_score = float(scores[local_idx])
146
  normalized = (s_max - raw_score) / denom # 0..1
147
  confidence = round(0.6 + 0.35 * normalized, 4) # 0.60..0.95
148
+
149
+ attribution, reason = self._attribution_for_row(
150
+ z_all[local_idx], features[local_idx], means
151
+ )
152
+
153
+ # Category-as-method name: e.g. "AI: Location Outlier" instead
154
+ # of one undifferentiated "iforest" slice. The category is
155
+ # driven by the top-z feature in the attribution; rows with no
156
+ # attribution (all |z| < threshold) fall back to "AI:
157
+ # Multivariate".
158
+ top_feature = attribution[0]['feature'] if attribution else None
159
+ method_name = self._FEATURE_CATEGORY.get(
160
+ top_feature, self._DEFAULT_CATEGORY
161
+ ) if top_feature else self._DEFAULT_CATEGORY
162
+
163
  results.append(self._create_anomaly(
164
  row_index=global_idx,
165
  column_name='multivariate',
166
  value=raw_score,
167
+ method=method_name,
168
  score=raw_score,
169
  threshold=float(model.offset_),
170
  confidence=confidence,
 
172
  metadata={
173
  'features': self.FEATURE_COLUMNS,
174
  'subscriber_samples': len(indices),
175
+ 'attribution': attribution,
176
+ 'reason': reason,
177
+ # Family marker so downstream code can tell "any AI
178
+ # flag" apart from rule-detector flags even though
179
+ # detection_method now varies.
180
+ 'family': 'iforest',
181
+ 'top_feature': top_feature or '',
182
  },
183
  ))
184
  return results
185
 
186
+ def _attribution_for_row(
187
+ self,
188
+ z_row: np.ndarray,
189
+ feature_row: np.ndarray,
190
+ feature_means: np.ndarray,
191
+ ) -> Tuple[List[Dict[str, Any]], str]:
192
+ """
193
+ Pick the top-k features by |z|, build a human-readable reason.
194
+
195
+ Returns:
196
+ attribution: list of {feature, z, value, direction} dicts
197
+ (machine-readable; ends up in metadata so the visualisation
198
+ screen can render bars / chips per feature).
199
+ reason: short string like
200
+ "duration high (+3.2σ); imei_freq low (-2.1σ)"
201
+ suitable for inline display alongside the rule detectors'
202
+ single-column reasons.
203
+ """
204
+ abs_z = np.abs(z_row)
205
+ order = np.argsort(-abs_z)
206
+ picks: List[Tuple[int, float]] = []
207
+ for k in order:
208
+ if len(picks) >= self._ATTRIBUTION_TOP_K:
209
+ break
210
+ if abs_z[k] < self._ATTRIBUTION_MIN_ABS_Z:
211
+ break
212
+ picks.append((int(k), float(z_row[k])))
213
+
214
+ attribution: List[Dict[str, Any]] = []
215
+ reason_parts: List[str] = []
216
+ for k, z in picks:
217
+ name = self.FEATURE_COLUMNS[k]
218
+ value = float(feature_row[k])
219
+ direction = 'high' if z >= 0 else 'low'
220
+ attribution.append({
221
+ 'feature': name,
222
+ 'z': round(z, 2),
223
+ 'value': round(value, 3),
224
+ 'direction': direction,
225
+ })
226
+ reason_parts.append(
227
+ f"{name} {direction} ({'+' if z >= 0 else '-'}{abs(z):.1f}σ)"
228
+ )
229
+
230
+ # If literally nothing stood out (all |z| < 1) we still want the row
231
+ # to have a reason — the score itself was anomalous in the model's
232
+ # joint distribution, just no single feature dominated.
233
+ reason = '; '.join(reason_parts) if reason_parts else \
234
+ 'multivariate combination outside subscriber norm'
235
+ return attribution, reason
236
+
237
  def _build_features(self, rows: List[Dict[str, Any]]) -> np.ndarray:
238
  region_freq = self._frequency_map(rows, 'region')
239
  district_freq = self._frequency_map(rows, 'district')
240
+ imei_freq = self._frequency_map(rows, 'imei')
241
+ gaps = self._time_since_last_map(rows)
242
 
243
  lat_values = [r.get('latitude') for r in rows if r.get('latitude') is not None]
244
  lon_values = [r.get('longitude') for r in rows if r.get('longitude') is not None]
 
246
  lon_default = float(np.median(lon_values)) if lon_values else 0.0
247
 
248
  features = []
249
+ for i, row in enumerate(rows):
250
  duration = float(row.get('duration') or 0.0)
251
  hour = self._hour_of_day(row.get('event_time', ''))
252
  is_night = 1 if row.get('is_night') else 0
 
259
  lon = float(lon) if lon is not None else lon_default
260
  region = str(row.get('region') or '')
261
  district = str(row.get('district') or '')
262
+ imei = str(row.get('imei') or '')
263
 
264
  features.append([
265
  duration,
 
271
  lon,
272
  region_freq.get(region, 0.0),
273
  district_freq.get(district, 0.0),
274
+ imei_freq.get(imei, 0.0),
275
+ gaps[i],
276
  ])
277
 
278
  return np.asarray(features, dtype=np.float64)
 
288
  total = sum(counts.values()) or 1
289
  return {value: count / total for value, count in counts.items()}
290
 
291
+ @staticmethod
292
+ def _time_since_last_map(rows: List[Dict]) -> List[float]:
293
+ """
294
+ Seconds since this subscriber's previous call (in chronological
295
+ order). The first chronological row gets 0. Rows without a
296
+ parseable datetime also get 0 (they sort to the front but the
297
+ IForest sees them as "unknown gap" — harmless given the rest of
298
+ the feature signal).
299
+
300
+ Returns a list aligned with `rows`' original order so the feature
301
+ matrix stays index-stable.
302
+ """
303
+ n = len(rows)
304
+ result = [0.0] * n
305
+
306
+ with_dt: List[Tuple[int, datetime]] = []
307
+ for i, r in enumerate(rows):
308
+ dt = r.get('datetime')
309
+ if isinstance(dt, datetime):
310
+ with_dt.append((i, dt))
311
+
312
+ if not with_dt:
313
+ return result
314
+
315
+ with_dt.sort(key=lambda x: x[1])
316
+ prev_dt: datetime = with_dt[0][1]
317
+ for orig_idx, dt in with_dt[1:]:
318
+ delta = (dt - prev_dt).total_seconds()
319
+ # Clamp tiny negative drift (shouldn't happen post-sort but be
320
+ # safe) and cap absurd gaps so a single multi-day silence
321
+ # doesn't dominate the std.
322
+ result[orig_idx] = max(0.0, float(delta))
323
+ prev_dt = dt
324
+ return result
325
+
326
  @staticmethod
327
  def _hour_of_day(time_str) -> int:
328
  try:
 
329
  if isinstance(time_str, _time):
330
  return int(time_str.hour)
331
  return int(str(time_str).split(':')[0])