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

Parameter Learning and Conditional Probability Tables

Structure learning (hc, tabu, tree.bayes) and hand-drawn structure (model2network) both produce a DAG with no numbers attached — an arc says two variables are dependent, not by how much. Fitting parameters means estimating a conditional probability table (CPT) for every discrete node, or a conditional distribution for every continuous one, from data. bnlearn::bn.fit does this over an existing structure by one of several estimators, selected with its method argument. This file works through the two discrete estimators — maximum likelihood (method = "mle") and Bayesian estimation with a Dirichlet prior (method = "bayes") — then the continuous and hybrid cases, which fit a different kind of parameter (regression coefficients, not a table) regardless of which of those two estimators is asked for.

BayesianAnalysisWithBnlearn.md, Procedures 4 and 5, already used both bn.fit(..., method = "mle") on a hand-specified structure and bn.fit(..., method = "bayes", iss = 1) on a Tree-Augmented Naive Bayes structure, without dwelling on what separates the two estimators or what happens when a parent configuration is rare or absent from the training data. That is this file’s subject.

Maximum likelihood: counting and dividing

Procedure 1: Maximum likelihood estimation on a discrete network

method = "mle" is the plain estimator: for every node, for every configuration of its parents’ states, count how many training rows fall into each of the node’s own states, and divide by the row total for that parent configuration. No prior belief and no adjustment — the fitted probability for a cell is exactly its observed relative frequency. This is run here on learning.test, a bundled discrete dataset (six variables, all factors, 5000 rows) that ships with bnlearn for exactly this kind of exercise, with structure learned by hill-climbing first so the CPTs are fitted over a real, non-trivial DAG rather than a single hand-drawn arc.

library(bnlearn)

data(learning.test)
str(learning.test)

dag <- hc(learning.test)
cat("\nLearned structure arcs:\n")
print(arcs(dag))

fitted.mle <- bn.fit(dag, learning.test, method = "mle")
cat("\nMLE-fitted CPT for node F:\n")
print(fitted.mle$F)

Console output:

'data.frame':	5000 obs. of  6 variables:
 $ A: Factor w/ 3 levels "a","b","c": 2 2 1 1 1 3 3 2 2 2 ...
 $ B: Factor w/ 3 levels "a","b","c": 3 1 1 1 1 3 3 2 2 1 ...
 $ C: Factor w/ 3 levels "a","b","c": 2 3 1 1 2 1 2 1 2 2 ...
 $ D: Factor w/ 3 levels "a","b","c": 1 1 1 1 3 3 3 2 1 1 ...
 $ E: Factor w/ 3 levels "a","b","c": 2 2 1 2 1 3 3 2 3 1 ...
 $ F: Factor w/ 2 levels "a","b": 2 2 1 2 1 1 1 2 1 1 ...

Learned structure arcs:
     from to 
[1,] "A"  "B"
[2,] "A"  "D"
[3,] "C"  "D"
[4,] "B"  "E"
[5,] "F"  "E"

MLE-fitted CPT for node F:

  Parameters of node F (multinomial distribution)

Conditional probability table:
      a      b 
0.5018 0.4982 

Node F has no parents in the learned structure, so its CPT is a single unconditional split — the observed proportions 0.5018/0.4982 out of 5000 rows, nothing more.

Tool equivalent: none for fitting a CPT over an arbitrary learned or hand-drawn structure. The nearest fitting step under the same maximum-likelihood principle, for the one structure the product does fit automatically, is AdvancedAnalyticsTreeAugmentedNaiveBayesClassifierTrainingService (BayesianAnalysisWithBnlearn.md, Procedure 7’s structure step, with bn.fit(..., method = "mle") matching BayesianAnalysisWithBnlearn.md Procedure 4’s use of the same estimator).

Procedure 2: The zero-count problem and the Dirichlet-prior fix

