How to fit spatial logit models

Binary outcomes with spatial dependence. SARLogit is the reduced form and the one to reach for; SARLogitStructural is the same model parameterised without inverting \((I - \rho W)\); SEMLogit puts the spatial structure in the errors instead, as a nuisance rather than a channel for spillovers.

All three sample by Pólya–Gamma Gibbs and have no NUTS path — fit(sampler="nuts") raises. Equations and constructor arguments are in Supported Models; if you have not yet decided between a lag and an error specification, start with the Bayesian LM specification tests.

import arviz as az
import numpy as np
import pandas as pd

from neighbayes.dgp import simulate_sar_logit, simulate_sem_logit
from neighbayes.models import SARLogit, SARLogitStructural, SEMLogit
/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

Simulate a known process

simulate_sar_logit builds a square grid of side n, so n=35 gives \(35^2 = 1225\) observations. That matters: binary outcomes carry far less information per observation than continuous ones, and \(\rho\) in a spatial logit is weakly identified below roughly a thousand units. Fitting one of these to \(n = 100\) and concluding the sampler is broken is a common mistake.

RHO_TRUE, BETA_TRUE = 0.5, np.array([-0.3, 0.5])

data_sar = simulate_sar_logit(n=35, rho=RHO_TRUE, beta=BETA_TRUE, seed=42)
y_sar, X_sar, W_sar = data_sar["y"], data_sar["X"], data_sar["W_graph"]

print(f"observations : {len(y_sar)}  (35 x 35 queen grid)")
print(f"covariates   : {X_sar.shape[1]}")
print(f"outcome      : {int(y_sar.sum())} ones, {int(len(y_sar) - y_sar.sum())} zeros")
observations : 1225  (35 x 35 queen grid)
covariates   : 2
outcome      : 463 ones, 762 zeros

Fit the reduced-form model

The sweep has four blocks, only one of which is non-conjugate:

  1. \(\omega \mid \eta\) — Pólya–Gamma augmentation

  2. \(\eta \mid \omega, \rho, \beta\) — spatial-normal draw

  3. \(\beta \mid \eta, \rho\) — conjugate normal

  4. \(\rho \mid \beta, \omega, y\) — collapsed one-dimensional slice, with \(\eta\) integrated out

Collapsing block 4 is what makes this mix. Conditioning \(\rho\) on the current \(\eta\) draw instead couples the two so tightly that the chain crawls.

model_sar = SARLogit(y=y_sar, X=X_sar, W=W_sar)
idata_sar = model_sar.fit(
    draws=1500, tune=800, chains=4, random_seed=42, progressbar=False
)

az.summary(idata_sar, var_names=["rho", "beta"]).round(3)
mean sd hdi_3% hdi_97% mcse_mean mcse_sd ess_bulk ess_tail r_hat
rho 0.432 0.177 0.098 0.742 0.003 0.002 4838.0 3942.0 1.0
beta[x0] -0.287 0.099 -0.474 -0.114 0.001 0.001 5402.0 4691.0 1.0
beta[x1] 0.437 0.068 0.304 0.558 0.001 0.001 4773.0 5006.0 1.0
print(f"rho  : {float(idata_sar.posterior['rho'].mean()):.3f}   (true {RHO_TRUE})")
print(
    f"beta : {idata_sar.posterior['beta'].mean(dim=['chain', 'draw']).values.round(3)}"
    f"   (true {BETA_TRUE})"
)
rho  : 0.432   (true 0.5)
beta : [-0.287  0.437]   (true [-0.3  0.5])

\(\rho\) comes back with a wide credible interval even at \(n = 1225\). That is the model, not the sampler — check ess_bulk and r_hat above to confirm the chain is healthy, then read the width as what the data support.

Read the spillovers

In a linear SAR model \(\beta\) is a marginal effect. Here it is not. The link is nonlinear, so a change in \(x_i\) moves \(y_j\) through both the spatial multiplier \((I - \rho W)^{-1}\) and the logistic derivative, which varies with \(\eta_i\). spatial_effects() does the averaging and returns the LeSage–Pace decomposition — direct, indirect, total — on the probability scale.

model_sar.spatial_effects().round(4)
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
x1 0.4661 0.3429 0.5907 0.0 0.3639 0.0159 0.8821 0.0367 0.83 0.4514 1.3517 0.0

Report these, not the raw coefficients, whenever the question is “how much does \(x\) change the outcome”. The gap between direct and the coefficient is the part of the effect that the spatial multiplier feeds back onto the unit itself.

Fit the error model

SEMLogit swaps the lag for spatially correlated disturbances. The collapsed density for \(\lambda\) carries an extra term the SAR version does not: the correction \(-\tfrac{1}{2}(X\beta)^\top A_\lambda^\top A_\lambda (X\beta)\) depends on \(\lambda\), whereas the SAR analogue is constant in \(\rho\) and drops out.

data_sem = simulate_sem_logit(n=35, lam=0.5, beta=BETA_TRUE, seed=42)
y_sem, X_sem, W_sem = data_sem["y"], data_sem["X"], data_sem["W_graph"]

model_sem = SEMLogit(y=y_sem, X=X_sem, W=W_sem)
idata_sem = model_sem.fit(
    draws=1500, tune=800, chains=4, random_seed=42, progressbar=False
)

