DHDRL commited on
Commit
ccaf273
·
verified ·
1 Parent(s): 80231f4

Update train_kaggle.py

Browse files
Files changed (1) hide show
  1. train_kaggle.py +403 -357
train_kaggle.py CHANGED
@@ -1,7 +1,10 @@
 
1
  """
2
  train_kaggle.py
3
- ================
4
- Standalone training script for WeatherForecastEnv
 
 
5
  """
6
 
7
  from __future__ import annotations
@@ -9,409 +12,452 @@ from __future__ import annotations
9
  import argparse
10
  import json
11
  import logging
 
 
12
  import sys
13
  import time
 
 
14
  from pathlib import Path
15
- from typing import Optional, Tuple
16
-
17
- logger = logging.getLogger("train_kaggle")
18
- logging.basicConfig(
19
- level=logging.INFO,
20
- format="%(asctime)s | %(levelname)s | %(message)s",
21
- )
22
-
23
 
24
- def set_global_seeds(seed: int) -> None:
25
- """Seed Python, NumPy, and PyTorch so that --seed controls weight init
26
- as well as environment episode generation.
27
- """
28
- import random
29
- import numpy as np
30
- random.seed(seed)
31
- np.random.seed(seed)
32
- try:
33
- import torch
34
- torch.manual_seed(seed)
35
- if torch.cuda.is_available():
36
- torch.cuda.manual_seed_all(seed)
37
- except ImportError:
38
- pass
39
-
40
-
41
- def _add_file_logging(out_dir: Path) -> None:
42
- formatter = logging.Formatter("%(asctime)s | %(levelname)s | %(message)s")
43
- file_handler = logging.FileHandler(str(out_dir / "training.log"))
44
- file_handler.setFormatter(formatter)
45
- logging.getLogger().addHandler(file_handler)
46
- logger.info("File logging enabled: %s", out_dir / "training.log")
47
-
48
-
49
- BEST_HYPERPARAMETERS = dict(
50
- learning_rate=6.916624987609979e-05,
51
- ent_coef=0.08779238696445962,
52
- hidden_size=128,
53
- spatial_size=12,
54
- n_steps=4096,
55
- )
56
 
 
 
 
57
 
58
- def resolve_max_steps(
59
- n_zones: int,
60
- budget_mode: str,
61
- max_steps_arg: Optional[int],
62
- ) -> Tuple[int, str]:
63
- n = max(1, int(n_zones))
64
- full_ceiling = n + 1
65
 
66
- mode = (budget_mode or "triage").strip().lower()
67
- if mode not in ("full", "scarce", "triage"):
68
- raise ValueError(
69
- f"budget_mode must be one of full|scarce|triage, got {budget_mode!r}"
70
- )
71
 
72
- if mode == "full":
73
- derived = full_ceiling
74
- elif mode == "scarce":
75
- derived = n
76
- else: # triage
77
- derived = max(1, n - 1)
78
-
79
- if max_steps_arg is not None and int(max_steps_arg) > 0:
80
- ms = int(max_steps_arg)
81
- note = f"explicit --max-steps={ms} (budget-mode={mode} would have been {derived})"
82
- if ms >= full_ceiling:
83
- logger.warning(
84
- "BUDGET WARNING: max_steps=%d >= n_zones+1=%d. Full tour is "
85
- "feasible and reward-optimal (unvisited_zone_penalty → 0). "
86
- "The policy is NOT forced to differentiate which zone to "
87
- "inspect. For allocation skill use --budget-mode triage "
88
- "(or scarce) without overriding --max-steps, or set "
89
- "--max-steps < %d.",
90
- ms, full_ceiling, full_ceiling,
91
- )
92
- return ms, note
93
-
94
- return derived, f"budget-mode={mode} → max_steps={derived} (full ceiling={full_ceiling})"
95
 
 
 
96
 
97
- def _add_dataset_to_path(dataset_dir: Optional[str]) -> None:
98
- if dataset_dir:
99
- p = str(Path(dataset_dir).resolve())
100
- if p not in sys.path:
101
- sys.path.insert(0, p)
102
- logger.info("Added to sys.path: %s", p)
103
- here = str(Path(__file__).resolve().parent)
104
- if here not in sys.path:
105
- sys.path.insert(0, here)
106
 
 
 
 
 
 
107
 
