Aryan Singh Claude Opus 4.8 (1M context) commited on
Commit
67eae2d
·
1 Parent(s): fcd607d

Improve mule classifier: native-NaN + missingness (CV PR-AUC 0.88->0.91, recall 13->15/16)

Browse files

- builder.py: numerics kept as NaN for LightGBM native handling; add missingness
indicators + row missing-count; IsolationForest uses a median-imputed copy
- train.py: logistic baseline imputes before scaling (NaN-safe)
- scoring.py: direction-aware narrative (cleared accounts explain what kept risk low)
- rings.py: median-impute features before k-NN similarity graph (NaN-safe)
- dashboard: center gauge value; remove fabricated channel/feed-source panels;
add real risk-tier breakdown
- regenerate model/pipeline/metadata/threshold/feature-list/rings/alert-feed

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

artifacts/feature_list.json CHANGED
@@ -1,97 +1,111 @@
1
  [
2
  "anomaly_score",
3
- "F3908",
4
- "F3811",
5
- "F3493",
6
  "F3898",
 
 
 
7
  "F3805",
8
- "F2637",
9
- "F1921",
10
- "F270",
11
- "F3801",
12
  "F2489",
 
 
 
 
 
 
13
  "F3484",
14
- "F3914",
15
- "F3804",
16
- "F3831",
17
  "F1819",
18
- "F3872",
19
- "F3873",
20
- "F1597",
21
- "F1813",
22
- "F3799",
23
  "F3806",
24
  "F3810",
25
- "F3499",
26
- "F3748",
27
- "F3813",
28
- "F3105",
29
- "F114",
30
- "F2486",
31
- "F3825",
32
  "F3907",
33
- "F3922",
34
- "F156",
35
- "F2344",
 
 
 
36
  "F2182",
37
- "F3535",
38
  "F3532",
39
- "F421",
40
- "F3526",
41
- "F3913",
42
- "F1602",
43
- "F3894",
44
- "F1821",
45
- "F3812",
46
- "F3753",
47
- "F3751",
48
- "F2638",
49
  "F949",
50
- "F1814",
51
- "F3055",
52
- "F3537",
 
53
  "F364",
54
- "F2581",
55
- "F3798",
56
- "F2543",
57
- "F2949",
58
  "F1166",
59
- "F2076",
60
- "F3800",
61
- "F1057",
62
- "F3640",
63
- "F2737",
64
- "F3056",
65
- "F950",
66
- "F3919",
67
- "F1815",
68
- "F64",
69
- "F267",
70
  "F3920",
71
- "F2293",
72
- "F422",
73
- "F2350",
74
- "F3807",
75
- "F2891",
76
- "F3871",
77
- "F3817",
78
- "F3819",
79
  "F1825",
 
 
 
 
80
  "F1812",
 
 
 
 
 
 
 
 
 
 
81
  "F1863",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  "F115",
 
83
  "F1692",
 
84
  "F2082",
 
85
  "F2122",
 
86
  "F2582",
 
87
  "F2678",
 
 
 
88
  "F2956",
 
89
  "F3043",
 
90
  "F321",
 
91
  "F3836",
 
92
  "F3887",
 
93
  "F3888_age_days",
94
  "F3889_recency_ord",
 
95
  "F3891_agriculture",
96
  "F3891_housewife",
97
  "F3891_others",
@@ -99,7 +113,11 @@
99
  "F3891_salaried",
100
  "F3891_selfemployed",
101
  "F3891_student",
 
102
  "F527",
 
103
  "F531",
104
- "F670"
 
 
105
  ]
 
1
  [
2
  "anomaly_score",
3
+ "F3105",
 
 
4
  "F3898",
5
+ "F3811",
6
+ "F3908",
7
+ "F3914",
8
  "F3805",
9
+ "F3748",
 
 
 
10
  "F2489",
11
+ "missing_count",
12
+ "F3493",
13
+ "F270",
14
+ "F3813",
15
+ "F3395",
16
+ "F3348",
17
  "F3484",
 
 
 
18
  "F1819",
 
 
 
 
 
19
  "F3806",
20
  "F3810",
 
 
 
 
 
 
 
21
  "F3907",
22
+ "F1813",
23
+ "F3812",
24
+ "F267",
25
+ "F3799",
26
+ "F3801",
27
+ "F3526",
28
  "F2182",
 
29
  "F3532",
30
+ "F3640",
 
 
 
 
 
 
 
 
 
31
  "F949",
32
+ "F1921",
33
+ "F2637",
34
+ "F119",
35
+ "F3894",
36
  "F364",
37
+ "F2349",
 
 
 
38
  "F1166",
 
 
 
 
 
 
 
 
 
 
 
39
  "F3920",
40
+ "F156",
41
+ "F2076",
42
+ "F3499",
43
+ "F1165",
 
 
 
 
44
  "F1825",
45
+ "F3798",
46
+ "F2999",
47
+ "F3922",
48
+ "F3108",
49
  "F1812",
50
+ "F2581",
51
+ "F2390",
52
+ "F421",
53
+ "F3873",
54
+ "F2486",
55
+ "F3703",
56
+ "F261",
57
+ "F2350",
58
+ "F3796",
59
+ "F3804",
60
  "F1863",
61
+ "F3900",
62
+ "F3830",
63
+ "F471",
64
+ "F162",
65
+ "F2738",
66
+ "F3494",
67
+ "F2686",
68
+ "F556",
69
+ "F3240",
70
+ "F3351",
71
+ "F3534",
72
+ "F2891",
73
+ "F1057",
74
+ "F1861",
75
+ "F3488",
76
+ "F3825",
77
+ "F1597",
78
+ "F2064",
79
+ "F3807",
80
+ "F3401",
81
+ "F1598",
82
  "F115",
83
+ "F115__isna",
84
  "F1692",
85
+ "F1692__isna",
86
  "F2082",
87
+ "F2082__isna",
88
  "F2122",
89
+ "F2122__isna",
90
  "F2582",
91
+ "F2582__isna",
92
  "F2678",
93
+ "F2678__isna",
94
+ "F2737",
95
+ "F2737__isna",
96
  "F2956",
97
+ "F2956__isna",
98
  "F3043",
99
+ "F3043__isna",
100
  "F321",
101
+ "F321__isna",
102
  "F3836",
103
+ "F3836__isna",
104
  "F3887",
105
+ "F3887__isna",
106
  "F3888_age_days",
107
  "F3889_recency_ord",
108
+ "F3889_recency_ord__isna",
109
  "F3891_agriculture",
110
  "F3891_housewife",
111
  "F3891_others",
 
113
  "F3891_salaried",
114
  "F3891_selfemployed",
115
  "F3891_student",
116
+ "F3894__isna",
117
  "F527",
118
+ "F527__isna",
119
  "F531",
120
+ "F531__isna",
121
+ "F670",
122
+ "F670__isna"
123
  ]
artifacts/feature_pipeline.pkl CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:7f1e3a1d81184b9417151f77fbe5b38abb634bf21f595ba393e459643a907a90
3
- size 7536812
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b61c2596fdf4270bcdf6d680aed8ce1a7e4d1f383ad8dd8b29f7d917c12bd8aa
3
+ size 7716093
artifacts/metadata.json CHANGED
@@ -1,6 +1,6 @@
1
  {
2
  "model": "CalibratedClassifierCV(LightGBM, sigmoid) fused with IsolationForest anomaly score",
3
- "n_features": 103,
4
  "scale_pos_weight": 110.76923076923077,
5
  "seed": 42,
6
  "leakage_excluded": [
@@ -8,39 +8,39 @@
8
  "F2230"
9
  ],
10
  "cv_metrics": {
11
- "oof_pr_auc": 0.8839458460565208,
12
- "oof_roc_auc": 0.9866282051282051,
13
- "cv_pr_auc_mean": 0.8824602773017075,
14
- "cv_pr_auc_std": 0.07097233877977675,
15
- "cv_roc_auc_mean": 0.9859375000000001,
16
- "cv_roc_auc_std": 0.012452607980646804
17
  },
18
  "threshold_info": {
19
- "threshold": 0.10590650187102484,
20
- "f2_at_threshold": 0.8558558558558558,
21
- "precision_at_threshold": 0.7808219178082192,
22
  "recall_at_threshold": 0.8769230769230769,
23
- "recall_at_precision_0.30": 0.9384615384615385,
24
  "recall_at_precision_0.50": 0.9076923076923077,
25
- "recall_at_precision_0.70": 0.8769230769230769
26
  },
27
  "logistic_baseline": {
28
- "cv_pr_auc": 0.3893039380004367,
29
- "cv_roc_auc": 0.9719978632478632
30
  },
31
  "test_metrics": {
32
- "pr_auc": 0.9563001336898396,
33
- "roc_auc": 0.9995141588006663,
34
- "precision": 0.8666666666666667,
35
- "recall": 0.8125,
36
- "f2": 0.8227848101265823,
37
  "confusion_matrix": {
38
- "tn": 1799,
39
- "fp": 2,
40
- "fn": 3,
41
- "tp": 13
42
  },
43
- "alerts_raised": 15,
44
- "alert_rate": 0.00825536598789213
45
  }
46
  }
 
