How to fit zero-inflated SAR negative binomial models

Count data with more zeros than a negative binomial can account for, where the excess zeros come from a separate process — corridors that are inactive altogether, not merely low-intensity. SARZINB fits the selection and count equations jointly, each with its own spatial structure and its own weights matrix, and decomposes the observed zeros between the two.

It is Gibbs-only. Equations and constructor arguments are in Supported Models.

Important

Check that you need zero inflation Excess zeros are often ordinary count zeros from a small mean, not a separate process — Santos Silva & Tenreyro (2011) argue exactly this for trade flows. Fit a plain negative binomial first and compare before reaching for this model.

import arviz as az
import matplotlib.pyplot as plt
import numpy as np

from neighbayes.dgp import simulate_sar_zinb
from neighbayes.models import SARZINB

az.style.use("arviz-darkgrid")
plt.rcParams["figure.figsize"] = (10, 6)
/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

The simulate_sar_zinb function generates data from a two-equation spatial model:

  • Selection: SAR-logit with parameter λ and covariates Z

  • Count: Reduced-form SAR-NB with parameter ρ, covariates X, and dispersion α

We use a 50×50 grid (2500 observations) for a demo.

# Simulate data from the ZINB DGP
# Use n=50 (50x50 grid → 2500 observations) for reliable parameter recovery.
# Spatial parameters (ρ, λ) require n ≥ 900 for reliable recovery in
# cross-sectional count models. The model raises a UserWarning when n < 900.
data = simulate_sar_zinb(
    n=50,  # 50x50 grid → 2500 observations
    rho=0.4,  # Count SAR parameter
    lam=0.3,  # Selection SAR parameter
    alpha=5.0,  # NB dispersion (larger = less overdispersion)
    beta=np.array([1.0, 0.6]),  # Count coefficients [intercept, slope]
    gamma=np.array([0.3, 1.0]),  # Selection coefficients [intercept, slope]
    target_pi=0.7,  # Target corridor activation probability
    seed=42,
)

y = data["y"]
X = data["X"]
Z = data["Z"]
W_graph = data["W_graph"]
W_sel_graph = data["W_sel_graph"]

print(f"Observations: {len(y)}")
print(f"Zero fraction: {(y == 0).mean():.2%}")
print(f"Mean (y>0): {y[y > 0].mean():.1f}")
print(f"Max: {y.max():.0f}")
print("\nTrue parameters:")
print(f"  ρ (count SAR):  {data['params_true']['rho']}")
print(f"  λ (sel SAR):    {data['params_true']['lam']}")
print(f"  α (dispersion): {data['params_true']['alpha']}")
print(f"  β (count coef): {data['params_true']['beta']}")
print(f"  γ (sel coef):   {data['params_true']['gamma']}")
Observations: 2500
Zero fraction: 33.92%
Mean (y>0): 6.6
Max: 65

True parameters:
  ρ (count SAR):  0.4
  λ (sel SAR):    0.3
  α (dispersion): 5.0
  β (count coef): [1.  0.6]
  γ (sel coef):   [0.99612844 1.        ]

Fit the model

The SARZINB model class accepts:

  • y: non-negative integer counts

  • X: count covariate matrix

  • Z: selection covariate matrix (defaults to X if not provided)

  • W: spatial weights for the count equation

  • W_sel: spatial weights for the selection equation (defaults to W)

The fit() method runs a 9-block Gibbs sampler with profile-log-likelihood initialisation.

# Create and fit the ZINB-SAR model
model = SARZINB(
    y=y,
    X=X,
    Z=Z,
    W=W_graph,
    W_sel=W_sel_graph,
)

