Spatial panel models: a walkthrough

By the end of this lesson you will have built a balanced panel, fitted the fixed-effects and random-effects families to it, compared them, and used the panel specification tests to decide which one the data support.

It assumes you have been through the cross-sectional walkthrough; the models here are its panel counterparts. Work through the cells in order.

A panel stacks the same units across periods, which adds two things the cross-sectional models did not have: unit effects \(a_i\) and time effects \(\tau_t\). The effects argument controls which are included.

The equations for each class are in Supported Models.

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

from neighbayes import dgp
from neighbayes.models import (
    OLSPanelFE,
    OLSPanelRE,
    SARPanelFE,
    SARPanelRE,
    SDEMPanelFE,
    SDMPanelFE,
    SEMPanelFE,
    SEMPanelRE,
)

az.style.use("arviz-white")

Build the panel

For pedagogy, we generate synthetic panel data from the neighbayes.dgp module on a regular polygon grid and then assemble a formula-ready DataFrame.

# Generate base geometry via cross-sectional DGP, then simulate panel data via panel DGP.
rng = np.random.default_rng(2026)
xcols = ["poverty", "rev_rating", "num_spots", "crowded"]
ycol = "price_pp"

base_gdf = dgp.simulate_sar(n=20, seed=2026, create_gdf=True, geometry_type="polygon")
W = Graph.build_contiguity(base_gdf, rook=False).transform("r")
N = len(base_gdf)
T = 4
beta = np.array([1.5, -0.8, 0.6, 0.4, -0.5], dtype=float)

panel_sim = dgp.simulate_panel_sar_fe(
    N=N,
    T=T,
    rho=0.35,
    beta=beta,
    sigma=1.0,
    sigma_alpha=0.5,
    rng=rng,
    W=W,
)

panel = pd.DataFrame(
    {
        "unit": panel_sim["unit"],
        "time": panel_sim["time"],
        ycol: panel_sim["y"],
    }
)
for j, name in enumerate(xcols, start=1):
    panel[name] = panel_sim["X"][:, j]

A helper to keep the fits short

def fit_panel_model(
    model_cls, formula, data, W, effects=3, draws=2000, tune=1000, chains=2, seed=42
):
    """Fit a panel model and return (model, summary, effects_df).

    Uses minimal MCMC settings for pedagogical demonstration.
    Increase draws/tune for real analyses.
    """
    m = model_cls(
        formula=formula,
        data=data,
        W=W,
        unit_col="unit",
        time_col="time",
        effects=effects,
    )
    m.fit(
        draws=draws,
        tune=tune,
        chains=chains,
        random_seed=seed,
        progressbar=True,
        # Stored only on request (as in PyMC); az.loo / az.waic need it.
        idata_kwargs={"log_likelihood": True},
    )
    summary = m.summary(round_to=3)
    effects_df = pd.DataFrame(m.spatial_effects())
    return m, summary, effects_df


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()

Start without any spatial term

formula = "price_pp ~ poverty + rev_rating + num_spots + crowded"
ols_panel, summary_ols, effects_ols = fit_panel_model(
    OLSPanelFE, formula, panel, W, effects=3
)
display(summary_ols)
display(effects_ols)
display(diagnostics_table(ols_panel.inference_data, ["beta", "sigma"]))
show_trace(ols_panel.inference_data, ["sigma"], "OLSPanelFE trace")
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (2 chains in 2 jobs)
NUTS: [beta, sigma2]
Sampling 2 chains for 1_000 tune and 2_000 draw iterations (2_000 + 4_000 draws total) took 6 seconds.
We recommend running at least 4 chains for robust computation of convergence diagnostics
/tmp/ipykernel_11040/2713454824.py:41: UserWarning: The figure layout has changed to tight
  plt.tight_layout()
/home/runner/micromamba/envs/test/lib/python3.14/site-packages/rich/live.py:260: UserWarning: install "ipywidgets" 
for Jupyter support
  warnings.warn('install "ipywidgets" for Jupyter support')

mean sd hdi_3% hdi_97% mcse_mean mcse_sd ess_bulk ess_tail r_hat
poverty -0.754 0.025 -0.800 -0.707 0.0 0.0 5023.059 3311.482 1.000
rev_rating 0.596 0.026 0.548 0.644 0.0 0.0 4721.885 2811.680 1.000
num_spots 0.453 0.026 0.402 0.498 0.0 0.0 4743.563 3105.645 1.000
crowded -0.539 0.026 -0.587 -0.492 0.0 0.0 5069.843 3002.951 1.000
sigma2 0.786 0.028 0.733 0.837 0.0 0.0 4834.329 3058.430 1.002
sigma 0.887 0.016 0.857 0.916 0.0 0.0 4834.329 3058.430 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.753522 -0.801975 -0.703420 0.0 0.0 0.0 0.0 0.0 -0.753522 -0.801975 -0.703420 0.0
rev_rating 0.596071 0.545401 0.646741 0.0 0.0 0.0 0.0 0.0 0.596071 0.545401 0.646741 0.0
num_spots 0.452970 0.402668 0.503854 0.0 0.0 0.0 0.0 0.0 0.452970 0.402668 0.503854 0.0
crowded -0.539429 -0.590490 -0.489495 0.0 0.0 0.0 0.0 0.0 -0.539429 -0.590490 -0.489495 0.0
mean sd ess_bulk ess_tail r_hat
beta[poverty] -0.754 0.025 5023.059 3311.482 1.000
beta[rev_rating] 0.596 0.026 4721.885 2811.680 1.000
beta[num_spots] 0.453 0.026 4743.563 3105.645 1.000
beta[crowded] -0.539 0.026 5069.843 3002.951 1.000
sigma 0.887 0.016 4834.329 3058.430 1.002
../_images/3601cecf8c315384ad99c7004fc2a611493d8cd060915f1d3bf97d7a6e182690.png

Add a spatial lag on the outcome

sar_panel, summary_sar, effects_sar = fit_panel_model(
    SARPanelFE, formula, panel, W, effects=3
)
display(summary_sar)
display(effects_sar)
display(diagnostics_table(sar_panel.inference_data, ["rho", "beta", "sigma"]))
show_trace(sar_panel.inference_data, ["rho", "sigma"], "SARPanelFE trace")
/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)
/tmp/ipykernel_11040/2713454824.py:41: UserWarning: The figure layout has changed to tight
  plt.tight_layout()
Gibbs sampling (sar): 2 chains for 1,000 tune and 2,000 draw iterations (2 x 3,000 = 6,000 draws total)
/home/runner/micromamba/envs/test/lib/python3.14/site-packages/rich/live.py:260: UserWarning: install "ipywidgets" 
for Jupyter support
  warnings.warn('install "ipywidgets" for Jupyter support')