1
  {
2
  "model": "CalibratedClassifierCV(LightGBM, sigmoid) fused with IsolationForest anomaly score",
3
+ "n_features": 121,
4
  "scale_pos_weight": 110.76923076923077,
5
  "seed": 42,
6
  "leakage_excluded": [
 
8
  "F2230"
9
  ],
10
  "cv_metrics": {
11
+ "oof_pr_auc": 0.8959478273631826,
12
+ "oof_roc_auc": 0.9792606837606839,
13
+ "cv_pr_auc_mean": 0.9122952152228322,
14
+ "cv_pr_auc_std": 0.05931175025937131,
15
+ "cv_roc_auc_mean": 0.9869364316239316,
16
+ "cv_roc_auc_std": 0.014505969017386374
17
  },
18
  "threshold_info": {
19
+ "threshold": 0.20996241663498982,
20
+ "f2_at_threshold": 0.8796296296296294,
21
+ "precision_at_threshold": 0.890625,
22
  "recall_at_threshold": 0.8769230769230769,
23
+ "recall_at_precision_0.30": 0.9076923076923077,
24
  "recall_at_precision_0.50": 0.9076923076923077,
25
+ "recall_at_precision_0.70": 0.9076923076923077
26
  },
27
  "logistic_baseline": {
28
+ "cv_pr_auc": 0.4184005172976527,
29
+ "cv_roc_auc": 0.9590064102564103
30
  },
31
  "test_metrics": {
32
+ "pr_auc": 0.9924172794117647,
33
+ "roc_auc": 0.9999305941143809,
34
+ "precision": 0.9375,
35
+ "recall": 0.9375,
36
+ "f2": 0.9375,
37
  "confusion_matrix": {
38
+ "tn": 1800,
39
+ "fp": 1,
40
+ "fn": 1,
41
+ "tp": 15
42
  },
43
+ "alerts_raised": 16,
44
+ "alert_rate": 0.008805723720418272
45
  }
46
  }
