How to estimate Poisson origin–destination flow models

This guide fits SARPoissonFlowSeparable to origin–destination counts and decides between Poisson and negative binomial observation noise for the same spatial structure.

Use it when your flows are counts, you want a correctly specified count likelihood rather than a log-transformed Gaussian, and you need to know whether the extra dispersion parameter of a negative binomial is buying you anything.

The Poisson flow models sample by auxiliary-mixture Gibbs (Frühwirth-Schnatter & Wagner 2006): Poisson inter-arrival times are augmented and \(-\log\tau\) is approximated by a ten-component normal mixture, which leaves a conditionally Gaussian model that the existing SAR machinery — the same \(\rho\) step, the same sparse solves, the same log-determinant — already serves.

Bayesian Poisson is not PPML

Santos Silva & Tenreyro’s Poisson pseudo-maximum-likelihood estimator is a quasi-MLE: its point estimates are consistent under a correct conditional mean regardless of the true variance, and the inferential work is done by sandwich standard errors. A Bayesian posterior under a knowingly misspecified Poisson gives you the right point estimates but credible intervals that are too narrow when the data are overdispersed — nothing in the posterior plays the role of the sandwich.

To carry the actual robustness property you would need a weighted-likelihood bootstrap or a Gibbs posterior with a sandwich-matched learning rate (Müller 2013; Syring & Martin 2019). Neither is implemented here, so this model is family = "poisson" — a correctly specified Poisson, not PPML. Read the comparison below as a specification question, not as a robust alternative to one.

Simulate flow counts

generate_poisson_flow_data_separable draws counts from \(y_{ij}\sim\text{Poisson}(\mu_{ij})\) with \(\log\boldsymbol\mu = A(\rho_d,\rho_o,\rho_w)^{-1}X\beta\) and the separable restriction \(\rho_w = -\rho_d\rho_o\). With \(n = 25\) regions there are \(N = n^2 = 625\) flows.

import time

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

from neighbayes.dgp.flows import generate_poisson_flow_data_separable
from neighbayes.models.flow import SARNegBinFlowSeparable, SARPoissonFlowSeparable

RHO_D, RHO_O = 0.35, 0.25

data = generate_poisson_flow_data_separable(n=25, rho_d=RHO_D, rho_o=RHO_O, seed=7)
y, X, G = data["y_vec"], data["X"], data["G"]

print(f"flows      : {y.size}")
print(f"mean count : {y.mean():.2f}")
print(f"variance   : {y.var():.2f}")
print(f"zeros      : {(y == 0).mean():.1%}")
flows      : 625
mean count : 8.12
variance   : 51.81
zeros      : 4.5%
/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

Note the variance-to-mean ratio. These counts are conditionally Poisson by construction, yet marginally they look badly overdispersed, because the spatial filter \(A^{-1}\) spreads \(\mu_{ij}\) over orders of magnitude across cells. Marginal overdispersion is not evidence that you need a negative binomial — in flow data it is the ordinary signature of a gravity mean with spatial feedback. The question has to be settled by fitting both.

Fit the Poisson flow model

The interface is the shared flow-model interface: pass the vectorized flow counts, the \(N\times p\) origin–destination design matrix, and the regional weights graph on \(n\) units. Poisson flow models are Gibbs-only — fit(sampler="nuts") raises.

model_pois = SARPoissonFlowSeparable(y, X, G, col_names=data["col_names"])

start = time.perf_counter()
idata_pois = model_pois.fit(
    draws=1000, tune=500, chains=2, random_seed=11, progressbar=False
)
secs_pois = time.perf_counter() - start

az.summary(idata_pois, var_names=["rho_d", "rho_o", "rho_w"])
mean sd hdi_3% hdi_97% mcse_mean mcse_sd ess_bulk ess_tail r_hat
rho_d 0.311 0.047 0.228 0.405 0.002 0.001 499.0 846.0 1.01
rho_o 0.226 0.055 0.119 0.321 0.002 0.001 495.0 950.0 1.00
rho_w -0.070 0.020 -0.108 -0.034 0.001 0.000 494.0 1017.0 1.00

rho_w is deterministic here — the separable model pins it to \(-\rho_d\rho_o\) rather than sampling it — so it carries no independent information. It is reported because the spatial-effects decomposition needs all three.

Check recovery against the truth:

post = idata_pois.posterior
print(f"rho_d: {float(post['rho_d'].mean()):.3f}  (true {RHO_D})")
print(f"rho_o: {float(post['rho_o'].mean()):.3f}  (true {RHO_O})")
rho_d: 0.311  (true 0.35)
rho_o: 0.226  (true 0.25)

Read the spillovers

