The Disability Belt Over Time — a Spatio-Temporal Analysis¶

Is the belt expanding, holding, or receding? (SSDI, 2004–2024)¶

The cross-sectional notebooks mapped the belt in a single year. Here we add time: a real five-snapshot panel of county SSDI receipt — 2004, 2009, 2014, 2019, 2024 (every five years, phased to catch the 2009 recession and the 2014 national DI peak) — assembled from SSA's county tables across three different file formats. The question the whole spatial arc has been pointing at: has the belt grown?

The tool is the spatially-varying linear-trend model (the space-time capstone applied for real; CARBayesST::ST.CARlinear in R): $$y_{it}\sim\text{Poisson}\big(E_{it}\,e^{\alpha+\phi_i+\gamma_t+b_i\,c_t}\big),$$ a persistent spatial level $\phi_i$ (the time-averaged belt), a national trajectory $\gamma_t$, and — the centrepiece — a county-specific trend deviation $b_i$: $b_i>0$ means county $i$'s relative receipt grew faster than the nation, $b_i<0$ that it receded. Mapping $b_i$ answers the question directly.

Crucially, $E_{it}$ is the year-specific age-standardized expected count — each snapshot uses that year's national disabled-worker-by-age rates (SSA Table 19) applied to that year's county age structure (three Census vintages). Without this the national ageing wave (baby-boomers moving through the high-disability 55–64 band) would masquerade as belt dynamics.

In [1]:
import numpy as np, pandas as pd, matplotlib.pyplot as plt, geopandas as gpd
import belt as B
rng=np.random.default_rng(0)
BLUE="#2b6cb0"; RED="#c53030"; GREEN="#2f855a"; ORANGE="#dd6b20"
years=[2004,2009,2014,2019,2024]; c=(np.array(years)-2014)/10.0
P=pd.read_csv("belt_panel.csv", dtype={"fips":str})
_e=pd.read_csv("belt_panel_adj_edges.csv", dtype=str)              # county-pair edge list
W=B.adjacency_from_edges(_e["fips_i"], _e["fips_j"], sorted(P["fips"].unique()))
fips=sorted(P["fips"].unique()); n=len(fips); T=len(years); idx={f:i for i,f in enumerate(fips)}
gdf=gpd.read_file("belt_counties.geojson").set_crs(4326).to_crs(5070)
gdf=gdf[gdf["id"].isin(set(fips))].merge(pd.DataFrame({"fips":fips,"o":range(n)}),left_on="id",right_on="fips").sort_values("o").reset_index(drop=True)
y=np.zeros((n,T)); E=np.zeros((n,T)); SPR=np.zeros((n,T))
for _,r in P.iterrows():
    i=idx[r["fips"]]; t=years.index(int(r["year"])); y[i,t]=r["DI"]; E[i,t]=r["E_age"]; SPR[i,t]=r["spr"]
def choro(ax,v,title,cmap="OrRd",vmin=None,vmax=None):
    g=gdf.copy(); g["v"]=v; g.plot(column="v",ax=ax,cmap=cmap,edgecolor="none",legend=True,legend_kwds={"shrink":0.55},vmin=vmin,vmax=vmax)
    ax.set_title(title,fontsize=10); ax.axis("off")
print(f"panel: {n} counties x {T} years (2004-2024); {int(y.sum()):,} county-year disabled-worker records")
nat=P.groupby("year").apply(lambda d:1000*d["DI"].sum()/d["wa_pop"].sum())
print("national crude DI rate /1,000 working-age:", {int(k):round(v,1) for k,v in nat.items()})
panel: 3035 counties x 5 years (2004-2024); 36,245,435 county-year disabled-worker records
national crude DI rate /1,000 working-age: {2004: 33.2, 2009: 39.7, 2014: 44.6, 2019: 41.2, 2024: 35.1}

1. The panel and the national arc¶

The national SSDI rolls rose to a 2014 peak then fell — a demographic-plus-policy cycle (boomers ageing in, then converting to retirement; the post-recession recovery; tighter administration). The left panel shows it; the right shows the age-standardized participation ratio (SPR) mapped at each snapshot — the belt as it stood every five years. The dark coalfield core is unmistakable and stays put.