artifacts/model.pkl CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:881965b596fff68c8936f41c3d0b5fecf76db3985fe57f7947b083e3cacad575
3
- size 3121976
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:dcfc31fa6cfc548f64dfeb34f66d465bdb1bda0782bed6c536f900edded102d5
3
+ size 3022316
artifacts/ring_graph.json CHANGED
@@ -1 +1 @@
1
- {"nodes": [{"id": 285, "acc": "ACC-000285", "x": 0.2419826690282192, "y": 0.44777145146412217, "risk": 55.1, "tier": "MEDIUM", "mule": 0, "ring": 3}, {"id": 1316, "acc": "ACC-001316", "x": -0.16881476913597523, "y": 0.4509765307015742, "risk": 53.3, "tier": "MEDIUM", "mule": 0, "ring": 3}, {"id": 2811, "acc": "ACC-002811", "x": -0.6988318501541344, "y": -0.32424176162466206, "risk": 55.2, "tier": "MEDIUM", "mule": 0, "ring": 2}, {"id": 9001, "acc": "ACC-009001", "x": 0.3043538600442867, "y": 0.1971660879337363, "risk": 98.9, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9002, "acc": "ACC-009002", "x": -0.3523605317563945, "y": -0.15846699167553507, "risk": 99.2, "tier": "CRITICAL", "mule": 1, "ring": 2}, {"id": 9003, "acc": "ACC-009003", "x": -0.419827233748121, "y": -0.38474338163215893, "risk": 99.2, "tier": "CRITICAL", "mule": 1, "ring": 2}, {"id": 9004, "acc": "ACC-009004", "x": -0.1123957643283365, "y": 0.04531783865024353, "risk": 98.7, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9005, "acc": "ACC-009005", "x": 0.4039307202197782, "y": 0.15123938216910435, "risk": 99.2, "tier": "CRITICAL", "mule": 1, "ring": 4}, {"id": 9006, "acc": "ACC-009006", "x": -0.25827687733159393, "y": -0.2389239950616756, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 2}, {"id": 9007, "acc": "ACC-009007", "x": -0.1236433713513943, "y": 0.11656873813712151, "risk": 96.8, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9008, "acc": "ACC-009008", "x": 0.4952062067087485, "y": 0.07953650142998826, "risk": 99.1, "tier": "CRITICAL", "mule": 1, "ring": 4}, {"id": 9009, "acc": "ACC-009009", "x": 0.13847676404166856, "y": 0.3202596246567417, "risk": 99.1, "tier": "CRITICAL", "mule": 1, "ring": 3}, {"id": 9010, "acc": "ACC-009010", "x": -0.18221978037607478, "y": 0.5357334745468432, "risk": 88.5, "tier": "CRITICAL", "mule": 1, "ring": 3}, {"id": 9011, "acc": "ACC-009011", "x": 0.19617686326810016, "y": -0.0024530122783164676, "risk": 99.2, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9012, "acc": "ACC-009012", "x": 0.39390133323954135, "y": 0.046596528396413844, "risk": 68.6, "tier": "MEDIUM", "mule": 1, "ring": 1}, {"id": 9013, "acc": "ACC-009013", "x": 0.3617690032006379, "y": 0.3042641049146207, "risk": 99.1, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9014, "acc": "ACC-009014", "x": 0.2428962238305574, "y": 0.20920132135873648, "risk": 99.1, "tier": "CRITICAL", "mule": 1, "ring": 3}, {"id": 9015, "acc": "ACC-009015", "x": -0.03369462931485818, "y": -0.42167833292119106, "risk": 88.6, "tier": "CRITICAL", "mule": 1, "ring": 2}, {"id": 9016, "acc": "ACC-009016", "x": 0.17885344473104645, "y": -0.05158476673770526, "risk": 99.1, "tier": "CRITICAL", "mule": 1, "ring": 3}, {"id": 9017, "acc": "ACC-009017", "x": 0.2250655086119152, "y": -0.32485541441085697, "risk": 99.2, "tier": "CRITICAL", "mule": 1, "ring": 4}, {"id": 9018, "acc": "ACC-009018", "x": -0.0688478361342121, "y": 0.1909398647609103, "risk": 99.1, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9019, "acc": "ACC-009019", "x": 0.08832134023946228, "y": 0.08623231325189323, "risk": 99.2, "tier": "CRITICAL", "mule": 1, "ring": 3}, {"id": 9020, "acc": "ACC-009020", "x": -0.03120571338493974, "y": 0.2541985958008338, "risk": 99.2, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9021, "acc": "ACC-009021", "x": 0.5003113611724221, "y": 0.17327566476855435, "risk": 98.4, "tier": "CRITICAL", "mule": 1, "ring": 4}, {"id": 9022, "acc": "ACC-009022", "x": 0.1530078594638951, "y": 0.17205966024136357, "risk": 99.2, "tier": "CRITICAL", "mule": 1, "ring": 3}, {"id": 9023, "acc": "ACC-009023", "x": -0.0706967260942182, "y": -0.5504940880097502, "risk": 99.2, "tier": "CRITICAL", "mule": 1, "ring": 2}, {"id": 9024, "acc": "ACC-009024", "x": -0.41170486637350534, "y": -0.09585096304411093, "risk": 99.1, "tier": "CRITICAL", "mule": 1, "ring": 2}, {"id": 9025, "acc": "ACC-009025", "x": 0.380860243159697, "y": -0.08323672203204208, "risk": 99.2, "tier": "CRITICAL", "mule": 1, "ring": 4}, {"id": 9026, "acc": "ACC-009026", "x": 0.100750595065319, "y": -0.06820353316914983, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9027, "acc": "ACC-009027", "x": -0.13522723455850552, "y": 0.3429842979387077, "risk": 90.7, "tier": "CRITICAL", "mule": 1, "ring": 3}, {"id": 9028, "acc": "ACC-009028", "x": -0.34677501160607155, "y": -0.392007532792684, "risk": 99.2, "tier": "CRITICAL", "mule": 1, "ring": 2}, {"id": 9029, "acc": "ACC-009029", "x": 0.04668146292543929, "y": 0.47926083052866364, "risk": 99.1, "tier": "CRITICAL", "mule": 1, "ring": 3}, {"id": 9030, "acc": "ACC-009030", "x": -0.0018478557645538903, "y": -0.11958635590635118, "risk": 88.5, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9031, "acc": "ACC-009031", "x": -0.07806948894643906, "y": -0.023186288279780407, "risk": 87.2, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9032, "acc": "ACC-009032", "x": 0.0014590509999882803, "y": 0.35946592496150154, "risk": 99.1, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9033, "acc": "ACC-009033", "x": 0.050892159485209636, "y": -0.551347750254027, "risk": 99.2, "tier": "CRITICAL", "mule": 1, "ring": 4}, {"id": 9034, "acc": "ACC-009034", "x": -0.34659652970481114, "y": -0.47529323471943835, "risk": 99.2, "tier": "CRITICAL", "mule": 1, "ring": 2}, {"id": 9036, "acc": "ACC-009036", "x": -0.40782100134222565, "y": -0.24992371872954894, "risk": 99.2, "tier": "CRITICAL", "mule": 1, "ring": 2}, {"id": 9037, "acc": "ACC-009037", "x": 0.6077766764521862, "y": -0.15919857808288493, "risk": 99.2, "tier": "CRITICAL", "mule": 1, "ring": 4}, {"id": 9038, "acc": "ACC-009038", "x": 0.02745911935502191, "y": -0.33198807443552475, "risk": 99.2, "tier": "CRITICAL", "mule": 1, "ring": 4}, {"id": 9040, "acc": "ACC-009040", "x": 0.577381161462815, "y": -0.07796831849830921, "risk": 88.2, "tier": "CRITICAL", "mule": 1, "ring": 4}, {"id": 9041, "acc": "ACC-009041", "x": -0.17730530321989643, "y": -0.6604989916721671, "risk": 99.2, "tier": "CRITICAL", "mule": 1, "ring": 2}, {"id": 9042, "acc": "ACC-009042", "x": -0.19900732021104367, "y": -0.5432038603533638, "risk": 99.2, "tier": "CRITICAL", "mule": 1, "ring": 2}, {"id": 9043, "acc": "ACC-009043", "x": -0.09878418741459578, "y": 0.4231071695650307, "risk": 76.9, "tier": "HIGH", "mule": 1, "ring": 3}, {"id": 9044, "acc": "ACC-009044", "x": 0.4437039348003288, "y": -0.3326896268579162, "risk": 99.2, "tier": "CRITICAL", "mule": 1, "ring": 4}, {"id": 9045, "acc": "ACC-009045", "x": 0.4945414634228577, "y": -0.19027612904562347, "risk": 99.2, "tier": "CRITICAL", "mule": 1, "ring": 4}, {"id": 9046, "acc": "ACC-009046", "x": 0.3884456220456726, "y": 0.38420236306682726, "risk": 99.2, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9047, "acc": "ACC-009047", "x": 0.47608167362147247, "y": -0.27452211792513403, "risk": 88.1, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9048, "acc": "ACC-009048", "x": 0.31345308385768644, "y": 0.015441940182401876, "risk": 87.6, "tier": "CRITICAL", "mule": 1, "ring": 3}, {"id": 9049, "acc": "ACC-009049", "x": -0.5017382774185501, "y": -0.31633484628309666, "risk": 99.2, "tier": "CRITICAL", "mule": 1, "ring": 2}, {"id": 9050, "acc": "ACC-009050", "x": 0.08267258037129373, "y": 0.37605644891003964, "risk": 88.6, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9051, "acc": "ACC-009051", "x": -0.5615204523795383, "y": -0.25555834501536623, "risk": 96.3, "tier": "CRITICAL", "mule": 1, "ring": 2}, {"id": 9052, "acc": "ACC-009052", "x": 0.40099281976419476, "y": -0.21586873352446714, "risk": 99.2, "tier": "CRITICAL", "mule": 1, "ring": 4}, {"id": 9053, "acc": "ACC-009053", "x": 0.14240454183277815, "y": 0.10215590507910702, "risk": 93.2, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9054, "acc": "ACC-009054", "x": -0.25227820024786346, "y": 0.053943937650685995, "risk": 99.2, "tier": "CRITICAL", "mule": 1, "ring": 2}, {"id": 9055, "acc": "ACC-009055", "x": -0.7147696641037072, "y": -0.20814615538930936, "risk": 99.1, "tier": "CRITICAL", "mule": 1, "ring": 2}, {"id": 9056, "acc": "ACC-009056", "x": 0.5619304509905689, "y": 0.2933162142750889, "risk": 99.2, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9057, "acc": "ACC-009057", "x": 0.32035734500579377, "y": 0.4178553201837094, "risk": 88.2, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9058, "acc": "ACC-009058", "x": 0.015306481719341819, "y": -0.05086900051980521, "risk": 99.2, "tier": "CRITICAL", "mule": 1, "ring": 3}, {"id": 9059, "acc": "ACC-009059", "x": 0.19878680858003192, "y": 0.5797838676964551, "risk": 88.2, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9060, "acc": "ACC-009060", "x": -0.12436693603324184, "y": -0.25588765896544635, "risk": 98.9, "tier": "CRITICAL", "mule": 1, "ring": 2}, {"id": 9061, "acc": "ACC-009061", "x": 0.16376491658603876, "y": -0.4659552258948993, "risk": 99.2, "tier": "CRITICAL", "mule": 1, "ring": 4}, {"id": 9062, "acc": "ACC-009062", "x": -0.9326637246567073, "y": 0.11372733727137188, "risk": 99.2, "tier": "CRITICAL", "mule": 1, "ring": 0}, {"id": 9063, "acc": "ACC-009063", "x": 0.5683519367769582, "y": 0.7883589795240052, "risk": 92.8, "tier": "CRITICAL", "mule": 1, "ring": 0}, {"id": 9064, "acc": "ACC-009064", "x": -0.3838098475701112, "y": -0.3025601477971763, "risk": 99.2, "tier": "CRITICAL", "mule": 1, "ring": 2}, {"id": 9065, "acc": "ACC-009065", "x": -0.1824168853111868, "y": -0.08631103192564536, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 2}, {"id": 9066, "acc": "ACC-009066", "x": -0.6320643310528762, "y": 0.20238354233564154, "risk": 88.6, "tier": "CRITICAL", "mule": 1, "ring": 3}, {"id": 9067, "acc": "ACC-009067", "x": -0.4112494551099628, "y": 0.09095195904640295, "risk": 88.0, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9068, "acc": "ACC-009068", "x": 0.30644556749471036, "y": 0.3032240624850035, "risk": 98.9, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9070, "acc": "ACC-009070", "x": 0.19517775084901556, "y": 0.2028460367077606, "risk": 93.9, "tier": "CRITICAL", "mule": 1, "ring": 3}, {"id": 9071, "acc": "ACC-009071", "x": 0.2756209789843878, "y": -0.3765362402271118, "risk": 99.2, "tier": "CRITICAL", "mule": 1, "ring": 4}, {"id": 9072, "acc": "ACC-009072", "x": 0.570817571228798, "y": 0.10382228432625919, "risk": 88.6, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9073, "acc": "ACC-009073", "x": -0.4763717177181108, "y": -0.4439161679094066, "risk": 99.2, "tier": "CRITICAL", "mule": 1, "ring": 2}, {"id": 9074, "acc": "ACC-009074", "x": -0.45922963961529906, "y": -0.016059051713056695, "risk": 99.2, "tier": "CRITICAL", "mule": 1, "ring": 2}, {"id": 9075, "acc": "ACC-009075", "x": -0.5761546389362234, "y": -0.4075873382336465, "risk": 99.1, "tier": "CRITICAL", "mule": 1, "ring": 2}, {"id": 9076, "acc": "ACC-009076", "x": 0.6464184848398064, "y": 0.06970549726169974, "risk": 89.0, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9077, "acc": "ACC-009077", "x": -0.9999999999999999, "y": 0.09565603057618935, "risk": 88.0, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9078, "acc": "ACC-009078", "x": 0.07948397928061847, "y": 0.23958769004718988, "risk": 88.2, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9079, "acc": "ACC-009079", "x": -0.5203153665383611, "y": -0.24377143913693902, "risk": 99.1, "tier": "CRITICAL", "mule": 1, "ring": 2}, {"id": 9080, "acc": "ACC-009080", "x": 0.12486263389011727, "y": 0.42375651098546724, "risk": 99.2, "tier": "CRITICAL", "mule": 1, "ring": 3}, {"id": 9081, "acc": "ACC-009081", "x": -0.03423123370478958, "y": 0.4888530888972661, "risk": 90.1, "tier": "CRITICAL", "mule": 1, "ring": 1}], "edges": [[0, 16], [0, 7], [0, 43], [0, 79], [0, 23], [0, 29], [1, 9], [1, 12], [1, 31], [1, 54], [1, 77], [2, 5], [2, 26], [2, 51], [2, 37], [2, 4], [3, 14], [3, 77], [3, 68], [3, 71], [3, 65], [3, 57], [3, 75], [3, 80], [4, 64], [4, 54], [4, 30], [4, 36], [4, 72], [4, 6], [4, 22], [4, 49], [4, 51], [4, 53], [4, 55], [4, 65], [4, 73], [4, 74], [4, 78], [5, 37], [5, 26], [5, 30], [5, 64], [5, 39], [5, 8], [5, 42], [5, 55], [6, 53], [6, 13], [6, 54], [6, 21], [6, 22], [7, 23], [7, 27], [7, 52], [7, 15], [7, 79], [7, 10], [8, 17], [8, 36], [8, 42], [8, 78], [9, 20], [9, 28], [9, 32], [9, 77], [9, 13], [9, 67], [10, 38], [10, 27], [10, 15], [10, 13], [10, 45], [10, 50], [10, 56], [11, 21], [11, 79], [11, 16], [11, 58], [11, 24], [11, 59], [12, 31], [12, 43], [12, 79], [12, 54], [12, 77], [13, 54], [13, 60], [13, 38], [13, 14], [13, 28], [13, 32], [13, 33], [13, 47], [13, 53], [13, 57], [13, 75], [13, 77], [14, 53], [14, 56], [14, 68], [14, 17], [14, 46], [14, 71], [14, 75], [15, 22], [15, 23], [15, 27], [15, 34], [15, 50], [15, 56], [15, 59], [16, 24], [16, 48], [16, 27], [16, 18], [16, 21], [16, 23], [16, 29], [16, 31], [16, 40], [16, 43], [17, 36], [17, 42], [17, 41], [18, 24], [18, 48], [18, 39], [18, 58], [18, 69], [19, 61], [19, 52], [19, 70], [19, 35], [19, 27], [19, 21], [19, 39], [20, 22], [20, 68], [20, 28], [20, 32], [20, 33], [20, 50], [20, 67], [20, 80], [21, 79], [21, 24], [21, 58], [21, 31], [21, 39], [21, 69], [22, 54], [22, 34], [22, 50], [22, 59], [22, 68], [23, 27], [23, 52], [24, 48], [24, 27], [24, 29], [24, 31], [24, 58], [24, 69], [24, 79], [25, 36], [25, 42], [25, 41], [25, 30], [25, 61], [26, 37], [26, 58], [26, 78], [26, 64], [26, 34], [26, 51], [26, 55], [27, 70], [27, 38], [27, 40], [27, 45], [27, 48], [27, 52], [27, 61], [28, 77], [28, 32], [28, 33], [28, 44], [29, 79], [29, 43], [29, 66], [29, 67], [30, 64], [30, 36], [30, 78], [30, 49], [30, 35], [30, 51], [30, 60], [30, 72], [30, 73], [30, 74], [31, 43], [31, 79], [31, 50], [32, 33], [32, 44], [32, 67], [33, 77], [34, 46], [34, 68], [35, 61], [35, 70], [35, 39], [35, 42], [36, 64], [36, 78], [36, 49], [36, 41], [36, 42], [36, 72], [37, 78], [37, 58], [37, 64], [37, 42], [38, 45], [38, 40], [38, 44], [39, 61], [40, 45], [40, 48], [40, 44], [41, 42], [41, 61], [41, 64], [42, 61], [43, 79], [43, 66], [43, 77], [44, 45], [46, 56], [46, 77], [46, 53], [47, 60], [48, 58], [48, 69], [49, 64], [49, 54], [51, 78], [51, 64], [51, 55], [51, 60], [51, 73], [51, 74], [52, 70], [52, 61], [53, 56], [53, 57], [53, 65], [54, 64], [54, 73], [54, 60], [54, 65], [54, 67], [54, 80], [55, 78], [57, 77], [57, 79], [58, 70], [58, 69], [59, 68], [59, 79], [59, 80], [60, 64], [61, 70], [64, 72], [64, 73], [64, 78], [65, 77], [65, 72], [65, 74], [67, 76], [68, 71], [68, 75], [71, 75], [71, 77], [72, 74], [77, 80]]}
 
