neighbayes Replication of panelg_manual and sdemo_programs

This notebook reproduces representative examples from:

  • reference/panelg_manual.pdf (panel SDEM synthetic example, Chapter 2), and

  • reference/sdemo_programs (cross-sectional SDM and SDEM synthetic examples, Chapter 7).

It also performs explicit fidelity checks against data-generating-process (DGP) truth where available.

Scope and Faithfulness Criteria

Faithfulness in this notebook means:

  1. Matching the same DGP structure and parameter values used in the reference examples.

  2. Matching panel/cross-sectional structure and fixed-effects settings.

  3. Checking that posterior means recover true parameters/effects within reasonable Monte Carlo tolerance.

Note: sdemo_programs/chapter4 convex-combination estimators (with unknown gamma weights over multiple W matrices) are not currently implemented in neighbayes. This notebook uses examples that are exactly representable by current neighbayes classes.

import numpy as np
import pandas as pd
from libpysal.graph import Graph
from scipy.sparse import csr_matrix

from neighbayes import dgp
from neighbayes.models import SDEM, SDM, SDEMPanelFE
def make_knn_w(xcoord: np.ndarray, ycoord: np.ndarray, k: int) -> np.ndarray:
    """Build row-standardized k-NN weights (no self-neighbors)."""
    coords = np.column_stack([xcoord, ycoord])
    n = coords.shape[0]
    d = np.sqrt(((coords[:, None, :] - coords[None, :, :]) ** 2).sum(axis=2))
    np.fill_diagonal(d, np.inf)
    nn = np.argpartition(d, kth=k - 1, axis=1)[:, :k]

    W = np.zeros((n, n), dtype=float)
    for i in range(n):
        W[i, nn[i]] = 1.0

    rs = W.sum(axis=1, keepdims=True)
    rs[rs == 0.0] = 1.0
    return W / rs


def to_graph(W: np.ndarray) -> Graph:
    return Graph.from_sparse(csr_matrix(W)).transform("r")


def summarize_recovery(df: pd.DataFrame, abs_tol: float = 0.25) -> pd.DataFrame:
    out = df.copy()
    out["abs_error"] = (out["estimate"] - out["truth"]).abs()
    out["relative_error_pct"] = (
        100.0 * out["abs_error"] / np.maximum(np.abs(out["truth"]), 1e-12)
    )
    out["within_tol"] = out["abs_error"] <= abs_tol
    return out

Example A: panelg_manual Chapter 2 (sdem_panel_gd)

Reference DGP (manual pages around the SDEM section):

  • n = 100, t = 20, k = 2,

  • spatial error coefficient lambda = 0.7,

  • coefficients on X: +1, coefficients on WX: -1,

  • both region and time fixed effects (model = 3).

rng = np.random.default_rng(10203040)
n, t, k = 100, 20, 2
lam_true = 0.7
beta_true = np.ones(k)
theta_true = -np.ones(k)
sige = 0.1

x = rng.standard_normal((n * t, k))
Wn = make_knn_w(rng.random(n), rng.random(n), k=5)
Wbig = np.kron(np.eye(t), Wn)

SFE = np.kron(np.ones(t), np.arange(1, n + 1) / n)
TFE = np.kron(np.arange(1, t + 1) / t, np.ones(n))
u = np.linalg.solve(
    np.eye(n * t) - lam_true * Wbig, rng.standard_normal(n * t) * np.sqrt(sige)
)
y = x @ beta_true + (Wbig @ x) @ theta_true + SFE + TFE + u

