MuleGuard / src /config.py
MuleGuard
MuleGuard: end-to-end mule-account detection + HF Space deploy
af879c2
Raw
History Blame Contribute Delete
3.55 kB
"""Central configuration: paths, constants, and domain priors for MuleGuard."""
from __future__ import annotations
from pathlib import Path
# ---- Paths -----------------------------------------------------------------
ROOT = Path(__file__).resolve().parents[1]
DATA_DIR = ROOT / "data"
ARTIFACTS_DIR = ROOT / "artifacts"
REPORTS_DIR = ROOT / "reports"
FIGURES_DIR = REPORTS_DIR / "figures"
RAW_CSV = DATA_DIR / "DataSet.csv"
CACHE_PARQUET = DATA_DIR / "dataset.parquet" # fast-load cache of the raw CSV
# Persisted model artifacts
PIPELINE_PATH = ARTIFACTS_DIR / "feature_pipeline.pkl"
MODEL_PATH = ARTIFACTS_DIR / "model.pkl"
FEATURE_LIST_PATH = ARTIFACTS_DIR / "feature_list.json"
THRESHOLD_PATH = ARTIFACTS_DIR / "threshold.json"
METADATA_PATH = ARTIFACTS_DIR / "metadata.json"
SHAP_EXPLAINER_PATH = ARTIFACTS_DIR / "shap_explainer.pkl"
TEST_SPLIT_PATH = ARTIFACTS_DIR / "test_holdout.parquet"
# Lightweight sample shipped with the deployed app (so it runs without the 111MB dataset).
SAMPLE_ACCOUNTS_PATH = ARTIFACTS_DIR / "sample_accounts.parquet"
def stream_source() -> "Path":
"""Held-out set for streaming/scoring: full test split locally, sample on deploy."""
return TEST_SPLIT_PATH if TEST_SPLIT_PATH.exists() else SAMPLE_ACCOUNTS_PATH
# ---- Modeling constants ----------------------------------------------------
TARGET = "F3924"
INDEX_COL = "Unnamed: 0" # the leading unnamed index column in the CSV
# Leakage exclusions — removed to keep the model honest and metrics trustworthy:
# F3912 — binary flag aligned ~perfectly with the target (single-feature AUC
# 0.987; 1 for 79/81 mules, 0 for 8998/9001 legit). A label-adjacent
# "fraud-flagged" indicator, not a learned behavioral signal.
# F2230 — observation month. ALL legit accounts fall in Oct25 while ALL mules
# fall in Sep/Nov/Dec25 — a temporal data-collection artifact that
# perfectly separates classes (AUC 1.000) but encodes no real behavior.
LEAKAGE_EXCLUDE = ["F3912", "F2230"]
SEED = 42
TEST_SIZE = 0.20
N_SELECT = 80 # target number of features after selection
# Bank-flagged "commonly used" high-signal fraud features (domain priors).
KNOWN_IMPORTANT = [
"F115", "F321", "F527", "F531", "F670", "F1692", "F2082", "F2122",
"F2582", "F2678", "F2737", "F2956", "F3043", "F3836", "F3887",
"F3889", "F3891", "F3894",
]
# Risk tiers on the threshold-anchored 0-100 score (50 == decision threshold,
# so anything >=50 is a flagged alert and tiers are always consistent).
RISK_TIERS = [
(0, 50, "LOW"),
(50, 70, "MEDIUM"),
(70, 85, "HIGH"),
(85, 101, "CRITICAL"),
]
def ensure_dirs() -> None:
for d in (ARTIFACTS_DIR, REPORTS_DIR, FIGURES_DIR):
d.mkdir(parents=True, exist_ok=True)
def prob_to_risk(prob: float, threshold: float) -> float:
"""Map a calibrated probability to a threshold-anchored 0-100 risk score.
The decision threshold maps to exactly 50, so risk >= 50 == flagged. Below
threshold scales into [0, 50]; above into [50, 100]. Keeps score, tier, and
decision mutually consistent and intuitive for analysts.
"""
threshold = min(max(threshold, 1e-6), 1 - 1e-6)
if prob < threshold:
score = 50.0 * (prob / threshold)
else:
score = 50.0 + 50.0 * (prob - threshold) / (1.0 - threshold)
return round(min(max(score, 0.0), 100.0), 1)
def risk_tier(score_0_100: float) -> str:
for lo, hi, name in RISK_TIERS:
if lo <= score_0_100 < hi:
return name
return "CRITICAL"