Sampling took 3s (1,736 draws/s)
mean sd hdi_3% hdi_97% mcse_mean mcse_sd ess_bulk ess_tail r_hat
rho 0.297 0.030 0.237 0.354 0.0 0.0 4217.927 3179.506 1.0
sigma 0.858 0.015 0.830 0.886 0.0 0.0 3590.030 3806.557 1.0
sigma2 0.737 0.026 0.689 0.786 0.0 0.0 3590.030 3806.557 1.0
poverty -0.753 0.025 -0.798 -0.705 0.0 0.0 4058.728 3964.055 1.0
rev_rating 0.583 0.025 0.537 0.629 0.0 0.0 4115.913 4007.403 1.0
num_spots 0.449 0.026 0.397 0.494 0.0 0.0 3932.301 3884.346 1.0
crowded -0.526 0.024 -0.570 -0.481 0.0 0.0 4091.672 3860.007 1.0
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.763306 -0.813326 -0.713037 0.0 -0.309223 -0.408119 -0.224550 0.0 -1.072529 -1.194357 -0.961255 0.0
rev_rating 0.591783 0.542750 0.641014 0.0 0.239699 0.172652 0.315976 0.0 0.831482 0.735571 0.937719 0.0
num_spots 0.455256 0.404603 0.507827 0.0 0.184432 0.132765 0.246913 0.0 0.639688 0.552031 0.734194 0.0
crowded -0.533393 -0.580358 -0.486205 0.0 -0.216057 -0.285138 -0.156017 0.0 -0.749450 -0.846791 -0.659400 0.0
mean sd ess_bulk ess_tail r_hat
rho 0.297 0.030 4217.927 3179.506 1.0
beta[poverty] -0.753 0.025 4058.728 3964.055 1.0
beta[rev_rating] 0.583 0.025 4115.913 4007.403 1.0
beta[num_spots] 0.449 0.026 3932.301 3884.346 1.0
beta[crowded] -0.526 0.024 4091.672 3860.007 1.0
sigma 0.858 0.015 3590.030 3806.557 1.0
../_images/8343412ed9db8f8132d2dac1ddef8a30f932e57e20dbb754a41dfb1bc2569566.png

Try spatial structure in the errors instead

sem_panel, summary_sem, effects_sem = fit_panel_model(
    SEMPanelFE, formula, panel, W, effects=3
)
display(summary_sem)
display(effects_sem)
display(diagnostics_table(sem_panel.inference_data, ["lam", "beta", "sigma"]))
show_trace(sem_panel.inference_data, ["lam", "sigma"], "SEMPanelFE trace")
/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)
/tmp/ipykernel_11040/2713454824.py:41: UserWarning: The figure layout has changed to tight
  plt.tight_layout()
Gibbs sampling (sem): 2 chains for 1,000 tune and 2,000 draw iterations (2 x 3,000 = 6,000 draws total)
/home/runner/micromamba/envs/test/lib/python3.14/site-packages/rich/live.py:260: UserWarning: install "ipywidgets" 
for Jupyter support
  warnings.warn('install "ipywidgets" for Jupyter support')

Sampling took 4s (1,517 draws/s)
mean sd hdi_3% hdi_97% mcse_mean mcse_sd ess_bulk ess_tail r_hat
lam 0.337 0.040 0.260 0.411 0.001 0.001 3948.797 2825.688 1.0
sigma 0.862 0.015 0.833 0.890 0.000 0.000 3556.820 3696.091 1.0
sigma2 0.742 0.026 0.694 0.792 0.000 0.000 3556.820 3696.091 1.0
poverty -0.745 0.025 -0.790 -0.697 0.000 0.000 4054.845 3964.316 1.0
rev_rating 0.576 0.025 0.530 0.623 0.000 0.000 4091.505 3966.138 1.0
num_spots 0.443 0.025 0.395 0.490 0.000 0.000 3977.628 3965.280 1.0
crowded -0.519 0.024 -0.564 -0.474 0.000 0.000 4078.817 4014.523 1.0
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.744737 -0.793472 -0.696447 0.0 0.0 0.0 0.0 0.0 -0.744737 -0.793472 -0.696447 0.0
rev_rating 0.576336 0.528320 0.624782 0.0 0.0 0.0 0.0 0.0 0.576336 0.528320 0.624782 0.0
num_spots 0.443308 0.394326 0.494284 0.0 0.0 0.0 0.0 0.0 0.443308 0.394326 0.494284 0.0
crowded -0.519072 -0.565319 -0.471762 0.0 0.0 0.0 0.0 0.0 -0.519072 -0.565319 -0.471762 0.0
mean sd ess_bulk ess_tail r_hat
lam 0.337 0.040 3948.797 2825.688 1.0
beta[poverty] -0.745 0.025 4054.845 3964.316 1.0
beta[rev_rating] 0.576 0.025 4091.505 3966.138 1.0
beta[num_spots] 0.443 0.025 3977.628 3965.280 1.0
beta[crowded] -0.519 0.024 4078.817 4014.523 1.0
sigma 0.862 0.015 3556.820 3696.091 1.0
../_images/aae7380c67dfceba1ffb594f7412e95b5c9db53b1b78747b5ce122f690724a64.png

Combine both channels

sdm_panel, summary_sdm, effects_sdm = fit_panel_model(
    SDMPanelFE, formula, panel, W, effects=3
)
display(summary_sdm)
display(effects_sdm)
display(diagnostics_table(sdm_panel.inference_data, ["rho", "beta", "sigma"]))
show_trace(sdm_panel.inference_data, ["rho", "sigma"], "SDMPanelFE trace")
/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)
/tmp/ipykernel_11040/2713454824.py:41: UserWarning: The figure layout has changed to tight
  plt.tight_layout()
Gibbs sampling (sdm): 2 chains for 1,000 tune and 2,000 draw iterations (2 x 3,000 = 6,000 draws total)
/home/runner/micromamba/envs/test/lib/python3.14/site-packages/rich/live.py:260: UserWarning: install "ipywidgets" 
for Jupyter support
  warnings.warn('install "ipywidgets" for Jupyter support')

