DHDRL commited on
Commit
6a71b30
·
verified ·
1 Parent(s): be71657

Update backtest_indonesia.py

Browse files
Files changed (1) hide show
  1. backtest_indonesia.py +2 -78
backtest_indonesia.py CHANGED
@@ -3,17 +3,6 @@ backtest_indonesia.py
3
  =====================
4
  Historical replay / evaluation harness for the Indonesia weather-risk stack.
5
 
6
- WHAT THIS IS FOR
7
- ----------------
8
- The Optuna sweep (train_kaggle.py) evaluates the RL AGENT's hyperparameters.
9
- This harness answers a different, more operational question: "fed real
10
- historical weather for Indonesian zones, does the deterministic scoring
11
- stack (climatology anomalies -> ZoneObs signals -> crop_risk_scorer alert
12
- levels) actually fire on the days when the climate says something bad was
13
- happening -- and with how much lead time?" It is the missing link between
14
- "the modules pass unit tests" and "the system demonstrably works as a
15
- weather modelling / prediction / planning tool for Indonesia".
16
-
17
  PIPELINE PER REPLAY STEP (per zone, per date)
18
  ---------------------------------------------
19
  1. obs: live mode -> era5_data_pipeline.fetch_zone_obs (Open-Meteo archive
@@ -24,37 +13,10 @@ PIPELINE PER REPLAY STEP (per zone, per date)
24
  event blocks (offline, CI-friendly).
25
  2. anomalies: climatology.apply_climatology_anomalies with a PINNED
26
  climatology whose period ends BEFORE the replay window starts
27
- (end_year = replay_start.year - 1). This is the no-look-ahead
28
- guarantee; fetching anomalies through cfg.use_climatology_anomalies
29
- would instead use the most-recent years and leak the replayed period.
30
  3. forecast: timesfm_wrapper 'baseline' backend (persistence /
31
  climatology-reverting, derived from the obs only -- no look-ahead).
32
- A real NWP hindcast archive would be the strict upgrade; the baseline
33
- keeps the replay honest and reproducible.
34
  4. score: crop_risk_scorer.compute_risk_score -> alert level per day.
35
-
36
- GROUND TRUTH
37
- ------------
38
- Default (proxy): climatological percentiles from the SAME pinned climatology:
39
- drought day: obs.precip_30d_mm < mean_30d - 0.84 * std_30d
40
- flood day: obs.precip_7d_mm > mean_7d + 1.28 * std_7d
41
- L1 (optional): --impact-labels PATH loads impact_labels JSON (e.g.
42
- impact_labels_java_v1.json). When a zone-day has an L1 drought/flood
43
- event, that overrides the proxy flags for that day only. Days without
44
- L1 coverage keep the proxy. This is the documented production path
45
- toward BNPB/provincial catalogues without inventing live APIs.
46
-
47
- PRODUCT EMIT (optional)
48
- -----------------------
49
- --emit-product-alerts: after each compute_risk_score, call
50
- product_alert_service.emit_product_alert (idempotent, WARNING+ or
51
- hazard gate). Trusted backend path only; uses LocalTransport by default.
52
-
53
- SUGGESTED LIVE DEMO WINDOWS (historically documented events):
54
- * 2023 El Nino + positive-IOD dry season, Java:
55
- --zones karawang_rice,indramayu_rice --start 2023-07-01 --end 2023-11-30
56
- * 2020-21 La Nina wet season (Jan 2021 Java floods):
57
- --zones karawang_rice --start 2020-12-01 --end 2021-02-28
58
  """
59
 
60
  from __future__ import annotations
@@ -132,7 +94,6 @@ class DayRecord:
132
 
133
  @dataclass
134
  class BacktestMetrics:
135
- """Day-level confusion + event-level detection/lead time."""
136
  n_days: int = 0
137
  tp: int = 0
138
  fp: int = 0
@@ -174,17 +135,6 @@ class BacktestMetrics:
174
 
175
 
176
  def compute_metrics(records: Sequence[DayRecord]) -> BacktestMetrics:
177
- """Day-level confusion + event-run detection with lead time.
178
-
179
- Day-level: TP = alert on an event day, FP = alert on a non-event day,
180
- FN = missed event day, TN = quiet day correctly quiet.
181
- Run-level: contiguous event days (merged across gaps <=
182
- _RUN_MERGE_GAP_DAYS) form one event run. A run is DETECTED
183
- if any alert fires inside the run or in the
184
- _LEAD_WINDOW_DAYS before its first day. Lead time = days
185
- from that first qualifying alert to the run start (0 for
186
- alerts landing on day 1 of the run).
187
- """
188
  m = BacktestMetrics(n_days=len(records))
189
  for r in records:
190
  if r.event and r.alert:
@@ -243,13 +193,6 @@ def compute_metrics(records: Sequence[DayRecord]) -> BacktestMetrics:
243
 
244
 
245
  def compute_product_l1_metrics(records: Sequence[Dict[str, Any]]) -> Dict[str, Any]:
246
- """
247
- Primary product metrics: EMITTED vs L1 event days (not ADVISORY vs proxy).
248
-
249
- Uses record dicts (as written to result JSON). A day is:
250
- product_positive = product_emit == 'EMITTED'
251
- l1_event = gt_source == 'l1' and (event_drought or event_flood)
252
- """
253
  n = len(records)
254
  tp = fp = fn = tn = 0
255
  n_l1 = 0
@@ -299,7 +242,6 @@ def compute_product_l1_metrics(records: Sequence[Dict[str, Any]]) -> Dict[str, A
299
  # ---------------------------------------------------------------------------
300
 
301
  def _classify_events(obs: ZoneObs, clim: ZoneClimatology) -> Tuple[bool, bool]:
302
- """Proxy ground truth from the pinned climatology (see module docstring)."""
303
  event_drought = False
304
  event_flood = False
305
  if obs.precip_30d_mm > 0.0:
@@ -324,18 +266,6 @@ def replay_zone(
324
  emission_ledger: Any = None,
325
  transport: Any = None,
326
  ) -> List[DayRecord]:
327
- """Replay one zone over [start, end] at `step_days` resolution.
328
-
329
- mode='live': obs via era5_data_pipeline (Open-Meteo archive for
330
- historical dates), forecast via baseline backend.
331
- mode='synthetic': deterministic synthetic obs; `planted_events` maps
332
- 'drought'/'flood' -> (start, end) blocks during which
333
- the synthetic generator's event flag is forced on.
334
- impact_store: optional ImpactLabelStore; L1 flags override proxy
335
- when present for that zone-day.
336
- emit_product: if True, call product_alert_service after each score
337
- (requires emission_ledger + transport).
338
- """
339
  if start.tzinfo is None:
340
  start = start.replace(tzinfo=timezone.utc)
341
  if end.tzinfo is None:
@@ -378,9 +308,6 @@ def replay_zone(
378
  seed=_zo._stable_seed(f"{zone_id}|{vt.date().isoformat()}"),
379
  **flag,
380
  )
381
- # The factory draws its own random valid_time from the seed;
382
- # the replay clock is authoritative -- override via the
383
- # codebase's to_dict/from_dict idiom (never mutate).
384
  _d = obs.to_dict()
385
  _d.pop("_schema_version", None)
386
  _d["valid_time"] = vt.isoformat()
@@ -397,7 +324,6 @@ def replay_zone(
397
  ev_drought, ev_flood = _classify_events(obs, clim)
398
  gt_source = "proxy"
399
 
400
- # L1 override: when store has a label for this zone-day, use it.
401
  if impact_store is not None:
402
  try:
403
  l1_d, l1_f = impact_store.labels_for_day(zone_id, vt.date())
@@ -427,7 +353,6 @@ def replay_zone(
427
  except RuntimeError:
428
  loop = None
429
  if loop and loop.is_running():
430
- # Nested running loop (e.g. notebook): schedule carefully
431
  import concurrent.futures
432
  with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
433
  product_emit = pool.submit(lambda: asyncio.run(_one())).result().outcome_code
@@ -467,7 +392,6 @@ def run_backtest(
467
  impact_labels_path: Optional[str] = None,
468
  emit_product_alerts: bool = False,
469
  ) -> Dict[str, Any]:
470
- """Replay several zones; return per-zone + overall metrics and records."""
471
  if mode == "live":
472
  register_indonesia_zones()
473
 
@@ -737,4 +661,4 @@ if __name__ == "__main__":
737
  if len(sys.argv) > 1:
738
  sys.exit(_main())
739
  else:
740
- sys.exit(_self_test())
 
3
  =====================
4
  Historical replay / evaluation harness for the Indonesia weather-risk stack.
5
 
 
 
 
 
 
 
 
 
 
 
 
6
  PIPELINE PER REPLAY STEP (per zone, per date)
7
  ---------------------------------------------
8
  1. obs: live mode -> era5_data_pipeline.fetch_zone_obs (Open-Meteo archive
 
13
  event blocks (offline, CI-friendly).
14
  2. anomalies: climatology.apply_climatology_anomalies with a PINNED
15
  climatology whose period ends BEFORE the replay window starts
16
+ (end_year = replay_start.year - 1).
 
 
17
  3. forecast: timesfm_wrapper 'baseline' backend (persistence /
18
  climatology-reverting, derived from the obs only -- no look-ahead).
 
 
19
  4. score: crop_risk_scorer.compute_risk_score -> alert level per day.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  """
21
 
22
  from __future__ import annotations
 
94
 
95
  @dataclass
96
  class BacktestMetrics:
 
97
  n_days: int = 0
98
  tp: int = 0
99
  fp: int = 0
 
135
 
136
 
137
  def compute_metrics(records: Sequence[DayRecord]) -> BacktestMetrics:
 
 
 
 
 
 
 
 
 
 
 
138
  m = BacktestMetrics(n_days=len(records))
139
  for r in records:
140
  if r.event and r.alert:
 
193
 
194
 
195
  def compute_product_l1_metrics(records: Sequence[Dict[str, Any]]) -> Dict[str, Any]:
 
 
 
 
 
 
 
196
  n = len(records)
197
  tp = fp = fn = tn = 0
198
  n_l1 = 0
 
242
  # ---------------------------------------------------------------------------
243
 
244
  def _classify_events(obs: ZoneObs, clim: ZoneClimatology) -> Tuple[bool, bool]:
 
245
  event_drought = False
246
  event_flood = False
247
  if obs.precip_30d_mm > 0.0:
 
266
  emission_ledger: Any = None,
267
  transport: Any = None,
268
  ) -> List[DayRecord]:
 
 
 
 
 
 
 
 
 
 
 
 
