Skip to main content

NeuralBayesianNetworks (NBN)

A PyTorch-native Bayesian network library where each mechanism is learnt by a neural network. Every node carries a learnable, batched, GPU-resident conditional distribution; every query is a batched tensor operation; inference and parameter learning both run end-to-end on cuda.

NBN is 9-22× faster than pgmpy on continuous Linear Gaussian inference and 2.3× more accurate on discrete parameter learning at scale, and is the only library in our benchmark suite that handles hybrid (mixed continuous-discrete) networks at scale.

Install

pip install nbn                 # the library
pip install "nbn[neural]"       # + zuko-backed flows / MDNs (NBN's headline mechanisms)
pip install "nbn[bench]"        # + the benchmark suite (`nbn-bench`, `nbn.bench`)

The import name is nbn. The [neural] extra adds zuko-backed flows/MDNs; bench pulls in the benchmark runner's own dependencies (pandas, pyarrow, scipy, yaml, tqdm), the external-baseline libraries (pgmpy, pomegranate) and plotting (matplotlib, seaborn); gp/mcmc add the gpytorch and pyro baselines. All of bench, gp, and mcmc are required for paper-grade benchmark runs — without them the runner silently skips those baselines (cells emit not_supported rather than erroring).

Working from a checkout (development, or reproducing the paper runs, whose YAML configs are addressed by repo-relative path):

git clone https://github.com/Giovannibriglia/NeuralBayesianNetworks.git
cd NeuralBayesianNetworks
pip install -e ".[dev,bench,neural,gp,mcmc]"

dev is the test and docs toolchain. Releases are cut by pushing a v* tag; .github/workflows/publish.yml builds from the tag and uploads to PyPI.

Run the benchmark suite

From the repo root, launch the six paper-scale benchmarks. At most three run in parallel; the next starts as soon as one finishes:

bash scripts/run_all_benchmarks.sh

Override the pool size or device via MAX_PARALLEL=2 bash … or DEVICE=cpu bash … (three GPU benchmarks in flight can exceed an 8 GB card). Each benchmark leaves a run directory under results/; turn it into figures and tables with nbn-bench plot (see Plot the results).

Why NBN

NBN is to Bayesian Networks what GPyTorch is to Gaussian Processes: a torch-native, batchable, autograd-friendly framework where every conditional distribution is a swappable, learnable module, and every query is a batched tensor operation.

Library Discrete BN Continuous Batched queries Neural CPDs Hybrid native
pgmpy ✅ exact ✅ Gaussian only ⚠️ CG only
pomegranate partial ⚠️ limited
GPyTorch ✅ GP
Pyro / NumPyro ✅ via enum partial ✅ universal
NBN ✅ exact ✅ MDN/Flow/GP ✅ batched VE ✅ native

Headline results

These numbers come from the canonical paper-data run at tag v0.6c-d (see Reproducibility below).

Inference: 9-22× faster on continuous Linear Gaussian networks

Inference total time vs network size

NBN-lg-lw vs pgmpy-lg-predict on continuous Linear Gaussian networks: 22× faster at n=10 (1.9 ms vs 42 ms), 12× at n=1000 (0.72 s vs 8.5 s). Accuracy matches pgmpy within 0.02 W₁ at every n_nodes — speed gain comes without quality regression.

On discrete networks at n=10, NBN-cat-ve runs at 1.4 ms vs pgmpy-mle-ve at 108 ms (75× faster).

Parameter learning: 2.3× more accurate on discrete networks at scale

Parameter learning accuracy vs network size

On discrete Bayesian networks, NBN-cat reaches TV ≈ 0.14 across all n_nodes ≥ 50; pgmpy-mle saturates at TV ≈ 0.34. The quality gap opens at n=50 (0.10 vs 0.25) and persists through n=1000 (0.146 vs 0.340). NBN's gradient-based fitting scales past pgmpy's sample-complexity wall.

On continuous Linear Gaussian networks, NBN matches pgmpy quality (W₁ ≈ 0.083 across all n) at 2× the speed.

Hybrid networks

NBN-hybrid handles mixed continuous-discrete networks across all n_nodes in our benchmark (n ∈ {10, 50, 100, 500, 1000}). Among the external libraries, only pyro covers hybrid inference (Importance sampler); pgmpy, gpytorch, and pomegranate have no applicable hybrid baselines.

Quick start

import torch
from nbn import NeuralBayesianNetwork, TensorVariableElimination
from nbn.mechanisms import CategoricalTableMechanism

