Skip to content

Commit dc9a77d

Browse files
authored
Merge pull request #234 from KraHsu/hotfix/skrl-offpolicy-stability
fix(rl/skrl): off-policy training stability (obs norm, critic LayerNorm, SAC clip)
2 parents 912f942 + 5dfe5eb commit dc9a77d

4 files changed

Lines changed: 100 additions & 3 deletions

File tree

‎src/genelab/rl/backends/skrl/__init__.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,18 @@ def _build_agent(agent_cfg: SkrlAgentCfg, wrapper: Any, device: Any, log_dir: Pa
120120
models = build_models(agent_cfg.algorithm, wrapper, agent_cfg.models, device)
121121
agent_cls, default_cfg = _agent_class(agent_cfg.algorithm)
122122
cfg = _build_skrl_cfg(agent_cfg, default_cfg, log_dir)
123+
if agent_cfg.models.obs_normalization and "state_preprocessor" in cfg:
124+
# Empirical obs normalization (analogue of rsl_rl's obs_normalization). The
125+
# scaler is owned by the agent and applied to states before policy/critic
126+
# forward passes (see skrl ``_update``); ``size`` is taken from the observation
127+
# space so it matches whichever ``obs_group`` feeds the agent.
128+
from skrl.resources.preprocessors.torch import RunningStandardScaler
129+
130+
cfg["state_preprocessor"] = RunningStandardScaler
131+
cfg["state_preprocessor_kwargs"] = {
132+
"size": wrapper.observation_space,
133+
"device": device,
134+
}
123135
return agent_cls(
124136
models=models,
125137
memory=memory,

‎src/genelab/rl/backends/skrl/config.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ class SkrlModelCfg:
3232
min_log_std: float = -20.0
3333
max_log_std: float = 2.0
3434
clip_actions: bool = False
35+
# Empirical observation normalization (skrl ``RunningStandardScaler`` state
36+
# preprocessor), the analogue of ``RslRlModelCfg.obs_normalization``. Off by
37+
# default; load-bearing for off-policy stability (SAC/TD3 regress a bootstrapped
38+
# Q and diverge without standardized critic inputs).
39+
obs_normalization: bool = False
3540

3641

3742
@dataclass

‎src/genelab/rl/backends/skrl/models.py‎

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,28 @@ def compute(self, inputs: Mapping[str, Any], role: str = "") -> tuple[Any, dict[
128128
return self.net(inputs["states"]), {}
129129

130130

131+
def _mlp_layernorm(
132+
in_dim: int, hidden_dims: tuple[int, ...], out_dim: int, activation: str
133+
) -> nn.Sequential:
134+
"""MLP with LayerNorm after every hidden linear: ``Linear -> LayerNorm -> act``.
135+
136+
LayerNorm in the value network bounds the critic's output extrapolation, the key
137+
ingredient that prevents the bootstrapped-Q overestimation divergence off-policy
138+
algorithms suffer on long-horizon / contact-rich tasks (cf. CrossQ, SAC-LN). The
139+
final linear has no norm/activation so the Q range stays unconstrained.
140+
"""
141+
act = _activation(activation)
142+
layers: list[nn.Module] = []
143+
prev = in_dim
144+
for width in hidden_dims:
145+
layers += [nn.Linear(prev, width), nn.LayerNorm(width), act()]
146+
prev = width
147+
layers.append(nn.Linear(prev, out_dim))
148+
return nn.Sequential(*layers)
149+
150+
131151
class QCritic(DeterministicMixin, Model):
132-
"""Action-value Q(s, a) for SAC / TD3 / DDPG."""
152+
"""Action-value Q(s, a) for SAC / TD3 / DDPG (LayerNorm-stabilized)."""
133153

134154
def __init__(
135155
self,
@@ -142,7 +162,7 @@ def __init__(
142162
DeterministicMixin.__init__(self, clip_actions=False)
143163
num_obs = cast(int, self.num_observations)
144164
num_act = cast(int, self.num_actions)
145-
self.net = _mlp(num_obs + num_act, model_cfg.hidden_dims, 1, model_cfg.activation)
165+
self.net = _mlp_layernorm(num_obs + num_act, model_cfg.hidden_dims, 1, model_cfg.activation)
146166
self.to(device)
147167

148168
def compute(self, inputs: Mapping[str, Any], role: str = "") -> tuple[Any, dict[str, Any]]:
@@ -197,8 +217,14 @@ def q() -> Model:
197217
"target_critic_2": q(),
198218
}
199219
if algorithm == "SAC":
220+
# clip_actions MUST be False here: skrl's GaussianMixin clamps the sampled
221+
# action *before* computing its log-prob, so with clipping on, a policy mean
222+
# that drifts outside the action bounds yields log_prob(±1 | N(μ,σ)) → -inf,
223+
# and SAC's entropy term (-α·log_prob) then blows up the Q-target. Left
224+
# unclipped the log-prob is in-distribution (bounded); the env clamps the
225+
# action downstream. (TD3/DDPG use a deterministic tanh policy, no such issue.)
200226
return {
201-
"policy": gaussian(clip_actions=True),
227+
"policy": gaussian(clip_actions=False),
202228
"critic_1": q(),
203229
"critic_2": q(),
204230
"target_critic_1": q(),

‎tests/test_skrl_pipeline.py‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@
1212
skrl = pytest.importorskip("skrl") # noqa: F841
1313

1414
import gymnasium # noqa: E402
15+
import torch.nn as nn # noqa: E402
1516

1617
from genelab.rl import RslRlOnPolicyRunnerCfg, SkrlAgentCfg # noqa: E402
1718
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
1821
from genelab.rl.backends.skrl.models import build_models # noqa: E402
1922
from genelab.rl.vecenvs.skrl import GenelabSkrlWrapper # noqa: E402
2023

@@ -124,3 +127,54 @@ def test_build_models_per_algorithm(algorithm: str) -> None:
124127
wrapped = GenelabSkrlWrapper(_FakeEnv())
125128
models = build_models(algorithm, wrapped, SkrlAgentCfg().models, device="cpu")
126129
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

Comments
 (0)