Cross-Sectional Model Comparison: neighbayes vs spreg

Columbus example

This notebook compares equivalent cross-sectional models between neighbayes and spreg on a real example dataset from the PySAL/spreg ecosystem.

The dependent variable is HOVAL, with INC and CRIME used as explanatory variables.

Model mapping used:

  • SLX (neighbayes) vs spreg.OLS(slx_lags=1)

  • SAR (neighbayes) vs spreg.GM_Lag

  • SEM (neighbayes) vs spreg.GM_Error

  • SDM (neighbayes) vs spreg.GM_Lag(slx_lags=1, w_lags=2)

  • SDEM (neighbayes) vs spreg.GM_Error(slx_lags=1)

Dataset: Columbus neighborhood data from libpysal.examples.

import geopandas as gpd
import libpysal
import numpy as np
import pandas as pd
import spreg
from IPython.display import display
from spreg.sputils import _sp_effects, spmultiplier

from neighbayes.models import SAR, SDEM, SDM, SEM, SLX
columbus = gpd.read_file(libpysal.examples.get_path("columbus.shp"))
columbus.plot("HOVAL", scheme="quantiles")
<Axes: >
../_images/b2eb2c391762159c81fbae4af0503bb742d65762bd6d6c40de1c8c925c0367a7.png
w = libpysal.graph.Graph.build_contiguity(columbus).transform("r")
y = columbus.HOVAL.values
X = columbus[["INC", "CRIME"]]

spreg_results = {
    "SLX": spreg.OLS(y, X, w=w, slx_lags=1, name_y="HOVAL", name_x=["INC", "CRIME"]),
    "SAR": spreg.ML_Lag(y, X, w=w, name_y="HOVAL", name_x=["INC", "CRIME"]),
    "SEM": spreg.ML_Error(y, X, w=w, name_y="HOVAL", name_x=["INC", "CRIME"]),
    "SDM": spreg.ML_Lag(
        y, X, w=w, slx_lags=1, w_lags=2, name_y="HOVAL", name_x=["INC", "CRIME"]
    ),
    "SDEM": spreg.GM_Error(
        y, X, w=w, slx_lags=1, name_y="HOVAL", name_x=["INC", "CRIME"]
    ),
}

spec = "HOVAL ~  1 + INC + CRIME"
sample_kwargs = dict(draws=1000, tune=1000, chains=4, random_seed=42, progressbar=False)
bayes_models = {
    "SLX": SLX(spec, data=columbus, W=w),
    "SAR": SAR(spec, data=columbus, W=w),
    "SEM": SEM(spec, data=columbus, W=w),
    "SDM": SDM(spec, data=columbus, W=w),
    "SDEM": SDEM(spec, data=columbus, W=w),
}
bayes_idata = {name: model.fit(**sample_kwargs) for name, model in bayes_models.items()}
ML_Lag
ML_Error
ML_Lag
GM_Error
/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)
/home/runner/micromamba/envs/test/lib/python3.14/site-packages/spreg/ml_error.py:184: RuntimeWarning: Method 'bounded' does not support relative tolerance in x; defaulting to absolute tolerance.
  res = minimize_scalar(
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (4 chains in 2 jobs)
NUTS: [beta, sigma2]
Sampling 4 chains for 1_000 tune and 1_000 draw iterations (4_000 + 4_000 draws total) took 16 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)
bayes_models["SAR"].summary()
mean sd hdi_3% hdi_97% mcse_mean mcse_sd ess_bulk ess_tail r_hat
rho 0.212 0.167 -0.100 0.532 0.003 0.003 4119.0 3042.0 1.0
sigma 15.032 1.531 12.134 17.822 0.026 0.019 3534.0 3797.0 1.0
sigma2 228.293 47.386 145.507 315.788 0.789 0.691 3534.0 3797.0 1.0
Intercept 37.939 13.899 12.610 64.089 0.225 0.161 3802.0 3434.0 1.0
INC 0.574 0.528 -0.422 1.522 0.009 0.006 3748.0 4057.0 1.0
CRIME -0.454 0.177 -0.768 -0.101 0.003 0.002 3850.0 3920.0 1.0

Sampler adequacy for \(\rho\)

Before trusting the SAR posterior summary above, verify that the spatial parameter \(\rho\) has been sampled efficiently. Wolf, Anselin & Arribas-Bel (2018) [Wolf et al., 2018] show that \(\rho\) tends to mix slowly, so a chain that converges in mean can still under-cover the tails by 10–12 %.

from neighbayes.diagnostics import spatial_mcmc_diagnostic

