Source code: DBSCAN and HDBSCAN spatial clustering in Python: finding Glasgow's dining districts
Runnable Python scripts, requirements and console output for the tutorial “DBSCAN and HDBSCAN spatial clustering in Python: finding Glasgow's dining…
Last updated
These are the exact scripts used to produce every figure and number in DBSCAN and HDBSCAN spatial clustering in Python: finding Glasgow's dining districts. Download them, install the requirements into a fresh virtual environment and run them from this folder.
- dbscan_dining_districts.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 dbscan_dining_districts.pydbscan_dining_districts.py
"""DBSCAN and HDBSCAN spatial clustering in Python: finding Glasgow's dining districtsin Overture Maps places (restaurants, cafés, bars and takeaways).
TechnicalGIS tutorial: https://www.technicalgis.com/tutorials/dbscan-spatial-clustering-python/Data: Overture Maps Foundation places (CDLA-Permissive-2.0), ONS local authority boundaries (OGL v3)."""import jsonimport sysimport timeimport warningsfrom importlib.metadata import versionfrom pathlib import Path
import duckdbimport geopandas as gpdimport hdbscanimport matplotlibimport numpy as npimport pandas as pdimport requestsimport shapely
matplotlib.use("Agg") # render to files only; no GUI backendimport matplotlib.pyplot as pltfrom matplotlib.colors import ListedColormapfrom matplotlib.lines import Line2Dfrom matplotlib.patches import Patchfrom sklearn.cluster import DBSCAN, HDBSCANfrom sklearn.metrics import adjusted_rand_score, silhouette_scorefrom sklearn.neighbors import BallTree, NearestNeighbors
warnings.filterwarnings("ignore")plt.rcParams.update({"figure.dpi": 150, "savefig.bbox": "tight", "font.size": 9})
DATA = Path("data"); DATA.mkdir(exist_ok=True)OUT = Path("output"); OUT.mkdir(exist_ok=True)SEED = 42CRS = "EPSG:27700" # British National Grid, metresR_EARTH = 6_371_008.8 # mean Earth radius in metres (IUGG)LAD = "S12000049" # Glasgow CityRELEASE = "2026-10-21.0" # Overture keeps only recent releases; the script falls back to the latest oneFOOD = ("restaurant", "casual_eatery", "cafe", "coffee_shop", "fast_food_restaurant", "bar")ONS = "https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services"LAD_URL = f"{ONS}/Local_Authority_Districts_May_2026_Boundaries_UK_BGC/FeatureServer/0/query"
# colour-blind-safe qualitative palette (Paul Tol "muted" + "bright"), cycled for cluster idsTOL = ["#332288", "#88CCEE", "#44AA99", "#117733", "#999933", "#DDCC77", "#CC6677", "#882255", "#AA4499", "#4477AA", "#EE6677", "#228833", "#CCBB44", "#66CCEE", "#AA3377"]NOISE = "#c8c8c8"
def section(title): print(f"\n=== {title} ===")
def download(url, path, **params): if not path.exists(): r = requests.get(url, params=params or None, timeout=600, headers={"User-Agent": "TechnicalGIS-tutorial"}) r.raise_for_status() path.write_bytes(r.content) return path
def cluster_colours(labels): return np.array([NOISE if l < 0 else TOL[l % len(TOL)] for l in labels])
section("versions")print(f"python {sys.version.split()[0]} | scikit-learn {version('scikit-learn')} | hdbscan {version('hdbscan')} | " f"geopandas {version('geopandas')} | shapely {version('shapely')} | duckdb {version('duckdb')} | " f"numpy {version('numpy')}")
# ---------------------------------------------------------------- 1. data: Glasgow boundary + Overture placessection("Glasgow City boundary")boundary = gpd.read_file(download(LAD_URL, DATA / "glasgow_lad.geojson", where=f"LAD26CD='{LAD}'", outFields="LAD26CD,LAD26NM", outSR=27700, f="geojson"))boundary = boundary.set_crs(CRS, allow_override=True)city = boundary.geometry.iloc[0]xmin, ymin, xmax, ymax = boundary.to_crs(4326).total_bounds.round(4)print(f"{boundary.LAD26NM.iloc[0]}: {city.area / 1e6:.1f} km2, lon/lat bbox {xmin}, {ymin}, {xmax}, {ymax}")
section("Overture Maps places")cache = DATA / "glasgow_places.parquet"if not cache.exists(): stac = requests.get("https://stac.overturemaps.org/catalog.json", timeout=60).json() releases = [l["href"].split("/")[1] for l in stac["links"] if l.get("rel") == "child"] release = RELEASE if RELEASE in releases else stac["latest"] con = duckdb.connect() con.sql("INSTALL spatial; LOAD spatial; INSTALL httpfs; LOAD httpfs; SET s3_region = 'us-west-2'") files = con.sql(f""" SELECT assets.aws.alternate.s3.href AS url FROM read_parquet('https://stac.overturemaps.org/{release}/collections.parquet') WHERE collection = 'place' AND bbox.xmin <= {xmax} AND bbox.xmax >= {xmin} AND bbox.ymin <= {ymax} AND bbox.ymax >= {ymin}""").df().url.tolist() con.sql(f""" COPY (SELECT id, names.primary AS name, basic_category, taxonomy.hierarchy[1] AS top_category, confidence, operating_status, addresses[1].postcode AS postcode, ST_X(geometry) AS lon, ST_Y(geometry) AS lat, '{release}' AS release FROM read_parquet({files}) WHERE bbox.xmin BETWEEN {xmin} AND {xmax} AND bbox.ymin BETWEEN {ymin} AND {ymax}) TO '{cache.as_posix()}' (FORMAT parquet)""")places = pd.read_parquet(cache)print(f"Overture release {places.release.iloc[0]}: {len(places):,} places of all kinds in the bounding box")
g = gpd.GeoDataFrame(places, geometry=gpd.points_from_xy(places.lon, places.lat), crs=4326).to_crs(CRS)g = g[g.within(city)].copy()food = g[g.basic_category.isin(FOOD) & (g.confidence >= 0.5) & (g.operating_status.fillna("open") != "permanently_closed")].reset_index(drop=True)print(f"inside Glasgow City: {len(g):,} places; food and drink ({', '.join(FOOD)}), " f"confidence >= 0.5, not permanently closed: {len(food):,}")print(food.basic_category.value_counts().to_string())dup = food.duplicated(subset=["lon", "lat"]).sum()print(f"points sharing exact coordinates with another: {dup} ({dup / len(food):.1%})")food["district"] = food.postcode.str.upper().str.extract(r"^([A-Z]{1,2}\d{1,2}[A-Z]?)\s")[0]print(f"postcode district available for {food.district.notna().mean():.0%} of places")
X_m = np.column_stack([food.geometry.x, food.geometry.y]) # metres, British National GridX_rad = np.radians(food[["lat", "lon"]].to_numpy()) # haversine wants [lat, lon] in radians
# ---------------------------------------------------------------- 2. choose eps with a k-distance plotsection("k-distance plot")MIN_SAMPLES = 10nn = NearestNeighbors(n_neighbors=MIN_SAMPLES).fit(X_m) # the query point itself is neighbour 0kdist = np.sort(nn.kneighbors(X_m)[0][:, -1])
def knee(y): """Kneedle-style knee: the point of a sorted curve furthest below the chord from first to last value.""" x = np.linspace(0, 1, len(y)); yn = (y - y[0]) / (y[-1] - y[0]) return int(np.argmax(x - yn))
# the top 2% are isolated outliers several km from anything; they would stretch the chordk_i = knee(kdist[: int(0.98 * len(kdist))])print(f"distance to the {MIN_SAMPLES - 1}th nearest other place: median {np.median(kdist):.0f} m, " f"90th percentile {np.percentile(kdist, 90):.0f} m, max {kdist.max():,.0f} m")print(f"knee of the k-distance curve at rank {k_i} ({k_i / len(kdist):.0%}): {kdist[k_i]:.0f} m")EPS = 150.0print(f"chosen eps = {EPS:.0f} m ({(kdist <= EPS).mean():.0%} of places have {MIN_SAMPLES - 1} others within it)")
fig, (a1, a2) = plt.subplots(1, 2, figsize=(11, 4.8), gridspec_kw={"width_ratios": [1.15, 1]})boundary.boundary.plot(ax=a1, color="#555555", lw=0.6)cols = {"restaurant": "#CC6677", "casual_eatery": "#DDCC77", "cafe": "#117733", "coffee_shop": "#44AA99", "fast_food_restaurant": "#882255", "bar": "#332288"}for c in FOOD: s = food[food.basic_category == c] a1.scatter(s.geometry.x, s.geometry.y, s=2.5, color=cols[c], label=f"{c.replace('_', ' ')} ({len(s):,})", lw=0)a1.legend(loc="lower left", fontsize=7, markerscale=3, frameon=True, title="Overture basic_category", title_fontsize=7)a1.set_title(f"{len(food):,} food and drink places in Glasgow City")a1.set_axis_off(); a1.set_aspect("equal")a2.plot(np.arange(len(kdist)), kdist, color="#332288", lw=1.5)a2.axhline(EPS, color="#CC6677", ls="--", lw=1)a2.annotate(f"eps = {EPS:.0f} m", (0.02 * len(kdist), EPS), xytext=(0, 5), textcoords="offset points", color="#CC6677")a2.scatter([k_i], [kdist[k_i]], color="#117733", zorder=3, s=25)a2.annotate(f"knee ≈ {kdist[k_i]:.0f} m", (k_i, kdist[k_i]), xytext=(-90, 25), textcoords="offset points", color="#117733", arrowprops={"arrowstyle": "->", "color": "#117733"})a2.set_yscale("log"); a2.set_ylim(5, kdist.max() * 1.2)a2.set_xlabel("places sorted by k-distance"); a2.set_ylabel(f"distance to {MIN_SAMPLES - 1}th nearest neighbour (m, log)")a2.set_title(f"k-distance plot (min_samples = {MIN_SAMPLES})"); a2.grid(alpha=0.3)fig.savefig(OUT / "01-places-k-distance.png"); plt.close(fig)
# ---------------------------------------------------------------- 3. DBSCAN: haversine on radians vs metressection("DBSCAN: haversine on lat/lon vs Euclidean on metres")t0 = time.perf_counter()db_hav = DBSCAN(eps=EPS / R_EARTH, min_samples=MIN_SAMPLES, metric="haversine", algorithm="ball_tree").fit(X_rad)t_hav = time.perf_counter() - t0t0 = time.perf_counter()db = DBSCAN(eps=EPS, min_samples=MIN_SAMPLES).fit(X_m)t_m = time.perf_counter() - t0lab = db.labels_for name, l, t in [("haversine (radians)", db_hav.labels_, t_hav), ("projected metres", lab, t_m)]: print(f"{name:20s}: {l.max() + 1} clusters, noise {np.mean(l < 0):.1%}, {t * 1000:.0f} ms")print(f"adjusted Rand index between the two: {adjusted_rand_score(db_hav.labels_, lab):.4f}; " f"points labelled differently as noise/non-noise: {np.sum((db_hav.labels_ < 0) != (lab < 0))}")bad = DBSCAN(eps=EPS, min_samples=MIN_SAMPLES, metric="haversine").fit(X_rad).labels_print(f"mistake check: eps={EPS:.0f} passed straight to haversine (radians) -> {bad.max() + 1} cluster(s), " f"noise {np.mean(bad < 0):.0%}")deg = DBSCAN(eps=EPS / 111_320, min_samples=MIN_SAMPLES).fit(food[["lon", "lat"]].to_numpy()).labels_print(f"mistake check: Euclidean on raw degrees (eps = {EPS:.0f}/111,320) -> {deg.max() + 1} clusters, " f"ARI vs metres {adjusted_rand_score(deg, lab):.3f}")core = np.zeros(len(lab), bool); core[db.core_sample_indices_] = Trueprint(f"core points {core.sum():,}, border points {np.sum(~core & (lab >= 0)):,}, noise {np.sum(lab < 0):,}")sizes = pd.Series(lab[lab >= 0]).value_counts()print(f"largest cluster {sizes.iloc[0]:,} places ({sizes.iloc[0] / len(lab):.0%} of all); median cluster size " f"{int(sizes.median())}")
# ---------------------------------------------------------------- 4. sensitivity to eps and min_samplessection("DBSCAN sensitivity")EPS_GRID = [75, 100, 150, 200, 300, 400]MS_GRID = [5, 10, 15, 20, 30]ncl = np.zeros((len(MS_GRID), len(EPS_GRID)), int); noise = np.zeros_like(ncl, float)big = np.zeros_like(ncl, float)for i, ms in enumerate(MS_GRID): for j, e in enumerate(EPS_GRID): l = DBSCAN(eps=e, min_samples=ms).fit(X_m).labels_ ncl[i, j] = l.max() + 1; noise[i, j] = np.mean(l < 0) big[i, j] = pd.Series(l[l >= 0]).value_counts().max() / len(l) if (l >= 0).any() else 0print("rows min_samples, columns eps (m): number of clusters / % noise / largest cluster as % of places")print(pd.DataFrame([[f"{ncl[i, j]:>3} / {noise[i, j]:4.0%} / {big[i, j]:4.0%}" for j in range(len(EPS_GRID))] for i in range(len(MS_GRID))], index=MS_GRID, columns=EPS_GRID).to_string())
fig, axes = plt.subplots(1, 2, figsize=(11, 3.8))for ax, M, title, cmap, fmt in [(axes[0], ncl, "Number of clusters", "viridis", "{:d}"), (axes[1], noise * 100, "Noise (% of places)", "cividis", "{:.0f}")]: im = ax.imshow(M, cmap=cmap, aspect="auto") for i in range(M.shape[0]): for j in range(M.shape[1]): v = M[i, j] ax.text(j, i, fmt.format(int(v)) if fmt == "{:d}" else fmt.format(v), ha="center", va="center", color="white" if v < (M.max() + M.min()) / 2 else "black", fontsize=8) ax.set_xticks(range(len(EPS_GRID)), EPS_GRID); ax.set_yticks(range(len(MS_GRID)), MS_GRID) ax.set_xlabel("eps (m)"); ax.set_ylabel("min_samples"); ax.set_title(title) jj, ii = EPS_GRID.index(int(EPS)), MS_GRID.index(MIN_SAMPLES) ax.add_patch(plt.Rectangle((jj - 0.5, ii - 0.5), 1, 1, fill=False, ec="#CC6677", lw=2.5)) fig.colorbar(im, ax=ax, shrink=0.85, label="clusters" if M is ncl else "% of places")fig.suptitle("DBSCAN on Glasgow food and drink places: sensitivity to eps and min_samples (chosen: red box)", y=1.02)fig.savefig(OUT / "03-dbscan-sensitivity.png"); plt.close(fig)
# ---------------------------------------------------------------- 5. HDBSCAN for variable densitysection("HDBSCAN")MIN_CLUSTER = 15t0 = time.perf_counter()hdb = HDBSCAN(min_cluster_size=MIN_CLUSTER, min_samples=MIN_SAMPLES, cluster_selection_method="eom", copy=True).fit(X_m)t_hdb = time.perf_counter() - t0hl = hdb.labels_hs = pd.Series(hl[hl >= 0]).value_counts()print(f"sklearn HDBSCAN(min_cluster_size={MIN_CLUSTER}, min_samples={MIN_SAMPLES}): {hl.max() + 1} clusters, " f"noise {np.mean(hl < 0):.1%}, largest {hs.iloc[0]} places, median size {int(hs.median())}, " f"{t_hdb * 1000:.0f} ms")hdb_hav = HDBSCAN(min_cluster_size=MIN_CLUSTER, min_samples=MIN_SAMPLES, metric="haversine").fit(X_rad)print(f"same on lat/lon radians with metric='haversine': {hdb_hav.labels_.max() + 1} clusters, " f"ARI vs metres {adjusted_rand_score(hdb_hav.labels_, hl):.4f}")leaf = HDBSCAN(min_cluster_size=MIN_CLUSTER, min_samples=MIN_SAMPLES, cluster_selection_method="leaf").fit(X_m).labels_print(f"cluster_selection_method='leaf': {leaf.max() + 1} clusters, noise {np.mean(leaf < 0):.1%}")for mcs in (10, 25, 50): l = HDBSCAN(min_cluster_size=mcs, min_samples=MIN_SAMPLES).fit(X_m).labels_ print(f"min_cluster_size={mcs:>2}: {l.max() + 1} clusters, noise {np.mean(l < 0):.1%}")print(f"ARI DBSCAN vs HDBSCAN: {adjusted_rand_score(lab, hl):.3f}")
# per-cluster density in places per km2 of convex hull: shows why one eps cannot fit every clusterfood["hdb"] = hldens = []for c, grp in food[food.hdb >= 0].groupby("hdb"): a = shapely.convex_hull(shapely.MultiPoint(grp.geometry.tolist())).area / 1e6 dens.append(len(grp) / max(a, 1e-3))dens = np.array(dens)print(f"HDBSCAN cluster density (places per km2 of convex hull): min {dens.min():.0f}, median {np.median(dens):.0f}, " f"max {dens.max():.0f} ({dens.max() / dens.min():.0f}x range)")
# the hdbscan package gives the condensed tree and DBCVhp = hdbscan.HDBSCAN(min_cluster_size=MIN_CLUSTER, min_samples=MIN_SAMPLES, gen_min_span_tree=True).fit(X_m)print(f"hdbscan package: {hp.labels_.max() + 1} clusters, ARI vs sklearn {adjusted_rand_score(hp.labels_, hl):.4f}, " f"relative_validity_ (fast DBCV approximation) {hp.relative_validity_:.3f}")print(f"membership strength: median {np.median(hp.probabilities_[hp.labels_ >= 0]):.2f}; outlier scores > 0.9: " f"{np.sum(hp.outlier_scores_ > 0.9)}")
centre = (258_900, 665_300) # George Squarezoom = (centre[0] - 4500, centre[0] + 4500, centre[1] - 3000, centre[1] + 3200)fig, axes = plt.subplots(1, 2, figsize=(12, 4.6), layout="constrained")for ax, l, title in [(axes[0], lab, f"DBSCAN eps={EPS:.0f} m, min_samples={MIN_SAMPLES}: {lab.max() + 1} clusters, " f"{np.mean(lab < 0):.0%} noise"), (axes[1], hl, f"HDBSCAN min_cluster_size={MIN_CLUSTER}: {hl.max() + 1} clusters, " f"{np.mean(hl < 0):.0%} noise")]: boundary.boundary.plot(ax=ax, color="#555555", lw=0.6) n = l < 0 ax.scatter(X_m[n, 0], X_m[n, 1], s=3, color=NOISE, lw=0, label="noise") ax.scatter(X_m[~n, 0], X_m[~n, 1], s=5, c=cluster_colours(l[~n]), lw=0) ax.set_xlim(zoom[:2]); ax.set_ylim(zoom[2:]); ax.set_aspect("equal") ax.set_xticks([]); ax.set_yticks([]); ax.set_title(title, fontsize=8.5) ax.plot([zoom[0] + 300, zoom[0] + 1300], [zoom[2] + 300] * 2, color="black", lw=2) ax.text(zoom[0] + 800, zoom[2] + 420, "1 km", ha="center", fontsize=7) ax.legend(handles=[Line2D([], [], marker="o", ls="", color=NOISE, label="noise"), Line2D([], [], marker="o", ls="", color=TOL[0], label="cluster (colour = id)")], loc="upper right", fontsize=7, frameon=True)fig.suptitle("Central Glasgow: one global eps chains the centre into a single large cluster; " "HDBSCAN splits it by local density")fig.savefig(OUT / "02-dbscan-vs-hdbscan.png"); plt.close(fig)
# the minimum density DBSCAN demands of a core point: min_samples places in a circle of radius epsdb_floor = MIN_SAMPLES / (np.pi * (EPS / 1000) ** 2)print(f"DBSCAN density floor: {MIN_SAMPLES} places in a {EPS:.0f} m circle = {db_floor:.0f} places per km2; " f"{np.sum(dens < db_floor)} of {len(dens)} HDBSCAN clusters are sparser than that")fig, (a1, a2) = plt.subplots(1, 2, figsize=(12, 4.4), gridspec_kw={"width_ratios": [1.3, 1]})# select_clusters=True draws ellipses that fail with matplotlib 3.11, so the tree is drawn plainhp.condensed_tree_.plot(select_clusters=False, axis=a1, colorbar=True, cmap="viridis", log_size=True)a1.set_title("HDBSCAN condensed tree (λ = 1 / distance in 1/m; width = log number of places)")order = np.sort(dens)[::-1]a2.bar(np.arange(len(order)), order, color="#44AA99", width=0.85)a2.axhline(db_floor, color="#CC6677", ls="--", lw=1.2)a2.text(0.97, 0.97, f"dashed line: DBSCAN core density floor\n{MIN_SAMPLES} places within {EPS:.0f} m " f"= {db_floor:.0f} per km²", transform=a2.transAxes, ha="right", va="top", color="#CC6677", fontsize=8, bbox={"fc": "white", "ec": "none", "alpha": 0.9})a2.set_yscale("log"); a2.grid(alpha=0.3, axis="y")a2.set_xlabel("HDBSCAN clusters, densest first"); a2.set_ylabel("places per km² of convex hull (log)")a2.set_title(f"Cluster densities span a {dens.max() / dens.min():.0f}x range")fig.savefig(OUT / "04-hdbscan-condensed-tree.png"); plt.close(fig)
# ---------------------------------------------------------------- 6. hulls, profiles and noisesection("cluster polygons: convex vs concave hulls")rows = []for c, grp in food[food.hdb >= 0].groupby("hdb"): mp = shapely.MultiPoint(grp.geometry.tolist()) convex = shapely.convex_hull(mp) concave = shapely.concave_hull(mp, ratio=0.3) top = grp.basic_category.value_counts() rows.append({"cluster": c, "n": len(grp), "district": grp.district.mode().iloc[0] if grp.district.notna().any() else "", "convex_km2": convex.area / 1e6, "concave_km2": concave.area / 1e6, "top": f"{top.index[0].replace('_', ' ')} {top.iloc[0] / len(grp):.0%}", "bars": (grp.basic_category == "bar").mean(), "geometry": concave, "convex": convex})hulls = gpd.GeoDataFrame(rows, geometry="geometry", crs=CRS)hulls["density"] = hulls.n / hulls.concave_km2.clip(lower=0.005)print(f"{len(hulls)} clusters: convex hulls cover {hulls.convex_km2.sum():.2f} km2, " f"concave hulls (ratio 0.3) {hulls.concave_km2.sum():.2f} km2 " f"({hulls.concave_km2.sum() / hulls.convex_km2.sum():.0%} of convex)")print(f"clustered places: {hulls.n.sum():,} of {len(food):,} ({hulls.n.sum() / len(food):.0%}) " f"in {hulls.concave_km2.sum() / (city.area / 1e6):.1%} of the city's area")print("ten largest clusters:")print(hulls.sort_values("n", ascending=False).head(10)[["cluster", "n", "district", "concave_km2", "convex_km2", "top", "bars"]] .to_string(index=False, float_format=lambda v: f"{v:.2f}"))noise_pts = food[food.hdb < 0]print(f"noise: {len(noise_pts):,} places; most common categories: " + ", ".join(f"{k.replace('_', ' ')} {v:.0%}" for k, v in noise_pts.basic_category.value_counts(normalize=True) .head(3).items()))nn_noise = BallTree(X_m[hl >= 0]).query(X_m[hl < 0], k=1)[0][:, 0]print(f"distance from each noise place to the nearest clustered place: median {np.median(nn_noise):.0f} m, " f"{np.mean(nn_noise > 500):.0%} more than 500 m away")hulls.drop(columns="convex").to_file(OUT / "glasgow_dining_clusters.gpkg", layer="hdbscan_concave_hulls", driver="GPKG")food[["id", "name", "basic_category", "district", "hdb", "geometry"]].assign(dbscan=lab).to_file( OUT / "glasgow_dining_clusters.gpkg", layer="places", driver="GPKG")
fig, axes = plt.subplots(1, 2, figsize=(12, 4.8), layout="constrained")for ax, which, title in [(axes[0], "convex", "Convex hulls"), (axes[1], "geometry", "Concave hulls (ratio = 0.3)")]: ax.scatter(noise_pts.geometry.x, noise_pts.geometry.y, s=3, color=NOISE, lw=0) gs = gpd.GeoSeries(hulls[which], crs=CRS) gs.plot(ax=ax, color=[TOL[c % len(TOL)] for c in hulls.cluster], alpha=0.35, edgecolor="black", lw=0.6) cl = food[food.hdb >= 0] ax.scatter(cl.geometry.x, cl.geometry.y, s=3, c=cluster_colours(cl.hdb), lw=0) for _, r in hulls[hulls.n >= 60].iterrows(): p = r.geometry.representative_point() if zoom[0] < p.x < zoom[1] and zoom[2] < p.y < zoom[3]: ax.annotate(f"{r.district} ({r.n})", (p.x, p.y), fontsize=7, ha="center", bbox={"boxstyle": "round,pad=0.15", "fc": "white", "ec": "none", "alpha": 0.8}) ax.set_xlim(zoom[:2]); ax.set_ylim(zoom[2:]); ax.set_aspect("equal"); ax.set_xticks([]); ax.set_yticks([]) ax.set_title(f"{title}: {gs.area.sum() / 1e6:.2f} km² in total") ax.plot([zoom[0] + 300, zoom[0] + 1300], [zoom[2] + 300] * 2, color="black", lw=2) ax.text(zoom[0] + 800, zoom[2] + 420, "1 km", ha="center", fontsize=7)axes[1].legend(handles=[Patch(fc=TOL[1], alpha=0.5, ec="black", label="HDBSCAN cluster polygon"), Line2D([], [], marker="o", ls="", color=NOISE, label="noise place")], loc="upper right", fontsize=7)fig.suptitle("HDBSCAN dining districts as polygons; labels give the most common postcode district and the " "number of places (clusters of 60+)")fig.savefig(OUT / "05-cluster-hulls.png"); plt.close(fig)
# ---------------------------------------------------------------- 7. internal validation (with caveats)section("internal validation: silhouette and DBCV")val = {}for name, l in [("DBSCAN", lab), ("HDBSCAN", hl)]: m = l >= 0 sil = silhouette_score(X_m[m], l[m], random_state=SEED) dbcv = hdbscan.validity.validity_index(X_m.astype(np.float64), l.astype(np.intp)) val[name] = {"silhouette": round(float(sil), 3), "dbcv": round(float(dbcv), 3), "clusters": int(l.max() + 1), "noise": round(float(np.mean(~m)), 3)} print(f"{name:8s}: silhouette (non-noise only) {sil:.3f} | DBCV {dbcv:.3f} | {l.max() + 1} clusters | " f"noise {np.mean(~m):.1%}")rng = np.random.default_rng(SEED)lo, hi = X_m.min(0), X_m.max(0)U = rng.uniform(lo, hi, size=(len(X_m), 2))U = U[gpd.GeoSeries(gpd.points_from_xy(U[:, 0], U[:, 1]), crs=CRS).within(city).to_numpy()]ul = HDBSCAN(min_cluster_size=MIN_CLUSTER, min_samples=MIN_SAMPLES).fit(U).labels_print(f"null check: {len(U):,} uniformly random points in the city -> HDBSCAN finds {ul.max() + 1} clusters, " f"noise {np.mean(ul < 0):.0%}" + (f", DBCV {hdbscan.validity.validity_index(U, ul.astype(np.intp)):.3f}" if ul.max() >= 1 else ""))udb = DBSCAN(eps=EPS, min_samples=MIN_SAMPLES).fit(U).labels_print(f"null check: DBSCAN (eps={EPS:.0f} m, min_samples={MIN_SAMPLES}) on the same random points -> " f"{udb.max() + 1} clusters, noise {np.mean(udb < 0):.0%}")
# ---------------------------------------------------------------- 8. scaling with a BallTreesection("scaling: BallTree neighbourhoods")allpts = np.radians(g[["lat", "lon"]].to_numpy())print(f"all Overture places in Glasgow City: {len(allpts):,}")t0 = time.perf_counter(); tree = BallTree(allpts, metric="haversine"); t_build = time.perf_counter() - t0t0 = time.perf_counter(); counts = tree.query_radius(allpts, r=EPS / R_EARTH, count_only=True)t_q = time.perf_counter() - t0print(f"BallTree build {t_build * 1000:.0f} ms; {len(allpts):,} radius queries of {EPS:.0f} m in {t_q * 1000:.0f} ms; " f"neighbours per point median {int(np.median(counts))}, max {counts.max():,}")print(f"a dense haversine distance matrix for all of them would need {len(allpts) ** 2 * 8 / 1e9:.1f} GB")sizes_n = [2_000, 4_000, 8_000, 16_000, 32_000, len(allpts)]DBSCAN(eps=EPS / R_EARTH, min_samples=MIN_SAMPLES, metric="haversine", algorithm="brute").fit(allpts[:500]) # warm-uptiming = []for n in sizes_n: idx = rng.choice(len(allpts), n, replace=False) Xs = allpts[idx] row = {"n": n} for algo in ("ball_tree", "brute"): if algo == "brute" and n > 16_000: continue t0 = time.perf_counter() DBSCAN(eps=EPS / R_EARTH, min_samples=MIN_SAMPLES, metric="haversine", algorithm=algo).fit(Xs) row[algo] = time.perf_counter() - t0 t0 = time.perf_counter() HDBSCAN(min_cluster_size=MIN_CLUSTER, min_samples=MIN_SAMPLES, metric="haversine").fit(Xs) row["hdbscan"] = time.perf_counter() - t0 timing.append(row) print(f"n={n:>6,}: DBSCAN ball_tree {row['ball_tree']:.2f} s" + (f" | brute {row['brute']:.2f} s" if "brute" in row else "") + f" | HDBSCAN {row['hdbscan']:.2f} s")tdf = pd.DataFrame(timing)t0 = time.perf_counter()HDBSCAN(min_cluster_size=MIN_CLUSTER, min_samples=MIN_SAMPLES).fit(np.column_stack([g.geometry.x, g.geometry.y]))print(f"HDBSCAN on all {len(g):,} places in projected metres (KD-tree): {time.perf_counter() - t0:.2f} s")fig, ax = plt.subplots(figsize=(6.5, 4))for col, c, lbl in [("ball_tree", "#332288", "DBSCAN, BallTree (haversine)"), ("brute", "#CC6677", "DBSCAN, brute force (haversine)"), ("hdbscan", "#117733", "HDBSCAN (haversine)")]: d = tdf.dropna(subset=[col]) ax.plot(d.n, d[col], marker="o", color=c, label=lbl)ax.set_xscale("log"); ax.set_yscale("log"); ax.grid(alpha=0.3, which="both")ax.set_xticks(sizes_n, [f"{n / 1000:.0f}k" for n in sizes_n]); ax.minorticks_off()ax.set_xlabel("number of points (random samples of all Glasgow places)"); ax.set_ylabel("run time (s, log)")ax.set_title(f"Run time with eps = {EPS:.0f} m, min_samples = {MIN_SAMPLES} (single thread)")ax.legend(fontsize=8)fig.savefig(OUT / "06-scaling-balltree.png"); plt.close(fig)
json.dump({"n_places": len(food), "eps_m": EPS, "min_samples": MIN_SAMPLES, "validation": val, "timing": timing}, open(OUT / "metrics.json", "w"), indent=2)print("\nwrote output/glasgow_dining_clusters.gpkg, output/metrics.json and 6 figures")