Open-source agentic AI for transaction monitoring. Just ask Jooby.What's Jube? →

Exact and Approximate Inference

A fitted network (see ParameterLearningAndCPTs.md) is a compact factorisation of a full joint distribution over every node. Querying it means computing P(event | evidence) — the probability of some node’s state given other nodes are fixed — without ever materialising that full joint table, which grows exponentially with the number of nodes. bnlearn splits this into two genuinely different kinds of answer: exact inference, computed once via junction-tree propagation and correct up to floating-point precision; and approximate inference, which estimates the same quantity by simulating from the fitted network, carrying a sampling error controlled only by how many particles are drawn.

bnlearn does not implement junction-tree propagation itself — its own inference functions, cpquery and cpdist, are both simulation-based and therefore approximate by construction. Exact inference over a bnlearn-fitted network goes through the companion package gRain (Højsgaard, part of the gRbase ecosystem), via bnlearn::as.grain, which converts a fitted bn.fit object into a gRain network with no re-fitting involved. Installing gRain requires BiocManager::install(c("graph", "RBGL")) for its two Bioconductor-hosted graph-theory dependencies, then install.packages(c("gRbase", "gRain")).

Procedure 1: Fit a network to query

The same structure-then-parameters pipeline as the rest of this module — hc for structure, bn.fit with a Dirichlet prior for parameters — fitted once here on the bundled asia dataset and reused by every procedure below, so that the exact and approximate answers later in this file are answers to the same query on the same fitted network.

library(bnlearn)
data(asia)

dag <- hc(asia)
fitted <- bn.fit(dag, asia, method = "bayes", iss = 1)

cat("Learned structure arcs:\n")
print(arcs(dag))
cat("\nParents of D (dyspnoea): "); print(parents(dag, "D"))
cat("Parents of T (tuberculosis): "); print(parents(dag, "T"))
cat("\nMarginal P(T = yes) in the data: ", mean(asia$T == "yes"), "\n")

Console output:

Learned structure arcs:
     from to 
[1,] "B"  "D"
[2,] "L"  "E"
[3,] "E"  "X"
[4,] "S"  "B"
[5,] "T"  "E"
[6,] "E"  "D"
[7,] "S"  "L"

Parents of D (dyspnoea): [1] "B" "E"
Parents of T (tuberculosis): character(0)

Marginal P(T = yes) in the data:  0.0088

T (tuberculosis) has no parents in this learned structure and is rare in the data (0.88% of rows) — a genuinely low-probability piece of evidence, exactly the case the rest of this file uses to show why the choice between approximate methods matters, not just the choice between exact and approximate. The query used throughout is P(D = yes | T = yes) — dyspnoea given tuberculosis, reached in this structure via T -> E -> D.

Tool equivalent: none — see the closing note below; the structure- and parameter-learning steps themselves are covered in ParameterLearningAndCPTs.md and, for the one classifier-shaped overlap that exists, in BayesianAnalysisWithBnlearn.md.

Procedure 2: Exact inference via junction-tree propagation

Junction-tree propagation (Lauritzen and Spiegelhalter’s algorithm, the method gRain implements and the one described at length in Scutari and Denis, Bayesian Networks with R) turns the DAG into an undirected tree of node clusters — moralise the DAG (marry any two nodes sharing a child, then drop arc direction), triangulate the result so every cycle of length four or more has a chord, group the triangulated graph’s maximal cliques into a tree of clusters connected by shared-variable separators, then pass messages between neighbouring clusters until every cluster’s local table agrees with every other on their shared variables. Once that message-passing has converged, any single node’s (or small node set’s) marginal or conditional distribution can be read directly off whichever cluster contains it, with evidence entered by multiplying a 0/1 indicator into that node’s table before message-passing runs. The propagation is exact: no sampling is involved anywhere in it, so the only error in the final answer is ordinary floating-point rounding.

bnlearn::as.grain converts an already-fitted network straight into this representation with no re-fitting; gRain::compile builds the junction tree and runs the initial round of propagation; gRain::setEvidence absorbs an observed node value into the compiled tree; gRain::querygrain reads off the requested node’s posterior distribution.