In [2]:
di_tot=[y[:,t].sum()/1e6 for t in range(T)]; rate=[nat[yr] for yr in years]
fig,ax=plt.subplots(figsize=(8.5,4.4))
l1=ax.plot(years,di_tot,"o-",color=BLUE,lw=2,label="disabled workers (millions, left axis)")
ax.set_xlabel("year"); ax.set_ylabel("disabled workers (millions)",color=BLUE); ax.tick_params(axis="y",labelcolor=BLUE)
ax.set_xticks(years)
ax2=ax.twinx(); l2=ax2.plot(years,rate,"s--",color=RED,lw=2,label="crude rate per 1,000 working-age (right axis)")
ax2.set_ylabel("rate per 1,000 working-age",color=RED); ax2.tick_params(axis="y",labelcolor=RED)
ax.axvline(2014,color="grey",ls=":",lw=1.2); ax.annotate("2014 peak",(2014,max(di_tot)),xytext=(2015.5,max(di_tot)),fontsize=8,color="grey",va="top")
ax.set_title("National SSDI: rise to the 2014 peak, then decline")
lns=l1+l2; ax.legend(lns,[l.get_label() for l in lns],loc="lower center",frameon=False,fontsize=8.5)
plt.tight_layout(); plt.show()
fig,ax=plt.subplots(1,5,figsize=(19,3.4))
for t,yr in enumerate(years): choro(ax[t], SPR[:,t], f"{yr}", cmap="OrRd", vmin=0, vmax=3)
fig.suptitle("Age-standardized participation ratio (SPR) — the belt at each snapshot, 2004–2024", y=1.04, fontsize=11)
plt.tight_layout(); plt.show()
print("The Central-Appalachian coalfield belt is present and dark in every panel: persistent, not fleeting.")
No description has been provided for this image
No description has been provided for this image
The Central-Appalachian coalfield belt is present and dark in every panel: persistent, not fleeting.

2. Fit the spatially-varying trend model¶

We fit from scratch (Metropolis-within-Gibbs, scaled to 3,000 counties): persistent spatial level $\phi_i$ (ICAR), national trajectory $\gamma_t$, and county trend deviation $b_i$ (ICAR). The relative sizes of the level SD and trend SD already tell the headline story — how much of the belt is fixed geography versus movement.

In [3]:
res=B.st_linear_gibbs(y,E,W,c,rng,draws=1500,burn=1200)
phi=res["phi"].mean(0); bb=res["b"].mean(0); gam=res["gamma"].mean(0)
print(f"level SD {res['level_sd'].mean():.2f}  >>  trend SD {res['trend_sd'].mean():.3f}")
print(f"  -> the persistent belt is ~{res['level_sd'].mean()/res['trend_sd'].mean():.0f}x larger than the year-to-year dynamics:")
print(f"     the geography of high receipt is ENTRENCHED, not rapidly shifting.")
print("national trajectory gamma_t (log-scale deviation):", {yr:round(g,3) for yr,g in zip(years,gam)})
level SD 0.53  >>  trend SD 0.126
  -> the persistent belt is ~4x larger than the year-to-year dynamics:
     the geography of high receipt is ENTRENCHED, not rapidly shifting.
national trajectory gamma_t (log-scale deviation): {2004: np.float64(-0.046), 2009: np.float64(-0.025), 2014: np.float64(0.0), 2019: np.float64(0.026), 2024: np.float64(0.045)}

3. The persistent belt, and the national trajectory¶

$\phi_i$ is the time-averaged belt — where receipt is high across the whole quarter-century — and $\gamma_t$ the national trajectory, tracing the rise-and-fall the raw rolls showed. The persistent-belt map is essentially the cross-sectional belt, now shown to be stable over 20 years.

In [4]:
fig,ax=plt.subplots(1,2,figsize=(15,4.6))
choro(ax[0], np.exp(phi), "Persistent belt  exp(phi)  (time-averaged relative risk)", cmap="OrRd", vmin=0.4, vmax=np.percentile(np.exp(phi),98))
ax[1].plot(years, np.exp(gam), "o-", color=GREEN, lw=2); ax[1].axhline(1,color="grey",ls=":")
ax[1].axvline(2014,color=RED,ls="--",lw=1,label="2014 peak"); ax[1].set_ylabel("national relative level  exp(gamma_t)")
ax[1].set_title("National trajectory"); ax[1].legend(frameon=False,fontsize=8)
plt.tight_layout(); plt.show()
db=pd.read_csv("disability_belt.csv",dtype={"fips":str})
lev=pd.DataFrame({"fips":fips,"phi":phi}).merge(db[["fips","county","STNAME"]],on="fips")
print("persistent belt core (highest phi):")
print(lev.nlargest(6,"phi")[["county","STNAME","phi"]].round(2).to_string(index=False))
No description has been provided for this image
persistent belt core (highest phi):
   county        STNAME  phi