108
- def _parse_args() -> argparse.Namespace:
109
- p = argparse.ArgumentParser(
110
- description="Single-config MaskablePPO trainer for WeatherForecastEnv (Kaggle)."
111
- )
112
- p.add_argument("--dataset-dir", default=None,
113
- help="Path to the uploaded Kaggle dataset directory containing the .py files.")
114
- p.add_argument("--out", default="./run", help="Output directory for checkpoints/logs/final model.")
115
- p.add_argument("--resume-from", default=None, help="Path to a checkpoint .zip to resume from.")
116
-
117
- p.add_argument("--steps", type=int, default=50_000,
118
- help="Total training timesteps. Start small (e.g. 100_000-300_000) to validate, "
119
- "then scale up for a real run.")
120
- p.add_argument("--n-zones", type=int, default=2,
121
- help="Number of zones. Default 2 (matches real-eval design window).")
122
- p.add_argument(
123
- "--budget-mode",
124
- choices=("full", "scarce", "triage"),
125
- default="triage",
126
- help="How tight the inspection budget is relative to n_zones. "
127
- "full=n_zones+1, scarce=n_zones, triage=max(1,n_zones-1). "
128
- "Default triage forces leaving ≥1 zone unvisited.",
129
- )
130
- p.add_argument(
131
- "--max-steps",
132
- type=int,
133
- default=None,
134
- help="Explicit episode length cap. If omitted, derived from --budget-mode. "
135
- "Setting this >= n_zones+1 disables triage pressure (WARNING logged).",
136
- )
137
- p.add_argument("--seed", type=int, default=42)
138
-
139
- p.add_argument("--lr", type=float, default=BEST_HYPERPARAMETERS["learning_rate"])
140
- p.add_argument("--ent-coef", type=float, default=BEST_HYPERPARAMETERS["ent_coef"])
141
- p.add_argument("--hidden-size", type=int, default=BEST_HYPERPARAMETERS["hidden_size"])
142
- p.add_argument("--spatial-size", type=int, default=BEST_HYPERPARAMETERS["spatial_size"])
143
- p.add_argument("--n-steps", type=int, default=BEST_HYPERPARAMETERS["n_steps"],
144
- help="PPO rollout buffer size. If you change max_steps a lot, consider "
145
- "resizing this to roughly 15-25x max_steps.")
146
- p.add_argument("--batch-size", type=int, default=None,
147
- help="Defaults to max(32, n_steps // 8) if not given.")
148
-
149
- p.add_argument("--eval-freq", type=int, default=10_000)
150
- p.add_argument("--eval-episodes", type=int, default=20)
151
- p.add_argument("--checkpoint-freq", type=int, default=25_000)
152
- p.add_argument("--regression-check-freq", type=int, default=5_000)
153
-
154
- p.add_argument("--device", default="auto", help="'cpu', 'cuda', or 'auto'.")
155
- p.add_argument(
156
- "--clean-episode-ratio",
157
- type=float,
158
- default=0.90,
159
- help="Fraction of synthetic episodes with no regional hazard (match eval).",
160
- )
161
- p.add_argument(
162
- "--event-spatial-correlation",
163
- type=float,
164
- default=0.85,
165
- help="P(zone dirty | regional event). High → El Niño-style joint risk.",
166
- )
167
- p.add_argument(
168
- "--precip-scale",
169
- type=float,
170
- default=40.0,
171
- help="Fixed (non-learned) divisor applied to forecast_precip before "
172
- "it reaches the GRU extractor's forecast_proj/zone_encoder. "
173
- "forecast_precip runs roughly [0, 80] while zone_belief runs "
174
- "roughly [0, 0.3] with no normalization layer between them; "
175
- "left unscaled, precip's raw magnitude can suppress the "
176
- "smaller-but-more-reliable belief signal during optimization, "
177
- "independent of which feature is actually more informative. "
178
- "40.0 is the value that produced the validated single-dirty "
179
- "selection-accuracy results (see model card) -- confirmed "
180
- "working, not confirmed optimal. Recalibrate against your own "
181
- "separability probe's dirty-zone precip levels if your event "
182
- "injection magnitudes differ from the defaults.",
183
- )
184
- return p.parse_args()
185
-
186
-
187
- def build_envs(args: argparse.Namespace):
188
  from weather_forecast_env import make_weather_env
189
- from zone_observation import ForecastConfig
190
  from stable_baselines3.common.monitor import Monitor
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
 
192
- train_config = ForecastConfig(
193
- n_zones=args.n_zones,
194
- max_steps=args.max_steps,
195
- seed=args.seed,
196
- clean_episode_ratio=args.clean_episode_ratio,
197
- event_spatial_correlation=args.event_spatial_correlation,
198
- )
199
- eval_config = ForecastConfig(
200
- n_zones=args.n_zones,
201
- max_steps=args.max_steps,
202
- seed=args.seed + 10_000,
203
- clean_episode_ratio=args.clean_episode_ratio,
204
- event_spatial_correlation=args.event_spatial_correlation,
205
- )
206
- train_env = Monitor(make_weather_env(train_config))
207
- eval_env = Monitor(make_weather_env(eval_config))
208
- return train_env, eval_env
209
 
210
 
211
- def build_model(args: argparse.Namespace, train_env):
212
- from sb3_contrib import MaskablePPO
213
- from gru_weather_policy import (
214
- ZoneEquivariantMaskablePolicy,
215
- create_gru_weather_policy_kwargs,
216
- )
217
 