idata = model.fit(
    draws=2000,
    tune=1000,
    chains=4,
    random_seed=123,
    n_jobs=-1,
    progressbar=True,
)
Gibbs sampling (zinb_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 60s (200 draws/s)

Read the two equations separately

Compare posterior means to the true parameter values used in the DGP.

# Posterior summary
summary = az.summary(idata, var_names=["rho", "lam", "alpha", "beta", "gamma"])
print(summary)

# Compare with true values
print("\n--- Parameter Recovery ---")
print(
    f"ρ (count SAR):  true={data['params_true']['rho']:.2f}, "
    f"posterior mean={float(idata.posterior['rho'].mean()):.3f} "
    f"± {float(idata.posterior['rho'].std()):.3f}"
)
print(
    f"λ (sel SAR):    true={data['params_true']['lam']:.2f}, "
    f"posterior mean={float(idata.posterior['lam'].mean()):.3f} "
    f"± {float(idata.posterior['lam'].std()):.3f}"
)
print(
    f"α (dispersion): true={data['params_true']['alpha']:.2f}, "
    f"posterior mean={float(idata.posterior['alpha'].mean()):.3f} "
    f"± {float(idata.posterior['alpha'].std()):.3f}"
)

# Note: spatial parameters (ρ, λ) require large samples (n ≥ 900)
# for reliable recovery in cross-sectional count models. The posterior
# is typically wide and may be attenuated toward zero at moderate n.
# The model raises a UserWarning when n < 900.
            mean     sd  hdi_3%  hdi_97%  mcse_mean  mcse_sd  ess_bulk  \
rho        0.363  0.041   0.286    0.438      0.001    0.000    6066.0   
lam        0.272  0.117   0.040    0.478      0.002    0.001    3919.0   
alpha      4.987  0.365   4.332    5.683      0.005    0.004    5291.0   
beta[x0]   1.065  0.067   0.940    1.192      0.001    0.001    5967.0   
beta[x1]   0.599  0.017   0.565    0.630      0.000    0.000    4977.0   
gamma[z0]  0.715  0.124   0.503    0.958      0.002    0.001    3901.0   
gamma[z1]  0.810  0.062   0.697    0.928      0.001    0.001    2707.0   

           ess_tail  r_hat  
rho          5443.0    1.0  
lam          5295.0    1.0  
alpha        6007.0    1.0  
beta[x0]     5460.0    1.0  
beta[x1]     6731.0    1.0  
gamma[z0]    4581.0    1.0  
gamma[z1]    4782.0    1.0  

--- Parameter Recovery ---
ρ (count SAR):  true=0.40, posterior mean=0.363 ± 0.041
λ (sel SAR):    true=0.30, posterior mean=0.272 ± 0.117
α (dispersion): true=5.00, posterior mean=4.987 ± 0.365

What to check before trusting the output

Check R-hat and effective sample sizes to verify MCMC convergence.

# Convergence diagnostics
az.plot_trace(idata, var_names=["rho", "lam", "alpha"], compact=False)
plt.tight_layout()
plt.show()

# R-hat and ESS
diagnostics = az.summary(idata, var_names=["rho", "lam", "alpha"])
print(diagnostics[["r_hat", "ess_bulk", "ess_tail"]])
/tmp/ipykernel_6872/1873529208.py:3: UserWarning: The figure layout has changed to tight
  plt.tight_layout()
../_images/5ed8d4741b51c9b62fb25d39ba63f10b863057d54c61b40cc5ad3ba7b6f94328.png
       r_hat  ess_bulk  ess_tail
rho      1.0    6066.0    5443.0
lam      1.0    3919.0    5295.0
alpha    1.0    5291.0    6007.0

Split the zeros between the two processes

The ZINB model decomposes observed zeros into two types:

  • Structural zeros: the observation is inactive (d=0)

  • Sampling zeros: the observation is active (d=1) but the NB draw was zero

The zero_attribution() method computes the posterior probability of each type for every zero observation.

# Zero attribution
attribution = model.zero_attribution()

print(f"Number of zero observations: {len(attribution['zero_indices'])}")
print(f"\nMean P(structural | y=0): {attribution['structural_prob'].mean():.3f}")
print(f"Mean P(sampling | y=0):   {attribution['sampling_prob'].mean():.3f}")

# Histogram of structural zero probabilities
fig, ax = plt.subplots(1, 2, figsize=(12, 4))
ax[0].hist(attribution["structural_prob"], bins=20, edgecolor="white")
ax[0].set_xlabel("P(structural zero | y=0)")
ax[0].set_ylabel("Count")
ax[0].set_title("Structural Zero Probabilities")

ax[1].hist(attribution["sampling_prob"], bins=20, edgecolor="white", color="C1")
ax[1].set_xlabel("P(sampling zero | y=0)")
ax[1].set_ylabel("Count")
ax[1].set_title("Sampling Zero Probabilities")
plt.tight_layout()
plt.show()
Number of zero observations: 848

Mean P(structural | y=0): 0.877
Mean P(sampling | y=0):   0.123
/tmp/ipykernel_6872/1143200135.py:19: UserWarning: The figure layout has changed to tight
  plt.tight_layout()
../_images/dc04f659406143c99d21cd92fc91e0dce3e5e833f6ab62c1cb1092d109d6628f.png

Get per-observation activation probabilities

The corridor_probabilities() method returns the posterior-mean probability that each observation is “active” (i.e., in the count regime rather than the always-zero regime).

# Corridor activation probabilities
pi = model.corridor_probabilities()

print(f"Mean corridor probability: {pi.mean():.3f}")
print(f"Min corridor probability:   {pi.min():.3f}")
print(f"Max corridor probability:   {pi.max():.3f}")

# Compare with true d
d_true = data["d"]
print(f"\nTrue activation rate: {d_true.mean():.3f}")
print(f"Estimated activation rate: {pi.mean():.3f}")

# Scatter: pi vs observed y
fig, ax = plt.subplots(figsize=(8, 5))
ax.scatter(pi, y, alpha=0.5, s=20)
ax.set_xlabel("Estimated P(active)")
ax.set_ylabel("Observed count y")
ax.set_title("Corridor Activation Probability vs Observed Count")
ax.axhline(0, color="grey", linestyle="--", alpha=0.5)
plt.tight_layout()
plt.show()
Mean corridor probability: 0.703
Min corridor probability:   0.142
Max corridor probability:   0.980

True activation rate: 0.704
Estimated activation rate: 0.703
/tmp/ipykernel_6872/1500508181.py:20: UserWarning: The figure layout has changed to tight
  plt.tight_layout()
../_images/f034a93ca2864770f59e61184a2b82382d59821d4e15b773c939124992204b09.png

Predict expected counts

The fitted_values() method returns E[y_i] = π_i · exp(η_i^cnt), the posterior-mean expected count for each observation.

# Fitted mean counts
fitted = model.fitted_values()

print(f"Mean fitted count: {fitted.mean():.2f}")
print(f"Mean observed count: {y.mean():.2f}")

# Scatter: fitted vs observed
fig, ax = plt.subplots(figsize=(8, 5))
ax.scatter(fitted, y, alpha=0.5, s=20)
ax.plot(
    [0, max(y.max(), fitted.max())],
    [0, max(y.max(), fitted.max())],
    "r--",
    alpha=0.5,
    label="45° line",
)
ax.set_xlabel("Fitted E[y]")
ax.set_ylabel("Observed y")
ax.set_title("Fitted vs Observed Counts")
ax.legend()
plt.tight_layout()
plt.show()
Mean fitted count: 4.40
Mean observed count: 4.39
/tmp/ipykernel_6872/2483833273.py:21: UserWarning: The figure layout has changed to tight
  plt.tight_layout()
../_images/26381b3dfa3d406117e64098dc22a5981d170aef35d9e6b067f625004b944baf.png

Inspect the posteriors

Visualize the posterior distributions of the spatial parameters ρ and λ, and the NB dispersion α, with true values marked.

# Posterior distributions with true values
fig, axes = plt.subplots(1, 3, figsize=(14, 4))

# ρ (count SAR)
az.plot_posterior(
    idata, var_names=["rho"], ref_val=data["params_true"]["rho"], ax=axes[0]
)
axes[0].set_title("ρ (Count SAR)")

# λ (selection SAR)
az.plot_posterior(
    idata, var_names=["lam"], ref_val=data["params_true"]["lam"], ax=axes[1]
)
axes[1].set_title("λ (Selection SAR)")

# α (NB dispersion)
az.plot_posterior(
    idata, var_names=["alpha"], ref_val=data["params_true"]["alpha"], ax=axes[2]
)
axes[2].set_title("α (NB Dispersion)")

plt.tight_layout()
plt.show()

# Note: spatial parameters (ρ, λ) require large samples (n ≥ 900)
# for reliable recovery in cross-sectional count models. The posterior
# is typically wide and may be attenuated toward zero at moderate n.
findfont: Failed to find font weight semibold for DejaVu Sans, now using 700.
/tmp/ipykernel_6872/297505054.py:22: UserWarning: The figure layout has changed to tight
  plt.tight_layout()
../_images/57dc3ac2c291c04e65e7b40cd44a2229316c2fef270386afcb537e6b1ad2c653.png

See also