Bayesian spatial models: a walkthrough¶
By the end of this lesson you will have fitted six models to one dataset, watched OLS fail a diagnostic that the spatial models pass, and used the package’s own tests to arrive at the specification the data support.
You do not need to know the spatial econometrics beforehand — each model is introduced when the previous one runs out of road. Work through the cells in order; every step produces something you can look at.
Every model here accepts a formula and a weights graph:
SAR(formula="y ~ x1 + x2", data=gdf, W=W)
The equations behind each one are in Supported Models if you want them; you will not need them to follow along.
import arviz as az
import geodatasets
import geopandas as gpd
import matplotlib.pyplot as plt
import pandas as pd
from libpysal.graph import Graph
from neighbayes.diagnostics.bayesfactor import bayes_factor_compare_models
from neighbayes.models import OLS, SAR, SDEM, SDM, SEM, SLX
az.style.use("arviz-white")
/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
# Load sample spatial dataset
gdf = gpd.read_file(geodatasets.data.geoda.airbnb.url)
xcols = ["poverty", "rev_rating", "num_spots", "crowded"]
ycol = "price_pp"
gdf = gdf.dropna(subset=xcols + [ycol]).copy()
# Build contiguity graph (queen)
W = Graph.build_contiguity(gdf, rook=False).transform("r")
print(f"Observations: {len(gdf)}")
print(f"Predictors: {xcols}")
Observations: 67
Predictors: ['poverty', 'rev_rating', 'num_spots', 'crowded']
Helper Functions
To keep each model section focused, we use utility functions to:
fit with consistent MCMC settings,
print posterior summaries,
tabulate direct/indirect/total effects.
For a quick pedagogical run, we use small draw counts. Increase these for real inference.
def fit_and_report(model_cls, formula, data, W, draws=400, tune=400, chains=4, seed=42):
"""Fit a spatial model and return (model, summary, effects_df).
Uses minimal MCMC settings for pedagogical demonstration.
Increase draws/tune for real analyses.
"""
model = model_cls(formula=formula, data=data, W=W, logdet_method="eigenvalue")
model.fit(
draws=draws,
tune=tune,
chains=chains,
random_seed=seed,
progressbar=False,
idata_kwargs={"log_likelihood": True},
)
summary = model.summary(round_to=3)
effects_df = pd.DataFrame(model.spatial_effects())
return model, summary, effects_df
Convergence Diagnostics Helper
For each fitted model, we will inspect:
r_hat(target close to 1.00)effective sample sizes (
ess_bulk,ess_tail)trace plots for key parameters.
def diagnostics_table(idata, var_names):
"""Show key MCMC diagnostics for the given parameters."""
cols = ["mean", "sd", "ess_bulk", "ess_tail", "r_hat"]
return az.summary(idata, var_names=var_names, round_to=3)[cols]
def show_trace(idata, var_names, title):
"""Plot trace plots for the given parameters."""
az.plot_trace(idata, var_names=var_names)
plt.suptitle(title, y=1.02)
plt.tight_layout()
plt.show()
The models, one at a time¶
Start with OLS, and see what it misses¶
Model:
ols = OLS(
formula="price_pp ~ poverty + rev_rating + num_spots + crowded",
data=gdf,
W=W,
)
olsfit = ols.fit(
draws=400,
tune=400,
chains=4,
random_seed=42,
progressbar=False,
idata_kwargs={"log_likelihood": True},
)
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (4 chains in 2 jobs)
NUTS: [beta, sigma2]
Sampling 4 chains for 400 tune and 400 draw iterations (1_600 + 1_600 draws total) took 13 seconds.
ols.summary()
| mean | sd | hdi_3% | hdi_97% | mcse_mean | mcse_sd | ess_bulk | ess_tail | r_hat | |
|---|---|---|---|---|---|---|---|---|---|
| Intercept | 34.304 | 60.873 | -75.650 | 148.171 | 2.331 | 1.625 | 683.0 | 850.0 | 1.0 |
| poverty | 0.219 | 0.337 | -0.451 | 0.807 | 0.011 | 0.009 | 958.0 | 1032.0 | 1.0 |
| rev_rating | 0.384 | 0.646 | -0.865 | 1.549 | 0.025 | 0.018 | 686.0 | 850.0 | 1.0 |
| num_spots | 0.120 | 0.024 | 0.076 | 0.167 | 0.001 | 0.001 | 1114.0 | 986.0 | 1.0 |
| crowded | -2.240 | 0.964 | -4.068 | -0.454 | 0.028 | 0.024 | 1152.0 | 997.0 | 1.0 |
| sigma2 | 760.110 | 137.576 | 514.365 | 1013.764 | 4.210 | 4.060 | 1111.0 | 1046.0 | 1.0 |
| sigma | 27.461 | 2.450 | 22.780 | 31.933 | 0.074 | 0.065 | 1111.0 | 1046.0 | 1.0 |
Add the neighbours’ covariates (SLX)¶
Model:
Interpretation:
betacaptures local covariate effects.thetacaptures neighbor-covariate spillovers.No spatial lag on \(y\), so no autoregressive propagation through outcomes.
slx, summary_slx, effects_slx = fit_and_report(
SLX,
formula="price_pp ~ poverty + rev_rating + num_spots + crowded",
data=gdf,
W=W,
)
display(summary_slx)
display(effects_slx)
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (4 chains in 2 jobs)
NUTS: [beta, sigma2]
Sampling 4 chains for 400 tune and 400 draw iterations (1_600 + 1_600 draws total) took 16 seconds.
| mean | sd | hdi_3% | hdi_97% | mcse_mean | mcse_sd | ess_bulk | ess_tail | r_hat | |
|---|---|---|---|---|---|---|---|---|---|
| Intercept | 89.774 | 76.776 | -49.068 | 236.743 | 2.846 | 1.800 | 739.464 | 731.272 | 1.007 |
| poverty | -0.609 | 0.456 | -1.438 | 0.233 | 0.014 | 0.012 | 1035.774 | 737.892 | 1.001 |
| rev_rating | 0.608 | 0.752 | -0.655 | 2.111 | 0.022 | 0.018 | 1131.814 | 1101.190 | 1.001 |
| num_spots | 0.023 | 0.033 | -0.040 | 0.085 | 0.001 | 0.001 | 1038.612 | 1045.020 | 1.002 |
| crowded | -1.029 | 1.214 | -3.455 | 1.150 | 0.041 | 0.027 | 894.497 | 824.635 | 1.004 |
| W*poverty | 1.287 | 0.629 | 0.076 | 2.394 | 0.019 | 0.018 | 1145.391 | 820.396 | 1.000 |
| W*rev_rating | -0.975 | 0.941 | -2.846 | 0.692 | 0.031 | 0.023 | 904.702 | 883.591 | 1.009 |
| W*num_spots | 0.194 | 0.049 | 0.103 | 0.286 | 0.002 | 0.001 | 814.179 | 847.357 | 1.008 |
| W*crowded | -1.684 | 1.615 | -4.569 | 1.454 | 0.051 | 0.040 | 1015.970 | 1055.031 | 1.004 |
| sigma2 | 601.982 | 112.115 | 421.786 | 814.672 | 3.096 | 3.656 | 1382.910 | 992.499 | 1.001 |
| sigma | 24.433 | 2.242 | 20.620 | 28.604 | 0.061 | 0.066 | 1382.910 | 992.499 | 1.002 |
| direct | direct_ci_lower | direct_ci_upper | direct_pvalue | indirect | indirect_ci_lower | indirect_ci_upper | indirect_pvalue | total | total_ci_lower | total_ci_upper | total_pvalue | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| variable | ||||||||||||
| poverty | -0.609429 | -1.503888 | 0.254490 | 0.18375 | 1.287254 | 0.097030 | 2.547024 | 0.03250 | 0.677825 | -0.149122 | 1.503368 | 0.1275 |
| rev_rating | 0.608013 | -0.857204 | 2.081878 | 0.42375 | -0.974812 | -2.843300 | 0.874559 | 0.30375 | -0.366799 | -1.989623 | 1.174650 | 0.6725 |
| num_spots | 0.022631 | -0.047673 | 0.086548 | 0.47375 | 0.193666 | 0.103714 | 0.294280 | 0.00000 | 0.216297 | 0.156463 | 0.282983 | 0.0000 |
| crowded | -1.028763 | -3.423304 | 1.413084 | 0.37750 | -1.683775 | -4.914515 | 1.415826 | 0.29500 | -2.712538 | -5.147152 | -0.466462 | 0.0225 |
az.plot_forest(slx.inference_data)
display(diagnostics_table(slx.inference_data, ["beta", "sigma"]))
show_trace(slx.inference_data, ["sigma"], "SLX Trace: sigma")
| mean | sd | ess_bulk | ess_tail | r_hat | |
|---|---|---|---|---|---|
| beta[Intercept] | 89.774 | 76.776 | 739.464 | 731.272 | 1.007 |
| beta[poverty] | -0.609 | 0.456 | 1035.774 | 737.892 | 1.001 |
| beta[rev_rating] | 0.608 | 0.752 | 1131.814 | 1101.190 | 1.001 |
| beta[num_spots] | 0.023 | 0.033 | 1038.612 | 1045.020 | 1.002 |
| beta[crowded] | -1.029 | 1.214 | 894.497 | 824.635 | 1.004 |
| beta[W*poverty] | 1.287 | 0.629 | 1145.391 | 820.396 | 1.000 |
| beta[W*rev_rating] | -0.975 | 0.941 | 904.702 | 883.591 | 1.009 |
| beta[W*num_spots] | 0.194 | 0.049 | 814.179 | 847.357 | 1.008 |
| beta[W*crowded] | -1.684 | 1.615 | 1015.970 | 1055.031 | 1.004 |
| sigma | 24.433 | 2.242 | 1382.910 | 992.499 | 1.002 |
/tmp/ipykernel_11205/3581438262.py:11: UserWarning: The figure layout has changed to tight
plt.tight_layout()
Let the outcome itself spill over (SAR)¶
Model:
Interpretation:
\(\rho\) controls feedback from neighboring outcomes.
Effects are amplified through \((I - \rho W)^{-1}\), so direct and indirect impacts differ from raw \(\beta\).
sar, summary_sar, effects_sar = fit_and_report(
SAR,
formula="price_pp ~ poverty + rev_rating + num_spots + crowded",
data=gdf,
W=W,
)
summary_sar
/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)
| mean | sd | hdi_3% | hdi_97% | mcse_mean | mcse_sd | ess_bulk | ess_tail | r_hat | |
|---|---|---|---|---|---|---|---|---|---|
| rho | 0.430 | 0.135 | 0.183 | 0.693 | 0.003 | 0.003 | 1551.439 | 1317.324 | 1.001 |
| sigma | 25.429 | 2.211 | 21.580 | 29.825 | 0.058 | 0.047 | 1449.005 | 1390.197 | 1.001 |
| sigma2 | 651.537 | 114.755 | 457.757 | 880.123 | 3.013 | 2.789 | 1449.005 | 1390.197 | 1.001 |
| Intercept | 16.073 | 60.068 | -102.182 | 123.203 | 1.551 | 1.058 | 1500.723 | 1618.080 | 1.001 |
| poverty | 0.029 | 0.316 | -0.565 | 0.609 | 0.008 | 0.006 | 1614.876 | 1574.109 | 1.001 |
| rev_rating | 0.288 | 0.641 | -1.078 | 1.383 | 0.017 | 0.011 | 1499.921 | 1475.097 | 1.001 |
| num_spots | 0.079 | 0.025 | 0.034 | 0.127 | 0.001 | 0.001 | 1544.044 | 1486.345 | 1.002 |
| crowded | -1.712 | 0.902 | -3.463 | -0.104 | 0.023 | 0.015 | 1598.993 | 1516.523 | 1.000 |
effects_sar
| direct | direct_ci_lower | direct_ci_upper | direct_pvalue | indirect | indirect_ci_lower | indirect_ci_upper | indirect_pvalue | total | total_ci_lower | total_ci_upper | total_pvalue | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| variable | ||||||||||||
| poverty | 0.030780 | -0.616819 | 0.685859 | 0.95125 | 0.020644 | -0.542073 | 0.650480 | 0.95250 | 0.051424 | -1.113733 | 1.238435 | 0.95125 |
| rev_rating | 0.305388 | -1.081107 | 1.665712 | 0.64250 | 0.230108 | -0.905863 | 1.718661 | 0.64375 | 0.535496 | -1.992336 | 3.210643 | 0.64250 |
| num_spots | 0.083954 | 0.032009 | 0.137614 | 0.00000 | 0.063418 | 0.009356 | 0.169505 | 0.00125 | 0.147372 | 0.051657 | 0.291715 | 0.00000 |
| crowded | -1.814524 | -3.683152 | 0.098869 | 0.06250 | -1.360144 | -4.278443 | 0.055350 | 0.06375 | -3.174668 | -7.727311 | 0.175706 | 0.06250 |
az.plot_forest(sar.inference_data)
display(diagnostics_table(sar.inference_data, ["rho", "beta", "sigma"]))
show_trace(sar.inference_data, ["rho", "sigma"], "SAR Trace: rho, sigma")
| mean | sd | ess_bulk | ess_tail | r_hat | |
|---|---|---|---|---|---|
| rho | 0.430 | 0.135 | 1551.439 | 1317.324 | 1.001 |
| beta[Intercept] | 16.073 | 60.068 | 1500.723 | 1618.080 | 1.001 |
| beta[poverty] | 0.029 | 0.316 | 1614.876 | 1574.109 | 1.001 |
| beta[rev_rating] | 0.288 | 0.641 | 1499.921 | 1475.097 | 1.001 |
| beta[num_spots] | 0.079 | 0.025 | 1544.044 | 1486.345 | 1.002 |
| beta[crowded] | -1.712 | 0.902 | 1598.993 | 1516.523 | 1.000 |
| sigma | 25.429 | 2.211 | 1449.005 | 1390.197 | 1.001 |
/tmp/ipykernel_11205/3581438262.py:11: UserWarning: The figure layout has changed to tight
plt.tight_layout()
Treat the clustering as nuisance instead (SEM)¶
Model:
Interpretation:
Spatial dependence is moved to latent shocks rather than outcome feedback.
Useful when omitted spatial factors induce correlated residuals.
sem, summary_sem, effects_sem = fit_and_report(
SEM,
formula="price_pp ~ poverty + rev_rating + num_spots + crowded",
data=gdf,
W=W,
)
display(summary_sem)
display(effects_sem)
/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)
| mean | sd | hdi_3% | hdi_97% | mcse_mean | mcse_sd | ess_bulk | ess_tail | r_hat | |
|---|---|---|---|---|---|---|---|---|---|
| lam | 0.489 | 0.176 | 0.138 | 0.786 | 0.005 | 0.004 | 1525.126 | 1343.762 | 1.001 |
| sigma | 26.087 | 2.296 | 21.993 | 30.569 | 0.060 | 0.049 | 1452.437 | 1358.397 | 1.000 |
| sigma2 | 685.776 | 122.355 | 483.678 | 934.466 | 3.226 | 2.956 | 1452.437 | 1358.397 | 1.000 |
| Intercept | 20.974 | 58.338 | -85.075 | 132.744 | 1.495 | 1.028 | 1522.358 | 1611.833 | 1.001 |
| poverty | -0.159 | 0.441 | -0.954 | 0.666 | 0.011 | 0.008 | 1532.719 | 1380.956 | 1.000 |
| rev_rating | 0.611 | 0.630 | -0.657 | 1.753 | 0.016 | 0.011 | 1515.520 | 1589.968 | 1.002 |
| num_spots | 0.073 | 0.034 | 0.013 | 0.139 | 0.001 | 0.001 | 1502.598 | 1488.721 | 1.001 |
| crowded | -1.831 | 1.152 | -3.985 | 0.262 | 0.029 | 0.021 | 1606.060 | 1251.815 | 1.000 |
| direct | direct_ci_lower | direct_ci_upper | direct_pvalue | indirect | indirect_ci_lower | indirect_ci_upper | indirect_pvalue | total | total_ci_lower | total_ci_upper | total_pvalue | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| variable | ||||||||||||
| poverty | -0.159132 | -1.068804 | 0.652745 | 0.7150 | 0.0 | 0.0 | 0.0 | 0.0 | -0.159132 | -1.068804 | 0.652745 | 0.7150 |
| rev_rating | 0.611211 | -0.669602 | 1.868173 | 0.3175 | 0.0 | 0.0 | 0.0 | 0.0 | 0.611211 | -0.669602 | 1.868173 | 0.3175 |
| num_spots | 0.073299 | 0.004002 | 0.136186 | 0.0425 | 0.0 | 0.0 | 0.0 | 0.0 | 0.073299 | 0.004002 | 0.136186 | 0.0425 |
| crowded | -1.830834 | -4.004642 | 0.529181 | 0.1200 | 0.0 | 0.0 | 0.0 | 0.0 | -1.830834 | -4.004642 | 0.529181 | 0.1200 |
az.plot_forest(sem.inference_data)
display(diagnostics_table(sem.inference_data, ["lam", "beta", "sigma"]))
show_trace(sem.inference_data, ["lam", "sigma"], "SEM Trace: lam, sigma")
| mean | sd | ess_bulk | ess_tail | r_hat | |
|---|---|---|---|---|---|
| lam | 0.489 | 0.176 | 1525.126 | 1343.762 | 1.001 |
| beta[Intercept] | 20.974 | 58.338 | 1522.358 | 1611.833 | 1.001 |
| beta[poverty] | -0.159 | 0.441 | 1532.719 | 1380.956 | 1.000 |
| beta[rev_rating] | 0.611 | 0.630 | 1515.520 | 1589.968 | 1.002 |
| beta[num_spots] | 0.073 | 0.034 | 1502.598 | 1488.721 | 1.001 |
| beta[crowded] | -1.831 | 1.152 | 1606.060 | 1251.815 | 1.000 |
| sigma | 26.087 | 2.296 | 1452.437 | 1358.397 | 1.000 |
/tmp/ipykernel_11205/3581438262.py:11: UserWarning: The figure layout has changed to tight
plt.tight_layout()
Combine both channels (SDM)¶
Model:
Interpretation:
Includes both outcome feedback and neighbor-covariate channels.
Often used as a flexible nesting model for SAR/SLX.
sdm, summary_sdm, effects_sdm = fit_and_report(
SDM,
formula="price_pp ~ poverty + rev_rating + num_spots + crowded",
data=gdf,
W=W,
)
display(summary_sdm)
display(effects_sdm)
/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)
| mean | sd | hdi_3% | hdi_97% | mcse_mean | mcse_sd | ess_bulk | ess_tail | r_hat | |
|---|---|---|---|---|---|---|---|---|---|
| rho | 0.229 | 0.163 | -0.087 | 0.522 | 0.004 | 0.004 | 1585.399 | 1249.928 | 1.001 |
| sigma | 24.098 | 2.138 | 20.590 | 28.554 | 0.058 | 0.045 | 1367.823 | 1534.073 | 1.001 |
| sigma2 | 585.264 | 105.176 | 400.285 | 785.391 | 2.853 | 2.483 | 1367.823 | 1534.073 | 1.001 |
| Intercept | 88.353 | 77.536 | -61.153 | 231.811 | 2.078 | 1.408 | 1392.868 | 1421.182 | 1.002 |
| poverty | -0.634 | 0.440 | -1.452 | 0.173 | 0.011 | 0.008 | 1611.936 | 1598.968 | 1.001 |
| rev_rating | 0.653 | 0.758 | -0.774 | 2.101 | 0.019 | 0.013 | 1576.420 | 1659.903 | 1.000 |
| num_spots | 0.016 | 0.031 | -0.047 | 0.069 | 0.001 | 0.001 | 1666.324 | 1565.305 | 1.001 |
| crowded | -0.968 | 1.183 | -3.246 | 1.105 | 0.031 | 0.021 | 1466.797 | 1521.188 | 1.002 |
| W*poverty | 1.167 | 0.613 | -0.030 | 2.267 | 0.016 | 0.012 | 1504.523 | 1502.548 | 1.002 |
| W*rev_rating | -1.149 | 0.953 | -2.857 | 0.671 | 0.026 | 0.017 | 1384.228 | 1450.976 | 1.001 |
| W*num_spots | 0.163 | 0.050 | 0.071 | 0.258 | 0.001 | 0.001 | 1657.056 | 1647.218 | 1.003 |
| W*crowded | -1.292 | 1.679 | -4.600 | 1.665 | 0.043 | 0.029 | 1556.008 | 1467.845 | 1.002 |
| direct | direct_ci_lower | direct_ci_upper | direct_pvalue | indirect | indirect_ci_lower | indirect_ci_upper | indirect_pvalue | total | total_ci_lower | total_ci_upper | total_pvalue | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| variable | ||||||||||||
| poverty | -0.577846 | -1.403080 | 0.279090 | 0.18250 | 1.301492 | -0.063521 | 2.753672 | 0.06750 | 0.723645 | -0.360529 | 2.054139 | 0.22125 |
| rev_rating | 0.598544 | -0.877290 | 2.052779 | 0.41250 | -1.272170 | -3.594445 | 0.926502 | 0.25125 | -0.673626 | -3.069641 | 1.611446 | 0.54250 |
| num_spots | 0.026531 | -0.035684 | 0.086140 | 0.40375 | 0.217657 | 0.086016 | 0.394405 | 0.00000 | 0.244188 | 0.118888 | 0.425800 | 0.00000 |
| crowded | -1.067520 | -3.207157 | 1.197672 | 0.35125 | -2.014851 | -6.289802 | 1.839746 | 0.33500 | -3.082371 | -7.211803 | 0.215577 | 0.07500 |
az.plot_forest(sdm.inference_data)
display(diagnostics_table(sdm.inference_data, ["rho", "beta", "sigma"]))
show_trace(sdm.inference_data, ["rho", "sigma"], "SDM Trace: rho, sigma")
| mean | sd | ess_bulk | ess_tail | r_hat | |
|---|---|---|---|---|---|
| rho | 0.229 | 0.163 | 1585.399 | 1249.928 | 1.001 |
| beta[Intercept] | 88.353 | 77.536 | 1392.868 | 1421.182 | 1.002 |
| beta[poverty] | -0.634 | 0.440 | 1611.936 | 1598.968 | 1.001 |
| beta[rev_rating] | 0.653 | 0.758 | 1576.420 | 1659.903 | 1.000 |
| beta[num_spots] | 0.016 | 0.031 | 1666.324 | 1565.305 | 1.001 |
| beta[crowded] | -0.968 | 1.183 | 1466.797 | 1521.188 | 1.002 |
| beta[W*poverty] | 1.167 | 0.613 | 1504.523 | 1502.548 | 1.002 |
| beta[W*rev_rating] | -1.149 | 0.953 | 1384.228 | 1450.976 | 1.001 |
| beta[W*num_spots] | 0.163 | 0.050 | 1657.056 | 1647.218 | 1.003 |
| beta[W*crowded] | -1.292 | 1.679 | 1556.008 | 1467.845 | 1.002 |
| sigma | 24.098 | 2.138 | 1367.823 | 1534.073 | 1.001 |
/tmp/ipykernel_11205/3581438262.py:11: UserWarning: The figure layout has changed to tight
plt.tight_layout()
The error-side combination (SDEM)¶
Model:
Interpretation:
Neighbor covariates matter directly (through \(WX\)),
and unmodeled shocks are spatially correlated (through \(u\)).
sdem, summary_sdem, effects_sdem = fit_and_report(
SDEM,
formula="price_pp ~ poverty + rev_rating + num_spots + crowded",
data=gdf,
W=W,
)
display(summary_sdem)
display(effects_sdem)
/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)
| mean | sd | hdi_3% | hdi_97% | mcse_mean | mcse_sd | ess_bulk | ess_tail | r_hat | |
|---|---|---|---|---|---|---|---|---|---|
| lam | 0.325 | 0.187 | -0.007 | 0.689 | 0.005 | 0.005 | 1451.228 | 1162.854 | 1.003 |
| sigma | 24.159 | 2.142 | 20.137 | 28.150 | 0.058 | 0.044 | 1359.933 | 1459.839 | 1.001 |
| sigma2 | 588.263 | 105.655 | 405.509 | 792.429 | 2.870 | 2.430 | 1359.933 | 1459.839 | 1.001 |
| Intercept | 89.304 | 78.324 | -64.159 | 234.760 | 2.105 | 1.426 | 1385.310 | 1457.200 | 1.003 |
| poverty | -0.551 | 0.424 | -1.360 | 0.248 | 0.010 | 0.008 | 1674.855 | 1615.808 | 1.000 |
| rev_rating | 0.728 | 0.688 | -0.532 | 2.076 | 0.017 | 0.012 | 1569.599 | 1572.163 | 1.001 |
| num_spots | 0.028 | 0.030 | -0.027 | 0.082 | 0.001 | 0.001 | 1563.386 | 1533.317 | 1.000 |
| crowded | -1.052 | 1.149 | -3.300 | 0.916 | 0.029 | 0.021 | 1544.621 | 1461.216 | 1.001 |
| W*poverty | 1.140 | 0.658 | -0.093 | 2.365 | 0.017 | 0.013 | 1504.776 | 1502.020 | 1.002 |
| W*rev_rating | -1.057 | 0.859 | -2.615 | 0.525 | 0.023 | 0.015 | 1430.407 | 1447.225 | 1.003 |
| W*num_spots | 0.180 | 0.049 | 0.093 | 0.277 | 0.001 | 0.001 | 1681.046 | 1574.454 | 1.000 |
| W*crowded | -1.947 | 1.881 | -5.372 | 1.719 | 0.049 | 0.036 | 1507.133 | 1479.839 | 1.002 |
| direct | direct_ci_lower | direct_ci_upper | direct_pvalue | indirect | indirect_ci_lower | indirect_ci_upper | indirect_pvalue | total | total_ci_lower | total_ci_upper | total_pvalue | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| variable | ||||||||||||
| poverty | -0.551439 | -1.392223 | 0.301967 | 0.20000 | 1.139665 | -0.161241 | 2.406471 | 0.08500 | 0.588227 | -0.538419 | 1.693977 | 0.29375 |
| rev_rating | 0.728421 | -0.670654 | 2.067441 | 0.27375 | -1.057182 | -2.747472 | 0.561737 | 0.22250 | -0.328761 | -1.971905 | 1.321780 | 0.69375 |
| num_spots | 0.028240 | -0.028823 | 0.083605 | 0.33625 | 0.180411 | 0.080730 | 0.276904 | 0.00000 | 0.208650 | 0.123103 | 0.291763 | 0.00125 |
| crowded | -1.051651 | -3.218586 | 1.243445 | 0.34375 | -1.946911 | -5.732658 | 1.695646 | 0.29375 | -2.998562 | -6.378927 | 0.257203 | 0.07250 |
az.plot_forest(sdem.inference_data)
display(diagnostics_table(sdem.inference_data, ["lam", "beta", "sigma"]))
show_trace(sdem.inference_data, ["lam", "sigma"], "SDEM Trace: lam, sigma")
| mean | sd | ess_bulk | ess_tail | r_hat | |
|---|---|---|---|---|---|
| lam | 0.325 | 0.187 | 1451.228 | 1162.854 | 1.003 |
| beta[Intercept] | 89.304 | 78.324 | 1385.310 | 1457.200 | 1.003 |
| beta[poverty] | -0.551 | 0.424 | 1674.855 | 1615.808 | 1.000 |
| beta[rev_rating] | 0.728 | 0.688 | 1569.599 | 1572.163 | 1.001 |
| beta[num_spots] | 0.028 | 0.030 | 1563.386 | 1533.317 | 1.000 |
| beta[crowded] | -1.052 | 1.149 | 1544.621 | 1461.216 | 1.001 |
| beta[W*poverty] | 1.140 | 0.658 | 1504.776 | 1502.020 | 1.002 |
| beta[W*rev_rating] | -1.057 | 0.859 | 1430.407 | 1447.225 | 1.003 |
| beta[W*num_spots] | 0.180 | 0.049 | 1681.046 | 1574.454 | 1.000 |
| beta[W*crowded] | -1.947 | 1.881 | 1507.133 | 1479.839 | 1.002 |
| sigma | 24.159 | 2.142 | 1359.933 | 1459.839 | 1.001 |
/tmp/ipykernel_11205/3581438262.py:11: UserWarning: The figure layout has changed to tight
plt.tight_layout()
Check the chains before comparing anything¶
Bayesian estimation of spatial models has a well-known efficiency
pitfall: the spatial dependence parameter \(\rho\) (or \(\lambda\)) often
mixes slowly, so a chain that looks converged in its point estimate
can still produce posterior credible intervals that are 10–12 % too
narrow because the sampler has not visited the tails enough times
[Wolf et al., 2018]. The
spatial_mcmc_diagnostic helper checks effective sample size, sampler
yield, \(\hat{R}\), and HPDI stability for the spatial parameter and
warns when any threshold is violated.
from neighbayes.diagnostics import spatial_mcmc_diagnostic
# Run on the SDM (most parameters; both rho and impact summaries depend on it)
report = spatial_mcmc_diagnostic(sdm, emit_warnings=False)
report.to_frame()
| ess_bulk | ess_tail | r_hat | mcse_mean | yield_pct | hpdi_drift_pct | adequate | |
|---|---|---|---|---|---|---|---|
| parameter | |||||||
| rho | 1585.399321 | 1249.927855 | 1.000685 | 0.004124 | 99.087458 | 7.980914 | False |
sdm.spatial_diagnostics()
| statistic | median | df | p_value | ci_lower | ci_upper | |
|---|---|---|---|---|---|---|
| test | ||||||
| LM-Error-SDM | 156.886909 | 5.872738 | 1 | 0.000000 | 0.006098 | 1315.299194 |
| Robust-LM-Error-SDM | 4.382309 | 4.052241 | 1 | 0.036314 | 0.080023 | 10.133382 |
ols.spatial_diagnostics()
| statistic | median | df | p_value | ci_lower | ci_upper | |
|---|---|---|---|---|---|---|
| test | ||||||
| LM-Lag | 19.611839 | 9.879244 | 1 | 0.000009 | 0.033314 | 91.967413 |
| LM-Error | 3.414405 | 2.556607 | 1 | 0.064630 | 0.549058 | 11.566860 |
| LM-SDM-Joint | 3742.238771 | 1570.118204 | 5 | 0.000000 | 23.498883 | 18981.871009 |
| LM-SLX-Error-Joint | 3740.288168 | 1568.402328 | 5 | 0.000000 | 23.728712 | 18983.593561 |
| Robust-LM-Lag | 11.250429 | 10.678782 | 1 | 0.000796 | 5.146118 | 20.791696 |
| Robust-LM-Error | 5.958842 | 5.656067 | 1 | 0.014644 | 2.725666 | 11.012419 |
sar.spatial_diagnostics_decision()
sem.spatial_diagnostics()
| statistic | median | df | p_value | ci_lower | ci_upper | |
|---|---|---|---|---|---|---|
| test | ||||||
| LM-Lag | 159.909959 | 51.451165 | 1 | 0.000000 | 0.108970 | 967.317919 |
| LM-WX | 27238.536421 | 7087.647426 | 4 | 0.000000 | 65.799124 | 165107.947711 |
| Robust-LM-Lag | 4.248355 | 0.918227 | 1 | 0.039288 | 0.002069 | 26.202227 |
| Robust-LM-WX | 2427.095741 | 507.164469 | 4 | 0.000000 | 7.272236 | 15578.197815 |
report
SpatialMCMCReport(parameters=['rho'], ess_bulk={'rho': 1585.3993209654486}, ess_tail={'rho': 1249.92785492861}, r_hat={'rho': 1.000685177748173}, mcse_mean={'rho': 0.0041244539862753124}, nominal_size=1600, yield_pct={'rho': 99.08745756034054}, hpdi_drift_pct={'rho': 7.980914100963144}, warnings_triggered=["95% HPDI width for 'rho' drifts by 8.0% between the last third and the full chain (> 5%); the credible interval has not yet stabilized. Consider doubling `draws` and/or `tune`, or re-running with more chains. See Wolf, Anselin & Arribas-Bel (2018), Geographical Analysis 50:97-119."], adequate=False, adequate_by_param={'rho': False})
Compare what you have fitted¶
# Collect all fitted models for comparison
model_dict = {
"OLS": ols,
"SLX": slx,
"SAR": sar,
"SEM": sem,
"SDM": sdm,
"SDEM": sdem,
}
idata_dict = {name: m.inference_data for name, m in model_dict.items()}
Bayes Factor Model Comparison¶
Bayes factors provide an alternative to information criteria for comparing competing models. While WAIC and LOO assess predictive performance, Bayes factors compare marginal likelihoods — the probability of the data under each model, integrated over all parameter values weighted by the prior:
This makes Bayes factors sensitive to the prior: models with diffuse priors on unnecessary parameters are penalized more heavily, because the marginal likelihood averages over all possible parameter values weighted by the prior. In spatial models, this means that models with many WX coefficients under wide priors (e.g., SLX, SDM, SDEM) can receive substantially lower marginal likelihoods than more parsimonious alternatives (e.g., OLS, SAR, SEM) — even if their log-likelihoods are similar.
Interpreting Bayes factors (Kass & Raftery, 1995):
BF range |
Evidence strength |
|---|---|
1 – 3 |
Anecdotal |
3 – 10 |
Moderate |
10 – 30 |
Strong |
30 – 100 |
Very strong |
> 100 |
Extreme |
Method. We use bridge sampling (Meng & Wong, 1996) to estimate each model’s log marginal likelihood, following the R bridgesampling package (Gronau et al., 2020). The implementation uses ESS weighting, two-phase convergence, and MCSE diagnostics. For reliable estimates, 40,000+ posterior samples are recommended.
Important caveat. Bayes factors can be very sensitive to prior specification. Models with diffuse priors on many parameters will tend to have lower marginal likelihoods. This is by design — it is the Bayesian Occam’s razor at work — but it means that Bayes factors and information criteria may disagree when priors are uninformative.
bayes_factor_compare_models(model_dict, method="bridge", log=True).round(3)
/tmp/ipykernel_11205/497883469.py:1: UserWarning: Bridge sampling with 1600 posterior samples for 'OLS' may yield imprecise marginal-likelihood estimates. A conservative rule of thumb is 40,000+ samples (Gronau, Singmann, & Wagenmakers, 2017).
bayes_factor_compare_models(model_dict, method="bridge", log=True).round(3)
| OLS | SLX | SAR | SEM | SDM | SDEM | |
|---|---|---|---|---|---|---|
| OLS | 0.000 | 3.021 | -0.179 | -1.277 | 8.249 | 3.281 |
| SLX | -3.021 | 0.000 | -3.200 | -4.298 | 5.228 | 0.260 |
| SAR | 0.179 | 3.200 | 0.000 | -1.098 | 8.428 | 3.460 |
| SEM | 1.277 | 4.298 | 1.098 | 0.000 | 9.526 | 4.558 |
| SDM | -8.249 | -5.228 | -8.428 | -9.526 | 0.000 | -4.968 |
| SDEM | -3.281 | -0.260 | -3.460 | -4.558 | 4.968 | 0.000 |
bayes_factor_compare_models(model_dict, method="bic", log=True).round(3)
| OLS | SLX | SAR | SEM | SDM | SDEM | |
|---|---|---|---|---|---|---|
| OLS | 0.000 | -1.190 | -2.223 | 0.421 | 0.213 | 0.576 |
| SLX | 1.190 | 0.000 | -1.033 | 1.612 | 1.403 | 1.766 |
| SAR | 2.223 | 1.033 | 0.000 | 2.645 | 2.436 | 2.799 |
| SEM | -0.421 | -1.612 | -2.645 | 0.000 | -0.209 | 0.154 |
| SDM | -0.213 | -1.403 | -2.436 | 0.209 | 0.000 | 0.363 |
| SDEM | -0.576 | -1.766 | -2.799 | -0.154 | -0.363 | 0.000 |
Bridge Sampling vs. BIC¶
BIC: SAR > SLX > OLS > SEM (SAR wins, SLX is moderate, SEM is worst)
Bridge: SAR > SEM > OLS (SAR wins, SEM is moderate)
The two methods can produce qualitatively different model rankings. This is expected and reflects a fundamental difference in what they assume about priors:
BIC approximates \(\log(ML) \approx \hat\ell\_{\max} - \frac{k}{2}\log(n)\), which assumes unit-information priors (priors containing as much information as a single observation). The penalty per parameter is fixed at \(\frac{1}{2}\log(n) \approx 2.1\) for \(n = 77\).
Bridge sampling integrates over the actual priors in the model. When priors are wide (e.g.,
Normal(0, 100)on WX coefficients), the marginal likelihood penalizes each such parameter by roughly \(\log(\sigma\_{\text{prior}} / \sigma\_{\text{post}})\), which can be 5–10× larger than the BIC penalty.
This is why models with many WX terms (SLX, SDM, SDEM) may look reasonable under BIC but receive extreme Bayes factors under bridge sampling: the wide priors on the WX coefficients are “wasted” — they spread probability mass over implausible parameter values, reducing the marginal likelihood. This is Bayesian Occam’s razor at work, and bridge sampling is generally more trustworthy because it accounts for the actual prior specification.
Let the tests pick the specification¶
The neighbayes.diagnostics module provides Bayesian Lagrange-multiplier tests that operate on posterior draws rather than point estimates. These replace the frequentist LM tests that were previously available.
Below we run the Bayesian LM tests from the fitted OLS model:
bayesian_lm_lag_test— tests \(H\_0: \rho = 0\) (no spatial lag)bayesian_lm_error_test— tests \(H\_0: \lambda = 0\) (no spatial error)bayesian_lm_wx_test— tests \(H\_0: \gamma = 0\) (no WX, from SAR null)bayesian_lm_sdm_joint_test— joint test for SDM (\(H\_0: \rho = 0\) and \(\gamma = 0\))bayesian_lm_slx_error_joint_test— joint test for SDEM (\(H\_0: \lambda = 0\) and \(\gamma = 0\))bayesian_robust_lm_lag_test/bayesian_robust_lm_error_test— Anselin–Florax robust pair (SAR vs SEM)bayesian_robust_lm_lag_sdm_test/bayesian_robust_lm_wx_test/bayesian_robust_lm_error_sdem_test— Neyman-orthogonal robust testsbayesian_lm_error_sdm_test/bayesian_lm_lag_sdem_test— SDM/SDEM-aware tests using correctly filtered residuals
In practice, prefer the method API: model.spatial_diagnostics() runs the tests wired to that model class, and model.spatial_diagnostics_decision() returns a recommended specification.
Each returns a BayesianLMTestResult with posterior summary statistics and a Bayesian p-value.
# `spatial_diagnostics()` runs all tests wired to a model class
# and returns a tidy DataFrame.
ols.spatial_diagnostics()
| statistic | median | df | p_value | ci_lower | ci_upper | |
|---|---|---|---|---|---|---|
| test | ||||||
| LM-Lag | 19.611839 | 9.879244 | 1 | 0.000009 | 0.033314 | 91.967413 |
| LM-Error | 3.414405 | 2.556607 | 1 | 0.064630 | 0.549058 | 11.566860 |
| LM-SDM-Joint | 3742.238771 | 1570.118204 | 5 | 0.000000 | 23.498883 | 18981.871009 |
| LM-SLX-Error-Joint | 3740.288168 | 1568.402328 | 5 | 0.000000 | 23.728712 | 18983.593561 |
| Robust-LM-Lag | 11.250429 | 10.678782 | 1 | 0.000796 | 5.146118 | 20.791696 |
| Robust-LM-Error | 5.958842 | 5.656067 | 1 | 0.014644 | 2.725666 | 11.012419 |
See how the spatial parameter moves across models¶
This cell compares posterior means/intervals for the spatial scalar where present:
rhoin SAR/SDMlamin SEM/SDEM
# Compare spatial parameters across models
spatial_rows = []
for name, model, var in [
("SAR", sar, "rho"),
("SEM", sem, "lam"),
("SDM", sdm, "rho"),
("SDEM", sdem, "lam"),
]:
if var in model.inference_data.posterior:
summary = az.summary(model.inference_data, var_names=[var], round_to=3)
summary.insert(0, "model", name)
spatial_rows.append(summary)
pd.concat(spatial_rows)
| model | mean | sd | hdi_3% | hdi_97% | mcse_mean | mcse_sd | ess_bulk | ess_tail | r_hat | |
|---|---|---|---|---|---|---|---|---|---|---|
| rho | SAR | 0.430 | 0.135 | 0.183 | 0.693 | 0.003 | 0.003 | 1551.439 | 1317.324 | 1.001 |
| lam | SEM | 0.489 | 0.176 | 0.138 | 0.786 | 0.005 | 0.004 | 1525.126 | 1343.762 | 1.001 |
| rho | SDM | 0.229 | 0.163 | -0.087 | 0.522 | 0.004 | 0.004 | 1585.399 | 1249.928 | 1.001 |
| lam | SDEM | 0.325 | 0.187 | -0.007 | 0.689 | 0.005 | 0.005 | 1451.228 | 1162.854 | 1.003 |
Where to go next¶
How to run Bayesian LM specification tests — the tests used above, in full
How to run spatial block cross-validation — an out-of-sample check on the specification you chose
Supported Models — every model and its equations