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.
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.
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
A detector is only as good as the queue it produces. Three habits keep the queue useful.
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.
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.
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.
"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.
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.
Ask a question
Testing: only staff see this
Stuck on something in this layer? Ask it here. Questions are answered in the order they arrive, and the answer appears under My questions.
Sign in (free) to ask a question. You can ask anonymously.
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.
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.
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.
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.
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.
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.
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
In VS Code's file list, right-click unit02, choose New File and name it flag_unusual_invoices.py.
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()
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.
Start with the table at the top. It is the whole topic in four lines.
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.
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.
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.
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.
In VS Code, right-click unit02, choose New File and name it known_patterns.csv.
Paste these two lines and save:
InvoicingParty,MinAmount,MaxAmount,Reason
170090,31000,33000,Quarterly service charge in the lease contract (checked by AP lead)
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.
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.
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.
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:
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.
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 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.
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.
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
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.
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.
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.
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)
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.
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).
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.
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.
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.
Ask a question
Testing: only staff see this
Stuck on something in this layer? Ask it here. Questions are answered in the order they arrive, and the answer appears under My questions.
Sign in (free) to ask a question. You can ask anonymously.
Related topics
Picked from the terms, links and tags this topic shares with others.
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
Entering Invoices with Variances (SAP Learning, Invoice Verification in SAP S/4HANA)— tolerance limits per variance type and company code; tolerance keys such as DQ, PP and BR; above the upper limit the invoice can be posted but is automatically blocked for payment; the whole invoice is blocked even if one item varies; blocked invoices are released in a separate step
Exploring Capabilities and Processes of SAP BIS Solution (SAP Learning)— design, setup, detect, investigate; top-down detection methods (expert rules, all SAP standard content) and bottom-up predictive models; detection strategies, thresholds and weighting; simulation, calibration and what-if analysis; mass and online detection; alerts and investigation; standard content for procure-to-pay; powered by SAP HANA and SAP BTP
Isolation Forest (Liu, Ting and Zhou, ICDM 2008)— anomalies are "few and different"; anomaly score 2^(-E(h(x))/c(n)) with c(n) the average path length of an unsuccessful binary search tree search; scores near 1 are anomalies, about 0.5 everywhere means no distinct anomalies; sub-sampling alleviates swamping and masking; height limit ceil(log2 of the sub-sample size)
IsolationForest (scikit-learn 1.9.1 documentation)— n_estimators=100, max_samples='auto' (min(256, n)), contamination='auto' with offset -0.5; score_samples lower means more abnormal; predict returns -1 for outliers; cites Liu, Ting and Zhou (2008) and the 2012 follow-up