Orchestrate

Report an issue

Something wrong, out of date or unclear? Tell us. Corrections are prioritized over new topics. You don't need to sign in.

0 / 2000

Don't include personal or confidential company details. Reports are reviewed with AI assistance.

Anomaly detection in finance and procurement

Flag unusual invoices and journal entries without labeled examples, keep the review queue short enough to work, and close the loop with reviewers.

Updated Oct 15, 2026Foundational 8 minDeep 40 min
Foundational layer · 8 min read

The 60-second version

Anomaly detection finds the records that don't look like the rest: an invoice ten times the supplier's usual amount, a journal entry posted at an odd time, the same invoice keyed twice. It doesn't say what is wrong. It says where a person should look.

It differs from the prediction topics earlier in this unit in one way that matters. There, the history told the model which deliveries were late. Here, nobody has labeled last year's invoices as "fraud", "duplicate" or "fine". Problems are rare, varied and often never found. So the method learns what normal looks like and points at what doesn't fit.

Three families of methods do this, from simple to clever:

  • Rules and thresholds: "price more than 5% over the purchase order", "same supplier, amount and reference". Clear, cheap, and blind to anything nobody wrote down.
  • Statistical outliers: "far from this supplier's usual amount". Adapts to each supplier, but looks at one number at a time.
  • Machine learning, such as an isolation forest: "an unusual combination", such as a new supplier, a round amount, no purchase order and a late posting. Finds what rules miss, but explains itself less well.

The output of all three is the same: a list a reviewer works through. The real design question is how long that list may be.

Why it matters to the business

Take the course's running example of three-way match exceptions in procure-to-pay. SAP S/4HANA already compares each supplier invoice with the purchase order and goods receipt. Variances beyond the configured tolerance block the invoice for payment. That is anomaly detection by rule, and it works for the cases it was set up for.

What it doesn't catch costs money in three ways:

  • Paying twice. A duplicate that slips past the duplicate check, because the reference was keyed as INV 0412 instead of INV-412.
  • Paying the wrong amount. An extra zero, a price that crept above the contract but stayed inside tolerance, an invoice from a supplier used once.
  • Missing a control failure. Odd journal entries in record-to-report: manual postings to unusual accounts, at period end, by unusual users. Auditors look for exactly these.

The cost on the other side is people. Every flag takes a reviewer's time. A detector that flags 300 invoices a month when 20 are real problems teaches reviewers to click "fine" without reading. This is alert fatigue, and it turns a control into theater. So a good anomaly program is judged by two numbers: of the flags, how many were worth checking (precision), and how much reviewer time it took.

How SAP does it

