Source code: Geographically weighted regression in Python: GWR and MGWR with mgwr
Runnable Python scripts, requirements and console output for the tutorial “Geographically weighted regression in Python: GWR and MGWR with mgwr”.
Last updated
These are the exact scripts used to produce every figure and number in Geographically weighted regression in Python: GWR and MGWR with mgwr. Download them, install the requirements into a fresh virtual environment and run them from this folder.
- geographically_weighted_regression.py
- wy_data.py
- requirements.txt
- console.txt: the console output recorded when we ran it
python -m venv .venv.venv\Scripts\activate # macOS/Linux: source .venv/bin/activatepip install -r requirements.txtpython geographically_weighted_regression.pygeographically_weighted_regression.py
"""Geographically weighted regression in Python (Part 2 of 2): GWR and MGWR with mgwr on median house pricesin West Yorkshire's 301 MSOAs, compared with the global OLS, spatial lag and spatial error models of Part 1.
TechnicalGIS tutorial: https://www.technicalgis.com/tutorials/geographically-weighted-regression-python/Data: ONS / HM Land Registry median price paid by MSOA, Census 2021 (Nomis) and ONS Open Geography boundaries,all under the Open Government Licence v3.0. wy_data.py (shared with Part 1) downloads and joins them."""import contextlibimport ioimport jsonimport sysimport timeimport warningsfrom importlib.metadata import versionfrom pathlib import Path
import numpy as npimport pandas as pdimport matplotlibmatplotlib.use("Agg") # render to files onlyimport matplotlib.pyplot as pltfrom matplotlib.colors import TwoSlopeNormfrom matplotlib.patches import Patchfrom libpysal.weights import Queenimport esdaimport spregfrom mgwr.gwr import GWR, MGWRfrom mgwr.sel_bw import Sel_BW
from wy_data import load_west_yorkshire
warnings.filterwarnings("ignore")plt.rcParams.update({"figure.dpi": 150, "savefig.bbox": "tight", "font.size": 9, "axes.titlesize": 10})OUT = Path("output"); OUT.mkdir(exist_ok=True)PERMS = 9999np.random.seed(42) # esda.Moran has no seed argumentGREY = "#cfcfcf"
def section(title): print(f"\n=== {title} ===")
section("versions")print(f"python {sys.version.split()[0]} | mgwr {version('mgwr')} | spreg {version('spreg')} | " f"libpysal {version('libpysal')} | esda {version('esda')} | geopandas {version('geopandas')} | " f"numpy {version('numpy')}")
# ---------------------------------------------------------------- Step 1: data, standardisedsection("data")gdf = load_west_yorkshire()X_NAMES = ["level4", "detached", "no_car", "ln_density"]NAMES = ["Intercept"] + X_NAMESLABELS = {"Intercept": "Intercept (local price level)", "level4": "Level 4+ qualifications", "detached": "Detached houses", "no_car": "Households with no car", "ln_density": "ln(population density)"}coords = np.column_stack([gdf.centroid.x, gdf.centroid.y])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)print(f"{n} MSOAs; y = standardised ln(median price); X = standardised {', '.join(X_NAMES)}")print(f"SD of ln(price) = {y_raw.std():.3f}; SDs of X: " + ", ".join(f"{k} {s:.2f}" for k, s in zip(X_NAMES, X_raw.std(axis=0))))
w = Queen.from_dataframe(gdf, use_index=False)w.transform = "r"lads = gdf.dissolve("lad").reset_index()
def outline(ax): lads.boundary.plot(ax=ax, color="#222222", linewidth=0.5) ax.set_axis_off()
def aicc(logl, p): """AICc with p parameters including the error variance; for OLS this equals mgwr's GWR AICc formula.""" return -2 * logl + 2 * n * p / (n - p - 1)
def resid_moran(e): m = esda.Moran(np.asarray(e).flatten(), w, permutations=PERMS) return m.I, m.p_sim
# ---------------------------------------------------------------- Step 2: the global models from Part 1section("global models on the standardised data (as in Part 1)")ols = spreg.OLS(y, X, name_x=X_NAMES)with contextlib.redirect_stdout(io.StringIO()): # spreg prints the class name of each ML fit lag = spreg.ML_Lag(y, X, w=w, name_x=X_NAMES) err = spreg.ML_Error(y, X, w=w, name_x=X_NAMES)glob = {"OLS": (ols.betas[:, 0], ols.r2, aicc(ols.logll, 6), resid_moran(ols.u)), "Spatial lag (ML)": (lag.betas[:5, 0], lag.pr2, aicc(lag.logll, 7), resid_moran(lag.u)), "Spatial error (ML)": (err.betas[:5, 0], err.pr2, aicc(err.logll, 7), resid_moran(err.e_filtered))}for k, (b, r2, a, (mi, p)) in glob.items(): print(f"{k:<19} R2 {r2:.3f} AICc {a:7.1f} residual Moran's I {mi:+.3f} (p {p:.4f}) betas " + " ".join(f"{v:+.3f}" for v in b))
# ---------------------------------------------------------------- Step 3: GWR bandwidth by AICcsection("GWR bandwidth selection (adaptive bisquare, AICc)")t0 = time.perf_counter()gwr_selector = Sel_BW(coords, y, X) # defaults: adaptive (nearest neighbours), bisquare kernel, golden sectiongwr_bw = gwr_selector.search(criterion="AICc")t_gwr_bw = time.perf_counter() - t0print(f"optimal bandwidth: {gwr_bw:.0f} nearest neighbours ({100 * gwr_bw / n:.0f}% of the MSOAs); " f"search took {t_gwr_bw:.1f} s")
grid = np.arange(30, 301, 6)curve = np.array([GWR(coords, y, X, int(b)).fit().aicc for b in grid])print("AICc profile: " + ", ".join(f"{b}: {a:.1f}" for b, a in zip(grid[::5], curve[::5])))
t0 = time.perf_counter()gwr = GWR(coords, y, X, gwr_bw).fit()print(f"GWR fit in {time.perf_counter() - t0:.2f} s")gwr.summary()
# ---------------------------------------------------------------- Step 4: local t-values and multiple testingsection("GWR local significance with the corrected critical t-value")# no arguments = the corrected 5% level; passing alpha=0.05 would give the UNcorrected critical valuecrit = gwr.critical_tval()naive_crit = gwr.critical_tval(alpha=0.05)print(f"effective number of parameters ENP = {gwr.ENP:.1f}; adjusted alpha = 0.05 * k / ENP = {gwr.adj_alpha[1]:.5f}; " f"critical |t| = {crit:.2f} (vs {naive_crit:.2f} uncorrected)")t_filt = gwr.filter_tvals() # t-values with the non-significant ones set to 0for j, nm in enumerate(NAMES): b = gwr.params[:, j] sig = t_filt[:, j] != 0 naive = np.abs(gwr.tvalues[:, j]) > naive_crit print(f"{nm:<11} beta min {b.min():+.3f} median {np.median(b):+.3f} max {b.max():+.3f} | significant: " f"{sig.sum():3d} ({(b[sig] > 0).sum()} +, {(b[sig] < 0).sum()} -) | uncorrected: {naive.sum():3d}")
# ---------------------------------------------------------------- Step 5: MGWRsection("MGWR bandwidth selection (back-fitting, AICc)")t0 = time.perf_counter()mgwr_selector = Sel_BW(coords, y, X, multi=True)mgwr_bws = mgwr_selector.search(criterion="AICc")t_mgwr_bw = time.perf_counter() - t0mgwr = MGWR(coords, y, X, mgwr_selector).fit()t_mgwr = time.perf_counter() - t0print(f"bandwidth search {t_mgwr_bw:.1f} s; search + fit {t_mgwr:.1f} s (GWR search took {t_gwr_bw:.1f} s)")mgwr.summary()with contextlib.redirect_stdout(io.StringIO()): bw_ci = mgwr.get_bws_intervals(mgwr_selector)crit_j = mgwr.critical_tval() # one corrected critical value per covariatemt_filt = mgwr.filter_tvals()for j, nm in enumerate(NAMES): b = mgwr.params[:, j] sig = mt_filt[:, j] != 0 print(f"{nm:<11} bandwidth {mgwr_bws[j]:3.0f} (95% interval {bw_ci[j][0]:.0f}-{bw_ci[j][1]:.0f}) ENP_j {mgwr.ENP_j[j]:5.1f} " f"critical |t| {crit_j[j]:.2f} | beta min {b.min():+.3f} median {np.median(b):+.3f} max {b.max():+.3f} | " f"significant {sig.sum():3d} ({(b[sig] > 0).sum()} +, {(b[sig] < 0).sum()} -)")
# ---------------------------------------------------------------- Step 6: local R2 and collinearitysection("local R2 and local collinearity")gdf["gwr_r2"] = gwr.localR2.flatten()print(f"GWR local R2: min {gdf.gwr_r2.min():.2f}, median {gdf.gwr_r2.median():.2f}, max {gdf.gwr_r2.max():.2f}; " f"global GWR R2 {gwr.R2:.3f}, MGWR R2 {mgwr.R2:.3f}")low = gdf.nsmallest(3, "gwr_r2")print("lowest local R2: " + "; ".join(f"{r['name']} ({r.lad}) {r.gwr_r2:.2f}" for _, r in low.iterrows()))corr, vifs, cn, vdp = gwr.local_collinearity()gdf["gwr_cn"] = cn.flatten()print(f"GWR local condition number: min {cn.min():.1f}, median {np.median(cn):.1f}, max {cn.max():.1f}; " f"> 30 in {(cn > 30).sum()} MSOAs")print("GWR local VIF max per variable: " + ", ".join(f"{k} {v:.1f}" for k, v in zip(X_NAMES, vifs.max(axis=0))) + f"; MSOAs with any VIF > 10: {(vifs > 10).any(axis=1).sum()}")pairs = [(a, b) for i, a in enumerate(X_NAMES) for b in X_NAMES[i + 1:]]jmax = np.argmax(np.abs(corr).max(axis=0))print(f"strongest local correlation: {pairs[jmax][0]} vs {pairs[jmax][1]}, from {corr[:, jmax].min():+.2f} to " f"{corr[:, jmax].max():+.2f} (global {np.corrcoef(X[:, X_NAMES.index(pairs[jmax][0])], X[:, X_NAMES.index(pairs[jmax][1])])[0, 1]:+.2f})")mcn, _ = mgwr.local_collinearity()print(f"MGWR local condition number: median {np.median(mcn):.1f}, max {mcn.max():.1f}; > 30 in {(mcn > 30).sum()} MSOAs")
# ---------------------------------------------------------------- Step 7: comparisonsection("comparison with the global models")mi_gwr = resid_moran(gwr.resid_response)mi_mgwr = resid_moran(mgwr.resid_response)rows = [(k, v[1], v[2], v[3][0], v[3][1]) for k, v in glob.items()]rows += [(f"GWR (bw {gwr_bw:.0f})", gwr.R2, gwr.aicc, *mi_gwr), ("MGWR", mgwr.R2, mgwr.aicc, *mi_mgwr)]comp = pd.DataFrame(rows, columns=["model", "R2", "AICc", "resid_I", "resid_p"])comp["ENP"] = [5, 6, 6, gwr.ENP, mgwr.ENP] # coefficients (+ rho or lambda); GWR/MGWR: trace of the hat matrixprint("(R2 for the lag model is spreg's pseudo R2; for the error model it is the R2 of the non-spatial part)")print(comp.round(3).to_string(index=False))
# ---------------------------------------------------------------- figures# 1. bandwidth selectionfig, axes = plt.subplots(1, 2, figsize=(11, 3.8), gridspec_kw={"width_ratios": [1.2, 1]})ax = axes[0]ax.plot(grid, curve, "-", color="#0072B2", lw=1.6)ax.scatter([gwr_bw], [gwr.aicc], color="#D55E00", zorder=3, s=40, label=f"golden-section optimum: {gwr_bw:.0f} neighbours (AICc {gwr.aicc:.1f})")ax.axhline(glob["OLS"][2], color="#777777", ls="--", lw=1, label=f"OLS (AICc {glob['OLS'][2]:.1f})")ax.set_xlabel("Adaptive bandwidth (nearest neighbours)"); ax.set_ylabel("AICc (lower is better)")ax.set_title("GWR: AICc against bandwidth"); ax.legend(frameon=False, fontsize=8); ax.grid(color="#e5e5e5", lw=0.6)ax = axes[1]yy = np.arange(len(NAMES))lo_ = [mgwr_bws[j] - bw_ci[j][0] for j in range(len(NAMES))]hi_ = [bw_ci[j][1] - mgwr_bws[j] for j in range(len(NAMES))]ax.errorbar(mgwr_bws, yy, xerr=[lo_, hi_], fmt="o", color="#009E73", capsize=3, ms=6, label="MGWR bandwidth (95% interval)")ax.axvline(gwr_bw, color="#D55E00", ls=":", lw=1.4, label=f"GWR single bandwidth ({gwr_bw:.0f})")ax.axvline(n - 1, color="#777777", ls="--", lw=1, label=f"all {n - 1} neighbours (global)")ax.set_yticks(yy, [LABELS[k] for k in NAMES]); ax.invert_yaxis()ax.set_xlim(0, 320); ax.set_xlabel("Bandwidth (nearest neighbours)")ax.set_title("MGWR: one bandwidth per variable"); ax.legend(frameon=False, fontsize=7.5, loc="lower right")ax.grid(axis="x", color="#e5e5e5", lw=0.6)fig.tight_layout(); fig.savefig(OUT / "01-bandwidth-aicc.png"); plt.close(fig)
# one colour scale for the intercept and one shared by all slopes, in both figures, so GWR and MGWR are comparableLIM_INT = max(np.abs(gwr.params[:, 0]).max(), np.abs(mgwr.params[:, 0]).max())LIM_SLOPE = max(np.abs(gwr.params[:, 1:]).max(), np.abs(mgwr.params[:, 1:]).max())
def coef_maps(params, tfilt, title, fname, bws=None, crits=None): fig, axes = plt.subplots(2, 3, figsize=(12.5, 7.6)) for j, (nm, ax) in enumerate(zip(NAMES, axes.flat)): b = params[:, j] lim = LIM_INT if j == 0 else LIM_SLOPE norm = TwoSlopeNorm(0, -lim, lim) sig = tfilt[:, j] != 0 gdf.assign(b=b).plot("b", ax=ax, cmap="RdBu_r", norm=norm, edgecolor="white", linewidth=0.1) if (~sig).any(): gdf[~sig].plot(ax=ax, color=GREY, edgecolor="white", linewidth=0.1) outline(ax) sm = plt.cm.ScalarMappable(cmap="RdBu_r", norm=norm) fig.colorbar(sm, ax=ax, shrink=0.7, label="Local coefficient (SD of ln price per SD)") extra = f"bandwidth {bws[j]:.0f}, |t| > {crits[j]:.2f}" if bws is not None else f"|t| > {crits:.2f}" ax.set_title(f"{LABELS[nm]}\n{extra}: {sig.sum()} of {n} significant", fontsize=9) ax = axes.flat[-1]; ax.set_axis_off() ax.legend(handles=[Patch(facecolor=GREY, edgecolor="#999999", label="Not significant after correction")], loc="center", frameon=False) ax.text(0.5, 0.25, "Red: positive local coefficient\nBlue: negative local coefficient\n" "Coefficients are standardised; the four slopes\nshare one colour scale in the GWR and MGWR maps", ha="center", va="center", transform=ax.transAxes, fontsize=8.5) fig.suptitle(title, fontsize=11) fig.tight_layout(); fig.savefig(OUT / fname); plt.close(fig)
coef_maps(gwr.params, t_filt, f"GWR local coefficients (adaptive bisquare, {gwr_bw:.0f} neighbours)", "02-gwr-coefficients.png", crits=crit)coef_maps(mgwr.params, mt_filt, "MGWR local coefficients (one bandwidth per variable)", "03-mgwr-coefficients.png", bws=mgwr_bws, crits=crit_j)
# 4. local R2 and local condition numberfig, axes = plt.subplots(1, 2, figsize=(11.5, 4.3))gdf.plot("gwr_r2", ax=axes[0], cmap="viridis", edgecolor="white", linewidth=0.1, legend=True, legend_kwds={"label": "GWR local R²", "shrink": 0.75})outline(axes[0]); axes[0].set_title(f"GWR local R² (median {gdf.gwr_r2.median():.2f})")gdf.plot("gwr_cn", ax=axes[1], cmap="cividis", edgecolor="white", linewidth=0.1, legend=True, legend_kwds={"label": "Local condition number", "shrink": 0.75})if (gdf.gwr_cn > 30).any(): gdf[gdf.gwr_cn > 30].boundary.plot(ax=axes[1], color="#D55E00", linewidth=1.2)n_hi = int((gdf.gwr_cn > 30).sum())axes[1].set_title(f"GWR local condition number (max {gdf.gwr_cn.max():.1f}; " + (f"{n_hi} MSOAs above 30 outlined)" if n_hi else "none above the warning level of 30)"))outline(axes[1])fig.tight_layout(); fig.savefig(OUT / "04-local-r2-condition-number.png"); plt.close(fig)
# 5. comparison with Part 1's global modelsfig, axes = plt.subplots(1, 2, figsize=(12, 4.3), gridspec_kw={"width_ratios": [1, 1.35]})ax = axes[0]cols = ["#999999", "#E69F00", "#56B4E9", "#D55E00", "#009E73"]ax.set_axisbelow(True); ax.grid(axis="x", color="#e5e5e5", lw=0.6)ax.barh(comp.model, comp.AICc, color=cols)for i, (a, mi) in enumerate(zip(comp.AICc, comp.resid_I)): ax.text(a + 8, i, f"AICc {a:.0f}\nresidual I {mi:+.2f}", va="center", ha="left", fontsize=8, color="#111111")ax.set_xlim(0, comp.AICc.max() * 1.32)ax.invert_yaxis(); ax.set_xlabel("AICc on standardised data (lower is better)")ax.set_title("Fit and residual Moran's I by model")ax = axes[1]pos = np.arange(1, len(X_NAMES) + 1)bp1 = ax.boxplot([gwr.params[:, j] for j in range(1, 5)], positions=pos - 0.18, widths=0.3, patch_artist=True, showfliers=False)bp2 = ax.boxplot([mgwr.params[:, j] for j in range(1, 5)], positions=pos + 0.18, widths=0.3, patch_artist=True, showfliers=False)for bp, c in [(bp1, "#D55E00"), (bp2, "#009E73")]: for patch in bp["boxes"]: patch.set_facecolor(c); patch.set_alpha(0.55) for med in bp["medians"]: med.set_color("#111111")ax.scatter(pos, ols.betas[1:, 0], marker="D", color="#333333", zorder=4, s=28, label="OLS")ax.scatter(pos, err.betas[1:5, 0], marker="s", color="#56B4E9", edgecolor="#333333", zorder=4, s=28, label="Spatial error")ax.axhline(0, color="#777777", lw=0.8)ax.set_xticks(pos, [LABELS[k].replace(" ", "\n", 1) for k in X_NAMES])ax.set_ylabel("Coefficient (SD of ln price per SD)")ax.legend(handles=[Patch(facecolor="#D55E00", alpha=0.55, label="GWR local (IQR, whiskers 1.5 IQR)"), Patch(facecolor="#009E73", alpha=0.55, label="MGWR local"), *ax.get_legend_handles_labels()[0]], fontsize=7.5, frameon=False, loc="lower left")ax.set_title("Local coefficients against the global estimates"); ax.set_axisbelow(True); ax.grid(axis="y", color="#e5e5e5", lw=0.6)fig.tight_layout(); fig.savefig(OUT / "05-model-comparison.png"); plt.close(fig)
gdf[["msoa", "name", "lad", "ln_price", "gwr_r2", "gwr_cn", "geometry"]].assign( **{f"gwr_{k}": gwr.params[:, j] for j, k in enumerate(NAMES)}, **{f"mgwr_{k}": mgwr.params[:, j] for j, k in enumerate(NAMES)}, **{f"mgwr_t_{k}": mt_filt[:, j] for j, k in enumerate(NAMES)}).to_file(OUT / "gwr_mgwr_results.gpkg", driver="GPKG")metrics = {"gwr_bw": gwr_bw, "gwr_aicc": gwr.aicc, "gwr_r2": gwr.R2, "mgwr_aicc": mgwr.aicc, "mgwr_r2": mgwr.R2, "ols_aicc": glob["OLS"][2], "err_aicc": glob["Spatial error (ML)"][2], "mgwr_seconds": t_mgwr}(OUT / "metrics.json").write_text(json.dumps({k: round(float(v), 4) for k, v in metrics.items()}, indent=2))print("\nwrote figures, output/gwr_mgwr_results.gpkg and output/metrics.json")wy_data.py
"""Shared data preparation for the TechnicalGIS series "Spatial regression in Python".
Builds data/west_yorkshire_msoa.gpkg: the 2021 MSOAs of West Yorkshire with - the ONS median price paid for all dwellings, year ending March 2026 (formerly HPSSA dataset 2), - four Census 2021 variables from the Nomis bulk tables (TS067, TS044, TS045, TS006), - the ONS Open Geography 2021 MSOA boundaries (generalised, clipped) in British National Grid.
Run it on its own with `python wy_data.py`, or import load_west_yorkshire() from either part of the series.Data: Office for National Statistics and HM Land Registry, Census 2021 via Nomis, ONS Open Geography Portal(all Open Government Licence v3.0; boundaries contain OS data (c) Crown copyright and database right)."""import zipfilefrom pathlib import Path
import numpy as npimport pandas as pdimport geopandas as gpdimport requests
DATA = Path("data")CRS = "EPSG:27700"GPKG = DATA / "west_yorkshire_msoa.gpkg"
# West Yorkshire's five metropolitan districtsLADS = {"E08000032": "Bradford", "E08000033": "Calderdale", "E08000034": "Kirklees", "E08000035": "Leeds", "E08000036": "Wakefield"}
PRICE_URL = ("https://www.ons.gov.uk/file?uri=/peoplepopulationandcommunity/housing/datasets/" "medianhousepricesbymiddlelayersuperoutputarea/yearendingmarch2026/medianpricepaidformsoa.xlsx")PRICE_COL = "Year ending Mar 2026"NOMIS = "https://www.nomisweb.co.uk/output/census/2021/census2021-{t}.zip"MSOA_URL = ("https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/" "Middle_layer_Super_Output_Areas_December_2021_Boundaries_EW_BGC_V3/FeatureServer/0/query")
def download(url, path, **params): if not path.exists(): r = requests.get(url, params=params or None, timeout=900, headers={"User-Agent": "Mozilla/5.0 (TechnicalGIS tutorial)"}) r.raise_for_status() path.write_bytes(r.content) return path
def nomis_msoa(table): """One Census 2021 bulk table at MSOA level, indexed by the 2021 MSOA code.""" z = download(NOMIS.format(t=table), DATA / f"census2021-{table}.zip") with zipfile.ZipFile(z) as zf: df = pd.read_csv(zf.open(f"census2021-{table}-msoa.csv")) return df.set_index("geography code")
def build(): DATA.mkdir(exist_ok=True)
# 1. median price paid, all dwellings, by 2021 MSOA (table 1a); "[x]" = fewer than 5 sales xlsx = download(PRICE_URL, DATA / "medianpricepaidformsoa_ye_mar2026.xlsx") price = pd.read_excel(xlsx, sheet_name="1a", header=2, usecols=["Local authority code", "Local authority name", "MSOA code", "MSOA name", PRICE_COL]) price = price[price["Local authority code"].isin(LADS)].rename(columns={ "Local authority code": "lad_code", "Local authority name": "lad", "MSOA code": "msoa", "MSOA name": "name", PRICE_COL: "price"}) price["price"] = pd.to_numeric(price.price, errors="coerce")
# 2. Census 2021 explanatory variables (shares in per cent) q = nomis_msoa("ts067") acc = nomis_msoa("ts044") car = nomis_msoa("ts045") dens = nomis_msoa("ts006") census = pd.DataFrame({ "level4": 100 * q["Highest level of qualification: Level 4 qualifications and above"] / q["Highest level of qualification: Total: All usual residents aged 16 years and over"], "detached": 100 * acc["Accommodation type: Detached"] / acc["Accommodation type: Total: All households"], "no_car": 100 * car["Number of cars or vans: No cars or vans in household"] / car["Number of cars or vans: Total: All households"], "density": dens["Population Density: Persons per square kilometre; measures: Value"], })
# 3. 2021 MSOA boundaries (generalised to 20 m, clipped to the coastline), in British National Grid where = " OR ".join(f"MSOA21NM LIKE '{n} %'" for n in LADS.values()) geo = gpd.read_file(download(MSOA_URL, DATA / "msoa2021_west_yorkshire.geojson", where=where, outFields="MSOA21CD,MSOA21NM", outSR=27700, f="geojson")) geo = geo.set_crs(CRS, allow_override=True).rename(columns={"MSOA21CD": "msoa", "MSOA21NM": "msoa_nm"})
gdf = geo.merge(price, on="msoa", how="inner").merge(census, left_on="msoa", right_index=True, how="left") gdf["ln_price"] = np.log(gdf.price) gdf["ln_density"] = np.log(gdf.density) gdf = gdf.sort_values("msoa").reset_index(drop=True) gdf[["msoa", "msoa_nm", "name", "lad_code", "lad", "price", "ln_price", "level4", "detached", "no_car", "density", "ln_density", "geometry"]].to_file(GPKG, layer="msoa", driver="GPKG") return gdf
def load_west_yorkshire(drop_missing=True): """Return the West Yorkshire MSOA GeoDataFrame, building it on the first call.""" gdf = gpd.read_file(GPKG, layer="msoa") if GPKG.exists() else build() if drop_missing: gdf = gdf.dropna(subset=["price", "level4", "detached", "no_car", "density"]).reset_index(drop=True) return gdf
if __name__ == "__main__": g = load_west_yorkshire(drop_missing=False) print(f"{len(g)} MSOAs in West Yorkshire; {g.price.isna().sum()} without a median price; written to {GPKG}")