← All Posts
Machine Learning · Anomaly Detection

Turn Anomaly Scores into an Investigation Queue

Before you start: Isolation Forest scores and telemetry row and exposure definitions.

A detector scores individual feature rows; reviewers investigate cases. Turning row scores into a review queue requires three operations: an admission rule (cutoff vs. capacity cap), deduplication into cases before applying the review budget, and local explanations whose units match the reported score.

A score cutoff and a capacity limit answer different questions

Given $N$ eligible rows sorted by score $s_1 \ge s_2 \ge \dots \ge s_N$ (with ties broken by row ID ascending), compare four admission policies:

Keep the rule fixed while the day changes
Q01 · 0.82Admitted
Q02 · 0.74Admitted
Q03 · 0.68Not admitted
Q04 · 0.62Not admitted
Q05 · 0.55Not admitted
Q06 · 0.42Not admitted
Q07 · 0.31Not admitted
Q08 · 0.18Not admitted

2 of 8 rows admitted. Lowest admitted score: 0.74.

Green rows enter the queue. Under the combined rule ($\text{score} \ge 0.70$, cap $3$), the quiet, busy, and lower-score days admit $2$, $3$, and $0$ rows respectively.

If $\lambda$ cases arrive per day and reviewers resolve $\mu$ cases per day, admitting $\lambda > \mu$ grows the unresolved backlog by $\lambda - \mu$ cases/day (e.g., $8 - 3 = 5$ cases/day on the busy cohort). Select thresholds on resolved historical cohorts and audit unreviewed traffic separately (see Evaluate Graph Risk).

Group rows into cases before applying the budget

Partition the day's rows by an investigation key $g(i)$ (such as an app–tenant pair within a seven-day window) into candidate cases $G_k = \{i : g(i) = k\}$, and score each case by its maximum member score:

$$S(G_k) = \max_{i \in G_k} s_i$$

On the busy day, the top three rows (`B01 = 0.93`, `B02 = 0.90`, `B03 = 0.87`) all belong to case `K1`. Taking the top $3$ rows first and merging afterward yields only 1 case (`K1`). Grouping into cases first and then taking the top $K = 3$ cases admits `K1` ($0.93$), `K2` ($0.84$), and `K3` ($0.78$) while retaining all six underlying member rows as evidence.

The order of grouping and selection changes the result
Leading busy-day rows

B01 · 0.93 → K1

B02 · 0.90 → K1

B03 · 0.87 → K1

B04 · 0.84 → K2

B05 · 0.81 → K2

B06 · 0.78 → K3

Admitted work

B01 · 0.93Evidence: B01

B02 · 0.90Evidence: B02

B03 · 0.87Evidence: B03

Three row-level work items, all for K1. The budget is consumed by repeated case evidence.

Case ranking uses $S(G_k) = \max_{i \in G_k} s_i$. Grouping before applying the budget ensures the cap counts distinct investigations.
A small group-before-budget implementation

Download the synthetic rows and explanation data to reproduce the examples.

def select_cases(rows, budget, minimum_score=None):
    ranked = sorted(rows, key=lambda row: (-row["score"], row["id"]))
    groups = {}
    for row in ranked:
        groups.setdefault(row["group"], []).append(row)
    cases = []
    for key, members in groups.items():
        representative = members[0]  # Highest score, then lowest ID.
        if minimum_score is None or representative["score"] >= minimum_score:
            cases.append({"group": key, "score": representative["score"],
                          "representative": representative["id"],
                          "members": [row["id"] for row in members]})
    return cases[:budget]

When evaluating queue quality, match the metric denominator to the unit reviewers handle: case-level precision counts confirmed positive cases divided by admitted cases, so three duplicate rows inside `K1` count as one investigation rather than three separate hits.

Additive explanations in path space vs. score space

