Module 6 · Probability and Random Variables Module demo

Fraud Alert Triage Simulator

One alert, one question.

5:59 clipUses lessons 51–60Watch on YouTube

Transcript

43 sentences · select one to jump there

Code lab

Run it yourself

The demo source in one language. Edit it, run TypeScript and Python right here, and compare with the expected output.

demo-fraud-alert-triage-simulator.ts
Start from GitHub
/**
 * Fintech Math Bootcamp · Module 06 demo · Fraud Alert Triage Simulator
 * A card issuer runs two fraud rules on 100,000 synthetic payments a month: rule A (new device) and
 * rule B (unusual amount). When an alert fires, what is the chance the payment is really fraud, and
 * what loss should the review team budget for?
 * Lessons 051–060: outcome spaces, probability rules, conditional probability, independence, Bayes,
 * discrete random variables, expected value, variance, joint and conditional distributions, covariance.
 * Synthetic counts; not a description of any real fraud system.
 */

// One cell of the outcome space: is it fraud, does rule A fire, does rule B fire, and how many payments.
export type Cell = {fraud: boolean; a: boolean; b: boolean; count: number};

// 051 · the outcome space: eight disjoint cells that together cover every payment
export function outcomeSpace(): Cell[] {
  const cells: Cell[] = [];
  // fraud payments: A only, B only, both, neither
  const fraud = [[true, false, 250], [false, true, 150], [true, true, 550], [false, false, 50]] as const;
  // legitimate payments
  const legit = [[true, false, 2900], [false, true, 1950], [true, true, 100], [false, false, 94050]] as const;
  for (const [a, b, count] of fraud) cells.push({fraud: true, a, b, count});
  for (const [a, b, count] of legit) cells.push({fraud: false, a, b, count});
  return cells;
}

const total = (cells: Cell[]) => cells.reduce((s, c) => s + c.count, 0);
// probability of an event = its share of the outcome space
export const prob = (cells: Cell[], event: (c: Cell) => boolean) => total(cells.filter(event)) / total(cells);

// 052 · union without double counting: P(A ∪ B) = P(A) + P(B) − P(A ∩ B)
export function union(pa: number, pb: number, both: number) {
  if (both < Math.max(0, pa + pb - 1) || both > Math.min(pa, pb)) throw new Error("Impossible intersection");
  return {either: pa + pb - both, naive: pa + pb, neither: 1 - (pa + pb - both), onlyA: pa - both, onlyB: pb - both};
}

// 053 · conditional probability: shrink the denominator to the condition
export const conditional = (cells: Cell[], event: (c: Cell) => boolean, given: (c: Cell) => boolean) =>
  total(cells.filter(c => given(c) && event(c))) / total(cells.filter(given));

// 054 · independence check: compare the measured joint with the product of the marginals
export const independenceGap = (pa: number, pb: number, both: number) => ({ifIndependent: pa * pb, measured: both, ratio: both / (pa * pb)});

// 055 · Bayes with the base rate
export const bayes = (prior: number, sensitivity: number, falsePositiveRate: number) =>
  sensitivity * prior / (sensitivity * prior + falsePositiveRate * (1 - prior));

// 056 / 057 / 058 · a discrete random variable: its expected value, second moment and variance
export function moments(values: number[], p: number[]) {
  const total = p.reduce((s, v) => s + v, 0);
  if (Math.abs(total - 1) > 1e-9) throw new Error("Probabilities must sum to 1");
  const mean = values.reduce((s, x, i) => s + p[i] * x, 0);
  const second = values.reduce((s, x, i) => s + p[i] * x * x, 0);
  return {contributions: values.map((x, i) => x * p[i]), mean, second, variance: second - mean ** 2, sd: Math.sqrt(second - mean ** 2)};
}

