Module 5 · Dispersion, Shape, and Robust Statistics Module demo

Anomaly Alert Engine

An engine that scores every settlement.

6:13 clipUses lessons 41–50Watch on YouTube

Transcript

47 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-anomaly-alert-engine.ts
Start from GitHub
/**
 * Fintech Math Bootcamp · Module 05 demo · Anomaly Alert Engine
 * A payments team watches how long each merchant's card payments take to settle. When a new settlement
 * arrives, the engine asks: how unusual is this, compared with the merchant's own history?
 * Lessons 041–050: deviations, range and IQR, the two MADs, variance and standard deviation,
 * coefficient of variation, z-score versus robust z-score, skewness and kurtosis.
 * Synthetic data; an alert is a request for review, never proof of fraud.
 */

export type Summary = {n: number; mean: number; median: number; min: number; max: number};

const sum = (x: number[]) => x.reduce((s, v) => s + v, 0);
export const mean = (x: number[]) => sum(x) / x.length;

// 034 · median, reused as the robust centre
export function median(x: number[]): number {
  const s = [...x].sort((a, b) => a - b), m = Math.floor(s.length / 2);
  return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
}

// 041 · deviation, absolute deviation and squared deviation from a centre
export const deviations = (x: number[], center: number) => x.map(v => v - center);
export const absolute = (d: number[]) => d.map(Math.abs);
export const squared = (d: number[]) => d.map(v => v * v);

// 042 · range and interquartile range (linear interpolation between order statistics)
export function quantile(x: number[], p: number): number {
  const s = [...x].sort((a, b) => a - b), h = (s.length - 1) * p, i = Math.floor(h);
  return s[i] + (h - i) * (s[Math.min(i + 1, s.length - 1)] - s[i]);
}
export const range = (x: number[]) => Math.max(...x) - Math.min(...x);
export const iqr = (x: number[]) => quantile(x, 0.75) - quantile(x, 0.25);

// 043 · mean absolute deviation: average distance from the mean
export const meanAbsDev = (x: number[]) => mean(absolute(deviations(x, mean(x))));

// 044 · median absolute deviation: median distance from the median (raw), and its normal-scaled version
export const rawMAD = (x: number[]) => median(absolute(deviations(x, median(x))));
export const scaledMAD = (x: number[]) => 1.4826 * rawMAD(x);

// 045 · population and sample variance from the same sum of squared deviations
export function variances(x: number[]) {
  if (x.length < 2) throw new Error("Sample variance needs n >= 2");
  const ss = sum(squared(deviations(x, mean(x))));
  return {ss, population: ss / x.length, sample: ss / (x.length - 1)};
}

// 046 · standard deviation returns the spread to the original unit (hours, dollars)
export const sampleSD = (x: number[]) => Math.sqrt(variances(x).sample);

// 047 · coefficient of variation: spread relative to scale, for positive quantities
export function cv(x: number[]): number {
  const m = mean(x);
  if (m === 0) throw new Error("CV undefined at zero mean");
  return sampleSD(x) / Math.abs(m);
}

// 048 · classic z-score (mean, sample SD) and robust z-score (median, raw MAD, 0.6745 scaling)
export const zScore = (value: number, history: number[]) => (value - mean(history)) / sampleSD(history);
export function robustZ(value: number, history: number[]): number | null {
  const mad = rawMAD(history);
  return mad === 0 ? null : 0.67448975 * (value - median(history)) / mad;
}

// 049 · moment skewness; 050 · kurtosis and excess kurtosis (population moments)
export function shape(x: number[]) {
  const m = mean(x), mk = (k: number) => mean(x.map(v => (v - m) ** k));
  const kurtosis = mk(4) / mk(2) ** 2;
  return {skewness: mk(3) / mk(2) ** 1.5, kurtosis, excess: kurtosis - 3};
}

export const Z_LIMIT = 3, ROBUST_LIMIT = 3.5;

// The engine: score one new settlement against the merchant's own history.
export function score(value: number, history: number[]) {
  const z = zScore(value, history), rz = robustZ(value, history);
  return {value, z, robustZ: rz, classicAlert: Math.abs(z) > Z_LIMIT, robustAlert: rz !== null && Math.abs(rz) > ROBUST_LIMIT};
}

