use anyhow::{Result, anyhow};
use burn::{
module::AutodiffModule,
optim::{GradientsParams, Optimizer},
tensor::{Int, Tensor, backend::AutodiffBackend},
};
use rand::rngs::StdRng;
use crate::train::{
grad_clip::clip_grads_by_global_norm,
optimizer::{BackendOptimizer, BurnOptimizer},
ppo::loss::{
compute_policy_loss, compute_value_loss, generate_minibatch_indices_with_rng, scalar_f64,
},
};
pub trait JointPolicy<B: AutodiffBackend>: AutodiffModule<B> + Clone {
fn get_action_host_seeded(
&self,
obs: Tensor<B, 2>,
rng: &mut StdRng,
) -> (Vec<i64>, Vec<f32>, Vec<f32>);
fn get_actions_host_seeded_batched(
&self,
obs: Tensor<B, 2>,
rng: &mut StdRng,
) -> (Vec<i64>, Vec<f32>, Vec<f32>) {
self.get_action_host_seeded(obs, rng)
}
fn evaluate_actions_joint(
&self,
obs: Tensor<B, 2>,
actions: Tensor<B, 2, Int>,
) -> (Tensor<B, 1>, Tensor<B, 1>, Tensor<B, 1>);
fn encoder_features_joint(&self, obs: Tensor<B, 2>) -> Tensor<B, 2>;
fn action_dims_joint(&self) -> Vec<i64>;
}
impl<B: AutodiffBackend> JointPolicy<B> for crate::policy::mlp::MlpBurnPolicy<B>
where
Self: AutodiffModule<B> + Clone,
{
fn get_action_host_seeded(
&self,
obs: Tensor<B, 2>,
rng: &mut StdRng,
) -> (Vec<i64>, Vec<f32>, Vec<f32>) {
let (actions, log_probs, values) = self.get_action_host_seeded(obs, rng);
(actions, log_probs, values)
}
fn get_actions_host_seeded_batched(
&self,
obs: Tensor<B, 2>,
rng: &mut StdRng,
) -> (Vec<i64>, Vec<f32>, Vec<f32>) {
self.get_actions_host_seeded_batched(obs, rng)
}
fn evaluate_actions_joint(
&self,
obs: Tensor<B, 2>,
actions: Tensor<B, 2, Int>,
) -> (Tensor<B, 1>, Tensor<B, 1>, Tensor<B, 1>) {
let actions_1d: Tensor<B, 1, Int> = actions.squeeze_dim::<1>(1);
self.evaluate_actions(obs, actions_1d)
}
fn encoder_features_joint(&self, obs: Tensor<B, 2>) -> Tensor<B, 2> {
self.encoder_features(obs)
}
fn action_dims_joint(&self) -> Vec<i64> {
let head_dims = self.policy_head_action_dim();
vec![head_dims as i64]
}
}
impl<B: AutodiffBackend> JointPolicy<B>
for crate::policy::multi_discrete_mlp::MultiDiscreteMlpBurnPolicy<B>
where
Self: AutodiffModule<B> + Clone,
{
fn get_action_host_seeded(
&self,
obs: Tensor<B, 2>,
rng: &mut StdRng,
) -> (Vec<i64>, Vec<f32>, Vec<f32>) {
self.get_action_host_seeded(obs, rng)
}
fn get_actions_host_seeded_batched(
&self,
obs: Tensor<B, 2>,
rng: &mut StdRng,
) -> (Vec<i64>, Vec<f32>, Vec<f32>) {
self.get_actions_host_seeded_batched(obs, rng)
}
fn evaluate_actions_joint(
&self,
obs: Tensor<B, 2>,
actions: Tensor<B, 2, Int>,
) -> (Tensor<B, 1>, Tensor<B, 1>, Tensor<B, 1>) {
self.evaluate_actions(obs, actions)
}
fn encoder_features_joint(&self, obs: Tensor<B, 2>) -> Tensor<B, 2> {
self.encoder_features(obs)
}
fn action_dims_joint(&self) -> Vec<i64> {
self.action_dim_cardinalities().into_iter().map(|d| d as i64).collect()
}
}
#[derive(Debug, Clone)]
pub struct JointStepResult {
pub rewards: Vec<f32>,
pub done: bool,
pub observations: Vec<Vec<f32>>,
}
pub trait JointEnv {
fn reset_joint(&mut self, seed: Option<u64>) -> Vec<Vec<f32>>;
fn step_joint(&mut self, actions: &[Vec<i64>]) -> JointStepResult;
}
#[derive(Debug, Clone)]
pub struct JointTrainerConfig {
pub num_agents: usize,
pub rollout_steps: usize,
pub gamma: f64,
pub gae_lambda: f64,
pub clip_range: f64,
pub clip_range_vf: f64,
pub vf_coef: f64,
pub ent_coef: f64,
pub n_epochs: usize,
pub minibatch_size: usize,
pub max_grad_norm: f64,
pub normalize_advantages: bool,
pub iterate_all_minibatches: bool,
pub critic_lr: Option<f64>,
pub max_minibatches_per_epoch: Option<usize>,
pub comms_coef: f64,
}
impl Default for JointTrainerConfig {
fn default() -> Self {
Self {
num_agents: 4,
rollout_steps: 2048,
gamma: 0.99,
gae_lambda: 0.95,
clip_range: 0.2,
clip_range_vf: 0.2,
vf_coef: 0.5,
ent_coef: 0.01,
n_epochs: 4,
minibatch_size: 256,
max_grad_norm: 0.5,
normalize_advantages: true,
iterate_all_minibatches: false,
critic_lr: None,
max_minibatches_per_epoch: None,
comms_coef: 0.0,
}
}
}
#[derive(Debug, Clone)]
pub struct JointRollout {
pub observations_per_agent: Vec<Vec<f32>>,
pub obs_dims: Vec<usize>,
pub actions: Vec<Vec<i64>>,
pub num_action_dims: usize,
pub log_probs: Vec<Vec<f32>>,
pub values: Vec<Vec<f32>>,
pub rewards: Vec<Vec<f32>>,
pub dones: Vec<f32>,
}
impl JointRollout {
pub fn num_steps(&self) -> usize {
self.dones.len()
}
pub fn num_agents(&self) -> usize {
self.actions.len()
}
}
#[derive(Debug, Clone, Default)]
pub struct JointStats {
pub policy_loss: Vec<f64>,
pub value_loss: Vec<f64>,
pub entropy: Vec<f64>,
pub clip_fraction: Vec<f64>,
pub approx_kl: Vec<f64>,
pub explained_var: Vec<f64>,
pub aux_loss: f64,
pub total_loss: f64,
pub num_mb_steps: usize,
}
impl JointStats {
pub fn zeros(num_agents: usize) -> Self {
Self {
policy_loss: vec![0.0; num_agents],
value_loss: vec![0.0; num_agents],
entropy: vec![0.0; num_agents],
clip_fraction: vec![0.0; num_agents],
approx_kl: vec![0.0; num_agents],
explained_var: vec![0.0; num_agents],
aux_loss: 0.0,
total_loss: 0.0,
num_mb_steps: 0,
}
}
}
pub struct JointMultiAgentTrainer<B, P, O>
where
B: AutodiffBackend,
P: JointPolicy<B>,
O: Optimizer<P, B>,
{
policies: Vec<Option<P>>,
optimizers: Vec<BurnOptimizer<B, P, O>>,
critic_optimizers: Vec<BurnOptimizer<B, P, O>>,
config: JointTrainerConfig,
device: B::Device,
}
impl<B, P, O> JointMultiAgentTrainer<B, P, O>
where
B: AutodiffBackend,
P: JointPolicy<B>,
O: Optimizer<P, B>,
{
pub fn new(
policies: Vec<P>,
optimizers: Vec<BurnOptimizer<B, P, O>>,
config: JointTrainerConfig,
device: B::Device,
) -> Result<Self> {
if policies.is_empty() {
return Err(anyhow!("JointMultiAgentTrainer requires at least one policy"));
}
if policies.len() != config.num_agents {
return Err(anyhow!(
"JointMultiAgentTrainer: policies.len() ({}) != config.num_agents ({})",
policies.len(),
config.num_agents
));
}
if optimizers.len() != policies.len() {
return Err(anyhow!(
"JointMultiAgentTrainer: optimizers.len() ({}) != policies.len() ({})",
optimizers.len(),
policies.len()
));
}
let mut optimizers = optimizers;
for opt in optimizers.iter_mut() {
opt.clip_grad_norm(config.max_grad_norm);
}
Ok(Self {
policies: policies.into_iter().map(Some).collect(),
optimizers,
critic_optimizers: Vec::new(),
config,
device,
})
}
pub fn with_critic_optimizers(
policies: Vec<P>,
optimizers: Vec<BurnOptimizer<B, P, O>>,
critic_optimizers: Vec<BurnOptimizer<B, P, O>>,
config: JointTrainerConfig,
device: B::Device,
) -> Result<Self> {
if critic_optimizers.len() != policies.len() {
return Err(anyhow!(
"JointMultiAgentTrainer: critic_optimizers.len() ({}) != policies.len() ({})",
critic_optimizers.len(),
policies.len()
));
}
let mut trainer = Self::new(policies, optimizers, config, device)?;
let mut critic_optimizers = critic_optimizers;
for opt in critic_optimizers.iter_mut() {
opt.clip_grad_norm(trainer.config.max_grad_norm);
}
trainer.critic_optimizers = critic_optimizers;
Ok(trainer)
}
pub fn device(&self) -> &B::Device {
&self.device
}
pub fn config(&self) -> &JointTrainerConfig {
&self.config
}
pub fn policy(&self, i: usize) -> &P {
self.policies[i].as_ref().expect("policy is None mid-update")
}
pub fn collect_rollout<E: JointEnv>(
&self,
env: &mut E,
last_obs: &mut [Vec<f32>],
rng: &mut StdRng,
) -> JointRollout {
let num_steps = self.config.rollout_steps;
let num_agents = self.config.num_agents;
assert_eq!(
last_obs.len(),
num_agents,
"collect_rollout: last_obs length ({}) must equal num_agents ({})",
last_obs.len(),
num_agents,
);
let obs_dims: Vec<usize> = last_obs.iter().map(|o| o.len()).collect();
let device = self.device.clone();
let num_action_dims: usize = self.policies[0]
.as_ref()
.expect("policy 0 present at rollout time")
.action_dims_joint()
.len();
let mut obs_buf_per_agent: Vec<Vec<f32>> =
(0..num_agents).map(|i| vec![0.0_f32; num_steps * obs_dims[i]]).collect();
let mut act_buf: Vec<Vec<i64>> =
(0..num_agents).map(|_| vec![0_i64; num_steps * num_action_dims]).collect();
let mut lp_buf: Vec<Vec<f32>> = (0..num_agents).map(|_| vec![0.0_f32; num_steps]).collect();
let mut val_buf: Vec<Vec<f32>> =
(0..num_agents).map(|_| vec![0.0_f32; num_steps]).collect();
let mut rew_buf: Vec<Vec<f32>> =
(0..num_agents).map(|_| vec![0.0_f32; num_steps]).collect();
let mut done_buf = vec![0.0_f32; num_steps];
for t in 0..num_steps {
let mut joint_action: Vec<Vec<i64>> = Vec::with_capacity(num_agents);
for (i, slot) in self.policies.iter().enumerate() {
let policy = slot.as_ref().expect("policy present at rollout time");
let obs_dim_i = obs_dims[i];
let start = t * obs_dim_i;
let agent_obs = &last_obs[i];
obs_buf_per_agent[i][start..start + obs_dim_i].copy_from_slice(agent_obs);
let obs_t = Tensor::<B, 2>::from_data(
burn::tensor::TensorData::new(agent_obs.clone(), [1, obs_dim_i]),
&device,
);
let (actions_host, log_probs_host, values_host) =
policy.get_action_host_seeded(obs_t, rng);
let row: Vec<i64> = actions_host[..num_action_dims].to_vec();
let off = t * num_action_dims;
act_buf[i][off..off + num_action_dims].copy_from_slice(&row);
joint_action.push(row);
lp_buf[i][t] = log_probs_host.first().copied().unwrap_or(0.0);
val_buf[i][t] = values_host.first().copied().unwrap_or(0.0);
}
let result = env.step_joint(&joint_action);
for (i, rew) in rew_buf.iter_mut().enumerate().take(num_agents) {
rew[t] = result.rewards[i];
}
done_buf[t] = if result.done { 1.0 } else { 0.0 };
if result.done {
let fresh = env.reset_joint(None);
last_obs[..num_agents].clone_from_slice(&fresh[..num_agents]);
} else {
last_obs[..num_agents].clone_from_slice(&result.observations[..num_agents]);
}
}
JointRollout {
observations_per_agent: obs_buf_per_agent,
obs_dims,
actions: act_buf,
num_action_dims,
log_probs: lp_buf,
values: val_buf,
rewards: rew_buf,
dones: done_buf,
}
}
pub fn update<F>(
&mut self,
rollout: &JointRollout,
rng: &mut StdRng,
aux_fn: F,
) -> Result<JointStats>
where
F: FnMut(&[Tensor<B, 2>]) -> Option<Tensor<B, 1>>,
{
let num_agents = self.config.num_agents;
let active = vec![true; num_agents];
self.update_with_active_agents(rollout, &active, rng, aux_fn)
}
pub fn update_with_active_agents<F>(
&mut self,
rollout: &JointRollout,
active: &[bool],
rng: &mut StdRng,
mut aux_fn: F,
) -> Result<JointStats>
where
F: FnMut(&[Tensor<B, 2>]) -> Option<Tensor<B, 1>>,
{
if active.len() != self.config.num_agents {
return Err(anyhow!(
"active mask length {} != config.num_agents {}",
active.len(),
self.config.num_agents
));
}
let device = self.device.clone();
let num_agents = self.config.num_agents;
let num_steps = rollout.num_steps();
if num_steps == 0 {
return Err(anyhow!("rollout is empty"));
}
if rollout.num_agents() != num_agents {
return Err(anyhow!(
"rollout has {} agents but trainer is configured for {}",
rollout.num_agents(),
num_agents
));
}
let mut advantages_host: Vec<Vec<f32>> = Vec::with_capacity(num_agents);
let mut returns_host: Vec<Vec<f32>> = Vec::with_capacity(num_agents);
for i in 0..num_agents {
let (adv, ret) = compute_gae_single_agent(
&rollout.rewards[i],
&rollout.values[i],
&rollout.dones,
self.config.gamma as f32,
self.config.gae_lambda as f32,
);
let adv = if self.config.normalize_advantages {
normalize_advantages(&adv)
} else {
adv
};
advantages_host.push(adv);
returns_host.push(ret);
}
let mut stats = JointStats::zeros(num_agents);
let mb_size = self.config.minibatch_size.min(num_steps);
let mut num_mb_steps: usize = 0;
for _epoch in 0..self.config.n_epochs {
use rand::seq::SliceRandom;
let mut minibatches: Vec<Vec<usize>> = if self.config.iterate_all_minibatches {
generate_minibatch_indices_with_rng(num_steps, mb_size, rng)
} else {
let mut indices: Vec<usize> = (0..num_steps).collect();
indices.shuffle(rng);
indices.truncate(mb_size);
vec![indices]
};
if let Some(cap) = self.config.max_minibatches_per_epoch {
let cap = cap.max(1);
if cap < minibatches.len() {
minibatches.truncate(cap);
}
}
for indices in minibatches {
num_mb_steps += 1;
let obs_mb_per_agent: Vec<Tensor<B, 2>> = (0..num_agents)
.map(|i| {
select_obs(
&rollout.observations_per_agent[i],
rollout.obs_dims[i],
&indices,
&device,
)
})
.collect();
let mut per_agent_losses: Vec<Tensor<B, 1>> = Vec::with_capacity(num_agents);
let mut features: Vec<Tensor<B, 2>> = Vec::with_capacity(num_agents);
let split_critic = !self.critic_optimizers.is_empty();
let mut per_agent_value_losses: Vec<Tensor<B, 1>> = if split_critic {
Vec::with_capacity(num_agents)
} else {
Vec::new()
};
let mut policy_loss_hosts = vec![0.0_f64; num_agents];
let mut value_loss_hosts = vec![0.0_f64; num_agents];
let mut entropy_hosts = vec![0.0_f64; num_agents];
let mut clip_frac_hosts = vec![0.0_f64; num_agents];
let mut kl_hosts = vec![0.0_f64; num_agents];
let mut ev_hosts = vec![0.0_f64; num_agents];
for i in 0..num_agents {
let policy = self.policies[i]
.as_ref()
.ok_or_else(|| anyhow!("policy {} is None mid-update", i))?;
let obs_mb_i = obs_mb_per_agent[i].clone();
let actions_mb = select_actions(
&rollout.actions[i],
rollout.num_action_dims,
&indices,
&device,
);
let old_lp_mb = select_f32_row(&rollout.log_probs[i], &indices, &device);
let adv_mb = select_f32_row(&advantages_host[i], &indices, &device);
let ret_mb = select_f32_row(&returns_host[i], &indices, &device);
let old_v_mb = select_f32_row(&rollout.values[i], &indices, &device);
let (new_lp, entropy, values_mb) =
policy.evaluate_actions_joint(obs_mb_i.clone(), actions_mb);
let feat = policy.encoder_features_joint(obs_mb_i);
let (policy_loss, clip_frac, kl) =
compute_policy_loss(new_lp, old_lp_mb, adv_mb, self.config.clip_range);
let (value_loss, explained_var) =
compute_value_loss(values_mb, old_v_mb, ret_mb, self.config.clip_range_vf);
let entropy_mean = entropy.mean();
policy_loss_hosts[i] = scalar_f64(policy_loss.clone());
value_loss_hosts[i] = scalar_f64(value_loss.clone());
entropy_hosts[i] = scalar_f64(entropy_mean.clone());
clip_frac_hosts[i] = clip_frac;
kl_hosts[i] = kl;
ev_hosts[i] = explained_var;
let entropy_term = entropy_mean.neg().mul_scalar(self.config.ent_coef as f32);
let agent_loss = if split_critic {
per_agent_value_losses.push(value_loss);
policy_loss + entropy_term
} else {
policy_loss
+ value_loss.mul_scalar(self.config.vf_coef as f32)
+ entropy_term
};
per_agent_losses.push(agent_loss);
features.push(feat);
}
let mut joint_loss: Option<Tensor<B, 1>> = None;
for l in per_agent_losses {
joint_loss = Some(match joint_loss.take() {
Some(acc) => acc + l,
None => l,
});
}
let aux_opt = aux_fn(&features);
let aux_scalar: f64 =
aux_opt.as_ref().map(|t| scalar_f64(t.clone())).unwrap_or(0.0);
stats.aux_loss += aux_scalar;
if let Some(aux) = aux_opt {
joint_loss = Some(match joint_loss.take() {
Some(acc) => acc + aux,
None => aux,
});
}
let comms_scalar = comms_penalty(
self.config.comms_coef,
&rollout.actions,
rollout.num_action_dims,
&indices,
);
if comms_scalar != 0.0 {
stats.aux_loss += comms_scalar;
let comms_t = Tensor::<B, 1>::from_data(
burn::tensor::TensorData::new(vec![comms_scalar as f32], [1]),
&device,
);
joint_loss = Some(match joint_loss.take() {
Some(acc) => acc + comms_t,
None => comms_t,
});
}
let joint_loss = joint_loss.ok_or_else(|| anyhow!("no losses to backprop"))?;
stats.total_loss += scalar_f64(joint_loss.clone());
let mut grads = joint_loss.backward();
for i in 0..num_agents {
let policy = self.policies[i]
.take()
.ok_or_else(|| anyhow!("policy {} is None mid-step", i))?;
let policy_grads = GradientsParams::from_module(&mut grads, &policy);
let updated = if active[i] {
let lr = self.optimizers[i].learning_rate();
let policy_grads = match self.optimizers[i].grad_clip_norm() {
Some(max_norm) if max_norm > 0.0 => clip_grads_by_global_norm::<B, P>(
&policy,
policy_grads,
max_norm as f32,
),
_ => policy_grads,
};
self.optimizers[i].inner_mut().step(lr, policy, policy_grads)
} else {
drop(policy_grads);
policy
};
self.policies[i] = Some(updated);
stats.policy_loss[i] += policy_loss_hosts[i];
stats.value_loss[i] += value_loss_hosts[i];
stats.entropy[i] += entropy_hosts[i];
stats.clip_fraction[i] += clip_frac_hosts[i];
stats.approx_kl[i] += kl_hosts[i];
stats.explained_var[i] += ev_hosts[i];
}
if split_critic {
let mut critic_joint: Option<Tensor<B, 1>> = None;
for vl in per_agent_value_losses {
let term = vl.mul_scalar(self.config.vf_coef as f32);
critic_joint = Some(match critic_joint.take() {
Some(acc) => acc + term,
None => term,
});
}
let critic_joint =
critic_joint.ok_or_else(|| anyhow!("no value losses to backprop"))?;
let mut critic_grads = critic_joint.backward();
#[allow(clippy::needless_range_loop)]
for i in 0..num_agents {
let policy = self.policies[i]
.take()
.ok_or_else(|| anyhow!("policy {} is None mid-critic-step", i))?;
let value_grads = GradientsParams::from_module(&mut critic_grads, &policy);
let updated = if active[i] {
let lr = self.critic_optimizers[i].learning_rate();
let value_grads = match self.critic_optimizers[i].grad_clip_norm() {
Some(max_norm) if max_norm > 0.0 => {
clip_grads_by_global_norm::<B, P>(
&policy,
value_grads,
max_norm as f32,
)
}
_ => value_grads,
};
self.critic_optimizers[i].inner_mut().step(lr, policy, value_grads)
} else {
drop(value_grads);
policy
};
self.policies[i] = Some(updated);
}
}
}
}
let n = num_mb_steps as f64;
if n > 0.0 {
for i in 0..num_agents {
stats.policy_loss[i] /= n;
stats.value_loss[i] /= n;
stats.entropy[i] /= n;
stats.clip_fraction[i] /= n;
stats.approx_kl[i] /= n;
stats.explained_var[i] /= n;
}
stats.aux_loss /= n;
stats.total_loss /= n;
}
stats.num_mb_steps = num_mb_steps;
Ok(stats)
}
}
fn compute_gae_single_agent(
rewards: &[f32],
values: &[f32],
dones: &[f32],
gamma: f32,
gae_lambda: f32,
) -> (Vec<f32>, Vec<f32>) {
let t = rewards.len();
let mut advantages = vec![0.0_f32; t];
let mut gae = 0.0_f32;
for i in (0..t).rev() {
let next_v = if i == t - 1 { 0.0 } else { values[i + 1] };
let mask = 1.0 - dones[i];
let delta = rewards[i] + gamma * next_v * mask - values[i];
gae = delta + gamma * gae_lambda * mask * gae;
advantages[i] = gae;
}
let returns: Vec<f32> = advantages.iter().zip(values).map(|(&a, &v)| a + v).collect();
(advantages, returns)
}
fn normalize_advantages(adv: &[f32]) -> Vec<f32> {
if adv.is_empty() {
return Vec::new();
}
let n = adv.len() as f64;
let mean: f64 = adv.iter().map(|&x| x as f64).sum::<f64>() / n;
let var: f64 = adv.iter().map(|&x| (x as f64 - mean).powi(2)).sum::<f64>() / n;
let std = var.sqrt().max(1e-8);
adv.iter().map(|&x| ((x as f64 - mean) / std) as f32).collect()
}
fn comms_penalty(
comms_coef: f64,
actions: &[Vec<i64>],
num_action_dims: usize,
indices: &[usize],
) -> f64 {
if comms_coef == 0.0 {
return 0.0;
}
let total: f64 = actions.iter().map(|a| token_entropy(a, num_action_dims, indices)).sum();
comms_coef * total
}
fn token_entropy(actions_flat: &[i64], num_action_dims: usize, indices: &[usize]) -> f64 {
use std::collections::HashMap;
let mut counts: HashMap<i64, usize> = HashMap::new();
let mut n: usize = 0;
for &t in indices {
let off = t * num_action_dims;
for tok in &actions_flat[off..off + num_action_dims] {
*counts.entry(*tok).or_insert(0) += 1;
n += 1;
}
}
if n == 0 {
return 0.0;
}
let n_f = n as f64;
counts
.values()
.map(|&c| {
let p = c as f64 / n_f;
-p * p.ln()
})
.sum()
}
fn select_obs<B: AutodiffBackend>(
obs_flat: &[f32],
obs_dim: usize,
indices: &[usize],
device: &B::Device,
) -> Tensor<B, 2> {
let mut out = Vec::with_capacity(indices.len() * obs_dim);
for &i in indices {
let start = i * obs_dim;
out.extend_from_slice(&obs_flat[start..start + obs_dim]);
}
Tensor::<B, 2>::from_data(burn::tensor::TensorData::new(out, [indices.len(), obs_dim]), device)
}
fn select_actions<B: AutodiffBackend>(
actions_flat: &[i64],
num_action_dims: usize,
indices: &[usize],
device: &B::Device,
) -> Tensor<B, 2, Int> {
let mut out = Vec::with_capacity(indices.len() * num_action_dims);
for &i in indices {
let start = i * num_action_dims;
out.extend_from_slice(&actions_flat[start..start + num_action_dims]);
}
Tensor::<B, 2, Int>::from_data(
burn::tensor::TensorData::new(out, [indices.len(), num_action_dims]),
device,
)
}
fn select_f32_row<B: AutodiffBackend>(
src: &[f32],
indices: &[usize],
device: &B::Device,
) -> Tensor<B, 1> {
let out: Vec<f32> = indices.iter().map(|&i| src[i]).collect();
Tensor::<B, 1>::from_data(burn::tensor::TensorData::new(out, [indices.len()]), device)
}
#[cfg(test)]
mod tests {
use burn::{
backend::{Autodiff, NdArray, ndarray::NdArrayDevice},
optim::AdamConfig,
};
use rand::SeedableRng;
use super::*;
use crate::{
policy::{mlp::MlpBurnPolicy, multi_discrete_mlp::MultiDiscreteMlpBurnPolicy},
train::optimizer::BurnOptimizer,
};
type B = Autodiff<NdArray<f32>>;
#[test]
fn gae_matches_hand_computed_reference_with_terminal_and_truncation() {
let rewards = [1.0_f32, 2.0, 3.0, 4.0];
let values = [0.5_f32, 1.0, 1.5, 2.0];
let dones = [0.0_f32, 1.0, 0.0, 0.0];
let gamma = 0.9_f32;
let lambda = 0.8_f32;
let (adv, ret) = compute_gae_single_agent(&rewards, &values, &dones, gamma, lambda);
let expected_adv = [2.12_f32, 1.00, 4.74, 2.00];
let expected_ret = [2.62_f32, 2.00, 6.24, 4.00];
assert_eq!(adv.len(), 4);
assert_eq!(ret.len(), 4);
for (i, (&a, &e)) in adv.iter().zip(&expected_adv).enumerate() {
assert!(
(a - e).abs() < 1e-5,
"advantage[{i}] = {a}, expected {e} (terminal at t=1, truncation at t=3)"
);
}
for (i, (&r, &e)) in ret.iter().zip(&expected_ret).enumerate() {
assert!((r - e).abs() < 1e-5, "return[{i}] = {r}, expected {e}");
}
assert!(
(adv[1] - (rewards[1] - values[1])).abs() < 1e-6,
"terminal-step advantage must equal its own TD error, no GAE carry-through"
);
}
#[test]
fn gae_all_terminal_collapses_to_td_errors() {
let rewards = [1.0_f32, -2.0, 0.5];
let values = [0.25_f32, 0.5, -0.5];
let dones = [1.0_f32, 1.0, 1.0];
let gamma = 0.99_f32;
let lambda = 0.95_f32;
let (adv, ret) = compute_gae_single_agent(&rewards, &values, &dones, gamma, lambda);
for i in 0..rewards.len() {
let td = rewards[i] - values[i]; assert!((adv[i] - td).abs() < 1e-6, "adv[{i}] should be pure TD error");
assert!((ret[i] - (td + values[i])).abs() < 1e-6, "ret[{i}] = adv + value");
}
}
#[test]
fn normalize_advantages_zero_mean_unit_std() {
let adv = [2.12_f32, 1.00, 4.74, 2.00];
let out = normalize_advantages(&adv);
let n = out.len() as f64;
let mean: f64 = out.iter().map(|&x| x as f64).sum::<f64>() / n;
let var: f64 = out.iter().map(|&x| (x as f64 - mean).powi(2)).sum::<f64>() / n;
assert!(mean.abs() < 1e-5, "normalized mean should be ~0, got {mean}");
assert!(
(var.sqrt() - 1.0).abs() < 1e-4,
"normalized std should be ~1, got {}",
var.sqrt()
);
let argmax_raw = 2usize; let argmax_norm =
out.iter().enumerate().max_by(|a, b| a.1.partial_cmp(b.1).unwrap()).unwrap().0;
assert_eq!(argmax_raw, argmax_norm, "normalization must preserve ordering");
}
struct MockEnv {
num_agents: usize,
obs_dim: usize,
t: usize,
}
impl MockEnv {
fn new(num_agents: usize, obs_dim: usize) -> Self {
Self { num_agents, obs_dim, t: 0 }
}
fn obs_for(&self) -> Vec<f32> {
(0..self.obs_dim)
.map(|i| (((self.t * 7 + i * 13) % 100) as f32) / 100.0 - 0.5)
.collect()
}
}
impl JointEnv for MockEnv {
fn reset_joint(&mut self, _seed: Option<u64>) -> Vec<Vec<f32>> {
self.t = 0;
let obs = self.obs_for();
(0..self.num_agents).map(|_| obs.clone()).collect()
}
fn step_joint(&mut self, actions: &[Vec<i64>]) -> JointStepResult {
self.t += 1;
let rewards: Vec<f32> = actions
.iter()
.map(|a| a.iter().map(|&x| x as f32).sum::<f32>() / 10.0)
.collect();
let obs = self.obs_for();
let observations = (0..self.num_agents).map(|_| obs.clone()).collect();
JointStepResult { rewards, done: false, observations }
}
}
fn make_mlp_policies(
num_agents: usize,
obs_dim: usize,
action_dim: usize,
hidden_dim: usize,
device: &NdArrayDevice,
) -> Vec<MlpBurnPolicy<B>> {
(0..num_agents)
.map(|_| MlpBurnPolicy::<B>::new(obs_dim, action_dim, hidden_dim, device))
.collect()
}
fn make_multi_discrete_policies(
num_agents: usize,
obs_dim: usize,
action_dims: Vec<usize>,
hidden_dim: usize,
device: &NdArrayDevice,
) -> Vec<MultiDiscreteMlpBurnPolicy<B>> {
(0..num_agents)
.map(|_| {
MultiDiscreteMlpBurnPolicy::<B>::new(
obs_dim,
action_dims.clone(),
hidden_dim,
device,
)
})
.collect()
}
fn build_optimizers<P>(n: usize, lr: f64) -> Vec<BurnOptimizer<B, P, impl Optimizer<P, B>>>
where
P: AutodiffModule<B>,
{
(0..n)
.map(|_| {
let inner = AdamConfig::new().init();
BurnOptimizer::<B, P, _>::new(inner, lr)
})
.collect()
}
#[test]
fn test_joint_trainer_smoke() {
let device = Default::default();
let num_agents = 2;
let obs_dim: usize = 4;
let action_dim: usize = 3;
let policies = make_mlp_policies(num_agents, obs_dim, action_dim, 16, &device);
let optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents, 3e-4);
let config = JointTrainerConfig {
num_agents,
rollout_steps: 64,
n_epochs: 2,
minibatch_size: 32,
..Default::default()
};
let mut trainer =
JointMultiAgentTrainer::new(policies, optimizers, config, device).unwrap();
let mut env = MockEnv::new(num_agents, obs_dim);
let mut last_obs = env.reset_joint(None);
let mut rng = StdRng::seed_from_u64(0);
let rollout = trainer.collect_rollout(&mut env, &mut last_obs, &mut rng);
let stats = trainer
.update(&rollout, &mut rng, |_features: &[Tensor<B, 2>]| -> Option<Tensor<B, 1>> {
None
})
.expect("update should not error");
assert!(stats.total_loss.is_finite(), "total_loss must be finite");
assert_eq!(stats.aux_loss, 0.0, "aux_loss must be 0 when aux_fn returns None");
for i in 0..num_agents {
assert!(stats.policy_loss[i].is_finite(), "policy_loss[{i}] finite");
assert!(stats.value_loss[i].is_finite(), "value_loss[{i}] finite");
assert!(stats.entropy[i].is_finite(), "entropy[{i}] finite");
assert!(stats.clip_fraction[i].is_finite(), "clip_fraction[{i}] finite");
assert!(stats.approx_kl[i].is_finite(), "approx_kl[{i}] finite");
assert!(stats.explained_var[i].is_finite(), "explained_var[{i}] finite");
}
}
#[test]
fn test_max_minibatches_per_epoch_caps_grad_steps() {
let num_agents = 2;
let obs_dim: usize = 4;
let action_dim: usize = 3;
let n_epochs = 2;
let rollout_steps = 64;
let minibatch_size = 16;
let run = |iterate_all: bool, cap: Option<usize>| -> usize {
let device = Default::default();
let policies = make_mlp_policies(num_agents, obs_dim, action_dim, 16, &device);
let optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents, 3e-4);
let config = JointTrainerConfig {
num_agents,
rollout_steps,
n_epochs,
minibatch_size,
iterate_all_minibatches: iterate_all,
max_minibatches_per_epoch: cap,
..Default::default()
};
let mut trainer =
JointMultiAgentTrainer::new(policies, optimizers, config, device).unwrap();
let mut env = MockEnv::new(num_agents, obs_dim);
let mut last_obs = env.reset_joint(None);
let mut rng = StdRng::seed_from_u64(0);
let rollout = trainer.collect_rollout(&mut env, &mut last_obs, &mut rng);
trainer
.update(&rollout, &mut rng, |_f: &[Tensor<B, 2>]| -> Option<Tensor<B, 1>> { None })
.expect("update should not error")
.num_mb_steps
};
assert_eq!(run(true, None), n_epochs * (rollout_steps / minibatch_size));
assert_eq!(run(true, Some(2)), n_epochs * 2);
assert_eq!(run(true, Some(1)), n_epochs);
assert_eq!(run(true, Some(99)), n_epochs * (rollout_steps / minibatch_size));
assert_eq!(run(true, Some(0)), n_epochs);
assert_eq!(run(false, None), n_epochs);
assert_eq!(run(false, Some(2)), n_epochs);
}
#[test]
fn test_joint_rollout_shapes() {
let device = Default::default();
let num_agents = 3;
let obs_dim: usize = 5;
let t: usize = 32;
let policies = make_mlp_policies(num_agents, obs_dim, 4, 16, &device);
let optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents, 3e-4);
let config = JointTrainerConfig {
num_agents,
rollout_steps: t,
n_epochs: 1,
minibatch_size: t,
..Default::default()
};
let trainer = JointMultiAgentTrainer::new(policies, optimizers, config, device).unwrap();
let mut env = MockEnv::new(num_agents, obs_dim);
let mut last_obs = env.reset_joint(None);
let mut rng = StdRng::seed_from_u64(0);
let rollout = trainer.collect_rollout(&mut env, &mut last_obs, &mut rng);
assert_eq!(rollout.num_steps(), t);
assert_eq!(rollout.num_agents(), num_agents);
assert_eq!(rollout.obs_dims, vec![obs_dim; num_agents]);
assert_eq!(rollout.num_action_dims, 1);
assert_eq!(rollout.observations_per_agent.len(), num_agents);
for buf in &rollout.observations_per_agent {
assert_eq!(buf.len(), t * obs_dim);
}
for a in &rollout.actions {
assert_eq!(a.len(), t);
}
for r in &rollout.rewards {
assert_eq!(r.len(), t);
}
for lp in &rollout.log_probs {
assert_eq!(lp.len(), t);
}
for v in &rollout.values {
assert_eq!(v.len(), t);
}
assert_eq!(rollout.dones.len(), t);
}
#[test]
fn test_aux_fn_couples_all_agents_into_stats() {
let device = Default::default();
let num_agents = 2;
let obs_dim: usize = 4;
let policies = make_mlp_policies(num_agents, obs_dim, 3, 16, &device);
let optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents, 1e-3);
let config = JointTrainerConfig {
num_agents,
rollout_steps: 32,
n_epochs: 1,
minibatch_size: 32,
normalize_advantages: false,
..Default::default()
};
let mut trainer =
JointMultiAgentTrainer::new(policies, optimizers, config, device).unwrap();
let mut env = MockEnv::new(num_agents, obs_dim);
let mut last_obs = env.reset_joint(None);
let mut rng = StdRng::seed_from_u64(0);
let rollout = trainer.collect_rollout(&mut env, &mut last_obs, &mut rng);
let stats = trainer
.update(&rollout, &mut rng, |features: &[Tensor<B, 2>]| -> Option<Tensor<B, 1>> {
Some((features[0].clone() - features[1].clone()).powf_scalar(2.0_f32).sum())
})
.expect("update should not error");
assert!(
stats.aux_loss > 0.0,
"aux_loss must be > 0 with non-zero feature diff, got {}",
stats.aux_loss
);
assert!(stats.total_loss.is_finite());
}
#[test]
fn test_joint_trainer_multi_discrete() {
let device = Default::default();
let num_agents = 2;
let obs_dim: usize = 4;
let action_dims = vec![3_usize, 2];
let policies =
make_multi_discrete_policies(num_agents, obs_dim, action_dims.clone(), 16, &device);
let optimizers = build_optimizers::<MultiDiscreteMlpBurnPolicy<B>>(num_agents, 3e-4);
let config = JointTrainerConfig {
num_agents,
rollout_steps: 32,
n_epochs: 1,
minibatch_size: 32,
..Default::default()
};
let mut trainer =
JointMultiAgentTrainer::new(policies, optimizers, config, device).unwrap();
let mut env = MockEnv::new(num_agents, obs_dim);
let mut last_obs = env.reset_joint(None);
let mut rng = StdRng::seed_from_u64(0);
let rollout = trainer.collect_rollout(&mut env, &mut last_obs, &mut rng);
assert_eq!(rollout.num_action_dims, action_dims.len());
for a in &rollout.actions {
assert_eq!(a.len(), 32 * action_dims.len());
}
let stats = trainer
.update(&rollout, &mut rng, |_features: &[Tensor<B, 2>]| -> Option<Tensor<B, 1>> {
None
})
.expect("update should not error");
assert!(stats.total_loss.is_finite());
}
struct PerAgentObsMockEnv {
num_agents: usize,
obs_dim: usize,
}
impl PerAgentObsMockEnv {
fn new(num_agents: usize, obs_dim: usize) -> Self {
assert!(obs_dim >= num_agents, "obs_dim must be >= num_agents for one-hot encoding");
Self { num_agents, obs_dim }
}
fn per_agent_obs(&self) -> Vec<Vec<f32>> {
(0..self.num_agents)
.map(|i| {
let mut v = vec![0.0_f32; self.obs_dim];
v[i] = 1.0;
v
})
.collect()
}
}
impl JointEnv for PerAgentObsMockEnv {
fn reset_joint(&mut self, _seed: Option<u64>) -> Vec<Vec<f32>> {
self.per_agent_obs()
}
fn step_joint(&mut self, _actions: &[Vec<i64>]) -> JointStepResult {
JointStepResult {
rewards: vec![0.0_f32; self.num_agents],
done: false,
observations: self.per_agent_obs(),
}
}
}
#[test]
fn test_collect_rollout_reads_per_agent_observations() {
let device = Default::default();
let num_agents = 3;
let obs_dim: usize = 4; let t: usize = 16;
let policies = make_mlp_policies(num_agents, obs_dim, 2, 8, &device);
let optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents, 3e-4);
let config = JointTrainerConfig {
num_agents,
rollout_steps: t,
n_epochs: 1,
minibatch_size: t,
..Default::default()
};
let trainer = JointMultiAgentTrainer::new(policies, optimizers, config, device).unwrap();
let mut env = PerAgentObsMockEnv::new(num_agents, obs_dim);
let mut last_obs = env.reset_joint(None);
let mut rng = StdRng::seed_from_u64(0);
let rollout = trainer.collect_rollout(&mut env, &mut last_obs, &mut rng);
assert_eq!(rollout.observations_per_agent.len(), num_agents);
for (i, buf) in rollout.observations_per_agent.iter().enumerate() {
assert_eq!(buf.len(), t * obs_dim, "obs buffer for agent {i} has wrong length");
let mut expected = vec![0.0_f32; obs_dim];
expected[i] = 1.0;
for step in 0..t {
let start = step * obs_dim;
let slice = &buf[start..start + obs_dim];
assert_eq!(
slice,
expected.as_slice(),
"agent {i} step {step}: observation slice {:?} does not match agent {i}'s view {:?}",
slice,
expected,
);
}
}
}
#[test]
fn test_iterate_all_minibatches_runs() {
let device = Default::default();
let num_agents = 2;
let obs_dim: usize = 4;
let action_dim: usize = 3;
let policies = make_mlp_policies(num_agents, obs_dim, action_dim, 16, &device);
let optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents, 3e-4);
let config = JointTrainerConfig {
num_agents,
rollout_steps: 128,
n_epochs: 2,
minibatch_size: 32,
iterate_all_minibatches: true,
..Default::default()
};
let mut trainer =
JointMultiAgentTrainer::new(policies, optimizers, config, device).unwrap();
let mut env = MockEnv::new(num_agents, obs_dim);
let mut last_obs = env.reset_joint(None);
let mut rng = StdRng::seed_from_u64(0);
let rollout = trainer.collect_rollout(&mut env, &mut last_obs, &mut rng);
let stats = trainer
.update(&rollout, &mut rng, |_features: &[Tensor<B, 2>]| -> Option<Tensor<B, 1>> {
None
})
.expect("update should not error");
for i in 0..num_agents {
assert!(stats.policy_loss[i].is_finite(), "policy_loss[{i}] finite");
assert!(stats.value_loss[i].is_finite(), "value_loss[{i}] finite");
assert!(stats.entropy[i].is_finite(), "entropy[{i}] finite");
}
assert!(stats.total_loss.is_finite());
}
#[test]
fn comms_smoke_joint_trainer() {
use crate::env::games::signaling::{LISTENER, SPEAKER, SignalingGame};
let device: NdArrayDevice = Default::default();
let num_agents = 2;
let vocab = 4usize;
let speaker = MlpBurnPolicy::<B>::new(vocab, vocab, 8, &device);
let listener = MlpBurnPolicy::<B>::new(1, vocab, 8, &device);
let policies = vec![speaker, listener];
let optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents, 3e-4);
let config = JointTrainerConfig {
num_agents,
rollout_steps: 16,
n_epochs: 1,
minibatch_size: 16,
comms_coef: 0.5,
..Default::default()
};
let mut trainer =
JointMultiAgentTrainer::new(policies, optimizers, config, device).unwrap();
let mut env = SignalingGame::with_hidden(vocab, 2);
let mut last_obs = env.reset_joint(None);
assert_eq!(last_obs[SPEAKER].len(), vocab);
assert_eq!(last_obs[LISTENER].len(), 1);
let mut rng = StdRng::seed_from_u64(7);
let rollout = trainer.collect_rollout(&mut env, &mut last_obs, &mut rng);
assert_eq!(rollout.obs_dims, vec![vocab, 1]);
assert_eq!(rollout.observations_per_agent[SPEAKER].len(), 16 * vocab);
assert_eq!(rollout.observations_per_agent[LISTENER].len(), 16);
let stats = trainer
.update(&rollout, &mut rng, |_features: &[Tensor<B, 2>]| -> Option<Tensor<B, 1>> {
None
})
.expect("update should not error on the signaling game");
assert!(stats.aux_loss.is_finite(), "aux_loss must be finite");
assert_ne!(stats.aux_loss, 0.0, "comms_coef > 0 must populate aux_loss");
for i in 0..num_agents {
assert!(stats.policy_loss[i].is_finite(), "policy_loss[{i}] finite");
assert!(stats.value_loss[i].is_finite(), "value_loss[{i}] finite");
assert!(stats.entropy[i].is_finite(), "entropy[{i}] finite");
}
assert!(stats.total_loss.is_finite(), "total_loss must be finite");
}
#[test]
fn comms_disabled_by_default_leaves_aux_loss_zero() {
use crate::env::games::signaling::SignalingGame;
let device: NdArrayDevice = Default::default();
let num_agents = 2;
let vocab = 4usize;
let speaker = MlpBurnPolicy::<B>::new(vocab, vocab, 8, &device);
let listener = MlpBurnPolicy::<B>::new(1, vocab, 8, &device);
let policies = vec![speaker, listener];
let optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents, 3e-4);
let config = JointTrainerConfig {
num_agents,
rollout_steps: 16,
n_epochs: 1,
minibatch_size: 16,
..Default::default()
};
assert_eq!(config.comms_coef, 0.0);
let mut trainer =
JointMultiAgentTrainer::new(policies, optimizers, config, device).unwrap();
let mut env = SignalingGame::with_hidden(vocab, 1);
let mut last_obs = env.reset_joint(None);
let mut rng = StdRng::seed_from_u64(11);
let rollout = trainer.collect_rollout(&mut env, &mut last_obs, &mut rng);
let stats = trainer
.update(&rollout, &mut rng, |_features: &[Tensor<B, 2>]| -> Option<Tensor<B, 1>> {
None
})
.expect("update should not error");
assert_eq!(stats.aux_loss, 0.0, "aux_loss must be 0 when comms is off and aux_fn is None");
}
#[test]
fn test_split_critic_optimizer_runs() {
let device = Default::default();
let num_agents = 2;
let obs_dim: usize = 4;
let action_dim: usize = 3;
let policies = make_mlp_policies(num_agents, obs_dim, action_dim, 16, &device);
let optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents, 3e-4);
let critic_optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents, 1e-3);
let config = JointTrainerConfig {
num_agents,
rollout_steps: 128,
n_epochs: 2,
minibatch_size: 32,
iterate_all_minibatches: true,
critic_lr: Some(1e-3),
..Default::default()
};
let mut trainer = JointMultiAgentTrainer::with_critic_optimizers(
policies,
optimizers,
critic_optimizers,
config,
device,
)
.unwrap();
let mut env = MockEnv::new(num_agents, obs_dim);
let mut last_obs = env.reset_joint(None);
let mut rng = StdRng::seed_from_u64(0);
let rollout = trainer.collect_rollout(&mut env, &mut last_obs, &mut rng);
let stats = trainer
.update(&rollout, &mut rng, |_features: &[Tensor<B, 2>]| -> Option<Tensor<B, 1>> {
None
})
.expect("split-critic update should not error");
for i in 0..num_agents {
assert!(stats.policy_loss[i].is_finite(), "policy_loss[{i}] finite");
assert!(stats.value_loss[i].is_finite(), "value_loss[{i}] finite");
assert!(stats.entropy[i].is_finite(), "entropy[{i}] finite");
assert!(stats.explained_var[i].is_finite(), "explained_var[{i}] finite");
}
assert!(stats.total_loss.is_finite());
}
#[test]
fn test_with_critic_optimizers_length_mismatch_errors() {
let device = Default::default();
let num_agents = 2;
let policies = make_mlp_policies(num_agents, 4, 3, 8, &device);
let optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents, 3e-4);
let critic_optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents - 1, 1e-3);
let config = JointTrainerConfig { num_agents, ..Default::default() };
let result = JointMultiAgentTrainer::with_critic_optimizers(
policies,
optimizers,
critic_optimizers,
config,
device,
);
assert!(result.is_err(), "mismatched critic_optimizers length must be rejected");
}
#[test]
fn test_default_path_has_no_critic_optimizers() {
let device = Default::default();
let num_agents = 2;
let policies = make_mlp_policies(num_agents, 4, 3, 8, &device);
let optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents, 3e-4);
let config = JointTrainerConfig { num_agents, ..Default::default() };
assert!(
JointTrainerConfig::default().critic_lr.is_none(),
"default critic_lr must be None"
);
let trainer = JointMultiAgentTrainer::new(policies, optimizers, config, device).unwrap();
assert!(
trainer.critic_optimizers.is_empty(),
"default trainer must carry no critic optimizers (single combined backward)"
);
}
}