Bayesian LM tests against classical spreg¶
Cross-implementation check for the Lagrange-Multiplier specification tests.
neighbayes evaluates the LM statistic at every posterior draw and reports the
posterior mean; spreg computes the classical point statistic from an OLS fit.
On data simulated under \(H_0\) with weakly-informative priors the two should tell
the same story, and where they diverge the reason should be understood rather
than assumed.
This is evidence about the implementation, not a guide to using it — for that see How to run Bayesian LM specification tests.
import warnings
import libpysal
import numpy as np
import pandas as pd
from spreg import OLS as SpregOLS
from spreg import LMtests as SpregLMtests
from neighbayes.diagnostics.lmtests import (
bayesian_lm_error_test,
bayesian_lm_lag_test,
bayesian_lm_sdm_joint_test,
bayesian_lm_slx_error_joint_test,
bayesian_lm_wx_sem_test,
bayesian_lm_wx_test,
bayesian_robust_lm_error_sdem_test,
bayesian_robust_lm_error_test,
bayesian_robust_lm_lag_sdm_test,
bayesian_robust_lm_lag_test,
bayesian_robust_lm_wx_test,
)
from neighbayes.models import OLS, SAR, SEM, SLX
warnings.filterwarnings("ignore", category=FutureWarning)
warnings.filterwarnings("ignore", category=UserWarning)
/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
import geopandas as gpd
from libpysal.graph import Graph
from libpysal.weights import Rook
# Generate data under H0: no spatial effects
np.random.seed(42)
# Load Columbus dataset for spatial weights
columbus_path = libpysal.examples.get_path("columbus.shp")
gdf = gpd.read_file(columbus_path)
# Create a Graph (modern libpysal API) for neighbayes models
# Row-standardize the graph so spatial models work correctly
g = Graph.build_contiguity(gdf, rook=True).transform("r")
n = g.n
# Legacy W for spreg comparison
w_spreg = Rook.from_shapefile(columbus_path)
w_spreg.transform = "r"
# Get sparse and dense W matrices
W_sparse = g.sparse.tocsr().astype(np.float64)
W_dense = np.array(W_sparse.todense())
# Design matrix
k = 3
X = np.column_stack([np.ones(n), np.random.normal(size=(n, k - 1))])
beta_true = np.array([1.0, 2.0, -1.5])
y = X @ beta_true + np.random.normal(scale=1.0, size=n)
print(f"W shape: {W_sparse.shape}, nnz: {W_sparse.nnz}")
W shape: (49, 49), nnz: 200
# Fit OLS model (null for joint tests)
ols_model = OLS(y=y, X=X, W=g)
ols_model.fit(draws=5000, tune=5000, chains=4, random_seed=42)
# Fit SAR model (null for LM-WX and robust LM-WX)
sar_model = SAR(y=y, X=X, W=g)
sar_model.fit(draws=5000, tune=5000, chains=4, random_seed=42)
# Fit SLX model (null for robust LM-Lag-SDM and robust LM-Error-SDEM)
slx_model = SLX(y=y, X=X, W=g)
slx_model.fit(draws=5000, tune=5000, chains=4, random_seed=42)
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (4 chains in 2 jobs)
NUTS: [beta, sigma2]
Sampling 4 chains for 5_000 tune and 5_000 draw iterations (20_000 + 20_000 draws total) took 17 seconds.
/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)
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (4 chains in 2 jobs)
NUTS: [beta, sigma2]
Sampling 4 chains for 5_000 tune and 5_000 draw iterations (20_000 + 20_000 draws total) took 18 seconds.
Gibbs sampling (sar): 4 chains for 5,000 tune and 5,000 draw iterations (4 x 10,000 = 40,000 draws total)
Sampling took 4s (9,732 draws/s)
-
<xarray.Dataset> Size: 1MB Dimensions: (chain: 4, draw: 5000, coefficient: 5) Coordinates: * chain (chain) int64 32B 0 1 2 3 * draw (draw) int64 40kB 0 1 2 3 4 5 ... 4994 4995 4996 4997 4998 4999 * coefficient (coefficient) <U4 80B 'x0' 'x1' 'x2' 'W*x1' 'W*x2' Data variables: beta (chain, draw, coefficient) float64 800kB 1.111 2.242 ... 0.1962 sigma2 (chain, draw) float64 160kB 1.597 1.393 1.32 ... 1.29 1.257 sigma (chain, draw) float64 160kB 1.264 1.18 1.149 ... 1.136 1.121 Attributes: created_at: 2026-09-23T21:18:54.376779+00:00 arviz_version: 0.23.4 inference_library: pymc inference_library_version: 5.28.5 sampling_time: 17.73244547843933 tuning_steps: 5000 -
<xarray.Dataset> Size: 3MB Dimensions: (chain: 4, draw: 5000) Coordinates: * chain (chain) int64 32B 0 1 2 3 * draw (draw) int64 40kB 0 1 2 3 4 ... 4996 4997 4998 4999 Data variables: (12/18) tree_depth (chain, draw) int64 160kB 4 3 3 3 3 3 ... 4 3 4 2 3 2 reached_max_treedepth (chain, draw) bool 20kB False False ... False False step_size_bar (chain, draw) float64 160kB 0.5441 0.5441 ... 0.5554 max_energy_error (chain, draw) float64 160kB 0.9167 -0.2973 ... -0.179 energy (chain, draw) float64 160kB 91.67 90.34 ... 87.35 diverging (chain, draw) bool 20kB False False ... False False ... ... acceptance_rate (chain, draw) float64 160kB 0.6525 0.9695 ... 0.9937 energy_error (chain, draw) float64 160kB 0.3811 ... -0.179 lp (chain, draw) float64 160kB -88.98 -87.89 ... -86.2 index_in_trajectory (chain, draw) int64 160kB -7 2 5 3 -5 ... 6 -2 -3 3 2 divergences (chain, draw) int64 160kB 0 0 0 0 0 0 ... 0 0 0 0 0 0 largest_eigval (chain, draw) float64 160kB nan nan nan ... nan nan Attributes: created_at: 2026-09-23T21:18:54.400718+00:00 arviz_version: 0.23.4 inference_library: pymc inference_library_version: 5.28.5 sampling_time: 17.73244547843933 tuning_steps: 5000 -
<xarray.Dataset> Size: 784B Dimensions: (obs_dim_0: 49) Coordinates: * obs_dim_0 (obs_dim_0) int64 392B 0 1 2 3 4 5 6 7 ... 42 43 44 45 46 47 48 Data variables: obs (obs_dim_0) float64 392B 2.206 -0.2238 -0.5325 ... 3.193 -0.03629 Attributes: created_at: 2026-09-23T21:18:54.404742+00:00 arviz_version: 0.23.4 inference_library: pymc inference_library_version: 5.28.5
Bayesian LM statistics¶
Evaluated at the null model each test requires.
# Fit the SEM null required by LM-WX-SEM (Bayesian analogue of Koley–Bera 2024).
sem_model = SEM(y=y, X=X, W=g)
sem_model.fit(draws=2500, tune=2500, chains=2, random_seed=42)
# Non-robust Bayesian LM tests evaluated at the appropriate null model.
non_robust_results = pd.DataFrame(
[
bayesian_lm_lag_test(ols_model).to_series(),
bayesian_lm_error_test(ols_model).to_series(),
bayesian_lm_wx_test(sar_model).to_series(),
bayesian_lm_wx_sem_test(sem_model).to_series(),
bayesian_lm_sdm_joint_test(ols_model).to_series(),
bayesian_lm_slx_error_joint_test(ols_model).to_series(),
],
index=[
"LM-Lag",
"LM-Error",
"LM-WX",
"LM-WX-SEM",
"LM-SDM Joint",
"LM-SLX-Error Joint",
],
)
non_robust_results
/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 (sem): 2 chains for 2,500 tune and 2,500 draw iterations (2 x 5,000 = 10,000 draws total)
Sampling took 4s (2,638 draws/s)
| lm_samples | mean | median | credible_interval | bayes_pvalue | test_type | df | n_draws | k_wx | |
|---|---|---|---|---|---|---|---|---|---|
| LM-Lag | [0.2142964999714562, 1.0784033337488124, 1.280... | 1.063234 | 0.656830 | (0.00204675682175708, 4.2687677969034805) | 0.302479 | bayesian_lm_lag | 1 | 20000 | NaN |
| LM-Error | [0.7079187163674764, 0.7756572487282847, 0.744... | 0.669052 | 0.683859 | (0.014393878917328799, 1.3853911845443372) | 0.413382 | bayesian_lm_error | 1 | 20000 | NaN |
| LM-WX | [1.4814399069845414, 3.360987014281438, 0.8809... | 2.112357 | 1.413838 | (0.09593622926595723, 8.194730280544038) | 0.347782 | bayesian_lm_wx | 2 | 20000 | 2.0 |
| LM-WX-SEM | [3.2315485732635807, 1.0743489513363125, 0.196... | 1.351305 | 0.898337 | (0.05536302605253931, 5.110249054287485) | 0.508824 | bayesian_lm_wx_sem | 2 | 5000 | 2.0 |
| LM-SDM Joint | [0.7240693263411846, 2.7662057621909844, 2.499... | 3.926935 | 2.786218 | (0.42552310256782655, 14.128384100207635) | 0.269463 | bayesian_lm_sdm_joint | 3 | 20000 | 2.0 |
| LM-SLX-Error Joint | [1.3998088300832858, 3.5034345838334366, 1.597... | 2.113251 | 1.633809 | (0.7011929236378855, 6.1492522670831296) | 0.549237 | bayesian_lm_slx_error_joint | 3 | 20000 | 2.0 |
# Robust Bayesian LM tests (Neyman orthogonal score)
robust_results = pd.DataFrame(
[
bayesian_robust_lm_lag_test(ols_model).to_series(),
bayesian_robust_lm_error_test(ols_model).to_series(),
bayesian_robust_lm_lag_sdm_test(slx_model).to_series(),
bayesian_robust_lm_wx_test(sar_model).to_series(),
bayesian_robust_lm_error_sdem_test(slx_model).to_series(),
],
index=[
"Robust LM-Lag",
"Robust LM-Error",
"Robust LM-Lag-SDM",
"Robust LM-WX",
"Robust LM-Error-SDEM",
],
)
robust_results
| lm_samples | mean | median | credible_interval | bayes_pvalue | test_type | df | n_draws | k_wx | tr_MzWMzW_pair | |
|---|---|---|---|---|---|---|---|---|---|---|
| Robust LM-Lag | [0.04470652999465163, 0.06039321001634612, 0.0... | 0.052556 | 0.049121 | (0.021080445691699274, 0.10259303831611782) | 0.818673 | bayesian_robust_lm_lag | 1 | 20000 | NaN | NaN |
| Robust LM-Error | [0.2592088833186119, 0.35016040229998335, 0.23... | 0.304723 | 0.284806 | (0.12222462329905084, 0.5948354055071119) | 0.580937 | bayesian_robust_lm_error | 1 | 20000 | NaN | NaN |
| Robust LM-Lag-SDM | [1.497849382284626, 1.7175227063115825, 1.8123... | 2.003181 | 1.974842 | (1.2853662789897897, 2.877608504387878) | 0.156969 | bayesian_robust_lm_lag_sdm | 1 | 20000 | 2.0 | 19.966602 |
| Robust LM-WX | [2.548722904936673, 2.5345872759229016, 0.1998... | 3.448256 | 1.918213 | (0.08724517028584271, 15.464011910652697) | 0.178328 | bayesian_robust_lm_wx | 2 | 20000 | 2.0 | NaN |
| Robust LM-Error-SDEM | [1.4121519102807134, 1.6051095505017356, 1.687... | 1.844711 | 1.826631 | (1.222114877277616, 2.565960814166178) | 0.174400 | bayesian_robust_lm_error_sdem | 1 | 20000 | 2.0 | 19.966602 |
Classical spreg statistics on the same data¶
# Classical spreg LM tests
ols_spreg = SpregOLS(y, X)
lm_spreg = SpregLMtests(ols_spreg, w_spreg)
spreg_results = {
"LM-Lag": lm_spreg.lml,
"LM-Error": lm_spreg.lme,
"LM-WX": lm_spreg.lmwx,
"LM-SDM Joint": lm_spreg.lmspdurbin,
"LM-SLX-Error Joint": lm_spreg.lmslxerr,
"Robust LM-Lag": lm_spreg.rlml,
"Robust LM-Error": lm_spreg.rlme,
"Robust LM-Lag-SDM": lm_spreg.rlmdurlag,
"Robust LM-WX": lm_spreg.rlmwx,
}
all_results = pd.concat([non_robust_results, robust_results])
comparison_rows = []
for bname in all_results.index:
row = all_results.loc[bname]
if bname in spreg_results:
s_stat, s_pval = spreg_results[bname]
else:
s_stat, s_pval = np.nan, np.nan
comparison_rows.append(
{
"bayes_mean": row["mean"],
"spreg_stat": s_stat,
"bayes_pvalue": row["bayes_pvalue"],
"spreg_pvalue": s_pval,
}
)
comparison_df = pd.DataFrame(comparison_rows, index=all_results.index)
comparison_df
/home/runner/micromamba/envs/test/lib/python3.14/site-packages/spreg/diagnostics.py:620: ComplexWarning: Casting complex values to real discards the imaginary part
ci_result = sqrt(max_eigval / min_eigval)
| bayes_mean | spreg_stat | bayes_pvalue | spreg_pvalue | |
|---|---|---|---|---|
| LM-Lag | 1.063234 | 0.886074 | 0.302479 | 0.346543 |
| LM-Error | 0.669052 | 1.341625 | 0.413382 | 0.246748 |
| LM-WX | 2.112357 | 0.616118 | 0.347782 | 0.734872 |
| LM-WX-SEM | 1.351305 | NaN | 0.508824 | NaN |
| LM-SDM Joint | 3.926935 | 1.957743 | 0.269463 | 0.581224 |
| LM-SLX-Error Joint | 2.113251 | 1.957743 | 0.549237 | 0.581224 |
| Robust LM-Lag | 0.052556 | 0.057811 | 0.818673 | 0.809990 |
| Robust LM-Error | 0.304723 | 0.513362 | 0.580937 | 0.473687 |
| Robust LM-Lag-SDM | 2.003181 | 1.341625 | 0.156969 | 0.246748 |
| Robust LM-WX | 3.448256 | 1.071669 | 0.178328 | 0.585181 |
| Robust LM-Error-SDEM | 1.844711 | NaN | 0.174400 | NaN |
The divergence in the error and WX tests is expected because the Bayesian LM statistics use the information matrix (not \(E[gg']\)), which gives different variance estimates than the classical approach. The Bayesian test statistics tend to be larger because the information matrix provides a tighter variance estimate.