Dickenson      Virginia 1.30
 Buchanan      Virginia 1.30
   Norton      Virginia 1.20
    Floyd      Kentucky 1.12
     Pike      Kentucky 1.11
    Mingo West Virginia 1.09

4. Where has the belt grown, held, or receded? — the trend map¶

The centrepiece. $b_i$ is each county's trend relative to the national trajectory: red where relative receipt grew, blue where it receded. Beside it, the posterior probability $P(b_i>0)$ — where we are confident about the direction.

In [5]:
bpos=(res["b"]>0).mean(0)
lim=np.percentile(np.abs(bb),98)
fig,ax=plt.subplots(1,2,figsize=(15,4.6))
choro(ax[0], bb, "Trend deviation  b_i   (red = grew vs nation, blue = receded)", cmap="RdBu_r", vmin=-lim, vmax=lim)
choro(ax[1], bpos, "P(b_i > 0)  — confidence the county grew", cmap="RdBu_r", vmin=0, vmax=1)
plt.tight_layout(); plt.show()
tr=pd.DataFrame({"fips":fips,"phi":phi,"b":bb,"P":bpos}).merge(db[["fips","county","STNAME"]],on="fips")
core=tr[tr.phi>np.percentile(phi,90)]
print(f"BELT CORE (top-decile phi): mean trend b = {core['b'].mean():+.3f}  -> the coalfield core is stable-to-slightly-receding,")
print("  entrenched but not expanding in relative terms.")
print("\nfastest RECEDING (booming economies pulling away from disability):")
print(tr.nsmallest(6,"b")[["county","STNAME","phi","b"]].round(2).to_string(index=False))
print("\nfastest GROWING (relative receipt rising):")
print(tr.nlargest(6,"b")[["county","STNAME","phi","b"]].round(2).to_string(index=False))
No description has been provided for this image
BELT CORE (top-decile phi): mean trend b = -0.020  -> the coalfield core is stable-to-slightly-receding,
  entrenched but not expanding in relative terms.

fastest RECEDING (booming economies pulling away from disability):
       county        STNAME   phi     b
San Francisco    California -0.56 -0.31
      Andrews         Texas -0.29 -0.28
      Trinity    California  0.13 -0.28
      Winkler         Texas -0.23 -0.28
    San Mateo    California -0.99 -0.27
    Nantucket Massachusetts -1.38 -0.26

fastest GROWING (relative receipt rising):
       county    STNAME   phi    b
        Geary    Kansas  0.18 0.29
      Liberty   Georgia  0.01 0.29
Chattahoochee   Georgia -0.02 0.25
      Coryell     Texas -0.02 0.24
       Vernon Louisiana  0.22 0.22
     Trumbull      Ohio  0.09 0.21

5. SSI over time — a different program, a different signature¶

DI is social insurance; SSI is means-tested welfare. Their time signatures differ. Both national rolls peak around 2014, but DI booms and busts while SSI is far flatter and on a gentle secular decline (its 2024 rate sits below 2004). We fit the same spatially-varying-trend model to SSI (18–64 blind/disabled), on the counties SSA discloses in every year. (SSI is population-standardized, not age-standardized — SSA does not publish SSI-by-age and SSI's age gradient is much flatter than DI's, so age composition matters far less here.)

In [6]:
# national DI vs SSI rate trajectory (SSI-disclosed-all-years counties)
ok=P.dropna(subset=["SSI_1864"]).groupby("fips")["year"].nunique(); sfips=[f for f in fips if ok.get(f,0)==5]
sidx=[idx[f] for f in sfips]; Ws=W[np.ix_(sidx,sidx)]
al=Ws.sum(1)>0; Ws=Ws[np.ix_(al,al)]; sfips=[f for f,a in zip(sfips,al) if a]; ns=len(sfips); si={f:i for i,f in enumerate(sfips)}
Ps=P[P.fips.isin(set(sfips))]
natssi={yr:1000*Ps[Ps.year==yr]["SSI_1864"].sum()/Ps[Ps.year==yr]["wa_pop"].sum() for yr in years}
natdi ={yr:1000*Ps[Ps.year==yr]["DI"].sum()/Ps[Ps.year==yr]["wa_pop"].sum() for yr in years}
fig,ax=plt.subplots(figsize=(8,4.2))
ax.plot(years,[natdi[y] for y in years],"o-",color=BLUE,lw=2,label="DI (insurance)")
ax.plot(years,[natssi[y] for y in years],"s-",color=RED,lw=2,label="SSI (means-tested)")
ax.axvline(2014,color="grey",ls=":"); ax.set_xticks(years); ax.set_xlabel("year"); ax.set_ylabel("rate per 1,000 working-age")
ax.set_title("Two programs, two time signatures: DI booms & busts, SSI flatter & declining"); ax.legend(frameon=False)
plt.tight_layout(); plt.show()
ys=np.zeros((ns,T)); Es=np.zeros((ns,T))
for _,r in Ps.iterrows():
    i=si[r["fips"]]; t=years.index(int(r["year"])); ys[i,t]=r["SSI_1864"]; Es[i,t]=r["wa_pop"]*natssi[int(r["year"])]/1000
