
9.2 Space-Time Weights and Global Analysis¶
Part: 9 — Spatio-Temporal Analysis (Chapter 9.2 of 7)
Dataset: data_1998_2010_long_lbc.csv (year-by-year Moran's I); crime_data_2018.csv (a hand-built Knox test)
Focus: year-by-year Moran's I and Moran_Diff (library-supported); Kronecker space-time weights and the Knox test (hand-built; not in the library)
Library: spatialstats.esda for Sections 3-4; everything in Section 5 is built by hand
1. Overview¶
This chapter runs the two genuinely library-supported space-time analyses this Part has (Sections 3-4), then
builds, entirely by hand, one further classical space-time method the outline calls for but
spatialstats.spacetime does not implement: the Knox test for event interaction.
| Step | What we do | Library status |
|---|---|---|
| Year-by-year Moran's I | 15 separate esda.Moran calls, one fixed weights matrix |
✅ Library-supported |
Moran_Diff |
Is the change in RATE between two years itself spatially clustered? |
✅ Library-supported |
| Knox test | Are crime incidents close in space also close in time, more than chance? | 🔨 Hand-built, not in the library |
| Kronecker space-time weights | What a real implementation would need | 🔲 Explained, not built |
2. Python Setup¶
import sys, warnings
from pathlib import Path
import numpy as np
import pandas as pd
import geopandas as gpd
import matplotlib.pyplot as plt
from scipy.spatial import cKDTree
warnings.filterwarnings("ignore", category=FutureWarning)
warnings.filterwarnings("ignore", category=DeprecationWarning)
def find_repo_root(start: Path = Path.cwd()) -> Path:
for p in [start, *start.parents]:
if (p / "pyproject.toml").exists() and (p / "data").is_dir():
return p
raise FileNotFoundError("Run this notebook from inside the PySpatialStats repository.")
ROOT = find_repo_root()
DATA = ROOT / "data"
try:
import spatialstats
except ImportError:
sys.path.insert(0, str(ROOT))
import spatialstats
from spatialstats.esda import Moran, Moran_Diff
from spatialstats.weights import KNN
plt.rcParams.update({
"figure.dpi": 100, "savefig.dpi": 100, "font.size": 10, "axes.titlesize": 11,
"axes.titleweight": "bold", "axes.spines.top": False, "axes.spines.right": False,
"axes.grid": True, "grid.alpha": 0.25, "legend.frameon": False,
})
COLORS = {"blue": "#2b6cb0", "red": "#c0392b", "green": "#2f855a", "orange": "#dd6b20",
"grey": "#718096", "purple": "#6b46c1", "teal": "#2c7a7b"}
print(f"spatialstats : version {spatialstats.__version__}")
spatialstats : version 0.2.0
3. Year-by-Year Moran's I¶
The panel is balanced (Chapter 9.1) and every year shares the same 3,107 counties in the same order, so a single spatial weights matrix, built once from any one year's coordinates, applies unchanged to every year — counties do not move.
panel = pd.read_csv(DATA / "data_1998_2010_long_lbc.csv")
reference = panel[panel["Year"] == 1998].sort_values("FIPS").reset_index(drop=True)
coords = reference[["X", "Y"]].to_numpy()
w = KNN(coords, k=6)
years = sorted(panel["Year"].unique())
moran_by_year = []
for year in years:
d = panel[panel["Year"] == year].sort_values("FIPS").reset_index(drop=True)
assert (d["FIPS"].to_numpy() == reference["FIPS"].to_numpy()).all(), "county order must match the weights matrix"
m = Moran(d["RATE"].to_numpy(), w, permutations=499, seed=1)
moran_by_year.append({"year": year, "I": m.I, "p_value": m.p_value})
moran_df = pd.DataFrame(moran_by_year)
moran_df
| year | I | p_value | |
|---|---|---|---|
| 0 | 1998 | 0.732525 | 0.002 |
| 1 | 1999 | 0.730552 | 0.002 |
| 2 | 2000 | 0.729447 | 0.002 |
| 3 | 2001 | 0.726398 | 0.002 |
| 4 | 2002 | 0.723535 | 0.002 |
| 5 | 2003 | 0.723010 | 0.002 |
| 6 | 2004 | 0.720286 | 0.002 |
| 7 | 2005 | 0.719953 | 0.002 |
| 8 | 2006 | 0.717638 | 0.002 |
| 9 | 2007 | 0.718024 | 0.002 |
| 10 | 2008 | 0.717213 | 0.002 |
| 11 | 2009 | 0.717788 | 0.002 |
| 12 | 2010 | 0.716857 | 0.002 |
| 13 | 2011 | 0.715757 | 0.002 |
| 14 | 2012 | 0.713953 | 0.002 |
fig, ax = plt.subplots(figsize=(8, 5))
ax.plot(moran_df["year"], moran_df["I"], "o-", color=COLORS["blue"])
ax.set_xlabel("Year"); ax.set_ylabel("Moran's I")
ax.set_title("RATE's spatial autocorrelation, 1998-2012\n(fixed KNN(k=6) weights matrix every year)")
plt.tight_layout()
plt.savefig("moran_by_year.png", dpi=100, bbox_inches="tight")
plt.show()
print(f"1998: I={moran_df.iloc[0]['I']:.4f} 2012: I={moran_df.iloc[-1]['I']:.4f} "
f"change: {moran_df.iloc[-1]['I'] - moran_df.iloc[0]['I']:+.4f}")
1998: I=0.7325 2012: I=0.7140 change: -0.0186
RATE is strongly, significantly spatially autocorrelated in every single year ($p<0.01$ throughout) — but
the strength of that clustering declines gradually and almost monotonically across the 15-year span, from
0.733 in 1998 to 0.714 in 2012. This is exactly a case where treating the cube as "the same spatial pattern,
just repeated 15 times" would miss something real: the underlying geography of clustering is not static, even
though it never disappears.
4. Moran_Diff: Is the Change Itself Clustered?¶
Chapter 9.1 established that Moran_Diff computes the Moran's I of the difference $y_t - y_{t0}$, not a
test of whether $I(t)$ differs from $I(t_0)$. This answers a genuinely different, useful question: do counties
that increased tend to sit near other counties that also increased (organized, spatially clustered change),
or is the year-to-year change itself spatially patternless noise superimposed on a spatially structured level?
d1998 = panel[panel["Year"] == 1998].sort_values("FIPS").reset_index(drop=True)
d2012 = panel[panel["Year"] == 2012].sort_values("FIPS").reset_index(drop=True)
assert (d1998["FIPS"].to_numpy() == d2012["FIPS"].to_numpy()).all()
diff_result = Moran_Diff(d2012["RATE"].to_numpy(), d1998["RATE"].to_numpy(), w, permutations=999, seed=1)
print(f"Moran's I of (RATE_2012 - RATE_1998): {diff_result.I:.4f} p={diff_result.p_value:.4f}")
change = d2012["RATE"].to_numpy() - d1998["RATE"].to_numpy()
print(f"change range: {change.min():.2f} to {change.max():.2f} mean: {change.mean():.2f}")
fig, ax = plt.subplots(figsize=(7, 6))
sc = ax.scatter(d2012["X"], d2012["Y"], c=change, cmap="RdBu_r", s=4,
vmin=-np.abs(change).max(), vmax=np.abs(change).max())
plt.colorbar(sc, ax=ax, label="RATE change, 1998-2012")
ax.set_title(f"Change in RATE, 1998-2012\nMoran's I of the change itself = {diff_result.I:.3f} (p={diff_result.p_value:.3f})")
ax.set_aspect("equal")
plt.tight_layout()
plt.savefig("moran_diff_map.png", dpi=100, bbox_inches="tight")
plt.show()
Moran's I of (RATE_2012 - RATE_1998): 0.6815 p=0.0010 change range: -28.24 to 21.72 mean: -7.34
Yes — the change itself is significantly, positively spatially clustered. Counties that saw RATE rise over
the 15 years are not scattered at random; they cluster near other counties that also rose, and the same is true
of counties that fell. This is a materially different and complementary finding to Section 3's declining-$I(t)$
curve: the level of clustering is gently weakening, but where the change happened is itself geographically
organized — a real, regional pattern in who got better and who got worse, worth investigating substantively
(e.g. against SMOKING, POVERTY, or the pollution covariates in the same file) rather than reading purely as
noise.
5. The Knox Test for Space-Time Interaction (Hand-Built)¶
Not in this library. The Knox test asks a classic space-time epidemiology question: do event pairs that are close in space tend to also be close in time, more often than chance alone would produce? For a chosen spatial threshold $D_s$ and temporal threshold $D_t$, the test statistic is simply the count of pairs satisfying both: $$ X = \sum_{i<j} \mathbb{1}\!\left[d_s(i,j) < D_s\right] \cdot \mathbb{1}\!\left[d_t(i,j) < D_t\right] $$ Its null distribution (no space-time interaction — spatial and temporal proximity are independent) is built by permutation: shuffle the time labels across the fixed locations, recompute $X$, repeat, and compare the observed $X$ to that null distribution.
crime = pd.read_csv(DATA / "crime_data_2018.csv")
crime["date_incid"] = pd.to_datetime(crime["date_incid"])
crime["day_of_year"] = crime["date_incid"].dt.dayofyear
crime_gdf = gpd.GeoDataFrame(crime, geometry=gpd.points_from_xy(crime["long"], crime["lat"]),
crs="EPSG:4326").to_crs("EPSG:32617") # Chapter 9.1's verified fix
coords_crime = np.c_[crime_gdf.geometry.x, crime_gdf.geometry.y]
doy = crime["day_of_year"].to_numpy()
D_S, D_T = 100.0, 1 # metres, days
tree = cKDTree(coords_crime)
spatial_pairs = np.array(list(tree.query_pairs(D_S)))
print(f"{len(spatial_pairs):,} pairs within {D_S:.0f} m of each other (out of "
f"{len(coords_crime)*(len(coords_crime)-1)//2:,} possible pairs)")
def knox_statistic(day_labels, pairs, d_t):
dt = np.abs(day_labels[pairs[:, 0]] - day_labels[pairs[:, 1]])
return int((dt <= d_t).sum())
observed = knox_statistic(doy, spatial_pairs, D_T)
print(f"observed space-time-close pairs (within {D_S:.0f} m AND {D_T} day): {observed:,}")
111,135 pairs within 100 m of each other (out of 98,007,000 possible pairs) observed space-time-close pairs (within 100 m AND 1 day): 1,278
rng = np.random.default_rng(1)
n_perm = 999
null_counts = np.empty(n_perm, dtype=int)
for i in range(n_perm):
null_counts[i] = knox_statistic(rng.permutation(doy), spatial_pairs, D_T)
p_value = (null_counts >= observed).mean()
z_score = (observed - null_counts.mean()) / null_counts.std()
print(f"null distribution: mean={null_counts.mean():.1f}, sd={null_counts.std():.1f}")
print(f"observed={observed} z={z_score:.2f} p={p_value:.4f} ({n_perm} permutations)")
fig, ax = plt.subplots(figsize=(7, 5))
ax.hist(null_counts, bins=30, color=COLORS["grey"], edgecolor="white", label="null (time labels shuffled)")
ax.axvline(observed, color=COLORS["red"], linewidth=2, label=f"observed = {observed}")
ax.set_xlabel("space-time-close pair count")
ax.set_ylabel("permutations")
ax.set_title(f"Knox test: D_s={D_S:.0f}m, D_t={D_T} day\nz={z_score:.1f}, p={p_value:.4f}")
ax.legend()
plt.tight_layout()
plt.savefig("knox_test.png", dpi=100, bbox_inches="tight")
plt.show()
null distribution: mean=932.9, sd=30.6 observed=1278 z=11.27 p=0.0000 (999 permutations)
A strong, significant space-time interaction: the observed count of pairs close in both space and time is far above anything the 999 permutations produced under the no-interaction null ($z\approx12$, $p<0.001$) — incidents within 100m of each other are meaningfully more likely to also occur within a day of each other than chance alone would explain. This is consistent with genuine short-range space-time clustering in crime incidence (e.g., retaliatory or opportunistic follow-on events near a recent incident), and is exactly the kind of pattern a purely spatial analysis (ignoring time) or a purely temporal one (ignoring location) would each miss on their own.
6. What a Real Kronecker Space-Time Weights Matrix Would Add¶
Section 5's Knox test works directly on raw distances — it needed no formal spatial weights object at all. A
fuller space-time framework (what spatialstats.spacetime's stub docstring gestures at) would instead build an
explicit space-time weights matrix, typically as a Kronecker product of a spatial weights matrix $W_s$ and a
temporal weights matrix $W_t$:
$$
W_{st} = W_s \otimes W_t
$$
so that unit $(i, t)$ is a "space-time neighbour" of $(j, t')$ exactly when $i,j$ are spatial neighbours and
$t, t'$ are temporal neighbours (e.g., adjacent years). This would support a genuine space-time Moran's I —
a single statistic over the whole cube at once, rather than Section 3's year-by-year series — and would let
Chapter 9.3's hot-spot categories be derived from one coherent statistical model instead of assembled by hand
from independent per-year results. Building $W_{st}$ itself is mechanically simple (it is literally a Kronecker
product, available directly from scipy.sparse.kron given $W_s$ and $W_t$ as sparse matrices); what this library
does not yet provide is the inferential machinery built on top of it — the correct permutation scheme,
variance formula, and significance test for a statistic computed over $W_{st}$ are not the same as running
Section 3's ordinary Moran fifteen times, and getting that adaptation right is genuine, unimplemented work
this Part does not attempt to fake.
7. Summary and Conclusions¶
This chapter did everything this Part's global space-time analysis can currently do with real library support, then built one further classical method — the Knox test — entirely from smaller existing pieces, being explicit throughout about which was which.
What we did¶
- Ran
esda.Moranyear by year on a fixed weights matrix, finding significant clustering in every year with a gradual, real decline in strength from 1998 to 2012. - Used
Moran_Diffto show the change inRATEover that period is itself significantly, positively spatially clustered — a distinct finding from Section 3's declining-level trend. - Built a Knox test by hand (
cKDTree.query_pairsplus a permutation test) and found significant space-time interaction in the 2018 crime data at a 100m/1-day threshold. - Explained precisely what a real Kronecker space-time weights matrix and its accompanying inference would add beyond what Sections 3-5 already accomplish, and why building it convincingly was out of scope here.
Practical takeaways¶
- A declining Moran's I over time and a significantly clustered change are two different, complementary findings — check both before concluding a spatial pattern is simply "weakening" or "stable."
- The Knox test needs no formal spatial-weights object;
cKDTree.query_pairsplus a permutation test is enough, and scales comfortably to tens of thousands of points. - Distinguish "this library computes it" from "I built this from smaller pieces this library provides" — both
are legitimate, but only the first carries the same tested guarantees as, say, Part 2's
Moran.
Next steps¶
Chapter 9.3 (Local and Emerging Hot Spots) extends Section 3's year-by-year approach to the local statistic (Gi*), then hand-assembles a hot-spot trend categorization (new, persistent, intensifying, and so on) from the resulting 15-year series — directly following on from this chapter's finding that the spatial pattern genuinely shifts over time.
8. Resources¶
Textbooks¶
| Reference | Notes |
|---|---|
| Cressie, N. & Wikle, C. K. (2011). op. cit. (Chapter 9.1) | Space-time weights and the Kronecker construction in Section 6 |
Key papers¶
| Paper | Contribution |
|---|---|
| Knox, E. G. (1964). The detection of space-time interactions. Journal of the Royal Statistical Society, Series C, 13, 25–29. | The original Knox test, hand-built in Section 5 |
In this library¶
| Object | Role |
|---|---|
spatialstats.esda.Moran, Moran_Diff |
Sections 3-4 |
spatialstats.weights.KNN |
Section 3's fixed weights matrix |
| Chapter 9.3 | Extends the year-by-year approach to Gi* and hand-built trend categories |