Pólya-Gamma Model Backend Profiling: numpy vs JAX (sparse)

This notebook re-profiles the Pólya-Gamma spatial models — SAR-logit and the structural SAR negative binomial — across the two Gibbs execution backends, now that the JAX path is fully sparse:

  • numpy — CHOLMOD sparse Cholesky (sksparse) for the augmented β/η block, numpy PG draws, and a numpy log-determinant callable. The long-standing default.

  • jax — a single @jax.jit Gibbs loop using cholmod_jax (cholgraph, JAX-native sparse CHOLMOD) for the β/η solve, BCOO sparse W matvecs (the matrix is never densified), and a JAX-native differentiable log-determinant.

The two W regimes (this is the important axis)

The augmented β/η precision AᵀΩA + prior is symmetric SPD for any W, so the solve is a Cholesky either way. The backend split that actually matters is the log-determinant log|I − ρW|, and it hinges on whether W is D-symmetrizable, not on whether the matrix is literally symmetric:

  • Symmetric adjacency → row-standardized (rook/queen contiguity): W = D⁻¹A with A symmetric, so W is D-symmetrizable even though the row-standardized matrix is not literally symmetric. → exact Cholesky logdet (cheb_cholesky / cholmod).

  • Asymmetric adjacency → row-standardized (directed KNN, travel time, migration): the underlying adjacency is not symmetric, so W is not D-symmetrizable and a Cholesky logdet does not exist. → exact LU logdet (aaa, KLU-seeded).

Both logdet methods are exact (machine precision); they differ only in the factorization the matrix admits. max|W − Wᵀ| is not the discriminator — a row-standardized rook W and a row-standardized KNN W can have identical raw asymmetry while landing on different logdet paths. We therefore key on the auto-resolved logdet method reported by the model.

Question

With the sparse JAX path in place, does jax now beat numpy on CPU, and at what n? Both models currently register auto_backend="numpy"; this benchmark is the evidence for whether that default should flip.

import time
import warnings
from dataclasses import dataclass

import arviz as az
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from libpysal import graph

from neighbayes import dgp
from neighbayes.models import SARLogit, SARNegBin

warnings.filterwarnings("ignore")
/home/runner/micromamba/envs/test/lib/python3.14/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html
  from .autonotebook import tqdm as notebook_tqdm
def build_w(gdf, kind: str, k: int = 6):
    """Row-standardized W. ``kind="sym"`` = rook contiguity (D-symmetrizable →
    Cholesky logdet); ``kind="asym"`` = directed KNN on centroids
    (non-D-symmetrizable → LU/aaa logdet)."""
    if kind == "sym":
        return graph.Graph.build_contiguity(gdf, rook=True).transform("r")
    pts = gdf.copy()
    pts["geometry"] = gdf.geometry.centroid
    return graph.Graph.build_knn(pts, k=k).transform("r")


def make_dataset(model: str, n_side: int, kind: str, seed: int = 2026):
    """Simulate one PG dataset on an ``n_side × n_side`` grid with the chosen W."""
    gdf = dgp.simulate_sar(n=n_side, create_gdf=True, seed=seed)
    W = build_w(gdf, kind)
    sim = (dgp.simulate_sar_logit if model == "logit" else dgp.simulate_sar_negbin)(
        W=W, gdf=gdf, rho=0.4, seed=seed
    )
    y = sim["y"].to_numpy() if hasattr(sim["y"], "to_numpy") else np.asarray(sim["y"])
    X_cols = [c for c in sim.columns if c.startswith("X_")]
    X = sim[X_cols].to_numpy()
    return y, X, W


def fit_and_time(model: str, y, X, W, backend: str, draws, tune, chains, seed=2026):
    """Fit one PG model on one backend; return timing + ESS + resolved logdet method."""
    Model = SARLogit if model == "logit" else SARNegBin
    m = Model(y=y, X=X, W=W)
    t0 = time.perf_counter()
    idata = m.fit(
        draws=draws,
        tune=tune,
        chains=chains,
        random_seed=seed,
        progressbar=False,
        gibbs_backend=backend,
        sampler="gibbs",
    )
    elapsed = time.perf_counter() - t0
    ess = float(az.summary(idata, var_names=["rho"]).loc["rho", "ess_bulk"])
    rho_hat = float(idata.posterior["rho"].mean())
    return {
        "total_s": elapsed,
        "ess_rho": ess,
        "ess_per_s": ess / elapsed,
        "rho_hat": rho_hat,
        "logdet_method": m._resolved_logdet_method,
    }