// 059 · joint, marginal and conditional from a joint table (rows: segment; columns: fraud, legitimate)
export function jointTable(counts: number[][]) {
  const n = counts.flat().reduce((s, v) => s + v, 0);
  const joint = counts.map(r => r.map(v => v / n));
  const rowMarginal = joint.map(r => r[0] + r[1]);
  const fraudMarginal = joint[0][0] + joint[1][0];
  return {n, joint, rowMarginal, fraudMarginal, fraudGivenRow: joint.map((r, i) => r[0] / rowMarginal[i])};
}

// 060 · covariance and correlation of two loss types over shared states
export function covariance(states: {p: number; x: number; y: number}[]) {
  const ex = states.reduce((s, a) => s + a.p * a.x, 0), ey = states.reduce((s, a) => s + a.p * a.y, 0);
  const vx = states.reduce((s, a) => s + a.p * (a.x - ex) ** 2, 0), vy = states.reduce((s, a) => s + a.p * (a.y - ey) ** 2, 0);
  const cov = states.reduce((s, a) => s + a.p * (a.x - ex) * (a.y - ey), 0);
  return {ex, ey, vx, vy, cov, corr: cov / Math.sqrt(vx * vy), varTotal: vx + vy + 2 * cov, varIfIndependent: vx + vy};
}

export function runDemo() {
  const cells = outcomeSpace();
  const payments = total(cells);
  const count = (f: (c: Cell) => boolean) => total(cells.filter(f));
  const A = (c: Cell) => c.a, B = (c: Cell) => c.b, both = (c: Cell) => c.a && c.b, alert = (c: Cell) => c.a || c.b, fraud = (c: Cell) => c.fraud;

  // 051 · the triage policy maps every payment to one of three outcomes
  const outcomes = {approved: prob(cells, c => !alert(c)), review: prob(cells, c => alert(c) && !both(c)), declined: prob(cells, both)};
  const notDeclined = outcomes.approved + outcomes.review;

  const pA = prob(cells, A), pB = prob(cells, B), pAB = prob(cells, both);
  const rules = union(pA, pB, pAB);

  const prior = prob(cells, fraud);
  const sensitivity = conditional(cells, alert, fraud);
  const falsePositiveRate = conditional(cells, alert, c => !c.fraud);
  const accuracy = prob(cells, c => alert(c) === c.fraud);

  // 056–058 · fraud loss on one alerted payment if it were approved without review
  const posterior = bayes(prior, sensitivity, falsePositiveRate);
  const largeShare = 0.25, small = 120, large = 900;
  const lossValues = [0, small, large];
  const lossProbs = [1 - posterior, posterior * (1 - largeShare), posterior * largeShare];
  const loss = moments(lossValues, lossProbs);

  // 059 · the same alerts split by customer segment: rows new / established, columns fraud / legitimate
  const segments = jointTable([[500, 900], [450, 4050]]);

  // 060 · two loss types on the same day: card-not-present fraud (x) and account takeover (y), in dollars
  const dayStates = [{p: 0.55, x: 2000, y: 1500}, {p: 0.25, x: 6000, y: 1000}, {p: 0.15, x: 3000, y: 6000}, {p: 0.05, x: 20000, y: 12000}];
  const days = covariance(dayStates);

  return {
    payments,
    cells: cells.map(c => ({...c})),
    counts: {fraud: count(fraud), legit: count(c => !c.fraud), A: count(A), B: count(B), both: count(both), alerts: count(alert),
      caught: count(c => c.fraud && alert(c)), falseAlarms: count(c => !c.fraud && alert(c)), missed: count(c => c.fraud && !alert(c))},
    outcomes, notDeclined,
    rules: {pA, pB, pAB, ...rules},
    conditionalFraud: {givenBoth: conditional(cells, fraud, both), givenAOnly: conditional(cells, fraud, c => c.a && !c.b),
      givenBOnly: conditional(cells, fraud, c => c.b && !c.a), givenAlert: conditional(cells, fraud, alert), givenNoAlert: conditional(cells, fraud, c => !alert(c))},
    independence: {...independenceGap(pA, pB, pAB), bGivenA: pAB / pA},
    detector: {prior, sensitivity, falsePositiveRate, accuracy, posterior},
    loss: {values: lossValues, probs: lossProbs, ...loss, per1000: 1000 * loss.mean, sdPer1000: Math.sqrt(1000) * loss.sd, largeShare},
    segments,
    days: {...days, states: dayStates, sdTotal: Math.sqrt(days.varTotal), sdIfIndependent: Math.sqrt(days.varIfIndependent)},
  };
}

