Skip to main content

rlevo_evolution/
module_eval_fn.rs

1//! [`ModuleEvalFn`] — fitness adapter that scores a population of flat
2//! parameter rows by reconstructing one Burn module per row.
3//!
4//! This is the *only* place flatten/unflatten happens in the weight-only
5//! pipeline. The [`WeightOnly`](crate::algorithms::neuroevolution::WeightOnly)
6//! strategy keeps the genome as a flat `Tensor<B, 2>` end-to-end and never
7//! reshapes; reshaping is confined to this evaluation boundary so the inner
8//! strategy's selection/crossover/mutation operate on plain tensors.
9//!
10//! Evaluation is loop-over-N: Burn 0.21 has no `vmap`/batched-forward
11//! primitive, so each population row is unflattened into a module and scored
12//! individually. A batched forward path is a future addition.
13
14use std::marker::PhantomData;
15
16use burn::tensor::{Tensor, TensorData, backend::Backend};
17
18use crate::fitness::BatchFitnessFn;
19use crate::param_reshaper::ParamReshaper;
20use rlevo_core::objective::ObjectiveSense;
21
22/// Bridges a flat population tensor to per-member module scoring.
23///
24/// Implements [`BatchFitnessFn<B, Tensor<B, 2>>`]: each row of the
25/// `(pop_size, num_params)` population is unflattened into a module via the
26/// [`ParamReshaper`], passed to a host-side `scorer`, and the resulting scalar
27/// is collected into the fitness tensor in the scorer's **natural** value
28/// space. Direction is declared once via [`ObjectiveSense`] (default
29/// [`ObjectiveSense::Maximize`]; pass [`with_sense`](ModuleEvalFn::with_sense)
30/// for a cost scorer like MSE) and reconciled by the harness — no hand-negation
31/// in the scorer.
32///
33/// # Gradient isolation
34///
35/// `B: Backend`, not `AutodiffBackend`. The reconstructed modules carry no
36/// gradient tracking — `scorer` performs forward-only work (loss, rollout
37/// return, …).
38///
39/// # Type Parameters
40///
41/// - `R`: a [`ParamReshaper`] producing `R::Module`.
42/// - `F`: a host-side `Fn(&R::Module) -> f32` scorer (MSE, accuracy, negative
43///   episode return, …).
44///
45/// # Device convention
46///
47/// Population rows are sliced on the supplied `device`; the reshaper splats
48/// them into modules on that same device. Callers must construct the
49/// population and the reshaper's template on one device.
50pub struct ModuleEvalFn<B: Backend, R: ParamReshaper<B>, F> {
51    reshaper: R,
52    scorer: F,
53    sense: ObjectiveSense,
54    _backend: PhantomData<fn() -> B>,
55}
56
57impl<B: Backend, R: ParamReshaper<B>, F> std::fmt::Debug for ModuleEvalFn<B, R, F> {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        f.debug_struct("ModuleEvalFn").finish_non_exhaustive()
60    }
61}
62
63impl<B, R, F> ModuleEvalFn<B, R, F>
64where
65    B: Backend,
66    R: ParamReshaper<B>,
67    F: Fn(&R::Module) -> f32 + Send,
68{
69    /// Build an evaluator from a reshaper and a per-module scorer, defaulting
70    /// the objective sense to [`ObjectiveSense::Maximize`] (accuracy, reward,
71    /// episode return). Use [`with_sense`](Self::with_sense) for a cost scorer
72    /// such as MSE.
73    pub fn new(reshaper: R, scorer: F) -> Self {
74        Self::with_sense(reshaper, scorer, ObjectiveSense::Maximize)
75    }
76
77    /// Build an evaluator with an explicit [`ObjectiveSense`].
78    pub fn with_sense(reshaper: R, scorer: F, sense: ObjectiveSense) -> Self {
79        Self {
80            reshaper,
81            scorer,
82            sense,
83            _backend: PhantomData,
84        }
85    }
86
87    /// Borrow the underlying reshaper.
88    #[must_use]
89    pub fn reshaper(&self) -> &R {
90        &self.reshaper
91    }
92}
93
94impl<B, R, F> BatchFitnessFn<B, Tensor<B, 2>> for ModuleEvalFn<B, R, F>
95where
96    B: Backend,
97    R: ParamReshaper<B>,
98    F: Fn(&R::Module) -> f32 + Send,
99{
100    /// Score every row of `population` by reconstructing one module per row.
101    ///
102    /// # Panics
103    ///
104    /// Panics at batch entry if the population width (`population.dims()[1]`)
105    /// differs from the reshaper's [`num_params`](ParamReshaper::num_params),
106    /// failing fast before any per-row work rather than mid-loop inside
107    /// [`ParamReshaper::unflatten`].
108    fn evaluate_batch(&mut self, population: &Tensor<B, 2>, device: &B::Device) -> Tensor<B, 1> {
109        let [pop_size, num_params] = population.dims();
110        assert_eq!(
111            num_params,
112            self.reshaper.num_params(),
113            "population genome width must equal reshaper num_params"
114        );
115        let mut fitness: Vec<f32> = Vec::with_capacity(pop_size);
116        for row in 0..pop_size {
117            #[allow(clippy::single_range_in_vec_init)]
118            let genome: Tensor<B, 1> = population
119                .clone()
120                .slice([row..row + 1])
121                .reshape([num_params]);
122            let module = self.reshaper.unflatten(genome);
123            fitness.push((self.scorer)(&module));
124        }
125        Tensor::<B, 1>::from_data(TensorData::new(fitness, [pop_size]), device)
126    }
127
128    fn sense(&self) -> ObjectiveSense {
129        self.sense
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use crate::param_reshaper::ModuleReshaper;
137    use burn::backend::Flex;
138    use burn::module::Module;
139    use burn::nn::{Linear, LinearConfig};
140
141    type TestBackend = Flex;
142
143    /// Single linear layer `2 -> 1`: 2 weights + 1 bias = 3 float leaves.
144    #[derive(Module, Debug)]
145    struct TestTiny<B: Backend> {
146        l: Linear<B>,
147    }
148
149    impl<B: Backend> TestTiny<B> {
150        fn new(device: &B::Device) -> Self {
151            Self {
152                l: LinearConfig::new(2, 1).init(device),
153            }
154        }
155
156        fn forward(&self, x: Tensor<B, 2>) -> Tensor<B, 2> {
157            self.l.forward(x)
158        }
159    }
160
161    #[test]
162    fn test_module_eval_fn_evaluate_batch_preserves_shape_and_order() {
163        let device = Default::default();
164        let reshaper = ModuleReshaper::new(TestTiny::<TestBackend>::new(&device));
165        let num_params = reshaper.num_params();
166        assert_eq!(num_params, 3);
167
168        // Scorer: forward a fixed input [1, 1] through the reconstructed net and
169        // return the scalar output — deterministic given the genome.
170        let dev = device;
171        let mut eval = ModuleEvalFn::new(reshaper, move |m: &TestTiny<TestBackend>| {
172            let x = Tensor::<TestBackend, 2>::from_data(
173                TensorData::new(vec![1.0f32, 1.0], [1, 2]),
174                &dev,
175            );
176            let y = m.forward(x);
177            y.into_data()
178                .into_vec::<f32>()
179                .expect("output host-read of a tensor this test just built")[0]
180        });
181
182        // Row 0: weights [1, 0], bias [0] -> output = 1*1 + 0*1 + 0 = 1.
183        // Row 1: weights [0, 0], bias [5] -> output = 5.
184        let pop = Tensor::<TestBackend, 2>::from_data(
185            TensorData::new(vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 5.0], [2, 3]),
186            &device,
187        );
188        let fitness = eval.evaluate_batch(&pop, &device);
189        let values = fitness
190            .into_data()
191            .into_vec::<f32>()
192            .expect("fitness host-read of a tensor this test just built");
193        assert_eq!(values.len(), 2);
194        approx::assert_relative_eq!(values[0], 1.0, epsilon = 1e-6);
195        approx::assert_relative_eq!(values[1], 5.0, epsilon = 1e-6);
196    }
197}