1
+ {"nodes": [{"id": 285, "acc": "ACC-000285", "x": 0.07318200884179325, "y": 0.04652564221367404, "risk": 55.2, "tier": "MEDIUM", "mule": 0, "ring": 2}, {"id": 9001, "acc": "ACC-009001", "x": 0.7382459987626354, "y": -0.25736316561299927, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 6}, {"id": 9002, "acc": "ACC-009002", "x": -0.0043691986832889735, "y": 0.16617286145828095, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9003, "acc": "ACC-009003", "x": -0.3537614455397953, "y": 0.2120020617486719, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9004, "acc": "ACC-009004", "x": 0.27562507880665543, "y": 0.11523499745016828, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9005, "acc": "ACC-009005", "x": -0.3159904571978656, "y": 0.09990415945864455, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 5}, {"id": 9006, "acc": "ACC-009006", "x": -0.3091199209908236, "y": -0.14830341426330937, "risk": 98.9, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9007, "acc": "ACC-009007", "x": 0.23928775269119038, "y": -0.4444793084342218, "risk": 97.2, "tier": "CRITICAL", "mule": 1, "ring": 2}, {"id": 9008, "acc": "ACC-009008", "x": -0.14876815232897408, "y": -0.1370445977802355, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 3}, {"id": 9009, "acc": "ACC-009009", "x": 0.3067342154869285, "y": 0.33631312657171764, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 4}, {"id": 9010, "acc": "ACC-009010", "x": 0.2783488639728471, "y": -0.3001975032873407, "risk": 88.0, "tier": "CRITICAL", "mule": 1, "ring": 2}, {"id": 9011, "acc": "ACC-009011", "x": 0.12475563836884494, "y": -0.1203926520571536, "risk": 98.7, "tier": "CRITICAL", "mule": 1, "ring": 3}, {"id": 9013, "acc": "ACC-009013", "x": -0.22376428097318224, "y": -0.1752955111323971, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 3}, {"id": 9014, "acc": "ACC-009014", "x": 0.21657106833643933, "y": 0.20052993301008096, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 4}, {"id": 9015, "acc": "ACC-009015", "x": -0.3818484165789747, "y": -0.18567263477796817, "risk": 98.9, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9016, "acc": "ACC-009016", "x": 0.2405529603273867, "y": 0.4001232125900001, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 4}, {"id": 9017, "acc": "ACC-009017", "x": -0.3413074564608301, "y": 0.5863980004128138, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 5}, {"id": 9018, "acc": "ACC-009018", "x": 0.7678986450868037, "y": -0.11097972043293708, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 6}, {"id": 9019, "acc": "ACC-009019", "x": 0.22310779465836067, "y": 0.3684222010053199, "risk": 98.9, "tier": "CRITICAL", "mule": 1, "ring": 4}, {"id": 9020, "acc": "ACC-009020", "x": 0.1750302651284595, "y": -0.08365864223466728, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 3}, {"id": 9021, "acc": "ACC-009021", "x": -0.3565767842095951, "y": 0.005667040668998755, "risk": 89.9, "tier": "CRITICAL", "mule": 1, "ring": 5}, {"id": 9022, "acc": "ACC-009022", "x": 0.15133359224597367, "y": 0.35471352514311255, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 4}, {"id": 9023, "acc": "ACC-009023", "x": -0.48958615634835306, "y": 0.3308477400757028, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9024, "acc": "ACC-009024", "x": -0.5186787764061692, "y": 0.3828574668988502, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9025, "acc": "ACC-009025", "x": -0.16564886385909616, "y": 0.3297935358213334, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 5}, {"id": 9026, "acc": "ACC-009026", "x": 0.3141816960846406, "y": -0.445618608080559, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 2}, {"id": 9027, "acc": "ACC-009027", "x": 0.40631827999622655, "y": -0.034964967088124084, "risk": 86.6, "tier": "CRITICAL", "mule": 1, "ring": 2}, {"id": 9028, "acc": "ACC-009028", "x": -0.227092588297968, "y": 0.31710040203857737, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9029, "acc": "ACC-009029", "x": -0.31472548520091675, "y": -0.7085596741168768, "risk": 98.9, "tier": "CRITICAL", "mule": 1, "ring": 2}, {"id": 9030, "acc": "ACC-009030", "x": 0.19173942531211358, "y": -0.5463984310296066, "risk": 86.6, "tier": "CRITICAL", "mule": 1, "ring": 2}, {"id": 9031, "acc": "ACC-009031", "x": 0.2711599460374145, "y": -0.5772236188461646, "risk": 89.9, "tier": "CRITICAL", "mule": 1, "ring": 2}, {"id": 9032, "acc": "ACC-009032", "x": 0.07945536092236948, "y": -0.1370327372340594, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 3}, {"id": 9033, "acc": "ACC-009033", "x": -0.33142334422581937, "y": 0.4944057880986874, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 5}, {"id": 9034, "acc": "ACC-009034", "x": -0.2545895459007579, "y": 0.13237601600249388, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9035, "acc": "ACC-009035", "x": 0.30212670726691354, "y": 0.19243880633190585, "risk": 64.2, "tier": "MEDIUM", "mule": 1, "ring": 1}, {"id": 9036, "acc": "ACC-009036", "x": -0.4514705515749587, "y": 0.24628159240951794, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9037, "acc": "ACC-009037", "x": -0.2076923492189214, "y": -0.08904721207207007, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 3}, {"id": 9038, "acc": "ACC-009038", "x": -0.026931839008019057, "y": 0.5661639914684745, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 4}, {"id": 9039, "acc": "ACC-009039", "x": 0.4260402666504279, "y": -0.9377915219800559, "risk": 60.4, "tier": "MEDIUM", "mule": 1, "ring": 0}, {"id": 9040, "acc": "ACC-009040", "x": -0.2390419519099508, "y": 0.24335088567134983, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 5}, {"id": 9041, "acc": "ACC-009041", "x": -0.22445079500778775, "y": 0.08493730894286328, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 3}, {"id": 9042, "acc": "ACC-009042", "x": -0.5418916192262342, "y": 0.14296432180210256, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9043, "acc": "ACC-009043", "x": 0.19668206417026562, "y": -0.3070396039841842, "risk": 53.4, "tier": "MEDIUM", "mule": 1, "ring": 2}, {"id": 9044, "acc": "ACC-009044", "x": -0.06272545501004455, "y": -0.13508749449029514, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 3}, {"id": 9045, "acc": "ACC-009045", "x": -0.08026368932955012, "y": 0.003735639310192398, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 3}, {"id": 9046, "acc": "ACC-009046", "x": -0.0695260201512459, "y": -0.3391384757758756, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 3}, {"id": 9047, "acc": "ACC-009047", "x": 0.19533091444133435, "y": 0.2603897980900428, "risk": 86.8, "tier": "CRITICAL", "mule": 1, "ring": 3}, {"id": 9048, "acc": "ACC-009048", "x": 0.20364933467584645, "y": 0.5371208896234837, "risk": 97.6, "tier": "CRITICAL", "mule": 1, "ring": 4}, {"id": 9049, "acc": "ACC-009049", "x": 0.0023455759362088894, "y": 0.3063373033268076, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9050, "acc": "ACC-009050", "x": 0.03758659075256554, "y": -0.45390983980940486, "risk": 95.8, "tier": "CRITICAL", "mule": 1, "ring": 3}, {"id": 9051, "acc": "ACC-009051", "x": -0.415821383524402, "y": 0.18808633500101216, "risk": 93.9, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9052, "acc": "ACC-009052", "x": -0.3948346839946454, "y": 0.42729659301123923, "risk": 98.8, "tier": "CRITICAL", "mule": 1, "ring": 5}, {"id": 9053, "acc": "ACC-009053", "x": 0.5588753539765322, "y": -0.024968591377306743, "risk": 98.3, "tier": "CRITICAL", "mule": 1, "ring": 6}, {"id": 9054, "acc": "ACC-009054", "x": 0.12342350607641277, "y": 0.09660094739687185, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9055, "acc": "ACC-009055", "x": -0.533811911843638, "y": 0.030391268419109916, "risk": 97.0, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9056, "acc": "ACC-009056", "x": -0.3597550336558368, "y": -0.3247845635201574, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 3}, {"id": 9057, "acc": "ACC-009057", "x": 0.4744408701372476, "y": -0.38577999472348734, "risk": 86.6, "tier": "CRITICAL", "mule": 1, "ring": 2}, {"id": 9058, "acc": "ACC-009058", "x": -0.05632253989078768, "y": 0.4905361229063307, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 4}, {"id": 9059, "acc": "ACC-009059", "x": 0.791125638281935, "y": -0.020126950299430905, "risk": 86.6, "tier": "CRITICAL", "mule": 1, "ring": 6}, {"id": 9060, "acc": "ACC-009060", "x": 0.014649412450743105, "y": 0.10996860743316107, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9061, "acc": "ACC-009061", "x": -0.2646912465119761, "y": 0.5238532120805128, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 5}, {"id": 9062, "acc": "ACC-009062", "x": -0.2125232763770292, "y": -1.0, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 0}, {"id": 9063, "acc": "ACC-009063", "x": -0.9069933559037844, "y": -0.3452091210122438, "risk": 86.7, "tier": "CRITICAL", "mule": 1, "ring": 0}, {"id": 9064, "acc": "ACC-009064", "x": -0.10804423708836668, "y": 0.1957559882449757, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9065, "acc": "ACC-009065", "x": -0.17971566606851674, "y": 0.14047866509547718, "risk": 98.8, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9066, "acc": "ACC-009066", "x": 0.597496311901886, "y": -0.45750066547708573, "risk": 98.4, "tier": "CRITICAL", "mule": 1, "ring": 2}, {"id": 9067, "acc": "ACC-009067", "x": -0.04088624567010987, "y": -0.49983214079826416, "risk": 86.5, "tier": "CRITICAL", "mule": 1, "ring": 2}, {"id": 9068, "acc": "ACC-009068", "x": 0.9313740438590021, "y": -0.1978217616689529, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 6}, {"id": 9069, "acc": "ACC-009069", "x": 0.3610166974588172, "y": -0.22522940788538479, "risk": 55.9, "tier": "MEDIUM", "mule": 1, "ring": 2}, {"id": 9070, "acc": "ACC-009070", "x": 0.22164047028902176, "y": 0.6400448851826231, "risk": 97.1, "tier": "CRITICAL", "mule": 1, "ring": 4}, {"id": 9071, "acc": "ACC-009071", "x": -0.19230139130797935, "y": 0.4874907951784373, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 5}, {"id": 9072, "acc": "ACC-009072", "x": 0.7019007483404354, "y": -0.39865968758085174, "risk": 86.6, "tier": "CRITICAL", "mule": 1, "ring": 6}, {"id": 9073, "acc": "ACC-009073", "x": -0.30356142098354505, "y": 0.24321401314324084, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9074, "acc": "ACC-009074", "x": 0.02043804927286256, "y": 0.4015517124497672, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9075, "acc": "ACC-009075", "x": -0.4152915969478865, "y": 0.28736817520132957, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9076, "acc": "ACC-009076", "x": 0.4033204570624954, "y": -0.1352354751056307, "risk": 87.6, "tier": "CRITICAL", "mule": 1, "ring": 6}, {"id": 9077, "acc": "ACC-009077", "x": -0.6900671025500293, "y": -0.39496104066083215, "risk": 86.5, "tier": "CRITICAL", "mule": 1, "ring": 2}, {"id": 9078, "acc": "ACC-009078", "x": 0.33178150429917685, "y": -0.39294505131740554, "risk": 90.1, "tier": "CRITICAL", "mule": 1, "ring": 2}, {"id": 9079, "acc": "ACC-009079", "x": -0.4617515198605906, "y": 0.12028233393422998, "risk": 99.0, "tier": "CRITICAL", "mule": 1, "ring": 1}, {"id": 9080, "acc": "ACC-009080", "x": 0.26713812040122, "y": -0.08770911186019935, "risk": 98.8, "tier": "CRITICAL", "mule": 1, "ring": 2}, {"id": 9081, "acc": "ACC-009081", "x": -0.058293472950189176, "y": -0.2800650045144513, "risk": 92.1, "tier": "CRITICAL", "mule": 1, "ring": 2}], "edges": [[0, 79], [0, 13], [0, 42], [0, 18], [0, 5], [0, 26], [1, 71], [1, 77], [1, 52], [1, 67], [1, 58], [1, 75], [2, 63], [2, 53], [2, 11], [2, 59], [2, 27], [2, 4], [2, 19], [2, 33], [2, 34], [2, 48], [2, 50], [2, 52], [2, 64], [2, 72], [2, 73], [2, 74], [3, 35], [3, 27], [3, 72], [3, 37], [3, 63], [3, 6], [3, 14], [3, 23], [3, 41], [3, 74], [4, 11], [4, 53], [4, 18], [4, 63], [4, 52], [5, 20], [5, 24], [5, 51], [5, 12], [5, 39], [5, 80], [6, 14], [6, 33], [6, 40], [6, 72], [7, 10], [7, 25], [7, 79], [7, 11], [7, 29], [7, 56], [7, 66], [8, 36], [8, 44], [8, 11], [8, 12], [8, 24], [8, 40], [8, 43], [8, 49], [8, 55], [9, 13], [9, 18], [9, 57], [9, 21], [9, 69], [9, 26], [9, 31], [9, 46], [10, 77], [10, 79], [10, 25], [10, 53], [10, 80], [11, 53], [11, 63], [11, 36], [11, 19], [11, 25], [11, 29], [11, 30], [11, 43], [11, 44], [11, 45], [11, 46], [11, 52], [11, 59], [11, 64], [11, 68], [11, 75], [12, 19], [12, 20], [12, 36], [12, 31], [12, 41], [12, 49], [12, 55], [13, 21], [13, 47], [13, 18], [13, 24], [13, 15], [13, 26], [13, 42], [13, 79], [14, 40], [14, 33], [14, 78], [15, 21], [15, 37], [15, 47], [15, 18], [16, 60], [16, 32], [16, 70], [16, 51], [16, 24], [16, 39], [17, 67], [17, 58], [17, 52], [17, 19], [18, 21], [18, 47], [18, 79], [18, 69], [19, 53], [19, 21], [19, 31], [19, 45], [19, 49], [20, 24], [20, 39], [20, 51], [20, 45], [20, 55], [21, 47], [21, 24], [21, 37], [21, 57], [21, 69], [21, 79], [22, 33], [22, 27], [22, 41], [22, 40], [22, 63], [23, 35], [23, 57], [23, 50], [23, 78], [23, 54], [24, 39], [24, 70], [24, 32], [24, 51], [24, 60], [25, 77], [25, 68], [25, 42], [25, 29], [25, 30], [25, 49], [25, 56], [25, 71], [25, 80], [26, 79], [26, 42], [26, 65], [27, 63], [27, 48], [27, 33], [27, 72], [27, 73], [27, 78], [28, 42], [29, 77], [29, 30], [29, 42], [29, 66], [30, 42], [30, 77], [30, 49], [31, 45], [31, 42], [32, 60], [32, 70], [32, 37], [32, 40], [32, 41], [33, 63], [33, 78], [33, 53], [33, 41], [33, 48], [34, 59], [34, 63], [34, 53], [34, 48], [34, 58], [35, 78], [35, 50], [35, 57], [35, 54], [36, 44], [36, 39], [36, 40], [36, 43], [36, 55], [37, 47], [37, 60], [37, 69], [39, 70], [39, 60], [39, 43], [39, 44], [40, 44], [40, 60], [40, 41], [40, 75], [42, 79], [42, 65], [42, 66], [42, 68], [42, 77], [43, 44], [44, 46], [45, 55], [45, 77], [45, 80], [46, 57], [46, 59], [47, 57], [48, 63], [48, 53], [48, 73], [50, 78], [50, 63], [50, 54], [50, 64], [51, 70], [51, 60], [52, 58], [52, 67], [53, 63], [53, 59], [53, 73], [53, 75], [54, 78], [54, 59], [56, 77], [56, 79], [56, 68], [57, 69], [58, 67], [59, 63], [60, 70], [63, 72], [63, 73], [63, 74], [63, 78], [64, 72], [64, 74], [66, 79], [66, 77], [66, 76], [67, 71], [68, 77], [68, 79], [71, 75], [71, 77], [72, 74], [77, 79], [77, 80]]}
artifacts/rings.json CHANGED
@@ -1,63 +1,28 @@
1
  {
2
  "n_flagged": 81,
3
  "total_mules": 81,
4
- "n_rings": 4,
5
- "mules_in_rings": 76,
6
- "largest_ring": 26,
7
  "rings": [
8
  {
9
  "ring_id": 1,
10
- "size": 26,
11
- "mean_risk": 92.6,
12
- "max_risk": 99.2,
13
- "actual_mules": 26,
14
- "members": [
15
- "ACC-009001",
16
- "ACC-009004",
17
- "ACC-009007",
18
- "ACC-009011",
19
- "ACC-009012",
20
- "ACC-009013",
21
- "ACC-009018",
22
- "ACC-009020",
23
- "ACC-009026",
24
- "ACC-009030",
25
- "ACC-009031",
26
- "ACC-009032",
27
- "ACC-009046",
28
- "ACC-009047",
29
- "ACC-009050",
30
- "ACC-009053",
31
- "ACC-009056",
32
- "ACC-009057",
33
- "ACC-009059",
34
- "ACC-009067",
35
- "ACC-009068",
36
- "ACC-009072",
37
- "ACC-009076",
38
- "ACC-009077",
39
- "ACC-009078",
40
- "ACC-009081"
41
- ]
42
- },
43
- {
44
- "ring_id": 2,
45
  "size": 23,
46
- "mean_risk": 96.7,
47
- "max_risk": 99.2,
48
- "actual_mules": 22,
49
  "members": [
50
- "ACC-002811",
51
  "ACC-009002",
52
  "ACC-009003",
 
53
  "ACC-009006",
54
  "ACC-009015",
55
  "ACC-009023",
56
  "ACC-009024",
57
  "ACC-009028",
58
  "ACC-009034",
 
59
  "ACC-009036",
60
- "ACC-009041",
61
  "ACC-009042",
62
  "ACC-009049",
63
  "ACC-009051",
@@ -73,52 +38,104 @@
73
  ]
74
  },
75
  {
76
- "ring_id": 3,
77
- "size": 16,
78
- "mean_risk": 89.2,
79
- "max_risk": 99.2,
80
- "actual_mules": 14,
81
  "members": [
82
  "ACC-000285",
83
- "ACC-001316",
84
- "ACC-009009",
85
  "ACC-009010",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  "ACC-009014",
87
  "ACC-009016",
88
  "ACC-009019",
89
  "ACC-009022",
90
- "ACC-009027",
91
- "ACC-009029",
92
- "ACC-009043",
93
  "ACC-009048",
94
  "ACC-009058",
95
- "ACC-009066",
96
- "ACC-009070",
97
- "ACC-009080"
98
  ]
99
  },
100
  {
101
- "ring_id": 4,
102
- "size": 14,
103
- "mean_risk": 98.4,
104
- "max_risk": 99.2,
105
- "actual_mules": 14,
106
  "members": [
107
  "ACC-009005",
108
- "ACC-009008",
109
  "ACC-009017",
110
  "ACC-009021",
111
  "ACC-009025",
112
  "ACC-009033",
113
- "ACC-009037",
114
- "ACC-009038",
115
  "ACC-009040",
116
- "ACC-009044",
117
- "ACC-009045",
118
  "ACC-009052",
119
  "ACC-009061",
120
  "ACC-009071"
121
  ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  }
123
  ]
124
  }
 
1
  {
2
  "n_flagged": 81,
3
  "total_mules": 81,
4
+ "n_rings": 6,
5
+ "mules_in_rings": 77,
6
+ "largest_ring": 23,
7
  "rings": [
8
  {
9
  "ring_id": 1,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  "size": 23,
11
+ "mean_risk": 97.2,
12
+ "max_risk": 99.0,
13
+ "actual_mules": 23,
14
  "members": [
 
15
  "ACC-009002",
16
  "ACC-009003",
17
+ "ACC-009004",
18
  "ACC-009006",
19
  "ACC-009015",
20
  "ACC-009023",
21
  "ACC-009024",
22
  "ACC-009028",
23
  "ACC-009034",
24
+ "ACC-009035",
25
  "ACC-009036",
 
26
  "ACC-009042",
27
  "ACC-009049",
28
  "ACC-009051",
 
38
  ]
39
  },
40
  {
41
+ "ring_id": 2,
42
+ "size": 17,
43
+ "mean_risk": 85.3,
44
+ "max_risk": 99.0,
45
+ "actual_mules": 16,
46
  "members": [
47
  "ACC-000285",
48
+ "ACC-009007",
 
49
  "ACC-009010",
50
+ "ACC-009026",
51
+ "ACC-009027",
52
+ "ACC-009029",
53
+ "ACC-009030",
54
+ "ACC-009031",
55
+ "ACC-009043",
56
+ "ACC-009057",
57
+ "ACC-009066",
58
+ "ACC-009067",
59
+ "ACC-009069",
60
+ "ACC-009077",
61
+ "ACC-009078",
62
+ "ACC-009080",
63
+ "ACC-009081"
64
+ ]
65
+ },
66
+ {
67
+ "ring_id": 3,
68
+ "size": 13,
69
+ "mean_risk": 97.8,
70
+ "max_risk": 99.0,
71
+ "actual_mules": 13,
72
+ "members": [
73
+ "ACC-009008",
74
+ "ACC-009011",
75
+ "ACC-009013",
76
+ "ACC-009020",
77
+ "ACC-009032",
78
+ "ACC-009037",
79
+ "ACC-009041",
80
+ "ACC-009044",
81
+ "ACC-009045",
82
+ "ACC-009046",
83
+ "ACC-009047",
84
+ "ACC-009050",
85
+ "ACC-009056"
86
+ ]
87
+ },
88
+ {
89
+ "ring_id": 4,
90
+ "size": 9,
91
+ "mean_risk": 98.6,
92
+ "max_risk": 99.0,
93
+ "actual_mules": 9,
94
+ "members": [
95
+ "ACC-009009",
96
  "ACC-009014",
97
  "ACC-009016",
98
  "ACC-009019",
99
  "ACC-009022",
100
+ "ACC-009038",
 
 
101
  "ACC-009048",
102
  "ACC-009058",
103
+ "ACC-009070"
 
 
104
  ]
105
  },
106
  {
107
+ "ring_id": 5,
108
+ "size": 9,
109
+ "mean_risk": 98.0,
110
+ "max_risk": 99.0,
111
+ "actual_mules": 9,
112
  "members": [
113
  "ACC-009005",
 
114
  "ACC-009017",
115
  "ACC-009021",
116
  "ACC-009025",
117
  "ACC-009033",
 
 
118
  "ACC-009040",
 
 
119
  "ACC-009052",
120
  "ACC-009061",
121
  "ACC-009071"
122
  ]
123
+ },
124
+ {
125
+ "ring_id": 6,
126
+ "size": 7,
127
+ "mean_risk": 93.7,
128
+ "max_risk": 99.0,
129
+ "actual_mules": 7,
130
+ "members": [
131
+ "ACC-009001",
132
+ "ACC-009018",
133
+ "ACC-009053",
134
+ "ACC-009059",
135
+ "ACC-009068",
136
+ "ACC-009072",
137
+ "ACC-009076"
138
+ ]
139
  }
140
  ]
141
  }
artifacts/threshold.json CHANGED
@@ -1,4 +1,4 @@
1
  {
2
- "threshold": 0.10590650187102484,
3
  "operating_point": "max F2 on OOF predictions"
4
  }
 
1
  {
2
+ "threshold": 0.20996241663498982,
3
  "operating_point": "max F2 on OOF predictions"
4
  }
dashboard/app.py CHANGED
@@ -83,7 +83,7 @@ html, body, [class*="css"]{ font-family:'Manrope',sans-serif; }
83
  .kpi .d{ font-size:12px; margin-top:3px; color:var(--teal); font-weight:600; }
84
 
85
  /* alert rows */
86
- .q-row{ display:grid; grid-template-columns:120px 1fr 90px 100px 130px; align-items:center;
87
  gap:14px; padding:12px 16px; border:1px solid var(--border); border-radius:12px;
88
  background:var(--surface); margin-bottom:9px; transition:border-color .15s, transform .15s; }
89
  .q-row:hover{ border-color:var(--teal); transform:translateX(2px); }
@@ -229,11 +229,10 @@ def alert_row(a):
229
  f'<div class="q-row">'
230
  f'<div><div class="risknum" style="color:{c}">{a["risk_score"]:.0f}</div>'
231
  f'<div class="riskbar"><i style="width:{w}%;background:{c}"></i></div></div>'
232
- f'<div><div class="q-id">{a["account_id"]} · {a["channel"]}</div>'
233
  f'<div class="q-narr">{a["narrative"]}</div></div>'
234
  f'<div>{pill(a["risk_tier"])}</div>'
235
  f'<div class="q-id">{a["decision"]}</div>'
236
- f'<div class="q-src">{a["feed_source"]}</div>'
237
  f'</div>'
238
  )
