Skip to content

Commit f533cf0

Browse files
authored
Merge pull request #97 from UT-Austin-RPL/nstep
Nstep Returns
2 parents ae56673 + 4be39df commit f533cf0

1 file changed

Lines changed: 105 additions & 15 deletions

File tree

amago/agent.py

Lines changed: 105 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import abc
66
import itertools
7+
from functools import partial
78
from typing import Type, Optional, Tuple, Any, List, Iterable
89

910
import torch
@@ -200,6 +201,83 @@ def exp_filter(
200201
return weights
201202

202203

204+
# compiled within the agent
205+
def nstep_return(
206+
r: torch.Tensor,
207+
d: torch.Tensor,
208+
q: torch.Tensor,
209+
gamma: torch.Tensor,
210+
n: int,
211+
mask: torch.Tensor,
212+
) -> torch.Tensor:
213+
"""Compute n-step TD targets
214+
215+
Args:
216+
r: Rewards, shape ``(B, L, 1, G, 1)``. Already scaled by reward_multiplier.
217+
d: Dones (float 0 or 1), shape ``(B, L, 1, G, 1)``.
218+
q: Bootstrap Q-values (ensemble-reduced), shape ``(B, L, 1, G, 1)``.
219+
``q[b, t]`` = Q(s_{t+1}, a'_{t+1}) — already aligned as next-state values.
220+
gamma: Discount factors, shape ``(G, 1)``.
221+
n: N-step horizon. n=1 recovers the standard 1-step Bellman target.
222+
mask: Valid timestep mask (1=valid, 0=padding), shape ``(B, L, 1, 1, 1)``.
223+
224+
Returns:
225+
N-step TD targets, shape ``(B, L, 1, G, 1)``.
226+
"""
227+
B, L, _one, G, _one2 = r.shape
228+
device = r.device
229+
230+
gamma_pow = gamma.unsqueeze(0).unsqueeze(0).unsqueeze(0) # (1, 1, 1, G, 1)
231+
232+
r_masked = r * mask
233+
d_masked = d * mask
234+
235+
cumulative_reward = torch.zeros(B, L, 1, G, 1, device=device, dtype=r.dtype)
236+
survival = torch.ones(B, L, 1, G, 1, device=device, dtype=r.dtype)
237+
238+
last_survival = torch.ones(B, L, 1, G, 1, device=device, dtype=r.dtype)
239+
last_q_idx = torch.zeros(B, L, 1, G, 1, device=device, dtype=torch.long)
240+
reached_n = torch.zeros(B, L, 1, G, 1, device=device, dtype=torch.bool)
241+
242+
for i in range(n):
243+
if i == 0:
244+
r_i = r_masked
245+
d_i = d_masked
246+
valid_i = mask
247+
else:
248+
r_i = F.pad(r_masked[:, i:, ...], (0, 0, 0, 0, 0, 0, 0, i), value=0.0)
249+
d_i = F.pad(d_masked[:, i:, ...], (0, 0, 0, 0, 0, 0, 0, i), value=0.0)
250+
valid_i = F.pad(mask[:, i:, ...], (0, 0, 0, 0, 0, 0, 0, i), value=0.0)
251+
252+
step_valid = valid_i * (~reached_n).float()
253+
cumulative_reward += (gamma_pow**i) * survival * r_i * step_valid
254+
255+
new_survival = survival * (1.0 - d_i)
256+
257+
active = step_valid.bool()
258+
last_survival = torch.where(active, new_survival, last_survival)
259+
last_q_idx = torch.where(active, torch.tensor(i, device=device), last_q_idx)
260+
261+
done_here = (d_i > 0.5) & active
262+
invalid_here = (valid_i < 0.5) & (~reached_n)
263+
reached_n = reached_n | done_here | invalid_here
264+
265+
survival = new_survival
266+
267+
reached_n[:] = True
268+
269+
k = last_q_idx + 1
270+
gamma_k = gamma_pow ** k.float()
271+
272+
t_indices = torch.arange(L, device=device).view(1, L, 1, 1, 1).expand(B, L, 1, G, 1)
273+
boot_indices = (t_indices + last_q_idx).clamp(max=L - 1)
274+
275+
q_bootstrap = torch.gather(q, dim=1, index=boot_indices)
276+
bootstrap = gamma_k * last_survival * q_bootstrap
277+
278+
return cumulative_reward + bootstrap
279+
280+
203281
#####################
204282
## Built-in Agents ##
205283
#####################
@@ -495,6 +573,10 @@ def V(state, critic, action_dist, k) -> float:
495573
use_target_actor: If True, use a target actor to sample actions used in TD targets.
496574
Defaults to True.
497575
use_multigamma: If True, train on multiple discount horizons (:py:class:`~amago.agent.Multigammas`) in parallel. Defaults to True.
576+
n_step: N-step horizon for TD targets. n=1 gives standard 1-step Bellman targets.
577+
Higher values use :py:func:`~amago.agent.nstep_return` to sum discounted
578+
rewards over ``n`` future steps before bootstrapping with Q(s_{t+n}).
579+
Defaults to 1.
498580
actor_type: Actor MLP head for producing action distributions. Defaults to :py:class:`~amago.nets.actor_critic.Actor`.
499581
critic_type: Critic MLP head for producing Q-values. Defaults to :py:class:`~amago.nets.actor_critic.NCritics`.
500582
pass_obs_keys_to_actor: List of keys from the observation space to pass directly to the actor network's forward pass if needed for some reason (e.g., for masking actions). Defaults to None.
@@ -523,6 +605,7 @@ def __init__(
523605
popart: bool = True,
524606
use_target_actor: bool = True,
525607
use_multigamma: bool = True,
608+
n_step: int = 1,
526609
actor_type: Type[actor_critic.BaseActorHead] = actor_critic.Actor,
527610
critic_type: Type[actor_critic.BaseCriticHead] = actor_critic.NCritics,
528611
pass_obs_keys_to_actor: Optional[Iterable[str]] = None,
@@ -546,6 +629,11 @@ def __init__(
546629
self.critic_loss_weight = critic_loss_weight
547630
self.tau = tau
548631
self.use_target_actor = use_target_actor
632+
assert n_step >= 1, f"n_step must be >= 1, got {n_step}"
633+
self.n_step = n_step
634+
self._nstep_fn = torch.compile(
635+
partial(nstep_return, n=n_step), mode="reduce-overhead"
636+
)
549637
multigammas = (
550638
Multigammas().discrete if self.discrete else Multigammas().continuous
551639
)
@@ -663,23 +751,23 @@ def get_actions(
663751
dtype = torch.uint8 if (self.discrete or self.multibinary) else torch.float32
664752
return actions.to(dtype=dtype), hidden_state
665753

666-
def _critic_ensemble_to_td_target(self, ensemble_td_target: torch.Tensor):
667-
B, L, C, G, _ = ensemble_td_target.shape
754+
def _reduce_critic_ensemble(self, q_ensemble: torch.Tensor) -> torch.Tensor:
755+
B, L, C, G, _ = q_ensemble.shape
668756
# random subset of critic ensemble
669757
random_subset = torch.randint(
670758
low=0,
671759
high=C,
672760
size=(B, L, self.num_critics_td, G, 1),
673-
device=ensemble_td_target.device,
761+
device=q_ensemble.device,
674762
)
675-
td_target_rand = torch.take_along_dim(ensemble_td_target, random_subset, dim=2)
763+
q_subset = torch.take_along_dim(q_ensemble, random_subset, dim=2)
676764
if self.online_coeff > 0:
677765
# clipped double q
678-
td_target = td_target_rand.min(2, keepdims=True).values
766+
q_reduced = q_subset.min(2, keepdims=True).values
679767
else:
680768
# without DPG updates the usual min creates strong underestimation. take mean instead
681-
td_target = td_target_rand.mean(2, keepdims=True)
682-
return td_target
769+
q_reduced = q_subset.mean(2, keepdims=True)
770+
return q_reduced
683771

684772
def _compute_loss(
685773
self,
@@ -809,10 +897,10 @@ def forward(self, batch: Batch, log_step: bool) -> torch.Tensor:
809897
# Q_target(s', a')
810898
q_targ_sp_ap_gp = self.popart(self.target_critics(*sp_ap_gp).mean(0), normalized=False)
811899
assert q_targ_sp_ap_gp.shape == (B, L - 1, C, G, 1)
812-
# y = r + gamma * (1 - d) * Q_target(s', a')
813-
ensemble_td_target = r + gamma * (1.0 - d) * q_targ_sp_ap_gp
814-
assert ensemble_td_target.shape == (B, L - 1, C, G, 1)
815-
td_target = self._critic_ensemble_to_td_target(ensemble_td_target)
900+
q_reduced = self._reduce_critic_ensemble(q_targ_sp_ap_gp)
901+
assert q_reduced.shape == (B, L - 1, 1, G, 1)
902+
nstep_mask = state_mask.float().unsqueeze(-1).unsqueeze(-1) # (B, L-1, 1, 1, 1)
903+
td_target = self._nstep_fn(r, d, q_reduced, gamma, mask=nstep_mask)
816904
assert td_target.shape == (B, L - 1, 1, G, 1)
817905
self.popart.update_stats(
818906
td_target, mask=critic_mask.all(2, keepdim=True)
@@ -1061,6 +1149,7 @@ def __init__(
10611149
popart: bool = True,
10621150
use_target_actor: bool = True,
10631151
use_multigamma: bool = True,
1152+
n_step: int = 1,
10641153
actor_type: Type[actor_critic.BaseActorHead] = actor_critic.Actor,
10651154
critic_type: Type[actor_critic.BaseCriticHead] = actor_critic.NCriticsTwoHot,
10661155
pass_obs_keys_to_actor: Optional[Iterable[str]] = None,
@@ -1087,6 +1176,7 @@ def __init__(
10871176
critic_loss_weight=critic_loss_weight,
10881177
use_target_actor=use_target_actor,
10891178
use_multigamma=use_multigamma,
1179+
n_step=n_step,
10901180
fbc_filter_func=fbc_filter_func,
10911181
popart=popart,
10921182
actor_type=actor_type,
@@ -1168,10 +1258,10 @@ def forward(self, batch: Batch, log_step: bool):
11681258
assert q_targ_sp_ap_gp.probs.shape == (K_c, B, L - 1, C, G, Bins)
11691259
q_targ_sp_ap_gp = self.target_critics.bin_dist_to_raw_vals(q_targ_sp_ap_gp).mean(0)
11701260
assert q_targ_sp_ap_gp.shape == (B, L - 1, C, G, 1)
1171-
# y = r + gamma * (1.0 - d) * Q(s', a')
1172-
ensemble_td_target = r + gamma * (1.0 - d) * q_targ_sp_ap_gp
1173-
assert ensemble_td_target.shape == (B, L - 1, C, G, 1)
1174-
td_target = self._critic_ensemble_to_td_target(ensemble_td_target)
1261+
q_reduced = self._reduce_critic_ensemble(q_targ_sp_ap_gp)
1262+
assert q_reduced.shape == (B, L - 1, 1, G, 1)
1263+
nstep_mask = state_mask.float().unsqueeze(-1).unsqueeze(-1) # (B, L-1, 1, 1, 1)
1264+
td_target = self._nstep_fn(r, d, q_reduced, gamma, mask=nstep_mask)
11751265
assert td_target.shape == (B, L - 1, 1, G, 1)
11761266
self.popart.update_stats(
11771267
td_target, mask=critic_mask.all(2, keepdim=True)

0 commit comments

Comments
 (0)