use burn::tensor::{Tensor, TensorData, backend::Backend};
use rand::{Rng, RngExt};
use rlevo_core::config::{self, ConfigError};
use crate::probability_model::ProbabilityModel;
#[derive(Debug, Clone)]
pub struct UnivariateBernoulliParams {
pub genome_dim: usize,
pub learning_rate: f32,
pub negative_learning_rate: f32,
}
impl UnivariateBernoulliParams {
#[must_use]
pub fn default_for(genome_dim: usize) -> Self {
Self {
genome_dim,
learning_rate: 0.1,
negative_learning_rate: 0.075,
}
}
}
#[derive(Debug, Clone)]
pub struct UnivariateBernoulliState {
prob: Vec<f32>,
}
impl UnivariateBernoulliState {
pub fn try_new(prob: Vec<f32>) -> Result<Self, ConfigError> {
config::nonzero("UnivariateBernoulliState", "prob", prob.len())?;
for &p in &prob {
config::in_range("UnivariateBernoulliState", "prob", 0.0, 1.0, f64::from(p))?;
}
Ok(Self { prob })
}
#[must_use]
pub fn prob(&self) -> &[f32] {
&self.prob
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct UnivariateBernoulli;
impl<B: Backend> ProbabilityModel<B> for UnivariateBernoulli {
type Params = UnivariateBernoulliParams;
type State = UnivariateBernoulliState;
fn fit(
&self,
params: &Self::Params,
prev: Option<&Self::State>,
population: Tensor<B, 2>,
fitness: Tensor<B, 1>,
device: &<B as burn::tensor::backend::BackendTypes>::Device,
) -> Self::State {
let _ = device;
let Some(prev) = prev else {
let _ = (population, fitness);
return UnivariateBernoulliState {
prob: vec![0.5; params.genome_dim],
};
};
let [k, d] = population.dims();
if k == 0 {
return UnivariateBernoulliState {
prob: prev.prob.clone(),
};
}
let rows = population
.into_data()
.into_vec::<f32>()
.expect("population tensor must be readable as f32");
let fit_host = fitness
.into_data()
.into_vec::<f32>()
.expect("fitness tensor must be readable as f32");
let mut best_idx = 0_usize;
let mut worst_idx = 0_usize;
let mut best_f = f32::NEG_INFINITY;
let mut worst_f = f32::INFINITY;
for i in 0..k {
let f = crate::fitness::sanitize_fitness(
fit_host.get(i).copied().unwrap_or(f32::NEG_INFINITY),
);
if f.total_cmp(&best_f) == std::cmp::Ordering::Greater {
best_f = f;
best_idx = i;
}
if f.total_cmp(&worst_f) == std::cmp::Ordering::Less {
worst_f = f;
worst_idx = i;
}
}
let lr = params.learning_rate;
let neg_lr = params.negative_learning_rate;
let mut prob = prev.prob.clone();
for j in 0..d {
let best_gene = rows[best_idx * d + j];
let worst_gene = rows[worst_idx * d + j];
let mut updated = prob[j] * (1.0 - lr) + lr * best_gene;
if (best_gene - worst_gene).abs() > 0.5 {
updated = updated * (1.0 - neg_lr) + neg_lr * best_gene;
}
prob[j] = updated;
}
UnivariateBernoulliState { prob }
}
fn sample(
&self,
state: &Self::State,
n: usize,
rng: &mut dyn Rng,
device: &<B as burn::tensor::backend::BackendTypes>::Device,
) -> Tensor<B, 2> {
let d = state.prob.len();
let mut rows = Vec::with_capacity(n * d);
for _ in 0..n {
for &p in &state.prob {
let gene = if rng.random::<f32>() < p { 1.0 } else { 0.0 };
rows.push(gene);
}
}
Tensor::<B, 2>::from_data(TensorData::new(rows, [n, d]), device)
}
}
#[cfg(test)]
mod tests {
use super::*;
use burn::backend::Flex;
use rand::SeedableRng;
use rand::rngs::StdRng;
type TestBackend = Flex;
fn pop(rows: Vec<f32>, n: usize, d: usize) -> Tensor<TestBackend, 2> {
let device = Default::default();
Tensor::<TestBackend, 2>::from_data(TensorData::new(rows, [n, d]), &device)
}
fn fitness(values: Vec<f32>) -> Tensor<TestBackend, 1> {
let device = Default::default();
let n = values.len();
Tensor::<TestBackend, 1>::from_data(TensorData::new(values, [n]), &device)
}
fn fit_prior(p: &UnivariateBernoulliParams) -> UnivariateBernoulliState {
let device = Default::default();
<UnivariateBernoulli as ProbabilityModel<TestBackend>>::fit(
&UnivariateBernoulli,
p,
None,
pop(vec![], 0, 0),
fitness(vec![]),
&device,
)
}
#[test]
fn prior_is_half() {
let p = UnivariateBernoulliParams::default_for(4);
let state = fit_prior(&p);
assert_eq!(state.prob, vec![0.5, 0.5, 0.5, 0.5]);
}
#[test]
fn try_new_accepts_valid_and_rejects_out_of_range() {
let state = UnivariateBernoulliState::try_new(vec![0.0, 0.5, 1.0]).unwrap();
assert_eq!(state.prob(), &[0.0, 0.5, 1.0]);
assert!(UnivariateBernoulliState::try_new(vec![]).is_err());
assert!(UnivariateBernoulliState::try_new(vec![1.2]).is_err());
assert!(UnivariateBernoulliState::try_new(vec![-0.5]).is_err());
assert!(UnivariateBernoulliState::try_new(vec![f32::NAN]).is_err());
}
#[test]
fn interpolation_not_overwrite() {
let device = Default::default();
let p = UnivariateBernoulliParams {
genome_dim: 1,
learning_rate: 0.1,
negative_learning_rate: 0.0,
};
let prior = fit_prior(&p);
let state = <UnivariateBernoulli as ProbabilityModel<TestBackend>>::fit(
&UnivariateBernoulli,
&p,
Some(&prior),
pop(vec![1.0, 0.0], 2, 1),
fitness(vec![1.0, 0.0]),
&device,
);
assert!(state.prob[0] > 0.5 && state.prob[0] < 1.0);
approx::assert_relative_eq!(state.prob[0], 0.55, epsilon = 1e-6);
}
#[test]
fn neg_lr_applies_only_to_differing_genes() {
let device = Default::default();
let p = UnivariateBernoulliParams {
genome_dim: 2,
learning_rate: 0.1,
negative_learning_rate: 0.2,
};
let prior = fit_prior(&p);
let state = <UnivariateBernoulli as ProbabilityModel<TestBackend>>::fit(
&UnivariateBernoulli,
&p,
Some(&prior),
pop(vec![1.0, 1.0, 1.0, 0.0], 2, 2),
fitness(vec![1.0, 0.0]),
&device,
);
approx::assert_relative_eq!(state.prob[0], 0.55, epsilon = 1e-6);
approx::assert_relative_eq!(state.prob[1], 0.64, epsilon = 1e-6);
}
#[test]
fn convergence_direction_toward_zeros() {
let device = Default::default();
let p = UnivariateBernoulliParams::default_for(1);
let mut state = fit_prior(&p);
for _ in 0..50 {
state = <UnivariateBernoulli as ProbabilityModel<TestBackend>>::fit(
&UnivariateBernoulli,
&p,
Some(&state),
pop(vec![0.0, 1.0], 2, 1),
fitness(vec![1.0, 0.0]),
&device,
);
}
assert!(
state.prob[0] < 0.1,
"p did not converge toward 0, got {}",
state.prob[0]
);
}
#[test]
fn samples_are_binary() {
let device = Default::default();
let state = UnivariateBernoulliState {
prob: vec![0.3, 0.7, 0.5],
};
let mut rng = StdRng::seed_from_u64(5);
let samples = <UnivariateBernoulli as ProbabilityModel<TestBackend>>::sample(
&UnivariateBernoulli,
&state,
500,
&mut rng,
&device,
);
let data = samples
.into_data()
.into_vec::<f32>()
.expect("samples host-read of a tensor this test just built");
for v in data {
#[allow(clippy::float_cmp)]
let is_binary = v == 0.0 || v == 1.0;
assert!(is_binary, "non-binary gene {v}");
}
}
#[test]
fn fit_empty_population_returns_prior() {
let device = Default::default();
let p = UnivariateBernoulliParams::default_for(3);
let prior = fit_prior(&p);
let state = <UnivariateBernoulli as ProbabilityModel<TestBackend>>::fit(
&UnivariateBernoulli,
&p,
Some(&prior),
pop(vec![], 0, 3),
fitness(vec![]),
&device,
);
assert_eq!(
state.prob, prior.prob,
"empty population must return prior unchanged"
);
}
#[test]
fn nan_fitness_not_selected_as_best() {
let device = Default::default();
let p = UnivariateBernoulliParams::default_for(2);
let prior = fit_prior(&p);
let state = <UnivariateBernoulli as ProbabilityModel<TestBackend>>::fit(
&UnivariateBernoulli,
&p,
Some(&prior),
pop(vec![1.0, 1.0, 0.0, 0.0], 2, 2),
fitness(vec![f32::NAN, 5.0]),
&device,
);
for &pj in &state.prob {
assert!(
pj < 0.5,
"best should be the finite-fitness zero row, got {pj}"
);
}
}
#[test]
fn probabilities_stay_within_bounds_across_generations() {
let device = Default::default();
let p = UnivariateBernoulliParams::default_for(6);
let (min_prob, max_prob) = (0.0_f32, 1.0_f32);
let (k, d) = (8_usize, 6_usize);
let mut state = fit_prior(&p);
let mut rng = StdRng::seed_from_u64(4242);
for _ in 0..40 {
let rows: Vec<f32> = (0..k * d)
.map(|_| if rng.random::<f32>() < 0.5 { 0.0 } else { 1.0 })
.collect();
let fit_vals: Vec<f32> = (0..k).map(|_| rng.random::<f32>()).collect();
state = <UnivariateBernoulli as ProbabilityModel<TestBackend>>::fit(
&UnivariateBernoulli,
&p,
Some(&state),
pop(rows, k, d),
fitness(fit_vals),
&device,
);
for &pj in &state.prob {
assert!(pj.is_finite(), "prob must stay finite, got {pj}");
assert!(
(min_prob..=max_prob).contains(&pj),
"prob {pj} escaped [{min_prob}, {max_prob}]"
);
}
}
}
}