spatial_mcmc_diagnostic(bayes_models["SAR"], emit_warnings=False).to_frame()
ess_bulk ess_tail r_hat mcse_mean yield_pct hpdi_drift_pct adequate
parameter
rho 4118.821751 3041.960166 0.999832 0.00262 102.970544 0.07253 True
print(spreg_results["SAR"].summary)
REGRESSION RESULTS
------------------

SUMMARY OF OUTPUT: MAXIMUM LIKELIHOOD SPATIAL LAG (METHOD = FULL)
------------------------------------------------------------------------------------
Data set            :     unknown
Weights matrix      :     unknown
Dependent Variable  :       HOVAL                Number of Observations:          49
Mean dependent var  :     38.4362                Number of Variables   :           4
S.D. dependent var  :     18.4661                Degrees of Freedom    :          45
Pseudo R-squared    :      0.3826
Spatial Pseudo R-squared:  0.3271
Log likelihood      :   -200.4394
Sigma-square ML     :    206.3220                Akaike info criterion :     408.879
S.E of regression   :     14.3639                Schwarz criterion     :     416.446

------------------------------------------------------------------------------------
            Variable     Coefficient       Std.Error     z-Statistic     Probability
------------------------------------------------------------------------------------
            CONSTANT        37.26813        13.94565         2.67238         0.00753
                 INC         0.56316         0.51316         1.09742         0.27246
               CRIME        -0.45102         0.17770        -2.53815         0.01114
             W_HOVAL         0.23063         0.15496         1.48836         0.13666
------------------------------------------------------------------------------------

SPATIAL LAG MODEL IMPACTS
Impacts computed using the 'simple' method.
            Variable         Direct        Indirect          Total
                 INC         0.5632          0.1688          0.7320
               CRIME        -0.4510         -0.1352         -0.5862
================================ END OF REPORT =====================================
def extract_spreg_params(model):
    names = list(getattr(model, "name_x", []))
    betas = np.asarray(model.betas).flatten()
    if len(betas) == len(names) + 1 and type(model).__name__ == "GM_Lag":
        names = names + ["rho"]
    if len(names) != len(betas):
        names = [f"param_{i}" for i in range(len(betas))]
    return pd.Series(betas, index=names, dtype=float)


def extract_neighbayes_params(model_name, model, idata):
    out = {}
    beta_mean = idata.posterior["beta"].mean(("chain", "draw")).values
    beta_names = (
        model._beta_names()
        if model_name in {"SLX", "SDM", "SDEM"}
        else list(model._feature_names)
    )
    for name, val in zip(beta_names, beta_mean):
        out[name] = float(val)
    if model_name in {"SAR", "SDM"}:
        out["rho"] = float(idata.posterior["rho"].mean())
    if model_name in {"SEM", "SDEM"}:
        out["lambda"] = float(idata.posterior["lam"].mean())
    harmonized = {}
    for k, v in out.items():
        key = k.replace("W*", "W_")
        if key.lower() == "intercept":
            key = "CONSTANT"
        harmonized[key] = v
    return pd.Series(harmonized, dtype=float)


def bayes_effects_df(model):
    eff = model.spatial_effects()
    # spatial_effects() returns a DataFrame indexed by feature names
    return pd.DataFrame(
        {
            "feature": eff.index.tolist(),
            "direct_neighbayes": eff["direct"].values,
            "indirect_neighbayes": eff["indirect"].values,
            "total_neighbayes": eff["total"].values,
        }
    )


def spreg_effects_df(model_name, sp_model):
    if model_name == "SEM":
        # SEM has no spatial multiplier on the systematic component,
        # so direct = coefficient, indirect = 0, total = coefficient.
        # neighbayes excludes the intercept from effects, so we
        # only compare non-constant covariates here.
        sp_params = extract_spreg_params(sp_model)
        features = [f for f in ["INC", "CRIME"] if f in sp_params.index]
        return pd.DataFrame(
            {
                "feature": features,
                "direct_spreg": [float(sp_params[f]) for f in features],
                "indirect_spreg": [0.0 for _ in features],
                "total_spreg": [float(sp_params[f]) for f in features],
            }
        )

    output = sp_model.output.copy()
    output["regime"] = "global"

    def classify_var(var_name):
        if var_name == "CONSTANT":
            return "o"
        if var_name in {"lambda", "W_HOVAL"}:
            return "rho"
        if var_name.startswith("W_"):
            return "wx"
        return "x"

    output["var_type"] = output["var_names"].map(classify_var)
    sp_model.output = output

    vars_x = output.query("var_type == 'x'")
    rho = (
        float(np.squeeze(getattr(sp_model, "rho", 0.0)))
        if hasattr(sp_model, "rho")
        else 0.0
    )
    multipliers = spmultiplier(w, rho)
    slx_lags = getattr(sp_model, "slx_lags", 0) or (
        1 if model_name in {"SLX", "SDEM"} else 0
    )
    slx_vars = getattr(sp_model, "slx_vars", None)
    if slx_vars is None:
        slx_vars = "All"

    total_spreg, direct_spreg, indirect_spreg = _sp_effects(
        sp_model,
        vars_x,
        multipliers,
        slx_lags=slx_lags,
        slx_vars=slx_vars,
    )

    return pd.DataFrame(
        {
            "feature": vars_x["var_names"].tolist(),
            "direct_spreg": np.asarray(direct_spreg).flatten(),
            "indirect_spreg": np.asarray(indirect_spreg).flatten(),
            "total_spreg": np.asarray(total_spreg).flatten(),
        }
    )


