Banner

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)


Table of Contents¶

  1. Overview

  2. Python Setup

  3. Availability Guards

  4. GNNWR: Neural Network Weighted Regression

    • 4.1 A finding: local coefficients barely vary at all
  5. GTNNWR: the Same Time-Blindness Bug as GTWR

  6. NetworkGWR: Distance Along a Graph

    • 6.1 Two verified issues
  7. Summary and Conclusions

  8. Resources


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¶

In [1]:
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.

In [2]:
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?

In [3]:
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)
Out[3]:
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():

In [4]:
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:

In [5]:
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.

In [6]:
# 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:

In [7]:
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¶

  1. Explained the availability-guard pattern (construct without PyTorch, fail only at .fit()).
  2. 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.
  3. 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 times parameter).
  4. Found two further NetworkGWR issues: a TypeError crash for non-tuple node labels even with correctly set x/y attributes (from Python's eager dict.get() default evaluation), and confirmed edge weight attributes 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() nor GTNNWR.predict() should currently be used for time-based forecasting — this is a library-wide, not model-specific, limitation.
  • NetworkGWR needs 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