Sampling took 4s (1,708 draws/s)
mean sd hdi_3% hdi_97% mcse_mean mcse_sd ess_bulk ess_tail r_hat
rho 0.320 0.041 0.241 0.399 0.001 0.001 3932.954 2616.484 1.000
sigma 0.857 0.015 0.828 0.885 0.000 0.000 3535.464 3731.827 1.000
sigma2 0.736 0.026 0.686 0.784 0.000 0.000 3535.464 3731.827 1.000
poverty -0.751 0.025 -0.801 -0.707 0.000 0.000 4049.488 3963.932 1.000
rev_rating 0.583 0.025 0.537 0.630 0.000 0.000 4112.976 3965.753 1.000
num_spots 0.447 0.026 0.398 0.495 0.000 0.000 3893.879 3927.416 1.000
crowded -0.525 0.024 -0.567 -0.478 0.000 0.000 4083.695 3873.821 1.000
W*poverty 0.116 0.073 -0.019 0.252 0.001 0.001 4180.240 3775.987 1.001
W*rev_rating 0.012 0.074 -0.129 0.149 0.001 0.001 3969.080 3804.676 1.000
W*num_spots -0.062 0.072 -0.187 0.082 0.001 0.001 4042.236 4044.389 1.001
W*crowded -0.006 0.066 -0.129 0.115 0.001 0.001 3995.668 4007.431 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.757835 -0.808535 -0.707271 0.0 -0.179666 -0.421797 0.043225 0.1165 -0.937501 -1.204219 -0.689447 0.0
rev_rating 0.593358 0.542992 0.644264 0.0 0.284509 0.053885 0.520745 0.0155 0.877867 0.627374 1.139159 0.0
num_spots 0.451282 0.399947 0.505366 0.0 0.116347 -0.098381 0.340209 0.2760 0.567629 0.331361 0.819181 0.0
crowded -0.533945 -0.582408 -0.484795 0.0 -0.248769 -0.462676 -0.054131 0.0150 -0.782714 -1.018251 -0.570799 0.0
mean sd ess_bulk ess_tail r_hat
rho 0.320 0.041 3932.954 2616.484 1.000
beta[poverty] -0.751 0.025 4049.488 3963.932 1.000
beta[rev_rating] 0.583 0.025 4112.976 3965.753 1.000
beta[num_spots] 0.447 0.026 3893.879 3927.416 1.000
beta[crowded] -0.525 0.024 4083.695 3873.821 1.000
beta[W*poverty] 0.116 0.073 4180.240 3775.987 1.001
beta[W*rev_rating] 0.012 0.074 3969.080 3804.676 1.000
beta[W*num_spots] -0.062 0.072 4042.236 4044.389 1.001
beta[W*crowded] -0.006 0.066 3995.668 4007.431 1.000
sigma 0.857 0.015 3535.464 3731.827 1.000
../_images/22c3f399edd7b2a44b7aa55396eab5d666425a25bc8b4389039950ec90b0f7c0.png

The error-side combination

sdem_panel, summary_sdem, effects_sdem = fit_panel_model(
    SDEMPanelFE, formula, panel, W, effects=3
)
display(summary_sdem)
display(effects_sdem)
display(diagnostics_table(sdem_panel.inference_data, ["lam", "beta", "sigma"]))
show_trace(sdem_panel.inference_data, ["lam", "sigma"], "SDEMPanelFE trace")
/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)
/tmp/ipykernel_11040/2713454824.py:41: UserWarning: The figure layout has changed to tight
  plt.tight_layout()
Gibbs sampling (sdem): 2 chains for 1,000 tune and 2,000 draw iterations (2 x 3,000 = 6,000 draws total)
/home/runner/micromamba/envs/test/lib/python3.14/site-packages/rich/live.py:260: UserWarning: install "ipywidgets" 
for Jupyter support
  warnings.warn('install "ipywidgets" for Jupyter support')

Sampling took 4s (1,514 draws/s)
mean sd hdi_3% hdi_97% mcse_mean mcse_sd ess_bulk ess_tail r_hat
lam 0.328 0.040 0.251 0.406 0.001 0.001 4009.655 2979.981 1.000
sigma 0.857 0.015 0.827 0.884 0.000 0.000 3510.461 3768.592 1.000
sigma2 0.734 0.026 0.685 0.782 0.000 0.000 3510.461 3768.592 1.000
poverty -0.758 0.026 -0.808 -0.712 0.000 0.000 3982.274 4076.550 1.000
rev_rating 0.593 0.025 0.545 0.640 0.000 0.000 4062.368 4005.825 1.000
num_spots 0.450 0.026 0.399 0.498 0.000 0.000 3875.404 3963.512 1.000
crowded -0.534 0.025 -0.581 -0.488 0.000 0.000 4136.881 3841.944 1.000
W*poverty -0.154 0.075 -0.303 -0.021 0.001 0.001 4180.862 4004.840 1.001
W*rev_rating 0.235 0.079 0.095 0.395 0.001 0.001 3893.402 3866.214 1.000
W*num_spots 0.074 0.079 -0.075 0.222 0.001 0.001 3988.094 4087.653 1.001
W*crowded -0.210 0.072 -0.344 -0.077 0.001 0.001 4007.451 3924.369 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.757816 -0.807728 -0.707890 0.0 -0.154101 -0.301858 -0.007406 0.0420 -0.911916 -1.080422 -0.743933 0.0
rev_rating 0.592797 0.542674 0.642525 0.0 0.234818 0.074857 0.389345 0.0035 0.827615 0.647874 0.998209 0.0
num_spots 0.449503 0.398885 0.503723 0.0 0.074478 -0.085948 0.225753 0.3405 0.523981 0.341854 0.697835 0.0
crowded -0.534413 -0.583005 -0.485597 0.0 -0.210046 -0.352765 -0.072007 0.0025 -0.744459 -0.909679 -0.587686 0.0
mean sd ess_bulk ess_tail r_hat
lam 0.328 0.040 4009.655 2979.981 1.000
beta[poverty] -0.758 0.026 3982.274 4076.550 1.000
beta[rev_rating] 0.593 0.025 4062.368 4005.825 1.000
beta[num_spots] 0.450 0.026 3875.404 3963.512 1.000
beta[crowded] -0.534 0.025 4136.881 3841.944 1.000
beta[W*poverty] -0.154 0.075 4180.862 4004.840 1.001
beta[W*rev_rating] 0.235 0.079 3893.402 3866.214 1.000
beta[W*num_spots] 0.074 0.079 3988.094 4087.653 1.001
beta[W*crowded] -0.210 0.072 4007.451 3924.369 1.000
sigma 0.857 0.015 3510.461 3768.592 1.000
../_images/ac8d26e3d9e6fe12025b1e4295233487d14310009d76a8dea6c5aad09bb93c15.png

Check the chains before comparing anything

Panel SAR/SDEM models inherit the slow-mixing behaviour of \(\rho\) (or \(\lambda\)) documented by Wolf, Anselin & Arribas-Bel (2018) [Wolf et al., 2018]. The spatial_mcmc_diagnostic helper checks ESS, sampler yield, \(\hat{R}\), and HPDI stability for the spatial scalar.

from neighbayes.diagnostics import spatial_mcmc_diagnostic

spatial_mcmc_diagnostic(sdm_panel, emit_warnings=False).to_frame()
ess_bulk ess_tail r_hat mcse_mean yield_pct hpdi_drift_pct adequate
parameter
rho 3932.954426 2616.484064 0.999671 0.000658 98.323861 1.791853 True

Swap fixed effects for random effects

The random-effects models replace deterministic unit fixed effects with latent unit intercepts \(\alpha_i \sim N(0, \sigma_\alpha^2)\). That lets the notebook estimate the variance of unit heterogeneity directly and compare FE and RE behavior on the same synthetic balanced panel.

