Choosing GWR Bandwidth with Golden-Section Search
TL;DR — Call
bw = Sel_BW(coords, y, X, kernel="bisquare", fixed=False).search(criterion="AICc", search_method="golden_section"). The golden-section search minimises the corrected AIC over candidate bandwidths and returns one scalar — a neighbour count (adaptive) or a distance (fixed). Cross-check withcriterion="CV", and confirm the minimum with a manual bandwidth sweep. AICc is the default because it resists small-bandwidth overfitting.
Why this matters
The bandwidth is the one parameter that decides whether a geographically weighted regression is a faithful map of local variation or an overfit mess of noise. Too small and every local surface chases sampling noise; too large and GWR collapses into the global model it was meant to improve on. Getting it right is the whole game, and mgwr automates it through Sel_BW. This page is the deep dive on that selection step for the Geographically Weighted Regression in Python guide, within the larger Python workflows for spatial modeling and regression pipeline.
Environment
pip install "mgwr>=2.2" "libpysal>=4.8" "spreg>=1.4" \
"geopandas>=0.13" "numpy>=1.22" "scipy>=1.9"
import numpy as np # >= 1.22
import geopandas as gpd # >= 0.13
from mgwr.sel_bw import Sel_BW # mgwr >= 2.2
from mgwr.gwr import GWR
How golden-section search works
Sel_BW.search() does not test every possible bandwidth. It uses a golden-section search, a one-dimensional optimiser that repeatedly narrows a bracketing interval by evaluating the criterion at two interior points spaced by the golden ratio . At each iteration it discards the sub-interval that cannot contain the minimum, shrinking the search by a constant factor of per step. It converges in criterion evaluations instead of scanning a full grid — critical because each evaluation fits a complete GWR model.
The method assumes the criterion is unimodal over the bandwidth range: a single minimum with the curve rising on both sides. For AICc as a function of bandwidth this generally holds, which is why golden-section is the default search_method. The alternative "interval" method scans a user-defined coarse grid and is a safer choice when you suspect multiple local minima.
Step-by-step
Step 1 — Prepare arrays and configure Sel_BW
gdf = gpd.read_file("data/tracts.gpkg").to_crs("EPSG:32633").reset_index(drop=True)
coords = np.array(list(zip(gdf.geometry.centroid.x, gdf.geometry.centroid.y)))
y = gdf["median_price"].values.reshape(-1, 1)
X = gdf[["income", "rooms", "dist_cbd"]].values
X = (X - X.mean(axis=0)) / X.std(axis=0)
# kernel: bisquare (compact) | gaussian | exponential
# fixed=False -> adaptive (neighbour count) ; fixed=True -> distance in metres
selector = Sel_BW(coords, y, X, kernel="bisquare", fixed=False)
Step 2 — Run the golden-section search on AICc
bw_aicc = selector.search(criterion="AICc", search_method="golden_section")
print(f"AICc-optimal adaptive bandwidth: {bw_aicc:.0f} neighbours")
criterion accepts "AICc", "AIC", "BIC", or "CV". The returned scalar is the neighbour count for an adaptive kernel or a metric distance for a fixed one.
Step 3 — Cross-check against the CV criterion
selector_cv = Sel_BW(coords, y, X, kernel="bisquare", fixed=False)
bw_cv = selector_cv.search(criterion="CV", search_method="golden_section")
print(f"CV-optimal bandwidth: {bw_cv:.0f} AICc-optimal: {bw_aicc:.0f}")
If AICc and CV land close together, the choice is robust. A large gap — CV often selects a smaller bandwidth — is a signal to inspect the criterion curve directly (Step 5).
Step 4 — Compare fixed vs adaptive under the same kernel
# Adaptive: neighbour count expands/contracts with local density
bw_adaptive = Sel_BW(coords, y, X, kernel="bisquare", fixed=False).search(criterion="AICc")
# Fixed: constant radius in metres — only for evenly spaced data
bw_fixed = Sel_BW(coords, y, X, kernel="bisquare", fixed=True).search(criterion="AICc")
res_a = GWR(coords, y, X, bw_adaptive, fixed=False).fit()
res_f = GWR(coords, y, X, bw_fixed, fixed=True ).fit()
print(f"adaptive AICc={res_a.aicc:.1f} | fixed AICc={res_f.aicc:.1f} (lower wins)")
Step 5 — Sensitivity sweep to confirm the minimum
from mgwr.gwr import GWR
# Manual grid around the selected bandwidth to see the AICc curve shape
grid = range(int(bw_aicc) - 40, int(bw_aicc) + 60, 10)
for k in grid:
if k < len(y):
aicc_k = GWR(coords, y, X, k, kernel="bisquare", fixed=False).fit().aicc
marker = " <-- selected" if abs(k - bw_aicc) < 5 else ""
print(f"bw={k:4d} AICc={aicc_k:9.2f}{marker}")
A clear, single-basined dip around the selected bandwidth confirms the golden-section search found a well-identified optimum. A flat valley means the result is insensitive over a range — report that range rather than a single number.
Interpreting the selected bandwidth
The bandwidth is not just a tuning knob; it is a result. Read it relative to the sample size :
- Near the maximum (approaching , or a very large distance): relationships are effectively stationary. GWR is converging on global OLS, and a global spatial regression model is the more parsimonious choice.
- Small (a low neighbour count or short radius): strong local non-stationarity. The covariate effects genuinely vary over short distances.
- Intermediate: the typical, informative case. The value estimates the spatial scale over which the modelled relationships hold roughly constant.
For an adaptive kernel the bandwidth is a count of neighbours; for a fixed kernel it is a distance you can quote in metres or kilometres as the characteristic scale of the process.
Critical best practices
Prefer AICc as the default criterion
AICc penalises the effective degrees of freedom , so it actively resists the over-localised solutions that leave-one-out CV tends to favour. Treat CV as a robustness check, or use it only when out-of-sample prediction accuracy is the explicit objective.
Always bound the search
Pass bw_min and bw_max so the search cannot wander into degenerate territory:
bw = selector.search(criterion="AICc", search_method="golden_section",
bw_min=30, bw_max=len(y) - 1)
An unbounded search can select a neighbour count exceeding or a radius that encloses the whole study area, both of which defeat the purpose of local modelling.
Watch for multimodal criterion curves
Golden-section assumes unimodality. If the sensitivity sweep in Step 5 reveals more than one dip, switch to search_method="interval" for a coarse global scan, then refine near the true minimum. Silent convergence to the wrong basin is the main failure mode of the default search.
Re-select on the actual model data, never reuse a stale bandwidth
The optimal bandwidth is specific to the coordinates, the sample size, and the covariate set. Adding a predictor, changing the CRS, or subsampling all shift it. Never carry a bandwidth over from a different run; re-run Sel_BW whenever the data change.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Search returns bw_max |
Stationary relationships; GWR unwarranted | Accept a global model; report stationarity |
| Search returns the smallest allowed value | Overfitting or noisy y; CV criterion too aggressive |
Switch to AICc; raise bw_min; smooth or clean y |
| AICc and CV disagree widely | Multimodal criterion or weak signal | Run the manual sweep; prefer AICc; verify global fit |
| Search is extremely slow | Large n, each evaluation a full GWR fit |
Bound the search; use bisquare; prototype on a subsample |
| Bandwidth meaningless (huge distance) | Coordinates in degrees, not metres | Reproject to a projected CRS before building coords |
Selected bandwidth exceeds n (adaptive) |
No bw_max bound |
Set bw_max=len(y) - 1 |
Next steps
With a defensible bandwidth in hand, run the full fit and read the surfaces in how to run geographically weighted regression in mgwr. Return to the Geographically Weighted Regression in Python guide for MGWR, where each covariate gets its own bandwidth.
Frequently Asked Questions
Should I select a GWR bandwidth with AICc or cross-validation?
Use AICc as the default. It penalises the effective degrees of freedom directly, so it resists the small-bandwidth overfitting that leave-one-out cross-validation is prone to. Cross-validation (criterion='CV') is a useful robustness check and is preferable when prediction accuracy, rather than model parsimony, is the explicit goal. When AICc and CV disagree strongly, inspect the AICc curve across a manual bandwidth grid before trusting either.
What does the selected bandwidth value actually mean?
For an adaptive kernel it is the number of nearest neighbours in each local regression; for a fixed kernel it is a radius in the coordinate units, normally metres. A bandwidth close to the sample size means relationships are nearly stationary and GWR approaches global OLS; a small bandwidth means strong local variation. The value is itself an interpretable estimate of the spatial scale of the process.
Why does golden-section search sometimes miss the true optimum?
Golden-section search assumes the criterion is unimodal over the bandwidth range. If the AICc curve has multiple local minima, the search can settle in the wrong basin. Supplying explicit bw_min and bw_max bounds and confirming the result with a manual grid sweep guards against this, as does the interval search method for a coarse global scan.
Related
- How to Run Geographically Weighted Regression in mgwr — the full fit that consumes this bandwidth
- Geographically Weighted Regression in Python — kernels, MGWR, and interpretation
- Spatial Regression Models — the global alternative when the bandwidth goes near-maximum