---
title: "Geographically weighted regression in Python: GWR and MGWR with mgwr"
description: "GWR and MGWR in Python with mgwr: AICc bandwidths, local coefficient maps with corrected t-values, local R² and collinearity, on real house price data."
url: https://www.technicalgis.com/tutorials/geographically-weighted-regression-python/
author: TechnicalGIS Team
publisher: TechnicalGIS (https://www.technicalgis.com/about/)
published: 2026-10-27
updated: 2026-10-27
language: en-GB
---

# Geographically weighted regression in Python: GWR and MGWR with mgwr

> GWR and MGWR in Python with mgwr: AICc bandwidths, local coefficient maps with corrected t-values, local R² and collinearity, on real house price data.

- Level: Advanced
- Tested with: Python 3.12
- Source code: [Download scripts](https://www.technicalgis.com/code/geographically-weighted-regression-python/)

Series · Part 2 of 2

## Spatial regression in Python

1.  [Spatial regression in Python: OLS diagnostics, spatial lag and spatial error models with spreg](https://www.technicalgis.com/tutorials/spatial-regression-python/)
2.  Geographically weighted regression in Python: GWR and MGWR with mgwr

## Key takeaways

1.  GWR fits a separate weighted regression at every location, so each coefficient becomes a map. In Python, \`Sel\_BW(coords, y, X).search()\` chooses the bandwidth by AICc and \`GWR(coords, y, X, bw).fit()\` fits the model.
2.  For median house prices in West Yorkshire's 301 MSOAs, the AICc-optimal GWR uses the 62 nearest neighbours. It raises R² from 0.74 (OLS) to 0.91 and cuts AICc from 462.0 to 282.1, better than the spatial error model's 301.7.
3.  Local t-tests need a multiple-testing correction. With 56.5 effective parameters, mgwr's corrected critical |t| is 2.87, not 1.97. Applying it cuts the MSOAs where 'detached houses' is significant from 42 to 1.
4.  MGWR gives each variable its own bandwidth, and here that changes the story. Only the intercept (54 neighbours) and population density (43) vary locally; qualifications, detached houses and car ownership get bandwidths of 298–300 of 300, so their effects are effectively global. MGWR has the best AICc of all (250.0).
5.  GWR is exploratory. Local coefficients are correlated with each other and with the bandwidth, local collinearity can inflate them (the largest local VIF here is 13.0), and the MGWR bandwidth search took 111 s against 5 s for GWR on only 301 areas.

## Before you start

-   Comfortable with ordinary least squares regression and with GeoPandas
-   Part 1 of this series is helpful but not required: the shared wy\_data.py script rebuilds its dataset with one command
-   Python 3.10+ with geopandas, pyogrio, libpysal, esda, spreg, mgwr, numpy, pandas, matplotlib, requests and openpyxl
-   An internet connection on the first run (about 60 MB of ONS, Nomis and ONS Open Geography downloads) and about two minutes of computing time for MGWR

**Short answer:** to run geographically weighted regression in Python, install **mgwr** (part of PySAL). Choose the bandwidth with `Sel_BW(coords, y, X).search(criterion="AICc")`, fit with `GWR(coords, y, X, bw).fit()`, and map the columns of `results.params`. For **MGWR**, use `Sel_BW(coords, y, X, multi=True)` and `MGWR(coords, y, X, selector).fit()`, which give each variable its own bandwidth. Before mapping significance, call `results.filter_tvals()` with **no arguments**: that applies mgwr’s corrected critical t-value for multiple testing. For house prices in West Yorkshire, MGWR finds that only the local price level and the effect of population density vary across the county; the other three effects are effectively global.

[Part 1](https://www.technicalgis.com/tutorials/spatial-regression-python/) of this series fitted global spatial regression models to median house prices in West Yorkshire’s 301 MSOAs. The OLS residuals were strongly clustered (Moran’s I = 0.457), and a spatial error model fitted much better. But all of those models assume that the effect of, say, the share of graduates on prices is **the same everywhere**: in Ilkley, in inner Bradford and in Wakefield.

Geographically weighted regression drops that assumption. It fits a separate regression around every MSOA, weighting nearby MSOAs more heavily, so each coefficient becomes a surface you can map. This makes it a powerful exploratory tool, and an easy one to over-interpret.

In this tutorial you will:

1.  prepare the data and coordinates for **mgwr**, and refit the Part 1 global models on the same scale;
2.  choose a GWR **bandwidth by AICc** and see the AICc curve;
3.  fit **GWR** and map the local coefficients, greying out MSOAs that are **not significant after correcting for multiple testing**;
4.  fit **MGWR** with one bandwidth per variable and read its bandwidth intervals;
5.  map the **local R²** and check **local multicollinearity** with condition numbers and VIFs;
6.  compare GWR and MGWR with the **OLS, spatial lag and spatial error** models from Part 1.

All outputs below are real results from [`geographically_weighted_regression.py`](https://www.technicalgis.com/code/geographically-weighted-regression-python/), run in October 2026 with Python 3.12.0, mgwr 2.2.1, spreg 1.9.1, libpysal 4.15.1, esda 2.10.0, geopandas 1.2.0 and numpy 2.5.4.

## What you need from Part 1

This part uses exactly the same data as Part 1: the ONS **median price paid for all dwellings in the year ending March 2026** for each 2021 MSOA (the successor to HPSSA dataset 2), and four Census 2021 variables from Nomis:

-   `level4`: % of residents aged 16+ with a Level 4+ (degree-level) qualification;
-   `detached`: % of households in a detached house;
-   `no_car`: % of households with no car or van;
-   `ln_density`: the log of residents per km².

You do **not** need to have run Part 1. The [code folder](https://www.technicalgis.com/code/geographically-weighted-regression-python/) contains the same `wy_data.py` module, which downloads the price workbook, the four Nomis bulk tables and the ONS MSOA 2021 boundaries, joins them, and caches `data/west_yorkshire_msoa.gpkg`. Run it once:

```bash
pip install -r requirements.txt
python wy_data.py
```

`Output — wy_data.py`

```text
301 MSOAs in West Yorkshire; 0 without a median price; written to data\west_yorkshire_msoa.gpkg
```

The GWR script imports `load_west_yorkshire()` from the same module, so it also builds the file on first use if you skip this step. From Part 1 you only need the ideas: the OLS residuals are spatially autocorrelated, and the robust LM tests and AIC favour a spatial error model. Step 1 refits those global models here so the comparison in Step 6 is like for like.

## Step 1: standardise the data and refit the global models

mgwr needs three arrays: an n × 2 array of **coordinates**, an n × 1 **y**, and an n × k **X** without a constant (mgwr adds the intercept). We use the MSOA centroids in British National Grid metres.

It is standard practice to **standardise** y and X before GWR (subtract the mean, divide by the standard deviation). The coefficients are then in standard deviations of y per standard deviation of x. That puts the local coefficients of different variables on one scale, and it matters for MGWR, whose back-fitting algorithm assumes comparable scales.

`geographically_weighted_regression.py`

```python
import numpy as np
from libpysal.weights import Queen
import spreg
from mgwr.gwr import GWR, MGWR
from mgwr.sel_bw import Sel_BW
from wy_data import load_west_yorkshire

gdf = load_west_yorkshire()
X_NAMES = ["level4", "detached", "no_car", "ln_density"]
coords = np.column_stack([gdf.centroid.x, gdf.centroid.y])   # British National Grid, metres
y_raw = gdf[["ln_price"]].to_numpy()
X_raw = gdf[X_NAMES].to_numpy()
y = (y_raw - y_raw.mean()) / y_raw.std()
X = (X_raw - X_raw.mean(axis=0)) / X_raw.std(axis=0)
n = len(gdf)
```

To compare global and local models we need the same criterion on the same y. mgwr reports AICc; for a global ML model with p parameters (including the error variance) that is −2 log L + 2np / (n − p − 1). We refit Part 1’s three models on the standardised data and compute it:

`geographically_weighted_regression.py`

```python
w = Queen.from_dataframe(gdf, use_index=False)
w.transform = "r"

def aicc(logl, p):
    """AICc with p parameters including the error variance; for OLS this equals mgwr's AICc."""
    return -2 * logl + 2 * n * p / (n - p - 1)

ols = spreg.OLS(y, X, name_x=X_NAMES)
lag = spreg.ML_Lag(y, X, w=w, name_x=X_NAMES)
err = spreg.ML_Error(y, X, w=w, name_x=X_NAMES)
print(aicc(ols.logll, 6), aicc(lag.logll, 7), aicc(err.logll, 7))
```

`Output — global models on the standardised data`

```text
OLS                 R2 0.739  AICc   462.0  residual Moran's I +0.457 (p 0.0001)  betas -0.000 +0.487 +0.149 -0.409 +0.008
Spatial lag (ML)    R2 0.848  AICc   319.9  residual Moran's I +0.094 (p 0.0031)  betas -0.016 +0.291 +0.157 -0.269 +0.038
Spatial error (ML)  R2 0.696  AICc   301.7  residual Moran's I -0.074 (p 0.0164)  betas +0.031 +0.227 +0.179 -0.381 -0.059
```

These are the Part 1 models, just rescaled: the residual Moran’s I values and the ranking are identical. The OLS AICc of 462.0 matches the “Global Regression Results” that mgwr prints in its own summary below, which confirms the two are on the same scale. To convert a standardised coefficient back to the units of Part 1, multiply by SD(ln price) / SD(x) = 0.333 / 9.66 for `level4`. The OLS value of 0.487 becomes 0.0168, as in Part 1.

## Step 2: choose the GWR bandwidth by AICc

The **bandwidth** controls how local the regressions are. With an **adaptive** kernel it is the number of nearest neighbours that get a non-zero weight, so it adapts to small, dense urban MSOAs and large rural ones. The weights fall off with distance following a **bisquare** kernel and reach zero at the bandwidth-th neighbour. Both are mgwr’s defaults. `Sel_BW` searches for the bandwidth that minimises AICc with a golden-section search:

`geographically_weighted_regression.py`

```python
import time

t0 = time.perf_counter()
gwr_selector = Sel_BW(coords, y, X)   # adaptive bisquare kernel by default; fixed=True for a distance bandwidth
gwr_bw = gwr_selector.search(criterion="AICc")
print(f"optimal bandwidth: {gwr_bw:.0f} nearest neighbours; search took {time.perf_counter() - t0:.1f} s")

grid = np.arange(30, 301, 6)   # the whole AICc curve, for the figure
curve = np.array([GWR(coords, y, X, int(b)).fit().aicc for b in grid])
```

`Output — GWR bandwidth`

```text
optimal bandwidth: 62 nearest neighbours (21% of the MSOAs); search took 5.0 s
AICc profile: 30: 352.2, 60: 282.9, 90: 284.0, 120: 291.8, 150: 298.6, 180: 308.9, 210: 322.7, 240: 341.3, 270: 361.6, 300: 405.2
```

Each local regression uses the 62 nearest MSOAs, about a fifth of the county. The curve on the left of the figure shows that the minimum is flat: anywhere between about 55 and 90 neighbours gives an AICc within a few points of the optimum, so the exact value of 62 should not be over-read. Even at 300 neighbours (almost global, but with bisquare weights that still favour the nearest MSOAs) the AICc of 405 beats OLS.

![Left: line chart of GWR AICc against adaptive bandwidth from 30 to 300 nearest neighbours. The curve falls steeply to a broad minimum of about 282 at 62 neighbours, marked with an orange dot, then rises to about 405 at 300; a dashed line marks OLS at 462. Right: dot plot of MGWR bandwidths with 95% intervals: intercept about 54 and ln(population density) 43, both close to the GWR bandwidth of 62, while Level 4+, detached houses and households with no car are at 298 to 300, the global limit.](https://www.technicalgis.com/_astro/01-bandwidth-aicc.Cz8p44WG_1LNbfo.webp)

_Left: AICc against the GWR bandwidth, with the golden-section optimum. Right: the MGWR bandwidth of each variable with its 95% interval, the single GWR bandwidth (dotted) and the global limit of 300 neighbours (dashed)._

## Step 3: fit GWR and map the local coefficients

With the bandwidth chosen, fitting takes a fraction of a second:

`geographically_weighted_regression.py`

```python
gwr = GWR(coords, y, X, gwr_bw).fit()
gwr.summary()
```

`Output — GWR summary (abridged)`

```text
Geographically Weighted Regression (GWR) Results
---------------------------------------------------------------------------
Spatial kernel:                                           Adaptive bisquare
Bandwidth used:                                                      62.000

Diagnostic information
---------------------------------------------------------------------------
Residual sum of squares:                                             27.995
Effective number of parameters (trace(S)):                           56.517
Degree of freedom (n - trace(S)):                                   244.483
Log-likelihood:                                                     -69.650
AICc:                                                               282.094
R2:                                                                   0.907
Adjusted R2:                                                          0.885
Adj. alpha (95%):                                                     0.004
Adj. critical t value (95%):                                          2.868

Summary Statistics For GWR Parameter Estimates
---------------------------------------------------------------------------
Variable                   Mean        STD        Min     Median        Max
-------------------- ---------- ---------- ---------- ---------- ----------
X0                        0.012      0.381     -0.622     -0.069      0.979
X1                        0.283      0.125      0.024      0.262      0.628
X2                        0.080      0.169     -0.641      0.092      0.540
X3                       -0.487      0.179     -0.794     -0.506     -0.116
X4                       -0.127      0.149     -0.554     -0.115      0.143
```

X0 is the intercept, and X1–X4 are `level4`, `detached`, `no_car` and `ln_density` in that order. GWR explains 90.7% of the variation in ln(price), against 73.9% for OLS, and its AICc (282.1) is lower than that of every global model in Step 1, including the spatial error model (301.7). But it uses **56.5 effective parameters** to do it, against 5 for OLS. ENP is the trace of the hat matrix, a measure of how flexible the model is, and AICc’s penalty is what stops the bandwidth search from simply choosing the most local fit.

The local estimates vary a great deal. The `level4` coefficient runs from 0.02 to 0.63, and `detached` even changes sign, from −0.64 to +0.54. Before reading anything into that, we need to know which of these local estimates are distinguishable from zero.

### Local t-values and the multiple-testing correction

Every MSOA has its own t-value for every coefficient: 301 × 5 = 1,505 tests. Compared with |t| > 1.97, about 5% of them would be “significant” by chance even if nothing varied. The local tests are also not independent, because neighbouring regressions share most of their data. mgwr implements the correction of da Silva and Fotheringham (2016), which divides α by ENP / k, the effective number of independent tests per coefficient:

adjusted α = 0.05 × k / ENP = 0.05 × 5 / 56.5 = 0.0044, so the critical |t| becomes **2.87**.

`geographically_weighted_regression.py`

```python
crit = gwr.critical_tval()                  # no arguments = the corrected 5% level
naive_crit = gwr.critical_tval(alpha=0.05)  # passing alpha gives the UNcorrected value
t_filt = gwr.filter_tvals()                 # t-values, with the non-significant ones set to 0
for j, nm in enumerate(["Intercept"] + X_NAMES):
    sig = t_filt[:, j] != 0
    naive = np.abs(gwr.tvalues[:, j]) > naive_crit
    print(nm, sig.sum(), naive.sum())
```

`Output — local significance with the corrected critical t-value`

```text
effective number of parameters ENP = 56.5; adjusted alpha = 0.05 * k / ENP = 0.00442; critical |t| = 2.87 (vs 1.97 uncorrected)
Intercept   beta min -0.622 median -0.069 max +0.979 | significant: 177 (73 +, 104 -) | uncorrected: 211
level4      beta min +0.024 median +0.262 max +0.628 | significant: 157 (157 +, 0 -) | uncorrected: 210
detached    beta min -0.641 median +0.092 max +0.540 | significant:   1 (0 +, 1 -) | uncorrected:  42
no_car      beta min -0.794 median -0.506 max -0.116 | significant: 215 (0 +, 215 -) | uncorrected: 247
ln_density  beta min -0.554 median -0.115 max +0.143 | significant:  36 (0 +, 36 -) | uncorrected: 103
```

The correction matters most for the weaker variables. Uncorrected, `detached` looks significant in 42 MSOAs; corrected, in just one. Population density falls from 103 to 36. And the sign changes in `detached` turn out to be noise: for none of the four slopes is the coefficient significantly positive in some MSOAs and significantly negative in others. Only the intercept genuinely changes sign.

The maps show the local coefficients on a diverging scale centred on zero, with **non-significant MSOAs in grey**. All four slopes share one colour scale (and the same scale is used for MGWR below), so the colours are comparable across panels and across figures.

![Five maps of West Yorkshire showing GWR local coefficients on a red-blue diverging scale centred on zero, with non-significant MSOAs in grey. Intercept: positive (red) across north Leeds and negative (blue) in Calderdale, Kirklees and west Bradford, 177 of 301 significant. Level 4+ qualifications: positive, strongest in north Bradford district and east Leeds and Wakefield, 157 significant. Detached houses: grey everywhere except 1 MSOA. Households with no car: negative across the west and centre, grey in the east, 215 significant. Population density: negative in small patches in Bradford, Kirklees and Wakefield, 36 significant.](https://www.technicalgis.com/_astro/02-gwr-coefficients.C_ZtW2t6_ZQiQbE.webp)

_GWR local coefficients (standardised; adaptive bisquare kernel with 62 neighbours). MSOAs whose local |t| is below the corrected critical value of 2.87 are grey._

Read with the corrections in mind, the GWR maps suggest:

-   **The intercept is the strongest local signal.** After allowing for the four census variables, prices are higher than average across north and east Leeds and lower in Calderdale, Kirklees and west Bradford. This is the regional price surface that the spatial error model in Part 1 absorbed into its error term.
-   **Level 4+ qualifications** have a positive effect wherever they are significant, apparently strongest in the north of Bradford district and on the eastern side of the county.
-   **No-car households** have a negative effect across the west and centre, but it is not significant in much of Leeds and Wakefield.
-   **Detached houses** and **density** have little local signal.

How much of that variation is real? GWR uses one bandwidth for every variable, so if the intercept genuinely varies over short distances, every other coefficient is forced to vary over the same distance too. That is the problem MGWR solves.

## Step 4: fit MGWR, one bandwidth per variable

**Multiscale GWR** (Fotheringham, Yang and Kang, 2017) lets each variable have its own bandwidth. A variable whose effect really is constant gets a bandwidth near n, which makes it effectively global, while a variable that varies locally gets a small one. It is fitted by **back-fitting**, which cycles through the variables and re-optimises each bandwidth until the estimates converge. That makes it much slower.

`geographically_weighted_regression.py`

```python
t0 = time.perf_counter()
mgwr_selector = Sel_BW(coords, y, X, multi=True)
mgwr_bws = mgwr_selector.search(criterion="AICc")
mgwr = MGWR(coords, y, X, mgwr_selector).fit()
print(f"search + fit {time.perf_counter() - t0:.1f} s")
mgwr.summary()
bw_ci = mgwr.get_bws_intervals(mgwr_selector)   # 95% intervals for each bandwidth
crit_j = mgwr.critical_tval()                    # one corrected critical value per covariate
mt_filt = mgwr.filter_tvals()
```

`Output — MGWR (abridged)`

```text
bandwidth search 110.7 s; search + fit 111.7 s (GWR search took 5.0 s)
MGWR bandwidths
---------------------------------------------------------------------------
Variable             Bandwidth      ENP_j   Adj t-val(95%)   Adj alpha(95%)
X0                      54.000     13.675            2.929            0.004
X1                     298.000      1.373            2.102            0.036
X2                     300.000      1.340            2.092            0.037
X3                     300.000      1.203            2.047            0.042
X4                      43.000     20.053            3.050            0.002

Diagnostic information
---------------------------------------------------------------------------
Residual sum of squares:                                             30.090
Effective number of parameters (trace(S)):                           37.645
AICc:                                                               250.042
R2                                                                    0.900
Adjusted R2                                                           0.886

Intercept   bandwidth  54 (95% interval 51-65) ENP_j  13.7 critical |t| 2.93 | beta min -0.516 median -0.047 max +0.773 | significant 188 (92 +, 96 -)
level4      bandwidth 298 (95% interval 202-298) ENP_j   1.4 critical |t| 2.10 | beta min +0.247 median +0.259 max +0.278 | significant 301 (301 +, 0 -)
detached    bandwidth 300 (95% interval 240-300) ENP_j   1.3 critical |t| 2.09 | beta min +0.090 median +0.103 max +0.119 | significant 301 (301 +, 0 -)
no_car      bandwidth 300 (95% interval 240-300) ENP_j   1.2 critical |t| 2.05 | beta min -0.516 median -0.506 max -0.489 | significant 301 (0 +, 301 -)
ln_density  bandwidth  43 (95% interval 43-57) ENP_j  20.1 critical |t| 3.05 | beta min -0.445 median -0.088 max +0.161 | significant 83 (0 +, 83 -)
```

This is the most informative result in the series. MGWR splits the variables into two groups:

-   **Local:** the intercept (bandwidth 54) and population density (43). These vary over distances of a few kilometres.
-   **Effectively global:** Level 4+ qualifications (298), detached houses (300) and no-car households (300). Their bandwidths are at or next to the maximum of n − 1 = 300 neighbours, and their 95% intervals (202–300 and 240–300) exclude anything as local as GWR’s 62. Their coefficients barely vary: `level4` runs only from 0.247 to 0.278.

Much of the variation in the GWR coefficient maps, then, was the single bandwidth forcing every variable to be as local as the intercept. MGWR also needs fewer effective parameters (37.6 against 56.5), so its AICc is much lower (250.0 against 282.1), with almost the same R² (0.900). Each covariate also gets its own corrected critical t-value, from 2.05 for the near-global variables to 3.05 for density, because the correction depends on that covariate’s own ENP\_j.

![Five MGWR coefficient maps on the same red-blue scale as the GWR maps. Intercept: positive across north Leeds, negative in the west, grey in the east, 188 significant. Level 4+, detached houses and households with no car are each a single uniform colour across the whole county (pale red, very pale red and mid blue), all 301 significant. Population density: negative in a band through Bradford, Calderdale and Kirklees, grey elsewhere, 83 significant.](https://www.technicalgis.com/_astro/03-mgwr-coefficients.91YoRtVT_8CJhi.webp)

_MGWR local coefficients on the same scales as the GWR maps, with each variable's bandwidth and corrected critical |t|. Three of the four slopes are effectively constant; only the intercept and population density vary locally._

The MGWR global coefficients also agree with Part 1’s spatial error model rather than OLS. The `level4` coefficient is about 0.26 SD per SD in MGWR and 0.23 in the error model, against 0.49 in OLS. Both local and global spatial models say the same thing: OLS overstates the qualifications effect because graduates are concentrated in the expensive parts of the county, and once the regional price level is allowed for, the effect roughly halves.

The **density** effect is new. It is negative and significant in a band from Bradford through Calderdale into Kirklees, where denser MSOAs are cheaper than their other characteristics suggest, and not significant elsewhere. That is a hypothesis worth investigating, perhaps about the type of dense housing in the mill towns compared with central Leeds, not a finding.

## Step 5: local R² and local multicollinearity

GWR also gives a **local R²** for each regression: how well the local model fits the MSOAs around each point.

`geographically_weighted_regression.py`

```python
gdf["gwr_r2"] = gwr.localR2.flatten()
corr, vifs, cn, vdp = gwr.local_collinearity()   # local correlations, VIFs, condition numbers, VDPs
gdf["gwr_cn"] = cn.flatten()
mcn, mvdp = mgwr.local_collinearity()            # MGWR returns condition numbers and VDPs only
```

`Output — local R² and local collinearity`

```text
GWR local R2: min 0.69, median 0.86, max 0.95; global GWR R2 0.907, MGWR R2 0.900
lowest local R2: Farnley East (Leeds) 0.69; Holbeck (Leeds) 0.70; Farnley West & Gamble Hill (Leeds) 0.71
GWR local condition number: min 3.0, median 5.7, max 18.4; > 30 in 0 MSOAs
GWR local VIF max per variable: level4 3.7, detached 13.0, no_car 7.1, ln_density 5.2; MSOAs with any VIF > 10: 4
strongest local correlation: detached vs no_car, from -0.92 to -0.65 (global -0.78)
MGWR local condition number: median 3.4, max 5.8; > 30 in 0 MSOAs
```

![Left: map of GWR local R squared from 0.69 (dark purple) to 0.95 (yellow), highest in the north of Bradford district and north-east Leeds, lowest in inner south Leeds. Right: map of the GWR local condition number from about 3 to 18, low in most of the county, higher around central Leeds and Bradford and highest in a few MSOAs on the southern edge of the county.](https://www.technicalgis.com/_astro/04-local-r2-condition-number.tv6bq4G9_Z1bOGcW.webp)

_Left: GWR local R². Right: GWR local condition number; no MSOA reaches the usual warning level of 30._

Local R² ranges from 0.69 to 0.95. The model fits best in the north of Bradford district and north-east Leeds, and worst in inner south Leeds (Holbeck and Farnley), where something the four census variables do not capture, perhaps the mix of regeneration and older terraces, drives prices. Low local R² is a pointer to missing variables, not a significance test.

**Local multicollinearity** is a well-known weakness of GWR (Wheeler and Tiefelsdorf, 2005). Two variables can be only moderately correlated across the whole study area but almost collinear within one local window, and then their local coefficients become unstable and often take opposite, offsetting signs. Here, `detached` and `no_car` have a global correlation of −0.78, but locally it reaches −0.92. A few MSOAs have a local VIF above 10, and the local condition numbers, at most 18.4, stay below the usual warning level of 30. So collinearity is not driving the results, but it is a plausible contributor to the wide, insignificant swings in GWR’s `detached` coefficient (−0.64 to +0.54), since `detached` is the variable with the highest local VIF. MGWR, whose condition numbers are at most 5.8, does not show the problem.

## Step 6: compare with the global models from Part 1

`Output — comparison with the global models`

```text
             model    R2    AICc  resid_I  resid_p    ENP
               OLS 0.739 462.041    0.457    0.000  5.000
  Spatial lag (ML) 0.848 319.916    0.094    0.003  6.000
Spatial error (ML) 0.696 301.679   -0.074    0.016  6.000
       GWR (bw 62) 0.907 282.094   -0.027    0.254 56.517
              MGWR 0.900 250.042   -0.034    0.189 37.645
```

The R² of the lag model is spreg’s pseudo R², and the R² of the error model covers only its non-spatial part, so compare the models by AICc, not R².

![Left: horizontal bar chart of AICc by model: OLS 462, spatial lag 320, spatial error 302, GWR 282 and MGWR 250, each labelled with its residual Moran's I (+0.46, +0.09, −0.07, −0.03, −0.03). Right: box plots of the GWR and MGWR local coefficients for the four variables, with the OLS and spatial error estimates as markers. GWR boxes are wide; MGWR boxes for Level 4+, detached and no car are almost flat lines, close to the spatial error estimates; the density boxes are wide for both.](https://www.technicalgis.com/_astro/05-model-comparison.BAdl6wTb_Z2uKdJn.webp)

_Left: AICc on the standardised data and residual Moran's I for each model. Right: the spread of the GWR and MGWR local coefficients (boxes show the interquartile range, whiskers 1.5 × IQR) against the OLS and spatial error estimates._

By AICc the order is MGWR < GWR < spatial error < spatial lag < OLS. Both local models remove the residual spatial autocorrelation altogether (I = −0.03, p ≈ 0.2), which neither global spatial model quite managed. The box plots show the other side of the trade-off: GWR’s local estimates spread widely around the global values, while MGWR collapses three of the four back to a single global value.

What should you report? MGWR is the best-fitting and the most honest about which effects vary. But a mixed model is not always the right deliverable. If the question is “what is the effect of X?”, the spatial error model of Part 1 gives one coefficient with a standard error that respects spatial dependence. If the question is “where does the relationship differ?”, MGWR is the tool, and here its answer is that the relationships with qualifications, housing type and car ownership hardly differ. What differs is the **baseline price level**.

## Pitfalls and limitations

-   **GWR is exploratory.** It is a tool for spotting where a global model may be wrong, not for estimating causal local effects. Local coefficients are smoothed, correlated with their neighbours, and depend on the bandwidth and kernel. Treat patterns as hypotheses to test with better data or a theory-driven model.
-   **Multiple testing.** Hundreds of local t-tests need a correction. Use mgwr’s corrected critical values by calling `filter_tvals()` and `critical_tval()` without `alpha`. For the formal question “does this coefficient really vary?”, mgwr has a Monte Carlo test, `results.spatial_variability(selector, n_iters=1000)`. It refits the model for each random permutation of the locations, so it is very slow; MGWR’s bandwidth intervals answer much the same question here.
-   **Local multicollinearity.** Always check the local condition numbers and VIFs. Values above 30 (condition number) or 10 (VIF) mean the local coefficients in those places should not be interpreted. Here the largest condition number is 18.4, but four MSOAs have a local VIF above 10.
-   **Computing time.** GWR on 301 areas took 5 s to choose the bandwidth; MGWR took 111 s on a laptop (89 s on an earlier run, so expect variation). MGWR scales badly with n because each back-fitting iteration refits n local regressions per variable. For larger data, restrict the bandwidth search (`multi_bw_min`, `multi_bw_max`), start MGWR from the GWR bandwidth (`init_multi`), and use parallel processing (`n_jobs` in `Sel_BW`). For tens of thousands of points, consider the FastGWR implementation.
-   **Same data caveats as Part 1.** These are associations across 301 areas. The census is from 2021 and the prices from the year to March 2026. MSOA medians are noisy. MSOAs at the edge of West Yorkshire have their local regressions shifted inwards, because the data stop at the county boundary.
-   **The bandwidth curve is flat.** AICc changes by only a few points between about 55 and 90 neighbours. Coefficient maps from bandwidths in that range are equally defensible, so do not over-read fine detail.

## Frequently asked questions

### What is geographically weighted regression used for?

GWR is used to explore whether the relationship between a dependent variable and its predictors changes across space: for example, whether house prices respond more to schools in one city than another, or whether a disease risk factor matters more in some regions. It produces a map of each coefficient, a local R² and local residuals.

### What is the difference between GWR and MGWR?

GWR uses one bandwidth for every variable, so all coefficients vary over the same spatial scale. MGWR gives each variable its own bandwidth, so some can be local, some regional and some global. MGWR usually fits better, is less noisy, and tells you directly which relationships vary, but it is much slower to fit.

### How do I choose the bandwidth in GWR?

Let the data choose it by minimising AICc (or cross-validation) with mgwr’s `Sel_BW(...).search(criterion="AICc")`. Use an adaptive kernel (a number of nearest neighbours) when your areas vary a lot in size, as census areas do. Plot AICc against the bandwidth to see how flat the minimum is.

### How do I test whether GWR coefficients are significant?

Compare each local t-value with mgwr’s corrected critical value, which adjusts α for the effective number of independent tests (`results.critical_tval()` with no arguments, or `results.filter_tvals()` to set non-significant t-values to zero). Grey out the non-significant areas on the coefficient maps. Do not use 1.96 for hundreds of local tests.

### Is GWR better than a spatial lag or spatial error model?

They answer different questions. Spatial lag and error models assume one global relationship and model spatial dependence through the weights matrix; GWR and MGWR allow the relationship itself to vary. GWR often fits better because it is much more flexible, but that flexibility costs effective parameters and interpretability. Here MGWR had the lowest AICc, yet its main finding was that three of the four effects are global.

### Can I use GWR for prediction?

Yes: mgwr’s `GWR.predict()` estimates at new locations using the calibrated bandwidth. But GWR is not designed as a predictive model. For prediction at unsampled locations, compare it with [kriging](https://www.technicalgis.com/tutorials/kriging-spatial-interpolation-python/) or a machine-learning model, evaluated with [spatially blocked cross-validation](https://www.technicalgis.com/tutorials/land-cover-classification-random-forest/).

## Next steps

-   If you skipped it, **[Part 1: spatial regression in Python](https://www.technicalgis.com/tutorials/spatial-regression-python/)** covers OLS diagnostics, the Lagrange Multiplier tests, spatial lag and error models, and how to interpret lag-model impacts.
-   To test the local residuals or coefficients for clustering, use the local statistics in the [hot spot analysis tutorial](https://www.technicalgis.com/tutorials/hot-spot-analysis-getis-ord-python/).
-   For a simpler first look at a relationship between two census variables, see [bivariate correlation analysis with census and crime data](https://www.technicalgis.com/2022/08/14/performing-bivariate-correlation-analysis-using-census-and-crime-data/) (in ArcGIS Pro).
-   To produce the coefficient maps at publication quality, see [maps in Python with Matplotlib and Cartopy](https://www.technicalgis.com/tutorials/python-maps-matplotlib-cartopy/). The script also writes all the local estimates to `output/gwr_mgwr_results.gpkg` for use in QGIS.

## Data and licences

-   **Median house prices by MSOA** (formerly HPSSA dataset 2), year ending March 2026, Office for National Statistics, derived from HM Land Registry Price Paid Data. Open Government Licence v3.0. Contains HM Land Registry data © Crown copyright and database right 2026.
-   **Census 2021** tables TS067, TS044, TS045 and TS006, Office for National Statistics, via Nomis. Open Government Licence v3.0.
-   **MSOA (December 2021) boundaries EW BGC V3**, ONS Open Geography Portal. Open Government Licence v3.0. Contains OS data © Crown copyright and database right 2026.

References: da Silva, A. R. and Fotheringham, A. S. (2016) The multiple testing issue in geographically weighted regression, _Geographical Analysis_ 48(3); Fotheringham, A. S., Yang, W. and Kang, W. (2017) Multiscale geographically weighted regression (MGWR), _Annals of the American Association of Geographers_ 107(6); Oshan, T. M. et al. (2019) mgwr: a Python implementation of multiscale geographically weighted regression, _ISPRS International Journal of Geo-Information_ 8(6); Wheeler, D. and Tiefelsdorf, M. (2005) Multicollinearity and correlation among local regression coefficients in geographically weighted regression, _Journal of Geographical Systems_ 7(2).

## Key terms

- **Geographically weighted regression (GWR)**: A local regression that fits a separate weighted least-squares model at each location, giving more weight to nearby observations, so that every coefficient can vary across space.
- **Multiscale GWR (MGWR)**: An extension of GWR in which each explanatory variable has its own bandwidth, so some relationships can be local and others regional or global. It is fitted by back-fitting.
- **Bandwidth**: The size of the local neighbourhood in GWR: a distance (fixed kernel) or a number of nearest neighbours (adaptive kernel). Small bandwidths give local, noisy estimates; a bandwidth of all observations reproduces a global model.
- **AICc**: The Akaike information criterion with a small-sample correction. In GWR it uses the trace of the hat matrix as the number of parameters, and it is the usual criterion for choosing the bandwidth.
- **Effective number of parameters (ENP)**: The trace of the GWR hat matrix, which measures how flexible the local model is. It is used in AICc and in the multiple-testing correction for local t-values.
- **Local condition number**: The condition number of the weighted design matrix at one location. Values above about 30 warn that local collinearity may make the local coefficients unreliable.

## Data for this tutorial

-   [Nomis](https://www.technicalgis.com/data-sources/united-kingdom/#src-nomis) · Census 2021 bulk tables TS067, TS044, TS045 and TS006 at MSOA level
-   [Open Geography Portal](https://www.technicalgis.com/data-sources/united-kingdom/#src-open-geography-portal) · Middle layer Super Output Areas (December 2021) boundaries, generalised and clipped (BGC)
-   [Median house prices by MSOA (ONS)](https://www.technicalgis.com/data-sources/united-kingdom/#src-ons-median-house-prices-msoa) · Median price paid for all dwellings, year ending March 2026, the dependent variable

Licences and formats for each source are listed in the [GIS data sources catalogue](https://www.technicalgis.com/data-sources/).

[← Part 1 Spatial regression in Python: OLS diagnostics, spatial lag and spatial error models with spreg](https://www.technicalgis.com/tutorials/spatial-regression-python/)

---

Source: https://www.technicalgis.com/tutorials/geographically-weighted-regression-python/