res_s=B.st_linear_gibbs(ys,Es,Ws,c,rng,draws=1500,burn=1200)
phis=res_s["phi"].mean(0); bs=res_s["b"].mean(0)
print(f"SSI: level SD {res_s['level_sd'].mean():.2f} >> trend SD {res_s['trend_sd'].mean():.3f}  (also entrenched)")
No description has been provided for this image
SSI: level SD 0.87 >> trend SD 0.194  (also entrenched)
In [7]:
# SSI belt + trend maps, and agreement with DI on geography and dynamics
gs=gdf[gdf.fips.isin(set(sfips))].merge(pd.DataFrame({"fips":sfips,"os":range(ns)}),on="fips").sort_values("os").reset_index(drop=True)
def choro_s(ax,v,title,cmap,vmin=None,vmax=None):
    g=gs.copy(); g["v"]=v; g.plot(column="v",ax=ax,cmap=cmap,edgecolor="none",legend=True,legend_kwds={"shrink":0.55},vmin=vmin,vmax=vmax); ax.set_title(title,fontsize=10); ax.axis("off")
lim=np.percentile(np.abs(bs),98)
fig,ax=plt.subplots(1,2,figsize=(15,4.6))
choro_s(ax[0], np.exp(phis), "Persistent SSI belt  exp(phi)", "BuPu", 0.4, np.percentile(np.exp(phis),98))
choro_s(ax[1], bs, "SSI trend deviation  (red grew / blue receded)", "RdBu_r", -lim, lim)
plt.tight_layout(); plt.show()
phi_di=np.array([phi[idx[f]] for f in sfips]); b_di=np.array([bb[idx[f]] for f in sfips])
print(f"DI vs SSI share the same PERSISTENT belt: corr(phi_DI, phi_SSI) = {np.corrcoef(phi_di,phis)[0,1]:.2f}")
print(f"DI vs SSI dynamics agree less:            corr(b_DI, b_SSI)     = {np.corrcoef(b_di,bs)[0,1]:.2f}")
sd=pd.DataFrame({'fips':sfips,'phis':phis}).merge(db[['fips','county','STNAME']],on='fips')
print("\npersistent SSI belt core (highest phi):")
print(sd.nlargest(6,'phis')[['county','STNAME','phis']].round(2).to_string(index=False))
No description has been provided for this image
DI vs SSI share the same PERSISTENT belt: corr(phi_DI, phi_SSI) = 0.88
DI vs SSI dynamics agree less:            corr(b_DI, b_SSI)     = 0.57

persistent SSI belt core (highest phi):
   county        STNAME  phis
    Wolfe      Kentucky  2.04
Breathitt      Kentucky  2.00
 McDowell West Virginia  1.96
     Clay      Kentucky  1.92
   Owsley      Kentucky  1.88
   Wilcox       Alabama  1.80

6. Does the belt spread? — testing the spillover reading of $\rho$¶

The cross-sectional notebook found a large spatial multiplier, $\rho\approx0.75$, and was careful to read it as association rather than diffusion: on one snapshot, an omitted regional factor and genuine county-to-county spread leave the identical signature. A panel can tell them apart. If receipt really does propagate across county lines, the belt should be expanding at its edges — the counties ringing the core, exposed to it but not yet part of it, ought to show the largest positive trend deviations. If instead the belt reflects persistent local conditions, the ring should look like everywhere else.

In [8]:
core_m = phi > np.percentile(phi, 90)                       # the persistent belt core
ring_m = (W @ core_m.astype(float) > 0) & ~core_m           # touches the core, not in it
rest_m = ~core_m & ~ring_m
Bd = res['b']                                               # posterior draws of the trend deviations
print(f"{'':28s}{'counties':>9s}{'mean trend b':>14s}{'P(mean b>0)':>13s}")
for nm, m in ((' belt core', core_m), (' ring adjacent to the core', ring_m), (' rest of the country', rest_m)):
    dd = Bd[:, m].mean(1)
    print(f'{nm:28s}{int(m.sum()):9d}{dd.mean():+14.3f}{(dd>0).mean():13.2f}')