library(bnlearn)
library(gRain)

data(asia)
dag <- hc(asia)
fitted <- bn.fit(dag, asia, method = "bayes", iss = 1)

junction <- as.grain(fitted)
cat("class(junction): ", paste(class(junction), collapse = ", "), "\n")

junction.compiled <- compile(junction)
junction.evidence <- setEvidence(junction.compiled, evidence = list(T = "yes"))

result <- querygrain(junction.evidence, nodes = "D")
cat("\nExact P(D | T = yes) via junction-tree propagation (querygrain):\n")
print(result)

exact.p <- result$D["yes"]
cat(sprintf("\nExact P(D = yes | T = yes) = %.6f\n", exact.p))

Console output:

Loading required package: gRbase

Attaching package: ‘gRbase’

The following objects are masked from ‘package:bnlearn’:

    ancestors, children, nodes, parents

class(junction):  cpt_grain, grain 

Exact P(D | T = yes) via junction-tree propagation (querygrain):
$D
D
       no       yes 
0.2122375 0.7877625 


Exact P(D = yes | T = yes) = 0.787762

0.787762 is now the ground truth the rest of this file’s simulation-based estimates are checked against — computed once, deterministically, with no particle count to tune and no random seed involved anywhere in the computation.

Tool equivalent: none. No shipped tool builds a junction tree or runs exact probabilistic propagation over an arbitrary graph of variables — see the closing note below.

Procedure 3: Approximate inference — logic sampling versus likelihood weighting

cpquery estimates the same kind of conditional probability by simulation, using one of two methods. method = "ls" (logic sampling, the default) forward-samples complete rows from the network’s generative process, keeps only the rows that happen to match the evidence exactly, and reports the fraction of kept rows for which the event holds — plain rejection sampling. method = "lw" (likelihood weighting) instead forces every simulated row’s evidence nodes to the observed value directly, and compensates by weighting each row by how probable that forced value actually was given its own simulated parents, so no row is ever discarded.

The difference between the two is invisible when evidence is common, and stark when it is not: with T observed in only 0.88% of real rows, logic sampling has to reject on the order of 99% of every simulated batch before it has any evidence-matching rows left to average, while likelihood weighting keeps and correctly re-weights all of them. Five independent replications (different seeds) at each of four particle counts show exactly this gap, checked here against Procedure 2’s exact value of 0.787762.

library(bnlearn)
data(asia)

dag <- hc(asia)
fitted <- bn.fit(dag, asia, method = "bayes", iss = 1)

run.reps <- function(method, n, reps = 5) {
  vals <- numeric(reps)
  for (i in seq_len(reps)) {
    set.seed(100 + i)
    if (method == "lw") {
      vals[i] <- cpquery(fitted, event = (D == "yes"), evidence = list(T = "yes"), method = "lw", n = n)
    } else {
      vals[i] <- cpquery(fitted, event = (D == "yes"), evidence = (T == "yes"), method = "ls", n = n)
    }
  }
  vals
}

for (n in c(200, 1000, 10000, 1e5)) {
  ls.vals <- run.reps("ls", n)
  lw.vals <- run.reps("lw", n)
  cat(sprintf("n = %d\n", n))
  cat("  logic sampling (ls) estimates:          ", paste(sprintf("%.4f", ls.vals), collapse = ", "), "\n")
  cat(sprintf("  logic sampling (ls) mean, sd:            %.4f, %.4f\n", mean(ls.vals, na.rm = TRUE), sd(ls.vals[!is.na(ls.vals)])))
  cat("  likelihood weighting (lw) estimates:    ", paste(sprintf("%.4f", lw.vals), collapse = ", "), "\n")
  cat(sprintf("  likelihood weighting (lw) mean, sd:      %.4f, %.4f\n\n", mean(lw.vals, na.rm = TRUE), sd(lw.vals[!is.na(lw.vals)])))
}

Console output:

n = 200
  logic sampling (ls) estimates:           0.0000, 1.0000, 0.0000, 0.0000, 1.0000 
  logic sampling (ls) mean, sd:            0.4000, 0.5477
  likelihood weighting (lw) estimates:     0.8050, 0.8100, 0.7400, 0.7850, 0.7450 
  likelihood weighting (lw) mean, sd:      0.7770, 0.0329

