Logdet Method Profiling Across Matrix Sizes¶
This notebook profiles the runtime of different log-determinant strategies used in neighbayes.
The profiling matrices come from a regular polygon grid generated by the neighbayes.dgp module. Each size n is a grid side length producing an n × n rook-contiguity layout with n² spatial units; the spatial graph is built from the polygon GeoDataFrame returned by the DGP function.
Methods compared:
Deterministic, exact
eigenvalue: precompute eigenvalues ofWonce; evaluatesum(log(1 - rho * lam_i))per call.
Cholesky–Chebyshev (exact, SPD symmetrised)
cheb_cholesky: D-symmetriseWto makeI - ρWsymmetric positive definite, then evaluate the exact logdet via sparse Cholesky (CHOLMOD) at Chebyshev nodes. Symbolic factorisation is reused across nodes (~64% setup speedup). Machine-precision accuracy (~1e-6) with ~1.4μs per-ρ evaluation via Clenshaw recurrence. Auto-selected for 500 < n ≤ 20000. Works for the full theoretical range ρ ∈ (-1, 1) with adaptive order selection.
AAA rational approximation (exact, non-symmetric W)
aaa: evaluate the exact logdet via sparse LU (UMFPACK) at adaptively-selected support points, then fit a rational function in barycentric form via the AAA algorithm (Nakatsukasa, Sète & Trefethen 2018). Rational approximation converges exponentially faster than polynomials near singularities, needing only ~6–15 support points. Best for non-symmetricW(directed graphs: KNN, travel time, migration) where Cholesky is unavailable.
Stochastic Chebyshev expansion (Han, Malioutov & Shin 2015)
cheb_stochastic: operator-valued Chebyshev polynomials with geometric (Bernstein ellipse) convergence. Same matvec cost as Barry-Pace but better accuracy at high |ρ|. Auto-selected for n > 20000.
Stochastic Lanczos Quadrature
slq: D-symmetrised batched Lanczos with Gauss quadrature trace estimation → Chebyshev coefficients. 300 matvecs, ρ-independent precompute.
Polynomial / spectral approximation
chebyshev: Barry-Pace Chebyshev polynomial approximation via Clenshaw recurrence (Pace & LeSage 2004). For largenwhere eigendecomposition is impractical, the Chebyshev coefficients are built from Hutchinson stochastic trace estimates.
For each matrix size, we report:
setup time: build + compile callable logdet function
evaluation time: average cost to evaluate at many rho values
import time
from dataclasses import dataclass
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import pytensor
import pytensor.tensor as pt
import scipy.sparse as sp
from libpysal import graph
from neighbayes import dgp
from neighbayes._logdet import make_logdet_fn, make_logdet_numpy_fn
def make_grid_w(n_side: int) -> np.ndarray:
"""Create a row-standardized rook-contiguity matrix from an n_side x n_side polygon grid.
Uses ``dgp.simulate_sar`` with ``create_gdf=True`` to generate the polygon
geometry, then builds a contiguity graph from the returned GeoDataFrame.
"""
gdf = dgp.simulate_sar(n=n_side, create_gdf=True)
W = (
graph.Graph.build_contiguity(gdf, rook=True)
.transform("r")
.sparse.toarray()
.astype(np.float64)
)
return W
def compile_logdet_callable(
W: np.ndarray,
method: str,
rho_min: float,
rho_max: float,
):
"""Return a compiled callable f(rho) and its setup time in seconds."""
t0 = time.perf_counter()
rho = pt.scalar("rho")
expr = make_logdet_fn(
W,
method=method,
rho_min=rho_min,
rho_max=rho_max,
)(rho)
fn = pytensor.function([rho], expr)
setup_s = time.perf_counter() - t0
return fn, setup_s
def bench_eval_seconds(fn, rhos: np.ndarray, repeats: int = 5) -> float:
"""Median per-call evaluation latency in microseconds."""
run_times = []
for _ in range(repeats):
t0 = time.perf_counter()
for r in rhos:
_ = fn(float(r))
elapsed = time.perf_counter() - t0
run_times.append(elapsed / len(rhos))
return float(np.median(run_times))
@dataclass
class ProfileConfig:
# Grid side lengths; obs count = n_side². e.g. 10→100, 20→400, 75→5625.
sizes: tuple[int, ...] = (10, 20, 25, 40, 50, 75)
method_specs: tuple[dict, ...] = (
{
"label": "eigenvalue",
"method": "eigenvalue",
"rho_min": -0.95,
"rho_max": 0.95,
},
{
"label": "cheb_stochastic",
"method": "cheb_stochastic",
"rho_min": -0.95,
"rho_max": 0.95,
},
{
"label": "slq",
"method": "slq",
"rho_min": -0.95,
"rho_max": 0.95,
},
{
"label": "chebyshev",
"method": "chebyshev",
"rho_min": -0.95,
"rho_max": 0.95,
},
{
"label": "cheb_cholesky",
"method": "cheb_cholesky",
"rho_min": -0.95,
"rho_max": 0.95,
},
{
"label": "aaa",
"method": "aaa",
"rho_min": -0.95,
"rho_max": 0.95,
},
)
method_max_n: dict = None
eval_points: int = 80
eval_repeats: int = 3
seed: int = 2026
def __post_init__(self):
if self.method_max_n is None:
self.method_max_n = {spec["label"]: 75 for spec in self.method_specs}
cfg = ProfileConfig()
results = []
skipped = []
for n in cfg.sizes:
W = make_grid_w(n_side=n)
print(f"Profiling n_side={n} ({n * n} obs)...")
for spec in cfg.method_specs:
label = spec["label"]
method = spec["method"]
if n > cfg.method_max_n[label]:
skipped.append(
{
"n_side": n,
"n_obs": n * n,
"method": label,
"reason": "above method_max_n cap",
}
)
continue
rho_min = spec["rho_min"]
rho_max = spec["rho_max"]
rho_grid = np.linspace(rho_min, rho_max, cfg.eval_points)
try:
fn, setup_s = compile_logdet_callable(
W,
method=method,
rho_min=rho_min,
rho_max=rho_max,
)
eval_s = bench_eval_seconds(fn, rho_grid, repeats=cfg.eval_repeats)
results.append(
{
"n_side": n,
"n_obs": n * n,
"method": label,
"logdet_method": method,
"rho_min": rho_min,
"rho_max": rho_max,
"setup_ms": 1e3 * setup_s,
"eval_us": 1e6 * eval_s,
}
)
except Exception as exc:
skipped.append(
{
"n_side": n,
"n_obs": n * n,
"method": label,
"reason": f"failed: {type(exc).__name__}: {exc}",
}
)
res = pd.DataFrame(results).sort_values(["method", "n_obs"]).reset_index(drop=True)
if not res.empty:
res["total_ms"] = res["setup_ms"] + (res["eval_us"] * cfg.eval_points / 1e3)
res
Profiling n_side=10 (100 obs)...
Profiling n_side=20 (400 obs)...
Profiling n_side=25 (625 obs)...
Profiling n_side=40 (1600 obs)...
Profiling n_side=50 (2500 obs)...
Profiling n_side=75 (5625 obs)...
| n_side | n_obs | method | logdet_method | rho_min | rho_max | setup_ms | eval_us | total_ms | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 10 | 100 | aaa | aaa | -0.95 | 0.95 | 1640.955251 | 5.126737 | 1641.365390 |
| 1 | 20 | 400 | aaa | aaa | -0.95 | 0.95 | 22.828061 | 5.033725 | 23.230759 |
| 2 | 25 | 625 | aaa | aaa | -0.95 | 0.95 | 25.889234 | 5.202862 | 26.305463 |
| 3 | 40 | 1600 | aaa | aaa | -0.95 | 0.95 | 50.399643 | 5.212375 | 50.816633 |
| 4 | 50 | 2500 | aaa | aaa | -0.95 | 0.95 | 86.634340 | 5.259575 | 87.055106 |
| 5 | 75 | 5625 | aaa | aaa | -0.95 | 0.95 | 309.281688 | 5.206750 | 309.698228 |
| 6 | 10 | 100 | cheb_cholesky | cheb_cholesky | -0.95 | 0.95 | 1354.836715 | 4.196462 | 1355.172432 |
| 7 | 20 | 400 | cheb_cholesky | cheb_cholesky | -0.95 | 0.95 | 1182.242592 | 4.887875 | 1182.633622 |
| 8 | 25 | 625 | cheb_cholesky | cheb_cholesky | -0.95 | 0.95 | 1185.626554 | 4.420562 | 1185.980199 |
| 9 | 40 | 1600 | cheb_cholesky | cheb_cholesky | -0.95 | 0.95 | 1434.204579 | 4.053487 | 1434.528858 |
| 10 | 50 | 2500 | cheb_cholesky | cheb_cholesky | -0.95 | 0.95 | 1277.762016 | 4.112462 | 1278.091013 |
| 11 | 75 | 5625 | cheb_cholesky | cheb_cholesky | -0.95 | 0.95 | 1525.654468 | 4.290363 | 1525.997697 |
| 12 | 10 | 100 | cheb_stochastic | cheb_stochastic | -0.95 | 0.95 | 1181.864091 | 4.201950 | 1182.200247 |
| 13 | 20 | 400 | cheb_stochastic | cheb_stochastic | -0.95 | 0.95 | 1189.362337 | 4.432825 | 1189.716963 |
| 14 | 25 | 625 | cheb_stochastic | cheb_stochastic | -0.95 | 0.95 | 1185.990463 | 4.105825 | 1186.318929 |
| 15 | 40 | 1600 | cheb_stochastic | cheb_stochastic | -0.95 | 0.95 | 1222.397421 | 4.722750 | 1222.775241 |
| 16 | 50 | 2500 | cheb_stochastic | cheb_stochastic | -0.95 | 0.95 | 1313.686315 | 4.238413 | 1314.025388 |
| 17 | 75 | 5625 | cheb_stochastic | cheb_stochastic | -0.95 | 0.95 | 1897.646323 | 4.527737 | 1898.008542 |
| 18 | 10 | 100 | chebyshev | chebyshev | -0.95 | 0.95 | 790.306473 | 3.635000 | 790.597273 |
| 19 | 20 | 400 | chebyshev | chebyshev | -0.95 | 0.95 | 822.276981 | 3.677938 | 822.571216 |
| 20 | 25 | 625 | chebyshev | chebyshev | -0.95 | 0.95 | 912.899863 | 4.083425 | 913.226537 |
| 21 | 40 | 1600 | chebyshev | chebyshev | -0.95 | 0.95 | 1746.825613 | 3.864463 | 1747.134770 |
| 22 | 50 | 2500 | chebyshev | chebyshev | -0.95 | 0.95 | 845.237855 | 3.670550 | 845.531499 |
| 23 | 75 | 5625 | chebyshev | chebyshev | -0.95 | 0.95 | 984.277552 | 3.997425 | 984.597346 |
| 24 | 10 | 100 | eigenvalue | eigenvalue | -0.95 | 0.95 | 3241.402433 | 5.374475 | 3241.832391 |
| 25 | 20 | 400 | eigenvalue | eigenvalue | -0.95 | 0.95 | 68.812448 | 8.000213 | 69.452465 |
| 26 | 25 | 625 | eigenvalue | eigenvalue | -0.95 | 0.95 | 155.317940 | 9.144812 | 156.049525 |
| 27 | 40 | 1600 | eigenvalue | eigenvalue | -0.95 | 0.95 | 983.526031 | 16.475875 | 984.844101 |
| 28 | 50 | 2500 | eigenvalue | eigenvalue | -0.95 | 0.95 | 3183.263262 | 23.609200 | 3185.151998 |
| 29 | 75 | 5625 | eigenvalue | eigenvalue | -0.95 | 0.95 | 32810.237609 | 46.176938 | 32813.931764 |
| 30 | 10 | 100 | slq | slq | -0.95 | 0.95 | 793.452168 | 4.248650 | 793.792060 |
| 31 | 20 | 400 | slq | slq | -0.95 | 0.95 | 804.810449 | 3.996163 | 805.130142 |
| 32 | 25 | 625 | slq | slq | -0.95 | 0.95 | 1037.457731 | 3.985762 | 1037.776592 |
| 33 | 40 | 1600 | slq | slq | -0.95 | 0.95 | 851.652118 | 3.740275 | 851.951340 |
| 34 | 50 | 2500 | slq | slq | -0.95 | 0.95 | 930.992394 | 3.636112 | 931.283283 |
| 35 | 75 | 5625 | slq | slq | -0.95 | 0.95 | 1377.439070 | 3.959738 | 1377.755849 |
if res.empty:
raise RuntimeError("No profiling results were generated.")
# Distinct color + marker per profiled configuration.
method_styles = {
"eigenvalue": {"color": "#ff7f0e", "marker": "s", "linestyle": "-"},
"cheb_stochastic": {"color": "#1f77b4", "marker": "D", "linestyle": "-"},
"slq": {"color": "#2ca02c", "marker": "o", "linestyle": "-"},
"chebyshev": {"color": "#000000", "marker": "*", "linestyle": "-"},
"cheb_cholesky": {"color": "#d62728", "marker": "^", "linestyle": "-"},
"aaa": {"color": "#9467bd", "marker": "v", "linestyle": "-"},
}
fig, axes = plt.subplots(1, 3, figsize=(17, 4.8), constrained_layout=True)
for method, grp in res.groupby("method"):
grp = grp.sort_values("n_obs")
style = method_styles.get(method, {"marker": "o"})
axes[0].plot(grp["n_obs"], grp["setup_ms"], label=method, **style)
axes[1].plot(grp["n_obs"], grp["eval_us"], label=method, **style)
axes[2].plot(grp["n_obs"], grp["total_ms"], label=method, **style)
axes[0].set_title("Setup Time vs n")
axes[0].set_xlabel("observations")
axes[0].set_ylabel("setup time (ms)")
axes[0].set_yscale("log")
axes[0].grid(True, alpha=0.3)
axes[1].set_title("Evaluation Time vs n")
axes[1].set_xlabel("observations")
axes[1].set_ylabel("time per rho eval (us)")
axes[1].set_yscale("log")
axes[1].grid(True, alpha=0.3)
axes[2].set_title(f"Total Time vs n (setup + {cfg.eval_points} evals)")
axes[2].set_xlabel("observations")
axes[2].set_ylabel("total time (ms)")
axes[2].set_yscale("log")
axes[2].grid(True, alpha=0.3)
# Single shared legend to the right of the figure so it doesn't crowd the panels.
handles, labels = axes[0].get_legend_handles_labels()
fig.legend(
handles,
labels,
loc="center left",
bbox_to_anchor=(1.0, 0.5),
frameon=False,
title="configuration",
)
plt.show()
summary = res.pivot_table(
index="n_obs", columns="method", values=["setup_ms", "eval_us", "total_ms"]
).sort_index()
display(summary)
if skipped:
skipped_df = (
pd.DataFrame(skipped).sort_values(["n_side", "method"]).reset_index(drop=True)
)
print("Skipped combinations (due to safety caps or failures):")
display(skipped_df)
| eval_us | setup_ms | total_ms | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| method | aaa | cheb_cholesky | cheb_stochastic | chebyshev | eigenvalue | slq | aaa | cheb_cholesky | cheb_stochastic | chebyshev | eigenvalue | slq | aaa | cheb_cholesky | cheb_stochastic | chebyshev | eigenvalue | slq |
| n_obs | ||||||||||||||||||
| 100 | 5.126737 | 4.196462 | 4.201950 | 3.635000 | 5.374475 | 4.248650 | 1640.955251 | 1354.836715 | 1181.864091 | 790.306473 | 3241.402433 | 793.452168 | 1641.365390 | 1355.172432 | 1182.200247 | 790.597273 | 3241.832391 | 793.792060 |
| 400 | 5.033725 | 4.887875 | 4.432825 | 3.677938 | 8.000213 | 3.996163 | 22.828061 | 1182.242592 | 1189.362337 | 822.276981 | 68.812448 | 804.810449 | 23.230759 | 1182.633622 | 1189.716963 | 822.571216 | 69.452465 | 805.130142 |
| 625 | 5.202862 | 4.420562 | 4.105825 | 4.083425 | 9.144812 | 3.985762 | 25.889234 | 1185.626554 | 1185.990463 | 912.899863 | 155.317940 | 1037.457731 | 26.305463 | 1185.980199 | 1186.318929 | 913.226537 | 156.049525 | 1037.776592 |
| 1600 | 5.212375 | 4.053487 | 4.722750 | 3.864463 | 16.475875 | 3.740275 | 50.399643 | 1434.204579 | 1222.397421 | 1746.825613 | 983.526031 | 851.652118 | 50.816633 | 1434.528858 | 1222.775241 | 1747.134770 | 984.844101 | 851.951340 |
| 2500 | 5.259575 | 4.112462 | 4.238413 | 3.670550 | 23.609200 | 3.636112 | 86.634340 | 1277.762016 | 1313.686315 | 845.237855 | 3183.263262 | 930.992394 | 87.055106 | 1278.091013 | 1314.025388 | 845.531499 | 3185.151998 | 931.283283 |
| 5625 | 5.206750 | 4.290363 | 4.527737 | 3.997425 | 46.176938 | 3.959738 | 309.281688 | 1525.654468 | 1897.646323 | 984.277552 | 32810.237609 | 1377.439070 | 309.698228 | 1525.997697 | 1898.008542 | 984.597346 | 32813.931764 | 1377.755849 |
Logdet Approximation Accuracy¶
This section directly compares how accurately each stochastic method approximates the true log-determinant curve, independent of sampling noise. We compute the exact log|I - ρW| via eigenvalues and compare against each method’s approximation across a dense ρ grid.
from neighbayes._logdet import (
slq_logdet_eval,
slq_logdet_precompute,
)
from neighbayes._logdet._aaa import (
aaa_logdet_eval,
aaa_logdet_precompute,
)
from neighbayes._logdet._chol_cheb import (
chol_cheb_logdet_eval,
chol_cheb_logdet_precompute,
)
# Use the same W from the last profiling run (or regenerate)
W_accuracy = make_grid_w(n_side=25)
W_sp = sp.csr_matrix(W_accuracy)
# Exact logdet via eigenvalues
W_dense = W_sp.toarray()
eigs = np.linalg.eigvals(W_dense)
rho_grid_acc = np.linspace(-0.95, 0.95, 200)
exact_logdet = np.array([np.sum(np.log(np.abs(1 - r * eigs))) for r in rho_grid_acc])
# Chebyshev approximation (coefficients built from exact eigenvalues when available)
cheb_fn = make_logdet_numpy_fn(
W_sp,
eigs,
method="chebyshev",
rho_min=-0.95,
rho_max=0.95,
)
cheb_approx = np.array([cheb_fn(r) for r in rho_grid_acc])
cheb_err = np.abs(cheb_approx - exact_logdet)
# SLQ approximation (Arnoldi-based, ρ-independent precompute)
slq_pre = slq_logdet_precompute(
W_sp, n_probes=20, lanczos_deg=30, rng=np.random.default_rng(0)
)
slq_approx = np.array([slq_logdet_eval(slq_pre, r) for r in rho_grid_acc])
slq_err = np.abs(slq_approx - exact_logdet)
# Cholesky-Chebyshev (exact via sparse Cholesky at Chebyshev nodes, SPD symmetrized)
# Use the empirical range [0.1, 0.8] where the method is most accurate
rho_grid_emp = np.linspace(0.1, 0.8, 200)
exact_logdet_emp = np.array(
[np.sum(np.log(np.abs(1 - r * eigs))) for r in rho_grid_emp]
)
chol_pre = chol_cheb_logdet_precompute(W_sp, order=None, rho_min=0.1, rho_max=0.8)
chol_approx = np.array([chol_cheb_logdet_eval(chol_pre, r) for r in rho_grid_emp])
chol_err = np.abs(chol_approx - exact_logdet_emp)
# AAA rational approximation (sparse LU at adaptively-selected support points)
aaa_pre = aaa_logdet_precompute(W_sp, rho_min=0.1, rho_max=0.8)
aaa_approx = np.array([aaa_logdet_eval(aaa_pre, r) for r in rho_grid_emp])
aaa_err = np.abs(aaa_approx - exact_logdet_emp)
acc_df = pd.DataFrame(
[
{
"method": "chebyshev",
"logdet_method": "chebyshev",
"max_err": cheb_err.max(),
"mean_err": cheb_err.mean(),
"rmse": np.sqrt((cheb_err**2).mean()),
},
{
"method": "slq",
"logdet_method": "slq",
"max_err": slq_err.max(),
"mean_err": slq_err.mean(),
"rmse": np.sqrt((slq_err**2).mean()),
},
{
"method": "cheb_cholesky",
"logdet_method": "cheb_cholesky",
"max_err": chol_err.max(),
"mean_err": chol_err.mean(),
"rmse": np.sqrt((chol_err**2).mean()),
},
{
"method": "aaa",
"logdet_method": "aaa",
"max_err": aaa_err.max(),
"mean_err": aaa_err.mean(),
"rmse": np.sqrt((aaa_err**2).mean()),
},
]
)
display(acc_df)
fig, axes = plt.subplots(1, 2, figsize=(16, 5), constrained_layout=True)
# Full range [-0.95, 0.95]
axes[0].plot(rho_grid_acc, cheb_approx - exact_logdet, label="chebyshev", alpha=0.7)
axes[0].plot(rho_grid_acc, slq_approx - exact_logdet, label="slq", alpha=0.7)
axes[0].axhline(0, color="black", linestyle="--", linewidth=0.8)
axes[0].set_xlabel("rho")
axes[0].set_ylabel("approximation error")
axes[0].set_title("Logdet Error vs Exact (full range [-0.95, 0.95])")
axes[0].legend()
axes[0].grid(True, alpha=0.3)
# Empirical range [0.1, 0.8]
axes[1].plot(
rho_grid_emp,
chol_approx - exact_logdet_emp,
label="cheb_cholesky",
alpha=0.7,
color="#d62728",
)
axes[1].plot(
rho_grid_emp, aaa_approx - exact_logdet_emp, label="aaa", alpha=0.7, color="#9467bd"
)
axes[1].axhline(0, color="black", linestyle="--", linewidth=0.8)
axes[1].set_xlabel("rho")
axes[1].set_ylabel("approximation error")
axes[1].set_title("Logdet Error vs Exact (empirical range [0.1, 0.8])")
axes[1].legend()
axes[1].grid(True, alpha=0.3)
plt.show()
| method | logdet_method | max_err | mean_err | rmse | |
|---|---|---|---|---|---|
| 0 | chebyshev | chebyshev | 4.306964e-03 | 7.462411e-04 | 1.016806e-03 |
| 1 | slq | slq | 2.336356e+00 | 1.588955e-01 | 3.938684e-01 |
| 2 | cheb_cholesky | cheb_cholesky | 4.601689e-08 | 1.405270e-08 | 1.684522e-08 |
| 3 | aaa | aaa | 2.617881e-09 | 2.087496e-10 | 4.372022e-10 |
Coefficient and Fit-Time Comparison Across Logdet Methods¶
This section uses a regular polygon grid generated by neighbayes.dgp to simulate one SAR dataset, maps the simulated response, and estimates the same SAR model using each logdet_method.
We compare:
posterior mean coefficients (
rho,beta_0,beta_1,beta_2)total wall-clock time to estimate each model
To keep this section runnable in docs contexts, sampling is intentionally modest.
from neighbayes.models import SAR
def simulate_sar_data(n_side: int = 25, seed: int = 2026):
"""Simulate SAR data on an n_side x n_side polygon grid using the DGP module."""
rng = np.random.default_rng(seed)
beta_true = np.array([1.0, 0.8, -0.5], dtype=np.float64)
rho_true = 0.35
sigma_true = 0.7
gdf = dgp.simulate_sar(
n=n_side,
rho=rho_true,
beta=beta_true,
sigma=sigma_true,
rng=rng,
create_gdf=True,
)
# Keep the SAR parameterization consistent with model assumptions.
W_graph = graph.Graph.build_contiguity(gdf, rook=True).transform("r")
y = gdf["y"].to_numpy()
X_cols = [c for c in gdf.columns if c.startswith("X_")]
X = gdf[X_cols].to_numpy()
return gdf, y, X, W_graph, rho_true, beta_true
def fit_sar_for_method(
y,
X,
W,
method: str,
label: str | None = None,
draws: int = 1000,
tune: int = 1000,
seed: int = 2026,
):
"""Fit SAR with a specific logdet configuration and return posterior means + runtime."""
t0 = time.perf_counter()
model = SAR(
y=y,
X=X,
W=W,
logdet_method=method,
)
idata = model.fit(
draws=draws,
tune=tune,
chains=2,
random_seed=seed,
progressbar=False,
)
elapsed_s = time.perf_counter() - t0
beta_mean = idata.posterior["beta"].mean(("chain", "draw")).to_numpy()
rho_mean = float(idata.posterior["rho"].mean(("chain", "draw")).to_numpy())
return {
"method": label or method,
"logdet_method": method,
"beta_mean": beta_mean,
"rho_mean": rho_mean,
"fit_seconds": elapsed_s,
}
# Build the dataset once and sweep the methods we actually compare.
gdf_model, y_model, X_model, W_model, rho_true, beta_true = simulate_sar_data(n_side=25)
methods_for_model = [
{"label": "eigenvalue", "method": "eigenvalue"},
{"label": "slq", "method": "slq"},
{"label": "chebyshev", "method": "chebyshev"},
{"label": "cheb_stochastic", "method": "cheb_stochastic"},
{"label": "cheb_cholesky", "method": "cheb_cholesky"},
{"label": "aaa", "method": "aaa"},
]
fit_rows = []
for spec in methods_for_model:
print(f"Fitting SAR with logdet_method={spec['method']} ...")
fit_rows.append(
fit_sar_for_method(
y_model,
X_model,
W_model,
method=spec["method"],
label=spec["label"],
)
)
coef_df = pd.DataFrame(fit_rows)
display(coef_df)
Fitting SAR with logdet_method=eigenvalue ...
Fitting SAR with logdet_method=slq ...
Fitting SAR with logdet_method=chebyshev ...
Fitting SAR with logdet_method=cheb_stochastic ...
Fitting SAR with logdet_method=cheb_cholesky ...
Fitting SAR with logdet_method=aaa ...
/home/runner/micromamba/envs/test/lib/python3.14/site-packages/neighbayes/_logdet/_jax.py:188: ComplexWarning: Casting complex values to real discards the imaginary part
W_arr = np.asarray(W, dtype=np.float64)
| method | logdet_method | beta_mean | rho_mean | fit_seconds | |
|---|---|---|---|---|---|
| 0 | eigenvalue | eigenvalue | [0.8829390472288875, 0.810449676856428, -0.520... | 0.381051 | 5.040280 |
| 1 | slq | slq | [0.8826773783402142, 0.8104350777546576, -0.52... | 0.381204 | 3.643140 |
| 2 | chebyshev | chebyshev | [0.882350057385162, 0.8104140460408557, -0.520... | 0.381346 | 3.592100 |
| 3 | cheb_stochastic | cheb_stochastic | [0.8829390472288875, 0.810449676856428, -0.520... | 0.381051 | 3.360947 |
| 4 | cheb_cholesky | cheb_cholesky | [0.881127514064843, 0.8103407296240626, -0.520... | 0.382184 | 3.145226 |
| 5 | aaa | aaa | [0.8817424383728726, 0.8103785199214184, -0.52... | 0.381834 | 2.951647 |
Notes¶
eigenvaluecarries a one-time O(n³) eigendecomposition cost, then evaluates in O(n) perrho. Strong choice for repeated evaluation at moderate n.slqruns Arnoldi iteration onWonce (300 matvecs by default), producing Gauss quadrature rules that can evaluate at anyrhoin O(k) time. Best accuracy in the moderate-ρ range (0.3–0.7) where the sampler spends most time.chebyshevbuilds Chebyshev coefficients once, then evaluates in O(m) per call. For small matrices or supplied eigenvalues it uses exact spectral coefficients; for large matrices it uses Barry-Pace Hutchinson stochastic trace estimates.cheb_choleskyevaluates the exact logdet via sparse Cholesky (CHOLMOD) at Chebyshev nodes after D-symmetrisingWto makeI - ρWSPD. Symbolic factorisation is reused across nodes (~64% setup speedup). Machine-precision accuracy (~1e-6) with ~1.4μs per-ρ evaluation via Clenshaw recurrence. Best for symmetricW(undirected graphs) with n ∈ (500, 20000].aaaevaluates the exact logdet via sparse LU (UMFPACK) at adaptively-selected support points, then fits a rational function in barycentric form via the AAA algorithm. Rational approximation converges exponentially faster than polynomials near singularities, needing only ~6-15 support points. Best for non-symmetricW(directed graphs: KNN, travel time, migration) where Cholesky is unavailable.Parameter ranges:
eigenvalue,slq,chebyshev, andcheb_stochasticare profiled on the full symmetric range[-0.95, 0.95].cheb_choleskyandaaause the empirical range[0.1, 0.8]where they are most accurate (closer to the singularity at ρ=1 requires higher order / more support points).Adjust
ProfileConfig.sizesandmethod_max_nfor deeper stress tests.
import arviz as az
import pandas as pd
# Refit each logdet configuration and compare effective sample sizes for rho.
ess_rows = []
for spec in methods_for_model:
label = spec["label"]
model = SAR(
y=y_model,
X=X_model,
W=W_model,
logdet_method=spec["method"],
priors={"rho_lower": -1, "rho_upper": 1},
)
idata = model.fit(
draws=1000,
tune=1000,
chains=2,
random_seed=2026,
progressbar=False,
)
summary = az.summary(idata, var_names=["rho"])
ess = summary.loc["rho", "ess_bulk"]
ess_rows.append(
{
"method": label,
"logdet_method": spec["method"],
"ess_rho": ess,
}
)
az.plot_trace(idata, var_names=["rho"], compact=True, legend=False)
plt.suptitle(label)
ess_df = pd.DataFrame(ess_rows)
display(ess_df)
ess_df.set_index("method")["ess_rho"].plot.bar(
ylabel="ESS (rho)", title="Effective Sample Size for rho by Logdet Configuration"
)
/home/runner/micromamba/envs/test/lib/python3.14/site-packages/neighbayes/_logdet/_jax.py:188: ComplexWarning: Casting complex values to real discards the imaginary part
W_arr = np.asarray(W, dtype=np.float64)
| method | logdet_method | ess_rho | |
|---|---|---|---|
| 0 | eigenvalue | eigenvalue | 1826.0 |
| 1 | slq | slq | 1810.0 |
| 2 | chebyshev | chebyshev | 1986.0 |
| 3 | cheb_stochastic | cheb_stochastic | 1826.0 |
| 4 | cheb_cholesky | cheb_cholesky | 2022.0 |
| 5 | aaa | aaa | 2116.0 |
<Axes: title={'center': 'Effective Sample Size for rho by Logdet Configuration'}, xlabel='method', ylabel='ESS (rho)'>
Method selection policy¶
The auto-selector (logdet_method=None) chooses:
eigenvalueforn ≤ 500(exact, fast per-call evaluation)cheb_choleskyfor500 < n ≤ 20000(exact via sparse Cholesky + Chebyshev interpolation, SPD symmetrized)cheb_stochasticforn > 20000(stochastic Chebyshev expansion, avoids factorisation entirely)
Manual overrides:
Default for most work. Leave
logdet_method=Noneand let the auto-selector choose. For moderate n this resolves tocheb_cholesky(exact, fast), for large n tocheb_stochastic.Exact at moderate n. Use
logdet_method="eigenvalue"when an O(n³) eigendecomposition is affordable and you want bit-for-bit reproducibility.Exact for symmetric W, medium n. Use
logdet_method="cheb_cholesky"for exact logdet via sparse Cholesky at Chebyshev nodes. Machine-precision accuracy, ~1.4μs per-ρ eval. Requires undirected graph (symmetrisable W).Non-symmetric W. Use
logdet_method="aaa"for rational approximation via sparse LU at adaptively-selected support points. Works with directed graphs (KNN, travel time, migration flows).Polynomial approximation. Use
logdet_method="chebyshev"for near-minimax polynomial approximation via Clenshaw recurrence.Reporting and publication. Record
logdet_methodandrhobounds in every benchmark or model report.