m_panel = SDEMPanelFE(
    y=y,
    X=pd.DataFrame(x, columns=["x1", "x2"]),
    W=to_graph(Wn),
    N=n,
    T=t,
    effects=3,
    priors={"lam_lower": -0.95, "lam_upper": 0.95},
)
idata_panel = m_panel.fit(
    draws=160, tune=160, chains=2, progressbar=False, random_seed=11
)
display(m_panel.summary(round_to=4))
/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)
mean sd hdi_3% hdi_97% mcse_mean mcse_sd ess_bulk ess_tail r_hat
lam 0.6968 0.0174 0.6686 0.7341 0.0010 0.0010 342.9777 243.5414 1.0014
sigma 0.3118 0.0051 0.3034 0.3227 0.0003 0.0002 317.9319 314.7534 0.9963
sigma2 0.0972 0.0032 0.0921 0.1041 0.0002 0.0001 317.9319 314.7534 0.9963
x1 1.0023 0.0079 0.9884 1.0175 0.0004 0.0003 398.8197 308.6243 1.0030
x2 1.0134 0.0085 0.9980 1.0282 0.0005 0.0003 325.5553 316.9856 0.9990
W*x1 -0.9893 0.0229 -1.0328 -0.9480 0.0012 0.0009 372.8001 361.1374 1.0001
W*x2 -0.9869 0.0238 -1.0276 -0.9395 0.0014 0.0010 295.8235 310.8763 0.9980
beta_hat = idata_panel.posterior["beta"].mean(("chain", "draw")).to_numpy()
lam_hat = float(idata_panel.posterior["lam"].mean(("chain", "draw")).to_numpy())
faith_panel = pd.DataFrame(
    {
        "term": ["lam", "x1", "x2", "W*x1", "W*x2"],
        "truth": [lam_true, 1.0, 1.0, -1.0, -1.0],
        "estimate": [lam_hat, beta_hat[0], beta_hat[1], beta_hat[2], beta_hat[3]],
    }
)
faith_panel = summarize_recovery(faith_panel, abs_tol=0.30)
display(faith_panel)
print("Panel SDEM pass rate:", faith_panel["within_tol"].mean())
term truth estimate abs_error relative_error_pct within_tol
0 lam 0.7 0.696845 0.003155 0.450726 True
1 x1 1.0 1.002266 0.002266 0.226637 True
2 x2 1.0 1.013421 0.013421 1.342057 True
3 W*x1 -1.0 -0.989325 0.010675 1.067527 True
4 W*x2 -1.0 -0.986913 0.013087 1.308706 True
Panel SDEM pass rate: 1.0

Example B: sdemo_programs Chapter 7 (sdm_cross_section_gd)

Reference DGP:

  • n = 1000, t = 1, rho = 0.5, beta = [1, 1], theta = [-0.5, -0.5],

  • intercept of 2,

  • W from k-NN coordinates with 6 neighbors.

rng = np.random.default_rng(30203040)
n, k = 1000, 2
rho_true = 0.5
intercept_true = 2.0
beta_true = np.ones(k)
theta_true = -0.5 * np.ones(k)

Wn = make_knn_w(rng.random(n), rng.random(n), k=6)
sdm_data = dgp.simulate_sdm(
    W=Wn,
    rho=rho_true,
    beta1=np.array([intercept_true, *beta_true]),
    beta2=theta_true,
    sigma=np.sqrt(0.5),
    rng=rng,
)
y = sdm_data["y"]
X_arr = sdm_data["X"]