239
 
@@ -241,8 +240,7 @@ def alert_row(a):
241
  def gauge(score):
242
  tier = config.risk_tier(score); c = TIER[tier]
243
  fig = go.Figure(go.Indicator(
244
- mode="gauge+number", value=score,
245
- number={"font": {"color": c, "size": 40, "family": "JetBrains Mono"}, "suffix": "/100"},
246
  gauge={"axis": {"range": [0, 100], "tickcolor": "#8593a6"},
247
  "bar": {"color": c, "thickness": 0.28},
248
  "bgcolor": "rgba(0,0,0,0)", "borderwidth": 0,
@@ -250,7 +248,15 @@ def gauge(score):
250
  {"range": [50, 70], "color": "rgba(254,188,46,.12)"},
251
  {"range": [70, 85], "color": "rgba(255,159,67,.14)"},
252
  {"range": [85, 100], "color": "rgba(255,77,79,.16)"}],
253
- "threshold": {"line": {"color": "#e7eef6", "width": 3}, "value": 50}}))
 
 
 
 
 
 
 
 
254
  fig.update_layout(height=240, margin=dict(l=20, r=20, t=18, b=8),
255
  paper_bgcolor="rgba(0,0,0,0)", font={"color": "#e7eef6"})
256
  return fig
@@ -315,19 +321,17 @@ with t_queue:
315
  rows = "".join(alert_row(a) for _, a in q.iterrows())