export const checkedResult = {"payments":100000,"cells":[{"fraud":true,"a":true,"b":false,"count":250},{"fraud":true,"a":false,"b":true,"count":150},{"fraud":true,"a":true,"b":true,"count":550},{"fraud":true,"a":false,"b":false,"count":50},{"fraud":false,"a":true,"b":false,"count":2900},{"fraud":false,"a":false,"b":true,"count":1950},{"fraud":false,"a":true,"b":true,"count":100},{"fraud":false,"a":false,"b":false,"count":94050}],"counts":{"fraud":1000,"legit":99000,"A":3800,"B":2750,"both":650,"alerts":5900,"caught":950,"falseAlarms":4950,"missed":50},"outcomes":{"approved":0.941,"review":0.0525,"declined":0.0065},"notDeclined":0.9934999999999999,"rules":{"pA":0.038,"pB":0.0275,"pAB":0.0065,"either":0.059000000000000004,"naive":0.0655,"neither":0.941,"onlyA":0.0315,"onlyB":0.021},"conditionalFraud":{"givenBoth":0.8461538461538461,"givenAOnly":0.07936507936507936,"givenBOnly":0.07142857142857142,"givenAlert":0.16101694915254236,"givenNoAlert":0.0005313496280552603},"independence":{"ifIndependent":0.001045,"measured":0.0065,"ratio":6.220095693779904,"bGivenA":0.17105263157894737},"detector":{"prior":0.01,"sensitivity":0.95,"falsePositiveRate":0.05,"accuracy":0.95,"posterior":0.16101694915254236},"loss":{"values":[0,120,900],"probs":[0.8389830508474576,0.12076271186440676,0.04025423728813559],"contributions":[0,14.491525423728811,36.22881355932203],"mean":50.72033898305084,"second":34344.91525423728,"variance":31772.362467681698,"sd":178.24803636416783,"per1000":50720.33898305084,"sdPer1000":5636.697833632888,"largeShare":0.25},"segments":{"n":5900,"joint":[[0.0847457627118644,0.15254237288135594],[0.07627118644067797,0.6864406779661016]],"rowMarginal":[0.23728813559322035,0.7627118644067796],"fraudMarginal":0.1610169491525424,"fraudGivenRow":[0.3571428571428571,0.1]},"days":{"ex":4050,"ey":2575,"vx":16147500,"vy":7456875,"cov":7421250,"corr":0.6763102722078465,"varTotal":38446875,"varIfIndependent":23604375,"states":[{"p":0.55,"x":2000,"y":1500},{"p":0.25,"x":6000,"y":1000},{"p":0.15,"x":3000,"y":6000},{"p":0.05,"x":20000,"y":12000}],"sdTotal":6200.55441069587,"sdIfIndependent":4858.433389478547}};

// Run this file directly: npx tsx lessons/06-probability-and-random-variables/demo-fraud-alert-triage-simulator.ts
if (process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/\\/g, "/").split("/").pop()!)) {
  console.log(JSON.stringify(runDemo(), null, 2));
}

Your output

Press Run to execute the code in your browser.

Expected output

