agriflow-api / benchmarks /anomaly_detector_gap.py
masterAAA123's picture
Space deploy v5: orphan snapshot of main, zero binary files
b81a86b
Raw
History Blame Contribute Delete
12.7 kB
"""
benchmarks/anomaly_detector_gap.py — Quantifies the gap between AgriFlow's TWO
price-anomaly detectors on the project's own real data.
BACKGROUND
----------
Two detectors exist in the codebase, both in the production path:
matching_engine/engine.py:62 detect_price_anomaly(node, historical_median,
historical_std) -- z_score = |price - median| / std, flag if > 3.0
(PRICE_ANOMALY_SIGMA). This is what actually gates matching: called at
engine.py:416 and :429 inside run_matching()'s D3 preprocessing, and a
flagged node is DROPPED from the surplus/deficit pool entirely (not just
down-weighted).
analysis/price_anomaly.py detect_anomalies() -- S-H-ESD: deseasonalise
(rolling-median trend + monthly seasonal), then robust MAD on the
residual, with a persistence filter. This is validated against the
literature (Hochenbaum/Vallis/Kejariwal 2017) and is what feeds the
dashboard anomaly panel / API -- NOT the matching engine.
detect_price_anomaly's std is non-robust: a real outlier inflates std, which
shrinks the z-score of ALL points (including the outlier itself and future
genuine anomalies) -- the classic "masking effect" in robust statistics. This
script measures whether that mechanism is real on THIS project's actual price
data, and quantifies it two ways:
PART 1 -- "As shipped" gap on real data.
The production historical_prices dict is NOT a rolling window -- it is a
single STATIC (median, std) constant per commodity, hand-set in
sample_data/historical_price_stats.csv (mirrored into db/schema.sql's
historical_prices table). This part runs the ACTUAL shipped function,
detect_price_anomaly(), against every real observed price in
sample_data/price_history/*.csv using the ACTUAL shipped static
constants, and compares the flag set against the already-validated
S-H-ESD persistent-anomaly set (ground truth for "this was a real
event"). Reports recall (of genuine persistent anomalies, how many does
the static 3-sigma catch) and precision-adjacent flag volume.
PART 2 -- Masking mechanism, isolated, on real price levels.
Simulates the scenario the code's OWN docstrings claim ("historical_prices:
dict commodity_code -> (median, std) 30-day rolling", engine.py:352,370)
but the shipped loaders never actually implement: a rolling 30-day window
recomputed from real data. Calls the ACTUAL detect_price_anomaly()
function (not a re-implementation) with median/std computed from a
window seeded with real cabai_rawit/beras_medium price levels, sweeping
the number of contaminating outliers already present in that window
(0..~40% of the window), and reports at what contamination fraction the
REAL genuine spike stops being flagged. Cross-checked against the
MAD-based rule (median +/- k*1.4826*MAD, same k=3.0) at every
contamination level.
Both parts are seeded (--seed, default 2026) for reproducibility.
Usage:
python benchmarks/anomaly_detector_gap.py
"""
from __future__ import annotations
import argparse
import os
import random
import statistics
import sys
from datetime import datetime
from typing import Dict, List, Tuple
if sys.platform == "win32":
try:
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
except (AttributeError, OSError):
pass
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
from matching_engine.engine import PRICE_ANOMALY_SIGMA, detect_price_anomaly
from matching_engine.models import Commodity, DemandNode, Kabupaten, Tier
from analysis.price_anomaly import CITY_NAMES, load_series, scan_all
DEFAULT_SEED = 2026
PRICE_HISTORY_DIR = os.path.join(ROOT, "sample_data", "price_history")
HISTORICAL_STATS_CSV = os.path.join(ROOT, "sample_data", "historical_price_stats.csv")
def load_static_stats() -> Dict[str, Tuple[float, float, str]]:
"""Read the ACTUAL shipped static (median, std, source) per commodity."""
out = {}
with open(HISTORICAL_STATS_CSV, encoding="utf-8") as fh:
import csv
for row in csv.DictReader(fh):
out[row["commodity_code"]] = (
float(row["median_idr_per_kg"]),
float(row["std_idr_per_kg"]),
row["source"],
)
return out
def _dummy_node(price: float):
"""
Minimal SupplyNode-like stand-in: detect_price_anomaly only reads
node.price_per_kg, so a lightweight namespace avoids constructing full
Kabupaten/Commodity graphs for a million-point scan.
"""
class _N:
pass
n = _N()
n.price_per_kg = price
return n
# =============================================================================
# PART 1 — "AS SHIPPED": static constants vs real observed prices
# =============================================================================
def run_as_shipped_gap(seed: int) -> None:
print("=" * 82)
print(" PART 1 — 'As shipped': static (median, std) vs real PIHPS Jatim prices")
print("=" * 82)
print(f" historical_price_stats.csv is a STATIC constant per commodity "
f"(not a rolling window,\n despite engine.py's docstring claiming "
f"'30-day rolling') -- this reproduces exactly what\n production does "
f"today: detect_price_anomaly() called with the SAME fixed (median, std)\n"
f" against every real observed price.\n")
static_stats = load_static_stats()
ground_truth = scan_all(PRICE_HISTORY_DIR, window=30, k=3.0, persist=2)
ground_truth_persistent = [a for a in ground_truth if a["persistent"]]
header = (f" {'commodity':<15}{'source':<38}{'n_obs':>8}{'static_3σ_flags':>17}"
f"{'MAD_persistent':>16}{'overlap':>9}{'recall':>9}")
print(header)
print(" " + "-" * (len(header) - 2))
total_obs = 0
total_static_flags = 0
total_mad_persistent = 0
total_overlap = 0
for commodity, (median, std, source) in sorted(static_stats.items()):
gt_comm = [a for a in ground_truth_persistent if a["commodity_code"] == commodity]
if not gt_comm and not any(True for _ in []):
pass # commodity may still have price series even with 0 persistent anomalies
n_obs = 0
static_flag_dates = set() # (city_id, date)
for city_id in CITY_NAMES:
series = load_series(commodity, city_id, PRICE_HISTORY_DIR)
if not series:
continue
for date, price in series:
n_obs += 1
if detect_price_anomaly(_dummy_node(price), median, std):
static_flag_dates.add((city_id, date))
if n_obs == 0:
continue # commodity has no real price series (e.g. cabai_merah, tomat, ...)
gt_dates = {(a["city_id"], a["date"]) for a in gt_comm}
overlap = static_flag_dates & gt_dates
recall = (len(overlap) / len(gt_dates) * 100.0) if gt_dates else float("nan")
total_obs += n_obs
total_static_flags += len(static_flag_dates)
total_mad_persistent += len(gt_dates)
total_overlap += len(overlap)
recall_str = f"{recall:5.1f}%" if gt_dates else "n/a"
print(f" {commodity:<15}{source:<38}{n_obs:>8}{len(static_flag_dates):>17}"
f"{len(gt_dates):>16}{len(overlap):>9}{recall_str:>9}")
print()
overall_recall = (total_overlap / total_mad_persistent * 100.0) if total_mad_persistent else 0.0
print(f" TOTAL: {total_obs} real observations across 7 commodities x 8 IHK cities.")
print(f" Static 3σ flagged {total_static_flags} points total.")
print(f" S-H-ESD (robust, validated) flagged {total_mad_persistent} PERSISTENT genuine events.")
print(f" Static 3σ caught {total_overlap}/{total_mad_persistent} of those "
f"({overall_recall:.1f}% recall) on the SAME date+city.")
print(f"\n Caveat: telur_ayam and daging_ayam static constants are labelled "
f"'SYNTHETIC' in\n historical_price_stats.csv (never calibrated against "
f"the real PIHPS series at all) --\n their recall numbers reflect a "
f"made-up threshold, not a stale-but-real one.\n")
# =============================================================================
# PART 2 — Masking mechanism, isolated, on real price levels
# =============================================================================
def run_masking_mechanism(seed: int, window: int = 30, trials: int = 200) -> None:
print("=" * 82)
print(" PART 2 — Masking mechanism (rolling window, real price levels)")
print("=" * 82)
print(f" Simulates the 'rolling {window}-day' design the docstring promises "
f"(engine.py:352)\n but the shipped loader never implements. Calls the "
f"ACTUAL detect_price_anomaly()\n function with median/std computed "
f"from a window built on REAL median price levels\n (not an arbitrary "
f"Rp30,000 placeholder), sweeping how many contaminating outliers\n"
f" are already sitting in that window when a genuine 3x spike arrives.\n")
# Real median price levels, PIHPS Jatim (Surabaya, 3578), for grounding.
scenarios = [
("cabai_rawit (volatile, MAPE 23.2% per forecasting validation)", 41_000.0),
("beras_medium (low-volatility staple)", 14_000.0),
]
for label, base_price in scenarios:
print(f" --- {label}: base price Rp{base_price:,.0f}/kg ---")
header = (f" {'contam_n':>9}{'contam_%':>10}{'mean_std':>12}"
f"{'3σ_flags_spike':>16}{'MAD_flags_spike':>17}")
print(header)
for contam_n in range(0, int(window * 0.5) + 1, 2):
contam_frac = contam_n / window * 100.0
rng = random.Random(seed + contam_n)
spike3sigma_hits = 0
mad_hits = 0
stds = []
for _ in range(trials):
# Build a window of `window` observations: base price with mild
# noise, `contam_n` of them replaced by contaminating spikes
# (2-3x base, random sign/magnitude), and ONE genuine test spike
# (3x base) at a fixed slot -- this is the point we ask both
# detectors to catch.
win = [base_price * rng.gauss(1.0, 0.03) for _ in range(window)]
contam_positions = rng.sample(range(window - 1), k=min(contam_n, window - 1))
for p in contam_positions:
mult = rng.uniform(2.0, 3.0) * rng.choice([1, -0.4])
win[p] = max(1.0, base_price * abs(mult))
test_spike_price = base_price * 3.0
win[-1] = test_spike_price # genuine anomaly under test
median = statistics.median(win)
std = statistics.pstdev(win)
stds.append(std)
# ACTUAL shipped function — not a re-implementation.
if detect_price_anomaly(_dummy_node(test_spike_price), median, std):
spike3sigma_hits += 1
# MAD-equivalent at the SAME k, matching analysis/price_anomaly's
# spread estimator (robust) instead of engine.py's std (non-robust).
abs_devs = [abs(x - median) for x in win]
mad = statistics.median(abs_devs)
if mad > 0:
mad_z = abs(test_spike_price - median) / (1.4826 * mad)
if mad_z > PRICE_ANOMALY_SIGMA:
mad_hits += 1
else:
mad_hits += 1 # flat window + spike -> trivially anomalous
mean_std = statistics.mean(stds)
print(f" {contam_n:>9}{contam_frac:>9.1f}%{mean_std:>12,.0f}"
f"{spike3sigma_hits:>10}/{trials:<5}{mad_hits:>10}/{trials:<5}")
print()
def main() -> None:
parser = argparse.ArgumentParser(
description="Quantify the gap between engine.py's z-score detector and "
"analysis/price_anomaly's MAD detector, on AgriFlow's own real data.",
)
parser.add_argument("--seed", type=int, default=DEFAULT_SEED)
parser.add_argument("--trials", type=int, default=200)
args = parser.parse_args()
print(f"AgriFlow — Anomaly Detector Gap Analysis (seed={args.seed})")
print(f"Run: {datetime.now().isoformat(timespec='seconds')}\n")
run_as_shipped_gap(args.seed)
run_masking_mechanism(args.seed, trials=args.trials)
if __name__ == "__main__":
main()