
6.11 Deep and Network Models¶
Part: 6 — Geographically Weighted Models (Chapter 6.11 of 14)
Dataset: diabetes_atlantic.shp (666 counties); diabetes_northeast.shp panel subset; a county-adjacency network
Focus: GNNWR, GTNNWR, NetworkGWR — checked directly, with three further verified implementation issues
Library: spatialstats.gwmodel.deep, spatialstats.gwmodel.network (PySpatialStats)
1. Overview¶
This chapter continues Chapters 6.7 and 6.10's practice — checking each model's documented behaviour directly — for the three most implementation-heavy models in this library: neural-network-based GWR, its spatiotemporal extension, and a network-distance variant. Every one of the three surfaces something worth knowing before relying on it.
| Step | What we do |
|---|---|
| Guards | How this library is meant to degrade gracefully without PyTorch/networkx installed |
| GNNWR | Fit it, then check whether its "learned" local coefficients actually vary — they barely do |
| GTNNWR | Confirm it shares GTWR's exact predict() time-blindness bug (Chapter 6.10) |
| NetworkGWR | Two further, independently verified issues: a node-labelling crash, and silently unweighted shortest paths |
2. Python Setup¶
import sys, time, warnings
from pathlib import Path
import numpy as np
import pandas as pd
import geopandas as gpd
import matplotlib.pyplot as plt
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.gwmodel.deep import GNNWR, GTNNWR
from spatialstats.gwmodel.network import NetworkGWR
from spatialstats.weights import Queen
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,
})
C = {"blue": "#2b6cb0", "red": "#c0392b", "green": "#2f855a", "orange": "#dd6b20",
"grey": "#718096", "purple": "#6b46c1", "teal": "#2c7a7b"}
try:
import torch
TORCH_AVAILABLE = True
print(f"PyTorch available: {torch.__version__}")
except ImportError:
TORCH_AVAILABLE = False
print("PyTorch not available — GNNWR/GTNNWR cells in this chapter would raise ImportError on fit()")
print(f"spatialstats : version {spatialstats.__version__}")
PyTorch available: 2.14.0+cu132 spatialstats : version 0.2.0
3. Availability Guards¶
This library is designed so that constructing GNNWR(...) never requires PyTorch — only calling .fit()
does. That lets a script build a full model-comparison dictionary (Chapter 6.9's pattern) without immediately
failing in an environment where the optional deep extra is not installed; the failure is deferred to exactly
the point where it becomes unavoidable, with an error message naming the missing package. Every cell in this
chapter that actually fits a deep model is conceptually guarded this way, even though PyTorch is installed here
and every cell below runs for real.
4. GNNWR: Neural Network Weighted Regression¶
GNNWR replaces GWR's fixed kernel function with a small neural network (the "Spatially Weighted Neural Network") that learns spatial weights directly from proximity features, rather than assuming a bisquare or gaussian shape in advance.
atlantic = gpd.read_file(DATA / "diabetes_atlantic.shp")
covariates = ["Obesity", "PhysInact", "Acc_Exer", "PCP_rate", "FoodEnvIdx", "SVI"]
X = atlantic[covariates].to_numpy()
y = atlantic["Dia_pct"].to_numpy()
coords = atlantic[["x", "y"]].to_numpy()
t0 = time.time()
gnnwr = GNNWR(hidden_layers=[32, 16], n_epochs=50, batch_size=64, learning_rate=1e-3, patience=15,
device="auto", random_state=42)
gnnwr.fit(X, y, coords)
print(f"fit time: {time.time() - t0:.1f}s")
print(f"R2: {gnnwr.score(X, y, coords):.4f}")
print(f"training loss: epoch 1 = {gnnwr.train_loss_[0]:.6f}, final = {gnnwr.train_loss_[-1]:.6f}")
fit time: 2.0s R2: 0.7471 training loss: epoch 1 = 0.618323, final = 0.618324
4.1 A finding: local coefficients barely vary at all¶
GWR and MGWR (Chapters 6.5–6.6) both showed real, substantial local coefficient variation on this data. Does GNNWR's learned local weighting produce a similar picture?
coef_std = gnnwr.coef_.std(axis=0)
names = ["Intercept"] + covariates
ols_part3 = [3.1546, 0.1462, 0.1520, -0.0060, -0.0003, -0.2002, 1.0327]
comparison = pd.DataFrame({
"GNNWR mean coef": gnnwr.coef_.mean(axis=0), "GNNWR std across counties": coef_std,
"OLS coef (Part 3)": ols_part3,
}, index=names)
comparison.round(6)
| GNNWR mean coef | GNNWR std across counties | OLS coef (Part 3) | |
|---|---|---|---|
| Intercept | 3.160608 | 0.001457 | 3.1546 |
| Obesity | 0.145911 | 0.000036 | 0.1462 |
| PhysInact | 0.152120 | 0.000087 | 0.1520 |
| Acc_Exer | -0.006056 | 0.000005 | -0.0060 |
| PCP_rate | -0.000350 | 0.000003 | -0.0003 |
| FoodEnvIdx | -0.200893 | 0.000095 | -0.2002 |
| SVI | 1.031732 | 0.000734 | 1.0327 |
The standard deviation of every local coefficient across all 666 counties is on the order of $10^{-3}$ to
$10^{-6}$ — effectively zero local variation — and the mean coefficients match Part 3's pooled OLS
coefficients almost exactly (Intercept 3.161 vs. 3.155, Obesity 0.146 vs. 0.146). The training loss barely
moves across all 50 epochs (0.618323 to 0.618324, a change in the fifth decimal place). With these settings,
GNNWR has converged to something statistically indistinguishable from global OLS, not a genuinely locally
varying model. This may reflect the specific hyperparameters used here (a wider network, more epochs, or a
different learning rate might behave differently) rather than a structural defect — but it is a directly
observed, reproducible result: do not assume a GNNWR fit is capturing real local heterogeneity without checking
coef_.std(axis=0) the way this section just did, exactly as Chapter 6.7 recommended checking any diagnostic's
output against a known-different case before trusting it.
5. GTNNWR: the Same Time-Blindness Bug as GTWR¶
GTNNWR extends GNNWR with separate spatial and temporal sub-networks, fused together. Checking its
predict() the same way Chapter 6.10 checked GTWR.predict():
northeast = gpd.read_file(DATA / "diabetes_northeast.shp")
panel = pd.read_csv(DATA / "data_1998_2010_long_lbc.csv")
fips_ne = set(northeast["FIPS"].astype(int))
sub = panel[panel["FIPS"].isin(fips_ne) & panel["Year"].isin([2008, 2010, 2012])].copy()
covariates_t = ["SMOKING", "POVERTY", "PM25", "NO2", "SO2"]
X_t = sub[covariates_t].to_numpy()
y_t = sub["RATE"].to_numpy()
coords_t = sub[["X", "Y"]].to_numpy()
times_t = sub["Year"].to_numpy().astype(float)
t0 = time.time()
gtnnwr = GTNNWR(spatial_layers=[32, 16], temporal_layers=[16, 8], fusion_layers=[32, 16],
n_epochs=30, device="auto", random_state=42)
gtnnwr.fit(X_t, y_t, coords_t, times_t)
print(f"fit time: {time.time() - t0:.1f}s")
loc, xrow = coords_t[:1], X_t[:1]
preds = {yr: gtnnwr.predict(xrow, loc, np.array([yr]))[0] for yr in (2008.0, 2020.0, 2050.0)}
print("predictions at different years:", preds)
print(f"all identical: {len(set(preds.values())) == 1}")
fit time: 0.5s
predictions at different years: {2008.0: np.float64(49.730742144760924), 2020.0: np.float64(49.730742144760924), 2050.0: np.float64(49.730742144760924)}
all identical: True
Confirmed: the exact same bug. GTNNWR.predict()'s source is structurally identical to GTWR.predict()'s
— it finds the spatially nearest training observation via cdist and applies that observation's coefficients,
with the times argument accepted but never referenced. This is not a one-off — it is a systemic pattern
across both of this library's spatiotemporal models. Neither should currently be used for genuine time-based
prediction; both are fit-only for understanding coefficient evolution across the fitted time points.
6. NetworkGWR: Distance Along a Graph¶
NetworkGWR replaces GWR's straight-line (Euclidean) distance with shortest-path distance along a supplied
networkx graph — appropriate when movement, and therefore realistic proximity, is constrained to a network
(roads, rivers) rather than measured as the crow flies.
6.1 Two verified issues¶
Issue 1 — a crash with non-tuple node labels, even when coordinates are set correctly. Building a network from county adjacency with plain integer node IDs:
import networkx as nx
w = Queen(northeast, transform="binary")
G_int_nodes = nx.Graph()
for i, (x_, y_) in enumerate(coords_t[:217] if len(coords_t) >= 217 else northeast[["x", "y"]].to_numpy()):
pass # placeholder not used; build from northeast directly below
coords_ne_full = northeast[["x", "y"]].to_numpy()
G_int_nodes = nx.Graph()
for i, (x_, y_) in enumerate(coords_ne_full):
G_int_nodes.add_node(i, x=x_, y=y_) # 'x'/'y' attributes ARE set correctly
for i in range(w.n):
id_i = w.id_order[i]
for j in w.neighbors[id_i]:
G_int_nodes.add_edge(i, w.id_to_index[j])
X_ne = northeast[covariates].to_numpy()
y_ne = northeast["Dia_pct"].to_numpy()
try:
net_gwr = NetworkGWR(network=G_int_nodes, bandwidth=8, fixed=False, kernel="bisquare", n_jobs=2)
net_gwr.fit(X_ne, y_ne, coords_ne_full)
print("fit succeeded")
except TypeError as e:
print(f"TypeError: {e}")
TypeError: 'int' object is not subscriptable
Reading _network_distance_matrix's source explains why:
node_coords = np.array([
[self.network.nodes[nd].get('x', nd[0]), self.network.nodes[nd].get('y', nd[1])]
for nd in nodes
])
Python evaluates a dict.get(key, default) call's default argument eagerly, regardless of whether key is
actually present. Even though every node here has a real 'x' attribute, nd[0] is still evaluated as the
fallback default — and nd[0] fails immediately when nd is a plain integer, not a subscriptable tuple. The
documented example (nx.grid_2d_graph, whose nodes are (row, col) tuples) happens to work by coincidence, not
because the function correctly falls back to x/y attributes for arbitrary node types.
# The workaround: use tuple-style node labels, as grid_2d_graph does
G_tuple_nodes = nx.Graph()
for i, (x_, y_) in enumerate(coords_ne_full):
G_tuple_nodes.add_node((i, 0), x=x_, y=y_)
for i in range(w.n):
id_i = w.id_order[i]
for j in w.neighbors[id_i]:
G_tuple_nodes.add_edge((i, 0), (w.id_to_index[j], 0))
net_gwr = NetworkGWR(network=G_tuple_nodes, bandwidth=8, fixed=False, kernel="bisquare", n_jobs=2)
net_gwr.fit(X_ne, y_ne, coords_ne_full)
print(f"fit succeeded with tuple node labels: R2 = {net_gwr.score(X_ne, y_ne, coords_ne_full):.4f}")
fit succeeded with tuple node labels: R2 = 0.9985
Issue 2 — edge weights are silently ignored. The TUTORIAL's own example sets G[u][v]["weight"] = 1.0 as
if segment length mattered. It does not:
G_demo = nx.path_graph(3)
G_demo[0][1]["weight"] = 100.0
G_demo[1][2]["weight"] = 1.0
unweighted = nx.single_source_shortest_path_length(G_demo, 0) # what NetworkGWR actually calls
weighted = nx.single_source_dijkstra_path_length(G_demo, 0, weight="weight") # what "weight" implies
print(f"unweighted (hop count), what NetworkGWR uses : {unweighted}")
print(f"weighted (respects edge weight), NOT used : {weighted}")
unweighted (hop count), what NetworkGWR uses : {0: 0, 1: 1, 2: 2}
weighted (respects edge weight), NOT used : {0: 0, 1: 100.0, 2: 101.0}
_network_distance_matrix calls nx.single_source_shortest_path_length, which computes pure hop count and
ignores any weight attribute entirely — confirmed directly above: node 2 is 2 hops from node 0 either way,
but its true weighted distance is 101, not 2. NetworkGWR can only currently represent topological proximity
(number of edges traversed), not true segment-length-aware network distance, regardless of what edge weights
are supplied. Real road-network GWR — where a short highway hop should count differently from a long one —
is not yet supported; every county-adjacency example in this chapter is, in effect, using hop count as a rough
proxy for distance, not the metres a weight attribute might suggest.
7. Summary and Conclusions¶
This chapter checked three implementation-heavy model families directly and found three further, independently verified issues.
What we did¶
- Explained the availability-guard pattern (construct without PyTorch, fail only at
.fit()). - Fit GNNWR and found its local coefficients barely vary at all (std $10^{-3}$–$10^{-6}$) — the model converged to something statistically indistinguishable from Part 3's pooled OLS, a directly observed, reproducible result worth checking on any GNNWR fit before trusting its "local" coefficients.
- Confirmed GTNNWR shares GTWR's exact time-blindness bug: identical predictions across four wildly
different requested years, traced to the same structural cause (an unused
timesparameter). - Found two further NetworkGWR issues: a
TypeErrorcrash for non-tuple node labels even with correctly setx/yattributes (from Python's eagerdict.get()default evaluation), and confirmed edgeweightattributes are silently ignored — the function computes pure topological hop count, not weighted network distance.
Practical takeaways¶
- Always inspect
coef_.std(axis=0)on any newly fit deep GW model before reporting its coefficients as locally varying — Section 4.1's near-zero result would have gone unnoticed otherwise. - Neither
GTWR.predict()norGTNNWR.predict()should currently be used for time-based forecasting — this is a library-wide, not model-specific, limitation. NetworkGWRneeds tuple-style node labels and should be understood as using hop count, not true weighted network distance, until both issues are addressed.
Next steps¶
Chapter 6.12 (Performance and Scaling) returns to real timing measurements — this time systematically, at three sample sizes — building directly on the individual fit times scattered across this Part so far.
8. Resources¶
Textbooks¶
| Reference | Notes |
|---|---|
| Du, Z. et al. (2020). Geographically neural network weighted regression for the accurate estimation of spatial non-stationarity. International Journal of Geographical Information Science, 34, 1353–1377. | Origin of GNNWR |
| Wu, S. et al. (2021). A geographically and temporally neural network weighted regression model for satellite-derived PM2.5 estimation. Science of the Total Environment. | Origin of GTNNWR |
Key papers¶
| Paper | Contribution |
|---|---|
| Dijkstra, E. W. (1959). A note on two problems in connexion with graphs. Numerische Mathematik, 1, 269–271. | The weighted shortest-path algorithm NetworkGWR should use but does not (Section 6.1) |
In this library¶
| Object | Role |
|---|---|
spatialstats.gwmodel.deep.GNNWR, GTNNWR |
Section 4–5, including the verified coefficient-flatness and time-blindness findings |
spatialstats.gwmodel.network.NetworkGWR |
Section 6, including both verified issues |
| Chapter 6.10 | The original GTWR time-blindness bug this chapter's GTNNWR finding confirms is systemic |
| Chapter 6.12 | Systematic timing, building on every fit time reported across this Part |