coef_rows = []
effect_tables = {}

for model_name in ["SLX", "SAR", "SEM", "SDM", "SDEM"]:
    sp = extract_spreg_params(spreg_results[model_name])
    by = extract_neighbayes_params(
        model_name, bayes_models[model_name], bayes_idata[model_name]
    )

    common = sorted(set(sp.index).intersection(set(by.index)))
    for param in common:
        coef_rows.append(
            {
                "model": model_name,
                "parameter": param,
                "neighbayes_posterior_mean": by[param],
                "spreg_estimate": sp[param],
                "difference": by[param] - sp[param],
            }
        )

    beff = bayes_effects_df(bayes_models[model_name]).copy()
    seff = spreg_effects_df(model_name, spreg_results[model_name]).copy()
    merged = beff.merge(seff, on="feature", how="inner")
    if not merged.empty:
        merged["direct_difference"] = (
            merged["direct_neighbayes"] - merged["direct_spreg"]
        )
        merged["indirect_difference"] = (
            merged["indirect_neighbayes"] - merged["indirect_spreg"]
        )
        merged["total_difference"] = merged["total_neighbayes"] - merged["total_spreg"]
        merged.insert(0, "model", model_name)
    effect_tables[model_name] = merged

comparison = (
    pd.DataFrame(coef_rows).sort_values(["model", "parameter"]).reset_index(drop=True)
)
spatial_effects_comparison = pd.concat(
    [tbl for tbl in effect_tables.values() if not tbl.empty],
    ignore_index=True,
)

Coefficient comparison

display(comparison)
model parameter neighbayes_posterior_mean spreg_estimate difference
0 SAR CONSTANT 37.938906 37.268135 0.670771
1 SAR CRIME -0.453989 -0.451020 -0.002969
2 SAR INC 0.573664 0.563156 0.010509
3 SDEM CONSTANT 32.107988 29.460538 2.647450
4 SDEM CRIME -0.583494 -0.575430 -0.008064
5 SDEM INC 0.839808 0.820920 0.018889
6 SDEM W_CRIME 0.242009 0.300693 -0.058685
7 SDEM W_INC 0.451883 0.478086 -0.026203
8 SDEM lambda 0.432330 0.358330 0.074000
9 SDM CONSTANT 23.621707 17.045361 6.576346
10 SDM CRIME -0.609352 -0.608430 -0.000922
11 SDM INC 0.762524 0.803686 -0.041162
12 SDM W_CRIME 0.399872 0.475451 -0.075580
13 SDM W_INC -0.072790 0.042213 -0.115003
14 SEM CONSTANT 47.234057 48.008252 -0.774195
15 SEM CRIME -0.555294 -0.559458 0.004164
16 SEM INC 0.756096 0.711532 0.044564
17 SEM lambda 0.415846 0.390499 0.025348
18 SLX CONSTANT 29.978742 27.727885 2.250857
19 SLX CRIME -0.579939 -0.590675 0.010737
20 SLX INC 0.745974 0.742442 0.003532
21 SLX W_CRIME 0.354822 0.390156 -0.035335
22 SLX W_INC 0.390795 0.486179 -0.095384

Direct/Indirect/Total effects comparison

display(spatial_effects_comparison)
model feature direct_neighbayes indirect_neighbayes total_neighbayes direct_spreg indirect_spreg total_spreg direct_difference indirect_difference total_difference
0 SLX INC 0.745974 0.390795 1.136769 0.742442 0.486179 1.228621 0.003532 -0.095384 -0.091852
1 SLX CRIME -0.579939 0.354822 -0.225117 -0.590675 0.390156 -0.200519 0.010737 -0.035335 -0.024598
2 SAR INC 0.586206 0.176688 0.762894 0.563156 0.168814 0.731970 0.023050 0.007874 0.030924
3 SAR CRIME -0.464093 -0.141969 -0.606061 -0.451020 -0.135200 -0.586219 -0.013073 -0.006769 -0.019842
4 SEM INC 0.756096 0.000000 0.756096 0.711532 0.000000 0.711532 0.044564 0.000000 0.044564
5 SEM CRIME -0.555294 0.000000 -0.555294 -0.559458 0.000000 -0.559458 0.004164 0.000000 0.004164
6 SDM INC 0.788745 0.304948 1.093693 0.803686 0.523510 1.327196 -0.014941 -0.218562 -0.233503
7 SDM CRIME -0.595859 0.260551 -0.335309 -0.608430 0.399790 -0.208640 0.012570 -0.139239 -0.126669
8 SDEM INC 0.839808 0.451883 1.291691 0.820920 0.478086 1.299005 0.018889 -0.026203 -0.007314
9 SDEM CRIME -0.583494 0.242009 -0.341485 -0.575430 0.300693 -0.274737 -0.008064 -0.058685 -0.066748