# A → B → C, all categorical with cardinality 4
edges = [("A", "B"), ("B", "C")]
model = NeuralBayesianNetwork(
    edges,
    variables={"A": ("discrete", 4), "B": ("discrete", 4), "C": ("discrete", 4)},
)

# Fit each node's mechanism from data
data = {"A": torch.randint(0, 4, (10_000,)),
        "B": torch.randint(0, 4, (10_000,)),
        "C": torch.randint(0, 4, (10_000,))}
for node in model.dag.topological_order():
    parents = model.dag.parents(node)
    pa = torch.stack([data[p] for p in parents], dim=-1).float() if parents else None
    mech = CategoricalTableMechanism()
    mech.fit_local(data[node], pa, parent_cards=[4] * len(parents))
    model.set_mechanism(node, mech)

# Batched query: P(C | A=a) for 4 evidence rows at once
engine = TensorVariableElimination()
posterior = engine.query_batch(model, ["C"], {"A": torch.tensor([0, 1, 2, 3])})
# posterior shape: (B=4, 1, 4) — a distribution over C for each evidence row

For continuous, hybrid, and neural-mechanism examples, see the test suite under tests/integration/.

Gradients

Parent values and do= values are gradient-transparent — a parent computed by your own nn.Module carries autograd back into it:

path differentiable
mechanism.log_prob(x, parents) yes
model.log_prob(data), incl. per_node=True yes
model.sample(n) / model.sample(n, do=v) yes, w.r.t. parameters and v
model.query / model.query_batch no — VE detaches at factor build, LW runs under torch.inference_mode()
model.intervene(do=...) no — returns a deepcopy, so its parameters are fresh leaves

The last two are deliberate. When you need gradients through an intervention, use model.sample(n, do=...), which applies it against the live parameters; intervene() is for building a mutilated model to query. The contract is pinned by tests/unit/test_parent_gradient_contract.py.

Snapshotting parameters

To take an optimisation step and be able to reject it (backtracking an M-step that decreased the objective, say), use torch's state_dict / load_state_dict — but copy the snapshot:

snap = copy.deepcopy(mech.state_dict())   # NOT mech.state_dict()
...                                       # optimiser step
mech.load_state_dict(snap)                # reverts exactly

state_dict() returns tensors sharing storage with the live parameters, and optimisers update in place, so an uncopied snapshot is mutated by the step it is meant to undo — silently, with nothing raising. Pinned by tests/unit/test_parameter_snapshot_contract.py.

Repository layout

nbn/                Library code (mechanisms, inference, sampling, core).
nbn/bench/          Benchmark suite: runner, baseline adapters, configs, data.
notebooks/          Colab-ready notebooks for the six paper-scale benchmarks.
scripts/            run_all_benchmarks.sh and other operational helpers.
results/            Where nbn-bench writes runs (gitignored).
tests/              Unit + integration tests.
RESEARCH.md         Paper outline and contribution claims.

Reproducibility

The headline numbers above are anchored at tag v0.6c-d (commit 2e0dd32):

git checkout v0.6c-d          # the suite lived at benchmarking/ at this tag
nbn-bench inference \
  --config benchmarking/configs/inference_paper_laptop.yaml
nbn-bench param-learning \
  --config benchmarking/configs/parameter_learning_paper_laptop.yaml

Numerical values vary within MC noise across hardware; STATUS counts and qualitative findings (cluster, speedup, quality gap) are stable. The committed parquets, tables, and figures under results/{raw,tables,figures}/ are the canonical paper artefacts.

Crash tests

NBN ships two crash tests on synthetic Bayesian networks with known ground truth, sweeping network size on the x-axis:

  1. Parameter-learning crash test — measures accuracy of fitted CPDs against the true generative process. Speed is not measured.
  2. Inference crash test — measures both accuracy and total time for Q conditional queries. NBN uses query_batch(B=Q) (one batched call); other libraries loop over the same Q queries in Python.

Each crash test has a smoke config (CI, < 60s) and a paper config (local reproduction, ~17.9 h on RTX 4070 Laptop 8 GB; CPU not supported for paper-config).

Reproduce

# Smoke (runs in CI):
nbn-bench param-learning --config nbn/bench/configs/synthetic/smoke_tests/parameter_learning_smoke.yaml
nbn-bench inference      --config nbn/bench/configs/synthetic/smoke_tests/inference_smoke.yaml

