Getting Started with neighbayes

This notebook walks through a complete spatial econometric workflow:

  1. Simulate spatial data from a known DGP

  2. Fit a baseline OLS model

  3. Run Bayesian LM diagnostics to test for spatial dependence

  4. Use the decision tree to select a better model

  5. Fit the recommended SAR model

  6. Compute direct, indirect, and total spatial effects

  7. Peek at the underlying PyMC model for customization

import libpysal

from neighbayes.dgp import simulate_sar

gdf = simulate_sar(n=20, beta=[1, 0.4, 2.5], rho=0.6, create_gdf=True)
gdf
y X_0 X_1 X_2 geometry
0 -0.813218 1.0 -0.775638 -0.774242 POLYGON ((1 0, 1 1, 0 1, 0 0, 1 0))
1 -2.942186 1.0 -1.465023 -1.171733 POLYGON ((2 0, 2 1, 1 1, 1 0, 2 0))
2 0.003527 1.0 -1.596144 -0.454324 POLYGON ((3 0, 3 1, 2 1, 2 0, 3 0))
3 1.945309 1.0 0.071310 0.556691 POLYGON ((4 0, 4 1, 3 1, 3 0, 4 0))
4 5.211010 1.0 0.114352 0.305765 POLYGON ((5 0, 5 1, 4 1, 4 0, 5 0))
... ... ... ... ... ...
395 0.346182 1.0 -1.133226 0.664088 POLYGON ((16 19, 16 20, 15 20, 15 19, 16 19))
396 -2.586067 1.0 2.037456 -1.572567 POLYGON ((17 19, 17 20, 16 20, 16 19, 17 19))
397 1.051976 1.0 0.177340 -0.574260 POLYGON ((18 19, 18 20, 17 20, 17 19, 18 19))
398 10.023798 1.0 0.109630 1.845905 POLYGON ((19 19, 19 20, 18 20, 18 19, 19 19))
399 6.520899 1.0 0.053479 0.409256 POLYGON ((20 19, 20 20, 19 20, 19 19, 20 19))

400 rows × 5 columns

gdf.plot("X_1").set_title("X1")
Text(0.5, 1.0, 'X1')
../_images/8b17fd53d41085d06ac78e18c9eadefab24f62af7b56dd8a21f9bee5fd9e6125.png
gdf.plot("y").set_title("y")
Text(0.5, 1.0, 'y')
../_images/d57bbf91f28e0b82eb5260301e0c702ed6ae752a9f93bbf5a5868ff2f1c741b7.png

1. Create a Spatial Weights Matrix

Spatial econometric models require a spatial weights matrix \(W\) that encodes the spatial relationships between observations. Here we use Rook contiguity (regions that share a boundary or vertex are neighbors).

G = libpysal.graph.Graph.build_contiguity(gdf, rook=False).transform("r")

2. Fit a Baseline OLS Model

We start with a non-spatial OLS regression to establish a baseline.

\[ y = X\beta + \varepsilon, \quad \varepsilon \sim \mathcal{N}(0, \sigma^2 I) \]
# remove the intercept since we have one (-1)
form = "y ~ -1 + X_0 + X_1 + X_2"
from neighbayes.models import OLS

ols = OLS(formula=form, W=G, data=gdf)
ols.fit(draws=1000, tune=500, chains=2, random_seed=42)
ols.summary()
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (2 chains in 2 jobs)
NUTS: [beta, sigma2]
Sampling 2 chains for 500 tune and 1_000 draw iterations (1_000 + 2_000 draws total) took 5 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
X_0 2.241 0.081 2.094 2.391 0.002 0.002 1981.0 1418.0 1.0
X_1 0.509 0.080 0.357 0.649 0.002 0.002 1965.0 1504.0 1.0
X_2 2.682 0.078 2.541 2.832 0.002 0.002 2399.0 1578.0 1.0
sigma2 2.538 0.184 2.184 2.862 0.004 0.005 1743.0 1471.0 1.0
sigma 1.592 0.057 1.487 1.700 0.001 0.001 1743.0 1471.0 1.0

Notice all the estimated beta parameters are biased upward.

Use arviz to inspect fit

the fit method attaches an inference_data object to each model that can be used directly with arviz functions

import arviz as az

az.plot_forest(ols.inference_data)
array([<Axes: title={'center': '94.0% HDI'}>], dtype=object)
../_images/9f401f34e465fa275c7d837449f960522d015f17c63d0b59957ee4145fe86776.png
az.plot_trace(ols.inference_data)
array([[<Axes: title={'center': 'beta'}>,
        <Axes: title={'center': 'beta'}>],
       [<Axes: title={'center': 'sigma2'}>,
        <Axes: title={'center': 'sigma2'}>],
       [<Axes: title={'center': 'sigma'}>,
        <Axes: title={'center': 'sigma'}>]], dtype=object)
../_images/524ddc855b11ec383c3c55932896de1c1272d277b81747d5961239b5c84ab5b8.png

3. Run Bayesian LM Diagnostics