spatial_effects() gives the LeSage–Thomas-Agnan decomposition of each covariate into origin, destination, intra-regional and network components.

model_pois.spatial_effects().round(4)
mean ci_lower ci_upper bayes_pvalue
predictor side effect
x0 dest origin 0.0079 0.0044 0.0124 0.0
x1 dest origin 0.0068 0.0033 0.0114 0.0
x0 dest destination 0.4034 0.3710 0.4391 0.0
x1 dest destination 0.3911 0.3603 0.4236 0.0
x0 dest intra 0.0188 0.0129 0.0247 0.0
x1 dest intra 0.0161 0.0101 0.0221 0.0
x0 dest network 0.1705 0.1086 0.2406 0.0
x1 dest network 0.1657 0.1052 0.2405 0.0
x0 dest total 0.6006 0.5321 0.6761 0.0
x1 dest total 0.5797 0.5053 0.6704 0.0
x0 orig origin 0.4544 0.4140 0.4960 0.0
x1 orig origin 0.4659 0.4350 0.4974 0.0
x0 orig destination 0.0054 0.0024 0.0089 0.0
x1 orig destination 0.0055 0.0025 0.0090 0.0
x0 orig intra 0.0189 0.0173 0.0207 0.0
x1 orig intra 0.0194 0.0181 0.0207 0.0
x0 orig network 0.1287 0.0579 0.2138 0.0
x1 orig network 0.1316 0.0601 0.2160 0.0
x0 orig total 0.6073 0.5102 0.7275 0.0
x1 orig total 0.6224 0.5286 0.7248 0.0

Decide between Poisson and negative binomial

Fit SARNegBinFlowSeparable to the same counts, the same design, the same weights. Only the observation model differs — the negative binomial adds a free dispersion parameter alpha.

model_nb = SARNegBinFlowSeparable(y, X, G, col_names=data["col_names"])

start = time.perf_counter()
idata_nb = model_nb.fit(
    draws=1000, tune=500, chains=2, random_seed=11, progressbar=False
)
secs_nb = time.perf_counter() - start

az.summary(idata_nb, var_names=["rho_d", "rho_o", "alpha"])
mean sd hdi_3% hdi_97% mcse_mean mcse_sd ess_bulk ess_tail r_hat
rho_d 0.312 0.052 0.212 0.414 0.002 0.001 556.0 896.0 1.0
rho_o 0.226 0.058 0.116 0.333 0.003 0.002 393.0 729.0 1.0
alpha 121.873 79.086 35.647 258.705 2.151 4.054 1780.0 1223.0 1.0

Look at alpha before anything else. Under the negative binomial, \(\operatorname{Var}(y) = \mu + \mu^2/\alpha\), so \(\alpha \to \infty\) is the Poisson. A posterior mean in the hundreds with a standard deviation of the same order is the model telling you it cannot locate the dispersion parameter, because there is no conditional overdispersion to locate. That is the first piece of evidence for the simpler likelihood.

Its converse is worth knowing too: as \(\alpha\) grows, the Pólya–Gamma augmentation the negative binomial sampler relies on degenerates — the working precision \(E[\omega]\) diverges while the marginal Fisher information stays at \(\mu\), and effective sample size collapses. Fixing \(\alpha\) at a large value to “approximate” a Poisson is therefore not a shortcut; it is the one regime where that sampler fails. The auxiliary-mixture scheme exists to reach the Poisson directly instead.

Do the two posteriors agree?

Two independently implemented samplers landing on the same \(\rho\) posterior is mutual validation. Disagreement beyond Monte Carlo error is a specification signal, not a rounding difference.

rows = []
for name in ("rho_d", "rho_o"):
    rows.append(
        {
            "parameter": name,
            "truth": {"rho_d": RHO_D, "rho_o": RHO_O}[name],
            "poisson": float(idata_pois.posterior[name].mean()),
            "negbin": float(idata_nb.posterior[name].mean()),
            "poisson sd": float(idata_pois.posterior[name].std()),
            "negbin sd": float(idata_nb.posterior[name].std()),
        }
    )
pd.DataFrame(rows).set_index("parameter").round(4)
truth poisson negbin poisson sd negbin sd
parameter
rho_d 0.35 0.3112 0.3117 0.0473 0.0522
rho_o 0.25 0.2259 0.2260 0.0551 0.0583

What does each cost?

Sampling efficiency is effective sample size per second, not draws per second. The two likelihoods have different per-sweep costs — the auxiliary-mixture scheme draws two latent inter-arrival times per observation, the Pólya–Gamma scheme draws one mixing weight — so equal draws are not equal work.