const r = (v: number, d = 4) => Math.round(v * 10 ** d) / 10 ** d;

export function runDemo() {
  // Settlement delay in hours for Maple Street Bakery's last twelve card batches.
  // Batch 12 got stuck over a long weekend: 30 hours.
  const delays = [2, 3, 2, 4, 3, 2, 3, 5, 2, 3, 4, 30];
  const m = mean(delays), med = median(delays);
  const dev = deviations(delays, m);
  const v = variances(delays);
  const withoutStuck = delays.slice(0, -1);

  // three new batches arrive and are scored against the history
  const incoming = [4, 12, 1].map(x => score(x, delays));
  const target = incoming[1];

  // two merchants of very different scale: average ticket in dollars
  const bakeryTickets = [6.5, 9, 12, 7.5, 14, 8, 5.5, 10.5];
  const furnitureTickets = [980, 1450, 1210, 890, 1320, 1150, 1580, 1020];
  const merchant = (name: string, x: number[]) => ({name, tickets: x, mean: mean(x), sd: sampleSD(x), cv: cv(x)});

  const sh = shape(delays), shClean = shape(withoutStuck);

  return {
    delays, n: delays.length, mean: m, median: med,
    deviations: dev.map(d => r(d)), absolute: absolute(dev).map(d => r(d)), squared: squared(dev).map(d => r(d)),
    range: range(delays), q25: quantile(delays, 0.25), q75: quantile(delays, 0.75), iqr: iqr(delays),
    meanAbsDev: meanAbsDev(delays), rawMAD: rawMAD(delays), scaledMAD: scaledMAD(delays),
    ss: v.ss, populationVariance: v.population, sampleVariance: v.sample,
    populationSD: Math.sqrt(v.population), sampleSD: Math.sqrt(v.sample),
    limits: {z: Z_LIMIT, robust: ROBUST_LIMIT},
    incoming, target,
    // what the classic score would say if the stuck batch had never inflated the spread
    clean: {mean: mean(withoutStuck), sd: sampleSD(withoutStuck), z: zScore(target.value, withoutStuck)},
    merchants: [merchant('Maple Street Bakery', bakeryTickets), merchant('Oakline Furniture', furnitureTickets)],
    shape: sh, shapeWithoutStuck: shClean,
  };
}

export const checkedResult = {"delays":[2,3,2,4,3,2,3,5,2,3,4,30],"n":12,"mean":5.25,"median":3,"deviations":[-3.25,-2.25,-3.25,-1.25,-2.25,-3.25,-2.25,-0.25,-3.25,-2.25,-1.25,24.75],"absolute":[3.25,2.25,3.25,1.25,2.25,3.25,2.25,0.25,3.25,2.25,1.25,24.75],"squared":[10.5625,5.0625,10.5625,1.5625,5.0625,10.5625,5.0625,0.0625,10.5625,5.0625,1.5625,612.5625],"range":28,"q25":2,"q75":4,"iqr":2,"meanAbsDev":4.125,"rawMAD":1,"scaledMAD":1.4826,"ss":678.25,"populationVariance":56.520833333333336,"sampleVariance":61.65909090909091,"populationSD":7.5180338741810235,"sampleSD":7.852330285277798,"limits":{"z":3,"robust":3.5},"incoming":[{"value":4,"z":-0.1591884134501581,"robustZ":0.67448975,"classicAlert":false,"robustAlert":false},{"value":12,"z":0.8596174326308538,"robustZ":6.07040775,"classicAlert":false,"robustAlert":true},{"value":1,"z":-0.5412406057305376,"robustZ":-1.3489795,"classicAlert":false,"robustAlert":false}],"target":{"value":12,"z":0.8596174326308538,"robustZ":6.07040775,"classicAlert":false,"robustAlert":true},"clean":{"mean":3,"sd":1,"z":9},"merchants":[{"name":"Maple Street Bakery","tickets":[6.5,9,12,7.5,14,8,5.5,10.5],"mean":9.125,"sd":2.8753881725529062,"cv":0.3151110326085377},{"name":"Oakline Furniture","tickets":[980,1450,1210,890,1320,1150,1580,1020],"mean":1200,"sd":239.28464818525833,"cv":0.19940387348771527}],"shape":{"skewness":2.9366174393368616,"kurtosis":9.802634215757388,"excess":6.802634215757388},"shapeWithoutStuck":{"skewness":0.6292853089020909,"kurtosis":2.4200000000000004,"excess":-0.5799999999999996}};