Maximum likelihood has one structural failure mode: a parent configuration that is rare, or entirely absent, in the training data gets its CPT column estimated from very few rows, or none. A column estimated from very few rows can assign a state exactly zero probability purely because that state happened not to occur in a handful of observations — and a zero in a CPT is not a small number, it is a certainty that state can never happen, which then makes every future case in that cell impossible to score rather than merely unlikely.

method = "bayes" fixes this by blending the observed counts with a Dirichlet prior before dividing: instead of estimating a cell’s probability as n_ijk / n_ij (count in the cell over the count for that parent configuration), it estimates (n_ijk + alpha_ijk) / (n_ij + alpha_ij), where alpha_ijk is a small “pseudo-count” added to every cell before any real data is seen. bnlearn spreads a single tuning constant, iss (imaginary sample size — the total weight of the prior, expressed as if it were iss additional observations), uniformly over every cell of the node’s full CPT: with r states for the node itself and q parent configurations, each cell gets alpha_ijk = iss / (r * q), so alpha_ij = iss / q per parent configuration. This is the BDeu (Bayesian Dirichlet equivalent, uniform) prior described in Scutari and Denis, Bayesian Networks with R, and in the bnlearn documentation for bn.fit.

The effect is concrete and worth seeing on data engineered to have exactly the failure MLE has: a Parent variable with three levels, two of them common (500 rows each, roughly even splits of a binary Child) and one rare (5 rows, all of which happen to have landed on Child = "x") — the kind of rare-combination cell a real dataset produces without anyone engineering it.

library(bnlearn)

set.seed(2024)

n.a <- 500
n.b <- 500
n.c <- 5

parent <- factor(c(rep("a", n.a), rep("b", n.b), rep("c", n.c)),
                  levels = c("a", "b", "c"))

child.a <- factor(sample(c("x", "y"), n.a, replace = TRUE, prob = c(0.5, 0.5)),
                   levels = c("x", "y"))
child.b <- factor(sample(c("x", "y"), n.b, replace = TRUE, prob = c(0.4, 0.6)),
                   levels = c("x", "y"))
child.c <- factor(rep("x", n.c), levels = c("x", "y"))

data <- data.frame(Parent = parent, Child = c(as.character(child.a), as.character(child.b), as.character(child.c)))
data$Child <- factor(data$Child, levels = c("x", "y"))

cat("Observed counts, Child by Parent:\n")
print(table(Child = data$Child, Parent = data$Parent))

dag <- model2network("[Parent][Child|Parent]")

fitted.mle <- bn.fit(dag, data, method = "mle")
cat("\nMLE-fitted CPT for Child | Parent:\n")
print(fitted.mle$Child)

fitted.bayes.iss1 <- bn.fit(dag, data, method = "bayes", iss = 1)
cat("\nBayesian (Dirichlet prior, iss = 1) CPT for Child | Parent:\n")
print(fitted.bayes.iss1$Child)

fitted.bayes.iss10 <- bn.fit(dag, data, method = "bayes", iss = 10)
cat("\nBayesian (Dirichlet prior, iss = 10) CPT for Child | Parent:\n")
print(fitted.bayes.iss10$Child)

Console output:

Observed counts, Child by Parent:
     Parent
Child   a   b   c
    x 258 196   5
    y 242 304   0

MLE-fitted CPT for Child | Parent:

  Parameters of node Child (multinomial distribution)

Conditional probability table:
 
     Parent
Child     a     b     c
    x 0.516 0.392 1.000
    y 0.484 0.608 0.000

Bayesian (Dirichlet prior, iss = 1) CPT for Child | Parent:

  Parameters of node Child (multinomial distribution)

Conditional probability table:
 
     Parent
Child         a         b         c
    x 0.5159893 0.3920720 0.9687500
    y 0.4840107 0.6079280 0.0312500

Bayesian (Dirichlet prior, iss = 10) CPT for Child | Parent:

  Parameters of node Child (multinomial distribution)

Conditional probability table:
 
     Parent