@dataclass
class Config:
    # grid sides -> obs = side²: 20→400, 30→900, 45→2025, 71→5041 (≥5000)
    sides: tuple = (20, 30, 45, 71)
    models: tuple = ("logit", "nb")
    w_kinds: tuple = ("sym", "asym")
    backends: tuple = ("numpy", "jax")
    draws: int = 400
    tune: int = 400
    chains: int = 2
    seed: int = 2026


cfg = Config()
rows = []
for side in cfg.sides:
    n = side * side
    for model in cfg.models:
        for kind in cfg.w_kinds:
            y, X, W = make_dataset(model, side, kind, seed=cfg.seed)
            for backend in cfg.backends:
                print(
                    f"n={n:5d} | {model:5s} | W={kind:4s} | backend={backend} ...",
                    flush=True,
                )
                try:
                    r = fit_and_time(
                        model,
                        y,
                        X,
                        W,
                        backend,
                        cfg.draws,
                        cfg.tune,
                        cfg.chains,
                        cfg.seed,
                    )
                    r.update(n_obs=n, model=model, w_kind=kind, backend=backend)
                    rows.append(r)
                except Exception as exc:  # keep the sweep going
                    print(f"    FAILED: {type(exc).__name__}: {exc}")

res = pd.DataFrame(rows)
res
n=  400 | logit | W=sym  | backend=numpy ...
n=  400 | logit | W=sym  | backend=jax ...
n=  400 | logit | W=asym | backend=numpy ...
n=  400 | logit | W=asym | backend=jax ...
n=  400 | nb    | W=sym  | backend=numpy ...
n=  400 | nb    | W=sym  | backend=jax ...
n=  400 | nb    | W=asym | backend=numpy ...
n=  400 | nb    | W=asym | backend=jax ...
n=  900 | logit | W=sym  | backend=numpy ...
n=  900 | logit | W=sym  | backend=jax ...
n=  900 | logit | W=asym | backend=numpy ...
n=  900 | logit | W=asym | backend=jax ...
n=  900 | nb    | W=sym  | backend=numpy ...
n=  900 | nb    | W=sym  | backend=jax ...
n=  900 | nb    | W=asym | backend=numpy ...
n=  900 | nb    | W=asym | backend=jax ...
n= 2025 | logit | W=sym  | backend=numpy ...
n= 2025 | logit | W=sym  | backend=jax ...
n= 2025 | logit | W=asym | backend=numpy ...
n= 2025 | logit | W=asym | backend=jax ...
n= 2025 | nb    | W=sym  | backend=numpy ...
n= 2025 | nb    | W=sym  | backend=jax ...
n= 2025 | nb    | W=asym | backend=numpy ...
n= 2025 | nb    | W=asym | backend=jax ...
n= 5041 | logit | W=sym  | backend=numpy ...
n= 5041 | logit | W=sym  | backend=jax ...
n= 5041 | logit | W=asym | backend=numpy ...
n= 5041 | logit | W=asym | backend=jax ...
n= 5041 | nb    | W=sym  | backend=numpy ...
n= 5041 | nb    | W=sym  | backend=jax ...
n= 5041 | nb    | W=asym | backend=numpy ...
n= 5041 | nb    | W=asym | backend=jax ...
total_s ess_rho ess_per_s rho_hat logdet_method n_obs model w_kind backend
0 4.626102 644.0 139.210078 0.407026 eigenvalue 400 logit sym numpy
1 2.877476 596.0 207.125993 0.411499 eigenvalue 400 logit sym jax
2 9.385594 549.0 58.493902 0.286821 eigenvalue 400 logit asym numpy
3 2.702539 573.0 212.022876 0.294478 eigenvalue 400 logit asym jax
4 5.492101 512.0 93.224799 0.310197 eigenvalue 400 nb sym numpy
5 2.669504 566.0 212.024435 0.310236 eigenvalue 400 nb sym jax
6 6.068312 553.0 91.129139 0.481693 eigenvalue 400 nb asym numpy
7 2.578503 476.0 184.603242 0.483538 eigenvalue 400 nb asym jax
8 6.929463 678.0 97.843075 0.365717 chol_aaa 900 logit sym numpy
9 3.288190 726.0 220.790138 0.367117 chol_aaa 900 logit sym jax
10 10.067103 458.0 45.494716 0.385569 aaa 900 logit asym numpy
11 3.945417 549.0 139.148802 0.378072 aaa 900 logit asym jax
12 6.386023 421.0 65.925227 0.316779 chol_aaa 900 nb sym numpy
13 3.652455 421.0 115.264937 0.319204 chol_aaa 900 nb sym jax
14 7.733325 362.0 46.810398 0.374221 aaa 900 nb asym numpy
15 3.707910 422.0 113.810739 0.371923 aaa 900 nb asym jax
16 6.879222 629.0 91.434759 0.326141 chol_aaa 2025 logit sym numpy
17 4.415690 587.0 132.935071 0.331321 chol_aaa 2025 logit sym jax
18 10.354158 753.0 72.724406 0.278453 aaa 2025 logit asym numpy
19 4.950617 748.0 151.092274 0.274259 aaa 2025 logit asym jax
20 7.836816 464.0 59.207718 0.365762 chol_aaa 2025 nb sym numpy
21 5.294758 434.0 81.967856 0.369997 chol_aaa 2025 nb sym jax
22 8.316810 509.0 61.201347 0.405620 aaa 2025 nb asym numpy
23 5.264100 553.0 105.051196 0.408422 aaa 2025 nb asym jax
24 8.240712 518.0 62.858645 0.374226 chol_aaa 5041 logit sym numpy
25 4.610965 588.0 127.522114 0.374079 chol_aaa 5041 logit sym jax
26 8.881234 674.0 75.890355 0.352340 aaa 5041 logit asym numpy
27 6.023660 738.0 122.516868 0.350102 aaa 5041 logit asym jax
28 12.543090 535.0 42.652968 0.388053 chol_aaa 5041 nb sym numpy
29 9.794676 445.0 45.432843 0.385796 chol_aaa 5041 nb sym jax
30 13.083420 615.0 47.006059 0.392792 aaa 5041 nb asym numpy
31 9.853810 528.0 53.583334 0.394930 aaa 5041 nb asym jax
# Confirm the two W regimes land on the two exact logdet methods.
if not res.empty:
    method_map = (
        res.groupby(["w_kind"])["logdet_method"].unique().apply(sorted).to_dict()
    )
    print("Resolved logdet method by W regime:")
    for kind, methods in method_map.items():
        print(f"  W={kind:4s} -> {methods}")
    display(
        res.pivot_table(
            index=["model", "n_obs"], columns=["w_kind", "backend"], values="total_s"
        ).round(2)
    )
