thrust_rl/policy/mlp.rs
1//! Burn-backend MLP actor-critic policy.
2//!
3//! Implements a 2/3-layer MLP actor-critic architecture with orthogonal
4//! initialization (PPO recipe — gain `sqrt(2)` on the trunk, `0.01` on
5//! the output heads).
6//!
7//! # Entry points
8//!
9//! - `MlpBurnPolicy::new` — the simple scout-era constructor (random Kaiming
10//! init, 2 layers).
11//! - `MlpBurnConfig` — builder-style configuration with orthogonal init,
12//! activation, and depth knobs; supports the encoder-tap helper that
13//! downstream regularizers want.
14//!
15//! # Why generic over `B: Backend`?
16//!
17//! Burn's idiomatic pattern is to make every `Module` generic over a
18//! `Backend` type parameter (CPU `NdArray`, GPU `Wgpu`/`Cuda`,
19//! autodiff-decorated variants, etc.). Production trainers can re-use
20//! the same modules with a different backend at the top of the binary
21//! without touching the policy code.
22
23use burn::{
24 module::{Module, Param},
25 nn::{Initializer, Linear},
26 tensor::{Int, Tensor, activation, backend::Backend},
27};
28
29/// Build a [`Linear`] layer with an explicit weight initializer and a
30/// zeroed bias.
31///
32/// Burn's `LinearConfig::with_initializer` applies the same initializer
33/// to both the weight and the bias, but [`Initializer::Orthogonal`]
34/// requires a rank-≥2 tensor and panics on the 1D bias. The PPO recipe
35/// (mirrored on the tch path) initializes biases to zero anyway, so the
36/// idiomatic Burn analogue is "Orthogonal on the weight, zero on the
37/// bias". This helper packages that two-step setup.
38///
39/// Re-used by [`MlpBurnPolicy`],
40/// [`crate::policy::multi_discrete_mlp::MultiDiscreteMlpBurnPolicy`],
41/// [`crate::policy::q_network::QNetworkBurn`], and
42/// [`crate::policy::snake_cnn::SnakeCnnBurnPolicy`].
43pub(crate) fn linear_with_init<B: Backend>(
44 d_input: usize,
45 d_output: usize,
46 initializer: Initializer,
47 device: &B::Device,
48) -> Linear<B> {
49 // Build a 2D weight Param via the initializer, and a 1D zero bias
50 // Param via Param::from_tensor. `LinearConfig::with_initializer`
51 // can't help here because it applies the same initializer to both
52 // weight and bias, and `Initializer::Orthogonal` panics on the
53 // rank-1 bias tensor (it requires `D >= 2`).
54 let weight: Param<Tensor<B, 2>> = initializer.init_with::<B, 2, _>(
55 [d_input, d_output],
56 Some(d_input),
57 Some(d_output),
58 device,
59 );
60 let bias_tensor = Tensor::<B, 1>::zeros([d_output], device);
61 Linear::<B> { weight, bias: Some(Param::from_tensor(bias_tensor)) }
62}
63
64/// Build a [`Linear`] layer from a pre-computed, row-major
65/// `[d_input, d_output]` weight buffer (and a zeroed bias).
66///
67/// This is the seeded counterpart to [`linear_with_init`]: instead of
68/// routing through Burn's unseedable [`Initializer`], the caller
69/// supplies weights produced by
70/// [`crate::policy::seeded_init`] (driven by `StdRng::seed_from_u64`),
71/// so two constructions with the same seed yield bit-identical layers.
72/// Used by the `with_config` seeded path on both
73/// [`MlpBurnPolicy`] and
74/// [`crate::policy::multi_discrete_mlp::MultiDiscreteMlpBurnPolicy`]
75/// (issue #135).
76pub(crate) fn linear_from_weights<B: Backend>(
77 d_input: usize,
78 d_output: usize,
79 weights: &[f32],
80 device: &B::Device,
81) -> Linear<B> {
82 debug_assert_eq!(weights.len(), d_input * d_output, "weight buffer must be d_input * d_output");
83 let weight_tensor = Tensor::<B, 2>::from_data(
84 burn::tensor::TensorData::new(weights.to_vec(), [d_input, d_output]),
85 device,
86 );
87 let bias_tensor = Tensor::<B, 1>::zeros([d_output], device);
88 Linear::<B> {
89 weight: Param::from_tensor(weight_tensor),
90 bias: Some(Param::from_tensor(bias_tensor)),
91 }
92}
93
94/// Produce a seeded weight buffer for one layer, honoring the
95/// orthogonal-vs-Kaiming recipe shared by both MLP policies.
96///
97/// Mirrors the gains used on the unseeded [`Initializer`] path:
98/// orthogonal trunk `sqrt(2)` / head `0.01`; Kaiming `1/sqrt(3)` for
99/// both heads and trunk (Burn's `KaimingUniform` default gain).
100pub(crate) fn seeded_layer_weights(
101 seed: u64,
102 d_in: usize,
103 d_out: usize,
104 use_orthogonal: bool,
105 is_head: bool,
106) -> Vec<f32> {
107 use crate::policy::seeded_init::{seeded_kaiming_uniform, seeded_orthogonal};
108 if use_orthogonal {
109 let gain = if is_head { 0.01_f32 } else { 2.0_f32.sqrt() };
110 seeded_orthogonal(seed, d_in, d_out, gain)
111 } else {
112 let gain = 1.0_f32 / 3.0_f32.sqrt();
113 seeded_kaiming_uniform(seed, d_in, d_out, gain)
114 }
115}
116
117/// Derive a distinct per-layer seed from a base construction seed.
118///
119/// Each `Linear` layer in a policy must draw from a *different* RNG
120/// stream — otherwise every layer of the same shape would get identical
121/// weights. We mix the base seed with a small per-layer index using a
122/// SplitMix64-style finalizer so the streams are decorrelated yet fully
123/// determined by `(base_seed, layer_index)`.
124pub(crate) fn derive_layer_seed(base_seed: u64, layer_index: u64) -> u64 {
125 let mut z = base_seed.wrapping_add(layer_index.wrapping_mul(0x9E37_79B9_7F4A_7C15));
126 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
127 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
128 z ^ (z >> 31)
129}
130
131/// Activation function applied between hidden layers in
132/// [`MlpBurnPolicy`] (and its multi-discrete sibling).
133///
134/// Mirrors [`crate::policy::mlp::BurnActivation`] on the tch path; the two
135/// enums are deliberately separate so the Burn module does not pull in
136/// `tch` types under `--features training-burn` alone.
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum BurnActivation {
139 /// Rectified linear unit (`max(0, x)`).
140 ReLU,
141 /// Hyperbolic tangent (`tanh(x)`).
142 Tanh,
143}
144
145/// Host-side categorical distribution for one or more rows, produced by
146/// [`MlpBurnPolicy::forward_to_host_dist`].
147///
148/// Holds the flattened per-row `probs` / `log_probs` (`[batch, n_actions]`
149/// row-major) and per-row `values`. The seeded draw lives in
150/// [`HostCategoricalDist::sample_actions`] so the tensor forward and the
151/// RNG-consuming sample are cleanly separated — the precondition that lets
152/// the batched sampler do one `[N, obs_dim]` forward while keeping the
153/// per-row RNG draw order bit-identical (issue #235).
154struct HostCategoricalDist {
155 batch: usize,
156 n_actions: usize,
157 probs_flat: Vec<f32>,
158 log_probs_flat: Vec<f32>,
159 values_host: Vec<f32>,
160}
161
162impl HostCategoricalDist {
163 /// Draw one action per row from the categorical distribution, consuming
164 /// exactly one `rng.random()` per row in ascending row order. Returns
165 /// `(actions, log_probs_of_chosen, values)`.
166 ///
167 /// The draw is byte-for-byte the loop that
168 /// [`MlpBurnPolicy::get_action_host_seeded`] used before the
169 /// forward/sample split, so a same-seeded `rng` reproduces the exact
170 /// same action stream (issue #114 / #235).
171 fn sample_actions(&self, rng: &mut rand::rngs::StdRng) -> (Vec<i64>, Vec<f32>, Vec<f32>) {
172 use rand::Rng;
173 let mut actions = Vec::with_capacity(self.batch);
174 let mut log_probs = Vec::with_capacity(self.batch);
175 for row in 0..self.batch {
176 let u: f32 = rng.random();
177 let mut cum = 0.0;
178 let mut chosen = (self.n_actions - 1) as i64;
179 for j in 0..self.n_actions {
180 cum += self.probs_flat[row * self.n_actions + j];
181 if u < cum {
182 chosen = j as i64;
183 break;
184 }
185 }
186 actions.push(chosen);
187 log_probs.push(self.log_probs_flat[row * self.n_actions + chosen as usize]);
188 }
189 (actions, log_probs, self.values_host.clone())
190 }
191}
192
193/// Configuration for [`MlpBurnPolicy`] architecture.
194///
195/// Mirrors [`crate::policy::mlp::MlpBurnConfig`] on the tch path. Stored
196/// inside the policy so the parity tests can compare both backends on
197/// identical hyperparameters.
198#[derive(Debug, Clone, Copy)]
199pub struct MlpBurnConfig {
200 /// Number of hidden layers in the shared trunk. Only `2` or `3` are
201 /// supported; anything else is treated as `2`.
202 pub num_layers: usize,
203 /// Width of every hidden layer.
204 pub hidden_dim: usize,
205 /// If `true`, initialize hidden-layer weights with
206 /// [`Initializer::Orthogonal`] (gain `sqrt(2)`) and output heads
207 /// with `Initializer::Orthogonal { gain = 0.01 }`. Set `false` to
208 /// fall back to Burn's default Kaiming-uniform init.
209 pub use_orthogonal_init: bool,
210 /// Activation applied between hidden layers.
211 pub activation: BurnActivation,
212 /// Optional construction seed. When `Some`, `with_config` builds
213 /// every layer from a deterministic, [`StdRng`](rand::rngs::StdRng)-
214 /// driven weight buffer (see [`crate::policy::seeded_init`]) instead
215 /// of Burn's unseedable [`Initializer`], so two constructions with
216 /// the same seed produce **bit-identical** policies. When `None`
217 /// (the default) the behavior is unchanged — Burn's `Initializer`
218 /// path is used verbatim. This is the load-bearing knob behind the
219 /// end-to-end `PsroConfig::seed` / `NfspConfig::seed` reproducibility
220 /// contract (issue #135). The seeded path covers **both** the
221 /// orthogonal and Kaiming-uniform recipes (selected by
222 /// `use_orthogonal_init`), so seeding works regardless of which init
223 /// the caller picks.
224 pub seed: Option<u64>,
225}
226
227impl Default for MlpBurnConfig {
228 fn default() -> Self {
229 Self {
230 num_layers: 2,
231 hidden_dim: 64,
232 use_orthogonal_init: true,
233 activation: BurnActivation::Tanh,
234 seed: None,
235 }
236 }
237}
238
239impl MlpBurnConfig {
240 /// Set the construction seed, enabling the deterministic
241 /// host-side init path in `with_config`.
242 ///
243 /// Builder-style; returns `self` for chaining:
244 /// `MlpBurnConfig::default().with_seed(42)`.
245 pub fn with_seed(mut self, seed: u64) -> Self {
246 self.seed = Some(seed);
247 self
248 }
249}
250
251/// Two- or three-layer MLP actor-critic for **discrete** action spaces,
252/// ported to Burn.
253///
254/// Layout mirrors [`crate::policy::mlp::MlpBurnPolicy`] at a high level:
255///
256/// ```text
257/// obs → fc1 →act→ fc2 →act→ (fc3 →act→)? policy_head (logits)
258/// └─ value_head (V(s))
259/// ```
260///
261/// Both heads share the trunk activations — standard PPO actor-critic.
262///
263/// # Numerical parity
264///
265/// When constructed with `use_orthogonal_init = true` (the default), the
266/// trunk uses [`Initializer::Orthogonal { gain: sqrt(2) }`] and the
267/// output heads use `gain = 0.01`. These match the tch policy's init
268/// gains exactly (see [`crate::policy::mlp::MlpBurnPolicy::with_config`]),
269/// which is the necessary precondition for the phase-4 numerical-parity
270/// check called out on issue #81.
271#[derive(Module, Debug)]
272pub struct MlpBurnPolicy<B: Backend> {
273 fc1: Linear<B>,
274 fc2: Linear<B>,
275 fc3: Option<Linear<B>>,
276 policy_head: Linear<B>,
277 value_head: Linear<B>,
278 activation: BurnActivation,
279}
280
281impl<B: Backend> MlpBurnPolicy<B> {
282 /// Backward-compatible 2-layer constructor (the phase 1 scout
283 /// signature). Uses Burn's default Kaiming-uniform init — kept so
284 /// the existing bandit trainer and parity tests are not perturbed.
285 ///
286 /// New call sites that want PPO-style orthogonal init should call
287 /// [`MlpBurnPolicy::with_config`] instead.
288 pub fn new(obs_dim: usize, action_dim: usize, hidden_dim: usize, device: &B::Device) -> Self {
289 let config = MlpBurnConfig {
290 num_layers: 2,
291 hidden_dim,
292 // Preserve scout behavior — the phase 1 scout used the
293 // default LinearConfig init (Kaiming uniform), not the
294 // PPO orthogonal recipe.
295 use_orthogonal_init: false,
296 activation: BurnActivation::Tanh,
297 seed: None,
298 };
299 Self::with_config(obs_dim, action_dim, config, device)
300 }
301
302 /// Seeded variant of [`new`](Self::new): same 2-layer Kaiming
303 /// architecture, but constructed deterministically from `seed` so
304 /// two calls with the same seed produce bit-identical weights.
305 ///
306 /// Convenience wrapper for callers (and the PSRO/NFSP policy
307 /// factories) that want reproducible policies without assembling a
308 /// full [`MlpBurnConfig`]. Equivalent to
309 /// `with_config(.., MlpBurnConfig { use_orthogonal_init: false,
310 /// .., seed: Some(seed) }, ..)`.
311 pub fn new_seeded(
312 obs_dim: usize,
313 action_dim: usize,
314 hidden_dim: usize,
315 seed: u64,
316 device: &B::Device,
317 ) -> Self {
318 let config = MlpBurnConfig {
319 num_layers: 2,
320 hidden_dim,
321 use_orthogonal_init: false,
322 activation: BurnActivation::Tanh,
323 seed: Some(seed),
324 };
325 Self::with_config(obs_dim, action_dim, config, device)
326 }
327
328 /// Build a fresh policy on `device` with the given configuration.
329 ///
330 /// This is the production constructor for phase 4 onwards. Mirrors
331 /// [`crate::policy::mlp::MlpBurnPolicy::with_config`].
332 pub fn with_config(
333 obs_dim: usize,
334 action_dim: usize,
335 config: MlpBurnConfig,
336 device: &B::Device,
337 ) -> Self {
338 // Seeded path (issue #135): when `config.seed` is set, build
339 // every layer from a deterministic `StdRng`-driven weight buffer
340 // so two constructions with the same seed are bit-identical.
341 // Each layer gets a distinct derived seed so layers of the same
342 // shape don't collide. Layer indices are fixed:
343 // 0=fc1, 1=fc2, 2=fc3, 3=policy_head, 4=value_head.
344 if let Some(seed) = config.seed {
345 let orth = config.use_orthogonal_init;
346 let mk = |idx: u64, d_in: usize, d_out: usize, is_head: bool| {
347 let s = derive_layer_seed(seed, idx);
348 let w = seeded_layer_weights(s, d_in, d_out, orth, is_head);
349 linear_from_weights::<B>(d_in, d_out, &w, device)
350 };
351 let fc1 = mk(0, obs_dim, config.hidden_dim, false);
352 let fc2 = mk(1, config.hidden_dim, config.hidden_dim, false);
353 let fc3 = if config.num_layers >= 3 {
354 Some(mk(2, config.hidden_dim, config.hidden_dim, false))
355 } else {
356 None
357 };
358 let policy_head = mk(3, config.hidden_dim, action_dim, true);
359 let value_head = mk(4, config.hidden_dim, 1, true);
360 return Self { fc1, fc2, fc3, policy_head, value_head, activation: config.activation };
361 }
362
363 let hidden_init = if config.use_orthogonal_init {
364 Initializer::Orthogonal { gain: 2.0_f64.sqrt() }
365 } else {
366 // Burn's default — see LinearConfig docs.
367 Initializer::KaimingUniform { gain: 1.0_f64 / 3.0_f64.sqrt(), fan_out_only: false }
368 };
369 let output_init = if config.use_orthogonal_init {
370 Initializer::Orthogonal { gain: 0.01 }
371 } else {
372 Initializer::KaimingUniform { gain: 1.0_f64 / 3.0_f64.sqrt(), fan_out_only: false }
373 };
374
375 let fc1 = linear_with_init::<B>(obs_dim, config.hidden_dim, hidden_init.clone(), device);
376 let fc2 = linear_with_init::<B>(
377 config.hidden_dim,
378 config.hidden_dim,
379 hidden_init.clone(),
380 device,
381 );
382 let fc3 = if config.num_layers >= 3 {
383 Some(linear_with_init::<B>(config.hidden_dim, config.hidden_dim, hidden_init, device))
384 } else {
385 None
386 };
387
388 let policy_head =
389 linear_with_init::<B>(config.hidden_dim, action_dim, output_init.clone(), device);
390 let value_head = linear_with_init::<B>(config.hidden_dim, 1, output_init, device);
391
392 Self { fc1, fc2, fc3, policy_head, value_head, activation: config.activation }
393 }
394
395 fn apply_activation<const D: usize>(&self, x: Tensor<B, D>) -> Tensor<B, D> {
396 match self.activation {
397 BurnActivation::ReLU => activation::relu(x),
398 BurnActivation::Tanh => activation::tanh(x),
399 }
400 }
401
402 /// Forward pass: returns `(logits, value)`.
403 ///
404 /// * `obs` is shape `[batch, obs_dim]`.
405 /// * `logits` is shape `[batch, action_dim]` (pre-softmax).
406 /// * `value` is shape `[batch]` (squeezed from `[batch, 1]`).
407 pub fn forward(&self, obs: Tensor<B, 2>) -> (Tensor<B, 2>, Tensor<B, 1>) {
408 let h = self.encoder_features(obs);
409 let logits = self.policy_head.forward(h.clone());
410 let value = self.value_head.forward(h).squeeze_dim::<1>(1);
411 (logits, value)
412 }
413
414 /// Compute the shared-trunk feature representation for `obs`.
415 ///
416 /// Mirrors [`crate::policy::mlp::MlpBurnPolicy::encoder_features`] —
417 /// auxiliary regularizers (cross-agent redundancy penalties,
418 /// behavioural-diversity bonuses) tap this directly.
419 ///
420 /// Gradients flow back into the trunk.
421 pub fn encoder_features(&self, obs: Tensor<B, 2>) -> Tensor<B, 2> {
422 let h = self.apply_activation(self.fc1.forward(obs));
423 let h = self.apply_activation(self.fc2.forward(h));
424 if let Some(fc3) = &self.fc3 {
425 self.apply_activation(fc3.forward(h))
426 } else {
427 h
428 }
429 }
430
431 /// Action-head output dimensionality (number of discrete actions).
432 ///
433 /// Reads the `policy_head` weight tensor's shape — Burn's
434 /// [`burn::nn::Linear`] stores `weight: Param<Tensor<B, 2>>` with
435 /// shape `[d_input, d_output]`, so `d_output` is the action
436 /// cardinality. Used by the multi-agent joint trainer's
437 /// [`crate::multi_agent::joint::JointPolicy::action_dims_joint`] impl
438 /// to size the rollout action buffer without consuming RNG draws.
439 pub fn policy_head_action_dim(&self) -> usize {
440 self.policy_head.weight.val().dims()[1]
441 }
442
443 /// Borrow the first shared-trunk linear layer.
444 pub fn fc1(&self) -> &Linear<B> {
445 &self.fc1
446 }
447
448 /// Borrow the second shared-trunk linear layer.
449 pub fn fc2(&self) -> &Linear<B> {
450 &self.fc2
451 }
452
453 /// Borrow the policy (action-logits) head.
454 pub fn policy_head(&self) -> &Linear<B> {
455 &self.policy_head
456 }
457
458 /// Borrow the value (`V(s)`) head.
459 pub fn value_head(&self) -> &Linear<B> {
460 &self.value_head
461 }
462
463 /// Sample one action per row from the policy's categorical
464 /// distribution and return `(actions_host, log_probs_host,
465 /// values_host)` as plain `Vec`s.
466 ///
467 /// Thin backwards-compat wrapper around
468 /// [`MlpBurnPolicy::get_action_host_seeded`] that constructs a
469 /// thread-local RNG. **Not deterministic across calls** — use
470 /// [`get_action_host_seeded`](Self::get_action_host_seeded) and pass
471 /// a seeded [`rand::rngs::StdRng`] when reproducibility is required
472 /// (PSRO/NFSP/joint trainer rollouts call the seeded form via the
473 /// [`crate::multi_agent::joint::JointPolicy`] trait so that
474 /// `PsroConfig::seed` / `NfspConfig::seed` produce bit-identical
475 /// rollouts; see issue #114).
476 ///
477 /// Retained for example-driver convenience where the caller does
478 /// not need bit-exact reproducibility and would otherwise have to
479 /// thread an `&mut StdRng` through bespoke rollout loops.
480 pub fn get_action_host(&self, obs: Tensor<B, 2>) -> (Vec<i64>, Vec<f32>, Vec<f32>) {
481 use rand::SeedableRng;
482 // Seed from OS entropy so the wrapper remains stochastic for
483 // non-deterministic callers (the same behavior pre-#114, just
484 // routed through `StdRng`).
485 let mut rng = rand::rngs::StdRng::from_os_rng();
486 self.get_action_host_seeded(obs, &mut rng)
487 }
488
489 /// Same contract as [`get_action_host`](Self::get_action_host) but
490 /// the host-side categorical draws consume `rng` instead of the
491 /// thread-local generator.
492 ///
493 /// The trainer-side rollout loop does not need gradient flow
494 /// through the sampled action (only the eventual
495 /// [`MlpBurnPolicy::evaluate_actions`] call on the stored
496 /// transitions matters for the PPO surrogate). We therefore do the
497 /// categorical draw on the host with `rand`, sidestepping Burn
498 /// 0.21's lack of a first-class `multinomial` op.
499 ///
500 /// Bit-exactness contract: two calls with the same `obs`, same
501 /// `policy` state, and same-seeded `rng` (`StdRng::seed_from_u64`)
502 /// must produce element-wise identical
503 /// `(actions, log_probs, values)`. This is the load-bearing
504 /// guarantee `PsroConfig::seed` / `NfspConfig::seed` rely on after
505 /// issue #114.
506 pub fn get_action_host_seeded(
507 &self,
508 obs: Tensor<B, 2>,
509 rng: &mut rand::rngs::StdRng,
510 ) -> (Vec<i64>, Vec<f32>, Vec<f32>) {
511 // Decoupled into a pure-tensor forward (`forward_to_host_dist`,
512 // no RNG) followed by a host-side categorical draw
513 // (`sample_actions_from_host_dist`, the only RNG-consuming half).
514 // This split is what makes the **batched** entry point
515 // (`get_actions_host_seeded_batched`) possible without changing
516 // the per-row RNG draw order: a single forward over `[N, obs_dim]`
517 // produces N rows of host probs, then the per-row loop draws RNG in
518 // the exact same row-major sequence as N separate `[1, obs_dim]`
519 // calls would. Bit-exactness (issue #114 / #235) is therefore
520 // preserved by construction.
521 let dist = self.forward_to_host_dist(obs);
522 dist.sample_actions(rng)
523 }
524
525 /// Tensor half of [`get_action_host_seeded`]: run the policy forward
526 /// and pull the host-side categorical distribution (`probs`,
527 /// `log_probs`) plus values for every row. **Consumes no RNG.**
528 ///
529 /// Returns a [`HostCategoricalDist`] from which
530 /// [`HostCategoricalDist::sample_actions`] performs the seeded draw.
531 /// Splitting the forward (one batched tensor op over `[N, obs_dim]`)
532 /// from the sample (a row-major host loop) lets the batched sampler
533 /// replace N batch-1 forwards with a single `[N, obs_dim]` forward
534 /// while keeping the RNG draw order — and therefore the sampled
535 /// action stream — bit-identical (issue #235).
536 fn forward_to_host_dist(&self, obs: Tensor<B, 2>) -> HostCategoricalDist {
537 let (logits, value) = self.forward(obs);
538 let probs = activation::softmax(logits.clone(), 1);
539 let log_probs_all = activation::log_softmax(logits, 1);
540
541 let dims = probs.dims();
542 let batch = dims[0];
543 let n_actions = dims[1];
544
545 let probs_flat: Vec<f32> = probs.into_data().to_vec().expect("probs to_vec");
546 let log_probs_flat: Vec<f32> =
547 log_probs_all.into_data().to_vec().expect("log_probs to_vec");
548 let values_host: Vec<f32> = value.into_data().to_vec().expect("values to_vec");
549
550 HostCategoricalDist { batch, n_actions, probs_flat, log_probs_flat, values_host }
551 }
552
553 /// Batched seeded sampler: one forward over `[N, obs_dim]`, then N
554 /// host-side categorical draws in row-major order.
555 ///
556 /// Bit-identical to calling [`Self::get_action_host_seeded`] once per row
557 /// on `[1, obs_dim]` slices **provided the rows are drawn from the same
558 /// policy** — the forward is a single matmul over all N rows (same
559 /// weights), and the RNG is consumed one draw per row, ascending. This
560 /// is the [`crate::multi_agent::joint::JointPolicy`]-trait batched
561 /// entry point that eliminates per-call batch-1 overhead on the
562 /// NdArray backend wherever many observations are scored through the
563 /// **same** model in one step (issue #235).
564 pub fn get_actions_host_seeded_batched(
565 &self,
566 obs: Tensor<B, 2>,
567 rng: &mut rand::rngs::StdRng,
568 ) -> (Vec<i64>, Vec<f32>, Vec<f32>) {
569 self.forward_to_host_dist(obs).sample_actions(rng)
570 }
571
572 /// Evaluate a batch of `(obs, actions)` pairs.
573 ///
574 /// Returns `(action_log_probs, entropy_per_row, values)` — the
575 /// quantities the PPO surrogate loss needs. Entropy is per-row here
576 /// (not the mean): the caller decides how to aggregate. This
577 /// matches the tch policy's contract (the tch
578 /// `evaluate_actions` returns a scalar mean; the trainer reduces
579 /// per-row entropy on the Burn path inside
580 /// [`crate::train::ppo::trainer::PPOTrainerBurn::train_step`]).
581 pub fn evaluate_actions(
582 &self,
583 obs: Tensor<B, 2>,
584 actions: Tensor<B, 1, Int>,
585 ) -> (Tensor<B, 1>, Tensor<B, 1>, Tensor<B, 1>) {
586 let (logits, value) = self.forward(obs);
587 let log_probs = activation::log_softmax(logits, 1);
588 let probs = log_probs.clone().exp();
589
590 let action_log_probs =
591 log_probs.clone().gather(1, actions.unsqueeze_dim::<2>(1)).squeeze_dim::<1>(1);
592 // H = -Σ p * log p over the action axis.
593 let entropy = -(probs * log_probs).sum_dim(1).squeeze_dim::<1>(1);
594
595 (action_log_probs, entropy, value)
596 }
597}
598
599#[cfg(test)]
600mod tests {
601 use burn::backend::{Autodiff, NdArray};
602
603 use super::*;
604
605 type B = Autodiff<NdArray<f32>>;
606
607 #[test]
608 fn test_policy_creation_default() {
609 let device = Default::default();
610 let _policy = MlpBurnPolicy::<B>::new(4, 2, 64, &device);
611 }
612
613 #[test]
614 fn test_with_config_two_layer() {
615 let device = Default::default();
616 let cfg = MlpBurnConfig::default();
617 let policy = MlpBurnPolicy::<B>::with_config(4, 2, cfg, &device);
618 assert!(policy.fc3.is_none());
619 }
620
621 #[test]
622 fn test_with_config_three_layer() {
623 let device = Default::default();
624 let cfg = MlpBurnConfig { num_layers: 3, ..Default::default() };
625 let policy = MlpBurnPolicy::<B>::with_config(4, 2, cfg, &device);
626 assert!(policy.fc3.is_some());
627 }
628
629 #[test]
630 fn test_forward_pass_two_layer() {
631 let device = Default::default();
632 let cfg = MlpBurnConfig::default();
633 let policy = MlpBurnPolicy::<B>::with_config(4, 2, cfg, &device);
634 let obs = Tensor::<B, 2>::zeros([8, 4], &device);
635 let (logits, values) = policy.forward(obs);
636 assert_eq!(logits.dims(), [8, 2]);
637 assert_eq!(values.dims(), [8]);
638 }
639
640 #[test]
641 fn test_forward_pass_three_layer() {
642 let device = Default::default();
643 let cfg = MlpBurnConfig { num_layers: 3, ..Default::default() };
644 let policy = MlpBurnPolicy::<B>::with_config(4, 2, cfg, &device);
645 let obs = Tensor::<B, 2>::zeros([8, 4], &device);
646 let (logits, values) = policy.forward(obs);
647 assert_eq!(logits.dims(), [8, 2]);
648 assert_eq!(values.dims(), [8]);
649 }
650
651 #[test]
652 fn test_evaluate_actions_shapes() {
653 let device = Default::default();
654 let policy = MlpBurnPolicy::<B>::with_config(4, 2, MlpBurnConfig::default(), &device);
655 let obs = Tensor::<B, 2>::zeros([8, 4], &device);
656 let actions = Tensor::<B, 1, Int>::from_data(
657 burn::tensor::TensorData::new(vec![0i64, 1, 0, 1, 0, 1, 0, 1], [8]),
658 &device,
659 );
660 let (log_probs, entropy, values) = policy.evaluate_actions(obs, actions);
661 assert_eq!(log_probs.dims(), [8]);
662 assert_eq!(entropy.dims(), [8]);
663 assert_eq!(values.dims(), [8]);
664 }
665
666 #[test]
667 fn test_relu_activation_branch() {
668 let device = Default::default();
669 let cfg = MlpBurnConfig {
670 activation: BurnActivation::ReLU,
671 use_orthogonal_init: false,
672 ..Default::default()
673 };
674 let policy = MlpBurnPolicy::<B>::with_config(4, 2, cfg, &device);
675 let obs = Tensor::<B, 2>::zeros([2, 4], &device);
676 let (logits, _values) = policy.forward(obs);
677 assert_eq!(logits.dims(), [2, 2]);
678 }
679
680 /// Bit-exact reproducibility of [`MlpBurnPolicy::get_action_host_seeded`]
681 /// across same-seeded `StdRng` invocations.
682 ///
683 /// This is the load-bearing guarantee for `PsroConfig::seed` /
684 /// `NfspConfig::seed` after issue #114: two
685 /// `get_action_host_seeded` calls with the same `obs`, same policy
686 /// state, and same-seeded RNG must produce element-wise identical
687 /// `(actions, log_probs, values)`. The PSRO/NFSP integration
688 /// tests (`tests/test_psro_matching_pennies.rs` and
689 /// `tests/test_nfsp_matching_pennies.rs`) build their bit-exact
690 /// reproducibility chain on this primitive.
691 #[test]
692 fn test_get_action_host_seeded_is_bit_exact() {
693 use rand::{SeedableRng, rngs::StdRng};
694
695 let device = Default::default();
696 let policy = MlpBurnPolicy::<B>::with_config(4, 3, MlpBurnConfig::default(), &device);
697
698 // Two-row batch so we exercise the per-row loop body.
699 let obs_data = vec![0.1_f32, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8];
700 let obs_a = Tensor::<B, 2>::from_data(
701 burn::tensor::TensorData::new(obs_data.clone(), [2, 4]),
702 &device,
703 );
704 let obs_b =
705 Tensor::<B, 2>::from_data(burn::tensor::TensorData::new(obs_data, [2, 4]), &device);
706
707 // Same seed → bit-identical output.
708 let mut rng_a = StdRng::seed_from_u64(42);
709 let mut rng_b = StdRng::seed_from_u64(42);
710 let (a_a, lp_a, v_a) = policy.get_action_host_seeded(obs_a, &mut rng_a);
711 let (a_b, lp_b, v_b) = policy.get_action_host_seeded(obs_b, &mut rng_b);
712 assert_eq!(a_a, a_b, "same-seed actions must be bit-identical");
713 assert_eq!(lp_a, lp_b, "same-seed log_probs must be bit-identical");
714 assert_eq!(v_a, v_b, "same-seed values must be bit-identical");
715
716 // Different seed → at least one row's action should differ
717 // (modulo the unlikely event of identical samples — for 3
718 // actions, P(both rows match) = 1/9 in expectation under
719 // uniform logits; we use orthogonal init which doesn't
720 // produce uniform logits, so the probability is even lower).
721 let obs_c = Tensor::<B, 2>::from_data(
722 burn::tensor::TensorData::new(vec![0.1_f32, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8], [2, 4]),
723 &device,
724 );
725 let mut rng_c = StdRng::seed_from_u64(99);
726 let (a_c, _, _) = policy.get_action_host_seeded(obs_c, &mut rng_c);
727 // We can't assert hard inequality (low-but-nonzero probability
728 // of accidental match) — but at least the call must succeed
729 // and produce a 2-row response.
730 assert_eq!(a_c.len(), 2, "two-row batch returns two actions");
731 }
732
733 /// Flatten every weight + bias of a policy into one comparison
734 /// vector (test helper for the bit-identity assertions below).
735 fn collect_params(p: &MlpBurnPolicy<B>) -> Vec<f32> {
736 let mut out = Vec::new();
737 let mut push = |lin: &Linear<B>| {
738 out.extend::<Vec<f32>>(lin.weight.val().into_data().to_vec().unwrap());
739 if let Some(b) = &lin.bias {
740 out.extend::<Vec<f32>>(b.val().into_data().to_vec().unwrap());
741 }
742 };
743 push(&p.fc1);
744 push(&p.fc2);
745 if let Some(fc3) = &p.fc3 {
746 push(fc3);
747 }
748 push(&p.policy_head);
749 push(&p.value_head);
750 out
751 }
752
753 /// Two seeded constructions (orthogonal init) with the same seed
754 /// produce bit-identical weights; a different seed differs. This is
755 /// the core guarantee behind end-to-end `PsroConfig::seed` /
756 /// `NfspConfig::seed` reproducibility (issue #135).
757 #[test]
758 fn test_with_seed_is_bit_identical_orthogonal() {
759 let device = Default::default();
760 let cfg = MlpBurnConfig { num_layers: 3, ..Default::default() }.with_seed(42);
761 let a = MlpBurnPolicy::<B>::with_config(4, 2, cfg, &device);
762 let b = MlpBurnPolicy::<B>::with_config(4, 2, cfg, &device);
763 assert_eq!(collect_params(&a), collect_params(&b), "same seed must be bit-identical");
764
765 let cfg_diff = MlpBurnConfig { num_layers: 3, ..Default::default() }.with_seed(43);
766 let c = MlpBurnPolicy::<B>::with_config(4, 2, cfg_diff, &device);
767 assert_ne!(collect_params(&a), collect_params(&c), "different seed must differ");
768 }
769
770 /// Same as above but for the Kaiming-uniform path (`new_seeded`
771 /// uses `use_orthogonal_init = false`). This is the path the
772 /// matching-pennies tests exercise (issue #135, Correction 2).
773 #[test]
774 fn test_new_seeded_is_bit_identical_kaiming() {
775 let device = Default::default();
776 let a = MlpBurnPolicy::<B>::new_seeded(4, 2, 16, 7, &device);
777 let b = MlpBurnPolicy::<B>::new_seeded(4, 2, 16, 7, &device);
778 assert_eq!(collect_params(&a), collect_params(&b), "same seed must be bit-identical");
779 let c = MlpBurnPolicy::<B>::new_seeded(4, 2, 16, 8, &device);
780 assert_ne!(collect_params(&a), collect_params(&c), "different seed must differ");
781 }
782
783 /// Distinct layers within one policy must not share weights even
784 /// when they have the same shape (the per-layer seed derivation
785 /// must decorrelate them). fc2 and fc3 are both
786 /// `[hidden, hidden]`; assert they differ.
787 #[test]
788 fn test_seeded_layers_are_decorrelated() {
789 let device = Default::default();
790 let cfg = MlpBurnConfig { num_layers: 3, hidden_dim: 8, ..Default::default() }.with_seed(1);
791 let p = MlpBurnPolicy::<B>::with_config(8, 2, cfg, &device);
792 let fc2: Vec<f32> = p.fc2.weight.val().into_data().to_vec().unwrap();
793 let fc3: Vec<f32> = p.fc3.as_ref().unwrap().weight.val().into_data().to_vec().unwrap();
794 assert_ne!(fc2, fc3, "same-shape trunk layers must get distinct seeded weights");
795 }
796
797 /// The unseeded path (`seed: None`) is unchanged — two
798 /// constructions are *not* required to match (Burn's unseeded
799 /// init), and the seeded path must not accidentally fire. We just
800 /// assert construction succeeds and produces the right shapes, i.e.
801 /// the `None` branch is still wired to Burn's `Initializer`.
802 #[test]
803 fn test_unseeded_path_still_constructs() {
804 let device = Default::default();
805 let cfg = MlpBurnConfig::default(); // seed: None
806 assert!(cfg.seed.is_none());
807 let p = MlpBurnPolicy::<B>::with_config(4, 2, cfg, &device);
808 let obs = Tensor::<B, 2>::zeros([3, 4], &device);
809 let (logits, values) = p.forward(obs);
810 assert_eq!(logits.dims(), [3, 2]);
811 assert_eq!(values.dims(), [3]);
812 }
813}