Child         a         b         c
    x 0.5158940 0.3927152 0.8000000
    y 0.4841060 0.6072848 0.2000000

Column c is the payoff. MLE assigns Child = "y" a flat zero probability given Parent = "c", because none of the five observed rows in that cell happened to be "y" — any future case with Parent = "c" and Child = "y" is now impossible under this model, not just rare. Both Bayesian fits pull that cell off zero: with r = 2 states of Child and q = 3 parent configurations, alpha_ijk = iss / 6. At iss = 1, alpha_ijk ≈ 0.1667, giving P(y | c) = (0 + 0.1667) / (5 + 0.3333) = 0.03125 — matching the printed 0.0312500 exactly. At iss = 10, the same arithmetic with alpha_ijk = 10/6 ≈ 1.6667 gives (0 + 1.6667) / (5 + 3.3333) = 0.2 — again matching the printed value exactly. Columns a and b, backed by hundreds of observations each, barely move under either prior (0.5160 and 0.3921 against MLE’s 0.516 and 0.392): the prior’s pseudo-count is swamped by real data as soon as there is enough of it, which is the whole design goal — iss matters where data is thin and disappears where it is not. Raising iss from 1 to 10 visibly trades away some of that rare cell’s fidelity to its five real observations in exchange for a less extreme estimate; there is no universally correct iss, only a stated tolerance for how much a handful of observations should be allowed to imply certainty.

Tool equivalent: none for fitting a discrete CPT under a Dirichlet/BDeu prior over an arbitrary structure. The same problem — a parameter estimate collapsing to a degenerate value because a class/predictor combination is thin or absent — has a shipped answer for the product’s two fitted classifier-shaped families that face it: Jube.Service.Agent.AdvancedAnalyticsTools.NaiveBayesClassifierTrainingRunTools / tool "SubmitNaiveBayesClassifierTraining" and Jube.Service.Agent.AdvancedAnalyticsTools.TreeAugmentedNaiveBayesClassifierTrainingRunTools / tool "SubmitTreeAugmentedNaiveBayesClassifierTraining" both expose a VarianceRegularisation constant on their training DTOs (default 1e-9), described in Jube.Service.Repository.AnalyticsTrainingRun.AdvancedAnalyticsNaiveBayesClassifierTrainingDto as “the constant used in place of a class-conditional variance that would otherwise be zero, undefined or infinite, expressed as a share of that predictor’s overall training variance.” It plays the same structural role as iss here — a floor that keeps a thin or degenerate class/predictor combination from collapsing a fitted distribution to zero or infinite precision — but it floors a Gaussian variance for a continuous predictor, not a Dirichlet pseudo-count over a discrete CPT cell, because neither product classifier fits a discrete multinomial CPT in the first place (both fit a per-class Gaussian per predictor).

Continuous and hybrid networks

Procedure 3: Linear-Gaussian parameter fitting on a continuous network

When every node is continuous, bn.fit has nothing to count — there is no discrete state to tally. Each node is instead fitted as a linear regression of its own value on its parents’ values: bnlearn calls the result a linear-Gaussian node, and its stored parameters are exactly what lm would report — an intercept, a coefficient per parent, and the residual standard deviation. method still defaults to maximum likelihood in this case (there is no meaningful Bayesian/Dirichlet variant for a continuous regression fit, since a Dirichlet prior is a distribution over discrete-cell probabilities, not over regression coefficients — see Procedure 4’s explicit check of this). The bundled gaussian.test dataset (seven continuous variables, 5000 rows, generated to have a real linear-Gaussian structure) is used here with a hill-climbed structure, as in Procedure 1.

library(bnlearn)

data(gaussian.test)
str(gaussian.test)

dag <- hc(gaussian.test)
cat("\nLearned structure arcs:\n")
print(arcs(dag))

fitted <- bn.fit(dag, gaussian.test)
cat("\nLinear-Gaussian fit for node F (class 'bn.fit.gnode'):\n")
print(fitted$F)
cat("\nclass(fitted$F):\n")
print(class(fitted$F))