# Paper (8 GB VRAM, the laptop variant used for v0.6c-d paper data):
nbn-bench param-learning --config nbn/bench/configs/synthetic/complete/parameter_learning_complete_laptop.yaml
nbn-bench inference      --config nbn/bench/configs/synthetic/complete/inference_complete_laptop.yaml

# Paper (≥16 GB VRAM, canonical config without batch reductions):
nbn-bench param-learning --config nbn/bench/configs/synthetic/complete/parameter_learning_complete.yaml
nbn-bench inference      --config nbn/bench/configs/synthetic/complete/inference_complete.yaml

Each invocation writes one run directory under results/; the parquet is the single canonical artefact of a run (figures and tables are never generated automatically):

results/benchmark_<benchmark>_<config_name>_<YYYYMMDD_HHMMSS>/
    <config_name>_metrics.parquet     one row per (cell, metric)
    metrics.jsonl                     the same rows, streamed while running
    run.log                           per-cell log (fit/query phases, errors)

Plot the results

nbn-bench plot turns one (or more) run directories into paper figures and LaTeX tables. The same command serves every benchmark; the figures it emits are decided by what the parquet contains (which metrics have ok rows, whether n_train or batch_size is swept), so you never pick a plotter:

nbn-bench plot results/benchmark_synthetic_learning_curves_20260908_092714 \
  --output-dir results/figures/learning_curves
# options: --aggregation iqm_iqr|mean_std (default iqm_iqr)
#          --benchmark synthetic|bnlearn  (default: every benchmark in the parquet)

Output tree: <output-dir>/<benchmark>/<family>/all/{plots,tables}/, plus common/ or subset<k>/ siblings of all/ when the baselines do not all cover the same problems (_subsets_overview.txt explains the split). A figure that had unfinished cells gets a *_dnf.txt sidecar naming them. The run-directory name uses the config's config_name (complete, scalability_complete, batch_speed, param_learning_complete, learning_curves, bnlearn_complete), not the YAML file name. Per benchmark:

Benchmark (config) Run with Plot with What you get under <output-dir>/<benchmark>/<family>/all/
Synthetic inference (synthetic/complete/inference_complete.yaml) nbn-bench inference --config … nbn-bench plot <run-dir> --output-dir <out> plots/{tv,jsd,w1}_per_node_vs_{n_nodes,n_parameters}.pdf, plots/{fit_time,total_query_time}_vs_{n_nodes,n_parameters}.pdf, plots/success_rate.pdf; tables/table_overall.tex, table_role_<role>.tex, table_kind_<kind>.tex
Inference scalability (synthetic/complete/inference_scalability_complete.yaml) nbn-bench inference --config … same same set; the time-scaling figures (*_time_vs_n_nodes.pdf) are the headline
Inference speed / batching (synthetic/speed/inference_speed.yaml, a batch_sizes sweep) nbn-bench inference --config … same <output-dir>/<benchmark>/batch_speed.pdf (per-query time vs batch size, one panel per family) + batch_speed_table_<family>.tex, next to the per-family tree above
Parameter learning (synthetic/complete/parameter_learning_complete.yaml) nbn-bench param-learning --config … same plots/log_likelihood_vs_{n_nodes,n_parameters}.pdf, param_recovery_{tv,kl}_vs_n_nodes.pdf (discrete), calibration_{pit_ks,sd_ratio}_vs_n_nodes.pdf (continuous), success_rate.pdf; tables/table_overall.tex, table_role_param_learning.tex, table_kind_prediction.tex
Learning curves / sample efficiency (synthetic/learning_curves/learning_curves.yaml, an n_train_sweep) nbn-bench param-learning --config … same everything in the parameter-learning row plus plots/<metric>_vs_n_train.pdf and tables/<metric>_vs_n_train.tex (rows = baselines, columns = n_train, best per column in bold) for each metric above
bnlearn inference (bnlearn/complete/inference_complete.yaml) nbn-bench inference --config … same the synthetic-inference set under <output-dir>/bnlearn/…, with *_vs_n_parameters.pdf as the natural axis (real networks differ in parameter count more than node count)
Calibration vs accuracy divergence (no config: combine two runs) one param-learning run + one inference run on the same families nbn-bench plot <pl-run-dir> <inference-run-dir> --output-dir <out> plots/divergence_calibration_pit_ks_vs_w1_per_node.pdf per continuous family (rows are concatenated; engine suffixes such as -lw are stripped to align nbn-mdn-lw with nbn-mdn)

