"""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")