Console output:

'data.frame':	5000 obs. of  7 variables:
 $ A: num  1.113 -0.248 1.855 0.834 0.489 ...
 $ B: num  1.93 11.33 3.03 3.86 4.51 ...
 $ C: num  7.07 24.35 11.09 11.22 10 ...
 $ D: num  8.66 23.36 11.06 11.94 12.54 ...
 $ E: num  0.882 7.04 3.835 1.006 4.085 ...
 $ F: num  24.7 36.8 22 23.3 24.5 ...
 $ G: num  9.22 3.68 2.42 6.09 5.12 ...

Learned structure arcs:
     from to 
[1,] "B"  "D"
[2,] "B"  "C"
[3,] "A"  "C"
[4,] "G"  "F"
[5,] "E"  "F"
[6,] "A"  "F"
[7,] "D"  "F"

Linear-Gaussian fit for node F (class 'bn.fit.gnode'):

  Parameters of node F (Gaussian distribution)

Conditional density: F | A + D + E + G
Coefficients:
 (Intercept)             A             D             E             G  
-0.006047321   1.994853041   1.005636909   1.002577002   1.494373265  
Standard deviation of the residuals: 0.9961475 

class(fitted$F):
[1] "bn.fit.gnode"

F has four parents in the learned structure (A, D, E, G), and its fitted node is a plain multiple linear regression of F on those four — same coefficients, same residual standard deviation an lm(F ~ A + D + E + G, data = gaussian.test) call would report, just stored as a network node (bn.fit.gnode) rather than a bare lm object.

Tool equivalent: none for a continuous node whose fitted distribution is conditioned on a graph of other nodes. The nearest single-node analogue is a flat multiple linear regression fit over whichever continuous predictors are supplied — Jube.Service.Agent.AdvancedAnalyticsTools.RelationshipsAndRegression.LinearRegressionTools / tool "FitMultipleLinearRegression", backed by AdvancedAnalyticsLinearRegressionTrainingService — but that fits exactly one regression, with no notion of a second node’s own conditional distribution feeding into it as here, and no discrete node able to switch the fitted mean and variance as in Procedure 4 below.

Procedure 4: Conditional-Gaussian parameter fitting on a hybrid network

A hybrid network mixes discrete and continuous nodes, with one structural restriction bnlearn enforces: a discrete node may not have a continuous parent (a continuous value cannot be “counted” into a discrete CPT cell), so every discrete node must sit at or above the continuous ones it influences. bn.fit’s fitting method for this case is "mle-cg" (conditional Gaussian) rather than plain "mle" — passing "mle" on a hybrid network is rejected outright, since "mle" only knows how to estimate discrete CPTs. Fitting a conditional-Gaussian node whose parents include a discrete variable works by splitting the continuous data into one group per discrete parent configuration and fitting a separate linear (here, intercept-only) Gaussian within each group — so a discrete parent doesn’t contribute a regression coefficient the way a continuous parent would, it selects which of several fitted Gaussians applies.

Three nodes are constructed here to show both hybrid cases in one small network: A (discrete, root), B (continuous, with discrete parent A — the conditional-Gaussian case), and C (continuous, with continuous parent B — the plain linear-Gaussian case from Procedure 3, chained beneath a conditional-Gaussian node).

library(bnlearn)

set.seed(99)
n <- 2000

A <- factor(sample(c("lo", "hi"), n, replace = TRUE, prob = c(0.5, 0.5)))
B <- ifelse(A == "lo", rnorm(n, mean = 10, sd = 2), rnorm(n, mean = 20, sd = 2))
C <- 3 + 0.5 * B + rnorm(n, mean = 0, sd = 1)

hybrid <- data.frame(A = A, B = B, C = C)
str(hybrid)

dag <- model2network("[A][B|A][C|B]")

fitted <- bn.fit(dag, hybrid, method = "mle-cg")

cat("\nNode A (root, discrete) parameters:\n")
print(fitted$A)

