Skip to main content

rlevo_evolution/
param_reshaper.rs

1//! Bridge between a Burn [`Module`] and a flat parameter vector.
2//!
3//! Weight-only neuroevolution evolves a `Tensor<B, 2>` population of shape
4//! `(pop_size, num_params)` and must, per population member, splat one flat
5//! parameter row back into a concrete network to score it. The
6//! [`ParamReshaper`] trait captures that bidirectional bridge:
7//!
8//! - [`flatten`](ParamReshaper::flatten) walks a module's float leaves in a
9//!   deterministic order and concatenates them into a 1-D tensor.
10//! - [`unflatten`](ParamReshaper::unflatten) clones a template module and
11//!   replaces each float leaf with the matching slice of a flat tensor, in the
12//!   *same* order. The reconstructed leaves are **views into the flat tensor's
13//!   storage**, not copies — see [`unflatten`](ParamReshaper::unflatten)'s
14//!   `# Aliasing` note.
15//!
16//! [`ModuleReshaper`] is the concrete implementation. It relies on the fact
17//! that Burn's `#[derive(Module)]` generates `visit`/`map` traversals that
18//! visit fields in declaration order, recursively — so `flatten` (a
19//! [`ModuleVisitor`]) and `unflatten` (a [`ModuleMapper`]) agree leaf-for-leaf.
20//!
21//! # Non-trainable module state
22//!
23//! Burn's `visit`/`map` traversal touches every float leaf reachable through
24//! the `Module` tree, **including** non-`Param` running statistics such as a
25//! [`burn::nn::BatchNorm`] layer's running mean/variance (they are wrapped in a
26//! `RunningState`, which is itself a `Module` that forwards to `visit_float` /
27//! `map_float`). The proof-of-concept in this module's test submodule verifies this
28//! empirically. The practical consequence: if an evolved network contains
29//! `BatchNorm`, its running statistics are flattened, perturbed by evolution,
30//! and re-splatted like any weight. For fixed-topology MLP policies (the v1
31//! target) this is moot — there are no running buffers. Callers that evolve
32//! batch-normalized networks should reset running statistics after
33//! [`unflatten`](ParamReshaper::unflatten).
34//!
35//! # Gradient isolation
36//!
37//! This module is generic over `B: Backend`, **not** `AutodiffBackend`.
38//! Tensors produced by [`unflatten`](ParamReshaper::unflatten) do not require
39//! gradients. Callers holding an autodiff module call `.valid()` before
40//! constructing a [`ModuleReshaper`], so the constraint is enforced at the
41//! type level rather than by convention.
42
43use std::marker::PhantomData;
44
45use burn::module::{Module, ModuleMapper, ModuleVisitor, Param};
46use burn::tensor::{Tensor, backend::Backend};
47
48/// Bridges a Burn [`Module`] and a flat `Tensor<B, 1>` parameter vector.
49///
50/// Lives entirely in `rlevo-evolution` and depends only on `burn` — no
51/// `rlevo-core` coupling.
52///
53/// # Invariants
54///
55/// - [`flatten`](Self::flatten) and [`unflatten`](Self::unflatten) must visit
56///   float leaves in the *same* deterministic order, so that
57///   `unflatten(flatten(m))` reconstructs `m` leaf-for-leaf.
58/// - [`num_params`](Self::num_params) equals the total element count produced
59///   by [`flatten`](Self::flatten).
60///
61/// Implementors are `Send + Sync` so a single reshaper can be shared across
62/// parallel fitness evaluations.
63pub trait ParamReshaper<B: Backend>: Send + Sync {
64    /// The Burn module type this reshaper flattens and reconstructs.
65    type Module: Module<B>;
66
67    /// Total number of trainable float parameters (the flat-vector length).
68    fn num_params(&self) -> usize;
69
70    /// Flatten all float `Param` leaves of `module` into a 1-D tensor.
71    ///
72    /// The returned tensor is moved onto `device` and has length
73    /// [`num_params`](Self::num_params). Leaf visitation order is
74    /// deterministic and matches [`unflatten`](Self::unflatten).
75    ///
76    /// # Panics
77    ///
78    /// Panics if `module` has no float leaves (the underlying tensor
79    /// concatenation requires at least one part).
80    fn flatten(&self, module: &Self::Module, device: &B::Device) -> Tensor<B, 1>;
81
82    /// Clone the template module and replace its float leaves with slices of
83    /// `flat`, in the same order as [`flatten`](Self::flatten).
84    ///
85    /// # Aliasing
86    ///
87    /// The returned module's leaves are **views into `flat`'s storage** (Burn
88    /// `Tensor::clone` is a refcount bump; `slice`/`reshape` are view ops). This
89    /// is allocation-free and safe for the forward-only scoring this bridge
90    /// exists for. Do **not** mutate `flat` or the returned module's weights in
91    /// place while the other is live — they share backing storage. The output
92    /// module inherits `flat`'s device.
93    ///
94    /// # Panics
95    ///
96    /// Panics if `flat.dims()[0] != self.num_params()`.
97    fn unflatten(&self, flat: Tensor<B, 1>) -> Self::Module;
98}
99
100/// A [`ParamReshaper`] backed by a cloned template module.
101///
102/// Construction clones the supplied module once and counts its float leaves.
103/// Each [`unflatten`](ParamReshaper::unflatten) call clones that template and
104/// maps the flat buffer into the clone's leaves; each
105/// [`flatten`](ParamReshaper::flatten) call visits a module's leaves and
106/// concatenates them.
107///
108/// # Single-source width convention
109///
110/// A reshaper *is* the genome-width source of truth. Build **one** reshaper for
111/// the width, then hand `reshaper.clone()` (this type is [`Clone`]) to the
112/// fitness adapter — the strategy and its evaluator then agree on
113/// [`num_params`](ParamReshaper::num_params) *by construction*. Prefer this over
114/// building two `ModuleReshaper::new(template.clone())` instances whose widths
115/// match only by convention: a silent divergence surfaces late as the
116/// documented width-mismatch panic in
117/// [`unflatten`](ParamReshaper::unflatten) / `evaluate_batch`. Where a
118/// [`WeightOnly`](crate::algorithms::neuroevolution::WeightOnly) wrapper already
119/// owns a reshaper, clone *its* via `strategy.reshaper().clone()`.
120///
121/// # Example
122///
123/// See the [`module_eval_fn`](crate::module_eval_fn) tests for a runnable
124/// end-to-end example of `flatten`/`unflatten` in the weight-only pipeline.
125pub struct ModuleReshaper<B: Backend, M: Module<B>> {
126    template: M,
127    num_params: usize,
128    // `fn() -> B` keeps the marker `Send + Sync` for any `B` and encodes that
129    // `B` is produced, never consumed — mirroring the crate's other markers.
130    _backend: PhantomData<fn() -> B>,
131}
132
133impl<B: Backend, M: Module<B>> std::fmt::Debug for ModuleReshaper<B, M> {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        f.debug_struct("ModuleReshaper")
136            .field("num_params", &self.num_params)
137            .finish_non_exhaustive()
138    }
139}
140
141// Hand-written, not `#[derive(Clone)]`: the derive would emit a spurious
142// `where B: Clone` bound, but `B: Backend` does not imply `B: Clone` and the
143// only `B` this struct stores is `PhantomData<fn() -> B>` (which is `Clone` for
144// any `B`). Bound instead on what is actually cloned — `template`, via `M`'s
145// `Module: Clone` supertrait — and copy the `num_params` scalar. This is the
146// enabling half of the single-source pattern documented on `ModuleReshaper`.
147impl<B: Backend, M: Module<B>> Clone for ModuleReshaper<B, M> {
148    fn clone(&self) -> Self {
149        Self {
150            template: self.template.clone(),
151            num_params: self.num_params,
152            _backend: PhantomData,
153        }
154    }
155}
156
157impl<B: Backend, M: Module<B>> ModuleReshaper<B, M> {
158    /// Build a reshaper from a template module.
159    ///
160    /// The template is cloned and retained; its float-leaf count is computed
161    /// once and cached as [`num_params`](ParamReshaper::num_params).
162    #[must_use]
163    pub fn new(template: M) -> Self {
164        let mut counter = CountVisitor { count: 0 };
165        template.visit(&mut counter);
166        Self {
167            template,
168            num_params: counter.count,
169            _backend: PhantomData,
170        }
171    }
172
173    /// Borrow the retained template module.
174    #[must_use]
175    pub fn template(&self) -> &M {
176        &self.template
177    }
178
179    /// Number of float parameters (flat-vector length).
180    ///
181    /// Inherent mirror of [`ParamReshaper::num_params`] so callers can read the
182    /// width without the `M: Sync` bound the trait requires.
183    #[must_use]
184    pub fn num_params(&self) -> usize {
185        self.num_params
186    }
187}
188
189impl<B, M> ParamReshaper<B> for ModuleReshaper<B, M>
190where
191    B: Backend,
192    // `Sync` is required by the `ParamReshaper` supertrait so the reshaper can
193    // be shared across parallel evaluations; Burn modules built from
194    // `Param<Tensor>` leaves satisfy it.
195    M: Module<B> + Sync,
196{
197    type Module = M;
198
199    fn num_params(&self) -> usize {
200        self.num_params
201    }
202
203    fn flatten(&self, module: &M, device: &B::Device) -> Tensor<B, 1> {
204        let mut visitor: FlattenVisitor<B> = FlattenVisitor { parts: Vec::new() };
205        module.visit(&mut visitor);
206        assert!(
207            !visitor.parts.is_empty(),
208            "module has no float parameters to flatten"
209        );
210        Tensor::cat(visitor.parts, 0).to_device(device)
211    }
212
213    fn unflatten(&self, flat: Tensor<B, 1>) -> M {
214        let len = flat.dims()[0];
215        assert_eq!(
216            len, self.num_params,
217            "flat length {len} does not match num_params {}",
218            self.num_params
219        );
220        let mut mapper: SlicingMapper<B> = SlicingMapper { flat, cursor: 0 };
221        self.template.clone().map(&mut mapper)
222    }
223}
224
225/// Counts the total number of float-leaf elements in a module.
226struct CountVisitor {
227    count: usize,
228}
229
230impl<B: Backend> ModuleVisitor<B> for CountVisitor {
231    fn visit_float<const D: usize>(&mut self, param: &Param<Tensor<B, D>>) {
232        self.count += param.dims().iter().product::<usize>();
233    }
234}
235
236/// Collects each float leaf, reshaped to 1-D, in visitation order.
237struct FlattenVisitor<B: Backend> {
238    parts: Vec<Tensor<B, 1>>,
239}
240
241impl<B: Backend> ModuleVisitor<B> for FlattenVisitor<B> {
242    fn visit_float<const D: usize>(&mut self, param: &Param<Tensor<B, D>>) {
243        let value: Tensor<B, D> = param.val();
244        let n: usize = value.dims().iter().product();
245        self.parts.push(value.reshape([n]));
246    }
247}
248
249/// Replaces each float leaf with the next `n` elements of `flat`, reshaped to
250/// the leaf's original shape, advancing a cursor in visitation order.
251struct SlicingMapper<B: Backend> {
252    flat: Tensor<B, 1>,
253    cursor: usize,
254}
255
256impl<B: Backend> ModuleMapper<B> for SlicingMapper<B> {
257    fn map_float<const D: usize>(&mut self, param: Param<Tensor<B, D>>) -> Param<Tensor<B, D>> {
258        let dims: [usize; D] = param.dims();
259        let n: usize = dims.iter().product();
260        let start = self.cursor;
261        self.cursor += n;
262        let flat = self.flat.clone();
263        // `Param::map` preserves the parameter id and any load/save mapper while
264        // swapping the inner tensor; the new tensor does not require grad
265        // (gradient isolation — see module docs).
266        param.map(move |_old| {
267            #[allow(clippy::single_range_in_vec_init)]
268            let slice = flat.slice([start..start + n]);
269            slice.reshape(dims)
270        })
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use burn::backend::Flex;
278    use burn::nn::{
279        BatchNorm, BatchNormConfig, Linear, LinearConfig, Relu,
280        conv::{Conv2d, Conv2dConfig},
281    };
282    use burn::tensor::TensorData;
283
284    type TestBackend = Flex;
285
286    /// 2-layer MLP: `Linear(3 -> 4) -> ReLU -> Linear(4 -> 2)`.
287    ///
288    /// Float-leaf count: `3*4 + 4` (l1 weight + bias) `+ 4*2 + 2`
289    /// (l2 weight + bias) = `26`.
290    #[derive(Module, Debug)]
291    struct TestMlp<B: Backend> {
292        l1: Linear<B>,
293        act: Relu,
294        l2: Linear<B>,
295    }
296
297    impl<B: Backend> TestMlp<B> {
298        fn new(device: &B::Device) -> Self {
299            Self {
300                l1: LinearConfig::new(3, 4).init(device),
301                act: Relu::new(),
302                l2: LinearConfig::new(4, 2).init(device),
303            }
304        }
305    }
306
307    fn approx_eq(a: &Tensor<TestBackend, 1>, b: &Tensor<TestBackend, 1>) {
308        let av = a
309            .to_data()
310            .into_vec::<f32>()
311            .expect("genome host-read of a tensor this test just built");
312        let bv = b
313            .to_data()
314            .into_vec::<f32>()
315            .expect("genome host-read of a tensor this test just built");
316        assert_eq!(av.len(), bv.len(), "length mismatch");
317        for (x, y) in av.iter().zip(bv.iter()) {
318            approx::assert_relative_eq!(x, y, epsilon = 1e-6);
319        }
320    }
321
322    #[test]
323    fn test_module_reshaper_num_params_matches_expected() {
324        let device = Default::default();
325        let mlp = TestMlp::<TestBackend>::new(&device);
326        let reshaper = ModuleReshaper::new(mlp);
327        assert_eq!(reshaper.num_params(), 26);
328    }
329
330    /// `flatten` panics when the module has no float leaves — locks the
331    /// documented `# Panics` contract. `Relu` is a `Module` with zero
332    /// parameters, so its visitor collects no parts.
333    #[test]
334    #[should_panic(expected = "module has no float parameters to flatten")]
335    fn test_module_reshaper_flatten_panics_on_empty_module() {
336        let device = Default::default();
337        let reshaper = ModuleReshaper::<TestBackend, Relu>::new(Relu::new());
338        let _ = reshaper.flatten(&Relu::new(), &device);
339    }
340
341    /// `unflatten` panics when the flat length differs from `num_params` —
342    /// locks the documented `# Panics` contract.
343    #[test]
344    #[should_panic(expected = "flat length")]
345    fn test_module_reshaper_unflatten_panics_on_length_mismatch() {
346        let device = Default::default();
347        let reshaper = ModuleReshaper::new(TestMlp::<TestBackend>::new(&device));
348        let wrong =
349            Tensor::<TestBackend, 1>::from_data(TensorData::new(vec![0f32; 10], [10]), &device);
350        let _ = reshaper.unflatten(wrong);
351    }
352
353    /// AC #2: `unflatten(flatten(m)) ≈ m`. We compare via re-flatten, which is
354    /// element-wise injective over the deterministic leaf order, so equality of
355    /// the flat vectors is equivalent to equality of the modules' float leaves.
356    #[test]
357    fn test_module_reshaper_round_trip_mlp() {
358        let device = Default::default();
359        let mlp = TestMlp::<TestBackend>::new(&device);
360        let reshaper = ModuleReshaper::new(mlp.clone());
361
362        let flat = reshaper.flatten(&mlp, &device);
363        assert_eq!(flat.dims(), [26]);
364
365        let restored = reshaper.unflatten(flat.clone());
366        let flat2 = reshaper.flatten(&restored, &device);
367        approx_eq(&flat, &flat2);
368    }
369
370    /// Property test catching leaf-ordering bugs: a known flat buffer survives
371    /// `unflatten -> flatten` unchanged.
372    #[test]
373    fn test_module_reshaper_round_trip_arbitrary_flat() {
374        let device = Default::default();
375        let mlp = TestMlp::<TestBackend>::new(&device);
376        let reshaper = ModuleReshaper::new(mlp);
377
378        #[allow(clippy::cast_precision_loss)]
379        let values: Vec<f32> = (0..26).map(|i| i as f32 * 0.1 - 1.3).collect();
380        let flat = Tensor::<TestBackend, 1>::from_data(TensorData::new(values, [26]), &device);
381
382        let module = reshaper.unflatten(flat.clone());
383        let flat2 = reshaper.flatten(&module, &device);
384        approx_eq(&flat, &flat2);
385    }
386
387    /// Confirms whether Burn's traversal touches non-trainable `BatchNorm`
388    /// running statistics. Empirically yes — `RunningState` is a `Module` and
389    /// forwards to `visit_float` / `map_float`. A `BatchNorm` over `d` features
390    /// therefore exposes `4*d` float leaves: `gamma`, `beta`, `running_mean`,
391    /// `running_var`.
392    #[test]
393    fn test_module_reshaper_batchnorm_running_stats_traversed() {
394        let device = Default::default();
395        let d = 5;
396        let bn: BatchNorm<TestBackend> = BatchNormConfig::new(d).init(&device);
397        let reshaper = ModuleReshaper::new(bn.clone());
398        // 4 * d if running stats are traversed; 2 * d if only gamma/beta are.
399        assert_eq!(
400            reshaper.num_params(),
401            4 * d,
402            "expected BatchNorm running stats to be traversed as float leaves"
403        );
404        // And the round-trip must still hold over all traversed leaves.
405        let flat = reshaper.flatten(&bn, &device);
406        let restored = reshaper.unflatten(flat.clone());
407        approx_eq(&flat, &reshaper.flatten(&restored, &device));
408    }
409
410    /// A non-trivial module with a conv layer also round-trips, confirming the
411    /// reshaper is not MLP-specific.
412    #[test]
413    fn test_module_reshaper_round_trip_conv() {
414        let device = Default::default();
415        let conv: Conv2d<TestBackend> = Conv2dConfig::new([2, 3], [3, 3]).init(&device);
416        let reshaper = ModuleReshaper::new(conv.clone());
417        let flat = reshaper.flatten(&conv, &device);
418        let restored = reshaper.unflatten(flat.clone());
419        approx_eq(&flat, &reshaper.flatten(&restored, &device));
420    }
421
422    // --- Bounded-NAS enum-derive probe ---------------------------------------
423    //
424    // Question: does Burn 0.21 `#[derive(Module)]` work on a Rust *enum* whose
425    // arms hold heterogeneous concrete `Module` variants? The bounded-NAS
426    // design (closure-erased `VariantEvaluator` registry) does NOT depend on
427    // the answer; this probe records the finding (it compiles) for reference.
428    //
429    // If `#[derive(Module)]` below fails to compile, the whole crate fails to
430    // build and this probe never runs — a build failure IS the negative result.
431
432    /// Minimal one-hidden-layer MLP variant for the enum-derive probe.
433    #[derive(Module, Debug)]
434    struct TestSmallMlp<B: Backend> {
435        l1: Linear<B>,
436        l2: Linear<B>,
437    }
438
439    impl<B: Backend> TestSmallMlp<B> {
440        fn new(device: &B::Device) -> Self {
441            Self {
442                l1: LinearConfig::new(2, 4).init(device),
443                l2: LinearConfig::new(4, 1).init(device),
444            }
445        }
446    }
447
448    /// Minimal two-hidden-layer MLP variant for the enum-derive probe.
449    #[derive(Module, Debug)]
450    struct TestLargeMlp<B: Backend> {
451        l1: Linear<B>,
452        l2: Linear<B>,
453        l3: Linear<B>,
454    }
455
456    impl<B: Backend> TestLargeMlp<B> {
457        fn new(device: &B::Device) -> Self {
458            Self {
459                l1: LinearConfig::new(2, 8).init(device),
460                l2: LinearConfig::new(8, 4).init(device),
461                l3: LinearConfig::new(4, 1).init(device),
462            }
463        }
464    }
465
466    /// Two-arm enum with heterogeneous `Module` variants. The backend generic
467    /// must be named literally `B` for `#[derive(Module)]` to succeed.
468    // This is a derive-capability probe, not a runtime data structure — the
469    // size disparity between arms is irrelevant here.
470    #[allow(clippy::large_enum_variant)]
471    #[derive(Module, Debug)]
472    enum TestArch<B: Backend> {
473        Shallow(TestSmallMlp<B>),
474        Deep(TestLargeMlp<B>),
475    }
476
477    /// Probe: confirm `#[derive(Module)]` on a heterogeneous-arm enum compiles
478    /// and that the enum can be visited as a `Module` (flattened) — i.e. the
479    /// derive emits real `visit`/`map` traversals, not just a stub.
480    #[test]
481    fn test_module_reshaper_enum_derive_compiles() {
482        let device = Default::default();
483
484        let shallow = TestArch::<TestBackend>::Shallow(TestSmallMlp::new(&device));
485        let deep = TestArch::<TestBackend>::Deep(TestLargeMlp::new(&device));
486
487        // TestSmallMlp: 2*4 + 4 + 4*1 + 1 = 17 ; TestLargeMlp: 2*8+8 + 8*4+4 + 4*1+1 = 65.
488        let shallow_reshaper = ModuleReshaper::new(shallow);
489        let deep_reshaper = ModuleReshaper::new(deep);
490
491        println!(
492            "burn_enum_derive_probe: #[derive(Module)] on enum COMPILES; \
493             enum is a Module. Shallow arm flattens to {} params, \
494             Deep arm flattens to {} params.",
495            shallow_reshaper.num_params(),
496            deep_reshaper.num_params(),
497        );
498
499        // The enum derive visits the active arm's leaves.
500        assert_eq!(shallow_reshaper.num_params(), 17);
501        assert_eq!(deep_reshaper.num_params(), 65);
502    }
503}