• Joined on 2026-01-17

cpp-banns (1.0.0)

Published 2026-05-16 20:49:48 +02:00 by matmar

Installation

pip install --index-url  --extra-index-url https://pypi.org/simple cpp-banns

About this package

C++ port of BANNs.py (libtorch backend) with optional CUDA support

cpp_banns — C++ port of BANNs.py (libtorch backend, CPU + CUDA)

This folder is a C++ port of ../Python Code/BANNs.py built on top of libtorch. It compiles into both

  • a standalone C++ static library (banns_core) with unit tests, and
  • a Python extension module (cpp_banns) installed via pip install -e Cpp_code/, exposing exactly the same class surface as the Python original (NeuralNetwork, BANNs, Boosted_BANNs, Boosted_NNs).

The Python module supports a runtime device='cpu' | 'cuda' | 'auto' argument and, when built with CUDA support, transparently runs the Gibbs sweep on the GPU. The same source builds on a GPU-less server and on the developer's RTX 5080 box — choose CPU-only at build time with -DBANNS_ENABLE_CUDA=OFF.


Prerequisites

All builds

  • CMake ≥ 3.20

  • A C++17 compiler (g++ ≥ 11 or clang++ ≥ 14)

  • Python ≥ 3.10 with development headers (sudo apt install python3-dev python3-venv python3-pip)

  • A venv with torch, numpy, pytest, pybind11, scikit-build-core:

    python3 -m venv .venv
    source .venv/bin/activate
    pip install --upgrade pip
    pip install numpy pytest pybind11 scikit-build-core
    

    Then install the right torch wheel for the target:

    # CPU-only target (server with no GPU)
    pip install torch --index-url https://download.pytorch.org/whl/cpu
    
    # CUDA target (dev machine with the RTX 5080, requires CUDA Toolkit 12.8+)
    pip install torch --index-url https://download.pytorch.org/whl/cu128
    

GPU builds additionally need

  • NVIDIA driver ≥ 555

  • CUDA Toolkit ≥ 12.8 — the RTX 5080 is Blackwell (sm_120). Earlier CUDA releases will silently compile but emit code that crashes at kernel launch on the 5080. Check with:

    nvidia-smi
    nvcc --version
    

The default CUDA_ARCHITECTURES list is 75;120 — Turing (GTX 1660 SUPER) and Blackwell (RTX 5080). Override with -DCMAKE_CUDA_ARCHITECTURES=... on the CMake command line if you build on a different GPU.


Build the C++ side

Default AUTO device detection — CUDA if a toolkit is found, otherwise CPU:

cmake -S Cpp_code -B Cpp_code/build -DCMAKE_BUILD_TYPE=Release
cmake --build Cpp_code/build -j
ctest --test-dir Cpp_code/build --output-on-failure

Force CPU-only

cmake -S Cpp_code -B Cpp_code/build -DBANNS_ENABLE_CUDA=OFF
cmake --build Cpp_code/build -j

Force CUDA on (errors if toolkit missing)

cmake -S Cpp_code -B Cpp_code/build -DBANNS_ENABLE_CUDA=ON
cmake --build Cpp_code/build -j

You can have both build directories side-by-side: e.g. build_cpu/ and build_cuda/. CMake doesn't care; only your pip install decides which one the Python module loads.


Install the Python binding

From the repo root, with the venv active:

pip install -e Cpp_code/

scikit-build-core invokes CMake under the hood. The wheel inherits the CUDA-ness of the build environment — if torch in your venv was the cu128 wheel and nvcc is on PATH, the resulting cpp_banns module will have CUDA enabled. Verify:

python -c "import cpp_banns; print('cuda?', cpp_banns.cuda_available())"

To force a CPU-only Python build even on a GPU machine:

CMAKE_ARGS="-DBANNS_ENABLE_CUDA=OFF" pip install -e Cpp_code/ --force-reinstall

Quickstart — use cpp_banns from Python

import numpy as np
import torch       # required: must come BEFORE `import cpp_banns` so that
                   # libtorch_cpu.so is loaded into the process. cpp_banns
                   # is a pybind11 extension dynamically linked against
                   # libtorch; without this import you'll see
                   # `ImportError: libtorch_cpu.so: cannot open shared object file`
import cpp_banns

print("CUDA available:", cpp_banns.cuda_available())

# Synthetic regression problem
rng = np.random.default_rng(0)
X      = rng.normal(size=(200, 5))
beta   = np.array([1.0, -2.0, 0.5, 0.0, 1.0])
y      = X @ beta + 0.3 * rng.normal(size=200)
X_test = rng.normal(size=(50, 5))

