use burn::tensor::{Tensor, TensorData, backend::Backend};
use rand::Rng;
use rand_distr::{Distribution as _, Normal};
use rlevo_core::config::{self, ConfigError, ConstraintKind};
use crate::probability_model::ProbabilityModel;
#[derive(Debug, Clone)]
pub struct UnivariateGaussianParams {
pub genome_dim: usize,
pub init_mean: f32,
pub init_std: f32,
pub min_variance: f32,
}
impl UnivariateGaussianParams {
#[must_use]
pub fn default_for(genome_dim: usize) -> Self {
Self {
genome_dim,
init_mean: 0.0,
init_std: 2.0,
min_variance: 1e-6,
}
}
}
#[derive(Debug, Clone)]
pub struct UnivariateGaussianState {
mean: Vec<f32>,
variance: Vec<f32>,
}
impl UnivariateGaussianState {
pub fn try_new(mean: Vec<f32>, variance: Vec<f32>) -> Result<Self, ConfigError> {
config::nonzero("UnivariateGaussianState", "mean", mean.len())?;
if mean.len() != variance.len() {
return Err(ConfigError {
config: "UnivariateGaussianState",
field: "variance",
kind: ConstraintKind::Custom("mean and variance must have equal length"),
});
}
if variance.iter().any(|v| !v.is_finite() || *v < 0.0) {
return Err(ConfigError {
config: "UnivariateGaussianState",
field: "variance",
kind: ConstraintKind::Custom("every variance must be finite and non-negative"),
});
}
Ok(Self { mean, variance })
}
#[must_use]
pub fn mean(&self) -> &[f32] {
&self.mean
}
#[must_use]
pub fn variance(&self) -> &[f32] {
&self.variance
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct UnivariateGaussian;
impl<B: Backend> ProbabilityModel<B> for UnivariateGaussian {
type Params = UnivariateGaussianParams;
type State = UnivariateGaussianState;
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 _ = fitness;
let Some(_prev) = prev else {
let d = params.genome_dim;
return UnivariateGaussianState {
mean: vec![params.init_mean; d],
variance: vec![params.init_std * params.init_std; d],
};
};
let [k, d] = population.dims();
let rows = population
.into_data()
.into_vec::<f32>()
.expect("population tensor must be readable as f32");
#[allow(clippy::cast_precision_loss)]
let kf = k as f32;
let mut mean = vec![0.0_f32; d];
for i in 0..k {
for j in 0..d {
mean[j] += rows[i * d + j];
}
}
for m in &mut mean {
let mu = *m / kf;
*m = if mu.is_finite() { mu } else { params.init_mean };
}
let mut variance = vec![0.0_f32; d];
for i in 0..k {
for j in 0..d {
let diff = rows[i * d + j] - mean[j];
variance[j] += diff * diff;
}
}
for v in &mut variance {
let mle = *v / kf;
*v = if mle.is_finite() && mle > params.min_variance {
mle
} else {
params.min_variance
};
}
UnivariateGaussianState { mean, variance }
}
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.mean.len();
let normals: Vec<Normal<f32>> = (0..d)
.map(|j| {
Normal::new(state.mean[j], state.variance[j].sqrt())
.expect("floored std is positive and finite")
})
.collect();
let mut rows = Vec::with_capacity(n * d);
for _ in 0..n {
for normal in &normals {
rows.push(normal.sample(rng));
}
}
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)
}
#[test]
fn prior_from_params() {
let device = Default::default();
let model = UnivariateGaussian;
let p = UnivariateGaussianParams::default_for(3);
let state = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
&model,
&p,
None,
pop(vec![], 0, 0),
fitness(vec![]),
&device,
);
assert_eq!(state.mean, vec![0.0, 0.0, 0.0]);
for v in &state.variance {
approx::assert_relative_eq!(*v, 4.0, epsilon = 1e-6);
}
}
#[test]
fn mle_matches_hand_computed() {
let device = Default::default();
let model = UnivariateGaussian;
let p = UnivariateGaussianParams::default_for(2);
let population = pop(vec![0.0, 1.0, 2.0, 1.0, 4.0, 4.0], 3, 2);
let prior = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
&model,
&p,
None,
pop(vec![], 0, 0),
fitness(vec![]),
&device,
);
let state = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
&model,
&p,
Some(&prior),
population,
fitness(vec![0.0, 1.0, 2.0]),
&device,
);
approx::assert_relative_eq!(state.mean[0], 2.0, epsilon = 1e-5);
approx::assert_relative_eq!(state.mean[1], 2.0, epsilon = 1e-5);
approx::assert_relative_eq!(state.variance[0], 8.0 / 3.0, epsilon = 1e-5);
approx::assert_relative_eq!(state.variance[1], 2.0, epsilon = 1e-5);
}
#[test]
fn variance_floor_engages_on_constant_column() {
let device = Default::default();
let model = UnivariateGaussian;
let p = UnivariateGaussianParams::default_for(1);
let prior = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
&model,
&p,
None,
pop(vec![], 0, 0),
fitness(vec![]),
&device,
);
let state = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
&model,
&p,
Some(&prior),
pop(vec![3.0, 3.0, 3.0], 3, 1),
fitness(vec![0.0, 0.0, 0.0]),
&device,
);
approx::assert_relative_eq!(state.variance[0], p.min_variance, epsilon = 1e-9);
}
#[test]
fn fitness_is_ignored() {
let device = Default::default();
let model = UnivariateGaussian;
let p = UnivariateGaussianParams::default_for(2);
let prior = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
&model,
&p,
None,
pop(vec![], 0, 0),
fitness(vec![]),
&device,
);
let rows = vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0];
let a = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
&model,
&p,
Some(&prior),
pop(rows.clone(), 3, 2),
fitness(vec![0.0, 1.0, 2.0]),
&device,
);
let b = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
&model,
&p,
Some(&prior),
pop(rows, 3, 2),
fitness(vec![100.0, -7.0, 42.0]),
&device,
);
assert_eq!(a.mean, b.mean);
assert_eq!(a.variance, b.variance);
}
#[test]
fn try_new_accepts_valid_and_round_trips() {
let state = UnivariateGaussianState::try_new(vec![1.0, -2.0], vec![0.5, 4.0]).unwrap();
assert_eq!(state.mean(), &[1.0, -2.0]);
assert_eq!(state.variance(), &[0.5, 4.0]);
}
#[test]
fn try_new_rejects_length_mismatch_and_bad_variance() {
assert!(UnivariateGaussianState::try_new(vec![0.0, 0.0], vec![1.0]).is_err());
assert!(UnivariateGaussianState::try_new(vec![], vec![]).is_err());
assert!(UnivariateGaussianState::try_new(vec![0.0], vec![-1.0]).is_err());
assert!(UnivariateGaussianState::try_new(vec![0.0], vec![f32::NAN]).is_err());
}
#[test]
fn seeded_sampling_mean_matches_state() {
let device = Default::default();
let model = UnivariateGaussian;
let state = UnivariateGaussianState {
mean: vec![3.0, -1.0],
variance: vec![1.0, 0.25],
};
let mut rng = StdRng::seed_from_u64(123);
let samples = <UnivariateGaussian as ProbabilityModel<TestBackend>>::sample(
&model, &state, 10_000, &mut rng, &device,
);
let dims = samples.dims();
assert_eq!(dims, [10_000, 2]);
let data = samples
.into_data()
.into_vec::<f32>()
.expect("samples host-read of a tensor this test just built");
let mut sum0 = 0.0_f32;
let mut sum1 = 0.0_f32;
for i in 0..10_000 {
sum0 += data[i * 2];
sum1 += data[i * 2 + 1];
}
approx::assert_relative_eq!(sum0 / 10_000.0, 3.0, epsilon = 0.1);
approx::assert_relative_eq!(sum1 / 10_000.0, -1.0, epsilon = 0.1);
}
#[test]
fn inf_variance_floored_to_min() {
let device = Default::default();
let p = UnivariateGaussianParams::default_for(1);
let prior = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
&UnivariateGaussian,
&p,
None,
pop(vec![], 0, 0),
fitness(vec![]),
&device,
);
let state = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
&UnivariateGaussian,
&p,
Some(&prior),
pop(vec![1e38, -1e38], 2, 1),
fitness(vec![0.0, 1.0]),
&device,
);
let v = state.variance[0];
assert!(v.is_finite(), "variance must be finite, got {v}");
approx::assert_relative_eq!(v, p.min_variance, epsilon = 1e-12);
}
#[test]
fn nonfinite_gene_mean_falls_back_to_init_mean() {
let device = Default::default();
let mut p = UnivariateGaussianParams::default_for(1);
p.init_mean = 3.0;
let prior = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
&UnivariateGaussian,
&p,
None,
pop(vec![], 0, 0),
fitness(vec![]),
&device,
);
let state = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
&UnivariateGaussian,
&p,
Some(&prior),
pop(vec![f32::NAN, 0.0], 2, 1),
fitness(vec![0.0, 1.0]),
&device,
);
assert!(state.mean[0].is_finite(), "mean must be finite");
approx::assert_relative_eq!(state.mean[0], p.init_mean, epsilon = 1e-12);
}
#[test]
fn single_row_variance_floored() {
let device = Default::default();
let model = UnivariateGaussian;
let p = UnivariateGaussianParams::default_for(2);
let prior = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
&model,
&p,
None,
pop(vec![], 0, 0),
fitness(vec![]),
&device,
);
let state = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
&model,
&p,
Some(&prior),
pop(vec![5.0, -3.0], 1, 2),
fitness(vec![0.0]),
&device,
);
approx::assert_relative_eq!(state.mean[0], 5.0, epsilon = 1e-6);
approx::assert_relative_eq!(state.mean[1], -3.0, epsilon = 1e-6);
for v in &state.variance {
approx::assert_relative_eq!(*v, p.min_variance, epsilon = 1e-9);
}
}
#[test]
fn seeded_sampling_variance_matches_state() {
let device = Default::default();
let model = UnivariateGaussian;
let state = UnivariateGaussianState {
mean: vec![3.0, -1.0],
variance: vec![1.0, 0.25],
};
let mut rng = StdRng::seed_from_u64(321);
let n = 20_000_usize;
let samples = <UnivariateGaussian as ProbabilityModel<TestBackend>>::sample(
&model, &state, n, &mut rng, &device,
);
let data = samples
.into_data()
.into_vec::<f32>()
.expect("samples host-read of a tensor this test just built");
#[allow(clippy::cast_precision_loss)]
let nf = n as f64;
for j in 0..2 {
let mut sum = 0.0_f64;
for i in 0..n {
sum += f64::from(data[i * 2 + j]);
}
let mean = sum / nf;
let mut var = 0.0_f64;
for i in 0..n {
let diff = f64::from(data[i * 2 + j]) - mean;
var += diff * diff;
}
var /= nf;
#[allow(clippy::cast_possible_truncation)]
let var_f32 = var as f32;
approx::assert_abs_diff_eq!(var_f32, state.variance()[j], epsilon = 0.05);
}
}
#[test]
fn refit_overwrites_prev_state() {
let device = Default::default();
let model = UnivariateGaussian;
let p = UnivariateGaussianParams::default_for(1);
let prev = UnivariateGaussianState {
mean: vec![100.0],
variance: vec![50.0],
};
let state = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
&model,
&p,
Some(&prev),
pop(vec![0.0, 2.0, 4.0], 3, 1),
fitness(vec![0.0, 1.0, 2.0]),
&device,
);
approx::assert_relative_eq!(state.mean[0], 2.0, epsilon = 1e-5);
approx::assert_relative_eq!(state.variance[0], 8.0 / 3.0, epsilon = 1e-5);
assert!(
(state.mean[0] - 100.0).abs() > 50.0,
"refit must overwrite, not blend with prev mean"
);
}
use proptest::prelude::*;
proptest! {
#![proptest_config(ProptestConfig { cases: 64, ..ProptestConfig::default() })]
#[test]
fn fit_produces_finite_floored_state(
data in prop::collection::vec(-1e6f32..1e6f32, 2usize..200),
d in 2usize..=8,
) {
let device = Default::default();
let model = UnivariateGaussian;
let params = UnivariateGaussianParams::default_for(d);
let k = data.len() / d;
prop_assume!(k >= 1);
let rows: Vec<f32> = data[..k * d].to_vec();
let population = pop(rows, k, d);
let prior = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
&model,
¶ms,
None,
pop(vec![], 0, 0),
fitness(vec![]),
&device,
);
let state = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
&model,
¶ms,
Some(&prior),
population,
fitness(vec![0.0_f32; k]),
&device,
);
prop_assert_eq!(state.mean().len(), d);
prop_assert_eq!(state.variance().len(), d);
for &m in state.mean() {
prop_assert!(m.is_finite(), "mean entry not finite: {}", m);
}
for &v in state.variance() {
prop_assert!(v.is_finite(), "variance entry not finite: {}", v);
prop_assert!(
v >= params.min_variance,
"variance {} below floor {}",
v,
params.min_variance
);
}
}
}
proptest! {
#![proptest_config(ProptestConfig {
cases: 16,
max_shrink_iters: 64,
..ProptestConfig::default()
})]
#[test]
fn sample_mean_is_unbiased(
mu in -10f32..10f32,
sigma2 in 1e-3f32..10f32,
n in 5_000usize..=20_000,
seed in any::<u64>(),
) {
let device = Default::default();
let model = UnivariateGaussian;
let state = UnivariateGaussianState {
mean: vec![mu],
variance: vec![sigma2],
};
let mut rng = StdRng::seed_from_u64(seed);
let samples = <UnivariateGaussian as ProbabilityModel<TestBackend>>::sample(
&model, &state, n, &mut rng, &device,
);
prop_assert_eq!(samples.dims(), [n, 1]);
let data = samples
.into_data()
.into_vec::<f32>()
.expect("samples host-read of a tensor this test just built");
let sum: f64 = data.iter().map(|&x| f64::from(x)).sum();
#[allow(clippy::cast_precision_loss)]
let sample_mean = sum / n as f64;
let sigma = f64::from(sigma2).sqrt();
let bound = 0.1 * sigma;
let diff = (sample_mean - f64::from(mu)).abs();
prop_assert!(
diff < bound,
"sample mean {} strayed from mu {} by {} (bound {})",
sample_mean,
mu,
diff,
bound
);
}
}
}