As of October 2026, SAP covers anomaly detection in finance and procurement at several levels. Names and scope change, so treat this as a map, not a licensing guide.

  • Rules inside SAP S/4HANA. Invoice verification uses tolerance limits per variance type and company code, such as price and quantity variances. Above the upper limit, the invoice can still be posted but is blocked for payment. The duplicate invoice check compares new invoices with existing ones by supplier and currency, plus company code, reference and invoice date where configured. Both are configuration, not AI, and both are where any anomaly program should start.
  • SAP Business Integrity Screening (BIS). SAP Learning describes a cycle of design, setup, detect and investigate. It combines expert rules (SAP's standard content is built this way) with predictive models trained on labeled or unlabeled data. Thresholds and weights decide when a hit becomes an alert, and a what-if analysis tests changes on historical data before go-live. Standard content covers procure-to-pay, and checks can stop suspicious payments in the S/4HANA payment run.
  • Machine learning for reconciliation. SAP Learning lists a cloud machine learning service that monitors goods receipt/invoice receipt (GR/IR) reconciliation and recommends next steps. It also lists situation alerts such as "GR/IR Deviation Exceeds Threshold".
  • SAP HANA's Predictive Analysis Library (PAL), reachable from Python through hana-ml, includes an isolation forest and simpler outlier tests, for teams that build their own detectors on HANA data.

Rules, statistics or machine learning: a decision guide

Situation Start with Why
You can state the problem in one sentence ("same supplier, amount and reference") A rule Exact, explainable, cheap to run and audit
"Unusual" depends on the supplier or account A statistical outlier per group Each supplier gets its own normal
Problems hide in combinations of fields An isolation forest or similar model Finds rare combinations no one wrote a rule for
Auditors need to know exactly why a record was flagged Rules first, model second A model's score needs a human-readable reason next to it
You have hundreds of confirmed past cases A classifier, as in Classification and the metrics that matter With labels, learn the problem directly
SAP's standard check covers it The SAP setting Don't rebuild a control you already own

Most real programs layer all three. Rules catch the known problems, statistics catch the obvious outliers, and a model ranks what is left.

Alert fatigue and the reviewer loop

A detector is only as good as the queue it produces. Three habits keep the queue useful.

  1. Size the queue to the team, not the model. If accounts payable can check 20 invoices a day with care, the detector shows the 20 most unusual. A model's default cut-off is not a business decision.
  2. Every flag gets a recorded decision. Confirmed problem, false alarm, or expected (unusual but explained, such as a quarterly lease charge). Without decisions, nobody can measure precision or improve the detector.
  3. Feed decisions back, narrowly. "Supplier X's quarterly charge of about 31,800 EUR is expected" should silence that pattern, not the supplier. And some flags call for a fix in SAP, not in the detector: a supplier whose prices sit 8% above every purchase order needs updated purchase orders.

In the hands-on sample for this topic, a model's default cut-off flags about one invoice in six. A ranked queue of 20 has 16 real problems in it, and 18 after one reviewer note. That gap is the difference between a control people trust and one they ignore.

Questions to ask

  1. Which problems is this meant to find: duplicates, wrong amounts, fraud, control failures? Each needs different data.
  2. What do our SAP tolerance limits and duplicate check already catch, and how are they configured?
  3. How many flags per day or week will reviewers see, and who are they?
  4. What share of flags turned out to be real in the pilot, and how was "real" decided?
  5. How are reviewer decisions recorded, and how do they change the detector?
  6. Can a reviewer see why each record was flagged, in business terms?
  7. Who approves marking a pattern as "expected", and how is that change audited?
  8. Which suppliers, accounts or company codes have too little history to judge, and who covers them?
  9. Does any flag trigger an automatic action, such as a payment block, or does a person always decide?

Common misconceptions

  • "AI will find the fraud." It finds the unusual. Most unusual records are legitimate, and a careful fraud can look normal. A person decides.
  • "More flags means better coverage." Past a point, more flags means less attention per flag. Recall without precision burns out reviewers.
  • "We can measure accuracy like any model." Without labels, you can't. Precision comes from reviewer decisions; recall needs planted test cases or audits.
  • "Rules are old-fashioned." Rules are the most explainable detector you have, and SAP's tolerances and duplicate check are rules. Models add to them; they don't replace them.
  • "Once tuned, it stays tuned." New suppliers, price changes and reorganizations change what "normal" means. Review the detector's results regularly.

Key terms

All course terms, A to Z, in the glossary

  • Anomaly (outlier): a record that differs from most others. Not necessarily an error.
  • Unsupervised: learning without labeled examples of the problem.
  • Tolerance limit: in S/4HANA invoice verification, how far an invoice may differ from the purchase order or goods receipt before it is blocked.
  • Duplicate invoice check: S/4HANA's comparison of a new invoice with existing ones by supplier, currency and configured fields.
  • Robust z-score: distance from the median, scaled by the median absolute deviation, so outliers don't distort the yardstick.
  • Isolation forest: a model that isolates records with random splits; records that are easy to isolate are unusual.
  • Precision (of a queue): the share of flagged records that were worth checking.
  • Alert fatigue: reviewers stop checking carefully because most flags are noise.
  • Known pattern: an unusual but explained pattern, approved by a reviewer, that the detector stops flagging.

Check yourself

Pick one answer for each question. The explanation appears after you choose.

1How does anomaly detection differ from the classification models earlier in this unit?

Answer: A. In finance and procurement, problems are rare and seldom labeled, so the method learns normal behavior and flags what doesn't fit. With many confirmed past cases, a classifier would be the better tool.

2Before funding an AI detector for invoice problems, what should you review first?

Answer: C. SAP's tolerances and duplicate check are the first detector, and they are configuration you already own. A misconfigured duplicate check, or warnings nobody acts on, should be fixed before anything is built on top.

3A pilot flags 300 invoices a month and 20 are real problems. What is the main risk?

Answer: C. That is alert fatigue. When most flags are noise, reviewers click through them, and the control stops working. Size the queue to the team and track precision.

4How should the length of the review queue be decided?

Answer: B. A model ranks records; the business decides how many to look at. A default cut-off is a technical setting, not a statement of reviewer capacity.

5A reviewer confirms that one supplier's large quarterly charge is legitimate. What is the right change?

Answer: A. Feedback should silence the explained pattern, not the supplier. A duplicate or a different odd amount from the same supplier must still be flagged, and the change itself should be approved and auditable.

6Why can't you report a single "accuracy" number for an anomaly detector?

Answer: D. Without labeled problems, accuracy can't be computed. Precision is measured from recorded reviewer decisions; recall needs planted test cases or audit findings.
Deep layer · 40 min read

Mental model: a detector ranks, a reviewer decides, the decisions teach

An anomaly detector answers one question: how unusual is this record compared with the rest? It returns a score, not a verdict. Everything useful happens around that score.

  • A ranking turns scores into a queue.
  • A budget cuts the queue to what reviewers can check: the top 20, not everything above a default threshold.
  • A decision per flagged record (confirmed, false alarm, expected) is the only ground truth you will get.
  • Feedback turns decisions into narrow known patterns, or into fixes in SAP.

If you remember one thing, make it this: you evaluate an anomaly detector by the precision of the queue reviewers actually see, at the length they can handle. That is the same idea as picking a threshold from business costs in Classification and the metrics that matter, without the labels.

How it works

Anomaly detection versus classification

Classification Anomaly detection
Training data Labeled examples of each outcome Mostly normal records, few or no labels
What it learns The difference between classes What normal looks like
Output Probability of a known outcome An unusualness score
Finds new kinds of problems No, only those it was shown Yes, if they look unusual
How you measure it Precision and recall on a test set Precision of reviewed flags; recall on planted cases
Main risk Labels are wrong or biased Unusual is not the same as wrong

The scikit-learn user guide separates two settings. In outlier detection, the training data already contains the odd records, and the model must spot them. In novelty detection, the training data is clean, and the model judges new records. Invoice screening is usually the first: last year's invoices include the duplicates you haven't found yet.

Three families of methods

Rules and thresholds. A rule is a condition someone wrote down: price variance over 5%, same supplier, amount and reference. SAP's tolerance limits and duplicate check are rules. They are exact and auditable, and they find only what someone anticipated.

Statistical outliers. Measure how far a value sits from what is usual for its group. The plain z-score uses the mean and standard deviation, but a few big outliers inflate both and hide themselves. The NIST handbook recommends the modified z-score of Iglewicz and Hoaglin instead:

M = 0.6745 × (x − median) / MAD
MAD = median of |x − median|

Values with |M| above 3.5 are labeled potential outliers. The median and MAD barely move when a few extreme values appear, so the yardstick stays honest. Two cautions: the handbook notes that such tests assume roughly normal data, so take logarithms of skewed amounts first. And when more than half the values are identical, the MAD is zero and the score breaks down.

Isolation forest. Instead of describing normal, it measures how easy a record is to separate from the rest. Each tree picks a random feature and a random split value between that feature's minimum and maximum, and repeats until each record sits alone. An unusual record gets isolated after few splits; a typical one sits deep in the crowd. The forest averages the path length over many trees.

flowchart TD
  A[All invoices] -->|random split| B[3 invoices]
  A -->|random split| C[1,800 invoices]
  B -->|one more split| D[Alone after 2 splits: unusual]
  C -->|many more splits| E[Alone after 11 splits: typical]

scikit-learn's IsolationForest defaults to 100 trees, each built on at most 256 sampled records. score_samples returns lower values for more abnormal records, and predict returns -1 for outliers. With contamination="auto", the cut-off sits at -0.5 on that score, following the original paper. A float such as 0.02 instead says "assume 2% are outliers". Either way, the cut-off is a guess about your data, not a business decision.

Features: what the model can see

An isolation forest only sees the columns you give it. For invoices, useful features describe the invoice in context:

Feature Why it helps
Amount (log scale) Very large or tiny invoices
Amount versus this supplier's usual (robust z) Normal for one supplier, odd for another
Price variance versus the purchase order The three-way match signal
Days between document date and posting Old invoices posted late
No purchase order Non-PO invoices carry more risk
Number of invoices from this supplier New or one-off suppliers

Feature choice shapes what counts as unusual. A rare yes/no feature, such as "round amount", can dominate a forest, because one split isolates every record that has it. You will see this in the hands-on part.

Scores, budgets and precision at k

Without labels, you can't compute recall. What you can measure is precision at k: of the top k records shown to reviewers, how many were worth checking. Choose k from capacity: a team that can review 20 invoices a day gets k = 20. Then compare detectors by their precision at that k.

To estimate recall, plant known problems: copy a real invoice with a reformatted reference, multiply an amount by ten. Count how many the queue catches. The hands-on sample does exactly this.

Closing the loop

Each reviewed flag ends in one of three decisions:

  • Confirmed: a real problem. Fix it in SAP (block, reverse, contact the supplier), and keep the case as a labeled example.
  • False alarm: nothing unusual on inspection. If a feature keeps causing these, change the features.
  • Expected: unusual but explained. Record a narrow known pattern (supplier, amount range, reason, approver) so it stops appearing.

Over time, confirmed cases become labels. With enough of them, a classifier can join the detector. That is how anomaly programs mature.

Build it yourself: flag unusual supplier invoices

You will run one script that reads 12 months of supplier invoices shaped like S/4HANA invoice headers, flags problems three ways, and builds a review queue of the 20 most unusual. The sample has 27 planted problems, so you can see what each method catches. Then you'll act as the reviewer and feed one decision back.

Before you start: complete Set up your computer for this course and Set up for Unit 2: data science tools. They give you the orchestrate-course folder, its .venv, the unit02 folder, and numpy, pandas and scikit-learn. This walkthrough needs no new library.

flowchart LR
  I[Invoices CSV] --> R[1 Rules: duplicates, price over PO]
  I --> Z[2 Amount vs supplier: robust z]
  I --> F[3 Isolation forest]
  R --> Q[Review queue: top 20]
  Z --> Q
  F --> Q
  Q --> D[Reviewer decisions]
  D -->|known_patterns.csv| Q

What you need

  • The course folder, .venv and unit02 folder from the two setup topics.
  • About 40 minutes. No account, no key and no internet: --sample builds the data on your computer.
  • Optional: your own invoice extract as a CSV file (Step 7).

Step 1: Open the course folder and turn on the environment

  1. Open VS Code, choose File > Open Folder and open orchestrate-course.

  2. Open a terminal: Terminal > New Terminal.

  3. Turn on the virtual environment if the prompt doesn't start with (.venv):

    • Windows (PowerShell):

      .venv\Scripts\Activate.ps1
    • macOS / Linux:

      source .venv/bin/activate
  4. Check that scikit-learn imports (the same command on Windows and macOS/Linux):

    python -c "import sklearn; print(sklearn.__version__)"

    What success looks like: a version number such as 1.9.1. Any version from 1.2 on works.

  5. Go into the Unit 2 folder:

    cd unit02

Step 2: Save the script

  1. In VS Code's file list, right-click unit02, choose New File and name it flag_unusual_invoices.py.
  2. Paste the code below and save (Ctrl+S, or Cmd+S on Mac).
"""Anomaly detection in procure-to-pay: flag unusual supplier invoices and build a short review queue.

How to run (from the unit02 folder, with the course .venv turned on):
    python flag_unusual_invoices.py --sample                    # 12 months of made-up invoices with planted problems
    python flag_unusual_invoices.py --sample --top 30           # a longer review queue
    python flag_unusual_invoices.py --sample --known known_patterns.csv   # apply reviewer feedback
    python flag_unusual_invoices.py --file my_invoices.csv      # your own extract (see the topic for columns)
"""
import argparse
import re
import sys
from pathlib import Path

import numpy as np
import pandas as pd
from sklearn.ensemble import IsolationForest

PRICE_TOLERANCE_PCT = 5.0   # made-up tolerance for the sample; real ones come from your configuration
DUPLICATE_WINDOW_DAYS = 14  # same supplier and amount within this many days counts as a possible duplicate
ROBUST_Z_LIMIT = 3.5        # modified z-score cut-off recommended by Iglewicz and Hoaglin (NIST handbook)
MIN_HISTORY = 8             # fewer invoices than this: the supplier has no "normal" to compare with
REQUIRED = ["InvoicingParty", "SupplierInvoiceIDByInvcgParty", "DocumentDate", "PostingDate", "InvoiceGrossAmount"]


# ---------------------------------------------------------------- sample data
def make_sample(seed=7):
    """Made-up invoices shaped like S/4HANA supplier invoice headers, plus a few planted problems."""
    rng = np.random.default_rng(seed)
    start = pd.Timestamp("2025-10-01")
    rows = []

    def add(supplier, ref, doc_date, delay, amount, po, variance, issue=""):
        rows.append({
            "CompanyCode": "1010", "InvoicingParty": supplier, "SupplierInvoiceIDByInvcgParty": ref,
            "DocumentDate": doc_date.date(), "PostingDate": (doc_date + pd.Timedelta(days=int(delay))).date(),
            "InvoiceGrossAmount": round(float(amount), 2), "DocumentCurrency": "EUR",
            "PurchaseOrder": po, "PriceVariancePct": round(float(variance), 1), "PlantedIssue": issue,
        })

    def weekday(day):
        while day.weekday() >= 5:
            day += pd.Timedelta(days=1)
        return day

    # 40 regular suppliers with their own typical invoice size and rhythm
    for s in range(40):
        supplier = f"1700{s:02d}"
        typical = float(np.exp(rng.uniform(np.log(400), np.log(40000))))
        n = int(rng.integers(20, 90)) if s < 34 else int(rng.integers(2, 6))  # the last 6 are occasional suppliers
        po_share = 0.9 if s % 4 else 0.4
        for i in range(n):
            doc = weekday(start + pd.Timedelta(days=int(rng.integers(0, 360))))
            amount = typical * rng.lognormal(0, 0.3)
            is_po = rng.random() < po_share
            if not is_po and rng.random() < 0.25:
                amount = round(amount, -2)  # services billed in round amounts are normal
            add(supplier, f"INV-{s:02d}{i + 101:03d}", doc, rng.integers(1, 8), amount,
                f"45000{s:02d}{i:03d}" if is_po else "", rng.normal(0, 1.2) if is_po else 0.0)

    # normal but surprising 1: a lease supplier with a monthly fee and a large quarterly service charge
    for m in range(12):
        doc = weekday(start + pd.Timedelta(days=30 * m + 2))
        add("170090", f"L-{m + 1:02d}/26", doc, 3, 2950 + rng.normal(0, 60), "", 0.0)
        if m % 3 == 2:
            add("170090", f"LQ-{m // 3 + 1}/26", doc, 3, 31800.00, "", 0.0)

    # normal but surprising 2: a carrier that bills a flat rate per shipment, often twice a week
    for w in range(0, 50, 2):
        doc = weekday(start + pd.Timedelta(days=7 * w))
        for k in range(2 if w % 6 == 0 else 1):
            add("170091", f"FR{w:02d}{k}", doc + pd.Timedelta(days=k), 2, 1450.00,
                f"4500091{w:02d}{k}", rng.normal(0, 0.5))

    # normal but surprising 3: an agreed price increase nobody entered in the purchase orders
    for i in range(10):
        doc = weekday(start + pd.Timedelta(days=200 + 15 * i))
        add("170092", f"MX-{i + 501}", doc, 3, 5200 * rng.lognormal(0, 0.2), f"45000920{i:02d}", rng.uniform(7, 9))
    for i in range(20):
        doc = weekday(start + pd.Timedelta(days=10 * i))
        add("170092", f"MX-{i + 401}", doc, 3, 5000 * rng.lognormal(0, 0.2), f"45000921{i:02d}", rng.normal(0, 1))

    df = pd.DataFrame(rows)
    normal = df[df["InvoicingParty"].str[-2:].astype(int) < 34]  # regular suppliers only

    # planted 1: duplicates, keyed again with a slightly different reference
    for _, r in normal.sample(8, random_state=1).iterrows():
        ref = r["SupplierInvoiceIDByInvcgParty"]
        variants = [ref.replace("-", ""), ref.lower(), ref.replace("INV-", "INV 0"), ref + " "]
        doc = pd.Timestamp(r["DocumentDate"]) + pd.Timedelta(days=int(rng.integers(0, 4)))
        add(r["InvoicingParty"], variants[int(rng.integers(0, 4))], doc, rng.integers(8, 20),
            r["InvoiceGrossAmount"], r["PurchaseOrder"], r["PriceVariancePct"], "duplicate")

    # planted 2: an extra zero keyed into the amount
    for _, r in normal.sample(6, random_state=2).iterrows():
        doc = weekday(start + pd.Timedelta(days=int(rng.integers(0, 360))))
        add(r["InvoicingParty"], r["SupplierInvoiceIDByInvcgParty"] + "X", doc, rng.integers(1, 8),
            r["InvoiceGrossAmount"] * 10, r["PurchaseOrder"], r["PriceVariancePct"], "amount keyed x10")

    # planted 3: price far above the purchase order (a three-way match exception)
    for _, r in normal[normal["PurchaseOrder"] != ""].sample(8, random_state=3).iterrows():
        doc = weekday(start + pd.Timedelta(days=int(rng.integers(0, 360))))
        variance = rng.uniform(12, 30)
        add(r["InvoicingParty"], r["SupplierInvoiceIDByInvcgParty"] + "P", doc, rng.integers(1, 8),
            r["InvoiceGrossAmount"] * (1 + variance / 100), r["PurchaseOrder"], variance, "price over PO")

    # planted 4: one-off suppliers, round amounts, no PO, posted long after the document date
    for k, amount in enumerate([9900, 14500, 9800, 12000, 19500]):
        doc = weekday(start + pd.Timedelta(days=int(rng.integers(30, 300))))
        add(f"1799{k:02d}", f"{k + 1}", doc, rng.integers(45, 80), amount, "", 0.0, "one-off, no PO, late")

    df = pd.DataFrame(rows).sample(frac=1, random_state=4).reset_index(drop=True)  # now with planted rows
    df.insert(0, "SupplierInvoice", [f"51056{i:05d}" for i in range(1, len(df) + 1)])  # made-up numbers
    return df


# ---------------------------------------------------------------- loading
def load(args):
    if args.sample:
        df = make_sample()
        df.to_csv("sample_invoices.csv", index=False)
        print(f"Source: made-up sample. {len(df)} invoices saved as sample_invoices.csv.")
    else:
        path = Path(args.file)
        if not path.exists():
            sys.exit(f"File not found: {path}. Put the CSV in the unit02 folder or give the full path.")
        df = pd.read_csv(path, dtype=str)
        missing = [c for c in REQUIRED if c not in df.columns]
        if missing:
            sys.exit(f"Missing columns: {', '.join(missing)}. Required: {', '.join(REQUIRED)}")
        print(f"Source: {path.name}. {len(df)} invoices.")
    for col, default in [("SupplierInvoice", ""), ("CompanyCode", ""), ("DocumentCurrency", ""),
                         ("PurchaseOrder", ""), ("PriceVariancePct", 0.0), ("PlantedIssue", "")]:
        if col not in df.columns:
            df[col] = default
    df = df.fillna({"PurchaseOrder": "", "PlantedIssue": "", "DocumentCurrency": "", "CompanyCode": ""})
    if not df["SupplierInvoice"].astype(str).str.strip().any():
        df["SupplierInvoice"] = [f"51056{i:05d}" for i in range(1, len(df) + 1)]  # made-up document numbers
    df["DocumentDate"] = pd.to_datetime(df["DocumentDate"], errors="coerce")
    df["PostingDate"] = pd.to_datetime(df["PostingDate"], errors="coerce")
    df["InvoiceGrossAmount"] = pd.to_numeric(df["InvoiceGrossAmount"], errors="coerce")
    df["PriceVariancePct"] = pd.to_numeric(df["PriceVariancePct"], errors="coerce").fillna(0.0)
    bad = df[["DocumentDate", "PostingDate", "InvoiceGrossAmount"]].isna().any(axis=1)
    if bad.any():
        print(f"Skipped {int(bad.sum())} rows with a missing date or amount.")
        df = df[~bad].copy()
    return df.reset_index(drop=True)


# ---------------------------------------------------------------- detectors
def normalize_ref(ref):
    """INV-0042, inv 42 and INV42 all become INV42."""
    text = re.sub(r"[^A-Z0-9]", "", str(ref).upper())
    return re.sub(r"(?<=[A-Z])0+(?=\d)", "", text)


def rule_flags(df):
    """Method 1: rules. Duplicates and price variances above the tolerance.

    Returns two columns of reasons: 'Duplicate' for the same supplier, amount and reference
    (a strong signal) and 'Rule' for weaker hits (same amount within the window, price variance).
    """
    strong = {i: "" for i in df.index}
    weak = {i: [] for i in df.index}
    ordered = df.sort_values(["PostingDate", "SupplierInvoice"])
    for _, group in ordered.groupby(["InvoicingParty", "DocumentCurrency"]):
        seen = []
        for i, row in group.iterrows():
            for earlier in seen:
                if row["InvoiceGrossAmount"] != earlier["InvoiceGrossAmount"]:
                    continue
                if normalize_ref(row["SupplierInvoiceIDByInvcgParty"]) == normalize_ref(earlier["SupplierInvoiceIDByInvcgParty"]):
                    strong[i] = f"same amount and reference as {earlier['SupplierInvoice']}"
                    break
                if abs((row["DocumentDate"] - earlier["DocumentDate"]).days) <= DUPLICATE_WINDOW_DAYS:
                    weak[i].append(f"same amount as {earlier['SupplierInvoice']} within {DUPLICATE_WINDOW_DAYS} days")
                    break
            seen.append(row)
    for i in df.index[df["PriceVariancePct"] > PRICE_TOLERANCE_PCT]:
        weak[i].append(f"price {df.at[i, 'PriceVariancePct']:+.0f}% vs PO")
    return pd.Series(strong), pd.Series({i: "; ".join(r) for i, r in weak.items()})


def robust_z(values):
    """Modified z-score: 0.6745 * (x - median) / MAD. Robust to the outliers it is looking for."""
    med = np.median(values)
    mad = np.median(np.abs(values - med))
    if mad == 0:
        return np.zeros(len(values))
    return 0.6745 * (values - med) / mad


def add_features(df):
    """Turn each invoice into numbers a model can compare."""
    df["LogAmount"] = np.log10(df["InvoiceGrossAmount"].clip(lower=1))
    df["SupplierInvoices"] = df.groupby("InvoicingParty")["InvoiceGrossAmount"].transform("size")
    df["AmountZ"] = 0.0
    for _, idx in df.groupby(["InvoicingParty", "DocumentCurrency"]).groups.items():
        if len(idx) >= MIN_HISTORY:
            df.loc[idx, "AmountZ"] = robust_z(df.loc[idx, "LogAmount"].to_numpy())
    df["PostingDelayDays"] = (df["PostingDate"] - df["DocumentDate"]).dt.days
    df["RoundAmount"] = (df["InvoiceGrossAmount"] % 100 == 0).astype(int)
    df["NoPO"] = (df["PurchaseOrder"].astype(str).str.strip() == "").astype(int)
    return df


FEATURES = ["LogAmount", "AmountZ", "PriceVariancePct", "PostingDelayDays", "NoPO", "SupplierInvoices"]


def forest_scores(df):
    """Method 3: isolation forest. Lower score = easier to isolate = more unusual."""
    model = IsolationForest(n_estimators=300, contamination="auto", random_state=42)
    model.fit(df[FEATURES])
    return pd.Series(model.score_samples(df[FEATURES]), index=df.index)


def explain(row, typical):
    """Name the features that sit far from the typical invoice, so a reviewer knows where to look."""
    notes = []
    if abs(row["AmountZ"]) > ROBUST_Z_LIMIT:
        notes.append(f"amount unusual for supplier (z {row['AmountZ']:+.1f})")
    if row["PostingDelayDays"] > typical["PostingDelayDays"] * 4:
        notes.append(f"posted {row['PostingDelayDays']} days after document date")
    if row["SupplierInvoices"] < MIN_HISTORY:
        count = int(row["SupplierInvoices"])
        notes.append(f"supplier has only {count} invoice{'' if count == 1 else 's'}")
    if row["NoPO"] and row["RoundAmount"]:
        notes.append("round amount, no PO")
    return "; ".join(notes)


# ---------------------------------------------------------------- scoring the methods
def score(name, flagged, truth, has_truth):
    hits = int((flagged & truth).sum())
    line = f"  {name:<36}{int(flagged.sum()):>8}"
    if has_truth:
        precision = hits / flagged.sum() if flagged.sum() else 0.0
        recall = hits / truth.sum() if truth.sum() else 0.0
        line += f"{hits:>8}{precision:>11.0%}{recall:>8.0%}"
    print(line)


def main():
    parser = argparse.ArgumentParser(description="Flag unusual supplier invoices for review.")
    source = parser.add_mutually_exclusive_group(required=True)
    source.add_argument("--sample", action="store_true", help="use made-up sample invoices")
    source.add_argument("--file", help="your own CSV extract")
    parser.add_argument("--top", type=int, default=20, help="how many invoices a reviewer can check (default 20)")
    parser.add_argument("--known", help="CSV of reviewer-confirmed normal patterns: InvoicingParty, MinAmount, MaxAmount, Reason")
    args = parser.parse_args()

    df = add_features(load(args))
    if len(df) < 50:
        sys.exit("Fewer than 50 invoices: not enough history to say what is normal.")

    df["Duplicate"], df["Rule"] = rule_flags(df)
    df["ForestScore"] = forest_scores(df)

    # reviewer feedback: a known, explained pattern stops weak flags, never a same-reference duplicate
    df["Known"] = ""
    if args.known:
        known = pd.read_csv(args.known, dtype={"InvoicingParty": str})
        for _, k in known.iterrows():
            match = ((df["InvoicingParty"] == k["InvoicingParty"])
                     & df["InvoiceGrossAmount"].between(float(k["MinAmount"]), float(k["MaxAmount"])))
            df.loc[match, "Known"] = k["Reason"]
        print(f"Known patterns: {len(known)} loaded, {int((df['Known'] != '').sum())} invoices treated as explained.")

    explained = df["Known"] != ""
    truth = df["PlantedIssue"].astype(str).str.strip() != ""
    has_truth = bool(truth.any())
    by_dup = df["Duplicate"] != ""
    by_rules = by_dup | ((df["Rule"] != "") & ~explained)
    by_z = (df["AmountZ"].abs() > ROBUST_Z_LIMIT) & ~explained
    by_forest = (df["ForestScore"] < -0.5) & ~explained  # -0.5 is scikit-learn's 'auto' cut-off

    # the review queue: same-reference duplicates first, then everything else by how isolated it is
    df["Priority"] = np.where(by_dup, 0, 1)
    queue = df[by_dup | ~explained].sort_values(["Priority", "ForestScore"]).head(args.top).copy()
    in_queue = pd.Series(df.index.isin(queue.index), index=df.index)
    typical = df.median(numeric_only=True)
    queue["Reason"] = [r["Duplicate"] or "; ".join(x for x in [r["Rule"], explain(r, typical)] if x)
                       or "unusual combination of features" for _, r in queue.iterrows()]

    print(f"\nWho flags what ({len(df)} invoices"
          + (f", {int(truth.sum())} planted problems)" if has_truth else ")"))
    header = f"  {'Method':<36}{'Flagged':>8}"
    print(header + (f"{'Real':>8}{'Precision':>11}{'Recall':>8}" if has_truth else ""))
    score("1 Rules (duplicates, price > 5%)", by_rules, truth, has_truth)
    score("2 Amount vs supplier (|z| > 3.5)", by_z, truth, has_truth)
    score("3 Isolation forest (auto cut-off)", by_forest, truth, has_truth)
    score(f"Review queue (top {args.top})", in_queue, truth, has_truth)

    print(f"\nReview queue: top {args.top}")
    for rank, (_, row) in enumerate(queue.iterrows(), start=1):
        mark = ""
        if has_truth:
            mark = f"  <- planted: {row['PlantedIssue']}" if row["PlantedIssue"] else "  <- not planted"
        print(f"{rank:>3}. {row['SupplierInvoice']}  supplier {row['InvoicingParty']}  "
              f"{row['InvoiceGrossAmount']:>10,.2f} {row['DocumentCurrency']}  {row['Reason']}{mark}")

    out = queue[["SupplierInvoice", "InvoicingParty", "SupplierInvoiceIDByInvcgParty", "DocumentDate",
                 "InvoiceGrossAmount", "DocumentCurrency", "Reason", "ForestScore"]].copy()
    out["DocumentDate"] = out["DocumentDate"].dt.date
    out["ForestScore"] = out["ForestScore"].round(3)
    out["Decision"] = ""   # the reviewer fills in: confirmed, false alarm, or expected
    out["Note"] = ""
    out.to_csv("review_queue.csv", index=False)
    print("\nWrote review_queue.csv. Add a Decision for each row: confirmed, false alarm or expected.")


if __name__ == "__main__":
    main()

Step 3: Run it on sample data

In the terminal, still inside unit02, run:

python flag_unusual_invoices.py --sample

It takes a few seconds. What success looks like:

Source: made-up sample. 1865 invoices saved as sample_invoices.csv.

Who flags what (1865 invoices, 27 planted problems)
  Method                               Flagged    Real  Precision  Recall
  1 Rules (duplicates, price > 5%)          59      16        27%     59%
  2 Amount vs supplier (|z| > 3.5)          14       6        43%     22%
  3 Isolation forest (auto cut-off)        306      27         9%    100%
  Review queue (top 20)                     20      16        80%     59%

Review queue: top 20
  1. 5105601435  supplier 170006      856.27 EUR  same amount and reference as 5105601257  <- planted: duplicate
  2. 5105601572  supplier 170023      621.28 EUR  same amount and reference as 5105601286  <- planted: duplicate
  3. 5105601082  supplier 170026      700.00 EUR  same amount and reference as 5105600932  <- planted: duplicate
  4. 5105601321  supplier 170002   43,634.22 EUR  same amount and reference as 5105600015  <- planted: duplicate
  5. 5105600672  supplier 170008    7,670.70 EUR  same amount and reference as 5105601370  <- planted: duplicate
  6. 5105601138  supplier 170017      518.28 EUR  same amount and reference as 5105600422  <- planted: duplicate
  7. 5105600209  supplier 170002   26,615.18 EUR  same amount and reference as 5105601237  <- planted: duplicate
  8. 5105600039  supplier 170019    4,440.05 EUR  same amount and reference as 5105601416  <- planted: duplicate
  9. 5105600600  supplier 170090   31,800.00 EUR  amount unusual for supplier (z +78.0); round amount, no PO  <- not planted
 10. 5105601210  supplier 170090   31,800.00 EUR  amount unusual for supplier (z +78.0); round amount, no PO  <- not planted
 11. 5105601703  supplier 170090   31,800.00 EUR  amount unusual for supplier (z +78.0); round amount, no PO  <- not planted
 12. 5105601750  supplier 170090   31,800.00 EUR  amount unusual for supplier (z +78.0); round amount, no PO  <- not planted
 13. 5105600043  supplier 179904   19,500.00 EUR  posted 71 days after document date; supplier has only 1 invoice; round amount, no PO  <- planted: one-off, no PO, late
 14. 5105601730  supplier 179900    9,900.00 EUR  posted 72 days after document date; supplier has only 1 invoice; round amount, no PO  <- planted: one-off, no PO, late
 15. 5105600333  supplier 179901   14,500.00 EUR  posted 52 days after document date; supplier has only 1 invoice; round amount, no PO  <- planted: one-off, no PO, late
 16. 5105601785  supplier 179902    9,800.00 EUR  posted 52 days after document date; supplier has only 1 invoice; round amount, no PO  <- planted: one-off, no PO, late
 17. 5105601832  supplier 179903   12,000.00 EUR  posted 45 days after document date; supplier has only 1 invoice; round amount, no PO  <- planted: one-off, no PO, late
 18. 5105600131  supplier 170016   30,752.70 EUR  price +26% vs PO  <- planted: price over PO
 19. 5105600051  supplier 170016   14,116.35 EUR  price +25% vs PO  <- planted: price over PO
 20. 5105601607  supplier 170008  152,974.50 EUR  amount unusual for supplier (z +8.8)  <- planted: amount keyed x10

Wrote review_queue.csv. Add a Decision for each row: confirmed, false alarm or expected.

Your numbers should match, because the sample uses fixed random seeds. A small difference is fine; library versions can cause that. Two files appear in unit02: sample_invoices.csv, the input, and review_queue.csv, the queue.

Step 4: Read the results

Start with the table at the top. It is the whole topic in four lines.

  1. Rules flag 59 invoices, 16 real. They catch all 8 duplicates and all 8 prices far above the purchase order, with no misses in those two categories. But 43 flags are noise: a carrier that bills a flat 1,450 EUR per shipment, often twice a week, trips the "same amount within 14 days" rule. A supplier with an agreed price increase trips the price rule on every invoice. Rules are exact about what they check and blind to context.
  2. The robust z-score flags 14, 6 real. These are the extra-zero invoices: each is far from its own supplier's normal. It can't see duplicates, because a duplicate has a perfectly normal amount.
  3. The isolation forest's default cut-off flags 306 invoices, about one in six. It catches all 27 planted problems, but at 9% precision. Nobody can review 306 invoices a month carefully. This is alert fatigue in one number.
  4. The review queue of 20 has 16 real problems. Same-reference duplicates go first, because they are cheap to confirm. Then the rest are ranked by the forest's score. Ranking plus a budget beats any single cut-off.

Now read the queue itself:

  • Rows 1 to 8 are the duplicates. Look at sample_invoices.csv for one pair: the references differ (INV-06112 and INV06112) but mean the same.
  • Rows 9 to 12 are not planted. Supplier 170090 is a lease with a quarterly service charge of 31,800 EUR. Its robust z of +78 looks alarming because the monthly fees are almost identical, so the MAD is tiny. It is unusual, and it is fine.
  • Rows 13 to 17 are one-off suppliers: one invoice, a round amount, no purchase order, posted 45 to 72 days after the document date. No single rule here flags them; the combination does.
  • Rows 18 to 20 are two prices far above the purchase order and an extra-zero amount.

Step 5: Be the reviewer and feed one decision back

You checked rows 9 to 12 and confirmed with the lease contract that the quarterly charge is expected. Record that as a known pattern.

  1. In VS Code, right-click unit02, choose New File and name it known_patterns.csv.

  2. Paste these two lines and save:

    InvoicingParty,MinAmount,MaxAmount,Reason
    170090,31000,33000,Quarterly service charge in the lease contract (checked by AP lead)
  3. Run the script with the known patterns:

    python flag_unusual_invoices.py --sample --known known_patterns.csv

What success looks like (the top of the output):

Known patterns: 1 loaded, 4 invoices treated as explained.

Who flags what (1865 invoices, 27 planted problems)
  Method                               Flagged    Real  Precision  Recall
  1 Rules (duplicates, price > 5%)          59      16        27%     59%
  2 Amount vs supplier (|z| > 3.5)          10       6        60%     22%
  3 Isolation forest (auto cut-off)        302      27         9%    100%
  Review queue (top 20)                     20      18        90%     67%

The four lease rows are gone, and two more planted price problems moved into the top 20. Precision of the queue rose from 80% to 90% with one reviewer note. The pattern is narrow on purpose: supplier 170090's monthly fees are still checked, and a same-reference duplicate is never silenced by a known pattern.

Step 6: Change the review budget

Run the queue at 40 instead of 20, as if a second reviewer joined:

python flag_unusual_invoices.py --sample --top 40

The last line of the table changes to:

  Review queue (top 40)                     40      23        57%     85%

Twice the effort finds 7 more real problems, and precision falls from 80% to 57%. Rows 21 to 40 are mostly ordinary small invoices from suppliers with few invoices. Whether those 7 are worth a second reviewer is a business decision: compare the value of the problems found with the cost of the time.

Step 7 (optional): Use your own extract

If you have supplier invoice headers from a system you are allowed to use, save them as a CSV file in unit02 with these columns. The names follow the header fields of S/4HANA's supplier invoice API (A_SupplierInvoice), as used in the invoice exception copilot project; check them against your own system.

InvoicingParty,SupplierInvoiceIDByInvcgParty,DocumentDate,PostingDate,InvoiceGrossAmount,DocumentCurrency,PurchaseOrder,PriceVariancePct
1000012,INV-2041,2026-05-04,2026-05-06,1250.00,EUR,4500001234,0.4

The first five columns are required. PurchaseOrder may be empty for non-PO invoices. PriceVariancePct is not an SAP field: compute it yourself from the invoice item price and the purchase order net price, or leave it out. Then run:

python flag_unusual_invoices.py --file my_invoices.csv

What success looks like: the same table without the Real, Precision and Recall columns, and a queue of 20. With fewer than 50 invoices, the script stops and says so.

Step 8: Save your work in Git

From the course folder (one level up), the same on Windows and macOS/Linux:

cd ..
git add unit02/flag_unusual_invoices.py unit02/known_patterns.csv
git commit -m "Unit 2: flag unusual supplier invoices"

Leave sample_invoices.csv and review_queue.csv out; the script recreates them.

What each part of the script does

Part What it does
make_sample Builds 12 months of made-up invoices for 40 suppliers, three legitimate but surprising suppliers, and 27 planted problems
load Reads the sample or your CSV, checks required columns, converts dates and amounts, and skips broken rows
normalize_ref Makes INV-0042, inv 42 and INV42 comparable
rule_flags Method 1: same amount and reference (strong), same amount within 14 days or price over 5% (weak)
robust_z The modified z-score from the NIST handbook
add_features Turns each invoice into numbers: log amount, robust z per supplier, posting delay, no PO, supplier invoice count
forest_scores Method 3: fits an isolation forest and returns score_samples (lower is more unusual)
explain Writes the reason a reviewer sees, from the features that sit far from typical
Known patterns block Applies reviewer feedback: explained records leave the queue, except same-reference duplicates
Queue block Strong duplicates first, then the rest by forest score, cut to --top
score Prints flags, and precision and recall when planted problems exist

If something goes wrong

What you see What it means What to do
python: command not found or 'python' is not recognized Python isn't on the path, or the environment is off Turn on .venv (Step 1); on macOS/Linux try python3; see the Unit 1 setup topic
ModuleNotFoundError: No module named 'sklearn' scikit-learn isn't installed in this environment Turn on .venv, then pip install -r requirements.txt from the course folder
error: one of the arguments --sample --file is required No data source given Add --sample or --file my_invoices.csv
File not found: ... The CSV isn't in unit02 Move it there, or give the full path
Missing columns: ... Your CSV headers differ Rename the headers to match Step 7 exactly
Fewer than 50 invoices Not enough history to define normal Use a longer period or more company codes
KeyError: 'MinAmount' with --known The known patterns file has different headers Copy the header line from Step 5 exactly
pip fails with a proxy or SSL error A company network blocks downloads Ask IT for the proxy setting, or install from a home network; --sample needs no internet once libraries are installed

The SAP way

As of October 2026, here is where each method from this topic lives in SAP. Check the documentation for your release before you plan around any of it.

Rules S/4HANA already runs

Tolerance limits in invoice verification. SAP Learning's invoice verification course describes tolerance limits per variance type, set per company code in Customizing under Logistics Invoice Verification > Invoice Block > Set Tolerance Limits. Tolerance keys include DQ for quantity, PP for price and BR for the order price quantity. Within tolerance, the invoice posts quietly. Above the upper limit, it can still be posted but is automatically blocked for payment, and the whole invoice is blocked even if one item varies. A blocked invoice is released in a separate step. This is the rule layer of the three-way match, and the reason the course calls those blocks exceptions.

The duplicate invoice check. A SAP Community post in the "Blogs by SAP" area explains how the check works in materials management invoice verification:

  • It runs at the financial accounting level, against table BSIP.
  • The supplier and currency are always compared. Company code, invoice date and reference are configurable in OMRDC. When the reference check is off, the amount is used instead.
  • The supplier master needs the duplicate check flag ("Chk double inv.").
  • The standard messages (M8 108, M8 462) may be warnings, which let users post anyway. The post explains how to set them to errors in OMRM, including for automatic channels.

Two lessons for builders. First, the exact-reference comparison is why the planted duplicates in this topic use reformatted references: the gap between exact and normalized matching is where a detector adds value. Second, a warning that users can click past is a control with alert fatigue built in.

SAP Business Integrity Screening

SAP Learning describes BIS as a cycle of four phases: design, setup, detect and investigate, followed by performance analysis.

BIS concept The idea from this topic
Top-down detection methods (expert logic; SAP's standard content) Rules
Bottom-up detection methods (predictive models, supervised or unsupervised) Statistical outliers and models
Detection strategies with thresholds and weighting Combining methods into one queue
Simulation, calibration and what-if analysis on production data Testing the queue length before go-live
Alerts, investigation, task lists The review queue and decisions
Mass detection (batch) and online detection (synchronous) Screening after posting versus stopping a payment before it leaves
KPIs such as alert processing time and strategy efficiency Precision and reviewer cost

BIS standard content covers procure-to-pay, including suppliers and purchase orders. Online checks can be integrated into the S/4HANA payment run, so a suspicious payment is stopped before it reaches the bank. SAP Learning describes BIS as powered by SAP HANA and SAP BTP. Licensing and deployment options are outside this topic; check them with SAP for your landscape.

Machine learning in finance reconciliation

SAP Learning's list of SAP Business AI use cases in finance includes "Machine Learning for Monitoring of Goods and Invoice Receipts". It is a cloud machine learning service that monitors GR/IR account reconciliation and recommends the next processing step. It also lists situation alerts such as "GR/IR Deviation Exceeds Threshold" and "Due Date for Blocked Invoice Approaching". The first is a learned model; the second and third are threshold rules with a notification. Both feed a person, not an automatic posting.

SAP HANA: PAL through hana-ml

For teams building their own detectors on HANA data, the Predictive Analysis Library offers several outlier methods through the hana-ml Python client: IsolationForest, variance_test (mean and standard deviation), iqr (inter-quartile range), grubbs_test, KMeansOutlier, OneClassSVM, time-series outlier detection, and a Benford's law analysis.

PAL's isolation forest mirrors the one you just ran. It takes n_estimators, max_samples (default 256) and random_state. Its predict takes a contamination share, default 0.1, and returns an ID, a SCORE and a LABEL of -1 for outliers and 1 for inliers. Note the different default: scikit-learn's "auto" cut-off and PAL's 10% will flag different numbers of records on the same data.

# SKETCH: needs SAP HANA Cloud with PAL and a hana-ml connection; not runnable in this unit.
from hana_ml.algorithms.pal.preprocessing import IsolationForest

# invoice_features: a hana-ml DataFrame with one row per invoice and the feature columns from this topic
model = IsolationForest(n_estimators=300, max_samples=256, random_state=42)
result = model.fit_predict(invoice_features, key="SUPPLIER_INVOICE",
                           features=["LOG_AMOUNT", "AMOUNT_Z", "PRICE_VARIANCE_PCT", "POSTING_DELAY_DAYS"],
                           contamination=0.02)
queue = result.collect().sort_values("SCORE", ascending=False).head(20)  # check the score direction first

Build vs. SAP

Need SAP option Build it yourself Lean
Block invoices that break price or quantity tolerances Tolerance limits in invoice verification Nothing to build SAP
Catch exact duplicate invoices Duplicate invoice check, messages set to error Nothing to build SAP
Catch near-duplicates with reformatted references Check what BIS standard content covers Normalized matching, as in this topic Either; check BIS first if licensed
Fraud and compliance screening across processes with investigation workflow SAP Business Integrity Screening A queue and workflow from scratch SAP if licensed; building audit-grade workflow is costly
Screen payments before they leave BIS online detection in the payment run Hard to do safely outside SAP SAP
Explore a new anomaly idea quickly on an extract PAL on HANA data scikit-learn on a CSV, as in this topic Build, then move what works into SAP
GR/IR reconciliation support SAP's machine learning service for GR/IR monitoring Rarely worth it SAP

The pattern matches the rest of the course: use SAP's controls for what they already do, prototype the new idea on open tools, and move proven detectors to where the data and authorizations live.

Production concerns

  • Authorizations and data protection. An invoice detector reads supplier, price and payment data across company codes. Run it with a technical user scoped to the company codes in scope. The review queue must respect the same restrictions: an AP clerk for one company code shouldn't see flags for another.
  • Never act alone. The detector flags; a person decides. Any change in SAP, such as setting a payment block or reversing a document, goes through a human approval step. Designing that approval is covered in Designing human approval into AI workflows.
  • Explainability for audit. Each flag needs a reason in business terms, the score, the detector version and the decision. Auditors will ask why an invoice was or wasn't flagged on a given day.
  • Known patterns are configuration. Store them with owner, reason, approval date and expiry, under change control. An unreviewed list of exceptions is the easiest way to hide a problem.
  • Evaluation. Track precision at k from reviewer decisions every week. Plant test cases (a copied invoice with a reformatted reference) on a schedule to estimate recall. If precision drops, look for a new supplier pattern before retuning the model.
  • Drift. New suppliers, price rounds, mergers and new company codes change what normal looks like. Retrain on a rolling window, and keep the old model's queue for comparison during the switch.
  • Cost. An isolation forest on a few hundred thousand invoices runs in seconds to minutes on a laptop-class machine. The real cost is reviewer time, so budget the queue, not the compute.
  • Clean core. Read invoice data through released APIs or replicated data, and keep the detector outside the S/4HANA core. Results go back as a work list or a situation, not as custom code in invoice posting.
  • Monitoring. Log queue size, decisions and time to decision per run, as described in Observability for AI systems.

Pitfalls

  • Treating the model's default cut-off as policy. contamination="auto" flagged one invoice in six in the sample. Decide k from reviewer capacity.
  • One normal for everyone. A 30,000 EUR invoice is routine for one supplier and alarming for another. Compare within supplier, account or company code.
  • Ignoring tiny groups. Suppliers with two invoices have no "normal". Cover them with rules (new supplier, no PO) or a separate check.
  • A zero MAD. When most values are identical, the robust z-score explodes on any change, as with the lease supplier. Add a floor to the MAD or treat fixed-amount suppliers separately.
  • Rare yes/no features dominate. One split isolates every record with a rare flag. In the sample, a "round amount" feature crowded the queue with harmless small invoices, so the script uses it only to explain flags, not to score them.
  • Silencing too broadly. "Ignore supplier X" also hides X's duplicate. Make known patterns narrow, and never let them silence a same-reference duplicate.
  • No decisions recorded. Without them you can't measure precision, and the detector never improves.
  • Rebuilding SAP's controls. If the duplicate check is set to warning, change it to error before you build a detector for duplicates.
  • Reading "unusual" as "fraud". Reviewers and reports should say "flagged for review", never "suspected fraud", unless a person has concluded that.

Exercise: tune the queue and write the detector note

You'll add one more reviewer decision, find a pattern that needs a fix in SAP rather than in the detector, and write a one-page note. The note feeds Designing human approval into AI workflows, where you design the human approval step, and the invoice exception copilot project, which works the same blocked invoices.

  1. Open known_patterns.csv and add a line for the carrier whose flat-rate shipments trip the duplicate rule:

    170091,1450,1450,Flat rate per shipment; two shipments a week are normal (checked by AP lead)
  2. Save, then run:

    python flag_unusual_invoices.py --sample --known known_patterns.csv
  3. Check the first line of the table. What success looks like:

      1 Rules (duplicates, price > 5%)          26      16        62%     59%

    Rule precision rose from 27% to 62%. The planted duplicates are still caught, because known patterns never silence a same-reference duplicate.

  4. Look at supplier 170092's price variances over time (one line; the same on Windows and macOS/Linux):

    python -c "import pandas as pd; d=pd.read_csv('sample_invoices.csv', dtype=str); print(d[d.InvoicingParty=='170092'][['DocumentDate','InvoiceGrossAmount','PriceVariancePct']].sort_values('DocumentDate').tail(12).to_string(index=False))"

    What success looks like: from late April 2026, every invoice is 7.5% to 9% above its purchase order.

    DocumentDate InvoiceGrossAmount PriceVariancePct
      2026-03-30            6563.65             -1.9
      2026-04-09            4905.29              0.7
      2026-04-20             6191.1              7.5
      2026-05-04            5654.09              8.9
    ...
  5. Decide what this is. It is not an anomaly in each invoice; it is a price change. Don't add a known pattern for it. Write down the SAP fix instead: confirm the agreed price with purchasing and update the purchase orders or contract, so the tolerance check stops blocking correct invoices.

  6. Create unit02/detector_note.md with five short sections: What it looks for (the three methods, one line each), Queue size (your chosen --top and why), Results on the sample (precision and recall of the queue before and after your known patterns), Known patterns (each line with its reason and who approved it), and Fixes in SAP (the supplier 170092 finding).

  7. Commit known_patterns.csv and detector_note.md to Git.

Done when: known_patterns.csv has two lines, each with a reason and an approver; the rules line shows 26 flagged with 16 real; and detector_note.md states the queue size, its precision before and after feedback, and the purchase order fix for supplier 170092.

Going further

For readers who want the math behind the two methods and the wider family of outlier tests.

Why isolation works. Liu, Ting and Zhou's 2008 paper starts from the observation that anomalies are "few and different": a minority whose values differ from the rest, which makes them easy to isolate. Each tree records the path length h(x) needed to isolate a point. The anomaly score is s = 2^(−E[h(x)] / c(n)), where c(n) is the average path length of an unsuccessful search in a binary search tree of n points. The normalization makes scores comparable across sample sizes.

Reading the score: values close to 1 are clear anomalies, values well below 0.5 are safely normal, and if every point scores about 0.5, the sample has no distinct anomalies. scikit-learn's score_samples returns the negative of this score, which is why its "auto" cut-off sits at -0.5.

Two design choices in the paper explain the defaults you used. Trees stop growing at a height of about log2 of the sub-sample size, because only points with shorter-than-average paths are likely anomalies. And each tree sees a small sub-sample (256 by default in both scikit-learn and PAL). The paper argues this reduces swamping, where normal points near anomalies get flagged, and masking, where a cluster of anomalies hides itself. More data per tree is not better here.

Robust statistics. The NIST handbook explains why the plain z-score can mislead, and recommends the modified z-score for flagging candidates. It also notes that formal outlier tests assume roughly normal data and can suffer from masking and swamping, so it pairs them with plots. For skewed amounts, log-transform first. PAL offers formal tests too, such as grubbs_test.

Other detectors. PAL's algorithm list also includes one-class SVMs, k-means-based outlier detection, time-series outlier detection and a Benford's law analysis, a classic audit test on the digits of amounts. scikit-learn's outlier guide compares isolation forest with Local Outlier Factor, which looks at local density, and the elliptic envelope, which assumes roughly Gaussian data. On invoice data, try two methods and compare their precision at your k on planted cases before choosing.

Sources to open next:

Check yourself

Pick one answer for each question. The explanation appears after you choose.

1Why does the script rank invoices and cut to the top 20, instead of using the isolation forest's default cut-off?

Answer: A. With contamination="auto", the forest flagged 306 invoices, about one in six, at 9% precision. Cutting a ranked list to reviewer capacity gave 16 real problems in 20. The business sets k; the model only ranks.

2How does an isolation forest decide that an invoice is unusual?

Answer: B. Each tree splits on random features and values until records sit alone. Unusual records sit in sparse regions, so they are isolated after few splits; the forest averages that path length. No labels are used.

3The robust z-score flags none of the planted duplicates. Why?

Answer: A. The z-score looks at one number, the amount, against the supplier's usual. A copied invoice has a perfectly ordinary amount. Duplicates need a rule that compares records with each other, such as normalized reference matching.

4Supplier 170090's quarterly charge gets a robust z of +78. What causes such an extreme value?

Answer: D. The modified z-score divides by the median absolute deviation. When most values are almost the same, the MAD is close to zero and any different value looks extreme. Treat fixed-amount suppliers separately or put a floor under the MAD.

5A reviewer asks you to stop flagging supplier 170091, the flat-rate carrier. What do you implement?

Answer: A. The false alarms come from the weak "same amount within 14 days" rule. A narrow known pattern silences that, and the script still catches a duplicate with the same reference. Removing the supplier or widening the rule for everyone would hide real problems.

6Supplier 170092's invoices have been 7.5% to 9% above the purchase order for months. What should happen?

Answer: B. This is a price change, not an anomaly in each invoice. The fix belongs in SAP's data, so the tolerance check stops blocking correct invoices. Silencing the flag or widening the tolerance would hide a future real overcharge.

7In S/4HANA, the duplicate check messages are set to warning. What does that mean for an anomaly program?

Answer: B. A warning lets the user post the invoice anyway, which is alert fatigue built into the control. The SAP Community post explains setting the messages to error in OMRM. Fix the existing control before adding a detector for the same problem.

8Which SAP offering matches a design with expert rules plus predictive models, calibrated with what-if analysis, and alerts that people investigate?

Answer: B. SAP Learning describes BIS with top-down and bottom-up detection methods, detection strategies with thresholds and weights, simulation and calibration, and alert investigation. Tolerance limits and the GR/IR situation are single rules, and hana-ml is a library without the investigation workflow.

9Your detector's weekly precision at 20 drops from 80% to 40%. What do you check first?

Answer: B. A sudden drop usually means the data changed: a new supplier, a price round, a new company code. Reviewer decisions show which pattern fills the queue. Retuning the model before understanding the change can hide real problems.

Practice this in the lab

Orchestrate Lab is a free practice ERP in your browser, built around the fictional Calder Pumpworks. These are the places in it where you do what this topic teaches.

Stages build on each other: a stage link opens the core path at that stage. To start there without doing the earlier stages yourself, use Do this stage for me on the stage before it. A chapter starts inside your own company once the core path is done (or the lab finishes the core for you first); drills and exercises start from their own prepared copy. Everything is saved in this browser only.

Sources

Sign in to track your progress

We'll email you a one-time sign-in link. No password needed.

or

Tell us a little about you

Optional, every field. It helps us pitch answers to your questions at the right level and decide which topics to write next. It is never shown publicly, and you can change or clear it anytime from the account menu.

SAP areas you work in