The spatial_diagnostics() method runs a battery of Bayesian LM tests (Doğan, Taşpınar & Bera 2021) that check whether spatial dependence is present in the residuals.

  • LM-Lag: Tests \(H_0: \rho = 0\) (no spatial lag on \(y\))

  • LM-Error: Tests \(H_0: \lambda = 0\) (no spatial error correlation)

  • LM-SDM-Joint: Joint test for \(\rho = 0\) and \(\gamma = 0\)

  • Robust-LM-Lag / Robust-LM-Error: Neyman-orthogonal robust versions

diag = ols.spatial_diagnostics()
diag
/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
statistic median df p_value ci_lower ci_upper
test
LM-Lag 341.597863 340.015392 1 0.00000 236.356784 454.128981
LM-Error 223.464626 222.660718 1 0.00000 177.274172 275.049116
LM-SDM-Joint 362.320818 355.920270 3 0.00000 237.012246 520.423493
LM-SLX-Error-Joint 361.003218 360.069090 3 0.00000 317.185324 410.842873
Robust-LM-Lag 139.350613 137.858420 1 0.00000 103.571825 181.737901
Robust-LM-Error 16.462251 16.285970 1 0.00005 12.235507 21.469693

4. Model Selection Decision Tree

The spatial_diagnostics_decision() method walks a Koley & Bera decision tree using the Bayesian p-values above and recommends the next model to fit.

decision = ols.spatial_diagnostics_decision(alpha=0.05, format="ascii")
print(decision)
LM-Lag *  (p=0.0000, alpha=0.05)
├── <sig> LM-Error *  (p=0.0000, alpha=0.05)
│   ├── <sig> Robust-LM-Lag *  (p=0.0000, alpha=0.05)
│   │   ├── <sig> Robust-LM-Error *  (p=0.0000, alpha=0.05)
│   │   │   ├── <sig> Robust-LM-Lag p <= Robust-LM-Error p *
│   │   │   │   ├── [SAR] * ← SELECTED
│   │   │   │   └── [SEM]
│   │   │   └── [SAR]
│   │   └── <not sig> Robust-LM-Error
│   │       ├── [SEM]
│   │       └── <not sig> LM-Lag p <= LM-Error p
│   │           ├── [SAR]
│   │           └── [SEM]
│   └── [SAR]
└── <not sig> LM-Error
    ├── [SEM]
    └── [OLS]
ols.spatial_diagnostics_decision(alpha=0.05)
../_images/1b36d8a642ec3999a5a8bea5fdc31fd2b781fef5d5a9edce07a39210e555f04a.svg

If the diagnostics suggest spatial dependence, we fit a Spatial Autoregressive (SAR) model:

\[ y = \rho W y + X\beta + \varepsilon, \quad \varepsilon \sim \mathcal{N}(0, \sigma^2 I) \]

The likelihood includes the Jacobian \(\log|I - \rho W|\) so that posterior inference on \(\rho\) is exact.

from neighbayes.models import SAR

sar = SAR(formula=form, W=G, data=gdf)
sar.fit(draws=2000, tune=100, chains=4, random_seed=42)
sar.summary()
/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)
Gibbs sampling (sar): 4 chains for 100 tune and 2,000 draw iterations (4 x 2,100 = 8,400 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,994 draws/s)
mean sd hdi_3% hdi_97% mcse_mean mcse_sd ess_bulk ess_tail r_hat
rho 0.592 0.030 0.535 0.647 0.000 0.000 8128.0 5887.0 1.0
sigma 1.135 0.040 1.061 1.212 0.000 0.000 7590.0 7809.0 1.0
sigma2 1.289 0.092 1.120 1.463 0.001 0.001 7590.0 7809.0 1.0
X_0 1.042 0.083 0.886 1.198 0.001 0.001 7830.0 6835.0 1.0
X_1 0.429 0.056 0.326 0.533 0.001 0.000 7881.0 8101.0 1.0
X_2 2.531 0.057 2.426 2.639 0.001 0.000 7913.0 8104.0 1.0
az.plot_trace(sar.inference_data)
array([[<Axes: title={'center': 'rho'}>, <Axes: title={'center': 'rho'}>],
       [<Axes: title={'center': 'sigma'}>,
        <Axes: title={'center': 'sigma'}>],
       [<Axes: title={'center': 'sigma2'}>,
        <Axes: title={'center': 'sigma2'}>],
       [<Axes: title={'center': 'beta'}>,
        <Axes: title={'center': 'beta'}>]], dtype=object)
../_images/be7866781682aad234022d10a3756366cab6930517f1813cfbc4916735e1f600.png

6. Compute Spatial Effects

For the SAR model, a change in \(X\) propagates through the spatial multiplier \((I - \rho W)^{-1}\). The effects decompose into:

  • Direct: Effect of \(X_i\) on \(y_i\) (own-region)

  • Indirect: Effect of \(X_j\) on \(y_i\) for \(j \neq i\) (spillover)

  • Total: Direct + Indirect

effects = sar.spatial_effects()
effects
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
X_1 0.460241 0.343029 0.575660 0.0 0.597216 0.403994 0.827120 0.0 1.057457 0.761193 1.383055 0.0
X_2 2.718336 2.590771 2.853071 0.0 3.526522 2.739414 4.493698 0.0 6.244858 5.385595 7.301870 0.0