n = 1000
  logic sampling (ls) estimates:           0.8333, 0.9231, 0.7000, 0.9000, 1.0000 
  logic sampling (ls) mean, sd:            0.8713, 0.1128
  likelihood weighting (lw) estimates:     0.7860, 0.7850, 0.7890, 0.7920, 0.8050 
  likelihood weighting (lw) mean, sd:      0.7914, 0.0081

n = 10000
  logic sampling (ls) estimates:           0.7624, 0.7500, 0.8228, 0.8515, 0.7363 
  logic sampling (ls) mean, sd:            0.7846, 0.0499
  likelihood weighting (lw) estimates:     0.7904, 0.7873, 0.7901, 0.7821, 0.7792 
  likelihood weighting (lw) mean, sd:      0.7858, 0.0050

n = 100000
  logic sampling (ls) estimates:           0.7770, 0.7722, 0.7825, 0.8267, 0.7800 
  logic sampling (ls) mean, sd:            0.7877, 0.0222
  likelihood weighting (lw) estimates:     0.7882, 0.7883, 0.7896, 0.7865, 0.7882 
  likelihood weighting (lw) mean, sd:      0.7882, 0.0011

At n = 200, logic sampling is not merely noisy, it is unusable: only one or two simulated rows out of 200 survive rejection, so each replication’s estimate is either exactly 0 or exactly 1 depending on the single surviving row’s outcome (mean 0.40, standard deviation 0.55 across five replications — a coin flip, not an estimate). Likelihood weighting at the same n = 200 already has a standard deviation of 0.033 around a mean within 0.011 of the exact value. Both methods converge towards 0.787762 as n grows, but likelihood weighting’s standard deviation is smaller than logic sampling’s at every particle count tested here — by roughly seventeen-fold at n = 200, falling to twofold by n = 1e5 as logic sampling finally accumulates enough accepted rows (on the order of 880 out of 100,000) to stop being dominated by rejection noise. This is the general reason likelihood weighting is preferred whenever evidence sits on a low-probability state: it never discards a simulated row, so its accuracy is governed by n alone, while logic sampling’s accuracy is governed by n × P(evidence) — the number of rows it actually keeps — which collapses for rare evidence unless n is raised to compensate.

Tool equivalent: none. No shipped tool draws forward samples from a fitted joint distribution over a graph of variables, with or without importance weighting — see the closing note below.

Procedure 4: Sampling a node’s distribution as particles — cpdist

cpquery answers one probability at a time. cpdist instead returns the simulated particles themselves — a data frame of draws from one or more nodes’ conditional distribution given evidence — for further summarising, plotting, or feeding into some other calculation, rather than collapsing straight to a single number. The distinction between the two sampling methods shows up directly in the shape of what cpdist hands back: logic sampling returns however many rows survived rejection (which can be far fewer than requested), with no weights attached, since every surviving row is by construction an equally-valid sample from the evidence-conditioned distribution; likelihood weighting always returns exactly the requested number of rows, but they are only valid once each row’s "weights" attribute is taken into account.

The query here moves to E (has parents T and L in this structure, so its likelihood weights genuinely vary from row to row rather than degenerating to a constant) as evidence, with X as the sampled node — a 20,000-request budget is not used; 5,000 is enough to show both effects clearly.

library(bnlearn)
data(asia)

dag <- hc(asia)
fitted <- bn.fit(dag, asia, method = "bayes", iss = 1)

cat("Parents of E in the learned structure: ")
print(parents(dag, "E"))

set.seed(5)
particles.ls <- cpdist(fitted, nodes = "X", evidence = (E == "yes"), method = "ls", n = 5000)
cat("\ncpdist, method = 'ls' (logic sampling / rejection):\n")
cat("rows returned (after rejection, requested n = 5000): ", nrow(particles.ls), "\n")
cat("has 'weights' attribute: ", !is.null(attr(particles.ls, "weights")), "\n")
print(prop.table(table(particles.ls$X)))