Two things that bite:

  • learning_curves.yaml and parameter_learning_complete.yaml declare metrics: log_likelihood and their baselines carry no inference_method, so they must run under param-learning. Under inference the loader refuses them and prints the command to use.
  • A figure is only written when at least one ok row exists for its metric in that family. If a plot you expect is missing, nbn-bench plot -v logs skip empty (...) with the reason, and run.log in the run directory has the per-cell error.

Design notes and the full figure/table spec live in docs/v0.13-paper-figures.md.

Configuration

Each config is a YAML file with these fields:

mode:                 'parameter_learning' | 'inference'
families:             list of families ∈ {discrete, continuous_lg,
                      continuous_nongauss, hybrid}
n_nodes:              list of network sizes
n_seeds:              number of seeds per cell (mean ± std reported)
n_queries_per_cell:   number of queries per cell
nbn_batch_size:       B for NBN's query_batch (inference mode only)
baselines:            list of baseline spec dicts, each with required
                      fields {library, mechanism, param_method} plus
                      optional inference_method and device (cpu|cuda|auto)
per_cell_timeout_s:   wall-clock cap per (family, n_nodes, seed, baseline)

See nbn/bench/configs/**/*.yaml for all shipped configs.

Status

Current release: v0.6c-d (paper-data anchor). The library is in publishable empirical state.

Component Status
Core (DAG, Variables, Factor)
Mechanisms (Categorical, NeuralCategorical, LG, MDN, Flow, GP, Hybrid)
Tensor VE + LW + HybridRouter
Vectorised batched query_batch
Synthetic crash-test framework
Method-keyed baseline registry ✅ v0.6c-C
Aggregator + tables (CSV/MD/parquet/TEX) ✅ v0.6c-C-3
Paper-grade figures + paper-data anchor ✅ v0.6c-d
Multi-library baselines (pgmpy, gpytorch, pomegranate, pyro)
Per-baseline YAML device override ✅ v0.12
README enrichment ✅ v0.6d

Active backlog (v0.7, none paper-blocking):

  • Plotter polish: W₁ band lower-clip (#42), parameter-learning accuracy panels for non-discrete families (#44)
  • Adapter audits: pgmpy-mle vs pgmpy-bayes / nbn-cat vs nbn-neuralcat fit-path distinctness (#43)
  • HybridRouter cuda assert at hybrid n ≥ 10 (#30)
  • NeuralCategorical-VE engine refactor (#26)
  • pyro inference speedup (v0.8 candidate) — current Importance sampler is Python-bound and CPU-only; GPU is 11× slower at benchmark scale, so speedup requires pyro.plate vectorisation or alternative inference modes (SVI, NUTS). See docs/audits/v0.12-pyro-gpu-investigation.md.

See the open issues for the full v0.7 backlog.

License

Apache License 2.0 — see LICENSE.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

nbn-0.17.0.tar.gz (2.8 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

nbn-0.17.0-py3-none-any.whl (2.4 MB view details)

Uploaded Python 3

File details

Details for the file nbn-0.17.0.tar.gz.

File metadata

  • Download URL: nbn-0.17.0.tar.gz
  • Upload date:
  • Size: 2.8 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for nbn-0.17.0.tar.gz
Algorithm Hash digest
SHA256 20c8a1a7d1a2a9c5d6c66b118dde4d6b9013b30aa32c70b77ff581ea2c340d22
MD5 53149a4ff6cd5c1fbe296397a2129481
BLAKE2b-256 8f645d408e96d6e0d109a9d812965354ff96b8bda6abb8032eb72f68c8b6d89b

See more details on using hashes here.

Provenance

The following attestation bundles were made for nbn-0.17.0.tar.gz:

Publisher: publish.yml on Giovannibriglia/NeuralBayesianNetworks

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file nbn-0.17.0-py3-none-any.whl.

File metadata

  • Download URL: nbn-0.17.0-py3-none-any.whl
  • Upload date:
  • Size: 2.4 MB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for nbn-0.17.0-py3-none-any.whl
Algorithm Hash digest
SHA256 94693cd53a56beca7ed76983752e22da5ed23323542567ce4859d67dea204440
MD5 b8d69541258d72b4448944f493c0b6f8
BLAKE2b-256 f703621a7ce0de6161ed90cb42f75673b361bd77172414ce29b307b617f171fd

See more details on using hashes here.

Provenance

The following attestation bundles were made for nbn-0.17.0-py3-none-any.whl:

Publisher: publish.yml on Giovannibriglia/NeuralBayesianNetworks

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.17.0 This release

2 files

0.16.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page