neighbayes uses Bayesian inference while spreg reports frequentist point estimates, so exact equality is not expected. The coefficient and effects tables are intended to verify directional and numerical agreement under matched model formulas with HOVAL as the dependent variable.

For effects, this notebook now uses spreg’s own spatial-impact utilities: spreg.sputils.spmultiplier and spreg.sputils._sp_effects. SEM is the one exception, since it has no spatial multiplier on the systematic component and therefore uses direct = coefficient, indirect = 0, total = coefficient.

Note on intercept exclusion: neighbayes excludes the intercept from spatial effects for all model types, since the intercept has no meaningful spatial impact interpretation. This is consistent with spreg’s _sp_effects, which also excludes the constant (CONSTANT) from its impact calculations.

Example: Synthetic Data on a 30x30 Grid

This example demonstrates how to generate synthetic spatial data on a 30x30 regular grid, construct a spatial weights matrix, and fit a cross-sectional spatial regression model using the neighbayes package.

from neighbayes import dgp
from neighbayes.models import SAR

# Set random seed for reproducibility
np.random.seed(42)

# True parameters
beta_true = np.array([1.0, -1.0])
rho_true = 0.5
sigma_true = 1.0

gdf = dgp.simulate_sar(
    n=40,
    beta=beta_true,
    rho=rho_true,
    sigma=sigma_true,
    create_gdf=True,
    contiguity="queen",
)

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

# Fit SAR model
model = SAR("y ~ X_1", W=w, data=gdf)
res = model.fit()

model.summary()
Gibbs sampling (sar): 4 chains for 1,000 tune and 2,000 draw iterations (4 x 3,000 = 12,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 (3,407 draws/s)
mean sd hdi_3% hdi_97% mcse_mean mcse_sd ess_bulk ess_tail r_hat
rho 0.512 0.027 0.461 0.565 0.000 0.000 7693.0 5844.0 1.0
sigma 1.038 0.019 1.004 1.074 0.000 0.000 7936.0 7660.0 1.0
sigma2 1.078 0.038 1.008 1.154 0.000 0.000 7936.0 7660.0 1.0
Intercept 0.951 0.059 0.845 1.067 0.001 0.001 7714.0 6495.0 1.0
X_1 -1.039 0.026 -1.089 -0.991 0.000 0.000 8210.0 7728.0 1.0
model.spatial_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 -1.088809 -1.143583 -1.034024 0.0 -1.045492 -1.297881 -0.835712 0.0 -2.134301 -2.41185 -1.895391 0.0
sim_spreg = spreg.GM_Lag(
    gdf["y"],
    gdf["X_1"],
    w=w,
)
GM_Lag
print(sim_spreg.summary)
REGRESSION RESULTS
------------------

SUMMARY OF OUTPUT: SPATIAL TWO STAGE LEAST SQUARES
------------------------------------------------------------------------------------
Data set            :     unknown
Weights matrix      :     unknown
Dependent Variable  :           y                Number of Observations:        1600
Mean dependent var  :      1.9241                Number of Variables   :           3
S.D. dependent var  :      1.5903                Degrees of Freedom    :        1597
Pseudo R-squared    :      0.5740
Spatial Pseudo R-squared:  0.5031

------------------------------------------------------------------------------------
            Variable     Coefficient       Std.Error     z-Statistic     Probability
------------------------------------------------------------------------------------
            CONSTANT         0.99133         0.09632        10.29219         0.00000
                 X_1        -1.04136         0.02621       -39.73854         0.00000
                 W_y         0.49052         0.04814        10.18866         0.00000
------------------------------------------------------------------------------------
Instrumented: W_y
Instruments: W_X_1

DIAGNOSTICS FOR SPATIAL DEPENDENCE
TEST                              DF         VALUE           PROB
Anselin-Kelejian Test             1          0.057           0.8117

SPATIAL LAG MODEL IMPACTS
Impacts computed using the 'simple' method.
            Variable         Direct        Indirect          Total
                 X_1        -1.0414         -1.0026         -2.0440
================================ END OF REPORT =====================================