thrust_rl/multi_agent/joint.rs
1//! Synchronized joint multi-agent PPO trainer (Burn backend).
2//!
3//! Burn-native rebuild of the pre-Burn `JointMultiAgentTrainer`. The previous
4//! `tch`-coupled implementation was deleted in PR #98 along with the rest of
5//! `src/multi_agent/`; this module re-establishes the *synchronized* (one
6//! shared backward pass) joint trainer on top of the Burn policy networks
7//! and the [`crate::train::optimizer::BurnOptimizer`] wrapper.
8//!
9//! # When to use this module
10//!
11//! Use the joint trainer when you need a loss term that depends on **all**
12//! agents' parameters evaluated on the **same minibatch** at the **same**
13//! optimization step. The canonical motivating example is the Slepian-Wolf
14//! MARL P3 cross-agent representational redundancy penalty
15//!
16//! ```text
17//! L_red = λ * Σ_{i<j} || corr(Z_i(obs), Z_j(obs)) ||_F² / d²
18//! L_total = Σ_i L_ppo[i](obs, a_i, ...) + L_red
19//! ```
20//!
21//! where `Z_i = encoder_features_i(obs)`. The penalty couples every policy's
22//! encoder through one shared backward pass; per-thread learners cannot
23//! express this without heavy synchronization.
24//!
25//! # Single-graph, multiple-optimizer semantics under Burn
26//!
27//! Burn's [`burn::optim::Optimizer::step`] consumes the module by value and
28//! returns the updated copy. That makes the tch-style "one `.backward()` plus
29//! N independent `Optimizer::step` calls touching disjoint var-stores"
30//! pattern slightly different in Burn:
31//!
32//! 1. Each policy computes its own `(policy_loss, value_loss, entropy)` on the
33//! minibatch.
34//! 2. The caller-supplied `aux_fn` is invoked on every policy's encoder
35//! features and may return an additional scalar loss.
36//! 3. All per-agent losses plus the aux loss are summed into one scalar
37//! `joint_loss`; we call `.backward()` once.
38//! 4. For each policy `i`, we slice the joint gradients down to policy `i`'s
39//! parameters with [`burn::optim::GradientsParams::from_grads(grads.clone(),
40//! &policies[i])`], then call `optimizer_i.step(lr, policies[i], slice_i)`.
41//!
42//! That last step is the Burn analog of "every optimizer reads only its own
43//! var-store's `.grad`" on the tch path — gradient flow stays
44//! parameter-isolated because each `from_grads` slice only contains the
45//! ids of one policy's params. The aux term's contribution flows into every
46//! policy's slice through the shared backward pass, by construction.
47//!
48//! # Minibatch sampling
49//!
50//! By default (and to keep the smoke test deterministic) this trainer
51//! takes **one** minibatch per epoch, sampled via
52//! [`crate::train::ppo::loss::generate_minibatch_indices`] and truncated to
53//! `config.minibatch_size`. Setting
54//! [`JointTrainerConfig::iterate_all_minibatches`] switches to the
55//! conventional PPO pattern of walking *every* `minibatch_size` chunk per
56//! epoch, so a large rollout is fully consumed instead of ~97% discarded
57//! (issue #239). The default stays single-minibatch so existing NFSP/PSRO
58//! determinism tests are bit-identical.
59//!
60//! # Gradient clipping
61//!
62//! `JointTrainerConfig::max_grad_norm` is applied as a **global** L2-norm
63//! clip on each policy's gradient slice before the optimizer step (issue
64//! #239): if the joint gradient norm exceeds the cap, every gradient is
65//! scaled down by `max_norm / ‖g‖`, preserving direction. This stops the
66//! raw-scale value-loss gradient from swamping the policy heads on the
67//! shared actor-critic trunk.
68
69use anyhow::{Result, anyhow};
70use burn::{
71 module::{AutodiffModule, Module, ModuleVisitor, Param},
72 optim::{GradientsParams, Optimizer},
73 tensor::{Int, Tensor, backend::AutodiffBackend},
74};
75use rand::rngs::StdRng;
76
77use crate::train::{
78 optimizer::{BackendOptimizer, BurnOptimizer},
79 ppo::loss::{
80 compute_policy_loss, compute_value_loss, generate_minibatch_indices_with_rng, scalar_f64,
81 },
82};
83
84// -----------------------------------------------------------------------
85// Trait: what a policy must support to participate.
86// -----------------------------------------------------------------------
87
88/// Capabilities a policy must expose to participate in
89/// [`JointMultiAgentTrainer`].
90///
91/// The trait pins exactly the surface the trainer needs:
92///
93/// - **`get_action_host_seeded`** — rollout-time sampling. Returns
94/// `(actions_per_dim, log_probs, values)` on the host so the trainer can
95/// build the rollout buffer without tying it to a particular backend tensor.
96/// Takes the trainer-owned `StdRng` so `PsroConfig::seed` /
97/// `NfspConfig::seed` produce bit-identical rollouts (issue #114).
98/// - **`evaluate_actions`** — re-evaluate the current policy on previously
99/// sampled actions to compute updated log-probs / entropy / value for the PPO
100/// loss; this is the only place autograd-bearing tensors are produced.
101/// - **`encoder_features`** — shared-trunk activations for the auxiliary loss.
102/// - **`action_dims`** — per-dim action cardinalities, used to size action
103/// buffers without invoking the policy.
104pub trait JointPolicy<B: AutodiffBackend>: AutodiffModule<B> + Clone {
105 /// Sample actions for a single rollout step using a caller-supplied
106 /// seeded RNG.
107 ///
108 /// `obs` carries one row per environment in the rollout batch. Returns
109 /// host-side `(actions, log_probs, values)` where:
110 ///
111 /// - `actions` is laid out flat per-row: `actions[row * num_dims + d]` is
112 /// the action sampled for dim `d` of row `row`. Length is
113 /// `obs.dims()\[0\] * num_dims`.
114 /// - `log_probs[row]` is the joint log-probability summed across dims.
115 /// - `values[row]` is the value estimate.
116 ///
117 /// Bit-exactness: two calls with the same `obs`, same policy state,
118 /// and same-seeded `rng` produce element-wise identical outputs —
119 /// the load-bearing guarantee that `PsroConfig::seed` /
120 /// `NfspConfig::seed` rely on after issue #114 completed plumbing
121 /// the trainer-owned `StdRng` through the rollout-time action
122 /// sampler.
123 fn get_action_host_seeded(
124 &self,
125 obs: Tensor<B, 2>,
126 rng: &mut StdRng,
127 ) -> (Vec<i64>, Vec<f32>, Vec<f32>);
128
129 /// Batched seeded sampler: `obs` carries `N` rows scored through **this
130 /// one policy** in a single forward, returning `N` actions.
131 ///
132 /// # When this helps (and when it does not)
133 ///
134 /// This eliminates per-call batch-1 forward overhead on the NdArray
135 /// backend wherever many observations are scored through the **same**
136 /// model in one step — e.g. the constant-obs marginal probe
137 /// ([`crate::multi_agent::nfsp::NfspTrainer::action_marginal_for`]),
138 /// or a future parallel-env rollout where one frozen policy scores
139 /// many env rows.
140 ///
141 /// It does **not** apply across the per-agent forwards of NFSP's (or
142 /// PSRO's) rollout: those `num_agents` forwards each run a *distinct*
143 /// per-agent policy module, and a single batched forward applies one
144 /// weight set to every row — so stacking the agents' observations
145 /// into one `[N, obs_dim]` tensor would be semantically wrong. NFSP
146 /// runs a single shared joint episode (`env.step_joint`), so there is
147 /// no within-model batch dimension to collapse in the rollout itself;
148 /// the per-step forward count there is irreducible. See issue #235.
149 ///
150 /// # Determinism
151 ///
152 /// The default implementation simply calls
153 /// [`Self::get_action_host_seeded`] on the full `[N, obs_dim]` tensor,
154 /// which already draws RNG in row-major order. Implementors that
155 /// override this (the concrete MLP policies do) must keep the per-row
156 /// RNG draw order identical so the sampled action stream is
157 /// bit-for-bit reproducible under `PsroConfig::seed` / `NfspConfig::seed`
158 /// (issue #114).
159 fn get_actions_host_seeded_batched(
160 &self,
161 obs: Tensor<B, 2>,
162 rng: &mut StdRng,
163 ) -> (Vec<i64>, Vec<f32>, Vec<f32>) {
164 self.get_action_host_seeded(obs, rng)
165 }
166
167 /// Re-evaluate the policy on previously-sampled actions.
168 ///
169 /// `actions` is shape `[batch, num_dims]`. For scalar discrete policies
170 /// (`num_dims == 1`) pass actions reshaped to `[batch, 1]`. Returns
171 /// `(log_probs, entropy, values)` where every tensor has shape
172 /// `[batch]`.
173 fn evaluate_actions_joint(
174 &self,
175 obs: Tensor<B, 2>,
176 actions: Tensor<B, 2, Int>,
177 ) -> (Tensor<B, 1>, Tensor<B, 1>, Tensor<B, 1>);
178
179 /// Shared-trunk feature representation; gradients flow back into the
180 /// encoder. The natural quantity to feed into cross-agent regularizers.
181 ///
182 /// Shape: `[batch, hidden_dim]`.
183 fn encoder_features_joint(&self, obs: Tensor<B, 2>) -> Tensor<B, 2>;
184
185 /// Per-dimension action cardinalities.
186 ///
187 /// - Scalar discrete (e.g. [`crate::policy::mlp::MlpBurnPolicy`] with
188 /// `action_dim = 5`): returns `vec![5]` (one dim, cardinality 5). The
189 /// rollout buffer uses `num_action_dims = 1`.
190 /// - Multi-discrete (e.g.
191 /// [`crate::policy::multi_discrete_mlp::MultiDiscreteMlpBurnPolicy`] with
192 /// action dims `[10, 2]`): returns `vec![10, 2]`. The rollout buffer uses
193 /// `num_action_dims = 2`.
194 fn action_dims_joint(&self) -> Vec<i64>;
195}
196
197// MlpBurnPolicy: scalar discrete. We don't have direct access to the policy's
198// action_dim through `Module`, so the impl forces callers to record the
199// cardinality at construction time via a thin wrapper. To stay zero-friction
200// here we expose the impls on the concrete policy types directly: the MLP
201// policy's `policy_head` output dimension is what `action_dims_joint` would
202// return, but Burn's `Linear` doesn't surface that directly through the
203// `Module` API. We track action dims by inspecting the `policy_head`'s
204// weight tensor shape — it lives on the module so this is parity-preserving.
205impl<B: AutodiffBackend> JointPolicy<B> for crate::policy::mlp::MlpBurnPolicy<B>
206where
207 Self: AutodiffModule<B> + Clone,
208{
209 fn get_action_host_seeded(
210 &self,
211 obs: Tensor<B, 2>,
212 rng: &mut StdRng,
213 ) -> (Vec<i64>, Vec<f32>, Vec<f32>) {
214 let (actions, log_probs, values) = self.get_action_host_seeded(obs, rng);
215 // Scalar discrete: actions is already 1-per-row.
216 (actions, log_probs, values)
217 }
218
219 fn get_actions_host_seeded_batched(
220 &self,
221 obs: Tensor<B, 2>,
222 rng: &mut StdRng,
223 ) -> (Vec<i64>, Vec<f32>, Vec<f32>) {
224 // One forward over all rows (same model) instead of per-row
225 // batch-1 forwards; RNG draw order unchanged (issue #235).
226 self.get_actions_host_seeded_batched(obs, rng)
227 }
228
229 fn evaluate_actions_joint(
230 &self,
231 obs: Tensor<B, 2>,
232 actions: Tensor<B, 2, Int>,
233 ) -> (Tensor<B, 1>, Tensor<B, 1>, Tensor<B, 1>) {
234 // The MLP policy's `evaluate_actions` takes rank-1 actions. Squeeze
235 // the dim-1 axis to match.
236 let actions_1d: Tensor<B, 1, Int> = actions.squeeze_dim::<1>(1);
237 self.evaluate_actions(obs, actions_1d)
238 }
239
240 fn encoder_features_joint(&self, obs: Tensor<B, 2>) -> Tensor<B, 2> {
241 self.encoder_features(obs)
242 }
243
244 fn action_dims_joint(&self) -> Vec<i64> {
245 // `policy_head.weight` has shape `[hidden_dim, action_dim]` in Burn's
246 // `Linear` layout; we pull `action_dim` off the second axis.
247 let head_dims = self.policy_head_action_dim();
248 vec![head_dims as i64]
249 }
250}
251
252impl<B: AutodiffBackend> JointPolicy<B>
253 for crate::policy::multi_discrete_mlp::MultiDiscreteMlpBurnPolicy<B>
254where
255 Self: AutodiffModule<B> + Clone,
256{
257 fn get_action_host_seeded(
258 &self,
259 obs: Tensor<B, 2>,
260 rng: &mut StdRng,
261 ) -> (Vec<i64>, Vec<f32>, Vec<f32>) {
262 self.get_action_host_seeded(obs, rng)
263 }
264
265 fn get_actions_host_seeded_batched(
266 &self,
267 obs: Tensor<B, 2>,
268 rng: &mut StdRng,
269 ) -> (Vec<i64>, Vec<f32>, Vec<f32>) {
270 // One forward over all rows (same model) instead of per-row
271 // batch-1 forwards; RNG draw order unchanged (issue #235).
272 self.get_actions_host_seeded_batched(obs, rng)
273 }
274
275 fn evaluate_actions_joint(
276 &self,
277 obs: Tensor<B, 2>,
278 actions: Tensor<B, 2, Int>,
279 ) -> (Tensor<B, 1>, Tensor<B, 1>, Tensor<B, 1>) {
280 self.evaluate_actions(obs, actions)
281 }
282
283 fn encoder_features_joint(&self, obs: Tensor<B, 2>) -> Tensor<B, 2> {
284 self.encoder_features(obs)
285 }
286
287 fn action_dims_joint(&self) -> Vec<i64> {
288 self.action_dim_cardinalities().into_iter().map(|d| d as i64).collect()
289 }
290}
291
292// -----------------------------------------------------------------------
293// Trait: minimal joint environment for rollout collection.
294// -----------------------------------------------------------------------
295
296/// Per-step result returned by a [`JointEnv`] implementation.
297#[derive(Debug, Clone)]
298pub struct JointStepResult {
299 /// Per-agent rewards.
300 pub rewards: Vec<f32>,
301 /// Whether the episode has terminated.
302 pub done: bool,
303 /// Per-agent observations after the step (length = `num_agents`).
304 pub observations: Vec<Vec<f32>>,
305}
306
307/// Minimal joint-environment surface needed by
308/// [`JointMultiAgentTrainer::collect_rollout`].
309///
310/// Why a fresh trait instead of
311/// [`crate::multi_agent::environment::MultiAgentEnvironment`]? The base trait's
312/// `step_multi` already takes `actions: &[Vec<i64>]` so it nominally fits, but
313/// it also requires a full `Environment` impl (single-action `step`, action
314/// spaces, snapshot/restore, etc.). The joint trainer only needs `reset_joint`
315/// / `step_joint`, and adapter envs can implement this trait directly without
316/// touching the wider trait hierarchy.
317pub trait JointEnv {
318 /// Reset the env in-place. Returns per-agent observations.
319 fn reset_joint(&mut self, seed: Option<u64>) -> Vec<Vec<f32>>;
320
321 /// Step the env with per-agent actions.
322 ///
323 /// `actions[i]` is the full per-dim action vector for agent `i`:
324 /// - Length 1 for scalar discrete (e.g. `[3]`).
325 /// - Length `num_dims` for multi-discrete (e.g. `[house_index, mode]`).
326 fn step_joint(&mut self, actions: &[Vec<i64>]) -> JointStepResult;
327}
328
329// -----------------------------------------------------------------------
330// Config / Rollout / Stats
331// -----------------------------------------------------------------------
332
333/// Trainer configuration. Plain data; defaults match the tch-era
334/// `JointTrainerConfig` field-for-field so the smoke-test parameters
335/// stay portable.
336#[derive(Debug, Clone)]
337pub struct JointTrainerConfig {
338 /// Number of agents trained jointly. Must match the length of
339 /// [`JointMultiAgentTrainer`]'s policy / optimizer slots.
340 pub num_agents: usize,
341 /// Steps collected per rollout before each PPO update.
342 pub rollout_steps: usize,
343 /// Discount factor `γ ∈ [0, 1]`.
344 pub gamma: f64,
345 /// GAE smoothing parameter `λ ∈ [0, 1]`.
346 pub gae_lambda: f64,
347 /// PPO policy-ratio clip range `ε`.
348 pub clip_range: f64,
349 /// PPO value-function clip range. Use `f64::INFINITY` to fall back to
350 /// plain MSE (matches the Burn [`compute_value_loss`] contract).
351 pub clip_range_vf: f64,
352 /// Weight on the value-function loss term inside the joint loss.
353 pub vf_coef: f64,
354 /// Weight on the entropy bonus.
355 pub ent_coef: f64,
356 /// Number of PPO epochs per update.
357 pub n_epochs: usize,
358 /// Minibatch size for SGD within each PPO epoch.
359 pub minibatch_size: usize,
360 /// Global gradient-norm clip applied through each per-policy optimizer.
361 pub max_grad_norm: f64,
362 /// Standardize advantages to zero mean / unit variance per minibatch
363 /// before computing the surrogate.
364 pub normalize_advantages: bool,
365 /// Iterate over **all** minibatches per epoch instead of a single
366 /// truncated `minibatch_size` draw.
367 ///
368 /// The historical Burn-native cut took one shuffled minibatch per
369 /// epoch (issue #100 simplification), which discards ~97% of a large
370 /// rollout and starves the best-response (issue #239). When `true`,
371 /// each epoch shuffles the rollout once and walks every
372 /// `minibatch_size` chunk, the conventional PPO pattern.
373 ///
374 /// Defaults to `false` to keep existing NFSP/PSRO determinism tests
375 /// bit-identical; callers that want the full-rollout update (e.g. the
376 /// bucket-brigade examples) opt in explicitly.
377 pub iterate_all_minibatches: bool,
378 /// Optional **separate critic learning rate** (issue #239, ranked fix #4).
379 ///
380 /// The shared actor-critic trunk is trained with one optimizer and one
381 /// combined `policy + vf_coef·value − ent_coef·entropy` backward. On the
382 /// bucket-brigade cells the critic never fits (explained-variance pinned
383 /// at ~0 — see #239), so the normalized advantages are noise and the
384 /// policy gradient stays ~0 (entropy stuck at the uniform-max floor).
385 /// PR #240's grad-clip / all-minibatch / `vf_coef` knobs did not move
386 /// `ev` off 0, and dropping `BR_REWARD_SCALE` to 0.001 collapsed value
387 /// loss to ~8 yet `ev` *still* stayed flat — i.e. the critic is not
388 /// fitting even tiny well-scaled targets at the shared 3e-4 LR.
389 ///
390 /// When `Some(lr)`, the joint update splits the combined backward into
391 /// two passes per minibatch:
392 /// 1. **actor pass** — `policy − ent_coef·entropy` stepped through the
393 /// usual per-agent optimizer at its construction LR;
394 /// 2. **critic pass** — `value_loss` alone stepped through a dedicated
395 /// per-agent **critic optimizer** at `critic_lr`.
396 ///
397 /// Both passes update the full module (shared trunk + their respective
398 /// heads), but the critic gets its own Adam moment state and (typically
399 /// higher) LR, so it can fit the value target without the policy LR
400 /// dragging it. The critic optimizers are supplied via
401 /// [`JointMultiAgentTrainer::with_critic_optimizers`]; if `critic_lr` is
402 /// `Some` but no critic optimizers were supplied the trainer falls back
403 /// to the single combined backward (logged once at construction).
404 ///
405 /// Defaults to `None` (single combined backward — the historical
406 /// behaviour), so existing NFSP/PSRO runs and determinism tests are
407 /// bit-identical unless a caller opts in.
408 pub critic_lr: Option<f64>,
409 /// Optional **cap on the number of minibatch gradient steps per epoch**
410 /// (issue #251, throughput lever).
411 ///
412 /// The #239 best-response fix sets
413 /// [`iterate_all_minibatches`](Self::iterate_all_minibatches) `= true`,
414 /// which walks *every* `minibatch_size` chunk of the rollout
415 /// each epoch. Combined with `br_train_steps_per_iteration = 8` over the
416 /// un-batchable (issue #235) bucket-brigade rollout, the per-iteration BR
417 /// update became the dominant outer-loop cost (>1 h/iter at 2048 rollout
418 /// — see issue #251). At `rollout_steps = 2048`, `minibatch_size = 256`
419 /// that is 8 minibatches × `n_epochs` × `br_train_steps` gradient steps
420 /// per outer iteration.
421 ///
422 /// When `Some(cap)`, after each epoch's minibatch index-sets are built
423 /// (and globally shuffled), the set is truncated to at most `cap`
424 /// minibatches — a uniformly-random subsample of the rollout, since the
425 /// indices were shuffled before chunking. This trades a bounded amount of
426 /// BR fit per epoch for throughput **without** reverting the #239
427 /// learning behaviour: grad-clip, `vf_coef`, `iterate_all_minibatches`,
428 /// and `br_train_steps_per_iteration` are all unchanged, and each capped
429 /// minibatch is still a full forward+backward over `minibatch_size`
430 /// samples. `cap` is clamped to at least 1 so a capped update never
431 /// degenerates to zero gradient steps.
432 ///
433 /// `None` (default) preserves the full all-minibatch coverage exactly, so
434 /// existing NFSP/PSRO runs and determinism tests are bit-identical unless
435 /// a caller opts in. With the default single-minibatch path
436 /// (`iterate_all_minibatches == false`) the per-epoch set already has one
437 /// entry, so any `cap >= 1` is a no-op there.
438 pub max_minibatches_per_epoch: Option<usize>,
439}
440
441impl Default for JointTrainerConfig {
442 fn default() -> Self {
443 Self {
444 num_agents: 4,
445 rollout_steps: 2048,
446 gamma: 0.99,
447 gae_lambda: 0.95,
448 clip_range: 0.2,
449 clip_range_vf: 0.2,
450 vf_coef: 0.5,
451 ent_coef: 0.01,
452 n_epochs: 4,
453 minibatch_size: 256,
454 max_grad_norm: 0.5,
455 normalize_advantages: true,
456 iterate_all_minibatches: false,
457 critic_lr: None,
458 max_minibatches_per_epoch: None,
459 }
460 }
461}
462
463/// Synchronized rollout buffer (host-side).
464///
465/// Per-agent observations are stored independently so environments with
466/// distinct per-agent views (e.g. partial observability, asymmetric
467/// information) work without any pre-concatenation. Each agent `i`
468/// records its own observation stream into
469/// `observations_per_agent[i]` (a flat `[T * obs_dim]` buffer).
470/// Per-agent actions / log-probs / values / rewards are stored as
471/// parallel host buffers and materialized into Burn tensors lazily
472/// inside [`JointMultiAgentTrainer::update`].
473#[derive(Debug, Clone)]
474pub struct JointRollout {
475 /// Per-agent observations: `Vec<N>[T * obs_dim]`. Each inner buffer
476 /// holds the observation stream for one agent across the rollout.
477 pub observations_per_agent: Vec<Vec<f32>>,
478 /// Observation dimensionality (uniform across agents).
479 pub obs_dim: usize,
480 /// Per-agent actions: `Vec<N>[T * num_action_dims]`. `num_action_dims`
481 /// is 1 for scalar discrete, `num_dims` for multi-discrete.
482 pub actions: Vec<Vec<i64>>,
483 /// Number of action dimensions (uniform across agents in this first cut).
484 pub num_action_dims: usize,
485 /// Per-agent rollout-time log-probs: `Vec<N>[T]`.
486 pub log_probs: Vec<Vec<f32>>,
487 /// Per-agent value estimates: `Vec<N>[T]`.
488 pub values: Vec<Vec<f32>>,
489 /// Per-agent rewards: `Vec<N>[T]`.
490 pub rewards: Vec<Vec<f32>>,
491 /// Episode-termination flag (shared across agents): `[T]`.
492 pub dones: Vec<f32>,
493}
494
495impl JointRollout {
496 /// Rollout length in steps.
497 pub fn num_steps(&self) -> usize {
498 self.dones.len()
499 }
500
501 /// Number of agents represented in this rollout.
502 pub fn num_agents(&self) -> usize {
503 self.actions.len()
504 }
505}
506
507/// Per-update training statistics for the joint trainer.
508///
509/// Mirrors [`crate::train::ppo::TrainingStats`] but with per-agent
510/// vectors for the agent-local quantities and a single shared scalar
511/// for the auxiliary cross-agent term.
512#[derive(Debug, Clone, Default)]
513pub struct JointStats {
514 /// Per-agent policy loss (averaged across PPO epochs).
515 pub policy_loss: Vec<f64>,
516 /// Per-agent value-function loss.
517 pub value_loss: Vec<f64>,
518 /// Per-agent entropy.
519 pub entropy: Vec<f64>,
520 /// Per-agent fraction of clipped updates.
521 pub clip_fraction: Vec<f64>,
522 /// Per-agent approximate KL divergence between old and new policy.
523 pub approx_kl: Vec<f64>,
524 /// Per-agent explained variance of the value function.
525 pub explained_var: Vec<f64>,
526 /// Auxiliary cross-agent loss (e.g. λ * redundancy_penalty). Scalar
527 /// shared by all agents because it's computed jointly on the same
528 /// minibatch features.
529 pub aux_loss: f64,
530 /// Total summed loss `Σ_i agent_loss_i + aux_loss` (averaged across
531 /// PPO epochs).
532 pub total_loss: f64,
533 /// Number of minibatch gradient steps actually taken across the whole
534 /// update (`Σ_epochs` minibatches walked). For the default
535 /// single-minibatch path this equals `n_epochs`; with
536 /// [`JointTrainerConfig::iterate_all_minibatches`] it equals
537 /// `n_epochs × ceil(num_steps / minibatch_size)`, and with
538 /// [`JointTrainerConfig::max_minibatches_per_epoch`] `= Some(cap)` it is
539 /// reduced to `n_epochs × min(cap, num_minibatches)`. Exposed so the
540 /// throughput lever (issue #251) is observable/testable.
541 pub num_mb_steps: usize,
542}
543
544impl JointStats {
545 /// Construct a fully-zeroed [`JointStats`] sized for `num_agents`
546 /// agents.
547 pub fn zeros(num_agents: usize) -> Self {
548 Self {
549 policy_loss: vec![0.0; num_agents],
550 value_loss: vec![0.0; num_agents],
551 entropy: vec![0.0; num_agents],
552 clip_fraction: vec![0.0; num_agents],
553 approx_kl: vec![0.0; num_agents],
554 explained_var: vec![0.0; num_agents],
555 aux_loss: 0.0,
556 total_loss: 0.0,
557 num_mb_steps: 0,
558 }
559 }
560}
561
562// -----------------------------------------------------------------------
563// Trainer
564// -----------------------------------------------------------------------
565
566/// Synchronized joint multi-agent PPO trainer (Burn backend).
567///
568/// Generic over:
569/// - `B: AutodiffBackend` — the Burn backend.
570/// - `P: JointPolicy<B>` — the per-agent policy module type.
571/// - `O: Optimizer<P, B>` — the Burn optimizer type (typically built from
572/// `AdamConfig::new().init()`).
573///
574/// The trainer owns `N` policies and `N` optimizers; gradient flow is
575/// parameter-isolated because each [`burn::optim::GradientsParams::from_grads`]
576/// slice extracts only one policy's parameters from the shared autograd
577/// gradients.
578pub struct JointMultiAgentTrainer<B, P, O>
579where
580 B: AutodiffBackend,
581 P: JointPolicy<B>,
582 O: Optimizer<P, B>,
583{
584 /// Owned policies. Stored in `Option<P>` slots because Burn's
585 /// `Optimizer::step` consumes the module by value; we `.take()` and
586 /// put back across each step.
587 policies: Vec<Option<P>>,
588 /// One optimizer per policy.
589 optimizers: Vec<BurnOptimizer<B, P, O>>,
590 /// Optional dedicated **critic** optimizer per policy (issue #239 fix
591 /// #4). Present only when [`JointTrainerConfig::critic_lr`] is `Some`
592 /// and the caller supplied them via
593 /// [`JointMultiAgentTrainer::with_critic_optimizers`]. When present, the
594 /// joint update splits actor / critic into two backward passes so the
595 /// critic can step at its own LR (see `critic_lr` docs). Empty otherwise.
596 critic_optimizers: Vec<BurnOptimizer<B, P, O>>,
597 /// Trainer configuration.
598 config: JointTrainerConfig,
599 /// Device the policies live on.
600 device: B::Device,
601}
602
603impl<B, P, O> JointMultiAgentTrainer<B, P, O>
604where
605 B: AutodiffBackend,
606 P: JointPolicy<B>,
607 O: Optimizer<P, B>,
608{
609 /// Construct a trainer from a fully-initialized set of policies and
610 /// optimizers.
611 ///
612 /// `optimizers[i]` is paired with `policies[i]` and only ever updates
613 /// `policies[i]`'s parameters.
614 pub fn new(
615 policies: Vec<P>,
616 optimizers: Vec<BurnOptimizer<B, P, O>>,
617 config: JointTrainerConfig,
618 device: B::Device,
619 ) -> Result<Self> {
620 if policies.is_empty() {
621 return Err(anyhow!("JointMultiAgentTrainer requires at least one policy"));
622 }
623 if policies.len() != config.num_agents {
624 return Err(anyhow!(
625 "JointMultiAgentTrainer: policies.len() ({}) != config.num_agents ({})",
626 policies.len(),
627 config.num_agents
628 ));
629 }
630 if optimizers.len() != policies.len() {
631 return Err(anyhow!(
632 "JointMultiAgentTrainer: optimizers.len() ({}) != policies.len() ({})",
633 optimizers.len(),
634 policies.len()
635 ));
636 }
637 // Apply the configured gradient-norm cap on every optimizer.
638 let mut optimizers = optimizers;
639 for opt in optimizers.iter_mut() {
640 opt.clip_grad_norm(config.max_grad_norm);
641 }
642 Ok(Self {
643 policies: policies.into_iter().map(Some).collect(),
644 optimizers,
645 critic_optimizers: Vec::new(),
646 config,
647 device,
648 })
649 }
650
651 /// Construct a trainer with a **dedicated per-agent critic optimizer**
652 /// (issue #239, ranked fix #4).
653 ///
654 /// `critic_optimizers[i]` is paired with `policies[i]` and steps only the
655 /// value-loss gradient (a second backward over `value_loss` alone) at its
656 /// own learning rate. This decouples the critic's effective LR from the
657 /// actor's so the critic can fit the bucket-brigade value target (whose
658 /// explained-variance was pinned at ~0 under the single shared optimizer —
659 /// see [`JointTrainerConfig::critic_lr`]).
660 ///
661 /// Requires [`JointTrainerConfig::critic_lr`] to be `Some`; the value is
662 /// the LR the supplied critic optimizers were constructed with (recorded
663 /// for diagnostics — the actual step uses each critic optimizer's own
664 /// construction LR). The same `max_grad_norm` cap is staged on the critic
665 /// optimizers too.
666 pub fn with_critic_optimizers(
667 policies: Vec<P>,
668 optimizers: Vec<BurnOptimizer<B, P, O>>,
669 critic_optimizers: Vec<BurnOptimizer<B, P, O>>,
670 config: JointTrainerConfig,
671 device: B::Device,
672 ) -> Result<Self> {
673 if critic_optimizers.len() != policies.len() {
674 return Err(anyhow!(
675 "JointMultiAgentTrainer: critic_optimizers.len() ({}) != policies.len() ({})",
676 critic_optimizers.len(),
677 policies.len()
678 ));
679 }
680 let mut trainer = Self::new(policies, optimizers, config, device)?;
681 let mut critic_optimizers = critic_optimizers;
682 for opt in critic_optimizers.iter_mut() {
683 opt.clip_grad_norm(trainer.config.max_grad_norm);
684 }
685 trainer.critic_optimizers = critic_optimizers;
686 Ok(trainer)
687 }
688
689 /// Device the trainer (and all its policies) live on.
690 pub fn device(&self) -> &B::Device {
691 &self.device
692 }
693
694 /// Trainer configuration.
695 pub fn config(&self) -> &JointTrainerConfig {
696 &self.config
697 }
698
699 /// Borrow agent `i`'s policy. Panics if the trainer is mid-`update`.
700 pub fn policy(&self, i: usize) -> &P {
701 self.policies[i].as_ref().expect("policy is None mid-update")
702 }
703
704 /// Drive a [`JointEnv`] for `config.rollout_steps` and return the
705 /// synchronized rollout buffer.
706 ///
707 /// `last_obs` is the persistent "next observation per agent" handed
708 /// in across iterations: pass the per-agent observations from the
709 /// most recent `env.reset_joint()` or step (i.e. the env's full
710 /// `Vec<Vec<f32>>` shape, length = `num_agents`). The trainer
711 /// updates it in place so callers can keep the rollout stream
712 /// stitched across iterations.
713 ///
714 /// `rng` is consumed by each per-step
715 /// [`JointPolicy::get_action_host_seeded`] call. Pass the
716 /// trainer-owned `StdRng` (e.g. `PsroTrainer::self.rng`) for
717 /// `PsroConfig::seed` / `NfspConfig::seed` to produce bit-identical
718 /// rollouts (issue #114).
719 pub fn collect_rollout<E: JointEnv>(
720 &self,
721 env: &mut E,
722 last_obs: &mut [Vec<f32>],
723 rng: &mut StdRng,
724 ) -> JointRollout {
725 let num_steps = self.config.rollout_steps;
726 let num_agents = self.config.num_agents;
727 assert_eq!(
728 last_obs.len(),
729 num_agents,
730 "collect_rollout: last_obs length ({}) must equal num_agents ({})",
731 last_obs.len(),
732 num_agents,
733 );
734 let obs_dim = last_obs[0].len();
735 let device = self.device.clone();
736
737 // Probe per-dim action layout from agent 0's policy (shape-only — no
738 // tensor ops touched, so the result is RNG-neutral). For this first
739 // Burn-native cut we require every agent to share the same
740 // num_action_dims; per-agent heterogeneous layouts can come later.
741 let num_action_dims: usize = self.policies[0]
742 .as_ref()
743 .expect("policy 0 present at rollout time")
744 .action_dims_joint()
745 .len();
746
747 let mut obs_buf_per_agent: Vec<Vec<f32>> =
748 (0..num_agents).map(|_| vec![0.0_f32; num_steps * obs_dim]).collect();
749 let mut act_buf: Vec<Vec<i64>> =
750 (0..num_agents).map(|_| vec![0_i64; num_steps * num_action_dims]).collect();
751 let mut lp_buf: Vec<Vec<f32>> = (0..num_agents).map(|_| vec![0.0_f32; num_steps]).collect();
752 let mut val_buf: Vec<Vec<f32>> =
753 (0..num_agents).map(|_| vec![0.0_f32; num_steps]).collect();
754 let mut rew_buf: Vec<Vec<f32>> =
755 (0..num_agents).map(|_| vec![0.0_f32; num_steps]).collect();
756 let mut done_buf = vec![0.0_f32; num_steps];
757
758 for t in 0..num_steps {
759 let start = t * obs_dim;
760
761 let mut joint_action: Vec<Vec<i64>> = Vec::with_capacity(num_agents);
762 for (i, slot) in self.policies.iter().enumerate() {
763 let policy = slot.as_ref().expect("policy present at rollout time");
764
765 // Per-agent observation: record into the agent-i buffer and
766 // build a single-row obs tensor for the agent-i policy.
767 let agent_obs = &last_obs[i];
768 obs_buf_per_agent[i][start..start + obs_dim].copy_from_slice(agent_obs);
769 let obs_t = Tensor::<B, 2>::from_data(
770 burn::tensor::TensorData::new(agent_obs.clone(), [1, obs_dim]),
771 &device,
772 );
773
774 let (actions_host, log_probs_host, values_host) =
775 policy.get_action_host_seeded(obs_t, rng);
776
777 // Extract per-agent action vector (length = num_action_dims).
778 let row: Vec<i64> = actions_host[..num_action_dims].to_vec();
779 let off = t * num_action_dims;
780 act_buf[i][off..off + num_action_dims].copy_from_slice(&row);
781 joint_action.push(row);
782
783 lp_buf[i][t] = log_probs_host.first().copied().unwrap_or(0.0);
784 val_buf[i][t] = values_host.first().copied().unwrap_or(0.0);
785 }
786
787 let result = env.step_joint(&joint_action);
788 for (i, rew) in rew_buf.iter_mut().enumerate().take(num_agents) {
789 rew[t] = result.rewards[i];
790 }
791 done_buf[t] = if result.done { 1.0 } else { 0.0 };
792
793 if result.done {
794 let fresh = env.reset_joint(None);
795 last_obs[..num_agents].clone_from_slice(&fresh[..num_agents]);
796 } else {
797 last_obs[..num_agents].clone_from_slice(&result.observations[..num_agents]);
798 }
799 }
800
801 JointRollout {
802 observations_per_agent: obs_buf_per_agent,
803 obs_dim,
804 actions: act_buf,
805 num_action_dims,
806 log_probs: lp_buf,
807 values: val_buf,
808 rewards: rew_buf,
809 dones: done_buf,
810 }
811 }
812
813 /// Joint PPO update.
814 ///
815 /// `aux_fn` receives a slice of per-agent encoder-feature tensors for
816 /// the current minibatch (one entry per policy, shape
817 /// `[mb, hidden_dim]`) and returns an optional pre-scaled scalar loss
818 /// (e.g. the cross-agent redundancy penalty). One `.backward()` flows
819 /// through every encoder when `aux_fn` returns `Some`.
820 ///
821 /// # Minibatch sampling
822 ///
823 /// One shuffled minibatch of size `config.minibatch_size` is drawn per
824 /// epoch, truncated against the rollout length. The order of indices
825 /// within the minibatch is irrelevant because every loss is a `mean` /
826 /// `sum` reduction over the minibatch dim and therefore
827 /// permutation-invariant.
828 pub fn update<F>(
829 &mut self,
830 rollout: &JointRollout,
831 rng: &mut StdRng,
832 aux_fn: F,
833 ) -> Result<JointStats>
834 where
835 F: FnMut(&[Tensor<B, 2>]) -> Option<Tensor<B, 1>>,
836 {
837 let num_agents = self.config.num_agents;
838 let active = vec![true; num_agents];
839 self.update_with_active_agents(rollout, &active, rng, aux_fn)
840 }
841
842 /// Joint PPO update with per-agent active mask — the freeze-N-1
843 /// primitive used by PSRO's best-response step.
844 ///
845 /// Identical to [`Self::update`] except that frozen agents
846 /// (`active[i] == false`) skip the optimizer step. Their loss is
847 /// still summed into the joint backward so the shared autograd
848 /// graph remains balanced, but their parameters are guaranteed
849 /// unchanged: we put the original policy back in its slot without
850 /// calling `optimizer.step`. Per-agent stats for frozen agents are
851 /// still recorded in the returned [`JointStats`] so callers can
852 /// monitor the mixture's behaviour on the rollout.
853 ///
854 /// # Use case
855 ///
856 /// PSRO's outer loop trains one *best-response* policy at a time
857 /// against a meta-Nash mixture over the rest of the population
858 /// (see [`crate::multi_agent::psro`]). Passing
859 /// `active = [false, ..., true (active idx), ..., false]` here is
860 /// the canonical freeze-N-1 pattern.
861 ///
862 /// # Panics
863 ///
864 /// Returns `Err` if `active.len() != config.num_agents`.
865 pub fn update_with_active_agents<F>(
866 &mut self,
867 rollout: &JointRollout,
868 active: &[bool],
869 rng: &mut StdRng,
870 mut aux_fn: F,
871 ) -> Result<JointStats>
872 where
873 F: FnMut(&[Tensor<B, 2>]) -> Option<Tensor<B, 1>>,
874 {
875 if active.len() != self.config.num_agents {
876 return Err(anyhow!(
877 "active mask length {} != config.num_agents {}",
878 active.len(),
879 self.config.num_agents
880 ));
881 }
882 let device = self.device.clone();
883 let num_agents = self.config.num_agents;
884 let num_steps = rollout.num_steps();
885 if num_steps == 0 {
886 return Err(anyhow!("rollout is empty"));
887 }
888 if rollout.num_agents() != num_agents {
889 return Err(anyhow!(
890 "rollout has {} agents but trainer is configured for {}",
891 rollout.num_agents(),
892 num_agents
893 ));
894 }
895
896 // Per-agent advantages and returns. Computed once outside the epoch
897 // loop (matches the tch-era reference behaviour).
898 let mut advantages_host: Vec<Vec<f32>> = Vec::with_capacity(num_agents);
899 let mut returns_host: Vec<Vec<f32>> = Vec::with_capacity(num_agents);
900 for i in 0..num_agents {
901 let (adv, ret) = compute_gae_single_agent(
902 &rollout.rewards[i],
903 &rollout.values[i],
904 &rollout.dones,
905 self.config.gamma as f32,
906 self.config.gae_lambda as f32,
907 );
908 let adv = if self.config.normalize_advantages {
909 normalize_advantages(&adv)
910 } else {
911 adv
912 };
913 advantages_host.push(adv);
914 returns_host.push(ret);
915 }
916
917 let mut stats = JointStats::zeros(num_agents);
918 let mb_size = self.config.minibatch_size.min(num_steps);
919 // Number of minibatch gradient steps actually taken across the
920 // whole update; used to average the accumulated per-step stats.
921 let mut num_mb_steps: usize = 0;
922
923 for _epoch in 0..self.config.n_epochs {
924 // Build this epoch's minibatch index-sets.
925 //
926 // * `iterate_all_minibatches == false` (default): one shuffled `minibatch_size`
927 // draw per epoch — the historical #100 behaviour, kept bit-identical so
928 // existing NFSP/PSRO determinism tests are unaffected.
929 // * `iterate_all_minibatches == true`: shuffle once and walk every
930 // `minibatch_size` chunk so the BR consumes the full rollout instead of
931 // discarding ~97% of it (issue #239).
932 //
933 // Both branches draw their shuffle randomness from the
934 // caller-supplied RNG so PSRO / NFSP runs stay reproducible
935 // under their configured seeds (see issues #109 / #114).
936 use rand::seq::SliceRandom;
937 let mut minibatches: Vec<Vec<usize>> = if self.config.iterate_all_minibatches {
938 generate_minibatch_indices_with_rng(num_steps, mb_size, rng)
939 } else {
940 let mut indices: Vec<usize> = (0..num_steps).collect();
941 indices.shuffle(rng);
942 indices.truncate(mb_size);
943 vec![indices]
944 };
945
946 // Issue #251 throughput lever: cap the number of minibatch
947 // gradient steps per epoch. The indices were globally shuffled
948 // before chunking, so keeping the first `cap` chunks is a
949 // uniformly-random subsample of the rollout. Clamp to >= 1 so a
950 // capped update never degenerates to zero steps. `None` (default)
951 // leaves the full all-minibatch coverage untouched.
952 if let Some(cap) = self.config.max_minibatches_per_epoch {
953 let cap = cap.max(1);
954 if cap < minibatches.len() {
955 minibatches.truncate(cap);
956 }
957 }
958
959 for indices in minibatches {
960 num_mb_steps += 1;
961 // Per-agent obs minibatches: agent `i` reads from its own
962 // observation buffer at `rollout.observations_per_agent[i]`.
963 let obs_mb_per_agent: Vec<Tensor<B, 2>> = (0..num_agents)
964 .map(|i| {
965 select_obs(
966 &rollout.observations_per_agent[i],
967 rollout.obs_dim,
968 &indices,
969 &device,
970 )
971 })
972 .collect();
973
974 // Per-agent forward + per-agent loss accumulation.
975 //
976 // We collect per-agent loss tensors and feature tensors first,
977 // sum them into a single `joint_loss`, then backward once. The
978 // gradients of `joint_loss` w.r.t. each policy's parameters are
979 // then extracted via `GradientsParams::from_grads` and applied
980 // per-policy through that policy's optimizer.
981 let mut per_agent_losses: Vec<Tensor<B, 1>> = Vec::with_capacity(num_agents);
982 let mut features: Vec<Tensor<B, 2>> = Vec::with_capacity(num_agents);
983 // Issue #239 fix #4: when a dedicated critic optimizer is
984 // present, the value-loss term is held out of the actor's
985 // joint loss and stepped separately at `critic_lr`. We keep
986 // each per-agent value-loss tensor (graph still alive) for a
987 // second backward below.
988 let split_critic = !self.critic_optimizers.is_empty();
989 let mut per_agent_value_losses: Vec<Tensor<B, 1>> = if split_critic {
990 Vec::with_capacity(num_agents)
991 } else {
992 Vec::new()
993 };
994
995 // Per-agent host scratch for stats.
996 let mut policy_loss_hosts = vec![0.0_f64; num_agents];
997 let mut value_loss_hosts = vec![0.0_f64; num_agents];
998 let mut entropy_hosts = vec![0.0_f64; num_agents];
999 let mut clip_frac_hosts = vec![0.0_f64; num_agents];
1000 let mut kl_hosts = vec![0.0_f64; num_agents];
1001 let mut ev_hosts = vec![0.0_f64; num_agents];
1002
1003 for i in 0..num_agents {
1004 let policy = self.policies[i]
1005 .as_ref()
1006 .ok_or_else(|| anyhow!("policy {} is None mid-update", i))?;
1007
1008 let obs_mb_i = obs_mb_per_agent[i].clone();
1009 let actions_mb = select_actions(
1010 &rollout.actions[i],
1011 rollout.num_action_dims,
1012 &indices,
1013 &device,
1014 );
1015 let old_lp_mb = select_f32_row(&rollout.log_probs[i], &indices, &device);
1016 let adv_mb = select_f32_row(&advantages_host[i], &indices, &device);
1017 let ret_mb = select_f32_row(&returns_host[i], &indices, &device);
1018 let old_v_mb = select_f32_row(&rollout.values[i], &indices, &device);
1019
1020 let (new_lp, entropy, values_mb) =
1021 policy.evaluate_actions_joint(obs_mb_i.clone(), actions_mb);
1022 let feat = policy.encoder_features_joint(obs_mb_i);
1023
1024 let (policy_loss, clip_frac, kl) =
1025 compute_policy_loss(new_lp, old_lp_mb, adv_mb, self.config.clip_range);
1026 let (value_loss, explained_var) =
1027 compute_value_loss(values_mb, old_v_mb, ret_mb, self.config.clip_range_vf);
1028 let entropy_mean = entropy.mean();
1029
1030 // Host-side stats. We pull scalars from each per-agent loss
1031 // tensor *before* moving them into the joint sum so we don't
1032 // need to clone twice.
1033 policy_loss_hosts[i] = scalar_f64(policy_loss.clone());
1034 value_loss_hosts[i] = scalar_f64(value_loss.clone());
1035 entropy_hosts[i] = scalar_f64(entropy_mean.clone());
1036 clip_frac_hosts[i] = clip_frac;
1037 kl_hosts[i] = kl;
1038 ev_hosts[i] = explained_var;
1039
1040 let entropy_term = entropy_mean.neg().mul_scalar(self.config.ent_coef as f32);
1041 let agent_loss = if split_critic {
1042 // Critic stepped separately at `critic_lr`; keep the
1043 // value-loss tensor (its autodiff graph is still live)
1044 // for the second backward, and exclude it from the
1045 // actor's joint loss.
1046 per_agent_value_losses.push(value_loss);
1047 policy_loss + entropy_term
1048 } else {
1049 policy_loss
1050 + value_loss.mul_scalar(self.config.vf_coef as f32)
1051 + entropy_term
1052 };
1053
1054 per_agent_losses.push(agent_loss);
1055 features.push(feat);
1056 }
1057
1058 // Aggregate per-agent losses, then add the cross-agent aux term.
1059 let mut joint_loss: Option<Tensor<B, 1>> = None;
1060 for l in per_agent_losses {
1061 joint_loss = Some(match joint_loss.take() {
1062 Some(acc) => acc + l,
1063 None => l,
1064 });
1065 }
1066 let aux_opt = aux_fn(&features);
1067 let aux_scalar: f64 =
1068 aux_opt.as_ref().map(|t| scalar_f64(t.clone())).unwrap_or(0.0);
1069 stats.aux_loss += aux_scalar;
1070 if let Some(aux) = aux_opt {
1071 joint_loss = Some(match joint_loss.take() {
1072 Some(acc) => acc + aux,
1073 None => aux,
1074 });
1075 }
1076 let joint_loss = joint_loss.ok_or_else(|| anyhow!("no losses to backprop"))?;
1077 stats.total_loss += scalar_f64(joint_loss.clone());
1078
1079 // Single backward over the joint loss; the resulting `Gradients`
1080 // carry grads for every policy's parameters. Sliced per-policy
1081 // below.
1082 //
1083 // Burn's `Gradients` container is *consumed* per param when we
1084 // call `from_module(&mut grads, policy_i)` — each visit removes
1085 // policy `i`'s param tensors from the shared container. That's
1086 // exactly the per-agent isolation we want: each optimizer only
1087 // sees grads for its own policy's parameters, and a single
1088 // backward feeds all of them.
1089 let mut grads = joint_loss.backward();
1090
1091 for i in 0..num_agents {
1092 let policy = self.policies[i]
1093 .take()
1094 .ok_or_else(|| anyhow!("policy {} is None mid-step", i))?;
1095 // Drain gradient slice for policy `i` either way; this
1096 // keeps the `Gradients` container consistent across all
1097 // agents (Burn removes policy `i`'s params on
1098 // `from_module`, so we always do the drain).
1099 let policy_grads = GradientsParams::from_module(&mut grads, &policy);
1100 let updated = if active[i] {
1101 let lr = self.optimizers[i].learning_rate();
1102 // Global gradient-norm clipping (issue #239). The cap
1103 // configured via `JointTrainerConfig::max_grad_norm` is
1104 // staged on every optimizer in `new`; apply it here to
1105 // each policy's gradient slice before the move-through
1106 // step so the (potentially large) value-loss gradient
1107 // cannot swamp the shared actor-critic trunk. A `None`
1108 // cap leaves the gradients untouched.
1109 let policy_grads = match self.optimizers[i].grad_clip_norm() {
1110 Some(max_norm) if max_norm > 0.0 => clip_grads_by_global_norm::<B, P>(
1111 &policy,
1112 policy_grads,
1113 max_norm as f32,
1114 ),
1115 _ => policy_grads,
1116 };
1117 self.optimizers[i].inner_mut().step(lr, policy, policy_grads)
1118 } else {
1119 // Frozen agent: drop the gradients and put the policy
1120 // back unchanged. This is the freeze-N-1 invariant
1121 // PSRO's best-response step relies on.
1122 drop(policy_grads);
1123 policy
1124 };
1125 self.policies[i] = Some(updated);
1126
1127 stats.policy_loss[i] += policy_loss_hosts[i];
1128 stats.value_loss[i] += value_loss_hosts[i];
1129 stats.entropy[i] += entropy_hosts[i];
1130 stats.clip_fraction[i] += clip_frac_hosts[i];
1131 stats.approx_kl[i] += kl_hosts[i];
1132 stats.explained_var[i] += ev_hosts[i];
1133 }
1134
1135 // ---- Critic pass (issue #239 fix #4) -----------------------
1136 // When a dedicated critic optimizer is present, run a second
1137 // backward over the value loss alone and step it at the
1138 // critic LR. This gives the critic its own Adam moment state
1139 // and (typically higher) learning rate, decoupled from the
1140 // actor, so it can fit the bucket-brigade value target whose
1141 // explained-variance was otherwise pinned at ~0.
1142 if split_critic {
1143 // Sum the per-agent value losses (weighted by vf_coef, so
1144 // the scalar magnitude matches the historical combined
1145 // term and the existing grad-clip cap stays calibrated).
1146 let mut critic_joint: Option<Tensor<B, 1>> = None;
1147 for vl in per_agent_value_losses {
1148 let term = vl.mul_scalar(self.config.vf_coef as f32);
1149 critic_joint = Some(match critic_joint.take() {
1150 Some(acc) => acc + term,
1151 None => term,
1152 });
1153 }
1154 let critic_joint =
1155 critic_joint.ok_or_else(|| anyhow!("no value losses to backprop"))?;
1156 let mut critic_grads = critic_joint.backward();
1157
1158 // Indexing `self.policies` / `self.critic_optimizers` by `i`
1159 // is load-bearing (`.take()` mutates the `Option` slot), so
1160 // the range loop cannot be rewritten as an iterator over a
1161 // single slice; mirrors the actor step loop above.
1162 #[allow(clippy::needless_range_loop)]
1163 for i in 0..num_agents {
1164 let policy = self.policies[i]
1165 .take()
1166 .ok_or_else(|| anyhow!("policy {} is None mid-critic-step", i))?;
1167 let value_grads = GradientsParams::from_module(&mut critic_grads, &policy);
1168 let updated = if active[i] {
1169 let lr = self.critic_optimizers[i].learning_rate();
1170 let value_grads = match self.critic_optimizers[i].grad_clip_norm() {
1171 Some(max_norm) if max_norm > 0.0 => {
1172 clip_grads_by_global_norm::<B, P>(
1173 &policy,
1174 value_grads,
1175 max_norm as f32,
1176 )
1177 }
1178 _ => value_grads,
1179 };
1180 self.critic_optimizers[i].inner_mut().step(lr, policy, value_grads)
1181 } else {
1182 drop(value_grads);
1183 policy
1184 };
1185 self.policies[i] = Some(updated);
1186 }
1187 }
1188 }
1189 }
1190
1191 // Average across all minibatch gradient steps taken (one per
1192 // minibatch per epoch). With the default single-minibatch path this
1193 // is exactly `n_epochs`, preserving the historical averaging.
1194 let n = num_mb_steps as f64;
1195 if n > 0.0 {
1196 for i in 0..num_agents {
1197 stats.policy_loss[i] /= n;
1198 stats.value_loss[i] /= n;
1199 stats.entropy[i] /= n;
1200 stats.clip_fraction[i] /= n;
1201 stats.approx_kl[i] /= n;
1202 stats.explained_var[i] /= n;
1203 }
1204 stats.aux_loss /= n;
1205 stats.total_loss /= n;
1206 }
1207 stats.num_mb_steps = num_mb_steps;
1208
1209 Ok(stats)
1210 }
1211}
1212
1213// -----------------------------------------------------------------------
1214// Helpers
1215// -----------------------------------------------------------------------
1216
1217/// Clip a policy's gradients to a global L2 norm (issue #239).
1218///
1219/// This is the standard PPO/A2C "global gradient norm clip": compute the
1220/// L2 norm of the **concatenation** of every parameter's gradient, and if
1221/// it exceeds `max_norm`, scale *all* gradients by
1222/// `max_norm / (total_norm + eps)`. Unlike Burn's built-in
1223/// `GradientClipping::Norm` (which clips each parameter tensor
1224/// independently), this preserves the *direction* of the joint gradient —
1225/// which is exactly what we need so the large shared-trunk value-loss
1226/// gradient is tamed without distorting the relative scale of the policy
1227/// and value contributions.
1228///
1229/// Implemented with two module visitor passes:
1230/// 1. accumulate `Σ ‖g_p‖²` across all parameters `p`,
1231/// 2. if the resulting norm exceeds `max_norm`, multiply every gradient by the
1232/// clip coefficient and re-register it.
1233///
1234/// A non-finite or non-positive total norm leaves the gradients untouched.
1235fn clip_grads_by_global_norm<B, M>(
1236 module: &M,
1237 grads: GradientsParams,
1238 max_norm: f32,
1239) -> GradientsParams
1240where
1241 B: AutodiffBackend,
1242 M: Module<B>,
1243{
1244 // Burn stores gradient tensors on the *inner* (non-autodiff) backend,
1245 // so we fetch / re-register them as `Tensor<B::InnerBackend, D>`.
1246 type Inner<B> = <B as AutodiffBackend>::InnerBackend;
1247
1248 // Pass 1: sum of squared gradient norms.
1249 struct NormAccumulator<'a, B: AutodiffBackend> {
1250 grads: &'a GradientsParams,
1251 sum_sq: f64,
1252 _marker: core::marker::PhantomData<B>,
1253 }
1254 impl<B: AutodiffBackend> ModuleVisitor<B> for NormAccumulator<'_, B> {
1255 fn visit_float<const D: usize>(&mut self, param: &Param<Tensor<B, D>>) {
1256 if let Some(g) = self.grads.get::<Inner<B>, D>(param.id) {
1257 let sq = g.powf_scalar(2.0).sum();
1258 self.sum_sq += scalar_f64(sq);
1259 }
1260 }
1261 }
1262 let mut acc =
1263 NormAccumulator::<B> { grads: &grads, sum_sq: 0.0, _marker: core::marker::PhantomData };
1264 module.visit(&mut acc);
1265
1266 let total_norm = acc.sum_sq.sqrt();
1267 if !total_norm.is_finite() || total_norm <= max_norm as f64 || max_norm <= 0.0 {
1268 // Already within the cap (or degenerate) — nothing to scale.
1269 return grads;
1270 }
1271 let clip_coef = (max_norm as f64 / (total_norm + 1e-6)) as f32;
1272
1273 // Pass 2: scale every gradient by the clip coefficient.
1274 struct Scaler<'a, B: AutodiffBackend> {
1275 grads: &'a mut GradientsParams,
1276 coef: f32,
1277 _marker: core::marker::PhantomData<B>,
1278 }
1279 impl<B: AutodiffBackend> ModuleVisitor<B> for Scaler<'_, B> {
1280 fn visit_float<const D: usize>(&mut self, param: &Param<Tensor<B, D>>) {
1281 if let Some(g) = self.grads.remove::<Inner<B>, D>(param.id) {
1282 self.grads.register::<Inner<B>, D>(param.id, g.mul_scalar(self.coef));
1283 }
1284 }
1285 }
1286 let mut grads = grads;
1287 let mut scaler =
1288 Scaler::<B> { grads: &mut grads, coef: clip_coef, _marker: core::marker::PhantomData };
1289 module.visit(&mut scaler);
1290 grads
1291}
1292
1293/// Per-agent single-trajectory GAE (host-side).
1294///
1295/// Mirrors the pre-Burn `compute_gae_single_agent` helper: takes 1-D
1296/// rewards / values / dones host buffers and returns
1297/// `(advantages, returns)`. The trailing-step value is taken as zero
1298/// (no post-rollout bootstrap), matching the tch reference.
1299fn compute_gae_single_agent(
1300 rewards: &[f32],
1301 values: &[f32],
1302 dones: &[f32],
1303 gamma: f32,
1304 gae_lambda: f32,
1305) -> (Vec<f32>, Vec<f32>) {
1306 let t = rewards.len();
1307 let mut advantages = vec![0.0_f32; t];
1308 let mut gae = 0.0_f32;
1309 for i in (0..t).rev() {
1310 let next_v = if i == t - 1 { 0.0 } else { values[i + 1] };
1311 let mask = 1.0 - dones[i];
1312 let delta = rewards[i] + gamma * next_v * mask - values[i];
1313 gae = delta + gamma * gae_lambda * mask * gae;
1314 advantages[i] = gae;
1315 }
1316 let returns: Vec<f32> = advantages.iter().zip(values).map(|(&a, &v)| a + v).collect();
1317 (advantages, returns)
1318}
1319
1320/// Standardize a vector of advantages to zero mean / unit variance.
1321fn normalize_advantages(adv: &[f32]) -> Vec<f32> {
1322 if adv.is_empty() {
1323 return Vec::new();
1324 }
1325 let n = adv.len() as f64;
1326 let mean: f64 = adv.iter().map(|&x| x as f64).sum::<f64>() / n;
1327 let var: f64 = adv.iter().map(|&x| (x as f64 - mean).powi(2)).sum::<f64>() / n;
1328 let std = var.sqrt().max(1e-8);
1329 adv.iter().map(|&x| ((x as f64 - mean) / std) as f32).collect()
1330}
1331
1332/// Build a `[mb, obs_dim]` tensor from the host observation buffer.
1333fn select_obs<B: AutodiffBackend>(
1334 obs_flat: &[f32],
1335 obs_dim: usize,
1336 indices: &[usize],
1337 device: &B::Device,
1338) -> Tensor<B, 2> {
1339 let mut out = Vec::with_capacity(indices.len() * obs_dim);
1340 for &i in indices {
1341 let start = i * obs_dim;
1342 out.extend_from_slice(&obs_flat[start..start + obs_dim]);
1343 }
1344 Tensor::<B, 2>::from_data(burn::tensor::TensorData::new(out, [indices.len(), obs_dim]), device)
1345}
1346
1347/// Build a `[mb, num_action_dims]` int tensor from the host action buffer.
1348fn select_actions<B: AutodiffBackend>(
1349 actions_flat: &[i64],
1350 num_action_dims: usize,
1351 indices: &[usize],
1352 device: &B::Device,
1353) -> Tensor<B, 2, Int> {
1354 let mut out = Vec::with_capacity(indices.len() * num_action_dims);
1355 for &i in indices {
1356 let start = i * num_action_dims;
1357 out.extend_from_slice(&actions_flat[start..start + num_action_dims]);
1358 }
1359 Tensor::<B, 2, Int>::from_data(
1360 burn::tensor::TensorData::new(out, [indices.len(), num_action_dims]),
1361 device,
1362 )
1363}
1364
1365/// Build a `[mb]` float tensor by gathering host rows.
1366fn select_f32_row<B: AutodiffBackend>(
1367 src: &[f32],
1368 indices: &[usize],
1369 device: &B::Device,
1370) -> Tensor<B, 1> {
1371 let out: Vec<f32> = indices.iter().map(|&i| src[i]).collect();
1372 Tensor::<B, 1>::from_data(burn::tensor::TensorData::new(out, [indices.len()]), device)
1373}
1374
1375// -----------------------------------------------------------------------
1376// Tests
1377// -----------------------------------------------------------------------
1378
1379#[cfg(test)]
1380mod tests {
1381 use burn::{
1382 backend::{Autodiff, NdArray, ndarray::NdArrayDevice},
1383 optim::AdamConfig,
1384 };
1385 use rand::SeedableRng;
1386
1387 use super::*;
1388 use crate::{
1389 policy::{mlp::MlpBurnPolicy, multi_discrete_mlp::MultiDiscreteMlpBurnPolicy},
1390 train::optimizer::BurnOptimizer,
1391 };
1392
1393 type B = Autodiff<NdArray<f32>>;
1394
1395 /// Hand-computed GAE/returns reference on a tiny rollout (issue #243).
1396 ///
1397 /// This pins the [`compute_gae_single_agent`] recursion against values
1398 /// computed by hand, independent of any env. The rollout deliberately
1399 /// contains **both** boundary kinds the joint trainer can encounter:
1400 ///
1401 /// * a **terminal** boundary at interior step `t == 1` (`done == 1.0`):
1402 /// both the value bootstrap `γ·V(s_{t+1})` *and* the GAE carry
1403 /// `γ·λ·A_{t+1}` must be masked to zero, so the advantage at the terminal
1404 /// step is exactly its own one-step TD error and the GAE chain does not
1405 /// leak across the episode boundary.
1406 /// * a **truncation** boundary at the final step `t == T-1`: the rollout
1407 /// ends here with `done == 0.0`, and this implementation uses a zero
1408 /// trailing bootstrap (`next_v == 0.0`, no post-rollout `V(s_T)`), as
1409 /// documented on `compute_gae_single_agent`.
1410 ///
1411 /// Reference values (γ = 0.9, λ = 0.8), computed step-by-step in reverse:
1412 ///
1413 /// ```text
1414 /// rewards = [1.0, 2.0, 3.0, 4.0]
1415 /// values = [0.5, 1.0, 1.5, 2.0]
1416 /// dones = [0.0, 1.0, 0.0, 0.0]
1417 ///
1418 /// t=3 (truncation/rollout-end): next_v=0, mask=1
1419 /// delta = 4 + 0.9*0*1 - 2.0 = 2.00
1420 /// A_3 = 2.00 + 0.72*1*0 = 2.00
1421 /// t=2: next_v=2.0, mask=1
1422 /// delta = 3 + 0.9*2.0*1 - 1.5 = 3.30
1423 /// A_2 = 3.30 + 0.72*1*2.00 = 4.74
1424 /// t=1 (terminal, done=1): next_v=1.5, mask=0
1425 /// delta = 2 + 0.9*1.5*0 - 1.0 = 1.00 (bootstrap masked)
1426 /// A_1 = 1.00 + 0.72*0*4.74 = 1.00 (GAE carry masked)
1427 /// t=0: next_v=1.0, mask=1
1428 /// delta = 1 + 0.9*1.0*1 - 0.5 = 1.40
1429 /// A_0 = 1.40 + 0.72*1*1.00 = 2.12
1430 ///
1431 /// advantages = [2.12, 1.00, 4.74, 2.00]
1432 /// returns = advantages + values = [2.62, 2.00, 6.24, 4.00]
1433 /// ```
1434 #[test]
1435 fn gae_matches_hand_computed_reference_with_terminal_and_truncation() {
1436 let rewards = [1.0_f32, 2.0, 3.0, 4.0];
1437 let values = [0.5_f32, 1.0, 1.5, 2.0];
1438 let dones = [0.0_f32, 1.0, 0.0, 0.0];
1439 let gamma = 0.9_f32;
1440 let lambda = 0.8_f32;
1441
1442 let (adv, ret) = compute_gae_single_agent(&rewards, &values, &dones, gamma, lambda);
1443
1444 let expected_adv = [2.12_f32, 1.00, 4.74, 2.00];
1445 let expected_ret = [2.62_f32, 2.00, 6.24, 4.00];
1446
1447 assert_eq!(adv.len(), 4);
1448 assert_eq!(ret.len(), 4);
1449 for (i, (&a, &e)) in adv.iter().zip(&expected_adv).enumerate() {
1450 assert!(
1451 (a - e).abs() < 1e-5,
1452 "advantage[{i}] = {a}, expected {e} (terminal at t=1, truncation at t=3)"
1453 );
1454 }
1455 for (i, (&r, &e)) in ret.iter().zip(&expected_ret).enumerate() {
1456 assert!((r - e).abs() < 1e-5, "return[{i}] = {r}, expected {e}");
1457 }
1458
1459 // Explicit invariant check on the terminal boundary: A_1 must equal
1460 // exactly its own TD error (r_1 - V(s_1) = 1.0) with no leakage of
1461 // the γλ carry from A_2 across the episode boundary.
1462 assert!(
1463 (adv[1] - (rewards[1] - values[1])).abs() < 1e-6,
1464 "terminal-step advantage must equal its own TD error, no GAE carry-through"
1465 );
1466 }
1467
1468 /// A pure-terminal rollout (every step `done == 1`) must produce
1469 /// advantages equal to each step's own one-step TD error with the
1470 /// trailing bootstrap zeroed — there is no cross-step propagation at
1471 /// all. This isolates the `(1 - done)` masking from the recursion.
1472 #[test]
1473 fn gae_all_terminal_collapses_to_td_errors() {
1474 let rewards = [1.0_f32, -2.0, 0.5];
1475 let values = [0.25_f32, 0.5, -0.5];
1476 let dones = [1.0_f32, 1.0, 1.0];
1477 let gamma = 0.99_f32;
1478 let lambda = 0.95_f32;
1479
1480 let (adv, ret) = compute_gae_single_agent(&rewards, &values, &dones, gamma, lambda);
1481
1482 for i in 0..rewards.len() {
1483 let td = rewards[i] - values[i]; // bootstrap and carry both masked
1484 assert!((adv[i] - td).abs() < 1e-6, "adv[{i}] should be pure TD error");
1485 assert!((ret[i] - (td + values[i])).abs() < 1e-6, "ret[{i}] = adv + value");
1486 }
1487 }
1488
1489 /// `normalize_advantages` must standardize to zero mean / unit variance
1490 /// (population std) and preserve relative ordering / sign structure.
1491 #[test]
1492 fn normalize_advantages_zero_mean_unit_std() {
1493 let adv = [2.12_f32, 1.00, 4.74, 2.00];
1494 let out = normalize_advantages(&adv);
1495
1496 let n = out.len() as f64;
1497 let mean: f64 = out.iter().map(|&x| x as f64).sum::<f64>() / n;
1498 let var: f64 = out.iter().map(|&x| (x as f64 - mean).powi(2)).sum::<f64>() / n;
1499 assert!(mean.abs() < 1e-5, "normalized mean should be ~0, got {mean}");
1500 assert!(
1501 (var.sqrt() - 1.0).abs() < 1e-4,
1502 "normalized std should be ~1, got {}",
1503 var.sqrt()
1504 );
1505
1506 // Ordering preserved: the largest raw advantage stays the largest.
1507 let argmax_raw = 2usize; // 4.74
1508 let argmax_norm =
1509 out.iter().enumerate().max_by(|a, b| a.1.partial_cmp(b.1).unwrap()).unwrap().0;
1510 assert_eq!(argmax_raw, argmax_norm, "normalization must preserve ordering");
1511 }
1512
1513 /// Deterministic mock env: 4-dim obs (sin/cos-ish encoding of t),
1514 /// scalar rewards = sum of actions, never terminates within rollout.
1515 struct MockEnv {
1516 num_agents: usize,
1517 obs_dim: usize,
1518 t: usize,
1519 }
1520
1521 impl MockEnv {
1522 fn new(num_agents: usize, obs_dim: usize) -> Self {
1523 Self { num_agents, obs_dim, t: 0 }
1524 }
1525
1526 fn obs_for(&self) -> Vec<f32> {
1527 (0..self.obs_dim)
1528 .map(|i| (((self.t * 7 + i * 13) % 100) as f32) / 100.0 - 0.5)
1529 .collect()
1530 }
1531 }
1532
1533 impl JointEnv for MockEnv {
1534 fn reset_joint(&mut self, _seed: Option<u64>) -> Vec<Vec<f32>> {
1535 self.t = 0;
1536 let obs = self.obs_for();
1537 (0..self.num_agents).map(|_| obs.clone()).collect()
1538 }
1539
1540 fn step_joint(&mut self, actions: &[Vec<i64>]) -> JointStepResult {
1541 self.t += 1;
1542 let rewards: Vec<f32> = actions
1543 .iter()
1544 .map(|a| a.iter().map(|&x| x as f32).sum::<f32>() / 10.0)
1545 .collect();
1546 let obs = self.obs_for();
1547 let observations = (0..self.num_agents).map(|_| obs.clone()).collect();
1548 JointStepResult { rewards, done: false, observations }
1549 }
1550 }
1551
1552 fn make_mlp_policies(
1553 num_agents: usize,
1554 obs_dim: usize,
1555 action_dim: usize,
1556 hidden_dim: usize,
1557 device: &NdArrayDevice,
1558 ) -> Vec<MlpBurnPolicy<B>> {
1559 (0..num_agents)
1560 .map(|_| MlpBurnPolicy::<B>::new(obs_dim, action_dim, hidden_dim, device))
1561 .collect()
1562 }
1563
1564 fn make_multi_discrete_policies(
1565 num_agents: usize,
1566 obs_dim: usize,
1567 action_dims: Vec<usize>,
1568 hidden_dim: usize,
1569 device: &NdArrayDevice,
1570 ) -> Vec<MultiDiscreteMlpBurnPolicy<B>> {
1571 (0..num_agents)
1572 .map(|_| {
1573 MultiDiscreteMlpBurnPolicy::<B>::new(
1574 obs_dim,
1575 action_dims.clone(),
1576 hidden_dim,
1577 device,
1578 )
1579 })
1580 .collect()
1581 }
1582
1583 fn build_optimizers<P>(n: usize, lr: f64) -> Vec<BurnOptimizer<B, P, impl Optimizer<P, B>>>
1584 where
1585 P: AutodiffModule<B>,
1586 {
1587 (0..n)
1588 .map(|_| {
1589 let inner = AdamConfig::new().init();
1590 BurnOptimizer::<B, P, _>::new(inner, lr)
1591 })
1592 .collect()
1593 }
1594
1595 #[test]
1596 fn test_joint_trainer_smoke() {
1597 // 2 tiny MlpBurnPolicy instances, scalar discrete actions, 64-step
1598 // rollout, one update with aux_fn returning None. Assert no panics
1599 // + finite stats. This is the load-bearing acceptance test for the
1600 // Burn-native multi_agent port (issue #100).
1601 let device = Default::default();
1602 let num_agents = 2;
1603 let obs_dim: usize = 4;
1604 let action_dim: usize = 3;
1605 let policies = make_mlp_policies(num_agents, obs_dim, action_dim, 16, &device);
1606 let optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents, 3e-4);
1607
1608 let config = JointTrainerConfig {
1609 num_agents,
1610 rollout_steps: 64,
1611 n_epochs: 2,
1612 minibatch_size: 32,
1613 ..Default::default()
1614 };
1615 let mut trainer =
1616 JointMultiAgentTrainer::new(policies, optimizers, config, device).unwrap();
1617
1618 let mut env = MockEnv::new(num_agents, obs_dim);
1619 let mut last_obs = env.reset_joint(None);
1620
1621 let mut rng = StdRng::seed_from_u64(0);
1622 let rollout = trainer.collect_rollout(&mut env, &mut last_obs, &mut rng);
1623 let stats = trainer
1624 .update(&rollout, &mut rng, |_features: &[Tensor<B, 2>]| -> Option<Tensor<B, 1>> {
1625 None
1626 })
1627 .expect("update should not error");
1628
1629 assert!(stats.total_loss.is_finite(), "total_loss must be finite");
1630 assert_eq!(stats.aux_loss, 0.0, "aux_loss must be 0 when aux_fn returns None");
1631 for i in 0..num_agents {
1632 assert!(stats.policy_loss[i].is_finite(), "policy_loss[{i}] finite");
1633 assert!(stats.value_loss[i].is_finite(), "value_loss[{i}] finite");
1634 assert!(stats.entropy[i].is_finite(), "entropy[{i}] finite");
1635 assert!(stats.clip_fraction[i].is_finite(), "clip_fraction[{i}] finite");
1636 assert!(stats.approx_kl[i].is_finite(), "approx_kl[{i}] finite");
1637 assert!(stats.explained_var[i].is_finite(), "explained_var[{i}] finite");
1638 }
1639 }
1640
1641 #[test]
1642 fn test_max_minibatches_per_epoch_caps_grad_steps() {
1643 // Issue #251 throughput lever: assert `max_minibatches_per_epoch`
1644 // reduces the number of minibatch gradient steps as configured, that
1645 // its `None` default preserves the full all-minibatch behaviour, and
1646 // that aggressive / edge settings do not panic.
1647 //
1648 // rollout_steps=64, minibatch_size=16 => 4 minibatches per epoch in
1649 // the all-minibatch branch; n_epochs=2 => 8 full steps uncapped.
1650 let num_agents = 2;
1651 let obs_dim: usize = 4;
1652 let action_dim: usize = 3;
1653 let n_epochs = 2;
1654 let rollout_steps = 64;
1655 let minibatch_size = 16;
1656
1657 // Returns the `num_mb_steps` taken by one update under `config`.
1658 let run = |iterate_all: bool, cap: Option<usize>| -> usize {
1659 let device = Default::default();
1660 let policies = make_mlp_policies(num_agents, obs_dim, action_dim, 16, &device);
1661 let optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents, 3e-4);
1662 let config = JointTrainerConfig {
1663 num_agents,
1664 rollout_steps,
1665 n_epochs,
1666 minibatch_size,
1667 iterate_all_minibatches: iterate_all,
1668 max_minibatches_per_epoch: cap,
1669 ..Default::default()
1670 };
1671 let mut trainer =
1672 JointMultiAgentTrainer::new(policies, optimizers, config, device).unwrap();
1673 let mut env = MockEnv::new(num_agents, obs_dim);
1674 let mut last_obs = env.reset_joint(None);
1675 let mut rng = StdRng::seed_from_u64(0);
1676 let rollout = trainer.collect_rollout(&mut env, &mut last_obs, &mut rng);
1677 trainer
1678 .update(&rollout, &mut rng, |_f: &[Tensor<B, 2>]| -> Option<Tensor<B, 1>> { None })
1679 .expect("update should not error")
1680 .num_mb_steps
1681 };
1682
1683 // All-minibatch, uncapped: every 16-sample chunk walked each epoch.
1684 assert_eq!(run(true, None), n_epochs * (rollout_steps / minibatch_size));
1685 // All-minibatch, cap=2: exactly 2 minibatch steps per epoch.
1686 assert_eq!(run(true, Some(2)), n_epochs * 2);
1687 // All-minibatch, cap=1: exactly 1 minibatch step per epoch.
1688 assert_eq!(run(true, Some(1)), n_epochs);
1689 // Cap >= available minibatches is a no-op (still full coverage).
1690 assert_eq!(run(true, Some(99)), n_epochs * (rollout_steps / minibatch_size));
1691 // cap=0 is clamped to 1 (never zero gradient steps; must not panic).
1692 assert_eq!(run(true, Some(0)), n_epochs);
1693
1694 // Default single-minibatch path: one minibatch per epoch regardless,
1695 // so a cap is a no-op and `num_mb_steps == n_epochs`.
1696 assert_eq!(run(false, None), n_epochs);
1697 assert_eq!(run(false, Some(2)), n_epochs);
1698 }
1699
1700 #[test]
1701 fn test_joint_rollout_shapes() {
1702 let device = Default::default();
1703 let num_agents = 3;
1704 let obs_dim: usize = 5;
1705 let t: usize = 32;
1706 let policies = make_mlp_policies(num_agents, obs_dim, 4, 16, &device);
1707 let optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents, 3e-4);
1708
1709 let config = JointTrainerConfig {
1710 num_agents,
1711 rollout_steps: t,
1712 n_epochs: 1,
1713 minibatch_size: t,
1714 ..Default::default()
1715 };
1716 let trainer = JointMultiAgentTrainer::new(policies, optimizers, config, device).unwrap();
1717
1718 let mut env = MockEnv::new(num_agents, obs_dim);
1719 let mut last_obs = env.reset_joint(None);
1720 let mut rng = StdRng::seed_from_u64(0);
1721 let rollout = trainer.collect_rollout(&mut env, &mut last_obs, &mut rng);
1722
1723 assert_eq!(rollout.num_steps(), t);
1724 assert_eq!(rollout.num_agents(), num_agents);
1725 assert_eq!(rollout.obs_dim, obs_dim);
1726 assert_eq!(rollout.num_action_dims, 1);
1727 assert_eq!(rollout.observations_per_agent.len(), num_agents);
1728 for buf in &rollout.observations_per_agent {
1729 assert_eq!(buf.len(), t * obs_dim);
1730 }
1731 for a in &rollout.actions {
1732 assert_eq!(a.len(), t);
1733 }
1734 for r in &rollout.rewards {
1735 assert_eq!(r.len(), t);
1736 }
1737 for lp in &rollout.log_probs {
1738 assert_eq!(lp.len(), t);
1739 }
1740 for v in &rollout.values {
1741 assert_eq!(v.len(), t);
1742 }
1743 assert_eq!(rollout.dones.len(), t);
1744 }
1745
1746 #[test]
1747 fn test_aux_fn_couples_all_agents_into_stats() {
1748 // With aux_fn = || (features[0] - features[1]).square().sum() the
1749 // aux_loss stat must be strictly positive after one update because
1750 // the two policies' initial encoders are independently initialized
1751 // and so produce different features.
1752 let device = Default::default();
1753 let num_agents = 2;
1754 let obs_dim: usize = 4;
1755 let policies = make_mlp_policies(num_agents, obs_dim, 3, 16, &device);
1756 let optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents, 1e-3);
1757
1758 let config = JointTrainerConfig {
1759 num_agents,
1760 rollout_steps: 32,
1761 n_epochs: 1,
1762 minibatch_size: 32,
1763 normalize_advantages: false,
1764 ..Default::default()
1765 };
1766 let mut trainer =
1767 JointMultiAgentTrainer::new(policies, optimizers, config, device).unwrap();
1768
1769 let mut env = MockEnv::new(num_agents, obs_dim);
1770 let mut last_obs = env.reset_joint(None);
1771 let mut rng = StdRng::seed_from_u64(0);
1772 let rollout = trainer.collect_rollout(&mut env, &mut last_obs, &mut rng);
1773
1774 let stats = trainer
1775 .update(&rollout, &mut rng, |features: &[Tensor<B, 2>]| -> Option<Tensor<B, 1>> {
1776 Some((features[0].clone() - features[1].clone()).powf_scalar(2.0_f32).sum())
1777 })
1778 .expect("update should not error");
1779
1780 assert!(
1781 stats.aux_loss > 0.0,
1782 "aux_loss must be > 0 with non-zero feature diff, got {}",
1783 stats.aux_loss
1784 );
1785 assert!(stats.total_loss.is_finite());
1786 }
1787
1788 #[test]
1789 fn test_joint_trainer_multi_discrete() {
1790 // Multi-discrete repeat of the smoke test: factored [3, 2] action
1791 // space, exercises the `MultiDiscreteMlpBurnPolicy` path through
1792 // `evaluate_actions_joint` and `select_actions` (action layout
1793 // `[T, num_dims]`).
1794 let device = Default::default();
1795 let num_agents = 2;
1796 let obs_dim: usize = 4;
1797 let action_dims = vec![3_usize, 2];
1798 let policies =
1799 make_multi_discrete_policies(num_agents, obs_dim, action_dims.clone(), 16, &device);
1800 let optimizers = build_optimizers::<MultiDiscreteMlpBurnPolicy<B>>(num_agents, 3e-4);
1801
1802 let config = JointTrainerConfig {
1803 num_agents,
1804 rollout_steps: 32,
1805 n_epochs: 1,
1806 minibatch_size: 32,
1807 ..Default::default()
1808 };
1809 let mut trainer =
1810 JointMultiAgentTrainer::new(policies, optimizers, config, device).unwrap();
1811
1812 let mut env = MockEnv::new(num_agents, obs_dim);
1813 let mut last_obs = env.reset_joint(None);
1814 let mut rng = StdRng::seed_from_u64(0);
1815 let rollout = trainer.collect_rollout(&mut env, &mut last_obs, &mut rng);
1816
1817 assert_eq!(rollout.num_action_dims, action_dims.len());
1818 for a in &rollout.actions {
1819 assert_eq!(a.len(), 32 * action_dims.len());
1820 }
1821
1822 let stats = trainer
1823 .update(&rollout, &mut rng, |_features: &[Tensor<B, 2>]| -> Option<Tensor<B, 1>> {
1824 None
1825 })
1826 .expect("update should not error");
1827 assert!(stats.total_loss.is_finite());
1828 }
1829
1830 /// Env that returns *distinct* per-agent observations every step:
1831 /// agent `i` always sees a one-hot vector with the `i`-th slot set.
1832 /// Used as the load-bearing regression assertion that the trainer
1833 /// reads agent `i`'s observation for agent `i` (and not agent 0's
1834 /// view for everyone) after the per-agent obs refactor.
1835 struct PerAgentObsMockEnv {
1836 num_agents: usize,
1837 obs_dim: usize,
1838 }
1839
1840 impl PerAgentObsMockEnv {
1841 fn new(num_agents: usize, obs_dim: usize) -> Self {
1842 assert!(obs_dim >= num_agents, "obs_dim must be >= num_agents for one-hot encoding");
1843 Self { num_agents, obs_dim }
1844 }
1845
1846 fn per_agent_obs(&self) -> Vec<Vec<f32>> {
1847 (0..self.num_agents)
1848 .map(|i| {
1849 let mut v = vec![0.0_f32; self.obs_dim];
1850 v[i] = 1.0;
1851 v
1852 })
1853 .collect()
1854 }
1855 }
1856
1857 impl JointEnv for PerAgentObsMockEnv {
1858 fn reset_joint(&mut self, _seed: Option<u64>) -> Vec<Vec<f32>> {
1859 self.per_agent_obs()
1860 }
1861
1862 fn step_joint(&mut self, _actions: &[Vec<i64>]) -> JointStepResult {
1863 JointStepResult {
1864 rewards: vec![0.0_f32; self.num_agents],
1865 done: false,
1866 observations: self.per_agent_obs(),
1867 }
1868 }
1869 }
1870
1871 #[test]
1872 fn test_collect_rollout_reads_per_agent_observations() {
1873 // Regression guard for the per-agent observation refactor (PR
1874 // #118). The trainer must read agent `i`'s observation for
1875 // agent `i` — *not* agent 0's view for everyone. We construct
1876 // an env whose `step_joint` returns distinct one-hot
1877 // observations per agent and assert that
1878 // `rollout.observations_per_agent[i]` at every timestep
1879 // contains agent `i`'s one-hot, not agent 0's.
1880 let device = Default::default();
1881 let num_agents = 3;
1882 let obs_dim: usize = 4; // >= num_agents for one-hot encoding
1883 let t: usize = 16;
1884 let policies = make_mlp_policies(num_agents, obs_dim, 2, 8, &device);
1885 let optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents, 3e-4);
1886
1887 let config = JointTrainerConfig {
1888 num_agents,
1889 rollout_steps: t,
1890 n_epochs: 1,
1891 minibatch_size: t,
1892 ..Default::default()
1893 };
1894 let trainer = JointMultiAgentTrainer::new(policies, optimizers, config, device).unwrap();
1895
1896 let mut env = PerAgentObsMockEnv::new(num_agents, obs_dim);
1897 let mut last_obs = env.reset_joint(None);
1898 let mut rng = StdRng::seed_from_u64(0);
1899 let rollout = trainer.collect_rollout(&mut env, &mut last_obs, &mut rng);
1900
1901 assert_eq!(rollout.observations_per_agent.len(), num_agents);
1902 for (i, buf) in rollout.observations_per_agent.iter().enumerate() {
1903 assert_eq!(buf.len(), t * obs_dim, "obs buffer for agent {i} has wrong length");
1904 // Expected per-step view for agent i: one-hot with slot `i` = 1.0.
1905 let mut expected = vec![0.0_f32; obs_dim];
1906 expected[i] = 1.0;
1907 for step in 0..t {
1908 let start = step * obs_dim;
1909 let slice = &buf[start..start + obs_dim];
1910 assert_eq!(
1911 slice,
1912 expected.as_slice(),
1913 "agent {i} step {step}: observation slice {:?} does not match agent {i}'s view {:?}",
1914 slice,
1915 expected,
1916 );
1917 }
1918 }
1919 }
1920
1921 /// Issue #239: the global-norm clip must (a) leave an already-small
1922 /// gradient untouched and (b) scale an oversized gradient down so its
1923 /// global L2 norm equals the cap.
1924 #[test]
1925 fn test_clip_grads_by_global_norm() {
1926 let device: NdArrayDevice = Default::default();
1927 let policy = MlpBurnPolicy::<B>::new(4, 3, 16, &device);
1928
1929 // Build a real gradient via a forward/backward so the param ids in
1930 // `GradientsParams` line up with the module's params.
1931 let obs = Tensor::<B, 2>::from_data(
1932 burn::tensor::TensorData::new(vec![0.1_f32; 4 * 8], [8, 4]),
1933 &device,
1934 );
1935 let logits = policy.encoder_features_joint(obs);
1936 let loss = logits.powf_scalar(2.0).sum();
1937 let grads = GradientsParams::from_grads(loss.backward(), &policy);
1938
1939 // Helper: global L2 norm of a GradientsParams over `policy`'s params.
1940 fn global_norm(policy: &MlpBurnPolicy<B>, grads: &GradientsParams) -> f64 {
1941 struct Acc<'a> {
1942 grads: &'a GradientsParams,
1943 sum_sq: f64,
1944 }
1945 impl ModuleVisitor<B> for Acc<'_> {
1946 fn visit_float<const D: usize>(&mut self, param: &Param<Tensor<B, D>>) {
1947 if let Some(g) =
1948 self.grads.get::<<B as AutodiffBackend>::InnerBackend, D>(param.id)
1949 {
1950 self.sum_sq += scalar_f64(g.powf_scalar(2.0).sum());
1951 }
1952 }
1953 }
1954 let mut acc = Acc { grads, sum_sq: 0.0 };
1955 policy.visit(&mut acc);
1956 acc.sum_sq.sqrt()
1957 }
1958
1959 let norm_before = global_norm(&policy, &grads);
1960 assert!(norm_before.is_finite() && norm_before > 0.0);
1961
1962 // (a) A cap well above the gradient norm is a no-op.
1963 let big_cap = norm_before * 10.0;
1964 let unclipped =
1965 clip_grads_by_global_norm::<B, MlpBurnPolicy<B>>(&policy, grads, big_cap as f32);
1966 let norm_unclipped = global_norm(&policy, &unclipped);
1967 assert!(
1968 (norm_unclipped - norm_before).abs() < 1e-4,
1969 "cap above norm must not change gradients: {norm_before} -> {norm_unclipped}"
1970 );
1971
1972 // (b) A small cap scales the global norm down to (approximately) the
1973 // cap.
1974 let small_cap = (norm_before / 4.0) as f32;
1975 let clipped =
1976 clip_grads_by_global_norm::<B, MlpBurnPolicy<B>>(&policy, unclipped, small_cap);
1977 let norm_clipped = global_norm(&policy, &clipped);
1978 assert!(
1979 (norm_clipped - small_cap as f64).abs() < 1e-3 * small_cap as f64 + 1e-4,
1980 "clipped global norm {norm_clipped} should equal cap {small_cap}"
1981 );
1982 }
1983
1984 /// Issue #239: with `iterate_all_minibatches = true` the update walks
1985 /// every minibatch per epoch (more gradient steps than the single-draw
1986 /// default) and still produces finite stats.
1987 #[test]
1988 fn test_iterate_all_minibatches_runs() {
1989 let device = Default::default();
1990 let num_agents = 2;
1991 let obs_dim: usize = 4;
1992 let action_dim: usize = 3;
1993 let policies = make_mlp_policies(num_agents, obs_dim, action_dim, 16, &device);
1994 let optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents, 3e-4);
1995
1996 // 128-step rollout, minibatch 32 => 4 minibatches per epoch.
1997 let config = JointTrainerConfig {
1998 num_agents,
1999 rollout_steps: 128,
2000 n_epochs: 2,
2001 minibatch_size: 32,
2002 iterate_all_minibatches: true,
2003 ..Default::default()
2004 };
2005 let mut trainer =
2006 JointMultiAgentTrainer::new(policies, optimizers, config, device).unwrap();
2007
2008 let mut env = MockEnv::new(num_agents, obs_dim);
2009 let mut last_obs = env.reset_joint(None);
2010 let mut rng = StdRng::seed_from_u64(0);
2011 let rollout = trainer.collect_rollout(&mut env, &mut last_obs, &mut rng);
2012 let stats = trainer
2013 .update(&rollout, &mut rng, |_features: &[Tensor<B, 2>]| -> Option<Tensor<B, 1>> {
2014 None
2015 })
2016 .expect("update should not error");
2017
2018 for i in 0..num_agents {
2019 assert!(stats.policy_loss[i].is_finite(), "policy_loss[{i}] finite");
2020 assert!(stats.value_loss[i].is_finite(), "value_loss[{i}] finite");
2021 assert!(stats.entropy[i].is_finite(), "entropy[{i}] finite");
2022 }
2023 assert!(stats.total_loss.is_finite());
2024 }
2025
2026 /// Issue #239 fix #4: with a dedicated critic optimizer
2027 /// (`with_critic_optimizers`) the update runs the actor and critic in two
2028 /// backward passes, steps both, and still produces finite stats. Mirrors
2029 /// `test_iterate_all_minibatches_runs` but exercises the split-critic path.
2030 #[test]
2031 fn test_split_critic_optimizer_runs() {
2032 let device = Default::default();
2033 let num_agents = 2;
2034 let obs_dim: usize = 4;
2035 let action_dim: usize = 3;
2036 let policies = make_mlp_policies(num_agents, obs_dim, action_dim, 16, &device);
2037 let optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents, 3e-4);
2038 // Dedicated critic optimizers at a higher LR (the #239 fix #4 pattern).
2039 let critic_optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents, 1e-3);
2040
2041 let config = JointTrainerConfig {
2042 num_agents,
2043 rollout_steps: 128,
2044 n_epochs: 2,
2045 minibatch_size: 32,
2046 iterate_all_minibatches: true,
2047 critic_lr: Some(1e-3),
2048 ..Default::default()
2049 };
2050 let mut trainer = JointMultiAgentTrainer::with_critic_optimizers(
2051 policies,
2052 optimizers,
2053 critic_optimizers,
2054 config,
2055 device,
2056 )
2057 .unwrap();
2058
2059 let mut env = MockEnv::new(num_agents, obs_dim);
2060 let mut last_obs = env.reset_joint(None);
2061 let mut rng = StdRng::seed_from_u64(0);
2062 let rollout = trainer.collect_rollout(&mut env, &mut last_obs, &mut rng);
2063 let stats = trainer
2064 .update(&rollout, &mut rng, |_features: &[Tensor<B, 2>]| -> Option<Tensor<B, 1>> {
2065 None
2066 })
2067 .expect("split-critic update should not error");
2068
2069 for i in 0..num_agents {
2070 assert!(stats.policy_loss[i].is_finite(), "policy_loss[{i}] finite");
2071 assert!(stats.value_loss[i].is_finite(), "value_loss[{i}] finite");
2072 assert!(stats.entropy[i].is_finite(), "entropy[{i}] finite");
2073 assert!(stats.explained_var[i].is_finite(), "explained_var[{i}] finite");
2074 }
2075 assert!(stats.total_loss.is_finite());
2076 }
2077
2078 /// Issue #239 fix #4: supplying a critic-optimizer count that mismatches
2079 /// the policy count is rejected at construction.
2080 #[test]
2081 fn test_with_critic_optimizers_length_mismatch_errors() {
2082 let device = Default::default();
2083 let num_agents = 2;
2084 let policies = make_mlp_policies(num_agents, 4, 3, 8, &device);
2085 let optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents, 3e-4);
2086 // One too few critic optimizers.
2087 let critic_optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents - 1, 1e-3);
2088 let config = JointTrainerConfig { num_agents, ..Default::default() };
2089 let result = JointMultiAgentTrainer::with_critic_optimizers(
2090 policies,
2091 optimizers,
2092 critic_optimizers,
2093 config,
2094 device,
2095 );
2096 assert!(result.is_err(), "mismatched critic_optimizers length must be rejected");
2097 }
2098
2099 /// Issue #239 fix #4 regression guard: the default trainer (no critic
2100 /// optimizers) takes the single combined backward and is unaffected by
2101 /// the split-critic plumbing — `critic_lr` defaults to `None`.
2102 #[test]
2103 fn test_default_path_has_no_critic_optimizers() {
2104 let device = Default::default();
2105 let num_agents = 2;
2106 let policies = make_mlp_policies(num_agents, 4, 3, 8, &device);
2107 let optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents, 3e-4);
2108 let config = JointTrainerConfig { num_agents, ..Default::default() };
2109 assert!(
2110 JointTrainerConfig::default().critic_lr.is_none(),
2111 "default critic_lr must be None"
2112 );
2113 let trainer = JointMultiAgentTrainer::new(policies, optimizers, config, device).unwrap();
2114 assert!(
2115 trainer.critic_optimizers.is_empty(),
2116 "default trainer must carry no critic optimizers (single combined backward)"
2117 );
2118 }
2119}