set.seed(5)
particles.lw <- cpdist(fitted, nodes = "X", evidence = list(E = "yes"), method = "lw", n = 5000)
cat("\ncpdist, method = 'lw' (likelihood weighting):\n")
cat("rows returned (n = 5000 requested): ", nrow(particles.lw), "\n")
w <- attr(particles.lw, "weights")
cat("has 'weights' attribute: ", !is.null(w), "\n")
cat("distinct weight values: ", length(unique(w)), "\n")
print(summary(w))
cat("weighted P(X = yes | E = yes) from these particles: ",
    sum(w[particles.lw$X == "yes"]) / sum(w), "\n")
cat("unweighted (naive, ignores weights) P(X = yes | E = yes) from same particles: ",
    mean(particles.lw$X == "yes"), "\n")

Console output:

Parents of E in the learned structure: [1] "T" "L"

cpdist, method = 'ls' (logic sampling / rejection):
rows returned (after rejection, requested n = 5000):  367 
has 'weights' attribute:  FALSE 

         no         yes 
0.005449591 0.994550409 

cpdist, method = 'lw' (likelihood weighting):
rows returned (n = 5000 requested):  5000 
has 'weights' attribute:  TRUE 
distinct weight values:  4 
    Min.  1st Qu.   Median     Mean  3rd Qu.     Max. 
0.000027 0.000027 0.000027 0.073573 0.000027 1.000000 
weighted P(X = yes | E = yes) from these particles:  0.9918495 
unweighted (naive, ignores weights) P(X = yes | E = yes) from same particles:  0.992

Logic sampling kept 367 of the 5,000 requested rows (7.3%, matching E’s roughly 7.4% marginal prevalence in the data) — every one of them unweighted, because every one was generated by an unconstrained forward pass that happened to land on E = "yes" by chance. Likelihood weighting kept all 5,000, with only four distinct weight values because E’s two parents (T, L) are both binary, giving four possible parent configurations and therefore four possible values of P(E = "yes" | T, L) for the importance weight to take — from 0.000027 for the combination that makes E = "yes" almost impossible given its simulated parents, up to 1.0 for the combination that makes it certain. Reading off the sampled node’s distribution correctly requires using those weights (sum(w[X == "yes"]) / sum(w)), not a plain row count; the two estimates agree closely here (0.9918 weighted against 0.9920 naive) only because X happens to be driven almost entirely by E in this network, so ignoring the weights costs little in this particular case — that near-agreement is a property of this query, not a property of likelihood weighting in general, and the weighted calculation is the one that is correct regardless.

Tool equivalent: none. See the closing note below.

Can AdvancedAnalytics run either kind of query?

Not as a native, per-transaction tool. Both exact junction-tree propagation and bnlearn’s own approximate simulation depend on the same thing this whole BayesianNetworks/ set of files has already recorded as absent: a general multi-node network, constructed by hand or learned, with arbitrary nodes queryable and evidence settable anywhere. That capability is out of scope for AdvancedAnalytics as a native [AdvancedAnalyticsTool] (docs/AnalyticalMethodology/Reference/ModelCatalogue.md, recorded in full in BayesianAnalysisWithBnlearn.md’s closing section). The one shipped classifier that shares an algorithmic root with this file’s material, AdvancedAnalyticsTreeAugmentedNaiveBayesClassifierTrainingService, only ever answers a forward question — the probability of its one designated class variable given a full row of predictor values — by a single closed-form calculation over its fitted Gaussians-per-class, not by junction-tree propagation or particle simulation over a general graph, and it has no notion of an internal node two arcs from the class variable to condition on or sample from, because its only “nodes” are the class and its direct predictors. Neither exact nor approximate inference over an arbitrary evidence/query node pair, of the kind this file demonstrates throughout, has a native product recall path today.

That is not the same as having no way to run it at all: the R script execution job pattern (Jube.R, the sandboxed R server, and the RScriptTools submit/poll/result agent tools; see R Server) lets an agent submit exactly the as.grain/compile/querygrain or cpquery/cpdist code this file demonstrates as a job and read back the result — a batch, expert-reviewed operation, not a live one, but a genuine, callable one. See Explanatory Role Inside AdvancedAnalytics for how this sits alongside TAN.