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 test_physics_dynamics.py
Browse files- test_physics_dynamics.py +1 -53
test_physics_dynamics.py
CHANGED
|
@@ -124,7 +124,6 @@ def batch_size() -> int:
|
|
| 124 |
|
| 125 |
@pytest.fixture
|
| 126 |
def state(n_zones, horizon_days, batch_size) -> ZoneStateTensor:
|
| 127 |
-
"""Standard random state tensor for use across tests."""
|
| 128 |
torch.manual_seed(0)
|
| 129 |
return ZoneStateTensor(
|
| 130 |
precip=torch.rand(batch_size, n_zones, horizon_days) * 20.0,
|
|
@@ -143,7 +142,6 @@ def model(n_zones, horizon_days) -> TemporalDynamicsModel:
|
|
| 143 |
|
| 144 |
@pytest.fixture
|
| 145 |
def tiny_pairs(n_zones, horizon_days) -> list:
|
| 146 |
-
"""Small set of (current, next) pairs for trainer tests."""
|
| 147 |
torch.manual_seed(7)
|
| 148 |
pairs = []
|
| 149 |
for _ in range(20):
|
|
@@ -302,36 +300,23 @@ class TestPhysicsResidualLoss:
|
|
| 302 |
assert loss_fn(u, v).item() == pytest.approx(0.0)
|
| 303 |
|
| 304 |
def test_exact_linear_advection_has_low_residual(self):
|
| 305 |
-
"""
|
| 306 |
-
If u_next is u shifted by exactly v steps along the horizon axis,
|
| 307 |
-
the advection term v*∂u/∂τ should largely cancel ∂u/∂t,
|
| 308 |
-
producing a small residual (not exactly zero due to diffusion term
|
| 309 |
-
and boundary approximations, but significantly lower than random).
|
| 310 |
-
"""
|
| 311 |
loss_fn = PhysicsResidualLoss(weight=1.0)
|
| 312 |
-
# Fix v to a known value for this test
|
| 313 |
with torch.no_grad():
|
| 314 |
loss_fn.log_v.fill_(0.0) # v = 1.0
|
| 315 |
loss_fn.log_D.fill_(-10.0) # D ≈ 0 (nearly pure advection)
|
| 316 |
|
| 317 |
B, Z, H = 1, 1, 20
|
| 318 |
tau = torch.arange(H, dtype=torch.float32)
|
| 319 |
-
# Linear ramp: u = a * tau + b (∂u/∂τ = a, ∂²u/∂τ² = 0)
|
| 320 |
-
# Exact solution after dt=1: u_next = a*(tau+1) + b = u + a
|
| 321 |
a = 2.0
|
| 322 |
u = a * tau.unsqueeze(0).unsqueeze(0).expand(B, Z, H)
|
| 323 |
u_next = u + a # shift by a (= v * ∂u/∂τ = 1.0 * a)
|
| 324 |
|
| 325 |
residual_advection = loss_fn(u, u_next)
|
| 326 |
|
| 327 |
-
# For comparison: random u_next should have a larger residual than
|
| 328 |
-
# the analytically correct solution.
|
| 329 |
torch.manual_seed(0)
|
| 330 |
u_random = torch.rand_like(u) * 40.0 # unrelated to u
|
| 331 |
residual_random = loss_fn(u, u_random)
|
| 332 |
|
| 333 |
-
# The analytically correct solution should produce a strictly lower
|
| 334 |
-
# residual than a completely unrelated random prediction.
|
| 335 |
assert residual_advection.item() < residual_random.item(), (
|
| 336 |
f"Expected advection residual ({residual_advection.item():.4f}) < "
|
| 337 |
f"random residual ({residual_random.item():.4f})"
|
|
@@ -611,14 +596,8 @@ class TestDynamicsTrainer:
|
|
| 611 |
assert loss >= 0.0, f"Negative loss: {loss}"
|
| 612 |
|
| 613 |
def test_loss_decreases_over_training(self, n_zones, horizon_days):
|
| 614 |
-
"""
|
| 615 |
-
Loss should trend downward over sufficient epochs on a small fixed dataset.
|
| 616 |
-
We test that final loss < initial loss (not strictly monotonic — that is
|
| 617 |
-
not guaranteed with SGD). Seed is fixed for reproducibility.
|
| 618 |
-
"""
|
| 619 |
torch.manual_seed(0)
|
| 620 |
np.random.seed(0)
|
| 621 |
-
# Build a more learnable target: next = current + small noise
|
| 622 |
pairs = []
|
| 623 |
for _ in range(30):
|
| 624 |
curr = ZoneStateTensor(
|
|
@@ -664,21 +643,10 @@ class TestDynamicsTrainer:
|
|
| 664 |
),
|
| 665 |
)
|
| 666 |
trainer = DynamicsTrainer(n_zones=n_zones, horizon_days=horizon_days)
|
| 667 |
-
# val_split=0.0 → n_val=0, n_train=1 (after the guard fix).
|
| 668 |
-
# With val_split=0.1 and N=1 → n_val would be 1, n_train=0 → should raise.
|
| 669 |
-
# val_split=0.0 is the correct way to train on a tiny dataset.
|
| 670 |
history = trainer.train([pair], epochs=1, batch_size=1, val_split=0.0)
|
| 671 |
assert "train_loss" in history
|
| 672 |
assert len(history["train_loss"]) == 1
|
| 673 |
|
| 674 |
-
# Verify that passing exactly 2 pairs with val_split=1.0 raises clearly
|
| 675 |
-
# (n_val = max(0, 2-1) = 1, n_train = 1 is fine — but val_split=1.0
|
| 676 |
-
# with the guard: n_val = int(2*1.0) = 2 >= N=2 → n_val = max(0,2-1)=1
|
| 677 |
-
# So we need val_split such that int(N*val_split) >= N with our guard.
|
| 678 |
-
# The guard sets n_val = max(0, N-1), so n_train = 1 always survives.
|
| 679 |
-
# The real failure case is N=1 with val_split=0.5+: int(1*0.5)=0 → fine.
|
| 680 |
-
# Actually to trigger the error we need a 0-sample training set which
|
| 681 |
-
# cannot happen with the guard. Verify graceful handling instead.
|
| 682 |
history2 = trainer.train([pair, pair], epochs=1, batch_size=1, val_split=0.5)
|
| 683 |
assert "train_loss" in history2
|
| 684 |
|
|
@@ -698,10 +666,6 @@ class TestDynamicsTrainer:
|
|
| 698 |
n_zones=n_zones, horizon_days=horizon_days, physics_weight=0.0
|
| 699 |
)
|
| 700 |
history = trainer.train(tiny_pairs, epochs=2, batch_size=4)
|
| 701 |
-
# history["physics_loss"] records the RAW unweighted PDE residual,
|
| 702 |
-
# not the weighted contribution to the total loss. When physics_weight=0
|
| 703 |
-
# the residual is still computed and logged — it just doesn't affect
|
| 704 |
-
# the gradient. We verify it is finite and non-negative.
|
| 705 |
for pl in history["physics_loss"]:
|
| 706 |
assert np.isfinite(pl), f"Physics loss is not finite: {pl}"
|
| 707 |
assert pl >= 0.0, f"Physics loss is negative: {pl}"
|
|
@@ -735,19 +699,12 @@ class TestEnsembleDynamics:
|
|
| 735 |
assert epistemic.item() >= 0.0
|
| 736 |
|
| 737 |
def test_single_model_ensemble_has_zero_uncertainty(self, state, n_zones, horizon_days):
|
| 738 |
-
"""
|
| 739 |
-
With n_models=1 there is no variance — std of a single value is 0.
|
| 740 |
-
"""
|
| 741 |
torch.manual_seed(0)
|
| 742 |
ens = EnsembleDynamics(n_models=1, n_zones=n_zones, horizon_days=horizon_days)
|
| 743 |
_, epistemic = ens.predict(state)
|
| 744 |
assert epistemic.item() == pytest.approx(0.0, abs=1e-6)
|
| 745 |
|
| 746 |
def test_multi_model_ensemble_has_positive_uncertainty(self, state, n_zones, horizon_days):
|
| 747 |
-
"""
|
| 748 |
-
With n_models=5 and randomly initialised weights, the ensemble members
|
| 749 |
-
will disagree, producing non-zero epistemic uncertainty.
|
| 750 |
-
"""
|
| 751 |
torch.manual_seed(99)
|
| 752 |
ens = EnsembleDynamics(n_models=5, n_zones=n_zones, horizon_days=horizon_days)
|
| 753 |
_, epistemic = ens.predict(state)
|
|
@@ -797,9 +754,7 @@ class TestDynaRolloutBuffer:
|
|
| 797 |
assert bonus.item() >= 0.0
|
| 798 |
|
| 799 |
def test_compute_surprise_bonus_bounded_by_uncertainty_weight(self, buffer, state):
|
| 800 |
-
"""Bonus is clipped to [0, uncertainty_weight]."""
|
| 801 |
n_zones, horizon_days = state.n_zones, state.horizon_days
|
| 802 |
-
# Make next_state very different to maximise surprise
|
| 803 |
very_different = ZoneStateTensor(
|
| 804 |
precip=torch.full_like(state.precip, 499.0),
|
| 805 |
uncertainty=torch.ones_like(state.uncertainty),
|
|
@@ -809,12 +764,6 @@ class TestDynaRolloutBuffer:
|
|
| 809 |
assert bonus.item() <= buffer.uncertainty_weight + 1e-6
|
| 810 |
|
| 811 |
def test_identical_states_give_near_zero_bonus(self, buffer, state):
|
| 812 |
-
"""
|
| 813 |
-
When current and next are identical, the model's prediction error
|
| 814 |
-
should be low, producing a near-zero surprise bonus.
|
| 815 |
-
Note: not exactly zero because the model doesn't predict the identity.
|
| 816 |
-
We just check it is lower than the maximum possible bonus.
|
| 817 |
-
"""
|
| 818 |
bonus_same = buffer.compute_surprise_bonus(state, state)
|
| 819 |
very_different = ZoneStateTensor(
|
| 820 |
precip=torch.full_like(state.precip, 499.0),
|
|
@@ -822,7 +771,6 @@ class TestDynaRolloutBuffer:
|
|
| 822 |
belief=torch.zeros_like(state.belief),
|
| 823 |
)
|
| 824 |
bonus_diff = buffer.compute_surprise_bonus(state, very_different)
|
| 825 |
-
# Identical input should produce smaller or equal bonus than maximally different
|
| 826 |
assert bonus_same.item() <= bonus_diff.item() + 1e-6
|
| 827 |
|
| 828 |
def test_no_gradient_in_compute_surprise_bonus(self, buffer, state):
|
|
@@ -857,4 +805,4 @@ class TestDynaRolloutBuffer:
|
|
| 857 |
)
|
| 858 |
bonus = buf.compute_surprise_bonus(state, very_different)
|
| 859 |
assert bonus.item() <= weight + 1e-6
|
| 860 |
-
assert bonus.item() >= 0.0
|
|
|
|
| 124 |
|
| 125 |
@pytest.fixture
|
| 126 |
def state(n_zones, horizon_days, batch_size) -> ZoneStateTensor:
|
|
|
|
| 127 |
torch.manual_seed(0)
|
| 128 |
return ZoneStateTensor(
|
| 129 |
precip=torch.rand(batch_size, n_zones, horizon_days) * 20.0,
|
|
|
|
| 142 |
|
| 143 |
@pytest.fixture
|
| 144 |
def tiny_pairs(n_zones, horizon_days) -> list:
|
|
|
|
| 145 |
torch.manual_seed(7)
|
| 146 |
pairs = []
|
| 147 |
for _ in range(20):
|
|
|
|
| 300 |
assert loss_fn(u, v).item() == pytest.approx(0.0)
|
| 301 |
|
| 302 |
def test_exact_linear_advection_has_low_residual(self):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
loss_fn = PhysicsResidualLoss(weight=1.0)
|
|
|
|
| 304 |
with torch.no_grad():
|
| 305 |
loss_fn.log_v.fill_(0.0) # v = 1.0
|
| 306 |
loss_fn.log_D.fill_(-10.0) # D ≈ 0 (nearly pure advection)
|
| 307 |
|
| 308 |
B, Z, H = 1, 1, 20
|
| 309 |
tau = torch.arange(H, dtype=torch.float32)
|
|
|
|
|
|
|
| 310 |
a = 2.0
|
| 311 |
u = a * tau.unsqueeze(0).unsqueeze(0).expand(B, Z, H)
|
| 312 |
u_next = u + a # shift by a (= v * ∂u/∂τ = 1.0 * a)
|
| 313 |
|
| 314 |
residual_advection = loss_fn(u, u_next)
|
| 315 |
|
|
|
|
|
|
|
| 316 |
torch.manual_seed(0)
|
| 317 |
u_random = torch.rand_like(u) * 40.0 # unrelated to u
|
| 318 |
residual_random = loss_fn(u, u_random)
|
| 319 |
|
|
|
|
|
|
|
| 320 |
assert residual_advection.item() < residual_random.item(), (
|
| 321 |
f"Expected advection residual ({residual_advection.item():.4f}) < "
|
| 322 |
f"random residual ({residual_random.item():.4f})"
|
|
|
|
| 596 |
assert loss >= 0.0, f"Negative loss: {loss}"
|
| 597 |
|
| 598 |
def test_loss_decreases_over_training(self, n_zones, horizon_days):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 599 |
torch.manual_seed(0)
|
| 600 |
np.random.seed(0)
|
|
|
|
| 601 |
pairs = []
|
| 602 |
for _ in range(30):
|
| 603 |
curr = ZoneStateTensor(
|
|
|
|
| 643 |
),
|
| 644 |
)
|
| 645 |
trainer = DynamicsTrainer(n_zones=n_zones, horizon_days=horizon_days)
|
|
|
|
|
|
|
|
|
|
| 646 |
history = trainer.train([pair], epochs=1, batch_size=1, val_split=0.0)
|
| 647 |
assert "train_loss" in history
|
| 648 |
assert len(history["train_loss"]) == 1
|
| 649 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 650 |
history2 = trainer.train([pair, pair], epochs=1, batch_size=1, val_split=0.5)
|
| 651 |
assert "train_loss" in history2
|
| 652 |
|
|
|
|
| 666 |
n_zones=n_zones, horizon_days=horizon_days, physics_weight=0.0
|
| 667 |
)
|
| 668 |
history = trainer.train(tiny_pairs, epochs=2, batch_size=4)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 669 |
for pl in history["physics_loss"]:
|
| 670 |
assert np.isfinite(pl), f"Physics loss is not finite: {pl}"
|
| 671 |
assert pl >= 0.0, f"Physics loss is negative: {pl}"
|
|
|
|
| 699 |
assert epistemic.item() >= 0.0
|
| 700 |
|
| 701 |
def test_single_model_ensemble_has_zero_uncertainty(self, state, n_zones, horizon_days):
|
|
|
|
|
|
|
|
|
|
| 702 |
torch.manual_seed(0)
|
| 703 |
ens = EnsembleDynamics(n_models=1, n_zones=n_zones, horizon_days=horizon_days)
|
| 704 |
_, epistemic = ens.predict(state)
|
| 705 |
assert epistemic.item() == pytest.approx(0.0, abs=1e-6)
|
| 706 |
|
| 707 |
def test_multi_model_ensemble_has_positive_uncertainty(self, state, n_zones, horizon_days):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 708 |
torch.manual_seed(99)
|
| 709 |
ens = EnsembleDynamics(n_models=5, n_zones=n_zones, horizon_days=horizon_days)
|
| 710 |
_, epistemic = ens.predict(state)
|
|
|
|
| 754 |
assert bonus.item() >= 0.0
|
| 755 |
|
| 756 |
def test_compute_surprise_bonus_bounded_by_uncertainty_weight(self, buffer, state):
|
|
|
|
| 757 |
n_zones, horizon_days = state.n_zones, state.horizon_days
|
|
|
|
| 758 |
very_different = ZoneStateTensor(
|
| 759 |
precip=torch.full_like(state.precip, 499.0),
|
| 760 |
uncertainty=torch.ones_like(state.uncertainty),
|
|
|
|
| 764 |
assert bonus.item() <= buffer.uncertainty_weight + 1e-6
|
| 765 |
|
| 766 |
def test_identical_states_give_near_zero_bonus(self, buffer, state):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 767 |
bonus_same = buffer.compute_surprise_bonus(state, state)
|
| 768 |
very_different = ZoneStateTensor(
|
| 769 |
precip=torch.full_like(state.precip, 499.0),
|
|
|
|
| 771 |
belief=torch.zeros_like(state.belief),
|
| 772 |
)
|
| 773 |
bonus_diff = buffer.compute_surprise_bonus(state, very_different)
|
|
|
|
| 774 |
assert bonus_same.item() <= bonus_diff.item() + 1e-6
|
| 775 |
|
| 776 |
def test_no_gradient_in_compute_surprise_bonus(self, buffer, state):
|
|
|
|
| 805 |
)
|
| 806 |
bonus = buf.compute_surprise_bonus(state, very_different)
|
| 807 |
assert bonus.item() <= weight + 1e-6
|
| 808 |
+
assert bonus.item() >= 0.0
|