Resolved logdet method by W regime:
  W=asym -> ['aaa', 'eigenvalue']
  W=sym  -> ['chol_aaa', 'eigenvalue']
w_kind asym sym
backend jax numpy jax numpy
model n_obs
logit 400 2.70 9.39 2.88 4.63
900 3.95 10.07 3.29 6.93
2025 4.95 10.35 4.42 6.88
5041 6.02 8.88 4.61 8.24
nb 400 2.58 6.07 2.67 5.49
900 3.71 7.73 3.65 6.39
2025 5.26 8.32 5.29 7.84
5041 9.85 13.08 9.79 12.54
if not res.empty:
    fig, axes = plt.subplots(2, 2, figsize=(13, 9), constrained_layout=True)
    styles = {"numpy": dict(marker="o", ls="-"), "jax": dict(marker="s", ls="--")}
    colors = {"sym": "#1f77b4", "asym": "#d62728"}
    for j, model in enumerate(("logit", "nb")):
        for backend in cfg.backends:
            for kind in cfg.w_kinds:
                sub = res[
                    (res.model == model)
                    & (res.backend == backend)
                    & (res.w_kind == kind)
                ].sort_values("n_obs")
                if sub.empty:
                    continue
                lbl = f"{backend}·{kind}"
                axes[0, j].plot(
                    sub.n_obs,
                    sub.total_s,
                    color=colors[kind],
                    alpha=0.9 if backend == "jax" else 0.5,
                    label=lbl,
                    **styles[backend],
                )
                axes[1, j].plot(
                    sub.n_obs,
                    sub.ess_per_s,
                    color=colors[kind],
                    alpha=0.9 if backend == "jax" else 0.5,
                    label=lbl,
                    **styles[backend],
                )
        axes[0, j].set(
            title=f"{model}: total time vs n",
            xlabel="observations",
            ylabel="total time (s)",
            yscale="log",
        )
        axes[1, j].set(
            title=f"{model}: ESS(rho)/sec vs n",
            xlabel="observations",
            ylabel="ESS per second",
            yscale="log",
        )
        for ax in (axes[0, j], axes[1, j]):
            ax.grid(True, alpha=0.3)
            ax.legend(fontsize=8)
    plt.show()
