Skip to content

Commit b28e4a4

Browse files
committed
[BugFix] Fix GAE compact path bias on recurrent value nets at internal truncations
`_call_value_net_compact` previously built `data_in = [root[0:T], boundary]` along the time dim and read `value_[t] = V(data_in[t+1])`. For every internal `done[t]=True` (`t < T-1`), that read was `V(root_obs[t+1])` -- the post-reset first observation of the next episode -- rather than the env-returned `("next", obs)[t]` (the true pre-reset truncation observation). GAE bootstraps with `(1 - terminated)`, so on envs that truncate without terminating (Isaac-Ant, Pendulum-on-timeout, ...) the wrong `next_state_value` was not masked out and flowed straight into the value target. With recurrent value nets the wrong observation also corrupted the LSTM hidden state going forward, cascading into downstream slots. The wandb runs `g71pk34w` / `x05igvw7` (`shifted='compact'`) trailed `sln6yf2a` / `6c8ihgh7` (`shifted=False`) by ~20% end-of-traj reward at iter 1000 for exactly this reason. The fix replaces the `T+1` interleave with a fused batched call: the root and `("next", ...)` streams are concatenated along a non-time batch dim into a constant-shape `[2*B, T, *F]` tensor and the value net is invoked once. Reads of `value` and `value_` are simple batch-half slices. The `("final", k)` collector contract still overrides the next side at slot `T-1`. For recurrent value nets `("next", "is_init") |= root_is_init` is applied so the LSTM resets at every trajectory boundary, exactly matching the `shifted=False` reference (verified byte-exact on the regression fixture). `_fill_missing_next_inputs` handles `compact_obs=True` rollouts that don't populate `("next", k)`. A `time_idx == 0` guard keeps 1D rollouts correct by unsqueezing a batch dim before the cat. Shape stays constant across calls in a training run (no `.item()` syncs, no Python branching on tensor values), so `torch.compile` and `vmap` remain happy. The regression test `test_gae_recurrent_shifted_compact_matches_unshifted_isaac_shape` asserts compact matches `shifted=False` to within `rel < 0.05` on an Isaac-shaped multi-trajectory rollout (`B=4`, `T=16`, truncations every 4 steps, `compact_obs=False` semantics). Pre-fix LSTM/GRU rel-err: 0.52 / 0.52. Post-fix: < 1e-7 (FP noise) for both. All other 1972 tests in `test/objectives/test_values.py` continue to pass. ghstack-source-id: f2d1a3e Pull-Request: #3771
1 parent 5d11fa3 commit b28e4a4

2 files changed

Lines changed: 236 additions & 35 deletions

File tree

test/objectives/test_values.py

Lines changed: 156 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import pytest
1212
import torch
13+
from packaging import version
1314

1415
from tensordict import assert_allclose_td, TensorDict
1516
from tensordict.nn import (
@@ -53,6 +54,8 @@
5354
PENDULUM_VERSIONED,
5455
)
5556

57+
_TORCH_VERSION = version.parse(version.parse(torch.__version__).base_version)
58+
5659

