Skip to content

Commit 300a5ed

Browse files
kirann-05araffinCopilot
authored
fix: clear cached probs in MaskableCategorical.apply_masking to avoid Simplex validate_args error (torch 2.9+) (#326)
* fix: clear cached probs in MaskableCategorical.apply_masking to avoid Simplex validate_args error (torch 2.9+, issue #322) * fix: clear cached probs in apply_masking, add regression test, update changelog (#322) * style: apply black formatting to test_distributions.py * Update mask PPO test and add comments * Simplify implementation * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update comments --------- Co-authored-by: Antonin RAFFIN <antonin.raffin@ensta.org> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 6fa2410 commit 300a5ed

3 files changed

Lines changed: 54 additions & 20 deletions

File tree

docs/misc/changelog.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@
1616

1717
### Bug Fixes:
1818

19-
- Fix `MaskablePPO` and `RecurrentPPO` inaccurate `n_updates` counting when `target_kl` early exits the training loop
20-
- Fix `RecurrentPPO` and `MaskablePPO` forward and predict do not reshape action before clip it (@immortal-boy)
19+
- Fixed `MaskablePPO` and `RecurrentPPO` inaccurate `n_updates` counting when `target_kl` early exits the training loop
20+
- Fixed `RecurrentPPO` and `MaskablePPO` `forward` and `predict` not reshaping the action before clipping it (@immortal-boy)
2121
- Do not call `forward()` method directly in `RecurrentPPO` (@immortal-boy)
22+
- Fixed `MaskableCategorical.apply_masking()` crashing with `ValueError: Simplex` when cached `probs` deviate from sum=1 in float32 with large action spaces (torch 2.9+) (@kirann-05)
2223

2324
### Deprecations:
2425

sb3_contrib/common/maskable/distributions.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
from stable_baselines3.common.distributions import Distribution
88
from torch import nn
99
from torch.distributions import Categorical
10-
from torch.distributions.utils import logits_to_probs
1110

1211
SelfMaskableCategoricalDistribution = TypeVar("SelfMaskableCategoricalDistribution", bound="MaskableCategoricalDistribution")
1312
SelfMaskableMultiCategoricalDistribution = TypeVar(
@@ -65,10 +64,10 @@ def apply_masking(self, masks: MaybeMasks) -> None:
6564
logits = self._original_logits
6665

6766
# Reinitialize with updated logits
68-
super().__init__(logits=logits)
69-
70-
# self.probs may already be cached, so we must force an update
71-
self.probs = logits_to_probs(self.logits)
67+
# Clear cached probs before reinit to avoid validate_args Simplex error
68+
# when stale float32 probs deviate from sum=1 by >1e-6 (torch 2.9+, many categories)
69+
self.__dict__.pop("probs", None)
70+
super().__init__(logits=logits, validate_args=self._validate_args)
7271

7372
def entropy(self) -> th.Tensor:
7473
if self.masks is None:

tests/test_distributions.py

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import numpy as np
22
import pytest
33
import torch as th
4+
from torch.distributions.utils import logits_to_probs
45

56
from sb3_contrib.common.maskable.distributions import (
67
MaskableBernoulliDistribution,
@@ -16,8 +17,8 @@ def test_applying_mask(self):
1617
Show that probs change as a result of masking
1718
"""
1819

19-
starting_probs = th.Tensor([[0.2, 0.2, 0.6], [1, 0, 0]])
20-
expected_probs = th.Tensor([[0, 0.25, 0.75], [0, 0.5, 0.5]])
20+
starting_probs = th.tensor([[0.2, 0.2, 0.6], [1, 0, 0]])
21+
expected_probs = th.tensor([[0, 0.25, 0.75], [0, 0.5, 0.5]])
2122
mask = np.array([[False, True, True], [False, True, True]])
2223

2324
distribution = MaskableCategorical(probs=starting_probs)
@@ -29,8 +30,8 @@ def test_modifying_mask(self):
2930
Show that masks apply independently of each other
3031
"""
3132

32-
starting_probs = th.Tensor([[0.2, 0.2, 0.6], [1, 0, 0]])
33-
expected_probs = th.Tensor([[0.5, 0.5, 0], [0, 1, 0]])
33+
starting_probs = th.tensor([[0.2, 0.2, 0.6], [1, 0, 0]])
34+
expected_probs = th.tensor([[0.5, 0.5, 0], [0, 1, 0]])
3435
first_mask = np.array([[False, True, True], [False, True, True]])
3536
second_mask = np.array([[True, True, False], [False, True, False]])
3637

@@ -52,7 +53,7 @@ def test_removing_mask(self):
5253
Show that masking may be unapplied to recover original probs
5354
"""
5455

55-
starting_probs = th.Tensor([[0.2, 0.2, 0.6], [1, 0, 0]])
56+
starting_probs = th.tensor([[0.2, 0.2, 0.6], [1, 0, 0]])
5657
mask = np.array([[False, True, True], [False, True, True]])
5758

5859
distribution = MaskableCategorical(probs=starting_probs)
@@ -64,14 +65,47 @@ def test_removing_mask(self):
6465
def test_masking_affects_entropy(self):
6566
# All outcomes equally likely
6667
NUM_DIMS = 3
67-
logits = th.Tensor([[0] * NUM_DIMS])
68+
logits = th.zeros((1, NUM_DIMS), dtype=th.float32)
6869
dist = MaskableCategorical(logits=logits)
6970

70-
# For each possible number of valid actions v, show that e^entropy == v
71-
for v in range(1, NUM_DIMS + 1):
72-
masks = [j < v for j in range(NUM_DIMS)]
71+
# For each possible number of valid actions valid_action, show that e^entropy == valid_action
72+
for valid_action in range(1, NUM_DIMS + 1):
73+
masks = [j < valid_action for j in range(NUM_DIMS)]
7374
dist.apply_masking(masks)
74-
assert int(dist.entropy().exp()) == v
75+
assert int(dist.entropy().exp()) == valid_action
76+
77+
def test_apply_masking_no_simplex_error_with_cached_probs(self):
78+
"""
79+
Regression test for issue #322.
80+
apply_masking() should not raise a Simplex validate_args error
81+
when probs are cached before masking is re-applied (torch 2.9+, large action spaces).
82+
"""
83+
n = 992
84+
delta = 17
85+
# Set all logits to -delta, then set the first one to 0 to make it the most likely
86+
logits = th.full((1, n), -delta, dtype=th.float32)
87+
logits[0, 0] = 0.0
88+
89+
# Expected probs: first dim is close to one, rest are almost zero after the softmax
90+
expected_proba = th.cat((th.ones(1, 1), th.zeros(1, n - 1)), dim=1)
91+
92+
distribution = MaskableCategorical(logits=logits, validate_args=True)
93+
_ = distribution.probs # cache probs on the instance
94+
95+
th.testing.assert_close(distribution.probs, expected_proba, rtol=5e-5, atol=5e-5)
96+
# Apply softmax, should be within the default tolerance
97+
th.testing.assert_close(distribution.probs, logits_to_probs(logits))
98+
99+
mask = th.zeros((1, n), dtype=th.bool)
100+
# Only the first action is valid, mask the rest of the actions,
101+
# the rtol and atol when comparing to expected_proba can be kept to the default values
102+
mask[0, 0] = True
103+
104+
# Should not raise ValueError: Simplex constraint
105+
distribution.apply_masking(mask.numpy())
106+
th.testing.assert_close(distribution.probs, expected_proba)
107+
# After masking, it should deviate from the original probs
108+
assert not th.allclose(distribution.probs, logits_to_probs(logits))
75109

76110

77111
class TestMaskableCategoricalDistribution:
@@ -133,7 +167,7 @@ def test_dim_masking(self):
133167
NUM_DIMS = 2
134168
dist = MaskableCategoricalDistribution(NUM_DIMS)
135169

136-
logits = th.Tensor([[0] * NUM_DIMS])
170+
logits = th.zeros((1, NUM_DIMS), dtype=th.float32)
137171
dist.proba_distribution(logits)
138172

139173
assert (dist.distribution.probs == 0.5).all()
@@ -216,7 +250,7 @@ def test_dim_masking(self):
216250
NUM_CATS = 3
217251
dist = MaskableMultiCategoricalDistribution([DIMS_PER_CAT] * NUM_CATS)
218252

219-
logits = th.Tensor([[0] * DIMS_PER_CAT * NUM_CATS])
253+
logits = th.tensor([[0] * DIMS_PER_CAT * NUM_CATS], dtype=th.float32)
220254
dist.proba_distribution(logits)
221255

222256
assert len(dist.distributions) == NUM_CATS
@@ -304,7 +338,7 @@ def test_dim_masking(self):
304338
BINARY_STATES = 2
305339
dist = MaskableBernoulliDistribution(NUM_DIMS)
306340

307-
logits = th.Tensor([[0] * BINARY_STATES * NUM_DIMS])
341+
logits = th.tensor([[0] * BINARY_STATES * NUM_DIMS], dtype=th.float32)
308342
dist.proba_distribution(logits)
309343

310344
assert len(dist.distributions) == NUM_DIMS

0 commit comments

Comments
 (0)