// Run this file directly: npx tsx lessons/05-dispersion-shape-and-robust-statistics/demo-anomaly-alert-engine.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

{
  "delays": [
    2,
    3,
    2,
    4,
    3,
    2,
    3,
    5,
    2,
    3,
    4,
    30
  ],
  "n": 12,
  "mean": 5.25,
  "median": 3,
  "deviations": [
    -3.25,
    -2.25,
    -3.25,
    -1.25,
    -2.25,
    -3.25,
    -2.25,
    -0.25,
    -3.25,
    -2.25,
    -1.25,
    24.75
  ],
  "absolute": [
    3.25,
    2.25,
    3.25,
    1.25,
    2.25,
    3.25,
    2.25,
    0.25,
    3.25,
    2.25,
    1.25,
    24.75
  ],
  "squared": [
    10.5625,
    5.0625,
    10.5625,
    1.5625,
    5.0625,
    10.5625,
    5.0625,
    0.0625,
    10.5625,
    5.0625,
    1.5625,
    612.5625
  ],
  "range": 28,
  "q25": 2,
  "q75": 4,
  "iqr": 2,
  "meanAbsDev": 4.125,
  "rawMAD": 1,
  "scaledMAD": 1.4826,
  "ss": 678.25,
  "populationVariance": 56.520833333333336,
  "sampleVariance": 61.65909090909091,
  "populationSD": 7.5180338741810235,
  "sampleSD": 7.852330285277798,
  "limits": {
    "z": 3,
    "robust": 3.5
  },
  "incoming": [
    {
      "value": 4,
      "z": -0.1591884134501581,
      "robustZ": 0.67448975,
      "classicAlert": false,
      "robustAlert": false
    },
    {
      "value": 12,
      "z": 0.8596174326308538,
      "robustZ": 6.07040775,
      "classicAlert": false,
      "robustAlert": true
    },
    {
      "value": 1,
      "z": -0.5412406057305376,
      "robustZ": -1.3489795,
      "classicAlert": false,
      "robustAlert": false
    }
  ],
  "target": {
    "value": 12,
    "z": 0.8596174326308538,
    "robustZ": 6.07040775,
    "classicAlert": false,
    "robustAlert": true
  },
  "clean": {
    "mean": 3,
    "sd": 1,
    "z": 9
  },
  "merchants": [
    {
      "name": "Maple Street Bakery",
      "tickets": [
        6.5,
        9,
        12,
        7.5,
        14,
        8,
        5.5,
        10.5
      ],
      "mean": 9.125,
      "sd": 2.8753881725529062,
      "cv": 0.3151110326085377
    },
    {
      "name": "Oakline Furniture",
      "tickets": [
        980,
        1450,
        1210,
        890,
        1320,
        1150,
        1580,
        1020
      ],
      "mean": 1200,
      "sd": 239.28464818525833,
      "cv": 0.19940387348771527
    }
  ],
  "shape": {
    "skewness": 2.9366174393368616,
    "kurtosis": 9.802634215757388,
    "excess": 6.802634215757388
  },
  "shapeWithoutStuck": {
    "skewness": 0.6292853089020909,
    "kurtosis": 2.4200000000000004,
    "excess": -0.5799999999999996
  }
}

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

What the demo does

An engine scores new card settlements against each merchant's own history: deviations, range and IQR, the two MADs, variance and standard deviation, the coefficient of variation, classic versus robust z-scores, and skewness and kurtosis as tail warnings. Every module 05 lesson becomes one engine feature.