Spaces:
Sleeping
Sleeping
fix AI model
Browse files- check_ai_only.py +49 -0
- check_model_summary.py +44 -0
- telecom_anomaly/api.py +17 -3
- telecom_anomaly/core/detector.py +152 -6
- telecom_anomaly/core/models.py +20 -0
- telecom_anomaly/detection/isolation_forest.py +21 -0
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 |
-
|
| 213 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
"""
|
| 203 |
Run anomaly detectors over the loaded data.
|
| 204 |
|
| 205 |
Args:
|
| 206 |
-
use_model: When True,
|
| 207 |
-
(Isolation Forest).
|
| 208 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
"""
|
| 210 |
start_time = datetime.now()
|
| 211 |
logger.info(
|
| 212 |
-
f"Starting anomaly detection
|
|
|
|
| 213 |
)
|
| 214 |
|
| 215 |
-
detectors_to_run =
|
|
|
|
|
|
|
| 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],
|