# Fit a BANNs ensemble (CPU-friendly settings)
model = cpp_banns.BANNs(
    M=50,
    hidden_layer_sizes=[8],
    activation_functions=["tanh"],
    seed=42,
    device="auto",          # 'cuda' if available, else 'cpu'
)
model.fit(
    X, y,
    n_sweeps=100, burn_in=50, n_sghmc_steps=5,
    prior_var=1.0, X_test=X_test,
)

mean, lower, upper = model.predict(X_test,
                                   quantiles=(0.05, 0.95),
                                   predictive=True)
print("test RMSE vs zero-noise truth:",
      np.sqrt(((mean - X_test @ beta) ** 2).mean()))
print("mean 90% CI width:", float(np.mean(upper - lower)))

Switching device at runtime

# Always works
model_cpu  = cpp_banns.BANNs(M=100, device="cpu")

# Requires both: built with -DBANNS_ENABLE_CUDA=ON  AND  a visible GPU
model_cuda = cpp_banns.BANNs(M=100, device="cuda")

# Picks 'cuda' if available else 'cpu' (this is the default)
model_auto = cpp_banns.BANNs(M=100, device="auto")

Drop-in swap from the Python implementation

The class names and constructor / fit / predict signatures match ../Python Code/BANNs.py exactly. To switch a driver script over, edit one line:

- from BANNs import BANNs, Boosted_BANNs, Boosted_NNs
+ from cpp_banns import BANNs, Boosted_BANNs, Boosted_NNs

Everything downstream — fit calls, predict calls, the (mean, lower, upper) return tuple — stays identical.


Re-syncing from upstream UiO

The Python source under ../Python Code/ is mirrored from the read-only upstream UiO repository (upstream git remote — see the repo root). When UiO publishes updated Python code, run:

Cpp_code/scripts/sync_from_upstream.sh