../_images/ca72d26628a1784aa0ec0b1ce96dcc583fd1f7cf50d2b498787cfbbac8d62869.png
# Correctness: numpy and jax must agree (within MC noise) on the posterior.
# Deterministic side-check of the JAX exact logdet is in logdet_profiling.ipynb;
# here we confirm the end-to-end samplers agree on rho.
if not res.empty:
    agree = res.pivot_table(
        index=["model", "w_kind", "n_obs"], columns="backend", values="rho_hat"
    ).dropna()
    agree["abs_diff"] = (agree["numpy"] - agree["jax"]).abs()
    print("Posterior mean rho: numpy vs jax (should agree within MC noise)")
    display(agree.round(3))
Posterior mean rho: numpy vs jax (should agree within MC noise)
backend jax numpy abs_diff
model w_kind n_obs
logit asym 400 0.294 0.287 0.008
900 0.378 0.386 0.007
2025 0.274 0.278 0.004
5041 0.350 0.352 0.002
sym 400 0.411 0.407 0.004
900 0.367 0.366 0.001
2025 0.331 0.326 0.005
5041 0.374 0.374 0.000
nb asym 400 0.484 0.482 0.002
900 0.372 0.374 0.002
2025 0.408 0.406 0.003
5041 0.395 0.393 0.002
sym 400 0.310 0.310 0.000
900 0.319 0.317 0.002
2025 0.370 0.366 0.004
5041 0.386 0.388 0.002

Reading the results

  • Two exact logdet paths, auto-selected by W regime. The sym (rook) datasets resolve to the Cholesky logdet; the asym (directed KNN) datasets resolve to the LU-based aaa logdet. Both are exact — the split is which factorization W admits, driven by D-symmetrizability, not raw matrix asymmetry.

  • Backend crossover. numpy/CHOLMOD historically won on CPU at small–moderate n (dispatch-free per-iteration factorization). The fully-sparse jax path pays a one-time JIT-compile cost (folded into total time here) but then runs a single fused kernel per sweep; its advantage grows with n. The crossover point is the practically important number.

  • ess_per_s is the fair summary — it nets out any per-iteration cost differences and answers “which backend gives more effective draws per wall-second.”

  • Same posterior. numpy and jax use different RNG streams, so exact agreement is not expected, but the rho posterior means should match within Monte Carlo noise.

  • auto_backend policy. Both PG models register auto_backend="numpy". If jax wins on ess_per_s above some n, the auto-selector should prefer jax beyond that threshold (mirroring the size-based logdet auto-selection).

Caveats. Single-run wall-clock at benchmark sizes; NUTS-free Gibbs so timings are dominated by the per-sweep linear algebra. The JAX total time includes compilation — amortized away across longer chains than the modest draws used here.