use burn::tensor::{Tensor, TensorData, backend::Backend};
use rand::{Rng, RngExt};
use crate::probability_model::ProbabilityModel;
#[derive(Debug, Clone)]
pub struct BayesianNetworkParams {
pub genome_dim: usize,
pub max_parents: usize,
pub init_prob: f32,
pub smoothing_count: usize,
}
impl BayesianNetworkParams {
#[must_use]
pub fn default_for(genome_dim: usize) -> Self {
Self {
genome_dim,
max_parents: 3,
init_prob: 0.5,
smoothing_count: 1,
}
}
}
#[derive(Debug, Clone)]
pub struct BayesianNetworkState {
pub order: Vec<usize>,
pub parents: Vec<Vec<usize>>,
pub cpt: Vec<Vec<f32>>,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct BayesianNetwork;
fn prior_state(d: usize, init_prob: f32) -> BayesianNetworkState {
let p = if init_prob.is_nan() {
0.5
} else {
init_prob.clamp(1e-6, 1.0 - 1e-6)
};
BayesianNetworkState {
order: (0..d).collect(),
parents: vec![Vec::new(); d],
cpt: vec![vec![p]; d],
}
}
fn pack_config(bits: &[u8], row_base: usize, parents: &[usize]) -> usize {
let mut config = 0usize;
for (j, &p) in parents.iter().enumerate() {
if bits[row_base + p] == 1 {
config |= 1 << j;
}
}
config
}
#[allow(clippy::many_single_char_names)]
fn bic_score(bits: &[u8], n: usize, d: usize, v: usize, parents: &[usize]) -> f64 {
let q = parents.len();
let num_configs = 1usize << q;
let mut counts = vec![0u32; num_configs * 2];
for i in 0..n {
let base = i * d;
let x = usize::from(bits[base + v]);
let config = pack_config(bits, base, parents);
counts[config * 2 + x] += 1;
}
let mut log_likelihood = 0.0_f64;
for c in 0..num_configs {
let count_0 = counts[c * 2];
let count_1 = counts[c * 2 + 1];
let count_total = count_0 + count_1;
if count_total == 0 {
continue;
}
let total_f = f64::from(count_total);
for &count_x in &[count_0, count_1] {
if count_x == 0 {
continue;
}
let count_x_f = f64::from(count_x);
log_likelihood += count_x_f * (count_x_f / total_f).ln();
}
}
#[allow(clippy::cast_precision_loss)]
let nf = n as f64;
#[allow(clippy::cast_precision_loss)]
let penalty = 0.5 * nf.ln() * (num_configs as f64);
log_likelihood - penalty
}
fn insert_sorted(parents: &mut Vec<usize>, value: usize) {
let pos = parents.partition_point(|&p| p < value);
parents.insert(pos, value);
}
fn creates_cycle(parents: &[Vec<usize>], u: usize, v: usize) -> bool {
let d = parents.len();
let mut visited = vec![false; d];
let mut stack = vec![u];
while let Some(node) = stack.pop() {
if node == v {
return true;
}
if visited[node] {
continue;
}
visited[node] = true;
for &p in &parents[node] {
if !visited[p] {
stack.push(p);
}
}
}
false
}
fn topological_order(parents: &[Vec<usize>]) -> Vec<usize> {
let d = parents.len();
let mut indegree: Vec<usize> = parents.iter().map(Vec::len).collect();
let mut emitted = vec![false; d];
let mut order = Vec::with_capacity(d);
while order.len() < d {
let mut next = None;
for v in 0..d {
if !emitted[v] && indegree[v] == 0 {
next = Some(v);
break;
}
}
let Some(node) = next else { break };
emitted[node] = true;
order.push(node);
for (child, ps) in parents.iter().enumerate() {
if !emitted[child] && ps.contains(&node) {
indegree[child] -= 1;
}
}
}
order
}
impl<B: Backend> ProbabilityModel<B> for BayesianNetwork {
type Params = BayesianNetworkParams;
type State = BayesianNetworkState;
#[allow(clippy::too_many_lines, clippy::many_single_char_names)]
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 {
return prior_state(params.genome_dim, params.init_prob);
};
let [n, d] = population.dims();
debug_assert_eq!(
d, params.genome_dim,
"population column count must match params.genome_dim"
);
debug_assert!(
params.max_parents < usize::BITS as usize,
"max_parents must be below usize::BITS"
);
let rows = population
.into_data()
.into_vec::<f32>()
.expect("population tensor must be readable as f32");
if n == 0 {
return prior_state(params.genome_dim, params.init_prob);
}
let bits: Vec<u8> = rows.iter().map(|&v| u8::from(v >= 0.5)).collect();
let mut parents: Vec<Vec<usize>> = vec![Vec::new(); d];
let mut base_score: Vec<f64> = (0..d).map(|v| bic_score(&bits, n, d, v, &[])).collect();
let mut gain_cache = vec![f64::NEG_INFINITY; d * d];
for u in 0..d {
for v in 0..d {
if u == v {
continue;
}
let mut cand = parents[v].clone();
insert_sorted(&mut cand, u);
gain_cache[u * d + v] = bic_score(&bits, n, d, v, &cand) - base_score[v];
}
}
loop {
let mut best: Option<(f64, usize, usize)> = None;
for u in 0..d {
for v in 0..d {
if u == v
|| parents[v].len() >= params.max_parents
|| parents[v].contains(&u)
|| creates_cycle(&parents, u, v)
{
continue;
}
let g = gain_cache[u * d + v];
if best.is_none_or(|(bg, _, _)| g > bg) {
best = Some((g, u, v));
}
}
}
let Some((gain, u, v)) = best else { break };
if gain <= 0.0 {
break;
}
insert_sorted(&mut parents[v], u);
base_score[v] += gain;
for uu in 0..d {
if uu == v {
continue;
}
let mut cand = parents[v].clone();
if !cand.contains(&uu) {
insert_sorted(&mut cand, uu);
}
gain_cache[uu * d + v] = bic_score(&bits, n, d, v, &cand) - base_score[v];
}
}
let s = params.smoothing_count.max(1);
let mut cpt: Vec<Vec<f32>> = Vec::with_capacity(d);
#[allow(clippy::cast_precision_loss)]
let s_f = s as f64;
for v in 0..d {
let q = parents[v].len();
let num_configs = 1usize << q;
let mut counts = vec![0u32; num_configs * 2];
for i in 0..n {
let base = i * d;
let x = usize::from(bits[base + v]);
let config = pack_config(&bits, base, &parents[v]);
counts[config * 2 + x] += 1;
}
let mut table = Vec::with_capacity(num_configs);
for c in 0..num_configs {
let count_1 = counts[c * 2 + 1];
let count_total = counts[c * 2] + count_1;
let num = f64::from(count_1) + s_f;
let den = f64::from(count_total) + 2.0 * s_f;
#[allow(clippy::cast_possible_truncation)]
let prob = (num / den) as f32;
table.push(prob);
}
cpt.push(table);
}
let order = topological_order(&parents);
debug_assert_eq!(order.len(), d, "topological order must cover all nodes");
BayesianNetworkState {
order,
parents,
cpt,
}
}
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.parents.len();
let mut rows = vec![0.0_f32; n * d];
for i in 0..n {
let base = i * d;
for &v in &state.order {
let mut config = 0usize;
for (j, &p) in state.parents[v].iter().enumerate() {
if rows[base + p] >= 0.5 {
config |= 1 << j;
}
}
let p1 = state.cpt[v][config];
rows[base + v] = if rng.random::<f32>() < p1 { 1.0 } else { 0.0 };
}
}
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: &BayesianNetworkParams) -> BayesianNetworkState {
let device = Default::default();
<BayesianNetwork as ProbabilityModel<TestBackend>>::fit(
&BayesianNetwork,
p,
None,
pop(vec![], 0, 0),
fitness(vec![]),
&device,
)
}
fn refit(
p: &BayesianNetworkParams,
rows: Vec<f32>,
n: usize,
d: usize,
) -> BayesianNetworkState {
let device = Default::default();
let prior = fit_prior(p);
#[allow(clippy::cast_precision_loss)]
let fit_values: Vec<f32> = (0..n).map(|i| i as f32).collect();
<BayesianNetwork as ProbabilityModel<TestBackend>>::fit(
&BayesianNetwork,
p,
Some(&prior),
pop(rows, n, d),
fitness(fit_values),
&device,
)
}
#[test]
fn prior_is_edgeless_with_init_prob() {
let p = BayesianNetworkParams::default_for(3);
let state = fit_prior(&p);
assert_eq!(state.order, vec![0, 1, 2], "prior order is natural");
for ps in &state.parents {
assert!(ps.is_empty(), "prior parent lists must be empty");
}
for table in &state.cpt {
assert_eq!(table, &vec![0.5], "prior CPT is single-cell init_prob");
}
}
#[test]
fn two_fits_same_data_identical_state() {
let p = BayesianNetworkParams::default_for(3);
let rows = vec![
0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 1.0, 1.0, 1.0, ];
let a = refit(&p, rows.clone(), 10, 3);
let b = refit(&p, rows, 10, 3);
assert_eq!(a.order, b.order, "order must be bit-deterministic");
assert_eq!(a.parents, b.parents, "parents must be bit-deterministic");
assert_eq!(a.cpt, b.cpt, "CPTs must be bit-deterministic");
}
#[test]
fn cpt_probabilities_strictly_interior() {
let p = BayesianNetworkParams::default_for(3);
let rows = vec![
0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, 0.0, ];
let state = refit(&p, rows, 6, 3);
for (v, table) in state.cpt.iter().enumerate() {
for (c, &prob) in table.iter().enumerate() {
assert!(
prob > 0.0 && prob < 1.0,
"cpt[{v}][{c}] = {prob} not strictly interior"
);
}
}
}
#[test]
fn samples_are_binary_and_finite() {
let p = BayesianNetworkParams::default_for(4);
let rows = vec![
0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0, ];
let state = refit(&p, rows, 4, 4);
let device = Default::default();
let mut rng = StdRng::seed_from_u64(7);
let samples = <BayesianNetwork as ProbabilityModel<TestBackend>>::sample(
&BayesianNetwork,
&state,
1000,
&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 {
assert!(v.is_finite(), "sampled gene must be finite, got {v}");
#[allow(clippy::float_cmp)]
let is_binary = v == 0.0 || v == 1.0;
assert!(is_binary, "non-binary gene {v}");
}
}
#[test]
fn recovers_pairwise_dependency() {
let p = BayesianNetworkParams::default_for(3);
let mut rows = Vec::with_capacity(20 * 3);
for i in 0..20 {
let g0 = if i < 10 { 0.0 } else { 1.0 };
let g1 = g0; let g2 = if i % 2 == 0 { 0.0 } else { 1.0 }; rows.push(g0);
rows.push(g1);
rows.push(g2);
}
let state = refit(&p, rows, 20, 3);
let edge_0_to_1 = state.parents[1] == vec![0];
let edge_1_to_0 = state.parents[0] == vec![1];
assert!(
edge_0_to_1 ^ edge_1_to_0,
"expected exactly one 0↔1 edge, parents = {:?}",
state.parents
);
assert!(state.parents[2].is_empty(), "gene2 must have no parents");
for ps in &state.parents {
assert!(!ps.contains(&2), "gene2 must not be a parent: {ps:?}");
}
let child = usize::from(edge_0_to_1);
assert_eq!(state.cpt[child].len(), 2, "child CPT has 2 cells");
assert!(
state.cpt[child][0] < 0.2,
"P(child=1 | parent=0) too high: {}",
state.cpt[child][0]
);
assert!(
state.cpt[child][1] > 0.8,
"P(child=1 | parent=1) too low: {}",
state.cpt[child][1]
);
}
#[test]
fn recovers_two_parent_dependency() {
let p = BayesianNetworkParams::default_for(3);
let mut rows = Vec::with_capacity(32 * 3);
for _ in 0..8 {
for &(a, b) in &[(0.0, 0.0), (0.0, 1.0), (1.0, 0.0), (1.0, 1.0)] {
let c = if a >= 0.5 && b >= 0.5 { 1.0 } else { 0.0 };
rows.push(a);
rows.push(b);
rows.push(c);
}
}
let state = refit(&p, rows, 32, 3);
assert_eq!(
state.parents[2],
vec![0, 1],
"gene2 must depend on both 0 and 1, got {:?}",
state.parents[2]
);
}
#[test]
fn independent_data_yields_no_edges() {
let p = BayesianNetworkParams::default_for(2);
let rows = vec![
0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0, ];
let state = refit(&p, rows, 4, 2);
for ps in &state.parents {
assert!(
ps.is_empty(),
"independent data must yield no edges: {ps:?}"
);
}
}
#[test]
fn max_parents_cap_respected() {
let mut p = BayesianNetworkParams::default_for(3);
p.max_parents = 1;
let mut rows = Vec::with_capacity(32 * 3);
for _ in 0..8 {
for &(a, b) in &[(0.0, 0.0), (0.0, 1.0), (1.0, 0.0), (1.0, 1.0)] {
let c = if a >= 0.5 && b >= 0.5 { 1.0 } else { 0.0 };
rows.push(a);
rows.push(b);
rows.push(c);
}
}
let state = refit(&p, rows, 32, 3);
for ps in &state.parents {
assert!(ps.len() <= 1, "max_parents=1 violated: {ps:?}");
}
}
#[test]
fn order_is_topological() {
let p = BayesianNetworkParams::default_for(3);
let mut rows = Vec::with_capacity(32 * 3);
for _ in 0..8 {
for &(a, b) in &[(0.0, 0.0), (0.0, 1.0), (1.0, 0.0), (1.0, 1.0)] {
let c = if a >= 0.5 && b >= 0.5 { 1.0 } else { 0.0 };
rows.push(a);
rows.push(b);
rows.push(c);
}
}
let state = refit(&p, rows, 32, 3);
let mut seen = state.order.clone();
seen.sort_unstable();
assert_eq!(seen, vec![0, 1, 2], "order must be a permutation of 0..d");
let position: Vec<usize> = {
let mut pos = vec![0usize; state.order.len()];
for (idx, &node) in state.order.iter().enumerate() {
pos[node] = idx;
}
pos
};
for (child, ps) in state.parents.iter().enumerate() {
for &parent in ps {
assert!(
position[parent] < position[child],
"parent {parent} must precede child {child} in {:?}",
state.order
);
}
}
}
#[test]
fn sampling_respects_learned_dependency() {
let p = BayesianNetworkParams::default_for(2);
let mut rows = Vec::with_capacity(20 * 2);
for i in 0..20 {
let g0 = if i < 10 { 0.0 } else { 1.0 };
rows.push(g0);
rows.push(g0); }
let state = refit(&p, rows, 20, 2);
let device = Default::default();
let mut rng = StdRng::seed_from_u64(123);
let n = 5000;
let samples = <BayesianNetwork as ProbabilityModel<TestBackend>>::sample(
&BayesianNetwork,
&state,
n,
&mut rng,
&device,
);
let data = samples
.into_data()
.into_vec::<f32>()
.expect("samples host-read of a tensor this test just built");
let mut agree = 0usize;
for i in 0..n {
if (data[i * 2] - data[i * 2 + 1]).abs() < 0.5 {
agree += 1;
}
}
#[allow(clippy::cast_precision_loss)]
let frac = agree as f64 / n as f64;
assert!(
frac > 0.9,
"sampled columns 0 and 1 should agree on > 90% of rows, got {frac}"
);
}
#[test]
fn nan_init_prob_clamped_on_prior() {
let mut p = BayesianNetworkParams::default_for(3);
p.init_prob = f32::NAN;
let state = fit_prior(&p);
for table in &state.cpt {
let v = table[0];
assert!(v.is_finite(), "clamped init_prob must be finite, got {v}");
assert!(
v > 0.0 && v < 1.0,
"clamped init_prob must be interior, got {v}"
);
}
}
#[test]
fn out_of_range_init_prob_clamped_on_prior() {
for bad in [1.5_f32, -0.3, f32::INFINITY] {
let mut p = BayesianNetworkParams::default_for(2);
p.init_prob = bad;
let state = fit_prior(&p);
for table in &state.cpt {
let v = table[0];
assert!(
v > 0.0 && v < 1.0,
"init_prob {bad} must clamp interior, got {v}"
);
}
}
}
#[test]
fn smoothing_count_zero_keeps_cpt_interior() {
let mut p = BayesianNetworkParams::default_for(1);
p.smoothing_count = 0;
let state = refit(&p, vec![1.0, 1.0, 1.0, 1.0], 4, 1);
let v = state.cpt[0][0];
assert!(
v > 0.0 && v < 1.0,
"s=0 must be floored to keep CPT interior, got {v}"
);
}
#[test]
fn cpt_sizes_and_parents_sorted_unique_over_random_fits() {
let p = BayesianNetworkParams::default_for(4);
let d = 4;
let n = 16;
let mut rng = StdRng::seed_from_u64(2024);
for _ in 0..30 {
let rows: Vec<f32> = (0..n * d)
.map(|_| if rng.random::<f32>() < 0.5 { 0.0 } else { 1.0 })
.collect();
let state = refit(&p, rows, n, d);
for v in 0..d {
assert_eq!(
state.cpt[v].len(),
1 << state.parents[v].len(),
"cpt[{v}] must have 2^|parents| cells, parents = {:?}",
state.parents[v]
);
for w in state.parents[v].windows(2) {
assert!(
w[0] < w[1],
"parents[{v}] must be strictly ascending (sorted & unique), got {:?}",
state.parents[v]
);
}
}
}
}
#[test]
fn single_gene_is_edgeless() {
let p = BayesianNetworkParams::default_for(1);
let state = refit(&p, vec![0.0, 1.0, 1.0, 0.0], 4, 1);
assert_eq!(state.order, vec![0], "single-gene order is natural");
assert!(state.parents[0].is_empty(), "single gene must be edgeless");
assert_eq!(state.cpt[0].len(), 1, "single-gene CPT is one cell");
}
#[test]
fn single_individual_population_yields_prior_shape() {
let p = BayesianNetworkParams::default_for(3);
let state = refit(&p, vec![1.0, 0.0, 1.0], 1, 3);
assert_eq!(state.order, vec![0, 1, 2], "order must be natural");
for v in 0..3 {
assert!(state.parents[v].is_empty(), "node {v} must be edgeless");
assert_eq!(state.cpt[v].len(), 1, "node {v} CPT must be one cell");
}
}
}