For a model output $f(x)$ and a background reference dataset $X_{\text{ref}}$, an additive attribution (Lundberg & Lee, 2017) decomposes the prediction into the reference expectation plus feature contributions $\sum_{j=1}^M \phi_j(x) = f(x) - \mathbb{E}[f(X)]$. When $M = 1$ (a single feature), the entire difference belongs to $\phi_1(x)$:

$$\phi_1(x) = f(x) - \mu_f, \qquad \mu_f = \frac{1}{|X_{\text{ref}}|} \sum_{x_i \in X_{\text{ref}}} f(x_i)$$

In tree explainers such as SHAP's `IsoTree`, the tree leaf values are corrected path lengths $h_t(x)$ rather than normalized anomaly scores $s(x)$. Using $X_{\text{ref}} = \{1, 2, 3, 4, 20\}$ from chapter 1 gives two different additive decompositions:

Because $g(z) = 2^{-z / c(5)}$ is nonlinear, you cannot exponentiate $\mu_{\text{path}}$ and $\phi_{\text{path}}(x)$ separately; you must sum the path components first and then apply the transform:

$$s(x) = 2^{-\bigl(\mu_{\text{path}} + \phi_{\text{path}}(x)\bigr) / c(5)}, \qquad c(5) = 2.3270$$
Path-space vs. score-space additive decompositions on the fitted forest ($\mu_{\text{path}} = 2.8000,\; \mu_{\text{score}} = 0.4595,\; c(5) = 2.3270$).
Query $x$Path decomposition $\mu_{\text{path}} + \phi_{\text{path}}(x) = \bar{h}(x)$Nonlinear map $2^{-\bar{h}(x)/2.3270}$Score decomposition $\mu_{\text{score}} + \phi_{\text{score}}(x) = s(x)$
$1$$2.8000 + (-1.1333) = 1.6667$$2^{-1.6667/2.3270} = 0.6087$$0.4595 + (+0.1492) = \mathbf{0.6087}$
$2$$2.8000 + (+0.2000) = 3.0000$$2^{-3.0000/2.3270} = 0.4092$$0.4595 + (-0.0504) = \mathbf{0.4092}$
$3$$2.8000 + (+1.2000) = 4.0000$$2^{-4.0000/2.3270} = 0.3038$$0.4595 + (-0.1558) = \mathbf{0.3038}$
$4$$2.8000 + (+1.2000) = 4.0000$$2^{-4.0000/2.3270} = 0.3038$$0.4595 + (-0.1558) = \mathbf{0.3038}$
$20$$2.8000 + (-1.4667) = 1.3333$$2^{-1.3333/2.3270} = 0.6722$$0.4595 + (+0.2127) = \mathbf{0.6722}$
$100$$2.8000 + (-1.4667) = 1.3333$$2^{-1.3333/2.3270} = 0.6722$$0.4595 + (+0.2127) = \mathbf{0.6722}$
Same fitted forest, two explicitly defined explanation outputs
Reference mean2.8000Average output on [1, 2, 3, 4, 20]
+ Feature contribution-1.4667Corrected path units
= Query’s output1.3333Mean corrected path

Sum the path explanation first: 2.8000 + (-1.4667) = 1.3333. Transform the full path sum with reference length 2.3270 to get anomaly score 0.6722.

For multi-feature TreeSHAP explanations on Isolation Forests, see Isolation Forest SHAP and TreeSHAP limitations.

Keep one compact, reproducible case record

When admitting a case to the queue, persist the grouping key, member rows, admission policy, feature snapshot, and peer baseline version alongside an append-only outcome log:

{
  "case_id": "K3",
  "representative_row": "B06",
  "member_rows": ["B06"],
  "score": 0.78,
  "score_name": "higher_is_more_unusual",
  "admission_rank": 3,
  "queue_policy": "group-max-then-top3-v1",
  "grouping_window": "declared-seven-day-window",
  "model_version": "recorded-model-version",
  "feature_snapshot": "recorded-as-of-snapshot",
  "peer_reference": "recorded-peer-version",
  "evidence_ids": ["event-group-41"],
  "outcome": "pending"
}

References and further reading