X = pd.DataFrame({"Intercept": X_arr[:, 0], "x1": X_arr[:, 1], "x2": X_arr[:, 2]})
m_sdm = SDM(
    y=y, X=X, W=sdm_data["W_graph"], priors={"rho_lower": -0.95, "rho_upper": 0.95}
)
idata_sdm = m_sdm.fit(draws=160, tune=160, chains=2, progressbar=False, random_seed=22)
display(m_sdm.summary(round_to=4))
mean sd hdi_3% hdi_97% mcse_mean mcse_sd ess_bulk ess_tail r_hat
rho 0.4900 0.0376 0.4261 0.5631 0.0021 0.0016 313.3832 240.9541 1.0016
sigma 0.7026 0.0148 0.6734 0.7288 0.0008 0.0007 308.1941 314.2477 0.9993
sigma2 0.4939 0.0209 0.4535 0.5311 0.0012 0.0010 308.1941 314.2477 0.9994
Intercept 2.0618 0.1534 1.7468 2.3208 0.0085 0.0069 324.5976 187.2371 0.9992
x1 0.9870 0.0228 0.9467 1.0318 0.0013 0.0009 286.5444 313.9318 1.0078
x2 0.9889 0.0223 0.9446 1.0266 0.0013 0.0009 276.6480 220.3554 1.0010
W*x1 -0.5280 0.0671 -0.6541 -0.4096 0.0037 0.0026 337.3603 293.4802 1.0069
W*x2 -0.4568 0.0661 -0.5776 -0.3402 0.0035 0.0024 346.9066 266.8384 1.0008
post = idata_sdm.posterior
rho_hat = float(post["rho"].mean(("chain", "draw")).to_numpy())
beta_hat = post["beta"].mean(("chain", "draw")).to_numpy()

faith_sdm = pd.DataFrame(
    {
        "term": ["rho", "Intercept", "x1", "x2", "W*x1", "W*x2"],
        "truth": [
            rho_true,
            intercept_true,
            beta_true[0],
            beta_true[1],
            theta_true[0],
            theta_true[1],
        ],
        "estimate": [
            rho_hat,
            beta_hat[0],
            beta_hat[1],
            beta_hat[2],
            beta_hat[3],
            beta_hat[4],
        ],
    }
)

faith_sdm = summarize_recovery(faith_sdm, abs_tol=0.30)
display(faith_sdm)
print("Cross-sectional SDM fidelity pass rate:", faith_sdm["within_tol"].mean())
term truth estimate abs_error relative_error_pct within_tol
0 rho 0.5 0.489974 0.010026 2.005285 True
1 Intercept 2.0 2.061772 0.061772 3.088590 True
2 x1 1.0 0.986985 0.013015 1.301480 True
3 x2 1.0 0.988946 0.011054 1.105406 True
4 W*x1 -0.5 -0.528004 0.028004 5.600791 True
5 W*x2 -0.5 -0.456767 0.043233 8.646522 True
Cross-sectional SDM fidelity pass rate: 1.0

Example C: sdemo_programs Chapter 7 (sdem_cross_section_gd)

Reference DGP:

  • n = 3000, t = 1, rho = 0.8,

  • beta = [1,1,1,1], theta = 0.5 * beta,

  • intercept of 1,

  • SDEM disturbance process with k-NN(5) weights.

rng = np.random.default_rng(221010)
n, k = 3000, 4
lam_true = 0.8
intercept_true = 1.0
beta_true = np.ones(k)
theta_true = 0.5 * np.ones(k)

Wn = make_knn_w(rng.standard_normal(n), rng.standard_normal(n), k=5)
sdem_data = dgp.simulate_sdem(
    W=Wn,
    lam=lam_true,
    beta1=np.array([intercept_true, *beta_true]),
    beta2=theta_true,
    sigma=1.0,
    rng=rng,
)
y = sdem_data["y"]
X_arr = sdem_data["X"]

X = pd.DataFrame({"Intercept": X_arr[:, 0]})
for j in range(k):
    X[f"x{j + 1}"] = X_arr[:, j + 1]

