How to estimate spatial negative binomial models¶
Overdispersed counts with spatial dependence. SARNegBin is the reduced form
and the default choice; SARNegBinStructural adds an explicit latent noise
term and a sigma parameter, at a large cost in mixing.
Both sample by Pólya–Gamma Gibbs, which fit() selects for you; SARNegBin
also has a NUTS path. Equations and constructor arguments are in
Supported Models.
import contextlib
import io
import time
import arviz as az
from neighbayes.dgp import simulate_sar_negbin
from neighbayes.models import SARNegBin
/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_negbin builds a square grid of side n, so n=25 gives 625
observations. As with the spatial logit models, \(\rho\) in a count model is
weakly identified at small \(n\) — a few hundred units is the floor for the
spatial parameter to be recoverable at all.
RHO_TRUE, ALPHA_TRUE = 0.4, 2.0
data = simulate_sar_negbin(n=25, rho=RHO_TRUE, seed=1)
y, X, W = data["y"], data["X"], data["W_graph"]
print(f"observations : {len(y)}")
print(f"true parameters: {data['params_true']}")
print(f"mean count : {y.mean():.2f}")
print(f"variance : {y.var():.2f} (>> mean: overdispersed, as intended)")
observations : 625
true parameters: {'rho': 0.4, 'beta': array([1. , 0.6]), 'alpha': 2.0}
mean count : 6.52
variance : 64.00 (>> mean: overdispersed, as intended)
Fit the reduced-form model¶
def fit_timed(model, **kw):
start = time.perf_counter()
with (
contextlib.redirect_stdout(io.StringIO()),
contextlib.redirect_stderr(io.StringIO()),
):
idata = model.fit(progressbar=False, **kw)
return idata, time.perf_counter() - start
COMMON = dict(draws=1500, tune=800, chains=4, random_seed=1)
idata_gibbs, secs_gibbs = fit_timed(SARNegBin(y=y, X=X, W=W), **COMMON)
az.summary(idata_gibbs, var_names=["rho", "beta", "alpha"]).round(3)
| mean | sd | hdi_3% | hdi_97% | mcse_mean | mcse_sd | ess_bulk | ess_tail | r_hat | |
|---|---|---|---|---|---|---|---|---|---|
| rho | 0.384 | 0.090 | 0.212 | 0.550 | 0.002 | 0.001 | 3533.0 | 4222.0 | 1.0 |
| beta[x0] | 1.055 | 0.154 | 0.768 | 1.343 | 0.003 | 0.002 | 3556.0 | 3854.0 | 1.0 |
| beta[x1] | 0.598 | 0.039 | 0.523 | 0.669 | 0.001 | 0.000 | 3117.0 | 4612.0 | 1.0 |
| alpha | 2.029 | 0.158 | 1.738 | 2.333 | 0.002 | 0.002 | 5764.0 | 4185.0 | 1.0 |
print(f"rho : {float(idata_gibbs.posterior['rho'].mean()):.3f} (true {RHO_TRUE})")
print(
f"alpha : {float(idata_gibbs.posterior['alpha'].mean()):.3f} (true {ALPHA_TRUE})"
)
rho : 0.384 (true 0.4)
alpha : 2.029 (true 2.0)
The dispersion parameter recovers tightly. \(\rho\) recovers to within its posterior standard deviation — read the interval, not the point.
Configure the sampler¶
gibbs_backend is "auto" (JAX when installed, else NumPy), "jax", or
"numpy", as elsewhere in the package. The count families accept these
additional options; anything else raises TypeError.
Option |
|
|
Meaning |
|---|---|---|---|
|
✅ |
— |
initial slice interval for ρ |
|
✅ |
— |
dispersion of the chain starting points around the GLM warm start |
|
✅ |
— |
how many ρ/ω sub-sweeps per iteration |
|
✅ |
✅ |
shift-invert Krylov basis for the ρ conditional |
|
✅ |
— |
reuse that basis across draws |
|
✅ |
— |
abort a run exceeding this many seconds |
|
— |
✅ |
stochastic log-determinant depth and probe count |
|
— |
✅ |
truncation of the Pólya–Gamma series |
|
— |
✅ |
keep the latent field in the returned |
idata_tuned, _ = fit_timed(
SARNegBin(y=y, X=X, W=W),
gibbs_backend="numpy",
slice_width=0.3,
init_jitter=0.5,
krylov_reuse=True,
draws=800,
tune=500,
chains=2,
random_seed=7,
)
print(
f"rho = {float(idata_tuned.posterior['rho'].mean()):.3f}, "
f"ess = {float(az.ess(idata_tuned, var_names=['rho'])['rho']):.0f}"
)
rho = 0.381, ess = 1005
Is the negative binomial the right likelihood?¶
The dispersion parameter answers this. \(\alpha \to \infty\) is the Poisson, so
a posterior for alpha that is large and poorly determined says the data carry
no conditional overdispersion for the extra parameter to fit.
alpha = idata_gibbs.posterior["alpha"]
print(f"alpha: mean {float(alpha.mean()):.2f}, sd {float(alpha.std()):.2f}")
print(
f"94% HDI: {az.hdi(idata_gibbs, var_names=['alpha'], hdi_prob=0.94)['alpha'].values.round(2)}"
)
alpha: mean 2.03, sd 0.16
94% HDI: [1.74 2.33]
Here alpha is tightly determined near its true value of 2, so the
negative binomial is earning its parameter.
Caution
Do not approximate a Poisson by fixing alpha large
It is tempting to reach for a big fixed alpha when you want Poisson
behaviour. That is the one regime where PG augmentation degenerates: the
working precision \(E[\omega]\) diverges while the marginal Fisher information
stays at \(\mu\), and effective sample size collapses — measured here as ESS on
\(\beta\) falling from 57 at \(\alpha = 10\) to 3 at \(\alpha = 10^4\), with
\(\hat R = 1.80\). Use a real Poisson sampler instead; the flow case is covered in
How to estimate Poisson origin–destination flow models.
See also¶
How to fit zero-inflated SAR negative binomial models — when excess zeros come from a separate process
How to estimate Poisson origin–destination flow models — the Poisson case, and why it needs a different augmentation
How to set priors —
SARNegBinPriorsaddsalpha_sigmaandalpha_nuHow to fit spatial logit models — the same Pólya–Gamma machinery for binary outcomes