MuleGuard / deck /build_deck.py
MuleGuard
Add mule-ring detection (novelty) + new dark pitch deck
fcd607d
Raw
History Blame Contribute Delete
14.4 kB
"""MuleGuard pitch deck β€” dark command-center theme matching the live product.
Large, high-contrast text. Mule-ring detection is the centerpiece (slide 7).
Build: .venv/bin/python deck/build_deck.py
"""
from __future__ import annotations
import json
from pathlib import Path
from PIL import Image
from pptx import Presentation
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_SHAPE
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
from pptx.util import Emu, Inches, Pt
ROOT = Path(__file__).resolve().parents[1]
FIG = ROOT / "reports" / "figures"
DOCS = ROOT / "docs"
META = json.load(open(ROOT / "artifacts" / "metadata.json"))
RINGS = json.load(open(ROOT / "artifacts" / "rings.json"))
BG = RGBColor(0x0B, 0x11, 0x19)
PANEL = RGBColor(0x13, 0x1B, 0x27)
PANEL2 = RGBColor(0x1A, 0x24, 0x33)
BORDER = RGBColor(0x26, 0x34, 0x4A)
WHITE = RGBColor(0xE7, 0xEE, 0xF6)
MUTED = RGBColor(0x85, 0x93, 0xA6)
TEAL = RGBColor(0x2D, 0xD4, 0xBF)
RED = RGBColor(0xFF, 0x4D, 0x4F)
AMBER = RGBColor(0xFF, 0x9F, 0x43)
CARD_W = RGBColor(0xFF, 0xFF, 0xFF)
HFONT, BFONT, MONO = "Trebuchet MS", "Calibri", "Consolas"
SW, SH = Inches(13.333), Inches(7.5)
prs = Presentation(); prs.slide_width, prs.slide_height = SW, SH
BLANK = prs.slide_layouts[6]
def slide(bg=BG):
s = prs.slides.add_slide(BLANK)
r = s.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0, SW, SH)
r.fill.solid(); r.fill.fore_color.rgb = bg; r.line.fill.background(); r.shadow.inherit = False
s.shapes._spTree.remove(r._element); s.shapes._spTree.insert(2, r._element)
return s
def box(s, x, y, w, h, text, size, color, *, bold=False, font=BFONT, align=PP_ALIGN.LEFT,
anchor=MSO_ANCHOR.TOP, italic=False, ls=1.0):
tb = s.shapes.add_textbox(x, y, w, h); tf = tb.text_frame
tf.word_wrap = True; tf.vertical_anchor = anchor
tf.margin_left = tf.margin_right = Pt(2); tf.margin_top = tf.margin_bottom = Pt(2)
for i, ln in enumerate(text.split("\n")):
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
p.alignment = align; p.line_spacing = ls
r = p.add_run(); r.text = ln
r.font.size = Pt(size); r.font.bold = bold; r.font.italic = italic
r.font.name = font; r.font.color.rgb = color
return tb
def rect(s, x, y, w, h, color, line=None, lw=1.0):
r = s.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, x, y, w, h)
r.fill.solid(); r.fill.fore_color.rgb = color
if line: r.line.color.rgb = line; r.line.width = Pt(lw)
else: r.line.fill.background()
r.shadow.inherit = False
r.adjustments[0] = 0.06
return r
def bar(s, x, y, w, h, color):
r = s.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, w, h)
r.fill.solid(); r.fill.fore_color.rgb = color; r.line.fill.background(); r.shadow.inherit = False
return r
def node_title(s, title, color=TEAL):
c = s.shapes.add_shape(MSO_SHAPE.OVAL, Inches(0.62), Inches(0.62), Inches(0.22), Inches(0.22))
c.fill.solid(); c.fill.fore_color.rgb = color; c.line.fill.background(); c.shadow.inherit = False
box(s, Inches(1.0), Inches(0.45), Inches(11.7), Inches(0.95), title, 30, WHITE,
bold=True, font=HFONT, anchor=MSO_ANCHOR.MIDDLE)
bar(s, Inches(0.62), Inches(1.42), Inches(1.1), Pt(3), color)
def stat(s, x, y, w, big, label, color=TEAL, big_size=46, panel=PANEL):
rect(s, x, y, w, Inches(1.7), panel, line=BORDER)
bar(s, x, y, Inches(0.06), Inches(1.7), color)
box(s, x + Inches(0.12), y + Inches(0.22), w - Inches(0.2), Inches(0.95), big, big_size, color,
bold=True, font=MONO, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
box(s, x + Inches(0.12), y + Inches(1.12), w - Inches(0.2), Inches(0.5), label, 12, MUTED,
align=PP_ALIGN.CENTER)
def pic_fit(s, path, rx, ry, rw, rh, card=False):
iw, ih = Image.open(path).size; ar = iw / ih
rw_e, rh_e = int(rw), int(rh)
if rw_e / rh_e > ar:
h = rh_e; w = int(rh_e * ar)
else:
w = rw_e; h = int(rw_e / ar)
x = int(rx) + (rw_e - w) // 2; y = int(ry) + (rh_e - h) // 2
if card:
pad = Inches(0.12)
rect(s, Emu(x) - pad, Emu(y) - pad, Emu(w) + 2 * pad, Emu(h) + 2 * pad, CARD_W)
s.shapes.add_picture(str(path), Emu(x), Emu(y), width=Emu(w), height=Emu(h))
cv, tm = META["cv_metrics"], META["test_metrics"]
cm = tm["confusion_matrix"]
# 1 ── Title
s = slide()
bar(s, 0, Inches(2.95), SW, Pt(3), TEAL)
c = s.shapes.add_shape(MSO_SHAPE.OVAL, Inches(0.95), Inches(1.95), Inches(0.3), Inches(0.3))
c.fill.solid(); c.fill.fore_color.rgb = TEAL; c.line.fill.background(); c.shadow.inherit = False
box(s, Inches(1.5), Inches(1.55), Inches(11), Inches(1.2), "MULEGUARD", 60, WHITE, bold=True, font=HFONT)
box(s, Inches(0.98), Inches(3.15), Inches(11.5), Inches(1.0),
"Real-time AI/ML detection of suspicious mule accounts β€” and the rings they move money through.",
21, TEAL, italic=True, ls=1.1)
stat(s, Inches(1.2), Inches(4.15), Inches(3.4), f"{cv['cv_pr_auc_mean']:.2f}", "CV PR-AUC", TEAL, 44)
stat(s, Inches(4.95), Inches(4.15), Inches(3.4), f"{tm['recall']*100:.0f}%", "recall β€” mules caught", TEAL, 44)
stat(s, Inches(8.7), Inches(4.15), Inches(3.4), f"{RINGS['n_rings']} rings", "mule rings surfaced", AMBER, 44)
box(s, Inches(0.98), Inches(6.5), Inches(11.5), Inches(0.5),
"BOI Hackathon Β· Problem Statement 2 Β· Classification of Suspicious Mule Accounts", 14, MUTED)
# 2 ── Problem
s = slide(); node_title(s, "Mules move stolen money β€” in rings, in minutes")
box(s, Inches(0.7), Inches(1.85), Inches(7.0), Inches(4.2),
"Cyber-fraud relies on mule accounts to receive, layer and conceal stolen funds "
"across UPI, IMPS, NEFT and cards.\n\n"
"They are recruited and discarded fast, and they operate in networks β€” fan-in / fan-out "
"and layering chains β€” that static rules can't keep up with.\n\n"
"Banks need to learn the behaviour, flag accounts proactively, and act on the whole "
"ring before the money is gone.", 19, WHITE, ls=1.2)
stat(s, Inches(8.0), Inches(1.95), Inches(2.4), "β‚Ή11k cr+", "reported cyber-fraud\nloss, 2024 (I4C)", RED, 28)
stat(s, Inches(10.65), Inches(1.95), Inches(2.0), "Minutes", "to act before\nfunds vanish", AMBER, 28)
rect(s, Inches(8.0), Inches(3.95), Inches(4.65), Inches(2.1), PANEL, line=TEAL, lw=1.5)
box(s, Inches(8.25), Inches(4.15), Inches(4.2), Inches(1.7),
"Goal: separate mule from legitimate accounts β€” accurately, in real time, with auditable "
"reasons, and surface the rings.", 17, WHITE, anchor=MSO_ANCHOR.MIDDLE, italic=True, ls=1.15)
# 3 ── Data
s = slide(); node_title(s, "One dataset. Three hard problems at once")
stat(s, Inches(0.7), Inches(1.85), Inches(2.85), "9,082", "accounts")
stat(s, Inches(3.75), Inches(1.85), Inches(2.85), "3,924", "anonymized features")
stat(s, Inches(6.8), Inches(1.85), Inches(2.85), "0.89%", "are mules", RED)
stat(s, Inches(9.85), Inches(1.85), Inches(2.8), "81", "positives only", RED)
box(s, Inches(0.7), Inches(4.0), Inches(11.9), Inches(2.6),
"β€’ Extreme class imbalance β€” 1 mule per ~112 accounts\n"
"β€’ Very high dimensionality β€” 3,924 mostly-anonymized features\n"
"β€’ Heavy missingness, mixed numeric & categorical types\n\n"
"Accuracy is a trap: predicting β€œall legit” already scores 99.1%. "
"We optimize PR-AUC, recall-at-precision and F2 instead.", 19, WHITE, ls=1.35)
# 4 ── Architecture
s = slide(); node_title(s, "MuleGuard β€” end to end")
pic_fit(s, DOCS / "architecture.png", Inches(0.7), Inches(1.65), Inches(11.95), Inches(4.7), card=True)
box(s, Inches(0.7), Inches(6.55), Inches(11.95), Inches(0.6),
"Ingest β†’ engineer features (+ anomaly fusion) β†’ score β†’ explain β†’ detect rings β†’ alert β†’ "
"freeze. Every stage shares the same persisted artifacts.", 14, MUTED, italic=True,
align=PP_ALIGN.CENTER)
# 5 ── Rigor / leakage
s = slide(); node_title(s, "We caught the leakage others would ship", RED)
box(s, Inches(0.7), Inches(1.7), Inches(11.9), Inches(0.7),
"A naive model scored a perfect 100% β€” a red flag, not a win. We traced it to two "
"label-leaking features and removed them:", 18, WHITE)
for i, (feat, desc, auc) in enumerate([
("F3912", "A binary β€œfraud-flagged” indicator β€” 1 for 79 of 81 mules. A copy of the "
"label, not learned behaviour.", "AUC 0.987"),
("F2230", "Observation month. ALL legit accounts in Oct-25, ALL mules in Sep / Nov / Dec-25 "
"β€” a data-collection artifact.", "AUC 1.000")]):
x = Inches(0.7) + i * Inches(6.1)
rect(s, x, Inches(2.65), Inches(5.85), Inches(2.0), PANEL, line=RED, lw=1.5)
box(s, x + Inches(0.3), Inches(2.8), Inches(2.8), Inches(0.7), feat, 30, RED, bold=True, font=MONO)
box(s, x + Inches(3.2), Inches(2.92), Inches(2.4), Inches(0.5), auc, 16, MUTED, bold=True,
align=PP_ALIGN.RIGHT)
box(s, x + Inches(0.3), Inches(3.6), Inches(5.25), Inches(1.0), desc, 15, WHITE, ls=1.12)
rect(s, Inches(0.7), Inches(4.95), Inches(11.95), Inches(1.75), PANEL, line=TEAL, lw=1.5)
box(s, Inches(1.0), Inches(5.1), Inches(11.35), Inches(1.5),
"After removing them, LightGBM reaches CV PR-AUC β‰ˆ 0.88 while a logistic baseline manages "
"only 0.39. That gap is the proof: the model learns genuine non-linear behaviour β€” not a "
"label shortcut. Honest metrics over a fake leaderboard.", 18, WHITE, anchor=MSO_ANCHOR.MIDDLE,
italic=True, ls=1.18)
# 6 ── Results
s = slide(); node_title(s, "Deployable β€” not just accurate")
stat(s, Inches(0.7), Inches(1.9), Inches(2.85), f"{cv['cv_pr_auc_mean']:.2f}", "CV PR-AUC (Β±0.07)", TEAL, 50)
stat(s, Inches(3.75), Inches(1.9), Inches(2.85), f"{tm['recall']*100:.0f}%", "recall β€” mules caught", TEAL, 50)
stat(s, Inches(6.8), Inches(1.9), Inches(2.85), f"{tm['precision']*100:.0f}%", "precision", TEAL, 50)
stat(s, Inches(9.85), Inches(1.9), Inches(2.8), f"{cm['tp']}/{cm['tp']+cm['fn']}", "mules caught on test", TEAL, 50)
rect(s, Inches(0.7), Inches(4.0), Inches(11.95), Inches(2.3), PANEL, line=BORDER)
box(s, Inches(1.05), Inches(4.3), Inches(11.3), Inches(1.8),
f"On a held-out test set of 1,817 accounts:\n\n"
f"β€’ Caught {cm['tp']} of {cm['tp']+cm['fn']} mules, with only {cm['fp']} false positives "
f"out of 1,801 legitimate accounts\n"
f"β€’ Just {tm['alerts_raised']} alerts raised β€” a {tm['alert_rate']:.1%} alert rate a real "
f"fraud team can actually work", 19, WHITE, ls=1.3)
# 7 ── β˜… Novelty: rings
s = slide(); node_title(s, "We don't just flag accounts β€” we expose the rings", TEAL)
pic_fit(s, DOCS / "mule_rings.png", Inches(0.6), Inches(1.6), Inches(8.1), Inches(5.5))
rx = Inches(8.95)
stat(s, rx, Inches(1.75), Inches(3.7), f"{RINGS['mules_in_rings']}/{RINGS['total_mules']}",
"mules sit inside a ring", RED, 40)
stat(s, rx, Inches(3.6), Inches(3.7), f"{RINGS['n_rings']}", "dense rings surfaced", AMBER, 40)
box(s, rx, Inches(5.45), Inches(3.7), Inches(1.7),
"No transaction edges? We infer rings from behavioural co-similarity β€” a k-NN graph over the "
"flagged accounts + Louvain communities. Freeze the ring, stop the circulation.",
14, WHITE, ls=1.15)
# 8 ── Explainability
s = slide(); node_title(s, "Every alert is explainable")
pic_fit(s, FIG / "shap_importance.png", Inches(0.7), Inches(1.7), Inches(6.0), Inches(5.0), card=True)
box(s, Inches(7.1), Inches(1.85), Inches(5.5), Inches(2.6),
"In banking, β€œwhy was this flagged?” must always have an answer.\n\n"
"MuleGuard attaches SHAP reason codes to every score β€” the exact features driving risk up or "
"down β€” and turns them into a one-line narrative.", 18, WHITE, ls=1.2)
rect(s, Inches(7.1), Inches(4.85), Inches(5.5), Inches(1.7), PANEL, line=TEAL, lw=1.5)
box(s, Inches(7.35), Inches(5.05), Inches(5.0), Inches(1.3),
"β€œRisk 98/100 β€” driven mainly by F3898, F3914 and an elevated anomaly score.”",
17, WHITE, italic=True, anchor=MSO_ANCHOR.MIDDLE)
# 9 ── Live product
s = slide(); node_title(s, "A live, deployed product β€” not a notebook")
pic_fit(s, DOCS / "shot_overview.png", Inches(0.7), Inches(1.65), Inches(11.95), Inches(4.8))
box(s, Inches(0.7), Inches(6.6), Inches(11.95), Inches(0.6),
"FastAPI scoring backend + Streamlit analyst console, fused live. Deployed public on "
"HuggingFace, auto-synced from GitHub. SHAP reason codes on every alert.", 14, MUTED,
italic=True, align=PP_ALIGN.CENTER)
# 10 ── Impact & roadmap
s = slide(); node_title(s, "Impact & path to production")
rect(s, Inches(0.7), Inches(1.75), Inches(5.85), Inches(4.1), PANEL, line=BORDER)
box(s, Inches(1.0), Inches(2.0), Inches(5.3), Inches(0.5), "What it delivers today", 21, TEAL,
bold=True, font=HFONT)
box(s, Inches(1.0), Inches(2.75), Inches(5.3), Inches(3.0),
"β€’ Catches mules early, before funds are layered\n\n"
"β€’ 0.8% alert rate β€” ranked, explained, workable\n\n"
"β€’ Surfaces whole rings to freeze together\n\n"
"β€’ Adapts to evolving fraud via the anomaly score", 16.5, WHITE, ls=1.15)
rect(s, Inches(6.8), Inches(1.75), Inches(5.85), Inches(4.1), PANEL, line=BORDER)
box(s, Inches(7.1), Inches(2.0), Inches(5.3), Inches(0.5), "Productionization roadmap", 21, AMBER,
bold=True, font=HFONT)
box(s, Inches(7.1), Inches(2.75), Inches(5.3), Inches(3.0),
"β†’ Real regulatory feeds (I4C / NCRP) + core banking\n\n"
"β†’ Streaming scoring (Kafka) + retrain feedback loop\n\n"
"β†’ True money-flow graph for ring detection\n\n"
"β†’ Case management + auto-hold on Critical alerts", 16.5, WHITE, ls=1.15)
# 11 ── Closing
s = slide()
bar(s, 0, Inches(3.35), SW, Pt(3), TEAL)
box(s, Inches(0.95), Inches(2.15), Inches(11.5), Inches(1.1), "MULEGUARD", 52, WHITE, bold=True, font=HFONT)
box(s, Inches(0.98), Inches(3.6), Inches(11.5), Inches(1.6),
"Honest ML Β· novelty detection Β· ring discovery Β· full explainability Β· a live demo.\n"
"Built to actually stop fraudulent proceeds β€” not just to top a leaderboard.",
19, TEAL, italic=True, ls=1.25)
box(s, Inches(0.98), Inches(6.4), Inches(11.5), Inches(0.5),
"huggingface.co/spaces/EeshanSingh/MuleGuard Β· BOI Hackathon Β· PS2", 14, MUTED)
OUT = ROOT / "deck" / "MuleGuard_Pitch.pptx"
prs.save(str(OUT))
print("Saved", OUT, "Β·", len(prs.slides._sldIdLst), "slides")