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:

\[ y = X\beta + \epsilon \]
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:

\[ y = X\beta + WX\theta + \epsilon \]

Interpretation:

  • beta captures local covariate effects.

  • theta captures 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()
../_images/ce7a5948e15e38197749dd7c8d0a9b077afc9fac80a0413f0a6c7570b4a6835a.png ../_images/f160e36666a85d67bed1f97d2386e5ae4c8b6b0692d9e3f325a308650c5a79cc.png

Let the outcome itself spill over (SAR)

Model:

\[ y = \rho Wy + X\beta + \epsilon \]

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()
../_images/7055202fa94031307d5ca3509750c4c0dc0ff963ed6c45b46ce951564d75d53f.png ../_images/983b6e30ee78e08780bbed98b1b630a93cdc6fb2fbde49008cb70d0f2e2c5359.png

Treat the clustering as nuisance instead (SEM)

Model:

\[ y = X\beta + u, \quad u = \lambda Wu + \epsilon \]

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()
../_images/2918f1eb2688071350854e53f3c6daa759fb374fa61f62679c8438816849a98b.png ../_images/d65dff80ad3e7117b5ef0a0af6d28e71306b385efbdb0f8a103ebbd721f4cd2b.png

Combine both channels (SDM)

Model:

\[ y = \rho Wy + X\beta + WX\theta + \epsilon \]

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()
../_images/5c3f64ddac20aa9187d4c42c0b967070e8632b1a6bbdd996299dc3947a77a570.png ../_images/38337e873465b284349576f4613db87d71ecdeaa5a405892f2ac4a4a2aa21bea.png

The error-side combination (SDEM)

Model:

\[ y = X\beta + WX\theta + u, \quad u = \lambda Wu + \epsilon \]

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()
../_images/57008aa5ccf32e8075b9f947baf3f1196e43d92404d3b5179c58b6d0e6b8c716.png ../_images/1580940a928fde26fcd5d87aa17e544a3da8e1464764061769c7ce28d4cdeeb9.png

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()
../_images/31f211cdb7b189f785d7392694d8932af37c8a7f057e9b67f73ea58f12317596.svg
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:

\[BF\_{ij} = \frac{p(y \mid \mathcal{M}\_i)}{p(y \mid \mathcal{M}\_j)} = \frac{ML\_i}{ML\_j}\]

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 tests

  • bayesian_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:

  • rho in SAR/SDM

  • lam in 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