rlmodule 0.1.7__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {rlmodule-0.1.7/rlmodule.egg-info → rlmodule-0.2.0}/PKG-INFO +1 -1
- {rlmodule-0.1.7 → rlmodule-0.2.0}/pyproject.toml +1 -1
- {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/skrl/torch/network/__init__.py +4 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/source/log_std.py +14 -7
- {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/source/model.py +4 -1
- {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/source/network.py +156 -1
- {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/source/network_cfg.py +30 -1
- {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/source/output_layer.py +59 -2
- {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/source/output_layer_cfg.py +18 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/source/rlmodel.py +5 -2
- {rlmodule-0.1.7 → rlmodule-0.2.0/rlmodule.egg-info}/PKG-INFO +1 -1
- {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule.egg-info/SOURCES.txt +3 -1
- {rlmodule-0.1.7 → rlmodule-0.2.0}/tests/test_cnn_backbone.py +205 -1
- rlmodule-0.2.0/tests/test_combined_log_std.py +42 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/tests/test_graph_network.py +69 -0
- rlmodule-0.2.0/tests/test_squashed_gaussian.py +118 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/LICENSE +0 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/README.md +0 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/__init__.py +0 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/skrl/__init__.py +0 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/skrl/torch/__init__.py +0 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/skrl/torch/examples/gymnasium/cnn_experimental.py +0 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/skrl/torch/examples/gymnasium/pendulum_ppo_mlp_separate_model.py +0 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/skrl/torch/examples/gymnasium/pendulum_ppo_mlp_shared_model.py +0 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/skrl/torch/examples/gymnasium/pendulum_ppo_rnn_forwarded_input_shared_model.py +0 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/skrl/torch/examples/gymnasium/pendulum_ppo_rnn_separate_model.py +0 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/skrl/torch/examples/gymnasium/pendulum_ppo_rnn_shared_model.py +0 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/skrl/torch/examples/gymnasium/pendulum_sac_mlp_separate_model.py +0 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/skrl/torch/examples/gymnasium/skrl_no_module.py +0 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/skrl/torch/examples/gymnasium/skrl_no_module_rnn.py +0 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/skrl/torch/examples/gymnasium/skrl_sac.py +0 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/skrl/torch/examples/gymnasium/temp.py +0 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/skrl/torch/output_layer/__init__.py +0 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/source/__init__.py +0 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/source/build.py +0 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/source/log_std_cfg.py +0 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/source/nvidia_utils/__init__.py +0 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/source/nvidia_utils/configclass.py +0 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/source/nvidia_utils/dict.py +0 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/source/nvidia_utils/string.py +0 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/source/rlmodel_cfg.py +0 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/source/utils.py +0 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule.egg-info/dependency_links.txt +0 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule.egg-info/requires.txt +0 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule.egg-info/top_level.txt +0 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/setup.cfg +0 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/tests/test_build_model_integration.py +0 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/tests/test_head_mlp.py +0 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/tests/test_mlp_layer_norm.py +0 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/tests/test_residual_mlp.py +0 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/tests/test_rnn_memory_and_correctness.py +0 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/tests/test_skrl_startup.py +0 -0
- {rlmodule-0.1.7 → rlmodule-0.2.0}/tests/test_skrl_train_smoke.py +0 -0
|
@@ -9,6 +9,7 @@ __all__ = [
|
|
|
9
9
|
"RnnMlpWithForwardedInput",
|
|
10
10
|
"ResidualMlp",
|
|
11
11
|
"CnnBackbone",
|
|
12
|
+
"Cnn3dBackbone",
|
|
12
13
|
"CnnMlp",
|
|
13
14
|
"CnnProprioMlp",
|
|
14
15
|
"GraphNetwork",
|
|
@@ -23,6 +24,7 @@ __all__ = [
|
|
|
23
24
|
"RnnMlpCfg",
|
|
24
25
|
"CnnCfg",
|
|
25
26
|
"CnnBackboneCfg",
|
|
27
|
+
"Cnn3dBackboneCfg",
|
|
26
28
|
"CnnMlpCfg",
|
|
27
29
|
"CnnProprioMlpCfg",
|
|
28
30
|
"GraphNetworkCfg",
|
|
@@ -33,6 +35,7 @@ from rlmodule.source.network import ( # noqa: F401
|
|
|
33
35
|
LSTM,
|
|
34
36
|
MLP,
|
|
35
37
|
RNN,
|
|
38
|
+
Cnn3dBackbone,
|
|
36
39
|
CnnBackbone,
|
|
37
40
|
CnnMlp,
|
|
38
41
|
CnnProprioMlp,
|
|
@@ -43,6 +46,7 @@ from rlmodule.source.network import ( # noqa: F401
|
|
|
43
46
|
RnnMlpWithForwardedInput,
|
|
44
47
|
)
|
|
45
48
|
from rlmodule.source.network_cfg import ( # noqa: F401
|
|
49
|
+
Cnn3dBackboneCfg,
|
|
46
50
|
CnnBackboneCfg,
|
|
47
51
|
CnnCfg,
|
|
48
52
|
CnnMlpCfg,
|
|
@@ -102,18 +102,24 @@ class CombinedLogStd(LogStd):
|
|
|
102
102
|
def __init__(self, device: Union[str, torch.device], input_size: int, output_size: int, cfg):
|
|
103
103
|
super().__init__(device, input_size, output_size, cfg)
|
|
104
104
|
|
|
105
|
-
self._modules
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
# NOTE: must NOT be ``self._modules`` — that name is ``nn.Module``'s
|
|
106
|
+
# internal child-module registry. Assigning a plain list there
|
|
107
|
+
# silently breaks parameter registration (``.parameters()`` /
|
|
108
|
+
# ``.to()`` / ``state_dict()``), so the sub-LogStd params never reach
|
|
109
|
+
# the optimizer and their std stays frozen. Use a registered
|
|
110
|
+
# ``nn.ModuleList`` under a distinct attribute instead.
|
|
111
|
+
self._sub_log_stds = nn.ModuleList(
|
|
112
|
+
[module_cfg.class_type(device, input_size, output_size, module_cfg) for module_cfg in cfg.combined_modules]
|
|
113
|
+
)
|
|
108
114
|
|
|
109
115
|
if cfg.combination_constants is None:
|
|
110
|
-
self._combination_constants = list(repeat(1.0, len(self.
|
|
116
|
+
self._combination_constants = list(repeat(1.0, len(self._sub_log_stds)))
|
|
111
117
|
else:
|
|
112
118
|
self._combination_constants = cfg.combination_constants
|
|
113
|
-
if len(self.
|
|
119
|
+
if len(self._sub_log_stds) != len(self._combination_constants):
|
|
114
120
|
raise ValueError(
|
|
115
121
|
f"Number of combination constants {len(self._combination_constants)} does not match number of"
|
|
116
|
-
f" modules {len(self.
|
|
122
|
+
f" modules {len(self._sub_log_stds)}"
|
|
117
123
|
)
|
|
118
124
|
|
|
119
125
|
if cfg.combination_method == "max":
|
|
@@ -128,6 +134,7 @@ class CombinedLogStd(LogStd):
|
|
|
128
134
|
def forward(self, input: torch.Tensor) -> torch.Tensor:
|
|
129
135
|
"""Compute standard deviation as a combination from multiple Std modules."""
|
|
130
136
|
collected_stds = [
|
|
131
|
-
self._combination_constants[module_id] * module(input)
|
|
137
|
+
self._combination_constants[module_id] * module(input)
|
|
138
|
+
for module_id, module in enumerate(self._sub_log_stds)
|
|
132
139
|
]
|
|
133
140
|
return self.clip_std(reduce(self._combination_method, collected_stds))
|
|
@@ -3,14 +3,17 @@ from typing import Any, Mapping, Optional, Tuple, Union
|
|
|
3
3
|
import collections
|
|
4
4
|
import gym
|
|
5
5
|
import gymnasium
|
|
6
|
+
import logging
|
|
6
7
|
|
|
7
8
|
import torch
|
|
8
9
|
import torch.nn as nn
|
|
9
10
|
|
|
10
|
-
from rlmodule import logger
|
|
11
11
|
from rlmodule.source.utils import contains_rnn_module
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
logger = logging.getLogger("rlmodule")
|
|
15
|
+
|
|
16
|
+
|
|
14
17
|
class Model(torch.nn.Module):
|
|
15
18
|
def __init__(self, device: Union[str, torch.device], network: nn.Module) -> None:
|
|
16
19
|
"""Base class representing a function approximator"""
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
from typing import Any, Sequence, Union
|
|
2
2
|
|
|
3
|
+
import logging
|
|
4
|
+
|
|
3
5
|
import torch
|
|
4
6
|
import torch.nn as nn
|
|
5
7
|
|
|
6
|
-
from rlmodule import logger
|
|
7
8
|
from rlmodule.source.utils import get_cnn_shape, get_output_size, get_space_size
|
|
8
9
|
|
|
9
10
|
|
|
11
|
+
logger = logging.getLogger("rlmodule")
|
|
12
|
+
|
|
13
|
+
|
|
10
14
|
LN_POSITION_PRE = "pre"
|
|
11
15
|
LN_POSITION_POST = "post"
|
|
12
16
|
_VALID_LN_POSITIONS = (LN_POSITION_PRE, LN_POSITION_POST)
|
|
@@ -707,14 +711,66 @@ def _build_impala_cnn(in_channels: int, widths: Sequence[int] = (16, 32, 32)) ->
|
|
|
707
711
|
return nn.Sequential(*stages)
|
|
708
712
|
|
|
709
713
|
|
|
714
|
+
class SpatialSoftmax(nn.Module):
|
|
715
|
+
"""Spatial soft-argmax keypoints (Levine et al. 2016, "End-to-End Training of
|
|
716
|
+
Deep Visuomotor Policies").
|
|
717
|
+
|
|
718
|
+
For a ``(B, C, H, W)`` feature map, each channel is turned into a softmax
|
|
719
|
+
distribution over its ``H * W`` positions and reduced to that distribution's
|
|
720
|
+
expected image coordinates. The output is ``(B, 2 * C)``, flat:
|
|
721
|
+
``[x_0, y_0, x_1, y_1, ...]``, with ``x`` (columns) and ``y`` (rows) in
|
|
722
|
+
``[-1, 1]`` (pixel centres, -1 = left / top). It gives *where* a feature is,
|
|
723
|
+
which average pooling blurs away for features only a few pixels wide.
|
|
724
|
+
|
|
725
|
+
``temperature`` divides the logits (lower = sharper, closer to a hard
|
|
726
|
+
argmax). With ``learnable_temperature`` it is one trained scalar, stored as
|
|
727
|
+
its log so it stays positive. The reduction runs in float32 whatever the
|
|
728
|
+
input dtype, since a bf16 softmax over hundreds of positions loses the
|
|
729
|
+
sub-pixel precision the keypoints are for.
|
|
730
|
+
"""
|
|
731
|
+
|
|
732
|
+
def __init__(self, temperature: float = 1.0, learnable_temperature: bool = True):
|
|
733
|
+
super().__init__()
|
|
734
|
+
if temperature <= 0:
|
|
735
|
+
raise ValueError(f"SpatialSoftmax temperature must be > 0; got {temperature!r}.")
|
|
736
|
+
log_t = torch.tensor(float(temperature)).log()
|
|
737
|
+
if learnable_temperature:
|
|
738
|
+
self.log_temperature = nn.Parameter(log_t)
|
|
739
|
+
else:
|
|
740
|
+
self.register_buffer("log_temperature", log_t)
|
|
741
|
+
|
|
742
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
743
|
+
if x.dim() != 4:
|
|
744
|
+
raise ValueError(f"SpatialSoftmax expects (B, C, H, W); got shape {tuple(x.shape)}.")
|
|
745
|
+
b, c, h, w = x.shape
|
|
746
|
+
logits = x.float().reshape(b, c, h * w) / self.log_temperature.float().exp()
|
|
747
|
+
attention = torch.softmax(logits, dim=-1)
|
|
748
|
+
ys = torch.linspace(-1.0, 1.0, h, device=x.device) if h > 1 else torch.zeros(1, device=x.device)
|
|
749
|
+
xs = torch.linspace(-1.0, 1.0, w, device=x.device) if w > 1 else torch.zeros(1, device=x.device)
|
|
750
|
+
grid_y, grid_x = torch.meshgrid(ys, xs, indexing="ij")
|
|
751
|
+
ex = (attention * grid_x.reshape(1, 1, -1)).sum(-1)
|
|
752
|
+
ey = (attention * grid_y.reshape(1, 1, -1)).sum(-1)
|
|
753
|
+
return torch.stack([ex, ey], dim=-1).reshape(b, 2 * c).to(x.dtype)
|
|
754
|
+
|
|
755
|
+
|
|
710
756
|
_NN_LAYER_REGISTRY: dict[str, type] = {
|
|
711
757
|
"Conv2d": nn.Conv2d,
|
|
712
758
|
"ELU": nn.ELU,
|
|
713
759
|
"ReLU": nn.ReLU,
|
|
714
760
|
"MaxPool2d": nn.MaxPool2d,
|
|
715
761
|
"AdaptiveAvgPool2d": nn.AdaptiveAvgPool2d,
|
|
762
|
+
# Keeps a small, strong response (a box a few pixels wide) that average
|
|
763
|
+
# pooling would dilute with its neighbourhood.
|
|
764
|
+
"AdaptiveMaxPool2d": nn.AdaptiveMaxPool2d,
|
|
765
|
+
# Per-channel expected (x, y) keypoints; its output is already flat.
|
|
766
|
+
"SpatialSoftmax": SpatialSoftmax,
|
|
716
767
|
"Flatten": nn.Flatten,
|
|
717
768
|
"BatchNorm2d": nn.BatchNorm2d,
|
|
769
|
+
# 3D variants for volumetric (e.g. occupancy-grid) backbones.
|
|
770
|
+
"Conv3d": nn.Conv3d,
|
|
771
|
+
"MaxPool3d": nn.MaxPool3d,
|
|
772
|
+
"AdaptiveAvgPool3d": nn.AdaptiveAvgPool3d,
|
|
773
|
+
"BatchNorm3d": nn.BatchNorm3d,
|
|
718
774
|
}
|
|
719
775
|
|
|
720
776
|
|
|
@@ -796,6 +852,66 @@ class CnnBackbone(nn.Module):
|
|
|
796
852
|
return self.cnn(x)
|
|
797
853
|
|
|
798
854
|
|
|
855
|
+
class Cnn3dBackbone(nn.Module):
|
|
856
|
+
"""Flat-vector **3D** CNN backbone (the volumetric analogue of :class:`CnnBackbone`).
|
|
857
|
+
|
|
858
|
+
Accepts a flat ``(B, D*H*W*C)`` observation, reshapes it internally to
|
|
859
|
+
``(B, C, D, H, W)`` (using the DHWC-ordered ``input_states`` shape — same
|
|
860
|
+
channels-last convention as :class:`CnnBackbone`, extended by a leading
|
|
861
|
+
depth axis), runs the chosen ``Conv3d`` stack, and returns a flat feature
|
|
862
|
+
vector suitable for an MLP head.
|
|
863
|
+
|
|
864
|
+
Unlike the 2D backbone there are no canned ``nature`` / ``impala`` presets
|
|
865
|
+
(those reference 2D image architectures), so only ``preset="custom"`` is
|
|
866
|
+
supported: build the conv stack from an explicit ``cfg.layers`` sequence
|
|
867
|
+
(e.g. ``Conv3d``/``MaxPool3d``/``AdaptiveAvgPool3d``/``Flatten``).
|
|
868
|
+
|
|
869
|
+
This mirrors the flat-vector contract of :class:`CnnBackbone` exactly, so it
|
|
870
|
+
drops straight into ``SharedRLModel`` / ``SeparatedRLModel`` with no
|
|
871
|
+
SKRL-side changes. It is the right primitive for occupancy grids and other
|
|
872
|
+
small volumetric observations, where treating the grid as a stack of 2D
|
|
873
|
+
channels would discard 3D locality.
|
|
874
|
+
"""
|
|
875
|
+
|
|
876
|
+
def __init__(self, cfg):
|
|
877
|
+
super().__init__()
|
|
878
|
+
self._input_shape = get_cnn_shape(cfg.input_states) # (D, H, W, C)
|
|
879
|
+
if len(self._input_shape) != 4:
|
|
880
|
+
raise ValueError(
|
|
881
|
+
"Cnn3dBackboneCfg.input_states must be a 4-tuple in DHWC order "
|
|
882
|
+
f"(D, H, W, C); got {self._input_shape!r}."
|
|
883
|
+
)
|
|
884
|
+
d, h, w, c = self._input_shape
|
|
885
|
+
self._flat_size = int(d) * int(h) * int(w) * int(c)
|
|
886
|
+
|
|
887
|
+
preset = getattr(cfg, "preset", "custom")
|
|
888
|
+
if preset == "custom":
|
|
889
|
+
if cfg.layers is None or len(cfg.layers) == 0:
|
|
890
|
+
raise ValueError(
|
|
891
|
+
"Cnn3dBackboneCfg.preset='custom' requires an explicit 'layers' sequence."
|
|
892
|
+
)
|
|
893
|
+
self.cnn = nn.Sequential(*list(cfg.layers))
|
|
894
|
+
else:
|
|
895
|
+
raise ValueError(
|
|
896
|
+
f"Unknown 3D CNN preset {preset!r}; only 'custom' is supported "
|
|
897
|
+
"(2D 'nature'/'impala' presets do not apply to volumetric input)."
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
def forward(self, input: torch.Tensor) -> torch.Tensor:
|
|
901
|
+
# Reshape a flat (B, D*H*W*C) input to (B, C, D, H, W). DHWC->CDHW permute
|
|
902
|
+
# mirrors the 2D backbone's HWC->CHW convention. Validate the flat size up
|
|
903
|
+
# front so a layout/offset mismatch fails with a clear message instead of
|
|
904
|
+
# an opaque ``.view()`` "shape is invalid for input of size" error.
|
|
905
|
+
if input.shape[-1] != self._flat_size:
|
|
906
|
+
raise ValueError(
|
|
907
|
+
f"Cnn3dBackbone expected a flat input of size {self._flat_size} "
|
|
908
|
+
f"(D*H*W*C for input_states={self._input_shape}), got "
|
|
909
|
+
f"{input.shape[-1]}."
|
|
910
|
+
)
|
|
911
|
+
x = input.view(-1, *self._input_shape).permute(0, 4, 1, 2, 3).contiguous()
|
|
912
|
+
return self.cnn(x)
|
|
913
|
+
|
|
914
|
+
|
|
799
915
|
class CnnMlp(nn.Module):
|
|
800
916
|
"""Convolutional backbone followed by an MLP (or ResidualMlp) trunk.
|
|
801
917
|
|
|
@@ -1016,6 +1132,7 @@ class GraphNetwork(nn.Module):
|
|
|
1016
1132
|
"concat",
|
|
1017
1133
|
"residual_add",
|
|
1018
1134
|
"cnn_backbone",
|
|
1135
|
+
"cnn3d_backbone",
|
|
1019
1136
|
"rnn",
|
|
1020
1137
|
"gru",
|
|
1021
1138
|
"lstm",
|
|
@@ -1236,6 +1353,7 @@ class GraphNetwork(nn.Module):
|
|
|
1236
1353
|
action_input_size: int,
|
|
1237
1354
|
):
|
|
1238
1355
|
from rlmodule.source.network_cfg import ( # local import to avoid cycle
|
|
1356
|
+
Cnn3dBackboneCfg,
|
|
1239
1357
|
CnnBackboneCfg,
|
|
1240
1358
|
GruCfg,
|
|
1241
1359
|
LstmCfg,
|
|
@@ -1386,6 +1504,43 @@ class GraphNetwork(nn.Module):
|
|
|
1386
1504
|
cnn_out = get_output_size(module, in_dim)
|
|
1387
1505
|
return module, int(cnn_out)
|
|
1388
1506
|
|
|
1507
|
+
if node_type == "cnn3d_backbone":
|
|
1508
|
+
in_dim = self._single_input_dim(node_id)
|
|
1509
|
+
# Accept either ``volume_shape`` or ``image_shape`` for symmetry with
|
|
1510
|
+
# the 2D node; both must be a 4-dim DHWC tuple ``(D, H, W, C)``.
|
|
1511
|
+
volume_shape = params.get("volume_shape")
|
|
1512
|
+
if volume_shape is None:
|
|
1513
|
+
volume_shape = params.get("image_shape")
|
|
1514
|
+
if volume_shape is None:
|
|
1515
|
+
raise ValueError(
|
|
1516
|
+
f"GraphNetwork cnn3d_backbone {node_id!r} requires "
|
|
1517
|
+
"params['volume_shape'] = (D, H, W, C)."
|
|
1518
|
+
)
|
|
1519
|
+
volume_shape = tuple(volume_shape)
|
|
1520
|
+
if len(volume_shape) != 4:
|
|
1521
|
+
raise ValueError(
|
|
1522
|
+
f"GraphNetwork cnn3d_backbone {node_id!r} volume_shape must "
|
|
1523
|
+
f"have 4 dims (D, H, W, C); got {volume_shape!r}."
|
|
1524
|
+
)
|
|
1525
|
+
# 3D backbone only supports the 'custom' preset; require explicit layers.
|
|
1526
|
+
layers_spec = params.get("layers")
|
|
1527
|
+
if not layers_spec:
|
|
1528
|
+
raise ValueError(
|
|
1529
|
+
f"GraphNetwork cnn3d_backbone {node_id!r} requires a 'layers' list "
|
|
1530
|
+
"(only the 'custom' preset is supported for 3D)."
|
|
1531
|
+
)
|
|
1532
|
+
custom_layers = _build_nn_layers_from_spec(layers_spec)
|
|
1533
|
+
sub_cfg = Cnn3dBackboneCfg(
|
|
1534
|
+
input_size=in_dim,
|
|
1535
|
+
input_states=volume_shape,
|
|
1536
|
+
preset="custom",
|
|
1537
|
+
layers=custom_layers,
|
|
1538
|
+
)
|
|
1539
|
+
module = Cnn3dBackbone(sub_cfg)
|
|
1540
|
+
# Probe the conv stack's flat output size.
|
|
1541
|
+
cnn_out = get_output_size(module, in_dim)
|
|
1542
|
+
return module, int(cnn_out)
|
|
1543
|
+
|
|
1389
1544
|
if node_type in ("rnn", "gru", "lstm"):
|
|
1390
1545
|
in_dim = self._single_input_dim(node_id)
|
|
1391
1546
|
# RnnBaseCfg marks all four of these as required (MISSING), so the
|
|
@@ -4,17 +4,18 @@ from collections.abc import Callable
|
|
|
4
4
|
from dataclasses import MISSING, field
|
|
5
5
|
import gym
|
|
6
6
|
import gymnasium
|
|
7
|
+
import logging
|
|
7
8
|
|
|
8
9
|
import torch
|
|
9
10
|
import torch.nn as nn
|
|
10
11
|
|
|
11
|
-
from rlmodule import logger
|
|
12
12
|
from rlmodule.source.network import (
|
|
13
13
|
CNN,
|
|
14
14
|
GRU,
|
|
15
15
|
LSTM,
|
|
16
16
|
MLP,
|
|
17
17
|
RNN,
|
|
18
|
+
Cnn3dBackbone,
|
|
18
19
|
CnnBackbone,
|
|
19
20
|
CnnMlp,
|
|
20
21
|
CnnProprioMlp,
|
|
@@ -25,6 +26,9 @@ from rlmodule.source.network import (
|
|
|
25
26
|
)
|
|
26
27
|
|
|
27
28
|
|
|
29
|
+
logger = logging.getLogger("rlmodule")
|
|
30
|
+
|
|
31
|
+
|
|
28
32
|
# use isaac-lab native configclass if available to avoid it being declared twice
|
|
29
33
|
try:
|
|
30
34
|
from omni.isaac.lab.utils import configclass
|
|
@@ -200,6 +204,31 @@ class CnnBackboneCfg(NetworkCfg):
|
|
|
200
204
|
"""Per-stage channel widths for the IMPALA preset."""
|
|
201
205
|
|
|
202
206
|
|
|
207
|
+
@configclass
|
|
208
|
+
class Cnn3dBackboneCfg(NetworkCfg):
|
|
209
|
+
"""Configuration for :class:`rlmodule.source.network.Cnn3dBackbone`.
|
|
210
|
+
|
|
211
|
+
The volumetric analogue of :class:`CnnBackboneCfg`. ``input_states`` must be
|
|
212
|
+
a **DHWC**-ordered shape (``(D, H, W, C)``): a leading depth axis prepended
|
|
213
|
+
to the 2D backbone's HWC convention. Internally the module reshapes the flat
|
|
214
|
+
observation to ``(B, C, D, H, W)``, runs the ``Conv3d`` stack, and returns a
|
|
215
|
+
flat feature vector suitable for an MLP head.
|
|
216
|
+
|
|
217
|
+
Only ``preset="custom"`` is supported (the 2D ``"nature"`` / ``"impala"``
|
|
218
|
+
presets reference image architectures that have no 3D counterpart here), so
|
|
219
|
+
``layers`` must be provided — e.g. a stack of ``Conv3d`` / ``MaxPool3d`` /
|
|
220
|
+
``AdaptiveAvgPool3d`` / ``Flatten`` modules.
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
module: type[Cnn3dBackbone] = Cnn3dBackbone
|
|
224
|
+
|
|
225
|
+
preset: str = "custom"
|
|
226
|
+
"""Backbone preset. Only ``"custom"`` is supported for 3D."""
|
|
227
|
+
|
|
228
|
+
layers: Sequence[nn.Module] = None
|
|
229
|
+
"""Explicit ``Conv3d`` layer stack; required for ``preset="custom"``."""
|
|
230
|
+
|
|
231
|
+
|
|
203
232
|
@configclass
|
|
204
233
|
class CnnMlpCfg(NetworkCfg):
|
|
205
234
|
"""Configuration for :class:`rlmodule.source.network.CnnMlp` (CNN trunk + MLP head).
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import math
|
|
1
2
|
from typing import Union
|
|
2
3
|
|
|
3
4
|
import gym
|
|
@@ -5,6 +6,7 @@ import gymnasium
|
|
|
5
6
|
|
|
6
7
|
import torch
|
|
7
8
|
import torch.nn as nn
|
|
9
|
+
import torch.nn.functional as F
|
|
8
10
|
from torch.distributions import Normal
|
|
9
11
|
|
|
10
12
|
from rlmodule.source.utils import get_output_size, get_space_size
|
|
@@ -80,6 +82,16 @@ class GaussianLayer(OutputLayer):
|
|
|
80
82
|
else torch.sum if cfg.reduction == "sum" else torch.prod if cfg.reduction == "prod" else None
|
|
81
83
|
)
|
|
82
84
|
|
|
85
|
+
self._squash = cfg.squash
|
|
86
|
+
if self._squash and cfg.output_activation is not nn.Identity:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
"squash=True bounds the sampled action with tanh, so the mean must be the pre-squash value: "
|
|
89
|
+
f"set output_activation=nn.Identity (got {cfg.output_activation})"
|
|
90
|
+
)
|
|
91
|
+
if cfg.entropy_reduction not in ["mean", "sum"]:
|
|
92
|
+
raise ValueError("entropy_reduction must be one of 'mean' or 'sum'")
|
|
93
|
+
self._entropy_reduction = cfg.entropy_reduction
|
|
94
|
+
|
|
83
95
|
self._net = nn.Sequential(nn.Linear(self._projection_in, self._output_size), cfg.output_activation())
|
|
84
96
|
|
|
85
97
|
self._log_std = cfg.log_std.class_type(device, self._projection_in, self._output_size, cfg.log_std)
|
|
@@ -121,6 +133,9 @@ class GaussianLayer(OutputLayer):
|
|
|
121
133
|
# distribution
|
|
122
134
|
self._distribution = Normal(mean_actions, self._cached_log_std.exp())
|
|
123
135
|
|
|
136
|
+
if self._squash:
|
|
137
|
+
return self._squashed_forward(mean_actions, taken_actions, outputs_dict)
|
|
138
|
+
|
|
124
139
|
# sample using the reparametrization trick
|
|
125
140
|
actions = self._distribution.rsample()
|
|
126
141
|
|
|
@@ -162,6 +177,42 @@ class GaussianLayer(OutputLayer):
|
|
|
162
177
|
# return output * self.instantiator_output_scale, self._log_std, {}
|
|
163
178
|
# return output, self._log_std, output_dict
|
|
164
179
|
|
|
180
|
+
# Keeps atanh finite for actions stored at the bound (float32 tanh saturates to 1).
|
|
181
|
+
_SQUASH_EPS = 1e-6
|
|
182
|
+
|
|
183
|
+
def _squashed_forward(self, mean_actions, taken_actions, outputs_dict):
|
|
184
|
+
"""Tanh-squashed Gaussian: ``u ~ N(mean, std)``, ``a = output_scale * tanh(u)``.
|
|
185
|
+
|
|
186
|
+
``self._distribution`` stays the pre-squash Normal over ``u``. ``mean_actions``
|
|
187
|
+
here is ``output_scale * net(x)`` (``output_activation`` is Identity), so
|
|
188
|
+
``mu = mean_actions / output_scale`` in ``u`` space.
|
|
189
|
+
|
|
190
|
+
``log_prob`` applies the change of variables per action dimension before the
|
|
191
|
+
reduction: ``log p(a) = log N(u) - log(output_scale) - log(1 - tanh(u)^2)``,
|
|
192
|
+
with ``log(1 - tanh(u)^2) = 2 * (log 2 - u - softplus(-2u))`` for stability.
|
|
193
|
+
"""
|
|
194
|
+
scale = self._output_scale
|
|
195
|
+
self._distribution = Normal(mean_actions / scale, self._cached_log_std.exp())
|
|
196
|
+
|
|
197
|
+
if taken_actions is None:
|
|
198
|
+
pre_squash = self._distribution.rsample()
|
|
199
|
+
actions = scale * torch.tanh(pre_squash)
|
|
200
|
+
else:
|
|
201
|
+
actions = taken_actions
|
|
202
|
+
bound = 1.0 - self._SQUASH_EPS
|
|
203
|
+
pre_squash = torch.atanh(torch.clamp(taken_actions / scale, -bound, bound))
|
|
204
|
+
|
|
205
|
+
log_det = 2.0 * (math.log(2.0) - pre_squash - F.softplus(-2.0 * pre_squash)) + math.log(scale)
|
|
206
|
+
log_prob = self._distribution.log_prob(pre_squash) - log_det
|
|
207
|
+
if self._reduction is not None:
|
|
208
|
+
log_prob = self._reduction(log_prob, dim=-1)
|
|
209
|
+
if log_prob.dim() != actions.dim():
|
|
210
|
+
log_prob = log_prob.unsqueeze(-1)
|
|
211
|
+
|
|
212
|
+
outputs_dict["mean_actions"] = scale * torch.tanh(mean_actions / scale)
|
|
213
|
+
outputs_dict["log_prob"] = log_prob
|
|
214
|
+
return actions, outputs_dict
|
|
215
|
+
|
|
165
216
|
def get_entropy(self, role: str = "") -> torch.Tensor:
|
|
166
217
|
"""Compute and return the entropy of the model
|
|
167
218
|
|
|
@@ -174,11 +225,17 @@ class GaussianLayer(OutputLayer):
|
|
|
174
225
|
|
|
175
226
|
>>> entropy = model.get_entropy()
|
|
176
227
|
>>> print(entropy.shape)
|
|
177
|
-
torch.Size([4096, 8])
|
|
228
|
+
torch.Size([4096, 8]) # entropy_reduction="mean"; [4096, 1] with "sum"
|
|
229
|
+
|
|
230
|
+
With ``squash=True`` this is the entropy of the pre-squash Gaussian: the
|
|
231
|
+
squashed distribution has no closed form (SAC uses ``-log_prob`` instead).
|
|
178
232
|
"""
|
|
179
233
|
if self._distribution is None:
|
|
180
234
|
return torch.tensor(0.0, device=self.device)
|
|
181
|
-
|
|
235
|
+
entropy = self._distribution.entropy().to(self.device)
|
|
236
|
+
if self._entropy_reduction == "sum":
|
|
237
|
+
entropy = entropy.sum(dim=-1, keepdim=True)
|
|
238
|
+
return entropy
|
|
182
239
|
|
|
183
240
|
def get_log_std(self, role: str = "") -> torch.Tensor:
|
|
184
241
|
"""Return the log standard deviation of the model
|
|
@@ -74,6 +74,24 @@ class GaussianLayerCfg(OutputLayerCfg):
|
|
|
74
74
|
log_std: LogStdCfg = ParameterLogStdCfg()
|
|
75
75
|
"""Module to specify how standard deviation is computed."""
|
|
76
76
|
|
|
77
|
+
squash: bool = False
|
|
78
|
+
"""Tanh-squash the sampled action: ``a = output_scale * tanh(u)``, ``u ~ N(mean, std)``.
|
|
79
|
+
|
|
80
|
+
``log_prob`` includes the tanh change-of-variables correction, so it is the density of
|
|
81
|
+
the bounded action the env executes. This is the SAC actor parameterisation: without it
|
|
82
|
+
an unbounded Gaussian's entropy term can grow ``std`` without limit while the env clamps
|
|
83
|
+
every sample to its bound. Requires ``output_activation=nn.Identity``; ``clip_actions``
|
|
84
|
+
is not applied (the action is already within ``[-output_scale, output_scale]``).
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
entropy_reduction: str = "mean"
|
|
88
|
+
"""How ``get_entropy`` reduces over action dimensions: ``"mean"`` keeps the per-dimension
|
|
89
|
+
``(num_samples, num_actions)`` entropy, which skrl's PPO/A2C/RPO then ``.mean()`` over, so
|
|
90
|
+
the bonus is the average per-dimension entropy (skrl's convention). ``"sum"`` returns the
|
|
91
|
+
joint entropy ``(num_samples, 1)`` (rsl_rl's convention), making the same
|
|
92
|
+
``entropy_loss_scale`` ``num_actions`` times stronger.
|
|
93
|
+
"""
|
|
94
|
+
|
|
77
95
|
|
|
78
96
|
@configclass
|
|
79
97
|
class DeterministicLayerCfg(OutputLayerCfg):
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
from typing import Any, Mapping, Optional, Tuple, Union
|
|
2
2
|
|
|
3
|
+
import logging
|
|
4
|
+
|
|
3
5
|
import torch
|
|
4
6
|
import torch.nn as nn
|
|
5
7
|
|
|
6
|
-
from rlmodule import logger
|
|
7
|
-
|
|
8
8
|
from .model import Model
|
|
9
9
|
|
|
10
10
|
|
|
11
|
+
logger = logging.getLogger("rlmodule")
|
|
12
|
+
|
|
13
|
+
|
|
11
14
|
# SKRL 2.x roles whose canonical trunk input is the actor's observation. Every
|
|
12
15
|
# other role (``value``, ``critic_*``, ``target_critic_*``) is a critic and
|
|
13
16
|
# reads ``states``.
|
|
@@ -40,10 +40,12 @@ rlmodule/source/nvidia_utils/dict.py
|
|
|
40
40
|
rlmodule/source/nvidia_utils/string.py
|
|
41
41
|
tests/test_build_model_integration.py
|
|
42
42
|
tests/test_cnn_backbone.py
|
|
43
|
+
tests/test_combined_log_std.py
|
|
43
44
|
tests/test_graph_network.py
|
|
44
45
|
tests/test_head_mlp.py
|
|
45
46
|
tests/test_mlp_layer_norm.py
|
|
46
47
|
tests/test_residual_mlp.py
|
|
47
48
|
tests/test_rnn_memory_and_correctness.py
|
|
48
49
|
tests/test_skrl_startup.py
|
|
49
|
-
tests/test_skrl_train_smoke.py
|
|
50
|
+
tests/test_skrl_train_smoke.py
|
|
51
|
+
tests/test_squashed_gaussian.py
|
|
@@ -11,8 +11,9 @@ import pytest
|
|
|
11
11
|
import torch
|
|
12
12
|
import torch.nn as nn
|
|
13
13
|
|
|
14
|
-
from rlmodule.source.network import CnnBackbone, CnnMlp, CnnProprioMlp
|
|
14
|
+
from rlmodule.source.network import Cnn3dBackbone, CnnBackbone, CnnMlp, CnnProprioMlp
|
|
15
15
|
from rlmodule.source.network_cfg import (
|
|
16
|
+
Cnn3dBackboneCfg,
|
|
16
17
|
CnnBackboneCfg,
|
|
17
18
|
CnnMlpCfg,
|
|
18
19
|
CnnProprioMlpCfg,
|
|
@@ -58,6 +59,121 @@ def test_cnn_backbone_custom_preset_uses_user_layers():
|
|
|
58
59
|
assert out.shape[0] == 2 and out.dim() == 2
|
|
59
60
|
|
|
60
61
|
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# Cnn3dBackbone (volumetric / occupancy-grid CNN, flat-vector contract)
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _occ_3d_layers():
|
|
68
|
+
"""A small Conv3d stack that flattens to a fixed-size embedding regardless of
|
|
69
|
+
the (possibly anisotropic) input volume dims."""
|
|
70
|
+
return [
|
|
71
|
+
nn.Conv3d(1, 8, kernel_size=3, padding=1),
|
|
72
|
+
nn.ELU(),
|
|
73
|
+
nn.MaxPool3d(kernel_size=2, ceil_mode=True),
|
|
74
|
+
nn.Conv3d(8, 16, kernel_size=3, padding=1),
|
|
75
|
+
nn.ELU(),
|
|
76
|
+
nn.AdaptiveAvgPool3d((2, 2, 2)),
|
|
77
|
+
nn.Flatten(),
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pytest.mark.parametrize("volume", [(5, 5, 5, 1), (8, 8, 4, 1), (6, 4, 3, 1)])
|
|
82
|
+
def test_cnn3d_backbone_custom_forward(volume):
|
|
83
|
+
cfg = Cnn3dBackboneCfg(input_states=volume, preset="custom", layers=_occ_3d_layers())
|
|
84
|
+
net = Cnn3dBackbone(cfg)
|
|
85
|
+
d, h, w, c = volume
|
|
86
|
+
flat = torch.randn(3, d * h * w * c)
|
|
87
|
+
out = net(flat)
|
|
88
|
+
assert out.dim() == 2
|
|
89
|
+
assert out.shape[0] == 3
|
|
90
|
+
# AdaptiveAvgPool3d((2,2,2)) over 16 channels -> 16*2*2*2 = 128 features.
|
|
91
|
+
assert out.shape[1] == 128
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_cnn3d_backbone_custom_preset_requires_layers():
|
|
95
|
+
with pytest.raises(ValueError):
|
|
96
|
+
Cnn3dBackbone(Cnn3dBackboneCfg(input_states=(5, 5, 5, 1), preset="custom"))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_cnn3d_backbone_rejects_2d_presets():
|
|
100
|
+
for preset in ("nature", "impala", "does-not-exist"):
|
|
101
|
+
with pytest.raises(ValueError):
|
|
102
|
+
Cnn3dBackbone(
|
|
103
|
+
Cnn3dBackboneCfg(
|
|
104
|
+
input_states=(5, 5, 5, 1), preset=preset, layers=_occ_3d_layers()
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_cnn3d_backbone_rejects_wrong_flat_size():
|
|
110
|
+
cfg = Cnn3dBackboneCfg(input_states=(5, 5, 5, 1), preset="custom", layers=_occ_3d_layers())
|
|
111
|
+
net = Cnn3dBackbone(cfg)
|
|
112
|
+
bad = torch.randn(2, 5 * 5 * 5 * 1 + 7) # off-by-7 flat slice
|
|
113
|
+
with pytest.raises(ValueError, match="expected a flat input of size 125"):
|
|
114
|
+
net(bad)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_cnn3d_backbone_requires_4d_input_states():
|
|
118
|
+
with pytest.raises(ValueError, match="4-tuple in DHWC"):
|
|
119
|
+
Cnn3dBackbone(
|
|
120
|
+
Cnn3dBackboneCfg(input_states=(5, 5, 1), preset="custom", layers=_occ_3d_layers())
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_cnn3d_backbone_dhwc_reshape_is_correct():
|
|
125
|
+
"""A flat DHWC vector must map to (B, C, D, H, W) so 3D locality is preserved.
|
|
126
|
+
|
|
127
|
+
We use an identity-ish Conv3d (Flatten only) and check the permutation maps
|
|
128
|
+
the right elements: element at flat index for (d, h, w) with C=1 must land at
|
|
129
|
+
spatial position (d, h, w) of the volume the conv sees.
|
|
130
|
+
"""
|
|
131
|
+
d, h, w, c = 2, 3, 4, 1
|
|
132
|
+
net = Cnn3dBackbone(
|
|
133
|
+
Cnn3dBackboneCfg(
|
|
134
|
+
input_states=(d, h, w, c),
|
|
135
|
+
preset="custom",
|
|
136
|
+
# AvgPool over the H,W plane per depth slice so each output element is
|
|
137
|
+
# the mean of one (h,w) plane -> lets us verify the depth axis ordering.
|
|
138
|
+
layers=[nn.AdaptiveAvgPool3d((d, 1, 1)), nn.Flatten()],
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
# Build a volume where every voxel in depth-slice k has value k.
|
|
142
|
+
vol = torch.zeros(1, d, h, w, c)
|
|
143
|
+
for k in range(d):
|
|
144
|
+
vol[:, k] = float(k)
|
|
145
|
+
flat = vol.reshape(1, -1)
|
|
146
|
+
out = net(flat) # shape (1, d)
|
|
147
|
+
assert out.shape == (1, d)
|
|
148
|
+
assert torch.allclose(out[0], torch.arange(d, dtype=torch.float32))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_cnn3d_backbone_backward_reaches_input_and_params():
|
|
152
|
+
torch.manual_seed(0)
|
|
153
|
+
volume = (5, 5, 5, 1)
|
|
154
|
+
cfg = Cnn3dBackboneCfg(input_states=volume, preset="custom", layers=_occ_3d_layers())
|
|
155
|
+
net = Cnn3dBackbone(cfg)
|
|
156
|
+
d, h, w, c = volume
|
|
157
|
+
flat = torch.randn(2, d * h * w * c, requires_grad=True)
|
|
158
|
+
out = net(flat)
|
|
159
|
+
out.sum().backward()
|
|
160
|
+
|
|
161
|
+
assert flat.grad is not None
|
|
162
|
+
assert torch.isfinite(flat.grad).all()
|
|
163
|
+
conv_params = [
|
|
164
|
+
(name, p) for name, p in net.named_parameters() if isinstance_conv3d_param(name)
|
|
165
|
+
]
|
|
166
|
+
assert conv_params, "expected Conv3d parameters in the backbone"
|
|
167
|
+
for name, p in net.named_parameters():
|
|
168
|
+
assert p.grad is not None, f"{name} has no grad"
|
|
169
|
+
assert torch.isfinite(p.grad).all()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def isinstance_conv3d_param(name: str) -> bool:
|
|
173
|
+
# Conv3d submodules live at indices 0 and 3 of the sequential stack here.
|
|
174
|
+
return name.startswith("cnn.0.") or name.startswith("cnn.3.")
|
|
175
|
+
|
|
176
|
+
|
|
61
177
|
def test_cnn_mlp_with_layer_norm_trunk():
|
|
62
178
|
cfg = CnnMlpCfg(
|
|
63
179
|
input_states=(84, 84, 4),
|
|
@@ -309,3 +425,91 @@ def test_visual_amp_dtype_fp16_logs_warning_on_nonfinite_output():
|
|
|
309
425
|
assert net._nonfinite_visual_warned is True
|
|
310
426
|
_ = net(x)
|
|
311
427
|
assert net._nonfinite_visual_warned is True
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# ---------------------------------------------------------------------------
|
|
431
|
+
# AdaptiveMaxPool2d / SpatialSoftmax custom layers
|
|
432
|
+
# ---------------------------------------------------------------------------
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def test_spatial_softmax_finds_a_single_hot_pixel():
|
|
436
|
+
from rlmodule.source.network import SpatialSoftmax
|
|
437
|
+
|
|
438
|
+
layer = SpatialSoftmax(temperature=0.01, learnable_temperature=False)
|
|
439
|
+
x = torch.zeros(2, 3, 5, 9)
|
|
440
|
+
x[0, 0, 0, 0] = 10.0 # top-left
|
|
441
|
+
x[0, 1, 4, 8] = 10.0 # bottom-right
|
|
442
|
+
x[0, 2, 2, 4] = 10.0 # centre
|
|
443
|
+
x[1, 0, 1, 6] = 10.0 # row 1 of 5 -> y -0.5; column 6 of 9 -> x 0.5
|
|
444
|
+
out = layer(x)
|
|
445
|
+
assert out.shape == (2, 6)
|
|
446
|
+
torch.testing.assert_close(out[0], torch.tensor([-1.0, -1.0, 1.0, 1.0, 0.0, 0.0]), atol=1e-4, rtol=0)
|
|
447
|
+
torch.testing.assert_close(out[1, :2], torch.tensor([0.5, -0.5]), atol=1e-4, rtol=0)
|
|
448
|
+
# A flat map spreads the attention uniformly: the keypoint is the centre.
|
|
449
|
+
torch.testing.assert_close(out[1, 2:], torch.zeros(4), atol=1e-6, rtol=0)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def test_spatial_softmax_temperature_is_trainable_and_positive():
|
|
453
|
+
from rlmodule.source.network import SpatialSoftmax
|
|
454
|
+
|
|
455
|
+
layer = SpatialSoftmax(temperature=0.5)
|
|
456
|
+
assert float(layer.log_temperature.detach().exp()) == pytest.approx(0.5)
|
|
457
|
+
x = torch.randn(4, 2, 6, 6, requires_grad=True)
|
|
458
|
+
layer(x).pow(2).sum().backward()
|
|
459
|
+
assert x.grad is not None and torch.isfinite(x.grad).all()
|
|
460
|
+
assert layer.log_temperature.grad is not None
|
|
461
|
+
fixed = SpatialSoftmax(learnable_temperature=False)
|
|
462
|
+
assert not list(fixed.parameters())
|
|
463
|
+
with pytest.raises(ValueError):
|
|
464
|
+
SpatialSoftmax(temperature=0.0)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def test_spatial_softmax_keeps_the_input_dtype():
|
|
468
|
+
from rlmodule.source.network import SpatialSoftmax
|
|
469
|
+
|
|
470
|
+
out = SpatialSoftmax()(torch.randn(2, 3, 4, 4, dtype=torch.bfloat16))
|
|
471
|
+
assert out.dtype == torch.bfloat16
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def test_adaptive_max_pool_keeps_a_small_peak_that_avg_pool_dilutes():
|
|
475
|
+
from rlmodule.source.network import _build_nn_layers_from_spec
|
|
476
|
+
|
|
477
|
+
mx, = _build_nn_layers_from_spec([{"type": "AdaptiveMaxPool2d", "output_size": [2, 2]}])
|
|
478
|
+
av, = _build_nn_layers_from_spec([{"type": "AdaptiveAvgPool2d", "output_size": [2, 2]}])
|
|
479
|
+
x = torch.zeros(1, 1, 8, 8)
|
|
480
|
+
x[0, 0, 1, 1] = 1.0
|
|
481
|
+
assert float(mx(x)[0, 0, 0, 0]) == 1.0
|
|
482
|
+
assert float(av(x)[0, 0, 0, 0]) == pytest.approx(1 / 16)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def test_cnn_backbone_custom_spec_with_spatial_softmax_and_max_pool_branches():
|
|
486
|
+
"""Two branches over one 32x48 depth image, as a GraphNetwork builds them."""
|
|
487
|
+
from rlmodule.source.network import GraphNetwork
|
|
488
|
+
from rlmodule.source.network_cfg import GraphNetworkCfg
|
|
489
|
+
|
|
490
|
+
h, w = 32, 48
|
|
491
|
+
conv = {"type": "Conv2d", "in_channels": 1, "out_channels": 8, "kernel_size": 3, "padding": 1}
|
|
492
|
+
cfg = GraphNetworkCfg(
|
|
493
|
+
nodes=[
|
|
494
|
+
{"id": "img", "type": "observation_input",
|
|
495
|
+
"params": {"indices": list(range(h * w)), "dim": h * w, "shape": [h, w, 1], "modality": "image"}},
|
|
496
|
+
{"id": "keypoints", "type": "cnn_backbone",
|
|
497
|
+
"params": {"preset": "custom", "image_shape": [h, w, 1],
|
|
498
|
+
"layers": [conv, {"type": "ELU"}, {"type": "SpatialSoftmax", "temperature": 1.0}]}},
|
|
499
|
+
{"id": "maxpool", "type": "cnn_backbone",
|
|
500
|
+
"params": {"preset": "custom", "image_shape": [h, w, 1],
|
|
501
|
+
"layers": [conv, {"type": "ELU"}, {"type": "AdaptiveMaxPool2d", "output_size": [4, 6]},
|
|
502
|
+
{"type": "Flatten"}]}},
|
|
503
|
+
{"id": "cat", "type": "concat", "params": {}},
|
|
504
|
+
],
|
|
505
|
+
edges=[{"from": "img", "to": "keypoints"}, {"from": "img", "to": "maxpool"},
|
|
506
|
+
{"from": "keypoints", "to": "cat"}, {"from": "maxpool", "to": "cat"}],
|
|
507
|
+
output_node_id="cat",
|
|
508
|
+
flat_input_size=h * w,
|
|
509
|
+
)
|
|
510
|
+
net = GraphNetwork(cfg)
|
|
511
|
+
out = net(torch.rand(3, h * w))
|
|
512
|
+
out = out[0] if isinstance(out, tuple) else out
|
|
513
|
+
assert out.shape == (3, 8 * 2 + 8 * 4 * 6)
|
|
514
|
+
assert torch.isfinite(out).all()
|
|
515
|
+
assert out[:, :16].abs().max() <= 1.0
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Regression tests for ``CombinedLogStd`` parameter registration.
|
|
2
|
+
|
|
3
|
+
``CombinedLogStd`` previously stored its sub-modules in a plain list assigned
|
|
4
|
+
to ``self._modules`` — which is ``nn.Module``'s *internal* child-module
|
|
5
|
+
registry. That silently prevented the sub-LogStd parameters from being
|
|
6
|
+
registered, so they never reached the optimizer and the policy std stayed
|
|
7
|
+
frozen. These tests pin the fix: sub-module parameters must be discoverable via
|
|
8
|
+
``named_parameters()`` and must receive gradients.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import torch
|
|
12
|
+
|
|
13
|
+
from rlmodule.source.log_std import CombinedLogStd
|
|
14
|
+
from rlmodule.source.log_std_cfg import CombinedLogStdCfg, ParameterLogStdCfg
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _combined_cfg() -> CombinedLogStdCfg:
|
|
18
|
+
return CombinedLogStdCfg(
|
|
19
|
+
combination_method="max",
|
|
20
|
+
combined_modules=[
|
|
21
|
+
ParameterLogStdCfg(initial_log_std=0.5),
|
|
22
|
+
ParameterLogStdCfg(initial_log_std=-0.5),
|
|
23
|
+
],
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_combined_log_std_registers_submodule_parameters() -> None:
|
|
28
|
+
module = CombinedLogStd("cpu", input_size=8, output_size=4, cfg=_combined_cfg())
|
|
29
|
+
log_params = [name for name, _ in module.named_parameters() if "_log_parameter" in name]
|
|
30
|
+
# Two ParameterLogStd sub-modules -> two registered learnable tensors.
|
|
31
|
+
assert len(log_params) == 2, f"expected 2 registered log_std params, got {log_params}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_combined_log_std_submodule_params_receive_grad() -> None:
|
|
35
|
+
module = CombinedLogStd("cpu", input_size=8, output_size=4, cfg=_combined_cfg())
|
|
36
|
+
out = module(torch.zeros(3, 8))
|
|
37
|
+
out.sum().backward()
|
|
38
|
+
# The element-wise ``max`` routes gradient to the dominant sub-module; the
|
|
39
|
+
# point is that the registered parameters are on the autograd graph at all
|
|
40
|
+
# (a plain-list assignment detached them entirely).
|
|
41
|
+
graphed = [p for name, p in module.named_parameters() if "_log_parameter" in name and p.grad is not None]
|
|
42
|
+
assert graphed, "no CombinedLogStd sub-parameter received a gradient"
|
|
@@ -309,6 +309,75 @@ def test_graph_network_cnn_backbone_accepts_image_slice():
|
|
|
309
309
|
assert torch.isfinite(out).all()
|
|
310
310
|
|
|
311
311
|
|
|
312
|
+
def test_graph_network_cnn3d_backbone_accepts_volume_slice():
|
|
313
|
+
d, h, w, c = 5, 5, 5, 1
|
|
314
|
+
obs_dim = d * h * w * c
|
|
315
|
+
cfg = GraphNetworkCfg(
|
|
316
|
+
nodes=[
|
|
317
|
+
{
|
|
318
|
+
"id": "obs",
|
|
319
|
+
"type": "observation_input",
|
|
320
|
+
"params": {
|
|
321
|
+
"indices": list(range(obs_dim)),
|
|
322
|
+
"dim": obs_dim,
|
|
323
|
+
"shape": [d, h, w, c],
|
|
324
|
+
"modality": "volume",
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
"id": "cnn3d",
|
|
329
|
+
"type": "cnn3d_backbone",
|
|
330
|
+
"params": {
|
|
331
|
+
"volume_shape": [d, h, w, c],
|
|
332
|
+
"layers": [
|
|
333
|
+
{"type": "Conv3d", "in_channels": 1, "out_channels": 8,
|
|
334
|
+
"kernel_size": 3, "padding": 1},
|
|
335
|
+
{"type": "ELU"},
|
|
336
|
+
{"type": "AdaptiveAvgPool3d", "output_size": [2, 2, 2]},
|
|
337
|
+
{"type": "Flatten"},
|
|
338
|
+
],
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
],
|
|
342
|
+
edges=[{"from": "obs", "to": "cnn3d"}],
|
|
343
|
+
output_node_id="cnn3d",
|
|
344
|
+
flat_input_size=obs_dim,
|
|
345
|
+
)
|
|
346
|
+
net = GraphNetwork(cfg)
|
|
347
|
+
# 8 channels * 2*2*2 = 64 features.
|
|
348
|
+
assert net.output_dim == 64
|
|
349
|
+
flat = torch.randn(2, obs_dim)
|
|
350
|
+
out, _ = _run_forward(net, flat)
|
|
351
|
+
assert out.shape == (2, 64)
|
|
352
|
+
assert torch.isfinite(out).all()
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def test_graph_network_cnn3d_backbone_requires_4d_volume_shape():
|
|
356
|
+
obs_dim = 25
|
|
357
|
+
cfg = GraphNetworkCfg(
|
|
358
|
+
nodes=[
|
|
359
|
+
{
|
|
360
|
+
"id": "obs",
|
|
361
|
+
"type": "observation_input",
|
|
362
|
+
"params": {"indices": list(range(obs_dim)), "dim": obs_dim},
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
"id": "cnn3d",
|
|
366
|
+
"type": "cnn3d_backbone",
|
|
367
|
+
"params": {
|
|
368
|
+
"volume_shape": [5, 5, 1], # only 3 dims -> invalid
|
|
369
|
+
"layers": [{"type": "Flatten"}],
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
],
|
|
373
|
+
edges=[{"from": "obs", "to": "cnn3d"}],
|
|
374
|
+
output_node_id="cnn3d",
|
|
375
|
+
flat_input_size=obs_dim,
|
|
376
|
+
)
|
|
377
|
+
with pytest.raises(ValueError, match="4 dims"):
|
|
378
|
+
GraphNetwork(cfg)
|
|
379
|
+
|
|
380
|
+
|
|
312
381
|
def test_graph_network_output_dim_is_exposed_after_construction():
|
|
313
382
|
cfg = GraphNetworkCfg(
|
|
314
383
|
nodes=[
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Tests for ``GaussianLayerCfg.squash`` (tanh-squashed Gaussian) and
|
|
2
|
+
``GaussianLayerCfg.entropy_reduction``.
|
|
3
|
+
|
|
4
|
+
An unbounded Gaussian head trained with SAC can grow ``std`` without limit
|
|
5
|
+
while the env clamps every sample to its bound: the entropy term keeps
|
|
6
|
+
rewarding a spread the env never executes. ``squash=True`` bounds the action
|
|
7
|
+
with ``tanh`` and makes ``log_prob`` the density of that bounded action.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
import torch
|
|
14
|
+
import torch.nn as nn
|
|
15
|
+
from torch.distributions import AffineTransform, Normal, TanhTransform, TransformedDistribution
|
|
16
|
+
|
|
17
|
+
from rlmodule.source.log_std_cfg import ParameterLogStdCfg
|
|
18
|
+
from rlmodule.source.output_layer import GaussianLayer
|
|
19
|
+
from rlmodule.source.output_layer_cfg import GaussianLayerCfg
|
|
20
|
+
|
|
21
|
+
N_IN, N_ACT = 6, 4
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _layer(**kw) -> GaussianLayer:
|
|
25
|
+
torch.manual_seed(0)
|
|
26
|
+
kw.setdefault("log_std", ParameterLogStdCfg(initial_log_std=1.5))
|
|
27
|
+
cfg = GaussianLayerCfg(output_size=N_ACT, **kw)
|
|
28
|
+
return GaussianLayer("cpu", N_IN, cfg)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _squashed(**kw) -> GaussianLayer:
|
|
32
|
+
return _layer(squash=True, output_activation=nn.Identity, **kw)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_squashed_actions_stay_within_output_scale() -> None:
|
|
36
|
+
layer = _squashed(output_scale=2.0)
|
|
37
|
+
actions, _ = layer(torch.randn(4096, N_IN) * 10, None, {})
|
|
38
|
+
assert actions.abs().max() <= 2.0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.mark.parametrize("scale", [1.0, 2.0])
|
|
42
|
+
def test_squashed_log_prob_matches_torch_transformed_distribution(scale: float) -> None:
|
|
43
|
+
layer = _squashed(output_scale=scale)
|
|
44
|
+
x = torch.randn(256, N_IN)
|
|
45
|
+
actions, out = layer(x, None, {})
|
|
46
|
+
base = Normal(layer._net(x), layer._cached_log_std.exp())
|
|
47
|
+
ref = TransformedDistribution(base, [TanhTransform(), AffineTransform(0.0, scale)])
|
|
48
|
+
# Keep away from the bound, where the reference's atanh loses precision.
|
|
49
|
+
ok = (actions.abs() < 0.99 * scale).all(dim=-1)
|
|
50
|
+
expected = ref.log_prob(actions).sum(dim=-1, keepdim=True)
|
|
51
|
+
torch.testing.assert_close(out["log_prob"][ok], expected[ok], atol=1e-4, rtol=1e-4)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_squashed_log_prob_of_taken_actions_matches_the_sampled_one() -> None:
|
|
55
|
+
# PPO-style re-evaluation: feeding the sampled action back must give the same density.
|
|
56
|
+
layer = _squashed()
|
|
57
|
+
x = torch.randn(512, N_IN)
|
|
58
|
+
actions, out = layer(x, None, {})
|
|
59
|
+
_, again = layer(x, actions, {})
|
|
60
|
+
ok = (actions.abs() < 0.999).all(dim=-1)
|
|
61
|
+
torch.testing.assert_close(again["log_prob"][ok], out["log_prob"][ok], atol=1e-3, rtol=1e-4)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_squashed_log_prob_is_finite_at_the_bound() -> None:
|
|
65
|
+
layer = _squashed()
|
|
66
|
+
x = torch.randn(3, N_IN)
|
|
67
|
+
_, out = layer(x, torch.ones(3, N_ACT), {})
|
|
68
|
+
assert torch.isfinite(out["log_prob"]).all()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_squashed_mean_action_is_tanh_of_the_mean() -> None:
|
|
72
|
+
layer = _squashed(output_scale=2.0)
|
|
73
|
+
x = torch.randn(8, N_IN)
|
|
74
|
+
_, out = layer(x, None, {})
|
|
75
|
+
torch.testing.assert_close(out["mean_actions"], 2.0 * torch.tanh(layer._net(x)))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_saturating_the_bound_lowers_the_entropy_estimate() -> None:
|
|
79
|
+
# The SAC failure: without squashing, a huge std reads as huge entropy even though
|
|
80
|
+
# the env executes +-1. With squashing, a very wide std piles samples at the bound,
|
|
81
|
+
# where the density is high, so -log_prob falls instead of rising.
|
|
82
|
+
x = torch.zeros(20000, N_IN)
|
|
83
|
+
ent = {}
|
|
84
|
+
for log_std in (0.0, 3.0):
|
|
85
|
+
layer = _squashed(log_std=ParameterLogStdCfg(initial_log_std=log_std, max_log_std=4.0))
|
|
86
|
+
_, out = layer(x, None, {})
|
|
87
|
+
ent[log_std] = -out["log_prob"].mean().item()
|
|
88
|
+
assert ent[3.0] < ent[0.0]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_squash_requires_identity_output_activation() -> None:
|
|
92
|
+
with pytest.raises(ValueError, match="output_activation=nn.Identity"):
|
|
93
|
+
_layer(squash=True)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_unsquashed_layer_is_unchanged() -> None:
|
|
97
|
+
layer = _layer()
|
|
98
|
+
x = torch.randn(64, N_IN)
|
|
99
|
+
torch.manual_seed(1)
|
|
100
|
+
actions, out = layer(x, None, {})
|
|
101
|
+
mean = layer._net(x)
|
|
102
|
+
ref = Normal(mean, layer._cached_log_std.exp()).log_prob(actions).sum(dim=-1, keepdim=True)
|
|
103
|
+
torch.testing.assert_close(out["log_prob"], ref)
|
|
104
|
+
torch.testing.assert_close(out["mean_actions"], mean)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_entropy_reduction_defaults_to_per_dimension() -> None:
|
|
108
|
+
layer = _layer()
|
|
109
|
+
layer(torch.randn(5, N_IN), None, {})
|
|
110
|
+
assert layer.get_entropy().shape == (5, N_ACT)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_entropy_reduction_sum_is_the_joint_entropy() -> None:
|
|
114
|
+
layer = _layer(entropy_reduction="sum", log_std=ParameterLogStdCfg(initial_log_std=0.0))
|
|
115
|
+
layer(torch.randn(5, N_IN), None, {})
|
|
116
|
+
entropy = layer.get_entropy()
|
|
117
|
+
assert entropy.shape == (5, 1)
|
|
118
|
+
torch.testing.assert_close(entropy, torch.full((5, 1), N_ACT * 0.5 * math.log(2 * math.pi * math.e)))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/skrl/torch/examples/gymnasium/cnn_experimental.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/skrl/torch/examples/gymnasium/skrl_no_module_rnn.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|