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.
Files changed (53) hide show
  1. {rlmodule-0.1.7/rlmodule.egg-info → rlmodule-0.2.0}/PKG-INFO +1 -1
  2. {rlmodule-0.1.7 → rlmodule-0.2.0}/pyproject.toml +1 -1
  3. {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/skrl/torch/network/__init__.py +4 -0
  4. {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/source/log_std.py +14 -7
  5. {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/source/model.py +4 -1
  6. {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/source/network.py +156 -1
  7. {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/source/network_cfg.py +30 -1
  8. {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/source/output_layer.py +59 -2
  9. {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/source/output_layer_cfg.py +18 -0
  10. {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/source/rlmodel.py +5 -2
  11. {rlmodule-0.1.7 → rlmodule-0.2.0/rlmodule.egg-info}/PKG-INFO +1 -1
  12. {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule.egg-info/SOURCES.txt +3 -1
  13. {rlmodule-0.1.7 → rlmodule-0.2.0}/tests/test_cnn_backbone.py +205 -1
  14. rlmodule-0.2.0/tests/test_combined_log_std.py +42 -0
  15. {rlmodule-0.1.7 → rlmodule-0.2.0}/tests/test_graph_network.py +69 -0
  16. rlmodule-0.2.0/tests/test_squashed_gaussian.py +118 -0
  17. {rlmodule-0.1.7 → rlmodule-0.2.0}/LICENSE +0 -0
  18. {rlmodule-0.1.7 → rlmodule-0.2.0}/README.md +0 -0
  19. {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/__init__.py +0 -0
  20. {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/skrl/__init__.py +0 -0
  21. {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/skrl/torch/__init__.py +0 -0
  22. {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/skrl/torch/examples/gymnasium/cnn_experimental.py +0 -0
  23. {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/skrl/torch/examples/gymnasium/pendulum_ppo_mlp_separate_model.py +0 -0
  24. {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/skrl/torch/examples/gymnasium/pendulum_ppo_mlp_shared_model.py +0 -0
  25. {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/skrl/torch/examples/gymnasium/pendulum_ppo_rnn_forwarded_input_shared_model.py +0 -0
  26. {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/skrl/torch/examples/gymnasium/pendulum_ppo_rnn_separate_model.py +0 -0
  27. {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/skrl/torch/examples/gymnasium/pendulum_ppo_rnn_shared_model.py +0 -0
  28. {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/skrl/torch/examples/gymnasium/pendulum_sac_mlp_separate_model.py +0 -0
  29. {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/skrl/torch/examples/gymnasium/skrl_no_module.py +0 -0
  30. {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/skrl/torch/examples/gymnasium/skrl_no_module_rnn.py +0 -0
  31. {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/skrl/torch/examples/gymnasium/skrl_sac.py +0 -0
  32. {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/skrl/torch/examples/gymnasium/temp.py +0 -0
  33. {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/skrl/torch/output_layer/__init__.py +0 -0
  34. {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/source/__init__.py +0 -0
  35. {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/source/build.py +0 -0
  36. {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/source/log_std_cfg.py +0 -0
  37. {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/source/nvidia_utils/__init__.py +0 -0
  38. {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/source/nvidia_utils/configclass.py +0 -0
  39. {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/source/nvidia_utils/dict.py +0 -0
  40. {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/source/nvidia_utils/string.py +0 -0
  41. {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/source/rlmodel_cfg.py +0 -0
  42. {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule/source/utils.py +0 -0
  43. {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule.egg-info/dependency_links.txt +0 -0
  44. {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule.egg-info/requires.txt +0 -0
  45. {rlmodule-0.1.7 → rlmodule-0.2.0}/rlmodule.egg-info/top_level.txt +0 -0
  46. {rlmodule-0.1.7 → rlmodule-0.2.0}/setup.cfg +0 -0
  47. {rlmodule-0.1.7 → rlmodule-0.2.0}/tests/test_build_model_integration.py +0 -0
  48. {rlmodule-0.1.7 → rlmodule-0.2.0}/tests/test_head_mlp.py +0 -0
  49. {rlmodule-0.1.7 → rlmodule-0.2.0}/tests/test_mlp_layer_norm.py +0 -0
  50. {rlmodule-0.1.7 → rlmodule-0.2.0}/tests/test_residual_mlp.py +0 -0
  51. {rlmodule-0.1.7 → rlmodule-0.2.0}/tests/test_rnn_memory_and_correctness.py +0 -0
  52. {rlmodule-0.1.7 → rlmodule-0.2.0}/tests/test_skrl_startup.py +0 -0
  53. {rlmodule-0.1.7 → rlmodule-0.2.0}/tests/test_skrl_train_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rlmodule
3
- Version: 0.1.7
3
+ Version: 0.2.0
4
4
  Summary: Flexible reinforcement learning models instantiators library
5
5
  Author: Lopatovsky
6
6
  Author-email: lopatovsky@gmail.com
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "rlmodule"
7
- version = "0.1.7"
7
+ version = "0.2.0"
8
8
  description = "Flexible reinforcement learning models instantiators library"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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
- module_cfg.class_type(device, input_size, output_size, module_cfg) for module_cfg in cfg.combined_modules
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._modules)))
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._modules) != len(self._combination_constants):
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._modules)}"
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) for module_id, module in enumerate(self._modules)
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
- return self._distribution.entropy().to(self.device)
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``.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rlmodule
3
- Version: 0.1.7
3
+ Version: 0.2.0
4
4
  Summary: Flexible reinforcement learning models instantiators library
5
5
  Author: Lopatovsky
6
6
  Author-email: lopatovsky@gmail.com
@@ -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