cat("\nNode B (continuous, discrete parent A) parameters - conditional Gaussian:\n")
print(fitted$B)
cat("class(fitted$B): ")
print(class(fitted$B))

cat("\nNode C (continuous, continuous parent B) parameters - linear Gaussian:\n")
print(fitted$C)
cat("class(fitted$C): ")
print(class(fitted$C))

cat("\nOverall class of fitted network: ")
print(class(fitted))

cat("\nAttempting bayes-method fit on this hybrid network (expect it to be discrete-only):\n")
result <- tryCatch({
  bn.fit(dag, hybrid, method = "bayes", iss = 1)
}, error = function(e) {
  cat("Error: ", conditionMessage(e), "\n")
  NULL
})

Console output:

'data.frame':	2000 obs. of  3 variables:
 $ A: Factor w/ 2 levels "hi","lo": 2 1 2 2 2 2 2 1 1 1 ...
 $ B: num  10.53 22.07 9.83 9.47 9.48 ...
 $ C: num  8.07 13.4 8.2 6.91 7.32 ...

Node A (root, discrete) parameters:

  Parameters of node A (multinomial distribution)

Conditional probability table:
    hi    lo 
0.512 0.488 

Node B (continuous, discrete parent A) parameters - conditional Gaussian:

  Parameters of node B (conditional Gaussian distribution)

Conditional density: B | A
Coefficients:
                    0         1
(Intercept)  20.08005  10.08127
Standard deviation of the residuals:
       0         1  
2.020688  1.983867  
Discrete parents' configurations:
    A
0  hi
1  lo
class(fitted$B): [1] "bn.fit.cgnode"

Node C (continuous, continuous parent B) parameters - linear Gaussian:

  Parameters of node C (Gaussian distribution)

Conditional density: C | B
Coefficients:
(Intercept)            B  
  3.0044385    0.4997403  
Standard deviation of the residuals: 0.9814828 
class(fitted$C): [1] "bn.fit.gnode"

Overall class of fitted network: [1] "bn.fit"       "bn.fit.cgnet"

Attempting bayes-method fit on this hybrid network (expect it to be discrete-only):
Error:  parameter estimator 'bayes' may only be used with discrete data.

Node B’s fit (bn.fit.cgnode, a genuinely different class from C’s plain bn.fit.gnode) is not one regression but two: an intercept-only Gaussian fitted separately within A = "hi" (mean 20.08, sd 2.02) and within A = "lo" (mean 10.08, sd 1.98) — recovering the generating means (20 and 10) and standard deviations (2 and 2) closely, as expected from 2000 simulated rows. Node C’s fit is the Procedure 3 case again, recovering the generating intercept (3) and slope (0.5) on B almost exactly, unaffected by A sitting two arcs upstream of it. The overall fitted object’s class, bn.fit.cgnet, names this a conditional-Gaussian network — bnlearn’s term for exactly this mixture of tabular and regression-fitted nodes. The final call confirms directly, from the package’s own error message, that method = "bayes" — the Dirichlet-prior estimator from Procedure 2 — has no continuous counterpart in bnlearn: a Dirichlet distribution is a prior over the cell probabilities of a discrete table, and there is no equivalent notion of a “pseudo-count” prior over a regression coefficient in this package’s Bayesian-estimation code path.

Tool equivalent: none. No shipped training service fits a continuous node’s distribution as several separate Gaussians switched by a categorical parent (the specific mechanism a conditional-Gaussian node uses) — the closest available building blocks remain the same two named in Procedures 2 and 3 separately: VarianceRegularisation -floored per-class Gaussian fitting in the Naive Bayes and TAN classifiers (Procedure 2) for the “one Gaussian per discrete grouping” half, and flat least-squares regression (LinearRegressionTools, Procedure 3) for the “Gaussian regressed on continuous parents” half — never both chained through one node’s fitted distribution as bn.fit(..., method = "mle-cg") does here.