rlevo_evolution/ops/selection.rs
1//! Parent-selection operators.
2//!
3//! Selection operators turn a fitness slice into winner indices that the
4//! caller uses (via `Tensor::select`) to gather parents out of the
5//! population. The baseline is host-side sampling + a single device
6//! gather; a fused [kernel](super::kernels) variant is tracked as
7//! follow-up work.
8//!
9//! All operators that draw random numbers accept an explicit `&mut dyn Rng`
10//! (the host-RNG convention). Operators never touch thread-local or
11//! process-wide backend RNG state (`B::seed` / `Tensor::random`), which
12//! would race with sibling tests under the parallel test runner.
13//!
14//! # Fitness convention
15//!
16//! Fitness values are interpreted as "lower is better" (cost).
17//! Tournament selection retains the *smallest* fitness seen across all
18//! candidates in a single draw; truncation selection returns the `top_k`
19//! entries with the smallest fitnesses.
20
21use burn::tensor::{backend::Backend, Int, Tensor, TensorData};
22use rand::{Rng, RngExt};
23
24/// K-ary tournament selection over a fitness slice.
25///
26/// Draws `tournament_size` candidate indices uniformly at random (with
27/// replacement) and retains the one with the smallest fitness. Repeats
28/// the draw `n_winners` times and returns the winning index for each
29/// draw. Randomness is drawn entirely from the caller-supplied `rng`;
30/// no global or backend RNG state is touched.
31///
32/// `fitness` is a flat slice where index `i` is the cost of population
33/// member `i` (lower is better). `tournament_size` controls selection
34/// pressure: larger values yield stronger pressure toward lower-cost
35/// members.
36///
37/// # Examples
38///
39/// ```
40/// use rand::SeedableRng;
41/// use rand::rngs::StdRng;
42/// use rlevo_evolution::ops::selection::tournament_indices_host;
43///
44/// let fitness = [10.0_f32, 1.0, 10.0, 10.0];
45/// let mut rng = StdRng::seed_from_u64(0);
46/// let winners = tournament_indices_host(&fitness, 2, 100, &mut rng);
47/// // The unique best member (index 1) is selected far more often than any
48/// // single higher-cost member.
49/// let best_wins = winners.iter().filter(|&&w| w == 1).count();
50/// let high_cost_wins = winners.iter().filter(|&&w| w == 0).count();
51/// assert!(best_wins > high_cost_wins);
52/// ```
53///
54/// # Panics
55///
56/// Panics if `fitness.is_empty()` or if `tournament_size < 2`.
57#[must_use]
58pub fn tournament_indices_host(
59 fitness: &[f32],
60 tournament_size: usize,
61 n_winners: usize,
62 rng: &mut dyn Rng,
63) -> Vec<i32> {
64 assert!(!fitness.is_empty(), "fitness must be non-empty");
65 assert!(tournament_size >= 2, "tournament size must be >= 2");
66 let pop_size = fitness.len();
67 let mut winners = Vec::with_capacity(n_winners);
68 for _ in 0..n_winners {
69 let mut best_idx = rng.random_range(0..pop_size);
70 let mut best_f = fitness[best_idx];
71 for _ in 1..tournament_size {
72 let idx = rng.random_range(0..pop_size);
73 if fitness[idx] < best_f {
74 best_f = fitness[idx];
75 best_idx = idx;
76 }
77 }
78 #[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
79 winners.push(best_idx as i32);
80 }
81 winners
82}
83
84/// Gathers `n_winners` rows out of a population tensor by tournament.
85///
86/// Convenience wrapper that runs [`tournament_indices_host`] on the
87/// host and then performs a single `Tensor::select` gather on the
88/// device. The returned tensor has shape `(n_winners, genome_dim)`.
89///
90/// `population` must have shape `(N, D)` where `N` matches
91/// `fitness.len()`. `tournament_size` and `n_winners` are forwarded
92/// directly to [`tournament_indices_host`].
93///
94/// # Panics
95///
96/// Inherits the panic conditions of [`tournament_indices_host`]:
97/// `fitness.is_empty()` or `tournament_size < 2`.
98#[must_use]
99pub fn tournament_select<B: Backend>(
100 population: &Tensor<B, 2>,
101 fitness: &[f32],
102 tournament_size: usize,
103 n_winners: usize,
104 rng: &mut dyn Rng,
105 device: &<B as burn::tensor::backend::BackendTypes>::Device,
106) -> Tensor<B, 2> {
107 let winners = tournament_indices_host(fitness, tournament_size, n_winners, rng);
108 let indices = Tensor::<B, 1, Int>::from_data(TensorData::new(winners, [n_winners]), device);
109 population.clone().select(0, indices)
110}
111
112/// Returns the indices of the `top_k` lowest-fitness members.
113///
114/// Sorts the population by fitness (ascending, i.e. lowest cost first)
115/// and returns the first `top_k` indices. The returned `Vec` is ordered
116/// from best to worst among the selected members. Ties are broken by
117/// `f32::partial_cmp`, with `NaN` values sorted last.
118///
119/// This is the host-side building block; call [`truncation_select`] when
120/// you need the corresponding population rows as a tensor.
121///
122/// # Examples
123///
124/// ```
125/// use rlevo_evolution::ops::selection::truncation_indices_host;
126///
127/// let fitness = [5.0_f32, 1.0, 3.0, 2.0, 4.0];
128/// let idx = truncation_indices_host(&fitness, 3);
129/// assert_eq!(idx.len(), 3);
130/// // The three cheapest members are at original indices 1 (1.0), 3 (2.0), 2 (3.0).
131/// assert!(idx.contains(&1));
132/// assert!(idx.contains(&3));
133/// assert!(idx.contains(&2));
134/// ```
135///
136/// # Panics
137///
138/// Panics if `top_k > fitness.len()` or `fitness.is_empty()`.
139#[must_use]
140pub fn truncation_indices_host(fitness: &[f32], top_k: usize) -> Vec<i32> {
141 assert!(!fitness.is_empty(), "fitness must be non-empty");
142 assert!(top_k <= fitness.len(), "top_k must be <= population size");
143 let mut indexed: Vec<(usize, f32)> = fitness.iter().copied().enumerate().collect();
144 indexed
145 .sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
146 #[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
147 indexed
148 .into_iter()
149 .take(top_k)
150 .map(|(i, _)| i as i32)
151 .collect()
152}
153
154/// Gathers the `top_k` lowest-fitness rows out of a population tensor.
155///
156/// Convenience wrapper that runs [`truncation_indices_host`] on the
157/// host and then performs a single `Tensor::select` gather on the
158/// device. The returned tensor has shape `(top_k, genome_dim)` with
159/// rows ordered from best to worst fitness.
160///
161/// `population` must have shape `(N, D)` where `N` matches
162/// `fitness.len()`.
163///
164/// # Panics
165///
166/// Inherits the panic conditions of [`truncation_indices_host`]:
167/// `fitness.is_empty()` or `top_k > fitness.len()`.
168#[must_use]
169pub fn truncation_select<B: Backend>(
170 population: &Tensor<B, 2>,
171 fitness: &[f32],
172 top_k: usize,
173 device: &<B as burn::tensor::backend::BackendTypes>::Device,
174) -> Tensor<B, 2> {
175 let winners = truncation_indices_host(fitness, top_k);
176 let indices = Tensor::<B, 1, Int>::from_data(TensorData::new(winners, [top_k]), device);
177 population.clone().select(0, indices)
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183 use burn::backend::Flex;
184 use rand::SeedableRng;
185 use rand::rngs::StdRng;
186
187 type TestBackend = Flex;
188
189 #[test]
190 fn tournament_prefers_better_fitness_in_expectation() {
191 let mut rng = StdRng::seed_from_u64(1);
192 let fitness = [100.0f32, 0.0, 100.0, 100.0];
193 let winners = tournament_indices_host(&fitness, 2, 1000, &mut rng);
194 let wins_for_best = winners.iter().filter(|&&w| w == 1).count();
195 // For pop_size=4 and tournament_size=2, P(best wins) =
196 // 1 − (3/4)² = 7/16 ≈ 0.4375 → ~437 wins per 1000 trials.
197 // Use a generous band to stay stable across RNG versions.
198 assert!(
199 (350..=550).contains(&wins_for_best),
200 "wins_for_best={wins_for_best} (expected ~437)",
201 );
202 }
203
204 #[test]
205 fn truncation_returns_smallest_fitness_first() {
206 let fitness = [5.0f32, 1.0, 3.0, 2.0, 4.0];
207 let idx = truncation_indices_host(&fitness, 3);
208 // The three smallest fitnesses live at indices 1 (=1.0), 3 (=2.0), 2 (=3.0).
209 assert_eq!(idx.len(), 3);
210 assert!(idx.contains(&1));
211 assert!(idx.contains(&3));
212 assert!(idx.contains(&2));
213 }
214
215 #[test]
216 fn tournament_select_returns_shaped_tensor() {
217 let device = Default::default();
218 let data = TensorData::new(vec![0.0f32, 1.0, 2.0, 3.0, 4.0, 5.0], [3, 2]);
219 let pop = Tensor::<TestBackend, 2>::from_data(data, &device);
220 let fitness = [10.0_f32, 0.0, 10.0];
221 let mut rng = StdRng::seed_from_u64(2);
222 let parents = tournament_select(&pop, &fitness, 2, 4, &mut rng, &device);
223 assert_eq!(parents.dims(), [4, 2]);
224 }
225}