Skip to main content

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}