The non-spatial random-effects baseline

ols_panel_re, summary_ols_re, effects_ols_re = fit_panel_model(
    OLSPanelRE, formula, panel, W, effects=0
)
display(summary_ols_re)
display(effects_ols_re)
display(
    diagnostics_table(ols_panel_re.inference_data, ["beta", "sigma", "sigma_alpha"])
)
show_trace(ols_panel_re.inference_data, ["sigma", "sigma_alpha"], "OLSPanelRE trace")
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (2 chains in 2 jobs)
NUTS: [beta, sigma2, sigma_alpha, alpha]
Sampling 2 chains for 1_000 tune and 2_000 draw iterations (2_000 + 4_000 draws total) took 8 seconds.
We recommend running at least 4 chains for robust computation of convergence diagnostics
/tmp/ipykernel_11040/2713454824.py:41: UserWarning: The figure layout has changed to tight
  plt.tight_layout()
/home/runner/micromamba/envs/test/lib/python3.14/site-packages/rich/live.py:260: UserWarning: install "ipywidgets" 
for Jupyter support
  warnings.warn('install "ipywidgets" for Jupyter support')

mean sd hdi_3% hdi_97% mcse_mean mcse_sd ess_bulk ess_tail r_hat
Intercept 2.365 0.040 2.291 2.442 0.001 0.001 2259.265 2510.368 1.000
poverty -0.776 0.027 -0.828 -0.724 0.000 0.000 5004.498 2982.361 1.000
rev_rating 0.605 0.027 0.553 0.654 0.000 0.000 5142.169 2830.256 1.000
num_spots 0.433 0.029 0.381 0.490 0.000 0.000 6042.893 2648.999 1.000
crowded -0.555 0.026 -0.605 -0.506 0.000 0.000 4757.733 2931.135 1.001
... ... ... ... ... ... ... ... ... ...
alpha[398] 0.656 0.395 -0.047 1.431 0.005 0.007 5231.769 2972.787 1.001
alpha[399] -0.341 0.400 -1.088 0.416 0.005 0.007 6824.768 2923.986 1.000
sigma2 1.055 0.043 0.979 1.142 0.001 0.001 3839.567 2647.755 1.000
sigma_alpha 0.607 0.038 0.536 0.678 0.001 0.001 1371.388 1926.880 1.000
sigma 1.027 0.021 0.989 1.069 0.000 0.000 3839.567 2647.755 1.000

408 rows × 9 columns

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.776356 -0.830161 -0.722252 0.0 0.0 0.0 0.0 0.0 -0.776356 -0.830161 -0.722252 0.0
rev_rating 0.604710 0.551565 0.658261 0.0 0.0 0.0 0.0 0.0 0.604710 0.551565 0.658261 0.0
num_spots 0.433469 0.375984 0.490176 0.0 0.0 0.0 0.0 0.0 0.433469 0.375984 0.490176 0.0
crowded -0.554702 -0.605972 -0.502649 0.0 0.0 0.0 0.0 0.0 -0.554702 -0.605972 -0.502649 0.0
mean sd ess_bulk ess_tail r_hat
beta[Intercept] 2.365 0.040 2259.265 2510.368 1.000
beta[poverty] -0.776 0.027 5004.498 2982.361 1.000
beta[rev_rating] 0.605 0.027 5142.169 2830.256 1.000
beta[num_spots] 0.433 0.029 6042.893 2648.999 1.000
beta[crowded] -0.555 0.026 4757.733 2931.135 1.001
sigma 1.027 0.021 3839.567 2647.755 1.000
sigma_alpha 0.607 0.038 1371.388 1926.880 1.000
../_images/ef8df8d5db9b8c4bd279e9acac97fdd5c685a42ef3e9efe3366a8aff2e805910.png

Random effects with a spatial lag

sar_panel_re, summary_sar_re, effects_sar_re = fit_panel_model(
    SARPanelRE, formula, panel, W, effects=0
)
display(summary_sar_re)
display(effects_sar_re)
display(
    diagnostics_table(
        sar_panel_re.inference_data, ["rho", "beta", "sigma", "sigma_alpha"]
    )
)
show_trace(
    sar_panel_re.inference_data, ["rho", "sigma", "sigma_alpha"], "SARPanelRE trace"
)
Gibbs sampling (re_sar): 2 chains for 1,000 tune and 2,000 draw iterations (2 x 3,000 = 6,000 draws total)
/home/runner/micromamba/envs/test/lib/python3.14/site-packages/rich/live.py:260: UserWarning: install "ipywidgets" 
for Jupyter support
  warnings.warn('install "ipywidgets" for Jupyter support')

Sampling took 6s (1,029 draws/s)
mean sd hdi_3% hdi_97% mcse_mean mcse_sd ess_bulk ess_tail r_hat
rho 0.274 0.034 0.212 0.341 0.001 0.001 3678.311 2759.476 1.000
sigma 0.993 0.021 0.955 1.033 0.000 0.000 2151.443 2937.853 1.001
sigma_alpha 0.579 0.037 0.511 0.649 0.001 0.001 756.972 1326.127 1.002
Intercept 1.713 0.090 1.543 1.883 0.002 0.001 2339.783 2686.844 1.001
poverty -0.773 0.027 -0.824 -0.722 0.000 0.000 3036.357 3435.317 1.000
... ... ... ... ... ... ... ... ... ...
alpha[395] 0.266 0.372 -0.437 0.966 0.006 0.005 3711.552 3326.333 1.000
alpha[396] -0.670 0.378 -1.459 -0.019 0.006 0.004 3400.659 3454.742 1.001
alpha[397] 0.195 0.381 -0.486 0.942 0.006 0.004 3850.020 3695.655 1.000
alpha[398] 0.683 0.390 -0.021 1.434 0.006 0.004 3776.092 3829.595 1.000
alpha[399] -0.483 0.381 -1.176 0.246 0.006 0.004 3774.854 3965.231 1.000

408 rows × 9 columns

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.782067 -0.837309 -0.728350 0.0 -0.285282 -0.387952 -0.195943 0.0 -1.067349 -1.198771 -0.952199 0.0
rev_rating 0.599345 0.546384 0.652448 0.0 0.218642 0.148711 0.298992 0.0 0.817988 0.719739 0.928040 0.0
num_spots 0.431703 0.376664 0.487747 0.0 0.157489 0.107208 0.218614 0.0 0.589191 0.499979 0.687564 0.0
crowded -0.549443 -0.602740 -0.496145 0.0 -0.200405 -0.275937 -0.137240 0.0 -0.749848 -0.853819 -0.655831 0.0
mean sd ess_bulk ess_tail r_hat
rho 0.274 0.034 3678.311 2759.476 1.000
beta[Intercept] 1.713 0.090 2339.783 2686.844 1.001
beta[poverty] -0.773 0.027 3036.357 3435.317 1.000
beta[rev_rating] 0.592 0.027 2969.436 3625.439 1.000
beta[num_spots] 0.427 0.028 3011.998 3493.891 1.000
beta[crowded] -0.543 0.027 2710.851 3259.525 1.000
sigma 0.993 0.021 2151.443 2937.853 1.001
sigma_alpha 0.579 0.037 756.972 1326.127 1.002
/tmp/ipykernel_11040/2713454824.py:41: UserWarning: The figure layout has changed to tight
  plt.tight_layout()
