1use burn::tensor::{Tensor, TensorData, backend::Backend};
25use rand::Rng;
26use rand_distr::{Distribution as _, Normal};
27use rlevo_core::config::{self, ConfigError, ConstraintKind};
28
29use crate::probability_model::ProbabilityModel;
30
31#[derive(Debug, Clone)]
38pub struct UnivariateGaussianParams {
39 pub genome_dim: usize,
42 pub init_mean: f32,
44 pub init_std: f32,
47 pub min_variance: f32,
51}
52
53impl UnivariateGaussianParams {
54 #[must_use]
56 pub fn default_for(genome_dim: usize) -> Self {
57 Self {
58 genome_dim,
59 init_mean: 0.0,
60 init_std: 2.0,
61 min_variance: 1e-6,
62 }
63 }
64}
65
66#[derive(Debug, Clone)]
80pub struct UnivariateGaussianState {
81 mean: Vec<f32>,
83 variance: Vec<f32>,
86}
87
88impl UnivariateGaussianState {
89 pub fn try_new(mean: Vec<f32>, variance: Vec<f32>) -> Result<Self, ConfigError> {
97 config::nonzero("UnivariateGaussianState", "mean", mean.len())?;
98 if mean.len() != variance.len() {
99 return Err(ConfigError {
100 config: "UnivariateGaussianState",
101 field: "variance",
102 kind: ConstraintKind::Custom("mean and variance must have equal length"),
103 });
104 }
105 if variance.iter().any(|v| !v.is_finite() || *v < 0.0) {
106 return Err(ConfigError {
107 config: "UnivariateGaussianState",
108 field: "variance",
109 kind: ConstraintKind::Custom("every variance must be finite and non-negative"),
110 });
111 }
112 Ok(Self { mean, variance })
113 }
114
115 #[must_use]
117 pub fn mean(&self) -> &[f32] {
118 &self.mean
119 }
120
121 #[must_use]
123 pub fn variance(&self) -> &[f32] {
124 &self.variance
125 }
126}
127
128#[derive(Debug, Clone, Copy, Default)]
138pub struct UnivariateGaussian;
139
140impl<B: Backend> ProbabilityModel<B> for UnivariateGaussian {
141 type Params = UnivariateGaussianParams;
142 type State = UnivariateGaussianState;
143
144 fn fit(
159 &self,
160 params: &Self::Params,
161 prev: Option<&Self::State>,
162 population: Tensor<B, 2>,
163 fitness: Tensor<B, 1>,
164 device: &<B as burn::tensor::backend::BackendTypes>::Device,
165 ) -> Self::State {
166 let _ = device;
167 let _ = fitness;
169 let Some(_prev) = prev else {
170 let d = params.genome_dim;
172 return UnivariateGaussianState {
173 mean: vec![params.init_mean; d],
174 variance: vec![params.init_std * params.init_std; d],
175 };
176 };
177 let [k, d] = population.dims();
180 let rows = population
181 .into_data()
182 .into_vec::<f32>()
183 .expect("population tensor must be readable as f32");
184 #[allow(clippy::cast_precision_loss)]
187 let kf = k as f32;
188
189 let mut mean = vec![0.0_f32; d];
190 for i in 0..k {
191 for j in 0..d {
192 mean[j] += rows[i * d + j];
193 }
194 }
195 for m in &mut mean {
196 let mu = *m / kf;
197 *m = if mu.is_finite() { mu } else { params.init_mean };
204 }
205
206 let mut variance = vec![0.0_f32; d];
207 for i in 0..k {
208 for j in 0..d {
209 let diff = rows[i * d + j] - mean[j];
210 variance[j] += diff * diff;
211 }
212 }
213 for v in &mut variance {
214 let mle = *v / kf;
221 *v = if mle.is_finite() && mle > params.min_variance {
222 mle
223 } else {
224 params.min_variance
225 };
226 }
227
228 UnivariateGaussianState { mean, variance }
229 }
230
231 fn sample(
247 &self,
248 state: &Self::State,
249 n: usize,
250 rng: &mut dyn Rng,
251 device: &<B as burn::tensor::backend::BackendTypes>::Device,
252 ) -> Tensor<B, 2> {
253 let d = state.mean.len();
254 let normals: Vec<Normal<f32>> = (0..d)
256 .map(|j| {
257 Normal::new(state.mean[j], state.variance[j].sqrt())
258 .expect("floored std is positive and finite")
259 })
260 .collect();
261 let mut rows = Vec::with_capacity(n * d);
263 for _ in 0..n {
264 for normal in &normals {
265 rows.push(normal.sample(rng));
266 }
267 }
268 Tensor::<B, 2>::from_data(TensorData::new(rows, [n, d]), device)
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275 use burn::backend::Flex;
276 use rand::SeedableRng;
277 use rand::rngs::StdRng;
278
279 type TestBackend = Flex;
280
281 fn pop(rows: Vec<f32>, n: usize, d: usize) -> Tensor<TestBackend, 2> {
282 let device = Default::default();
283 Tensor::<TestBackend, 2>::from_data(TensorData::new(rows, [n, d]), &device)
284 }
285
286 fn fitness(values: Vec<f32>) -> Tensor<TestBackend, 1> {
287 let device = Default::default();
288 let n = values.len();
289 Tensor::<TestBackend, 1>::from_data(TensorData::new(values, [n]), &device)
290 }
291
292 #[test]
293 fn prior_from_params() {
294 let device = Default::default();
295 let model = UnivariateGaussian;
296 let p = UnivariateGaussianParams::default_for(3);
297 let state = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
298 &model,
299 &p,
300 None,
301 pop(vec![], 0, 0),
302 fitness(vec![]),
303 &device,
304 );
305 assert_eq!(state.mean, vec![0.0, 0.0, 0.0]);
306 for v in &state.variance {
307 approx::assert_relative_eq!(*v, 4.0, epsilon = 1e-6);
308 }
309 }
310
311 #[test]
312 fn mle_matches_hand_computed() {
313 let device = Default::default();
314 let model = UnivariateGaussian;
315 let p = UnivariateGaussianParams::default_for(2);
316 let population = pop(vec![0.0, 1.0, 2.0, 1.0, 4.0, 4.0], 3, 2);
319 let prior = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
320 &model,
321 &p,
322 None,
323 pop(vec![], 0, 0),
324 fitness(vec![]),
325 &device,
326 );
327 let state = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
328 &model,
329 &p,
330 Some(&prior),
331 population,
332 fitness(vec![0.0, 1.0, 2.0]),
333 &device,
334 );
335 approx::assert_relative_eq!(state.mean[0], 2.0, epsilon = 1e-5);
336 approx::assert_relative_eq!(state.mean[1], 2.0, epsilon = 1e-5);
337 approx::assert_relative_eq!(state.variance[0], 8.0 / 3.0, epsilon = 1e-5);
338 approx::assert_relative_eq!(state.variance[1], 2.0, epsilon = 1e-5);
339 }
340
341 #[test]
342 fn variance_floor_engages_on_constant_column() {
343 let device = Default::default();
344 let model = UnivariateGaussian;
345 let p = UnivariateGaussianParams::default_for(1);
346 let prior = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
347 &model,
348 &p,
349 None,
350 pop(vec![], 0, 0),
351 fitness(vec![]),
352 &device,
353 );
354 let state = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
356 &model,
357 &p,
358 Some(&prior),
359 pop(vec![3.0, 3.0, 3.0], 3, 1),
360 fitness(vec![0.0, 0.0, 0.0]),
361 &device,
362 );
363 approx::assert_relative_eq!(state.variance[0], p.min_variance, epsilon = 1e-9);
364 }
365
366 #[test]
367 fn fitness_is_ignored() {
368 let device = Default::default();
369 let model = UnivariateGaussian;
370 let p = UnivariateGaussianParams::default_for(2);
371 let prior = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
372 &model,
373 &p,
374 None,
375 pop(vec![], 0, 0),
376 fitness(vec![]),
377 &device,
378 );
379 let rows = vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0];
380 let a = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
381 &model,
382 &p,
383 Some(&prior),
384 pop(rows.clone(), 3, 2),
385 fitness(vec![0.0, 1.0, 2.0]),
386 &device,
387 );
388 let b = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
389 &model,
390 &p,
391 Some(&prior),
392 pop(rows, 3, 2),
393 fitness(vec![100.0, -7.0, 42.0]),
394 &device,
395 );
396 assert_eq!(a.mean, b.mean);
397 assert_eq!(a.variance, b.variance);
398 }
399
400 #[test]
401 fn try_new_accepts_valid_and_round_trips() {
402 let state = UnivariateGaussianState::try_new(vec![1.0, -2.0], vec![0.5, 4.0]).unwrap();
403 assert_eq!(state.mean(), &[1.0, -2.0]);
404 assert_eq!(state.variance(), &[0.5, 4.0]);
405 }
406
407 #[test]
408 fn try_new_rejects_length_mismatch_and_bad_variance() {
409 assert!(UnivariateGaussianState::try_new(vec![0.0, 0.0], vec![1.0]).is_err());
410 assert!(UnivariateGaussianState::try_new(vec![], vec![]).is_err());
411 assert!(UnivariateGaussianState::try_new(vec![0.0], vec![-1.0]).is_err());
412 assert!(UnivariateGaussianState::try_new(vec![0.0], vec![f32::NAN]).is_err());
413 }
414
415 #[test]
416 fn seeded_sampling_mean_matches_state() {
417 let device = Default::default();
418 let model = UnivariateGaussian;
419 let state = UnivariateGaussianState {
420 mean: vec![3.0, -1.0],
421 variance: vec![1.0, 0.25],
422 };
423 let mut rng = StdRng::seed_from_u64(123);
424 let samples = <UnivariateGaussian as ProbabilityModel<TestBackend>>::sample(
425 &model, &state, 10_000, &mut rng, &device,
426 );
427 let dims = samples.dims();
428 assert_eq!(dims, [10_000, 2]);
429 let data = samples
430 .into_data()
431 .into_vec::<f32>()
432 .expect("samples host-read of a tensor this test just built");
433 let mut sum0 = 0.0_f32;
434 let mut sum1 = 0.0_f32;
435 for i in 0..10_000 {
436 sum0 += data[i * 2];
437 sum1 += data[i * 2 + 1];
438 }
439 approx::assert_relative_eq!(sum0 / 10_000.0, 3.0, epsilon = 0.1);
440 approx::assert_relative_eq!(sum1 / 10_000.0, -1.0, epsilon = 0.1);
441 }
442
443 #[test]
444 fn inf_variance_floored_to_min() {
445 let device = Default::default();
449 let p = UnivariateGaussianParams::default_for(1);
450 let prior = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
451 &UnivariateGaussian,
452 &p,
453 None,
454 pop(vec![], 0, 0),
455 fitness(vec![]),
456 &device,
457 );
458 let state = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
459 &UnivariateGaussian,
460 &p,
461 Some(&prior),
462 pop(vec![1e38, -1e38], 2, 1),
463 fitness(vec![0.0, 1.0]),
464 &device,
465 );
466 let v = state.variance[0];
467 assert!(v.is_finite(), "variance must be finite, got {v}");
468 approx::assert_relative_eq!(v, p.min_variance, epsilon = 1e-12);
469 }
470
471 #[test]
472 fn nonfinite_gene_mean_falls_back_to_init_mean() {
473 let device = Default::default();
476 let mut p = UnivariateGaussianParams::default_for(1);
477 p.init_mean = 3.0;
478 let prior = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
479 &UnivariateGaussian,
480 &p,
481 None,
482 pop(vec![], 0, 0),
483 fitness(vec![]),
484 &device,
485 );
486 let state = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
487 &UnivariateGaussian,
488 &p,
489 Some(&prior),
490 pop(vec![f32::NAN, 0.0], 2, 1),
491 fitness(vec![0.0, 1.0]),
492 &device,
493 );
494 assert!(state.mean[0].is_finite(), "mean must be finite");
495 approx::assert_relative_eq!(state.mean[0], p.init_mean, epsilon = 1e-12);
496 }
497
498 #[test]
499 fn single_row_variance_floored() {
500 let device = Default::default();
504 let model = UnivariateGaussian;
505 let p = UnivariateGaussianParams::default_for(2);
506 let prior = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
507 &model,
508 &p,
509 None,
510 pop(vec![], 0, 0),
511 fitness(vec![]),
512 &device,
513 );
514 let state = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
515 &model,
516 &p,
517 Some(&prior),
518 pop(vec![5.0, -3.0], 1, 2),
519 fitness(vec![0.0]),
520 &device,
521 );
522 approx::assert_relative_eq!(state.mean[0], 5.0, epsilon = 1e-6);
523 approx::assert_relative_eq!(state.mean[1], -3.0, epsilon = 1e-6);
524 for v in &state.variance {
525 approx::assert_relative_eq!(*v, p.min_variance, epsilon = 1e-9);
526 }
527 }
528
529 #[test]
530 fn seeded_sampling_variance_matches_state() {
531 let device = Default::default();
535 let model = UnivariateGaussian;
536 let state = UnivariateGaussianState {
537 mean: vec![3.0, -1.0],
538 variance: vec![1.0, 0.25],
539 };
540 let mut rng = StdRng::seed_from_u64(321);
541 let n = 20_000_usize;
542 let samples = <UnivariateGaussian as ProbabilityModel<TestBackend>>::sample(
543 &model, &state, n, &mut rng, &device,
544 );
545 let data = samples
546 .into_data()
547 .into_vec::<f32>()
548 .expect("samples host-read of a tensor this test just built");
549 #[allow(clippy::cast_precision_loss)]
551 let nf = n as f64;
552 for j in 0..2 {
553 let mut sum = 0.0_f64;
554 for i in 0..n {
555 sum += f64::from(data[i * 2 + j]);
556 }
557 let mean = sum / nf;
558 let mut var = 0.0_f64;
559 for i in 0..n {
560 let diff = f64::from(data[i * 2 + j]) - mean;
561 var += diff * diff;
562 }
563 var /= nf;
564 #[allow(clippy::cast_possible_truncation)]
566 let var_f32 = var as f32;
567 approx::assert_abs_diff_eq!(var_f32, state.variance()[j], epsilon = 0.05);
568 }
569 }
570
571 #[test]
572 fn refit_overwrites_prev_state() {
573 let device = Default::default();
577 let model = UnivariateGaussian;
578 let p = UnivariateGaussianParams::default_for(1);
579 let prev = UnivariateGaussianState {
580 mean: vec![100.0],
581 variance: vec![50.0],
582 };
583 let state = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
585 &model,
586 &p,
587 Some(&prev),
588 pop(vec![0.0, 2.0, 4.0], 3, 1),
589 fitness(vec![0.0, 1.0, 2.0]),
590 &device,
591 );
592 approx::assert_relative_eq!(state.mean[0], 2.0, epsilon = 1e-5);
593 approx::assert_relative_eq!(state.variance[0], 8.0 / 3.0, epsilon = 1e-5);
594 assert!(
596 (state.mean[0] - 100.0).abs() > 50.0,
597 "refit must overwrite, not blend with prev mean"
598 );
599 }
600
601 use proptest::prelude::*;
602
603 proptest! {
604 #![proptest_config(ProptestConfig { cases: 64, ..ProptestConfig::default() })]
607
608 #[test]
619 fn fit_produces_finite_floored_state(
620 data in prop::collection::vec(-1e6f32..1e6f32, 2usize..200),
621 d in 2usize..=8,
622 ) {
623 let device = Default::default();
624 let model = UnivariateGaussian;
625 let params = UnivariateGaussianParams::default_for(d);
626
627 let k = data.len() / d;
630 prop_assume!(k >= 1);
631 let rows: Vec<f32> = data[..k * d].to_vec();
632 let population = pop(rows, k, d);
633
634 let prior = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
635 &model,
636 ¶ms,
637 None,
638 pop(vec![], 0, 0),
639 fitness(vec![]),
640 &device,
641 );
642 let state = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
643 &model,
644 ¶ms,
645 Some(&prior),
646 population,
647 fitness(vec![0.0_f32; k]),
648 &device,
649 );
650
651 prop_assert_eq!(state.mean().len(), d);
652 prop_assert_eq!(state.variance().len(), d);
653 for &m in state.mean() {
654 prop_assert!(m.is_finite(), "mean entry not finite: {}", m);
655 }
656 for &v in state.variance() {
657 prop_assert!(v.is_finite(), "variance entry not finite: {}", v);
658 prop_assert!(
659 v >= params.min_variance,
660 "variance {} below floor {}",
661 v,
662 params.min_variance
663 );
664 }
665 }
666 }
667
668 proptest! {
669 #![proptest_config(ProptestConfig {
672 cases: 16,
673 max_shrink_iters: 64,
674 ..ProptestConfig::default()
675 })]
676
677 #[test]
691 fn sample_mean_is_unbiased(
692 mu in -10f32..10f32,
693 sigma2 in 1e-3f32..10f32,
694 n in 5_000usize..=20_000,
695 seed in any::<u64>(),
696 ) {
697 let device = Default::default();
698 let model = UnivariateGaussian;
699 let state = UnivariateGaussianState {
700 mean: vec![mu],
701 variance: vec![sigma2],
702 };
703 let mut rng = StdRng::seed_from_u64(seed);
704 let samples = <UnivariateGaussian as ProbabilityModel<TestBackend>>::sample(
705 &model, &state, n, &mut rng, &device,
706 );
707 prop_assert_eq!(samples.dims(), [n, 1]);
708
709 let data = samples
710 .into_data()
711 .into_vec::<f32>()
712 .expect("samples host-read of a tensor this test just built");
713 let sum: f64 = data.iter().map(|&x| f64::from(x)).sum();
714 #[allow(clippy::cast_precision_loss)]
716 let sample_mean = sum / n as f64;
717
718 let sigma = f64::from(sigma2).sqrt();
719 let bound = 0.1 * sigma;
720 let diff = (sample_mean - f64::from(mu)).abs();
721 prop_assert!(
722 diff < bound,
723 "sample mean {} strayed from mu {} by {} (bound {})",
724 sample_mean,
725 mu,
726 diff,
727 bound
728 );
729 }
730 }
731}