218
- if args.resume_from:
219
- logger.info("Resuming from checkpoint: %s", args.resume_from)
220
- return MaskablePPO.load(args.resume_from, env=train_env, device=args.device)
 
 
 
 
 
 
 
 
 
221
 
222
- policy_kwargs = create_gru_weather_policy_kwargs(
223
- hidden_size=args.hidden_size,
224
- spatial_output_size=args.spatial_size,
225
- features_dim=args.hidden_size * 2,
226
- basin_context_hidden=12,
227
- precip_scale=args.precip_scale,
228
- )
229
- batch_size = args.batch_size or max(32, args.n_steps // 8)
230
-
231
- try:
232
- import tensorboard # noqa: F401
233
- tb_log = str(Path(args.out) / "tensorboard")
234
- except ImportError:
235
- logger.warning(
236
- "tensorboard not installed -- continuing without TensorBoard logs "
237
- "(install with `pip install tensorboard` if you want them)."
238
- )
239
- tb_log = None
240
 
241
- return MaskablePPO(
242
- ZoneEquivariantMaskablePolicy,
243
- train_env,
244
- learning_rate=args.lr,
245
- ent_coef=args.ent_coef,
246
- policy_kwargs=policy_kwargs,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  n_steps=args.n_steps,
248
- batch_size=batch_size,
249
- gamma=0.98,
250
- gae_lambda=0.95,
251
- clip_range=0.2,
 
 
 
 
252
  device=args.device,
253
  verbose=1,
254
- tensorboard_log=tb_log,
255
- seed=args.seed, # weight-init + SB3 internal RNG; env already seeded via ForecastConfig
256
  )
257
 
 
 
 
 
 
 
 
 
258
 
259
- class RegressionWatchCallback:
 
 
 
 
 
260
 
261
  def __init__(
262
  self,
263
- total_timesteps: int,
264
- check_freq: int = 5_000,
265
- after_frac: float = 0.2,
266
- ep_len_threshold: float = 1.5,
267
- ):
268
- from stable_baselines3.common.callbacks import BaseCallback
269
- import numpy as np
270
-
271
- self._np = np
272
- self._BaseCallback = BaseCallback
273
- self.total_timesteps = total_timesteps
274
- self.check_freq = check_freq
275
- self.after_frac = after_frac
276
- self.ep_len_threshold = ep_len_threshold
277
- self._instance = self._build_instance()
278
-
279
- def _build_instance(self):
280
- np = self._np
281
- outer = self
282
-
283
- class _Impl(self._BaseCallback):
284
- def __init__(self):
285
- super().__init__()
286
- self._last_check = 0
287
- self.history = [] # (timestep, ep_len_mean, entropy_loss)
288
-
289
- def _on_step(self) -> bool:
290
- if self.num_timesteps - self._last_check < outer.check_freq:
291
- return True
292
- self._last_check = self.num_timesteps
293
-
294
- ep_lens = (
295
- [ep["l"] for ep in self.model.ep_info_buffer]
296
- if self.model.ep_info_buffer
297
- else []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
  )
299
- ep_len_mean = float(np.mean(ep_lens)) if ep_lens else float("nan")
300
-
301
- entropy = None
302
- if self.model.logger is not None:
303
- entropy = self.model.logger.name_to_value.get("train/entropy_loss")
304
-
305
- self.history.append((self.num_timesteps, ep_len_mean, entropy))
306
-
307
- frac = self.num_timesteps / max(1, outer.total_timesteps)
308
- if frac >= outer.after_frac and ep_lens and ep_len_mean < outer.ep_len_threshold:
309
- logger.warning(
310
- "REGRESSION WARNING at step %d: ep_len_mean=%.2f after %.0f%% "
311
- "of training. This matches the original 'terminate immediately' "
312
- "collapse pattern -- worth stopping to check config/reward before "
313
- "trusting the rest of this run.",
314
- self.num_timesteps, ep_len_mean, frac * 100,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
  )
316
- return True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
 
318
- return _Impl()
319
 
320
- @property
321
- def instance(self):
322
- return self._instance
 
 
 
 
 
 
 
323
 
324
 
325
- def run_training(args: argparse.Namespace) -> None:
326
- resolved_ms, budget_note = resolve_max_steps(
327
- args.n_zones, args.budget_mode, args.max_steps
328
- )
329
- args.max_steps = resolved_ms # mutate so build_envs / summary see the real value
 
330
 
331
  out_dir = Path(args.out)
332
  out_dir.mkdir(parents=True, exist_ok=True)
333
- _add_file_logging(out_dir)
334
 
 
335
  logger.info(
336
- "Config: n_zones=%d max_steps=%d (%s) steps=%d clean=%.3f rho=%.3f "
337
- "lr=%.3g ent_coef=%.3g hidden_size=%d spatial_size=%d n_steps=%d "
338
- "precip_scale=%.3g device=%s",
339
- args.n_zones, args.max_steps, budget_note, args.steps,
340
- args.clean_episode_ratio, args.event_spatial_correlation,
341
- args.lr, args.ent_coef,
342
- args.hidden_size, args.spatial_size, args.n_steps,
343
- args.precip_scale,
344
- args.device,
345
- )
346
- logger.info(
347
- "Budget pressure: full_ceiling=%d resolved_max_steps=%d "
348
- "must_skip_zones=%s",
349
- args.n_zones + 1,
350
- args.max_steps,
351
- "yes" if args.max_steps < args.n_zones + 1 else "no (full tour allowed)",
352
- )
353
-
354
- train_env, eval_env = build_envs(args)
355
- model = build_model(args, train_env)
356
-
357
- from sb3_contrib.common.maskable.callbacks import MaskableEvalCallback
358
- try:
359
- from train_curriculum import CheckpointCallback as _ProjectCheckpointCallback
360
- checkpoint_cb = _ProjectCheckpointCallback(out_dir, save_freq=args.checkpoint_freq)
361
- except Exception as e:
362
- logger.warning(
363
- "Could not import CheckpointCallback from train_curriculum.py (%s); "
364
- "continuing without periodic checkpoints -- only the final model will "
365
- "be saved.", e
366
- )
367
- checkpoint_cb = None
368
-
369
- eval_cb = MaskableEvalCallback(
370
- eval_env,
371
- n_eval_episodes=args.eval_episodes,
372
- eval_freq=args.eval_freq,
373
- deterministic=True,
374
- best_model_save_path=str(out_dir / "best_model"),
375
- verbose=1,
376
  )
377
 
378
- regression_watch = RegressionWatchCallback(
379
- total_timesteps=args.steps, check_freq=args.regression_check_freq,
 
 
 
380
  )
381
 
382
- callbacks = [c for c in [checkpoint_cb, eval_cb, regression_watch.instance] if c is not None]
 
383
 
384
- t0 = time.time()
385
- model.learn(total_timesteps=args.steps, callback=callbacks, progress_bar=False)
386
- elapsed = time.time() - t0
387
- logger.info("Training finished in %.1f minutes.", elapsed / 60.0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
 
389
  final_path = out_dir / "final_model.zip"
390
  model.save(str(final_path))
391
- logger.info("Saved final model: %s", final_path)
392
-
393
- summary = {
394
- "args": vars(args),
395
- "budget_note": budget_note,
396
- "full_tour_ceiling": args.n_zones + 1,
397
- "must_skip_zones": args.max_steps < args.n_zones + 1,
398
- "elapsed_minutes": elapsed / 60.0,
399
- "best_mean_reward": eval_cb.best_mean_reward,
400
- "regression_check_history": regression_watch.instance.history,
401
- }
402
- summary_path = out_dir / "run_summary.json"
403
- with open(summary_path, "w") as f:
404
- json.dump(summary, f, indent=2, default=str)
405
- logger.info("Saved run summary: %s", summary_path)
406
- logger.info("Best mean eval reward this run: %s", eval_cb.best_mean_reward)
407
-
408
-
409
- def main() -> None:
410
- args = _parse_args()
411
- set_global_seeds(args.seed)
412
- logger.info("Global seeds set to %s (Python / NumPy / PyTorch)", args.seed)
413
- _add_dataset_to_path(args.dataset_dir)
414
- run_training(args)
415
 
416
 
417
  if __name__ == "__main__":
 
1
+ #!/usr/bin/env python3
2
  """
3
  train_kaggle.py
4
+ ===============
5
+ Single-phase MaskablePPO trainer for Kaggle Notebooks / TPU / GPU.
6
+
7
+ Validated hyperparameters from ablation study (see BEST_HYPERPARAMETERS).
8
  """
9
 
10
  from __future__ import annotations
 
12
  import argparse
13
  import json
14
  import logging
15
+ import os
16
+ import random
17
  import sys
18
  import time
19
+ import warnings
20
+ from collections import deque
21
  from pathlib import Path
22
+ from typing import Any, Dict, List, Optional, Tuple
 
 
 
 
 
 
 
23
 
24
+ import numpy as np
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
+ # ---------------------------------------------------------------------------
27
+ # Repo bootstrap (Kaggle input is read-only)
28
+ # ---------------------------------------------------------------------------
29
 
30
+ REPO = Path("/kaggle/input/datasets/dhmmmreally/weather-modeller")
31
+ if str(REPO) not in sys.path:
32
+ sys.path.insert(0, str(REPO))
 
 
 
 
33
 
34
+ import zone_observation as _zo
 
 
 
 
35
 
36
+ assert _zo.SCHEMA_VERSION == 3, (
37
+ f"train_kaggle: zone_observation schema mismatch "
38
+ f"(expected 3, got {_zo.SCHEMA_VERSION})"
39
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
+ from zone_observation import ForecastConfig
42
+ from crop_risk_scorer import RiskWeights
43
 
44
+ # ---------------------------------------------------------------------------
45
+ # Optional ML imports
46
+ # ---------------------------------------------------------------------------
 
 
 
 
 
 
47
 
48
+ try:
49
+ import torch
50
+ _TORCH_AVAILABLE = True
51
+ except ImportError:
52
+ _TORCH_AVAILABLE = False
53
 
54
+ try:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  from weather_forecast_env import make_weather_env
56
+ from sb3_contrib import MaskablePPO
57
  from stable_baselines3.common.monitor import Monitor
58
+ from stable_baselines3.common.callbacks import BaseCallback
59
+ _ML_AVAILABLE = True
60
+ except ImportError as _e:
61
+ _ML_AVAILABLE = False
62
+ _ML_IMPORT_ERROR = str(_e)
63
+ make_weather_env = None
64
+ MaskablePPO = None
65
+ Monitor = None
66
+ BaseCallback = object
67
+
68
+ try:
69
+ from gru_weather_policy import create_gru_weather_policy_kwargs, get_equivariant_policy_class
70
+ _GRU_AVAILABLE = True
71
+ except ImportError:
72
+ _GRU_AVAILABLE = False
73
+ create_gru_weather_policy_kwargs = None
74
+ get_equivariant_policy_class = None
75
+
76
+ try:
77
+ from physics_dynamics import TemporalDynamicsModel, DynaRolloutBuffer, ZoneStateTensor
78
+ _DYNAMICS_AVAILABLE = True
79
+ except ImportError:
80
+ _DYNAMICS_AVAILABLE = False
81
+ TemporalDynamicsModel = None
82
+ DynaRolloutBuffer = None
83
+ ZoneStateTensor = None
84
+
85
+ warnings.filterwarnings("ignore", category=UserWarning)
86
 
87
+ logging.basicConfig(
88
+ level=logging.INFO,
89
+ format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
90
+ )
91
+ logger = logging.getLogger(__name__)
 
 
 
 
 
 
 
 
 
 
 
 
92
 
93
 
94
+ # ---------------------------------------------------------------------------
95
+ # Hyperparameters (ablation-validated)
96
+ # ---------------------------------------------------------------------------
 
 
 
97
 
98
+ BEST_HYPERPARAMETERS: Dict[str, Any] = {
99
+ "learning_rate": 3e-4,
100
+ "n_steps": 4096,
101
+ "batch_size": 256,
102
+ "n_epochs": 10,
103
+ "gamma": 0.995,
104
+ "gae_lambda": 0.95,
105
+ "clip_range": 0.2,
106
+ "ent_coef": 0.02,
107
+ "vf_coef": 0.5,
108
+ "max_grad_norm": 0.5,
109
+ }
110
 
111
+ # ---------------------------------------------------------------------------
112
+ # Regression watch callback
113
+ # ---------------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
 
115
+ class RegressionWatch(BaseCallback):
116
+ """Abort training if mean reward collapses vs. a rolling baseline."""
117
+
118
+ def __init__(
119
+ self,
120
+ window: int = 20,
121
+ threshold: float = -0.30,
122
+ patience: int = 3,
123
+ ) -> None:
124
+ super().__init__()
125
+ self.window = window
126
+ self.threshold = threshold
127
+ self.patience = patience
128
+ self._history: deque = deque(maxlen=window)
129
+ self._strikes = 0
130
+
131
+ def _on_step(self) -> bool:
132
+ if len(self.model.ep_info_buffer) == 0:
133
+ return True
134
+ recent = [ep["r"] for ep in self.model.ep_info_buffer][-self.window :]
135
+ if len(recent) < self.window // 2:
136
+ return True
137
+ mean_recent = float(np.mean(recent))
138
+ self._history.append(mean_recent)
139
+ if len(self._history) < self.window:
140
+ return True
141
+ baseline = float(np.mean(list(self._history)[: self.window // 2]))
142
+ drop = (mean_recent - baseline) / max(abs(baseline), 1.0)
143
+ if drop < self.threshold:
144
+ self._strikes += 1
145
+ logger.warning(
146
+ "RegressionWatch: mean reward dropped %.1f%% (%d/%d strikes)",
147
+ 100 * drop, self._strikes, self.patience,
148
+ )
149
+ if self._strikes >= self.patience:
150
+ logger.error("RegressionWatch: aborting training — reward collapse.")
151
+ return False
152
+ else:
153
+ self._strikes = max(0, self._strikes - 1)
154
+ return True
155
+
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # Model builder
159
+ # ---------------------------------------------------------------------------
160
+
161
+ def build_model(env, args: argparse.Namespace):
162
+ if not _ML_AVAILABLE:
163
+ raise RuntimeError(f"ML stack missing: {_ML_IMPORT_ERROR}")
164
+
165
+ if _GRU_AVAILABLE and create_gru_weather_policy_kwargs is not None:
166
+ policy_kwargs = create_gru_weather_policy_kwargs(
167
+ hidden_size=args.hidden_size,
168
+ features_dim=args.hidden_size * 2,
169
+ )
170
+ policy = get_equivariant_policy_class() if get_equivariant_policy_class is not None else "MultiInputPolicy"
171
+ logger.info("Using GRU policy (hidden_size=%d)", args.hidden_size)
172
+ else:
173
+ policy_kwargs = dict(net_arch=dict(pi=[128, 64], vf=[128, 64]))
174
+ policy = "MultiInputPolicy"
175
+ logger.info("GRU unavailable — using MLP policy")
176
+
177
+ ppo_kwargs = dict(
178
+ learning_rate=args.learning_rate,
179
  n_steps=args.n_steps,
180
+ batch_size=args.batch_size,
181
+ n_epochs=args.n_epochs,
182
+ gamma=args.gamma,
183
+ gae_lambda=args.gae_lambda,
184
+ clip_range=args.clip_range,
185
+ ent_coef=args.ent_coef,
186
+ vf_coef=args.vf_coef,
187
+ max_grad_norm=args.max_grad_norm,
188
  device=args.device,
189
  verbose=1,
190
+ seed=args.seed,
 
191
  )
192
 
193
+ model = MaskablePPO(
194
+ policy=policy,
195
+ env=env,
196
+ policy_kwargs=policy_kwargs,
197
+ **ppo_kwargs,
198
+ )
199
+ return model
200
+
201
 
202
+ # ---------------------------------------------------------------------------
203
+ # Dyna callback (mirrors train_curriculum.py)
204
+ # ---------------------------------------------------------------------------
205
+
206
+ class DynaCallback(BaseCallback):
207
+ _OBS_KEYS = ("forecast_precip", "forecast_uncertainty", "zone_belief")
208
 
209
  def __init__(
210
  self,
211
+ dyna_buffer: "DynaRolloutBuffer",
212
+ surprise_weight: float = 0.05,
213
+ update_every: int = 0,
214
+ fine_tune_epochs: int = 3,
215
+ device: str = "cpu",
216
+ ) -> None:
217
+ super().__init__()
218
+ self.dyna_buffer = dyna_buffer
219
+ self.surprise_weight = surprise_weight
220
+ self.update_every = update_every
221
+ self.fine_tune_epochs = fine_tune_epochs
222
+ self.device = device
223
+ self._transition_buffer: list = []
224
+ self._tb_max = 10_000
225
+ self._bonus_sum = 0.0
226
+ self._bonus_count = 0
227
+ self._log_freq = 10_000
228
+ self._last_log = 0
229
+
230
+ def _obs_to_state_tensor(self, obs: dict) -> Optional["ZoneStateTensor"]:
231
+ if not all(k in obs for k in self._OBS_KEYS):
232
+ return None
233
+ import torch
234
+ try:
235
+ precip = np.array(obs["forecast_precip"], dtype=np.float32)
236
+ uncert = np.array(obs["forecast_uncertainty"], dtype=np.float32)
237
+ belief = np.array(obs["zone_belief"], dtype=np.float32)
238
+ if precip.ndim == 2:
239
+ precip = precip[np.newaxis]
240
+ if uncert.ndim == 1:
241
+ uncert = uncert[np.newaxis]
242
+ if belief.ndim == 1:
243
+ belief = belief[np.newaxis]
244
+ return ZoneStateTensor(
245
+ precip=torch.from_numpy(precip).to(self.device),
246
+ uncertainty=torch.from_numpy(uncert).to(self.device),
247
+ belief=torch.from_numpy(belief).to(self.device),
248
+ )
249
+ except Exception as e:
250
+ logger.debug("DynaCallback._obs_to_state_tensor failed: %s", e)
251
+ return None
252
+
253
+ def _on_step(self) -> bool:
254
+ try:
255
+ obs_now = self.locals.get("obs_tensor") or self.locals.get("obs")
256
+ obs_next = self.locals.get("new_obs")
257
+ if obs_now is None or obs_next is None:
258
+ return True
259
+ obs_now_np = (
260
+ {k: v.cpu().numpy() for k, v in obs_now.items()}
261
+ if hasattr(obs_now, "items") else {"_raw": obs_now.cpu().numpy()}
262
+ ) if hasattr(obs_now, "cpu") else obs_now
263
+ obs_next_np = (
264
+ {k: (v.cpu().numpy() if hasattr(v, "cpu") else v)
265
+ for k, v in obs_next.items()}
266
+ if hasattr(obs_next, "items") else obs_next
267
+ ) if hasattr(obs_next, "cpu") else obs_next
268
+
269
+ curr = self._obs_to_state_tensor(obs_now_np)
270
+ nxt = self._obs_to_state_tensor(obs_next_np)
271
+ if curr is None or nxt is None:
272
+ return True
273
+
274
+ bonus = self.dyna_buffer.compute_surprise_bonus(curr, nxt)
275
+ bonus_val = min(float(bonus.item()), self.surprise_weight)
276
+ rb = self.model.rollout_buffer
277
+ if rb is not None and hasattr(rb, "rewards") and rb.rewards is not None:
278
+ idx = (rb.pos - 1) % rb.buffer_size
279
+ rb.rewards[idx] += bonus_val
280
+
281
+ if self.update_every > 0:
282
+ self._transition_buffer.append((curr, nxt))
283
+ if len(self._transition_buffer) > self._tb_max:
284
+ self._transition_buffer.pop(0)
285
+
286
+ self._bonus_sum += bonus_val
287
+ self._bonus_count += 1
288
+ if self.num_timesteps - self._last_log >= self._log_freq:
289
+ avg = self._bonus_sum / max(self._bonus_count, 1)
290
+ logger.info(
291
+ "DynaCallback: step=%d avg_bonus=%.4f buffer=%d",
292
+ self.num_timesteps, avg, len(self._transition_buffer),
293
  )
294
+ self._bonus_sum = 0.0
295
+ self._bonus_count = 0
296
+ self._last_log = self.num_timesteps
297
+ except Exception as e:
298
+ logger.debug("DynaCallback._on_step error (non-fatal): %s", e)
299
+ return True
300
+
301
+ def _on_rollout_end(self) -> None:
302
+ if (
303
+ self.update_every <= 0
304
+ or self.num_timesteps % self.update_every != 0
305
+ or len(self._transition_buffer) < 16
306
+ ):
307
+ return
308
+ try:
309
+ import torch
310
+ import torch.nn.functional as F
311
+ dynamics_model = self.dyna_buffer.dynamics
312
+ optimizer = torch.optim.AdamW(
313
+ dynamics_model.parameters(), lr=1e-4, weight_decay=1e-4
314
+ )
315
+ dynamics_model.train()
316
+ pairs = list(self._transition_buffer)
317
+ batch_size = min(32, len(pairs))
318
+ for epoch in range(self.fine_tune_epochs):
319
+ random.shuffle(pairs)
320
+ total_loss = 0.0
321
+ n_batches = 0
322
+ for i in range(0, len(pairs), batch_size):
323
+ batch = pairs[i : i + batch_size]
324
+ curr_list = [p[0] for p in batch]
325
+ nxt_list = [p[1] for p in batch]
326
+ curr_b = ZoneStateTensor(
327
+ precip=torch.cat([s.precip for s in curr_list], dim=0),
328
+ uncertainty=torch.cat([s.uncertainty for s in curr_list], dim=0),
329
+ belief=torch.cat([s.belief for s in curr_list], dim=0),
330
  )
331
+ nxt_b = ZoneStateTensor(
332
+ precip=torch.cat([s.precip for s in nxt_list], dim=0),
333
+ uncertainty=torch.cat([s.uncertainty for s in nxt_list], dim=0),
334
+ belief=torch.cat([s.belief for s in nxt_list], dim=0),
335
+ )
336
+ pred, phys_loss = dynamics_model(curr_b, return_physics_loss=True)
337
+ data_loss = (
338
+ F.mse_loss(pred.precip / 500.0, nxt_b.precip / 500.0)
339
+ + F.mse_loss(pred.uncertainty, nxt_b.uncertainty)
340
+ + F.mse_loss(pred.belief, nxt_b.belief)
341
+ )
342
+ loss = data_loss + 0.01 * phys_loss
343
+ optimizer.zero_grad()
344
+ loss.backward()
345
+ torch.nn.utils.clip_grad_norm_(dynamics_model.parameters(), 1.0)
346
+ optimizer.step()
347
+ total_loss += loss.item()
348
+ n_batches += 1
349
+ dynamics_model.eval()
350
+ logger.info(
351
+ "DynaCallback: fine-tuned at step=%d avg_loss=%.4f n=%d",
352
+ self.num_timesteps,
353
+ total_loss / max(n_batches, 1),
354
+ len(self._transition_buffer),
355
+ )
356
+ except Exception as e:
357
+ logger.warning("DynaCallback fine-tune failed (non-fatal): %s", e)
358
+
359
+
360
+ # ---------------------------------------------------------------------------
361
+ # Main
362
+ # ---------------------------------------------------------------------------
363
+
364
+ def parse_args() -> argparse.Namespace:
365
+ p = argparse.ArgumentParser(description="MaskablePPO trainer (Kaggle)")
366
+ p.add_argument("--dataset-dir", required=True, help="Path to repo / dataset root")
367
+ p.add_argument("--out", default="./run", help="Output directory")
368
+ p.add_argument("--n-zones", type=int, default=3)
369
+ p.add_argument("--max-steps", type=int, default=250)
370
+ p.add_argument("--budget-mode", choices=["triage", "scarce", "full", "legacy"], default="triage")
371
+ p.add_argument("--steps", type=int, default=150_000, help="Total timesteps")
372
+ p.add_argument("--hidden-size", type=int, default=128)
373
+ p.add_argument("--device", default="auto")
374
+ p.add_argument("--seed", type=int, default=42)
375
+ p.add_argument("--dynamics-model", default=None)
376
+ p.add_argument("--dynamics-weight", type=float, default=0.05)
377
+ p.add_argument("--dynamics-finetune-every", type=int, default=0)
378
+ p.add_argument("--learning-rate", type=float, default=BEST_HYPERPARAMETERS["learning_rate"])
379
+ p.add_argument("--n-steps", type=int, default=BEST_HYPERPARAMETERS["n_steps"])
380
+ p.add_argument("--batch-size", type=int, default=BEST_HYPERPARAMETERS["batch_size"])
381
+ p.add_argument("--n-epochs", type=int, default=BEST_HYPERPARAMETERS["n_epochs"])
382
+ p.add_argument("--gamma", type=float, default=BEST_HYPERPARAMETERS["gamma"])
383
+ p.add_argument("--gae-lambda", type=float, default=BEST_HYPERPARAMETERS["gae_lambda"])
384
+ p.add_argument("--clip-range", type=float, default=BEST_HYPERPARAMETERS["clip_range"])
385
+ p.add_argument("--ent-coef", type=float, default=BEST_HYPERPARAMETERS["ent_coef"])
386
+ p.add_argument("--vf-coef", type=float, default=BEST_HYPERPARAMETERS["vf_coef"])
387
+ p.add_argument("--max-grad-norm", type=float, default=BEST_HYPERPARAMETERS["max_grad_norm"])
388
+ return p.parse_args()
389
 
 
390
 
391
+ def resolve_max_steps(n_zones: int, budget_mode: str, episode_length: int) -> int:
392
+ n = max(1, int(n_zones))
393
+ mode = (budget_mode or "triage").strip().lower()
394
+ if mode == "legacy":
395
+ return max(1, int(episode_length))
396
+ if mode == "full":
397
+ return n + 1
398
+ if mode == "scarce":
399
+ return n
400
+ return max(1, n - 1)
401
 
402
 
403
+ def main() -> None:
404
+ args = parse_args()
405
+ random.seed(args.seed)
406
+ np.random.seed(args.seed)
407
+ if _TORCH_AVAILABLE:
408
+ torch.manual_seed(args.seed)
409
 
410
  out_dir = Path(args.out)
411
  out_dir.mkdir(parents=True, exist_ok=True)
 
412
 
413
+ max_steps = resolve_max_steps(args.n_zones, args.budget_mode, args.max_steps)
414
  logger.info(
415
+ "n_zones=%d max_steps=%d budget_mode=%s", args.n_zones, max_steps, args.budget_mode
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
416
  )
417
 
418
+ config = ForecastConfig(
419
+ n_zones=args.n_zones,
420
+ max_steps=max_steps,
421
+ soft_reset=True,
422
+ seed=args.seed,
423
  )
424
 
425
+ env = Monitor(make_weather_env(config))
426
+ model = build_model(env, args)
427
 
428
+ callbacks = [RegressionWatch()]
429
+ if args.dynamics_model and _DYNAMICS_AVAILABLE and TemporalDynamicsModel is not None:
430
+ try:
431
+ import torch as _torch
432
+ dyna_model = TemporalDynamicsModel.load(
433
+ args.dynamics_model, device=_torch.device(args.device)
434
+ )
435
+ dyna_model.eval()
436
+ dyna_buffer = DynaRolloutBuffer(
437
+ dynamics=dyna_model, uncertainty_weight=args.dynamics_weight
438
+ )
439
+ callbacks.append(
440
+ DynaCallback(
441
+ dyna_buffer=dyna_buffer,
442
+ surprise_weight=args.dynamics_weight,
443
+ update_every=args.dynamics_finetune_every,
444
+ device=args.device,
445
+ )
446
+ )
447
+ logger.info("Dyna augmentation active")
448
+ except Exception as e:
449
+ logger.warning("Dyna init failed: %s", e)
450
+
451
+ model.learn(
452
+ total_timesteps=args.steps,
453
+ callback=callbacks,
454
+ reset_num_timesteps=True,
455
+ use_masking=True,
456
+ )
457
 
458
  final_path = out_dir / "final_model.zip"
459
  model.save(str(final_path))
460
+ logger.info("Saved: %s", final_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
461
 
462
 
463
  if __name__ == "__main__":