MuleGuard / src /models /rings.py
Aryan Singh
Improve mule classifier: native-NaN + missingness (CV PR-AUC 0.88->0.91, recall 13->15/16)
67eae2d
Raw
History Blame Contribute Delete
6.79 kB
"""Mule-ring detection.
The defining trait of mule accounts is that they operate in *networks*, not in
isolation. The dataset has no transaction edges, so we infer latent rings from
BEHAVIOURAL co-similarity: among the accounts the model FLAGS, we build a k-NN
similarity graph and run community detection. Tight, dense clusters of
behaviourally near-identical high-risk accounts are candidate mule rings — the
unit a fraud team should freeze together to stop circulation.
Honest framing: these are behavioural-similarity rings (accounts that act alike
and score high together), not proven money-flow links.
"""
from __future__ import annotations
import json
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import networkx as nx
import numpy as np
from sklearn.neighbors import NearestNeighbors
from sklearn.preprocessing import StandardScaler
from src import config
from src.data.load import load_raw, split_xy
from src.models.scoring import load_artifacts
K = 5 # neighbours per flagged account
MIN_RING = 3 # a ring needs >=3 accounts
TIER_HEX = {"CRITICAL": "#ff4d4f", "HIGH": "#ff9f43", "MEDIUM": "#febc2e", "LOW": "#2bd97c"}
def _score_all():
art = load_artifacts()
X, y = split_xy(load_raw())
feats = art.builder.transform(X)
prob = art.model.predict_proba(feats)[:, 1]
risk = np.array([config.prob_to_risk(p, art.threshold) for p in prob])
return feats, risk, y.values
def build_ring_graph(feats, risk):
"""k-NN similarity graph among flagged accounts only."""
fidx = np.where(risk >= 50)[0]
# Native-NaN features: median-impute for the distance computation only
# (StandardScaler / k-NN need finite input; the classifier still sees raw NaN).
vals = feats.values.astype(float)
col_med = np.nan_to_num(np.nanmedian(vals, axis=0), nan=0.0)
nanpos = np.where(np.isnan(vals))
vals[nanpos] = np.take(col_med, nanpos[1])
Z = StandardScaler().fit_transform(vals)[fidx]
k = min(K, len(fidx) - 1)
dist, idx = NearestNeighbors(n_neighbors=k + 1).fit(Z).kneighbors(Z)
thr = np.median(dist[:, 1:]) * 1.6 # only keep genuinely-similar edges
G = nx.Graph()
G.add_nodes_from(range(len(fidx)))
for i in range(len(fidx)):
for j, d in zip(idx[i, 1:], dist[i, 1:]):
if d <= thr:
G.add_edge(i, int(j), weight=1.0 / (1.0 + float(d)))
return G, fidx
def detect_rings(G, fidx, risk, y):
comms = nx.community.louvain_communities(G, weight="weight", seed=config.SEED)
rings = []
for cid, comm in enumerate(comms):
local = sorted(comm)
if len(local) < MIN_RING:
continue
gi = [int(fidx[t]) for t in local]
rings.append({
"ring_id": len(rings) + 1, "size": len(gi),
"mean_risk": round(float(risk[gi].mean()), 1),
"max_risk": round(float(risk[gi].max()), 1),
"actual_mules": int(y[gi].sum()),
"members": [f"ACC-{g:06d}" for g in gi],
"_local": local,
})
rings.sort(key=lambda r: (-r["size"], -r["mean_risk"]))
for rank, r in enumerate(rings):
r["ring_id"] = rank + 1
return rings
def render_and_export(G, fidx, rings, risk, y):
pos = nx.spring_layout(G, seed=config.SEED, k=0.5, iterations=120)
ring_of = {}
for r in rings:
for t in r["_local"]:
ring_of[t] = r["ring_id"]
palette = ["#2dd4bf", "#ff9f43", "#a78bfa", "#ff4d4f", "#38bdf8", "#f472b6"]
fig, ax = plt.subplots(figsize=(11, 7.2), facecolor="#0b1119")
ax.set_facecolor("#0b1119")
for u, v in G.edges():
ax.plot([pos[u][0], pos[v][0]], [pos[u][1], pos[v][1]],
color="#33475f", lw=0.7, alpha=0.55, zorder=1)
nodes = list(G.nodes())
for r in rings:
col = palette[(r["ring_id"] - 1) % len(palette)]
rn = r["_local"]
xs = [pos[n][0] for n in rn]; ys = [pos[n][1] for n in rn]
sizes = [70 + risk[int(fidx[n])] * 3 for n in rn]
ax.scatter(xs, ys, color=col, s=sizes, edgecolors="#0b1119", linewidths=0.8,
zorder=3, label=f"Ring {r['ring_id']} · {r['size']} accts")
# unringed flagged nodes (singletons/pairs) in muted grey
ringed = {n for r in rings for n in r["_local"]}
other = [n for n in nodes if n not in ringed]
if other:
ax.scatter([pos[n][0] for n in other], [pos[n][1] for n in other],
color="#5b6b7f", s=50, zorder=2, label="unclustered")
mx = [pos[n][0] for n in nodes if y[int(fidx[n])] == 1]
my = [pos[n][1] for n in nodes if y[int(fidx[n])] == 1]
ax.scatter(mx, my, s=320, facecolors="none", edgecolors="#e7eef6", linewidths=1.4,
zorder=4, label="confirmed mule")
ax.set_title(f"{len(rings)} mule rings surfaced among the {len(nodes)} flagged accounts\n"
"behavioural-similarity graph · Louvain community detection",
color="#e7eef6", fontsize=13.5, weight="bold")
ax.legend(loc="upper left", facecolor="#131b27", edgecolor="#26344a", labelcolor="#e7eef6",
fontsize=9, framealpha=0.9)
ax.axis("off"); fig.tight_layout()
fig.savefig(config.ROOT / "docs" / "mule_rings.png", dpi=150, facecolor="#0b1119", bbox_inches="tight")
plt.close(fig)
g_nodes = [{"id": int(fidx[n]), "acc": f"ACC-{int(fidx[n]):06d}",
"x": float(pos[n][0]), "y": float(pos[n][1]),
"risk": float(risk[int(fidx[n])]), "tier": config.risk_tier(risk[int(fidx[n])]),
"mule": int(y[int(fidx[n])]), "ring": int(ring_of.get(n, 0))} for n in nodes]
g_edges = [[int(u), int(v)] for u, v in G.edges()]
(config.ARTIFACTS_DIR / "ring_graph.json").write_text(json.dumps({"nodes": g_nodes, "edges": g_edges}))
def main():
config.ensure_dirs()
feats, risk, y = _score_all()
G, fidx = build_ring_graph(feats, risk)
rings = detect_rings(G, fidx, risk, y)
render_and_export(G, fidx, rings, risk, y)
mules_in_rings = sum(r["actual_mules"] for r in rings)
summary = {
"n_flagged": int((risk >= 50).sum()), "total_mules": int(y.sum()),
"n_rings": len(rings), "mules_in_rings": int(mules_in_rings),
"largest_ring": rings[0]["size"] if rings else 0,
"rings": [{k: v for k, v in r.items() if k != "_local"} for r in rings],
}
(config.ARTIFACTS_DIR / "rings.json").write_text(json.dumps(summary, indent=2))
print(f"Rings: {len(rings)} | flagged: {summary['n_flagged']} | "
f"mules in rings: {mules_in_rings}/{summary['total_mules']}")
for r in rings[:6]:
print(f" Ring {r['ring_id']}: {r['size']} accts, mean_risk {r['mean_risk']}, "
f"{r['actual_mules']} confirmed mules")
if __name__ == "__main__":
main()