contrast = Bd[:, ring_m].mean(1) - Bd[:, rest_m].mean(1)
print(f"\nring minus rest: {contrast.mean():+.3f}   P(ring grew faster than the rest) = {(contrast>0).mean():.2f}")
print()
rr = Bd[:, ring_m].mean(1).mean(); rs = Bd[:, rest_m].mean(1).mean()
print('If receipt diffused across county lines, the ring would be the fastest-growing group in the country.')
print(f'It is the opposite. The ring sits at {rr:+.3f} against {rs:+.3f} for the rest of the country, and the')
print(f'posterior probability that the ring grew FASTER is {(contrast>0).mean():.2f}. The belt\'s edge is receding')
print('relative to the nation, not advancing into it, and the core is doing the same. Two decades is ample')
print('time for a contagious process to show itself at the boundary; what shows up there is retreat.')
print()
print('That is evidence the cross-section could not supply. A large rho fitted to one snapshot is consistent')
print('with diffusion OR with stable regional conditions that the model does not observe; the panel favours')
print('the second. So read the spatial multiplier as a statement about how the association is DISTRIBUTED')
print('across space -- poverty in one county tracks receipt in its neighbours -- rather than as a mechanism')
print('by which receipt travels. The policy reading changes accordingly: the belt is a place where')
print('disadvantage persists, not a front that is advancing.')
                             counties  mean trend b  P(mean b>0)
 belt core                        304        -0.020         0.00
 ring adjacent to the core        394        -0.009         0.00
 rest of the country             2337        +0.004         1.00

ring minus rest: -0.013   P(ring grew faster than the rest) = 0.00

If receipt diffused across county lines, the ring would be the fastest-growing group in the country.
It is the opposite. The ring sits at -0.009 against +0.004 for the rest of the country, and the
posterior probability that the ring grew FASTER is 0.00. The belt's edge is receding
relative to the nation, not advancing into it, and the core is doing the same. Two decades is ample
time for a contagious process to show itself at the boundary; what shows up there is retreat.

That is evidence the cross-section could not supply. A large rho fitted to one snapshot is consistent
with diffusion OR with stable regional conditions that the model does not observe; the panel favours
the second. So read the spatial multiplier as a statement about how the association is DISTRIBUTED
across space -- poverty in one county tracks receipt in its neighbours -- rather than as a mechanism
by which receipt travels. The policy reading changes accordingly: the belt is a place where
disadvantage persists, not a front that is advancing.

7. Summary¶

Adding time to the belt gives a clear, slightly surprising answer: the belt is entrenched, not expanding. The persistent spatial level dominates the county-specific trend by roughly four to one ($\phi$ SD ≈ 0.53 vs $b$ SD ≈ 0.13), the coalfield core (Buchanan, Dickenson, Norton VA; Floyd, Pike KY; Mingo WV) sits at the top of the map in every snapshot from 2004 to 2024, and its relative trend is flat-to-slightly-negative — high receipt locked in, not spreading. Nationally the rolls rose to the 2014 peak and fell back ($\gamma_t$), but that tide lifted and dropped the whole country; it did not redraw the belt. The visible dynamics are idiosyncratic and local: relative receipt receded fastest where economies boomed (the Bay Area; Permian-Basin oil counties), and rose in scattered deindustrializing and base-adjacent counties — movement around the belt, not expansion of it.

SSI tells a complementary story (§5): as means-tested welfare it has a different time signature — flatter than DI, and on a gentle secular decline rather than DI's boom-bust — yet it shares the same persistent geography (the SSI and DI persistent-belt fields are strongly correlated), and it too is entrenched rather than expanding. The two programs sit on the same map; they move through time differently.

Methodologically this is the space-time capstone made real: the spatially-varying-trend CAR (ST.CARlinear) on a genuine five-snapshot SSA panel, with year-specific age-standardization (three Census vintages × the yearly SSA age distribution) so the boomer ageing wave — which by itself would have manufactured a spurious "growing belt" — is removed before the trend is read. The R notebook fits the same model with CARBayesST. A natural extension is a full space-time interaction (ST.CARanova) allowing non-linear county trajectories. This closes the disability-belt application across space (areal CAR, econometrics, BYM) and time, for both programs.