az.summary(idata_sem, var_names=["lam", "beta"]).round(3)
mean sd hdi_3% hdi_97% mcse_mean mcse_sd ess_bulk ess_tail r_hat
lam 0.369 0.152 0.080 0.627 0.003 0.002 3589.0 3504.0 1.00
beta[x0] -0.365 0.083 -0.516 -0.207 0.002 0.001 1204.0 2621.0 1.00
beta[x1] 0.511 0.080 0.359 0.659 0.004 0.002 352.0 844.0 1.01

Reduced form or structural form

SARLogitStructural samples the same SAR-logit model without inverting \((I - \rho W)\) into the mean. Fit both on the same data and they should agree; what differs is the sampler’s internals.

model_struct = SARLogitStructural(y=y_sar, X=X_sar, W=W_sar)
idata_struct = model_struct.fit(
    draws=1500, tune=800, chains=4, random_seed=42, progressbar=False
)

pd.DataFrame(
    {
        "reduced (SARLogit)": [
            float(idata_sar.posterior["rho"].mean()),
            float(idata_sar.posterior["rho"].std()),
            float(az.ess(idata_sar, var_names=["rho"])["rho"]),
        ],
        "structural": [
            float(idata_struct.posterior["rho"].mean()),
            float(idata_struct.posterior["rho"].std()),
            float(az.ess(idata_struct, var_names=["rho"])["rho"]),
        ],
    },
    index=["rho mean", "rho sd", "rho ess"],
).round(3)
reduced (SARLogit) structural
rho mean 0.432 0.346
rho sd 0.177 0.128
rho ess 4837.670 3945.139

The two parameterisations are algebraically the same model — multiply the structural form through by \((I - \rho W)^{-1}\) and you have the reduced one — so the check is whether the posteriors are consistent, not whether the means match to three decimals. Compare the gap between the means against the posterior standard deviations: a difference well inside one standard deviation is two independent implementations agreeing, and that is worth more than either fit alone.

They are not interchangeable in practice. The structural sampler conditions on \(\eta\) rather than integrating through the inverse, which changes both the cost per sweep and the width of the \(\rho\) posterior it reports. Prefer SARLogit unless you have a reason to want the latent field itself.

Configure the sampler

gibbs_backend behaves as it does everywhere in the package: "auto" (the default — JAX when installed, else NumPy), "jax", or "numpy".

Beyond the shared fit() arguments, the two families accept different options. Anything else raises TypeError rather than being silently ignored.

Option

SARLogit

SEMLogit, SARLogitStructural

Meaning

slice_width

✅

—

initial slice interval for ρ

init_jitter

✅

—

scale of the random perturbation applied to chain starts

krylov_degree, krylov_dmax, krylov_reuse

✅

krylov_degree, krylov_dmax

shift-invert Krylov basis for the ρ conditional — degree, trust radius, and whether the basis is reused across draws

timeout

✅

—

abort a run that exceeds this many seconds

lanczos_deg, n_probes

—

✅

stochastic log-determinant: Lanczos depth and number of probe vectors

pg_n_terms

—

✅

truncation of the Pólya–Gamma series

return_eta

—

✅

keep the latent field \(\eta\) in the returned InferenceData

idata_tuned = SARLogit(y=y_sar, X=X_sar, W=W_sar).fit(
    draws=800,
    tune=500,
    chains=2,
    random_seed=7,
    progressbar=False,
    gibbs_backend="numpy",
    slice_width=0.4,
    krylov_reuse=True,
)
print(
    f"rho = {float(idata_tuned.posterior['rho'].mean()):.3f}, "
    f"ess = {float(az.ess(idata_tuned, var_names=['rho'])['rho']):.0f}"
)
rho = 0.433, ess = 1277

Fitted probabilities

fitted_probabilities() returns \(P(y_i = 1)\) at the posterior mean. The two model types differ in what the spatial parameter does to the mean:

  • SARLogit — \(\hat\eta = (I - \hat\rho W)^{-1} X\hat\beta\); the multiplier is in the mean

  • SEMLogit — \(\hat\eta = X\hat\beta\); the spatial parameter shapes the variance, leaving the mean alone

probs_sar = model_sar.fitted_probabilities()
probs_sem = model_sem.fitted_probabilities()

pd.DataFrame(
    {
        "SARLogit": [probs_sar.mean(), probs_sar.min(), probs_sar.max()],
        "SEMLogit": [probs_sem.mean(), probs_sem.min(), probs_sem.max()],
    },
    index=["mean", "min", "max"],
).round(3)
SARLogit SEMLogit
mean 0.377 0.411
min 0.103 0.097
max 0.737 0.779

What to check before trusting the output

print("groups:", idata_sar.groups())
az.summary(idata_sar, var_names=["rho"]).round(3)
groups: ['posterior', 'observed_data']
mean sd hdi_3% hdi_97% mcse_mean mcse_sd ess_bulk ess_tail r_hat
rho 0.432 0.177 0.098 0.742 0.003 0.002 4838.0 3942.0 1.0

Caution

az.loo and az.waic run on this output and should not be used to choose between a lag and an error specification. Pointwise leave-one-out assumes observations are exchangeable given the parameters; under a spatial lag they are not, because removing \(y_i\) changes the implied mean of its neighbours through \((I - \rho W)^{-1}\). Use the Bayesian LM specification tests or spatial block cross-validation instead.

See also