m_sdem = SDEM(
    y=y, X=X, W=sdem_data["W_graph"], priors={"lam_lower": -0.95, "lam_upper": 0.95}
)
idata_sdem = m_sdem.fit(
    draws=160, tune=160, chains=2, progressbar=False, random_seed=33
)
display(m_sdem.summary(round_to=4))
mean sd hdi_3% hdi_97% mcse_mean mcse_sd ess_bulk ess_tail r_hat
lam 0.8159 0.0102 0.7989 0.8347 0.0006 0.0005 316.2049 257.5622 1.0086
sigma 1.0018 0.0139 0.9812 1.0318 0.0008 0.0006 323.3394 282.6139 0.9960
sigma2 1.0038 0.0278 0.9628 1.0646 0.0015 0.0012 323.3394 282.6139 0.9960
Intercept 0.8750 0.1068 0.6447 1.0470 0.0059 0.0047 311.0832 311.3319 1.0012
x1 1.0461 0.0223 1.0058 1.0868 0.0012 0.0008 376.9933 313.4532 0.9970
x2 0.9754 0.0231 0.9320 1.0144 0.0013 0.0011 334.0216 188.5729 1.0057
x3 1.0207 0.0202 0.9833 1.0579 0.0012 0.0007 280.8867 318.7052 1.0036
x4 1.0251 0.0224 0.9905 1.0673 0.0013 0.0010 279.9342 316.6450 1.0021
W*x1 0.5498 0.0636 0.4374 0.6608 0.0037 0.0021 295.3593 316.6450 0.9963
W*x2 0.4788 0.0679 0.3725 0.6230 0.0037 0.0025 331.0868 314.4909 1.0000
W*x3 0.6113 0.0590 0.4981 0.7155 0.0043 0.0024 181.0170 248.1914 1.0085
W*x4 0.6313 0.0649 0.5151 0.7543 0.0038 0.0026 294.5434 216.1454 1.0064
post = idata_sdem.posterior
lam_hat = float(post["lam"].mean(("chain", "draw")).to_numpy())
beta_hat = post["beta"].mean(("chain", "draw")).to_numpy()

terms = (
    ["lam", "Intercept"]
    + [f"x{i + 1}" for i in range(k)]
    + [f"W*x{i + 1}" for i in range(k)]
)
truth_vals = [lam_true, intercept_true] + beta_true.tolist() + theta_true.tolist()
est_vals = (
    [lam_hat, beta_hat[0]]
    + beta_hat[1 : 1 + k].tolist()
    + beta_hat[1 + k : 1 + 2 * k].tolist()
)

faith_sdem = pd.DataFrame({"term": terms, "truth": truth_vals, "estimate": est_vals})
faith_sdem = summarize_recovery(faith_sdem, abs_tol=0.30)
display(faith_sdem)
print("Cross-sectional SDEM fidelity pass rate:", faith_sdem["within_tol"].mean())
term truth estimate abs_error relative_error_pct within_tol
0 lam 0.8 0.815852 0.015852 1.981444 True
1 Intercept 1.0 0.874959 0.125041 12.504054 True
2 x1 1.0 1.046056 0.046056 4.605639 True
3 x2 1.0 0.975366 0.024634 2.463422 True
4 x3 1.0 1.020686 0.020686 2.068577 True
5 x4 1.0 1.025077 0.025077 2.507698 True
6 W*x1 0.5 0.549787 0.049787 9.957426 True
7 W*x2 0.5 0.478798 0.021202 4.240396 True
8 W*x3 0.5 0.611250 0.111250 22.250052 True
9 W*x4 0.5 0.631347 0.131347 26.269410 True
Cross-sectional SDEM fidelity pass rate: 1.0

Overall Fidelity Verdict

This notebook demonstrates that neighbayes is faithful to the selected panel/cross-sectional examples from panelg_manual and sdemo_programs that are representable by the implemented model classes (SAR/SDM/SEM/SDEM/SLX with one W matrix and FE modes).

Caveat:

  • Convex-combination examples that estimate unknown gamma weights over multiple W matrices (for example *_conv_panel_g) are not currently implemented in neighbayes. Those are outside current fidelity scope.

Example D: semip-style Spatial Probit with Spatial Regional Effects

This mirrors the core legacy semip_g structure:

  • binary outcome from latent threshold,

  • region-specific effects,

  • spatial dependence in region effects a = rho * W * a + u.

Unlike legacy semip_g, this demonstration uses homoskedastic observation-level probit variance (no v_i/r hierarchy).

from neighbayes.models import SARProbit

rng = np.random.default_rng(707)

# semip-like setup: m regions with mobs observations each
m = 48
mobs = np.full(m, 60, dtype=int)
n = int(mobs.sum())
k = 3

