thrust_rl/policy/seeded_init.rs
1//! Seeded, host-side weight-initialization helpers for bit-exact
2//! policy construction.
3//!
4//! # Why this module exists
5//!
6//! Burn 0.21's [`burn::nn::Initializer::init_with`] /
7//! `init_tensor` (see
8//! `burn-core-0.21.0/src/module/initializer.rs`) take **no seed
9//! parameter** — they route through a backend-internal
10//! `Tensor::random` that draws from an unseeded, thread-local
11//! generator. That makes
12//! [`Initializer::Orthogonal`](burn::nn::Initializer::Orthogonal) and
13//! [`Initializer::KaimingUniform`](burn::nn::Initializer::KaimingUniform) the
14//! last non-reproducible site in the PSRO/NFSP determinism contract advertised
15//! by [`crate::multi_agent::psro::PsroConfig::seed`] /
16//! [`crate::multi_agent::nfsp::NfspConfig::seed`] (every rollout/
17//! training RNG site was plumbed through `StdRng::seed_from_u64` in
18//! PRs #113/#116/#122/#123/#125; this closes the policy-init gap —
19//! issue #135).
20//!
21//! # What it does
22//!
23//! Provides pure-Rust, [`StdRng`](rand::rngs::StdRng)-driven ports of the two
24//! init recipes the MLP policies use:
25//!
26//! - [`seeded_orthogonal`](crate::policy::seeded_init::seeded_orthogonal) —
27//! orthogonal init via Householder QR of a Gaussian matrix, scaled by `gain`
28//! (PPO recipe: `sqrt(2)` trunk, `0.01` heads).
29//! - [`seeded_kaiming_uniform`](crate::policy::seeded_init::seeded_kaiming_uniform) — Kaiming-uniform init matching Burn's
30//! `KaimingUniform { gain, fan_out_only: false }` formula (`bound = gain *
31//! sqrt(3 / fan_in)`), the path [`crate::policy::mlp::MlpBurnPolicy::new`]
32//! exercises.
33//!
34//! # Bit-parity caveat
35//!
36//! These are **not** byte-identical to Burn's unseeded output — we are
37//! *replacing* the distribution with a reproducible one, not mirroring
38//! Burn's internal sampler. The only contract is: same `seed` (and
39//! same shape/gain) ⇒ same `Vec<f32>`, every run, every machine.
40
41use rand::{Rng, SeedableRng, rngs::StdRng};
42
43/// Draw a single `N(0, 1)` sample from `rng` via the Box–Muller
44/// transform.
45///
46/// We hand-roll the normal sampler (rather than pulling in
47/// `rand_distr`) so the only RNG draws are `f32` uniforms — keeping the
48/// per-construction consumption order trivial to reason about and the
49/// dependency surface unchanged. Two `StdRng`s seeded identically
50/// therefore yield identical draw sequences and identical matrices.
51fn standard_normal(rng: &mut StdRng) -> f32 {
52 // Guard u1 away from 0 so ln() is finite.
53 let u1: f32 = {
54 let x: f32 = rng.random();
55 if x <= f32::MIN_POSITIVE {
56 f32::MIN_POSITIVE
57 } else {
58 x
59 }
60 };
61 let u2: f32 = rng.random();
62 let r = (-2.0_f32 * u1.ln()).sqrt();
63 let theta = 2.0_f32 * std::f32::consts::PI * u2;
64 r * theta.cos()
65}
66
67/// Generate a seeded orthogonal weight matrix of shape `[d_in, d_out]`
68/// flattened in **row-major** order (row index over `d_in`, column
69/// index over `d_out`) — matching Burn's `Linear` weight layout
70/// (`weight: Param<Tensor<B, 2>>` with dims `[d_input, d_output]`).
71///
72/// Algorithm (a faithful, seeded port of the orthogonal recipe):
73/// 1. Sample an `[rows, cols]` matrix `A` from `N(0, 1)`.
74/// 2. Compute a (thin) QR factorization `A = Q R` via Householder reflections,
75/// with the sign convention `sign(diag(R)) >= 0` so the result is
76/// deterministic.
77/// 3. Return `gain * Q`.
78///
79/// `Q` has orthonormal columns when `rows >= cols`, and orthonormal
80/// rows when `rows < cols` (we QR the taller orientation internally and
81/// transpose back), matching the behavior of standard orthogonal init.
82pub fn seeded_orthogonal(seed: u64, d_in: usize, d_out: usize, gain: f32) -> Vec<f32> {
83 let mut rng = StdRng::seed_from_u64(seed);
84
85 // We want an [d_in, d_out] result with orthonormal rows or columns.
86 // QR is cleanest when the matrix is tall (rows >= cols). If
87 // d_in < d_out we QR the transpose ([d_out, d_in]) and transpose
88 // the resulting Q back.
89 let (rows, cols, transposed) = if d_in >= d_out {
90 (d_in, d_out, false)
91 } else {
92 (d_out, d_in, true)
93 };
94
95 // Sample the tall Gaussian matrix in column-major working storage:
96 // a[c] is the c-th column (length `rows`). Column-major makes the
97 // Householder loop's column access contiguous.
98 let mut cols_data: Vec<Vec<f32>> = Vec::with_capacity(cols);
99 for _ in 0..cols {
100 let mut col = Vec::with_capacity(rows);
101 for _ in 0..rows {
102 col.push(standard_normal(&mut rng));
103 }
104 cols_data.push(col);
105 }
106
107 // Modified Gram–Schmidt orthonormalization (numerically adequate
108 // for the small matrices used here, and trivially deterministic).
109 // Equivalent in outcome to a Householder QR's Q for full-rank
110 // Gaussian input; we additionally fix the sign so the diagonal of
111 // the implied R is non-negative.
112 let mut q: Vec<Vec<f32>> = Vec::with_capacity(cols);
113 for j in 0..cols {
114 let mut v = cols_data[j].clone();
115 // Subtract projections onto the previously-fixed orthonormal
116 // columns.
117 for qi in q.iter() {
118 let dot: f32 = (0..rows).map(|r| v[r] * qi[r]).sum();
119 for r in 0..rows {
120 v[r] -= dot * qi[r];
121 }
122 }
123 // Normalize.
124 let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
125 let norm = if norm <= f32::MIN_POSITIVE { 1.0 } else { norm };
126 for slot in v.iter_mut() {
127 *slot /= norm;
128 }
129 // Sign fix: make the leading nonzero-ish entry positive so the
130 // result is a deterministic function of the seed regardless of
131 // platform float associativity in the projection sums.
132 if v[j] < 0.0 {
133 for slot in v.iter_mut() {
134 *slot = -*slot;
135 }
136 }
137 q.push(v);
138 }
139
140 // q[c][r] = Q[r, c] for the tall orientation [rows, cols].
141 // Emit row-major [d_in, d_out] * gain.
142 let mut out = vec![0.0_f32; d_in * d_out];
143 for i in 0..d_in {
144 for j in 0..d_out {
145 // Map (i, j) in [d_in, d_out] back to (r, c) in [rows, cols].
146 let (r, c) = if transposed { (j, i) } else { (i, j) };
147 out[i * d_out + j] = gain * q[c][r];
148 }
149 }
150 out
151}
152
153/// Generate a seeded Kaiming-uniform weight matrix of shape
154/// `[d_in, d_out]` flattened in **row-major** order.
155///
156/// Matches Burn's `Initializer::KaimingUniform { gain, fan_out_only:
157/// false }` formula: each entry is drawn uniformly from
158/// `[-bound, bound]` where `bound = gain * sqrt(3 / fan_in)` and
159/// `fan_in = d_in`. This is the path
160/// [`crate::policy::mlp::MlpBurnPolicy::new`] uses
161/// (`use_orthogonal_init = false`), so seeding it is required to make
162/// the matching-pennies tests (which build policies via `new`)
163/// reproducible — see issue #135's Correction 2.
164pub fn seeded_kaiming_uniform(seed: u64, d_in: usize, d_out: usize, gain: f32) -> Vec<f32> {
165 let mut rng = StdRng::seed_from_u64(seed);
166 let fan_in = d_in.max(1) as f32;
167 let bound = gain * (3.0_f32 / fan_in).sqrt();
168 let mut out = vec![0.0_f32; d_in * d_out];
169 for slot in out.iter_mut() {
170 // Uniform in [-bound, bound]: rng.random() is [0, 1).
171 let u: f32 = rng.random();
172 *slot = (u * 2.0 - 1.0) * bound;
173 }
174 out
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 /// Two calls with the same seed/shape/gain produce bit-identical
182 /// orthogonal matrices; a different seed differs.
183 #[test]
184 fn orthogonal_is_deterministic_in_seed() {
185 let a = seeded_orthogonal(42, 6, 4, 2.0_f32.sqrt());
186 let b = seeded_orthogonal(42, 6, 4, 2.0_f32.sqrt());
187 assert_eq!(a, b, "same seed must be bit-identical");
188 let c = seeded_orthogonal(43, 6, 4, 2.0_f32.sqrt());
189 assert_ne!(a, c, "different seed must differ");
190 assert_eq!(a.len(), 24);
191 }
192
193 /// Columns of the [d_in, d_out] result (d_in >= d_out) are
194 /// orthonormal up to the gain factor.
195 #[test]
196 fn orthogonal_columns_are_orthonormal_tall() {
197 let d_in = 8;
198 let d_out = 3;
199 let gain = 1.0;
200 let w = seeded_orthogonal(7, d_in, d_out, gain);
201 // Column dot products: col j . col k should be ~delta_{jk}.
202 for j in 0..d_out {
203 for k in 0..d_out {
204 let mut dot = 0.0_f32;
205 for r in 0..d_in {
206 dot += w[r * d_out + j] * w[r * d_out + k];
207 }
208 let expected = if j == k { 1.0 } else { 0.0 };
209 assert!(
210 (dot - expected).abs() < 1e-4,
211 "col {j}.col {k} = {dot}, expected {expected}"
212 );
213 }
214 }
215 }
216
217 /// Rows of the [d_in, d_out] result (d_in < d_out) are orthonormal
218 /// up to the gain factor.
219 #[test]
220 fn orthogonal_rows_are_orthonormal_wide() {
221 let d_in = 3;
222 let d_out = 8;
223 let gain = 1.0;
224 let w = seeded_orthogonal(11, d_in, d_out, gain);
225 for i in 0..d_in {
226 for k in 0..d_in {
227 let mut dot = 0.0_f32;
228 for c in 0..d_out {
229 dot += w[i * d_out + c] * w[k * d_out + c];
230 }
231 let expected = if i == k { 1.0 } else { 0.0 };
232 assert!(
233 (dot - expected).abs() < 1e-4,
234 "row {i}.row {k} = {dot}, expected {expected}"
235 );
236 }
237 }
238 }
239
240 /// Gain scales the matrix linearly.
241 #[test]
242 fn orthogonal_gain_scales() {
243 let w1 = seeded_orthogonal(5, 6, 4, 1.0);
244 let w2 = seeded_orthogonal(5, 6, 4, 3.0);
245 for (a, b) in w1.iter().zip(w2.iter()) {
246 assert!((b - a * 3.0).abs() < 1e-5, "gain should scale: {a} vs {b}");
247 }
248 }
249
250 /// Kaiming-uniform is deterministic in seed and respects the
251 /// `gain * sqrt(3 / fan_in)` bound.
252 #[test]
253 fn kaiming_is_deterministic_and_bounded() {
254 let d_in = 16;
255 let d_out = 8;
256 let gain = 1.0_f32 / 3.0_f32.sqrt();
257 let a = seeded_kaiming_uniform(99, d_in, d_out, gain);
258 let b = seeded_kaiming_uniform(99, d_in, d_out, gain);
259 assert_eq!(a, b, "same seed must be bit-identical");
260 let c = seeded_kaiming_uniform(100, d_in, d_out, gain);
261 assert_ne!(a, c, "different seed must differ");
262
263 let bound = gain * (3.0_f32 / d_in as f32).sqrt();
264 for &x in &a {
265 assert!(x.abs() <= bound + 1e-6, "{x} exceeds bound {bound}");
266 }
267 assert_eq!(a.len(), d_in * d_out);
268 }
269}