5760
class TestValues:
5861
@pytest.mark.parametrize(
@@ -411,7 +414,8 @@ def _build_shifted_test_td(self, *, with_internal_done: bool):
411414
return td, obs_dim
412415

413416
@pytest.mark.parametrize("with_internal_done", [False, True])
414-
def test_gae_shifted_compact_and_legacy(self, with_internal_done):
417+
@pytest.mark.parametrize("compact_cat_dim", ["batch", "time"])
418+
def test_gae_shifted_compact_and_legacy(self, with_internal_done, compact_cat_dim):
415419
# Both shifted='compact' and shifted='legacy' must produce a valid
416420
# advantage. 'legacy' must match shifted=False exactly. 'compact'
417421
# is allowed a small boundary bias from copying V(obs[T-1]) at
@@ -424,7 +428,11 @@ def test_gae_shifted_compact_and_legacy(self, with_internal_done):
424428
out_keys=["state_value"],
425429
)
426430
gae_compact = GAE(
427-
gamma=0.9, lmbda=0.95, value_network=value_net, shifted="compact"
431+
gamma=0.9,
432+
lmbda=0.95,
433+
value_network=value_net,
434+
shifted="compact",
435+
compact_cat_dim=compact_cat_dim,
428436
)
429437
gae_legacy = GAE(
430438
gamma=0.9, lmbda=0.95, value_network=value_net, shifted="legacy"
@@ -455,6 +463,152 @@ def test_gae_shifted_true_deprecation_aliases_legacy(self):
455463
adv_legacy = gae_legacy(td.copy())["advantage"]
456464
torch.testing.assert_close(adv_true, adv_legacy)
457465

466+
@pytest.mark.skipif(
467+
_TORCH_VERSION < version.parse("2.7"),
468+
reason="GAE compact recurrent path uses torch.vmap chunked semantics that fall "
469+
"back to _pseudo_vmap on torch<2.7 (NotImplementedError).",
470+
)
471+
@pytest.mark.parametrize("module", ["lstm", "gru"])
472+
@pytest.mark.parametrize("compact_cat_dim", ["batch", "time"])
473+
def test_gae_recurrent_shifted_compact_matches_unshifted_isaac_shape(
474+
self, module, compact_cat_dim
475+
):
476+
# Isaac-shaped regression test: recurrent value network, multi-trajectory
477+
# rollout with truncations every `episode_len` steps (never terminations),
478+
# and ``compact_obs=False`` semantics — ``("next", obs)`` is populated
479+
# everywhere, in particular at internal-done positions where it carries
480+
# the true pre-reset terminal observation (not the post-reset first obs
481+
# of the new episode).
482+
#
483+
# Under these conditions shifted="compact" must match shifted=False
484+
# to within a small tolerance. The compact path currently builds
485+
# ``data_in = [root_obs[0:T], boundary_obs]`` and reads
486+
# ``value_[t] = V(data_in[t+1])``; for ``t < T-1`` that is
487+
# ``V(root_obs[t+1])``, which at internal-done positions is the
488+
# **post-reset** obs rather than ``("next", obs)[t]``. The
489+
# boundary-override mechanism in ``_call_value_net_compact`` only fills
490+
# the rollout-edge slot, leaving internal-done positions corrupted.
491+
# GAE then bootstraps with ``(1 - terminated)`` (truncations are
492+
# *not* masked), so the wrong ``next_state_value`` propagates straight
493+
# into the value target / advantage.
494+
#
495+
# See ``examples/collectors/isaaclab_rnn_ppo_memory.py`` and
496+
# ``torchrl/objectives/value/advantages.py:_call_value_net_compact``.
497+
torch.manual_seed(0)
498+
B, T, obs_dim, hidden = 4, 16, 6, 8
499+
episode_len = 4 # internal truncation every 4 steps
500+
g = torch.Generator(device="cpu").manual_seed(0)
501+
all_obs = torch.randn(B, T + 1, obs_dim, generator=g)
502+
obs = all_obs[:, :T].clone()
503+
next_obs = all_obs[:, 1:].clone()
504+
done = torch.zeros(B, T, 1, dtype=torch.bool)
505+
for t in range(episode_len - 1, T, episode_len):
506+
done[:, t, 0] = True
507+
if t < T - 1:
508+
# Decouple next_obs[t] from obs[t+1]: env returned the true
509+
# truncation obs, then auto-reset gave a fresh obs[t+1].
510+
next_obs[:, t] = torch.randn(B, obs_dim, generator=g)
511+
# Isaac-Ant only ever truncates (max_episode_steps); never terminates.
512+
terminated = torch.zeros_like(done)
513+
truncated = done.clone()
514+
is_init = torch.zeros(B, T, 1, dtype=torch.bool)
515+
is_init[:, 0, 0] = True
516+
is_init[:, 1:][done[:, :-1]] = True
517+
next_is_init = done.clone()
518+
reward = torch.randn(B, T, 1, generator=g) * 0.1
519+
td = TensorDict(
520+
{
521+
"observation": obs,
522+
"is_init": is_init,
523+
"next": TensorDict(
524+
{
525+
"observation": next_obs,
526+
"reward": reward,
527+
"done": done,
528+
"terminated": terminated,
529+
"truncated": truncated,
530+
"is_init": next_is_init,
531+
},
532+
[B, T],
533+
),
534+
},
535+
[B, T],
536+
)
537+
538+
if module == "lstm":
539+
recurrent_module = LSTMModule(
540+
input_size=obs_dim,
541+
hidden_size=hidden,
542+
num_layers=1,
543+
in_keys=["observation", "rs_h", "rs_c"],
544+
out_keys=["intermediate", ("next", "rs_h"), ("next", "rs_c")],
545+
python_based=True,
546+
recurrent_backend="pad",
547+
dropout=0,
548+
)
549+
else:
550+
recurrent_module = GRUModule(
551+
input_size=obs_dim,
552+
hidden_size=hidden,
553+
num_layers=1,
554+
in_keys=["observation", "rs_h"],
555+
out_keys=["intermediate", ("next", "rs_h")],
556+
python_based=True,
557+
recurrent_backend="pad",
558+
dropout=0,
559+
)
560+
recurrent_module.eval()
561+
value_net = Seq(
562+
recurrent_module,
563+
Mod(
564+
nn.Linear(hidden, 1), in_keys=["intermediate"], out_keys=["state_value"]
565+
),
566+
)
567+
568+
gae_unshifted = GAE(
569+
gamma=0.99,
570+
lmbda=0.95,
571+
value_network=value_net,
572+
shifted=False,
573+
deactivate_vmap=True,
574+
average_gae=False,
575+
)
576+
gae_compact = GAE(
577+
gamma=0.99,
578+
lmbda=0.95,
579+
value_network=value_net,
580+
shifted="compact",
581+
compact_cat_dim=compact_cat_dim,
582+
deactivate_vmap=False,
583+
average_gae=False,
584+
)
585+
with set_recurrent_mode(True), torch.no_grad():
586+
adv_unshifted = gae_unshifted(td.clone())["advantage"]
587+
adv_compact = gae_compact(td.clone())["advantage"]
588+
# Tolerance is generous because the recurrent value net has its own
589+
# set of mild approximations (legacy/False stack-and-vmap; compact
590+
# single-call with boundary overrides). The bound here is the level
591+
# at which we have empirically observed the Isaac PPO run diverge
592+
# from the shifted=False baseline; values above ~5% mean-rel-err
593+
# corresponded to a ~20% relative reward shortfall at iter 1000 on
594+
# Isaac-Ant. See the wandb runs cited above.
595+
mean_abs_diff = (adv_compact - adv_unshifted).abs().mean()
596+
mean_unshifted_mag = adv_unshifted.abs().mean().clamp_min(1e-6)
597+
rel = mean_abs_diff / mean_unshifted_mag
598+
assert rel < 0.05, (
599+
f"shifted='compact' advantage diverges from shifted=False by "
600+
f"mean rel-err={float(rel):.4f} on the Isaac-shaped fixture. "
601+
"This indicates the compact path's _call_value_net_compact is "
602+
"not overriding internal-done positions of `data_in` with the "
603+
"env-returned `('next', obs)` even when it is populated, so the "
604+
"bootstrap value at every truncation step is computed against "
605+
"the post-reset observation instead of the true truncation "
606+
"observation. Bootstraps for truncations are not masked by "
607+
"GAE's (1 - terminated) factor on Isaac-Ant (where every "
608+
"episode boundary is a truncation), so the bias propagates "
609+
"into the value target."
610+
)
611+
458612
@pytest.mark.parametrize("device", get_default_devices())
459613
@pytest.mark.parametrize("gamma", [0.1, 0.5, 0.99])
460614
@pytest.mark.parametrize("lmbda", [0.1, 0.5, 0.99])

0 commit comments

Comments
 (0)