rlevo_evolution/algorithms/eda/univariate_bernoulli.rs
1//! Univariate Bernoulli model (PBIL — Population-Based Incremental Learning)
2//! for binary search spaces.
3//!
4//! Each gene is an independent Bernoulli variable with a learned probability
5//! of being `1`. [`fit`] nudges the probability vector toward the **best**
6//! individual of the selected subset (positive update at rate
7//! [`UnivariateBernoulliParams::learning_rate`]) and away from the **worst**
8//! on genes where best and worst disagree (negative update at rate
9//! [`UnivariateBernoulliParams::negative_learning_rate`]). The classic PBIL
10//! probability-mutation step is **not** implemented. [`sample`] emits a fresh
11//! tensor of raw `{0, 1}` `f32` genes; [`EdaParams::bounds`](crate::algorithms::eda::EdaParams::bounds)
12//! clamps are therefore no-ops.
13//!
14//! The best and worst individuals are the argmax and argmin of the fitness
15//! vector (canonical maximise: higher is better) over the truncation-selected
16//! subset, not over the full population.
17//! This departs slightly from the original Baluja formulation, which drew a
18//! fresh sample for comparison, but is consistent with the upstream selection
19//! already performed by [`EdaStrategy`](crate::algorithms::eda::EdaStrategy).
20//!
21//! # References
22//!
23//! - Baluja (1994), *Population-based incremental learning: a method for
24//! integrating genetic search based function optimization and competitive
25//! learning*.
26//!
27//! [`fit`]: crate::ProbabilityModel::fit
28//! [`sample`]: crate::ProbabilityModel::sample
29
30use burn::tensor::{Tensor, TensorData, backend::Backend};
31use rand::{Rng, RngExt};
32use rlevo_core::config::{self, ConfigError};
33
34use crate::probability_model::ProbabilityModel;
35
36/// Per-run configuration for the [`UnivariateBernoulli`] model.
37///
38/// Held inside [`EdaParams::model`](crate::algorithms::eda::EdaParams::model)
39/// for the lifetime of a run. Use
40/// [`UnivariateBernoulliParams::default_for`] for typical PBIL defaults.
41#[derive(Debug, Clone)]
42pub struct UnivariateBernoulliParams {
43 /// Number of bits per genome; determines the length of
44 /// [`UnivariateBernoulliState::prob`].
45 pub genome_dim: usize,
46 /// Interpolation rate toward the best individual's gene value per
47 /// generation (`0 < learning_rate < 1`; original Baluja uses 0.1).
48 pub learning_rate: f32,
49 /// Additional interpolation rate applied on genes where the best and
50 /// worst individuals disagree. The extra step interpolates toward the
51 /// *best* individual's gene — identical, for binary `{0, 1}` genes, to
52 /// moving away from the worst's value, since the two differ
53 /// (`0 ≤ negative_learning_rate < 1`; original uses 0.075).
54 pub negative_learning_rate: f32,
55}
56
57impl UnivariateBernoulliParams {
58 /// Sensible PBIL defaults for a `genome_dim`-bit problem.
59 #[must_use]
60 pub fn default_for(genome_dim: usize) -> Self {
61 Self {
62 genome_dim,
63 learning_rate: 0.1,
64 negative_learning_rate: 0.075,
65 }
66 }
67}
68
69/// Fitted state for the [`UnivariateBernoulli`] model after one call to
70/// [`ProbabilityModel::fit`].
71///
72/// The vector has length `genome_dim`. On the prior path (`prev = None`) it
73/// is uniformly `0.5`; on subsequent calls the entries are nudged by the PBIL
74/// update rule (see [module docs](self)).
75///
76/// The field is private so an out-of-range probability is unrepresentable
77/// from outside this module; build one with
78/// [`try_new`](UnivariateBernoulliState::try_new) and read it via
79/// [`prob`](UnivariateBernoulliState::prob).
80#[derive(Debug, Clone)]
81pub struct UnivariateBernoulliState {
82 /// Per-gene probability of sampling a `1.0` (always in `[0, 1]`).
83 prob: Vec<f32>,
84}
85
86impl UnivariateBernoulliState {
87 /// Builds a PBIL state from a per-gene probability vector.
88 ///
89 /// # Errors
90 ///
91 /// Returns a [`ConfigError`] if `prob` is empty or if any entry is outside
92 /// the closed interval `[0, 1]` (or is non-finite).
93 pub fn try_new(prob: Vec<f32>) -> Result<Self, ConfigError> {
94 config::nonzero("UnivariateBernoulliState", "prob", prob.len())?;
95 for &p in &prob {
96 config::in_range("UnivariateBernoulliState", "prob", 0.0, 1.0, f64::from(p))?;
97 }
98 Ok(Self { prob })
99 }
100
101 /// Per-gene probabilities of sampling a `1.0`, each in `[0, 1]`.
102 #[must_use]
103 pub fn prob(&self) -> &[f32] {
104 &self.prob
105 }
106}
107
108/// Population-Based Incremental Learning model for binary spaces (PBIL).
109///
110/// Implements [`ProbabilityModel`] with a per-gene Bernoulli probability vector
111/// updated by nudging toward the best (and away from the worst) of the
112/// truncation-selected subset. The classic probability-mutation step is omitted.
113/// Samples are raw `{0, 1}` `f32` values;
114/// [`EdaParams::bounds`](crate::algorithms::eda::EdaParams::bounds) clamps
115/// are no-ops for this model.
116///
117/// See the [module docs](self) for the update rule and references.
118#[derive(Debug, Clone, Copy, Default)]
119pub struct UnivariateBernoulli;
120
121impl<B: Backend> ProbabilityModel<B> for UnivariateBernoulli {
122 type Params = UnivariateBernoulliParams;
123 type State = UnivariateBernoulliState;
124
125 /// Update the per-gene probability vector from the selected population.
126 ///
127 /// When `prev = None` returns the uniform-`0.5` prior; `population` and
128 /// `fitness` are ignored on that path. Otherwise locates the argmax
129 /// (best) and argmin (worst) individuals by fitness, applies the positive
130 /// PBIL interpolation toward the best's genes, and applies the negative
131 /// update on genes where the best and worst disagree. Does **not** apply
132 /// the classic Baluja probability-mutation step.
133 fn fit(
134 &self,
135 params: &Self::Params,
136 prev: Option<&Self::State>,
137 population: Tensor<B, 2>,
138 fitness: Tensor<B, 1>,
139 device: &<B as burn::tensor::backend::BackendTypes>::Device,
140 ) -> Self::State {
141 let _ = device;
142 let Some(prev) = prev else {
143 // Prior path: uniform 0.5 per gene; population/fitness ignored.
144 let _ = (population, fitness);
145 return UnivariateBernoulliState {
146 prob: vec![0.5; params.genome_dim],
147 };
148 };
149
150 let [k, d] = population.dims();
151 if k == 0 {
152 // Empty selected population: the argmax/argmin below would leave
153 // `best_idx`/`worst_idx` at `0` and then index `rows[0 * d + j]` on
154 // an empty `rows`, panicking out of bounds. Return the previous
155 // probabilities unchanged. `EdaStrategy::tell` clamps `k ≥ 2`, but
156 // `fit` is a public trait method reachable directly.
157 return UnivariateBernoulliState {
158 prob: prev.prob.clone(),
159 };
160 }
161 let rows = population
162 .into_data()
163 .into_vec::<f32>()
164 .expect("population tensor must be readable as f32");
165 let fit_host = fitness
166 .into_data()
167 .into_vec::<f32>()
168 .expect("fitness tensor must be readable as f32");
169
170 // Argmax (best) and argmin (worst), ties → lowest index.
171 // Canonical maximise: higher is better.
172 let mut best_idx = 0_usize;
173 let mut worst_idx = 0_usize;
174 let mut best_f = f32::NEG_INFINITY;
175 let mut worst_f = f32::INFINITY;
176 for i in 0..k {
177 // Sanitize `NaN → −inf` at the seam, mirroring `compact_genetic` so
178 // the two binary EDAs stay symmetric. `tell` sanitizes upstream, but
179 // a direct `fit` caller passing a `NaN` fitness would otherwise have
180 // it sort as the largest value under `total_cmp` and be picked as the
181 // best individual.
182 let f = crate::fitness::sanitize_fitness(
183 fit_host.get(i).copied().unwrap_or(f32::NEG_INFINITY),
184 );
185 if f.total_cmp(&best_f) == std::cmp::Ordering::Greater {
186 best_f = f;
187 best_idx = i;
188 }
189 if f.total_cmp(&worst_f) == std::cmp::Ordering::Less {
190 worst_f = f;
191 worst_idx = i;
192 }
193 }
194
195 let lr = params.learning_rate;
196 let neg_lr = params.negative_learning_rate;
197 let mut prob = prev.prob.clone();
198 for j in 0..d {
199 let best_gene = rows[best_idx * d + j];
200 let worst_gene = rows[worst_idx * d + j];
201 // Positive update: interpolate toward the best individual's gene.
202 let mut updated = prob[j] * (1.0 - lr) + lr * best_gene;
203 // Negative update only where best and worst disagree.
204 if (best_gene - worst_gene).abs() > 0.5 {
205 updated = updated * (1.0 - neg_lr) + neg_lr * best_gene;
206 }
207 prob[j] = updated;
208 }
209
210 UnivariateBernoulliState { prob }
211 }
212
213 /// Draw `n` binary genomes from the per-gene Bernoulli probabilities.
214 ///
215 /// Each gene is sampled independently as `1.0` with probability `p[j]`,
216 /// `0.0` otherwise, using the supplied host RNG (never `Tensor::random` /
217 /// `B::seed`). The returned tensor has shape `(n, D)` and contains only
218 /// `0.0` and `1.0` values.
219 fn sample(
220 &self,
221 state: &Self::State,
222 n: usize,
223 rng: &mut dyn Rng,
224 device: &<B as burn::tensor::backend::BackendTypes>::Device,
225 ) -> Tensor<B, 2> {
226 let d = state.prob.len();
227 let mut rows = Vec::with_capacity(n * d);
228 // Row-major: outer individuals, inner dimensions.
229 for _ in 0..n {
230 for &p in &state.prob {
231 let gene = if rng.random::<f32>() < p { 1.0 } else { 0.0 };
232 rows.push(gene);
233 }
234 }
235 Tensor::<B, 2>::from_data(TensorData::new(rows, [n, d]), device)
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use burn::backend::Flex;
243 use rand::SeedableRng;
244 use rand::rngs::StdRng;
245
246 type TestBackend = Flex;
247
248 fn pop(rows: Vec<f32>, n: usize, d: usize) -> Tensor<TestBackend, 2> {
249 let device = Default::default();
250 Tensor::<TestBackend, 2>::from_data(TensorData::new(rows, [n, d]), &device)
251 }
252
253 fn fitness(values: Vec<f32>) -> Tensor<TestBackend, 1> {
254 let device = Default::default();
255 let n = values.len();
256 Tensor::<TestBackend, 1>::from_data(TensorData::new(values, [n]), &device)
257 }
258
259 fn fit_prior(p: &UnivariateBernoulliParams) -> UnivariateBernoulliState {
260 let device = Default::default();
261 <UnivariateBernoulli as ProbabilityModel<TestBackend>>::fit(
262 &UnivariateBernoulli,
263 p,
264 None,
265 pop(vec![], 0, 0),
266 fitness(vec![]),
267 &device,
268 )
269 }
270
271 #[test]
272 fn prior_is_half() {
273 let p = UnivariateBernoulliParams::default_for(4);
274 let state = fit_prior(&p);
275 assert_eq!(state.prob, vec![0.5, 0.5, 0.5, 0.5]);
276 }
277
278 #[test]
279 fn try_new_accepts_valid_and_rejects_out_of_range() {
280 let state = UnivariateBernoulliState::try_new(vec![0.0, 0.5, 1.0]).unwrap();
281 assert_eq!(state.prob(), &[0.0, 0.5, 1.0]);
282 assert!(UnivariateBernoulliState::try_new(vec![]).is_err());
283 assert!(UnivariateBernoulliState::try_new(vec![1.2]).is_err());
284 assert!(UnivariateBernoulliState::try_new(vec![-0.5]).is_err());
285 assert!(UnivariateBernoulliState::try_new(vec![f32::NAN]).is_err());
286 }
287
288 #[test]
289 fn interpolation_not_overwrite() {
290 let device = Default::default();
291 let p = UnivariateBernoulliParams {
292 genome_dim: 1,
293 learning_rate: 0.1,
294 negative_learning_rate: 0.0,
295 };
296 let prior = fit_prior(&p);
297 // best = row 0 (gene 1), worst = row 1 (gene 0). neg_lr = 0 so only the
298 // positive update fires: p = 0.5*0.9 + 0.1*1 = 0.55, strictly in (0.5, 1).
299 let state = <UnivariateBernoulli as ProbabilityModel<TestBackend>>::fit(
300 &UnivariateBernoulli,
301 &p,
302 Some(&prior),
303 pop(vec![1.0, 0.0], 2, 1),
304 fitness(vec![1.0, 0.0]),
305 &device,
306 );
307 assert!(state.prob[0] > 0.5 && state.prob[0] < 1.0);
308 approx::assert_relative_eq!(state.prob[0], 0.55, epsilon = 1e-6);
309 }
310
311 #[test]
312 fn neg_lr_applies_only_to_differing_genes() {
313 let device = Default::default();
314 let p = UnivariateBernoulliParams {
315 genome_dim: 2,
316 learning_rate: 0.1,
317 negative_learning_rate: 0.2,
318 };
319 let prior = fit_prior(&p);
320 // Canonical maximise: row 0 (fitness 1.0) is best, row 1 (0.0) worst.
321 // gene 0: best=1, worst=1 (same) → only positive update.
322 // gene 1: best=1, worst=0 (differ) → positive then negative update.
323 let state = <UnivariateBernoulli as ProbabilityModel<TestBackend>>::fit(
324 &UnivariateBernoulli,
325 &p,
326 Some(&prior),
327 pop(vec![1.0, 1.0, 1.0, 0.0], 2, 2),
328 fitness(vec![1.0, 0.0]),
329 &device,
330 );
331 // gene 0: 0.5*0.9 + 0.1 = 0.55.
332 approx::assert_relative_eq!(state.prob[0], 0.55, epsilon = 1e-6);
333 // gene 1: 0.55 then 0.55*0.8 + 0.2 = 0.64.
334 approx::assert_relative_eq!(state.prob[1], 0.64, epsilon = 1e-6);
335 }
336
337 #[test]
338 fn convergence_direction_toward_zeros() {
339 let device = Default::default();
340 let p = UnivariateBernoulliParams::default_for(1);
341 let mut state = fit_prior(&p);
342 // Best individual (highest fitness) is all-zeros; repeated fits must
343 // drive p down.
344 for _ in 0..50 {
345 state = <UnivariateBernoulli as ProbabilityModel<TestBackend>>::fit(
346 &UnivariateBernoulli,
347 &p,
348 Some(&state),
349 pop(vec![0.0, 1.0], 2, 1),
350 fitness(vec![1.0, 0.0]),
351 &device,
352 );
353 }
354 assert!(
355 state.prob[0] < 0.1,
356 "p did not converge toward 0, got {}",
357 state.prob[0]
358 );
359 }
360
361 #[test]
362 fn samples_are_binary() {
363 let device = Default::default();
364 let state = UnivariateBernoulliState {
365 prob: vec![0.3, 0.7, 0.5],
366 };
367 let mut rng = StdRng::seed_from_u64(5);
368 let samples = <UnivariateBernoulli as ProbabilityModel<TestBackend>>::sample(
369 &UnivariateBernoulli,
370 &state,
371 500,
372 &mut rng,
373 &device,
374 );
375 let data = samples
376 .into_data()
377 .into_vec::<f32>()
378 .expect("samples host-read of a tensor this test just built");
379 for v in data {
380 // Exact float compare is correct here: sample() writes literal 0.0
381 // or 1.0, never a computed value.
382 #[allow(clippy::float_cmp)]
383 let is_binary = v == 0.0 || v == 1.0;
384 assert!(is_binary, "non-binary gene {v}");
385 }
386 }
387
388 #[test]
389 fn fit_empty_population_returns_prior() {
390 // k == 0 would index an empty `rows` and panic; the guard (#129) returns
391 // the previous probabilities unchanged.
392 let device = Default::default();
393 let p = UnivariateBernoulliParams::default_for(3);
394 let prior = fit_prior(&p);
395 let state = <UnivariateBernoulli as ProbabilityModel<TestBackend>>::fit(
396 &UnivariateBernoulli,
397 &p,
398 Some(&prior),
399 pop(vec![], 0, 3),
400 fitness(vec![]),
401 &device,
402 );
403 assert_eq!(
404 state.prob, prior.prob,
405 "empty population must return prior unchanged"
406 );
407 }
408
409 #[test]
410 fn nan_fitness_not_selected_as_best() {
411 // Row 0 all-ones + NaN fitness; row 1 all-zeros + finite fitness. The
412 // sanitized seam (#129) must pick row 1 as best and push prob toward 0.
413 let device = Default::default();
414 let p = UnivariateBernoulliParams::default_for(2);
415 let prior = fit_prior(&p);
416 let state = <UnivariateBernoulli as ProbabilityModel<TestBackend>>::fit(
417 &UnivariateBernoulli,
418 &p,
419 Some(&prior),
420 pop(vec![1.0, 1.0, 0.0, 0.0], 2, 2),
421 fitness(vec![f32::NAN, 5.0]),
422 &device,
423 );
424 for &pj in &state.prob {
425 assert!(
426 pj < 0.5,
427 "best should be the finite-fitness zero row, got {pj}"
428 );
429 }
430 }
431
432 #[test]
433 fn probabilities_stay_within_bounds_across_generations() {
434 // §7.2: the PBIL update interpolates `prob*(1-lr) + lr*gene` with
435 // `gene ∈ {0, 1}` and `prob ∈ [min_prob, max_prob]`, so every generation
436 // keeps each probability inside the bounds. Drive many seeded
437 // fit/update generations over random binary populations and assert the
438 // invariant holds throughout.
439 let device = Default::default();
440 let p = UnivariateBernoulliParams::default_for(6);
441 let (min_prob, max_prob) = (0.0_f32, 1.0_f32);
442 let (k, d) = (8_usize, 6_usize);
443 let mut state = fit_prior(&p);
444 let mut rng = StdRng::seed_from_u64(4242);
445 for _ in 0..40 {
446 let rows: Vec<f32> = (0..k * d)
447 .map(|_| if rng.random::<f32>() < 0.5 { 0.0 } else { 1.0 })
448 .collect();
449 let fit_vals: Vec<f32> = (0..k).map(|_| rng.random::<f32>()).collect();
450 state = <UnivariateBernoulli as ProbabilityModel<TestBackend>>::fit(
451 &UnivariateBernoulli,
452 &p,
453 Some(&state),
454 pop(rows, k, d),
455 fitness(fit_vals),
456 &device,
457 );
458 for &pj in &state.prob {
459 assert!(pj.is_finite(), "prob must stay finite, got {pj}");
460 assert!(
461 (min_prob..=max_prob).contains(&pj),
462 "prob {pj} escaped [{min_prob}, {max_prob}]"
463 );
464 }
465 }
466 }
467}