Skip to main content

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}