""" analysis/run_anomaly_report.py -- Price anomaly report for AgriFlow. Usage (from project root): python analysis/run_anomaly_report.py python analysis/run_anomaly_report.py --top 20 --k 2.5 python analysis/run_anomaly_report.py --window 14 --k 3.0 --commodity cabai_rawit python analysis/run_anomaly_report.py --compare # show BEFORE vs AFTER flag counts Method v2: S-H-ESD (Seasonal-Hybrid ESD). v1 was rolling-median + MAD on raw prices; this is deseasonalise first, then MAD on residuals, with persistence and low-vol gates. Result: ~70 % reduction in flag count (14,261 -> 4,192 on 2021-2025 Jatim data). No external API calls; fully offline. """ from __future__ import annotations import argparse import sys from pathlib import Path # Force UTF-8 stdout/stderr on Windows (default cp1252 breaks "Rp", arrows, etc.) if sys.platform == "win32": try: sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8") except (AttributeError, OSError): pass # Allow running from project root without pip install _ROOT = Path(__file__).parent.parent if str(_ROOT) not in sys.path: sys.path.insert(0, str(_ROOT)) from analysis.price_anomaly import scan_all, CITY_NAMES PRICE_DIR = _ROOT / "sample_data" / "price_history" COMMODITY_LABELS = { "cabai_rawit": "Cabai Rawit", "bawang_merah": "Bawang Merah", "bawang_putih": "Bawang Putih", "daging_ayam": "Daging Ayam", "telur_ayam": "Telur Ayam", "beras_medium": "Beras Medium", "beras_premium":"Beras Premium", } def _fmt_price(p: float) -> str: return f"Rp {p:,.0f}/kg" def _run_v1_count() -> int: """ Reproduce the v1 (raw-price rolling-MAD) flag count for BEFORE/AFTER comparison. Uses the same k=3.0, window=30 as the default. """ from analysis.price_anomaly import _load_all_rows import numpy as np series_map = _load_all_rows(PRICE_DIR) total = 0 window = 30 k = 3.0 for (commodity, city), series in series_map.items(): if len(series) < window: continue prices = np.array([p for _, p in series], dtype=float) n = len(prices) for t in range(window - 1, n): w = prices[t - window + 1 : t + 1] roll_med = float(np.median(w)) mad = float(np.median(np.abs(w - roll_med))) if mad == 0.0: if prices[t] != roll_med: total += 1 continue if abs(prices[t] - roll_med) > k * 1.4826 * mad: total += 1 return total def main() -> None: parser = argparse.ArgumentParser(description="AgriFlow price anomaly report (S-H-ESD v2)") parser.add_argument("--top", type=int, default=15, help="Top N to show (default 15)") parser.add_argument("--window", type=int, default=30, help="Rolling window size (default 30)") parser.add_argument("--k", type=float, default=3.0, help="MAD sensitivity k (default 3.0)") parser.add_argument("--persist", type=int, default=2, help="Persistence threshold (default 2)") parser.add_argument("--commodity", type=str, default=None, help="Filter to one commodity code") parser.add_argument("--compare", action="store_true", help="Show BEFORE (v1 raw-price MAD) vs AFTER (S-H-ESD v2) counts") args = parser.parse_args() print() print("=" * 72) print(" AgriFlow -- Deteksi Anomali Harga (S-H-ESD v2, PIHPS Jatim 2021-2025)") print("=" * 72) print(f" Data : {PRICE_DIR}") print(f" Window : {args.window} observations") print(f" Threshold : k={args.k} (|dev| > {args.k} * 1.4826 * MAD on RESIDUAL)") print(f" Persist : >= {args.persist} consecutive flagged observations") print(f" Method : S-H-ESD -- deseasonalise, then robust MAD on residual") print(f" (Hochenbaum/Vallis/Kejariwal arXiv:1704.07706)") print(f" NOT 'AI'; interpretable statistical detector") print() # BEFORE/AFTER comparison if args.compare: print(" Computing BEFORE count (v1 rolling-MAD on raw prices) ...", end=" ", flush=True) before_count = _run_v1_count() print(f"done. {before_count:,} flags.") print(" Running S-H-ESD v2 ...", end=" ", flush=True) anomalies = scan_all( PRICE_DIR, window=args.window, k=args.k, persist=args.persist, ) after_count = len(anomalies) print(f"done. {after_count:,} anomaly points detected.") if args.compare: reduction = (before_count - after_count) / before_count * 100 print() print(" BEFORE vs AFTER:") print(f" v1 (raw-price rolling-MAD) : {before_count:>7,} flags") print(f" v2 (S-H-ESD, deseasonalised): {after_count:>7,} flags") print(f" Reduction : {reduction:>6.1f} %") print() if args.commodity: anomalies = [a for a in anomalies if a["commodity_code"] == args.commodity] print(f" Filtered to '{args.commodity}': {len(anomalies):,} anomalies.") print() if not anomalies: print(" No anomalies found for the given filters.") return top = anomalies[: args.top] print(f" Top {len(top)} anomalies ranked by score (highest first):") print() print(f" {'#':>3} {'Date':<12} {'Type':<6} {'Commodity':<15} {'Kota':<20}" f" {'Price':>14} {'Dev%':>8} {'Score':>7} {'Persist':<8}") print(" " + "-" * 106) for i, a in enumerate(top, 1): city_name = CITY_NAMES.get(a["city_id"], a["city_id"]) comm_label = COMMODITY_LABELS.get(a["commodity_code"], a["commodity_code"]) sign = "+" if a["deviation_pct"] > 0 else "" persist_marker = "YES" if a["persistent"] else "no" print( f" {i:>3} {str(a['date']):<12} {a['type']:<6} {comm_label:<15} " f"{city_name:<20} {_fmt_price(a['price']):>14} " f"{sign}{a['deviation_pct']:>6.1f}% {a['score']:>7.2f} {persist_marker:<8}" ) print() print(" Catatan keterbatasan (S-H-ESD v2):") print(" - Seasonal komponen: monthly median -- Ramadan (Hijri) drifts ~11 hr/thn;") print(" spike Ramadan di tanggal masehi tak biasa masih bisa muncul parsial.") print(" - Trend window = rolling median; lag 15-obs saat price-regime shift cepat.") print(" - Persist filter = consecutive observations, bukan hari kalender.") print(" Untuk data mingguan, persist=2 = '2 minggu berturut-turut'.") print(" - beras_medium/premium: rata-rata 2 sub-grade PIHPS; anomali sedikit") print(" konservatif dibanding single-grade.") print() # Summary by commodity from collections import Counter by_comm = Counter(a["commodity_code"] for a in anomalies) print(" Total anomalies per commodity (semua, bukan hanya top N):") for comm, count in by_comm.most_common(): label = COMMODITY_LABELS.get(comm, comm) persistent_count = sum(1 for a in anomalies if a["commodity_code"] == comm and a["persistent"]) print(f" {label:<20} : {count:>5} points ({persistent_count} persistent)") print() print("=" * 72) if __name__ == "__main__": main()