Classic ML · Easy · 15 min

Spot the odd invoice

Two rules you can explain to your accountant before you touch a model: z-score and IQR. You will see why one typo hides another.

AMOUNTS holds 36 office-supply invoices (synthetic data, in EUR). Two are odd: a typo of 4250 for 425 and a duplicated order of 980. Write five small functions:

zScores(xs): (x - mean) / standard deviation, dividing by n. If every value is the same, return zeros.

quantile(sorted, p): takes a sorted list. Work out the position (n - 1) * p and interpolate between the two neighbours (the NumPy default).

iqrFences(xs): returns { q1, q3, iqr, low, high }, with low = q1 - 1.5 * iqr and high = q3 + 1.5 * iqr.

flagZ(xs, limit) and flagIQR(xs): the indexes of the flagged invoices, in order. The preview draws what each rule flags and lets you move the z limit.

Challenges 0/4

  • z-score with the deviation over n, and zeros when nothing varies
  • Interpolated quartiles and fences at 1.5 IQR
  • With a limit of 3, the z-score only sees the 4250 typo
  • The IQR flags the typo and the duplicated order

function zScores(xs) {
  // (x - mean) / standard deviation, dividing by n; all zeros when every value is the same
  return xs.map(() => 0);
}

function quantile(sorted, p) {
  // position (n - 1) * p, then interpolate between the two neighbours
  return sorted[0];
}

function iqrFences(xs) {
  // q1, q3, iqr = q3 - q1, low = q1 - 1.5 * iqr, high = q3 + 1.5 * iqr
  return { q1: 0, q3: 0, iqr: 0, low: 0, high: 0 };
}

function flagZ(xs, limit) {
  // indexes whose |z| is above limit
  return [];
}

function flagIQR(xs) {
  // indexes below low or above high
  return [];
}

console.log('z:', flagZ(AMOUNTS, 3), 'IQR:', flagIQR(AMOUNTS));
Console output appears here (console.log).

The idea

Before you train anything, try a rule you can explain in one sentence. "This invoice is more than three deviations from the mean" or "it sits above the IQR fence" is easy to follow in a review with your accountant, and that is worth more than a model nobody can justify.

The z-score has a known flaw: the mean and the deviation come from all the data, the odd values included. The 4250 typo inflates the deviation so much that the duplicated 980 order ends up with a z below 1 and slips through. This is called masking.

The IQR uses quartiles, and an extreme value barely moves a quartile. That is why it flags both invoices. The isolation idea goes one step further: cut the range at random until the invoice is alone. An odd invoice is isolated in few cuts. This is the basis of Isolation Forest, and the preview computes it with 100 trees.

No rule decides for you. They say what to check first, and a person confirms.

Run it on your machine

With Python and pandas, on this exercise's CSV (CC BY 4.0):

curl -O https://vorluxai.com/data/labs/invoices-office-supplies.csv
python3 -c "
import pandas as pd
df = pd.read_csv('invoices-office-supplies.csv')
q1, q3 = df.amount.quantile([0.25, 0.75])
high = q3 + 1.5 * (q3 - q1)
print(df[df.amount > high])
"

Check what you learned

1. Why does the z-score miss the 980 invoice?
2. What does the IQR have that the z-score lacks?
3. In the isolation idea, what tells you an invoice is odd?
4. What do you do with a flagged invoice?

Go deeper: the scikit-learn reference →

This in production, with your data? Let's talk for 15 minutes →