Source code: Fast vector geoprocessing in Python: spatial indexes, overlays and modern file formats

Runnable Python scripts, requirements and console output for the tutorial “Fast vector geoprocessing in Python: spatial indexes, overlays and modern file…

Last updated

These are the exact scripts used to produce every figure and number in Fast vector geoprocessing in Python: spatial indexes, overlays and modern file formats. Download them, install the requirements into a fresh virtual environment and run them from this folder.

Run it
python -m venv .venv
.venv\Scripts\activate # macOS/Linux: source .venv/bin/activate
pip install -r requirements.txt
python vector_performance.py

vector_performance.py

vector_performance.py
"""Fast vector geoprocessing with GeoPandas and Shapely 2.
Benchmarks point-in-polygon joins (naive loop vs STRtree vs sjoin), buffers,
overlays and dissolves, then compares Shapefile, GeoPackage, GeoParquet and
FlatGeobuf for writing, reading and bounding-box queries.
Run with: python vector_performance.py
"""
import time
import urllib.request
from contextlib import contextmanager
from pathlib import Path
import geopandas as gpd
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import shapely
from shapely.geometry import box
HERE = Path(__file__).parent
DATA, OUT = HERE / "data", HERE / "output"
DATA.mkdir(exist_ok=True)
OUT.mkdir(exist_ok=True)
plt.rcParams.update({"figure.dpi": 150, "savefig.bbox": "tight", "font.size": 9})
pd.set_option("display.width", 120)
rng = np.random.default_rng(42)
timings = {}
def section(title: str) -> None:
print(f"\n=== {title} ===")
@contextmanager
def timer(label: str):
t0 = time.perf_counter()
yield
timings[label] = time.perf_counter() - t0
print(f"{label:<42s} {timings[label]:8.3f} s")
def fetch(name: str) -> Path:
"""Download a Natural Earth zip (scale taken from the file name) once into ./data."""
dest = DATA / name
if not dest.exists():
scale = name.split("_")[1] # e.g. "10m"
urllib.request.urlretrieve(f"https://naciscdn.org/naturalearth/{scale}/cultural/{name}", dest)
return dest
section("versions")
print("geopandas", gpd.__version__, "| shapely", shapely.__version__, "| GEOS", shapely.geos_version_string)
# --- Step 1: build a realistic test dataset -------------------------------------
# 250,000 "incident" points clustered around UK towns (weighted by population),
# plus a 5 km analysis grid over Great Britain, all in British National Grid.
countries = gpd.read_file(fetch("ne_50m_admin_0_countries.zip"), engine="pyogrio")
uk = countries.loc[countries.ADM0_A3 == "GBR", ["geometry"]].to_crs(27700)
gb = uk.explode(index_parts=False).reset_index(drop=True) # parts share one label until reset
gb = gb.loc[[gb.area.idxmax()]] # the island of Great Britain only
places = gpd.read_file(fetch("ne_10m_populated_places.zip"), engine="pyogrio")
towns = places.loc[places.ADM0_A3 == "GBR"].to_crs(27700).clip(gb)
N = 250_000
w = towns.POP_MAX.to_numpy(float)
centre = rng.choice(len(towns), size=N, p=w / w.sum())
xy = np.column_stack([towns.geometry.x.to_numpy()[centre], towns.geometry.y.to_numpy()[centre]])
xy += rng.normal(scale=8_000, size=(N, 2))
noise = rng.random(N) < 0.25 # a quarter of points spread uniformly
minx, miny, maxx, maxy = gb.total_bounds
xy[noise] = np.column_stack([rng.uniform(minx, maxx, noise.sum()), rng.uniform(miny, maxy, noise.sum())])
points = gpd.GeoDataFrame(
{"id": np.arange(N), "category": rng.choice(["burglary", "vehicle", "criminal damage", "theft"], N),
"value": rng.gamma(2.0, 50.0, N).round(2)},
geometry=gpd.points_from_xy(xy[:, 0], xy[:, 1]), crs=27700)
points = points[points.intersects(gb.geometry.iloc[0])].reset_index(drop=True)
cell = 5_000
gx, gy = np.meshgrid(np.arange(minx // cell * cell, maxx, cell), np.arange(miny // cell * cell, maxy, cell))
grid = gpd.GeoDataFrame(geometry=shapely.box(gx.ravel(), gy.ravel(), gx.ravel() + cell, gy.ravel() + cell),
crs=27700)
grid = grid[grid.intersects(gb.geometry.iloc[0])].reset_index(drop=True)
grid["cell_id"] = np.arange(len(grid))
# 100 km "region" code, handy for dissolving later
grid["region"] = ((grid.geometry.bounds.minx // 100_000).astype(int).astype(str) + "-"
+ (grid.geometry.bounds.miny // 100_000).astype(int).astype(str))
section("test data")
print(f"towns used as cluster centres: {len(towns)}")
print(f"points: {len(points):,} grid cells: {len(grid):,} regions: {grid.region.nunique()}")
print(points.head().to_string())
# --- Step 2: point-in-polygon, three ways -----------------------------------------
section("point-in-polygon")
sample = points.iloc[:2_000]
with timer("naive loop (2,000 points)"):
naive = []
for pt in sample.geometry:
hit = grid.index[grid.contains(pt)]
naive.append(hit[0] if len(hit) else -1)
per_point = timings["naive loop (2,000 points)"] / len(sample)
print(f"{' -> extrapolated to all points':<42s} {per_point * len(points):8.1f} s")
timings["naive loop (extrapolated)"] = per_point * len(points)
with timer("STRtree.query, all points"):
tree = shapely.STRtree(grid.geometry.values.data)
pt_idx, poly_idx = tree.query(points.geometry.values.data, predicate="within")
with timer("gpd.sjoin, all points"):
joined = gpd.sjoin(points, grid[["cell_id", "geometry"]], how="inner", predicate="within")
assert np.array_equal(np.sort(pt_idx), np.sort(joined.index.to_numpy()))
check = pd.Series(poly_idx, index=pt_idx).sort_index()
assert (check.iloc[:2_000].to_numpy() == np.array(naive)[check.index[check.index < 2_000]]).all()
print(f"matched {len(joined):,} of {len(points):,} points; all three methods agree")
speedup = timings["naive loop (extrapolated)"] / timings["gpd.sjoin, all points"]
print(f"sjoin speed-up over naive loop: ~{speedup:,.0f}x")
counts = joined.groupby("cell_id").size().rename("n_points")
grid = grid.join(counts, on="cell_id").fillna({"n_points": 0}).astype({"n_points": int})
print(grid.sort_values("n_points", ascending=False).head(5)[["cell_id", "region", "n_points"]].to_string())
# --- Step 3: buffers ----------------------------------------------------------------
section("buffers")
subset = points.iloc[:50_000]
with timer("buffer 500 m, quad_segs=16 (default)"):
b16 = subset.buffer(500)
with timer("buffer 500 m, quad_segs=4"):
b4 = subset.buffer(500, resolution=4)
print(f"vertices per buffer: {shapely.get_num_coordinates(b16.values.data[0])} vs "
f"{shapely.get_num_coordinates(b4.values.data[0])}; area error of quad_segs=4: "
f"{(1 - b4.area.mean() / (np.pi * 500 ** 2)) * 100:.2f}%")
# --- Step 4: overlay & clip -----------------------------------------------------------
section("overlay")
burglary = points[points.category == "burglary"].iloc[:20_000]
with timer("buffer + unary_union (20,000 x 1 km)"):
zones = gpd.GeoDataFrame(geometry=[burglary.buffer(1_000, resolution=8).unary_union], crs=27700)
zones = zones.explode(index_parts=False).reset_index(drop=True)
print(f"merged into {len(zones):,} hotspot polygons")
with timer("overlay(grid, zones, 'intersection')"):
inter = gpd.overlay(grid[["cell_id", "region", "geometry"]], zones, how="intersection")
inter["area_km2"] = inter.area / 1e6
cover = inter.groupby("cell_id").area_km2.sum()
print(f"{len(inter):,} intersection pieces; {(cover > 12.5).sum():,} cells are more than half covered")
# --- Step 5: dissolve ---------------------------------------------------------------
section("dissolve")
with timer("dissolve(by='region') [unary_union]"):
regions = grid.dissolve(by="region", aggfunc={"n_points": "sum"})
with timer("coverage_union_all per region"):
cov = {r: shapely.coverage_union_all(g.geometry.values.data) for r, g in grid.groupby("region")}
diff = max(abs(regions.loc[r].geometry.area - g.area) for r, g in cov.items())
print(f"{len(regions)} regions; max area difference between methods: {diff:.6f} m²")
# --- Step 6: file formats -------------------------------------------------------------
section("file formats")
formats = {
"Shapefile": ("points.shp", "ESRI Shapefile"),
"GeoPackage": ("points.gpkg", "GPKG"),
"FlatGeobuf": ("points.fgb", "FlatGeobuf"),
"GeoParquet": ("points.parquet", None),
}
query_box = box(420_000, 420_000, 450_000, 450_000) # 30 x 30 km around Sheffield/Manchester
rows = []
for name, (fname, driver) in formats.items():
path = OUT / fname
for p in OUT.glob(path.stem + ".*"):
if p.suffix in {".shp", ".shx", ".dbf", ".prj", ".cpg", ".gpkg", ".fgb", ".parquet"}:
p.unlink()
t0 = time.perf_counter()
if driver:
points.to_file(path, driver=driver, engine="pyogrio")
else:
points.to_parquet(path)
t_write = time.perf_counter() - t0
t0 = time.perf_counter()
back = gpd.read_file(path, engine="pyogrio") if driver else gpd.read_parquet(path)
t_read = time.perf_counter() - t0
assert len(back) == len(points)
t0 = time.perf_counter()
if driver:
sub = gpd.read_file(path, engine="pyogrio", bbox=query_box.bounds)
else: # geopandas 0.13 has no bbox filter for Parquet: read, then filter
sub = gpd.read_parquet(path)
sub = sub[sub.intersects(query_box)]
t_bbox = time.perf_counter() - t0
size = sum(p.stat().st_size for p in OUT.glob(path.stem + ".*")
if p.suffix in {".shp", ".shx", ".dbf", ".prj", ".cpg", ".gpkg", ".fgb", ".parquet"})
rows.append({"format": name, "size_MB": size / 1e6, "write_s": t_write, "read_s": t_read,
"bbox_read_s": t_bbox, "bbox_rows": len(sub)})
fmt = pd.DataFrame(rows).set_index("format").round({"size_MB": 1, "write_s": 2, "read_s": 2, "bbox_read_s": 3})
print(fmt.to_string())
fmt.to_html(OUT / "formats_table.html", border=0)
# --- Step 7: figures --------------------------------------------------------------------
fig, ax = plt.subplots(figsize=(7.5, 2.8))
labels = ["naive loop (extrapolated)", "STRtree.query, all points", "gpd.sjoin, all points"]
vals = [timings[k] for k in labels]
bars = ax.barh(["Naive loop\n(extrapolated)", "shapely.STRtree\n.query()", "geopandas\n.sjoin()"], vals,
color=["#adb5bd", "#2f9e44", "#1d6fa3"])
ax.set_xscale("log")
ax.invert_yaxis()
for b, v in zip(bars, vals):
ax.text(v * 1.15, b.get_y() + b.get_height() / 2, f"{v:,.2f} s", va="center")
ax.set_xlim(min(vals) / 3, max(vals) * 8)
ax.set_xlabel("Seconds (log scale)")
ax.set_title(f"Point-in-polygon: {len(points):,} points x {len(grid):,} grid cells")
fig.savefig(OUT / "01-pip-timings.png")
plt.close(fig)
fig, ax = plt.subplots(figsize=(6, 7.5))
grid.plot(column="n_points", ax=ax, cmap="magma_r", scheme="NaturalBreaks", k=7, linewidth=0,
legend=True, legend_kwds={"title": "Points per 5 km cell", "loc": "upper left", "fontsize": 7, "fmt": "{:.0f}"})
gb.boundary.plot(ax=ax, color="#495057", linewidth=0.4)
ax.set_axis_off()
ax.set_title(f"{len(joined):,} points aggregated to 5 km cells with gpd.sjoin")
fig.savefig(OUT / "02-grid-counts.png")
plt.close(fig)
fig, ax = plt.subplots(figsize=(7, 6))
view = box(380_000, 360_000, 480_000, 460_000)
g_view = grid[grid.intersects(view)]
g_view.boundary.plot(ax=ax, color="#ced4da", linewidth=0.5)
inter[inter.intersects(view)].plot(ax=ax, column="area_km2", cmap="viridis", linewidth=0.2, edgecolor="white",
legend=True, legend_kwds={"label": "Intersected area per cell (km²)",
"shrink": 0.7})
burglary[burglary.intersects(view)].plot(ax=ax, color="#e8590c", markersize=1)
ax.set_xlim(view.bounds[0], view.bounds[2])
ax.set_ylim(view.bounds[1], view.bounds[3])
ax.set_axis_off()
ax.set_title("overlay(): 1 km hotspot zones cut by the 5 km grid\n(Manchester, Leeds/Bradford and Sheffield)")
fig.savefig(OUT / "03-overlay.png")
plt.close(fig)
fig, axes = plt.subplots(1, 3, figsize=(11, 3))
for ax, col, title in zip(axes, ["size_MB", "write_s", "read_s"], ["File size (MB)", "Write time (s)",
"Full read time (s)"]):
ax.bar(fmt.index, fmt[col], color=["#adb5bd", "#1d6fa3", "#2f9e44", "#e8590c"])
ax.set_title(title)
ax.tick_params(axis="x", rotation=30)
ax.grid(axis="y", alpha=0.3)
fig.suptitle(f"{len(points):,} points with 3 attribute columns", y=1.03)
fig.savefig(OUT / "04-formats.png")
plt.close(fig)
print("\nfigures written to", OUT)