# Region-level kNN weights and spatial random effects
Wm = make_knn_w(rng.random(m), rng.random(m), k=6)
rho_true = 0.45
sigma_a_true = 1.2

u = rng.standard_normal(m) * sigma_a_true
a = np.linalg.solve(np.eye(m) - rho_true * Wm, u)

region_ids = np.repeat(np.arange(m), mobs)
X = rng.standard_normal((n, k))
X = np.column_stack([np.ones(n), X])
feature_names = ["Intercept", "x1", "x2", "x3"]

beta_true = np.array([0.4, 0.8, -0.6, 0.5])
eta = X @ beta_true + a[region_ids]
y = (eta + rng.standard_normal(n) > 0.0).astype(float)

sp = SARProbit(
    y=y,
    X=pd.DataFrame(X, columns=feature_names),
    W=to_graph(Wm),
    region_ids=region_ids,
    priors={"rho_lower": -0.95, "rho_upper": 0.95, "beta_sigma": 5.0},
)
idata_sp = sp.fit(
    draws=220,
    tune=220,
    chains=1,
    target_accept=0.92,
    progressbar=False,
    random_seed=707,
)

display(sp.summary(var_names=["beta", "rho", "sigma_a"], round_to=4))
Initializing NUTS using jitter+adapt_diag...
Sequential sampling (1 chains in 1 job)
NUTS: [rho, beta, sigma_a, a_raw]
Sampling 1 chain for 220 tune and 220 draw iterations (220 + 220 draws total) took 5 seconds.
Only one chain was sampled, this makes it impossible to run some convergence checks
arviz - WARNING - Shape validation failed: input_shape: (1, 220), minimum_shape: (chains=2, draws=4)
mean sd hdi_3% hdi_97% mcse_mean mcse_sd ess_bulk ess_tail r_hat
Intercept 0.8603 0.3164 0.2117 1.3094 0.1624 0.0335 4.0874 64.9692 NaN
x1 0.8670 0.0426 0.7964 0.9455 0.0036 0.0023 141.7810 154.6362 NaN
x2 -0.6241 0.0333 -0.6741 -0.5598 0.0022 0.0019 226.3296 172.2862 NaN
x3 0.5364 0.0383 0.4697 0.6003 0.0034 0.0023 132.4636 124.0137 NaN
rho 0.3801 0.1559 0.0914 0.6334 0.0332 0.0128 23.3282 69.6757 NaN
sigma_a 1.3326 0.1751 1.0528 1.6499 0.0376 0.0410 29.1071 21.2750 NaN
beta_hat = idata_sp.posterior["beta"].mean(("chain", "draw")).to_numpy()
rho_hat = float(idata_sp.posterior["rho"].mean(("chain", "draw")).to_numpy())

a_mean_hat = sp.random_effects_mean().to_numpy()
a_rmse = float(np.sqrt(np.mean((a_mean_hat - a) ** 2)))

faith_sp = pd.DataFrame(
    {
        "term": ["rho"] + feature_names,
        "truth": [rho_true] + beta_true.tolist(),
        "estimate": [rho_hat] + beta_hat.tolist(),
    }
)
faith_sp = summarize_recovery(faith_sp, abs_tol=0.30)

print(f"Regional-effects RMSE: {a_rmse:.4f}")
display(faith_sp)
print("SpatialProbit semip-style pass rate:", faith_sp["within_tol"].mean())
Regional-effects RMSE: 0.5189
SpatialProbit semip-style pass rate: 0.8
term truth estimate abs_error relative_error_pct within_tol
0 rho 0.45 0.380146 0.069854 15.523142 True
1 Intercept 0.40 0.860304 0.460304 115.075997 False
2 x1 0.80 0.867033 0.067033 8.379112 True
3 x2 -0.60 -0.624085 0.024085 4.014249 True
4 x3 0.50 0.536419 0.036419 7.283891 True