agriflow-api / analysis /run_anomaly_report.py
masterAAA123's picture
Space deploy v5: orphan snapshot of main, zero binary files
b81a86b
Raw
History Blame Contribute Delete
7.32 kB
"""
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()