269
  if start.tzinfo is None:
270
  start = start.replace(tzinfo=timezone.utc)
271
  if end.tzinfo is None:
 
308
  seed=_zo._stable_seed(f"{zone_id}|{vt.date().isoformat()}"),
309
  **flag,
310
  )
 
 
 
311
  _d = obs.to_dict()
312
  _d.pop("_schema_version", None)
313
  _d["valid_time"] = vt.isoformat()
 
324
  ev_drought, ev_flood = _classify_events(obs, clim)
325
  gt_source = "proxy"
326
 
 
327
  if impact_store is not None:
328
  try:
329
  l1_d, l1_f = impact_store.labels_for_day(zone_id, vt.date())
 
353
  except RuntimeError:
354
  loop = None
355
  if loop and loop.is_running():
 
356
  import concurrent.futures
357
  with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
358
  product_emit = pool.submit(lambda: asyncio.run(_one())).result().outcome_code
 
392
  impact_labels_path: Optional[str] = None,
393
  emit_product_alerts: bool = False,
394
  ) -> Dict[str, Any]:
 
395
  if mode == "live":
396
  register_indonesia_zones()
397
 
 
661
  if len(sys.argv) > 1:
662
  sys.exit(_main())
663
  else:
664
+ sys.exit(_self_test())