Skip to main content

rlevo_evolution/
probability_model.rs

1//! The [`ProbabilityModel`] trait shared by estimation-of-distribution
2//! algorithms (EDAs).
3//!
4//! An EDA replaces an explicit recombination operator with an explicit
5//! probabilistic model of the promising region of search space. Each
6//! generation fits a model to the selected individuals and then samples a
7//! fresh population from it. This module defines the seam between the
8//! generic [`crate::algorithms::eda::EdaStrategy`] driver and the concrete
9//! model implementations (univariate Gaussian, Bernoulli, compact-GA, and a
10//! dependency-chain MIMIC model).
11
12use std::fmt::Debug;
13
14use burn::tensor::{Tensor, backend::Backend};
15use rand::Rng;
16
17/// A probabilistic model of a promising region of search space.
18///
19/// EDAs run a `fit` → `sample` loop: each generation, [`fit`](Self::fit)
20/// estimates model parameters from the (already truncation-selected)
21/// population, then [`sample`](Self::sample) draws the next candidate
22/// population from the fitted model. The model fully replaces the crossover
23/// and mutation operators of a classical GA.
24///
25/// The two associated types separate *static* per-model configuration
26/// ([`Params`](Self::Params), e.g. learning rates, initial means) from the
27/// *evolving* fitted statistics ([`State`](Self::State), e.g. per-dimension
28/// means and variances). [`State`](Self::State) is deliberately `Sync` (a
29/// stronger bound than the `Send`-only [`crate::Strategy::State`]) so a future
30/// covariance-carrying CMA-ES state can be shared across threads; the same
31/// `fit`/`sample` shape is intended to host that model unchanged.
32///
33/// The `fitness` tensor is passed to [`fit`](Self::fit) so models that weight
34/// or rank the selected individuals (rank-μ updates, weighted MLE) can use it.
35/// The univariate models shipped here perform an unweighted maximum-likelihood
36/// fit and ignore it.
37///
38/// # Invariants
39///
40/// - **Prior path.** When `prev = None`, the model builds its prior *purely*
41///   from [`params`](Self::Params). On this path the `population` and
42///   `fitness` tensors are ignored; [`EdaStrategy`](crate::algorithms::eda::EdaStrategy)'s
43///   `init` passes them as a `0 × 0` population and a length-`0` fitness tensor,
44///   so a model must never read their contents when `prev` is `None`.
45/// - **Host RNG only.** All randomness in [`sample`](Self::sample) must come
46///   from the supplied `rng`. Implementations must never call `Tensor::random`
47///   or `B::seed`: Burn's GPU PRNG kernels are seeded through process-global
48///   state, which interleaves across parallel strategy calls and breaks the
49///   per-stream determinism the crate guarantees. Sample on the host and upload
50///   with `Tensor::from_data`.
51/// - **Selection order.** The population rows handed to [`fit`](Self::fit) by
52///   [`EdaStrategy`](crate::algorithms::eda::EdaStrategy)'s `tell` arrive in
53///   *ascending-fitness*
54///   order (best first), deterministically. Models that need the best or worst
55///   row (e.g. PBIL, cGA) must still compute argmin/argmax themselves rather
56///   than assume a fixed index — the ascending order is a convenience, not a
57///   contract a model may hard-code against.
58/// - **`Sync` state.** [`State`](Self::State) requires `Sync`, deliberately
59///   exceeding [`crate::Strategy::State`]'s `Send`-only bound, to leave room for
60///   a future thread-shared CMA-ES state.
61/// - **Sanitized input assumed.** [`fit`](Self::fit) and
62///   [`sample`](Self::sample) are infallible by design. The
63///   [`EdaStrategy`](crate::algorithms::eda::EdaStrategy) driver sanitizes
64///   fitness (`NaN` → `-inf`) and clamps the selected-row count to `≥ 2` before
65///   calling `fit`, so the supported call path never supplies empty populations
66///   or non-finite fitness. Callers that invoke `fit`/`sample` directly
67///   (bypassing the driver) must uphold the same preconditions; otherwise
68///   behaviour is unspecified — implementations may emit `NaN` statistics or
69///   panic in `sample`.
70///
71/// # Examples
72///
73/// ```
74/// use burn::backend::Flex;
75/// use burn::tensor::{Tensor, TensorData};
76/// use rand::{rngs::StdRng, SeedableRng};
77/// use rlevo_evolution::ProbabilityModel;
78/// use rlevo_evolution::algorithms::eda::{UnivariateGaussian, UnivariateGaussianParams};
79///
80/// let device = Default::default();
81/// let model = UnivariateGaussian;
82/// let params = UnivariateGaussianParams::default_for(2);
83///
84/// // Fit to a tiny two-row, two-column selected population.
85/// let pop = Tensor::<Flex, 2>::from_data(
86///     TensorData::new(vec![0.0_f32, 0.0, 2.0, 2.0], [2, 2]),
87///     &device,
88/// );
89/// let fitness = Tensor::<Flex, 1>::from_data(TensorData::new(vec![0.0_f32, 8.0], [2]), &device);
90/// let state =
91///     ProbabilityModel::<Flex>::fit(&model, &params, None, pop.clone(), fitness.clone(), &device);
92/// let next = ProbabilityModel::<Flex>::fit(&model, &params, Some(&state), pop, fitness, &device);
93///
94/// // Sample a fresh population from the fitted model.
95/// let mut rng = StdRng::seed_from_u64(0);
96/// let drawn: Tensor<Flex, 2> = model.sample(&next, 4, &mut rng, &device);
97/// assert_eq!(drawn.dims(), [4, 2]);
98/// ```
99pub trait ProbabilityModel<B: Backend>: Send + Sync {
100    /// Static, per-run model configuration (learning rates, priors, …).
101    type Params: Clone + Debug + Send + Sync;
102
103    /// Evolving fitted statistics carried generation to generation.
104    type State: Clone + Debug + Send + Sync;
105
106    /// Fit the model to the selected population.
107    ///
108    /// When `prev = None` the returned state is a prior derived purely from
109    /// `params` (see the trait [`Invariants`](Self#invariants)); `population`
110    /// and `fitness` are ignored on that path. Otherwise the model is refit to
111    /// the supplied selected rows (ascending fitness order). `fitness` is
112    /// available for weighted or rank-based updates; unweighted models ignore
113    /// it.
114    fn fit(
115        &self,
116        params: &Self::Params,
117        prev: Option<&Self::State>,
118        population: Tensor<B, 2>,
119        fitness: Tensor<B, 1>,
120        device: &<B as burn::tensor::backend::BackendTypes>::Device,
121    ) -> Self::State;
122
123    /// Draw `n` candidate genomes from the fitted model.
124    ///
125    /// All randomness must be drawn from `rng` (host RNG only — never
126    /// `Tensor::random`/`B::seed`). The returned tensor has shape `(n, D)`
127    /// where `D` is the model's genome dimensionality.
128    ///
129    /// # Panics
130    ///
131    /// Implementations may panic if `state` was not produced by a prior
132    /// [`fit`](Self::fit) call — e.g. a user-constructed state with non-finite
133    /// mean or variance (concrete impls call `Normal::new(...).expect(...)`,
134    /// which panics on non-finite parameters).
135    fn sample(
136        &self,
137        state: &Self::State,
138        n: usize,
139        rng: &mut dyn Rng,
140        device: &<B as burn::tensor::backend::BackendTypes>::Device,
141    ) -> Tensor<B, 2>;
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use crate::algorithms::eda::{UnivariateGaussian, UnivariateGaussianParams};
148    use burn::backend::Flex;
149
150    type TestBackend = Flex;
151
152    #[test]
153    fn prior_ignores_empty_population_and_fitness() {
154        // Pin the `prev = None` invariant: a 0×0 population and 0-length
155        // fitness tensor (exactly what `EdaStrategy::init` passes) must be
156        // ignored, with the prior built purely from `params`.
157        let device = Default::default();
158        let model = UnivariateGaussian;
159        let params = UnivariateGaussianParams::default_for(3);
160
161        let empty_pop = Tensor::<TestBackend, 2>::empty([0, 0], &device);
162        let empty_fitness = Tensor::<TestBackend, 1>::empty([0], &device);
163
164        let state = model.fit(&params, None, empty_pop, empty_fitness, &device);
165
166        assert_eq!(state.mean().len(), 3);
167        assert_eq!(state.variance().len(), 3);
168        for &m in state.mean() {
169            approx::assert_relative_eq!(m, params.init_mean, epsilon = 1e-6);
170        }
171        let expected_var = params.init_std * params.init_std;
172        for &v in state.variance() {
173            approx::assert_relative_eq!(v, expected_var, epsilon = 1e-6);
174        }
175    }
176}