316
  st.markdown(rows, unsafe_allow_html=True)
317
  with right:
318
- st.markdown('<div class="section-h">Feed sources</div>', unsafe_allow_html=True)
319
- for src, n in flagged["feed_source"].value_counts().items():
 
 
 
 
 
320
  st.markdown(
321
  f'<div class="q-row" style="grid-template-columns:1fr 50px">'
322
- f'<div class="q-id">{src}</div>'
323
- f'<div class="risknum" style="color:var(--teal);text-align:right">{n}</div></div>',
324
- unsafe_allow_html=True)
325
- st.markdown('<div class="section-h" style="margin-top:18px">Channels flagged</div>', unsafe_allow_html=True)
326
- for ch, n in flagged["channel"].value_counts().items():
327
- st.markdown(
328
- f'<div class="q-row" style="grid-template-columns:1fr 50px">'
329
- f'<div class="q-id">{ch}</div>'
330
- f'<div class="risknum" style="color:var(--high);text-align:right">{n}</div></div>',
331
  unsafe_allow_html=True)
332
 
333
  # ── Mule Rings ──
 
83
  .kpi .d{ font-size:12px; margin-top:3px; color:var(--teal); font-weight:600; }
84
 
85
  /* alert rows */
86
+ .q-row{ display:grid; grid-template-columns:120px 1fr 90px 110px; align-items:center;
87
  gap:14px; padding:12px 16px; border:1px solid var(--border); border-radius:12px;
88
  background:var(--surface); margin-bottom:9px; transition:border-color .15s, transform .15s; }
89
  .q-row:hover{ border-color:var(--teal); transform:translateX(2px); }
 
229
  f'<div class="q-row">'
230
  f'<div><div class="risknum" style="color:{c}">{a["risk_score"]:.0f}</div>'
231
  f'<div class="riskbar"><i style="width:{w}%;background:{c}"></i></div></div>'
232
+ f'<div><div class="q-id">{a["account_id"]}</div>'
233
  f'<div class="q-narr">{a["narrative"]}</div></div>'
234
  f'<div>{pill(a["risk_tier"])}</div>'
235
  f'<div class="q-id">{a["decision"]}</div>'
 
236
  f'</div>'
237
  )
238
 
 
240
  def gauge(score):
241
  tier = config.risk_tier(score); c = TIER[tier]
