Reinforcement Learning
stable-baselines3
deep-reinforcement-learning
agricultural-ai
weather-modelling
curriculum-learning
edge-ai
Instructions to use DHDRL/monsoon-rl with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- stable-baselines3
How to use DHDRL/monsoon-rl with stable-baselines3:
from huggingface_sb3 import load_from_hub checkpoint = load_from_hub( repo_id="DHDRL/monsoon-rl", filename="{MODEL FILENAME}.zip", ) - Notebooks
- Google Colab
- Kaggle
Update physics_dynamics.py
Browse files- physics_dynamics.py +1 -245
physics_dynamics.py
CHANGED
|
@@ -14,28 +14,6 @@ The model predicts how the zone-level forecast state evolves over time,
|
|
| 14 |
constrained by an advection-diffusion PDE residual that prevents physically
|
| 15 |
impossible predictions (e.g. precipitation materialising from nothing,
|
| 16 |
uncertainty decreasing without new observations).
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
Classes
|
| 20 |
-
-------
|
| 21 |
-
ZoneStateTensor — named container for the three observation arrays
|
| 22 |
-
TemporalDynamicsModel — core learned model (GRU encoder + MLP transition)
|
| 23 |
-
PhysicsResidualLoss — advection-diffusion PDE residual along time axis
|
| 24 |
-
DynamicsTrainer — offline pre-training on ERA5 sequences
|
| 25 |
-
EnsembleDynamics — N models for epistemic uncertainty quantification
|
| 26 |
-
DynaRolloutBuffer — generates synthetic transitions for PPO augmentation
|
| 27 |
-
|
| 28 |
-
Usage
|
| 29 |
-
-----
|
| 30 |
-
# 1. Pre-train on ERA5 sequences
|
| 31 |
-
trainer = DynamicsTrainer(n_zones=4, horizon_days=14)
|
| 32 |
-
trainer.train(era5_sequences) # list of ZoneStateTensor
|
| 33 |
-
trainer.save("dynamics_model.pt")
|
| 34 |
-
|
| 35 |
-
# 2. Load in training loop and generate synthetic rollouts
|
| 36 |
-
dynamics = TemporalDynamicsModel.load("dynamics_model.pt")
|
| 37 |
-
buffer = DynaRolloutBuffer(dynamics, n_synthetic_per_real=4)
|
| 38 |
-
# Pass buffer to custom PPO callback (see train_curriculum.py notes)
|
| 39 |
"""
|
| 40 |
|
| 41 |
from __future__ import annotations
|
|
@@ -60,19 +38,6 @@ logger = logging.getLogger(__name__)
|
|
| 60 |
|
| 61 |
@dataclass
|
| 62 |
class ZoneStateTensor:
|
| 63 |
-
"""
|
| 64 |
-
A single time-step of zone-level state, as tensors.
|
| 65 |
-
|
| 66 |
-
Mirrors the WeatherForecastEnv observation space:
|
| 67 |
-
precip: [batch, n_zones, horizon_days]
|
| 68 |
-
uncertainty: [batch, n_zones]
|
| 69 |
-
belief: [batch, n_zones]
|
| 70 |
-
|
| 71 |
-
All values float32 in their natural ranges:
|
| 72 |
-
precip [0, 500] mm
|
| 73 |
-
uncertainty [0, 1]
|
| 74 |
-
belief [0, 1]
|
| 75 |
-
"""
|
| 76 |
precip: torch.Tensor # [batch, n_zones, horizon_days]
|
| 77 |
uncertainty: torch.Tensor # [batch, n_zones]
|
| 78 |
belief: torch.Tensor # [batch, n_zones]
|
|
@@ -97,7 +62,6 @@ class ZoneStateTensor:
|
|
| 97 |
)
|
| 98 |
|
| 99 |
def flat(self) -> torch.Tensor:
|
| 100 |
-
"""Flatten to [batch, n_zones * (horizon_days + 2)] for MLP input."""
|
| 101 |
B, Z, H = self.precip.shape
|
| 102 |
precip_flat = self.precip.reshape(B, Z * H)
|
| 103 |
return torch.cat([precip_flat, self.uncertainty, self.belief], dim=-1)
|
|
@@ -113,7 +77,6 @@ class ZoneStateTensor:
|
|
| 113 |
uncertainty: np.ndarray,
|
| 114 |
belief: np.ndarray,
|
| 115 |
) -> "ZoneStateTensor":
|
| 116 |
-
"""Construct from numpy arrays (adds batch dim if missing)."""
|
| 117 |
if precip.ndim == 2:
|
| 118 |
precip = precip[None]
|
| 119 |
if uncertainty.ndim == 1:
|
|
@@ -132,36 +95,10 @@ class ZoneStateTensor:
|
|
| 132 |
# ---------------------------------------------------------------------------
|
| 133 |
|
| 134 |
class PhysicsResidualLoss(nn.Module):
|
| 135 |
-
"""
|
| 136 |
-
Advection-diffusion PDE residual along the TEMPORAL dimension.
|
| 137 |
-
|
| 138 |
-
The physical intuition: as time advances by dt days, the precipitation
|
| 139 |
-
forecast at day t in the horizon should approximately equal the forecast
|
| 140 |
-
at day (t - dt) from the previous time step, shifted by advection and
|
| 141 |
-
smoothed by diffusion. This is the atmospheric forecast evolution equation.
|
| 142 |
-
|
| 143 |
-
Residual: ∂u/∂t + v ∂u/∂τ - D ∂²u/∂τ² = 0
|
| 144 |
-
|
| 145 |
-
where:
|
| 146 |
-
u = precipitation forecast value
|
| 147 |
-
t = real time (day-to-day model evolution)
|
| 148 |
-
τ = forecast lead time (the horizon axis, days 0..H-1)
|
| 149 |
-
v = advection speed (learnable)
|
| 150 |
-
D = diffusion coefficient (learnable)
|
| 151 |
-
|
| 152 |
-
This is applied per zone independently (zones are not a spatial grid).
|
| 153 |
-
|
| 154 |
-
Args:
|
| 155 |
-
weight: Scalar multiplier for the physics loss term.
|
| 156 |
-
Start with 0.01-0.05; increase if predictions are unphysical.
|
| 157 |
-
"""
|
| 158 |
|
| 159 |
def __init__(self, weight: float = 0.01):
|
| 160 |
super().__init__()
|
| 161 |
self.weight = weight
|
| 162 |
-
# Learnable physics parameters — initialised to physically plausible values
|
| 163 |
-
# v: forecast advection ~1 day/day (forecast evolves with real time)
|
| 164 |
-
# D: diffusion smoothing ~0.1 (moderate smoothing of forecast errors)
|
| 165 |
self.log_v = nn.Parameter(torch.tensor(0.0)) # exp(0) = 1.0
|
| 166 |
self.log_D = nn.Parameter(torch.tensor(-2.3)) # exp(-2.3) ≈ 0.1
|
| 167 |
|
|
@@ -179,13 +116,6 @@ class PhysicsResidualLoss(nn.Module):
|
|
| 179 |
u_next: torch.Tensor, # [batch, n_zones, horizon_days]
|
| 180 |
dt: float = 1.0,
|
| 181 |
) -> torch.Tensor:
|
| 182 |
-
"""
|
| 183 |
-
Compute mean squared PDE residual.
|
| 184 |
-
|
| 185 |
-
u_current: forecast at time t
|
| 186 |
-
u_next: predicted forecast at time t + dt
|
| 187 |
-
dt: real-time step in days
|
| 188 |
-
"""
|
| 189 |
B, Z, H = u_current.shape
|
| 190 |
|
| 191 |
# ∂u/∂t ≈ (u_next - u_current) / dt
|
|
@@ -219,27 +149,6 @@ class PhysicsResidualLoss(nn.Module):
|
|
| 219 |
# ---------------------------------------------------------------------------
|
| 220 |
|
| 221 |
class TemporalDynamicsModel(nn.Module):
|
| 222 |
-
"""
|
| 223 |
-
Learned dynamics model: predicts next zone state from current state.
|
| 224 |
-
|
| 225 |
-
Architecture:
|
| 226 |
-
1. Per-zone GRU encoder compresses the forecast horizon sequence
|
| 227 |
-
into a latent zone embedding.
|
| 228 |
-
2. MLP transition model maps current latent → next latent.
|
| 229 |
-
3. MLP decoder reconstructs full next-state from latent.
|
| 230 |
-
|
| 231 |
-
Why GRU encoder (not FNO): Your forecast data is [n_zones, horizon_days]
|
| 232 |
-
where n_zones is small (2-4) and horizon_days is short (7-14). FNO is
|
| 233 |
-
designed for large spatial fields (64x64+). A GRU over the horizon axis
|
| 234 |
-
per zone is exact for this scale and directly compatible with your
|
| 235 |
-
existing GRUWeatherFeaturesExtractor architecture.
|
| 236 |
-
|
| 237 |
-
Args:
|
| 238 |
-
n_zones: Number of geographic zones (matches env config)
|
| 239 |
-
horizon_days: Forecast horizon length (matches env config)
|
| 240 |
-
latent_dim: Dimension of per-zone latent embedding
|
| 241 |
-
hidden_dim: MLP hidden size for transition and decoder
|
| 242 |
-
"""
|
| 243 |
|
| 244 |
def __init__(
|
| 245 |
self,
|
|
@@ -253,8 +162,6 @@ class TemporalDynamicsModel(nn.Module):
|
|
| 253 |
self.horizon_days = horizon_days
|
| 254 |
self.latent_dim = latent_dim
|
| 255 |
|
| 256 |
-
# --- Encoder: horizon sequence → zone latent ---
|
| 257 |
-
# Applied identically to each zone (weight sharing)
|
| 258 |
self.precip_encoder = nn.GRU(
|
| 259 |
input_size=1,
|
| 260 |
hidden_size=latent_dim,
|
|
@@ -262,7 +169,6 @@ class TemporalDynamicsModel(nn.Module):
|
|
| 262 |
batch_first=True,
|
| 263 |
)
|
| 264 |
|
| 265 |
-
# Zone metadata (uncertainty + belief) → extra latent dims
|
| 266 |
self.meta_encoder = nn.Sequential(
|
| 267 |
nn.Linear(2, latent_dim),
|
| 268 |
nn.Tanh(),
|
|
@@ -270,7 +176,6 @@ class TemporalDynamicsModel(nn.Module):
|
|
| 270 |
|
| 271 |
zone_latent_dim = latent_dim * 2 # precip latent + meta latent
|
| 272 |
|
| 273 |
-
# --- Transition: current latent → next latent (all zones jointly) ---
|
| 274 |
full_latent_dim = n_zones * zone_latent_dim
|
| 275 |
self.transition = nn.Sequential(
|
| 276 |
nn.Linear(full_latent_dim, hidden_dim),
|
|
@@ -280,7 +185,6 @@ class TemporalDynamicsModel(nn.Module):
|
|
| 280 |
nn.Linear(hidden_dim, full_latent_dim),
|
| 281 |
)
|
| 282 |
|
| 283 |
-
# --- Decoder: latent → next state components ---
|
| 284 |
self.precip_decoder = nn.Sequential(
|
| 285 |
nn.Linear(zone_latent_dim, hidden_dim),
|
| 286 |
nn.SiLU(),
|
|
@@ -300,7 +204,6 @@ class TemporalDynamicsModel(nn.Module):
|
|
| 300 |
nn.Sigmoid(), # belief in [0, 1]
|
| 301 |
)
|
| 302 |
|
| 303 |
-
# Physics loss module (parameters learned jointly with model)
|
| 304 |
self.physics_loss = PhysicsResidualLoss(weight=0.01)
|
| 305 |
|
| 306 |
logger.info(
|
|
@@ -309,16 +212,12 @@ class TemporalDynamicsModel(nn.Module):
|
|
| 309 |
)
|
| 310 |
|
| 311 |
def _encode(self, state: ZoneStateTensor) -> torch.Tensor:
|
| 312 |
-
"""Encode zone state → latent. Returns [batch, n_zones, zone_latent_dim]."""
|
| 313 |
B, Z, H = state.precip.shape
|
| 314 |
|
| 315 |
-
# Encode each zone's precipitation forecast sequence with the GRU
|
| 316 |
-
# Reshape to [batch * n_zones, horizon_days, 1] for GRU
|
| 317 |
precip_seq = state.precip.reshape(B * Z, H, 1)
|
| 318 |
_, h_n = self.precip_encoder(precip_seq) # h_n: [1, B*Z, latent_dim]
|
| 319 |
precip_latent = h_n.squeeze(0).reshape(B, Z, self.latent_dim)
|
| 320 |
|
| 321 |
-
# Encode per-zone metadata [uncertainty, belief]
|
| 322 |
meta = torch.stack([state.uncertainty, state.belief], dim=-1) # [B, Z, 2]
|
| 323 |
meta_flat = meta.reshape(B * Z, 2)
|
| 324 |
meta_latent = self.meta_encoder(meta_flat).reshape(B, Z, self.latent_dim)
|
|
@@ -331,30 +230,14 @@ class TemporalDynamicsModel(nn.Module):
|
|
| 331 |
return_physics_loss: bool = True,
|
| 332 |
dt: float = 1.0,
|
| 333 |
) -> Tuple[ZoneStateTensor, Optional[torch.Tensor]]:
|
| 334 |
-
"""
|
| 335 |
-
Predict next state from current state.
|
| 336 |
-
|
| 337 |
-
Args:
|
| 338 |
-
current: Current zone state
|
| 339 |
-
return_physics_loss: Whether to compute and return the physics residual
|
| 340 |
-
dt: Real-time gap in days between current and next snapshot.
|
| 341 |
-
Must match pairing cadence (e.g. 5.0 for cache step_days=5).
|
| 342 |
-
|
| 343 |
-
Returns:
|
| 344 |
-
next_state: Predicted next zone state
|
| 345 |
-
physics_loss: PDE residual loss (None if return_physics_loss=False)
|
| 346 |
-
"""
|
| 347 |
B, Z, H = current.precip.shape
|
| 348 |
|
| 349 |
-
# Encode current state
|
| 350 |
latent = self._encode(current) # [B, Z, zone_latent_dim]
|
| 351 |
latent_flat = latent.reshape(B, -1) # [B, Z * zone_latent_dim]
|
| 352 |
|
| 353 |
-
# Transition in latent space (all zones jointly — captures inter-zone correlations)
|
| 354 |
next_latent_flat = self.transition(latent_flat)
|
| 355 |
next_latent = next_latent_flat.reshape(B, Z, -1) # [B, Z, zone_latent_dim]
|
| 356 |
|
| 357 |
-
# Decode next state per zone
|
| 358 |
next_latent_per_zone = next_latent.reshape(B * Z, -1)
|
| 359 |
|
| 360 |
next_precip = self.precip_decoder(next_latent_per_zone).reshape(B, Z, H)
|
|
@@ -367,7 +250,6 @@ class TemporalDynamicsModel(nn.Module):
|
|
| 367 |
belief=next_belief,
|
| 368 |
)
|
| 369 |
|
| 370 |
-
# Physics residual loss on the precipitation forecast evolution
|
| 371 |
phys_loss = None
|
| 372 |
if return_physics_loss:
|
| 373 |
phys_loss = self.physics_loss(
|
|
@@ -381,20 +263,11 @@ class TemporalDynamicsModel(nn.Module):
|
|
| 381 |
initial: ZoneStateTensor,
|
| 382 |
steps: int = 5,
|
| 383 |
) -> List[ZoneStateTensor]:
|
| 384 |
-
"""
|
| 385 |
-
Generate a multi-step synthetic rollout.
|
| 386 |
-
|
| 387 |
-
Used by DynaRolloutBuffer to produce model-imagined transitions
|
| 388 |
-
for PPO augmentation. Gradients are not tracked here (inference only).
|
| 389 |
-
|
| 390 |
-
Returns list of states [s_0, s_1, ..., s_steps] where s_0 = initial.
|
| 391 |
-
"""
|
| 392 |
states = [initial]
|
| 393 |
current = initial
|
| 394 |
with torch.no_grad():
|
| 395 |
for _ in range(steps):
|
| 396 |
next_state, _ = self.forward(current, return_physics_loss=False)
|
| 397 |
-
# Clamp to valid ranges
|
| 398 |
next_state = ZoneStateTensor(
|
| 399 |
precip=torch.clamp(next_state.precip, 0.0, 500.0),
|
| 400 |
uncertainty=torch.clamp(next_state.uncertainty, 0.0, 1.0),
|
|
@@ -438,23 +311,6 @@ class TemporalDynamicsModel(nn.Module):
|
|
| 438 |
# ---------------------------------------------------------------------------
|
| 439 |
|
| 440 |
class DynamicsTrainer:
|
| 441 |
-
"""
|
| 442 |
-
Pre-trains TemporalDynamicsModel on sequences of zone states.
|
| 443 |
-
|
| 444 |
-
Training data format: list of consecutive (current, next) ZoneStateTensor
|
| 445 |
-
pairs extracted from ERA5 reanalysis or from environment rollouts.
|
| 446 |
-
|
| 447 |
-
Loss: data_loss + physics_loss
|
| 448 |
-
data_loss = MSE(predicted_next, actual_next) for all three components
|
| 449 |
-
physics_loss = advection-diffusion PDE residual (see PhysicsResidualLoss)
|
| 450 |
-
|
| 451 |
-
Args:
|
| 452 |
-
n_zones: Must match your env config
|
| 453 |
-
horizon_days: Must match your env config
|
| 454 |
-
physics_weight: Weight for physics residual in total loss.
|
| 455 |
-
Start at 0.01, increase to 0.1 if predictions violate physics.
|
| 456 |
-
device: 'cuda' if available, else 'cpu'
|
| 457 |
-
"""
|
| 458 |
|
| 459 |
def __init__(
|
| 460 |
self,
|
|
@@ -487,24 +343,6 @@ class DynamicsTrainer:
|
|
| 487 |
dts: Optional[List[float]] = None,
|
| 488 |
default_dt: float = 1.0,
|
| 489 |
) -> dict:
|
| 490 |
-
"""
|
| 491 |
-
Train the dynamics model on (current_state, next_state) pairs.
|
| 492 |
-
|
| 493 |
-
Args:
|
| 494 |
-
sequence_pairs: List of (current, next) ZoneStateTensor pairs.
|
| 495 |
-
epochs: Training epochs
|
| 496 |
-
batch_size: Batch size
|
| 497 |
-
lr: Learning rate
|
| 498 |
-
val_split: Fraction of data held out for validation
|
| 499 |
-
dts: Optional per-pair real-time gaps in days (same
|
| 500 |
-
length as sequence_pairs). When None, uses
|
| 501 |
-
default_dt for every pair.
|
| 502 |
-
default_dt: Fallback dt (days). Use 5.0 for historical
|
| 503 |
-
cache pairs built at step_days=5.
|
| 504 |
-
|
| 505 |
-
Returns:
|
| 506 |
-
Training history dict with 'train_loss' and 'val_loss' lists.
|
| 507 |
-
"""
|
| 508 |
if not sequence_pairs:
|
| 509 |
raise ValueError("sequence_pairs is empty — provide ERA5 data")
|
| 510 |
|
|
@@ -514,7 +352,6 @@ class DynamicsTrainer:
|
|
| 514 |
f"{len(sequence_pairs)}"
|
| 515 |
)
|
| 516 |
|
| 517 |
-
# Build tensor dataset from pairs
|
| 518 |
current_precips, current_uncerts, current_beliefs = [], [], []
|
| 519 |
next_precips, next_uncerts, next_beliefs = [], [], []
|
| 520 |
dt_list: List[float] = []
|
|
@@ -528,7 +365,6 @@ class DynamicsTrainer:
|
|
| 528 |
next_beliefs.append(nxt.belief)
|
| 529 |
dt_list.append(float(dts[i]) if dts is not None else float(default_dt))
|
| 530 |
|
| 531 |
-
# Stack along batch dimension
|
| 532 |
cp = torch.cat(current_precips, dim=0)
|
| 533 |
cu = torch.cat(current_uncerts, dim=0)
|
| 534 |
cb = torch.cat(current_beliefs, dim=0)
|
|
@@ -538,8 +374,6 @@ class DynamicsTrainer:
|
|
| 538 |
dt_t = torch.tensor(dt_list, dtype=torch.float32)
|
| 539 |
|
| 540 |
N = cp.shape[0]
|
| 541 |
-
# Guard: we need at least 1 sample in the training split.
|
| 542 |
-
# When val_split=0 or N is too small, skip validation entirely.
|
| 543 |
n_val = int(N * val_split) if val_split > 0 else 0
|
| 544 |
if n_val >= N:
|
| 545 |
n_val = max(0, N - 1) # leave at least 1 sample for training
|
|
@@ -571,7 +405,6 @@ class DynamicsTrainer:
|
|
| 571 |
history = {"train_loss": [], "val_loss": [], "physics_loss": [], "dt_mean": float(dt_t.mean())}
|
| 572 |
|
| 573 |
for epoch in range(epochs):
|
| 574 |
-
# --- Train ---
|
| 575 |
self.model.train()
|
| 576 |
epoch_data_loss = 0.0
|
| 577 |
epoch_phys_loss = 0.0
|
|
@@ -584,15 +417,11 @@ class DynamicsTrainer:
|
|
| 584 |
current = ZoneStateTensor(precip=cp_b, uncertainty=cu_b, belief=cb_b)
|
| 585 |
target = ZoneStateTensor(precip=np_b, uncertainty=nu_b, belief=nb_b)
|
| 586 |
|
| 587 |
-
# Batch may mix dts; use batch mean (pairs are homogeneous
|
| 588 |
-
# when built with exact step_days only).
|
| 589 |
batch_dt = float(dt_b.mean().item())
|
| 590 |
pred, phys_loss = self.model(
|
| 591 |
current, return_physics_loss=True, dt=batch_dt,
|
| 592 |
)
|
| 593 |
|
| 594 |
-
# Data fidelity: MSE on all three components
|
| 595 |
-
# Normalise precip by max scale (500mm) to balance loss magnitudes
|
| 596 |
data_loss = (
|
| 597 |
F.mse_loss(pred.precip / 500.0, target.precip / 500.0)
|
| 598 |
+ F.mse_loss(pred.uncertainty, target.uncertainty)
|
|
@@ -614,7 +443,6 @@ class DynamicsTrainer:
|
|
| 614 |
avg_data = epoch_data_loss / len(train_loader)
|
| 615 |
avg_phys = epoch_phys_loss / len(train_loader)
|
| 616 |
|
| 617 |
-
# --- Validate ---
|
| 618 |
self.model.eval()
|
| 619 |
val_loss = 0.0
|
| 620 |
with torch.no_grad():
|
|
@@ -658,19 +486,6 @@ class DynamicsTrainer:
|
|
| 658 |
# ---------------------------------------------------------------------------
|
| 659 |
|
| 660 |
class EnsembleDynamics:
|
| 661 |
-
"""
|
| 662 |
-
Ensemble of N dynamics models for epistemic uncertainty quantification.
|
| 663 |
-
|
| 664 |
-
Each model is trained with different random seed initialization.
|
| 665 |
-
Disagreement between models = epistemic uncertainty = regions where
|
| 666 |
-
the policy should be conservative (important for plasma/fusion applications
|
| 667 |
-
where high uncertainty = potentially dangerous operating regime).
|
| 668 |
-
|
| 669 |
-
Usage in PPO training:
|
| 670 |
-
ensemble = EnsembleDynamics(n_models=5, ...)
|
| 671 |
-
mean_next, uncertainty = ensemble.predict(current_state)
|
| 672 |
-
# Add uncertainty penalty to reward: reward -= uncertainty_weight * uncertainty
|
| 673 |
-
"""
|
| 674 |
|
| 675 |
def __init__(self, n_models: int = 5, **model_kwargs):
|
| 676 |
self.models = [TemporalDynamicsModel(**model_kwargs) for _ in range(n_models)]
|
|
@@ -680,13 +495,6 @@ class EnsembleDynamics:
|
|
| 680 |
self,
|
| 681 |
current: ZoneStateTensor,
|
| 682 |
) -> Tuple[ZoneStateTensor, torch.Tensor]:
|
| 683 |
-
"""
|
| 684 |
-
Return mean prediction and epistemic uncertainty.
|
| 685 |
-
|
| 686 |
-
uncertainty is a scalar tensor: mean std across all zones and features.
|
| 687 |
-
Use this to penalise the RL policy for actions that lead to high
|
| 688 |
-
uncertainty states (encourages conservative, well-characterised behaviour).
|
| 689 |
-
"""
|
| 690 |
all_precips, all_uncerts, all_beliefs = [], [], []
|
| 691 |
|
| 692 |
for model in self.models:
|
|
@@ -707,10 +515,7 @@ class EnsembleDynamics:
|
|
| 707 |
belief=belief_stack.mean(0),
|
| 708 |
)
|
| 709 |
|
| 710 |
-
# Epistemic uncertainty: normalised std across ensemble members
|
| 711 |
epistemic = (
|
| 712 |
-
# correction=0 avoids NaN when n_models=1 (Bessel correction
|
| 713 |
-
# would divide by zero with a single sample).
|
| 714 |
(precip_stack.std(0, correction=0) / 500.0).mean()
|
| 715 |
+ uncert_stack.std(0, correction=0).mean()
|
| 716 |
+ belief_stack.std(0, correction=0).mean()
|
|
@@ -729,40 +534,6 @@ class EnsembleDynamics:
|
|
| 729 |
# ---------------------------------------------------------------------------
|
| 730 |
|
| 731 |
class DynaRolloutBuffer:
|
| 732 |
-
"""
|
| 733 |
-
Generates synthetic (s, a, r, s') transitions for PPO augmentation.
|
| 734 |
-
|
| 735 |
-
Dyna-style model-based RL: use the learned dynamics model to generate
|
| 736 |
-
additional training transitions from states already in the replay buffer.
|
| 737 |
-
This improves sample efficiency without changing the PPO algorithm.
|
| 738 |
-
|
| 739 |
-
Integration with train_curriculum.py:
|
| 740 |
-
-------------------------------------
|
| 741 |
-
Add a DynaCallback to the PPO training loop:
|
| 742 |
-
|
| 743 |
-
class DynaCallback(BaseCallback):
|
| 744 |
-
def __init__(self, dynamics: TemporalDynamicsModel, n_synthetic: int = 4):
|
| 745 |
-
super().__init__()
|
| 746 |
-
self.dynamics = dynamics
|
| 747 |
-
self.n_synthetic = n_synthetic
|
| 748 |
-
|
| 749 |
-
def _on_rollout_end(self) -> None:
|
| 750 |
-
# After each real rollout, generate synthetic transitions
|
| 751 |
-
# and inject them into the rollout buffer before the update.
|
| 752 |
-
# (Implementation depends on SB3 internals — see notes below.)
|
| 753 |
-
pass
|
| 754 |
-
|
| 755 |
-
Note: Direct rollout buffer injection is not officially supported in SB3.
|
| 756 |
-
The practical approach is to use the dynamics model for reward shaping:
|
| 757 |
-
predict next state, measure surprise (|| actual - predicted ||), and add
|
| 758 |
-
a small exploration bonus for high-surprise transitions. This requires
|
| 759 |
-
no SB3 modifications and still improves sample efficiency.
|
| 760 |
-
|
| 761 |
-
Args:
|
| 762 |
-
dynamics: Trained TemporalDynamicsModel
|
| 763 |
-
n_synthetic_steps: Steps to roll out from each seed state
|
| 764 |
-
uncertainty_weight: Weight for epistemic uncertainty penalty in reward
|
| 765 |
-
"""
|
| 766 |
|
| 767 |
def __init__(
|
| 768 |
self,
|
|
@@ -779,20 +550,10 @@ class DynaRolloutBuffer:
|
|
| 779 |
obs_current: ZoneStateTensor,
|
| 780 |
obs_actual_next: ZoneStateTensor,
|
| 781 |
) -> torch.Tensor:
|
| 782 |
-
"""
|
| 783 |
-
Reward bonus for transitions that surprise the dynamics model.
|
| 784 |
-
|
| 785 |
-
High surprise = model uncertainty = exploration bonus.
|
| 786 |
-
This is the simplest Dyna integration: no SB3 modifications needed,
|
| 787 |
-
just add this to the reward in a step callback.
|
| 788 |
-
|
| 789 |
-
Returns scalar bonus in [0, ~1].
|
| 790 |
-
"""
|
| 791 |
self.dynamics.eval()
|
| 792 |
with torch.no_grad():
|
| 793 |
pred_next, _ = self.dynamics(obs_current, return_physics_loss=False)
|
| 794 |
|
| 795 |
-
# Normalised prediction error
|
| 796 |
precip_err = F.mse_loss(
|
| 797 |
pred_next.precip / 500.0,
|
| 798 |
obs_actual_next.precip / 500.0,
|
|
@@ -807,9 +568,4 @@ class DynaRolloutBuffer:
|
|
| 807 |
self,
|
| 808 |
seed_state: ZoneStateTensor,
|
| 809 |
) -> List[ZoneStateTensor]:
|
| 810 |
-
|
| 811 |
-
Generate synthetic state sequence from a seed state.
|
| 812 |
-
|
| 813 |
-
Returns list of n_synthetic_steps + 1 states starting from seed_state.
|
| 814 |
-
"""
|
| 815 |
-
return self.dynamics.rollout(seed_state, steps=self.n_synthetic_steps)
|
|
|
|
| 14 |
constrained by an advection-diffusion PDE residual that prevents physically
|
| 15 |
impossible predictions (e.g. precipitation materialising from nothing,
|
| 16 |
uncertainty decreasing without new observations).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
"""
|
| 18 |
|
| 19 |
from __future__ import annotations
|
|
|
|
| 38 |
|
| 39 |
@dataclass
|
| 40 |
class ZoneStateTensor:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
precip: torch.Tensor # [batch, n_zones, horizon_days]
|
| 42 |
uncertainty: torch.Tensor # [batch, n_zones]
|
| 43 |
belief: torch.Tensor # [batch, n_zones]
|
|
|
|
| 62 |
)
|
| 63 |
|
| 64 |
def flat(self) -> torch.Tensor:
|
|
|
|
| 65 |
B, Z, H = self.precip.shape
|
| 66 |
precip_flat = self.precip.reshape(B, Z * H)
|
| 67 |
return torch.cat([precip_flat, self.uncertainty, self.belief], dim=-1)
|
|
|
|
| 77 |
uncertainty: np.ndarray,
|
| 78 |
belief: np.ndarray,
|
| 79 |
) -> "ZoneStateTensor":
|
|
|
|
| 80 |
if precip.ndim == 2:
|
| 81 |
precip = precip[None]
|
| 82 |
if uncertainty.ndim == 1:
|
|
|
|
| 95 |
# ---------------------------------------------------------------------------
|
| 96 |
|
| 97 |
class PhysicsResidualLoss(nn.Module):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
def __init__(self, weight: float = 0.01):
|
| 100 |
super().__init__()
|
| 101 |
self.weight = weight
|
|
|
|
|
|
|
|
|
|
| 102 |
self.log_v = nn.Parameter(torch.tensor(0.0)) # exp(0) = 1.0
|
| 103 |
self.log_D = nn.Parameter(torch.tensor(-2.3)) # exp(-2.3) ≈ 0.1
|
| 104 |
|
|
|
|
| 116 |
u_next: torch.Tensor, # [batch, n_zones, horizon_days]
|
| 117 |
dt: float = 1.0,
|
| 118 |
) -> torch.Tensor:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
B, Z, H = u_current.shape
|
| 120 |
|
| 121 |
# ∂u/∂t ≈ (u_next - u_current) / dt
|
|
|
|
| 149 |
# ---------------------------------------------------------------------------
|
| 150 |
|
| 151 |
class TemporalDynamicsModel(nn.Module):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
|
| 153 |
def __init__(
|
| 154 |
self,
|
|
|
|
| 162 |
self.horizon_days = horizon_days
|
| 163 |
self.latent_dim = latent_dim
|
| 164 |
|
|
|
|
|
|
|
| 165 |
self.precip_encoder = nn.GRU(
|
| 166 |
input_size=1,
|
| 167 |
hidden_size=latent_dim,
|
|
|
|
| 169 |
batch_first=True,
|
| 170 |
)
|
| 171 |
|
|
|
|
| 172 |
self.meta_encoder = nn.Sequential(
|
| 173 |
nn.Linear(2, latent_dim),
|
| 174 |
nn.Tanh(),
|
|
|
|
| 176 |
|
| 177 |
zone_latent_dim = latent_dim * 2 # precip latent + meta latent
|
| 178 |
|
|
|
|
| 179 |
full_latent_dim = n_zones * zone_latent_dim
|
| 180 |
self.transition = nn.Sequential(
|
| 181 |
nn.Linear(full_latent_dim, hidden_dim),
|
|
|
|
| 185 |
nn.Linear(hidden_dim, full_latent_dim),
|
| 186 |
)
|
| 187 |
|
|
|
|
| 188 |
self.precip_decoder = nn.Sequential(
|
| 189 |
nn.Linear(zone_latent_dim, hidden_dim),
|
| 190 |
nn.SiLU(),
|
|
|
|
| 204 |
nn.Sigmoid(), # belief in [0, 1]
|
| 205 |
)
|
| 206 |
|
|
|
|
| 207 |
self.physics_loss = PhysicsResidualLoss(weight=0.01)
|
| 208 |
|
| 209 |
logger.info(
|
|
|
|
| 212 |
)
|
| 213 |
|
| 214 |
def _encode(self, state: ZoneStateTensor) -> torch.Tensor:
|
|
|
|
| 215 |
B, Z, H = state.precip.shape
|
| 216 |
|
|
|
|
|
|
|
| 217 |
precip_seq = state.precip.reshape(B * Z, H, 1)
|
| 218 |
_, h_n = self.precip_encoder(precip_seq) # h_n: [1, B*Z, latent_dim]
|
| 219 |
precip_latent = h_n.squeeze(0).reshape(B, Z, self.latent_dim)
|
| 220 |
|
|
|
|
| 221 |
meta = torch.stack([state.uncertainty, state.belief], dim=-1) # [B, Z, 2]
|
| 222 |
meta_flat = meta.reshape(B * Z, 2)
|
| 223 |
meta_latent = self.meta_encoder(meta_flat).reshape(B, Z, self.latent_dim)
|
|
|
|
| 230 |
return_physics_loss: bool = True,
|
| 231 |
dt: float = 1.0,
|
| 232 |
) -> Tuple[ZoneStateTensor, Optional[torch.Tensor]]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
B, Z, H = current.precip.shape
|
| 234 |
|
|
|
|
| 235 |
latent = self._encode(current) # [B, Z, zone_latent_dim]
|
| 236 |
latent_flat = latent.reshape(B, -1) # [B, Z * zone_latent_dim]
|
| 237 |
|
|
|
|
| 238 |
next_latent_flat = self.transition(latent_flat)
|
| 239 |
next_latent = next_latent_flat.reshape(B, Z, -1) # [B, Z, zone_latent_dim]
|
| 240 |
|
|
|
|
| 241 |
next_latent_per_zone = next_latent.reshape(B * Z, -1)
|
| 242 |
|
| 243 |
next_precip = self.precip_decoder(next_latent_per_zone).reshape(B, Z, H)
|
|
|
|
| 250 |
belief=next_belief,
|
| 251 |
)
|
| 252 |
|
|
|
|
| 253 |
phys_loss = None
|
| 254 |
if return_physics_loss:
|
| 255 |
phys_loss = self.physics_loss(
|
|
|
|
| 263 |
initial: ZoneStateTensor,
|
| 264 |
steps: int = 5,
|
| 265 |
) -> List[ZoneStateTensor]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
states = [initial]
|
| 267 |
current = initial
|
| 268 |
with torch.no_grad():
|
| 269 |
for _ in range(steps):
|
| 270 |
next_state, _ = self.forward(current, return_physics_loss=False)
|
|
|
|
| 271 |
next_state = ZoneStateTensor(
|
| 272 |
precip=torch.clamp(next_state.precip, 0.0, 500.0),
|
| 273 |
uncertainty=torch.clamp(next_state.uncertainty, 0.0, 1.0),
|
|
|
|
| 311 |
# ---------------------------------------------------------------------------
|
| 312 |
|
| 313 |
class DynamicsTrainer:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
|
| 315 |
def __init__(
|
| 316 |
self,
|
|
|
|
| 343 |
dts: Optional[List[float]] = None,
|
| 344 |
default_dt: float = 1.0,
|
| 345 |
) -> dict:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 346 |
if not sequence_pairs:
|
| 347 |
raise ValueError("sequence_pairs is empty — provide ERA5 data")
|
| 348 |
|
|
|
|
| 352 |
f"{len(sequence_pairs)}"
|
| 353 |
)
|
| 354 |
|
|
|
|
| 355 |
current_precips, current_uncerts, current_beliefs = [], [], []
|
| 356 |
next_precips, next_uncerts, next_beliefs = [], [], []
|
| 357 |
dt_list: List[float] = []
|
|
|
|
| 365 |
next_beliefs.append(nxt.belief)
|
| 366 |
dt_list.append(float(dts[i]) if dts is not None else float(default_dt))
|
| 367 |
|
|
|
|
| 368 |
cp = torch.cat(current_precips, dim=0)
|
| 369 |
cu = torch.cat(current_uncerts, dim=0)
|
| 370 |
cb = torch.cat(current_beliefs, dim=0)
|
|
|
|
| 374 |
dt_t = torch.tensor(dt_list, dtype=torch.float32)
|
| 375 |
|
| 376 |
N = cp.shape[0]
|
|
|
|
|
|
|
| 377 |
n_val = int(N * val_split) if val_split > 0 else 0
|
| 378 |
if n_val >= N:
|
| 379 |
n_val = max(0, N - 1) # leave at least 1 sample for training
|
|
|
|
| 405 |
history = {"train_loss": [], "val_loss": [], "physics_loss": [], "dt_mean": float(dt_t.mean())}
|
| 406 |
|
| 407 |
for epoch in range(epochs):
|
|
|
|
| 408 |
self.model.train()
|
| 409 |
epoch_data_loss = 0.0
|
| 410 |
epoch_phys_loss = 0.0
|
|
|
|
| 417 |
current = ZoneStateTensor(precip=cp_b, uncertainty=cu_b, belief=cb_b)
|
| 418 |
target = ZoneStateTensor(precip=np_b, uncertainty=nu_b, belief=nb_b)
|
| 419 |
|
|
|
|
|
|
|
| 420 |
batch_dt = float(dt_b.mean().item())
|
| 421 |
pred, phys_loss = self.model(
|
| 422 |
current, return_physics_loss=True, dt=batch_dt,
|
| 423 |
)
|
| 424 |
|
|
|
|
|
|
|
| 425 |
data_loss = (
|
| 426 |
F.mse_loss(pred.precip / 500.0, target.precip / 500.0)
|
| 427 |
+ F.mse_loss(pred.uncertainty, target.uncertainty)
|
|
|
|
| 443 |
avg_data = epoch_data_loss / len(train_loader)
|
| 444 |
avg_phys = epoch_phys_loss / len(train_loader)
|
| 445 |
|
|
|
|
| 446 |
self.model.eval()
|
| 447 |
val_loss = 0.0
|
| 448 |
with torch.no_grad():
|
|
|
|
| 486 |
# ---------------------------------------------------------------------------
|
| 487 |
|
| 488 |
class EnsembleDynamics:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 489 |
|
| 490 |
def __init__(self, n_models: int = 5, **model_kwargs):
|
| 491 |
self.models = [TemporalDynamicsModel(**model_kwargs) for _ in range(n_models)]
|
|
|
|
| 495 |
self,
|
| 496 |
current: ZoneStateTensor,
|
| 497 |
) -> Tuple[ZoneStateTensor, torch.Tensor]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 498 |
all_precips, all_uncerts, all_beliefs = [], [], []
|
| 499 |
|
| 500 |
for model in self.models:
|
|
|
|
| 515 |
belief=belief_stack.mean(0),
|
| 516 |
)
|
| 517 |
|
|
|
|
| 518 |
epistemic = (
|
|
|
|
|
|
|
| 519 |
(precip_stack.std(0, correction=0) / 500.0).mean()
|
| 520 |
+ uncert_stack.std(0, correction=0).mean()
|
| 521 |
+ belief_stack.std(0, correction=0).mean()
|
|
|
|
| 534 |
# ---------------------------------------------------------------------------
|
| 535 |
|
| 536 |
class DynaRolloutBuffer:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 537 |
|
| 538 |
def __init__(
|
| 539 |
self,
|
|
|
|
| 550 |
obs_current: ZoneStateTensor,
|
| 551 |
obs_actual_next: ZoneStateTensor,
|
| 552 |
) -> torch.Tensor:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 553 |
self.dynamics.eval()
|
| 554 |
with torch.no_grad():
|
| 555 |
pred_next, _ = self.dynamics(obs_current, return_physics_loss=False)
|
| 556 |
|
|
|
|
| 557 |
precip_err = F.mse_loss(
|
| 558 |
pred_next.precip / 500.0,
|
| 559 |
obs_actual_next.precip / 500.0,
|
|
|
|
| 568 |
self,
|
| 569 |
seed_state: ZoneStateTensor,
|
| 570 |
) -> List[ZoneStateTensor]:
|
| 571 |
+
return self.dynamics.rollout(seed_state, steps=self.n_synthetic_steps)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|