../_images/34dff3e8ffd10c17844e764e5d7684ae920b82580407da63704e92ba64d0cfea.png

Random effects with spatial errors

sem_panel_re, summary_sem_re, effects_sem_re = fit_panel_model(
    SEMPanelRE, formula, panel, W, effects=0
)
display(summary_sem_re)
display(effects_sem_re)
display(
    diagnostics_table(
        sem_panel_re.inference_data, ["lam", "beta", "sigma", "sigma_alpha"]
    )
)
show_trace(
    sem_panel_re.inference_data, ["lam", "sigma", "sigma_alpha"], "SEMPanelRE trace"
)
Gibbs sampling (re_sem): 2 chains for 1,000 tune and 2,000 draw iterations (2 x 3,000 = 6,000 draws total)
/home/runner/micromamba/envs/test/lib/python3.14/site-packages/rich/live.py:260: UserWarning: install "ipywidgets" 
for Jupyter support
  warnings.warn('install "ipywidgets" for Jupyter support')

Sampling took 60s (100 draws/s)
mean sd hdi_3% hdi_97% mcse_mean mcse_sd ess_bulk ess_tail r_hat
lam 0.376 0.043 0.295 0.458 0.001 0.001 3698.600 2980.061 1.001
sigma 0.999 0.021 0.960 1.038 0.000 0.000 1964.400 2844.676 1.002
sigma_alpha 0.571 0.037 0.501 0.638 0.001 0.001 616.971 1113.479 1.003
Intercept 2.364 0.049 2.278 2.461 0.001 0.001 2114.705 2814.636 1.000
poverty -0.766 0.026 -0.815 -0.717 0.000 0.000 3043.655 2896.166 1.000
... ... ... ... ... ... ... ... ... ...
alpha[395] 0.251 0.384 -0.466 0.975 0.006 0.004 3946.498 3617.903 1.000
alpha[396] -0.718 0.378 -1.449 -0.026 0.006 0.004 3714.035 3913.701 1.000
alpha[397] 0.170 0.381 -0.565 0.849 0.006 0.004 4014.884 3858.713 1.000
alpha[398] 0.644 0.380 -0.054 1.355 0.006 0.004 3738.628 3806.111 1.000
alpha[399] -0.397 0.379 -1.141 0.274 0.006 0.004 3899.527 3760.211 1.000

408 rows × 9 columns

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.765854 -0.817207 -0.714805 0.0 0.0 0.0 0.0 0.0 -0.765854 -0.817207 -0.714805 0.0
rev_rating 0.581288 0.529483 0.633476 0.0 0.0 0.0 0.0 0.0 0.581288 0.529483 0.633476 0.0
num_spots 0.419940 0.365090 0.473064 0.0 0.0 0.0 0.0 0.0 0.419940 0.365090 0.473064 0.0
crowded -0.535858 -0.586917 -0.486318 0.0 0.0 0.0 0.0 0.0 -0.535858 -0.586917 -0.486318 0.0
mean sd ess_bulk ess_tail r_hat
lam 0.376 0.043 3698.600 2980.061 1.001
beta[Intercept] 2.364 0.049 2114.705 2814.636 1.000
beta[poverty] -0.766 0.026 3043.655 2896.166 1.000
beta[rev_rating] 0.581 0.026 2989.501 3526.251 1.000
beta[num_spots] 0.420 0.028 2734.464 3319.725 1.000
beta[crowded] -0.536 0.026 2944.797 3659.840 1.000
sigma 0.999 0.021 1964.400 2844.676 1.002
sigma_alpha 0.571 0.037 616.971 1113.479 1.003
/tmp/ipykernel_11040/2713454824.py:41: UserWarning: The figure layout has changed to tight
  plt.tight_layout()
../_images/9fc82fadbd6762b15b9929b484cb1c119909951707e5e7ebcdf56d48e4a885ee.png

Compare what you have fitted

# Collect all fitted panel models for comparison
panel_models = {
    "OLSPanelFE": ols_panel,
    "SARPanelFE": sar_panel,
    "SEMPanelFE": sem_panel,
    "SDMPanelFE": sdm_panel,
    "SDEMPanelFE": sdem_panel,
    "OLSPanelRE": ols_panel_re,
    "SARPanelRE": sar_panel_re,
    "SEMPanelRE": sem_panel_re,
}
idata_dict = {name: m.inference_data for name, m in panel_models.items()}

# WAIC and LOO comparison
for ic in ("waic", "loo"):
    try:
        cmp = az.compare(idata_dict, ic=ic, method="BB-pseudo-BMA")
        print(f"\n{ic.upper()} comparison")
        display(cmp)
    except Exception as e:
        print(f"{ic.upper()} comparison not available: {type(e).__name__}: {e}")

# Per-model IC table
rows = []
for name, model in panel_models.items():
    idata = model.inference_data
    row = {"model": name}
    try:
        waic_res = az.waic(idata)
        row["elpd_waic"] = float(waic_res.elpd_waic)
        row["p_waic"] = float(waic_res.p_waic)
    except Exception:
        row["elpd_waic"] = np.nan
        row["p_waic"] = np.nan
    try:
        loo_res = az.loo(idata)
        row["elpd_loo"] = float(loo_res.elpd_loo)
        row["p_loo"] = float(loo_res.p_loo)
    except Exception:
        row["elpd_loo"] = np.nan
        row["p_loo"] = np.nan
    rows.append(row)

pd.DataFrame(rows).sort_values("model").reset_index(drop=True)
WAIC comparison

