rlevo_evolution/shaping.rs
1//! Fitness shaping transforms.
2//!
3//! Strategies occasionally benefit from monotone transforms of raw
4//! fitness values: centered-rank mapping flattens outliers, z-scoring
5//! normalizes scale across generations, and weight decay penalizes
6//! large-norm genomes (common in neuroevolution). These helpers work
7//! directly on device tensors and are pure functions (RNG-free).
8
9use burn::prelude::ElementConversion;
10use burn::tensor::{Tensor, backend::Backend};
11
12/// Error returned by fallible fitness-shaping transforms.
13#[derive(Debug, thiserror::Error)]
14pub enum ShapingError {
15 /// The input tensor's element data could not be read as `f32` — e.g. an
16 /// integer-typed backend tensor was passed to a transform that ranks host
17 /// values. This is a backend/dtype mismatch, surfaced as a recoverable error
18 /// rather than a panic.
19 #[error("shaping transform requires f32 tensor data")]
20 NonFloatData,
21}
22
23/// Returns `fitness - fitness.mean()` divided by the (population) std-dev,
24/// clamped to avoid divide-by-zero when all fitnesses are equal.
25///
26/// The std-dev floor is `1e-8`; degenerate populations (all-equal fitness)
27/// therefore map to a vector of zeros rather than producing NaNs.
28///
29/// # Examples
30///
31/// ```
32/// use burn::backend::Flex;
33/// use burn::tensor::Tensor;
34/// use rlevo_evolution::shaping::z_score;
35///
36/// let device = Default::default();
37/// // Five fitness values: mean 3.0, all distinct.
38/// let t = Tensor::<Flex, 1>::from_floats([1.0f32, 2.0, 3.0, 4.0, 5.0], &device);
39/// let z = z_score(t);
40/// let values = z.into_data().into_vec::<f32>().expect("shaped tensor must be readable as f32");
41/// // After z-scoring the mean of the output is 0 (within floating-point tolerance).
42/// let mean: f32 = values.iter().sum::<f32>() / values.len() as f32;
43/// assert!(mean.abs() < 1e-5);
44/// ```
45#[must_use]
46pub fn z_score<B: Backend>(fitness: Tensor<B, 1>) -> Tensor<B, 1> {
47 let mean = fitness.clone().mean().into_scalar().elem::<f32>();
48 let n = fitness.dims()[0];
49 #[allow(clippy::cast_precision_loss)]
50 let n_f = n.max(1) as f32;
51 let centered = fitness - mean;
52 let var = centered
53 .clone()
54 .powf_scalar(2.0)
55 .sum()
56 .into_scalar()
57 .elem::<f32>()
58 / n_f;
59 let std = var.sqrt().max(1e-8);
60 centered / std
61}
62
63/// Returns centered ranks: the largest fitness maps to `+0.5`, the
64/// smallest to `-0.5`, with linear spacing in between.
65///
66/// Under the crate's maximise convention (canonical: higher is better)
67/// this assigns the **best** (highest-fitness) individual the highest
68/// utility `+0.5` and the worst the lowest `-0.5`, which is the sign a
69/// gradient-style ES update expects — no negation at the call site.
70///
71/// Centered ranks are standard in modern ES (e.g. OpenAI-ES) because they
72/// remove outlier fitness magnitudes and keep the signal scale-free across
73/// generations. Implemented host-side because the argsort pathway is
74/// easier to reason about; swap in a tensor-native implementation if this
75/// ever shows up on a profile.
76///
77/// An empty input returns an empty tensor.
78///
79/// # Examples
80///
81/// ```
82/// use burn::backend::Flex;
83/// use burn::tensor::Tensor;
84/// use rlevo_evolution::shaping::centered_rank;
85///
86/// let device = Default::default();
87/// let t = Tensor::<Flex, 1>::from_floats([10.0f32, 20.0, 30.0, 40.0], &device);
88/// let r = centered_rank(t, &device).unwrap();
89/// let values = r.into_data().into_vec::<f32>().expect("shaped tensor must be readable as f32");
90/// // Smallest value maps to -0.5, largest to +0.5.
91/// assert!((values[0] - (-0.5)).abs() < 1e-6);
92/// assert!((values[3] - 0.5).abs() < 1e-6);
93/// ```
94///
95/// # Errors
96///
97/// Returns [`ShapingError::NonFloatData`] if the tensor's element data cannot be
98/// read as `f32` — for example, when using a backend that stores integer-typed
99/// tensors and `into_vec::<f32>()` fails.
100pub fn centered_rank<B: Backend>(
101 fitness: Tensor<B, 1>,
102 device: &<B as burn::tensor::backend::BackendTypes>::Device,
103) -> Result<Tensor<B, 1>, ShapingError> {
104 let raw = fitness
105 .into_data()
106 .into_vec::<f32>()
107 .map_err(|_| ShapingError::NonFloatData)?;
108 let n = raw.len();
109 if n == 0 {
110 return Ok(Tensor::<B, 1>::from_floats([0.0f32; 0], device));
111 }
112 // Sanitize NaN → −inf (worst under maximise) so a NaN fitness ranks lowest
113 // rather than corrupting the ascending order.
114 let data: Vec<f32> = raw
115 .iter()
116 .map(|&f| crate::fitness::sanitize_fitness(f))
117 .collect();
118 let mut indices: Vec<usize> = (0..n).collect();
119 indices.sort_by(|&i, &j| data[i].total_cmp(&data[j]));
120
121 #[allow(clippy::cast_precision_loss)]
122 let n_f = (n - 1).max(1) as f32;
123 let mut ranks = vec![0.0_f32; n];
124 for (rank, &idx) in indices.iter().enumerate() {
125 #[allow(clippy::cast_precision_loss)]
126 let r = rank as f32 / n_f - 0.5;
127 ranks[idx] = r;
128 }
129 Ok(Tensor::<B, 1>::from_floats(ranks.as_slice(), device))
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135 use burn::backend::Flex;
136 type TestBackend = Flex;
137
138 #[test]
139 #[allow(clippy::cast_precision_loss)]
140 fn z_score_zero_mean_unit_std() {
141 let device = Default::default();
142 let t = Tensor::<TestBackend, 1>::from_floats([1.0f32, 2.0, 3.0, 4.0, 5.0], &device);
143 let z = z_score(t);
144 let values = z
145 .into_data()
146 .into_vec::<f32>()
147 .expect("shaped tensor host-read of a tensor this test just built");
148 let mean: f32 = values.iter().sum::<f32>() / values.len() as f32;
149 approx::assert_relative_eq!(mean, 0.0, epsilon = 1e-5);
150 }
151
152 #[test]
153 fn centered_rank_spans_half_interval() {
154 let device = Default::default();
155 let t = Tensor::<TestBackend, 1>::from_floats([10.0f32, 20.0, 30.0, 40.0], &device);
156 let r = centered_rank(t, &device).unwrap();
157 let values = r
158 .into_data()
159 .into_vec::<f32>()
160 .expect("shaped tensor host-read of a tensor this test just built");
161 // smallest → -0.5, largest → +0.5
162 approx::assert_relative_eq!(values[0], -0.5, epsilon = 1e-6);
163 approx::assert_relative_eq!(values[3], 0.5, epsilon = 1e-6);
164 }
165
166 #[test]
167 fn centered_rank_preserves_order() {
168 let device = Default::default();
169 let t = Tensor::<TestBackend, 1>::from_floats([3.0f32, 1.0, 2.0], &device);
170 let r = centered_rank(t, &device).unwrap();
171 let values = r
172 .into_data()
173 .into_vec::<f32>()
174 .expect("shaped tensor host-read of a tensor this test just built");
175 // original: 3, 1, 2 → ranks sorted ascending: [1, 2, 3] at indices [1, 2, 0]
176 // rank-positions centered: index 1 → -0.5, index 2 → 0.0, index 0 → 0.5
177 approx::assert_relative_eq!(values[1], -0.5, epsilon = 1e-6);
178 approx::assert_relative_eq!(values[2], 0.0, epsilon = 1e-6);
179 approx::assert_relative_eq!(values[0], 0.5, epsilon = 1e-6);
180 }
181
182 #[test]
183 fn centered_rank_empty_is_ok() {
184 let device = Default::default();
185 let t = Tensor::<TestBackend, 1>::from_floats([0.0f32; 0], &device);
186 let r = centered_rank(t, &device).expect("empty input is not an error");
187 assert_eq!(r.dims()[0], 0);
188 }
189}