{
  "payments": 100000,
  "cells": [
    {
      "fraud": true,
      "a": true,
      "b": false,
      "count": 250
    },
    {
      "fraud": true,
      "a": false,
      "b": true,
      "count": 150
    },
    {
      "fraud": true,
      "a": true,
      "b": true,
      "count": 550
    },
    {
      "fraud": true,
      "a": false,
      "b": false,
      "count": 50
    },
    {
      "fraud": false,
      "a": true,
      "b": false,
      "count": 2900
    },
    {
      "fraud": false,
      "a": false,
      "b": true,
      "count": 1950
    },
    {
      "fraud": false,
      "a": true,
      "b": true,
      "count": 100
    },
    {
      "fraud": false,
      "a": false,
      "b": false,
      "count": 94050
    }
  ],
  "counts": {
    "fraud": 1000,
    "legit": 99000,
    "A": 3800,
    "B": 2750,
    "both": 650,
    "alerts": 5900,
    "caught": 950,
    "falseAlarms": 4950,
    "missed": 50
  },
  "outcomes": {
    "approved": 0.941,
    "review": 0.0525,
    "declined": 0.0065
  },
  "notDeclined": 0.9934999999999999,
  "rules": {
    "pA": 0.038,
    "pB": 0.0275,
    "pAB": 0.0065,
    "either": 0.059000000000000004,
    "naive": 0.0655,
    "neither": 0.941,
    "onlyA": 0.0315,
    "onlyB": 0.021
  },
  "conditionalFraud": {
    "givenBoth": 0.8461538461538461,
    "givenAOnly": 0.07936507936507936,
    "givenBOnly": 0.07142857142857142,
    "givenAlert": 0.16101694915254236,
    "givenNoAlert": 0.0005313496280552603
  },
  "independence": {
    "ifIndependent": 0.001045,
    "measured": 0.0065,
    "ratio": 6.220095693779904,
    "bGivenA": 0.17105263157894737
  },
  "detector": {
    "prior": 0.01,
    "sensitivity": 0.95,
    "falsePositiveRate": 0.05,
    "accuracy": 0.95,
    "posterior": 0.16101694915254236
  },
  "loss": {
    "values": [
      0,
      120,
      900
    ],
    "probs": [
      0.8389830508474576,
      0.12076271186440676,
      0.04025423728813559
    ],
    "contributions": [
      0,
      14.491525423728811,
      36.22881355932203
    ],
    "mean": 50.72033898305084,
    "second": 34344.91525423728,
    "variance": 31772.362467681698,
    "sd": 178.24803636416783,
    "per1000": 50720.33898305084,
    "sdPer1000": 5636.697833632888,
    "largeShare": 0.25
  },
  "segments": {
    "n": 5900,
    "joint": [
      [
        0.0847457627118644,
        0.15254237288135594
      ],
      [
        0.07627118644067797,
        0.6864406779661016
      ]
    ],
    "rowMarginal": [
      0.23728813559322035,
      0.7627118644067796
    ],
    "fraudMarginal": 0.1610169491525424,
    "fraudGivenRow": [
      0.3571428571428571,
      0.1
    ]
  },
  "days": {
    "ex": 4050,
    "ey": 2575,
    "vx": 16147500,
    "vy": 7456875,
    "cov": 7421250,
    "corr": 0.6763102722078465,
    "varTotal": 38446875,
    "varIfIndependent": 23604375,
    "states": [
      {
        "p": 0.55,
        "x": 2000,
        "y": 1500
      },
      {
        "p": 0.25,
        "x": 6000,
        "y": 1000
      },
      {
        "p": 0.15,
        "x": 3000,
        "y": 6000
      },
      {
        "p": 0.05,
        "x": 20000,
        "y": 12000
      }
    ],
    "sdTotal": 6200.55441069587,
    "sdIfIndependent": 4858.433389478547
  }
}

Prefer your own machine? Every file is in the course repository · open it in Codespaces.

What the demo does

Two fraud rules watch 100,000 synthetic card payments. The simulator builds the outcome space, counts alerts without double counting, tests whether the rules are independent, applies Bayes with the base rate, budgets expected loss per 1,000 alerts, and splits risk by segment and by loss type. Every module 06 lesson becomes one simulator feature.