242
  fig = go.Figure(go.Indicator(
243
+ mode="gauge", value=score,
 
244
  gauge={"axis": {"range": [0, 100], "tickcolor": "#8593a6"},
245
  "bar": {"color": c, "thickness": 0.28},
246
  "bgcolor": "rgba(0,0,0,0)", "borderwidth": 0,
 
248
  {"range": [50, 70], "color": "rgba(254,188,46,.12)"},
249
  {"range": [70, 85], "color": "rgba(255,159,67,.14)"},
250
  {"range": [85, 100], "color": "rgba(255,77,79,.16)"}],
251
+ "threshold": {"line": {"color": "#e7eef6", "width": 3}, "value": 50}},
252
+ domain={"x": [0, 1], "y": [0, 1]}))
253
+ # Centered value label in the middle of the half-circle (Plotly's built-in
254
+ # gauge number renders off-centre, so we place our own annotation at x=0.5).
255
+ fig.add_annotation(
256
+ x=0.5, y=0.16, xref="paper", yref="paper", showarrow=False,
257
+ text=f"<b>{score:.0f}</b><span style='font-size:0.5em'> /100</span>",
258
+ font={"color": c, "size": 38, "family": "JetBrains Mono"},
259
+ xanchor="center", yanchor="middle", align="center")
260
  fig.update_layout(height=240, margin=dict(l=20, r=20, t=18, b=8),
261
  paper_bgcolor="rgba(0,0,0,0)", font={"color": "#e7eef6"})
262
  return fig
 
321
  rows = "".join(alert_row(a) for _, a in q.iterrows())
322
  st.markdown(rows, unsafe_allow_html=True)
323
  with right:
324
+ st.markdown('<div class="section-h">Risk tiers</div>', unsafe_allow_html=True)
325
+ counts = flagged["risk_tier"].value_counts()
326
+ for t in ["CRITICAL", "HIGH", "MEDIUM", "LOW"]:
327
+ n = int(counts.get(t, 0))
328
+ if not n:
329
+ continue
330
+ col = TIER.get(t, "#888")
331
  st.markdown(
332
  f'<div class="q-row" style="grid-template-columns:1fr 50px">'
333
+ f'<div>{pill(t)}</div>'
334
+ f'<div class="risknum" style="color:{col};text-align:right">{n}</div></div>',
 
 
 
 
 
 
 
335
  unsafe_allow_html=True)
336
 
337
  # ── Mule Rings ──
src/features/builder.py CHANGED
@@ -1,59 +1,75 @@
1
  """FeatureBuilder: the single fitted object that turns raw account records into
2
  the model's input matrix. Used identically by training and serving for parity.
3
 
4
- Pipeline: clean -> impute/one-hot -> append Isolation Forest anomaly score
5
- -> select a stable top-K feature subset (importance voting + domain priors).
 
 
 
 
 
 
 
 
 
6
  """
7
  from __future__ import annotations
8
 
9
  import numpy as np
10
  import pandas as pd
11
  from lightgbm import LGBMClassifier
12
- from sklearn.compose import ColumnTransformer
13
  from sklearn.ensemble import IsolationForest
14
- from sklearn.impute import SimpleImputer
15
  from sklearn.model_selection import RepeatedStratifiedKFold
16
- from sklearn.pipeline import Pipeline
17
  from sklearn.preprocessing import OneHotEncoder
18
 
19
  from src import config
20
  from src.features.clean import clean_frame, split_column_types
21
 
22
  ANOMALY_COL = "anomaly_score"
 
23
 
24
 
25
  class FeatureBuilder:
26
  def __init__(self, n_select: int = config.N_SELECT, seed: int = config.SEED):
27
  self.n_select = n_select
28
  self.seed = seed
29
- self.preprocessor: ColumnTransformer | None = None
30
- self.iso: IsolationForest | None = None
 
 
 
 
31
  self.feature_names_full_: list[str] = []
32
  self.selected_features_: list[str] = []
33
  self.selection_freq_: dict[str, float] = {}
34
 
35
  # ---- internal helpers --------------------------------------------------
36
- def _build_preprocessor(self, df: pd.DataFrame) -> ColumnTransformer:
37
- nums, cats = split_column_types(df)
38
- num_pipe = Pipeline([("impute", SimpleImputer(strategy="median"))])
39
- cat_pipe = Pipeline([
40
- ("impute", SimpleImputer(strategy="most_frequent")),
41
- ("ohe", OneHotEncoder(handle_unknown="ignore", min_frequency=20, sparse_output=False)),
42
- ])
43
- return ColumnTransformer(
44
- [("num", num_pipe, nums), ("cat", cat_pipe, cats)],
45
- remainder="drop",
46
- verbose_feature_names_out=False,
47
- )
48
-
49
- def _transform_no_select(self, X: pd.DataFrame) -> pd.DataFrame:
50
- """clean -> preprocess -> append anomaly score. Returns full feature frame."""
51
- cleaned = clean_frame(X)
52
- mat = self.preprocessor.transform(cleaned)
53
- names = list(self.preprocessor.get_feature_names_out())
54
- out = pd.DataFrame(mat, columns=names, index=X.index)
55
- # Isolation Forest: higher = more anomalous (we negate decision_function).
56
- out[ANOMALY_COL] = -self.iso.decision_function(mat)
 
 
 
 
 
57
  return out
58
 
59
  def _known_important_columns(self, all_names: list[str]) -> list[str]:
@@ -65,23 +81,34 @@ class FeatureBuilder:
65
  if name in priors or base in priors:
66
  keep.add(name)
67
  keep.add(ANOMALY_COL)
 
68
  return sorted(keep)
69
 
70
  # ---- public API --------------------------------------------------------
71
  def fit(self, X: pd.DataFrame, y: pd.Series) -> "FeatureBuilder":
72
  cleaned = clean_frame(X)
73
- self.preprocessor = self._build_preprocessor(cleaned)
74
- mat = self.preprocessor.fit_transform(cleaned)
75
- names = list(self.preprocessor.get_feature_names_out())
76
-
77
- # Unsupervised anomaly model fit on training rows only.
78
- self.iso = IsolationForest(
 
 
 
 
 
 
 
 
 
 
 
 
79
  n_estimators=200, contamination="auto", random_state=self.seed, n_jobs=-1
80
- )
81
- self.iso.fit(mat)
82
 
83
- full = pd.DataFrame(mat, columns=names, index=X.index)
84
- full[ANOMALY_COL] = -self.iso.decision_function(mat)
85
  self.feature_names_full_ = list(full.columns)
86
 
87
  # ---- feature selection via importance voting across CV folds -------
@@ -98,7 +125,6 @@ class FeatureBuilder:
98
  )
99
  clf.fit(full.iloc[tr_idx], y.iloc[tr_idx])
100
  imp = pd.Series(clf.feature_importances_, index=full.columns)
101
- # rank-based vote: top features in this fold get a point
102
  top = imp.sort_values(ascending=False).head(self.n_select).index
103
  votes[top] += 1.0
104
  self.selection_freq_ = (votes / (cv.get_n_splits())).to_dict()
@@ -106,7 +132,7 @@ class FeatureBuilder:
106
  ranked = votes.sort_values(ascending=False)
107
  selected = list(ranked.head(self.n_select).index)
108
 
109
- # Always retain domain priors + anomaly score.
110
  for col in self._known_important_columns(self.feature_names_full_):
111
  if col not in selected:
112
  selected.append(col)
@@ -114,8 +140,8 @@ class FeatureBuilder:
114
  return self
115
 
116
  def transform(self, X: pd.DataFrame) -> pd.DataFrame:
117
- full = self._transform_no_select(X)
118
- # Guarantee all selected columns exist (serving robustness).
119
  for col in self.selected_features_:
120
  if col not in full.columns:
121
  full[col] = 0.0
 
1
  """FeatureBuilder: the single fitted object that turns raw account records into
2
  the model's input matrix. Used identically by training and serving for parity.
3
 
4
+ Pipeline (v2 native-NaN):
5
+ clean -> keep numerics WITH NaN (LightGBM splits on missingness natively)
6
+ -> one-hot encode the semantic categoricals
7
+ -> add per-feature missingness indicators + a row missing-count
8
+ -> append an Isolation Forest anomaly score (computed on a median-imputed
9
+ copy, since IsolationForest cannot consume NaN)
10
+ -> select a stable top-K subset (importance voting + domain priors).
11
+
12
+ Why native-NaN: ~28% of values are missing and mules are characterised largely
13
+ by a distinctive *missingness* pattern; median-imputation erased that signal.
14
+ The classifier sees raw NaNs; only the anomaly model gets an imputed copy.
15
  """
16
  from __future__ import annotations
17
 
18
  import numpy as np
19
  import pandas as pd
20
  from lightgbm import LGBMClassifier
 
21
  from sklearn.ensemble import IsolationForest
 
22
  from sklearn.model_selection import RepeatedStratifiedKFold
 
23
  from sklearn.preprocessing import OneHotEncoder
24
 
25
  from src import config
26
  from src.features.clean import clean_frame, split_column_types
27
 
28
  ANOMALY_COL = "anomaly_score"
29
+ MISSCOUNT_COL = "missing_count"
30
 
31
 
32
  class FeatureBuilder:
33
  def __init__(self, n_select: int = config.N_SELECT, seed: int = config.SEED):
34
  self.n_select = n_select
35
  self.seed = seed
36
+ self.num_cols_: list[str] = []
37
+ self.cat_cols_: list[str] = []
38
+ self.ohe_: OneHotEncoder | None = None
39
+ self.ohe_cols_: list[str] = []
40
+ self.num_medians_: np.ndarray | None = None # only to feed the anomaly model
41
+ self.iso_: IsolationForest | None = None
42
  self.feature_names_full_: list[str] = []
43
  self.selected_features_: list[str] = []
44
  self.selection_freq_: dict[str, float] = {}
45
 
46
  # ---- internal helpers --------------------------------------------------
