44
55import abc
66import itertools
7+ from functools import partial
78from typing import Type , Optional , Tuple , Any , List , Iterable
89
910import 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 )
@@ -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 . _critic_ensemble_to_td_target ( 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 . _critic_ensemble_to_td_target ( 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