Metadata-Version: 2.2
Name: cpp_banns
Version: 1.0.0
Summary: C++ port of BANNs.py (libtorch backend) with optional CUDA support
Author: Master Thesis Data Science (UiO) — C++ port
Requires-Python: >=3.10
Requires-Dist: numpy>=1.24
Requires-Dist: torch>=2.0
Provides-Extra: test
Requires-Dist: pytest>=7.0; extra == "test"
Description-Content-Type: text/markdown

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

This folder is a C++ port of [../Python Code/BANNs.py](../Python%20Code/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`:

  ```bash
  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:

  ```bash
  # 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:

  ```bash
  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:

```bash
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

```bash
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)

```bash
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:

```bash
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:

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

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

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

---

## Quickstart — use `cpp_banns` from Python

```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

```python
# 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](../Python%20Code/BANNs.py) exactly. To switch a
driver script over, edit one line:

```diff
- 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/](../Python%20Code/) is mirrored from
the read-only upstream UiO repository (`upstream` git remote — see the repo
root). When UiO publishes updated Python code, run:

```bash
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/](./.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`:

   ```bash
   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:

```bash
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`](../.gitea/workflows/release.yml) fires on
git tag pushes matching `v[0-9]+.[0-9]+.[0-9]+`. Cut a release with:

```bash
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](https://docs.gitea.com/usage/actions/overview)).
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:

```bash
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)`:

```python
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](https://docs.gitea.com/usage/actions/comparison).

**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](#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.