47
+ def _numeric_frame(self, cleaned: pd.DataFrame) -> pd.DataFrame:
48
+ """Numeric columns as float WITH NaN preserved (reindexed for serve safety)."""
49
+ return (cleaned.reindex(columns=self.num_cols_)
50
+ .apply(pd.to_numeric, errors="coerce").astype("float64"))
51
+
52
+ def _ohe_frame(self, cleaned: pd.DataFrame) -> pd.DataFrame:
53
+ if not self.cat_cols_:
54
+ return pd.DataFrame(index=cleaned.index)
55
+ vals = cleaned.reindex(columns=self.cat_cols_).astype("object")
56
+ vals = vals.where(pd.notna(vals), "NA")
57
+ mat = self.ohe_.transform(vals)
58
+ return pd.DataFrame(mat, columns=self.ohe_cols_, index=cleaned.index)
59
+
60
+ def _impute_for_iso(self, num: pd.DataFrame) -> np.ndarray:
61
+ arr = num.values.copy()
62
+ pos = np.where(np.isnan(arr))
63
+ arr[pos] = np.take(self.num_medians_, pos[1])
64
+ return arr
65
+
66
+ def _assemble(self, cleaned: pd.DataFrame) -> pd.DataFrame:
67
+ num = self._numeric_frame(cleaned)
68
+ ind = num.isna().astype("float64")
69
+ ind.columns = [f"{c}__isna" for c in num.columns]
70
+ out = pd.concat([num, self._ohe_frame(cleaned), ind], axis=1)
71
+ out[MISSCOUNT_COL] = num.isna().sum(axis=1).astype("float64").values
72
+ out[ANOMALY_COL] = -self.iso_.decision_function(self._impute_for_iso(num))
73
  return out
74
 
75
  def _known_important_columns(self, all_names: list[str]) -> list[str]:
 
81
  if name in priors or base in priors:
82
  keep.add(name)
83
  keep.add(ANOMALY_COL)
84
+ keep.add(MISSCOUNT_COL)
85
  return sorted(keep)
86
 
87
  # ---- public API --------------------------------------------------------
88
  def fit(self, X: pd.DataFrame, y: pd.Series) -> "FeatureBuilder":
89
  cleaned = clean_frame(X)
90
+ self.num_cols_, self.cat_cols_ = split_column_types(cleaned)
91
+ num = cleaned[self.num_cols_].apply(pd.to_numeric, errors="coerce").astype("float64")
92
+
93
+ # Per-column median — used ONLY to feed the anomaly model (cannot take NaN).
94
+ self.num_medians_ = np.nan_to_num(np.nanmedian(num.values, axis=0), nan=0.0)
95
+
96
+ # One-hot encoder for the semantic categoricals.
97
+ if self.cat_cols_:
98
+ self.ohe_ = OneHotEncoder(handle_unknown="ignore", min_frequency=20, sparse_output=False)
99
+ vals = cleaned[self.cat_cols_].astype("object")
100
+ vals = vals.where(pd.notna(vals), "NA")
101
+ self.ohe_.fit(vals)
102
+ self.ohe_cols_ = list(self.ohe_.get_feature_names_out(self.cat_cols_))
103
+ else:
104
+ self.ohe_cols_ = []
105
+
106
+ # Unsupervised anomaly model on median-imputed training rows only.
107
+ self.iso_ = IsolationForest(
108
  n_estimators=200, contamination="auto", random_state=self.seed, n_jobs=-1
109
+ ).fit(self._impute_for_iso(num))
 
110
 
111
+ full = self._assemble(cleaned)
 
112
  self.feature_names_full_ = list(full.columns)
113
 
114
  # ---- feature selection via importance voting across CV folds -------
 
125
  )
126
  clf.fit(full.iloc[tr_idx], y.iloc[tr_idx])
127
  imp = pd.Series(clf.feature_importances_, index=full.columns)
 
128
  top = imp.sort_values(ascending=False).head(self.n_select).index
129
  votes[top] += 1.0
130
  self.selection_freq_ = (votes / (cv.get_n_splits())).to_dict()
 
132
  ranked = votes.sort_values(ascending=False)
133
  selected = list(ranked.head(self.n_select).index)
134
 
135
+ # Always retain domain priors + anomaly score + missing-count.
136
  for col in self._known_important_columns(self.feature_names_full_):
137
  if col not in selected:
138
  selected.append(col)
 
140
  return self
141
 
142
  def transform(self, X: pd.DataFrame) -> pd.DataFrame:
143
+ full = self._assemble(clean_frame(X))
144
+ # Serving robustness: guarantee every selected column exists.
145
  for col in self.selected_features_:
146
  if col not in full.columns:
147
  full[col] = 0.0
src/models/rings.py CHANGED
@@ -43,7 +43,13 @@ def _score_all():
43
  def build_ring_graph(feats, risk):
44
  """k-NN similarity graph among flagged accounts only."""
45
  fidx = np.where(risk >= 50)[0]
46
- Z = StandardScaler().fit_transform(feats.values)[fidx]
 
 
 
 
 
 
47
  k = min(K, len(fidx) - 1)
48
  dist, idx = NearestNeighbors(n_neighbors=k + 1).fit(Z).kneighbors(Z)
49
  thr = np.median(dist[:, 1:]) * 1.6 # only keep genuinely-similar edges
 
43
  def build_ring_graph(feats, risk):
44
  """k-NN similarity graph among flagged accounts only."""
45
  fidx = np.where(risk >= 50)[0]
46
+ # Native-NaN features: median-impute for the distance computation only
47
+ # (StandardScaler / k-NN need finite input; the classifier still sees raw NaN).
48
+ vals = feats.values.astype(float)
49
+ col_med = np.nan_to_num(np.nanmedian(vals, axis=0), nan=0.0)
50
+ nanpos = np.where(np.isnan(vals))
51
+ vals[nanpos] = np.take(col_med, nanpos[1])
52
+ Z = StandardScaler().fit_transform(vals)[fidx]
53
  k = min(K, len(fidx) - 1)
54
  dist, idx = NearestNeighbors(n_neighbors=k + 1).fit(Z).kneighbors(Z)
55
  thr = np.median(dist[:, 1:]) * 1.6 # only keep genuinely-similar edges
src/models/scoring.py CHANGED
@@ -87,7 +87,16 @@ def explain_row(raw_row: pd.DataFrame, top_n: int = 5) -> list[dict]:
87
 
88
 
89
  def narrative(risk_score: float, reasons: list[dict]) -> str:
90
- """Short human-readable alert narrative from reason codes."""
91
- up = [r["feature"] for r in reasons if r["shap"] > 0][:3]
92
- drivers = ", ".join(up) if up else "diffuse low-level signals"
93
- return f"Risk {risk_score:.0f}/100driven mainly by {drivers}."
 
 
 
 
 
 
 
 
 
 
87
 
88
 
89
  def narrative(risk_score: float, reasons: list[dict]) -> str:
90
+ """Short human-readable alert narrative from reason codes.
91
+
92
+ Direction-aware: flagged accounts describe what *raised* risk; cleared
93
+ accounts describe what *kept it low* so the wording always matches the
94
+ decision instead of always naming risk-increasing features.
95
+ """
96
+ if risk_score >= 50:
97
+ feats = [r["feature"] for r in reasons if r["shap"] > 0][:3]
98
+ drivers = ", ".join(feats) if feats else "diffuse elevated signals"
99
+ return f"Risk {risk_score:.0f}/100 — elevated mainly by {drivers}."
100
+ feats = [r["feature"] for r in reasons if r["shap"] < 0][:3]
101
+ drivers = ", ".join(feats) if feats else "no material risk signals"
102
+ return f"Risk {risk_score:.0f}/100 — kept low mainly by {drivers}."
src/models/train.py CHANGED
@@ -20,6 +20,7 @@ from sklearn.metrics import (average_precision_score, confusion_matrix, fbeta_sc
20
  precision_recall_curve, roc_auc_score)
21
  from sklearn.model_selection import (RepeatedStratifiedKFold, StratifiedKFold,
22
  cross_val_predict, cross_val_score)
 
23
  from sklearn.pipeline import Pipeline
24
  from sklearn.preprocessing import StandardScaler
25
 
@@ -93,7 +94,8 @@ def main() -> None:
93
  threshold = thr_info["threshold"]
94
 
95
  # ---- Logistic baseline (sanity) ----
96
- logit = Pipeline([("scale", StandardScaler()),
 
97
  ("clf", LogisticRegression(max_iter=2000, class_weight="balanced", C=0.1))])
98
  logit_ap = cross_val_score(logit, X_tr, y_tr, cv=skf, scoring="average_precision", n_jobs=-1).mean()
99
  logit_auc = cross_val_score(logit, X_tr, y_tr, cv=skf, scoring="roc_auc", n_jobs=-1).mean()
 
20
  precision_recall_curve, roc_auc_score)
21
  from sklearn.model_selection import (RepeatedStratifiedKFold, StratifiedKFold,
22
  cross_val_predict, cross_val_score)
23
+ from sklearn.impute import SimpleImputer
24
  from sklearn.pipeline import Pipeline
25
  from sklearn.preprocessing import StandardScaler
26
 
 
94
  threshold = thr_info["threshold"]
95
 
96
  # ---- Logistic baseline (sanity) ----
97
+ logit = Pipeline([("impute", SimpleImputer(strategy="median")),
98
+ ("scale", StandardScaler()),
99
  ("clf", LogisticRegression(max_iter=2000, class_weight="balanced", C=0.1))])
100
  logit_ap = cross_val_score(logit, X_tr, y_tr, cv=skf, scoring="average_precision", n_jobs=-1).mean()
101
  logit_auc = cross_val_score(logit, X_tr, y_tr, cv=skf, scoring="roc_auc", n_jobs=-1).mean()
src/simulator/alerts_store.jsonl CHANGED
The diff for this file is too large to render. See raw diff