|
12 | 12 | skrl = pytest.importorskip("skrl") # noqa: F841 |
13 | 13 |
|
14 | 14 | import gymnasium # noqa: E402 |
| 15 | +import torch.nn as nn # noqa: E402 |
15 | 16 |
|
16 | 17 | from genelab.rl import RslRlOnPolicyRunnerCfg, SkrlAgentCfg # noqa: E402 |
17 | 18 | from genelab.rl.backends import select_backend # noqa: E402 |
| 19 | +from genelab.rl.backends.skrl import _build_agent # noqa: E402 # pyright: ignore[reportPrivateUsage] |
| 20 | +from genelab.rl.backends.skrl.config import SkrlModelCfg # noqa: E402 |
18 | 21 | from genelab.rl.backends.skrl.models import build_models # noqa: E402 |
19 | 22 | from genelab.rl.vecenvs.skrl import GenelabSkrlWrapper # noqa: E402 |
20 | 23 |
|
@@ -124,3 +127,54 @@ def test_build_models_per_algorithm(algorithm: str) -> None: |
124 | 127 | wrapped = GenelabSkrlWrapper(_FakeEnv()) |
125 | 128 | models = build_models(algorithm, wrapped, SkrlAgentCfg().models, device="cpu") |
126 | 129 | assert set(models) == _EXPECTED_MODEL_KEYS[algorithm] |
| 130 | + |
| 131 | + |
| 132 | +# --- off-policy stability fixes ------------------------------------------------ |
| 133 | +# Three changes make off-policy (SAC/TD3/DDPG) actually trainable instead of |
| 134 | +# diverging: empirical obs normalization, a LayerNorm-stabilized Q-critic, and a |
| 135 | +# non-clipping SAC policy (so the entropy term can't blow up the Q-target). |
| 136 | + |
| 137 | + |
| 138 | +def test_obs_normalization_defaults_off() -> None: |
| 139 | + assert SkrlModelCfg().obs_normalization is False |
| 140 | + |
| 141 | + |
| 142 | +def test_qcritic_is_layernorm_stabilized() -> None: |
| 143 | + # The Q-critic (SAC/TD3/DDPG only) gets LayerNorm to bound value extrapolation. |
| 144 | + wrapped = GenelabSkrlWrapper(_FakeEnv()) |
| 145 | + models = build_models("SAC", wrapped, SkrlAgentCfg().models, device="cpu") |
| 146 | + q_has_ln = any(isinstance(m, nn.LayerNorm) for m in models["critic_1"].net.modules()) |
| 147 | + assert q_has_ln |
| 148 | + # PPO uses the state-value critic, which must stay a plain MLP (no behaviour change). |
| 149 | + ppo = build_models("PPO", wrapped, SkrlAgentCfg().models, device="cpu") |
| 150 | + assert not any(isinstance(m, nn.LayerNorm) for m in ppo["value"].net.modules()) |
| 151 | + |
| 152 | + |
| 153 | +def test_sac_policy_does_not_clip_actions() -> None: |
| 154 | + # skrl clamps the action before taking its log-prob; clipping on would let a |
| 155 | + # drifting policy mean send -alpha*log_prob (and the Q-target) to infinity. |
| 156 | + wrapped = GenelabSkrlWrapper(_FakeEnv()) |
| 157 | + policy = build_models("SAC", wrapped, SkrlAgentCfg().models, device="cpu")["policy"] |
| 158 | + assert policy._g_clip_actions is False # skrl GaussianMixin flag |
| 159 | + |
| 160 | + |
| 161 | +def test_obs_normalization_wires_state_preprocessor() -> None: |
| 162 | + from skrl.resources.preprocessors.torch import RunningStandardScaler |
| 163 | + |
| 164 | + wrapped = GenelabSkrlWrapper(_FakeEnv(), state_group=None) |
| 165 | + off = _build_agent( |
| 166 | + SkrlAgentCfg(algorithm="SAC", state_group=None, learning_starts=0), wrapped, "cpu", None |
| 167 | + ) |
| 168 | + assert not isinstance(off._state_preprocessor, RunningStandardScaler) |
| 169 | + on = _build_agent( |
| 170 | + SkrlAgentCfg( |
| 171 | + algorithm="SAC", |
| 172 | + state_group=None, |
| 173 | + learning_starts=0, |
| 174 | + models=SkrlModelCfg(obs_normalization=True), |
| 175 | + ), |
| 176 | + wrapped, |
| 177 | + "cpu", |
| 178 | + None, |
| 179 | + ) |
| 180 | + assert isinstance(on._state_preprocessor, RunningStandardScaler) |
0 commit comments