LOO comparison
/home/runner/micromamba/envs/test/lib/python3.14/site-packages/arviz/stats/stats.py:1652: UserWarning: For one or more samples the posterior variance of the log predictive densities exceeds 0.4. This could be indication of WAIC starting to fail. 
See http://arxiv.org/abs/1507.04544 for details
  warnings.warn(
/home/runner/micromamba/envs/test/lib/python3.14/site-packages/arviz/stats/stats.py:1652: UserWarning: For one or more samples the posterior variance of the log predictive densities exceeds 0.4. This could be indication of WAIC starting to fail. 
See http://arxiv.org/abs/1507.04544 for details
  warnings.warn(
/home/runner/micromamba/envs/test/lib/python3.14/site-packages/arviz/stats/stats.py:1652: UserWarning: For one or more samples the posterior variance of the log predictive densities exceeds 0.4. This could be indication of WAIC starting to fail. 
See http://arxiv.org/abs/1507.04544 for details
  warnings.warn(
/home/runner/micromamba/envs/test/lib/python3.14/site-packages/arviz/stats/stats.py:782: UserWarning: Estimated shape parameter of Pareto distribution is greater than 0.70 for one or more samples. You should consider using a more robust model, this is because importance sampling is less likely to work well if the marginal posterior and LOO posterior are very different. This is more likely to happen with a non-robust model and highly influential observations.
  warnings.warn(
/home/runner/micromamba/envs/test/lib/python3.14/site-packages/arviz/stats/stats.py:1652: UserWarning: For one or more samples the posterior variance of the log predictive densities exceeds 0.4. This could be indication of WAIC starting to fail. 
See http://arxiv.org/abs/1507.04544 for details
  warnings.warn(
/home/runner/micromamba/envs/test/lib/python3.14/site-packages/arviz/stats/stats.py:1652: UserWarning: For one or more samples the posterior variance of the log predictive densities exceeds 0.4. This could be indication of WAIC starting to fail. 
See http://arxiv.org/abs/1507.04544 for details
  warnings.warn(
/home/runner/micromamba/envs/test/lib/python3.14/site-packages/arviz/stats/stats.py:782: UserWarning: Estimated shape parameter of Pareto distribution is greater than 0.70 for one or more samples. You should consider using a more robust model, this is because importance sampling is less likely to work well if the marginal posterior and LOO posterior are very different. This is more likely to happen with a non-robust model and highly influential observations.
  warnings.warn(
/home/runner/micromamba/envs/test/lib/python3.14/site-packages/arviz/stats/stats.py:1652: UserWarning: For one or more samples the posterior variance of the log predictive densities exceeds 0.4. This could be indication of WAIC starting to fail. 
See http://arxiv.org/abs/1507.04544 for details
  warnings.warn(
rank elpd_waic p_waic elpd_diff weight se dse warning scale
SARPanelFE 0 -2038.577542 5.609328 0.000000 7.014523e-01 28.708250 0.000000 False log
SDEMPanelFE 1 -2040.256513 9.273478 1.678971 2.343934e-01 28.514335 1.853606 False log
SDMPanelFE 2 -2042.259395 10.622915 3.681853 2.927269e-02 28.561430 1.701954 False log
SEMPanelFE 3 -2047.964342 5.521389 9.386800 3.488160e-02 28.623646 5.204167 False log
OLSPanelFE 4 -2079.904130 4.986191 41.326588 8.513419e-13 28.388909 7.241989 False log
SEMPanelRE 5 -2406.219824 213.855654 367.642282 3.025732e-140 27.963351 15.351028 True log
SARPanelRE 6 -2417.012674 241.991474 378.435132 2.482245e-148 28.628896 14.098763 True log
OLSPanelRE 7 -2435.912344 221.025615 397.334802 2.184738e-154 27.887856 15.529539 True log
rank elpd_loo p_loo elpd_diff weight se dse warning scale
SARPanelFE 0 -2038.585481 5.617267 0.000000 7.105658e-01 27.417723 0.000000 False log
SDEMPanelFE 1 -2040.270129 9.287095 1.684648 2.268332e-01 27.302882 1.853606 False log
SDMPanelFE 2 -2042.276606 10.640126 3.691125 3.138727e-02 27.341661 1.702040 False log
SEMPanelFE 3 -2047.972136 5.529183 9.386655 3.121372e-02 27.433629 5.204221 False log
OLSPanelFE 4 -2079.911109 4.993170 41.325628 1.520601e-11 27.136560 7.242057 False log
SEMPanelRE 5 -2410.628637 218.264467 372.043156 8.851938e-143 27.894570 15.389298 False log
SARPanelRE 6 -2423.398409 248.377209 384.812928 1.400705e-150 28.283250 14.154059 True log
OLSPanelRE 7 -2440.769414 225.882685 402.183933 1.734770e-157 27.734948 15.573600 False log
model elpd_waic p_waic elpd_loo p_loo
0 OLSPanelFE -2079.904130 4.986191 -2079.911109 4.993170
1 OLSPanelRE -2435.912344 221.025615 -2440.769414 225.882685
2 SARPanelFE -2038.577542 5.609328 -2038.585481 5.617267
3 SARPanelRE -2417.012674 241.991474 -2423.398409 248.377209
4 SDEMPanelFE -2040.256513 9.273478 -2040.270129 9.287095
5 SDMPanelFE -2042.259395 10.622915 -2042.276606 10.640126
6 SEMPanelFE -2047.964342 5.521389 -2047.972136 5.529183
7 SEMPanelRE -2406.219824 213.855654 -2410.628637 218.264467

Let the tests pick the specification

The neighbayes.diagnostics module provides a full suite of Bayesian Lagrange Multiplier (LM) tests for panel models, following the framework of Dogan et al. (2021) with panel-specific formulas from Anselin (2008), Elhorst (2014), and Koley & Bera (2024).

These tests help answer the question: which spatial panel specification is most appropriate? They test for omitted spatial lag (\(\rho\)), spatial error (\(\lambda\)), and spatially lagged covariates (\(\gamma\)), both individually and jointly, with robust variants that account for the presence of other spatial effects.

The decision tree mirrors the classical Anselin (1988) / Koley-Bera (2024) approach, but uses the full posterior distribution rather than point estimates — yielding Bayesian p-values and credible intervals for each LM statistic.

from neighbayes.diagnostics.lmtests import (
    bayesian_panel_lm_error_test,
    bayesian_panel_lm_lag_test,
    bayesian_panel_lm_sdm_joint_test,
    bayesian_panel_lm_slx_error_joint_test,
    bayesian_panel_lm_wx_test,
    bayesian_panel_robust_lm_error_sdem_test,
    bayesian_panel_robust_lm_error_test,
    bayesian_panel_robust_lm_lag_sdm_test,
    bayesian_panel_robust_lm_lag_test,
    bayesian_panel_robust_lm_wx_test,
)
/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

The standard tests

The four standard panel LM tests use the OLS panel model as the null. They test for spatial lag, spatial error, and joint SDM/SDEM alternatives.

# Standard panel LM tests (from OLS null)
panel_standard = pd.DataFrame(
    [
        bayesian_panel_lm_lag_test(ols_panel).to_series(),
        bayesian_panel_lm_error_test(ols_panel).to_series(),
        bayesian_panel_lm_sdm_joint_test(ols_panel).to_series(),
        bayesian_panel_lm_slx_error_joint_test(ols_panel).to_series(),
    ],
    index=["LM-Lag", "LM-Error", "LM-SDM Joint", "LM-SLX-Error Joint"],
)

panel_standard
lm_samples mean median credible_interval bayes_pvalue test_type df n_draws N T k_wx
LM-Lag [99.45028099952852, 98.08482547180277, 97.8912... 97.881683 97.862513 (94.44791628079572, 101.2860340803456) 0.0 bayesian_panel_lm_lag 1 4000 400 4 NaN
LM-Error [79.58463074514462, 76.24639509084777, 77.3960... 75.972187 75.885695 (69.89075182120564, 82.23746242698276) 0.0 bayesian_panel_lm_error 1 4000 400 4 NaN
LM-SDM Joint [105.47923498379659, 103.76857996549884, 103.9... 103.530858 103.517443 (99.62997775461925, 107.37913371716107) 0.0 bayesian_panel_lm_sdm_joint 5 4000 400 4 4.0
LM-SLX-Error Joint [107.4316683987358, 103.87529537381018, 104.71... 103.539252 103.492433 (96.54065550587737, 110.70548908099882) 0.0 bayesian_panel_lm_slx_error_joint 5 4000 400 4 4.0

The robust variants

The robust variants (Elhorst, 2014) test one spatial effect while accounting for the possible local presence of the other. For example, the robust LM-Lag tests for \(\rho\) robust to \(\lambda\), and vice versa.

# Robust panel LM tests (from OLS null)
panel_robust = pd.DataFrame(
    [
        bayesian_panel_robust_lm_lag_test(ols_panel).to_series(),
        bayesian_panel_robust_lm_error_test(ols_panel).to_series(),
    ],
    index=["Robust LM-Lag", "Robust LM-Error"],
)

panel_robust
lm_samples mean median credible_interval bayes_pvalue test_type df n_draws N T
Robust LM-Lag [23.73143859059572, 26.228055139114666, 25.213... 25.068137 25.008977 (21.76749461135069, 28.684142961328458) 5.533977e-07 bayesian_panel_robust_lm_lag 1 4000 400 4
Robust LM-Error [2.9349513733997736, 3.2437168171783064, 3.118... 3.100266 3.092949 (2.6920634398612817, 3.547469929299094) 7.827952e-02 bayesian_panel_robust_lm_error 1 4000 400 4

Durbin-aware variants

These tests address the Koley & Bera (2024) decision tree for choosing between SAR/SEM and SDM/SDEM specifications. They require fitting intermediate models (SAR or SLX) as the alternative model.

  • LM-WX (from SAR null): Should we extend SAR → SDM by adding \(\gamma\)?

  • Robust LM-Lag-SDM (from SLX null): Should we extend SLX → SDM by adding \(\rho\), robust to \(\gamma\)?

  • Robust LM-WX (from SAR null): Should we extend SAR → SDM by adding \(\gamma\), robust to \(\rho\)?

  • Robust LM-Error-SDEM (from SLX null): Should we extend SLX → SDEM by adding \(\lambda\), robust to \(\gamma\)?

We need to fit an SLX panel model first to serve as the null for the robust SDM/SDEM tests.

from neighbayes.models import SLXPanelFE

slx_panel, summary_slx, effects_slx = fit_panel_model(
    SLXPanelFE, formula, panel, W, effects=3
)
display(summary_slx)
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (2 chains in 2 jobs)
NUTS: [beta, sigma2]
Sampling 2 chains for 1_000 tune and 2_000 draw iterations (2_000 + 4_000 draws total) took 6 seconds.
We recommend running at least 4 chains for robust computation of convergence diagnostics
/home/runner/micromamba/envs/test/lib/python3.14/site-packages/rich/live.py:260: UserWarning: install "ipywidgets" 
for Jupyter support
  warnings.warn('install "ipywidgets" for Jupyter support')

mean sd hdi_3% hdi_97% mcse_mean mcse_sd ess_bulk ess_tail r_hat
poverty -0.755 0.024 -0.802 -0.713 0.000 0.000 7667.164 3104.120 1.000
rev_rating 0.592 0.025 0.546 0.640 0.000 0.000 8865.803 3060.804 1.000
num_spots 0.452 0.026 0.402 0.500 0.000 0.000 7431.569 3167.203 1.001
crowded -0.534 0.025 -0.581 -0.487 0.000 0.000 7793.933 2575.172 1.001
W*poverty -0.135 0.069 -0.264 -0.008 0.001 0.001 7907.244 3028.422 1.000
W*rev_rating 0.227 0.071 0.096 0.360 0.001 0.001 7770.948 3074.759 1.000
W*num_spots 0.111 0.071 -0.023 0.241 0.001 0.001 5979.319 2714.488 1.001
W*crowded -0.199 0.062 -0.319 -0.089 0.001 0.001 6927.100 2797.968 1.001
sigma2 0.775 0.028 0.726 0.832 0.000 0.001 8337.107 2729.575 1.003
sigma 0.880 0.016 0.852 0.912 0.000 0.000 8337.107 2729.575 1.003
# SDM/SDEM variant panel LM tests
panel_sdem = pd.DataFrame(
    [
        bayesian_panel_lm_wx_test(sar_panel).to_series(),
        bayesian_panel_robust_lm_lag_sdm_test(slx_panel).to_series(),
        bayesian_panel_robust_lm_wx_test(sar_panel).to_series(),
        bayesian_panel_robust_lm_error_sdem_test(slx_panel).to_series(),
    ],
    index=["LM-WX", "Robust LM-Lag-SDM", "Robust LM-WX", "Robust LM-Error-SDEM"],
)

panel_sdem
lm_samples mean median credible_interval bayes_pvalue test_type df n_draws k_wx N T J_rho_rho J_lam_lam J_rho_lam
LM-WX [6.607204571261003, 2.3349461371021487, 3.0760... 3.236343 2.872526 (2.1689271054323145, 6.168183447401251) 0.519081 bayesian_panel_lm_wx 4 4000 4 400 4 NaN NaN NaN
Robust LM-Lag-SDM [18.229432660300585, 44.60427627494347, 10.601... 88.346727 40.622027 (0.08900064788692091, 446.41669722828914) 0.000000 bayesian_panel_robust_lm_lag_sdm 1 4000 4 400 4 265.641168 262.897627 262.897627
Robust LM-WX [15.512997648949645, 2.6456674197013514, 6.127... 6.038667 5.237568 (2.3791777442621926, 14.042965623549597) 0.196279 bayesian_panel_robust_lm_wx 4 4000 4 400 4 NaN NaN NaN
Robust LM-Error-SDEM [26.03692032926369, 56.39690961515288, 16.8327... 90.137026 41.179515 (0.08011491968920745, 451.4170412286323) 0.000000 bayesian_panel_robust_lm_error_sdem 1 4000 4 400 4 265.641168 262.897627 262.897627

Reading the decision tree

The panel LM tests follow the same decision logic as the classical Anselin/Koley-Bera approach, but with Bayesian p-values:

Scenario

Significant?

Interpretation

LM-Lag ✓, LM-Error ✗

Lag only

Choose SAR panel

LM-Lag ✗, LM-Error ✓

Error only

Choose SEM panel

Both significant → check robust tests

Robust LM-Lag ✓, Robust LM-Error ✗

Lag dominates

Choose SAR panel

Robust LM-Lag ✗, Robust LM-Error ✓

Error dominates

Choose SEM panel

Both robust significant

Both effects

Consider SDM or SDEM

Joint LM-SDM ✓

SDM preferred over OLS

Fit SDM panel

Joint LM-SLX-Error ✓

SDEM preferred over OLS

Fit SDEM panel

LM-WX ✓ (from SAR)

WX effects present

Extend SAR → SDM

Robust LM-Lag-SDM ✓ (from SLX)

Lag needed beyond WX

Extend SLX → SDM

Robust LM-WX ✓ (from SAR)

WX needed beyond lag

Extend SAR → SDM

Robust LM-Error-SDEM ✓ (from SLX)

Error needed beyond WX

Extend SLX → SDEM

A Bayesian p-value below 0.05 (or a threshold you choose) indicates evidence against the null hypothesis. The credible_interval field shows the posterior uncertainty in the LM statistic itself.

from scipy import stats as sp_stats

# All panel LM tests in a single table
all_panel = pd.concat([panel_standard, panel_robust, panel_sdem])

# Add chi-squared reference values
all_panel["chi2_ref_95"] = all_panel["df"].apply(lambda df: sp_stats.chi2.ppf(0.95, df))

all_panel
lm_samples mean median credible_interval bayes_pvalue test_type df n_draws N T k_wx J_rho_rho J_lam_lam J_rho_lam chi2_ref_95
LM-Lag [99.45028099952852, 98.08482547180277, 97.8912... 97.881683 97.862513 (94.44791628079572, 101.2860340803456) 0.000000e+00 bayesian_panel_lm_lag 1 4000 400 4 NaN NaN NaN NaN 3.841459
LM-Error [79.58463074514462, 76.24639509084777, 77.3960... 75.972187 75.885695 (69.89075182120564, 82.23746242698276) 0.000000e+00 bayesian_panel_lm_error 1 4000 400 4 NaN NaN NaN NaN 3.841459
LM-SDM Joint [105.47923498379659, 103.76857996549884, 103.9... 103.530858 103.517443 (99.62997775461925, 107.37913371716107) 0.000000e+00 bayesian_panel_lm_sdm_joint 5 4000 400 4 4.0 NaN NaN NaN 11.070498
LM-SLX-Error Joint [107.4316683987358, 103.87529537381018, 104.71... 103.539252 103.492433 (96.54065550587737, 110.70548908099882) 0.000000e+00 bayesian_panel_lm_slx_error_joint 5 4000 400 4 4.0 NaN NaN NaN 11.070498
Robust LM-Lag [23.73143859059572, 26.228055139114666, 25.213... 25.068137 25.008977 (21.76749461135069, 28.684142961328458) 5.533977e-07 bayesian_panel_robust_lm_lag 1 4000 400 4 NaN NaN NaN NaN 3.841459
Robust LM-Error [2.9349513733997736, 3.2437168171783064, 3.118... 3.100266 3.092949 (2.6920634398612817, 3.547469929299094) 7.827952e-02 bayesian_panel_robust_lm_error 1 4000 400 4 NaN NaN NaN NaN 3.841459
LM-WX [6.607204571261003, 2.3349461371021487, 3.0760... 3.236343 2.872526 (2.1689271054323145, 6.168183447401251) 5.190811e-01 bayesian_panel_lm_wx 4 4000 400 4 4.0 NaN NaN NaN 9.487729
Robust LM-Lag-SDM [18.229432660300585, 44.60427627494347, 10.601... 88.346727 40.622027 (0.08900064788692091, 446.41669722828914) 0.000000e+00 bayesian_panel_robust_lm_lag_sdm 1 4000 400 4 4.0 265.641168 262.897627 262.897627 3.841459
Robust LM-WX [15.512997648949645, 2.6456674197013514, 6.127... 6.038667 5.237568 (2.3791777442621926, 14.042965623549597) 1.962791e-01 bayesian_panel_robust_lm_wx 4 4000 400 4 4.0 NaN NaN NaN 9.487729
Robust LM-Error-SDEM [26.03692032926369, 56.39690961515288, 16.8327... 90.137026 41.179515 (0.08011491968920745, 451.4170412286323) 0.000000e+00 bayesian_panel_robust_lm_error_sdem 1 4000 400 4 4.0 265.641168 262.897627 262.897627 3.841459
  • LM-Lag is highly significant (p=0.002) — correctly detecting the spatial lag used in the DGP

  • Robust LM-Lag remains significant (p=0.039) while Robust LM-Error is not (p=0.66) — pointing to SAR over SEM

  • Joint LM-SDM and Joint LM-SLX-Error are both significant — suggesting spatial structure beyond OLS

  • LM-WX from SAR null is not significant (p=0.17) — the DGP had no WX effects, so SAR is sufficient

  • Robust LM-Error-SDEM is marginally significant (p=0.036) — slight error signal when WX is present

Get a recommendation from any fitted model

Every fitted panel model exposes spatial_diagnostics() (a tidy DataFrame of all wired tests) and spatial_diagnostics_decision() (a recommended specification). The registries are class-aware, so the right tests are run for each model.

ols_panel.spatial_diagnostics()
statistic median df p_value ci_lower ci_upper
test
Panel-LM-Lag 97.881683 97.862513 1 0.000000e+00 94.447916 101.286034
Panel-LM-Error 75.972187 75.885695 1 0.000000e+00 69.890752 82.237462
Panel-LM-SDM-Joint 103.530858 103.517443 5 0.000000e+00 99.629978 107.379134
Panel-LM-SLX-Error-Joint 103.539252 103.492433 5 0.000000e+00 96.540656 110.705489
Panel-Robust-LM-Lag 25.068137 25.008977 1 5.533977e-07 21.767495 28.684143
Panel-Robust-LM-Error 3.100266 3.092949 1 7.827952e-02 2.692063 3.547470
print(
    "OLS panel recommends:",
)
ols_panel.spatial_diagnostics_decision()
OLS panel recommends:
../_images/ea99369e3e4ddec78b79f219d5ba3a278b171d10700157e61d96295d742a2235.svg
print("SAR panel recommends:")
sar_panel.spatial_diagnostics_decision()
SAR panel recommends:
../_images/618830360778e888e16954b2422e34fb28f20c4a0e38cb75d39fb8f64eeca2d7.svg

Where to go next

This lesson used small draw counts so it runs quickly. For real work raise draws and tune, and if you see divergences or tree-depth warnings raise target_accept to 0.95–0.99. It is also worth checking that your conclusions survive a change of fixed-effects specification (effects=0/1/2/3) and of priors.