The script:

  1. git fetch upstream

  2. Compares each upstream Python Code/*.py against the snapshot recorded in .upstream_snapshot/ (a copy of the Python files as they were when the C++ was last hand-ported).

  3. If nothing changed: just rebuilds the C++ and runs the test suite.

  4. If something changed: prints a colorized diff per file and, using the mapping table below, lists the C++ files that mirror each changed Python section. Exits with code 1 so automation can see manual work is pending.

  5. After you've updated the C++ files, rerun with --finalize:

    Cpp_code/scripts/sync_from_upstream.sh --finalize
    

    This merges the upstream Python into the working tree, refreshes the snapshot, rebuilds, retests, and revalidates.

Why isn't this fully automated?

Because there is no reliable Python-to-idiomatic-C++ translator for numerical numpy code. Tools like Pythran and Cython compile a restricted subset of Python; they don't produce the kind of readable, performant libtorch C++ this port is. So the script does the parts that can be automated (fetch, diff, point-at-files-to-edit, rebuild, retest) and leaves the actual C++ edits to a human.

Mapping table — which C++ files mirror which Python section

Python section in BANNs.py C++ files
Lines 1–110 (activations, loss, weight init, clipping) include/banns/activation.hpp, src/activation.cpp, include/banns/loss.hpp, src/loss.cpp
Lines 110–185 (forward, backprop) include/banns/neural_network.hpp, src/neural_network.cpp
Lines 185–232 (adam, sghmc) include/banns/optimizers.hpp, src/optimizers.cpp
Lines 234–276 (NeuralNetwork.fit/predict) src/neural_network.cpp
BaseBANNs (standardize, feature subset, design matrix, posterior samplers) include/banns/base_banns.hpp, src/base_banns.cpp
BANNs.fit include/banns/banns.hpp, src/banns.cpp
Boosted_BANNs.fit include/banns/boosted_banns.hpp, src/boosted_banns.cpp
Boosted_NNs.fit/predict include/banns/boosted_nns.hpp, src/boosted_nns.cpp

Validation

End-to-end sanity check that the C++ port matches the Python reference within tolerance:

python Cpp_code/validation/run_validation.py

This runs the same synthetic problem through Python's BANNs.BANNs and through cpp_banns.BANNs(device='cpu') (and device='cuda' if available) across multiple seeds, prints a side-by-side metrics table, and exits 0 if every C++ metric agrees with Python within ±5% relative.

If a metric is outside ±5% — investigate, don't loosen the tolerance. The usual culprits are a sign error in a derivative or an off-by-one in the Gibbs sweep, and the unit tests don't catch either of those.


Project layout

Cpp_code/
├── CMakeLists.txt                  # build configuration, libtorch + Catch2 + pybind11
├── README.md                       # you are here
├── pyproject.toml                  # scikit-build-core packaging for `pip install`
├── .upstream_snapshot/             # baseline copies of upstream Python files
│   └── Python Code/
│       ├── BANNs.py
│       └── ...
├── include/banns/                  # public C++ headers
│   ├── types.hpp                   # Tensor alias, tensor_opts(device)
│   ├── device.hpp                  # Device enum, parse + resolve
│   ├── activation.hpp              # activation functions
│   ├── loss.hpp                    # MSE loss + derivative
│   ├── optimizers.hpp              # Adam + SGHMC state and step functions
│   ├── neural_network.hpp          # feed-forward NN class
│   ├── base_banns.hpp              # common ensemble machinery
│   ├── banns.hpp                   # BANNs ensemble
│   ├── boosted_banns.hpp           # Boosted BANNs ensemble
│   └── boosted_nns.hpp             # deterministic boosted NN ensemble
├── src/                            # implementations (one .cpp per .hpp)
├── bindings/
│   └── banns_module.cpp            # pybind11 module → `cpp_banns`
├── tests/
│   ├── test_device.cpp             # Catch2: device resolution
│   ├── test_activation.cpp         # Catch2: activations + derivatives
│   ├── test_neural_network.cpp     # Catch2: forward/backprop/Adam/SGHMC
│   ├── test_banns.cpp              # Catch2: BANNs ensemble
│   ├── test_boosted.cpp            # Catch2: boosted variants
│   └── test_python_binding.py      # pytest: cpp_banns Python surface
├── scripts/
│   ├── README.md
│   ├── sync_from_upstream.sh       # fetch upstream + diff + rebuild
│   └── export_onnx.py              # train Python BANNs + export bayaneunets-*.onnx
├── docker/
│   ├── README.md
│   ├── Dockerfile.build            # CPU build environment (libtorch-cpu + onnx)
│   ├── compose.build.yml           # one-shot release pipeline service
│   └── build_and_export.sh         # ctest + pytest + ONNX export + manifest
└── validation/
    ├── README.md
    └── run_validation.py           # Python vs C++ end-to-end comparison

The Gitea Actions workflow lives at .gitea/workflows/release.yml at the repo root.


Release process — building cpp_banns + exporting bayaneunets ONNX

A release of this repo produces two artifacts: the CPU-only cpp_banns Python wheel and a self-contained bayaneunets-vX.Y.Z.onnx model trained on the UCI Airfoil Self-Noise dataset. Both land in ./artifacts/ on the host that ran the build.

Triggering a release on Gitea

The Gitea Actions workflow at .gitea/workflows/release.yml fires on git tag pushes matching v[0-9]+.[0-9]+.[0-9]+. Cut a release with:

git tag v1.0.0
git push origin v1.0.0

The workflow:

  1. Checks out the repo with full tag history (fetch-depth: 0).
  2. Derives BAYANEUNETS_VERSION from the tag (strips the leading v).
  3. Runs docker compose -f Cpp_code/docker/compose.build.yml up --build --exit-code-from release — the compose file owns the build environment and the build steps.
  4. Uploads cpp_banns-*.whl, bayaneunets-*.onnx, and MANIFEST.txt as workflow artifacts (90-day retention).

Prerequisite: the gitea instance needs an Actions runner registered (see gitea Actions docs). The default act_runner Ubuntu image has Docker + Compose available.

Local dry run (no tag push)

The same compose file works locally, so you can verify the full pipeline before tagging:

mkdir -p artifacts
BAYANEUNETS_VERSION=1.0.0-rc1 \
  docker compose -f Cpp_code/docker/compose.build.yml up \
    --build --exit-code-from release

After ~5 minutes (depending on machine), ./artifacts/ contains:

File What it is
cpp_banns-1.0.0.rc1-*.whl Pip-installable wheel of the C++ port
bayaneunets-v1.0.0-rc1.onnx Trained BANNs as a 3-output ONNX graph
MANIFEST.txt Versions, SHA-256s, git commit, build timestamp

The compose file ${BAYANEUNETS_VERSION:?...} syntax refuses to run unless the env var is set, enforcing "release builds only" even by hand.

Consuming bayaneunets-vX.Y.Z.onnx

The ONNX file is device-agnostic and self-contained — it ships the feature scaler, the M weak networks, the posterior amplitudes, and the y de-standardizer all inside the graph. Inputs are raw airfoil features; outputs are (mean, lower, upper):

import numpy as np
import onnxruntime as ort

sess = ort.InferenceSession("bayaneunets-v1.0.0.onnx")
# X columns: [Frequency, Angle of attack, Chord length, Free-stream velocity,
#             Suction-side displacement thickness], raw scale
X = np.array([[1000.0, 0.0, 0.3048, 71.3, 0.00266337]], dtype=np.float32)
mean, lower, upper = sess.run(None, {"X": X})
print(f"sound pressure ~ {mean[0]:.1f} dB (90% CI: {lower[0]:.1f}{upper[0]:.1f})")

The graph uses only standard ONNX opset 17 ops (MatMul / Add / Tanh / Sort / Gather), so any ONNX runtime works — onnxruntime, TensorRT, OpenVINO, ML.NET, the browser-side onnxruntime-web, etc.

Versioning details

  • pyproject.toml uses setuptools_scm to derive the wheel version from the git tag. Tag v1.2.3 ⇒ wheel cpp_banns-1.2.3-*.whl. Untagged builds get a 0.0.0+dev fallback.
  • The ONNX filename's version comes from the same BAYANEUNETS_VERSION env var, set once at workflow entry and propagated through compose to export_onnx.py --output.
  • Want to retrain the ONNX on a different dataset (one of the other UCI datasets under R Code/Datasets/)? Pass --dataset <path> to Cpp_code/scripts/export_onnx.py. The CI build hard-codes airfoil; a follow-up enhancement is exposing this as a workflow input.

One-time setup: enable Gitea Packages publishing

By default, the workflow publishes everything to the workflow run's Artifacts panel (always works, no setup needed). It can additionally publish the wheel + onnx + examples directly to Gitea Packages, so consumers get stable URLs (e.g. pip install --index-url https://gitea.debiansrv.ddns.net/api/packages/matmar/pypi/simple/ cpp_banns).

This needs a Personal Access Token because Gitea's auto-injected workflow token (secrets.GITEA_TOKEN / secrets.GITHUB_TOKEN) is read-only per the Gitea Actions docs.

One-time setup (admin):

  1. Generate a PAT at https://gitea.debiansrv.ddns.net/user/settings/applications → "Generate New Token". Give it any name (e.g. banns-package-publish) and check just the write:package scope. Copy the token — Gitea only shows it once.
  2. Add it as a repo secret at https://gitea.debiansrv.ddns.net/matmar/banns/settings/actions/secrets → "Add Secret". Name it BANNS_PACKAGE_TOKEN (exact spelling — the workflow looks for this key), paste the token, save.

After the secret exists, every future tag push publishes to:

  • PyPI registry: https://gitea.debiansrv.ddns.net/-/packages/pypi/cpp_banns/X.Y.Z
  • Generic registry: https://gitea.debiansrv.ddns.net/-/packages/generic/bayaneunets/vX.Y.Z

If the secret is absent, the workflow detects this and skips the publish steps with a friendly warning — the build itself still succeeds and the artifacts are still in the workflow run's Artifacts panel.

Fallback if no Actions runner

If the gitea instance has no Actions runner registered, run the local dry-run above on the dev machine and upload the resulting artifacts/ contents to a Gitea Release manually via the web UI or tea release create.


Limitations

  • Statistical, not bit-identical, equivalence with Python. numpy's PCG64 and libtorch's MT19937 produce different streams from the same seed, and libtorch's CPU vs CUDA RNGs also differ. Aggregate metrics agree within ±5%; individual samples do not match.
  • Re-port is manual on upstream changes. The sync script detects what changed but does not auto-translate Python to C++. See Re-syncing from upstream UiO.
  • CUDA build requires CUDA Toolkit ≥ 12.8 for the RTX 5080. Earlier toolkits compile but produce binaries that crash on Blackwell.
  • CUDA-only on the GPU side. AMD ROCm / Intel SYCL are out of scope.
  • Hand-coded backprop, not torch::autograd. The Python original uses hand-derived gradients; we preserve that for line-by-line auditability and to avoid autograd's tape-recording overhead on per-sweep small batches.

Requirements

Requires Python: >=3.10
Details
PyPI
2026-05-16 20:49:48 +02:00
2
Master Thesis Data Science (UiO) — C++ port
185 KiB
Assets (1)
Versions (1) View all
1.0.0 2026-05-16