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}