ABCDE example¶
This example demonstrates how TBN performs inference on a Bayesian network that combines discrete and continuous variables, using tensorised Monte Carlo sampling and adaptive MCMC.
The example is intentionally minimal, but it exercises the full workflow: model definition, evidence conditioning, scalable inference, and posterior analysis.
Model structure¶
The Bayesian network consists of six variables:
A: discrete, two states
B: discrete, three states
C: continuous, conditioned on (A, B)
OC: continuous observation of C
D: binary
E: continuous, deterministic function of (C, D)
The BN graph is:
OC is shaded as an observed variable.
Task¶
Given multiple noisy observations of OC, the task is to infer the posterior distributions of A, B, and C, i.e. \(p(A, B, C \mid OC=\text{evidence})\).
Step 0: Define custom variables and probability distributions¶
A, B, and D are discrete, tractable (i.e. small state-space) variables, so they use built-in CPT classes.
C, OC, and E are continuous (or non-tabular) variables, requiring custom probability models:
C | A, B: Gaussian distribution with mean and variance depending on the discrete parents (A, B):
1import torch 2from torch.distributions import Normal 3 4from tbnpy.probability import Probability 5 6class C(Probability): 7 def __init__(self, childs, parents, sigma=0.6, device="cpu"): 8 """ 9 C | A,B ~ Normal(A + B, sigma^2) 10 11 childs : [Variable C] 12 parents : [Variable A, Variable B] 13 sigma : fixed noise std (float) 14 """ 15 super().__init__(childs, parents, device) # sets childs, parents, device 16 17 self.sigma = float(sigma) 18 19 # parent variables 20 self.A = parents[0] 21 self.B = parents[1] 22 23 # value lookup tables 24 self.A_values = torch.tensor( 25 self.A.values, dtype=torch.float32, device=device 26 ) 27 self.B_values = torch.tensor( 28 self.B.values, dtype=torch.float32, device=device 29 ) 30 31 # ------------------------------------------------------------------ 32 def form(self): 33 """What repr(), str() and tbnpy.describe() print as this object's form.""" 34 return f"normal(loc={self.A.name} + {self.B.name}, scale={self.sigma:g})" 35 36 # ------------------------------------------------------------------ 37 def sample(self, Cs_pars): 38 """ 39 Cs_pars : (N, 2) 40 Cs_pars[:,0] = A index 41 Cs_pars[:,1] = B index 42 43 Returns 44 ------- 45 Cs : (N,) sampled C values 46 logp : (N,) log p(C | A,B) 47 """ 48 Cs_pars = Cs_pars.to(self.device).long() 49 50 A_idx = Cs_pars[:, 0] 51 B_idx = Cs_pars[:, 1] 52 53 mean = self.A_values[A_idx] + self.B_values[B_idx] 54 std = torch.full_like(mean, self.sigma) 55 56 dist = Normal(mean, std) 57 Cs = dist.sample() 58 logp = dist.log_prob(Cs) 59 60 return Cs, logp 61 62 # ------------------------------------------------------------------ 63 def log_prob(self, Cs): 64 """ 65 Cs : (N, 3) 66 Cs[:,0] = C value 67 Cs[:,1] = A index 68 Cs[:,2] = B index 69 70 Returns 71 ------- 72 log p(C | A,B) : (N,) 73 """ 74 Cs = Cs.to(self.device) 75 76 C_val = Cs[:, 0] 77 A_idx = Cs[:, 1].long() 78 B_idx = Cs[:, 2].long() 79 80 mean = self.A_values[A_idx] + self.B_values[B_idx] 81 std = torch.full_like(mean, self.sigma) 82 83 dist = Normal(mean, std) 84 return dist.log_prob(C_val)
OC | C: Noisy observation model with Gaussian noise:
1import torch 2from torch.distributions import Normal 3 4from tbnpy.probability import Probability 5 6class OC(Probability): 7 def __init__(self, childs, parents, device='cpu'): 8 """ 9 childs: [OC variable] 10 parents: [C variable] 11 OC = C + Normal(0, 0.5^2) 12 """ 13 super().__init__(childs, parents, device) 14 15 # standard deviation is fixed 16 self.std = torch.tensor(0.5, device=device) 17 18 # -------------------------------------------------------------- 19 def form(self): 20 return f"normal(loc={self.parents[0].name}, scale={self.std.item():g})" 21 22 # -------------------------------------------------------------- 23 def sample(self, Cs_pars): 24 """ 25 Cs_pars: tensor (N, 1) 26 Cs_pars[:,0] = C value 27 28 Returns: 29 OC samples of shape (N,) 30 """ 31 Cs_pars = Cs_pars.to(self.device) 32 C_val = Cs_pars[:, 0] 33 34 dist = Normal(C_val, self.std) 35 Cs = dist.rsample() # (N,) 36 ps = dist.log_prob(Cs) # (N,) 37 38 return Cs, ps 39 40 # -------------------------------------------------------------- 41 def log_prob(self, Cs): 42 """ 43 Cs: tensor (N, 2) 44 Cs[:,0] = observed OC value 45 Cs[:,1] = C value 46 47 Returns: 48 log p(OC | C), tensor shape (N,) 49 """ 50 Cs = Cs.to(self.device) 51 52 OC_val = Cs[:, 0] 53 C_val = Cs[:, 1] 54 55 dist = Normal(C_val, self.std) 56 return dist.log_prob(OC_val) # (N,)
E | C, D: Deterministic relation defined by a function:
1import torch 2 3from tbnpy.probability import Probability 4 5class E(Probability): 6 def __init__(self, childs, parents, device='cpu'): 7 """ 8 childs: list [E] (unused but consistent with your structure) 9 parents: list [C, D] 10 C: continuous-valued parent (tensor-like values for samples) 11 D: binary parent (0 or 1) 12 """ 13 super().__init__(childs, parents, device) 14 15 # ------------------------------------------------------------------ 16 def form(self): 17 return "deterministic: E = C if D == 0, else 0" 18 19 # ------------------------------------------------------------------ 20 def sample(self, Cs_pars): 21 """ 22 Cs_pars: (N, 2) 23 Cs_pars[:,0] = C value (float) 24 Cs_pars[:,1] = D index (0 or 1) 25 26 Returns: 27 E samples (N,) 28 """ 29 Cs_pars = Cs_pars.to(self.device) 30 31 C_val = Cs_pars[:, 0] 32 D_idx = Cs_pars[:, 1].long() 33 34 # E = C if D==0, else 0 35 Cs = torch.where(D_idx == 0, C_val, torch.zeros_like(C_val)) 36 37 # deterministic function, i.e. P(E | C, D) = 1 38 n_sample = Cs_pars.shape[0] 39 ps = torch.log(torch.ones(n_sample,)).to(self.device) 40 41 return Cs, ps 42 43 # ------------------------------------------------------------------ 44 def log_prob(self, Es): 45 """ 46 Es: shape (N, 3) 47 Es[:,0] = E value 48 Es[:,1] = C value 49 Es[:,2] = D state (0 or 1) 50 51 Returns: 52 log p(E | C, D) of shape (N,) 53 """ 54 55 Es = Es.to(self.device) 56 57 E_val = Es[:, 0] 58 C_val = Es[:, 1] 59 D_idx = Es[:, 2].long() 60 61 # Deterministic rule: valid_E = C if D==0 else 0 62 expected_E = torch.where(D_idx == 0, C_val, torch.zeros_like(C_val)) 63 64 # Valid if E_val == expected_E 65 is_valid = (E_val == expected_E) 66 67 # log 1 = 0 for valid, log 0 = -inf for invalid 68 logp = torch.where(is_valid, torch.zeros_like(E_val), torch.full_like(E_val, -float("inf"))) 69 70 return logp
Important notes¶
A custom probability model class should include (at minimum) the following methods:
__init__defines core properties such aschilds,parents, anddevice(cpuorgpu). Additional properties may be added as needed.samplegenerates samples of child variables conditioned on given samples of the parent nodes.Inputs:
selfandCs_pars, whereCs_parsis a tensor of shape(num_samples, num_parents)storing realisations of the parent variables.Returns: - (recommended) a tensor of child samples of shape
(num_samples, num_childs)- a tensor of log-probabilities of shape(num_samples,)for the generated samples
log_probcomputes the log-probability of given samples.Inputs:
selfandCs, whereCsis a tensor of shape(num_samples, num_childs + num_parents)storing realisations of both child (first) and parent (second) variables.Returns: a tensor of shape
(num_samples,)storing log-probabilities.
All methods should be compatible with both CPU and GPU tensors and should use PyTorch tensor operations for efficiency.
The three classes inherit tbnpy.Probability.
This is optional (tbnpy accepts any class with the attributes and methods above), but
it makes each object print as the distribution it defines. Calling
super().__init__(childs, parents, device) sets childs, parents and device,
and each class says what its distribution is in a one-line form() method.
Step 1: defining variables and probability models¶
The file s1_define_model.py defines:
Variables (discrete vs. continuous)
Conditional probability objects for each node
In particular:
P(A) and P(B) are defined using categorical CPTs
P(C | A, B) follows a Gaussian model
P(OC | C) is a noisy observation model
P(E | C, D) is deterministic
This separation between variables and probability objects allows TBN to mix arbitrary discrete, continuous, and deterministic relations within a single network.
The full model definition is shown below.
1import os, sys
2BASE = os.path.dirname(os.path.abspath(__file__))
3sys.path.append(BASE)
4
5repo_root = os.path.abspath(os.path.join(BASE, "../.."))
6if repo_root not in sys.path:
7 sys.path.append(repo_root)
8
9import tbnpy
10from tbnpy import cpt, variable
11import numpy as np
12import torch
13
14import c, oc, e
15
16device = ('cuda' if os.environ.get('USE_CUDA', '0') == '1' else 'cpu')
17
18def define_variables():
19 varis = {}
20 varis['A'] = variable.Variable(name='A', values=[-0.3, 0.3])
21 varis['B'] = variable.Variable(name='B', values=[-0.2, 0.0, 0.2])
22 varis['C'] = variable.Variable(name='C', values=(-torch.inf, torch.inf)) # Continuous
23 varis['OC'] = variable.Variable(name='OC', values=(-torch.inf, torch.inf)) # Continuous
24 varis['D'] = variable.Variable(name='D', values=[0, 1]) # Binary
25 varis['E'] = variable.Variable(name='E', values=(-torch.inf, torch.inf)) # Continuous
26
27 return varis
28
29def define_probs(varis, device='cpu'):
30 probs = {}
31
32 #probs['A'] = cpt.Cpt(childs=[varis['A']], C=np.array([[0], [1]]), p=np.array([0.1, 0.9]), device=device)
33 probs['A'] = cpt.Cpt(childs=[varis['A']], C=np.array([[0], [1]]), p=np.array([0.5, 0.5]), device=device)
34 #probs['B'] = cpt.Cpt(childs=[varis['B']], C=np.array([[0], [1], [2]]), p=np.array([0.05, 0.15, 0.80]), device=device)
35 probs['B'] = cpt.Cpt(childs=[varis['B']], C=np.array([[0], [1], [2]]), p=np.array([0.3, 0.4, 0.3]), device=device)
36
37 probs['C'] = c.C(childs=[varis['C']], parents=[varis['A'], varis['B']], device=device)
38 probs['OC'] = oc.OC(childs=[varis['OC']], parents=[varis['C']], device=device)
39
40 probs['D'] = cpt.Cpt(childs=[varis['D']], C=np.array([[0], [1]]), p=np.array([0.4, 0.6]), device=device)
41
42 probs['E'] = e.E(childs=[varis['E']], parents=[varis['C'], varis['D']], device=device)
43
44 return probs
45
46if __name__ == '__main__':
47 varis = define_variables()
48 probs = define_probs(varis, device=device)
49
50 # Typing a name shows what it is: every Variable and every probability object
51 # prints as one line.
52 print(varis)
53 print(probs)
54
55 # str() (or print) gives the longer reading: each variable's domain, and a
56 # Cpt's table with its state labels.
57 print(probs['C'])
58 print(probs['B'])
59
60 # Sample a few rows through the whole model to confirm that every object keeps
61 # tbnpy's protocol, before running inference on it.
62 print(tbnpy.check_model(probs))
63
Inspecting and checking the model¶
Running python examples/ABCDE/s1_define_model.py prints what was defined. Each
variable and each probability object reads as one line:
{'A': Variable(name='A', values=[-0.3, 0.3]), 'B': Variable(name='B', values=[-0.2, 0.0, 0.2]),
'C': Variable(name='C', values=(-inf, inf)), 'OC': Variable(name='OC', values=(-inf, inf)),
'D': Variable(name='D', values=[0, 1]), 'E': Variable(name='E', values=(-inf, inf))}
{'A': Cpt(P(A): table of 2 rows), 'B': Cpt(P(B): table of 3 rows),
'C': C(P(C | A, B): normal(loc=A + B, scale=0.6)), 'OC': OC(P(OC | C): normal(loc=C, scale=0.5)),
'D': Cpt(P(D): table of 2 rows), 'E': E(P(E | C, D): deterministic: E = C if D == 0, else 0)}
print gives one object at length, with each variable’s domain and, for a
Cpt, its table with state labels:
P(C | A, B) [C]
form normal(loc=A + B, scale=0.6)
child C continuous (-inf, inf)
parent A discrete, 2 states: -0.3, 0.3
parent B discrete, 3 states: -0.2, 0.0, 0.2
P(B) [Cpt]
form table of 3 rows
child B discrete, 3 states: -0.2, 0.0, 0.2
B p
-0.2 0.3
0.0 0.4
0.2 0.3
Last, tbnpy.check_model samples a few rows
through the whole model and confirms that every object keeps tbnpy’s protocol: the
right shapes, values inside each variable’s domain, and a log_prob that agrees with
sample. Had C.log_prob read its columns in the wrong order, it would say so
here rather than through a wrong posterior later:
Model check: 6 probability objects, 6 variables, 8 sampled rows: no problems found
The same model as a spec¶
The model can also be written as data. abcde.tbn.json describes the same six
variables and nodes, with C and OC as distribution nodes, E as an
equation node, and A, B and D as discrete_cpt nodes
(see tbnpy.spec):
{
"name": "ABCDE",
"description": "The ABCDE toy network of s1_define_model.py, written declaratively. Compiling it gives the same model as the hand-written classes c.py, oc.py and e.py.",
"variables": [
{"name": "A", "kind": "discrete", "states": [-0.3, 0.3], "meaning": "first additive shift"},
{"name": "B", "kind": "discrete", "states": [-0.2, 0.0, 0.2], "meaning": "second additive shift"},
{"name": "C", "kind": "continuous", "meaning": "the quantity of interest"},
{"name": "OC", "kind": "continuous", "meaning": "a noisy observation of C"},
{"name": "D", "kind": "discrete", "states": [0, 1], "meaning": "gate: 0 passes C through to E, 1 blocks it"},
{"name": "E", "kind": "continuous", "meaning": "C when the gate is open, otherwise 0"}
],
"nodes": [
{"name": "A", "type": "discrete_cpt", "parents": [], "probabilities": [0.5, 0.5]},
{"name": "B", "type": "discrete_cpt", "parents": [], "probabilities": [0.3, 0.4, 0.3]},
{"name": "C", "type": "distribution", "parents": ["A", "B"], "family": "normal",
"parameters": {"loc": "A + B", "scale": "0.6"},
"meaning": "C | A, B ~ Normal(A + B, 0.6^2)"},
{"name": "OC", "type": "distribution", "parents": ["C"], "family": "normal",
"parameters": {"loc": "C", "scale": "0.5"},
"meaning": "OC | C ~ Normal(C, 0.5^2)"},
{"name": "D", "type": "discrete_cpt", "parents": [], "probabilities": [0.4, 0.6]},
{"name": "E", "type": "equation", "parents": ["C", "D"], "equation": "If(D == 0, C, 0)",
"meaning": "E = C if D == 0, else 0"}
],
"analysis": {
"query_nodes": ["C", "E"],
"observed_nodes": ["OC"],
"notes": "Condition on OC with tbnpy.inference.sample_evidence; E is deterministic, so observe OC rather than E."
}
}
s1_define_model_from_spec.py compiles it with tbnpy.compile.compile_file(),
prints the compiled nodes, checks them, and confirms that they give the same numbers
as the hand-written classes. Everything that follows runs on either model.
1"""
2The ABCDE model again, written as data rather than as classes.
3
4abcde.tbn.json describes the same six variables and six nodes as
5s1_define_model.py: C and OC are `distribution` nodes, E is an `equation` node,
6and A, B and D are `discrete_cpt` nodes. tbnpy.compile turns it into the same
7(varis, probs) pair, so everything downstream (inference, adaptiveMH,
8diagnostics) runs on it unchanged.
9"""
10
11import os, sys
12BASE = os.path.dirname(os.path.abspath(__file__))
13
14repo_root = os.path.abspath(os.path.join(BASE, "../.."))
15if repo_root not in sys.path:
16 sys.path.append(repo_root)
17
18import torch
19
20import tbnpy
21from tbnpy import inference
22from tbnpy.compile import compile_file
23
24SPEC = os.path.join(BASE, "abcde.tbn.json")
25
26
27def define_model(device="cpu"):
28 """Return (spec, varis, probs) compiled from abcde.tbn.json."""
29 return compile_file(SPEC, device=device)
30
31
32if __name__ == "__main__":
33 spec, varis, probs = define_model()
34
35 # Each compiled node prints as the distribution it defines, with its form
36 # taken from the spec: a family and its parameters, an equation, or a table.
37 print(probs)
38 print(probs["E"])
39
40 # The compiled model keeps tbnpy's protocol, like the hand-written one.
41 print(tbnpy.check_model(probs))
42
43 # And it gives the same numbers: compare with s1_define_model.py.
44 from s1_define_model import define_probs, define_variables
45 hand = define_probs(define_variables())
46 rows = torch.tensor([[0.5, 1.0, 2.0], [-1.0, 0.0, 0.0]]) # [C | A, B]
47 print("log p(C | A, B), compiled: ", probs["C"].log_prob(rows))
48 print("log p(C | A, B), hand-written:", hand["C"].log_prob(rows))
49
50 out = inference.sample(probs, ["E"], n_sample=10_000)
51 print("P(E == 0) ~", (out["E"].Cs == 0).float().mean().item(), "(D = 1 with probability 0.6)")
Its output begins:
{'A': Cpt(P(A): table of 2 rows), 'B': Cpt(P(B): table of 3 rows),
'C': DistributionSpecNode(P(C | A, B): normal(loc=A + B, scale=0.6)),
'OC': DistributionSpecNode(P(OC | C): normal(loc=C, scale=0.5)),
'D': Cpt(P(D): table of 2 rows), 'E': EquationSpecNode(P(E | C, D): E = If(D == 0, C, 0))}
Step 2: evidence and scalable inference¶
The file s2_run_sample.py performs inference conditioned on evidence
for OC.
Key steps are:
Prior sampling — forward inference
Forward inference (i.e. inference without any evidence) can be performed using
inference.sample_evidencewith an empty evidence dictionary. See the functionsample_prior.Posterior inference — MCMC sampling
Evidence definition. Multiple observations of OC are generated and stored in a tabular format, allowing batched conditioning.
Forward sampling initialisation. Initial MCMC chains are generated using forward sampling from the prior, improving stability and convergence.
Adaptive MCMC. An adaptive Metropolis–Hastings sampler is used to infer the posterior of (A, B, C) given the evidence, i.e. P(A, B, C | OC=oc).
TBN evaluates many Monte Carlo samples simultaneously by reformulating inference computations as tensor operations. This enables efficient scaling across chains, evidence rows, and iterations.
import os, sys
from pathlib import Path
BASE = os.path.dirname(os.path.abspath(__file__))
RESULTS = Path(__file__).parent / "results"
sys.path.append(BASE)
repo_root = os.path.abspath(os.path.join(BASE, "../.."))
if repo_root not in sys.path:
sys.path.append(repo_root)
import torch
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from tbnpy import inference, adaptiveMH
from s1_define_model import define_variables, define_probs
"""
Overall structure:
s2_run_sample.py
├─ define variables & probs (already done)
├─ define evidence
├─ forward sampling (initialisation)
├─ adaptive MH run
├─ posterior extraction
└─ plotting
"""
def define_evidence(n_evi=10, seed=123):
"""
Evidence DataFrame: shape (n_evi, n_evidence_vars)
OC ~ Normal(0, 0.15)
"""
rng = np.random.default_rng(seed)
evidence = pd.DataFrame({
"OC": rng.normal(loc=-0.3, scale=0.05, size=n_evi)
})
return evidence
def sample_prior(probs, variables, n_sample=5000):
"""
Sample prior for all variables using forward sampling.
Parameters
----------
probs : dict
BN probability objects
variables : dict or list
Variable objects
n_sample : int
Number of prior samples
Returns
-------
dict[var_name -> np.ndarray]
Each array has shape (n_sample,)
"""
# one dummy evidence row (no conditioning)
evidence = pd.DataFrame(index=[0])
# ensure variable list
if isinstance(variables, dict):
var_list = list(variables.values())
else:
var_list = list(variables)
query_nodes = [v.name for v in var_list]
probs_copy = inference.sample_evidence(
probs=probs,
query_nodes=query_nodes,
n_sample=n_sample,
evidence_df=evidence,
)
# container for prior samples
prior = {}
for prob in probs_copy.values():
Cs = prob.Cs # shape (1, n_sample, dim)
for j, child_var in enumerate(prob.childs):
name = child_var.name
prior[name] = Cs[0, :, j].detach().cpu().numpy()
return prior
def forward_initialise(probs, latent_vars, evidence, n_chain):
"""
Use forward sampling to initialise MCMC chains.
"""
probs_copy = inference.sample_evidence(
probs=probs,
query_nodes=[v.name for v in latent_vars],
n_sample=n_chain,
evidence_df=evidence,
)
return probs_copy
def run_mcmc(probs, varis, evidence, update_blocks, burnin=200, n_chain=5000, n_iter=2000, progress_every=100):
sampler = adaptiveMH.HybridAdaptiveMH(
probs=probs,
variables=list(varis.values()),
evidence_df=evidence,
n_chain=n_chain,
adapt=adaptiveMH.AdaptConfig(
burnin=burnin,
gamma=0.6,
target_accept=0.234,
alpha=0.5,
),
)
# --- Initialise from forward samples ---
probs_copy = forward_initialise(
probs,
sampler.latent_vars,
evidence,
n_chain,
)
sampler.init_state_from_forward_samples(probs_copy)
# --- Run MCMC ---
out = sampler.run(
n_iter=n_iter,
store_every=10, # thin
update_blocks=update_blocks,
progress_every=progress_every,
)
return sampler, out
def extract_posterior(sampler):
"""
Returns dict[var_name -> 1D np.ndarray]
(all evidence rows and chains flattened)
"""
posterior = {}
for v in sampler.latent_vars:
x = sampler.state[v.name] # (n_evi, n_chain)
posterior[v.name] = x.detach().cpu().numpy().reshape(-1)
return posterior
import numpy as np
import matplotlib.pyplot as plt
def plot_prior_vs_posterior(prior, posterior, var, bins=60, fname: str = None):
"""
Plot prior vs posterior for one variable.
Parameters
----------
prior : dict[str, np.ndarray]
Prior samples for all variables
posterior : dict[str, np.ndarray]
Posterior samples for all variables
var : Variable
tbnpy Variable object
bins : int
Number of bins for continuous histograms
fname : str, optional
File name to save the plot (saved in RESULTS folder). If None, the plot is not saved.
"""
name = var.name
if name not in prior:
raise KeyError(f"Variable '{name}' not found in prior samples.")
if name not in posterior:
raise KeyError(f"Variable '{name}' not found in posterior samples.")
x_prior = prior[name]
x_post = posterior[name]
plt.figure(figsize=(5, 4))
# Discrete variable
if isinstance(var.values, list):
K = len(var.values)
bins_disc = np.arange(K + 1) - 0.5
plt.hist(
x_prior,
bins=bins_disc,
density=True,
alpha=0.5,
label="Prior",
color="gray",
)
plt.hist(
x_post,
bins=bins_disc,
density=True,
alpha=0.6,
label="Posterior",
color="tab:blue",
)
plt.xticks(range(K), var.values)
plt.ylabel("Probability")
# Continuous variable
else:
plt.hist(
x_prior,
bins=bins,
density=True,
alpha=0.5,
label="Prior",
color="gray",
)
plt.hist(
x_post,
bins=bins,
density=True,
alpha=0.6,
label="Posterior",
color="tab:blue",
)
plt.ylabel("Density")
plt.xlabel(name)
plt.legend()
plt.tight_layout()
if fname is not None:
plt.savefig(RESULTS / fname, dpi=300)
if __name__ == "__main__":
device = "cuda" if os.environ.get("USE_CUDA", "0") == "1" else "cpu"
varis = define_variables()
probs = define_probs(varis, device=device)
# Prior distribution without evidence
prior = sample_prior(probs, varis, n_sample=10_000)
# Posterior inference with evidence
n_evi = 20
evidence = define_evidence(n_evi=n_evi)
n_chain = 100
n_iter = 30_000
burnin = 200
query_varis = {v: varis[v] for v in ['A', 'B', 'C', 'OC']} # only infer OC's ancestors
query_probs = {k: v for k, v in probs.items() if any(c.name in query_varis for c in v.childs)}
update_blocks = ['A', 'B', 'C']
sampler, out = run_mcmc(
query_probs,
query_varis,
evidence,
update_blocks = update_blocks,
burnin=burnin,
n_chain=n_chain,
n_iter=n_iter,
progress_every = 100
)
posterior = extract_posterior(sampler)
for _, v in query_varis.items():
if v.name in evidence.columns:
continue # skip evidence variables
plot_prior_vs_posterior(prior, posterior, v, fname=r"plot_" + v.name + f"_{n_evi}_evi_{burnin}_burnin_{n_chain}_chains_{n_iter}_iters" + ".png")
print("Acceptance rates:")
for k, v in out["accept_rate"].items():
print(f" {k}: {v:.3f}")
Prior vs posterior distributions¶
The prior and posterior distributions for variables A, B, and C illustrate how evidence propagates through both discrete and continuous parts of the network.
A: posterior probability shifts relative to the prior
B: moderate posterior update through indirect influence
C: posterior density becomes narrower
This demonstrates how noisy observations constrain latent variables.