rlevo_evolution/algorithms/eda/bayesian_network.rs
1//! Bayesian-network model (BOA — Bayesian Optimization Algorithm) for binary
2//! search spaces.
3//!
4//! Unlike the univariate binary models ([`super::univariate_bernoulli`],
5//! [`super::compact_genetic`]) and the first-order chain of
6//! [`super::dependency_chain`], this model learns an arbitrary-topology directed
7//! acyclic graph (DAG) over the binary genes, bounded to at most
8//! [`BayesianNetworkParams::max_parents`] parents per node. [`fit`] greedily
9//! constructs the network by adding the single edge with the highest score gain
10//! each round; [`sample`] performs ancestral sampling along a topological order,
11//! drawing each gene from its conditional probability table (CPT) given the
12//! already-sampled parent configuration.
13//!
14//! The chain is built à la BOA (Pelikan, Goldberg & Cantú-Paz, 1999): starting
15//! from an edgeless network, the algorithm repeatedly scores every candidate
16//! edge `u → v` and commits the one with the largest strictly-positive gain,
17//! subject to the `max_parents` cap and an acyclicity check, until no profitable
18//! edge remains.
19//!
20//! The `fitness` tensor is accepted by the [`ProbabilityModel`] interface but
21//! always ignored; the fit is unweighted (the MIMIC precedent).
22//!
23//! # Non-incremental fit
24//!
25//! `prev` is consumed only as the *not-bootstrap* signal: when `prev = Some(_)`
26//! the whole network — structure and CPTs — is relearned from scratch from the
27//! current generation's selected rows. Canonical BOA carries no cross-generation
28//! state, so the previous [`BayesianNetworkState`] is never read. The `Some`
29//! arm exists purely to distinguish the learning path from the
30//! [`params`](BayesianNetworkParams)-only prior path.
31//!
32//! # Structure score: BIC
33//!
34//! Edges are scored with the Bayesian Information Criterion (BIC). For a node
35//! `v` with sorted parent set `Pa` (`q = |Pa|`), let `N(c, x)` be the number of
36//! selected rows in which the parents take packed configuration `c` and gene `v`
37//! takes bit `x ∈ {0, 1}`, and `N(c) = N(c, 0) + N(c, 1)`:
38//!
39//! ```text
40//! score(v, Pa) = Σ_c Σ_x N(c, x) · ln( N(c, x) / N(c) ) − (ln(n) / 2) · 2^q
41//! ```
42//!
43//! The log-likelihood term rewards parents that make `v` more predictable; the
44//! `−½·ln(n)·2^q` complexity term penalises CPT size (`2^q` cells), which grows
45//! exponentially in the parent count. This penalty is the structural analogue
46//! of [`super::dependency_chain`]'s `|r| < 2/√k` significance filter: both
47//! suppress spurious dependencies that a univariate model would never pay for,
48//! here by requiring an edge's likelihood improvement to outweigh the cost of
49//! doubling the child's CPT. The score is computed on **raw maximum-likelihood
50//! counts** — never the Laplace-smoothed counts used for CPT estimation — so the
51//! penalty is the sole overfitting guard. Terms with `N(c, x) = 0` contribute
52//! exactly `0` (the `p·ln p → 0` limit), and configurations with `N(c) = 0`
53//! contribute `0` likelihood while still counting toward the `2^q` penalty. All
54//! scoring arithmetic is performed in `f64`.
55//!
56//! # Parent-configuration bit-packing
57//!
58//! For a node `v` with `parents[v]` sorted ascending, a row's parent
59//! configuration is packed as `config = Σ_j bit(gene[parents[v][j]]) << j`:
60//! parent `j` (in sorted order) contributes bit `j`. [`fit`] and [`sample`] use
61//! the identical packing, so the CPT index computed at sampling time matches the
62//! one used during estimation. See the [`cpt`](BayesianNetworkState::cpt) field.
63//!
64//! # Complexity
65//!
66//! [`fit`] is `O(D² · N · κ)` per generation with gain caching: the first sweep
67//! scores all `D²` candidate edges (each an `O(N·κ)` counting pass), and after
68//! each accepted edge only the `D` entries sharing the affected child are
69//! rescored. It is fully host-side and sequential. [`sample`] is `O(D)` per
70//! drawn individual: one conditional Bernoulli draw per gene.
71//!
72//! # Binary gene convention
73//!
74//! Genes are emitted as raw `{0, 1}` `f32` values, so
75//! [`EdaParams::bounds`](crate::algorithms::eda::EdaParams::bounds) clamps are a
76//! documented no-op (the PBIL / cGA precedent).
77//!
78//! # References
79//!
80//! - Pelikan, Goldberg & Cantú-Paz (1999), *BOA: The Bayesian optimization
81//! algorithm*.
82//!
83//! [`fit`]: crate::ProbabilityModel::fit
84//! [`sample`]: crate::ProbabilityModel::sample
85
86use burn::tensor::{Tensor, TensorData, backend::Backend};
87use rand::{Rng, RngExt};
88
89use crate::probability_model::ProbabilityModel;
90
91/// Per-run configuration for the [`BayesianNetwork`] model.
92///
93/// Held inside [`EdaParams::model`](crate::algorithms::eda::EdaParams::model)
94/// for the lifetime of a run. Use [`BayesianNetworkParams::default_for`] for
95/// typical binary-optimisation defaults.
96#[derive(Debug, Clone)]
97pub struct BayesianNetworkParams {
98 /// Number of bits per genome; the number of nodes `D` in the network and
99 /// the length of [`BayesianNetworkState::order`] and
100 /// [`BayesianNetworkState::parents`].
101 pub genome_dim: usize,
102 /// Maximum number of parents per node (`κ`); bounds each node's CPT to
103 /// `2^κ` cells and caps the greedy edge-addition search.
104 pub max_parents: usize,
105 /// Prior marginal probability of a `1` gene, used to seed every edgeless
106 /// CPT on the prior path (`prev = None`).
107 pub init_prob: f32,
108 /// Laplace pseudo-count `s` added per CPT cell during estimation; `s ≥ 1`
109 /// keeps every probability strictly inside `(0, 1)`. Applies only to CPT
110 /// estimation for sampling, never to the BIC structure score. The value is
111 /// floored to `1` inside [`fit`](ProbabilityModel::fit), so a supplied `0`
112 /// is treated as `1` to uphold the strictly-interior guarantee.
113 pub smoothing_count: usize,
114}
115
116impl BayesianNetworkParams {
117 /// Sensible BOA defaults for a `genome_dim`-bit problem.
118 #[must_use]
119 pub fn default_for(genome_dim: usize) -> Self {
120 Self {
121 genome_dim,
122 max_parents: 3,
123 init_prob: 0.5,
124 smoothing_count: 1,
125 }
126 }
127}
128
129/// Fitted state for the [`BayesianNetwork`] model after one call to
130/// [`ProbabilityModel::fit`].
131///
132/// On the prior path (`prev = None`) the network is edgeless: `order` is the
133/// natural order `[0, 1, …, D-1]`, every `parents[v]` is empty, and every
134/// `cpt[v]` is the single-entry vector `[init_prob]`.
135#[derive(Debug, Clone)]
136pub struct BayesianNetworkState {
137 /// Topological sampling order: a permutation of `0..D` such that every
138 /// node appears after all of its parents. Ancestral sampling walks this
139 /// order so each gene's parents are already drawn.
140 pub order: Vec<usize>,
141 /// `parents[node]` is the node's parent index set, kept sorted ascending.
142 /// The sort defines the bit positions used by the CPT packing (see the
143 /// [module docs](self)).
144 pub parents: Vec<Vec<usize>>,
145 /// Conditional probability tables: `cpt[node][config]` is
146 /// `P(node = 1 | parents = config)`, where `config` is the bit-packed
147 /// parent configuration `Σ_j bit(parent_j) << j` over `parents[node]` in
148 /// sorted order. Each inner vector has length `2^|parents[node]|`.
149 pub cpt: Vec<Vec<f32>>,
150}
151
152/// Bayesian-network model for binary spaces (BOA).
153///
154/// Implements [`ProbabilityModel`] by greedily learning a bounded-in-degree DAG
155/// over the binary genes with a BIC structure score, then ancestral-sampling
156/// from the fitted CPTs (see the [module docs](self) for the algorithm, the BIC
157/// rationale, bit-packing, and references). Fitness is accepted but ignored; the
158/// fit is always unweighted and non-incremental.
159///
160/// [`fit`](ProbabilityModel::fit) is `O(D² · N · κ)`;
161/// [`sample`](ProbabilityModel::sample) is `O(D)` per individual.
162#[derive(Debug, Clone, Copy, Default)]
163pub struct BayesianNetwork;
164
165/// Build the edgeless prior state: natural order, no parents, single-cell CPTs
166/// initialised to `init_prob`.
167///
168/// `init_prob` is clamped into the open interior `(0, 1)` before it seeds the
169/// CPTs. This is the single chokepoint for every prior return, so a
170/// misconfigured or non-finite `init_prob` (e.g. `NaN`, `1.5`, `-0.3`) cannot
171/// silently produce a degenerate population during sampling. `NaN` maps to the
172/// neutral `0.5` (`f32::clamp` would *propagate* `NaN`); `±inf` clamp to the
173/// interior bounds.
174fn prior_state(d: usize, init_prob: f32) -> BayesianNetworkState {
175 let p = if init_prob.is_nan() {
176 0.5
177 } else {
178 init_prob.clamp(1e-6, 1.0 - 1e-6)
179 };
180 BayesianNetworkState {
181 order: (0..d).collect(),
182 parents: vec![Vec::new(); d],
183 cpt: vec![vec![p]; d],
184 }
185}
186
187/// Pack the parent configuration for node `v` from a single row's bits.
188///
189/// `parents` is `parents[v]` (sorted ascending); parent `j` contributes bit `j`.
190fn pack_config(bits: &[u8], row_base: usize, parents: &[usize]) -> usize {
191 let mut config = 0usize;
192 for (j, &p) in parents.iter().enumerate() {
193 if bits[row_base + p] == 1 {
194 config |= 1 << j;
195 }
196 }
197 config
198}
199
200/// BIC score of node `v` given the (sorted) candidate parent set `parents`.
201///
202/// Single pass over the `n` rows: pack each row's parent config and increment
203/// `counts[config * 2 + bit_v]`. The likelihood term sums
204/// `N(c, x) · ln(N(c, x) / N(c))` over occupied cells (zero counts skipped), and
205/// the `½·ln(n)·2^q` complexity penalty applies regardless of which configs were
206/// observed. All arithmetic is `f64`; scores use raw MLE counts.
207//
208// Single-char math names (n, d, v, q, c, x) mirror the BIC formula and the
209// sibling EDA models; spelling them out would obscure the algebra.
210#[allow(clippy::many_single_char_names)]
211fn bic_score(bits: &[u8], n: usize, d: usize, v: usize, parents: &[usize]) -> f64 {
212 let q = parents.len();
213 let num_configs = 1usize << q;
214 // counts[config * 2 + x] = N(config, x).
215 let mut counts = vec![0u32; num_configs * 2];
216 for i in 0..n {
217 let base = i * d;
218 let x = usize::from(bits[base + v]);
219 let config = pack_config(bits, base, parents);
220 counts[config * 2 + x] += 1;
221 }
222
223 let mut log_likelihood = 0.0_f64;
224 for c in 0..num_configs {
225 let count_0 = counts[c * 2];
226 let count_1 = counts[c * 2 + 1];
227 let count_total = count_0 + count_1;
228 if count_total == 0 {
229 // N(c) == 0: zero likelihood, but the config still counts toward the
230 // 2^q penalty below (that is the pressure keeping q small).
231 continue;
232 }
233 // f64::from(u32) is a lossless widening, not a lossy cast.
234 let total_f = f64::from(count_total);
235 for &count_x in &[count_0, count_1] {
236 if count_x == 0 {
237 // N(c, x) == 0 contributes exactly 0 (the p·ln p → 0 limit); no
238 // ln(0) path is ever reached.
239 continue;
240 }
241 let count_x_f = f64::from(count_x);
242 log_likelihood += count_x_f * (count_x_f / total_f).ln();
243 }
244 }
245
246 // Complexity penalty: ½·ln(n)·2^q over all 2^q configs.
247 #[allow(clippy::cast_precision_loss)]
248 let nf = n as f64;
249 #[allow(clippy::cast_precision_loss)]
250 let penalty = 0.5 * nf.ln() * (num_configs as f64);
251 log_likelihood - penalty
252}
253
254/// Insert `value` into the ascending-sorted vector `parents`, keeping it sorted.
255fn insert_sorted(parents: &mut Vec<usize>, value: usize) {
256 let pos = parents.partition_point(|&p| p < value);
257 parents.insert(pos, value);
258}
259
260/// Does adding edge `u → v` create a cycle?
261///
262/// A cycle appears iff `v` is already an ancestor of `u`. Iterative DFS upward
263/// from `u` through `parents[]`, looking for `v`. `O(D·κ)` per check.
264fn creates_cycle(parents: &[Vec<usize>], u: usize, v: usize) -> bool {
265 let d = parents.len();
266 let mut visited = vec![false; d];
267 let mut stack = vec![u];
268 while let Some(node) = stack.pop() {
269 if node == v {
270 return true;
271 }
272 if visited[node] {
273 continue;
274 }
275 visited[node] = true;
276 for &p in &parents[node] {
277 if !visited[p] {
278 stack.push(p);
279 }
280 }
281 }
282 false
283}
284
285/// Kahn's algorithm with deterministic minimum-index selection.
286///
287/// Repeatedly emits the smallest-index node whose remaining (unemitted) parent
288/// count is zero. The greedy loop guarantees the graph is a DAG.
289fn topological_order(parents: &[Vec<usize>]) -> Vec<usize> {
290 let d = parents.len();
291 let mut indegree: Vec<usize> = parents.iter().map(Vec::len).collect();
292 let mut emitted = vec![false; d];
293 let mut order = Vec::with_capacity(d);
294 while order.len() < d {
295 // Smallest-index node with indegree 0 not yet emitted.
296 let mut next = None;
297 for v in 0..d {
298 if !emitted[v] && indegree[v] == 0 {
299 next = Some(v);
300 break;
301 }
302 }
303 let Some(node) = next else { break };
304 emitted[node] = true;
305 order.push(node);
306 // Decrement indegree of every node that has `node` as a parent.
307 for (child, ps) in parents.iter().enumerate() {
308 if !emitted[child] && ps.contains(&node) {
309 indegree[child] -= 1;
310 }
311 }
312 }
313 order
314}
315
316impl<B: Backend> ProbabilityModel<B> for BayesianNetwork {
317 type Params = BayesianNetworkParams;
318 type State = BayesianNetworkState;
319
320 /// Fit the Bayesian network to the selected population.
321 ///
322 /// When `prev = None` returns the edgeless prior (natural order, empty
323 /// parent lists, single-cell CPTs at `init_prob`); `population` and
324 /// `fitness` are ignored. Otherwise the whole network is relearned from
325 /// scratch (the fit is non-incremental — `prev` is the not-bootstrap signal
326 /// only):
327 ///
328 /// 1. Bitizes the selected rows to `{0, 1}` via `>= 0.5`.
329 /// 2. Greedily adds the highest-BIC-gain edge each round (subject to the
330 /// `max_parents` cap and acyclicity) until no strictly-positive gain
331 /// remains, using a `D × D` gain cache.
332 /// 3. Estimates Laplace-smoothed CPTs from the final structure.
333 /// 4. Computes a deterministic topological order (Kahn, min-index).
334 ///
335 /// The `fitness` argument is accepted but always ignored.
336 ///
337 /// # Panics
338 ///
339 /// In release builds, panics if the `population` tensor cannot be read back
340 /// as `f32` (`.expect("population tensor must be readable as f32")`).
341 ///
342 /// In debug builds, additionally panics on the following `debug_assert`
343 /// checks (all disabled in release):
344 ///
345 /// - the population column count disagrees with `params.genome_dim`;
346 /// - `params.max_parents >= usize::BITS` (which would overflow the
347 /// `1usize << q` CPT table sizing);
348 /// - the closing topological order fails to cover all `D` nodes (guaranteed
349 /// by the DAG invariant).
350 // The counting passes, gain-cached greedy search, CPT estimation, and
351 // topological ordering form one coherent fit; splitting them would scatter
352 // the shared `bits`/`parents` buffers without aiding readability.
353 // Single-char math names (n, d, v, q, s, u) mirror the BIC/CPT formulae and
354 // the sibling EDA models; spelling them out would obscure the algebra.
355 #[allow(clippy::too_many_lines, clippy::many_single_char_names)]
356 fn fit(
357 &self,
358 params: &Self::Params,
359 prev: Option<&Self::State>,
360 population: Tensor<B, 2>,
361 fitness: Tensor<B, 1>,
362 device: &<B as burn::tensor::backend::BackendTypes>::Device,
363 ) -> Self::State {
364 let _ = device;
365 // Fitness is accepted but ignored: the fit is unweighted.
366 let _ = fitness;
367 let Some(_prev) = prev else {
368 // Prior path: edgeless network in natural order; population and
369 // fitness ignored. `prev` is consumed only as the bootstrap signal.
370 return prior_state(params.genome_dim, params.init_prob);
371 };
372
373 // Host extraction and bitization. The population's column count is the
374 // row stride for every counting pass below, so it — not
375 // `params.genome_dim` — is the authoritative `d` (mirrors
376 // `DependencyChain::fit`); the two must agree.
377 let [n, d] = population.dims();
378 debug_assert_eq!(
379 d, params.genome_dim,
380 "population column count must match params.genome_dim"
381 );
382 // CPT sizes are 2^q with q <= max_parents; a cap at or above the
383 // word width would overflow the `1usize << q` table sizing.
384 debug_assert!(
385 params.max_parents < usize::BITS as usize,
386 "max_parents must be below usize::BITS"
387 );
388 let rows = population
389 .into_data()
390 .into_vec::<f32>()
391 .expect("population tensor must be readable as f32");
392 if n == 0 {
393 // Degenerate input: nothing to learn, return the prior-shaped
394 // state (params-shaped, since a 0×0 tensor carries no width).
395 return prior_state(params.genome_dim, params.init_prob);
396 }
397 let bits: Vec<u8> = rows.iter().map(|&v| u8::from(v >= 0.5)).collect();
398
399 // Greedy structure learning with a D×D gain cache.
400 let mut parents: Vec<Vec<usize>> = vec![Vec::new(); d];
401 let mut base_score: Vec<f64> = (0..d).map(|v| bic_score(&bits, n, d, v, &[])).collect();
402
403 // gain_cache[u * d + v] = score(v, parents[v] ∪ {u}) − base_score[v],
404 // an exact recomputation. Entries are recomputed for child v* after each
405 // accepted edge; eligibility (cycle / cap / already-present) is checked
406 // live at selection time, so the cache holds only the score gain.
407 // NOTE: this `NEG_INFINITY` is a structure-score (BDeu/likelihood)
408 // gain sentinel for greedy edge maximisation — NOT objective fitness.
409 // It is independent of the crate's maximise convention; do not flip it.
410 let mut gain_cache = vec![f64::NEG_INFINITY; d * d];
411 // Helper closure would need to borrow `bits`/`parents`/`base_score`
412 // mutably and immutably; an inline recompute keeps borrows simple.
413 // Initial full sweep.
414 for u in 0..d {
415 for v in 0..d {
416 if u == v {
417 continue;
418 }
419 let mut cand = parents[v].clone();
420 insert_sorted(&mut cand, u);
421 gain_cache[u * d + v] = bic_score(&bits, n, d, v, &cand) - base_score[v];
422 }
423 }
424
425 loop {
426 // Select the eligible (u, v) with the maximal cached gain.
427 // Lexicographic (u, v) scan with strict '>' ⇒ first pair wins ties.
428 let mut best: Option<(f64, usize, usize)> = None;
429 for u in 0..d {
430 for v in 0..d {
431 if u == v
432 || parents[v].len() >= params.max_parents
433 || parents[v].contains(&u)
434 || creates_cycle(&parents, u, v)
435 {
436 continue;
437 }
438 let g = gain_cache[u * d + v];
439 if best.is_none_or(|(bg, _, _)| g > bg) {
440 best = Some((g, u, v));
441 }
442 }
443 }
444
445 let Some((gain, u, v)) = best else { break };
446 if gain <= 0.0 {
447 // Strictly-positive gain required to add an edge.
448 break;
449 }
450 insert_sorted(&mut parents[v], u);
451 base_score[v] += gain;
452
453 // Only entries with child == v are now stale; recompute just those.
454 for uu in 0..d {
455 if uu == v {
456 continue;
457 }
458 let mut cand = parents[v].clone();
459 if !cand.contains(&uu) {
460 insert_sorted(&mut cand, uu);
461 }
462 gain_cache[uu * d + v] = bic_score(&bits, n, d, v, &cand) - base_score[v];
463 }
464 }
465
466 // CPT estimation from the final structure: one counting pass per node.
467 // Floor the smoothing at 1 so every probability stays strictly inside
468 // `(0, 1)` (the field-doc guarantee): with `s ≥ 1`, `den = N(c) + 2s > 0`
469 // always, so the `0/0` case is unreachable and `count_1/count_total`
470 // cannot pin a cell to an absorbing `0.0`/`1.0`.
471 let s = params.smoothing_count.max(1);
472 let mut cpt: Vec<Vec<f32>> = Vec::with_capacity(d);
473 // Laplace pseudo-count as f64; `s` is a tiny smoothing constant, far
474 // below f64's exact-integer range, so the cast is lossless.
475 #[allow(clippy::cast_precision_loss)]
476 let s_f = s as f64;
477 for v in 0..d {
478 let q = parents[v].len();
479 let num_configs = 1usize << q;
480 let mut counts = vec![0u32; num_configs * 2];
481 for i in 0..n {
482 let base = i * d;
483 let x = usize::from(bits[base + v]);
484 let config = pack_config(&bits, base, &parents[v]);
485 counts[config * 2 + x] += 1;
486 }
487 let mut table = Vec::with_capacity(num_configs);
488 for c in 0..num_configs {
489 let count_1 = counts[c * 2 + 1];
490 let count_total = counts[c * 2] + count_1;
491 // (N(c,1) + s) / (N(c) + 2s); f64::from(u32) is lossless. With
492 // `s ≥ 1` the denominator is always positive, so no `0/0` guard
493 // is needed.
494 let num = f64::from(count_1) + s_f;
495 let den = f64::from(count_total) + 2.0 * s_f;
496 // Probability in (0, 1) for s ≥ 1; the f64→f32 narrowing of a
497 // value in [0, 1] cannot truncate meaningfully.
498 #[allow(clippy::cast_possible_truncation)]
499 let prob = (num / den) as f32;
500 table.push(prob);
501 }
502 cpt.push(table);
503 }
504
505 let order = topological_order(&parents);
506 debug_assert_eq!(order.len(), d, "topological order must cover all nodes");
507
508 BayesianNetworkState {
509 order,
510 parents,
511 cpt,
512 }
513 }
514
515 /// Draw `n` binary genomes by ancestral sampling along the topological order.
516 ///
517 /// Each gene `v` is sampled from `P(v = 1 | parents)` read out of its CPT at
518 /// the bit-packed parent configuration (parents already sampled, since the
519 /// traversal follows the topological order). Exactly one `rng.random::<f32>()`
520 /// call is consumed per gene regardless of structure, keeping RNG
521 /// consumption stable. Host RNG only (never `Tensor::random` / `B::seed`).
522 /// The returned tensor has shape `(n, D)` and contains only `0.0` and `1.0`.
523 fn sample(
524 &self,
525 state: &Self::State,
526 n: usize,
527 rng: &mut dyn Rng,
528 device: &<B as burn::tensor::backend::BackendTypes>::Device,
529 ) -> Tensor<B, 2> {
530 let d = state.parents.len();
531 let mut rows = vec![0.0_f32; n * d];
532 for i in 0..n {
533 let base = i * d;
534 for &v in &state.order {
535 let mut config = 0usize;
536 for (j, &p) in state.parents[v].iter().enumerate() {
537 if rows[base + p] >= 0.5 {
538 config |= 1 << j;
539 }
540 }
541 let p1 = state.cpt[v][config];
542 rows[base + v] = if rng.random::<f32>() < p1 { 1.0 } else { 0.0 };
543 }
544 }
545 Tensor::<B, 2>::from_data(TensorData::new(rows, [n, d]), device)
546 }
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552 use burn::backend::Flex;
553 use rand::SeedableRng;
554 use rand::rngs::StdRng;
555
556 type TestBackend = Flex;
557
558 fn pop(rows: Vec<f32>, n: usize, d: usize) -> Tensor<TestBackend, 2> {
559 let device = Default::default();
560 Tensor::<TestBackend, 2>::from_data(TensorData::new(rows, [n, d]), &device)
561 }
562
563 fn fitness(values: Vec<f32>) -> Tensor<TestBackend, 1> {
564 let device = Default::default();
565 let n = values.len();
566 Tensor::<TestBackend, 1>::from_data(TensorData::new(values, [n]), &device)
567 }
568
569 fn fit_prior(p: &BayesianNetworkParams) -> BayesianNetworkState {
570 let device = Default::default();
571 <BayesianNetwork as ProbabilityModel<TestBackend>>::fit(
572 &BayesianNetwork,
573 p,
574 None,
575 pop(vec![], 0, 0),
576 fitness(vec![]),
577 &device,
578 )
579 }
580
581 fn refit(
582 p: &BayesianNetworkParams,
583 rows: Vec<f32>,
584 n: usize,
585 d: usize,
586 ) -> BayesianNetworkState {
587 let device = Default::default();
588 let prior = fit_prior(p);
589 // Test row counts are tiny; the cast is lossless.
590 #[allow(clippy::cast_precision_loss)]
591 let fit_values: Vec<f32> = (0..n).map(|i| i as f32).collect();
592 <BayesianNetwork as ProbabilityModel<TestBackend>>::fit(
593 &BayesianNetwork,
594 p,
595 Some(&prior),
596 pop(rows, n, d),
597 fitness(fit_values),
598 &device,
599 )
600 }
601
602 #[test]
603 fn prior_is_edgeless_with_init_prob() {
604 let p = BayesianNetworkParams::default_for(3);
605 let state = fit_prior(&p);
606 assert_eq!(state.order, vec![0, 1, 2], "prior order is natural");
607 for ps in &state.parents {
608 assert!(ps.is_empty(), "prior parent lists must be empty");
609 }
610 for table in &state.cpt {
611 assert_eq!(table, &vec![0.5], "prior CPT is single-cell init_prob");
612 }
613 }
614
615 #[test]
616 fn two_fits_same_data_identical_state() {
617 let p = BayesianNetworkParams::default_for(3);
618 // gene1 = copy of gene0; gene2 a balanced, decorrelated pattern.
619 let rows = vec![
620 0.0, 0.0, 0.0, //
621 0.0, 0.0, 1.0, //
622 0.0, 0.0, 0.0, //
623 0.0, 0.0, 1.0, //
624 0.0, 0.0, 0.0, //
625 1.0, 1.0, 1.0, //
626 1.0, 1.0, 0.0, //
627 1.0, 1.0, 1.0, //
628 1.0, 1.0, 0.0, //
629 1.0, 1.0, 1.0, //
630 ];
631 let a = refit(&p, rows.clone(), 10, 3);
632 let b = refit(&p, rows, 10, 3);
633 assert_eq!(a.order, b.order, "order must be bit-deterministic");
634 assert_eq!(a.parents, b.parents, "parents must be bit-deterministic");
635 assert_eq!(a.cpt, b.cpt, "CPTs must be bit-deterministic");
636 }
637
638 #[test]
639 fn cpt_probabilities_strictly_interior() {
640 let p = BayesianNetworkParams::default_for(3);
641 // gene1 is constant 1 (constant column); Laplace smoothing must keep
642 // every CPT entry strictly inside (0, 1).
643 let rows = vec![
644 0.0, 1.0, 0.0, //
645 0.0, 1.0, 1.0, //
646 1.0, 1.0, 0.0, //
647 1.0, 1.0, 1.0, //
648 0.0, 1.0, 1.0, //
649 1.0, 1.0, 0.0, //
650 ];
651 let state = refit(&p, rows, 6, 3);
652 for (v, table) in state.cpt.iter().enumerate() {
653 for (c, &prob) in table.iter().enumerate() {
654 assert!(
655 prob > 0.0 && prob < 1.0,
656 "cpt[{v}][{c}] = {prob} not strictly interior"
657 );
658 }
659 }
660 }
661
662 #[test]
663 fn samples_are_binary_and_finite() {
664 let p = BayesianNetworkParams::default_for(4);
665 let rows = vec![
666 0.0, 0.0, 1.0, 1.0, //
667 1.0, 1.0, 0.0, 0.0, //
668 0.0, 1.0, 0.0, 1.0, //
669 1.0, 0.0, 1.0, 0.0, //
670 ];
671 let state = refit(&p, rows, 4, 4);
672 let device = Default::default();
673 let mut rng = StdRng::seed_from_u64(7);
674 let samples = <BayesianNetwork as ProbabilityModel<TestBackend>>::sample(
675 &BayesianNetwork,
676 &state,
677 1000,
678 &mut rng,
679 &device,
680 );
681 let data = samples
682 .into_data()
683 .into_vec::<f32>()
684 .expect("samples host-read of a tensor this test just built");
685 for v in data {
686 assert!(v.is_finite(), "sampled gene must be finite, got {v}");
687 // Exact float compare is correct: sample() writes literal 0.0/1.0.
688 #[allow(clippy::float_cmp)]
689 let is_binary = v == 0.0 || v == 1.0;
690 assert!(is_binary, "non-binary gene {v}");
691 }
692 }
693
694 #[test]
695 fn recovers_pairwise_dependency() {
696 // d=3, n=20: gene0 balanced (10 zeros then 10 ones), gene1 = copy of
697 // gene0, gene2 alternating within each half (zero correlation to gene0).
698 // BIC: dependence gain ≈ n·ln2 ≈ 13.9 vs penalty increment ½·ln20 ≈ 1.5
699 // ⇒ exactly one edge between 0 and 1; gene2 isolated.
700 let p = BayesianNetworkParams::default_for(3);
701 let mut rows = Vec::with_capacity(20 * 3);
702 for i in 0..20 {
703 let g0 = if i < 10 { 0.0 } else { 1.0 };
704 let g1 = g0; // exact copy
705 let g2 = if i % 2 == 0 { 0.0 } else { 1.0 }; // alternating, decorrelated
706 rows.push(g0);
707 rows.push(g1);
708 rows.push(g2);
709 }
710 let state = refit(&p, rows, 20, 3);
711 // Direction-agnostic single edge between 0 and 1.
712 let edge_0_to_1 = state.parents[1] == vec![0];
713 let edge_1_to_0 = state.parents[0] == vec![1];
714 assert!(
715 edge_0_to_1 ^ edge_1_to_0,
716 "expected exactly one 0↔1 edge, parents = {:?}",
717 state.parents
718 );
719 // gene2 has no parents and appears in nobody's parent list.
720 assert!(state.parents[2].is_empty(), "gene2 must have no parents");
721 for ps in &state.parents {
722 assert!(!ps.contains(&2), "gene2 must not be a parent: {ps:?}");
723 }
724 // The child's 2-entry CPT is ≈ [<0.2, >0.8] after smoothing.
725 let child = usize::from(edge_0_to_1);
726 assert_eq!(state.cpt[child].len(), 2, "child CPT has 2 cells");
727 assert!(
728 state.cpt[child][0] < 0.2,
729 "P(child=1 | parent=0) too high: {}",
730 state.cpt[child][0]
731 );
732 assert!(
733 state.cpt[child][1] > 0.8,
734 "P(child=1 | parent=1) too low: {}",
735 state.cpt[child][1]
736 );
737 }
738
739 #[test]
740 fn recovers_two_parent_dependency() {
741 // gene2 = gene0 AND gene1 over the four balanced combos repeated 8×.
742 let p = BayesianNetworkParams::default_for(3);
743 let mut rows = Vec::with_capacity(32 * 3);
744 for _ in 0..8 {
745 for &(a, b) in &[(0.0, 0.0), (0.0, 1.0), (1.0, 0.0), (1.0, 1.0)] {
746 let c = if a >= 0.5 && b >= 0.5 { 1.0 } else { 0.0 };
747 rows.push(a);
748 rows.push(b);
749 rows.push(c);
750 }
751 }
752 let state = refit(&p, rows, 32, 3);
753 // Assert specifically on gene2's parents (a 0↔1 edge, if added, is fine).
754 assert_eq!(
755 state.parents[2],
756 vec![0, 1],
757 "gene2 must depend on both 0 and 1, got {:?}",
758 state.parents[2]
759 );
760 }
761
762 #[test]
763 fn independent_data_yields_no_edges() {
764 // d=2, all four combinations equally → no detectable dependency.
765 let p = BayesianNetworkParams::default_for(2);
766 let rows = vec![
767 0.0, 0.0, //
768 0.0, 1.0, //
769 1.0, 0.0, //
770 1.0, 1.0, //
771 ];
772 let state = refit(&p, rows, 4, 2);
773 for ps in &state.parents {
774 assert!(
775 ps.is_empty(),
776 "independent data must yield no edges: {ps:?}"
777 );
778 }
779 }
780
781 #[test]
782 fn max_parents_cap_respected() {
783 // AND dataset with max_parents = 1: must complete and respect the cap.
784 let mut p = BayesianNetworkParams::default_for(3);
785 p.max_parents = 1;
786 let mut rows = Vec::with_capacity(32 * 3);
787 for _ in 0..8 {
788 for &(a, b) in &[(0.0, 0.0), (0.0, 1.0), (1.0, 0.0), (1.0, 1.0)] {
789 let c = if a >= 0.5 && b >= 0.5 { 1.0 } else { 0.0 };
790 rows.push(a);
791 rows.push(b);
792 rows.push(c);
793 }
794 }
795 let state = refit(&p, rows, 32, 3);
796 for ps in &state.parents {
797 assert!(ps.len() <= 1, "max_parents=1 violated: {ps:?}");
798 }
799 }
800
801 #[test]
802 fn order_is_topological() {
803 // After a structure-learning fit, order is a permutation of 0..d with
804 // every parent preceding its child.
805 let p = BayesianNetworkParams::default_for(3);
806 let mut rows = Vec::with_capacity(32 * 3);
807 for _ in 0..8 {
808 for &(a, b) in &[(0.0, 0.0), (0.0, 1.0), (1.0, 0.0), (1.0, 1.0)] {
809 let c = if a >= 0.5 && b >= 0.5 { 1.0 } else { 0.0 };
810 rows.push(a);
811 rows.push(b);
812 rows.push(c);
813 }
814 }
815 let state = refit(&p, rows, 32, 3);
816 // Permutation check.
817 let mut seen = state.order.clone();
818 seen.sort_unstable();
819 assert_eq!(seen, vec![0, 1, 2], "order must be a permutation of 0..d");
820 // Parent precedes child.
821 let position: Vec<usize> = {
822 let mut pos = vec![0usize; state.order.len()];
823 for (idx, &node) in state.order.iter().enumerate() {
824 pos[node] = idx;
825 }
826 pos
827 };
828 for (child, ps) in state.parents.iter().enumerate() {
829 for &parent in ps {
830 assert!(
831 position[parent] < position[child],
832 "parent {parent} must precede child {child} in {:?}",
833 state.order
834 );
835 }
836 }
837 }
838
839 #[test]
840 fn sampling_respects_learned_dependency() {
841 // Fit the copy dataset, sample 5000, columns 0 and 1 agree on > 90%.
842 let p = BayesianNetworkParams::default_for(2);
843 let mut rows = Vec::with_capacity(20 * 2);
844 for i in 0..20 {
845 let g0 = if i < 10 { 0.0 } else { 1.0 };
846 rows.push(g0);
847 rows.push(g0); // gene1 = copy of gene0
848 }
849 let state = refit(&p, rows, 20, 2);
850 let device = Default::default();
851 let mut rng = StdRng::seed_from_u64(123);
852 let n = 5000;
853 let samples = <BayesianNetwork as ProbabilityModel<TestBackend>>::sample(
854 &BayesianNetwork,
855 &state,
856 n,
857 &mut rng,
858 &device,
859 );
860 let data = samples
861 .into_data()
862 .into_vec::<f32>()
863 .expect("samples host-read of a tensor this test just built");
864 let mut agree = 0usize;
865 for i in 0..n {
866 if (data[i * 2] - data[i * 2 + 1]).abs() < 0.5 {
867 agree += 1;
868 }
869 }
870 // Tiny counts vs f64 exact range; lossless.
871 #[allow(clippy::cast_precision_loss)]
872 let frac = agree as f64 / n as f64;
873 assert!(
874 frac > 0.9,
875 "sampled columns 0 and 1 should agree on > 90% of rows, got {frac}"
876 );
877 }
878
879 #[test]
880 fn nan_init_prob_clamped_on_prior() {
881 // A non-finite init_prob must not propagate into the CPTs (#129): the
882 // prior clamps it into the open interior (0, 1).
883 let mut p = BayesianNetworkParams::default_for(3);
884 p.init_prob = f32::NAN;
885 let state = fit_prior(&p);
886 for table in &state.cpt {
887 let v = table[0];
888 assert!(v.is_finite(), "clamped init_prob must be finite, got {v}");
889 assert!(
890 v > 0.0 && v < 1.0,
891 "clamped init_prob must be interior, got {v}"
892 );
893 }
894 }
895
896 #[test]
897 fn out_of_range_init_prob_clamped_on_prior() {
898 for bad in [1.5_f32, -0.3, f32::INFINITY] {
899 let mut p = BayesianNetworkParams::default_for(2);
900 p.init_prob = bad;
901 let state = fit_prior(&p);
902 for table in &state.cpt {
903 let v = table[0];
904 assert!(
905 v > 0.0 && v < 1.0,
906 "init_prob {bad} must clamp interior, got {v}"
907 );
908 }
909 }
910 }
911
912 #[test]
913 fn smoothing_count_zero_keeps_cpt_interior() {
914 // s = 0 with a constant-1 column would give count_1/count_total = 1.0
915 // (an absorbing gene) without the floor. Flooring s at 1 keeps it in
916 // (0, 1). Single gene, all ones ⇒ one CPT cell.
917 let mut p = BayesianNetworkParams::default_for(1);
918 p.smoothing_count = 0;
919 let state = refit(&p, vec![1.0, 1.0, 1.0, 1.0], 4, 1);
920 let v = state.cpt[0][0];
921 assert!(
922 v > 0.0 && v < 1.0,
923 "s=0 must be floored to keep CPT interior, got {v}"
924 );
925 }
926
927 #[test]
928 fn cpt_sizes_and_parents_sorted_unique_over_random_fits() {
929 // §7.3: over many seeded random fits, every node's CPT must have exactly
930 // 2^|parents| cells and its parent list must be strictly ascending (hence
931 // sorted and duplicate-free). These are structural invariants of `fit`.
932 let p = BayesianNetworkParams::default_for(4);
933 let d = 4;
934 let n = 16;
935 let mut rng = StdRng::seed_from_u64(2024);
936 for _ in 0..30 {
937 let rows: Vec<f32> = (0..n * d)
938 .map(|_| if rng.random::<f32>() < 0.5 { 0.0 } else { 1.0 })
939 .collect();
940 let state = refit(&p, rows, n, d);
941 for v in 0..d {
942 assert_eq!(
943 state.cpt[v].len(),
944 1 << state.parents[v].len(),
945 "cpt[{v}] must have 2^|parents| cells, parents = {:?}",
946 state.parents[v]
947 );
948 for w in state.parents[v].windows(2) {
949 assert!(
950 w[0] < w[1],
951 "parents[{v}] must be strictly ascending (sorted & unique), got {:?}",
952 state.parents[v]
953 );
954 }
955 }
956 }
957 }
958
959 #[test]
960 fn single_gene_is_edgeless() {
961 // §7.2: genome_dim == 1 has no candidate edges; the single gene must be
962 // parentless with a one-cell CPT, regardless of the data.
963 let p = BayesianNetworkParams::default_for(1);
964 let state = refit(&p, vec![0.0, 1.0, 1.0, 0.0], 4, 1);
965 assert_eq!(state.order, vec![0], "single-gene order is natural");
966 assert!(state.parents[0].is_empty(), "single gene must be edgeless");
967 assert_eq!(state.cpt[0].len(), 1, "single-gene CPT is one cell");
968 }
969
970 #[test]
971 fn single_individual_population_yields_prior_shape() {
972 // §7.2: n == 1. With one row the BIC complexity penalty ½·ln(1)·2^q is 0
973 // and every config's likelihood term is 0, so no edge yields positive
974 // gain: the learned structure is edgeless and prior-shaped (natural
975 // order, empty parent lists, single-cell CPTs).
976 let p = BayesianNetworkParams::default_for(3);
977 let state = refit(&p, vec![1.0, 0.0, 1.0], 1, 3);
978 assert_eq!(state.order, vec![0, 1, 2], "order must be natural");
979 for v in 0..3 {
980 assert!(state.parents[v].is_empty(), "node {v} must be edgeless");
981 assert_eq!(state.cpt[v].len(), 1, "node {v} CPT must be one cell");
982 }
983 }
984}