rows = []
for label, idata, secs in (
    ("Poisson", idata_pois, secs_pois),
    ("NegBin", idata_nb, secs_nb),
):
    ess = az.ess(idata, var_names=["rho_d", "rho_o"])
    rhat = az.rhat(idata, var_names=["rho_d", "rho_o"])
    rows.append(
        {
            "model": label,
            "seconds": secs,
            "ess rho_d": float(ess["rho_d"]),
            "ess rho_o": float(ess["rho_o"]),
            "ess/sec rho_d": float(ess["rho_d"]) / secs,
            "max rhat": float(max(rhat["rho_d"], rhat["rho_o"])),
        }
    )
pd.DataFrame(rows).set_index("model").round(3)
seconds ess rho_d ess rho_o ess/sec rho_d max rhat
model
Poisson 29.333 499.179 494.786 17.018 1.005
NegBin 14.123 556.337 393.144 39.392 1.004

Neither likelihood dominates on speed, and which one comes out ahead shifts with \(n\), with the count level, and with how much of the mass sits near zero — so measure it on your own data rather than carrying a rule of thumb. Efficiency is the tiebreaker, not the criterion. Both samplers here recover the same \(\rho\) posterior with \(\hat R\) at 1.00, so nothing about the science turns on the choice; what should decide it is whether the observation model is right, which is the predictive check below.

What LOO can and cannot settle here

Both models now store the true pointwise log-pmf of the observed counts, so az.loo puts them on one scale and the elpd difference between them is meaningful. Until recently they did not: every negative binomial site stored the NB2 log-pmf without its \(-\log\Gamma(y+1)\) normalizing term, which shifted elpd by \(+\sum_i \log(y_i!)\) — a draw-independent constant, invisible to any shape or finiteness check, large enough to drive the total positive. A positive elpd for discrete data was the tell. That constant is now included at every storage site, and pinned against scipy.stats.nbinom.logpmf by test.

What the comparison still cannot settle is spatial structure. The stored likelihood is conditional on the reduced-form \(\eta\), so leaving one flow out does not leave out its neighbours’ influence on that flow’s own linear predictor; elpd is not a clean out-of-sample quantity under spatial dependence. Read it as a verdict on the observation model — Poisson against negative binomial at fixed spatial structure, which is exactly the question here — and not as a way to choose \(\rho\) parameterizations. Weigh it alongside posterior agreement, sampling efficiency, and the predictive check in count space below.

Posterior predictive check in count space

The honest specification test is whether replicated counts reproduce features of the observed data that the likelihood did not fit directly — here the variance-to-mean ratio and the share of zeros.

y_rep = model_pois.posterior_predictive(random_seed=0)

obs = {"var/mean": y.var() / y.mean(), "zeros": (y == 0).mean(), "max": y.max()}
rep = {
    "var/mean": np.mean(y_rep.var(axis=1) / y_rep.mean(axis=1)),
    "zeros": np.mean((y_rep == 0).mean(axis=1)),
    "max": np.mean(y_rep.max(axis=1)),
}
pd.DataFrame({"observed": obs, "replicated (mean)": rep}).round(3)
observed replicated (mean)
var/mean 6.383 6.589
zeros 0.045 0.041
max 43.000 47.518

If the replicated variance-to-mean ratio and zero share bracket the observed values, the Poisson is doing its job and the negative binomial’s dispersion parameter is fitting noise. If replicated counts are systematically tighter than observed — too little spread, too few zeros — that is genuine excess dispersion and the negative binomial earns its extra parameter.

For real trade and migration flows, Santos Silva & Tenreyro (2011) argue that zero inflation is usually the wrong fix for excess zeros: the zeros in gravity data are ordinary Poisson zeros from small \(\mu_{ij}\), not a separate structural process. Running the comparison above on your own data is the way to check that claim rather than assume it.

When you want all three \(\rho\)

SARPoissonFlow exposes the unrestricted three-parameter model, with \(\rho_w\) free rather than pinned to \(-\rho_d\rho_o\).

Warning

The unrestricted parameterization is weakly identified at moderate \(n\) for both likelihoods. \(\rho_d\), \(\rho_o\) and \(\rho_w\) trade off along a nearly flat ridge that one-at-a-time slice updates cannot traverse: at \(n = 36\) (\(N = 1296\)), effective sample size falls to single digits out of 2000 draws with \(\hat R \approx 1.4\), for this sampler and for SARNegBinFlow alike.

This is weak identification, not a sampler defect — the profile likelihood is genuinely flat along the ridge, and the samplers are exploring it correctly. Tuning will not fix it. Prefer SARPoissonFlowSeparable unless \(\rho_w\) is itself the object of interest.

See also